4497 lines
168 KiB
JavaScript
4497 lines
168 KiB
JavaScript
// ============================================================
|
||
// 爱马仕 · 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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||
}
|
||
|
||
// 极简 Markdown 渲染器(专为聊天/会话内容)
|
||
// 支持: 代码块 / 行内代码 / 标题 / 列表 / 表格 / 引用 / 粗体 / 链接 / 段落
|
||
function renderMarkdown(raw) {
|
||
if (!raw) return "";
|
||
let text = String(raw);
|
||
|
||
// 1. 提取代码块占位(避免被后续规则破坏)
|
||
const codeBlocks = [];
|
||
text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => {
|
||
codeBlocks.push({ lang: lang || "", code });
|
||
return "\x00CB" + (codeBlocks.length - 1) + "\x00";
|
||
});
|
||
|
||
// 2. 转义 HTML
|
||
text = text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||
|
||
// 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(/^> (.+)$/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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||
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);
|
||
}
|