mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
commit
fd44a8381b
143 files changed
+4806
-628
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user