Files
hermes-glass-ui-personal/src/app.js

4497 lines
168 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// 爱马仕 · 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 = `
<div class="boot-issue-title">${escapeHTML(source)}</div>
<div class="boot-issue-msg">${escapeHTML(message)}</div>
<button type="button" onclick="this.closest('.boot-issue')?.remove()">关闭</button>
<details><summary>详情</summary><pre>${escapeHTML(detail)}</pre></details>
`;
};
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 = '<option value="">跟随对话默认 / 仅使用模型 ID</option>';
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 = '<div class="side-history-label">对话历史</div>';
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 = '<div class="settings-help">正在读取飞书桥接服务...</div>';
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 = '<div class="settings-help">还没有读取到飞书机器人配置。</div>';
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 `
<div class="feishu-app-card">
<div class="feishu-app-top">
<div>
<div class="feishu-app-title">${appId}</div>
<div class="feishu-app-meta">${isDefault ? "默认应用" : "独立应用"} · ${tokenCount} 个校验 Token</div>
</div>
<div class="feishu-app-actions">
<span class="feishu-status">已接入</span>
<button class="icon-btn-mini danger" onclick="deleteFeishuApp('${encodedAppId}')" title="删除机器人">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v5"/><path d="M14 11v5"/></svg>
</button>
</div>
</div>
<div class="feishu-callback">
<span>${callbackUrl}</span>
<button class="icon-btn-mini" onclick="copyText('${callbackUrl}')" title="复制回调地址">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button>
</div>
<div class="feishu-app-foot">
<span>事件: im.message.receive_v1</span>
<span>通知目标: ${app.has_default_receive_id ? escapeHTML(app.default_receive_id_type || "chat_id") : "按请求传入"}</span>
</div>
</div>`;
}).join("");
} catch (e) {
box.innerHTML = `<div class="settings-help">飞书桥接服务读取失败: ${escapeHTML(e.message || e)}</div>`;
} 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 = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>添加 / 更新机器人';
}
}
}
// ---------- 带认证续期的 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 ? '<span class="model-profile-badge">默认</span>' : "";
const disabledBadge = profile.enabled ? "" : '<span class="model-profile-badge off">停用</span>';
return `
<div class="model-profile-card ${profile.isDefault ? "active" : ""}">
<div class="model-profile-main">
<div class="model-profile-name">${escapeHTML(profile.name)}${defaultBadge}${disabledBadge}</div>
<div class="model-profile-model">${escapeHTML(profile.model)}</div>
<div class="model-profile-meta">
<span>${escapeHTML(profile.provider || "auto")}</span>
${profile.baseUrl ? `<span>${escapeHTML(profile.baseUrl)}</span>` : ""}
${profile.apiKeyRef ? `<span>${escapeHTML(profile.apiKeyRef)}</span>` : ""}
</div>
</div>
<div class="model-profile-actions">
<button type="button" onclick="editModelProfile('${escapeHTML(profile.id)}')">编辑</button>
<button type="button" onclick="makeModelProfileDefault('${escapeHTML(profile.id)}')">设默认</button>
<button type="button" onclick="applyModelProfileToRuntime('${escapeHTML(profile.id)}')">应用到运行配置</button>
<button type="button" class="danger" onclick="deleteModelProfile('${escapeHTML(profile.id)}')">删除</button>
</div>
</div>
`;
}
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 = `
<div class="welcome-hero">
<div class="welcome-hermes-tag">
<span class="wh-top">HERMÈS</span>
<span class="wh-mid">PARIS</span>
</div>
<div class="welcome-title">${greet},今天想聊点什么?</div>
<div class="welcome-sub">由当前 AI 模型驱动 · 你的私人 AI 助手</div>
<div class="welcome-chips">
<span class="chip" data-p="帮我规划今晚三道菜的晚餐">🍽 今晚吃什么</span>
<span class="chip" data-p="用通俗语言解释 MCP 协议">💡 解释一个概念</span>
<span class="chip" data-p="写一首关于液态玻璃的短诗">✍️ 写点东西</span>
<span class="chip" data-p="帮我做一个深度研究,主题是 ">🔍 深度研究</span>
</div>
</div>
`;
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 = '<div class="msg-body"><div class="msg-content"></div></div>';
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 = '<div class="thinking"><span></span><span></span><span></span></div>';
} 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 = '<button class="tc-toggle"><svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg><span>' + m.toolCalls.length + ' 个工具调用</span></button><div class="tc-body"></div>';
const body = tc.querySelector(".tc-body");
for (const t of m.toolCalls) {
const one = document.createElement("div");
one.className = "tc-item";
one.innerHTML = '<div class="tc-name"></div><pre class="tc-args"></pre>';
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 = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg><span>分支</span>';
branchBtn.onclick = () => branchFromMessage(capturedIdx);
actions.appendChild(branchBtn);
const toAgentBtn = document.createElement("button");
toAgentBtn.className = "msg-action-btn";
toAgentBtn.title = "把这里派给某个智能体接着聊";
toAgentBtn.innerHTML = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M6 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/></svg><span>派给</span>';
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 = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 14l-4-4 4-4"/><path d="M5 10h11a4 4 0 0 1 4 4v2"/></svg><span>拉回</span>';
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 = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M4 4.5A2.5 2.5 0 0 1 6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5z"/><path d="M8 7h8"/><path d="M8 11h8"/></svg><span>存周报</span>';
weeklyBtn.onclick = () => saveWeeklyReportFromMessage(capturedIdx);
actions.appendChild(weeklyBtn);
}
const copyBtn = document.createElement("button");
copyBtn.className = "msg-action-btn";
copyBtn.title = "复制";
copyBtn.innerHTML = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg><span>复制</span>';
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 = '<div class="settings-help">还没有保存过周报记录。</div>';
return;
}
box.innerHTML = reports.map(r => `
<div class="weekly-report-card">
<div class="weekly-report-top">
<div>
<div class="weekly-report-title">${escapeHTML(r.title || "周报记录")}</div>
<div class="weekly-report-meta">${escapeHTML(formatRecordTime(r.createdAt))}${r.tags?.length ? " · " + escapeHTML(r.tags.join(" / ")) : ""}</div>
</div>
<span class="weekly-report-badge">已保存</span>
</div>
<div class="weekly-report-section">
<div class="weekly-report-label">任务描述</div>
<div class="weekly-report-text">${escapeHTML(compactText(r.task || "未记录任务描述", 220))}</div>
</div>
<div class="weekly-report-section">
<div class="weekly-report-label">周报内容</div>
<div class="weekly-report-text">${escapeHTML(compactText(r.report, 260))}</div>
</div>
<div class="weekly-report-actions">
<button class="glass-btn-sm" onclick="openWeeklyRecord('${r.id}')">打开</button>
<button class="glass-btn-sm" onclick="copyWeeklyReport('${r.id}')">复制周报</button>
<button class="glass-btn-sm" onclick="copyWeeklyTask('${r.id}')">复制任务</button>
<button class="glass-btn-sm danger-btn" onclick="deleteWeeklyReport('${r.id}')">删除</button>
</div>
</div>
`).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<id>, agents: Set<id>, 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 = ['<div class="hm-month-spacer"></div>'];
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(`<div class="hm-month-label">${label}</div>`);
}
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 = '<div class="history-empty">这一天没有活动</div>';
return;
}
const agentsStr = [...data.agents].map(id => {
const a = state.agents[id];
return a ? (a.emoji + " " + a.name) : null;
}).filter(Boolean).join(" · ") || "(通用对话)";
let html = `<h4>${niceDate}</h4>
<div class="day-stats">
<div class="day-stat"><div class="num">${data.messages}</div><div class="lbl">总消息</div></div>
<div class="day-stat"><div class="num">${data.userMsgs}</div><div class="lbl">你发的</div></div>
<div class="day-stat"><div class="num">${data.assistantMsgs}</div><div class="lbl">AI 回复</div></div>
<div class="day-stat"><div class="num">${data.convos.size}</div><div class="lbl">对话数</div></div>
<div class="day-stat"><div class="num">${data.tokens.toLocaleString()}</div><div class="lbl">Token</div></div>
<div class="day-stat"><div class="num">${data.agents.size}</div><div class="lbl">智能体</div></div>
</div>
<div style="font-size:12px;color:var(--text-dim);margin-bottom:12px">涉及智能体: ${escapeHTML(agentsStr)}</div>
<div class="day-convos">`;
for (const cid of data.convos) {
const c = state.conversations[cid];
if (!c) continue;
const count = data.convoList[cid]?.count || 0;
html += `<div class="day-convo-item" data-cid="${escapeHTML(cid)}">
<span class="day-convo-title">${escapeHTML(c.title || "未命名")}</span>
<span class="day-convo-meta">${count} 条 · ${new Date(c.updatedAt || 0).toLocaleTimeString("zh-CN", {hour: "2-digit", minute: "2-digit"})}</span>
</div>`;
}
html += "</div>";
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 = `
<div class="ap-emoji">—</div>
<div class="ap-body">
<div class="ap-name">默认对话</div>
<div class="ap-desc">不绑定任何智能体,使用通用 prompt</div>
</div>
${!activeAgentId ? '<span class="ap-check">✓</span>' : ""}
`;
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 = `
<div class="ap-emoji"></div>
<div class="ap-body">
<div class="ap-name"></div>
<div class="ap-desc"></div>
</div>
${a.id === activeAgentId ? '<span class="ap-check">✓</span>' : ""}
`;
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 = `
<span class="pa-label">本次用</span>
<span class="pa-emoji">${escapeHTML(a.emoji || "🤖")}</span>
<span class="pa-name">${escapeHTML(a.name)}</span>
<button type="button" class="pa-clear" onclick="clearPendingAgent()" title="取消">×</button>
`;
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 = `<div class="ag-emoji">${escapeHTML(a.emoji || "🤖")}</div><span>${escapeHTML(a.name)}</span>`;
btn.title = "点击切换或移除智能体 · " + (a.desc || "");
} else {
btn.innerHTML = `<div class="ag-emoji">+</div><span>邀请智能体</span>`;
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 = '<div class="history-empty" style="grid-column:1/-1">还没有智能体,点右上角"新建智能体"开始。</div>';
return;
}
for (const a of list) {
const card = document.createElement("div");
card.className = "agent-card";
card.innerHTML = `
<div class="agent-card-head">
<div class="agent-avatar"></div>
<div class="agent-meta">
<div class="agent-name"></div>
<div class="agent-model"></div>
</div>
</div>
<div class="agent-desc"></div>
<div class="agent-skills"></div>
<div class="agent-actions">
<button class="chat">对话</button>
<button class="edit">编辑</button>
<button class="danger">删除</button>
</div>
`;
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 = '<span class="skill-ico">+</span><span>新建技能</span>';
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 = '<div class="history-empty">没有智能体,先去新建一个。</div>';
return;
}
for (const a of list) {
const chip = document.createElement("div");
chip.className = "cluster-pick-chip" + (state.clusterPicked.has(a.id) ? " on" : "");
chip.innerHTML = `<span>${a.emoji}</span><span>${escapeHTML(a.name)}</span>`;
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 = `
<div class="cluster-col-head">
<div class="cluster-col-avatar"></div>
<div class="cluster-col-name"></div>
<div class="cluster-col-status running">运行中</div>
</div>
<div class="cluster-col-body"><div class="thinking"><span></span><span></span><span></span></div></div>
`;
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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
// 3. 标题
text = text.replace(/^###### (.+)$/gm, "<h6>$1</h6>");
text = text.replace(/^##### (.+)$/gm, "<h5>$1</h5>");
text = text.replace(/^#### (.+)$/gm, "<h4>$1</h4>");
text = text.replace(/^### (.+)$/gm, "<h3>$1</h3>");
text = text.replace(/^## (.+)$/gm, "<h2>$1</h2>");
text = text.replace(/^# (.+)$/gm, "<h1>$1</h1>");
// 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 = '<table class="md-table"><thead><tr>';
for (const h of header) html += "<th>" + h + "</th>";
html += "</tr></thead><tbody>";
for (const row of rows) {
html += "<tr>";
for (const c of row) html += "<td>" + c + "</td>";
html += "</tr>";
}
return html + "</tbody></table>";
});
// 5. 无序列表
text = text.replace(/(^[-*] .+(?:\n[-*] .+)*)/gm, (m) => {
const items = m.split("\n").map(l => l.replace(/^[-*] /, ""));
return "<ul>" + items.map(i => "<li>" + i + "</li>").join("") + "</ul>";
});
// 6. 有序列表
text = text.replace(/(^\d+\. .+(?:\n\d+\. .+)*)/gm, (m) => {
const items = m.split("\n").map(l => l.replace(/^\d+\. /, ""));
return "<ol>" + items.map(i => "<li>" + i + "</li>").join("") + "</ol>";
});
// 7. 引用
text = text.replace(/^&gt; (.+)$/gm, "<blockquote>$1</blockquote>");
// 8. 行内: 粗体 / 行内代码 / 链接
text = text.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>");
text = text.replace(/`([^`\n]+)`/g, "<code>$1</code>");
text = text.replace(/\[([^\]]+)\]\((https?:[^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
// 9. 段落(双换行分段,单换行保留<br>)
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 "<p>" + b.replace(/\n/g, "<br>") + "</p>";
}).join("");
// 10. 恢复代码块
text = text.replace(/\x00CB(\d+)\x00/g, (_, i) => {
const b = codeBlocks[i];
const escaped = b.code.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const langLabel = b.lang ? '<span class="md-code-lang">' + escapeHTML(b.lang) + "</span>" : "";
return '<pre class="md-pre">' + langLabel + "<code>" + escaped + "</code></pre>";
});
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 = '<div class="history-empty">加载 Hermes 技能库…</div>';
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 = '<div class="history-empty">没有匹配的 skill</div>';
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 = '<div class="history-empty">你还没有自定义 skill<br>编辑智能体 → 技能区 → 新建技能</div>';
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 = `
<div class="studio-skill-icon"></div>
<div class="studio-skill-meta">
<div class="studio-skill-name"></div>
<div class="studio-skill-desc"></div>
</div>
<div class="studio-skill-src"></div>
`;
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 = '<div class="history-empty">点左侧技能查看详情</div>';
return;
}
const tagsHtml = (s.tags || []).map(t => `<span class="tag">${escapeHTML(String(t))}</span>`).join("");
const preCmds = s.prerequisites?.commands || [];
const preHtml = preCmds.length ? `<div class="studio-preview-tags">需要: ${preCmds.map(c => `<span class="tag">${escapeHTML(String(c))}</span>`).join("")}</div>` : "";
el.innerHTML = `
<h3></h3>
<div class="studio-preview-desc"></div>
${tagsHtml ? `<div class="studio-preview-tags">${tagsHtml}</div>` : ""}
${preHtml}
<div class="studio-preview-body"></div>
<button class="studio-preview-add">+ 加入执行阶段</button>
`;
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 = '<div class="studio-slot-empty">点右侧技能的"+ 加入"或左栏的技能</div>';
continue;
}
arr.forEach((id, idx) => {
const s = studioSkillById(id);
const chip = document.createElement("span");
chip.className = "studio-slot-chip";
chip.innerHTML = `
<span class="slot-order">${idx + 1}</span>
<span>${escapeHTML((s?.emoji || "🧩") + " " + (s?.name || id))}</span>
<button class="slot-del" title="上移">▲</button>
<button class="slot-del" title="下移">▼</button>
<button class="slot-del" title="移除">×</button>
`;
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 = `
<div class="ap-emoji">${escapeHTML(f.emoji || "🧠")}</div>
<div class="ap-body">
<div class="ap-name">${escapeHTML(f.name)}</div>
<div class="ap-desc">${escapeHTML(f.desc || "") + " · " + (f.skillIds?.length || 0) + " 个技能"}</div>
</div>
`;
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 ? `<span class="mini-skill">${s.emoji} ${escapeHTML(s.name)}</span>` : "";
}).join("");
card.innerHTML = `
<div class="flow-card-head">
<div class="flow-card-emoji">${escapeHTML(f.emoji || "🧠")}</div>
<div class="flow-card-meta">
<div class="flow-card-name">${escapeHTML(f.name)}</div>
<div class="flow-card-desc">${escapeHTML(f.desc || "")}</div>
</div>
</div>
<div class="flow-card-skills">${skillsHtml}</div>
<div class="flow-card-actions">
<button class="edit">编辑</button>
<button class="danger">删除</button>
</div>
`;
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 ? `<span class="mini-skill">${s.emoji} ${escapeHTML(s.name)}</span>` : "";
}).join("");
card.innerHTML = `
<div class="flow-card-head">
<div class="flow-card-emoji">${escapeHTML(f.emoji || "🧠")}</div>
<div class="flow-card-meta">
<div class="flow-card-name">${escapeHTML(f.name)}</div>
<div class="flow-card-desc">${escapeHTML(f.desc || "")}</div>
</div>
</div>
<div class="flow-card-skills">${skillsHtml}</div>
`;
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 = '<div class="history-empty" style="grid-column:1/-1">没有匹配</div>';
}
}
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 = '<div class="history-empty">加载中…</div>';
try {
const data = await cronFetch("?include_disabled=true");
const jobs = data?.jobs || data || [];
if (!Array.isArray(jobs) || !jobs.length) {
list.innerHTML = '<div class="history-empty">还没有定时任务。点右上角"新建任务"开始。</div>';
return;
}
list.innerHTML = "";
for (const j of jobs) list.appendChild(makeCronRow(j));
} catch (e) {
list.innerHTML = '<div class="history-empty">加载失败: ' + escapeHTML(e.message || String(e)) + '</div>';
}
}
function makeCronRow(j) {
const row = document.createElement("div");
row.className = "cron-item" + (j.enabled === false ? " disabled" : "");
row.innerHTML = `
<div class="cron-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
</div>
<div class="cron-body">
<div class="cron-name"></div>
<div class="cron-meta">
<span class="sched">调度: <code></code></span>
<span class="next"></span>
<span class="deliver"></span>
<span class="last"></span>
</div>
<div class="cron-prompt-preview"></div>
</div>
<span class="cron-pill"></span>
<button class="cron-btn run">立即执行</button>
<button class="cron-btn edit">编辑</button>
`;
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 = '<span class="t"></span><span class="tag"></span><span class="body"></span>';
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 = "运行中: <code>" + escapeHTML(_currentRunId) + "</code>";
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 = '<div class="history-empty">还没有会话</div>';
} 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 = `
<div class="session-row-title"></div>
<div class="session-row-meta">
<span></span>
<span></span>
<span></span>
<span class="session-row-action">→ 继续</span>
</div>
`;
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 = `
<div class="tool-ico"></div>
<div class="tool-meta">
<div class="tool-name"></div>
<div class="tool-desc"></div>
</div>
<span class="tool-status ${enabled ? "on" : "off"}"></span>
`;
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 = '<div class="history-empty">没有工具数据</div>';
} catch (e) {
grid.innerHTML = '<div class="history-empty">加载失败: ' + escapeHTML(e.message || String(e)) + '</div>';
}
}
// ---------- 数据导入导出 ----------
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);
}