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..75bc2121 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -5,7 +5,7 @@ * 安全:contextIsolation + nodeIntegration:false + sandbox:true(renderer 零网络权限)。 */ -const { app, BrowserWindow, dialog, ipcMain, shell } = require("electron"); +const { app, BrowserWindow, dialog, ipcMain } = require("electron"); const fs = require("fs"); const os = require("os"); const path = require("path"); @@ -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; @@ -438,15 +439,6 @@ vision = false return { ok: true }; }); - ipcMain.handle("emrg:openFile", async (_e, { filePath }) => { - // GUI / 指令 WorkBuddy P1:产物面板打开文件(系统默认程序) - if (typeof filePath !== "string" || !filePath.trim()) throw new Error("invalid file path"); - const p = path.resolve(filePath.trim()); - if (!fs.existsSync(p)) return { ok: false, error: "file_not_found" }; - const err = await shell.openPath(p); - return { ok: err === "", error: err || "" }; - }); - ipcMain.handle("emrg:listModels", async () => { const frame = await client.sendCommandAndWait("list_models", {}, 5000); return frame.models || []; 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..198b8dc8 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/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index 6062cf58..eb092aef 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -78,6 +78,7 @@ const ELEMENT_IDS = [ "rant-dialog", "rant-message", "rant-project", "rant-cancel", "rant-submit", "tasks-dialog", "tasks-list", "tasks-close", "result-panel", "result-list", "result-toggle", + "mode-switcher", ]; /** 构造浏览器沙箱(win 即全局对象) */ @@ -470,3 +471,27 @@ test("WorkBuddy P1:ResultPanel 折叠切换(⌘\ 与按钮)", async () => await vm.runInContext("ResultPanel.toggle()", ctx); assert.ok(!els["result-panel"].classList.contains("collapsed"), "再次 toggle 应展开"); }); + +test("WorkBuddy P2:Ask/Auto 模式切换 + sendMessage 携带 mode", async () => { + const { ctx, els } = makeSandbox({ + sendMessage: async (payload) => { globalThis.__lastSend = payload; return { ok: true, requestId: "r1" }; }, + }); + await tick(); + // 默认 auto + const m0 = vm.runInContext("App.state.mode", ctx); + assert.strictEqual(m0, "auto", "默认 auto 模式"); + // 切到 ask → state 更新(DOM 按钮高亮由 querySelectorAll 真实 DOM 完成,沙箱不模拟) + await vm.runInContext("App.setMode('ask')", ctx); + const m1 = vm.runInContext("App.state.mode", ctx); + assert.strictEqual(m1, "ask", "切换到 ask"); + // 发送消息 → mode 透传 + await vm.runInContext(` + (async () => { + App.state.sessionId = "s1"; + const input = document.getElementById("input"); + input.value = "问个问题"; + await App.sendMessage(); + })() + `, ctx); + assert.strictEqual(globalThis.__lastSend.mode, "ask", "sendMessage 应携带 mode=ask"); +}); 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 diff --git a/tests/test_daemon.py b/tests/test_daemon.py index 5e22f457..a7c2ddee 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -514,3 +514,79 @@ def test_rant_field_order(tmp_path, monkeypatch): assert entry["project"] == "emrg" assert entry["status"] == "pending" assert entry["message"] == "test rant message" + + +# ── WorkBuddy P2:Ask mode(rant 21:35) ────────────────────────── + + +class _FakeLlm: + """Fake LLM that records the tools it received and yields one text delta.""" + + def __init__(self): + self.received_tools = None + self.config = LlmConfig(base_url="http://localhost", api_key="test") + self.last_payload = None + self.last_response_status = None + self.last_response_headers = None + self.model = "test" + + async def chat_stream(self, messages, tools=None): + self.received_tools = tools + yield {"content": "纯对话回复"} + + +def test_run_tool_loop_ask_mode_no_tools(): + """mode=ask → _run_tool_loop receives allow_tools=False → LLM gets no tools.""" + import asyncio + + from emrg.protocol import TaskRequest + + server = _make_server() + fake = _FakeLlm() + server.llm = fake # type: ignore[assignment] + + session = Session.create_with_id("ask-test", Path("/tmp")) + req = TaskRequest( + id="ask-1", session_id="ask-test", cwd="/tmp", + prompt="这是什么?", stream=True, + ) + received: list[dict] = [] + + async def fake_send(d, _ws): + received.append(d) + + # 直接调用核心循环(allow_tools=False = Ask) + async def run(): + await server._run_tool_loop(req, None, session, None, allow_tools=False) + + asyncio.run(run()) + + assert fake.received_tools == [], ( + f"Ask 模式不应向 LLM 传工具,实际收到 {fake.received_tools!r}" + ) + + +def test_run_tool_loop_auto_mode_has_tools(): + """mode=auto(默认)→ LLM 收到完整工具集。""" + import asyncio + + from emrg.protocol import TaskRequest + + server = _make_server() + fake = _FakeLlm() + server.llm = fake # type: ignore[assignment] + + session = Session.create_with_id("auto-test", Path("/tmp")) + req = TaskRequest( + id="auto-1", session_id="auto-test", cwd="/tmp", + prompt="帮我看看", stream=True, + ) + + async def run(): + await server._run_tool_loop(req, None, session, None, allow_tools=True) + + asyncio.run(run()) + + assert fake.received_tools, "Auto 模式应携带工具集" + names = [t.get("function", {}).get("name") for t in fake.received_tools] + assert "bash" in names and "read" in names, f"工具集应含 bash/read,实际 {names}"