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:
Huang Xin
2026-05-26 02:41:21 +08:00
committed by GitHub
parent 7bd3386c20
commit 6bc4a96b99
18 changed files with 1617 additions and 101 deletions
@@ -0,0 +1,238 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Tool } from 'ai';
import type { RetrievalBackend } from '@/services/ai/adapters/retrievalBackend';
import { ReedySourceStore } from '@/services/ai/adapters/reedySourceStore';
import { DEFAULT_AI_SETTINGS } from '@/services/ai/constants';
import type { AISettings, ScoredChunk } from '@/services/ai/types';
// streamText is mocked so the test runs without an LLM provider — we assert
// what arguments the adapter PASSES, not what the model produces.
const streamTextMock = vi.fn();
const stepCountIsMock = vi.fn((n: number) => ({ __stepCountIs: n }));
vi.mock('ai', async (importOriginal) => {
const actual = await importOriginal<typeof import('ai')>();
return {
...actual,
streamText: streamTextMock,
stepCountIs: stepCountIsMock,
};
});
// Provider must return a model object that streamText accepts. Since
// streamText itself is mocked we can hand back any opaque sentinel.
vi.mock('@/services/ai/providers', () => ({
getAIProvider: () => ({
getModel: () => ({ __mock: 'language-model' }),
getEmbeddingModel: () => ({ __mock: 'embedding-model' }),
}),
}));
// Import after mocks so the adapter picks up the mocked streamText.
const { createTauriAdapter } = await import('@/services/ai/adapters/TauriChatAdapter');
interface FakeBackendOverrides {
searchResult?: ScoredChunk[];
buildLookupTool?: RetrievalBackend['buildLookupTool'];
}
function fakeLegacy(overrides: FakeBackendOverrides = {}): RetrievalBackend {
return {
kind: 'legacy-idb',
isIndexed: vi.fn(async () => true),
indexBook: vi.fn(async () => {}),
clearBook: vi.fn(async () => {}),
searchForSystemPrompt: vi.fn(async () => overrides.searchResult ?? []),
};
}
function fakeReedy(overrides: FakeBackendOverrides = {}): RetrievalBackend {
const tool = { __mock: 'tool' } as unknown as Tool<unknown, unknown>;
return {
kind: 'reedy',
isIndexed: vi.fn(async () => true),
indexBook: vi.fn(async () => {}),
clearBook: vi.fn(async () => {}),
buildLookupTool: overrides.buildLookupTool ?? vi.fn(() => tool),
};
}
const baseSettings: AISettings = {
...DEFAULT_AI_SETTINGS,
enabled: true,
provider: 'ollama',
reedy: { enabled: true },
};
async function* asyncIter<T>(items: T[]): AsyncGenerator<T> {
for (const item of items) yield item;
}
interface RunCall {
model: unknown;
system: string;
messages: Array<{ role: string; content: string }>;
tools?: Record<string, unknown>;
stopWhen?: unknown;
abortSignal?: AbortSignal;
}
async function drainRun(adapterRun: AsyncIterable<unknown>): Promise<void> {
for await (const _ of adapterRun) {
/* swallow */
}
}
beforeEach(() => {
streamTextMock.mockReset();
stepCountIsMock.mockClear();
// Default: streamText returns an empty text stream so the for-await loop
// in the adapter completes immediately.
streamTextMock.mockImplementation(
(args: RunCall) =>
({
...args,
textStream: asyncIter<string>([]),
}) as unknown,
);
});
describe('TauriChatAdapter wiring (M1.11)', () => {
it('Reedy backend: streamText receives tools.lookupPassage and stopWhen=stepCountIs(3)', async () => {
const sourceStore = new ReedySourceStore();
const backend = fakeReedy();
const adapter = createTauriAdapter(() => ({
settings: baseSettings,
bookHash: 'bk1',
bookTitle: 'Title',
authorName: 'Author',
currentPage: 0,
backend,
sourceStore,
}));
await drainRun(
adapter.run({
messages: [{ role: 'user', content: [{ type: 'text', text: 'What is Alice doing?' }] }],
abortSignal: undefined,
} as unknown as Parameters<typeof adapter.run>[0]) as AsyncIterable<unknown>,
);
expect(streamTextMock).toHaveBeenCalledTimes(1);
const call = streamTextMock.mock.calls[0]![0] as RunCall;
expect(call.tools).toBeDefined();
expect(call.tools!['lookupPassage']).toBeDefined();
expect(call.stopWhen).toEqual({ __stepCountIs: 3 });
expect(call.system).toMatch(/lookupPassage/);
expect(call.system).toMatch(/<retrieved/);
expect(backend.buildLookupTool).toHaveBeenCalledWith(
expect.objectContaining({
bookHash: 'bk1',
sourceStore,
}),
);
});
it('legacy backend: streamText receives NO tools and the system prompt holds chunks', async () => {
const sourceStore = new ReedySourceStore();
const chunks: ScoredChunk[] = [
{
id: 'c1',
bookHash: 'bk1',
sectionIndex: 0,
chapterTitle: 'Ch1',
pageNumber: 0,
text: 'Alice met the rabbit',
searchMethod: 'hybrid',
score: 0.9,
},
];
const backend = fakeLegacy({ searchResult: chunks });
const adapter = createTauriAdapter(() => ({
settings: { ...baseSettings, reedy: { enabled: false }, provider: 'ollama' },
bookHash: 'bk1',
bookTitle: 'Title',
authorName: 'Author',
currentPage: 0,
backend,
sourceStore,
}));
await drainRun(
adapter.run({
messages: [{ role: 'user', content: [{ type: 'text', text: 'rabbit' }] }],
abortSignal: undefined,
} as unknown as Parameters<typeof adapter.run>[0]) as AsyncIterable<unknown>,
);
expect(streamTextMock).toHaveBeenCalledTimes(1);
const call = streamTextMock.mock.calls[0]![0] as RunCall;
expect(call.tools).toBeUndefined();
expect(backend.searchForSystemPrompt).toHaveBeenCalled();
// Source store should be populated with the legacy chunks under the turn id.
const turnIds = (sourceStore as unknown as { sources: Map<string, unknown[]> }).sources;
const populatedTurns = [...turnIds.entries()].filter(([, v]) => v.length > 0);
expect(populatedTurns.length).toBe(1);
});
it('onTurnStart fires synchronously before the stream begins so the UI can subscribe', async () => {
const sourceStore = new ReedySourceStore();
const backend = fakeReedy();
const turnStartedWith: string[] = [];
const adapter = createTauriAdapter(() => ({
settings: baseSettings,
bookHash: 'bk1',
bookTitle: 'Title',
authorName: 'Author',
currentPage: 0,
backend,
sourceStore,
onTurnStart: (id) => turnStartedWith.push(id),
}));
await drainRun(
adapter.run({
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
abortSignal: undefined,
} as unknown as Parameters<typeof adapter.run>[0]) as AsyncIterable<unknown>,
);
expect(turnStartedWith).toHaveLength(1);
expect(turnStartedWith[0]).toMatch(/.+/);
// The store should hold the same key (replaced with [] at turn start).
expect(sourceStore.get(turnStartedWith[0]!)).toEqual([]);
});
it('Reedy backend: every system prompt includes the status-handling instructions', async () => {
const sourceStore = new ReedySourceStore();
const adapter = createTauriAdapter(() => ({
settings: baseSettings,
bookHash: 'bk1',
bookTitle: 'My Book',
authorName: '',
currentPage: 5,
backend: fakeReedy(),
sourceStore,
}));
await drainRun(
adapter.run({
messages: [{ role: 'user', content: [{ type: 'text', text: 'q' }] }],
abortSignal: undefined,
} as unknown as Parameters<typeof adapter.run>[0]) as AsyncIterable<unknown>,
);
const call = streamTextMock.mock.calls[0]![0] as RunCall;
for (const status of [
'not_indexed',
'empty_index',
'stale_index',
'degraded',
'budget_exceeded',
]) {
expect(call.system, `system prompt should mention ${status}`).toContain(status);
}
expect(call.system).toContain('My Book');
});
});
@@ -0,0 +1,126 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { NodeDatabaseService } from '@/services/database/nodeDatabaseService';
import { DatabaseService } from '@/types/database';
import { migrate } from '@/services/database/migrate';
import { getMigrations } from '@/services/database/migrations';
import {
NoopReedyMetrics,
REEDY_METRICS_SCHEMA_VERSION,
ReedyMetrics,
} from '@/services/reedy/instrumentation';
describe('ReedyMetrics', () => {
let svc: DatabaseService;
let metrics: ReedyMetrics;
beforeEach(async () => {
vi.useFakeTimers();
svc = await NodeDatabaseService.open(':memory:', { experimental: ['index_method'] });
await migrate(svc, getMigrations('reedy'));
metrics = new ReedyMetrics(svc, '0.9.99-test', 'session-1');
});
afterEach(async () => {
vi.useRealTimers();
await svc.close();
});
it('creates the reedy_metrics table via the new migration entry', async () => {
const tables = await svc.select<{ name: string }>(
"SELECT name FROM sqlite_master WHERE type='table' AND name='reedy_metrics'",
);
expect(tables).toHaveLength(1);
});
it('flush writes buffered rows with app_version, session_id, schema_version', async () => {
metrics.log('ai_tab_opened');
metrics.log('tool_called', { bookHash: 'bk1', turnId: 't1', payload: { q: 5 } });
await metrics.flush();
const rows = await svc.select<{
event: string;
app_version: string;
session_id: string | null;
book_hash: string | null;
turn_id: string | null;
schema_version: number;
payload: string | null;
}>('SELECT * FROM reedy_metrics ORDER BY id ASC');
expect(rows).toHaveLength(2);
expect(rows[0]!.event).toBe('ai_tab_opened');
expect(rows[0]!.app_version).toBe('0.9.99-test');
expect(rows[0]!.session_id).toBe('session-1');
expect(rows[0]!.schema_version).toBe(REEDY_METRICS_SCHEMA_VERSION);
expect(rows[1]!.event).toBe('tool_called');
expect(rows[1]!.book_hash).toBe('bk1');
expect(rows[1]!.turn_id).toBe('t1');
expect(JSON.parse(rows[1]!.payload!)).toEqual({ q: 5 });
});
it('flushes automatically after the debounce interval', async () => {
metrics.log('ai_tab_opened');
let rows = await svc.select('SELECT * FROM reedy_metrics');
expect(rows).toHaveLength(0);
await vi.advanceTimersByTimeAsync(2_500);
await metrics.flush();
rows = await svc.select('SELECT * FROM reedy_metrics');
expect(rows).toHaveLength(1);
});
it('flushes immediately when the buffer overflows', async () => {
for (let i = 0; i < 50; i++) metrics.log('tool_called', { payload: { i } });
// The 50th log triggers a synchronous flush() call; await it indirectly.
await metrics.flush();
const rows = await svc.select('SELECT * FROM reedy_metrics');
expect(rows.length).toBe(50);
});
it('exportBundle returns a JSON envelope with the schema version + events', async () => {
metrics.log('book_indexing_started', { bookHash: 'bk1' });
// Advance the fake clock so the second event's ts strictly increases
// and the ORDER BY ts ASC inside exportBundle is stable.
await vi.advanceTimersByTimeAsync(5);
metrics.log('book_indexed', { bookHash: 'bk1', payload: { chunks: 42 } });
await metrics.flush();
const json = await metrics.exportBundle({ days: 30 });
const parsed = JSON.parse(json);
expect(parsed.format).toBe('reedy-metrics-bundle');
expect(parsed.schemaVersion).toBe(REEDY_METRICS_SCHEMA_VERSION);
expect(parsed.windowDays).toBe(30);
expect(parsed.eventCount).toBe(2);
const indexed = parsed.events.find((e: { event: string }) => e.event === 'book_indexed');
expect(indexed.payload).toEqual({ chunks: 42 });
});
it('exportBundle excludes rows older than the requested window', async () => {
// Write a row "from 100 days ago" by direct SQL.
const oldTs = Date.now() - 100 * 24 * 60 * 60 * 1000;
await svc.execute(
`INSERT INTO reedy_metrics (ts, event, app_version, schema_version)
VALUES (?, ?, ?, ?)`,
[oldTs, 'ai_tab_opened', '0.0.1', REEDY_METRICS_SCHEMA_VERSION],
);
metrics.log('book_indexed', { bookHash: 'bk1' });
await metrics.flush();
const json = await metrics.exportBundle({ days: 30 });
const parsed = JSON.parse(json);
expect(parsed.eventCount).toBe(1);
expect(parsed.events[0].event).toBe('book_indexed');
});
});
describe('NoopReedyMetrics', () => {
it('log and flush are no-ops; exportBundle returns an empty envelope', async () => {
const noop = new NoopReedyMetrics();
noop.log();
await noop.flush();
const json = await noop.exportBundle();
expect(JSON.parse(json).events).toEqual([]);
});
});
@@ -23,6 +23,7 @@ function passage(id: string, text: string, position = 0): RetrieverResult['passa
bookHash: 'bk1',
cfi: `epubcfi(/6/2!/4/${position * 2 + 2})`,
endCfi: `epubcfi(/6/2!/4/${position * 2 + 4})`,
sectionIndex: 0,
chapterTitle: 'Ch1',
text,
positionIndex: position,
@@ -0,0 +1,156 @@
import { describe, it, expect, vi } from 'vitest';
import { selectBackend, type RetrievalBackend } from '@/services/ai/adapters/retrievalBackend';
import { ReedySourceStore } from '@/services/ai/adapters/reedySourceStore';
import { DEFAULT_AI_SETTINGS } from '@/services/ai/constants';
import type { AISettings } from '@/services/ai/types';
import type { RetrievedChunk } from '@/services/reedy/retrieval/BookRetriever';
const fakeLegacy: RetrievalBackend = {
kind: 'legacy-idb',
isIndexed: vi.fn(async () => true),
indexBook: vi.fn(async () => {}),
clearBook: vi.fn(async () => {}),
};
const fakeReedy: RetrievalBackend = {
kind: 'reedy',
isIndexed: vi.fn(async () => true),
indexBook: vi.fn(async () => {}),
clearBook: vi.fn(async () => {}),
};
function settingsWith(reedyEnabled: boolean): AISettings {
return { ...DEFAULT_AI_SETTINGS, enabled: true, reedy: { enabled: reedyEnabled } };
}
describe('selectBackend', () => {
it('returns Reedy when reedy.enabled=true and isTauri=true and a reedy backend is provided', () => {
const out = selectBackend({
settings: settingsWith(true),
isTauri: true,
legacy: fakeLegacy,
reedy: fakeReedy,
});
expect(out.kind).toBe('reedy');
});
it('falls back to Legacy on web (isTauri=false) even when reedy.enabled=true', () => {
const out = selectBackend({
settings: settingsWith(true),
isTauri: false,
legacy: fakeLegacy,
reedy: fakeReedy,
});
expect(out.kind).toBe('legacy-idb');
});
it('falls back to Legacy when reedy.enabled=false even on Tauri', () => {
const out = selectBackend({
settings: settingsWith(false),
isTauri: true,
legacy: fakeLegacy,
reedy: fakeReedy,
});
expect(out.kind).toBe('legacy-idb');
});
it('falls back to Legacy when reedy backend is missing (constructor failed)', () => {
const out = selectBackend({
settings: settingsWith(true),
isTauri: true,
legacy: fakeLegacy,
reedy: null,
});
expect(out.kind).toBe('legacy-idb');
});
it('treats missing reedy settings field as disabled', () => {
const out = selectBackend({
settings: { ...DEFAULT_AI_SETTINGS, enabled: true, reedy: undefined },
isTauri: true,
legacy: fakeLegacy,
reedy: fakeReedy,
});
expect(out.kind).toBe('legacy-idb');
});
});
describe('ReedySourceStore', () => {
function chunk(id: string, text: string): RetrievedChunk {
return {
id,
bookHash: 'bk1',
cfi: `epubcfi(${id})`,
endCfi: `epubcfi(${id}-end)`,
sectionIndex: 0,
chapterTitle: 'Ch1',
text,
positionIndex: 0,
score: 0.5,
};
}
it('get returns empty array for an unknown turnId (no throw)', () => {
const store = new ReedySourceStore();
expect(store.get('missing')).toEqual([]);
});
it('replace overwrites; get returns the new snapshot', () => {
const store = new ReedySourceStore();
store.replace('t1', [chunk('a', 'one')]);
store.replace('t1', [chunk('b', 'two')]);
const out = store.get('t1');
expect(out).toHaveLength(1);
expect(out[0]!.id).toBe('b');
});
it('append merges, dedup by id, preserves insertion order', () => {
const store = new ReedySourceStore();
store.append('t1', [chunk('a', 'A'), chunk('b', 'B')]);
store.append('t1', [chunk('b', 'B-again'), chunk('c', 'C')]);
const out = store.get('t1');
expect(out.map((c) => c.id)).toEqual(['a', 'b', 'c']);
// dedup keeps the FIRST occurrence (preserves rank from the first call)
expect(out[1]!.text).toBe('B');
});
it('subscribe is invoked on replace/append; unsubscribe stops notifications', () => {
const store = new ReedySourceStore();
const calls: RetrievedChunk[][] = [];
const off = store.subscribe('t1', (snapshot) => calls.push(snapshot));
store.replace('t1', [chunk('a', 'A')]);
expect(calls).toHaveLength(1);
expect(calls[0]!.map((c) => c.id)).toEqual(['a']);
store.append('t1', [chunk('b', 'B')]);
expect(calls).toHaveLength(2);
off();
store.append('t1', [chunk('c', 'C')]);
expect(calls).toHaveLength(2);
});
it('clear drops every turn and stops notifications', () => {
const store = new ReedySourceStore();
const listener = vi.fn();
store.subscribe('t1', listener);
store.replace('t1', [chunk('a', 'A')]);
expect(listener).toHaveBeenCalledTimes(1);
store.clear();
expect(store.get('t1')).toEqual([]);
store.replace('t1', [chunk('b', 'B')]);
// After clear() subscription is gone — listener not invoked.
expect(listener).toHaveBeenCalledTimes(1);
});
it('turns are independent — replacing t1 does not affect t2', () => {
const store = new ReedySourceStore();
store.replace('t1', [chunk('a', 'A')]);
store.replace('t2', [chunk('b', 'B'), chunk('c', 'C')]);
expect(store.get('t1')).toHaveLength(1);
expect(store.get('t2')).toHaveLength(2);
});
});