feat(settings): choose the AI usage level, and set its bounds as an operator

Settings, AI gains one card with the three levels, each with what it does
and the server's estimate of tokens per day, labelled as one. Levels above
the operator's ceiling are shown disabled with the reason; a capped choice
is named and kept; a stored choice can return to the server default. The
automatic document read says when the frugal level rests it. Admin, Coach
gains the ceiling, the default and the sync gap, and the provider readout
the accounts per level. Strings in all seven languages.

The admin card's save confirms with a success toast like its neighbour
assistant-section; it is a settings write without counts, so it is pinned
in the success-affordance guard at one.

Refs #1217
This commit is contained in:
Marc-André Bombeck committed 2026-10-15 22:55:40 +02:00
1 parent 74dcc67ef7
commit ec67663def
17 files changed
+905 -32

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"