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

+228
View File
@@ -0,0 +1,228 @@
"use client";
/**
* v1.43 (#1217) — the operator's AI usage bounds.
*
* The operator pays for the shared key, so the operator sets the highest
* level an account may choose and the level an account without a choice runs
* at; each person picks within that under Settings > AI. Beside them, the
* gap between two status-card regenerations after a sync at `standard`
* (whole hours, 1 to 48; empty follows `AI_SYNC_REGEN_GAP_HOURS`, else six).
*
* Reads and writes the `aiUsage` block of the assistant-flags route, the same
* query the assistant switches use. Lowering the ceiling caps every account
* above it at once; their stored choice is kept.
*/
import { useState, type FormEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Gauge } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import {
AI_USAGE_LEVELS,
SYNC_REGEN_GAP_HOURS_MAX,
SYNC_REGEN_GAP_HOURS_MIN,
usageLevelRank,
type AiUsageLevel,
} from "@/lib/ai/usage-level";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import {
aiInputDependentKeys,
invalidateKeys,
queryKeys,
} from "@/lib/query-keys";
interface AiUsageBounds {
maxLevel: AiUsageLevel;
defaultLevel: AiUsageLevel;
syncRegenGapHours: number | null;
effectiveSyncRegenGapHours: number;
}
const LEVEL_LABEL_KEYS = {
frugal: "settings.ai.usage.levels.frugal.label",
standard: "settings.ai.usage.levels.standard.label",
full: "settings.ai.usage.levels.full.label",
} as const satisfies Record<AiUsageLevel, string>;
export function AiUsageSection() {
const { t } = useTranslations();
const { data } = useQuery({
queryKey: queryKeys.adminAssistantFlags(),
queryFn: () =>
apiGet<{ aiUsage?: AiUsageBounds }>(
"/api/admin/settings/assistant-flags",
),
});
const bounds = data?.aiUsage;
if (!bounds) {
return (
<SettingsCard>
<SettingsCardHeader
icon={Gauge}
title={t("admin.aiUsage.title")}
description={t("admin.aiUsage.description")}
/>
</SettingsCard>
);
}
// Keyed on the stored values, so a save (or another operator's) re-seeds
// the form instead of leaving it on stale edits.
return (
<AiUsageForm
key={`${bounds.maxLevel}:${bounds.defaultLevel}:${bounds.syncRegenGapHours}`}
bounds={bounds}
/>
);
}
function AiUsageForm({ bounds }: { bounds: AiUsageBounds }) {
const { t } = useTranslations();
const client = useQueryClient();
const [maxLevel, setMaxLevel] = useState<AiUsageLevel>(bounds.maxLevel);
const [defaultLevel, setDefaultLevel] = useState<AiUsageLevel>(
bounds.defaultLevel,
);
const [gap, setGap] = useState(
bounds.syncRegenGapHours === null ? "" : String(bounds.syncRegenGapHours),
);
const gapValue = gap.trim() === "" ? null : Number(gap);
const gapValid =
gapValue === null ||
(Number.isInteger(gapValue) &&
gapValue >= SYNC_REGEN_GAP_HOURS_MIN &&
gapValue <= SYNC_REGEN_GAP_HOURS_MAX);
const defaultAbove = usageLevelRank(defaultLevel) > usageLevelRank(maxLevel);
const save = useMutation({
mutationFn: () =>
apiPut<{ aiUsage: AiUsageBounds }>(
"/api/admin/settings/assistant-flags",
{
aiUsage: { maxLevel, defaultLevel, syncRegenGapHours: gapValue },
},
),
onSuccess: (result) => {
client.setQueryData(queryKeys.adminAssistantFlags(), result);
void client.invalidateQueries({
queryKey: queryKeys.adminProviderHealth(),
});
void invalidateKeys(client, aiInputDependentKeys);
toast.success(t("common.saved"));
},
onError: (err) => {
toast.error(
err instanceof Error && err.message
? err.message
: t("admin.settingsSaveError"),
);
},
});
function onSubmit(event: FormEvent) {
event.preventDefault();
if (!gapValid || defaultAbove) return;
save.mutate();
}
return (
<SettingsCard data-slot="admin-ai-usage">
<SettingsCardHeader
icon={Gauge}
title={t("admin.aiUsage.title")}
description={t("admin.aiUsage.description")}
/>
<form onSubmit={onSubmit} className="flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="admin-ai-usage-max">
{t("admin.aiUsage.maxLabel")}
</Label>
<NativeSelect
id="admin-ai-usage-max"
value={maxLevel}
disabled={save.isPending}
onChange={(e) => setMaxLevel(e.target.value as AiUsageLevel)}
>
{AI_USAGE_LEVELS.map((level) => (
<option key={level} value={level}>
{t(LEVEL_LABEL_KEYS[level])}
</option>
))}
</NativeSelect>
</div>
<div className="space-y-1.5">
<Label htmlFor="admin-ai-usage-default">
{t("admin.aiUsage.defaultLabel")}
</Label>
<NativeSelect
id="admin-ai-usage-default"
value={defaultLevel}
disabled={save.isPending}
aria-invalid={defaultAbove || undefined}
onChange={(e) => setDefaultLevel(e.target.value as AiUsageLevel)}
>
{AI_USAGE_LEVELS.map((level) => (
<option key={level} value={level}>
{t(LEVEL_LABEL_KEYS[level])}
</option>
))}
</NativeSelect>
</div>
</div>
{defaultAbove ? (
<p role="alert" className="text-destructive text-xs">
{t("admin.aiUsage.defaultAboveCeiling")}
</p>
) : null}
<div className="space-y-1.5">
<Label htmlFor="admin-ai-usage-gap">
{t("admin.aiUsage.gapLabel")}
</Label>
<Input
id="admin-ai-usage-gap"
type="number"
inputMode="numeric"
min={SYNC_REGEN_GAP_HOURS_MIN}
max={SYNC_REGEN_GAP_HOURS_MAX}
step={1}
className="sm:max-w-40"
value={gap}
placeholder={String(bounds.effectiveSyncRegenGapHours)}
disabled={save.isPending}
aria-invalid={!gapValid || undefined}
aria-describedby="admin-ai-usage-gap-hint"
onChange={(e) => setGap(e.target.value)}
/>
<p
id="admin-ai-usage-gap-hint"
className="text-muted-foreground text-xs"
>
{t("admin.aiUsage.gapHint", {
hours: bounds.effectiveSyncRegenGapHours,
})}
</p>
</div>
<SettingsCardActions>
<Button
type="submit"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={save.isPending || !gapValid || defaultAbove}
>
{t("common.save")}
</Button>
</SettingsCardActions>
</form>
</SettingsCard>
);
}
@@ -69,9 +69,11 @@ export function ProviderHealthSection() {
const { data, isPending, isError } = useQuery({
queryKey: queryKeys.adminProviderHealth(),
queryFn: () =>
apiGet<{ providers: ProviderHealthRow[]; spendToday: SpendToday }>(
"/api/admin/provider-health",
),
apiGet<{
providers: ProviderHealthRow[];
spendToday: SpendToday;
usageLevels?: { frugal: number; standard: number; full: number };
}>("/api/admin/provider-health"),
});
const typeLabel = (type: string): string => {
@@ -97,6 +99,7 @@ export function ProviderHealthSection() {
const providers = data?.providers ?? [];
const spendToday = data?.spendToday ?? null;
const usageLevels = data?.usageLevels ?? null;
return (
<SettingsCard>
@@ -114,6 +117,18 @@ export function ProviderHealthSection() {
})}
</p>
) : null}
{usageLevels ? (
<p
className="text-muted-foreground text-sm"
data-slot="ai-usage-levels"
>
{t("admin.providerHealth.usageLevels", {
frugal: groupDigits(usageLevels.frugal),
standard: groupDigits(usageLevels.standard),
full: groupDigits(usageLevels.full),
})}
</p>
) : null}
{isPending ? (
<p className="text-muted-foreground text-sm">
{t("admin.providerHealth.loading")}
@@ -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"