fix(prompt): merge disabled-tool notice into leading system prompts

This commit is contained in:
JOJO 2026-05-09 18:56:49 +08:00
parent d406244604
commit 8395cf8b4b
55 changed files with 4158 additions and 514 deletions

1
.gitignore vendored
View File

@ -41,3 +41,4 @@ doc/
# Host workspace config (local, use .example) # Host workspace config (local, use .example)
config/host_workspaces.json config/host_workspaces.json
config/custom_models.json

View File

@ -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 Flashreasoning_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 Flashreasoning_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 Proreasoning_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 Proreasoning_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 上下文。"
}
]
}

View File

@ -2,7 +2,7 @@
"models": [ "models": [
{ {
"model_name": "kimi-k2.6", "model_name": "kimi-k2.6",
"description": "Kimi-k2.6(测试别名),配置与 Kimi-k2.5 一致", "description": "Kimi-k2.6",
"visible": true, "visible": true,
"url": "${API_BASE_KIMI_OFFICIAL}", "url": "${API_BASE_KIMI_OFFICIAL}",
"apikey": "${API_KEY_KIMI_OFFICIAL}", "apikey": "${API_KEY_KIMI_OFFICIAL}",
@ -12,7 +12,7 @@
"max_output_tokens": 32768, "max_output_tokens": 32768,
"thinkmode_status": { "thinkmode_status": {
"type": "param_toggle", "type": "param_toggle",
"model_id": "kimi-k2.5", "model_id": "kimi-k2.6",
"fast_extra_parameter": { "fast_extra_parameter": {
"thinking": { "thinking": {
"type": "disabled" "type": "disabled"
@ -26,7 +26,7 @@
} }
}, },
"extra_parameter": {}, "extra_parameter": {},
"model_description": "你的基础模型是 Kimi-k2.6(测试别名),底层与 Kimi-k2.5 一致,并通过 thinking 参数开启/关闭思考能力。" "model_description": "你的基础模型是 Kimi-k2.6,并通过 thinking 参数开启/关闭思考能力。"
}, },
{ {
"model_name": "DeepSeek-V4-Flash Max", "model_name": "DeepSeek-V4-Flash Max",

View File

@ -1,27 +1,41 @@
"""项目路径与目录配置。""" """项目路径与目录配置。"""
import os 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 # 宿主机模式工作区配置文件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保留该键实际宿主机路径选择改由 JSON 管理)
HOST_PROJECT_PATH = os.environ.get("HOST_PROJECT_PATH", DEFAULT_PROJECT_PATH) HOST_PROJECT_PATH = _resolve_repo_path(os.environ.get("HOST_PROJECT_PATH", ""), DEFAULT_PROJECT_PATH)
PROMPTS_DIR = "./prompts" PROMPTS_DIR = _resolve_repo_path(os.environ.get("PROMPTS_DIR", ""), "./prompts")
DATA_DIR = "./data" DATA_DIR = _resolve_repo_path(os.environ.get("DATA_DIR", ""), "./data")
LOGS_DIR = "./logs" LOGS_DIR = _resolve_repo_path(os.environ.get("LOGS_DIR", ""), "./logs")
AGENT_SKILLS_DIR = "./agentskills" AGENT_SKILLS_DIR = _resolve_repo_path(os.environ.get("AGENT_SKILLS_DIR", ""), "./agentskills")
WORKSPACE_SKILLS_DIRNAME = "skills" 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" USERS_DB_FILE = f"{DATA_DIR}/users.json"
INVITE_CODES_FILE = f"{DATA_DIR}/invite_codes.json" INVITE_CODES_FILE = f"{DATA_DIR}/invite_codes.json"
ADMIN_POLICY_FILE = f"{DATA_DIR}/admin_policy.json" ADMIN_POLICY_FILE = f"{DATA_DIR}/admin_policy.json"
# API 专用用户与工作区(与网页用户隔离) # 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_USERS_DB_FILE = f"{DATA_DIR}/api_users.json"
API_TOKENS_FILE = f"{DATA_DIR}/api_tokens.json" API_TOKENS_FILE = f"{DATA_DIR}/api_tokens.json"
API_USAGE_FILE = f"{DATA_DIR}/api_usage.json" API_USAGE_FILE = f"{DATA_DIR}/api_usage.json"

View File

@ -1,6 +1,20 @@
"""子智能体相关配置。""" """子智能体相关配置。"""
import os 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") 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_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_TASKS_BASE_DIR = _resolve_repo_path(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_PROJECT_RESULTS_DIR = _resolve_repo_path(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_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")) SUB_AGENT_MAX_ACTIVE = int(os.environ.get("SUB_AGENT_MAX_ACTIVE", "5"))
__all__ = [ __all__ = [

View File

@ -140,7 +140,7 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
) )
self.easter_egg_manager = EasterEggManager() self.easter_egg_manager = EasterEggManager()
self._announced_sub_agent_tasks = set() self._announced_sub_agent_tasks = set()
self.silent_tool_disable = False # 是否静默工具禁用提示 self.silent_tool_disable = True # 是否静默工具禁用提示(默认开启)
self.current_session_id = 0 # 用于标识不同的任务会话 self.current_session_id = 0 # 用于标识不同的任务会话
# 工具类别(可被管理员动态覆盖) # 工具类别(可被管理员动态覆盖)
self.tool_categories_map = dict(TOOL_CATEGORIES) self.tool_categories_map = dict(TOOL_CATEGORIES)
@ -396,6 +396,13 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
except Exception: except Exception:
pass 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 场景) # 强制下次请求重新同步 skills含 AGENTS.md 场景)
self._skills_synced_project_path = None self._skills_synced_project_path = None

View File

@ -292,6 +292,14 @@ class MainTerminalContextMixin:
if isinstance(custom_system_prompt, str) and custom_system_prompt.strip(): if isinstance(custom_system_prompt, str) and custom_system_prompt.strip():
messages.append({"role": "system", "content": 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消息 # 添加对话历史保留完整结构包括tool_calls和tool消息
conversation = context["conversation"] conversation = context["conversation"]
replaced_tool_count = 0 replaced_tool_count = 0
@ -377,13 +385,6 @@ class MainTerminalContextMixin:
}) })
# 当前用户输入已经在conversation中了不需要重复添加 # 当前用户输入已经在conversation中了不需要重复添加
disabled_notice = self._format_disabled_tool_notice()
if disabled_notice:
messages.append({
"role": "system",
"content": disabled_notice
})
if shallow_replace_enabled: if shallow_replace_enabled:
print(f"[ContextCompression] build_messages 替换tool占位符: {replaced_tool_count}") print(f"[ContextCompression] build_messages 替换tool占位符: {replaced_tool_count}")
return messages return messages

View File

@ -540,14 +540,19 @@ class MainTerminalToolsDefinitionMixin:
}, },
"old_string": { "old_string": {
"type": "string", "type": "string",
"description": "要替换的文本(需与文件内容精确匹配,保留缩进" "description": "要替换的文本(需与文件内容精确匹配,保留缩进建议提供至少3行提升定位稳定性。需要批量替换的场景可以单行或不足一行"
}, },
"new_string": { "new_string": {
"type": "string", "type": "string",
"description": "用于替换的新文本(必须不同于 old_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"]
} }
} }
}, },

View File

@ -1065,16 +1065,27 @@ class MainTerminalToolsExecutionMixin:
path = arguments.get("file_path") path = arguments.get("file_path")
old_text = arguments.get("old_string") old_text = arguments.get("old_string")
new_text = arguments.get("new_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: if not path:
result = {"success": False, "error": "缺少必要参数: file_path"} result = {"success": False, "error": "缺少必要参数: file_path"}
elif old_text is None or new_text is None: elif old_text is None or new_text is None:
result = {"success": False, "error": "缺少必要参数: old_string/new_string"} 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: elif old_text == new_text:
result = {"success": False, "error": "old_string 与 new_string 相同,无法执行替换"} result = {"success": False, "error": "old_string 与 new_string 相同,无法执行替换"}
elif not old_text: elif not old_text:
result = {"success": False, "error": "old_string 不能为空,请从 read_file 内容中精确复制"} result = {"success": False, "error": "old_string 不能为空,请从 read_file 内容中精确复制"}
else: 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": elif tool_name == "create_folder":
result = self.file_manager.create_folder(arguments["path"]) result = self.file_manager.create_folder(arguments["path"])

View File

@ -46,7 +46,8 @@ function formatReadFile(result) {
function formatEditFile(result) { function formatEditFile(result) {
if (!result.success) return formatFailure(result.error || '修改失败'); if (!result.success) return formatFailure(result.error || '修改失败');
const count = typeof result.replacements === 'number' ? result.replacements : 0; 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) { function formatWebSearch(result) {

View File

@ -41,28 +41,43 @@ function diffSummary(oldText, newText) {
return { added, removed, hunks }; 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) { function editFileTool(workspace, args) {
const target = resolvePath(workspace, args.file_path); const target = resolvePath(workspace, args.file_path);
const oldString = args.old_string ?? ''; const oldString = args.old_string ?? '';
const newString = args.new_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(); const ctx = getContainerContext();
if (ctx) { if (ctx) {
const resolved = resolveContainerPath(workspace, args.file_path || '.', ctx); const resolved = resolveContainerPath(workspace, args.file_path || '.', ctx);
if (resolved.error) return { success: false, error: resolved.error }; if (resolved.error) return { success: false, error: resolved.error };
const rel = resolved.relativePath; const rel = resolved.relativePath;
if (!oldString) { if (!oldString) return { success: false, error: 'old_string 不能为空,请从 read_file 内容中精确复制' };
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,
};
}
const readResp = execContainerAction(ctx, 'read_file', { path: rel }); const readResp = execContainerAction(ctx, 'read_file', { path: rel });
if (!readResp.success) return { success: false, error: readResp.error || '读取失败' }; if (!readResp.success) return { success: false, error: readResp.error || '读取失败' };
@ -70,8 +85,12 @@ function editFileTool(workspace, args) {
if (!original.includes(oldString)) { if (!original.includes(oldString)) {
return { success: false, error: 'old_string 未匹配到内容' }; return { success: false, error: 'old_string 未匹配到内容' };
} }
const updated = original.split(oldString).join(newString); const matchedLines = findMatchLineNumbers(original, oldString);
const replacements = original.split(oldString).length - 1; 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', { const writeResp = execContainerAction(ctx, 'write_file', {
path: rel, path: rel,
content: updated, content: updated,
@ -79,10 +98,20 @@ function editFileTool(workspace, args) {
}); });
if (!writeResp.success) return { success: false, error: writeResp.error || '写入失败' }; if (!writeResp.success) return { success: false, error: writeResp.error || '写入失败' };
const diff = diffSummary(original, updated); const diff = diffSummary(original, updated);
const messageParts = [];
if (shortOldStringNotice) {
messageParts.push('提示old_string 少于3行已继续执行需要批量替换的场景可以单行或不足一行');
}
if (foundCount > 1) {
messageParts.push(`发现${foundCount}处,于${matchedLines.join(',')}行共替换${replacements}`);
}
return { return {
success: true, success: true,
path: resolved.containerPath, path: resolved.containerPath,
replacements, replacements,
found_matches: foundCount,
matched_lines: matchedLines,
message: messageParts.length > 0 ? messageParts.join('') : undefined,
diff, diff,
}; };
} }
@ -98,21 +127,35 @@ function editFileTool(workspace, args) {
return { success: false, error: '目标不是文件' }; return { success: false, error: '目标不是文件' };
} }
const original = fs.readFileSync(target, 'utf8'); const original = fs.readFileSync(target, 'utf8');
if (!creating && oldString === '') { if (oldString === '') {
return { success: false, error: 'old_string 不能为空,请从 read_file 内容中精确复制' }; return { success: false, error: 'old_string 不能为空,请从 read_file 内容中精确复制' };
} }
if (!creating && !original.includes(oldString)) { if (!creating && !original.includes(oldString)) {
return { success: false, error: 'old_string 未匹配到内容' }; return { success: false, error: 'old_string 未匹配到内容' };
} }
const updated = creating ? newString : original.split(oldString).join(newString); const matchedLines = creating ? [] : findMatchLineNumbers(original, oldString);
let replacements = creating ? 0 : original.split(oldString).length - 1; 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; if (creating && newString) replacements = 1;
fs.writeFileSync(target, updated, 'utf8'); fs.writeFileSync(target, updated, 'utf8');
const diff = diffSummary(original, updated); const diff = diffSummary(original, updated);
const messageParts = [];
if (shortOldStringNotice) {
messageParts.push('提示old_string 少于3行已继续执行需要批量替换的场景可以单行或不足一行');
}
if (foundCount > 1) {
messageParts.push(`发现${foundCount}处,于${matchedLines.join(',')}行共替换${replacements}`);
}
return { return {
success: true, success: true,
path: target, path: target,
replacements, replacements,
found_matches: foundCount,
matched_lines: matchedLines,
message: messageParts.length > 0 ? messageParts.join('') : undefined,
diff, diff,
}; };
} catch (err) { } catch (err) {

View File

@ -25,6 +25,7 @@ class BackgroundCommandManager:
def __init__(self, project_path: str): def __init__(self, project_path: str):
self.project_path = Path(project_path).resolve() self.project_path = Path(project_path).resolve()
self._records: Dict[str, Dict[str, Any]] = {} self._records: Dict[str, Dict[str, Any]] = {}
self._processes: Dict[str, subprocess.Popen] = {}
self._lock = threading.RLock() self._lock = threading.RLock()
self._cv = threading.Condition(self._lock) self._cv = threading.Condition(self._lock)
@ -257,6 +258,7 @@ class BackgroundCommandManager:
if rec is not None: if rec is not None:
rec["pid"] = process.pid rec["pid"] = process.pid
rec["updated_at"] = time.time() rec["updated_at"] = time.time()
self._processes[command_id] = process
def _reader(stream, collector, rec_key: str): def _reader(stream, collector, rec_key: str):
try: try:
@ -338,13 +340,193 @@ class BackgroundCommandManager:
with self._cv: with self._cv:
rec = self._records.get(command_id) rec = self._records.get(command_id)
if rec is not None: if rec is not None:
rec["status"] = status existing_status = rec.get("status")
rec["result"] = result existing_result = rec.get("result")
rec["truncated"] = truncated if existing_status == "cancelled" and isinstance(existing_result, dict):
rec["updated_at"] = time.time() existing_output = str(existing_result.get("output") or "")
rec["finished_at"] = time.time() 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() 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: def _build_current_output(self, rec: Dict[str, Any]) -> str:
output = "".join((rec.get("stdout_chunks") or []) + (rec.get("stderr_chunks") or [])) output = "".join((rec.get("stdout_chunks") or []) + (rec.get("stderr_chunks") or []))
if MAX_RUN_COMMAND_CHARS and len(output) > MAX_RUN_COMMAND_CHARS: 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]: 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: with self._cv:
rec = self._records.get(command_id) rec = self._records.get(command_id)
if not rec: if not rec:
@ -400,6 +583,7 @@ class BackgroundCommandManager:
def poll_updates(self, conversation_id: Optional[str] = None) -> List[Dict[str, Any]]: def poll_updates(self, conversation_id: Optional[str] = None) -> List[Dict[str, Any]]:
"""获取未通知且未被 sleep 领取的已完成任务。""" """获取未通知且未被 sleep 领取的已完成任务。"""
updates: List[Dict[str, Any]] = [] updates: List[Dict[str, Any]] = []
self.reconcile_stale_records(conversation_id=conversation_id)
with self._lock: with self._lock:
for rec in self._records.values(): for rec in self._records.values():
if conversation_id and rec.get("conversation_id") != conversation_id: if conversation_id and rec.get("conversation_id") != conversation_id:
@ -430,6 +614,7 @@ class BackgroundCommandManager:
rec["updated_at"] = time.time() rec["updated_at"] = time.time()
def get_record(self, command_id: str) -> Optional[Dict[str, Any]]: def get_record(self, command_id: str) -> Optional[Dict[str, Any]]:
self.reconcile_stale_records()
with self._lock: with self._lock:
rec = self._records.get(command_id) rec = self._records.get(command_id)
if not rec: if not rec:
@ -438,6 +623,7 @@ class BackgroundCommandManager:
def get_record_with_output(self, command_id: str) -> Optional[Dict[str, Any]]: def get_record_with_output(self, command_id: str) -> Optional[Dict[str, Any]]:
"""获取单条后台命令记录,并附带当前可读输出。""" """获取单条后台命令记录,并附带当前可读输出。"""
self.reconcile_stale_records()
with self._lock: with self._lock:
rec = self._records.get(command_id) rec = self._records.get(command_id)
if not rec: if not rec:
@ -453,6 +639,7 @@ class BackgroundCommandManager:
limit: int = 200, limit: int = 200,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""列出后台命令记录(按创建时间倒序)。""" """列出后台命令记录(按创建时间倒序)。"""
self.reconcile_stale_records(conversation_id=conversation_id)
with self._lock: with self._lock:
items: List[Dict[str, Any]] = [] items: List[Dict[str, Any]] = []
for rec in self._records.values(): for rec in self._records.values():
@ -468,6 +655,7 @@ class BackgroundCommandManager:
def has_pending_for_conversation(self, conversation_id: Optional[str]) -> bool: def has_pending_for_conversation(self, conversation_id: Optional[str]) -> bool:
if not conversation_id: if not conversation_id:
return False return False
self.reconcile_stale_records(conversation_id=conversation_id)
with self._lock: with self._lock:
for rec in self._records.values(): for rec in self._records.values():
if rec.get("conversation_id") != conversation_id: if rec.get("conversation_id") != conversation_id:
@ -482,6 +670,7 @@ class BackgroundCommandManager:
items: List[Dict[str, Any]] = [] items: List[Dict[str, Any]] = []
if not conversation_id: if not conversation_id:
return items return items
self.reconcile_stale_records(conversation_id=conversation_id)
with self._lock: with self._lock:
for rec in self._records.values(): for rec in self._records.values():
if rec.get("conversation_id") != conversation_id: if rec.get("conversation_id") != conversation_id:

View File

@ -122,6 +122,16 @@ def _read_text_segment(root, payload):
} }
data, lines = _read_text(target) data, lines = _read_text(target)
total = len(lines) 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_start = start if start and start > 0 else 1
line_end = end if end and end >= line_start else total line_end = end if end and end >= line_start else total
if line_start > total: if line_start > total:

View File

@ -4,6 +4,7 @@ import os
import shutil import shutil
from pathlib import Path from pathlib import Path
import re import re
from bisect import bisect_right
from typing import Optional, Dict, List, Set, Tuple, TYPE_CHECKING from typing import Optional, Dict, List, Set, Tuple, TYPE_CHECKING
from datetime import datetime from datetime import datetime
try: try:
@ -495,6 +496,17 @@ class FileManager:
lines = result["lines"] lines = result["lines"]
total_lines = len(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 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 end = end_line if end_line and end_line >= start else total_lines
if start > total_lines: if start > total_lines:
@ -1166,7 +1178,7 @@ class FileManager:
"error": write_error "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) result = self.read_file(path)
@ -1189,15 +1201,23 @@ class FileManager:
"error": "替换的新文本过长,建议分块处理", "error": "替换的新文本过长,建议分块处理",
"suggestion": "请将大内容分成多个小的替换操作" "suggestion": "请将大内容分成多个小的替换操作"
} }
short_old_text_notice = bool(old_text and len(old_text.splitlines()) < 3)
# 检查是否包含要替换的内容 # 检查是否包含要替换的内容
if old_text and old_text not in content: if old_text and old_text not in content:
return {"success": False, "error": "未找到要替换的内容"} 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: if old_text:
new_content = content.replace(old_text, new_text) if replace_all:
count = content.count(old_text) count = found_count
new_content = content.replace(old_text, new_text)
else:
count = 1
new_content = content.replace(old_text, new_text, 1)
else: else:
# 空文件直接写入新内容 # 空文件直接写入新内容
new_content = new_text new_content = new_text
@ -1207,9 +1227,43 @@ class FileManager:
result = self.write_file(path, new_content) result = self.write_file(path, new_content)
if result["success"]: if result["success"]:
result["replacements"] = count 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} 处内容") print(f"{OUTPUT_FORMATS['file']} 替换了 {count} 处内容")
return result 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: def clear_file(self, path: str) -> Dict:
"""清空文件内容""" """清空文件内容"""

View File

@ -3,7 +3,10 @@
from __future__ import annotations from __future__ import annotations
import json import json
import os
import re import re
import tempfile
import threading
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union 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}$") _WORKSPACE_ID_RE = re.compile(r"^[a-zA-Z0-9._-]{1,64}$")
_REPO_ROOT = Path(__file__).resolve().parents[1] _REPO_ROOT = Path(__file__).resolve().parents[1]
_HOST_WORKSPACE_LOCK = threading.RLock()
def _resolve_config_path(config_path: Optional[Union[str, Path]] = None) -> Path: 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) path.parent.mkdir(parents=True, exist_ok=True)
if path.exists(): if path.exists():
try: try:
data = json.loads(path.read_text(encoding="utf-8")) data = json.loads(path.read_text(encoding="utf-8"))
if isinstance(data, dict): if isinstance(data, dict):
return data return data
except Exception: if strict:
pass 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() data = _default_payload()
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") _atomic_write_json(path, data)
return data return data
@ -107,7 +133,8 @@ def load_host_workspace_catalog(
config_path: Optional[Union[str, Path]] = None, config_path: Optional[Union[str, Path]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
cfg_path = _resolve_config_path(config_path) 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") raw_workspaces = payload.get("workspaces")
if not isinstance(raw_workspaces, list): if not isinstance(raw_workspaces, list):
raw_workspaces = [] raw_workspaces = []
@ -174,56 +201,56 @@ def create_host_workspace(
config_path: Optional[Union[str, Path]] = None, config_path: Optional[Union[str, Path]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
cfg_path = _resolve_config_path(config_path) 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") raw_workspaces = payload.get("workspaces")
if not isinstance(raw_workspaces, list): if not isinstance(raw_workspaces, list):
raw_workspaces = [] raw_workspaces = []
normalized_path = _normalize_workspace_path(path) normalized_path = _normalize_workspace_path(path)
normalized_path.mkdir(parents=True, exist_ok=True) normalized_path.mkdir(parents=True, exist_ok=True)
target_path_str = str(normalized_path) target_path_str = str(normalized_path)
for idx, item in enumerate(raw_workspaces): for idx, item in enumerate(raw_workspaces):
existing = _normalize_entry(item, idx) existing = _normalize_entry(item, idx)
if not existing: if not existing:
continue continue
if existing.get("path") == target_path_str: if existing.get("path") == target_path_str:
# 已存在相同路径则直接返回 # 已存在相同路径则直接返回
return { return {
"created": False, "created": False,
"workspace": existing, "workspace": existing,
"catalog": load_host_workspace_catalog(config_path=cfg_path), "catalog": load_host_workspace_catalog(config_path=cfg_path),
} }
clean_label = str(label or "").strip() clean_label = str(label or "").strip()
base_id_seed = clean_label or normalized_path.name or "workspace" base_id_seed = clean_label or normalized_path.name or "workspace"
base_id = _slugify_workspace_id(base_id_seed) base_id = _slugify_workspace_id(base_id_seed)
existing_ids = { existing_ids = {
_normalize_workspace_id(item.get("workspace_id") or item.get("id"), i) _normalize_workspace_id(item.get("workspace_id") or item.get("id"), i)
for i, item in enumerate(raw_workspaces) for i, item in enumerate(raw_workspaces)
if isinstance(item, dict) if isinstance(item, dict)
} }
workspace_id = base_id workspace_id = base_id
suffix = 2 suffix = 2
while workspace_id in existing_ids: while workspace_id in existing_ids:
workspace_id = f"{base_id}-{suffix}" workspace_id = f"{base_id}-{suffix}"
suffix += 1 suffix += 1
workspace = { workspace = {
"workspace_id": workspace_id, "workspace_id": workspace_id,
"label": clean_label or workspace_id, "label": clean_label or workspace_id,
"path": target_path_str, "path": target_path_str,
} }
raw_workspaces.append(workspace) raw_workspaces.append(workspace)
payload["workspaces"] = raw_workspaces payload["workspaces"] = raw_workspaces
default_id = str(payload.get("default_workspace_id") or "").strip() default_id = str(payload.get("default_workspace_id") or "").strip()
if set_default or not default_id: if set_default or not default_id:
payload["default_workspace_id"] = workspace_id payload["default_workspace_id"] = workspace_id
cfg_path.parent.mkdir(parents=True, exist_ok=True) _atomic_write_json(cfg_path, payload)
cfg_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
return { return {
"created": True, "created": True,

View File

@ -211,7 +211,12 @@ class _StdioMCPClient:
if not use_container: if not use_container:
env = dict(os.environ) env = dict(os.environ)
env.update(env_raw) 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() container_name = str(getattr(session, "container_name", "") or "").strip()
if not container_name: if not container_name:
@ -731,6 +736,7 @@ class MCPClientManager:
self.registry = registry self.registry = registry
self.protocol_version = str(protocol_version or MCP_PROTOCOL_VERSION) self.protocol_version = str(protocol_version or MCP_PROTOCOL_VERSION)
self.container_session = container_session self.container_session = container_session
self._container_session_signature = self._compute_session_signature(container_session)
self._latest_alias_map: Dict[str, MCPToolBinding] = {} self._latest_alias_map: Dict[str, MCPToolBinding] = {}
self._client_pool: Dict[str, MCPClientPoolEntry] = {} self._client_pool: Dict[str, MCPClientPoolEntry] = {}
self._pool_lock = threading.RLock() self._pool_lock = threading.RLock()
@ -741,11 +747,27 @@ class MCPClientManager:
except Exception: except Exception:
pass 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: 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 return
self.close_all_clients() self.close_all_clients()
self.container_session = session self.container_session = session
self._container_session_signature = next_signature
@staticmethod @staticmethod
def _server_signature(server: Dict[str, Any]) -> str: def _server_signature(server: Dict[str, Any]) -> str:

View File

@ -75,7 +75,7 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = {
"shallow_compress_trigger_tool_calls_interval": None, "shallow_compress_trigger_tool_calls_interval": None,
"shallow_compress_keep_user_turn_tools": None, # 保留最近N次用户输入后的工具不压缩默认3 "shallow_compress_keep_user_turn_tools": None, # 保留最近N次用户输入后的工具不压缩默认3
"deep_compress_trigger_tokens": None, "deep_compress_trigger_tokens": None,
"silent_tool_disable": False, # 禁用工具时不向模型插入提示 "silent_tool_disable": True, # 禁用工具时不向模型插入提示(默认开启)
"enhanced_tool_display": True, # 增强工具显示 "enhanced_tool_display": True, # 增强工具显示
"versioning_restore_mode": "overwrite", # 版本回溯模式固定为 overwrite "versioning_restore_mode": "overwrite", # 版本回溯模式固定为 overwrite
"agents_md_auto_inject": False, # AGENTS.md 自动注入开关 "agents_md_auto_inject": False, # AGENTS.md 自动注入开关

View File

@ -4,16 +4,16 @@ import httpx
import json import json
from typing import Dict, Optional, Any, List from typing import Dict, Optional, Any, List
from datetime import datetime from datetime import datetime
from pathlib import Path
import re import re
try: 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: except ImportError:
import sys import sys
from pathlib import Path
project_root = Path(__file__).resolve().parents[1] project_root = Path(__file__).resolve().parents[1]
if str(project_root) not in sys.path: if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root)) 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: class SearchEngine:
def __init__(self): def __init__(self):
@ -286,19 +286,16 @@ class SearchEngine:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"search_{timestamp}.json" filename = f"search_{timestamp}.json"
file_path = f"./data/searches/{filename}" file_path = Path(DATA_DIR).expanduser().resolve() / "searches" / filename
file_path.parent.mkdir(parents=True, exist_ok=True)
# 确保目录存在
import os
os.makedirs(os.path.dirname(file_path), 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) json.dump(results, f, ensure_ascii=False, indent=2)
print(f"{OUTPUT_FORMATS['file']} 搜索结果已保存到: {file_path}") print(f"{OUTPUT_FORMATS['file']} 搜索结果已保存到: {file_path}")
return file_path return str(file_path)
def load_results(self, filename: str) -> Optional[Dict]: def load_results(self, filename: str) -> Optional[Dict]:
""" """
@ -310,10 +307,10 @@ class SearchEngine:
Returns: Returns:
搜索结果字典或None 搜索结果字典或None
""" """
file_path = f"./data/searches/{filename}" file_path = Path(DATA_DIR).expanduser().resolve() / "searches" / filename
try: 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) return json.load(f)
except FileNotFoundError: except FileNotFoundError:
print(f"{OUTPUT_FORMATS['error']} 文件不存在: {file_path}") print(f"{OUTPUT_FORMATS['error']} 文件不存在: {file_path}")

View File

@ -6,6 +6,7 @@ import time
import uuid import uuid
import os import os
import shutil import shutil
import signal
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
from typing import Dict, List, Optional, Any, Tuple, TYPE_CHECKING from typing import Dict, List, Optional, Any, Tuple, TYPE_CHECKING
@ -62,6 +63,10 @@ class SubAgentManager:
self.conversation_agents: Dict[str, List[int]] = {} self.conversation_agents: Dict[str, List[int]] = {}
self.processes: Dict[str, subprocess.Popen] = {} # task_id -> Popen对象 self.processes: Dict[str, subprocess.Popen] = {} # task_id -> Popen对象
self._load_state() self._load_state()
try:
self.reconcile_task_states()
except Exception:
pass
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 公共方法 # 公共方法
@ -267,6 +272,7 @@ class SubAgentManager:
task_id = task["task_id"] task_id = task["task_id"]
process = self.processes.get(task_id) process = self.processes.get(task_id)
pid = task.get("pid")
if process and process.poll() is None: if process and process.poll() is None:
# 进程还在运行,终止它 # 进程还在运行,终止它
@ -279,17 +285,17 @@ class SubAgentManager:
process.wait() process.wait()
except Exception as exc: except Exception as exc:
return {"success": False, "error": f"终止进程失败: {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" self.processes.pop(task_id, None)
task["updated_at"] = time.time() self._mark_task_terminated(
task["notified"] = True task,
task["final_result"] = { message="子智能体已被强制关闭。",
"success": False, system_message=f"🛑 子智能体{task.get('agent_id')} 已被手动关闭。",
"status": "terminated", notified=True,
"task_id": task_id, )
"agent_id": task.get("agent_id"),
"message": "子智能体已被强制关闭。",
}
self._save_state() self._save_state()
return { return {
@ -662,6 +668,155 @@ class SubAgentManager:
if migrated: if migrated:
self._save_state() 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): def _save_state(self):
payload = { payload = {
"tasks": self.tasks, "tasks": self.tasks,
@ -674,6 +829,7 @@ class SubAgentManager:
return f"sub_{agent_id}_{int(time.time())}_{suffix}" return f"sub_{agent_id}_{int(time.time())}_{suffix}"
def _active_task_count(self, conversation_id: Optional[str] = None) -> int: def _active_task_count(self, conversation_id: Optional[str] = None) -> int:
self.reconcile_task_states(conversation_id=conversation_id)
active = [ active = [
t for t in self.tasks.values() t for t in self.tasks.values()
if t.get("status") in {"pending", "running"} if t.get("status") in {"pending", "running"}
@ -729,6 +885,7 @@ class SubAgentManager:
return candidate return candidate
def _select_task(self, task_id: Optional[str], agent_id: Optional[int]) -> Optional[Dict]: def _select_task(self, task_id: Optional[str], agent_id: Optional[int]) -> Optional[Dict]:
self.reconcile_task_states()
if task_id: if task_id:
return self.tasks.get(task_id) return self.tasks.get(task_id)
@ -761,6 +918,7 @@ class SubAgentManager:
def poll_updates(self) -> List[Dict]: def poll_updates(self) -> List[Dict]:
"""检查运行中的子智能体任务,返回新完成的结果。""" """检查运行中的子智能体任务,返回新完成的结果。"""
updates: List[Dict] = [] updates: List[Dict] = []
self.reconcile_task_states()
pending_tasks = [ pending_tasks = [
task for task in self.tasks.values() task for task in self.tasks.values()
if task.get("status") not in TERMINAL_STATUSES.union({"terminated"}) 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]]: 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]] = [] overview: List[Dict[str, Any]] = []
state_changed = False
for task_id, task in self.tasks.items(): for task_id, task in self.tasks.items():
if conversation_id and task.get("conversation_id") != conversation_id: if conversation_id and task.get("conversation_id") != conversation_id:
continue continue
@ -1103,30 +1261,6 @@ class SubAgentManager:
"sub_conversation_id": task.get("sub_conversation_id"), "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": if snapshot["status"] in TERMINAL_STATUSES or snapshot["status"] == "terminated":
# 已结束的任务带上最终结果/系统消息,方便前端展示 # 已结束的任务带上最终结果/系统消息,方便前端展示
final_result = task.get("final_result") or {} final_result = task.get("final_result") or {}
@ -1136,6 +1270,4 @@ class SubAgentManager:
overview.append(snapshot) overview.append(snapshot)
overview.sort(key=lambda item: item.get("created_at") or 0, reverse=True) overview.sort(key=lambda item: item.get("created_at") or 0, reverse=True)
if state_changed:
self._save_state()
return overview return overview

View File

@ -27,6 +27,8 @@ class VersioningPaths:
class ConversationVersioningManager: class ConversationVersioningManager:
"""Manage hidden git snapshots bound to a conversation.""" """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") SYSTEM_AUTO_DIRS = ("compact_result", "skills", "user_upload")
def __init__(self, project_path: Path | str, data_dir: Path | str, conversation_id: str): 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(), 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 # low-level helpers
# ---------------------------- # ----------------------------
@ -151,16 +160,20 @@ class ConversationVersioningManager:
return { return {
"enabled": False, "enabled": False,
"mode": "overwrite", "mode": "overwrite",
"tracking_mode": self.TRACKING_MODE_WORKSPACE_AND_CONVERSATION,
"last_commit": None, "last_commit": None,
"last_input_seq": 0, "last_input_seq": 0,
"updated_at": None, "updated_at": None,
} }
try: 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: except Exception:
return { return {
"enabled": False, "enabled": False,
"mode": "overwrite", "mode": "overwrite",
"tracking_mode": self.TRACKING_MODE_WORKSPACE_AND_CONVERSATION,
"last_commit": None, "last_commit": None,
"last_input_seq": 0, "last_input_seq": 0,
"updated_at": None, "updated_at": None,
@ -168,6 +181,7 @@ class ConversationVersioningManager:
def save_meta(self, meta: Dict[str, Any]) -> Dict[str, Any]: def save_meta(self, meta: Dict[str, Any]) -> Dict[str, Any]:
payload = dict(meta or {}) payload = dict(meta or {})
payload["tracking_mode"] = self.normalize_tracking_mode(payload.get("tracking_mode"))
payload["updated_at"] = datetime.now().isoformat() payload["updated_at"] = datetime.now().isoformat()
self.paths.meta_path.parent.mkdir(parents=True, exist_ok=True) 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") 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]: def _normalize_checkpoint_row(self, row: Dict[str, Any]) -> Dict[str, Any]:
payload = dict(row or {}) payload = dict(row or {})
payload["tracking_mode"] = self.normalize_tracking_mode(payload.get("tracking_mode"))
files = payload.get("files") files = payload.get("files")
if isinstance(files, list): if isinstance(files, list):
normalized_files: List[Dict[str, Any]] = [] normalized_files: List[Dict[str, Any]] = []
@ -276,12 +291,22 @@ class ConversationVersioningManager:
head = self._get_head_commit() head = self._get_head_commit()
return {"ok": True, "head": head} 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() 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() self.ensure_repo()
meta["enabled"] = bool(enabled) meta["enabled"] = bool(enabled)
meta["mode"] = "overwrite" meta["mode"] = "overwrite"
meta["tracking_mode"] = normalized_tracking_mode
meta["last_commit"] = self._get_head_commit() meta["last_commit"] = self._get_head_commit()
saved = self.save_meta(meta) saved = self.save_meta(meta)
return saved return saved
@ -291,17 +316,20 @@ class ConversationVersioningManager:
*, *,
workspace_path: Optional[str] = None, workspace_path: Optional[str] = None,
conversation_snapshot: Optional[Dict[str, Any]] = None, conversation_snapshot: Optional[Dict[str, Any]] = None,
tracking_mode: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Ensure a visible seq=0 checkpoint exists after enabling versioning. 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) existing = self.get_checkpoint(0)
if existing: if existing:
return {"created": False, "row": existing, "reason": "already_exists"} return {"created": False, "row": existing, "reason": "already_exists"}
current_head = self._get_head_commit() 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") raise VersioningError("创建初始版本点失败:未获取到 commit")
row = { row = {
@ -319,23 +347,37 @@ class ConversationVersioningManager:
"files": [], "files": [],
"changed": False, "changed": False,
"run_status": "initial", "run_status": "initial",
"tracking_mode": normalized_tracking_mode,
} }
if isinstance(conversation_snapshot, dict): if isinstance(conversation_snapshot, dict):
row["snapshot_file"] = self._write_snapshot(0, conversation_snapshot) row["snapshot_file"] = self._write_snapshot(0, conversation_snapshot)
self._append_input(row) self._append_input(row)
meta = self.load_meta() meta = self.load_meta()
meta["tracking_mode"] = normalized_tracking_mode
meta["last_commit"] = current_head meta["last_commit"] = current_head
if int(meta.get("last_input_seq") or 0) < 0: if int(meta.get("last_input_seq") or 0) < 0:
meta["last_input_seq"] = 0 meta["last_input_seq"] = 0
self.save_meta(meta) self.save_meta(meta)
return {"created": True, "row": row, "reason": "created"} 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. Ensure the baseline commit reflects current workspace before first manual input checkpoint.
This is intentionally hidden from checkpoint list (no seq row created). 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() self.ensure_repo()
meta = self.load_meta() meta = self.load_meta()
if self.get_checkpoint(0): if self.get_checkpoint(0):
@ -439,28 +481,37 @@ class ConversationVersioningManager:
workspace_path: Optional[str] = None, workspace_path: Optional[str] = None,
conversation_snapshot: Optional[Dict[str, Any]] = None, conversation_snapshot: Optional[Dict[str, Any]] = None,
run_status: Optional[str] = None, run_status: Optional[str] = None,
tracking_mode: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
self.ensure_repo() meta = self.load_meta()
previous_head = self._get_head_commit() normalized_tracking_mode = self.normalize_tracking_mode(
self._stage_all() tracking_mode or meta.get("tracking_mode")
_, staged_numstat, _ = self._run_git(["diff", "--cached", "--numstat"], check=False) )
has_changes = bool((staged_numstat or "").strip()) previous_head: Optional[str] = self._get_head_commit()
if has_changes: current_head: Optional[str] = previous_head
next_seq = int(self.load_meta().get("last_input_seq") or 0) + 1
title = (message or "").strip().splitlines()[0][:72] if message else "" if normalized_tracking_mode == self.TRACKING_MODE_WORKSPACE_AND_CONVERSATION:
commit_msg = f"input#{next_seq} {title}".strip() self.ensure_repo()
self._run_git(["commit", "-m", commit_msg]) previous_head = self._get_head_commit()
current_head = self._get_head_commit() self._stage_all()
if not current_head: _, staged_numstat, _ = self._run_git(["diff", "--cached", "--numstat"], check=False)
raise VersioningError("创建版本快照失败:未获取到 commit") 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 = ( diff_summary = (
self._build_diff_summary(previous_head, current_head) 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": []} else {"insertions": 0, "deletions": 0, "files_changed": 0, "files": []}
) )
meta = self.load_meta()
seq = int(meta.get("last_input_seq") or 0) + 1 seq = int(meta.get("last_input_seq") or 0) + 1
row = { row = {
"seq": seq, "seq": seq,
@ -475,7 +526,11 @@ class ConversationVersioningManager:
"deletions": int(diff_summary.get("deletions") or 0), "deletions": int(diff_summary.get("deletions") or 0),
"files_changed": int(diff_summary.get("files_changed") or 0), "files_changed": int(diff_summary.get("files_changed") or 0),
"files": diff_summary.get("files") or [], "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): if isinstance(conversation_snapshot, dict):
row["snapshot_file"] = self._write_snapshot(seq, conversation_snapshot) row["snapshot_file"] = self._write_snapshot(seq, conversation_snapshot)
@ -484,6 +539,7 @@ class ConversationVersioningManager:
self._append_input(row) self._append_input(row)
meta["last_input_seq"] = seq meta["last_input_seq"] = seq
meta["last_commit"] = current_head meta["last_commit"] = current_head
meta["tracking_mode"] = normalized_tracking_mode
self.save_meta(meta) self.save_meta(meta)
return row return row
@ -686,7 +742,22 @@ class ConversationVersioningManager:
self.save_meta(meta) self.save_meta(meta)
return {"success": True, "commit": commit} 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() expected = str(expected_workspace_path or "").strip()
current = str(self.project_path) current = str(self.project_path)
workspace_matched = True if not expected else (Path(expected).expanduser().resolve() == self.project_path) workspace_matched = True if not expected else (Path(expected).expanduser().resolve() == self.project_path)

View File

@ -87,7 +87,7 @@
- `create_file`:创建空文件(禁止在根目录创建,需先建子目录) - `create_file`:创建空文件(禁止在根目录创建,需先建子目录)
- `write_file`:写入文件(`append` 控制覆盖/追加) - `write_file`:写入文件(`append` 控制覆盖/追加)
- `read_file`:读取文件(支持 `read`/`search`/`extract` 三种模式) - `read_file`:读取文件(支持 `read`/`search`/`extract` 三种模式)
- `edit_file`:精确字符串替换 - `edit_file`:精确字符串替换`replace_all` 必填,必须显式传入 `true/false``old_string` 建议至少 3 行以提升定位稳定性。需要批量替换的场景可以单行或不足一行)
- `delete_file` / `rename_file` / `create_folder`:文件管理 - `delete_file` / `rename_file` / `create_folder`:文件管理
**注意**:超大文件用 `search` 定位 + `extract` 抽取,避免全文读取。 **注意**:超大文件用 `search` 定位 + `extract` 抽取,避免全文读取。

View File

@ -87,7 +87,7 @@
- `create_file`:创建空文件(禁止在根目录创建,需先建子目录) - `create_file`:创建空文件(禁止在根目录创建,需先建子目录)
- `write_file`:写入文件(`append` 控制覆盖/追加) - `write_file`:写入文件(`append` 控制覆盖/追加)
- `read_file`:读取文件(支持 `read`/`search`/`extract` 三种模式) - `read_file`:读取文件(支持 `read`/`search`/`extract` 三种模式)
- `edit_file`:精确字符串替换 - `edit_file`:精确字符串替换`replace_all` 必填,必须显式传入 `true/false``old_string` 建议至少 3 行以提升定位稳定性。需要批量替换的场景可以单行或不足一行)
- `delete_file` / `rename_file` / `create_folder`:文件管理 - `delete_file` / `rename_file` / `create_folder`:文件管理
**注意**:超大文件用 `search` 定位 + `extract` 抽取,避免全文读取。 **注意**:超大文件用 `search` 定位 + `extract` 抽取,避免全文读取。

View File

@ -79,10 +79,14 @@ def minimal_tool_definitions() -> List[Dict[str, Any]]:
"type": "object", "type": "object",
"properties": { "properties": {
"file_path": {"type": "string", "description": "目标文件的相对路径"}, "file_path": {"type": "string", "description": "目标文件的相对路径"},
"old_string": {"type": "string", "description": "需要替换的原文"}, "old_string": {
"new_string": {"type": "string", "description": "替换后的新内容"} "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"]
} }
} }
} }

View File

@ -159,8 +159,6 @@ def _prepare_hidden_versioning_baseline_for_first_input(
) -> None: ) -> None:
"""Ensure first-run baseline is committed (hidden, no checkpoint row).""" """Ensure first-run baseline is committed (hidden, no checkpoint row)."""
try: try:
if not bool(getattr(web_terminal, "username", None) == "host"):
return
if _should_skip_versioning_for_message( if _should_skip_versioning_for_message(
message=message, message=message,
auto_user_message_event=auto_user_message_event, 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 {}) versioning_meta = ((conv_data.get("metadata") or {}).get("versioning") or {})
if not bool(versioning_meta.get("enabled", False)): if not bool(versioning_meta.get("enabled", False)):
return 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( manager = ConversationVersioningManager(
project_path=workspace.project_path, project_path=workspace.project_path,
data_dir=workspace.data_dir, data_dir=workspace.data_dir,
conversation_id=conversation_id, conversation_id=conversation_id,
) )
baseline = manager.ensure_baseline_for_first_input() baseline = manager.ensure_baseline_for_first_input(tracking_mode=tracking_mode)
debug_log( debug_log(
f"[Versioning][Baseline] conv={conversation_id} " f"[Versioning][Baseline] conv={conversation_id} "
f"tracking_mode={tracking_mode} "
f"created={baseline.get('created')} skipped={baseline.get('skipped')} " f"created={baseline.get('created')} skipped={baseline.get('skipped')} "
f"reason={baseline.get('reason')} head={baseline.get('head')}" 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, auto_user_message_event: bool,
run_status: str = "completed", run_status: str = "completed",
) -> None: ) -> None:
"""Host-only hidden snapshot after current manual user input run finished.""" """Hidden snapshot after current manual user input run finished."""
try: try:
if not bool(getattr(web_terminal, "username", None) == "host"):
return
if _should_skip_versioning_for_message( if _should_skip_versioning_for_message(
message=message, message=message,
auto_user_message_event=auto_user_message_event, 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 {}) versioning_meta = ((conv_data.get("metadata") or {}).get("versioning") or {})
if not bool(versioning_meta.get("enabled", False)): if not bool(versioning_meta.get("enabled", False)):
return 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_messages = conv_data.get("messages") or []
snapshot_payload = { snapshot_payload = {
"conversation_id": conversation_id, "conversation_id": conversation_id,
@ -237,9 +250,11 @@ def _record_hidden_versioning_checkpoint_after_run(
workspace_path=str(workspace.project_path), workspace_path=str(workspace.project_path),
conversation_snapshot=snapshot_payload, conversation_snapshot=snapshot_payload,
run_status=str(run_status or "completed"), run_status=str(run_status or "completed"),
tracking_mode=tracking_mode,
) )
debug_log( debug_log(
f"[Versioning][Checkpoint] conv={conversation_id} seq={row.get('seq')} " 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"msg_index={message_index} snapshot_messages={len(snapshot_messages)} "
f"commit={row.get('commit')} changed={row.get('changed')} status={row.get('run_status')}" 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": { "versioning": {
"enabled": True, "enabled": True,
"mode": "overwrite", "mode": "overwrite",
"tracking_mode": tracking_mode,
"last_commit": row.get("commit"), "last_commit": row.get("commit"),
"last_input_seq": int(row.get("seq") or 0), "last_input_seq": int(row.get("seq") or 0),
"updated_at": datetime.now().isoformat(), "updated_at": datetime.now().isoformat(),
@ -1350,6 +1366,10 @@ async def handle_task_with_sender(
bg_manager = getattr(web_terminal, "background_command_manager", None) bg_manager = getattr(web_terminal, "background_command_manager", None)
has_running_background_commands = False has_running_background_commands = False
if manager: 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"): if not hasattr(web_terminal, "_announced_sub_agent_tasks"):
web_terminal._announced_sub_agent_tasks = set() web_terminal._announced_sub_agent_tasks = set()
running_tasks = [ running_tasks = [
@ -1396,6 +1416,10 @@ async def handle_task_with_sender(
# 检查是否有后台 run_command 或待通知任务 # 检查是否有后台 run_command 或待通知任务
if bg_manager and conversation_id: 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) waiting_items = bg_manager.list_waiting_items(conversation_id)
if waiting_items: if waiting_items:
has_running_background_commands = True has_running_background_commands = True
@ -1420,6 +1444,17 @@ async def handle_task_with_sender(
socketio.start_background_task(run_bg_poll) socketio.start_background_task(run_bg_poll)
has_running_completion_jobs = has_running_sub_agents or has_running_background_commands 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: 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_sub_agents': has_running_completion_jobs,
'has_running_background_commands': has_running_background_commands, 'has_running_background_commands': has_running_background_commands,
'pending_runtime_guidance_messages': pending_runtime_guidance_messages,
}) })

View File

@ -45,10 +45,26 @@ def _build_tool_approval_preview(web_terminal, function_name: str, arguments: Di
file_path = args.get("file_path") file_path = args.get("file_path")
old_string = args.get("old_string") old_string = args.get("old_string")
new_string = args.get("new_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["file_path"] = file_path
preview["replace_all"] = replace_all
if not file_path: if not file_path:
preview["summary"] = "缺少 file_path" preview["summary"] = "缺少 file_path"
return preview 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: try:
valid, err, full_path = web_terminal.file_manager._validate_path(str(file_path)) valid, err, full_path = web_terminal.file_manager._validate_path(str(file_path))
if not valid or full_path is None: 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"] = "目标文件不存在,无法生成上下文预览" preview["summary"] = "目标文件不存在,无法生成上下文预览"
return preview return preview
content = full_path.read_text(encoding="utf-8", errors="ignore") content = full_path.read_text(encoding="utf-8", errors="ignore")
old_text = str(old_string or "")
new_text = str(new_string or "") new_text = str(new_string or "")
old_lines = old_text.splitlines() old_lines = old_text.splitlines()
new_lines = new_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_start_line": start_line_no,
"old_end_line": end_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: else:
preview["edit_context"] = { preview["edit_context"] = {
"before": [], "before": [],
@ -93,6 +109,8 @@ def _build_tool_approval_preview(web_terminal, function_name: str, arguments: Di
"old_end_line": None, "old_end_line": None,
} }
preview["summary"] = "未在文件中定位到 old_string显示原始替换内容" preview["summary"] = "未在文件中定位到 old_string显示原始替换内容"
if short_old_text_notice:
preview["summary"] = f"{preview['summary']}old_string 少于3行已告知并继续"
except Exception as exc: except Exception as exc:
preview["summary"] = f"生成编辑预览失败: {exc}" preview["summary"] = f"生成编辑预览失败: {exc}"
return preview return preview
@ -936,6 +954,52 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
if tool_failed: if tool_failed:
mark_force_thinking(web_terminal, reason=f"{function_name}_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 web_terminal._tool_loop_active = previous_tool_loop_active
return {"stopped": False, "last_tool_call_time": last_tool_call_time} return {"stopped": False, "last_tool_call_time": last_tool_call_time}

View File

@ -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) current_conv_id = getattr(getattr(terminal, "context_manager", None), "current_conversation_id", None)
if not current_conv_id: if not current_conv_id:
return 0 return 0
try:
manager.reconcile_task_states(conversation_id=current_conv_id)
except Exception:
pass
running_tasks = [ running_tasks = [
task for task in manager.tasks.values() task for task in manager.tasks.values()
if task.get("status") not in TERMINAL_STATUSES.union({"terminated"}) 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}" 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: def _is_host_mode_request(username: str) -> bool:
return bool(session.get("host_mode")) or (username == "host") 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]: def _get_conversation_versioning_meta(terminal: WebTerminal, conversation_id: str) -> Dict[str, Any]:
normalized = _normalize_conv_id(conversation_id) normalized = _normalize_conv_id(conversation_id)
data = terminal.context_manager.conversation_manager.load_conversation(normalized) or {} 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): if not isinstance(versioning, dict):
versioning = {} versioning = {}
enabled = bool(versioning.get("enabled", False)) enabled = bool(versioning.get("enabled", False))
tracking_mode = _normalize_versioning_tracking_mode(versioning.get("tracking_mode"))
mode = "overwrite" 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( def _update_conversation_versioning_meta(
@ -187,14 +249,17 @@ def _update_conversation_versioning_meta(
*, *,
enabled: bool, enabled: bool,
mode: str, mode: str,
tracking_mode: Optional[str] = None,
last_commit: Optional[str] = None, last_commit: Optional[str] = None,
last_input_seq: Optional[int] = None, last_input_seq: Optional[int] = None,
) -> bool: ) -> bool:
normalized = _normalize_conv_id(conversation_id) normalized = _normalize_conv_id(conversation_id)
normalized_tracking_mode = _normalize_versioning_tracking_mode(tracking_mode)
payload: Dict[str, Any] = { payload: Dict[str, Any] = {
"versioning": { "versioning": {
"enabled": bool(enabled), "enabled": bool(enabled),
"mode": "overwrite", "mode": "overwrite",
"tracking_mode": normalized_tracking_mode,
"updated_at": datetime.now().isoformat(), "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) 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 拆分) === # === 背景生成对话标题(从 app_legacy 拆分) ===
@conversation_bp.route('/api/conversations', methods=['GET']) @conversation_bp.route('/api/conversations', methods=['GET'])
@api_login_required @api_login_required
@ -361,37 +499,46 @@ def load_conversation(conversation_id, terminal: WebTerminal, workspace: UserWor
session['thinking_mode'] = terminal.thinking_mode session['thinking_mode'] = terminal.thinking_mode
session['model_key'] = getattr(terminal, "model_key", None) session['model_key'] = getattr(terminal, "model_key", None)
normalized_id = _normalize_conv_id(conversation_id) normalized_id = _normalize_conv_id(conversation_id)
if _is_host_mode_request(username): try:
try: vm = _get_conv_versioning_manager(workspace, normalized_id)
vm = _get_conv_versioning_manager(workspace, normalized_id) vmeta = _get_conversation_versioning_meta(terminal, normalized_id)
vmeta = _get_conversation_versioning_meta(terminal, normalized_id) tracking_mode = _normalize_versioning_tracking_mode(vmeta.get("tracking_mode"))
latest_checkpoint = vm.get_latest_checkpoint() if vmeta.get("enabled") else None enabled = bool(vmeta.get("enabled")) and _can_use_versioning_scope(username, tracking_mode)
latest_commit = (latest_checkpoint or {}).get("commit") if latest_checkpoint else None latest_checkpoint = vm.get_latest_checkpoint() if enabled else None
expected_workspace_path = (latest_checkpoint or {}).get("workspace_path") if latest_checkpoint else None latest_commit = (latest_checkpoint or {}).get("commit") if latest_checkpoint else None
mismatch = vm.detect_mismatch(latest_commit, expected_workspace_path=expected_workspace_path) if vmeta.get("enabled") else { expected_workspace_path = (latest_checkpoint or {}).get("workspace_path") if latest_checkpoint else None
"has_repo": False, mismatch = vm.detect_mismatch(
"head": None, latest_commit,
"dirty": False, expected_workspace_path=expected_workspace_path,
"mismatch": False, tracking_mode=tracking_mode,
"workspace_matched": True, ) if enabled else {
} "has_repo": False,
result["versioning"] = { "head": None,
"enabled": bool(vmeta.get("enabled")), "dirty": False,
"mode": "overwrite", "mismatch": False,
"latest_seq": int((latest_checkpoint or {}).get("seq") or 0) if latest_checkpoint else None, "workspace_matched": True,
"latest_commit": latest_commit, }
"mismatch": bool(mismatch.get("mismatch")), result["versioning"] = {
"dirty": bool(mismatch.get("dirty")), "host_mode": _is_host_mode_request(username),
"workspace_matched": bool(mismatch.get("workspace_matched", True)), "enabled": enabled,
} "mode": "overwrite",
except Exception as exc: "tracking_mode": tracking_mode,
debug_log(f"[Versioning] load status 读取失败: {exc}") "latest_seq": int((latest_checkpoint or {}).get("seq") or 0) if latest_checkpoint else None,
result["versioning"] = { "latest_commit": latest_commit,
"enabled": False, "mismatch": bool(mismatch.get("mismatch")),
"mode": "overwrite", "dirty": bool(mismatch.get("dirty")),
"mismatch": False, "workspace_matched": bool(mismatch.get("workspace_matched", True)),
"error": str(exc), }
} 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', { 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) normalized_id = _normalize_conv_id(conversation_id)
host_mode = _is_host_mode_request(username) host_mode = _is_host_mode_request(username)
versioning_meta = _get_conversation_versioning_meta(terminal, normalized_id) 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) 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( mismatch = manager.detect_mismatch(
(latest or {}).get("commit"), (latest or {}).get("commit"),
expected_workspace_path=(latest or {}).get("workspace_path"), 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, "has_repo": False,
"head": None, "head": None,
"dirty": False, "dirty": False,
@ -606,8 +756,11 @@ def get_conversation_versioning_status(conversation_id, terminal: WebTerminal, w
"data": { "data": {
"conversation_id": normalized_id, "conversation_id": normalized_id,
"host_mode": host_mode, "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", "mode": "overwrite",
"tracking_mode": tracking_mode,
"latest_seq": int((latest or {}).get("seq") or 0) if latest else None, "latest_seq": int((latest or {}).get("seq") or 0) if latest else None,
"latest_commit": (latest or {}).get("commit"), "latest_commit": (latest or {}).get("commit"),
"mismatch": bool(mismatch.get("mismatch")), "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): def update_conversation_versioning(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
try: try:
normalized_id = _normalize_conv_id(conversation_id) normalized_id = _normalize_conv_id(conversation_id)
if not _is_host_mode_request(username): host_mode = _is_host_mode_request(username)
return jsonify({"success": False, "error": "版本管理仅支持宿主机模式"}), 400 current_meta = _get_conversation_versioning_meta(terminal, normalized_id)
payload = request.get_json(silent=True) or {} payload = request.get_json(silent=True) or {}
enabled = bool(payload.get("enabled")) enabled = bool(payload.get("enabled"))
mode = "overwrite" 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) 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: if enabled:
conv_data = terminal.context_manager.conversation_manager.load_conversation(normalized_id) or {} conv_data = terminal.context_manager.conversation_manager.load_conversation(normalized_id) or {}
snapshot_payload = { snapshot_payload = {
@ -646,10 +804,12 @@ def update_conversation_versioning(conversation_id, terminal: WebTerminal, works
init_result = manager.ensure_initial_checkpoint( init_result = manager.ensure_initial_checkpoint(
workspace_path=str(workspace.project_path), workspace_path=str(workspace.project_path),
conversation_snapshot=snapshot_payload, conversation_snapshot=snapshot_payload,
tracking_mode=tracking_mode,
) )
init_row = init_result.get("row") or {} init_row = init_result.get("row") or {}
debug_log( debug_log(
f"[Versioning][Init] conv={normalized_id} enabled={enabled} " 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"created={init_result.get('created')} reason={init_result.get('reason')} "
f"seq={init_row.get('seq')} commit={init_row.get('commit')}" 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, normalized_id,
enabled=enabled, enabled=enabled,
mode=mode, mode=mode,
tracking_mode=tracking_mode,
last_commit=meta.get("last_commit"), last_commit=meta.get("last_commit"),
last_input_seq=int(meta.get("last_input_seq") or 0), 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, "conversation_id": normalized_id,
"enabled": bool(meta.get("enabled")), "enabled": bool(meta.get("enabled")),
"mode": "overwrite", "mode": "overwrite",
"tracking_mode": tracking_mode,
"last_commit": meta.get("last_commit"), "last_commit": meta.get("last_commit"),
"last_input_seq": int(meta.get("last_input_seq") or 0), "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): def list_conversation_versioning_checkpoints(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
try: try:
normalized_id = _normalize_conv_id(conversation_id) normalized_id = _normalize_conv_id(conversation_id)
if not _is_host_mode_request(username): host_mode = _is_host_mode_request(username)
return jsonify({"success": False, "error": "版本管理仅支持宿主机模式"}), 400
versioning_meta = _get_conversation_versioning_meta(terminal, normalized_id) 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"): 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) manager = _get_conv_versioning_manager(workspace, normalized_id)
rows = manager.list_checkpoints() rows = manager.list_checkpoints()
return jsonify({ return jsonify({
@ -699,6 +866,7 @@ def list_conversation_versioning_checkpoints(conversation_id, terminal: WebTermi
"data": { "data": {
"enabled": True, "enabled": True,
"mode": "overwrite", "mode": "overwrite",
"tracking_mode": tracking_mode,
"items": rows, "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): def get_conversation_versioning_checkpoint_detail(conversation_id, seq: int, terminal: WebTerminal, workspace: UserWorkspace, username: str):
try: try:
normalized_id = _normalize_conv_id(conversation_id) normalized_id = _normalize_conv_id(conversation_id)
if not _is_host_mode_request(username): host_mode = _is_host_mode_request(username)
return jsonify({"success": False, "error": "版本管理仅支持宿主机模式"}), 400 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) manager = _get_conv_versioning_manager(workspace, normalized_id)
row = manager.get_checkpoint_detail(seq, include_patch=True) row = manager.get_checkpoint_detail(seq, include_patch=True)
if not row: 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): def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
try: try:
normalized_id = _normalize_conv_id(conversation_id) normalized_id = _normalize_conv_id(conversation_id)
if not _is_host_mode_request(username): host_mode = _is_host_mode_request(username)
return jsonify({"success": False, "error": "版本管理仅支持宿主机模式"}), 400
payload = request.get_json(silent=True) or {} payload = request.get_json(silent=True) or {}
seq = int(payload.get("seq") or 0) seq = int(payload.get("seq") or 0)
if seq < 0: if seq < 0:
@ -746,16 +916,28 @@ def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTer
checkpoint = manager.get_checkpoint(seq) checkpoint = manager.get_checkpoint(seq)
if not checkpoint: if not checkpoint:
return jsonify({"success": False, "error": "未找到对应版本点"}), 404 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( debug_log(
f"[Versioning][Restore] start conv={normalized_id} seq={seq} mode={restore_mode} " 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}" f"target_commit={checkpoint.get('commit')} workspace={workspace.project_path}"
) )
manager.restore_to_commit(checkpoint.get("commit")) if tracking_mode == ConversationVersioningManager.TRACKING_MODE_WORKSPACE_AND_CONVERSATION:
debug_log( manager.restore_to_commit(checkpoint.get("commit"))
f"[Versioning][Restore] git restored conv={normalized_id} seq={seq} " debug_log(
f"commit={checkpoint.get('commit')}" 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 cm = terminal.context_manager.conversation_manager
conv_data = cm.load_conversation(normalized_id) or {} conv_data = cm.load_conversation(normalized_id) or {}
@ -810,6 +992,9 @@ def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTer
if not ok: if not ok:
return jsonify({"success": False, "error": "覆盖当前对话失败"}), 500 return jsonify({"success": False, "error": "覆盖当前对话失败"}), 500
prune_info = manager.prune_checkpoints_after(seq) 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( debug_log(
f"[Versioning][Restore] overwrite pruned conv={normalized_id} seq<={seq} " f"[Versioning][Restore] overwrite pruned conv={normalized_id} seq<={seq} "
f"kept={prune_info.get('kept')} removed={prune_info.get('removed')} " 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, normalized_id,
enabled=True, enabled=True,
mode="overwrite", 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), last_input_seq=int(prune_info.get("max_seq") or checkpoint.get("seq") or 0),
) )
cm.update_conversation_metadata( cm.update_conversation_metadata(
@ -831,7 +1017,8 @@ def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTer
"versioning": { "versioning": {
"enabled": True, "enabled": True,
"mode": "overwrite", "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), "last_input_seq": int(prune_info.get("max_seq") or checkpoint.get("seq") or 0),
"updated_at": datetime.now().isoformat(), "updated_at": datetime.now().isoformat(),
}, },
@ -876,6 +1063,7 @@ def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTer
"conversation_id": target_conversation_id, "conversation_id": target_conversation_id,
"restored_seq": int(checkpoint.get("seq") or 0), "restored_seq": int(checkpoint.get("seq") or 0),
"restored_commit": checkpoint.get("commit"), "restored_commit": checkpoint.get("commit"),
"tracking_mode": tracking_mode,
"source_conversation_id": normalized_id, "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 return jsonify({"success": False, "error": str(exc)}), 500
@conversation_bp.route('/api/sub_agents/<task_id>/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']) @conversation_bp.route('/api/background_commands', methods=['GET'])
@api_login_required @api_login_required
@with_terminal @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) records = manager.list_records(conversation_id=conversation_id, limit=limit_num)
data = [] data = []
terminal_statuses = {"completed", "failed", "timeout", "cancelled"}
for rec in records: for rec in records:
result = rec.get("result") if isinstance(rec.get("result"), dict) else {} 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({ data.append({
"command_id": rec.get("command_id"), "command_id": rec.get("command_id"),
"status": rec.get("status"), "status": status,
"command": rec.get("command"), "command": rec.get("command"),
"conversation_id": rec.get("conversation_id"), "conversation_id": rec.get("conversation_id"),
"created_at": rec.get("created_at"), "created_at": rec.get("created_at"),
@ -1197,6 +1423,7 @@ def list_background_commands(terminal: WebTerminal, workspace: UserWorkspace, us
"timeout": rec.get("timeout"), "timeout": rec.get("timeout"),
"return_code": result.get("return_code"), "return_code": result.get("return_code"),
"run_in_background": True, "run_in_background": True,
"notice_pending": notice_pending,
}) })
return jsonify({"success": True, "data": data}) return jsonify({"success": True, "data": data})
except Exception as exc: 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 return jsonify({"success": False, "error": str(exc)}), 500
@conversation_bp.route('/api/background_commands/<command_id>/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/<conversation_id>/duplicate', methods=['POST']) @conversation_bp.route('/api/conversations/<conversation_id>/duplicate', methods=['POST'])
@api_login_required @api_login_required
@with_terminal @with_terminal

View File

@ -253,6 +253,9 @@ async def run_deep_compression(
old_title = conv_data.get("title") or "未命名" old_title = conv_data.get("title") or "未命名"
source_versioning = metadata.get("versioning") if isinstance(metadata.get("versioning"), dict) else {} source_versioning = metadata.get("versioning") if isinstance(metadata.get("versioning"), dict) else {}
source_versioning_enabled = bool(source_versioning.get("enabled")) 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) compression_count = int(metadata.get("compression_count", 0) or 0)
previous_records = _normalize_deep_compression_records(metadata) previous_records = _normalize_deep_compression_records(metadata)
previous_max_count = max([int(item.get("count") or 0) for item in previous_records], default=0) 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, data_dir=workspace.data_dir,
conversation_id=new_conv_id, 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 {} new_conv_data = cm.conversation_manager.load_conversation(new_conv_id) or {}
snapshot_payload = { snapshot_payload = {
"conversation_id": new_conv_id, "conversation_id": new_conv_id,
@ -362,6 +369,7 @@ async def run_deep_compression(
init_result = vm.ensure_initial_checkpoint( init_result = vm.ensure_initial_checkpoint(
workspace_path=str(workspace.project_path), workspace_path=str(workspace.project_path),
conversation_snapshot=snapshot_payload, conversation_snapshot=snapshot_payload,
tracking_mode=source_tracking_mode,
) )
init_row = init_result.get("row") or {} init_row = init_result.get("row") or {}
last_commit = init_row.get("commit") or vm_meta.get("last_commit") last_commit = init_row.get("commit") or vm_meta.get("last_commit")
@ -371,6 +379,7 @@ async def run_deep_compression(
"versioning": { "versioning": {
"enabled": True, "enabled": True,
"mode": "overwrite", "mode": "overwrite",
"tracking_mode": source_tracking_mode,
"updated_at": datetime.now().isoformat(), "updated_at": datetime.now().isoformat(),
"last_commit": last_commit, "last_commit": last_commit,
"last_input_seq": int(vm_meta.get("last_input_seq") or 0), "last_input_seq": int(vm_meta.get("last_input_seq") or 0),

View File

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import time import time
import re import re
import os
from pathlib import Path from pathlib import Path
from flask import Blueprint, jsonify, request, send_file, session from flask import Blueprint, jsonify, request, send_file, session
@ -20,9 +21,12 @@ from modules.host_workspace_manager import (
resolve_host_workspace, resolve_host_workspace,
) )
from utils.host_workspace_debug import write_host_workspace_debug from utils.host_workspace_debug import write_host_workspace_debug
from .utils_common import log_conn_diag
from . import state from . import state
status_bp = Blueprint('status', __name__) 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]: 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" 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') @status_bp.route('/api/status')
@api_login_required @api_login_required
@with_terminal @with_terminal
def get_status(terminal, workspace, username): 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: if terminal.terminal_manager:
status['terminals'] = terminal.terminal_manager.list_terminals() status['terminals'] = terminal.terminal_manager.list_terminals()
timings["terminals_ms"] = (time.perf_counter() - phase_started_at) * 1000
phase_started_at = time.perf_counter()
try: try:
current_conv = terminal.context_manager.current_conversation_id current_conv = terminal.context_manager.current_conversation_id
status.setdefault('conversation', {})['current_id'] = current_conv status.setdefault('conversation', {})['current_id'] = current_conv
@ -92,13 +142,18 @@ def get_status(terminal, workspace, username):
"job_id": meta.get("compression_job_id"), "job_id": meta.get("compression_job_id"),
} }
except Exception as exc: 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) status['project_path'] = str(workspace.project_path)
phase_started_at = time.perf_counter()
try: try:
status['container'] = container_manager.get_container_status(username) status['container'] = container_manager.get_container_status(username)
except Exception as exc: except Exception as exc:
status['container'] = {"success": False, "error": str(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 status['version'] = AGENT_VERSION
phase_started_at = time.perf_counter()
try: try:
policy = resolve_admin_policy(user_manager.get_user(username)) policy = resolve_admin_policy(user_manager.get_user(username))
status['admin_policy'] = { status['admin_policy'] = {
@ -109,6 +164,20 @@ def get_status(terminal, workspace, username):
} }
except Exception: except Exception:
pass 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) return jsonify(status)

View File

@ -14,7 +14,7 @@ from .auth_helpers import api_login_required, get_current_username
from .context import get_user_resources, ensure_conversation_loaded from .context import get_user_resources, ensure_conversation_loaded
from .chat_flow import run_chat_task_sync from .chat_flow import run_chat_task_sync
from .state import stop_flags 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 from utils.host_workspace_debug import write_host_workspace_debug
@ -38,6 +38,8 @@ class TaskRecord:
"session_data", "session_data",
"stop_requested", "stop_requested",
"next_event_idx", "next_event_idx",
"runtime_pending_queue",
"runtime_guidance_queue",
) )
def __init__( def __init__(
@ -72,6 +74,8 @@ class TaskRecord:
self.session_data: Dict[str, Any] = {} self.session_data: Dict[str, Any] = {}
self.stop_requested: bool = False self.stop_requested: bool = False
self.next_event_idx: int = 0 self.next_event_idx: int = 0
self.runtime_pending_queue: List[Dict[str, Any]] = []
self.runtime_guidance_queue: List[str] = []
class TaskManager: class TaskManager:
@ -199,6 +203,233 @@ class TaskManager:
debug_log(f"[Task] 任务 {task_id} 已取消,事件队列已清空") debug_log(f"[Task] 任务 {task_id} 已取消,事件队列已清空")
return True 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 ---- # ---- internal helpers ----
def _append_event(self, rec: TaskRecord, event_type: str, data: Dict[str, Any]): def _append_event(self, rec: TaskRecord, event_type: str, data: Dict[str, Any]):
with self._lock: with self._lock:
@ -533,9 +764,14 @@ def _normalize_media_payload(images: List[Any], videos: List[Any]) -> tuple[List
@tasks_bp.route("/api/tasks/<task_id>", methods=["GET"]) @tasks_bp.route("/api/tasks/<task_id>", methods=["GET"])
@api_login_required @api_login_required
def get_task_api(task_id: str): def get_task_api(task_id: str):
started_at = time.time()
username = get_current_username() username = get_current_username()
poll_req_id = request.headers.get("X-Task-Poll", "-")
rec = task_manager.get_task(username, task_id) rec = task_manager.get_task(username, task_id)
if not rec: 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 return jsonify({"success": False, "error": "任务不存在"}), 404
try: try:
offset = int(request.args.get("from", 0)) offset = int(request.args.get("from", 0))
@ -543,6 +779,20 @@ def get_task_api(task_id: str):
offset = 0 offset = 0
events = [e for e in rec.events if e["idx"] >= offset] events = [e for e in rec.events if e["idx"] >= offset]
next_offset = events[-1]["idx"] + 1 if events else 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({ return jsonify({
"success": True, "success": True,
"data": { "data": {
@ -555,6 +805,9 @@ def get_task_api(task_id: str):
"error": rec.error, "error": rec.error,
"events": events, "events": events,
"next_offset": next_offset, "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: if not ok:
return jsonify({"success": False, "error": "任务不存在"}), 404 return jsonify({"success": False, "error": "任务不存在"}), 404
return jsonify({"success": True}) return jsonify({"success": True})
@tasks_bp.route("/api/tasks/<task_id>/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/<task_id>/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/<task_id>/runtime_queue/<message_id>", 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/<task_id>/runtime_queue/<message_id>/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 [],
},
}
)

View File

@ -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_BACKEND_LOG_FILE = Path(LOGS_DIR).expanduser().resolve() / "chunk_backend.log"
CHUNK_FRONTEND_LOG_FILE = Path(LOGS_DIR).expanduser().resolve() / "chunk_frontend.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" 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: def _write_log(file_path: Path, message: str) -> None:
@ -158,6 +159,11 @@ def debug_log(message):
_write_log(DEBUG_LOG_FILE, 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): 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') preview = content_preview.replace('\n', '\\n')
_write_log( _write_log(
@ -192,4 +198,6 @@ __all__ = [
"CHUNK_BACKEND_LOG_FILE", "CHUNK_BACKEND_LOG_FILE",
"CHUNK_FRONTEND_LOG_FILE", "CHUNK_FRONTEND_LOG_FILE",
"STREAMING_DEBUG_LOG_FILE", "STREAMING_DEBUG_LOG_FILE",
"CONN_DIAG_LOG_FILE",
"log_conn_diag",
] ]

View File

@ -121,6 +121,7 @@
'chat-container--monitor': chatDisplayMode === 'monitor', 'chat-container--monitor': chatDisplayMode === 'monitor',
'has-title-ribbon': titleRibbonVisible 'has-title-ribbon': titleRibbonVisible
}" }"
:style="chatContainerStyle"
> >
<TokenDrawer <TokenDrawer
:visible="Boolean(currentConversationId)" :visible="Boolean(currentConversationId)"
@ -294,6 +295,7 @@
:permission-options="permissionModeOptions" :permission-options="permissionModeOptions"
:current-context-tokens="currentContextTokens" :current-context-tokens="currentContextTokens"
:versioning-enabled="versioningEnabled" :versioning-enabled="versioningEnabled"
:runtime-queued-messages="runtimeQueuedMessages"
@update:input-message="inputSetMessage" @update:input-message="inputSetMessage"
@input-change="handleInputChange" @input-change="handleInputChange"
@input-focus="handleInputFocus" @input-focus="handleInputFocus"
@ -323,6 +325,9 @@
@toggle-permission-menu="togglePermissionMenu" @toggle-permission-menu="togglePermissionMenu"
@change-permission-mode="changePermissionMode" @change-permission-mode="changePermissionMode"
@open-versioning-dialog="openVersioningDialog" @open-versioning-dialog="openVersioningDialog"
@guide-runtime-message="handleGuideRuntimeMessage"
@delete-runtime-message="handleDeleteRuntimeMessage"
@composer-height-change="handleComposerHeightChange"
/> />
</div> </div>
</main> </main>
@ -402,6 +407,7 @@
v-if="versioningDialogOpen" v-if="versioningDialogOpen"
:host-mode="versioningHostMode" :host-mode="versioningHostMode"
:enabled="versioningEnabled" :enabled="versioningEnabled"
:tracking-mode="versioningTrackingMode"
:loading="versioningLoading" :loading="versioningLoading"
:items="versioningCheckpoints" :items="versioningCheckpoints"
:selected-seq="versioningSelectedSeq" :selected-seq="versioningSelectedSeq"
@ -415,6 +421,7 @@
@toggle-enabled="toggleConversationVersioning" @toggle-enabled="toggleConversationVersioning"
@select="selectVersioningCheckpoint" @select="selectVersioningCheckpoint"
@update:restore-mode="versioningRestoreMode = $event" @update:restore-mode="versioningRestoreMode = $event"
@update:tracking-mode="handleVersioningTrackingModeChange"
@confirm="confirmVersioningRestore" @confirm="confirmVersioningRestore"
/> />
</transition> </transition>

View File

@ -129,6 +129,17 @@ export const computed = {
titleRibbonVisible() { titleRibbonVisible() {
return !this.isMobileViewport && this.chatDisplayMode === 'chat'; 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, [ ...mapWritableState(useToolStore, [
'preparingTools', 'preparingTools',
'activeTools', 'activeTools',

View File

@ -71,6 +71,7 @@ export async function mounted() {
document.addEventListener('click', this.handleCopyCodeClick); document.addEventListener('click', this.handleCopyCodeClick);
window.addEventListener('popstate', this.handlePopState); window.addEventListener('popstate', this.handlePopState);
window.addEventListener('keydown', this.handleMobileOverlayEscape); window.addEventListener('keydown', this.handleMobileOverlayEscape);
window.addEventListener('beforeunload', this.handleBeforeUnloadDraftPersist);
this.setupMobileViewportWatcher(); this.setupMobileViewportWatcher();
this.subAgentFetch(); this.subAgentFetch();
@ -112,6 +113,7 @@ export function beforeUnmount() {
document.removeEventListener('click', this.handleCopyCodeClick); document.removeEventListener('click', this.handleCopyCodeClick);
window.removeEventListener('popstate', this.handlePopState); window.removeEventListener('popstate', this.handlePopState);
window.removeEventListener('keydown', this.handleMobileOverlayEscape); window.removeEventListener('keydown', this.handleMobileOverlayEscape);
window.removeEventListener('beforeunload', this.handleBeforeUnloadDraftPersist);
this.teardownMobileViewportWatcher(); this.teardownMobileViewportWatcher();
this.subAgentStopPolling(); this.subAgentStopPolling();
this.backgroundCommandStopPolling(); this.backgroundCommandStopPolling();

View File

@ -94,6 +94,18 @@ export const conversationMethods = {
this.inputSetLineCount(1); this.inputSetLineCount(1);
this.inputSetMultiline(false); this.inputSetMultiline(false);
this.inputClearMessage(); 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.inputClearSelectedImages();
this.inputSetImagePickerOpen(false); this.inputSetImagePickerOpen(false);
this.imageEntries = []; this.imageEntries = [];
@ -309,6 +321,12 @@ export const conversationMethods = {
console.error('[切换对话] 检查/停止任务失败:', error); console.error('[切换对话] 检查/停止任务失败:', error);
} }
await this.persistComposerDraftNow({
reason: `switch-conversation:${conversationId}`,
force: true,
keepalive: true
}).catch(() => {});
try { try {
// 1. 调用加载API // 1. 调用加载API
const response = await fetch(`/api/conversations/${conversationId}/load`, { const response = await fetch(`/api/conversations/${conversationId}/load`, {
@ -440,6 +458,12 @@ export const conversationMethods = {
}); });
this.logMessageState('createNewConversation:start'); this.logMessageState('createNewConversation:start');
await this.persistComposerDraftNow({
reason: 'create-new-conversation',
force: true,
keepalive: true
}).catch(() => {});
// 应用个性化设置中的默认模型和思考模式 // 应用个性化设置中的默认模型和思考模式
try { try {
const personalizationStore = usePersonalizationStore(); const personalizationStore = usePersonalizationStore();

View File

@ -3,6 +3,114 @@ import { debugLog } from './common';
import { useModelStore } from '../../stores/model'; import { useModelStore } from '../../stores/model';
export const messageMethods = { 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 = {}) { async executeSystemCommand(rawCommand, options = {}) {
const command = (rawCommand || '').toString().trim(); const command = (rawCommand || '').toString().trim();
if (!command) { 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) { if (this.compressionInProgress) {
this.uiPushToast({ this.uiPushToast({
title: '对话自动压缩中', title: '对话自动压缩中',
@ -111,15 +561,39 @@ export const messageMethods = {
}); });
return; 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 (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(); this.stopTask();
} else { } else {
this.sendMessage(); this.sendMessage();
} }
}, },
async sendMessage() { async sendMessage(options = {}) {
console.log('[DEBUG_AWAITING] ===== sendMessage ====='); console.log('[DEBUG_AWAITING] ===== sendMessage =====');
const presetText = typeof options?.presetText === 'string' ? options.presetText : null;
const usePresetText = presetText !== null;
if (this.compressionInProgress) { if (this.compressionInProgress) {
this.uiPushToast({ this.uiPushToast({
@ -127,35 +601,51 @@ export const messageMethods = {
message: '压缩完成后才能继续发送消息', message: '压缩完成后才能继续发送消息',
type: 'warning' type: 'warning'
}); });
return; return false;
} }
if (this.streamingUi) { if (this.streamingUi && !usePresetText) {
return; return false;
} }
if (this.mediaUploading) { if (!this.isConnected) {
this.uiPushToast({
title: '连接已断开',
message: '当前无法发送消息,请等待连接恢复后重试',
type: 'warning'
});
return false;
}
if (this.mediaUploading && !usePresetText) {
this.uiPushToast({ this.uiPushToast({
title: '上传中', title: '上传中',
message: '请等待图片/视频上传完成后再发送', message: '请等待图片/视频上传完成后再发送',
type: 'info' type: 'info'
}); });
return; return false;
} }
const text = (this.inputMessage || '').trim(); const text = ((usePresetText ? presetText : this.inputMessage) || '').trim();
const images = Array.isArray(this.selectedImages) ? this.selectedImages.slice(0, 9) : []; const images = usePresetText
const videos = Array.isArray(this.selectedVideos) ? this.selectedVideos.slice(0, 1) : []; ? []
: 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 hasText = text.length > 0;
const hasImages = images.length > 0; const hasImages = images.length > 0;
const hasVideos = videos.length > 0; const hasVideos = videos.length > 0;
if (!hasText && !hasImages && !hasVideos) { if (!hasText && !hasImages && !hasVideos) {
return; return false;
} }
const quotaType = this.thinkingMode ? 'thinking' : 'fast'; const quotaType = this.thinkingMode ? 'thinking' : 'fast';
if (this.isQuotaExceeded(quotaType)) { if (this.isQuotaExceeded(quotaType)) {
this.showQuotaToast({ type: quotaType }); this.showQuotaToast({ type: quotaType });
return; return false;
} }
const modelStore = useModelStore(); const modelStore = useModelStore();
@ -166,7 +656,7 @@ export const messageMethods = {
message: '请切换到支持图片输入的模型再发送图片', message: '请切换到支持图片输入的模型再发送图片',
type: 'error' type: 'error'
}); });
return; return false;
} }
if (hasVideos && !currentModel?.supportsVideo) { if (hasVideos && !currentModel?.supportsVideo) {
@ -175,7 +665,7 @@ export const messageMethods = {
message: '请切换到支持视频输入的模型后再发送视频', message: '请切换到支持视频输入的模型后再发送视频',
type: 'error' type: 'error'
}); });
return; return false;
} }
if (hasVideos && hasImages) { if (hasVideos && hasImages) {
@ -184,7 +674,7 @@ export const messageMethods = {
message: '视频与图片需分开发送,每条仅包含一种媒体', message: '视频与图片需分开发送,每条仅包含一种媒体',
type: 'warning' type: 'warning'
}); });
return; return false;
} }
if (hasVideos) { if (hasVideos) {
@ -248,7 +738,7 @@ export const messageMethods = {
message: error?.message || '创建新对话失败,请重试', message: error?.message || '创建新对话失败,请重试',
type: 'error' type: 'error'
}); });
return; return false;
} }
} }
@ -277,7 +767,8 @@ export const messageMethods = {
await taskStore.createTask(message, images, videos, targetConversationId, { await taskStore.createTask(message, images, videos, targetConversationId, {
model_key: this.currentModelKey, model_key: this.currentModelKey,
run_mode: this.runMode, run_mode: this.runMode,
thinking_mode: this.thinkingMode thinking_mode: this.thinkingMode,
eventHandler: (event: any) => this.handleTaskEvent(event)
}); });
debugLog('[Message] 任务已创建,开始轮询'); debugLog('[Message] 任务已创建,开始轮询');
@ -296,16 +787,23 @@ export const messageMethods = {
if (typeof this.forceUnlockMonitor === 'function') { if (typeof this.forceUnlockMonitor === 'function') {
this.forceUnlockMonitor('create_task_failed'); this.forceUnlockMonitor('create_task_failed');
} }
return; return false;
} }
this.inputClearMessage(); if (!usePresetText) {
this.inputClearSelectedImages(); this.inputClearMessage();
this.inputClearSelectedVideos(); this.inputClearSelectedImages();
this.inputSetImagePickerOpen(false); this.inputClearSelectedVideos();
this.inputSetVideoPickerOpen(false); this.inputSetImagePickerOpen(false);
this.inputSetLineCount(1); this.inputSetVideoPickerOpen(false);
this.inputSetMultiline(false); this.inputSetLineCount(1);
this.inputSetMultiline(false);
this.persistComposerDraftNow({
reason: 'send-message-cleared',
force: true,
keepalive: true
}).catch(() => {});
}
if (hasImages) { if (hasImages) {
this.conversationHasImages = true; this.conversationHasImages = true;
this.conversationHasVideos = false; this.conversationHasVideos = false;
@ -325,6 +823,7 @@ export const messageMethods = {
this.updateCurrentContextTokens(); this.updateCurrentContextTokens();
} }
}, 1000); }, 1000);
return true;
}, },
// 新增:停止任务方法 // 新增:停止任务方法
@ -345,6 +844,9 @@ export const messageMethods = {
} }
const shouldDropToolEvents = this.streamingUi; const shouldDropToolEvents = this.streamingUi;
if (typeof this.markRuntimeQueueSuppressedByManualStop === 'function') {
this.markRuntimeQueueSuppressedByManualStop();
}
this.stopRequested = true; this.stopRequested = true;
this.dropToolEvents = shouldDropToolEvents; this.dropToolEvents = shouldDropToolEvents;
@ -580,7 +1082,9 @@ export const messageMethods = {
if (typeof this.clearProcessedEvents === 'function') { if (typeof this.clearProcessedEvents === 'function') {
this.clearProcessedEvents(); this.clearProcessedEvents();
} }
await taskStore.createTask(message, [], [], this.currentConversationId); await taskStore.createTask(message, [], [], this.currentConversationId, {
eventHandler: (event: any) => this.handleTaskEvent(event)
});
} catch (error) { } catch (error) {
console.error('[Message] 自动消息创建任务失败:', error); console.error('[Message] 自动消息创建任务失败:', error);
this.uiPushToast({ this.uiPushToast({

View File

@ -146,6 +146,66 @@ export const taskPollingMethods = {
clearInterval(this.waitingTaskProbeTimer); clearInterval(this.waitingTaskProbeTimer);
this.waitingTaskProbeTimer = null; 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() { async tryResumeWaitingNoticeTask() {
@ -239,6 +299,30 @@ export const taskPollingMethods = {
debugNotifyLog('[DEBUG_NOTIFY][ui] waitingTaskProbe:resumed-and-stop'); debugNotifyLog('[DEBUG_NOTIFY][ui] waitingTaskProbe:resumed-and-stop');
keyNotifyLog('[DEBUG_NOTIFY_KEY][ui] waitingTaskProbe:resumed-and-stop'); keyNotifyLog('[DEBUG_NOTIFY_KEY][ui] waitingTaskProbe:resumed-and-stop');
this.stopWaitingTaskProbe(); 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); }, 1000);
}, },
@ -484,6 +568,9 @@ export const taskPollingMethods = {
case 'system_message': case 'system_message':
this.handleSystemMessage(eventData, eventIdx); this.handleSystemMessage(eventData, eventIdx);
break; break;
case 'runtime_queue_sync':
this.handleRuntimeQueueSync(eventData);
break;
default: default:
debugLog(`[TaskPolling] 未知事件类型: ${eventType}`); debugLog(`[TaskPolling] 未知事件类型: ${eventType}`);
@ -1154,6 +1241,29 @@ export const taskPollingMethods = {
handleTaskComplete(data: any) { handleTaskComplete(data: any) {
const pendingToolsBefore = const pendingToolsBefore =
typeof this.hasPendingToolActions === 'function' ? this.hasPendingToolActions() : null; 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', { jsonDebug('handleTaskComplete:before', {
data, data,
taskInProgress: this.taskInProgress, taskInProgress: this.taskInProgress,
@ -1199,6 +1309,11 @@ export const taskPollingMethods = {
this.waitingForBackgroundCommand = false; this.waitingForBackgroundCommand = false;
this.stopWaitingTaskProbe(); this.stopWaitingTaskProbe();
this.clearTaskState(); // 清理任务状态 this.clearTaskState(); // 清理任务状态
this.$nextTick(() => {
if (typeof this.tryAutoSendRuntimeQueuedMessages === 'function') {
this.tryAutoSendRuntimeQueuedMessages('task_complete');
}
});
} }
this.$forceUpdate(); this.$forceUpdate();
@ -1251,6 +1366,11 @@ export const taskPollingMethods = {
this.$forceUpdate(); this.$forceUpdate();
this.clearTaskState(); this.clearTaskState();
this.$nextTick(() => {
if (typeof this.tryAutoSendRuntimeQueuedMessages === 'function') {
this.tryAutoSendRuntimeQueuedMessages('task_stopped');
}
});
jsonDebug('handleTaskStopped:after', { jsonDebug('handleTaskStopped:after', {
taskInProgress: this.taskInProgress, taskInProgress: this.taskInProgress,
streamingMessage: this.streamingMessage, 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() { clearTaskState() {
this.stopWaitingTaskProbe(); this.stopWaitingTaskProbe();
@ -1476,6 +1645,11 @@ export const taskPollingMethods = {
const taskStore = useTaskStore(); const taskStore = useTaskStore();
taskStore.stopPolling(); taskStore.stopPolling();
})(); })();
this.$nextTick(() => {
if (typeof this.tryAutoSendRuntimeQueuedMessages === 'function') {
this.tryAutoSendRuntimeQueuedMessages('task_error');
}
});
}, },
handleTokenUpdate(data: any) { handleTokenUpdate(data: any) {
@ -2052,11 +2226,27 @@ export const taskPollingMethods = {
(task: any) => task?.run_in_background && !terminalStatuses.has(task?.status) (task: any) => task?.run_in_background && !terminalStatuses.has(task?.status)
); );
const pendingNotice = relevant.filter((task: any) => task?.notice_pending); 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] 恢复子智能体等待状态', { debugLog('[TaskPolling] 恢复子智能体等待状态', {
running: running.length, running: running.length,
pendingNotice: pendingNotice.length, pendingNotice: pendingNotice.length,
backgroundPending,
runningTasks: running.map((task: any) => ({ runningTasks: running.map((task: any) => ({
task_id: task?.task_id, task_id: task?.task_id,
status: task?.status status: task?.status
@ -2067,12 +2257,20 @@ export const taskPollingMethods = {
})) }))
}); });
this.waitingForSubAgent = true; this.waitingForSubAgent = true;
this.waitingForBackgroundCommand = false; this.waitingForBackgroundCommand = backgroundPending;
this.taskInProgress = true; this.taskInProgress = true;
this.streamingMessage = false; this.streamingMessage = false;
this.stopRequested = false; this.stopRequested = false;
this.startWaitingTaskProbe(); this.startWaitingTaskProbe();
this.$forceUpdate(); this.$forceUpdate();
} else {
this.waitingForSubAgent = false;
this.waitingForBackgroundCommand = false;
this.stopWaitingTaskProbe();
if (!this.streamingMessage) {
this.taskInProgress = false;
}
this.$forceUpdate();
} }
} catch (error) { } catch (error) {
console.error('[TaskPolling] 恢复子智能体等待状态失败:', error); console.error('[TaskPolling] 恢复子智能体等待状态失败:', error);

View File

@ -60,6 +60,60 @@ function uiBounceTrace(
console.log('[SCROLL_BOUNCE_TRACE_UI]', event, payload); 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<string, any>) {
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<string, any> = {},
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 { function parseSubAgentDoneLabel(rawContent: any): string | null {
const content = (rawContent || '').toString().trim(); const content = (rawContent || '').toString().trim();
if (!content) { if (!content) {
@ -262,11 +316,171 @@ export const uiMethods = {
this.uiSetMobileOverlayMenuOpen(false); 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() { refreshCurrentPage() {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return; return;
} }
window.location.reload(); this.persistComposerDraftNow({
reason: 'refresh-page',
force: true,
keepalive: true
})
.catch(() => {})
.finally(() => {
window.location.reload();
});
}, },
handleMobileRefreshClick() { handleMobileRefreshClick() {
@ -533,6 +747,14 @@ export const uiMethods = {
this.hostWorkspaceSwitching = true; this.hostWorkspaceSwitching = true;
try { try {
// 先在“当前工作区作用域”落盘草稿,再切换工作区。
// 否则会把旧工作区草稿误写到目标工作区作用域里。
await this.persistComposerDraftNow({
reason: 'switch-host-workspace:before-select',
force: true,
keepalive: true
}).catch(() => {});
const query = encodeURIComponent(targetId); const query = encodeURIComponent(targetId);
const resp = await fetch(`/api/host/workspaces/select?workspace_id=${query}`); const resp = await fetch(`/api/host/workspaces/select?workspace_id=${query}`);
const payload = await resp.json().catch(() => ({})); const payload = await resp.json().catch(() => ({}));
@ -1052,6 +1274,7 @@ export const uiMethods = {
handleInputChange() { handleInputChange() {
this.autoResizeInput(); this.autoResizeInput();
this.scheduleComposerDraftPersist('input-change');
}, },
handleInputFocus() { handleInputFocus() {
@ -1637,6 +1860,21 @@ export const uiMethods = {
return this.$refs.inputComposer || null; 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) { getComposerElement(field) {
const composer = this.getInputComposerRef(); const composer = this.getInputComposerRef();
const unwrap = (value: any) => { const unwrap = (value: any) => {
@ -1781,6 +2019,7 @@ export const uiMethods = {
this.startTitleTyping('新对话', { animate: false }); this.startTitleTyping('新对话', { animate: false });
this.initialRouteResolved = true; this.initialRouteResolved = true;
this.refreshBlankHeroState(); this.refreshBlankHeroState();
await this.restoreComposerDraftState('bootstrap-route:new');
return; return;
} }
@ -1831,6 +2070,7 @@ export const uiMethods = {
} finally { } finally {
this.initialRouteResolved = true; this.initialRouteResolved = true;
} }
await this.restoreComposerDraftState('bootstrap-route:conversation');
}, },
handlePopState(event) { handlePopState(event) {
@ -1844,6 +2084,7 @@ export const uiMethods = {
this.logMessageState('handlePopState:after-clear-no-conversation'); this.logMessageState('handlePopState:after-clear-no-conversation');
this.resetAllStates('handlePopState:no-conversation'); this.resetAllStates('handlePopState:no-conversation');
this.resetTokenStatistics(); this.resetTokenStatistics();
this.restoreComposerDraftState('popstate:new').catch(() => {});
return; return;
} }
this.loadConversation(convId); this.loadConversation(convId);
@ -1970,24 +2211,136 @@ export const uiMethods = {
}, },
async checkConnectionHealth() { 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 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 { try {
const response = await fetch('/api/status', { const response = await fetch(healthUrl, {
method: 'GET', method: 'GET',
cache: 'no-store', cache: 'no-store',
headers: {
'X-Connection-Heartbeat': requestId
},
signal: controller?.signal signal: controller?.signal
}); });
responseStatus = response.status;
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP ${response.status}`); throw new Error(`HTTP ${response.status}`);
} }
const failCountBeforeRecover = this.connectionHeartbeatFailCount || 0;
this.isConnected = true; this.isConnected = true;
this.connectionHeartbeatFailCount = 0; 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) { } catch (error) {
this.connectionHeartbeatFailCount = (this.connectionHeartbeatFailCount || 0) + 1; const nextFailCount = (this.connectionHeartbeatFailCount || 0) + 1;
// 一次失败就先置灰,避免“服务已断但常亮绿色”的误导 this.connectionHeartbeatFailCount = nextFailCount;
this.isConnected = false; 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 { } finally {
this.connectionHeartbeatInFlight = false;
if (timeoutId) { if (timeoutId) {
clearTimeout(timeoutId); clearTimeout(timeoutId);
} }
@ -2000,6 +2353,15 @@ export const uiMethods = {
} }
this.connectionHeartbeatActive = true; this.connectionHeartbeatActive = true;
this.connectionHeartbeatFailCount = 0; this.connectionHeartbeatFailCount = 0;
connectionDiag(
'log',
'heartbeat-start',
{
connectedIntervalMs: this.connectionHeartbeatIntervalMs,
disconnectedIntervalMs: this.connectionHeartbeatDisconnectedIntervalMs
},
{ force: true }
);
const runHeartbeat = async () => { const runHeartbeat = async () => {
if (!this.connectionHeartbeatActive) { if (!this.connectionHeartbeatActive) {
return; return;
@ -2009,7 +2371,8 @@ export const uiMethods = {
return; return;
} }
const connectedInterval = const connectedInterval =
typeof this.connectionHeartbeatIntervalMs === 'number' && this.connectionHeartbeatIntervalMs > 0 typeof this.connectionHeartbeatIntervalMs === 'number' &&
this.connectionHeartbeatIntervalMs > 0
? this.connectionHeartbeatIntervalMs ? this.connectionHeartbeatIntervalMs
: 8000; : 8000;
const disconnectedInterval = const disconnectedInterval =
@ -2018,6 +2381,17 @@ export const uiMethods = {
? this.connectionHeartbeatDisconnectedIntervalMs ? this.connectionHeartbeatDisconnectedIntervalMs
: 1000; : 1000;
const nextInterval = this.isConnected ? connectedInterval : disconnectedInterval; 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(() => { this.connectionHeartbeatTimer = window.setTimeout(() => {
runHeartbeat(); runHeartbeat();
}, nextInterval); }, nextInterval);
@ -2033,6 +2407,7 @@ export const uiMethods = {
clearTimeout(this.connectionHeartbeatTimer); clearTimeout(this.connectionHeartbeatTimer);
this.connectionHeartbeatTimer = null; this.connectionHeartbeatTimer = null;
} }
connectionDiag('log', 'heartbeat-stop', {}, { force: true });
}, },
openRealtimeTerminal() { openRealtimeTerminal() {

View File

@ -1,6 +1,12 @@
// @ts-nocheck // @ts-nocheck
import { debugLog } from './common'; 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 = { export const versioningMethods = {
async fetchVersioningStatus(conversationId = null, options = {}) { async fetchVersioningStatus(conversationId = null, options = {}) {
const targetId = conversationId || this.currentConversationId; const targetId = conversationId || this.currentConversationId;
@ -18,6 +24,14 @@ export const versioningMethods = {
const payload = data.data || {}; const payload = data.data || {};
this.versioningHostMode = !!payload.host_mode; this.versioningHostMode = !!payload.host_mode;
this.versioningEnabled = !!payload.enabled; 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.versioningMode = 'overwrite';
this.versioningRestoreMode = 'overwrite'; this.versioningRestoreMode = 'overwrite';
this.versioningMismatch = !!payload.mismatch; this.versioningMismatch = !!payload.mismatch;
@ -45,6 +59,9 @@ export const versioningMethods = {
if (!resp.ok || !data?.success) { if (!resp.ok || !data?.success) {
throw new Error(data?.error || '加载版本点失败'); 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 : []; const items = Array.isArray(data?.data?.items) ? data.data.items : [];
this.versioningCheckpoints = items; this.versioningCheckpoints = items;
if (!items.length) { if (!items.length) {
@ -67,6 +84,12 @@ export const versioningMethods = {
async openVersioningDialog() { async openVersioningDialog() {
if (!this.currentConversationId) { if (!this.currentConversationId) {
this.versioningEnabled = !!this.newConversationVersioningEnabled; this.versioningEnabled = !!this.newConversationVersioningEnabled;
this.versioningTrackingMode = normalizeTrackingMode(
this.newConversationVersioningTrackingMode
);
if (!this.versioningHostMode && this.versioningTrackingMode !== 'conversation_only') {
this.versioningTrackingMode = 'conversation_only';
}
this.versioningWorkspaceMatched = true; this.versioningWorkspaceMatched = true;
this.versioningCheckpoints = []; this.versioningCheckpoints = [];
this.versioningSelectedSeq = null; this.versioningSelectedSeq = null;
@ -87,18 +110,22 @@ export const versioningMethods = {
await this.fetchVersioningCheckpoints(this.currentConversationId); await this.fetchVersioningCheckpoints(this.currentConversationId);
}, },
async handleVersioningTrackingModeChange(mode: string) {
await this.updateVersioningTrackingMode(mode);
},
async toggleConversationVersioning(enabled: boolean) { async toggleConversationVersioning(enabled: boolean) {
if (!this.currentConversationId) { if (!this.currentConversationId) {
if (!this.versioningHostMode) {
this.uiPushToast({
title: '版本管理',
message: '仅宿主机模式支持版本管理',
type: 'warning'
});
return;
}
this.newConversationVersioningEnabled = !!enabled; this.newConversationVersioningEnabled = !!enabled;
this.versioningEnabled = !!enabled; this.versioningEnabled = !!enabled;
if (enabled) {
if (!this.versioningHostMode) {
this.versioningTrackingMode = 'conversation_only';
}
this.newConversationVersioningTrackingMode = normalizeTrackingMode(
this.versioningTrackingMode
);
}
this.uiPushToast({ this.uiPushToast({
title: '版本管理', title: '版本管理',
message: enabled ? '已为下一次新对话开启版本管理' : '已取消下一次新对话的版本管理', message: enabled ? '已为下一次新对话开启版本管理' : '已取消下一次新对话的版本管理',
@ -108,12 +135,18 @@ export const versioningMethods = {
} }
this.versioningLoading = true; this.versioningLoading = true;
try { 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`, { const resp = await fetch(`/api/conversations/${this.currentConversationId}/versioning`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
enabled: !!enabled, enabled: !!enabled,
mode: 'overwrite' mode: 'overwrite',
tracking_mode: trackingMode
}) })
}); });
const data = await resp.json().catch(() => ({})); const data = await resp.json().catch(() => ({}));
@ -121,6 +154,9 @@ export const versioningMethods = {
throw new Error(data?.error || '切换版本管理失败'); throw new Error(data?.error || '切换版本管理失败');
} }
this.versioningEnabled = !!data?.data?.enabled; this.versioningEnabled = !!data?.data?.enabled;
this.versioningTrackingMode = normalizeTrackingMode(
data?.data?.tracking_mode || this.versioningTrackingMode
);
this.versioningMode = 'overwrite'; this.versioningMode = 'overwrite';
this.versioningRestoreMode = 'overwrite'; this.versioningRestoreMode = 'overwrite';
this.uiPushToast({ this.uiPushToast({
@ -264,15 +300,20 @@ export const versioningMethods = {
async applyPendingVersioningToConversation(conversationId: string) { async applyPendingVersioningToConversation(conversationId: string) {
const targetId = conversationId || this.currentConversationId; const targetId = conversationId || this.currentConversationId;
if (!targetId) return; if (!targetId) return;
if (!this.versioningHostMode) return;
if (!this.newConversationVersioningEnabled) return; if (!this.newConversationVersioningEnabled) return;
try { try {
const trackingMode = normalizeTrackingMode(
!this.versioningHostMode
? 'conversation_only'
: (this.newConversationVersioningTrackingMode || this.versioningTrackingMode)
);
const resp = await fetch(`/api/conversations/${targetId}/versioning`, { const resp = await fetch(`/api/conversations/${targetId}/versioning`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
enabled: true, enabled: true,
mode: 'overwrite' mode: 'overwrite',
tracking_mode: trackingMode
}) })
}); });
const data = await resp.json().catch(() => ({})); const data = await resp.json().catch(() => ({}));
@ -281,6 +322,9 @@ export const versioningMethods = {
} }
this.newConversationVersioningEnabled = false; this.newConversationVersioningEnabled = false;
this.versioningEnabled = true; this.versioningEnabled = true;
this.versioningTrackingMode = normalizeTrackingMode(
data?.data?.tracking_mode || trackingMode
);
} catch (error) { } catch (error) {
this.uiPushToast({ this.uiPushToast({
title: '版本管理', title: '版本管理',
@ -288,5 +332,64 @@ export const versioningMethods = {
type: 'error' 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;
}
} }
}; };

View File

@ -25,6 +25,17 @@ export function dataState() {
taskInProgress: false, taskInProgress: false,
// 等待后台通知期间,用于发现并接管新建任务的轮询定时器 // 等待后台通知期间,用于发现并接管新建任务的轮询定时器
waitingTaskProbeTimer: null, waitingTaskProbeTimer: null,
// 运行期消息堆积(提前发送 / 引导对话)
runtimeQueuedMessages: [],
runtimeGuidanceFallbackQueue: [],
runtimeQueueSuppressedMessageIds: new Set(),
runtimeGuidanceSuppressedTextCounts: {},
runtimeQueueLimit: 5,
runtimeQueueAutoSendInProgress: false,
runtimeQueueSyncLockKey: '',
runtimeQueueSyncLockUntil: 0,
// 输入区动态保留高度(用于同步扩大消息区可滚动范围)
composerReservedHeight: 80,
// 记录上一次成功加载历史的对话ID防止初始化阶段重复加载导致动画播放两次 // 记录上一次成功加载历史的对话ID防止初始化阶段重复加载导致动画播放两次
lastHistoryLoadedConversationId: null, lastHistoryLoadedConversationId: null,
@ -98,6 +109,8 @@ export function dataState() {
hostWorkspaceCreateError: '', hostWorkspaceCreateError: '',
versioningEnabled: false, versioningEnabled: false,
newConversationVersioningEnabled: false, newConversationVersioningEnabled: false,
versioningTrackingMode: 'workspace_and_conversation',
newConversationVersioningTrackingMode: 'workspace_and_conversation',
versioningMode: 'overwrite', versioningMode: 'overwrite',
versioningMismatch: false, versioningMismatch: false,
versioningWorkspaceMatched: true, versioningWorkspaceMatched: true,
@ -139,8 +152,20 @@ export function dataState() {
connectionHeartbeatTimer: null, connectionHeartbeatTimer: null,
connectionHeartbeatActive: false, connectionHeartbeatActive: false,
connectionHeartbeatFailCount: 0, connectionHeartbeatFailCount: 0,
connectionHeartbeatSeq: 0,
connectionHeartbeatLastLatencyMs: 0,
connectionHeartbeatLastError: '',
connectionHeartbeatLastStatusCode: null,
connectionHeartbeatLastChangeAt: 0,
connectionHeartbeatInFlight: false,
connectionHeartbeatFailThreshold: 3,
connectionHeartbeatRequestTimeoutMs: 5000,
connectionHeartbeatIntervalMs: 8000, connectionHeartbeatIntervalMs: 8000,
connectionHeartbeatDisconnectedIntervalMs: 1000, connectionHeartbeatDisconnectedIntervalMs: 1000,
composerDraftSaveTimer: null,
composerDraftDirty: false,
composerDraftLastSyncedContent: '',
composerDraftFetchSeq: 0,
// 工具控制菜单 // 工具控制菜单
icons: ICONS, icons: ICONS,

View File

@ -4,6 +4,9 @@ import { debugLog, traceLog } from './methods/common';
export const watchers = { export const watchers = {
inputMessage() { inputMessage() {
this.autoResizeInput(); this.autoResizeInput();
if (typeof this.scheduleComposerDraftPersist === 'function') {
this.scheduleComposerDraftPersist('watch-input-message');
}
}, },
messages: { messages: {
deep: true, deep: true,
@ -11,6 +14,37 @@ export const watchers = {
this.refreshBlankHeroState(); 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) { currentConversationTitle(newVal, oldVal) {
const target = (newVal && newVal.trim()) || ''; const target = (newVal && newVal.trim()) || '';
if (this.suppressTitleTyping) { if (this.suppressTitleTyping) {
@ -47,8 +81,15 @@ export const watchers = {
newValue, newValue,
skipConversationHistoryReload: this.skipConversationHistoryReload 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_')) { if (!newValue || typeof newValue !== 'string' || newValue.startsWith('temp_')) {
this.versioningEnabled = !!this.newConversationVersioningEnabled; this.versioningEnabled = !!this.newConversationVersioningEnabled;
this.versioningTrackingMode =
this.newConversationVersioningTrackingMode || 'workspace_and_conversation';
this.versioningMismatch = false; this.versioningMismatch = false;
return; return;
} }

View File

@ -270,14 +270,19 @@ function renderWriteFile(result: any, args: any): string {
const path = args.file_path || args.path || result.path || ''; const path = args.file_path || args.path || result.path || '';
const status = formatToolStatusLabel(result, '✓ 已写入'); const status = formatToolStatusLabel(result, '✓ 已写入');
const content = args.content || ''; 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 = '<div class="tool-result-meta">'; let html = '<div class="tool-result-meta">';
html += `<div><strong>路径:</strong>${escapeHtml(path)}</div>`; html += `<div><strong>路径:</strong>${escapeHtml(path)}</div>`;
html += `<div><strong>状态:</strong>${status}</div>`; html += `<div><strong>状态:</strong>${status}</div>`;
if (isAppend) { html += `<div><strong>模式:</strong>${writeMode}</div>`;
html += `<div><strong>模式:</strong>追加</div>`;
}
if (!result.success && result.error) { if (!result.success && result.error) {
html += `<div><strong>错误:</strong>${escapeHtml(String(result.error))}</div>`; html += `<div><strong>错误:</strong>${escapeHtml(String(result.error))}</div>`;
} }
@ -299,6 +304,15 @@ function renderWriteFile(result: any, args: any): string {
function renderEditFile(result: any, args: any): string { function renderEditFile(result: any, args: any): string {
const path = args.file_path || args.path || result.path || ''; const path = args.file_path || args.path || result.path || '';
const status = formatToolStatusLabel(result, '✓ 已编辑'); 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 // 1. args.replacements
@ -310,6 +324,9 @@ function renderEditFile(result: any, args: any): string {
let html = '<div class="tool-result-meta">'; let html = '<div class="tool-result-meta">';
html += `<div><strong>路径:</strong>${escapeHtml(path)}</div>`; html += `<div><strong>路径:</strong>${escapeHtml(path)}</div>`;
html += `<div><strong>状态:</strong>${status}</div>`; html += `<div><strong>状态:</strong>${status}</div>`;
html += `<div><strong>替换全部:</strong>${replaceAllValue}</div>`;
html += `<div><strong>找到:</strong>${foundCount ?? '无'}处</div>`;
html += `<div><strong>替换:</strong>${replacedCount ?? '无'}处</div>`;
if (!result.success && result.error) { if (!result.success && result.error) {
html += `<div><strong>错误:</strong>${escapeHtml(String(result.error))}</div>`; html += `<div><strong>错误:</strong>${escapeHtml(String(result.error))}</div>`;
} }

View File

@ -210,14 +210,19 @@ function renderWriteFile(result: any, args: any): string {
const path = args.file_path || args.path || result.path || ''; const path = args.file_path || args.path || result.path || '';
const status = formatToolStatusLabel(result, '✓ 已写入'); const status = formatToolStatusLabel(result, '✓ 已写入');
const content = args.content || ''; 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 = '<div class="tool-result-meta">'; let html = '<div class="tool-result-meta">';
html += `<div><strong>路径:</strong>${escapeHtml(path)}</div>`; html += `<div><strong>路径:</strong>${escapeHtml(path)}</div>`;
html += `<div><strong>状态:</strong>${status}</div>`; html += `<div><strong>状态:</strong>${status}</div>`;
if (isAppend) { html += `<div><strong>模式:</strong>${writeMode}</div>`;
html += `<div><strong>模式:</strong>追加</div>`;
}
if (!result.success && result.error) { if (!result.success && result.error) {
html += `<div><strong>错误:</strong>${escapeHtml(String(result.error))}</div>`; html += `<div><strong>错误:</strong>${escapeHtml(String(result.error))}</div>`;
} }
@ -239,6 +244,15 @@ function renderWriteFile(result: any, args: any): string {
function renderEditFile(result: any, args: any): string { function renderEditFile(result: any, args: any): string {
const path = args.file_path || args.path || result.path || ''; const path = args.file_path || args.path || result.path || '';
const status = formatToolStatusLabel(result, '✓ 已编辑'); 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 数组 // 1. 新格式args.replacements 数组
@ -250,6 +264,9 @@ function renderEditFile(result: any, args: any): string {
let html = '<div class="tool-result-meta">'; let html = '<div class="tool-result-meta">';
html += `<div><strong>路径:</strong>${escapeHtml(path)}</div>`; html += `<div><strong>路径:</strong>${escapeHtml(path)}</div>`;
html += `<div><strong>状态:</strong>${status}</div>`; html += `<div><strong>状态:</strong>${status}</div>`;
html += `<div><strong>替换全部:</strong>${replaceAllValue}</div>`;
html += `<div><strong>找到:</strong>${foundCount ?? '无'}处</div>`;
html += `<div><strong>替换:</strong>${replacedCount ?? '无'}处</div>`;
if (!result.success && result.error) { if (!result.success && result.error) {
html += `<div><strong>错误:</strong>${escapeHtml(String(result.error))}</div>`; html += `<div><strong>错误:</strong>${escapeHtml(String(result.error))}</div>`;
} }

View File

@ -1,6 +1,38 @@
<template> <template>
<div class="input-area compact-input-area"> <div class="input-area compact-input-area" ref="inputAreaRoot">
<div class="stadium-input-wrapper" ref="stadiumShellOuter"> <div class="stadium-input-wrapper" ref="stadiumShellOuter">
<div
class="runtime-queue-list"
:class="{ 'runtime-queue-list--empty': !runtimeQueuedMessagesForRender.length }"
ref="runtimeQueueList"
>
<div
v-for="(entry, index) in runtimeQueuedMessagesForRender"
:key="entry.id"
class="runtime-queue-item"
:class="{
'runtime-queue-item--top': index === 0
}"
:data-runtime-queue-id="entry.id || ''"
:ref="bindRuntimeQueueItemRef(entry.id)"
>
<span class="runtime-queue-item__text">{{ entry.text || '' }}</span>
<button
type="button"
class="runtime-queue-item__action"
@click.stop="$emit('guide-runtime-message', entry.id)"
>
引导
</button>
<button
type="button"
class="runtime-queue-item__action runtime-queue-item__action--danger"
@click.stop="$emit('delete-runtime-message', entry.id)"
>
删除
</button>
</div>
</div>
<div <div
class="stadium-shell" class="stadium-shell"
ref="compactInputShell" ref="compactInputShell"
@ -60,32 +92,28 @@
+ +
</button> </button>
<textarea <textarea
:key="composerInputKey"
ref="stadiumInput" ref="stadiumInput"
class="stadium-input" class="stadium-input"
rows="1" rows="1"
:value="inputMessage" :value="inputMessage"
:disabled="!isConnected || streamingMessage || inputLocked" :disabled="!isConnected || inputLocked"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
placeholder="输入消息... (Ctrl+Enter 发送)" placeholder="输入消息... (Ctrl+Enter 发送)"
@input="onInput" @input="onInput"
@focus="$emit('input-focus')" @focus="$emit('input-focus')"
@blur="$emit('input-blur')" @blur="$emit('input-blur')"
@keydown.enter.ctrl.prevent="$emit('send-message')" @keydown.enter.ctrl.prevent="$emit('send-or-stop')"
></textarea> ></textarea>
<button <button
type="button" type="button"
class="stadium-btn send-btn" class="stadium-btn send-btn"
@click="$emit('send-or-stop')" @click="$emit('send-or-stop')"
:disabled=" :disabled="sendButtonDisabled"
!isConnected ||
(inputLocked && !streamingMessage) ||
(mediaUploading && !streamingMessage) ||
(!(inputMessage || '').trim() &&
!selectedImages?.length &&
!selectedVideos?.length &&
!streamingMessage)
"
> >
<span v-if="streamingMessage" class="stop-icon"></span> <span v-if="showStopIcon" class="stop-icon"></span>
<span v-else class="send-icon"></span> <span v-else class="send-icon"></span>
</button> </button>
</div> </div>
@ -196,7 +224,16 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, watch, nextTick, computed } from 'vue'; import {
onMounted,
onBeforeUnmount,
onBeforeUpdate,
onUpdated,
ref,
watch,
nextTick,
computed
} from 'vue';
import QuickMenu from '@/components/input/QuickMenu.vue'; import QuickMenu from '@/components/input/QuickMenu.vue';
import { useInputStore } from '@/stores/input'; import { useInputStore } from '@/stores/input';
import { usePersonalizationStore } from '@/stores/personalization'; import { usePersonalizationStore } from '@/stores/personalization';
@ -232,7 +269,10 @@ const emit = defineEmits([
'open-review', 'open-review',
'toggle-permission-menu', 'toggle-permission-menu',
'change-permission-mode', 'change-permission-mode',
'open-versioning-dialog' 'open-versioning-dialog',
'guide-runtime-message',
'delete-runtime-message',
'composer-height-change'
]); ]);
const props = defineProps<{ const props = defineProps<{
@ -281,14 +321,18 @@ const props = defineProps<{
permissionOptions: Array<{ value: string; label: string; description: string }>; permissionOptions: Array<{ value: string; label: string; description: string }>;
currentContextTokens: number; currentContextTokens: number;
versioningEnabled?: boolean; versioningEnabled?: boolean;
runtimeQueuedMessages?: Array<{ id: string; text: string }>;
}>(); }>();
const inputStore = useInputStore(); const inputStore = useInputStore();
const personalizationStore = usePersonalizationStore(); const personalizationStore = usePersonalizationStore();
const inputAreaRoot = ref<HTMLElement | null>(null);
const stadiumShellOuter = ref<HTMLElement | null>(null); const stadiumShellOuter = ref<HTMLElement | null>(null);
const compactInputShell = ref<HTMLElement | null>(null); const compactInputShell = ref<HTMLElement | null>(null);
const stadiumInput = ref<HTMLTextAreaElement | null>(null); const stadiumInput = ref<HTMLTextAreaElement | null>(null);
const fileUploadInput = ref<HTMLInputElement | null>(null); const fileUploadInput = ref<HTMLInputElement | null>(null);
const baselineComposerVisualHeight = ref<number>(0);
let composerResizeObserver: ResizeObserver | null = null;
const formatImageName = (path: string): string => { const formatImageName = (path: string): string => {
if (!path) return ''; if (!path) return '';
@ -306,12 +350,331 @@ const applyLineMetrics = (lines: number, multiline: boolean) => {
inputStore.setInputMultiline(multiline); inputStore.setInputMultiline(multiline);
}; };
const composerInputKey = computed(
() => `${props.currentConversationId || 'new'}:${props.isConnected ? 'online' : 'offline'}`
);
const runtimeQueuedMessagesForRender = computed(() => {
const list = Array.isArray(props.runtimeQueuedMessages) ? props.runtimeQueuedMessages : [];
return list.filter((item) => item && item.id && item.text);
});
const RUNTIME_QUEUE_ANIM_DURATION = 300;
const RUNTIME_QUEUE_ANIM_EASING = 'cubic-bezier(0.25, 0.8, 0.25, 1)';
const runtimeQueueList = ref<HTMLElement | null>(null);
const runtimeQueueItemRefs = new Map<string, HTMLElement>();
const runtimeQueuePrevIds: string[] = [];
const runtimeQueueRenderedIds: string[] = [];
const runtimeQueuePrevRects = new Map<string, DOMRect>();
const runtimeQueuePrevElements = new Map<string, HTMLElement>();
const runtimeQueueGhosts = new Map<string, HTMLElement>();
const runtimeQueueActiveAnimations = new WeakMap<HTMLElement, Animation>();
const runtimeQueueFallbackTimers = new WeakMap<HTMLElement, number>();
let runtimeQueueAnimationReady = false;
const bindRuntimeQueueItemRef = (id: string) => (el: Element | null) => {
if (!id) {
return;
}
if (el instanceof HTMLElement) {
runtimeQueueItemRefs.set(id, el);
} else {
runtimeQueueItemRefs.delete(id);
}
};
const stopRuntimeQueueAnimation = (el: HTMLElement) => {
const anim = runtimeQueueActiveAnimations.get(el);
if (anim) {
try {
anim.cancel();
} catch {
// ignore
}
runtimeQueueActiveAnimations.delete(el);
}
const timer = runtimeQueueFallbackTimers.get(el);
if (timer) {
clearTimeout(timer);
runtimeQueueFallbackTimers.delete(el);
}
el.style.removeProperty('transition');
el.style.removeProperty('transform');
};
const playRuntimeQueueTransform = (
el: HTMLElement,
fromY: number,
toY: number,
done?: () => void
) => {
if (Math.abs(fromY - toY) < 0.5) {
stopRuntimeQueueAnimation(el);
el.style.removeProperty('transition');
el.style.removeProperty('transform');
done?.();
return;
}
stopRuntimeQueueAnimation(el);
const from = `translateY(${fromY}px)`;
const to = `translateY(${toY}px)`;
const finish = () => {
el.style.removeProperty('transition');
el.style.removeProperty('transform');
done?.();
};
el.style.transition = 'none';
el.style.transform = from;
if (typeof el.animate === 'function') {
//
void el.offsetHeight;
const animation = el.animate([{ transform: from }, { transform: to }], {
duration: RUNTIME_QUEUE_ANIM_DURATION,
easing: RUNTIME_QUEUE_ANIM_EASING,
fill: 'both'
});
runtimeQueueActiveAnimations.set(el, animation);
animation.addEventListener(
'finish',
() => {
if (runtimeQueueActiveAnimations.get(el) === animation) {
runtimeQueueActiveAnimations.delete(el);
}
finish();
},
{ once: true }
);
animation.addEventListener(
'cancel',
() => {
if (runtimeQueueActiveAnimations.get(el) === animation) {
runtimeQueueActiveAnimations.delete(el);
}
},
{ once: true }
);
return;
}
requestAnimationFrame(() => {
el.style.transition = `transform ${RUNTIME_QUEUE_ANIM_DURATION}ms ${RUNTIME_QUEUE_ANIM_EASING}`;
el.style.transform = to;
const timer = window.setTimeout(() => {
runtimeQueueFallbackTimers.delete(el);
finish();
}, RUNTIME_QUEUE_ANIM_DURATION + 24);
runtimeQueueFallbackTimers.set(el, timer);
});
};
const resolveRuntimeQueueOffset = (el: HTMLElement): number => {
const rectHeight = Math.round(el.getBoundingClientRect().height);
if (rectHeight > 0) {
return rectHeight;
}
if (el.offsetHeight > 0) {
return el.offsetHeight;
}
return 34;
};
const snapshotRuntimeQueueIds = (): string[] => {
return runtimeQueuedMessagesForRender.value
.map((entry) => String(entry?.id || '').trim())
.filter((id) => !!id);
};
const spawnRuntimeQueueLeaveGhost = (id: string) => {
const sourceEl = runtimeQueuePrevElements.get(id);
const sourceRect = runtimeQueuePrevRects.get(id);
const listEl = runtimeQueueList.value;
if (!sourceEl || !sourceRect || !listEl || !listEl.isConnected) {
return;
}
const existingGhost = runtimeQueueGhosts.get(id);
if (existingGhost) {
stopRuntimeQueueAnimation(existingGhost);
existingGhost.remove();
runtimeQueueGhosts.delete(id);
}
const ghost = sourceEl.cloneNode(true) as HTMLElement;
const listRect = listEl.getBoundingClientRect();
const top = sourceRect.top - listRect.top;
const width = sourceRect.width > 0 ? sourceRect.width : listRect.width;
ghost.style.position = 'absolute';
ghost.style.left = '0';
ghost.style.top = `${Math.round(top)}px`;
ghost.style.width = `${Math.round(width)}px`;
ghost.style.pointerEvents = 'none';
ghost.style.zIndex = '0';
ghost.style.margin = '0';
ghost.querySelectorAll('button').forEach((button) => {
if (button instanceof HTMLButtonElement) {
button.disabled = true;
}
});
listEl.appendChild(ghost);
runtimeQueueGhosts.set(id, ghost);
const offset = resolveRuntimeQueueOffset(ghost);
playRuntimeQueueTransform(ghost, 0, offset, () => {
runtimeQueueGhosts.delete(id);
ghost.remove();
});
};
const animateRuntimeQueueEnter = (el: HTMLElement) => {
const offset = resolveRuntimeQueueOffset(el);
el.style.pointerEvents = 'none';
playRuntimeQueueTransform(el, offset, 0, () => {
el.style.removeProperty('pointer-events');
});
};
onBeforeUpdate(() => {
runtimeQueuePrevIds.splice(0, runtimeQueuePrevIds.length, ...runtimeQueueRenderedIds);
runtimeQueuePrevRects.clear();
runtimeQueuePrevElements.clear();
runtimeQueueRenderedIds.forEach((id) => {
const el = runtimeQueueItemRefs.get(id);
if (!el?.isConnected) {
return;
}
runtimeQueuePrevRects.set(id, el.getBoundingClientRect());
runtimeQueuePrevElements.set(id, el);
});
});
onUpdated(() => {
const currentIds = snapshotRuntimeQueueIds();
if (!runtimeQueueAnimationReady) {
runtimeQueueRenderedIds.splice(0, runtimeQueueRenderedIds.length, ...currentIds);
runtimeQueueAnimationReady = true;
runtimeQueuePrevIds.splice(0, runtimeQueuePrevIds.length);
runtimeQueuePrevRects.clear();
runtimeQueuePrevElements.clear();
return;
}
const previousIdSet = new Set(runtimeQueuePrevIds);
const currentIdSet = new Set(currentIds);
const enteringIdSet = new Set(currentIds.filter((id) => !previousIdSet.has(id)));
const leavingIds = runtimeQueuePrevIds.filter((id) => !currentIdSet.has(id));
leavingIds.forEach((id) => {
spawnRuntimeQueueLeaveGhost(id);
runtimeQueueItemRefs.delete(id);
});
currentIds.forEach((id) => {
const el = runtimeQueueItemRefs.get(id);
if (!el?.isConnected) {
return;
}
const prevRect = runtimeQueuePrevRects.get(id);
if (!prevRect || enteringIdSet.has(id)) {
animateRuntimeQueueEnter(el);
return;
}
const nextRect = el.getBoundingClientRect();
const deltaY = prevRect.top - nextRect.top;
if (Math.abs(deltaY) < 0.5) {
return;
}
playRuntimeQueueTransform(el, deltaY, 0);
});
runtimeQueueRenderedIds.splice(0, runtimeQueueRenderedIds.length, ...currentIds);
runtimeQueuePrevIds.splice(0, runtimeQueuePrevIds.length);
runtimeQueuePrevRects.clear();
runtimeQueuePrevElements.clear();
});
const hasComposerContent = computed(() => {
const hasText = !!(props.inputMessage || '').trim();
if (props.streamingMessage) {
return hasText;
}
const hasImages = Array.isArray(props.selectedImages) && props.selectedImages.length > 0;
const hasVideos = Array.isArray(props.selectedVideos) && props.selectedVideos.length > 0;
return hasText || hasImages || hasVideos;
});
const showStopIcon = computed(() => props.streamingMessage && !hasComposerContent.value);
const sendButtonDisabled = computed(() => {
if (!props.isConnected) {
return true;
}
if (props.streamingMessage) {
return false;
}
if (props.inputLocked) {
return true;
}
if (props.mediaUploading) {
return true;
}
return !hasComposerContent.value;
});
const hasRuntimeLayoutExpansion = computed(() => {
const hasQueue = runtimeQueuedMessagesForRender.value.length > 0;
const hasImages = Array.isArray(props.selectedImages) && props.selectedImages.length > 0;
const hasVideos = Array.isArray(props.selectedVideos) && props.selectedVideos.length > 0;
return hasQueue || hasImages || hasVideos || !!props.inputIsMultiline;
});
const collectComposerVisualHeight = () => {
const root = inputAreaRoot.value;
const shell = compactInputShell.value;
if (!root || !shell) {
return 0;
}
const shellRect = shell.getBoundingClientRect();
let top = shellRect.top;
let bottom = shellRect.bottom;
const nodes = root.querySelectorAll('.runtime-queue-list:not(.runtime-queue-list--empty)');
nodes.forEach((node) => {
if (!(node instanceof HTMLElement)) return;
const rect = node.getBoundingClientRect();
top = Math.min(top, rect.top);
bottom = Math.max(bottom, rect.bottom);
});
return Math.max(0, bottom - top);
};
const emitComposerHeight = () => {
const visualHeight = collectComposerVisualHeight();
if (!visualHeight) {
return;
}
if (baselineComposerVisualHeight.value <= 0 || !hasRuntimeLayoutExpansion.value) {
baselineComposerVisualHeight.value = visualHeight;
}
const baseline = baselineComposerVisualHeight.value || visualHeight;
const growth = Math.max(0, visualHeight - baseline);
const baseReservedHeight = 80;
const reservedHeight = Math.ceil(baseReservedHeight + growth);
emit('composer-height-change', {
reservedHeight,
visualHeight,
growth
});
};
const adjustTextareaSize = () => { const adjustTextareaSize = () => {
const textarea = stadiumInput.value; const textarea = stadiumInput.value;
if (!textarea) { if (!textarea) {
return; return;
} }
const previousHeight = textarea.offsetHeight;
textarea.style.height = 'auto'; textarea.style.height = 'auto';
const computedStyle = window.getComputedStyle(textarea); const computedStyle = window.getComputedStyle(textarea);
const lineHeight = parseFloat(computedStyle.lineHeight || '20') || 20; const lineHeight = parseFloat(computedStyle.lineHeight || '20') || 20;
@ -320,20 +683,18 @@ const adjustTextareaSize = () => {
const lines = Math.max(1, Math.round(targetHeight / lineHeight)); const lines = Math.max(1, Math.round(targetHeight / lineHeight));
const multiline = targetHeight > lineHeight * 1.4; const multiline = targetHeight > lineHeight * 1.4;
applyLineMetrics(lines, multiline); applyLineMetrics(lines, multiline);
textarea.style.height = `${targetHeight}px`;
if (Math.abs(targetHeight - previousHeight) <= 0.5) { nextTick(() => {
textarea.style.height = `${targetHeight}px`; emitComposerHeight();
return;
}
textarea.style.height = `${previousHeight}px`;
void textarea.offsetHeight;
requestAnimationFrame(() => {
textarea.style.height = `${targetHeight}px`;
}); });
}; };
const onInput = (event: Event) => { const onInput = (event: Event) => {
const target = event.target as HTMLTextAreaElement; const target = event.target as HTMLTextAreaElement;
if (!props.isConnected || props.inputLocked) {
target.value = props.inputMessage || '';
return;
}
emit('update:input-message', target.value); emit('update:input-message', target.value);
emit('input-change'); emit('input-change');
adjustTextareaSize(); adjustTextareaSize();
@ -421,6 +782,7 @@ const contextUsageCompactText = computed(() => {
}); });
defineExpose({ defineExpose({
inputAreaRoot,
stadiumShellOuter, stadiumShellOuter,
compactInputShell, compactInputShell,
stadiumInput, stadiumInput,
@ -435,8 +797,85 @@ watch(
} }
); );
watch(
() => props.isConnected,
async (connected) => {
if (!connected && stadiumInput.value) {
stadiumInput.value.blur();
}
await nextTick();
adjustTextareaSize();
}
);
watch(
() => props.currentConversationId,
async () => {
baselineComposerVisualHeight.value = 0;
await nextTick();
emitComposerHeight();
}
);
watch(
() => props.runtimeQueuedMessages,
async () => {
await nextTick();
emitComposerHeight();
},
{ deep: true }
);
watch(
() => [props.selectedImages?.length || 0, props.selectedVideos?.length || 0],
async () => {
await nextTick();
emitComposerHeight();
}
);
onMounted(() => { onMounted(() => {
adjustTextareaSize(); adjustTextareaSize();
nextTick(() => {
emitComposerHeight();
if (typeof ResizeObserver !== 'undefined') {
composerResizeObserver = new ResizeObserver(() => {
emitComposerHeight();
});
if (inputAreaRoot.value) {
composerResizeObserver.observe(inputAreaRoot.value);
}
if (stadiumShellOuter.value) {
composerResizeObserver.observe(stadiumShellOuter.value);
}
if (compactInputShell.value) {
composerResizeObserver.observe(compactInputShell.value);
}
}
});
});
onBeforeUnmount(() => {
runtimeQueueItemRefs.forEach((el) => {
if (el) {
stopRuntimeQueueAnimation(el);
}
});
runtimeQueueGhosts.forEach((ghost) => {
stopRuntimeQueueAnimation(ghost);
ghost.remove();
});
runtimeQueueGhosts.clear();
runtimeQueueItemRefs.clear();
runtimeQueuePrevIds.splice(0, runtimeQueuePrevIds.length);
runtimeQueueRenderedIds.splice(0, runtimeQueueRenderedIds.length);
runtimeQueuePrevRects.clear();
runtimeQueuePrevElements.clear();
runtimeQueueAnimationReady = false;
if (composerResizeObserver) {
composerResizeObserver.disconnect();
composerResizeObserver = null;
}
}); });
</script> </script>
@ -490,5 +929,4 @@ onMounted(() => {
.image-remove-btn-hover:hover { .image-remove-btn-hover:hover {
color: #ef4444; color: #ef4444;
} }
</style> </style>

View File

@ -20,6 +20,17 @@
{{ activeDetail?.command || activeCommand.command }} {{ activeDetail?.command || activeCommand.command }}
</span> </span>
</div> </div>
<div class="subagent-activity-actions" v-if="canStop">
<button
type="button"
class="subagent-stop-btn"
:disabled="stopLoading"
@click="handleStop"
>
{{ stopLoading ? '停止中...' : '手动停止' }}
</button>
<span v-if="stopError" class="subagent-activity-error">{{ stopError }}</span>
</div>
<div class="subagent-activity-body"> <div class="subagent-activity-body">
<div v-if="detailError" class="subagent-activity-error">{{ detailError }}</div> <div v-if="detailError" class="subagent-activity-error">{{ detailError }}</div>
<div v-else-if="detailLoading && !displayOutput" class="subagent-activity-empty"> <div v-else-if="detailLoading && !displayOutput" class="subagent-activity-empty">
@ -33,17 +44,46 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, ref } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useBackgroundCommandStore } from '@/stores/backgroundCommand'; import { useBackgroundCommandStore } from '@/stores/backgroundCommand';
const commandStore = useBackgroundCommandStore(); const commandStore = useBackgroundCommandStore();
const { activeCommand, activeDetail, detailLoading, detailError } = storeToRefs(commandStore); const { activeCommand, activeDetail, detailLoading, detailError, stoppingCommandIds } =
storeToRefs(commandStore);
const stopError = ref('');
const close = () => { const close = () => {
commandStore.closeCommand(); commandStore.closeCommand();
}; };
const isTerminalStatus = (status?: string) => {
const normalized = (status || '').toString().toLowerCase();
return ['completed', 'failed', 'timeout', 'cancelled'].includes(normalized);
};
const canStop = computed(() => {
const status = activeDetail.value?.status || activeCommand.value?.status;
if (!activeCommand.value?.command_id) return false;
return !isTerminalStatus(status);
});
const stopLoading = computed(() => {
const commandId = activeCommand.value?.command_id;
if (!commandId) return false;
return !!stoppingCommandIds.value?.[commandId];
});
const handleStop = async () => {
const commandId = activeCommand.value?.command_id;
if (!commandId || stopLoading.value) return;
stopError.value = '';
const result = await commandStore.cancelCommand(commandId);
if (!result?.success) {
stopError.value = result?.error || '停止失败';
}
};
const displayOutput = computed(() => { const displayOutput = computed(() => {
const text = (activeDetail.value?.output || '').toString(); const text = (activeDetail.value?.output || '').toString();
return text; return text;

View File

@ -16,6 +16,17 @@
activeAgent.summary activeAgent.summary
}}</span> }}</span>
</div> </div>
<div class="subagent-activity-actions" v-if="canStop">
<button
type="button"
class="subagent-stop-btn"
:disabled="stopLoading"
@click="handleStop"
>
{{ stopLoading ? '停止中...' : '手动停止' }}
</button>
<span v-if="stopError" class="subagent-activity-error">{{ stopError }}</span>
</div>
<div class="subagent-activity-body"> <div class="subagent-activity-body">
<div v-if="activityError" class="subagent-activity-error">{{ activityError }}</div> <div v-if="activityError" class="subagent-activity-error">{{ activityError }}</div>
<div v-else-if="!displayItems.length" class="subagent-activity-empty"> <div v-else-if="!displayItems.length" class="subagent-activity-empty">
@ -34,7 +45,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, ref } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useSubAgentStore } from '@/stores/subAgent'; import { useSubAgentStore } from '@/stores/subAgent';
@ -47,7 +58,9 @@ type ActivityEntry = {
}; };
const subAgentStore = useSubAgentStore(); const subAgentStore = useSubAgentStore();
const { activeAgent, activityEntries, activityLoading, activityError } = storeToRefs(subAgentStore); const { activeAgent, activityEntries, activityLoading, activityError, stoppingTaskIds } =
storeToRefs(subAgentStore);
const stopError = ref('');
const close = () => { const close = () => {
subAgentStore.closeSubAgent(); subAgentStore.closeSubAgent();
@ -98,6 +111,32 @@ const buildText = (entry: ActivityEntry) => {
return `${tool || '工具'}`; return `${tool || '工具'}`;
}; };
const isTerminalStatus = (status?: string) => {
const normalized = (status || '').toString().toLowerCase();
return ['completed', 'failed', 'timeout', 'terminated', 'cancelled'].includes(normalized);
};
const canStop = computed(() => {
if (!activeAgent.value?.task_id) return false;
return !isTerminalStatus(activeAgent.value.status);
});
const stopLoading = computed(() => {
const taskId = activeAgent.value?.task_id;
if (!taskId) return false;
return !!stoppingTaskIds.value?.[taskId];
});
const handleStop = async () => {
const taskId = activeAgent.value?.task_id;
if (!taskId || stopLoading.value) return;
stopError.value = '';
const result = await subAgentStore.terminateSubAgent(taskId);
if (!result?.success) {
stopError.value = result?.error || '停止失败';
}
};
const displayItems = computed(() => { const displayItems = computed(() => {
const order: string[] = []; const order: string[] = [];
const map = new Map<string, ActivityEntry>(); const map = new Map<string, ActivityEntry>();

View File

@ -8,11 +8,24 @@
<input <input
type="checkbox" type="checkbox"
:checked="enabled" :checked="enabled"
:disabled="!hostMode || loading" :disabled="loading"
@change="$emit('toggle-enabled', ($event.target as HTMLInputElement).checked)" @change="$emit('toggle-enabled', ($event.target as HTMLInputElement).checked)"
/> />
<span>开启</span> <span>开启</span>
</label> </label>
<label class="scope-line">
<span>管理范围</span>
<select
:value="trackingMode"
:disabled="!hostMode || loading || restoring"
@change="$emit('update:tracking-mode', ($event.target as HTMLSelectElement).value)"
>
<option value="workspace_and_conversation" :disabled="!hostMode">
管理工作区和对话
</option>
<option value="conversation_only">仅管理对话</option>
</select>
</label>
</div> </div>
<div class="versioning-header-actions"> <div class="versioning-header-actions">
<button type="button" class="refresh-btn" :disabled="loading" @click="$emit('refresh')"> <button type="button" class="refresh-btn" :disabled="loading" @click="$emit('refresh')">
@ -84,14 +97,14 @@
</div> </div>
<footer class="versioning-footer"> <footer class="versioning-footer">
<div v-if="workspaceMatched === false" class="workspace-mismatch-tip"> <div v-if="needWorkspaceMatch && workspaceMatched === false" class="workspace-mismatch-tip">
当前对话所属工作区与当前工作区不一致无法执行回溯 当前对话所属工作区与当前工作区不一致无法执行回溯
</div> </div>
<label>回溯模式覆盖当前对话</label> <label>回溯模式覆盖当前对话</label>
<button <button
type="button" type="button"
class="confirm-btn" class="confirm-btn"
:disabled="!enabled || selectedSeq === null || selectedSeq === undefined || restoring || workspaceMatched === false" :disabled="!enabled || selectedSeq === null || selectedSeq === undefined || restoring || (needWorkspaceMatch && workspaceMatched === false)"
@click="$emit('confirm')" @click="$emit('confirm')"
> >
{{ restoring ? '回溯中...' : '确认回溯' }} {{ restoring ? '回溯中...' : '确认回溯' }}
@ -107,6 +120,7 @@ import { computed, ref, watch } from 'vue';
const props = defineProps<{ const props = defineProps<{
hostMode: boolean; hostMode: boolean;
enabled: boolean; enabled: boolean;
trackingMode: string;
loading: boolean; loading: boolean;
items: any[]; items: any[];
selectedSeq: number | null; selectedSeq: number | null;
@ -121,11 +135,14 @@ defineEmits([
'close', 'close',
'refresh', 'refresh',
'toggle-enabled', 'toggle-enabled',
'update:tracking-mode',
'select', 'select',
'confirm', 'confirm',
'update:restore-mode' 'update:restore-mode'
]); ]);
const needWorkspaceMatch = computed(() => String(props.trackingMode || '') !== 'conversation_only');
const orderedItems = computed(() => { const orderedItems = computed(() => {
const src = Array.isArray(props.items) ? props.items : []; const src = Array.isArray(props.items) ? props.items : [];
return [...src].sort((a: any, b: any) => Number(a?.seq || 0) - Number(b?.seq || 0)); return [...src].sort((a: any, b: any) => Number(a?.seq || 0) - Number(b?.seq || 0));
@ -169,6 +186,8 @@ const formatTime = (value: string) => {
.versioning-header-actions { display: inline-flex; align-items: center; gap: 8px; } .versioning-header-actions { display: inline-flex; align-items: center; gap: 8px; }
.close-btn { border: 0; background: transparent; font-size: 24px; cursor: pointer; } .close-btn { border: 0; background: transparent; font-size: 24px; cursor: pointer; }
.switch-line { display: flex; align-items: center; gap: 8px; font-size: 14px; } .switch-line { display: flex; align-items: center; gap: 8px; font-size: 14px; }
.scope-line { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--claude-text-secondary); }
.scope-line select { border: 1px solid var(--theme-control-border); border-radius: 8px; padding: 5px 10px; background: var(--theme-surface-soft); color: var(--claude-text); }
.refresh-btn { border: 1px solid var(--theme-control-border); border-radius: 8px; padding: 6px 10px; background: var(--theme-surface-soft); color: var(--claude-text); cursor: pointer; } .refresh-btn { border: 1px solid var(--theme-control-border); border-radius: 8px; padding: 6px 10px; background: var(--theme-surface-soft); color: var(--claude-text); cursor: pointer; }
.versioning-body { display: grid; grid-template-columns: 380px 1fr; min-height: 0; flex: 1; border-bottom: 1px solid var(--theme-control-border); } .versioning-body { display: grid; grid-template-columns: 380px 1fr; min-height: 0; flex: 1; border-bottom: 1px solid var(--theme-control-border); }
.checkpoint-list { overflow: auto; border-right: 1px solid var(--theme-control-border); padding: 10px; } .checkpoint-list { overflow: auto; border-right: 1px solid var(--theme-control-border); padding: 10px; }

View File

@ -5,6 +5,7 @@ interface BackgroundCommand {
command_id: string; command_id: string;
status?: string; status?: string;
command?: string; command?: string;
notice_pending?: boolean;
created_at?: number; created_at?: number;
updated_at?: number; updated_at?: number;
finished_at?: number | null; finished_at?: number | null;
@ -22,6 +23,7 @@ interface BackgroundCommandState {
pollTimer: ReturnType<typeof setInterval> | null; pollTimer: ReturnType<typeof setInterval> | null;
detailPollTimer: ReturnType<typeof setInterval> | null; detailPollTimer: ReturnType<typeof setInterval> | null;
activeCommand: BackgroundCommand | null; activeCommand: BackgroundCommand | null;
stoppingCommandIds: Record<string, boolean>;
activeDetail: BackgroundCommandDetail | null; activeDetail: BackgroundCommandDetail | null;
detailLoading: boolean; detailLoading: boolean;
detailError: string | null; detailError: string | null;
@ -35,6 +37,7 @@ export const useBackgroundCommandStore = defineStore('backgroundCommand', {
pollTimer: null, pollTimer: null,
detailPollTimer: null, detailPollTimer: null,
activeCommand: null, activeCommand: null,
stoppingCommandIds: {},
activeDetail: null, activeDetail: null,
detailLoading: false, detailLoading: false,
detailError: null detailError: null
@ -49,6 +52,13 @@ export const useBackgroundCommandStore = defineStore('backgroundCommand', {
const data = await resp.json(); const data = await resp.json();
if (data.success) { if (data.success) {
this.commands = Array.isArray(data.data) ? data.data : []; this.commands = Array.isArray(data.data) ? data.data : [];
const activeId = this.activeCommand?.command_id;
if (activeId) {
const latest = this.commands.find((item) => item.command_id === activeId);
if (latest) {
this.activeCommand = { ...latest };
}
}
} }
} catch (error) { } catch (error) {
console.error('获取后台指令列表失败:', error); console.error('获取后台指令列表失败:', error);
@ -120,6 +130,12 @@ export const useBackgroundCommandStore = defineStore('backgroundCommand', {
if (current) { if (current) {
Object.assign(current, data.data); Object.assign(current, data.data);
} }
if (this.activeCommand && this.activeCommand.command_id === commandId) {
this.activeCommand = {
...this.activeCommand,
...data.data
};
}
const status = (data.data.status || '').toString(); const status = (data.data.status || '').toString();
if (TERMINAL_STATUSES.has(status)) { if (TERMINAL_STATUSES.has(status)) {
this.stopDetailPolling(); this.stopDetailPolling();
@ -131,6 +147,38 @@ export const useBackgroundCommandStore = defineStore('backgroundCommand', {
} finally { } finally {
this.detailLoading = false; this.detailLoading = false;
} }
},
async cancelCommand(commandId: string) {
const normalizedId = (commandId || '').toString().trim();
if (!normalizedId) {
return { success: false, error: 'command_id 不能为空' };
}
this.stoppingCommandIds = {
...this.stoppingCommandIds,
[normalizedId]: true
};
try {
const resp = await fetch(
`/api/background_commands/${encodeURIComponent(normalizedId)}/cancel`,
{ method: 'POST' }
);
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data?.success) {
throw new Error(data?.error || `HTTP ${resp.status}`);
}
await this.fetchCommands();
if (this.activeCommand?.command_id === normalizedId) {
await this.fetchCommandDetail(normalizedId);
}
return { success: true, data: data?.data || null };
} catch (error: any) {
const message = error?.message || String(error);
return { success: false, error: message };
} finally {
const next = { ...this.stoppingCommandIds };
delete next[normalizedId];
this.stoppingCommandIds = next;
}
} }
} }
}); });

View File

@ -8,6 +8,7 @@ interface SubAgent {
summary?: string; summary?: string;
last_tool?: string; last_tool?: string;
conversation_id?: string; conversation_id?: string;
notice_pending?: boolean;
} }
interface SubAgentActivityEntry { interface SubAgentActivityEntry {
@ -24,17 +25,21 @@ interface SubAgentState {
pollTimer: ReturnType<typeof setInterval> | null; pollTimer: ReturnType<typeof setInterval> | null;
activityTimer: ReturnType<typeof setInterval> | null; activityTimer: ReturnType<typeof setInterval> | null;
activeAgent: SubAgent | null; activeAgent: SubAgent | null;
stoppingTaskIds: Record<string, boolean>;
activityEntries: SubAgentActivityEntry[]; activityEntries: SubAgentActivityEntry[];
activityLoading: boolean; activityLoading: boolean;
activityError: string | null; activityError: string | null;
} }
const TERMINAL_STATUSES = new Set(['completed', 'failed', 'timeout', 'terminated']);
export const useSubAgentStore = defineStore('subAgent', { export const useSubAgentStore = defineStore('subAgent', {
state: (): SubAgentState => ({ state: (): SubAgentState => ({
subAgents: [], subAgents: [],
pollTimer: null, pollTimer: null,
activityTimer: null, activityTimer: null,
activeAgent: null, activeAgent: null,
stoppingTaskIds: {},
activityEntries: [], activityEntries: [],
activityLoading: false, activityLoading: false,
activityError: null activityError: null
@ -49,6 +54,13 @@ export const useSubAgentStore = defineStore('subAgent', {
const data = await resp.json(); const data = await resp.json();
if (data.success) { if (data.success) {
this.subAgents = Array.isArray(data.data) ? data.data : []; this.subAgents = Array.isArray(data.data) ? data.data : [];
const activeTaskId = this.activeAgent?.task_id;
if (activeTaskId) {
const latest = this.subAgents.find((item) => item.task_id === activeTaskId);
if (latest) {
this.activeAgent = { ...latest };
}
}
} }
} catch (error) { } catch (error) {
console.error('获取子智能体列表失败:', error); console.error('获取子智能体列表失败:', error);
@ -117,6 +129,16 @@ export const useSubAgentStore = defineStore('subAgent', {
if (data && data.success && data.data) { if (data && data.success && data.data) {
const entries = Array.isArray(data.data.entries) ? data.data.entries : []; const entries = Array.isArray(data.data.entries) ? data.data.entries : [];
this.activityEntries = entries; this.activityEntries = entries;
if (this.activeAgent && this.activeAgent.task_id === taskId) {
this.activeAgent = {
...this.activeAgent,
status: data.data.status || this.activeAgent.status
};
}
const status = (data.data.status || '').toString();
if (TERMINAL_STATUSES.has(status)) {
this.stopActivityPolling();
}
} }
} catch (error: any) { } catch (error: any) {
this.activityError = error?.message || String(error); this.activityError = error?.message || String(error);
@ -125,6 +147,41 @@ export const useSubAgentStore = defineStore('subAgent', {
this.activityLoading = false; this.activityLoading = false;
} }
}, },
async terminateSubAgent(taskId: string) {
const normalizedId = (taskId || '').toString().trim();
if (!normalizedId) {
return { success: false, error: 'task_id 不能为空' };
}
this.stoppingTaskIds = {
...this.stoppingTaskIds,
[normalizedId]: true
};
try {
const resp = await fetch(`/api/sub_agents/${encodeURIComponent(normalizedId)}/terminate`, {
method: 'POST'
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data?.success) {
throw new Error(data?.error || `HTTP ${resp.status}`);
}
await this.fetchSubAgents();
if (this.activeAgent?.task_id === normalizedId) {
await this.fetchSubAgentActivity(normalizedId);
this.activeAgent = {
...this.activeAgent,
status: 'terminated'
};
}
return { success: true, data: data?.data || null };
} catch (error: any) {
const message = error?.message || String(error);
return { success: false, error: message };
} finally {
const next = { ...this.stoppingTaskIds };
delete next[normalizedId];
this.stoppingTaskIds = next;
}
},
stripConversationPrefix(conversationId: string) { stripConversationPrefix(conversationId: string) {
if (!conversationId) return ''; if (!conversationId) return '';
return conversationId.startsWith('conv_') ? conversationId.slice(5) : conversationId; return conversationId.startsWith('conv_') ? conversationId.slice(5) : conversationId;

View File

@ -11,6 +11,27 @@ const keyNotifyLog = (...args: any[]) => {
const jsonDebug = (...args: any[]) => { const jsonDebug = (...args: any[]) => {
console.log('[JSONDEBUG]', ...args); console.log('[JSONDEBUG]', ...args);
}; };
const CONN_DIAG_PREFIX = '[CONN_DIAG]';
const TASK_POLL_DIAG_MAX = 2000;
const taskPollDiag = (event: string, payload: Record<string, any> = {}) => {
const ts = new Date().toISOString();
const record = { ts, event, ...payload };
try {
if (typeof window !== 'undefined') {
const w = window as any;
if (!Array.isArray(w.__CONN_DIAG_LOGS__)) {
w.__CONN_DIAG_LOGS__ = [];
}
w.__CONN_DIAG_LOGS__.push(record);
if (w.__CONN_DIAG_LOGS__.length > TASK_POLL_DIAG_MAX) {
w.__CONN_DIAG_LOGS__.splice(0, w.__CONN_DIAG_LOGS__.length - TASK_POLL_DIAG_MAX);
}
}
} catch {
// ignore
}
console.log(CONN_DIAG_PREFIX, event, record);
};
export const useTaskStore = defineStore('task', { export const useTaskStore = defineStore('task', {
state: () => ({ state: () => ({
@ -21,8 +42,15 @@ export const useTaskStore = defineStore('task', {
isPolling: false, isPolling: false,
pollingError: null as string | null, pollingError: null as string | null,
pollingErrorCount: 0, // 新增错误计数 pollingErrorCount: 0, // 新增错误计数
pollingInFlight: false,
pollingSeq: 0,
pollingWarned: false,
pollingRequestTimeoutMs: 12000,
pollingFailThreshold: 8,
pollingIntervalMs: 250,
taskCreatedAt: null as number | null, taskCreatedAt: null as number | null,
taskUpdatedAt: null as number | null taskUpdatedAt: null as number | null,
runtimeQueueSnapshotKey: ''
}), }),
getters: { getters: {
@ -40,6 +68,7 @@ export const useTaskStore = defineStore('task', {
model_key?: string | null; model_key?: string | null;
run_mode?: 'fast' | 'thinking' | 'deep' | null; run_mode?: 'fast' | 'thinking' | 'deep' | null;
thinking_mode?: boolean | null; thinking_mode?: boolean | null;
eventHandler?: (event: any) => void;
} = {} } = {}
) { ) {
try { try {
@ -76,16 +105,27 @@ export const useTaskStore = defineStore('task', {
this.taskStatus = result.data.status; this.taskStatus = result.data.status;
this.lastEventIndex = 0; this.lastEventIndex = 0;
this.taskCreatedAt = result.data.created_at; this.taskCreatedAt = result.data.created_at;
this.runtimeQueueSnapshotKey = '';
this.pollingError = null; this.pollingError = null;
this.pollingErrorCount = 0;
this.pollingWarned = false;
debugLog('[Task] 任务创建成功:', result.data.task_id); debugLog('[Task] 任务创建成功:', result.data.task_id);
taskPollDiag('task-created', {
taskId: result.data.task_id,
conversationId
});
// 立即开始轮询 // 立即开始轮询
this.startPolling(); this.startPolling(options.eventHandler);
return result.data; return result.data;
} catch (error) { } catch (error) {
debugLog('[Task] 创建任务失败:', error); debugLog('[Task] 创建任务失败:', error);
taskPollDiag('task-create-failed', {
error: error?.message || String(error),
conversationId
});
throw error; throw error;
} }
}, },
@ -93,14 +133,29 @@ export const useTaskStore = defineStore('task', {
async pollTaskEvents(eventHandler: (event: any) => void) { async pollTaskEvents(eventHandler: (event: any) => void) {
if (!this.currentTaskId) { if (!this.currentTaskId) {
debugLog('[Task] 没有活跃任务,停止轮询'); debugLog('[Task] 没有活跃任务,停止轮询');
this.stopPolling(); this.stopPolling('no-active-task');
return; return;
} }
if (this.pollingInFlight) {
return;
}
this.pollingInFlight = true;
const taskId = this.currentTaskId;
const fromOffset = this.lastEventIndex;
const requestId = `${Date.now()}-${++this.pollingSeq}`;
const startedAt = Date.now();
try { try {
const response = await fetch( const response = await fetch(
`/api/tasks/${this.currentTaskId}?from=${this.lastEventIndex}`, `/api/tasks/${taskId}?from=${fromOffset}`,
{ signal: AbortSignal.timeout(5000) } // 5秒超时 {
signal: AbortSignal.timeout(this.pollingRequestTimeoutMs),
headers: {
'X-Task-Poll': requestId
}
}
); );
if (!response.ok) { if (!response.ok) {
@ -113,17 +168,53 @@ export const useTaskStore = defineStore('task', {
} }
const data = result.data; const data = result.data;
const eventsCount = Array.isArray(data?.events) ? data.events.length : 0;
jsonDebug('taskStore.poll:response', { jsonDebug('taskStore.poll:response', {
taskId: this.currentTaskId, taskId,
from: this.lastEventIndex, from: fromOffset,
status: data?.status, status: data?.status,
nextOffset: data?.next_offset, nextOffset: data?.next_offset,
eventsCount: Array.isArray(data?.events) ? data.events.length : 0 eventsCount
}); });
if (eventsCount > 0 || this.pollingErrorCount > 0 || data?.status !== 'running' || fromOffset === 0) {
taskPollDiag('task-poll-ok', {
requestId,
taskId,
from: fromOffset,
nextOffset: data?.next_offset,
status: data?.status,
eventsCount,
elapsedMs: Date.now() - startedAt
});
}
// 更新任务状态 // 更新任务状态
this.taskStatus = data.status; this.taskStatus = data.status;
this.taskUpdatedAt = data.updated_at; this.taskUpdatedAt = data.updated_at;
const runtimeQueueMessages = Array.isArray(data?.runtime_queued_messages)
? data.runtime_queued_messages
: [];
const runtimeQueueSnapshotKey = JSON.stringify(
runtimeQueueMessages.map((item: any) => [
String(item?.id || ''),
String(item?.text || '')
])
);
if (runtimeQueueSnapshotKey !== this.runtimeQueueSnapshotKey) {
this.runtimeQueueSnapshotKey = runtimeQueueSnapshotKey;
try {
eventHandler({
type: 'runtime_queue_sync',
data: {
task_id: data?.task_id || this.currentTaskId,
conversation_id: data?.conversation_id || null,
messages: runtimeQueueMessages
}
});
} catch (err) {
console.warn('[Task] 处理 runtime_queue_sync 失败:', err);
}
}
let sawTaskCompleteEvent = false; let sawTaskCompleteEvent = false;
let sawTaskStoppedEvent = false; let sawTaskStoppedEvent = false;
@ -233,17 +324,29 @@ export const useTaskStore = defineStore('task', {
// 如果任务已完成,停止轮询 // 如果任务已完成,停止轮询
if (this.isTaskCompleted) { if (this.isTaskCompleted) {
debugLog('[Task] 任务已完成,停止轮询:', this.taskStatus); debugLog('[Task] 任务已完成,停止轮询:', this.taskStatus);
this.stopPolling(); this.stopPolling(`task-${this.taskStatus}`);
} }
// 重置错误计数 // 重置错误计数
this.pollingError = null; this.pollingError = null;
this.pollingErrorCount = 0; this.pollingErrorCount = 0;
this.pollingWarned = false;
} catch (error) { } catch (error) {
this.pollingErrorCount++; this.pollingErrorCount++;
this.pollingError = error.message; this.pollingError = error.message;
const message = error?.message || String(error);
const isTimeoutLike = /timeout|timed out|abort|aborted/i.test(message);
console.error('[Task] 轮询失败:', error); console.error('[Task] 轮询失败:', error);
taskPollDiag('task-poll-failed', {
requestId,
taskId,
from: fromOffset,
elapsedMs: Date.now() - startedAt,
error: message,
isTimeoutLike,
pollingErrorCount: this.pollingErrorCount
});
debugNotifyLog('[DEBUG_NOTIFY][task] pollTaskEvents:error', { debugNotifyLog('[DEBUG_NOTIFY][task] pollTaskEvents:error', {
taskId: this.currentTaskId, taskId: this.currentTaskId,
from: this.lastEventIndex, from: this.lastEventIndex,
@ -251,19 +354,26 @@ export const useTaskStore = defineStore('task', {
pollingErrorCount: this.pollingErrorCount pollingErrorCount: this.pollingErrorCount
}); });
// 连续失败 5 次后停止 // 任务不存在时结束轮询
if (this.pollingErrorCount >= 5) { if (/HTTP 404|HTTP 410/.test(message)) {
this.stopPolling(); this.stopPolling('task-not-found');
// 通知用户 return;
}
// 连续失败达到阈值仅告警,不直接停轮询(避免后端仍运行但前端停止更新)
if (this.pollingErrorCount >= this.pollingFailThreshold && !this.pollingWarned) {
this.pollingWarned = true;
if ((window as any).__vueApp?.uiPushToast) { if ((window as any).__vueApp?.uiPushToast) {
(window as any).__vueApp.uiPushToast({ (window as any).__vueApp.uiPushToast({
title: '轮询失败', title: '轮询波动',
message: '请刷新页面重试', message: '消息更新暂时不稳定,正在自动重试',
type: 'error', type: 'warning',
duration: 10000 duration: 5000
}); });
} }
} }
} finally {
this.pollingInFlight = false;
} }
}, },
@ -288,23 +398,36 @@ export const useTaskStore = defineStore('task', {
lastEventIndex: this.lastEventIndex lastEventIndex: this.lastEventIndex
}); });
this.isPolling = true; this.isPolling = true;
this.pollingError = null;
this.pollingErrorCount = 0;
this.pollingWarned = false;
// 如果没有传入 eventHandler从根实例获取 // 如果没有传入 eventHandler从根实例获取
const handler = eventHandler || (window as any).__taskEventHandler; const handler = eventHandler || (window as any).__taskEventHandler;
if (!handler) { if (!handler) {
console.error('[Task] 没有事件处理器,无法启动轮询'); console.error('[Task] 没有事件处理器,无法启动轮询');
taskPollDiag('task-poll-start-failed-no-handler', {
taskId: this.currentTaskId
});
this.isPolling = false; this.isPolling = false;
return; return;
} }
// 立即执行一次 taskPollDiag('task-poll-start', {
this.pollTaskEvents(handler); taskId: this.currentTaskId,
from: this.lastEventIndex,
intervalMs: this.pollingIntervalMs,
timeoutMs: this.pollingRequestTimeoutMs
});
// 设置定时轮询150ms 间隔,接近流式输出效果) // 立即执行一次
void this.pollTaskEvents(handler);
// 设置定时轮询(单飞由 pollTaskEvents 内部保证)
this.pollingInterval = window.setInterval(() => { this.pollingInterval = window.setInterval(() => {
this.pollTaskEvents(handler); void this.pollTaskEvents(handler);
}, 150); }, this.pollingIntervalMs);
}, },
resumeTask( resumeTask(
@ -337,16 +460,27 @@ export const useTaskStore = defineStore('task', {
this.lastEventIndex = 0; this.lastEventIndex = 0;
} }
this.pollingError = null; this.pollingError = null;
this.runtimeQueueSnapshotKey = '';
this.startPolling(options.eventHandler); this.startPolling(options.eventHandler);
}, },
stopPolling() { stopPolling(reason = 'manual') {
taskPollDiag('task-poll-stop', {
reason,
taskId: this.currentTaskId,
status: this.taskStatus,
from: this.lastEventIndex,
pollingErrorCount: this.pollingErrorCount
});
if (this.pollingInterval) { if (this.pollingInterval) {
debugLog('[Task] 停止轮询'); debugLog('[Task] 停止轮询');
clearInterval(this.pollingInterval); clearInterval(this.pollingInterval);
this.pollingInterval = null; this.pollingInterval = null;
} }
this.isPolling = false; this.isPolling = false;
this.pollingInFlight = false;
this.pollingWarned = false;
this.runtimeQueueSnapshotKey = '';
this.currentTaskId = null; // 清除任务 ID this.currentTaskId = null; // 清除任务 ID
}, },
@ -407,6 +541,9 @@ export const useTaskStore = defineStore('task', {
this.taskCreatedAt = runningTask.created_at; this.taskCreatedAt = runningTask.created_at;
this.taskUpdatedAt = runningTask.updated_at; this.taskUpdatedAt = runningTask.updated_at;
this.pollingError = null; this.pollingError = null;
this.pollingErrorCount = 0;
this.pollingWarned = false;
this.runtimeQueueSnapshotKey = '';
// 获取任务详情,计算已处理的事件数量 // 获取任务详情,计算已处理的事件数量
try { try {
@ -447,8 +584,11 @@ export const useTaskStore = defineStore('task', {
this.lastEventIndex = 0; this.lastEventIndex = 0;
this.taskStatus = 'idle'; this.taskStatus = 'idle';
this.pollingError = null; this.pollingError = null;
this.pollingErrorCount = 0;
this.pollingWarned = false;
this.taskCreatedAt = null; this.taskCreatedAt = null;
this.taskUpdatedAt = null; this.taskUpdatedAt = null;
this.runtimeQueueSnapshotKey = '';
}, },
resetForNewConversation() { resetForNewConversation() {
@ -458,6 +598,9 @@ export const useTaskStore = defineStore('task', {
this.lastEventIndex = 0; this.lastEventIndex = 0;
this.taskStatus = 'idle'; this.taskStatus = 'idle';
this.pollingError = null; this.pollingError = null;
this.pollingErrorCount = 0;
this.pollingWarned = false;
this.runtimeQueueSnapshotKey = '';
} }
} }
}); });

View File

@ -1,7 +1,9 @@
/* 聊天容器整体布局,保证聊天区可见并支持上下滚动 */ /* 聊天容器整体布局,保证聊天区可见并支持上下滚动 */
.chat-container { .chat-container {
--chat-surface-color: var(--theme-surface-strong, #ffffff); --chat-surface-color: var(--theme-surface-strong, #ffffff);
--composer-reserved-height: calc(75px + var(--app-bottom-inset, 0px)); --composer-base-height: calc(80px + var(--app-bottom-inset, 0px));
--composer-reserved-height: var(--composer-base-height);
--composer-growth-height: 0px;
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -75,7 +77,7 @@
overflow-anchor: none; overflow-anchor: none;
padding: 24px; padding: 24px;
padding-top: 30px; padding-top: 30px;
padding-bottom: calc(20px + var(--app-bottom-inset, 0px)); padding-bottom: calc(20px + var(--composer-growth-height, 0px));
min-height: 0; min-height: 0;
position: relative; position: relative;
} }
@ -152,7 +154,8 @@
@media (max-width: 768px) { @media (max-width: 768px) {
.chat-container { .chat-container {
--composer-reserved-height: calc(96px + var(--app-bottom-inset, 0px)); --composer-base-height: calc(80px + var(--app-bottom-inset, 0px));
--composer-reserved-height: var(--composer-base-height);
} }
.chat-container--mobile { .chat-container--mobile {

View File

@ -24,6 +24,115 @@
pointer-events: auto; pointer-events: auto;
} }
.runtime-queue-list {
--runtime-queue-bg: var(--theme-surface-soft);
--runtime-queue-divider: rgba(15, 23, 42, 0.12);
position: absolute;
left: 50%;
bottom: calc(100% - 2px);
transform: translateX(-50%);
z-index: 1;
display: flex;
flex-direction: column;
width: 90%;
max-width: 90%;
margin: 0;
overflow: visible;
border: none;
background: transparent;
box-shadow: none;
pointer-events: auto;
}
.runtime-queue-list--empty {
height: 0;
border: none;
box-shadow: none;
background: transparent;
margin: 0;
overflow: visible;
pointer-events: none;
}
.runtime-queue-item {
position: relative;
z-index: var(--runtime-queue-z, 1);
min-height: 34px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 10px;
padding: 6px 10px 6px 12px;
border: none;
border-radius: 0;
background: var(--runtime-queue-bg);
}
.runtime-queue-item--top {
border-top-left-radius: 12px;
border-top-right-radius: 12px;
}
.runtime-queue-item__text {
min-width: 0;
font-size: 13px;
line-height: 1.3;
color: var(--claude-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.runtime-queue-item__action {
border: none;
background: transparent;
color: var(--claude-text-secondary);
font-size: 12px;
line-height: 1.2;
padding: 3px 4px;
border-radius: 6px;
cursor: pointer;
}
.runtime-queue-item__action:hover {
background: var(--theme-chip-bg);
color: var(--claude-text);
}
.runtime-queue-item__action--danger:hover {
color: #b91c1c;
background: rgba(185, 28, 28, 0.12);
}
.runtime-queue-item + .runtime-queue-item::before {
content: '';
position: absolute;
left: 0;
right: 0;
top: 0;
height: 1px;
background: var(--runtime-queue-divider);
pointer-events: none;
}
body[data-theme='dark'] {
.runtime-queue-list {
--runtime-queue-bg: #2a2a2a;
--runtime-queue-divider: rgba(255, 255, 255, 0.12);
background: transparent;
}
.runtime-queue-item {
background: var(--runtime-queue-bg);
}
.runtime-queue-list.runtime-queue-list--empty {
background: transparent;
border: none;
box-shadow: none;
}
}
.permission-switcher { .permission-switcher {
position: absolute; position: absolute;
left: 20px; left: 20px;
@ -222,6 +331,7 @@
.stadium-shell { .stadium-shell {
--stadium-radius: 24px; --stadium-radius: 24px;
position: relative; position: relative;
z-index: 2;
width: 100%; width: 100%;
min-height: calc(var(--stadium-radius) * 2.1); min-height: calc(var(--stadium-radius) * 2.1);
padding: 12px 18px; padding: 12px 18px;
@ -232,8 +342,6 @@
display: flex; display: flex;
gap: 12px; gap: 12px;
transition: transition:
padding 0.2s ease,
min-height 0.2s ease,
box-shadow 0.45s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.45s cubic-bezier(0.4, 0, 0.2, 1),
border-color 0.45s cubic-bezier(0.4, 0, 0.2, 1); border-color 0.45s cubic-bezier(0.4, 0, 0.2, 1);
} }
@ -327,8 +435,7 @@
outline: none; outline: none;
overflow-y: auto; overflow-y: auto;
scrollbar-width: none; scrollbar-width: none;
transition: height 0.28s cubic-bezier(0.4, 0, 0.2, 1); transition: none;
will-change: height;
} }
.stadium-input:disabled { .stadium-input:disabled {
@ -548,8 +655,8 @@
.composer-container { .composer-container {
position: relative; position: relative;
height: var(--composer-reserved-height, calc(108px + var(--app-bottom-inset, 0px))); height: var(--composer-base-height, calc(80px + var(--app-bottom-inset, 0px)));
flex: 0 0 var(--composer-reserved-height, calc(108px + var(--app-bottom-inset, 0px))); flex: 0 0 var(--composer-base-height, calc(80px + var(--app-bottom-inset, 0px)));
transition: transform 0.3s ease; transition: transform 0.3s ease;
z-index: 2; z-index: 2;
} }

View File

@ -2005,6 +2005,34 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.subagent-activity-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.subagent-stop-btn {
border: 1px solid rgba(239, 68, 68, 0.4);
background: rgba(239, 68, 68, 0.12);
color: #ef4444;
border-radius: 10px;
font-size: 12px;
padding: 4px 10px;
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease;
}
.subagent-stop-btn:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.18);
border-color: rgba(239, 68, 68, 0.55);
}
.subagent-stop-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.subagent-activity-status { .subagent-activity-status {
padding: 2px 8px; padding: 2px 8px;
border-radius: 999px; border-radius: 999px;
@ -2021,7 +2049,9 @@
} }
.subagent-activity-status.failed, .subagent-activity-status.failed,
.subagent-activity-status.timeout { .subagent-activity-status.timeout,
.subagent-activity-status.terminated,
.subagent-activity-status.cancelled {
background: rgba(239, 68, 68, 0.15); background: rgba(239, 68, 68, 0.15);
color: #ef4444; color: #ef4444;
} }

View File

@ -404,6 +404,12 @@
.sub-agent-panel { .sub-agent-panel {
padding: 16px 20px 24px; padding: 16px 20px 24px;
display: flex;
flex-direction: column;
gap: 10px;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
} }
.sub-agent-cards { .sub-agent-cards {
@ -458,7 +464,9 @@
} }
.sub-agent-status.failed, .sub-agent-status.failed,
.sub-agent-status.timeout { .sub-agent-status.timeout,
.sub-agent-status.terminated,
.sub-agent-status.cancelled {
color: #d63031; color: #d63031;
} }

View File

@ -0,0 +1,87 @@
from __future__ import annotations
import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
class ConfigPathsResolutionTest(unittest.TestCase):
def setUp(self):
self.repo_root = Path(__file__).resolve().parents[1]
def _load_paths(self, *, cwd: Path, extra_env: dict | None = None) -> dict:
code = """
import json
import config
print(json.dumps({
"DEFAULT_PROJECT_PATH": config.DEFAULT_PROJECT_PATH,
"HOST_WORKSPACES_FILE": config.HOST_WORKSPACES_FILE,
"DATA_DIR": config.DATA_DIR,
"LOGS_DIR": config.LOGS_DIR,
"USER_SPACE_DIR": config.USER_SPACE_DIR,
"API_USER_SPACE_DIR": config.API_USER_SPACE_DIR,
"SUB_AGENT_STATE_FILE": config.SUB_AGENT_STATE_FILE,
}, ensure_ascii=False))
"""
env = dict(os.environ)
env["PYTHONPATH"] = str(self.repo_root)
if extra_env:
env.update(extra_env)
completed = subprocess.run(
[sys.executable, "-c", code],
cwd=str(cwd),
env=env,
capture_output=True,
text=True,
check=True,
)
lines = [line.strip() for line in (completed.stdout or "").splitlines() if line.strip()]
self.assertTrue(lines, f"stdout is empty, stderr={completed.stderr}")
return json.loads(lines[-1])
def test_default_paths_are_resolved_from_repo_root(self):
with tempfile.TemporaryDirectory() as td:
data = self._load_paths(cwd=Path(td))
for key, raw in data.items():
value = Path(str(raw))
self.assertTrue(value.is_absolute(), f"{key} is not absolute: {raw}")
self.assertTrue(
str(value).startswith(str(self.repo_root)),
f"{key} should be anchored to repo root: {raw}",
)
def test_relative_env_overrides_also_anchor_to_repo_root(self):
with tempfile.TemporaryDirectory() as td:
data = self._load_paths(
cwd=Path(td),
extra_env={
"DEFAULT_PROJECT_PATH": "./workspace_rel",
"DATA_DIR": "./data_rel",
"LOGS_DIR": "./logs_rel",
"USER_SPACE_DIR": "./users_rel",
"HOST_WORKSPACES_FILE": "./config/host_workspaces_rel.json",
"SUB_AGENT_STATE_FILE": "./data/sub_agents_rel.json",
},
)
self.assertEqual(data["DEFAULT_PROJECT_PATH"], str((self.repo_root / "workspace_rel").resolve()))
self.assertEqual(data["DATA_DIR"], str((self.repo_root / "data_rel").resolve()))
self.assertEqual(data["LOGS_DIR"], str((self.repo_root / "logs_rel").resolve()))
self.assertEqual(data["USER_SPACE_DIR"], str((self.repo_root / "users_rel").resolve()))
self.assertEqual(
data["HOST_WORKSPACES_FILE"],
str((self.repo_root / "config" / "host_workspaces_rel.json").resolve()),
)
self.assertEqual(
data["SUB_AGENT_STATE_FILE"],
str((self.repo_root / "data" / "sub_agents_rel.json").resolve()),
)
if __name__ == "__main__":
unittest.main()

View File

@ -478,6 +478,51 @@ class MCPIntegrationTest(unittest.TestCase):
self.assertNotIn("/opt/homebrew/bin/npx", launch_cmd) self.assertNotIn("/opt/homebrew/bin/npx", launch_cmd)
self.assertIn("/Users/jojo/Desktop", launch_cmd) self.assertIn("/Users/jojo/Desktop", launch_cmd)
def test_stdio_host_mode_uses_workspace_as_default_cwd(self):
workspace = (self.project_dir / "workspace_a").resolve()
workspace.mkdir(parents=True, exist_ok=True)
fake_session = SimpleNamespace(
mode="host",
workspace_path=str(workspace),
mount_path=str(workspace),
container_name=None,
sandbox_bin="docker",
)
server = {
"command": sys.executable,
"args": ["-V"],
"cwd": "",
"env": {},
}
client = _StdioMCPClient(server, timeout_seconds=10, protocol_version="2025-06-18", container_session=fake_session)
_, cwd, _ = client._prepare_launch()
self.assertEqual(cwd, str(workspace))
def test_set_container_session_detects_inplace_workspace_change(self):
manager = MCPClientManager(self.registry)
close_calls = []
original_close = manager.close_all_clients
manager.close_all_clients = lambda: close_calls.append("closed") # type: ignore[assignment]
try:
session = SimpleNamespace(
mode="host",
workspace_path=str((self.project_dir / "ws_a").resolve()),
mount_path="/workspace",
container_name="",
sandbox_bin="docker",
)
manager.set_container_session(session)
self.assertEqual(len(close_calls), 1)
session.workspace_path = str((self.project_dir / "ws_b").resolve())
manager.set_container_session(session)
self.assertEqual(len(close_calls), 2)
manager.set_container_session(session)
self.assertEqual(len(close_calls), 2)
finally:
manager.close_all_clients = original_close # type: ignore[assignment]
def test_stateful_stdio_server_reuses_persistent_session(self): def test_stateful_stdio_server_reuses_persistent_session(self):
stateful_script = self._write_temp_server( stateful_script = self._write_temp_server(
"stateful_mcp_server.py", "stateful_mcp_server.py",