diff --git a/.memory/worklog.json b/.memory/worklog.json index 494441c..dddbb4f 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -1,18 +1,5 @@ { "entries": [ - { - "files_changed": 1, - "hash": "ce1b8c3", - "message": "auto-save 2026-05-10 08:21 (~1)", - "ts": "2026-05-10T08:21:27+08:00", - "type": "commit" - }, - { - "files_changed": 1, - "message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 1 项未提交变更 · 最近提交:auto-save 2026-05-10 08:21 (~1)", - "ts": "2026-05-10T00:26:24Z", - "type": "session-heartbeat" - }, { "files_changed": 1, "hash": "3b0d2b2", @@ -3263,6 +3250,19 @@ "message": "auto-save 2026-05-11 16:48 (~1)", "hash": "8b5eb7a", "files_changed": 1 + }, + { + "ts": "2026-05-11T16:53:47+08:00", + "type": "commit", + "message": "auto-save 2026-05-11 16:53 (~2)", + "hash": "48474dc", + "files_changed": 2 + }, + { + "ts": "2026-05-11T08:56:29Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 3 项未提交变更 · 最近提交:auto-save 2026-05-11 16:53 (~2)", + "files_changed": 3 } ] } diff --git a/server/feishu_bridge.py b/server/feishu_bridge.py index d9b537d..5758084 100644 --- a/server/feishu_bridge.py +++ b/server/feishu_bridge.py @@ -27,6 +27,7 @@ from typing import Any FEISHU_API_BASE = "https://open.feishu.cn/open-apis" APP_ID_RE = re.compile(r"^cli_[A-Za-z0-9]+$") +UI_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,80}$") def _env(name: str, default: str = "") -> str: @@ -616,12 +617,32 @@ def normalize_mcp_yaml(value: str) -> str: return "mcp_servers:\n" + indented -def validate_hermes_config_payload(body: dict[str, Any]) -> dict[str, str]: +def validate_hermes_config_payload(body: dict[str, Any], current: dict[str, Any] | None = None) -> dict[str, str]: + current_model = (current or {}).get("model") if isinstance((current or {}).get("model"), dict) else {} model = body.get("model") if isinstance(body.get("model"), dict) else {} - default_model = str(model.get("default") or body.get("model_default") or "").strip() - provider = str(model.get("provider") or body.get("provider") or "openrouter").strip() - base_url = str(model.get("base_url") or body.get("base_url") or "").strip() - mcp_servers_yaml = normalize_mcp_yaml(str(body.get("mcp_servers_yaml") or "")) + default_model = str( + model.get("default") + or body.get("model_default") + or current_model.get("default") + or current_model.get("model") + or "" + ).strip() + provider = str( + model.get("provider") + or body.get("provider") + or current_model.get("provider") + or "openrouter" + ).strip() + base_url = str( + model.get("base_url") + or body.get("base_url") + or current_model.get("base_url") + or "" + ).strip() + if "mcp_servers_yaml" in body: + mcp_servers_yaml = normalize_mcp_yaml(str(body.get("mcp_servers_yaml") or "")) + else: + mcp_servers_yaml = str((current or {}).get("mcp_servers_yaml") or "") for label, value, limit in ( ("model.default", default_model, 180), @@ -650,7 +671,8 @@ def validate_hermes_config_payload(body: dict[str, Any]) -> dict[str, str]: def write_hermes_runtime_config(body: dict[str, Any]) -> dict[str, Any]: - payload = validate_hermes_config_payload(body) + current = read_hermes_runtime_config() + payload = validate_hermes_config_payload(body, current) script = r''' import json import pathlib @@ -761,6 +783,232 @@ def handle_hermes_config_post(headers: dict[str, str], body: dict[str, Any]) -> return 200, {"code": 0, "msg": "ok", "config": config} +def one_line(value: Any, limit: int, default: str = "") -> str: + text = str(value if value is not None else default).strip() + text = text.replace("\r", " ").replace("\n", " ").strip() + if len(text) > limit: + text = text[:limit].rstrip() + return text + + +def string_list(value: Any, limit: int = 200) -> list[str]: + if not isinstance(value, list): + return [] + out: list[str] = [] + for item in value[:limit]: + text = one_line(item, 180) + if text: + out.append(text) + return out + + +def now_ms() -> int: + return int(time.time() * 1000) + + +def runtime_model_profile(runtime: dict[str, Any]) -> dict[str, Any]: + model = runtime.get("model") if isinstance(runtime.get("model"), dict) else {} + model_id = one_line(model.get("default") or Config.hermes_model or "gemini-3-pro-preview", 180) + provider = one_line(model.get("provider") or "openrouter", 80) + base_url = one_line(model.get("base_url") or "", 220) + created_at = now_ms() + return { + "id": "runtime-default", + "name": "线上默认模型", + "provider": provider, + "model": model_id, + "baseUrl": base_url, + "apiKeyRef": "服务器环境变量", + "enabled": True, + "isDefault": True, + "createdAt": created_at, + "updatedAt": created_at, + } + + +def normalize_model_profile(raw: Any, fallback: dict[str, Any], index: int) -> dict[str, Any] | None: + if not isinstance(raw, dict): + return None + profile_id = one_line(raw.get("id"), 80) + if not profile_id or not UI_ID_RE.match(profile_id): + profile_id = f"model_{index + 1}" + name = one_line(raw.get("name"), 80, fallback.get("name") or "模型接入") + model = one_line(raw.get("model"), 180, fallback.get("model") or "") + provider = one_line(raw.get("provider"), 80, fallback.get("provider") or "openrouter") + base_url = one_line(raw.get("baseUrl") or raw.get("base_url"), 220, fallback.get("baseUrl") or "") + api_key_ref = one_line(raw.get("apiKeyRef") or raw.get("api_key_ref"), 120, fallback.get("apiKeyRef") or "") + if not model: + return None + created_at = raw.get("createdAt") if isinstance(raw.get("createdAt"), (int, float)) else now_ms() + updated_at = raw.get("updatedAt") if isinstance(raw.get("updatedAt"), (int, float)) else now_ms() + return { + "id": profile_id, + "name": name or model, + "provider": provider, + "model": model, + "baseUrl": base_url, + "apiKeyRef": api_key_ref, + "enabled": bool(raw.get("enabled", True)), + "isDefault": bool(raw.get("isDefault", False)), + "createdAt": int(created_at), + "updatedAt": int(updated_at), + } + + +def normalize_stages(value: Any) -> dict[str, list[str]]: + value = value if isinstance(value, dict) else {} + return { + "pre": string_list(value.get("pre")), + "exec": string_list(value.get("exec")), + "post": string_list(value.get("post")), + } + + +def normalize_agent(raw: Any, index: int) -> dict[str, Any] | None: + if not isinstance(raw, dict): + return None + agent_id = one_line(raw.get("id"), 80) + if not agent_id or not UI_ID_RE.match(agent_id): + agent_id = f"agent_{index + 1}" + name = one_line(raw.get("name"), 80) + system_prompt = str(raw.get("systemPrompt") or raw.get("system_prompt") or "").strip() + if not name or not system_prompt: + return None + created_at = raw.get("createdAt") if isinstance(raw.get("createdAt"), (int, float)) else now_ms() + updated_at = raw.get("updatedAt") if isinstance(raw.get("updatedAt"), (int, float)) else now_ms() + return { + "id": agent_id, + "emoji": one_line(raw.get("emoji"), 16, "🤖"), + "name": name, + "desc": one_line(raw.get("desc"), 180), + "model": one_line(raw.get("model"), 180), + "modelProfileId": one_line(raw.get("modelProfileId") or raw.get("model_profile_id"), 80), + "systemPrompt": system_prompt[:20000], + "skills": string_list(raw.get("skills")), + "stages": normalize_stages(raw.get("stages")), + "createdAt": int(created_at), + "updatedAt": int(updated_at), + } + + +def normalize_ui_config(raw: dict[str, Any], runtime: dict[str, Any] | None = None) -> dict[str, Any]: + runtime_profile = runtime_model_profile(runtime or {}) + model_profiles_raw = raw.get("modelProfiles") if isinstance(raw.get("modelProfiles"), list) else [] + profiles: list[dict[str, Any]] = [] + seen_profile_ids: set[str] = set() + for idx, item in enumerate(model_profiles_raw[:80]): + profile = normalize_model_profile(item, runtime_profile, idx) + if not profile or profile["id"] in seen_profile_ids: + continue + seen_profile_ids.add(profile["id"]) + profiles.append(profile) + if not profiles: + profiles.append(runtime_profile) + if not any(profile.get("isDefault") for profile in profiles): + profiles[0]["isDefault"] = True + default_seen = False + for profile in profiles: + if profile.get("isDefault") and not default_seen: + default_seen = True + else: + profile["isDefault"] = False + + agents_raw = raw.get("agents") if isinstance(raw.get("agents"), list) else [] + agents: list[dict[str, Any]] = [] + seen_agent_ids: set[str] = set() + for idx, item in enumerate(agents_raw[:200]): + agent = normalize_agent(item, idx) + if not agent or agent["id"] in seen_agent_ids: + continue + seen_agent_ids.add(agent["id"]) + agents.append(agent) + + return { + "version": 1, + "modelProfiles": profiles, + "agents": agents, + "updatedAt": now_ms(), + "configPath": Config.hermes_ui_config_path, + "lxc": Config.hermes_agent_lxc, + } + + +def read_ui_config_file() -> dict[str, Any]: + script = r''' +import json +import pathlib +import sys + +path = pathlib.Path(sys.argv[1]) +if not path.exists(): + print("{}") +else: + print(path.read_text(encoding="utf-8")) +''' + raw = run_lxc_command(["python3", "-c", script, Config.hermes_ui_config_path], timeout=20) + if not raw.strip(): + return {} + data = json.loads(raw) + return data if isinstance(data, dict) else {} + + +def write_ui_config_file(config: dict[str, Any]) -> None: + script = r''' +import json +import os +import pathlib +import sys + +path = pathlib.Path(sys.argv[1]) +payload = json.loads(sys.stdin.read()) +path.parent.mkdir(parents=True, exist_ok=True) +tmp = path.with_name(path.name + f".tmp-{os.getpid()}") +tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") +tmp.chmod(0o600) +tmp.replace(path) +''' + run_lxc_command( + ["python3", "-c", script, Config.hermes_ui_config_path], + input_text=json.dumps(config, ensure_ascii=False), + timeout=20, + ) + + +def handle_ui_config_get(headers: dict[str, str]) -> tuple[int, dict[str, Any]]: + ok, status, message = is_admin_request(headers) + if not ok: + return status, {"code": status, "msg": message} + try: + runtime = read_hermes_runtime_config() + config = normalize_ui_config(read_ui_config_file(), runtime) + except Exception as exc: + logging.error("failed to read Hermes UI config:\n%s", traceback.format_exc()) + return 500, {"code": 500, "msg": str(exc)} + return 200, {"code": 0, "msg": "ok", "config": config} + + +def handle_ui_config_post(headers: dict[str, str], body: dict[str, Any]) -> tuple[int, dict[str, Any]]: + ok, status, message = is_admin_request(headers) + if not ok: + return status, {"code": status, "msg": message} + try: + runtime = read_hermes_runtime_config() + raw_config = body.get("config") if isinstance(body.get("config"), dict) else body + config = normalize_ui_config(raw_config, runtime) + write_ui_config_file(config) + except ValueError as exc: + return 400, {"code": 400, "msg": str(exc)} + except Exception as exc: + logging.error("failed to write Hermes UI config:\n%s", traceback.format_exc()) + return 500, {"code": 500, "msg": str(exc)} + logging.info( + "updated Hermes UI config model_profiles=%s agents=%s", + len(config.get("modelProfiles", [])), + len(config.get("agents", [])), + ) + return 200, {"code": 0, "msg": "ok", "config": config} + + def is_admin_request(headers: dict[str, str]) -> tuple[bool, int, str]: cookie = headers.get("cookie", "") if "hermes_auth=ok" not in cookie: @@ -947,6 +1195,10 @@ class Handler(BaseHTTPRequestHandler): status, payload = handle_hermes_config_get(headers) self.send_json(status, payload) return + if self.path == "/feishu/ui-config": + status, payload = handle_ui_config_get(headers) + self.send_json(status, payload) + return self.send_json(404, {"code": 404, "msg": "not found"}) def do_POST(self) -> None: @@ -959,6 +1211,8 @@ class Handler(BaseHTTPRequestHandler): status, payload = handle_apps_admin(headers, body) elif self.path == "/feishu/hermes-config": status, payload = handle_hermes_config_post(headers, body) + elif self.path == "/feishu/ui-config": + status, payload = handle_ui_config_post(headers, body) elif self.path == "/feishu/notify" or self.path.startswith("/feishu/notify/"): status, payload = handle_notify(self.path, headers, body) else: diff --git a/src/app.js b/src/app.js index 08028c5..a8e9b13 100644 --- a/src/app.js +++ b/src/app.js @@ -11,6 +11,7 @@ const LS_CUSTOM_SKILLS = "hermes-ui-custom-skills-v1"; const LS_FLOWS = "hermes-ui-flows-v1"; const LS_TAB = "hermes-ui-active-tab-v1"; const LS_WEEKLY_REPORTS = "hermes-ui-weekly-reports-v1"; +const UI_CONFIG_ENDPOINT = "/feishu/ui-config"; const DEFAULT_MODEL_ID = "google/gemini-3.1-pro-preview"; const LEGACY_DEFAULT_MODEL_ID = "gemini-3-pro-preview"; @@ -30,6 +31,10 @@ const state = { // 智能体 agents: {}, // {id: {id, emoji, name, desc, model, systemPrompt, createdAt}} editingAgentId: null, + modelProfiles: [], + editingModelProfileId: null, + sharedConfigLoaded: false, + sharedConfigAvailable: false, // 自定义 skill customSkills: {}, // {id: {id, emoji, name, prompt, custom: true}} @@ -54,22 +59,65 @@ const state = { }; // ---------- 入口 ---------- +function showBootIssue(error, source = "运行错误") { + const message = error?.message || String(error || "未知错误"); + const detail = error?.stack || message; + const render = () => { + if (!document.body) return; + let box = document.getElementById("bootIssue"); + if (!box) { + box = document.createElement("div"); + box.id = "bootIssue"; + box.className = "boot-issue"; + document.body.appendChild(box); + } + box.innerHTML = ` +
${escapeHTML(source)}
+
${escapeHTML(message)}
+ +
详情
${escapeHTML(detail)}
+ `; + }; + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", render, { once: true }); + } else { + render(); + } +} + +function safeBoot(label, fn) { + try { + return fn(); + } catch (error) { + console.error("[boot]", label, error); + showBootIssue(error, label); + return undefined; + } +} + +window.addEventListener("error", (event) => showBootIssue(event.error || event.message, "页面脚本错误")); +window.addEventListener("unhandledrejection", (event) => showBootIssue(event.reason, "异步请求错误")); + document.addEventListener("DOMContentLoaded", () => { - loadTheme(); - loadSettings(); - loadCustomSkills(); - loadFlows(); - loadAgents(); - loadConversations(); - loadWeeklyReports(); - bindTabs(); - bindChat(); - bindSearch(); - bindStudio(); - renderSidebar(); - renderChat(); - renderAgents(); - restoreActiveTab(); + safeBoot("加载主题", loadTheme); + safeBoot("加载本地设置", loadSettings); + safeBoot("加载自定义技能", loadCustomSkills); + safeBoot("加载本地编排", loadFlows); + safeBoot("加载本地智能体", loadAgents); + safeBoot("加载本地对话", loadConversations); + safeBoot("加载周报记录", loadWeeklyReports); + safeBoot("绑定导航", bindTabs); + safeBoot("绑定对话", bindChat); + safeBoot("绑定搜索", bindSearch); + safeBoot("绑定 Skill Studio", bindStudio); + safeBoot("渲染侧栏", renderSidebar); + safeBoot("渲染对话", renderChat); + safeBoot("渲染智能体", renderAgents); + safeBoot("恢复页面", restoreActiveTab); + refreshUiConfig({ migrateLocalAgents: true }).catch((error) => { + console.warn("[ui-config] fallback to local config", error); + setSharedConfigStatus("共享配置读取失败,本机仍可临时使用: " + (error.message || error), true); + }); pingBackend(); fetchIP(); setInterval(pingBackend, 30000); @@ -178,6 +226,135 @@ function syncModelPick(modelValue, providerValue = "") { updateModelDisplay(model, providerValue); } +function makeId(prefix) { + return prefix + "_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); +} + +function normalizeModelProfile(raw = {}) { + const model = (raw.model || raw.default || "").trim(); + return { + id: raw.id || makeId("model"), + name: (raw.name || model || "模型接入").trim(), + provider: (raw.provider || "openrouter").trim(), + model, + baseUrl: (raw.baseUrl || raw.base_url || "").trim(), + apiKeyRef: (raw.apiKeyRef || raw.api_key_ref || "").trim(), + enabled: raw.enabled !== false, + isDefault: !!raw.isDefault, + createdAt: Number(raw.createdAt || Date.now()), + updatedAt: Number(raw.updatedAt || Date.now()), + }; +} + +function defaultModelProfile() { + const model = state.model || DEFAULT_MODEL_ID; + return normalizeModelProfile({ + id: "runtime-default", + name: "线上默认模型", + provider: "openrouter", + model, + apiKeyRef: "服务器环境变量", + enabled: true, + isDefault: true, + }); +} + +function normalizeModelProfiles(list) { + const seen = new Set(); + const profiles = (Array.isArray(list) ? list : []) + .map(normalizeModelProfile) + .filter(profile => profile.model && !seen.has(profile.id) && seen.add(profile.id)); + if (!profiles.length) profiles.push(defaultModelProfile()); + if (!profiles.some(profile => profile.isDefault)) profiles[0].isDefault = true; + let defaultSeen = false; + for (const profile of profiles) { + if (profile.isDefault && !defaultSeen) defaultSeen = true; + else profile.isDefault = false; + } + return profiles; +} + +function activeModelProfile() { + return state.modelProfiles.find(profile => profile.isDefault && profile.enabled) + || state.modelProfiles.find(profile => profile.enabled) + || state.modelProfiles[0] + || defaultModelProfile(); +} + +function modelProfileById(id) { + return state.modelProfiles.find(profile => profile.id === id) || null; +} + +function modelForAgent(agent) { + const profile = modelProfileById(agent?.modelProfileId); + return profile?.model || agent?.model || state.model || DEFAULT_MODEL_ID; +} + +function modelLabelForAgent(agent) { + const profile = modelProfileById(agent?.modelProfileId); + if (profile) return profile.name + " · " + profile.model; + return agent?.model || state.model || DEFAULT_MODEL_ID; +} + +function syncModelOptionsFromProfiles() { + for (const profile of state.modelProfiles) { + ensureModelChoice(profile.model, profile.name || profile.model); + } + const active = activeModelProfile(); + if (active?.model) { + syncModelPick(active.model, active.provider); + } +} + +function upsertRuntimeModelProfile(model) { + if (!model?.default && !model?.model) return; + const modelId = model.default || model.model; + const existing = state.modelProfiles.find(profile => profile.isDefault) + || state.modelProfiles.find(profile => profile.id === "runtime-default"); + const next = normalizeModelProfile({ + ...(existing || {}), + id: existing?.id || "runtime-default", + name: existing?.name || "线上默认模型", + provider: model.provider || existing?.provider || "openrouter", + model: modelId, + baseUrl: model.base_url || model.baseUrl || existing?.baseUrl || "", + apiKeyRef: existing?.apiKeyRef || "服务器环境变量", + enabled: true, + isDefault: true, + updatedAt: Date.now(), + }); + state.modelProfiles = normalizeModelProfiles([ + next, + ...state.modelProfiles.filter(profile => profile.id !== next.id).map(profile => ({ ...profile, isDefault: false })), + ]); + syncModelOptionsFromProfiles(); + renderModelProfiles(); +} + +function renderAgentModelProfileOptions(selectedId = "") { + const select = document.getElementById("agentModelProfile"); + if (!select) return; + const profiles = normalizeModelProfiles(state.modelProfiles); + select.innerHTML = ''; + for (const profile of profiles) { + const option = document.createElement("option"); + option.value = profile.id; + option.textContent = `${profile.name} · ${profile.model}`; + select.appendChild(option); + } + select.value = selectedId && profiles.some(profile => profile.id === selectedId) ? selectedId : ""; +} + +function applyAgentModelProfileSelection() { + const select = document.getElementById("agentModelProfile"); + const input = document.getElementById("agentModel"); + const profile = modelProfileById(select?.value || ""); + if (profile?.model && input) { + input.value = profile.model; + ensureModelChoice(profile.model, profile.name || profile.model); + } +} + // ---------- 会话持久化 ---------- function loadConversations() { try { @@ -679,8 +856,12 @@ async function refreshHermesConfig(force = false) { }, mcp_servers_yaml: config.mcp_servers_yaml || "", }; - if (model.default) syncModelPick(model.default, model.provider || ""); - else updateModelDisplay(state.model, model.provider || ""); + if (model.default) { + syncModelPick(model.default, model.provider || ""); + if (!state.modelProfiles.length) upsertRuntimeModelProfile(model); + } else { + updateModelDisplay(state.model, model.provider || ""); + } _hermesConfigLoaded = true; const suffix = config.lxc ? " · " + config.lxc : ""; setHermesModelStatus("已读取模型配置" + suffix); @@ -723,6 +904,128 @@ async function postHermesRuntimeConfig(payload) { return data.config || {}; } +function setSharedConfigStatus(text, isError = false) { + setSettingsStatus("sharedConfigStatus", text, isError); +} + +function agentsForSharedConfig() { + return sortedAgents().map(agent => ({ + id: agent.id, + emoji: agent.emoji || "🤖", + name: agent.name || "", + desc: agent.desc || "", + model: agent.model || "", + modelProfileId: agent.modelProfileId || "", + systemPrompt: agent.systemPrompt || "", + skills: Array.isArray(agent.skills) ? agent.skills : [], + stages: agent.stages || null, + createdAt: agent.createdAt || Date.now(), + updatedAt: agent.updatedAt || Date.now(), + })); +} + +function applySharedAgents(agents) { + if (!Array.isArray(agents) || !agents.length) return false; + const next = {}; + for (const agent of agents) { + if (!agent?.id || !agent?.name || !agent?.systemPrompt) continue; + next[agent.id] = { + id: agent.id, + emoji: agent.emoji || "🤖", + name: agent.name, + desc: agent.desc || "", + model: agent.model || "", + modelProfileId: agent.modelProfileId || "", + systemPrompt: agent.systemPrompt, + skills: Array.isArray(agent.skills) ? agent.skills : [], + stages: agent.stages || null, + createdAt: agent.createdAt || Date.now(), + updatedAt: agent.updatedAt || Date.now(), + }; + } + if (!Object.keys(next).length) return false; + state.agents = next; + saveAgents(); + renderAgents(); + return true; +} + +async function fetchUiConfig() { + const res = await fetch(UI_CONFIG_ENDPOINT, { + credentials: "same-origin", + cache: "no-store", + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.code !== 0) throw new Error(data.msg || ("HTTP " + res.status)); + return data.config || {}; +} + +async function saveSharedConfig(options = {}) { + const config = { + version: 1, + modelProfiles: normalizeModelProfiles(state.modelProfiles), + agents: agentsForSharedConfig(), + }; + const res = await fetch(UI_CONFIG_ENDPOINT, { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ config }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.code !== 0) throw new Error(data.msg || ("HTTP " + res.status)); + const saved = data.config || config; + state.modelProfiles = normalizeModelProfiles(saved.modelProfiles); + state.sharedConfigLoaded = true; + state.sharedConfigAvailable = true; + syncModelOptionsFromProfiles(); + renderModelProfiles(); + renderAgentModelProfileOptions(document.getElementById("agentModelProfile")?.value || ""); + if (!options.silent) toast("共享配置已保存"); + setSharedConfigStatus("共享配置已保存到服务器"); + return saved; +} + +async function persistAgents(options = {}) { + saveAgents(); + renderAgents(); + if (!state.sharedConfigAvailable) { + if (!options.silent) toast("已保存到本机;共享配置暂不可用"); + return; + } + try { + await saveSharedConfig({ silent: true }); + if (!options.silent) toast("智能体已保存到服务器"); + } catch (error) { + setSharedConfigStatus("智能体共享保存失败: " + (error.message || error), true); + if (!options.silent) toast("共享保存失败,已保留在本机"); + } +} + +async function refreshUiConfig(options = {}) { + try { + const config = await fetchUiConfig(); + const profiles = normalizeModelProfiles(config.modelProfiles); + state.modelProfiles = profiles; + state.sharedConfigLoaded = true; + state.sharedConfigAvailable = true; + syncModelOptionsFromProfiles(); + renderModelProfiles(); + renderAgentModelProfileOptions(document.getElementById("agentModelProfile")?.value || ""); + + const hadServerAgents = applySharedAgents(config.agents); + if (!hadServerAgents && options.migrateLocalAgents && Object.keys(state.agents || {}).length) { + await saveSharedConfig({ silent: true }); + setSharedConfigStatus("已把本机智能体迁移到服务器共享配置"); + } else { + setSharedConfigStatus("已读取服务器共享配置" + (config.lxc ? " · " + config.lxc : "")); + } + } catch (error) { + state.sharedConfigAvailable = false; + throw error; + } +} + async function saveModelConfig() { const model = readModelConfigFields(); if (!model.default) { @@ -754,6 +1057,10 @@ async function saveModelConfig() { mcp_servers_yaml: _hermesConfigSnapshot ? _hermesConfigSnapshot.mcp_servers_yaml : (saved.mcp_servers_yaml || ""), }; _hermesConfigLoaded = false; + upsertRuntimeModelProfile(savedModel.default ? savedModel : model); + saveSharedConfig({ silent: true }).catch((error) => { + setSharedConfigStatus("模型 Profile 共享保存失败: " + (error.message || error), true); + }); setHermesModelStatus("模型配置已保存并重启 · 备份 " + (saved.backup || "已创建")); toast("AI 模型接入配置已生效"); setTimeout(() => { @@ -773,11 +1080,6 @@ async function saveModelConfig() { async function saveMcpConfig() { const mcpServersYaml = document.getElementById("mcpServersYaml")?.value || ""; - const model = snapshotModelOrFields(); - if (!model.default) { - toast("先读取或填写默认模型"); - return; - } if (!confirm("保存 MCP 工具接入配置后会重启线上 Hermes agent,当前正在生成的任务可能中断。继续吗?")) return; const btn = document.getElementById("hermesMcpSaveBtn"); const oldHTML = btn?.innerHTML; @@ -788,11 +1090,10 @@ async function saveMcpConfig() { setHermesMcpStatus("正在写入 mcp_servers 配置并重启 Hermes agent..."); try { const saved = await postHermesRuntimeConfig({ - model, mcp_servers_yaml: mcpServersYaml, restart: true, }); - const savedModel = saved.model || model; + const savedModel = saved.model || snapshotModelOrFields(); if (savedModel.default) syncModelPick(savedModel.default, savedModel.provider || model.provider || ""); document.getElementById("mcpServersYaml").value = saved.mcp_servers_yaml || ""; _hermesConfigSnapshot = { @@ -905,7 +1206,7 @@ async function sendMessage(text) { if (useAgent) { const sys = composeSystemPrompt(useAgent); if (sys) msgsForApi = [{ role: "system", content: sys }, ...msgsForApi]; - modelForApi = useAgent.model || state.model; + modelForApi = modelForAgent(useAgent); } // 本次使用完清掉 pendingAgent @@ -2072,6 +2373,7 @@ function loadAgents() { migrated = true; } ensureModelChoice(agent.model || DEFAULT_MODEL_ID); + if (!("modelProfileId" in agent)) agent.modelProfileId = ""; } if (migrated) saveAgents(); } @@ -2112,7 +2414,7 @@ function renderAgents() { `; card.querySelector(".agent-avatar").textContent = a.emoji || "🤖"; card.querySelector(".agent-name").textContent = a.name; - card.querySelector(".agent-model").textContent = a.model; + card.querySelector(".agent-model").textContent = modelLabelForAgent(a); card.querySelector(".agent-desc").textContent = a.desc || "(无简介)"; const sEl = card.querySelector(".agent-skills"); const skills = (a.skills || []).map(skillById).filter(Boolean); @@ -2321,6 +2623,7 @@ function openAgentModal(id) { document.getElementById("agentDesc").value = a.desc || ""; ensureModelChoice(a.model || DEFAULT_MODEL_ID); document.getElementById("agentModel").value = a.model || DEFAULT_MODEL_ID; + renderAgentModelProfileOptions(a.modelProfileId || ""); document.getElementById("agentPrompt").value = a.systemPrompt || ""; renderSkillsPicker(a.skills || []); } else { @@ -2329,6 +2632,7 @@ function openAgentModal(id) { document.getElementById("agentName").value = ""; document.getElementById("agentDesc").value = ""; document.getElementById("agentModel").value = state.model || DEFAULT_MODEL_ID; + renderAgentModelProfileOptions(activeModelProfile()?.id || ""); document.getElementById("agentPrompt").value = ""; renderSkillsPicker([]); } @@ -2344,6 +2648,7 @@ function saveAgent() { const name = document.getElementById("agentName").value.trim(); const desc = document.getElementById("agentDesc").value.trim(); const model = document.getElementById("agentModel").value.trim(); + const modelProfileId = document.getElementById("agentModelProfile")?.value || ""; const systemPrompt = document.getElementById("agentPrompt").value.trim(); if (!name) { toast("请填写名称"); return; } if (!model) { toast("请填写模型 ID"); return; } @@ -2353,21 +2658,29 @@ function saveAgent() { const skills = readSkillsPicker(); if (state.editingAgentId && state.agents[state.editingAgentId]) { - Object.assign(state.agents[state.editingAgentId], { emoji, name, desc, model, systemPrompt, skills }); + Object.assign(state.agents[state.editingAgentId], { + emoji, + name, + desc, + model, + modelProfileId, + systemPrompt, + skills, + updatedAt: Date.now(), + }); } else { - const id = "a_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); - state.agents[id] = { id, emoji, name, desc, model, systemPrompt, skills, createdAt: Date.now() }; + const id = makeId("a"); + state.agents[id] = { id, emoji, name, desc, model, modelProfileId, systemPrompt, skills, createdAt: Date.now(), updatedAt: Date.now() }; } - saveAgents(); - renderAgents(); + persistAgents({ silent: true }); closeAgentModal(); - toast("已保存"); + toast(state.sharedConfigAvailable ? "已保存到服务器" : "已保存到本机"); } function deleteAgent(id) { if (!confirm("删除这个智能体?已有的对话不受影响。")) return; delete state.agents[id]; - saveAgents(); - renderAgents(); + persistAgents({ silent: true }); + toast(state.sharedConfigAvailable ? "已从服务器删除" : "已从本机删除"); } function chatWithAgent(id) { const a = state.agents[id]; @@ -2456,7 +2769,7 @@ async function runClusterOne(agent, prompt, col) { "Content-Type": "application/json", "Authorization": "Bearer " + state.apiKey, }, - body: JSON.stringify({ model: agent.model, messages, stream: false }), + body: JSON.stringify({ model: modelForAgent(agent), messages, stream: false }), }); if (!res.ok) throw new Error("HTTP " + res.status); const data = await res.json();