From 8395cf8b4bb9ab8c8e8828b38f8080a81ff588b7 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sat, 9 May 2026 18:56:49 +0800 Subject: [PATCH] fix(prompt): merge disabled-tool notice into leading system prompts --- .gitignore | 1 + config/custom_models.json | 144 ----- config/custom_models.json.example | 6 +- config/paths.py | 32 +- config/sub_agent.py | 20 +- core/main_terminal.py | 9 +- core/main_terminal_parts/context.py | 15 +- core/main_terminal_parts/tools_definition.py | 9 +- core/main_terminal_parts/tools_execution.py | 13 +- easyagent/src/tools/dispatcher.js | 3 +- easyagent/src/tools/edit_file.js | 79 ++- modules/background_command_manager.py | 199 ++++++- modules/container_file_proxy.py | 10 + modules/file_manager.py | 66 ++- modules/host_workspace_manager.py | 123 ++-- modules/mcp_client_manager.py | 26 +- modules/personalization_manager.py | 2 +- modules/search_engine.py | 21 +- modules/sub_agent_manager.py | 206 +++++-- modules/versioning_manager.py | 117 +++- prompts/main_system.txt | 2 +- prompts/main_system_qwenvl.txt | 2 +- scripts/api_tool_role_experiment.py | 10 +- server/chat_flow_task_main.py | 48 +- server/chat_flow_tool_loop.py | 68 ++- server/conversation.py | 358 +++++++++-- server/deep_compression.py | 11 +- server/status.py | 73 ++- server/tasks.py | 361 +++++++++++- server/utils_common.py | 8 + static/src/App.vue | 7 + static/src/app/computed.ts | 11 + static/src/app/lifecycle.ts | 2 + static/src/app/methods/conversation.ts | 24 + static/src/app/methods/message.ts | 556 +++++++++++++++++- static/src/app/methods/taskPolling.ts | 202 ++++++- static/src/app/methods/ui.ts | 389 +++++++++++- static/src/app/methods/versioning.ts | 125 +++- static/src/app/state.ts | 25 + static/src/app/watchers.ts | 41 ++ .../components/chat/actions/ToolAction.vue | 25 +- .../components/chat/actions/toolRenderers.ts | 25 +- static/src/components/input/InputComposer.vue | 490 ++++++++++++++- .../overlay/BackgroundCommandDialog.vue | 44 +- .../overlay/SubAgentActivityDialog.vue | 43 +- .../components/overlay/VersioningDialog.vue | 25 +- static/src/stores/backgroundCommand.ts | 48 ++ static/src/stores/subAgent.ts | 57 ++ static/src/stores/task.ts | 189 +++++- .../styles/components/chat/_chat-area.scss | 9 +- .../styles/components/input/_composer.scss | 119 +++- .../styles/components/overlays/_overlays.scss | 32 +- .../styles/components/panels/_left-panel.scss | 10 +- test/test_config_paths_resolution.py | 87 +++ test/test_mcp_integration.py | 45 ++ 55 files changed, 4158 insertions(+), 514 deletions(-) delete mode 100644 config/custom_models.json create mode 100644 test/test_config_paths_resolution.py diff --git a/.gitignore b/.gitignore index b72e2b9..51dbee8 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ doc/ # Host workspace config (local, use .example) config/host_workspaces.json +config/custom_models.json diff --git a/config/custom_models.json b/config/custom_models.json deleted file mode 100644 index ceee03f..0000000 --- a/config/custom_models.json +++ /dev/null @@ -1,144 +0,0 @@ -{ - "models": [ - { - "model_name": "kimi-k2.6", - "description": "Kimi-k2.6(测试别名),配置与 Kimi-k2.5 一致", - "visible": true, - "url": "${API_BASE_KIMI_OFFICIAL}", - "apikey": "${API_KEY_KIMI_OFFICIAL}", - "multimodal": "image,video", - "reasoning_capability": "fast,thinking", - "context_window": 256000, - "max_output_tokens": 32768, - "thinkmode_status": { - "type": "param_toggle", - "model_id": "kimi-k2.5", - "fast_extra_parameter": { - "thinking": { - "type": "disabled" - } - }, - "thinking_extra_parameter": { - "thinking": { - "type": "enabled" - }, - "enable_thinking": true - } - }, - "extra_parameter": {}, - "model_description": "你的基础模型是 Kimi-k2.6(测试别名),底层与 Kimi-k2.5 一致,并通过 thinking 参数开启/关闭思考能力。" - }, - { - "model_name": "DeepSeek-V4-Flash Max", - "description": "DeepSeek V4 Flash(reasoning_effort=max),支持快速/思考/深度思考", - "visible": true, - "url": "https://api.deepseek.com", - "apikey": "${API_KEY_DEEPSEEK}", - "multimodal": "none", - "reasoning_capability": "fast,thinking", - "context_window": 1000000, - "max_output_tokens": 384000, - "thinkmode_status": { - "type": "param_toggle", - "model_id": "deepseek-v4-flash", - "fast_extra_parameter": { - "thinking": { - "type": "disabled" - } - }, - "thinking_extra_parameter": { - "thinking": { - "type": "enabled" - }, - "reasoning_effort": "max" - } - }, - "extra_parameter": {}, - "model_description": "你是DeepSeek-V4-Flash,一个快捷、高效的通用模型,具备接近旗舰级的推理能力,在简单到中等复杂度任务中表现出色,并支持 1M 上下文,适合追求响应速度与成本效率的使用场景。" - }, - { - "model_name": "DeepSeek-V4-Flah High", - "description": "DeepSeek V4 Flash(reasoning_effort=high),支持快速/思考/深度思考", - "visible": true, - "url": "https://api.deepseek.com", - "apikey": "${API_KEY_DEEPSEEK}", - "multimodal": "none", - "reasoning_capability": "fast,thinking", - "context_window": 1000000, - "max_output_tokens": 384000, - "thinkmode_status": { - "type": "param_toggle", - "model_id": "deepseek-v4-flash", - "fast_extra_parameter": { - "thinking": { - "type": "disabled" - } - }, - "thinking_extra_parameter": { - "thinking": { - "type": "enabled" - }, - "reasoning_effort": "high" - } - }, - "extra_parameter": {}, - "model_description": "你是DeepSeek-V4-Flash,一个快捷、高效的通用模型,具备接近旗舰级的推理能力,在简单到中等复杂度任务中表现出色,并支持 1M 上下文,适合追求响应速度与成本效率的使用场景。" - }, - { - "model_name": "DeepSeek-V4-Pro Max", - "description": "DeepSeek V4 Pro(reasoning_effort=max),支持快速/思考/深度思考", - "visible": true, - "url": "https://api.deepseek.com", - "apikey": "${API_KEY_DEEPSEEK}", - "multimodal": "none", - "reasoning_capability": "fast,thinking", - "context_window": 1000000, - "max_output_tokens": 384000, - "thinkmode_status": { - "type": "param_toggle", - "model_id": "deepseek-v4-pro", - "fast_extra_parameter": { - "thinking": { - "type": "disabled" - } - }, - "thinking_extra_parameter": { - "thinking": { - "type": "enabled" - }, - "reasoning_effort": "max" - } - }, - "extra_parameter": {}, - "model_description": "你是DeepSeek-V4-Pro,一个面向复杂任务的高性能通用模型,具备突出的 Agent 能力、丰富的世界知识和顶级推理表现,在编程、数学、STEM 与复杂问题分析场景中表现尤为出色,并支持 1M 上下文。" - }, - { - "model_name": "DeepSeek-V4-Pro High", - "description": "DeepSeek V4 Pro(reasoning_effort=high),支持快速/思考/深度思考", - "visible": true, - "url": "https://api.deepseek.com", - "apikey": "${API_KEY_DEEPSEEK}", - "multimodal": "none", - "reasoning_capability": "fast,thinking", - "context_window": 1000000, - "max_output_tokens": 384000, - "thinkmode_status": { - "type": "param_toggle", - "model_id": "deepseek-v4-pro", - "fast_extra_parameter": { - "thinking": { - "type": "disabled" - } - }, - "thinking_extra_parameter": { - "thinking": { - "type": "enabled" - }, - "reasoning_effort": "high" - } - }, - "extra_parameter": {}, - "model_description": "你是DeepSeek-V4-Pro,一个面向复杂任务的高性能通用模型,具备突出的 Agent 能力、丰富的世界知识和顶级推理表现,在编程、数学、STEM 与复杂问题分析场景中表现尤为出色,并支持 1M 上下文。" - } - ] -} diff --git a/config/custom_models.json.example b/config/custom_models.json.example index ceee03f..b04e1f8 100644 --- a/config/custom_models.json.example +++ b/config/custom_models.json.example @@ -2,7 +2,7 @@ "models": [ { "model_name": "kimi-k2.6", - "description": "Kimi-k2.6(测试别名),配置与 Kimi-k2.5 一致", + "description": "Kimi-k2.6", "visible": true, "url": "${API_BASE_KIMI_OFFICIAL}", "apikey": "${API_KEY_KIMI_OFFICIAL}", @@ -12,7 +12,7 @@ "max_output_tokens": 32768, "thinkmode_status": { "type": "param_toggle", - "model_id": "kimi-k2.5", + "model_id": "kimi-k2.6", "fast_extra_parameter": { "thinking": { "type": "disabled" @@ -26,7 +26,7 @@ } }, "extra_parameter": {}, - "model_description": "你的基础模型是 Kimi-k2.6(测试别名),底层与 Kimi-k2.5 一致,并通过 thinking 参数开启/关闭思考能力。" + "model_description": "你的基础模型是 Kimi-k2.6,并通过 thinking 参数开启/关闭思考能力。" }, { "model_name": "DeepSeek-V4-Flash Max", diff --git a/config/paths.py b/config/paths.py index d9d3ae6..17f7c68 100644 --- a/config/paths.py +++ b/config/paths.py @@ -1,27 +1,41 @@ """项目路径与目录配置。""" import os +from pathlib import Path + + +_REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _resolve_repo_path(raw_value: str, default: str) -> str: + candidate = str(raw_value or "").strip() or str(default) + path = Path(candidate).expanduser() + if not path.is_absolute(): + path = (_REPO_ROOT / path).resolve() + else: + path = path.resolve() + return str(path) # 默认项目路径,可通过环境变量覆盖以指向宿主机任意目录 -DEFAULT_PROJECT_PATH = os.environ.get("DEFAULT_PROJECT_PATH", "./project") +DEFAULT_PROJECT_PATH = _resolve_repo_path(os.environ.get("DEFAULT_PROJECT_PATH", ""), "./project") # 宿主机模式工作区配置文件(JSON) -HOST_WORKSPACES_FILE = os.environ.get("HOST_WORKSPACES_FILE", "./config/host_workspaces.json") +HOST_WORKSPACES_FILE = _resolve_repo_path(os.environ.get("HOST_WORKSPACES_FILE", ""), "./config/host_workspaces.json") # 兼容旧配置:若仍有模块读取 HOST_PROJECT_PATH,保留该键(实际宿主机路径选择改由 JSON 管理) -HOST_PROJECT_PATH = os.environ.get("HOST_PROJECT_PATH", DEFAULT_PROJECT_PATH) -PROMPTS_DIR = "./prompts" -DATA_DIR = "./data" -LOGS_DIR = "./logs" -AGENT_SKILLS_DIR = "./agentskills" +HOST_PROJECT_PATH = _resolve_repo_path(os.environ.get("HOST_PROJECT_PATH", ""), DEFAULT_PROJECT_PATH) +PROMPTS_DIR = _resolve_repo_path(os.environ.get("PROMPTS_DIR", ""), "./prompts") +DATA_DIR = _resolve_repo_path(os.environ.get("DATA_DIR", ""), "./data") +LOGS_DIR = _resolve_repo_path(os.environ.get("LOGS_DIR", ""), "./logs") +AGENT_SKILLS_DIR = _resolve_repo_path(os.environ.get("AGENT_SKILLS_DIR", ""), "./agentskills") WORKSPACE_SKILLS_DIRNAME = "skills" # 多用户空间 -USER_SPACE_DIR = "./users" +USER_SPACE_DIR = _resolve_repo_path(os.environ.get("USER_SPACE_DIR", ""), "./users") USERS_DB_FILE = f"{DATA_DIR}/users.json" INVITE_CODES_FILE = f"{DATA_DIR}/invite_codes.json" ADMIN_POLICY_FILE = f"{DATA_DIR}/admin_policy.json" # API 专用用户与工作区(与网页用户隔离) -API_USER_SPACE_DIR = "./api/users" +API_USER_SPACE_DIR = _resolve_repo_path(os.environ.get("API_USER_SPACE_DIR", ""), "./api/users") API_USERS_DB_FILE = f"{DATA_DIR}/api_users.json" API_TOKENS_FILE = f"{DATA_DIR}/api_tokens.json" API_USAGE_FILE = f"{DATA_DIR}/api_usage.json" diff --git a/config/sub_agent.py b/config/sub_agent.py index 493304f..74d387e 100644 --- a/config/sub_agent.py +++ b/config/sub_agent.py @@ -1,6 +1,20 @@ """子智能体相关配置。""" import os +from pathlib import Path + + +_REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _resolve_repo_path(raw_value: str, default: str) -> str: + candidate = str(raw_value or "").strip() or str(default) + path = Path(candidate).expanduser() + if not path.is_absolute(): + path = (_REPO_ROOT / path).resolve() + else: + path = path.resolve() + return str(path) # 子智能体服务 SUB_AGENT_SERVICE_BASE_URL = os.environ.get("SUB_AGENT_SERVICE_URL", "http://127.0.0.1:8092") @@ -8,9 +22,9 @@ SUB_AGENT_DEFAULT_TIMEOUT = int(os.environ.get("SUB_AGENT_DEFAULT_TIMEOUT", "180 SUB_AGENT_STATUS_POLL_INTERVAL = float(os.environ.get("SUB_AGENT_STATUS_POLL_INTERVAL", "2.0")) # 存储与并发限制 -SUB_AGENT_TASKS_BASE_DIR = os.environ.get("SUB_AGENT_TASKS_BASE_DIR", "./sub_agent/tasks") -SUB_AGENT_PROJECT_RESULTS_DIR = os.environ.get("SUB_AGENT_PROJECT_RESULTS_DIR", "./project/sub_agent_results") -SUB_AGENT_STATE_FILE = os.environ.get("SUB_AGENT_STATE_FILE", "./data/sub_agents.json") +SUB_AGENT_TASKS_BASE_DIR = _resolve_repo_path(os.environ.get("SUB_AGENT_TASKS_BASE_DIR", ""), "./sub_agent/tasks") +SUB_AGENT_PROJECT_RESULTS_DIR = _resolve_repo_path(os.environ.get("SUB_AGENT_PROJECT_RESULTS_DIR", ""), "./project/sub_agent_results") +SUB_AGENT_STATE_FILE = _resolve_repo_path(os.environ.get("SUB_AGENT_STATE_FILE", ""), "./data/sub_agents.json") SUB_AGENT_MAX_ACTIVE = int(os.environ.get("SUB_AGENT_MAX_ACTIVE", "5")) __all__ = [ diff --git a/core/main_terminal.py b/core/main_terminal.py index 0f67719..6cfe589 100644 --- a/core/main_terminal.py +++ b/core/main_terminal.py @@ -140,7 +140,7 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi ) self.easter_egg_manager = EasterEggManager() self._announced_sub_agent_tasks = set() - self.silent_tool_disable = False # 是否静默工具禁用提示 + self.silent_tool_disable = True # 是否静默工具禁用提示(默认开启) self.current_session_id = 0 # 用于标识不同的任务会话 # 工具类别(可被管理员动态覆盖) self.tool_categories_map = dict(TOOL_CATEGORIES) @@ -396,6 +396,13 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi except Exception: pass + # MCP stdio 客户端是长连接子进程,切换工作区后需重建,确保后续 cwd 与路径映射一致。 + if getattr(self, "mcp_client_manager", None): + try: + self.mcp_client_manager.close_all_clients() + except Exception: + pass + # 强制下次请求重新同步 skills(含 AGENTS.md 场景) self._skills_synced_project_path = None diff --git a/core/main_terminal_parts/context.py b/core/main_terminal_parts/context.py index a2d5688..2980d87 100644 --- a/core/main_terminal_parts/context.py +++ b/core/main_terminal_parts/context.py @@ -292,6 +292,14 @@ class MainTerminalContextMixin: if isinstance(custom_system_prompt, str) and custom_system_prompt.strip(): messages.append({"role": "system", "content": custom_system_prompt.strip()}) + # 禁用工具提示也作为“开头 system prompt”注入,便于后续统一合并。 + disabled_notice = self._format_disabled_tool_notice() + if disabled_notice: + messages.append({ + "role": "system", + "content": disabled_notice + }) + # 添加对话历史(保留完整结构,包括tool_calls和tool消息) conversation = context["conversation"] replaced_tool_count = 0 @@ -377,13 +385,6 @@ class MainTerminalContextMixin: }) # 当前用户输入已经在conversation中了,不需要重复添加 - - disabled_notice = self._format_disabled_tool_notice() - if disabled_notice: - messages.append({ - "role": "system", - "content": disabled_notice - }) if shallow_replace_enabled: print(f"[ContextCompression] build_messages 替换tool占位符: {replaced_tool_count} 条") return messages diff --git a/core/main_terminal_parts/tools_definition.py b/core/main_terminal_parts/tools_definition.py index ffff9d9..1e76cf1 100644 --- a/core/main_terminal_parts/tools_definition.py +++ b/core/main_terminal_parts/tools_definition.py @@ -540,14 +540,19 @@ class MainTerminalToolsDefinitionMixin: }, "old_string": { "type": "string", - "description": "要替换的文本(需与文件内容精确匹配,保留缩进)" + "description": "要替换的文本(需与文件内容精确匹配,保留缩进;建议提供至少3行提升定位稳定性。需要批量替换的场景可以单行或不足一行)" }, "new_string": { "type": "string", "description": "用于替换的新文本(必须不同于 old_string)" + }, + "replace_all": { + "type": "boolean", + "enum": [True, False], + "description": "是否替换所有匹配内容(必填)。false=仅替换首个匹配,true=替换全部匹配。" } }), - "required": ["file_path", "old_string", "new_string"] + "required": ["file_path", "old_string", "new_string", "replace_all"] } } }, diff --git a/core/main_terminal_parts/tools_execution.py b/core/main_terminal_parts/tools_execution.py index e252cd5..3e3963e 100644 --- a/core/main_terminal_parts/tools_execution.py +++ b/core/main_terminal_parts/tools_execution.py @@ -1065,16 +1065,27 @@ class MainTerminalToolsExecutionMixin: path = arguments.get("file_path") old_text = arguments.get("old_string") new_text = arguments.get("new_string") + replace_all_provided = "replace_all" in arguments + replace_all = arguments.get("replace_all") if replace_all_provided else None if not path: result = {"success": False, "error": "缺少必要参数: file_path"} elif old_text is None or new_text is None: result = {"success": False, "error": "缺少必要参数: old_string/new_string"} + elif not replace_all_provided: + result = {"success": False, "error": "缺少必要参数: replace_all(必须显式传入 true 或 false)"} + elif not isinstance(replace_all, bool): + result = {"success": False, "error": "replace_all 必须是 true 或 false"} elif old_text == new_text: result = {"success": False, "error": "old_string 与 new_string 相同,无法执行替换"} elif not old_text: result = {"success": False, "error": "old_string 不能为空,请从 read_file 内容中精确复制"} else: - result = self.file_manager.replace_in_file(path, old_text, new_text) + result = self.file_manager.replace_in_file( + path, + old_text, + new_text, + replace_all=replace_all + ) elif tool_name == "create_folder": result = self.file_manager.create_folder(arguments["path"]) diff --git a/easyagent/src/tools/dispatcher.js b/easyagent/src/tools/dispatcher.js index 44c86ae..e6a3b57 100644 --- a/easyagent/src/tools/dispatcher.js +++ b/easyagent/src/tools/dispatcher.js @@ -46,7 +46,8 @@ function formatReadFile(result) { function formatEditFile(result) { if (!result.success) return formatFailure(result.error || '修改失败'); const count = typeof result.replacements === 'number' ? result.replacements : 0; - return `已替换 ${count} 处: ${result.path}`; + const msg = result.message ? `,${result.message}` : ''; + return `已替换 ${count} 处: ${result.path}${msg}`; } function formatWebSearch(result) { diff --git a/easyagent/src/tools/edit_file.js b/easyagent/src/tools/edit_file.js index d947320..319d276 100644 --- a/easyagent/src/tools/edit_file.js +++ b/easyagent/src/tools/edit_file.js @@ -41,28 +41,43 @@ function diffSummary(oldText, newText) { return { added, removed, hunks }; } +function countLines(text) { + if (!text) return 0; + return String(text).split(/\r?\n/).length; +} + +function findMatchLineNumbers(content, target) { + if (!target) return []; + const lines = []; + let start = 0; + while (true) { + const idx = content.indexOf(target, start); + if (idx === -1) break; + const lineNo = content.slice(0, idx).split(/\r?\n/).length; + lines.push(lineNo); + start = idx + Math.max(1, target.length); + } + return lines; +} + function editFileTool(workspace, args) { const target = resolvePath(workspace, args.file_path); const oldString = args.old_string ?? ''; const newString = args.new_string ?? ''; + if (!Object.prototype.hasOwnProperty.call(args || {}, 'replace_all')) { + return { success: false, error: '缺少必要参数: replace_all(必须显式传入 true 或 false)' }; + } + const replaceAll = args.replace_all; + if (typeof replaceAll !== 'boolean') { + return { success: false, error: 'replace_all 必须是 true 或 false' }; + } + const shortOldStringNotice = countLines(oldString) < 3; const ctx = getContainerContext(); if (ctx) { const resolved = resolveContainerPath(workspace, args.file_path || '.', ctx); if (resolved.error) return { success: false, error: resolved.error }; const rel = resolved.relativePath; - if (!oldString) { - const writeResp = execContainerAction(ctx, 'write_file', { - path: rel, - content: newString || '', - mode: 'w', - }); - if (!writeResp.success) return { success: false, error: writeResp.error || '写入失败' }; - return { - success: true, - path: resolved.containerPath, - replacements: newString ? 1 : 0, - }; - } + if (!oldString) return { success: false, error: 'old_string 不能为空,请从 read_file 内容中精确复制' }; const readResp = execContainerAction(ctx, 'read_file', { path: rel }); if (!readResp.success) return { success: false, error: readResp.error || '读取失败' }; @@ -70,8 +85,12 @@ function editFileTool(workspace, args) { if (!original.includes(oldString)) { return { success: false, error: 'old_string 未匹配到内容' }; } - const updated = original.split(oldString).join(newString); - const replacements = original.split(oldString).length - 1; + const matchedLines = findMatchLineNumbers(original, oldString); + const foundCount = matchedLines.length; + const replacements = replaceAll ? (original.split(oldString).length - 1) : 1; + const updated = replaceAll + ? original.split(oldString).join(newString) + : original.replace(oldString, newString); const writeResp = execContainerAction(ctx, 'write_file', { path: rel, content: updated, @@ -79,10 +98,20 @@ function editFileTool(workspace, args) { }); if (!writeResp.success) return { success: false, error: writeResp.error || '写入失败' }; const diff = diffSummary(original, updated); + const messageParts = []; + if (shortOldStringNotice) { + messageParts.push('提示:old_string 少于3行,已继续执行;需要批量替换的场景可以单行或不足一行'); + } + if (foundCount > 1) { + messageParts.push(`发现${foundCount}处,于${matchedLines.join(',')}行共替换${replacements}处`); + } return { success: true, path: resolved.containerPath, replacements, + found_matches: foundCount, + matched_lines: matchedLines, + message: messageParts.length > 0 ? messageParts.join(';') : undefined, diff, }; } @@ -98,21 +127,35 @@ function editFileTool(workspace, args) { return { success: false, error: '目标不是文件' }; } const original = fs.readFileSync(target, 'utf8'); - if (!creating && oldString === '') { + if (oldString === '') { return { success: false, error: 'old_string 不能为空,请从 read_file 内容中精确复制' }; } if (!creating && !original.includes(oldString)) { return { success: false, error: 'old_string 未匹配到内容' }; } - const updated = creating ? newString : original.split(oldString).join(newString); - let replacements = creating ? 0 : original.split(oldString).length - 1; + const matchedLines = creating ? [] : findMatchLineNumbers(original, oldString); + const foundCount = matchedLines.length; + const updated = creating + ? newString + : (replaceAll ? original.split(oldString).join(newString) : original.replace(oldString, newString)); + let replacements = creating ? 0 : (replaceAll ? (original.split(oldString).length - 1) : 1); if (creating && newString) replacements = 1; fs.writeFileSync(target, updated, 'utf8'); const diff = diffSummary(original, updated); + const messageParts = []; + if (shortOldStringNotice) { + messageParts.push('提示:old_string 少于3行,已继续执行;需要批量替换的场景可以单行或不足一行'); + } + if (foundCount > 1) { + messageParts.push(`发现${foundCount}处,于${matchedLines.join(',')}行共替换${replacements}处`); + } return { success: true, path: target, replacements, + found_matches: foundCount, + matched_lines: matchedLines, + message: messageParts.length > 0 ? messageParts.join(';') : undefined, diff, }; } catch (err) { diff --git a/modules/background_command_manager.py b/modules/background_command_manager.py index 66c2f29..b12548c 100644 --- a/modules/background_command_manager.py +++ b/modules/background_command_manager.py @@ -25,6 +25,7 @@ class BackgroundCommandManager: def __init__(self, project_path: str): self.project_path = Path(project_path).resolve() self._records: Dict[str, Dict[str, Any]] = {} + self._processes: Dict[str, subprocess.Popen] = {} self._lock = threading.RLock() self._cv = threading.Condition(self._lock) @@ -257,6 +258,7 @@ class BackgroundCommandManager: if rec is not None: rec["pid"] = process.pid rec["updated_at"] = time.time() + self._processes[command_id] = process def _reader(stream, collector, rec_key: str): try: @@ -338,13 +340,193 @@ class BackgroundCommandManager: with self._cv: rec = self._records.get(command_id) if rec is not None: - rec["status"] = status - rec["result"] = result - rec["truncated"] = truncated - rec["updated_at"] = time.time() - rec["finished_at"] = time.time() + existing_status = rec.get("status") + existing_result = rec.get("result") + if existing_status == "cancelled" and isinstance(existing_result, dict): + existing_output = str(existing_result.get("output") or "") + if not existing_output and combined_output: + existing_result["output"] = combined_output + rec["result"] = existing_result + rec["updated_at"] = time.time() + rec["finished_at"] = rec.get("finished_at") or time.time() + else: + rec["status"] = status + rec["result"] = result + rec["truncated"] = truncated + rec["updated_at"] = time.time() + rec["finished_at"] = time.time() + self._processes.pop(command_id, None) self._cv.notify_all() + @staticmethod + def _coerce_pid(value: Any) -> Optional[int]: + try: + pid = int(value) + return pid if pid > 0 else None + except (TypeError, ValueError): + return None + + def _is_pid_alive(self, pid: Any) -> bool: + normalized = self._coerce_pid(pid) + if not normalized: + return False + try: + os.kill(normalized, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + except Exception: + return False + return True + + def _terminate_pid(self, pid: Any) -> bool: + normalized = self._coerce_pid(pid) + if not normalized: + return False + try: + os.killpg(normalized, signal.SIGINT) + except Exception: + try: + os.kill(normalized, signal.SIGINT) + except Exception: + return False + deadline = time.time() + 2.0 + while time.time() < deadline: + if not self._is_pid_alive(normalized): + return True + time.sleep(0.05) + try: + os.killpg(normalized, signal.SIGKILL) + except Exception: + try: + os.kill(normalized, signal.SIGKILL) + except Exception: + return not self._is_pid_alive(normalized) + return not self._is_pid_alive(normalized) + + @staticmethod + def _is_record_timeout_stale(rec: Dict[str, Any]) -> bool: + try: + created_at = float(rec.get("created_at") or 0) + timeout = float(rec.get("timeout") or 0) + except (TypeError, ValueError): + return False + if created_at <= 0 or timeout <= 0: + return False + return (time.time() - created_at) > (timeout + 120) + + def reconcile_stale_records(self, conversation_id: Optional[str] = None) -> int: + """兜底修正后台命令卡死的 running 状态。""" + changed = 0 + with self._lock: + for rec in self._records.values(): + if not isinstance(rec, dict): + continue + if conversation_id and rec.get("conversation_id") != conversation_id: + continue + if rec.get("status") != "running": + continue + command_id = rec.get("command_id") + process = self._processes.get(command_id) if command_id else None + pid = rec.get("pid") + stale_timeout = self._is_record_timeout_stale(rec) + if process and process.poll() is None and not stale_timeout: + continue + if (not process) and self._is_pid_alive(pid) and not stale_timeout: + continue + if stale_timeout and self._is_pid_alive(pid): + self._terminate_pid(pid) + output = self._build_current_output(rec) + message = ( + "后台指令运行超时,已自动清理运行状态。" + if stale_timeout + else "检测到后台指令进程已退出,已自动清理运行状态。" + ) + rec["status"] = "failed" + rec["result"] = { + "success": False, + "status": "failed", + "command": rec.get("command"), + "output": output, + "return_code": rec.get("result", {}).get("return_code") + if isinstance(rec.get("result"), dict) + else -1, + "truncated": bool(rec.get("truncated")), + "timeout": rec.get("timeout"), + "elapsed_ms": int(max(0.0, (time.time() - float(rec.get("created_at") or time.time())) * 1000)), + "command_id": command_id, + "run_in_background": True, + "message": message, + } + rec["updated_at"] = time.time() + rec["finished_at"] = rec.get("finished_at") or time.time() + changed += 1 + if command_id: + self._processes.pop(command_id, None) + if changed: + self._cv.notify_all() + return changed + + def cancel_command(self, command_id: str) -> Dict[str, Any]: + if not command_id: + return {"success": False, "status": "error", "error": "command_id 不能为空"} + + with self._lock: + rec = self._records.get(command_id) + if not rec: + return {"success": False, "status": "error", "error": f"未找到后台命令: {command_id}"} + status = str(rec.get("status") or "") + if status in TERMINAL_STATUSES: + payload = dict(rec.get("result") or {}) + if payload: + return payload + return { + "success": status == "completed", + "status": status, + "command_id": command_id, + "message": "后台命令已结束", + } + process = self._processes.get(command_id) + pid = rec.get("pid") + + stopped = False + if process and process.poll() is None: + stopped = self._terminate_pid(process.pid) + elif self._is_pid_alive(pid): + stopped = self._terminate_pid(pid) + else: + stopped = True + + with self._cv: + rec = self._records.get(command_id) + if not rec: + return {"success": False, "status": "error", "error": f"未找到后台命令: {command_id}"} + + output = self._build_current_output(rec) + now = time.time() + rec["status"] = "cancelled" + rec["updated_at"] = now + rec["finished_at"] = now + rec["notified"] = True + result = { + "success": False, + "status": "cancelled", + "command": rec.get("command"), + "output": output, + "return_code": None, + "truncated": bool(rec.get("truncated")), + "timeout": rec.get("timeout"), + "elapsed_ms": int(max(0.0, (now - float(rec.get("created_at") or now)) * 1000)), + "command_id": command_id, + "run_in_background": True, + "message": "后台命令已手动停止" if stopped else "后台命令停止请求已发送", + } + rec["result"] = result + self._processes.pop(command_id, None) + self._cv.notify_all() + return dict(result) + def _build_current_output(self, rec: Dict[str, Any]) -> str: output = "".join((rec.get("stdout_chunks") or []) + (rec.get("stderr_chunks") or [])) if MAX_RUN_COMMAND_CHARS and len(output) > MAX_RUN_COMMAND_CHARS: @@ -353,6 +535,7 @@ class BackgroundCommandManager: def wait_for_completion(self, command_id: str, timeout_seconds: Optional[float] = None, claim: bool = False) -> Dict[str, Any]: """阻塞等待后台命令完成。""" + self.reconcile_stale_records() with self._cv: rec = self._records.get(command_id) if not rec: @@ -400,6 +583,7 @@ class BackgroundCommandManager: def poll_updates(self, conversation_id: Optional[str] = None) -> List[Dict[str, Any]]: """获取未通知且未被 sleep 领取的已完成任务。""" updates: List[Dict[str, Any]] = [] + self.reconcile_stale_records(conversation_id=conversation_id) with self._lock: for rec in self._records.values(): if conversation_id and rec.get("conversation_id") != conversation_id: @@ -430,6 +614,7 @@ class BackgroundCommandManager: rec["updated_at"] = time.time() def get_record(self, command_id: str) -> Optional[Dict[str, Any]]: + self.reconcile_stale_records() with self._lock: rec = self._records.get(command_id) if not rec: @@ -438,6 +623,7 @@ class BackgroundCommandManager: def get_record_with_output(self, command_id: str) -> Optional[Dict[str, Any]]: """获取单条后台命令记录,并附带当前可读输出。""" + self.reconcile_stale_records() with self._lock: rec = self._records.get(command_id) if not rec: @@ -453,6 +639,7 @@ class BackgroundCommandManager: limit: int = 200, ) -> List[Dict[str, Any]]: """列出后台命令记录(按创建时间倒序)。""" + self.reconcile_stale_records(conversation_id=conversation_id) with self._lock: items: List[Dict[str, Any]] = [] for rec in self._records.values(): @@ -468,6 +655,7 @@ class BackgroundCommandManager: def has_pending_for_conversation(self, conversation_id: Optional[str]) -> bool: if not conversation_id: return False + self.reconcile_stale_records(conversation_id=conversation_id) with self._lock: for rec in self._records.values(): if rec.get("conversation_id") != conversation_id: @@ -482,6 +670,7 @@ class BackgroundCommandManager: items: List[Dict[str, Any]] = [] if not conversation_id: return items + self.reconcile_stale_records(conversation_id=conversation_id) with self._lock: for rec in self._records.values(): if rec.get("conversation_id") != conversation_id: diff --git a/modules/container_file_proxy.py b/modules/container_file_proxy.py index a2ee6a2..ce8b158 100644 --- a/modules/container_file_proxy.py +++ b/modules/container_file_proxy.py @@ -122,6 +122,16 @@ def _read_text_segment(root, payload): } data, lines = _read_text(target) total = len(lines) + if total == 0: + return { + "success": True, + "path": rel, + "content": "", + "size": size, + "line_start": 0, + "line_end": 0, + "total_lines": 0 + } line_start = start if start and start > 0 else 1 line_end = end if end and end >= line_start else total if line_start > total: diff --git a/modules/file_manager.py b/modules/file_manager.py index f4e0a1c..22f321f 100644 --- a/modules/file_manager.py +++ b/modules/file_manager.py @@ -4,6 +4,7 @@ import os import shutil from pathlib import Path import re +from bisect import bisect_right from typing import Optional, Dict, List, Set, Tuple, TYPE_CHECKING from datetime import datetime try: @@ -495,6 +496,17 @@ class FileManager: lines = result["lines"] total_lines = len(lines) + if total_lines == 0: + relative_path = self._relative_path(full_path) + return { + "success": True, + "path": relative_path, + "content": "", + "size": result["size"], + "line_start": 0, + "line_end": 0, + "total_lines": 0, + } start = start_line if start_line and start_line > 0 else 1 end = end_line if end_line and end_line >= start else total_lines if start > total_lines: @@ -1166,7 +1178,7 @@ class FileManager: "error": write_error } - def replace_in_file(self, path: str, old_text: str, new_text: str) -> Dict: + def replace_in_file(self, path: str, old_text: str, new_text: str, replace_all: bool = False) -> Dict: """替换文件中的内容""" # 先读取文件 result = self.read_file(path) @@ -1189,15 +1201,23 @@ class FileManager: "error": "替换的新文本过长,建议分块处理", "suggestion": "请将大内容分成多个小的替换操作" } - + short_old_text_notice = bool(old_text and len(old_text.splitlines()) < 3) + # 检查是否包含要替换的内容 if old_text and old_text not in content: return {"success": False, "error": "未找到要替换的内容"} - + + matched_lines = self._find_match_line_numbers(content, old_text) if old_text else [] + found_count = len(matched_lines) if old_text else 0 + # 替换内容 if old_text: - new_content = content.replace(old_text, new_text) - count = content.count(old_text) + if replace_all: + count = found_count + new_content = content.replace(old_text, new_text) + else: + count = 1 + new_content = content.replace(old_text, new_text, 1) else: # 空文件直接写入新内容 new_content = new_text @@ -1207,9 +1227,43 @@ class FileManager: result = self.write_file(path, new_content) if result["success"]: result["replacements"] = count + message_parts: List[str] = [] + if old_text: + result["found_matches"] = found_count + result["matched_lines"] = matched_lines + if found_count > 1: + line_text = ",".join(str(line_no) for line_no in matched_lines) + message_parts.append(f"发现{found_count}处,于{line_text}行共替换{count}处") + if short_old_text_notice: + message_parts.insert( + 0, + "提示:old_string 少于3行,已继续执行;需要批量替换的场景可以单行或不足一行" + ) + if message_parts: + result["message"] = ";".join(message_parts) print(f"{OUTPUT_FORMATS['file']} 替换了 {count} 处内容") - + return result + + @staticmethod + def _find_match_line_numbers(content: str, target: str) -> List[int]: + """返回 target 在 content 中每个匹配起始位置对应的行号(1-based)。""" + if not target: + return [] + newline_positions = [idx for idx, ch in enumerate(content) if ch == "\n"] + line_numbers: List[int] = [] + search_start = 0 + target_len = len(target) + + while True: + idx = content.find(target, search_start) + if idx < 0: + break + line_no = bisect_right(newline_positions, idx) + 1 + line_numbers.append(line_no) + search_start = idx + max(1, target_len) + + return line_numbers def clear_file(self, path: str) -> Dict: """清空文件内容""" diff --git a/modules/host_workspace_manager.py b/modules/host_workspace_manager.py index fb77587..0ebc07f 100644 --- a/modules/host_workspace_manager.py +++ b/modules/host_workspace_manager.py @@ -3,7 +3,10 @@ from __future__ import annotations import json +import os import re +import tempfile +import threading from pathlib import Path from typing import Any, Dict, List, Optional, Tuple, Union @@ -11,6 +14,7 @@ from config import DEFAULT_PROJECT_PATH, HOST_WORKSPACES_FILE _WORKSPACE_ID_RE = re.compile(r"^[a-zA-Z0-9._-]{1,64}$") _REPO_ROOT = Path(__file__).resolve().parents[1] +_HOST_WORKSPACE_LOCK = threading.RLock() def _resolve_config_path(config_path: Optional[Union[str, Path]] = None) -> Path: @@ -43,17 +47,39 @@ def _default_payload() -> Dict[str, Any]: } -def _ensure_config_file(path: Path) -> Dict[str, Any]: +def _atomic_write_json(path: Path, data: Dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_path = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent)) + try: + with os.fdopen(fd, "w", encoding="utf-8") as fp: + json.dump(data, fp, ensure_ascii=False, indent=2) + fp.flush() + os.fsync(fp.fileno()) + os.replace(tmp_path, path) + finally: + try: + if os.path.exists(tmp_path): + os.remove(tmp_path) + except Exception: + pass + + +def _ensure_config_file(path: Path, *, strict: bool = False) -> Dict[str, Any]: path.parent.mkdir(parents=True, exist_ok=True) if path.exists(): try: data = json.loads(path.read_text(encoding="utf-8")) if isinstance(data, dict): return data - except Exception: - pass + if strict: + raise RuntimeError("host_workspaces 配置格式错误(非 JSON 对象),已停止写入以避免覆盖原文件") + except Exception as exc: + if strict: + raise RuntimeError(f"host_workspaces 配置解析失败,已停止写入以避免覆盖原文件: {exc}") from exc + # 只回退到内存默认值,不覆盖磁盘文件 + return _default_payload() data = _default_payload() - path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + _atomic_write_json(path, data) return data @@ -107,7 +133,8 @@ def load_host_workspace_catalog( config_path: Optional[Union[str, Path]] = None, ) -> Dict[str, Any]: cfg_path = _resolve_config_path(config_path) - payload = _ensure_config_file(cfg_path) + with _HOST_WORKSPACE_LOCK: + payload = _ensure_config_file(cfg_path, strict=False) raw_workspaces = payload.get("workspaces") if not isinstance(raw_workspaces, list): raw_workspaces = [] @@ -174,56 +201,56 @@ def create_host_workspace( config_path: Optional[Union[str, Path]] = None, ) -> Dict[str, Any]: cfg_path = _resolve_config_path(config_path) - payload = _ensure_config_file(cfg_path) + with _HOST_WORKSPACE_LOCK: + payload = _ensure_config_file(cfg_path, strict=True) - raw_workspaces = payload.get("workspaces") - if not isinstance(raw_workspaces, list): - raw_workspaces = [] + raw_workspaces = payload.get("workspaces") + if not isinstance(raw_workspaces, list): + raw_workspaces = [] - normalized_path = _normalize_workspace_path(path) - normalized_path.mkdir(parents=True, exist_ok=True) - target_path_str = str(normalized_path) + normalized_path = _normalize_workspace_path(path) + normalized_path.mkdir(parents=True, exist_ok=True) + target_path_str = str(normalized_path) - for idx, item in enumerate(raw_workspaces): - existing = _normalize_entry(item, idx) - if not existing: - continue - if existing.get("path") == target_path_str: - # 已存在相同路径则直接返回 - return { - "created": False, - "workspace": existing, - "catalog": load_host_workspace_catalog(config_path=cfg_path), - } + for idx, item in enumerate(raw_workspaces): + existing = _normalize_entry(item, idx) + if not existing: + continue + if existing.get("path") == target_path_str: + # 已存在相同路径则直接返回 + return { + "created": False, + "workspace": existing, + "catalog": load_host_workspace_catalog(config_path=cfg_path), + } - clean_label = str(label or "").strip() - base_id_seed = clean_label or normalized_path.name or "workspace" - base_id = _slugify_workspace_id(base_id_seed) - existing_ids = { - _normalize_workspace_id(item.get("workspace_id") or item.get("id"), i) - for i, item in enumerate(raw_workspaces) - if isinstance(item, dict) - } - workspace_id = base_id - suffix = 2 - while workspace_id in existing_ids: - workspace_id = f"{base_id}-{suffix}" - suffix += 1 + clean_label = str(label or "").strip() + base_id_seed = clean_label or normalized_path.name or "workspace" + base_id = _slugify_workspace_id(base_id_seed) + existing_ids = { + _normalize_workspace_id(item.get("workspace_id") or item.get("id"), i) + for i, item in enumerate(raw_workspaces) + if isinstance(item, dict) + } + workspace_id = base_id + suffix = 2 + while workspace_id in existing_ids: + workspace_id = f"{base_id}-{suffix}" + suffix += 1 - workspace = { - "workspace_id": workspace_id, - "label": clean_label or workspace_id, - "path": target_path_str, - } - raw_workspaces.append(workspace) - payload["workspaces"] = raw_workspaces + workspace = { + "workspace_id": workspace_id, + "label": clean_label or workspace_id, + "path": target_path_str, + } + raw_workspaces.append(workspace) + payload["workspaces"] = raw_workspaces - default_id = str(payload.get("default_workspace_id") or "").strip() - if set_default or not default_id: - payload["default_workspace_id"] = workspace_id + default_id = str(payload.get("default_workspace_id") or "").strip() + if set_default or not default_id: + payload["default_workspace_id"] = workspace_id - cfg_path.parent.mkdir(parents=True, exist_ok=True) - cfg_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + _atomic_write_json(cfg_path, payload) return { "created": True, diff --git a/modules/mcp_client_manager.py b/modules/mcp_client_manager.py index 54bf2ad..8f35b30 100644 --- a/modules/mcp_client_manager.py +++ b/modules/mcp_client_manager.py @@ -211,7 +211,12 @@ class _StdioMCPClient: if not use_container: env = dict(os.environ) env.update(env_raw) - return [command_raw, *args_raw], cwd_raw, env + resolved_cwd = cwd_raw + if not resolved_cwd and session is not None: + workspace_hint = self._normalize_workspace_path(getattr(session, "workspace_path", "")) + if workspace_hint: + resolved_cwd = workspace_hint + return [command_raw, *args_raw], resolved_cwd, env container_name = str(getattr(session, "container_name", "") or "").strip() if not container_name: @@ -731,6 +736,7 @@ class MCPClientManager: self.registry = registry self.protocol_version = str(protocol_version or MCP_PROTOCOL_VERSION) self.container_session = container_session + self._container_session_signature = self._compute_session_signature(container_session) self._latest_alias_map: Dict[str, MCPToolBinding] = {} self._client_pool: Dict[str, MCPClientPoolEntry] = {} self._pool_lock = threading.RLock() @@ -741,11 +747,27 @@ class MCPClientManager: except Exception: pass + @staticmethod + def _compute_session_signature(session: Optional["ContainerHandle"]) -> Optional[Tuple[str, str, str, str, str]]: + if not session: + return None + return ( + str(getattr(session, "mode", "") or ""), + str(getattr(session, "workspace_path", "") or ""), + str(getattr(session, "mount_path", "") or ""), + str(getattr(session, "container_name", "") or ""), + str(getattr(session, "sandbox_bin", "") or ""), + ) + def set_container_session(self, session: Optional["ContainerHandle"]) -> None: - if session is self.container_session: + next_signature = self._compute_session_signature(session) + if next_signature == self._container_session_signature: + # 引用对象可能相同(甚至被原地修改),这里仍刷新引用,便于后续读取最新字段。 + self.container_session = session return self.close_all_clients() self.container_session = session + self._container_session_signature = next_signature @staticmethod def _server_signature(server: Dict[str, Any]) -> str: diff --git a/modules/personalization_manager.py b/modules/personalization_manager.py index bd16232..337bec2 100644 --- a/modules/personalization_manager.py +++ b/modules/personalization_manager.py @@ -75,7 +75,7 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = { "shallow_compress_trigger_tool_calls_interval": None, "shallow_compress_keep_user_turn_tools": None, # 保留最近N次用户输入后的工具不压缩(默认3) "deep_compress_trigger_tokens": None, - "silent_tool_disable": False, # 禁用工具时不向模型插入提示 + "silent_tool_disable": True, # 禁用工具时不向模型插入提示(默认开启) "enhanced_tool_display": True, # 增强工具显示 "versioning_restore_mode": "overwrite", # 版本回溯模式固定为 overwrite "agents_md_auto_inject": False, # AGENTS.md 自动注入开关 diff --git a/modules/search_engine.py b/modules/search_engine.py index 356ed9a..bccf932 100644 --- a/modules/search_engine.py +++ b/modules/search_engine.py @@ -4,16 +4,16 @@ import httpx import json from typing import Dict, Optional, Any, List from datetime import datetime +from pathlib import Path import re try: - from config import TAVILY_API_KEY, SEARCH_MAX_RESULTS, OUTPUT_FORMATS + from config import TAVILY_API_KEY, SEARCH_MAX_RESULTS, OUTPUT_FORMATS, DATA_DIR except ImportError: import sys - from pathlib import Path project_root = Path(__file__).resolve().parents[1] if str(project_root) not in sys.path: sys.path.insert(0, str(project_root)) - from config import TAVILY_API_KEY, SEARCH_MAX_RESULTS, OUTPUT_FORMATS + from config import TAVILY_API_KEY, SEARCH_MAX_RESULTS, OUTPUT_FORMATS, DATA_DIR class SearchEngine: def __init__(self): @@ -286,19 +286,16 @@ class SearchEngine: timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"search_{timestamp}.json" - file_path = f"./data/searches/{filename}" - - # 确保目录存在 - import os - os.makedirs(os.path.dirname(file_path), exist_ok=True) + file_path = Path(DATA_DIR).expanduser().resolve() / "searches" / filename + file_path.parent.mkdir(parents=True, exist_ok=True) # 保存结果 - with open(file_path, 'w', encoding='utf-8') as f: + with file_path.open('w', encoding='utf-8') as f: json.dump(results, f, ensure_ascii=False, indent=2) print(f"{OUTPUT_FORMATS['file']} 搜索结果已保存到: {file_path}") - return file_path + return str(file_path) def load_results(self, filename: str) -> Optional[Dict]: """ @@ -310,10 +307,10 @@ class SearchEngine: Returns: 搜索结果字典或None """ - file_path = f"./data/searches/{filename}" + file_path = Path(DATA_DIR).expanduser().resolve() / "searches" / filename try: - with open(file_path, 'r', encoding='utf-8') as f: + with file_path.open('r', encoding='utf-8') as f: return json.load(f) except FileNotFoundError: print(f"{OUTPUT_FORMATS['error']} 文件不存在: {file_path}") diff --git a/modules/sub_agent_manager.py b/modules/sub_agent_manager.py index 977e248..488d7bb 100644 --- a/modules/sub_agent_manager.py +++ b/modules/sub_agent_manager.py @@ -6,6 +6,7 @@ import time import uuid import os import shutil +import signal from pathlib import Path, PurePosixPath from typing import Dict, List, Optional, Any, Tuple, TYPE_CHECKING @@ -62,6 +63,10 @@ class SubAgentManager: self.conversation_agents: Dict[str, List[int]] = {} self.processes: Dict[str, subprocess.Popen] = {} # task_id -> Popen对象 self._load_state() + try: + self.reconcile_task_states() + except Exception: + pass # ------------------------------------------------------------------ # 公共方法 @@ -267,6 +272,7 @@ class SubAgentManager: task_id = task["task_id"] process = self.processes.get(task_id) + pid = task.get("pid") if process and process.poll() is None: # 进程还在运行,终止它 @@ -279,17 +285,17 @@ class SubAgentManager: process.wait() except Exception as exc: return {"success": False, "error": f"终止进程失败: {exc}"} + elif self._is_pid_alive(pid): + if not self._terminate_pid(pid): + return {"success": False, "error": f"终止进程失败: PID {pid} 无法停止"} - task["status"] = "terminated" - task["updated_at"] = time.time() - task["notified"] = True - task["final_result"] = { - "success": False, - "status": "terminated", - "task_id": task_id, - "agent_id": task.get("agent_id"), - "message": "子智能体已被强制关闭。", - } + self.processes.pop(task_id, None) + self._mark_task_terminated( + task, + message="子智能体已被强制关闭。", + system_message=f"🛑 子智能体{task.get('agent_id')} 已被手动关闭。", + notified=True, + ) self._save_state() return { @@ -662,6 +668,155 @@ class SubAgentManager: if migrated: self._save_state() + @staticmethod + def _coerce_pid(value: Any) -> Optional[int]: + try: + pid = int(value) + return pid if pid > 0 else None + except (TypeError, ValueError): + return None + + def _is_pid_alive(self, pid: Any) -> bool: + normalized = self._coerce_pid(pid) + if not normalized: + return False + try: + os.kill(normalized, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + except Exception: + return False + return True + + def _terminate_pid(self, pid: Any) -> bool: + normalized = self._coerce_pid(pid) + if not normalized: + return False + try: + os.killpg(normalized, signal.SIGTERM) + except Exception: + try: + os.kill(normalized, signal.SIGTERM) + except Exception: + return False + deadline = time.time() + 5 + while time.time() < deadline: + if not self._is_pid_alive(normalized): + return True + time.sleep(0.1) + try: + os.killpg(normalized, signal.SIGKILL) + except Exception: + try: + os.kill(normalized, signal.SIGKILL) + except Exception: + return not self._is_pid_alive(normalized) + return not self._is_pid_alive(normalized) + + def _mark_task_terminated( + self, + task: Dict[str, Any], + *, + message: str, + system_message: Optional[str] = None, + notified: bool = False, + ) -> Dict[str, Any]: + task["status"] = "terminated" + task["updated_at"] = time.time() + if notified: + task["notified"] = True + result = { + "success": False, + "status": "terminated", + "task_id": task.get("task_id"), + "agent_id": task.get("agent_id"), + "message": message, + "system_message": system_message or message, + } + task["final_result"] = result + return result + + def _should_force_cleanup_stale_task(self, task: Dict[str, Any]) -> bool: + try: + created_at = float(task.get("created_at") or 0) + except (TypeError, ValueError): + created_at = 0 + if created_at <= 0: + return False + timeout_seconds = int(task.get("timeout_seconds") or SUB_AGENT_DEFAULT_TIMEOUT or 0) + timeout_seconds = max(timeout_seconds, 1) + grace_seconds = 120 + elapsed = time.time() - created_at + if elapsed <= (timeout_seconds + grace_seconds): + return False + output_file = Path(task.get("output_file", "")) + if output_file.exists(): + return False + progress_file = Path(task.get("progress_file", "")) + if progress_file.exists(): + try: + stale_span = time.time() - progress_file.stat().st_mtime + if stale_span <= grace_seconds: + return False + except Exception: + pass + return True + + def _refresh_task_runtime_state(self, task: Dict[str, Any]) -> Dict[str, Any]: + """刷新单个任务运行态(用于进程句柄丢失/重启后的兜底修正)。""" + status = task.get("status") + if status in TERMINAL_STATUSES.union({"terminated"}): + return {"status": status, "task_id": task.get("task_id")} + + task_id = task.get("task_id") + process = self.processes.get(task_id) if task_id else None + if process: + poll_result = process.poll() + if poll_result is None: + return {"status": "running", "task_id": task_id} + return self._check_task_status(task) + + output_file = Path(task.get("output_file", "")) + if output_file.exists(): + return self._check_task_status(task) + + pid = task.get("pid") + if self._is_pid_alive(pid): + if self._should_force_cleanup_stale_task(task): + return self._mark_task_terminated( + task, + message="子智能体疑似僵尸任务,已超时自动清理运行状态。", + system_message="⚠️ 子智能体长时间未结束,系统已自动清理运行状态。", + notified=True, + ) + return {"status": "running", "task_id": task_id} + + return self._mark_task_terminated( + task, + message="检测到子智能体进程已退出,已自动清理运行状态。", + system_message="⚠️ 子智能体进程异常退出,系统已自动清理运行状态。", + notified=True, + ) + + def reconcile_task_states(self, conversation_id: Optional[str] = None) -> int: + """修正运行态任务状态,返回修正条目数。""" + changed = 0 + for task in self.tasks.values(): + if not isinstance(task, dict): + continue + if conversation_id and task.get("conversation_id") != conversation_id: + continue + before_status = task.get("status") + before_notified = task.get("notified") + self._refresh_task_runtime_state(task) + if task.get("status") != before_status or task.get("notified") != before_notified: + changed += 1 + if changed: + self._save_state() + return changed + def _save_state(self): payload = { "tasks": self.tasks, @@ -674,6 +829,7 @@ class SubAgentManager: return f"sub_{agent_id}_{int(time.time())}_{suffix}" def _active_task_count(self, conversation_id: Optional[str] = None) -> int: + self.reconcile_task_states(conversation_id=conversation_id) active = [ t for t in self.tasks.values() if t.get("status") in {"pending", "running"} @@ -729,6 +885,7 @@ class SubAgentManager: return candidate def _select_task(self, task_id: Optional[str], agent_id: Optional[int]) -> Optional[Dict]: + self.reconcile_task_states() if task_id: return self.tasks.get(task_id) @@ -761,6 +918,7 @@ class SubAgentManager: def poll_updates(self) -> List[Dict]: """检查运行中的子智能体任务,返回新完成的结果。""" updates: List[Dict] = [] + self.reconcile_task_states() pending_tasks = [ task for task in self.tasks.values() if task.get("status") not in TERMINAL_STATUSES.union({"terminated"}) @@ -1082,8 +1240,8 @@ class SubAgentManager: def get_overview(self, conversation_id: Optional[str] = None) -> List[Dict[str, Any]]: """返回子智能体任务概览,用于前端展示。""" + self.reconcile_task_states(conversation_id=conversation_id) overview: List[Dict[str, Any]] = [] - state_changed = False for task_id, task in self.tasks.items(): if conversation_id and task.get("conversation_id") != conversation_id: continue @@ -1103,30 +1261,6 @@ class SubAgentManager: "sub_conversation_id": task.get("sub_conversation_id"), } - # 运行中的任务检查进程状态 - if snapshot["status"] not in TERMINAL_STATUSES and snapshot["status"] != "terminated": - # 检查进程是否还在运行 - process = self.processes.get(task_id) - if process: - poll_result = process.poll() - if poll_result is not None: - # 进程已结束,检查输出 - status_result = self._check_task_status(task) - snapshot["status"] = status_result.get("status", "failed") - if status_result.get("status") in TERMINAL_STATUSES: - task["status"] = status_result["status"] - task["final_result"] = status_result - state_changed = True - else: - # 进程句柄丢失(重启后常见),尝试直接检查输出文件 - logger.debug("[SubAgentManager] 进程句柄缺失,尝试读取输出文件: %s", task_id) - status_result = self._check_task_status(task) - snapshot["status"] = status_result.get("status", snapshot["status"]) - if status_result.get("status") in TERMINAL_STATUSES: - task["status"] = status_result["status"] - task["final_result"] = status_result - state_changed = True - if snapshot["status"] in TERMINAL_STATUSES or snapshot["status"] == "terminated": # 已结束的任务带上最终结果/系统消息,方便前端展示 final_result = task.get("final_result") or {} @@ -1136,6 +1270,4 @@ class SubAgentManager: overview.append(snapshot) overview.sort(key=lambda item: item.get("created_at") or 0, reverse=True) - if state_changed: - self._save_state() return overview diff --git a/modules/versioning_manager.py b/modules/versioning_manager.py index f316aa2..e2040ab 100644 --- a/modules/versioning_manager.py +++ b/modules/versioning_manager.py @@ -27,6 +27,8 @@ class VersioningPaths: class ConversationVersioningManager: """Manage hidden git snapshots bound to a conversation.""" + TRACKING_MODE_WORKSPACE_AND_CONVERSATION = "workspace_and_conversation" + TRACKING_MODE_CONVERSATION_ONLY = "conversation_only" SYSTEM_AUTO_DIRS = ("compact_result", "skills", "user_upload") def __init__(self, project_path: Path | str, data_dir: Path | str, conversation_id: str): @@ -43,6 +45,13 @@ class ConversationVersioningManager: snapshots_dir=(self.data_dir / "save" / self.conversation_id / "snapshots").resolve(), ) + @classmethod + def normalize_tracking_mode(cls, mode: Optional[str]) -> str: + raw = str(mode or "").strip().lower() + if raw == cls.TRACKING_MODE_CONVERSATION_ONLY: + return cls.TRACKING_MODE_CONVERSATION_ONLY + return cls.TRACKING_MODE_WORKSPACE_AND_CONVERSATION + # ---------------------------- # low-level helpers # ---------------------------- @@ -151,16 +160,20 @@ class ConversationVersioningManager: return { "enabled": False, "mode": "overwrite", + "tracking_mode": self.TRACKING_MODE_WORKSPACE_AND_CONVERSATION, "last_commit": None, "last_input_seq": 0, "updated_at": None, } try: - return json.loads(self.paths.meta_path.read_text(encoding="utf-8")) or {} + payload = json.loads(self.paths.meta_path.read_text(encoding="utf-8")) or {} + payload["tracking_mode"] = self.normalize_tracking_mode(payload.get("tracking_mode")) + return payload except Exception: return { "enabled": False, "mode": "overwrite", + "tracking_mode": self.TRACKING_MODE_WORKSPACE_AND_CONVERSATION, "last_commit": None, "last_input_seq": 0, "updated_at": None, @@ -168,6 +181,7 @@ class ConversationVersioningManager: def save_meta(self, meta: Dict[str, Any]) -> Dict[str, Any]: payload = dict(meta or {}) + payload["tracking_mode"] = self.normalize_tracking_mode(payload.get("tracking_mode")) payload["updated_at"] = datetime.now().isoformat() self.paths.meta_path.parent.mkdir(parents=True, exist_ok=True) self.paths.meta_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") @@ -221,6 +235,7 @@ class ConversationVersioningManager: def _normalize_checkpoint_row(self, row: Dict[str, Any]) -> Dict[str, Any]: payload = dict(row or {}) + payload["tracking_mode"] = self.normalize_tracking_mode(payload.get("tracking_mode")) files = payload.get("files") if isinstance(files, list): normalized_files: List[Dict[str, Any]] = [] @@ -276,12 +291,22 @@ class ConversationVersioningManager: head = self._get_head_commit() return {"ok": True, "head": head} - def set_enabled(self, enabled: bool, mode: Optional[str] = None) -> Dict[str, Any]: + def set_enabled( + self, + enabled: bool, + mode: Optional[str] = None, + *, + tracking_mode: Optional[str] = None, + ) -> Dict[str, Any]: meta = self.load_meta() - if enabled: + normalized_tracking_mode = self.normalize_tracking_mode( + tracking_mode or meta.get("tracking_mode") + ) + if enabled and normalized_tracking_mode == self.TRACKING_MODE_WORKSPACE_AND_CONVERSATION: self.ensure_repo() meta["enabled"] = bool(enabled) meta["mode"] = "overwrite" + meta["tracking_mode"] = normalized_tracking_mode meta["last_commit"] = self._get_head_commit() saved = self.save_meta(meta) return saved @@ -291,17 +316,20 @@ class ConversationVersioningManager: *, workspace_path: Optional[str] = None, conversation_snapshot: Optional[Dict[str, Any]] = None, + tracking_mode: Optional[str] = None, ) -> Dict[str, Any]: """ Ensure a visible seq=0 checkpoint exists after enabling versioning. """ - self.ensure_repo() + normalized_tracking_mode = self.normalize_tracking_mode(tracking_mode) + if normalized_tracking_mode == self.TRACKING_MODE_WORKSPACE_AND_CONVERSATION: + self.ensure_repo() existing = self.get_checkpoint(0) if existing: return {"created": False, "row": existing, "reason": "already_exists"} current_head = self._get_head_commit() - if not current_head: + if normalized_tracking_mode == self.TRACKING_MODE_WORKSPACE_AND_CONVERSATION and not current_head: raise VersioningError("创建初始版本点失败:未获取到 commit") row = { @@ -319,23 +347,37 @@ class ConversationVersioningManager: "files": [], "changed": False, "run_status": "initial", + "tracking_mode": normalized_tracking_mode, } if isinstance(conversation_snapshot, dict): row["snapshot_file"] = self._write_snapshot(0, conversation_snapshot) self._append_input(row) meta = self.load_meta() + meta["tracking_mode"] = normalized_tracking_mode meta["last_commit"] = current_head if int(meta.get("last_input_seq") or 0) < 0: meta["last_input_seq"] = 0 self.save_meta(meta) return {"created": True, "row": row, "reason": "created"} - def ensure_baseline_for_first_input(self) -> Dict[str, Any]: + def ensure_baseline_for_first_input( + self, + *, + tracking_mode: Optional[str] = None, + ) -> Dict[str, Any]: """ Ensure the baseline commit reflects current workspace before first manual input checkpoint. This is intentionally hidden from checkpoint list (no seq row created). """ + normalized_tracking_mode = self.normalize_tracking_mode(tracking_mode) + if normalized_tracking_mode == self.TRACKING_MODE_CONVERSATION_ONLY: + return { + "created": False, + "skipped": True, + "reason": "conversation_only_mode", + "head": self._get_head_commit(), + } self.ensure_repo() meta = self.load_meta() if self.get_checkpoint(0): @@ -439,28 +481,37 @@ class ConversationVersioningManager: workspace_path: Optional[str] = None, conversation_snapshot: Optional[Dict[str, Any]] = None, run_status: Optional[str] = None, + tracking_mode: Optional[str] = None, ) -> Dict[str, Any]: - self.ensure_repo() - previous_head = self._get_head_commit() - self._stage_all() - _, staged_numstat, _ = self._run_git(["diff", "--cached", "--numstat"], check=False) - has_changes = bool((staged_numstat or "").strip()) - if has_changes: - next_seq = int(self.load_meta().get("last_input_seq") or 0) + 1 - title = (message or "").strip().splitlines()[0][:72] if message else "" - commit_msg = f"input#{next_seq} {title}".strip() - self._run_git(["commit", "-m", commit_msg]) - current_head = self._get_head_commit() - if not current_head: - raise VersioningError("创建版本快照失败:未获取到 commit") + meta = self.load_meta() + normalized_tracking_mode = self.normalize_tracking_mode( + tracking_mode or meta.get("tracking_mode") + ) + previous_head: Optional[str] = self._get_head_commit() + current_head: Optional[str] = previous_head + + if normalized_tracking_mode == self.TRACKING_MODE_WORKSPACE_AND_CONVERSATION: + self.ensure_repo() + previous_head = self._get_head_commit() + self._stage_all() + _, staged_numstat, _ = self._run_git(["diff", "--cached", "--numstat"], check=False) + has_changes = bool((staged_numstat or "").strip()) + if has_changes: + next_seq = int(meta.get("last_input_seq") or 0) + 1 + title = (message or "").strip().splitlines()[0][:72] if message else "" + commit_msg = f"input#{next_seq} {title}".strip() + self._run_git(["commit", "-m", commit_msg]) + current_head = self._get_head_commit() + if not current_head: + raise VersioningError("创建版本快照失败:未获取到 commit") diff_summary = ( self._build_diff_summary(previous_head, current_head) - if current_head != previous_head + if normalized_tracking_mode == self.TRACKING_MODE_WORKSPACE_AND_CONVERSATION + and current_head != previous_head else {"insertions": 0, "deletions": 0, "files_changed": 0, "files": []} ) - meta = self.load_meta() seq = int(meta.get("last_input_seq") or 0) + 1 row = { "seq": seq, @@ -475,7 +526,11 @@ class ConversationVersioningManager: "deletions": int(diff_summary.get("deletions") or 0), "files_changed": int(diff_summary.get("files_changed") or 0), "files": diff_summary.get("files") or [], - "changed": bool(current_head != previous_head), + "changed": bool( + normalized_tracking_mode == self.TRACKING_MODE_WORKSPACE_AND_CONVERSATION + and current_head != previous_head + ), + "tracking_mode": normalized_tracking_mode, } if isinstance(conversation_snapshot, dict): row["snapshot_file"] = self._write_snapshot(seq, conversation_snapshot) @@ -484,6 +539,7 @@ class ConversationVersioningManager: self._append_input(row) meta["last_input_seq"] = seq meta["last_commit"] = current_head + meta["tracking_mode"] = normalized_tracking_mode self.save_meta(meta) return row @@ -686,7 +742,22 @@ class ConversationVersioningManager: self.save_meta(meta) return {"success": True, "commit": commit} - def detect_mismatch(self, latest_commit: Optional[str], expected_workspace_path: Optional[str] = None) -> Dict[str, Any]: + def detect_mismatch( + self, + latest_commit: Optional[str], + expected_workspace_path: Optional[str] = None, + *, + tracking_mode: Optional[str] = None, + ) -> Dict[str, Any]: + normalized_tracking_mode = self.normalize_tracking_mode(tracking_mode) + if normalized_tracking_mode == self.TRACKING_MODE_CONVERSATION_ONLY: + return { + "has_repo": (self.paths.git_dir / "HEAD").exists(), + "head": self._get_head_commit(), + "dirty": False, + "mismatch": False, + "workspace_matched": True, + } expected = str(expected_workspace_path or "").strip() current = str(self.project_path) workspace_matched = True if not expected else (Path(expected).expanduser().resolve() == self.project_path) diff --git a/prompts/main_system.txt b/prompts/main_system.txt index 81ccfb3..0ebc36f 100644 --- a/prompts/main_system.txt +++ b/prompts/main_system.txt @@ -87,7 +87,7 @@ - `create_file`:创建空文件(禁止在根目录创建,需先建子目录) - `write_file`:写入文件(`append` 控制覆盖/追加) - `read_file`:读取文件(支持 `read`/`search`/`extract` 三种模式) -- `edit_file`:精确字符串替换 +- `edit_file`:精确字符串替换(`replace_all` 必填,必须显式传入 `true/false`;`old_string` 建议至少 3 行以提升定位稳定性。需要批量替换的场景可以单行或不足一行) - `delete_file` / `rename_file` / `create_folder`:文件管理 **注意**:超大文件用 `search` 定位 + `extract` 抽取,避免全文读取。 diff --git a/prompts/main_system_qwenvl.txt b/prompts/main_system_qwenvl.txt index d46e047..6280840 100644 --- a/prompts/main_system_qwenvl.txt +++ b/prompts/main_system_qwenvl.txt @@ -87,7 +87,7 @@ - `create_file`:创建空文件(禁止在根目录创建,需先建子目录) - `write_file`:写入文件(`append` 控制覆盖/追加) - `read_file`:读取文件(支持 `read`/`search`/`extract` 三种模式) -- `edit_file`:精确字符串替换 +- `edit_file`:精确字符串替换(`replace_all` 必填,必须显式传入 `true/false`;`old_string` 建议至少 3 行以提升定位稳定性。需要批量替换的场景可以单行或不足一行) - `delete_file` / `rename_file` / `create_folder`:文件管理 **注意**:超大文件用 `search` 定位 + `extract` 抽取,避免全文读取。 diff --git a/scripts/api_tool_role_experiment.py b/scripts/api_tool_role_experiment.py index ca018fc..c33ce8f 100644 --- a/scripts/api_tool_role_experiment.py +++ b/scripts/api_tool_role_experiment.py @@ -79,10 +79,14 @@ def minimal_tool_definitions() -> List[Dict[str, Any]]: "type": "object", "properties": { "file_path": {"type": "string", "description": "目标文件的相对路径"}, - "old_string": {"type": "string", "description": "需要替换的原文"}, - "new_string": {"type": "string", "description": "替换后的新内容"} + "old_string": { + "type": "string", + "description": "需要替换的原文(建议至少3行提升定位稳定性。需要批量替换的场景可以单行或不足一行)" + }, + "new_string": {"type": "string", "description": "替换后的新内容"}, + "replace_all": {"type": "boolean", "description": "是否替换全部匹配(必填)"} }, - "required": ["file_path", "old_string", "new_string"] + "required": ["file_path", "old_string", "new_string", "replace_all"] } } } diff --git a/server/chat_flow_task_main.py b/server/chat_flow_task_main.py index 5ffeff1..35bc6db 100644 --- a/server/chat_flow_task_main.py +++ b/server/chat_flow_task_main.py @@ -159,8 +159,6 @@ def _prepare_hidden_versioning_baseline_for_first_input( ) -> None: """Ensure first-run baseline is committed (hidden, no checkpoint row).""" try: - if not bool(getattr(web_terminal, "username", None) == "host"): - return if _should_skip_versioning_for_message( message=message, auto_user_message_event=auto_user_message_event, @@ -173,14 +171,23 @@ def _prepare_hidden_versioning_baseline_for_first_input( versioning_meta = ((conv_data.get("metadata") or {}).get("versioning") or {}) if not bool(versioning_meta.get("enabled", False)): return + tracking_mode = ConversationVersioningManager.normalize_tracking_mode( + versioning_meta.get("tracking_mode") + ) + if ( + tracking_mode == ConversationVersioningManager.TRACKING_MODE_WORKSPACE_AND_CONVERSATION + and not bool(getattr(web_terminal, "username", None) == "host") + ): + return manager = ConversationVersioningManager( project_path=workspace.project_path, data_dir=workspace.data_dir, conversation_id=conversation_id, ) - baseline = manager.ensure_baseline_for_first_input() + baseline = manager.ensure_baseline_for_first_input(tracking_mode=tracking_mode) debug_log( f"[Versioning][Baseline] conv={conversation_id} " + f"tracking_mode={tracking_mode} " f"created={baseline.get('created')} skipped={baseline.get('skipped')} " f"reason={baseline.get('reason')} head={baseline.get('head')}" ) @@ -200,10 +207,8 @@ def _record_hidden_versioning_checkpoint_after_run( auto_user_message_event: bool, run_status: str = "completed", ) -> None: - """Host-only hidden snapshot after current manual user input run finished.""" + """Hidden snapshot after current manual user input run finished.""" try: - if not bool(getattr(web_terminal, "username", None) == "host"): - return if _should_skip_versioning_for_message( message=message, auto_user_message_event=auto_user_message_event, @@ -216,6 +221,14 @@ def _record_hidden_versioning_checkpoint_after_run( versioning_meta = ((conv_data.get("metadata") or {}).get("versioning") or {}) if not bool(versioning_meta.get("enabled", False)): return + tracking_mode = ConversationVersioningManager.normalize_tracking_mode( + versioning_meta.get("tracking_mode") + ) + if ( + tracking_mode == ConversationVersioningManager.TRACKING_MODE_WORKSPACE_AND_CONVERSATION + and not bool(getattr(web_terminal, "username", None) == "host") + ): + return snapshot_messages = conv_data.get("messages") or [] snapshot_payload = { "conversation_id": conversation_id, @@ -237,9 +250,11 @@ def _record_hidden_versioning_checkpoint_after_run( workspace_path=str(workspace.project_path), conversation_snapshot=snapshot_payload, run_status=str(run_status or "completed"), + tracking_mode=tracking_mode, ) debug_log( f"[Versioning][Checkpoint] conv={conversation_id} seq={row.get('seq')} " + f"tracking_mode={tracking_mode} " f"msg_index={message_index} snapshot_messages={len(snapshot_messages)} " f"commit={row.get('commit')} changed={row.get('changed')} status={row.get('run_status')}" ) @@ -249,6 +264,7 @@ def _record_hidden_versioning_checkpoint_after_run( "versioning": { "enabled": True, "mode": "overwrite", + "tracking_mode": tracking_mode, "last_commit": row.get("commit"), "last_input_seq": int(row.get("seq") or 0), "updated_at": datetime.now().isoformat(), @@ -1350,6 +1366,10 @@ async def handle_task_with_sender( bg_manager = getattr(web_terminal, "background_command_manager", None) has_running_background_commands = False if manager: + try: + manager.reconcile_task_states(conversation_id=conversation_id) + except Exception as exc: + debug_log(f"[SubAgent] reconcile_task_states failed: {exc}") if not hasattr(web_terminal, "_announced_sub_agent_tasks"): web_terminal._announced_sub_agent_tasks = set() running_tasks = [ @@ -1396,6 +1416,10 @@ async def handle_task_with_sender( # 检查是否有后台 run_command 或待通知任务 if bg_manager and conversation_id: + try: + bg_manager.reconcile_stale_records(conversation_id=conversation_id) + except Exception as exc: + debug_log(f"[BgCmdDebug] reconcile_stale_records failed: {exc}") waiting_items = bg_manager.list_waiting_items(conversation_id) if waiting_items: has_running_background_commands = True @@ -1420,6 +1444,17 @@ async def handle_task_with_sender( socketio.start_background_task(run_bg_poll) has_running_completion_jobs = has_running_sub_agents or has_running_background_commands + pending_runtime_guidance_messages: List[str] = [] + try: + from .tasks import task_manager + + pending_runtime_guidance_messages = task_manager.consume_runtime_guidance_messages( + username=username, + task_id=client_sid, + ) + except Exception as exc: + debug_log(f"[RuntimeGuidance] 读取剩余引导消息失败: {exc}") + pending_runtime_guidance_messages = [] # 发送完成事件(如果有后台完成任务在运行,前端会保持等待状态) if not has_running_completion_jobs: @@ -1434,4 +1469,5 @@ async def handle_task_with_sender( # 沿用子智能体字段,确保前端直接走已验证通路 'has_running_sub_agents': has_running_completion_jobs, 'has_running_background_commands': has_running_background_commands, + 'pending_runtime_guidance_messages': pending_runtime_guidance_messages, }) diff --git a/server/chat_flow_tool_loop.py b/server/chat_flow_tool_loop.py index 2a04a4a..c52e566 100644 --- a/server/chat_flow_tool_loop.py +++ b/server/chat_flow_tool_loop.py @@ -45,10 +45,26 @@ def _build_tool_approval_preview(web_terminal, function_name: str, arguments: Di file_path = args.get("file_path") old_string = args.get("old_string") new_string = args.get("new_string") + replace_all_provided = "replace_all" in args + replace_all = args.get("replace_all") if replace_all_provided else None preview["file_path"] = file_path + preview["replace_all"] = replace_all if not file_path: preview["summary"] = "缺少 file_path" return preview + if old_string is None or new_string is None: + preview["summary"] = "缺少 old_string/new_string" + return preview + if not replace_all_provided: + preview["summary"] = "缺少 replace_all(必须显式传入 true 或 false)" + return preview + if not isinstance(replace_all, bool): + preview["summary"] = "replace_all 必须是 true 或 false" + return preview + old_text = str(old_string or "") + short_old_text_notice = len(old_text.splitlines()) < 3 + if short_old_text_notice: + preview["notice"] = "提示:old_string 少于3行,允许继续执行;需要批量替换的场景可以单行或不足一行" try: valid, err, full_path = web_terminal.file_manager._validate_path(str(file_path)) if not valid or full_path is None: @@ -60,7 +76,6 @@ def _build_tool_approval_preview(web_terminal, function_name: str, arguments: Di preview["summary"] = "目标文件不存在,无法生成上下文预览" return preview content = full_path.read_text(encoding="utf-8", errors="ignore") - old_text = str(old_string or "") new_text = str(new_string or "") old_lines = old_text.splitlines() new_lines = new_text.splitlines() @@ -82,7 +97,8 @@ def _build_tool_approval_preview(web_terminal, function_name: str, arguments: Di "old_start_line": start_line_no, "old_end_line": end_line_no, } - preview["summary"] = f"编辑 {file_path} 第 {start_line_no}-{end_line_no} 行" + mode_text = "全部匹配" if replace_all is True else "首个匹配" + preview["summary"] = f"编辑 {file_path} 第 {start_line_no}-{end_line_no} 行({mode_text})" else: preview["edit_context"] = { "before": [], @@ -93,6 +109,8 @@ def _build_tool_approval_preview(web_terminal, function_name: str, arguments: Di "old_end_line": None, } preview["summary"] = "未在文件中定位到 old_string,显示原始替换内容" + if short_old_text_notice: + preview["summary"] = f"{preview['summary']}(old_string 少于3行,已告知并继续)" except Exception as exc: preview["summary"] = f"生成编辑预览失败: {exc}" return preview @@ -936,6 +954,52 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie if tool_failed: mark_force_thinking(web_terminal, reason=f"{function_name}_failed") + # 运行期“引导对话”:需要等待同一轮全部工具执行结束后再注入, + # 避免一轮内并行/多工具调用时过早插入。 + try: + from .tasks import task_manager + + runtime_guidance_original = task_manager.pop_runtime_guidance_for_injection( + username=username, task_id=client_sid + ) + except Exception as exc: + runtime_guidance_original = None + debug_log(f"[RuntimeGuidance] 读取引导队列失败: {exc}") + + if runtime_guidance_original: + runtime_guidance_text = runtime_guidance_original + runtime_guidance_meta = { + "runtime_guidance": True, + "runtime_guidance_original": runtime_guidance_original, + } + try: + web_terminal.context_manager.add_conversation( + "user", + runtime_guidance_text, + metadata=runtime_guidance_meta, + ) + except Exception as exc: + debug_log(f"[RuntimeGuidance] 持久化引导消息失败: {exc}") + messages.append( + { + "role": "user", + "content": runtime_guidance_text, + "metadata": runtime_guidance_meta, + } + ) + sender( + "user_message", + { + "message": runtime_guidance_text, + "conversation_id": conversation_id, + "runtime_guidance": True, + "runtime_guidance_original": runtime_guidance_original, + }, + ) + debug_log( + "[RuntimeGuidance] 已在工具结果批次结束后注入引导消息 " + f"task_id={client_sid}" + ) web_terminal._tool_loop_active = previous_tool_loop_active return {"stopped": False, "last_tool_call_time": last_tool_call_time} diff --git a/server/conversation.py b/server/conversation.py index ea174f9..35ede1c 100644 --- a/server/conversation.py +++ b/server/conversation.py @@ -97,6 +97,10 @@ def _terminate_running_sub_agents(terminal: WebTerminal, reason: str = "") -> in current_conv_id = getattr(getattr(terminal, "context_manager", None), "current_conversation_id", None) if not current_conv_id: return 0 + try: + manager.reconcile_task_states(conversation_id=current_conv_id) + except Exception: + pass running_tasks = [ task for task in manager.tasks.values() if task.get("status") not in TERMINAL_STATUSES.union({"terminated"}) @@ -156,6 +160,17 @@ def _normalize_conv_id(conversation_id: str) -> str: return conv if conv.startswith("conv_") else f"conv_{conv}" +def _normalize_versioning_tracking_mode(value: Optional[str]) -> str: + return ConversationVersioningManager.normalize_tracking_mode(value) + + +def _can_use_versioning_scope(username: str, tracking_mode: str) -> bool: + normalized_tracking_mode = _normalize_versioning_tracking_mode(tracking_mode) + return _is_host_mode_request(username) or ( + normalized_tracking_mode == ConversationVersioningManager.TRACKING_MODE_CONVERSATION_ONLY + ) + + def _is_host_mode_request(username: str) -> bool: return bool(session.get("host_mode")) or (username == "host") @@ -169,6 +184,46 @@ def _get_conv_versioning_manager(workspace: UserWorkspace, conversation_id: str) ) +def _sanitize_scope_component(value: Any, default: str = "default") -> str: + raw = str(value or "").strip() + if not raw: + return default + sanitized = re.sub(r"[^0-9A-Za-z._-]+", "_", raw).strip("._-") + if not sanitized: + sanitized = default + return sanitized[:120] + + +def _resolve_input_draft_path(workspace: UserWorkspace, username: str) -> Tuple[Path, str]: + base_dir = Path(workspace.data_dir).expanduser().resolve() / "composer_drafts" + if _is_host_mode_request(username): + workspace_id = session.get("host_workspace_id") or session.get("workspace_id") or "default" + scope = f"host_workspace:{workspace_id}" + filename = f"{_sanitize_scope_component(workspace_id)}.json" + return (base_dir / "host" / filename).resolve(), scope + safe_user = _sanitize_scope_component(username or "user", default="user") + scope = f"user:{safe_user}" + # docker 模式下 data_dir 本身已按用户隔离,这里固定单文件即可。 + return (base_dir / "docker" / "draft.json").resolve(), scope + + +def _read_input_draft_payload(path: Path) -> Dict[str, Any]: + if not path.exists(): + return {} + try: + raw = json.loads(path.read_text(encoding="utf-8")) or {} + return raw if isinstance(raw, dict) else {} + except Exception: + return {} + + +def _atomic_write_input_draft(path: Path, payload: Dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + tmp.replace(path) + + def _get_conversation_versioning_meta(terminal: WebTerminal, conversation_id: str) -> Dict[str, Any]: normalized = _normalize_conv_id(conversation_id) data = terminal.context_manager.conversation_manager.load_conversation(normalized) or {} @@ -177,8 +232,15 @@ def _get_conversation_versioning_meta(terminal: WebTerminal, conversation_id: st if not isinstance(versioning, dict): versioning = {} enabled = bool(versioning.get("enabled", False)) + tracking_mode = _normalize_versioning_tracking_mode(versioning.get("tracking_mode")) mode = "overwrite" - return {"enabled": enabled, "mode": mode, "conversation_id": normalized, "metadata": meta} + return { + "enabled": enabled, + "mode": mode, + "tracking_mode": tracking_mode, + "conversation_id": normalized, + "metadata": meta, + } def _update_conversation_versioning_meta( @@ -187,14 +249,17 @@ def _update_conversation_versioning_meta( *, enabled: bool, mode: str, + tracking_mode: Optional[str] = None, last_commit: Optional[str] = None, last_input_seq: Optional[int] = None, ) -> bool: normalized = _normalize_conv_id(conversation_id) + normalized_tracking_mode = _normalize_versioning_tracking_mode(tracking_mode) payload: Dict[str, Any] = { "versioning": { "enabled": bool(enabled), "mode": "overwrite", + "tracking_mode": normalized_tracking_mode, "updated_at": datetime.now().isoformat(), } } @@ -205,6 +270,79 @@ def _update_conversation_versioning_meta( return terminal.context_manager.conversation_manager.update_conversation_metadata(normalized, payload) +@conversation_bp.route('/api/input-draft', methods=['GET']) +@api_login_required +@with_terminal +def get_input_draft(terminal: WebTerminal, workspace: UserWorkspace, username: str): + del terminal + try: + path, scope = _resolve_input_draft_path(workspace, username) + payload = _read_input_draft_payload(path) + content = payload.get("content") + updated_at = payload.get("updated_at") + return jsonify({ + "success": True, + "data": { + "content": content if isinstance(content, str) else "", + "updated_at": str(updated_at or ""), + "scope": scope, + } + }) + except Exception as exc: + return jsonify({"success": False, "error": f"读取输入草稿失败: {exc}"}), 500 + + +@conversation_bp.route('/api/input-draft', methods=['POST', 'PUT']) +@api_login_required +@with_terminal +def upsert_input_draft(terminal: WebTerminal, workspace: UserWorkspace, username: str): + del terminal + try: + body = request.get_json(silent=True) or {} + content = body.get("content") if isinstance(body, dict) else "" + if content is None: + content = "" + if not isinstance(content, str): + content = str(content) + if len(content) > 40000: + return jsonify({"success": False, "error": "输入草稿过长(最大 40000 字符)"}), 400 + + path, scope = _resolve_input_draft_path(workspace, username) + if content == "": + if path.exists(): + try: + path.unlink() + except Exception: + pass + return jsonify({ + "success": True, + "data": { + "saved": True, + "cleared": True, + "scope": scope, + "updated_at": datetime.now().isoformat(), + } + }) + + payload = { + "content": content, + "updated_at": datetime.now().isoformat(), + "scope": scope, + } + _atomic_write_input_draft(path, payload) + return jsonify({ + "success": True, + "data": { + "saved": True, + "cleared": False, + "scope": scope, + "updated_at": payload["updated_at"], + } + }) + except Exception as exc: + return jsonify({"success": False, "error": f"保存输入草稿失败: {exc}"}), 500 + + # === 背景生成对话标题(从 app_legacy 拆分) === @conversation_bp.route('/api/conversations', methods=['GET']) @api_login_required @@ -361,37 +499,46 @@ def load_conversation(conversation_id, terminal: WebTerminal, workspace: UserWor session['thinking_mode'] = terminal.thinking_mode session['model_key'] = getattr(terminal, "model_key", None) normalized_id = _normalize_conv_id(conversation_id) - if _is_host_mode_request(username): - try: - vm = _get_conv_versioning_manager(workspace, normalized_id) - vmeta = _get_conversation_versioning_meta(terminal, normalized_id) - latest_checkpoint = vm.get_latest_checkpoint() if vmeta.get("enabled") else None - latest_commit = (latest_checkpoint or {}).get("commit") if latest_checkpoint else None - expected_workspace_path = (latest_checkpoint or {}).get("workspace_path") if latest_checkpoint else None - mismatch = vm.detect_mismatch(latest_commit, expected_workspace_path=expected_workspace_path) if vmeta.get("enabled") else { - "has_repo": False, - "head": None, - "dirty": False, - "mismatch": False, - "workspace_matched": True, - } - result["versioning"] = { - "enabled": bool(vmeta.get("enabled")), - "mode": "overwrite", - "latest_seq": int((latest_checkpoint or {}).get("seq") or 0) if latest_checkpoint else None, - "latest_commit": latest_commit, - "mismatch": bool(mismatch.get("mismatch")), - "dirty": bool(mismatch.get("dirty")), - "workspace_matched": bool(mismatch.get("workspace_matched", True)), - } - except Exception as exc: - debug_log(f"[Versioning] load status 读取失败: {exc}") - result["versioning"] = { - "enabled": False, - "mode": "overwrite", - "mismatch": False, - "error": str(exc), - } + try: + vm = _get_conv_versioning_manager(workspace, normalized_id) + vmeta = _get_conversation_versioning_meta(terminal, normalized_id) + tracking_mode = _normalize_versioning_tracking_mode(vmeta.get("tracking_mode")) + enabled = bool(vmeta.get("enabled")) and _can_use_versioning_scope(username, tracking_mode) + latest_checkpoint = vm.get_latest_checkpoint() if enabled else None + latest_commit = (latest_checkpoint or {}).get("commit") if latest_checkpoint else None + expected_workspace_path = (latest_checkpoint or {}).get("workspace_path") if latest_checkpoint else None + mismatch = vm.detect_mismatch( + latest_commit, + expected_workspace_path=expected_workspace_path, + tracking_mode=tracking_mode, + ) if enabled else { + "has_repo": False, + "head": None, + "dirty": False, + "mismatch": False, + "workspace_matched": True, + } + result["versioning"] = { + "host_mode": _is_host_mode_request(username), + "enabled": enabled, + "mode": "overwrite", + "tracking_mode": tracking_mode, + "latest_seq": int((latest_checkpoint or {}).get("seq") or 0) if latest_checkpoint else None, + "latest_commit": latest_commit, + "mismatch": bool(mismatch.get("mismatch")), + "dirty": bool(mismatch.get("dirty")), + "workspace_matched": bool(mismatch.get("workspace_matched", True)), + } + except Exception as exc: + debug_log(f"[Versioning] load status 读取失败: {exc}") + result["versioning"] = { + "host_mode": _is_host_mode_request(username), + "enabled": False, + "mode": "overwrite", + "tracking_mode": ConversationVersioningManager.TRACKING_MODE_WORKSPACE_AND_CONVERSATION, + "mismatch": False, + "error": str(exc), + } # 广播对话切换事件 socketio.emit('conversation_changed', { @@ -589,12 +736,15 @@ def get_conversation_versioning_status(conversation_id, terminal: WebTerminal, w normalized_id = _normalize_conv_id(conversation_id) host_mode = _is_host_mode_request(username) versioning_meta = _get_conversation_versioning_meta(terminal, normalized_id) + tracking_mode = _normalize_versioning_tracking_mode(versioning_meta.get("tracking_mode")) + enabled = bool(versioning_meta.get("enabled")) and _can_use_versioning_scope(username, tracking_mode) manager = _get_conv_versioning_manager(workspace, normalized_id) - latest = manager.get_latest_checkpoint() if versioning_meta.get("enabled") and host_mode else None + latest = manager.get_latest_checkpoint() if enabled else None mismatch = manager.detect_mismatch( (latest or {}).get("commit"), expected_workspace_path=(latest or {}).get("workspace_path"), - ) if versioning_meta.get("enabled") and host_mode else { + tracking_mode=tracking_mode, + ) if enabled else { "has_repo": False, "head": None, "dirty": False, @@ -606,8 +756,11 @@ def get_conversation_versioning_status(conversation_id, terminal: WebTerminal, w "data": { "conversation_id": normalized_id, "host_mode": host_mode, - "enabled": bool(versioning_meta.get("enabled")) if host_mode else False, + "supports_workspace_tracking": host_mode, + "supports_conversation_only": True, + "enabled": enabled, "mode": "overwrite", + "tracking_mode": tracking_mode, "latest_seq": int((latest or {}).get("seq") or 0) if latest else None, "latest_commit": (latest or {}).get("commit"), "mismatch": bool(mismatch.get("mismatch")), @@ -625,14 +778,19 @@ def get_conversation_versioning_status(conversation_id, terminal: WebTerminal, w def update_conversation_versioning(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str): try: normalized_id = _normalize_conv_id(conversation_id) - if not _is_host_mode_request(username): - return jsonify({"success": False, "error": "版本管理仅支持宿主机模式"}), 400 + host_mode = _is_host_mode_request(username) + current_meta = _get_conversation_versioning_meta(terminal, normalized_id) payload = request.get_json(silent=True) or {} enabled = bool(payload.get("enabled")) mode = "overwrite" + tracking_mode = _normalize_versioning_tracking_mode( + payload.get("tracking_mode") if "tracking_mode" in payload else current_meta.get("tracking_mode") + ) + if enabled and tracking_mode == ConversationVersioningManager.TRACKING_MODE_WORKSPACE_AND_CONVERSATION and not host_mode: + tracking_mode = ConversationVersioningManager.TRACKING_MODE_CONVERSATION_ONLY manager = _get_conv_versioning_manager(workspace, normalized_id) - meta = manager.set_enabled(enabled=enabled, mode=mode) + meta = manager.set_enabled(enabled=enabled, mode=mode, tracking_mode=tracking_mode) if enabled: conv_data = terminal.context_manager.conversation_manager.load_conversation(normalized_id) or {} snapshot_payload = { @@ -646,10 +804,12 @@ def update_conversation_versioning(conversation_id, terminal: WebTerminal, works init_result = manager.ensure_initial_checkpoint( workspace_path=str(workspace.project_path), conversation_snapshot=snapshot_payload, + tracking_mode=tracking_mode, ) init_row = init_result.get("row") or {} debug_log( f"[Versioning][Init] conv={normalized_id} enabled={enabled} " + f"tracking_mode={tracking_mode} " f"created={init_result.get('created')} reason={init_result.get('reason')} " f"seq={init_row.get('seq')} commit={init_row.get('commit')}" ) @@ -660,6 +820,7 @@ def update_conversation_versioning(conversation_id, terminal: WebTerminal, works normalized_id, enabled=enabled, mode=mode, + tracking_mode=tracking_mode, last_commit=meta.get("last_commit"), last_input_seq=int(meta.get("last_input_seq") or 0), ) @@ -671,6 +832,7 @@ def update_conversation_versioning(conversation_id, terminal: WebTerminal, works "conversation_id": normalized_id, "enabled": bool(meta.get("enabled")), "mode": "overwrite", + "tracking_mode": tracking_mode, "last_commit": meta.get("last_commit"), "last_input_seq": int(meta.get("last_input_seq") or 0), } @@ -687,11 +849,16 @@ def update_conversation_versioning(conversation_id, terminal: WebTerminal, works def list_conversation_versioning_checkpoints(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str): try: normalized_id = _normalize_conv_id(conversation_id) - if not _is_host_mode_request(username): - return jsonify({"success": False, "error": "版本管理仅支持宿主机模式"}), 400 + host_mode = _is_host_mode_request(username) versioning_meta = _get_conversation_versioning_meta(terminal, normalized_id) + tracking_mode = _normalize_versioning_tracking_mode(versioning_meta.get("tracking_mode")) + if tracking_mode == ConversationVersioningManager.TRACKING_MODE_WORKSPACE_AND_CONVERSATION and not host_mode: + return jsonify({"success": False, "error": "当前模式不支持工作区版本点,请切换到宿主机模式"}), 400 if not versioning_meta.get("enabled"): - return jsonify({"success": True, "data": {"enabled": False, "items": []}}) + return jsonify({ + "success": True, + "data": {"enabled": False, "mode": "overwrite", "tracking_mode": tracking_mode, "items": []} + }) manager = _get_conv_versioning_manager(workspace, normalized_id) rows = manager.list_checkpoints() return jsonify({ @@ -699,6 +866,7 @@ def list_conversation_versioning_checkpoints(conversation_id, terminal: WebTermi "data": { "enabled": True, "mode": "overwrite", + "tracking_mode": tracking_mode, "items": rows, } }) @@ -712,8 +880,11 @@ def list_conversation_versioning_checkpoints(conversation_id, terminal: WebTermi def get_conversation_versioning_checkpoint_detail(conversation_id, seq: int, terminal: WebTerminal, workspace: UserWorkspace, username: str): try: normalized_id = _normalize_conv_id(conversation_id) - if not _is_host_mode_request(username): - return jsonify({"success": False, "error": "版本管理仅支持宿主机模式"}), 400 + host_mode = _is_host_mode_request(username) + vmeta = _get_conversation_versioning_meta(terminal, normalized_id) + tracking_mode = _normalize_versioning_tracking_mode(vmeta.get("tracking_mode")) + if tracking_mode == ConversationVersioningManager.TRACKING_MODE_WORKSPACE_AND_CONVERSATION and not host_mode: + return jsonify({"success": False, "error": "当前模式不支持工作区版本详情,请切换到宿主机模式"}), 400 manager = _get_conv_versioning_manager(workspace, normalized_id) row = manager.get_checkpoint_detail(seq, include_patch=True) if not row: @@ -729,8 +900,7 @@ def get_conversation_versioning_checkpoint_detail(conversation_id, seq: int, ter def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str): try: normalized_id = _normalize_conv_id(conversation_id) - if not _is_host_mode_request(username): - return jsonify({"success": False, "error": "版本管理仅支持宿主机模式"}), 400 + host_mode = _is_host_mode_request(username) payload = request.get_json(silent=True) or {} seq = int(payload.get("seq") or 0) if seq < 0: @@ -746,16 +916,28 @@ def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTer checkpoint = manager.get_checkpoint(seq) if not checkpoint: return jsonify({"success": False, "error": "未找到对应版本点"}), 404 + tracking_mode = _normalize_versioning_tracking_mode( + checkpoint.get("tracking_mode") or vmeta.get("tracking_mode") + ) + if tracking_mode == ConversationVersioningManager.TRACKING_MODE_WORKSPACE_AND_CONVERSATION and not host_mode: + return jsonify({"success": False, "error": "当前模式不支持工作区回溯,请切换到宿主机模式"}), 400 debug_log( f"[Versioning][Restore] start conv={normalized_id} seq={seq} mode={restore_mode} " + f"tracking_mode={tracking_mode} " f"target_commit={checkpoint.get('commit')} workspace={workspace.project_path}" ) - manager.restore_to_commit(checkpoint.get("commit")) - debug_log( - f"[Versioning][Restore] git restored conv={normalized_id} seq={seq} " - f"commit={checkpoint.get('commit')}" - ) + if tracking_mode == ConversationVersioningManager.TRACKING_MODE_WORKSPACE_AND_CONVERSATION: + manager.restore_to_commit(checkpoint.get("commit")) + debug_log( + f"[Versioning][Restore] git restored conv={normalized_id} seq={seq} " + f"commit={checkpoint.get('commit')}" + ) + else: + debug_log( + f"[Versioning][Restore] conversation-only mode, skip workspace restore " + f"conv={normalized_id} seq={seq}" + ) cm = terminal.context_manager.conversation_manager conv_data = cm.load_conversation(normalized_id) or {} @@ -810,6 +992,9 @@ def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTer if not ok: return jsonify({"success": False, "error": "覆盖当前对话失败"}), 500 prune_info = manager.prune_checkpoints_after(seq) + resolved_last_commit = prune_info.get("last_commit") or checkpoint.get("commit") + if not resolved_last_commit: + resolved_last_commit = (manager.load_meta() or {}).get("last_commit") debug_log( f"[Versioning][Restore] overwrite pruned conv={normalized_id} seq<={seq} " f"kept={prune_info.get('kept')} removed={prune_info.get('removed')} " @@ -821,7 +1006,8 @@ def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTer normalized_id, enabled=True, mode="overwrite", - last_commit=prune_info.get("last_commit") or checkpoint.get("commit"), + tracking_mode=tracking_mode, + last_commit=resolved_last_commit, last_input_seq=int(prune_info.get("max_seq") or checkpoint.get("seq") or 0), ) cm.update_conversation_metadata( @@ -831,7 +1017,8 @@ def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTer "versioning": { "enabled": True, "mode": "overwrite", - "last_commit": prune_info.get("last_commit") or checkpoint.get("commit"), + "tracking_mode": tracking_mode, + "last_commit": resolved_last_commit, "last_input_seq": int(prune_info.get("max_seq") or checkpoint.get("seq") or 0), "updated_at": datetime.now().isoformat(), }, @@ -876,6 +1063,7 @@ def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTer "conversation_id": target_conversation_id, "restored_seq": int(checkpoint.get("seq") or 0), "restored_commit": checkpoint.get("commit"), + "tracking_mode": tracking_mode, "source_conversation_id": normalized_id, } }) @@ -1166,6 +1354,37 @@ def get_sub_agent_activity(task_id: str, terminal: WebTerminal, workspace: UserW return jsonify({"success": False, "error": str(exc)}), 500 +@conversation_bp.route('/api/sub_agents//terminate', methods=['POST']) +@api_login_required +@with_terminal +def terminate_sub_agent(task_id: str, terminal: WebTerminal, workspace: UserWorkspace, username: str): + """手动停止指定子智能体。""" + manager = getattr(terminal, "sub_agent_manager", None) + if not manager: + return jsonify({"success": False, "error": "子智能体管理器不可用"}), 404 + try: + try: + manager._load_state() + except Exception: + pass + + task = manager.tasks.get(task_id) + if not task: + return jsonify({"success": False, "error": "未找到对应子智能体任务"}), 404 + + current_conv_id = terminal.context_manager.current_conversation_id + task_conv_id = task.get("conversation_id") + if current_conv_id and task_conv_id and task_conv_id != current_conv_id: + return jsonify({"success": False, "error": "无权限停止该子智能体任务"}), 403 + + result = manager.terminate_sub_agent(task_id=task_id) + if not result.get("success"): + return jsonify({"success": False, "error": result.get("error") or "停止子智能体失败"}), 400 + return jsonify({"success": True, "data": result}) + except Exception as exc: + return jsonify({"success": False, "error": str(exc)}), 500 + + @conversation_bp.route('/api/background_commands', methods=['GET']) @api_login_required @with_terminal @@ -1184,11 +1403,18 @@ def list_background_commands(terminal: WebTerminal, workspace: UserWorkspace, us records = manager.list_records(conversation_id=conversation_id, limit=limit_num) data = [] + terminal_statuses = {"completed", "failed", "timeout", "cancelled"} for rec in records: result = rec.get("result") if isinstance(rec.get("result"), dict) else {} + status = rec.get("status") + notice_pending = bool( + status in terminal_statuses + and not rec.get("notified") + and not rec.get("claimed_by_sleep") + ) data.append({ "command_id": rec.get("command_id"), - "status": rec.get("status"), + "status": status, "command": rec.get("command"), "conversation_id": rec.get("conversation_id"), "created_at": rec.get("created_at"), @@ -1197,6 +1423,7 @@ def list_background_commands(terminal: WebTerminal, workspace: UserWorkspace, us "timeout": rec.get("timeout"), "return_code": result.get("return_code"), "run_in_background": True, + "notice_pending": notice_pending, }) return jsonify({"success": True, "data": data}) except Exception as exc: @@ -1241,6 +1468,31 @@ def get_background_command_detail(command_id: str, terminal: WebTerminal, worksp return jsonify({"success": False, "error": str(exc)}), 500 +@conversation_bp.route('/api/background_commands//cancel', methods=['POST']) +@api_login_required +@with_terminal +def cancel_background_command(command_id: str, terminal: WebTerminal, workspace: UserWorkspace, username: str): + """手动停止指定后台 run_command。""" + manager = getattr(terminal, "background_command_manager", None) + if not manager: + return jsonify({"success": False, "error": "后台命令管理器不可用"}), 404 + try: + rec = manager.get_record(command_id) + if not rec: + return jsonify({"success": False, "error": "未找到对应后台命令"}), 404 + current_conv_id = terminal.context_manager.current_conversation_id + rec_conv_id = rec.get("conversation_id") + if current_conv_id and rec_conv_id and rec_conv_id != current_conv_id: + return jsonify({"success": False, "error": "无权限停止该后台命令"}), 403 + + result = manager.cancel_command(command_id) + if not result.get("status"): + return jsonify({"success": False, "error": result.get("error") or "停止后台命令失败"}), 400 + return jsonify({"success": True, "data": result}) + except Exception as exc: + return jsonify({"success": False, "error": str(exc)}), 500 + + @conversation_bp.route('/api/conversations//duplicate', methods=['POST']) @api_login_required @with_terminal diff --git a/server/deep_compression.py b/server/deep_compression.py index ea2d99e..141517b 100644 --- a/server/deep_compression.py +++ b/server/deep_compression.py @@ -253,6 +253,9 @@ async def run_deep_compression( old_title = conv_data.get("title") or "未命名" source_versioning = metadata.get("versioning") if isinstance(metadata.get("versioning"), dict) else {} source_versioning_enabled = bool(source_versioning.get("enabled")) + source_tracking_mode = ConversationVersioningManager.normalize_tracking_mode( + source_versioning.get("tracking_mode") + ) compression_count = int(metadata.get("compression_count", 0) or 0) previous_records = _normalize_deep_compression_records(metadata) previous_max_count = max([int(item.get("count") or 0) for item in previous_records], default=0) @@ -349,7 +352,11 @@ async def run_deep_compression( data_dir=workspace.data_dir, conversation_id=new_conv_id, ) - vm_meta = vm.set_enabled(enabled=True, mode="overwrite") + vm_meta = vm.set_enabled( + enabled=True, + mode="overwrite", + tracking_mode=source_tracking_mode, + ) new_conv_data = cm.conversation_manager.load_conversation(new_conv_id) or {} snapshot_payload = { "conversation_id": new_conv_id, @@ -362,6 +369,7 @@ async def run_deep_compression( init_result = vm.ensure_initial_checkpoint( workspace_path=str(workspace.project_path), conversation_snapshot=snapshot_payload, + tracking_mode=source_tracking_mode, ) init_row = init_result.get("row") or {} last_commit = init_row.get("commit") or vm_meta.get("last_commit") @@ -371,6 +379,7 @@ async def run_deep_compression( "versioning": { "enabled": True, "mode": "overwrite", + "tracking_mode": source_tracking_mode, "updated_at": datetime.now().isoformat(), "last_commit": last_commit, "last_input_seq": int(vm_meta.get("last_input_seq") or 0), diff --git a/server/status.py b/server/status.py index 05a213a..85f4ea6 100644 --- a/server/status.py +++ b/server/status.py @@ -1,6 +1,7 @@ from __future__ import annotations import time import re +import os from pathlib import Path from flask import Blueprint, jsonify, request, send_file, session @@ -20,9 +21,12 @@ from modules.host_workspace_manager import ( resolve_host_workspace, ) from utils.host_workspace_debug import write_host_workspace_debug +from .utils_common import log_conn_diag from . import state status_bp = Blueprint('status', __name__) +STATUS_DIAG_VERBOSE = os.environ.get("STATUS_DIAG_VERBOSE", "0") not in {"0", "false", "False"} +STATUS_DIAG_SLOW_MS = int(os.environ.get("STATUS_DIAG_SLOW_MS", "1200") or 1200) def _parse_android_version_from_gradle(project_root: Path) -> tuple[int, str]: @@ -65,14 +69,60 @@ def _is_host_mode_request() -> bool: return bool(session.get("host_mode")) and (TERMINAL_SANDBOX_MODE or "").lower() == "host" +@status_bp.route('/api/health') +@api_login_required +def get_health(): + """轻量健康检查:用于前端连接心跳,避免触发重型 status 计算。""" + started_at = time.perf_counter() + heartbeat_id = request.headers.get("X-Connection-Heartbeat", "").strip() + username = session.get("username") or "-" + payload = { + "success": True, + "status": "ok", + "server_time_ms": int(time.time() * 1000), + } + elapsed_ms = (time.perf_counter() - started_at) * 1000 + if request.args.get("diag") == "1" or elapsed_ms >= 800: + log_conn_diag( + f"health hb={heartbeat_id or '-'} user={username} " + f"elapsed_ms={elapsed_ms:.1f} host_mode={bool(session.get('host_mode'))} " + f"workspace_id={session.get('workspace_id') or '-'}" + ) + return jsonify(payload) + + @status_bp.route('/api/status') @api_login_required @with_terminal def get_status(terminal, workspace, username): """获取系统状态(包含对话、容器、版本等信息)""" - status = terminal.get_status() + started_at = time.perf_counter() + heartbeat_id = request.headers.get("X-Connection-Heartbeat", "").strip() + diag_requested = STATUS_DIAG_VERBOSE or request.args.get("diag") == "1" + timings = { + "terminal_ms": 0.0, + "terminals_ms": 0.0, + "conversation_meta_ms": 0.0, + "container_ms": 0.0, + "policy_ms": 0.0, + } + phase_started_at = time.perf_counter() + try: + status = terminal.get_status() + except Exception as exc: + timings["terminal_ms"] = (time.perf_counter() - phase_started_at) * 1000 + total_ms = (time.perf_counter() - started_at) * 1000 + log_conn_diag( + f"status hb={heartbeat_id or '-'} user={username} phase=terminal.get_status " + f"total_ms={total_ms:.1f} terminal_ms={timings['terminal_ms']:.1f} error={exc}" + ) + raise + timings["terminal_ms"] = (time.perf_counter() - phase_started_at) * 1000 + phase_started_at = time.perf_counter() if terminal.terminal_manager: status['terminals'] = terminal.terminal_manager.list_terminals() + timings["terminals_ms"] = (time.perf_counter() - phase_started_at) * 1000 + phase_started_at = time.perf_counter() try: current_conv = terminal.context_manager.current_conversation_id status.setdefault('conversation', {})['current_id'] = current_conv @@ -92,13 +142,18 @@ def get_status(terminal, workspace, username): "job_id": meta.get("compression_job_id"), } except Exception as exc: - print(f"[Status] 获取当前对话信息失败: {exc}") + log_conn_diag(f"status conversation-meta-failed user={username} error={exc}") + timings["conversation_meta_ms"] = (time.perf_counter() - phase_started_at) * 1000 status['project_path'] = str(workspace.project_path) + phase_started_at = time.perf_counter() try: status['container'] = container_manager.get_container_status(username) except Exception as exc: status['container'] = {"success": False, "error": str(exc)} + log_conn_diag(f"status container-status-failed user={username} error={exc}") + timings["container_ms"] = (time.perf_counter() - phase_started_at) * 1000 status['version'] = AGENT_VERSION + phase_started_at = time.perf_counter() try: policy = resolve_admin_policy(user_manager.get_user(username)) status['admin_policy'] = { @@ -109,6 +164,20 @@ def get_status(terminal, workspace, username): } except Exception: pass + timings["policy_ms"] = (time.perf_counter() - phase_started_at) * 1000 + total_ms = (time.perf_counter() - started_at) * 1000 + is_slow = total_ms >= STATUS_DIAG_SLOW_MS + if diag_requested or is_slow: + conversation = status.get("conversation") or {} + log_conn_diag( + f"status hb={heartbeat_id or '-'} user={username} host_mode={bool(session.get('host_mode'))} " + f"workspace_id={session.get('workspace_id') or '-'} " + f"conversation_id={conversation.get('current_id') or '-'} " + f"total_ms={total_ms:.1f} slow={is_slow} " + f"terminal_ms={timings['terminal_ms']:.1f} terminals_ms={timings['terminals_ms']:.1f} " + f"conversation_meta_ms={timings['conversation_meta_ms']:.1f} " + f"container_ms={timings['container_ms']:.1f} policy_ms={timings['policy_ms']:.1f}" + ) return jsonify(status) diff --git a/server/tasks.py b/server/tasks.py index ffdb6c6..217673d 100644 --- a/server/tasks.py +++ b/server/tasks.py @@ -14,7 +14,7 @@ from .auth_helpers import api_login_required, get_current_username from .context import get_user_resources, ensure_conversation_loaded from .chat_flow import run_chat_task_sync from .state import stop_flags -from .utils_common import debug_log +from .utils_common import debug_log, log_conn_diag from utils.host_workspace_debug import write_host_workspace_debug @@ -38,6 +38,8 @@ class TaskRecord: "session_data", "stop_requested", "next_event_idx", + "runtime_pending_queue", + "runtime_guidance_queue", ) def __init__( @@ -72,6 +74,8 @@ class TaskRecord: self.session_data: Dict[str, Any] = {} self.stop_requested: bool = False self.next_event_idx: int = 0 + self.runtime_pending_queue: List[Dict[str, Any]] = [] + self.runtime_guidance_queue: List[str] = [] class TaskManager: @@ -199,6 +203,233 @@ class TaskManager: debug_log(f"[Task] 任务 {task_id} 已取消,事件队列已清空") return True + @staticmethod + def _normalize_runtime_pending_queue(raw_queue: Any) -> List[Dict[str, Any]]: + now_ts = time.time() + normalized: List[Dict[str, Any]] = [] + if not isinstance(raw_queue, list): + return normalized + for raw_item in raw_queue: + if isinstance(raw_item, dict): + item_id = str(raw_item.get("id") or "").strip() + text = str(raw_item.get("text") or "").strip() + created_at = raw_item.get("created_at") + else: + item_id = "" + text = str(raw_item or "").strip() + created_at = None + if not text: + continue + if not item_id: + item_id = str(uuid.uuid4()) + try: + created_at_float = float(created_at) + except Exception: + created_at_float = now_ts + normalized.append( + { + "id": item_id, + "text": text, + "created_at": created_at_float, + } + ) + return normalized + + @staticmethod + def _runtime_pending_public(queue: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + result: List[Dict[str, Any]] = [] + for item in queue or []: + if not isinstance(item, dict): + continue + text = str(item.get("text") or "").strip() + item_id = str(item.get("id") or "").strip() + if not text or not item_id: + continue + result.append( + { + "id": item_id, + "text": text, + "created_at": item.get("created_at"), + } + ) + return result + + def enqueue_runtime_pending_message( + self, username: str, task_id: str, message: str, max_queue_size: int = 5 + ) -> Dict[str, Any]: + text = str(message or "").strip() + if not text: + return {"success": False, "code": "empty_message", "error": "消息不能为空"} + limit = int(max(1, max_queue_size)) + with self._lock: + rec = self._tasks.get(task_id) + if not rec or rec.username != username: + return {"success": False, "code": "task_not_found", "error": "任务不存在"} + if rec.status not in {"pending", "running", "cancel_requested"}: + return {"success": False, "code": "task_not_running", "error": "任务已结束,无法追加消息"} + queue = self._normalize_runtime_pending_queue(getattr(rec, "runtime_pending_queue", None)) + if len(queue) >= limit: + return { + "success": False, + "code": "queue_full", + "error": f"堆积消息已满(最多 {limit} 条)", + } + item = { + "id": str(uuid.uuid4()), + "text": text, + "created_at": time.time(), + } + queue.append(item) + rec.runtime_pending_queue = queue + rec.updated_at = time.time() + return { + "success": True, + "task_id": rec.task_id, + "item": item, + "messages": self._runtime_pending_public(queue), + } + + def remove_runtime_pending_message( + self, username: str, task_id: str, message_id: str + ) -> Dict[str, Any]: + target_id = str(message_id or "").strip() + if not target_id: + return {"success": False, "code": "invalid_message_id", "error": "消息ID无效"} + with self._lock: + rec = self._tasks.get(task_id) + if not rec or rec.username != username: + return {"success": False, "code": "task_not_found", "error": "任务不存在"} + queue = self._normalize_runtime_pending_queue(getattr(rec, "runtime_pending_queue", None)) + remove_idx = -1 + for idx, item in enumerate(queue): + if str(item.get("id") or "") == target_id: + remove_idx = idx + break + if remove_idx < 0: + return {"success": False, "code": "message_not_found", "error": "消息不存在"} + queue.pop(remove_idx) + rec.runtime_pending_queue = queue + rec.updated_at = time.time() + return { + "success": True, + "task_id": rec.task_id, + "messages": self._runtime_pending_public(queue), + } + + def promote_runtime_pending_to_guidance( + self, + username: str, + task_id: str, + message_id: str, + max_guidance_queue_size: int = 5, + ) -> Dict[str, Any]: + target_id = str(message_id or "").strip() + if not target_id: + return {"success": False, "code": "invalid_message_id", "error": "消息ID无效"} + guidance_limit = int(max(1, max_guidance_queue_size)) + with self._lock: + rec = self._tasks.get(task_id) + if not rec or rec.username != username: + return {"success": False, "code": "task_not_found", "error": "任务不存在"} + if rec.status not in {"pending", "running", "cancel_requested"}: + return {"success": False, "code": "task_not_running", "error": "任务已结束,无法引导"} + queue = self._normalize_runtime_pending_queue(getattr(rec, "runtime_pending_queue", None)) + guidance_queue = getattr(rec, "runtime_guidance_queue", None) + if not isinstance(guidance_queue, list): + guidance_queue = [] + if len(guidance_queue) >= guidance_limit: + return { + "success": False, + "code": "guidance_queue_full", + "error": f"引导队列已满(最多 {guidance_limit} 条)", + } + selected = None + remain_queue: List[Dict[str, Any]] = [] + for item in queue: + if selected is None and str(item.get("id") or "") == target_id: + selected = item + continue + remain_queue.append(item) + if not selected: + return {"success": False, "code": "message_not_found", "error": "消息不存在"} + selected_text = str(selected.get("text") or "").strip() + if not selected_text: + return {"success": False, "code": "empty_message", "error": "消息内容为空"} + guidance_queue.append(selected_text) + rec.runtime_guidance_queue = guidance_queue + rec.runtime_pending_queue = remain_queue + rec.updated_at = time.time() + return { + "success": True, + "task_id": rec.task_id, + "queued_count": len(guidance_queue), + "messages": self._runtime_pending_public(remain_queue), + } + + def get_runtime_pending_messages(self, username: str, task_id: str) -> List[Dict[str, Any]]: + with self._lock: + rec = self._tasks.get(task_id) + if not rec or rec.username != username: + return [] + queue = self._normalize_runtime_pending_queue(getattr(rec, "runtime_pending_queue", None)) + rec.runtime_pending_queue = queue + return self._runtime_pending_public(queue) + + def enqueue_runtime_guidance( + self, username: str, task_id: str, message: str, max_queue_size: int = 5 + ) -> Dict[str, Any]: + text = str(message or "").strip() + if not text: + return {"success": False, "code": "empty_message", "error": "引导内容不能为空"} + with self._lock: + rec = self._tasks.get(task_id) + if not rec or rec.username != username: + return {"success": False, "code": "task_not_found", "error": "任务不存在"} + if rec.status not in {"pending", "running", "cancel_requested"}: + return {"success": False, "code": "task_not_running", "error": "任务已结束,无法追加引导"} + queue = getattr(rec, "runtime_guidance_queue", None) + if not isinstance(queue, list): + queue = [] + rec.runtime_guidance_queue = queue + if len(queue) >= int(max(1, max_queue_size)): + return { + "success": False, + "code": "queue_full", + "error": f"引导队列已满(最多 {int(max(1, max_queue_size))} 条)", + } + queue.append(text) + rec.updated_at = time.time() + return { + "success": True, + "queued_count": len(queue), + "task_id": rec.task_id, + } + + def pop_runtime_guidance_for_injection(self, username: str, task_id: str) -> Optional[str]: + with self._lock: + rec = self._tasks.get(task_id) + if not rec or rec.username != username: + return None + queue = getattr(rec, "runtime_guidance_queue", None) + if not isinstance(queue, list) or not queue: + return None + item = str(queue.pop(0) or "").strip() + rec.updated_at = time.time() + return item or None + + def consume_runtime_guidance_messages(self, username: str, task_id: str) -> List[str]: + with self._lock: + rec = self._tasks.get(task_id) + if not rec or rec.username != username: + return [] + queue = getattr(rec, "runtime_guidance_queue", None) + if not isinstance(queue, list) or not queue: + return [] + items = [str(item or "").strip() for item in queue] + rec.runtime_guidance_queue = [] + rec.updated_at = time.time() + return [item for item in items if item] + # ---- internal helpers ---- def _append_event(self, rec: TaskRecord, event_type: str, data: Dict[str, Any]): with self._lock: @@ -533,9 +764,14 @@ def _normalize_media_payload(images: List[Any], videos: List[Any]) -> tuple[List @tasks_bp.route("/api/tasks/", methods=["GET"]) @api_login_required def get_task_api(task_id: str): + started_at = time.time() username = get_current_username() + poll_req_id = request.headers.get("X-Task-Poll", "-") rec = task_manager.get_task(username, task_id) if not rec: + log_conn_diag( + f"task-poll-missing req={poll_req_id} user={username} task_id={task_id}" + ) return jsonify({"success": False, "error": "任务不存在"}), 404 try: offset = int(request.args.get("from", 0)) @@ -543,6 +779,20 @@ def get_task_api(task_id: str): offset = 0 events = [e for e in rec.events if e["idx"] >= offset] next_offset = events[-1]["idx"] + 1 if events else offset + elapsed_ms = (time.time() - started_at) * 1000.0 + should_log = ( + offset == 0 + or len(events) > 0 + or rec.status != "running" + or elapsed_ms >= 800 + ) + if should_log: + log_conn_diag( + "task-poll " + f"req={poll_req_id} user={username} task_id={task_id} status={rec.status} " + f"from={offset} events={len(events)} next_offset={next_offset} " + f"elapsed_ms={elapsed_ms:.1f} slow={elapsed_ms >= 800}" + ) return jsonify({ "success": True, "data": { @@ -555,6 +805,9 @@ def get_task_api(task_id: str): "error": rec.error, "events": events, "next_offset": next_offset, + "runtime_queued_messages": task_manager.get_runtime_pending_messages( + username, task_id + ), } }) @@ -567,3 +820,109 @@ def cancel_task_api(task_id: str): if not ok: return jsonify({"success": False, "error": "任务不存在"}), 404 return jsonify({"success": True}) + + +@tasks_bp.route("/api/tasks//runtime_guidance", methods=["POST"]) +@api_login_required +def enqueue_runtime_guidance_api(task_id: str): + username = get_current_username() + payload = request.get_json() or {} + message = (payload.get("message") or "").strip() + if not message: + return jsonify({"success": False, "error": "引导内容不能为空"}), 400 + + result = task_manager.enqueue_runtime_guidance(username, task_id, message) + if not result.get("success"): + code = result.get("code") or "runtime_guidance_failed" + if code == "task_not_found": + return jsonify({"success": False, "error": result.get("error") or "任务不存在"}), 404 + if code in {"queue_full", "task_not_running"}: + return jsonify({"success": False, "error": result.get("error") or "任务状态不允许"}), 409 + return jsonify({"success": False, "error": result.get("error") or "追加引导失败"}), 400 + + return jsonify( + { + "success": True, + "data": { + "task_id": result.get("task_id"), + "queued_count": result.get("queued_count", 0), + }, + } + ) + + +@tasks_bp.route("/api/tasks//runtime_queue", methods=["POST"]) +@api_login_required +def enqueue_runtime_queue_message_api(task_id: str): + username = get_current_username() + payload = request.get_json() or {} + message = (payload.get("message") or "").strip() + if not message: + return jsonify({"success": False, "error": "消息不能为空"}), 400 + result = task_manager.enqueue_runtime_pending_message(username, task_id, message) + if not result.get("success"): + code = result.get("code") or "runtime_queue_enqueue_failed" + if code == "task_not_found": + return jsonify({"success": False, "error": result.get("error") or "任务不存在"}), 404 + if code in {"queue_full", "task_not_running"}: + return jsonify({"success": False, "error": result.get("error") or "任务状态不允许"}), 409 + return jsonify({"success": False, "error": result.get("error") or "追加消息失败"}), 400 + return jsonify( + { + "success": True, + "data": { + "task_id": result.get("task_id"), + "item": result.get("item"), + "messages": result.get("messages") or [], + }, + } + ) + + +@tasks_bp.route("/api/tasks//runtime_queue/", methods=["DELETE"]) +@api_login_required +def delete_runtime_queue_message_api(task_id: str, message_id: str): + username = get_current_username() + result = task_manager.remove_runtime_pending_message(username, task_id, message_id) + if not result.get("success"): + code = result.get("code") or "runtime_queue_delete_failed" + if code == "task_not_found": + return jsonify({"success": False, "error": result.get("error") or "任务不存在"}), 404 + if code == "message_not_found": + return jsonify({"success": False, "error": result.get("error") or "消息不存在"}), 404 + return jsonify({"success": False, "error": result.get("error") or "删除失败"}), 400 + return jsonify( + { + "success": True, + "data": { + "task_id": result.get("task_id"), + "messages": result.get("messages") or [], + }, + } + ) + + +@tasks_bp.route("/api/tasks//runtime_queue//guide", methods=["POST"]) +@api_login_required +def guide_runtime_queue_message_api(task_id: str, message_id: str): + username = get_current_username() + result = task_manager.promote_runtime_pending_to_guidance(username, task_id, message_id) + if not result.get("success"): + code = result.get("code") or "runtime_queue_guide_failed" + if code == "task_not_found": + return jsonify({"success": False, "error": result.get("error") or "任务不存在"}), 404 + if code == "message_not_found": + return jsonify({"success": False, "error": result.get("error") or "消息不存在"}), 404 + if code in {"guidance_queue_full", "task_not_running"}: + return jsonify({"success": False, "error": result.get("error") or "任务状态不允许"}), 409 + return jsonify({"success": False, "error": result.get("error") or "引导失败"}), 400 + return jsonify( + { + "success": True, + "data": { + "task_id": result.get("task_id"), + "queued_count": result.get("queued_count", 0), + "messages": result.get("messages") or [], + }, + } + ) diff --git a/server/utils_common.py b/server/utils_common.py index 0a6f141..bf66a4c 100644 --- a/server/utils_common.py +++ b/server/utils_common.py @@ -144,6 +144,7 @@ DEBUG_LOG_FILE = Path(LOGS_DIR).expanduser().resolve() / "debug_stream.log" CHUNK_BACKEND_LOG_FILE = Path(LOGS_DIR).expanduser().resolve() / "chunk_backend.log" CHUNK_FRONTEND_LOG_FILE = Path(LOGS_DIR).expanduser().resolve() / "chunk_frontend.log" STREAMING_DEBUG_LOG_FILE = Path(LOGS_DIR).expanduser().resolve() / "streaming_debug.log" +CONN_DIAG_LOG_FILE = Path(LOGS_DIR).expanduser().resolve() / "conn_diag.log" def _write_log(file_path: Path, message: str) -> None: @@ -158,6 +159,11 @@ def debug_log(message): _write_log(DEBUG_LOG_FILE, message) +def log_conn_diag(message: str): + """连接诊断日志:统一关键词并直接落盘到独立文件。""" + _write_log(CONN_DIAG_LOG_FILE, f"[CONN_DIAG] {message}") + + def log_backend_chunk(conversation_id: str, iteration: int, chunk_index: int, elapsed: float, char_len: int, content_preview: str): preview = content_preview.replace('\n', '\\n') _write_log( @@ -192,4 +198,6 @@ __all__ = [ "CHUNK_BACKEND_LOG_FILE", "CHUNK_FRONTEND_LOG_FILE", "STREAMING_DEBUG_LOG_FILE", + "CONN_DIAG_LOG_FILE", + "log_conn_diag", ] diff --git a/static/src/App.vue b/static/src/App.vue index aa8e6ca..8bb7407 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -121,6 +121,7 @@ 'chat-container--monitor': chatDisplayMode === 'monitor', 'has-title-ribbon': titleRibbonVisible }" + :style="chatContainerStyle" > @@ -402,6 +407,7 @@ v-if="versioningDialogOpen" :host-mode="versioningHostMode" :enabled="versioningEnabled" + :tracking-mode="versioningTrackingMode" :loading="versioningLoading" :items="versioningCheckpoints" :selected-seq="versioningSelectedSeq" @@ -415,6 +421,7 @@ @toggle-enabled="toggleConversationVersioning" @select="selectVersioningCheckpoint" @update:restore-mode="versioningRestoreMode = $event" + @update:tracking-mode="handleVersioningTrackingModeChange" @confirm="confirmVersioningRestore" /> diff --git a/static/src/app/computed.ts b/static/src/app/computed.ts index e2110b3..a60fe9f 100644 --- a/static/src/app/computed.ts +++ b/static/src/app/computed.ts @@ -129,6 +129,17 @@ export const computed = { titleRibbonVisible() { return !this.isMobileViewport && this.chatDisplayMode === 'chat'; }, + chatContainerStyle() { + const minHeight = 80; + const raw = Number(this.composerReservedHeight || 0); + const height = Number.isFinite(raw) ? Math.max(minHeight, Math.round(raw)) : minHeight; + const growth = Math.max(0, height - minHeight); + return { + '--composer-base-height': `calc(${minHeight}px + var(--app-bottom-inset, 0px))`, + '--composer-reserved-height': `calc(${height}px + var(--app-bottom-inset, 0px))`, + '--composer-growth-height': `${growth}px` + }; + }, ...mapWritableState(useToolStore, [ 'preparingTools', 'activeTools', diff --git a/static/src/app/lifecycle.ts b/static/src/app/lifecycle.ts index 165e242..d05d3a4 100644 --- a/static/src/app/lifecycle.ts +++ b/static/src/app/lifecycle.ts @@ -71,6 +71,7 @@ export async function mounted() { document.addEventListener('click', this.handleCopyCodeClick); window.addEventListener('popstate', this.handlePopState); window.addEventListener('keydown', this.handleMobileOverlayEscape); + window.addEventListener('beforeunload', this.handleBeforeUnloadDraftPersist); this.setupMobileViewportWatcher(); this.subAgentFetch(); @@ -112,6 +113,7 @@ export function beforeUnmount() { document.removeEventListener('click', this.handleCopyCodeClick); window.removeEventListener('popstate', this.handlePopState); window.removeEventListener('keydown', this.handleMobileOverlayEscape); + window.removeEventListener('beforeunload', this.handleBeforeUnloadDraftPersist); this.teardownMobileViewportWatcher(); this.subAgentStopPolling(); this.backgroundCommandStopPolling(); diff --git a/static/src/app/methods/conversation.ts b/static/src/app/methods/conversation.ts index cfa85a9..2c4d09e 100644 --- a/static/src/app/methods/conversation.ts +++ b/static/src/app/methods/conversation.ts @@ -94,6 +94,18 @@ export const conversationMethods = { this.inputSetLineCount(1); this.inputSetMultiline(false); this.inputClearMessage(); + this.runtimeQueuedMessages = []; + this.runtimeGuidanceFallbackQueue = []; + this.runtimeQueueAutoSendInProgress = false; + this.runtimeQueueSyncLockKey = ''; + this.runtimeQueueSyncLockUntil = 0; + if (typeof this.clearRuntimeQueueSuppressionState === 'function') { + this.clearRuntimeQueueSuppressionState(); + } else { + this.runtimeQueueSuppressedMessageIds = new Set(); + this.runtimeGuidanceSuppressedTextCounts = {}; + } + this.composerReservedHeight = 80; this.inputClearSelectedImages(); this.inputSetImagePickerOpen(false); this.imageEntries = []; @@ -309,6 +321,12 @@ export const conversationMethods = { console.error('[切换对话] 检查/停止任务失败:', error); } + await this.persistComposerDraftNow({ + reason: `switch-conversation:${conversationId}`, + force: true, + keepalive: true + }).catch(() => {}); + try { // 1. 调用加载API const response = await fetch(`/api/conversations/${conversationId}/load`, { @@ -440,6 +458,12 @@ export const conversationMethods = { }); this.logMessageState('createNewConversation:start'); + await this.persistComposerDraftNow({ + reason: 'create-new-conversation', + force: true, + keepalive: true + }).catch(() => {}); + // 应用个性化设置中的默认模型和思考模式 try { const personalizationStore = usePersonalizationStore(); diff --git a/static/src/app/methods/message.ts b/static/src/app/methods/message.ts index 9bfa621..5938de2 100644 --- a/static/src/app/methods/message.ts +++ b/static/src/app/methods/message.ts @@ -3,6 +3,114 @@ import { debugLog } from './common'; import { useModelStore } from '../../stores/model'; export const messageMethods = { + buildRuntimeQueueSnapshotKey(messages = []) { + const list = Array.isArray(messages) ? messages : []; + return JSON.stringify( + list.map((item) => [ + String(item?.id || ''), + String(item?.text || ''), + Number(item?.createdAt || 0) + ]) + ); + }, + + setRuntimeQueueSyncLock(messages = [], ttlMs = 1800) { + this.runtimeQueueSyncLockKey = this.buildRuntimeQueueSnapshotKey(messages); + this.runtimeQueueSyncLockUntil = Date.now() + Math.max(300, Number(ttlMs || 0)); + }, + + ensureRuntimeQueueSuppressionState() { + if (!(this.runtimeQueueSuppressedMessageIds instanceof Set)) { + this.runtimeQueueSuppressedMessageIds = new Set(); + } + if ( + !this.runtimeGuidanceSuppressedTextCounts || + typeof this.runtimeGuidanceSuppressedTextCounts !== 'object' || + Array.isArray(this.runtimeGuidanceSuppressedTextCounts) + ) { + this.runtimeGuidanceSuppressedTextCounts = {}; + } + }, + + markRuntimeQueueSuppressedByManualStop() { + this.ensureRuntimeQueueSuppressionState(); + const queueList = Array.isArray(this.runtimeQueuedMessages) ? this.runtimeQueuedMessages : []; + queueList.forEach((item) => { + const id = String(item?.id || '').trim(); + if (id) { + this.runtimeQueueSuppressedMessageIds.add(id); + } + }); + + const fallbackList = Array.isArray(this.runtimeGuidanceFallbackQueue) + ? this.runtimeGuidanceFallbackQueue + : []; + fallbackList.forEach((item) => { + const text = String(item || '').trim(); + if (!text) return; + const counts = this.runtimeGuidanceSuppressedTextCounts; + counts[text] = Number(counts[text] || 0) + 1; + }); + }, + + consumeSuppressedRuntimeGuidanceText(rawText) { + this.ensureRuntimeQueueSuppressionState(); + const text = String(rawText || '').trim(); + if (!text) return false; + const counts = this.runtimeGuidanceSuppressedTextCounts; + const current = Number(counts[text] || 0); + return Number.isFinite(current) && current > 0; + }, + + consumeSuppressedRuntimeQueueMessageId(rawId) { + this.ensureRuntimeQueueSuppressionState(); + const id = String(rawId || '').trim(); + if (!id) return false; + return this.runtimeQueueSuppressedMessageIds.has(id); + }, + + clearRuntimeQueueSuppressionState() { + this.runtimeQueueSuppressedMessageIds = new Set(); + this.runtimeGuidanceSuppressedTextCounts = {}; + }, + + pruneSuppressedRuntimeQueues() { + this.ensureRuntimeQueueSuppressionState(); + const blockedIds = this.runtimeQueueSuppressedMessageIds; + const currentQueue = Array.isArray(this.runtimeQueuedMessages) + ? this.runtimeQueuedMessages + : []; + const nextQueue = currentQueue.filter((item) => { + const id = String(item?.id || '').trim(); + if (!id) return false; + return !blockedIds.has(id); + }); + if (nextQueue.length !== currentQueue.length) { + this.runtimeQueuedMessages = nextQueue; + this.setRuntimeQueueSyncLock(nextQueue, 2200); + } + + const currentFallback = Array.isArray(this.runtimeGuidanceFallbackQueue) + ? this.runtimeGuidanceFallbackQueue + : []; + if (currentFallback.length > 0) { + const keptFallback = []; + currentFallback.forEach((item) => { + const text = String(item || '').trim(); + if (!text) { + return; + } + if (this.consumeSuppressedRuntimeGuidanceText(text)) { + return; + } + keptFallback.push(text); + }); + if (keptFallback.length !== currentFallback.length) { + this.runtimeGuidanceFallbackQueue = keptFallback; + } + } + }, + async executeSystemCommand(rawCommand, options = {}) { const command = (rawCommand || '').toString().trim(); if (!command) { @@ -102,7 +210,349 @@ export const messageMethods = { } }, - handleSendOrStop() { + applyRuntimeQueuedMessages(messages = []) { + this.ensureRuntimeQueueSuppressionState(); + const previousList = Array.isArray(this.runtimeQueuedMessages) + ? this.runtimeQueuedMessages + : []; + const previousById = new Map(previousList.map((item) => [item?.id, item])); + const previousIndexById = new Map(previousList.map((item, index) => [item?.id, index])); + const limit = Math.max(1, Number(this.runtimeQueueLimit || 5)); + const normalizedRaw = (Array.isArray(messages) ? messages : []) + .map((item) => { + if (!item) return null; + const id = String(item.id || '').trim(); + const text = String(item.text || '').trim(); + if (!id || !text) return null; + const previous = previousById.get(id); + const rawCreatedAt = Number(item.created_at ?? item.createdAt ?? Date.now()); + return { + id, + text, + createdAt: Number.isFinite(rawCreatedAt) ? rawCreatedAt : Date.now(), + source: previous?.source || 'user' + }; + }) + .filter((item) => !!item); + + const dedupById = new Map(); + normalizedRaw.forEach((item) => { + if (!item?.id || dedupById.has(item.id)) { + return; + } + dedupById.set(item.id, item); + }); + + const normalized = Array.from(dedupById.values()) + .sort((a, b) => { + const at = Number(a?.createdAt || 0); + const bt = Number(b?.createdAt || 0); + if (at !== bt) { + return at - bt; + } + const ai = previousIndexById.has(a?.id) + ? Number(previousIndexById.get(a?.id)) + : Number.MAX_SAFE_INTEGER; + const bi = previousIndexById.has(b?.id) + ? Number(previousIndexById.get(b?.id)) + : Number.MAX_SAFE_INTEGER; + if (ai !== bi) { + return ai - bi; + } + return String(a?.id || '').localeCompare(String(b?.id || '')); + }) + .slice(0, limit); + + const unchanged = + previousList.length === normalized.length && + previousList.every((item, index) => { + const next = normalized[index]; + return ( + item?.id === next?.id && + item?.text === next?.text && + Number(item?.createdAt || 0) === Number(next?.createdAt || 0) + ); + }); + + if (unchanged) { + return previousList; + } + this.runtimeQueuedMessages = normalized; + return normalized; + }, + + async enqueueRuntimeQueuedMessage(rawMessage) { + const text = (rawMessage || '').toString().trim(); + if (!text) { + return false; + } + try { + const { useTaskStore } = await import('../../stores/task'); + const taskStore = useTaskStore(); + const taskId = taskStore.currentTaskId; + if (!taskId) { + this.uiPushToast({ + title: '暂不可堆积', + message: '未检测到活跃任务,请稍后再试', + type: 'warning' + }); + return false; + } + const response = await fetch(`/api/tasks/${encodeURIComponent(taskId)}/runtime_queue`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + message: text + }) + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok || !payload?.success) { + throw new Error(payload?.error || '堆积消息失败'); + } + const nextQueue = this.applyRuntimeQueuedMessages(payload?.data?.messages || []) || []; + this.setRuntimeQueueSyncLock(nextQueue, 2200); + return true; + } catch (error) { + this.uiPushToast({ + title: '堆积失败', + message: error?.message || '请稍后重试', + type: 'error' + }); + return false; + } + }, + + async handleDeleteRuntimeMessage(messageId) { + const targetId = String(messageId || '').trim(); + if (!targetId) { + return; + } + try { + const { useTaskStore } = await import('../../stores/task'); + const taskStore = useTaskStore(); + const taskId = taskStore.currentTaskId; + if (!taskId) { + const nextQueue = (this.runtimeQueuedMessages || []).filter( + (item) => item?.id !== targetId + ); + this.runtimeQueuedMessages = nextQueue; + this.setRuntimeQueueSyncLock(nextQueue, 1200); + return; + } + const response = await fetch( + `/api/tasks/${encodeURIComponent(taskId)}/runtime_queue/${encodeURIComponent(targetId)}`, + { + method: 'DELETE' + } + ); + const payload = await response.json().catch(() => ({})); + if (!response.ok || !payload?.success) { + throw new Error(payload?.error || '删除失败'); + } + const nextQueue = this.applyRuntimeQueuedMessages(payload?.data?.messages || []) || []; + this.setRuntimeQueueSyncLock(nextQueue, 2200); + } catch (error) { + this.uiPushToast({ + title: '删除失败', + message: error?.message || '请稍后重试', + type: 'error' + }); + } + }, + + async handleGuideRuntimeMessage(messageId) { + const item = (this.runtimeQueuedMessages || []).find((entry) => entry?.id === messageId); + if (!item?.text) { + return; + } + if (!this.composerBusy) { + const sent = !!(await this.sendMessage({ + presetText: String(item.text || '').trim(), + source: 'runtime_queue_manual_guide' + })); + if (!sent) { + this.uiPushToast({ + title: '发送失败', + message: '引导消息未发出,请稍后重试', + type: 'warning' + }); + return; + } + const currentQueue = Array.isArray(this.runtimeQueuedMessages) + ? [...this.runtimeQueuedMessages] + : []; + const nextQueue = currentQueue.filter((entry) => entry?.id !== item.id); + if (nextQueue.length !== currentQueue.length) { + this.runtimeQueuedMessages = nextQueue; + this.setRuntimeQueueSyncLock(nextQueue, 2200); + } + this.ensureRuntimeQueueSuppressionState(); + if (item?.id) { + this.runtimeQueueSuppressedMessageIds.delete(item.id); + } + return; + } + try { + const { useTaskStore } = await import('../../stores/task'); + const taskStore = useTaskStore(); + const taskId = taskStore.currentTaskId; + if (!taskId) { + this.uiPushToast({ + title: '暂不可引导', + message: '未检测到活跃任务,请稍后再试', + type: 'warning' + }); + return; + } + const response = await fetch( + `/api/tasks/${encodeURIComponent(taskId)}/runtime_queue/${encodeURIComponent(messageId)}/guide`, + { + method: 'POST' + } + ); + const payload = await response.json().catch(() => ({})); + if (!response.ok || !payload?.success) { + throw new Error(payload?.error || '提交引导失败'); + } + const nextQueue = this.applyRuntimeQueuedMessages(payload?.data?.messages || []) || []; + this.setRuntimeQueueSyncLock(nextQueue, 2200); + this.uiPushToast({ + title: '已设为引导对话', + message: '将在下一次工具结果后插入到当前对话', + type: 'success', + duration: 1800 + }); + } catch (error) { + this.uiPushToast({ + title: '引导失败', + message: error?.message || '请稍后重试', + type: 'error' + }); + } + }, + + async tryAutoSendRuntimeQueuedMessages(reason = 'unspecified') { + if (this.runtimeQueueAutoSendInProgress) { + return false; + } + if ( + this.composerBusy || + this.streamingMessage || + this.taskInProgress || + this.stopRequested || + this.waitingForSubAgent + ) { + return false; + } + + const fallbackQueue = Array.isArray(this.runtimeGuidanceFallbackQueue) + ? this.runtimeGuidanceFallbackQueue + : []; + const runtimeQueue = Array.isArray(this.runtimeQueuedMessages) + ? this.runtimeQueuedMessages + : []; + let nextText = ''; + let source = 'runtime_queue'; + let runtimeQueueMessageId = null; + let fallbackIndex = -1; + + if (fallbackQueue.length > 0) { + for (let i = 0; i < fallbackQueue.length; i += 1) { + const candidate = (fallbackQueue[i] || '').toString().trim(); + if (!candidate) { + continue; + } + if (this.consumeSuppressedRuntimeGuidanceText(candidate)) { + continue; + } + nextText = candidate; + source = 'runtime_guidance_fallback'; + fallbackIndex = i; + break; + } + } + + if (!nextText && runtimeQueue.length > 0) { + for (let i = 0; i < runtimeQueue.length; i += 1) { + const next = runtimeQueue[i]; + const candidateId = String(next?.id || '').trim(); + const candidateText = (next?.text || '').toString().trim(); + if (!candidateId || !candidateText) { + continue; + } + if (this.consumeSuppressedRuntimeQueueMessageId(candidateId)) { + continue; + } + nextText = candidateText; + source = next?.source || 'runtime_queue'; + runtimeQueueMessageId = candidateId; + break; + } + } + + if (!nextText) { + return false; + } + + this.runtimeQueueAutoSendInProgress = true; + let sent = false; + try { + sent = !!(await this.sendMessage({ + presetText: nextText, + source + })); + } catch { + sent = false; + } finally { + this.runtimeQueueAutoSendInProgress = false; + } + + if (!sent) { + return false; + } + + if (source === 'runtime_guidance_fallback') { + const current = Array.isArray(this.runtimeGuidanceFallbackQueue) + ? [...this.runtimeGuidanceFallbackQueue] + : []; + const removeIndex = + fallbackIndex >= 0 + ? fallbackIndex + : current.findIndex((item) => String(item || '').trim() === nextText); + if (removeIndex >= 0) { + current.splice(removeIndex, 1); + this.runtimeGuidanceFallbackQueue = current; + } + } else { + const current = Array.isArray(this.runtimeQueuedMessages) + ? [...this.runtimeQueuedMessages] + : []; + if (runtimeQueueMessageId) { + const nextQueue = current.filter((item) => item?.id !== runtimeQueueMessageId); + this.runtimeQueuedMessages = nextQueue; + this.setRuntimeQueueSyncLock(nextQueue, 1500); + } else { + const removeIndex = current.findIndex( + (item) => String(item?.text || '').trim() === nextText + ); + if (removeIndex >= 0) { + current.splice(removeIndex, 1); + this.runtimeQueuedMessages = current; + this.setRuntimeQueueSyncLock(current, 1500); + } + } + } + debugLog('[RuntimeQueue] auto-send success', { + reason, + source, + messagePreview: nextText.slice(0, 60) + }); + return true; + }, + + async handleSendOrStop() { if (this.compressionInProgress) { this.uiPushToast({ title: '对话自动压缩中', @@ -111,15 +561,39 @@ export const messageMethods = { }); return; } + const hasText = !!((this.inputMessage || '').trim().length > 0); + const hasMedia = + (Array.isArray(this.selectedImages) && this.selectedImages.length > 0) || + (Array.isArray(this.selectedVideos) && this.selectedVideos.length > 0); if (this.composerBusy) { + if (hasText) { + const queued = await this.enqueueRuntimeQueuedMessage(this.inputMessage); + if (queued) { + this.inputClearMessage(); + this.inputSetLineCount(1); + this.inputSetMultiline(false); + this.autoResizeInput(); + } + return; + } + if (hasMedia) { + this.uiPushToast({ + title: '运行中仅支持文本', + message: '图片/视频请等待当前任务结束后发送', + type: 'warning' + }); + return; + } this.stopTask(); } else { this.sendMessage(); } }, - async sendMessage() { + async sendMessage(options = {}) { console.log('[DEBUG_AWAITING] ===== sendMessage ====='); + const presetText = typeof options?.presetText === 'string' ? options.presetText : null; + const usePresetText = presetText !== null; if (this.compressionInProgress) { this.uiPushToast({ @@ -127,35 +601,51 @@ export const messageMethods = { message: '压缩完成后才能继续发送消息', type: 'warning' }); - return; + return false; } - if (this.streamingUi) { - return; + if (this.streamingUi && !usePresetText) { + return false; } - if (this.mediaUploading) { + if (!this.isConnected) { + this.uiPushToast({ + title: '连接已断开', + message: '当前无法发送消息,请等待连接恢复后重试', + type: 'warning' + }); + return false; + } + if (this.mediaUploading && !usePresetText) { this.uiPushToast({ title: '上传中', message: '请等待图片/视频上传完成后再发送', type: 'info' }); - return; + return false; } - const text = (this.inputMessage || '').trim(); - const images = Array.isArray(this.selectedImages) ? this.selectedImages.slice(0, 9) : []; - const videos = Array.isArray(this.selectedVideos) ? this.selectedVideos.slice(0, 1) : []; + const text = ((usePresetText ? presetText : this.inputMessage) || '').trim(); + const images = usePresetText + ? [] + : Array.isArray(this.selectedImages) + ? this.selectedImages.slice(0, 9) + : []; + const videos = usePresetText + ? [] + : Array.isArray(this.selectedVideos) + ? this.selectedVideos.slice(0, 1) + : []; const hasText = text.length > 0; const hasImages = images.length > 0; const hasVideos = videos.length > 0; if (!hasText && !hasImages && !hasVideos) { - return; + return false; } const quotaType = this.thinkingMode ? 'thinking' : 'fast'; if (this.isQuotaExceeded(quotaType)) { this.showQuotaToast({ type: quotaType }); - return; + return false; } const modelStore = useModelStore(); @@ -166,7 +656,7 @@ export const messageMethods = { message: '请切换到支持图片输入的模型再发送图片', type: 'error' }); - return; + return false; } if (hasVideos && !currentModel?.supportsVideo) { @@ -175,7 +665,7 @@ export const messageMethods = { message: '请切换到支持视频输入的模型后再发送视频', type: 'error' }); - return; + return false; } if (hasVideos && hasImages) { @@ -184,7 +674,7 @@ export const messageMethods = { message: '视频与图片需分开发送,每条仅包含一种媒体', type: 'warning' }); - return; + return false; } if (hasVideos) { @@ -248,7 +738,7 @@ export const messageMethods = { message: error?.message || '创建新对话失败,请重试', type: 'error' }); - return; + return false; } } @@ -277,7 +767,8 @@ export const messageMethods = { await taskStore.createTask(message, images, videos, targetConversationId, { model_key: this.currentModelKey, run_mode: this.runMode, - thinking_mode: this.thinkingMode + thinking_mode: this.thinkingMode, + eventHandler: (event: any) => this.handleTaskEvent(event) }); debugLog('[Message] 任务已创建,开始轮询'); @@ -296,16 +787,23 @@ export const messageMethods = { if (typeof this.forceUnlockMonitor === 'function') { this.forceUnlockMonitor('create_task_failed'); } - return; + return false; } - this.inputClearMessage(); - this.inputClearSelectedImages(); - this.inputClearSelectedVideos(); - this.inputSetImagePickerOpen(false); - this.inputSetVideoPickerOpen(false); - this.inputSetLineCount(1); - this.inputSetMultiline(false); + if (!usePresetText) { + this.inputClearMessage(); + this.inputClearSelectedImages(); + this.inputClearSelectedVideos(); + this.inputSetImagePickerOpen(false); + this.inputSetVideoPickerOpen(false); + this.inputSetLineCount(1); + this.inputSetMultiline(false); + this.persistComposerDraftNow({ + reason: 'send-message-cleared', + force: true, + keepalive: true + }).catch(() => {}); + } if (hasImages) { this.conversationHasImages = true; this.conversationHasVideos = false; @@ -325,6 +823,7 @@ export const messageMethods = { this.updateCurrentContextTokens(); } }, 1000); + return true; }, // 新增:停止任务方法 @@ -345,6 +844,9 @@ export const messageMethods = { } const shouldDropToolEvents = this.streamingUi; + if (typeof this.markRuntimeQueueSuppressedByManualStop === 'function') { + this.markRuntimeQueueSuppressedByManualStop(); + } this.stopRequested = true; this.dropToolEvents = shouldDropToolEvents; @@ -580,7 +1082,9 @@ export const messageMethods = { if (typeof this.clearProcessedEvents === 'function') { this.clearProcessedEvents(); } - await taskStore.createTask(message, [], [], this.currentConversationId); + await taskStore.createTask(message, [], [], this.currentConversationId, { + eventHandler: (event: any) => this.handleTaskEvent(event) + }); } catch (error) { console.error('[Message] 自动消息创建任务失败:', error); this.uiPushToast({ diff --git a/static/src/app/methods/taskPolling.ts b/static/src/app/methods/taskPolling.ts index 3fa3f45..18400df 100644 --- a/static/src/app/methods/taskPolling.ts +++ b/static/src/app/methods/taskPolling.ts @@ -146,6 +146,66 @@ export const taskPollingMethods = { clearInterval(this.waitingTaskProbeTimer); this.waitingTaskProbeTimer = null; } + this._waitingProbeStableEmptyCount = 0; + }, + + async inspectWaitingCompletionState() { + const terminalStatuses = new Set(['completed', 'failed', 'timeout', 'terminated', 'cancelled']); + let hasSubAgentPending = false; + let hasBackgroundPending = false; + let resolved = true; + + try { + const [subResp, bgResp] = await Promise.all([ + fetch('/api/sub_agents'), + fetch('/api/background_commands?limit=200') + ]); + + if (subResp.ok) { + const subResult = await subResp.json().catch(() => ({})); + const tasks = Array.isArray(subResult?.data) ? subResult.data : []; + const relevantTasks = tasks.filter((task: any) => { + if (!task) return false; + if (task.conversation_id && task.conversation_id !== this.currentConversationId) + return false; + return true; + }); + hasSubAgentPending = relevantTasks.some((task: any) => { + if (!task?.run_in_background) return false; + const status = String(task?.status || '').toLowerCase(); + return !terminalStatuses.has(status) || !!task?.notice_pending; + }); + } else { + resolved = false; + } + + if (bgResp.ok) { + const bgResult = await bgResp.json().catch(() => ({})); + const commands = Array.isArray(bgResult?.data) ? bgResult.data : []; + hasBackgroundPending = commands.some((command: any) => { + if (!command) return false; + const status = String(command?.status || '').toLowerCase(); + return !terminalStatuses.has(status) || !!command?.notice_pending; + }); + } else { + resolved = false; + } + } catch (error) { + debugNotifyLog('[DEBUG_NOTIFY][ui] inspectWaitingCompletionState:error', { + error: error?.message || String(error) + }); + return { + resolved: false, + hasPending: true, + hasBackgroundPending: this.waitingForBackgroundCommand + }; + } + + return { + resolved, + hasPending: !resolved || hasSubAgentPending || hasBackgroundPending, + hasBackgroundPending + }; }, async tryResumeWaitingNoticeTask() { @@ -239,6 +299,30 @@ export const taskPollingMethods = { debugNotifyLog('[DEBUG_NOTIFY][ui] waitingTaskProbe:resumed-and-stop'); keyNotifyLog('[DEBUG_NOTIFY_KEY][ui] waitingTaskProbe:resumed-and-stop'); this.stopWaitingTaskProbe(); + return; + } + + const state = await this.inspectWaitingCompletionState(); + if (state.hasPending) { + this._waitingProbeStableEmptyCount = 0; + this.waitingForBackgroundCommand = !!state.hasBackgroundPending; + return; + } + + this._waitingProbeStableEmptyCount = (this._waitingProbeStableEmptyCount || 0) + 1; + if (this._waitingProbeStableEmptyCount >= 2) { + debugLog('[TaskPolling] 等待态兜底清理:未发现运行中的后台子智能体/后台指令'); + this.streamingMessage = false; + this.taskInProgress = false; + this.stopRequested = false; + this.waitingForSubAgent = false; + this.waitingForBackgroundCommand = false; + if (typeof this.clearPendingTools === 'function') { + this.clearPendingTools('waiting_probe_auto_clear'); + } + this.clearTaskState(); + this.stopWaitingTaskProbe(); + this.$forceUpdate(); } }, 1000); }, @@ -484,6 +568,9 @@ export const taskPollingMethods = { case 'system_message': this.handleSystemMessage(eventData, eventIdx); break; + case 'runtime_queue_sync': + this.handleRuntimeQueueSync(eventData); + break; default: debugLog(`[TaskPolling] 未知事件类型: ${eventType}`); @@ -1154,6 +1241,29 @@ export const taskPollingMethods = { handleTaskComplete(data: any) { const pendingToolsBefore = typeof this.hasPendingToolActions === 'function' ? this.hasPendingToolActions() : null; + const pendingRuntimeGuidance = Array.isArray(data?.pending_runtime_guidance_messages) + ? data.pending_runtime_guidance_messages + .map((item: any) => String(item || '').trim()) + .filter((item: string) => item.length > 0) + : []; + if (pendingRuntimeGuidance.length > 0) { + const limit = Math.max(1, Number(this.runtimeQueueLimit || 5)); + const mergedGuidance = [ + ...(this.runtimeGuidanceFallbackQueue || []), + ...pendingRuntimeGuidance + ] + .map((item: any) => String(item || '').trim()) + .filter((item: string) => item.length > 0) + .slice(0, limit); + const queueAllowance = Math.max(0, limit - mergedGuidance.length); + if ( + Array.isArray(this.runtimeQueuedMessages) && + this.runtimeQueuedMessages.length > queueAllowance + ) { + this.runtimeQueuedMessages = this.runtimeQueuedMessages.slice(0, queueAllowance); + } + this.runtimeGuidanceFallbackQueue = mergedGuidance; + } jsonDebug('handleTaskComplete:before', { data, taskInProgress: this.taskInProgress, @@ -1199,6 +1309,11 @@ export const taskPollingMethods = { this.waitingForBackgroundCommand = false; this.stopWaitingTaskProbe(); this.clearTaskState(); // 清理任务状态 + this.$nextTick(() => { + if (typeof this.tryAutoSendRuntimeQueuedMessages === 'function') { + this.tryAutoSendRuntimeQueuedMessages('task_complete'); + } + }); } this.$forceUpdate(); @@ -1251,6 +1366,11 @@ export const taskPollingMethods = { this.$forceUpdate(); this.clearTaskState(); + this.$nextTick(() => { + if (typeof this.tryAutoSendRuntimeQueuedMessages === 'function') { + this.tryAutoSendRuntimeQueuedMessages('task_stopped'); + } + }); jsonDebug('handleTaskStopped:after', { taskInProgress: this.taskInProgress, streamingMessage: this.streamingMessage, @@ -1258,6 +1378,55 @@ export const taskPollingMethods = { }); }, + handleRuntimeQueueSync(data: any) { + const messages = Array.isArray(data?.messages) ? data.messages : []; + const buildSnapshotKey = + typeof this.buildRuntimeQueueSnapshotKey === 'function' + ? this.buildRuntimeQueueSnapshotKey + : (list: any[]) => + JSON.stringify( + (Array.isArray(list) ? list : []).map((item: any) => [ + String(item?.id || ''), + String(item?.text || ''), + Number(item?.createdAt ?? item?.created_at ?? 0) + ]) + ); + const incomingPreview = (Array.isArray(messages) ? messages : []).map((item: any) => ({ + id: String(item?.id || '').trim(), + text: String(item?.text || '').trim(), + createdAt: Number(item?.created_at ?? item?.createdAt ?? 0) + })); + const incomingKey = buildSnapshotKey(incomingPreview); + const now = Date.now(); + const lockKey = String(this.runtimeQueueSyncLockKey || ''); + const lockUntil = Number(this.runtimeQueueSyncLockUntil || 0); + if (lockKey && now < lockUntil && incomingKey !== lockKey) { + debugLog('[RuntimeQueue] 忽略可能过期的轮询同步', { + now, + lockUntil, + incomingSize: incomingPreview.length + }); + return; + } + if (lockKey && incomingKey === lockKey) { + this.runtimeQueueSyncLockKey = ''; + this.runtimeQueueSyncLockUntil = 0; + } + if (typeof this.applyRuntimeQueuedMessages === 'function') { + this.applyRuntimeQueuedMessages(messages); + return; + } + const limit = Math.max(1, Number(this.runtimeQueueLimit || 5)); + this.runtimeQueuedMessages = messages + .map((item: any) => ({ + id: String(item?.id || '').trim(), + text: String(item?.text || '').trim(), + createdAt: Number(item?.created_at ?? item?.createdAt ?? Date.now()) + })) + .filter((item: any) => item.id && item.text) + .slice(0, limit); + }, + // 新增统一清理方法 clearTaskState() { this.stopWaitingTaskProbe(); @@ -1476,6 +1645,11 @@ export const taskPollingMethods = { const taskStore = useTaskStore(); taskStore.stopPolling(); })(); + this.$nextTick(() => { + if (typeof this.tryAutoSendRuntimeQueuedMessages === 'function') { + this.tryAutoSendRuntimeQueuedMessages('task_error'); + } + }); }, handleTokenUpdate(data: any) { @@ -2052,11 +2226,27 @@ export const taskPollingMethods = { (task: any) => task?.run_in_background && !terminalStatuses.has(task?.status) ); const pendingNotice = relevant.filter((task: any) => task?.notice_pending); + let backgroundPending = false; + try { + const bgResp = await fetch('/api/background_commands?limit=200'); + if (bgResp.ok) { + const bgResult = await bgResp.json().catch(() => ({})); + const bgCommands = Array.isArray(bgResult?.data) ? bgResult.data : []; + backgroundPending = bgCommands.some((command: any) => { + if (!command) return false; + const status = String(command?.status || '').toLowerCase(); + return !terminalStatuses.has(status) || !!command?.notice_pending; + }); + } + } catch (bgErr) { + console.warn('[TaskPolling] 获取后台指令状态失败:', bgErr); + } - if (running.length || pendingNotice.length) { + if (running.length || pendingNotice.length || backgroundPending) { debugLog('[TaskPolling] 恢复子智能体等待状态', { running: running.length, pendingNotice: pendingNotice.length, + backgroundPending, runningTasks: running.map((task: any) => ({ task_id: task?.task_id, status: task?.status @@ -2067,12 +2257,20 @@ export const taskPollingMethods = { })) }); this.waitingForSubAgent = true; - this.waitingForBackgroundCommand = false; + this.waitingForBackgroundCommand = backgroundPending; this.taskInProgress = true; this.streamingMessage = false; this.stopRequested = false; this.startWaitingTaskProbe(); this.$forceUpdate(); + } else { + this.waitingForSubAgent = false; + this.waitingForBackgroundCommand = false; + this.stopWaitingTaskProbe(); + if (!this.streamingMessage) { + this.taskInProgress = false; + } + this.$forceUpdate(); } } catch (error) { console.error('[TaskPolling] 恢复子智能体等待状态失败:', error); diff --git a/static/src/app/methods/ui.ts b/static/src/app/methods/ui.ts index 0fcb8ca..f40b6f0 100644 --- a/static/src/app/methods/ui.ts +++ b/static/src/app/methods/ui.ts @@ -60,6 +60,60 @@ function uiBounceTrace( console.log('[SCROLL_BOUNCE_TRACE_UI]', event, payload); } +function isConnectionDiagEnabled() { + if (typeof window === 'undefined') return true; + try { + const explicit = (window as any).__CONN_DIAG__; + if (explicit === false || explicit === '0') return false; + if (explicit === true || explicit === '1') return true; + const localFlag = window.localStorage?.getItem('connDiag'); + if (localFlag === '0' || localFlag === 'false') return false; + if (localFlag === '1' || localFlag === 'true') return true; + // 默认常开:无需手动启动 + return true; + } catch { + return true; + } +} + +function pushConnectionDiagRecord(record: Record) { + if (typeof window === 'undefined') return; + try { + const w = window as any; + if (!Array.isArray(w.__CONN_DIAG_LOGS__)) { + w.__CONN_DIAG_LOGS__ = []; + } + w.__CONN_DIAG_LOGS__.push(record); + const max = 400; + if (w.__CONN_DIAG_LOGS__.length > max) { + w.__CONN_DIAG_LOGS__.splice(0, w.__CONN_DIAG_LOGS__.length - max); + } + } catch { + // ignore + } +} + +function connectionDiag( + level: 'log' | 'warn' | 'error', + event: string, + payload: Record = {}, + options: { force?: boolean } = {} +) { + const force = !!options.force; + const enabled = isConnectionDiagEnabled(); + if (!enabled && !force) { + return; + } + const record = { + ts: new Date().toISOString(), + event, + ...payload + }; + pushConnectionDiagRecord(record); + const logger = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log; + logger('[CONN_DIAG]', event, record); +} + function parseSubAgentDoneLabel(rawContent: any): string | null { const content = (rawContent || '').toString().trim(); if (!content) { @@ -262,11 +316,171 @@ export const uiMethods = { this.uiSetMobileOverlayMenuOpen(false); }, + normalizeComposerDraftContent(rawValue) { + let text = ''; + if (typeof rawValue === 'string') { + text = rawValue; + } else if (rawValue === null || rawValue === undefined) { + text = ''; + } else { + text = String(rawValue); + } + if (text.length > 40000) { + text = text.slice(0, 40000); + } + return text; + }, + + scheduleComposerDraftPersist(reason = 'input') { + const current = this.normalizeComposerDraftContent(this.inputMessage); + const lastSynced = this.normalizeComposerDraftContent(this.composerDraftLastSyncedContent); + const dirty = current !== lastSynced; + this.composerDraftDirty = dirty; + if (this.composerDraftSaveTimer) { + clearTimeout(this.composerDraftSaveTimer); + this.composerDraftSaveTimer = null; + } + if (!dirty) { + return; + } + this.composerDraftSaveTimer = window.setTimeout(() => { + this.persistComposerDraftNow({ reason: `debounce:${reason}` }).catch(() => {}); + }, 1000); + }, + + async persistComposerDraftNow(options = {}) { + const reason = String(options?.reason || 'manual'); + const force = !!options?.force; + const useBeacon = !!options?.useBeacon; + const content = this.normalizeComposerDraftContent( + Object.prototype.hasOwnProperty.call(options || {}, 'content') + ? options.content + : this.inputMessage + ); + const lastSynced = this.normalizeComposerDraftContent(this.composerDraftLastSyncedContent); + if (!force && content === lastSynced) { + this.composerDraftDirty = false; + return { success: true, skipped: true }; + } + + if (this.composerDraftSaveTimer) { + clearTimeout(this.composerDraftSaveTimer); + this.composerDraftSaveTimer = null; + } + + const payloadText = JSON.stringify({ content }); + if ( + useBeacon && + typeof navigator !== 'undefined' && + typeof navigator.sendBeacon === 'function' + ) { + try { + const blob = new Blob([payloadText], { type: 'application/json' }); + navigator.sendBeacon('/api/input-draft', blob); + this.composerDraftLastSyncedContent = content; + this.composerDraftDirty = false; + return { success: true, queued: true, reason }; + } catch (error) { + // sendBeacon 失败时回落到 fetch + } + } + + const response = await fetch('/api/input-draft', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'same-origin', + body: payloadText, + keepalive: !!options?.keepalive + }); + const data = await response.json().catch(() => ({})); + if (!response.ok || !data?.success) { + throw new Error(data?.error || '保存输入草稿失败'); + } + this.composerDraftLastSyncedContent = content; + this.composerDraftDirty = false; + return { success: true, saved: true, reason }; + }, + + async restoreComposerDraftState(reason = 'manual') { + const fetchSeq = Number(this.composerDraftFetchSeq || 0) + 1; + this.composerDraftFetchSeq = fetchSeq; + try { + const response = await fetch('/api/input-draft', { + method: 'GET', + credentials: 'same-origin' + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok || !payload?.success) { + throw new Error(payload?.error || '获取输入草稿失败'); + } + if (Number(this.composerDraftFetchSeq || 0) !== fetchSeq) { + return; + } + const content = this.normalizeComposerDraftContent(payload?.data?.content || ''); + this.composerDraftLastSyncedContent = content; + this.composerDraftDirty = false; + this.inputSetMessage(content); + this.$nextTick(() => { + if (typeof this.autoResizeInput === 'function') { + this.autoResizeInput(); + } + }); + debugLog('[UI] 输入草稿已恢复', { reason, length: content.length }); + } catch (error) { + console.warn('[UI] 恢复输入草稿失败:', error); + } + }, + + handleBeforeUnloadDraftPersist() { + this.persistComposerDraftNow({ + reason: 'beforeunload', + force: true, + useBeacon: true + }).catch(() => {}); + }, + + clearComposerDraftState(reason = 'manual') { + debugLog('[UI] 清理输入草稿状态', { reason }); + this.inputClearMessage(); + this.composerDraftLastSyncedContent = ''; + this.composerDraftDirty = false; + if (this.composerDraftSaveTimer) { + clearTimeout(this.composerDraftSaveTimer); + this.composerDraftSaveTimer = null; + } + this.inputSetLineCount(1); + this.inputSetMultiline(false); + this.inputClearSelectedImages(); + this.inputClearSelectedVideos(); + this.inputSetImagePickerOpen(false); + this.inputSetVideoPickerOpen(false); + this.imageEntries = []; + this.videoEntries = []; + this.imageLoading = false; + this.videoLoading = false; + this.mediaUploading = false; + this.$nextTick(() => { + if (typeof this.autoResizeInput === 'function') { + this.autoResizeInput(); + } + }); + }, + refreshCurrentPage() { if (typeof window === 'undefined') { return; } - window.location.reload(); + this.persistComposerDraftNow({ + reason: 'refresh-page', + force: true, + keepalive: true + }) + .catch(() => {}) + .finally(() => { + window.location.reload(); + }); }, handleMobileRefreshClick() { @@ -533,6 +747,14 @@ export const uiMethods = { this.hostWorkspaceSwitching = true; try { + // 先在“当前工作区作用域”落盘草稿,再切换工作区。 + // 否则会把旧工作区草稿误写到目标工作区作用域里。 + await this.persistComposerDraftNow({ + reason: 'switch-host-workspace:before-select', + force: true, + keepalive: true + }).catch(() => {}); + const query = encodeURIComponent(targetId); const resp = await fetch(`/api/host/workspaces/select?workspace_id=${query}`); const payload = await resp.json().catch(() => ({})); @@ -1052,6 +1274,7 @@ export const uiMethods = { handleInputChange() { this.autoResizeInput(); + this.scheduleComposerDraftPersist('input-change'); }, handleInputFocus() { @@ -1637,6 +1860,21 @@ export const uiMethods = { return this.$refs.inputComposer || null; }, + handleComposerHeightChange(payload = {}) { + const raw = + Number(payload?.reservedHeight || 0) || + Number(payload?.height || 0) || + Number(payload?.composerHeight || 0); + if (!Number.isFinite(raw) || raw <= 0) { + return; + } + const next = Math.max(80, Math.min(560, Math.round(raw))); + if (Math.abs(next - Number(this.composerReservedHeight || 0)) < 1) { + return; + } + this.composerReservedHeight = next; + }, + getComposerElement(field) { const composer = this.getInputComposerRef(); const unwrap = (value: any) => { @@ -1781,6 +2019,7 @@ export const uiMethods = { this.startTitleTyping('新对话', { animate: false }); this.initialRouteResolved = true; this.refreshBlankHeroState(); + await this.restoreComposerDraftState('bootstrap-route:new'); return; } @@ -1831,6 +2070,7 @@ export const uiMethods = { } finally { this.initialRouteResolved = true; } + await this.restoreComposerDraftState('bootstrap-route:conversation'); }, handlePopState(event) { @@ -1844,6 +2084,7 @@ export const uiMethods = { this.logMessageState('handlePopState:after-clear-no-conversation'); this.resetAllStates('handlePopState:no-conversation'); this.resetTokenStatistics(); + this.restoreComposerDraftState('popstate:new').catch(() => {}); return; } this.loadConversation(convId); @@ -1970,24 +2211,136 @@ export const uiMethods = { }, async checkConnectionHealth() { + if (this.connectionHeartbeatInFlight) { + connectionDiag('log', 'health-skip-inflight', { + seq: this.connectionHeartbeatSeq, + isConnected: !!this.isConnected, + failCount: this.connectionHeartbeatFailCount + }); + return; + } + this.connectionHeartbeatInFlight = true; + const seq = Number(this.connectionHeartbeatSeq || 0) + 1; + this.connectionHeartbeatSeq = seq; + const requestId = `${Date.now()}-${seq}`; + const startedAt = Date.now(); + const diagEnabled = isConnectionDiagEnabled(); + const healthUrl = diagEnabled ? '/api/health?diag=1' : '/api/health'; + const wasConnected = !!this.isConnected; + let responseStatus: number | null = null; const controller = typeof AbortController !== 'undefined' ? new AbortController() : null; - const timeoutId = controller ? window.setTimeout(() => controller.abort(), 5000) : null; + const timeoutMs = + typeof this.connectionHeartbeatRequestTimeoutMs === 'number' && + this.connectionHeartbeatRequestTimeoutMs > 0 + ? this.connectionHeartbeatRequestTimeoutMs + : 5000; + const timeoutId = controller ? window.setTimeout(() => controller.abort(), timeoutMs) : null; try { - const response = await fetch('/api/status', { + const response = await fetch(healthUrl, { method: 'GET', cache: 'no-store', + headers: { + 'X-Connection-Heartbeat': requestId + }, signal: controller?.signal }); + responseStatus = response.status; if (!response.ok) { throw new Error(`HTTP ${response.status}`); } + const failCountBeforeRecover = this.connectionHeartbeatFailCount || 0; this.isConnected = true; this.connectionHeartbeatFailCount = 0; + this.connectionHeartbeatLastLatencyMs = Date.now() - startedAt; + this.connectionHeartbeatLastStatusCode = responseStatus; + this.connectionHeartbeatLastError = ''; + if (!wasConnected) { + this.connectionHeartbeatLastChangeAt = Date.now(); + connectionDiag( + 'warn', + 'health-recovered', + { + requestId, + seq, + elapsedMs: this.connectionHeartbeatLastLatencyMs, + failCountBeforeRecover, + status: responseStatus, + diagEnabled, + endpoint: healthUrl, + conversationId: this.currentConversationId || null, + taskInProgress: !!this.taskInProgress, + streamingMessage: !!this.streamingMessage, + visibility: document?.visibilityState || 'unknown', + online: typeof navigator !== 'undefined' ? navigator.onLine : null + }, + { force: true } + ); + } else if ( + isConnectionDiagEnabled() && + (this.connectionHeartbeatLastLatencyMs >= 700 || seq <= 3 || seq % 60 === 0) + ) { + connectionDiag('log', 'health-ok', { + requestId, + seq, + elapsedMs: this.connectionHeartbeatLastLatencyMs, + status: responseStatus, + endpoint: healthUrl, + visibility: document?.visibilityState || 'unknown' + }); + } } catch (error) { - this.connectionHeartbeatFailCount = (this.connectionHeartbeatFailCount || 0) + 1; - // 一次失败就先置灰,避免“服务已断但常亮绿色”的误导 - this.isConnected = false; + const nextFailCount = (this.connectionHeartbeatFailCount || 0) + 1; + this.connectionHeartbeatFailCount = nextFailCount; + this.connectionHeartbeatLastLatencyMs = Date.now() - startedAt; + this.connectionHeartbeatLastStatusCode = responseStatus; + const errName = error?.name || 'Error'; + const errMessage = error?.message || String(error); + this.connectionHeartbeatLastError = `${errName}: ${errMessage}`; + const failThreshold = + typeof this.connectionHeartbeatFailThreshold === 'number' && + this.connectionHeartbeatFailThreshold > 0 + ? this.connectionHeartbeatFailThreshold + : 3; + const shouldDisconnect = nextFailCount >= failThreshold; + // 改为连续失败阈值后再置灰,避免偶发请求超时导致“假断连” + if (shouldDisconnect) { + this.isConnected = false; + } + if (wasConnected && shouldDisconnect) { + this.connectionHeartbeatLastChangeAt = Date.now(); + } + const shouldLogFail = + wasConnected || nextFailCount <= failThreshold || nextFailCount % 10 === 0; + connectionDiag( + shouldLogFail ? 'warn' : 'log', + 'health-failed', + { + requestId, + seq, + elapsedMs: this.connectionHeartbeatLastLatencyMs, + failCount: nextFailCount, + status: responseStatus, + endpoint: healthUrl, + diagEnabled, + wasConnected, + nowConnected: !!this.isConnected, + failThreshold, + shouldDisconnect, + errorName: errName, + errorMessage: errMessage, + timeout: errName === 'AbortError', + visibility: document?.visibilityState || 'unknown', + online: typeof navigator !== 'undefined' ? navigator.onLine : null, + conversationId: this.currentConversationId || null, + taskInProgress: !!this.taskInProgress, + streamingMessage: !!this.streamingMessage, + hasPendingTools: + typeof this.hasPendingToolActions === 'function' ? this.hasPendingToolActions() : null + }, + { force: shouldLogFail } + ); } finally { + this.connectionHeartbeatInFlight = false; if (timeoutId) { clearTimeout(timeoutId); } @@ -2000,6 +2353,15 @@ export const uiMethods = { } this.connectionHeartbeatActive = true; this.connectionHeartbeatFailCount = 0; + connectionDiag( + 'log', + 'heartbeat-start', + { + connectedIntervalMs: this.connectionHeartbeatIntervalMs, + disconnectedIntervalMs: this.connectionHeartbeatDisconnectedIntervalMs + }, + { force: true } + ); const runHeartbeat = async () => { if (!this.connectionHeartbeatActive) { return; @@ -2009,7 +2371,8 @@ export const uiMethods = { return; } const connectedInterval = - typeof this.connectionHeartbeatIntervalMs === 'number' && this.connectionHeartbeatIntervalMs > 0 + typeof this.connectionHeartbeatIntervalMs === 'number' && + this.connectionHeartbeatIntervalMs > 0 ? this.connectionHeartbeatIntervalMs : 8000; const disconnectedInterval = @@ -2018,6 +2381,17 @@ export const uiMethods = { ? this.connectionHeartbeatDisconnectedIntervalMs : 1000; const nextInterval = this.isConnected ? connectedInterval : disconnectedInterval; + if ( + isConnectionDiagEnabled() && + (!this.isConnected || (this.connectionHeartbeatSeq || 0) <= 3) + ) { + connectionDiag('log', 'heartbeat-next', { + seq: this.connectionHeartbeatSeq, + isConnected: !!this.isConnected, + failCount: this.connectionHeartbeatFailCount, + nextInterval + }); + } this.connectionHeartbeatTimer = window.setTimeout(() => { runHeartbeat(); }, nextInterval); @@ -2033,6 +2407,7 @@ export const uiMethods = { clearTimeout(this.connectionHeartbeatTimer); this.connectionHeartbeatTimer = null; } + connectionDiag('log', 'heartbeat-stop', {}, { force: true }); }, openRealtimeTerminal() { diff --git a/static/src/app/methods/versioning.ts b/static/src/app/methods/versioning.ts index 7f86111..6e542cc 100644 --- a/static/src/app/methods/versioning.ts +++ b/static/src/app/methods/versioning.ts @@ -1,6 +1,12 @@ // @ts-nocheck import { debugLog } from './common'; +const normalizeTrackingMode = (value: any): 'workspace_and_conversation' | 'conversation_only' => { + return String(value || '').toLowerCase() === 'conversation_only' + ? 'conversation_only' + : 'workspace_and_conversation'; +}; + export const versioningMethods = { async fetchVersioningStatus(conversationId = null, options = {}) { const targetId = conversationId || this.currentConversationId; @@ -18,6 +24,14 @@ export const versioningMethods = { const payload = data.data || {}; this.versioningHostMode = !!payload.host_mode; this.versioningEnabled = !!payload.enabled; + const resolvedTrackingMode = normalizeTrackingMode(payload.tracking_mode); + this.versioningTrackingMode = !this.versioningHostMode && + resolvedTrackingMode !== 'conversation_only' + ? 'conversation_only' + : resolvedTrackingMode; + if (!this.versioningHostMode) { + this.newConversationVersioningTrackingMode = 'conversation_only'; + } this.versioningMode = 'overwrite'; this.versioningRestoreMode = 'overwrite'; this.versioningMismatch = !!payload.mismatch; @@ -45,6 +59,9 @@ export const versioningMethods = { if (!resp.ok || !data?.success) { throw new Error(data?.error || '加载版本点失败'); } + if (data?.data?.tracking_mode) { + this.versioningTrackingMode = normalizeTrackingMode(data.data.tracking_mode); + } const items = Array.isArray(data?.data?.items) ? data.data.items : []; this.versioningCheckpoints = items; if (!items.length) { @@ -67,6 +84,12 @@ export const versioningMethods = { async openVersioningDialog() { if (!this.currentConversationId) { this.versioningEnabled = !!this.newConversationVersioningEnabled; + this.versioningTrackingMode = normalizeTrackingMode( + this.newConversationVersioningTrackingMode + ); + if (!this.versioningHostMode && this.versioningTrackingMode !== 'conversation_only') { + this.versioningTrackingMode = 'conversation_only'; + } this.versioningWorkspaceMatched = true; this.versioningCheckpoints = []; this.versioningSelectedSeq = null; @@ -87,18 +110,22 @@ export const versioningMethods = { await this.fetchVersioningCheckpoints(this.currentConversationId); }, + async handleVersioningTrackingModeChange(mode: string) { + await this.updateVersioningTrackingMode(mode); + }, + async toggleConversationVersioning(enabled: boolean) { if (!this.currentConversationId) { - if (!this.versioningHostMode) { - this.uiPushToast({ - title: '版本管理', - message: '仅宿主机模式支持版本管理', - type: 'warning' - }); - return; - } this.newConversationVersioningEnabled = !!enabled; this.versioningEnabled = !!enabled; + if (enabled) { + if (!this.versioningHostMode) { + this.versioningTrackingMode = 'conversation_only'; + } + this.newConversationVersioningTrackingMode = normalizeTrackingMode( + this.versioningTrackingMode + ); + } this.uiPushToast({ title: '版本管理', message: enabled ? '已为下一次新对话开启版本管理' : '已取消下一次新对话的版本管理', @@ -108,12 +135,18 @@ export const versioningMethods = { } this.versioningLoading = true; try { + let trackingMode = normalizeTrackingMode(this.versioningTrackingMode); + if (!this.versioningHostMode && trackingMode !== 'conversation_only') { + trackingMode = 'conversation_only'; + this.versioningTrackingMode = trackingMode; + } const resp = await fetch(`/api/conversations/${this.currentConversationId}/versioning`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: !!enabled, - mode: 'overwrite' + mode: 'overwrite', + tracking_mode: trackingMode }) }); const data = await resp.json().catch(() => ({})); @@ -121,6 +154,9 @@ export const versioningMethods = { throw new Error(data?.error || '切换版本管理失败'); } this.versioningEnabled = !!data?.data?.enabled; + this.versioningTrackingMode = normalizeTrackingMode( + data?.data?.tracking_mode || this.versioningTrackingMode + ); this.versioningMode = 'overwrite'; this.versioningRestoreMode = 'overwrite'; this.uiPushToast({ @@ -264,15 +300,20 @@ export const versioningMethods = { async applyPendingVersioningToConversation(conversationId: string) { const targetId = conversationId || this.currentConversationId; if (!targetId) return; - if (!this.versioningHostMode) return; if (!this.newConversationVersioningEnabled) return; try { + const trackingMode = normalizeTrackingMode( + !this.versioningHostMode + ? 'conversation_only' + : (this.newConversationVersioningTrackingMode || this.versioningTrackingMode) + ); const resp = await fetch(`/api/conversations/${targetId}/versioning`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: true, - mode: 'overwrite' + mode: 'overwrite', + tracking_mode: trackingMode }) }); const data = await resp.json().catch(() => ({})); @@ -281,6 +322,9 @@ export const versioningMethods = { } this.newConversationVersioningEnabled = false; this.versioningEnabled = true; + this.versioningTrackingMode = normalizeTrackingMode( + data?.data?.tracking_mode || trackingMode + ); } catch (error) { this.uiPushToast({ title: '版本管理', @@ -288,5 +332,64 @@ export const versioningMethods = { type: 'error' }); } + }, + + async updateVersioningTrackingMode(mode: string) { + const normalizedMode = normalizeTrackingMode(mode); + if (!this.versioningHostMode && normalizedMode === 'workspace_and_conversation') { + this.uiPushToast({ + title: '版本管理', + message: '当前模式仅支持“仅管理对话”', + type: 'warning' + }); + this.versioningTrackingMode = 'conversation_only'; + this.newConversationVersioningTrackingMode = 'conversation_only'; + return false; + } + this.versioningTrackingMode = normalizedMode; + if (!this.currentConversationId) { + this.newConversationVersioningTrackingMode = normalizedMode; + return true; + } + if (!this.versioningEnabled) { + return true; + } + this.versioningLoading = true; + try { + const resp = await fetch(`/api/conversations/${this.currentConversationId}/versioning`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + enabled: true, + mode: 'overwrite', + tracking_mode: normalizedMode + }) + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok || !data?.success) { + throw new Error(data?.error || '更新版本管理范围失败'); + } + this.versioningTrackingMode = normalizeTrackingMode( + data?.data?.tracking_mode || normalizedMode + ); + this.uiPushToast({ + title: '版本管理', + message: + this.versioningTrackingMode === 'conversation_only' + ? '已切换为仅管理对话' + : '已切换为管理工作区和对话', + type: 'success' + }); + return true; + } catch (error) { + this.uiPushToast({ + title: '版本管理', + message: error?.message || '更新版本管理范围失败', + type: 'error' + }); + return false; + } finally { + this.versioningLoading = false; + } } }; diff --git a/static/src/app/state.ts b/static/src/app/state.ts index 0b70be8..3ff3843 100644 --- a/static/src/app/state.ts +++ b/static/src/app/state.ts @@ -25,6 +25,17 @@ export function dataState() { taskInProgress: false, // 等待后台通知期间,用于发现并接管新建任务的轮询定时器 waitingTaskProbeTimer: null, + // 运行期消息堆积(提前发送 / 引导对话) + runtimeQueuedMessages: [], + runtimeGuidanceFallbackQueue: [], + runtimeQueueSuppressedMessageIds: new Set(), + runtimeGuidanceSuppressedTextCounts: {}, + runtimeQueueLimit: 5, + runtimeQueueAutoSendInProgress: false, + runtimeQueueSyncLockKey: '', + runtimeQueueSyncLockUntil: 0, + // 输入区动态保留高度(用于同步扩大消息区可滚动范围) + composerReservedHeight: 80, // 记录上一次成功加载历史的对话ID,防止初始化阶段重复加载导致动画播放两次 lastHistoryLoadedConversationId: null, @@ -98,6 +109,8 @@ export function dataState() { hostWorkspaceCreateError: '', versioningEnabled: false, newConversationVersioningEnabled: false, + versioningTrackingMode: 'workspace_and_conversation', + newConversationVersioningTrackingMode: 'workspace_and_conversation', versioningMode: 'overwrite', versioningMismatch: false, versioningWorkspaceMatched: true, @@ -139,8 +152,20 @@ export function dataState() { connectionHeartbeatTimer: null, connectionHeartbeatActive: false, connectionHeartbeatFailCount: 0, + connectionHeartbeatSeq: 0, + connectionHeartbeatLastLatencyMs: 0, + connectionHeartbeatLastError: '', + connectionHeartbeatLastStatusCode: null, + connectionHeartbeatLastChangeAt: 0, + connectionHeartbeatInFlight: false, + connectionHeartbeatFailThreshold: 3, + connectionHeartbeatRequestTimeoutMs: 5000, connectionHeartbeatIntervalMs: 8000, connectionHeartbeatDisconnectedIntervalMs: 1000, + composerDraftSaveTimer: null, + composerDraftDirty: false, + composerDraftLastSyncedContent: '', + composerDraftFetchSeq: 0, // 工具控制菜单 icons: ICONS, diff --git a/static/src/app/watchers.ts b/static/src/app/watchers.ts index aa6e60b..05fec2c 100644 --- a/static/src/app/watchers.ts +++ b/static/src/app/watchers.ts @@ -4,6 +4,9 @@ import { debugLog, traceLog } from './methods/common'; export const watchers = { inputMessage() { this.autoResizeInput(); + if (typeof this.scheduleComposerDraftPersist === 'function') { + this.scheduleComposerDraftPersist('watch-input-message'); + } }, messages: { deep: true, @@ -11,6 +14,37 @@ export const watchers = { this.refreshBlankHeroState(); } }, + composerBusy(newValue, oldValue) { + if (oldValue && !newValue && typeof this.tryAutoSendRuntimeQueuedMessages === 'function') { + this.tryAutoSendRuntimeQueuedMessages('watch-composer-idle'); + } + }, + runtimeQueuedMessages: { + deep: true, + handler(list) { + if ( + Array.isArray(list) && + list.length > 0 && + !this.composerBusy && + typeof this.tryAutoSendRuntimeQueuedMessages === 'function' + ) { + this.tryAutoSendRuntimeQueuedMessages('watch-runtime-queue'); + } + } + }, + runtimeGuidanceFallbackQueue: { + deep: true, + handler(list) { + if ( + Array.isArray(list) && + list.length > 0 && + !this.composerBusy && + typeof this.tryAutoSendRuntimeQueuedMessages === 'function' + ) { + this.tryAutoSendRuntimeQueuedMessages('watch-runtime-guidance-fallback'); + } + } + }, currentConversationTitle(newVal, oldVal) { const target = (newVal && newVal.trim()) || ''; if (this.suppressTitleTyping) { @@ -47,8 +81,15 @@ export const watchers = { newValue, skipConversationHistoryReload: this.skipConversationHistoryReload }); + if (oldValue !== newValue && typeof this.restoreComposerDraftState === 'function') { + this.restoreComposerDraftState( + `watch-conversation-id:${oldValue || 'none'}->${newValue || 'none'}` + ); + } if (!newValue || typeof newValue !== 'string' || newValue.startsWith('temp_')) { this.versioningEnabled = !!this.newConversationVersioningEnabled; + this.versioningTrackingMode = + this.newConversationVersioningTrackingMode || 'workspace_and_conversation'; this.versioningMismatch = false; return; } diff --git a/static/src/components/chat/actions/ToolAction.vue b/static/src/components/chat/actions/ToolAction.vue index 00785fd..11d641d 100644 --- a/static/src/components/chat/actions/ToolAction.vue +++ b/static/src/components/chat/actions/ToolAction.vue @@ -270,14 +270,19 @@ function renderWriteFile(result: any, args: any): string { const path = args.file_path || args.path || result.path || ''; const status = formatToolStatusLabel(result, '✓ 已写入'); const content = args.content || ''; - const isAppend = args.append || false; + const appendFlag = typeof args.append === 'boolean' ? args.append : null; + const modeRaw = String(result.mode || '').toLowerCase(); + const writeMode = + appendFlag === true || modeRaw === 'a' + ? '追加' + : appendFlag === false || modeRaw === 'w' + ? '覆盖' + : '无'; let html = '
'; html += `
路径:${escapeHtml(path)}
`; html += `
状态:${status}
`; - if (isAppend) { - html += `
模式:追加
`; - } + html += `
模式:${writeMode}
`; if (!result.success && result.error) { html += `
错误:${escapeHtml(String(result.error))}
`; } @@ -299,6 +304,15 @@ function renderWriteFile(result: any, args: any): string { function renderEditFile(result: any, args: any): string { const path = args.file_path || args.path || result.path || ''; const status = formatToolStatusLabel(result, '✓ 已编辑'); + const replaceAllValue = + typeof args.replace_all === 'boolean' ? (args.replace_all ? '开' : '关') : '无'; + const foundCount = + typeof result.found_matches === 'number' + ? result.found_matches + : typeof result.replacements === 'number' + ? result.replacements + : null; + const replacedCount = typeof result.replacements === 'number' ? result.replacements : null; // 兼容两种数据格式: // 1. 新格式:args.replacements 数组 @@ -310,6 +324,9 @@ function renderEditFile(result: any, args: any): string { let html = '
'; html += `
路径:${escapeHtml(path)}
`; html += `
状态:${status}
`; + html += `
替换全部:${replaceAllValue}
`; + html += `
找到:${foundCount ?? '无'}处
`; + html += `
替换:${replacedCount ?? '无'}处
`; if (!result.success && result.error) { html += `
错误:${escapeHtml(String(result.error))}
`; } diff --git a/static/src/components/chat/actions/toolRenderers.ts b/static/src/components/chat/actions/toolRenderers.ts index 51ba563..b39b26e 100644 --- a/static/src/components/chat/actions/toolRenderers.ts +++ b/static/src/components/chat/actions/toolRenderers.ts @@ -210,14 +210,19 @@ function renderWriteFile(result: any, args: any): string { const path = args.file_path || args.path || result.path || ''; const status = formatToolStatusLabel(result, '✓ 已写入'); const content = args.content || ''; - const isAppend = args.append || false; + const appendFlag = typeof args.append === 'boolean' ? args.append : null; + const modeRaw = String(result.mode || '').toLowerCase(); + const writeMode = + appendFlag === true || modeRaw === 'a' + ? '追加' + : appendFlag === false || modeRaw === 'w' + ? '覆盖' + : '无'; let html = '
'; html += `
路径:${escapeHtml(path)}
`; html += `
状态:${status}
`; - if (isAppend) { - html += `
模式:追加
`; - } + html += `
模式:${writeMode}
`; if (!result.success && result.error) { html += `
错误:${escapeHtml(String(result.error))}
`; } @@ -239,6 +244,15 @@ function renderWriteFile(result: any, args: any): string { function renderEditFile(result: any, args: any): string { const path = args.file_path || args.path || result.path || ''; const status = formatToolStatusLabel(result, '✓ 已编辑'); + const replaceAllValue = + typeof args.replace_all === 'boolean' ? (args.replace_all ? '开' : '关') : '无'; + const foundCount = + typeof result.found_matches === 'number' + ? result.found_matches + : typeof result.replacements === 'number' + ? result.replacements + : null; + const replacedCount = typeof result.replacements === 'number' ? result.replacements : null; // 兼容两种数据格式: // 1. 新格式:args.replacements 数组 @@ -250,6 +264,9 @@ function renderEditFile(result: any, args: any): string { let html = '
'; html += `
路径:${escapeHtml(path)}
`; html += `
状态:${status}
`; + html += `
替换全部:${replaceAllValue}
`; + html += `
找到:${foundCount ?? '无'}处
`; + html += `
替换:${replacedCount ?? '无'}处
`; if (!result.success && result.error) { html += `
错误:${escapeHtml(String(result.error))}
`; } diff --git a/static/src/components/input/InputComposer.vue b/static/src/components/input/InputComposer.vue index ca7a632..3751cf3 100644 --- a/static/src/components/input/InputComposer.vue +++ b/static/src/components/input/InputComposer.vue @@ -1,6 +1,38 @@ @@ -490,5 +929,4 @@ onMounted(() => { .image-remove-btn-hover:hover { color: #ef4444; } - diff --git a/static/src/components/overlay/BackgroundCommandDialog.vue b/static/src/components/overlay/BackgroundCommandDialog.vue index e7ad277..d6d1605 100644 --- a/static/src/components/overlay/BackgroundCommandDialog.vue +++ b/static/src/components/overlay/BackgroundCommandDialog.vue @@ -20,6 +20,17 @@ {{ activeDetail?.command || activeCommand.command }}
+
+ + {{ stopError }} +
{{ detailError }}
@@ -33,17 +44,46 @@