From 2595306bcc6b80df50f86e54e606291209fd0bb3 Mon Sep 17 00:00:00 2001 From: kang Date: Tue, 21 Apr 2026 11:33:43 +0800 Subject: [PATCH] initial: Hermes Glass UI personal fork + deployment memory --- .gitignore | 7 + .memory/deployment-kang.md | 145 ++ .project.json | 9 + README.md | 22 + src/app.js | 3267 ++++++++++++++++++++++++++++++++++++ src/icon.svg | 16 + src/index.html | 1139 +++++++++++++ src/login.html | 508 ++++++ src/manifest.webmanifest | 15 + src/styles.css | 3232 +++++++++++++++++++++++++++++++++++ src/sw.js | 35 + 11 files changed, 8395 insertions(+) create mode 100644 .gitignore create mode 100644 .memory/deployment-kang.md create mode 100644 .project.json create mode 100644 README.md create mode 100644 src/app.js create mode 100644 src/icon.svg create mode 100644 src/index.html create mode 100644 src/login.html create mode 100644 src/manifest.webmanifest create mode 100644 src/styles.css create mode 100644 src/sw.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1dcd92a --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +deploy/secrets/ +*.pem +*.key +*.htpasswd* +.env +.env.local diff --git a/.memory/deployment-kang.md b/.memory/deployment-kang.md new file mode 100644 index 0000000..9dc12d2 --- /dev/null +++ b/.memory/deployment-kang.md @@ -0,0 +1,145 @@ +--- +name: Hermes Glass UI 个人版 · kang-kang.com 部署 +description: 从公司版 hermes.milejoy.com fork + 合规隔离重建的个人 VPS 部署完整细节 +type: project +--- + +# 架构 + +``` +浏览器 + ↓ https://hermes.kang-kang.com/ +Coolify Traefik (443, letsencrypt 自动签) + ↓ Host(`hermes.kang-kang.com`) → http://10.0.0.1:8088 +宿主 nginx 1.24 (listen 10.0.0.1:8088, /etc/nginx/sites-available/hermes.kang-kang.com) + ├─ /login.html + /_auth/verify + /_auth/logout cookie 门禁 + ├─ / /var/www/hermes-kang/(Glass UI 静态) + ├─ /v1/ + /api/v1/(rewrite) + /api/jobs 注入 Bearer → LXC + ├─ /memory/ + /hermes-skills/ + /classic/ 空目录占位 + └─ /health 免门禁 +Incus LXC hermes-personal (10.3.73.137, Debian trixie, privileged + nesting) + └─ Docker 26.1.5 (hermes-agent container) + └─ Hermes Agent v0.7, gateway run, 0.0.0.0:8642 + └─ OpenRouter → google/gemini-3.1-pro-preview +``` + +# 关键决策 + +## 为什么用 Debian trixie 不用 Ubuntu 24.04 +- Ubuntu 24.04 + Docker 29 在非/特权 LXC 内启动容器报 `net.ipv4.ip_unprivileged_port_start: permission denied` +- Debian trixie + Docker 26 没这个 bug(公司版 hermes-box 用的就是这个组合,直接照搬) +- 也加了 `security.syscalls.intercept.mknod/setxattr=true`(跟公司版对齐) + +## 为什么宿主 nginx 听 10.0.0.1:8088 不是 127.0.0.1:8088 +- Coolify-proxy (Traefik) 跑在 docker coolify 网络,从容器内访问宿主要走 docker0 gateway +- 宿主 docker0 IP 是 `10.0.0.1`(非标准,Coolify 自定义) +- 127.0.0.1 从 Traefik 容器里访问是容器自己的 localhost,不是宿主 +- Traefik 容器 /etc/hosts 有 `10.0.0.1 host.docker.internal` —— 用宿主 docker0 IP 直连宿主 nginx + +## 为什么 gitea.yaml 和 notebooklm-mcp.yaml 被改了 +- Traefik watcher 解析 `dynamic/` 目录时遇到这两个文件里 `dialTimeout/responseHeaderTimeout` **不在 forwardingTimeouts 下**,报 `field not found` +- 这个错误**阻止了整个 directory 的 reload**(不是 per-file 隔离),所以我新写的 hermes-kang.yaml 一直没被 pick up +- 修复:把两个文件里的 `dialTimeout`/`responseHeaderTimeout` 包到 `forwardingTimeouts:` 下(Traefik v3 正确嵌套) +- 备份:`gitea.yaml.bak-`, `notebooklm-mcp.yaml.bak-` +- gitea/lobehub/notebooklm-mcp 都继续工作(verified via curl 200/302) + +## 为什么不用 Traefik basicauth middleware / forwardAuth sidecar +- 保留公司版的自定义 Liquid Glass login.html + cookie 门禁体验 +- 单用户场景 Authelia sidecar 不划算 +- nginx 熟悉度高,排障快 + +# 凭证 + +存 `credentials.md` 不在本文件重复。 + +# 文件清单 + +## 宿主 76.13.31.179 + +| 路径 | 作用 | 备注 | +|---|---|---| +| `/etc/nginx/sites-available/hermes.kang-kang.com` | nginx 站点,listen 10.0.0.1:8088 | sites-enabled 软链已建 | +| `/etc/nginx/.htpasswd-hermes-kang` | bcrypt 密码 | 只 `kang` 一个用户 | +| `/data/coolify/proxy/dynamic/hermes-kang.yaml` | Traefik 路由+letsencrypt | owner 9999:root, mode 700 | +| `/var/www/hermes-kang/` | Glass UI 静态 | rsync 自本机个人版 src/ | +| `/var/www/hermes-memory-kang/` | `/memory/` 路由空目录 | 自己以后填 | +| `/var/www/hermes-skills-kang/` | `/hermes-skills/` 路由空目录 | 自己以后填 | +| `/opt/hermes-build/` | 镜像 build 源码(rsync 自本机)| 32MB | +| `/tmp/hermes-build.log` | build 过程 log | 留作参考 | + +## Incus LXC hermes-personal + +| 路径 | 作用 | +|---|---| +| `/opt/hermes-agent/docker-compose.yml` | compose | +| `/opt/hermes-agent/config.yaml` | Hermes gateway 配置(OpenRouter + gemini-3.1-pro-preview)| +| `/opt/hermes-agent/.env` | `OPENROUTER_API_KEY` + `API_SERVER_KEY`, mode 600 | +| `/opt/hermes-agent/data/` | Hermes workspace(HERMES_HOME)| + +# 常用操作 + +## 改前端代码后同步 +```bash +cd ~/Projects/code/20260421-hermes-glass-ui-personal +# 编辑 src/* +rsync -az --delete src/ root@76.13.31.179:/var/www/hermes-kang/ +# sw.js 如需强刷:bump CACHE 版本号 +``` + +## 改后端配置/模型 +```bash +ssh root@76.13.31.179 +incus exec hermes-personal -- bash +cd /opt/hermes-agent +vi config.yaml # 改模型 +vi .env # 改 key +docker compose down && docker compose up -d +# ⚠️ docker restart 不 reload env_file,必须 down + up +``` + +## 换 OpenRouter key +```bash +incus exec hermes-personal -- bash -c "sed -i 's|^OPENROUTER_API_KEY=.*|OPENROUTER_API_KEY=<新key>|' /opt/hermes-agent/.env && cd /opt/hermes-agent && docker compose down && docker compose up -d" +``` + +## 查日志 +```bash +incus exec hermes-personal -- docker logs hermes-agent --tail 50 +ssh root@76.13.31.179 tail -f /var/log/nginx/error.log +ssh root@76.13.31.179 docker logs coolify-proxy --since 2m 2>&1 | grep -i hermes +``` + +# 不破坏的约束 + +- ✅ 不碰 Coolify 现有 22+ 容器 + Coolify-proxy 本体 +- ✅ 不碰 `/opt/lobechat-mirror/` 独立 docker compose +- ✅ 不碰 `/opt/gitea/` `/opt/postgres/` `/opt/mysql/` +- ✅ hermes-kang 走独立 LXC + 独立 nginx 站点 + 独立 Traefik dynamic file +- ✅ 宿主 nginx 独自启动(systemd nginx.service 新启用) +- ✅ 禁用了 sites-enabled/default 和 sites-enabled/styles.kang-kang.com(冲突 listen 80,且 styles 实际由 style-gallery-nginx docker 容器跑) + +# 与公司版的差异 + +| 维度 | 公司版 hermes.milejoy.com | 个人版 hermes.kang-kang.com | +|---|---|---| +| 宿主 | 公司 VPS 2.24.28.41 | 个人 VPS 76.13.31.179 | +| 入口 | 宿主 nginx 直听 443 | Coolify Traefik 443 → 宿主 nginx 10.0.0.1:8088 | +| 证书 | certbot ai.milejoy.com 复用 | Traefik letsencrypt certresolver | +| LXC | hermes-box, Debian trixie, 10.146.223.10 | hermes-personal, Debian trixie, 10.3.73.137 | +| 模型 | Gemini 3 Pro Preview 直连(GOOGLE_API_KEY)| Gemini 3.1 Pro Preview via OpenRouter | +| 认证 | basic auth boss/mile | basic auth kang | +| Skills/Memory | 78 真实 skill + 同步真实 memory | 空目录(未来按需填充) | + +# 合规边界(离职场景) + +✅ **可搬**: +- 个人编写的 Glass UI 前端源码(fork 到 `code/20260421-hermes-glass-ui-personal/`) +- Hermes Agent 开源代码(NousResearch 上游) +- 架构和 nginx/Traefik 配置思路 + +❌ **未搬**: +- 公司 API Server Key `ffd2f8af...`(重新用 openssl 生成新的) +- 公司 GOOGLE_API_KEY(换 OpenRouter + 个人 key) +- 公司 LXC 里的 memory/skills/sessions/对话历史(个人版从零起) +- 公司 basic auth 账号 `boss/mile`(改 `kang` 单账号) +- 公司 nginx 证书(letsencrypt 新签) diff --git a/.project.json b/.project.json new file mode 100644 index 0000000..3b91c33 --- /dev/null +++ b/.project.json @@ -0,0 +1,9 @@ +{ + "name": "Hermes Glass UI · 个人版", + "description": "Fork from 20260414-hermes-glass-ui, 单用户个人 VPS 部署 (hermes.kang-kang.com)", + "created": "2026-04-21", + "kind": "app", + "status": "active", + "stack": ["HTML/CSS/JS", "Liquid Glass UI", "LXC", "OpenRouter"], + "urls": [{ "label": "个人 VPS", "url": "https://hermes.kang-kang.com" }] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..dbc7be0 --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# Hermes Glass UI · 个人版 + +基于 `business/20260414-hermes-glass-ui` fork,精简为个人单用户版本。 + +## 部署 + +- **线上**: https://hermes.kang-kang.com/ +- **宿主**: Hetzner VPS (`76.13.31.179`) +- **入口**: Coolify Traefik (443) → 宿主 nginx (`127.0.0.1:8088`, cookie 门禁) → Incus LXC `hermes-personal` → Docker hermes-agent (8642) +- **模型**: Google Gemini 3 Pro Preview (via OpenRouter) +- **登录**: 单用户 `kang` + +## 与公司版的差异 + +| 维度 | 公司版 (hermes.milejoy.com) | 个人版 (hermes.kang-kang.com) | +|---|---|---| +| 用户 | `boss` / `mile` 双账号 | `kang` 单账号 | +| 后端 | Incus LXC hermes-box + Docker (Gemini 3 Pro via 直连) | Incus LXC hermes-personal + Docker (Gemini 3 Pro via OpenRouter) | +| 入口 | 宿主 nginx 直听 443 | Coolify Traefik 443 → 宿主 nginx 内部 8088 | +| 证书 | certbot (ai.milejoy.com 复用) | Traefik + letsencrypt certresolver | + +详见 `.memory/deployment-kang.md`。 diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..e5fffeb --- /dev/null +++ b/src/app.js @@ -0,0 +1,3267 @@ +// ============================================================ +// 爱马仕 · 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 = '
对话历史
'; + 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 = ` +
+
+ HERMÈS + PARIS +
+
${greet},今天想聊点什么?
+
由 Gemini 3 Pro 驱动 · 你的私人 AI 助手
+
+ 🍽 今晚吃什么 + 💡 解释一个概念 + ✍️ 写点东西 + 🔍 深度研究 +
+
+ `; + container.querySelectorAll(".chip").forEach(chip => { + chip.onclick = () => fillPrompt(chip.dataset.p); + }); + return; + } + + const wasNearBottom = + container.scrollTop + container.clientHeight >= container.scrollHeight - 80; + + container.innerHTML = ""; + const msgs = c.messages; + const lastIdx = msgs.length - 1; + for (let i = 0; i < msgs.length; i++) { + const m = msgs[i]; + const row = document.createElement("div"); + row.className = "msg " + m.role; + + if (m.role === "error") { + row.innerHTML = '
'; + row.querySelector(".msg-content").textContent = m.content; + container.appendChild(row); + continue; + } + + const msgAgent = (m.role === "assistant" && m.agentId && state.agents[m.agentId]) ? state.agents[m.agentId] : null; + + const avatar = document.createElement("div"); + avatar.className = "msg-avatar"; + if (m.role === "user") { + avatar.textContent = "你"; + } else if (msgAgent) { + avatar.textContent = msgAgent.emoji || "H"; + } else { + avatar.textContent = "H"; + } + + const body = document.createElement("div"); + body.className = "msg-body"; + + const name = document.createElement("div"); + name.className = "msg-name"; + if (m.role === "user") { + name.textContent = "你"; + } else if (msgAgent) { + name.textContent = msgAgent.name; + } else { + name.textContent = "爱马仕"; + } + + const content = document.createElement("div"); + content.className = "msg-content"; + const isStreamingThis = + streaming && i === lastIdx && m.role === "assistant"; + + if (m.role === "assistant" && !m.content) { + content.innerHTML = '
'; + } else if (m.role === "assistant") { + // markdown 渲染 + content.innerHTML = renderMarkdown(m.content); + if (isStreamingThis) content.classList.add("streaming"); + // tool calls 显示 + if (m.toolCalls && m.toolCalls.length) { + const tc = document.createElement("div"); + tc.className = "msg-toolcalls"; + tc.innerHTML = '
'; + const body = tc.querySelector(".tc-body"); + for (const t of m.toolCalls) { + const one = document.createElement("div"); + one.className = "tc-item"; + one.innerHTML = '
';
+          one.querySelector(".tc-name").textContent = t.name || t.function?.name || "unknown";
+          one.querySelector(".tc-args").textContent = (t.arguments || t.function?.arguments || "").slice(0, 800);
+          body.appendChild(one);
+        }
+        tc.querySelector(".tc-toggle").onclick = () => tc.classList.toggle("open");
+        content.appendChild(tc);
+      }
+    } else {
+      // 用户消息保留原文
+      content.textContent = m.content;
+    }
+
+    body.appendChild(name);
+    body.appendChild(content);
+
+    // 只给最后一条加入场动画,避免切 tab 时所有消息集体播动画
+    if (i === lastIdx && !streaming) row.classList.add("msg-in");
+
+    // 分支 / 复制按钮(流式中的最后一条不显示)
+    if (m.role !== "error" && m.content && !isStreamingThis) {
+      const actions = document.createElement("div");
+      actions.className = "msg-actions";
+      const capturedIdx = i;
+      const capturedText = m.content;
+
+      const branchBtn = document.createElement("button");
+      branchBtn.className = "msg-action-btn";
+      branchBtn.title = "从这里新开分支(保留当前智能体)";
+      branchBtn.innerHTML = '分支';
+      branchBtn.onclick = () => branchFromMessage(capturedIdx);
+      actions.appendChild(branchBtn);
+
+      const toAgentBtn = document.createElement("button");
+      toAgentBtn.className = "msg-action-btn";
+      toAgentBtn.title = "把这里派给某个智能体接着聊";
+      toAgentBtn.innerHTML = '派给';
+      toAgentBtn.onclick = () => branchToAgent(capturedIdx);
+      actions.appendChild(toAgentBtn);
+
+      // 如果是分支对话,AI 回答有"拉回原对话"按钮
+      if (c.parentId && state.conversations[c.parentId] && m.role === "assistant") {
+        const pullBtn = document.createElement("button");
+        pullBtn.className = "msg-action-btn";
+        pullBtn.title = "把这条回答拉回到原对话";
+        pullBtn.innerHTML = '拉回';
+        pullBtn.onclick = () => pullToParent(capturedIdx);
+        actions.appendChild(pullBtn);
+      }
+
+      const copyBtn = document.createElement("button");
+      copyBtn.className = "msg-action-btn";
+      copyBtn.title = "复制";
+      copyBtn.innerHTML = '复制';
+      copyBtn.onclick = () => {
+        navigator.clipboard?.writeText(capturedText).then(() => toast("已复制"));
+      };
+      actions.appendChild(copyBtn);
+
+      body.appendChild(actions);
+    }
+
+    row.appendChild(avatar);
+    row.appendChild(body);
+    container.appendChild(row);
+  }
+
+  if (wasNearBottom) container.scrollTop = container.scrollHeight;
+}
+
+function updateStats() {
+  const t = document.getElementById("statTurns");
+  const k = document.getElementById("statTokens");
+  if (t) t.textContent = state.turns;
+  if (k) k.textContent = state.tokens.toLocaleString();
+  refreshDashboardLocal();
+}
+
+function refreshDashboardLocal() {
+  const convos = Object.values(state.conversations || {});
+  const convosEl = document.getElementById("statConvos");
+  const convosSubEl = document.getElementById("statConvosSub");
+  const agentsEl = document.getElementById("statAgents");
+  const storageEl = document.getElementById("statStorage");
+  if (convosEl) convosEl.textContent = convos.length;
+  if (convosSubEl) {
+    const totalMsgs = convos.reduce((s, c) => s + (c.messages?.length || 0), 0);
+    convosSubEl.textContent = totalMsgs + " 条消息";
+  }
+  if (agentsEl) agentsEl.textContent = Object.keys(state.agents || {}).length;
+  if (storageEl) {
+    try {
+      let bytes = 0;
+      for (let i = 0; i < localStorage.length; i++) {
+        const k = localStorage.key(i);
+        if (k && k.startsWith("hermes-ui-")) bytes += (localStorage.getItem(k) || "").length;
+      }
+      storageEl.textContent = (bytes / 1024).toFixed(1) + " KB";
+    } catch (e) { storageEl.textContent = "—"; }
+  }
+}
+
+async function refreshDashboard() {
+  refreshDashboardLocal();
+  pingBackend();
+  // 模型列表
+  try {
+    const res = await 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, agents: Set, convoTitles: []}}
+  const out = {};
+  for (const c of Object.values(state.conversations || {})) {
+    // 为每条消息按 ts 分桶(无 ts 就落到 updatedAt)
+    const fallback = c.updatedAt || c.createdAt || Date.now();
+    const perConvoDays = new Set();
+    for (const m of c.messages || []) {
+      const ts = m.ts || fallback;
+      const k = dayKey(ts);
+      perConvoDays.add(k);
+      if (!out[k]) out[k] = { messages: 0, userMsgs: 0, assistantMsgs: 0, tokens: 0, convos: new Set(), agents: new Set(), convoList: {} };
+      out[k].messages += 1;
+      if (m.role === "user") out[k].userMsgs += 1;
+      else if (m.role === "assistant") out[k].assistantMsgs += 1;
+      out[k].convos.add(c.id);
+      if (c.agentId) out[k].agents.add(c.agentId);
+      out[k].convoList[c.id] = { title: c.title, count: (out[k].convoList[c.id]?.count || 0) + 1 };
+    }
+    // 把 c.tokens 平均摊到该对话涉及的天里(简化)
+    if (c.tokens && perConvoDays.size > 0) {
+      const share = Math.round(c.tokens / perConvoDays.size);
+      for (const k of perConvoDays) {
+        if (out[k]) out[k].tokens += share;
+      }
+    }
+  }
+  _bucketCache = out;
+  return out;
+}
+
+function refreshDashboard() {
+  refreshDashboardLocal();
+  pingBackend();
+  renderHeatmap();
+  renderDashStats();
+  if (!selectedDay) selectedDay = todayKey();
+  renderDayDetail(selectedDay);
+}
+
+function renderDashStats() {
+  const b = bucketUsage();
+  const today = todayKey();
+  const weekStart = daysAgo(6);
+  const monthStart = daysAgo(29);
+
+  let todayMsgs = 0, todayTokens = 0, todayConvos = new Set();
+  let weekMsgs = 0, weekTokens = 0;
+  let monthMsgs = 0, monthTokens = 0;
+  let allMsgs = 0, allTokens = 0;
+  const agentCounts = {};
+  for (const [k, v] of Object.entries(b)) {
+    allMsgs += v.messages;
+    allTokens += v.tokens;
+    if (k >= monthStart) { monthMsgs += v.messages; monthTokens += v.tokens; }
+    if (k >= weekStart) { weekMsgs += v.messages; weekTokens += v.tokens; }
+    if (k === today) { todayMsgs = v.messages; todayTokens = v.tokens; todayConvos = v.convos; }
+  }
+  // top agent (overall)
+  for (const c of Object.values(state.conversations || {})) {
+    if (c.agentId && c.messages?.length) {
+      agentCounts[c.agentId] = (agentCounts[c.agentId] || 0) + c.messages.length;
+    }
+  }
+  let topAgent = null, topCount = 0;
+  for (const [id, cnt] of Object.entries(agentCounts)) {
+    if (cnt > topCount) { topAgent = id; topCount = cnt; }
+  }
+
+  setText("stTodayTokens", todayTokens.toLocaleString());
+  setText("stTodaySub", todayMsgs + " 条消息");
+  setText("stTodayConvosText", (todayConvos.size || 0) + " 个对话");
+  setText("stWeek", weekMsgs);
+  setText("stWeekSub", weekTokens.toLocaleString() + " tokens");
+  setText("stMonth", monthMsgs);
+  setText("stMonthSub", monthTokens.toLocaleString() + " tokens");
+  setText("stAll", Object.keys(state.conversations || {}).length);
+  setText("stAllSub", allMsgs + " 条消息");
+
+  const avatarEl = document.getElementById("topAgentAvatar");
+  if (topAgent && state.agents[topAgent]) {
+    const a = state.agents[topAgent];
+    setText("stTopAgent", a.name);
+    setText("stTopAgentSub", topCount + " 条消息 · " + a.desc);
+    if (avatarEl) avatarEl.textContent = a.emoji || "🤖";
+  } else {
+    setText("stTopAgent", "还未使用智能体");
+    setText("stTopAgentSub", "去 Agent 面板创建一个开始对话");
+    if (avatarEl) avatarEl.textContent = "—";
+  }
+}
+
+function setText(id, v) { const el = document.getElementById(id); if (el) el.textContent = v; }
+
+function renderHeatmap() {
+  const el = document.getElementById("heatmap");
+  if (!el) return;
+  el.innerHTML = "";
+  const b = bucketUsage();
+
+  // 根据容器宽度动态决定多少列,每格至少 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 = '
这一天没有活动
'; + return; + } + const agentsStr = [...data.agents].map(id => { + const a = state.agents[id]; + return a ? (a.emoji + " " + a.name) : null; + }).filter(Boolean).join(" · ") || "(通用对话)"; + + let html = `

${niceDate}

+
+
${data.messages}
总消息
+
${data.userMsgs}
你发的
+
${data.assistantMsgs}
AI 回复
+
${data.convos.size}
对话数
+
${data.tokens.toLocaleString()}
Token
+
${data.agents.size}
智能体
+
+
涉及智能体: ${escapeHTML(agentsStr)}
+
`; + for (const cid of data.convos) { + const c = state.conversations[cid]; + if (!c) continue; + const count = data.convoList[cid]?.count || 0; + html += `
+ ${escapeHTML(c.title || "未命名")} + ${count} 条 · ${new Date(c.updatedAt || 0).toLocaleTimeString("zh-CN", {hour: "2-digit", minute: "2-digit"})} +
`; + } + html += "
"; + el.innerHTML = html; + el.querySelectorAll(".day-convo-item").forEach(row => { + row.onclick = () => switchConvo(row.dataset.cid); + }); +} + +// ---------- 对话分支 ---------- +function branchFromMessage(msgIdx) { + const c = activeConvo(); + if (!c || msgIdx < 0 || msgIdx >= c.messages.length) return; + const cid = createConvo(); + const nc = state.conversations[cid]; + nc.messages = JSON.parse(JSON.stringify(c.messages.slice(0, msgIdx + 1))); + nc.title = "🌿 " + (c.title || "分支"); + nc.agentId = c.agentId; + nc.parentId = c.id; + nc.branchedAt = msgIdx; + saveConversations(); + renderSidebar(); + renderChat(); + switchTab("chat"); + toast("已创建分支"); +} + +// ---------- 通用 Agent Picker ---------- +let agentPickerCallback = null; + +function openAgentPicker(title, activeAgentId, callback) { + agentPickerCallback = callback; + document.getElementById("agentPickerTitle").textContent = title || "选择智能体"; + const list = document.getElementById("agentPickerList"); + list.innerHTML = ""; + + // "无 / 默认" 选项 + const none = document.createElement("div"); + none.className = "agent-picker-item" + (!activeAgentId ? " active" : ""); + none.innerHTML = ` +
+
+
默认对话
+
不绑定任何智能体,使用通用 prompt
+
+ ${!activeAgentId ? '' : ""} + `; + none.onclick = () => { callback(null); closeAgentPicker(); }; + list.appendChild(none); + + for (const a of sortedAgents()) { + const row = document.createElement("div"); + row.className = "agent-picker-item" + (a.id === activeAgentId ? " active" : ""); + row.innerHTML = ` +
+
+
+
+
+ ${a.id === activeAgentId ? '' : ""} + `; + row.querySelector(".ap-emoji").textContent = a.emoji || "🤖"; + row.querySelector(".ap-name").textContent = a.name; + const skillStr = (a.skills || []).map(skillById).filter(Boolean).map(s => s.emoji).join(" "); + row.querySelector(".ap-desc").textContent = (a.desc || "") + (skillStr ? " · " + skillStr : ""); + row.onclick = () => { callback(a.id); closeAgentPicker(); }; + list.appendChild(row); + } + document.getElementById("agentPicker").classList.add("open"); +} +function closeAgentPicker() { + document.getElementById("agentPicker").classList.remove("open"); + agentPickerCallback = null; +} + +function openAgentPickerForChat() { + const c = activeConvo(); + openAgentPicker("为当前对话选择智能体", c?.agentId, (agentId) => { + c.agentId = agentId || null; + c.updatedAt = Date.now(); + saveConversations(); + renderChat(); + toast(agentId ? "已切换到 " + state.agents[agentId].name : "已恢复默认对话"); + }); +} + +function branchToAgent(msgIdx) { + const c = activeConvo(); + if (!c || msgIdx < 0 || msgIdx >= c.messages.length) return; + openAgentPicker("派分支给智能体", null, (agentId) => { + const cid = createConvo(); + const nc = state.conversations[cid]; + nc.messages = JSON.parse(JSON.stringify(c.messages.slice(0, msgIdx + 1))); + const agent = agentId ? state.agents[agentId] : null; + nc.title = (agent ? agent.emoji + " " : "🌿 ") + (c.title || "分支"); + nc.agentId = agentId || null; + nc.parentId = c.id; + nc.branchedAt = msgIdx; + saveConversations(); + renderSidebar(); + renderChat(); + switchTab("chat"); + toast(agent ? "已派给 " + agent.name : "已分支"); + }); +} + +// 把分支里的某条回答拉回原对话 +function pullToParent(msgIdx) { + const c = activeConvo(); + if (!c || !c.parentId) return; + const parent = state.conversations[c.parentId]; + if (!parent) return; + const m = c.messages[msgIdx]; + if (!m || m.role !== "assistant" || !m.content) return; + const agent = m.agentId && state.agents[m.agentId] ? state.agents[m.agentId] : (c.agentId && state.agents[c.agentId] ? state.agents[c.agentId] : null); + const header = agent ? `↩ 来自分支 · ${agent.emoji || ""} ${agent.name}` : "↩ 来自分支"; + parent.messages.push({ + role: "assistant", + content: header + "\n\n" + m.content, + ts: Date.now(), + agentId: agent?.id || null, + fromBranch: c.id, + }); + parent.updatedAt = Date.now(); + saveConversations(); + switchConvo(c.parentId); + toast("已拉回原对话"); +} + +// 本次对话 @ 一个智能体(一次性) +function useAgentOnce() { + openAgentPicker("本次消息使用", state.pendingAgent, (agentId) => { + state.pendingAgent = agentId || null; + updatePendingAgentBar(); + setTimeout(() => document.getElementById("chatInput")?.focus(), 50); + }); +} +function clearPendingAgent() { + state.pendingAgent = null; + updatePendingAgentBar(); +} +function updatePendingAgentBar() { + const bar = document.getElementById("pendingAgentBar"); + const atBtn = document.querySelector(".input-at"); + if (!bar) return; + if (state.pendingAgent && state.agents[state.pendingAgent]) { + const a = state.agents[state.pendingAgent]; + bar.innerHTML = ` + 本次用 + ${escapeHTML(a.emoji || "🤖")} + ${escapeHTML(a.name)} + + `; + bar.style.display = "flex"; + if (atBtn) atBtn.classList.add("active"); + } else { + bar.style.display = "none"; + if (atBtn) atBtn.classList.remove("active"); + } +} + +function updateChatAgentBadge() { + const btn = document.getElementById("chatAgentBtn"); + if (!btn) return; + const c = activeConvo(); + if (c && c.agentId && state.agents[c.agentId]) { + const a = state.agents[c.agentId]; + btn.innerHTML = `
${escapeHTML(a.emoji || "🤖")}
${escapeHTML(a.name)}`; + btn.title = "点击切换或移除智能体 · " + (a.desc || ""); + } else { + btn.innerHTML = `
+
邀请智能体`; + btn.title = "为当前对话选择一个智能体"; + } +} + +// ---------- Skill 库 ---------- +const SKILLS_LIB = [ + { id: "steps", emoji: "🪜", name: "步骤分解", prompt: "面对复杂任务,先拆成 3-5 步列出,每步一句话,再逐步执行。" }, + { id: "rigor", emoji: "🧪", name: "严谨事实", prompt: "不确定时明确说 \"我不确定\",绝不编造引用或数据。宁缺毋滥。" }, + { id: "ask", emoji: "💬", name: "追问澄清", prompt: "信息不足时先问 1-2 个最关键的澄清问题,再动手。" }, + { id: "summary", emoji: "📋", name: "先摘要", prompt: "对长内容先给 3-5 条要点摘要,再根据需要展开。" }, + { id: "plain", emoji: "🎓", name: "通俗解释", prompt: "用大白话 + 类比解释技术概念,避免行话,除非用户明确是专业人士。" }, + { id: "structured", emoji: "📊", name: "结构化输出", prompt: "优先用编号列表或表格组织答案,让信息层次清晰。" }, + { id: "runnable", emoji: "🔧", name: "代码可运行", prompt: "给出的代码必须能直接运行,提供示例输入与预期输出。" }, + { id: "goal", emoji: "🎯", name: "目标导向", prompt: "回答前先确认用户真正想达成的目标,避免答非所问。" }, + { id: "bilingual", emoji: "🌐", name: "中英对照", prompt: "专业术语和关键词同时给中英双语。" }, + { id: "cite", emoji: "🔗", name: "给出出处", prompt: "引用事实或数据时,尽量给出可追溯的来源(即便是 \"来自官方文档\")。" }, + { id: "brief", emoji: "✂️", name: "言简意赅", prompt: "回答直接切重点,避免冗长铺垫和客套话。" }, + { id: "critic", emoji: "🧐", name: "批判思考", prompt: "遇到方案主动指出 2-3 个潜在风险或缺点,不要只报喜。" }, +]; + +function skillById(id) { + return state.customSkills?.[id] || SKILLS_LIB.find(s => s.id === id); +} +function allSkills() { + return [...SKILLS_LIB, ...Object.values(state.customSkills || {}).sort((a, b) => a.createdAt - b.createdAt)]; +} + +function loadCustomSkills() { + try { + const raw = localStorage.getItem(LS_CUSTOM_SKILLS); + if (raw) state.customSkills = JSON.parse(raw) || {}; + } catch (e) { state.customSkills = {}; } +} +function saveCustomSkillsToLS() { + localStorage.setItem(LS_CUSTOM_SKILLS, JSON.stringify(state.customSkills)); +} + +// ---------- Flows (Skill 编排) ---------- +const BUILTIN_FLOWS = [ + { id: "flow_research", emoji: "🧠", name: "研究型", desc: "深度调研,严谨出处,结构化呈现", + skillIds: ["steps", "rigor", "cite", "structured"] }, + { id: "flow_dev", emoji: "💻", name: "开发型", desc: "代码可运行,先定根因再修", + skillIds: ["steps", "runnable", "rigor", "critic"] }, + { id: "flow_writing", emoji: "✍️", name: "创作型", desc: "贴合受众,通俗清晰,有取舍", + skillIds: ["goal", "plain", "structured", "brief"] }, + { id: "flow_teaching", emoji: "🎓", name: "教学型", desc: "大白话 + 类比 + 拆步骤", + skillIds: ["plain", "steps", "brief"] }, + { id: "flow_product", emoji: "🎯", name: "产品型", desc: "目标导向,批判思考,风险前置", + skillIds: ["goal", "critic", "steps", "ask"] }, + { id: "flow_simple", emoji: "⚡", name: "快答型", desc: "切重点,不啰嗦", + skillIds: ["brief", "goal"] }, +]; + +function loadFlows() { + try { + const raw = localStorage.getItem(LS_FLOWS); + if (raw) state.flows = JSON.parse(raw) || {}; + } catch (e) { state.flows = {}; } + // 植入内建 flows(不覆盖用户自定义) + for (const f of BUILTIN_FLOWS) { + if (!state.flows[f.id]) state.flows[f.id] = { ...f, builtin: true }; + } + saveFlowsToLS(); +} +function saveFlowsToLS() { + localStorage.setItem(LS_FLOWS, JSON.stringify(state.flows)); +} +function sortedFlows() { + return Object.values(state.flows).sort((a, b) => { + if (a.builtin !== b.builtin) return a.builtin ? -1 : 1; + return (a.createdAt || 0) - (b.createdAt || 0); + }); +} + +// 解析一个 skill id -> prompt 片段 (支持 hermes:/builtin:/custom:/plain) +function resolveSkillText(id, opts = {}) { + if (!id) return ""; + const s = String(id); + if (s.startsWith("hermes:")) { + const path = s.slice(7); + const hs = (_hermesSkillIndex || []).find(x => x.path === path); + if (!hs) return `- [Hermes skill 未加载: ${path}]`; + const bodyLimit = opts.bodyLimit || 1200; + const body = (hs.body || "").substring(0, bodyLimit); + return `### ${hs.emoji || "🧩"} ${hs.name}\n${hs.description || ""}\n${body}`; + } + let shortId = s; + if (s.startsWith("builtin:")) shortId = s.slice(8); + else if (s.startsWith("custom:")) shortId = s.slice(7); + const sk = skillById(shortId); + return sk ? `- ${sk.emoji} ${sk.name}: ${sk.prompt}` : ""; +} + +async function ensureHermesSkillsLoaded(agentOrIds) { + // 如果 agent 含 hermes: 前缀的 id 或 stages 里有,才异步加载 + let ids = []; + if (Array.isArray(agentOrIds)) ids = agentOrIds; + else if (agentOrIds) { + ids = [...(agentOrIds.skills || []), + ...(agentOrIds.stages?.pre || []), + ...(agentOrIds.stages?.exec || []), + ...(agentOrIds.stages?.post || [])]; + } + if (ids.some(id => String(id).startsWith("hermes:")) && !_hermesSkillIndex) { + await fetchHermesSkillIndex(); + } +} + +function composeSystemPrompt(agent) { + if (!agent) return ""; + let sys = agent.systemPrompt || ""; + + // 优先走 stages 编排(3 阶段) + const st = agent.stages; + if (st && (st.pre?.length || st.exec?.length || st.post?.length)) { + const renderStage = (title, arr) => { + if (!arr?.length) return ""; + const parts = arr.map(id => resolveSkillText(id)).filter(Boolean); + return parts.length ? `\n\n## ${title}\n${parts.join("\n\n")}` : ""; + }; + sys += renderStage("阶段一 · 前置准备(理解目标 / 拆解)", st.pre); + sys += renderStage("阶段二 · 主要执行(工具调用 / 生成)", st.exec); + sys += renderStage("阶段三 · 收尾审查(自查 / 输出格式)", st.post); + return sys; + } + + // 回退: 扁平 skills 数组 + const skills = agent.skills || []; + if (skills.length) { + const parts = skills.map(id => resolveSkillText(id)).filter(Boolean); + if (parts.length) sys += "\n\n## 启用的技能\n" + parts.join("\n"); + } + return sys; +} + +// ---------- 智能体 ---------- +const DEFAULT_AGENTS = [ + { + emoji: "H", + name: "通用助手", + desc: "什么都能聊,默认的爱马仕,适合日常问答和闲聊。", + model: "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 = '
还没有智能体,点右上角"新建智能体"开始。
'; + return; + } + for (const a of list) { + const card = document.createElement("div"); + card.className = "agent-card"; + card.innerHTML = ` +
+
+
+
+
+
+
+
+
+
+ + + +
+ `; + card.querySelector(".agent-avatar").textContent = a.emoji || "🤖"; + card.querySelector(".agent-name").textContent = a.name; + card.querySelector(".agent-model").textContent = 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 = '+新建技能'; + 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 = '
没有智能体,先去新建一个。
'; + return; + } + for (const a of list) { + const chip = document.createElement("div"); + chip.className = "cluster-pick-chip" + (state.clusterPicked.has(a.id) ? " on" : ""); + chip.innerHTML = `${a.emoji}${escapeHTML(a.name)}`; + chip.onclick = () => { + if (state.clusterPicked.has(a.id)) state.clusterPicked.delete(a.id); + else state.clusterPicked.add(a.id); + renderClusterPick(); + }; + wrap.appendChild(chip); + } +} +async function runCluster() { + const prompt = document.getElementById("clusterPrompt").value.trim(); + if (!prompt) { toast("请输入问题"); return; } + if (state.clusterPicked.size === 0) { toast("至少选一个智能体"); return; } + const results = document.getElementById("clusterResults"); + results.innerHTML = ""; + const picked = [...state.clusterPicked].map(id => state.agents[id]).filter(Boolean); + const cols = {}; + for (const a of picked) { + const col = document.createElement("div"); + col.className = "cluster-col"; + col.innerHTML = ` +
+
+
+
运行中
+
+
+ `; + col.querySelector(".cluster-col-avatar").textContent = a.emoji; + col.querySelector(".cluster-col-name").textContent = a.name; + results.appendChild(col); + cols[a.id] = col; + } + await Promise.all(picked.map(a => runClusterOne(a, prompt, cols[a.id]))); +} +async function runClusterOne(agent, prompt, col) { + const status = col.querySelector(".cluster-col-status"); + const body = col.querySelector(".cluster-col-body"); + try { + await ensureHermesSkillsLoaded(agent); + const messages = [ + { role: "system", content: composeSystemPrompt(agent) }, + { role: "user", content: prompt }, + ]; + const 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, ">"); + + // 3. 标题 + text = text.replace(/^###### (.+)$/gm, "
$1
"); + text = text.replace(/^##### (.+)$/gm, "
$1
"); + text = text.replace(/^#### (.+)$/gm, "

$1

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

$1

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

$1

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

$1

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

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

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

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

智能体

+

创建不同角色的 AI 助手,单独对话或组成集群并行协作。

+
+
+ + + +
+
+
+
+
+ + + + + + + + +
+
+
+
+

Skill Studio

+

技能库 + 编排画布 · 接入 Hermes 真实 skill(75 个,26 类) + 前端 prompt 技能

+
+
+ + + + +
+
+
+ +
+ +
+
+
+ + + +
+ +
+
+
加载中…
+
+
+ + +
+
+ +
+
🎯
+ + +
+
+
+
+
+ 1 +
前置 · Prep
+
理解目标 / 拆解任务 / 澄清
+
+
+
+
+
+
+ 2 +
执行 · Execute
+
主任务 / 工具调用 / 生成内容
+
+
+
+
+
+
+ 3 +
收尾 · Review
+
自查 / 格式化输出 / 风险提示
+
+
+
+
+
+ + +
+
+
详情预览
+
+
+
点左侧技能查看详情 · 点"+ 加入编排"放入画布
+
+
+
+
+ + +
+
+
+
+

定时任务

+

直接对接 Hermes 后端 /api/jobs,所有创建/更新/删除都是真实后端操作

+
+
+ + +
+
+
+
+
加载中…
+
+
+ + + + + +
+
+
+
+

记忆

+

Hermes 后端 SOUL.md + 会话历史快照 · 每分钟自动同步

+
+
+ +
+
+
+
+
+
SOUL.md · 人格文档
+
加载中…
+
+
+
会话历史 · 0
+
加载中…
+
+
+
+ + +
+
+
+
+

工具集

+

Hermes 已注册工具包 · hermes tools list 输出 · 每分钟同步

+
+
+ +
+
+
+
加载中…
+
+ + +
+
+
+
+

异步任务

+

POST /v1/runs 启动长任务,SSE 实时事件流展示 agent 调工具/思考/结果

+
+
+ + +
+
+
+ +
+
+
任务指令
+ +
长任务 / 带工具调用 / 需要流式看中间步骤时用这个。短问题用对话 tab 即可。
+
+
+
+
+ 事件流 + 未启动 +
+
+
点「运行」启动一个任务,事件会实时显示在这里
+
+
+
+
+ + +
+
+
+
+

官方 Console

+

内嵌 Hermes 官方 Web UI(React + Vite 构建),7 个页面含完整架构文档 / 技能 / 记忆 / 工具 / 定时任务说明

+
+ +
+
+ +
+ + +
+
+
+

🏗 总体架构

+

爱马仕 UI 是 纯前端 SPA(无构建),通过 nginx 反代对接 Hermes Agent 后端。Hermes 后端部署在 Incus LXC 容器里,内嵌 Docker 容器跑 Python Gateway,暴露 OpenAI 兼容的 HTTP API。

+
浏览器
+ └─ https://hermes.kang-kang.com/
+ └─ 宿主 nginx (cookie 门禁 + Bearer 注入)
+    ├─ /                 静态 UI (index.html + app.js + styles.css)
+    ├─ /_auth/verify     htpasswd 登录(kang)
+    ├─ /v1/*             → Hermes API (LXC incusbr0:8642)
+    ├─ /api/jobs*        → 定时任务 CRUD
+    ├─ /hermes-skills/   真实 78 个 SKILL.md(docker cp 快照)
+    └─ /memory/          SOUL.md + sessions + tools.txt(systemd timer 每 1 分钟同步)
+
+ +
+

🔌 各 tab 对应的后端调用

+ + + + + + + + + + + + + +
Tab调用数据源
对话POST /v1/chat/completions (SSE 流式)实时 · 前端 localStorage 存会话
研究跳回对话 + 预填 prompt纯前端
Agent前端 CRUD + system prompt 组装localStorage (hermes-ui-agents-v1)
Skill StudioGET /hermes-skills/ autoindex JSONdocker cp 快照 (78 个真实 SKILL.md)
定时任务GET/POST/PATCH/DELETE /api/jobsHermes 后端实时
记忆GET /memory/SOUL.md + /memory/sessions.json + /memory/sessions/{id}.jsonsystemd timer 每 1 分钟 sync
工具GET /memory/tools.txthermes tools list 输出每分钟刷新
异步任务POST /v1/runs + EventSource /v1/runs/{id}/events实时 SSE
仪表盘GET /v1/models + localStorage 聚合实时 + 本地
+
+ +
+

🔐 认证与安全

+
    +
  • Cookie 门禁: hermes_auth=ok,HttpOnly + Secure + 24h 有效
  • +
  • 登录流程: 前端 login.html → fetch /_auth/verify Basic Auth → nginx 校验 htpasswd → 返回 Set-Cookie
  • +
  • Bearer 注入: nginx 在 proxy_pass 时自动加 Authorization: Bearer ...,浏览器永远看不到真 API key
  • +
  • 用户: kang(bcrypt 存在 /etc/nginx/.htpasswd-hermes-kang)
  • +
+
+ +
+

🧠 Skill Studio 编排原理

+

不是真的在后端运行编排,而是前端组装复合 system prompt:

+
Agent.stages.pre    → 阶段一 · 前置(理解目标)
+Agent.stages.exec   → 阶段二 · 执行(工具调用 / 生成)
+Agent.stages.post   → 阶段三 · 收尾(自查 / 格式化)
+
+每个 stage 把选中的 skill 文本拼起来
+→ 作为 messages[0] system 发给 /v1/chat/completions
+→ Gemini 3 Pro 按阶段指令输出
+

真实 Hermes skill(SKILL.md)会把前 1200 字节正文注入 prompt,内建 skill 是简短的 prompt 片段。

+
+ +
+

⚙ 部署 / 更新

+
# 本机改 src/ 后
+cd ~/Projects/code/20260421-hermes-glass-ui-personal
+
+# 同步个人 VPS
+rsync -az src/ root@76.13.31.179:/var/www/hermes-kang/
+
+# Git
+git push  # Gitea kangwan/hermes-glass-ui-personal
+
+# 如果改了 JS/CSS 要让浏览器刷新,记得 bump sw.js 的 CACHE 版本号
+
+ +
+

🛠 常见问题

+
    +
  • 新改的东西没显示 → Service Worker 缓存顽固,bump sw.jsCACHE 版本号,再 F12 → Application → Unregister
  • +
  • VPS 后端换模型 → 改容器内 /opt/hermes-agent/config.yaml + .env,docker restart hermes-agent
  • +
  • 记忆 tab 看不到最新会话 → systemd timer 1 分钟一次,急的话 ssh root@76.13.31.179 /usr/local/bin/sync-hermes-memory.sh
  • +
  • Cron 任务不触发 → 看 Hermes 容器日志 docker logs hermes-agent
  • +
+
+ +
+

📦 技术栈

+
    +
  • 前端:纯 HTML + CSS + JS,无构建,无任何 npm 依赖,约 120KB app.js + 75KB styles.css
  • +
  • 风格:Apple Liquid Glass(iOS/macOS 26 风格)+ Hermès 橙 #FF6900
  • +
  • 后端对接:OpenAI 兼容 HTTP + SSE,不走 WebSocket
  • +
  • 持久化:前端 localStorage(6 个 key),后端 Hermes session DB
  • +
  • PWA:manifest + service worker,可装 Dock App(macOS 本地版)
  • +
+
+
+
+ + +
+
+

研究

+

爱马仕能帮你深度调研、解读文档、调用工具。

+
+
+
+
🔍
+
深度研究
+
给一个主题,自动拆解 → 搜索 → 总结 → 输出报告。
+ +
+
+
📄
+
文档问答
+
粘贴长文档或 URL,就文档内容提问。
+ +
+
+
🛠
+
工具调用
+
浏览器 / 文件 / 终端 — Hermes 的工具集通过后端执行。
+ +
+
+
🧠
+
记忆体系
+
对话历史沉淀进 SQLite FTS5,未来 skill 可自动提炼。
+ +
+
+
+ + +
+
+
+
+

仪表盘

+

用量、系统状态和实时日志。

+
+
+ +
+
+
+ +
+ + +
+
+
今日 Token 消耗
+
0
+
+ 0 条消息 + · + 0 个对话 +
+
+
+
+
0
+
本周消息
+
0 tokens
+
+
+
0
+
本月消息
+
0 tokens
+
+
+
0
+
总对话
+
0 条消息
+
+
+
+ + +
+
+
+ +
+
+
API 状态
+
+
127.0.0.1:8642
+
+
+
+
+ +
+
+
当前模型
+
Gemini 3 Pro
+
Google AI Studio
+
+
+
+
+ +
+
+
本机地址
+
+
Mac mini M4
+
+
+
+
+ +
+
+
代理
+
Clash
+
127.0.0.1:7897
+
+
+
+
+ +
+
+
智能体
+
0
+
已创建
+
+
+
+
+ +
+
+
存储占用
+
+
localStorage
+
+
+
+ + +
+
+
+
最常使用的智能体
+
还未使用智能体
+
创建一个智能体开始对话
+
+
+ + + +
+
+
+ +
+
+
+
+
+ +
+
+ + + +
+
点上面热力图中的任意一天查看详情
+
+ +
+
+ + +
+
+

设置

+

调整连接、偏好、数据和外观。

+
+ +
+ + +
+
+
+ +
+
+
连接
+
爱马仕后端的 API 地址和密钥
+
+
+
+
+ + +
Hermes API Server 的 OpenAI 兼容端点。本地默认 /api/v1(nginx 反代到 127.0.0.1:8642)
+
+
+ + +
任意字符串,只要和 Hermes .env 里的 API_SERVER_KEY 一致
+
+
+
+ + +
+
+
+ +
+
+
对话偏好
+
控制消息发送行为
+
+
+
+
+
+ +
打开后 AI 边生成边显示,更接近打字机效果
+
+ +
+
+
+ + +
+
+
+ +
+
+
外观
+
主题、字体、动画
+
+
+
+
+
+ +
左下角"明亮/暗色"按钮切换
+
+ +
+
+
+ + +
+
+
+ +
+
+
数据
+
导出、导入、清空本地数据
+
+
+
+
+ + + + +
+
+
+ + +
+
+
+ +
+
+
关于
+
爱马仕 · AI
+
+
+
+
+
+
版本
+
v0.2 · Liquid Glass
+
+
+
运行于
+
Mac mini M4 · macOS 26.3
+
+
+
模型
+
Gemini 3 Pro · Google AI Studio
+
+
+
代理
+
Clash Verge · 127.0.0.1:7897
+
+
+
+
+ +
+ +
+ +
+
+ +
+
+ + + + diff --git a/src/login.html b/src/login.html new file mode 100644 index 0000000..63e65e5 --- /dev/null +++ b/src/login.html @@ -0,0 +1,508 @@ + + + + + + +爱马仕 AI · 登录 + + + + + +
+
+
+
+
+
+
+ +
+
+ + +
+
+ HERMÈS + PARIS +
+ +
+
YOUR PRIVATE
+
爱马仕
+
你的私人 AI 助手
+
+ 多智能体 · Skill 编排 · 75 真实 Hermes 技能库
+ 私密对话不外发 · 24 小时单点登录 +
+
+ +
+ Kang · 个人版 + Gemini 3 Pro + v0.3 +
+
+ + +
+
欢迎回来
+
请输入你的账号和密码继续
+ +
+
+ + +
+
+ + +
+
账号或密码错误
+ +
+ +
+ +
+
+ +
+
+ + + + + diff --git a/src/manifest.webmanifest b/src/manifest.webmanifest new file mode 100644 index 0000000..c802231 --- /dev/null +++ b/src/manifest.webmanifest @@ -0,0 +1,15 @@ +{ + "name": "爱马仕 Hermes", + "short_name": "Hermes", + "description": "私人 AI 助手 · Liquid Glass UI · Gemini 3 Pro", + "start_url": "./", + "display": "standalone", + "orientation": "any", + "background_color": "#0a0f1e", + "theme_color": "#0a0f1e", + "icons": [ + { "src": "./icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }, + { "src": "./icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "./icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } + ] +} diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..e89befe --- /dev/null +++ b/src/styles.css @@ -0,0 +1,3232 @@ +/* ========================================================================= + 爱马仕 · AI — Hermès Orange + Liquid Glass + 风格库: ~/Projects/research/20260305-网页风格库/13-Liquid-Glass.md + 主色: Hermès Orange #FF6900 + ========================================================================= */ + +* { box-sizing: border-box; } + +:root { + --orange: #ff6900; + --orange-2: #ff8830; + --orange-3: #ffa85a; + --orange-dim: rgba(255,105,0,0.35); + --orange-soft: rgba(255,105,0,0.10); + + /* 暗色(默认) */ + --bg-0: #0c0e12; + --bg-1: #141720; + --bg-2: #1c2030; + + --aurora-a: #1a2040; + --aurora-b: #241a36; + --blob-1: #3b6eff; + --blob-2: #6b3dff; + --blob-3: #ff6900; + --blob-4: #2b9efc; + --blob-opa: 0.5; + + --panel-bg: rgba(255,255,255,0.04); + --panel-border: rgba(255,255,255,0.10); + --panel-border-strong: rgba(255,255,255,0.18); + --panel-shadow: 0 10px 40px rgba(0,0,0,0.4); + --panel-inset: inset 0 1px 0 rgba(255,255,255,0.12), inset 0 -1px 0 rgba(255,255,255,0.04); + + --text: #f5f6f8; + --text-dim: rgba(245,246,248,0.72); + --text-dim2: rgba(245,246,248,0.48); + --text-dim3: rgba(245,246,248,0.28); + + --line: rgba(255,255,255,0.10); + --line-strong: rgba(255,255,255,0.18); + + --card-bg: rgba(255,255,255,0.03); + --card-hover-bg: rgba(255,255,255,0.06); + --input-bg: rgba(0,0,0,0.25); + --msg-user-avatar: rgba(255,255,255,0.12); + + --ok: #6ef08d; + --warn: #ffc83a; + --err: #ff5d7a; +} + +/* 明亮色 */ +[data-theme="light"] { + --bg-0: #f5f6fa; + --bg-1: #eaecf3; + --bg-2: #dfe3ee; + + --aurora-a: #e0e7ff; + --aurora-b: #f1e5ff; + --blob-1: #93b9ff; + --blob-2: #c9a8ff; + --blob-3: #ffb57a; + --blob-4: #9fd4ff; + --blob-opa: 0.55; + + --panel-bg: rgba(255,255,255,0.65); + --panel-border: rgba(15,22,40,0.08); + --panel-border-strong: rgba(15,22,40,0.14); + --panel-shadow: 0 10px 40px rgba(60,80,120,0.18); + --panel-inset: inset 0 1px 0 rgba(255,255,255,0.9), inset 0 -1px 0 rgba(255,255,255,0.35); + + --text: #141a24; + --text-dim: rgba(20,26,36,0.70); + --text-dim2: rgba(20,26,36,0.48); + --text-dim3: rgba(20,26,36,0.30); + + --line: rgba(15,22,40,0.08); + --line-strong: rgba(15,22,40,0.14); + + --card-bg: rgba(255,255,255,0.55); + --card-hover-bg: rgba(255,255,255,0.8); + --input-bg: rgba(255,255,255,0.7); + --msg-user-avatar: rgba(15,22,40,0.08); +} + +html, body { + margin: 0; + padding: 0; + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "PingFang SC", "Helvetica Neue", Arial, sans-serif; + color: var(--text); + background: var(--bg-0); + overflow: hidden; + -webkit-font-smoothing: antialiased; +} + +a { color: var(--orange-3); text-decoration: none; } + +/* ========== Aurora 背景 ========== */ +.bg-aurora { + position: fixed; + inset: 0; + z-index: -2; + background: + radial-gradient(ellipse at 15% 15%, var(--aurora-a) 0%, var(--bg-0) 55%), + radial-gradient(ellipse at 85% 85%, var(--aurora-b) 0%, transparent 60%); + overflow: hidden; + transition: background 0.4s ease; +} +.blob { + position: absolute; + border-radius: 50%; + filter: blur(80px); + opacity: var(--blob-opa); + mix-blend-mode: screen; + animation: float 60s ease-in-out infinite; + transition: background 0.4s ease; +} +@media (prefers-reduced-motion: reduce) { + .blob { animation: none; } +} +.blob-1 { width: 520px; height: 520px; top: -120px; left: -80px; background: var(--blob-1); } +.blob-2 { width: 620px; height: 620px; bottom: -180px; right: -120px; background: var(--blob-2); animation-delay: -15s; } +.blob-3 { display: none; } +.blob-4 { display: none; } + +[data-theme="light"] .blob { mix-blend-mode: multiply; opacity: 0.35; } +[data-theme="light"] body { background: var(--bg-0); } + +@keyframes float { + 0%, 100% { transform: translate3d(0, 0, 0) scale(1); } + 25% { transform: translate3d(60px, -40px, 0) scale(1.08); } + 50% { transform: translate3d(-30px, 50px, 0) scale(0.95); } + 75% { transform: translate3d(40px, 30px, 0) scale(1.05); } +} + +/* ========== 主布局 ========== */ +.app-shell { + display: flex; + height: 100vh; + width: 100vw; + padding: 14px; + gap: 14px; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +/* ========== 侧栏 (磨砂玻璃) ========== */ +.sidebar { + width: clamp(200px, 20vw, 250px); + flex: 0 0 auto; + display: flex; + flex-direction: column; + padding: 18px 14px; + background: var(--panel-bg); + border: 1px solid var(--panel-border); + border-radius: 22px; + backdrop-filter: blur(22px) saturate(1.3); + -webkit-backdrop-filter: blur(22px) saturate(1.3); + box-shadow: var(--panel-shadow), var(--panel-inset); + transition: background 0.3s; + min-height: 0; + overflow: hidden; +} + +.side-brand { + display: flex; + align-items: center; + gap: 10px; + padding: 4px 6px 18px; + border-bottom: 1px solid var(--line); + margin-bottom: 14px; +} +.hermes-tag { + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 6px 11px 7px; + background: var(--orange); + color: #111; + border-radius: 4px; + line-height: 1; + letter-spacing: 1.8px; + font-family: "Didot", "Bodoni 72", "SF Pro Display", -apple-system, serif; + font-weight: 700; + box-shadow: 0 2px 14px rgba(255,105,0,0.55); +} +.hermes-tag-top { font-size: 11px; } +.hermes-tag-mid { font-size: 7px; margin-top: 2px; opacity: 0.8; letter-spacing: 1px; } +.side-brand-text { + font-size: 13px; + font-weight: 600; + color: var(--text); + letter-spacing: 0.3px; +} + +.side-nav { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; +} +.side-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + border: 0; + background: transparent; + color: var(--text-dim); + font-size: 14px; + font-weight: 600; + font-family: inherit; + cursor: pointer; + border-radius: 12px; + transition: all 0.2s; + text-align: left; +} +.side-item svg { + width: 18px; + height: 18px; + flex: 0 0 18px; +} +.side-item:hover { + background: rgba(255,180,120,0.08); + color: var(--text); +} +.side-item.active { + background: linear-gradient(135deg, var(--orange), var(--orange-2)); + color: #1a0f08; + box-shadow: 0 4px 14px rgba(255,105,0,0.35), inset 0 1px 0 rgba(255,255,255,0.35); +} + +/* 侧栏搜索 */ +.side-search { + position: relative; + display: flex; + align-items: center; + gap: 8px; + margin-top: 10px; + padding: 9px 12px 9px 34px; + background: rgba(255,255,255,0.04); + border: 1px solid var(--line); + border-radius: 10px; + flex: 0 0 auto; + transition: border-color 0.2s; +} +[data-theme="light"] .side-search { background: rgba(15,22,40,0.04); } +.side-search:focus-within { border-color: var(--orange-dim); background: rgba(255,105,0,0.06); } +.side-search svg { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + width: 14px; + height: 14px; + color: var(--text-dim2); + pointer-events: none; +} +.side-search input { + flex: 1; + background: transparent; + border: 0; + outline: none; + color: var(--text); + font-size: 12.5px; + font-family: inherit; + min-width: 0; +} +.side-search input::placeholder { color: var(--text-dim3); } +.search-clear { + background: transparent; + border: 0; + color: var(--text-dim2); + font-size: 16px; + line-height: 1; + cursor: pointer; + padding: 0 4px; + border-radius: 4px; +} +.search-clear:hover { color: var(--text); } + +/* 会话历史列表 */ +.side-history { + flex: 1 1 auto; + min-height: 0; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--line); + display: flex; + flex-direction: column; + gap: 2px; + overflow-y: auto; +} +.side-history::-webkit-scrollbar { width: 4px; } +.side-history::-webkit-scrollbar-thumb { background: var(--line-strong); border-radius: 2px; } +.side-history-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-dim3); + font-weight: 700; + padding: 6px 10px 8px; +} +.history-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border: 0; + background: transparent; + color: var(--text-dim); + font-size: 13px; + font-family: inherit; + cursor: pointer; + border-radius: 10px; + transition: all 0.15s; + text-align: left; + position: relative; +} +.history-item:hover { background: rgba(255,255,255,0.06); color: var(--text); } +[data-theme="light"] .history-item:hover { background: rgba(15,22,40,0.06); } +.history-item.active { + background: rgba(255,105,0,0.12); + color: var(--text); + box-shadow: inset 2px 0 0 var(--orange); +} +.history-title { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 500; +} +.history-del { + flex: 0 0 auto; + opacity: 0; + background: transparent; + border: 0; + color: var(--text-dim2); + font-size: 14px; + line-height: 1; + cursor: pointer; + padding: 2px 4px; + border-radius: 4px; + transition: opacity 0.15s; +} +.history-item:hover .history-del { opacity: 1; } +.history-del:hover { color: var(--err); background: rgba(255,93,122,0.12); } +.history-empty { + padding: 12px 10px; + font-size: 12px; + color: var(--text-dim3); + text-align: center; +} +.history-item { flex-direction: column; align-items: stretch; gap: 4px; } +.history-item-main { display: flex; align-items: center; gap: 8px; } +.history-item-main .history-title { flex: 1; min-width: 0; } +.history-item-tags { + display: flex; + gap: 4px; + flex-wrap: wrap; + padding-left: 2px; +} +.history-tag { + font-size: 9px; + padding: 1px 6px; + background: rgba(255,105,0,0.14); + color: var(--orange-3); + border-radius: 6px; + border: 1px solid rgba(255,105,0,0.25); + font-weight: 600; +} +.history-act { + display: flex; + gap: 2px; + margin-left: 4px; + opacity: 0; + transition: opacity 0.15s; +} +.history-item:hover .history-act { opacity: 1; } +.history-act button { + background: transparent; + border: 0; + padding: 2px 4px; + color: var(--text-dim2); + cursor: pointer; + border-radius: 4px; + line-height: 1; + font-size: 14px; +} +.history-act button:hover { color: var(--text); background: rgba(255,255,255,0.08); } +[data-theme="light"] .history-act button:hover { background: rgba(15,22,40,0.08); } +.history-act button.danger:hover { color: var(--err); background: rgba(255,93,122,0.12); } + +/* Emoji / 品牌图标 选择器 */ +.modal-md { max-width: 560px; } +.emoji-input-wrap { + display: flex; + align-items: center; + gap: 12px; + flex: 1; +} +.emoji-preview { + width: 48px; height: 48px; + border-radius: 12px; + background: linear-gradient(135deg, rgba(255,105,0,0.2), rgba(255,136,48,0.08)); + border: 1px solid var(--line-strong); + display: flex; + align-items: center; + justify-content: center; + font-size: 26px; + cursor: pointer; + transition: transform 0.15s; + flex: 0 0 48px; + overflow: hidden; +} +.emoji-preview:hover { transform: scale(1.05); border-color: var(--orange); } +.emoji-preview svg { width: 100%; height: 100%; display: block; } + +.emoji-section-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-dim2); + font-weight: 700; + padding: 4px 2px 10px; +} +.emoji-search-wrap { margin-bottom: 12px; } +.emoji-search-wrap input { + width: 100%; + background: var(--input-bg); + border: 1px solid var(--line-strong); + color: var(--text); + padding: 9px 14px; + border-radius: 10px; + font-family: inherit; + font-size: 13px; + outline: none; +} +.emoji-search-wrap input:focus { border-color: var(--orange); } + +.emoji-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); + gap: 6px; + max-height: 240px; + overflow-y: auto; + margin-bottom: 14px; + padding-right: 4px; +} +.emoji-cell { + aspect-ratio: 1; + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; + border: 1px solid transparent; + border-radius: 8px; + cursor: pointer; + transition: all 0.15s; + background: transparent; + overflow: hidden; +} +.emoji-cell:hover { + background: rgba(255,105,0,0.12); + border-color: var(--orange-dim); + transform: scale(1.08); +} +.emoji-cell svg { width: 80%; height: 80%; display: block; } +.brand-grid .emoji-cell { + background: var(--card-bg); + border: 1px solid var(--line); + aspect-ratio: 1.1; + font-size: 18px; + font-weight: 800; + font-family: "Didot", "Bodoni 72", serif; +} +.brand-grid .emoji-cell.brand-hermes { + background: var(--orange); + color: #1a0f08; + border-color: var(--orange); +} +.brand-grid .emoji-cell.brand-milejoy { + background: linear-gradient(135deg, #1a4fff, #3b6eff); + color: #fff; + border-color: #3b6eff; + font-family: "SF Pro Display", -apple-system, sans-serif; +} +.brand-grid { + grid-template-columns: repeat(auto-fill, minmax(60px, 1fr)); +} + +/* Skills picker */ +.skills-picker { + display: flex; + flex-wrap: wrap; + gap: 8px; + width: 100%; +} +.skill-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 13px; + border: 1px solid var(--line-strong); + background: var(--card-bg); + color: var(--text-dim); + border-radius: 10px; + cursor: pointer; + font-size: 12px; + font-weight: 600; + font-family: inherit; + transition: all 0.2s; + user-select: none; +} +.skill-chip:hover { color: var(--text); background: var(--card-hover-bg); } +.skill-chip.on { + background: rgba(255,105,0,0.14); + border-color: var(--orange-dim); + color: var(--orange-3); + box-shadow: inset 0 0 0 1px rgba(255,105,0,0.25); +} +.skill-chip .skill-ico { font-size: 14px; line-height: 1; } +.skill-chip.custom { + border-style: dashed; +} +.skill-chip.custom.on { + border-style: solid; +} +.skill-chip .skill-edit { + margin-left: 4px; + padding: 0 4px; + font-size: 11px; + color: var(--text-dim2); + border-radius: 4px; + transition: all 0.15s; +} +.skill-chip .skill-edit:hover { color: var(--orange); background: rgba(255,105,0,0.15); } +.skill-chip.skill-add { + border-style: dashed; + color: var(--text-dim2); + background: transparent; +} +.skill-chip.skill-add:hover { + color: var(--orange-3); + border-color: var(--orange-dim); + background: rgba(255,105,0,0.08); +} +.skill-chip.skill-add .skill-ico { font-weight: 800; } + +/* Skill 选中状态下的序号 + 上下移动 */ +.skill-chip.on .skill-order { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + background: var(--orange); + color: #1a0f08; + border-radius: 50%; + font-size: 10px; + font-weight: 800; + margin-right: 2px; +} +.skill-chip .skill-order { display: none; } +.skill-chip .skill-move { + display: none; + gap: 1px; + margin-left: 4px; +} +.skill-chip.on .skill-move { + display: inline-flex; +} +.skill-chip .skill-move button { + background: transparent; + border: 0; + color: var(--text-dim2); + font-size: 10px; + width: 14px; + height: 14px; + line-height: 1; + cursor: pointer; + padding: 0; + border-radius: 3px; +} +.skill-chip .skill-move button:hover { color: var(--orange); background: rgba(255,105,0,0.15); } + +/* Skill 分区标题 */ +.skills-section-label { + flex: 1 0 100%; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-dim3); + font-weight: 700; + padding: 6px 2px 2px; + margin: 0; +} +.skills-section-label:first-child { padding-top: 0; } + +/* Flow (编排) */ +.flow-intro { + font-size: 12px; + color: var(--text-dim); + line-height: 1.6; + padding: 10px 14px; + background: rgba(255,105,0,0.06); + border: 1px solid rgba(255,105,0,0.18); + border-radius: 10px; + margin-bottom: 14px; +} +.flow-toolbar { + display: flex; + justify-content: flex-end; + margin-bottom: 12px; +} +.flow-list, .flow-apply-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 12px; +} +.flow-card { + padding: 14px 16px; + background: var(--card-bg); + border: 1px solid var(--line); + border-radius: 14px; + transition: all 0.2s; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 8px; + /* 卡片层不再单独磨砂 */ +} +.flow-card:hover { + background: var(--card-hover-bg); + border-color: var(--orange-dim); + transform: translateY(-2px); +} +.flow-card-head { + display: flex; + align-items: center; + gap: 10px; +} +.flow-card-emoji { + width: 36px; height: 36px; + border-radius: 10px; + background: linear-gradient(135deg, rgba(255,105,0,0.22), rgba(255,136,48,0.08)); + border: 1px solid var(--line-strong); + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + flex: 0 0 36px; +} +.flow-card-meta { flex: 1; min-width: 0; } +.flow-card-name { font-size: 14px; font-weight: 700; color: var(--text); } +.flow-card-desc { font-size: 11px; color: var(--text-dim2); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.flow-card-skills { + display: flex; + flex-wrap: wrap; + gap: 4px; +} +.flow-card-skills .mini-skill { + font-size: 10px; + padding: 2px 7px; + background: rgba(255,105,0,0.1); + color: var(--orange-3); + border: 1px solid rgba(255,105,0,0.25); + border-radius: 5px; + font-weight: 600; +} +.flow-card-actions { + display: flex; + gap: 6px; + margin-top: 4px; + padding-top: 8px; + border-top: 1px solid var(--line); +} +.flow-card-actions button { + flex: 1; + padding: 6px 8px; + font-size: 11px; + font-weight: 600; + border: 1px solid var(--line-strong); + background: transparent; + color: var(--text-dim); + border-radius: 7px; + cursor: pointer; + font-family: inherit; +} +.flow-card-actions button:hover { color: var(--text); background: var(--card-hover-bg); } +.flow-card-actions button.danger:hover { color: var(--err); border-color: rgba(255,93,122,0.4); background: rgba(255,93,122,0.08); } +.flow-card.builtin .flow-card-actions button.edit, .flow-card.builtin .flow-card-actions button.danger { display: none; } +.flow-card.builtin::after { + content: "内建"; + position: absolute; + top: 12px; right: 12px; + font-size: 9px; + padding: 1px 6px; + background: rgba(255,255,255,0.08); + color: var(--text-dim2); + border-radius: 4px; + font-weight: 700; +} +.flow-card { position: relative; } + +/* ========== Skill Studio (全屏 3 栏编排) ========== */ +.studio-grid { + flex: 1 1 auto; + min-height: 0; + display: grid; + grid-template-columns: 280px minmax(0, 1fr) 320px; + gap: 14px; + overflow: hidden; +} +@media (max-width: 1200px) { + .studio-grid { grid-template-columns: 240px minmax(0, 1fr); } + .studio-preview-col { display: none; } +} +@media (max-width: 900px) { + .studio-grid { grid-template-columns: 1fr; grid-template-rows: auto 1fr; overflow: auto; } + .studio-library { max-height: 260px; } +} + +.studio-col { + display: flex; + flex-direction: column; + background: var(--card-bg); + border: 1px solid var(--line); + border-radius: 16px; + overflow: hidden; + min-height: 0; + min-width: 0; +} +.studio-col-head { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 14px; + border-bottom: 1px solid var(--line); + flex: 0 0 auto; + flex-wrap: wrap; +} +.studio-tabs { + display: flex; + gap: 2px; + flex: 1; + min-width: 0; + overflow-x: auto; +} +.studio-tab { + padding: 6px 11px; + background: transparent; + border: 0; + color: var(--text-dim2); + font-size: 11px; + font-weight: 700; + font-family: inherit; + cursor: pointer; + border-radius: 8px; + white-space: nowrap; +} +.studio-tab:hover { color: var(--text); background: rgba(255,255,255,0.06); } +.studio-tab.active { color: #1a0f08; background: var(--orange); } +.studio-search { + background: var(--input-bg); + border: 1px solid var(--line-strong); + color: var(--text); + border-radius: 8px; + padding: 6px 10px; + font-size: 11px; + font-family: inherit; + outline: none; + width: 100%; + margin-top: 4px; +} +.studio-search:focus { border-color: var(--orange); } + +.studio-list { + flex: 1 1 auto; + overflow-y: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 4px; +} +.studio-cat { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.8px; + color: var(--text-dim3); + font-weight: 800; + padding: 10px 8px 4px; +} +.studio-skill-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border: 1px solid transparent; + border-radius: 8px; + cursor: pointer; + transition: all 0.15s; + background: transparent; +} +.studio-skill-item:hover { + background: rgba(255,255,255,0.05); + border-color: var(--line); +} +.studio-skill-item.active { + background: rgba(255,105,0,0.12); + border-color: var(--orange-dim); +} +.studio-skill-icon { + width: 26px; height: 26px; + flex: 0 0 26px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + border-radius: 7px; + background: linear-gradient(135deg, rgba(255,105,0,0.2), rgba(255,136,48,0.06)); + border: 1px solid var(--line-strong); +} +.studio-skill-meta { flex: 1; min-width: 0; } +.studio-skill-name { + font-size: 12.5px; + font-weight: 700; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.studio-skill-desc { + font-size: 10.5px; + color: var(--text-dim2); + margin-top: 1px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.studio-skill-src { + font-size: 9px; + padding: 1px 6px; + background: rgba(255,255,255,0.06); + color: var(--text-dim3); + border-radius: 4px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +/* 画布 */ +.studio-canvas-col .studio-col-head { + flex-wrap: wrap; + gap: 6px; +} +.studio-flow-title { + flex: 1 1 200px; + background: transparent; + border: 0; + color: var(--text); + font-size: 16px; + font-weight: 800; + font-family: inherit; + outline: none; + padding: 4px 0; +} +.studio-flow-title::placeholder { color: var(--text-dim3); } +.studio-flow-meta { + display: flex; + align-items: center; + gap: 8px; + flex: 1 1 100%; +} +.studio-flow-emoji { width: 34px !important; height: 34px !important; font-size: 18px !important; flex: 0 0 34px !important; } +.studio-desc { + flex: 1; + background: var(--input-bg); + border: 1px solid var(--line-strong); + border-radius: 8px; + color: var(--text); + padding: 6px 10px; + font-size: 11px; + font-family: inherit; + outline: none; +} +.studio-desc:focus { border-color: var(--orange); } + +.studio-canvas { + flex: 1 1 auto; + overflow-y: auto; + padding: 18px 22px; + display: flex; + flex-direction: column; + align-items: stretch; +} +.studio-stage { + background: rgba(255,255,255,0.03); + border: 1px dashed var(--line-strong); + border-radius: 14px; + padding: 14px 18px 16px; + min-height: 100px; + transition: border-color 0.15s, background 0.15s; +} +.studio-stage.drop-hover { + border-color: var(--orange); + background: rgba(255,105,0,0.08); +} +.studio-stage-head { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 10px; +} +.studio-stage-badge { + width: 26px; height: 26px; + border-radius: 50%; + background: var(--orange); + color: #1a0f08; + display: flex; + align-items: center; + justify-content: center; + font-weight: 800; + font-size: 12px; + flex: 0 0 26px; +} +.studio-stage-name { font-size: 14px; font-weight: 800; color: var(--text); } +.studio-stage-desc { font-size: 11px; color: var(--text-dim2); flex: 1; text-align: right; } +.studio-stage-slots { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-height: 40px; + padding: 6px; + background: rgba(0,0,0,0.15); + border-radius: 10px; +} +[data-theme="light"] .studio-stage-slots { background: rgba(15,22,40,0.04); } +.studio-slot-empty { + font-size: 11px; + color: var(--text-dim3); + padding: 8px 12px; + font-style: italic; +} +.studio-slot-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + background: rgba(255,105,0,0.15); + border: 1px solid var(--orange-dim); + color: var(--orange-3); + border-radius: 8px; + font-size: 11px; + font-weight: 700; + cursor: grab; +} +.studio-slot-chip .slot-order { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; height: 16px; + background: var(--orange); + color: #1a0f08; + border-radius: 50%; + font-size: 9px; + font-weight: 800; +} +.studio-slot-chip .slot-del { + background: transparent; + border: 0; + color: inherit; + padding: 0 2px; + cursor: pointer; + font-size: 14px; + line-height: 1; + opacity: 0.6; +} +.studio-slot-chip .slot-del:hover { opacity: 1; color: var(--err); } +.studio-stage-arrow { + text-align: center; + color: var(--orange-dim); + font-size: 18px; + font-weight: 800; + padding: 6px 0; +} + +/* 预览 */ +.studio-preview { + flex: 1 1 auto; + overflow-y: auto; + padding: 16px 18px; +} +.studio-preview h3 { + margin: 0 0 4px; + font-size: 16px; + font-weight: 800; + color: var(--text); +} +.studio-preview .studio-preview-desc { + font-size: 12px; + color: var(--text-dim2); + margin-bottom: 12px; +} +.studio-preview .studio-preview-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: 12px; +} +.studio-preview-tags .tag { + font-size: 10px; + padding: 2px 8px; + background: rgba(255,255,255,0.05); + border: 1px solid var(--line); + border-radius: 4px; + color: var(--text-dim); +} +.studio-preview-body { + font-size: 12px; + line-height: 1.7; + color: var(--text-dim); + white-space: pre-wrap; + word-wrap: break-word; + max-height: calc(100% - 120px); + overflow-y: auto; + padding: 12px; + background: rgba(0,0,0,0.15); + border-radius: 8px; + border: 1px solid var(--line); + font-family: "SF Mono", ui-monospace, Menlo, monospace; + font-size: 11px; +} +[data-theme="light"] .studio-preview-body { background: rgba(15,22,40,0.04); } +.studio-preview-add { + margin-top: 12px; + width: 100%; + padding: 10px; + border-radius: 8px; + border: 1px solid var(--orange-dim); + background: rgba(255,105,0,0.1); + color: var(--orange-3); + font-size: 12px; + font-weight: 700; + cursor: pointer; + font-family: inherit; +} +.studio-preview-add:hover { background: var(--orange); color: #1a0f08; } + +/* flow-skill-picker: 在 flow 编辑 modal 里用 */ +.flow-skill-picker { + display: flex; + flex-wrap: wrap; + gap: 8px; + width: 100%; + padding: 12px; + background: var(--input-bg); + border: 1px solid var(--line-strong); + border-radius: 10px; + min-height: 60px; +} + +/* Agent card 上的 skill 小标 */ +.agent-skills { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding-top: 4px; +} +.agent-skills .mini-skill { + font-size: 11px; + padding: 2px 8px; + background: rgba(255,105,0,0.1); + color: var(--orange-3); + border: 1px solid rgba(255,105,0,0.25); + border-radius: 6px; + font-weight: 600; +} + +/* Agent picker 列表 */ +.agent-picker-list { + display: flex; + flex-direction: column; + gap: 6px; + max-height: 60vh; + overflow-y: auto; + padding-right: 4px; +} +.agent-picker-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + background: var(--card-bg); + border: 1px solid var(--line); + border-radius: 12px; + cursor: pointer; + transition: all 0.15s; +} +.agent-picker-item:hover { + background: rgba(255,105,0,0.08); + border-color: var(--orange-dim); + transform: translateX(2px); +} +.agent-picker-item.active { + border-color: var(--orange); + background: rgba(255,105,0,0.12); +} +.agent-picker-item .ap-emoji { + width: 40px; height: 40px; + border-radius: 10px; + background: linear-gradient(135deg, rgba(255,105,0,0.22), rgba(255,136,48,0.08)); + border: 1px solid var(--line-strong); + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + flex: 0 0 40px; +} +.agent-picker-item .ap-body { flex: 1; min-width: 0; } +.agent-picker-item .ap-name { + font-size: 14px; + font-weight: 700; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.agent-picker-item .ap-desc { + font-size: 12px; + color: var(--text-dim2); + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.agent-picker-item .ap-check { + margin-left: auto; + font-size: 18px; + color: var(--orange); +} + +/* 聊天顶部当前智能体徽章 */ +.chat-agent-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 12px 5px 6px; + background: rgba(255,105,0,0.12); + border: 1px solid var(--orange-dim); + border-radius: 20px; + cursor: pointer; + font-size: 12px; + font-weight: 600; + color: var(--orange-3); + transition: all 0.15s; +} +.chat-agent-badge:hover { background: rgba(255,105,0,0.22); } +.chat-agent-badge .ag-emoji { + width: 22px; + height: 22px; + border-radius: 6px; + background: rgba(255,255,255,0.12); + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; +} + +.side-bottom { + padding-top: 14px; + border-top: 1px solid var(--line); + display: flex; + flex-direction: column; + gap: 8px; + flex: 0 0 auto; +} + +/* 主题切换按钮 */ +.theme-toggle { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border: 1px solid var(--line); + background: transparent; + color: var(--text-dim); + font-size: 12px; + font-weight: 600; + font-family: inherit; + border-radius: 12px; + cursor: pointer; + transition: all 0.2s; +} +.theme-toggle:hover { background: rgba(255,255,255,0.06); color: var(--text); } +[data-theme="light"] .theme-toggle:hover { background: rgba(15,22,40,0.06); } +.theme-toggle svg { width: 16px; height: 16px; flex: 0 0 16px; } +.theme-toggle .icon-sun { display: none; } +.theme-toggle .icon-moon { display: inline-block; } +[data-theme="light"] .theme-toggle .icon-sun { display: inline-block; } +[data-theme="light"] .theme-toggle .icon-moon { display: none; } +[data-theme="light"] .theme-toggle .theme-label::before { content: "暗色"; } +.theme-toggle .theme-label::before { content: "明亮"; } +.theme-toggle .theme-label { color: inherit; } +.theme-toggle .theme-label:empty::before { content: inherit; } +.side-new { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 11px; + border: 1px solid var(--line-strong); + background: rgba(255,105,0,0.08); + color: var(--orange-3); + font-size: 13px; + font-weight: 700; + font-family: inherit; + border-radius: 12px; + cursor: pointer; + transition: all 0.2s; +} +.side-new svg { width: 14px; height: 14px; } +.side-new:hover { background: rgba(255,105,0,0.16); color: var(--text); } + +.side-status { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + font-size: 11px; + color: var(--text-dim2); + font-weight: 500; +} +.side-status .dot { + width: 8px; height: 8px; border-radius: 50%; + background: var(--warn); + box-shadow: 0 0 8px currentColor; + transition: background .3s; +} +.side-status.ok .dot { background: var(--ok); } +.side-status.err .dot { background: var(--err); } + +/* ========== 主区 (磨砂玻璃) ========== */ +.main { + flex: 1 1 0; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + background: var(--panel-bg); + border: 1px solid var(--panel-border); + border-radius: 22px; + backdrop-filter: blur(22px) saturate(1.3); + -webkit-backdrop-filter: blur(22px) saturate(1.3); + box-shadow: var(--panel-shadow), var(--panel-inset); + overflow: hidden; + position: relative; + transition: background 0.3s; +} + +.tab-panel { + display: none; + flex: 1 1 auto; + flex-direction: column; + padding: 22px 28px; + overflow: hidden; + min-width: 0; + min-height: 0; +} +.tab-panel.active { display: flex; } +@keyframes fadeIn { from{opacity:0} to{opacity:1} } + +.panel-head { + padding: 4px 4px 18px; + flex: 0 0 auto; +} +.panel-head h2 { margin: 0 0 4px; font-size: clamp(18px, 2vw, 22px); font-weight: 700; letter-spacing: -0.3px; } +.panel-head p { margin: 0; color: var(--text-dim); font-size: 13px; } + +/* ========== Chat ========== */ +.chat-topbar { + display: flex; + align-items: center; + gap: 10px; + padding: 4px 4px 14px; + border-bottom: 1px solid var(--line); + flex: 0 0 auto; + flex-wrap: wrap; +} +.chat-topbar-title { + font-size: 15px; + font-weight: 700; + flex: 1 1 180px; + color: var(--text); + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.chat-model-pick select { + background: var(--input-bg); + color: var(--text); + border: 1px solid var(--line-strong); + border-radius: 10px; + padding: 7px 14px; + font-family: inherit; + font-size: 12px; + font-weight: 600; + cursor: pointer; + outline: none; +} +.chat-model-pick select:focus { border-color: var(--orange); } +.chat-clear { + background: transparent; + border: 1px solid var(--line-strong); + color: var(--text-dim); + border-radius: 10px; + padding: 7px 14px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + font-family: inherit; +} +.chat-clear:hover { color: var(--text); background: rgba(255,105,0,0.08); border-color: var(--orange-dim); } + +.gpt-messages { + flex: 1 1 auto; + overflow-y: auto; + overflow-x: hidden; + padding: 20px 6px 20px 4px; + display: flex; + flex-direction: column; + gap: 24px; + scroll-behavior: smooth; + min-height: 0; + min-width: 0; +} +.gpt-messages::-webkit-scrollbar { width: 8px; } +.gpt-messages::-webkit-scrollbar-thumb { background: rgba(255,180,120,0.12); border-radius: 4px; } +.gpt-messages::-webkit-scrollbar-thumb:hover { background: rgba(255,180,120,0.24); } + +/* GPT 式消息行 */ +.msg { + display: flex; + gap: 14px; + align-items: flex-start; + max-width: 820px; + width: 100%; + margin: 0 auto; +} +/* 只对 "新加进来" 的最后一条做淡入,历史渲染不播动画 */ +.msg.msg-in { animation: msgIn 0.25s ease-out; } +@keyframes msgIn { from { opacity:0; transform: translateY(4px); } to { opacity:1; transform: translateY(0);} } +.msg-avatar { + flex: 0 0 32px; + width: 32px; height: 32px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 700; + user-select: none; +} +.msg.user .msg-avatar { + background: var(--msg-user-avatar); + color: var(--text); +} +.msg.assistant .msg-avatar { + background: var(--orange); + color: #1a0f08; + font-family: "Didot", "Bodoni 72", serif; + font-size: 16px; + box-shadow: 0 2px 10px rgba(255,105,0,0.5); +} +.msg-body { flex: 1; min-width: 0; } +.msg-name { + font-size: 12px; + font-weight: 700; + color: var(--text-dim2); + margin-bottom: 6px; + letter-spacing: 0.3px; +} +.msg.assistant .msg-name { color: var(--orange-3); } +.msg-content { + font-size: 15px; + line-height: 1.75; + color: var(--text); + word-wrap: break-word; + white-space: pre-wrap; +} +.msg.error { justify-content: center; } +.msg.error .msg-content { + display: inline-block; + background: rgba(255,93,122,0.1); + border: 1px solid rgba(255,93,122,0.3); + color: #ffb6c3; + font-size: 13px; + padding: 10px 16px; + border-radius: 12px; +} + +.thinking { display: inline-flex; gap: 4px; padding: 8px 0; } +.thinking span { + width: 7px; height: 7px; border-radius: 50%; + background: var(--orange-3); + animation: dot 1.4s infinite; +} +.thinking span:nth-child(2) { animation-delay: 0.2s; } +.thinking span:nth-child(3) { animation-delay: 0.4s; } +@keyframes dot { 0%,60%,100%{opacity:0.3;transform:translateY(0)} 30%{opacity:1;transform:translateY(-3px)} } + +/* 消息底部操作按钮 */ +.msg-actions { + display: flex; + gap: 6px; + margin-top: 8px; + opacity: 0; + transition: opacity 0.2s; +} +.msg:hover .msg-actions { opacity: 1; } +.msg-action-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + font-size: 11px; + font-weight: 600; + border: 1px solid var(--line); + background: transparent; + color: var(--text-dim2); + border-radius: 8px; + cursor: pointer; + font-family: inherit; + transition: all 0.15s; +} +.msg-action-btn:hover { + color: var(--orange-3); + border-color: var(--orange-dim); + background: rgba(255,105,0,0.08); +} + +.msg-content.streaming::after { + content: "▋"; + display: inline-block; + color: var(--orange); + animation: blink 1s infinite; + margin-left: 2px; +} + +/* 空对话品牌欢迎页 */ +.welcome-hero { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + text-align: center; +} +.welcome-hermes-tag { + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px 38px 22px; + background: var(--orange); + color: #111; + border-radius: 6px; + line-height: 1; + font-family: "Didot", "Bodoni 72", "SF Pro Display", serif; + font-weight: 700; + box-shadow: 0 16px 50px rgba(255,105,0,0.4), 0 0 0 1px rgba(26,15,8,0.2); + margin-bottom: 36px; + position: relative; +} +.welcome-hermes-tag::before { + content: ""; + position: absolute; + inset: 5px; + border: 1px solid rgba(26,15,8,0.35); + border-radius: 3px; + pointer-events: none; +} +.welcome-hermes-tag .wh-top { + font-size: 38px; + letter-spacing: 6px; +} +.welcome-hermes-tag .wh-mid { + font-size: 11px; + margin-top: 8px; + letter-spacing: 4px; + opacity: 0.78; +} +.welcome-title { + font-size: clamp(22px, 2.4vw, 30px); + font-weight: 700; + letter-spacing: -0.5px; + margin-bottom: 8px; + background: linear-gradient(135deg, var(--text), var(--text-dim)); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} +.welcome-sub { + font-size: 13px; + color: var(--text-dim2); + margin-bottom: 32px; +} +.welcome-chips { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: center; + max-width: 680px; +} +.welcome-chips .chip { + padding: 11px 18px; + font-size: 13px; + font-weight: 500; + background: var(--card-bg); + border: 1px solid var(--line); + border-radius: 14px; + cursor: pointer; + color: var(--text-dim); + transition: all 0.2s; +} +.welcome-chips .chip:hover { + background: rgba(255,105,0,0.1); + border-color: var(--orange-dim); + color: var(--orange-3); + transform: translateY(-2px); +} +@keyframes blink { 0%,50%{opacity:1} 51%,100%{opacity:0} } + +/* 输入框 */ +.gpt-input-wrap { + flex: 0 0 auto; + padding: 10px 0 0; + max-width: 820px; + margin: 0 auto; + width: 100%; +} +.gpt-input-box { + display: flex; + align-items: flex-end; + gap: 10px; + padding: 12px 14px 12px 20px; + background: var(--input-bg); + border: 1px solid var(--panel-border-strong); + border-radius: 22px; + box-shadow: 0 6px 20px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.08); + transition: all 0.2s; + /* blur 已在外壳统一处理,内部卡片不再单独磨砂以降低合成成本 */ +} +.gpt-input-box:focus-within { + border-color: var(--orange); + box-shadow: 0 6px 22px rgba(0,0,0,0.3), 0 0 0 3px rgba(255,105,0,0.15); +} +.gpt-input-box textarea { + flex: 1; + background: transparent; + border: 0; + resize: none; + outline: none; + color: var(--text); + font-size: 15px; + font-family: inherit; + line-height: 1.5; + padding: 8px 0; + max-height: 200px; +} +.gpt-input-box textarea::placeholder { color: var(--text-dim3); } + +.send-btn { + flex: 0 0 auto; + width: 36px; height: 36px; + border-radius: 50%; + border: 0; + background: var(--orange); + color: #1a0f08; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 2.2); + box-shadow: 0 3px 12px rgba(255,105,0,0.45); +} +.send-btn:hover { transform: scale(1.08); background: var(--orange-2); } +.send-btn:disabled { opacity: 0.35; cursor: not-allowed; transform: none; background: rgba(255,255,255,0.15); box-shadow: none; color: var(--text-dim); } + +.gpt-footnote { + text-align: center; + margin-top: 10px; + padding-bottom: 4px; + font-size: 11px; + color: var(--text-dim3); +} + +/* 输入框左侧 @ 按钮 */ +.input-at { + flex: 0 0 34px; + width: 34px; + height: 34px; + border-radius: 10px; + border: 1px solid var(--line); + background: transparent; + color: var(--text-dim2); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-family: inherit; + transition: all 0.2s; + align-self: flex-end; +} +.input-at:hover { + color: var(--orange); + border-color: var(--orange-dim); + background: rgba(255,105,0,0.1); +} +.input-at.active { + color: #1a0f08; + background: var(--orange); + border-color: var(--orange); +} + +/* 本次使用 agent 提示条 */ +.pending-agent-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + margin-bottom: 8px; + background: rgba(255,105,0,0.1); + border: 1px solid var(--orange-dim); + border-radius: 12px; + font-size: 12px; + color: var(--orange-3); +} +.pending-agent-bar .pa-label { font-weight: 700; opacity: 0.85; } +.pending-agent-bar .pa-emoji { + width: 22px; height: 22px; + display: flex; align-items: center; justify-content: center; + border-radius: 6px; + background: rgba(255,255,255,0.14); + font-size: 13px; +} +.pending-agent-bar .pa-name { font-weight: 700; color: var(--text); } +.pending-agent-bar .pa-clear { + margin-left: auto; + background: transparent; + border: 0; + color: var(--text-dim); + font-size: 16px; + cursor: pointer; + padding: 2px 8px; + border-radius: 6px; +} +.pending-agent-bar .pa-clear:hover { background: rgba(255,255,255,0.1); color: var(--text); } + +/* 消息上的 agent 标识(非默认 assistant) */ +.msg.assistant[data-agent] .msg-name::after { + content: ""; +} + +/* ========== 研究 ========== */ +.grid-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 16px; + overflow-y: auto; + padding-right: 6px; + flex: 1 1 auto; + min-height: 0; + align-content: start; +} +.card { + padding: 24px 22px; + background: var(--card-bg); + border: 1px solid var(--line); + border-radius: 18px; + transition: transform 0.2s, border-color 0.2s; +} +.card:hover { + transform: translateY(-3px); + border-color: var(--line-strong); + background: var(--card-hover-bg); + box-shadow: 0 12px 30px rgba(0,0,0,0.25); +} +.card-icon { font-size: 28px; margin-bottom: 12px; } +.card-title { font-size: 16px; font-weight: 700; margin-bottom: 8px; color: var(--text); } +.card-desc { font-size: 13px; color: var(--text-dim); line-height: 1.6; } + +.glass-btn-sm { + margin-top: 14px; + padding: 8px 18px; + font-size: 12px; + font-weight: 700; + border: 1px solid var(--orange-dim); + border-radius: 10px; + background: rgba(255,105,0,0.1); + color: var(--orange-3); + cursor: pointer; + transition: all 0.25s; + font-family: inherit; +} +.glass-btn-sm:hover { background: var(--orange); color: #1a0f08; border-color: var(--orange); } + +/* ========== 仪表盘 ========== */ +.dash-scroll { + flex: 1; + overflow-y: auto; + padding-right: 6px; +} +.dash-section-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1.2px; + color: var(--text-dim2); + font-weight: 700; + padding: 18px 4px 12px; +} + +/* Hero 大区块 */ +.dash-hero { + display: grid; + grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr); + gap: 16px; + padding: clamp(20px, 3vw, 32px) clamp(22px, 3vw, 36px); + margin-bottom: 18px; + background: linear-gradient(135deg, rgba(255,105,0,0.12), rgba(255,105,0,0.03) 60%, rgba(255,255,255,0.04)); + border: 1px solid var(--line); + border-radius: 24px; + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + overflow: hidden; + position: relative; +} +.dash-hero::before { + content: ""; + position: absolute; + top: -50%; + right: -20%; + width: 60%; + height: 200%; + background: radial-gradient(ellipse, rgba(255,105,0,0.18), transparent 70%); + pointer-events: none; +} +[data-theme="light"] .dash-hero { + background: linear-gradient(135deg, rgba(255,105,0,0.1), rgba(255,255,255,0.6) 60%, rgba(255,255,255,0.4)); +} +.dash-hero-main { position: relative; z-index: 1; } +.dash-hero-label { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 1.5px; + color: var(--text-dim2); + font-weight: 700; + margin-bottom: 10px; +} +.dash-hero-num { + font-size: clamp(48px, 7vw, 88px); + font-weight: 900; + line-height: 1; + letter-spacing: -3px; + font-variant-numeric: tabular-nums; + background: linear-gradient(135deg, #ff6900 0%, #ff8830 50%, #ffa85a 100%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + margin: 4px 0 14px; +} +.dash-hero-sub { + font-size: 14px; + color: var(--text-dim); + font-weight: 500; +} +.dash-hero-dot { margin: 0 8px; color: var(--text-dim3); } + +.dash-hero-side { + display: flex; + flex-direction: column; + gap: 14px; + justify-content: center; + position: relative; + z-index: 1; +} +.hero-side-item { + padding: 12px 18px; + background: rgba(255,255,255,0.04); + border: 1px solid var(--line); + border-radius: 14px; + backdrop-filter: blur(10px); +} +[data-theme="light"] .hero-side-item { background: rgba(255,255,255,0.55); } +.hero-side-num { + font-size: 26px; + font-weight: 800; + color: var(--text); + letter-spacing: -0.5px; + font-variant-numeric: tabular-nums; + line-height: 1.1; +} +.hero-side-lbl { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.8px; + color: var(--text-dim2); + font-weight: 700; + margin-top: 2px; +} +.hero-side-sub { + font-size: 11px; + color: var(--text-dim3); + margin-top: 1px; +} + +/* 大号 stat 卡片带图标 */ +.dash-grid-lg { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 14px; + padding-right: 0; + margin-bottom: 18px; +} +.stat-lg { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 20px 22px !important; +} +.stat-icon { + flex: 0 0 44px; + width: 44px; height: 44px; + border-radius: 12px; + background: linear-gradient(135deg, rgba(255,105,0,0.18), rgba(255,136,48,0.08)); + border: 1px solid var(--line-strong); + display: flex; + align-items: center; + justify-content: center; + color: var(--orange-3); +} +.stat-body { flex: 1; min-width: 0; } +.pill-ok { + font-size: 12px; + color: var(--ok); + margin-left: 4px; +} + +/* 最常用智能体大卡 */ +.top-agent-card { + display: flex; + align-items: center; + gap: 18px; + padding: 22px 26px; + background: var(--card-bg); + border: 1px solid var(--line); + border-radius: 18px; + margin-bottom: 18px; + /* blur 已在外壳统一处理,内部卡片不再单独磨砂以降低合成成本 */ +} +.top-agent-avatar { + width: 58px; height: 58px; + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + font-size: 30px; + background: linear-gradient(135deg, rgba(255,105,0,0.22), rgba(255,136,48,0.1)); + border: 1px solid var(--line-strong); + flex: 0 0 58px; +} +.top-agent-info { flex: 1; min-width: 0; } +.top-agent-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-dim2); + font-weight: 700; + margin-bottom: 4px; +} +.top-agent-name { + font-size: 20px; + font-weight: 800; + color: var(--text); + letter-spacing: -0.3px; +} +.top-agent-sub { + font-size: 12px; + color: var(--text-dim); + margin-top: 3px; +} +/* 热力图 — 全宽大格 */ +.heatmap-wrap { + padding: 24px 28px 22px; + background: var(--card-bg); + border: 1px solid var(--line); + border-radius: 18px; + margin-bottom: 18px; + /* blur 已在外壳统一处理,内部卡片不再单独磨砂以降低合成成本 */ +} +.heatmap { + display: grid; + grid-template-columns: auto repeat(var(--hm-cols, 16), 1fr); + grid-auto-rows: 1fr; + gap: 8px; + width: 100%; +} +.hm-row-label { + font-size: 10px; + color: var(--text-dim3); + font-weight: 700; + text-align: right; + padding-right: 6px; + align-self: center; + line-height: 1; +} +.hm-cell { + width: 100%; + aspect-ratio: 1; + min-height: 0; + border-radius: 6px; + background: rgba(255,255,255,0.04); + border: 1px solid var(--line); + cursor: pointer; + transition: transform 0.15s, border-color 0.15s, box-shadow 0.15s; +} +[data-theme="light"] .hm-cell { background: rgba(15,22,40,0.04); } +.hm-cell:hover { + transform: scale(1.12); + border-color: var(--orange); + box-shadow: 0 0 0 2px rgba(255,105,0,0.25); + z-index: 2; +} +.hm-cell.lvl-1 { background: rgba(255,105,0,0.22); border-color: rgba(255,105,0,0.35); } +.hm-cell.lvl-2 { background: rgba(255,105,0,0.40); border-color: rgba(255,105,0,0.55); } +.hm-cell.lvl-3 { background: rgba(255,105,0,0.62); border-color: rgba(255,105,0,0.8); } +.hm-cell.lvl-4 { + background: var(--orange); + border-color: var(--orange); + box-shadow: 0 0 16px rgba(255,105,0,0.4); +} +.hm-cell.sel { + outline: 2px solid var(--orange); + outline-offset: 3px; + z-index: 3; +} +.hm-cell.future { + opacity: 0.18; + cursor: default; + background: transparent; +} +.hm-cell.future:hover { transform: none; box-shadow: none; border-color: var(--line); } + +.heatmap-legend { + display: flex; + align-items: center; + gap: 8px; + margin-top: 18px; + padding-top: 16px; + border-top: 1px solid var(--line); + font-size: 11px; + color: var(--text-dim2); + font-weight: 600; +} +.heatmap-legend .hm-cell { + width: 16px; + height: 16px; + aspect-ratio: auto; + cursor: default; + flex: 0 0 16px; +} +.heatmap-legend .hm-cell:hover { transform: none; box-shadow: none; } + +/* 日详情 */ +.day-detail { + padding: 18px 20px; + background: var(--card-bg); + border: 1px solid var(--line); + border-radius: 14px; + min-height: 100px; + margin-bottom: 10px; + /* 卡片层不再单独磨砂 */ +} +.day-detail h4 { + margin: 0 0 14px; + font-size: 14px; + font-weight: 700; + color: var(--text); + letter-spacing: -0.2px; +} +.day-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 12px; + margin-bottom: 14px; +} +.day-stat { + display: flex; + flex-direction: column; +} +.day-stat .num { + font-size: 20px; + font-weight: 800; + color: var(--orange-3); + letter-spacing: -0.5px; + font-variant-numeric: tabular-nums; +} +.day-stat .lbl { font-size: 11px; color: var(--text-dim2); text-transform: uppercase; letter-spacing: 0.8px; font-weight: 700; margin-top: 2px; } + +.day-convos { display: flex; flex-direction: column; gap: 8px; } +.day-convo-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + background: rgba(255,255,255,0.03); + border: 1px solid var(--line); + border-radius: 10px; + cursor: pointer; + transition: all 0.15s; +} +[data-theme="light"] .day-convo-item { background: rgba(15,22,40,0.03); } +.day-convo-item:hover { background: var(--card-hover-bg); border-color: var(--line-strong); } +.day-convo-title { flex: 1; font-size: 13px; font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.day-convo-meta { font-size: 11px; color: var(--text-dim2); white-space: nowrap; } + +.dash-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 14px; + padding-right: 0; + align-content: start; + margin-bottom: 18px; +} +.stat { + padding: 20px 22px; + background: var(--card-bg); + border: 1px solid var(--line); + border-radius: 18px; + transition: border-color 0.2s, background 0.2s; +} +.stat:hover { border-color: var(--line-strong); background: var(--card-hover-bg); } +.stat-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-dim2); + font-weight: 700; +} +.stat-value { + font-size: 26px; + font-weight: 800; + letter-spacing: -0.5px; + margin: 6px 0; + color: var(--text); + font-variant-numeric: tabular-nums; +} +.stat-sub { font-size: 12px; color: var(--text-dim); } + +/* ========== 设置 ========== */ +.settings-scroll { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 4px 8px 8px 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); + gap: 16px; + align-content: start; +} +/* "数据" 和 "关于" 在更宽时跨两列,显得大气 */ +.settings-group.wide { grid-column: 1 / -1; } +@media (min-width: 1400px) { + .settings-scroll { grid-template-columns: repeat(3, 1fr); } + .settings-group.wide { grid-column: span 3; } +} + +.settings-group { + background: var(--card-bg); + border: 1px solid var(--line); + border-radius: 18px; + display: flex; + flex-direction: column; + min-width: 0; +} +.settings-group-head { + display: flex; + align-items: center; + gap: 14px; + padding: 18px 22px 16px; + border-bottom: 1px solid var(--line); + min-width: 0; +} +.settings-group-head > div:last-child { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; +} +.settings-group-icon { + width: 40px; height: 40px; + border-radius: 12px; + background: linear-gradient(135deg, rgba(255,105,0,0.2), rgba(255,136,48,0.08)); + border: 1px solid var(--line-strong); + display: flex; + align-items: center; + justify-content: center; + color: var(--orange-3); + flex: 0 0 40px; +} +.settings-group-icon svg { width: 20px; height: 20px; } +.settings-group-title { + font-size: 15px; + font-weight: 700; + color: var(--text); + letter-spacing: -0.2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.settings-group-desc { + font-size: 12px; + color: var(--text-dim2); + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.settings-group-body { + padding: 18px 22px 22px; + display: flex; + flex-direction: column; + gap: 16px; + min-width: 0; +} + +.settings-field { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} +.settings-field > label { + font-size: 12px; + font-weight: 700; + color: var(--text); + letter-spacing: 0.2px; +} +.settings-field.toggle-field { + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 18px; + flex-wrap: wrap; +} +.settings-field.toggle-field > div:first-child { flex: 1 1 200px; min-width: 0; } +.settings-field input[type="text"], +.settings-field input[type="password"] { + width: 100%; + max-width: 100%; + box-sizing: border-box; + background: var(--input-bg); + border: 1px solid var(--line-strong); + color: var(--text); + padding: 11px 14px; + border-radius: 10px; + font-family: inherit; + font-size: 13px; + outline: none; + transition: border-color 0.2s, box-shadow 0.2s; +} +.settings-field input:focus { + border-color: var(--orange); + box-shadow: 0 0 0 3px rgba(255,105,0,0.12); +} +.settings-help { + font-size: 11px; + color: var(--text-dim2); + line-height: 1.55; + word-wrap: break-word; + overflow-wrap: break-word; +} +.settings-help code { + display: inline-block; + padding: 1px 6px; + background: rgba(255,105,0,0.12); + border: 1px solid rgba(255,105,0,0.2); + border-radius: 4px; + color: var(--orange-3); + font-size: 10.5px; + font-family: "SF Mono", ui-monospace, Menlo, monospace; + word-break: break-all; + max-width: 100%; +} + +.theme-chip { + padding: 8px 16px; + font-size: 12px; + font-weight: 600; + background: rgba(255,105,0,0.1); + border: 1px solid var(--orange-dim); + color: var(--orange-3); + border-radius: 10px; + cursor: pointer; + font-family: inherit; + transition: all 0.2s; +} +.theme-chip:hover { background: var(--orange); color: #1a0f08; } + +.settings-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} +.settings-actions .glass-btn-sm { + margin: 0; + padding: 10px 16px; + font-size: 12px; +} +.danger-btn { + border-color: rgba(255,93,122,0.3) !important; + color: var(--err) !important; + background: rgba(255,93,122,0.08) !important; +} +.danger-btn:hover { + background: rgba(255,93,122,0.18) !important; + border-color: var(--err) !important; +} + +.about-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 14px; +} +.about-item { + padding: 12px 14px; + background: rgba(255,255,255,0.03); + border: 1px solid var(--line); + border-radius: 10px; +} +[data-theme="light"] .about-item { background: rgba(15,22,40,0.03); } +.about-lbl { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.8px; + color: var(--text-dim2); + font-weight: 700; +} +.about-val { + font-size: 13px; + color: var(--text); + font-weight: 600; + margin-top: 3px; +} + +.settings-save-bar { + display: flex; + justify-content: flex-end; + padding: 4px 0 16px; + grid-column: 1 / -1; +} +.settings-save-bar .glass-btn-sm { + padding: 12px 24px; + font-size: 13px; +} + +/* 兼容旧的 setting-row (agent modal 里还在用) */ +.setting-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 0; + border-bottom: 1px solid var(--line); +} +.setting-row label { + font-size: 13px; + color: var(--text-dim); + font-weight: 600; + flex: 0 0 120px; +} +.setting-row input[type="text"], +.setting-row input[type="password"] { + flex: 1; + background: var(--input-bg); + border: 1px solid var(--line-strong); + color: var(--text); + padding: 10px 14px; + border-radius: 10px; + font-family: inherit; + font-size: 13px; + outline: none; + transition: border-color 0.2s; +} +.setting-row input:focus { border-color: var(--orange); } + +.switch { position: relative; display: inline-block; width: 44px; height: 24px; } +.switch input { opacity: 0; width: 0; height: 0; } +.slider { + position: absolute; cursor: pointer; inset: 0; + background: rgba(255,255,255,0.14); border-radius: 24px; + transition: 0.3s; +} +.slider::before { + content: ""; position: absolute; + width: 18px; height: 18px; left: 3px; top: 3px; + background: #fff; border-radius: 50%; + transition: 0.3s; +} +.switch input:checked + .slider { background: var(--orange); } +.switch input:checked + .slider::before { transform: translateX(20px); } + +.about { + margin-top: 28px; + font-size: 12px; + color: var(--text-dim3); + line-height: 1.8; +} + +/* ========== Agent / Cluster ========== */ +.panel-head-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} +.panel-head-actions { display: flex; gap: 8px; } +.glass-btn-sm.primary { + background: var(--orange); + border-color: var(--orange); + color: #1a0f08; +} +.glass-btn-sm.primary:hover { background: var(--orange-2); border-color: var(--orange-2); color: #1a0f08; } +.glass-btn-sm svg { vertical-align: middle; margin-right: 4px; } + +.agent-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 16px; + overflow-y: auto; + padding-right: 6px; + align-content: start; + flex: 1 1 auto; + min-height: 0; +} +.agent-card { + padding: 22px 20px 18px; + background: var(--card-bg); + border: 1px solid var(--line); + border-radius: 18px; + transition: transform 0.2s, border-color 0.2s, background 0.2s; + display: flex; + flex-direction: column; + gap: 10px; + position: relative; +} +.agent-card:hover { + transform: translateY(-3px); + border-color: var(--line-strong); + background: var(--card-hover-bg); + box-shadow: 0 12px 30px rgba(0,0,0,0.25); +} +.agent-card-head { + display: flex; + align-items: center; + gap: 12px; +} +.agent-avatar { + width: 44px; height: 44px; + border-radius: 12px; + background: linear-gradient(135deg, rgba(255,105,0,0.18), rgba(255,136,48,0.1)); + border: 1px solid var(--line-strong); + display: flex; align-items: center; justify-content: center; + font-size: 24px; + flex: 0 0 44px; +} +.agent-meta { flex: 1; min-width: 0; } +.agent-name { + font-size: 15px; + font-weight: 700; + color: var(--text); + margin-bottom: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.agent-model { + font-size: 11px; + color: var(--text-dim3); + font-weight: 600; + letter-spacing: 0.3px; +} +.agent-desc { + font-size: 12.5px; + color: var(--text-dim); + line-height: 1.55; + flex: 1; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.agent-actions { + display: flex; + gap: 6px; + margin-top: 4px; + padding-top: 10px; + border-top: 1px solid var(--line); +} +.agent-actions button { + flex: 1; + padding: 7px 10px; + font-size: 11.5px; + font-weight: 600; + border: 1px solid var(--line-strong); + background: transparent; + color: var(--text-dim); + border-radius: 8px; + font-family: inherit; + cursor: pointer; + transition: all 0.2s; +} +.agent-actions button:hover { color: var(--text); background: var(--card-hover-bg); } +.agent-actions button.chat { background: rgba(255,105,0,0.1); color: var(--orange-3); border-color: var(--orange-dim); } +.agent-actions button.chat:hover { background: var(--orange); color: #1a0f08; border-color: var(--orange); } +.agent-actions button.danger:hover { color: var(--err); border-color: rgba(255,93,122,0.4); background: rgba(255,93,122,0.08); } + +/* ========== Modal ========== */ +.modal-mask { + position: fixed; inset: 0; + background: rgba(0,0,0,0.55); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + z-index: 500; + display: none; + align-items: center; + justify-content: center; + padding: 20px; + animation: fadeIn 0.2s ease; +} +.modal-mask.open { display: flex; } +[data-theme="light"] .modal-mask { background: rgba(60,80,120,0.32); } + +.modal { + background: var(--panel-bg); + border: 1px solid var(--panel-border-strong); + border-radius: 22px; + box-shadow: 0 20px 60px rgba(0,0,0,0.5), var(--panel-inset); + backdrop-filter: blur(22px) saturate(1.3); + -webkit-backdrop-filter: blur(22px) saturate(1.3); + width: 100%; + max-width: 520px; + max-height: 88vh; + display: flex; + flex-direction: column; + animation: scaleIn 0.25s cubic-bezier(0.22, 1, 0.36, 1); +} +.modal-lg { max-width: 880px; } +@keyframes scaleIn { from{opacity:0;transform:scale(0.96)} to{opacity:1;transform:scale(1)} } + +.modal-head { + display: flex; align-items: center; justify-content: space-between; + padding: 18px 22px; + border-bottom: 1px solid var(--line); +} +.modal-head h3 { margin: 0; font-size: 16px; font-weight: 700; } +.modal-close { + background: transparent; border: 0; + color: var(--text-dim); font-size: 24px; line-height: 1; + cursor: pointer; padding: 4px 10px; border-radius: 8px; +} +.modal-close:hover { color: var(--text); background: var(--card-hover-bg); } + +.modal-body { + padding: 18px 22px; + overflow-y: auto; + flex: 1; +} +.modal-foot { + display: flex; justify-content: flex-end; gap: 8px; + padding: 14px 22px; + border-top: 1px solid var(--line); +} + +.setting-row-full { + flex-direction: column; + align-items: flex-start; + gap: 8px; +} +.setting-row-full label { flex: none; } +.setting-row-full textarea, +.setting-row textarea { + width: 100%; + background: var(--input-bg); + border: 1px solid var(--line-strong); + color: var(--text); + padding: 10px 14px; + border-radius: 10px; + font-family: inherit; + font-size: 13px; + line-height: 1.6; + outline: none; + resize: vertical; + transition: border-color 0.2s; +} +.setting-row textarea:focus, +.setting-row input:focus, +.setting-row select:focus { border-color: var(--orange); } +.setting-row select { + flex: 1; + background: var(--input-bg); + border: 1px solid var(--line-strong); + color: var(--text); + padding: 10px 14px; + border-radius: 10px; + font-family: inherit; + font-size: 13px; + outline: none; + cursor: pointer; +} + +/* Cluster */ +.cluster-pick { margin-bottom: 16px; } +.cluster-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-dim2); + font-weight: 700; + margin-bottom: 10px; +} +.cluster-agent-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.cluster-pick-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + font-size: 12px; + font-weight: 600; + border: 1px solid var(--line-strong); + background: var(--card-bg); + color: var(--text-dim); + border-radius: 10px; + cursor: pointer; + transition: all 0.2s; +} +.cluster-pick-chip:hover { color: var(--text); background: var(--card-hover-bg); } +.cluster-pick-chip.on { + background: rgba(255,105,0,0.14); + border-color: var(--orange-dim); + color: var(--orange-3); +} + +.cluster-input { + display: flex; + gap: 10px; + align-items: stretch; + margin-bottom: 16px; +} +.cluster-input textarea { + flex: 1; + background: var(--input-bg); + border: 1px solid var(--line-strong); + color: var(--text); + padding: 12px 14px; + border-radius: 12px; + font-family: inherit; + font-size: 13px; + outline: none; + resize: none; +} +.cluster-input textarea:focus { border-color: var(--orange); } +.cluster-input .glass-btn-sm { align-self: flex-end; white-space: nowrap; } + +.cluster-results { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 12px; + max-height: 52vh; + overflow-y: auto; + padding-right: 4px; +} +.cluster-col { + background: var(--card-bg); + border: 1px solid var(--line); + border-radius: 14px; + padding: 14px; + display: flex; + flex-direction: column; + gap: 8px; + min-height: 140px; +} +.cluster-col-head { + display: flex; + align-items: center; + gap: 8px; + padding-bottom: 8px; + border-bottom: 1px solid var(--line); +} +.cluster-col-avatar { + width: 26px; height: 26px; + border-radius: 8px; + background: linear-gradient(135deg, rgba(255,105,0,0.2), rgba(255,136,48,0.1)); + border: 1px solid var(--line-strong); + display: flex; align-items: center; justify-content: center; + font-size: 14px; +} +.cluster-col-name { font-size: 13px; font-weight: 700; flex: 1; } +.cluster-col-status { + font-size: 10px; + padding: 2px 7px; + border-radius: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.cluster-col-status.running { background: rgba(255,200,58,0.18); color: var(--warn); } +.cluster-col-status.done { background: rgba(110,240,141,0.18); color: var(--ok); } +.cluster-col-status.err { background: rgba(255,93,122,0.18); color: var(--err); } +.cluster-col-body { + font-size: 13px; + line-height: 1.65; + color: var(--text); + white-space: pre-wrap; + word-wrap: break-word; +} + +/* ========== Cron 定时任务 ========== */ +.cron-list { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding-right: 6px; + display: flex; + flex-direction: column; + gap: 10px; +} +.cron-item { + padding: 16px 20px; + background: var(--card-bg); + border: 1px solid var(--line); + border-radius: 14px; + display: grid; + grid-template-columns: auto 1fr auto auto auto; + gap: 14px; + align-items: center; + transition: border-color 0.2s, background 0.2s; +} +.cron-item:hover { border-color: var(--line-strong); background: var(--card-hover-bg); } +.cron-item.disabled { opacity: 0.55; } +.cron-icon { + width: 38px; height: 38px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, rgba(255,105,0,0.2), rgba(255,136,48,0.08)); + border: 1px solid var(--line-strong); + color: var(--orange-3); + flex: 0 0 38px; +} +.cron-icon svg { width: 18px; height: 18px; } +.cron-body { min-width: 0; } +.cron-name { + font-size: 14px; + font-weight: 700; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.cron-meta { + font-size: 11px; + color: var(--text-dim2); + margin-top: 3px; + display: flex; + gap: 10px; + flex-wrap: wrap; +} +.cron-meta code { + font-family: "SF Mono", ui-monospace, Menlo, monospace; + background: rgba(255,105,0,0.1); + color: var(--orange-3); + padding: 1px 6px; + border-radius: 4px; + font-size: 10.5px; +} +.cron-prompt-preview { + font-size: 11.5px; + color: var(--text-dim); + margin-top: 4px; + max-width: 560px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.cron-pill { + font-size: 10px; + padding: 3px 10px; + border-radius: 50px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.cron-pill.on { background: rgba(110,240,141,0.15); color: var(--ok); border: 1px solid rgba(110,240,141,0.3); } +.cron-pill.off { background: rgba(255,255,255,0.06); color: var(--text-dim2); border: 1px solid var(--line); } +.cron-btn { + padding: 6px 12px; + font-size: 11px; + font-weight: 700; + border: 1px solid var(--line-strong); + background: transparent; + color: var(--text-dim); + border-radius: 8px; + font-family: inherit; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} +.cron-btn:hover { color: var(--text); background: var(--card-hover-bg); } +.cron-btn.run:hover { color: var(--orange-3); border-color: var(--orange-dim); background: rgba(255,105,0,0.08); } + +@media (max-width: 720px) { + .cron-item { grid-template-columns: auto 1fr; grid-auto-flow: row; } + .cron-item > .cron-pill, + .cron-item > .cron-btn { grid-column: 2; justify-self: start; } +} + +/* ========== Memory ========== */ +.memory-grid { + flex: 1 1 auto; + min-height: 0; + display: grid; + grid-template-columns: 1.1fr 1fr; + gap: 14px; + overflow: hidden; +} +@media (max-width: 960px) { + .memory-grid { grid-template-columns: 1fr; overflow: auto; } +} +.memory-pane { + display: flex; + flex-direction: column; + background: var(--card-bg); + border: 1px solid var(--line); + border-radius: 14px; + overflow: hidden; + min-height: 0; +} +.memory-pane-head { + padding: 12px 16px; + border-bottom: 1px solid var(--line); + font-size: 12px; + font-weight: 700; + color: var(--text-dim); + letter-spacing: 0.3px; + flex: 0 0 auto; +} +.memory-body { + flex: 1 1 auto; + overflow: auto; + padding: 16px 20px; + margin: 0; + font-size: 12px; + line-height: 1.7; + color: var(--text); + white-space: pre-wrap; + word-wrap: break-word; + font-family: "SF Mono", ui-monospace, Menlo, monospace; +} +.memory-sessions { + flex: 1 1 auto; + overflow-y: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 4px; +} +.session-row { + padding: 10px 14px; + border: 1px solid var(--line); + border-radius: 10px; + background: transparent; + cursor: default; + transition: background 0.15s, border-color 0.15s, transform 0.15s; +} +.session-row.clickable { cursor: pointer; } +.session-row:hover { background: var(--card-hover-bg); border-color: var(--line-strong); } +.session-row.clickable:hover { + border-color: var(--orange-dim); + background: rgba(255,105,0,0.06); + transform: translateX(2px); +} +.session-row-action { + margin-left: auto; + color: var(--orange-3); + font-weight: 700; + opacity: 0; + transition: opacity 0.15s; +} +.session-row.clickable:hover .session-row-action { opacity: 1; } +.session-row-meta { align-items: center; } +.session-row-title { + font-size: 13px; + font-weight: 600; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 3px; +} +.session-row-meta { + font-size: 10px; + color: var(--text-dim2); + display: flex; + gap: 10px; + font-weight: 600; +} + +/* ========== Tools ========== */ +.tools-grid { + flex: 1 1 auto; + overflow-y: auto; + padding-right: 6px; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 10px; + align-content: start; +} +.tool-chip { + padding: 14px 16px; + background: var(--card-bg); + border: 1px solid var(--line); + border-radius: 12px; + display: flex; + align-items: center; + gap: 12px; + transition: border-color 0.2s, background 0.2s; +} +.tool-chip:hover { background: var(--card-hover-bg); border-color: var(--line-strong); } +.tool-chip.off { opacity: 0.5; } +.tool-chip .tool-ico { font-size: 22px; flex: 0 0 28px; text-align: center; } +.tool-chip .tool-meta { flex: 1; min-width: 0; } +.tool-chip .tool-name { + font-size: 13px; + font-weight: 700; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.tool-chip .tool-desc { + font-size: 11px; + color: var(--text-dim2); + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.tool-chip .tool-status { + font-size: 9px; + padding: 2px 8px; + border-radius: 50px; + font-weight: 700; + letter-spacing: 0.3px; + text-transform: uppercase; +} +.tool-chip .tool-status.on { background: rgba(110,240,141,0.15); color: var(--ok); border: 1px solid rgba(110,240,141,0.3); } +.tool-chip .tool-status.off { background: rgba(255,255,255,0.05); color: var(--text-dim3); border: 1px solid var(--line); } +.tools-section-label { + grid-column: 1 / -1; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-dim3); + font-weight: 800; + padding: 8px 4px 2px; +} + +/* ========== 异步任务 Runs ========== */ +.runs-layout { + flex: 1 1 auto; + min-height: 0; + display: grid; + grid-template-columns: 1fr 1.4fr; + gap: 14px; + overflow: hidden; +} +@media (max-width: 900px) { + .runs-layout { grid-template-columns: 1fr; overflow: auto; } +} + +.runs-left, .runs-right { + display: flex; + flex-direction: column; + background: var(--card-bg); + border: 1px solid var(--line); + border-radius: 14px; + overflow: hidden; + min-height: 0; +} + +.runs-input-head, +.runs-events-head { + padding: 12px 16px; + border-bottom: 1px solid var(--line); + font-size: 12px; + font-weight: 700; + color: var(--text-dim); + letter-spacing: 0.3px; + flex: 0 0 auto; + display: flex; + justify-content: space-between; + align-items: center; +} +.runs-meta { + font-size: 10px; + color: var(--text-dim3); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.runs-meta.running { color: var(--warn); } +.runs-meta.done { color: var(--ok); } +.runs-meta.err { color: var(--err); } + +#runPrompt { + flex: 1 1 auto; + min-height: 160px; + background: var(--input-bg); + border: 0; + color: var(--text); + padding: 18px 20px; + font-family: inherit; + font-size: 14px; + line-height: 1.6; + resize: none; + outline: none; +} +#runPrompt::placeholder { color: var(--text-dim3); } + +.runs-hint { + font-size: 11px; + color: var(--text-dim2); + padding: 10px 16px; + border-top: 1px solid var(--line); + background: rgba(255,105,0,0.04); + flex: 0 0 auto; +} +.runs-active { + padding: 12px 16px; + font-size: 11px; + color: var(--text-dim2); + border-top: 1px solid var(--line); + font-family: "SF Mono", ui-monospace, Menlo, monospace; + flex: 0 0 auto; + min-height: 36px; +} +.runs-active code { color: var(--orange-3); } + +.runs-events { + flex: 1 1 auto; + overflow-y: auto; + padding: 14px 18px; + font-family: "SF Mono", ui-monospace, Menlo, monospace; + font-size: 11.5px; + line-height: 1.7; +} +.run-evt { + display: grid; + grid-template-columns: 60px auto 1fr; + gap: 10px; + padding: 6px 0; + border-bottom: 1px dashed rgba(255,255,255,0.05); + align-items: baseline; +} +[data-theme="light"] .run-evt { border-bottom-color: rgba(15,22,40,0.08); } +.run-evt .t { color: var(--text-dim3); font-size: 10px; } +.run-evt .tag { + display: inline-block; + padding: 1px 8px; + border-radius: 4px; + font-size: 9px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.5px; + white-space: nowrap; +} +.run-evt .tag.start { background: rgba(110,240,141,0.14); color: var(--ok); border: 1px solid rgba(110,240,141,0.3); } +.run-evt .tag.delta { background: rgba(255,105,0,0.12); color: var(--orange-3); border: 1px solid var(--orange-dim); } +.run-evt .tag.tool { background: rgba(124,170,255,0.14); color: #7caaff; border: 1px solid rgba(124,170,255,0.3); } +.run-evt .tag.ok { background: rgba(110,240,141,0.14); color: var(--ok); border: 1px solid rgba(110,240,141,0.3); } +.run-evt .tag.err { background: rgba(255,93,122,0.14); color: var(--err); border: 1px solid rgba(255,93,122,0.3); } +.run-evt .tag.info { background: rgba(255,255,255,0.06); color: var(--text-dim); border: 1px solid var(--line); } +.run-evt .body { + color: var(--text); + white-space: pre-wrap; + word-wrap: break-word; + word-break: break-word; + overflow: hidden; +} +.run-evt.delta .body { color: var(--text-dim); } + +/* ========== Markdown 渲染(消息/记忆/帮助) ========== */ +.msg-content h1, .msg-content h2, .msg-content h3, +.msg-content h4, .msg-content h5, .msg-content h6 { + margin: 18px 0 8px; + font-weight: 800; + color: var(--text); + letter-spacing: -0.2px; +} +.msg-content h1 { font-size: 22px; } +.msg-content h2 { font-size: 18px; } +.msg-content h3 { font-size: 16px; } +.msg-content h4 { font-size: 14px; } +.msg-content h1:first-child, .msg-content h2:first-child, .msg-content h3:first-child { margin-top: 2px; } +.msg-content p { margin: 6px 0; line-height: 1.75; } +.msg-content p:first-child { margin-top: 0; } +.msg-content p:last-child { margin-bottom: 0; } +.msg-content ul, .msg-content ol { margin: 8px 0; padding-left: 22px; } +.msg-content ul li, .msg-content ol li { margin: 4px 0; line-height: 1.7; } +.msg-content blockquote { + margin: 8px 0; + padding: 6px 14px; + border-left: 3px solid var(--orange); + background: rgba(255,105,0,0.06); + color: var(--text-dim); + border-radius: 0 8px 8px 0; +} +.msg-content code { + background: rgba(255,105,0,0.12); + color: var(--orange-3); + padding: 1px 7px; + border-radius: 4px; + font-size: 0.92em; + font-family: "SF Mono", ui-monospace, "JetBrains Mono", Menlo, monospace; + border: 1px solid rgba(255,105,0,0.18); +} +.msg-content a { color: var(--orange-3); text-decoration: underline; } +.msg-content strong { color: var(--text); font-weight: 800; } +.msg-content .md-pre { + position: relative; + margin: 10px 0; + padding: 14px 16px; + background: rgba(0,0,0,0.35); + border: 1px solid var(--line); + border-radius: 10px; + overflow-x: auto; +} +[data-theme="light"] .msg-content .md-pre { background: rgba(15,22,40,0.06); } +.msg-content .md-pre code { + background: transparent; + border: 0; + color: var(--text); + padding: 0; + font-size: 12px; + line-height: 1.65; + white-space: pre; +} +.msg-content .md-code-lang { + position: absolute; + top: 6px; right: 10px; + font-size: 10px; + color: var(--text-dim3); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.msg-content .md-table { + width: 100%; + margin: 10px 0; + border-collapse: collapse; + font-size: 12.5px; +} +.msg-content .md-table th, +.msg-content .md-table td { + padding: 8px 12px; + border: 1px solid var(--line); + text-align: left; +} +.msg-content .md-table th { + background: rgba(255,105,0,0.08); + color: var(--orange-3); + font-weight: 800; +} +.msg-content .md-table tr:nth-child(even) td { background: rgba(255,255,255,0.02); } + +/* Tool calls 展示 */ +.msg-toolcalls { + margin-top: 10px; + padding-top: 8px; + border-top: 1px dashed var(--line); +} +.msg-toolcalls .tc-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + background: rgba(124,170,255,0.1); + border: 1px solid rgba(124,170,255,0.28); + border-radius: 8px; + color: #7caaff; + font-size: 11px; + font-weight: 700; + cursor: pointer; + font-family: inherit; +} +.msg-toolcalls .tc-toggle:hover { background: rgba(124,170,255,0.2); } +.msg-toolcalls .tc-body { display: none; margin-top: 8px; flex-direction: column; gap: 6px; } +.msg-toolcalls.open .tc-body { display: flex; } +.msg-toolcalls .tc-item { + padding: 8px 12px; + background: rgba(0,0,0,0.25); + border: 1px solid var(--line); + border-radius: 8px; +} +[data-theme="light"] .msg-toolcalls .tc-item { background: rgba(15,22,40,0.04); } +.msg-toolcalls .tc-name { + font-size: 11px; + font-weight: 800; + color: var(--orange-3); + font-family: "SF Mono", ui-monospace, Menlo, monospace; + margin-bottom: 4px; +} +.msg-toolcalls .tc-args { + margin: 0; + font-size: 10.5px; + line-height: 1.55; + color: var(--text-dim); + white-space: pre-wrap; + word-break: break-all; + font-family: "SF Mono", ui-monospace, Menlo, monospace; +} + +/* 帮助 tab · 官方 Console iframe */ +.help-iframe { + flex: 1 1 auto; + width: 100%; + border: 1px solid var(--line); + border-radius: 14px; + background: #000; + min-height: 0; +} + +/* 帮助 tab 老版本文档卡(隐藏但保留样式以防恢复) */ +.help-scroll { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding-right: 6px; + display: flex; + flex-direction: column; + gap: 16px; +} +.help-card { + padding: 22px 26px; + background: var(--card-bg); + border: 1px solid var(--line); + border-radius: 16px; +} +.help-card h3 { + margin: 0 0 10px; + font-size: 16px; + font-weight: 800; + letter-spacing: -0.2px; +} +.help-card p { margin: 6px 0; font-size: 13px; line-height: 1.7; color: var(--text-dim); } +.help-card ul { margin: 8px 0; padding-left: 20px; } +.help-card ul li { margin: 4px 0; font-size: 13px; line-height: 1.7; color: var(--text-dim); } +.help-card pre.md-pre { + background: rgba(0,0,0,0.3); + border: 1px solid var(--line); + border-radius: 10px; + padding: 14px 16px; + overflow-x: auto; + font-size: 11.5px; + line-height: 1.6; + color: var(--text); +} +.help-card .md-table { width: 100%; margin: 10px 0; border-collapse: collapse; font-size: 12.5px; } +.help-card .md-table th, .help-card .md-table td { padding: 8px 12px; border: 1px solid var(--line); text-align: left; } +.help-card .md-table th { background: rgba(255,105,0,0.08); color: var(--orange-3); } + +/* ========== 响应式 ========== */ +@media (max-width: 1024px) { + .dash-hero { grid-template-columns: 1fr; } + .dash-hero-side { flex-direction: row; flex-wrap: wrap; } + .dash-hero-side .hero-side-item { flex: 1 1 120px; } +} + +@media (max-width: 860px) { + .app-shell { flex-direction: column; padding: 10px; gap: 10px; height: 100vh; } + .sidebar { + width: 100%; + flex: 0 0 auto; + flex-direction: row; + padding: 10px 12px; + border-radius: 18px; + } + .side-brand { border-bottom: 0; margin-bottom: 0; padding: 0 10px 0 4px; border-right: 1px solid var(--line); margin-right: 10px; } + .side-brand-text { display: none; } + .side-nav { flex-direction: row; flex: 1; overflow-x: auto; padding: 0; border: 0; margin: 0; } + .side-history { display: none; } + .side-item { padding: 8px 12px; } + .side-item span { display: none; } + .side-bottom { flex-direction: row; border: 0; padding: 0; margin-left: 10px; gap: 6px; } + .side-status { display: none; } + .side-new span, .theme-toggle .theme-label { display: none; } + .side-new, .theme-toggle { padding: 8px 12px; } + .tab-panel { padding: 16px 14px; } + .msg { max-width: 100%; } + .panel-head-row { flex-direction: column; align-items: stretch; } + .panel-head-actions { justify-content: flex-end; } + .dash-hero-num { font-size: 48px; letter-spacing: -2px; } +} + +@media (max-width: 560px) { + .hermes-tag-mid { display: none; } + .chat-topbar-title { flex: 1 1 100%; } +} diff --git a/src/sw.js b/src/sw.js new file mode 100644 index 0000000..d7108b0 --- /dev/null +++ b/src/sw.js @@ -0,0 +1,35 @@ +// 爱马仕 Hermes · 轻量 Service Worker +// 只缓存静态壳,API 请求始终联网 +const CACHE = "hermes-ui-v6"; +const ASSETS = [ + "./", + "./index.html", + "./styles.css", + "./app.js", + "./manifest.webmanifest", + "./icon.svg", +]; + +self.addEventListener("install", (e) => { + e.waitUntil(caches.open(CACHE).then((c) => c.addAll(ASSETS))); + self.skipWaiting(); +}); + +self.addEventListener("activate", (e) => { + e.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))) + ) + ); + self.clients.claim(); +}); + +self.addEventListener("fetch", (e) => { + const url = new URL(e.request.url); + // API 请求直通 + if (url.pathname.startsWith("/api/")) return; + // 其他静态资源走 cache-first + e.respondWith( + caches.match(e.request).then((hit) => hit || fetch(e.request)) + ); +});