From 1e2b1d2c6a7328b2e7a54da0d913fa308462fe24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 9 Jul 2026 13:41:59 +0200 Subject: [PATCH 1/3] fix(ai): treat gpt-5.x-codex slugs as vision-capable The document/lab-OCR vision gate excluded every `-codex` slug from the GPT-5.x line as text-only. That is stale: the `codex/responses` wire reads an `input_image` block on these slugs (docs/codex-protocol-spec.md plus the current model lineup), and the 5.2-Codex line is vision-tuned. An operator pinned to a `-codex` slug therefore resolved no vision-capable provider and hit a 422 "No vision-capable AI provider is configured" when reading a document with AI. Drop the `-codex` exclusion from the gpt-5 branch so a codex-only chain resolves a document-vision provider; keep the `-mini`/`-preview` reasoning-slug guard. Lab-OCR on a `-codex` pin gains vision the same way. --- .../ai/__tests__/vision-capability.test.ts | 12 ++++++++---- src/lib/ai/vision-capability.ts | 19 ++++++++++++------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/lib/ai/__tests__/vision-capability.test.ts b/src/lib/ai/__tests__/vision-capability.test.ts index ec2fbb395..982410f8a 100644 --- a/src/lib/ai/__tests__/vision-capability.test.ts +++ b/src/lib/ai/__tests__/vision-capability.test.ts @@ -71,7 +71,7 @@ describe("supportsVisionForConfig", () => { expect(supportsVisionForConfig("local", null)).toBe(true); }); - it("reports vision for codex only on multimodal slugs", () => { + it("reports vision for codex on multimodal slugs, including -codex", () => { for (const model of [ "gpt-5.5", "gpt-5.4", @@ -79,6 +79,13 @@ describe("supportsVisionForConfig", () => { "gpt-4o", "gpt-4.1", "o1", + // The GPT-5.x `-codex` specialists are vision-capable: the wire reads + // an `input_image` block on these slugs (docs/codex-protocol-spec.md + + // OpenAI's current model lineup). The old "-codex is text-only" + // exclusion was stale. + "gpt-5-codex", + "gpt-5.3-codex", + "gpt-5.1-codex-mini", ]) { expect(supportsVisionForConfig("codex", model)).toBe(true); } @@ -86,9 +93,6 @@ describe("supportsVisionForConfig", () => { expect(supportsVisionForConfig("codex", "gpt-4")).toBe(false); expect(supportsVisionForConfig("codex", "gpt-3.5-turbo")).toBe(false); expect(supportsVisionForConfig("codex", null)).toBe(false); - // The `-codex` specialist slugs are text-only despite the gpt-5 prefix. - expect(supportsVisionForConfig("codex", "gpt-5-codex")).toBe(false); - expect(supportsVisionForConfig("codex", "gpt-5.1-codex-mini")).toBe(false); // Text-only o-series variants are excluded for codex too. expect(supportsVisionForConfig("codex", "o1-mini")).toBe(false); expect(supportsVisionForConfig("codex", "o3-mini")).toBe(false); diff --git a/src/lib/ai/vision-capability.ts b/src/lib/ai/vision-capability.ts index baea4da03..32edfa108 100644 --- a/src/lib/ai/vision-capability.ts +++ b/src/lib/ai/vision-capability.ts @@ -73,9 +73,10 @@ function openaiSupportsVision(model: string): boolean { /** * Codex (ChatGPT-OAuth) vision models. Image INPUT is reachable over the * `codex/responses` endpoint (docs/codex-protocol-spec.md §2b) on a multimodal - * slug — no API key needed. The accepted GPT-5.x line is text+vision; the older - * `-codex` specialist slugs and the legacy `gpt-4`-class non-multimodal pins - * are text-only. The slug we test is the codex client's resolved working slug + * slug — no API key needed. The whole GPT-5.x line is text+vision, including + * the `-codex` specialist slugs (gpt-5.x-codex); only the legacy `gpt-4`-class + * non-multimodal pins are text-only. The slug we test is the codex client's + * resolved working slug * (cached or chain head), so a slug rotation onto a non-vision model degrades * to false rather than 400-ing the user at the image block. * @@ -85,10 +86,14 @@ function openaiSupportsVision(model: string): boolean { function codexSupportsVision(model: string): boolean { const m = model.toLowerCase(); return ( - // GPT-5.x line is multimodal (text + vision) — but the `-codex` specialist - // slugs (e.g. gpt-5-codex) are text-only, so they must not claim vision or - // every lab-OCR upload burns a doomed image attempt before falling back. - (m.startsWith("gpt-5") && !m.includes("-codex")) || + // GPT-5.x line is multimodal (text + vision) — INCLUDING the `-codex` + // specialist slugs (gpt-5.x-codex), which gained vision in the 5.2-Codex + // line (screenshots / diagrams / charts). Verified against + // docs/codex-protocol-spec.md (an `input_image` block on a gpt-5-codex + // request) plus OpenAI's current model lineup. The historical + // "-codex is text-only" exclusion was stale — the wire reads images on + // these slugs, so lab-OCR and document reads both succeed on a `-codex` pin. + m.startsWith("gpt-5") || m.startsWith("gpt-4o") || m.startsWith("gpt-4.1") || m.startsWith("gpt-4-turbo") || From 97f8cb430388a42be8bdefa64dd4955420b80230 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 9 Jul 2026 13:42:14 +0200 Subject: [PATCH 2/3] fix(ui): trim document-read copy and de-duplicate recovery headings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the two per-document AI copy blocks in the document detail sheet: the external-egress notice and the "read this document" explainer. The settings-toggle honesty confirm already states the egress trade, so the per-document repeat is redundant. Drop the now-unused `egressExternal` prop and the `documents.ai.egressNotice` + `documents.ai.available` keys across all six locales. Keep the capability `egress` DTO field — the API still reports it. On /insights/recovery, the metric block rendered an outer heading plus an explainer above a tile whose own header repeated the same title. Move the explainer inside the tile as a `description` beneath its `TileHeader` and promote the tile title to a real `

` (new `titleAs` affordance), so each metric shows one heading with the blurb under it and the h1->h2 outline stays nested. Applies to every metric the block renders. --- e2e/documents-auto-read.spec.ts | 23 +++++++------ messages/de.json | 2 -- messages/en.json | 2 -- messages/es.json | 2 -- messages/fr.json | 2 -- messages/it.json | 2 -- messages/pl.json | 2 -- .../__tests__/document-ai-section.test.tsx | 21 ++++-------- .../documents/document-ai-section.tsx | 24 +------------ .../documents/document-detail-sheet.tsx | 1 - src/components/insights/metric-stat-strip.tsx | 34 +++++++++++++++++-- .../recovery/recovery-metric-block.tsx | 24 +++++-------- src/components/insights/tile-header.tsx | 34 +++++++++++++++---- 13 files changed, 88 insertions(+), 85 deletions(-) diff --git a/e2e/documents-auto-read.spec.ts b/e2e/documents-auto-read.spec.ts index bce3a9870..db4da6e05 100644 --- a/e2e/documents-auto-read.spec.ts +++ b/e2e/documents-auto-read.spec.ts @@ -171,20 +171,19 @@ test.describe("automatic AI reading — vault per-document contract", () => { test.beforeEach(() => test.slow()); - test("the vault shows the per-egress notice once and the explicit read action", async ({ + test("the vault shows the explicit read action for an external provider", async ({ page, }) => { - // External egress (the codex/OAuth or any non-local provider case). + // External egress (the codex/OAuth or any non-local provider case). The + // per-document egress notice was retired — the settings-toggle honesty + // confirm already covers the egress trade — so no per-document notice + // renders here regardless of egress mode. await mockDocumentCapability(page, { egress: "external" }); await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`); const sheet = page.getByRole("dialog"); await expect(sheet).toBeVisible(); - // The vendor-blind "leaves your machine" notice is shown exactly once. - await expect( - sheet.locator('[data-slot="document-ai-egress-notice"]'), - ).toHaveCount(1); // The per-document read is the explicit path — the action is present and the // user must tap it (auto-read never removes the affordance, only the tap). await expect(sheet.locator('[data-slot="document-read-ai"]')).toBeVisible(); @@ -218,8 +217,12 @@ test.describe("automatic AI reading — vault per-document contract", () => { expect(indexCalls).toBe(1); }); - test("a local-egress read shows no external notice", async ({ page }) => { - // A self-hosted local model never leaves the machine — no egress notice. + test("a local-egress provider shows the explicit read action", async ({ + page, + }) => { + // A self-hosted local model never leaves the machine; the read action is + // offered the same way as for an external provider (no per-document notice + // in either mode after the notice was retired). await mockDocumentCapability(page, { egress: "local", pdfSupported: false, @@ -229,8 +232,6 @@ test.describe("automatic AI reading — vault per-document contract", () => { const sheet = page.getByRole("dialog"); await expect(sheet).toBeVisible(); - await expect( - sheet.locator('[data-slot="document-ai-egress-notice"]'), - ).toHaveCount(0); + await expect(sheet.locator('[data-slot="document-read-ai"]')).toBeVisible(); }); }); diff --git a/messages/de.json b/messages/de.json index 65d74b3dc..e1f8fa227 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8226,9 +8226,7 @@ "close": "Ausblenden" }, "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.", "blockTitle": "Mit KI auslesen", - "available": "Lass die KI dieses Dokument lesen — Titel, Art und Datum vorschlagen, zusammenfassen oder den ganzen Text durchsuchbar machen.", "read": "Mit KI auslesen", "reread": "Erneut lesen", "reading": "Liest…", diff --git a/messages/en.json b/messages/en.json index c7fac3b9b..1be224582 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8226,9 +8226,7 @@ "close": "Hide" }, "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.", "blockTitle": "Read with AI", - "available": "Let the AI read this document — suggest a title, type and date, summarise it, or make its full text searchable.", "read": "Read with AI", "reread": "Read again", "reading": "Reading…", diff --git a/messages/es.json b/messages/es.json index 17567f082..045b0f084 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8226,9 +8226,7 @@ "close": "Ocultar" }, "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í.", "blockTitle": "Leer con IA", - "available": "Deja que la IA lea este documento: sugerir un título, tipo y fecha, resumirlo o hacer que todo su texto sea buscable.", "read": "Leer con IA", "reread": "Leer de nuevo", "reading": "Leyendo…", diff --git a/messages/fr.json b/messages/fr.json index b15587733..5e175ac5b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8226,9 +8226,7 @@ "close": "Masquer" }, "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.", "blockTitle": "Lire avec l’IA", - "available": "Laissez l’IA lire ce document — suggérer un titre, un type et une date, le résumer ou rendre tout son texte consultable.", "read": "Lire avec l’IA", "reread": "Relire", "reading": "Lecture…", diff --git a/messages/it.json b/messages/it.json index 71a0a2c87..d0c5f9786 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8226,9 +8226,7 @@ "close": "Nascondi" }, "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ì.", "blockTitle": "Leggi con l’IA", - "available": "Lascia che l’IA legga questo documento: suggerire un titolo, un tipo e una data, riassumerlo o rendere ricercabile tutto il testo.", "read": "Leggi con l’IA", "reread": "Leggi di nuovo", "reading": "Lettura…", diff --git a/messages/pl.json b/messages/pl.json index ad032cec2..0d7643685 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8226,9 +8226,7 @@ "close": "Ukryj" }, "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.", "blockTitle": "Odczytaj z AI", - "available": "Pozwól AI odczytać ten dokument — zaproponować tytuł, typ i datę, streścić go lub udostępnić cały tekst do wyszukiwania.", "read": "Odczytaj z AI", "reread": "Odczytaj ponownie", "reading": "Odczytywanie…", diff --git a/src/components/documents/__tests__/document-ai-section.test.tsx b/src/components/documents/__tests__/document-ai-section.test.tsx index 6a718d754..0a9a307ce 100644 --- a/src/components/documents/__tests__/document-ai-section.test.tsx +++ b/src/components/documents/__tests__/document-ai-section.test.tsx @@ -34,7 +34,6 @@ function base( aiEnabled: true, indexEnabled: true, unavailableReason: null, - egressExternal: false, actionsDisabled: false, onSuggest: noop, suggestPending: false, @@ -143,20 +142,14 @@ 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 })); + it("renders no per-document egress notice — the settings confirm covers it", () => { + // The per-document egress notice was retired; the settings-toggle honesty + // confirm is now the single place the egress trade is stated. + const html = render(base({ aiEnabled: true })); expect(html).not.toContain('data-slot="document-ai-egress-notice"'); + // The actions are still offered. + expect(html).toContain('data-slot="assist-suggest"'); + expect(html).toContain('data-slot="document-read-ai"'); }); it("hides the Read-with-AI action when indexing is unavailable but keeps suggest", () => { diff --git a/src/components/documents/document-ai-section.tsx b/src/components/documents/document-ai-section.tsx index a6aad46f4..c3d8368bd 100644 --- a/src/components/documents/document-ai-section.tsx +++ b/src/components/documents/document-ai-section.tsx @@ -22,7 +22,7 @@ * status pill stays, honestly reflecting any local auto-index, and the manual * form below stays fully usable. */ -import { FileText, ScanText, ShieldAlert, WandSparkles } from "lucide-react"; +import { FileText, ScanText, WandSparkles } from "lucide-react"; import type { ReactNode } from "react"; import { Button } from "@/components/ui/button"; @@ -47,7 +47,6 @@ export function DocumentAiSection({ aiEnabled, indexEnabled, unavailableReason, - egressExternal, actionsDisabled, onSuggest, suggestPending, @@ -75,12 +74,6 @@ 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; @@ -143,23 +136,8 @@ export function DocumentAiSection({ /> - {egressExternal ? ( -
- -

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

-
- ) : null} - {aiEnabled ? ( <> -

- {t("documents.ai.available")} -

-
{indexEnabled ? (