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