diff --git a/.easyagent/conversations/c22a415e-d9a7-46e6-8d6b-6b405dc93ee2.json b/.easyagent/conversations/c22a415e-d9a7-46e6-8d6b-6b405dc93ee2.json new file mode 100644 index 0000000..5207547 --- /dev/null +++ b/.easyagent/conversations/c22a415e-d9a7-46e6-8d6b-6b405dc93ee2.json @@ -0,0 +1,33 @@ +{ + "id": "c22a415e-d9a7-46e6-8d6b-6b405dc93ee2", + "title": "nih你好!", + "created_at": "2026-03-09T08:47:38.238Z", + "updated_at": "2026-03-09T08:47:58.940Z", + "metadata": { + "model_key": "kimi-k2.5", + "model_id": "kimi-k2.5", + "thinking_mode": true, + "allow_mode": "full_access", + "token_usage": { + "prompt": 1278, + "completion": 98, + "total": 1376 + }, + "cwd": "/Users/jojo/Desktop/agents/正在修复中/agents" + }, + "messages": [ + { + "role": "user", + "content": "nih你好!" + }, + { + "role": "assistant", + "content": "你好!很高兴见到你。有什么我可以帮你的吗?", + "reasoning_content": "用户发送了一条消息,看起来像是在输入中文\"你好\"之前有一些乱码字符(可能是Ctrl+H或其他控制字符)。用户用中文问候\"你好\",所以我应该用中文回复。\n\n消息内容是:\"nih你好!\"\n\n看起来用户可能在尝试输入\"nihao\"(拼音),然后出现了一些乱码字符,最后正确输入了\"你好\"。\n\n我应该友好地用中文回复。" + }, + { + "role": "user", + "content": "\u0003\u0003\u0003\u0003/exit" + } + ] +} \ No newline at end of file diff --git a/.easyagent/conversations/eff6d3c4-193b-4f13-9867-47d9b7620577.json b/.easyagent/conversations/eff6d3c4-193b-4f13-9867-47d9b7620577.json new file mode 100644 index 0000000..8e33021 --- /dev/null +++ b/.easyagent/conversations/eff6d3c4-193b-4f13-9867-47d9b7620577.json @@ -0,0 +1,29 @@ +{ + "id": "eff6d3c4-193b-4f13-9867-47d9b7620577", + "title": "你好", + "created_at": "2026-03-09T10:26:17.218Z", + "updated_at": "2026-03-09T10:26:26.596Z", + "metadata": { + "model_key": "kimi-k2.5", + "model_id": "kimi-k2.5", + "thinking_mode": true, + "allow_mode": "full_access", + "token_usage": { + "prompt": 1270, + "completion": 81, + "total": 1351 + }, + "cwd": "/Users/jojo/Desktop/agents/正在修复中/agents" + }, + "messages": [ + { + "role": "user", + "content": "你好" + }, + { + "role": "assistant", + "content": "你好!我是 EasyAgent,一个终端智能体,可以帮助你完成各种开发任务。\n\n有什么我可以帮你的吗?比如:\n\n- 搜索或读取文件\n- 编辑代码\n- 执行命令\n- 网络搜索\n- 等等...\n\n请告诉我你的需求!", + "reasoning_content": "用户只是简单地打了个招呼\"你好\"。我应该礼貌地回应,并询问用户有什么可以帮忙的。" + } + ] +} \ No newline at end of file diff --git a/.easyagent/index.json b/.easyagent/index.json new file mode 100644 index 0000000..14286dd --- /dev/null +++ b/.easyagent/index.json @@ -0,0 +1,22 @@ +{ + "c22a415e-d9a7-46e6-8d6b-6b405dc93ee2": { + "title": "nih你好!", + "created_at": "2026-03-09T08:47:38.238Z", + "updated_at": "2026-03-09T08:47:58.940Z", + "total_messages": 3, + "total_tools": 0, + "thinking_mode": true, + "run_mode": "thinking", + "model_key": "kimi-k2.5" + }, + "eff6d3c4-193b-4f13-9867-47d9b7620577": { + "title": "你好", + "created_at": "2026-03-09T10:26:17.218Z", + "updated_at": "2026-03-09T10:26:26.596Z", + "total_messages": 2, + "total_tools": 0, + "thinking_mode": true, + "run_mode": "thinking", + "model_key": "kimi-k2.5" + } +} \ No newline at end of file diff --git a/SUB_AGENT_COMPLETION_SUMMARY.md b/SUB_AGENT_COMPLETION_SUMMARY.md new file mode 100644 index 0000000..c148f24 --- /dev/null +++ b/SUB_AGENT_COMPLETION_SUMMARY.md @@ -0,0 +1,211 @@ +# 子智能体实现完成总结 + +## 已完成的工作 + +### 1. 复制 easyagent 代码 ✅ +- 将 easyagent 的核心代码复制到 `easyagent/` 目录 +- 包含 src/、prompts/、doc/、package.json + +### 2. 创建批处理模式 ✅ +- 创建 `easyagent/src/batch/index.js` 批处理入口 +- 支持命令行参数:workspace、task-file、system-prompt-file、output-file、stats-file、agent-id、timeout +- 实现对话循环和工具执行 +- 添加 finish_task 工具定义 +- 实现兜底机制:自动提醒调用 finish_task +- 实现对话记录保存到 `.subagent/conversation.json` +- 实现超时和轮次限制保护 + +### 3. 更新工具定义 ✅ +- 更新 `core/main_terminal_parts/tools_definition.py` +- 替换旧的 create_sub_agent 工具定义 +- 移除 wait_sub_agent 工具(合并到 create_sub_agent 的 run_in_background 参数) +- 添加 terminate_sub_agent 工具 +- 添加 get_sub_agent_status 工具 +- 详细的工具描述,包括使用场景和限制 + +### 4. 更新工具执行 ✅ +- 更新 `core/main_terminal_parts/tools_execution.py` +- 修改 create_sub_agent 执行逻辑: + - 支持 deliverables_dir 参数(替代 target_dir) + - 支持 run_in_background 参数 + - 阻塞模式自动等待完成 +- 添加 terminate_sub_agent 执行逻辑 +- 添加 get_sub_agent_status 执行逻辑 + +### 5. 更新 SubAgentManager ✅ +- 已有新的实现(子进程模式) +- 支持启动 Node.js 子进程 +- 实现任务状态管理 +- 实现进程监控和终止 +- 更新 `_build_system_prompt` 为完整版本: + - 详细的工作流程 + - 工作原则(专注性、独立性、效率性、完整性) + - 交付要求 + - 工具使用说明 + - 注意事项 + - 当前环境信息 + +### 6. 创建文档 ✅ +- `SUB_AGENT_IMPLEMENTATION_PLAN.md` - 完整实现方案 +- `SUB_AGENT_TESTING.md` - 测试指南 + +## 核心特性 + +### 1. 简化的参数 +- 去掉 reference_files(子智能体自己搜索文件) +- 去掉 delivery_mode(统一为文件交付) +- 去掉 reason 参数(终止时不需要原因) +- deliverables_dir 可选(默认 sub_agent_results/agent_{id}) + +### 2. 后台运行机制 +- run_in_background 参数(默认 false) +- 后台模式:立即返回,完成后通知 +- 阻塞模式:等待完成后返回结果 +- 工具描述中明确说明何时使用 + +### 3. 完成任务工具 +- 只需两个参数:success 和 summary +- 调用后立即退出 +- 兜底机制:忘记调用时自动提醒 + +### 4. 统计信息 +- 运行时间 +- 文件读取次数 +- 搜索次数 +- 网页查看次数 +- 命令执行次数 +- Token 使用量 + +### 5. 对话记录 +- 保存在交付目录的 `.subagent/conversation.json` +- 包含完整的消息历史 +- 包含统计信息 +- 便于调试和审计 + +### 6. 安全限制 +- 最多 5 个并发子智能体 +- 禁止操作相同文件/目录 +- 禁止工作重叠 +- 超时自动终止 +- 轮次限制防止无限循环 + +## 待完成的工作 + +### 1. 后台任务轮询机制 ⏳ +需要在主智能体的工具执行后添加检查逻辑: +```python +# 在每次工具执行后 +completed_agents = sub_agent_manager.poll_updates() +if completed_agents: + # 插入 system 消息通知 + for agent in completed_agents: + system_message = build_completion_message(agent) + messages.append({"role": "system", "content": system_message}) + # 继续调用 API +``` + +### 2. 对话状态管理 ⏳ +需要在主智能体完成但子智能体未完成时: +- 设置对话状态标记 +- 前端显示警告(切换对话会终止子智能体) +- 轮询等待完成 +- 完成后发送 user 消息触发新一轮 + +### 3. 前端集成 ⏳ +- 显示子智能体运行状态 +- 显示进度和统计信息 +- 支持手动终止 +- 支持查看对话记录 + +### 4. 测试和调试 ⏳ +- 按照 SUB_AGENT_TESTING.md 进行测试 +- 修复发现的 bug +- 优化性能 + +### 5. 模型配置 ⏳ +需要将 easyagent 的 models.json 配置集成到主项目: +- 复制或链接 models.json +- 或者从主项目的配置生成 models.json + +## 文件清单 + +### 新增文件 +- `easyagent/` - easyagent 代码目录 +- `easyagent/src/batch/index.js` - 批处理入口 +- `SUB_AGENT_IMPLEMENTATION_PLAN.md` - 实现方案 +- `SUB_AGENT_TESTING.md` - 测试指南 +- `SUB_AGENT_COMPLETION_SUMMARY.md` - 本文件 + +### 修改文件 +- `core/main_terminal_parts/tools_definition.py` - 工具定义 +- `core/main_terminal_parts/tools_execution.py` - 工具执行 +- `modules/sub_agent_manager.py` - 子智能体管理器(已有新实现) + +## 下一步行动 + +1. **测试批处理模式** + ```bash + cd /tmp && mkdir test_subagent + # 按照 SUB_AGENT_TESTING.md 的步骤测试 + ``` + +2. **配置模型** + ```bash + cp ~/Desktop/easyagent/models.json /Users/jojo/Desktop/agents/正在修复中/agents/easyagent/ + ``` + +3. **测试主智能体集成** + - 启动 Web 服务器 + - 创建子智能体 + - 观察执行过程 + +4. **添加轮询机制** + - 在 WebTerminal 或主循环中添加 poll_updates 调用 + - 实现 system 消息插入 + - 实现 user 消息触发 + +5. **完善前端** + - 显示子智能体状态 + - 添加控制按钮 + - 优化用户体验 + +## 注意事项 + +1. **Node.js 依赖** + - 确保 Node.js 18+ 已安装 + - 运行 `npm install` 安装依赖 + +2. **路径问题** + - easyagent 批处理文件的路径是硬编码的 + - 如果项目位置改变,需要更新 SubAgentManager 中的路径 + +3. **模型配置** + - easyagent 需要 models.json 配置文件 + - 需要与主项目的模型配置保持一致 + +4. **权限问题** + - 确保交付目录有写权限 + - 确保 .subagent 目录可以创建 + +5. **调试** + - 查看 logs/debug_stream.log + - 查看子进程的 stdout/stderr + - 查看 output.json 和 stats.json + +## 总结 + +核心功能已经实现完成,包括: +- ✅ 批处理模式 +- ✅ 工具定义和执行 +- ✅ 子智能体管理 +- ✅ 对话记录保存 +- ✅ 统计信息收集 +- ✅ 兜底机制 + +还需要完成: +- ⏳ 后台任务轮询 +- ⏳ 对话状态管理 +- ⏳ 前端集成 +- ⏳ 测试和调试 + +整体架构清晰,代码结构合理,可以开始测试和完善了。 diff --git a/SUB_AGENT_FIXES_SUMMARY.md b/SUB_AGENT_FIXES_SUMMARY.md new file mode 100644 index 0000000..ca83d4d --- /dev/null +++ b/SUB_AGENT_FIXES_SUMMARY.md @@ -0,0 +1,117 @@ +# 子智能体问题修复总结 + +## 已修复的问题 + +### 1. 前端子智能体显示问题 ✅ +- **问题**:创建子智能体后不显示,只在完成后突然显示 +- **原因**:`get_overview` 方法中调用了旧的 `_call_service`(HTTP 模式),但新实现是子进程模式 +- **修复**:移除 `_call_service` 调用,改为直接检查进程状态 + +### 2. 暗色模式适配 ✅ +- **问题**:子智能体卡片没有暗色模式样式 +- **修复**:在 `static/src/styles/components/panels/_left-panel.scss` 中添加暗色模式样式 + +### 3. deliverables_dir 参数问题 ✅ +- **问题**:模型每次调用时都忘了提供这个参数 +- **修复**:将 `deliverables_dir` 改为必需参数(在 `required` 数组中) + +### 4. 后台运行时的 system 消息插入 ✅ +- **状态**:已经实现 +- **位置**:`server/chat_flow_task_support.py` 中的 `process_sub_agent_updates` 函数 +- **调用**:在 `server/chat_flow_tool_loop.py` 的 `execute_tool_calls` 中每次工具执行后调用 + +## 还需要实现的功能 + +### 空闲期间的 user 消息自动发送 ⏳ + +**需求**: +- 当主智能体完成任务进入空闲状态时 +- 如果还有子智能体在后台运行 +- 需要轮询等待子智能体完成 +- 完成后自动发送 user 消息触发新一轮对话 + +**实现位置**: +需要在主对话循环结束时添加检查逻辑,可能的位置: +1. `server/chat_flow_stream_loop.py` - 流式循环结束时 +2. `server/chat_flow_task_main.py` - 主任务完成时 + +**实现逻辑**: +```python +# 在对话循环结束时 +async def check_background_sub_agents(web_terminal, messages, sender): + manager = getattr(web_terminal, "sub_agent_manager", None) + if not manager: + return False + + # 检查是否有运行中的子智能体 + running_tasks = [ + task for task in manager.tasks.values() + if task.get("status") not in {"completed", "failed", "timeout", "terminated"} + and task.get("run_in_background") + ] + + if not running_tasks: + return False + + # 设置对话状态:有子智能体运行中 + # 前端显示警告:切换对话会终止子智能体 + + # 轮询等待完成 + while running_tasks: + await asyncio.sleep(5) + updates = manager.poll_updates() + + for update in updates: + # 发送 user 消息触发新一轮 + user_message = build_completion_user_message(update) + messages.append({ + "role": "user", + "content": user_message, + "metadata": {"sub_agent_completion": True} + }) + + # 触发新一轮 API 调用 + sender('user_message', {'content': user_message}) + return True # 表示需要继续对话 + + # 重新检查运行中的任务 + running_tasks = [ + task for task in manager.tasks.values() + if task.get("status") not in {"completed", "failed", "timeout", "terminated"} + and task.get("run_in_background") + ] + + return False +``` + +**user 消息格式**: +```python +def build_completion_user_message(update): + agent_id = update.get("agent_id") + summary = update.get("summary") + result_summary = update.get("result_summary") + deliverables_dir = update.get("deliverables_dir") + + return f"""子智能体{agent_id} ({summary}) 已完成任务。 + +{result_summary} + +交付目录:{deliverables_dir}""" +``` + +## 测试清单 + +- [x] 创建子智能体后立即在前端显示 +- [x] 暗色模式下子智能体卡片样式正确 +- [x] deliverables_dir 必须提供,否则报错 +- [x] 后台运行时工具执行后插入 system 消息 +- [ ] 空闲期间子智能体完成后自动发送 user 消息 +- [ ] 切换对话时提示会终止子智能体 +- [ ] 强制切换对话时终止所有子智能体 + +## 下一步 + +1. 实现空闲期间的 user 消息自动发送 +2. 添加对话状态管理(标记有子智能体运行中) +3. 前端添加警告提示(切换对话会终止子智能体) +4. 测试完整流程 diff --git a/SUB_AGENT_IMPLEMENTATION_PLAN.md b/SUB_AGENT_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..2e6ce32 --- /dev/null +++ b/SUB_AGENT_IMPLEMENTATION_PLAN.md @@ -0,0 +1,382 @@ +# 子智能体实现方案 + +## 概述 + +将 easyagent (Node.js) 集成为主智能体的子智能体系统,支持后台并行执行独立任务。 + +## 核心设计 + +### 1. 工具定义 + +#### create_sub_agent - 创建子智能体 + +**参数:** +- `agent_id` (int, 必需): 子智能体编号 1-99,同一对话中唯一 +- `summary` (str, 必需): 任务摘要 10-30 字 +- `task` (str, 必需): 详细任务描述,包括目标、要求、交付内容、工作范围 +- `deliverables_dir` (str, 可选): 交付目录相对路径,默认 `sub_agent_results/agent_{agent_id}` +- `run_in_background` (bool, 默认 false): 是否后台运行 +- `timeout_seconds` (int, 默认 600): 超时时间 60-3600 秒 + +**何时使用后台运行:** +- 任务耗时较长(预计超过 5 分钟) +- 可以继续处理其他工作,不需要立即使用结果 +- 多个独立任务可以并行执行 + +**何时使用阻塞运行(默认):** +- 任务较快(几分钟内完成) +- 后续工作依赖子智能体的结果 +- 需要立即查看和使用输出 + +**返回格式(阻塞模式):** +```json +{ + "success": true, + "agent_id": 1, + "status": "completed", + "summary": "生成API文档", + "message": "子智能体1 已完成任务", + "deliverables_dir": "docs/api", + "deliverables_files": ["api.md", "endpoints.json"], + "result_summary": "已生成 API 文档,包含 15 个端点的详细说明。", + "sub_conversation_id": "conv_abc123", + "stats": { + "runtime_seconds": 120, + "files_read": 8, + "searches": 3, + "web_pages": 0, + "commands": 2 + } +} +``` + +**返回格式(后台模式):** +```json +{ + "success": true, + "agent_id": 1, + "status": "running", + "summary": "生成API文档", + "message": "子智能体1 已启动,正在后台运行", + "deliverables_dir": "docs/api", + "sub_conversation_id": "conv_abc123", + "background": true +} +``` + +#### terminate_sub_agent - 终止子智能体 + +**参数:** +- `agent_id` (int, 必需): 要终止的子智能体编号 + +**返回格式:** +```json +{ + "success": true, + "agent_id": 1, + "status": "terminated", + "message": "子智能体1 已被终止", + "partial_results": true, + "deliverables_dir": "docs/api" +} +``` + +#### get_sub_agent_status - 查询子智能体状态 + +**参数:** +- `agent_ids` (list[int], 必需): 要查询的子智能体编号列表 + +**返回格式:** +```json +{ + "success": true, + "agents": [ + { + "agent_id": 1, + "summary": "生成API文档", + "status": "running", + "runtime_seconds": 45, + "files_read": 5, + "searches": 2, + "web_pages": 0, + "commands": 1, + "last_action": "正在读取 src/api/routes.py", + "deliverables_dir": "docs/api" + } + ] +} +``` + +#### finish_task - 完成任务(子智能体工具) + +**参数:** +- `success` (bool, 必需): 任务是否成功完成 +- `summary` (str, 必需): 完成摘要 50-200 字,说明完成了什么、生成了哪些文件、关键发现 + +**返回格式:** +```json +{ + "success": true, + "message": "任务已完成,子智能体即将退出", + "will_terminate": true +} +``` + +### 2. 执行流程 + +#### 阻塞模式(run_in_background=false) + +``` +主智能体调用 create_sub_agent + ↓ +启动 Node.js 子进程(easyagent 批处理模式) + ↓ +子进程读取任务文件和系统提示 + ↓ +子进程执行对话循环 + ↓ +子进程调用 finish_task 或超时 + ↓ +主智能体读取输出文件 + ↓ +返回结果给主智能体 +``` + +#### 后台模式(run_in_background=true) + +``` +主智能体调用 create_sub_agent + ↓ +启动 Node.js 子进程(easyagent 批处理模式) + ↓ +立即返回 "running" 状态 + ↓ +主智能体继续工作 + ↓ +每次工具执行后,检查子智能体是否完成 + ↓ +如果完成,插入 system 消息通知 + ↓ +继续调用 API + ↓ +如果主智能体完成但子智能体未完成,设置对话状态 + ↓ +轮询等待子智能体完成 + ↓ +完成后发送 user 消息触发新一轮 +``` + +### 3. 消息拼接 + +#### 初始 user 消息(发送给子智能体) + +``` +你是子智能体 {agent_id},负责以下任务: + +【任务摘要】 +{summary} + +【任务详情】 +{task} + +【交付目录】 +{deliverables_dir} + +【要求】 +1. 完成任务后,将所有结果文件放到交付目录 +2. 使用 finish_task 工具提交任务完成报告 +3. 任务超时时间:{timeout_seconds} 秒 + +现在开始执行任务。 +``` + +#### 完成通知(system 消息,工具执行后) + +``` +子智能体{agent_id} ({summary}) 已完成。 + +运行时间:{runtime_seconds}秒 +阅读了 {files_read} 个文件 +搜索了 {searches} 次 +查看了 {web_pages} 个网页 +运行了 {commands} 个指令 + +交付目录:{deliverables_dir} +结果摘要:{result_summary} +``` + +#### 完成通知(user 消息,主智能体空闲时) + +``` +子智能体{agent_id} ({summary}) 已完成任务。 + +{result_summary} + +交付目录:{deliverables_dir} +``` + +### 4. 兜底机制 + +如果子智能体输出结束但未调用 finish_task,自动发送 user 消息: + +``` +如果你已经完成了任务,请调用 finish_task 工具提交完成报告。如果还没有完成,请继续执行任务。 +``` + +### 5. 子智能体 System Prompt + +``` +你是一个专注的子智能体,负责独立完成分配的任务。 + +# 身份定位 + +你是主智能体创建的子智能体,拥有完整的工具能力(读写文件、执行命令、搜索网页等)。你的职责是专注完成分配的单一任务,不要偏离任务目标。 + +# 工作流程 + +1. **理解任务**:仔细阅读任务描述,明确目标和要求 +2. **制定计划**:规划完成任务的步骤 +3. **执行任务**:使用工具完成各个步骤 +4. **生成交付**:将所有结果文件放到指定的交付目录 +5. **提交报告**:使用 finish_task 工具提交完成报告并退出 + +# 工作原则 + +## 专注性 +- 只完成分配的任务,不要做额外的工作 +- 不要尝试与用户对话或询问问题 +- 遇到问题时,在能力范围内解决或在报告中说明 + +## 独立性 +- 你与主智能体共享工作区,可以访问所有文件 +- 你的工作范围应该与其他子智能体不重叠 +- 不要修改任务描述之外的文件 + +## 效率性 +- 直接开始工作,不要过度解释 +- 合理使用工具,避免重复操作 +- 注意超时限制,在时间内完成核心工作 + +## 完整性 +- 确保交付目录中的文件完整可用 +- 生成的文档要清晰、格式正确 +- 代码要包含必要的注释和说明 + +# 交付要求 + +所有结果文件必须放在指定的交付目录中,包括: +- 主要成果文件(文档、代码、报告等) +- 支持文件(数据、配置、示例等) +- 不要在交付目录外创建文件 + +# 完成任务 + +任务完成后,必须调用 finish_task 工具: +- success: 是否成功完成 +- summary: 完成摘要(说明做了什么、生成了什么) + +调用 finish_task 后,你会立即退出,无法继续工作。 + +# 工具使用 + +你拥有以下工具能力: +- read_file: 读取文件内容 +- write_file / edit_file: 创建或修改文件 +- search_workspace: 搜索文件和代码 +- run_command: 执行终端命令 +- web_search / extract_webpage: 搜索和提取网页内容 +- finish_task: 完成任务并退出(必须调用) + +# 注意事项 + +1. **不要无限循环**:如果任务无法完成,说明原因并提交报告 +2. **不要超出范围**:只操作任务描述中指定的文件/目录 +3. **不要等待输入**:你是自主运行的,不会收到用户的进一步指令 +4. **注意时间限制**:超时会被强制终止,优先完成核心工作 + +# 当前环境 + +- 工作区路径: {workspace} +- 系统: {system} +- 当前时间: {current_time} + +现在开始执行任务。 +``` + +### 6. 对话记录存储 + +子智能体的对话记录保存在:`{deliverables_dir}/.subagent/conversations.json` + +格式: +```json +{ + "agent_id": 1, + "summary": "生成API文档", + "created_at": "2026-03-10T12:00:00Z", + "completed_at": "2026-03-10T12:02:00Z", + "messages": [ + {"role": "system", "content": "..."}, + {"role": "user", "content": "..."}, + {"role": "assistant", "content": "..."}, + ... + ], + "stats": { + "runtime_seconds": 120, + "files_read": 8, + "searches": 3, + "web_pages": 0, + "commands": 2, + "token_usage": {"prompt": 1000, "completion": 500, "total": 1500} + } +} +``` + +### 7. 文件结构 + +``` +project/ +├── sub_agent_results/ +│ └── agent_1/ +│ ├── .subagent/ +│ │ └── conversations.json # 对话记录 +│ ├── api.md # 交付文件 +│ ├── endpoints.json +│ └── ... +├── docs/ +│ └── api/ +│ ├── .subagent/ +│ │ └── conversations.json +│ ├── api.md +│ └── ... +└── ... +``` + +### 8. 限制和约束 + +1. **最多 5 个并发子智能体** +2. **禁止多个子智能体操作相同文件或目录** +3. **禁止子智能体间的工作重叠** +4. **超时后强制终止,已生成的部分结果保留** +5. **切换对话会强制终止所有子智能体** + +### 9. 实现步骤 + +1. ✅ 复制 easyagent 代码到 `easyagent/` 目录 +2. ✅ 创建 `easyagent/src/batch/index.js` 批处理入口 +3. ⏳ 修改 `SubAgentManager` 使用新的批处理模式 +4. ⏳ 添加工具定义到 `core/tool_config.py` +5. ⏳ 修改主智能体的工具执行逻辑 +6. ⏳ 添加后台任务轮询机制 +7. ⏳ 添加对话状态管理(子智能体运行中) +8. ⏳ 测试和调试 + +## 关键改进点 + +1. **简化参数**:去掉 reference_files、delivery_mode、reason 等不必要参数 +2. **清晰的后台机制**:默认阻塞,明确说明何时用后台 +3. **详细的统计信息**:工具使用次数一目了然 +4. **智能通知**:system 消息(工具执行后)+ user 消息(主智能体空闲时) +5. **防止冲突**:工具描述中明确禁止重叠工作 +6. **兜底机制**:自动提醒调用 finish_task +7. **对话记录保留**:便于调试和审计 + diff --git a/SUB_AGENT_TESTING.md b/SUB_AGENT_TESTING.md new file mode 100644 index 0000000..dc35a16 --- /dev/null +++ b/SUB_AGENT_TESTING.md @@ -0,0 +1,202 @@ +# 子智能体集成测试 + +本文档说明如何测试新的子智能体系统。 + +## 前置条件 + +1. 确保 Node.js 已安装(18+) +2. 安装 easyagent 依赖: + ```bash + cd easyagent + npm install + ``` + +3. 确保 models.json 配置正确(复制 easyagent 根目录的 models.json 到 easyagent/ 目录) + +## 测试步骤 + +### 1. 测试批处理模式 + +```bash +# 创建测试目录 +mkdir -p /tmp/test_subagent +cd /tmp/test_subagent + +# 创建测试任务文件 +cat > task.txt << 'EOF' +你是子智能体 1,负责以下任务: + +**任务摘要**:创建测试文件 + +**任务详情**: +在当前目录创建一个名为 test.txt 的文件,内容为 "Hello from sub-agent"。 + +**交付目录**:/tmp/test_subagent/deliverables +请将所有生成的文件保存到此目录。 + +**超时时间**:60 秒 + +完成任务后,请调用 finish_task 工具提交完成报告。 +EOF + +# 创建系统提示文件 +cat > system.txt << 'EOF' +你是一个专注的子智能体,负责独立完成分配的任务。 +完成任务后,必须调用 finish_task 工具。 +EOF + +# 运行批处理 +node /Users/jojo/Desktop/agents/正在修复中/agents/easyagent/src/batch/index.js \ + --workspace /tmp/test_subagent \ + --task-file task.txt \ + --system-prompt-file system.txt \ + --output-file output.json \ + --stats-file stats.json \ + --agent-id 1 \ + --timeout 60 + +# 检查结果 +cat output.json +ls -la deliverables/ +``` + +### 2. 测试主智能体集成 + +启动 Web 服务器: +```bash +cd /Users/jojo/Desktop/agents/正在修复中/agents +python -m server.app +``` + +在浏览器中测试: + +1. 登录系统 +2. 创建新对话 +3. 发送消息: + ``` + 请创建一个子智能体,编号1,任务是在项目根目录创建一个 test.md 文件,内容为"测试子智能体功能"。 + ``` + +4. 观察子智能体执行过程 +5. 检查交付目录:`sub_agent_results/agent_1/` + +### 3. 测试后台运行 + +发送消息: +``` +请创建一个后台运行的子智能体,编号2,任务是搜索项目中所有的 Python 文件并统计行数,生成报告到 reports/line_count.md。设置为后台运行。 +``` + +然后继续发送其他消息,观察子智能体完成后的通知。 + +### 4. 测试多个并行子智能体 + +发送消息: +``` +请同时创建3个子智能体: +1. 编号1:分析 core/ 目录的代码结构 +2. 编号2:分析 modules/ 目录的代码结构 +3. 编号3:分析 utils/ 目录的代码结构 + +都设置为后台运行,交付目录分别为 reports/core、reports/modules、reports/utils。 +``` + +### 5. 测试查询状态 + +在子智能体运行时,发送消息: +``` +查询子智能体 1、2、3 的状态 +``` + +### 6. 测试终止子智能体 + +发送消息: +``` +终止子智能体 2 +``` + +## 预期结果 + +1. **批处理模式**: + - output.json 包含 success 和 summary + - deliverables/ 目录包含生成的文件 + - deliverables/.subagent/conversation.json 包含对话记录 + +2. **主智能体集成**: + - 阻塞模式:立即返回完成结果 + - 后台模式:立即返回 running 状态,完成后收到通知 + +3. **并行执行**: + - 多个子智能体同时运行 + - 各自独立完成任务 + - 不会相互干扰 + +4. **状态查询**: + - 返回每个子智能体的运行时间、工具使用统计 + - 显示当前状态(running/completed) + +5. **终止功能**: + - 子智能体进程被终止 + - 部分结果保留 + +## 常见问题 + +### 1. Node.js 找不到 + +确保 Node.js 已安装并在 PATH 中: +```bash +which node +node --version +``` + +### 2. 模型配置错误 + +检查 easyagent/models.json 是否存在且配置正确。 + +### 3. 权限问题 + +确保交付目录有写权限: +```bash +chmod -R 755 sub_agent_results/ +``` + +### 4. 子进程无法启动 + +检查 easyagent 批处理文件路径是否正确: +```bash +ls -la /Users/jojo/Desktop/agents/正在修复中/agents/easyagent/src/batch/index.js +``` + +### 5. 对话记录未保存 + +检查交付目录的 .subagent/ 子目录权限。 + +## 调试技巧 + +1. 查看子进程输出: + ```bash + tail -f logs/debug_stream.log + ``` + +2. 查看任务状态文件: + ```bash + cat data/sub_agent_state.json + ``` + +3. 查看子智能体输出: + ```bash + cat sub_agent_tasks/sub_1_*/output.json + ``` + +4. 查看统计信息: + ```bash + cat sub_agent_tasks/sub_1_*/stats.json + ``` + +## 下一步 + +测试通过后,可以: +1. 添加更多工具到子智能体 +2. 优化性能和错误处理 +3. 添加前端展示界面 +4. 完善文档和示例 diff --git a/core/main_terminal_parts/tools_definition.py b/core/main_terminal_parts/tools_definition.py index 6b60a38..ddb504a 100644 --- a/core/main_terminal_parts/tools_definition.py +++ b/core/main_terminal_parts/tools_definition.py @@ -682,39 +682,71 @@ class MainTerminalToolsDefinitionMixin: "type": "function", "function": { "name": "create_sub_agent", - "description": "创建新的子智能体任务。适合大规模信息搜集、网页提取与多文档总结等会占用大量上下文的工作,需要提供任务摘要、详细要求、交付目录以及参考文件。注意:同一时间最多运行5个子智能体。", + "description": "创建一个子智能体来处理独立任务。子智能体拥有完整的工具能力(读写文件、执行命令、搜索网页等),与主智能体共享工作区。\n\n适用场景:\n1. 可以独立完成的任务(生成文档、代码分析、测试执行)\n2. 需要大量工具调用的任务(批量文件处理、数据收集)\n3. 可以并行处理的子任务(多模块开发、多方面分析)\n\n何时使用后台运行:\n- 任务耗时较长(预计超过5分钟)\n- 你可以继续处理其他工作,不需要立即使用结果\n- 多个独立任务可以并行执行\n\n何时使用阻塞运行(默认):\n- 任务较快(几分钟内完成)\n- 后续工作依赖子智能体的结果\n- 需要立即查看和使用输出\n\n重要限制:\n- 最多同时运行 5 个子智能体\n- 禁止多个子智能体操作相同的文件或目录(会导致冲突)\n- 禁止子智能体间的工作有重叠(如同时修改同一模块、同时测试同一功能)\n- 每个子智能体应该有明确独立的职责范围", "parameters": { "type": "object", "properties": self._inject_intent({ - "agent_id": {"type": "integer", "description": "子智能体代号(1~5)"}, - "summary": {"type": "string", "description": "任务摘要,简要说明目标"}, - "task": {"type": "string", "description": "任务详细要求"}, - "target_dir": {"type": "string", "description": "项目下用于接收交付的相对目录"}, - "reference_files": { - "type": "array", - "description": "提供给子智能体的参考文件列表(相对路径),禁止在summary和task中直接告知子智能体引用图片的路径,必须使用本参数提供", - "items": {"type": "string"}, - "maxItems": 10 + "agent_id": { + "type": "integer", + "description": "子智能体编号(1-99),用于标识和管理。同一对话中每个编号只能使用一次。建议按顺序分配:1、2、3..." }, - "timeout_seconds": {"type": "integer", "description": "子智能体最大运行秒数:单/双次搜索建议180秒,多轮搜索整理建议300秒,深度调研或长篇分析可设600秒"} + "summary": { + "type": "string", + "description": "任务简短摘要(10-30字),用于显示和跟踪。例如:'生成API文档'、'分析性能瓶颈'、'编写单元测试'。" + }, + "task": { + "type": "string", + "description": "详细的任务描述,必须包括:\n1. 任务目标:要完成什么\n2. 具体要求:如何完成、注意事项\n3. 交付内容:在交付目录生成哪些文件\n4. 工作范围:明确指定操作的文件/目录范围,避免与其他子智能体冲突\n\n示例:'分析 src/api/ 目录下的所有 Python 文件,检查代码质量问题(复杂度、重复代码、潜在bug),生成分析报告 analysis.md 到交付目录。'" + }, + "deliverables_dir": { + "type": "string", + "description": "交付文件夹的相对路径(相对于项目根目录)。子智能体会将所有结果文件放在此目录。\n\n留空则使用默认路径:sub_agent_results/agent_{agent_id}\n\n示例:'docs/api'、'reports/performance'、'tests/generated'" + }, + "run_in_background": { + "type": "boolean", + "description": "是否后台运行。\n\ntrue(后台):立即返回,子智能体在后台执行,完成后会通知你。适合耗时任务或可以并行处理的任务。\n\nfalse(阻塞,默认):等待子智能体完成后返回结果。适合快速任务或后续工作依赖结果的情况。" + }, + "timeout_seconds": { + "type": "integer", + "description": "超时时间(秒),范围 60-3600。超时后子智能体会被强制终止,已生成的部分结果会保留。默认 600 秒(10分钟)。" + } }), - "required": ["agent_id", "summary", "task", "target_dir"] + "required": ["agent_id", "summary", "task", "deliverables_dir"] } } }, { "type": "function", "function": { - "name": "wait_sub_agent", - "description": "等待指定子智能体任务结束(或超时)。任务完成后会返回交付目录,并将结果复制到指定的项目文件夹。调用时 `timeout_seconds` 应不少于对应子智能体的 `timeout_seconds`,否则可能提前终止等待。", + "name": "terminate_sub_agent", + "description": "强制终止正在运行的子智能体。用于:\n1. 任务不再需要\n2. 子智能体陷入死循环或执行错误\n3. 用户要求停止\n\n终止后无法恢复,但已生成的部分结果会保留在交付目录。", "parameters": { "type": "object", "properties": self._inject_intent({ - "task_id": {"type": "string", "description": "子智能体任务ID"}, - "agent_id": {"type": "integer", "description": "子智能体代号(可选,用于缺省 task_id 的情况)"}, - "timeout_seconds": {"type": "integer", "description": "本次等待的超时时长(秒)"} + "agent_id": { + "type": "integer", + "description": "要终止的子智能体编号。" + } }), - "required": [] + "required": ["agent_id"] + } + } + }, + { + "type": "function", + "function": { + "name": "get_sub_agent_status", + "description": "查询一个或多个子智能体的当前状态和工作进度。用于检查后台运行的子智能体是否完成、当前在做什么、使用了哪些工具。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "agent_ids": { + "type": "array", + "items": {"type": "integer"}, + "description": "要查询的子智能体编号列表。必须指定至少一个编号。例如:[1] 或 [1, 2, 3]。" + } + }), + "required": ["agent_ids"] } } }, diff --git a/core/main_terminal_parts/tools_execution.py b/core/main_terminal_parts/tools_execution.py index 31d4c9f..59f3d65 100644 --- a/core/main_terminal_parts/tools_execution.py +++ b/core/main_terminal_parts/tools_execution.py @@ -625,31 +625,30 @@ class MainTerminalToolsExecutionMixin: agent_id=arguments.get("agent_id"), summary=arguments.get("summary", ""), task=arguments.get("task", ""), - target_dir=arguments.get("target_dir", ""), - reference_files=arguments.get("reference_files", []), + deliverables_dir=arguments.get("deliverables_dir", ""), + run_in_background=arguments.get("run_in_background", False), timeout_seconds=arguments.get("timeout_seconds"), conversation_id=self.context_manager.current_conversation_id ) - elif tool_name == "wait_sub_agent": - wait_timeout = arguments.get("timeout_seconds") - if not wait_timeout: - task_ref = self.sub_agent_manager.lookup_task( - task_id=arguments.get("task_id"), - agent_id=arguments.get("agent_id") + # 如果不是后台运行,阻塞等待完成 + if not arguments.get("run_in_background", False) and result.get("success"): + task_id = result.get("task_id") + wait_result = self.sub_agent_manager.wait_for_completion( + task_id=task_id, + timeout_seconds=arguments.get("timeout_seconds") ) - if task_ref: - wait_timeout = task_ref.get("timeout_seconds") - result = self.sub_agent_manager.wait_for_completion( - task_id=arguments.get("task_id"), - agent_id=arguments.get("agent_id"), - timeout_seconds=wait_timeout + # 合并结果 + result.update(wait_result) + + elif tool_name == "terminate_sub_agent": + result = self.sub_agent_manager.terminate_sub_agent( + agent_id=arguments.get("agent_id") ) - elif tool_name == "close_sub_agent": - result = self.sub_agent_manager.terminate_sub_agent( - task_id=arguments.get("task_id"), - agent_id=arguments.get("agent_id") + elif tool_name == "get_sub_agent_status": + result = self.sub_agent_manager.get_sub_agent_status( + agent_ids=arguments.get("agent_ids", []) ) elif tool_name == "trigger_easter_egg": diff --git a/easyagent/models.json b/easyagent/models.json new file mode 100644 index 0000000..1afd5c7 --- /dev/null +++ b/easyagent/models.json @@ -0,0 +1,33 @@ +{ + "tavily_api_key": "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", + "default_model": "kimi-k2.5", + "models": [ + { + "url": "https://api.moonshot.cn/v1", + "name": "kimi-k2.5", + "apikey": "sk-xW0xjfQM6Mp9ZCWMLlnHiRJcpEOIZPTkXcN0dQ15xpZSuw2y", + "modes": "快速,思考", + "multimodal": "图片,视频", + "max_output": 32000, + "max_context": 256000 + }, + { + "url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "name": "qwen3.5-plus", + "apikey": "sk-64af1343e67d46d7a902ef5bcf6817ad", + "modes": "快速,思考", + "multimodal": "图片", + "max_output": 32768, + "max_context": 256000 + }, + { + "url": "https://api.minimaxi.com/v1", + "name": "minimax-m2.5", + "apikey": "sk-api-iUGqWBvtdqbm74ch4wy__PGZE5xsWzYiHZsWS9naQCWILeRiH9SNWJmnFPbGYDEF37lNjO4GYJPYilB5Z82FmUyVXuKzwNUgk9BvJY5v-lMtRJy0CDrqWCw", + "modes": "思考", + "multimodal": "", + "max_output": 65536, + "max_context": 204800 + } + ] +} diff --git a/easyagent/package-lock.json b/easyagent/package-lock.json new file mode 100644 index 0000000..707e401 --- /dev/null +++ b/easyagent/package-lock.json @@ -0,0 +1,437 @@ +{ + "name": "easyagent", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "easyagent", + "version": "0.1.0", + "dependencies": { + "@inquirer/search": "^4.1.4", + "@inquirer/select": "^5.1.0", + "diff": "^5.2.0", + "fast-glob": "^3.3.2", + "mime-types": "^2.1.35" + }, + "bin": { + "eagent": "bin/eagent" + } + }, + "node_modules/@inquirer/ansi": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.3.tgz", + "integrity": "sha512-g44zhR3NIKVs0zUesa4iMzExmZpLUdTLRMCStqX3GE5NT6VkPcxQGJ+uC8tDgBUC/vB1rUhUd55cOf++4NZcmw==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + } + }, + "node_modules/@inquirer/core": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.5.tgz", + "integrity": "sha512-QQPAX+lka8GyLcZ7u7Nb1h6q72iZ/oy0blilC3IB2nSt1Qqxp7akt94Jqhi/DzARuN3Eo9QwJRvtl4tmVe4T5A==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.3", + "@inquirer/figures": "^2.0.3", + "@inquirer/type": "^4.0.3", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.3.tgz", + "integrity": "sha512-y09iGt3JKoOCBQ3w4YrSJdokcD8ciSlMIWsD+auPu+OZpfxLuyz+gICAQ6GCBOmJJt4KEQGHuZSVff2jiNOy7g==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + } + }, + "node_modules/@inquirer/search": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.1.4.tgz", + "integrity": "sha512-9yPTxq7LPmYjrGn3DRuaPuPbmC6u3fiWcsE9ggfLcdgO/ICHYgxq7mEy1yJ39brVvgXhtOtvDVjDh9slJxE4LQ==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.5", + "@inquirer/figures": "^2.0.3", + "@inquirer/type": "^4.0.3" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.1.0.tgz", + "integrity": "sha512-OyYbKnchS1u+zRe14LpYrN8S0wH1vD0p2yKISvSsJdH2TpI87fh4eZdWnpdbrGauCRWDph3NwxRmM4Pcm/hx1Q==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.3", + "@inquirer/core": "^11.1.5", + "@inquirer/figures": "^2.0.3", + "@inquirer/type": "^4.0.3" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.3.tgz", + "integrity": "sha512-cKZN7qcXOpj1h+1eTTcGDVLaBIHNMT1Rz9JqJP5MnEJ0JhgVWllx7H/tahUp5YEK1qaByH2Itb8wLG/iScD5kw==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/diff": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", + "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.0.tgz", + "integrity": "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + } + } +} diff --git a/easyagent/package.json b/easyagent/package.json new file mode 100644 index 0000000..df26ca4 --- /dev/null +++ b/easyagent/package.json @@ -0,0 +1,20 @@ +{ + "name": "easyagent", + "version": "0.1.0", + "private": true, + "description": "easyagent terminal AI", + "bin": { + "eagent": "bin/eagent" + }, + "type": "commonjs", + "scripts": { + "start": "node src/cli/index.js" + }, + "dependencies": { + "@inquirer/search": "^4.1.4", + "@inquirer/select": "^5.1.0", + "diff": "^5.2.0", + "fast-glob": "^3.3.2", + "mime-types": "^2.1.35" + } +} diff --git a/easyagent/prompts/system.txt b/easyagent/prompts/system.txt new file mode 100644 index 0000000..1b3afa6 --- /dev/null +++ b/easyagent/prompts/system.txt @@ -0,0 +1,11 @@ +你是 EasyAgent,一个极简终端智能体。 +目标:帮助用户完成开发任务,优先高信息密度输出。 +输出限制:禁止使用 Markdown(md)格式,内容无法渲染,必须使用纯文字格式输出。 + +- 当前时间:{current_time} +- 当前模型:{model_id} +- 工作区路径:{path} +- 系统信息:{system} +- 终端类型:{terminal} +- 权限:{allow_mode} +- Git:{git} diff --git a/easyagent/src/batch/index.js b/easyagent/src/batch/index.js new file mode 100644 index 0000000..a14a603 --- /dev/null +++ b/easyagent/src/batch/index.js @@ -0,0 +1,405 @@ +#!/usr/bin/env node +'use strict'; + +/** + * easyagent 批处理模式 + * 用于子智能体执行,不需要交互式 CLI + */ + +const fs = require('fs'); +const path = require('path'); +const { streamChat } = require('../model/client'); +const { executeTool } = require('../tools/dispatcher'); +const { getModelByKey } = require('../config'); +const { applyUsage, normalizeUsagePayload } = require('../utils/token_usage'); + +// 解析命令行参数 +function parseArgs() { + const args = process.argv.slice(2); + const config = { + workspace: process.cwd(), + taskFile: null, + systemPromptFile: null, + outputFile: null, + statsFile: null, + agentId: null, + modelKey: null, + timeout: 600, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--workspace' && i + 1 < args.length) { + config.workspace = args[++i]; + } else if (arg === '--task-file' && i + 1 < args.length) { + config.taskFile = args[++i]; + } else if (arg === '--system-prompt-file' && i + 1 < args.length) { + config.systemPromptFile = args[++i]; + } else if (arg === '--output-file' && i + 1 < args.length) { + config.outputFile = args[++i]; + } else if (arg === '--stats-file' && i + 1 < args.length) { + config.statsFile = args[++i]; + } else if (arg === '--agent-id' && i + 1 < args.length) { + config.agentId = args[++i]; + } else if (arg === '--model-key' && i + 1 < args.length) { + config.modelKey = args[++i]; + } else if (arg === '--timeout' && i + 1 < args.length) { + config.timeout = parseInt(args[++i], 10); + } + } + + return config; +} + +// 读取文件内容 +function readFile(filePath) { + try { + return fs.readFileSync(filePath, 'utf8'); + } catch (err) { + console.error(`读取文件失败: ${filePath}`, err); + process.exit(1); + } +} + +// 写入输出文件 +function writeOutput(filePath, data) { + try { + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8'); + } catch (err) { + console.error(`写入输出文件失败: ${filePath}`, err); + } +} + +// 更新统计文件 +function updateStats(statsFile, stats) { + try { + fs.writeFileSync(statsFile, JSON.stringify(stats, null, 2), 'utf8'); + } catch (err) { + console.error(`更新统计文件失败: ${statsFile}`, err); + } +} + +// 主函数 +async function main() { + const config = parseArgs(); + + if (!config.taskFile || !config.systemPromptFile || !config.outputFile) { + console.error('缺少必需参数: --task-file, --system-prompt-file, --output-file'); + process.exit(1); + } + + // 读取任务和系统提示 + const taskMessage = readFile(config.taskFile); + const systemPrompt = readFile(config.systemPromptFile); + + // 加载模型配置 + const modelConfig = require('../config'); + const ensuredConfig = modelConfig.ensureConfig(); + + if (!ensuredConfig.valid_models || ensuredConfig.valid_models.length === 0) { + writeOutput(config.outputFile, { + success: false, + summary: '未找到可用模型配置', + error: 'no_model_config', + }); + process.exit(1); + } + + // 使用指定模型或默认模型 + const modelKey = config.modelKey || ensuredConfig.default_model_key; + const model = getModelByKey(ensuredConfig, modelKey); + + if (!model) { + writeOutput(config.outputFile, { + success: false, + summary: `未找到模型: ${modelKey}`, + error: 'model_not_found', + }); + process.exit(1); + } + + // 加载工具定义 + const tools = JSON.parse(fs.readFileSync(path.join(__dirname, '../../doc/tools.json'), 'utf8')); + + // 添加 finish_task 工具 + tools.push({ + type: 'function', + function: { + name: 'finish_task', + description: '完成当前任务并退出。调用此工具表示你已经完成了分配的任务,所有交付文件已准备好。', + parameters: { + type: 'object', + properties: { + success: { + type: 'boolean', + description: '任务是否成功完成。true=成功完成所有要求;false=部分完成或遇到问题无法继续。', + }, + summary: { + type: 'string', + description: '任务完成摘要(50-200字),说明完成了什么工作、生成了哪些文件、关键发现或结果。如果失败,说明原因和已完成的部分。', + }, + }, + required: ['success', 'summary'], + }, + }, + }); + + // 初始化对话 + const messages = [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: taskMessage }, + ]; + + // 统计信息 + const stats = { + runtime_start: Date.now(), + runtime_seconds: 0, + files_read: 0, + searches: 0, + web_pages: 0, + commands: 0, + token_usage: { prompt: 0, completion: 0, total: 0 }, + }; + + const startTime = Date.now(); + const timeoutMs = config.timeout * 1000; + let turnCount = 0; + const maxTurns = 50; // 防止无限循环 + + try { + while (true) { + turnCount++; + + // 检查超时 + const elapsed = Date.now() - startTime; + if (elapsed > timeoutMs) { + writeOutput(config.outputFile, { + success: false, + summary: '任务超时未完成', + timeout: true, + stats: { + ...stats, + runtime_seconds: Math.floor(elapsed / 1000), + }, + }); + process.exit(1); + } + + // 检查轮次限制 + if (turnCount > maxTurns) { + writeOutput(config.outputFile, { + success: false, + summary: `任务执行超过 ${maxTurns} 轮,可能陷入循环`, + max_turns_exceeded: true, + stats: { + ...stats, + runtime_seconds: Math.floor(elapsed / 1000), + }, + }); + process.exit(1); + } + + // 更新统计文件 + if (config.statsFile) { + updateStats(config.statsFile, { + ...stats, + runtime_seconds: Math.floor(elapsed / 1000), + turn_count: turnCount, + }); + } + + // 调用 API + let assistantMessage = { role: 'assistant', content: '', tool_calls: [] }; + let currentToolCall = null; + let usage = null; + + try { + for await (const chunk of streamChat({ + config: ensuredConfig, + modelKey, + messages, + tools, + thinkingMode: false, + currentContextTokens: 0, + abortSignal: null, + })) { + const delta = chunk.choices?.[0]?.delta; + if (!delta) continue; + + // 处理内容 + if (delta.content) { + assistantMessage.content += delta.content; + } + + // 处理工具调用 + if (delta.tool_calls) { + for (const tc of delta.tool_calls) { + if (tc.index !== undefined) { + if (!assistantMessage.tool_calls[tc.index]) { + assistantMessage.tool_calls[tc.index] = { + id: tc.id || '', + type: 'function', + function: { name: '', arguments: '' }, + }; + currentToolCall = assistantMessage.tool_calls[tc.index]; + } else { + currentToolCall = assistantMessage.tool_calls[tc.index]; + } + + if (tc.id) currentToolCall.id = tc.id; + if (tc.function?.name) currentToolCall.function.name += tc.function.name; + if (tc.function?.arguments) currentToolCall.function.arguments += tc.function.arguments; + } + } + } + + // 处理 usage + if (chunk.usage) { + usage = normalizeUsagePayload(chunk.usage); + } + } + } catch (err) { + writeOutput(config.outputFile, { + success: false, + summary: `API 调用失败: ${err.message}`, + error: 'api_error', + stats: { + ...stats, + runtime_seconds: Math.floor((Date.now() - startTime) / 1000), + }, + }); + process.exit(1); + } + + // 更新 token 统计 + if (usage) { + applyUsage(stats.token_usage, usage); + } + + // 添加助手消息到历史 + messages.push(assistantMessage); + + // 如果没有工具调用,检查是否忘记调用 finish_task + if (!assistantMessage.tool_calls || assistantMessage.tool_calls.length === 0) { + // 兜底机制:提醒调用 finish_task + messages.push({ + role: 'user', + content: '如果你已经完成了任务,请调用 finish_task 工具提交完成报告。如果还没有完成,请继续执行任务。', + }); + continue; + } + + // 执行工具调用 + for (const toolCall of assistantMessage.tool_calls) { + const toolName = toolCall.function.name; + + // 检查是否是 finish_task + if (toolName === 'finish_task') { + let args = {}; + try { + args = JSON.parse(toolCall.function.arguments || '{}'); + } catch (err) { + writeOutput(config.outputFile, { + success: false, + summary: 'finish_task 参数解析失败', + error: 'invalid_finish_args', + stats: { + ...stats, + runtime_seconds: Math.floor((Date.now() - startTime) / 1000), + }, + }); + process.exit(1); + } + + // 保存对话记录到交付目录的 .subagent/ 子目录 + try { + // 从任务文件路径推断交付目录 + const taskFileDir = path.dirname(config.taskFile); + // 读取任务消息以获取交付目录路径 + const taskContent = fs.readFileSync(config.taskFile, 'utf8'); + const deliverablesDirMatch = taskContent.match(/\*\*交付目录\*\*:(.+)/); + if (deliverablesDirMatch) { + const deliverablesDirPath = deliverablesDirMatch[1].split('\n')[0].trim(); + const subagentDir = path.join(deliverablesDirPath, '.subagent'); + + // 确保目录存在 + if (!fs.existsSync(subagentDir)) { + fs.mkdirSync(subagentDir, { recursive: true }); + } + + // 保存对话记录 + const conversationData = { + agent_id: config.agentId, + created_at: new Date(startTime).toISOString(), + completed_at: new Date().toISOString(), + success: args.success, + summary: args.summary, + messages: messages, + stats: { + ...stats, + runtime_seconds: Math.floor((Date.now() - startTime) / 1000), + }, + }; + + const conversationFile = path.join(subagentDir, 'conversation.json'); + fs.writeFileSync(conversationFile, JSON.stringify(conversationData, null, 2), 'utf8'); + } + } catch (err) { + // 保存对话记录失败不影响任务完成 + console.error('保存对话记录失败:', err); + } + + // 写入输出并退出 + writeOutput(config.outputFile, { + success: args.success || false, + summary: args.summary || '任务完成', + stats: { + ...stats, + runtime_seconds: Math.floor((Date.now() - startTime) / 1000), + }, + }); + process.exit(0); + } + + // 执行其他工具 + const result = await executeTool({ + workspace: config.workspace, + config: ensuredConfig, + allowMode: 'full_access', + toolCall, + abortSignal: null, + }); + + // 更新统计 + if (toolName === 'read_file') stats.files_read++; + else if (toolName === 'search_workspace') stats.searches++; + else if (toolName === 'web_search' || toolName === 'extract_webpage') stats.web_pages++; + else if (toolName === 'run_command') stats.commands++; + + // 添加工具结果到历史 + messages.push({ + role: 'tool', + tool_call_id: toolCall.id, + content: result.formatted || '', + }); + } + } + } catch (err) { + writeOutput(config.outputFile, { + success: false, + summary: `执行出错: ${err.message}`, + error: 'execution_error', + stats: { + ...stats, + runtime_seconds: Math.floor((Date.now() - startTime) / 1000), + }, + }); + process.exit(1); + } +} + +// 运行 +main().catch((err) => { + console.error('批处理模式执行失败:', err); + process.exit(1); +}); diff --git a/easyagent/src/cli/commands.js b/easyagent/src/cli/commands.js new file mode 100644 index 0000000..7581225 --- /dev/null +++ b/easyagent/src/cli/commands.js @@ -0,0 +1,363 @@ +'use strict'; + +const { createConversation, loadConversation, listConversations, updateConversation } = require('../storage/conversation_store'); +const { formatRelativeTime } = require('../utils/time'); +const { maskKey, getModelByKey } = require('../config'); +const { runSelect } = require('../ui/select_prompt'); +const { runResumeMenu } = require('../ui/resume_menu'); +const { buildFinalLine, formatResultLines, printResultLines } = require('../ui/tool_display'); +const { renderBox } = require('../ui/banner'); +const { createIndentedWriter } = require('../ui/indented_writer'); +const { cyan, green } = require('../utils/colors'); +const { Spinner } = require('../ui/spinner'); +const { normalizeTokenUsage } = require('../utils/token_usage'); + +function printHelp() { + console.log('/new 创建新对话'); + console.log('/resume 加载旧对话'); + console.log('/allow 切换运行模式(只读/无限制)'); + console.log('/model 切换模型和思考模式'); + console.log('/status 查看当前对话状态'); + console.log('/compact 压缩当前对话'); + console.log('/config 查看当前配置'); + console.log('/exit 退出程序'); + console.log('/help 显示指令列表'); +} + +function printNotice(message) { + console.log(''); + console.log(message); + console.log(''); +} + +function applyModelState(state, config, modelKey, preferredThinking) { + const model = getModelByKey(config, modelKey); + if (!model || !model.valid) return false; + state.modelKey = model.key; + state.modelId = model.model_id || model.name || model.key; + if (model.modes === 'fast') state.thinkingMode = false; + else if (model.modes === 'thinking') state.thinkingMode = true; + else state.thinkingMode = !!preferredThinking; + return true; +} + +async function handleCommand(input, ctx) { + const { rl, state, config, workspace, statusBar } = ctx; + const persist = () => { + if (!state.conversation) return; + state.conversation = updateConversation(workspace, state.conversation, state.messages || [], { + model_key: state.modelKey, + model_id: state.modelId, + thinking_mode: state.thinkingMode, + allow_mode: state.allowMode, + token_usage: state.tokenUsage, + cwd: workspace, + }); + }; + const [cmd, ...rest] = input.split(/\s+/); + const arg = rest.join(' ').trim(); + + if (cmd === '/exit') { + rl.close(); + return { exit: true }; + } + + if (cmd === '/help') { + printHelp(); + return { exit: false }; + } + + if (cmd === '/new') { + state.tokenUsage = normalizeTokenUsage({ prompt: 0, completion: 0, total: 0 }); + const conv = createConversation(workspace, { + model_key: state.modelKey, + model_id: state.modelId, + thinking_mode: state.thinkingMode, + allow_mode: state.allowMode, + token_usage: state.tokenUsage, + cwd: workspace, + }); + state.conversation = conv; + state.messages = []; + printNotice(`已创建新对话: ${conv.id}`); + persist(); + if (statusBar) statusBar.render(); + return { exit: false }; + } + + if (cmd === '/resume') { + if (arg) { + const conv = loadConversation(workspace, arg); + if (!conv) { + printNotice('未找到对话'); + return { exit: false }; + } + state.conversation = conv; + state.messages = conv.messages || []; + const ok = applyModelState(state, config, conv.metadata?.model_key || state.modelKey, !!conv.metadata?.thinking_mode); + if (!ok) { + printNotice('对话中的模型不可用,已保留当前模型'); + } + state.allowMode = conv.metadata?.allow_mode || state.allowMode; + state.tokenUsage = normalizeTokenUsage(conv.metadata?.token_usage); + printNotice(`已加载对话: ${conv.id}`); + renderConversation(state.messages); + persist(); + return { exit: false }; + } + + const items = listConversations(workspace); + if (!items.length) { + printNotice('暂无对话记录'); + return { exit: false }; + } + + const filtered = items.filter((it) => it.id !== state.conversation?.id); + if (!filtered.length) { + printNotice('暂无可恢复的对话'); + return { exit: false }; + } + const displayItems = filtered.map((item) => { + const rel = formatRelativeTime(item.updated_at || item.created_at); + const title = item.title || '新对话'; + return { id: item.id, time: rel, title }; + }); + + const result = await runResumeMenu({ rl, items: displayItems }); + if (!result) return { exit: false }; + if (result.type === 'new') { + const convNew = createConversation(workspace, { + model_key: state.modelKey, + model_id: state.modelId, + thinking_mode: state.thinkingMode, + allow_mode: state.allowMode, + token_usage: state.tokenUsage, + cwd: workspace, + }); + state.conversation = convNew; + state.messages = []; + printNotice(`已创建新对话: ${convNew.id}`); + persist(); + return { exit: false }; + } + const conv = loadConversation(workspace, result.id); + if (!conv) { + printNotice('未找到对话'); + return { exit: false }; + } + state.conversation = conv; + state.messages = conv.messages || []; + const ok = applyModelState(state, config, conv.metadata?.model_key || state.modelKey, !!conv.metadata?.thinking_mode); + if (!ok) { + printNotice('对话中的模型不可用,已保留当前模型'); + } + state.allowMode = conv.metadata?.allow_mode || state.allowMode; + state.tokenUsage = normalizeTokenUsage(conv.metadata?.token_usage); + printNotice(`已加载对话: ${conv.id}`); + renderConversation(state.messages); + persist(); + return { exit: false }; + } + + if (cmd === '/allow') { + const choices = [ + { + name: `1. Read Only${state.allowMode === 'read_only' ? ' (current)' : ''} 只能读取文件与搜索,不能修改和运行指令`, + value: 'read_only', + }, + { + name: `2. Full Access${state.allowMode === 'full_access' ? ' (current)' : ''} 可修改/执行,允许工作区外文件与网络`, + value: 'full_access', + }, + ]; + const selected = await runSelect({ rl, message: '', choices, pageSize: 6 }); + if (selected) { + state.allowMode = selected; + printNotice(`运行模式已切换为: ${state.allowMode}`); + persist(); + } + return { exit: false }; + } + + if (cmd === '/model') { + const models = config.valid_models || []; + if (!models.length) { + printNotice('未找到可用模型,请先完善 models.json'); + return { exit: false }; + } + const modelChoices = models.map((model, idx) => ({ + name: `${idx + 1}. ${model.name}${state.modelKey === model.key ? ' (current)' : ''}`, + value: model.key, + })); + const modelKey = await runSelect({ rl, message: '', choices: modelChoices, pageSize: 6 }); + if (!modelKey) return { exit: false }; + const selected = getModelByKey(config, modelKey); + if (!selected || !selected.valid) { + printNotice('模型配置无效'); + return { exit: false }; + } + state.modelKey = selected.key; + state.modelId = selected.model_id || selected.name || selected.key; + printNotice(`模型已切换为: ${state.modelKey}`); + + if (selected.modes === 'fast') { + state.thinkingMode = false; + printNotice('思考模式: fast'); + persist(); + return { exit: false }; + } + if (selected.modes === 'thinking') { + state.thinkingMode = true; + printNotice('思考模式: thinking'); + persist(); + return { exit: false }; + } + + const thinkingChoices = [ + { name: `1. Fast${!state.thinkingMode ? ' (current)' : ''}`, value: 'fast' }, + { name: `2. Thinking${state.thinkingMode ? ' (current)' : ''}`, value: 'thinking' }, + ]; + const mode = await runSelect({ rl, message: '', choices: thinkingChoices, pageSize: 6 }); + if (mode) { + state.thinkingMode = mode === 'thinking'; + printNotice(`思考模式: ${mode}`); + persist(); + } + return { exit: false }; + } + + if (cmd === '/status') { + const usage = normalizeTokenUsage(state.tokenUsage); + const model = getModelByKey(config, state.modelKey); + const title = 'Status'; + const maxContext = model && model.max_context ? model.max_context : ''; + const maxOutput = model && model.max_output ? model.max_output : ''; + const lines = [ + `model: ${state.modelKey}`, + `thinking: ${state.thinkingMode ? 'thinking' : 'fast'}`, + `workspace: ${workspace}`, + `allow: ${state.allowMode}`, + `conversation: ${state.conversation?.id || 'none'}`, + `tokens(in): ${usage.prompt}`, + `tokens(out): ${usage.completion}`, + `tokens(total): ${usage.total}`, + `max_context: ${maxContext}`, + `max_output: ${maxOutput}`, + ]; + renderBox({ title, lines }); + return { exit: false }; + } + + if (cmd === '/config') { + const model = getModelByKey(config, state.modelKey); + console.log(''); + console.log(`config: ${config.path || ''}`); + if (model) { + console.log(`base_url: ${model.base_url || ''}`); + console.log(`modelname: ${model.model_id || model.name || ''}`); + console.log(`apikey: ${maskKey(model.api_key)}`); + console.log(`modes: ${model.modes || ''}`); + console.log(`multimodal: ${model.multimodal || ''}`); + console.log(`max_output: ${model.max_output || ''}`); + console.log(`max_context: ${model.max_context || ''}`); + } + console.log(`tavily_api_key: ${maskKey(config.tavily_api_key)}`); + console.log(''); + return { exit: false }; + } + + if (cmd === '/compact') { + if (!state.conversation) return { exit: false }; + const oldId = state.conversation.id; + const spinner = new Spinner(); + spinner.start(() => ''); + const messages = (state.messages || []).filter((msg) => msg.role === 'user' || msg.role === 'assistant'); + const cleaned = messages.map((msg) => { + if (msg.role === 'assistant') { + const copy = { ...msg }; + delete copy.tool_calls; + delete copy.tool_raw; + return copy; + } + return msg; + }); + const newConv = createConversation(workspace, { + model_key: state.modelKey, + model_id: state.modelId, + thinking_mode: state.thinkingMode, + allow_mode: state.allowMode, + token_usage: state.tokenUsage, + cwd: workspace, + }); + const updated = updateConversation(workspace, newConv, cleaned, { + model_key: state.modelKey, + model_id: state.modelId, + thinking_mode: state.thinkingMode, + allow_mode: state.allowMode, + token_usage: state.tokenUsage, + cwd: workspace, + }); + state.conversation = updated; + state.messages = cleaned; + spinner.stopSilent(); + printNotice(`压缩完成:${oldId} -> ${state.conversation.id}`); + persist(); + return { exit: false }; + } + + printNotice(`未知指令: ${cmd},使用 /help 查看指令列表。`); + return { exit: false }; +} + +function renderConversation(messages) { + if (!Array.isArray(messages) || messages.length === 0) return; + const toolCallMap = new Map(); + + for (const msg of messages) { + if (msg.role === 'user') { + console.log(''); + const w = createIndentedWriter(' '); + w.write(`${cyan('用户:')}${msg.content || ''}`); + console.log(''); + console.log(''); + continue; + } + + if (msg.role === 'assistant') { + if (Array.isArray(msg.tool_calls)) { + msg.tool_calls.forEach((tc) => { + if (tc && tc.id) toolCallMap.set(tc.id, tc); + }); + } + if (msg.content) { + console.log(''); + const w = createIndentedWriter(' '); + w.write(`${green('Eagent:')}`); + if (msg.content) w.write(msg.content); + console.log(''); + console.log(''); + } + continue; + } + + if (msg.role === 'tool') { + const toolCall = toolCallMap.get(msg.tool_call_id) || {}; + const name = toolCall.function?.name || 'tool'; + let args = {}; + try { + args = JSON.parse(toolCall.function?.arguments || '{}'); + } catch (_) { + args = {}; + } + const line = buildFinalLine(name, args); + // 静态回放:直接输出完成态 + process.stdout.write(`• ${line}\n`); + const raw = msg.tool_raw || { success: true }; + const resultLines = formatResultLines(name, args, raw); + printResultLines(resultLines); + continue; + } + } +} + +module.exports = { handleCommand }; diff --git a/easyagent/src/cli/index.js b/easyagent/src/cli/index.js new file mode 100644 index 0000000..7e4c22f --- /dev/null +++ b/easyagent/src/cli/index.js @@ -0,0 +1,851 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const readline = require('readline'); + +const { ensureConfig } = require('../config'); +const { createState } = require('../core/state'); +const { buildSystemPrompt } = require('../core/context'); +const { getModelByKey } = require('../config'); +const { streamChat } = require('../model/client'); +const { executeTool } = require('../tools/dispatcher'); +const { openCommandMenu, hasCommandMatch } = require('../ui/command_menu'); +const { handleCommand } = require('./commands'); +const { Spinner, truncateThinking } = require('../ui/spinner'); +const { renderBanner } = require('../ui/banner'); +const { buildStartLine, buildFinalLine, startToolDisplay, formatResultLines, printResultLines } = require('../ui/tool_display'); +const { createConversation, updateConversation } = require('../storage/conversation_store'); +const { applyUsage, normalizeTokenUsage, normalizeUsagePayload } = require('../utils/token_usage'); +const { gray, cyan, green, red, blue } = require('../utils/colors'); +const { createIndentedWriter } = require('../ui/indented_writer'); +const { createStatusBar } = require('../ui/status_bar'); +const { visibleWidth } = require('../utils/text_width'); +const { readMediafileTool } = require('../tools/read_mediafile'); + +const WORKSPACE = process.cwd(); +const WORKSPACE_NAME = path.basename(WORKSPACE); +const USERNAME = os.userInfo().username || 'user'; +const PROMPT = `${USERNAME}@${WORKSPACE_NAME} % `; +const MENU_PAGE_SIZE = 6; +const SLASH_COMMANDS = new Set(['/new', '/resume', '/allow', '/model', '/status', '/compact', '/config', '/help', '/exit']); +const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tiff', '.heic']); +const VIDEO_EXTS = new Set(['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v']); + +const config = ensureConfig(); +if (!config.valid_models || config.valid_models.length === 0) { + console.log(''); + console.log(`未找到可用模型,请先在 ${config.path} 填写完整模型信息。`); + console.log(''); + process.exit(1); +} +const state = createState(config, WORKSPACE); +state.conversation = createConversation(WORKSPACE, { + model_key: state.modelKey, + model_id: state.modelId, + thinking_mode: state.thinkingMode, + allow_mode: state.allowMode, + token_usage: state.tokenUsage, + cwd: WORKSPACE, +}); + +const systemPrompt = fs.readFileSync(path.join(__dirname, '../../prompts/system.txt'), 'utf8'); +const tools = JSON.parse(fs.readFileSync(path.join(__dirname, '../../doc/tools.json'), 'utf8')); + +renderBanner({ + modelKey: state.modelKey, + workspace: WORKSPACE, + conversationId: state.conversation?.id, +}); +console.log(''); +console.log(''); + +let rl = null; +let statusBar = null; +let isRunning = false; +let escPendingCancel = false; +let activeStreamController = null; +let activeToolController = null; +let currentMedia = { tokens: [], text: '' }; +let commandMenuActive = false; +let menuSearchTerm = ''; +let menuLastSearchTerm = ''; +let menuJustClosedAt = 0; +let menuInjectedCommand = null; +let menuAbortController = null; +let menuJustClosedInjected = false; +let suppressSlashMenuUntil = 0; +let pendingSlashTimer = null; + +function printNotice(message) { + console.log(''); + console.log(message); + console.log(''); +} + +function printNoticeInline(message) { + console.log(message); +} + +function getPathExt(p) { + const idx = p.lastIndexOf('.'); + if (idx === -1) return ''; + return p.slice(idx).toLowerCase(); +} + +function decodeEscapedPath(p) { + let text = String(p || ''); + if (text.startsWith('file://')) { + text = text.replace(/^file:\/\//, ''); + } + try { + text = decodeURIComponent(text); + } catch (_) {} + return text.replace(/\\ /g, ' ').replace(/\\\\/g, '\\'); +} + +function findMediaMatches(line) { + const matches = []; + const quoted = /'([^']+)'/g; + let m; + while ((m = quoted.exec(line)) !== null) { + matches.push({ raw: m[0], path: m[1], index: m.index }); + } + const extGroup = Array.from(new Set([...IMAGE_EXTS, ...VIDEO_EXTS])) + .map((e) => e.replace('.', '\\.')) + .join('|'); + const unquoted = new RegExp(`/((?:\\\\ |[^\\s])+?)\\.(${extGroup})`, 'g'); + while ((m = unquoted.exec(line)) !== null) { + const raw = `/${m[1]}.${m[2]}`; + matches.push({ raw, path: raw, index: m.index }); + } + matches.sort((a, b) => a.index - b.index); + return matches; +} + +function applyMediaTokens(line) { + if (!line) return { line: '', tokens: [] }; + const matches = findMediaMatches(line); + if (!matches.length) return { line, tokens: [] }; + + let images = 0; + let videos = 0; + let cursor = 0; + let out = ''; + const tokens = []; + + for (const match of matches) { + if (match.index < cursor) continue; + const before = line.slice(cursor, match.index); + out += before; + const decoded = decodeEscapedPath(match.path); + if (!fs.existsSync(decoded)) { + out += match.raw; + cursor = match.index + match.raw.length; + continue; + } + const ext = getPathExt(decoded); + const isImage = IMAGE_EXTS.has(ext); + const isVideo = VIDEO_EXTS.has(ext); + if (!isImage && !isVideo) { + out += match.raw; + cursor = match.index + match.raw.length; + continue; + } + if (isImage && images >= 9) { + out += match.raw; + cursor = match.index + match.raw.length; + continue; + } + if (isVideo && videos >= 1) { + out += match.raw; + cursor = match.index + match.raw.length; + continue; + } + const token = isImage ? `[图片 #${images + 1}]` : `[视频 #${videos + 1}]`; + tokens.push({ token, path: decoded, type: isImage ? 'image' : 'video' }); + out += token; + if (isImage) images += 1; + if (isVideo) videos += 1; + cursor = match.index + match.raw.length; + } + out += line.slice(cursor); + return { line: out, tokens }; +} + +function applySingleMediaPath(line, rawPath, tokens) { + if (!line || !rawPath) return { line, tokens }; + const idx = line.indexOf(rawPath); + if (idx === -1) return { line, tokens }; + const decoded = decodeEscapedPath(rawPath); + if (!fs.existsSync(decoded)) return { line, tokens }; + const ext = getPathExt(decoded); + const isImage = IMAGE_EXTS.has(ext); + const isVideo = VIDEO_EXTS.has(ext); + if (!isImage && !isVideo) return { line, tokens }; + const images = tokens.filter((t) => t.type === 'image').length; + const videos = tokens.filter((t) => t.type === 'video').length; + if (isImage && images >= 9) return { line, tokens }; + if (isVideo && videos >= 1) return { line, tokens }; + const token = isImage ? `[图片 #${images + 1}]` : `[视频 #${videos + 1}]`; + const nextLine = line.slice(0, idx) + token + line.slice(idx + rawPath.length); + const nextTokens = tokens.concat([{ token, path: decoded, type: isImage ? 'image' : 'video' }]); + return { line: nextLine, tokens: nextTokens }; +} + +function colorizeTokens(line) { + return line.replace(/\[(图片|视频) #\d+\]/g, (t) => blue(t)); +} + +function refreshInputLine() { + if (!rl || !process.stdout.isTTY || commandMenuActive) return; + const line = rl.line || ''; + const colorLine = colorizeTokens(line); + readline.clearLine(process.stdout, 0); + readline.cursorTo(process.stdout, 0); + process.stdout.write(PROMPT + colorLine); + const cursorCol = visibleWidth(PROMPT) + visibleWidth(line.slice(0, rl.cursor || 0)); + readline.cursorTo(process.stdout, cursorCol); +} + +function removeTokenAtCursor(line, cursor, allowStart = false) { + const tokenRe = /\[(图片|视频) #\d+\]/g; + let m; + while ((m = tokenRe.exec(line)) !== null) { + const start = m.index; + const end = m.index + m[0].length; + if ((allowStart ? cursor >= start : cursor > start) && cursor <= end) { + const nextLine = line.slice(0, start) + line.slice(end); + return { line: nextLine, cursor: start, removed: true }; + } + } + return { line, cursor, removed: false }; +} + +function isSlashCommand(line) { + if (!line || line[0] !== '/') return false; + const cmd = line.split(/\s+/)[0]; + return SLASH_COMMANDS.has(cmd); +} + +readline.emitKeypressEvents(process.stdin); +if (process.stdin.isTTY) process.stdin.setRawMode(true); + +process.stdin.on('data', (chunk) => { + if (isRunning || commandMenuActive || !rl) return; + const text = chunk ? chunk.toString() : ''; + if (!text) return; + const looksLikePath = text.includes('file://') || /\.(png|jpe?g|gif|webp|bmp|tiff|heic|mp4|mov|avi|mkv|webm|m4v)\b/i.test(text); + if (!looksLikePath) return; + suppressSlashMenuUntil = Date.now() + 200; + setImmediate(() => { + const line = rl.line || ''; + const raw = text.replace(/\r?\n/g, '').trim(); + const decoded = raw ? decodeEscapedPath(raw) : ''; + let applied = applyMediaTokens(line); + if (raw) { + if (line.includes(raw)) { + applied = applySingleMediaPath(applied.line, raw, applied.tokens); + } else if (decoded && line.includes(decoded)) { + applied = applySingleMediaPath(applied.line, decoded, applied.tokens); + } + } + if (applied.tokens.length) { + rl.line = applied.line; + rl.cursor = rl.line.length; + currentMedia = { tokens: applied.tokens, text: applied.line }; + refreshInputLine(); + } + }); +}); + +initReadline(); + +statusBar = createStatusBar({ + getTokens: () => normalizeTokenUsage(state.tokenUsage).total || 0, + getMaxTokens: () => { + const model = getModelByKey(config, state.modelKey); + return model && Number.isFinite(model.max_context) ? model.max_context : null; + }, +}); +statusBar.render(); + +process.stdin.on('keypress', (str, key) => { + if (commandMenuActive) { + if (key && key.name === 'backspace' && menuSearchTerm === '') { + if (menuAbortController && !menuAbortController.signal.aborted) { + menuAbortController.abort(); + } + } + if (key && key.name === 'return') { + const term = String(menuSearchTerm || '').replace(/^\/+/, '').trim(); + if (term && !hasCommandMatch(term)) { + menuInjectedCommand = `/${term}`; + if (menuAbortController && !menuAbortController.signal.aborted) { + menuAbortController.abort(); + } + } + } + return; + } + if (!isRunning && key && (key.name === 'backspace' || key.name === 'delete')) { + const line = rl.line || ''; + const cursor = rl.cursor || 0; + const updated = removeTokenAtCursor(line, cursor, key.name === 'delete'); + if (updated.removed) { + rl.line = updated.line; + rl.cursor = updated.cursor; + refreshInputLine(); + if (statusBar) statusBar.render(); + return; + } + if (statusBar) statusBar.render(); + } + if (key && key.name === 'escape' && isRunning) { + escPendingCancel = true; + if (activeStreamController && typeof activeStreamController.abort === 'function') { + activeStreamController.abort(); + } + if (activeToolController && typeof activeToolController.abort === 'function') { + activeToolController.abort(); + } + return; + } + if (str === '/' && Date.now() < suppressSlashMenuUntil) { + return; + } + if (pendingSlashTimer && (str !== '/' || (rl.line && rl.line !== '/'))) { + clearTimeout(pendingSlashTimer); + pendingSlashTimer = null; + } + if (!isRunning) { + const rawLine = rl.line || ''; + let applied = { line: rawLine, tokens: [] }; + if (!isSlashCommand(rawLine)) { + const hasToken = /\[(图片|视频) #\d+\]/.test(rawLine); + if (hasToken && currentMedia.tokens.length && currentMedia.text === rawLine) { + applied = { line: rawLine, tokens: currentMedia.tokens }; + } else { + applied = applyMediaTokens(rawLine); + } + if (hasToken && !applied.tokens.length && currentMedia.tokens.length) { + applied = { line: rawLine, tokens: currentMedia.tokens }; + } + } + if (applied.line !== (rl.line || '')) { + rl.line = applied.line; + rl.cursor = rl.line.length; + currentMedia = { tokens: applied.tokens, text: applied.line }; + } else if (currentMedia.text !== applied.line) { + currentMedia = { tokens: applied.tokens, text: applied.line }; + } + refreshInputLine(); + } + if (statusBar) statusBar.render(); + if (str === '/' && (rl.line === '' || rl.line === '/')) { + if (pendingSlashTimer) { + clearTimeout(pendingSlashTimer); + pendingSlashTimer = null; + } + pendingSlashTimer = setTimeout(() => { + pendingSlashTimer = null; + if (rl.line === '' || rl.line === '/') { + commandMenuActive = true; + if (rl) { + rl.pause(); + rl.line = ''; + rl.cursor = 0; + readline.clearLine(process.stdout, 0); + readline.cursorTo(process.stdout, 0); + } + menuSearchTerm = ''; + menuAbortController = new AbortController(); + void openCommandMenu({ + rl, + prompt: PROMPT, + pageSize: MENU_PAGE_SIZE, + colorEnabled: process.stdout.isTTY, + resetAnsi: '\x1b[0m', + onInput: (input) => { + menuSearchTerm = input || ''; + }, + abortSignal: menuAbortController.signal, + }) + .then((result) => { + if (result && result.chosen && !result.cancelled) { + menuInjectedCommand = result.chosen; + } + }) + .finally(() => { + commandMenuActive = false; + menuAbortController = null; + menuJustClosedAt = menuInjectedCommand ? Date.now() : 0; + menuJustClosedInjected = !!menuInjectedCommand; + menuLastSearchTerm = menuSearchTerm; + drainStdin(); + rl.line = ''; + rl.cursor = 0; + menuSearchTerm = ''; + if (process.stdout.isTTY) { + readline.clearScreenDown(process.stdout); + } + // Clear possible echoes from the base readline line (current + previous line). + if (process.stdout.isTTY) { + readline.clearLine(process.stdout, 0); + readline.cursorTo(process.stdout, 0); + readline.moveCursor(process.stdout, 0, -1); + readline.clearLine(process.stdout, 0); + readline.cursorTo(process.stdout, 0); + } else { + readline.clearLine(process.stdout, 0); + readline.cursorTo(process.stdout, 0); + } + promptWithStatus(true); + if (menuInjectedCommand) { + const injected = menuInjectedCommand; + menuInjectedCommand = null; + setImmediate(() => injectLine(injected)); + } + }); + } + }, 80); + return; + } +}); + +function initReadline() { + if (rl) { + try { + rl.removeAllListeners(); + } catch (_) {} + } + rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: true, + }); + + process.stdin.resume(); + rl.setPrompt(PROMPT); + promptWithStatus(); + + rl.on('line', async (line) => { + if (rl) { + rl.line = ''; + rl.cursor = 0; + } + if (commandMenuActive) { + return; + } + let applied = { line, tokens: [] }; + if (!isSlashCommand(line)) { + const hasToken = /\[(图片|视频) #\d+\]/.test(line); + if (hasToken && currentMedia.tokens.length && currentMedia.text === line) { + applied = { line, tokens: currentMedia.tokens }; + } else { + applied = applyMediaTokens(line); + } + if (hasToken && !applied.tokens.length && currentMedia.tokens.length) { + applied = { line, tokens: currentMedia.tokens }; + } + } + const normalizedLine = applied.line; + currentMedia = { tokens: applied.tokens, text: normalizedLine }; + const input = normalizedLine.trim(); + if (menuJustClosedAt) { + if (!menuJustClosedInjected) { + const tooOld = Date.now() - menuJustClosedAt > 800; + const normalizedMenu = String(menuLastSearchTerm).trim().replace(/^\/+/, ''); + const normalizedInput = input.replace(/^\/+/, ''); + if (!tooOld && (!normalizedMenu ? input === '' : normalizedInput === normalizedMenu)) { + menuJustClosedAt = 0; + menuLastSearchTerm = ''; + if (process.stdout.isTTY) { + readline.moveCursor(process.stdout, 0, -1); + readline.clearLine(process.stdout, 0); + readline.cursorTo(process.stdout, 0); + } + promptWithStatus(); + return; + } + } + menuJustClosedAt = 0; + menuLastSearchTerm = ''; + menuJustClosedInjected = false; + } + if (!input) { + promptWithStatus(); + return; + } + + if (input.startsWith('/') && isSlashCommand(input)) { + const result = await handleCommand(input, { rl, state, config, workspace: WORKSPACE, statusBar }); + if (result && result.exit) return; + promptWithStatus(); + return; + } + if (input.startsWith('/') && !isSlashCommand(input)) { + printNoticeInline(`无效的命令“${input}”`); + promptWithStatus(); + return; + } + + console.log(''); + const userWriter = createIndentedWriter(' '); + const displayLine = colorizeTokens(normalizedLine); + userWriter.writeLine(`${cyan('用户:')}${displayLine}`); + const content = buildUserContent(normalizedLine, currentMedia.tokens); + state.messages.push({ role: 'user', content }); + persistConversation(); + await runAssistantLoop(); + promptWithStatus(); + }); + + rl.on('close', () => { + if (statusBar) statusBar.destroy(); + process.stdout.write('\n'); + process.exit(0); + }); +} + +function drainStdin() { + if (!process.stdin.isTTY) return; + process.stdin.pause(); + while (process.stdin.read() !== null) {} + process.stdin.resume(); +} + +function injectLine(text) { + if (!rl) return; + rl.write(text); + rl.write('\n'); +} + +function persistConversation() { + if (!state.conversation) return; + state.conversation = updateConversation(WORKSPACE, state.conversation, state.messages, { + model_key: state.modelKey, + model_id: state.modelId, + thinking_mode: state.thinkingMode, + allow_mode: state.allowMode, + token_usage: state.tokenUsage, + cwd: WORKSPACE, + }); +} + +function promptWithStatus(force = false) { + if (!rl) return; + if (process.stdout.isTTY) { + const rows = process.stdout.rows || 24; + if (rows >= 3) { + readline.cursorTo(process.stdout, 0, rows - 3); + readline.clearLine(process.stdout, 0); + } + } + rl.prompt(force); + if (statusBar) statusBar.render(); +} + +function buildApiMessages() { + const system = buildSystemPrompt(systemPrompt, { + workspace: WORKSPACE, + allowMode: state.allowMode, + modelId: state.modelId || state.modelKey, + }); + const messages = [{ role: 'system', content: system }]; + for (const msg of state.messages) { + const m = { role: msg.role }; + if (msg.role === 'tool') { + m.tool_call_id = msg.tool_call_id; + m.content = msg.content; + } else if (msg.role === 'assistant' && Array.isArray(msg.tool_calls)) { + m.content = msg.content || null; + m.tool_calls = msg.tool_calls; + if (Object.prototype.hasOwnProperty.call(msg, 'reasoning_content')) { + m.reasoning_content = msg.reasoning_content; + } else if (state.thinkingMode) { + m.reasoning_content = ''; + } + } else { + m.content = msg.content; + if (msg.role === 'assistant' && Object.prototype.hasOwnProperty.call(msg, 'reasoning_content')) { + m.reasoning_content = msg.reasoning_content; + } + } + messages.push(m); + } + return messages; +} + +function buildUserContent(line, tokens) { + if (!tokens.length) return line; + const parts = [{ type: 'text', text: line }]; + for (const info of tokens) { + const media = readMediafileTool(WORKSPACE, { path: info.path }); + if (media && media.success) { + const url = `data:${media.mime};base64,${media.b64}`; + parts.push({ + type: media.type === 'image' ? 'image_url' : 'video_url', + [media.type === 'image' ? 'image_url' : 'video_url']: { url }, + }); + } + } + return parts; +} + +function printCancelLine() { + console.log(''); + process.stdout.write(` ${red('已取消本次响应')}\n\n`); +} + +function stopSpinnerForCancel(spinner, thinkingActive, showThinkingLabel, thinkingMode) { + if (!spinner) return; + if (thinkingMode && (thinkingActive || showThinkingLabel)) { + spinner.stop('∙ 停止思考'); + } else { + spinner.stopSilent(); + } +} + +async function runAssistantLoop() { + let continueLoop = true; + let firstLoop = true; + const hideCursor = () => process.stdout.write('\x1b[?25l'); + const showCursor = () => process.stdout.write('\x1b[?25h'); + isRunning = true; + escPendingCancel = false; + if (statusBar) statusBar.setMode('running'); + try { + while (continueLoop) { + hideCursor(); + const spinner = new Spinner(' ', firstLoop ? 1 : 0); + firstLoop = false; + let thinkingBuffer = ''; + let fullThinkingBuffer = ''; + let thinkingActive = false; + let showThinkingLabel = false; + let gotAnswer = false; + let assistantContent = ''; + let toolCalls = {}; + let usageTotal = null; + let usagePrompt = null; + let usageCompletion = null; + let firstContent = true; + let assistantWriter = null; + let cancelled = false; + + const thinkingDelay = setTimeout(() => { + if (state.thinkingMode) showThinkingLabel = true; + }, 400); + + spinner.start(() => { + if (!state.thinkingMode) return ''; + if (!showThinkingLabel) return ''; + return { label: ' 思考中...', thinking: thinkingBuffer, colorThinking: true }; + }); + + const messages = buildApiMessages(); + const streamController = new AbortController(); + activeStreamController = streamController; + try { + const currentContextTokens = normalizeTokenUsage(state.tokenUsage).total || 0; + for await (const chunk of streamChat({ + config, + modelKey: state.modelKey, + messages, + tools, + thinkingMode: state.thinkingMode, + currentContextTokens, + abortSignal: streamController.signal, + })) { + const choice = chunk.choices && chunk.choices[0]; + const usage = (chunk && chunk.usage) + || (choice && choice.usage) + || (choice && choice.delta && choice.delta.usage); + const normalizedUsage = normalizeUsagePayload(usage); + if (normalizedUsage) { + if (Number.isFinite(normalizedUsage.prompt_tokens)) usagePrompt = normalizedUsage.prompt_tokens; + if (Number.isFinite(normalizedUsage.completion_tokens)) usageCompletion = normalizedUsage.completion_tokens; + if (Number.isFinite(normalizedUsage.total_tokens)) usageTotal = normalizedUsage.total_tokens; + } + if (!choice) continue; + const delta = choice.delta || {}; + + if (delta.reasoning_content || delta.reasoning_details || choice.reasoning_details) { + thinkingActive = true; + showThinkingLabel = true; + let rc = ''; + if (delta.reasoning_content) { + rc = delta.reasoning_content; + } else if (delta.reasoning_details) { + if (Array.isArray(delta.reasoning_details)) { + rc = delta.reasoning_details.map((d) => d.text || '').join(''); + } else if (typeof delta.reasoning_details === 'string') { + rc = delta.reasoning_details; + } else if (delta.reasoning_details && typeof delta.reasoning_details.text === 'string') { + rc = delta.reasoning_details.text; + } + } else if (choice.reasoning_details) { + if (Array.isArray(choice.reasoning_details)) { + rc = choice.reasoning_details.map((d) => d.text || '').join(''); + } else if (typeof choice.reasoning_details === 'string') { + rc = choice.reasoning_details; + } else if (choice.reasoning_details && typeof choice.reasoning_details.text === 'string') { + rc = choice.reasoning_details.text; + } + } + fullThinkingBuffer += rc; + thinkingBuffer = truncateThinking(fullThinkingBuffer); + } + + if (delta.tool_calls) { + delta.tool_calls.forEach((tc) => { + const idx = tc.index; + if (!toolCalls[idx]) toolCalls[idx] = { id: tc.id, type: tc.type, function: { name: '', arguments: '' } }; + if (tc.function?.name) toolCalls[idx].function.name = tc.function.name; + if (tc.function?.arguments) toolCalls[idx].function.arguments += tc.function.arguments; + }); + } + + if (delta.content) { + if (!gotAnswer) { + clearTimeout(thinkingDelay); + if (state.thinkingMode) { + spinner.stop(thinkingActive ? '∙ 思考完成' : '∙'); + } else { + spinner.stopSilent(); + } + console.log(''); + assistantWriter = createIndentedWriter(' '); + assistantWriter.write(`${green('Eagent:')}`); + gotAnswer = true; + } + if (!gotAnswer) return; + if (!assistantWriter) assistantWriter = createIndentedWriter(' '); + assistantWriter.write(delta.content); + assistantContent += delta.content; + } + + if (choice.finish_reason) { + if (choice.finish_reason === 'tool_calls') { + continueLoop = true; + } + } + } + } catch (err) { + if (err && (err.code === 'aborted' || err.name === 'AbortError' || escPendingCancel)) { + cancelled = true; + } else { + clearTimeout(thinkingDelay); + if (state.thinkingMode) { + spinner.stop('∙'); + } else { + spinner.stopSilent(); + } + showCursor(); + console.log(`错误: ${err.message || err}`); + return; + } + } + activeStreamController = null; + + clearTimeout(thinkingDelay); + if (cancelled) { + if (gotAnswer) { + spinner.stopSilent(); + } else { + stopSpinnerForCancel(spinner, thinkingActive, showThinkingLabel, state.thinkingMode); + } + showCursor(); + printCancelLine(); + return; + } + if (!gotAnswer) { + if (state.thinkingMode) { + spinner.stop(thinkingActive ? '∙ 思考完成' : '∙'); + } else { + spinner.stopSilent(); + } + } else { + process.stdout.write('\n\n'); + } + showCursor(); + + if (usageTotal !== null || usagePrompt !== null || usageCompletion !== null) { + state.tokenUsage = applyUsage(normalizeTokenUsage(state.tokenUsage), { + prompt_tokens: usagePrompt, + completion_tokens: usageCompletion, + total_tokens: usageTotal, + }); + } + + const toolCallList = Object.keys(toolCalls) + .map((k) => Number(k)) + .sort((a, b) => a - b) + .map((k) => toolCalls[k]); + if (toolCallList.length) { + const assistantMsg = { + role: 'assistant', + content: assistantContent || null, + tool_calls: toolCallList, + }; + if (state.thinkingMode) assistantMsg.reasoning_content = fullThinkingBuffer || ''; + state.messages.push(assistantMsg); + persistConversation(); + + for (const call of toolCallList) { + let args = {}; + try { args = JSON.parse(call.function.arguments || '{}'); } catch (_) {} + const startLine = buildStartLine(call.function.name, args); + const finalLine = buildFinalLine(call.function.name, args); + const indicator = startToolDisplay(startLine); + const toolController = new AbortController(); + activeToolController = toolController; + const toolResult = await executeTool({ + workspace: WORKSPACE, + config, + allowMode: state.allowMode, + toolCall: call, + abortSignal: toolController.signal, + }); + activeToolController = null; + indicator.stop(finalLine); + const resultLines = formatResultLines(call.function.name, args, toolResult.raw || { success: toolResult.success, error: toolResult.error }); + printResultLines(resultLines); + + const toolMsg = { + role: 'tool', + tool_call_id: call.id, + content: toolResult.tool_content || toolResult.formatted, + tool_raw: toolResult.raw, + }; + state.messages.push(toolMsg); + persistConversation(); + if (escPendingCancel || (toolResult.raw && toolResult.raw.cancelled)) { + return; + } + } + continueLoop = true; + hideCursor(); + continue; + } + + if (assistantContent) { + const msg = { role: 'assistant', content: assistantContent }; + if (state.thinkingMode) msg.reasoning_content = fullThinkingBuffer || ''; + state.messages.push(msg); + persistConversation(); + } + continueLoop = false; + } + } finally { + showCursor(); + isRunning = false; + escPendingCancel = false; + activeStreamController = null; + activeToolController = null; + if (statusBar) statusBar.setMode('input'); + } +} diff --git a/easyagent/src/config.js b/easyagent/src/config.js new file mode 100644 index 0000000..78538b9 --- /dev/null +++ b/easyagent/src/config.js @@ -0,0 +1,133 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const DEFAULT_CONFIG_NAME = 'models.json'; +const DEFAULT_CONFIG_PATH = path.resolve(__dirname, '..', DEFAULT_CONFIG_NAME); + +function isNonEmptyString(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +function parsePositiveInt(value) { + if (value === null || value === undefined || value === '') return null; + const num = Number(value); + if (!Number.isFinite(num)) return null; + const out = Math.floor(num); + return out > 0 ? out : null; +} + +function normalizeModes(value) { + if (!value) return null; + const text = String(value).trim().toLowerCase(); + if (!text) return null; + const parts = text.split(/[,,\s]+/).filter(Boolean); + const hasFast = parts.some((p) => p.includes('fast') || p.includes('快速')); + const hasThinking = parts.some((p) => p.includes('thinking') || p.includes('思考')); + if (hasFast && hasThinking) return 'fast+thinking'; + if (hasThinking) return 'thinking'; + if (hasFast) return 'fast'; + return null; +} + +function normalizeMultimodal(value) { + if (value === null || value === undefined) return 'none'; + const text = String(value).trim().toLowerCase(); + if (!text) return 'none'; + if (text.includes('无') || text.includes('none') || text.includes('no')) return 'none'; + const parts = text.split(/[,,\s]+/).filter(Boolean); + const hasImage = parts.some((p) => p.includes('图片') || p.includes('image')); + const hasVideo = parts.some((p) => p.includes('视频') || p.includes('video')); + if (hasVideo && hasImage) return 'image+video'; + if (hasVideo) return 'image+video'; + if (hasImage) return 'image'; + return null; +} + +function normalizeModel(raw) { + const name = String(raw.name || raw.model_name || raw.model || '').trim(); + const url = String(raw.url || raw.base_url || '').trim(); + const apiKey = String(raw.apikey || raw.api_key || '').trim(); + const modes = normalizeModes(raw.modes || raw.mode || raw.supported_modes); + const multimodal = normalizeMultimodal(raw.multimodal || raw.multi_modal || raw.multi); + const maxOutput = parsePositiveInt(raw.max_output ?? raw.max_tokens ?? raw.max_output_tokens); + const maxContext = parsePositiveInt(raw.max_context ?? raw.context_window ?? raw.max_context_tokens); + const valid = Boolean( + isNonEmptyString(name) + && isNonEmptyString(url) + && isNonEmptyString(apiKey) + && modes + && multimodal + && maxOutput + && maxContext + ); + return { + key: name, + name, + model_id: name, + base_url: url, + api_key: apiKey, + modes, + multimodal, + max_output: maxOutput, + max_context: maxContext, + valid, + }; +} + +function buildConfig(raw, filePath) { + const modelsRaw = Array.isArray(raw.models) ? raw.models : []; + const models = modelsRaw.map((item) => normalizeModel(item || {})); + const modelMap = new Map(); + models.forEach((model) => { + if (model.key) modelMap.set(model.key, model); + }); + const validModels = models.filter((model) => model.valid); + const defaultModelKey = String(raw.default_model || raw.default_model_key || '').trim(); + const resolvedDefault = modelMap.get(defaultModelKey)?.valid + ? defaultModelKey + : (validModels[0] ? validModels[0].key : ''); + return { + path: filePath, + tavily_api_key: String(raw.tavily_api_key || '').trim(), + models, + valid_models: validModels, + model_map: modelMap, + default_model_key: resolvedDefault, + }; +} + +function ensureConfig() { + const file = DEFAULT_CONFIG_PATH; + if (!fs.existsSync(file)) { + const template = { + tavily_api_key: '', + default_model: '', + models: [], + }; + fs.writeFileSync(file, JSON.stringify(template, null, 2), 'utf8'); + } + const content = fs.readFileSync(file, 'utf8'); + const raw = JSON.parse(content); + return buildConfig(raw, file); +} + +function maskKey(key) { + if (!key) return ''; + if (key.length <= 8) return key; + return `${key.slice(0, 3)}...${key.slice(-3)}`; +} + +function getModelByKey(config, key) { + if (!config || !key) return null; + if (config.model_map && typeof config.model_map.get === 'function') { + return config.model_map.get(key) || null; + } + if (Array.isArray(config.models)) { + return config.models.find((m) => m && m.key === key) || null; + } + return null; +} + +module.exports = { ensureConfig, maskKey, getModelByKey }; diff --git a/easyagent/src/core/context.js b/easyagent/src/core/context.js new file mode 100644 index 0000000..f6fbf7f --- /dev/null +++ b/easyagent/src/core/context.js @@ -0,0 +1,60 @@ +'use strict'; + +const os = require('os'); +const path = require('path'); +const { execSync } = require('child_process'); + +function getTerminalType() { + if (process.platform === 'win32') { + if (process.env.PSModulePath || process.env.POWERSHELL_DISTRIBUTION_CHANNEL) return 'powershell'; + return 'cmd'; + } + return 'terminal'; +} + +function getGitInfo(workspace) { + try { + const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: workspace, stdio: ['ignore', 'pipe', 'ignore'] }) + .toString() + .trim(); + if (!branch) return '未初始化'; + const status = execSync('git status --porcelain', { cwd: workspace, stdio: ['ignore', 'pipe', 'ignore'] }) + .toString() + .trim(); + const dirty = status ? 'dirty' : 'clean'; + return `${branch} (${dirty})`; + } catch (_) { + return '未初始化'; + } +} + +function buildSystemPrompt(basePrompt, opts) { + const now = new Date(); + const tzOffset = now.getTimezoneOffset(); + const localMs = now.getTime() - tzOffset * 60 * 1000; + const localIso = new Date(localMs).toISOString().slice(0, 16); + const platform = os.platform(); + const systemName = platform === 'darwin' ? 'macos' : platform === 'win32' ? 'windows' : 'linux'; + let prompt = basePrompt; + const allowModeValue = opts.allowMode === 'read_only' + ? `${opts.allowMode}\n 已禁用 edit_file、run_command。若用户要求使用,请告知当前无权限需要用户输入 /allow 切换权限。` + : opts.allowMode; + const replacements = { + current_time: localIso, + path: opts.workspace, + workspace: opts.workspace, + system: `${systemName} (${os.release()})`, + terminal: getTerminalType(), + allow_mode: allowModeValue, + permissions: allowModeValue, + git: getGitInfo(opts.workspace), + model_id: opts.modelId || '', + }; + for (const [key, value] of Object.entries(replacements)) { + const token = `{${key}}`; + prompt = prompt.split(token).join(String(value)); + } + return prompt.trim(); +} + +module.exports = { buildSystemPrompt }; diff --git a/easyagent/src/core/state.js b/easyagent/src/core/state.js new file mode 100644 index 0000000..dea2990 --- /dev/null +++ b/easyagent/src/core/state.js @@ -0,0 +1,29 @@ +'use strict'; + +function resolveDefaultModel(config) { + const key = config.default_model_key || ''; + const model = config.model_map && typeof config.model_map.get === 'function' + ? config.model_map.get(key) + : null; + const modelKey = model && model.key ? model.key : key; + const modelId = model && (model.model_id || model.name) ? (model.model_id || model.name) : modelKey; + const supportsThinking = model ? (model.modes === 'thinking' || model.modes === 'fast+thinking') : false; + const thinkingMode = supportsThinking && model.modes !== 'fast'; + return { modelKey, modelId, thinkingMode }; +} + +function createState(config, workspace) { + const resolved = resolveDefaultModel(config); + return { + workspace, + allowMode: 'full_access', + modelKey: resolved.modelKey, + modelId: resolved.modelId, + thinkingMode: resolved.thinkingMode, + tokenUsage: { prompt: 0, completion: 0, total: 0 }, + conversation: null, + messages: [], + }; +} + +module.exports = { createState }; diff --git a/easyagent/src/model/client.js b/easyagent/src/model/client.js new file mode 100644 index 0000000..3cfc54e --- /dev/null +++ b/easyagent/src/model/client.js @@ -0,0 +1,102 @@ +'use strict'; + +const { getModelProfile } = require('./model_profiles'); + +function computeMaxTokens(profile, currentContextTokens) { + const baseMax = Number.isFinite(profile.max_output) ? Math.max(1, Math.floor(profile.max_output)) : null; + if (!baseMax) return null; + const maxContext = Number.isFinite(profile.max_context) ? Math.max(1, Math.floor(profile.max_context)) : null; + if (!maxContext) return baseMax; + const used = Number.isFinite(currentContextTokens) ? Math.max(0, Math.floor(currentContextTokens)) : 0; + const available = maxContext - used; + if (available <= 0) return 1; + return Math.min(baseMax, available); +} + +async function* streamChat({ config, modelKey, messages, tools, thinkingMode, currentContextTokens, abortSignal }) { + const profile = getModelProfile(config, modelKey); + const url = `${profile.base_url}/chat/completions`; + const headers = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${profile.api_key}`, + }; + + const payload = { + model: profile.model_id, + messages, + tools, + tool_choice: tools && tools.length ? 'auto' : undefined, + stream: true, + stream_options: { include_usage: true }, + }; + payload.include_usage = true; + payload.reasoning_split = true; + const maxTokens = computeMaxTokens(profile, currentContextTokens); + if (maxTokens) payload.max_tokens = maxTokens; + + const useThinking = thinkingMode && profile.supports_thinking; + if (useThinking) { + Object.assign(payload, profile.thinking_params.thinking); + } else { + Object.assign(payload, profile.thinking_params.fast); + } + + let res; + try { + res = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(payload), + signal: abortSignal, + }); + } catch (err) { + if (abortSignal && abortSignal.aborted) { + const error = new Error('请求已取消'); + error.code = 'aborted'; + throw error; + } + const cause = err && err.cause ? ` (${err.cause.code || err.cause.message || err.cause})` : ''; + throw new Error(`请求失败: ${err.message || err}${cause}`); + } + + if (!res.ok || !res.body) { + const text = await res.text(); + throw new Error(`API错误 ${res.status}: ${text}`); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder('utf-8'); + let buffer = ''; + + while (true) { + if (abortSignal && abortSignal.aborted) { + const error = new Error('请求已取消'); + error.code = 'aborted'; + throw error; + } + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let idx; + while ((idx = buffer.indexOf('\n\n')) !== -1) { + const chunk = buffer.slice(0, idx); + buffer = buffer.slice(idx + 2); + const lines = chunk.split(/\r?\n/); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith('data:')) continue; + const data = trimmed.slice(5).trim(); + if (!data) continue; + if (data === '[DONE]') return; + try { + const json = JSON.parse(data); + yield json; + } catch (_) { + continue; + } + } + } + } +} + +module.exports = { streamChat }; diff --git a/easyagent/src/model/model_profiles.js b/easyagent/src/model/model_profiles.js new file mode 100644 index 0000000..f37c452 --- /dev/null +++ b/easyagent/src/model/model_profiles.js @@ -0,0 +1,35 @@ +'use strict'; + +const { getModelByKey } = require('../config'); + +function buildProfile(model) { + const supportsThinking = model.modes === 'thinking' || model.modes === 'fast+thinking'; + return { + key: model.key, + name: model.name, + base_url: model.base_url, + api_key: model.api_key, + model_id: model.model_id || model.name, + modes: model.modes, + multimodal: model.multimodal, + max_output: Number.isFinite(model.max_output) ? model.max_output : null, + max_context: Number.isFinite(model.max_context) ? model.max_context : null, + supports_thinking: supportsThinking, + thinking_params: supportsThinking + ? { + fast: { thinking: { type: 'disabled' } }, + thinking: { thinking: { type: 'enabled' } }, + } + : { fast: {}, thinking: {} }, + }; +} + +function getModelProfile(config, modelKey) { + const model = getModelByKey(config, modelKey); + if (!model || !model.valid) { + throw new Error(`模型配置无效或不存在: ${modelKey || ''}`); + } + return buildProfile(model); +} + +module.exports = { getModelProfile }; diff --git a/easyagent/src/storage/conversation_store.js b/easyagent/src/storage/conversation_store.js new file mode 100644 index 0000000..c00ea4d --- /dev/null +++ b/easyagent/src/storage/conversation_store.js @@ -0,0 +1,151 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { toISO } = require('../utils/time'); +const { normalizeTokenUsage } = require('../utils/token_usage'); + +function getWorkspaceStore(workspace) { + const base = path.join(workspace, '.easyagent'); + const convDir = path.join(base, 'conversations'); + const indexFile = path.join(base, 'index.json'); + if (!fs.existsSync(convDir)) fs.mkdirSync(convDir, { recursive: true }); + if (!fs.existsSync(indexFile)) fs.writeFileSync(indexFile, JSON.stringify({}, null, 2), 'utf8'); + return { base, convDir, indexFile }; +} + +function newConversationId() { + return crypto.randomUUID(); +} + +function loadIndex(indexFile) { + try { + const raw = fs.readFileSync(indexFile, 'utf8'); + return raw ? JSON.parse(raw) : {}; + } catch (_) { + return {}; + } +} + +function saveIndex(indexFile, index) { + fs.writeFileSync(indexFile, JSON.stringify(index, null, 2), 'utf8'); +} + +function extractTitle(messages) { + for (const msg of messages) { + if (msg.role === 'user') { + let content = ''; + if (typeof msg.content === 'string') { + content = msg.content.trim(); + } else if (Array.isArray(msg.content)) { + content = msg.content + .filter((part) => part && part.type === 'text' && typeof part.text === 'string') + .map((part) => part.text) + .join(' ') + .trim(); + } + if (content) return content.length > 50 ? `${content.slice(0, 50)}...` : content; + } + } + return '新对话'; +} + +function countTools(messages) { + let count = 0; + for (const msg of messages) { + if (msg.role === 'assistant' && Array.isArray(msg.tool_calls)) count += msg.tool_calls.length; + if (msg.role === 'tool') count += 1; + } + return count; +} + +function saveConversation(workspace, conversation) { + const { convDir, indexFile } = getWorkspaceStore(workspace); + const filePath = path.join(convDir, `${conversation.id}.json`); + fs.writeFileSync(filePath, JSON.stringify(conversation, null, 2), 'utf8'); + + const index = loadIndex(indexFile); + index[conversation.id] = { + title: conversation.title || '新对话', + created_at: conversation.created_at, + updated_at: conversation.updated_at, + total_messages: (conversation.messages || []).length, + total_tools: countTools(conversation.messages || []), + thinking_mode: conversation.metadata?.thinking_mode || false, + run_mode: conversation.metadata?.thinking_mode ? 'thinking' : 'fast', + model_key: conversation.metadata?.model_key || '', + }; + saveIndex(indexFile, index); +} + +function createConversation(workspace, metadata = {}) { + const id = newConversationId(); + const now = toISO(); + const conversation = { + id, + title: '新对话', + created_at: now, + updated_at: now, + metadata: { + model_key: metadata.model_key || '', + model_id: metadata.model_id || '', + thinking_mode: !!metadata.thinking_mode, + allow_mode: metadata.allow_mode || 'full_access', + token_usage: normalizeTokenUsage(metadata.token_usage), + cwd: metadata.cwd || '', + }, + messages: [], + }; + saveConversation(workspace, conversation); + return conversation; +} + +function loadConversation(workspace, id) { + const { convDir } = getWorkspaceStore(workspace); + const filePath = path.join(convDir, `${id}.json`); + if (!fs.existsSync(filePath)) return null; + const raw = fs.readFileSync(filePath, 'utf8'); + if (!raw) return null; + return JSON.parse(raw); +} + +function listConversations(workspace) { + const { indexFile } = getWorkspaceStore(workspace); + const index = loadIndex(indexFile); + const entries = Object.entries(index).map(([id, meta]) => ({ id, ...meta })); + entries.sort((a, b) => { + const ta = new Date(a.updated_at || a.created_at || 0).getTime(); + const tb = new Date(b.updated_at || b.created_at || 0).getTime(); + return tb - ta; + }); + return entries; +} + +function updateConversation(workspace, conversation, messages, metadataUpdates = {}) { + const now = toISO(); + const updated = { + ...conversation, + messages, + updated_at: now, + }; + updated.title = extractTitle(messages); + updated.metadata = { + ...conversation.metadata, + ...metadataUpdates, + token_usage: metadataUpdates && Object.prototype.hasOwnProperty.call(metadataUpdates, 'token_usage') + ? normalizeTokenUsage(metadataUpdates.token_usage) + : normalizeTokenUsage(conversation.metadata?.token_usage), + }; + saveConversation(workspace, updated); + return updated; +} + +module.exports = { + getWorkspaceStore, + createConversation, + loadConversation, + listConversations, + updateConversation, + saveConversation, +}; diff --git a/easyagent/src/tools/dispatcher.js b/easyagent/src/tools/dispatcher.js new file mode 100644 index 0000000..44c86ae --- /dev/null +++ b/easyagent/src/tools/dispatcher.js @@ -0,0 +1,217 @@ +'use strict'; + +const { readFileTool } = require('./read_file'); +const { editFileTool } = require('./edit_file'); +const { runCommandTool } = require('./run_command'); +const { webSearchTool, extractWebpageTool } = require('./web_search'); +const { searchWorkspaceTool } = require('./search_workspace'); +const { readMediafileTool } = require('./read_mediafile'); +const path = require('path'); + +const MAX_RUN_COMMAND_CHARS = 10000; +const MAX_EXTRACT_WEBPAGE_CHARS = 80000; + +function formatFailure(err) { + return `失败: ${err}`; +} + +function formatReadFile(result) { + if (!result.success) return formatFailure(result.error || '读取失败'); + if (result.type === 'read') { + return result.content || ''; + } + if (result.type === 'search') { + const parts = []; + for (const match of result.matches || []) { + const hits = (match.hits || []).join(',') || ''; + parts.push(`[${match.id}] L${match.line_start}-${match.line_end} hits:${hits}`); + parts.push(match.snippet || ''); + parts.push(''); + } + return parts.join('\n').trim(); + } + if (result.type === 'extract') { + const parts = []; + for (const seg of result.segments || []) { + const label = seg.label || 'segment'; + parts.push(`[${label}] L${seg.line_start}-${seg.line_end}`); + parts.push(seg.content || ''); + parts.push(''); + } + return parts.join('\n').trim(); + } + return ''; +} + +function formatEditFile(result) { + if (!result.success) return formatFailure(result.error || '修改失败'); + const count = typeof result.replacements === 'number' ? result.replacements : 0; + return `已替换 ${count} 处: ${result.path}`; +} + +function formatWebSearch(result) { + if (!result.success) return formatFailure(result.error || '搜索失败'); + const time = result.searched_at || new Date().toISOString(); + const summary = result.answer || ''; + const lines = []; + lines.push(`🔍 搜索查询: ${result.query}`); + lines.push(`📅 搜索时间: ${time}`); + lines.push(''); + lines.push('📝 AI摘要:'); + lines.push(summary || ''); + lines.push(''); + lines.push('---'); + lines.push(''); + lines.push('📊 搜索结果:'); + const results = result.results || []; + results.forEach((item, idx) => { + lines.push(`${idx + 1}. ${item.title || ''}`); + lines.push(` 🔗 ${item.url || ''}`); + lines.push(` 📄 ${item.content || ''}`); + }); + return lines.join('\n').trim(); +} + +function formatExtractWebpage(result, mode, url, targetPath) { + if (!result.success) return formatFailure(result.error || '提取失败'); + if (mode === 'save') { + return `已保存: ${targetPath} (chars=${result.chars}, bytes=${result.bytes})`; + } + let content = result.content || ''; + if (content.length > MAX_EXTRACT_WEBPAGE_CHARS) content = content.slice(0, MAX_EXTRACT_WEBPAGE_CHARS); + return `URL: ${url}\n${content}`; +} + +function formatRunCommand(result) { + if (!result.success) { + if (result.status === 'timeout') { + const output = result.output ? result.output.slice(-MAX_RUN_COMMAND_CHARS) : ''; + return `[timeout after ${result.timeout}s]\n${output}`.trim(); + } + return `[error rc=${result.return_code ?? '1'}] ${result.error || ''}\n${result.output || ''}`.trim(); + } + let output = result.output || ''; + if (output.length > MAX_RUN_COMMAND_CHARS) output = output.slice(-MAX_RUN_COMMAND_CHARS); + return output || '[no_output]'; +} + +function formatSearchWorkspace(result) { + if (!result.success) return formatFailure(result.error || '搜索失败'); + if (result.mode === 'file') { + const lines = [`命中文件(${result.matches.length}):`]; + result.matches.forEach((p, idx) => lines.push(`${idx + 1}) ${p}`)); + return lines.join('\n'); + } + if (result.mode === 'content') { + const parts = []; + for (const item of result.results || []) { + parts.push(item.file); + for (const m of item.matches || []) { + parts.push(`- L${m.line}: ${m.snippet}`); + } + parts.push(''); + } + return parts.join('\n').trim(); + } + return ''; +} + +function formatReadMediafile(result) { + if (!result.success) return formatFailure(result.error || '读取失败'); + if (result.type === 'image') return `已附加图片: ${result.path}`; + return `已附加视频: ${result.path}`; +} + +function buildToolContent(result) { + if (result.type === 'image' || result.type === 'video') { + const payload = { + type: result.type === 'image' ? 'image_url' : 'video_url', + [result.type === 'image' ? 'image_url' : 'video_url']: { + url: `data:${result.mime};base64,${result.b64}`, + }, + }; + return [ + { type: 'text', text: formatReadMediafile(result) }, + payload, + ]; + } + return null; +} + +async function executeTool({ workspace, config, allowMode, toolCall, abortSignal }) { + const name = toolCall.function.name; + let args = {}; + try { + args = JSON.parse(toolCall.function.arguments || '{}'); + } catch (err) { + return { + success: false, + tool: name, + error: `JSON解析失败: ${err.message || err}`, + formatted: formatFailure('参数解析失败'), + }; + } + + if (allowMode === 'read_only' && (name === 'edit_file' || name === 'run_command')) { + const note = '当前为只读模式,已禁用 edit_file、run_command。若需使用,请告知当前无权限需要用户输入 /allow 切换权限。'; + return { + success: false, + tool: name, + error: note, + formatted: note, + }; + } + + if (abortSignal && abortSignal.aborted) { + return { + success: false, + tool: name, + error: '任务被用户取消', + formatted: '任务被用户取消', + raw: { success: false, error: '任务被用户取消', cancelled: true }, + }; + } + + let raw; + if (name === 'read_file') raw = readFileTool(workspace, args); + else if (name === 'edit_file') raw = editFileTool(workspace, args); + else if (name === 'run_command') raw = await runCommandTool(workspace, args, abortSignal); + else if (name === 'web_search') raw = await webSearchTool(config, args, abortSignal); + else if (name === 'extract_webpage') { + if (!args.mode) args.mode = 'read'; + const targetPath = args.target_path ? (path.isAbsolute(args.target_path) ? args.target_path : path.join(workspace, args.target_path)) : null; + if (args.mode === 'save') { + if (!targetPath || !targetPath.toLowerCase().endsWith('.md')) { + raw = { success: false, error: 'target_path 必须是 .md 文件' }; + } else { + raw = await extractWebpageTool(config, args, targetPath, abortSignal); + } + } else { + raw = await extractWebpageTool(config, args, targetPath, abortSignal); + } + } else if (name === 'search_workspace') raw = await searchWorkspaceTool(workspace, args); + else if (name === 'read_mediafile') raw = readMediafileTool(workspace, args); + else raw = { success: false, error: '未知工具' }; + + let formatted = ''; + if (name === 'read_file') formatted = formatReadFile(raw); + else if (name === 'edit_file') formatted = formatEditFile(raw); + else if (name === 'run_command') formatted = formatRunCommand(raw); + else if (name === 'web_search') formatted = formatWebSearch(raw); + else if (name === 'extract_webpage') formatted = formatExtractWebpage(raw, args.mode, args.url, args.target_path); + else if (name === 'search_workspace') formatted = formatSearchWorkspace(raw); + else if (name === 'read_mediafile') formatted = formatReadMediafile(raw); + else formatted = raw.success ? '' : formatFailure(raw.error || '失败'); + + const toolContent = buildToolContent(raw); + + return { + success: raw.success, + tool: name, + raw, + formatted, + tool_content: toolContent, + }; +} + +module.exports = { executeTool }; diff --git a/easyagent/src/tools/edit_file.js b/easyagent/src/tools/edit_file.js new file mode 100644 index 0000000..4a19244 --- /dev/null +++ b/easyagent/src/tools/edit_file.js @@ -0,0 +1,81 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { structuredPatch } = require('diff'); + +function resolvePath(workspace, p) { + if (path.isAbsolute(p)) return p; + return path.join(workspace, p); +} + +function diffSummary(oldText, newText) { + const patch = structuredPatch('old', 'new', oldText, newText, '', '', { context: 1 }); + let added = 0; + let removed = 0; + const hunks = patch.hunks.map((hunk) => { + let oldLine = hunk.oldStart; + let newLine = hunk.newStart; + const lines = hunk.lines.map((line) => { + const prefix = line[0]; + const content = line.slice(1); + let lineNum = null; + if (prefix === ' ') { + lineNum = oldLine; + oldLine += 1; + newLine += 1; + } else if (prefix === '-') { + lineNum = oldLine; + removed += 1; + oldLine += 1; + } else if (prefix === '+') { + lineNum = newLine; + added += 1; + newLine += 1; + } + return { prefix, lineNum, content }; + }); + return lines; + }); + return { added, removed, hunks }; +} + +function editFileTool(workspace, args) { + const target = resolvePath(workspace, args.file_path); + const oldString = args.old_string ?? ''; + const newString = args.new_string ?? ''; + try { + let creating = false; + if (!fs.existsSync(target)) { + creating = true; + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, '', 'utf8'); + } + const stat = fs.statSync(target); + if (!stat.isFile()) { + return { success: false, error: '目标不是文件' }; + } + const original = fs.readFileSync(target, 'utf8'); + if (!creating && 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; + if (creating && newString) replacements = 1; + fs.writeFileSync(target, updated, 'utf8'); + const diff = diffSummary(original, updated); + return { + success: true, + path: target, + replacements, + diff, + }; + } catch (err) { + return { success: false, error: err.message || String(err) }; + } +} + +module.exports = { editFileTool, resolvePath }; diff --git a/easyagent/src/tools/read_file.js b/easyagent/src/tools/read_file.js new file mode 100644 index 0000000..41df509 --- /dev/null +++ b/easyagent/src/tools/read_file.js @@ -0,0 +1,103 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +function resolvePath(workspace, p) { + if (path.isAbsolute(p)) return p; + return path.join(workspace, p); +} + +function readLines(content) { + return content.split(/\r?\n/); +} + +function applyMaxChars(text, maxChars) { + if (!maxChars || text.length <= maxChars) return { text, truncated: false }; + return { text: text.slice(0, maxChars), truncated: true }; +} + +function readFileTool(workspace, args) { + const target = resolvePath(workspace, args.path); + const type = args.type || 'read'; + try { + const raw = fs.readFileSync(target, 'utf8'); + if (type === 'read') { + const lines = readLines(raw); + const start = Math.max(1, args.start_line || 1); + const end = Math.min(lines.length, args.end_line || lines.length); + const slice = lines.slice(start - 1, end).join('\n'); + const capped = applyMaxChars(slice, args.max_chars); + return { + success: true, + type: 'read', + path: target, + line_start: start, + line_end: end, + content: capped.text, + truncated: capped.truncated, + }; + } + if (type === 'search') { + const lines = readLines(raw); + const query = args.query || ''; + const caseSensitive = !!args.case_sensitive; + const maxMatches = args.max_matches || 20; + const before = args.context_before || 0; + const after = args.context_after || 0; + const matches = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const hay = caseSensitive ? line : line.toLowerCase(); + const needle = caseSensitive ? query : query.toLowerCase(); + if (!needle) break; + if (hay.includes(needle)) { + const start = Math.max(0, i - before); + const end = Math.min(lines.length - 1, i + after); + const snippet = lines.slice(start, end + 1).join('\n'); + matches.push({ + id: `match_${matches.length + 1}`, + line_start: start + 1, + line_end: end + 1, + hits: [i + 1], + snippet, + }); + if (matches.length >= maxMatches) break; + } + } + return { + success: true, + type: 'search', + path: target, + query, + case_sensitive: caseSensitive, + matches, + }; + } + if (type === 'extract') { + const lines = readLines(raw); + const segments = Array.isArray(args.segments) ? args.segments : []; + const extracted = segments.map((seg, idx) => { + const start = Math.max(1, seg.start_line || 1); + const end = Math.min(lines.length, seg.end_line || lines.length); + return { + label: seg.label || `segment_${idx + 1}`, + line_start: start, + line_end: end, + content: lines.slice(start - 1, end).join('\n'), + }; + }); + return { + success: true, + type: 'extract', + path: target, + segments: extracted, + }; + } + return { success: false, error: '未知 read_file type' }; + } catch (err) { + return { success: false, error: err.message || String(err) }; + } +} + +module.exports = { readFileTool, resolvePath }; diff --git a/easyagent/src/tools/read_mediafile.js b/easyagent/src/tools/read_mediafile.js new file mode 100644 index 0000000..ed6c007 --- /dev/null +++ b/easyagent/src/tools/read_mediafile.js @@ -0,0 +1,31 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const mime = require('mime-types'); + +function resolvePath(workspace, p) { + if (path.isAbsolute(p)) return p; + return path.join(workspace, p); +} + +function readMediafileTool(workspace, args) { + const target = resolvePath(workspace, args.path); + try { + const stat = fs.statSync(target); + if (!stat.isFile()) return { success: false, error: '不是文件' }; + const mt = mime.lookup(target); + if (!mt) return { success: false, error: '无法识别文件类型' }; + if (!mt.startsWith('image/') && !mt.startsWith('video/')) { + return { success: false, error: '禁止的文件类型' }; + } + const data = fs.readFileSync(target); + const b64 = data.toString('base64'); + const type = mt.startsWith('image/') ? 'image' : 'video'; + return { success: true, path: target, mime: mt, type, b64 }; + } catch (err) { + return { success: false, error: err.message || String(err) }; + } +} + +module.exports = { readMediafileTool }; diff --git a/easyagent/src/tools/run_command.js b/easyagent/src/tools/run_command.js new file mode 100644 index 0000000..76d3ba1 --- /dev/null +++ b/easyagent/src/tools/run_command.js @@ -0,0 +1,53 @@ +'use strict'; + +const { exec } = require('child_process'); +const path = require('path'); + +function resolvePath(workspace, p) { + if (!p) return workspace; + if (path.isAbsolute(p)) return p; + return path.join(workspace, p); +} + +function runCommandTool(workspace, args, abortSignal) { + return new Promise((resolve) => { + const cmd = args.command; + const timeoutSec = Number(args.timeout || 0); + const cwd = resolvePath(workspace, args.working_dir || '.'); + if (!cmd) return resolve({ success: false, error: 'command 不能为空' }); + if (abortSignal && abortSignal.aborted) { + return resolve({ success: false, error: '任务被用户取消', cancelled: true }); + } + const timeoutMs = Math.max(0, timeoutSec) * 1000; + let finished = false; + const child = exec(cmd, { cwd, timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024, shell: true }, (err, stdout, stderr) => { + if (finished) return; + finished = true; + if (abortSignal) abortSignal.removeEventListener('abort', onAbort); + const output = [stdout, stderr].filter(Boolean).join(''); + if (err) { + const isTimeout = err.killed && err.signal === 'SIGTERM'; + return resolve({ + success: false, + status: isTimeout ? 'timeout' : 'error', + error: err.message, + return_code: err.code, + output, + timeout: timeoutSec, + }); + } + resolve({ success: true, status: 'ok', output }); + }); + const onAbort = () => { + if (finished) return; + finished = true; + try { + child.kill('SIGTERM'); + } catch (_) {} + return resolve({ success: false, error: '任务被用户取消', cancelled: true }); + }; + if (abortSignal) abortSignal.addEventListener('abort', onAbort, { once: true }); + }); +} + +module.exports = { runCommandTool }; diff --git a/easyagent/src/tools/search_workspace.js b/easyagent/src/tools/search_workspace.js new file mode 100644 index 0000000..bb24ae5 --- /dev/null +++ b/easyagent/src/tools/search_workspace.js @@ -0,0 +1,119 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const fg = require('fast-glob'); +const { execSync } = require('child_process'); + +function resolvePath(workspace, p) { + if (!p) return workspace; + if (path.isAbsolute(p)) return p; + return path.join(workspace, p); +} + +function hasRg() { + try { + execSync('rg --version', { stdio: 'ignore' }); + return true; + } catch (_) { + return false; + } +} + +async function searchWorkspaceTool(workspace, args) { + const root = resolvePath(workspace, args.root || '.'); + const mode = args.mode; + const query = args.query || ''; + const useRegex = !!args.use_regex; + const caseSensitive = !!args.case_sensitive; + const maxResults = args.max_results || 20; + const maxMatchesPerFile = args.max_matches_per_file || 3; + const includeGlob = Array.isArray(args.include_glob) && args.include_glob.length ? args.include_glob : ['**/*']; + const excludeGlob = Array.isArray(args.exclude_glob) ? args.exclude_glob : []; + const maxFileSize = args.max_file_size || null; + + if (mode === 'file') { + const files = await fg(includeGlob, { cwd: root, dot: true, ignore: excludeGlob, onlyFiles: true, absolute: true }); + const matcher = useRegex ? new RegExp(query, caseSensitive ? '' : 'i') : null; + const matches = []; + for (const file of files) { + const name = path.basename(file); + const hay = caseSensitive ? name : name.toLowerCase(); + const needle = caseSensitive ? query : query.toLowerCase(); + const ok = useRegex ? matcher.test(name) : hay.includes(needle); + if (ok) { + matches.push(file); + if (matches.length >= maxResults) break; + } + } + return { success: true, mode: 'file', root, query, matches }; + } + + if (mode === 'content') { + if (hasRg()) { + const argsList = ['--line-number', '--no-heading', '--color=never']; + if (!useRegex) argsList.push('--fixed-strings'); + if (!caseSensitive) argsList.push('-i'); + if (maxMatchesPerFile) argsList.push(`--max-count=${maxMatchesPerFile}`); + if (maxFileSize) argsList.push(`--max-filesize=${maxFileSize}`); + for (const g of includeGlob) argsList.push('-g', g); + for (const g of excludeGlob) argsList.push('-g', `!${g}`); + argsList.push(query); + argsList.push(root); + let output = ''; + try { + output = execSync(`rg ${argsList.map((a) => JSON.stringify(a)).join(' ')}`, { maxBuffer: 10 * 1024 * 1024 }).toString(); + } catch (err) { + output = err.stdout ? err.stdout.toString() : ''; + } + const lines = output.split(/\r?\n/).filter(Boolean); + const files = new Map(); + for (const line of lines) { + const [filePath, lineNum, ...rest] = line.split(':'); + const snippet = rest.join(':').trim(); + if (!files.has(filePath)) files.set(filePath, []); + if (files.get(filePath).length < maxMatchesPerFile) { + files.get(filePath).push({ line: Number(lineNum), snippet }); + } + if (files.size >= maxResults) break; + } + const results = Array.from(files.entries()).map(([file, matches]) => ({ file, matches })); + return { success: true, mode: 'content', root, query, results }; + } + + // fallback + const files = await fg(includeGlob, { cwd: root, dot: true, ignore: excludeGlob, onlyFiles: true, absolute: true }); + const matcher = useRegex ? new RegExp(query, caseSensitive ? '' : 'i') : null; + const results = []; + for (const file of files) { + if (maxFileSize) { + try { + const stat = fs.statSync(file); + if (stat.size > maxFileSize) continue; + } catch (_) {} + } + const content = fs.readFileSync(file, 'utf8'); + const lines = content.split(/\r?\n/); + const matches = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const hay = caseSensitive ? line : line.toLowerCase(); + const needle = caseSensitive ? query : query.toLowerCase(); + const ok = useRegex ? matcher.test(line) : hay.includes(needle); + if (ok) { + matches.push({ line: i + 1, snippet: line.trim() }); + if (matches.length >= maxMatchesPerFile) break; + } + } + if (matches.length) { + results.push({ file, matches }); + if (results.length >= maxResults) break; + } + } + return { success: true, mode: 'content', root, query, results }; + } + + return { success: false, error: '未知 search_workspace mode' }; +} + +module.exports = { searchWorkspaceTool }; diff --git a/easyagent/src/tools/web_search.js b/easyagent/src/tools/web_search.js new file mode 100644 index 0000000..6ececd8 --- /dev/null +++ b/easyagent/src/tools/web_search.js @@ -0,0 +1,74 @@ +'use strict'; + +async function webSearchTool(config, args, abortSignal) { + if (abortSignal && abortSignal.aborted) { + return { success: false, error: '任务被用户取消', cancelled: true }; + } + const body = { + api_key: config.tavily_api_key, + query: args.query, + }; + if (args.max_results) body.max_results = args.max_results; + if (args.topic) body.topic = args.topic; + if (args.time_range) body.time_range = args.time_range; + if (args.days) body.days = args.days; + if (args.start_date) body.start_date = args.start_date; + if (args.end_date) body.end_date = args.end_date; + if (args.country) body.country = args.country; + + try { + const res = await fetch('https://api.tavily.com/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: abortSignal, + }); + const json = await res.json(); + if (!res.ok) { + return { success: false, error: json.error || JSON.stringify(json) }; + } + return { success: true, ...json, query: args.query, searched_at: new Date().toISOString() }; + } catch (err) { + if (err && err.name === 'AbortError') { + return { success: false, error: '任务被用户取消', cancelled: true }; + } + return { success: false, error: err.message || String(err) }; + } +} + +async function extractWebpageTool(config, args, savePath, abortSignal) { + if (abortSignal && abortSignal.aborted) { + return { success: false, error: '任务被用户取消', cancelled: true }; + } + const body = { + api_key: config.tavily_api_key, + urls: [args.url], + include_images: false, + }; + try { + const res = await fetch('https://api.tavily.com/extract', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: abortSignal, + }); + const json = await res.json(); + if (!res.ok) { + return { success: false, error: json.error || JSON.stringify(json) }; + } + const content = (json.results && json.results[0] && json.results[0].content) || ''; + if (args.mode === 'save') { + const fs = require('fs'); + fs.writeFileSync(savePath, content, 'utf8'); + return { success: true, saved_path: savePath, content, chars: content.length, bytes: Buffer.byteLength(content) }; + } + return { success: true, content, url: args.url }; + } catch (err) { + if (err && err.name === 'AbortError') { + return { success: false, error: '任务被用户取消', cancelled: true }; + } + return { success: false, error: err.message || String(err) }; + } +} + +module.exports = { webSearchTool, extractWebpageTool }; diff --git a/easyagent/src/ui/banner.js b/easyagent/src/ui/banner.js new file mode 100644 index 0000000..ba6f2d7 --- /dev/null +++ b/easyagent/src/ui/banner.js @@ -0,0 +1,40 @@ +'use strict'; + +const { bold } = require('../utils/colors'); +const { visibleWidth, truncateVisible, padEndVisible } = require('../utils/text_width'); + +function renderBox({ title, lines }) { + const cols = Number(process.stdout.columns) || 80; + const safeTitle = title ? String(title) : ''; + const safeLines = Array.isArray(lines) ? lines.map((line) => String(line)) : []; + if (cols < 20) { + console.log(''); + if (safeTitle) console.log(safeTitle); + safeLines.forEach((line) => console.log(line)); + console.log(''); + return; + } + const contentWidth = Math.max(visibleWidth(safeTitle), ...safeLines.map(visibleWidth)); + const innerWidth = Math.max(10, Math.min(cols - 2, contentWidth + 2)); + const top = `+${'-'.repeat(innerWidth)}+`; + const maxLine = innerWidth - 2; + const renderLine = (text) => `| ${padEndVisible(truncateVisible(text, maxLine), maxLine)} |`; + console.log(''); + console.log(top); + if (safeTitle) console.log(renderLine(safeTitle)); + safeLines.forEach((line) => console.log(renderLine(line))); + console.log(top); + console.log(''); +} + +function renderBanner({ modelKey, workspace, conversationId }) { + const title = `>_ Welcome to ${bold('EasyAgent')}`; + const lines = [ + `model: ${modelKey || ''}`, + `path: ${workspace || ''}`, + `conversation: ${conversationId || 'none'}`, + ]; + renderBox({ title, lines }); +} + +module.exports = { renderBanner, renderBox }; diff --git a/easyagent/src/ui/command_menu.js b/easyagent/src/ui/command_menu.js new file mode 100644 index 0000000..4bc0fe3 --- /dev/null +++ b/easyagent/src/ui/command_menu.js @@ -0,0 +1,89 @@ +'use strict'; + +const readline = require('readline'); + +const COMMAND_CHOICES = [ + { value: '/new', desc: '创建新对话' }, + { value: '/resume', desc: '加载旧对话' }, + { value: '/allow', desc: '切换运行模式(只读/无限制)' }, + { value: '/model', desc: '切换模型和思考模式' }, + { value: '/status', desc: '查看当前对话状态' }, + { value: '/compact', desc: '压缩当前对话' }, + { value: '/config', desc: '查看当前配置' }, + { value: '/help', desc: '显示指令列表' }, + { value: '/exit', desc: '退出程序' }, +]; + +function hasCommandMatch(term) { + if (!term) return false; + const t = term.toLowerCase(); + return COMMAND_CHOICES.some( + (c) => c.value.toLowerCase().includes(t) || c.desc.toLowerCase().includes(t) + ); +} + +async function openCommandMenu(options) { + const { rl, prompt, pageSize, colorEnabled, resetAnsi, onInput, abortSignal } = options; + let latestInput = ''; + + rl.pause(); + rl.line = ''; + rl.cursor = 0; + readline.clearLine(process.stdout, 0); + readline.cursorTo(process.stdout, 0); + + try { + const { default: search } = await import('@inquirer/search'); + const chosen = await search({ + message: prompt.trimEnd(), + pageSize, + theme: { + helpMode: 'never', + prefix: '', + icon: { cursor: '>' }, + style: { + message: (text) => text, + searchTerm: (text) => `/${(text || '').replace(/^\/+/, '')}`, + highlight: (text) => (colorEnabled ? `\x1b[94m${text}${resetAnsi}` : text), + error: () => '无效的指令', + keysHelpTip: () => '', + description: (text) => text, + answer: () => '', + }, + }, + source: async (input) => { + latestInput = input || ''; + if (typeof onInput === 'function') onInput(latestInput); + const term = latestInput.replace(/^\/+/, '').toLowerCase(); + const maxCmdLen = Math.max(...COMMAND_CHOICES.map((c) => c.value.length)); + const indentLen = Math.max(0, prompt.length - 2); + const indent = ' '.repeat(indentLen); + const format = (c) => { + const pad = ' '.repeat(Math.max(1, maxCmdLen - c.value.length + 2)); + return `${indent}${c.value}${pad}${c.desc}`; + }; + const filtered = term + ? COMMAND_CHOICES.filter((c) => + c.value.toLowerCase().includes(term) || c.desc.toLowerCase().includes(term) + ) + : COMMAND_CHOICES; + return filtered.map((c) => ({ name: format(c), value: c.value })); + }, + }, abortSignal ? { signal: abortSignal, clearPromptOnDone: true } : { clearPromptOnDone: true }); + return { chosen, term: latestInput, cancelled: false }; + } catch (err) { + if (err && (err.name === 'AbortPromptError' || err.name === 'CancelPromptError')) { + return { chosen: null, term: latestInput, cancelled: true }; + } + console.log('指令菜单不可用,请先安装依赖: npm i @inquirer/search'); + return { chosen: null, term: '', cancelled: true }; + } finally { + if (process.stdin.isTTY) process.stdin.setRawMode(true); + process.stdin.resume(); + try { + rl.resume(); + } catch (_) {} + } +} + +module.exports = { openCommandMenu, hasCommandMatch }; diff --git a/easyagent/src/ui/indented_writer.js b/easyagent/src/ui/indented_writer.js new file mode 100644 index 0000000..6c36b18 --- /dev/null +++ b/easyagent/src/ui/indented_writer.js @@ -0,0 +1,40 @@ +'use strict'; + +function createIndentedWriter(indent = ' ') { + let col = 0; + function width() { + return process.stdout.columns || 80; + } + function write(text) { + const str = String(text ?? ''); + for (const ch of str) { + if (col === 0) { + process.stdout.write(indent); + col = indent.length; + } + if (ch === '\r') continue; + if (ch === '\n') { + process.stdout.write('\n'); + col = 0; + continue; + } + process.stdout.write(ch); + col += 1; + if (col >= width()) { + process.stdout.write('\n'); + col = 0; + } + } + } + function writeLine(text = '') { + write(text); + process.stdout.write('\n'); + col = 0; + } + function reset() { + col = 0; + } + return { write, writeLine, reset }; +} + +module.exports = { createIndentedWriter }; diff --git a/easyagent/src/ui/resume_menu.js b/easyagent/src/ui/resume_menu.js new file mode 100644 index 0000000..eaa69e3 --- /dev/null +++ b/easyagent/src/ui/resume_menu.js @@ -0,0 +1,173 @@ +'use strict'; + +const readline = require('readline'); +const { blue, gray } = require('../utils/colors'); + +function isFullwidthCodePoint(code) { + return ( + code >= 0x1100 && + (code <= 0x115f || + code === 0x2329 || + code === 0x232a || + (code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f) || + (code >= 0xac00 && code <= 0xd7a3) || + (code >= 0xf900 && code <= 0xfaff) || + (code >= 0xfe10 && code <= 0xfe19) || + (code >= 0xfe30 && code <= 0xfe6f) || + (code >= 0xff00 && code <= 0xff60) || + (code >= 0xffe0 && code <= 0xffe6) || + (code >= 0x1f300 && code <= 0x1f64f) || + (code >= 0x1f900 && code <= 0x1f9ff)) + ); +} + +function stringWidth(text) { + if (!text) return 0; + let width = 0; + for (const char of text) { + const code = char.codePointAt(0); + if (code == null) continue; + width += isFullwidthCodePoint(code) ? 2 : 1; + } + return width; +} + +function padEndDisplay(text, targetWidth) { + const current = stringWidth(text); + if (current >= targetWidth) return text; + return text + ' '.repeat(targetWidth - current); +} + +function truncateDisplay(text, width) { + if (!text || width <= 0) return ''; + let out = ''; + let w = 0; + for (const ch of text) { + const c = ch.codePointAt(0); + const cw = c != null && isFullwidthCodePoint(c) ? 2 : 1; + if (w + cw > width) break; + out += ch; + w += cw; + } + return out; +} + +function fitLine(text, width) { + if (!text) return ''; + if (stringWidth(text) <= width) return text; + return truncateDisplay(text, Math.max(0, width - 1)); +} + +async function runResumeMenu({ rl, items }) { + rl.pause(); + rl.line = ''; + rl.cursor = 0; + readline.clearLine(process.stdout, 0); + readline.cursorTo(process.stdout, 0); + // Keep stdin flowing for keypress events while readline is paused. + readline.emitKeypressEvents(process.stdin); + if (process.stdin.isTTY) process.stdin.setRawMode(true); + process.stdin.resume(); + + let selected = 0; + let start = 0; + + function render() { + const rows = process.stdout.rows || 24; + const cols = process.stdout.columns || 80; + const leftPad = ' '; + const gap = ' '; + const timeWidth = Math.min( + 16, + Math.max(8, ...items.map((it) => (it.time ? stringWidth(it.time) : 0))) + ); + const headerLines = [ + `${leftPad}Resume a previous session`, + '', + `${leftPad} ${padEndDisplay('Updated', timeWidth)}${gap}Conversation`, + ]; + const footer = 'enter to resume esc to start new ctrl + c to quit ↑/↓ to browse'; + const headerCount = headerLines.length; + const footerCount = 1; + const listHeight = Math.max(1, rows - headerCount - footerCount); + + if (selected < start) start = selected; + if (selected >= start + listHeight) start = selected - listHeight + 1; + + const lines = []; + headerLines.forEach((line) => lines.push(fitLine(line, cols))); + + for (let i = 0; i < listHeight; i++) { + const idx = start + i; + if (idx < items.length) { + const prefix = idx === selected ? '>' : ' '; + const time = items[idx].time || ''; + const title = items[idx].title || ''; + const line = `${leftPad}${prefix}${padEndDisplay(time, timeWidth)}${gap}${title}`; + lines.push(idx === selected ? blue(fitLine(line, cols)) : fitLine(line, cols)); + } else { + lines.push(''); + } + } + + lines.push(gray(fitLine(footer, cols))); + + process.stdout.write('\x1b[2J\x1b[H'); + process.stdout.write(lines.join('\n')); + } + + render(); + + return new Promise((resolve) => { + let resolved = false; + const cleanup = () => { + process.stdin.off('keypress', onKey); + if (process.stdin.isTTY) process.stdin.setRawMode(true); + process.stdin.resume(); + try { + rl.resume(); + } catch (_) {} + process.stdout.write('\x1b[2J\x1b[H'); + }; + + const finish = (val) => { + if (resolved) return; + resolved = true; + cleanup(); + resolve(val); + }; + + const onKey = (str, key) => { + if (key && key.ctrl && key.name === 'c') { + process.stdout.write('\n'); + process.exit(0); + } + if (key && key.name === 'up') { + if (items.length) { + selected = Math.max(0, selected - 1); + render(); + } + return; + } + if (key && key.name === 'down') { + if (items.length) { + selected = Math.min(items.length - 1, selected + 1); + render(); + } + return; + } + if (key && key.name === 'return') { + const chosen = items[selected]; + finish({ type: 'resume', id: chosen ? chosen.id : null }); + return; + } + if (key && key.name === 'escape') { + finish({ type: 'new' }); + return; + } + }; + process.stdin.on('keypress', onKey); + }); +} + +module.exports = { runResumeMenu }; diff --git a/easyagent/src/ui/select_prompt.js b/easyagent/src/ui/select_prompt.js new file mode 100644 index 0000000..a78ef8a --- /dev/null +++ b/easyagent/src/ui/select_prompt.js @@ -0,0 +1,39 @@ +'use strict'; + +const readline = require('readline'); + +async function runSelect({ rl, message, choices, pageSize = 6 }) { + rl.pause(); + rl.line = ''; + rl.cursor = 0; + readline.clearLine(process.stdout, 0); + readline.cursorTo(process.stdout, 0); + + try { + const { default: select } = await import('@inquirer/select'); + const value = await select({ + message: message || '', + choices, + pageSize, + theme: { + prefix: '', + icon: { cursor: '›' }, + style: { + message: (t) => t, + keysHelpTip: () => '', + }, + }, + }, { clearPromptOnDone: true }); + readline.clearLine(process.stdout, 0); + readline.cursorTo(process.stdout, 0); + return value; + } finally { + if (process.stdin.isTTY) process.stdin.setRawMode(true); + process.stdin.resume(); + try { + rl.resume(); + } catch (_) {} + } +} + +module.exports = { runSelect }; diff --git a/easyagent/src/ui/spinner.js b/easyagent/src/ui/spinner.js new file mode 100644 index 0000000..7265c10 --- /dev/null +++ b/easyagent/src/ui/spinner.js @@ -0,0 +1,151 @@ +'use strict'; + +const readline = require('readline'); +const { gray } = require('../utils/colors'); + +const FRAMES = ['⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + +class Spinner { + constructor(indent = '', leadingLines = 0) { + this.timer = null; + this.index = 0; + this.textProvider = () => ''; + this.active = false; + this.indent = indent; + this.leadingLines = Math.max(0, Number(leadingLines) || 0); + this.leadingWritten = false; + this.thinkingLineReady = false; + } + + setIndent(indent) { + this.indent = indent || ''; + } + + start(textProvider) { + this.textProvider = textProvider || (() => ''); + this.active = true; + this.thinkingLineReady = false; + if (this.leadingLines > 0 && !this.leadingWritten) { + process.stdout.write('\n'.repeat(this.leadingLines)); + this.leadingWritten = true; + } + this.render(); + this.timer = setInterval(() => this.render(), 120); + } + + ensureThinkingLine() { + if (this.thinkingLineReady) return; + process.stdout.write('\n'); + this.thinkingLineReady = true; + readline.moveCursor(process.stdout, 0, -1); + readline.cursorTo(process.stdout, 0); + } + + render() { + readline.clearLine(process.stdout, 0); + readline.cursorTo(process.stdout, 0); + const frame = FRAMES[this.index % FRAMES.length]; + this.index += 1; + const provided = this.textProvider(frame); + let suffix = ''; + let thinking = ''; + let colorThinking = false; + let hasThinkingLine = false; + if (typeof provided === 'string') { + suffix = provided; + } else if (provided && typeof provided === 'object') { + suffix = provided.label || ''; + thinking = provided.thinking || ''; + colorThinking = !!provided.colorThinking; + hasThinkingLine = Object.prototype.hasOwnProperty.call(provided, 'thinking'); + } + const linePrefix = `${this.indent}${frame}${suffix}`; + process.stdout.write(linePrefix); + if (hasThinkingLine) { + this.ensureThinkingLine(); + const width = process.stdout.columns || 80; + let visibleThinking = thinking; + const maxThinking = Math.max(0, width - this.indent.length - 1); + if (visibleThinking && maxThinking > 0 && visibleThinking.length > maxThinking) { + visibleThinking = visibleThinking.slice(0, maxThinking); + } + if (visibleThinking && maxThinking === 0) visibleThinking = ''; + const line = visibleThinking ? (colorThinking ? gray(visibleThinking) : visibleThinking) : ''; + const leadSpaces = (suffix.match(/^\s*/) || [''])[0].length; + const alignCol = this.indent.length + frame.length + leadSpaces; + readline.moveCursor(process.stdout, 0, 1); + readline.cursorTo(process.stdout, alignCol); + readline.clearLine(process.stdout, 0); + process.stdout.write(line); + readline.moveCursor(process.stdout, 0, -1); + readline.cursorTo(process.stdout, 0); + } + } + + stop(finalText) { + if (this.timer) clearInterval(this.timer); + this.timer = null; + this.active = false; + readline.clearLine(process.stdout, 0); + readline.cursorTo(process.stdout, 0); + const provided = this.textProvider(''); + let thinking = ''; + let colorThinking = false; + let hasThinkingLine = false; + if (provided && typeof provided === 'object' && Object.prototype.hasOwnProperty.call(provided, 'thinking')) { + thinking = provided.thinking || ''; + colorThinking = !!provided.colorThinking; + hasThinkingLine = true; + } + const linePrefix = `${this.indent}${finalText}`; + process.stdout.write(linePrefix); + if (hasThinkingLine) { + this.ensureThinkingLine(); + const width = process.stdout.columns || 80; + let visibleThinking = thinking; + const maxThinking = Math.max(0, width - this.indent.length - 1); + if (visibleThinking && maxThinking > 0 && visibleThinking.length > maxThinking) { + visibleThinking = visibleThinking.slice(0, maxThinking); + } + if (visibleThinking && maxThinking === 0) visibleThinking = ''; + const line = visibleThinking ? (colorThinking ? gray(visibleThinking) : visibleThinking) : ''; + let alignCol = this.indent.length; + const firstNonSpace = finalText.search(/\S/); + if (firstNonSpace >= 0) { + const afterFirst = finalText.slice(firstNonSpace + 1); + const spaceAfter = (afterFirst.match(/^\s*/) || [''])[0].length; + alignCol = this.indent.length + firstNonSpace + 1 + spaceAfter; + } + readline.moveCursor(process.stdout, 0, 1); + readline.cursorTo(process.stdout, alignCol); + readline.clearLine(process.stdout, 0); + process.stdout.write(line); + process.stdout.write('\n'); + } else { + process.stdout.write('\n'); + } + this.thinkingLineReady = false; + } + + stopSilent() { + if (this.timer) clearInterval(this.timer); + this.timer = null; + this.active = false; + this.thinkingLineReady = false; + readline.clearLine(process.stdout, 0); + readline.cursorTo(process.stdout, 0); + } +} + +function truncateThinking(text, max = 50) { + const sanitized = String(text || '').replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' ').trim(); + if (sanitized.length <= max) return sanitized; + return sanitized.slice(0, max) + '...'; +} + +function formatThinkingLine(text) { + if (!text) return ''; + return gray(truncateThinking(text)); +} + +module.exports = { Spinner, formatThinkingLine, truncateThinking }; diff --git a/easyagent/src/ui/status_bar.js b/easyagent/src/ui/status_bar.js new file mode 100644 index 0000000..cb23335 --- /dev/null +++ b/easyagent/src/ui/status_bar.js @@ -0,0 +1,105 @@ +'use strict'; + +const readline = require('readline'); +const { visibleWidth, truncatePlain } = require('../utils/text_width'); + +function truncateNoEllipsis(text, maxCols) { + const out = truncatePlain(text, maxCols); + if (out.endsWith('...') && visibleWidth(text) > maxCols) { + return out.slice(0, -3); + } + return out; +} + +function createStatusBar({ getTokens, maxTokens, getMaxTokens }) { + let mode = 'input'; + let enabled = true; + let scrollApplied = false; + let lastRows = null; + + const render = () => { + if (!enabled || !process.stdout.isTTY) return; + const cols = process.stdout.columns || 80; + const rows = process.stdout.rows || 24; + if (rows < 3) return; + const left = mode === 'running' ? '按下Esc停止' : '输入/查看所有指令'; + const total = typeof getTokens === 'function' ? getTokens() : 0; + const formatCount = (value) => { + const num = Number(value) || 0; + if (num < 1000) return String(num); + const k = Math.round((num / 1000) * 10) / 10; + return `${k % 1 === 0 ? k.toFixed(0) : k.toFixed(1)}k`; + }; + const maxValue = typeof getMaxTokens === 'function' ? getMaxTokens() : maxTokens; + const maxText = typeof maxValue === 'number' ? formatCount(maxValue) : (maxValue || '?'); + const right = `当前上下文 ${formatCount(total)}/${maxText}`; + const rightTail = ` ${formatCount(total)}/${maxText}`; + const leftWidth = visibleWidth(left); + const rightWidth = visibleWidth(right); + const usableCols = Math.max(1, cols - 1); + let safeLine = ''; + + const availableLeft = usableCols - rightWidth - 1; + if (availableLeft >= 1) { + const displayLeft = truncateNoEllipsis(left, availableLeft); + const gap = usableCols - visibleWidth(displayLeft) - rightWidth; + safeLine = `${displayLeft}${' '.repeat(Math.max(0, gap))}${right}`; + } else { + safeLine = truncateNoEllipsis(right, usableCols); + } + + process.stdout.write('\x1b7'); // save cursor + if (!scrollApplied || lastRows !== rows) { + process.stdout.write('\x1b[r'); // reset scroll region + process.stdout.write(`\x1b[1;${rows - 2}r`); // reserve last 2 lines + scrollApplied = true; + lastRows = rows; + } + // keep one blank spacer line above status line + readline.cursorTo(process.stdout, 0, rows - 2); + readline.clearLine(process.stdout, 0); + // render status line on last row + readline.cursorTo(process.stdout, 0, rows - 1); + readline.clearLine(process.stdout, 0); + process.stdout.write('\x1b[?7l'); // disable auto-wrap + process.stdout.write(safeLine); + process.stdout.write('\x1b[?7h'); // re-enable auto-wrap + process.stdout.write('\x1b8'); // restore cursor + }; + + const setMode = (nextMode) => { + if (mode === nextMode) return; + mode = nextMode; + render(); + }; + + const setEnabled = (nextEnabled) => { + enabled = nextEnabled; + if (enabled) render(); + }; + + const destroy = () => { + if (!process.stdout.isTTY) return; + if (scrollApplied) { + process.stdout.write('\x1b[r'); // reset scroll region + scrollApplied = false; + } + const rows = process.stdout.rows || 24; + if (rows >= 2) { + readline.cursorTo(process.stdout, 0, rows - 2); + readline.clearLine(process.stdout, 0); + } + if (rows >= 1) { + readline.cursorTo(process.stdout, 0, rows - 1); + readline.clearLine(process.stdout, 0); + } + }; + + if (process.stdout.isTTY) { + process.stdout.on('resize', () => render()); + } + + return { render, setMode, setEnabled, destroy }; +} + +module.exports = { createStatusBar }; diff --git a/easyagent/src/ui/tool_display.js b/easyagent/src/ui/tool_display.js new file mode 100644 index 0000000..6aa38ed --- /dev/null +++ b/easyagent/src/ui/tool_display.js @@ -0,0 +1,223 @@ +'use strict'; + +const readline = require('readline'); +const { blue, gray, red, green } = require('../utils/colors'); + +const DOT_ON = '•'; +const DOT_OFF = '◦'; +const { stripAnsi, visibleWidth, truncatePlain, truncateVisible } = require('../utils/text_width'); + +function toolNameMap(name) { + const map = { + read_file: '读取文件', + edit_file: '修改文件', + run_command: '运行指令', + web_search: '网络搜索', + extract_webpage: '提取网页', + search_workspace: '文件搜索', + read_mediafile: '读取媒体文件', + }; + return map[name] || name; +} + + +function normalizePreviewLine(line) { + return String(line ?? '').replace(/^\s+/, ''); +} + +function splitCommandLines(command) { + const text = String(command ?? ''); + const parts = text.split(/\r?\n/); + if (parts.length > 1 && parts[parts.length - 1] === '') parts.pop(); + return parts.length ? parts : ['']; +} + +function buildRunCommandLines(label, command, suffix = '') { + const width = Number(process.stdout.columns) || 80; + const parts = splitCommandLines(command); + const firstLine = parts[0] ?? ''; + const labelLen = visibleWidth(label); + const suffixLen = visibleWidth(suffix); + const available = width - labelLen - 1 - suffixLen; + const commandLine = available > 0 ? truncatePlain(firstLine, available) : ''; + const startLine = truncateVisible(`${label} ${commandLine}${suffix}`, width); + if (parts.length <= 1) { + return { startLine, finalLine: startLine }; + } + const preview = parts.slice(1, 3).map(normalizePreviewLine); + const lines = [startLine, ...preview.map((line) => ` ${truncatePlain(line, Math.max(0, width - 2))}`), ` 总指令${parts.length}行`]; + return { startLine, finalLine: lines.join('\n') }; +} + +function buildStartLine(name, args) { + const label = blue(toolNameMap(name)); + if (name === 'run_command') { + const command = args && Object.prototype.hasOwnProperty.call(args, 'command') ? args.command : ''; + const { startLine } = buildRunCommandLines(label, command, ` (timeout=${args.timeout}s)`); + return startLine; + } + if (name === 'web_search') { + return `${label} ${args.query}`; + } + if (name === 'search_workspace') { + const root = args.root || '.'; + const mode = args.mode === 'content' ? '内容搜索' : '文件搜索'; + return `${blue(mode)} 在 ${root} 搜索 ${args.query}`; + } + if (name === 'read_file') { + return `${label} ${args.path}`; + } + if (name === 'edit_file') { + return `${label} ${args.file_path}`; + } + if (name === 'extract_webpage') { + return `${label} ${args.url}`; + } + if (name === 'read_mediafile') { + return `${label} ${args.path}`; + } + return `${label}`; +} + +function buildFinalLine(name, args) { + if (name === 'run_command') { + const label = blue(toolNameMap(name)); + const command = args && Object.prototype.hasOwnProperty.call(args, 'command') ? args.command : ''; + const { finalLine } = buildRunCommandLines(label, command, ` (timeout=${args.timeout}s)`); + return finalLine; + } + return buildStartLine(name, args); +} + +function startToolDisplay(line) { + let on = false; + const rawLine = String(line ?? '').replace(/\r?\n/g, ' '); + let lastLines = 1; + function clearPrevious() { + if (lastLines > 1) readline.moveCursor(process.stdout, 0, -(lastLines - 1)); + for (let i = 0; i < lastLines; i += 1) { + readline.clearLine(process.stdout, 0); + if (i < lastLines - 1) readline.moveCursor(process.stdout, 0, 1); + } + if (lastLines > 1) readline.moveCursor(process.stdout, 0, -(lastLines - 1)); + readline.cursorTo(process.stdout, 0); + } + function render() { + clearPrevious(); + const dot = on ? DOT_ON : DOT_OFF; + const width = Number(process.stdout.columns) || 80; + const maxLine = Math.max(0, width - 3); + const displayLine = truncateVisible(rawLine, maxLine); + const lineText = `${dot} ${displayLine}`; + process.stdout.write(lineText); + const lineLen = visibleWidth(lineText); + lastLines = Math.max(1, Math.ceil(lineLen / Math.max(1, width))); + } + render(); + const timer = setInterval(() => { + on = !on; + render(); + }, 120); + return { + stop(finalLine) { + clearInterval(timer); + clearPrevious(); + process.stdout.write(`${DOT_ON} ${finalLine}\n`); + }, + }; +} + +function formatResultLines(name, args, raw) { + if (!raw || raw.success === false) { + const msg = raw && raw.error ? raw.error : '执行失败'; + if (msg === '任务被用户取消') return [red(msg)]; + return [red(`失败: ${msg}`)]; + } + if (name === 'run_command') { + const output = raw.output || ''; + const lines = output.split(/\r?\n/).filter((l) => l !== ''); + const tail = lines.slice(-5); + const summary = '运行结果'; + return [summary, ...tail]; + } + if (name === 'web_search') { + const results = raw.results || []; + const title = results[0]?.title || ''; + return [`浏览 ${results.length} 个网站 | ${title}`]; + } + if (name === 'search_workspace') { + if (raw.mode === 'file') { + const total = raw.matches.length; + const top = raw.matches.slice(0, 3); + const lines = [`搜索到 ${total} 个结果`, ...top]; + if (total > 3) lines.push(gray(`更多 ${total - 3} 个结果`)); + return lines; + } + if (raw.mode === 'content') { + const total = raw.results.length; + const top = raw.results.slice(0, 3).map((item) => { + const first = item.matches && item.matches[0]; + return first ? `${item.file}: L${first.line} ${first.snippet}` : item.file; + }); + const lines = [`搜索到 ${total} 个文件`, ...top]; + if (total > 3) lines.push(gray(`更多 ${total - 3} 个结果`)); + return lines; + } + } + if (name === 'read_file') { + if (raw.type === 'search') { + const count = (raw.matches || []).length; + return [`搜索到 ${count} 个结果`]; + } + if (raw.type === 'extract') { + const count = (raw.segments || []).length; + return [`抽取了 ${count} 个片段`]; + } + const lines = raw.line_end && raw.line_start ? raw.line_end - raw.line_start + 1 : 0; + const chars = raw.content ? raw.content.length : 0; + return [`读取了 ${lines} 行 / ${chars} 字符`]; + } + if (name === 'extract_webpage') { + if (args.mode === 'save') { + return [`已保存 ${args.target_path}`]; + } + const preview = (raw.content || '').slice(0, 50); + return [`${preview}${preview.length ? '...' : ''}`]; + } + if (name === 'edit_file') { + const diff = raw.diff || { added: 0, removed: 0, hunks: [] }; + const lines = [`减少了 ${diff.removed} 行 增加了 ${diff.added} 行`]; + diff.hunks.forEach((hunk, idx) => { + if (idx > 0) lines.push(' ⋮'); + hunk.forEach((ln) => { + if (ln.lineNum == null) return; + const num = String(ln.lineNum).padStart(4, ' '); + const mark = ln.prefix === ' ' ? ' ' : ln.prefix; + let text = `${num} ${mark} ${ln.content}`; + if (ln.prefix === '+') text = green(text); + if (ln.prefix === '-') text = red(text); + lines.push(text); + }); + }); + return lines; + } + if (name === 'read_mediafile') { + if (raw.type === 'image') return ['已附加图片']; + if (raw.type === 'video') return ['已附加视频']; + } + return ['完成']; +} + +function printResultLines(lines) { + if (!lines || !lines.length) return; + lines.forEach((line, idx) => { + if (idx === 0) { + process.stdout.write(` └ ${line}\n`); + } else { + process.stdout.write(` ${line}\n`); + } + }); + process.stdout.write('\n'); +} + +module.exports = { buildStartLine, buildFinalLine, startToolDisplay, formatResultLines, printResultLines }; diff --git a/easyagent/src/utils/colors.js b/easyagent/src/utils/colors.js new file mode 100644 index 0000000..a99ca72 --- /dev/null +++ b/easyagent/src/utils/colors.js @@ -0,0 +1,21 @@ +'use strict'; + +const enabled = process.stdout.isTTY; + +function wrap(code, text) { + if (!enabled) return String(text); + return `\x1b[${code}m${text}\x1b[0m`; +} + +module.exports = { + enabled, + blue: (t) => wrap('34', t), + cyan: (t) => wrap('36', t), + gray: (t) => wrap('90', t), + red: (t) => wrap('31', t), + green: (t) => wrap('32', t), + dim: (t) => wrap('2', t), + bold: (t) => wrap('1', t), + invert: (t) => wrap('7', t), + reset: enabled ? '\x1b[0m' : '', +}; diff --git a/easyagent/src/utils/text_width.js b/easyagent/src/utils/text_width.js new file mode 100644 index 0000000..a59ca79 --- /dev/null +++ b/easyagent/src/utils/text_width.js @@ -0,0 +1,428 @@ +'use strict'; + +const ANSI_REGEX = /\x1B\[[0-?]*[ -/]*[@-~]/g; +const COMBINING_RANGES = [ + [0x0300, 0x036f], + [0x0483, 0x0489], + [0x0591, 0x05bd], + [0x05bf, 0x05bf], + [0x05c1, 0x05c2], + [0x05c4, 0x05c5], + [0x05c7, 0x05c7], + [0x0610, 0x061a], + [0x064b, 0x065f], + [0x0670, 0x0670], + [0x06d6, 0x06dc], + [0x06df, 0x06e4], + [0x06e7, 0x06e8], + [0x06ea, 0x06ed], + [0x0711, 0x0711], + [0x0730, 0x074a], + [0x07a6, 0x07b0], + [0x07eb, 0x07f3], + [0x0816, 0x0819], + [0x081b, 0x0823], + [0x0825, 0x0827], + [0x0829, 0x082d], + [0x0859, 0x085b], + [0x0900, 0x0902], + [0x093a, 0x093a], + [0x093c, 0x093c], + [0x0941, 0x0948], + [0x094d, 0x094d], + [0x0951, 0x0957], + [0x0962, 0x0963], + [0x0981, 0x0981], + [0x09bc, 0x09bc], + [0x09c1, 0x09c4], + [0x09cd, 0x09cd], + [0x09e2, 0x09e3], + [0x0a01, 0x0a02], + [0x0a3c, 0x0a3c], + [0x0a41, 0x0a42], + [0x0a47, 0x0a48], + [0x0a4b, 0x0a4d], + [0x0a51, 0x0a51], + [0x0a70, 0x0a71], + [0x0a75, 0x0a75], + [0x0a81, 0x0a82], + [0x0abc, 0x0abc], + [0x0ac1, 0x0ac5], + [0x0ac7, 0x0ac8], + [0x0acd, 0x0acd], + [0x0ae2, 0x0ae3], + [0x0b01, 0x0b01], + [0x0b3c, 0x0b3c], + [0x0b3f, 0x0b3f], + [0x0b41, 0x0b44], + [0x0b4d, 0x0b4d], + [0x0b56, 0x0b56], + [0x0b62, 0x0b63], + [0x0b82, 0x0b82], + [0x0bc0, 0x0bc0], + [0x0bcd, 0x0bcd], + [0x0c00, 0x0c00], + [0x0c3e, 0x0c40], + [0x0c46, 0x0c48], + [0x0c4a, 0x0c4d], + [0x0c55, 0x0c56], + [0x0c62, 0x0c63], + [0x0c81, 0x0c81], + [0x0cbc, 0x0cbc], + [0x0cbf, 0x0cbf], + [0x0cc6, 0x0cc6], + [0x0ccc, 0x0ccd], + [0x0ce2, 0x0ce3], + [0x0d00, 0x0d01], + [0x0d3b, 0x0d3c], + [0x0d41, 0x0d44], + [0x0d4d, 0x0d4d], + [0x0d62, 0x0d63], + [0x0dca, 0x0dca], + [0x0dd2, 0x0dd4], + [0x0dd6, 0x0dd6], + [0x0e31, 0x0e31], + [0x0e34, 0x0e3a], + [0x0e47, 0x0e4e], + [0x0eb1, 0x0eb1], + [0x0eb4, 0x0ebc], + [0x0ec8, 0x0ecd], + [0x0f18, 0x0f19], + [0x0f35, 0x0f35], + [0x0f37, 0x0f37], + [0x0f39, 0x0f39], + [0x0f71, 0x0f7e], + [0x0f80, 0x0f84], + [0x0f86, 0x0f87], + [0x0f8d, 0x0f97], + [0x0f99, 0x0fbc], + [0x0fc6, 0x0fc6], + [0x102d, 0x1030], + [0x1032, 0x1037], + [0x1039, 0x103a], + [0x103d, 0x103e], + [0x1058, 0x1059], + [0x105e, 0x1060], + [0x1071, 0x1074], + [0x1082, 0x1082], + [0x1085, 0x1086], + [0x108d, 0x108d], + [0x109d, 0x109d], + [0x135d, 0x135f], + [0x1712, 0x1714], + [0x1732, 0x1734], + [0x1752, 0x1753], + [0x1772, 0x1773], + [0x17b4, 0x17b5], + [0x17b7, 0x17bd], + [0x17c6, 0x17c6], + [0x17c9, 0x17d3], + [0x17dd, 0x17dd], + [0x180b, 0x180d], + [0x1885, 0x1886], + [0x18a9, 0x18a9], + [0x1920, 0x1922], + [0x1927, 0x1928], + [0x1932, 0x1932], + [0x1939, 0x193b], + [0x1a17, 0x1a18], + [0x1a1b, 0x1a1b], + [0x1a56, 0x1a56], + [0x1a58, 0x1a5e], + [0x1a60, 0x1a60], + [0x1a62, 0x1a62], + [0x1a65, 0x1a6c], + [0x1a73, 0x1a7c], + [0x1a7f, 0x1a7f], + [0x1ab0, 0x1ace], + [0x1b00, 0x1b03], + [0x1b34, 0x1b34], + [0x1b36, 0x1b3a], + [0x1b3c, 0x1b3c], + [0x1b42, 0x1b42], + [0x1b6b, 0x1b73], + [0x1b80, 0x1b81], + [0x1ba2, 0x1ba5], + [0x1ba8, 0x1ba9], + [0x1bab, 0x1bad], + [0x1be6, 0x1be6], + [0x1be8, 0x1be9], + [0x1bed, 0x1bed], + [0x1bef, 0x1bf1], + [0x1c2c, 0x1c33], + [0x1c36, 0x1c37], + [0x1cd0, 0x1cd2], + [0x1cd4, 0x1ce0], + [0x1ce2, 0x1ce8], + [0x1ced, 0x1ced], + [0x1cf4, 0x1cf4], + [0x1cf8, 0x1cf9], + [0x1dc0, 0x1df9], + [0x1dfb, 0x1dff], + [0x200b, 0x200f], + [0x202a, 0x202e], + [0x2060, 0x2064], + [0x2066, 0x206f], + [0x20d0, 0x20f0], + [0x2cef, 0x2cf1], + [0x2d7f, 0x2d7f], + [0x2de0, 0x2dff], + [0x302a, 0x302f], + [0x3099, 0x309a], + [0xa66f, 0xa672], + [0xa674, 0xa67d], + [0xa69e, 0xa69f], + [0xa6f0, 0xa6f1], + [0xa802, 0xa802], + [0xa806, 0xa806], + [0xa80b, 0xa80b], + [0xa825, 0xa826], + [0xa8c4, 0xa8c5], + [0xa8e0, 0xa8f1], + [0xa926, 0xa92d], + [0xa947, 0xa951], + [0xa980, 0xa982], + [0xa9b3, 0xa9b3], + [0xa9b6, 0xa9b9], + [0xa9bc, 0xa9bc], + [0xa9e5, 0xa9e5], + [0xaa29, 0xaa2e], + [0xaa31, 0xaa32], + [0xaa35, 0xaa36], + [0xaa43, 0xaa43], + [0xaa4c, 0xaa4c], + [0xaa7c, 0xaa7c], + [0xaab0, 0xaab0], + [0xaab2, 0xaab4], + [0xaab7, 0xaab8], + [0xaabe, 0xaabf], + [0xaac1, 0xaac1], + [0xaaec, 0xaaed], + [0xaaf6, 0xaaf6], + [0xabe5, 0xabe5], + [0xabe8, 0xabe8], + [0xabed, 0xabed], + [0xfb1e, 0xfb1e], + [0xfe00, 0xfe0f], + [0xfe20, 0xfe2f], + [0xfeff, 0xfeff], + [0xfff9, 0xfffb], + [0x101fd, 0x101fd], + [0x102e0, 0x102e0], + [0x10376, 0x1037a], + [0x10a01, 0x10a03], + [0x10a05, 0x10a06], + [0x10a0c, 0x10a0f], + [0x10a38, 0x10a3a], + [0x10a3f, 0x10a3f], + [0x10ae5, 0x10ae6], + [0x10d24, 0x10d27], + [0x10eab, 0x10eac], + [0x10f46, 0x10f50], + [0x11001, 0x11001], + [0x11038, 0x11046], + [0x1107f, 0x11081], + [0x110b3, 0x110b6], + [0x110b9, 0x110ba], + [0x110c2, 0x110c2], + [0x11100, 0x11102], + [0x11127, 0x1112b], + [0x1112d, 0x11134], + [0x11173, 0x11173], + [0x11180, 0x11181], + [0x111b6, 0x111be], + [0x111c9, 0x111cc], + [0x1122f, 0x11231], + [0x11234, 0x11234], + [0x11236, 0x11237], + [0x1123e, 0x1123e], + [0x112df, 0x112df], + [0x112e3, 0x112ea], + [0x11300, 0x11301], + [0x1133b, 0x1133c], + [0x11340, 0x11340], + [0x11366, 0x1136c], + [0x11370, 0x11374], + [0x11438, 0x1143f], + [0x11442, 0x11444], + [0x11446, 0x11446], + [0x1145e, 0x1145e], + [0x114b3, 0x114b8], + [0x114ba, 0x114ba], + [0x114bf, 0x114c0], + [0x114c2, 0x114c3], + [0x115b2, 0x115b5], + [0x115bc, 0x115bd], + [0x115bf, 0x115c0], + [0x115dc, 0x115dd], + [0x11633, 0x1163a], + [0x1163d, 0x1163d], + [0x1163f, 0x11640], + [0x116ab, 0x116ab], + [0x116ad, 0x116ad], + [0x116b0, 0x116b5], + [0x116b7, 0x116b7], + [0x1171d, 0x1171f], + [0x11722, 0x11725], + [0x11727, 0x1172b], + [0x1182f, 0x11837], + [0x11839, 0x1183a], + [0x1193b, 0x1193c], + [0x1193e, 0x1193e], + [0x11943, 0x11943], + [0x119d4, 0x119d7], + [0x119da, 0x119db], + [0x119e0, 0x119e0], + [0x11a01, 0x11a0a], + [0x11a33, 0x11a38], + [0x11a3b, 0x11a3e], + [0x11a47, 0x11a47], + [0x11a51, 0x11a56], + [0x11a59, 0x11a5b], + [0x11a8a, 0x11a96], + [0x11a98, 0x11a99], + [0x11c30, 0x11c36], + [0x11c38, 0x11c3d], + [0x11c3f, 0x11c3f], + [0x11c92, 0x11ca7], + [0x11caa, 0x11cb0], + [0x11cb2, 0x11cb3], + [0x11cb5, 0x11cb6], + [0x11d31, 0x11d36], + [0x11d3a, 0x11d3a], + [0x11d3c, 0x11d3d], + [0x11d3f, 0x11d45], + [0x11d47, 0x11d47], + [0x11d90, 0x11d91], + [0x11ef3, 0x11ef4], + [0x16af0, 0x16af4], + [0x16b30, 0x16b36], + [0x16f4f, 0x16f4f], + [0x16f8f, 0x16f92], + [0x16fe4, 0x16fe4], + [0x1bc9d, 0x1bc9e], + [0x1cf00, 0x1cf2d], + [0x1cf30, 0x1cf46], + [0x1d167, 0x1d169], + [0x1d17b, 0x1d182], + [0x1d185, 0x1d18b], + [0x1d1aa, 0x1d1ad], + [0x1d242, 0x1d244], + [0xe0100, 0xe01ef], +]; + +function stripAnsi(text) { + return String(text ?? '').replace(ANSI_REGEX, ''); +} + +function isCombining(codePoint) { + for (const [start, end] of COMBINING_RANGES) { + if (codePoint >= start && codePoint <= end) return true; + } + return false; +} + +function isFullWidth(codePoint) { + if (codePoint >= 0x1100 && ( + codePoint <= 0x115f || + codePoint === 0x2329 || + codePoint === 0x232a || + (codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) || + (codePoint >= 0xac00 && codePoint <= 0xd7a3) || + (codePoint >= 0xf900 && codePoint <= 0xfaff) || + (codePoint >= 0xfe10 && codePoint <= 0xfe19) || + (codePoint >= 0xfe30 && codePoint <= 0xfe6f) || + (codePoint >= 0xff00 && codePoint <= 0xff60) || + (codePoint >= 0xffe0 && codePoint <= 0xffe6) || + (codePoint >= 0x20000 && codePoint <= 0x3fffd) + )) { + return true; + } + return false; +} + +function visibleWidth(text) { + const str = stripAnsi(text); + let width = 0; + for (let i = 0; i < str.length; i += 1) { + const codePoint = str.codePointAt(i); + if (codePoint > 0xffff) i += 1; + if (codePoint === 0) continue; + if (codePoint < 32 || (codePoint >= 0x7f && codePoint <= 0x9f)) continue; + if (isCombining(codePoint)) continue; + width += isFullWidth(codePoint) ? 2 : 1; + } + return width; +} + +function truncatePlain(text, maxCols) { + const str = String(text ?? ''); + if (maxCols <= 0) return ''; + if (visibleWidth(str) <= maxCols) return str; + const useEllipsis = maxCols > 3; + const limit = maxCols - (useEllipsis ? 3 : 0); + let out = ''; + let width = 0; + for (let i = 0; i < str.length && width < limit; i += 1) { + const codePoint = str.codePointAt(i); + if (codePoint > 0xffff) i += 1; + if (codePoint === 0) continue; + if (codePoint < 32 || (codePoint >= 0x7f && codePoint <= 0x9f)) continue; + if (isCombining(codePoint)) continue; + const nextWidth = isFullWidth(codePoint) ? 2 : 1; + if (width + nextWidth > limit) break; + out += String.fromCodePoint(codePoint); + width += nextWidth; + } + if (useEllipsis) out += '...'; + return out; +} + +function truncateVisible(text, maxCols) { + const str = String(text ?? ''); + if (maxCols <= 0) return ''; + if (visibleWidth(str) <= maxCols) return str; + const useEllipsis = maxCols > 3; + const limit = maxCols - (useEllipsis ? 3 : 0); + let out = ''; + let width = 0; + const ansiRegex = new RegExp(ANSI_REGEX.source, 'y'); + for (let i = 0; i < str.length && width < limit; ) { + ansiRegex.lastIndex = i; + const match = ansiRegex.exec(str); + if (match && match.index === i) { + out += match[0]; + i += match[0].length; + continue; + } + const codePoint = str.codePointAt(i); + const char = String.fromCodePoint(codePoint); + if (codePoint > 0xffff) i += 2; + else i += 1; + if (codePoint === 0) continue; + if (codePoint < 32 || (codePoint >= 0x7f && codePoint <= 0x9f)) continue; + if (isCombining(codePoint)) continue; + const nextWidth = isFullWidth(codePoint) ? 2 : 1; + if (width + nextWidth > limit) break; + out += char; + width += nextWidth; + } + if (useEllipsis) out += '...'; + if (str.includes('\x1b[')) out += '\x1b[0m'; + return out; +} + +function padEndVisible(text, targetWidth) { + const str = String(text ?? ''); + const width = visibleWidth(str); + if (width >= targetWidth) return str; + return str + ' '.repeat(targetWidth - width); +} + +module.exports = { + stripAnsi, + visibleWidth, + truncatePlain, + truncateVisible, + padEndVisible, +}; diff --git a/easyagent/src/utils/time.js b/easyagent/src/utils/time.js new file mode 100644 index 0000000..71d4de2 --- /dev/null +++ b/easyagent/src/utils/time.js @@ -0,0 +1,27 @@ +'use strict'; + +function toISO(dt = new Date()) { + return dt.toISOString(); +} + +function formatRelativeTime(iso) { + if (!iso) return ''; + const t = new Date(iso).getTime(); + if (Number.isNaN(t)) return ''; + const now = Date.now(); + const diff = Math.max(0, now - t); + const sec = Math.floor(diff / 1000); + if (sec < 60) return `${sec}秒前`; + const min = Math.floor(sec / 60); + if (min < 60) return `${min}分钟前`; + const hour = Math.floor(min / 60); + if (hour < 24) return `${hour}小时前`; + const day = Math.floor(hour / 24); + if (day < 30) return `${day}天前`; + const month = Math.floor(day / 30); + if (month < 12) return `${month}个月前`; + const year = Math.floor(month / 12); + return `${year}年前`; +} + +module.exports = { toISO, formatRelativeTime }; diff --git a/easyagent/src/utils/token_usage.js b/easyagent/src/utils/token_usage.js new file mode 100644 index 0000000..d1cf130 --- /dev/null +++ b/easyagent/src/utils/token_usage.js @@ -0,0 +1,39 @@ +'use strict'; + +function normalizeTokenUsage(value) { + if (value && typeof value === 'object') { + return { + prompt: Number(value.prompt) || 0, + completion: Number(value.completion) || 0, + total: Number(value.total) || 0, + }; + } + const num = Number(value); + return { + prompt: 0, + completion: 0, + total: Number.isFinite(num) ? num : 0, + }; +} + +function applyUsage(base, usage) { + const normalized = normalizeTokenUsage(base); + if (!usage) return normalized; + if (Number.isFinite(usage.prompt_tokens)) normalized.prompt += usage.prompt_tokens; + if (Number.isFinite(usage.completion_tokens)) normalized.completion += usage.completion_tokens; + if (Number.isFinite(usage.total_tokens)) normalized.total = usage.total_tokens; + return normalized; +} + +function normalizeUsagePayload(usage) { + if (!usage || typeof usage !== 'object') return null; + const toNum = (value) => (Number.isFinite(Number(value)) ? Number(value) : 0); + const prompt = toNum(usage.prompt_tokens ?? usage.input_tokens ?? usage.prompt ?? usage.input); + const completion = toNum(usage.completion_tokens ?? usage.output_tokens ?? usage.completion ?? usage.output); + let total = toNum(usage.total_tokens ?? usage.total); + if (!total && (prompt || completion)) total = prompt + completion; + if (!prompt && !completion && !total) return null; + return { prompt_tokens: prompt, completion_tokens: completion, total_tokens: total }; +} + +module.exports = { normalizeTokenUsage, applyUsage, normalizeUsagePayload }; diff --git a/modules/sub_agent_manager.py b/modules/sub_agent_manager.py index 99b89cf..13546b9 100644 --- a/modules/sub_agent_manager.py +++ b/modules/sub_agent_manager.py @@ -1,20 +1,16 @@ -"""子智能体任务管理。""" +"""子智能体任务管理(子进程模式)。""" import json -import shutil +import subprocess import time import uuid from pathlib import Path -from typing import Dict, List, Optional, Tuple, Any - -import httpx +from typing import Dict, List, Optional, Any, Tuple from config import ( OUTPUT_FORMATS, SUB_AGENT_DEFAULT_TIMEOUT, SUB_AGENT_MAX_ACTIVE, - SUB_AGENT_PROJECT_RESULTS_DIR, - SUB_AGENT_SERVICE_BASE_URL, SUB_AGENT_STATE_FILE, SUB_AGENT_STATUS_POLL_INTERVAL, SUB_AGENT_TASKS_BASE_DIR, @@ -33,23 +29,23 @@ TERMINAL_STATUSES = {"completed", "failed", "timeout"} class SubAgentManager: - """负责主智能体与子智能体服务之间的任务调度。""" + """负责主智能体与子智能体的任务调度(子进程模式)。""" def __init__(self, project_path: str, data_dir: str): self.project_path = Path(project_path).resolve() self.data_dir = Path(data_dir).resolve() self.base_dir = Path(SUB_AGENT_TASKS_BASE_DIR).resolve() - self.results_dir = Path(SUB_AGENT_PROJECT_RESULTS_DIR).resolve() self.state_file = Path(SUB_AGENT_STATE_FILE).resolve() - self.sub_agent_conversations_dir = (self.data_dir / "conversations" / "sub_agent").resolve() + + # easyagent批处理入口 + self.easyagent_batch = Path(__file__).parent.parent / "easyagent" / "src" / "batch" / "index.js" self.base_dir.mkdir(parents=True, exist_ok=True) - self.results_dir.mkdir(parents=True, exist_ok=True) self.state_file.parent.mkdir(parents=True, exist_ok=True) - self.sub_agent_conversations_dir.mkdir(parents=True, exist_ok=True) self.tasks: Dict[str, Dict] = {} self.conversation_agents: Dict[str, List[int]] = {} + self.processes: Dict[str, subprocess.Popen] = {} # task_id -> Popen对象 self._load_state() # ------------------------------------------------------------------ @@ -61,14 +57,14 @@ class SubAgentManager: agent_id: int, summary: str, task: str, - target_dir: str, - reference_files: Optional[List[str]] = None, + deliverables_dir: str, timeout_seconds: Optional[int] = None, conversation_id: Optional[str] = None, + run_in_background: bool = False, + model_key: Optional[str] = None, ) -> Dict: - """创建子智能体任务并启动远端服务。""" - reference_files = reference_files or [] - validation_error = self._validate_create_params(agent_id, summary, task, target_dir) + """创建子智能体任务并启动子进程。""" + validation_error = self._validate_create_params(agent_id, summary, task, deliverables_dir) if validation_error: return {"success": False, "error": validation_error} @@ -89,84 +85,92 @@ class SubAgentManager: task_id = self._generate_task_id(agent_id) task_root = self.base_dir / task_id - workspace_dir = task_root / "workspace" - references_dir = workspace_dir / "references" - deliverables_dir = workspace_dir / "deliverables" - - for path in (task_root, references_dir, deliverables_dir, workspace_dir): - path.mkdir(parents=True, exist_ok=True) - - copied_refs, copy_errors = self._copy_reference_files(reference_files, references_dir) - if copy_errors: - return {"success": False, "error": "; ".join(copy_errors)} + task_root.mkdir(parents=True, exist_ok=True) + # 解析deliverables_dir(相对于project_path) try: - target_project_dir = self._ensure_project_subdir(target_dir) + deliverables_path = self._resolve_deliverables_dir(deliverables_dir) except ValueError as exc: return {"success": False, "error": str(exc)} + # 创建.subagent目录用于存储对话历史 + subagent_dir = deliverables_path / ".subagent" + subagent_dir.mkdir(parents=True, exist_ok=True) + + # 准备文件路径 + task_file = task_root / "task.txt" + system_prompt_file = task_root / "system_prompt.txt" + output_file = task_root / "output.json" + stats_file = task_root / "stats.json" + + # 构建用户消息 + user_message = self._build_user_message(agent_id, summary, task, deliverables_path, timeout_seconds) + task_file.write_text(user_message, encoding="utf-8") + + # 构建系统提示 + system_prompt = self._build_system_prompt() + system_prompt_file.write_text(system_prompt, encoding="utf-8") + + # 启动子进程 timeout_seconds = timeout_seconds or SUB_AGENT_DEFAULT_TIMEOUT - payload = { - "task_id": task_id, - "agent_id": agent_id, - "summary": summary, - "task": task, - "target_project_dir": str(target_project_dir), - "workspace_dir": str(workspace_dir), - "references_dir": str(references_dir), - "deliverables_dir": str(deliverables_dir), - "timeout_seconds": timeout_seconds, - "parent_conversation_id": conversation_id, - "data_dir": str(self.data_dir), - "reference_manifest": copied_refs, - "conversation_storage_dir": str(self.sub_agent_conversations_dir), - } + cmd = [ + "node", + str(self.easyagent_batch), + "--workspace", str(self.project_path), + "--task-file", str(task_file), + "--system-prompt-file", str(system_prompt_file), + "--output-file", str(output_file), + "--stats-file", str(stats_file), + "--agent-id", str(agent_id), + "--timeout", str(timeout_seconds), + ] + if model_key: + cmd.extend(["--model-key", model_key]) - service_response = self._call_service("POST", "/tasks", payload, timeout_seconds + 5) - if not service_response.get("success"): - self._cleanup_task_folder(task_root) - return { - "success": False, - "error": service_response.get("error", "子智能体服务调用失败"), - "details": service_response, - } + try: + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=str(self.project_path), + ) + except Exception as exc: + return {"success": False, "error": f"启动子智能体失败: {exc}"} - status = service_response.get("status", "pending") - sub_conversation_id = service_response.get("sub_conversation_id") + # 记录任务 task_record = { "task_id": task_id, "agent_id": agent_id, "summary": summary, "task": task, - "status": status, - "target_project_dir": str(target_project_dir), - "references_dir": str(references_dir), - "deliverables_dir": str(deliverables_dir), - "workspace_dir": str(workspace_dir), - "copied_references": copied_refs, + "status": "running", + "deliverables_dir": str(deliverables_path), + "subagent_dir": str(subagent_dir), "timeout_seconds": timeout_seconds, - "service_payload": payload, "created_at": time.time(), "conversation_id": conversation_id, - "sub_conversation_id": sub_conversation_id, - "parent_conversation_id": conversation_id, + "run_in_background": run_in_background, + "task_root": str(task_root), + "output_file": str(output_file), + "stats_file": str(stats_file), + "pid": process.pid, } self.tasks[task_id] = task_record + self.processes[task_id] = process self._mark_agent_id_used(conversation_id, agent_id) self._save_state() - message = f"子智能体{agent_id} 已创建,任务ID: {task_id},当前状态:{status}" + message = f"子智能体{agent_id} 已创建,任务ID: {task_id},PID: {process.pid}" print(f"{OUTPUT_FORMATS['info']} {message}") return { "success": True, "task_id": task_id, "agent_id": agent_id, - "status": status, + "status": "running", "message": message, - "deliverables_dir": str(deliverables_dir), - "copied_references": copied_refs, - "sub_conversation_id": sub_conversation_id, + "deliverables_dir": str(deliverables_path), + "run_in_background": run_in_background, } def wait_for_completion( @@ -188,32 +192,17 @@ class SubAgentManager: timeout_seconds = timeout_seconds or task.get("timeout_seconds") or SUB_AGENT_DEFAULT_TIMEOUT deadline = time.time() + timeout_seconds - last_payload: Optional[Dict] = None while time.time() < deadline: - last_payload = self._call_service("GET", f"/tasks/{task['task_id']}", timeout=15) - status = last_payload.get("status") - if not last_payload.get("success") and status not in TERMINAL_STATUSES: - time.sleep(SUB_AGENT_STATUS_POLL_INTERVAL) - continue - - if status in {"completed", "failed", "timeout", "terminated"}: - break + # 检查进程状态 + status_result = self._check_task_status(task) + if status_result["status"] in TERMINAL_STATUSES: + return status_result time.sleep(SUB_AGENT_STATUS_POLL_INTERVAL) - else: - status = "timeout" - last_payload = {"success": False, "status": status, "message": "等待超时"} - if not last_payload: - last_payload = {"success": False, "status": "unknown", "message": "无法获取子智能体状态"} - status = "unknown" - else: - status = last_payload.get("status", status) - - finalize_result = self._finalize_task(task, last_payload or {}, status) - self._save_state() - return finalize_result + # 超时 + return self._handle_timeout(task) def terminate_sub_agent( self, @@ -227,27 +216,281 @@ class SubAgentManager: return {"success": False, "error": "未找到对应的子智能体任务"} task_id = task["task_id"] - response = self._call_service("POST", f"/tasks/{task_id}/terminate", timeout=10) - response["task_id"] = task_id - if response.get("success"): - task["status"] = "terminated" - task["final_result"] = { - "success": False, - "status": "terminated", - "task_id": task_id, - "agent_id": task.get("agent_id"), - "message": response.get("message") or "子智能体已被强制关闭。", - } - self._save_state() - if "system_message" not in response: - response["system_message"] = response.get("message") or "🛑 子智能体已被手动关闭。" - elif "system_message" not in response: - response["system_message"] = response.get("message") - return response + process = self.processes.get(task_id) + + if process and process.poll() is None: + # 进程还在运行,终止它 + try: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + except Exception as exc: + return {"success": False, "error": f"终止进程失败: {exc}"} + + task["status"] = "terminated" + task["final_result"] = { + "success": False, + "status": "terminated", + "task_id": task_id, + "agent_id": task.get("agent_id"), + "message": "子智能体已被强制关闭。", + } + self._save_state() + + return { + "success": True, + "task_id": task_id, + "message": "子智能体已被强制关闭。", + "system_message": f"🛑 子智能体{task.get('agent_id')} 已被手动关闭。", + } # ------------------------------------------------------------------ # 内部工具方法 # ------------------------------------------------------------------ + def _check_task_status(self, task: Dict) -> Dict: + """检查任务状态,如果完成则解析输出。""" + task_id = task["task_id"] + process = self.processes.get(task_id) + + # 检查进程是否结束 + if process: + returncode = process.poll() + if returncode is None: + # 进程还在运行 + return {"status": "running", "task_id": task_id} + + # 进程已结束,读取输出文件 + output_file = Path(task.get("output_file", "")) + if not output_file.exists(): + # 输出文件不存在,可能是异常退出 + task["status"] = "failed" + task["updated_at"] = time.time() + result = { + "success": False, + "status": "failed", + "task_id": task_id, + "agent_id": task.get("agent_id"), + "message": "子智能体异常退出,未生成输出文件。", + "system_message": f"❌ 子智能体{task.get('agent_id')} 异常退出。", + } + task["final_result"] = result + return result + + # 解析输出 + try: + output = json.loads(output_file.read_text(encoding="utf-8")) + except Exception as exc: + task["status"] = "failed" + task["updated_at"] = time.time() + result = { + "success": False, + "status": "failed", + "task_id": task_id, + "agent_id": task.get("agent_id"), + "message": f"输出文件解析失败: {exc}", + "system_message": f"❌ 子智能体{task.get('agent_id')} 输出解析失败。", + } + task["final_result"] = result + return result + + # 根据输出更新任务状态 + success = output.get("success", False) + summary = output.get("summary", "") + stats = output.get("stats", {}) + + if output.get("timeout"): + status = "timeout" + elif output.get("max_turns_exceeded"): + status = "failed" + summary = f"任务执行超过最大轮次限制。{summary}" + elif success: + status = "completed" + else: + status = "failed" + + task["status"] = status + task["updated_at"] = time.time() + + # 构建系统消息 + agent_id = task.get("agent_id") + task_summary = task.get("summary") + deliverables_dir = task.get("deliverables_dir") + + if status == "completed": + system_message = f"✅ 子智能体{agent_id} 任务摘要:{task_summary} 已完成。\n\n{summary}\n\n交付目录:{deliverables_dir}" + elif status == "timeout": + system_message = f"⏱️ 子智能体{agent_id} 任务摘要:{task_summary} 超时未完成。\n\n{summary}" + else: + system_message = f"❌ 子智能体{agent_id} 任务摘要:{task_summary} 执行失败。\n\n{summary}" + + result = { + "success": success, + "status": status, + "task_id": task_id, + "agent_id": agent_id, + "message": summary, + "deliverables_dir": deliverables_dir, + "stats": stats, + "system_message": system_message, + } + task["final_result"] = result + return result + + def _handle_timeout(self, task: Dict) -> Dict: + """处理任务超时。""" + task_id = task["task_id"] + process = self.processes.get(task_id) + + # 终止进程 + if process and process.poll() is None: + try: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + except Exception: + pass + + task["status"] = "timeout" + task["updated_at"] = time.time() + + result = { + "success": False, + "status": "timeout", + "task_id": task_id, + "agent_id": task.get("agent_id"), + "message": "等待超时,子智能体已被终止。", + "system_message": f"⏱️ 子智能体{task.get('agent_id')} 任务摘要:{task.get('summary')} 超时未完成。", + } + task["final_result"] = result + self._save_state() + return result + + def _build_user_message( + self, + agent_id: int, + summary: str, + task: str, + deliverables_path: Path, + timeout_seconds: Optional[int], + ) -> str: + """构建发送给子智能体的用户消息。""" + timeout_seconds = timeout_seconds or SUB_AGENT_DEFAULT_TIMEOUT + return f"""你是子智能体 #{agent_id},负责完成以下任务: + +**任务摘要**:{summary} + +**任务详情**: +{task} + +**交付目录**:{deliverables_path} +请将所有生成的文件保存到此目录。对话历史会自动保存到 {deliverables_path}/.subagent/ 目录。 + +**超时时间**:{timeout_seconds} 秒 + +完成任务后,请调用 finish_task 工具提交完成报告。""" + + def _build_system_prompt(self) -> str: + """构建子智能体的系统提示。""" + import platform + from datetime import datetime + + system_info = f"{platform.system()} {platform.release()}" + current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + return f"""你是一个专注的子智能体,负责独立完成分配的任务。 + +# 身份定位 + +你是主智能体创建的子智能体,拥有完整的工具能力(读写文件、执行命令、搜索网页等)。你的职责是专注完成分配的单一任务,不要偏离任务目标。 + +# 工作流程 + +1. **理解任务**:仔细阅读任务描述,明确目标和要求 +2. **制定计划**:规划完成任务的步骤 +3. **执行任务**:使用工具完成各个步骤 +4. **生成交付**:将所有结果文件放到指定的交付目录 +5. **提交报告**:使用 finish_task 工具提交完成报告并退出 + +# 工作原则 + +## 专注性 +- 只完成分配的任务,不要做额外的工作 +- 不要尝试与用户对话或询问问题 +- 遇到问题时,在能力范围内解决或在报告中说明 + +## 独立性 +- 你与主智能体共享工作区,可以访问所有文件 +- 你的工作范围应该与其他子智能体不重叠 +- 不要修改任务描述之外的文件 + +## 效率性 +- 直接开始工作,不要过度解释 +- 合理使用工具,避免重复操作 +- 注意超时限制,在时间内完成核心工作 + +## 完整性 +- 确保交付目录中的文件完整可用 +- 生成的文档要清晰、格式正确 +- 代码要包含必要的注释和说明 + +# 交付要求 + +所有结果文件必须放在指定的交付目录中,包括: +- 主要成果文件(文档、代码、报告等) +- 支持文件(数据、配置、示例等) +- 不要在交付目录外创建文件 + +# 完成任务 + +任务完成后,必须调用 finish_task 工具: +- success: 是否成功完成 +- summary: 完成摘要(说明做了什么、生成了什么) + +调用 finish_task 后,你会立即退出,无法继续工作。 + +# 工具使用 + +你拥有以下工具能力: +- read_file: 读取文件内容 +- write_file / edit_file: 创建或修改文件 +- search_workspace: 搜索文件和代码 +- run_command: 执行终端命令 +- web_search / extract_webpage: 搜索和提取网页内容 +- finish_task: 完成任务并退出(必须调用) + +# 注意事项 + +1. **不要无限循环**:如果任务无法完成,说明原因并提交报告 +2. **不要超出范围**:只操作任务描述中指定的文件/目录 +3. **不要等待输入**:你是自主运行的,不会收到用户的进一步指令 +4. **注意时间限制**:超时会被强制终止,优先完成核心工作 + +# 当前环境 + +- 工作区路径: {self.project_path} +- 系统: {system_info} +- 当前时间: {current_time} + +现在开始执行任务。""" + + def _resolve_deliverables_dir(self, relative_dir: str) -> Path: + """解析交付目录(相对于project_path)。""" + relative_dir = relative_dir.strip() if relative_dir else "" + if not relative_dir: + raise ValueError("交付目录不能为空,必须指定") + + deliverables_path = (self.project_path / relative_dir).resolve() + if not str(deliverables_path).startswith(str(self.project_path)): + raise ValueError("交付目录必须位于项目目录内") + + deliverables_path.mkdir(parents=True, exist_ok=True) + return deliverables_path def _load_state(self): if self.state_file.exists(): try: @@ -382,19 +625,66 @@ class SubAgentManager: state_changed = False for task in pending_tasks: - payload = self._call_service("GET", f"/tasks/{task['task_id']}", timeout=10) - status = payload.get("status") - logger.debug(f"[SubAgentManager] 任务 {task['task_id']} 服务状态: {status}") - if status not in TERMINAL_STATUSES: - continue - result = self._finalize_task(task, payload, status) - updates.append(result) - state_changed = True + result = self._check_task_status(task) + if result["status"] in TERMINAL_STATUSES: + updates.append(result) + state_changed = True if state_changed: self._save_state() return updates + def get_sub_agent_status( + self, + *, + agent_ids: Optional[List[int]] = None, + ) -> Dict: + """获取指定子智能体的详细状态。""" + if not agent_ids: + return {"success": False, "error": "必须指定至少一个agent_id"} + + results = [] + for agent_id in agent_ids: + task = self._select_task(None, agent_id) + if not task: + results.append({ + "agent_id": agent_id, + "found": False, + "error": "未找到对应的子智能体任务" + }) + continue + + # 如果任务还在运行,检查最新状态 + if task.get("status") not in TERMINAL_STATUSES.union({"terminated"}): + self._check_task_status(task) + + # 读取统计信息 + stats = {} + stats_file = Path(task.get("stats_file", "")) + if stats_file.exists(): + try: + stats = json.loads(stats_file.read_text(encoding="utf-8")) + except Exception: + pass + + results.append({ + "agent_id": agent_id, + "found": True, + "task_id": task["task_id"], + "status": task["status"], + "summary": task.get("summary"), + "created_at": task.get("created_at"), + "updated_at": task.get("updated_at"), + "deliverables_dir": task.get("deliverables_dir"), + "stats": stats, + "final_result": task.get("final_result"), + }) + + return { + "success": True, + "results": results, + } + def _call_service(self, method: str, path: str, payload: Optional[Dict] = None, timeout: Optional[int] = None) -> Dict: url = f"{SUB_AGENT_SERVICE_BASE_URL.rstrip('/')}{path}" try: @@ -591,15 +881,21 @@ class SubAgentManager: "sub_conversation_id": task.get("sub_conversation_id"), } - # 运行中的任务尝试同步远端最新状态 - if snapshot["status"] not in TERMINAL_STATUSES: - remote = self._call_service("GET", f"/tasks/{task_id}", timeout=5) - if remote.get("success"): - snapshot["status"] = remote.get("status", snapshot["status"]) - snapshot["remote_message"] = remote.get("message") - snapshot["last_tool"] = remote.get("last_tool") - task["last_tool"] = snapshot["last_tool"] - else: + # 运行中的任务检查进程状态 + 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 + + if snapshot["status"] in TERMINAL_STATUSES or snapshot["status"] == "terminated": # 已结束的任务带上最终结果/系统消息,方便前端展示 final_result = task.get("final_result") or {} snapshot["final_message"] = final_result.get("system_message") or final_result.get("message") diff --git a/server/chat_flow_task_main.py b/server/chat_flow_task_main.py index 8a7f94b..1888378 100644 --- a/server/chat_flow_task_main.py +++ b/server/chat_flow_task_main.py @@ -89,6 +89,7 @@ from .state import ( terminal_rooms, connection_users, stop_flags, + active_polling_tasks, get_stop_flag, set_stop_flag, clear_stop_flag, @@ -128,8 +129,177 @@ from .chat_flow_task_support import process_sub_agent_updates from .chat_flow_tool_loop import execute_tool_calls from .chat_flow_stream_loop import run_streaming_attempts +async def poll_sub_agent_completion(*, web_terminal, workspace, conversation_id, client_sid, username): + """后台轮询子智能体完成状态,完成后触发新一轮对话""" + from .extensions import socketio + + manager = getattr(web_terminal, "sub_agent_manager", None) + if not manager: + debug_log("[SubAgent] poll_sub_agent_completion: manager 不存在") + return + + max_wait_time = 3600 # 最多等待1小时 + start_wait = time.time() + + debug_log(f"[SubAgent] 开始后台轮询,conversation_id={conversation_id}, username={username}") + + # 创建 sender 函数,用于发送 socket 事件 + def sender(event_type, data): + try: + socketio.emit(event_type, data, room=f"user_{username}") + debug_log(f"[SubAgent] 发送事件: {event_type}") + except Exception as e: + debug_log(f"[SubAgent] 发送事件失败: {event_type}, 错误: {e}") + + while (time.time() - start_wait) < max_wait_time: + await asyncio.sleep(5) + debug_log(f"[SubAgent] 轮询检查...") + + # 检查停止标志 + client_stop_info = get_stop_flag(client_sid, username) + if client_stop_info: + stop_requested = client_stop_info.get('stop', False) if isinstance(client_stop_info, dict) else client_stop_info + if stop_requested: + debug_log("[SubAgent] 用户请求停止,终止轮询") + break + + updates = manager.poll_updates() + debug_log(f"[SubAgent] poll_updates 返回 {len(updates)} 个更新") + + for update in updates: + agent_id = update.get("agent_id") + summary = update.get("summary") + result_summary = update.get("result_summary") or update.get("message", "") + deliverables_dir = update.get("deliverables_dir", "") + status = update.get("status") + + debug_log(f"[SubAgent] 子智能体{agent_id}完成,状态: {status}") + + # 构建 user 消息 + user_message = f"""子智能体{agent_id} ({summary}) 已完成任务。 + +{result_summary} + +交付目录:{deliverables_dir}""" + + debug_log(f"[SubAgent] 准备直接调用 process_message_task") + debug_log(f"[SubAgent] 消息内容: {user_message[:100]}...") + + try: + sender('user_message', { + 'message': user_message, + 'conversation_id': conversation_id + }) + # 直接在当前事件循环中处理,避免嵌套事件循环 + entry = get_stop_flag(client_sid, username) + if not isinstance(entry, dict): + entry = {'stop': False, 'task': None, 'terminal': None} + entry['stop'] = False + task = asyncio.create_task(handle_task_with_sender( + terminal=web_terminal, + workspace=workspace, + message=user_message, + images=[], + sender=sender, + client_sid=client_sid, + username=username, + videos=[] + )) + entry['task'] = task + entry['terminal'] = web_terminal + set_stop_flag(client_sid, username, entry) + await task + debug_log(f"[SubAgent] process_message_task 调用成功") + except Exception as e: + debug_log(f"[SubAgent] process_message_task 失败: {e}") + import traceback + debug_log(f"[SubAgent] 错误堆栈: {traceback.format_exc()}") + finally: + clear_stop_flag(client_sid, username) + + return # 只处理第一个完成的子智能体 + + # 检查是否还有运行中的任务 + running_tasks = [ + task for task in manager.tasks.values() + if task.get("status") not in {"completed", "failed", "timeout", "terminated"} + and task.get("run_in_background") + and task.get("conversation_id") == conversation_id + ] + + debug_log(f"[SubAgent] 当前还有 {len(running_tasks)} 个运行中的任务") + + if not running_tasks: + debug_log("[SubAgent] 所有子智能体已完成") + # 若状态已提前被更新为终态(poll_updates 返回空),补发完成提示 + completed_tasks = [ + task for task in manager.tasks.values() + if task.get("status") in {"completed", "failed", "timeout", "terminated"} + and task.get("run_in_background") + and task.get("conversation_id") == conversation_id + ] + if completed_tasks: + completed_tasks.sort( + key=lambda item: item.get("updated_at") or item.get("created_at") or 0, + reverse=True + ) + task = completed_tasks[0] + agent_id = task.get("agent_id") + summary = task.get("summary") or "" + final_result = task.get("final_result") or {} + result_summary = ( + final_result.get("message") + or final_result.get("result_summary") + or final_result.get("system_message") + or "" + ) + deliverables_dir = final_result.get("deliverables_dir") or task.get("deliverables_dir") or "" + status = final_result.get("status") or task.get("status") + debug_log(f"[SubAgent] 补发完成提示: task={task.get('task_id')} status={status}") + + user_message = f"""子智能体{agent_id} ({summary}) 已完成任务。 + +{result_summary} + +交付目录:{deliverables_dir}""" + + try: + sender('user_message', { + 'message': user_message, + 'conversation_id': conversation_id + }) + entry = get_stop_flag(client_sid, username) + if not isinstance(entry, dict): + entry = {'stop': False, 'task': None, 'terminal': None} + entry['stop'] = False + task_handle = asyncio.create_task(handle_task_with_sender( + terminal=web_terminal, + workspace=workspace, + message=user_message, + images=[], + sender=sender, + client_sid=client_sid, + username=username, + videos=[] + )) + entry['task'] = task_handle + entry['terminal'] = web_terminal + set_stop_flag(client_sid, username, entry) + await task_handle + except Exception as e: + debug_log(f"[SubAgent] 补发完成提示失败: {e}") + import traceback + debug_log(f"[SubAgent] 错误堆栈: {traceback.format_exc()}") + finally: + clear_stop_flag(client_sid, username) + break + + debug_log("[SubAgent] 后台轮询结束") + async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspace, message, images, sender, client_sid, username: str, videos=None): """处理任务并发送消息 - 集成token统计版本""" + from .extensions import socketio + web_terminal = terminal conversation_id = getattr(web_terminal.context_manager, "current_conversation_id", None) videos = videos or [] @@ -140,17 +310,25 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac if not isinstance(data, dict): raw_sender(event_type, data) return - payload = data + payload = dict(data) + current_conv = conversation_id or getattr(web_terminal.context_manager, "current_conversation_id", None) + + # 为所有事件添加 conversation_id,确保前端能正确匹配 + if current_conv and event_type not in {"connect", "disconnect", "system_ready"}: + payload.setdefault("conversation_id", current_conv) + + # 调试信息:记录关键事件 + if event_type in {"user_message", "ai_message_start", "text_start", "text_chunk", "tool_preparing"}: + debug_log(f"[SENDER] 发送事件: {event_type}, conversation_id={current_conv}, data_keys={list(payload.keys())}") + + # 为关键事件添加额外的标识信息 if event_type in {"error", "quota_exceeded", "task_stopped", "task_complete"}: - payload = dict(data) - current_conv = conversation_id or getattr(web_terminal.context_manager, "current_conversation_id", None) - if current_conv: - payload.setdefault("conversation_id", current_conv) task_id = getattr(web_terminal, "task_id", None) or client_sid if task_id: payload.setdefault("task_id", task_id) if client_sid: payload.setdefault("client_sid", client_sid) + raw_sender(event_type, payload) # 如果是思考模式,重置状态 @@ -750,9 +928,48 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac debug_log(f" 累积响应: {len(accumulated_response)} 字符") debug_log(f"{'='*40}\n") - # 发送完成事件 + # 检查是否有后台运行的子智能体 + manager = getattr(web_terminal, "sub_agent_manager", None) + has_running_sub_agents = False + if manager: + running_tasks = [ + task for task in manager.tasks.values() + if task.get("status") not in {"completed", "failed", "timeout", "terminated"} + and task.get("run_in_background") + and task.get("conversation_id") == conversation_id + ] + + if running_tasks: + has_running_sub_agents = True + debug_log(f"[SubAgent] 检测到 {len(running_tasks)} 个后台子智能体运行中,通知前端等待") + # 先通知前端:有子智能体在运行,保持等待状态 + sender('sub_agent_waiting', { + 'count': len(running_tasks), + 'tasks': [{'agent_id': t.get('agent_id'), 'summary': t.get('summary')} for t in running_tasks] + }) + + # 启动后台任务来轮询子智能体完成 + def run_poll(): + import asyncio + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(poll_sub_agent_completion( + web_terminal=web_terminal, + workspace=workspace, + conversation_id=conversation_id, + client_sid=client_sid, + username=username + )) + finally: + loop.close() + + socketio.start_background_task(run_poll) + + # 发送完成事件(如果有子智能体在运行,前端会保持等待状态) sender('task_complete', { 'total_iterations': total_iterations, 'total_tool_calls': total_tool_calls, - 'auto_fix_attempts': auto_fix_attempts + 'auto_fix_attempts': auto_fix_attempts, + 'has_running_sub_agents': has_running_sub_agents }) diff --git a/server/chat_flow_task_support.py b/server/chat_flow_task_support.py index d30f261..f176222 100644 --- a/server/chat_flow_task_support.py +++ b/server/chat_flow_task_support.py @@ -10,20 +10,37 @@ async def process_sub_agent_updates(*, messages: List[Dict], inline: bool = Fals manager = getattr(web_terminal, "sub_agent_manager", None) if not manager: return + + # 获取已通知的任务集合 + if not hasattr(web_terminal, '_announced_sub_agent_tasks'): + web_terminal._announced_sub_agent_tasks = set() + try: updates = manager.poll_updates() debug_log(f"[SubAgent] poll inline={inline} updates={len(updates)}") except Exception as exc: debug_log(f"子智能体状态检查失败: {exc}") return + for update in updates: + task_id = update.get("task_id") + + # 检查是否已经通知过这个任务 + if task_id and task_id in web_terminal._announced_sub_agent_tasks: + debug_log(f"[SubAgent] 任务 {task_id} 已通知过,跳过") + continue + message = update.get("system_message") if not message: continue - task_id = update.get("task_id") + debug_log(f"[SubAgent] update task={task_id} inline={inline} msg={message}") - web_terminal._record_sub_agent_message(message, task_id, inline=inline) - debug_log(f"[SubAgent] recorded task={task_id}, 计算插入位置") + + # 标记任务已通知 + if task_id: + web_terminal._announced_sub_agent_tasks.add(task_id) + + debug_log(f"[SubAgent] 计算插入位置") insert_index = len(messages) if after_tool_call_id: diff --git a/server/state.py b/server/state.py index e22898f..aae1e39 100644 --- a/server/state.py +++ b/server/state.py @@ -25,6 +25,7 @@ connection_users: Dict[str, str] = {} RECENT_UPLOAD_EVENT_LIMIT = 150 RECENT_UPLOAD_FEED_LIMIT = 60 stop_flags: Dict[str, Dict[str, Any]] = {} +active_polling_tasks: Dict[str, bool] = {} # conversation_id -> is_polling # 监控/限流/用量 MONITOR_FILE_TOOLS = {'append_to_file', 'modify_file', 'write_file', 'edit_file'} diff --git a/static/debug-theme.html b/static/debug-theme.html new file mode 100644 index 0000000..894eb47 --- /dev/null +++ b/static/debug-theme.html @@ -0,0 +1,94 @@ + + + + Theme Debug + + + +

请在浏览器中打开主应用,然后查看控制台输出。

+

或者将以下代码复制到浏览器控制台运行:

+
+console.log('=== Theme Debug Info ===');
+console.log('document.documentElement.getAttribute("data-theme"):', document.documentElement.getAttribute('data-theme'));
+console.log('document.body.getAttribute("data-theme"):', document.body.getAttribute('data-theme'));
+console.log('document.documentElement.dataset.theme:', document.documentElement.dataset.theme);
+console.log('document.body.dataset.theme:', document.body.dataset.theme);
+
+const topbarTitle = document.querySelector('.mobile-topbar-title');
+if (topbarTitle) {
+    const styles = window.getComputedStyle(topbarTitle);
+    console.log('=== .mobile-topbar-title ===');
+    console.log('color:', styles.color);
+    console.log('text-shadow:', styles.textShadow);
+}
+
+const topbarSelector = document.querySelector('.mobile-topbar-selector');
+if (topbarSelector) {
+    const styles = window.getComputedStyle(topbarSelector);
+    console.log('=== .mobile-topbar-selector ===');
+    console.log('color:', styles.color);
+}
+
+const quickMenu = document.querySelector('.quick-menu');
+if (quickMenu) {
+    const styles = window.getComputedStyle(quickMenu);
+    console.log('=== .quick-menu ===');
+    console.log('background:', styles.background);
+    console.log('background-color:', styles.backgroundColor);
+}
+
+const menuEntry = document.querySelector('.menu-entry');
+if (menuEntry) {
+    const styles = window.getComputedStyle(menuEntry);
+    console.log('=== .menu-entry ===');
+    console.log('color:', styles.color);
+}
+
+console.log('=== End Debug Info ===');
+    
+ + diff --git a/static/src/app/methods/conversation.ts b/static/src/app/methods/conversation.ts index 034c617..a33572e 100644 --- a/static/src/app/methods/conversation.ts +++ b/static/src/app/methods/conversation.ts @@ -4,6 +4,12 @@ import { debugLog, traceLog } from './common'; export const conversationMethods = { // 完整重置所有状态 resetAllStates(reason = 'unspecified', options: { preserveMonitorWindows?: boolean } = {}) { + // 如果正在等待子智能体完成,不重置任务状态 + if (this.waitingForSubAgent) { + debugLog('跳过状态重置:正在等待子智能体完成', { reason }); + return; + } + debugLog('重置所有前端状态', { reason, conversationId: this.currentConversationId }); this.logMessageState('resetAllStates:before-cleanup', { reason }); this.fileHideContextMenu(); @@ -173,6 +179,43 @@ export const conversationMethods = { return; } + // 有任务或后台子智能体运行时,提示用户确认切换 + try { + const { useTaskStore } = await import('../../stores/task'); + const taskStore = useTaskStore(); + const hasActiveTask = taskStore.hasActiveTask || this.taskInProgress || this.waitingForSubAgent; + if (hasActiveTask) { + const confirmed = await this.confirmAction({ + title: '切换对话', + message: this.waitingForSubAgent + ? '后台子智能体正在运行,切换对话后将不会自动接收完成提示。确定要切换吗?' + : '当前有任务正在执行,切换对话后任务会停止。确定要切换吗?', + confirmText: '切换', + cancelText: '取消' + }); + if (!confirmed) { + this.suppressTitleTyping = false; + this.titleReady = true; + return; + } + + if (taskStore.hasActiveTask) { + await taskStore.cancelTask(); + taskStore.clearTask(); + } + + if (this.waitingForSubAgent && !taskStore.hasActiveTask) { + this.waitingForSubAgent = false; + } + + this.streamingMessage = false; + this.taskInProgress = false; + this.stopRequested = false; + } + } catch (error) { + console.error('[切换对话] 检查/停止任务失败:', error); + } + try { // 1. 调用加载API const response = await fetch(`/api/conversations/${conversationId}/load`, { @@ -257,13 +300,15 @@ export const conversationMethods = { const { useTaskStore } = await import('../../stores/task'); const taskStore = useTaskStore(); - if (taskStore.hasActiveTask) { + if (taskStore.hasActiveTask || this.taskInProgress || this.waitingForSubAgent) { hasActiveTask = true; // 显示提示 const confirmed = await this.confirmAction({ title: '创建新对话', - message: '当前有任务正在执行,创建新对话后任务会停止。确定要创建吗?', + message: this.waitingForSubAgent + ? '后台子智能体正在运行,创建新对话后将不会自动接收完成提示。确定要创建吗?' + : '当前有任务正在执行,创建新对话后任务会停止。确定要创建吗?', confirmText: '创建', cancelText: '取消' }); @@ -275,13 +320,18 @@ export const conversationMethods = { // 用户确认,停止任务 debugLog('[创建新对话] 用户确认,正在停止任务...'); - await taskStore.cancelTask(); - taskStore.clearTask(); + if (taskStore.hasActiveTask) { + await taskStore.cancelTask(); + taskStore.clearTask(); + } // 重置任务相关状态 this.streamingMessage = false; this.taskInProgress = false; this.stopRequested = false; + if (this.waitingForSubAgent && !taskStore.hasActiveTask) { + this.waitingForSubAgent = false; + } debugLog('[创建新对话] 任务已停止'); } diff --git a/static/src/app/methods/taskPolling.ts b/static/src/app/methods/taskPolling.ts index 82cccd0..091c53b 100644 --- a/static/src/app/methods/taskPolling.ts +++ b/static/src/app/methods/taskPolling.ts @@ -100,6 +100,10 @@ export const taskPollingMethods = { this.handleConversationResolved(eventData, eventIdx); break; + case 'user_message': + this.handleUserMessage(eventData, eventIdx); + break; + default: debugLog(`[TaskPolling] 未知事件类型: ${eventType}`); } @@ -110,6 +114,9 @@ export const taskPollingMethods = { handleAiMessageStart(data: any, eventIdx: number) { debugLog('[TaskPolling] AI消息开始, idx:', eventIdx); + if (this.waitingForSubAgent) { + this.waitingForSubAgent = false; + } // 检查是否已经有 assistant 消息(刷新恢复的情况) const lastMessage = this.messages[this.messages.length - 1]; @@ -516,10 +523,21 @@ export const taskPollingMethods = { }, handleTaskComplete(data: any) { - debugLog('[TaskPolling] 任务完成'); + const hasRunningSubAgents = !!data?.has_running_sub_agents; + if (hasRunningSubAgents) { + debugLog('[TaskPolling] 任务完成,但仍有后台子智能体运行'); + } else { + debugLog('[TaskPolling] 任务完成'); + } this.streamingMessage = false; - this.taskInProgress = false; this.stopRequested = false; + if (hasRunningSubAgents) { + this.taskInProgress = true; + this.waitingForSubAgent = true; + } else { + this.taskInProgress = false; + this.waitingForSubAgent = false; + } this.$forceUpdate(); // 停止轮询 @@ -538,6 +556,20 @@ export const taskPollingMethods = { }, 500); }, + handleUserMessage(data: any) { + const message = (data?.message || data?.content || '').trim(); + if (!message) { + return; + } + debugLog('[TaskPolling] 收到用户消息事件'); + this.chatAddUserMessage(message, data?.images || [], data?.videos || []); + this.taskInProgress = true; + this.streamingMessage = false; + this.stopRequested = false; + this.$forceUpdate(); + this.conditionalScrollToBottom(); + }, + handleTaskError(data: any) { console.error('[TaskPolling] 任务错误:', data.message); this.uiPushToast({ diff --git a/static/src/app/state.ts b/static/src/app/state.ts index 973fcea..8279f53 100644 --- a/static/src/app/state.ts +++ b/static/src/app/state.ts @@ -9,6 +9,8 @@ export function dataState() { // 轮询模式标志(禁用 WebSocket 事件处理) usePollingMode: true, + // 后台子智能体等待状态 + waitingForSubAgent: false, // 工具状态跟踪 preparingTools: new Map(), diff --git a/static/src/composables/useLegacySocket.ts b/static/src/composables/useLegacySocket.ts index d27e103..d591cd1 100644 --- a/static/src/composables/useLegacySocket.ts +++ b/static/src/composables/useLegacySocket.ts @@ -860,13 +860,54 @@ export async function initializeLegacySocket(ctx: any) { } }); + // 用户消息(后台子智能体完成后自动触发) + ctx.socket.on('user_message', (data) => { + if (ctx.usePollingMode && !ctx.waitingForSubAgent) { + return; + } + if (!data) { + return; + } + if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { + socketLog('跳过user_message(对话不匹配)', data.conversation_id); + return; + } + const message = (data.message || data.content || '').trim(); + if (!message) { + return; + } + ctx.chatAddUserMessage(message, data.images || [], data.videos || []); + ctx.taskInProgress = true; + ctx.streamingMessage = false; + ctx.stopRequested = false; + ctx.$forceUpdate(); + ctx.conditionalScrollToBottom(); + }); + + // AI消息开始 ctx.socket.on('ai_message_start', () => { // 轮询模式下跳过 WebSocket 事件 - if (ctx.usePollingMode) { + if (ctx.usePollingMode && !ctx.waitingForSubAgent) { socketLog('AI消息开始 (轮询模式,跳过)'); return; } + console.log('[DEBUG] 收到 ai_message_start 事件:', { + currentConversationId: ctx.currentConversationId, + messagesCount: ctx.messages.length, + taskInProgress: ctx.taskInProgress, + streamingMessage: ctx.streamingMessage, + waitingForSubAgent: ctx.waitingForSubAgent + }); + + // 清除等待子智能体的标志(轮询模式下保留,避免中断后续 socket 流) + if (ctx.waitingForSubAgent && !ctx.usePollingMode) { + console.log('[DEBUG] 清除 waitingForSubAgent 标志'); + ctx.waitingForSubAgent = false; + ctx.taskInProgress = false; + ctx.stopRequested = false; + } + socketLog('AI消息开始'); logStreamingDebug('socket:ai_message_start'); finalizeStreamingText({ force: true }); @@ -882,12 +923,18 @@ export async function initializeLegacySocket(ctx: any) { ctx.stopRequested = false; ctx.streamingMessage = true; // 确保设置为流式状态 ctx.scrollToBottom(); + console.log('[DEBUG] ai_message_start 处理完成,当前状态:', { + messagesCount: ctx.messages.length, + currentMessageIndex: ctx.currentMessageIndex, + taskInProgress: ctx.taskInProgress, + streamingMessage: ctx.streamingMessage + }); }); // 思考流开始 ctx.socket.on('thinking_start', () => { // 轮询模式下跳过 WebSocket 事件 - if (ctx.usePollingMode) { + if (ctx.usePollingMode && !ctx.waitingForSubAgent) { socketLog('思考开始 (轮询模式,跳过)'); return; } @@ -913,7 +960,7 @@ export async function initializeLegacySocket(ctx: any) { // 思考内容块 ctx.socket.on('thinking_chunk', (data) => { // 轮询模式下跳过 WebSocket 事件 - if (ctx.usePollingMode) { + if (ctx.usePollingMode && !ctx.waitingForSubAgent) { return; } if (streamingState.ignoreThinking) { @@ -935,7 +982,7 @@ export async function initializeLegacySocket(ctx: any) { // 思考结束 ctx.socket.on('thinking_end', (data) => { // 轮询模式下跳过 WebSocket 事件 - if (ctx.usePollingMode) { + if (ctx.usePollingMode && !ctx.waitingForSubAgent) { socketLog('思考结束 (轮询模式,跳过)'); return; } @@ -960,10 +1007,15 @@ export async function initializeLegacySocket(ctx: any) { // 文本流开始 ctx.socket.on('text_start', () => { // 轮询模式下跳过 WebSocket 事件 - if (ctx.usePollingMode) { + if (ctx.usePollingMode && !ctx.waitingForSubAgent) { socketLog('文本开始 (轮询模式,跳过)'); return; } + console.log('[DEBUG] 收到 text_start 事件:', { + currentConversationId: ctx.currentConversationId, + messagesCount: ctx.messages.length, + currentMessageIndex: ctx.currentMessageIndex + }); socketLog('文本开始'); logStreamingDebug('socket:text_start'); finalizeStreamingText({ force: true }); @@ -974,14 +1026,21 @@ export async function initializeLegacySocket(ctx: any) { streamingState.activeTextAction = action || ensureActiveTextAction(); ensureActiveMessageBinding(); ctx.$forceUpdate(); + console.log('[DEBUG] text_start 处理完成'); }); // 文本内容块 ctx.socket.on('text_chunk', (data) => { // 轮询模式下跳过 WebSocket 事件 - if (ctx.usePollingMode) { + if (ctx.usePollingMode && !ctx.waitingForSubAgent) { return; } + console.log('[DEBUG] 收到 text_chunk 事件:', { + conversation_id: data?.conversation_id, + currentConversationId: ctx.currentConversationId, + chunkLength: (data?.content || '').length, + index: data?.index + }); logStreamingDebug('socket:text_chunk', { index: data?.index ?? null, elapsed: data?.elapsed ?? null, @@ -1016,7 +1075,7 @@ export async function initializeLegacySocket(ctx: any) { // 文本结束 ctx.socket.on('text_end', (data) => { // 轮询模式下跳过 WebSocket 事件 - if (ctx.usePollingMode) { + if (ctx.usePollingMode && !ctx.waitingForSubAgent) { socketLog('文本结束 (轮询模式,跳过)'); return; } @@ -1070,12 +1129,19 @@ export async function initializeLegacySocket(ctx: any) { // 工具准备中事件 - 实时显示 ctx.socket.on('tool_preparing', (data) => { // 轮询模式下跳过 WebSocket 事件 - if (ctx.usePollingMode) { + if (ctx.usePollingMode && !ctx.waitingForSubAgent) { return; } if (ctx.dropToolEvents) { return; } + console.log('[DEBUG] 收到 tool_preparing 事件:', { + name: data.name, + id: data.id, + conversation_id: data?.conversation_id, + currentConversationId: ctx.currentConversationId, + match: data?.conversation_id === ctx.currentConversationId + }); socketLog('工具准备中:', data.name); if (typeof console !== 'undefined' && console.debug) { console.debug('[tool_intent] preparing', { @@ -1086,11 +1152,13 @@ export async function initializeLegacySocket(ctx: any) { }); } if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { + console.log('[DEBUG] 跳过 tool_preparing (对话不匹配)'); socketLog('跳过tool_preparing(对话不匹配)', data.conversation_id); return; } const msg = ctx.chatEnsureAssistantMessage(); if (!msg) { + console.log('[DEBUG] tool_preparing: 无法获取assistant消息'); return; } if (msg.awaitingFirstContent) { @@ -1121,6 +1189,7 @@ export async function initializeLegacySocket(ctx: any) { if (data.intent) { startIntentTyping(action, data.intent); } + console.log('[DEBUG] tool_preparing 处理完成,action已添加'); ctx.$forceUpdate(); ctx.conditionalScrollToBottom(); if (ctx.monitorPreviewTool) { @@ -1131,7 +1200,7 @@ export async function initializeLegacySocket(ctx: any) { // 工具意图(流式增量)事件 ctx.socket.on('tool_intent', (data) => { // 轮询模式下跳过 WebSocket 事件 - if (ctx.usePollingMode) { + if (ctx.usePollingMode && !ctx.waitingForSubAgent) { return; } if (ctx.dropToolEvents) { @@ -1160,7 +1229,7 @@ export async function initializeLegacySocket(ctx: any) { // 工具状态更新事件 - 实时显示详细状态 ctx.socket.on('tool_status', (data) => { // 轮询模式下跳过 WebSocket 事件 - if (ctx.usePollingMode) { + if (ctx.usePollingMode && !ctx.waitingForSubAgent) { return; } if (ctx.dropToolEvents) { @@ -1196,7 +1265,7 @@ export async function initializeLegacySocket(ctx: any) { // 工具开始(从准备转为执行) ctx.socket.on('tool_start', (data) => { // 轮询模式下跳过 WebSocket 事件 - if (ctx.usePollingMode) { + if (ctx.usePollingMode && !ctx.waitingForSubAgent) { return; } if (ctx.dropToolEvents) { @@ -1268,7 +1337,7 @@ export async function initializeLegacySocket(ctx: any) { // 更新action(工具完成) ctx.socket.on('update_action', (data) => { // 轮询模式下跳过 WebSocket 事件 - if (ctx.usePollingMode) { + if (ctx.usePollingMode && !ctx.waitingForSubAgent) { return; } if (ctx.dropToolEvents) { @@ -1428,19 +1497,75 @@ export async function initializeLegacySocket(ctx: any) { // 任务完成(重点:更新Token统计) ctx.socket.on('task_complete', (data) => { + console.log('[DEBUG] 收到 task_complete 事件:', data); + console.log('[DEBUG] 当前状态 (before task_complete):', { + taskInProgress: ctx.taskInProgress, + streamingMessage: ctx.streamingMessage, + has_running_sub_agents: data.has_running_sub_agents + }); socketLog('任务完成', data); - ctx.taskInProgress = false; - ctx.scheduleResetAfterTask('socket:task_complete', { preserveMonitorWindows: true }); + + // 如果有运行中的子智能体,不重置任务状态 + if (!data.has_running_sub_agents) { + console.log('[DEBUG] 没有运行中的子智能体,重置任务状态'); + if (ctx.waitingForSubAgent) { + ctx.waitingForSubAgent = false; + } + ctx.taskInProgress = false; + ctx.scheduleResetAfterTask('socket:task_complete', { preserveMonitorWindows: true }); + } else { + console.log('[DEBUG] 有运行中的子智能体,保持任务状态'); + } + + console.log('[DEBUG] 当前状态 (after task_complete):', { + taskInProgress: ctx.taskInProgress, + streamingMessage: ctx.streamingMessage + }); resetPendingToolEvents(); // 任务完成后立即更新Token统计(关键修复) if (ctx.currentConversationId) { ctx.updateCurrentContextTokens(); - ctx.fetchConversationTokenStatistics(); + ctx.fetchConversationTokenStatistics(); } }); + // 子智能体等待状态 + ctx.socket.on('sub_agent_waiting', (data) => { + console.log('[DEBUG] 收到 sub_agent_waiting 事件:', data); + console.log('[DEBUG] 当前状态 (before sub_agent_waiting):', { + taskInProgress: ctx.taskInProgress, + streamingMessage: ctx.streamingMessage, + stopRequested: ctx.stopRequested + }); + socketLog('等待子智能体完成:', data); + + // 设置标志:有子智能体在运行,阻止状态重置 + ctx.waitingForSubAgent = true; + + // 保持任务进行中状态 + ctx.taskInProgress = true; + ctx.streamingMessage = false; + ctx.stopRequested = false; + + console.log('[DEBUG] 当前状态 (after sub_agent_waiting):', { + taskInProgress: ctx.taskInProgress, + streamingMessage: ctx.streamingMessage, + stopRequested: ctx.stopRequested, + waitingForSubAgent: ctx.waitingForSubAgent + }); + + // 显示等待提示 + if (typeof ctx.appendSystemAction === 'function') { + const taskList = data.tasks.map((t: any) => `子智能体${t.agent_id} (${t.summary || '无描述'})`).join('、'); + ctx.appendSystemAction(`⏳ 等待 ${data.count} 个后台子智能体完成:${taskList}`); + } + + ctx.$forceUpdate(); + console.log('[DEBUG] sub_agent_waiting 处理完成'); + }); + // 聚焦文件更新 ctx.socket.on('focused_files_update', (data) => { ctx.focusSetFiles(data || {}); diff --git a/static/src/styles/components/panels/_left-panel.scss b/static/src/styles/components/panels/_left-panel.scss index 2c0b28b..8545486 100644 --- a/static/src/styles/components/panels/_left-panel.scss +++ b/static/src/styles/components/panels/_left-panel.scss @@ -397,11 +397,21 @@ background: rgba(255, 255, 255, 0.92); cursor: pointer; transition: border-color 0.2s ease, box-shadow 0.2s ease; + + [data-theme="dark"] & { + background: rgba(30, 30, 30, 0.6); + border-color: rgba(255, 255, 255, 0.1); + } } .sub-agent-card:hover { border-color: #6c5ce7; box-shadow: 0 6px 16px rgba(108, 92, 231, 0.15); + + [data-theme="dark"] & { + border-color: #8b7ce7; + box-shadow: 0 6px 16px rgba(139, 124, 231, 0.25); + } } .sub-agent-header {