diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 756279cdb..ee746e936 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -8905,6 +8905,27 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 + /api/documents/inbound/capability: + get: + tags: + - Documents + summary: Probe document-AI availability + egress + description: "Cheap probe (no provider call) the vault uses to decide the AI transport and whether to warn before a read + leaves the machine. Resolved over the DOCUMENT provider order (local-first, ChatGPT-subscription OAuth last), so + `mode` / `pdfSupported` / `egress` match exactly what the document AI routes do. `egress` is vendor-blind: + `\"local\"` (a self-hosted model — the document never leaves the operator's machine) or `\"external\"` (a + third-party AI service). The vault shows a per-egress notice before any external document read; sending a + document to any external provider also requires an active AI-consent receipt." + responses: + "200": + description: Document-AI capability flags + egress class. + content: + application/json: + schema: + $ref: "#/components/schemas/DocumentAiCapabilityEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 /api/documents/inbound/usage: get: tags: @@ -26253,6 +26274,58 @@ components: - data - error additionalProperties: false + DocumentAiCapabilityEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DocumentAiCapability" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DocumentAiCapability: + type: object + properties: + available: + type: boolean + mode: + anyOf: + - type: string + enum: + - vision + - text + - type: "null" + reason: + anyOf: + - type: string + enum: + - no-provider + - enable-local-ocr + - type: "null" + pdfSupported: + type: boolean + egress: + anyOf: + - type: string + enum: + - local + - external + - type: "null" + required: + - available + - mode + - reason + - pdfSupported + - egress + additionalProperties: false DocumentUsageEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index 5073c6e7b..63950f325 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8172,6 +8172,9 @@ "searchPlaceholder": "Nach Titel oder Dateiname suchen", "empty": "Keine Dokumente gefunden." }, + "ai": { + "egressNotice": "Dies sendet den Inhalt des Dokuments an deinen konfigurierten KI-Anbieter — es verlässt dieses Gerät zu einem KI-Dienst eines Drittanbieters. Ist der Anbieter ein persönliches Abonnement, gelten dessen eigene Datenschutz-Einstellungen, einschließlich Modelltraining, sofern du es dort nicht deaktiviert hast." + }, "assist": { "suggest": "Details vorschlagen", "suggesting": "Wird gelesen…", diff --git a/messages/en.json b/messages/en.json index e85e5c5fc..d5beaecab 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8172,6 +8172,9 @@ "searchPlaceholder": "Search by title or filename", "empty": "No documents found." }, + "ai": { + "egressNotice": "This sends the document's contents to your configured AI provider — it leaves this machine to a third-party AI service. If that provider is a personal subscription plan, its own data-use settings apply, including model training unless you have turned it off there." + }, "assist": { "suggest": "Suggest details", "suggesting": "Reading…", diff --git a/messages/es.json b/messages/es.json index 80d3c7b3c..ab25f0d11 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8172,6 +8172,9 @@ "searchPlaceholder": "Buscar por título o nombre de archivo", "empty": "No se encontraron documentos." }, + "ai": { + "egressNotice": "Esto envía el contenido del documento a tu proveedor de IA configurado — sale de esta máquina hacia un servicio de IA de terceros. Si ese proveedor es una suscripción personal, se aplican sus propios ajustes de uso de datos, incluido el entrenamiento del modelo salvo que lo hayas desactivado allí." + }, "assist": { "suggest": "Sugerir detalles", "suggesting": "Leyendo…", diff --git a/messages/fr.json b/messages/fr.json index 5bd017305..ed827b2c2 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8172,6 +8172,9 @@ "searchPlaceholder": "Rechercher par titre ou nom de fichier", "empty": "Aucun document trouvé." }, + "ai": { + "egressNotice": "Ceci envoie le contenu du document à votre fournisseur d'IA configuré — il quitte cette machine vers un service d'IA tiers. Si ce fournisseur est un abonnement personnel, ses propres paramètres d'utilisation des données s'appliquent, y compris l'entraînement du modèle sauf si vous l'avez désactivé là-bas." + }, "assist": { "suggest": "Suggérer des détails", "suggesting": "Lecture…", diff --git a/messages/it.json b/messages/it.json index 933289bb6..d1b386dea 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8172,6 +8172,9 @@ "searchPlaceholder": "Cerca per titolo o nome del file", "empty": "Nessun documento trovato." }, + "ai": { + "egressNotice": "Questo invia il contenuto del documento al provider IA configurato — lascia questa macchina verso un servizio IA di terze parti. Se quel provider è un abbonamento personale, si applicano le sue impostazioni sull'uso dei dati, incluso l'addestramento del modello a meno che tu non l'abbia disattivato lì." + }, "assist": { "suggest": "Suggerisci dettagli", "suggesting": "Lettura…", diff --git a/messages/pl.json b/messages/pl.json index fc43f1599..d36997efc 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8172,6 +8172,9 @@ "searchPlaceholder": "Szukaj po tytule lub nazwie pliku", "empty": "Nie znaleziono dokumentów." }, + "ai": { + "egressNotice": "To wysyła treść dokumentu do skonfigurowanego dostawcy AI — opuszcza ona to urządzenie i trafia do zewnętrznej usługi AI. Jeśli ten dostawca to subskrypcja osobista, obowiązują jego własne ustawienia wykorzystania danych, w tym trenowanie modelu, o ile nie wyłączysz go tam." + }, "assist": { "suggest": "Zaproponuj szczegóły", "suggesting": "Odczytywanie…", diff --git a/src/app/api/documents/inbound/capability/route.ts b/src/app/api/documents/inbound/capability/route.ts new file mode 100644 index 000000000..54e694882 --- /dev/null +++ b/src/app/api/documents/inbound/capability/route.ts @@ -0,0 +1,42 @@ +/** + * GET /api/documents/inbound/capability + * + * Document-scoped AI capability probe (no provider call). Mirrors the labs + * `/api/labs/ocr/capability` probe but resolves over the DOCUMENT provider + * order (local-first, codex last — governance fix, oauth-investigation + * SYNTHESIS §1) and adds the vendor-blind `egress` class so the vault UI can + * show the "this leaves your machine to a third-party AI" notice BEFORE a read. + * + * The `mode` / `pdfSupported` / `egress` here reflect exactly what the document + * routes (suggest / summary / extract / index) will do, so the affordance the + * UI offers never diverges from what the endpoint accepts. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { resolveDocumentAiCapability } from "@/lib/documents/provider-order"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const gate = await requireModuleEnabled(user.id, "inboundDocuments"); + if (!gate.enabled) return gate.response; + + const capability = await resolveDocumentAiCapability(user.id); + + annotate({ + action: { name: "documents.ai.capability" }, + meta: { + available: capability.available, + mode: capability.mode, + reason: capability.reason, + pdfSupported: capability.pdfSupported, + egress: capability.egress, + }, + }); + + return apiSuccess(capability); +}); diff --git a/src/components/documents/__tests__/document-ai-section.test.tsx b/src/components/documents/__tests__/document-ai-section.test.tsx index 9a304f2f3..9c3def4d2 100644 --- a/src/components/documents/__tests__/document-ai-section.test.tsx +++ b/src/components/documents/__tests__/document-ai-section.test.tsx @@ -33,6 +33,7 @@ function base( aiEnabled: true, indexEnabled: true, unavailableReason: null, + egressExternal: false, actionsDisabled: false, onSuggest: noop, suggestPending: false, @@ -106,6 +107,22 @@ describe("", () => { expect(html).toContain("Not saved"); }); + it("shows the vendor-blind egress notice before an external document read", () => { + const html = render(base({ aiEnabled: true, egressExternal: true })); + expect(html).toContain('data-slot="document-ai-egress-notice"'); + expect(html).toContain("third-party AI service"); + // Vendor-blind: never names a specific AI vendor in the document surface. + expect(html).not.toContain("OpenAI"); + expect(html).not.toContain("ChatGPT"); + // The actions are still offered — the notice informs, it does not block. + expect(html).toContain('data-slot="assist-suggest"'); + }); + + it("omits the egress notice when the read stays on the local machine", () => { + const html = render(base({ aiEnabled: true, egressExternal: false })); + expect(html).not.toContain('data-slot="document-ai-egress-notice"'); + }); + 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"'); diff --git a/src/components/documents/document-ai-section.tsx b/src/components/documents/document-ai-section.tsx index 10070d160..1ffef4e02 100644 --- a/src/components/documents/document-ai-section.tsx +++ b/src/components/documents/document-ai-section.tsx @@ -11,7 +11,7 @@ * 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 { FileText, ShieldAlert, Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useTranslations } from "@/lib/i18n/context"; @@ -32,6 +32,7 @@ export function DocumentAiSection({ aiEnabled, indexEnabled, unavailableReason, + egressExternal, actionsDisabled, onSuggest, suggestPending, @@ -57,6 +58,12 @@ export function DocumentAiSection({ aiEnabled: boolean; indexEnabled: boolean; unavailableReason: "no-provider" | "enable-local-ocr" | null; + /** + * True when a document read will egress to a third-party AI service (any + * provider other than a self-hosted local model). Drives the vendor-blind + * "this leaves your machine" notice shown before the AI actions. + */ + egressExternal: boolean; /** Capability still resolving — the transport mode isn't known yet. */ actionsDisabled: boolean; onSuggest: () => void; @@ -92,6 +99,17 @@ export function DocumentAiSection({ return (
+ {egressExternal ? ( +
+ +

{t("documents.ai.egressNotice")}

+
+ ) : null} +