From 4be61fe76e046fe238320a63cd66d9c638db9047 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Wed, 4 Mar 2026 23:49:10 +0800 Subject: [PATCH] feat: unify terminal session controls --- core/main_terminal.py | 51 ++++---------- core/tool_config.py | 1 - modules/persistent_terminal.py | 30 ++++---- modules/terminal_manager.py | 70 ++++++------------- package-lock.json | 25 ++++++- package.json | 1 + prompts/main_system.txt | 6 +- prompts/main_system_qwenvl.txt | 6 +- static/demo/realtime-terminal-demo.html | 2 +- .../chat/monitor/MonitorDirector.ts | 39 ++++------- static/src/stores/monitor.ts | 1 - static/src/utils/chatDisplay.ts | 7 +- static/src/utils/icons.ts | 1 - utils/tool_result_formatter.py | 8 +-- 14 files changed, 103 insertions(+), 145 deletions(-) diff --git a/core/main_terminal.py b/core/main_terminal.py index 25b6db2..787ac7c 100644 --- a/core/main_terminal.py +++ b/core/main_terminal.py @@ -1317,7 +1317,7 @@ class MainTerminal: "type": "function", "function": { "name": "sleep", - "description": "等待指定的秒数,用于短暂延迟/节奏控制(例如让终端产生更多输出、或在两次快照之间留出间隔)。命令是否完成必须用 terminal_snapshot 确认;需要控制命令最长运行时间请使用 run_command/terminal_input 的 timeout。", + "description": "等待指定的秒数,用于短暂延迟/节奏控制(例如让终端产生更多输出、或在两次快照之间留出间隔)。命令是否完成必须用 terminal_snapshot 确认;需要强制超时终止请使用 run_command。", "parameters": { "type": "object", "properties": self._inject_intent({ @@ -1543,12 +1543,12 @@ class MainTerminal: "properties": self._inject_intent({ "action": { "type": "string", - "enum": ["open", "close", "list", "switch"], - "description": "操作类型:open-打开新终端,close-关闭终端,list-列出所有终端,switch-切换活动终端" + "enum": ["open", "close", "list", "reset"], + "description": "操作类型:open-打开新终端,close-关闭终端,list-列出所有终端,reset-重置终端" }, "session_name": { "type": "string", - "description": "终端会话名称(open、close、switch时需要)" + "description": "终端会话名称(open、close、reset时需要)" }, "working_dir": { "type": "string", @@ -1563,7 +1563,7 @@ class MainTerminal: "type": "function", "function": { "name": "terminal_input", - "description": "向活动终端发送命令或输入。禁止启动会占用终端界面的程序(python/node/nano/vim 等);如遇卡死请结合 terminal_snapshot 并使用 terminal_reset 恢复。timeout 必填:\n1) 传入数字(秒,最大300)会对命令进行硬超时封装。系统终端执行环境若存在 timeout/gtimeout,会采用类似 timeout -k 2 {秒}s sh -c '命令; echo __CMD_DONE__...__' 的封装;若没有 timeout/gtimeout(少见情况),则退化为外层 sh -c 的 sleep/kill 包装,例如:sh -c '运行的指令 & CMD_PID=$!; (sleep 300 && kill -s INT $CMD_PID >/dev/null 2>&1 && sleep 2 && kill -s KILL $CMD_PID >/dev/null 2>&1) & WAITER=$!; wait $CMD_PID; CMD_STATUS=$?; kill $WAITER >/dev/null 2>&1; echo __CMD_DONE__1770826047975__; exit $CMD_STATUS'。超时后会先 INT 再 KILL,进程会被不可恢复地打断(可能留下半写文件、锁或残留子进程)。\n2) 传入 never 表示不封装、不杀进程,命令原样进入终端并维护状态,会在运行完成前持续占用终端窗口,在确认完成前无法发送新的指令;此时快照可能无法判断完成情况,需要在其他终端或用run_command使用 curl/ps/ls 等主动验证。\n适合 timeout=never 的场景示例:启动常驻服务/开发服务器(npm run dev、python web_server.py、uvicorn ...)、开启后台进程后在另一个终端测试、在后台运行时间极长的任务同时做其他事情、Github大项目克隆、大文件下载、持续输出/长时间任务(tail -f 日志、长时间编译/训练/备份/大下载)、需要维持会话状态的操作(例如登录远程服务器后连续执行多条命令)。适合用数字超时的示例:ls/rg/pytest/短脚本等快速命令。\n若不确定上一条命令是否结束,先用 terminal_snapshot 确认后再继续输入。", + "description": "向指定终端发送命令或输入。禁止启动会占用终端界面的程序(python/node/nano/vim 等);如遇卡死请结合 terminal_snapshot 并使用 terminal_session 的 reset 恢复。timeout 必填:传入数字(秒,最大300)表示本次等待输出的时长,不会封装命令、不会强杀进程;在等待窗口内若检测到命令已完成会提前返回,否则在超时后返回已产生的输出并保持命令继续运行。需要强制超时终止请使用 run_command。\n若不确定上一条命令是否结束,先用 terminal_snapshot 确认后再继续输入。", "parameters": { "type": "object", "properties": self._inject_intent({ @@ -1573,14 +1573,14 @@ class MainTerminal: }, "session_name": { "type": "string", - "description": "目标终端会话名称(可选,默认使用活动终端)" + "description": "目标终端会话名称(必填)" }, "timeout": { - "type": ["number", "string"], - "description": "等待输出的最长秒数,必填,最大300;填 never 表示不封装超时且不中断进程(数字超时会触发外层封装)" + "type": "number", + "description": "等待输出的最长秒数,必填,最大300;不会封装命令、不会中断进程" } }), - "required": ["command", "timeout"] + "required": ["command", "timeout", "session_name"] } } }, @@ -1608,22 +1608,6 @@ class MainTerminal: } } }, - { - "type": "function", - "function": { - "name": "terminal_reset", - "description": "重置指定终端:关闭当前进程并重新创建同名会话,用于从卡死或非法状态中恢复。请在总结中说明重置原因。", - "parameters": { - "type": "object", - "properties": self._inject_intent({ - "session_name": { - "type": "string", - "description": "目标终端会话名称(可选,默认活动终端)" - } - }) - } - } - }, { "type": "function", "function": { @@ -2072,13 +2056,13 @@ class MainTerminal: elif action == "list": result = self.terminal_manager.list_terminals() - - elif action == "switch": - result = self.terminal_manager.switch_terminal( - session_name=arguments.get("session_name", "default") + + elif action == "reset": + result = self.terminal_manager.reset_terminal( + session_name=arguments.get("session_name") ) if result["success"]: - print(f"{OUTPUT_FORMATS['session']} 切换到终端: {arguments.get('session_name', 'default')}") + print(f"{OUTPUT_FORMATS['session']} 终端会话已重置: {result['session']}") else: result = {"success": False, "error": f"未知操作: {action}"} @@ -2101,13 +2085,6 @@ class MainTerminal: max_chars=arguments.get("max_chars") ) - elif tool_name == "terminal_reset": - result = self.terminal_manager.reset_terminal( - session_name=arguments.get("session_name") - ) - if result["success"]: - print(f"{OUTPUT_FORMATS['session']} 终端会话已重置: {result['session']}") - # sleep工具 elif tool_name == "sleep": seconds = arguments.get("seconds", 1) diff --git a/core/tool_config.py b/core/tool_config.py index 673b45f..e507d62 100644 --- a/core/tool_config.py +++ b/core/tool_config.py @@ -55,7 +55,6 @@ TOOL_CATEGORIES: Dict[str, ToolCategory] = { "terminal_session", "terminal_input", "terminal_snapshot", - "terminal_reset", "sleep", ], ), diff --git a/modules/persistent_terminal.py b/modules/persistent_terminal.py index 95f63e6..a145e59 100644 --- a/modules/persistent_terminal.py +++ b/modules/persistent_terminal.py @@ -14,6 +14,7 @@ import queue from collections import deque import shutil import uuid +import codecs try: from config import ( OUTPUT_FORMATS, @@ -115,6 +116,7 @@ class PersistentTerminal: self.output_queue = queue.Queue() self.reader_thread = None self.is_reading = False + self._decoder = None # 状态标志 self.is_interactive = False # 是否在等待输入 @@ -355,6 +357,8 @@ class PersistentTerminal: "run", "--rm", "-i", + ] + cmd += [ "--name", container_name, "-w", @@ -453,19 +457,18 @@ class PersistentTerminal: def _read_output(self): """后台线程:持续读取输出(修复版,正确处理编码)""" + if self._decoder is None: + encoding = 'gbk' if self.is_windows else 'utf-8' + self._decoder = codecs.getincrementaldecoder(encoding)(errors='replace') while self.is_reading and self.process: try: - # 始终读取字节(因为我们没有使用text=True) - line_bytes = self.process.stdout.readline() - - if line_bytes: - # 解码字节到字符串 - line = self._decode_output(line_bytes) - - # 处理输出 - self.output_queue.put(line) - self._process_output(line) - + # 读取任意字节块,不依赖换行符 + chunk = self.process.stdout.read(1024) + if chunk: + text = self._decoder.decode(chunk) + if text: + self.output_queue.put(text) + self._process_output(text) elif self.process.poll() is not None: # 进程已结束 self.is_running = False @@ -759,7 +762,10 @@ class PersistentTerminal: "output_with_echo": "命令产生输出,但终端疑似重复回显", "timeout": f"命令超时({int(timeout)}秒)" } - message = message_map.get(status, "命令执行完成") + if timeout >= 60: + message = f"[运行了{int(timeout)}秒的所有输出]" + else: + message = message_map.get(status, "命令执行完成") if output_truncated: message += f"(输出已截断,保留末尾{TERMINAL_INPUT_MAX_CHARS}字符)" elapsed_ms = int((time.time() - start_time) * 1000) diff --git a/modules/terminal_manager.py b/modules/terminal_manager.py index ebc4713..cdc5a12 100644 --- a/modules/terminal_manager.py +++ b/modules/terminal_manager.py @@ -528,78 +528,48 @@ class TerminalManager: # 发送命令 terminal = self.terminals[target_session] - never_timeout = False if isinstance(timeout, str): - if timeout.lower() == "never": - never_timeout = True - else: - try: - timeout = float(timeout) - except (TypeError, ValueError): - return { - "success": False, - "error": "timeout 参数必须是数字或 'never'", - "status": "error", - "output": "timeout 参数无效" - } - - if not never_timeout: - if timeout is None or timeout <= 0: + try: + timeout = float(timeout) + except (TypeError, ValueError): return { "success": False, - "error": "timeout 参数必填且需大于0,或设置为 'never'", + "error": "timeout 参数必须是数字", "status": "error", - "output": "timeout 参数缺失" + "output": "timeout 参数无效" } - timeout = min(timeout, 300) - base_timeout = timeout - marker = f"__CMD_DONE__{int(time.time()*1000)}__" + if timeout is None or timeout <= 0: + return { + "success": False, + "error": "timeout 参数必填且需大于0", + "status": "error", + "output": "timeout 参数缺失" + } + timeout = min(timeout, 300) - wrapped_command, wait_timeout = self._build_wrapped_command(command, marker, timeout) - - result = terminal.send_command( - wrapped_command, - timeout=wait_timeout, - timeout_cutoff=base_timeout, - enforce_full_timeout=True, - sentinel=marker, - ) - result["timeout"] = base_timeout - result["never_timeout"] = False - self._apply_terminal_input_timeout_hint(target_session, result) - return result - - # never_timeout 分支:不包装命令,不发送结束标记,不强杀进程 result = terminal.send_command( command, - timeout=None, - timeout_cutoff=None, - enforce_full_timeout=False, + timeout=timeout, + timeout_cutoff=timeout, + enforce_full_timeout=True, sentinel=None, ) - result["timeout"] = "never" - result["never_timeout"] = True + result["timeout"] = timeout + result["never_timeout"] = False self._apply_terminal_input_timeout_hint(target_session, result) return result def _apply_terminal_input_timeout_hint(self, session_name: str, result: Dict) -> None: - """在终端输入连续超时时追加提示标记(仅数值型 timeout)。/ Add hint after consecutive timeouts (numeric only).""" + """在终端输入连续超时时追加提示标记。/ Add hint after consecutive timeouts.""" status = (result.get("status") or "").lower() - never_timeout = result.get("never_timeout") - if never_timeout is None: - timeout_value = result.get("timeout") - if isinstance(timeout_value, str) and timeout_value.lower() == "never": - never_timeout = True - if status == "timeout" and not never_timeout: + if status == "timeout": streak = self._terminal_input_timeout_streaks.get(session_name, 0) + 1 else: streak = 0 self._terminal_input_timeout_streaks[session_name] = streak if streak == 2: result["timeout_hint"] = "suggest_adjust_timeout" - elif streak == 3: - result["timeout_hint"] = "suggest_never_timeout" def _build_wrapped_command(self, command: str, marker: str, timeout: int) -> (str, int): """ diff --git a/package-lock.json b/package-lock.json index d3e3f1f..09ef73a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "4.1.0", "dependencies": { "@types/html2canvas": "^0.5.35", + "enquirer": "^2.4.1", "html2canvas": "^1.4.1", "katex": "^0.16.9", "marked": "^11.1.0", @@ -1842,11 +1843,19 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2195,6 +2204,19 @@ "node": ">=10.0.0" } }, + "node_modules/enquirer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", + "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.1", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8.6" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -3721,7 +3743,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" diff --git a/package.json b/package.json index 6ac26dc..67b7031 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@types/html2canvas": "^0.5.35", + "enquirer": "^2.4.1", "html2canvas": "^1.4.1", "katex": "^0.16.9", "marked": "^11.1.0", diff --git a/prompts/main_system.txt b/prompts/main_system.txt index da85874..957ca56 100644 --- a/prompts/main_system.txt +++ b/prompts/main_system.txt @@ -62,10 +62,10 @@ ### 3.4 终端操作 **持久终端**(`terminal_session` 系列): -- `terminal_session`:管理会话(open/close/list/switch),最多3个 -- `terminal_input`:发送命令(`timeout` 必填,最大300s或 `never`) +- `terminal_session`:管理会话(open/close/list/reset),最多3个 +- `terminal_input`:发送命令(`timeout` 必填,最大300s) - `terminal_snapshot`:获取输出快照(判断状态必备) -- `terminal_reset`:重置卡死终端 + - 终端卡死需用 `terminal_session` 的 reset 操作恢复 **快速执行**: - `run_command`:一次性命令(最长30s,输出限10000字符) diff --git a/prompts/main_system_qwenvl.txt b/prompts/main_system_qwenvl.txt index 8764c64..521495b 100644 --- a/prompts/main_system_qwenvl.txt +++ b/prompts/main_system_qwenvl.txt @@ -80,10 +80,10 @@ ### 3.4 终端操作 **持久终端**(`terminal_session` 系列): -- `terminal_session`:管理会话(open/close/list/switch),最多3个 -- `terminal_input`:发送命令(`timeout` 必填,最大300s或 `never`) +- `terminal_session`:管理会话(open/close/list/reset),最多3个 +- `terminal_input`:发送命令(`timeout` 必填,最大300s) - `terminal_snapshot`:获取输出快照(判断状态必备) -- `terminal_reset`:重置卡死终端 + - 终端卡死需用 `terminal_session` 的 reset 操作恢复 **快速执行**: - `run_command`:一次性命令(最长30s,输出限10000字符) diff --git a/static/demo/realtime-terminal-demo.html b/static/demo/realtime-terminal-demo.html index 5371cd6..f14e561 100644 --- a/static/demo/realtime-terminal-demo.html +++ b/static/demo/realtime-terminal-demo.html @@ -981,7 +981,7 @@ await helpers.sleep(1200); helpers.setStatus('正在规划'); - await helpers.showBubble('terminal_reset:快速清场并恢复初始提示符。', { duration: 2200 }); + await helpers.showBubble('terminal_session reset:快速清场并恢复初始提示符。', { duration: 2200 }); await helpers.moveMouseTo(resetBtn, { duration: 650 }); await helpers.click({ count: 1 }); helpers.appendTerminalLines(['session reset complete', '$'], { reset: true }); diff --git a/static/src/components/chat/monitor/MonitorDirector.ts b/static/src/components/chat/monitor/MonitorDirector.ts index 552bc48..92ab789 100644 --- a/static/src/components/chat/monitor/MonitorDirector.ts +++ b/static/src/components/chat/monitor/MonitorDirector.ts @@ -3362,10 +3362,14 @@ export class MonitorDirector implements MonitorDriver { }; this.sceneHandlers.terminalSession = async (payload, runtime) => { - this.applySceneStatus(runtime, 'terminalSession', '打开终端'); const action = (payload?.arguments?.action || payload?.action || '').toLowerCase(); - // 特殊处理:如果是关闭/切换终端,不要无意中新建会话 - if (action === 'close' || action === 'switch') { + if (action === 'reset') { + this.applySceneStatus(runtime, 'terminalSession', '正在重置终端'); + } else { + this.applySceneStatus(runtime, 'terminalSession', '打开终端'); + } + // 特殊处理:如果是关闭/重置终端,不要无意中新建会话 + if (action === 'close' || action === 'reset') { terminalMenuDebug('terminalSession:action', { action, payload }); const targetSession = this.resolveExistingSessionId(payload); if (!targetSession) { @@ -3379,15 +3383,14 @@ export class MonitorDirector implements MonitorDriver { } else { this.showWindow(shell.element); } - if (action === 'switch') { - const tab = this.getTabElement(targetSession); - if (tab) { - await this.movePointerToElement(tab, { duration: 420 }); - await this.click(); - this.activateSession(targetSession); - } + if (action === 'reset') { + await this.openTerminalContextMenu(targetSession); + await this.chooseTerminalMenuAction('reset'); + this.terminalHistories.set(targetSession, [{ text: '➜ ', role: 'prompt' }]); + this.renderTerminalHistory(targetSession); + this.appendTerminalNote(targetSession, '终端已重置'); this.ensurePromptLine(targetSession); - await sleep(200); + await sleep(300); return; } // action === 'close' @@ -3476,20 +3479,6 @@ export class MonitorDirector implements MonitorDriver { await sleep(300); }; - this.sceneHandlers.terminalReset = async (payload, runtime) => { - this.applySceneStatus(runtime, 'terminalReset', '正在重置终端'); - const { sessionId } = await this.ensureTerminalSessionReady(payload, { focusPrompt: false, activate: false }); - terminalMenuDebug('scene:terminalReset:start', { sessionId }); - await this.openTerminalContextMenu(sessionId); - terminalMenuDebug('scene:terminalReset:menu-opened', { sessionId }); - await this.chooseTerminalMenuAction('reset'); - this.terminalHistories.set(sessionId, [{ text: '➜ ', role: 'prompt' }]); - this.renderTerminalHistory(sessionId); - this.appendTerminalNote(sessionId, '终端已重置'); - this.ensurePromptLine(sessionId); - await sleep(300); - }; - this.sceneHandlers.terminalSleep = async (payload, runtime) => { this.applySceneStatus(runtime, 'terminalSleep', '正在等待'); const duration = diff --git a/static/src/stores/monitor.ts b/static/src/stores/monitor.ts index 2f04124..47c6363 100644 --- a/static/src/stores/monitor.ts +++ b/static/src/stores/monitor.ts @@ -110,7 +110,6 @@ const TOOL_SCENE_MAP: Record = { terminal_session: 'terminalSession', terminal_input: 'terminalInput', terminal_snapshot: 'terminalSnapshot', - terminal_reset: 'terminalReset', sleep: 'terminalSleep', update_memory: 'memoryUpdate', todo_create: 'todoCreate', diff --git a/static/src/utils/chatDisplay.ts b/static/src/utils/chatDisplay.ts index 1b79bf1..e417add 100644 --- a/static/src/utils/chatDisplay.ts +++ b/static/src/utils/chatDisplay.ts @@ -23,7 +23,6 @@ const RUNNING_ANIMATIONS: Record = { terminal_session: 'terminal-animation', terminal_input: 'terminal-animation', terminal_snapshot: 'terminal-animation', - terminal_reset: 'terminal-animation', todo_create: 'file-animation', todo_update_task: 'file-animation', create_sub_agent: 'terminal-animation', @@ -48,8 +47,7 @@ const RUNNING_STATUS_TEXTS: Record = { update_memory: '正在更新记忆...', terminal_session: '正在管理终端会话...', terminal_input: '调用 terminal_input', - terminal_snapshot: '正在获取终端快照...', - terminal_reset: '正在重置终端...' + terminal_snapshot: '正在获取终端快照...' }; const COMPLETED_STATUS_TEXTS: Record = { @@ -71,8 +69,7 @@ const COMPLETED_STATUS_TEXTS: Record = { update_memory: '记忆更新成功', terminal_session: '终端操作完成', terminal_input: '终端输入完成', - terminal_snapshot: '终端快照已返回', - terminal_reset: '终端已重置' + terminal_snapshot: '终端快照已返回' }; const LANGUAGE_CLASS_MAP: Record = { diff --git a/static/src/utils/icons.ts b/static/src/utils/icons.ts index b6f25b1..b3e376a 100644 --- a/static/src/utils/icons.ts +++ b/static/src/utils/icons.ts @@ -62,7 +62,6 @@ export const TOOL_ICON_MAP = Object.freeze({ todo_create: 'stickyNote', todo_update_task: 'check', terminal_input: 'keyboard', - terminal_reset: 'recycle', terminal_session: 'monitor', terminal_snapshot: 'clipboard', unfocus_file: 'eye', diff --git a/utils/tool_result_formatter.py b/utils/tool_result_formatter.py index 07a1ffb..144a73b 100644 --- a/utils/tool_result_formatter.py +++ b/utils/tool_result_formatter.py @@ -302,10 +302,10 @@ def _format_terminal_session(result_data: Dict[str, Any]) -> str: f"终端 {result_data.get('session')} 已关闭,新的活动会话: {new_active}。" f"剩余会话: {', '.join(remaining) if remaining else '无'}" ) - if action == "switch": - previous = result_data.get("previous") or "无" - current = result_data.get("current") or "未知" - return f"终端已从 {previous} 切换到 {current}。" + if action == "reset": + session = result_data.get("session") or "未知会话" + working_dir = result_data.get("working_dir") or "未知目录" + return f"终端 {session} 已重置,工作目录 {working_dir}。" if action == "list": sessions = result_data.get("sessions") or [] total = result_data.get("total", len(sessions))