From c640721c8e960d98c28ff6f50add8e60112ad081 Mon Sep 17 00:00:00 2001 From: jules Date: Sat, 2 May 2026 20:18:06 +1000 Subject: [PATCH] ai: composer chip inherits active config's reasoning default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls the reasoning storage out of ai.tsx and into the shared llm-configs.ts helpers so Settings → LLM and the /ai composer coordinate via one localStorage key (crema.ai.reasoning): - loadActiveReasoning / saveActiveReasoning: read/write helpers. - subscribeActiveReasoning: dispatches a CustomEvent on writes (same-tab) plus a storage-event listener (cross-tab), so the chip updates live when the operator stars a different config in another tab or in the settings panel. Wiring: - Settings panel onMakeActive() now also calls saveActiveReasoning(c.reasoning_effort ?? "off"). Starring a config seeds the chip with that config's default. - /ai chip useEffect subscribes to changes; a star in Settings while /ai is open flips the chip in real time. - resetAndClear no longer wipes reasoningEffort. Clearing the conversation shouldn't silently undo the operator's stated intent for thinking-mode (which is bound to their active config, not to the conversation). Net behaviour: - Star a config with reasoning_effort=medium → chip on /ai shows THINK MEDIUM next time you visit (or immediately if /ai is open). - Cycle the chip while on /ai → just an override for the current conversation, not back-propagated to the saved config. - Edit the config in Settings to change its default → propagates to the chip on next star (intentional — direct edits don't auto- re-activate). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../settings/llm-configurations-panel.tsx | 5 ++ app/lib/arcadia/llm-configs.ts | 46 +++++++++++++++++++ app/routes/ai.tsx | 40 +++++++++------- 3 files changed, 75 insertions(+), 16 deletions(-) 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) => {