feat(documents): review-first AI assist and session-only summary in the vault detail sheet

Add an optional AI layer to a stored document, gated on the existing AI
provider and consent — no new toggle. All of it rides the shared OCR
capability probe so the client only posts a transport the endpoint accepts
(vision empty-body POST, or local-OCR text posted from the browser).

- "Suggest details" prefills the title / type / date as reviewable DRAFTS.
  Nothing is written on its own: the title lands in the editable field and
  is saved through the existing commit path; type and date each apply on an
  explicit tap. The card carries a visible "review before saving" affordance.
- A summary / extracted-text button opens a transient panel that renders the
  result once and always shows a "not saved / not a diagnosis" note; closing
  it discards the text. Nothing persists beyond the view.
- When no provider is configured the AI area is a calm pointer to the AI
  settings, never an error; the manual metadata form stays fully usable.

The detail-sheet AI area is lifted into a presentational section component so
the availability gate and the review-first wiring render without the sheet's
dialog portal and are pinned by static-render tests. Strings land in all six
locales.
This commit is contained in:
Marc-André Bombeck committed 2026-07-07 19:14:34 +02:00
1 parent 2653647f97
commit afeb693562
14 files changed
+1723 -157

No files matched your search

+76 -26
View File
@@ -7838,48 +7838,48 @@
},
"validatedInEnglishNote": "Dieser Fragebogen ist in englischer Sprache validiert; die Fragen erscheinen im englischen Original.",
"who5Options": {
"5": "Die ganze Zeit",
"4": "Meistens",
"3": "Etwas mehr als die Hälfte der Zeit",
"2": "Etwas weniger als die Hälfte der Zeit",
"0": "Zu keinem Zeitpunkt",
"1": "Ab und zu",
"0": "Zu keinem Zeitpunkt"
"2": "Etwas weniger als die Hälfte der Zeit",
"3": "Etwas mehr als die Hälfte der Zeit",
"4": "Meistens",
"5": "Die ganze Zeit"
},
"sciOptions": {
"duration": {
"4": "0–15 min",
"3": "16–30 min",
"2": "31–45 min",
"0": "≥ 61 min",
"1": "46–60 min",
"0": "≥ 61 min"
"2": "31–45 min",
"3": "16–30 min",
"4": "0–15 min"
},
"nights": {
"4": "0–1",
"3": "2",
"2": "3",
"0": "5–7",
"1": "4",
"0": "5–7"
"2": "3",
"3": "2",
"4": "0–1"
},
"quality": {
"4": "Very good",
"3": "Good",
"2": "Average",
"0": "Very poor",
"1": "Poor",
"0": "Very poor"
"2": "Average",
"3": "Good",
"4": "Very good"
},
"impact": {
"4": "Not at all",
"3": "A little",
"2": "Somewhat",
"0": "Very much",
"1": "Much",
"0": "Very much"
"2": "Somewhat",
"3": "A little",
"4": "Not at all"
},
"problemDuration": {
"4": "I don't have a problem / < 1 month",
"3": "1–2 months",
"2": "3–6 months",
"0": "> 1 year",
"1": "7–12 months",
"0": "> 1 year"
"2": "3–6 months",
"3": "1–2 months",
"4": "I don't have a problem / < 1 month"
}
},
"stems": {
@@ -8052,7 +8052,8 @@
"openLabel": "{title} öffnen",
"attachmentBadge": "Nur Download",
"uploading": "Wird hochgeladen…",
"dismissFailed": "Fehlgeschlagenen Upload ausblenden"
"dismissFailed": "Fehlgeschlagenen Upload ausblenden",
"searchableBadge": "Inhalt durchsuchbar"
},
"error": {
"fileTooLarge": "Diese Datei ist größer als das Limit von {maxSize}.",
@@ -8137,6 +8138,55 @@
"description": "Wähle Dokumente aus deinem Bestand, die zu dieser Erkrankung gehören.",
"searchPlaceholder": "Nach Titel oder Dateiname suchen",
"empty": "Keine Dokumente gefunden."
},
"assist": {
"suggest": "Details vorschlagen",
"suggesting": "Wird gelesen…",
"reviewTitle": "KI-Vorschlag — vor dem Speichern prüfen",
"reviewHint": "Es wird nichts gespeichert, bis du es übernimmst. Vorschläge können falsch sein — prüfe sie.",
"suggestedTitle": "Vorgeschlagener Titel",
"suggestedKind": "Vorgeschlagener Typ",
"suggestedDate": "Vorgeschlagenes Datum",
"apply": "Übernehmen",
"useTitle": "Titel übernehmen",
"applied": "Übernommen",
"dismiss": "Verwerfen",
"empty": "Aus diesem Dokument konnte nichts gelesen werden.",
"unavailableBody": "Richte einen KI-Anbieter ein, um Titel, Typ und Datum vorzuschlagen.",
"unavailableLocalOcr": "Aktiviere lokale Texterkennung in den KI-Einstellungen, um Bilder auf diesem Gerät zu lesen.",
"unavailableAction": "KI-Einstellungen öffnen",
"errorGeneric": "Dokument konnte nicht gelesen werden. Bitte erneut versuchen.",
"errorOcr": "Aus diesem Bild konnte kein Text gelesen werden.",
"errorRateLimited": "Zu viele KI-Anfragen — bitte in ein paar Minuten erneut versuchen.",
"errorBudget": "Dein KI-Budget für heute ist aufgebraucht.",
"errorPdfNeedsVision": "Zum Scannen von PDFs wird ein Claude-Vision-Anbieter benötigt.",
"errorFileType": "Dieser Dateityp kann nicht gelesen werden.",
"errorLocalOcrDisabled": "Aktiviere zuerst die lokale Texterkennung in den KI-Einstellungen.",
"errorProvider": "Dafür ist kein KI-Anbieter eingerichtet.",
"errorTextImageOnly": "Lokale Texterkennung liest nur Bilder — nicht diese Datei."
},
"summary": {
"summarise": "Zusammenfassen",
"showText": "Erkannten Text anzeigen",
"summaryTitle": "Zusammenfassung",
"textTitle": "Erkannter Text",
"notSaved": "Nicht gespeichert · keine Diagnose. Nur einmalig für diese Ansicht angezeigt.",
"close": "Ausblenden"
},
"contentIndex": {
"searchHint": "Die Suche durchsucht jetzt auch den Inhalt deiner Dokumente (ganze Wörter).",
"indexPrompt": "Dokumentinhalte durchsuchbar machen.",
"searchable": "Inhalt durchsuchbar",
"notSearchable": "Inhalt noch nicht durchsuchbar",
"indexAction": "Für Suche indizieren",
"reindexAction": "Neu indizieren",
"indexing": "Wird indiziert…",
"indexed": "Für die Suche indiziert.",
"indexAll": "Alle für Suche indizieren",
"indexAllPending": "Wird gestartet…",
"indexAllQueued": "{count} Dokument(e) werden im Hintergrund indiziert.",
"indexAllNothing": "Alles ist bereits indiziert.",
"indexAllError": "Indizierung konnte nicht gestartet werden."
}
},
"selfDescription": {
+76 -26
View File
@@ -7838,48 +7838,48 @@
},
"validatedInEnglishNote": "This questionnaire is validated in English; the questions are shown in the original English wording.",
"who5Options": {
"5": "All of the time",
"4": "Most of the time",
"3": "More than half of the time",
"2": "Less than half of the time",
"0": "At no time",
"1": "Some of the time",
"0": "At no time"
"2": "Less than half of the time",
"3": "More than half of the time",
"4": "Most of the time",
"5": "All of the time"
},
"sciOptions": {
"duration": {
"4": "0–15 min",
"3": "16–30 min",
"2": "31–45 min",
"0": "≥ 61 min",
"1": "46–60 min",
"0": "≥ 61 min"
"2": "31–45 min",
"3": "16–30 min",
"4": "0–15 min"
},
"nights": {
"4": "0–1",
"3": "2",
"2": "3",
"0": "5–7",
"1": "4",
"0": "5–7"
"2": "3",
"3": "2",
"4": "0–1"
},
"quality": {
"4": "Very good",
"3": "Good",
"2": "Average",
"0": "Very poor",
"1": "Poor",
"0": "Very poor"
"2": "Average",
"3": "Good",
"4": "Very good"
},
"impact": {
"4": "Not at all",
"3": "A little",
"2": "Somewhat",
"0": "Very much",
"1": "Much",
"0": "Very much"
"2": "Somewhat",
"3": "A little",
"4": "Not at all"
},
"problemDuration": {
"4": "I don't have a problem / < 1 month",
"3": "1–2 months",
"2": "3–6 months",
"0": "> 1 year",
"1": "7–12 months",
"0": "> 1 year"
"2": "3–6 months",
"3": "1–2 months",
"4": "I don't have a problem / < 1 month"
}
},
"stems": {
@@ -8052,7 +8052,8 @@
"openLabel": "Open {title}",
"attachmentBadge": "Download only",
"uploading": "Uploading…",
"dismissFailed": "Dismiss failed upload"
"dismissFailed": "Dismiss failed upload",
"searchableBadge": "Contents searchable"
},
"error": {
"fileTooLarge": "This file is larger than the {maxSize} limit.",
@@ -8137,6 +8138,55 @@
"description": "Pick documents from your vault that belong to this condition.",
"searchPlaceholder": "Search by title or filename",
"empty": "No documents found."
},
"assist": {
"suggest": "Suggest details",
"suggesting": "Reading…",
"reviewTitle": "AI suggestion — review before saving",
"reviewHint": "Nothing is saved until you apply it. Suggestions can be wrong — check them.",
"suggestedTitle": "Suggested title",
"suggestedKind": "Suggested type",
"suggestedDate": "Suggested date",
"apply": "Apply",
"useTitle": "Use title",
"applied": "Applied",
"dismiss": "Dismiss",
"empty": "Nothing could be read from this document.",
"unavailableBody": "Set up an AI provider to suggest a title, type, and date.",
"unavailableLocalOcr": "Turn on local OCR in the AI settings to read images on this device.",
"unavailableAction": "Open AI settings",
"errorGeneric": "Couldn't read the document. Try again.",
"errorOcr": "Couldn't read text from this image.",
"errorRateLimited": "Too many AI requests — try again in a few minutes.",
"errorBudget": "Your AI budget for today is used up.",
"errorPdfNeedsVision": "Scanning PDFs needs a Claude vision provider.",
"errorFileType": "This file type can't be read.",
"errorLocalOcrDisabled": "Turn on local OCR in the AI settings first.",
"errorProvider": "No AI provider is set up for this.",
"errorTextImageOnly": "Local OCR reads images only — not this file."
},
"summary": {
"summarise": "Summarise",
"showText": "Show extracted text",
"summaryTitle": "Summary",
"textTitle": "Extracted text",
"notSaved": "Not saved · not a diagnosis. Shown once for this view only.",
"close": "Hide"
},
"contentIndex": {
"searchHint": "Search now looks inside your documents too (whole words).",
"indexPrompt": "Make document contents searchable.",
"searchable": "Contents searchable",
"notSearchable": "Contents not searchable yet",
"indexAction": "Index for search",
"reindexAction": "Re-index",
"indexing": "Indexing…",
"indexed": "Indexed for search.",
"indexAll": "Index all for search",
"indexAllPending": "Starting…",
"indexAllQueued": "Indexing {count} document(s) in the background.",
"indexAllNothing": "Everything is already indexed.",
"indexAllError": "Couldn't start indexing."
}
},
"selfDescription": {
+76 -26
View File
@@ -7838,48 +7838,48 @@
},
"validatedInEnglishNote": "Este cuestionario está validado en inglés; las preguntas se muestran en su versión original en inglés.",
"who5Options": {
"5": "Todo el tiempo",
"4": "La mayor parte del tiempo",
"3": "Más de la mitad del tiempo",
"2": "Menos de la mitad del tiempo",
"0": "Nunca",
"1": "De vez en cuando",
"0": "Nunca"
"2": "Menos de la mitad del tiempo",
"3": "Más de la mitad del tiempo",
"4": "La mayor parte del tiempo",
"5": "Todo el tiempo"
},
"sciOptions": {
"duration": {
"4": "0–15 min",
"3": "16–30 min",
"2": "31–45 min",
"0": "≥ 61 min",
"1": "46–60 min",
"0": "≥ 61 min"
"2": "31–45 min",
"3": "16–30 min",
"4": "0–15 min"
},
"nights": {
"4": "0–1",
"3": "2",
"2": "3",
"0": "5–7",
"1": "4",
"0": "5–7"
"2": "3",
"3": "2",
"4": "0–1"
},
"quality": {
"4": "Very good",
"3": "Good",
"2": "Average",
"0": "Very poor",
"1": "Poor",
"0": "Very poor"
"2": "Average",
"3": "Good",
"4": "Very good"
},
"impact": {
"4": "Not at all",
"3": "A little",
"2": "Somewhat",
"0": "Very much",
"1": "Much",
"0": "Very much"
"2": "Somewhat",
"3": "A little",
"4": "Not at all"
},
"problemDuration": {
"4": "I don't have a problem / < 1 month",
"3": "1–2 months",
"2": "3–6 months",
"0": "> 1 year",
"1": "7–12 months",
"0": "> 1 year"
"2": "3–6 months",
"3": "1–2 months",
"4": "I don't have a problem / < 1 month"
}
},
"stems": {
@@ -8052,7 +8052,8 @@
"openLabel": "Abrir {title}",
"attachmentBadge": "Solo descarga",
"uploading": "Subiendo…",
"dismissFailed": "Descartar subida fallida"
"dismissFailed": "Descartar subida fallida",
"searchableBadge": "Contenido buscable"
},
"error": {
"fileTooLarge": "Este archivo supera el límite de {maxSize}.",
@@ -8137,6 +8138,55 @@
"description": "Elige documentos de tu archivo que pertenezcan a esta afección.",
"searchPlaceholder": "Buscar por título o nombre de archivo",
"empty": "No se encontraron documentos."
},
"assist": {
"suggest": "Sugerir detalles",
"suggesting": "Leyendo…",
"reviewTitle": "Sugerencia de IA: revísala antes de guardar",
"reviewHint": "No se guarda nada hasta que la apliques. Las sugerencias pueden fallar: compruébalas.",
"suggestedTitle": "Título sugerido",
"suggestedKind": "Tipo sugerido",
"suggestedDate": "Fecha sugerida",
"apply": "Aplicar",
"useTitle": "Usar título",
"applied": "Aplicado",
"dismiss": "Descartar",
"empty": "No se pudo leer nada de este documento.",
"unavailableBody": "Configura un proveedor de IA para sugerir título, tipo y fecha.",
"unavailableLocalOcr": "Activa el OCR local en los ajustes de IA para leer imágenes en este dispositivo.",
"unavailableAction": "Abrir ajustes de IA",
"errorGeneric": "No se pudo leer el documento. Inténtalo de nuevo.",
"errorOcr": "No se pudo leer texto de esta imagen.",
"errorRateLimited": "Demasiadas solicitudes de IA: inténtalo en unos minutos.",
"errorBudget": "Tu presupuesto de IA de hoy se ha agotado.",
"errorPdfNeedsVision": "Escanear PDF requiere un proveedor de visión Claude.",
"errorFileType": "Este tipo de archivo no se puede leer.",
"errorLocalOcrDisabled": "Activa primero el OCR local en los ajustes de IA.",
"errorProvider": "No hay ningún proveedor de IA configurado para esto.",
"errorTextImageOnly": "El OCR local solo lee imágenes, no este archivo."
},
"summary": {
"summarise": "Resumir",
"showText": "Mostrar texto extraído",
"summaryTitle": "Resumen",
"textTitle": "Texto extraído",
"notSaved": "No se guarda · no es un diagnóstico. Se muestra solo una vez en esta vista.",
"close": "Ocultar"
},
"contentIndex": {
"searchHint": "La búsqueda ahora también mira dentro de tus documentos (palabras completas).",
"indexPrompt": "Haz que el contenido de los documentos sea buscable.",
"searchable": "Contenido buscable",
"notSearchable": "Contenido aún no buscable",
"indexAction": "Indexar para búsqueda",
"reindexAction": "Reindexar",
"indexing": "Indexando…",
"indexed": "Indexado para búsqueda.",
"indexAll": "Indexar todo para búsqueda",
"indexAllPending": "Iniciando…",
"indexAllQueued": "Indexando {count} documento(s) en segundo plano.",
"indexAllNothing": "Todo ya está indexado.",
"indexAllError": "No se pudo iniciar la indexación."
}
},
"selfDescription": {
+76 -26
View File
@@ -7838,48 +7838,48 @@
},
"validatedInEnglishNote": "Ce questionnaire est validé en anglais ; les questions sont affichées dans leur version originale anglaise.",
"who5Options": {
"5": "Tout le temps",
"4": "La plupart du temps",
"3": "Plus de la moitié du temps",
"2": "Moins de la moitié du temps",
"0": "Jamais",
"1": "De temps en temps",
"0": "Jamais"
"2": "Moins de la moitié du temps",
"3": "Plus de la moitié du temps",
"4": "La plupart du temps",
"5": "Tout le temps"
},
"sciOptions": {
"duration": {
"4": "0–15 min",
"3": "16–30 min",
"2": "31–45 min",
"0": "≥ 61 min",
"1": "46–60 min",
"0": "≥ 61 min"
"2": "31–45 min",
"3": "16–30 min",
"4": "0–15 min"
},
"nights": {
"4": "0–1",
"3": "2",
"2": "3",
"0": "5–7",
"1": "4",
"0": "5–7"
"2": "3",
"3": "2",
"4": "0–1"
},
"quality": {
"4": "Very good",
"3": "Good",
"2": "Average",
"0": "Very poor",
"1": "Poor",
"0": "Very poor"
"2": "Average",
"3": "Good",
"4": "Very good"
},
"impact": {
"4": "Not at all",
"3": "A little",
"2": "Somewhat",
"0": "Very much",
"1": "Much",
"0": "Very much"
"2": "Somewhat",
"3": "A little",
"4": "Not at all"
},
"problemDuration": {
"4": "I don't have a problem / < 1 month",
"3": "1–2 months",
"2": "3–6 months",
"0": "> 1 year",
"1": "7–12 months",
"0": "> 1 year"
"2": "3–6 months",
"3": "1–2 months",
"4": "I don't have a problem / < 1 month"
}
},
"stems": {
@@ -8052,7 +8052,8 @@
"openLabel": "Ouvrir {title}",
"attachmentBadge": "Téléchargement uniquement",
"uploading": "Téléversement…",
"dismissFailed": "Ignorer le téléversement échoué"
"dismissFailed": "Ignorer le téléversement échoué",
"searchableBadge": "Contenu consultable"
},
"error": {
"fileTooLarge": "Ce fichier dépasse la limite de {maxSize}.",
@@ -8137,6 +8138,55 @@
"description": "Choisissez dans vos documents ceux qui concernent cette affection.",
"searchPlaceholder": "Rechercher par titre ou nom de fichier",
"empty": "Aucun document trouvé."
},
"assist": {
"suggest": "Suggérer des détails",
"suggesting": "Lecture…",
"reviewTitle": "Suggestion IA — à vérifier avant d'enregistrer",
"reviewHint": "Rien n'est enregistré tant que vous ne l'appliquez pas. Les suggestions peuvent être erronées — vérifiez-les.",
"suggestedTitle": "Titre suggéré",
"suggestedKind": "Type suggéré",
"suggestedDate": "Date suggérée",
"apply": "Appliquer",
"useTitle": "Utiliser le titre",
"applied": "Appliqué",
"dismiss": "Ignorer",
"empty": "Impossible de lire ce document.",
"unavailableBody": "Configurez un fournisseur d'IA pour suggérer un titre, un type et une date.",
"unavailableLocalOcr": "Activez l'OCR local dans les paramètres d'IA pour lire les images sur cet appareil.",
"unavailableAction": "Ouvrir les paramètres d'IA",
"errorGeneric": "Impossible de lire le document. Réessayez.",
"errorOcr": "Impossible de lire le texte de cette image.",
"errorRateLimited": "Trop de requêtes IA — réessayez dans quelques minutes.",
"errorBudget": "Votre budget IA du jour est épuisé.",
"errorPdfNeedsVision": "La lecture des PDF nécessite un fournisseur de vision Claude.",
"errorFileType": "Ce type de fichier ne peut pas être lu.",
"errorLocalOcrDisabled": "Activez d'abord l'OCR local dans les paramètres d'IA.",
"errorProvider": "Aucun fournisseur d'IA n'est configuré pour cela.",
"errorTextImageOnly": "L'OCR local ne lit que les images, pas ce fichier."
},
"summary": {
"summarise": "Résumer",
"showText": "Afficher le texte extrait",
"summaryTitle": "Résumé",
"textTitle": "Texte extrait",
"notSaved": "Non enregistré · pas un diagnostic. Affiché une seule fois pour cette vue.",
"close": "Masquer"
},
"contentIndex": {
"searchHint": "La recherche explore désormais aussi le contenu de vos documents (mots entiers).",
"indexPrompt": "Rendre le contenu des documents consultable.",
"searchable": "Contenu consultable",
"notSearchable": "Contenu pas encore consultable",
"indexAction": "Indexer pour la recherche",
"reindexAction": "Réindexer",
"indexing": "Indexation…",
"indexed": "Indexé pour la recherche.",
"indexAll": "Tout indexer pour la recherche",
"indexAllPending": "Démarrage…",
"indexAllQueued": "Indexation de {count} document(s) en arrière-plan.",
"indexAllNothing": "Tout est déjà indexé.",
"indexAllError": "Impossible de démarrer l'indexation."
}
},
"selfDescription": {
+76 -26
View File
@@ -7838,48 +7838,48 @@
},
"validatedInEnglishNote": "Questo questionario è validato in inglese; le domande sono mostrate nella versione originale inglese.",
"who5Options": {
"5": "Sempre",
"4": "La maggior parte del tempo",
"3": "Più della metà del tempo",
"2": "Meno della metà del tempo",
"0": "Mai",
"1": "A volte",
"0": "Mai"
"2": "Meno della metà del tempo",
"3": "Più della metà del tempo",
"4": "La maggior parte del tempo",
"5": "Sempre"
},
"sciOptions": {
"duration": {
"4": "0–15 min",
"3": "16–30 min",
"2": "31–45 min",
"0": "≥ 61 min",
"1": "46–60 min",
"0": "≥ 61 min"
"2": "31–45 min",
"3": "16–30 min",
"4": "0–15 min"
},
"nights": {
"4": "0–1",
"3": "2",
"2": "3",
"0": "5–7",
"1": "4",
"0": "5–7"
"2": "3",
"3": "2",
"4": "0–1"
},
"quality": {
"4": "Very good",
"3": "Good",
"2": "Average",
"0": "Very poor",
"1": "Poor",
"0": "Very poor"
"2": "Average",
"3": "Good",
"4": "Very good"
},
"impact": {
"4": "Not at all",
"3": "A little",
"2": "Somewhat",
"0": "Very much",
"1": "Much",
"0": "Very much"
"2": "Somewhat",
"3": "A little",
"4": "Not at all"
},
"problemDuration": {
"4": "I don't have a problem / < 1 month",
"3": "1–2 months",
"2": "3–6 months",
"0": "> 1 year",
"1": "7–12 months",
"0": "> 1 year"
"2": "3–6 months",
"3": "1–2 months",
"4": "I don't have a problem / < 1 month"
}
},
"stems": {
@@ -8052,7 +8052,8 @@
"openLabel": "Apri {title}",
"attachmentBadge": "Solo download",
"uploading": "Caricamento…",
"dismissFailed": "Ignora caricamento non riuscito"
"dismissFailed": "Ignora caricamento non riuscito",
"searchableBadge": "Contenuto ricercabile"
},
"error": {
"fileTooLarge": "Questo file supera il limite di {maxSize}.",
@@ -8137,6 +8138,55 @@
"description": "Scegli i documenti del tuo archivio che riguardano questa patologia.",
"searchPlaceholder": "Cerca per titolo o nome del file",
"empty": "Nessun documento trovato."
},
"assist": {
"suggest": "Suggerisci dettagli",
"suggesting": "Lettura…",
"reviewTitle": "Suggerimento IA — verifica prima di salvare",
"reviewHint": "Nulla viene salvato finché non lo applichi. I suggerimenti possono essere errati: controllali.",
"suggestedTitle": "Titolo suggerito",
"suggestedKind": "Tipo suggerito",
"suggestedDate": "Data suggerita",
"apply": "Applica",
"useTitle": "Usa titolo",
"applied": "Applicato",
"dismiss": "Ignora",
"empty": "Impossibile leggere questo documento.",
"unavailableBody": "Configura un provider IA per suggerire titolo, tipo e data.",
"unavailableLocalOcr": "Attiva l'OCR locale nelle impostazioni IA per leggere le immagini su questo dispositivo.",
"unavailableAction": "Apri impostazioni IA",
"errorGeneric": "Impossibile leggere il documento. Riprova.",
"errorOcr": "Impossibile leggere il testo da questa immagine.",
"errorRateLimited": "Troppe richieste IA — riprova tra qualche minuto.",
"errorBudget": "Il tuo budget IA di oggi è esaurito.",
"errorPdfNeedsVision": "La scansione dei PDF richiede un provider di visione Claude.",
"errorFileType": "Questo tipo di file non può essere letto.",
"errorLocalOcrDisabled": "Attiva prima l'OCR locale nelle impostazioni IA.",
"errorProvider": "Nessun provider IA è configurato per questo.",
"errorTextImageOnly": "L'OCR locale legge solo immagini, non questo file."
},
"summary": {
"summarise": "Riassumi",
"showText": "Mostra testo estratto",
"summaryTitle": "Riepilogo",
"textTitle": "Testo estratto",
"notSaved": "Non salvato · non è una diagnosi. Mostrato una sola volta per questa vista.",
"close": "Nascondi"
},
"contentIndex": {
"searchHint": "La ricerca ora esamina anche il contenuto dei tuoi documenti (parole intere).",
"indexPrompt": "Rendi ricercabile il contenuto dei documenti.",
"searchable": "Contenuto ricercabile",
"notSearchable": "Contenuto non ancora ricercabile",
"indexAction": "Indicizza per la ricerca",
"reindexAction": "Reindicizza",
"indexing": "Indicizzazione…",
"indexed": "Indicizzato per la ricerca.",
"indexAll": "Indicizza tutto per la ricerca",
"indexAllPending": "Avvio…",
"indexAllQueued": "Indicizzazione di {count} documento/i in background.",
"indexAllNothing": "Tutto è già indicizzato.",
"indexAllError": "Impossibile avviare l'indicizzazione."
}
},
"selfDescription": {
+76 -26
View File
@@ -7838,48 +7838,48 @@
},
"validatedInEnglishNote": "Ten kwestionariusz jest zwalidowany w języku angielskim; pytania są wyświetlane w angielskim oryginale.",
"who5Options": {
"5": "Cały czas",
"4": "Prawie cały czas",
"3": "Więcej niż połowę czasu",
"2": "Mniej niż połowę czasu",
"0": "Nigdy",
"1": "Od czasu do czasu",
"0": "Nigdy"
"2": "Mniej niż połowę czasu",
"3": "Więcej niż połowę czasu",
"4": "Prawie cały czas",
"5": "Cały czas"
},
"sciOptions": {
"duration": {
"4": "0–15 min",
"3": "16–30 min",
"2": "31–45 min",
"0": "≥ 61 min",
"1": "46–60 min",
"0": "≥ 61 min"
"2": "31–45 min",
"3": "16–30 min",
"4": "0–15 min"
},
"nights": {
"4": "0–1",
"3": "2",
"2": "3",
"0": "5–7",
"1": "4",
"0": "5–7"
"2": "3",
"3": "2",
"4": "0–1"
},
"quality": {
"4": "Very good",
"3": "Good",
"2": "Average",
"0": "Very poor",
"1": "Poor",
"0": "Very poor"
"2": "Average",
"3": "Good",
"4": "Very good"
},
"impact": {
"4": "Not at all",
"3": "A little",
"2": "Somewhat",
"0": "Very much",
"1": "Much",
"0": "Very much"
"2": "Somewhat",
"3": "A little",
"4": "Not at all"
},
"problemDuration": {
"4": "I don't have a problem / < 1 month",
"3": "1–2 months",
"2": "3–6 months",
"0": "> 1 year",
"1": "7–12 months",
"0": "> 1 year"
"2": "3–6 months",
"3": "1–2 months",
"4": "I don't have a problem / < 1 month"
}
},
"stems": {
@@ -8052,7 +8052,8 @@
"openLabel": "Otwórz {title}",
"attachmentBadge": "Tylko do pobrania",
"uploading": "Przesyłanie…",
"dismissFailed": "Odrzuć nieudane przesyłanie"
"dismissFailed": "Odrzuć nieudane przesyłanie",
"searchableBadge": "Treść przeszukiwalna"
},
"error": {
"fileTooLarge": "Ten plik przekracza limit {maxSize}.",
@@ -8137,6 +8138,55 @@
"description": "Wybierz dokumenty z archiwum, które dotyczą tego schorzenia.",
"searchPlaceholder": "Szukaj po tytule lub nazwie pliku",
"empty": "Nie znaleziono dokumentów."
},
"assist": {
"suggest": "Zaproponuj szczegóły",
"suggesting": "Odczytywanie…",
"reviewTitle": "Sugestia AI — sprawdź przed zapisaniem",
"reviewHint": "Nic nie zostanie zapisane, dopóki tego nie zastosujesz. Sugestie mogą być błędne — sprawdź je.",
"suggestedTitle": "Sugerowany tytuł",
"suggestedKind": "Sugerowany typ",
"suggestedDate": "Sugerowana data",
"apply": "Zastosuj",
"useTitle": "Użyj tytułu",
"applied": "Zastosowano",
"dismiss": "Odrzuć",
"empty": "Nie udało się nic odczytać z tego dokumentu.",
"unavailableBody": "Skonfiguruj dostawcę AI, aby zaproponować tytuł, typ i datę.",
"unavailableLocalOcr": "Włącz lokalne OCR w ustawieniach AI, aby odczytywać obrazy na tym urządzeniu.",
"unavailableAction": "Otwórz ustawienia AI",
"errorGeneric": "Nie udało się odczytać dokumentu. Spróbuj ponownie.",
"errorOcr": "Nie udało się odczytać tekstu z tego obrazu.",
"errorRateLimited": "Zbyt wiele żądań AI — spróbuj za kilka minut.",
"errorBudget": "Twój dzisiejszy budżet AI został wyczerpany.",
"errorPdfNeedsVision": "Skanowanie plików PDF wymaga dostawcy wizji Claude.",
"errorFileType": "Tego typu pliku nie można odczytać.",
"errorLocalOcrDisabled": "Najpierw włącz lokalne OCR w ustawieniach AI.",
"errorProvider": "Nie skonfigurowano dostawcy AI do tego celu.",
"errorTextImageOnly": "Lokalne OCR odczytuje tylko obrazy, nie ten plik."
},
"summary": {
"summarise": "Podsumuj",
"showText": "Pokaż wyodrębniony tekst",
"summaryTitle": "Podsumowanie",
"textTitle": "Wyodrębniony tekst",
"notSaved": "Nie zapisano · to nie diagnoza. Wyświetlane tylko raz w tym widoku.",
"close": "Ukryj"
},
"contentIndex": {
"searchHint": "Wyszukiwanie przeszukuje teraz także treść Twoich dokumentów (całe słowa).",
"indexPrompt": "Udostępnij treść dokumentów do wyszukiwania.",
"searchable": "Treść przeszukiwalna",
"notSearchable": "Treść jeszcze nieprzeszukiwalna",
"indexAction": "Zindeksuj do wyszukiwania",
"reindexAction": "Zindeksuj ponownie",
"indexing": "Indeksowanie…",
"indexed": "Zindeksowano do wyszukiwania.",
"indexAll": "Zindeksuj wszystko do wyszukiwania",
"indexAllPending": "Uruchamianie…",
"indexAllQueued": "Indeksowanie {count} dokumentu/ów w tle.",
"indexAllNothing": "Wszystko jest już zindeksowane.",
"indexAllError": "Nie udało się uruchomić indeksowania."
}
},
"selfDescription": {
@@ -0,0 +1,199 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { DocumentSuggestionDto } from "@/lib/validations/inbound-documents";
import {
AiUnavailableHint,
AssistSuggestionReview,
ContentIndexStatus,
DocumentSummaryPanel,
} from "../document-ai-panels";
/**
* The AI panels' render contract:
* - the unavailable state is a CALM pointer to the AI settings, never an
* error, and adapts its copy to the actionable reason;
* - the suggestion card carries the review-first affordance ("review before
* saving") and applies nothing on its own — every value has its own tap;
* - the summary panel ALWAYS carries the "not saved · not a diagnosis" note,
* even while loading, so the session-only contract is never off-screen.
*/
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
const noop = () => {};
const suggestion = (
overrides: Partial<DocumentSuggestionDto> = {},
): DocumentSuggestionDto => ({
title: "Blood panel — March 2026",
kind: "LAB_RESULT",
documentDate: "2026-03-01",
...overrides,
});
describe("<AiUnavailableHint>", () => {
it("points calmly at the AI settings when no provider is configured", () => {
const html = render(<AiUnavailableHint reason="no-provider" />);
expect(html).toContain('data-slot="assist-unavailable"');
expect(html).toContain('href="/settings/ai"');
expect(html).toContain("Set up an AI provider");
// Never an error affordance.
expect(html).not.toContain('role="alert"');
});
it("nudges toward local OCR when a text-only provider needs it", () => {
const html = render(<AiUnavailableHint reason="enable-local-ocr" />);
expect(html).toContain("local OCR");
expect(html).toContain('href="/settings/ai"');
});
});
describe("<AssistSuggestionReview>", () => {
it("presents drafts behind an explicit review-before-saving affordance", () => {
const html = render(
<AssistSuggestionReview
suggestion={suggestion()}
kindLabel="Lab result"
dateLabel="1 Mar 2026"
applied={{ title: false, kind: false, date: false }}
onUseTitle={noop}
onUseKind={noop}
onUseDate={noop}
onDismiss={noop}
/>,
);
expect(html).toContain('data-slot="assist-suggestion-review"');
expect(html).toContain("AI suggestion — review before saving");
expect(html).toContain("Nothing is saved until you apply it");
// The suggested values are shown as reviewable drafts.
expect(html).toContain("Blood panel — March 2026");
expect(html).toContain("Lab result");
expect(html).toContain("1 Mar 2026");
// Each field has an explicit apply control.
expect(html).toContain("Use title");
expect(html).toContain("Apply");
});
it("marks an applied field instead of re-offering it", () => {
const html = render(
<AssistSuggestionReview
suggestion={suggestion()}
kindLabel="Lab result"
dateLabel="1 Mar 2026"
applied={{ title: true, kind: false, date: false }}
onUseTitle={noop}
onUseKind={noop}
onUseDate={noop}
onDismiss={noop}
/>,
);
expect(html).toContain("Applied");
});
it("shows an empty state when nothing could be read", () => {
const html = render(
<AssistSuggestionReview
suggestion={suggestion({ title: null, kind: null, documentDate: null })}
kindLabel={null}
dateLabel={null}
applied={{ title: false, kind: false, date: false }}
onUseTitle={noop}
onUseKind={noop}
onUseDate={noop}
onDismiss={noop}
/>,
);
expect(html).toContain("Nothing could be read from this document");
});
});
describe("<DocumentSummaryPanel>", () => {
it("keeps the not-saved / not-a-diagnosis note on screen while loading", () => {
const html = render(
<DocumentSummaryPanel
output="summary"
result={null}
isPending
errorKey={null}
onClose={noop}
/>,
);
expect(html).toContain('data-slot="document-summary-panel"');
expect(html).toContain('data-slot="document-summary-loading"');
expect(html).toContain("Not saved");
expect(html).toContain("not a diagnosis");
});
it("renders the summary body and the persistent note", () => {
const html = render(
<DocumentSummaryPanel
output="summary"
result={{ summary: "A lipid panel dated 1 March 2026." }}
isPending={false}
errorKey={null}
onClose={noop}
/>,
);
expect(html).toContain("A lipid panel dated 1 March 2026.");
expect(html).toContain("Not saved");
});
it("renders extracted text in a monospace block", () => {
const html = render(
<DocumentSummaryPanel
output="text"
result={{ text: "LDL 3.1 mmol/L" }}
isPending={false}
errorKey={null}
onClose={noop}
/>,
);
expect(html).toContain("LDL 3.1 mmol/L");
expect(html).toContain("Extracted text");
expect(html).toContain("font-mono");
});
it("surfaces an error via role=alert but keeps the note", () => {
const html = render(
<DocumentSummaryPanel
output="summary"
result={null}
isPending={false}
errorKey="documents.assist.errorGeneric"
onClose={noop}
/>,
);
expect(html).toContain('role="alert"');
expect(html).toContain("read the document");
expect(html).toContain("Not saved");
});
});
describe("<ContentIndexStatus>", () => {
it("offers indexing when the document is not yet searchable", () => {
const html = render(
<ContentIndexStatus
hasContentIndex={false}
isPending={false}
onIndex={noop}
/>,
);
expect(html).toContain('data-slot="content-index-status"');
expect(html).toContain("Contents not searchable yet");
expect(html).toContain("Index for search");
});
it("offers a re-index when the document is already searchable", () => {
const html = render(
<ContentIndexStatus hasContentIndex isPending={false} onIndex={noop} />,
);
expect(html).toContain("Contents searchable");
expect(html).toContain("Re-index");
});
});
@@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { DocumentSuggestionDto } from "@/lib/validations/inbound-documents";
import { DocumentAiSection } from "../document-ai-section";
/**
* The detail sheet's AI area is availability-gated: with a provider it shows the
* assist / summary toolbar; without one it shows ONLY the calm settings pointer
* (never an action the endpoint would 422). The summary panel, when open, keeps
* the session-only note visible.
*/
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
const noop = () => {};
const suggestion: DocumentSuggestionDto = {
title: "MRI knee report",
kind: "IMAGING",
documentDate: "2026-02-14",
};
function base(
overrides: Partial<React.ComponentProps<typeof DocumentAiSection>> = {},
) {
const props: React.ComponentProps<typeof DocumentAiSection> = {
aiEnabled: true,
indexEnabled: true,
unavailableReason: null,
actionsDisabled: false,
onSuggest: noop,
suggestPending: false,
suggestErrorKey: null,
onSummarise: noop,
summaryPending: false,
suggestion: null,
suggestionKindLabel: null,
suggestionDateLabel: null,
appliedFields: { title: false, kind: false, date: false },
onUseTitle: noop,
onUseKind: noop,
onUseDate: noop,
onDismissSuggestion: noop,
summaryOutput: null,
summaryResult: null,
summaryErrorKey: null,
onCloseSummary: noop,
hasContentIndex: false,
indexPending: false,
onIndex: noop,
...overrides,
};
return <DocumentAiSection {...props} />;
}
describe("<DocumentAiSection>", () => {
it("offers the assist + summary toolbar when a provider is available", () => {
const html = render(base({ aiEnabled: true }));
expect(html).toContain('data-slot="assist-suggest"');
expect(html).toContain("Suggest details");
expect(html).toContain("Summarise");
expect(html).toContain("Show extracted text");
// No unavailable pointer when the affordance is offered.
expect(html).not.toContain('data-slot="assist-unavailable"');
});
it("shows ONLY the calm settings pointer when no provider is available", () => {
const html = render(
base({ aiEnabled: false, unavailableReason: "no-provider" }),
);
expect(html).toContain('data-slot="assist-unavailable"');
expect(html).toContain('href="/settings/ai"');
// The assist / index actions must be absent — never a 422-guaranteed tap.
expect(html).not.toContain('data-slot="assist-suggest"');
expect(html).not.toContain('data-slot="content-index-status"');
});
it("renders the reviewed suggestion drafts when present", () => {
const html = render(
base({
suggestion,
suggestionKindLabel: "Imaging",
suggestionDateLabel: "14 Feb 2026",
}),
);
expect(html).toContain('data-slot="assist-suggestion-review"');
expect(html).toContain("MRI knee report");
expect(html).toContain("review before saving");
});
it("renders the transient summary panel with the session-only note", () => {
const html = render(
base({
summaryOutput: "summary",
summaryResult: { summary: "An MRI report of the left knee." },
}),
);
expect(html).toContain('data-slot="document-summary-panel"');
expect(html).toContain("An MRI report of the left knee.");
expect(html).toContain("Not saved");
});
it("hides the content-index row when indexing is unavailable", () => {
const html = render(base({ aiEnabled: true, indexEnabled: false }));
expect(html).toContain('data-slot="assist-suggest"');
expect(html).not.toContain('data-slot="content-index-status"');
});
});
@@ -0,0 +1,328 @@
"use client";
/**
* v1.27.22 (Document vault P2) — the presentational AI panels for the document
* detail sheet. Pure (props in, markup out) so the review-first contract and
* the session-only note are pinned by static-render tests.
*
* - `AiUnavailableHint` — the calm "set up an AI provider" pointer shown in
* place of the AI toolbar when no provider is configured (never an error).
* - `AssistSuggestionReview` — the reviewed DRAFT card: suggested title / type
* / date, each applied only by an explicit tap. Nothing is written until the
* user applies it (and the title lands in the editable field, not on disk).
* - `DocumentSummaryPanel` — the transient summary / extracted-text panel with
* the persistent "not saved · not a diagnosis" note.
* - `ContentIndexStatus` — the per-document searchable state + index action.
*/
import { Check, FileText, ScanSearch, Sparkles, X } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type {
DocumentSuggestionDto,
DocumentSummaryMode,
} from "@/lib/validations/inbound-documents";
import type { DocumentDescribeResult } from "./use-document-assist";
/** Calm pointer to the AI settings, shown when assist is unavailable. */
export function AiUnavailableHint({
reason,
}: {
reason: "no-provider" | "enable-local-ocr" | null;
}) {
const { t } = useTranslations();
const body =
reason === "enable-local-ocr"
? t("documents.assist.unavailableLocalOcr")
: t("documents.assist.unavailableBody");
return (
<div
data-slot="assist-unavailable"
className="border-border text-muted-foreground flex items-start gap-2 rounded-lg border border-dashed px-3 py-2.5 text-xs"
>
<Sparkles className="mt-0.5 size-3.5 shrink-0" aria-hidden />
<p className="min-w-0">
{body}{" "}
<Link
href="/settings/ai"
data-slot="assist-settings-link"
className="text-primary font-medium underline-offset-4 hover:underline"
>
{t("documents.assist.unavailableAction")}
</Link>
</p>
</div>
);
}
/** One reviewed draft row: a suggested value + an explicit apply control. */
function ReviewRow({
label,
value,
applied,
onApply,
applyLabel,
}: {
label: string;
value: string;
applied: boolean;
onApply: () => void;
applyLabel: string;
}) {
const { t } = useTranslations();
return (
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<p className="text-muted-foreground text-xs">{label}</p>
<p className="truncate text-sm font-medium">{value}</p>
</div>
{applied ? (
<span className="text-muted-foreground inline-flex items-center gap-1 text-xs">
<Check className="size-3.5" aria-hidden />
{t("documents.assist.applied")}
</span>
) : (
<Button
type="button"
variant="outline"
size="sm"
className="h-7 shrink-0 px-2.5 text-xs"
onClick={onApply}
>
{applyLabel}
</Button>
)}
</div>
);
}
/**
* The review-first suggestion card. Suggestions are DRAFTS: applying the title
* seeds the editable title field (the user still saves it); applying the type
* or date commits that single field through the existing edit-on-commit path —
* always an explicit tap, never automatic.
*/
export function AssistSuggestionReview({
suggestion,
kindLabel,
dateLabel,
applied,
onUseTitle,
onUseKind,
onUseDate,
onDismiss,
}: {
suggestion: DocumentSuggestionDto;
/** Translated label for the suggested kind, or null when none was read. */
kindLabel: string | null;
/** Formatted suggested date, or null when none was read. */
dateLabel: string | null;
applied: { title: boolean; kind: boolean; date: boolean };
onUseTitle: () => void;
onUseKind: () => void;
onUseDate: () => void;
onDismiss: () => void;
}) {
const { t } = useTranslations();
const hasAny =
suggestion.title !== null ||
suggestion.kind !== null ||
suggestion.documentDate !== null;
return (
<div
data-slot="assist-suggestion-review"
className="border-primary/30 bg-primary/5 space-y-3 rounded-lg border p-3"
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2">
<Sparkles
className="text-primary mt-0.5 size-4 shrink-0"
aria-hidden
/>
<div>
<p className="text-sm font-medium">
{t("documents.assist.reviewTitle")}
</p>
<p className="text-muted-foreground text-xs">
{t("documents.assist.reviewHint")}
</p>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 shrink-0"
onClick={onDismiss}
aria-label={t("documents.assist.dismiss")}
>
<X className="size-4" aria-hidden />
</Button>
</div>
{hasAny ? (
<div className="space-y-2.5">
{suggestion.title !== null ? (
<ReviewRow
label={t("documents.assist.suggestedTitle")}
value={suggestion.title}
applied={applied.title}
onApply={onUseTitle}
applyLabel={t("documents.assist.useTitle")}
/>
) : null}
{suggestion.kind !== null && kindLabel !== null ? (
<ReviewRow
label={t("documents.assist.suggestedKind")}
value={kindLabel}
applied={applied.kind}
onApply={onUseKind}
applyLabel={t("documents.assist.apply")}
/>
) : null}
{suggestion.documentDate !== null && dateLabel !== null ? (
<ReviewRow
label={t("documents.assist.suggestedDate")}
value={dateLabel}
applied={applied.date}
onApply={onUseDate}
applyLabel={t("documents.assist.apply")}
/>
) : null}
</div>
) : (
<p className="text-muted-foreground text-sm">
{t("documents.assist.empty")}
</p>
)}
</div>
);
}
/**
* The transient summary / extracted-text panel. The result is shown once and
* never persisted; the "not saved · not a diagnosis" note is always present.
*/
export function DocumentSummaryPanel({
output,
result,
isPending,
errorKey,
onClose,
}: {
output: DocumentSummaryMode;
result: DocumentDescribeResult | null;
isPending: boolean;
errorKey: string | null;
onClose: () => void;
}) {
const { t } = useTranslations();
const heading =
output === "text"
? t("documents.summary.textTitle")
: t("documents.summary.summaryTitle");
const body =
result === null ? null : "summary" in result ? result.summary : result.text;
return (
<div
data-slot="document-summary-panel"
className="border-border bg-muted/40 space-y-2 rounded-lg border p-3"
>
<div className="flex items-center justify-between gap-2">
<p className="inline-flex items-center gap-1.5 text-sm font-medium">
<FileText className="text-muted-foreground size-4" aria-hidden />
{heading}
</p>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 shrink-0"
onClick={onClose}
aria-label={t("documents.summary.close")}
>
<X className="size-4" aria-hidden />
</Button>
</div>
{isPending ? (
<div className="space-y-2" data-slot="document-summary-loading">
<Skeleton className="h-3.5 w-full" />
<Skeleton className="h-3.5 w-11/12" />
<Skeleton className="h-3.5 w-3/4" />
</div>
) : errorKey ? (
<p role="alert" className="text-destructive text-sm">
{t(errorKey)}
</p>
) : body !== null ? (
<p
className={cn(
"text-foreground text-sm",
output === "text" && "font-mono text-xs whitespace-pre-wrap",
)}
>
{body}
</p>
) : null}
<p className="text-muted-foreground border-border/60 border-t pt-2 text-xs">
{t("documents.summary.notSaved")}
</p>
</div>
);
}
/**
* The per-document content-search state: whether the body is searchable, and an
* explicit index / re-index action. Shown only when indexing is available.
*/
export function ContentIndexStatus({
hasContentIndex,
isPending,
onIndex,
}: {
hasContentIndex: boolean;
isPending: boolean;
onIndex: () => void;
}) {
const { t } = useTranslations();
return (
<div
data-slot="content-index-status"
className="flex items-center justify-between gap-2"
>
<p
className={cn(
"inline-flex items-center gap-1.5 text-xs",
hasContentIndex ? "text-muted-foreground" : "text-muted-foreground",
)}
>
<ScanSearch className="size-3.5 shrink-0" aria-hidden />
{hasContentIndex
? t("documents.contentIndex.searchable")
: t("documents.contentIndex.notSearchable")}
</p>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 shrink-0 px-2.5 text-xs"
onClick={onIndex}
disabled={isPending}
>
{isPending
? t("documents.contentIndex.indexing")
: hasContentIndex
? t("documents.contentIndex.reindexAction")
: t("documents.contentIndex.indexAction")}
</Button>
</div>
);
}
@@ -0,0 +1,168 @@
"use client";
/**
* v1.27.22 (Document vault P2) — the AI area of the document detail sheet,
* lifted out of the sheet so the availability gate (toolbar vs. calm pointer)
* and the review-first wiring render without the sheet's dialog portal and are
* pinned by static-render tests.
*
* Presentational: the sheet owns the mutations + capability probe and passes
* their state + handlers down. When `aiEnabled` is false the whole area is the
* calm "set up an AI provider" pointer — never an error, and the manual form
* below stays fully usable.
*/
import { FileText, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import type {
DocumentSuggestionDto,
DocumentSummaryMode,
} from "@/lib/validations/inbound-documents";
import {
AiUnavailableHint,
AssistSuggestionReview,
ContentIndexStatus,
DocumentSummaryPanel,
} from "./document-ai-panels";
import type { DocumentDescribeResult } from "./use-document-assist";
export function DocumentAiSection({
aiEnabled,
indexEnabled,
unavailableReason,
actionsDisabled,
onSuggest,
suggestPending,
suggestErrorKey,
onSummarise,
summaryPending,
suggestion,
suggestionKindLabel,
suggestionDateLabel,
appliedFields,
onUseTitle,
onUseKind,
onUseDate,
onDismissSuggestion,
summaryOutput,
summaryResult,
summaryErrorKey,
onCloseSummary,
hasContentIndex,
indexPending,
onIndex,
}: {
aiEnabled: boolean;
indexEnabled: boolean;
unavailableReason: "no-provider" | "enable-local-ocr" | null;
/** Capability still resolving — the transport mode isn't known yet. */
actionsDisabled: boolean;
onSuggest: () => void;
suggestPending: boolean;
suggestErrorKey: string | null;
onSummarise: (output: DocumentSummaryMode) => void;
summaryPending: boolean;
suggestion: DocumentSuggestionDto | null;
suggestionKindLabel: string | null;
suggestionDateLabel: string | null;
appliedFields: { title: boolean; kind: boolean; date: boolean };
onUseTitle: () => void;
onUseKind: () => void;
onUseDate: () => void;
onDismissSuggestion: () => void;
summaryOutput: DocumentSummaryMode | null;
summaryResult: DocumentDescribeResult | null;
summaryErrorKey: string | null;
onCloseSummary: () => void;
hasContentIndex: boolean;
indexPending: boolean;
onIndex: () => void;
}) {
const { t } = useTranslations();
if (!aiEnabled) {
return (
<div className="space-y-3" data-slot="document-ai-section">
<AiUnavailableHint reason={unavailableReason} />
</div>
);
}
return (
<div className="space-y-3" data-slot="document-ai-section">
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
data-slot="assist-suggest"
onClick={onSuggest}
disabled={suggestPending || actionsDisabled}
>
<Sparkles className="size-4" aria-hidden />
{suggestPending
? t("documents.assist.suggesting")
: t("documents.assist.suggest")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onSummarise("summary")}
disabled={summaryPending || actionsDisabled}
>
<FileText className="size-4" aria-hidden />
{t("documents.summary.summarise")}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onSummarise("text")}
disabled={summaryPending || actionsDisabled}
>
{t("documents.summary.showText")}
</Button>
</div>
{suggestErrorKey ? (
<p role="alert" className="text-destructive text-sm">
{t(suggestErrorKey)}
</p>
) : null}
{suggestion ? (
<AssistSuggestionReview
suggestion={suggestion}
kindLabel={suggestionKindLabel}
dateLabel={suggestionDateLabel}
applied={appliedFields}
onUseTitle={onUseTitle}
onUseKind={onUseKind}
onUseDate={onUseDate}
onDismiss={onDismissSuggestion}
/>
) : null}
{summaryOutput ? (
<DocumentSummaryPanel
output={summaryOutput}
result={summaryResult}
isPending={summaryPending}
errorKey={summaryErrorKey}
onClose={onCloseSummary}
/>
) : null}
{indexEnabled ? (
<ContentIndexStatus
hasContentIndex={hasContentIndex}
isPending={indexPending}
onIndex={onIndex}
/>
) : null}
</div>
);
}
@@ -0,0 +1,111 @@
"use client";
/**
* v1.27.22 (Document vault P2) — the shared client transport for the document
* AI routes (suggest / summary / index). One place decides VISION vs local-OCR
* TEXT so every affordance posts a shape the endpoint accepts:
* - `mode: "vision"` → an empty-body POST; the server decrypts the stored
* original and runs one provider call.
* - `mode: "text"` → the browser OCR's the image on-device (tesseract, lazy
* loaded) and posts ONLY the text. Images only — a PDF/attachment in text
* mode is refused client-side before any download/OCR work.
*
* A provider call is slow, so requests opt out of the default 15 s fetch window
* in favour of a generous ceiling.
*/
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import { ocrImageToText, LocalOcrError } from "@/lib/labs/local-ocr";
/** The transport an AI call uses, resolved from the OCR capability probe. */
export type DocumentAiMode = "vision" | "text";
/** The document facts the transport needs to fetch + OCR the original. */
export interface DocumentAiTarget {
documentId: string;
mimeType: string;
filename: string | null;
servingClass: "inline" | "attachment";
}
/**
* A client-side precondition failure that never reached the server (the image
* couldn't be fetched, the file isn't an image for text mode, or local OCR
* failed). Carries a stable `reason` the UI maps to a translated message.
*/
export class DocumentAssistClientError extends Error {
readonly reason: "textImageOnly" | "originalFetch" | "ocr";
constructor(reason: DocumentAssistClientError["reason"]) {
super(reason);
this.name = "DocumentAssistClientError";
this.reason = reason;
}
}
function isImageMime(mime: string): boolean {
return mime.startsWith("image/");
}
/** Fetch the stored original as a `File` for in-browser OCR (same-origin). */
async function fetchOriginalAsFile(target: DocumentAiTarget): Promise<File> {
let res: Response;
try {
res = await apiFetchRaw(
`/api/documents/inbound/${target.documentId}/original`,
{ signal: AbortSignal.timeout(30_000) },
);
} catch {
throw new DocumentAssistClientError("originalFetch");
}
if (!res.ok) throw new DocumentAssistClientError("originalFetch");
const blob = await res.blob();
return new File([blob], target.filename ?? "document", {
type: blob.type || target.mimeType,
});
}
/** Run one document AI call over the chosen transport. */
export async function runDocumentAi<T>(opts: {
path: string;
mode: DocumentAiMode;
target: DocumentAiTarget;
}): Promise<T> {
if (opts.mode === "text") {
if (
opts.target.servingClass !== "inline" ||
!isImageMime(opts.target.mimeType)
) {
// tesseract.js reads images only — refuse before any download/OCR work.
throw new DocumentAssistClientError("textImageOnly");
}
const file = await fetchOriginalAsFile(opts.target);
let text: string;
try {
text = await ocrImageToText(file);
} catch (err) {
throw err instanceof LocalOcrError
? new DocumentAssistClientError("ocr")
: err;
}
return apiPost<T>(
opts.path,
{ mode: "text", text },
{ signal: AbortSignal.timeout(120_000) },
);
}
// Vision: empty body → the route dispatches to its vision handler.
return apiPost<T>(opts.path, undefined, {
signal: AbortSignal.timeout(120_000),
});
}
/** Populate / refresh one document's content index over the chosen transport. */
export function runDocumentIndex(opts: {
mode: DocumentAiMode;
target: DocumentAiTarget;
}): Promise<{ indexed: boolean; tokenCount: number }> {
return runDocumentAi<{ indexed: boolean; tokenCount: number }>({
path: `/api/documents/inbound/${opts.target.documentId}/index`,
mode: opts.mode,
target: opts.target,
});
}
@@ -52,10 +52,21 @@ import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import {
INBOUND_DOCUMENT_KINDS,
type DocumentSuggestionDto,
type DocumentSummaryMode,
type InboundDocumentDetailDto,
type InboundDocumentKindValue,
} from "@/lib/validations/inbound-documents";
import { DocumentAiSection } from "./document-ai-section";
import type { DocumentAiTarget } from "./document-ai-transport";
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
import { useIndexDocument } from "./use-content-index";
import {
documentAiErrorKey,
useDocumentAiCapability,
useDocumentSummary,
useSuggestDetails,
} from "./use-document-assist";
import { formatBytes } from "./vault-utils";
type PatchInput = {
@@ -173,10 +184,20 @@ export function DocumentDetailSheet({
documentId,
open,
onOpenChange,
assistAvailable,
contentIndexEnabled,
}: {
documentId: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
/**
* Whether the AI "Suggest details" / summary actions can run (from
* `usage.assistAvailable`). When false the AI area shows a calm pointer to
* the AI settings instead of an error — the manual form stays fully usable.
*/
assistAvailable?: boolean;
/** Whether content indexing is available (from `usage.contentIndex.enabled`). */
contentIndexEnabled?: boolean;
}) {
const { t, locale } = useTranslations();
const format = useFormatters();
@@ -192,19 +213,51 @@ export function DocumentDetailSheet({
const episodes = useIllnessEpisodes(true);
// AI layer — capability drives the vision/text transport; the mutations run
// the review-first assist, the session-only summary, and content indexing.
// The affordance itself is gated on `assistAvailable` (from usage); the
// probe only resolves the transport mode + the actionable unavailable reason.
const capability = useDocumentAiCapability(open && documentId !== null);
const suggest = useSuggestDetails();
const summary = useDocumentSummary();
const indexDoc = useIndexDocument();
const [mutationError, setMutationError] = useState<string | null>(null);
const [editingTitle, setEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState("");
const [suggestion, setSuggestion] = useState<DocumentSuggestionDto | null>(
null,
);
const [appliedFields, setAppliedFields] = useState({
title: false,
kind: false,
date: false,
});
const [summaryOutput, setSummaryOutput] =
useState<DocumentSummaryMode | null>(null);
// Reset transient edit state whenever another document opens —
// Reset transient edit + AI state whenever another document opens —
// render-phase derived-state adjustment, not an effect.
const [lastDocumentId, setLastDocumentId] = useState(documentId);
if (lastDocumentId !== documentId) {
setLastDocumentId(documentId);
setEditingTitle(false);
setMutationError(null);
setSuggestion(null);
setAppliedFields({ title: false, kind: false, date: false });
setSummaryOutput(null);
}
// Clear stale mutation state (a prior document's assist error / summary)
// when the sheet switches documents or closes — the mutation stores live
// outside render, so they reset in an effect rather than during render.
useEffect(() => {
suggest.reset();
summary.reset();
indexDoc.reset();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [documentId]);
const patch = useMutation({
mutationFn: (input: PatchInput) =>
apiPatch<InboundDocumentDetailDto>(
@@ -272,6 +325,75 @@ export function DocumentDetailSheet({
patch.mutate({ episodeIds: next });
};
// The affordance gate falls back to the capability probe when the sheet is
// rendered without the usage-derived props (deep link before usage loads).
const aiEnabled = assistAvailable ?? capability.data?.available ?? false;
const indexEnabled =
contentIndexEnabled ?? capability.data?.available ?? false;
const aiMode = capability.data?.mode === "text" ? "text" : "vision";
const aiTarget: DocumentAiTarget | null = doc
? {
documentId: doc.id,
mimeType: doc.mimeType,
filename: doc.filename,
servingClass: doc.servingClass,
}
: null;
const runSuggest = () => {
if (!aiTarget) return;
setAppliedFields({ title: false, kind: false, date: false });
suggest.mutate(
{ mode: aiMode, target: aiTarget },
{ onSuccess: (result) => setSuggestion(result) },
);
};
const runSummary = (output: DocumentSummaryMode) => {
if (!aiTarget) return;
summary.reset();
setSummaryOutput(output);
summary.mutate({ mode: aiMode, target: aiTarget, output });
};
const runIndex = () => {
if (!aiTarget) return;
indexDoc.mutate(
{ mode: aiMode, target: aiTarget },
{
onSuccess: () => toast.success(t("documents.contentIndex.indexed")),
onError: (error) => toast.error(t(documentAiErrorKey(error))),
},
);
};
// Review-first apply: the title lands in the EDITABLE field (the user still
// saves it via blur/Enter — the existing commit path); type and date each
// commit that single field on an explicit tap. Never automatic.
const applyTitle = () => {
if (!suggestion?.title) return;
setTitleDraft(suggestion.title);
setEditingTitle(true);
setAppliedFields((prev) => ({ ...prev, title: true }));
};
const applyKind = () => {
if (!suggestion?.kind) return;
patch.mutate({ kind: suggestion.kind });
setAppliedFields((prev) => ({ ...prev, kind: true }));
};
const applyDate = () => {
if (!suggestion?.documentDate) return;
patch.mutate({ documentDate: suggestion.documentDate });
setAppliedFields((prev) => ({ ...prev, date: true }));
};
const suggestionKindLabel = suggestion?.kind
? t(`documents.kind.${suggestion.kind}`)
: null;
const suggestionDateLabel = suggestion?.documentDate
? format.date(`${suggestion.documentDate}T12:00:00.000Z`)
: null;
const title = doc?.title ?? doc?.filename ?? t("documents.card.untitled");
const Icon = doc ? DOCUMENT_KIND_ICONS[doc.kind] : null;
const originalHref = doc ? `/api/documents/inbound/${doc.id}/original` : "#";
@@ -352,6 +474,40 @@ export function DocumentDetailSheet({
</div>
)}
<DocumentAiSection
aiEnabled={aiEnabled}
indexEnabled={indexEnabled}
unavailableReason={capability.data?.reason ?? null}
actionsDisabled={capability.isPending}
onSuggest={runSuggest}
suggestPending={suggest.isPending}
suggestErrorKey={
suggest.isError ? documentAiErrorKey(suggest.error) : null
}
onSummarise={runSummary}
summaryPending={summary.isPending}
suggestion={suggestion}
suggestionKindLabel={suggestionKindLabel}
suggestionDateLabel={suggestionDateLabel}
appliedFields={appliedFields}
onUseTitle={applyTitle}
onUseKind={applyKind}
onUseDate={applyDate}
onDismissSuggestion={() => setSuggestion(null)}
summaryOutput={summaryOutput}
summaryResult={summary.data ?? null}
summaryErrorKey={
summary.isError ? documentAiErrorKey(summary.error) : null
}
onCloseSummary={() => {
setSummaryOutput(null);
summary.reset();
}}
hasContentIndex={doc.hasContentIndex}
indexPending={indexDoc.isPending}
onIndex={runIndex}
/>
{mutationError ? (
<p role="alert" className="text-destructive text-sm">
{mutationError}
@@ -0,0 +1,56 @@
"use client";
/**
* v1.27.22 (Document vault P2) — content-search indexing hooks.
*
* - `useIndexDocument()` populates / refreshes ONE document's blind content
* index (encrypted extracted text + opaque HMAC token array). Vision decrypts
* the stored original server-side; text OCR's the image on-device and posts
* only the text — the same transport split the assist hooks use.
* - `useReindexAll()` fires the per-user corpus backfill (its own rate bucket);
* the worker indexes every not-yet-indexed document off-request.
*
* Both invalidate the `["documents"]` prefix so the timeline's per-row
* `hasContentIndex`, the open detail sheet, and the usage coverage gauge all
* refresh in lockstep.
*/
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiPost } from "@/lib/api/api-fetch";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import {
runDocumentIndex,
type DocumentAiMode,
type DocumentAiTarget,
} from "./document-ai-transport";
/** Index / re-index one document for content search. */
export function useIndexDocument() {
const queryClient = useQueryClient();
return useMutation<
{ indexed: boolean; tokenCount: number },
Error,
{ mode: DocumentAiMode; target: DocumentAiTarget }
>({
mutationFn: ({ mode, target }) => runDocumentIndex({ mode, target }),
onSuccess: () => {
void invalidateKeys(queryClient, [queryKeys.documents()]);
},
});
}
/** Fire the corpus backfill; resolves with how many docs were enqueued. */
export function useReindexAll() {
const queryClient = useQueryClient();
return useMutation<{ enqueued: number }, Error, void>({
mutationFn: () =>
apiPost<{ enqueued: number }>(
"/api/documents/inbound/reindex",
undefined,
),
onSuccess: () => {
void invalidateKeys(queryClient, [queryKeys.documents()]);
},
});
}
@@ -0,0 +1,134 @@
"use client";
/**
* v1.27.22 (Document vault P2) — client hooks for the review-first AI layer on
* a stored document: the shared capability probe, filing-metadata suggestions,
* and the session-only summary / extracted text.
*
* Every affordance is gated on `usage.assistAvailable` at the call site; these
* hooks only RUN the call once the surface has decided to offer it. The
* transport (VISION vs local-OCR TEXT) comes from `document-ai-transport`.
*
* NOTHING here writes a document row. Suggestions are drafts the detail sheet
* prefills; the summary is transient. The only mutation is the caller applying
* a reviewed draft through the existing edit-on-commit machinery.
*/
import { useMutation, useQuery } from "@tanstack/react-query";
import { apiGet, ApiError } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type { OcrCapabilityDto } from "@/lib/validations/labs-ocr";
import type {
DocumentSuggestionDto,
DocumentSummaryMode,
} from "@/lib/validations/inbound-documents";
import {
DocumentAssistClientError,
runDocumentAi,
type DocumentAiMode,
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 shared OCR capability probe (`/api/labs/ocr/capability`) reused verbatim:
* the same server resolution both the lab-OCR and the document AI routes gate
* on, so the client picks the transport the endpoint accepts. Availability of
* the affordance itself is gated on `usage.assistAvailable`; this read only
* resolves the transport `mode` + PDF support.
*/
export function useDocumentAiCapability(enabled: boolean) {
return useQuery<OcrCapabilityDto>({
queryKey: queryKeys.ocrCapability(),
queryFn: () => apiGet<OcrCapabilityDto>("/api/labs/ocr/capability"),
enabled,
staleTime: 60_000,
});
}
/**
* Suggest filing metadata for a stored document. Returns DRAFTS only — the
* detail sheet prefills its edit fields and the user saves; this call writes
* nothing.
*/
export function useSuggestDetails() {
return useMutation<
DocumentSuggestionDto,
Error,
{ mode: DocumentAiMode; target: DocumentAiTarget }
>({
mutationFn: async ({ mode, target }) => {
const data = await runDocumentAi<{ suggestions: DocumentSuggestionDto }>({
path: `/api/documents/inbound/${target.documentId}/suggest`,
mode,
target,
});
return data.suggestions;
},
});
}
/**
* On-demand, session-only summary or extracted text. The result is rendered
* once and never persisted (P2-D4); closing the panel discards it.
*/
export function useDocumentSummary() {
return useMutation<
DocumentDescribeResult,
Error,
{
mode: DocumentAiMode;
target: DocumentAiTarget;
output: DocumentSummaryMode;
}
>({
mutationFn: ({ mode, target, output }) =>
runDocumentAi<DocumentDescribeResult>({
path: `/api/documents/inbound/${target.documentId}/summary?mode=${output}`,
mode,
target,
}),
});
}
/**
* Map an AI error to a translation key. Server errors carry a stable
* `meta.errorCode`; client-side precondition failures carry a `reason`. The
* default is the calm "try again" message — never a raw provider string.
*/
export function documentAiErrorKey(err: unknown): string {
if (err instanceof DocumentAssistClientError) {
switch (err.reason) {
case "textImageOnly":
return "documents.assist.errorTextImageOnly";
case "ocr":
return "documents.assist.errorOcr";
default:
return "documents.assist.errorGeneric";
}
}
if (err instanceof ApiError) {
const code =
typeof err.meta?.errorCode === "string" ? err.meta.errorCode : "";
switch (code) {
case "documents.inbound.rateLimited":
return "documents.assist.errorRateLimited";
case "documents.inbound.budgetExceeded":
return "documents.assist.errorBudget";
case "documents.inbound.pdfNeedsAnthropic":
return "documents.assist.errorPdfNeedsVision";
case "documents.inbound.fileType":
return "documents.assist.errorFileType";
case "documents.inbound.localOcrDisabled":
return "documents.assist.errorLocalOcrDisabled";
case "documents.inbound.providerUnsupported":
return "documents.assist.errorProvider";
default:
return "documents.assist.errorGeneric";
}
}
return "documents.assist.errorGeneric";
}