Skip to content
Closed
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
16 changes: 4 additions & 12 deletions emrg/gui/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down 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 Expand Up @@ -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 || [];
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
25 changes: 25 additions & 0 deletions emrg/gui/test/renderer.smoke.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 即全局对象) */
Expand Down Expand Up @@ -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");
});
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
76 changes: 76 additions & 0 deletions tests/test_daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Loading