// ============================================================ // 爱马仕 · AI · Glass UI — 前端逻辑 // ============================================================ const LS_SETTINGS = "hermes-ui-settings-v2"; const LS_CONVOS = "hermes-ui-convos-v1"; const LS_ACTIVE = "hermes-ui-active-v1"; const LS_THEME = "hermes-ui-theme-v1"; const LS_AGENTS = "hermes-ui-agents-v1"; 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 LS_MODEL_PROFILES = "hermes-ui-model-profiles-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"; const state = { apiBase: "/api/v1", apiKey: "hermes-mini-local-key-2026", stream: true, model: DEFAULT_MODEL_ID, // 所有会话 conversations: {}, // {id: {id, title, messages, agentId, tags, createdAt, updatedAt}} activeId: null, // 当前会话 ID searchQuery: "", renamingId: null, emojiTarget: null, // "agent" 或其他目标 // 智能体 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}} editingSkillId: null, // Skill 编排(flows) flows: {}, // {id: {id, emoji, name, desc, skillIds[], builtin?}} editingFlowId: null, flowEditSelected: [], // 编辑 flow 时的临时 skill id 数组 // 周报记录 weeklyReports: [], // [{id,title,task,report,messages,createdAt,...}] // 本次一次性使用的智能体 ID (不绑定会话) pendingAgent: null, // 集群选中 clusterPicked: new Set(), tokens: 0, turns: 0, }; // ---------- 入口 ---------- 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", () => { 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("初始化模型 Profiles", () => { state.modelProfiles = normalizeModelProfiles(state.modelProfiles); renderModelProfiles(); }); 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); disableLegacyServiceWorkers(); }); function disableLegacyServiceWorkers() { if (!("serviceWorker" in navigator)) return; navigator.serviceWorker.getRegistrations() .then((registrations) => Promise.all(registrations.map((registration) => registration.unregister()))) .catch(() => {}); if ("caches" in window) { caches.keys() .then((keys) => Promise.all(keys.filter((key) => key.startsWith("hermes-ui-")).map((key) => caches.delete(key)))) .catch(() => {}); } } // ---------- 主题 ---------- function loadTheme() { const theme = localStorage.getItem(LS_THEME) || "dark"; if (theme === "light") document.documentElement.setAttribute("data-theme", "light"); } function toggleTheme() { const cur = document.documentElement.getAttribute("data-theme"); if (cur === "light") { document.documentElement.removeAttribute("data-theme"); localStorage.setItem(LS_THEME, "dark"); } else { document.documentElement.setAttribute("data-theme", "light"); localStorage.setItem(LS_THEME, "light"); } } // ---------- 设置持久化 ---------- function loadSettings() { try { const raw = localStorage.getItem(LS_SETTINGS); if (raw) { const s = JSON.parse(raw); Object.assign(state, s); const ab = document.getElementById("apiBase"); const ak = document.getElementById("apiKey"); const sm = document.getElementById("streamMode"); if (ab) ab.value = state.apiBase; if (ak) ak.value = state.apiKey; if (sm) sm.checked = state.stream; } } catch (e) {} try { const rawProfiles = localStorage.getItem(LS_MODEL_PROFILES); if (rawProfiles) state.modelProfiles = normalizeModelProfiles(JSON.parse(rawProfiles)); } catch (e) { state.modelProfiles = []; } syncModelPick(state.model); } function saveSettings(options = {}) { state.apiBase = document.getElementById("apiBase").value.trim(); state.apiKey = document.getElementById("apiKey").value.trim(); state.stream = document.getElementById("streamMode").checked; state.model = document.getElementById("modelPick")?.value || state.model; localStorage.setItem(LS_SETTINGS, JSON.stringify({ apiBase: state.apiBase, apiKey: state.apiKey, stream: state.stream, model: state.model, })); if (!options.silent) toast("设置已保存"); pingBackend(); } function ensureModelChoice(modelValue, labelValue = "") { const model = (modelValue || "").trim(); if (!model) return; const label = (labelValue || model).trim(); const pick = document.getElementById("modelPick"); if (pick && !Array.from(pick.options).some(item => item.value === model)) { pick.appendChild(new Option(label, model)); } const list = document.getElementById("modelOptions"); if (list && !Array.from(list.options).some(item => item.value === model)) { const option = document.createElement("option"); option.value = model; option.label = label; list.appendChild(option); } } function updateModelDisplay(modelValue, providerValue = "") { const model = (modelValue || state.model || DEFAULT_MODEL_ID).trim(); const provider = (providerValue || "").trim(); const pick = document.getElementById("modelPick"); const option = pick ? Array.from(pick.options).find(item => item.value === model) : null; const label = option?.textContent || model; const stat = document.getElementById("statModel"); const statSub = document.getElementById("statModelSub"); const aboutModel = document.getElementById("aboutModelValue"); if (stat) stat.textContent = label; if (statSub) statSub.textContent = provider ? "Provider: " + provider : "Provider 以设置为准"; if (aboutModel) aboutModel.textContent = provider ? model + " · " + provider : model; } function syncModelPick(modelValue, providerValue = "") { const model = (modelValue || state.model || "").trim(); const pick = document.getElementById("modelPick"); if (!pick || !model) return; ensureModelChoice(model); pick.value = model; state.model = model; updateModelDisplay(model, providerValue); } function saveModelProfilesToLS() { localStorage.setItem(LS_MODEL_PROFILES, JSON.stringify(normalizeModelProfiles(state.modelProfiles))); } 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 profileForAgent(agent) { if (!agent?.modelProfileId) return null; const profile = modelProfileById(agent.modelProfileId); return profile && profile.enabled !== false ? profile : null; } function modelForAgent(agent) { const profile = profileForAgent(agent); return profile?.model || agent?.model || state.model || DEFAULT_MODEL_ID; } function modelLabelForAgent(agent) { const profile = profileForAgent(agent); if (profile) return profile.name + " · " + profile.model; return agent?.model || state.model || DEFAULT_MODEL_ID; } function profileNeedsProxy(profile) { return !!(profile && profile.id !== "runtime-default" && (profile.baseUrl || profile.apiKeyRef)); } function chatRouteForAgent(agent) { const profile = profileForAgent(agent); if (profileNeedsProxy(profile)) { return { url: "/feishu/chat/completions", proxy: true, modelProfileId: profile.id, }; } return { url: state.apiBase + "/chat/completions", proxy: false, modelProfileId: "", }; } function chatFetchOptions(route, body, stream = false) { const headers = { "Content-Type": "application/json" }; if (stream) headers.Accept = "text/event-stream"; if (!route.proxy) headers.Authorization = "Bearer " + state.apiKey; return { method: "POST", credentials: "same-origin", headers, body: JSON.stringify(body), }; } 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 })), ]); saveModelProfilesToLS(); 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 { const raw = localStorage.getItem(LS_CONVOS); if (raw) state.conversations = JSON.parse(raw) || {}; } catch (e) {} const active = localStorage.getItem(LS_ACTIVE); if (active && state.conversations[active]) { state.activeId = active; } else { // 没有活动会话就自动挑一个最新的,或创建新的 const ids = sortedConvoIds(); if (ids.length) state.activeId = ids[0]; else createConvo(); } } function saveConversations() { localStorage.setItem(LS_CONVOS, JSON.stringify(state.conversations)); if (state.activeId) localStorage.setItem(LS_ACTIVE, state.activeId); invalidateBucketCache(); _dashboardDirty = true; } function sortedConvoIds() { return Object.values(state.conversations) .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)) .map(c => c.id); } function createConvo() { const id = "c_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); state.conversations[id] = { id, title: "新对话", messages: [], createdAt: Date.now(), updatedAt: Date.now(), }; state.activeId = id; saveConversations(); return id; } function activeConvo() { if (!state.activeId || !state.conversations[state.activeId]) { createConvo(); } return state.conversations[state.activeId]; } function switchConvo(id) { if (!state.conversations[id]) return; state.activeId = id; localStorage.setItem(LS_ACTIVE, id); renderSidebar(); renderChat(); switchTab("chat"); } function deleteConvo(id, e) { if (e) e.stopPropagation(); if (!confirm("删除这个对话?此操作不可撤销。")) return; delete state.conversations[id]; if (state.activeId === id) { const ids = sortedConvoIds(); state.activeId = ids[0] || null; if (!state.activeId) createConvo(); } saveConversations(); renderSidebar(); renderChat(); } function newChat() { createConvo(); renderSidebar(); renderChat(); switchTab("chat"); setTimeout(() => document.getElementById("chatInput")?.focus(), 50); } // ---------- 侧栏搜索 ---------- function bindSearch() { const input = document.getElementById("searchInput"); if (!input) return; input.addEventListener("input", (e) => { state.searchQuery = e.target.value.trim().toLowerCase(); document.getElementById("searchClear").style.display = state.searchQuery ? "inline" : "none"; renderSidebar(); }); } function clearSearch() { const input = document.getElementById("searchInput"); if (input) input.value = ""; state.searchQuery = ""; document.getElementById("searchClear").style.display = "none"; renderSidebar(); } function matchConvo(c, q) { if (!q) return true; if ((c.title || "").toLowerCase().includes(q)) return true; if ((c.tags || []).some(t => t.toLowerCase().includes(q))) return true; // 消息内容也能搜,只看前 3 条避免慢 for (const m of (c.messages || []).slice(0, 30)) { if ((m.content || "").toLowerCase().includes(q)) return true; } return false; } // ---------- 侧栏会话列表 ---------- function renderSidebar() { const el = document.getElementById("sideHistory"); if (!el) return; el.innerHTML = '
对话历史
'; const allIds = sortedConvoIds(); const ids = allIds.filter(id => matchConvo(state.conversations[id], state.searchQuery)); if (!ids.length) { const empty = document.createElement("div"); empty.className = "history-empty"; empty.textContent = state.searchQuery ? "没有匹配的对话" : "暂无对话"; el.appendChild(empty); return; } for (const id of ids) { const c = state.conversations[id]; const row = document.createElement("div"); row.className = "history-item" + (id === state.activeId ? " active" : ""); const main = document.createElement("div"); main.className = "history-item-main"; main.onclick = () => switchConvo(id); const title = document.createElement("span"); title.className = "history-title"; title.textContent = c.title || "未命名"; main.appendChild(title); const act = document.createElement("div"); act.className = "history-act"; const renameBtn = document.createElement("button"); renameBtn.title = "重命名 / 标签"; renameBtn.innerHTML = "✎"; renameBtn.onclick = (e) => { e.stopPropagation(); openRenameModal(id); }; act.appendChild(renameBtn); const delBtn = document.createElement("button"); delBtn.className = "danger"; delBtn.title = "删除"; delBtn.innerHTML = "×"; delBtn.onclick = (e) => { e.stopPropagation(); deleteConvo(id, e); }; act.appendChild(delBtn); main.appendChild(act); row.appendChild(main); if (c.tags && c.tags.length) { const tagsEl = document.createElement("div"); tagsEl.className = "history-item-tags"; for (const t of c.tags) { const tag = document.createElement("span"); tag.className = "history-tag"; tag.textContent = t; tag.onclick = (e) => { e.stopPropagation(); document.getElementById("searchInput").value = t; state.searchQuery = t.toLowerCase(); document.getElementById("searchClear").style.display = "inline"; renderSidebar(); }; tagsEl.appendChild(tag); } row.appendChild(tagsEl); } el.appendChild(row); } } // ---------- 重命名 / 标签 modal ---------- function openRenameModal(id) { const c = state.conversations[id]; if (!c) return; state.renamingId = id; document.getElementById("renameTitle").value = c.title || ""; document.getElementById("renameTags").value = (c.tags || []).join(", "); document.getElementById("renameModal").classList.add("open"); setTimeout(() => document.getElementById("renameTitle").focus(), 50); } function closeRenameModal() { document.getElementById("renameModal").classList.remove("open"); state.renamingId = null; } function renameCurrent() { if (state.activeId) openRenameModal(state.activeId); } function saveRename() { const id = state.renamingId; if (!id || !state.conversations[id]) return; const c = state.conversations[id]; const title = document.getElementById("renameTitle").value.trim(); const tagsRaw = document.getElementById("renameTags").value; const tags = tagsRaw.split(/[,,]/).map(t => t.trim()).filter(Boolean).slice(0, 6); c.title = title || "未命名"; c.tags = tags; c.updatedAt = Date.now(); saveConversations(); renderSidebar(); renderChat(); closeRenameModal(); toast("已保存"); } // ---------- Tab 切换 ---------- function bindTabs() { document.querySelectorAll(".side-item").forEach(btn => { btn.addEventListener("click", () => switchTab(btn.dataset.tab)); }); } function restoreActiveTab() { const saved = localStorage.getItem(LS_TAB); if (!saved) return; if (!document.querySelector(`.side-item[data-tab="${CSS.escape(saved)}"]`)) return; if (!document.getElementById("tab-" + saved)) return; switchTab(saved, { persist: false }); } let _dashboardDirty = true; function markDashboardDirty() { _dashboardDirty = true; } function switchTab(name, options = {}) { if (options.persist !== false) localStorage.setItem(LS_TAB, name); document.querySelectorAll(".side-item").forEach(t => t.classList.toggle("active", t.dataset.tab === name)); document.querySelectorAll(".tab-panel").forEach(p => p.classList.toggle("active", p.id === "tab-" + name)); if (name === "chat") setTimeout(() => document.getElementById("chatInput")?.focus(), 50); if (name === "studio") { renderStudioLibrary(); renderStudioCanvas(); } if (name === "cron") refreshCron(); if (name === "memory") refreshMemory(); if (name === "models") { renderModelProfiles(); refreshUiConfig().catch((error) => { setSharedConfigStatus("共享配置读取失败: " + (error.message || error), true); }); refreshHermesConfig(); } if (name === "tools") { refreshTools(); refreshHermesConfig(); } if (name === "integrations") refreshFeishuApps(); if (name === "settings") { renderWeeklyReports(); } if (name === "runs") setTimeout(() => document.getElementById("runPrompt")?.focus(), 50); if (name === "dashboard" && _dashboardDirty) { // 推迟到下一帧,避免阻塞切换动画 requestAnimationFrame(() => { refreshDashboard(); _dashboardDirty = false; }); } } // ---------- 飞书集成 ---------- let _feishuAppsLoading = false; let _feishuLastApps = []; async function refreshFeishuApps() { const box = document.getElementById("feishuApps"); if (!box || _feishuAppsLoading) return; _feishuAppsLoading = true; box.innerHTML = '
正在读取飞书桥接服务...
'; try { const res = await apiFetch("/feishu/apps", { credentials: "same-origin", cache: "no-store", }); if (!res.ok) throw new Error("HTTP " + res.status); const data = await res.json(); const apps = Array.isArray(data.apps) ? data.apps : []; _feishuLastApps = apps; if (!apps.length) { box.innerHTML = '
还没有读取到飞书机器人配置。
'; return; } box.innerHTML = apps.map(app => { const appId = escapeHTML(app.app_id || ""); const encodedAppId = encodeURIComponent(app.app_id || ""); const callbackUrl = escapeHTML(app.callback_url || ""); const isDefault = app.app_id === data.default_app_id; const tokenCount = Number(app.verification_tokens_count || 0); return `
${appId}
${isDefault ? "默认应用" : "独立应用"} · ${tokenCount} 个校验 Token
已接入
${callbackUrl}
事件: im.message.receive_v1 通知目标: ${app.has_default_receive_id ? escapeHTML(app.default_receive_id_type || "chat_id") : "按请求传入"}
`; }).join(""); } catch (e) { box.innerHTML = `
飞书桥接服务读取失败: ${escapeHTML(e.message || e)}
`; } finally { _feishuAppsLoading = false; } } async function deleteFeishuApp(encodedAppId) { const appId = decodeURIComponent(encodedAppId || ""); if (!appId) return; const ok = confirm(`删除飞书机器人 ${appId}?\n\n删除后这个 App ID 的回调地址会立刻失效,Secret / Token 也会从服务器环境文件移除。`); if (!ok) return; try { const res = await apiFetch("/feishu/apps/" + encodeURIComponent(appId), { method: "DELETE", credentials: "same-origin", }); const data = await res.json().catch(() => ({})); if (!res.ok || data.code !== 0) throw new Error(data.msg || ("HTTP " + res.status)); toast("飞书机器人已删除"); await refreshFeishuApps(); } catch (e) { toast("删除失败: " + (e.message || e)); } } async function saveFeishuApp(event) { event.preventDefault(); const appIdEl = document.getElementById("feishuAppId"); const secretEl = document.getElementById("feishuAppSecret"); const tokenEl = document.getElementById("feishuVerifyToken"); const app_id = appIdEl?.value.trim() || ""; const app_secret = secretEl?.value.trim() || ""; const verification_token = tokenEl?.value.trim() || ""; if (!/^cli_[A-Za-z0-9]+$/.test(app_id)) { toast("App ID 格式不对"); return; } if (app_secret.length < 16 || verification_token.length < 16) { toast("Secret / Token 太短"); return; } const submit = event.target.querySelector("button[type='submit']"); const oldText = submit?.textContent; if (submit) { submit.disabled = true; submit.textContent = "正在保存..."; } try { const res = await apiFetch("/feishu/apps", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ app_id, app_secret, verification_token }), }); const data = await res.json().catch(() => ({})); if (!res.ok || data.code !== 0) throw new Error(data.msg || ("HTTP " + res.status)); secretEl.value = ""; tokenEl.value = ""; toast("飞书机器人已保存"); await refreshFeishuApps(); if (data.app?.callback_url) copyText(data.app.callback_url); } catch (e) { toast("保存失败: " + (e.message || e)); } finally { if (submit) { submit.disabled = false; submit.innerHTML = '添加 / 更新机器人'; } } } // ---------- 带认证续期的 fetch ---------- // nginx 的 hermes_auth cookie 默认 24h 过期;过期后 /api/v1/* 全部 401。 // 这里在 401 时尝试一次静默续期(浏览器若缓存了 Basic Auth 会自动带上), // 续期失败再跳登录页,避免对话直接抛 "HTTP 401"。 let _renewing = null; async function renewAuth() { if (_renewing) return _renewing; _renewing = (async () => { try { const r = await fetch("/_auth/verify", { credentials: "same-origin", cache: "no-store" }); return r.ok; } catch (e) { return false; } finally { setTimeout(() => { _renewing = null; }, 0); } })(); return _renewing; } async function apiFetch(url, init) { const res = await fetch(url, init); if (res.status !== 401) return res; if (await renewAuth()) { return fetch(url, init); } if (!location.pathname.endsWith("/login.html")) { location.href = "/login.html"; } return res; } // ---------- 健康检查 ---------- async function pingBackend() { const pill = document.getElementById("sideStatus"); const text = document.getElementById("statusText"); const statApi = document.getElementById("statApi"); const statApiSub = document.getElementById("statApiSub"); try { const res = await apiFetch(state.apiBase + "/models", { headers: { "Authorization": "Bearer " + state.apiKey }, signal: AbortSignal.timeout(4000), }); if (res.ok) { pill.classList.remove("err"); pill.classList.add("ok"); text.textContent = "在线"; if (statApi) statApi.textContent = "✓ 在线"; if (statApiSub) statApiSub.textContent = state.apiBase; } else { pill.classList.remove("ok"); pill.classList.add("err"); text.textContent = "HTTP " + res.status; if (statApi) statApi.textContent = "HTTP " + res.status; } } catch (e) { pill.classList.remove("ok"); pill.classList.add("err"); text.textContent = "离线"; if (statApi) statApi.textContent = "✗ 离线"; if (statApiSub) statApiSub.textContent = e.message || "连接失败"; } } async function testApiConnection() { saveSettings({ silent: true }); try { const res = await apiFetch(state.apiBase + "/models", { headers: { "Authorization": "Bearer " + state.apiKey }, signal: AbortSignal.timeout(5000), }); if (!res.ok) { toast("API 检测失败: HTTP " + res.status); return; } const data = await res.json().catch(() => ({})); const list = data?.data || data?.models || []; toast("API 连接正常" + (list.length ? " · " + list.length + " 个模型" : "")); } catch (e) { toast("API 检测失败: " + (e.message || e)); } finally { pingBackend(); } } let _hermesConfigLoaded = false; let _hermesConfigLoading = false; let _hermesConfigSnapshot = null; function setSettingsStatus(id, text, isError = false) { const el = document.getElementById(id); if (!el) return; el.textContent = text; el.style.color = isError ? "var(--err)" : ""; } function setHermesModelStatus(text, isError = false) { setSettingsStatus("hermesModelStatus", text, isError); } function setHermesMcpStatus(text, isError = false) { setSettingsStatus("hermesMcpStatus", text, isError); } function setHermesConfigStatuses(text, isError = false) { setHermesModelStatus(text, isError); setHermesMcpStatus(text, isError); } async function refreshHermesConfig(force = false) { if (_hermesConfigLoading || (_hermesConfigLoaded && !force)) return; const modelEl = document.getElementById("hermesModelDefault"); if (!modelEl) return; _hermesConfigLoading = true; setHermesConfigStatuses("正在读取线上配置..."); try { const res = await apiFetch("/feishu/hermes-config", { 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)); const config = data.config || {}; const model = config.model || {}; document.getElementById("hermesModelDefault").value = model.default || ""; document.getElementById("hermesModelProvider").value = model.provider || ""; document.getElementById("hermesModelBaseUrl").value = model.base_url || ""; document.getElementById("mcpServersYaml").value = config.mcp_servers_yaml || ""; _hermesConfigSnapshot = { model: { default: model.default || "", provider: model.provider || "", base_url: model.base_url || "", }, mcp_servers_yaml: config.mcp_servers_yaml || "", }; 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); setHermesMcpStatus("已读取 MCP 配置" + suffix); } catch (e) { setHermesConfigStatuses("读取失败: " + (e.message || e), true); } finally { _hermesConfigLoading = false; } } function readModelConfigFields() { const modelDefault = document.getElementById("hermesModelDefault")?.value.trim() || ""; const provider = document.getElementById("hermesModelProvider")?.value.trim() || "openrouter"; const baseUrl = document.getElementById("hermesModelBaseUrl")?.value.trim() || ""; return { default: modelDefault, provider, base_url: baseUrl }; } function snapshotModelOrFields() { const model = _hermesConfigSnapshot?.model || {}; if (model.default) return { ...model }; const fields = readModelConfigFields(); if (fields.default) return fields; return { default: state.model || DEFAULT_MODEL_ID, provider: fields.provider || "openrouter", base_url: fields.base_url || "", }; } async function postHermesRuntimeConfig(payload) { const res = await apiFetch("/feishu/hermes-config", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); const data = await res.json().catch(() => ({})); if (!res.ok || data.code !== 0) throw new Error(data.msg || ("HTTP " + res.status)); 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 apiFetch(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 apiFetch(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); saveModelProfilesToLS(); 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; saveModelProfilesToLS(); 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; } } function modelProfileCard(profile) { const defaultBadge = profile.isDefault ? '默认' : ""; const disabledBadge = profile.enabled ? "" : '停用'; return `
${escapeHTML(profile.name)}${defaultBadge}${disabledBadge}
${escapeHTML(profile.model)}
${escapeHTML(profile.provider || "auto")} ${profile.baseUrl ? `${escapeHTML(profile.baseUrl)}` : ""} ${profile.apiKeyRef ? `${escapeHTML(profile.apiKeyRef)}` : ""}
`; } function renderModelProfiles() { state.modelProfiles = normalizeModelProfiles(state.modelProfiles); saveModelProfilesToLS(); const list = document.getElementById("modelProfilesList"); if (list) { list.innerHTML = state.modelProfiles.map(modelProfileCard).join(""); } const active = activeModelProfile(); const count = document.getElementById("modelProfilesCount"); if (count) count.textContent = `${state.modelProfiles.length} 个模型 Profile · 默认 ${active?.name || "未设置"}`; renderAgentModelProfileOptions(document.getElementById("agentModelProfile")?.value || ""); } function clearModelProfileForm() { state.editingModelProfileId = null; const fields = { modelProfileName: "", modelProfileProvider: "openrouter", modelProfileModel: "", modelProfileBaseUrl: "", modelProfileApiKeyRef: "", }; for (const [id, value] of Object.entries(fields)) { const el = document.getElementById(id); if (el) el.value = value; } const enabled = document.getElementById("modelProfileEnabled"); const isDefault = document.getElementById("modelProfileDefault"); if (enabled) enabled.checked = true; if (isDefault) isDefault.checked = false; const title = document.getElementById("modelProfileFormTitle"); if (title) title.textContent = "新增模型 Profile"; } function editModelProfile(id) { const profile = modelProfileById(id); if (!profile) return; state.editingModelProfileId = id; const values = { modelProfileName: profile.name, modelProfileProvider: profile.provider, modelProfileModel: profile.model, modelProfileBaseUrl: profile.baseUrl, modelProfileApiKeyRef: profile.apiKeyRef, }; for (const [fieldId, value] of Object.entries(values)) { const el = document.getElementById(fieldId); if (el) el.value = value || ""; } const enabled = document.getElementById("modelProfileEnabled"); const isDefault = document.getElementById("modelProfileDefault"); if (enabled) enabled.checked = profile.enabled !== false; if (isDefault) isDefault.checked = !!profile.isDefault; const title = document.getElementById("modelProfileFormTitle"); if (title) title.textContent = "编辑模型 Profile"; } async function saveModelProfile() { const name = document.getElementById("modelProfileName")?.value.trim() || ""; const provider = document.getElementById("modelProfileProvider")?.value.trim() || "openrouter"; const model = document.getElementById("modelProfileModel")?.value.trim() || ""; const baseUrl = document.getElementById("modelProfileBaseUrl")?.value.trim() || ""; const apiKeyRef = document.getElementById("modelProfileApiKeyRef")?.value.trim() || ""; const enabled = document.getElementById("modelProfileEnabled")?.checked !== false; const isDefault = !!document.getElementById("modelProfileDefault")?.checked; if (!model) { toast("请填写模型 ID"); return; } if (baseUrl && !/^https?:\/\//i.test(baseUrl)) { toast("Base URL 必须以 http:// 或 https:// 开头"); return; } const id = state.editingModelProfileId || makeId("model"); const existing = modelProfileById(id); const next = normalizeModelProfile({ id, name: name || model, provider, model, baseUrl, apiKeyRef, enabled, isDefault, createdAt: existing?.createdAt || Date.now(), updatedAt: Date.now(), }); state.modelProfiles = state.modelProfiles.filter(profile => profile.id !== id); if (next.isDefault) { state.modelProfiles = state.modelProfiles.map(profile => ({ ...profile, isDefault: false })); } state.modelProfiles.push(next); state.modelProfiles = normalizeModelProfiles(state.modelProfiles); saveModelProfilesToLS(); syncModelOptionsFromProfiles(); renderModelProfiles(); clearModelProfileForm(); try { await saveSharedConfig({ silent: true }); toast("模型 Profile 已保存到服务器"); } catch (error) { setSharedConfigStatus("模型 Profile 保存失败: " + (error.message || error), true); toast("模型 Profile 暂存本机,服务器保存失败"); } } async function makeModelProfileDefault(id) { const profile = modelProfileById(id); if (!profile) return; state.modelProfiles = normalizeModelProfiles(state.modelProfiles.map(item => ({ ...item, isDefault: item.id === id, }))); saveModelProfilesToLS(); syncModelOptionsFromProfiles(); renderModelProfiles(); try { await saveSharedConfig({ silent: true }); toast("默认模型 Profile 已更新"); } catch (error) { setSharedConfigStatus("默认模型 Profile 保存失败: " + (error.message || error), true); } } function applyModelProfileToRuntime(id) { const profile = modelProfileById(id); if (!profile) return; document.getElementById("hermesModelDefault").value = profile.model || ""; document.getElementById("hermesModelProvider").value = profile.provider || ""; document.getElementById("hermesModelBaseUrl").value = profile.baseUrl || ""; toast("已填入运行配置,点击“保存模型并重启”后生效"); } async function deleteModelProfile(id) { if (state.modelProfiles.length <= 1) { toast("至少保留一个模型 Profile"); return; } if (!confirm("删除这个模型 Profile? 已绑定的智能体会回退到模型 ID。")) return; const wasDefault = modelProfileById(id)?.isDefault; state.modelProfiles = state.modelProfiles.filter(profile => profile.id !== id); if (wasDefault && state.modelProfiles[0]) state.modelProfiles[0].isDefault = true; for (const agent of Object.values(state.agents)) { if (agent.modelProfileId === id) agent.modelProfileId = ""; } state.modelProfiles = normalizeModelProfiles(state.modelProfiles); saveModelProfilesToLS(); syncModelOptionsFromProfiles(); renderModelProfiles(); renderAgents(); try { await saveSharedConfig({ silent: true }); toast("模型 Profile 已删除"); } catch (error) { setSharedConfigStatus("模型 Profile 删除保存失败: " + (error.message || error), true); } } async function saveModelConfig() { const model = readModelConfigFields(); if (!model.default) { toast("默认模型不能为空"); return; } if (!confirm("保存模型页的运行模型配置后会重启线上 Hermes agent,当前正在生成的任务可能中断。继续吗?")) return; const btn = document.getElementById("hermesModelSaveBtn"); const oldHTML = btn?.innerHTML; if (btn) { btn.disabled = true; btn.textContent = "保存模型中..."; } setHermesModelStatus("正在写入 model 配置并重启 Hermes agent..."); try { const saved = await postHermesRuntimeConfig({ model, mcp_servers_yaml: _hermesConfigSnapshot ? _hermesConfigSnapshot.mcp_servers_yaml : (document.getElementById("mcpServersYaml")?.value || ""), restart: true, }); const savedModel = saved.model || {}; if (savedModel.default) syncModelPick(savedModel.default, savedModel.provider || ""); _hermesConfigSnapshot = { model: { default: savedModel.default || model.default, provider: savedModel.provider || model.provider, base_url: savedModel.base_url || model.base_url, }, 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("模型页配置已生效"); setTimeout(() => { pingBackend(); refreshDashboard(); }, 1800); } catch (e) { setHermesModelStatus("保存失败: " + (e.message || e), true); toast("保存失败: " + (e.message || e)); } finally { if (btn) { btn.disabled = false; btn.innerHTML = oldHTML; } } } async function saveMcpConfig() { const mcpServersYaml = document.getElementById("mcpServersYaml")?.value || ""; if (!confirm("保存工具集里的 MCP 配置后会重启线上 Hermes agent,当前正在生成的任务可能中断。继续吗?")) return; const btn = document.getElementById("hermesMcpSaveBtn"); const oldHTML = btn?.innerHTML; if (btn) { btn.disabled = true; btn.textContent = "保存 MCP 中..."; } setHermesMcpStatus("正在写入 mcp_servers 配置并重启 Hermes agent..."); try { const saved = await postHermesRuntimeConfig({ mcp_servers_yaml: mcpServersYaml, restart: true, }); const fallbackModel = snapshotModelOrFields(); const savedModel = saved.model || fallbackModel; if (savedModel.default) syncModelPick(savedModel.default, savedModel.provider || fallbackModel.provider || ""); document.getElementById("mcpServersYaml").value = saved.mcp_servers_yaml || ""; _hermesConfigSnapshot = { model: { default: savedModel.default || fallbackModel.default, provider: savedModel.provider || fallbackModel.provider, base_url: savedModel.base_url || fallbackModel.base_url, }, mcp_servers_yaml: saved.mcp_servers_yaml || "", }; _hermesConfigLoaded = false; setHermesMcpStatus("MCP 配置已保存并重启 · 备份 " + (saved.backup || "已创建")); toast("工具集 MCP 配置已生效"); setTimeout(() => { pingBackend(); refreshDashboard(); }, 1800); } catch (e) { setHermesMcpStatus("保存失败: " + (e.message || e), true); toast("保存失败: " + (e.message || e)); } finally { if (btn) { btn.disabled = false; btn.innerHTML = oldHTML; } } } async function fetchIP() { const el = document.getElementById("statIP"); if (el) el.textContent = location.hostname; } // ---------- 对话 ---------- function bindChat() { const form = document.getElementById("chatForm"); const input = document.getElementById("chatInput"); input.addEventListener("input", () => { input.style.height = "auto"; input.style.height = Math.min(input.scrollHeight, 200) + "px"; }); input.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); form.requestSubmit(); } }); form.addEventListener("submit", (e) => { e.preventDefault(); const text = input.value.trim(); if (!text) return; input.value = ""; input.style.height = "auto"; sendMessage(text); }); document.getElementById("clearBtn").addEventListener("click", () => { if (!confirm("清空当前对话?")) return; const c = activeConvo(); c.messages = []; c.updatedAt = Date.now(); saveConversations(); renderChat(); }); document.getElementById("modelPick").addEventListener("change", (e) => { state.model = e.target.value; document.getElementById("statModel").textContent = e.target.options[e.target.selectedIndex].text; saveSettings({ silent: true }); }); } function fillPrompt(t) { const input = document.getElementById("chatInput"); input.value = t; input.focus(); input.dispatchEvent(new Event("input")); } async function sendMessage(text) { const c = activeConvo(); c.messages.push({ role: "user", content: text, ts: Date.now() }); // 标题自动生成(第一条用户消息) if (!c.title || c.title === "新对话") { c.title = text.slice(0, 24) + (text.length > 24 ? "…" : ""); } c.updatedAt = Date.now(); saveConversations(); renderSidebar(); renderChat(); // 确定本次用哪个智能体: pendingAgent 优先,其次是会话绑定 const pendingId = state.pendingAgent; const useAgentId = pendingId || c.agentId; const useAgent = useAgentId && state.agents[useAgentId] ? state.agents[useAgentId] : null; // 如果用到 Hermes skill 要先加载索引 if (useAgent) await ensureHermesSkillsLoaded(useAgent); const assistantMsg = { role: "assistant", content: "", ts: Date.now(), agentId: useAgentId || null }; c.messages.push(assistantMsg); renderChat(true); // prepend system prompt(含 skills) + 用对应智能体的模型 let msgsForApi = c.messages.slice(0, -1); let modelForApi = state.model; if (useAgent) { const sys = composeSystemPrompt(useAgent); if (sys) msgsForApi = [{ role: "system", content: sys }, ...msgsForApi]; modelForApi = modelForAgent(useAgent); } const route = chatRouteForAgent(useAgent); // 本次使用完清掉 pendingAgent if (pendingId) { state.pendingAgent = null; updatePendingAgentBar(); } const body = { model: modelForApi, messages: msgsForApi, stream: state.stream, }; if (route.modelProfileId) body.modelProfileId = route.modelProfileId; try { if (state.stream) { await streamChat(body, assistantMsg, route); } else { const res = await apiFetch(route.url, chatFetchOptions(route, body)); if (!res.ok) throw new Error("HTTP " + res.status + ": " + await res.text()); const data = await res.json(); assistantMsg.content = data?.choices?.[0]?.message?.content || "(无回复)"; if (data?.usage) { state.tokens += data.usage.total_tokens || 0; c.tokens = (c.tokens || 0) + (data.usage.total_tokens || 0); updateStats(); } renderChat(); } state.turns += 1; updateStats(); c.updatedAt = Date.now(); saveConversations(); } catch (e) { c.messages.pop(); c.messages.push({ role: "error", content: "发送失败: " + (e.message || e) }); saveConversations(); renderChat(); } } async function streamChat(body, assistantMsg, route = chatRouteForAgent(null)) { const res = await apiFetch(route.url, chatFetchOptions(route, body, true)); if (!res.ok) throw new Error("HTTP " + res.status + ": " + await res.text()); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; // 关键优化: 先做一次完整渲染,然后只增量更新最后一条的 content 元素 // 避免每个 delta 都重建整个消息列表的 DOM renderChat(true); const container = document.getElementById("chatMessages"); const lastRow = container.lastElementChild; const contentEl = lastRow?.querySelector(".msg-content"); if (contentEl) { contentEl.textContent = ""; contentEl.classList.add("streaming"); } let pendingText = ""; let rafScheduled = false; const flush = () => { rafScheduled = false; if (contentEl && pendingText) { assistantMsg.content += pendingText; pendingText = ""; // 流式时用 markdown 渲染(每 rAF 重建一次,代价不高因为文字不多) contentEl.innerHTML = renderMarkdown(assistantMsg.content); contentEl.classList.add("streaming"); const nearBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 160; if (nearBottom) container.scrollTop = container.scrollHeight; } }; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { const trimmed = line.trim(); if (!trimmed || !trimmed.startsWith("data:")) continue; const payload = trimmed.slice(5).trim(); if (payload === "[DONE]") continue; try { const chunk = JSON.parse(payload); const delta = chunk?.choices?.[0]?.delta?.content || ""; if (delta) { pendingText += delta; if (!rafScheduled) { rafScheduled = true; requestAnimationFrame(flush); } } // tool calls 捕获(OpenAI 兼容格式) const tcDelta = chunk?.choices?.[0]?.delta?.tool_calls; if (Array.isArray(tcDelta)) { if (!assistantMsg.toolCalls) assistantMsg.toolCalls = []; for (const tc of tcDelta) { const idx = tc.index ?? assistantMsg.toolCalls.length; if (!assistantMsg.toolCalls[idx]) assistantMsg.toolCalls[idx] = { name: "", arguments: "" }; if (tc.function?.name) assistantMsg.toolCalls[idx].name = tc.function.name; if (tc.function?.arguments) assistantMsg.toolCalls[idx].arguments += tc.function.arguments; if (tc.id) assistantMsg.toolCalls[idx].id = tc.id; } } if (chunk?.usage?.total_tokens) { state.tokens += chunk.usage.total_tokens; const c2 = activeConvo(); if (c2) c2.tokens = (c2.tokens || 0) + chunk.usage.total_tokens; } } catch (e) {} } } // flush 剩余 if (pendingText) flush(); if (contentEl) contentEl.classList.remove("streaming"); // 流结束后一次性重渲染 + 写 localStorage saveConversations(); renderChat(false); } function renderChat(streaming = false) { const container = document.getElementById("chatMessages"); if (!container) return; const c = activeConvo(); const title = document.getElementById("chatTitleText"); if (title) title.textContent = c.title || "新对话"; updateChatAgentBadge(); // 空对话 → 显示品牌欢迎 if (!c.messages.length) { const hours = new Date().getHours(); const greet = hours < 6 ? "夜深了" : hours < 12 ? "早上好" : hours < 18 ? "下午好" : "晚上好"; container.innerHTML = `
HERMÈS PARIS
${greet},今天想聊点什么?
由当前 AI 模型驱动 · 你的私人 AI 助手
🍽 今晚吃什么 💡 解释一个概念 ✍️ 写点东西 🔍 深度研究
`; container.querySelectorAll(".chip").forEach(chip => { chip.onclick = () => fillPrompt(chip.dataset.p); }); return; } const wasNearBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 80; container.innerHTML = ""; const msgs = c.messages; const lastIdx = msgs.length - 1; for (let i = 0; i < msgs.length; i++) { const m = msgs[i]; const row = document.createElement("div"); row.className = "msg " + m.role; if (m.role === "error") { row.innerHTML = '
'; row.querySelector(".msg-content").textContent = m.content; container.appendChild(row); continue; } const msgAgent = (m.role === "assistant" && m.agentId && state.agents[m.agentId]) ? state.agents[m.agentId] : null; const avatar = document.createElement("div"); avatar.className = "msg-avatar"; if (m.role === "user") { avatar.textContent = "你"; } else if (msgAgent) { avatar.textContent = msgAgent.emoji || "H"; } else { avatar.textContent = "H"; } const body = document.createElement("div"); body.className = "msg-body"; const name = document.createElement("div"); name.className = "msg-name"; if (m.role === "user") { name.textContent = "你"; } else if (msgAgent) { name.textContent = msgAgent.name; } else { name.textContent = "爱马仕"; } const content = document.createElement("div"); content.className = "msg-content"; const isStreamingThis = streaming && i === lastIdx && m.role === "assistant"; if (m.role === "assistant" && !m.content) { content.innerHTML = '
'; } else if (m.role === "assistant") { // markdown 渲染 content.innerHTML = renderMarkdown(m.content); if (isStreamingThis) content.classList.add("streaming"); // tool calls 显示 if (m.toolCalls && m.toolCalls.length) { const tc = document.createElement("div"); tc.className = "msg-toolcalls"; tc.innerHTML = '
'; const body = tc.querySelector(".tc-body"); for (const t of m.toolCalls) { const one = document.createElement("div"); one.className = "tc-item"; one.innerHTML = '
';
          one.querySelector(".tc-name").textContent = t.name || t.function?.name || "unknown";
          one.querySelector(".tc-args").textContent = (t.arguments || t.function?.arguments || "").slice(0, 800);
          body.appendChild(one);
        }
        tc.querySelector(".tc-toggle").onclick = () => tc.classList.toggle("open");
        content.appendChild(tc);
      }
    } else {
      // 用户消息保留原文
      content.textContent = m.content;
    }

    body.appendChild(name);
    body.appendChild(content);

    // 只给最后一条加入场动画,避免切 tab 时所有消息集体播动画
    if (i === lastIdx && !streaming) row.classList.add("msg-in");

    // 分支 / 复制按钮(流式中的最后一条不显示)
    if (m.role !== "error" && m.content && !isStreamingThis) {
      const actions = document.createElement("div");
      actions.className = "msg-actions";
      const capturedIdx = i;
      const capturedText = m.content;

      const branchBtn = document.createElement("button");
      branchBtn.className = "msg-action-btn";
      branchBtn.title = "从这里新开分支(保留当前智能体)";
      branchBtn.innerHTML = '分支';
      branchBtn.onclick = () => branchFromMessage(capturedIdx);
      actions.appendChild(branchBtn);

      const toAgentBtn = document.createElement("button");
      toAgentBtn.className = "msg-action-btn";
      toAgentBtn.title = "把这里派给某个智能体接着聊";
      toAgentBtn.innerHTML = '派给';
      toAgentBtn.onclick = () => branchToAgent(capturedIdx);
      actions.appendChild(toAgentBtn);

      // 如果是分支对话,AI 回答有"拉回原对话"按钮
      if (c.parentId && state.conversations[c.parentId] && m.role === "assistant") {
        const pullBtn = document.createElement("button");
        pullBtn.className = "msg-action-btn";
        pullBtn.title = "把这条回答拉回到原对话";
        pullBtn.innerHTML = '拉回';
        pullBtn.onclick = () => pullToParent(capturedIdx);
        actions.appendChild(pullBtn);
      }

      if (m.role === "assistant") {
        const weeklyBtn = document.createElement("button");
        weeklyBtn.className = "msg-action-btn";
        weeklyBtn.title = "保存这条回答为周报记录";
        weeklyBtn.innerHTML = '存周报';
        weeklyBtn.onclick = () => saveWeeklyReportFromMessage(capturedIdx);
        actions.appendChild(weeklyBtn);
      }

      const copyBtn = document.createElement("button");
      copyBtn.className = "msg-action-btn";
      copyBtn.title = "复制";
      copyBtn.innerHTML = '复制';
      copyBtn.onclick = () => {
        navigator.clipboard?.writeText(capturedText).then(() => toast("已复制"));
      };
      actions.appendChild(copyBtn);

      body.appendChild(actions);
    }

    row.appendChild(avatar);
    row.appendChild(body);
    container.appendChild(row);
  }

  if (wasNearBottom) container.scrollTop = container.scrollHeight;
}

function updateStats() {
  const t = document.getElementById("statTurns");
  const k = document.getElementById("statTokens");
  if (t) t.textContent = state.turns;
  if (k) k.textContent = state.tokens.toLocaleString();
  refreshDashboardLocal();
}

function refreshDashboardLocal() {
  const convos = Object.values(state.conversations || {});
  const convosEl = document.getElementById("statConvos");
  const convosSubEl = document.getElementById("statConvosSub");
  const agentsEl = document.getElementById("statAgents");
  const storageEl = document.getElementById("statStorage");
  if (convosEl) convosEl.textContent = convos.length;
  if (convosSubEl) {
    const totalMsgs = convos.reduce((s, c) => s + (c.messages?.length || 0), 0);
    convosSubEl.textContent = totalMsgs + " 条消息";
  }
  if (agentsEl) agentsEl.textContent = Object.keys(state.agents || {}).length;
  if (storageEl) {
    try {
      let bytes = 0;
      for (let i = 0; i < localStorage.length; i++) {
        const k = localStorage.key(i);
        if (k && k.startsWith("hermes-ui-")) bytes += (localStorage.getItem(k) || "").length;
      }
      storageEl.textContent = (bytes / 1024).toFixed(1) + " KB";
    } catch (e) { storageEl.textContent = "—"; }
  }
}

async function refreshDashboard() {
  refreshDashboardLocal();
  pingBackend();
  // 模型列表
  try {
    const res = await apiFetch(state.apiBase + "/models", {
      headers: { "Authorization": "Bearer " + state.apiKey },
      signal: AbortSignal.timeout(4000),
    });
    if (res.ok) {
      const data = await res.json();
      const list = data?.data || data?.models || [];
      const el = document.getElementById("statModels");
      const sub = document.getElementById("statModelsSub");
      if (el) el.textContent = list.length || "—";
      if (sub && list.length) sub.textContent = "/v1/models OK";
    }
  } catch (e) {}
}

// ---------- 周报记录 ----------
function loadWeeklyReports() {
  try {
    const raw = localStorage.getItem(LS_WEEKLY_REPORTS);
    state.weeklyReports = raw ? (JSON.parse(raw) || []) : [];
    if (!Array.isArray(state.weeklyReports)) state.weeklyReports = [];
  } catch (e) {
    state.weeklyReports = [];
  }
}
function saveWeeklyReports() {
  const sorted = (state.weeklyReports || [])
    .filter(r => r && r.id && r.report)
    .sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0))
    .slice(0, 80);
  state.weeklyReports = sorted;
  localStorage.setItem(LS_WEEKLY_REPORTS, JSON.stringify(sorted));
  refreshDashboardLocal();
}
function findWeeklyAssistantIndex(messages, idx) {
  if (!messages?.length) return -1;
  if (Number.isInteger(idx) && messages[idx]?.role === "assistant" && String(messages[idx].content || "").trim()) {
    return idx;
  }
  for (let i = messages.length - 1; i >= 0; i--) {
    if (messages[i].role === "assistant" && String(messages[i].content || "").trim()) return i;
  }
  return -1;
}
function findTaskBefore(messages, idx) {
  for (let i = idx - 1; i >= 0; i--) {
    if (messages[i].role === "user" && String(messages[i].content || "").trim()) return messages[i];
  }
  return null;
}
function compactText(text, max = 180) {
  const s = String(text || "").replace(/\s+/g, " ").trim();
  return s.length > max ? s.slice(0, max - 1) + "…" : s;
}
function formatRecordTime(ts) {
  const d = new Date(ts || Date.now());
  return d.toLocaleString("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" });
}
function makeWeeklyTitle(task, convo) {
  const base = compactText(task || convo?.title || "周报记录", 28);
  return base || "周报记录";
}
function buildWeeklyRecord(messageIndex) {
  const c = activeConvo();
  const messages = c.messages || [];
  const assistantIndex = findWeeklyAssistantIndex(messages, messageIndex);
  if (assistantIndex < 0) {
    toast("还没有可保存的周报内容");
    return null;
  }
  const answer = messages[assistantIndex];
  const taskMsg = findTaskBefore(messages, assistantIndex);
  const task = taskMsg?.content || c.title || "";
  const contextStart = Math.max(0, assistantIndex - 8);
  return {
    id: "wr_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
    title: makeWeeklyTitle(task, c),
    task,
    report: answer.content,
    messages: messages.slice(contextStart, assistantIndex + 1).map(m => ({
      role: m.role,
      content: m.content,
      ts: m.ts || Date.now(),
      agentId: m.agentId || null,
    })),
    tags: Array.isArray(c.tags) ? c.tags.slice(0, 8) : [],
    sourceConvoId: c.id,
    sourceAssistantTs: answer.ts || Date.now(),
    createdAt: Date.now(),
  };
}
function persistWeeklyRecord(record) {
  if (!record) return;
  const existing = state.weeklyReports.findIndex(r =>
    r.sourceConvoId === record.sourceConvoId &&
    r.sourceAssistantTs === record.sourceAssistantTs
  );
  if (existing >= 0) {
    state.weeklyReports[existing] = { ...state.weeklyReports[existing], ...record, id: state.weeklyReports[existing].id };
    saveWeeklyReports();
    renderWeeklyReports();
    toast("周报记录已更新");
    return;
  }
  state.weeklyReports.unshift(record);
  saveWeeklyReports();
  renderWeeklyReports();
  toast("周报记录已保存");
}
function saveWeeklyReportFromChat() {
  persistWeeklyRecord(buildWeeklyRecord());
}
function saveWeeklyReportFromMessage(index) {
  persistWeeklyRecord(buildWeeklyRecord(index));
}
function weeklyById(id) {
  return (state.weeklyReports || []).find(r => r.id === id);
}
function renderWeeklyReports() {
  const box = document.getElementById("weeklyReportsList");
  if (!box) return;
  const reports = (state.weeklyReports || []).slice().sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
  if (!reports.length) {
    box.innerHTML = '
还没有保存过周报记录。
'; return; } box.innerHTML = reports.map(r => `
${escapeHTML(r.title || "周报记录")}
${escapeHTML(formatRecordTime(r.createdAt))}${r.tags?.length ? " · " + escapeHTML(r.tags.join(" / ")) : ""}
已保存
任务描述
${escapeHTML(compactText(r.task || "未记录任务描述", 220))}
周报内容
${escapeHTML(compactText(r.report, 260))}
`).join(""); } function openWeeklyRecord(id) { const r = weeklyById(id); if (!r) return; const cid = createConvo(); const c = state.conversations[cid]; const savedMessages = Array.isArray(r.messages) && r.messages.length ? r.messages : [ { role: "user", content: r.task || r.title || "周报任务", ts: r.createdAt || Date.now() }, { role: "assistant", content: r.report || "", ts: r.createdAt || Date.now() }, ]; c.title = "周报记录 · " + (r.title || "未命名").slice(0, 32); c.tags = Array.from(new Set(["周报记录", ...(r.tags || [])])); c.messages = savedMessages.map(m => ({ role: m.role, content: m.content, ts: m.ts || Date.now(), agentId: m.agentId || null, })); c.updatedAt = Date.now(); saveConversations(); renderSidebar(); renderChat(); switchTab("chat"); } function copyWeeklyReport(id) { const r = weeklyById(id); if (r) copyText(r.report || ""); } function copyWeeklyTask(id) { const r = weeklyById(id); if (r) copyText(r.task || ""); } function deleteWeeklyReport(id) { const r = weeklyById(id); if (!r) return; if (!confirm("删除这条周报记录?")) return; state.weeklyReports = state.weeklyReports.filter(item => item.id !== id); saveWeeklyReports(); renderWeeklyReports(); } // ---------- 每日用量聚合 ---------- let selectedDay = null; function dayKey(ts) { const d = new Date(ts); return d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0"); } function todayKey() { return dayKey(Date.now()); } function daysAgo(n) { const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() - n); return dayKey(d.getTime()); } let _bucketCache = null; let _bucketCacheKey = null; function invalidateBucketCache() { _bucketCache = null; } function bucketUsage() { // 基于对话签名缓存,未变更直接返回 const key = Object.values(state.conversations || {}).map(c => c.id + ":" + (c.messages?.length || 0) + ":" + (c.updatedAt || 0) ).join("|"); if (_bucketCache && _bucketCacheKey === key) return _bucketCache; _bucketCacheKey = key; // 返回 {day: {messages, tokens, convos: Set, agents: Set, convoTitles: []}} const out = {}; for (const c of Object.values(state.conversations || {})) { // 为每条消息按 ts 分桶(无 ts 就落到 updatedAt) const fallback = c.updatedAt || c.createdAt || Date.now(); const perConvoDays = new Set(); for (const m of c.messages || []) { const ts = m.ts || fallback; const k = dayKey(ts); perConvoDays.add(k); if (!out[k]) out[k] = { messages: 0, userMsgs: 0, assistantMsgs: 0, tokens: 0, convos: new Set(), agents: new Set(), convoList: {} }; out[k].messages += 1; if (m.role === "user") out[k].userMsgs += 1; else if (m.role === "assistant") out[k].assistantMsgs += 1; out[k].convos.add(c.id); if (c.agentId) out[k].agents.add(c.agentId); out[k].convoList[c.id] = { title: c.title, count: (out[k].convoList[c.id]?.count || 0) + 1 }; } // 把 c.tokens 平均摊到该对话涉及的天里(简化) if (c.tokens && perConvoDays.size > 0) { const share = Math.round(c.tokens / perConvoDays.size); for (const k of perConvoDays) { if (out[k]) out[k].tokens += share; } } } _bucketCache = out; return out; } function refreshDashboard() { refreshDashboardLocal(); pingBackend(); renderHeatmap(); renderDashStats(); if (!selectedDay) selectedDay = todayKey(); renderDayDetail(selectedDay); } function renderDashStats() { const b = bucketUsage(); const today = todayKey(); const weekStart = daysAgo(6); const monthStart = daysAgo(29); let todayMsgs = 0, todayTokens = 0, todayConvos = new Set(); let weekMsgs = 0, weekTokens = 0; let monthMsgs = 0, monthTokens = 0; let allMsgs = 0, allTokens = 0; const agentCounts = {}; for (const [k, v] of Object.entries(b)) { allMsgs += v.messages; allTokens += v.tokens; if (k >= monthStart) { monthMsgs += v.messages; monthTokens += v.tokens; } if (k >= weekStart) { weekMsgs += v.messages; weekTokens += v.tokens; } if (k === today) { todayMsgs = v.messages; todayTokens = v.tokens; todayConvos = v.convos; } } // top agent (overall) for (const c of Object.values(state.conversations || {})) { if (c.agentId && c.messages?.length) { agentCounts[c.agentId] = (agentCounts[c.agentId] || 0) + c.messages.length; } } let topAgent = null, topCount = 0; for (const [id, cnt] of Object.entries(agentCounts)) { if (cnt > topCount) { topAgent = id; topCount = cnt; } } setText("stTodayTokens", todayTokens.toLocaleString()); setText("stTodaySub", todayMsgs + " 条消息"); setText("stTodayConvosText", (todayConvos.size || 0) + " 个对话"); setText("stWeek", weekMsgs); setText("stWeekSub", weekTokens.toLocaleString() + " tokens"); setText("stMonth", monthMsgs); setText("stMonthSub", monthTokens.toLocaleString() + " tokens"); setText("stAll", Object.keys(state.conversations || {}).length); setText("stAllSub", allMsgs + " 条消息"); const avatarEl = document.getElementById("topAgentAvatar"); if (topAgent && state.agents[topAgent]) { const a = state.agents[topAgent]; setText("stTopAgent", a.name); setText("stTopAgentSub", topCount + " 条消息 · " + a.desc); if (avatarEl) avatarEl.textContent = a.emoji || "🤖"; } else { setText("stTopAgent", "还未使用智能体"); setText("stTopAgentSub", "去 Agent 面板创建一个开始对话"); if (avatarEl) avatarEl.textContent = "—"; } } function setText(id, v) { const el = document.getElementById(id); if (el) el.textContent = v; } function renderHeatmap() { const el = document.getElementById("heatmap"); if (!el) return; el.innerHTML = ""; const b = bucketUsage(); // 根据容器宽度动态决定多少列,保持格子紧凑不拉伸成大方块 const width = el.parentElement.clientWidth - 64; // 减去内边距和星期 label 列 const cellMin = 18; const weeksCount = Math.max(14, Math.min(30, Math.floor(width / (cellMin + 6)))); el.style.setProperty("--hm-cols", weeksCount); document.getElementById("heatmapMonths")?.style.setProperty("--hm-cols", weeksCount); // 绝对阈值: 每天消息数落在哪个区间 // 0 = 无活动 | 1-5 = 轻 | 6-15 = 一般 | 16-40 = 活跃 | 41+ = 高强度 const levelOf = (n) => { if (n === 0) return 0; if (n <= 5) return 1; if (n <= 15) return 2; if (n <= 40) return 3; return 4; }; const today = new Date(); today.setHours(0, 0, 0, 0); // 起点: 今天所在周的周一,再往前推 weeksCount-1 周 const todayDow = (today.getDay() + 6) % 7; // 周一=0 const startMonday = new Date(today); startMonday.setDate(startMonday.getDate() - todayDow - (weeksCount - 1) * 7); const monthEl = document.getElementById("heatmapMonths"); if (monthEl) { const monthNames = ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"]; const monthCells = ['
']; for (let w = 0; w < weeksCount; w++) { const weekStart = new Date(startMonday); weekStart.setDate(weekStart.getDate() + w * 7); let label = ""; for (let d = 0; d < 7; d++) { const cursor = new Date(weekStart); cursor.setDate(cursor.getDate() + d); if (w === 0 || cursor.getDate() === 1) { label = monthNames[cursor.getMonth()]; break; } } monthCells.push(`
${label}
`); } monthEl.innerHTML = monthCells.join(""); } let activeDays = 0; let peakMsgs = 0; let peakKey = ""; // 按 row(weekday) × col(week) 的顺序铺,每行先放一个 label const labels = ["一", "", "三", "", "五", "", "日"]; for (let dow = 0; dow < 7; dow++) { const lbl = document.createElement("div"); lbl.className = "hm-row-label"; lbl.textContent = labels[dow]; el.appendChild(lbl); for (let w = 0; w < weeksCount; w++) { const cursor = new Date(startMonday); cursor.setDate(cursor.getDate() + w * 7 + dow); const k = dayKey(cursor.getTime()); const data = b[k]; const msgs = data?.messages || 0; const lvl = levelOf(msgs); const future = cursor > today; if (!future && msgs > 0) activeDays += 1; if (!future && msgs > peakMsgs) { peakMsgs = msgs; peakKey = k; } const cell = document.createElement("div"); cell.className = "hm-cell lvl-" + lvl + (future ? " future" : "") + (k === selectedDay ? " sel" : ""); cell.title = k + (msgs ? ` · ${msgs} 条消息` : " · 无活动"); cell.setAttribute("aria-label", cell.title); if (!future) { cell.onclick = () => { selectedDay = k; renderHeatmap(); renderDayDetail(k); }; } el.appendChild(cell); } } setText("hmActiveDays", activeDays + " 个活跃日"); setText("hmPeakDay", peakMsgs ? ("峰值 " + peakMsgs + " · " + peakKey.slice(5)) : "峰值 0"); } // 窗口尺寸变化时重新渲染热力图 window.addEventListener("resize", () => { if (document.getElementById("tab-dashboard")?.classList.contains("active")) { renderHeatmap(); } }); function renderDayDetail(k) { const el = document.getElementById("dayDetail"); const label = document.getElementById("daydetailLabel"); if (!el) return; const b = bucketUsage(); const data = b[k]; const d = new Date(k); const weekdays = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]; const niceDate = (d.getMonth() + 1) + " 月 " + d.getDate() + " 日 · " + weekdays[d.getDay()]; if (label) label.textContent = "日详情 · " + niceDate; if (!data) { el.innerHTML = '
这一天没有活动
'; return; } const agentsStr = [...data.agents].map(id => { const a = state.agents[id]; return a ? (a.emoji + " " + a.name) : null; }).filter(Boolean).join(" · ") || "(通用对话)"; let html = `

${niceDate}

${data.messages}
总消息
${data.userMsgs}
你发的
${data.assistantMsgs}
AI 回复
${data.convos.size}
对话数
${data.tokens.toLocaleString()}
Token
${data.agents.size}
智能体
涉及智能体: ${escapeHTML(agentsStr)}
`; for (const cid of data.convos) { const c = state.conversations[cid]; if (!c) continue; const count = data.convoList[cid]?.count || 0; html += `
${escapeHTML(c.title || "未命名")} ${count} 条 · ${new Date(c.updatedAt || 0).toLocaleTimeString("zh-CN", {hour: "2-digit", minute: "2-digit"})}
`; } html += "
"; el.innerHTML = html; el.querySelectorAll(".day-convo-item").forEach(row => { row.onclick = () => switchConvo(row.dataset.cid); }); } // ---------- 对话分支 ---------- function branchFromMessage(msgIdx) { const c = activeConvo(); if (!c || msgIdx < 0 || msgIdx >= c.messages.length) return; const cid = createConvo(); const nc = state.conversations[cid]; nc.messages = JSON.parse(JSON.stringify(c.messages.slice(0, msgIdx + 1))); nc.title = "🌿 " + (c.title || "分支"); nc.agentId = c.agentId; nc.parentId = c.id; nc.branchedAt = msgIdx; saveConversations(); renderSidebar(); renderChat(); switchTab("chat"); toast("已创建分支"); } // ---------- 通用 Agent Picker ---------- let agentPickerCallback = null; function openAgentPicker(title, activeAgentId, callback) { agentPickerCallback = callback; document.getElementById("agentPickerTitle").textContent = title || "选择智能体"; const list = document.getElementById("agentPickerList"); list.innerHTML = ""; // "无 / 默认" 选项 const none = document.createElement("div"); none.className = "agent-picker-item" + (!activeAgentId ? " active" : ""); none.innerHTML = `
默认对话
不绑定任何智能体,使用通用 prompt
${!activeAgentId ? '' : ""} `; none.onclick = () => { callback(null); closeAgentPicker(); }; list.appendChild(none); for (const a of sortedAgents()) { const row = document.createElement("div"); row.className = "agent-picker-item" + (a.id === activeAgentId ? " active" : ""); row.innerHTML = `
${a.id === activeAgentId ? '' : ""} `; row.querySelector(".ap-emoji").textContent = a.emoji || "🤖"; row.querySelector(".ap-name").textContent = a.name; const skillStr = (a.skills || []).map(skillById).filter(Boolean).map(s => s.emoji).join(" "); row.querySelector(".ap-desc").textContent = (a.desc || "") + (skillStr ? " · " + skillStr : ""); row.onclick = () => { callback(a.id); closeAgentPicker(); }; list.appendChild(row); } document.getElementById("agentPicker").classList.add("open"); } function closeAgentPicker() { document.getElementById("agentPicker").classList.remove("open"); agentPickerCallback = null; } function openAgentPickerForChat() { const c = activeConvo(); openAgentPicker("为当前对话选择智能体", c?.agentId, (agentId) => { c.agentId = agentId || null; c.updatedAt = Date.now(); saveConversations(); renderChat(); toast(agentId ? "已切换到 " + state.agents[agentId].name : "已恢复默认对话"); }); } function branchToAgent(msgIdx) { const c = activeConvo(); if (!c || msgIdx < 0 || msgIdx >= c.messages.length) return; openAgentPicker("派分支给智能体", null, (agentId) => { const cid = createConvo(); const nc = state.conversations[cid]; nc.messages = JSON.parse(JSON.stringify(c.messages.slice(0, msgIdx + 1))); const agent = agentId ? state.agents[agentId] : null; nc.title = (agent ? agent.emoji + " " : "🌿 ") + (c.title || "分支"); nc.agentId = agentId || null; nc.parentId = c.id; nc.branchedAt = msgIdx; saveConversations(); renderSidebar(); renderChat(); switchTab("chat"); toast(agent ? "已派给 " + agent.name : "已分支"); }); } // 把分支里的某条回答拉回原对话 function pullToParent(msgIdx) { const c = activeConvo(); if (!c || !c.parentId) return; const parent = state.conversations[c.parentId]; if (!parent) return; const m = c.messages[msgIdx]; if (!m || m.role !== "assistant" || !m.content) return; const agent = m.agentId && state.agents[m.agentId] ? state.agents[m.agentId] : (c.agentId && state.agents[c.agentId] ? state.agents[c.agentId] : null); const header = agent ? `↩ 来自分支 · ${agent.emoji || ""} ${agent.name}` : "↩ 来自分支"; parent.messages.push({ role: "assistant", content: header + "\n\n" + m.content, ts: Date.now(), agentId: agent?.id || null, fromBranch: c.id, }); parent.updatedAt = Date.now(); saveConversations(); switchConvo(c.parentId); toast("已拉回原对话"); } // 本次对话 @ 一个智能体(一次性) function useAgentOnce() { openAgentPicker("本次消息使用", state.pendingAgent, (agentId) => { state.pendingAgent = agentId || null; updatePendingAgentBar(); setTimeout(() => document.getElementById("chatInput")?.focus(), 50); }); } function clearPendingAgent() { state.pendingAgent = null; updatePendingAgentBar(); } function updatePendingAgentBar() { const bar = document.getElementById("pendingAgentBar"); const atBtn = document.querySelector(".input-at"); if (!bar) return; if (state.pendingAgent && state.agents[state.pendingAgent]) { const a = state.agents[state.pendingAgent]; bar.innerHTML = ` 本次用 ${escapeHTML(a.emoji || "🤖")} ${escapeHTML(a.name)} `; bar.style.display = "flex"; if (atBtn) atBtn.classList.add("active"); } else { bar.style.display = "none"; if (atBtn) atBtn.classList.remove("active"); } } function updateChatAgentBadge() { const btn = document.getElementById("chatAgentBtn"); if (!btn) return; const c = activeConvo(); if (c && c.agentId && state.agents[c.agentId]) { const a = state.agents[c.agentId]; btn.innerHTML = `
${escapeHTML(a.emoji || "🤖")}
${escapeHTML(a.name)}`; btn.title = "点击切换或移除智能体 · " + (a.desc || ""); } else { btn.innerHTML = `
+
邀请智能体`; btn.title = "为当前对话选择一个智能体"; } } // ---------- Skill 库 ---------- const SKILLS_LIB = [ { id: "steps", emoji: "🪜", name: "步骤分解", prompt: "面对复杂任务,先拆成 3-5 步列出,每步一句话,再逐步执行。" }, { id: "rigor", emoji: "🧪", name: "严谨事实", prompt: "不确定时明确说 \"我不确定\",绝不编造引用或数据。宁缺毋滥。" }, { id: "ask", emoji: "💬", name: "追问澄清", prompt: "信息不足时先问 1-2 个最关键的澄清问题,再动手。" }, { id: "summary", emoji: "📋", name: "先摘要", prompt: "对长内容先给 3-5 条要点摘要,再根据需要展开。" }, { id: "plain", emoji: "🎓", name: "通俗解释", prompt: "用大白话 + 类比解释技术概念,避免行话,除非用户明确是专业人士。" }, { id: "structured", emoji: "📊", name: "结构化输出", prompt: "优先用编号列表或表格组织答案,让信息层次清晰。" }, { id: "runnable", emoji: "🔧", name: "代码可运行", prompt: "给出的代码必须能直接运行,提供示例输入与预期输出。" }, { id: "goal", emoji: "🎯", name: "目标导向", prompt: "回答前先确认用户真正想达成的目标,避免答非所问。" }, { id: "bilingual", emoji: "🌐", name: "中英对照", prompt: "专业术语和关键词同时给中英双语。" }, { id: "cite", emoji: "🔗", name: "给出出处", prompt: "引用事实或数据时,尽量给出可追溯的来源(即便是 \"来自官方文档\")。" }, { id: "brief", emoji: "✂️", name: "言简意赅", prompt: "回答直接切重点,避免冗长铺垫和客套话。" }, { id: "critic", emoji: "🧐", name: "批判思考", prompt: "遇到方案主动指出 2-3 个潜在风险或缺点,不要只报喜。" }, ]; function skillById(id) { return state.customSkills?.[id] || SKILLS_LIB.find(s => s.id === id); } function allSkills() { return [...SKILLS_LIB, ...Object.values(state.customSkills || {}).sort((a, b) => a.createdAt - b.createdAt)]; } function loadCustomSkills() { try { const raw = localStorage.getItem(LS_CUSTOM_SKILLS); if (raw) state.customSkills = JSON.parse(raw) || {}; } catch (e) { state.customSkills = {}; } } function saveCustomSkillsToLS() { localStorage.setItem(LS_CUSTOM_SKILLS, JSON.stringify(state.customSkills)); } // ---------- Flows (Skill 编排) ---------- const BUILTIN_FLOWS = [ { id: "flow_research", emoji: "🧠", name: "研究型", desc: "深度调研,严谨出处,结构化呈现", skillIds: ["steps", "rigor", "cite", "structured"] }, { id: "flow_dev", emoji: "💻", name: "开发型", desc: "代码可运行,先定根因再修", skillIds: ["steps", "runnable", "rigor", "critic"] }, { id: "flow_writing", emoji: "✍️", name: "创作型", desc: "贴合受众,通俗清晰,有取舍", skillIds: ["goal", "plain", "structured", "brief"] }, { id: "flow_teaching", emoji: "🎓", name: "教学型", desc: "大白话 + 类比 + 拆步骤", skillIds: ["plain", "steps", "brief"] }, { id: "flow_product", emoji: "🎯", name: "产品型", desc: "目标导向,批判思考,风险前置", skillIds: ["goal", "critic", "steps", "ask"] }, { id: "flow_simple", emoji: "⚡", name: "快答型", desc: "切重点,不啰嗦", skillIds: ["brief", "goal"] }, ]; function loadFlows() { try { const raw = localStorage.getItem(LS_FLOWS); if (raw) state.flows = JSON.parse(raw) || {}; } catch (e) { state.flows = {}; } // 植入内建 flows(不覆盖用户自定义) for (const f of BUILTIN_FLOWS) { if (!state.flows[f.id]) state.flows[f.id] = { ...f, builtin: true }; } saveFlowsToLS(); } function saveFlowsToLS() { localStorage.setItem(LS_FLOWS, JSON.stringify(state.flows)); } function sortedFlows() { return Object.values(state.flows).sort((a, b) => { if (a.builtin !== b.builtin) return a.builtin ? -1 : 1; return (a.createdAt || 0) - (b.createdAt || 0); }); } // 解析一个 skill id -> prompt 片段 (支持 hermes:/builtin:/custom:/plain) function resolveSkillText(id, opts = {}) { if (!id) return ""; const s = String(id); if (s.startsWith("hermes:")) { const path = s.slice(7); const hs = (_hermesSkillIndex || []).find(x => x.path === path); if (!hs) return `- [Hermes skill 未加载: ${path}]`; const bodyLimit = opts.bodyLimit || 1200; const body = (hs.body || "").substring(0, bodyLimit); return `### ${hs.emoji || "🧩"} ${hs.name}\n${hs.description || ""}\n${body}`; } let shortId = s; if (s.startsWith("builtin:")) shortId = s.slice(8); else if (s.startsWith("custom:")) shortId = s.slice(7); const sk = skillById(shortId); return sk ? `- ${sk.emoji} ${sk.name}: ${sk.prompt}` : ""; } async function ensureHermesSkillsLoaded(agentOrIds) { // 如果 agent 含 hermes: 前缀的 id 或 stages 里有,才异步加载 let ids = []; if (Array.isArray(agentOrIds)) ids = agentOrIds; else if (agentOrIds) { ids = [...(agentOrIds.skills || []), ...(agentOrIds.stages?.pre || []), ...(agentOrIds.stages?.exec || []), ...(agentOrIds.stages?.post || [])]; } if (ids.some(id => String(id).startsWith("hermes:")) && !_hermesSkillIndex) { await fetchHermesSkillIndex(); } } function composeSystemPrompt(agent) { if (!agent) return ""; let sys = agent.systemPrompt || ""; // 优先走 stages 编排(3 阶段) const st = agent.stages; if (st && (st.pre?.length || st.exec?.length || st.post?.length)) { const renderStage = (title, arr) => { if (!arr?.length) return ""; const parts = arr.map(id => resolveSkillText(id)).filter(Boolean); return parts.length ? `\n\n## ${title}\n${parts.join("\n\n")}` : ""; }; sys += renderStage("阶段一 · 前置准备(理解目标 / 拆解)", st.pre); sys += renderStage("阶段二 · 主要执行(工具调用 / 生成)", st.exec); sys += renderStage("阶段三 · 收尾审查(自查 / 输出格式)", st.post); return sys; } // 回退: 扁平 skills 数组 const skills = agent.skills || []; if (skills.length) { const parts = skills.map(id => resolveSkillText(id)).filter(Boolean); if (parts.length) sys += "\n\n## 启用的技能\n" + parts.join("\n"); } return sys; } // ---------- 智能体 ---------- const DEFAULT_AGENTS = [ { emoji: "H", name: "通用助手", desc: "什么都能聊,默认的爱马仕,适合日常问答和闲聊。", model: DEFAULT_MODEL_ID, systemPrompt: "你是爱马仕,一个友好、专业、简洁的 AI 助手。用中文回答,除非用户明确要求其他语言。", skills: ["brief", "goal"], }, { emoji: "💻", name: "代码专家", desc: "精通各类编程语言和框架,擅长排 bug、写代码、解读架构。", model: DEFAULT_MODEL_ID, systemPrompt: "你是一位资深的软件工程师,擅长多种编程语言和框架。回答要准确、实用,提供可运行的代码示例,并解释关键点。遇到 bug 时先定位根因再提出修复方案。", skills: ["runnable", "rigor", "steps"], }, { emoji: "✍️", name: "写作助手", desc: "中英文写作、润色、翻译、摘要,文风可调。", model: DEFAULT_MODEL_ID, systemPrompt: "你是一位专业的中英文写作助手,擅长润色、翻译、摘要、改写。注重逻辑清晰、语言流畅、风格贴合语境。先理解用户的目标受众再动笔。", skills: ["bilingual", "plain", "structured"], }, { emoji: "🔍", name: "研究员", desc: "深度调研、信息整合、结构化输出报告。", model: DEFAULT_MODEL_ID, systemPrompt: "你是一名严谨的研究员。接到主题后先拆解问题、列出要调研的子问题、给出结构化的研究报告。引用时标注来源,对不确定的内容明确说明。", skills: ["steps", "rigor", "cite", "structured"], }, ]; function loadAgents() { try { const raw = localStorage.getItem(LS_AGENTS); if (raw) { state.agents = JSON.parse(raw) || {}; } } catch (e) {} if (Object.keys(state.agents).length === 0) { for (const a of DEFAULT_AGENTS) { const id = "a_" + Math.random().toString(36).slice(2, 10); state.agents[id] = { id, ...a, createdAt: Date.now() }; } saveAgents(); } let migrated = false; for (const agent of Object.values(state.agents || {})) { const builtin = DEFAULT_AGENTS.find(item => item.name === agent.name && item.systemPrompt === agent.systemPrompt); if (builtin && agent.model === LEGACY_DEFAULT_MODEL_ID) { agent.model = DEFAULT_MODEL_ID; migrated = true; } ensureModelChoice(agent.model || DEFAULT_MODEL_ID); if (!("modelProfileId" in agent)) agent.modelProfileId = ""; } if (migrated) saveAgents(); } function saveAgents() { localStorage.setItem(LS_AGENTS, JSON.stringify(state.agents)); } function sortedAgents() { return Object.values(state.agents).sort((a, b) => a.createdAt - b.createdAt); } function renderAgents() { const grid = document.getElementById("agentGrid"); if (!grid) return; grid.innerHTML = ""; const list = sortedAgents(); if (!list.length) { grid.innerHTML = '
还没有智能体,点右上角"新建智能体"开始。
'; return; } for (const a of list) { const card = document.createElement("div"); card.className = "agent-card"; card.innerHTML = `
`; card.querySelector(".agent-avatar").textContent = a.emoji || "🤖"; card.querySelector(".agent-name").textContent = a.name; 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); if (skills.length) { for (const s of skills) { const tag = document.createElement("span"); tag.className = "mini-skill"; tag.textContent = s.emoji + " " + s.name; sEl.appendChild(tag); } } card.querySelector(".chat").onclick = () => chatWithAgent(a.id); card.querySelector(".edit").onclick = () => openAgentModal(a.id); card.querySelector(".danger").onclick = () => deleteAgent(a.id); grid.appendChild(card); } } // 用 dataset 保留顺序: data-order 0..N 只在 .on 状态下使用 let pickerSelectedOrder = []; // 当前 picker 里的已选 id 顺序 function renderSkillsPicker(selectedIds) { const wrap = document.getElementById("skillsPicker"); if (!wrap) return; pickerSelectedOrder = [...(selectedIds || [])]; _rebuildSkillPicker(wrap); } function _rebuildSkillPicker(wrap) { wrap.innerHTML = ""; const selectedSet = new Set(pickerSelectedOrder); const all = allSkills(); // 1. 已启用分区(按序号) if (pickerSelectedOrder.length) { const lbl = document.createElement("div"); lbl.className = "skills-section-label"; lbl.textContent = "已启用 · 按顺序应用(前 = 高优先级)"; wrap.appendChild(lbl); pickerSelectedOrder.forEach((id, idx) => { const s = all.find(x => x.id === id); if (!s) return; wrap.appendChild(_makeChip(s, true, idx)); }); } // 2. 可选分区 const unselected = all.filter(s => !selectedSet.has(s.id)); if (unselected.length) { const lbl = document.createElement("div"); lbl.className = "skills-section-label"; lbl.textContent = "点击添加"; wrap.appendChild(lbl); unselected.forEach(s => wrap.appendChild(_makeChip(s, false, -1))); } // 3. 新建 const add = document.createElement("div"); add.className = "skill-chip skill-add"; add.innerHTML = '+新建技能'; add.onclick = () => openSkillModal(); wrap.appendChild(add); } function _makeChip(s, on, orderIdx) { const chip = document.createElement("div"); chip.className = "skill-chip" + (on ? " on" : "") + (s.custom ? " custom" : ""); chip.dataset.skill = s.id; chip.title = s.prompt; if (on) { const order = document.createElement("span"); order.className = "skill-order"; order.textContent = String(orderIdx + 1); chip.appendChild(order); } const ico = document.createElement("span"); ico.className = "skill-ico"; ico.textContent = s.emoji; chip.appendChild(ico); const name = document.createElement("span"); name.textContent = s.name; chip.appendChild(name); if (on) { const mv = document.createElement("span"); mv.className = "skill-move"; const up = document.createElement("button"); up.type = "button"; up.textContent = "▲"; up.title = "上移"; up.onclick = (e) => { e.stopPropagation(); _moveSel(s.id, -1); }; const dn = document.createElement("button"); dn.type = "button"; dn.textContent = "▼"; dn.title = "下移"; dn.onclick = (e) => { e.stopPropagation(); _moveSel(s.id, 1); }; mv.appendChild(up); mv.appendChild(dn); chip.appendChild(mv); } if (s.custom) { const edit = document.createElement("span"); edit.className = "skill-edit"; edit.textContent = "✎"; edit.title = "编辑技能"; edit.onclick = (e) => { e.stopPropagation(); openSkillModal(s.id); }; chip.appendChild(edit); } chip.onclick = () => { const idx = pickerSelectedOrder.indexOf(s.id); if (idx >= 0) pickerSelectedOrder.splice(idx, 1); else pickerSelectedOrder.push(s.id); _rebuildSkillPicker(document.getElementById("skillsPicker")); }; return chip; } function _moveSel(id, dir) { const idx = pickerSelectedOrder.indexOf(id); if (idx < 0) return; const tgt = idx + dir; if (tgt < 0 || tgt >= pickerSelectedOrder.length) return; [pickerSelectedOrder[idx], pickerSelectedOrder[tgt]] = [pickerSelectedOrder[tgt], pickerSelectedOrder[idx]]; _rebuildSkillPicker(document.getElementById("skillsPicker")); } function readSkillsPicker() { // 直接从顺序 state 取(保留顺序) return [...pickerSelectedOrder]; } // ---------- 自定义 Skill CRUD ---------- function openSkillModal(id) { state.editingSkillId = id || null; const modal = document.getElementById("skillModal"); const delBtn = document.getElementById("skillDeleteBtn"); document.getElementById("skillModalTitle").textContent = id ? "编辑技能" : "新建技能"; if (id && state.customSkills[id]) { const s = state.customSkills[id]; document.getElementById("skillEmoji").value = s.emoji || "✨"; document.getElementById("skillEmojiPreview").textContent = s.emoji || "✨"; document.getElementById("skillName").value = s.name || ""; document.getElementById("skillPrompt").value = s.prompt || ""; delBtn.style.display = "inline-flex"; } else { document.getElementById("skillEmoji").value = "✨"; document.getElementById("skillEmojiPreview").textContent = "✨"; document.getElementById("skillName").value = ""; document.getElementById("skillPrompt").value = ""; delBtn.style.display = "none"; } modal.classList.add("open"); setTimeout(() => document.getElementById("skillName").focus(), 50); } function closeSkillModal() { document.getElementById("skillModal").classList.remove("open"); state.editingSkillId = null; } function saveCustomSkill() { const emoji = document.getElementById("skillEmoji").value.trim() || "✨"; const name = document.getElementById("skillName").value.trim(); const prompt = document.getElementById("skillPrompt").value.trim(); if (!name) { toast("请填写名称"); return; } if (!prompt) { toast("请填写指令"); return; } if (state.editingSkillId && state.customSkills[state.editingSkillId]) { Object.assign(state.customSkills[state.editingSkillId], { emoji, name, prompt }); } else { const id = "cs_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); state.customSkills[id] = { id, emoji, name, prompt, custom: true, createdAt: Date.now() }; } saveCustomSkillsToLS(); // 刷新当前 agent 编辑面板的 skill picker(如果正在编辑 agent) const currentSelected = readSkillsPicker(); renderSkillsPicker(currentSelected); closeSkillModal(); toast("已保存"); } function deleteCurrentSkill() { const id = state.editingSkillId; if (!id || !state.customSkills[id]) return; if (!confirm("删除这个技能?已使用它的智能体不受影响(但会忽略这个技能)。")) return; delete state.customSkills[id]; saveCustomSkillsToLS(); const currentSelected = readSkillsPicker().filter(x => x !== id); renderSkillsPicker(currentSelected); closeSkillModal(); renderAgents(); toast("已删除"); } function openAgentModal(id) { state.editingAgentId = id || null; const modal = document.getElementById("agentModal"); document.getElementById("agentModalTitle").textContent = id ? "编辑智能体" : "新建智能体"; if (id && state.agents[id]) { const a = state.agents[id]; document.getElementById("agentEmoji").value = a.emoji || "🤖"; document.getElementById("agentEmojiPreview").textContent = a.emoji || "🤖"; document.getElementById("agentName").value = a.name || ""; 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 { document.getElementById("agentEmoji").value = "🤖"; document.getElementById("agentEmojiPreview").textContent = "🤖"; 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([]); } modal.classList.add("open"); setTimeout(() => document.getElementById("agentName").focus(), 50); } function closeAgentModal() { document.getElementById("agentModal").classList.remove("open"); state.editingAgentId = null; } function saveAgent() { const emoji = document.getElementById("agentEmoji").value.trim() || "🤖"; 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; } if (!systemPrompt) { toast("请填写角色设定"); return; } ensureModelChoice(model); const skills = readSkillsPicker(); if (state.editingAgentId && state.agents[state.editingAgentId]) { Object.assign(state.agents[state.editingAgentId], { emoji, name, desc, model, modelProfileId, systemPrompt, skills, updatedAt: Date.now(), }); } else { const id = makeId("a"); state.agents[id] = { id, emoji, name, desc, model, modelProfileId, systemPrompt, skills, createdAt: Date.now(), updatedAt: Date.now() }; } persistAgents({ silent: true }); closeAgentModal(); toast(state.sharedConfigAvailable ? "已保存到服务器" : "已保存到本机"); } function deleteAgent(id) { if (!confirm("删除这个智能体?已有的对话不受影响。")) return; delete state.agents[id]; persistAgents({ silent: true }); toast(state.sharedConfigAvailable ? "已从服务器删除" : "已从本机删除"); } function chatWithAgent(id) { const a = state.agents[id]; if (!a) return; const cid = createConvo(); const c = state.conversations[cid]; c.agentId = id; c.title = a.emoji + " " + a.name; saveConversations(); renderSidebar(); renderChat(); switchTab("chat"); // 预热 Hermes skill 索引(异步,不阻塞) ensureHermesSkillsLoaded(a); setTimeout(() => document.getElementById("chatInput")?.focus(), 50); } // ---------- 集群对话 ---------- function openClusterMode() { const modal = document.getElementById("clusterModal"); modal.classList.add("open"); renderClusterPick(); document.getElementById("clusterResults").innerHTML = ""; } function closeClusterMode() { document.getElementById("clusterModal").classList.remove("open"); } function renderClusterPick() { const wrap = document.getElementById("clusterAgentList"); wrap.innerHTML = ""; const list = sortedAgents(); if (!list.length) { wrap.innerHTML = '
没有智能体,先去新建一个。
'; return; } for (const a of list) { const chip = document.createElement("div"); chip.className = "cluster-pick-chip" + (state.clusterPicked.has(a.id) ? " on" : ""); chip.innerHTML = `${a.emoji}${escapeHTML(a.name)}`; chip.onclick = () => { if (state.clusterPicked.has(a.id)) state.clusterPicked.delete(a.id); else state.clusterPicked.add(a.id); renderClusterPick(); }; wrap.appendChild(chip); } } async function runCluster() { const prompt = document.getElementById("clusterPrompt").value.trim(); if (!prompt) { toast("请输入问题"); return; } if (state.clusterPicked.size === 0) { toast("至少选一个智能体"); return; } const results = document.getElementById("clusterResults"); results.innerHTML = ""; const picked = [...state.clusterPicked].map(id => state.agents[id]).filter(Boolean); const cols = {}; for (const a of picked) { const col = document.createElement("div"); col.className = "cluster-col"; col.innerHTML = `
运行中
`; col.querySelector(".cluster-col-avatar").textContent = a.emoji; col.querySelector(".cluster-col-name").textContent = a.name; results.appendChild(col); cols[a.id] = col; } await Promise.all(picked.map(a => runClusterOne(a, prompt, cols[a.id]))); } async function runClusterOne(agent, prompt, col) { const status = col.querySelector(".cluster-col-status"); const body = col.querySelector(".cluster-col-body"); try { await ensureHermesSkillsLoaded(agent); const messages = [ { role: "system", content: composeSystemPrompt(agent) }, { role: "user", content: prompt }, ]; const route = chatRouteForAgent(agent); const requestBody = { model: modelForAgent(agent), messages, stream: false }; if (route.modelProfileId) requestBody.modelProfileId = route.modelProfileId; const res = await apiFetch(route.url, chatFetchOptions(route, requestBody)); if (!res.ok) throw new Error("HTTP " + res.status); const data = await res.json(); const text = data?.choices?.[0]?.message?.content || "(无回复)"; body.textContent = text; status.className = "cluster-col-status done"; status.textContent = "完成"; } catch (e) { body.textContent = "失败: " + (e.message || e); status.className = "cluster-col-status err"; status.textContent = "错误"; } } function escapeHTML(s) { return String(s).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } // 极简 Markdown 渲染器(专为聊天/会话内容) // 支持: 代码块 / 行内代码 / 标题 / 列表 / 表格 / 引用 / 粗体 / 链接 / 段落 function renderMarkdown(raw) { if (!raw) return ""; let text = String(raw); // 1. 提取代码块占位(避免被后续规则破坏) const codeBlocks = []; text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => { codeBlocks.push({ lang: lang || "", code }); return "\x00CB" + (codeBlocks.length - 1) + "\x00"; }); // 2. 转义 HTML text = text.replace(/&/g, "&").replace(//g, ">"); // 3. 标题 text = text.replace(/^###### (.+)$/gm, "
$1
"); text = text.replace(/^##### (.+)$/gm, "
$1
"); text = text.replace(/^#### (.+)$/gm, "

$1

"); text = text.replace(/^### (.+)$/gm, "

$1

"); text = text.replace(/^## (.+)$/gm, "

$1

"); text = text.replace(/^# (.+)$/gm, "

$1

"); // 4. 表格(标准 markdown 格式) text = text.replace(/((?:^\|.+\|\n)+\|[-:\s|]+\|\n(?:\|.+\|\n?)+)/gm, (m) => { const lines = m.trim().split("\n"); if (lines.length < 2) return m; const header = lines[0].split("|").slice(1, -1).map(s => s.trim()); const rows = lines.slice(2).map(r => r.split("|").slice(1, -1).map(s => s.trim())); let html = ''; for (const h of header) html += ""; html += ""; for (const row of rows) { html += ""; for (const c of row) html += ""; html += ""; } return html + "
" + h + "
" + c + "
"; }); // 5. 无序列表 text = text.replace(/(^[-*] .+(?:\n[-*] .+)*)/gm, (m) => { const items = m.split("\n").map(l => l.replace(/^[-*] /, "")); return ""; }); // 6. 有序列表 text = text.replace(/(^\d+\. .+(?:\n\d+\. .+)*)/gm, (m) => { const items = m.split("\n").map(l => l.replace(/^\d+\. /, "")); return "
    " + items.map(i => "
  1. " + i + "
  2. ").join("") + "
"; }); // 7. 引用 text = text.replace(/^> (.+)$/gm, "
$1
"); // 8. 行内: 粗体 / 行内代码 / 链接 text = text.replace(/\*\*([^*\n]+)\*\*/g, "$1"); text = text.replace(/`([^`\n]+)`/g, "$1"); text = text.replace(/\[([^\]]+)\]\((https?:[^)]+)\)/g, '$1'); // 9. 段落(双换行分段,单换行保留
) const blocks = text.split(/\n\n+/); text = blocks.map(b => { b = b.trim(); if (!b) return ""; if (/^<(h[1-6]|ul|ol|table|blockquote|pre)/.test(b)) return b; return "

" + b.replace(/\n/g, "
") + "

"; }).join(""); // 10. 恢复代码块 text = text.replace(/\x00CB(\d+)\x00/g, (_, i) => { const b = codeBlocks[i]; const escaped = b.code.replace(/&/g, "&").replace(//g, ">"); const langLabel = b.lang ? '' + escapeHTML(b.lang) + "" : ""; return '
' + langLabel + "" + escaped + "
"; }); return text; } // ========== Skill Studio (全屏编排) ========== // 真实 Hermes 技能库缓存 let _hermesSkillIndex = null; // [{path, category, name, description, tags, prerequisites, content}] let _hermesSkillLoading = false; // Studio 画布状态 let studioState = { lib: "hermes", // 当前左栏 tab: hermes | builtin | custom search: "", flowId: null, // 正在编辑的 flow(可能是 null = 新建) name: "", emoji: "🎯", desc: "", pre: [], // 选中 skill id/path exec: [], post: [], activePreviewId: null, }; function studioNewFlow() { studioState = { lib: studioState.lib, search: "", flowId: null, name: "", emoji: "🎯", desc: "", pre: [], exec: [], post: [], activePreviewId: null }; document.getElementById("studioFlowName").value = ""; document.getElementById("studioFlowDesc").value = ""; document.getElementById("studioFlowEmoji").value = "🎯"; document.getElementById("studioFlowEmojiPreview").textContent = "🎯"; renderStudioCanvas(); renderStudioPreview(null); } // YAML frontmatter 极简解析器(只解 scalar/flow style list) function parseYamlFrontmatter(text) { const m = /^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/.exec(text); if (!m) return { meta: {}, body: text }; const raw = m[1]; const body = m[2]; const meta = {}; const lines = raw.split("\n"); let currentKey = null; let buffer = {}; let path = []; for (const line of lines) { if (!line.trim() || line.trim().startsWith("#")) continue; const mKV = /^(\s*)([a-zA-Z_][\w-]*)\s*:\s*(.*)$/.exec(line); if (!mKV) continue; const indent = mKV[1].length; const key = mKV[2]; let val = mKV[3].trim(); if (val === "" || val === null) { if (indent === 0) { meta[key] = {}; path = [key]; } continue; } if (val.startsWith("[") && val.endsWith("]")) { val = val.slice(1, -1).split(",").map(s => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean); } else { val = val.replace(/^["']|["']$/g, ""); } if (indent === 0) { meta[key] = val; path = [key]; } else { // 嵌套一层 let cur = meta; for (const p of path) { if (typeof cur[p] !== "object") cur[p] = {}; cur = cur[p]; } cur[key] = val; } } return { meta, body }; } async function fetchHermesSkillIndex() { if (_hermesSkillIndex) return _hermesSkillIndex; if (_hermesSkillLoading) return null; _hermesSkillLoading = true; const out = []; try { const catsRes = await fetch("/hermes-skills/"); if (!catsRes.ok) throw new Error("HTTP " + catsRes.status); const cats = await catsRes.json(); for (const cat of cats) { if (cat.type !== "directory") continue; // 枚举每个分类下的技能 try { const listRes = await fetch("/hermes-skills/" + cat.name + "/"); if (!listRes.ok) continue; const items = await listRes.json(); for (const it of items) { if (it.type === "directory") { // 可能是 skill 目录,拉 SKILL.md const path = cat.name + "/" + it.name; try { const mdRes = await fetch("/hermes-skills/" + path + "/SKILL.md"); if (mdRes.ok) { const text = await mdRes.text(); const { meta, body } = parseYamlFrontmatter(text); out.push({ id: "hermes:" + path, path, category: cat.name, name: meta.name || it.name, description: meta.description || "", version: meta.version || "", tags: (meta.metadata?.hermes?.tags) || meta.tags || [], prerequisites: meta.prerequisites || {}, platforms: meta.platforms || [], body: body.substring(0, 4000), source: "hermes", }); } } catch (e) {} } else if (it.type === "file" && it.name === "SKILL.md") { // 分类根下直接就是 SKILL.md const path = cat.name; try { const mdRes = await fetch("/hermes-skills/" + path + "/SKILL.md"); if (mdRes.ok) { const text = await mdRes.text(); const { meta, body } = parseYamlFrontmatter(text); out.push({ id: "hermes:" + path, path, category: cat.name, name: meta.name || cat.name, description: meta.description || "", version: meta.version || "", tags: (meta.metadata?.hermes?.tags) || meta.tags || [], prerequisites: meta.prerequisites || {}, platforms: meta.platforms || [], body: body.substring(0, 4000), source: "hermes", }); } } catch (e) {} } } } catch (e) {} } _hermesSkillIndex = out; } catch (e) { _hermesSkillIndex = []; console.error("[studio] fetch hermes skills failed", e); } finally { _hermesSkillLoading = false; } return _hermesSkillIndex; } function renderStudioLibrary() { const list = document.getElementById("studioLibrary"); if (!list) return; const q = (studioState.search || "").toLowerCase(); if (studioState.lib === "hermes") { if (!_hermesSkillIndex) { list.innerHTML = '
加载 Hermes 技能库…
'; fetchHermesSkillIndex().then(() => renderStudioLibrary()); return; } // 按 category 分组 const byCat = {}; for (const s of _hermesSkillIndex) { const matches = !q || s.name.toLowerCase().includes(q) || (s.description || "").toLowerCase().includes(q) || (s.tags || []).some(t => String(t).toLowerCase().includes(q)) || s.path.toLowerCase().includes(q); if (!matches) continue; if (!byCat[s.category]) byCat[s.category] = []; byCat[s.category].push(s); } list.innerHTML = ""; if (Object.keys(byCat).length === 0) { list.innerHTML = '
没有匹配的 skill
'; return; } for (const cat of Object.keys(byCat).sort()) { const lbl = document.createElement("div"); lbl.className = "studio-cat"; lbl.textContent = cat + " · " + byCat[cat].length; list.appendChild(lbl); for (const s of byCat[cat]) list.appendChild(_makeStudioItem(s)); } } else if (studioState.lib === "builtin") { list.innerHTML = ""; for (const s of SKILLS_LIB) { if (q && !(s.name.toLowerCase().includes(q) || s.prompt.toLowerCase().includes(q))) continue; list.appendChild(_makeStudioItem({ id: "builtin:" + s.id, name: s.name, description: s.prompt.substring(0, 80), body: s.prompt, emoji: s.emoji, source: "builtin", })); } } else { list.innerHTML = ""; const customs = Object.values(state.customSkills || {}); if (!customs.length) { list.innerHTML = '
你还没有自定义 skill
编辑智能体 → 技能区 → 新建技能
'; return; } for (const s of customs) { if (q && !(s.name.toLowerCase().includes(q) || s.prompt.toLowerCase().includes(q))) continue; list.appendChild(_makeStudioItem({ id: "custom:" + s.id, name: s.name, description: s.prompt.substring(0, 80), body: s.prompt, emoji: s.emoji, source: "custom", })); } } } function _makeStudioItem(s) { const row = document.createElement("div"); row.className = "studio-skill-item" + (studioState.activePreviewId === s.id ? " active" : ""); const emoji = s.emoji || (s.source === "hermes" ? "🧩" : "✨"); row.innerHTML = `
`; row.querySelector(".studio-skill-icon").textContent = emoji; row.querySelector(".studio-skill-name").textContent = s.name; row.querySelector(".studio-skill-desc").textContent = s.description || "(无描述)"; row.querySelector(".studio-skill-src").textContent = s.source === "hermes" ? "Hermes" : s.source === "builtin" ? "内建" : "自定义"; row.onclick = () => { studioState.activePreviewId = s.id; renderStudioLibrary(); renderStudioPreview(s); }; return row; } function renderStudioPreview(s) { const el = document.getElementById("studioPreview"); if (!el) return; if (!s) { el.innerHTML = '
点左侧技能查看详情
'; return; } const tagsHtml = (s.tags || []).map(t => `${escapeHTML(String(t))}`).join(""); const preCmds = s.prerequisites?.commands || []; const preHtml = preCmds.length ? `
需要: ${preCmds.map(c => `${escapeHTML(String(c))}`).join("")}
` : ""; el.innerHTML = `

${tagsHtml ? `
${tagsHtml}
` : ""} ${preHtml}
`; el.querySelector("h3").textContent = s.name; el.querySelector(".studio-preview-desc").textContent = s.description || ""; el.querySelector(".studio-preview-body").textContent = s.body || "(无内容)"; el.querySelector(".studio-preview-add").onclick = () => studioAddToStage(s.id, "exec"); } function studioAddToStage(id, stage) { const arr = studioState[stage]; if (!arr.includes(id)) arr.push(id); renderStudioCanvas(); toast("已加入" + ({ pre: "前置", exec: "执行", post: "收尾" }[stage])); } function studioRemoveFromStage(id, stage) { studioState[stage] = studioState[stage].filter(x => x !== id); renderStudioCanvas(); } function studioMoveInStage(id, stage, dir) { const arr = studioState[stage]; const idx = arr.indexOf(id); if (idx < 0) return; const tgt = idx + dir; if (tgt < 0 || tgt >= arr.length) return; [arr[idx], arr[tgt]] = [arr[tgt], arr[idx]]; renderStudioCanvas(); } function studioSkillById(id) { // id 形如 hermes:cat/skill-name, builtin:xxx, custom:xxx if (id.startsWith("hermes:")) { const path = id.slice(7); return (_hermesSkillIndex || []).find(s => s.path === path); } if (id.startsWith("builtin:")) { const rid = id.slice(8); const s = SKILLS_LIB.find(x => x.id === rid); return s ? { id, name: s.name, emoji: s.emoji, description: s.prompt.substring(0, 80), body: s.prompt, source: "builtin" } : null; } if (id.startsWith("custom:")) { const rid = id.slice(7); const s = state.customSkills[rid]; return s ? { id, name: s.name, emoji: s.emoji, description: s.prompt.substring(0, 80), body: s.prompt, source: "custom" } : null; } return null; } function renderStudioCanvas() { for (const stage of ["pre", "exec", "post"]) { const slot = document.querySelector(`.studio-stage-slots[data-slots="${stage}"]`); if (!slot) continue; slot.innerHTML = ""; const arr = studioState[stage]; if (!arr.length) { slot.innerHTML = '
点右侧技能的"+ 加入"或左栏的技能
'; continue; } arr.forEach((id, idx) => { const s = studioSkillById(id); const chip = document.createElement("span"); chip.className = "studio-slot-chip"; chip.innerHTML = ` ${idx + 1} ${escapeHTML((s?.emoji || "🧩") + " " + (s?.name || id))} `; const btns = chip.querySelectorAll(".slot-del"); btns[0].onclick = () => studioMoveInStage(id, stage, -1); btns[1].onclick = () => studioMoveInStage(id, stage, 1); btns[2].onclick = () => studioRemoveFromStage(id, stage); slot.appendChild(chip); }); } } function bindStudio() { document.querySelectorAll(".studio-tab[data-lib]").forEach(btn => { btn.addEventListener("click", () => { studioState.lib = btn.dataset.lib; document.querySelectorAll(".studio-tab[data-lib]").forEach(t => t.classList.toggle("active", t.dataset.lib === studioState.lib)); renderStudioLibrary(); }); }); const search = document.getElementById("studioSearch"); if (search) search.addEventListener("input", e => { studioState.search = e.target.value.trim(); renderStudioLibrary(); }); // 加右侧快捷按钮(preview-add 动态绑) const name = document.getElementById("studioFlowName"); if (name) name.addEventListener("input", e => studioState.name = e.target.value); const desc = document.getElementById("studioFlowDesc"); if (desc) desc.addEventListener("input", e => studioState.desc = e.target.value); } function studioSaveFlow() { const name = (document.getElementById("studioFlowName").value || studioState.name || "").trim(); const desc = (document.getElementById("studioFlowDesc").value || studioState.desc || "").trim(); const emoji = document.getElementById("studioFlowEmoji").value || "🎯"; if (!name) { toast("请填写编排名称"); return; } const total = studioState.pre.length + studioState.exec.length + studioState.post.length; if (!total) { toast("至少加入一个技能"); return; } const stages = { pre: [...studioState.pre], exec: [...studioState.exec], post: [...studioState.post], }; // 合并所有 stage 的 id 为一个扁平数组(保持 pre → exec → post 顺序) const allIds = [...studioState.pre, ...studioState.exec, ...studioState.post]; if (studioState.flowId && state.flows[studioState.flowId]) { const f = state.flows[studioState.flowId]; if (f.builtin) { toast("内建编排不能改,已另存为新编排"); studioState.flowId = null; } } if (!studioState.flowId) { studioState.flowId = "flow_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); } state.flows[studioState.flowId] = { id: studioState.flowId, emoji, name, desc, skillIds: allIds, // 完整 id(含 hermes:/builtin:/custom: 前缀) stages, createdAt: state.flows[studioState.flowId]?.createdAt || Date.now(), }; studioState.name = name; studioState.desc = desc; studioState.emoji = emoji; saveFlowsToLS(); toast("编排已保存 ✓"); } // 载入已保存的编排到画布 function studioLoadFlow() { const flows = sortedFlows(); if (!flows.length) { toast("还没有编排"); return; } openAgentPicker("载入哪个编排?", null, () => {}); // 重用不了,自己做一个 picker // 复用 agent picker 容器展示 flow 列表 const wrap = document.getElementById("agentPickerList"); document.getElementById("agentPickerTitle").textContent = "载入编排"; wrap.innerHTML = ""; for (const f of flows) { const row = document.createElement("div"); row.className = "agent-picker-item"; row.innerHTML = `
${escapeHTML(f.emoji || "🧠")}
${escapeHTML(f.name)}
${escapeHTML(f.desc || "") + " · " + (f.skillIds?.length || 0) + " 个技能"}
`; row.onclick = () => { // 载入到画布 studioState.flowId = f.id; studioState.name = f.name || ""; studioState.desc = f.desc || ""; studioState.emoji = f.emoji || "🎯"; if (f.stages) { studioState.pre = [...(f.stages.pre || [])]; studioState.exec = [...(f.stages.exec || [])]; studioState.post = [...(f.stages.post || [])]; } else { // 老 flow 没 stages,全进 exec studioState.pre = []; studioState.exec = (f.skillIds || []).map(id => id.includes(":") ? id : "builtin:" + id); studioState.post = []; } document.getElementById("studioFlowName").value = studioState.name; document.getElementById("studioFlowDesc").value = studioState.desc; document.getElementById("studioFlowEmoji").value = studioState.emoji; document.getElementById("studioFlowEmojiPreview").textContent = studioState.emoji; renderStudioCanvas(); // 若有 hermes skill,预加载索引 ensureHermesSkillsLoaded({ skills: studioState.exec }); closeAgentPicker(); toast("已载入 " + f.name); }; wrap.appendChild(row); } } // 把当前画布应用到某个智能体 function studioApplyToAgent() { const total = studioState.pre.length + studioState.exec.length + studioState.post.length; if (!total) { toast("画布为空,先加几个技能"); return; } openAgentPicker("把当前编排应用到哪个智能体?", null, (agentId) => { if (!agentId) { toast("要选一个智能体"); return; } const a = state.agents[agentId]; if (!a) return; a.stages = { pre: [...studioState.pre], exec: [...studioState.exec], post: [...studioState.post], }; // 同时维护扁平 skills 数组以便兼容 a.skills = [...studioState.pre, ...studioState.exec, ...studioState.post]; saveAgents(); renderAgents(); toast("已应用到 " + a.name + " (下次发消息生效)"); }); } // ---------- Flow 管理 ---------- function openFlowManager() { renderFlowList(); document.getElementById("flowModal").classList.add("open"); } function closeFlowManager() { document.getElementById("flowModal").classList.remove("open"); } function renderFlowList() { const wrap = document.getElementById("flowList"); wrap.innerHTML = ""; for (const f of sortedFlows()) { const card = document.createElement("div"); card.className = "flow-card" + (f.builtin ? " builtin" : ""); const skillsHtml = (f.skillIds || []).map(id => { const s = skillById(id); return s ? `${s.emoji} ${escapeHTML(s.name)}` : ""; }).join(""); card.innerHTML = `
${escapeHTML(f.emoji || "🧠")}
${escapeHTML(f.name)}
${escapeHTML(f.desc || "")}
${skillsHtml}
`; card.querySelector(".edit").onclick = (e) => { e.stopPropagation(); openFlowEdit(f.id); }; card.querySelector(".danger").onclick = (e) => { e.stopPropagation(); deleteFlowById(f.id); }; wrap.appendChild(card); } } function openFlowEdit(id) { state.editingFlowId = id || null; document.getElementById("flowEditTitle").textContent = id ? "编辑编排" : "新建编排"; const delBtn = document.getElementById("flowDeleteBtn"); if (id && state.flows[id]) { const f = state.flows[id]; document.getElementById("flowEmoji").value = f.emoji || "🧠"; document.getElementById("flowEmojiPreview").textContent = f.emoji || "🧠"; document.getElementById("flowName").value = f.name || ""; document.getElementById("flowDesc").value = f.desc || ""; state.flowEditSelected = [...(f.skillIds || [])]; delBtn.style.display = f.builtin ? "none" : "inline-flex"; } else { document.getElementById("flowEmoji").value = "🧠"; document.getElementById("flowEmojiPreview").textContent = "🧠"; document.getElementById("flowName").value = ""; document.getElementById("flowDesc").value = ""; state.flowEditSelected = []; delBtn.style.display = "none"; } renderFlowSkillPicker(); document.getElementById("flowEditModal").classList.add("open"); setTimeout(() => document.getElementById("flowName").focus(), 50); } function closeFlowEdit() { document.getElementById("flowEditModal").classList.remove("open"); state.editingFlowId = null; } function renderFlowSkillPicker() { const wrap = document.getElementById("flowSkillPicker"); if (!wrap) return; wrap.innerHTML = ""; const selSet = new Set(state.flowEditSelected); const all = allSkills(); // 已选 state.flowEditSelected.forEach((id, idx) => { const s = all.find(x => x.id === id); if (!s) return; wrap.appendChild(_makeFlowChip(s, true, idx)); }); // 未选 all.filter(s => !selSet.has(s.id)).forEach(s => wrap.appendChild(_makeFlowChip(s, false, -1))); } function _makeFlowChip(s, on, orderIdx) { const chip = document.createElement("div"); chip.className = "skill-chip" + (on ? " on" : ""); chip.dataset.skill = s.id; chip.title = s.prompt; if (on) { const order = document.createElement("span"); order.className = "skill-order"; order.textContent = String(orderIdx + 1); chip.appendChild(order); } const ico = document.createElement("span"); ico.className = "skill-ico"; ico.textContent = s.emoji; chip.appendChild(ico); const name = document.createElement("span"); name.textContent = s.name; chip.appendChild(name); if (on) { const mv = document.createElement("span"); mv.className = "skill-move"; const up = document.createElement("button"); up.type = "button"; up.textContent = "▲"; up.onclick = (e) => { e.stopPropagation(); _moveFlowSel(s.id, -1); }; const dn = document.createElement("button"); dn.type = "button"; dn.textContent = "▼"; dn.onclick = (e) => { e.stopPropagation(); _moveFlowSel(s.id, 1); }; mv.appendChild(up); mv.appendChild(dn); chip.appendChild(mv); } chip.onclick = () => { const idx = state.flowEditSelected.indexOf(s.id); if (idx >= 0) state.flowEditSelected.splice(idx, 1); else state.flowEditSelected.push(s.id); renderFlowSkillPicker(); }; return chip; } function _moveFlowSel(id, dir) { const idx = state.flowEditSelected.indexOf(id); if (idx < 0) return; const tgt = idx + dir; if (tgt < 0 || tgt >= state.flowEditSelected.length) return; [state.flowEditSelected[idx], state.flowEditSelected[tgt]] = [state.flowEditSelected[tgt], state.flowEditSelected[idx]]; renderFlowSkillPicker(); } function saveFlow() { const emoji = document.getElementById("flowEmoji").value.trim() || "🧠"; const name = document.getElementById("flowName").value.trim(); const desc = document.getElementById("flowDesc").value.trim(); const skillIds = [...state.flowEditSelected]; if (!name) { toast("请填写名称"); return; } if (!skillIds.length) { toast("至少选一个技能"); return; } if (state.editingFlowId && state.flows[state.editingFlowId]) { const f = state.flows[state.editingFlowId]; if (f.builtin) { toast("内建编排不能改,请新建一个"); return; } Object.assign(f, { emoji, name, desc, skillIds }); } else { const id = "flow_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); state.flows[id] = { id, emoji, name, desc, skillIds, createdAt: Date.now() }; } saveFlowsToLS(); renderFlowList(); closeFlowEdit(); toast("已保存"); } function deleteFlow() { const id = state.editingFlowId; if (!id || !state.flows[id] || state.flows[id].builtin) return; if (!confirm("删除这个编排?")) return; delete state.flows[id]; saveFlowsToLS(); renderFlowList(); closeFlowEdit(); toast("已删除"); } function deleteFlowById(id) { const f = state.flows[id]; if (!f) return; if (f.builtin) { toast("内建编排不能删除"); return; } if (!confirm("删除「" + f.name + "」?")) return; delete state.flows[id]; saveFlowsToLS(); renderFlowList(); } // 应用编排到当前 agent 编辑 function openFlowApply() { const wrap = document.getElementById("flowApplyList"); wrap.innerHTML = ""; for (const f of sortedFlows()) { const card = document.createElement("div"); card.className = "flow-card" + (f.builtin ? " builtin" : ""); const skillsHtml = (f.skillIds || []).map(id => { const s = skillById(id); return s ? `${s.emoji} ${escapeHTML(s.name)}` : ""; }).join(""); card.innerHTML = `
${escapeHTML(f.emoji || "🧠")}
${escapeHTML(f.name)}
${escapeHTML(f.desc || "")}
${skillsHtml}
`; card.onclick = () => applyFlowToPicker(f.id); wrap.appendChild(card); } document.getElementById("flowApplyModal").classList.add("open"); } function closeFlowApply() { document.getElementById("flowApplyModal").classList.remove("open"); } function applyFlowToPicker(flowId) { const f = state.flows[flowId]; if (!f) return; pickerSelectedOrder = [...(f.skillIds || [])]; _rebuildSkillPicker(document.getElementById("skillsPicker")); closeFlowApply(); toast("已应用「" + f.name + "」"); } // ---------- Emoji / 品牌图标 选择器 ---------- const BRAND_AVATARS = [ { val: "H", label: "爱马仕 HERMÈS", cls: "brand-hermes" }, { val: "米", label: "米乐 Milejoy", cls: "brand-milejoy" }, { val: "悦", label: "米乐悦", cls: "brand-milejoy" }, { val: "🦞", label: "OpenClaw", cls: "" }, ]; const EMOJI_LIST = [ { e: "🤖", k: "robot 机器人 ai" }, { e: "💻", k: "computer 电脑 代码 coding" }, { e: "✍️", k: "write 写作 writing pen" }, { e: "🔍", k: "search 搜索 research 研究" }, { e: "🧠", k: "brain 大脑 thinking 思考" }, { e: "🎨", k: "art 艺术 design 设计" }, { e: "📚", k: "books 书 学习 study" }, { e: "🎵", k: "music 音乐" }, { e: "🎬", k: "movie 电影 video" }, { e: "📷", k: "camera 相机 photo" }, { e: "🧭", k: "compass 规划 plan" }, { e: "🕸", k: "web 网页 spider" }, { e: "📦", k: "package 打包" }, { e: "⚡", k: "lightning 闪电 fast" }, { e: "🔥", k: "fire 火 热门" }, { e: "💎", k: "diamond 钻石 高端" }, { e: "🚀", k: "rocket 火箭 启动" }, { e: "🌟", k: "star 星星" }, { e: "⭐", k: "star 星" }, { e: "🌙", k: "moon 月亮 夜" }, { e: "☀️", k: "sun 太阳" }, { e: "🌈", k: "rainbow 彩虹" }, { e: "💡", k: "idea 灵感 bulb" }, { e: "🎯", k: "target 目标" }, { e: "🏆", k: "trophy 冠军" }, { e: "👑", k: "crown 王冠 vip" }, { e: "🎁", k: "gift 礼物" }, { e: "📌", k: "pin 置顶" }, { e: "🔔", k: "bell 提醒" }, { e: "🔒", k: "lock 锁 安全" }, { e: "🔑", k: "key 钥匙" }, { e: "⚙️", k: "gear 设置 settings" }, { e: "🛠", k: "tools 工具" }, { e: "🪝", k: "hook 钩" }, { e: "📝", k: "note 笔记" }, { e: "📊", k: "chart 图表" }, { e: "📈", k: "trending up 增长" }, { e: "💰", k: "money 钱" }, { e: "🏢", k: "office 公司" }, { e: "🏛", k: "classical 经典" }, { e: "🧾", k: "receipt 账单" }, { e: "📅", k: "calendar 日历" }, { e: "⏰", k: "alarm 闹钟" }, { e: "⏱", k: "timer 计时" }, { e: "🔁", k: "repeat 循环" }, { e: "🎪", k: "circus 活动" }, { e: "🎭", k: "mask 面具" }, { e: "🎲", k: "dice 骰子" }, { e: "🎮", k: "game 游戏" }, { e: "🥇", k: "gold 第一" }, { e: "🍀", k: "clover 幸运" }, { e: "🌍", k: "earth 地球" }, { e: "🌊", k: "wave 海" }, { e: "💧", k: "drop 水滴" }, { e: "❄️", k: "snow 雪" }, { e: "🎀", k: "ribbon 丝带" }, { e: "😎", k: "cool 酷" }, { e: "🤓", k: "nerd 学霸" }, { e: "🧐", k: "investigate 调查" }, { e: "😊", k: "smile 微笑" }, { e: "🙂", k: "slight smile" }, { e: "😇", k: "angel 天使" }, { e: "🤔", k: "thinking 思考" }, { e: "😤", k: "determined 坚决" }, { e: "🥰", k: "loving" }, { e: "😴", k: "sleep 睡觉" }, { e: "🐺", k: "wolf 狼" }, { e: "🦊", k: "fox 狐狸" }, { e: "🐯", k: "tiger 老虎" }, { e: "🦁", k: "lion 狮子" }, { e: "🐼", k: "panda 熊猫" }, { e: "🐉", k: "dragon 龙" }, { e: "🦄", k: "unicorn 独角兽" }, { e: "🐝", k: "bee 蜜蜂" }, { e: "🦉", k: "owl 猫头鹰" }, ]; function openEmojiPicker(target) { state.emojiTarget = target || "agent"; renderBrandGrid(); renderEmojiGrid(""); document.getElementById("emojiPicker").classList.add("open"); const search = document.getElementById("emojiSearch"); search.value = ""; search.oninput = (e) => renderEmojiGrid(e.target.value); setTimeout(() => search.focus(), 50); } function closeEmojiPicker() { document.getElementById("emojiPicker").classList.remove("open"); state.emojiTarget = null; } function renderBrandGrid() { const grid = document.getElementById("brandGrid"); if (!grid) return; grid.innerHTML = ""; for (const b of BRAND_AVATARS) { const cell = document.createElement("div"); cell.className = "emoji-cell " + (b.cls || ""); cell.title = b.label; cell.textContent = b.val; cell.onclick = () => pickEmoji(b.val); grid.appendChild(cell); } } function renderEmojiGrid(q) { const grid = document.getElementById("emojiGrid"); if (!grid) return; q = (q || "").trim().toLowerCase(); grid.innerHTML = ""; const filtered = q ? EMOJI_LIST.filter(x => x.k.includes(q)) : EMOJI_LIST; for (const x of filtered) { const cell = document.createElement("div"); cell.className = "emoji-cell"; cell.textContent = x.e; cell.title = x.k.split(" ").slice(0, 2).join(" / "); cell.onclick = () => pickEmoji(x.e); grid.appendChild(cell); } if (!filtered.length) { grid.innerHTML = '
没有匹配
'; } } function pickEmoji(val) { if (state.emojiTarget === "agent") { document.getElementById("agentEmoji").value = val; document.getElementById("agentEmojiPreview").textContent = val; } else if (state.emojiTarget === "skill") { document.getElementById("skillEmoji").value = val; document.getElementById("skillEmojiPreview").textContent = val; } else if (state.emojiTarget === "flow") { document.getElementById("flowEmoji").value = val; document.getElementById("flowEmojiPreview").textContent = val; } else if (state.emojiTarget === "studioFlow") { document.getElementById("studioFlowEmoji").value = val; document.getElementById("studioFlowEmojiPreview").textContent = val; studioState.emoji = val; } closeEmojiPicker(); } // ========== Cron 定时任务(真实对接 Hermes /api/jobs) ========== // Hermes 的 /api/jobs 路径在后端是真的,不在 /api/v1/ 下 function cronApiBase() { // 如果 apiBase 是 /api/v1,则 jobs 在 /api/jobs (同源) return "/api/jobs"; } async function cronFetch(path, init = {}) { const headers = { "Content-Type": "application/json", ...(init.headers || {}) }; if (state.apiKey) headers["Authorization"] = "Bearer " + state.apiKey; const res = await fetch("/api/jobs" + path, { ...init, headers }); if (!res.ok) throw new Error("HTTP " + res.status + ": " + await res.text()); return res.status === 204 ? null : res.json(); } let _cronEditingId = null; async function refreshCron() { const list = document.getElementById("cronList"); if (!list) return; list.innerHTML = '
加载中…
'; try { const data = await cronFetch("?include_disabled=true"); const jobs = data?.jobs || data || []; if (!Array.isArray(jobs) || !jobs.length) { list.innerHTML = '
还没有定时任务。点右上角"新建任务"开始。
'; return; } list.innerHTML = ""; for (const j of jobs) list.appendChild(makeCronRow(j)); } catch (e) { list.innerHTML = '
加载失败: ' + escapeHTML(e.message || String(e)) + '
'; } } function makeCronRow(j) { const row = document.createElement("div"); row.className = "cron-item" + (j.enabled === false ? " disabled" : ""); row.innerHTML = `
调度:
`; row.querySelector(".cron-name").textContent = j.name || j.id || "未命名"; // schedule 可能是字符串 / 带 display 字段的对象 let scheduleText = "—"; if (typeof j.schedule === "string") scheduleText = j.schedule; else if (j.schedule?.display) scheduleText = j.schedule.display; else if (j.schedule?.cron) scheduleText = j.schedule.cron; else if (j.cron) scheduleText = j.cron; row.querySelector(".sched code").textContent = scheduleText; // 下次执行 const nextEl = row.querySelector(".next"); if (j.next_run_at) { const t = new Date(j.next_run_at); nextEl.textContent = "下次: " + (isNaN(t) ? j.next_run_at.slice(0, 16) : t.toLocaleString("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" })); } else nextEl.style.display = "none"; // 投递目标 const delEl = row.querySelector(".deliver"); if (j.deliver) delEl.textContent = "→ " + j.deliver; else delEl.style.display = "none"; // 上次状态 const lastEl = row.querySelector(".last"); if (j.last_run_success === true) { lastEl.textContent = "✓ 上次成功"; lastEl.style.color = "var(--ok)"; } else if (j.last_run_success === false) { lastEl.textContent = "✗ 上次失败"; lastEl.style.color = "var(--err)"; } else lastEl.style.display = "none"; row.querySelector(".cron-prompt-preview").textContent = j.prompt || j.input || "(无 prompt)"; const pill = row.querySelector(".cron-pill"); pill.className = "cron-pill " + (j.enabled === false ? "off" : "on"); pill.textContent = j.enabled === false ? "已禁用" : "启用中"; row.querySelector(".run").onclick = () => triggerCron(j.id); row.querySelector(".edit").onclick = () => openCronModal(j); return row; } async function triggerCron(id) { try { await cronFetch("/" + encodeURIComponent(id) + "/run", { method: "POST" }); toast("已触发 " + id); } catch (e) { toast("触发失败: " + (e.message || e)); } } function openCronModal(job) { _cronEditingId = job?.id || null; document.getElementById("cronModalTitle").textContent = job ? "编辑定时任务" : "新建定时任务"; document.getElementById("cronName").value = job?.name || ""; let schedVal = ""; if (typeof job?.schedule === "string") schedVal = job.schedule; else if (job?.schedule?.display) schedVal = job.schedule.display; else if (job?.schedule?.cron) schedVal = job.schedule.cron; else if (job?.cron) schedVal = job.cron; document.getElementById("cronSchedule").value = schedVal; document.getElementById("cronPrompt").value = job?.prompt || job?.input || ""; document.getElementById("cronDeliver").value = job?.deliver || ""; document.getElementById("cronEnabled").checked = job?.enabled !== false; document.getElementById("cronDeleteBtn").style.display = job ? "inline-flex" : "none"; document.getElementById("cronModal").classList.add("open"); setTimeout(() => document.getElementById("cronName").focus(), 50); } function closeCronModal() { document.getElementById("cronModal").classList.remove("open"); _cronEditingId = null; } async function saveCronJob() { const name = document.getElementById("cronName").value.trim(); const schedule = document.getElementById("cronSchedule").value.trim(); const prompt = document.getElementById("cronPrompt").value.trim(); const deliver = document.getElementById("cronDeliver").value.trim(); const enabled = document.getElementById("cronEnabled").checked; if (!name || !schedule || !prompt) { toast("名称/调度/Prompt 都要填"); return; } const payload = { name, schedule, prompt, enabled }; if (deliver) payload.deliver = deliver; try { if (_cronEditingId) { await cronFetch("/" + encodeURIComponent(_cronEditingId), { method: "PATCH", body: JSON.stringify(payload) }); } else { await cronFetch("", { method: "POST", body: JSON.stringify(payload) }); } closeCronModal(); refreshCron(); toast("已保存"); } catch (e) { toast("保存失败: " + (e.message || e)); } } async function deleteCronJob() { if (!_cronEditingId) return; if (!confirm("删除这个定时任务?不可恢复。")) return; try { await cronFetch("/" + encodeURIComponent(_cronEditingId), { method: "DELETE" }); closeCronModal(); refreshCron(); toast("已删除"); } catch (e) { toast("删除失败: " + (e.message || e)); } } // ========== 异步任务 Runs (POST /v1/runs + SSE /v1/runs/{id}/events) ========== let _currentRunId = null; let _runEventSource = null; let _runStartTs = 0; function setRunsMeta(text, cls) { const el = document.getElementById("runsMeta"); if (!el) return; el.textContent = text; el.className = "runs-meta" + (cls ? " " + cls : ""); } function addRunEvent(tag, body, tagClass) { const wrap = document.getElementById("runsEvents"); if (!wrap) return; const first = wrap.querySelector(".history-empty"); if (first) first.remove(); const el = document.createElement("div"); el.className = "run-evt" + (tagClass === "delta" ? " delta" : ""); const t = new Date().toLocaleTimeString("zh-CN", { hour12: false }); const bodyStr = typeof body === "string" ? body : JSON.stringify(body); el.innerHTML = ''; el.querySelector(".t").textContent = t; const tagEl = el.querySelector(".tag"); tagEl.textContent = tag; tagEl.classList.add(tagClass || "info"); el.querySelector(".body").textContent = bodyStr.length > 800 ? bodyStr.slice(0, 800) + "…" : bodyStr; wrap.appendChild(el); wrap.scrollTop = wrap.scrollHeight; } function cancelRun() { if (_runEventSource) { _runEventSource.close(); _runEventSource = null; } _currentRunId = null; setRunsMeta("已断开", ""); document.getElementById("runCancelBtn").style.display = "none"; document.getElementById("runStartBtn").disabled = false; document.getElementById("runsActive").innerHTML = ""; } async function startRun() { const input = document.getElementById("runPrompt").value.trim(); if (!input) { toast("请输入任务指令"); return; } // 重置视图 document.getElementById("runsEvents").innerHTML = ""; document.getElementById("runStartBtn").disabled = true; document.getElementById("runCancelBtn").style.display = "inline-flex"; setRunsMeta("启动中…", "running"); _runStartTs = Date.now(); addRunEvent("SUBMIT", input, "start"); try { const res = await fetch((state.apiBase || "/api/v1").replace(/\/$/, "") + "/runs", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": "Bearer " + state.apiKey, }, body: JSON.stringify({ input }), }); if (!res.ok) throw new Error("HTTP " + res.status + ": " + await res.text()); const data = await res.json(); _currentRunId = data.run_id; addRunEvent("RUN_ID", _currentRunId, "info"); document.getElementById("runsActive").innerHTML = "运行中: " + escapeHTML(_currentRunId) + ""; setRunsMeta("运行中 · " + _currentRunId.slice(0, 18), "running"); // SSE 订阅事件流 const baseApi = (state.apiBase || "/api/v1").replace(/\/$/, ""); const esUrl = baseApi + "/runs/" + encodeURIComponent(_currentRunId) + "/events"; _runEventSource = new EventSource(esUrl); _runEventSource.onmessage = (e) => { try { const evt = JSON.parse(e.data); handleRunEvent(evt); } catch (err) { addRunEvent("PARSE_ERR", e.data, "err"); } }; _runEventSource.onerror = () => { if (_runEventSource) { _runEventSource.close(); _runEventSource = null; } if (_currentRunId) { setRunsMeta("连接断开", "err"); addRunEvent("SSE_CLOSED", "事件流中断(可能已完成)", "info"); } document.getElementById("runStartBtn").disabled = false; document.getElementById("runCancelBtn").style.display = "none"; }; } catch (e) { addRunEvent("ERROR", e.message || String(e), "err"); setRunsMeta("启动失败", "err"); document.getElementById("runStartBtn").disabled = false; document.getElementById("runCancelBtn").style.display = "none"; } } function handleRunEvent(evt) { const type = evt.event || evt.type || "event"; const shortType = String(type).replace(/^run\./, "").replace(/^message\./, "msg."); let cls = "info"; let body = ""; if (/delta/i.test(type)) { cls = "delta"; body = evt.delta?.content || evt.content || evt.text || ""; } else if (/tool/i.test(type)) { cls = "tool"; body = evt.tool_name ? (evt.tool_name + " " + (evt.arguments ? JSON.stringify(evt.arguments).slice(0, 200) : "")) : JSON.stringify(evt); } else if (/completed|done/i.test(type)) { cls = "ok"; body = "耗时 " + ((Date.now() - _runStartTs) / 1000).toFixed(1) + " 秒"; setRunsMeta("完成 · " + ((Date.now() - _runStartTs) / 1000).toFixed(1) + "s", "done"); document.getElementById("runStartBtn").disabled = false; document.getElementById("runCancelBtn").style.display = "none"; _currentRunId = null; } else if (/failed|error/i.test(type)) { cls = "err"; body = evt.error || evt.message || JSON.stringify(evt); setRunsMeta("失败", "err"); document.getElementById("runStartBtn").disabled = false; document.getElementById("runCancelBtn").style.display = "none"; _currentRunId = null; } else if (/start/i.test(type)) { cls = "start"; body = ""; } else { body = JSON.stringify(evt).slice(0, 300); } addRunEvent(shortType, body, cls); } // ========== Memory (真实 Hermes SOUL.md + sessions 快照) ========== async function refreshMemory() { const soulEl = document.getElementById("memorySoul"); const sessEl = document.getElementById("memorySessions"); const cntEl = document.getElementById("memorySessCount"); if (!soulEl || !sessEl) return; try { const [soulRes, sessRes] = await Promise.all([ fetch("/memory/SOUL.md", { cache: "no-store" }), fetch("/memory/sessions.json", { cache: "no-store" }), ]); soulEl.textContent = soulRes.ok ? await soulRes.text() : "(无 SOUL.md)"; if (sessRes.ok) { const data = await sessRes.json(); const list = data.sessions || []; cntEl.textContent = list.length; sessEl.innerHTML = ""; if (!list.length) { sessEl.innerHTML = '
还没有会话
'; } else { for (const s of list) { const row = document.createElement("div"); row.className = "session-row clickable"; row.title = "点击导入这个会话到对话面板继续聊"; const time = new Date(s.mtime * 1000).toLocaleString("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }); row.innerHTML = `
→ 继续
`; row.querySelector(".session-row-title").textContent = s.title || "(空)"; const metas = row.querySelectorAll(".session-row-meta span"); metas[0].textContent = time; metas[1].textContent = s.msgs + " 条消息"; metas[2].textContent = (s.size / 1024).toFixed(1) + " KB"; row.onclick = () => importHermesSession(s); sessEl.appendChild(row); } } } } catch (e) { soulEl.textContent = "加载失败: " + (e.message || e); } } // 从 Hermes 后端把一个 session 导入成本地新对话,之后可继续聊 async function importHermesSession(meta) { try { const res = await fetch("/memory/sessions/" + meta.id + ".json", { cache: "no-store" }); if (!res.ok) { toast("会话文件未同步(稍等 1 分钟 systemd timer)"); return; } const data = await res.json(); const rawMsgs = data.messages || data.history || []; // 归一化成我们前端的 msg 结构 const msgs = []; for (const m of rawMsgs) { const role = m.role; if (!role || role === "system") continue; let content = m.content; if (Array.isArray(content)) { content = content.map(c => (typeof c === "string" ? c : c.text || "")).join(""); } if (typeof content !== "string") content = JSON.stringify(content); if (!content.trim()) continue; msgs.push({ role, content, ts: Date.now() }); } if (!msgs.length) { toast("这个会话没有可导入的消息"); return; } // 新建本地会话 const cid = createConvo(); const c = state.conversations[cid]; c.messages = msgs; c.title = "☁️ " + (meta.title || "云端导入").slice(0, 50); c.tags = ["从云端"]; c.updatedAt = Date.now(); c.importedFrom = meta.id; saveConversations(); renderSidebar(); renderChat(); switchTab("chat"); toast("已导入 " + msgs.length + " 条消息 · 可继续聊"); } catch (e) { toast("导入失败: " + (e.message || e)); } } // ========== Tools (真实 hermes tools list 输出) ========== const TOOL_ICONS = { web: "🔍", browser: "🌐", terminal: "💻", file: "📁", code_execution: "⚡", vision: "👁", image_gen: "🎨", moa: "🧠", tts: "🔊", skills: "📚", todo: "📋", memory: "💾", session_search: "🔎", clarify: "❓", delegation: "👥", cronjob: "⏰", rl: "🧪", homeassistant: "🏠", }; async function refreshTools() { const grid = document.getElementById("toolsGrid"); if (!grid) return; try { const res = await fetch("/memory/tools.txt", { cache: "no-store" }); const text = res.ok ? await res.text() : "(无数据)"; const lines = text.split("\n"); grid.innerHTML = ""; let currentSection = null; for (const raw of lines) { const line = raw.trim(); if (!line) continue; // Section header(以冒号结尾) const sectionMatch = /^([A-Za-z][A-Za-z\s]+)\s*\(([^)]+)\):$/.exec(line); if (sectionMatch) { const lbl = document.createElement("div"); lbl.className = "tools-section-label"; lbl.textContent = sectionMatch[1].trim() + " · " + sectionMatch[2]; grid.appendChild(lbl); continue; } // ✓/✗ enabled/disabled name desc const m = /^([✓✗])\s+(enabled|disabled)\s+(\S+)\s+(.*)$/.exec(line); if (m) { const enabled = m[2] === "enabled"; const name = m[3]; const desc = m[4].trim(); const chip = document.createElement("div"); chip.className = "tool-chip" + (enabled ? "" : " off"); chip.innerHTML = `
`; chip.querySelector(".tool-ico").textContent = TOOL_ICONS[name] || "🔧"; chip.querySelector(".tool-name").textContent = name; chip.querySelector(".tool-desc").textContent = desc; chip.querySelector(".tool-status").textContent = enabled ? "ON" : "OFF"; grid.appendChild(chip); } } if (!grid.children.length) grid.innerHTML = '
没有工具数据
'; } catch (e) { grid.innerHTML = '
加载失败: ' + escapeHTML(e.message || String(e)) + '
'; } } // ---------- 数据导入导出 ---------- function exportData() { const data = { version: "hermes-ui-v0.2", exportedAt: new Date().toISOString(), settings: { apiBase: state.apiBase, apiKey: state.apiKey, stream: state.stream, }, conversations: state.conversations, weeklyReports: state.weeklyReports, agents: state.agents, modelProfiles: normalizeModelProfiles(state.modelProfiles), customSkills: state.customSkills, flows: state.flows, theme: localStorage.getItem(LS_THEME) || "dark", }; const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); const name = "hermes-ui-backup-" + new Date().toISOString().slice(0, 10) + ".json"; a.href = url; a.download = name; a.click(); URL.revokeObjectURL(url); toast("已导出 " + name); } function importData(event) { const file = event.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (e) => { try { const data = JSON.parse(e.target.result); if (!data.version) { toast("文件格式不对"); return; } if (!confirm("导入会覆盖当前所有对话、智能体和设置,确定吗?")) return; if (data.conversations) state.conversations = data.conversations; if (Array.isArray(data.weeklyReports)) { state.weeklyReports = data.weeklyReports; saveWeeklyReports(); } if (data.agents) state.agents = data.agents; if (Array.isArray(data.modelProfiles)) { state.modelProfiles = normalizeModelProfiles(data.modelProfiles); saveModelProfilesToLS(); } if (data.customSkills) { state.customSkills = data.customSkills; saveCustomSkillsToLS(); } if (data.flows) { state.flows = data.flows; saveFlowsToLS(); } if (data.settings) { state.apiBase = data.settings.apiBase || state.apiBase; state.apiKey = data.settings.apiKey || state.apiKey; state.stream = data.settings.stream !== undefined ? data.settings.stream : state.stream; localStorage.setItem(LS_SETTINGS, JSON.stringify({ apiBase: state.apiBase, apiKey: state.apiKey, stream: state.stream, })); document.getElementById("apiBase").value = state.apiBase; document.getElementById("apiKey").value = state.apiKey; document.getElementById("streamMode").checked = state.stream; } if (data.theme === "light") { document.documentElement.setAttribute("data-theme", "light"); localStorage.setItem(LS_THEME, "light"); } saveConversations(); saveAgents(); saveSharedConfig({ silent: true }).catch(() => {}); // 重新挑一条活动对话 const ids = sortedConvoIds(); state.activeId = ids[0] || null; if (!state.activeId) createConvo(); renderSidebar(); renderChat(); renderAgents(); renderModelProfiles(); refreshDashboard(); toast("已导入"); } catch (e) { toast("导入失败: " + e.message); } event.target.value = ""; }; reader.readAsText(file); } function wipeAll() { if (!confirm("清空所有本地数据(对话、智能体、设置)?此操作不可撤销,建议先导出备份。")) return; if (!confirm("真的要清空?最后一次确认。")) return; for (let i = localStorage.length - 1; i >= 0; i--) { const k = localStorage.key(i); if (k && k.startsWith("hermes-ui-")) localStorage.removeItem(k); } location.reload(); } // ---------- 杂项 ---------- function startResearch() { switchTab("chat"); fillPrompt("帮我做一个深度研究:主题是 "); } function openLog() { toast("日志查看暂未实现,可 SSH 到 Mac mini 查看 ~/.hermes/logs/"); } function copyText(text) { navigator.clipboard?.writeText(text).then(() => toast("已复制")); } function toast(text) { const el = document.createElement("div"); el.textContent = text; el.style.cssText = "position:fixed;bottom:40px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.85);color:#fff;padding:10px 20px;border-radius:50px;z-index:9999;font-size:14px;backdrop-filter:blur(10px)"; document.body.appendChild(el); setTimeout(() => el.remove(), 2200); }