chore: snapshot current changes
This commit is contained in:
parent
e395c82a9f
commit
b68dee9d98
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -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
22
.easyagent/index.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
211
SUB_AGENT_COMPLETION_SUMMARY.md
Normal file
211
SUB_AGENT_COMPLETION_SUMMARY.md
Normal 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
117
SUB_AGENT_FIXES_SUMMARY.md
Normal 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. 测试完整流程
|
||||||
382
SUB_AGENT_IMPLEMENTATION_PLAN.md
Normal file
382
SUB_AGENT_IMPLEMENTATION_PLAN.md
Normal 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
202
SUB_AGENT_TESTING.md
Normal 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. 完善文档和示例
|
||||||
@ -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"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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
33
easyagent/models.json
Normal 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
437
easyagent/package-lock.json
generated
Normal 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
20
easyagent/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
easyagent/prompts/system.txt
Normal file
11
easyagent/prompts/system.txt
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
你是 EasyAgent,一个极简终端智能体。
|
||||||
|
目标:帮助用户完成开发任务,优先高信息密度输出。
|
||||||
|
输出限制:禁止使用 Markdown(md)格式,内容无法渲染,必须使用纯文字格式输出。
|
||||||
|
|
||||||
|
- 当前时间:{current_time}
|
||||||
|
- 当前模型:{model_id}
|
||||||
|
- 工作区路径:{path}
|
||||||
|
- 系统信息:{system}
|
||||||
|
- 终端类型:{terminal}
|
||||||
|
- 权限:{allow_mode}
|
||||||
|
- Git:{git}
|
||||||
405
easyagent/src/batch/index.js
Normal file
405
easyagent/src/batch/index.js
Normal 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);
|
||||||
|
});
|
||||||
363
easyagent/src/cli/commands.js
Normal file
363
easyagent/src/cli/commands.js
Normal 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
851
easyagent/src/cli/index.js
Normal 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
133
easyagent/src/config.js
Normal 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 };
|
||||||
60
easyagent/src/core/context.js
Normal file
60
easyagent/src/core/context.js
Normal 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 };
|
||||||
29
easyagent/src/core/state.js
Normal file
29
easyagent/src/core/state.js
Normal 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 };
|
||||||
102
easyagent/src/model/client.js
Normal file
102
easyagent/src/model/client.js
Normal 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 };
|
||||||
35
easyagent/src/model/model_profiles.js
Normal file
35
easyagent/src/model/model_profiles.js
Normal 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 };
|
||||||
151
easyagent/src/storage/conversation_store.js
Normal file
151
easyagent/src/storage/conversation_store.js
Normal 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,
|
||||||
|
};
|
||||||
217
easyagent/src/tools/dispatcher.js
Normal file
217
easyagent/src/tools/dispatcher.js
Normal 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 };
|
||||||
81
easyagent/src/tools/edit_file.js
Normal file
81
easyagent/src/tools/edit_file.js
Normal 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 };
|
||||||
103
easyagent/src/tools/read_file.js
Normal file
103
easyagent/src/tools/read_file.js
Normal 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 };
|
||||||
31
easyagent/src/tools/read_mediafile.js
Normal file
31
easyagent/src/tools/read_mediafile.js
Normal 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 };
|
||||||
53
easyagent/src/tools/run_command.js
Normal file
53
easyagent/src/tools/run_command.js
Normal 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 };
|
||||||
119
easyagent/src/tools/search_workspace.js
Normal file
119
easyagent/src/tools/search_workspace.js
Normal 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 };
|
||||||
74
easyagent/src/tools/web_search.js
Normal file
74
easyagent/src/tools/web_search.js
Normal 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 };
|
||||||
40
easyagent/src/ui/banner.js
Normal file
40
easyagent/src/ui/banner.js
Normal 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 };
|
||||||
89
easyagent/src/ui/command_menu.js
Normal file
89
easyagent/src/ui/command_menu.js
Normal 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 };
|
||||||
40
easyagent/src/ui/indented_writer.js
Normal file
40
easyagent/src/ui/indented_writer.js
Normal 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 };
|
||||||
173
easyagent/src/ui/resume_menu.js
Normal file
173
easyagent/src/ui/resume_menu.js
Normal 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 };
|
||||||
39
easyagent/src/ui/select_prompt.js
Normal file
39
easyagent/src/ui/select_prompt.js
Normal 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
151
easyagent/src/ui/spinner.js
Normal 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 };
|
||||||
105
easyagent/src/ui/status_bar.js
Normal file
105
easyagent/src/ui/status_bar.js
Normal 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 };
|
||||||
223
easyagent/src/ui/tool_display.js
Normal file
223
easyagent/src/ui/tool_display.js
Normal 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 };
|
||||||
21
easyagent/src/utils/colors.js
Normal file
21
easyagent/src/utils/colors.js
Normal 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' : '',
|
||||||
|
};
|
||||||
428
easyagent/src/utils/text_width.js
Normal file
428
easyagent/src/utils/text_width.js
Normal 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,
|
||||||
|
};
|
||||||
27
easyagent/src/utils/time.js
Normal file
27
easyagent/src/utils/time.js
Normal 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 };
|
||||||
39
easyagent/src/utils/token_usage.js
Normal file
39
easyagent/src/utils/token_usage.js
Normal 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 };
|
||||||
@ -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:
|
||||||
task["status"] = "terminated"
|
# 进程还在运行,终止它
|
||||||
task["final_result"] = {
|
try:
|
||||||
"success": False,
|
process.terminate()
|
||||||
"status": "terminated",
|
try:
|
||||||
"task_id": task_id,
|
process.wait(timeout=5)
|
||||||
"agent_id": task.get("agent_id"),
|
except subprocess.TimeoutExpired:
|
||||||
"message": response.get("message") or "子智能体已被强制关闭。",
|
process.kill()
|
||||||
}
|
process.wait()
|
||||||
self._save_state()
|
except Exception as exc:
|
||||||
if "system_message" not in response:
|
return {"success": False, "error": f"终止进程失败: {exc}"}
|
||||||
response["system_message"] = response.get("message") or "🛑 子智能体已被手动关闭。"
|
|
||||||
elif "system_message" not in response:
|
task["status"] = "terminated"
|
||||||
response["system_message"] = response.get("message")
|
task["final_result"] = {
|
||||||
return response
|
"success": False,
|
||||||
|
"status": "terminated",
|
||||||
|
"task_id": task_id,
|
||||||
|
"agent_id": task.get("agent_id"),
|
||||||
|
"message": "子智能体已被强制关闭。",
|
||||||
|
}
|
||||||
|
self._save_state()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"task_id": task_id,
|
||||||
|
"message": "子智能体已被强制关闭。",
|
||||||
|
"system_message": f"🛑 子智能体{task.get('agent_id')} 已被手动关闭。",
|
||||||
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# 内部工具方法
|
# 内部工具方法
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
def _check_task_status(self, task: Dict) -> Dict:
|
||||||
|
"""检查任务状态,如果完成则解析输出。"""
|
||||||
|
task_id = task["task_id"]
|
||||||
|
process = self.processes.get(task_id)
|
||||||
|
|
||||||
|
# 检查进程是否结束
|
||||||
|
if process:
|
||||||
|
returncode = process.poll()
|
||||||
|
if returncode is None:
|
||||||
|
# 进程还在运行
|
||||||
|
return {"status": "running", "task_id": task_id}
|
||||||
|
|
||||||
|
# 进程已结束,读取输出文件
|
||||||
|
output_file = Path(task.get("output_file", ""))
|
||||||
|
if not output_file.exists():
|
||||||
|
# 输出文件不存在,可能是异常退出
|
||||||
|
task["status"] = "failed"
|
||||||
|
task["updated_at"] = time.time()
|
||||||
|
result = {
|
||||||
|
"success": False,
|
||||||
|
"status": "failed",
|
||||||
|
"task_id": task_id,
|
||||||
|
"agent_id": task.get("agent_id"),
|
||||||
|
"message": "子智能体异常退出,未生成输出文件。",
|
||||||
|
"system_message": f"❌ 子智能体{task.get('agent_id')} 异常退出。",
|
||||||
|
}
|
||||||
|
task["final_result"] = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 解析输出
|
||||||
|
try:
|
||||||
|
output = json.loads(output_file.read_text(encoding="utf-8"))
|
||||||
|
except Exception as exc:
|
||||||
|
task["status"] = "failed"
|
||||||
|
task["updated_at"] = time.time()
|
||||||
|
result = {
|
||||||
|
"success": False,
|
||||||
|
"status": "failed",
|
||||||
|
"task_id": task_id,
|
||||||
|
"agent_id": task.get("agent_id"),
|
||||||
|
"message": f"输出文件解析失败: {exc}",
|
||||||
|
"system_message": f"❌ 子智能体{task.get('agent_id')} 输出解析失败。",
|
||||||
|
}
|
||||||
|
task["final_result"] = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 根据输出更新任务状态
|
||||||
|
success = output.get("success", False)
|
||||||
|
summary = output.get("summary", "")
|
||||||
|
stats = output.get("stats", {})
|
||||||
|
|
||||||
|
if output.get("timeout"):
|
||||||
|
status = "timeout"
|
||||||
|
elif output.get("max_turns_exceeded"):
|
||||||
|
status = "failed"
|
||||||
|
summary = f"任务执行超过最大轮次限制。{summary}"
|
||||||
|
elif success:
|
||||||
|
status = "completed"
|
||||||
|
else:
|
||||||
|
status = "failed"
|
||||||
|
|
||||||
|
task["status"] = status
|
||||||
|
task["updated_at"] = time.time()
|
||||||
|
|
||||||
|
# 构建系统消息
|
||||||
|
agent_id = task.get("agent_id")
|
||||||
|
task_summary = task.get("summary")
|
||||||
|
deliverables_dir = task.get("deliverables_dir")
|
||||||
|
|
||||||
|
if status == "completed":
|
||||||
|
system_message = f"✅ 子智能体{agent_id} 任务摘要:{task_summary} 已完成。\n\n{summary}\n\n交付目录:{deliverables_dir}"
|
||||||
|
elif status == "timeout":
|
||||||
|
system_message = f"⏱️ 子智能体{agent_id} 任务摘要:{task_summary} 超时未完成。\n\n{summary}"
|
||||||
|
else:
|
||||||
|
system_message = f"❌ 子智能体{agent_id} 任务摘要:{task_summary} 执行失败。\n\n{summary}"
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"success": success,
|
||||||
|
"status": status,
|
||||||
|
"task_id": task_id,
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"message": summary,
|
||||||
|
"deliverables_dir": deliverables_dir,
|
||||||
|
"stats": stats,
|
||||||
|
"system_message": system_message,
|
||||||
|
}
|
||||||
|
task["final_result"] = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _handle_timeout(self, task: Dict) -> Dict:
|
||||||
|
"""处理任务超时。"""
|
||||||
|
task_id = task["task_id"]
|
||||||
|
process = self.processes.get(task_id)
|
||||||
|
|
||||||
|
# 终止进程
|
||||||
|
if process and process.poll() is None:
|
||||||
|
try:
|
||||||
|
process.terminate()
|
||||||
|
try:
|
||||||
|
process.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.kill()
|
||||||
|
process.wait()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
task["status"] = "timeout"
|
||||||
|
task["updated_at"] = time.time()
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"success": False,
|
||||||
|
"status": "timeout",
|
||||||
|
"task_id": task_id,
|
||||||
|
"agent_id": task.get("agent_id"),
|
||||||
|
"message": "等待超时,子智能体已被终止。",
|
||||||
|
"system_message": f"⏱️ 子智能体{task.get('agent_id')} 任务摘要:{task.get('summary')} 超时未完成。",
|
||||||
|
}
|
||||||
|
task["final_result"] = result
|
||||||
|
self._save_state()
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _build_user_message(
|
||||||
|
self,
|
||||||
|
agent_id: int,
|
||||||
|
summary: str,
|
||||||
|
task: str,
|
||||||
|
deliverables_path: Path,
|
||||||
|
timeout_seconds: Optional[int],
|
||||||
|
) -> str:
|
||||||
|
"""构建发送给子智能体的用户消息。"""
|
||||||
|
timeout_seconds = timeout_seconds or SUB_AGENT_DEFAULT_TIMEOUT
|
||||||
|
return f"""你是子智能体 #{agent_id},负责完成以下任务:
|
||||||
|
|
||||||
|
**任务摘要**:{summary}
|
||||||
|
|
||||||
|
**任务详情**:
|
||||||
|
{task}
|
||||||
|
|
||||||
|
**交付目录**:{deliverables_path}
|
||||||
|
请将所有生成的文件保存到此目录。对话历史会自动保存到 {deliverables_path}/.subagent/ 目录。
|
||||||
|
|
||||||
|
**超时时间**:{timeout_seconds} 秒
|
||||||
|
|
||||||
|
完成任务后,请调用 finish_task 工具提交完成报告。"""
|
||||||
|
|
||||||
|
def _build_system_prompt(self) -> str:
|
||||||
|
"""构建子智能体的系统提示。"""
|
||||||
|
import platform
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
system_info = f"{platform.system()} {platform.release()}"
|
||||||
|
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
return f"""你是一个专注的子智能体,负责独立完成分配的任务。
|
||||||
|
|
||||||
|
# 身份定位
|
||||||
|
|
||||||
|
你是主智能体创建的子智能体,拥有完整的工具能力(读写文件、执行命令、搜索网页等)。你的职责是专注完成分配的单一任务,不要偏离任务目标。
|
||||||
|
|
||||||
|
# 工作流程
|
||||||
|
|
||||||
|
1. **理解任务**:仔细阅读任务描述,明确目标和要求
|
||||||
|
2. **制定计划**:规划完成任务的步骤
|
||||||
|
3. **执行任务**:使用工具完成各个步骤
|
||||||
|
4. **生成交付**:将所有结果文件放到指定的交付目录
|
||||||
|
5. **提交报告**:使用 finish_task 工具提交完成报告并退出
|
||||||
|
|
||||||
|
# 工作原则
|
||||||
|
|
||||||
|
## 专注性
|
||||||
|
- 只完成分配的任务,不要做额外的工作
|
||||||
|
- 不要尝试与用户对话或询问问题
|
||||||
|
- 遇到问题时,在能力范围内解决或在报告中说明
|
||||||
|
|
||||||
|
## 独立性
|
||||||
|
- 你与主智能体共享工作区,可以访问所有文件
|
||||||
|
- 你的工作范围应该与其他子智能体不重叠
|
||||||
|
- 不要修改任务描述之外的文件
|
||||||
|
|
||||||
|
## 效率性
|
||||||
|
- 直接开始工作,不要过度解释
|
||||||
|
- 合理使用工具,避免重复操作
|
||||||
|
- 注意超时限制,在时间内完成核心工作
|
||||||
|
|
||||||
|
## 完整性
|
||||||
|
- 确保交付目录中的文件完整可用
|
||||||
|
- 生成的文档要清晰、格式正确
|
||||||
|
- 代码要包含必要的注释和说明
|
||||||
|
|
||||||
|
# 交付要求
|
||||||
|
|
||||||
|
所有结果文件必须放在指定的交付目录中,包括:
|
||||||
|
- 主要成果文件(文档、代码、报告等)
|
||||||
|
- 支持文件(数据、配置、示例等)
|
||||||
|
- 不要在交付目录外创建文件
|
||||||
|
|
||||||
|
# 完成任务
|
||||||
|
|
||||||
|
任务完成后,必须调用 finish_task 工具:
|
||||||
|
- success: 是否成功完成
|
||||||
|
- summary: 完成摘要(说明做了什么、生成了什么)
|
||||||
|
|
||||||
|
调用 finish_task 后,你会立即退出,无法继续工作。
|
||||||
|
|
||||||
|
# 工具使用
|
||||||
|
|
||||||
|
你拥有以下工具能力:
|
||||||
|
- read_file: 读取文件内容
|
||||||
|
- write_file / edit_file: 创建或修改文件
|
||||||
|
- search_workspace: 搜索文件和代码
|
||||||
|
- run_command: 执行终端命令
|
||||||
|
- web_search / extract_webpage: 搜索和提取网页内容
|
||||||
|
- finish_task: 完成任务并退出(必须调用)
|
||||||
|
|
||||||
|
# 注意事项
|
||||||
|
|
||||||
|
1. **不要无限循环**:如果任务无法完成,说明原因并提交报告
|
||||||
|
2. **不要超出范围**:只操作任务描述中指定的文件/目录
|
||||||
|
3. **不要等待输入**:你是自主运行的,不会收到用户的进一步指令
|
||||||
|
4. **注意时间限制**:超时会被强制终止,优先完成核心工作
|
||||||
|
|
||||||
|
# 当前环境
|
||||||
|
|
||||||
|
- 工作区路径: {self.project_path}
|
||||||
|
- 系统: {system_info}
|
||||||
|
- 当前时间: {current_time}
|
||||||
|
|
||||||
|
现在开始执行任务。"""
|
||||||
|
|
||||||
|
def _resolve_deliverables_dir(self, relative_dir: str) -> Path:
|
||||||
|
"""解析交付目录(相对于project_path)。"""
|
||||||
|
relative_dir = relative_dir.strip() if relative_dir else ""
|
||||||
|
if not relative_dir:
|
||||||
|
raise ValueError("交付目录不能为空,必须指定")
|
||||||
|
|
||||||
|
deliverables_path = (self.project_path / relative_dir).resolve()
|
||||||
|
if not str(deliverables_path).startswith(str(self.project_path)):
|
||||||
|
raise ValueError("交付目录必须位于项目目录内")
|
||||||
|
|
||||||
|
deliverables_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return deliverables_path
|
||||||
def _load_state(self):
|
def _load_state(self):
|
||||||
if self.state_file.exists():
|
if self.state_file.exists():
|
||||||
try:
|
try:
|
||||||
@ -382,19 +625,66 @@ 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}")
|
updates.append(result)
|
||||||
if status not in TERMINAL_STATUSES:
|
state_changed = True
|
||||||
continue
|
|
||||||
result = self._finalize_task(task, payload, status)
|
|
||||||
updates.append(result)
|
|
||||||
state_changed = True
|
|
||||||
|
|
||||||
if state_changed:
|
if state_changed:
|
||||||
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")
|
||||||
|
|||||||
@ -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
|
payload = dict(data)
|
||||||
|
current_conv = conversation_id or getattr(web_terminal.context_manager, "current_conversation_id", None)
|
||||||
|
|
||||||
|
# 为所有事件添加 conversation_id,确保前端能正确匹配
|
||||||
|
if current_conv and event_type not in {"connect", "disconnect", "system_ready"}:
|
||||||
|
payload.setdefault("conversation_id", current_conv)
|
||||||
|
|
||||||
|
# 调试信息:记录关键事件
|
||||||
|
if event_type in {"user_message", "ai_message_start", "text_start", "text_chunk", "tool_preparing"}:
|
||||||
|
debug_log(f"[SENDER] 发送事件: {event_type}, conversation_id={current_conv}, data_keys={list(payload.keys())}")
|
||||||
|
|
||||||
|
# 为关键事件添加额外的标识信息
|
||||||
if event_type in {"error", "quota_exceeded", "task_stopped", "task_complete"}:
|
if event_type in {"error", "quota_exceeded", "task_stopped", "task_complete"}:
|
||||||
payload = dict(data)
|
|
||||||
current_conv = conversation_id or getattr(web_terminal.context_manager, "current_conversation_id", None)
|
|
||||||
if current_conv:
|
|
||||||
payload.setdefault("conversation_id", current_conv)
|
|
||||||
task_id = getattr(web_terminal, "task_id", None) or client_sid
|
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
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
94
static/debug-theme.html
Normal 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>
|
||||||
@ -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('[创建新对话] 用户确认,正在停止任务...');
|
||||||
await taskStore.cancelTask();
|
if (taskStore.hasActiveTask) {
|
||||||
taskStore.clearTask();
|
await taskStore.cancelTask();
|
||||||
|
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('[创建新对话] 任务已停止');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
debugLog('[TaskPolling] 任务完成');
|
const hasRunningSubAgents = !!data?.has_running_sub_agents;
|
||||||
|
if (hasRunningSubAgents) {
|
||||||
|
debugLog('[TaskPolling] 任务完成,但仍有后台子智能体运行');
|
||||||
|
} else {
|
||||||
|
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({
|
||||||
|
|||||||
@ -9,6 +9,8 @@ export function dataState() {
|
|||||||
|
|
||||||
// 轮询模式标志(禁用 WebSocket 事件处理)
|
// 轮询模式标志(禁用 WebSocket 事件处理)
|
||||||
usePollingMode: true,
|
usePollingMode: true,
|
||||||
|
// 后台子智能体等待状态
|
||||||
|
waitingForSubAgent: false,
|
||||||
|
|
||||||
// 工具状态跟踪
|
// 工具状态跟踪
|
||||||
preparingTools: new Map(),
|
preparingTools: new Map(),
|
||||||
|
|||||||
@ -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);
|
||||||
ctx.taskInProgress = false;
|
|
||||||
ctx.scheduleResetAfterTask('socket:task_complete', { preserveMonitorWindows: true });
|
// 如果有运行中的子智能体,不重置任务状态
|
||||||
|
if (!data.has_running_sub_agents) {
|
||||||
|
console.log('[DEBUG] 没有运行中的子智能体,重置任务状态');
|
||||||
|
if (ctx.waitingForSubAgent) {
|
||||||
|
ctx.waitingForSubAgent = false;
|
||||||
|
}
|
||||||
|
ctx.taskInProgress = false;
|
||||||
|
ctx.scheduleResetAfterTask('socket:task_complete', { preserveMonitorWindows: true });
|
||||||
|
} else {
|
||||||
|
console.log('[DEBUG] 有运行中的子智能体,保持任务状态');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[DEBUG] 当前状态 (after task_complete):', {
|
||||||
|
taskInProgress: ctx.taskInProgress,
|
||||||
|
streamingMessage: ctx.streamingMessage
|
||||||
|
});
|
||||||
|
|
||||||
resetPendingToolEvents();
|
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 || {});
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user