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}"