feat: refine language controls and add per-tier ChatGPT Fast mode
This commit is contained in:
@@ -72,6 +72,8 @@ import {
|
||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import { prettyModel } from "@/components/usage-format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ZapIcon } from "lucide-react";
|
||||
import { Hint } from "@/components/ui/hint";
|
||||
import { SettingsGroup, SettingsRow, Toggle, CommitInput } from "./controls.tsx";
|
||||
import { ModelMenuList, ThinkingMenuList } from "./model-menu";
|
||||
import { ChatGPTBackgroundAccess } from "./chatgpt-connection";
|
||||
@@ -152,13 +154,22 @@ export function composeSlotModel(
|
||||
delete next[slot];
|
||||
return next;
|
||||
}
|
||||
const model = provider && id ? { provider, id } : prev?.model;
|
||||
next[slot] = {
|
||||
...(provider && id ? { model: { provider, id } } : prev?.model ? { model: prev.model } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(thinking ? { thinkingLevel: thinking as ThinkingLevel } : {}),
|
||||
...(model?.provider === "chatgpt" && prev?.fastMode !== undefined ? { fastMode: prev.fastMode } : {}),
|
||||
};
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Speed belongs to the tier, independently of its reasoning budget. */
|
||||
export function composeSlotFast(models: ModelsSettings | undefined, slot: ModelSlotName, enabled: boolean): ModelsSettings {
|
||||
const current = models?.[slot];
|
||||
if (current?.model?.provider !== "chatgpt") return { ...models };
|
||||
return { ...models, [slot]: { ...current, fastMode: enabled } };
|
||||
}
|
||||
|
||||
/** Compose: the search-engine preference. null = the explicit pin is
|
||||
* REMOVED (auto-activation takes over — the schema's resting state).
|
||||
* Pure — exported for tests. */
|
||||
@@ -258,11 +269,12 @@ function MenuTrigger({
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
data-testid={testid}
|
||||
onClick={() => {
|
||||
if (!disabled) onClick();
|
||||
}}
|
||||
className={`flex h-7 items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors ${
|
||||
className={`flex h-7 max-w-full items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors ${
|
||||
disabled ? "cursor-not-allowed" : "hover:border-muted-foreground/50"
|
||||
} ${widthClass}`}
|
||||
>
|
||||
@@ -302,12 +314,21 @@ export function AgentSection({
|
||||
/** Writes the composed WHOLE agent section (replace-PUT discipline). */
|
||||
onSectionChange: (next: AgentSettings) => void;
|
||||
/** Writes the composed models section (slots — replace-PUT discipline). */
|
||||
onModelsChange: (next: ModelsSettings) => void;
|
||||
onModelsChange: (next: ModelsSettings) => void | Promise<void>;
|
||||
/** Writes the whole compaction section (replace-PUT discipline). */
|
||||
onCompactionChange: (next: CompactionSettings) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [catalog, setCatalog] = useState<ModelCatalogEntry[] | null>(null);
|
||||
const [savingModels, setSavingModels] = useState(false);
|
||||
const [modelsError, setModelsError] = useState<string | null>(null);
|
||||
const updateModels = async (next: ModelsSettings) => {
|
||||
if (savingModels) return;
|
||||
setSavingModels(true); setModelsError(null);
|
||||
try { await onModelsChange(next); }
|
||||
catch { setModelsError("Could not save model settings"); }
|
||||
finally { setSavingModels(false); }
|
||||
};
|
||||
/** The three tiers' resolved pins — GET /api/models `slots`. The rows'
|
||||
* Default hints and thinking lists resolve from it. */
|
||||
const [slots, setSlots] = useState<ModelsBody["slots"] | null>(null);
|
||||
@@ -617,6 +638,7 @@ export function AgentSection({
|
||||
return (
|
||||
<>
|
||||
<SettingsGroup label={t("Models")} testid="group-models">
|
||||
{modelsError && <p role="alert" className="text-xs text-destructive">{t(modelsError)}</p>}
|
||||
{MODEL_SLOTS.map((slot) => {
|
||||
const override = models?.[slot];
|
||||
const modelId = override?.model?.id ?? "";
|
||||
@@ -644,12 +666,13 @@ export function AgentSection({
|
||||
label={SLOT_META[slot].label}
|
||||
hint={SLOT_META[slot].roster}
|
||||
testid={`slot-row-${slot}`}
|
||||
className="flex-wrap gap-y-2"
|
||||
>
|
||||
{/* The anchor for the outside-click contract is the
|
||||
span holding BOTH triggers of the open menu. */}
|
||||
<span
|
||||
ref={thisMenuOpen ? menuAnchorRef : undefined}
|
||||
className="relative flex items-center gap-2"
|
||||
className="relative ml-auto flex max-w-full flex-wrap items-center justify-end gap-2"
|
||||
>
|
||||
{slot !== "strong" && <><ChatGPTBackgroundAccess provider={coord?.provider} /><NativeSubscriptionBackgroundAccess provider={coord?.provider} /></>}
|
||||
<MenuTrigger
|
||||
@@ -660,6 +683,7 @@ export function AgentSection({
|
||||
open={thisMenuOpen === "model"}
|
||||
onClick={() => setOpenMenu(thisMenuOpen === "model" ? null : { target: slot, kind: "model" })}
|
||||
widthClass="min-w-20"
|
||||
disabled={savingModels}
|
||||
/>
|
||||
<MenuTrigger
|
||||
// Legacy display (chunk 2 item 4): no stored level + a known
|
||||
@@ -672,7 +696,19 @@ export function AgentSection({
|
||||
open={thisMenuOpen === "thinking"}
|
||||
onClick={() => setOpenMenu(thisMenuOpen === "thinking" ? null : { target: slot, kind: "thinking" })}
|
||||
widthClass="w-20"
|
||||
disabled={savingModels}
|
||||
/>
|
||||
<Hint label={t(provider === "chatgpt" ? "Fast mode uses more ChatGPT allowance." : "Fast mode is available only for ChatGPT models.")}>
|
||||
<span className="inline-flex">
|
||||
<button type="button" data-testid={`slot-fast-${slot}`} aria-label={t("{v0} fast mode", { v0: t(SLOT_META[slot].label) })}
|
||||
aria-pressed={provider === "chatgpt" && override?.fastMode === true} disabled={provider !== "chatgpt" || !chosen || savingModels}
|
||||
onClick={() => { setOpenMenu(null); void updateModels(composeSlotFast(models, slot, !override?.fastMode)); }}
|
||||
className={cn("flex h-7 items-center gap-1 rounded-sm border border-border px-1.5 font-ui text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-40",
|
||||
provider === "chatgpt" && override?.fastMode ? "bg-muted text-foreground" : "bg-background text-muted-foreground hover:border-muted-foreground/50")}>
|
||||
<ZapIcon className="size-3.5" aria-hidden /><span>{t("Fast")}</span>
|
||||
</button>
|
||||
</span>
|
||||
</Hint>
|
||||
{thisMenuOpen === "model" && (
|
||||
<MenuShell
|
||||
preferredSide="down"
|
||||
@@ -689,7 +725,7 @@ export function AgentSection({
|
||||
// fresh pick writes a level — the stored one rides as-is
|
||||
// (the write clamps server-side), else the ordering's level.
|
||||
const next = populateLevel(thinking || undefined, entry.supportedLevels);
|
||||
onModelsChange(composeSlotModel(models, slot, entry.provider, entry.id, next));
|
||||
void updateModels(composeSlotModel(models, slot, entry.provider, entry.id, next));
|
||||
setOpenMenu(null);
|
||||
}}
|
||||
footer={
|
||||
@@ -713,7 +749,7 @@ export function AgentSection({
|
||||
// the same one the trigger shows.
|
||||
effectiveLevel={thinking || (chosen ? defaultOrderLevel(levels) : undefined)}
|
||||
onPick={(level) => {
|
||||
onModelsChange(composeSlotModel(models, slot, provider, modelId, level));
|
||||
void updateModels(composeSlotModel(models, slot, provider, modelId, level));
|
||||
setOpenMenu(null);
|
||||
}}
|
||||
/>
|
||||
@@ -762,11 +798,12 @@ export function AgentSection({
|
||||
label={t("Quizzing")}
|
||||
hint={t("How both tutors check your understanding")}
|
||||
testid="quiz-mechanics-row"
|
||||
className="flex-wrap gap-y-2"
|
||||
>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={t("Quizzing mechanics")}
|
||||
className="flex rounded-lg border border-border/60 bg-muted/30 p-0.5"
|
||||
className="ml-auto flex max-w-full shrink-0 rounded-lg border border-border/60 bg-muted/30 p-0.5"
|
||||
>
|
||||
{(["choice", "free"] as const).map((m) => {
|
||||
const active = m === "free" ? free : !free;
|
||||
@@ -779,7 +816,7 @@ export function AgentSection({
|
||||
data-testid={`quiz-mechanics-${m}`}
|
||||
onClick={() => onSectionChange(composeQuizMechanics(agent, m))}
|
||||
className={cn(
|
||||
"h-8 rounded-md px-3 text-sm font-medium transition-colors",
|
||||
"min-h-8 min-w-0 rounded-md px-3 py-1 text-sm leading-snug font-medium break-words transition-colors",
|
||||
active ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user