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