mirror of
https://github.com/xmanrui/OpenClaw-bot-review.git
synced 2026-08-14 00:47:49 +00:00
feat: support agent model switching via gateway patch
This commit is contained in:
@@ -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) });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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)]">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 自带的配置更新机制,不依赖不确定的热更新行为,也能真正满足“切换后模型生效”的要求。
|
||||
Reference in New Issue
Block a user