From 4a33c708e9944149de69756f2bc15aec7111cdf1 Mon Sep 17 00:00:00 2001 From: argszero Date: Thu, 6 Aug 2026 07:09:48 +0800 Subject: [PATCH 1/2] =?UTF-8?q?emrg:=20GUI=20=E5=AE=8C=E5=85=A8=E9=87=8D?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=20P1=20=E2=80=94=20=E5=8F=8C=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=20token=20+=20=E5=B8=83=E5=B1=80=20+=20renderer=20?= =?UTF-8?q?=E9=AA=A8=E6=9E=B6=EF=BC=88rant=2008-05=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- emrg/gui/renderer/app.css | 316 ------------ emrg/gui/renderer/app.js | 647 ------------------------- emrg/gui/renderer/css/animations.css | 91 ++++ emrg/gui/renderer/css/base.css | 108 +++++ emrg/gui/renderer/css/components.css | 578 ++++++++++++++++++++++ emrg/gui/renderer/css/layout.css | 194 ++++++++ emrg/gui/renderer/css/tokens.css | 139 ++++++ emrg/gui/renderer/index.html | 167 +++++-- emrg/gui/renderer/js/app.js | 483 ++++++++++++++++++ emrg/gui/renderer/js/chat.js | 184 +++++++ emrg/gui/renderer/js/copywriting.js | 36 ++ emrg/gui/renderer/js/dialogs.js | 174 +++++++ emrg/gui/renderer/{ => js}/markdown.js | 0 emrg/gui/renderer/js/sidebar.js | 55 +++ emrg/gui/renderer/js/utils.js | 74 +++ 15 files changed, 2231 insertions(+), 1015 deletions(-) delete mode 100644 emrg/gui/renderer/app.css delete mode 100644 emrg/gui/renderer/app.js create mode 100644 emrg/gui/renderer/css/animations.css create mode 100644 emrg/gui/renderer/css/base.css create mode 100644 emrg/gui/renderer/css/components.css create mode 100644 emrg/gui/renderer/css/layout.css create mode 100644 emrg/gui/renderer/css/tokens.css create mode 100644 emrg/gui/renderer/js/app.js create mode 100644 emrg/gui/renderer/js/chat.js create mode 100644 emrg/gui/renderer/js/copywriting.js create mode 100644 emrg/gui/renderer/js/dialogs.js rename emrg/gui/renderer/{ => js}/markdown.js (100%) create mode 100644 emrg/gui/renderer/js/sidebar.js create mode 100644 emrg/gui/renderer/js/utils.js diff --git a/emrg/gui/renderer/app.css b/emrg/gui/renderer/app.css deleted file mode 100644 index 8d42fe6..0000000 --- a/emrg/gui/renderer/app.css +++ /dev/null @@ -1,316 +0,0 @@ -/* app.css — EMRG GUI 暗色主题(对齐 TUI 终端风格:近黑背景 + 绿/黄/红状态色) */ -:root { - --bg: #1a1b26; - --bg-panel: #16161e; - --bg-input: #0f1017; - --border: #2a2b3a; - --text: #c0caf5; - --text-dim: #565f89; - --green: #9ece6a; - --yellow: #e0af68; - --red: #f7768e; - --blue: #7aa2f7; - --user-bubble: #283457; - --assistant-bg: #1f2335; -} - -* { box-sizing: border-box; margin: 0; padding: 0; } - -html, body { - height: 100%; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Noto Sans CJK SC", sans-serif; - font-size: 14px; - background: var(--bg); - color: var(--text); - overflow: hidden; -} - -#app { - display: flex; - height: 100vh; -} - -/* ── 左侧栏 ─────────────────────────── */ -#sidebar { - width: 240px; - min-width: 200px; - background: var(--bg-panel); - border-right: 1px solid var(--border); - display: flex; - flex-direction: column; -} - -#sidebar-header { - padding: 12px; - border-bottom: 1px solid var(--border); -} -#sidebar-header h1 { - font-size: 16px; - letter-spacing: 1px; - color: var(--green); -} -#sidebar-header .sub { font-size: 11px; color: var(--text-dim); margin-top: 2px; } - -#session-list { - flex: 1; - overflow-y: auto; - padding: 6px; -} -.session-item { - padding: 8px 10px; - border-radius: 6px; - cursor: pointer; - margin-bottom: 2px; - display: flex; - justify-content: space-between; - gap: 6px; - font-size: 13px; -} -.session-item:hover { background: var(--bg-input); } -.session-item.active { background: var(--user-bubble); color: #fff; } -.session-item.placeholder { color: var(--text-dim); cursor: default; } -.sess-title { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.sess-count { color: var(--text-dim); font-size: 11px; flex-shrink: 0; } -.session-item.active .sess-count { color: #a9b1d6; } - -#sidebar-footer { - padding: 10px 12px; - border-top: 1px solid var(--border); - display: flex; - flex-direction: column; - gap: 6px; - font-size: 12px; -} -#sidebar-footer .row { display: flex; align-items: center; gap: 6px; } -.dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; } -.dot.green { background: var(--green); } -.dot.red { background: var(--red); } -.dot.gray { background: var(--text-dim); } -#server-id { color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -#project-name { color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - -.sidebar-btn { - background: none; - border: 1px solid var(--border); - color: var(--text); - padding: 6px 10px; - border-radius: 6px; - cursor: pointer; - font-size: 12px; - text-align: left; -} -.sidebar-btn:hover { border-color: var(--blue); } - -/* ── 主区域 ─────────────────────────── */ -#main { - flex: 1; - display: flex; - flex-direction: column; - min-width: 0; -} - -#topbar { - height: 44px; - border-bottom: 1px solid var(--border); - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 16px; - font-size: 13px; - color: var(--text-dim); -} -#topbar .model { color: var(--yellow); } - -#chat-view { - flex: 1; - overflow-y: auto; - padding: 16px; - display: flex; - flex-direction: column; - gap: 10px; - background: var(--bg); -} - -.msg { - max-width: 85%; - padding: 10px 14px; - border-radius: 10px; - line-height: 1.55; - word-break: break-word; - white-space: pre-wrap; -} -.msg.user { - align-self: flex-end; - background: var(--user-bubble); - color: #e8ecf8; -} -.msg.assistant { - align-self: flex-start; - background: var(--assistant-bg); - border: 1px solid var(--border); -} -.msg.assistant.remote { border-color: var(--yellow); } -.msg.system { - align-self: center; - background: none; - color: var(--text-dim); - font-size: 12px; - padding: 4px 10px; - max-width: 95%; -} -.remote-label { font-size: 11px; color: var(--yellow); margin-bottom: 4px; } - -/* markdown 渲染 */ -.msg-body { color: var(--text); } -.msg-body p { margin: 0 0 8px; } -.msg-body p:last-child { margin-bottom: 0; } -.msg-body pre { - background: var(--bg-input); - border: 1px solid var(--border); - border-radius: 8px; - padding: 10px; - overflow-x: auto; - font-size: 12.5px; - margin: 8px 0; -} -.msg-body code { - font-family: "SF Mono", "Fira Code", Menlo, Consolas, monospace; - background: var(--bg-input); - padding: 1px 5px; - border-radius: 4px; - font-size: 12.5px; -} -.msg-body pre code { padding: 0; background: none; } -.msg-body ul, .msg-body ol { padding-left: 20px; margin: 6px 0; } -.msg-body blockquote { border-left: 3px solid var(--blue); padding-left: 10px; color: var(--text-dim); margin: 6px 0; } -.msg-body h1, .msg-body h2, .msg-body h3, .msg-body h4 { margin: 10px 0 6px; color: #e0e5f8; } -.msg-body a { color: var(--blue); } - -/* 工具卡片 */ -.tool-card { - align-self: flex-start; - min-width: 320px; - max-width: 85%; - background: var(--bg-panel); - border: 1px solid var(--border); - border-left: 3px solid var(--yellow); - border-radius: 8px; - padding: 8px 12px; - font-size: 13px; - margin: 2px 0; -} -.tool-card.running { border-left-color: var(--yellow); } -.tool-card.done { border-left-color: var(--green); } -.tool-card.failed { border-left-color: var(--red); } -.tool-name { font-family: "SF Mono", Menlo, monospace; font-weight: 600; } -.tool-status { color: var(--text-dim); margin-left: 8px; font-size: 12px; } -.tool-output { - margin-top: 6px; - background: var(--bg-input); - border-radius: 6px; - padding: 6px 10px; - font-family: "SF Mono", Menlo, monospace; - font-size: 12px; - max-height: 180px; - overflow-y: auto; - white-space: pre-wrap; - word-break: break-all; -} -.tool-expand { - margin-top: 4px; - background: none; - border: none; - color: var(--blue); - cursor: pointer; - font-size: 12px; - padding: 0; -} - -/* ── 输入区 ─────────────────────────── */ -#composer { - border-top: 1px solid var(--border); - padding: 10px 16px; - display: flex; - gap: 8px; - align-items: flex-end; - background: var(--bg-panel); -} -#input { - flex: 1; - resize: none; - background: var(--bg-input); - border: 1px solid var(--border); - border-radius: 8px; - color: var(--text); - padding: 10px 12px; - font-size: 14px; - font-family: inherit; - line-height: 1.5; - max-height: 150px; - outline: none; -} -#input:focus { border-color: var(--blue); } -#input:disabled { opacity: 0.6; } - -#send-btn, #stop-btn { - padding: 10px 18px; - border-radius: 8px; - border: none; - cursor: pointer; - font-size: 14px; - font-weight: 600; -} -#send-btn { background: var(--blue); color: #fff; } -#send-btn:hover { filter: brightness(1.1); } -#send-btn:disabled { opacity: 0.5; cursor: default; } -#stop-btn { display: none; background: var(--red); color: #fff; } - -/* ── 对话框(首启引导 / 设置)────────── */ -dialog { - background: var(--bg-panel); - color: var(--text); - border: 1px solid var(--border); - border-radius: 12px; - padding: 24px; - min-width: 420px; - max-width: 90vw; -} -dialog::backdrop { background: rgba(0, 0, 0, 0.6); } -dialog h2 { font-size: 16px; margin-bottom: 6px; color: var(--green); } -dialog .desc { font-size: 12px; color: var(--text-dim); margin-bottom: 14px; } -.form-row { margin-bottom: 12px; } -.form-row label { display: block; font-size: 12px; color: var(--text-dim); margin-bottom: 4px; } -.form-row input, .form-row select { - width: 100%; - background: var(--bg-input); - border: 1px solid var(--border); - border-radius: 6px; - color: var(--text); - padding: 8px 10px; - font-size: 13px; - outline: none; -} -.form-row input:focus { border-color: var(--blue); } -.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; } -.dialog-actions button { - padding: 8px 16px; - border-radius: 6px; - border: 1px solid var(--border); - background: none; - color: var(--text); - cursor: pointer; - font-size: 13px; -} -.dialog-actions button.primary { background: var(--blue); border-color: var(--blue); color: #fff; font-weight: 600; } -.dialog-actions button:hover { filter: brightness(1.1); } -.dir-row { display: flex; gap: 6px; } -.dir-row input { flex: 1; } -.dir-row button { flex-shrink: 0; } - -::-webkit-scrollbar { width: 8px; height: 8px; } -::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } -::-webkit-scrollbar-track { background: transparent; } diff --git a/emrg/gui/renderer/app.js b/emrg/gui/renderer/app.js deleted file mode 100644 index 6343b31..0000000 --- a/emrg/gui/renderer/app.js +++ /dev/null @@ -1,647 +0,0 @@ -"use strict"; -/** - * app.js — renderer UI 逻辑:聊天渲染、会话列表、工具状态、事件绑定。 - * 事件已由 main 分类(onEvent 收 {type, data}),renderer 不重复做帧分类(G67)。 - */ - -const $ = (id) => document.getElementById(id); - -const state = { - sessionId: null, - sessions: [], - busy: false, - ownStreamRequestId: null, - apiKeyConfigured: false, - configExists: false, - projectDir: "", - model: "", - serverId: "", - autoScroll: true, - groupNodes: new Map(), // requestId → DOM 节点 - toolCards: new Map(), // tool_call_id → DOM 节点 -}; - -// ── 启动 ───────────────────────────────────────────────── - -async function boot() { - try { - const init = await window.emrg.init(); - state.configExists = init.config_exists; - state.apiKeyConfigured = init.api_key_configured; - state.projectDir = init.project_dir || ""; - state.serverId = init.server_id || ""; - state.model = init.model || ""; - updateStatusBar(init); - updateEvolutionCount(init.evolution_count); - - if (!init.config_exists) { - showWelcome(); // G71:config 缺失 → 首启引导(不拉起 daemon) - return; - } - if (!init.api_key_configured) { - showSettings(); // G36:config 存在但 key 空/占位符 - return; - } - if (!init.project_dir_valid) { - addSystemMessage("⚠️ 项目目录无效或不可写,请到设置中修正。"); - showSettings(); - return; - } - if (init.sessions && init.sessions.length > 0) { - renderSessions(init.sessions); - // 保持当前会话(boot 会因 saveSettings/saveWelcome 重跑——保存设置后不应弹回最新创建会话) - const current = init.sessions.find((s) => s.session_id === state.sessionId); - if (current) { - highlightActiveSession(state.sessionId); - } else { - await switchSession(init.sessions[0].session_id, { silent: true }); - } - } else { - await newSession(); - } - } catch (e) { - addSystemMessage(`启动失败: ${e.message}`); - } -} - -// ── UI 绑定 ────────────────────────────────────────────── - -function bindUi() { - $("send-btn").addEventListener("click", sendMessage); - $("stop-btn").addEventListener("click", () => window.emrg.cancel().catch(() => {})); // G141:断连瞬间 IPC 可能 reject——防 unhandled rejection - $("new-session-btn").addEventListener("click", newSession); - $("settings-btn").addEventListener("click", showSettings); - $("settings-cancel").addEventListener("click", () => $("settings-dialog").close()); - $("settings-save").addEventListener("click", saveSettings); - $("pick-dir-btn").addEventListener("click", async () => { - const dir = await window.emrg.pickProjectDir(); - if (dir) $("set-project-dir").value = dir; - }); - $("welcome-pick-btn").addEventListener("click", async () => { - const dir = await window.emrg.pickProjectDir(); - if (dir) $("welcome-project-dir").value = dir; - }); - $("welcome-save").addEventListener("click", saveWelcome); - - const input = $("input"); - input.addEventListener("keydown", (e) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - sendMessage(); - } - }); - input.addEventListener("input", () => { - input.style.height = "auto"; - input.style.height = Math.min(input.scrollHeight, 150) + "px"; - }); - - const chatView = $("chat-view"); - chatView.addEventListener("scroll", () => { - state.autoScroll = chatView.scrollTop + chatView.clientHeight >= chatView.scrollHeight - 40; - }); -} - -// ── 发送 ───────────────────────────────────────────────── - -async function sendMessage() { - const input = $("input"); - const text = input.value.trim(); - if (!text || state.busy) return; - if (!state.sessionId) { - addSystemMessage("请先创建/选择会话。"); - return; - } - state.busy = true; - setComposerDisabled(true); - addUserMessage(text); - input.value = ""; - input.style.height = "auto"; - // G143:send 前预生成 requestId 并标记自有流——消除 IPC 往返竞态窗口 - // (之前 await sendMessage 返回后才赋值,delta/tool_start 可能先到 → 自有流被误标「来自其他客户端」) - const requestId = genRequestId(); - state.ownStreamRequestId = requestId; - try { - const res = await window.emrg.sendMessage({ sessionId: state.sessionId, text, requestId }); - state.ownStreamRequestId = res.requestId || requestId; // G124:以 daemon 实际回显为准 - } catch (e) { - state.busy = false; - state.ownStreamRequestId = null; - setComposerDisabled(false); - addSystemMessage(`发送失败: ${e.message}(输入已恢复)`); - input.value = text; // G49:失败恢复输入框 - } -} - -// ── 会话 ───────────────────────────────────────────────── - -async function switchSession(sid, opts = {}) { - // G65:busy 即自有流进行中/发送中(IPC 往返窗口内 ownStreamRequestId 尚未赋值, - // 只查 busy 才能覆盖该窗口——与 main 侧 ownStream 锁语义对齐) - if (state.busy) { - addSystemMessage("当前有进行中的响应,请等待完成或停止后再切换。"); // G65 - return; - } - try { - const res = await window.emrg.switchSession({ sessionId: sid }); - state.sessionId = sid; - state.ownStreamRequestId = null; // G110:切会话清自有流标记 - clearChat(); - if (res.error === "session_not_found") { - addSystemMessage("会话已被删除,已切换到最近会话。"); - if (res.next_session) { - renderSessions(res.sessions || []); - await switchSession(res.next_session, { silent: true }); - } else { - await newSession(); - } - return; - } - // G13:历史不通过 resume 返回——v1 不加载历史(G12) - addSystemMessage(`已切换到会话 ${sid}`); - highlightActiveSession(sid); - } catch (e) { - addSystemMessage(`切换失败: ${e.message}`); - } -} - -async function newSession() { - if (state.busy) { - addSystemMessage("当前有进行中的响应,请等待完成或停止后再新建会话。"); // G65 同款锁 - return; - } - try { - const res = await window.emrg.newSession(); - const sid = res.session_id; - // 本地切订阅(resume 不存在的新会话会被 daemon 自动创建) - state.sessionId = sid; - clearChat(); - addSystemMessage(`新会话 ${sid}`); - await refreshSessions(); - highlightActiveSession(sid); - } catch (e) { - addSystemMessage(`新建失败: ${e.message}`); - } -} - -async function deleteSession(sid) { - // G65:自有流运行中禁止删除当前会话(删除后自动切换会被 busy 锁拒绝 → sessionId 指向已删会话) - if (state.busy && state.sessionId === sid) { - addSystemMessage("当前有进行中的响应,请等待完成或停止后再删除会话。"); - return; - } - if (!confirm(`确定删除会话 ${sid}?`)) return; // G76 - try { - await window.emrg.deleteSession({ sessionId: sid }); - if (state.sessionId === sid) { - const remaining = state.sessions.filter((s) => s.session_id !== sid); - if (remaining.length > 0) { - await switchSession(remaining[0].session_id, { silent: true }); - } else { - await newSession(); - } - } - await refreshSessions(); - } catch (e) { - addSystemMessage(`删除失败: ${e.message}`); - } -} - -async function refreshSessions() { - try { - const sessions = await window.emrg.listSessions(); - renderSessions(sessions); - } catch { /* 忽略 */ } -} - -// ── 设置 ───────────────────────────────────────────────── - -async function showSettings() { - try { - const s = await window.emrg.getSettings(); - $("set-api-key").value = s.apiKey || ""; - $("set-base-url").value = s.baseUrl || ""; - $("set-project-dir").value = s.projectDir || ""; - const sel = $("set-model"); - sel.innerHTML = ""; - for (const m of s.models || []) { - const opt = document.createElement("option"); - opt.value = m; - opt.textContent = m; - if (m === s.model) opt.selected = true; - sel.appendChild(opt); - } - if (!s.models?.length && s.model) { - const opt = document.createElement("option"); - opt.value = s.model; - opt.textContent = s.model; - opt.selected = true; - sel.appendChild(opt); - } - if (sel.selectedIndex < 0) sel.selectedIndex = 0; // G144:默认选中第一项,防空 model 保存 - } catch (e) { - addSystemMessage(`读取设置失败: ${e.message}`); - } - $("settings-dialog").showModal(); -} - -async function saveSettings() { - const config = { - apiKey: $("set-api-key").value.trim(), - baseUrl: $("set-base-url").value.trim(), - model: $("set-model").value, - projectDir: $("set-project-dir").value.trim(), - }; - if (!config.apiKey) { - alert("API Key 必填"); // G76 - return; - } - try { - const res = await window.emrg.saveSettings(config); - if ($("settings-dialog").open) $("settings-dialog").close(); // form method=dialog 可能已关 - addSystemMessage("设置已保存。"); - state.apiKeyConfigured = true; - state.projectDir = config.projectDir || state.projectDir; - await boot(); - } catch (e) { - addSystemMessage(`保存失败: ${e.message}`); - } -} - -// ── 首启引导(G36/G71/G82/G116/G117)────────────────── - -function showWelcome() { - $("welcome-project-dir").value = ""; // 跳过 → 默认 ~/.emrg/evolution(G82) - $("welcome-api-key").value = ""; - $("welcome-base-url").value = ""; - $("welcome-model").innerHTML = ""; - $("welcome-dialog").showModal(); - // 尝试预加载模型列表(config 缺失时 getSettings 返回默认值 G52) - window.emrg.getSettings().then((s) => { - const sel = $("welcome-model"); - sel.innerHTML = ""; - const models = s.models?.length ? s.models : ["deepseek-chat", "deepseek-v3", "gpt-4o"]; - for (const m of models) { - const opt = document.createElement("option"); - opt.value = m; - opt.textContent = m; - if (m === s.model) opt.selected = true; // G144:config 存在时回填当前模型 - sel.appendChild(opt); - } - if (sel.selectedIndex < 0) sel.selectedIndex = 0; // G144:默认选中第一项,防空 model 保存 - }); -} - -async function saveWelcome() { - const key = $("welcome-api-key").value.trim(); - if (!key) { - alert("API Key 必填"); - return; - } - const config = { - apiKey: key, - baseUrl: $("welcome-base-url").value.trim(), - model: $("welcome-model").value || "deepseek-chat", // G144:空 model 兜底默认模型 - projectDir: $("welcome-project-dir").value.trim() || "", - }; - try { - await window.emrg.saveSettings(config); - if ($("welcome-dialog").open) $("welcome-dialog").close(); - addSystemMessage("初始设置完成,正在启动 daemon…"); - state.apiKeyConfigured = true; - state.projectDir = config.projectDir || state.projectDir; - await boot(); - } catch (e) { - addSystemMessage(`初始化失败: ${e.message}`); - } -} - -// ── 渲染 ───────────────────────────────────────────────── - -function addUserMessage(text) { - const node = document.createElement("div"); - node.className = "msg user"; - node.textContent = text; - appendMsg(node); - return node; -} - -function addAssistantMessage(text) { - const node = document.createElement("div"); - node.className = "msg assistant"; - node.textContent = text || ""; - appendMsg(node); - return node; -} - -function addSystemMessage(text) { - const node = document.createElement("div"); - node.className = "msg system"; - node.textContent = text; - appendMsg(node); -} - -function appendMsg(node) { - $("chat-view").appendChild(node); - scrollToBottom(); -} - -function clearChat() { - $("chat-view").innerHTML = ""; - state.groupNodes.clear(); - state.toolCards.clear(); -} - -function scrollToBottom() { - if (state.autoScroll) { - const cv = $("chat-view"); - cv.scrollTop = cv.scrollHeight; - } -} - -function renderSessions(sessions) { - state.sessions = sessions || []; - const list = $("session-list"); - list.innerHTML = ""; - if (!state.sessions.length) { - list.innerHTML = "
暂无会话
"; - return; - } - for (const s of state.sessions) { - const item = document.createElement("div"); - item.className = "session-item"; - const title = s.title || s.session_id; // G27:title 优先 session_id 兜底 - const count = s.message_count || 0; - item.innerHTML = `${escapeHtml(title)}${count} msgs`; - item.addEventListener("click", () => switchSession(s.session_id)); - item.addEventListener("contextmenu", (e) => { - e.preventDefault(); - deleteSession(s.session_id); - }); - list.appendChild(item); - } - highlightActiveSession(state.sessionId); -} - -function highlightActiveSession(sid) { - for (const item of $("session-list").children) { - item.classList.toggle("active", item.textContent.includes(sid) && item.classList.contains("session-item")); - } -} - -function updateStatusBar(init) { - $("daemon-dot").className = "dot " + (init.config_exists && init.api_key_configured ? "green" : "gray"); - $("server-id").textContent = init.server_id || "—"; - $("model-name").textContent = init.model || "—"; - $("project-name").textContent = init.project_dir ? `📁 ${init.project_dir}` : ""; -} - -function setComposerDisabled(disabled) { - $("input").disabled = disabled; - $("send-btn").disabled = disabled; - $("stop-btn").style.display = disabled ? "inline-block" : "none"; -} - -function escapeHtml(s) { - return String(s).replace(/&/g, "&").replace(//g, ">"); -} - -// G143:生成 UUID(secure context 下 crypto.randomUUID;低版本兜底) -function genRequestId() { - if (window.crypto && typeof window.crypto.randomUUID === "function") { - return window.crypto.randomUUID(); - } - return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { - const r = (Math.random() * 16) | 0; - return (c === "x" ? r : (r & 0x3) | 0x8).toString(16); - }); -} - -// ── 事件处理(main 已分类)─────────────────────────────── - -async function handleEvent(evt) { - const { type, data } = evt; - switch (type) { - case "message_delta": - // G122:main 已批量(chunks 数组) - handleDelta(data.chunks || [data]); - break; - case "done": - handleDone(data); - break; - case "tool_started": - handleToolStart(data); - break; - case "tool_finished": - handleToolEnd(data); - break; - case "cancelled": - state.busy = false; - state.ownStreamRequestId = null; - setComposerDisabled(false); - break; - case "error": - handleError(data); - break; - case "pong": - state.serverId = data.identity?.instance_id || state.serverId; - state.model = data.model || state.model; - $("server-id").textContent = state.serverId || "—"; - $("model-name").textContent = state.model || "—"; - updateEvolutionCount(data.evolution_count); - break; - case "status": - handleStatus(data); - break; - case "sessions": - renderSessions(data.sessions || []); - break; - case "disconnected": - $("daemon-dot").className = "dot red"; - addSystemMessage("⏸ 与 daemon 的连接已断开,重连中…"); - // G89:断连时恢复输入条(流式中断连 → busy 永久卡死;不能依赖 30s 超时兜底) - state.busy = false; - state.ownStreamRequestId = null; - setComposerDisabled(false); - // G97:广播分组缓存清理(DOM 保留——历史消息应保留;仅清 Map 引用,防重连后"幽灵"分组缓存) - // daemon_client 侧 clearGroups() 已 emit group_cleared,此处兜底直接清空 - state.groupNodes.clear(); - // G97:工具卡片 tool_start 已建 / tool_end 未到 → 标「结果未知——连接中断」 - // (无重试语义——工具副作用不可重放;重连后若收到真实 tool_end 会被 handleToolEnd 纠正) - for (const card of state.toolCards.values()) { - if (card.classList.contains("running")) { - card.classList.remove("running"); - card.classList.add("failed"); - const st = card.querySelector(".tool-status"); - if (st) st.textContent = "结果未知——连接中断"; - } - } - break; - case "group_cleared": - // DOM 保留,仅清缓存 - state.groupNodes.delete(data.requestId); - break; - case "list_result": - if (data.type === "sessions_list") renderSessions(data.sessions || []); - break; - case "command_result": - if (data.type === "model_set") { - state.model = data.model || state.model; - $("model-name").textContent = state.model; - } - break; - default: - // 忽略未知 - break; - } -} - -function handleDelta(chunks) { - // G3:广播分组——新 request_id 建新节点并标注「来自其他客户端」 - for (const chunk of chunks) { - const rid = chunk.request_id; - if (!rid) continue; - let node = state.groupNodes.get(rid); - if (!node) { - const isOwn = state.ownStreamRequestId === rid; - node = document.createElement("div"); - node.className = "msg assistant"; - if (!isOwn) node.classList.add("remote"); - const label = document.createElement("div"); - label.className = "remote-label"; - label.textContent = isOwn ? "" : "(来自其他客户端)"; - if (!isOwn) node.appendChild(label); - const body = document.createElement("div"); - body.className = "msg-body"; - node.appendChild(body); - appendMsg(node); - state.groupNodes.set(rid, node); - } - const body = node.querySelector(".msg-body") || node; - body.textContent += chunk.content || ""; - scrollToBottom(); - } -} - -function handleDone(data) { - const rid = data.request_id; - // 找到分组节点 → 整体 marked 渲染(G127:requestIdleCallback 调度) - const node = state.groupNodes.get(rid); - if (node) { - const body = node.querySelector(".msg-body") || node; - const text = body.textContent; - const render = () => { - window.emrgMarkdown.renderMarkdown(text).then((html) => { - body.innerHTML = html; - scrollToBottom(); - }); - }; - if (window.requestIdleCallback) { - window.requestIdleCallback(render, { timeout: 2000 }); - } else { - render(); // rIC 不可用直接同步(G91 200KB 上限兜底) - } - state.groupNodes.delete(rid); - } - if (data.timeout) { - addSystemMessage("⚠️ 响应超时(daemon 未返回完成帧)。"); - } - if (state.ownStreamRequestId === rid || data.timeout) { - state.busy = false; - state.ownStreamRequestId = null; - setComposerDisabled(false); - } -} - -function handleToolStart(data) { - const rid = data.request_id; - if (rid && !state.groupNodes.has(rid)) { - // G104:tool_start 也建组(LLM 先出 tool_calls 后出文本)——与 handleDelta 一致,广播流标 remote - const isOwn = state.ownStreamRequestId === rid; - const node = document.createElement("div"); - node.className = "msg assistant"; - if (!isOwn) node.classList.add("remote"); - const label = document.createElement("div"); - label.className = "remote-label"; - label.textContent = isOwn ? "" : "(来自其他客户端)"; - if (!isOwn) node.appendChild(label); - const body = document.createElement("div"); - body.className = "msg-body"; - node.appendChild(body); - appendMsg(node); - state.groupNodes.set(rid, node); - } - const card = document.createElement("div"); - card.className = "tool-card running"; - card.innerHTML = `🔧 ${escapeHtml(data.tool_name)} 运行中…`; - appendMsg(card); - state.toolCards.set(data.tool_call_id, card); -} - -function handleToolEnd(data) { - const card = state.toolCards.get(data.tool_call_id); - if (!card) return; - const ok = !data.error; - card.className = "tool-card " + (ok ? "done" : "failed"); - const elapsed = data.elapsed !== undefined ? `${data.elapsed.toFixed(1)}s` : ""; - card.querySelector(".tool-status").textContent = ok ? `完成 ${elapsed}` : `失败 ${elapsed}`; - // G91/G131:content 默认截断 2000 字符 + 展开(textContent 纯文本,不做 marked) - const content = data.content || ""; - if (content) { - const truncated = content.length > 2000 ? content.slice(0, 2000) + "…" : content; - const body = document.createElement("div"); - body.className = "tool-output"; - body.textContent = truncated; - card.appendChild(body); - if (content.length > 2000) { - const btn = document.createElement("button"); - btn.className = "tool-expand"; - btn.textContent = "展开全文"; - btn.addEventListener("click", () => { - body.textContent = content; - btn.remove(); - }); - card.appendChild(btn); - } - } - scrollToBottom(); -} - -function handleError(data) { - // G42:session busy(带 session_id G128)/ 普通错误 - if (data.error && String(data.error).includes("session busy")) { - addSystemMessage(`⚠️ 会话正忙(${data.session_id || state.sessionId})——请等待当前响应完成。`); - state.busy = false; - state.ownStreamRequestId = null; - setComposerDisabled(false); - } else { - addSystemMessage(`错误: ${data.error || "未知错误"}`); - } -} - -function handleStatus(data) { - if (data.connected) { - $("daemon-dot").className = "dot green"; - if (data.server_id) $("server-id").textContent = data.server_id; - if (data.model) $("model-name").textContent = data.model; - updateEvolutionCount(data.evolution_count); - addSystemMessage("✓ 已重新连接"); - } else if (data.auth_failed) { - $("daemon-dot").className = "dot red"; - addSystemMessage("⚠️ 认证失败——请检查 ~/.emrg 配置,手动重试。"); - } else { - $("daemon-dot").className = "dot red"; - } -} - -// G19:状态栏演化计数(pong evolution_count / init / status) -function updateEvolutionCount(count) { - const el = $("evolution-count"); - if (!el) return; - el.textContent = count !== undefined && count !== null ? `演化 ${count} 次` : ""; -} - -// ── 启动 ───────────────────────────────────────────────── - -bindUi(); // 模块级只绑定一次(boot 可被 saveSettings/saveWelcome 重复调用,防重复 listener) -window.emrg.onEvent(handleEvent); // 模块级只注册一次(防 ipcRenderer listener 泄漏 → delta 重复渲染) -boot(); diff --git a/emrg/gui/renderer/css/animations.css b/emrg/gui/renderer/css/animations.css new file mode 100644 index 0000000..07e9fd8 --- /dev/null +++ b/emrg/gui/renderer/css/animations.css @@ -0,0 +1,91 @@ +/* animations.css — 动效:快速 150ms / 中 250ms / 慢 350ms,克制、无弹跳无循环 + * 原则:只动 transform/opacity(性能友好);消息上浮+淡入;光标脉动;对话框淡入缩放。 + */ + +/* 消息出现:轻微上浮 + 淡入 */ +@keyframes msg-in { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* 流式末尾柔和光标脉动 */ +@keyframes cursor-pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.25; + } +} + +/* 工具行旋转指示 */ +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* 对话框进入 */ +@keyframes dialog-in { + from { + opacity: 0; + transform: scale(0.96) translateY(8px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +/* 遮罩淡入 */ +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +/* 横幅滑入 */ +@keyframes banner-in { + from { + opacity: 0; + transform: translateX(-50%) translateY(-8px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} + +/* 下拉菜单进入 */ +@keyframes menu-in { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* 侧边栏 ✦ 温和闪烁(演化发生时) */ +@keyframes star-pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.4; + } +} +.brand-star.pulse { + animation: star-pulse 1.2s var(--ease) 2; +} diff --git a/emrg/gui/renderer/css/base.css b/emrg/gui/renderer/css/base.css new file mode 100644 index 0000000..d9c8158 --- /dev/null +++ b/emrg/gui/renderer/css/base.css @@ -0,0 +1,108 @@ +/* base.css — reset / 字体 / 滚动条 / 通用基础 */ + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, +body { + height: 100%; + overflow: hidden; + font-family: var(--font-sans); + font-size: var(--fs-body); + line-height: var(--lh-body); + background: var(--bg); + color: var(--text-1); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +/* 主题切换过渡(仅作用于整体背景/文字,避免闪烁) */ +body.theme-transition, +body.theme-transition * { + transition: + background-color var(--dur-slow) var(--ease), + border-color var(--dur-slow) var(--ease), + color var(--dur-slow) var(--ease) !important; +} + +button { + font-family: inherit; + font-size: var(--fs-secondary); + border: none; + background: none; + cursor: pointer; + color: inherit; + border-radius: var(--radius-btn); + transition: + background-color var(--dur-fast) var(--ease), + color var(--dur-fast) var(--ease), + box-shadow var(--dur-fast) var(--ease), + transform var(--dur-fast) var(--ease); +} +button:active { + transform: scale(0.97); +} +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +textarea, +input, +select { + font-family: inherit; + font-size: var(--fs-body); + color: var(--text-1); + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--radius-input); + outline: none; + transition: + border-color var(--dur-fast) var(--ease), + box-shadow var(--dur-fast) var(--ease), + background-color var(--dur-fast) var(--ease); +} +textarea:focus, +input:focus, +select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} + +::placeholder { + color: var(--text-3); +} + +/* 滚动条(WebKit) */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} +::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + border-radius: 4px; +} +::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-hover); +} +::-webkit-scrollbar-track { + background: transparent; +} + +/* 代码块等宽字体 */ +code, +pre, +.tool-output { + font-family: var(--font-mono); +} + +.hidden { + display: none !important; +} + +[hidden] { + display: none !important; +} diff --git a/emrg/gui/renderer/css/components.css b/emrg/gui/renderer/css/components.css new file mode 100644 index 0000000..cc92e34 --- /dev/null +++ b/emrg/gui/renderer/css/components.css @@ -0,0 +1,578 @@ +/* components.css — 组件:对话项 / 消息气泡 / 输入按钮 / 工具友好行 / 对话框 / 空状态 / 模型切换器 */ + +/* ── 侧边栏对话项 ─────────────────────── */ +.conv-item { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: 9px var(--sp-3); + border-radius: 10px; + cursor: pointer; + color: var(--text-2); + font-size: var(--fs-secondary); + margin-bottom: 1px; + transition: + background-color var(--dur-fast) var(--ease), + color var(--dur-fast) var(--ease); +} +.conv-item:hover { + background: var(--bg-soft); + color: var(--text-1); +} +.conv-item.active { + background: var(--accent-soft); + color: var(--accent); + font-weight: 500; +} +.conv-title { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.conv-item.placeholder { + color: var(--text-3); + cursor: default; + justify-content: center; +} + +/* ── 消息 ─────────────────────────────── */ +.msg { + max-width: 100%; +} +.msg.user { + align-self: flex-end; + background: var(--accent-soft); + color: var(--text-1); + border-radius: var(--radius-bubble); + border-bottom-right-radius: 6px; + padding: 10px 16px; + max-width: 72%; + white-space: pre-wrap; + word-break: break-word; + animation: msg-in var(--dur-med) var(--ease); +} +.msg.assistant { + display: flex; + flex-direction: column; + gap: var(--sp-1); +} +.msg-assistant-mark { + color: var(--accent); + font-weight: 600; + user-select: none; +} +.msg-body { + color: var(--text-1); + font-size: var(--fs-body); + line-height: var(--lh-body); + word-break: break-word; +} +.msg-body.typing::after { + content: "▍"; + color: var(--accent); + animation: cursor-pulse 1s ease-in-out infinite; +} +.msg.system { + text-align: center; + font-size: var(--fs-secondary); + color: var(--text-3); + padding: var(--sp-1) 0; + user-select: none; +} +.msg.remote-label { + font-size: var(--fs-aux); + color: var(--text-3); +} + +/* Markdown 内容排版 */ +.msg-body p { + margin: 0 0 0.6em; +} +.msg-body p:last-child { + margin-bottom: 0; +} +.msg-body h1, .msg-body h2, .msg-body h3, .msg-body h4 { + margin: 0.8em 0 0.4em; + font-weight: 600; + line-height: 1.4; +} +.msg-body h1 { font-size: 1.35em; } +.msg-body h2 { font-size: 1.2em; } +.msg-body h3 { font-size: 1.1em; } +.msg-body ul, .msg-body ol { + margin: 0.4em 0; + padding-left: 1.5em; +} +.msg-body li { + margin: 0.2em 0; +} +.msg-body code { + background: var(--bg-soft); + border-radius: 4px; + padding: 0.1em 0.35em; + font-size: 0.88em; +} +.msg-body pre { + background: var(--bg-soft); + border: 1px solid var(--border); + border-radius: var(--radius-card); + padding: var(--sp-3); + overflow-x: auto; + margin: 0.5em 0; +} +.msg-body pre code { + background: none; + padding: 0; + font-size: 0.85em; + line-height: 1.55; +} +.msg-body blockquote { + border-left: 3px solid var(--border); + color: var(--text-2); + margin: 0.5em 0; + padding-left: var(--sp-3); +} +.msg-body a { + color: var(--accent); + text-decoration: none; +} +.msg-body a:hover { + text-decoration: underline; +} +.msg-body table { + border-collapse: collapse; + margin: 0.5em 0; + font-size: 0.92em; +} +.msg-body th, .msg-body td { + border: 1px solid var(--border); + padding: 6px 10px; + text-align: left; +} +.msg-body img { + max-width: 100%; + border-radius: var(--radius-card); +} +.msg-body hr { + border: none; + border-top: 1px solid var(--border); + margin: 1em 0; +} + +/* ── 工具友好状态行 ───────────────────── */ +.tool-row { + display: flex; + align-items: center; + gap: var(--sp-2); + font-size: var(--fs-secondary); + color: var(--text-2); + padding: 4px 2px; + border-radius: 8px; + cursor: pointer; + user-select: none; + transition: + background-color var(--dur-fast) var(--ease), + color var(--dur-fast) var(--ease); +} +.tool-row:hover { + background: var(--bg-soft); +} +.tool-row .tool-spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid var(--amber-soft); + border-top-color: var(--amber); + border-radius: 50%; + animation: spin 0.8s linear infinite; + flex-shrink: 0; +} +.tool-row.running { + color: var(--amber); +} +.tool-row.done { + color: var(--text-2); +} +.tool-row.done .tool-check { + color: var(--accent); + font-weight: 700; +} +.tool-row.failed { + color: var(--red); +} +.tool-row .tool-chevron { + font-size: 10px; + color: var(--text-3); + margin-left: auto; + transition: transform var(--dur-med) var(--ease); +} +.tool-row.expanded .tool-chevron { + transform: rotate(180deg); +} +.tool-output { + font-size: var(--fs-secondary); + line-height: 1.6; + background: var(--bg-soft); + border: 1px solid var(--border); + border-radius: 10px; + padding: var(--sp-3); + margin: 2px 0 6px; + max-height: 320px; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-word; +} +.tool-expand-btn { + font-size: var(--fs-aux); + color: var(--accent); + padding: 2px 6px; + margin: 0 0 6px 2px; +} + +/* ── 空状态欢迎屏 ─────────────────────── */ +#empty-state { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--sp-4); + padding: var(--sp-6); + text-align: center; +} +.empty-hello { + font-size: 26px; + font-weight: 600; + color: var(--text-1); + display: flex; + align-items: center; + gap: var(--sp-2); +} +.empty-hello .brand-star { + color: var(--accent); + font-size: 26px; +} +.empty-sub { + color: var(--text-2); + font-size: var(--fs-secondary); + max-width: 420px; +} +.example-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: var(--sp-3); + max-width: 520px; + width: 100%; +} +.example-card { + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--radius-card); + padding: var(--sp-4); + font-size: var(--fs-secondary); + color: var(--text-2); + text-align: left; + box-shadow: var(--shadow-sm); + transition: + border-color var(--dur-fast) var(--ease), + box-shadow var(--dur-fast) var(--ease), + transform var(--dur-fast) var(--ease); +} +.example-card:hover { + border-color: var(--accent); + box-shadow: var(--shadow-md); + transform: translateY(-1px); + color: var(--text-1); +} + +/* ── 按钮 ─────────────────────────────── */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--sp-2); + padding: 8px var(--sp-4); + border-radius: var(--radius-btn); + font-size: var(--fs-secondary); + min-height: 36px; + transition: + background-color var(--dur-fast) var(--ease), + box-shadow var(--dur-fast) var(--ease), + transform var(--dur-fast) var(--ease); +} +.btn-primary { + background: var(--accent); + color: #fff; + font-weight: 500; + box-shadow: var(--shadow-sm); +} +.btn-primary:hover { + background: var(--accent-strong); + box-shadow: var(--shadow-md); +} +.btn-ghost { + background: var(--bg-soft); + color: var(--text-2); +} +.btn-ghost:hover { + background: var(--border); + color: var(--text-1); +} +.btn-danger { + background: var(--red); + color: #fff; +} +.btn-danger:hover { + background: #d0343a; +} + +/* 发送 / 停止按钮(圆形) */ +.send-btn, +.stop-btn { + width: 38px; + height: 38px; + min-width: 38px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + flex-shrink: 0; + transition: + background-color var(--dur-fast) var(--ease), + box-shadow var(--dur-fast) var(--ease), + transform var(--dur-fast) var(--ease); +} +.send-btn { + background: var(--accent); + color: #fff; + box-shadow: var(--shadow-sm); +} +.send-btn:hover:not(:disabled) { + background: var(--accent-strong); + box-shadow: var(--shadow-md); +} +.stop-btn { + background: var(--red); + color: #fff; +} +.stop-btn:hover { + background: #d0343a; +} + +/* ── 模型切换器 ───────────────────────── */ +.model-switcher { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border-radius: 999px; + font-size: var(--fs-secondary); + color: var(--text-2); + background: transparent; + border: 1px solid transparent; + cursor: pointer; + transition: + background-color var(--dur-fast) var(--ease), + border-color var(--dur-fast) var(--ease); +} +.model-switcher:hover { + background: var(--bg-soft); + border-color: var(--border); +} +.model-switcher.highlight { + background: var(--accent-soft); + color: var(--accent); + border-color: var(--accent); +} +.model-menu { + position: absolute; + bottom: 52px; + left: var(--sp-5); + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--radius-card); + box-shadow: var(--shadow-lg); + padding: var(--sp-2); + min-width: 240px; + z-index: 30; + animation: menu-in var(--dur-fast) var(--ease); +} +.model-menu-item { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: 8px 10px; + border-radius: 8px; + cursor: pointer; + font-size: var(--fs-secondary); + color: var(--text-1); +} +.model-menu-item:hover { + background: var(--bg-soft); +} +.model-menu-item .model-check { + color: var(--accent); + font-weight: 700; +} +.model-menu-item .model-vision { + font-size: var(--fs-aux); + color: var(--text-3); +} +.model-menu-empty { + padding: var(--sp-3); + font-size: var(--fs-secondary); + color: var(--text-3); + text-align: center; +} +.model-menu-empty a { + color: var(--accent); + cursor: pointer; +} + +/* ── 连接状态圆点 ─────────────────────── */ +.status-dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--text-3); + flex-shrink: 0; + transition: background-color var(--dur-med) var(--ease); +} +.status-dot.green { + background: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} +.status-dot.red { + background: var(--red); + box-shadow: 0 0 0 3px var(--red-soft); +} +.status-dot.gray { + background: var(--text-3); +} + +/* ── 对话框 ───────────────────────────── */ +dialog { + border: none; + padding: 0; + background: transparent; + border-radius: var(--radius-dialog); +} +dialog::backdrop { + background: rgba(0, 0, 0, 0.35); + backdrop-filter: blur(2px); + animation: fade-in var(--dur-slow) var(--ease); +} +.dialog-card { + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--radius-dialog); + box-shadow: var(--shadow-lg); + padding: var(--sp-5); + min-width: 400px; + max-width: 520px; + animation: dialog-in var(--dur-slow) var(--ease); +} +.dialog-card h2 { + font-size: var(--fs-title); + font-weight: 600; + margin-bottom: var(--sp-4); + color: var(--text-1); +} +.dialog-card label { + display: block; + font-size: var(--fs-secondary); + color: var(--text-2); + margin-bottom: var(--sp-3); +} +.dialog-card label > input, +.dialog-card label > select { + width: 100%; + margin-top: var(--sp-1); + padding: 8px 10px; +} +.dialog-card .hint { + font-size: var(--fs-aux); + color: var(--text-3); + margin-top: 4px; +} +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: var(--sp-2); + margin-top: var(--sp-5); +} +.dir-row { + display: flex; + gap: var(--sp-2); + margin-top: var(--sp-1); +} +.dir-row input { + flex: 1; + padding: 8px 10px; +} + +/* 设置分组 */ +.settings-group { + margin-bottom: var(--sp-4); +} +.settings-group-title { + font-size: var(--fs-secondary); + font-weight: 600; + color: var(--text-1); + margin-bottom: var(--sp-2); +} +.theme-options { + display: flex; + gap: var(--sp-2); +} +.theme-option { + flex: 1; + padding: 8px; + border: 1px solid var(--border); + border-radius: var(--radius-btn); + font-size: var(--fs-secondary); + text-align: center; + color: var(--text-2); + transition: + border-color var(--dur-fast) var(--ease), + background-color var(--dur-fast) var(--ease), + color var(--dur-fast) var(--ease); +} +.theme-option:hover { + border-color: var(--accent); +} +.theme-option.active { + border-color: var(--accent); + background: var(--accent-soft); + color: var(--accent); + font-weight: 500; +} + +/* 首启向导 */ +.welcome-brand { + text-align: center; + margin-bottom: var(--sp-4); +} +.welcome-brand .brand-star { + font-size: 34px; + color: var(--accent); +} +.welcome-brand h1 { + font-size: var(--fs-title-lg); + font-weight: 600; + margin-top: var(--sp-2); +} +.welcome-brand p { + color: var(--text-2); + font-size: var(--fs-secondary); + margin-top: var(--sp-1); +} + +/* ── 关于(演化计数) ──────────────────── */ +.about-line { + font-size: var(--fs-secondary); + color: var(--text-2); + margin-bottom: var(--sp-2); +} diff --git a/emrg/gui/renderer/css/layout.css b/emrg/gui/renderer/css/layout.css new file mode 100644 index 0000000..8e908a5 --- /dev/null +++ b/emrg/gui/renderer/css/layout.css @@ -0,0 +1,194 @@ +/* layout.css — 整体布局:左侧边栏 + 主区(居中单栏聊天 + 输入卡片) + * 结构: + * #app + * #sidebar(品牌区 + 新对话 + 分组列表 + 底部设置/连接圆点) + * #main(#chat-view 滚动区 + #empty-state 欢迎屏 + #composer-wrap 输入卡片) + */ + +#app { + display: flex; + height: 100vh; + width: 100vw; +} + +/* ── 侧边栏 ─────────────────────────────── */ +#sidebar { + width: 264px; + min-width: 220px; + display: flex; + flex-direction: column; + background: var(--bg-panel); + border-right: 1px solid var(--border); +} + +.sidebar-brand { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-4) var(--sp-4) var(--sp-3); + font-size: var(--fs-title-lg); + font-weight: 600; + color: var(--text-1); +} +.sidebar-brand .brand-star { + color: var(--accent); + font-size: 18px; +} + +.new-chat-btn { + margin: 0 var(--sp-3) var(--sp-3); + display: flex; + align-items: center; + justify-content: center; + gap: var(--sp-2); + padding: 9px var(--sp-3); + background: var(--accent); + color: #fff; + font-size: var(--fs-secondary); + font-weight: 500; + border-radius: var(--radius-btn); + box-shadow: var(--shadow-sm); + transition: + background-color var(--dur-fast) var(--ease), + box-shadow var(--dur-fast) var(--ease), + transform var(--dur-fast) var(--ease); +} +.new-chat-btn:hover { + background: var(--accent-strong); + box-shadow: var(--shadow-md); +} + +#conv-list { + flex: 1; + overflow-y: auto; + padding: 0 var(--sp-2) var(--sp-2); +} + +.conv-group-label { + padding: var(--sp-3) var(--sp-2) var(--sp-1); + font-size: var(--fs-aux); + font-weight: 600; + color: var(--text-3); + letter-spacing: 0.02em; +} + +.sidebar-footer { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-3) var(--sp-4); + border-top: 1px solid var(--border); +} + +/* ── 主区 ───────────────────────────────── */ +#main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + position: relative; + background: var(--bg); +} + +/* 聊天滚动区:居中单栏最大 760px,左右留白 ≥24px */ +#chat-view { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: var(--sp-5); + padding: var(--sp-5) var(--sp-5) var(--sp-4); + scroll-behavior: smooth; +} +#chat-view > * { + width: 100%; + max-width: 760px; + margin-left: auto; + margin-right: auto; +} + +/* 输入区 */ +#composer-wrap { + padding: var(--sp-2) var(--sp-5) var(--sp-4); + max-width: 808px; + margin: 0 auto; + width: 100%; +} + +.composer-card { + display: flex; + align-items: flex-end; + gap: var(--sp-2); + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--radius-input); + box-shadow: var(--shadow-md); + padding: var(--sp-2) var(--sp-2) var(--sp-2) var(--sp-4); + transition: + border-color var(--dur-fast) var(--ease), + box-shadow var(--dur-fast) var(--ease); +} +.composer-card:focus-within { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft), var(--shadow-md); +} + +#input { + flex: 1; + border: none; + background: transparent; + box-shadow: none; + resize: none; + max-height: 150px; + line-height: 1.6; + padding: var(--sp-2) 0; +} +#input:focus { + border: none; + box-shadow: none; +} + +.composer-hint { + text-align: center; + font-size: var(--fs-aux); + color: var(--text-3); + margin-top: var(--sp-2); +} + +/* ── 断连横幅 ───────────────────────────── */ +#conn-banner { + position: absolute; + top: var(--sp-3); + left: 50%; + transform: translateX(-50%); + background: var(--amber-soft); + color: var(--amber); + border: 1px solid var(--border); + border-radius: 999px; + padding: 6px var(--sp-4); + font-size: var(--fs-secondary); + box-shadow: var(--shadow-md); + z-index: 20; + animation: banner-in var(--dur-med) var(--ease); +} + +/* ── 侧边栏折叠(⌘B / 窄屏) ────────────── */ +body.sidebar-collapsed #sidebar { + display: none; +} +body.sidebar-collapsed #app { + /* 主区自动占满 */ +} + +/* 窄屏自动收起侧边栏 */ +@media (max-width: 720px) { + #sidebar { + display: none; + } + #chat-view { + padding: var(--sp-4) var(--sp-4) var(--sp-3); + } + #composer-wrap { + padding: var(--sp-2) var(--sp-3) var(--sp-3); + } +} diff --git a/emrg/gui/renderer/css/tokens.css b/emrg/gui/renderer/css/tokens.css new file mode 100644 index 0000000..a9eafe2 --- /dev/null +++ b/emrg/gui/renderer/css/tokens.css @@ -0,0 +1,139 @@ +/* tokens.css — 设计 token:双主题色板 / 字体 / 圆角 / 阴影 / 间距 / 动效 + * 设计来源:docs/design/gui-redesign.md(rant 2026-08-05T21:59:47 完整复述) + * 双主题:浅色 + 深色。默认 prefers-color-scheme 跟随系统, + * 手动覆盖:(设置里三选)。 + */ +:root { + /* 字体 */ + --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", + "Noto Sans CJK SC", "Microsoft YaHei", sans-serif; + --font-mono: "SF Mono", "Cascadia Code", Consolas, "JetBrains Mono", "Courier New", monospace; + --fs-body: 15px; + --fs-secondary: 13px; + --fs-aux: 12px; + --fs-title: 17px; + --fs-title-lg: 20px; + --lh-body: 1.7; + + /* 圆角 */ + --radius-bubble: 18px; + --radius-input: 12px; + --radius-btn: 12px; + --radius-card: 14px; + --radius-dialog: 20px; + + /* 间距(8px 基准网格) */ + --sp-1: 4px; + --sp-2: 8px; + --sp-3: 12px; + --sp-4: 16px; + --sp-5: 24px; + --sp-6: 32px; + + /* 动效 */ + --dur-fast: 150ms; + --dur-med: 250ms; + --dur-slow: 350ms; + --ease: cubic-bezier(0.2, 0.8, 0.3, 1); + + /* 浅色主题(默认) */ + --bg: #fafafa; + --bg-panel: #ffffff; + --bg-soft: #f2f3f5; + --text-1: #1a1d21; + --text-2: #5f6672; + --text-3: #9aa1ad; + --border: #e8eaed; + --accent: #16a06c; /* 生机绿 */ + --accent-strong: #0e8a5c; + --accent-soft: #e6f5ee; + --blue: #3b82f6; + --red: #e5484d; + --red-soft: #fdecec; + --amber: #f5a623; + --amber-soft: #fdf3e0; + + /* 阴影(浅色三档柔和) */ + --shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.06); + --shadow-md: 0 4px 12px rgba(16, 24, 40, 0.08); + --shadow-lg: 0 12px 32px rgba(16, 24, 40, 0.14); + + /* 滚动条 */ + --scrollbar-thumb: #d3d6db; + --scrollbar-hover: #b9bdc4; +} + +/* 深色主题(跟随系统) */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --bg: #17181c; + --bg-panel: #1f2127; + --bg-soft: #26282f; + --text-1: #eceef2; + --text-2: #a4a9b4; + --text-3: #6b7078; + --border: #2e3138; + --accent: #2fbf83; + --accent-strong: #3fd493; + --accent-soft: #1d3a2f; + --blue: #5b9cf6; + --red: #ef5d62; + --red-soft: #3a2326; + --amber: #f0a940; + --amber-soft: #3a3120; + /* 深色阴影弱化,靠底色分层 */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35); + --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.45); + --scrollbar-thumb: #3a3d45; + --scrollbar-hover: #4a4e58; + } +} + +/* 手动强制深色 */ +:root[data-theme="dark"] { + --bg: #17181c; + --bg-panel: #1f2127; + --bg-soft: #26282f; + --text-1: #eceef2; + --text-2: #a4a9b4; + --text-3: #6b7078; + --border: #2e3138; + --accent: #2fbf83; + --accent-strong: #3fd493; + --accent-soft: #1d3a2f; + --blue: #5b9cf6; + --red: #ef5d62; + --red-soft: #3a2326; + --amber: #f0a940; + --amber-soft: #3a3120; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35); + --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.45); + --scrollbar-thumb: #3a3d45; + --scrollbar-hover: #4a4e58; +} + +/* 手动强制浅色 */ +:root[data-theme="light"] { + --bg: #fafafa; + --bg-panel: #ffffff; + --bg-soft: #f2f3f5; + --text-1: #1a1d21; + --text-2: #5f6672; + --text-3: #9aa1ad; + --border: #e8eaed; + --accent: #16a06c; + --accent-strong: #0e8a5c; + --accent-soft: #e6f5ee; + --blue: #3b82f6; + --red: #e5484d; + --red-soft: #fdecec; + --amber: #f5a623; + --amber-soft: #fdf3e0; + --shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.06); + --shadow-md: 0 4px 12px rgba(16, 24, 40, 0.08); + --shadow-lg: 0 12px 32px rgba(16, 24, 40, 0.14); + --scrollbar-thumb: #d3d6db; + --scrollbar-hover: #b9bdc4; +} diff --git a/emrg/gui/renderer/index.html b/emrg/gui/renderer/index.html index 019fc9c..9694dc8 100644 --- a/emrg/gui/renderer/index.html +++ b/emrg/gui/renderer/index.html @@ -4,90 +4,153 @@ EMRG - + + + + +
- + - +
+ + + +
-
- - - + + + -
- -
- - - - - - - -
+ +
+
+ 加载中… + +
+
+ + + +
+
EMRG 可能会犯错,请核对重要信息
+
+
-
+

设置

- - - - +
+
模型服务
+ + + +
更多模型可在设置中管理(新增 / 编辑 / 设默认)
+
+
+
工作目录
+ +
+
+
外观
+ +
- - + +
- +
-
-

欢迎使用 EMRG

-

请先完成初始设置(选择工作目录 + 配置 API Key)。

-
+ + + +
+

确认

+

- + +
- +
- - + + + + + + + diff --git a/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js new file mode 100644 index 0000000..f4c5a65 --- /dev/null +++ b/emrg/gui/renderer/js/app.js @@ -0,0 +1,483 @@ +"use strict"; +/** + * app.js — 应用主控:启动、事件处理(main 已分类)、模型切换器、右键菜单、主题。 + * 事件契约与 main 不变:message_delta/done/tool_started/tool_finished/cancelled/ + * error/pong/status/sessions/disconnected/group_cleared/list_result/command_result。 + */ + +const App = (() => { + const state = { + sessionId: null, + sessions: [], + busy: false, + ownStreamRequestId: null, + apiKeyConfigured: false, + configExists: false, + projectDir: "", + model: "", + serverId: "", + evolutionCount: null, + autoScroll: true, + }; + + // ── 启动 ───────────────────────────────── + async function boot() { + try { + const init = await window.emrg.init(); + state.configExists = init.config_exists; + state.apiKeyConfigured = init.api_key_configured; + state.projectDir = init.project_dir || ""; + state.serverId = init.server_id || ""; + state.model = init.model || ""; + state.evolutionCount = init.evolution_count ?? null; + updateConnectionDot(init.config_exists && init.api_key_configured ? "green" : "gray"); + updateModelSwitcher(); + + if (!init.config_exists) { + Dialogs.showWelcome(); // 首启引导 + return; + } + if (!init.api_key_configured) { + Dialogs.showSettings(); // key 空/占位符 + return; + } + if (!init.project_dir_valid) { + Chat.addSystemMessage("工作目录不可用,请到设置里改一下。"); + Dialogs.showSettings(); + return; + } + if (init.sessions && init.sessions.length > 0) { + Sidebar.render(init.sessions); + const current = init.sessions.find((s) => s.session_id === state.sessionId); + if (current) { + Sidebar.highlight(state.sessionId); + } else { + await switchSession(init.sessions[0].session_id, { silent: true }); + } + } else { + await newSession(); + } + } catch (e) { + Chat.addSystemMessage(`启动遇到了问题:${e.message}`); + } + } + + // ── 发送 ───────────────────────────────── + async function sendMessage() { + const input = $("input"); + const text = input.value.trim(); + if (!text || state.busy) return; + if (!state.sessionId) { + Chat.addSystemMessage("请先创建一个对话。"); + return; + } + state.busy = true; + setComposerDisabled(true); + Chat.addUserMessage(text); + input.value = ""; + input.style.height = "auto"; + // G143:send 前预生成 requestId 并标记自有流——消除 IPC 往返竞态窗口 + const requestId = genRequestId(); + state.ownStreamRequestId = requestId; + try { + const res = await window.emrg.sendMessage({ sessionId: state.sessionId, text, requestId }); + state.ownStreamRequestId = res.requestId || requestId; // G124:以 daemon 回显为准 + } catch (e) { + state.busy = false; + state.ownStreamRequestId = null; + setComposerDisabled(false); + // G49:失败恢复输入框,文案不责怪用户 + Chat.addSystemMessage(EMRG_Copy.COPY.sendFailed); + input.value = text; + } + } + + // ── 会话 ───────────────────────────────── + async function switchSession(sid, opts = {}) { + // G65:busy 即自有流进行中/发送中(IPC 往返窗口内 ownStreamRequestId 尚未赋值) + if (state.busy) { + Chat.addSystemMessage(EMRG_Copy.COPY.sessionBusy); + return; + } + try { + const res = await window.emrg.switchSession({ sessionId: sid }); + state.sessionId = sid; + state.ownStreamRequestId = null; // G110:切会话清自有流标记 + Chat.clear(); + updateEmptyState(); + if (res.error === "session_not_found") { + Chat.addSystemMessage("这个对话已被删除,已帮你切到最近的对话。"); + if (res.next_session) { + Sidebar.render(res.sessions || []); + await switchSession(res.next_session, { silent: true }); + } else { + await newSession(); + } + return; + } + // G13:v1 不加载历史(G12) + if (!opts.silent) { + Chat.addSystemMessage("已切换对话。"); + } + updateEmptyState(); + Sidebar.highlight(sid); + } catch (e) { + Chat.addSystemMessage(`切换对话失败了:${e.message}`); + } + } + + async function newSession() { + if (state.busy) { + Chat.addSystemMessage(EMRG_Copy.COPY.sessionBusy); + return; + } + try { + const res = await window.emrg.newSession(); + state.sessionId = res.session_id; + Chat.clear(); + updateEmptyState(); // 欢迎屏即反馈 + await refreshSessions(); + Sidebar.highlight(state.sessionId); + } catch (e) { + Chat.addSystemMessage(`新建对话失败了:${e.message}`); + } + } + + async function deleteSession(sid) { + if (state.busy && state.sessionId === sid) { + Chat.addSystemMessage(EMRG_Copy.COPY.sessionBusy); + return; + } + try { + await window.emrg.deleteSession({ sessionId: sid }); + if (state.sessionId === sid) { + const remaining = state.sessions.filter((s) => s.session_id !== sid); + if (remaining.length > 0) { + await switchSession(remaining[0].session_id, { silent: true }); + } else { + await newSession(); + } + } + await refreshSessions(); + } catch (e) { + Chat.addSystemMessage(`删除失败了:${e.message}`); + } + } + + async function refreshSessions() { + try { + const sessions = await window.emrg.listSessions(); + Sidebar.render(sessions); + } catch { /* 忽略 */ } + } + + // ── 右键菜单(重命名 / 删除) ─────────── + function showConvMenu(item, sid, title) { + // 简单版:确认删除(P3 完善重命名)。右键菜单容器复用 conv-item 定位。 + Dialogs.showConfirm(EMRG_Copy.COPY.deleteConfirmTitle, EMRG_Copy.COPY.deleteConfirmBody, { + okText: "删除", + danger: true, + onOk: () => deleteSession(sid), + }); + } + + // ── 模型切换器 ───────────────────────── + async function updateModelSwitcher() { + const label = $("model-switcher-label"); + if (label) label.textContent = state.model || "选择模型"; + } + + function initModelSwitcher() { + const sw = $("model-switcher"); + sw.addEventListener("click", async (e) => { + e.stopPropagation(); + closeModelMenu(); + try { + const s = await window.emrg.getSettings(); + const models = s.models?.length ? s.models : s.model ? [s.model] : []; + const menu = el("div", { class: "model-menu" }); + if (!models.length) { + const empty = el("div", { class: "model-menu-empty" }, "还没有配置模型"); + const go = el("a", {}, "去设置添加"); + go.addEventListener("click", () => { + closeModelMenu(); + Dialogs.showSettings(); + }); + empty.appendChild(document.createTextNode(",")); + empty.appendChild(go); + menu.appendChild(empty); + } else { + for (const m of models) { + const name = typeof m === "string" ? m : m.name; + const vision = typeof m === "object" && m.vision; + const item = el("div", { class: "model-menu-item" }); + item.appendChild(el("span", { class: "model-check" }, name === state.model ? "✓ " : "")); + item.appendChild(el("span", {}, name)); + if (vision) item.appendChild(el("span", { class: "model-vision" }, " 🖼")); + item.addEventListener("click", async () => { + closeModelMenu(); + if (name === state.model) return; + try { + await window.emrg.setModel({ model: name }); + state.model = name; + updateModelSwitcher(); + sw.classList.add("highlight"); + setTimeout(() => sw.classList.remove("highlight"), 1200); + } catch (err) { + Chat.addSystemMessage(`切换模型失败了:${err.message}`); + } + }); + menu.appendChild(item); + } + } + $("main").appendChild(menu); + document.addEventListener("click", closeModelMenu, { once: true }); + } catch (err) { + Chat.addSystemMessage(`读取模型列表失败了:${err.message}`); + } + }); + } + + function closeModelMenu() { + const menu = document.querySelector(".model-menu"); + if (menu) menu.remove(); + } + + // ── 空状态欢迎屏 ─────────────────────── + function updateEmptyState() { + const empty = $("empty-state"); + const chatView = $("chat-view"); + empty.classList.toggle("hidden", chatView.children.length > 0); + } + + // ── 输入条状态 ───────────────────────── + function setComposerDisabled(disabled) { + $("input").disabled = disabled; + $("send-btn").disabled = disabled; + $("stop-btn").style.display = disabled ? "inline-flex" : "none"; + } + + // ── 连接状态 ─────────────────────────── + function updateConnectionDot(kind) { + const dot = $("status-dot"); + if (dot) dot.className = "status-dot " + kind; + } + + function showBanner(text) { + const b = $("conn-banner"); + b.textContent = text; + b.classList.remove("hidden"); + } + function hideBanner() { + $("conn-banner").classList.add("hidden"); + } + + // ── 事件处理(main 已分类) ───────────── + async function handleEvent(evt) { + const { type, data } = evt; + switch (type) { + case "message_delta": + Chat.handleDelta(data.chunks || [data]); + break; + case "done": + Chat.handleDone(data); + if (data.request_id && (state.ownStreamRequestId === data.request_id || data.timeout)) { + state.busy = false; + state.ownStreamRequestId = null; + setComposerDisabled(false); + } + break; + case "tool_started": + Chat.handleToolStart(data); + break; + case "tool_finished": + Chat.handleToolEnd(data); + break; + case "cancelled": + state.busy = false; + state.ownStreamRequestId = null; + setComposerDisabled(false); + break; + case "error": + handleError(data); + break; + case "pong": + state.serverId = data.identity?.instance_id || state.serverId; + state.model = data.model || state.model; + state.evolutionCount = data.evolution_count ?? state.evolutionCount; + updateModelSwitcher(); + break; + case "status": + handleStatus(data); + break; + case "sessions": + Sidebar.render(data.sessions || []); + break; + case "disconnected": + updateConnectionDot("red"); + showBanner(EMRG_Copy.COPY.disconnected); + // G89:断连时恢复输入条(不能依赖 30s 超时兜底) + state.busy = false; + state.ownStreamRequestId = null; + setComposerDisabled(false); + // G97:广播分组缓存清理(DOM 保留;仅清 Map 引用) + Chat.groupNodes.clear(); + // 进行中的工具行 → 结果未知(工具副作用不可重放) + for (const row of Chat.toolRows.values()) { + if (row.classList.contains("running")) { + row.classList.remove("running"); + row.classList.add("failed"); + const label = row.querySelector(".tool-label"); + if (label) label.textContent = "结果未知——连接中断"; + } + } + break; + case "group_cleared": + Chat.groupNodes.delete(data.requestId); + break; + case "list_result": + if (data.type === "sessions_list") Sidebar.render(data.sessions || []); + break; + case "command_result": + if (data.type === "model_set") { + state.model = data.model || state.model; + updateModelSwitcher(); + } + break; + default: + break; + } + } + + function handleError(data) { + if (data.error && String(data.error).includes("session busy")) { + Chat.addSystemMessage(EMRG_Copy.COPY.sessionBusy); + state.busy = false; + state.ownStreamRequestId = null; + setComposerDisabled(false); + } else { + Chat.addSystemMessage(`出了点问题:${data.error || "未知错误"}`); + } + } + + function handleStatus(data) { + if (data.connected) { + updateConnectionDot("green"); + hideBanner(); + if (data.server_id) state.serverId = data.server_id; + if (data.model) state.model = data.model; + state.evolutionCount = data.evolution_count ?? state.evolutionCount; + updateModelSwitcher(); + Chat.addSystemMessage(EMRG_Copy.COPY.reconnected); + } else if (data.auth_failed) { + updateConnectionDot("red"); + Chat.addSystemMessage("认证失败了,请检查设置里的 API Key。"); + } else { + updateConnectionDot("red"); + } + } + + // ── UI 绑定 ───────────────────────────── + function bindUi() { + $("send-btn").addEventListener("click", sendMessage); + $("stop-btn").addEventListener("click", () => window.emrg.cancel().catch(() => {})); + $("new-chat-btn").addEventListener("click", newSession); + $("settings-btn").addEventListener("click", Dialogs.showSettings); + $("settings-cancel").addEventListener("click", () => $("settings-dialog").close()); + $("settings-save").addEventListener("click", Dialogs.saveSettings); + $("pick-dir-btn").addEventListener("click", async () => { + const dir = await window.emrg.pickProjectDir(); + if (dir) $("set-project-dir").value = dir; + }); + $("welcome-pick-btn").addEventListener("click", async () => { + const dir = await window.emrg.pickProjectDir(); + if (dir) $("welcome-project-dir").value = dir; + }); + $("welcome-save").addEventListener("click", Dialogs.saveWelcome); + $("confirm-cancel").addEventListener("click", Dialogs.closeConfirm); + $("confirm-ok").addEventListener("click", Dialogs.confirmOk); + + const input = $("input"); + input.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + // Ctrl+Enter 同发送 + if (e.key === "Enter" && e.ctrlKey) { + e.preventDefault(); + sendMessage(); + } + }); + input.addEventListener("input", () => { + input.style.height = "auto"; + input.style.height = Math.min(input.scrollHeight, 150) + "px"; + }); + + const chatView = $("chat-view"); + chatView.addEventListener("scroll", () => { + state.autoScroll = + chatView.scrollTop + chatView.clientHeight >= chatView.scrollHeight - 40; + }); + + // 空状态示例问题卡片 → 填入输入框 + $("empty-state").addEventListener("click", (e) => { + const card = e.target.closest(".example-card"); + if (!card) return; + input.value = card.dataset.example || ""; + input.focus(); + input.style.height = "auto"; + input.style.height = Math.min(input.scrollHeight, 150) + "px"; + }); + + // 快捷键:⌘N 新对话 / ⌘B 折叠侧边栏 / ⌘, 设置 / ESC 停止或关弹窗 + document.addEventListener("keydown", (e) => { + if (e.metaKey || e.ctrlKey) { + if (e.key === "n") { + e.preventDefault(); + newSession(); + } else if (e.key === "b") { + e.preventDefault(); + document.body.classList.toggle("sidebar-collapsed"); + } else if (e.key === ",") { + e.preventDefault(); + Dialogs.showSettings(); + } + return; + } + if (e.key === "Escape") { + const dlgs = document.querySelectorAll("dialog[open]"); + if (dlgs.length) return; // dialog 原生 ESC 处理 + if (state.busy) { + e.preventDefault(); + window.emrg.cancel().catch(() => {}); + } + } + }); + + Dialogs.initThemeButtons(); + initModelSwitcher(); + } + + // ── 暴露 ───────────────────────────────── + return { + state, + boot, + sendMessage, + switchSession, + newSession, + deleteSession, + refreshSessions, + showConvMenu, + handleEvent, + bindUi, + updateEmptyState, + }; +})(); + +window.App = App; + +// ── 启动(模块级只绑定一次;boot 可被 saveSettings 重复调用) ── +App.bindUi(); +window.emrg.onEvent(App.handleEvent); +App.boot(); diff --git a/emrg/gui/renderer/js/chat.js b/emrg/gui/renderer/js/chat.js new file mode 100644 index 0000000..865f974 --- /dev/null +++ b/emrg/gui/renderer/js/chat.js @@ -0,0 +1,184 @@ +"use strict"; +/** + * chat.js — 聊天渲染:用户气泡 / EMRG ✦ 消息 / 流式 delta / 工具友好行 / 系统消息。 + * 事件类型与 main 契约不变(message_delta/done/tool_started/tool_finished/…)。 + */ + +const Chat = (() => { + // requestId → 消息 DOM 节点(广播分组) + const groupNodes = new Map(); + // tool_call_id → 工具行 DOM 节点 + const toolRows = new Map(); + + /** 追加节点到聊天区并滚动 */ + function append(node) { + $("chat-view").appendChild(node); + scrollToBottom(); + } + + function scrollToBottom() { + const cv = $("chat-view"); + cv.scrollTop = cv.scrollHeight; + } + + function clear() { + $("chat-view").innerHTML = ""; + groupNodes.clear(); + toolRows.clear(); + } + + /** 用户消息:右对齐柔和气泡 */ + function addUserMessage(text) { + const node = el("div", { class: "msg user" }, text); + append(node); + return node; + } + + /** EMRG 消息:全宽 + ✦ 标识(流式节点,body 先 textContent 后整体 marked) */ + function createAssistantNode(isOwn) { + const node = el("div", { class: "msg assistant" }); + if (!isOwn) { + node.appendChild(el("div", { class: "remote-label" }, "(来自其他客户端)")); + } + const mark = el("span", { class: "msg-assistant-mark" }, "✦ "); + const body = el("div", { class: "msg-body typing" }); + body.appendChild(mark); + node.appendChild(body); + append(node); + return node; + } + + /** 系统消息(温和置中) */ + function addSystemMessage(text) { + append(el("div", { class: "msg system" }, text)); + } + + /** 流式 delta(G122 main 已按 chunks 批量) */ + function handleDelta(chunks) { + for (const chunk of chunks) { + const rid = chunk.request_id; + if (!rid) continue; + let node = groupNodes.get(rid); + if (!node) { + const isOwn = App.state.ownStreamRequestId === rid; + node = createAssistantNode(isOwn); + groupNodes.set(rid, node); + } + const body = node.querySelector(".msg-body") || node; + // 流式中只动 textContent(不解析 Markdown)——性能约束 + body.textContent += chunk.content || ""; + scrollToBottom(); + } + } + + /** done:整体 Markdown 渲染(requestIdleCallback 调度,G127) */ + function handleDone(data) { + const rid = data.request_id; + const node = groupNodes.get(rid); + if (node) { + const body = node.querySelector(".msg-body") || node; + body.classList.remove("typing"); + const text = body.textContent; + const render = () => { + window.emrgMarkdown.renderMarkdown(text).then((html) => { + body.innerHTML = html; + scrollToBottom(); + }); + }; + if (window.requestIdleCallback) { + window.requestIdleCallback(render, { timeout: 2000 }); + } else { + render(); + } + groupNodes.delete(rid); + } + if (data.timeout) { + addSystemMessage("⚠️ 响应超时了,我还没说完——可以再试一次。"); + } + } + + /** 工具友好状态行(进行中 → 完成/失败,默认折叠,点开展示原始输出) */ + function handleToolStart(data) { + const rid = data.request_id; + if (rid && !groupNodes.has(rid)) { + // G104:tool_start 也建组(LLM 先出 tool_calls 后出文本) + const isOwn = App.state.ownStreamRequestId === rid; + groupNodes.set(rid, createAssistantNode(isOwn)); + } + const phrases = EMRG_Copy.toolPhrases(data.tool_name); + const row = el("div", { class: "tool-row running" }); + row.appendChild(el("span", { class: "tool-spinner" })); + const label = el("span", { class: "tool-label" }, phrases.doing); + row.appendChild(label); + row.appendChild(el("span", { class: "tool-chevron" }, "⌄")); + // 点展开 → 显示原始输出(等宽字体/圆角浅底) + row.addEventListener("click", () => { + const out = row.querySelector(".tool-output"); + if (out) { + out.classList.toggle("hidden"); + row.classList.toggle("expanded", !out.classList.contains("hidden")); + } + }); + append(row); + toolRows.set(data.tool_call_id, row); + } + + function handleToolEnd(data) { + const row = toolRows.get(data.tool_call_id); + if (!row) return; + const ok = !data.error; + row.classList.remove("running"); + row.classList.add(ok ? "done" : "failed"); + const phrases = EMRG_Copy.toolPhrases(data.tool_name); + const label = row.querySelector(".tool-label"); + if (label) { + if (ok) { + const check = el("span", { class: "tool-check" }, "✓ "); + row.insertBefore(check, label); + label.textContent = phrases.done; + } else { + label.textContent = EMRG_Copy.TOOL_FAIL_TEXT; + } + } + const elapsed = data.elapsed !== undefined ? `${data.elapsed.toFixed(1)}s` : ""; + if (elapsed && ok) { + row.title = `耗时 ${elapsed}`; + } + // G91/G131:content 截断 2000 字符 + 展开全文 + const content = data.content || ""; + if (content) { + const truncated = content.length > 2000 ? content.slice(0, 2000) + "…" : content; + const out = el("div", { class: "tool-output hidden" }, truncated); + row.appendChild(out); + if (content.length > 2000) { + const btn = el("button", { class: "tool-expand-btn" }, "展开全文"); + btn.addEventListener("click", () => { + out.textContent = content; + btn.remove(); + }); + row.appendChild(btn); + } + } + scrollToBottom(); + } + + return { + addUserMessage, + addSystemMessage, + createAssistantNode, + clear, + scrollToBottom, + handleDelta, + handleDone, + handleToolStart, + handleToolEnd, + get groupNodes() { + return groupNodes; + }, + get toolRows() { + return toolRows; + }, + }; +})(); + +window.EMRG_Chat = Chat; diff --git a/emrg/gui/renderer/js/copywriting.js b/emrg/gui/renderer/js/copywriting.js new file mode 100644 index 0000000..7540030 --- /dev/null +++ b/emrg/gui/renderer/js/copywriting.js @@ -0,0 +1,36 @@ +"use strict"; +/** + * copywriting.js — 去黑话文案映射(非开发者友好)。 + * 工具名 → 友好的动词短语;系统级状态 → 口语化鼓励性文案。 + */ + +/** 工具名 → 进行中 / 完成 短语(渐进披露:默认折叠,点开展示原始输出) */ +const TOOL_PHRASES = { + bash: { doing: "正在运行命令…", done: "已运行命令" }, + read: { doing: "正在读取文件…", done: "已读取文件" }, + write: { doing: "正在写入文件…", done: "已写入文件" }, + edit: { doing: "正在修改文件…", done: "已修改文件" }, + glob: { doing: "正在查找文件…", done: "已找到文件" }, + grep: { doing: "正在搜索内容…", done: "已完成搜索" }, +}; + +function toolPhrases(name) { + return TOOL_PHRASES[name] || { doing: "正在处理…", done: "已完成" }; +} + +/** 工具失败(不 blame 用户,给出下一步) */ +const TOOL_FAIL_TEXT = "这一步没成功,我换个方法试试"; + +/** 系统状态文案 */ +const COPY = { + disconnected: "连接中断了,正在重新连接…", + reconnected: "回来了,我们继续 ✦", + sessionBusy: "我还在处理上一条,稍等一下哦", + sendFailed: "没发送成功,你的话我还留着,再试一次?", + deleteConfirmTitle: "删除这段对话?", + deleteConfirmBody: "删除后无法恢复。", + noSessions: "还没有对话", + aboutEvolution: (n) => (n ? `EMRG 已自我成长 ${n} 次,感谢你的每一次反馈` : "EMRG 正在成长中"), +}; + +window.EMRG_Copy = { toolPhrases, TOOL_FAIL_TEXT, COPY }; diff --git a/emrg/gui/renderer/js/dialogs.js b/emrg/gui/renderer/js/dialogs.js new file mode 100644 index 0000000..10e631b --- /dev/null +++ b/emrg/gui/renderer/js/dialogs.js @@ -0,0 +1,174 @@ +"use strict"; +/** + * dialogs.js — 设置 / 首启引导 / 确认对话框。 + * - 设置:模型服务(API Key/接口地址/默认模型)、工作目录、外观(主题三选:浅色/深色/跟随系统) + * - 首启:品牌欢迎语 + 口语化文案(P4 完善多模型管理) + * - 确认:替代 confirm()(友好文案,不显示 session ID) + */ + +const Dialogs = (() => { + // ── 主题 ───────────────────────────────── + let theme = "system"; + + function renderThemeOptions() { + const wrap = $("theme-options"); + if (!wrap) return; + for (const btn of wrap.querySelectorAll(".theme-option")) { + btn.classList.toggle("active", btn.dataset.theme === theme); + } + } + + function initThemeButtons() { + const wrap = $("theme-options"); + if (!wrap) return; + wrap.addEventListener("click", (e) => { + const btn = e.target.closest(".theme-option"); + if (!btn) return; + theme = btn.dataset.theme || "system"; + applyTheme(theme); + renderThemeOptions(); + }); + } + + // ── 设置 ───────────────────────────────── + async function showSettings() { + try { + const s = await window.emrg.getSettings(); + $("set-api-key").value = s.apiKey || ""; + $("set-base-url").value = s.baseUrl || ""; + $("set-project-dir").value = s.projectDir || ""; + if (s.theme) theme = s.theme; + renderThemeOptions(); + const sel = $("set-model"); + sel.innerHTML = ""; + const models = s.models?.length ? s.models : s.model ? [s.model] : []; + for (const m of models) { + const opt = el("option", { value: m }, m); + if (m === s.model) opt.selected = true; + sel.appendChild(opt); + } + if (sel.selectedIndex < 0) sel.selectedIndex = 0; // G144 + } catch (e) { + Chat.addSystemMessage(`读取设置失败了:${e.message}`); + } + $("settings-dialog").showModal(); + } + + async function saveSettings() { + const config = { + apiKey: $("set-api-key").value.trim(), + baseUrl: $("set-base-url").value.trim(), + model: $("set-model").value, + projectDir: $("set-project-dir").value.trim(), + theme, + }; + if (!config.apiKey) { + showConfirm("还差一步", "API Key 是连接模型服务必需的,填上才能继续哦。", { + okText: "知道了", + danger: false, + }); + return; + } + try { + await window.emrg.saveSettings(config); + if ($("settings-dialog").open) $("settings-dialog").close(); + Chat.addSystemMessage("设置已保存。"); + App.state.apiKeyConfigured = true; + App.state.projectDir = config.projectDir || App.state.projectDir; + await App.boot(); + } catch (e) { + Chat.addSystemMessage(`保存失败了:${e.message}`); + } + } + + // ── 首启引导 ───────────────────────────── + function showWelcome() { + $("welcome-project-dir").value = ""; + $("welcome-api-key").value = ""; + $("welcome-base-url").value = ""; + $("welcome-model").innerHTML = ""; + $("welcome-dialog").showModal(); + window.emrg.getSettings().then((s) => { + const sel = $("welcome-model"); + sel.innerHTML = ""; + const models = s.models?.length ? s.models : ["deepseek-chat", "deepseek-v3", "gpt-4o"]; + for (const m of models) { + const opt = el("option", { value: m }, m); + if (m === s.model) opt.selected = true; // G144 + sel.appendChild(opt); + } + if (sel.selectedIndex < 0) sel.selectedIndex = 0; + }); + } + + async function saveWelcome() { + const key = $("welcome-api-key").value.trim(); + if (!key) { + showConfirm("还差一步", "API Key 是连接模型服务必需的,填上才能继续哦。", { + okText: "知道了", + danger: false, + }); + return; + } + const config = { + apiKey: key, + baseUrl: $("welcome-base-url").value.trim(), + model: $("welcome-model").value || "deepseek-chat", + projectDir: $("welcome-project-dir").value.trim() || "", + }; + try { + await window.emrg.saveSettings(config); + if ($("welcome-dialog").open) $("welcome-dialog").close(); + Chat.addSystemMessage("设置完成,正在启动…"); + App.state.apiKeyConfigured = true; + App.state.projectDir = config.projectDir || App.state.projectDir; + await App.boot(); + } catch (e) { + Chat.addSystemMessage(`初始化失败了:${e.message}`); + } + } + + // ── 确认对话框(替代 confirm/alert) ──── + let confirmCb = null; + function showConfirm(title, message, opts = {}) { + $("confirm-title").textContent = title; + $("confirm-message").textContent = message; + const ok = $("confirm-ok"); + ok.textContent = opts.okText || "删除"; + ok.className = opts.danger === false ? "btn btn-primary" : "btn btn-danger"; + confirmCb = opts.onOk || null; + $("confirm-dialog").showModal(); + } + + function closeConfirm() { + $("confirm-dialog").close(); + confirmCb = null; + } + + /** 确认按钮:执行回调后关闭 */ + function confirmOk() { + const cb = confirmCb; + $("confirm-dialog").close(); + confirmCb = null; + if (cb) cb(); + } + + return { + initThemeButtons, + showSettings, + saveSettings, + showWelcome, + saveWelcome, + showConfirm, + closeConfirm, + confirmOk, + get theme() { + return theme; + }, + set theme(v) { + theme = v; + }, + }; +})(); + +window.EMRG_Dialogs = Dialogs; diff --git a/emrg/gui/renderer/markdown.js b/emrg/gui/renderer/js/markdown.js similarity index 100% rename from emrg/gui/renderer/markdown.js rename to emrg/gui/renderer/js/markdown.js diff --git a/emrg/gui/renderer/js/sidebar.js b/emrg/gui/renderer/js/sidebar.js new file mode 100644 index 0000000..d5b6af2 --- /dev/null +++ b/emrg/gui/renderer/js/sidebar.js @@ -0,0 +1,55 @@ +"use strict"; +/** + * sidebar.js — 对话列表:时间分组(今天/昨天/更早)、标题显示、右键重命名/删除、折叠(⌘B)。 + * 去黑话:只显示标题,不显示 session ID / 消息数。 + */ + +const Sidebar = (() => { + let sessions = []; + + /** 渲染分组对话列表 */ + function render(list) { + sessions = list || []; + const nav = $("conv-list"); + nav.innerHTML = ""; + if (!sessions.length) { + nav.appendChild(el("div", { class: "conv-item placeholder" }, EMRG_Copy.COPY.noSessions)); + return; + } + const groups = { 今天: [], 昨天: [], 更早: [] }; + for (const s of sessions) { + const g = groupLabel(s.updated_at || s.created_at); + if (!groups[g]) groups[g] = []; + groups[g].push(s); + } + for (const [label, items] of Object.entries(groups)) { + if (!items.length) continue; + nav.appendChild(el("div", { class: "conv-group-label" }, label)); + for (const s of items) { + const item = el("div", { class: "conv-item" }); + item.dataset.sid = s.session_id; + const title = s.title || s.session_id; // G27:title 优先 + item.appendChild(el("span", { class: "conv-title" }, title)); + item.addEventListener("click", () => App.switchSession(s.session_id)); + // 右键菜单:重命名 / 删除(友好确认) + item.addEventListener("contextmenu", (e) => { + e.preventDefault(); + App.showConvMenu(item, s.session_id, title); + }); + nav.appendChild(item); + } + } + highlight(App.state.sessionId); + } + + /** 高亮当前对话 */ + function highlight(sid) { + for (const item of $("conv-list").querySelectorAll(".conv-item")) { + item.classList.toggle("active", item.dataset.sid === sid); + } + } + + return { render, highlight }; +})(); + +window.EMRG_Sidebar = Sidebar; diff --git a/emrg/gui/renderer/js/utils.js b/emrg/gui/renderer/js/utils.js new file mode 100644 index 0000000..ff5ce15 --- /dev/null +++ b/emrg/gui/renderer/js/utils.js @@ -0,0 +1,74 @@ +"use strict"; +/** + * utils.js — 通用 DOM / 工具助手 + */ + +/** getElementById 简写 */ +const $ = (id) => document.getElementById(id); + +/** 创建元素并设置属性/文本 */ +function el(tag, attrs = {}, text) { + const node = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (k === "class") node.className = v; + else if (k === "dataset") Object.assign(node.dataset, v); + else if (k.startsWith("on")) node.addEventListener(k.slice(2), v); + else node.setAttribute(k, v); + } + if (text !== undefined) node.textContent = text; + return node; +} + +/** HTML 转义 */ +function escapeHtml(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +/** + * G143:生成 requestId。 + * secure context 下 crypto.randomUUID;低版本兜底。 + */ +function genRequestId() { + if (window.crypto && typeof window.crypto.randomUUID === "function") { + return window.crypto.randomUUID(); + } + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + return (c === "x" ? r : (r & 0x3) | 0x8).toString(16); + }); +} + +/** 会话时间分组:今天 / 昨天 / 更早 */ +function groupLabel(ts) { + if (!ts) return "更早"; + const d = new Date(ts); + if (Number.isNaN(d.getTime())) return "更早"; + const now = new Date(); + const startOfDay = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime(); + const dayDiff = Math.round((startOfDay(now) - startOfDay(d)) / 86400000); + if (dayDiff <= 0) return "今天"; + if (dayDiff === 1) return "昨天"; + return "更早"; +} + +/** 主题应用:system 时移除 data-theme(CSS 跟随 prefers-color-scheme) */ +function applyTheme(mode) { + const root = document.documentElement; + if (mode === "system" || !mode) { + root.removeAttribute("data-theme"); + } else { + root.setAttribute("data-theme", mode); + } +} + +// 显式挂载到 window(多 script 顺序加载共享全局,显式挂载更健壮) +window.$ = $; +window.el = el; +window.escapeHtml = escapeHtml; +window.genRequestId = genRequestId; +window.groupLabel = groupLabel; +window.applyTheme = applyTheme; From 90925bd3ff08bfc4e6cf4833ad85019c0849add2 Mon Sep 17 00:00:00 2001 From: argszero Date: Thu, 6 Aug 2026 07:19:30 +0800 Subject: [PATCH 2/2] =?UTF-8?q?emrg:=20GUI=20=E9=87=8D=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=20P4=20=E2=80=94=20=E5=A4=9A=E6=A8=A1=E5=9E=8B=E7=AE=A1?= =?UTF-8?q?=E7=90=86=20UI=EF=BC=88=E8=AE=BE=E7=BD=AE=E9=87=8C=E5=A2=9E?= =?UTF-8?q?=E5=88=A0=E6=94=B9/=E8=AE=BE=E9=BB=98=E8=AE=A4=EF=BC=8C?= =?UTF-8?q?=E9=85=8D=E5=A5=97=20#418=20=E5=90=8E=E7=AB=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- emrg/gui/renderer/css/components.css | 136 +++++++++++++++++++++ emrg/gui/renderer/index.html | 26 +++- emrg/gui/renderer/js/app.js | 2 + emrg/gui/renderer/js/dialogs.js | 173 +++++++++++++++++++++++++-- 4 files changed, 320 insertions(+), 17 deletions(-) diff --git a/emrg/gui/renderer/css/components.css b/emrg/gui/renderer/css/components.css index cc92e34..d4bed6d 100644 --- a/emrg/gui/renderer/css/components.css +++ b/emrg/gui/renderer/css/components.css @@ -523,6 +523,142 @@ dialog::backdrop { color: var(--text-1); margin-bottom: var(--sp-2); } + +/* 多模型管理列表 */ +#model-list { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 4px; +} +.model-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg); + transition: + border-color var(--dur-fast) var(--ease), + box-shadow var(--dur-fast) var(--ease); +} +.model-item.default { + border-color: var(--accent); + background: var(--accent-soft); +} +.model-radio { + width: 18px; + height: 18px; + min-width: 18px; + border-radius: 50%; + border: 2px solid var(--text-3); + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + color: var(--accent); + padding: 0; + background: transparent; + transition: border-color var(--dur-fast) var(--ease); +} +.model-radio.checked { + border-color: var(--accent); +} +.model-radio:hover { + border-color: var(--accent); +} +.model-name { + font-size: var(--fs-secondary); + font-weight: 500; + color: var(--text-1); + display: flex; + align-items: center; + gap: 6px; +} +.model-badge { + font-size: 10px; + background: var(--accent); + color: #fff; + border-radius: 999px; + padding: 1px 8px; + font-weight: 600; +} +.model-id { + font-size: var(--fs-aux); + color: var(--text-3); + font-family: var(--font-mono); +} +.model-vision { + font-size: var(--fs-aux); + color: var(--text-2); +} +.model-actions { + margin-left: auto; + display: flex; + gap: 4px; + flex-shrink: 0; +} +.model-action-btn { + font-size: var(--fs-aux); + color: var(--text-2); + padding: 4px 8px; + border-radius: 6px; +} +.model-action-btn:hover { + background: var(--bg-soft); + color: var(--text-1); +} +.model-action-btn.danger:hover { + background: var(--red-soft); + color: var(--red); +} +.model-list-empty { + font-size: var(--fs-aux); + color: var(--text-3); + text-align: center; + padding: var(--sp-2); +} + +/* 行内模型表单 */ +.model-form { + margin-top: 8px; + padding: var(--sp-3); + border: 1px dashed var(--border); + border-radius: 10px; + background: var(--bg-soft); +} +.model-form-row { + display: flex; + gap: var(--sp-3); +} +.model-form-label { + flex: 1; + margin-bottom: 0; +} +.model-form-label input { + width: 100%; + margin-top: 2px; + padding: 6px 10px; + font-size: var(--fs-secondary); +} +.model-form-footer { + display: flex; + align-items: center; + margin-top: var(--sp-2); +} +.model-form-vision { + display: flex; + align-items: center; + gap: 6px; + font-size: var(--fs-secondary); + color: var(--text-2); + margin-bottom: 0; + cursor: pointer; +} +.model-form-vision input { + accent-color: var(--accent); +} .theme-options { display: flex; gap: var(--sp-2); diff --git a/emrg/gui/renderer/index.html b/emrg/gui/renderer/index.html index 9694dc8..86eca2a 100644 --- a/emrg/gui/renderer/index.html +++ b/emrg/gui/renderer/index.html @@ -70,10 +70,28 @@

设置

模型服务
- -
更多模型可在设置中管理(新增 / 编辑 / 设默认)
+
可用模型
+
+ + + +
点左侧圆点可设为默认模型,切换后下一条消息即生效
工作目录
diff --git a/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js index f4c5a65..b77c5cf 100644 --- a/emrg/gui/renderer/js/app.js +++ b/emrg/gui/renderer/js/app.js @@ -456,6 +456,7 @@ const App = (() => { }); Dialogs.initThemeButtons(); + Dialogs.initModelForm(); initModelSwitcher(); } @@ -472,6 +473,7 @@ const App = (() => { handleEvent, bindUi, updateEmptyState, + updateModelSwitcher, }; })(); diff --git a/emrg/gui/renderer/js/dialogs.js b/emrg/gui/renderer/js/dialogs.js index 10e631b..7d21f83 100644 --- a/emrg/gui/renderer/js/dialogs.js +++ b/emrg/gui/renderer/js/dialogs.js @@ -1,8 +1,9 @@ "use strict"; /** * dialogs.js — 设置 / 首启引导 / 确认对话框。 - * - 设置:模型服务(API Key/接口地址/默认模型)、工作目录、外观(主题三选:浅色/深色/跟随系统) - * - 首启:品牌欢迎语 + 口语化文案(P4 完善多模型管理) + * - 设置:模型服务(API Key/接口地址/可用模型增删改设默认)、工作目录、外观(主题三选) + * - 多模型管理:可用模型列表(默认项 + [[llm.models]] 全部条目),+添加/行内编辑/删除/单选设默认 + * - 首启:品牌欢迎语 + 口语化文案 * - 确认:替代 confirm()(友好文案,不显示 session ID) */ @@ -30,6 +31,146 @@ const Dialogs = (() => { }); } + // ── 多模型管理 ─────────────────────────── + // defaultName:当前默认模型(llm.model);extraModels:[[llm.models]] 条目(含与默认同名的配置项) + let defaultName = ""; + let extraModels = []; + + function renderModelList() { + const list = $("model-list"); + list.innerHTML = ""; + // 默认行(vision/modelId 从同名 extraModels 条目取,编辑默认模型时保留) + const def = extraModels.find((m) => m.name === defaultName); + list.appendChild( + renderModelItem( + defaultName || "未配置", + true, + Boolean(def?.vision), + def?.model && def.model !== def.name ? def.model : "" + ) + ); + // 额外模型行(去重:跳过与默认同名的——默认行已展示) + const seen = new Set([defaultName]); + for (const m of extraModels) { + if (seen.has(m.name)) continue; + seen.add(m.name); + list.appendChild( + renderModelItem(m.name, false, Boolean(m.vision), m.model && m.model !== m.name ? m.model : "") + ); + } + if (extraModels.filter((m) => m.name !== defaultName).length === 0) { + const hint = el("div", { class: "model-list-empty" }, "暂无其他模型,点下方按钮添加"); + list.appendChild(hint); + } + } + + function renderModelItem(name, isDefault, vision, modelId) { + const row = el("div", { class: "model-item" + (isDefault ? " default" : "") }); + // 单选圆点 → 设为默认 + const radio = el("button", { class: "model-radio" + (isDefault ? " checked" : ""), title: isDefault ? "当前默认模型" : "设为默认" }); + if (isDefault) { + radio.textContent = "●"; + } else { + radio.textContent = "○"; + radio.addEventListener("click", () => { + defaultName = name; + renderModelList(); + }); + } + row.appendChild(radio); + const title = el("span", { class: "model-name" }, name); + if (isDefault) title.appendChild(el("span", { class: "model-badge" }, "默认")); + row.appendChild(title); + if (modelId) row.appendChild(el("span", { class: "model-id" }, modelId)); + if (vision) row.appendChild(el("span", { class: "model-vision" }, "🖼 支持图片")); + // 操作:编辑(非默认行 → 打开表单填本条);删除(默认不可删) + const actions = el("span", { class: "model-actions" }); + const editBtn = el("button", { class: "model-action-btn", title: "编辑" }, "编辑"); + editBtn.addEventListener("click", () => openModelForm(name, isDefault)); + actions.appendChild(editBtn); + if (!isDefault) { + const delBtn = el("button", { class: "model-action-btn danger", title: "删除" }, "删除"); + delBtn.addEventListener("click", () => { + showConfirm("删除这个模型?", `「${name}」将从可用模型里移除。`, { + okText: "删除", + danger: true, + onOk: () => { + extraModels = extraModels.filter((m) => m.name !== name); + renderModelList(); + }, + }); + }); + actions.appendChild(delBtn); + } + row.appendChild(actions); + return row; + } + + // 行内表单:添加(无参)或编辑(name 非空) + let editingName = null; + function openModelForm(name, isDefault = false) { + editingName = isDefault ? (name || null) : name; + $("model-form-name").value = name || ""; + // 编辑时预填 model/vision + const found = extraModels.find((m) => m.name === name); + $("model-form-id").value = found?.model && found.model !== found.name ? found.model : ""; + $("model-form-vision").checked = Boolean(found?.vision); + $("model-form").classList.remove("hidden"); + $("model-form-name").focus(); + } + + function closeModelForm() { + $("model-form").classList.add("hidden"); + editingName = null; + $("model-form-name").value = ""; + $("model-form-id").value = ""; + $("model-form-vision").checked = false; + } + + function saveModelForm() { + const name = $("model-form-name").value.trim(); + if (!name) { + showConfirm("还差一步", "模型名称是必填的哦。", { okText: "知道了", danger: false }); + return; + } + const modelId = $("model-form-id").value.trim() || name; + const vision = $("model-form-vision").checked; + + if (editingName === null) { + // 添加新模型 + if (name === defaultName || extraModels.some((m) => m.name === name)) { + showConfirm("重名了", "这个名称已经有模型了,换个名字吧。", { okText: "知道了", danger: false }); + return; + } + extraModels.push({ name, model: modelId, vision }); + } else if (editingName === defaultName) { + // 编辑默认模型:更新默认名 + 其配置项(保留在 extraModels 中) + const prev = extraModels.find((m) => m.name === editingName); + extraModels = extraModels.filter((m) => m.name !== editingName); + extraModels.push({ name, model: modelId, vision, ...(prev ? {} : {}) }); + defaultName = name; + } else { + // 编辑非默认模型 + if (name === defaultName) { + showConfirm("重名了", "这个名称是默认模型,请在默认行编辑。", { okText: "知道了", danger: false }); + return; + } + if (name !== editingName && extraModels.some((m) => m.name === name)) { + showConfirm("重名了", "这个名称已经有模型了,换个名字吧。", { okText: "知道了", danger: false }); + return; + } + extraModels = extraModels.map((m) => (m.name === editingName ? { name, model: modelId, vision } : m)); + } + closeModelForm(); + renderModelList(); + } + + function initModelForm() { + $("add-model-btn").addEventListener("click", () => openModelForm("")); + $("model-form-save").addEventListener("click", saveModelForm); + $("model-form-cancel").addEventListener("click", closeModelForm); + } + // ── 设置 ───────────────────────────────── async function showSettings() { try { @@ -39,15 +180,13 @@ const Dialogs = (() => { $("set-project-dir").value = s.projectDir || ""; if (s.theme) theme = s.theme; renderThemeOptions(); - const sel = $("set-model"); - sel.innerHTML = ""; - const models = s.models?.length ? s.models : s.model ? [s.model] : []; - for (const m of models) { - const opt = el("option", { value: m }, m); - if (m === s.model) opt.selected = true; - sel.appendChild(opt); - } - if (sel.selectedIndex < 0) sel.selectedIndex = 0; // G144 + defaultName = s.model || ""; + extraModels = (s.modelDetails || []).map((m) => ({ + name: m.name || "", + model: m.model || m.name || "", + vision: Boolean(m.vision), + })); + renderModelList(); } catch (e) { Chat.addSystemMessage(`读取设置失败了:${e.message}`); } @@ -58,9 +197,10 @@ const Dialogs = (() => { const config = { apiKey: $("set-api-key").value.trim(), baseUrl: $("set-base-url").value.trim(), - model: $("set-model").value, + model: defaultName, projectDir: $("set-project-dir").value.trim(), theme, + models: extraModels, }; if (!config.apiKey) { showConfirm("还差一步", "API Key 是连接模型服务必需的,填上才能继续哦。", { @@ -75,6 +215,8 @@ const Dialogs = (() => { Chat.addSystemMessage("设置已保存。"); App.state.apiKeyConfigured = true; App.state.projectDir = config.projectDir || App.state.projectDir; + App.state.model = defaultName; + App.updateModelSwitcher(); await App.boot(); } catch (e) { Chat.addSystemMessage(`保存失败了:${e.message}`); @@ -91,7 +233,11 @@ const Dialogs = (() => { window.emrg.getSettings().then((s) => { const sel = $("welcome-model"); sel.innerHTML = ""; - const models = s.models?.length ? s.models : ["deepseek-chat", "deepseek-v3", "gpt-4o"]; + const models = s.modelDetails?.length + ? s.modelDetails.map((m) => m.name) + : s.models?.length + ? s.models + : ["deepseek-chat", "deepseek-v3", "gpt-4o"]; for (const m of models) { const opt = el("option", { value: m }, m); if (m === s.model) opt.selected = true; // G144 @@ -155,6 +301,7 @@ const Dialogs = (() => { return { initThemeButtons, + initModelForm, showSettings, saveSettings, showWelcome,