Merge branch 'v143/ai' into release/v1.43.0

# Conflicts:
#	docs/api/openapi.yaml
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/ko.json
#	messages/pl.json
#	src/__tests__/contract-stub-inventory.test.ts
This commit is contained in:
Marc-André Bombeck committed 2026-10-15 23:12:51 +02:00
commit fd44a8381b
143 files changed
+4806 -628

No files matched your search

@@ -54,6 +54,8 @@ vi.mock("@/hooks/use-ai-capability", () => ({
onDeviceAllowed: true,
}),
useAiCapabilityMap: () => null,
// v1.43 — no usage level published: the setting stays out of the way.
useAiUsage: () => null,
useAiProviderState: () => ({
configured: true,
managedBy: "user",
@@ -54,6 +54,8 @@ vi.mock("@/hooks/use-ai-capability", () => ({
onDeviceAllowed: true,
}),
useAiCapabilityMap: () => null,
// v1.43 — no usage level published: the setting stays out of the way.
useAiUsage: () => null,
useAiProviderState: () => ({
configured: true,
managedBy: "user",
@@ -0,0 +1,120 @@
/**
* #1217 — the AI usage setting renders what the server resolved: three
* options, the ones above the operator's ceiling disabled with their reason,
* each estimate labelled as one, and nothing computed on the client.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import type { AiUsageState } from "@/lib/ai/usage-level";
const state = vi.hoisted(() => ({
usage: null as AiUsageState | null,
canConfigure: true,
}));
vi.mock("@/hooks/use-ai-capability", () => ({
useAiUsage: () => state.usage,
useAiProviderState: () => ({
configured: true,
managedBy: "user",
canConfigure: state.canConfigure,
responseTimeoutMs: 60_000,
}),
}));
import { AiUsageCard } from "../ai-usage-card";
function render(usage: AiUsageState | null, canConfigure = true) {
state.usage = usage;
state.canConfigure = canConfigure;
const client = new QueryClient();
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<AiUsageCard />
</I18nProvider>
</QueryClientProvider>,
);
}
const CAPPED: AiUsageState = {
level: "standard",
preference: "full",
defaultLevel: "standard",
maxLevel: "standard",
source: "admin_cap",
options: [
{ level: "frugal", allowed: true, estimatedTokensPerDay: 48_000 },
{ level: "standard", allowed: true, estimatedTokensPerDay: 100_000 },
{ level: "full", allowed: false, estimatedTokensPerDay: 400_000 },
],
measuredTokensPerDay: 100_000,
measuredDays: 7,
};
describe("AiUsageCard", () => {
it("renders nothing on a server that publishes no level", () => {
expect(render(null)).toBe("");
});
it("checks the level in effect and disables the ones above the ceiling", () => {
const html = render(CAPPED);
expect(html.match(/role="radio"/g)).toHaveLength(3);
expect(html).toMatch(/aria-checked="true"[^>]*data-level="standard"/);
expect(html.match(/aria-checked="true"/g)).toHaveLength(1);
expect(html).toContain('data-level="full" data-allowed="false"');
expect(html).toContain("Limited by the server operator.");
// The capped choice is named and kept.
expect(html).toContain('data-slot="ai-usage-capped"');
expect(html).toContain("You chose Full");
});
it("shows the server's estimates, labelled, in the reader's number format", () => {
const html = render(CAPPED);
expect(html).toContain("about 48,000 tokens a day, estimated");
expect(html).toContain("about 400,000 tokens a day, estimated");
expect(html).toContain(
"Measured: 100,000 tokens a day over the last 7 days",
);
});
it("says when there is no estimate yet instead of inventing one", () => {
const html = render({
...CAPPED,
source: "default",
preference: null,
options: CAPPED.options.map((o) => ({
...o,
estimatedTokensPerDay: null,
})),
measuredTokensPerDay: null,
measuredDays: 0,
});
expect(html).not.toContain("tokens a day, estimated");
expect(html).toContain("An estimate per level appears after three days");
expect(html).toContain("You follow the server default (Standard).");
// No stored choice: nothing to return to.
expect(html).not.toContain('data-slot="ai-usage-use-default"');
});
it("offers the way back to the default when a choice is stored", () => {
const html = render({
...CAPPED,
source: "user",
preference: "frugal",
level: "frugal",
});
expect(html).toContain('data-slot="ai-usage-use-default"');
expect(html).toContain("Use the server default (Standard)");
});
it("locks the options where the account cannot set its own level", () => {
const html = render(CAPPED, false);
expect(html).toContain(
'role="radiogroup" aria-label="AI usage" aria-disabled="true"',
);
});
});
@@ -36,6 +36,7 @@ import {
import { AdminOpenAIProviderForm } from "./admin-openai-provider-form";
import { AiConsentCard } from "./ai-consent-card";
import { AiUsageCard } from "./ai-usage-card";
import { AnthropicProviderForm } from "./anthropic-provider-form";
import { AutoReadCard } from "./auto-read-card";
import { CentralCodexSwitch } from "./central-codex-switch";
@@ -233,6 +234,10 @@ export function AiInsightsCard({
onSelect={pickProvider}
/>
{/* v1.43 (#1217) — how much background AI work the account
asks for: one setting, three levels. */}
<AiUsageCard />
{/* v1.22 (#89) — per-user response timeout (mainly for slow
local/self-hosted backends). */}
<ResponseTimeoutCard userProvider={userProvider} />
@@ -0,0 +1,233 @@
"use client";
/* ────────────────────────────────────────────────────────────────
* v1.43 (#1217) — AI usage: one setting, three levels.
*
* The level decides how much background AI work the account asks for: how
* often status cards regenerate after a sync, the snapshot size, background
* reasoning, the length of the daily briefing, the automatic document read
* and the label photo. The Coach is the same at every level.
*
* Everything shown comes off `ai.usage` on `/api/auth/me`, resolved on the
* server: which options the operator allows, the estimate per option (only
* ever labelled as one), the measured use. The card computes nothing. A pick
* is written at once through `PUT /api/auth/me/ai-usage`; the account payload
* is refetched so every surface that reads a capability sees the new level.
* ──────────────────────────────────────────────────────────────── */
import { useRef } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Gauge } from "lucide-react";
import { Button } from "@/components/ui/button";
import { nextChoiceIndex } from "@/components/documents/sources/segmented-choice";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useAiProviderState, useAiUsage } from "@/hooks/use-ai-capability";
import { apiPut } from "@/lib/api/api-fetch";
import type { AiUsageLevel, AiUsageState } from "@/lib/ai/usage-level";
import { useTranslations } from "@/lib/i18n/context";
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
const LEVEL_KEYS = {
frugal: {
label: "settings.ai.usage.levels.frugal.label",
hint: "settings.ai.usage.levels.frugal.hint",
},
standard: {
label: "settings.ai.usage.levels.standard.label",
hint: "settings.ai.usage.levels.standard.hint",
},
full: {
label: "settings.ai.usage.levels.full.label",
hint: "settings.ai.usage.levels.full.hint",
},
} as const satisfies Record<AiUsageLevel, { label: string; hint: string }>;
export function AiUsageCard() {
const { t, locale } = useTranslations();
const queryClient = useQueryClient();
const usage = useAiUsage();
const provider = useAiProviderState();
const refs = useRef<Array<HTMLButtonElement | null>>([]);
const save = useMutation<AiUsageState, Error, AiUsageLevel | null>({
mutationFn: (level) =>
apiPut<AiUsageState>("/api/auth/me/ai-usage", { level }),
onSuccess: () => {
void invalidateKeys(queryClient, aiInputDependentKeys);
},
});
// An older server publishes no level: there is nothing to set.
if (!usage) return null;
// Only the account itself sets its level, and only while the operator's
// master switch is on (`canConfigure` is false inside somebody else's
// record and while AI is off on the server).
const editable = provider.canConfigure && !save.isPending;
const tokens = (value: number) => new Intl.NumberFormat(locale).format(value);
const levelName = (level: AiUsageLevel) => t(LEVEL_KEYS[level].label);
const allowedLevels = usage.options
.filter((option) => option.allowed)
.map((option) => option.level);
function pick(level: AiUsageLevel) {
if (!editable || level === usage?.level) return;
save.mutate(level);
}
function onKeyDown(
event: React.KeyboardEvent<HTMLButtonElement>,
level: AiUsageLevel,
) {
const from = allowedLevels.indexOf(level);
const next = nextChoiceIndex(event.key, from, allowedLevels.length);
if (next === null) return;
event.preventDefault();
const target = allowedLevels[next]!;
pick(target);
refs.current[usage!.options.findIndex((o) => o.level === target)]?.focus();
}
return (
<SettingsCard data-slot="ai-usage-card">
<SettingsCardHeader
anchor="ai-usage"
icon={Gauge}
title={t("settings.ai.usage.title")}
description={t("settings.ai.usage.description")}
/>
<div
role="radiogroup"
aria-label={t("settings.ai.usage.title")}
aria-disabled={!editable || undefined}
className="border-border divide-border divide-y rounded-lg border"
>
{usage.options.map((option, i) => {
const checked = option.level === usage.level;
const disabled = !option.allowed || !editable;
return (
<button
key={option.level}
ref={(el) => {
refs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={checked}
aria-disabled={disabled || undefined}
tabIndex={checked ? 0 : -1}
data-slot="ai-usage-option"
data-level={option.level}
data-allowed={option.allowed}
onClick={() => (option.allowed ? pick(option.level) : undefined)}
onKeyDown={(event) => onKeyDown(event, option.level)}
className={cn(
"flex min-h-11 w-full items-start gap-3 px-3 py-2.5 text-left first:rounded-t-lg last:rounded-b-lg",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none",
checked && "bg-primary/10",
!option.allowed && "cursor-not-allowed opacity-50",
)}
>
<span
aria-hidden
className={cn(
"border-border mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border",
checked && "border-primary",
)}
>
{checked ? (
<span className="bg-primary size-2 rounded-full" />
) : null}
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium">
{levelName(option.level)}
</span>
<span className="text-muted-foreground block text-xs">
{t(LEVEL_KEYS[option.level].hint)}
</span>
{!option.allowed ? (
<span
data-slot="ai-usage-limited"
className="text-muted-foreground block text-xs"
>
{t("settings.ai.usage.limited")}
</span>
) : null}
</span>
<span
data-slot="ai-usage-estimate"
className="text-muted-foreground shrink-0 text-right text-xs tabular-nums"
>
{option.estimatedTokensPerDay === null
? null
: t("settings.ai.usage.estimate", {
tokens: tokens(option.estimatedTokensPerDay),
})}
</span>
</button>
);
})}
</div>
<p
data-slot="ai-usage-measured"
className="text-muted-foreground text-xs"
>
{usage.measuredTokensPerDay === null || usage.measuredDays < 3
? t("settings.ai.usage.noEstimate")
: t("settings.ai.usage.measured", {
tokens: tokens(usage.measuredTokensPerDay),
days: usage.measuredDays,
})}
</p>
{usage.source === "admin_cap" && usage.preference ? (
<p data-slot="ai-usage-capped" className="text-sm">
{t("settings.ai.usage.capped", {
choice: levelName(usage.preference),
level: levelName(usage.level),
})}
</p>
) : usage.source === "default" ? (
<p
data-slot="ai-usage-default"
className="text-muted-foreground text-xs"
>
{t("settings.ai.usage.followsDefault", {
level: levelName(usage.defaultLevel),
})}
</p>
) : null}
{save.isError ? (
<p role="alert" className="text-destructive text-xs">
{t("settings.ai.errorGeneric")}
</p>
) : null}
{usage.preference !== null ? (
<SettingsCardActions>
<Button
type="button"
size="sm"
variant="outline"
className="min-h-11 sm:min-h-9"
disabled={!editable}
onClick={() => save.mutate(null)}
data-slot="ai-usage-use-default"
>
{t("settings.ai.usage.useDefault", {
level: levelName(usage.defaultLevel),
})}
</Button>
</SettingsCardActions>
) : null}
</SettingsCard>
);
}
+13 -1
View File
@@ -24,7 +24,7 @@ import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Switch } from "@/components/ui/switch";
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
import { useAiCapability } from "@/hooks/use-ai-capability";
import { useAiCapability, useAiUsage } from "@/hooks/use-ai-capability";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -41,6 +41,9 @@ export function AutoReadCard() {
// already-on preference off stays possible.
const documentAi = useAiCapability("documentAi");
const operatorOff = documentAi.reason === "operator_disabled";
// v1.43 (#1217) — on the frugal AI usage level the switch rests: documents
// are read only on request. The choice itself is kept.
const restingFrugal = useAiUsage()?.level === "frugal";
// Whether the honesty confirm is currently shown (user flipped the switch ON
// but has not yet acknowledged the trade). Off→on reveals it; the write only
@@ -115,6 +118,15 @@ export function AutoReadCard() {
</p>
) : null}
{restingFrugal && enabled && !operatorOff ? (
<p
data-slot="documents-auto-read-resting"
className="text-muted-foreground text-xs"
>
{t("settings.ai.autoRead.restingFrugal")}
</p>
) : null}
{pendingEnable ? (
<div
data-slot="documents-auto-read-confirm"