diff --git a/emrg/gui/daemon_client.js b/emrg/gui/daemon_client.js index 6f68453b..fefb5c74 100644 --- a/emrg/gui/daemon_client.js +++ b/emrg/gui/daemon_client.js @@ -272,10 +272,11 @@ class DaemonClient { // ── 消息发送 ──────────────────────────────────────────── - sendTask({ sessionId, cwd, prompt, stream = true, images = null, requestId = null }) { + sendTask({ sessionId, cwd, prompt, stream = true, images = null, requestId = null, mode = "auto" }) { // G32:request_id 必须作为 id 字段发出(daemon 只回显不自生成) // G96:stream 必须显式 true(daemon 读 stream 默认 False) // G143:外部预生成 requestId 优先(renderer send 前标记自有流,消除 IPC 往返竞态窗口) + // WorkBuddy P2:mode="ask" → daemon 不启用工具(纯对话) const rid = requestId || crypto.randomUUID(); const payload = { type: "task", @@ -287,6 +288,7 @@ class DaemonClient { stream, images, }; + if (mode && mode !== "auto") payload.mode = mode; this._setCurrentStream(rid); this.ws.send(JSON.stringify(payload)); return rid; diff --git a/emrg/gui/main.js b/emrg/gui/main.js index 56d8c77c..fe6ad29e 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -248,7 +248,7 @@ vision = false }; }); - ipcMain.handle("emrg:sendMessage", async (_e, { sessionId, text, requestId }) => { + ipcMain.handle("emrg:sendMessage", async (_e, { sessionId, text, requestId, mode }) => { if (!validateSessionId(sessionId)) throw new Error("invalid session_id"); if (!validateText(text)) throw new Error("invalid text"); if (requestId !== undefined && (typeof requestId !== "string" || requestId.length < 8 || requestId.length > 64)) { @@ -259,7 +259,8 @@ vision = false let rid; try { // G143:renderer 预生成 requestId(send 前标记自有流,消除 IPC 往返竞态窗口) - rid = client.sendTask({ sessionId, cwd: projectDir, prompt: text, stream: true, requestId }); + // WorkBuddy P2:mode="ask" → 纯对话(daemon 不启用工具) + rid = client.sendTask({ sessionId, cwd: projectDir, prompt: text, stream: true, requestId, mode }); } catch (e) { ownStream = false; // sendTask 抛异常(ws.send 失败)→ 释放锁,防 G65 锁泄漏 ownStreamRequestId = null; diff --git a/emrg/gui/renderer/css/components.css b/emrg/gui/renderer/css/components.css index 172183cd..21dbd4fe 100644 --- a/emrg/gui/renderer/css/components.css +++ b/emrg/gui/renderer/css/components.css @@ -887,3 +887,34 @@ dialog::backdrop { word-break: break-word; margin: 0; } + +/* ── 工作模式切换器(Ask/Auto,WorkBuddy P2) ─────────────── */ +.mode-switcher { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 2px; + border-radius: 999px; + background: var(--bg-soft); + border: 1px solid var(--border); + margin-left: 8px; +} +.mode-btn { + border: none; + background: transparent; + color: var(--text-3); + font-size: 11px; + font-weight: 600; + padding: 3px 10px; + border-radius: 999px; + cursor: pointer; + transition: background-color var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease); +} +.mode-btn.active { + background: var(--bg-panel); + color: var(--accent); + box-shadow: 0 1px 3px rgba(0,0,0,0.12); +} +.mode-btn:hover:not(.active) { + color: var(--text-1); +} diff --git a/emrg/gui/renderer/index.html b/emrg/gui/renderer/index.html index 105452bd..02f6e72e 100644 --- a/emrg/gui/renderer/index.html +++ b/emrg/gui/renderer/index.html @@ -55,6 +55,10 @@ 加载中… +
+ + +
diff --git a/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js index 1082a393..cc2ee686 100644 --- a/emrg/gui/renderer/js/app.js +++ b/emrg/gui/renderer/js/app.js @@ -25,6 +25,8 @@ const App = (() => { autoScroll: true, // GUI / 指令补全菜单(rant 19:44 P1):items=[{cmd,hint,phase}] index=当前高亮 cmdMenu: { items: [], index: -1 }, + // WorkBuddy P2(rant 21:35):工作模式 ask(纯对话)/ auto(默认,自动执行工具) + mode: "auto", }; // ── 启动 ───────────────────────────────── @@ -100,7 +102,7 @@ const App = (() => { const requestId = genRequestId(); state.ownStreamRequestId = requestId; try { - const res = await window.emrg.sendMessage({ sessionId: state.sessionId, text, requestId }); + const res = await window.emrg.sendMessage({ sessionId: state.sessionId, text, requestId, mode: state.mode }); state.ownStreamRequestId = res.requestId || requestId; // G124:以 daemon 回显为准 } catch (e) { state.busy = false; @@ -752,6 +754,31 @@ const App = (() => { } } + // ── 工作模式切换器(Ask/Auto,WorkBuddy P2) ─────── + function initModeSwitcher() { + const sw = $("mode-switcher"); + if (!sw) return; + sw.addEventListener("click", (e) => { + const btn = e.target.closest ? e.target.closest(".mode-btn") : null; + if (!btn || !btn.dataset.mode) return; + setMode(btn.dataset.mode); + }); + } + + function setMode(mode) { + if (mode !== "ask" && mode !== "auto") return; + state.mode = mode; + const sw = $("mode-switcher"); + if (!sw) return; + for (const btn of sw.querySelectorAll(".mode-btn")) { + btn.classList.toggle("active", btn.dataset.mode === mode); + } + // Ask 模式提示(仅当切到 ask 时轻提示一次,不打断) + if (mode === "ask") { + Chat.addSystemMessage("Ask 模式:我只对话,不执行工具。输入内容问我就好。"); + } + } + // ── 空状态欢迎屏 ─────────────────────── function updateEmptyState() { const empty = $("empty-state"); @@ -1043,6 +1070,7 @@ const App = (() => { Dialogs.initModelForm(); Dialogs.initRenameDialog(); initModelSwitcher(); + initModeSwitcher(); // WorkBuddy P2:Ask/Auto 工作模式 ResultPanel.init(); // WorkBuddy P1:结果面板(⌘\ 折叠 + 窄屏自动隐藏) } @@ -1061,6 +1089,7 @@ const App = (() => { bindUi, updateEmptyState, updateModelSwitcher, + setMode, // WorkBuddy P2:Ask/Auto 模式(导出供测试与外部调用) }; })(); diff --git a/emrg/gui/test/app-commands.test.js b/emrg/gui/test/app-commands.test.js index 5424813d..81d4b740 100644 --- a/emrg/gui/test/app-commands.test.js +++ b/emrg/gui/test/app-commands.test.js @@ -223,3 +223,37 @@ test("P4:/trigger 直接触发(不打开对话框)", async () => { await vm.runInContext("App.handleCommand({ type: 'command', cmd: '/trigger', args: ['emrg-task'] })", ctx); assert.ok(!(els["tasks-dialog"] && els["tasks-dialog"].__open), "direct trigger should not open dialog"); }); + +// ── WorkBuddy P2(rant 21:35):Ask/Auto 模式 ──────────────── +test("P2:setMode('ask') 更新 state.mode 并提示(Ask 只对话不执行工具)", async () => { + const { ctx } = makeSandbox({}); + await tick(); + await vm.runInContext("App.setMode('ask')", ctx); + const mode = await vm.runInContext("App.state.mode", ctx); + assert.strictEqual(mode, "ask", "state.mode 应为 ask"); + await vm.runInContext("App.setMode('auto')", ctx); + const mode2 = await vm.runInContext("App.state.mode", ctx); + assert.strictEqual(mode2, "auto", "state.mode 应切回 auto"); +}); + +test("P2:setMode 非法值不生效(白名单 ask/auto)", async () => { + const { ctx } = makeSandbox({}); + await tick(); + await vm.runInContext("App.setMode('bogus')", ctx); + const mode = await vm.runInContext("App.state.mode", ctx); + assert.strictEqual(mode, "auto", "非法 mode 应保持默认 auto"); +}); + +test("P2:sendMessage 透传 state.mode(ask → sendMessage 带 mode)", async () => { + let sent = null; + const { ctx, els } = makeSandbox({ + sendMessage: async (p) => { sent = p; return {}; }, + }); + await tick(); + vm.runInContext('App.state.sessionId = "s1"; App.state.mode = "ask";', ctx); + els["input"].value = "帮我写一段代码"; + await vm.runInContext("App.sendMessage()", ctx); + assert.ok(sent, "sendMessage 应被调用"); + assert.strictEqual(sent.mode, "ask", "ask 模式应透传 mode 参数"); + assert.strictEqual(sent.text, "帮我写一段代码", "文本应正常透传"); +}); diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index 5f4ee885..42e927b5 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -349,6 +349,9 @@ async def _handle_client(self, ws) -> None: except Exception as e: await self._send(ws, {"error": f"invalid task: {e}"}) continue + # WorkBuddy P2 (rant 21:35): Ask mode — pure chat, no tools. + # mode="ask" → LLM gets an empty tool set so it can only reply. + allow_tools = data.get("mode", "auto") != "ask" # Cancel previous task if still running if _tool_task and not _tool_task.done(): if _cancel_event: @@ -363,7 +366,7 @@ async def _handle_client(self, ws) -> None: self._session_busy[session_id] = True # lock (released in *locked wrapper) if req.stream: _tool_task = asyncio.create_task( - self._run_tool_loop_locked(req, ws, session, _cancel_event) + self._run_tool_loop_locked(req, ws, session, _cancel_event, allow_tools=allow_tools) ) else: _tool_task = asyncio.create_task( @@ -1119,11 +1122,12 @@ async def _run_chat_once( async def _run_tool_loop_locked( self, req: TaskRequest, ws, session: Session, cancel_event: asyncio.Event | None = None, + allow_tools: bool = True, ) -> None: """Run _run_tool_loop and release the session busy lock on exit.""" session_id = session.session_id try: - await self._run_tool_loop(req, ws, session, cancel_event) + await self._run_tool_loop(req, ws, session, cancel_event, allow_tools) finally: self._session_busy[session_id] = False @@ -1140,6 +1144,7 @@ async def _run_chat_once_locked( async def _run_tool_loop( self, req: TaskRequest, ws, session: Session, cancel_event: asyncio.Event | None = None, + allow_tools: bool = True, ) -> None: """Run the streaming tool-calling loop with session persistence. @@ -1154,6 +1159,9 @@ async def _run_tool_loop( Supports cancellation via cancel_event (checked between rounds) and asyncio task cancellation (interrupts streaming mid-round). + + allow_tools=False (Ask mode, WorkBuddy P2) sends an empty tool set — + the LLM can only reply in plain chat, the loop exits after round 1. """ system_prompt = self._build_system_prompt(session) history_messages = session.get_messages_for_llm() @@ -1174,7 +1182,7 @@ async def _run_tool_loop( *history_messages, {"role": "user", "content": user_content}, ] - tools_openai = self.tools.to_openai_tools() + tools_openai = self.tools.to_openai_tools() if allow_tools else [] for round_num in range(1, self._max_tool_rounds + 1): # Check for cancellation between rounds