diff --git a/apps/mobile/src/components/AskMarkdown.tsx b/apps/mobile/src/components/Markdown.tsx similarity index 96% rename from apps/mobile/src/components/AskMarkdown.tsx rename to apps/mobile/src/components/Markdown.tsx index d34a27984..83c4e5353 100644 --- a/apps/mobile/src/components/AskMarkdown.tsx +++ b/apps/mobile/src/components/Markdown.tsx @@ -5,14 +5,15 @@ import { fonts } from '../theme/typography' import { isTableSeparator } from '../lib/markdown' /** - * Small self-contained markdown renderer for assistant chat answers (tutor-register gpt-4.1 output: + * Small self-contained markdown renderer. Its content is what an outside assistant wrote back as a + * BookInsight (Markdown on the wire: * `##` headings, bullets, numbered lists, **bold**, *italic*, `inline code`, fenced code blocks, * blockquotes, and GFM tables). Deliberately NOT `react-native-markdown-display` — that dep is * unmaintained against RN 0.83 / the new architecture, and adding a native-adjacent parser is a * Play Data-Safety + OTA risk for zero upside here. This renderer is ~180 lines, theme-tokenised, * and pure-JS (OTA-safe). No raw-HTML path exists — model output is only ever tokenised as markdown. * - * Divergences vs web AskMarkdown: tables degrade to a horizontally-scrollable monospace block + * Divergences vs web Markdown: tables degrade to a horizontally-scrollable monospace block * (RN has no ); inline `[n]` citation markers render as plain text (the citation CHIPS below * the answer are the jump surface on mobile). Links render as literal text (the inline pass has no * link rule) — intended: answers are grounded in the book, external links are vanishingly rare. @@ -59,7 +60,7 @@ function renderInline(line: string, colors: InlineColors, keyPrefix: string): Re const HEADING_SIZE: Record = { 1: 22, 2: 19, 3: 17, 4: 15, 5: 14, 6: 13 } -export const AskMarkdown = memo(function AskMarkdown({ text }: { text: string }) { +export const Markdown = memo(function Markdown({ text }: { text: string }) { const { colors } = useTheme() const inline: InlineColors = { text: colors.text, code: colors.primary, codeBg: colors.border + '66' } const blocks: ReactNode[] = [] diff --git a/apps/mobile/src/components/library/BookInsightsSection.tsx b/apps/mobile/src/components/library/BookInsightsSection.tsx index a4cb217e2..2bd424260 100644 --- a/apps/mobile/src/components/library/BookInsightsSection.tsx +++ b/apps/mobile/src/components/library/BookInsightsSection.tsx @@ -4,7 +4,7 @@ import { insightsApi, insightChapterLabel, type BookInsight } from '@textstack/s import { useTheme } from '../../context/ThemeContext' import { useLanguage } from '../../context/LanguageContext' import { fonts } from '../../theme/typography' -import { AskMarkdown } from '../AskMarkdown' +import { Markdown } from '../Markdown' /** * "What you've worked out" — the conclusions an outside assistant wrote back into @@ -19,7 +19,7 @@ import { AskMarkdown } from '../AskMarkdown' * a tutorial for a feature you can only reach by connecting an assistant, and it * would sit on every book screen forever for the readers who never do. * - * Markdown goes through `AskMarkdown`, the renderer the Ask sheet already uses: + * Markdown goes through the shared `Markdown` renderer: * pure JS, theme-tokenised, OTA-safe, and no raw-HTML path — so assistant output * is only ever tokenised, never interpreted. */ @@ -71,7 +71,7 @@ export function BookInsightsSection({ userBookId, editionId }: Props) { ) : null} - + ))} diff --git a/apps/mobile/src/components/reader/ReaderShell.tsx b/apps/mobile/src/components/reader/ReaderShell.tsx index 2ebca20b5..fdd2ea328 100644 --- a/apps/mobile/src/components/reader/ReaderShell.tsx +++ b/apps/mobile/src/components/reader/ReaderShell.tsx @@ -3,7 +3,7 @@ import type { MutableRefObject, RefObject } from 'react' import { View, Text, StyleSheet, TouchableOpacity, Animated, Linking, BackHandler } from 'react-native' import { WebView } from 'react-native-webview' import { useRouter, Stack } from 'expo-router' -import { t, computeBookProgress, estimateTimeLeft, formatMinutesLeft, citationChapterSlug, makeSnippet, plural, resolvePdfResumePage, chapterEndPage } from '@textstack/shared' +import { t, computeBookProgress, estimateTimeLeft, formatMinutesLeft, plural, resolvePdfResumePage, chapterEndPage } from '@textstack/shared' import type { Chapter, BookmarkDto, TextPosition } from '@textstack/shared' import { buildReaderHtml, buildPdfViewerHtml } from '../../lib/readerHtml' import { @@ -649,10 +649,6 @@ export function ReaderShell(props: ReaderShellProps) { onNavigateChapter(slug) } - // RAG citation (AI-026d): scroll the WebView to the cited passage. - const pendingCitationRef = useRef<{ slug: string; snippet: string; charStart: number } | null>(null) - const scrollToCitation = (snippet: string, charStart: number) => - injectJs(`window.__textstackScrollToCitation && window.__textstackScrollToCitation(${JSON.stringify(snippet)}, ${charStart})`) // M2: scroll the reflow WebView to a saved highlight (no chapter navigation → // reading position preserved). The Highlights sheet's list is always the @@ -955,13 +951,6 @@ export function ReaderShell(props: ReaderShellProps) { // Scroll-restore is owned by useReaderPersistence — it coordinates // this signal with the async saved-position fetch (no race). onWebViewLoaded() - // A cross-chapter citation jump (AI-026d): once the cited chapter has rendered, scroll - // to the passage — after restore (delay) so the explicit jump wins. - const pc = pendingCitationRef.current - if (pc && pc.slug === activeSlug) { - pendingCitationRef.current = null - setTimeout(() => scrollToCitation(pc.snippet, pc.charStart), 120) - } }} originWhitelist={['*']} // Android's WebView ignores the viewport's user-scalable unless the diff --git a/apps/mobile/src/lib/markdown.ts b/apps/mobile/src/lib/markdown.ts index 80469a350..2bb4109da 100644 --- a/apps/mobile/src/lib/markdown.ts +++ b/apps/mobile/src/lib/markdown.ts @@ -1,4 +1,4 @@ -// Pure markdown-block helpers for the chat renderer (AskMarkdown). Kept RN-free so the block-detection +// Pure markdown-block helpers for the `Markdown` renderer. Kept RN-free so the block-detection // rules are unit-testable under Vitest (see markdown.test.ts) without bundling React Native. /** diff --git a/apps/mobile/src/lib/sse.ts b/apps/mobile/src/lib/sse.ts deleted file mode 100644 index a6172e2d4..000000000 --- a/apps/mobile/src/lib/sse.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { fetch as expoFetch } from 'expo/fetch' -import { - createSseParser, - SseUnauthorizedError, - SseUnsupportedError, - type SseEvent, -} from './sseParser' - -// SSE-over-POST for mobile. React Native's built-in `fetch` buffers the whole body (no streaming), -// so we use `expo/fetch` (Expo SDK 52+ WinterCG fetch) which exposes `response.body` as a real -// ReadableStream. Same POST-with-body pattern the web reader uses (EventSource can't POST) and the -// same minimal parser (./sseParser) — the wire format is byte-identical to the legacy `/ask` stream. -// -// Auth is Bearer (mobile has no cookies): the caller passes the access token; a 401 surfaces as -// `SseUnauthorizedError` so the caller can refresh + retry (see bookChat.sendChatMessage). - -export { SseUnauthorizedError, SseUnsupportedError } from './sseParser' - -/** - * POSTs JSON and consumes the SSE response, invoking `onEvent` per event. Maps non-OK statuses to - * readable errors BEFORE streaming (rate-limit/unavailable responses are JSON, not SSE). Throws - * `SseUnsupportedError` when the body can't be streamed so the caller can fall back to a plain POST. - */ -export async function postSse( - url: string, - token: string | null, - body: unknown, - onEvent: (e: SseEvent) => void, - signal?: AbortSignal, -): Promise { - const res = await expoFetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'text/event-stream', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - body: JSON.stringify(body), - signal, - }) - - if (!res.ok) { - if (res.status === 401) throw new SseUnauthorizedError() - if (res.status === 503) throw new Error('Service unavailable') - if (res.status === 504) throw new Error('Request timed out') - if (res.status === 429) throw new Error('Too many requests, try again later') - const text = await res.text().catch(() => '') - let error = `Request failed: ${res.status}` - try { - const json = JSON.parse(text) - if (json?.detail) error = json.detail - else if (json?.error) error = json.error - } catch { - // non-JSON error body — keep the status message - } - throw new Error(error) - } - - if (!res.body) throw new SseUnsupportedError() - - const parser = createSseParser(onEvent) - // Guard the stream setup itself: an OK response whose body isn't a spec ReadableStream (no - // `getReader`), or a runtime with no `TextDecoder` global, can't be streamed. Rethrow as - // `SseUnsupportedError` so the caller degrades to `sendChatMessageJson` instead of dead-ending - // on a raw TypeError banner. (A failure DURING the read below is a genuine stream error, not this.) - let reader: ReadableStreamDefaultReader - let decoder: TextDecoder - try { - reader = res.body.getReader() - decoder = new TextDecoder() - } catch { - throw new SseUnsupportedError() - } - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - if (value) parser.feed(decoder.decode(value, { stream: true })) - } - parser.feed(decoder.decode()) // flush any trailing multi-byte sequence - parser.end() - } finally { - reader.releaseLock() - } -} diff --git a/apps/mobile/src/lib/sseParser.test.ts b/apps/mobile/src/lib/sseParser.test.ts deleted file mode 100644 index 1039b3bba..000000000 --- a/apps/mobile/src/lib/sseParser.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { createSseParser, parseAskDone, makeAskSseHandler, type SseEvent } from './sseParser' - -describe('createSseParser', () => { - function collect(chunks: string[]): SseEvent[] { - const events: SseEvent[] = [] - const p = createSseParser(e => events.push(e)) - for (const c of chunks) p.feed(c) - p.end() - return events - } - - it('parses a single event split by a blank line', () => { - expect(collect(['event: delta\ndata: hello\n\n'])).toEqual([{ event: 'delta', data: 'hello' }]) - }) - - it('joins multi-line data with newlines', () => { - expect(collect(['data: a\ndata: b\n\n'])).toEqual([{ event: 'message', data: 'a\nb' }]) - }) - - it('reassembles an event split across chunk boundaries', () => { - expect(collect(['event: de', 'lta\ndata: wor', 'ld\n\n'])).toEqual([{ event: 'delta', data: 'world' }]) - }) - - it('is CRLF-tolerant and ignores comment/keep-alive lines', () => { - expect(collect([': keep-alive\r\nevent: done\r\ndata: {}\r\n\r\n'])).toEqual([{ event: 'done', data: '{}' }]) - }) - - it('flushes a trailing event on end() with no final blank line', () => { - expect(collect(['event: done\ndata: {"insufficient":true}'])).toEqual([ - { event: 'done', data: '{"insufficient":true}' }, - ]) - }) - - it('handles the full delta*+done stream shape', () => { - const events = collect([ - 'event: delta\ndata: The \n\n', - 'event: delta\ndata: whale\n\n', - 'event: done\ndata: {"citations":[],"insufficient":false}\n\n', - ]) - expect(events).toEqual([ - { event: 'delta', data: 'The ' }, - { event: 'delta', data: 'whale' }, - { event: 'done', data: '{"citations":[],"insufficient":false}' }, - ]) - }) -}) - -describe('parseAskDone', () => { - it('defaults every field on malformed JSON', () => { - expect(parseAskDone('not json')).toEqual({ citations: [], lastReadOrd: 0, insufficient: false }) - }) - - it('parses citations + insufficient + lastReadOrd', () => { - const done = parseAskDone('{"citations":[{"marker":1}],"lastReadOrd":4,"insufficient":true}') - expect(done.insufficient).toBe(true) - expect(done.lastReadOrd).toBe(4) - expect(done.citations).toHaveLength(1) - }) - - it('coerces a truthy non-boolean insufficient to a boolean', () => { - expect(parseAskDone('{"insufficient":1}').insufficient).toBe(true) - }) -}) - -describe('makeAskSseHandler', () => { - it('routes delta/done/error to the right callbacks', () => { - const onDelta = vi.fn(); const onDone = vi.fn(); const onError = vi.fn() - const h = makeAskSseHandler({ onDelta, onDone, onError }) - h({ event: 'delta', data: 'hi' }) - h({ event: 'done', data: '{"insufficient":true}' }) - h({ event: 'error', data: 'boom' }) - expect(onDelta).toHaveBeenCalledWith('hi') - expect(onDone).toHaveBeenCalledWith({ citations: [], lastReadOrd: 0, insufficient: true }) - expect(onError).toHaveBeenCalledWith('boom') - }) - - it('drops events once the signal is aborted', () => { - const onDelta = vi.fn() - const ctrl = new AbortController() - const h = makeAskSseHandler({ onDelta, onDone: vi.fn(), onError: vi.fn(), signal: ctrl.signal }) - ctrl.abort() - h({ event: 'delta', data: 'late' }) - expect(onDelta).not.toHaveBeenCalled() - }) - - it('falls back to a generic message for an empty error frame', () => { - const onError = vi.fn() - makeAskSseHandler({ onDelta: vi.fn(), onDone: vi.fn(), onError })({ event: 'error', data: '' }) - expect(onError).toHaveBeenCalledWith('Ask failed') - }) -}) diff --git a/apps/mobile/src/lib/sseParser.ts b/apps/mobile/src/lib/sseParser.ts deleted file mode 100644 index df44a513b..000000000 --- a/apps/mobile/src/lib/sseParser.ts +++ /dev/null @@ -1,119 +0,0 @@ -// Pure SSE parsing for the mobile chat/ask streams. Kept RN-free (no expo/fetch import) so it's -// unit-testable under Vitest (see sseParser.test.ts). The network layer that actually opens the -// stream lives in ./sse — it imports `createSseParser` from here. Wire format is identical to the -// web reader's parser (apps/web/src/lib/sse.ts): `event:`/`data:` fields, blank-line dispatch, -// multi-line data joined with \n, comment lines (`:`) ignored, CRLF-tolerant. - -export interface SseEvent { - event: string - data: string -} - -/** Terminal `done` payload of a streamed ask (same shape the legacy `/ask` `done` frame carries). */ -export interface AskDone { - citations: import('@textstack/shared').AskCitation[] - lastReadOrd: number - insufficient: boolean -} - -export interface AskStreamCallbacks { - onDelta: (fragment: string) => void - onDone: (done: AskDone) => void - onError: (message: string) => void - signal?: AbortSignal -} - -/** The SSE request was rejected with 401 — the caller should refresh the token / prompt sign-in. */ -export class SseUnauthorizedError extends Error { - constructor() { - super('Unauthorized') - this.name = 'SseUnauthorizedError' - } -} - -/** The environment can't stream (no response body reader) — caller falls back to a plain JSON POST. */ -export class SseUnsupportedError extends Error { - constructor() { - super('Streaming not supported') - this.name = 'SseUnsupportedError' - } -} - -/** - * Stateful SSE parser: feed it raw text chunks (any split points), it dispatches complete events. - * Subset of the SSE spec sufficient for our endpoints. Call `end()` to flush a trailing event from - * a stream that closed without a final blank line. - */ -export function createSseParser(onEvent: (e: SseEvent) => void) { - let buffer = '' - let eventType = 'message' - let dataLines: string[] = [] - - const dispatch = () => { - if (dataLines.length > 0) onEvent({ event: eventType, data: dataLines.join('\n') }) - eventType = 'message' - dataLines = [] - } - - const processLine = (line: string) => { - if (line === '') return dispatch() - if (line.startsWith(':')) return // comment / keep-alive - const colon = line.indexOf(':') - const field = colon === -1 ? line : line.slice(0, colon) - // Per spec a single space after the colon is stripped, further spaces are data. - let value = colon === -1 ? '' : line.slice(colon + 1) - if (value.startsWith(' ')) value = value.slice(1) - if (field === 'event') eventType = value - else if (field === 'data') dataLines.push(value) - // id/retry/unknown fields ignored - } - - return { - feed(chunk: string) { - buffer += chunk - let nl: number - while ((nl = buffer.indexOf('\n')) !== -1) { - let line = buffer.slice(0, nl) - if (line.endsWith('\r')) line = line.slice(0, -1) - buffer = buffer.slice(nl + 1) - processLine(line) - } - }, - end() { - if (buffer.length > 0) processLine(buffer.endsWith('\r') ? buffer.slice(0, -1) : buffer) - buffer = '' - dispatch() - }, - } -} - -/** - * Parses a `done` frame's JSON data into the terminal payload, defaulting every field so a malformed - * frame never throws. Mirrors web's `makeAskSseHandler` done-branch. - */ -export function parseAskDone(data: string): AskDone { - try { - const parsed = JSON.parse(data) as Partial - return { - citations: parsed.citations ?? [], - lastReadOrd: parsed.lastReadOrd ?? 0, - insufficient: Boolean(parsed.insufficient), - } - } catch { - return { citations: [], lastReadOrd: 0, insufficient: false } - } -} - -/** - * Builds the per-event handler for an ask-style SSE stream (`delta` → text fragment, `done` → - * citations/insufficient JSON, `error` → message). Single-sourced so the persistent book-chat parses - * the identical wire format as the legacy ask. Respects `signal` so aborted streams stop dispatching. - */ -export function makeAskSseHandler({ onDelta, onDone, onError, signal }: AskStreamCallbacks) { - return (e: SseEvent) => { - if (signal?.aborted) return - if (e.event === 'delta') onDelta(e.data) - else if (e.event === 'done') onDone(parseAskDone(e.data)) - else if (e.event === 'error') onError(e.data || 'Ask failed') - } -} diff --git a/docs/05-features/assistant-handoff.md b/docs/05-features/assistant-handoff.md index 607d8bfe2..ea57bea93 100644 --- a/docs/05-features/assistant-handoff.md +++ b/docs/05-features/assistant-handoff.md @@ -135,6 +135,23 @@ generated EF snapshots). Backend, web, admin and mobile all build; 1,234 backend | Mobile `Linking.openURL` | Still swallows the failure | | Tutor's worked example | `get_example_sentence` went with the retrieval spine. `VocabularyWord.Sentence` already holds the sentence a word was saved from, so this is a rewire with no retrieval — not started | +### Left behind by the cut — a follow-up, found 2026-09-10 after PR #596 opened + +Removing the chat left inert plumbing on the mobile side. None of it is a correctness risk — the +modules have no importers and the one live-looking block cannot execute — but it is exactly the +residue this work exists to remove, so it goes in its own small PR rather than riding along: + +- **`apps/mobile/src/lib/sse.ts` and `sseParser.ts` (+ its test) have no consumers at all.** Their + only caller was `bookChat.ts`. Note the web's `lib/sse.ts` is NOT dead — `useExplain` still streams + through it. +- **`ReaderShell.tsx` still imports `citationChapterSlug` and `makeSnippet`**, and keeps + `pendingCitationRef` + `scrollToCitation` alive at :653-654 and :960-963. The only writer of that + ref was the deleted `handleCitation`, so the ref is permanently null and the block at :960 can + never run. +- **`packages/shared/src/reader/citation.ts`** exists for that path only. +- **`AskCitation`, `AskResponse`, `AskTurnDto`, `AskTarget`** in `packages/shared/src/types/api.ts` + are now referenced only by the dead `sseParser` and by `citation.ts`'s own doc comment. + ### Found while cutting — decisions still open 1. ~~**`GET /books/{slug}/similar`**~~ — **decided: deleted.** The rail was fed by diff --git a/docs/STATUS.md b/docs/STATUS.md index 249f50a9e..18ebfa054 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -79,6 +79,14 @@ answers "what happened" and nothing answered "what is half-finished right now". ## Known-broken / open follow-ups +- **Five web modules and one stylesheet have no importers, and did not before this work either.** + `lib/fuzzyMatch.ts`, `lib/wordAtPoint.ts`, `hooks/useOfflineDownload.ts`, `hooks/useSwipe.ts`, + `hooks/useVocabLevel.ts`, and `styles/native-language-picker.css` (whose `.native-lang-picker` + selector appears in no component). Verified dead on `main` as well, so this is pre-existing rather + than fallout from the 2026-09-10 cut — recorded here rather than swept in with it. `useSwipe` and + `useOfflineDownload` in particular are documented in this file's own hook inventory as if they were + live; they are not. + Each of these is a real defect that is *known and not yet fixed*. They live here rather than in someone's memory. diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d029969d9..95e15c9c3 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -16,7 +16,6 @@ export * from './reader/textAnchor' export * from './reader/textPosition' export * from './reader/progressPayload' export * from './reader/continueReading' -export * from './reader/citation' export * from './reader/pdfProgress' export * from './reader/pdfPageWindow' export * from './reader/pdfHighlightAnchor' diff --git a/packages/shared/src/reader/citation.test.ts b/packages/shared/src/reader/citation.test.ts deleted file mode 100644 index c21c5bdce..000000000 --- a/packages/shared/src/reader/citation.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { citationLabel } from './citation' - -const chapters = [ - { chapterNumber: 0, title: 'Book I' }, - { chapterNumber: 1, title: 'Book II' }, -] - -describe('citationLabel', () => { - it('names the chapter rather than its stored ordinal', () => { - // The defect verbatim: six chips all reading "ch.0" under an answer about Book I. - expect(citationLabel({ marker: 6, chapterOrd: 0 }, chapters)).toBe('[6] Book I') - }) - - it('carries the marker so [n] in the answer can be found below it', () => { - expect(citationLabel({ marker: 13, chapterOrd: 1 }, chapters)).toBe('[13] Book II') - }) - - it('falls back to a 1-based number when the chapter list is not to hand', () => { - // ch.0 is the first chapter, not a missing value — so the fallback must not print the raw ord. - expect(citationLabel({ marker: 1, chapterOrd: 0 })).toBe('[1] ch.1') - expect(citationLabel({ chapterOrd: 3 })).toBe('ch.4') - }) - - it('prefers a page for an unanchored PDF citation', () => { - expect(citationLabel({ marker: 2, chapterOrd: 0, sourcePage: 12 }, chapters)).toBe('[2] p.12') - }) - - it('survives a citation with neither chapter nor page', () => { - expect(citationLabel({ marker: 4 })).toBe('[4]') - expect(citationLabel({})).toBe('—') - }) - - it('ignores a blank chapter title rather than rendering an empty chip', () => { - expect(citationLabel({ marker: 1, chapterOrd: 0 }, [{ chapterNumber: 0, title: ' ' }])) - .toBe('[1] ch.1') - }) -}) diff --git a/packages/shared/src/reader/citation.ts b/packages/shared/src/reader/citation.ts deleted file mode 100644 index 200ce97f0..000000000 --- a/packages/shared/src/reader/citation.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Resolves a RAG citation's chapter ordinal to the chapter's slug, for navigating the reader to the - * cited chapter (AI-026). Returns undefined when no chapter matches. - */ -export function citationChapterSlug( - chapters: { chapterNumber?: number; slug: string }[], - chapterOrd: number, -): string | undefined { - return chapters.find(c => c.chapterNumber === chapterOrd)?.slug -} - -/** The chapter a citation points at, when the reader's chapter list is available. */ -export function citationChapter( - chapters: T[], - chapterOrd: number | null | undefined, -): T | undefined { - if (chapterOrd == null) return undefined - return chapters.find(c => c.chapterNumber === chapterOrd) -} - -/** - * What a citation chip says. - * - * It used to say `ch.0`, six times, under an answer citing `[1][6][7][13][14][17]` — and QA could - * not match a single marker to a single chip. Two separate faults wearing one symptom: - * - * 1. **`chapterOrd` is 0-based.** `ch.0` was not a null leaking through; it is the first chapter, - * the one titled "Book I". Every other reader surface prints either the title or `ord + 1` - * (`ReaderTocDrawer`, `ReaderFooterNav`), so the chip was the one place showing the internal - * index. It prefers the real title now, and falls back to `ch.{ord + 1}` when the chapter list - * is not to hand. - * 2. **The marker was thrown away.** `AskCitation.marker` carries the `[n]` from the answer text and - * mobile never rendered it — and mobile deliberately does not make the inline markers tappable, - * so the chip is the only place the two could ever have been joined. - */ -export function citationLabel( - citation: { marker?: number; chapterOrd?: number | null; sourcePage?: number | null }, - chapters: { chapterNumber?: number; title?: string }[] = [], -): string { - const marker = citation.marker != null ? `[${citation.marker}] ` : '' - - if (citation.sourcePage != null) return `${marker}p.${citation.sourcePage}` - - const title = citationChapter(chapters, citation.chapterOrd)?.title?.trim() - if (title) return `${marker}${title}` - - // No chapter list (or no match): show the human 1-based number rather than the stored ordinal. - if (citation.chapterOrd != null) return `${marker}ch.${citation.chapterOrd + 1}` - - return marker.trim() || '—' -} - -const SNIPPET_MAX = 40 -const SNIPPET_MIN = 12 - -/** - * A short, distinctive prefix of a citation's preview, cut at a word boundary. Kept short so it's - * likely to sit within a single DOM text node — citation scroll locates the passage by searching the - * rendered text for this snippet (the chunk offsets are into PlainText, not the rendered DOM). Both - * web (AI-026b) and the mobile WebView (AI-026d) use it. Returns '' when there isn't enough to match on. - */ -export function makeSnippet(preview: string): string { - const text = preview.replace(/\s+/g, ' ').trim() - if (text.length < SNIPPET_MIN) return '' - if (text.length <= SNIPPET_MAX) return text - const cut = text.slice(0, SNIPPET_MAX) - const lastSpace = cut.lastIndexOf(' ') - return lastSpace >= SNIPPET_MIN ? cut.slice(0, lastSpace) : cut -} diff --git a/packages/shared/src/types/api.ts b/packages/shared/src/types/api.ts index 60a23f159..1113305ee 100644 --- a/packages/shared/src/types/api.ts +++ b/packages/shared/src/types/api.ts @@ -467,46 +467,3 @@ export interface UserBookChapterDto { /** 1-based PDF page where this chapter starts. Null for EPUBs / unknown. */ sourceStartPage?: number | null } - -// "Ask this book" (Phase 4 RAG, AI-025/026). Mirrors backend Contracts.Books.Ask*. -export interface AskCitation { - marker: number - chunkId: string - // Nullable at runtime for PDF citations that aren't chapter-anchored (ADR-012 S3b). - // Typed non-null here for mobile back-compat; consumers must null-guard. - chapterId: string - chapterOrd: number - charStart: number - charEnd: number - preview: string - // Vision-RAG / PDF citations (ADR-012 S3): the source page to jump the Original - // PDF viewer to, and an optional human-readable section path for tooltips. - sourcePage?: number | null - sectionPath?: string | null -} - -export interface AskResponse { - answer: string - citations: AskCitation[] - lastReadOrd: number - insufficient: boolean -} - -/** - * One prior turn of the conversation, sent back to the server for multi-turn "Ask this book" - * (AI-026e). The client bounds the history (last 6 turns) before sending. - */ -export interface AskTurnDto { - role: 'user' | 'assistant' - content: string -} - -/** - * Identifies what the "Ask this book" panel is pointed at (AI-027 P2). A catalog `edition` - * routes to `/books/{id}/...`; a user-uploaded `userbook` routes to `/me/books/{id}/...`. - * The reader builds this from whichever book it loaded and threads it through the sheet. - */ -export interface AskTarget { - kind: 'edition' | 'userbook' - id: string -}