From 5f6a0e42b47aa8a9abc6b8f790d08e311f659008 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 11:05:59 +0200 Subject: [PATCH] refactor(web): retire the feature-flag reader for the capability map The web no longer reads GET /api/feature-flags: useFeatureFlags and its query key are gone, and use-ai-capability gains the two readers the Coach pages and the operator notice need. A structural guard fails when web code reads the retired projection, and when a module requests an AI route without being registered with the capability that gates it. Adds the copy for every new state in all seven locales and drops the per-card no-provider strings. --- messages/de.json | 64 ++++-- messages/en.json | 64 ++++-- messages/es.json | 64 ++++-- messages/fr.json | 64 ++++-- messages/it.json | 64 ++++-- messages/ko.json | 64 ++++-- messages/pl.json | 64 ++++-- .../ai-capability-surface-guard.test.ts | 214 ++++++++++++++++++ .../success-affordance-guard.test.ts | 5 + src/__tests__/v1341-locale-parity.test.ts | 2 - .../__tests__/use-feature-flags.test.tsx | 88 ------- src/hooks/use-ai-capability.ts | 45 ++++ src/hooks/use-feature-flags.ts | 104 --------- .../__tests__/no-dead-assistant-flag.test.ts | 1 - 14 files changed, 621 insertions(+), 286 deletions(-) create mode 100644 src/__tests__/ai-capability-surface-guard.test.ts delete mode 100644 src/hooks/__tests__/use-feature-flags.test.tsx delete mode 100644 src/hooks/use-feature-flags.ts diff --git a/messages/de.json b/messages/de.json index 419b0f16b..f1a372d90 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1312,7 +1312,9 @@ "rateLimit": "Zu viele Anfragen — bitte kurz warten und erneut versuchen.", "noProvider": "Kein KI-Anbieter konfiguriert — bitte in den Einstellungen einrichten.", "budget": "Tägliches KI-Budget erreicht — bitte morgen erneut versuchen.", - "network": "KI-Anbieter nicht erreichbar — bitte erneut versuchen." + "network": "KI-Anbieter nicht erreichbar — bitte erneut versuchen.", + "consent": "Für das Lesen deiner Beschreibung mit KI brauchst du zuerst deine Einwilligung.", + "unavailable": "Das Lesen von Beschreibungen mit KI ist hier gerade nicht verfügbar." } }, "cadence": { @@ -2444,8 +2446,6 @@ "bmiEmptyAction": "Account-Einstellungen öffnen", "noAnalysisYet": "Noch keine Einschätzung vorhanden.", "assessmentPreparing": "Einschätzung wird vorbereitet…", - "noProviderConfigured": "Verbinde einen KI-Anbieter, um hier eine Einschätzung zu deinen Werten zu sehen.", - "noProviderAction": "Zu den KI-Einstellungen", "personalRecord": { "badge": "PR", "tooltip": "Persönlicher Rekord: {value} {unit} am {date}" @@ -2569,10 +2569,6 @@ "emptyDescription": "Erstelle das heutige Briefing, um eine Zusammenfassung deiner Trends zu bekommen.", "emptyAction": "Briefing erstellen", "loadingLabel": "Briefing wird zusammengestellt", - "noProviderTitle": "Kein KI-Anbieter verbunden", - "noProviderDescription": "Richte HealthLog auf ein lokales Modell (Ollama, LM Studio — nichts verlässt dein Netzwerk) oder hinterlege deinen eigenen OpenAI- oder Anthropic-Schlüssel, und das tägliche Briefing fasst deine eigenen Messwerte in klarer Sprache zusammen. Die Einrichtung dauert etwa eine Minute.", - "noProviderAction": "Zu den KI-Einstellungen", - "staleNoProviderHint": "Dieses Briefing kann nicht aktualisiert werden — es ist kein KI-Anbieter verbunden.", "refreshFailedHint": "Briefing konnte nicht aktualisiert werden — es wird das letzte angezeigt.", "retryAction": "Erneut versuchen", "failedTitle": "Briefing konnte nicht erstellt werden", @@ -2950,7 +2946,15 @@ "interrupted": { "note": "Diese Antwort wurde unterbrochen, bevor sie fertig war.", "retry": "Erneut fragen" - } + }, + "historyReadOnlyNote": "Der Coach ist gerade nicht verfügbar. Deine Gespräche kannst du trotzdem lesen und löschen.", + "transcriptYou": "Du", + "transcriptCoach": "Coach", + "errorConsent": "Der Coach braucht deine KI-Einwilligung, bevor er antworten kann. Du kannst sie unter Einstellungen, KI geben.", + "errorNotPermitted": "Der Coach ist in einer Akte, die jemand mit dir teilt, nicht verfügbar.", + "errorUnavailable": "Der Coach konnte seine Einstellungen gerade nicht prüfen. Bitte versuch es gleich noch einmal.", + "errorOperatorOff": "KI ist auf diesem Server abgeschaltet, deshalb kann der Coach nicht antworten.", + "errorCoachOff": "Der Coach ist ausgeschaltet. Du kannst ihn unter Einstellungen, Module wieder einschalten." }, "relativeJustNow": "gerade eben", "relativeMinutesAgoOne": "vor {count} Minute", @@ -4383,7 +4387,15 @@ "night": "Eine anhaltend erhöhte Ruhephase in der Nacht — möglicherweise etwas Anspannung." } }, - "navEcg": "EKG" + "navEcg": "EKG", + "sectionLoadError": "Dieser Bereich konnte nicht geladen werden.", + "aiSetupHint": { + "title": "KI ist optional", + "body": "Hier funktioniert alles auch ohne KI. Wenn du ein tägliches Briefing, kurze Notizen zu deinen Werten und den Coach möchtest, kannst du einen KI-Anbieter verbinden.", + "settingsBody": "HealthLog funktioniert vollständig ohne KI. Verbinde unten einen Anbieter, wenn du ein tägliches Briefing, kurze Notizen zu deinen Werten und den Coach möchtest.", + "action": "KI einrichten", + "dismiss": "Nicht jetzt" + } }, "thresholds": { "defaultLabel": "Default", @@ -5831,12 +5843,30 @@ "activeSince": "Erteilt am {date}. Deine Gesundheitsdaten dürfen für die KI-Funktionen an den eingestellten Anbieter gesendet werden.", "withdrawn": "Widerrufen. Es werden keine Gesundheitsdaten an einen KI-Anbieter gesendet; die KI-Funktionen bleiben aus.", "withdraw": "Einwilligung widerrufen", - "confirmBody": "Die KI-Funktionen sind sofort abgeschaltet. Bereits erfasste Daten werden nicht gelöscht — dafür gibt es Daten & Privatsphäre.", + "confirmBody": "Die KI-Funktionen stoppen sofort, und die Notizen, die ein Modell für dich geschrieben hat, werden gelöscht: Statusnotizen, das tägliche Briefing, von einem Modell geschriebene Zeitraum-Zusammenfassungen, Ankunftszeilen und Trainingsnotizen. Deine Coach-Gespräche, was sich der Coach gemerkt hat, deine Pläne und deine Dokument-Zusammenfassungen bleiben, und du kannst sie selbst lesen oder löschen.", "confirmWithdraw": "Widerrufen", "regrantHint": "Mit einer neuen Einwilligung dürfen die KI-Funktionen deine Gesundheitsdaten wieder an den eingestellten Anbieter senden.", "grant": "Einwilligung erteilen", "grantError": "Die Einwilligung konnte nicht gespeichert werden. Bitte versuche es erneut.", - "error": "Der Widerruf konnte nicht gespeichert werden. Bitte versuche es erneut." + "error": "Der Widerruf konnte nicht gespeichert werden. Bitte versuche es erneut.", + "documentReading": { + "title": "Einwilligung zum Lesen von Dokumenten", + "activeSince": "Erteilt am {date}. Dokumente, gescannte Laborbefunde und Medikamentenbeschreibungen dürfen zum Lesen an deinen KI-Dienst gesendet werden.", + "confirmBody": "Dokumente, gescannte Laborbefunde und Medikamentenbeschreibungen werden nicht mehr zum Lesen gesendet. Nichts, was du bereits hast, wird gelöscht." + } + }, + "operatorOff": { + "allTitle": "KI ist auf diesem Server abgeschaltet", + "allBody": "Wer diesen Server betreibt, hat KI abgeschaltet. HealthLog funktioniert weiter mit all deinen Daten; die Funktionen, die KI brauchen, werden einfach nicht angezeigt.", + "someTitle": "Einige KI-Funktionen sind auf diesem Server abgeschaltet", + "someBody": "Wer diesen Server betreibt, hat abgeschaltet: {switches}. Alles andere, auch all deine Daten, funktioniert wie gewohnt.", + "testRefused": "KI ist auf diesem Server abgeschaltet, daher gibt es nichts zu testen.", + "autoRead": "Das Lesen von Dokumenten mit KI ist auf diesem Server abgeschaltet." + }, + "coachConversations": { + "title": "Coach-Gespräche", + "description": "Öffne ein Gespräch, um es zu lesen, oder lösche es.", + "more": "Mehr anzeigen" } }, "dashboard": { @@ -8801,7 +8831,8 @@ "extractFailed": "Der Befund konnte nicht gelesen werden. Versuche ein schärferes Foto.", "pdfNeedsAnthropic": "PDF-Scannen benötigt einen Claude-Bildanbieter; lade stattdessen ein Foto hoch.", "refTextLabel": "Bereich wie gedruckt", - "refTextPlaceholder": "z. B. 3,5 - 5,0" + "refTextPlaceholder": "z. B. 3,5 - 5,0", + "aiUnavailable": "Das Scannen eines Befunds mit KI ist hier gerade nicht verfügbar." }, "loadMoreReadings": "Weitere Werte laden", "documentsLink": "Befund-Dokumente", @@ -9949,7 +9980,9 @@ "errorNotIndexed": "Lies das Dokument zuerst aus, dann extrahiere.", "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." + "errorTextImageOnly": "Lokale Texterkennung liest nur Bilder — nicht diese Datei.", + "errorConsent": "Für das Lesen von Dokumenten mit KI brauchst du zuerst deine Einwilligung.", + "errorUnavailable": "Das Lesen von Dokumenten mit KI ist hier gerade nicht verfügbar." }, "summary": { "summarise": "Zusammenfassen", @@ -10027,6 +10060,11 @@ "title": "Über dieses Dokument chatten", "openAria": "Coach über dieses Dokument fragen", "openButton": "Coach fragen" + }, + "readingConsent": { + "body": "Beim Lesen mit KI geht dies an deinen KI-Dienst außerhalb dieses Servers. Darf HealthLog Dokumente, Laborbefunde und Medikamentenbeschreibungen zum Lesen senden?", + "grant": "Lesen erlauben", + "error": "Die Einwilligung konnte nicht gespeichert werden. Bitte versuch es erneut." } }, "selfDescription": { diff --git a/messages/en.json b/messages/en.json index 31089bf21..d2442a434 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1312,7 +1312,9 @@ "rateLimit": "Too many requests — wait a moment and try again.", "noProvider": "No AI provider configured — set one up in Settings.", "budget": "Daily AI budget reached — try again tomorrow.", - "network": "Could not reach the AI provider — please retry." + "network": "Could not reach the AI provider — please retry.", + "consent": "Reading your description with AI needs your consent first.", + "unavailable": "Reading descriptions with AI is not available here right now." } }, "cadence": { @@ -2444,8 +2446,6 @@ "bmiEmptyTitle": "Set your height to see BMI", "bmiEmptyDescription": "BMI needs your height. Add it under Settings → Account.", "bmiEmptyAction": "Open account settings", - "noProviderConfigured": "Connect an AI provider to see an assessment of your readings here.", - "noProviderAction": "Open AI settings", "personalRecord": { "badge": "PR", "tooltip": "Personal record: {value} {unit} on {date}" @@ -2569,10 +2569,6 @@ "emptyDescription": "Generate today's briefing to get a narrative read of your trends.", "emptyAction": "Generate briefing", "loadingLabel": "Synthesising today's briefing", - "noProviderTitle": "No AI provider connected", - "noProviderDescription": "Point HealthLog at a local model (Ollama, LM Studio — nothing leaves your network) or bring your own OpenAI or Anthropic key, and the daily briefing turns your own readings into a plain-language summary. Setup takes about a minute.", - "noProviderAction": "Open AI settings", - "staleNoProviderHint": "This briefing can't refresh — no AI provider is connected.", "refreshFailedHint": "Couldn't refresh the briefing — showing the last one.", "retryAction": "Retry", "failedTitle": "Couldn't generate the briefing", @@ -2950,7 +2946,15 @@ "interrupted": { "note": "This answer was interrupted before it finished.", "retry": "Ask again" - } + }, + "historyReadOnlyNote": "The Coach is not available right now. You can still read and delete your conversations.", + "transcriptYou": "You", + "transcriptCoach": "Coach", + "errorConsent": "The Coach needs your AI consent before it can answer. You can give it in Settings, AI.", + "errorNotPermitted": "The Coach is not available inside a record someone shared with you.", + "errorUnavailable": "The Coach could not check its settings just now. Please try again in a moment.", + "errorOperatorOff": "AI is switched off on this server, so the Coach cannot answer.", + "errorCoachOff": "The Coach is switched off. You can turn it back on in Settings, Modules." }, "relativeJustNow": "just now", "relativeMinutesAgoOne": "{count} minute ago", @@ -4383,7 +4387,15 @@ "night": "A sustained elevated-at-rest stretch overnight — possibly some tension." } }, - "navEcg": "ECG" + "navEcg": "ECG", + "sectionLoadError": "This section could not be loaded.", + "aiSetupHint": { + "title": "AI is optional", + "body": "Everything here works without AI. If you want a daily briefing, short notes on your readings and the Coach, you can connect an AI provider.", + "settingsBody": "HealthLog works fully without AI. Connect a provider below if you want a daily briefing, short notes on your readings and the Coach.", + "action": "Set up AI", + "dismiss": "Not now" + } }, "thresholds": { "defaultLabel": "Default", @@ -5831,12 +5843,30 @@ "activeSince": "Given on {date}. Your health data may be sent to the configured provider for the AI features.", "withdrawn": "Withdrawn. No health data is sent to an AI provider; the AI features stay switched off.", "withdraw": "Withdraw consent", - "confirmBody": "The AI features stop working straight away. Nothing already recorded is deleted — use Data & privacy to export or erase your record.", + "confirmBody": "The AI features stop straight away, and the notes a model wrote for you are deleted: status notes, the daily briefing, model-written period summaries, arrival lines and workout notes. Your Coach conversations, what the Coach remembered, your plans and your document summaries stay, and you can read or delete them yourself.", "confirmWithdraw": "Withdraw", "regrantHint": "Granting again lets the AI features send your health data to the configured provider.", "grant": "Grant consent", "grantError": "The consent could not be saved. Please try again.", - "error": "The withdrawal could not be saved. Please try again." + "error": "The withdrawal could not be saved. Please try again.", + "documentReading": { + "title": "Consent to reading documents", + "activeSince": "Given on {date}. Documents, lab report scans and medication descriptions may be sent to your AI service for reading.", + "confirmBody": "Documents, lab report scans and medication descriptions are no longer sent for reading. Nothing you already have is deleted." + } + }, + "operatorOff": { + "allTitle": "AI is switched off on this server", + "allBody": "The person who runs this server has turned AI off. HealthLog keeps working with all your data; the features that need AI are simply not shown.", + "someTitle": "Some AI features are switched off on this server", + "someBody": "The person who runs this server has turned off: {switches}. Everything else, including all your data, works as usual.", + "testRefused": "AI is switched off on this server, so there is nothing to test.", + "autoRead": "Reading documents with AI is switched off on this server." + }, + "coachConversations": { + "title": "Coach conversations", + "description": "Open a conversation to read it, or delete it.", + "more": "Show more" } }, "dashboard": { @@ -8801,7 +8831,8 @@ "extractFailed": "Couldn't read the report. Try a clearer photo.", "pdfNeedsAnthropic": "PDF scanning needs a Claude vision provider; upload a photo instead.", "refTextLabel": "Range as printed", - "refTextPlaceholder": "e.g. 3.5 - 5.0" + "refTextPlaceholder": "e.g. 3.5 - 5.0", + "aiUnavailable": "Scanning a report with AI is not available here right now." }, "loadMoreReadings": "Load more readings", "documentsLink": "Lab report documents", @@ -9949,7 +9980,9 @@ "errorNotIndexed": "Read the document first, then extract.", "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." + "errorTextImageOnly": "Local OCR reads images only — not this file.", + "errorConsent": "Reading documents with AI needs your consent first.", + "errorUnavailable": "Reading documents with AI is not available here right now." }, "summary": { "summarise": "Summarise", @@ -10027,6 +10060,11 @@ "title": "Chat about this document", "openAria": "Ask the Coach about this document", "openButton": "Ask the Coach" + }, + "readingConsent": { + "body": "Reading with AI sends this to your AI service outside this server. Allow HealthLog to send documents, lab reports and medication descriptions for reading?", + "grant": "Allow reading", + "error": "The consent could not be saved. Please try again." } }, "selfDescription": { diff --git a/messages/es.json b/messages/es.json index 287124c44..122a1c6d2 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1312,7 +1312,9 @@ "rateLimit": "Demasiadas solicitudes — espera un momento e inténtalo de nuevo.", "noProvider": "Sin proveedor de IA configurado — configúralo en Ajustes.", "budget": "Se alcanzó el presupuesto diario de IA — inténtalo de nuevo mañana.", - "network": "No se pudo contactar con el proveedor de IA — inténtalo de nuevo." + "network": "No se pudo contactar con el proveedor de IA — inténtalo de nuevo.", + "consent": "Leer tu descripción con IA necesita primero tu consentimiento.", + "unavailable": "Leer descripciones con IA no está disponible aquí ahora mismo." } }, "cadence": { @@ -2444,8 +2446,6 @@ "bmiEmptyTitle": "Añade tu altura para calcular el IMC", "bmiEmptyDescription": "El IMC necesita tu altura. Guárdala en Ajustes → Cuenta.", "bmiEmptyAction": "Abrir ajustes de cuenta", - "noProviderConfigured": "Conecta un proveedor de IA para ver aquí una valoración de tus lecturas.", - "noProviderAction": "Abrir ajustes de IA", "personalRecord": { "badge": "PR", "tooltip": "Récord personal: {value} {unit} el {date}" @@ -2569,10 +2569,6 @@ "emptyDescription": "Genera el resumen de hoy para tener una vista rápida de tus tendencias.", "emptyAction": "Generar resumen", "loadingLabel": "Preparando el resumen", - "noProviderTitle": "Ningún proveedor de IA conectado", - "noProviderDescription": "Apunta HealthLog a un modelo local (Ollama, LM Studio: nada sale de tu red) o usa tu propia clave de OpenAI o Anthropic, y el resumen diario convierte tus propias mediciones en un resumen en lenguaje claro. La configuración lleva alrededor de un minuto.", - "noProviderAction": "Abrir ajustes de IA", - "staleNoProviderHint": "Este briefing no puede actualizarse: no hay ningún proveedor de IA conectado.", "refreshFailedHint": "No se pudo actualizar el briefing: se muestra el anterior.", "retryAction": "Reintentar", "failedTitle": "No se pudo generar el briefing", @@ -2950,7 +2946,15 @@ "interrupted": { "note": "Esta respuesta se interrumpió antes de terminar.", "retry": "Preguntar de nuevo" - } + }, + "historyReadOnlyNote": "El Coach no está disponible ahora mismo. Aun así puedes leer y eliminar tus conversaciones.", + "transcriptYou": "Tú", + "transcriptCoach": "Coach", + "errorConsent": "El Coach necesita tu consentimiento de IA antes de poder responder. Puedes darlo en Ajustes, IA.", + "errorNotPermitted": "El Coach no está disponible dentro de un registro que alguien comparte contigo.", + "errorUnavailable": "El Coach no pudo comprobar su configuración ahora. Inténtalo de nuevo en un momento.", + "errorOperatorOff": "La IA está desactivada en este servidor, así que el Coach no puede responder.", + "errorCoachOff": "El Coach está desactivado. Puedes volver a activarlo en Ajustes, Módulos." }, "relativeJustNow": "ahora mismo", "relativeMinutesAgoOne": "hace {count} minuto", @@ -4383,7 +4387,15 @@ "night": "Un tramo elevado en reposo por la noche; posiblemente algo de tensión." } }, - "navEcg": "ECG" + "navEcg": "ECG", + "sectionLoadError": "No se pudo cargar esta sección.", + "aiSetupHint": { + "title": "La IA es opcional", + "body": "Todo funciona aquí sin IA. Si quieres un resumen diario, notas breves sobre tus mediciones y el Coach, puedes conectar un proveedor de IA.", + "settingsBody": "HealthLog funciona por completo sin IA. Conecta un proveedor abajo si quieres un resumen diario, notas breves sobre tus mediciones y el Coach.", + "action": "Configurar IA", + "dismiss": "Ahora no" + } }, "thresholds": { "defaultLabel": "Por defecto", @@ -5831,12 +5843,30 @@ "activeSince": "Otorgado el {date}. Tus datos de salud pueden enviarse al proveedor configurado para las funciones de IA.", "withdrawn": "Retirado. No se envían datos de salud a ningún proveedor de IA; las funciones de IA permanecen desactivadas.", "withdraw": "Retirar el consentimiento", - "confirmBody": "Las funciones de IA dejan de funcionar de inmediato. No se borra nada de lo ya registrado: usa Datos y privacidad para exportar o eliminar tu historial.", + "confirmBody": "Las funciones de IA se detienen de inmediato y se eliminan las notas que un modelo escribió para ti: notas de estado, el resumen diario, los resúmenes de periodo escritos por un modelo, las líneas de llegada y las notas de entrenamiento. Tus conversaciones con el Coach, lo que el Coach recordaba, tus planes y los resúmenes de tus documentos se conservan, y puedes leerlos o eliminarlos tú mismo.", "confirmWithdraw": "Retirar", "regrantHint": "Si vuelves a otorgarlo, las funciones de IA podrán enviar de nuevo tus datos de salud al proveedor configurado.", "grant": "Otorgar el consentimiento", "grantError": "No se pudo guardar el consentimiento. Inténtalo de nuevo.", - "error": "No se pudo guardar la retirada. Inténtalo de nuevo." + "error": "No se pudo guardar la retirada. Inténtalo de nuevo.", + "documentReading": { + "title": "Consentimiento para leer documentos", + "activeSince": "Dado el {date}. Los documentos, los informes de laboratorio escaneados y las descripciones de medicamentos pueden enviarse a tu servicio de IA para leerlos.", + "confirmBody": "Los documentos, los informes de laboratorio escaneados y las descripciones de medicamentos dejan de enviarse para leerlos. No se elimina nada de lo que ya tienes." + } + }, + "operatorOff": { + "allTitle": "La IA está desactivada en este servidor", + "allBody": "Quien gestiona este servidor ha desactivado la IA. HealthLog sigue funcionando con todos tus datos; las funciones que necesitan IA simplemente no se muestran.", + "someTitle": "Algunas funciones de IA están desactivadas en este servidor", + "someBody": "Quien gestiona este servidor ha desactivado: {switches}. Todo lo demás, incluidos todos tus datos, funciona como siempre.", + "testRefused": "La IA está desactivada en este servidor, así que no hay nada que probar.", + "autoRead": "Leer documentos con IA está desactivado en este servidor." + }, + "coachConversations": { + "title": "Conversaciones con el Coach", + "description": "Abre una conversación para leerla o elimínala.", + "more": "Mostrar más" } }, "dashboard": { @@ -8801,7 +8831,8 @@ "extractFailed": "No se pudo leer el informe. Prueba con una foto más nítida.", "pdfNeedsAnthropic": "El escaneo de PDF necesita un proveedor de visión de Claude; sube una foto en su lugar.", "refTextLabel": "Rango tal como aparece", - "refTextPlaceholder": "p. ej. 3,5 - 5,0" + "refTextPlaceholder": "p. ej. 3,5 - 5,0", + "aiUnavailable": "Escanear un informe con IA no está disponible aquí ahora mismo." }, "loadMoreReadings": "Cargar más mediciones", "documentsLink": "Documentos de resultados", @@ -9949,7 +9980,9 @@ "errorNotIndexed": "Primero lee el documento y luego extrae.", "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." + "errorTextImageOnly": "El OCR local solo lee imágenes, no este archivo.", + "errorConsent": "Leer documentos con IA necesita primero tu consentimiento.", + "errorUnavailable": "Leer documentos con IA no está disponible aquí ahora mismo." }, "summary": { "summarise": "Resumir", @@ -10027,6 +10060,11 @@ "title": "Chatea sobre este documento", "openAria": "Pregunta al Coach sobre este documento", "openButton": "Preguntar al Coach" + }, + "readingConsent": { + "body": "Leer con IA envía esto a tu servicio de IA fuera de este servidor. ¿Permitir que HealthLog envíe documentos, informes de laboratorio y descripciones de medicamentos para leerlos?", + "grant": "Permitir lectura", + "error": "No se pudo guardar el consentimiento. Inténtalo de nuevo." } }, "selfDescription": { diff --git a/messages/fr.json b/messages/fr.json index dd59b3df6..7100c0fa0 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1312,7 +1312,9 @@ "rateLimit": "Trop de requêtes — attends un instant et réessaie.", "noProvider": "Aucun fournisseur d'IA configuré — configure-le dans les Paramètres.", "budget": "Budget IA quotidien atteint — réessaie demain.", - "network": "Impossible de joindre le fournisseur d'IA — réessaie." + "network": "Impossible de joindre le fournisseur d'IA — réessaie.", + "consent": "La lecture de votre description par IA nécessite d'abord votre consentement.", + "unavailable": "La lecture de descriptions par IA n'est pas disponible ici pour le moment." } }, "cadence": { @@ -2444,8 +2446,6 @@ "bmiEmptyTitle": "Ajoute ta taille pour l’IMC", "bmiEmptyDescription": "L’IMC nécessite ta taille. Renseigne-la dans Paramètres → Compte.", "bmiEmptyAction": "Ouvrir les paramètres du compte", - "noProviderConfigured": "Connecte un fournisseur d'IA pour voir ici une évaluation de tes mesures.", - "noProviderAction": "Ouvrir les réglages IA", "personalRecord": { "badge": "PR", "tooltip": "Record personnel : {value} {unit} le {date}" @@ -2569,10 +2569,6 @@ "emptyDescription": "Génère le briefing du jour pour obtenir un récap de tes tendances.", "emptyAction": "Générer le briefing", "loadingLabel": "Préparation du briefing", - "noProviderTitle": "Aucun fournisseur d'IA connecté", - "noProviderDescription": "Faites pointer HealthLog vers un modèle local (Ollama, LM Studio — rien ne quitte votre réseau) ou utilisez votre propre clé OpenAI ou Anthropic, et le résumé quotidien transforme vos propres relevés en un résumé en langage clair. La configuration prend environ une minute.", - "noProviderAction": "Ouvrir les réglages IA", - "staleNoProviderHint": "Ce briefing ne peut pas être actualisé — aucun fournisseur d'IA n'est connecté.", "refreshFailedHint": "Impossible d'actualiser le briefing — affichage du précédent.", "retryAction": "Réessayer", "failedTitle": "Impossible de générer le briefing", @@ -2950,7 +2946,15 @@ "interrupted": { "note": "Cette réponse a été interrompue avant la fin.", "retry": "Reposer la question" - } + }, + "historyReadOnlyNote": "Le Coach n'est pas disponible pour le moment. Vous pouvez toujours lire et supprimer vos conversations.", + "transcriptYou": "Vous", + "transcriptCoach": "Coach", + "errorConsent": "Le Coach a besoin de votre consentement à l'IA avant de pouvoir répondre. Vous pouvez le donner dans Réglages, IA.", + "errorNotPermitted": "Le Coach n'est pas disponible dans un dossier que quelqu'un partage avec vous.", + "errorUnavailable": "Le Coach n'a pas pu vérifier ses réglages pour l'instant. Réessayez dans un instant.", + "errorOperatorOff": "L'IA est désactivée sur ce serveur, le Coach ne peut donc pas répondre.", + "errorCoachOff": "Le Coach est désactivé. Vous pouvez le réactiver dans Réglages, Modules." }, "relativeJustNow": "à l’instant", "relativeMinutesAgoOne": "il y a {count} minute", @@ -4383,7 +4387,15 @@ "night": "Une période élevée au repos cette nuit — peut-être un peu de tension." } }, - "navEcg": "ECG" + "navEcg": "ECG", + "sectionLoadError": "Cette section n'a pas pu être chargée.", + "aiSetupHint": { + "title": "L'IA est facultative", + "body": "Tout fonctionne ici sans IA. Si vous voulez un briefing quotidien, de courtes notes sur vos mesures et le Coach, vous pouvez connecter un fournisseur d'IA.", + "settingsBody": "HealthLog fonctionne entièrement sans IA. Connectez un fournisseur ci-dessous si vous voulez un briefing quotidien, de courtes notes sur vos mesures et le Coach.", + "action": "Configurer l'IA", + "dismiss": "Pas maintenant" + } }, "thresholds": { "defaultLabel": "Par défaut", @@ -5831,12 +5843,30 @@ "activeSince": "Donné le {date}. Tes données de santé peuvent être transmises au fournisseur configuré pour les fonctions IA.", "withdrawn": "Retiré. Aucune donnée de santé n'est transmise à un fournisseur IA ; les fonctions IA restent désactivées.", "withdraw": "Retirer le consentement", - "confirmBody": "Les fonctions IA s'arrêtent immédiatement. Rien de ce qui est déjà enregistré n'est supprimé — utilise Données et confidentialité pour exporter ou effacer ton dossier.", + "confirmBody": "Les fonctions d'IA s'arrêtent immédiatement et les notes qu'un modèle a écrites pour vous sont supprimées : notes d'état, briefing quotidien, résumés de période écrits par un modèle, lignes d'arrivée et notes d'entraînement. Vos conversations avec le Coach, ce que le Coach a retenu, vos plans et les résumés de vos documents sont conservés, et vous pouvez les lire ou les supprimer vous-même.", "confirmWithdraw": "Retirer", "regrantHint": "En le redonnant, les fonctions IA pourront à nouveau transmettre tes données de santé au fournisseur configuré.", "grant": "Donner le consentement", "grantError": "Le consentement n'a pas pu être enregistré. Réessaie.", - "error": "Le retrait n'a pas pu être enregistré. Réessaie." + "error": "Le retrait n'a pas pu être enregistré. Réessaie.", + "documentReading": { + "title": "Consentement à la lecture de documents", + "activeSince": "Donné le {date}. Les documents, les comptes rendus d'analyses numérisés et les descriptions de médicaments peuvent être envoyés à votre service d'IA pour lecture.", + "confirmBody": "Les documents, les comptes rendus d'analyses numérisés et les descriptions de médicaments ne sont plus envoyés pour lecture. Rien de ce que vous avez déjà n'est supprimé." + } + }, + "operatorOff": { + "allTitle": "L'IA est désactivée sur ce serveur", + "allBody": "La personne qui gère ce serveur a désactivé l'IA. HealthLog continue de fonctionner avec toutes vos données ; les fonctions qui ont besoin de l'IA ne sont simplement pas affichées.", + "someTitle": "Certaines fonctions d'IA sont désactivées sur ce serveur", + "someBody": "La personne qui gère ce serveur a désactivé : {switches}. Tout le reste, y compris toutes vos données, fonctionne normalement.", + "testRefused": "L'IA est désactivée sur ce serveur, il n'y a donc rien à tester.", + "autoRead": "La lecture de documents par IA est désactivée sur ce serveur." + }, + "coachConversations": { + "title": "Conversations avec le Coach", + "description": "Ouvrez une conversation pour la lire, ou supprimez-la.", + "more": "Afficher plus" } }, "dashboard": { @@ -8801,7 +8831,8 @@ "extractFailed": "Impossible de lire le compte rendu. Essayez une photo plus nette.", "pdfNeedsAnthropic": "Le scan PDF nécessite un fournisseur de vision Claude ; importez plutôt une photo.", "refTextLabel": "Plage telle qu'imprimée", - "refTextPlaceholder": "p. ex. 3,5 - 5,0" + "refTextPlaceholder": "p. ex. 3,5 - 5,0", + "aiUnavailable": "La numérisation d'un compte rendu par IA n'est pas disponible ici pour le moment." }, "loadMoreReadings": "Charger plus de mesures", "documentsLink": "Documents de résultats", @@ -9949,7 +9980,9 @@ "errorNotIndexed": "Lisez d’abord le document, puis extrayez.", "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." + "errorTextImageOnly": "L'OCR local ne lit que les images, pas ce fichier.", + "errorConsent": "La lecture de documents par IA nécessite d'abord votre consentement.", + "errorUnavailable": "La lecture de documents par IA n'est pas disponible ici pour le moment." }, "summary": { "summarise": "Résumer", @@ -10027,6 +10060,11 @@ "title": "Discuter de ce document", "openAria": "Interroger le Coach sur ce document", "openButton": "Demander au Coach" + }, + "readingConsent": { + "body": "La lecture par IA envoie ceci à votre service d'IA hors de ce serveur. Autoriser HealthLog à envoyer des documents, des résultats d'analyses et des descriptions de médicaments pour lecture ?", + "grant": "Autoriser la lecture", + "error": "Le consentement n'a pas pu être enregistré. Veuillez réessayer." } }, "selfDescription": { diff --git a/messages/it.json b/messages/it.json index 6ab0f72c0..6e7b0141f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1312,7 +1312,9 @@ "rateLimit": "Troppe richieste — attendi un momento e riprova.", "noProvider": "Nessun provider IA configurato — configurane uno in Impostazioni.", "budget": "Budget IA giornaliero raggiunto — riprova domani.", - "network": "Impossibile contattare il provider IA — riprova." + "network": "Impossibile contattare il provider IA — riprova.", + "consent": "La lettura della tua descrizione con IA richiede prima il tuo consenso.", + "unavailable": "La lettura delle descrizioni con IA non è disponibile qui al momento." } }, "cadence": { @@ -2444,8 +2446,6 @@ "bmiEmptyTitle": "Aggiungi l’altezza per il BMI", "bmiEmptyDescription": "Per il BMI serve la tua altezza. Salvala in Impostazioni → Account.", "bmiEmptyAction": "Apri impostazioni account", - "noProviderConfigured": "Collega un provider IA per vedere qui una valutazione delle tue misurazioni.", - "noProviderAction": "Apri impostazioni IA", "personalRecord": { "badge": "PR", "tooltip": "Record personale: {value} {unit} il {date}" @@ -2569,10 +2569,6 @@ "emptyDescription": "Genera il briefing di oggi per avere un riepilogo dei tuoi trend.", "emptyAction": "Genera briefing", "loadingLabel": "Preparazione del briefing", - "noProviderTitle": "Nessun provider IA collegato", - "noProviderDescription": "Punta HealthLog verso un modello locale (Ollama, LM Studio: nulla lascia la tua rete) o usa la tua chiave OpenAI o Anthropic, e il riepilogo quotidiano trasforma le tue misurazioni in un riassunto in linguaggio chiaro. La configurazione richiede circa un minuto.", - "noProviderAction": "Apri impostazioni IA", - "staleNoProviderHint": "Questo briefing non può aggiornarsi: nessun provider IA è collegato.", "refreshFailedHint": "Impossibile aggiornare il briefing: viene mostrato l'ultimo.", "retryAction": "Riprova", "failedTitle": "Impossibile generare il briefing", @@ -2950,7 +2946,15 @@ "interrupted": { "note": "Questa risposta è stata interrotta prima di essere completata.", "retry": "Chiedi di nuovo" - } + }, + "historyReadOnlyNote": "Il Coach non è disponibile al momento. Puoi comunque leggere ed eliminare le tue conversazioni.", + "transcriptYou": "Tu", + "transcriptCoach": "Coach", + "errorConsent": "Il Coach ha bisogno del tuo consenso all'IA prima di poter rispondere. Puoi darlo in Impostazioni, IA.", + "errorNotPermitted": "Il Coach non è disponibile in una cartella che qualcuno condivide con te.", + "errorUnavailable": "Il Coach non è riuscito a controllare le sue impostazioni. Riprova tra un momento.", + "errorOperatorOff": "L'IA è disattivata su questo server, quindi il Coach non può rispondere.", + "errorCoachOff": "Il Coach è disattivato. Puoi riattivarlo in Impostazioni, Moduli." }, "relativeJustNow": "proprio ora", "relativeMinutesAgoOne": "{count} minuto fa", @@ -4383,7 +4387,15 @@ "night": "Un tratto elevato a riposo durante la notte — forse un po' di tensione." } }, - "navEcg": "ECG" + "navEcg": "ECG", + "sectionLoadError": "Non è stato possibile caricare questa sezione.", + "aiSetupHint": { + "title": "L'IA è facoltativa", + "body": "Qui tutto funziona anche senza IA. Se vuoi un riepilogo giornaliero, brevi note sulle tue misurazioni e il Coach, puoi collegare un fornitore di IA.", + "settingsBody": "HealthLog funziona completamente senza IA. Collega un fornitore qui sotto se vuoi un riepilogo giornaliero, brevi note sulle tue misurazioni e il Coach.", + "action": "Configura IA", + "dismiss": "Non ora" + } }, "thresholds": { "defaultLabel": "Predefinito", @@ -5831,12 +5843,30 @@ "activeSince": "Concesso il {date}. I tuoi dati sanitari possono essere inviati al provider configurato per le funzioni IA.", "withdrawn": "Revocato. Nessun dato sanitario viene inviato a un provider IA; le funzioni IA restano disattivate.", "withdraw": "Revoca il consenso", - "confirmBody": "Le funzioni IA si fermano subito. Nulla di quanto già registrato viene eliminato — usa Dati e privacy per esportare o cancellare il tuo archivio.", + "confirmBody": "Le funzioni di IA si fermano subito e le note che un modello ha scritto per te vengono eliminate: note di stato, il riepilogo giornaliero, i riepiloghi di periodo scritti da un modello, le righe di arrivo e le note sugli allenamenti. Le tue conversazioni con il Coach, ciò che il Coach ricordava, i tuoi piani e i riepiloghi dei tuoi documenti restano, e puoi leggerli o eliminarli tu stesso.", "confirmWithdraw": "Revoca", "regrantHint": "Concedendolo di nuovo, le funzioni IA potranno inviare ancora i tuoi dati sanitari al provider configurato.", "grant": "Concedi il consenso", "grantError": "Non è stato possibile salvare il consenso. Riprova.", - "error": "Non è stato possibile salvare la revoca. Riprova." + "error": "Non è stato possibile salvare la revoca. Riprova.", + "documentReading": { + "title": "Consenso alla lettura dei documenti", + "activeSince": "Dato il {date}. Documenti, referti di laboratorio scansionati e descrizioni di farmaci possono essere inviati al tuo servizio di IA per la lettura.", + "confirmBody": "Documenti, referti di laboratorio scansionati e descrizioni di farmaci non vengono più inviati per la lettura. Nulla di ciò che hai già viene eliminato." + } + }, + "operatorOff": { + "allTitle": "L'IA è disattivata su questo server", + "allBody": "Chi gestisce questo server ha disattivato l'IA. HealthLog continua a funzionare con tutti i tuoi dati; le funzioni che richiedono l'IA semplicemente non vengono mostrate.", + "someTitle": "Alcune funzioni di IA sono disattivate su questo server", + "someBody": "Chi gestisce questo server ha disattivato: {switches}. Tutto il resto, compresi tutti i tuoi dati, funziona come sempre.", + "testRefused": "L'IA è disattivata su questo server, quindi non c'è nulla da testare.", + "autoRead": "La lettura dei documenti con IA è disattivata su questo server." + }, + "coachConversations": { + "title": "Conversazioni con il Coach", + "description": "Apri una conversazione per leggerla oppure eliminala.", + "more": "Mostra altro" } }, "dashboard": { @@ -8801,7 +8831,8 @@ "extractFailed": "Impossibile leggere il referto. Prova con una foto più nitida.", "pdfNeedsAnthropic": "La scansione PDF richiede un provider di visione Claude; carica invece una foto.", "refTextLabel": "Intervallo come stampato", - "refTextPlaceholder": "es. 3,5 - 5,0" + "refTextPlaceholder": "es. 3,5 - 5,0", + "aiUnavailable": "La scansione di un referto con IA non è disponibile qui al momento." }, "loadMoreReadings": "Carica altre misurazioni", "documentsLink": "Documenti dei referti", @@ -9949,7 +9980,9 @@ "errorNotIndexed": "Leggi prima il documento, poi estrai.", "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." + "errorTextImageOnly": "L'OCR locale legge solo immagini, non questo file.", + "errorConsent": "La lettura dei documenti con IA richiede prima il tuo consenso.", + "errorUnavailable": "La lettura dei documenti con IA non è disponibile qui al momento." }, "summary": { "summarise": "Riassumi", @@ -10027,6 +10060,11 @@ "title": "Chatta su questo documento", "openAria": "Chiedi al Coach su questo documento", "openButton": "Chiedi al Coach" + }, + "readingConsent": { + "body": "La lettura con IA invia questo contenuto al tuo servizio di IA fuori da questo server. Consentire a HealthLog di inviare documenti, referti di laboratorio e descrizioni di farmaci per la lettura?", + "grant": "Consenti lettura", + "error": "Non è stato possibile salvare il consenso. Riprova." } }, "selfDescription": { diff --git a/messages/ko.json b/messages/ko.json index 48747e8c5..c9341f621 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -1312,7 +1312,9 @@ "rateLimit": "요청이 너무 많아요 — 잠시 후 다시 시도해 주세요.", "noProvider": "AI 제공자가 설정되지 않았어요 — 설정에서 등록해 주세요.", "budget": "오늘 AI 사용 한도를 다 썼어요 — 내일 다시 시도해 주세요.", - "network": "AI 제공자에 연결하지 못했어요 — 다시 시도해 주세요." + "network": "AI 제공자에 연결하지 못했어요 — 다시 시도해 주세요.", + "consent": "AI로 설명을 읽으려면 먼저 동의가 필요합니다.", + "unavailable": "지금 여기에서는 AI로 설명을 읽을 수 없습니다." } }, "cadence": { @@ -2444,8 +2446,6 @@ "bmiEmptyTitle": "BMI를 보려면 키를 입력해 주세요", "bmiEmptyDescription": "BMI에는 키가 필요해요. 설정 → 계정에서 추가해 주세요.", "bmiEmptyAction": "계정 설정 열기", - "noProviderConfigured": "AI 제공자를 연결하면 여기에서 측정값 평가를 볼 수 있어요.", - "noProviderAction": "AI 설정 열기", "personalRecord": { "badge": "신기록", "tooltip": "개인 최고 기록: {date} {value}{unit}" @@ -2569,10 +2569,6 @@ "emptyDescription": "오늘 브리핑을 만들면 추이를 이야기로 읽어 줘요.", "emptyAction": "브리핑 생성", "loadingLabel": "오늘 브리핑을 정리하는 중", - "noProviderTitle": "연결된 AI 제공자가 없어요", - "noProviderDescription": "HealthLog를 로컬 모델(Ollama, LM Studio — 네트워크 밖으로 나가지 않아요)에 연결하거나 직접 쓰는 OpenAI·Anthropic 키를 넣으면, 데일리 브리핑이 내 측정값을 쉬운 말 요약으로 바꿔 줘요. 설정은 1분쯤 걸려요.", - "noProviderAction": "AI 설정 열기", - "staleNoProviderHint": "연결된 AI 제공자가 없어서 이 브리핑을 새로 고칠 수 없어요.", "refreshFailedHint": "브리핑을 새로 고치지 못해서 마지막 브리핑을 보여 줘요.", "retryAction": "다시 시도", "failedTitle": "브리핑을 만들지 못했어요", @@ -2950,7 +2946,15 @@ "interrupted": { "note": "이 답변은 끝나기 전에 중단됐어요.", "retry": "다시 물어보기" - } + }, + "historyReadOnlyNote": "지금은 코치를 사용할 수 없습니다. 대화는 계속 읽고 삭제할 수 있습니다.", + "transcriptYou": "나", + "transcriptCoach": "코치", + "errorConsent": "코치가 답변하려면 AI 동의가 필요합니다. 설정의 AI에서 동의할 수 있습니다.", + "errorNotPermitted": "다른 사람이 공유한 기록 안에서는 코치를 사용할 수 없습니다.", + "errorUnavailable": "코치가 지금 설정을 확인하지 못했습니다. 잠시 후 다시 시도해 주세요.", + "errorOperatorOff": "이 서버에서 AI가 꺼져 있어 코치가 답변할 수 없습니다.", + "errorCoachOff": "코치가 꺼져 있습니다. 설정의 모듈에서 다시 켤 수 있습니다." }, "relativeJustNow": "방금", "relativeMinutesAgoOne": "{count}분 전", @@ -4383,7 +4387,15 @@ "night": "밤사이 안정 시 심박수가 높은 구간이 이어졌어요 — 긴장이 있었을 수 있어요." } }, - "navEcg": "심전도" + "navEcg": "심전도", + "sectionLoadError": "이 섹션을 불러오지 못했습니다.", + "aiSetupHint": { + "title": "AI는 선택 사항입니다", + "body": "여기의 모든 기능은 AI 없이도 작동합니다. 일일 브리핑, 측정값에 대한 짧은 메모, 코치를 원하시면 AI 제공자를 연결할 수 있습니다.", + "settingsBody": "HealthLog는 AI 없이도 모든 기능이 작동합니다. 일일 브리핑, 측정값에 대한 짧은 메모, 코치를 원하시면 아래에서 제공자를 연결하세요.", + "action": "AI 설정", + "dismiss": "나중에" + } }, "thresholds": { "defaultLabel": "기본값", @@ -5831,12 +5843,30 @@ "activeSince": "{date}에 동의했어요. AI 기능을 위해 건강 데이터가 설정한 제공자에게 전송될 수 있어요.", "withdrawn": "동의를 철회했어요. 건강 데이터를 AI 제공자에게 보내지 않고, AI 기능도 꺼진 상태로 유지돼요.", "withdraw": "동의 철회", - "confirmBody": "AI 기능이 바로 멈춰요. 이미 기록한 내용은 지워지지 않아요 — 내보내거나 삭제하려면 데이터 및 개인정보를 이용하세요.", + "confirmBody": "AI 기능이 즉시 중지되고 모델이 작성한 메모가 삭제됩니다: 상태 메모, 일일 브리핑, 모델이 작성한 기간 요약, 새 측정값 알림 문장, 운동 메모. 코치 대화, 코치가 기억한 내용, 계획, 문서 요약은 그대로 남으며 직접 읽거나 삭제할 수 있습니다.", "confirmWithdraw": "철회", "regrantHint": "다시 동의하면 AI 기능이 건강 데이터를 설정한 제공자에게 보낼 수 있어요.", "grant": "동의하기", "grantError": "동의를 저장하지 못했어요. 다시 시도해 주세요.", - "error": "철회를 저장하지 못했어요. 다시 시도해 주세요." + "error": "철회를 저장하지 못했어요. 다시 시도해 주세요.", + "documentReading": { + "title": "문서 읽기 동의", + "activeSince": "{date}에 동의함. 문서, 스캔한 검사 결과지, 약 설명이 읽기용으로 AI 서비스에 전송될 수 있습니다.", + "confirmBody": "문서, 스캔한 검사 결과지, 약 설명이 더 이상 읽기용으로 전송되지 않습니다. 이미 가지고 있는 것은 삭제되지 않습니다." + } + }, + "operatorOff": { + "allTitle": "이 서버에서 AI가 꺼져 있습니다", + "allBody": "이 서버를 운영하는 사람이 AI를 껐습니다. HealthLog는 모든 데이터로 계속 작동하며, AI가 필요한 기능만 표시되지 않습니다.", + "someTitle": "이 서버에서 일부 AI 기능이 꺼져 있습니다", + "someBody": "이 서버를 운영하는 사람이 다음을 껐습니다: {switches}. 모든 데이터를 포함한 나머지는 평소처럼 작동합니다.", + "testRefused": "이 서버에서 AI가 꺼져 있어 테스트할 대상이 없습니다.", + "autoRead": "이 서버에서 AI 문서 읽기가 꺼져 있습니다." + }, + "coachConversations": { + "title": "코치 대화", + "description": "대화를 열어 읽거나 삭제하세요.", + "more": "더 보기" } }, "dashboard": { @@ -8801,7 +8831,8 @@ "extractFailed": "결과지를 읽지 못했어요. 더 선명한 사진으로 시도해 주세요.", "pdfNeedsAnthropic": "PDF 스캔에는 Claude 비전 제공자가 필요해요. 대신 사진을 올려 주세요.", "refTextLabel": "적힌 그대로의 범위", - "refTextPlaceholder": "예: 3.5 - 5.0" + "refTextPlaceholder": "예: 3.5 - 5.0", + "aiUnavailable": "지금 여기에서는 AI로 결과지를 스캔할 수 없습니다." }, "loadMoreReadings": "측정값 더 보기", "documentsLink": "검사 결과지 문서", @@ -9949,7 +9980,9 @@ "errorNotIndexed": "문서를 먼저 읽은 뒤에 추출해 주세요.", "errorLocalOcrDisabled": "AI 설정에서 로컬 OCR을 먼저 켜 주세요.", "errorProvider": "이 작업에 쓸 AI 제공자가 설정돼 있지 않아요.", - "errorTextImageOnly": "로컬 OCR은 이미지만 읽어요 — 이 파일은 읽을 수 없어요." + "errorTextImageOnly": "로컬 OCR은 이미지만 읽어요 — 이 파일은 읽을 수 없어요.", + "errorConsent": "AI로 문서를 읽으려면 먼저 동의가 필요합니다.", + "errorUnavailable": "지금 여기에서는 AI로 문서를 읽을 수 없습니다." }, "summary": { "summarise": "요약하기", @@ -10027,6 +10060,11 @@ "title": "이 문서에 대해 대화", "openAria": "이 문서에 대해 코치에게 묻기", "openButton": "코치에게 묻기" + }, + "readingConsent": { + "body": "AI로 읽으면 이 내용이 이 서버 밖의 AI 서비스로 전송됩니다. HealthLog가 문서, 검사 결과지, 약 설명을 읽기용으로 보내도록 허용하시겠습니까?", + "grant": "읽기 허용", + "error": "동의를 저장하지 못했습니다. 다시 시도해 주세요." } }, "selfDescription": { diff --git a/messages/pl.json b/messages/pl.json index 6e60f460d..2bb11f3ad 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1312,7 +1312,9 @@ "rateLimit": "Zbyt wiele żądań — poczekaj chwilę i spróbuj ponownie.", "noProvider": "Brak skonfigurowanego dostawcy AI — skonfiguruj go w Ustawieniach.", "budget": "Osiągnięto dzienny limit AI — spróbuj jutro.", - "network": "Nie udało się połączyć z dostawcą AI — spróbuj ponownie." + "network": "Nie udało się połączyć z dostawcą AI — spróbuj ponownie.", + "consent": "Odczyt Twojego opisu przez AI wymaga najpierw Twojej zgody.", + "unavailable": "Odczyt opisów przez AI jest tu teraz niedostępny." } }, "cadence": { @@ -2444,8 +2446,6 @@ "bmiEmptyTitle": "Dodaj wzrost, aby obliczyć BMI", "bmiEmptyDescription": "BMI wymaga twojego wzrostu. Zapisz go w Ustawieniach → Konto.", "bmiEmptyAction": "Otwórz ustawienia konta", - "noProviderConfigured": "Połącz dostawcę AI, aby zobaczyć tutaj ocenę swoich pomiarów.", - "noProviderAction": "Otwórz ustawienia AI", "personalRecord": { "badge": "PR", "tooltip": "Rekord osobisty: {value} {unit} dnia {date}" @@ -2569,10 +2569,6 @@ "emptyDescription": "Wygeneruj dzisiejszy briefing, aby zobaczyć podsumowanie trendów.", "emptyAction": "Wygeneruj briefing", "loadingLabel": "Przygotowanie briefingu", - "noProviderTitle": "Brak połączonego dostawcy AI", - "noProviderDescription": "Skieruj HealthLog na model lokalny (Ollama, LM Studio — nic nie opuszcza Twojej sieci) lub podaj własny klucz OpenAI albo Anthropic, a codzienne podsumowanie zamieni Twoje pomiary w zrozumiały opis. Konfiguracja zajmuje około minuty.", - "noProviderAction": "Otwórz ustawienia AI", - "staleNoProviderHint": "Tego briefingu nie można odświeżyć — nie połączono żadnego dostawcy AI.", "refreshFailedHint": "Nie udało się odświeżyć briefingu — pokazujemy poprzedni.", "retryAction": "Spróbuj ponownie", "failedTitle": "Nie udało się wygenerować briefingu", @@ -2950,7 +2946,15 @@ "interrupted": { "note": "Ta odpowiedź została przerwana przed ukończeniem.", "retry": "Zapytaj ponownie" - } + }, + "historyReadOnlyNote": "Coach jest teraz niedostępny. Nadal możesz czytać i usuwać swoje rozmowy.", + "transcriptYou": "Ty", + "transcriptCoach": "Coach", + "errorConsent": "Coach potrzebuje Twojej zgody na AI, zanim odpowie. Możesz ją wyrazić w Ustawieniach, AI.", + "errorNotPermitted": "Coach nie jest dostępny w kartotece, którą ktoś Ci udostępnił.", + "errorUnavailable": "Coach nie mógł teraz sprawdzić swoich ustawień. Spróbuj ponownie za chwilę.", + "errorOperatorOff": "AI jest wyłączone na tym serwerze, więc Coach nie może odpowiedzieć.", + "errorCoachOff": "Coach jest wyłączony. Możesz go ponownie włączyć w Ustawieniach, Moduły." }, "relativeJustNow": "przed chwilą", "relativeMinutesAgoOne": "{count} minutę temu", @@ -4383,7 +4387,15 @@ "night": "Utrzymujący się podwyższony spoczynek w nocy — możliwe napięcie." } }, - "navEcg": "EKG" + "navEcg": "EKG", + "sectionLoadError": "Nie udało się wczytać tej sekcji.", + "aiSetupHint": { + "title": "AI jest opcjonalne", + "body": "Wszystko tutaj działa bez AI. Jeśli chcesz codzienne podsumowanie, krótkie notatki o pomiarach i Coacha, możesz podłączyć dostawcę AI.", + "settingsBody": "HealthLog działa w pełni bez AI. Podłącz dostawcę poniżej, jeśli chcesz codzienne podsumowanie, krótkie notatki o pomiarach i Coacha.", + "action": "Skonfiguruj AI", + "dismiss": "Nie teraz" + } }, "thresholds": { "defaultLabel": "Domyślne", @@ -5831,12 +5843,30 @@ "activeSince": "Udzielona {date}. Twoje dane zdrowotne mogą być wysyłane do skonfigurowanego dostawcy na potrzeby funkcji AI.", "withdrawn": "Wycofana. Żadne dane zdrowotne nie są wysyłane do dostawcy AI; funkcje AI pozostają wyłączone.", "withdraw": "Wycofaj zgodę", - "confirmBody": "Funkcje AI przestają działać natychmiast. Nic z już zapisanych danych nie zostaje usunięte — użyj sekcji Dane i prywatność, aby wyeksportować lub usunąć swoją historię.", + "confirmBody": "Funkcje AI zatrzymują się od razu, a notatki napisane dla Ciebie przez model zostają usunięte: notatki o stanie, codzienne podsumowanie, podsumowania okresu napisane przez model, linie po nowym pomiarze i notatki o treningach. Twoje rozmowy z Coachem, to, co zapamiętał Coach, Twoje plany i podsumowania dokumentów zostają, a Ty możesz je sam przeczytać lub usunąć.", "confirmWithdraw": "Wycofaj", "regrantHint": "Po ponownym udzieleniu zgody funkcje AI znów będą mogły wysyłać Twoje dane zdrowotne do skonfigurowanego dostawcy.", "grant": "Udziel zgody", "grantError": "Nie udało się zapisać zgody. Spróbuj ponownie.", - "error": "Nie udało się zapisać wycofania. Spróbuj ponownie." + "error": "Nie udało się zapisać wycofania. Spróbuj ponownie.", + "documentReading": { + "title": "Zgoda na odczyt dokumentów", + "activeSince": "Udzielona {date}. Dokumenty, zeskanowane wyniki badań i opisy leków mogą być wysyłane do Twojej usługi AI do odczytu.", + "confirmBody": "Dokumenty, zeskanowane wyniki badań i opisy leków nie są już wysyłane do odczytu. Nic, co już masz, nie zostaje usunięte." + } + }, + "operatorOff": { + "allTitle": "AI jest wyłączone na tym serwerze", + "allBody": "Osoba prowadząca ten serwer wyłączyła AI. HealthLog nadal działa ze wszystkimi Twoimi danymi; funkcje wymagające AI po prostu się nie wyświetlają.", + "someTitle": "Niektóre funkcje AI są wyłączone na tym serwerze", + "someBody": "Osoba prowadząca ten serwer wyłączyła: {switches}. Wszystko inne, w tym wszystkie Twoje dane, działa jak zwykle.", + "testRefused": "AI jest wyłączone na tym serwerze, więc nie ma czego testować.", + "autoRead": "Odczyt dokumentów przez AI jest wyłączony na tym serwerze." + }, + "coachConversations": { + "title": "Rozmowy z Coachem", + "description": "Otwórz rozmowę, aby ją przeczytać, albo ją usuń.", + "more": "Pokaż więcej" } }, "dashboard": { @@ -8801,7 +8831,8 @@ "extractFailed": "Nie udało się odczytać wyniku. Spróbuj wyraźniejszego zdjęcia.", "pdfNeedsAnthropic": "Skanowanie PDF wymaga dostawcy wizji Claude; prześlij zamiast tego zdjęcie.", "refTextLabel": "Zakres tak jak wydrukowano", - "refTextPlaceholder": "np. 3,5 - 5,0" + "refTextPlaceholder": "np. 3,5 - 5,0", + "aiUnavailable": "Skanowanie wyniku przez AI jest tu teraz niedostępne." }, "loadMoreReadings": "Wczytaj więcej pomiarów", "documentsLink": "Dokumenty wyników badań", @@ -9949,7 +9980,9 @@ "errorNotIndexed": "Najpierw odczytaj dokument, potem wyodrębnij.", "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." + "errorTextImageOnly": "Lokalne OCR odczytuje tylko obrazy, nie ten plik.", + "errorConsent": "Odczyt dokumentów przez AI wymaga najpierw Twojej zgody.", + "errorUnavailable": "Odczyt dokumentów przez AI jest tu teraz niedostępny." }, "summary": { "summarise": "Podsumuj", @@ -10027,6 +10060,11 @@ "title": "Porozmawiaj o tym dokumencie", "openAria": "Zapytaj Coacha o ten dokument", "openButton": "Zapytaj Coacha" + }, + "readingConsent": { + "body": "Odczyt przez AI wysyła to do Twojej usługi AI poza tym serwerem. Czy HealthLog może wysyłać dokumenty, wyniki badań i opisy leków do odczytu?", + "grant": "Zezwól na odczyt", + "error": "Nie udało się zapisać zgody. Spróbuj ponownie." } }, "selfDescription": { diff --git a/src/__tests__/ai-capability-surface-guard.test.ts b/src/__tests__/ai-capability-surface-guard.test.ts new file mode 100644 index 000000000..74dff38d1 --- /dev/null +++ b/src/__tests__/ai-capability-surface-guard.test.ts @@ -0,0 +1,214 @@ +/** + * Every web request to an AI route is behind the `ai` capability map, and no + * web code reads the retired switch projection. + * + * Since v1.39 the web renders every AI surface from one answer, the `ai` + * block on `GET /api/auth/me`, read through `useAiCapability`. Two ways back + * to the old behaviour are frozen here: + * + * 1. Reading `GET /api/feature-flags` (or the deleted `useFeatureFlags` + * hook). The projection knew only the operator's switches, not the + * person's opt-out, the provider or the consent, and it failed open, so + * AI chrome flashed and fired refusals before the switch set arrived. + * 2. A request to an AI route from a module nobody gates. Each such module + * is on `AI_REQUEST_SITES` with the capability that decides it and the + * file that reads that capability; the guard proves the file does read + * it. A new module that requests an AI route without an entry fails by + * name. + * + * Matchers are whitespace-tolerant (`useAiCapability ( "coach" )` counts) and + * run on comment-stripped source, so prose naming a route or the hook is not + * a hit. Each discovery asserts a non-zero count: a sweep that finds nothing + * would agree with every allowlist, so an empty match set fails instead. + * + * Its honest limit: it recognises a request by the route literal. A route + * built from pieces (`"/api/insights/" + name`) or read from a constant in + * another module would slip it; `AI_ROUTE_PATTERNS` lists the literal shapes + * the tree uses today. + */ +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import type { AiCapabilityKey } from "@/lib/ai/capabilities/types"; + +import { stripComments, walkSourceFiles } from "./helpers/source-files"; + +const SRC = path.resolve(__dirname, ".."); + +const NON_TEST_FILES = walkSourceFiles(SRC, { floor: 1500 }).filter( + (rel) => + !rel.startsWith("app/api/") && + !rel.startsWith("generated/") && + !rel.includes("__tests__/") && + !/\.test\.tsx?$/.test(rel), +); + +/** + * Web code for the request sweep: pages, components, hooks. `lib/` is left + * out of that sweep because it mixes server modules (the jobs and route + * helpers name the same routes) with client ones; the retired-projection + * check below walks it too, minus the switch loader and the API contract, + * which describe the route rather than read it. + */ +const CLIENT_FILES = NON_TEST_FILES.filter((rel) => !rel.startsWith("lib/")); + +const FEATURE_FLAG_SWEEP = NON_TEST_FILES.filter( + (rel) => + !rel.startsWith("lib/feature-flags/") && !rel.startsWith("lib/openapi/"), +); + +const read = (rel: string) => + stripComments(readFileSync(path.join(SRC, rel), "utf8")); + +/** Request literals of routes that call a model or serve model text. */ +const AI_ROUTE_PATTERNS: readonly RegExp[] = [ + /["'`]\/api\/insights\/generate["'`?]/, + /["'`]\/api\/insights\/\$\{\s*metric\s*\}-status/, + /["'`]\/api\/insights\/metric-status\?/, + /["'`]\/api\/insights\/biomarker-assessment\?/, + /["'`]\/api\/insights\/medication-compliance-status\?/, + /["'`]\/api\/insights\/coach\/nudge-status["'`]/, + /["'`]\/api\/insights\/chat["'`]/, + /["'`]\/api\/insights\/chat\/fenced["'`]/, + /["'`]\/api\/medications\/extract["'`]/, + /["'`]\/api\/labs\/ocr\/capability["'`]/, + /["'`]\/api\/documents\/inbound\/capability["'`]/, + /["'`]\/api\/ai\/test["'`]/, +]; + +interface RequestSite { + capability: AiCapabilityKey | null; + /** The file that reads the capability and keeps the request from firing. */ + gate: string; + /** Why, when `capability` is null. */ + reason?: string; +} + +/** + * Every client module that requests an AI route, keyed by path under src/. + * Adding a request site means adding it here with the capability that decides + * it; the guard then checks the gate file really reads that capability. + */ +const AI_REQUEST_SITES: Record = { + "components/insights/use-insights-advisor.ts": { + capability: "briefing", + gate: "components/insights/use-insights-advisor.ts", + }, + "components/settings/ai/runtime-actions-row.tsx": { + capability: "briefing", + gate: "components/settings/ai/runtime-actions-row.tsx", + }, + "hooks/use-insight-status.ts": { + capability: "statusText", + gate: "hooks/use-insight-status.ts", + }, + "app/insights/medications/page.tsx": { + capability: "statusText", + gate: "app/insights/medications/page.tsx", + }, + "components/insights/layout-coach-fab.tsx": { + capability: "coach", + gate: "components/insights/layout-coach-fab.tsx", + }, + "app/coach/page-client.tsx": { + capability: "coach", + gate: "app/coach/page-client.tsx", + }, + // The chat hooks run only inside the drawer, which mounts only while the + // Coach is available, and on /coach, which leaves while it is not. + "components/insights/coach-panel/use-coach.ts": { + capability: "coach", + gate: "components/insights/layout-coach-mount.tsx", + }, + "components/medications/scheduling/natural-language-extractor.tsx": { + capability: "medicationExtract", + gate: "components/medications/wizard/medication-wizard-dialog.tsx", + }, + "components/labs/use-ocr-extract.ts": { + capability: "labsOcr", + gate: "app/labs/page.tsx", + }, + "components/documents/use-document-assist.ts": { + capability: "documentAi", + gate: "components/documents/document-detail-sheet.tsx", + }, +}; + +/** Requests to AI routes that no capability decides, with the reason. */ +const UNGATED_BY_DESIGN: Record = { + // `POST /api/ai/test` checks a provider before anything can be on; the + // route answers the operator's master switch itself with a typed refusal. + "components/settings/ai/runtime-actions-row.tsx:/api/ai/test": + "the connection test is how a provider gets checked", +}; + +function readsCapability(source: string, key: AiCapabilityKey): boolean { + return new RegExp( + `useAiCapability(?:Answer)?\\s*\\(\\s*["']${key}["']\\s*\\)`, + ).test(source); +} + +describe("AI surfaces read the capability map", () => { + it("walks the web tree", () => { + expect(CLIENT_FILES.length).toBeGreaterThan(800); + }); + + it("no web code reads GET /api/feature-flags or the retired hook", () => { + expect(FEATURE_FLAG_SWEEP.length).toBeGreaterThan(1500); + const offenders = FEATURE_FLAG_SWEEP.filter((rel) => { + const src = read(rel); + return ( + /["'`]\/api\/feature-flags/.test(src) || + /["']@\/hooks\/use-feature-flags["']/.test(src) || + /\buseFeatureFlags\s*\(/.test(src) + ); + }); + expect(offenders).toEqual([]); + }); + + it("every request to an AI route comes from a registered, gated module", () => { + const discovered = CLIENT_FILES.filter((rel) => { + const src = read(rel); + return AI_ROUTE_PATTERNS.some((pattern) => pattern.test(src)); + }); + // A sweep that finds nothing agrees with every registry. + expect(discovered.length).toBeGreaterThanOrEqual(8); + + const unregistered = discovered.filter((rel) => !(rel in AI_REQUEST_SITES)); + expect( + unregistered, + "Module(s) request an AI route but are not on AI_REQUEST_SITES", + ).toEqual([]); + + const stale = Object.keys(AI_REQUEST_SITES).filter( + (rel) => !discovered.includes(rel), + ); + expect(stale, "AI_REQUEST_SITES entries that no longer request").toEqual( + [], + ); + }); + + it("each registered gate reads the capability it names", () => { + const missing = Object.entries(AI_REQUEST_SITES) + .filter(([, site]) => site.capability !== null) + .filter(([, site]) => !readsCapability(read(site.gate), site.capability!)) + .map(([rel, site]) => `${rel} -> ${site.gate} (${site.capability})`); + expect(missing).toEqual([]); + }); + + it("the ungated exceptions are real", () => { + for (const key of Object.keys(UNGATED_BY_DESIGN)) { + const [rel, route] = key.split(":"); + expect(read(rel)).toContain(route); + } + }); + + it("the account payload's ai block has web readers beyond the hook", () => { + const readers = CLIENT_FILES.filter((rel) => + /useAiCapability(?:Answer|Map)?\s*\(/.test(read(rel)), + ).filter((rel) => rel !== "hooks/use-ai-capability.ts"); + expect(readers.length).toBeGreaterThanOrEqual(20); + }); +}); diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index f1c58ba81..ab20989db 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -307,6 +307,11 @@ const PINNED_AFFORDANCES: Record< "text-success": 5, "toast.success": 1, }, + // The delete-with-undo toast, the same one the conversation list and the + // history rail raise for the same delete. + "src/components/settings/coach-conversations-memory-card.tsx": { + "toast.success": 1, + }, "src/components/settings/coach-memory-section.tsx": { "toast.success": 2 }, "src/components/settings/coach-prefs-section.tsx": { "toast.success": 1 }, "src/components/settings/dashboard-layout-section.tsx": { diff --git a/src/__tests__/v1341-locale-parity.test.ts b/src/__tests__/v1341-locale-parity.test.ts index 10d6e7733..bdd0570da 100644 --- a/src/__tests__/v1341-locale-parity.test.ts +++ b/src/__tests__/v1341-locale-parity.test.ts @@ -26,8 +26,6 @@ const REQUIRED_KEYS = [ // Existing terminal contracts reused by the final v1.34.1 call sites. "insights.assessmentPreparing", "insights.noAnalysisYet", - "insights.noProviderConfigured", - "insights.noProviderAction", "common.loadFailed", "common.networkError", "settings.googleHealthSyncResult", diff --git a/src/hooks/__tests__/use-feature-flags.test.tsx b/src/hooks/__tests__/use-feature-flags.test.tsx deleted file mode 100644 index 150e1a8ed..000000000 --- a/src/hooks/__tests__/use-feature-flags.test.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import React from "react"; -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -import { DEFAULT_ASSISTANT_FLAGS, useFeatureFlags } from "../use-feature-flags"; - -function Probe() { - const flags = useFeatureFlags(); - return
{JSON.stringify(flags)}
; -} - -function htmlEscape(s: string): string { - return s.replace(/"/g, """); -} - -describe("useFeatureFlags", () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("returns the all-on default when no QueryClient is mounted", () => { - const html = renderToStaticMarkup(); - expect(html).toContain(htmlEscape(JSON.stringify(DEFAULT_ASSISTANT_FLAGS))); - }); - - it("returns the all-on default while the query is still loading", () => { - const client = new QueryClient({ - defaultOptions: { queries: { retry: 0 } }, - }); - const html = renderToStaticMarkup( - - - , - ); - expect(html).toContain(htmlEscape(JSON.stringify(DEFAULT_ASSISTANT_FLAGS))); - }); - - it("returns the resolved matrix when the endpoint responds", async () => { - vi.stubGlobal( - "fetch", - vi.fn( - async () => - new Response( - JSON.stringify({ - data: { - assistant: { - enabled: true, - coach: false, - briefing: true, - insightStatus: false, - documentAi: true, - }, - }, - }), - { status: 200 }, - ), - ), - ); - - const client = new QueryClient({ - defaultOptions: { queries: { retry: 0 } }, - }); - // Pre-populate so the SSR pass surfaces the cached payload - // synchronously without a fetch round-trip. - client.setQueryData(["feature-flags"], { - assistant: { - enabled: true, - coach: false, - briefing: true, - insightStatus: false, - documentAi: true, - }, - }); - - const html = renderToStaticMarkup( - - - , - ); - expect(html).toContain(""coach":false"); - expect(html).toContain(""insightStatus":false"); - }); -}); diff --git a/src/hooks/use-ai-capability.ts b/src/hooks/use-ai-capability.ts index b4c13d094..fa8721c30 100644 --- a/src/hooks/use-ai-capability.ts +++ b/src/hooks/use-ai-capability.ts @@ -63,3 +63,48 @@ function useAiProviderStateInner(): AiProviderState { const { user } = useAuth(); return user?.ai?.provider ?? NO_PROVIDER_STATE; } + +/** + * The server's answer for one capability, or `null` while there is none yet + * (`/me` loading, no signed-in account, no query client). For the few places + * that act on an unavailable capability rather than just hiding: a Coach page + * that sends its visitor elsewhere must not do so on the loading frame, when + * `useAiCapability` already reads unavailable. + */ +export function useAiCapabilityAnswer( + key: AiCapabilityKey, +): AiCapabilityState | null { + const hasClient = useQueryClientMounted(); + if (!hasClient) return null; + // eslint-disable-next-line react-hooks/rules-of-hooks + return useAiCapabilityAnswerInner(key); +} + +function useAiCapabilityAnswerInner( + key: AiCapabilityKey, +): AiCapabilityState | null { + const { user, isLoading } = useAuth(); + if (isLoading || !user) return null; + return user.ai?.capabilities?.[key] ?? UNKNOWN; +} + +/** + * Every capability at once, or `null` until `/me` has answered. For the one + * surface that explains the whole set (the operator notice in Settings → AI); + * a surface that shows or hides one feature reads `useAiCapability`. + */ +export function useAiCapabilityMap(): Partial< + Record +> | null { + const hasClient = useQueryClientMounted(); + if (!hasClient) return null; + // eslint-disable-next-line react-hooks/rules-of-hooks + return useAiCapabilityMapInner(); +} + +function useAiCapabilityMapInner(): Partial< + Record +> | null { + const { user } = useAuth(); + return user?.ai?.capabilities ?? null; +} diff --git a/src/hooks/use-feature-flags.ts b/src/hooks/use-feature-flags.ts deleted file mode 100644 index 33880d0d4..000000000 --- a/src/hooks/use-feature-flags.ts +++ /dev/null @@ -1,104 +0,0 @@ -"use client"; - -import { useQuery } from "@tanstack/react-query"; - -import { queryKeys } from "@/lib/query-keys"; -import { apiFetchRaw } from "@/lib/api/api-fetch"; -import { useQueryClientMounted } from "@/hooks/_internal/use-query-client-safe"; - -/** - * v1.4.31 — Client-side accessor for the operator's assistant - * feature-flag matrix. Mirrors `GET /api/feature-flags`'s response - * shape directly so callers can rely on a typed structure. - * - * The matrix gates every assistant-driven surface on the web — - * Coach + chat, Daily Briefing, per-metric status notes, reading - * documents. Deprecated: `ai` on `GET /api/auth/me` (read through - * `useAiCapability`) is the resolved answer per capability. iOS reads the same - * endpoint and uses the same matrix; the contract is locked per - * `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5. - */ - -export interface AssistantFlagSet { - /** Master kill-switch — when false, every sub-flag is forced false. */ - enabled: boolean; - /** Coach drawer, chat SSE, history rail, FAB. */ - coach: boolean; - /** Daily Briefing card + advisor recommendations + regen icon. */ - briefing: boolean; - /** Per-metric status cards on every `/insights/` sub-page. */ - insightStatus: boolean; - /** Document vault reads, lab report scans, medication text extraction. */ - documentAi: boolean; -} - -interface FeatureFlagsPayload { - assistant: AssistantFlagSet; -} - -/** All-on default — matches the v1.4.30 behaviour for fresh installs. */ -export const DEFAULT_ASSISTANT_FLAGS: AssistantFlagSet = Object.freeze({ - enabled: true, - coach: true, - briefing: true, - insightStatus: true, - documentAi: true, -}); - -async function fetchFeatureFlags(): Promise { - // `apiFetchRaw` (no .ok throw) — this read soft-fails to the all-on - // default below instead of surfacing an error state. - const res = await apiFetchRaw("/api/feature-flags"); - if (!res.ok) { - // Soft-fail: any HTTP error returns the all-on default so the - // surface continues to render. The endpoint is a non-critical - // metadata read; a 5xx must not turn into a "Coach hidden" - // false negative for the user. - return { assistant: { ...DEFAULT_ASSISTANT_FLAGS } }; - } - const json = (await res.json()) as { data: FeatureFlagsPayload }; - return json.data; -} - -/** - * Read the operator's assistant feature-flag matrix. - * - * `staleTime: 60_000` matches the server-side cache header so the - * hot /insights mount path doesn't re-fetch the matrix on every - * remount within a minute. The hook fails open — any network error - * returns the all-on default so the user keeps seeing the surface. - * - * The hook is referenced from presentational components such as - * `` whose unit tests render without a - * ``. To keep those tests valid we delegate - * the React-Query call to a child hook that's only invoked when a - * client is mounted. The branch is stable across the component's - * lifetime — `QueryClientProvider` placement never flips between - * renders — so the conditional hook call is safe per the Rules of - * Hooks (same posture the React docs document for "different - * environments where the same component renders"). - * - * v1.4.48 M4 — the SSR-mount probe is shared with `useDisableCoach` - * via `_internal/use-query-client-safe` so the eslint-disable line - * stays in exactly one place per consumer. - */ -export function useFeatureFlags(): AssistantFlagSet { - const hasClient = useQueryClientMounted(); - if (!hasClient) return DEFAULT_ASSISTANT_FLAGS; - // eslint-disable-next-line react-hooks/rules-of-hooks - return useFeatureFlagsQuery(); -} - -function useFeatureFlagsQuery(): AssistantFlagSet { - const query = useQuery({ - queryKey: queryKeys.featureFlags(), - queryFn: fetchFeatureFlags, - staleTime: 60_000, - gcTime: 300_000, - retry: 0, - }); - - // While loading or on error, default to all-on so the assistant - // surfaces don't flicker out and back in. - return query.data?.assistant ?? DEFAULT_ASSISTANT_FLAGS; -} diff --git a/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts b/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts index 1a6cdd615..b80adf677 100644 --- a/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts +++ b/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts @@ -88,7 +88,6 @@ const RETIRED_SWITCHES: RetiredSwitch[] = [ */ const PLUMBING = [ "src/lib/feature-flags/index.ts", - "src/hooks/use-feature-flags.ts", "src/components/admin/assistant-section.tsx", "src/app/api/feature-flags/route.ts", "src/app/api/admin/settings/assistant-flags/route.ts",