feat: support agent model switching via gateway patch

This commit is contained in:
xmanrui
2026-03-12 02:13:21 +08:00
parent d4ae7f4eb7
commit cdad770132
8 changed files with 931 additions and 14 deletions
+242
View File
@@ -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<string, any> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function unwrapGatewayResult<T>(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<string, any> | 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<string>, 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<string> {
const models = new Set<string>();
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function getConfigSnapshot(): Promise<ConfigSnapshot> {
return unwrapGatewayResult<ConfigSnapshot>(
await callOpenclawGateway("config.get", {}, GATEWAY_CALL_TIMEOUT_MS),
);
}
async function waitForPatchedModel(agentId: string, model: string): Promise<void> {
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) });
}
}
+3 -3
View File
@@ -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 });
+107 -2
View File
@@ -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<string, PlatformTestResult | null>;
providerAccessModeMap?: Record<string, "auth" | "api_key">;
modelOptions?: AgentModelOptionGroup[];
onModelChange?: (agentId: string, model: string) => Promise<void>;
}) {
const [isEditingModel, setIsEditingModel] = useState(false);
const [draftModel, setDraftModel] = useState(agent.model);
const [isSavingModel, setIsSavingModel] = useState(false);
const [modelSaveError, setModelSaveError] = useState<string | null>(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<void> {
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 (
<div
className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-2.5 hover:border-[var(--accent)] transition-colors"
@@ -404,7 +445,7 @@ export function AgentCard({
</div>
<div>
<span className="text-xs text-[var(--text-muted)] block">{t("agent.model")}</span>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
<ModelBadge model={agent.model} accessMode={modelAccessMode} />
{testResult === undefined ? (
<span className="text-xs text-[var(--text-muted)]">--</span>
@@ -415,7 +456,71 @@ export function AgentCard({
) : (
<ErrorStatusWithCopy error={testResult.error} />
)}
{canSwitchModel && !isEditingModel && (
<button
type="button"
onClick={() => {
setDraftModel(agent.model);
setModelSaveError(null);
setIsEditingModel(true);
}}
className="px-2 py-0.5 rounded-full text-xs border border-[var(--border)] text-[var(--text-muted)] hover:text-[var(--text)] hover:border-[var(--accent)] transition"
>
{t("agent.switchModel")}
</button>
)}
</div>
{canSwitchModel && isEditingModel && (
<div className="mt-2 rounded-lg border border-[var(--border)] bg-[var(--bg)] p-2 space-y-2">
<select
value={draftModel}
onChange={(e) => setDraftModel(e.target.value)}
disabled={isSavingModel}
className="w-full px-2 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm text-[var(--text)]"
>
{!currentModelKnown && (
<option value={agent.model}>{`${t("agent.currentUnknownModel")}: ${agent.model}`}</option>
)}
{(modelOptions || []).map((group) => (
<optgroup key={group.providerId} label={group.providerName}>
{group.models.map((model) => (
<option key={`${group.providerId}/${model.id}`} value={`${group.providerId}/${model.id}`}>
{`${group.providerId} / ${model.name || model.id}`}
</option>
))}
</optgroup>
))}
</select>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => void handleModelSave()}
disabled={isSavingModel || !draftModel || draftModel === agent.model}
className="px-3 py-1.5 rounded-lg text-xs bg-[var(--accent)] text-[var(--bg)] disabled:opacity-50"
>
{isSavingModel ? t("agent.modelSaving") : t("agent.saveModel")}
</button>
<button
type="button"
onClick={() => {
setDraftModel(agent.model);
setModelSaveError(null);
setIsEditingModel(false);
}}
disabled={isSavingModel}
className="px-3 py-1.5 rounded-lg text-xs border border-[var(--border)] text-[var(--text-muted)] hover:text-[var(--text)] hover:border-[var(--accent)] transition disabled:opacity-50"
>
{t("agent.cancelModel")}
</button>
</div>
<p className="text-[10px] text-[var(--text-muted)]">
{t("agent.modelApplyHint")}
</p>
{modelSaveError && (
<p className="text-xs text-red-400">{modelSaveError}</p>
)}
</div>
)}
</div>
<div>
+38 -9
View File
@@ -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 (
<div className="p-3 md:p-4 max-w-6xl mx-auto">
{/* 头部 */}
@@ -621,9 +650,16 @@ export default function Home() {
</div>
</div>
{/* 卡片墙 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{data.agents.map((agent) => (
<AgentCard key={agent.id} agent={agent} gatewayPort={data.gateway?.port || 18789} gatewayToken={data.gateway?.token} gatewayHost={data.gateway?.host} t={t} testResult={testResults?.[agent.id]} platformTestResults={platformTestResults || undefined} sessionTestResult={sessionTestResults?.[agent.id]} agentState={agentStates[agent.id]} dmSessionResults={dmSessionResults || undefined} providerAccessModeMap={providerAccessModeMap} modelOptions={modelOptions} onModelChange={changeAgentModel} />
))}
</div>
{/* Agent 任務追蹤 */}
{agentActivity && agentActivity.some(a => a.state !== "offline") && (
<div className="mb-4 p-4 rounded-xl border border-[var(--border)] bg-[var(--card)]">
<div className="mt-4 p-4 rounded-xl border border-[var(--border)] bg-[var(--card)]">
<h2 className="text-sm font-semibold text-[var(--text-muted)] mb-3">📋 Agent </h2>
<div className="space-y-2">
{agentActivity
@@ -667,13 +703,6 @@ export default function Home() {
</div>
)}
{/* 卡片墙 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{data.agents.map((agent) => (
<AgentCard key={agent.id} agent={agent} gatewayPort={data.gateway?.port || 18789} gatewayToken={data.gateway?.token} gatewayHost={data.gateway?.host} t={t} testResult={testResults?.[agent.id]} platformTestResults={platformTestResults || undefined} sessionTestResult={sessionTestResults?.[agent.id]} agentState={agentStates[agent.id]} dmSessionResults={dmSessionResults || undefined} providerAccessModeMap={providerAccessModeMap} />
))}
</div>
{/* 汇总统计趋势 */}
{allStats && (
<div className="mt-8 p-5 rounded-xl border border-[var(--border)] bg-[var(--card)]">
+18
View File
@@ -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;
}
+21
View File
@@ -103,6 +103,13 @@ const translations: Record<Locale, Record<string, string>> = {
"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<Locale, Record<string, string>> = {
"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<Locale, Record<string, string>> = {
"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",
+45
View File
@@ -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<string, unknown> = {}, timeoutMs = 10000): Promise<any> {
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);
}
}
+457
View File
@@ -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<void>
```
建议新增本地状态:
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 自带的配置更新机制,不依赖不确定的热更新行为,也能真正满足“切换后模型生效”的要求。