From 053db95feeba12694de8d85d8a28ef6994123b83 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sun, 14 Dec 2025 04:20:57 +0800 Subject: [PATCH] feat: virtual monitor --- core/main_terminal.py | 2 +- doc/write_file_diff_description.txt | 237 ++++ modules/sub_agent_manager.py | 7 + static/src/app.ts | 5 + .../components/chat/VirtualMonitorSurface.vue | 23 +- .../chat/monitor/MonitorDirector.ts | 1176 +++++++++++++++-- static/src/components/chat/monitor/types.ts | 1 + static/src/composables/useLegacySocket.ts | 4 + static/src/stores/monitor.ts | 162 ++- .../components/chat/_virtual-monitor.scss | 223 +++- static/src/utils/icons.ts | 1 + utils/tool_result_formatter.py | 12 + web_server.py | 39 +- 13 files changed, 1698 insertions(+), 194 deletions(-) create mode 100644 doc/write_file_diff_description.txt diff --git a/core/main_terminal.py b/core/main_terminal.py index 3033ff1..20b3837 100644 --- a/core/main_terminal.py +++ b/core/main_terminal.py @@ -1217,7 +1217,7 @@ class MainTerminal: "type": "function", "function": { "name": "write_file_diff", - "description": "使用统一 diff(@@ 块、- / + 行)直接写入文件,可一次性完成追加/替换/删除。每个块以 @@ [id:数字] 开头,块内只能包含上下文行(空格开头)、- 原文行、+ 新内容行,请务必包含足够的上下文以确保定位准确。", + "description": "使用统一 diff(`@@` 块、`-`/`+`/空格行)对单个文件做精确编辑:追加、插入、替换、删除都可以在一次调用里完成。\\n硬性规则:\\n\\n1) 补丁必须被 `*** Begin Patch` 与 `*** End Patch` 包裹。\\n2) 每个修改块必须以 `@@ [id:数字]` 开头。\\n3) 块内每一行只能是三类之一:\\n - 上下文行:以空格开头(` ␠`),表示“文件里必须原样存在”的锚点;\\n - 删除行:以 `-` 开头,表示要从文件中移除的原文;\\n - 新增行:以 `+` 开头,表示要写入的新内容。\\n4) 任何“想新增/想删除/想替换”的内容都必须逐行写 `+` 或 `-`;空行也必须写成单独一行的 `+`(这一行只有 `+` 和换行);如果你把多行新内容直接贴上去却不加 `+`,它会被当成上下文锚点去匹配原文件,极易导致“未找到匹配的原文”。\\n5) 重要语义:一个块里如果完全没有上下文行(空格开头)也没有删除行(`-`),那么它会被视为“仅追加(append-only)”,也就是把所有 `+` 行追加到文件末尾——这对“给空文件写正文”很合适,但对“插入到中间”是错误的。\\n\\n正面案例(至少 5 个,且都包含多行原文/多处修改)\\n\\n1) 给空文件写完整正文(追加到末尾;空文件=正确)\\n目标:新建 README.md 后一次性写入标题、安装、用法、FAQ(多段落、多行)。\\n要点:没有上下文/删除行 → 追加模式;空文件时最常用。\\n\\n*** Begin Patch\\n@@ [id:1]\\n+# 项目名称\\n+\\n+一个简短说明:这个项目用于……\\n+\\n+## 安装\\n+\\n+```bash\\n+pip install -r requirements.txt\\n+```\\n+\\n+## 快速开始\\n+\\n+```bash\\n+python main.py\\n+```\\n+\\n+## 常见问题\\n+\\n+- Q: 为什么会报 xxx?\\n+ A: 先检查 yyy,再确认 zzz。\\n+\\n*** End Patch\\n\\n2) “不删除,直接插入内容”到函数内部(必须用上下文锚定插入位置)\\n目标:在 def build_prompt(...): 里插入日志与参数归一化,但不改动其它行。\\n要点:插入发生在“两个上下文行之间”,上下文必须精确(包含缩进)。\\n\\n*** Begin Patch\\n@@ [id:1]\\n def build_prompt(user_text: str, system_text: str, tags: list):\\n prompt_parts = []\\n+ # 参数归一化:去掉首尾空白,避免模型误判\\n+ user_text = (user_text or \\\"\\\").strip()\\n+ system_text = (system_text or \\\"\\\").strip()\\n+\\n+ logger.debug(\\\"build_prompt: tags=%s, user_len=%d\\\", tags, len(user_text))\\n prompt_parts.append(system_text)\\n prompt_parts.append(user_text)\\n if tags:\\n prompt_parts.append(\\\"TAGS: \\\" + \\\",\\\".join(tags))\\n*** End Patch\\n\\n3) 复杂替换:整段函数重构(多行 old/new + 保留稳定上下文)\\n目标:把旧的 apply_patch()(弱校验)替换成新实现(多分支、异常信息更清晰)。\\n要点:替换不是“改一行”,而是“删一段、加一段”,并用函数签名/相邻代码作锚点。\\n\\n*** Begin Patch\\n@@ [id:1]\\n class FilePatcher:\\n def __init__(self, root: Path):\\n self.root = root\\n\\n def apply_patch(self, path: str, patch_text: str) -> dict:\\n- # old: naive replace\\n- content = (self.root / path).read_text(encoding=\\\"utf-8\\\")\\n- content = content.replace(\\\"foo\\\", \\\"bar\\\")\\n- (self.root / path).write_text(content, encoding=\\\"utf-8\\\")\\n- return {\\\"success\\\": True}\\n+ full_path = (self.root / path).resolve()\\n+ if self.root not in full_path.parents and full_path != self.root:\\n+ return {\\\"success\\\": False, \\\"error\\\": \\\"非法路径:越界访问\\\"}\\n+\\n+ if \\\"*** Begin Patch\\\" not in patch_text or \\\"*** End Patch\\\" not in patch_text:\\n+ return {\\\"success\\\": False, \\\"error\\\": \\\"补丁格式错误:缺少 Begin/End 标记\\\"}\\n+\\n+ try:\\n+ original = full_path.read_text(encoding=\\\"utf-8\\\")\\n+ except Exception as e:\\n+ return {\\\"success\\\": False, \\\"error\\\": f\\\"读取失败: {e}\\\"}\\n+\\n+ # 这里省略:解析 blocks、逐块应用、失败回滚等\\n+ updated = original\\n+ try:\\n+ full_path.write_text(updated, encoding=\\\"utf-8\\\")\\n+ except Exception as e:\\n+ return {\\\"success\\\": False, \\\"error\\\": f\\\"写入失败: {e}\\\"}\\n+\\n+ return {\\\"success\\\": True, \\\"message\\\": \\\"已应用补丁\\\"}\\n*** End Patch\\n\\n4) 复杂多块:同一文件里同时“加 import + 替换逻辑 + 插入新 helper + 删除旧函数”\\n目标:一次调用完成 4 种操作,且每块都有足够上下文,避免误匹配。\\n要点:不同区域用不同 @@ [id:n] 分块,互不干扰。\\n\\n*** Begin Patch\\n@@ [id:1]\\n-import json\\n+import json\\n+import re\\n from pathlib import Path\\n\\n@@ [id:2]\\n def normalize_user_input(text: str) -> str:\\n- return text\\n+ text = (text or \\\"\\\").strip()\\n+ # 压缩多余空白,减少提示词抖动\\n+ text = re.sub(r\\\"\\\\\\\\s+\\\", \\\" \\\", text)\\n+ return text\\n\\n@@ [id:3]\\n def load_config(path: str) -> dict:\\n cfg_path = Path(path)\\n if not cfg_path.exists():\\n return {}\\n data = cfg_path.read_text(encoding=\\\"utf-8\\\")\\n return json.loads(data)\\n+\\n+def safe_get(cfg: dict, key: str, default=None):\\n+ if not isinstance(cfg, dict):\\n+ return default\\n+ return cfg.get(key, default)\\n\\n@@ [id:4]\\n-def legacy_parse_flags(argv):\\n- # deprecated, kept for compatibility\\n- flags = {}\\n- for item in argv:\\n- if item.startswith(\\\"--\\\"):\\n- k, _, v = item[2:].partition(\\\"=\\\")\\n- flags[k] = v or True\\n- return flags\\n-\\n def main():\\n cfg = load_config(\\\"config.json\\\")\\n # ...\\n*** End Patch\\n\\n5) 删除示例:删除一整段“废弃配置块”,并顺手修正周围空行(多行删除 + 上下文)\\n目标:删掉 DEPRECATED_* 配置和旧注释,确保删除位置精确。\\n要点:删除行必须逐行 `-`;保留上下文行确保定位。\\n\\n*** Begin Patch\\n@@ [id:1]\\n # ==============================\\n # Runtime Config\\n # ==============================\\n-DEPRECATED_TIMEOUT = 5\\n-DEPRECATED_RETRIES = 1\\n-# 注意:这些字段将在下个版本移除\\n-# 请迁移到 NEW_TIMEOUT / NEW_RETRIES\\n NEW_TIMEOUT = 30\\n NEW_RETRIES = 3\\n*** End Patch\\n\\n如何写“带上下文”的正确姿势(要点)\\n\\n- 上下文要选“稳定锚点”:函数签名、类名、关键注释、紧邻的两三行缩进代码。\\n- 不要用“容易变的行”当唯一锚点:时间戳、日志序号、随机 id、生成内容片段。\\n- 上下文必须字节级一致(空格/Tab/大小写/标点都算),否则会匹配失败。\\n\\n反面案例(至少 3 个,且都是“真实会踩坑”的类型)\\n\\n反例 A(来自一次常见错误):空文件时只有第一行加了 `+`,后面直接贴正文\\n这会让后面的正文变成“上下文锚点”,工具会去空文件里找这些原文,必然失败(常见报错:未找到匹配的原文)。\\n\\n*** Begin Patch\\n@@ [id:1]\\n+\\n仰望U9X·电驭苍穹\\n银箭破空电光闪\\n三千马力云中藏\\n*** End Patch\\n\\n正确做法:正文每一行都要写 `+`(包括空行也写 `+`),空行写法是一行单独的 `+`。\\n\\n(反例:空行没加 `+`,会被当成上下文,空文件/定位修改时容易失败)\\n\\n*** Begin Patch\\n@@ [id:1]\\n+title = \\\"示例\\\"\\n\\n+[db]\\n+enabled = true\\n*** End Patch\\n\\n(正确:空行也要用 `+` 表示)\\n\\n*** Begin Patch\\n@@ [id:1]\\n+title = \\\"示例\\\"\\n+\\n+[db]\\n+enabled = true\\n*** End Patch\\n\\n(对应的正确 patch 示例:向空文件追加多行)\\n\\n*** Begin Patch\\n@@ [id:1]\\n+\\n+仰望U9X·电驭苍穹\\n+银箭破空电光闪\\n+三千马力云中藏\\n*** End Patch\\n\\n反例 B:想“插入到中间”,却只写 `+`(没有任何上下文/删除行)\\n这种块会被当成“追加到文件末尾”,结果内容跑到文件最后,不会插入到你以为的位置。\\n\\n*** Begin Patch\\n@@ [id:1]\\n+# 我以为会插到某个函数上面\\n+print(\\\"hello\\\")\\n*** End Patch\\n\\n正确做法:用上下文锚定插入点(见正面案例 2)。\\n\\n(对应的正确 patch 示例:用上下文把内容插入到函数内部,而不是追加到文件末尾)\\n\\n*** Begin Patch\\n@@ [id:1]\\n def main():\\n config = load_config(\\\"config.json\\\")\\n+ # 这里插入:启动提示(不会移动到文件末尾)\\n+ print(\\\"hello\\\")\\n run(config)\\n*** End Patch\\n\\n反例 C:补丁在第一个 `@@` 之前出现内容 / 或漏掉 Begin/End 标记\\n解析会直接报格式错误(例如:“在检测到第一个 @@ 块之前出现内容”、“缺少 Begin/End 标记”)。\\n\\n(错误形态示意)\\n这里先写了一段说明文字(没有 @@)\\n@@ [id:1]\\n+...\\n\\n正确做法:确保第一段非空内容必须从 `@@ [id:n]` 开始,并且整体有 Begin/End。\\n\\n(对应的正确 patch 示例:完整结构、第一段内容从 @@ 块开始)\\n\\n*** Begin Patch\\n@@ [id:1]\\n # ==============================\\n # Runtime Config\\n # ==============================\\n+# 说明:此处新增一行注释作为示例\\n NEW_TIMEOUT = 30\\n*** End Patch\\n", "parameters": { "type": "object", "properties": { diff --git a/doc/write_file_diff_description.txt b/doc/write_file_diff_description.txt new file mode 100644 index 0000000..536c2d0 --- /dev/null +++ b/doc/write_file_diff_description.txt @@ -0,0 +1,237 @@ +使用统一 diff(`@@` 块、`-`/`+`/空格行)对单个文件做精确编辑:追加、插入、替换、删除都可以在一次调用里完成。 +硬性规则: + +1) 补丁必须被 `*** Begin Patch` 与 `*** End Patch` 包裹。 +2) 每个修改块必须以 `@@ [id:数字]` 开头。 +3) 块内每一行只能是三类之一: + - 上下文行:以空格开头(` ␠`),表示“文件里必须原样存在”的锚点; + - 删除行:以 `-` 开头,表示要从文件中移除的原文; + - 新增行:以 `+` 开头,表示要写入的新内容。 +4) 任何“想新增/想删除/想替换”的内容都必须逐行写 `+` 或 `-`;如果你把多行新内容直接贴上去却不加 `+`,它会被当成上下文锚点去匹配原文件,极易导致“未找到匹配的原文”。 +5) 重要语义:一个块里如果完全没有上下文行(空格开头)也没有删除行(`-`),那么它会被视为“仅追加(append-only)”,也就是把所有 `+` 行追加到文件末尾——这对“给空文件写正文”很合适,但对“插入到中间”是错误的。 + +正面案例(至少 5 个,且都包含多行原文/多处修改) + +1) 给空文件写完整正文(追加到末尾;空文件=正确) +目标:新建 README.md 后一次性写入标题、安装、用法、FAQ(多段落、多行)。 +要点:没有上下文/删除行 → 追加模式;空文件时最常用。 + +*** Begin Patch +@@ [id:1] ++# 项目名称 ++ ++一个简短说明:这个项目用于…… ++ ++## 安装 ++ ++```bash ++pip install -r requirements.txt ++``` ++ ++## 快速开始 ++ ++```bash ++python main.py ++``` ++ ++## 常见问题 ++ ++- Q: 为什么会报 xxx? ++ A: 先检查 yyy,再确认 zzz。 ++ +*** End Patch + +2) “不删除,直接插入内容”到函数内部(必须用上下文锚定插入位置) +目标:在 def build_prompt(...): 里插入日志与参数归一化,但不改动其它行。 +要点:插入发生在“两个上下文行之间”,上下文必须精确(包含缩进)。 + +*** Begin Patch +@@ [id:1] + def build_prompt(user_text: str, system_text: str, tags: list): + prompt_parts = [] ++ # 参数归一化:去掉首尾空白,避免模型误判 ++ user_text = (user_text or "").strip() ++ system_text = (system_text or "").strip() ++ ++ logger.debug("build_prompt: tags=%s, user_len=%d", tags, len(user_text)) + prompt_parts.append(system_text) + prompt_parts.append(user_text) + if tags: + prompt_parts.append("TAGS: " + ",".join(tags)) +*** End Patch + +3) 复杂替换:整段函数重构(多行 old/new + 保留稳定上下文) +目标:把旧的 apply_patch()(弱校验)替换成新实现(多分支、异常信息更清晰)。 +要点:替换不是“改一行”,而是“删一段、加一段”,并用函数签名/相邻代码作锚点。 + +*** Begin Patch +@@ [id:1] + class FilePatcher: + def __init__(self, root: Path): + self.root = root + + def apply_patch(self, path: str, patch_text: str) -> dict: +- # old: naive replace +- content = (self.root / path).read_text(encoding="utf-8") +- content = content.replace("foo", "bar") +- (self.root / path).write_text(content, encoding="utf-8") +- return {"success": True} ++ full_path = (self.root / path).resolve() ++ if self.root not in full_path.parents and full_path != self.root: ++ return {"success": False, "error": "非法路径:越界访问"} ++ ++ if "*** Begin Patch" not in patch_text or "*** End Patch" not in patch_text: ++ return {"success": False, "error": "补丁格式错误:缺少 Begin/End 标记"} ++ ++ try: ++ original = full_path.read_text(encoding="utf-8") ++ except Exception as e: ++ return {"success": False, "error": f"读取失败: {e}"} ++ ++ # 这里省略:解析 blocks、逐块应用、失败回滚等 ++ updated = original ++ try: ++ full_path.write_text(updated, encoding="utf-8") ++ except Exception as e: ++ return {"success": False, "error": f"写入失败: {e}"} ++ ++ return {"success": True, "message": "已应用补丁"} +*** End Patch + +4) 复杂多块:同一文件里同时“加 import + 替换逻辑 + 插入新 helper + 删除旧函数” +目标:一次调用完成 4 种操作,且每块都有足够上下文,避免误匹配。 +要点:不同区域用不同 @@ [id:n] 分块,互不干扰。 + +*** Begin Patch +@@ [id:1] +-import json ++import json ++import re + from pathlib import Path + +@@ [id:2] + def normalize_user_input(text: str) -> str: +- return text ++ text = (text or "").strip() ++ # 压缩多余空白,减少提示词抖动 ++ text = re.sub(r"\\s+", " ", text) ++ return text + +@@ [id:3] + def load_config(path: str) -> dict: + cfg_path = Path(path) + if not cfg_path.exists(): + return {} + data = cfg_path.read_text(encoding="utf-8") + return json.loads(data) ++ ++def safe_get(cfg: dict, key: str, default=None): ++ if not isinstance(cfg, dict): ++ return default ++ return cfg.get(key, default) + +@@ [id:4] +-def legacy_parse_flags(argv): +- # deprecated, kept for compatibility +- flags = {} +- for item in argv: +- if item.startswith("--"): +- k, _, v = item[2:].partition("=") +- flags[k] = v or True +- return flags +- + def main(): + cfg = load_config("config.json") + # ... +*** End Patch + +5) 删除示例:删除一整段“废弃配置块”,并顺手修正周围空行(多行删除 + 上下文) +目标:删掉 DEPRECATED_* 配置和旧注释,确保删除位置精确。 +要点:删除行必须逐行 `-`;保留上下文行确保定位。 + +*** Begin Patch +@@ [id:1] + # ============================== + # Runtime Config + # ============================== +-DEPRECATED_TIMEOUT = 5 +-DEPRECATED_RETRIES = 1 +-# 注意:这些字段将在下个版本移除 +-# 请迁移到 NEW_TIMEOUT / NEW_RETRIES + NEW_TIMEOUT = 30 + NEW_RETRIES = 3 +*** End Patch + +如何写“带上下文”的正确姿势(要点) + +- 上下文要选“稳定锚点”:函数签名、类名、关键注释、紧邻的两三行缩进代码。 +- 不要用“容易变的行”当唯一锚点:时间戳、日志序号、随机 id、生成内容片段。 +- 上下文必须字节级一致(空格/Tab/大小写/标点都算),否则会匹配失败。 + +反面案例(至少 3 个,且都是“真实会踩坑”的类型) + +反例 A(来自一次常见错误):空文件时只有第一行加了 `+`,后面直接贴正文 +这会让后面的正文变成“上下文锚点”,工具会去空文件里找这些原文,必然失败(常见报错:未找到匹配的原文)。 + +*** Begin Patch +@@ [id:1] ++ +仰望U9X·电驭苍穹 +银箭破空电光闪 +三千马力云中藏 +*** End Patch + +正确做法:正文每一行都要写 `+`(包括空行也写 `+`)。 + +(对应的正确 patch 示例:向空文件追加多行) + +*** Begin Patch +@@ [id:1] ++ ++仰望U9X·电驭苍穹 ++银箭破空电光闪 ++三千马力云中藏 +*** End Patch + +反例 B:想“插入到中间”,却只写 `+`(没有任何上下文/删除行) +这种块会被当成“追加到文件末尾”,结果内容跑到文件最后,不会插入到你以为的位置。 + +*** Begin Patch +@@ [id:1] ++# 我以为会插到某个函数上面 ++print("hello") +*** End Patch + +正确做法:用上下文锚定插入点(见正面案例 2)。 + +(对应的正确 patch 示例:用上下文把内容插入到函数内部,而不是追加到文件末尾) + +*** Begin Patch +@@ [id:1] + def main(): + config = load_config("config.json") ++ # 这里插入:启动提示(不会移动到文件末尾) ++ print("hello") + run(config) +*** End Patch + +反例 C:补丁在第一个 `@@` 之前出现内容 / 或漏掉 Begin/End 标记 +解析会直接报格式错误(例如:“在检测到第一个 @@ 块之前出现内容”、“缺少 Begin/End 标记”)。 + +(错误形态示意) +这里先写了一段说明文字(没有 @@) +@@ [id:1] ++... + +正确做法:确保第一段非空内容必须从 `@@ [id:n]` 开始,并且整体有 Begin/End。 + +(对应的正确 patch 示例:完整结构、第一段内容从 @@ 块开始) + +*** Begin Patch +@@ [id:1] + # ============================== + # Runtime Config + # ============================== ++# 说明:此处新增一行注释作为示例 + NEW_TIMEOUT = 30 +*** End Patch diff --git a/modules/sub_agent_manager.py b/modules/sub_agent_manager.py index 3c8a431..99b89cf 100644 --- a/modules/sub_agent_manager.py +++ b/modules/sub_agent_manager.py @@ -20,8 +20,15 @@ from config import ( SUB_AGENT_TASKS_BASE_DIR, ) from utils.logger import setup_logger +import logging +# 静音子智能体日志(交由前端提示/brief_log处理) logger = setup_logger(__name__) +logger.setLevel(logging.CRITICAL) +logger.disabled = True +logger.propagate = False +for h in list(logger.handlers): + logger.removeHandler(h) TERMINAL_STATUSES = {"completed", "failed", "timeout"} diff --git a/static/src/app.ts b/static/src/app.ts index 2a73f49..2dcae8b 100644 --- a/static/src/app.ts +++ b/static/src/app.ts @@ -575,6 +575,8 @@ const appOptions = { monitorShowSpeech: 'enqueueModelSpeech', monitorShowThinking: 'enqueueModelThinking', monitorEndModelOutput: 'endModelOutput', + monitorShowPendingReply: 'showPendingReply', + monitorPreviewTool: 'previewToolIntent', monitorQueueTool: 'enqueueToolEvent', monitorResolveTool: 'resolveToolResult' }), @@ -1947,6 +1949,9 @@ const appOptions = { this.chatAddUserMessage(message); this.socket.emit('send_message', { message: message, conversation_id: this.currentConversationId }); + if (typeof this.monitorShowPendingReply === 'function') { + this.monitorShowPendingReply(); + } this.inputClearMessage(); this.inputSetLineCount(1); this.inputSetMultiline(false); diff --git a/static/src/components/chat/VirtualMonitorSurface.vue b/static/src/components/chat/VirtualMonitorSurface.vue index 4cc4acb..0b1d374 100644 --- a/static/src/components/chat/VirtualMonitorSurface.vue +++ b/static/src/components/chat/VirtualMonitorSurface.vue @@ -78,10 +78,15 @@ - 命令行 + 终端 + +
+
+
+ +
+
-
-
@@ -159,7 +164,9 @@
- + + +
@@ -207,8 +214,10 @@ const editorHeaderText = ref(null); const editorBody = ref(null); const terminalWindow = ref(null); const terminalHeaderText = ref(null); +const terminalTabs = ref(null); +const terminalTabList = ref(null); +const terminalAddButton = ref(null); const terminalBody = ref(null); -const terminalInputLine = ref(null); const readerWindow = ref(null); const readerTitle = ref(null); const readerLines = ref(null); @@ -278,8 +287,10 @@ const mountDirector = async () => { editorBody: editorBody.value!, terminalWindow: terminalWindow.value!, terminalHeaderText: terminalHeaderText.value!, + terminalTabs: terminalTabs.value!, + terminalTabList: terminalTabList.value!, + terminalAddButton: terminalAddButton.value!, terminalBody: terminalBody.value!, - terminalInputLine: terminalInputLine.value!, readerWindow: readerWindow.value!, readerTitle: readerTitle.value!, readerLines: readerLines.value!, diff --git a/static/src/components/chat/monitor/MonitorDirector.ts b/static/src/components/chat/monitor/MonitorDirector.ts index 0c34cb7..7c6630c 100644 --- a/static/src/components/chat/monitor/MonitorDirector.ts +++ b/static/src/components/chat/monitor/MonitorDirector.ts @@ -4,6 +4,7 @@ import { getSceneProgressLabel } from './progressMap'; type SceneHandler = (payload: Record, runtime: MonitorSceneRuntime) => Promise; type ContextMenuType = 'desktop' | 'folder' | 'file' | 'browser' | 'terminal' | 'focus'; +type TerminalMenuAction = 'snapshot' | 'reset' | 'close'; type DesktopIcon = { id: string; @@ -39,8 +40,10 @@ export interface MonitorElements { editorBody: HTMLElement; terminalWindow: HTMLElement; terminalHeaderText: HTMLElement; + terminalTabs: HTMLElement; + terminalTabList: HTMLElement; + terminalAddButton: HTMLElement; terminalBody: HTMLElement; - terminalInputLine: HTMLElement; readerWindow: HTMLElement; readerTitle: HTMLElement; readerLines: HTMLElement; @@ -113,6 +116,14 @@ const monitorLifecycleDebug = (...args: any[]) => { } console.info('[MonitorDirector]', ...args); }; +const MONITOR_TERMINAL_DEBUG = true; +const terminalMenuDebug = (...args: any[]) => { + if (!MONITOR_TERMINAL_DEBUG) { + return; + } + // 使用 warn 级别,避免浏览器控制台过滤掉 info 级别 + console.warn('[TerminalMenu]', ...args); +}; const DESKTOP_APPS: Array<{ id: string; label: string; assetKey: string }> = [ { id: 'browser', label: '浏览器', assetKey: 'browser' }, @@ -144,6 +155,26 @@ type ExtractionWindowInstance = { titleEl: HTMLElement | null; }; +type TerminalSessionRecord = { + id: string; + name: string; + rawName?: string; +}; + +type TerminalShell = { + element: HTMLElement; + bodyEl: HTMLElement; + titleEl: HTMLElement | null; +}; + +type TerminalLine = { + text: string; + role: 'prompt' | 'output' | 'note'; +}; + +const DEFAULT_TERMINAL_NAME = '终端1'; +const TERMINAL_NAME_PREFIX = '终端'; + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); export class MonitorDirector implements MonitorDriver { @@ -168,6 +199,14 @@ export class MonitorDirector implements MonitorDriver { private extractionAnchor = { x: 0.6, y: 0.04 }; private extractionWindows = new Map(); private extractionTemplate: ExtractionWindowInstance | null = null; + private terminalSessions = new Map(); + private terminalSessionTitleMap = new Map(); + private terminalRawNameMap = new Map(); + private terminalSessionNames = new Map(); + private terminalHistories = new Map(); + // 用于右键菜单记住目标终端 + private terminalContextSessionId: string | null = null; + private activeTerminalSessionId: string | null = null; private manualInteractionEnabled = false; private manualListenersAttached = false; private manualPositions = new Map(); @@ -180,6 +219,13 @@ export class MonitorDirector implements MonitorDriver { private progressBubbleTimer: number | null = null; private progressBubbleBase: string | null = null; private progressSceneName: string | null = null; + private thinkingBubbleTimer: number | null = null; + private thinkingBubblePhase = 0; + private waitingBubbleTimer: number | null = null; + private waitingBubbleBase: string | null = null; + private progressBubbleActive = false; + private lastTerminalSessionId: string | null = null; + private terminalLastFocusedAt = 0; private applySceneStatus(runtime: MonitorSceneRuntime, sceneName: string, fallback: string) { if (!runtime || typeof runtime.setStatus !== 'function') { @@ -199,10 +245,14 @@ export class MonitorDirector implements MonitorDriver { this.screenRect = elements.screen.getBoundingClientRect(); this.setupAnchors(); this.setupScenes(); + this.bindTerminalInteractions(); this.populateDesktop(); this.extractionAnchor = this.windowAnchors.get(this.elements.extractionWindow) || this.extractionAnchor; this.prepareExtractionTemplate(); this.layoutFloatingWindows(); + // 标记当前构建,便于用户确认是否加载了最新前端代码 + (window as any).__TERMINAL_MENU_DEBUG_BUILD = '2025-12-13-1'; + terminalMenuDebug('constructor:init', { build: (window as any).__TERMINAL_MENU_DEBUG_BUILD }); const resizeHandler = () => { this.screenRect = this.elements.screen.getBoundingClientRect(); this.layoutFloatingWindows(); @@ -253,7 +303,13 @@ export class MonitorDirector implements MonitorDriver { this.elements.folderBody.innerHTML = ''; this.elements.editorBody.innerHTML = ''; this.elements.terminalBody.innerHTML = ''; - this.elements.terminalInputLine.textContent = ''; + this.terminalHistories.clear(); + this.terminalSessions.clear(); + this.terminalSessionNames.clear(); + this.terminalSessionTitleMap.clear(); + this.terminalContextSessionId = null; + this.activeTerminalSessionId = null; + this.terminalLastFocusedAt = 0; this.elements.readerLines.innerHTML = ''; this.elements.readerOcr.innerHTML = ''; this.elements.memoryList.innerHTML = ''; @@ -266,6 +322,7 @@ export class MonitorDirector implements MonitorDriver { if (Array.isArray(options?.desktopRoots) && options?.desktopRoots.length) { this.setDesktopRoots(options.desktopRoots, { immediate: true }); } + this.renderTerminalTabs(); } setDesktopRoots(roots: string[], options?: { immediate?: boolean }) { @@ -364,23 +421,19 @@ export class MonitorDirector implements MonitorDriver { } showThinkingBubble() { + this.stopBubbleTimers(); this.dismissBubble(true, { force: true }); const bubble = this.elements.speechBubble; - bubble.classList.remove('error', 'info'); - bubble.classList.add('thinking'); - this.elements.bubbleTextSlot.textContent = ''; + bubble.classList.remove('error', 'thinking', 'progress'); + bubble.classList.add('info'); + this.elements.bubbleIconSlot.classList.remove('show'); this.elements.bubbleIconSlot.innerHTML = ''; - const img = document.createElement('img'); - img.src = this.assets.brainIcon; - img.alt = '思考中'; - this.elements.bubbleIconSlot.appendChild(img); - this.elements.bubbleIconSlot.classList.add('show'); + this.startThinkingBubble('思考中'); this.positionBubble(); requestAnimationFrame(() => { bubble.classList.add('visible'); bubble.style.visibility = 'visible'; }); - this.stopBubbleTimers(); } hideBubble() { @@ -388,6 +441,8 @@ export class MonitorDirector implements MonitorDriver { } previewSceneProgress(name: string) { + // 先清理上一段进度气泡,避免多个计时器交替刷新导致闪烁 + this.stopProgressBubble(); const progressLabel = getSceneProgressLabel(name); progressDebug('previewSceneProgress', { scene: name, label: progressLabel }); if (!progressLabel) { @@ -413,6 +468,9 @@ export class MonitorDirector implements MonitorDriver { hasPreviewForScene, statusPhase: runtime?.statusPhase }); + if (name.startsWith('terminal')) { + terminalMenuDebug('playScene:enter', { scene: name, payload }); + } if (!hasPreviewForScene) { this.dismissBubble(true, { force: true }); } else if (isPlaybackPhase) { @@ -475,15 +533,38 @@ export class MonitorDirector implements MonitorDriver { clearTimeout(this.bubbleTimer); this.bubbleTimer = null; } + this.clearThinkingBubbleTimer(); + if (this.waitingBubbleTimer) { + clearTimeout(this.waitingBubbleTimer); + this.waitingBubbleTimer = null; + } } private dismissBubble(immediate = false, options?: { force?: boolean }) { - if (this.progressBubbleBase && !options?.force) { + // 当 progress 气泡处于活跃状态时,非强制模式下不打断;强制模式彻底清理 + if (this.progressBubbleActive && !options?.force) { progressDebug('dismissBubble:skip-active', { immediate }); return; } const bubble = this.elements.speechBubble; this.stopBubbleTimers(); + if (options?.force) { + if (this.progressBubbleTimer) { + window.clearTimeout(this.progressBubbleTimer); + this.progressBubbleTimer = null; + } + this.progressBubbleBase = null; + this.progressSceneName = null; + this.progressBubbleActive = false; + this.clearThinkingBubbleTimer(); + if (this.waitingBubbleTimer) { + clearTimeout(this.waitingBubbleTimer); + this.waitingBubbleTimer = null; + this.waitingBubbleBase = null; + } + bubble.classList.remove('progress'); + bubble.removeAttribute('data-progress'); + } if (!bubble.classList.contains('visible')) { if (immediate) { bubble.classList.remove('thinking', 'error', 'info'); @@ -499,7 +580,13 @@ export class MonitorDirector implements MonitorDriver { private startProgressBubble(text: string) { progressDebug('startProgressBubble', { text, scene: this.progressSceneName }); + // 确保旧的进度定时器被清除,避免多个气泡交错刷新 + if (this.progressBubbleTimer) { + window.clearTimeout(this.progressBubbleTimer); + this.progressBubbleTimer = null; + } this.progressBubbleBase = text; + this.progressBubbleActive = true; let phase = 0; const tick = () => { const dots = '.'.repeat(phase); @@ -516,6 +603,8 @@ export class MonitorDirector implements MonitorDriver { const bubble = this.elements.speechBubble; bubble.classList.remove('thinking', 'error'); bubble.classList.add('info'); + bubble.classList.add('progress'); + bubble.setAttribute('data-progress', '1'); this.elements.bubbleIconSlot.classList.remove('show'); this.elements.bubbleIconSlot.innerHTML = ''; this.elements.bubbleTextSlot.textContent = text; @@ -527,6 +616,45 @@ export class MonitorDirector implements MonitorDriver { } } + private startThinkingBubble(text: string) { + this.clearThinkingBubbleTimer(); + // 直接复用等待气泡的动画逻辑,保证与“等待回复”一致 + this.showWaitingBubble(text || '思考中'); + // 但保留独立的计时器引用,方便后续清理 + this.thinkingBubbleTimer = this.waitingBubbleTimer; + } + + private clearThinkingBubbleTimer() { + if (this.thinkingBubbleTimer) { + clearInterval(this.thinkingBubbleTimer); + this.thinkingBubbleTimer = null; + } + this.thinkingBubblePhase = 0; + } + + showWaitingBubble(text = '等待回复') { + this.dismissBubble(true, { force: true }); + const bubble = this.elements.speechBubble; + bubble.classList.remove('error', 'thinking', 'progress'); + bubble.classList.add('info'); + this.elements.bubbleIconSlot.classList.remove('show'); + this.elements.bubbleIconSlot.innerHTML = ''; + this.waitingBubbleBase = text; + let phase = 0; + const tick = () => { + const dots = '.'.repeat(phase); + this.elements.bubbleTextSlot.textContent = `${text}${dots}`; + this.positionBubble(); + phase = (phase + 1) % 4; + this.waitingBubbleTimer = window.setTimeout(tick, 520); + }; + tick(); + requestAnimationFrame(() => { + bubble.classList.add('visible'); + bubble.style.visibility = 'visible'; + }); + } + private stopProgressBubble(options?: { preserveBubble?: boolean }) { progressDebug('stopProgressBubble', { label: this.progressBubbleBase, @@ -537,14 +665,15 @@ export class MonitorDirector implements MonitorDriver { window.clearTimeout(this.progressBubbleTimer); this.progressBubbleTimer = null; } - if (this.progressBubbleBase) { - if (!options?.preserveBubble) { - this.hideBubble(); - } - } - if (!options?.preserveBubble) { - this.progressBubbleBase = null; - this.progressSceneName = null; + const bubble = this.elements.speechBubble; + const shouldHideBubble = this.progressBubbleActive && !options?.preserveBubble && bubble.classList.contains('progress'); + this.progressBubbleBase = null; + this.progressSceneName = null; + this.progressBubbleActive = false; + bubble.classList.remove('progress'); + bubble.removeAttribute('data-progress'); + if (shouldHideBubble) { + this.dismissBubble(true, { force: true }); } } @@ -1017,6 +1146,21 @@ export class MonitorDirector implements MonitorDriver { this.extractionWindows.clear(); } + private clearTerminalSessions() { + this.terminalSessions.clear(); + this.terminalSessionTitleMap.clear(); + this.terminalSessionNames.clear(); + this.terminalHistories.clear(); + this.activeTerminalSessionId = null; + this.lastTerminalSessionId = null; + this.terminalContextSessionId = null; + this.terminalLastFocusedAt = 0; + if (this.elements.terminalBody) { + this.elements.terminalBody.innerHTML = ''; + } + this.renderTerminalTabs(); + } + private purgeExtractionWindowByElement(el: HTMLElement) { for (const [key, instance] of this.extractionWindows.entries()) { if (instance.element === el) { @@ -1031,6 +1175,463 @@ export class MonitorDirector implements MonitorDriver { } } + private generateNewSessionId() { + return `session-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + } + + private isTerminalWindowOnTop(instance: TerminalShell | null) { + if (!instance?.element) { + return false; + } + const top = this.windowOrder[this.windowOrder.length - 1] || null; + return top === instance.element; + } + + private async revealTerminalWindow(instance: TerminalShell, title: string) { + await this.movePointerToApp('terminal'); + await this.click(); + this.showWindow(instance.element); + if (instance.titleEl) { + instance.titleEl.textContent = '终端'; + } + this.terminalLastFocusedAt = Date.now(); + } + + private async focusTerminalHeader(instance: TerminalShell, options: { force?: boolean } = {}) { + if (!instance?.element) { + return; + } + const header = instance.element.querySelector('.window-header') as HTMLElement | null; + const needFocus = options.force || !this.isTerminalWindowOnTop(instance); + if (header && needFocus) { + await this.movePointerToElement(header, { duration: 360 }); + await this.click(); + this.terminalLastFocusedAt = Date.now(); + return; + } + // 窗口本就在最前,保持层级即可 + this.raiseWindowForTarget(instance.element); + } + + private async focusTerminalPrompt(sessionId: string, instance: TerminalShell | null) { + this.ensurePromptLine(sessionId); + const promptEl = (instance?.bodyEl?.lastElementChild as HTMLElement | null) || null; + if (promptEl) { + await this.movePointerToElement(promptEl, { duration: 520, offsetX: -6 }); + await this.click(); + this.terminalLastFocusedAt = Date.now(); + } + return promptEl; + } + + private getTerminalHistory(sessionId: string): TerminalLine[] { + if (!this.terminalHistories.has(sessionId)) { + this.terminalHistories.set(sessionId, []); + } + return this.terminalHistories.get(sessionId)!; + } + + private renderTerminalHistory(sessionId: string) { + const history = this.getTerminalHistory(sessionId); + const { bodyEl } = this.getTerminalInstance(); + bodyEl.innerHTML = ''; + history.forEach(line => { + const pre = document.createElement('pre'); + pre.textContent = line.text; + if (line.role === 'prompt') { + pre.className = 'session-terminal-prompt-line'; + } else if (line.role === 'note') { + pre.className = 'session-terminal-note-line'; + } + bodyEl.appendChild(pre); + }); + bodyEl.scrollTop = bodyEl.scrollHeight; + } + + private ensurePromptLine(sessionId: string) { + const history = this.getTerminalHistory(sessionId); + if (!history.length || history[history.length - 1].role !== 'prompt') { + history.push({ text: '➜ ', role: 'prompt' }); + } + this.renderTerminalHistory(sessionId); + } + + private updatePromptText(sessionId: string, text: string) { + const history = this.getTerminalHistory(sessionId); + if (!history.length || history[history.length - 1].role !== 'prompt') { + history.push({ text: text, role: 'prompt' }); + } else { + history[history.length - 1].text = text; + } + this.renderTerminalHistory(sessionId); + } + + private appendTerminalOutputs(sessionId: string, command: string, outputs: string[]) { + const history = this.getTerminalHistory(sessionId); + // 确保上一条提示行记录了命令 + if (!history.length || history[history.length - 1].role !== 'prompt') { + history.push({ text: `➜ ${command}`.trimEnd(), role: 'prompt' }); + } else { + history[history.length - 1].text = `➜ ${command}`.trimEnd(); + } + outputs.forEach(line => history.push({ text: line, role: 'output' })); + history.push({ text: '➜ ', role: 'prompt' }); + this.renderTerminalHistory(sessionId); + } + + private appendTerminalNote(sessionId: string, text: string) { + const history = this.getTerminalHistory(sessionId); + history.push({ text, role: 'note' }); + this.renderTerminalHistory(sessionId); + } + + private closeTerminalSession(sessionId: string) { + this.terminalSessions.delete(sessionId); + for (const [title, mappedId] of this.terminalSessionTitleMap.entries()) { + if (mappedId === sessionId) { + this.terminalSessionTitleMap.delete(title); + } + } + this.terminalSessionNames.delete(sessionId); + this.terminalHistories.delete(sessionId); + if (this.activeTerminalSessionId === sessionId) { + this.activeTerminalSessionId = null; + } + if (this.lastTerminalSessionId === sessionId) { + this.lastTerminalSessionId = null; + } + if (this.terminalContextSessionId === sessionId) { + this.terminalContextSessionId = null; + } + const next = this.terminalSessions.size ? Array.from(this.terminalSessions.keys())[0] : null; + if (next) { + this.activateSession(next); + } else { + this.renderTerminalTabs(); + const { bodyEl } = this.getTerminalInstance(); + bodyEl.innerHTML = ''; + const hint = document.createElement('pre'); + hint.textContent = '尚未创建终端,点击 + 新建'; + hint.className = 'session-terminal-note-line'; + bodyEl.appendChild(hint); + } + } + + private async typeSessionCommand(sessionId: string, command: string) { + this.ensurePromptLine(sessionId); + const history = this.getTerminalHistory(sessionId); + const prompt = history[history.length - 1]; + prompt.text = '➜ '; + this.renderTerminalHistory(sessionId); + const { bodyEl } = this.getTerminalInstance(); + const promptEl = bodyEl.lastElementChild as HTMLElement | null; + let charIndex = 0; + const chars = command.split(''); + for (const ch of chars) { + charIndex += 1; + prompt.text += ch; + if (promptEl) { + promptEl.textContent = prompt.text; + } + if (promptEl && (charIndex % 6 === 0 || charIndex === chars.length)) { + this.scrollPromptIntoView(this.getTerminalInstance()); + } + await sleep(46); + } + } + + private getTerminalInstance() { + return { + element: this.elements.terminalWindow, + bodyEl: this.elements.terminalBody, + titleEl: this.elements.terminalHeaderText + }; + } + + private nextTerminalName() { + const existing = Array.from(this.terminalSessions.values()).map(s => s.name); + let idx = existing.length + 1; + let candidate = `${TERMINAL_NAME_PREFIX}${idx}`; + while (existing.includes(candidate)) { + idx += 1; + candidate = `${TERMINAL_NAME_PREFIX}${idx}`; + } + return candidate; + } + + private ensureSessionRecord(sessionId: string, name?: string, rawName?: string) { + const baseName = (name || rawName || this.terminalSessionNames.get(sessionId) || '').trim() || this.nextTerminalName(); + this.terminalSessions.set(sessionId, { id: sessionId, name: baseName, rawName: rawName || name }); + this.terminalSessionNames.set(sessionId, baseName); + this.terminalSessionTitleMap.set(baseName, sessionId); + if (rawName) { + this.terminalRawNameMap.set(rawName, sessionId); + } + if (!this.terminalHistories.has(sessionId)) { + this.terminalHistories.set(sessionId, [{ text: '➜ ', role: 'prompt' }]); + } + this.renderTerminalTabs(); + return this.terminalSessions.get(sessionId)!; + } + + private createSession(name?: string, rawName?: string) { + const sessionId = this.generateNewSessionId(); + this.ensureSessionRecord(sessionId, (name || '').trim(), rawName); + return sessionId; + } + + private getTabElement(sessionId: string) { + return this.elements.terminalTabList.querySelector(`[data-session-id="${sessionId}"]`); + } + + private renderTerminalTabs() { + if (!this.elements.terminalTabList || !this.elements.terminalAddButton) { + return; + } + this.elements.terminalTabList.innerHTML = ''; + const tabs: Array<{ id: string; name: string }> = Array.from(this.terminalSessions.values()).map(s => ({ + id: s.id, + name: s.name + })); + if (!tabs.length) { + this.elements.terminalAddButton.classList.add('lonely'); + this.elements.terminalTabs.appendChild(this.elements.terminalAddButton); + return; + } + this.elements.terminalAddButton.classList.remove('lonely'); + tabs.forEach(session => { + const btn = document.createElement('button'); + btn.className = 'terminal-tab'; + btn.dataset.sessionId = session.id; + btn.textContent = session.name; + if (this.activeTerminalSessionId && session.id === this.activeTerminalSessionId) { + btn.classList.add('active'); + } + this.elements.terminalTabList.appendChild(btn); + }); + // 确保 + 按钮始终在最右 + this.elements.terminalTabs.appendChild(this.elements.terminalAddButton); + } + + private activateSession(sessionId: string) { + const record = this.terminalSessions.get(sessionId) || this.ensureSessionRecord(sessionId); + const { element, bodyEl, titleEl } = this.getTerminalInstance(); + if (titleEl) { + titleEl.textContent = '终端'; + } + this.activeTerminalSessionId = sessionId; + this.lastTerminalSessionId = sessionId; + this.renderTerminalTabs(); + this.renderTerminalHistory(sessionId); + this.showWindow(element); + bodyEl.scrollTop = bodyEl.scrollHeight; + this.terminalLastFocusedAt = Date.now(); + return record; + } + + private async ensureTerminalSessionReady( + payload: any, + options: { focusPrompt?: boolean; forceNew?: boolean; createIfMissing?: boolean; activate?: boolean } = {} + ) { + const { focusPrompt = false, createIfMissing = true, forceNew = false, activate = true } = options; + const requestedNew = + payload?.arguments?.create_new === true || + payload?.arguments?.new_session === true || + payload?.arguments?.force_new === true || + payload?.arguments?.new === true; + const nameHint = + payload?.arguments?.session_name || + payload?.arguments?.session || + payload?.arguments?.name || + payload?.arguments?.title || + payload?.result?.session || + payload?.result?.name || + payload?.title || + null; + const meta = this.resolveSessionMeta(payload, { + persist: true, + createIfMissing, + forceNewIfAbsent: forceNew || requestedNew, + preferExisting: !(forceNew || requestedNew || !!nameHint) + }); + let sessionId = meta.sessionId; + const needsNew = forceNew || requestedNew || !sessionId || !this.terminalSessions.has(sessionId); + + const shell = this.getTerminalInstance(); + const wasVisible = this.isWindowVisible(shell.element); + if (!wasVisible) { + await this.revealTerminalWindow(shell, '终端'); + } else { + this.showWindow(shell.element); + } + + let created = false; + if (needsNew && this.elements.terminalAddButton) { + await this.movePointerToElement(this.elements.terminalAddButton, { duration: 520 }); + await this.click(); + sessionId = this.createSession(meta.title, meta.rawName || undefined); + created = true; + } else if (sessionId) { + // 已存在会话,仅保证名称映射,不改名 + this.ensureSessionRecord(sessionId, meta.title, meta.rawName || undefined); + } + + if (!sessionId && this.terminalSessions.size) { + sessionId = Array.from(this.terminalSessions.keys())[0]; + } + if (!sessionId) { + sessionId = this.createSession(meta.title, meta.rawName || undefined); + created = true; + } + + // 仅在需要激活时才点击标签/切换显示;否则保持当前显示不变 + if (activate && (created || this.activeTerminalSessionId !== sessionId)) { + const tabEl = this.getTabElement(sessionId); + if (tabEl) { + await this.movePointerToElement(tabEl, { duration: 420 }); + await this.click(); + } + } + + let promptEl: HTMLElement | null = null; + if (activate) { + this.activateSession(sessionId); + promptEl = this.elements.terminalBody.lastElementChild as HTMLElement | null; + if (focusPrompt && promptEl) { + await this.movePointerToElement(promptEl, { duration: 520, offsetX: -6 }); + await this.click(); + } + } else { + this.renderTerminalTabs(); + } + return { sessionId, title: meta.title, instance: this.getTerminalInstance(), prompt: promptEl, created, reopened: !wasVisible }; + } + + + private scrollPromptIntoView(instance: TerminalShell) { + if (!instance.bodyEl) { + return; + } + instance.bodyEl.scrollTop = instance.bodyEl.scrollHeight; + } + + private sanitizeTerminalOutput(lines: string[]): string[] { + const promptLike = /^root@[\w.-]+:.*[#\$]\s*/; + return lines + .map(line => (line === null || line === undefined ? '' : String(line))) + .filter(line => !promptLike.test(line.trim())) + .map(line => line.replace(/\r$/, '')); + } + + private resolveSessionMeta( + payload: any, + { + persist = false, + createIfMissing = false, + forceNewIfAbsent = false, + preferExisting = true + }: { persist?: boolean; createIfMissing?: boolean; forceNewIfAbsent?: boolean; preferExisting?: boolean } = {} + ) { + const nameCandidates = [ + payload?.arguments?.session_name, + payload?.arguments?.session, + payload?.arguments?.name, + payload?.arguments?.title, + payload?.result?.session, + payload?.result?.session_name, + payload?.result?.name, + payload?.result?.title, + payload?.name, + payload?.title + ].filter(Boolean) as string[]; + + const rawName = nameCandidates.find(Boolean) || null; + const explicitId = + payload?.arguments?.session_id || + payload?.arguments?.connection_id || + payload?.result?.session_id || + payload?.session_id || + null; + + // 以 session_id 优先,其次用原始名字作为唯一键,保证同名复用同一会话 + let sessionId: string | null = explicitId; + const nameKey = (rawName || '').trim() || null; + if (!sessionId && nameKey && this.terminalRawNameMap.has(nameKey)) { + sessionId = this.terminalRawNameMap.get(nameKey)!; + } + if (!sessionId && nameKey && this.terminalSessionTitleMap.has(nameKey)) { + sessionId = this.terminalSessionTitleMap.get(nameKey)!; + } + if (preferExisting) { + if (!sessionId && this.activeTerminalSessionId) { + sessionId = this.activeTerminalSessionId; + } + if (!sessionId && this.lastTerminalSessionId) { + sessionId = this.lastTerminalSessionId; + } + if (!sessionId && this.terminalSessions.size) { + sessionId = Array.from(this.terminalSessions.keys())[0]; + } + } + + let title: string; + if (sessionId && this.terminalSessionNames.has(sessionId)) { + title = this.terminalSessionNames.get(sessionId)!; + } else { + title = (rawName || '').trim() || this.nextTerminalName(); + } + + if (!sessionId && (createIfMissing || forceNewIfAbsent)) { + sessionId = this.generateNewSessionId(); + } + + if (persist && sessionId) { + this.lastTerminalSessionId = sessionId; + } + if (nameKey && sessionId) { + this.terminalSessionTitleMap.set(nameKey, sessionId); + this.terminalRawNameMap.set(nameKey, sessionId); + } + return { sessionId, title, rawName }; + } + + /** + * 仅查找现有终端会话,不会新建 + */ + private resolveExistingSessionId(payload: any): string | null { + const nameCandidates = [ + payload?.arguments?.session_name, + payload?.arguments?.session, + payload?.arguments?.name, + payload?.arguments?.title, + payload?.result?.session, + payload?.result?.session_name, + payload?.result?.name, + payload?.result?.title, + payload?.name, + payload?.title + ].filter(Boolean) as string[]; + const rawName = (nameCandidates.find(Boolean) || '').trim(); + if (rawName) { + if (this.terminalRawNameMap.has(rawName)) { + return this.terminalRawNameMap.get(rawName)!; + } + if (this.terminalSessionTitleMap.has(rawName)) { + return this.terminalSessionTitleMap.get(rawName)!; + } + } + if (this.activeTerminalSessionId) { + return this.activeTerminalSessionId; + } + if (this.lastTerminalSessionId) { + return this.lastTerminalSessionId; + } + if (this.terminalSessions.size) { + return Array.from(this.terminalSessions.keys())[0]; + } + return null; + } + private nextExtractionId() { return `extract-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; } @@ -1061,6 +1662,7 @@ export class MonitorDirector implements MonitorDriver { this.elements.todoWindow ].forEach(win => this.closeWindow(win, { animate: false })); this.clearExtractionWindows(); + this.clearTerminalSessions(); this.windowOrder = []; this.manualPositions.clear(); } @@ -1115,8 +1717,11 @@ export class MonitorDirector implements MonitorDriver { private setupScenes() { this.sceneHandlers.browserSearch = async (payload, runtime) => { this.applySceneStatus(runtime, 'browserSearch', '正在搜索'); - await this.movePointerToApp('browser'); - await this.click({ count: 2 }); + const browserVisible = this.isWindowVisible(this.elements.browserWindow); + if (!browserVisible) { + await this.movePointerToApp('browser'); + await this.click({ count: 2 }); + } this.showWindow(this.elements.browserWindow); await sleep(400); const searchBar = this.elements.browserSearchText.parentElement; @@ -1131,13 +1736,8 @@ export class MonitorDirector implements MonitorDriver { const results = Array.isArray(completion?.result?.results) ? completion.result.results : []; this.renderSearchResults(results); this.elements.browserStatus.textContent = completion?.status === 'completed' ? '搜索完成,已加载结果' : '搜索未完成'; - await sleep(400); - if (Array.isArray(results) && results[0]) { - const first = this.elements.browserResults.querySelector('li'); - if (first) { - await this.movePointerToElement(first, { duration: 700 }); - } - } + await sleep(320); + await this.simulateResultBrowsing(); this.pushWindowToStack(this.elements.browserWindow); }; @@ -1158,8 +1758,11 @@ export class MonitorDirector implements MonitorDriver { } } if (!usedExistingSearch) { - await this.movePointerToApp('browser'); - await this.click({ count: 2 }); + const browserVisible = this.isWindowVisible(this.elements.browserWindow); + if (!browserVisible) { + await this.movePointerToApp('browser'); + await this.click({ count: 2 }); + } this.showWindow(this.elements.browserWindow); const searchBar = this.elements.browserSearchText.parentElement; if (searchBar) { @@ -1494,33 +2097,37 @@ export class MonitorDirector implements MonitorDriver { this.sceneHandlers.runCommand = async (payload, runtime) => { this.applySceneStatus(runtime, 'runCommand', '正在执行命令'); - await this.movePointerToApp('command'); - await this.click({ count: 2 }); - this.showTerminalWindow('command', '命令行'); + const { sessionId } = await this.ensureTerminalSessionReady(payload, { focusPrompt: true, createIfMissing: true }); const command = payload?.arguments?.command || 'echo "Hello"'; - await this.typeTerminalInput(command); + await this.typeSessionCommand(sessionId, command); const completion = await runtime.waitForResult(payload.executionId || payload.id); const output = completion?.result?.output || completion?.result?.stdout || '命令执行完成'; - this.renderTerminalOutput(command, output); + const lines = this.sanitizeTerminalOutput( + typeof output === 'string' + ? output.split('\n') + : Array.isArray(output) + ? output.map(String) + : [String(output || '')] + ); + this.appendTerminalOutputs(sessionId, command, lines.length ? lines : ['命令执行完成']); await sleep(500); }; this.sceneHandlers.runPython = async (payload, runtime) => { this.applySceneStatus(runtime, 'runPython', '正在执行 Python'); - await this.movePointerToApp('command'); - await this.click({ count: 2 }); - this.showTerminalWindow('python', 'Python REPL'); + const { sessionId } = await this.ensureTerminalSessionReady(payload, { focusPrompt: true, createIfMissing: true }); const code = payload?.arguments?.code || 'print("Hello")'; - const snippets = this.normalizeLines(code).slice(0, 6); - this.elements.terminalBody.innerHTML = ''; - snippets.forEach(line => { - const pre = document.createElement('pre'); - pre.textContent = line; - this.elements.terminalBody.appendChild(pre); - }); + await this.typeSessionCommand(sessionId, code); const completion = await runtime.waitForResult(payload.executionId || payload.id); const output = completion?.result?.output || completion?.result?.stdout || '>>> 执行完成'; - this.renderTerminalOutput('python', output, { skipPrompt: true }); + const lines = this.sanitizeTerminalOutput( + typeof output === 'string' + ? output.split('\n') + : Array.isArray(output) + ? output.map(String) + : [String(output || '')] + ); + this.appendTerminalOutputs(sessionId, code, lines.length ? lines : ['>>> 执行完成']); await sleep(500); }; @@ -1708,53 +2315,171 @@ export class MonitorDirector implements MonitorDriver { await sleep(300); }; - this.sceneHandlers.terminalSession = async (_payload, runtime) => { - this.applySceneStatus(runtime, 'terminalSession', '正在连接终端'); - await this.movePointerToApp('terminal'); - await this.click({ count: 2 }); - this.showTerminalWindow('session', '实时终端'); - this.appendTerminalLine('连接远程终端...'); - await sleep(600); - this.appendTerminalLine('会话已建立'); + this.sceneHandlers.terminalSession = async (payload, runtime) => { + this.applySceneStatus(runtime, 'terminalSession', '正在查看终端'); + const action = (payload?.arguments?.action || payload?.action || '').toLowerCase(); + // 特殊处理:如果是关闭/切换终端,不要无意中新建会话 + if (action === 'close' || action === 'switch') { + terminalMenuDebug('terminalSession:action', { action, payload }); + const targetSession = this.resolveExistingSessionId(payload); + if (!targetSession) { + terminalMenuDebug('terminalSession:action:no-session', { action }); + return; + } + // 确保窗口可见,但不强制激活 + const shell = this.getTerminalInstance(); + if (!this.isWindowVisible(shell.element)) { + await this.revealTerminalWindow(shell, '终端'); + } 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); + } + this.ensurePromptLine(targetSession); + await sleep(200); + return; + } + // action === 'close' + await this.openTerminalContextMenu(targetSession); + await this.chooseTerminalMenuAction('close'); + this.closeTerminalSession(targetSession); + if (!this.terminalSessions.size) { + this.closeWindow(this.elements.terminalWindow, { animate: true }); + } + await sleep(320); + return; + } + + const explicitSessionProvided = + payload?.arguments?.session_id || + payload?.arguments?.connection_id || + payload?.arguments?.session || + payload?.arguments?.session_name; + const nameHint = + payload?.arguments?.session_name || + payload?.arguments?.session || + payload?.arguments?.name || + payload?.arguments?.title || + payload?.result?.session || + payload?.result?.name || + payload?.title || + null; + const mappedExisting = + nameHint && (this.terminalRawNameMap.has(nameHint) || this.terminalSessionTitleMap.has(nameHint)); + const shouldForceNew = + payload?.arguments?.create_new === true || + payload?.arguments?.new_session === true || + payload?.arguments?.force_new === true || + (!explicitSessionProvided && this.terminalSessions.size > 0 && !mappedExisting); + await this.ensureTerminalSessionReady(payload, { + focusPrompt: false, + forceNew: shouldForceNew, + createIfMissing: true, + activate: true + }); + await sleep(200); }; this.sceneHandlers.terminalInput = async (payload, runtime) => { - await this.sceneHandlers.terminalSession(payload, runtime); this.applySceneStatus(runtime, 'terminalInput', '正在发送命令'); - const command = payload?.arguments?.input || 'ls'; - this.appendTerminalLine(`$ ${command}`); + const { sessionId } = await this.ensureTerminalSessionReady(payload, { focusPrompt: true, activate: true }); + const command = + payload?.arguments?.command || + payload?.arguments?.input || + payload?.result?.command || + payload?.result?.input || + ''; + await this.typeSessionCommand(sessionId, command); + const completion = await runtime + .waitForResult(payload.executionId || payload.id) + .catch(error => { + console.warn('[MonitorDirector] terminalInput waitForResult error', error); + return null; + }); + const output = + completion?.result?.output || + completion?.result?.stdout || + completion?.result?.content || + completion?.result || + ''; + const lines = this.sanitizeTerminalOutput( + typeof output === 'string' + ? output.split('\n') + : Array.isArray(output) + ? output.map(String) + : [String(output || '')] + ); + this.appendTerminalOutputs(sessionId, command, lines.length ? lines : ['命令已发送']); await sleep(400); }; - this.sceneHandlers.terminalSnapshot = async (_payload, runtime) => { + this.sceneHandlers.terminalSnapshot = async (payload, runtime) => { this.applySceneStatus(runtime, 'terminalSnapshot', '正在获取终端'); - this.appendTerminalLine('[Snapshot Captured]'); + const { sessionId } = await this.ensureTerminalSessionReady(payload, { focusPrompt: false, activate: false }); + terminalMenuDebug('scene:terminalSnapshot:start', { sessionId }); + await this.openTerminalContextMenu(sessionId); + terminalMenuDebug('scene:terminalSnapshot:menu-opened', { sessionId }); + await this.chooseTerminalMenuAction('snapshot'); + this.appendTerminalNote(sessionId, '[Snapshot Captured]'); + this.ensurePromptLine(sessionId); await sleep(300); }; - this.sceneHandlers.terminalReset = async (_payload, runtime) => { + this.sceneHandlers.terminalReset = async (payload, runtime) => { this.applySceneStatus(runtime, 'terminalReset', '正在重置终端'); - this.elements.terminalBody.innerHTML = ''; + 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', '正在暂停终端'); - await this.movePointerToElement(this.elements.terminalWindow, { offsetX: 160, offsetY: -120 }); - await this.click({ right: true }); - this.showContextMenu('terminal'); - await sleep(200); - await this.highlightMenu('terminal', 'sleep'); - await this.click(); - this.hideContextMenus(); - await sleep(400); + this.sceneHandlers.terminalSleep = async (payload, runtime) => { + this.applySceneStatus(runtime, 'terminalSleep', '正在关闭终端'); + const { sessionId } = await this.ensureTerminalSessionReady(payload, { focusPrompt: false, activate: false }); + terminalMenuDebug('scene:terminalSleep:start', { sessionId }); + await this.openTerminalContextMenu(sessionId); + terminalMenuDebug('scene:terminalSleep:menu-opened', { sessionId }); + await this.chooseTerminalMenuAction('close'); + this.closeTerminalSession(sessionId); + // 如果已无会话,收起终端窗口以呈现“窗口被关闭”效果 + if (!this.terminalSessions.size) { + this.closeWindow(this.elements.terminalWindow, { animate: true }); + } + await sleep(320); }; this.sceneHandlers.webSave = async (_payload, runtime) => { this.applySceneStatus(runtime, 'webSave', '正在保存网页'); - await this.movePointerToElement(this.elements.browserWindow, { offsetX: 40, offsetY: -120 }); - await this.click({ right: true }); - this.showContextMenu('browser'); + const targetUrl = + _payload?.arguments?.url || _payload?.arguments?.target_url || _payload?.result?.url || _payload?.result?.source || ''; + let targetEl: HTMLLIElement | null = null; + if (targetUrl) { + targetEl = await this.focusSearchResultByUrl(targetUrl); + } + if (!targetEl) { + targetEl = this.elements.browserResults.querySelector('li') as HTMLLIElement | null; + } + if (targetEl) { + this.showWindow(this.elements.browserWindow); + await this.movePointerToElement(targetEl, { duration: 620 }); + await this.click({ right: true }); + this.showContextMenu('browser'); + } else { + await this.movePointerToElement(this.elements.browserWindow, { offsetX: 40, offsetY: -120 }); + await this.click({ right: true }); + this.showContextMenu('browser'); + } await sleep(200); await this.highlightMenu('browser', 'save'); await this.click(); @@ -1770,6 +2495,104 @@ export class MonitorDirector implements MonitorDriver { }; } + /** + * 终端标签区的手动交互:左键切换、点击 + 创建、右键打开菜单 + */ + private bindTerminalInteractions() { + const addBtn = this.elements.terminalAddButton; + const tabList = this.elements.terminalTabList; + const menu = this.elements.terminalMenu; + if (addBtn) { + addBtn.addEventListener('click', this.handleTerminalAddClick); + } + if (tabList) { + tabList.addEventListener('click', this.handleTerminalTabClick); + tabList.addEventListener('contextmenu', this.handleTerminalTabContext); + } + if (menu) { + menu.addEventListener('click', this.handleTerminalMenuClick); + } + this.destroyFns.push(() => { + addBtn?.removeEventListener('click', this.handleTerminalAddClick); + tabList?.removeEventListener('click', this.handleTerminalTabClick); + tabList?.removeEventListener('contextmenu', this.handleTerminalTabContext); + menu?.removeEventListener('click', this.handleTerminalMenuClick); + }); + } + + private handleTerminalAddClick = (event: MouseEvent) => { + if (!this.manualInteractionEnabled) { + return; + } + event.preventDefault(); + const sessionId = this.createSession(); + this.activateSession(sessionId); + }; + + private handleTerminalTabClick = (event: MouseEvent) => { + if (!this.manualInteractionEnabled) { + return; + } + const target = (event.target as HTMLElement | null)?.closest('.terminal-tab'); + if (!target || target.classList.contains('add-tab')) { + return; + } + const sessionId = target.getAttribute('data-session-id'); + if (sessionId) { + event.preventDefault(); + this.activateSession(sessionId); + } + }; + + private handleTerminalTabContext = (event: MouseEvent) => { + if (!this.manualInteractionEnabled) { + return; + } + const target = (event.target as HTMLElement | null)?.closest('.terminal-tab'); + if (!target || target.classList.contains('add-tab')) { + return; + } + const sessionId = target.getAttribute('data-session-id'); + if (!sessionId) { + return; + } + event.preventDefault(); + this.terminalContextSessionId = sessionId; + const screenRect = this.elements.screen.getBoundingClientRect(); + const x = event.clientX - screenRect.left; + const y = event.clientY - screenRect.top; + this.showContextMenu('terminal', { x, y }); + }; + + private handleTerminalMenuClick = (event: MouseEvent) => { + if (!this.manualInteractionEnabled) { + return; + } + const target = event.target as HTMLElement | null; + if (!target || target.tagName.toLowerCase() !== 'button') { + return; + } + const action = target.getAttribute('data-action'); + const sessionId = this.terminalContextSessionId || this.activeTerminalSessionId; + if (!action || !sessionId) { + this.hideContextMenus(); + return; + } + event.preventDefault(); + if (action === 'snapshot') { + this.appendTerminalNote(sessionId, '[Snapshot Captured]'); + this.ensurePromptLine(sessionId); + } else if (action === 'reset') { + this.terminalHistories.set(sessionId, [{ text: '➜ ', role: 'prompt' }]); + this.renderTerminalHistory(sessionId); + this.appendTerminalNote(sessionId, '终端已重置'); + this.ensurePromptLine(sessionId); + } else if (action === 'close') { + this.closeTerminalSession(sessionId); + } + this.hideContextMenus(); + }; + private bindManualInteractionListeners() { if (this.manualListenersAttached) { return; @@ -1936,6 +2759,10 @@ export class MonitorDirector implements MonitorDriver { this.cancelManualDrag(); this.closeWindow(windowEl, { animate: true }); this.windowOrder = this.windowOrder.filter(win => win && win !== windowEl && win.classList.contains('visible')); + if (windowEl === this.elements.terminalWindow) { + this.activeTerminalSessionId = null; + return; + } if (windowEl.dataset.extractId) { this.purgeExtractionWindowByElement(windowEl); } @@ -1976,6 +2803,43 @@ export class MonitorDirector implements MonitorDriver { await sleep(duration + 60); } + private isWindowVisible(win: HTMLElement | null) { + return !!win && win.classList.contains('visible'); + } + + private async simulateResultBrowsing(cycles = 2) { + const list = this.elements.browserResults; + if (!list) { + return; + } + const maxScroll = list.scrollHeight - list.clientHeight; + if (maxScroll <= 0) { + return; + } + const offset = Math.max(12, list.clientHeight / 2 - 14); + const duration = 520; + const rect = list.getBoundingClientRect(); + const centerX = rect.left - this.screenRect.left + rect.width / 2 - POINTER_TIP_OFFSET.x; + const startY = rect.top - this.screenRect.top + rect.height / 2 + offset - POINTER_TIP_OFFSET.y; + const endY = rect.top - this.screenRect.top + rect.height / 2 - offset - POINTER_TIP_OFFSET.y; + for (let i = 0; i < cycles; i += 1) { + list.scrollTop = maxScroll; + this.updatePointerTransform(centerX, startY, 260); + await sleep(280); + const steps = 8; + for (let s = 1; s <= steps; s += 1) { + const t = s / steps; + const y = startY + (endY - startY) * t; + list.scrollTop = maxScroll * (1 - t); + this.updatePointerTransform(centerX, y, 0); + await sleep(duration / steps); + } + await sleep(140); + } + this.updatePointerTransform(centerX, endY, 220); + await sleep(240); + } + private async click(options: { count?: number; interval?: number; right?: boolean } = {}) { const { count = 1, interval = 130, right = false } = options; if (!this.progressBubbleBase) { @@ -2109,7 +2973,7 @@ export class MonitorDirector implements MonitorDriver { return null; } - private showContextMenu(type: ContextMenuType) { + private showContextMenu(type: ContextMenuType, coords?: { x: number; y: number }) { const map: Record = { desktop: this.elements.desktopMenu, folder: this.elements.folderMenu, @@ -2124,8 +2988,8 @@ export class MonitorDirector implements MonitorDriver { } menu.classList.add('visible'); const tip = this.getPointerTip(); - const desiredX = tip.x + 16; - const desiredY = tip.y + 16; + const desiredX = typeof coords?.x === 'number' ? coords.x : tip.x + 16; + const desiredY = typeof coords?.y === 'number' ? coords.y : tip.y + 16; const { x, y } = this.clampToScreen(desiredX, desiredY, menu.offsetWidth, menu.offsetHeight); menu.style.left = `${x}px`; menu.style.top = `${y}px`; @@ -2143,6 +3007,7 @@ export class MonitorDirector implements MonitorDriver { menu.classList.remove('visible'); menu.querySelectorAll('button').forEach(btn => btn.classList.remove('active')); }); + this.terminalContextSessionId = null; } private async highlightMenu(type: ContextMenuType, action: string): Promise { @@ -2166,6 +3031,96 @@ export class MonitorDirector implements MonitorDriver { return true; } + /** + * 打开终端上下文菜单,确保出现右键动作和弹出二级菜单 + */ + private async openTerminalContextMenu(sessionId: string) { + const tabEl = this.getTabElement(sessionId); + let coords: { x: number; y: number } | undefined; + if (tabEl) { + terminalMenuDebug('openContextMenu:move-to-tab', { sessionId, tabText: tabEl.textContent }); + await this.movePointerToElement(tabEl, { duration: 420 }); + await this.click({ right: true }); + const rect = tabEl.getBoundingClientRect(); + coords = { + x: rect.left - this.screenRect.left + rect.width / 2, + y: rect.top - this.screenRect.top + rect.height / 2 + }; + terminalMenuDebug('openContextMenu:tab-rect', { sessionId, rect, coords }); + } else { + terminalMenuDebug('openContextMenu:no-tab', { sessionId }); + await this.movePointerToElement(this.elements.terminalWindow, { offsetX: 18, offsetY: 0, duration: 420 }); + await this.click({ right: true }); + } + this.terminalContextSessionId = sessionId; + this.showContextMenu('terminal', coords); + const visible = await this.waitForMenuVisible(this.elements.terminalMenu); + terminalMenuDebug('openContextMenu:visible', { sessionId, visible }); + // 右键后立即把指针轻推到菜单中心,减少后续对准偏差 + if (visible && this.elements.terminalMenu) { + this.snapPointerToElement(this.elements.terminalMenu); + } + } + + /** + * 选中终端菜单项,若高亮失败则回退到菜单中心点击,保证动画流程完整 + */ + private async chooseTerminalMenuAction(action: TerminalMenuAction) { + terminalMenuDebug('chooseMenuAction:start', { action }); + const menu = this.elements.terminalMenu; + const btn = menu?.querySelector(`button[data-action="${action}"]`) || null; + if (btn) { + await this.waitForMenuVisible(menu); + await this.movePointerToElement(btn, { duration: 320 }); + this.snapPointerToElement(btn); + btn.classList.add('active'); + await sleep(200); + btn.classList.remove('active'); + terminalMenuDebug('chooseMenuAction:target', { + action, + rect: btn.getBoundingClientRect().toJSON ? btn.getBoundingClientRect().toJSON() : btn.getBoundingClientRect() + }); + } else { + const highlighted = await this.highlightMenu('terminal', action); + terminalMenuDebug('chooseMenuAction:highlight', { action, highlighted }); + if (!highlighted && menu) { + await this.movePointerToElement(menu, { duration: 260 }); + this.snapPointerToElement(menu); + terminalMenuDebug('chooseMenuAction:fallback-move', { action }); + } + } + await this.click(); + await sleep(160); + this.hideContextMenus(); + terminalMenuDebug('chooseMenuAction:done', { action }); + } + + private async waitForMenuVisible(menu: HTMLElement | null, timeout = 240) { + if (!menu) { + return false; + } + const start = performance.now(); + while (!menu.classList.contains('visible') && performance.now() - start < timeout) { + await sleep(16); + } + return menu.classList.contains('visible'); + } + + /** + * 将指针瞬时对准目标元素的中心,防止动画收尾时产生偏移 + */ + private snapPointerToElement(target: Element | null, offsetX = 0, offsetY = 0) { + if (!target) { + return; + } + const rect = target.getBoundingClientRect(); + const desiredX = rect.left - this.screenRect.left + rect.width / 2 + offsetX; + const desiredY = rect.top - this.screenRect.top + rect.height / 2 + offsetY; + const pointerX = desiredX - POINTER_TIP_OFFSET.x; + const pointerY = desiredY - POINTER_TIP_OFFSET.y; + this.updatePointerTransform(pointerX, pointerY, 0); + } + private spawnDesktopFolder(name: string) { const icon = this.createDesktopFolderIcon(name); icon.classList.add('temporary-shortcut'); @@ -2763,6 +3718,30 @@ export class MonitorDirector implements MonitorDriver { this.syncEditorIndices(); } + private adjustEditorScrollForLine(target: HTMLElement) { + const container = this.elements.editorBody; + if (!container || !target) { + return; + } + const padding = 18; + const cRect = container.getBoundingClientRect(); + const tRect = target.getBoundingClientRect(); + let delta = 0; + if (tRect.bottom > cRect.bottom - padding) { + delta = tRect.bottom - (cRect.bottom - padding); + } else if (tRect.top < cRect.top + padding) { + delta = tRect.top - (cRect.top + padding); + } + if (delta !== 0) { + const nextTop = Math.max(0, container.scrollTop + delta); + try { + container.scrollTo({ top: nextTop, behavior: 'auto' }); + } catch (error) { + container.scrollTop = nextTop; + } + } + } + private async typeLineText(target: HTMLElement, text: string, options: { instant?: boolean } = {}) { const normalized = text && text.length ? text : ' '; const shouldInstant = @@ -2770,11 +3749,13 @@ export class MonitorDirector implements MonitorDriver { target.textContent = ''; if (shouldInstant) { target.textContent = normalized; + this.adjustEditorScrollForLine(target); await sleep(40); return; } for (const char of normalized.split('')) { target.textContent = `${target.textContent || ''}${char}`; + this.adjustEditorScrollForLine(target); await sleep(EDITOR_TYPING_INTERVAL); } } @@ -3175,45 +4156,6 @@ export class MonitorDirector implements MonitorDriver { return resolved || fallback; } - private showTerminalWindow(mode: 'session' | 'command' | 'python', caption: string) { - this.showWindow(this.elements.terminalWindow); - this.elements.terminalWindow.dataset.mode = mode; - this.elements.terminalHeaderText.textContent = caption; - this.elements.terminalBody.innerHTML = ''; - this.elements.terminalInputLine.textContent = ''; - } - - private async typeTerminalInput(text: string) { - this.elements.terminalInputLine.textContent = ''; - for (const char of text.split('')) { - this.elements.terminalInputLine.textContent = `${this.elements.terminalInputLine.textContent}${char}`; - await sleep(60); - } - } - - private renderTerminalOutput(command: string, output: any, options: { skipPrompt?: boolean } = {}) { - const { skipPrompt = false } = options; - const lines = typeof output === 'string' ? output.split('\n') : Array.isArray(output) ? output : [String(output || '')]; - this.elements.terminalBody.innerHTML = ''; - if (!skipPrompt) { - const pre = document.createElement('pre'); - pre.textContent = `$ ${command}`; - this.elements.terminalBody.appendChild(pre); - } - lines.forEach(line => { - const pre = document.createElement('pre'); - pre.textContent = line; - this.elements.terminalBody.appendChild(pre); - }); - this.elements.terminalInputLine.textContent = ''; - } - - private appendTerminalLine(text: string) { - const pre = document.createElement('pre'); - pre.textContent = text; - this.elements.terminalBody.appendChild(pre); - } - private normalizeLines(content: any): string[] { if (typeof content === 'string') { const parts = content.split(/\r?\n/).map(line => line.replace(/\t/g, ' ')); diff --git a/static/src/components/chat/monitor/types.ts b/static/src/components/chat/monitor/types.ts index b218cae..bc05f3b 100644 --- a/static/src/components/chat/monitor/types.ts +++ b/static/src/components/chat/monitor/types.ts @@ -25,6 +25,7 @@ export interface MonitorDriver { setDesktopRoots(roots: string[], options?: { immediate?: boolean }): void; setManualInteractionEnabled(enabled: boolean): void; showSpeechBubble(text: string, options?: MonitorBubbleOptions): void; + showWaitingBubble(text?: string): void; showThinkingBubble(): void; hideBubble(): void; previewSceneProgress(name: string): void; diff --git a/static/src/composables/useLegacySocket.ts b/static/src/composables/useLegacySocket.ts index fca3918..72efe07 100644 --- a/static/src/composables/useLegacySocket.ts +++ b/static/src/composables/useLegacySocket.ts @@ -832,6 +832,10 @@ export async function initializeLegacySocket(ctx: any) { ctx.toolTrackAction(data.name, action); ctx.$forceUpdate(); ctx.conditionalScrollToBottom(); + // 虚拟显示器:在模型检测到工具时立即展示“正在XX”预览 + if (ctx.monitorPreviewTool) { + ctx.monitorPreviewTool(data); + } }); // 工具状态更新事件 - 实时显示详细状态 diff --git a/static/src/stores/monitor.ts b/static/src/stores/monitor.ts index 46a52d0..4528793 100644 --- a/static/src/stores/monitor.ts +++ b/static/src/stores/monitor.ts @@ -51,6 +51,8 @@ interface MonitorState { playing: boolean; awaitingTools: Record; lastTreeSnapshot: string[]; + lastSpeechAt: number; + thinkingActive: boolean; pendingResults: Record; completedResults: Record; driver: MonitorDriver | null; @@ -127,6 +129,8 @@ export const useMonitorStore = defineStore('monitor', { playing: false, awaitingTools: {}, lastTreeSnapshot: [...DEFAULT_ROOTS], + lastSpeechAt: 0, + thinkingActive: false, pendingResults: {}, completedResults: {}, driver: null, @@ -197,6 +201,7 @@ export const useMonitorStore = defineStore('monitor', { this.queue = []; this.playing = false; } + this.lastSpeechAt = 0; if (!preserveAwaitingTools) { this.awaitingTools = {}; } @@ -213,12 +218,13 @@ export const useMonitorStore = defineStore('monitor', { this.speechBuffer = ''; this.bubbleActive = false; } + this.thinkingActive = false; this.pendingProgressScene = null; this.progressIndicator = { id: null, label: '', scene: null }; this.driver?.resetScene({ desktopRoots: this.lastTreeSnapshot, preserveBubble, preservePointer, preserveWindows }); this.driver?.setManualInteractionEnabled(!this.isLocked); }, - setProgressIndicator(payload: { id: string; label: string; scene: string }) { + setProgressIndicator(payload: { id: string | null; label: string; scene: string }) { this.progressIndicator = { id: payload.id, label: payload.label, @@ -226,6 +232,37 @@ export const useMonitorStore = defineStore('monitor', { }; monitorProgressDebug('progress-indicator:set', this.progressIndicator); }, + /** + * 仅用于模型刚检测到工具调用阶段的预览,不入队、不等待结果。 + * 使用空 id 以便后续真正的 tool_start/update_action 可以顺利覆盖与清除。 + */ + previewToolIntent(payload: Record) { + if (this.bubbleActive) { + this.hideBubble(`tool-intent:${payload?.name || 'unknown'}`); + } + this.speechBuffer = ''; + const script = TOOL_SCENE_MAP[payload.name] || 'genericTool'; + const progressLabel = getSceneProgressLabel(script); + if (!progressLabel) { + return; + } + this.setStatus(progressLabel); + this.progressIndicator = { + id: null, + label: progressLabel, + scene: script + }; + monitorProgressDebug('preview-tool-intent', { + tool: payload.name, + script, + label: progressLabel + }); + if (this.driver) { + this.driver.previewSceneProgress(script); + } else { + this.pendingProgressScene = script; + } + }, clearProgressIndicator(id?: string | null) { if (id && this.progressIndicator.id && id !== this.progressIndicator.id) { return; @@ -253,17 +290,29 @@ export const useMonitorStore = defineStore('monitor', { monitorTrace('hideBubble'); this.driver?.hideBubble(); this.bubbleActive = false; + this.thinkingActive = false; }, resetSpeechBuffer() { monitorDebug('resetSpeechBuffer'); this.speechBuffer = ''; }, + showPendingReply() { + this.setStatus('待机'); + if (this.driver && typeof this.driver.showWaitingBubble === 'function') { + this.driver.showWaitingBubble('等待回复'); + this.bubbleActive = true; + return; + } + this.driver?.showSpeechBubble('等待回复...', { variant: 'info', duration: 0 }); + this.bubbleActive = true; + }, enqueueModelSpeech(text: string) { if (!text) { return; } this.setStatus('正在规划'); this.speechBuffer = `${this.speechBuffer}${text}`; + this.lastSpeechAt = Date.now(); monitorDebug('enqueueModelSpeech', { incoming: text, combined: this.speechBuffer @@ -285,19 +334,24 @@ export const useMonitorStore = defineStore('monitor', { if (this.playing || !this.driver) { return; } + if (this.thinkingActive) { + monitorDebug('enqueueModelThinking already active'); + return; + } this.setStatus('思考中'); monitorDebug('enqueueModelThinking show bubble'); this.driver?.showThinkingBubble(); this.bubbleActive = true; + this.thinkingActive = true; }, endModelOutput() { this.setStatus('待机'); + this.thinkingActive = false; }, enqueueToolEvent(payload: Record) { - if (this.bubbleActive) { - this.hideBubble(`tool-enqueue:${payload?.name || 'unknown'}`); - } - this.speechBuffer = ''; + const now = Date.now(); + const recentSpeechGap = now - this.lastSpeechAt; + const MIN_SPEECH_VISIBLE_MS = 480; const script = TOOL_SCENE_MAP[payload.name] || 'genericTool'; const progressLabel = getSceneProgressLabel(script); if (progressLabel) { @@ -321,12 +375,24 @@ export const useMonitorStore = defineStore('monitor', { } else { this.clearProgressIndicator(); } - if (this.driver) { - monitorProgressDebug('enqueueToolEvent:preview-now', { tool: payload.name, script, id }); - this.driver.previewSceneProgress(script); + const doPreview = () => { + if (this.driver) { + monitorProgressDebug('enqueueToolEvent:preview-now', { tool: payload.name, script, id }); + this.driver.previewSceneProgress(script); + } else { + monitorProgressDebug('enqueueToolEvent:pending-preview', { tool: payload.name, script, id }); + this.pendingProgressScene = script; + } + if (this.bubbleActive) { + this.hideBubble(`tool-enqueue:${payload?.name || 'unknown'}`); + } + }; + if (this.bubbleActive && recentSpeechGap >= 0 && recentSpeechGap < MIN_SPEECH_VISIBLE_MS) { + const delay = MIN_SPEECH_VISIBLE_MS - recentSpeechGap; + monitorLifecycleLog('enqueue:delay-preview-for-speech', { delay, recentSpeechGap, tool: payload.name }); + setTimeout(doPreview, delay); } else { - monitorProgressDebug('enqueueToolEvent:pending-preview', { tool: payload.name, script, id }); - this.pendingProgressScene = script; + doPreview(); } this.processQueue(); }, @@ -494,53 +560,43 @@ export const useMonitorStore = defineStore('monitor', { this.setStatus('待机'); monitorLifecycleLog('queue-end'); }, - async runScript(event: MonitorEvent) { - if (!this.driver) { - this.queue.unshift(event); - return; - } - const waitKey = - event.payload?.executionId || event.payload?.execution_id || event.id || event.payload?.id || null; - let playbackResult: any = undefined; - let playbackSettled = false; - const waitStartedAt = Date.now(); - monitorLifecycleLog('runScript:start', { - script: event.script, - waitKey, - payloadTool: event.payload?.name, - payloadId: event.payload?.id - }); + async runScript(event: MonitorEvent) { + if (!this.driver) { + this.queue.unshift(event); + return; + } + const waitKey = + event.payload?.executionId || event.payload?.execution_id || event.id || event.payload?.id || null; + monitorLifecycleLog('runScript:start', { + script: event.script, + waitKey, + payloadTool: event.payload?.name, + payloadId: event.payload?.id + }); - const waitForCompletion = async () => { - if (!waitKey) { - playbackSettled = true; - if (playbackResult === undefined) { - playbackResult = null; - } - return playbackResult; - } - if (playbackSettled) { - return playbackResult; - } - try { - playbackResult = await this.waitForResult(waitKey); - } catch (error) { - console.warn('monitor waitForResult error', error); + let playbackResult: any = undefined; + let playbackSettled = false; + const waitForCompletion = async () => { + if (!waitKey) { + playbackSettled = true; + if (playbackResult === undefined) { playbackResult = null; - } finally { - playbackSettled = true; } return playbackResult; - }; - - await waitForCompletion(); - monitorLifecycleLog('runScript:resolved', { - script: event.script, - waitKey, - waitMs: Date.now() - waitStartedAt, - hasResult: playbackResult !== undefined && playbackResult !== null, - status: playbackResult?.status - }); + } + if (playbackSettled) { + return playbackResult; + } + try { + playbackResult = await this.waitForResult(waitKey); + } catch (error) { + console.warn('monitor waitForResult error', error); + playbackResult = null; + } finally { + playbackSettled = true; + } + return playbackResult; + }; const transformStatus = (raw?: string) => { const label = typeof raw === 'string' && raw.trim().length ? raw.trim() : '进行中'; diff --git a/static/src/styles/components/chat/_virtual-monitor.scss b/static/src/styles/components/chat/_virtual-monitor.scss index 1360a86..f478436 100644 --- a/static/src/styles/components/chat/_virtual-monitor.scss +++ b/static/src/styles/components/chat/_virtual-monitor.scss @@ -243,10 +243,12 @@ } .virtual-monitor-surface .terminal-window { - width: 420px; - height: 280px; - top: 180px; - right: 360px; + width: 640px; + height: 400px; + top: 150px; + left: 120px; + display: flex; + flex-direction: column; } .virtual-monitor-surface .reader-window { @@ -600,8 +602,9 @@ } .virtual-monitor-surface .terminal-window .window-header { - background: rgba(6, 12, 28, 0.9); - color: #c7dfff; + background: #e5ecff; + color: #1c2759; + border-bottom: 1px solid rgba(34, 63, 142, 0.12); } .virtual-monitor-surface .editor-body { @@ -683,22 +686,210 @@ } .virtual-monitor-surface .terminal-window { - background: #050a14; - color: #def3ff; + background: linear-gradient(180deg, #f9fbff 0%, #eef2ff 100%); + color: #0c1c3f; + border-color: rgba(60, 97, 190, 0.12); + box-shadow: 0 18px 44px rgba(25, 48, 112, 0.25); +} + +.virtual-monitor-surface .session-terminal-window { + width: 520px; + height: 320px; + background: linear-gradient(160deg, #f6f3ec 0%, #ffffff 40%, #f5efe2 100%); + color: #3d3929; + border: 1px solid rgba(118, 103, 84, 0.2); + box-shadow: 0 18px 40px rgba(61, 57, 41, 0.2); + display: flex; + flex-direction: column; +} + +.virtual-monitor-surface .session-terminal-window .window-header { + background: rgba(255, 255, 255, 0.9); + color: #5c5243; + border-bottom: 1px solid rgba(118, 103, 84, 0.16); +} + +.virtual-monitor-surface .session-terminal-output { + flex: 1; + min-height: 200px; + padding: 12px 16px 0; + background: rgba(255, 255, 255, 0.95); + overflow-y: auto; + font-family: 'JetBrains Mono', monospace; + font-size: 13px; + line-height: 1.55; + color: #2e2a1f; + scrollbar-width: thin; + scrollbar-color: rgba(136, 120, 99, 0.35) transparent; + box-shadow: inset 0 0 0 1px rgba(118, 103, 84, 0.14); + position: relative; +} + +.virtual-monitor-surface .session-terminal-output::-webkit-scrollbar { + width: 6px; +} + +.virtual-monitor-surface .session-terminal-output::-webkit-scrollbar-thumb { + background: rgba(136, 120, 99, 0.35); + border-radius: 3px; +} + +.virtual-monitor-surface .session-terminal-output:empty::before { + content: '等待终端输出...'; + color: #7f7766; + opacity: 0.85; +} + +.virtual-monitor-surface .session-terminal-output pre { + margin: 0 0 8px 0; + white-space: pre-wrap; + word-break: break-word; +} + +.virtual-monitor-surface .session-terminal-note-line { + color: #9db7d8; + font-style: italic; +} + +.virtual-monitor-surface .session-terminal-prompt { + color: #da7756; + font-weight: 700; + font-size: 14px; + letter-spacing: 0.04em; +} + +.virtual-monitor-surface .session-terminal-prompt-line { + color: #da7756; + font-weight: 700; + font-size: 13px; + letter-spacing: 0.03em; + margin-bottom: 6px; +} + +.virtual-monitor-surface .session-terminal-input-line { + flex: 1; + min-height: 20px; + display: flex; + align-items: center; + padding: 2px 0; + font-size: 13px; + line-height: 1.4; + letter-spacing: 0.02em; + white-space: pre-wrap; + word-break: break-word; } .virtual-monitor-surface .terminal-body { - font-family: 'JetBrains Mono', monospace; - min-height: 160px; - background: rgba(0, 0, 0, 0.45); - border-radius: 12px; - color: inherit; + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px 14px 14px; + flex: 1; + min-height: 0; } -.virtual-monitor-surface .terminal-input { - color: #58ffb4; - padding: 0 16px 12px; +.virtual-monitor-surface .terminal-tabs { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: 12px; + background: rgba(233, 239, 255, 0.9); + border: 1px solid rgba(66, 99, 181, 0.12); +} + +.virtual-monitor-surface .terminal-tab-list { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + flex-wrap: wrap; +} + +.virtual-monitor-surface .terminal-tab { + border: 1px solid rgba(66, 99, 181, 0.16); + background: #fff; + color: #1c2759; + border-radius: 10px; + padding: 6px 12px; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.01em; + box-shadow: 0 6px 14px rgba(25, 48, 112, 0.12); +} + +.virtual-monitor-surface .terminal-tab.active { + background: linear-gradient(135deg, #3056d3, #6d8dff); + color: #fff; + border-color: rgba(48, 86, 211, 0.28); + box-shadow: 0 10px 18px rgba(48, 86, 211, 0.22); +} + +.virtual-monitor-surface .terminal-tab.add-tab { + width: 32px; + height: 32px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 16px; + border-radius: 10px; + background: #fff; + border: 1px dashed rgba(66, 99, 181, 0.35); + color: #2f448b; +} + +.virtual-monitor-surface .terminal-tab.add-tab.lonely { + margin: 0 auto; + box-shadow: 0 8px 18px rgba(25, 48, 112, 0.16); +} + +.virtual-monitor-surface .terminal-output { + flex: 1; + min-height: 180px; + background: #ffffff; + border-radius: 14px; + padding: 12px 14px; + color: #1a2240; font-family: 'JetBrains Mono', monospace; + overflow-y: auto; + box-shadow: inset 0 0 0 1px rgba(66, 99, 181, 0.12), 0 10px 18px rgba(25, 48, 112, 0.12); + scrollbar-width: thin; + scrollbar-color: rgba(133, 160, 220, 0.35) transparent; +} + +.virtual-monitor-surface .terminal-output::-webkit-scrollbar { + width: 6px; +} + +.virtual-monitor-surface .terminal-output::-webkit-scrollbar-track { + background: transparent; +} + +.virtual-monitor-surface .terminal-output::-webkit-scrollbar-thumb { + background: rgba(80, 97, 132, 0.35); + border-radius: 3px; +} + +.virtual-monitor-surface .terminal-output:empty::before { + content: '尚未创建终端,点击 + 新建'; + color: #5a6b94; + opacity: 0.95; + font-size: 13px; +} + +.virtual-monitor-surface .terminal-output pre { + margin: 0 0 8px 0; + font-size: 13px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + color: #1a2240; +} + +.virtual-monitor-surface .terminal-output pre:last-child { + margin-bottom: 0; } .virtual-monitor-surface .reader-window.focused { diff --git a/static/src/utils/icons.ts b/static/src/utils/icons.ts index 4e4a59d..f880b58 100644 --- a/static/src/utils/icons.ts +++ b/static/src/utils/icons.ts @@ -49,6 +49,7 @@ export const TOOL_ICON_MAP = Object.freeze({ extract_webpage: 'globe', focus_file: 'eye', modify_file: 'pencil', + write_file_diff: 'pencil', ocr_image: 'camera', read_file: 'book', rename_file: 'pencil', diff --git a/utils/tool_result_formatter.py b/utils/tool_result_formatter.py index cf54116..682d5cc 100644 --- a/utils/tool_result_formatter.py +++ b/utils/tool_result_formatter.py @@ -111,10 +111,20 @@ def _format_write_file_diff(result_data: Dict[str, Any], raw_text: str) -> str: lines.append("⚠️ 失败块: " + ";".join(fail_descriptions)) if len(failed_blocks) > 3: lines.append(f"(其余 {len(failed_blocks) - 3} 个失败块略)") + # 通用排查提示:把最常见的坑点一次性说清楚,减少来回沟通成本 + lines.append("🔎 排查提示(常见易错点):") + lines.append("- 是否把“要新增/要删除/要替换”的每一行都标了 `+` 或 `-`?(漏标会被当成上下文/锚点)") + lines.append("- 空行也要写成单独一行的 `+`(只有 `+` 和换行),否则空行会消失或被当成上下文导致匹配失败。") + lines.append("- 若目标文件是空文件:应使用“仅追加”写法(块内只有 `+` 行,不要混入未加前缀的正文)。") + lines.append("- 若希望在文件中间插入/替换:必须提供足够的上下文行(以空格开头)或删除行(`-`)来锚定位置,不能只贴 `+`。") + lines.append("- 是否存在空格/Tab/缩进差异、全角半角标点差异、大小写差异?上下文与原文必须字节级一致。") + lines.append("- 是否是 CRLF(\\r\\n) 与 LF(\\n) 混用导致原文匹配失败?可先用终端查看/统一换行后再补丁。") + lines.append("- 是否遗漏 `*** Begin Patch`/`*** End Patch` 或在第一个 `@@` 之前写了其它内容?") detail_sections: List[str] = [] for item in failed_blocks: idx = item.get("index") reason = item.get("reason") or item.get("error") or "未说明原因" + hint = item.get("hint") block_patch = item.get("block_patch") or item.get("patch") if not block_patch: old_text = item.get("old_text") or "" @@ -127,6 +137,8 @@ def _format_write_file_diff(result_data: Dict[str, Any], raw_text: str) -> str: if synthetic_lines: block_patch = "\n".join(synthetic_lines) detail_sections.append(f"- #{idx}: {reason}") + if hint: + detail_sections.append(f" 提示: {hint}") if block_patch: detail_sections.append("```diff") detail_sections.append(block_patch.rstrip("\n")) diff --git a/web_server.py b/web_server.py index 8ee20ec..1d4f618 100644 --- a/web_server.py +++ b/web_server.py @@ -25,6 +25,41 @@ import secrets import logging import hmac +# 控制台输出策略:默认静默,只保留简要事件 +_ORIGINAL_PRINT = print +ENABLE_VERBOSE_CONSOLE = False + + +def brief_log(message: str): + """始终输出的简要日志(模型输出/工具调用等关键事件)""" + try: + _ORIGINAL_PRINT(message) + except Exception: + pass + + +if not ENABLE_VERBOSE_CONSOLE: + import builtins + + def _silent_print(*args, **kwargs): + return + + builtins.print = _silent_print + +# 抑制 Flask/Werkzeug 访问日志,只保留 brief_log 输出 +logging.getLogger('werkzeug').setLevel(logging.ERROR) +logging.getLogger('werkzeug').disabled = True +for noisy_logger in ('engineio.server', 'socketio.server'): + logging.getLogger(noisy_logger).setLevel(logging.ERROR) + logging.getLogger(noisy_logger).disabled = True +# 静音子智能体模块错误日志(交由 brief_log 或前端提示处理) +sub_agent_logger = logging.getLogger('modules.sub_agent_manager') +sub_agent_logger.setLevel(logging.CRITICAL) +sub_agent_logger.disabled = True +sub_agent_logger.propagate = False +for h in list(sub_agent_logger.handlers): + sub_agent_logger.removeHandler(h) + # 添加项目根目录到Python路径 sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) @@ -79,7 +114,7 @@ app.config['SESSION_COOKIE_SECURE'] = _cookie_secure_env in {"1", "true", "yes"} app.config['SESSION_COOKIE_HTTPONLY'] = True CORS(app) -socketio = SocketIO(app, cors_allowed_origins="*", async_mode='threading') +socketio = SocketIO(app, cors_allowed_origins="*", async_mode='threading', logger=False, engineio_logger=False) class EndpointFilter(logging.Filter): @@ -3696,6 +3731,7 @@ async def handle_task_with_sender(terminal: WebTerminal, message, sender, client text_started = True text_streaming = True sender('text_start', {}) + brief_log("模型输出了内容") await asyncio.sleep(0.05) if not pending_append: @@ -4252,6 +4288,7 @@ async def handle_task_with_sender(terminal: WebTerminal, message, sender, client 'monitor_snapshot': monitor_snapshot, 'conversation_id': conversation_id }) + brief_log(f"调用了工具: {function_name}") await asyncio.sleep(0.3) start_time = time.time()