Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion emrg/gui/daemon_client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions emrg/gui/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -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;
Expand Down
31 changes: 31 additions & 0 deletions emrg/gui/renderer/css/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
4 changes: 4 additions & 0 deletions emrg/gui/renderer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@
<span id="model-switcher-label">加载中…</span>
<span style="font-size:10px;">⌄</span>
</div>
<div class="mode-switcher" id="mode-switcher" title="工作模式:Ask 只对话 / Auto 自动执行">
<button type="button" class="mode-btn" data-mode="ask">Ask</button>
<button type="button" class="mode-btn active" data-mode="auto">Auto</button>
</div>
<div class="composer-card">
<textarea id="input" rows="1" placeholder="发消息给 EMRG… (Enter 发送 / Shift+Enter 换行)" disabled></textarea>
<button id="send-btn" class="send-btn" disabled title="发送">↑</button>
Expand Down
31 changes: 30 additions & 1 deletion emrg/gui/renderer/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};

// ── 启动 ─────────────────────────────────
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -1043,6 +1070,7 @@ const App = (() => {
Dialogs.initModelForm();
Dialogs.initRenameDialog();
initModelSwitcher();
initModeSwitcher(); // WorkBuddy P2:Ask/Auto 工作模式
ResultPanel.init(); // WorkBuddy P1:结果面板(⌘\ 折叠 + 窄屏自动隐藏)
}

Expand All @@ -1061,6 +1089,7 @@ const App = (() => {
bindUi,
updateEmptyState,
updateModelSwitcher,
setMode, // WorkBuddy P2:Ask/Auto 模式(导出供测试与外部调用)
};
})();

Expand Down
34 changes: 34 additions & 0 deletions emrg/gui/test/app-commands.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -223,3 +223,37 @@ test("P4:/trigger <name> 直接触发(不打开对话框)", 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, "帮我写一段代码", "文本应正常透传");
});
14 changes: 11 additions & 3 deletions emrg/server/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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(
Expand Down Expand Up @@ -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

Expand All @@ -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.

Expand All @@ -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()
Expand All @@ -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
Expand Down
Loading