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

@@ -92,6 +92,8 @@ const PINNED_AFFORDANCES: Record<
"text-success": 1,
},
"src/components/admin/assistant-section.tsx": { "toast.success": 1 },
// v1.43 — the AI usage bounds: a settings save, not a write with counts.
"src/components/admin/ai-usage-section.tsx": { "toast.success": 1 },
"src/components/admin/backups-section.tsx": { "toast.success": 4 },
"src/components/admin/central-codex-section.tsx": { "toast.success": 2 },
"src/components/admin/danger-zone-section.tsx": { "text-success": 1 },
+2
View File
@@ -22,6 +22,7 @@ import { AiServerKeySection } from "@/components/admin/ai-server-key-section";
import { ProviderHealthSection } from "@/components/admin/provider-health-section";
import { CentralCodexSection } from "@/components/admin/central-codex-section";
import { AssistantSection } from "@/components/admin/assistant-section";
import { AiUsageSection } from "@/components/admin/ai-usage-section";
import { CoachFeedbackSection } from "@/components/admin/coach-feedback-section";
import { ApiTokenOverviewSection } from "@/components/admin/api-token-overview-section";
import { AppLogPreviewSection } from "@/components/admin/app-log-preview-section";
@@ -102,6 +103,7 @@ export function AdminSectionRenderer({
return (
<SectionFrame>
<AssistantSection />
<AiUsageSection />
<AiServerKeySection />
<CentralCodexSection />
<ProviderHealthSection />
+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"