diff --git a/emrg/gui/main.js b/emrg/gui/main.js index e22fbc57..dbb019bd 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -289,6 +289,14 @@ vision = false return { ok: true }; }); + ipcMain.handle("emrg:renameSession", async (_e, { sessionId, title }) => { + if (!validateSessionId(sessionId)) throw new Error("invalid session_id"); + const clean = String(title || "").trim().slice(0, 80); // 截断超长标题 + if (!clean) throw new Error("empty title"); + const frame = await client.sendCommandAndWait("rename_session", { session_id: sessionId, cwd: projectDir, title: clean }, 5000); + return { ok: true, title: frame.title || clean }; + }); + ipcMain.handle("emrg:newSession", async () => { // G14/G81:本地生成 session_id(无 new_session 消息) const sid = generateSessionId(); diff --git a/emrg/gui/preload.js b/emrg/gui/preload.js index b3485800..5c676cd9 100644 --- a/emrg/gui/preload.js +++ b/emrg/gui/preload.js @@ -13,6 +13,7 @@ const api = { switchSession: (payload) => ipcRenderer.invoke("emrg:switchSession", payload), newSession: () => ipcRenderer.invoke("emrg:newSession"), deleteSession: (payload) => ipcRenderer.invoke("emrg:deleteSession", payload), + renameSession: (payload) => ipcRenderer.invoke("emrg:renameSession", payload), setModel: (payload) => ipcRenderer.invoke("emrg:setModel", payload), listModels: () => ipcRenderer.invoke("emrg:listModels"), saveSettings: (payload) => ipcRenderer.invoke("emrg:saveSettings", payload), diff --git a/emrg/gui/renderer/app.js b/emrg/gui/renderer/app.js index 32e6fc24..55cd0590 100644 --- a/emrg/gui/renderer/app.js +++ b/emrg/gui/renderer/app.js @@ -175,7 +175,7 @@ async function newSession() { // 本地切订阅(resume 不存在的新会话会被 daemon 自动创建) state.sessionId = sid; clearChat(); - addSystemMessage(`新会话 ${sid}`); + showWelcomeScreen(); // P3:空状态欢迎屏(设计 §3.5) await refreshSessions(); highlightActiveSession(sid); } catch (e) { @@ -189,21 +189,8 @@ async function deleteSession(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}`); - } + // P3:删除走友好确认对话框(sidebar.js),不再用原生 confirm() + await requestDeleteSession(sid); } async function refreshSessions() { @@ -341,6 +328,7 @@ function addSystemMessage(text) { } function appendMsg(node) { + hideWelcomeScreen(); // P3:首条真实消息到达即移除欢迎屏 $("chat-view").appendChild(node); scrollToBottom(); } @@ -351,6 +339,45 @@ function clearChat() { state.toolCards.clear(); } +// ── 空状态欢迎屏(P3,设计 §3.5)────────────────────── + +const WELCOME_SUGGESTIONS = [ + { icon: "📝", text: "帮我写一份周报" }, + { icon: "🗂", text: "整理这个文件夹" }, + { icon: "✈️", text: "规划一次旅行" }, +]; + +function showWelcomeScreen() { + const cv = $("chat-view"); + cv.innerHTML = ""; + const wrap = document.createElement("div"); + wrap.className = "welcome"; + const cards = WELCOME_SUGGESTIONS.map( + (s) => `` + ).join(""); + wrap.innerHTML = ` +
+
你好,我是 EMRG
+
我可以帮你写作、整理、查资料、处理文件…
+
${cards}
+ `; + for (const card of wrap.querySelectorAll(".welcome-card")) { + card.addEventListener("click", () => { + const input = $("input"); + input.value = card.dataset.text; + input.style.height = "auto"; + input.style.height = Math.min(input.scrollHeight, 150) + "px"; + input.focus(); + }); + } + cv.appendChild(wrap); +} + +function hideWelcomeScreen() { + const w = $("chat-view").querySelector(".welcome"); + if (w) w.remove(); +} + function scrollToBottom() { if (state.autoScroll) { const cv = $("chat-view"); @@ -366,25 +393,29 @@ function renderSessions(sessions) { 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); + // P3:按时间分组(今天/昨天/更早),只显示标题(设计 §3.2——不显示 session ID/消息数) + for (const [label, group] of groupSessionsByTime(state.sessions)) { + if (!group.length) continue; + const gl = document.createElement("div"); + gl.className = "session-group-label"; + gl.textContent = label; + list.appendChild(gl); + for (const s of group) { + const item = document.createElement("div"); + item.className = "session-item"; + item.dataset.sid = s.session_id; // P3:右键菜单定位 + const title = s.title || s.session_id; // G27:title 优先 session_id 兜底 + item.innerHTML = `${escapeHtml(title)}`; + item.addEventListener("click", () => switchSession(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")); + for (const item of $("session-list").querySelectorAll(".session-item")) { + item.classList.toggle("active", item.dataset.sid === sid); } } diff --git a/emrg/gui/renderer/css/components.css b/emrg/gui/renderer/css/components.css index 5b1a89fe..a0e08aaf 100644 --- a/emrg/gui/renderer/css/components.css +++ b/emrg/gui/renderer/css/components.css @@ -28,8 +28,6 @@ text-overflow: ellipsis; white-space: nowrap; } -.sess-count { color: var(--text-3); font-size: 11px; flex-shrink: 0; } -.session-item.active .sess-count { color: var(--accent); } /* ── 消息 ──────────────────────────── */ .msg { @@ -197,6 +195,83 @@ dialog .desc { font-size: var(--text-aux); color: var(--text-2); margin-bottom: .dir-row input { flex: 1; } .dir-row button { flex-shrink: 0; } +/* ── 空状态欢迎屏(P3,设计 §3.5)───────────────── */ +.welcome { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-3); + text-align: center; + padding: var(--space-5); + animation: msg-in var(--dur-med) var(--ease); +} +.welcome-mark { + font-size: 44px; + color: var(--accent); + line-height: 1; + margin-bottom: var(--space-2); +} +.welcome-title { font-size: 20px; font-weight: 600; color: var(--text-1); } +.welcome-sub { font-size: var(--text-secondary); color: var(--text-2); } +.welcome-cards { + display: flex; + flex-direction: column; + gap: var(--space-2); + margin-top: var(--space-4); + width: 100%; + max-width: 340px; +} +.welcome-card { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--radius-card); + color: var(--text-1); + font-size: var(--text-secondary); + text-align: left; + box-shadow: var(--shadow-sm); + transition: border-color var(--dur-fast), box-shadow var(--dur-fast), transform var(--dur-fast); +} +.welcome-card:hover { + border-color: var(--accent); + box-shadow: var(--shadow-md); + transform: translateY(-1px); +} +.welcome-icon { font-size: 18px; } + +/* ── 右键菜单(P3,设计 §3.2)────────────────── */ +.ctx-menu { + position: fixed; + z-index: 1000; + min-width: 150px; + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--radius-card); + box-shadow: var(--shadow-lg); + padding: 4px; + animation: msg-in var(--dur-fast) var(--ease); +} +.ctx-item { + padding: 9px 12px; + border-radius: 8px; + font-size: var(--text-secondary); + color: var(--text-1); + cursor: pointer; + transition: background var(--dur-fast); +} +.ctx-item:hover { background: var(--bg-soft); } +.ctx-item.danger { color: var(--red); } +.ctx-item.danger:hover { background: color-mix(in srgb, var(--red) 10%, transparent); } + +/* ── 对话框危险按钮 ─────────────────────────── */ +.dialog-actions button.danger { background: var(--red); border-color: var(--red); color: #fff; font-weight: 600; } +.dialog-actions button.danger:hover { filter: brightness(1.08); } + /* 消息入场动效:轻微上浮 + 淡入(克制) */ @keyframes msg-in { from { opacity: 0; transform: translateY(8px); } diff --git a/emrg/gui/renderer/css/layout.css b/emrg/gui/renderer/css/layout.css index 650219bb..ab0dffe1 100644 --- a/emrg/gui/renderer/css/layout.css +++ b/emrg/gui/renderer/css/layout.css @@ -26,6 +26,37 @@ .brand-mark { color: var(--accent); font-size: 20px; line-height: 1; } .brand-name { font-size: 17px; font-weight: 600; color: var(--text-1); letter-spacing: 0.2px; } +/* 折叠按钮(⌘B):hover 出现,轻柔 */ +.btn-sidebar-toggle { + margin-left: auto; + width: 26px; + height: 26px; + border-radius: 8px; + border: none; + background: none; + color: var(--text-3); + font-size: 12px; + line-height: 1; + transition: background var(--dur-fast), color var(--dur-fast); +} +.btn-sidebar-toggle:hover { background: var(--bg-soft); color: var(--text-1); } + +/* 折叠态:只留品牌区(窄条),其余隐藏 */ +#sidebar.collapsed { + width: 52px; + min-width: 52px; +} +#sidebar.collapsed .brand-name, +#sidebar.collapsed .btn-new, +#sidebar.collapsed #session-list, +#sidebar.collapsed .sidebar-footer .btn-settings { + display: none; +} +#sidebar.collapsed .brand { justify-content: center; padding: var(--space-3) 0; } +#sidebar.collapsed .btn-sidebar-toggle { margin-left: 0; } +#sidebar.collapsed .sidebar-footer { justify-content: center; padding: var(--space-3) 0; } +#sidebar.collapsed .conn-dot { margin: 0 auto; } + /* 新对话按钮 */ .btn-new { margin: 0 var(--space-4) var(--space-3); diff --git a/emrg/gui/renderer/index.html b/emrg/gui/renderer/index.html index 9b908f00..eb136caf 100644 --- a/emrg/gui/renderer/index.html +++ b/emrg/gui/renderer/index.html @@ -17,6 +17,7 @@
EMRG +
@@ -111,11 +112,39 @@

欢迎使用 EMRG

+ + + + + +

确认操作

+

+
+ + +
+
+ + + +

重命名对话

+
+ +
+
+ + +
+
+ + diff --git a/emrg/gui/renderer/js/sidebar.js b/emrg/gui/renderer/js/sidebar.js new file mode 100644 index 00000000..9801f514 --- /dev/null +++ b/emrg/gui/renderer/js/sidebar.js @@ -0,0 +1,213 @@ +"use strict"; +/** + * sidebar.js — 侧边栏:会话时间分组 / 右键菜单 / 重命名 / 删除确认 / 折叠 + * 设计:docs/design/gui-redesign.md §3.2 侧边栏 + §5.1 文案规范(友好确认,零黑话) + * 依赖:app.js 的 state / renderSessions / switchSession / refreshSessions(反向依赖:app.js 调用本文件函数) + */ + +// ── 时间分组 ───────────────────────────────────────────── + +/** 按 created_at 分三组:今天 / 昨天 / 更早。返回 [ [组名, sessions[]], ... ](保留降序) */ +function groupSessionsByTime(sessions) { + const groups = { 今天: [], 昨天: [], 更早: [] }; + const now = new Date(); + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const startOfYesterday = new Date(startOfToday); + startOfYesterday.setDate(startOfToday.getDate() - 1); + + for (const s of sessions || []) { + const ts = s.created_at ? new Date(s.created_at) : null; + if (!ts || isNaN(ts)) { + groups["更早"].push(s); // 无时间戳 → 更早兜底 + continue; + } + if (ts >= startOfToday) groups["今天"].push(s); + else if (ts >= startOfYesterday) groups["昨天"].push(s); + else groups["更早"].push(s); + } + return [["今天", groups["今天"]], ["昨天", groups["昨天"]], ["更早", groups["更早"]]]; +} + +// ── 确认对话框(友好文案,替代 confirm())─────────────── + +let _confirmCallback = null; + +function showConfirmDialog({ title, desc, okText = "确认", danger = true }) { + const dlg = document.getElementById("confirm-dialog"); + document.getElementById("confirm-title").textContent = title; + document.getElementById("confirm-desc").textContent = desc; + const ok = document.getElementById("confirm-ok"); + ok.textContent = okText; + ok.className = danger ? "danger" : "primary"; + _confirmCallback = null; + dlg.showModal(); + return new Promise((resolve) => { + _confirmCallback = resolve; + }); +} + +function _confirmResolve(result) { + const dlg = document.getElementById("confirm-dialog"); + if (dlg.open) dlg.close(); + if (_confirmCallback) { + const cb = _confirmCallback; + _confirmCallback = null; + cb(result); + } +} + +// ── 右键菜单 ───────────────────────────────────────────── + +function showContextMenu(sessionId, x, y) { + const menu = document.getElementById("ctx-menu"); + menu.innerHTML = ""; + const items = [ + { label: "✏️ 重命名", action: () => openRenameDialog(sessionId) }, + { label: "🗑 删除对话", action: () => requestDeleteSession(sessionId), danger: true }, + ]; + for (const it of items) { + const el = document.createElement("div"); + el.className = "ctx-item" + (it.danger ? " danger" : ""); + el.textContent = it.label; + el.addEventListener("click", () => { + hideContextMenu(); + it.action(); + }); + menu.appendChild(el); + } + menu.hidden = false; + // 定位:限制在视口内 + const mw = 160, mh = items.length * 40 + 8; + menu.style.left = Math.min(x, window.innerWidth - mw - 8) + "px"; + menu.style.top = Math.min(y, window.innerHeight - mh - 8) + "px"; +} + +function hideContextMenu() { + const menu = document.getElementById("ctx-menu"); + menu.hidden = true; +} + +// ── 重命名 ─────────────────────────────────────────────── + +let _renameSessionId = null; + +async function openRenameDialog(sessionId) { + _renameSessionId = sessionId; + const input = document.getElementById("rename-input"); + const s = (state.sessions || []).find((x) => x.session_id === sessionId); + input.value = s && s.title ? s.title : ""; + document.getElementById("rename-dialog").showModal(); + input.focus(); + input.select(); +} + +async function submitRename() { + if (!_renameSessionId) return; + const input = document.getElementById("rename-input"); + const title = input.value.trim(); + if (!title) { + input.focus(); + return; + } + try { + await window.emrg.renameSession({ sessionId: _renameSessionId, title }); + } catch (e) { + addSystemMessage(`重命名失败: ${e.message}`); + } + document.getElementById("rename-dialog").close(); + _renameSessionId = null; + await refreshSessions(); +} + +// ── 删除(友好确认)────────────────────────────────────── + +async function requestDeleteSession(sessionId) { + const ok = await showConfirmDialog({ + title: "删除这段对话?", + desc: "删除后无法恢复。", + okText: "删除", + danger: true, + }); + if (!ok) return; + try { + await window.emrg.deleteSession({ sessionId }); + } catch (e) { + addSystemMessage(`删除失败: ${e.message}`); + return; + } + // 若删除的是当前会话 → 切到剩余最近会话(app.js 逻辑),否则仅刷新列表 + if (state.sessionId === sessionId) { + const remaining = (state.sessions || []).filter((s) => s.session_id !== sessionId); + if (remaining.length > 0) { + await switchSession(remaining[0].session_id, { silent: true }); + } else { + await newSession(); + } + } + await refreshSessions(); +} + +// ── 折叠侧边栏 ─────────────────────────────────────────── + +let sidebarCollapsed = false; + +function toggleSidebar() { + sidebarCollapsed = !sidebarCollapsed; + document.getElementById("sidebar").classList.toggle("collapsed", sidebarCollapsed); + const btn = document.getElementById("sidebar-toggle"); + btn.textContent = sidebarCollapsed ? "▸" : "◂"; + btn.title = sidebarCollapsed ? "展开侧边栏 (⌘B)" : "折叠侧边栏 (⌘B)"; +} + +// ── 事件绑定 ───────────────────────────────────────────── + +function bindSidebarUi() { + const menu = document.getElementById("ctx-menu"); + + // 右键:会话项 → 自定义菜单;其他区域 → 隐藏 + document.addEventListener("contextmenu", (e) => { + const item = e.target.closest(".session-item"); + if (item && item.dataset.sid) { + e.preventDefault(); + showContextMenu(item.dataset.sid, e.clientX, e.clientY); + } else if (!menu.hidden) { + hideContextMenu(); + } + }); + + // 左键点击空白 → 隐藏菜单 + document.addEventListener("click", (e) => { + if (!menu.hidden && !menu.contains(e.target)) hideContextMenu(); + }); + + // ESC → 隐藏菜单 / 关闭对话框 + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + hideContextMenu(); + } + // ⌘B / Ctrl+B 折叠侧边栏 + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "b") { + e.preventDefault(); + toggleSidebar(); + } + }); + + // 确认对话框 + document.getElementById("confirm-cancel").addEventListener("click", () => _confirmResolve(false)); + document.getElementById("confirm-ok").addEventListener("click", () => _confirmResolve(true)); + + // 重命名对话框 + document.getElementById("rename-cancel").addEventListener("click", () => { + document.getElementById("rename-dialog").close(); + _renameSessionId = null; + }); + document.getElementById("rename-ok").addEventListener("click", submitRename); + document.getElementById("rename-input").addEventListener("keydown", (e) => { + if (e.key === "Enter") { e.preventDefault(); submitRename(); } + }); + + // 折叠按钮 + document.getElementById("sidebar-toggle").addEventListener("click", toggleSidebar); +} + +bindSidebarUi();