chore: snapshot current changes

This commit is contained in:
JOJO 2026-03-10 23:48:40 +08:00
parent e395c82a9f
commit b68dee9d98
51 changed files with 6743 additions and 189 deletions

View File

@ -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"
}
]
}

View File

@ -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": "用户只是简单地打了个招呼\"你好\"。我应该礼貌地回应,并询问用户有什么可以帮忙的。"
}
]
}

22
.easyagent/index.json Normal file
View File

@ -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"
}
}

View File

@ -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
## 总结
核心功能已经实现完成,包括:
- ✅ 批处理模式
- ✅ 工具定义和执行
- ✅ 子智能体管理
- ✅ 对话记录保存
- ✅ 统计信息收集
- ✅ 兜底机制
还需要完成:
- ⏳ 后台任务轮询
- ⏳ 对话状态管理
- ⏳ 前端集成
- ⏳ 测试和调试
整体架构清晰,代码结构合理,可以开始测试和完善了。

117
SUB_AGENT_FIXES_SUMMARY.md Normal file
View File

@ -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. 测试完整流程

View File

@ -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. **对话记录保留**:便于调试和审计

202
SUB_AGENT_TESTING.md Normal file
View File

@ -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. 完善文档和示例

View File

@ -682,39 +682,71 @@ class MainTerminalToolsDefinitionMixin:
"type": "function", "type": "function",
"function": { "function": {
"name": "create_sub_agent", "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": { "parameters": {
"type": "object", "type": "object",
"properties": self._inject_intent({ "properties": self._inject_intent({
"agent_id": {"type": "integer", "description": "子智能体代号1~5"}, "agent_id": {
"summary": {"type": "string", "description": "任务摘要,简要说明目标"}, "type": "integer",
"task": {"type": "string", "description": "任务详细要求"}, "description": "子智能体编号1-99用于标识和管理。同一对话中每个编号只能使用一次。建议按顺序分配1、2、3..."
"target_dir": {"type": "string", "description": "项目下用于接收交付的相对目录"},
"reference_files": {
"type": "array",
"description": "提供给子智能体的参考文件列表相对路径禁止在summary和task中直接告知子智能体引用图片的路径必须使用本参数提供",
"items": {"type": "string"},
"maxItems": 10
}, },
"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", "type": "function",
"function": { "function": {
"name": "wait_sub_agent", "name": "terminate_sub_agent",
"description": "等待指定子智能体任务结束(或超时)。任务完成后会返回交付目录,并将结果复制到指定的项目文件夹。调用时 `timeout_seconds` 应不少于对应子智能体的 `timeout_seconds`,否则可能提前终止等待。", "description": "强制终止正在运行的子智能体。用于:\n1. 任务不再需要\n2. 子智能体陷入死循环或执行错误\n3. 用户要求停止\n\n终止后无法恢复,但已生成的部分结果会保留在交付目录",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": self._inject_intent({ "properties": self._inject_intent({
"task_id": {"type": "string", "description": "子智能体任务ID"}, "agent_id": {
"agent_id": {"type": "integer", "description": "子智能体代号(可选,用于缺省 task_id 的情况)"}, "type": "integer",
"timeout_seconds": {"type": "integer", "description": "本次等待的超时时长(秒)"} "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"]
} }
} }
}, },

View File

@ -625,31 +625,30 @@ class MainTerminalToolsExecutionMixin:
agent_id=arguments.get("agent_id"), agent_id=arguments.get("agent_id"),
summary=arguments.get("summary", ""), summary=arguments.get("summary", ""),
task=arguments.get("task", ""), task=arguments.get("task", ""),
target_dir=arguments.get("target_dir", ""), deliverables_dir=arguments.get("deliverables_dir", ""),
reference_files=arguments.get("reference_files", []), run_in_background=arguments.get("run_in_background", False),
timeout_seconds=arguments.get("timeout_seconds"), timeout_seconds=arguments.get("timeout_seconds"),
conversation_id=self.context_manager.current_conversation_id conversation_id=self.context_manager.current_conversation_id
) )
elif tool_name == "wait_sub_agent": # 如果不是后台运行,阻塞等待完成
wait_timeout = arguments.get("timeout_seconds") if not arguments.get("run_in_background", False) and result.get("success"):
if not wait_timeout: task_id = result.get("task_id")
task_ref = self.sub_agent_manager.lookup_task( wait_result = self.sub_agent_manager.wait_for_completion(
task_id=arguments.get("task_id"), task_id=task_id,
agent_id=arguments.get("agent_id") timeout_seconds=arguments.get("timeout_seconds")
) )
if task_ref: # 合并结果
wait_timeout = task_ref.get("timeout_seconds") result.update(wait_result)
result = self.sub_agent_manager.wait_for_completion(
task_id=arguments.get("task_id"), elif tool_name == "terminate_sub_agent":
agent_id=arguments.get("agent_id"), result = self.sub_agent_manager.terminate_sub_agent(
timeout_seconds=wait_timeout agent_id=arguments.get("agent_id")
) )
elif tool_name == "close_sub_agent": elif tool_name == "get_sub_agent_status":
result = self.sub_agent_manager.terminate_sub_agent( result = self.sub_agent_manager.get_sub_agent_status(
task_id=arguments.get("task_id"), agent_ids=arguments.get("agent_ids", [])
agent_id=arguments.get("agent_id")
) )
elif tool_name == "trigger_easter_egg": elif tool_name == "trigger_easter_egg":

33
easyagent/models.json Normal file
View File

@ -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
}
]
}

437
easyagent/package-lock.json generated Normal file
View File

@ -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"
}
}
}
}

20
easyagent/package.json Normal file
View File

@ -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"
}
}

View File

@ -0,0 +1,11 @@
你是 EasyAgent一个极简终端智能体。
目标:帮助用户完成开发任务,优先高信息密度输出。
输出限制:禁止使用 Markdownmd格式内容无法渲染必须使用纯文字格式输出。
- 当前时间:{current_time}
- 当前模型:{model_id}
- 工作区路径:{path}
- 系统信息:{system}
- 终端类型:{terminal}
- 权限:{allow_mode}
- Git{git}

View File

@ -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);
});

View File

@ -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 };

851
easyagent/src/cli/index.js Normal file
View File

@ -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');
}
}

133
easyagent/src/config.js Normal file
View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -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,
};

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

151
easyagent/src/ui/spinner.js Normal file
View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -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' : '',
};

View File

@ -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,
};

View File

@ -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 };

View File

@ -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 };

View File

@ -1,20 +1,16 @@
"""子智能体任务管理""" """子智能体任务管理(子进程模式)"""
import json import json
import shutil import subprocess
import time import time
import uuid import uuid
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Tuple, Any from typing import Dict, List, Optional, Any, Tuple
import httpx
from config import ( from config import (
OUTPUT_FORMATS, OUTPUT_FORMATS,
SUB_AGENT_DEFAULT_TIMEOUT, SUB_AGENT_DEFAULT_TIMEOUT,
SUB_AGENT_MAX_ACTIVE, SUB_AGENT_MAX_ACTIVE,
SUB_AGENT_PROJECT_RESULTS_DIR,
SUB_AGENT_SERVICE_BASE_URL,
SUB_AGENT_STATE_FILE, SUB_AGENT_STATE_FILE,
SUB_AGENT_STATUS_POLL_INTERVAL, SUB_AGENT_STATUS_POLL_INTERVAL,
SUB_AGENT_TASKS_BASE_DIR, SUB_AGENT_TASKS_BASE_DIR,
@ -33,23 +29,23 @@ TERMINAL_STATUSES = {"completed", "failed", "timeout"}
class SubAgentManager: class SubAgentManager:
"""负责主智能体与子智能体服务之间的任务调度。""" """负责主智能体与子智能体的任务调度(子进程模式)"""
def __init__(self, project_path: str, data_dir: str): def __init__(self, project_path: str, data_dir: str):
self.project_path = Path(project_path).resolve() self.project_path = Path(project_path).resolve()
self.data_dir = Path(data_dir).resolve() self.data_dir = Path(data_dir).resolve()
self.base_dir = Path(SUB_AGENT_TASKS_BASE_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.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.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.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.tasks: Dict[str, Dict] = {}
self.conversation_agents: Dict[str, List[int]] = {} self.conversation_agents: Dict[str, List[int]] = {}
self.processes: Dict[str, subprocess.Popen] = {} # task_id -> Popen对象
self._load_state() self._load_state()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -61,14 +57,14 @@ class SubAgentManager:
agent_id: int, agent_id: int,
summary: str, summary: str,
task: str, task: str,
target_dir: str, deliverables_dir: str,
reference_files: Optional[List[str]] = None,
timeout_seconds: Optional[int] = None, timeout_seconds: Optional[int] = None,
conversation_id: Optional[str] = None, conversation_id: Optional[str] = None,
run_in_background: bool = False,
model_key: Optional[str] = None,
) -> Dict: ) -> Dict:
"""创建子智能体任务并启动远端服务。""" """创建子智能体任务并启动子进程。"""
reference_files = reference_files or [] validation_error = self._validate_create_params(agent_id, summary, task, deliverables_dir)
validation_error = self._validate_create_params(agent_id, summary, task, target_dir)
if validation_error: if validation_error:
return {"success": False, "error": validation_error} return {"success": False, "error": validation_error}
@ -89,84 +85,92 @@ class SubAgentManager:
task_id = self._generate_task_id(agent_id) task_id = self._generate_task_id(agent_id)
task_root = self.base_dir / task_id task_root = self.base_dir / task_id
workspace_dir = task_root / "workspace" task_root.mkdir(parents=True, exist_ok=True)
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)}
# 解析deliverables_dir相对于project_path
try: try:
target_project_dir = self._ensure_project_subdir(target_dir) deliverables_path = self._resolve_deliverables_dir(deliverables_dir)
except ValueError as exc: except ValueError as exc:
return {"success": False, "error": str(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 timeout_seconds = timeout_seconds or SUB_AGENT_DEFAULT_TIMEOUT
payload = { cmd = [
"task_id": task_id, "node",
"agent_id": agent_id, str(self.easyagent_batch),
"summary": summary, "--workspace", str(self.project_path),
"task": task, "--task-file", str(task_file),
"target_project_dir": str(target_project_dir), "--system-prompt-file", str(system_prompt_file),
"workspace_dir": str(workspace_dir), "--output-file", str(output_file),
"references_dir": str(references_dir), "--stats-file", str(stats_file),
"deliverables_dir": str(deliverables_dir), "--agent-id", str(agent_id),
"timeout_seconds": timeout_seconds, "--timeout", str(timeout_seconds),
"parent_conversation_id": conversation_id, ]
"data_dir": str(self.data_dir), if model_key:
"reference_manifest": copied_refs, cmd.extend(["--model-key", model_key])
"conversation_storage_dir": str(self.sub_agent_conversations_dir),
}
service_response = self._call_service("POST", "/tasks", payload, timeout_seconds + 5) try:
if not service_response.get("success"): process = subprocess.Popen(
self._cleanup_task_folder(task_root) cmd,
return { stdout=subprocess.PIPE,
"success": False, stderr=subprocess.PIPE,
"error": service_response.get("error", "子智能体服务调用失败"), cwd=str(self.project_path),
"details": service_response, )
} 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_record = {
"task_id": task_id, "task_id": task_id,
"agent_id": agent_id, "agent_id": agent_id,
"summary": summary, "summary": summary,
"task": task, "task": task,
"status": status, "status": "running",
"target_project_dir": str(target_project_dir), "deliverables_dir": str(deliverables_path),
"references_dir": str(references_dir), "subagent_dir": str(subagent_dir),
"deliverables_dir": str(deliverables_dir),
"workspace_dir": str(workspace_dir),
"copied_references": copied_refs,
"timeout_seconds": timeout_seconds, "timeout_seconds": timeout_seconds,
"service_payload": payload,
"created_at": time.time(), "created_at": time.time(),
"conversation_id": conversation_id, "conversation_id": conversation_id,
"sub_conversation_id": sub_conversation_id, "run_in_background": run_in_background,
"parent_conversation_id": conversation_id, "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.tasks[task_id] = task_record
self.processes[task_id] = process
self._mark_agent_id_used(conversation_id, agent_id) self._mark_agent_id_used(conversation_id, agent_id)
self._save_state() 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}") print(f"{OUTPUT_FORMATS['info']} {message}")
return { return {
"success": True, "success": True,
"task_id": task_id, "task_id": task_id,
"agent_id": agent_id, "agent_id": agent_id,
"status": status, "status": "running",
"message": message, "message": message,
"deliverables_dir": str(deliverables_dir), "deliverables_dir": str(deliverables_path),
"copied_references": copied_refs, "run_in_background": run_in_background,
"sub_conversation_id": sub_conversation_id,
} }
def wait_for_completion( def wait_for_completion(
@ -188,32 +192,17 @@ class SubAgentManager:
timeout_seconds = timeout_seconds or task.get("timeout_seconds") or SUB_AGENT_DEFAULT_TIMEOUT timeout_seconds = timeout_seconds or task.get("timeout_seconds") or SUB_AGENT_DEFAULT_TIMEOUT
deadline = time.time() + timeout_seconds deadline = time.time() + timeout_seconds
last_payload: Optional[Dict] = None
while time.time() < deadline: while time.time() < deadline:
last_payload = self._call_service("GET", f"/tasks/{task['task_id']}", timeout=15) # 检查进程状态
status = last_payload.get("status") status_result = self._check_task_status(task)
if not last_payload.get("success") and status not in TERMINAL_STATUSES: if status_result["status"] in TERMINAL_STATUSES:
time.sleep(SUB_AGENT_STATUS_POLL_INTERVAL) return status_result
continue
if status in {"completed", "failed", "timeout", "terminated"}:
break
time.sleep(SUB_AGENT_STATUS_POLL_INTERVAL) 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": "无法获取子智能体状态"} return self._handle_timeout(task)
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
def terminate_sub_agent( def terminate_sub_agent(
self, self,
@ -227,27 +216,281 @@ class SubAgentManager:
return {"success": False, "error": "未找到对应的子智能体任务"} return {"success": False, "error": "未找到对应的子智能体任务"}
task_id = task["task_id"] task_id = task["task_id"]
response = self._call_service("POST", f"/tasks/{task_id}/terminate", timeout=10) process = self.processes.get(task_id)
response["task_id"] = task_id
if response.get("success"): 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["status"] = "terminated"
task["final_result"] = { task["final_result"] = {
"success": False, "success": False,
"status": "terminated", "status": "terminated",
"task_id": task_id, "task_id": task_id,
"agent_id": task.get("agent_id"), "agent_id": task.get("agent_id"),
"message": response.get("message") or "子智能体已被强制关闭。", "message": "子智能体已被强制关闭。",
} }
self._save_state() self._save_state()
if "system_message" not in response:
response["system_message"] = response.get("message") or "🛑 子智能体已被手动关闭。" return {
elif "system_message" not in response: "success": True,
response["system_message"] = response.get("message") "task_id": task_id,
return response "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): def _load_state(self):
if self.state_file.exists(): if self.state_file.exists():
try: try:
@ -382,12 +625,8 @@ class SubAgentManager:
state_changed = False state_changed = False
for task in pending_tasks: for task in pending_tasks:
payload = self._call_service("GET", f"/tasks/{task['task_id']}", timeout=10) result = self._check_task_status(task)
status = payload.get("status") if result["status"] in TERMINAL_STATUSES:
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) updates.append(result)
state_changed = True state_changed = True
@ -395,6 +634,57 @@ class SubAgentManager:
self._save_state() self._save_state()
return updates 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: 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}" url = f"{SUB_AGENT_SERVICE_BASE_URL.rstrip('/')}{path}"
try: try:
@ -591,15 +881,21 @@ class SubAgentManager:
"sub_conversation_id": task.get("sub_conversation_id"), "sub_conversation_id": task.get("sub_conversation_id"),
} }
# 运行中的任务尝试同步远端最新状态 # 运行中的任务检查进程状态
if snapshot["status"] not in TERMINAL_STATUSES: if snapshot["status"] not in TERMINAL_STATUSES and snapshot["status"] != "terminated":
remote = self._call_service("GET", f"/tasks/{task_id}", timeout=5) # 检查进程是否还在运行
if remote.get("success"): process = self.processes.get(task_id)
snapshot["status"] = remote.get("status", snapshot["status"]) if process:
snapshot["remote_message"] = remote.get("message") poll_result = process.poll()
snapshot["last_tool"] = remote.get("last_tool") if poll_result is not None:
task["last_tool"] = snapshot["last_tool"] # 进程已结束,检查输出
else: 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 {} final_result = task.get("final_result") or {}
snapshot["final_message"] = final_result.get("system_message") or final_result.get("message") snapshot["final_message"] = final_result.get("system_message") or final_result.get("message")

View File

@ -89,6 +89,7 @@ from .state import (
terminal_rooms, terminal_rooms,
connection_users, connection_users,
stop_flags, stop_flags,
active_polling_tasks,
get_stop_flag, get_stop_flag,
set_stop_flag, set_stop_flag,
clear_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_tool_loop import execute_tool_calls
from .chat_flow_stream_loop import run_streaming_attempts 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): async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspace, message, images, sender, client_sid, username: str, videos=None):
"""处理任务并发送消息 - 集成token统计版本""" """处理任务并发送消息 - 集成token统计版本"""
from .extensions import socketio
web_terminal = terminal web_terminal = terminal
conversation_id = getattr(web_terminal.context_manager, "current_conversation_id", None) conversation_id = getattr(web_terminal.context_manager, "current_conversation_id", None)
videos = videos or [] videos = videos or []
@ -140,17 +310,25 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac
if not isinstance(data, dict): if not isinstance(data, dict):
raw_sender(event_type, data) raw_sender(event_type, data)
return return
payload = data
if event_type in {"error", "quota_exceeded", "task_stopped", "task_complete"}:
payload = dict(data) payload = dict(data)
current_conv = conversation_id or getattr(web_terminal.context_manager, "current_conversation_id", None) current_conv = conversation_id or getattr(web_terminal.context_manager, "current_conversation_id", None)
if current_conv:
# 为所有事件添加 conversation_id确保前端能正确匹配
if current_conv and event_type not in {"connect", "disconnect", "system_ready"}:
payload.setdefault("conversation_id", current_conv) 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"}:
task_id = getattr(web_terminal, "task_id", None) or client_sid task_id = getattr(web_terminal, "task_id", None) or client_sid
if task_id: if task_id:
payload.setdefault("task_id", task_id) payload.setdefault("task_id", task_id)
if client_sid: if client_sid:
payload.setdefault("client_sid", client_sid) payload.setdefault("client_sid", client_sid)
raw_sender(event_type, payload) 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" 累积响应: {len(accumulated_response)} 字符")
debug_log(f"{'='*40}\n") 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', { sender('task_complete', {
'total_iterations': total_iterations, 'total_iterations': total_iterations,
'total_tool_calls': total_tool_calls, '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
}) })

View File

@ -10,20 +10,37 @@ async def process_sub_agent_updates(*, messages: List[Dict], inline: bool = Fals
manager = getattr(web_terminal, "sub_agent_manager", None) manager = getattr(web_terminal, "sub_agent_manager", None)
if not manager: if not manager:
return return
# 获取已通知的任务集合
if not hasattr(web_terminal, '_announced_sub_agent_tasks'):
web_terminal._announced_sub_agent_tasks = set()
try: try:
updates = manager.poll_updates() updates = manager.poll_updates()
debug_log(f"[SubAgent] poll inline={inline} updates={len(updates)}") debug_log(f"[SubAgent] poll inline={inline} updates={len(updates)}")
except Exception as exc: except Exception as exc:
debug_log(f"子智能体状态检查失败: {exc}") debug_log(f"子智能体状态检查失败: {exc}")
return return
for update in updates: 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") message = update.get("system_message")
if not message: if not message:
continue continue
task_id = update.get("task_id")
debug_log(f"[SubAgent] update task={task_id} inline={inline} msg={message}") 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) insert_index = len(messages)
if after_tool_call_id: if after_tool_call_id:

View File

@ -25,6 +25,7 @@ connection_users: Dict[str, str] = {}
RECENT_UPLOAD_EVENT_LIMIT = 150 RECENT_UPLOAD_EVENT_LIMIT = 150
RECENT_UPLOAD_FEED_LIMIT = 60 RECENT_UPLOAD_FEED_LIMIT = 60
stop_flags: Dict[str, Dict[str, Any]] = {} 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'} MONITOR_FILE_TOOLS = {'append_to_file', 'modify_file', 'write_file', 'edit_file'}

94
static/debug-theme.html Normal file
View File

@ -0,0 +1,94 @@
<!DOCTYPE html>
<html>
<head>
<title>Theme Debug</title>
<script>
window.addEventListener('DOMContentLoaded', () => {
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 ===');
});
</script>
</head>
<body>
<p>请在浏览器中打开主应用,然后查看控制台输出。</p>
<p>或者将以下代码复制到浏览器控制台运行:</p>
<pre>
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 ===');
</pre>
</body>
</html>

View File

@ -4,6 +4,12 @@ import { debugLog, traceLog } from './common';
export const conversationMethods = { export const conversationMethods = {
// 完整重置所有状态 // 完整重置所有状态
resetAllStates(reason = 'unspecified', options: { preserveMonitorWindows?: boolean } = {}) { resetAllStates(reason = 'unspecified', options: { preserveMonitorWindows?: boolean } = {}) {
// 如果正在等待子智能体完成,不重置任务状态
if (this.waitingForSubAgent) {
debugLog('跳过状态重置:正在等待子智能体完成', { reason });
return;
}
debugLog('重置所有前端状态', { reason, conversationId: this.currentConversationId }); debugLog('重置所有前端状态', { reason, conversationId: this.currentConversationId });
this.logMessageState('resetAllStates:before-cleanup', { reason }); this.logMessageState('resetAllStates:before-cleanup', { reason });
this.fileHideContextMenu(); this.fileHideContextMenu();
@ -173,6 +179,43 @@ export const conversationMethods = {
return; 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 { try {
// 1. 调用加载API // 1. 调用加载API
const response = await fetch(`/api/conversations/${conversationId}/load`, { const response = await fetch(`/api/conversations/${conversationId}/load`, {
@ -257,13 +300,15 @@ export const conversationMethods = {
const { useTaskStore } = await import('../../stores/task'); const { useTaskStore } = await import('../../stores/task');
const taskStore = useTaskStore(); const taskStore = useTaskStore();
if (taskStore.hasActiveTask) { if (taskStore.hasActiveTask || this.taskInProgress || this.waitingForSubAgent) {
hasActiveTask = true; hasActiveTask = true;
// 显示提示 // 显示提示
const confirmed = await this.confirmAction({ const confirmed = await this.confirmAction({
title: '创建新对话', title: '创建新对话',
message: '当前有任务正在执行,创建新对话后任务会停止。确定要创建吗?', message: this.waitingForSubAgent
? '后台子智能体正在运行,创建新对话后将不会自动接收完成提示。确定要创建吗?'
: '当前有任务正在执行,创建新对话后任务会停止。确定要创建吗?',
confirmText: '创建', confirmText: '创建',
cancelText: '取消' cancelText: '取消'
}); });
@ -275,13 +320,18 @@ export const conversationMethods = {
// 用户确认,停止任务 // 用户确认,停止任务
debugLog('[创建新对话] 用户确认,正在停止任务...'); debugLog('[创建新对话] 用户确认,正在停止任务...');
if (taskStore.hasActiveTask) {
await taskStore.cancelTask(); await taskStore.cancelTask();
taskStore.clearTask(); taskStore.clearTask();
}
// 重置任务相关状态 // 重置任务相关状态
this.streamingMessage = false; this.streamingMessage = false;
this.taskInProgress = false; this.taskInProgress = false;
this.stopRequested = false; this.stopRequested = false;
if (this.waitingForSubAgent && !taskStore.hasActiveTask) {
this.waitingForSubAgent = false;
}
debugLog('[创建新对话] 任务已停止'); debugLog('[创建新对话] 任务已停止');
} }

View File

@ -100,6 +100,10 @@ export const taskPollingMethods = {
this.handleConversationResolved(eventData, eventIdx); this.handleConversationResolved(eventData, eventIdx);
break; break;
case 'user_message':
this.handleUserMessage(eventData, eventIdx);
break;
default: default:
debugLog(`[TaskPolling] 未知事件类型: ${eventType}`); debugLog(`[TaskPolling] 未知事件类型: ${eventType}`);
} }
@ -110,6 +114,9 @@ export const taskPollingMethods = {
handleAiMessageStart(data: any, eventIdx: number) { handleAiMessageStart(data: any, eventIdx: number) {
debugLog('[TaskPolling] AI消息开始, idx:', eventIdx); debugLog('[TaskPolling] AI消息开始, idx:', eventIdx);
if (this.waitingForSubAgent) {
this.waitingForSubAgent = false;
}
// 检查是否已经有 assistant 消息(刷新恢复的情况) // 检查是否已经有 assistant 消息(刷新恢复的情况)
const lastMessage = this.messages[this.messages.length - 1]; const lastMessage = this.messages[this.messages.length - 1];
@ -516,10 +523,21 @@ export const taskPollingMethods = {
}, },
handleTaskComplete(data: any) { handleTaskComplete(data: any) {
const hasRunningSubAgents = !!data?.has_running_sub_agents;
if (hasRunningSubAgents) {
debugLog('[TaskPolling] 任务完成,但仍有后台子智能体运行');
} else {
debugLog('[TaskPolling] 任务完成'); debugLog('[TaskPolling] 任务完成');
}
this.streamingMessage = false; this.streamingMessage = false;
this.taskInProgress = false;
this.stopRequested = false; this.stopRequested = false;
if (hasRunningSubAgents) {
this.taskInProgress = true;
this.waitingForSubAgent = true;
} else {
this.taskInProgress = false;
this.waitingForSubAgent = false;
}
this.$forceUpdate(); this.$forceUpdate();
// 停止轮询 // 停止轮询
@ -538,6 +556,20 @@ export const taskPollingMethods = {
}, 500); }, 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) { handleTaskError(data: any) {
console.error('[TaskPolling] 任务错误:', data.message); console.error('[TaskPolling] 任务错误:', data.message);
this.uiPushToast({ this.uiPushToast({

View File

@ -9,6 +9,8 @@ export function dataState() {
// 轮询模式标志(禁用 WebSocket 事件处理) // 轮询模式标志(禁用 WebSocket 事件处理)
usePollingMode: true, usePollingMode: true,
// 后台子智能体等待状态
waitingForSubAgent: false,
// 工具状态跟踪 // 工具状态跟踪
preparingTools: new Map(), preparingTools: new Map(),

View File

@ -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消息开始 // AI消息开始
ctx.socket.on('ai_message_start', () => { ctx.socket.on('ai_message_start', () => {
// 轮询模式下跳过 WebSocket 事件 // 轮询模式下跳过 WebSocket 事件
if (ctx.usePollingMode) { if (ctx.usePollingMode && !ctx.waitingForSubAgent) {
socketLog('AI消息开始 (轮询模式,跳过)'); socketLog('AI消息开始 (轮询模式,跳过)');
return; 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消息开始'); socketLog('AI消息开始');
logStreamingDebug('socket:ai_message_start'); logStreamingDebug('socket:ai_message_start');
finalizeStreamingText({ force: true }); finalizeStreamingText({ force: true });
@ -882,12 +923,18 @@ export async function initializeLegacySocket(ctx: any) {
ctx.stopRequested = false; ctx.stopRequested = false;
ctx.streamingMessage = true; // 确保设置为流式状态 ctx.streamingMessage = true; // 确保设置为流式状态
ctx.scrollToBottom(); 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', () => { ctx.socket.on('thinking_start', () => {
// 轮询模式下跳过 WebSocket 事件 // 轮询模式下跳过 WebSocket 事件
if (ctx.usePollingMode) { if (ctx.usePollingMode && !ctx.waitingForSubAgent) {
socketLog('思考开始 (轮询模式,跳过)'); socketLog('思考开始 (轮询模式,跳过)');
return; return;
} }
@ -913,7 +960,7 @@ export async function initializeLegacySocket(ctx: any) {
// 思考内容块 // 思考内容块
ctx.socket.on('thinking_chunk', (data) => { ctx.socket.on('thinking_chunk', (data) => {
// 轮询模式下跳过 WebSocket 事件 // 轮询模式下跳过 WebSocket 事件
if (ctx.usePollingMode) { if (ctx.usePollingMode && !ctx.waitingForSubAgent) {
return; return;
} }
if (streamingState.ignoreThinking) { if (streamingState.ignoreThinking) {
@ -935,7 +982,7 @@ export async function initializeLegacySocket(ctx: any) {
// 思考结束 // 思考结束
ctx.socket.on('thinking_end', (data) => { ctx.socket.on('thinking_end', (data) => {
// 轮询模式下跳过 WebSocket 事件 // 轮询模式下跳过 WebSocket 事件
if (ctx.usePollingMode) { if (ctx.usePollingMode && !ctx.waitingForSubAgent) {
socketLog('思考结束 (轮询模式,跳过)'); socketLog('思考结束 (轮询模式,跳过)');
return; return;
} }
@ -960,10 +1007,15 @@ export async function initializeLegacySocket(ctx: any) {
// 文本流开始 // 文本流开始
ctx.socket.on('text_start', () => { ctx.socket.on('text_start', () => {
// 轮询模式下跳过 WebSocket 事件 // 轮询模式下跳过 WebSocket 事件
if (ctx.usePollingMode) { if (ctx.usePollingMode && !ctx.waitingForSubAgent) {
socketLog('文本开始 (轮询模式,跳过)'); socketLog('文本开始 (轮询模式,跳过)');
return; return;
} }
console.log('[DEBUG] 收到 text_start 事件:', {
currentConversationId: ctx.currentConversationId,
messagesCount: ctx.messages.length,
currentMessageIndex: ctx.currentMessageIndex
});
socketLog('文本开始'); socketLog('文本开始');
logStreamingDebug('socket:text_start'); logStreamingDebug('socket:text_start');
finalizeStreamingText({ force: true }); finalizeStreamingText({ force: true });
@ -974,14 +1026,21 @@ export async function initializeLegacySocket(ctx: any) {
streamingState.activeTextAction = action || ensureActiveTextAction(); streamingState.activeTextAction = action || ensureActiveTextAction();
ensureActiveMessageBinding(); ensureActiveMessageBinding();
ctx.$forceUpdate(); ctx.$forceUpdate();
console.log('[DEBUG] text_start 处理完成');
}); });
// 文本内容块 // 文本内容块
ctx.socket.on('text_chunk', (data) => { ctx.socket.on('text_chunk', (data) => {
// 轮询模式下跳过 WebSocket 事件 // 轮询模式下跳过 WebSocket 事件
if (ctx.usePollingMode) { if (ctx.usePollingMode && !ctx.waitingForSubAgent) {
return; 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', { logStreamingDebug('socket:text_chunk', {
index: data?.index ?? null, index: data?.index ?? null,
elapsed: data?.elapsed ?? null, elapsed: data?.elapsed ?? null,
@ -1016,7 +1075,7 @@ export async function initializeLegacySocket(ctx: any) {
// 文本结束 // 文本结束
ctx.socket.on('text_end', (data) => { ctx.socket.on('text_end', (data) => {
// 轮询模式下跳过 WebSocket 事件 // 轮询模式下跳过 WebSocket 事件
if (ctx.usePollingMode) { if (ctx.usePollingMode && !ctx.waitingForSubAgent) {
socketLog('文本结束 (轮询模式,跳过)'); socketLog('文本结束 (轮询模式,跳过)');
return; return;
} }
@ -1070,12 +1129,19 @@ export async function initializeLegacySocket(ctx: any) {
// 工具准备中事件 - 实时显示 // 工具准备中事件 - 实时显示
ctx.socket.on('tool_preparing', (data) => { ctx.socket.on('tool_preparing', (data) => {
// 轮询模式下跳过 WebSocket 事件 // 轮询模式下跳过 WebSocket 事件
if (ctx.usePollingMode) { if (ctx.usePollingMode && !ctx.waitingForSubAgent) {
return; return;
} }
if (ctx.dropToolEvents) { if (ctx.dropToolEvents) {
return; 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); socketLog('工具准备中:', data.name);
if (typeof console !== 'undefined' && console.debug) { if (typeof console !== 'undefined' && console.debug) {
console.debug('[tool_intent] preparing', { console.debug('[tool_intent] preparing', {
@ -1086,11 +1152,13 @@ export async function initializeLegacySocket(ctx: any) {
}); });
} }
if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) {
console.log('[DEBUG] 跳过 tool_preparing (对话不匹配)');
socketLog('跳过tool_preparing(对话不匹配)', data.conversation_id); socketLog('跳过tool_preparing(对话不匹配)', data.conversation_id);
return; return;
} }
const msg = ctx.chatEnsureAssistantMessage(); const msg = ctx.chatEnsureAssistantMessage();
if (!msg) { if (!msg) {
console.log('[DEBUG] tool_preparing: 无法获取assistant消息');
return; return;
} }
if (msg.awaitingFirstContent) { if (msg.awaitingFirstContent) {
@ -1121,6 +1189,7 @@ export async function initializeLegacySocket(ctx: any) {
if (data.intent) { if (data.intent) {
startIntentTyping(action, data.intent); startIntentTyping(action, data.intent);
} }
console.log('[DEBUG] tool_preparing 处理完成action已添加');
ctx.$forceUpdate(); ctx.$forceUpdate();
ctx.conditionalScrollToBottom(); ctx.conditionalScrollToBottom();
if (ctx.monitorPreviewTool) { if (ctx.monitorPreviewTool) {
@ -1131,7 +1200,7 @@ export async function initializeLegacySocket(ctx: any) {
// 工具意图(流式增量)事件 // 工具意图(流式增量)事件
ctx.socket.on('tool_intent', (data) => { ctx.socket.on('tool_intent', (data) => {
// 轮询模式下跳过 WebSocket 事件 // 轮询模式下跳过 WebSocket 事件
if (ctx.usePollingMode) { if (ctx.usePollingMode && !ctx.waitingForSubAgent) {
return; return;
} }
if (ctx.dropToolEvents) { if (ctx.dropToolEvents) {
@ -1160,7 +1229,7 @@ export async function initializeLegacySocket(ctx: any) {
// 工具状态更新事件 - 实时显示详细状态 // 工具状态更新事件 - 实时显示详细状态
ctx.socket.on('tool_status', (data) => { ctx.socket.on('tool_status', (data) => {
// 轮询模式下跳过 WebSocket 事件 // 轮询模式下跳过 WebSocket 事件
if (ctx.usePollingMode) { if (ctx.usePollingMode && !ctx.waitingForSubAgent) {
return; return;
} }
if (ctx.dropToolEvents) { if (ctx.dropToolEvents) {
@ -1196,7 +1265,7 @@ export async function initializeLegacySocket(ctx: any) {
// 工具开始(从准备转为执行) // 工具开始(从准备转为执行)
ctx.socket.on('tool_start', (data) => { ctx.socket.on('tool_start', (data) => {
// 轮询模式下跳过 WebSocket 事件 // 轮询模式下跳过 WebSocket 事件
if (ctx.usePollingMode) { if (ctx.usePollingMode && !ctx.waitingForSubAgent) {
return; return;
} }
if (ctx.dropToolEvents) { if (ctx.dropToolEvents) {
@ -1268,7 +1337,7 @@ export async function initializeLegacySocket(ctx: any) {
// 更新action工具完成 // 更新action工具完成
ctx.socket.on('update_action', (data) => { ctx.socket.on('update_action', (data) => {
// 轮询模式下跳过 WebSocket 事件 // 轮询模式下跳过 WebSocket 事件
if (ctx.usePollingMode) { if (ctx.usePollingMode && !ctx.waitingForSubAgent) {
return; return;
} }
if (ctx.dropToolEvents) { if (ctx.dropToolEvents) {
@ -1428,9 +1497,30 @@ export async function initializeLegacySocket(ctx: any) {
// 任务完成重点更新Token统计 // 任务完成重点更新Token统计
ctx.socket.on('task_complete', (data) => { 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); socketLog('任务完成', data);
// 如果有运行中的子智能体,不重置任务状态
if (!data.has_running_sub_agents) {
console.log('[DEBUG] 没有运行中的子智能体,重置任务状态');
if (ctx.waitingForSubAgent) {
ctx.waitingForSubAgent = false;
}
ctx.taskInProgress = false; ctx.taskInProgress = false;
ctx.scheduleResetAfterTask('socket:task_complete', { preserveMonitorWindows: true }); ctx.scheduleResetAfterTask('socket:task_complete', { preserveMonitorWindows: true });
} else {
console.log('[DEBUG] 有运行中的子智能体,保持任务状态');
}
console.log('[DEBUG] 当前状态 (after task_complete):', {
taskInProgress: ctx.taskInProgress,
streamingMessage: ctx.streamingMessage
});
resetPendingToolEvents(); resetPendingToolEvents();
@ -1441,6 +1531,41 @@ export async function initializeLegacySocket(ctx: any) {
} }
}); });
// 子智能体等待状态
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.socket.on('focused_files_update', (data) => {
ctx.focusSetFiles(data || {}); ctx.focusSetFiles(data || {});

View File

@ -397,11 +397,21 @@
background: rgba(255, 255, 255, 0.92); background: rgba(255, 255, 255, 0.92);
cursor: pointer; cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease; 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 { .sub-agent-card:hover {
border-color: #6c5ce7; border-color: #6c5ce7;
box-shadow: 0 6px 16px rgba(108, 92, 231, 0.15); 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 { .sub-agent-header {