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()