From cdad770132eeca9594b6c99f0014cbd63c606e7d Mon Sep 17 00:00:00 2001 From: xmanrui <841206367@qq.com> Date: Thu, 12 Mar 2026 02:13:21 +0800 Subject: [PATCH] feat: support agent model switching via gateway patch --- app/api/config/agent-model/route.ts | 242 +++++++++++++++ app/api/config/route.ts | 6 +- app/components/agent-card.tsx | 109 ++++++- app/page.tsx | 47 ++- lib/config-cache.ts | 18 ++ lib/i18n.tsx | 21 ++ lib/openclaw-cli.ts | 45 +++ prd/agent_card_model_switch_prd.md | 457 ++++++++++++++++++++++++++++ 8 files changed, 931 insertions(+), 14 deletions(-) create mode 100644 app/api/config/agent-model/route.ts create mode 100644 lib/config-cache.ts create mode 100644 prd/agent_card_model_switch_prd.md diff --git a/app/api/config/agent-model/route.ts b/app/api/config/agent-model/route.ts new file mode 100644 index 0000000..bf158a8 --- /dev/null +++ b/app/api/config/agent-model/route.ts @@ -0,0 +1,242 @@ +import { NextResponse } from "next/server"; +import fs from "fs"; +import path from "path"; +import { clearConfigCache } from "@/lib/config-cache"; +import { callOpenclawGateway, resolveConfigSnapshotHash } from "@/lib/openclaw-cli"; +import { OPENCLAW_AGENTS_DIR } from "@/lib/openclaw-paths"; + +const GATEWAY_CALL_TIMEOUT_MS = 15000; +const GATEWAY_RECOVERY_TIMEOUT_MS = 45000; +const GATEWAY_RECOVERY_POLL_MS = 1000; + +type ConfigSnapshot = { + valid?: boolean; + hash?: string; + raw?: string | null; + config?: any; +}; + +const SESSION_MODEL_FIELDS_TO_CLEAR = [ + "providerOverride", + "modelOverride", + "authProfileOverride", + "authProfileOverrideSource", + "authProfileOverrideCompactionCount", + "fallbackNoticeSelectedModel", + "fallbackNoticeActiveModel", + "fallbackNoticeReason", + "claudeCliSessionId", + "modelProvider", + "model", +] as const; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function unwrapGatewayResult(payload: any): T { + if (isPlainObject(payload) && "result" in payload) { + return payload.result as T; + } + return payload as T; +} + +function normalizeErrorMessage(err: unknown): string { + if (err instanceof Error && err.message.trim()) return err.message.trim(); + return "Unknown error"; +} + +function statusForError(message: string): number { + const lower = message.toLowerCase(); + if (lower.includes("config changed since last load")) return 409; + if (lower.includes("missing") || lower.includes("invalid") || lower.includes("not found") || lower.includes("must")) return 400; + if ( + lower.includes("gateway closed") || + lower.includes("timeout") || + lower.includes("econn") || + lower.includes("not running") || + lower.includes("abnormal closure") + ) { + return 503; + } + return 500; +} + +function findAgentConfigEntry(config: any, agentId: string): Record | null { + const agentList = Array.isArray(config?.agents?.list) ? config.agents.list : null; + if (!agentList) return null; + const entry = agentList.find((agent: any) => agent && agent.id === agentId); + return isPlainObject(entry) ? entry : null; +} + +function addModelRef(set: Set, value: unknown): void { + if (typeof value !== "string") return; + const trimmed = value.trim(); + if (!trimmed || !trimmed.includes("/")) return; + set.add(trimmed); +} + +function collectKnownModels(config: any): Set { + const models = new Set(); + + const providers = isPlainObject(config?.models?.providers) ? config.models.providers : {}; + for (const [providerId, provider] of Object.entries(providers)) { + const providerModels = Array.isArray((provider as any)?.models) ? (provider as any).models : []; + for (const model of providerModels) { + addModelRef(models, `${providerId}/${model?.id ?? ""}`); + } + } + + const defaultsModel = config?.agents?.defaults?.model; + if (typeof defaultsModel === "string") { + addModelRef(models, defaultsModel); + } else if (isPlainObject(defaultsModel)) { + addModelRef(models, defaultsModel.primary); + const fallbacks = Array.isArray(defaultsModel.fallbacks) ? defaultsModel.fallbacks : []; + for (const fallback of fallbacks) addModelRef(models, fallback); + } + + const defaultsModels = isPlainObject(config?.agents?.defaults?.models) ? config.agents.defaults.models : {}; + for (const modelKey of Object.keys(defaultsModels)) { + addModelRef(models, modelKey); + } + + const agentList = Array.isArray(config?.agents?.list) ? config.agents.list : []; + for (const agent of agentList) { + if (!agent) continue; + addModelRef(models, agent.model); + if (isPlainObject(agent.model)) { + addModelRef(models, agent.model.primary); + addModelRef(models, agent.model.default); + } + } + + return models; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function getConfigSnapshot(): Promise { + return unwrapGatewayResult( + await callOpenclawGateway("config.get", {}, GATEWAY_CALL_TIMEOUT_MS), + ); +} + +async function waitForPatchedModel(agentId: string, model: string): Promise { + const deadline = Date.now() + GATEWAY_RECOVERY_TIMEOUT_MS; + let lastError = ""; + + while (Date.now() < deadline) { + await sleep(GATEWAY_RECOVERY_POLL_MS); + try { + const snapshot = await getConfigSnapshot(); + const config = snapshot?.config; + const agentEntry = findAgentConfigEntry(config, agentId); + if (agentEntry && typeof agentEntry.model === "string" && agentEntry.model.trim() === model) { + return; + } + } catch (err) { + lastError = normalizeErrorMessage(err); + } + } + + throw new Error(lastError || "Timed out waiting for Gateway to apply the new model"); +} + +function clearAgentSessionModelState(agentId: string): void { + const sessionsPath = path.join(OPENCLAW_AGENTS_DIR, agentId, "sessions", "sessions.json"); + if (!fs.existsSync(sessionsPath)) return; + + const raw = fs.readFileSync(sessionsPath, "utf8"); + const sessions = JSON.parse(raw); + if (!isPlainObject(sessions)) return; + + let changed = false; + for (const value of Object.values(sessions)) { + if (!isPlainObject(value)) continue; + for (const field of SESSION_MODEL_FIELDS_TO_CLEAR) { + if (Object.prototype.hasOwnProperty.call(value, field)) { + delete value[field]; + changed = true; + } + } + } + + if (!changed) return; + + const tmpPath = `${sessionsPath}.tmp`; + fs.writeFileSync(tmpPath, JSON.stringify(sessions, null, 2), "utf8"); + fs.renameSync(tmpPath, sessionsPath); +} + +export async function PATCH(request: Request) { + try { + const body = await request.json().catch(() => null); + const agentId = String(body?.agentId || "").trim(); + const model = String(body?.model || "").trim(); + + if (!agentId || !model) { + return NextResponse.json({ ok: false, error: "Missing agentId or model" }, { status: 400 }); + } + + const snapshot = await getConfigSnapshot(); + if (snapshot?.valid === false || !isPlainObject(snapshot?.config)) { + return NextResponse.json({ ok: false, error: "Gateway config is invalid or unavailable" }, { status: 400 }); + } + + const baseHash = resolveConfigSnapshotHash(snapshot); + if (!baseHash) { + return NextResponse.json({ ok: false, error: "Missing baseHash from config snapshot" }, { status: 500 }); + } + + const config = snapshot.config; + const agentEntry = findAgentConfigEntry(config, agentId); + if (!agentEntry) { + return NextResponse.json({ ok: false, error: `Agent not found in agents.list: ${agentId}` }, { status: 404 }); + } + + const knownModels = collectKnownModels(config); + if (!knownModels.has(model)) { + return NextResponse.json({ ok: false, error: `Unknown model: ${model}` }, { status: 400 }); + } + + const patch = { + agents: { + list: [ + { + id: agentId, + model, + }, + ], + }, + }; + + await callOpenclawGateway( + "config.patch", + { + raw: JSON.stringify(patch), + baseHash, + note: `Dashboard updated ${agentId} model to ${model}`, + }, + GATEWAY_CALL_TIMEOUT_MS, + ); + + clearConfigCache(); + await waitForPatchedModel(agentId, model); + clearAgentSessionModelState(agentId); + clearConfigCache(); + + return NextResponse.json({ + ok: true, + agentId, + model, + applied: true, + resetSessions: true, + }); + } catch (err) { + const error = normalizeErrorMessage(err); + return NextResponse.json({ ok: false, error }, { status: statusForError(error) }); + } +} diff --git a/app/api/config/route.ts b/app/api/config/route.ts index 9728315..1476a70 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -1,14 +1,13 @@ import { NextResponse } from "next/server"; import fs from "fs"; import path from "path"; +import { getConfigCache, setConfigCache } from "@/lib/config-cache"; import { OPENCLAW_CONFIG_PATH, OPENCLAW_HOME } from "@/lib/openclaw-paths"; // 配置文件路径:优先使用 OPENCLAW_HOME 环境变量,否则默认 ~/.openclaw const CONFIG_PATH = OPENCLAW_CONFIG_PATH; const OPENCLAW_DIR = OPENCLAW_HOME; -// 30秒内存缓存 -let configCache: { data: any; ts: number } | null = null; const CACHE_TTL_MS = 30_000; // 从配置的 allowFrom 读取用户 id,用于构建 session key @@ -256,6 +255,7 @@ function readIdentityName(agentId: string, agentDir?: string, workspace?: string export async function GET() { // 命中缓存直接返回 + const configCache = getConfigCache(); if (configCache && Date.now() - configCache.ts < CACHE_TTL_MS) { return NextResponse.json(configCache.data); } @@ -547,7 +547,7 @@ export async function GET() { }, groupChats, }; - configCache = { data, ts: Date.now() }; + setConfigCache({ data, ts: Date.now() }); return NextResponse.json(data); } catch (err: any) { return NextResponse.json({ error: err.message }, { status: 500 }); diff --git a/app/components/agent-card.tsx b/app/components/agent-card.tsx index 2aec776..7653913 100644 --- a/app/components/agent-card.tsx +++ b/app/components/agent-card.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { buildGatewayUrl } from "@/lib/gateway-url"; export interface AgentPlatform { @@ -31,6 +31,13 @@ export interface AgentCardAgent { session?: AgentCardSession; } +export interface AgentModelOptionGroup { + providerId: string; + providerName: string; + accessMode?: "auth" | "api_key"; + models: Array<{ id: string; name: string }>; +} + export interface PlatformTestResult { ok: boolean; reply?: string; @@ -341,6 +348,8 @@ export function AgentCard({ agentState, dmSessionResults, providerAccessModeMap, + modelOptions, + onModelChange, }: { agent: AgentCardAgent; gatewayPort: number; @@ -353,12 +362,30 @@ export function AgentCard({ agentState?: string; dmSessionResults?: Record; providerAccessModeMap?: Record; + modelOptions?: AgentModelOptionGroup[]; + onModelChange?: (agentId: string, model: string) => Promise; }) { + const [isEditingModel, setIsEditingModel] = useState(false); + const [draftModel, setDraftModel] = useState(agent.model); + const [isSavingModel, setIsSavingModel] = useState(false); + const [modelSaveError, setModelSaveError] = useState(null); const sessionKey = `agent:${agent.id}:main`; let sessionUrl = buildGatewayUrl(gatewayPort, "/chat", { session: sessionKey }, gatewayHost); if (gatewayToken) sessionUrl = buildGatewayUrl(gatewayPort, "/chat", { session: sessionKey, token: gatewayToken }, gatewayHost); const modelProvider = agent.model.includes("/") ? agent.model.split("/", 1)[0] : "default"; const modelAccessMode = providerAccessModeMap?.[modelProvider]; + const canSwitchModel = !!onModelChange && !!modelOptions && modelOptions.length > 0; + const knownModelRefs = new Set( + (modelOptions || []).flatMap((group) => group.models.map((model) => `${group.providerId}/${model.id}`)), + ); + const currentModelKnown = knownModelRefs.has(agent.model); + + useEffect(() => { + if (!isEditingModel) { + setDraftModel(agent.model); + setModelSaveError(null); + } + }, [agent.model, isEditingModel]); function formatTimeAgo(ts: number): string { const diff = Date.now() - ts; @@ -371,6 +398,20 @@ export function AgentCard({ return `${days} ${t("common.daysAgo")}`; } + async function handleModelSave(): Promise { + if (!onModelChange || !draftModel || draftModel === agent.model) return; + setIsSavingModel(true); + setModelSaveError(null); + try { + await onModelChange(agent.id, draftModel); + setIsEditingModel(false); + } catch (err: any) { + setModelSaveError(err?.message || t("agent.modelApplyFailed")); + } finally { + setIsSavingModel(false); + } + } + return (
{t("agent.model")} -
+
{testResult === undefined ? ( -- @@ -415,7 +456,71 @@ export function AgentCard({ ) : ( )} + {canSwitchModel && !isEditingModel && ( + + )}
+ {canSwitchModel && isEditingModel && ( +
+ +
+ + +
+

+ {t("agent.modelApplyHint")} +

+ {modelSaveError && ( +

{modelSaveError}

+ )} +
+ )}
diff --git a/app/page.tsx b/app/page.tsx index cef3a75..1b2d515 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -6,6 +6,7 @@ import { GatewayStatus } from "./gateway-status"; import { AgentCard, ModelBadge, + type AgentModelOptionGroup, type PlatformTestResult, type AgentModelTestResult, type AgentSessionTestResult, @@ -46,7 +47,11 @@ interface GroupChat { interface ConfigData { agents: Agent[]; defaults: { model: string; fallbacks: string[] }; - providers?: { id: string; accessMode?: "auth" | "api_key" }[]; + providers?: Array<{ + id: string; + accessMode?: "auth" | "api_key"; + models?: Array<{ id: string; name?: string }>; + }>; gateway?: { port: number; token?: string; host?: string }; groupChats?: GroupChat[]; } @@ -292,6 +297,19 @@ export default function Home() { }); }, []); + const changeAgentModel = useCallback(async (agentId: string, model: string) => { + const resp = await fetch("/api/config/agent-model", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ agentId, model }), + }); + const payload = await parseApiPayload(resp); + if (!payload.ok) { + throw new Error(payload.errorText || t("agent.modelApplyFailed")); + } + fetchData(true); + }, [fetchData, parseApiPayload, t]); + // 首次加载 - 从 localStorage 恢复测试状态 useEffect(() => { fetchData(!!cachedHomeData); @@ -545,6 +563,17 @@ export default function Home() { if (!p?.id || !p.accessMode) continue; providerAccessModeMap[p.id] = p.accessMode; } + const modelOptions: AgentModelOptionGroup[] = (data.providers || []) + .filter((provider) => provider?.id && Array.isArray(provider.models) && provider.models.length > 0) + .map((provider) => ({ + providerId: provider.id, + providerName: provider.id, + accessMode: provider.accessMode, + models: (provider.models || []).map((model) => ({ + id: model.id, + name: model.name || model.id, + })), + })); return (
{/* 头部 */} @@ -621,9 +650,16 @@ export default function Home() {
+ {/* 卡片墙 */} +
+ {data.agents.map((agent) => ( + + ))} +
+ {/* Agent 任務追蹤 */} {agentActivity && agentActivity.some(a => a.state !== "offline") && ( -
+

📋 Agent 任務追蹤

{agentActivity @@ -667,13 +703,6 @@ export default function Home() {
)} - {/* 卡片墙 */} -
- {data.agents.map((agent) => ( - - ))} -
- {/* 汇总统计趋势 */} {allStats && (
diff --git a/lib/config-cache.ts b/lib/config-cache.ts new file mode 100644 index 0000000..26f099e --- /dev/null +++ b/lib/config-cache.ts @@ -0,0 +1,18 @@ +type ConfigCacheEntry = { + data: any; + ts: number; +}; + +let configCache: ConfigCacheEntry | null = null; + +export function getConfigCache(): ConfigCacheEntry | null { + return configCache; +} + +export function setConfigCache(entry: ConfigCacheEntry): void { + configCache = entry; +} + +export function clearConfigCache(): void { + configCache = null; +} diff --git a/lib/i18n.tsx b/lib/i18n.tsx index 1822b6d..d391b60 100644 --- a/lib/i18n.tsx +++ b/lib/i18n.tsx @@ -103,6 +103,13 @@ const translations: Record> = { "agent.lastActive": "最近活躍", "agent.todayAvgResponse": "平均回應", "agent.todayAvgResponseTip": "今日平均回應時間", + "agent.switchModel": "切換模型", + "agent.saveModel": "儲存並套用", + "agent.cancelModel": "取消", + "agent.modelSaving": "套用中...", + "agent.modelApplyHint": "儲存後會自動重新啟動 Gateway,以套用新模型。", + "agent.modelApplyFailed": "模型套用失敗", + "agent.currentUnknownModel": "目前模型(不在候選列表)", // agent status "agent.status.working": "工作中", @@ -363,6 +370,13 @@ const translations: Record> = { "agent.lastActive": "最近活跃", "agent.todayAvgResponse": "平均响应", "agent.todayAvgResponseTip": "今日平均响应时间", + "agent.switchModel": "切换模型", + "agent.saveModel": "保存并应用", + "agent.cancelModel": "取消", + "agent.modelSaving": "应用中...", + "agent.modelApplyHint": "保存后会自动重启 Gateway,以应用新模型。", + "agent.modelApplyFailed": "模型应用失败", + "agent.currentUnknownModel": "当前模型(不在候选列表)", // agent status "agent.status.working": "工作中", @@ -623,6 +637,13 @@ const translations: Record> = { "agent.lastActive": "Last Active", "agent.todayAvgResponse": "Avg Response", "agent.todayAvgResponseTip": "Today's average response time", + "agent.switchModel": "Switch Model", + "agent.saveModel": "Save & Apply", + "agent.cancelModel": "Cancel", + "agent.modelSaving": "Applying...", + "agent.modelApplyHint": "Saving will restart the Gateway automatically to apply the new model.", + "agent.modelApplyFailed": "Failed to apply model", + "agent.currentUnknownModel": "Current model (not in option list)", // agent status "agent.status.working": "Working", diff --git a/lib/openclaw-cli.ts b/lib/openclaw-cli.ts index 12e45de..aa2bd7b 100644 --- a/lib/openclaw-cli.ts +++ b/lib/openclaw-cli.ts @@ -1,4 +1,5 @@ import { exec, execFile } from "child_process"; +import crypto from "crypto"; import { promisify } from "util"; const execFileAsync = promisify(execFile); @@ -61,3 +62,47 @@ export function parseJsonFromMixedOutput(output: string): any { } return null; } + +export function parseOpenclawJsonOutput(stdout: string, stderr = ""): any { + const trimmed = stdout.trim(); + if (trimmed) { + try { + return JSON.parse(trimmed); + } catch { + // Fallback below. + } + } + return parseJsonFromMixedOutput(`${stdout}\n${stderr}`); +} + +export function resolveConfigSnapshotHash(snapshot: { hash?: string; raw?: string | null } | null | undefined): string | null { + const hash = snapshot?.hash; + if (typeof hash === "string" && hash.trim()) return hash.trim(); + if (typeof snapshot?.raw !== "string") return null; + return crypto.createHash("sha256").update(snapshot.raw).digest("hex"); +} + +export async function callOpenclawGateway(method: string, params: Record = {}, timeoutMs = 10000): Promise { + try { + const { stdout, stderr } = await execOpenclaw([ + "gateway", + "call", + method, + "--json", + "--timeout", + String(timeoutMs), + "--params", + JSON.stringify(params), + ]); + const parsed = parseOpenclawJsonOutput(stdout, stderr); + if (parsed == null) { + throw new Error(`Failed to parse Gateway response for ${method}`); + } + return parsed; + } catch (err: any) { + const stderr = typeof err?.stderr === "string" ? err.stderr.trim() : ""; + const stdout = typeof err?.stdout === "string" ? err.stdout.trim() : ""; + const message = stderr || stdout || err?.message || `Gateway call failed: ${method}`; + throw new Error(message); + } +} diff --git a/prd/agent_card_model_switch_prd.md b/prd/agent_card_model_switch_prd.md new file mode 100644 index 0000000..04bf2e4 --- /dev/null +++ b/prd/agent_card_model_switch_prd.md @@ -0,0 +1,457 @@ +# 机器人卡片模型切换方案 + +## 一、背景 + +当前机器人总览页已经能展示每个 agent 当前使用的模型,但模型信息是只读的,用户如果想给某个 agent 切换模型,仍然需要手动修改配置。 + +目标是把这个操作前置到机器人卡片上,做到: + +1. 用户可以直接在页面上切换某个 agent 的模型 +2. 切换后配置被持久化保存 +3. 页面立即显示新模型 +4. 后续从 dashboard 发起的相关测试和展示都基于新模型 + +结合当前代码: + +- 首页总览在 [app/page.tsx](/Users/manruixie/code/pixel_agent/OpenClaw-bot-review/app/page.tsx) +- 机器人卡片在 [app/components/agent-card.tsx](/Users/manruixie/code/pixel_agent/OpenClaw-bot-review/app/components/agent-card.tsx) +- 配置读取在 [app/api/config/route.ts](/Users/manruixie/code/pixel_agent/OpenClaw-bot-review/app/api/config/route.ts) +- 配置文件路径来自 [lib/openclaw-paths.ts](/Users/manruixie/code/pixel_agent/OpenClaw-bot-review/lib/openclaw-paths.ts) + +进一步确认本机 OpenClaw 能力后,发现 OpenClaw 提供 Gateway 侧的 `config.patch` 能力,用于安全地做部分配置更新;并且该能力在写入后会自动触发 restart,使新配置真正生效。 + +因此,这个功能不能只改前端展示,也不应该由 dashboard 直接手写配置文件,而应该走 Gateway 的 `config.patch` 标准能力。 + +## 二、目标 + +本期目标: + +1. 在机器人卡片中增加模型切换入口 +2. 支持为单个 agent 选择一个新的模型 +3. 通过 Gateway `config.patch` 更新指定 agent 的模型 +4. 更新后自动触发 Gateway restart,使新模型真正生效 +5. 重启完成后刷新 dashboard 数据,使页面显示最新状态 + +## 三、非目标 + +本期不做以下事情: + +1. 不做批量切换多个 agent 模型 +2. 不编辑全局默认模型和 fallback 模型 +3. 不管理 provider 密钥或 auth profile +4. 不单独设计新的 reload 协议 +5. 不在本期中处理 agent 不存在于 `agents.list` 但被自动扫描出来时的持久化写回 + +## 四、用户故事 + +作为 dashboard 使用者,我希望直接在机器人卡片里切换某个 agent 的模型,这样我就不需要再去手动改配置,并且切换后系统会自动应用新模型。 + +## 五、现状分析 + +## 1. 模型数据来源 + +当前 `/api/config` 会读取 `openclaw.json`,并整理出: + +1. `agents[]` +2. `providers[]` +3. `defaults.model` +4. `defaults.fallbacks` + +其中每个 `agent` 最终展示的 `model` 是这样来的: + +- 优先使用 `agent.model` +- 如果没有,则 fallback 到全局默认模型 + +## 2. 卡片当前能力 + +当前 `AgentCard` 只负责展示: + +1. agent 名称、ID、状态 +2. 当前模型 +3. 平台信息 +4. session 统计 + +它没有任何“修改模型”的动作,也没有提交回调。 + +## 3. 真正生效的配置位置 + +如果要让“切换模型”真的生效,最终被修改的目标配置仍然是: + +`config.agents.list[i].model` + +也就是说,配置层面上最终需要更新的是: + +- 如果 agent 原来已经显式配置了 `model`,则直接改掉它 +- 如果 agent 原来没有 `model`,只是继承默认模型,也要为该 agent 显式补一条 `model` + +但更新动作不由 dashboard 直接写文件,而是由 Gateway `config.patch` 负责执行。 + +## 六、交互方案 + +## 1. 机器人卡片中的模型区域改造 + +当前模型区域是: + +- 模型 badge +- 模型测试状态 + +改造后建议为: + +- 正常态:显示当前模型 badge + “切换模型”按钮 +- 编辑态:显示模型选择器 + 保存 + 取消 +- 保存中:按钮禁用,展示“保存并应用中”状态 +- 失败态:保留编辑态,并显示错误信息 + +保存动作的用户心智应明确为: + +- 不只是“改展示” +- 而是“更新配置并应用” + +## 2. 推荐交互形式 + +采用“卡片内联编辑”,不要跳转单独页面,也不要打开重量级弹窗。 + +原因: + +1. 操作上下文最清晰,用户就是在看某个 agent 时改它 +2. 改动范围小,容易接入现有卡片结构 +3. 不需要额外复杂的页面状态管理 + +## 3. 模型选择器内容 + +模型选择器从首页已有的 `data.providers` 构造,按 provider 分组。 + +每个选项建议展示: + +- `providerId / model.name` +- 如果没有 `name`,则显示 `providerId / model.id` + +最终提交值统一使用: + +`providerId/modelId` + +## 七、接口设计 + +新增一个专用写接口: + +- `PATCH /api/config/agent-model` + +请求体: + +```json +{ + "agentId": "main", + "model": "openai/gpt-4.1" +} +``` + +成功返回: + +```json +{ + "ok": true, + "agentId": "main", + "model": "openai/gpt-4.1", + "applied": true +} +``` + +失败返回: + +```json +{ + "ok": false, + "error": "Agent not found" +} +``` + +## 八、后端实现方案 + +新接口建议放在: + +- [app/api/config/agent-model/route.ts](/Users/manruixie/code/pixel_agent/OpenClaw-bot-review/app/api/config/agent-model/route.ts) + +后端处理流程: + +1. 读取当前配置快照 +3. 校验 `agentId` +4. 校验 `model` +5. 校验目标模型是否存在于当前可用模型列表中 +6. 基于当前配置构造最小 patch,只修改目标 agent 的 `model` +7. 调用 Gateway `config.patch` +8. 等待 Gateway 写入配置并自动重启 +9. 清除 `/api/config` 的内存缓存 +10. 在前端轮询或重试拉取 `/api/config` / gateway 健康状态 +11. 返回成功结果 + +## 推荐调用方式 + +后端不要直接改 `openclaw.json`,而是调用 OpenClaw 的 Gateway 能力: + +1. 先拿到当前配置快照与 `baseHash` +2. 再用 `config.patch` 提交最小补丁 + +这样有几个好处: + +1. 只改一处,风险更低 +2. 与 OpenClaw 自身配置写入机制保持一致 +3. 写入后自动 restart,保证新模型真正生效 +4. 避免 dashboard 自己维护复杂的配置并发写入逻辑 + +## patch 构造建议 + +后端应始终基于最新配置快照和 `baseHash` 构造 patch,不允许前端直接提交 patch 内容。 + +推荐做法: + +1. 先调用 Gateway `config.get` +2. 从返回结果中提取: + - 当前配置快照 + - `baseHash` +3. 在服务端内存中找到目标 agent +4. 只更新该 agent 的 `model` +5. 生成最小变更 patch +6. 调用 Gateway `config.patch` + +patch 的目标是: + +- 只修改 `agents.list` 中目标 agent 的 `model` +- 不改全局默认模型 +- 不改 fallback +- 不改其他 agent 配置 +- 不改 provider 配置 + +实现原则: + +1. 前端只传 `agentId` 和 `model` +2. patch 完全在后端生成 +3. 一切并发控制都依赖 `baseHash` + +这样可以最大限度降低误改配置的风险。 + +## 九、校验规则 + +以下情况必须拒绝写入: + +1. `agentId` 缺失 +2. `model` 缺失 +3. `agents.list` 不存在 +4. 指定 agent 不存在 +5. 模型不在当前已知 provider/model 列表中 +6. 无法获取当前配置快照或 `baseHash` +7. Gateway `config.patch` 调用失败 + +## 十、前端实现方案 + +## 1. 首页 [app/page.tsx](/Users/manruixie/code/pixel_agent/OpenClaw-bot-review/app/page.tsx) + +需要新增的职责: + +1. 从 `data.providers` 整理出 `modelOptions` +2. 实现 `onModelChange(agentId, model)` 回调 +3. 回调中调用 `PATCH /api/config/agent-model` +4. 成功后等待 Gateway 重启并恢复可用 +5. 然后复用现有 `fetchData(true)` 刷新数据 +6. 把 `modelOptions` 和 `onModelChange` 传给 `AgentCard` + +## 2. 卡片组件 [app/components/agent-card.tsx](/Users/manruixie/code/pixel_agent/OpenClaw-bot-review/app/components/agent-card.tsx) + +建议新增 props: + +```ts +modelOptions?: Array<{ + providerId: string + providerName: string + accessMode?: "auth" | "api_key" + models: Array<{ id: string; name: string }> +}> +onModelChange?: (agentId: string, model: string) => Promise +``` + +建议新增本地状态: + +1. `isEditingModel` +2. `draftModel` +3. `isSavingModel` +4. `modelSaveError` + +## 3. 卡片行为建议 + +进入编辑态时: + +1. 默认选中当前模型 +2. 如果当前模型不在可选列表中,也要显示一个“当前模型(未知)”占位选项,避免用户失去上下文 + +保存按钮启用条件: + +1. 已选择模型 +2. 新模型和当前模型不同 +3. 当前不在保存中 + +保存成功后: + +1. 退出编辑态 +2. 清空错误信息 +3. 页面刷新后显示最新模型 +4. 若有需要,可给出“模型已应用”短提示 + +保存失败后: + +1. 保持编辑态 +2. 保留用户当前选择 +3. 展示错误信息 + +## 十一、“切换后模型要生效”的定义 + +在新的方案下,“模型生效”定义为: + +1. Gateway `config.patch` 已成功执行 +2. Gateway 已按 OpenClaw 机制自动 restart +3. restart 完成后 `/api/config` 返回新模型 +4. 卡片显示新模型 +5. 后续 dashboard 内触发的模型测试、agent 测试等使用新模型 + +这意味着,这次不是“仅修改配置”,而是“修改配置并应用配置”。 + +因此它能覆盖: + +1. 机器人卡片展示 +2. 首页状态刷新 +3. dashboard 内测试逻辑 + +## 十二、运行时说明 + +本方案不再依赖“运行时是否支持热更新”的不确定性。 + +因为根据当前 OpenClaw 能力,`config.patch` 在写入后会触发 restart,所以模型切换后会通过标准 restart 流程生效。 + +因此,产品语义应明确为: + +- dashboard 发起模型切换 +- OpenClaw 通过 `config.patch` 更新配置 +- 系统自动 restart 后应用新模型 + +这比“假设支持热更新”更稳妥,也更符合 OpenClaw 当前已有机制。 + +## 十三、缓存处理 + +当前 [app/api/config/route.ts](/Users/manruixie/code/pixel_agent/OpenClaw-bot-review/app/api/config/route.ts) 有 30 秒内存缓存。 + +如果不处理,模型切换后即使 Gateway 已经重启,页面也可能短时间看到旧值。 + +因此需要在写接口成功后清理缓存。 + +推荐方案: + +新增共享缓存模块: + +- [lib/config-cache.ts](/Users/manruixie/code/pixel_agent/OpenClaw-bot-review/lib/config-cache.ts) + +提供: + +1. `getConfigCache()` +2. `setConfigCache()` +3. `clearConfigCache()` + +然后: + +- `GET /api/config` 读取/写入这个共享缓存 +- `PATCH /api/config/agent-model` 成功后调用 `clearConfigCache()` + +这样可以避免把缓存逻辑散落在多个文件里。 + +## 十四、安全性要求 + +1. 不允许 dashboard 直接接受任意路径并写文件 +2. 只能通过 Gateway `config.patch` 修改配置 +3. 只能保存已经校验过的模型值 +4. 只能提交最小 patch +5. 不能影响配置中的其他字段 + +## 十五、边界情况 + +1. agent 当前没有显式 `model` + - 切换后为该 agent 新增显式 `model` + +2. agent 是从文件系统自动扫描出来的,但不在 `agents.list` + - 本期拒绝修改 + - 原因是 `config.patch` 需要稳定的配置落点 + +3. provider 存在,但 model 仅是推断出来的 + - 只要它出现在后端返回的候选模型列表里,就允许选择 + +4. 多个页面同时修改同一个 agent 模型 + - 依赖 `baseHash` 控制并发 + - 如果底层配置已变化,则要求前端重试 + +5. Gateway 正在重启或暂时不可用 + - 前端显示“正在应用配置,请稍候” + +6. `config.patch` 返回 baseHash 冲突 + - 提示用户刷新后重试 + +## 十六、实施步骤 + +1. 抽取共享 config cache +2. 新增 `PATCH /api/config/agent-model` +3. 在后端封装 Gateway `config.get` + `config.patch` 调用 +4. 在首页构造模型候选列表 +5. 给 `AgentCard` 增加内联模型编辑 UI +6. 保存后等待 Gateway restart 完成并刷新首页数据 +7. 补充错误提示 +8. 补充必要的验证 + +## 十七、测试方案 + +## 手动测试 + +1. 将某个 agent 从模型 A 切到模型 B,确认卡片立即更新 +2. 在切换过程中观察 Gateway 短暂重启,再恢复可用 +3. 刷新页面,确认模型 B 仍然存在 +4. 切换后执行“测试全部模型/测试 Agent”,确认使用的是新模型 +5. 给原本继承默认模型的 agent 切换模型,确认 patch 成功 +6. 传入非法模型,确认接口拒绝 +7. 模拟 Gateway 不可用或 patch 失败,确认卡片停留在编辑态并显示错误 + +## 自动化测试建议 + +1. API 测试:合法 agent/model 可以正确触发 `config.patch` +2. API 测试:非法 model 被拒绝 +3. API 测试:agent 不存在时被拒绝 +4. API 测试:`baseHash` 冲突时返回可理解错误 +5. API 测试:成功后缓存被清除 +6. 组件测试:卡片可以进入编辑态并提交保存 + +## 十八、验收标准 + +1. 每个机器人卡片都能进入模型切换模式 +2. 用户只能从合法模型列表中选择 +3. 保存后后端通过 Gateway `config.patch` 完成最小配置更新 +4. 配置更新后系统自动 restart 并应用新模型 +5. `/api/config` 在恢复后返回更新后的模型 +6. 卡片无需手动刷新即可看到新模型 +7. dashboard 内已有测试能力仍然正常可用 +8. 非法输入不会破坏配置 + +## 十九、涉及文件 + +建议涉及这些文件: + +1. [app/components/agent-card.tsx](/Users/manruixie/code/pixel_agent/OpenClaw-bot-review/app/components/agent-card.tsx) +2. [app/page.tsx](/Users/manruixie/code/pixel_agent/OpenClaw-bot-review/app/page.tsx) +3. [app/api/config/agent-model/route.ts](/Users/manruixie/code/pixel_agent/OpenClaw-bot-review/app/api/config/agent-model/route.ts) +4. [app/api/config/route.ts](/Users/manruixie/code/pixel_agent/OpenClaw-bot-review/app/api/config/route.ts) +5. [lib/openclaw-cli.ts](/Users/manruixie/code/pixel_agent/OpenClaw-bot-review/lib/openclaw-cli.ts) +6. 可选新增 [lib/config-cache.ts](/Users/manruixie/code/pixel_agent/OpenClaw-bot-review/lib/config-cache.ts) + +## 二十、结论 + +推荐采用: + +- 卡片内联编辑 +- 独立模型更新接口 +- 后端调用 Gateway `config.patch` +- 利用 OpenClaw 标准 restart 流程应用配置 +- 成功后清缓存并刷新首页 + +这是当前代码结构下最稳妥的方案,因为它复用了 OpenClaw 自带的配置更新机制,不依赖不确定的热更新行为,也能真正满足“切换后模型生效”的要求。