// ============================================================
// 爱马仕 · 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
`;
}).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.assistantMsgs}
AI 回复
${data.tokens.toLocaleString()}
Token
涉及智能体: ${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 += "| " + h + " | ";
html += "
";
for (const row of rows) {
html += "";
for (const c of row) html += "| " + c + " | ";
html += "
";
}
return html + "
";
});
// 5. 无序列表
text = text.replace(/(^[-*] .+(?:\n[-*] .+)*)/gm, (m) => {
const items = m.split("\n").map(l => l.replace(/^[-*] /, ""));
return "" + items.map(i => "- " + i + "
").join("") + "
";
});
// 6. 有序列表
text = text.replace(/(^\d+\. .+(?:\n\d+\. .+)*)/gm, (m) => {
const items = m.split("\n").map(l => l.replace(/^\d+\. /, ""));
return "" + items.map(i => "- " + i + "
").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 || "🧠")}
${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 || "🧠")}
${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);
}