ai: composer chip inherits active config's reasoning default

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) <noreply@anthropic.com>
This commit is contained in:
jules
2026-05-02 20:18:06 +10:00
parent c379ebc37a
commit c640721c8e
3 changed files with 75 additions and 16 deletions

View File

@@ -52,6 +52,7 @@ import {
getUsageSummary, getUsageSummary,
listConfigurations, listConfigurations,
REASONING_EFFORTS, REASONING_EFFORTS,
saveActiveReasoning,
updateConfiguration, updateConfiguration,
type CatalogEntry, type CatalogEntry,
type LlmConfiguration, type LlmConfiguration,
@@ -134,6 +135,10 @@ export function LlmConfigurationsPanel() {
baseURL: c.base_url || undefined, baseURL: c.base_url || undefined,
secretName: c.secret_name || 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()) setActive(loadActiveSettings())
} }

View File

@@ -198,3 +198,49 @@ export function findSpend(
): UsageByModelRow | undefined { ): UsageByModelRow | undefined {
return rows.find((r) => r.provider === config.provider && r.model === config.model) 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<ReasoningEffort>).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)
}
}

View File

@@ -79,6 +79,12 @@ import {
runLLMToolCalls, runLLMToolCalls,
} from "~/lib/admin-tools" } from "~/lib/admin-tools"
import { ARCADIA_KNOWLEDGE } from "~/lib/arcadia-knowledge" 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 { formatAdminContextForPrompt } from "~/lib/admin-context"
import { ConfirmCard } from "~/components/assistant/confirm-card" import { ConfirmCard } from "~/components/assistant/confirm-card"
import { renderToolResult } from "~/components/assistant/tool-result-renderers" import { renderToolResult } from "~/components/assistant/tool-result-renderers"
@@ -181,21 +187,11 @@ function clearLive() {
} }
/* Per-conversation reasoning override. Cycle order matters — the composer /* Per-conversation reasoning override. Cycle order matters — the composer
* chip walks this array. */ * chip walks this array. Storage helpers (load/save/subscribe) live in
type ReasoningEffort = "off" | "low" | "medium" | "high" | "max" * 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_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<T extends Record<string, unknown>>( function withReasoning<T extends Record<string, unknown>>(
extras: T, extras: T,
effort: ReasoningEffort, effort: ReasoningEffort,
@@ -537,7 +533,9 @@ function ChatSurface({
setMessages([]) setMessages([])
setAgentHistory(new Map()) setAgentHistory(new Map())
setMessageAgents(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]) }, [setMessages])
// Auto tool-loop using native function calls. Reads run automatically; // 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 // Per-conversation reasoning override. Persists across page reloads via
// localStorage so the operator's chosen level survives a refresh, but // localStorage so the operator's chosen level survives a refresh, but
// resets when they clear the conversation. "off" = pass nothing through. // 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<ReasoningEffort>( const [reasoningEffort, setReasoningEffort] = useState<ReasoningEffort>(
() => loadReasoning(), () => loadActiveReasoning(),
) )
useEffect(() => { useEffect(() => {
saveReasoning(reasoningEffort) saveActiveReasoning(reasoningEffort)
reasoningEffortRef.current = reasoningEffort reasoningEffortRef.current = reasoningEffort
}, [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(() => { const cycleReasoning = useCallback(() => {
setReasoningEffort((cur) => { setReasoningEffort((cur) => {