3268 lines
121 KiB
JavaScript
3268 lines
121 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 state = {
|
||
apiBase: "/api/v1",
|
||
apiKey: "hermes-mini-local-key-2026",
|
||
stream: true,
|
||
model: "gemini-3-pro-preview",
|
||
|
||
// 所有会话
|
||
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,
|
||
|
||
// 自定义 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 数组
|
||
|
||
// 本次一次性使用的智能体 ID (不绑定会话)
|
||
pendingAgent: null,
|
||
|
||
// 集群选中
|
||
clusterPicked: new Set(),
|
||
|
||
tokens: 0,
|
||
turns: 0,
|
||
};
|
||
|
||
// ---------- 入口 ----------
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
loadTheme();
|
||
loadSettings();
|
||
loadCustomSkills();
|
||
loadFlows();
|
||
loadAgents();
|
||
loadConversations();
|
||
bindTabs();
|
||
bindChat();
|
||
bindSearch();
|
||
bindStudio();
|
||
renderSidebar();
|
||
renderChat();
|
||
renderAgents();
|
||
pingBackend();
|
||
fetchIP();
|
||
setInterval(pingBackend, 30000);
|
||
|
||
if ("serviceWorker" in navigator) {
|
||
navigator.serviceWorker.register("./sw.js").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) {}
|
||
}
|
||
function saveSettings() {
|
||
state.apiBase = document.getElementById("apiBase").value.trim();
|
||
state.apiKey = document.getElementById("apiKey").value.trim();
|
||
state.stream = document.getElementById("streamMode").checked;
|
||
localStorage.setItem(LS_SETTINGS, JSON.stringify({
|
||
apiBase: state.apiBase,
|
||
apiKey: state.apiKey,
|
||
stream: state.stream,
|
||
}));
|
||
toast("设置已保存");
|
||
pingBackend();
|
||
}
|
||
|
||
// ---------- 会话持久化 ----------
|
||
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));
|
||
});
|
||
}
|
||
let _dashboardDirty = true;
|
||
function markDashboardDirty() { _dashboardDirty = true; }
|
||
|
||
function switchTab(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 === "tools") refreshTools();
|
||
if (name === "runs") setTimeout(() => document.getElementById("runPrompt")?.focus(), 50);
|
||
if (name === "dashboard" && _dashboardDirty) {
|
||
// 推迟到下一帧,避免阻塞切换动画
|
||
requestAnimationFrame(() => {
|
||
refreshDashboard();
|
||
_dashboardDirty = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
// ---------- 健康检查 ----------
|
||
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 fetch(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 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;
|
||
});
|
||
}
|
||
|
||
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 = useAgent.model || state.model;
|
||
}
|
||
|
||
// 本次使用完清掉 pendingAgent
|
||
if (pendingId) {
|
||
state.pendingAgent = null;
|
||
updatePendingAgentBar();
|
||
}
|
||
const body = {
|
||
model: modelForApi,
|
||
messages: msgsForApi,
|
||
stream: state.stream,
|
||
};
|
||
|
||
try {
|
||
if (state.stream) {
|
||
await streamChat(body, assistantMsg);
|
||
} else {
|
||
const res = await fetch(state.apiBase + "/chat/completions", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"Authorization": "Bearer " + state.apiKey,
|
||
},
|
||
body: JSON.stringify(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) {
|
||
const res = await fetch(state.apiBase + "/chat/completions", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"Authorization": "Bearer " + state.apiKey,
|
||
"Accept": "text/event-stream",
|
||
},
|
||
body: JSON.stringify(body),
|
||
});
|
||
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">由 Gemini 3 Pro 驱动 · 你的私人 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);
|
||
}
|
||
|
||
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 fetch(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) {}
|
||
}
|
||
|
||
// ---------- 每日用量聚合 ----------
|
||
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();
|
||
|
||
// 根据容器宽度动态决定多少列,每格至少 18px
|
||
const width = el.parentElement.clientWidth - 80; // 减去 padding 和 label 列
|
||
const cellMin = 22;
|
||
const weeksCount = Math.max(12, Math.min(26, Math.floor(width / (cellMin + 8))));
|
||
|
||
el.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);
|
||
|
||
// 按 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;
|
||
const cell = document.createElement("div");
|
||
cell.className = "hm-cell lvl-" + lvl + (future ? " future" : "") + (k === selectedDay ? " sel" : "");
|
||
cell.title = k + (msgs ? ` · ${msgs} 条消息` : " · 无活动");
|
||
if (!future) {
|
||
cell.onclick = () => { selectedDay = k; renderHeatmap(); renderDayDetail(k); };
|
||
}
|
||
el.appendChild(cell);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 窗口尺寸变化时重新渲染热力图
|
||
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: "gemini-3-pro-preview",
|
||
systemPrompt: "你是爱马仕,一个友好、专业、简洁的 AI 助手。用中文回答,除非用户明确要求其他语言。",
|
||
skills: ["brief", "goal"],
|
||
},
|
||
{
|
||
emoji: "💻",
|
||
name: "代码专家",
|
||
desc: "精通各类编程语言和框架,擅长排 bug、写代码、解读架构。",
|
||
model: "gemini-3-pro-preview",
|
||
systemPrompt: "你是一位资深的软件工程师,擅长多种编程语言和框架。回答要准确、实用,提供可运行的代码示例,并解释关键点。遇到 bug 时先定位根因再提出修复方案。",
|
||
skills: ["runnable", "rigor", "steps"],
|
||
},
|
||
{
|
||
emoji: "✍️",
|
||
name: "写作助手",
|
||
desc: "中英文写作、润色、翻译、摘要,文风可调。",
|
||
model: "gemini-3-pro-preview",
|
||
systemPrompt: "你是一位专业的中英文写作助手,擅长润色、翻译、摘要、改写。注重逻辑清晰、语言流畅、风格贴合语境。先理解用户的目标受众再动笔。",
|
||
skills: ["bilingual", "plain", "structured"],
|
||
},
|
||
{
|
||
emoji: "🔍",
|
||
name: "研究员",
|
||
desc: "深度调研、信息整合、结构化输出报告。",
|
||
model: "gemini-3-pro-preview",
|
||
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();
|
||
}
|
||
}
|
||
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 = a.model;
|
||
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.slice(0, 5)) {
|
||
const tag = document.createElement("span");
|
||
tag.className = "mini-skill";
|
||
tag.textContent = s.emoji + " " + s.name;
|
||
sEl.appendChild(tag);
|
||
}
|
||
if (skills.length > 5) {
|
||
const more = document.createElement("span");
|
||
more.className = "mini-skill";
|
||
more.textContent = "+" + (skills.length - 5);
|
||
sEl.appendChild(more);
|
||
}
|
||
}
|
||
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 || "";
|
||
document.getElementById("agentModel").value = a.model || "gemini-3-pro-preview";
|
||
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 = "gemini-3-pro-preview";
|
||
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;
|
||
const systemPrompt = document.getElementById("agentPrompt").value.trim();
|
||
if (!name) { toast("请填写名称"); return; }
|
||
if (!systemPrompt) { toast("请填写角色设定"); return; }
|
||
|
||
const skills = readSkillsPicker();
|
||
|
||
if (state.editingAgentId && state.agents[state.editingAgentId]) {
|
||
Object.assign(state.agents[state.editingAgentId], { emoji, name, desc, model, systemPrompt, skills });
|
||
} else {
|
||
const id = "a_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||
state.agents[id] = { id, emoji, name, desc, model, systemPrompt, skills, createdAt: Date.now() };
|
||
}
|
||
saveAgents();
|
||
renderAgents();
|
||
closeAgentModal();
|
||
toast("已保存");
|
||
}
|
||
function deleteAgent(id) {
|
||
if (!confirm("删除这个智能体?已有的对话不受影响。")) return;
|
||
delete state.agents[id];
|
||
saveAgents();
|
||
renderAgents();
|
||
}
|
||
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 res = await fetch(state.apiBase + "/chat/completions", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"Authorization": "Bearer " + state.apiKey,
|
||
},
|
||
body: JSON.stringify({ model: agent.model, messages, stream: false }),
|
||
});
|
||
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,
|
||
agents: state.agents,
|
||
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 (data.agents) state.agents = data.agents;
|
||
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();
|
||
// 重新挑一条活动对话
|
||
const ids = sortedConvoIds();
|
||
state.activeId = ids[0] || null;
|
||
if (!state.activeId) createConvo();
|
||
renderSidebar();
|
||
renderChat();
|
||
renderAgents();
|
||
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 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);
|
||
}
|