forked from akai/readest
feat(reedy): Phase 1B — wire Reedy into the chat, settings, and Sources UI (#4296)
* feat(reedy): wire RetrievalBackend interface + metrics into the chat adapter
Phase 1B backend integration. Adds a RetrievalBackend interface so
TauriChatAdapter holds a uniform reference instead of branching across the
file, with two impls — LegacyIdbBackend (wraps the existing IDB ragService
unchanged) and ReedyBackend (lazy-opens reedy.db, adapts the active
provider's embedding model to Reedy's narrower shape, exposes a Vercel
`lookupPassage` tool). selectBackend() gates Reedy behind both
aiSettings.reedy.enabled AND isTauriAppPlatform() per plan D15 so the MVP
cohort is desktop-only.
The Reedy path streams via streamText({ tools: { lookupPassage }, stopWhen:
stepCountIs(3) }) with a status-aware system prompt that tells the model
how to phrase responses for each RetrieverStatus value.
Replaces the module-global `lastSources` + 500ms poll with a per-instance
ReedySourceStore keyed by a synthetic per-turn id the adapter generates,
so the Sources dropdown stops racing on global state. Both legacy and
Reedy backends now feed citations through the same store; the UI is
backend-agnostic via a shared SourceItem shape both ScoredChunk and
RetrievedChunk satisfy.
Adds the reedy_metrics table to the reedy migration (versioned with
app_version + session_id + turn_id per row) plus a ReedyMetrics writer
that ReedyBackend uses to record indexing-lifecycle and tool-use events.
Always-on local; no network egress. NoopReedyMetrics keeps construction
cheap before the DB opens.
Tests: retrievalBackend selectBackend gates, ReedySourceStore semantics
(append/replace/subscribe/clear), ReedyMetrics debounced batching +
exportBundle, and a TauriChatAdapter contract test that asserts the
Reedy/legacy code-paths pass the right args to streamText.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(reedy): UI wiring — settings toggle, clickable sources, feedback bundle
Phase 1B UI integration. AIAssistant now constructs the active backend
(legacy or Reedy) via selectBackend with the platform gate from M1.7,
wires a ReedySourceStore for the chat adapter, and routes Sources-dropdown
clicks to `getView(bookKey)?.goTo(source.cfi)` when the source has a CFI.
Legacy-path sources still render as static rows because they have no CFI.
AIPanel grows a 'Reedy Retrieval (Beta)' BoxedList with the toggle and a
'Send Reedy feedback' button that calls exportReedyMetricsBundle and
triggers a JSON download of the last 90 days of events. The toggle is
disabled on web with an explanatory description per plan D15.
Thread accepts an onSourceClick callback and renders each source as a
button when its source carries a CFI, otherwise as a static div — so the
Sources dropdown is backend-agnostic via the shared SourceItem shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -34,10 +34,12 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/utils/tailwind';
|
||||
import type { ScoredChunk } from '@/services/ai/types';
|
||||
import type { SourceItem } from '@/services/ai/adapters/reedySourceStore';
|
||||
|
||||
interface ThreadProps {
|
||||
sources?: ScoredChunk[];
|
||||
sources?: SourceItem[];
|
||||
/** Invoked when a source row is clicked. Reedy passes the CFI to the reader's goTo. */
|
||||
onSourceClick?: (source: SourceItem) => void;
|
||||
onClear?: () => void;
|
||||
onResetIndex?: () => void;
|
||||
isLoadingHistory?: boolean;
|
||||
@@ -101,6 +103,7 @@ const ScrollToBottomButton: FC = () => {
|
||||
|
||||
export const Thread: FC<ThreadProps> = ({
|
||||
sources = [],
|
||||
onSourceClick,
|
||||
onClear,
|
||||
onResetIndex,
|
||||
isLoadingHistory = false,
|
||||
@@ -192,7 +195,9 @@ export const Thread: FC<ThreadProps> = ({
|
||||
components={{
|
||||
UserMessage,
|
||||
EditComposer,
|
||||
AssistantMessage: () => <AssistantMessage sources={sources} />,
|
||||
AssistantMessage: () => (
|
||||
<AssistantMessage sources={sources} onSourceClick={onSourceClick} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<p className='text-base-content/40 mx-auto w-full p-1 text-center text-[10px]'>
|
||||
@@ -280,10 +285,11 @@ const Composer: FC<ComposerProps> = ({ onClear, onResetIndex }) => {
|
||||
};
|
||||
|
||||
interface AssistantMessageProps {
|
||||
sources?: ScoredChunk[];
|
||||
sources?: SourceItem[];
|
||||
onSourceClick?: (source: SourceItem) => void;
|
||||
}
|
||||
|
||||
const AssistantMessage: FC<AssistantMessageProps> = ({ sources = [] }) => {
|
||||
const AssistantMessage: FC<AssistantMessageProps> = ({ sources = [], onSourceClick }) => {
|
||||
return (
|
||||
<MessagePrimitive.Root className='group/message animate-in fade-in slide-in-from-bottom-1 relative mx-auto mb-1 flex w-full flex-col pb-0.5 duration-200'>
|
||||
<div className='flex flex-col items-start'>
|
||||
@@ -316,19 +322,41 @@ const AssistantMessage: FC<AssistantMessageProps> = ({ sources = [] }) => {
|
||||
Sources from book
|
||||
</div>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
{sources.map((source, i) => (
|
||||
<div
|
||||
key={source.id || i}
|
||||
className='border-base-content/10 bg-base-200/50 rounded-lg border px-2 py-1.5 text-[11px]'
|
||||
>
|
||||
<div className='text-base-content font-medium'>
|
||||
{source.chapterTitle || `Section ${source.sectionIndex + 1}`}
|
||||
{sources.map((source, i) => {
|
||||
const clickable = !!source.cfi && !!onSourceClick;
|
||||
const baseClass =
|
||||
'border-base-content/10 bg-base-200/50 rounded-lg border px-2 py-1.5 text-[11px]';
|
||||
const content = (
|
||||
<>
|
||||
<div className='text-base-content font-medium'>
|
||||
{source.chapterTitle || `Section ${source.sectionIndex + 1}`}
|
||||
</div>
|
||||
<div className='text-base-content/60 mt-0.5 line-clamp-3'>
|
||||
{source.text}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
if (clickable) {
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
key={source.id || i}
|
||||
className={cn(
|
||||
baseClass,
|
||||
'hover:bg-base-200 text-start transition-colors',
|
||||
)}
|
||||
onClick={() => onSourceClick?.(source)}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={source.id || i} className={baseClass}>
|
||||
{content}
|
||||
</div>
|
||||
<div className='text-base-content/60 mt-0.5 line-clamp-3'>
|
||||
{source.text}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
} from '@/services/ai/providers/OpenRouterProvider';
|
||||
import { DEFAULT_AI_SETTINGS, GATEWAY_MODELS, MODEL_PRICING } from '@/services/ai/constants';
|
||||
import type { AISettings, AIProviderName } from '@/services/ai/types';
|
||||
import { exportReedyMetricsBundle } from '@/services/reedy/instrumentation';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { BoxedList, SettingLabel, SettingsRow, SettingsSwitchRow } from './primitives';
|
||||
|
||||
type ConnectionStatus = 'idle' | 'testing' | 'success' | 'error';
|
||||
@@ -67,12 +69,13 @@ const getModelOptions = (): ModelOption[] => [
|
||||
|
||||
const AIPanel: React.FC = () => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig } = useEnv();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
|
||||
const aiSettings: AISettings = settings?.aiSettings ?? DEFAULT_AI_SETTINGS;
|
||||
|
||||
const [enabled, setEnabled] = useState(aiSettings.enabled);
|
||||
const [reedyEnabled, setReedyEnabled] = useState(aiSettings.reedy?.enabled ?? false);
|
||||
const [provider, setProvider] = useState<AIProviderName>(aiSettings.provider);
|
||||
const [ollamaUrl, setOllamaUrl] = useState(aiSettings.ollamaBaseUrl);
|
||||
const [ollamaModel, setOllamaModel] = useState(aiSettings.ollamaModel);
|
||||
@@ -736,6 +739,65 @@ const AIPanel: React.FC = () => {
|
||||
</BoxedList>
|
||||
)}
|
||||
|
||||
<BoxedList
|
||||
title={_('Reedy Retrieval (Beta)')}
|
||||
className={disabledSection}
|
||||
description={
|
||||
isTauriAppPlatform()
|
||||
? _(
|
||||
'Uses Turso vector search + CFI-anchored citations. The model decides when to look up passages instead of getting them stuffed into the system prompt.',
|
||||
)
|
||||
: _('Reedy is desktop-only in this beta. Use the Readest desktop app to try it.')
|
||||
}
|
||||
>
|
||||
<SettingsSwitchRow
|
||||
label={_('Use Reedy retrieval')}
|
||||
description={_(
|
||||
'When on, indexing and chat go through Reedy. When off, the legacy IndexedDB pipeline is used.',
|
||||
)}
|
||||
checked={reedyEnabled}
|
||||
disabled={!enabled || !isTauriAppPlatform()}
|
||||
onChange={() => {
|
||||
const next = !reedyEnabled;
|
||||
setReedyEnabled(next);
|
||||
saveAiSetting('reedy', { enabled: next });
|
||||
}}
|
||||
/>
|
||||
<div className='flex min-h-14 items-center justify-between gap-3 ps-4 pe-4'>
|
||||
<div className='flex min-w-0 flex-col gap-0.5'>
|
||||
<SettingLabel>{_('Send Reedy feedback')}</SettingLabel>
|
||||
<span className='text-base-content/60 text-xs'>
|
||||
{_(
|
||||
'Download the last 90 days of Reedy events as a JSON bundle (local-only by default). Paste into a GitHub issue or feedback form.',
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className='btn btn-outline btn-sm'
|
||||
disabled={!enabled || !isTauriAppPlatform() || !appService}
|
||||
onClick={async () => {
|
||||
if (!appService) return;
|
||||
try {
|
||||
const bundle = await exportReedyMetricsBundle(appService);
|
||||
const blob = new Blob([bundle], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `reedy-feedback-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error('[Reedy] feedback export failed', err);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{_('Download')}
|
||||
</button>
|
||||
</div>
|
||||
</BoxedList>
|
||||
|
||||
<BoxedList title={_('Connection')} className={disabledSection}>
|
||||
<div className='flex min-h-14 items-center justify-between gap-3 pe-4'>
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user