Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion src/app/setup-api/ai-models/configure/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
DEFAULT_COMPACTION_RESERVE_TOKENS_FLOOR,
inferConfiguredLocalModel,
readConfig as readOpenClawConfig,
applyModelOverrideToAllAgentSessions,
parseFullyQualifiedModel,
} from "@/lib/openclaw-config";
import {
getDefaultLlamaCppModel,
Expand Down Expand Up @@ -588,7 +590,38 @@ export async function POST(request: Request) {
await ensureFallbackModel(config.defaultModel);
}

// 8. Restart OpenClaw gateway so it picks up the new auth profile and model
// 8. Sweep every existing session's per-session override to the new
// primary model, tagged `source: "user"` so OpenClaw's per-turn
// model resolver returns early and doesn't flip the session back
// to the previous provider on the first message after the switch.
// Without this, a session that was bound to e.g. openai-codex
// keeps routing to openai-codex even after the user changes the
// primary provider to ClawBox AI / DeepSeek / etc. — the new
// default only seeds future sessions. Mirror of the sweep in
// /setup-api/chat/model (see PR #73 for context on why "user" is
// the only sticky source value).
//
// Only sweep when this configure call actually set a new primary
// (skip for local-only local-AI setups that leave the primary
// alone).
if (!isLocalScope || shouldPromoteLocalToPrimary) {
const parsedPrimary = parseFullyQualifiedModel(config.defaultModel);
if (parsedPrimary) {
try {
await applyModelOverrideToAllAgentSessions({
provider: parsedPrimary.provider,
modelId: parsedPrimary.modelId,
source: "user",
});
} catch (err) {
// Non-fatal: the default change above still takes effect for
// brand-new sessions; worst case the user resets the open chat.
console.error("[configure] Failed to sweep session overrides:", err);
}
}
}

// 9. Restart OpenClaw gateway so it picks up the new auth profile and model
try {
await restartGateway();
} catch (err) {
Expand Down
1 change: 1 addition & 0 deletions src/app/setup-api/chat/model/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const PROVIDER_LABELS: Record<string, string> = {
clawai: "ClawBox AI",
anthropic: "Anthropic Claude",
openai: "OpenAI GPT",
"openai-codex": "OpenAI Codex",
google: "Google Gemini",
openrouter: "OpenRouter",
ollama: "Ollama Local",
Expand Down
91 changes: 82 additions & 9 deletions src/components/ChatPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -379,11 +379,23 @@ function ChatPopup({ isOpen, onClose, onOpenFull, onOpenSettingsSection, onThink
const sessionDefaults = snapshot?.sessionDefaults as Record<string, unknown> | undefined
const mainSessionKey = (sessionDefaults?.mainSessionKey as string) || 'main'
sessionKeyRef.current = mainSessionKey
// If a skill was just installed/uninstalled, start fresh session
// If a skill was just installed/uninstalled, start fresh session.
// Provider changes re-use the same flag for retry-budget + overlay
// purposes, but we skip the auto-send prompt for them (no skill
// changed, there's nothing to confirm) — just reset and hand
// control back to the user.
if (skillInstalledRef.current) {
const wasProviderChange = reloadReasonRef.current === 'provider'
skillInstalledRef.current = false
setMessages([])
greetedRef.current = true // prevent auto-greet
reloadReasonRef.current = 'skill' // reset for next reload
// Only reset the transcript for skill install/uninstall/etc.
// Provider changes keep the visible history so the user's
// earlier context isn't wiped — only the backend session
// override changed, not the conversation semantics.
if (!wasProviderChange) {
setMessages([])
greetedRef.current = true // prevent auto-greet
}
const evt = skillEventRef.current
skillEventRef.current = null
// Build context message about the skill change
Expand All @@ -400,9 +412,34 @@ function ChatPopup({ isOpen, onClose, onOpenFull, onOpenSettingsSection, onThink
// Complete the progress bar
if (reloadTimerRef.current) clearInterval(reloadTimerRef.current)
setReloadProgress(100)
// Small delay to show 100%, then switch to sending dots
setTimeout(() => {
// Small delay to show 100%, then either auto-send the skill
// context message (skill install/uninstall) or, for a
// provider change, just drop the overlay and surface a
// green "Switched chat to X" banner so the user has an
// explicit confirmation the new provider is active.
setTimeout(async () => {
setReloadingSkill(false)
if (wasProviderChange) {
// Refresh chat/model state so we can label the banner with
// the new active provider. Fire-and-forget — if the fetch
// fails the worst case is we don't show the banner, not
// that the chat is broken.
try {
const res = await fetch('/setup-api/chat/model', { cache: 'no-store' })
const state = await res.json() as ChatModelState
setChatModelState(state)
const label = state.activeLabel ?? state.primary?.label ?? 'the new AI provider'
Comment on lines +427 to +431

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate the chat-model response before committing it to state.

A non-OK JSON response like { error: ... } is currently cast to ChatModelState; the next render can crash when it reads chatModelState.options.map.

🛡️ Proposed fix
                 try {
                   const res = await fetch('/setup-api/chat/model', { cache: 'no-store' })
-                  const state = await res.json() as ChatModelState
+                  if (!res.ok) throw new Error('Failed to refresh chat model state')
+                  const state = await res.json() as ChatModelState
+                  if (!Array.isArray(state.options)) {
+                    throw new Error('Invalid chat model state response')
+                  }
                   setChatModelState(state)
                   const label = state.activeLabel ?? state.primary?.label ?? 'the new AI provider'
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
const res = await fetch('/setup-api/chat/model', { cache: 'no-store' })
const state = await res.json() as ChatModelState
setChatModelState(state)
const label = state.activeLabel ?? state.primary?.label ?? 'the new AI provider'
try {
const res = await fetch('/setup-api/chat/model', { cache: 'no-store' })
if (!res.ok) throw new Error('Failed to refresh chat model state')
const state = await res.json() as ChatModelState
if (!Array.isArray(state.options)) {
throw new Error('Invalid chat model state response')
}
setChatModelState(state)
const label = state.activeLabel ?? state.primary?.label ?? 'the new AI provider'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/ChatPopup.tsx` around lines 421 - 425, The fetch response from
'/setup-api/chat/model' should be validated before casting and setting state;
inside the try block where you call fetch and parse JSON, check res.ok (and/or
validate that parsed object matches ChatModelState shape and has options array)
and handle non-OK or malformed responses by throwing or returning early (e.g.,
log error and do not call setChatModelState). Update the logic around
setChatModelState and the subsequent label derivation (the const label =
state.activeLabel ...) to run only when the response is valid so that later uses
like chatModelState.options.map cannot crash. Ensure you reference the existing
symbols: fetch('/setup-api/chat/model'), setChatModelState, ChatModelState, and
chatModelState.options.map when applying the checks.

setMessages(prev => [...prev, {
role: 'system',
text: `Switched chat to ${label}.`,
timestamp: Date.now(),
variant: 'success',
}])
} catch {
// Ignore — banner is best-effort confirmation only.
}
return
}
setSending(true)
setMessages([{ role: 'user', text: contextMsg.replace(/\[System:.*?\]\s*/g, ''), timestamp: Date.now() }])
wsRequest('chat.send', {
Expand Down Expand Up @@ -774,12 +811,22 @@ function ChatPopup({ isOpen, onClose, onOpenFull, onOpenSettingsSection, onThink
const skillEventRef = useRef<{ action: string; name?: string; id?: string } | null>(null)
const [reloadingSkill, setReloadingSkill] = useState(false)
const [reloadProgress, setReloadProgress] = useState(0)
const [reloadReason, setReloadReason] = useState<'skill' | 'provider'>('skill')
// Duplicate of reloadReason behind a ref because the WebSocket `hello`
// resolve callback is created once (inside a useCallback with [] deps)
// and captures whatever reloadReason state was at mount time —
// without this ref, the `wasProviderChange` branch would never fire
// because the state update from the event handler doesn't propagate
// into that frozen closure.
const reloadReasonRef = useRef<'skill' | 'provider'>('skill')
const reloadTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => {
const handler = (e: Event) => {
const makeHandler = (reason: 'skill' | 'provider') => (e: Event) => {
const detail = (e as CustomEvent).detail || {}
skillInstalledRef.current = true
skillEventRef.current = detail
reloadReasonRef.current = reason
setReloadReason(reason)
setReloadingSkill(true)
setReloadProgress(0)
retryCountRef.current = 0
Expand All @@ -795,9 +842,35 @@ function ChatPopup({ isOpen, onClose, onOpenFull, onOpenSettingsSection, onThink
}
}, 200)
}
window.addEventListener('clawbox-skill-installed', handler)
const skillHandler = makeHandler('skill')
// Treat a primary-AI-provider change the same as a skill install:
// the gateway is restarting, the chat WS is about to drop, and
// without the progress overlay the user sees the chat freeze until
// the bare retry loop reconnects. Reusing the skillInstalledRef flag
// also gets us the quadrupled retry budget for the reconnect, so
// slower restarts don't trigger the 'Could not connect to gateway'
// fallback UI.
const providerReloadHandler = makeHandler('provider')
const providerHandler = (e: Event) => {
providerReloadHandler(e)
// The configure route restarts the gateway before returning its
// response, and the Settings event fires *after* the response —
// so by the time we get here the WS may already have reconnected
// on its own. If so, no future `hello` is coming to trip the
// reload branch in the resolve callback, and the overlay would
// stay up forever. Force a fresh connect() so the resolve-branch
// fires exactly once, right now, with reloadReasonRef=='provider'.
if (wsRef.current?.readyState === WebSocket.OPEN) {
try { wsRef.current.close() } catch { /* ignore */ }
}
retryCountRef.current = 0
connect()
}
window.addEventListener('clawbox-skill-installed', skillHandler)
window.addEventListener('clawbox:primary-ai-configured', providerHandler)
return () => {
window.removeEventListener('clawbox-skill-installed', handler)
window.removeEventListener('clawbox-skill-installed', skillHandler)
window.removeEventListener('clawbox:primary-ai-configured', providerHandler)
if (reloadTimerRef.current) clearInterval(reloadTimerRef.current)
}
}, [])
Expand Down Expand Up @@ -1024,7 +1097,7 @@ function ChatPopup({ isOpen, onClose, onOpenFull, onOpenSettingsSection, onThink
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 14, width: '85%' }}>
<div style={SPINNER_STYLE} />
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, width: '100%' }}>
<span>Reloading skills...</span>
<span>{reloadReason === 'provider' ? 'Switching AI provider...' : 'Reloading skills...'}</span>
<div role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={reloadProgress} aria-label="Reload progress" style={{ width: '100%', height: 4, borderRadius: 2, background: 'rgba(255,255,255,0.08)', overflow: 'hidden' }}>
<div style={{
height: '100%', borderRadius: 2, background: '#f97316',
Expand Down
Loading