diff --git a/app/components/settings/llm-configurations-panel.tsx b/app/components/settings/llm-configurations-panel.tsx index cc85200..1e35ae5 100644 --- a/app/components/settings/llm-configurations-panel.tsx +++ b/app/components/settings/llm-configurations-panel.tsx @@ -52,6 +52,7 @@ import { getUsageSummary, listConfigurations, REASONING_EFFORTS, + saveActiveReasoning, updateConfiguration, type CatalogEntry, type LlmConfiguration, @@ -134,6 +135,10 @@ export function LlmConfigurationsPanel() { baseURL: c.base_url || undefined, secretName: c.secret_name || undefined, }) + // Inherit this config's reasoning default. The /ai composer chip + // listens for this and updates live; if the operator already + // override it via the chip, the next save propagates here. + saveActiveReasoning(c.reasoning_effort ?? "off") setActive(loadActiveSettings()) } diff --git a/app/lib/arcadia/llm-configs.ts b/app/lib/arcadia/llm-configs.ts index 9fd8e14..ac51d1c 100644 --- a/app/lib/arcadia/llm-configs.ts +++ b/app/lib/arcadia/llm-configs.ts @@ -198,3 +198,49 @@ export function findSpend( ): UsageByModelRow | undefined { return rows.find((r) => r.provider === config.provider && r.model === config.model) } + +// --------------------------------------------------------------------------- +// Active reasoning_effort (shared between settings panel and /ai composer) +// +// Stored under crema.ai.reasoning. Written when the operator stars a config +// in the settings panel (so the chip on /ai inherits that config's default +// on next mount) and when the operator cycles the THINK chip on /ai (per- +// conversation override). Wiped on Clear conversation. +// --------------------------------------------------------------------------- + +const ACTIVE_REASONING_KEY = "crema.ai.reasoning" +const ACTIVE_REASONING_EVENT = "crema:ai-reasoning-change" + +export function loadActiveReasoning(): ReasoningEffort { + if (typeof window === "undefined") return "off" + const v = localStorage.getItem(ACTIVE_REASONING_KEY) as ReasoningEffort | null + return v && REASONING_EFFORTS.includes(v) ? v : "off" +} + +export function saveActiveReasoning(v: ReasoningEffort): void { + if (typeof window === "undefined") return + if (v === "off") localStorage.removeItem(ACTIVE_REASONING_KEY) + else localStorage.setItem(ACTIVE_REASONING_KEY, v) + window.dispatchEvent(new CustomEvent(ACTIVE_REASONING_EVENT, { detail: v })) +} + +export function subscribeActiveReasoning( + listener: (v: ReasoningEffort) => void, +): () => void { + if (typeof window === "undefined") return () => {} + const onChange = (e: Event) => { + const detail = (e as CustomEvent).detail + if (detail) listener(detail) + else listener(loadActiveReasoning()) + } + // Same-tab via the custom event; cross-tab via the storage event. + const onStorage = (e: StorageEvent) => { + if (e.key === ACTIVE_REASONING_KEY) listener(loadActiveReasoning()) + } + window.addEventListener(ACTIVE_REASONING_EVENT, onChange) + window.addEventListener("storage", onStorage) + return () => { + window.removeEventListener(ACTIVE_REASONING_EVENT, onChange) + window.removeEventListener("storage", onStorage) + } +} diff --git a/app/routes/ai.tsx b/app/routes/ai.tsx index 7965a22..4fcc21b 100644 --- a/app/routes/ai.tsx +++ b/app/routes/ai.tsx @@ -79,6 +79,12 @@ import { runLLMToolCalls, } from "~/lib/admin-tools" import { ARCADIA_KNOWLEDGE } from "~/lib/arcadia-knowledge" +import { + loadActiveReasoning, + saveActiveReasoning, + subscribeActiveReasoning, + type ReasoningEffort, +} from "~/lib/arcadia/llm-configs" import { formatAdminContextForPrompt } from "~/lib/admin-context" import { ConfirmCard } from "~/components/assistant/confirm-card" import { renderToolResult } from "~/components/assistant/tool-result-renderers" @@ -181,21 +187,11 @@ function clearLive() { } /* Per-conversation reasoning override. Cycle order matters — the composer - * chip walks this array. */ -type ReasoningEffort = "off" | "low" | "medium" | "high" | "max" + * chip walks this array. Storage helpers (load/save/subscribe) live in + * lib/arcadia/llm-configs.ts so the settings panel and the /ai composer + * coordinate via the same crema.ai.reasoning key. */ const REASONING_LEVELS: ReasoningEffort[] = ["off", "low", "medium", "high", "max"] -const REASONING_KEY = "crema.ai.reasoning" -function loadReasoning(): ReasoningEffort { - if (typeof window === "undefined") return "off" - const v = localStorage.getItem(REASONING_KEY) as ReasoningEffort | null - return v && REASONING_LEVELS.includes(v) ? v : "off" -} -function saveReasoning(v: ReasoningEffort) { - if (typeof window === "undefined") return - if (v === "off") localStorage.removeItem(REASONING_KEY) - else localStorage.setItem(REASONING_KEY, v) -} function withReasoning>( extras: T, effort: ReasoningEffort, @@ -537,7 +533,9 @@ function ChatSurface({ setMessages([]) setAgentHistory(new Map()) setMessageAgents(new Map()) - setReasoningEffort("off") + // Keep reasoningEffort as-is. It's bound to the active config's + // default (set when the operator stars a config in Settings) and + // resetting it would silently undo their intent on every clear. }, [setMessages]) // Auto tool-loop using native function calls. Reads run automatically; @@ -873,13 +871,23 @@ function ChatSurface({ // Per-conversation reasoning override. Persists across page reloads via // localStorage so the operator's chosen level survives a refresh, but // resets when they clear the conversation. "off" = pass nothing through. + // Initialize from the shared key (settings panel writes this when the + // operator stars a config), persist on change, and live-subscribe so a + // star action in another tab/route updates the chip without a refresh. const [reasoningEffort, setReasoningEffort] = useState( - () => loadReasoning(), + () => loadActiveReasoning(), ) useEffect(() => { - saveReasoning(reasoningEffort) + saveActiveReasoning(reasoningEffort) reasoningEffortRef.current = reasoningEffort }, [reasoningEffort]) + useEffect(() => { + return subscribeActiveReasoning((next) => { + // Don't loop on our own writes — we already wrote `reasoningEffort` + // when it changed. Only pick up writes that disagree with state. + setReasoningEffort((cur) => (cur === next ? cur : next)) + }) + }, []) const cycleReasoning = useCallback(() => { setReasoningEffort((cur) => {