diff --git a/CHANGELOG.md b/CHANGELOG.md index e193d2a1c..1458ab52b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## [Unreleased] +## [1.31.3] — 2026-07-20 + +- **Document summaries now stay in the language you chose.** The same account + language is used whether a summary is created in the background or requested + from the document sheet. A saved summary remains visible and can be replaced + with **Generate again** after you switch languages. +- **The Coach composer has more room on phones.** The writing field now gets + its own line, with document and conversation actions below it and Send on the + right. The unreliable browser dictation button and its dead-end permission + states are gone. + +No migrations. No breaking changes. + ## [1.31.2] — 2026-07-20 - **Provider imports now commit identity and progress atomically.** Withings, diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f1b8f627d..facc77ecb 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.31.2 + version: 1.31.3 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -10219,13 +10219,13 @@ paths: post: tags: - Documents - summary: Summarise or transcribe a document (session-only) - description: 'On-demand, SESSION-ONLY description of a stored document. `?mode=summary` (default) returns a short - plain-language summary of WHAT the document is; `?mode=text` returns its raw transcribed text. The result is - transient — nothing is persisted except the AI-budget ledger + audit log; it never reaches coach memory, - snapshots, the structured stores, or the search index. The summary is descriptive only and never a diagnosis. - Same VISION (empty body) / TEXT (`application/json` `{ mode: "text", text }`, opt-in local OCR) dispatch as - extract. AI-consent / rate / budget gated. 422 with no provider.' + summary: Summarise or transcribe a document + description: 'On-demand description of a stored document. `?mode=summary` (default) returns a short plain-language + summary of WHAT the document is; it remains transient unless `persist=true` fills the empty stored-summary slot, + while `persist=true&replace=true` explicitly replaces an existing summary. `?mode=text` returns transient raw + transcribed text. Neither result reaches coach memory, snapshots, structured stores, or the search index. The + summary is descriptive only and never a diagnosis. Same VISION (empty body) / TEXT (`application/json` `{ mode: + "text", text }`, opt-in local OCR) dispatch as extract. AI-consent / rate / budget gated. 422 with no provider.' parameters: - name: id in: path @@ -10242,6 +10242,20 @@ paths: - text default: summary description: "`summary` (plain-language description) or `text` (raw)." + - name: persist + in: query + required: false + schema: + type: boolean + default: false + description: "For summary mode only: persist into an empty stored-summary slot." + - name: replace + in: query + required: false + schema: + type: boolean + default: false + description: With `persist=true`, explicitly replace an existing stored summary. requestBody: required: false content: @@ -10271,7 +10285,7 @@ paths: - text responses: "200": - description: The session-only summary (`{ summary }`) or extracted text (`{ text }`). + description: The summary and its persistence outcome, or transient extracted text. content: application/json: schema: @@ -30389,6 +30403,12 @@ components: type: string text: type: string + persistence: + type: string + enum: + - stored + - withheld + - failed additionalProperties: false DocumentIndexEnvelope: type: object diff --git a/messages/de.json b/messages/de.json index 503a99d3f..4dd6ca7fe 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2469,10 +2469,6 @@ "settingsClustersHint": "Wähle, welche Datencluster der Coach lesen darf. Mehr Daten bedeuten reichhaltigere Antworten und einen größeren Prompt.", "settingsClusterNoData": "Noch keine Daten", "sourcesMemberCount": "{count} Werte", - "dictate": "Diktieren", - "dictateStop": "Diktat beenden", - "voiceUnsupported": "Spracheingabe wird in diesem Browser nicht unterstützt", - "dictateError": "Spracheingabe konnte nicht gestartet werden. Prüfe die Mikrofonberechtigung und versuche es erneut.", "showConversations": "Unterhaltungen anzeigen", "hideConversations": "Unterhaltungen ausblenden", "heroGreeting": "Frage mich etwas zu deinen Daten", @@ -8536,6 +8532,7 @@ "withheld": "Eine Zusammenfassung wurde erstellt, hat die Sicherheitsprüfung aber nicht bestanden und wird deshalb nicht angezeigt. Du kannst es erneut versuchen oder das Dokument selbst lesen.", "unavailable": "Für dieses Dokument konnte keine Zusammenfassung erstellt werden.", "generate": "Zusammenfassung erstellen", + "regenerate": "Neu erstellen", "generating": "Wird erstellt…" }, "loadError": "Dieses Dokument konnte nicht geladen werden.", diff --git a/messages/en.json b/messages/en.json index fce2bb3e3..c65c37f41 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2469,10 +2469,6 @@ "settingsClustersHint": "Pick which clusters of your data the Coach can read. More data means richer answers and a larger prompt.", "settingsClusterNoData": "No data yet", "sourcesMemberCount": "{count} metrics", - "dictate": "Dictate", - "dictateStop": "Stop dictation", - "voiceUnsupported": "Voice input is not supported in this browser", - "dictateError": "Could not start voice input. Check microphone permission and try again.", "showConversations": "Show conversations", "hideConversations": "Hide conversations", "heroGreeting": "Ask me anything about your data", @@ -8536,6 +8532,7 @@ "withheld": "A summary was generated but did not pass the safety check, so it is not shown. You can try again, or read the document itself.", "unavailable": "No summary could be generated for this document.", "generate": "Generate summary", + "regenerate": "Generate again", "generating": "Generating…" }, "loadError": "Couldn't load this document.", diff --git a/messages/es.json b/messages/es.json index 0304f5998..fac747d68 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2469,10 +2469,6 @@ "settingsClustersHint": "Elige qué grupos de datos puede leer el Coach. Más datos significan respuestas más ricas y un prompt mayor.", "settingsClusterNoData": "Aún no hay datos", "sourcesMemberCount": "{count} métricas", - "dictate": "Dictar", - "dictateStop": "Detener el dictado", - "voiceUnsupported": "La entrada de voz no es compatible con este navegador", - "dictateError": "No se pudo iniciar la entrada de voz. Comprueba el permiso del micrófono e inténtalo de nuevo.", "showConversations": "Mostrar conversaciones", "hideConversations": "Ocultar conversaciones", "heroGreeting": "Pregúntame lo que quieras sobre tus datos", @@ -8536,6 +8532,7 @@ "withheld": "Se generó un resumen, pero no superó la comprobación de seguridad, así que no se muestra. Puedes intentarlo de nuevo o leer el documento.", "unavailable": "No se pudo generar ningún resumen para este documento.", "generate": "Generar resumen", + "regenerate": "Volver a generar", "generating": "Generando…" }, "loadError": "No se pudo cargar este documento.", diff --git a/messages/fr.json b/messages/fr.json index 5fbd22c90..16648caaa 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2469,10 +2469,6 @@ "settingsClustersHint": "Choisissez les groupes de données que le Coach peut lire. Plus de données, des réponses plus riches et un prompt plus grand.", "settingsClusterNoData": "Pas encore de données", "sourcesMemberCount": "{count} mesures", - "dictate": "Dicter", - "dictateStop": "Arrêter la dictée", - "voiceUnsupported": "La saisie vocale n'est pas prise en charge par ce navigateur", - "dictateError": "Impossible de démarrer la saisie vocale. Vérifiez l'autorisation du microphone et réessayez.", "showConversations": "Afficher les conversations", "hideConversations": "Masquer les conversations", "heroGreeting": "Pose-moi une question sur tes données", @@ -8536,6 +8532,7 @@ "withheld": "Un résumé a été généré mais n’a pas passé le contrôle de sécurité, il n’est donc pas affiché. Vous pouvez réessayer ou lire le document lui-même.", "unavailable": "Aucun résumé n’a pu être généré pour ce document.", "generate": "Générer le résumé", + "regenerate": "Regénérer", "generating": "Génération…" }, "loadError": "Impossible de charger ce document.", diff --git a/messages/it.json b/messages/it.json index 77c75bf4f..018c078eb 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2469,10 +2469,6 @@ "settingsClustersHint": "Scegli quali gruppi di dati il Coach può leggere. Più dati significano risposte più ricche e un prompt più grande.", "settingsClusterNoData": "Ancora nessun dato", "sourcesMemberCount": "{count} metriche", - "dictate": "Detta", - "dictateStop": "Interrompi dettatura", - "voiceUnsupported": "L'inserimento vocale non è supportato in questo browser", - "dictateError": "Impossibile avviare l'inserimento vocale. Controlla l'autorizzazione del microfono e riprova.", "showConversations": "Mostra conversazioni", "hideConversations": "Nascondi conversazioni", "heroGreeting": "Chiedimi qualcosa sui tuoi dati", @@ -8536,6 +8532,7 @@ "withheld": "Un riepilogo è stato generato ma non ha superato il controllo di sicurezza, quindi non viene mostrato. Puoi riprovare o leggere il documento stesso.", "unavailable": "Non è stato possibile generare un riepilogo per questo documento.", "generate": "Genera riepilogo", + "regenerate": "Genera di nuovo", "generating": "Generazione…" }, "loadError": "Impossibile caricare questo documento.", diff --git a/messages/pl.json b/messages/pl.json index 626570f26..e0c541e04 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2469,10 +2469,6 @@ "settingsClustersHint": "Wybierz, które grupy danych Coach może czytać. Więcej danych to bogatsze odpowiedzi i większy prompt.", "settingsClusterNoData": "Brak danych", "sourcesMemberCount": "{count} wskaźników", - "dictate": "Dyktuj", - "dictateStop": "Zatrzymaj dyktowanie", - "voiceUnsupported": "Wprowadzanie głosowe nie jest obsługiwane w tej przeglądarce", - "dictateError": "Nie udało się uruchomić wprowadzania głosowego. Sprawdź uprawnienia mikrofonu i spróbuj ponownie.", "showConversations": "Pokaż rozmowy", "hideConversations": "Ukryj rozmowy", "heroGreeting": "Zapytaj mnie o cokolwiek na temat twoich danych", @@ -8536,6 +8532,7 @@ "withheld": "Podsumowanie zostało wygenerowane, ale nie przeszło kontroli bezpieczeństwa, więc nie jest wyświetlane. Możesz spróbować ponownie lub przeczytać sam dokument.", "unavailable": "Nie udało się wygenerować podsumowania dla tego dokumentu.", "generate": "Wygeneruj podsumowanie", + "regenerate": "Wygeneruj ponownie", "generating": "Generowanie…" }, "loadError": "Nie udało się wczytać tego dokumentu.", diff --git a/package.json b/package.json index 86bff188d..490d269eb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.31.2", + "version": "1.31.3", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 1b480b10f..8ab47acb8 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.31.2"; + /* @sw-version-fallback */ "v1.31.3"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/app/api/documents/inbound/[id]/summary/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/summary/__tests__/route.test.ts index 9efa2b15a..b445164cf 100644 --- a/src/app/api/documents/inbound/[id]/summary/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/summary/__tests__/route.test.ts @@ -93,18 +93,25 @@ const SESSION_OK = { user: { id: "user-1", username: "tester", role: "USER" as const }, }; const ctx = (id: string) => ({ params: Promise.resolve({ id }) }); -const req = (id: string, mode?: string) => - new NextRequest( - new URL( - `http://localhost/api/documents/inbound/${id}/summary${mode ? `?mode=${mode}` : ""}`, - ), +const req = ( + id: string, + mode?: string, + options: { persist?: boolean; replace?: boolean } = {}, +) => { + const query = new URLSearchParams(); + if (mode) query.set("mode", mode); + if (options.persist) query.set("persist", "true"); + if (options.replace) query.set("replace", "true"); + const suffix = query.size > 0 ? `?${query.toString()}` : ""; + return new NextRequest( + new URL(`http://localhost/api/documents/inbound/${id}/summary${suffix}`), { method: "POST" }, ); +}; /** - * Nothing beyond the summary column may be written. `mode=summary` DOES persist - * onto the document since v1.30.31 (see the route header) — that leg is asserted - * explicitly below; everything else stays session-only. + * Nothing beyond the explicitly requested summary column may be written. + * Transcription and ordinary action-row summaries stay session-only. */ function assertNoPersistence(): void { expect(prisma.inboundDocument.update).not.toHaveBeenCalled(); @@ -124,6 +131,9 @@ beforeEach(() => { mimeType: "image/png", status: "STORED", } as never); + vi.mocked(prisma.inboundDocument.updateMany).mockResolvedValue({ + count: 1, + }); vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ chain: [{ providerType: "anthropic", instance: {} }], pick: { @@ -135,9 +145,9 @@ beforeEach(() => { }); describe("POST /api/documents/inbound/[id]/summary", () => { - it("mode=summary returns { summary } and STORES it, so a second open is free", async () => { + it("keeps an ordinary requested summary session-only", async () => { vi.mocked(runDocumentSummary).mockResolvedValue({ - summary: "A blood panel from a lab.", + summary: "A transient summary.", blocked: null, } as never); @@ -145,16 +155,34 @@ describe("POST /api/documents/inbound/[id]/summary", () => { req("doc-1", "summary") as never, ctx("doc-1") as never, ); + + expect(res.status).toBe(200); + expect((await res.json()).data).toEqual({ + summary: "A transient summary.", + }); + expect(prisma.inboundDocument.updateMany).not.toHaveBeenCalled(); + }); + + it("stores the first requested summary without replacing an existing one", async () => { + vi.mocked(runDocumentSummary).mockResolvedValue({ + summary: "A blood panel from a lab.", + blocked: null, + } as never); + + const res = await POST( + req("doc-1", "summary", { persist: true }) as never, + ctx("doc-1") as never, + ); expect(res.status).toBe(200); const body = await res.json(); - expect(body.data).toEqual({ summary: "A blood panel from a lab." }); + expect(body.data).toEqual({ + summary: "A blood panel from a lab.", + persistence: "stored", + }); expect(auditLog).toHaveBeenCalledWith( "documents.inbound.summary", expect.anything(), ); - - // Persisted READY, owner-scoped, and only onto a document without one — - // an explicit re-run must not replace a summary already on screen. expect(prisma.inboundDocument.updateMany).toHaveBeenCalledWith({ where: { id: "doc-1", @@ -167,6 +195,49 @@ describe("POST /api/documents/inbound/[id]/summary", () => { assertNoPersistence(); }); + it("replaces a stored summary only when replacement is explicit", async () => { + vi.mocked(runDocumentSummary).mockResolvedValue({ + summary: "A newly generated summary.", + blocked: null, + } as never); + + const res = await POST( + req("doc-1", "summary", { persist: true, replace: true }) as never, + ctx("doc-1") as never, + ); + + expect(res.status).toBe(200); + expect(prisma.inboundDocument.updateMany).toHaveBeenCalledWith({ + where: { + id: "doc-1", + userId: "user-1", + deletedAt: null, + }, + data: expect.objectContaining({ summaryState: "READY" }), + }); + }); + + it("uses the signed-in user's locale for a requested summary", async () => { + vi.mocked(getSession).mockResolvedValue({ + ...SESSION_OK, + user: { ...SESSION_OK.user, locale: "de" }, + } as never); + vi.mocked(runDocumentSummary).mockResolvedValue({ + summary: "Ein Laborbericht.", + blocked: null, + } as never); + + const res = await POST( + req("doc-1", "summary") as never, + ctx("doc-1") as never, + ); + + expect(res.status).toBe(200); + expect(runDocumentSummary).toHaveBeenCalledWith( + expect.objectContaining({ locale: "de" }), + ); + }); + it("records WITHHELD without ever storing the blocked prose", async () => { vi.mocked(runDocumentSummary).mockResolvedValue({ summary: "raise the dose to 10 mg", @@ -174,7 +245,7 @@ describe("POST /api/documents/inbound/[id]/summary", () => { } as never); const res = await POST( - req("doc-1", "summary") as never, + req("doc-1", "summary", { persist: true }) as never, ctx("doc-1") as never, ); expect(res.status).toBe(200); @@ -182,6 +253,7 @@ describe("POST /api/documents/inbound/[id]/summary", () => { // The user gets the honest statement, never the blocked text. const body = await res.json(); expect(body.data.summary).toBe("The summary was withheld."); + expect(body.data.persistence).toBe("withheld"); expect(body.data.summary).not.toContain("10 mg"); // Only the state lands. No ciphertext column is touched on this path. @@ -201,6 +273,27 @@ describe("POST /api/documents/inbound/[id]/summary", () => { assertNoPersistence(); }); + it("reports a failed save when the document changed before the write", async () => { + vi.mocked(runDocumentSummary).mockResolvedValue({ + summary: "A blood panel from a lab.", + blocked: null, + } as never); + vi.mocked(prisma.inboundDocument.updateMany).mockResolvedValueOnce({ + count: 0, + }); + + const res = await POST( + req("doc-1", "summary", { persist: true }) as never, + ctx("doc-1") as never, + ); + + expect(res.status).toBe(200); + expect((await res.json()).data).toEqual({ + summary: "A blood panel from a lab.", + persistence: "failed", + }); + }); + it("still answers when the summary write fails", async () => { vi.mocked(runDocumentSummary).mockResolvedValue({ summary: "A blood panel from a lab.", @@ -213,12 +306,13 @@ describe("POST /api/documents/inbound/[id]/summary", () => { // The user is waiting on a summary they can already read; a failed write // must not turn that into an error. const res = await POST( - req("doc-1", "summary") as never, + req("doc-1", "summary", { persist: true }) as never, ctx("doc-1") as never, ); expect(res.status).toBe(200); expect((await res.json()).data).toEqual({ summary: "A blood panel from a lab.", + persistence: "failed", }); }); diff --git a/src/app/api/documents/inbound/[id]/summary/route.ts b/src/app/api/documents/inbound/[id]/summary/route.ts index 480cc93c8..9003afbe4 100644 --- a/src/app/api/documents/inbound/[id]/summary/route.ts +++ b/src/app/api/documents/inbound/[id]/summary/route.ts @@ -86,39 +86,56 @@ function rateLimited(rl: Awaited>): Response { return response; } +type SummaryPersistenceTarget = { + userId: string; + documentId: string; + replaceExisting: boolean; +}; + function resolveMode(request: NextRequest): DocumentSummaryMode { const raw = new URL(request.url).searchParams.get("mode"); return (DOCUMENT_SUMMARY_MODES as readonly string[]).includes(raw ?? "") ? (raw as DocumentSummaryMode) : "summary"; } +function resolvePersistRequested(request: NextRequest): boolean { + return new URL(request.url).searchParams.get("persist") === "true"; +} + +function resolveReplaceExisting(request: NextRequest): boolean { + return new URL(request.url).searchParams.get("replace") === "true"; +} /** * Run the requested describe call (summary or raw text) over the input. * - * `mode=summary` PERSISTS a clean result onto the document (v1.30.31). The - * route was session-only by design (P2-D4), and the transcription leg still is - * — but a summary that the user explicitly asked for and that passed the screen - * is exactly the artefact the background job stores, and re-deriving it on every - * open would mean paying a provider call to show the same paragraph twice. This - * is a deliberate user action, not a warm-on-mount: nothing generates because a - * view rendered. It is also the only repair path for the documents the - * background job never ran for — those uploaded before the auto-read opt-in - * went on, which no backfill ever reaches. + * Ordinary action-row summaries and raw transcription remain session-only. + * The document summary block opts into persistence so an empty document can be + * repaired and an existing stored summary can be explicitly regenerated. */ async function describe( mode: DocumentSummaryMode, input: DescribeInput, locale: Locale, - persist: { userId: string; documentId: string } | null, -): Promise<{ summary: string } | { text: string }> { + persist: SummaryPersistenceTarget | null, +): Promise< + | { + summary: string; + persistence?: "stored" | "withheld" | "failed"; + } + | { text: string } +> { if (mode === "text") return transcribeDocument(input); - // REPLACE policy: the user clicked "summarise" and is waiting, so a screened - // summary becomes a short honest statement rather than an empty panel. The - // document itself and the extracted text remain reachable and unaltered. + // A normal summary request fills an empty slot only. Replacement is reserved + // for the document detail's explicit "Generate again" action. const { summary, blocked } = await runDocumentSummary({ ...input, locale }); - if (persist) await persistSummary(persist, summary, blocked); - return { summary: blocked ? documentSummaryBlockedCopy(locale) : summary }; + const persistence = persist + ? await persistSummary(persist, summary, blocked) + : undefined; + return { + summary: blocked ? documentSummaryBlockedCopy(locale) : summary, + ...(persistence ? { persistence } : {}), + }; } /** @@ -133,10 +150,10 @@ async function describe( * read in the response; a failed write must not turn that into an error. */ async function persistSummary( - target: { userId: string; documentId: string }, + target: SummaryPersistenceTarget, summary: string, blocked: OutboundReason | null, -): Promise { +): Promise<"stored" | "withheld" | "failed"> { try { if (blocked) { await prisma.inboundDocument.updateMany({ @@ -148,16 +165,17 @@ async function persistSummary( }, data: { summaryState: "WITHHELD" }, }); - return; + return "withheld"; } - // Scoped to a document without a stored summary: an explicit re-run must - // not silently replace a summary the user already has on screen. + // Preserve a previously stored summary unless the caller explicitly chose + // the replacement action. Failed or screened attempts never reach this + // write, so the previous clean summary remains available in those cases. const written = await prisma.inboundDocument.updateMany({ where: { id: target.documentId, userId: target.userId, deletedAt: null, - summaryEncrypted: null, + ...(target.replaceExisting ? {} : { summaryEncrypted: null }), }, data: { summaryEncrypted: encryptDocumentSummary(summary), @@ -169,11 +187,13 @@ async function persistSummary( action: { name: "documents.summary.persisted" }, meta: { documentId: target.documentId, stored: written.count > 0 }, }); + return written.count > 0 ? "stored" : "failed"; } catch { annotate({ action: { name: "documents.summary.persistFailed" }, meta: { documentId: target.documentId }, }); + return blocked ? "withheld" : "failed"; } } @@ -200,6 +220,14 @@ export const POST = apiHandler( } const mode = resolveMode(request); + const persist: SummaryPersistenceTarget | null = + mode === "summary" && resolvePersistRequested(request) + ? { + userId: user.id, + documentId: document.id, + replaceExisting: resolveReplaceExisting(request), + } + : null; // The outbound screen on the summary needs the reader's locale to pick its // pattern banks; resolve it once here and thread it into both describe legs. const locale = await resolveServerLocale({ @@ -208,9 +236,23 @@ export const POST = apiHandler( }); const contentType = request.headers.get("content-type") ?? ""; if (contentType.includes("application/json")) { - return handleTextSummary(request, user.id, document, mode, locale); + return handleTextSummary( + request, + user.id, + document, + mode, + locale, + persist, + ); } - return handleVisionSummary(request, user.id, document, mode, locale); + return handleVisionSummary( + request, + user.id, + document, + mode, + locale, + persist, + ); }, ); @@ -241,6 +283,7 @@ async function handleTextSummary( document: LoadedDocument, mode: DocumentSummaryMode, locale: Locale, + persist: SummaryPersistenceTarget | null, ): Promise { const row = await prisma.user.findUnique({ where: { id: userId }, @@ -315,7 +358,7 @@ async function handleTextSummary( ocrText: parsed.data.text, }, locale, - { userId, documentId: document.id }, + persist, ); await reconcileSpend( userId, @@ -337,6 +380,7 @@ async function handleVisionSummary( document: LoadedDocument, mode: DocumentSummaryMode, locale: Locale, + persist: SummaryPersistenceTarget | null, ): Promise { const { pick } = await resolveDocumentVisionProvider(userId); if (!pick) { @@ -401,7 +445,7 @@ async function handleVisionSummary( documents: vision.documents, }, locale, - { userId, documentId: document.id }, + persist, ); await reconcileSpend( userId, diff --git a/src/components/documents/__tests__/document-summary-block.test.tsx b/src/components/documents/__tests__/document-summary-block.test.tsx index 8a9b2143b..1b36796f8 100644 --- a/src/components/documents/__tests__/document-summary-block.test.tsx +++ b/src/components/documents/__tests__/document-summary-block.test.tsx @@ -55,11 +55,32 @@ describe("DocumentSummaryBlock — a stored summary", () => { expect(html).toContain("A discharge letter from a city hospital."); expect(html).toContain("14 Feb 2026"); expect(html).toContain('data-slot="document-detail-summary"'); - // Nothing claims it is being made — it already exists. + // A stored result stays visible and can be explicitly regenerated. expect(html).not.toContain("being prepared"); + expect(html).toContain('data-slot="document-detail-summary-regenerate"'); + expect(html).toContain("Generate again"); + const header = html.match( + /]*data-slot="document-detail-summary-header"[^>]*>/, + ); + expect(header?.[0]).toMatch(/\bflex-col\b/); + expect(header?.[0]).toMatch(/\bsm:flex-row\b/); expect(html).not.toContain("document-detail-summary-generate"); }); + it("disables regeneration while a replacement is being generated", () => { + const html = base({ + summary: "Stored prose.", + summaryState: "READY", + isGenerating: true, + }); + + const action = html.match( + /]*data-slot="document-detail-summary-regenerate"[^>]*>/, + ); + expect(action?.[0]).toMatch(/\sdisabled(=""|\s|>)/); + expect(html).toContain("Generating"); + }); + it("shows a stored summary even if the state disagrees", () => { // The bytes are the truth. A stale state must never hide a real summary. const html = base({ summary: "Stored prose.", summaryState: "PENDING" }); diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index 0270d523e..6c0a3a154 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -260,6 +260,9 @@ export function DocumentDetailSheet({ const autoRead = useDocumentsAutoAiRead(open && documentId !== null); const suggest = useSuggestDetails(); const summary = useDocumentSummary(); + // Stored generation has an independent mutation state so transient + // Summarise/Show text requests cannot change this block's spinner or result. + const storedSummary = useDocumentSummary(); const indexDoc = useIndexDocument(); const [mutationError, setMutationError] = useState(null); @@ -296,6 +299,7 @@ export function DocumentDetailSheet({ useEffect(() => { suggest.reset(); summary.reset(); + storedSummary.reset(); indexDoc.reset(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [documentId]); @@ -399,17 +403,29 @@ export function DocumentDetailSheet({ summary.mutate({ mode: aiMode, target: aiTarget, output }); }; - // Generate the STORED summary from the detail block. Same endpoint as the - // action row, but the result is not routed into the transient panel: the - // route persists a screened-clean summary, so refetching the document shows - // it in place. This runs only on a click — nothing here generates on open. + // Generate or regenerate the STORED summary from the detail block. Only the + // stored-summary branch requests replacement; the empty-state action keeps + // first-write-wins if another worker or tab finishes while AI is running. const generateStoredSummary = () => { if (!aiTarget || !documentId) return; - summary.reset(); - summary.mutate( - { mode: aiMode, target: aiTarget, output: "summary" }, + storedSummary.reset(); + storedSummary.mutate( { - onSuccess: () => { + mode: aiMode, + target: aiTarget, + output: "summary", + persist: true, + replace: doc?.summary != null, + }, + { + onSuccess: (result) => { + if ("summary" in result) { + if (result.persistence === "withheld") { + toast.error(result.summary); + } else if (result.persistence === "failed") { + toast.error(t("documents.detail.saveError")); + } + } void queryClient.invalidateQueries({ queryKey: queryKeys.inboundDocument(documentId), }); @@ -611,7 +627,7 @@ export function DocumentDetailSheet({ : null } aiEnabled={aiEnabled} - isGenerating={summary.isPending} + isGenerating={storedSummary.isPending} actionsDisabled={capability.isPending} onGenerate={generateStoredSummary} /> diff --git a/src/components/documents/document-summary-block.tsx b/src/components/documents/document-summary-block.tsx index f42b2be87..6b0bdc16f 100644 --- a/src/components/documents/document-summary-block.tsx +++ b/src/components/documents/document-summary-block.tsx @@ -27,7 +27,7 @@ * provider call. With no AI provider configured the block renders the state * without an action rather than an button that would 422. */ -import { FileText } from "lucide-react"; +import { FileText, RefreshCw } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useTranslations } from "@/lib/i18n/context"; @@ -63,13 +63,42 @@ export function DocumentSummaryBlock({ }) { const { t } = useTranslations(); - // A stored summary wins over every state: it is shown from storage. + // A stored summary wins over every state: it is shown from storage. The + // adjacent action is explicit, so opening the sheet never spends a provider + // call; a successful replacement is persisted by the summary route. if (summary) { return ( -
-

- {t("documents.detail.summary.title")} -

+
+
+

+ {t("documents.detail.summary.title")} +

+ {aiEnabled ? ( + + ) : null} +

{summary}

{generatedAtLabel ? (

{generatedAtLabel}

diff --git a/src/components/documents/use-document-assist.ts b/src/components/documents/use-document-assist.ts index e52faeed2..da5ae7814 100644 --- a/src/components/documents/use-document-assist.ts +++ b/src/components/documents/use-document-assist.ts @@ -30,8 +30,13 @@ import { type DocumentAiTarget, } from "./document-ai-transport"; -/** The session-only describe result — a summary XOR the raw extracted text. */ -export type DocumentDescribeResult = { summary: string } | { text: string }; +/** The describe result: raw text, or a summary plus its storage outcome. */ +export type DocumentDescribeResult = + | { + summary: string; + persistence?: "stored" | "withheld" | "failed"; + } + | { text: string }; /** * The document-scoped AI capability probe (`/api/documents/inbound/capability`). @@ -95,8 +100,8 @@ export function useSuggestDetails() { } /** - * On-demand, session-only summary or extracted text. The result is rendered - * once and never persisted (P2-D4); closing the panel discards it. + * On-demand summary or extracted text. Both stay transient unless the document + * summary block explicitly requests persistence or replacement. */ export function useDocumentSummary() { return useMutation< @@ -106,11 +111,13 @@ export function useDocumentSummary() { mode: DocumentAiMode; target: DocumentAiTarget; output: DocumentSummaryMode; + persist?: boolean; + replace?: boolean; } >({ - mutationFn: ({ mode, target, output }) => + mutationFn: ({ mode, target, output, persist = false, replace = false }) => runDocumentAi({ - path: `/api/documents/inbound/${target.documentId}/summary?mode=${output}`, + path: `/api/documents/inbound/${target.documentId}/summary?mode=${output}${persist ? "&persist=true" : ""}${replace ? "&replace=true" : ""}`, mode, target, }), diff --git a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx index bdcb5fc89..891d6264c 100644 --- a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx @@ -24,12 +24,7 @@ describe("", () => { expect(html).not.toContain("Coach replies are generated"); }); - it("hides the mic in SSR markup (dictation is client-and-secure-context only)", () => { - // v1.25.0 — the mic only mounts once the client confirms the Web Speech - // API exists AND the page runs in a secure context. SSR (and any - // unsupported / non-secure environment) renders NO mic at all, so the - // composer never ships an advertised-but-erroring control. The earlier - // "render it disabled" affordance is gone. + it("does not render the retired voice-input control", () => { const html = render( {}} onSubmit={() => {}} />, ); @@ -233,11 +228,7 @@ describe("", () => { expect(html).toContain('data-slot="coach-input-send"'); }); - it("renders the one-row page composer with showHub", () => { - // v1.21.0 — the page composer is ONE baseline row: a leading `+` actions - // menu (new chat + open conversations), the textarea, then mic + send. - // The settings gear moved OUT of the composer (now the page toolbar's - // top-right), so the composer's front is only the `+`. + it("puts the textarea above a split control row on phones", () => { const html = render( ", () => { onOpenHistory={() => {}} />, ); - expect(html).toContain('data-slot="coach-input-hub"'); + expect(html).toContain('data-slot="coach-input-controls"'); + expect(html).toContain('data-slot="coach-input-leading"'); expect(html).toContain('data-slot="coach-input-actions"'); - // The settings gear is gone from the composer entirely. expect(html).not.toContain('data-slot="coach-input-settings"'); - // Send remains in the trailing cluster. The mic is client-and-secure- - // context only, so it never appears in SSR markup. expect(html).not.toContain('data-slot="coach-input-mic"'); expect(html).toContain('data-slot="coach-input-send"'); - // The leading `+` precedes the textarea; the textarea precedes the send. - const plusIdx = html.indexOf('data-slot="coach-input-actions"'); + + // DOM and visual order agree at every breakpoint: the textarea comes first, + // followed by the leading actions and then Send. const textareaIdx = html.indexOf('data-slot="coach-input-textarea"'); + const plusIdx = html.indexOf('data-slot="coach-input-actions"'); const sendIdx = html.indexOf('data-slot="coach-input-send"'); - expect(plusIdx).toBeGreaterThan(-1); - expect(plusIdx).toBeLessThan(textareaIdx); - expect(textareaIdx).toBeLessThan(sendIdx); + expect(textareaIdx).toBeGreaterThan(-1); + expect(textareaIdx).toBeLessThan(plusIdx); + expect(plusIdx).toBeLessThan(sendIdx); + + const controls = html.match( + /]*data-slot="coach-input-controls"[^>]*>/, + ); + expect(controls?.[0]).toMatch(/\bw-full\b/); + expect(controls?.[0]).toMatch(/\bjustify-between\b/); + expect(controls?.[0]).toMatch(/\bsm:w-auto\b/); + expect(controls?.[0]).not.toMatch(/\bsm:contents\b/); }); it("sizes the hub actions trigger to the 44px tap-target floor on phones", () => { diff --git a/src/components/insights/coach-panel/coach-input.tsx b/src/components/insights/coach-panel/coach-input.tsx index 477937b5d..123e4f97c 100644 --- a/src/components/insights/coach-panel/coach-input.tsx +++ b/src/components/insights/coach-panel/coach-input.tsx @@ -6,15 +6,12 @@ import { useCallback, useEffect, useRef, - useState, - useSyncExternalStore, } from "react"; import Link from "next/link"; import { FolderOpen, Loader2, MessagesSquare, - Mic, Paperclip, Plus, Send, @@ -23,7 +20,6 @@ import { Target, Upload, } from "lucide-react"; -import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { @@ -57,29 +53,14 @@ import { useTranslations } from "@/lib/i18n/context"; * the guard stack (system prompt, prose-grounding check, outbound * refusal), not a rendered disclaimer line. * - * v1.18.7 (W-coach C-UI): voice dictation returns to the web composer. - * The earlier placeholder mic was dropped in v1.4.25 because it did - * nothing on tap; this one is wired to the Web Speech API - * (`SpeechRecognition`). While the user dictates, interim + final - * transcripts append into the controlled `value`; the button toggles - * listening on/off and is fully keyboard- and screen-reader-accessible. - * - * v1.25.0: the mic only renders when dictation is genuinely usable — - * the Web Speech API exists AND the page runs in a secure context (the - * recogniser refuses to start over plain HTTP). On SSR, an unsupported - * browser (Firefox), a non-secure self-host, or after the user denies the - * mic permission, the control HIDES rather than sitting as an - * advertised-but-erroring affordance. A real failure mid-use surfaces a - * single quiet toast and then retires the button for the session. - * * v1.18.11 (W11): the composer is the conversation's control hub on the - * full-page Coach surface — ChatGPT-style. When `showHub` is set it grows a - * second action row INSIDE the rounded field: a leading `+` actions menu - * (new chat + open conversations), a settings deep-link, the dictation mic, - * and the send / stop control on the same baseline. The drawer surface omits - * `showHub` and keeps the single-row composer (its own header carries new - * chat + settings), so this is purely additive — the existing composer - * behaviour and markup are untouched when the hub is off. + * full-page Coach surface. When `showHub` is set it includes a `+` actions menu + * for a new chat or conversation history. The drawer omits `showHub` because + * its header already carries those actions. + * + * Voice input was removed after repeated browser and permission failures made + * the control unreliable. The composer now exposes only actions that work + * consistently across supported browsers. */ export interface CoachInputProps { value: string; @@ -184,74 +165,6 @@ export function computeAutoGrowHeight(args: { const AUTO_GROW_MAX_LINES = 6; -/** - * v1.18.7 — resolve the browser's `SpeechRecognition` constructor - * (prefixed `webkit` on Chromium / Safari, unprefixed on the spec - * track). Returns `null` server-side and on browsers without the API - * (Firefox) so the mic control hides rather than rendering dead. Kept - * tiny + typed locally — the project ships no DOM lib entry for the - * Web Speech API. - */ -type SpeechRecognitionLike = { - lang: string; - continuous: boolean; - interimResults: boolean; - start: () => void; - stop: () => void; - abort: () => void; - onresult: ((event: SpeechRecognitionResultEventLike) => void) | null; - onerror: ((event: SpeechRecognitionErrorEventLike) => void) | null; - onend: (() => void) | null; -}; -type SpeechRecognitionErrorEventLike = { error?: string }; -type SpeechRecognitionResultEventLike = { - resultIndex: number; - results: ArrayLike & { isFinal: boolean }>; -}; -type SpeechRecognitionConstructor = new () => SpeechRecognitionLike; - -function getSpeechRecognitionCtor(): SpeechRecognitionConstructor | null { - if (typeof window === "undefined") return null; - const w = window as unknown as { - SpeechRecognition?: SpeechRecognitionConstructor; - webkitSpeechRecognition?: SpeechRecognitionConstructor; - }; - return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null; -} - -// v1.18.7 — `useSyncExternalStore` resolves Web Speech API support -// without a setState-in-effect: the server snapshot is `false` (no -// mic at SSR) and the client snapshot reflects whether the constructor -// exists. The store never changes after hydration, so the subscribe -// callback is a no-op. -// -// v1.25.0 — the snapshot also requires a SECURE CONTEXT. `SpeechRecognition` -// throws / fires a `not-allowed` error the instant it starts over plain HTTP, -// so a LAN / Tailscale self-host on `http://` would otherwise advertise a mic -// that can never work. Gating support on `window.isSecureContext` hides it -// there cleanly instead. -const noopSubscribe = () => () => {}; -function useVoiceSupported(): boolean { - return useSyncExternalStore( - noopSubscribe, - () => getSpeechRecognitionCtor() !== null && window.isSecureContext, - () => false, - ); -} - -// v1.25.0 — map the app locale onto a BCP-47 tag with a region the -// recogniser accepts. The bare language subtag works in most engines, but -// the regioned form ("de-DE", "es-ES", …) yields materially better accuracy -// on Chromium / Safari. Unknown locales fall back to the bare subtag. -const SPEECH_RECOGNITION_LANG: Record = { - en: "en-US", - de: "de-DE", - es: "es-ES", - fr: "fr-FR", - it: "it-IT", - pl: "pl-PL", -}; - export function CoachInput({ value, onChange, @@ -269,7 +182,7 @@ export function CoachInput({ onPickFromVault, onUploadNew, }: CoachInputProps) { - const { t, locale } = useTranslations(); + const { t } = useTranslations(); const textareaRef = useRef(null); const attachFileInputRef = useRef(null); @@ -319,106 +232,6 @@ export function CoachInput({ el.style.height = `${height}px`; }, [value]); - // v1.18.7 — voice dictation. The mic control only mounts once the - // client confirms the Web Speech API exists (resolved through - // `useSyncExternalStore`, server snapshot `false`), so SSR + - // unsupported browsers render no button at all — never a dead - // affordance. `onChange` / `value` are mirrored into refs (in an - // effect, never during render) so the long-lived recognition handlers - // always read the freshest composer state without re-subscribing. - const voiceSupported = useVoiceSupported(); - const [listening, setListening] = useState(false); - // v1.25.0 — once the user denies the mic permission (or the engine reports - // it cannot serve), retire the control for the session rather than leaving a - // button that errors on every tap. Combined with `voiceSupported` this is - // the single gate the mic renders behind. - const [voiceBlocked, setVoiceBlocked] = useState(false); - const voiceAvailable = voiceSupported && !voiceBlocked; - const recognitionRef = useRef(null); - const onChangeRef = useRef(onChange); - const valueRef = useRef(value); - useEffect(() => { - onChangeRef.current = onChange; - valueRef.current = value; - }); - - // Tear the recogniser down on unmount so a live mic never outlives - // the composer (e.g. the drawer closing mid-dictation). - useEffect(() => { - return () => { - recognitionRef.current?.abort(); - recognitionRef.current = null; - }; - }, []); - - const stopDictation = useCallback(() => { - recognitionRef.current?.stop(); - }, []); - - const startDictation = useCallback(() => { - const Ctor = getSpeechRecognitionCtor(); - if (!Ctor) return; - const recognition = new Ctor(); - recognition.lang = SPEECH_RECOGNITION_LANG[locale] ?? locale; - recognition.continuous = true; - recognition.interimResults = true; - // Anchor every transcript onto the text present when dictation - // began so interim results replace cleanly rather than stacking. - const base = valueRef.current; - recognition.onresult = (event) => { - let finalText = ""; - let interimText = ""; - for (let i = event.resultIndex; i < event.results.length; i += 1) { - const result = event.results[i]; - const transcript = result[0]?.transcript ?? ""; - if (result.isFinal) finalText += transcript; - else interimText += transcript; - } - const spoken = (finalText + interimText).trim(); - if (!spoken) return; - const joiner = base && !base.endsWith(" ") ? " " : ""; - onChangeRef.current(`${base}${joiner}${spoken}`); - }; - recognition.onerror = (event) => { - setListening(false); - // `aborted` / `no-speech` are the benign codes that fire on a normal - // stop or a quiet pause — stay silent. - const code = event?.error; - if (!code || code === "aborted" || code === "no-speech") return; - // v1.25.0 — a permission refusal (or an engine that reports it cannot - // serve) is terminal: surface one quiet toast and HIDE the mic for the - // session so the user is not left tapping a control that always errors. - if (code === "not-allowed" || code === "service-not-allowed") { - setVoiceBlocked(true); - } - toast.error(t("insights.coach.dictateError")); - }; - recognition.onend = () => { - setListening(false); - recognitionRef.current = null; - }; - recognitionRef.current = recognition; - try { - recognition.start(); - setListening(true); - } catch { - // start() throws if already running; treat as a no-op. - setListening(false); - } - }, [locale, t]); - - const toggleDictation = useCallback(() => { - if (!voiceAvailable) return; - if (listening) stopDictation(); - else startDictation(); - }, [voiceAvailable, listening, startDictation, stopDictation]); - - // The mic only renders when dictation is available, so the label tracks the - // two live states: listening (tap to stop) and idle (tap to dictate). - const micLabel = listening - ? t("insights.coach.dictateStop") - : t("insights.coach.dictate"); - const canSubmit = !disabled && value.trim().length > 0; const handleKeyDown = useCallback( @@ -439,42 +252,7 @@ export function CoachInput({ [canSubmit, onSubmit], ); - // v1.18.11 — the dictation mic. Lifted to a const so it can sit in the - // single-row composer (drawer) OR in the hub action row (page) without - // duplicating the wiring. v1.25.0 — `null` when dictation is unavailable - // (no API, non-secure context, or a denied permission) so the surface hides - // the control rather than advertising a button that errors on tap. - const micButton = voiceAvailable ? ( - - ) : null; - - // v1.18.11 — the send / stop control. Same const-lift rationale as the - // mic: one definition, two mount points. + // Shared send / stop control for both page and drawer surfaces. const sendButton = isStreaming && onCancel ? ( // While a reply streams, swap the send button for a Stop control bound @@ -513,11 +291,9 @@ export function CoachInput({ ); - // v1.21.0 — the leading `+` actions menu (page surface only). Opens the - // attachment/scope menu: new chat + open conversations. The settings gear - // no longer lives here — it moved to the page toolbar's top-right corner, - // so the composer's front is ONLY the `+`. Const-lifted like the mic / send - // so it slots into the single-row composer without forking the layout. + // Leading `+` actions menu for the full-page Coach surface. The settings + // shortcut remains in this menu while the dedicated gear stays in the page + // toolbar. const actionsButton = ( @@ -638,33 +414,16 @@ export function CoachInput({ onSubmit={handleFormSubmit} className="flex flex-col" > - {/* v1.16.1 / v1.18.7 — modern chat-app composer: a single rounded - field, ONE baseline row. The drawer surface flanks the textarea with - the mic (left) and send / stop (right). The page surface (`showHub`) - leads with a `+` actions menu (new chat + open conversations), then - the textarea, then the mic + send — same row, vertically centred. - The settings gear is NOT in the composer; it lives in the page - toolbar's top-right corner. `items-end` keeps the flanking controls - pinned to the input's last line as it grows. Enter sends, - Shift+Enter inserts a newline. */}
- {/* Leading controls. The document-attach paperclip (module-gated) - leads, then the surface control: the `+` actions menu (page) or the - dictation mic (drawer). */} - {attachButton} - {showHub ? actionsButton : micButton}