mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #452 from MBombeck/release/v1.28.8
release/v1.28.8 — documents automatic when set, cleaner when not
This commit is contained in:
19 files changed
+179
-89
No files matched your search
@@ -2,6 +2,14 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.28.8] — 2026-07-09 — Documents: automatic when set, cleaner when not
|
||||
|
||||
With automatic AI reading turned on, a document is read on upload — so the
|
||||
per-document AI action buttons no longer appear (nothing to press). They return
|
||||
when automatic reading is off, for manual per-document control. The AI-suggestion
|
||||
review also drops its heading and grey sub-caption — just the fields to review
|
||||
and apply.
|
||||
|
||||
## [1.28.7] — 2026-07-09 — Scanned-PDF reading actually works
|
||||
|
||||
The prior fix made the PDF image library loadable, but the page renderer was
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.28.7
|
||||
version: 1.28.8
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
|
||||
@@ -110,6 +110,13 @@ async function mockAiEnabled(
|
||||
error: null,
|
||||
}),
|
||||
);
|
||||
// The detail sheet reads the auto-AI-read opt-in to decide whether to show
|
||||
// the manual per-document action row. Default it OFF here so the manual
|
||||
// Read / Suggest / Summarise actions render for these assertions; an unmocked
|
||||
// fetch would leave the sheet reading a live value.
|
||||
await page.route("**/api/auth/me/documents-auto-ai-read", (route) =>
|
||||
fulfilJson(route, { data: { documentsAutoAiRead: false }, error: null }),
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("document vault — AI assist + content search", () => {
|
||||
@@ -172,9 +179,6 @@ test.describe("document vault — AI assist + content search", () => {
|
||||
|
||||
const review = sheet.locator('[data-slot="assist-suggestion-review"]');
|
||||
await expect(review).toBeVisible();
|
||||
await expect(
|
||||
review.getByText("AI suggestion — review before saving"),
|
||||
).toBeVisible();
|
||||
|
||||
// Applying the title seeds the EDITABLE field only — no write yet.
|
||||
await review.getByRole("button", { name: "Use title" }).click();
|
||||
|
||||
@@ -44,9 +44,21 @@ async function fulfilJson(
|
||||
*/
|
||||
async function mockDocumentCapability(
|
||||
page: Page,
|
||||
opts: { egress: "local" | "external"; pdfSupported?: boolean },
|
||||
opts: {
|
||||
egress: "local" | "external";
|
||||
pdfSupported?: boolean;
|
||||
/** The per-user auto-AI-read opt-in. OFF keeps the manual action row. */
|
||||
autoRead?: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
const pdfSupported = opts.pdfSupported ?? true;
|
||||
const autoRead = opts.autoRead ?? false;
|
||||
await page.route("**/api/auth/me/documents-auto-ai-read", (route) =>
|
||||
fulfilJson(route, {
|
||||
data: { documentsAutoAiRead: autoRead },
|
||||
error: null,
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/documents/inbound/usage", (route) =>
|
||||
fulfilJson(route, {
|
||||
data: {
|
||||
@@ -184,8 +196,9 @@ test.describe("automatic AI reading — vault per-document contract", () => {
|
||||
const sheet = page.getByRole("dialog");
|
||||
await expect(sheet).toBeVisible();
|
||||
|
||||
// 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).
|
||||
// With auto-read OFF the per-document read is the explicit path — the action
|
||||
// is present and the user taps it. (When auto-read is ON the row collapses;
|
||||
// that is covered by its own test below.)
|
||||
await expect(sheet.locator('[data-slot="document-read-ai"]')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -234,4 +247,27 @@ test.describe("automatic AI reading — vault per-document contract", () => {
|
||||
|
||||
await expect(sheet.locator('[data-slot="document-read-ai"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("with auto-read ON the sheet drops the manual AI action row", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Auto-read reads every upload with no per-document tap, so the manual
|
||||
// Read / Suggest actions are redundant and collapse away. The section
|
||||
// header and the searchable status pill stay.
|
||||
await mockDocumentCapability(page, { egress: "external", autoRead: true });
|
||||
|
||||
await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`);
|
||||
const sheet = page.getByRole("dialog");
|
||||
await expect(sheet).toBeVisible();
|
||||
|
||||
const section = sheet.locator('[data-slot="document-ai-section"]');
|
||||
await expect(section).toBeVisible();
|
||||
await expect(
|
||||
section.locator('[data-slot="content-search-status"]'),
|
||||
).toBeVisible();
|
||||
await expect(sheet.locator('[data-slot="document-read-ai"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(sheet.locator('[data-slot="assist-suggest"]')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -83,6 +83,11 @@ async function mockAiEnabled(
|
||||
error: null,
|
||||
}),
|
||||
);
|
||||
// The detail sheet reads the auto-AI-read opt-in; default it OFF so the AI
|
||||
// section renders its full set of controls alongside the chat entry.
|
||||
await page.route("**/api/auth/me/documents-auto-ai-read", (route) =>
|
||||
fulfilJson(route, { data: { documentsAutoAiRead: false }, error: null }),
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("document vault — chat about a document", () => {
|
||||
|
||||
@@ -8229,8 +8229,6 @@
|
||||
"assist": {
|
||||
"suggest": "Details vorschlagen",
|
||||
"suggesting": "Wird gelesen…",
|
||||
"reviewTitle": "KI-Vorschlag — vor dem Speichern prüfen",
|
||||
"reviewHint": "Es wird nichts gespeichert, bis du es übernimmst. Vorschläge können falsch sein — prüfe sie.",
|
||||
"suggestedTitle": "Vorgeschlagener Titel",
|
||||
"suggestedKind": "Vorgeschlagener Typ",
|
||||
"suggestedDate": "Vorgeschlagenes Datum",
|
||||
|
||||
@@ -8229,8 +8229,6 @@
|
||||
"assist": {
|
||||
"suggest": "Suggest details",
|
||||
"suggesting": "Reading…",
|
||||
"reviewTitle": "AI suggestion — review before saving",
|
||||
"reviewHint": "Nothing is saved until you apply it. Suggestions can be wrong — check them.",
|
||||
"suggestedTitle": "Suggested title",
|
||||
"suggestedKind": "Suggested type",
|
||||
"suggestedDate": "Suggested date",
|
||||
|
||||
@@ -8229,8 +8229,6 @@
|
||||
"assist": {
|
||||
"suggest": "Sugerir detalles",
|
||||
"suggesting": "Leyendo…",
|
||||
"reviewTitle": "Sugerencia de IA: revísala antes de guardar",
|
||||
"reviewHint": "No se guarda nada hasta que la apliques. Las sugerencias pueden fallar: compruébalas.",
|
||||
"suggestedTitle": "Título sugerido",
|
||||
"suggestedKind": "Tipo sugerido",
|
||||
"suggestedDate": "Fecha sugerida",
|
||||
|
||||
@@ -8229,8 +8229,6 @@
|
||||
"assist": {
|
||||
"suggest": "Suggérer des détails",
|
||||
"suggesting": "Lecture…",
|
||||
"reviewTitle": "Suggestion IA — à vérifier avant d'enregistrer",
|
||||
"reviewHint": "Rien n'est enregistré tant que vous ne l'appliquez pas. Les suggestions peuvent être erronées — vérifiez-les.",
|
||||
"suggestedTitle": "Titre suggéré",
|
||||
"suggestedKind": "Type suggéré",
|
||||
"suggestedDate": "Date suggérée",
|
||||
|
||||
@@ -8229,8 +8229,6 @@
|
||||
"assist": {
|
||||
"suggest": "Suggerisci dettagli",
|
||||
"suggesting": "Lettura…",
|
||||
"reviewTitle": "Suggerimento IA — verifica prima di salvare",
|
||||
"reviewHint": "Nulla viene salvato finché non lo applichi. I suggerimenti possono essere errati: controllali.",
|
||||
"suggestedTitle": "Titolo suggerito",
|
||||
"suggestedKind": "Tipo suggerito",
|
||||
"suggestedDate": "Data suggerita",
|
||||
|
||||
@@ -8229,8 +8229,6 @@
|
||||
"assist": {
|
||||
"suggest": "Zaproponuj szczegóły",
|
||||
"suggesting": "Odczytywanie…",
|
||||
"reviewTitle": "Sugestia AI — sprawdź przed zapisaniem",
|
||||
"reviewHint": "Nic nie zostanie zapisane, dopóki tego nie zastosujesz. Sugestie mogą być błędne — sprawdź je.",
|
||||
"suggestedTitle": "Sugerowany tytuł",
|
||||
"suggestedKind": "Sugerowany typ",
|
||||
"suggestedDate": "Sugerowana data",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.28.7",
|
||||
"version": "1.28.8",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.28.7";
|
||||
/* @sw-version-fallback */ "v1.28.8";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
* The AI panels' render contract:
|
||||
* - the unavailable state is a CALM pointer to the AI settings, never an
|
||||
* error, and adapts its copy to the actionable reason;
|
||||
* - the suggestion card carries the review-first affordance ("review before
|
||||
* saving") and applies nothing on its own — every value has its own tap;
|
||||
* - the suggestion card is review-first: it applies nothing on its own —
|
||||
* every value has its own explicit apply tap, and a dismiss control;
|
||||
* - the summary panel ALWAYS carries the "not saved · not a diagnosis" note,
|
||||
* even while loading, so the session-only contract is never off-screen.
|
||||
*/
|
||||
@@ -55,7 +55,7 @@ describe("<AiUnavailableHint>", () => {
|
||||
});
|
||||
|
||||
describe("<AssistSuggestionReview>", () => {
|
||||
it("presents drafts behind an explicit review-before-saving affordance", () => {
|
||||
it("presents drafts as reviewable fields with explicit apply controls", () => {
|
||||
const html = render(
|
||||
<AssistSuggestionReview
|
||||
suggestion={suggestion()}
|
||||
@@ -69,8 +69,10 @@ describe("<AssistSuggestionReview>", () => {
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="assist-suggestion-review"');
|
||||
expect(html).toContain("AI suggestion — review before saving");
|
||||
expect(html).toContain("Nothing is saved until you apply it");
|
||||
// The heading + grey sub-caption were retired — the panel leads straight
|
||||
// with the reviewable draft fields.
|
||||
expect(html).not.toContain("review before saving");
|
||||
expect(html).not.toContain("Nothing is saved until you apply it");
|
||||
// The suggested values are shown as reviewable drafts.
|
||||
expect(html).toContain("Blood panel — March 2026");
|
||||
expect(html).toContain("Lab result");
|
||||
|
||||
@@ -127,7 +127,6 @@ describe("<DocumentAiSection>", () => {
|
||||
);
|
||||
expect(html).toContain('data-slot="assist-suggestion-review"');
|
||||
expect(html).toContain("MRI knee report");
|
||||
expect(html).toContain("review before saving");
|
||||
});
|
||||
|
||||
it("renders the transient summary panel", () => {
|
||||
@@ -157,6 +156,29 @@ describe("<DocumentAiSection>", () => {
|
||||
expect(html).not.toContain('data-slot="document-read-ai"');
|
||||
});
|
||||
|
||||
it("hides the manual AI action row when auto-read is on, keeping status + chat", () => {
|
||||
const chat = <div data-slot="chat-probe">chat here</div>;
|
||||
const html = render(
|
||||
base({ aiEnabled: true, autoReadEnabled: true, chatSlot: chat }),
|
||||
);
|
||||
// Auto-read reads on upload — the manual per-document actions are gone.
|
||||
expect(html).not.toContain('data-slot="document-read-ai"');
|
||||
expect(html).not.toContain('data-slot="assist-suggest"');
|
||||
expect(html).not.toContain("Summarise");
|
||||
expect(html).not.toContain("Show extracted text");
|
||||
// The header, the searchable status pill, and content-search chat stay.
|
||||
expect(html).toContain('data-slot="document-ai-section"');
|
||||
expect(html).toContain('data-slot="content-search-status"');
|
||||
expect(html).toContain('data-slot="chat-probe"');
|
||||
});
|
||||
|
||||
it("shows the manual AI action row when auto-read is off", () => {
|
||||
const html = render(base({ aiEnabled: true, autoReadEnabled: false }));
|
||||
expect(html).toContain('data-slot="document-read-ai"');
|
||||
expect(html).toContain('data-slot="assist-suggest"');
|
||||
expect(html).toContain("Summarise");
|
||||
});
|
||||
|
||||
it("renders the chat slot inside the AI area only when a provider is available", () => {
|
||||
const chat = <div data-slot="chat-probe">chat here</div>;
|
||||
const enabled = render(base({ aiEnabled: true, chatSlot: chat }));
|
||||
|
||||
@@ -148,21 +148,7 @@ export function AssistSuggestionReview({
|
||||
data-slot="assist-suggestion-review"
|
||||
className="border-primary/30 bg-primary/5 space-y-3 rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<Sparkles
|
||||
className="text-primary mt-0.5 size-4 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t("documents.assist.reviewTitle")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("documents.assist.reviewHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
||||
@@ -45,6 +45,7 @@ import type { DocumentDescribeResult } from "./use-document-assist";
|
||||
|
||||
export function DocumentAiSection({
|
||||
aiEnabled,
|
||||
autoReadEnabled = false,
|
||||
indexEnabled,
|
||||
unavailableReason,
|
||||
actionsDisabled,
|
||||
@@ -72,6 +73,14 @@ export function DocumentAiSection({
|
||||
chatSlot,
|
||||
}: {
|
||||
aiEnabled: boolean;
|
||||
/**
|
||||
* Whether "read documents automatically with AI" is ON. When true, reading +
|
||||
* indexing happen on upload, so the manual per-document action row (Read /
|
||||
* Suggest / Summarise) is hidden — only the header + searchable status +
|
||||
* content-search chat remain. Defaults to false so an omitting caller keeps
|
||||
* the manual controls.
|
||||
*/
|
||||
autoReadEnabled?: boolean;
|
||||
indexEnabled: boolean;
|
||||
unavailableReason: "no-provider" | "enable-local-ocr" | null;
|
||||
/** Capability still resolving — the transport mode isn't known yet. */
|
||||
@@ -138,58 +147,61 @@ export function DocumentAiSection({
|
||||
|
||||
{aiEnabled ? (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{indexEnabled ? (
|
||||
{!autoReadEnabled ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{indexEnabled ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
data-slot="document-read-ai"
|
||||
onClick={onIndex}
|
||||
disabled={indexPending || actionsDisabled}
|
||||
>
|
||||
<ScanText
|
||||
className={cn(
|
||||
"size-4",
|
||||
indexPending &&
|
||||
"animate-pulse motion-reduce:animate-none",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
{readLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-slot="document-read-ai"
|
||||
onClick={onIndex}
|
||||
disabled={indexPending || actionsDisabled}
|
||||
data-slot="assist-suggest"
|
||||
onClick={onSuggest}
|
||||
disabled={suggestPending || actionsDisabled}
|
||||
>
|
||||
<ScanText
|
||||
className={cn(
|
||||
"size-4",
|
||||
indexPending && "animate-pulse motion-reduce:animate-none",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
{readLabel}
|
||||
<WandSparkles className="size-4" aria-hidden />
|
||||
{suggestPending
|
||||
? t("documents.assist.suggesting")
|
||||
: t("documents.assist.suggest")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-slot="assist-suggest"
|
||||
onClick={onSuggest}
|
||||
disabled={suggestPending || actionsDisabled}
|
||||
>
|
||||
<WandSparkles className="size-4" aria-hidden />
|
||||
{suggestPending
|
||||
? t("documents.assist.suggesting")
|
||||
: t("documents.assist.suggest")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onSummarise("summary")}
|
||||
disabled={summaryPending || actionsDisabled}
|
||||
>
|
||||
<FileText className="size-4" aria-hidden />
|
||||
{t("documents.summary.summarise")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onSummarise("text")}
|
||||
disabled={summaryPending || actionsDisabled}
|
||||
>
|
||||
{t("documents.summary.showText")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onSummarise("summary")}
|
||||
disabled={summaryPending || actionsDisabled}
|
||||
>
|
||||
<FileText className="size-4" aria-hidden />
|
||||
{t("documents.summary.summarise")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onSummarise("text")}
|
||||
disabled={summaryPending || actionsDisabled}
|
||||
>
|
||||
{t("documents.summary.showText")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{suggestErrorKey ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
|
||||
@@ -67,6 +67,7 @@ import { useIndexDocument } from "./use-content-index";
|
||||
import {
|
||||
documentAiErrorKey,
|
||||
useDocumentAiCapability,
|
||||
useDocumentsAutoAiRead,
|
||||
useDocumentSummary,
|
||||
useSuggestDetails,
|
||||
} from "./use-document-assist";
|
||||
@@ -221,6 +222,9 @@ export function DocumentDetailSheet({
|
||||
// The affordance itself is gated on `assistAvailable` (from usage); the
|
||||
// probe only resolves the transport mode + the actionable unavailable reason.
|
||||
const capability = useDocumentAiCapability(open && documentId !== null);
|
||||
// When auto-read is ON, reading happens automatically on upload — the manual
|
||||
// per-document AI action row is redundant and collapses away.
|
||||
const autoRead = useDocumentsAutoAiRead(open && documentId !== null);
|
||||
const suggest = useSuggestDetails();
|
||||
const summary = useDocumentSummary();
|
||||
const indexDoc = useIndexDocument();
|
||||
@@ -335,6 +339,7 @@ export function DocumentDetailSheet({
|
||||
const aiEnabled = assistAvailable ?? capability.data?.available ?? false;
|
||||
const indexEnabled =
|
||||
contentIndexEnabled ?? capability.data?.available ?? false;
|
||||
const autoReadEnabled = autoRead.data?.documentsAutoAiRead ?? false;
|
||||
const aiMode = capability.data?.mode === "text" ? "text" : "vision";
|
||||
const aiTarget: DocumentAiTarget | null = doc
|
||||
? {
|
||||
@@ -504,6 +509,7 @@ export function DocumentDetailSheet({
|
||||
|
||||
<DocumentAiSection
|
||||
aiEnabled={aiEnabled}
|
||||
autoReadEnabled={autoReadEnabled}
|
||||
indexEnabled={indexEnabled}
|
||||
unavailableReason={capability.data?.reason ?? null}
|
||||
actionsDisabled={capability.isPending}
|
||||
|
||||
@@ -51,6 +51,27 @@ export function useDocumentAiCapability(enabled: boolean) {
|
||||
});
|
||||
}
|
||||
|
||||
/** The per-user "read documents automatically with AI" opt-in. */
|
||||
export interface DocumentsAutoAiReadPref {
|
||||
documentsAutoAiRead: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the per-user auto-AI-read flag (`GET /api/auth/me/documents-auto-ai-read`).
|
||||
* Shares the cache key with the AI-settings toggle, so a flip there and this
|
||||
* read stay in lockstep. When ON, reading happens automatically on upload and
|
||||
* the detail sheet drops the manual per-document AI action row.
|
||||
*/
|
||||
export function useDocumentsAutoAiRead(enabled: boolean) {
|
||||
return useQuery<DocumentsAutoAiReadPref>({
|
||||
queryKey: queryKeys.documentsAutoAiRead(),
|
||||
queryFn: () =>
|
||||
apiGet<DocumentsAutoAiReadPref>("/api/auth/me/documents-auto-ai-read"),
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggest filing metadata for a stored document. Returns DRAFTS only — the
|
||||
* detail sheet prefills its edit fields and the user saves; this call writes
|
||||
|
||||
Reference in new issue
Block a user