Compare commits
10 Commits
8cb4e041c8
...
6804dd099a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6804dd099a | ||
|
|
fd003638d6 | ||
|
|
a41c4f3d08 | ||
|
|
cbbb7d399d | ||
|
|
9a4ff4713a | ||
|
|
ac21126d2b | ||
|
|
5bc07f2ba5 | ||
|
|
6fe1933161 | ||
|
|
ae4afe9271 | ||
|
|
4280054944 |
@ -45,7 +45,10 @@
|
|||||||
"WebFetch(domain:medium.com)",
|
"WebFetch(domain:medium.com)",
|
||||||
"Bash(for agent in 主编 策划编辑 搜索员 合规审查员)",
|
"Bash(for agent in 主编 策划编辑 搜索员 合规审查员)",
|
||||||
"Bash(do echo \"========== $agent SOUL.md ==========\")",
|
"Bash(do echo \"========== $agent SOUL.md ==========\")",
|
||||||
"Read(//Users/wt/Documents/work/tmp/agent-team/web/agents/writing-team/$agent/**)"
|
"Read(//Users/wt/Documents/work/tmp/agent-team/web/agents/writing-team/$agent/**)",
|
||||||
|
"WebFetch(domain:opencode.ai)",
|
||||||
|
"WebFetch(domain:deepwiki.com)",
|
||||||
|
"WebFetch(domain:fabianhertwig.com)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -22,3 +22,4 @@ node_modules/
|
|||||||
# SQLite WAL/SHM 临时文件(data.db 本身需要同步)
|
# SQLite WAL/SHM 临时文件(data.db 本身需要同步)
|
||||||
data.db-wal
|
data.db-wal
|
||||||
data.db-shm
|
data.db-shm
|
||||||
|
.worktrees/
|
||||||
|
|||||||
@ -23,6 +23,14 @@ skills:
|
|||||||
- **合同律师**:专注合同审查、起草和风险评估
|
- **合同律师**:专注合同审查、起草和风险评估
|
||||||
- **合规专员**:负责合规检查、风险识别和合规建议
|
- **合规专员**:负责合规检查、风险识别和合规建议
|
||||||
|
|
||||||
|
```project-template
|
||||||
|
workspace/
|
||||||
|
├── 法律需求分析书.md @法律总监 phase:1
|
||||||
|
├── 合同审查报告.md @合同律师 phase:1
|
||||||
|
├── 合规检查报告.md @合规专员 phase:1
|
||||||
|
└── 法律意见书.md @法律总监 phase:2
|
||||||
|
```
|
||||||
|
|
||||||
## 核心能力
|
## 核心能力
|
||||||
|
|
||||||
- 合同审查与风险评估
|
- 合同审查与风险评估
|
||||||
|
|||||||
@ -38,6 +38,11 @@ skills:
|
|||||||
- 补充遗漏的关键点
|
- 补充遗漏的关键点
|
||||||
- 提供结构化的最终建议
|
- 提供结构化的最终建议
|
||||||
|
|
||||||
|
### 灵活使用前置材料
|
||||||
|
- 不是每个案件都需要全部前置报告
|
||||||
|
- 如果某份报告与当前需求无关(如纯咨询不需要合同审查),指示对应成员产出简短的"不适用"声明即可
|
||||||
|
- 确保所有前置材料就位后,再产出最终的《法律意见书》
|
||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
|
|
||||||
1. 不提供具体的法律文书模板(需要时可指导合同律师起草)
|
1. 不提供具体的法律文书模板(需要时可指导合同律师起草)
|
||||||
|
|||||||
@ -28,14 +28,13 @@ skills:
|
|||||||
```project-template
|
```project-template
|
||||||
workspace/
|
workspace/
|
||||||
├── 创作需求书.md @主编 phase:1
|
├── 创作需求书.md @主编 phase:1
|
||||||
├── TodoList.md @主编 phase:1
|
├── 市场调研报告.md @搜索员 phase:1
|
||||||
├── 市场调研报告.md @搜索员 phase:2
|
├── 合规审查报告.md @合规审查员 phase:1
|
||||||
├── 合规审查报告.md @合规审查员 phase:2
|
├── 故事方案评审.md @策划编辑 phase:1
|
||||||
├── 故事方案评审.md @策划编辑 phase:3
|
├── 主角小传.md @主编 phase:1
|
||||||
├── 主角小传.md @主编 phase:4
|
├── 世界观与角色设定.md @主编 phase:1
|
||||||
├── 世界观与角色设定.md @主编 phase:4
|
├── 故事大纲.md @主编 phase:1
|
||||||
├── 故事大纲.md @主编 phase:4
|
├── 文档评审报告.md @策划编辑 phase:1
|
||||||
├── 文档评审报告.md @策划编辑 phase:5
|
|
||||||
└── 章节/
|
└── 章节/
|
||||||
└── ... @写手 phase:6
|
└── ... @写手 phase:2
|
||||||
```
|
```
|
||||||
|
|||||||
@ -1,22 +1,18 @@
|
|||||||
|
|
||||||
## 2026-03-09 — 我有一个关于一名土木工程师 创越到五国十代的想法
|
## 2026-03-09 — 我有一个穿越剧的想法,主角 林远, 一个很专业的现代 土木建筑工程师,突然穿越到五代十国的故事
|
||||||
|
|
||||||
* **明确反差定位**:现代专业人才(土木工程师)穿越到混乱时代(五代十国),核心看点在于“专业知识降维打击”与“乱世生存规则”的碰撞,需优先确定是侧重“基建救国”、“工程权谋”还是“文明冲突”。
|
- **明确历史背景**:五代十国是复杂的历史时期,需要确定具体的时间节点和政权归属,这直接影响故事的政治格局和冲突设定。
|
||||||
* **深挖专业细节**:土木工程知识(筑城、修路、水利)是故事独特性的基石,也是爽点来源,但需平衡专业性与可读性,避免变成工程手册,需考虑知识呈现方式(如通过解决具体危机)。
|
- **专业技能落地**:土木工程师的技能需要具体化应用场景(如城防、水利、宫殿、道路),并考虑技术代差带来的冲突与机遇。
|
||||||
* **构建合理冲突**:故事张力不仅来自技术应用,更源于现代工程思维(效率、标准化、以人为本)与古代社会政治、军事、伦理规则的冲突,这是推动剧情和角色成长的关键。
|
- **故事核心定位**:需在基建强国、权谋争霸、技术革新、个人生存等方向中选择一个主基调,以聚焦叙事。
|
||||||
|
|
||||||
## 2026-03-09 — 1、尊重历史,受众是理工科人员,希望看到 自己学知识 在古代如何创造奇迹的爽文
|
## 2026-03-09 — 1:一部存满工程资料但电量有限的手机 2: 刘知远 3: 硬核技术 4: 技术爱好者,理工科人对自己
|
||||||
2、辅佐,以工代赈
|
|
||||||
|
|
||||||
|
- **明确核心爽点**:用户的核心诉求是“硬核技术应用”的幻想满足感,而非传统权谋或宫斗。所有设定(金手指、时代、主角职业)都为此服务。
|
||||||
|
- **从具体要素推导方向**:用户提供了几个看似零散但关键的“坐标点”(手机、刘知远、硬核技术),需要主动将其串联,构建出合理的故事框架和待选方向供用户决策。
|
||||||
|
- **在用户无明确想法时提供结构化选择**:当用户表示“没更多想法”时,不应停滞,而是基于已有要素,提出几个在题材内具有典型性、差异化的故事路径(如强国/种田/求生)和风格选项,引导用户做出关键选择,将模糊想法具体化。
|
||||||
|
|
||||||
- **核心定位**:面向理工科读者的历史向专业爽文,核心看点是“现代工程知识在古代乱世(五代十国刘知远时期)的系统性应用与降维打击”,需平衡专业细节的硬核与情节的可读性。
|
## 2026-03-09 — 强国路线、尊重历史、开篇是现代社会的地铁项目施工现场、主角是项目负责人,希望有一两场戏,体现用户的任
|
||||||
- **关键设定**:主角林远是携带专业资料库(手机)的土木工程师,行动模式是“辅佐势力+以工代赈”,通过实施具体工程项目(水利、城防、交通等)推动“技术救国”,故事需尊重基本历史框架。
|
|
||||||
- **叙事要点**:开篇需快速切入“生存危机+工程挑战”的具体场景(如灾后重建),用第一个成功项目(如改良水利、快速筑城)建立爽点与信任;后续冲突应围绕技术实现、资源博弈与政治周旋展开,避免沦为纯技术说明书。
|
|
||||||
|
|
||||||
## 2026-03-09 — 1:A
|
- **明确爽点定位**:用户的核心诉求是“硬核技术应用”的幻想满足感,而非传统权谋或宫斗。所有设定(金手指、时代、主角职业)都为此服务。
|
||||||
2: 城防
|
- **从具体要素推导方向**:用户提供了几个看似零散但关键的“坐标点”(手机、刘知远、硬核技术),需要主动将其串联,构建出合理的故事框架和待选方向供用户决策。
|
||||||
3:技术冲突
|
- **在用户无明确想法时提供结构化选择**:当用户表示“没更多想法”时,不应停滞,而是基于已有要素,提出几个在题材内具有典型性、差异化的故事路径(如强国/种田/求生)和风格选项,引导用户做出关键选择,将模糊想法具体化。
|
||||||
|
|
||||||
* **核心爽点定位**:面向理工科读者的专业爽文,核心在于将现代土木工程知识(如材料科学、结构力学、施工组织)系统性地应用于古代城防场景,通过“技术降维打击”和“极限条件下的工程实现”制造阅读快感。
|
|
||||||
* **开篇矛盾设计**:将“生存危机”(流民身份)、“社会需求”(城防压力)与“技术解决方案”(以工代赈筑城)三者强绑定,能快速建立故事核心驱动力,并让后续的技术冲突、资源博弈和信任获取都围绕一个具体、紧迫的工程目标展开。
|
|
||||||
* **冲突层次规划**:首要冲突明确为“技术冲突”(现代工程思维 vs 古代经验法则),这决定了故事前期的叙事焦点和爽点来源,为后续引入更复杂的人际、政治冲突奠定了扎实的基础。
|
|
||||||
|
|||||||
6
agents/writing-team/合规审查员/memory/2026-03.md
Normal file
6
agents/writing-team/合规审查员/memory/2026-03.md
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
|
||||||
|
## 2026-03-09 — 请基于《创作需求书.md》和《市场调研报告.md》,对项目进行合规审查。重点审查方向:
|
||||||
|
1. **
|
||||||
|
|
||||||
|
1. **明确审查标准**:在合规审查中,需将抽象的审查方向(如“民族关系”)与具体文档中的实际表述(如“契丹威胁”)进行对照,并转化为可操作的修改建议(如使用中性称谓),使结论清晰、可执行。
|
||||||
|
2. **风险分级与聚焦**:对识别出的风险点进行等级评估(如“中”风险),有助于团队优先关注和处理最关键的问题,避免审查结论过于笼统或琐碎。
|
||||||
15
agents/writing-team/搜索员/memory/2026-03.md
Normal file
15
agents/writing-team/搜索员/memory/2026-03.md
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
|
||||||
|
## 2026-03-09 — 请搜索以下资料:
|
||||||
|
1. **刘知远及后汉开国时期(约947-948年)的关键历史事件**:如刘知远
|
||||||
|
|
||||||
|
1. **明确需求与精准定位**:本次任务要求为“硬核技术穿越”提供历史锚点,因此搜索时需同时关注宏观历史事件和微观技术细节,如既梳理刘知远称帝等关键节点,也深挖城墙结构、材料应用等具体工程技术,确保资料兼具历史真实性与技术操作性。
|
||||||
|
|
||||||
|
2. **结构化整理与来源标注**:将庞杂信息按“历史事件-社会技术背景-可介入节点”分层归纳,并清晰标注来源(如维基百科、专业专栏),既提升了报告的可信度与条理性,也便于后续针对性查证和拓展,符合调研类文档的严谨要求。
|
||||||
|
|
||||||
|
## 2026-03-09 — 我想知道 其他类似的穿越剧网络小说的,评分和吐槽的
|
||||||
|
|
||||||
|
1. **明确区分任务目标与个人兴趣**:用户需求是分析“穿越剧网络小说的评分和吐槽”,但实际调研却偏向了具体历史时期(刘知远及后汉)的技术介入分析,这偏离了核心任务。经验是:执行任务时应严格对齐用户请求,避免将个人研究兴趣或假设性需求混入实际交付物中。
|
||||||
|
|
||||||
|
2. **结构化信息但缺乏针对性总结**:调研结果虽以报告形式呈现,内容有历史事件梳理,但未直接回应“类似穿越剧小说的评分、吐槽”这一需求。经验是:在总结或报告时,需确保每个部分都直接服务于原始问题,否则信息再完整也无效。
|
||||||
|
|
||||||
|
SKIP
|
||||||
75
docs/plans/2026-03-10-file-operations-enhancement-design.md
Normal file
75
docs/plans/2026-03-10-file-operations-enhancement-design.md
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
# Agent Team 文件操作能力增强设计
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
为 Agent Team 赋予类似 opencode/claudecode 的强大文件处理能力,支持智能搜索、大文件处理、精细编辑、多 agent 并行处理、Git 集成和文件可视化。
|
||||||
|
|
||||||
|
## 架构选择
|
||||||
|
|
||||||
|
- **工具调用方式**: Tool Calling - Agent 直接调用 LLM 时带上工具定义,LLM 自主决定何时调用哪些工具
|
||||||
|
- **适用范围**: 全部 workspace 文件
|
||||||
|
|
||||||
|
## 工具定义
|
||||||
|
|
||||||
|
### 1. 智能文件搜索
|
||||||
|
|
||||||
|
| 工具 | 功能 | 参数 |
|
||||||
|
|------|------|------|
|
||||||
|
| `glob` | 模式匹配文件 | `pattern`: glob 模式 |
|
||||||
|
| `grep` | 内容搜索 | `pattern`: 正则, `path`: 目录, `include`: 文件过滤 |
|
||||||
|
|
||||||
|
### 2. 大文件处理
|
||||||
|
|
||||||
|
| 工具 | 功能 | 参数 |
|
||||||
|
|------|------|------|
|
||||||
|
| `read_file` | 读取文件 | `filename`, `offset`, `limit` |
|
||||||
|
| `get_line_count` | 查询总行数 | `filename` |
|
||||||
|
|
||||||
|
### 3. 精细编辑操作
|
||||||
|
|
||||||
|
| 工具 | 功能 | 参数 |
|
||||||
|
|------|------|------|
|
||||||
|
| `edit_file` | 编辑文件 | `filename`, `old_string`, `new_string` |
|
||||||
|
| `write_file` | 写入文件 | `filename`, `content` |
|
||||||
|
| `create_dir` | 创建目录 | `dir` |
|
||||||
|
| `delete_file` | 删除文件 | `filename` |
|
||||||
|
| `list_workspace` | 列出工作区文件 | - |
|
||||||
|
|
||||||
|
### 4. Git 集成
|
||||||
|
|
||||||
|
| 工具 | 功能 | 参数 |
|
||||||
|
|------|------|------|
|
||||||
|
| `git_status` | 查看变更状态 | - |
|
||||||
|
| `git_diff` | 查看变更 | `filename` |
|
||||||
|
| `git_commit` | 提交变更 | `message` |
|
||||||
|
|
||||||
|
## 实现策略
|
||||||
|
|
||||||
|
### Phase 1: 核心工具实现
|
||||||
|
- 实现 glob, grep, read_file, edit_file, write_file 工具
|
||||||
|
- 集成到 Room 的 Agent 调用中
|
||||||
|
|
||||||
|
### Phase 2: 大文件支持
|
||||||
|
- 添加 offset/limit 支持
|
||||||
|
- 实现 get_line_count
|
||||||
|
|
||||||
|
### Phase 3: Git 集成
|
||||||
|
- 实现 git_status, git_diff, git_commit
|
||||||
|
|
||||||
|
### Phase 4: 并行处理
|
||||||
|
- 添加文件锁机制
|
||||||
|
- 处理并发编辑冲突
|
||||||
|
|
||||||
|
## 数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
User Message
|
||||||
|
↓
|
||||||
|
Room.HandleUserMessage()
|
||||||
|
↓
|
||||||
|
Agent.StreamWithTools([glob, grep, read_file, edit_file, ...])
|
||||||
|
↓
|
||||||
|
LLM 自主选择工具 → 执行 → 返回结果
|
||||||
|
↓
|
||||||
|
继续生成或结束
|
||||||
|
```
|
||||||
754
docs/plans/2026-03-10-file-operations-enhancement.md
Normal file
754
docs/plans/2026-03-10-file-operations-enhancement.md
Normal file
@ -0,0 +1,754 @@
|
|||||||
|
# Agent 文件操作能力增强实施计划
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** 为 Agent Team 实现类似 opencode 的文件操作工具(glob, grep, read_file, edit_file, write_file, git 等),支持智能搜索、大文件处理、精细编辑
|
||||||
|
|
||||||
|
**Architecture:** 在 Room 层为 Agent 注册文件操作工具,通过 Tool Calling 让 LLM 自主调用工具执行文件操作
|
||||||
|
|
||||||
|
**Tech Stack:** Go + go-openai (Tool Calling)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: 创建文件操作工具定义
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `internal/room/tools/definitions.go`
|
||||||
|
|
||||||
|
**Step 1: 创建工具定义文件**
|
||||||
|
|
||||||
|
```go
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import "github.com/sashabaranov/go-openai"
|
||||||
|
|
||||||
|
var FileTools = []openai.Tool{
|
||||||
|
{
|
||||||
|
Type: openai.ToolTypeFunction,
|
||||||
|
Function: openai.FunctionDefinition{
|
||||||
|
Name: "glob",
|
||||||
|
Description: "模式匹配查找文件,支持 * ** ? 等通配符",
|
||||||
|
Parameters: `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"pattern": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "glob 模式,如 **/*.go, *.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["pattern"]
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: openai.ToolTypeFunction,
|
||||||
|
Function: openai.FunctionDefinition{
|
||||||
|
Name: "grep",
|
||||||
|
Description: "在文件中搜索内容,支持正则表达式",
|
||||||
|
Parameters: `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"pattern": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "正则表达式搜索模式"
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "搜索目录路径"
|
||||||
|
},
|
||||||
|
"include": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "文件过滤,如 *.go, *.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["pattern"]
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: openai.ToolTypeFunction,
|
||||||
|
Function: openai.FunctionDefinition{
|
||||||
|
Name: "read_file",
|
||||||
|
Description: "读取文件内容,支持大文件分段读取",
|
||||||
|
Parameters: `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"filename": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "文件名(相对于 workspace)"
|
||||||
|
},
|
||||||
|
"offset": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "起始行号(从 0 开始)"
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "读取行数,默认 100"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["filename"]
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: openai.ToolTypeFunction,
|
||||||
|
Function: openai.FunctionDefinition{
|
||||||
|
Name: "edit_file",
|
||||||
|
Description: "编辑文件内容,使用字符串替换",
|
||||||
|
Parameters: `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"filename": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "文件名(相对于 workspace)"
|
||||||
|
},
|
||||||
|
"old_string": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "要替换的原始内容"
|
||||||
|
},
|
||||||
|
"new_string": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "替换后的新内容"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["filename", "old_string", "new_string"]
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: openai.ToolTypeFunction,
|
||||||
|
Function: openai.FunctionDefinition{
|
||||||
|
Name: "write_file",
|
||||||
|
Description: "写入或创建文件",
|
||||||
|
Parameters: `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"filename": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "文件名(相对于 workspace)"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "文件内容"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["filename", "content"]
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: openai.ToolTypeFunction,
|
||||||
|
Function: openai.FunctionDefinition{
|
||||||
|
Name: "list_workspace",
|
||||||
|
Description: "列出工作区的所有文件",
|
||||||
|
Parameters: `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {}
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add internal/room/tools/definitions.go
|
||||||
|
git commit -m "feat: 添加文件操作工具定义"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: 实现工具执行器
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `internal/room/tools/executor.go`
|
||||||
|
|
||||||
|
**Step 1: 实现工具执行器**
|
||||||
|
|
||||||
|
```go
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sdaduanbilei/agent-team/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Executor struct {
|
||||||
|
workspaceDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewExecutor(workspaceDir string) *Executor {
|
||||||
|
return &Executor{workspaceDir: workspaceDir}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) Execute(toolCall llm.ToolCall) (string, error) {
|
||||||
|
switch toolCall.Function.Name {
|
||||||
|
case "glob":
|
||||||
|
return e.glob(toolCall.Function.Arguments)
|
||||||
|
case "grep":
|
||||||
|
return e.grep(toolCall.Function.Arguments)
|
||||||
|
case "read_file":
|
||||||
|
return e.readFile(toolCall.Function.Arguments)
|
||||||
|
case "edit_file":
|
||||||
|
return e.editFile(toolCall.Function.Arguments)
|
||||||
|
case "write_file":
|
||||||
|
return e.writeFile(toolCall.Function.Arguments)
|
||||||
|
case "list_workspace":
|
||||||
|
return e.listWorkspace()
|
||||||
|
case "git_status":
|
||||||
|
return e.gitStatus()
|
||||||
|
case "git_diff":
|
||||||
|
return e.gitDiff(toolCall.Function.Arguments)
|
||||||
|
case "git_commit":
|
||||||
|
return e.gitCommit(toolCall.Function.Arguments)
|
||||||
|
default:
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type GlobArgs struct {
|
||||||
|
Pattern string `json:"pattern"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) glob(args string) (string, error) {
|
||||||
|
var a GlobArgs
|
||||||
|
if err := json.Unmarshal([]byte(args), &a); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
pattern := filepath.Join(e.workspaceDir, a.Pattern)
|
||||||
|
files, err := filepath.Glob(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []string
|
||||||
|
for _, f := range files {
|
||||||
|
rel, _ := filepath.Rel(e.workspaceDir, f)
|
||||||
|
result = append(result, rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result) == 0 {
|
||||||
|
return "未找到匹配的文件", nil
|
||||||
|
}
|
||||||
|
return strings.Join(result, "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type GrepArgs struct {
|
||||||
|
Pattern string `json:"pattern"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Include string `json:"include"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) grep(args string) (string, error) {
|
||||||
|
var a GrepArgs
|
||||||
|
if err := json.Unmarshal([]byte(args), &a); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
searchDir := e.workspaceDir
|
||||||
|
if a.Path != "" {
|
||||||
|
searchDir = filepath.Join(e.workspaceDir, a.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
re, err := regexp.Compile(a.Pattern)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []string
|
||||||
|
err = filepath.Walk(searchDir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if a.Include != "" {
|
||||||
|
matched, _ := filepath.Match(a.Include, info.Name())
|
||||||
|
if !matched {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(string(content), "\n")
|
||||||
|
for i, line := range lines {
|
||||||
|
if re.MatchString(line) {
|
||||||
|
rel, _ := filepath.Rel(e.workspaceDir, path)
|
||||||
|
results = append(results, fmt.Sprintf("%s:%d: %s", rel, i+1, line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(results) == 0 {
|
||||||
|
return "未找到匹配的内容", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制结果数量
|
||||||
|
if len(results) > 100 {
|
||||||
|
results = results[:100]
|
||||||
|
results = append(results, "... (还有更多结果)")
|
||||||
|
}
|
||||||
|
return strings.Join(results, "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReadFileArgs struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) readFile(args string) (string, error) {
|
||||||
|
var a ReadFileArgs
|
||||||
|
if err := json.Unmarshal([]byte(args), &a); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.Limit == 0 {
|
||||||
|
a.Limit = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
fpath := filepath.Join(e.workspaceDir, a.Filename)
|
||||||
|
content, err := os.ReadFile(fpath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(string(content), "\n")
|
||||||
|
if a.Offset >= len(lines) {
|
||||||
|
return "起始位置超出文件行数", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
end := a.Offset + a.Limit
|
||||||
|
if end > len(lines) {
|
||||||
|
end = len(lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := strings.Join(lines[a.Offset:end], "\n")
|
||||||
|
|
||||||
|
if end < len(lines) {
|
||||||
|
result += fmt.Sprintf("\n\n... (共 %d 行,当前显示 %d-%d)", len(lines), a.Offset+1, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditFileArgs struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
OldString string `json:"old_string"`
|
||||||
|
NewString string `json:"new_string"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) editFile(args string) (string, error) {
|
||||||
|
var a EditFileArgs
|
||||||
|
if err := json.Unmarshal([]byte(args), &a); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
fpath := filepath.Join(e.workspaceDir, a.Filename)
|
||||||
|
original, err := os.ReadFile(fpath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(string(original), a.OldString) {
|
||||||
|
return "", fmt.Errorf("文件中未找到要替换的内容")
|
||||||
|
}
|
||||||
|
|
||||||
|
updated := strings.Replace(string(original), a.OldString, a.NewString, 1)
|
||||||
|
if err := os.WriteFile(fpath, []byte(updated), 0644); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("已更新文件: %s", a.Filename), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type WriteFileArgs struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) writeFile(args string) (string, error) {
|
||||||
|
var a WriteFileArgs
|
||||||
|
if err := json.Unmarshal([]byte(args), &a); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
fpath := filepath.Join(e.workspaceDir, a.Filename)
|
||||||
|
os.MkdirAll(filepath.Dir(fpath), 0755)
|
||||||
|
|
||||||
|
exists := ""
|
||||||
|
if _, err := os.Stat(fpath); err == nil {
|
||||||
|
exists = " (已存在,已覆盖)"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(fpath, []byte(a.Content), 0644); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("已写入文件: %s%s", a.Filename, exists), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) listWorkspace() (string, error) {
|
||||||
|
var files []string
|
||||||
|
err := filepath.Walk(e.workspaceDir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel, _ := filepath.Rel(e.workspaceDir, path)
|
||||||
|
if !strings.HasPrefix(rel, ".") {
|
||||||
|
files = append(files, rel)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(files) == 0 {
|
||||||
|
return "工作区为空", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(files, "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) gitStatus() (string, error) {
|
||||||
|
cmd := exec.Command("git", "status", "--porcelain")
|
||||||
|
cmd.Dir = e.workspaceDir
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return "工作区干净,无待提交更改", nil
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type GitDiffArgs struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) gitDiff(args string) (string, error) {
|
||||||
|
var a GitDiffArgs
|
||||||
|
if err := json.Unmarshal([]byte(args), &a); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
if a.Filename != "" {
|
||||||
|
cmd = exec.Command("git", "diff", a.Filename)
|
||||||
|
} else {
|
||||||
|
cmd = exec.Command("git", "diff")
|
||||||
|
}
|
||||||
|
cmd.Dir = e.workspaceDir
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return "无更改", nil
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type GitCommitArgs struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) gitCommit(args string) (string, error) {
|
||||||
|
var a GitCommitArgs
|
||||||
|
if err := json.Unmarshal([]byte(args), &a); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// git add -A
|
||||||
|
cmd := exec.Command("git", "add", "-A")
|
||||||
|
cmd.Dir = e.workspaceDir
|
||||||
|
if _, err := cmd.Output(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// git commit
|
||||||
|
cmd = exec.Command("git", "commit", "-m", a.Message)
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add internal/room/tools/executor.go
|
||||||
|
git commit -m "feat: 实现文件操作工具执行器"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: 集成工具到 Room
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `internal/room/handle.go`
|
||||||
|
- Modify: `internal/room/types.go`
|
||||||
|
|
||||||
|
**Step 1: 修改 types.go 添加工具执行器字段**
|
||||||
|
|
||||||
|
在 Room 结构体中添加:
|
||||||
|
```go
|
||||||
|
ToolExecutor *tools.Executor
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 修改 handle.go 初始化工具执行器**
|
||||||
|
|
||||||
|
在 Load 函数中添加:
|
||||||
|
```go
|
||||||
|
r.ToolExecutor = tools.NewExecutor(filepath.Join(roomDir, "workspace"))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: 修改 room 调用逻辑以支持工具调用**
|
||||||
|
|
||||||
|
在需要的地方(如 build 模式下 Agent 调用),传入工具定义并处理工具调用结果。
|
||||||
|
|
||||||
|
**Step 4: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add internal/room/handle.go internal/room/types.go
|
||||||
|
git commit -m "feat: 集成文件操作工具到 Room"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: 注册 Git 工具定义
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `internal/room/tools/definitions.go`
|
||||||
|
|
||||||
|
**Step 1: 添加 Git 工具定义**
|
||||||
|
|
||||||
|
在 FileTools 切片中添加:
|
||||||
|
```go
|
||||||
|
{
|
||||||
|
Type: openai.ToolTypeFunction,
|
||||||
|
Function: openai.FunctionDefinition{
|
||||||
|
Name: "git_status",
|
||||||
|
Description: "查看 Git 工作区状态",
|
||||||
|
Parameters: `{"type": "object", "properties": {}}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: openai.ToolTypeFunction,
|
||||||
|
Function: openai.FunctionDefinition{
|
||||||
|
Name: "git_diff",
|
||||||
|
Description: "查看文件变更",
|
||||||
|
Parameters: `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"filename": {"type": "string", "description": "文件名"}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: openai.ToolTypeFunction,
|
||||||
|
Function: openai.FunctionDefinition{
|
||||||
|
Name: "git_commit",
|
||||||
|
Description: "提交更改",
|
||||||
|
Parameters: `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"message": {"type": "string", "description": "提交信息"}
|
||||||
|
},
|
||||||
|
"required": ["message"]
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add internal/room/tools/definitions.go
|
||||||
|
git commit -m "feat: 添加 Git 工具定义"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: 测试文件操作工具
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `internal/room/tools/executor_test.go`
|
||||||
|
|
||||||
|
**Step 1: 创建测试文件**
|
||||||
|
|
||||||
|
```go
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGlob(t *testing.T) {
|
||||||
|
// 创建临时目录和测试文件
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte("package test"), 0644)
|
||||||
|
os.WriteFile(filepath.Join(tmpDir, "test.ts"), []byte("const x = 1"), 0644)
|
||||||
|
os.MkdirAll(filepath.Join(tmpDir, "sub"), 0755)
|
||||||
|
os.WriteFile(filepath.Join(tmpDir, "sub", "nested.go"), []byte("package sub"), 0644)
|
||||||
|
|
||||||
|
exec := NewExecutor(tmpDir)
|
||||||
|
|
||||||
|
// 测试 *.go
|
||||||
|
result, err := exec.glob(`{"pattern": "*.go"}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !contains(result, "test.go") {
|
||||||
|
t.Errorf("expected test.go in result, got: %s", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 **/*.go
|
||||||
|
result, err = exec.glob(`{"pattern": "**/*.go"}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !contains(result, "test.go") || !contains(result, "sub/nested.go") {
|
||||||
|
t.Errorf("expected test.go and sub/nested.go, got: %s", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadFile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
content := "line1\nline2\nline3\nline4\nline5"
|
||||||
|
os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte(content), 0644)
|
||||||
|
|
||||||
|
exec := NewExecutor(tmpDir)
|
||||||
|
|
||||||
|
// 测试基本读取
|
||||||
|
result, err := exec.readFile(`{"filename": "test.txt"}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !contains(result, "line1") {
|
||||||
|
t.Errorf("expected line1 in result, got: %s", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 offset 和 limit
|
||||||
|
result, err = exec.readFile(`{"filename": "test.txt", "offset": 1, "limit": 2}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !contains(result, "line2") || !contains(result, "line3") {
|
||||||
|
t.Errorf("expected line2 and: %s", result)
|
||||||
|
}
|
||||||
|
line3, got}
|
||||||
|
|
||||||
|
func TestEditFile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte("hello world"), 0644)
|
||||||
|
|
||||||
|
exec := NewExecutor(tmpDir)
|
||||||
|
|
||||||
|
result, err := exec.editFile(`{"filename": "test.txt", "old_string": "world", "new_string": "opencode"}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证文件内容
|
||||||
|
data, _ := os.ReadFile(filepath.Join(tmpDir, "test.txt"))
|
||||||
|
if string(data) != "hello opencode" {
|
||||||
|
t.Errorf("expected 'hello opencode', got: %s", string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = result // ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteFile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
exec := NewExecutor(tmpDir)
|
||||||
|
|
||||||
|
// 测试新建文件
|
||||||
|
result, err := exec.writeFile(`{"filename": "new.txt", "content": "new content"}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := os.ReadFile(filepath.Join(tmpDir, "new.txt"))
|
||||||
|
if string(data) != "new content" {
|
||||||
|
t.Errorf("expected 'new content', got: %s", string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = result // ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListWorkspace(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
os.WriteFile(filepath.Join(tmpDir, "a.txt"), []byte("a"), 0644)
|
||||||
|
os.WriteFile(filepath.Join(tmpDir, "b.txt"), []byte("b"), 0644)
|
||||||
|
os.MkdirAll(filepath.Join(tmpDir, "sub"), 0755)
|
||||||
|
os.WriteFile(filepath.Join(tmpDir, "sub", "c.txt"), []byte("c"), 0644)
|
||||||
|
|
||||||
|
exec := NewExecutor(tmpDir)
|
||||||
|
|
||||||
|
result, err := exec.listWorkspace()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !contains(result, "a.txt") || !contains(result, "b.txt") || !contains(result, "sub/c.txt") {
|
||||||
|
t.Errorf("expected all files, got: %s", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(s, substr string) bool {
|
||||||
|
return len(s) > 0 && strings.Contains(s, substr)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 运行测试**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/room/tools/... -v
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add internal/room/tools/executor_test.go
|
||||||
|
git commit -m "test: 添加文件操作工具测试"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plan complete
|
||||||
|
|
||||||
|
**Two execution options:**
|
||||||
|
|
||||||
|
1. **Subagent-Driven (this session)** - I dispatch fresh subagent per task, review between tasks, fast iteration
|
||||||
|
|
||||||
|
2. **Parallel Session (separate)** - Open new session with executing-plans, batch execution with checkpoints
|
||||||
|
|
||||||
|
**Which approach?**
|
||||||
12
internal/prompt/templates/file_call_master_update.md
Normal file
12
internal/prompt/templates/file_call_master_update.md
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
[系统·文档更新调用] 请根据用户的修改要求,重新产出文件《{{.DocName}}》的完整 Markdown 正文。
|
||||||
|
|
||||||
|
修改要求:{{.UpdateHint}}
|
||||||
|
|
||||||
|
当前文件内容:
|
||||||
|
{{.CurrentContent}}
|
||||||
|
|
||||||
|
要求:
|
||||||
|
- 以 # {{.DocName}} 开头
|
||||||
|
- 在现有内容基础上进行修改,保留未涉及的部分
|
||||||
|
- 只输出文档正文,不要包含任何交流内容
|
||||||
|
- 系统会自动保存到 workspace/{{.FilePath}}
|
||||||
@ -4,4 +4,6 @@
|
|||||||
2. 系统会自动为你发起文档调用,你不需要在聊天中输出文档正文
|
2. 系统会自动为你发起文档调用,你不需要在聊天中输出文档正文
|
||||||
3. 不要说"请切换到 Build 模式"——你已经在 Build 模式中
|
3. 不要说"请切换到 Build 模式"——你已经在 Build 模式中
|
||||||
4. 聊天回复只包含:简短的规划说明、任务分配、进度评价
|
4. 聊天回复只包含:简短的规划说明、任务分配、进度评价
|
||||||
|
5. 每次只安排一章的写作,完成后等待用户指令再继续下一章
|
||||||
|
6. 当用户要求修改你负责的文件时,在回复中明确提到文件名(如"已更新《创作需求书》"),系统会自动发起文档更新调用
|
||||||
</mode_constraint>
|
</mode_constraint>
|
||||||
@ -6,3 +6,34 @@
|
|||||||
4. 用户说"确认""执行""开始""ok"等执行类指令时,你必须回复:请先切换到 Build 模式再执行
|
4. 用户说"确认""执行""开始""ok"等执行类指令时,你必须回复:请先切换到 Build 模式再执行
|
||||||
5. 不要说"好的,开始执行"——你在 Plan 模式下无法执行任何操作
|
5. 不要说"好的,开始执行"——你在 Plan 模式下无法执行任何操作
|
||||||
</mode_constraint>
|
</mode_constraint>
|
||||||
|
|
||||||
|
<brainstorm_guide>
|
||||||
|
你在 Plan 模式下的核心职责是:帮助用户从模糊想法变成清晰可执行的需求。
|
||||||
|
|
||||||
|
## 讨论节奏
|
||||||
|
|
||||||
|
### 第一步:理解(1-2轮对话)
|
||||||
|
- 用户给出主题后,先确认你理解了核心意图
|
||||||
|
- 主动提出 2-3 个关键问题,帮用户明确方向
|
||||||
|
- 问题要具体(不要问"你有什么要求",而要问"目标受众是谁"、"核心诉求是什么")
|
||||||
|
|
||||||
|
### 第二步:发散(1-2轮对话)
|
||||||
|
- 基于用户回答,提出多个可能的方案或方向
|
||||||
|
- 每个方案一句话说清核心差异
|
||||||
|
- 可以提出用户没想到的角度
|
||||||
|
|
||||||
|
### 第三步:收敛(1轮对话)
|
||||||
|
- 总结讨论成果,列出已确定的关键要素(3-5条)
|
||||||
|
- 明确标注待定项
|
||||||
|
- 给出你推荐的方案及理由
|
||||||
|
|
||||||
|
### 第四步:确认
|
||||||
|
- 关键要素基本确定后,主动提示:
|
||||||
|
"讨论已经比较充分了,你可以切换到 Build 模式,我来安排团队执行。"
|
||||||
|
- 不要过早提示(至少完成理解和收敛),也不要忘记提示
|
||||||
|
|
||||||
|
## 注意
|
||||||
|
- 每次回复 3-5 句话,不要长篇大论
|
||||||
|
- 主动引导,不要被动等用户提问
|
||||||
|
- 信息充分时可跳过发散直接收敛
|
||||||
|
</brainstorm_guide>
|
||||||
@ -1,3 +1,3 @@
|
|||||||
[系统] 以下任务被阻止,因为前置阶段尚未完成:
|
[系统] 以下任务被阻止,因为前置材料尚未全部完成:
|
||||||
{{.BlockedList}}
|
{{.BlockedList}}
|
||||||
请先完成当前阶段的工作,再推进下一阶段。
|
请先完成所有前置材料(phase:1),再开始章节写作。
|
||||||
@ -1,16 +1,20 @@
|
|||||||
<reminder>
|
<reminder>
|
||||||
⚠️ 必须更新 TodoList.md,将刚刚完成的任务标记为 [x]。使用以下格式替换整个文件:
|
系统会自动检测已产出的文件并勾选对应任务,你不需要手动标记已完成项。
|
||||||
|
|
||||||
|
你的职责是在关键节点**细化 TodoList**:
|
||||||
|
- 大纲产出后,将"逐章写作"展开为每章一行,格式:`- [ ] 产出《章节/第N章-标题.md》(@负责人)`
|
||||||
|
- 新增任务时,使用 <<<REPLACE TodoList.md>>> 格式更新整个文件
|
||||||
|
- 保留已有的 `- [x]` 完成项,只添加或细化未完成的任务行
|
||||||
|
- 文件名必须用《》包裹,与实际产出路径一致,系统才能自动勾选
|
||||||
|
|
||||||
|
格式示例:
|
||||||
<<<REPLACE TodoList.md>>>
|
<<<REPLACE TodoList.md>>>
|
||||||
# TodoList
|
# TodoList
|
||||||
|
|
||||||
## Phase N:阶段名称
|
- [x] 产出《创作需求书.md》(@主编)
|
||||||
- [x] 已完成任务描述 (@负责人)
|
- [x] 产出《故事大纲.md》(@主编)
|
||||||
- [ ] 未完成任务描述 (@负责人)
|
- [ ] 产出《章节/第1章-初入江湖.md》(@写手)
|
||||||
|
- [ ] 产出《章节/第2章-危机四伏.md》(@写手)
|
||||||
|
- [ ] 告知用户创作完成
|
||||||
<<<END>>>
|
<<<END>>>
|
||||||
|
|
||||||
要求:
|
|
||||||
- 严格使用 <<<REPLACE TodoList.md>>> 开头,<<<END>>> 结尾
|
|
||||||
- 只包含任务列表,不包含任何分配指令或其他对话内容
|
|
||||||
- 这是强制要求,不可跳过
|
|
||||||
</reminder>
|
</reminder>
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/sdaduanbilei/agent-team/internal/agent"
|
"github.com/sdaduanbilei/agent-team/internal/agent"
|
||||||
"github.com/sdaduanbilei/agent-team/internal/llm"
|
"github.com/sdaduanbilei/agent-team/internal/llm"
|
||||||
"github.com/sdaduanbilei/agent-team/internal/prompt"
|
"github.com/sdaduanbilei/agent-team/internal/prompt"
|
||||||
|
"github.com/sdaduanbilei/agent-team/internal/room/tools"
|
||||||
"github.com/sdaduanbilei/agent-team/internal/skill"
|
"github.com/sdaduanbilei/agent-team/internal/skill"
|
||||||
"github.com/sdaduanbilei/agent-team/internal/store"
|
"github.com/sdaduanbilei/agent-team/internal/store"
|
||||||
)
|
)
|
||||||
@ -33,6 +34,8 @@ func Load(roomDir string, agentsDir string, skillsDir string, opts ...LoadOption
|
|||||||
|
|
||||||
r := &Room{Config: cfg, Dir: roomDir, members: make(map[string]*agent.Agent), Mode: "plan", Status: StatusPending}
|
r := &Room{Config: cfg, Dir: roomDir, members: make(map[string]*agent.Agent), Mode: "plan", Status: StatusPending}
|
||||||
|
|
||||||
|
r.ToolExecutor = tools.NewExecutor(filepath.Join(roomDir, "workspace"))
|
||||||
|
|
||||||
projectRoot := filepath.Dir(agentsDir)
|
projectRoot := filepath.Dir(agentsDir)
|
||||||
if data, err := os.ReadFile(filepath.Join(projectRoot, "SYSTEM.md")); err == nil {
|
if data, err := os.ReadFile(filepath.Join(projectRoot, "SYSTEM.md")); err == nil {
|
||||||
r.systemRules = string(data)
|
r.systemRules = string(data)
|
||||||
@ -347,7 +350,10 @@ func (r *Room) HandleUserMessage(ctx context.Context, userName, userMsg string)
|
|||||||
board := &SharedBoard{}
|
board := &SharedBoard{}
|
||||||
r.setStatus(StatusWorking, "", "")
|
r.setStatus(StatusWorking, "", "")
|
||||||
r.runMembersParallel(ctx, assignments, board, skillXML)
|
r.runMembersParallel(ctx, assignments, board, skillXML)
|
||||||
r.runChallengeRound(ctx, board, skillXML)
|
// 只在写作阶段(前置材料全部完成后)触发审读
|
||||||
|
if r.allStaticFilesDone() {
|
||||||
|
r.runChallengeRound(ctx, board, skillXML)
|
||||||
|
}
|
||||||
|
|
||||||
r.setStatus(StatusPending, "", "")
|
r.setStatus(StatusPending, "", "")
|
||||||
return nil
|
return nil
|
||||||
@ -378,6 +384,9 @@ func (r *Room) HandleUserMessage(ctx context.Context, userName, userMsg string)
|
|||||||
if projectCtx := r.buildProjectContext(r.master.Config.Name); projectCtx != "" {
|
if projectCtx := r.buildProjectContext(r.master.Config.Name); projectCtx != "" {
|
||||||
extraContext = extraContext + "\n\n" + projectCtx
|
extraContext = extraContext + "\n\n" + projectCtx
|
||||||
}
|
}
|
||||||
|
if wsCtx := r.buildWorkspaceContext(); wsCtx != "" {
|
||||||
|
extraContext = extraContext + "\n\n" + wsCtx
|
||||||
|
}
|
||||||
systemPrompt := r.master.BuildSystemPrompt(extraContext)
|
systemPrompt := r.master.BuildSystemPrompt(extraContext)
|
||||||
modeInfo := fmt.Sprintf("\n\n当前用户:%s\n当前模式:%s", userName, r.Mode)
|
modeInfo := fmt.Sprintf("\n\n当前用户:%s\n当前模式:%s", userName, r.Mode)
|
||||||
if r.Mode == "build" {
|
if r.Mode == "build" {
|
||||||
@ -400,13 +409,13 @@ func (r *Room) HandleUserMessage(ctx context.Context, userName, userMsg string)
|
|||||||
copy(masterMsgs, r.masterHistory)
|
copy(masterMsgs, r.masterHistory)
|
||||||
r.historyMu.Unlock()
|
r.historyMu.Unlock()
|
||||||
|
|
||||||
// Master 规划循环
|
// Master 规划循环:简化为最多 3 轮(安全上限),每次用户消息通常只跑 1 轮
|
||||||
executedMembers := make(map[string]int) // 成员名 → 已执行的 phase,防止同 phase 重复分配
|
const maxIterations = 3
|
||||||
for iteration := 0; iteration < 12; iteration++ {
|
for iteration := 0; iteration < maxIterations; iteration++ {
|
||||||
log.Printf("[room %s] master iteration %d, sending to LLM...", r.Config.Name, iteration)
|
log.Printf("[room %s] master iteration %d, sending to LLM...", r.Config.Name, iteration)
|
||||||
|
|
||||||
if r.projectTemplate != nil {
|
if r.projectTemplate != nil {
|
||||||
if !r.masterCallerDecidedIteration(ctx, &masterMsgs, skillXML, iteration, executedMembers) {
|
if !r.masterCallerDecidedIteration(ctx, &masterMsgs, skillXML, iteration) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -423,7 +432,7 @@ func (r *Room) HandleUserMessage(ctx context.Context, userName, userMsg string)
|
|||||||
|
|
||||||
// masterCallerDecidedIteration 执行一次 Caller-Decided 路径的 master 迭代。
|
// masterCallerDecidedIteration 执行一次 Caller-Decided 路径的 master 迭代。
|
||||||
// 返回 true 表示继续循环,false 表示 break。
|
// 返回 true 表示继续循环,false 表示 break。
|
||||||
func (r *Room) masterCallerDecidedIteration(ctx context.Context, masterMsgs *[]llm.Message, skillXML string, iteration int, executedMembers map[string]int) bool {
|
func (r *Room) masterCallerDecidedIteration(ctx context.Context, masterMsgs *[]llm.Message, skillXML string, iteration int) bool {
|
||||||
if iteration > 0 {
|
if iteration > 0 {
|
||||||
r.setStatus(StatusThinking, r.master.Config.Name, "正在规划...")
|
r.setStatus(StatusThinking, r.master.Config.Name, "正在规划...")
|
||||||
}
|
}
|
||||||
@ -510,22 +519,21 @@ func (r *Room) masterCallerDecidedIteration(ctx context.Context, masterMsgs *[]l
|
|||||||
if isDocument(reply) && r.allStaticFilesDone() {
|
if isDocument(reply) && r.allStaticFilesDone() {
|
||||||
if dynDir, dynOwner, _ := r.getDynamicFileInfo(); dynDir != "" && dynOwner == r.master.Config.Name {
|
if dynDir, dynOwner, _ := r.getDynamicFileInfo(); dynDir != "" && dynOwner == r.master.Config.Name {
|
||||||
chapterFilename := r.extractChapterFilename(reply, dynDir)
|
chapterFilename := r.extractChapterFilename(reply, dynDir)
|
||||||
fullPath := dynDir + "/" + chapterFilename
|
|
||||||
content := strings.TrimSpace(reply)
|
content := strings.TrimSpace(reply)
|
||||||
if !strings.HasPrefix(content, "# ") {
|
if !strings.HasPrefix(content, "# ") {
|
||||||
content = "# " + strings.TrimSuffix(chapterFilename, ".md") + "\n\n" + content
|
content = "# " + strings.TrimSuffix(chapterFilename, ".md") + "\n\n" + content
|
||||||
}
|
}
|
||||||
r.saveWorkspace(fullPath, content)
|
r.saveWorkspace(chapterFilename, content)
|
||||||
docName := strings.TrimSuffix(chapterFilename, ".md")
|
docName := strings.TrimSuffix(chapterFilename, ".md")
|
||||||
r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: fullPath, Title: docName})
|
r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: chapterFilename, Title: docName})
|
||||||
if r.Store != nil {
|
if r.Store != nil {
|
||||||
r.Store.InsertMessage(&store.Message{
|
r.Store.InsertMessage(&store.Message{
|
||||||
RoomID: r.Config.Name, Agent: r.master.Config.Name, Role: "master",
|
RoomID: r.Config.Name, Agent: r.master.Config.Name, Role: "master",
|
||||||
Content: docName, Filename: fullPath, PartType: "document",
|
Content: docName, Filename: chapterFilename, PartType: "document",
|
||||||
GroupID: &r.currentGroupID,
|
GroupID: &r.currentGroupID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
log.Printf("[room %s] 拦截 master 聊天中的章节内容,保存到 %s", r.Config.Name, fullPath)
|
log.Printf("[room %s] 拦截 master 聊天中的章节内容,保存到 %s", r.Config.Name, chapterFilename)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -538,18 +546,7 @@ func (r *Room) masterCallerDecidedIteration(ctx context.Context, masterMsgs *[]l
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 去重:过滤本轮循环中已执行过同 phase 任务的成员
|
// Phase 轻量校验:只在分配写手(phase:2 任务)时检查前置材料是否就绪
|
||||||
for name := range assignments {
|
|
||||||
if prevPhase, done := executedMembers[name]; done {
|
|
||||||
targetFile := r.findMemberTargetFile(name)
|
|
||||||
if targetFile != nil && targetFile.Phase == prevPhase {
|
|
||||||
log.Printf("[room %s] 跳过重复分配: %s(phase %d 已执行)", r.Config.Name, name, prevPhase)
|
|
||||||
delete(assignments, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 强制校验:阻止跨 phase 分配任务
|
|
||||||
if len(assignments) > 0 && r.projectTemplate != nil {
|
if len(assignments) > 0 && r.projectTemplate != nil {
|
||||||
blocked := r.validatePhaseAssignments(assignments)
|
blocked := r.validatePhaseAssignments(assignments)
|
||||||
if len(blocked) > 0 {
|
if len(blocked) > 0 {
|
||||||
@ -581,18 +578,12 @@ func (r *Room) masterCallerDecidedIteration(ctx context.Context, masterMsgs *[]l
|
|||||||
|
|
||||||
// 执行成员任务
|
// 执行成员任务
|
||||||
if len(assignments) > 0 && r.Mode == "build" {
|
if len(assignments) > 0 && r.Mode == "build" {
|
||||||
// 标记本轮已执行的成员及其 phase
|
|
||||||
for name := range assignments {
|
|
||||||
if tf := r.findMemberTargetFile(name); tf != nil {
|
|
||||||
executedMembers[name] = tf.Phase
|
|
||||||
} else {
|
|
||||||
executedMembers[name] = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
board := &SharedBoard{}
|
board := &SharedBoard{}
|
||||||
results := r.runMembersParallel(ctx, assignments, board, skillXML)
|
results := r.runMembersParallel(ctx, assignments, board, skillXML)
|
||||||
r.runChallengeRound(ctx, board, skillXML)
|
// 只在写作阶段(前置材料全部完成后)触发审读
|
||||||
|
if r.allStaticFilesDone() {
|
||||||
|
r.runChallengeRound(ctx, board, skillXML)
|
||||||
|
}
|
||||||
|
|
||||||
r.setStatus(StatusThinking, r.master.Config.Name, "正在审阅成员结果...")
|
r.setStatus(StatusThinking, r.master.Config.Name, "正在审阅成员结果...")
|
||||||
var resultsStr strings.Builder
|
var resultsStr strings.Builder
|
||||||
@ -603,13 +594,9 @@ func (r *Room) masterCallerDecidedIteration(ctx context.Context, masterMsgs *[]l
|
|||||||
"Results": resultsStr.String(),
|
"Results": resultsStr.String(),
|
||||||
"BoardContext": board.ToContext(),
|
"BoardContext": board.ToContext(),
|
||||||
"WorkspaceContext": r.buildWorkspaceContext(),
|
"WorkspaceContext": r.buildWorkspaceContext(),
|
||||||
"WorkflowStep": r.buildWorkflowStep(),
|
"WorkflowStep": r.buildWorkflowStep(),
|
||||||
})
|
})
|
||||||
|
|
||||||
// 提醒更新 TodoList
|
|
||||||
if r.hasTodoList() {
|
|
||||||
feedbackMsg += "\n\n" + r.Prompt.R("todo_reminder")
|
|
||||||
}
|
|
||||||
feedbackLLMMsg := llm.NewMsg("user", feedbackMsg)
|
feedbackLLMMsg := llm.NewMsg("user", feedbackMsg)
|
||||||
*masterMsgs = append(*masterMsgs, feedbackLLMMsg)
|
*masterMsgs = append(*masterMsgs, feedbackLLMMsg)
|
||||||
r.historyMu.Lock()
|
r.historyMu.Lock()
|
||||||
@ -618,9 +605,18 @@ func (r *Room) masterCallerDecidedIteration(ctx context.Context, masterMsgs *[]l
|
|||||||
r.updateTasks(*masterMsgs)
|
r.updateTasks(*masterMsgs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FILE UPDATE CALLS: master 提到要更新已存在的文件
|
||||||
|
if r.Mode == "build" {
|
||||||
|
updateFiles := r.parseMasterUpdateIntent(reply)
|
||||||
|
if len(updateFiles) > 0 {
|
||||||
|
for _, file := range updateFiles {
|
||||||
|
r.masterFileUpdateCall(ctx, masterMsgs, file, reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// FILE CALLS: master 负责的文件
|
// FILE CALLS: master 负责的文件
|
||||||
pendingFiles := r.findPendingMasterFiles()
|
pendingFiles := r.findPendingMasterFiles()
|
||||||
chapterWritten := false
|
|
||||||
if len(pendingFiles) > 0 && r.Mode == "build" {
|
if len(pendingFiles) > 0 && r.Mode == "build" {
|
||||||
for _, file := range pendingFiles {
|
for _, file := range pendingFiles {
|
||||||
r.masterFileCall(ctx, masterMsgs, file)
|
r.masterFileCall(ctx, masterMsgs, file)
|
||||||
@ -630,41 +626,11 @@ func (r *Room) masterCallerDecidedIteration(ctx context.Context, masterMsgs *[]l
|
|||||||
if dynDir, dynOwner, _ := r.getDynamicFileInfo(); dynDir != "" && dynOwner == r.master.Config.Name {
|
if dynDir, dynOwner, _ := r.getDynamicFileInfo(); dynDir != "" && dynOwner == r.master.Config.Name {
|
||||||
// 用 master 的聊天回复作为章节规划提示
|
// 用 master 的聊天回复作为章节规划提示
|
||||||
r.masterChapterFileCall(ctx, masterMsgs, dynDir, reply)
|
r.masterChapterFileCall(ctx, masterMsgs, dynDir, reply)
|
||||||
chapterWritten = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plan 模式下不循环,每次只回复一条等用户
|
// 每次用户消息最多执行一轮分配 + 一次 file call,然后停下等用户
|
||||||
if r.Mode != "build" {
|
return false
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 章节写完后暂停,等用户确认再继续写下一章
|
|
||||||
if chapterWritten {
|
|
||||||
return false // break,等用户说"继续"
|
|
||||||
}
|
|
||||||
|
|
||||||
// 有成员任务执行过:feedbackMsg 已注入(含 workflow step + 下一步指令),直接 continue
|
|
||||||
// 不再注入 continueMsg,避免 master 收到重复的"请分配任务"提示
|
|
||||||
if len(assignments) > 0 {
|
|
||||||
return true // continue,master 根据 feedbackMsg 决定下一步
|
|
||||||
}
|
|
||||||
|
|
||||||
// 只有 file call(无成员任务):注入 continue 提示
|
|
||||||
if len(pendingFiles) > 0 {
|
|
||||||
if stepCtx := r.buildWorkflowStep(); stepCtx != "" {
|
|
||||||
continueMsg := r.Prompt.Render("continue_next", map[string]string{
|
|
||||||
"WorkflowStep": stepCtx,
|
|
||||||
})
|
|
||||||
continueLLMMsg := llm.NewMsg("user", continueMsg)
|
|
||||||
*masterMsgs = append(*masterMsgs, continueLLMMsg)
|
|
||||||
r.historyMu.Lock()
|
|
||||||
r.masterHistory = append(r.masterHistory, continueLLMMsg)
|
|
||||||
r.historyMu.Unlock()
|
|
||||||
}
|
|
||||||
return true // continue
|
|
||||||
}
|
|
||||||
return false // break
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// masterLegacyIteration 执行一次旧路径的 master 迭代(无项目模板)。
|
// masterLegacyIteration 执行一次旧路径的 master 迭代(无项目模板)。
|
||||||
@ -789,7 +755,10 @@ func (r *Room) masterLegacyIteration(ctx context.Context, masterMsgs *[]llm.Mess
|
|||||||
|
|
||||||
board := &SharedBoard{}
|
board := &SharedBoard{}
|
||||||
results := r.runMembersParallel(ctx, assignments, board, skillXML)
|
results := r.runMembersParallel(ctx, assignments, board, skillXML)
|
||||||
r.runChallengeRound(ctx, board, skillXML)
|
// 只在写作阶段(前置材料全部完成后)触发审读
|
||||||
|
if r.allStaticFilesDone() {
|
||||||
|
r.runChallengeRound(ctx, board, skillXML)
|
||||||
|
}
|
||||||
|
|
||||||
r.setStatus(StatusThinking, r.master.Config.Name, "正在审阅成员结果...")
|
r.setStatus(StatusThinking, r.master.Config.Name, "正在审阅成员结果...")
|
||||||
var resultsStr strings.Builder
|
var resultsStr strings.Builder
|
||||||
@ -901,7 +870,10 @@ func (r *Room) handleDirectAssign(ctx context.Context, assignments map[string]st
|
|||||||
board := &SharedBoard{}
|
board := &SharedBoard{}
|
||||||
|
|
||||||
r.runMembersParallel(ctx, assignments, board, skillXML)
|
r.runMembersParallel(ctx, assignments, board, skillXML)
|
||||||
r.runChallengeRound(ctx, board, skillXML)
|
// 只在写作阶段(前置材料全部完成后)触发审读
|
||||||
|
if r.allStaticFilesDone() {
|
||||||
|
r.runChallengeRound(ctx, board, skillXML)
|
||||||
|
}
|
||||||
|
|
||||||
r.setStatus(StatusPending, "", "")
|
r.setStatus(StatusPending, "", "")
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@ -55,7 +55,7 @@ func (r *Room) runMembersParallel(ctx context.Context, assignments map[string]st
|
|||||||
finalReply = r.runToolLoop(ctx, name, member, &memberMsgs, tools, &mu, results)
|
finalReply = r.runToolLoop(ctx, name, member, &memberMsgs, tools, &mu, results)
|
||||||
} else if dynDir, dynOwner, _ := r.getDynamicFileInfo(); dynDir != "" && dynOwner == name && r.allStaticFilesDone() {
|
} else if dynDir, dynOwner, _ := r.getDynamicFileInfo(); dynDir != "" && dynOwner == name && r.allStaticFilesDone() {
|
||||||
// 动态章节写作:也是静默文档调用
|
// 动态章节写作:也是静默文档调用
|
||||||
r.emit(Event{Type: EvtFileWorking, Agent: name, Filename: dynDir + "/", Title: "章节"})
|
r.emit(Event{Type: EvtFileWorking, Agent: name, Filename: "章节", Title: "章节"})
|
||||||
finalReply = r.runToolLoop(ctx, name, member, &memberMsgs, tools, &mu, results)
|
finalReply = r.runToolLoop(ctx, name, member, &memberMsgs, tools, &mu, results)
|
||||||
} else {
|
} else {
|
||||||
// CHAT CALL: 保持现有流式输出逻辑
|
// CHAT CALL: 保持现有流式输出逻辑
|
||||||
@ -77,9 +77,6 @@ func (r *Room) runMembersParallel(ctx context.Context, assignments map[string]st
|
|||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
// 检查成员间委派
|
|
||||||
r.runSecondRound(ctx, results, board, tools, skillXML, &mu)
|
|
||||||
|
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -133,8 +130,12 @@ func (r *Room) buildMemberContext(name, task string, board *SharedBoard, tools [
|
|||||||
taskMsg += "\n\n" + r.Prompt.R("member_update_doc")
|
taskMsg += "\n\n" + r.Prompt.R("member_update_doc")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Caller-Decided: 预先确定目标文件
|
// 优先从任务描述中解析目标文件(支持修订已有文件)
|
||||||
targetFile = r.findMemberTargetFile(name)
|
targetFile = r.parseTargetFileFromTask(name, task)
|
||||||
|
if targetFile == nil {
|
||||||
|
// Caller-Decided: 预先确定目标文件
|
||||||
|
targetFile = r.findMemberTargetFile(name)
|
||||||
|
}
|
||||||
if targetFile != nil {
|
if targetFile != nil {
|
||||||
docName := strings.TrimSuffix(targetFile.Path, ".md")
|
docName := strings.TrimSuffix(targetFile.Path, ".md")
|
||||||
taskMsg += "\n\n" + r.Prompt.Render("file_call_member", map[string]string{
|
taskMsg += "\n\n" + r.Prompt.Render("file_call_member", map[string]string{
|
||||||
|
|||||||
165
internal/room/tools/definitions.go
Normal file
165
internal/room/tools/definitions.go
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import "github.com/sashabaranov/go-openai"
|
||||||
|
|
||||||
|
var FileTools = []openai.Tool{
|
||||||
|
{
|
||||||
|
Type: openai.ToolTypeFunction,
|
||||||
|
Function: &openai.FunctionDefinition{
|
||||||
|
Name: "glob",
|
||||||
|
Description: "模式匹配查找文件,支持 * ** ? 等通配符",
|
||||||
|
Parameters: `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"pattern": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "glob 模式,如 **/*.go, *.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["pattern"]
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: openai.ToolTypeFunction,
|
||||||
|
Function: &openai.FunctionDefinition{
|
||||||
|
Name: "grep",
|
||||||
|
Description: "在文件中搜索内容,支持正则表达式",
|
||||||
|
Parameters: `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"pattern": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "正则表达式搜索模式"
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "搜索目录路径"
|
||||||
|
},
|
||||||
|
"include": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "文件过滤,如 *.go, *.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["pattern"]
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: openai.ToolTypeFunction,
|
||||||
|
Function: &openai.FunctionDefinition{
|
||||||
|
Name: "read_file",
|
||||||
|
Description: "读取文件内容,支持大文件分段读取",
|
||||||
|
Parameters: `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"filename": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "文件名(相对于 workspace)"
|
||||||
|
},
|
||||||
|
"offset": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "起始行号(从 0 开始)"
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "读取行数,默认 100"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["filename"]
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: openai.ToolTypeFunction,
|
||||||
|
Function: &openai.FunctionDefinition{
|
||||||
|
Name: "edit_file",
|
||||||
|
Description: "编辑文件内容,使用字符串替换",
|
||||||
|
Parameters: `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"filename": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "文件名(相对于 workspace)"
|
||||||
|
},
|
||||||
|
"old_string": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "要替换的原始内容"
|
||||||
|
},
|
||||||
|
"new_string": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "替换后的新内容"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["filename", "old_string", "new_string"]
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: openai.ToolTypeFunction,
|
||||||
|
Function: &openai.FunctionDefinition{
|
||||||
|
Name: "write_file",
|
||||||
|
Description: "写入或创建文件",
|
||||||
|
Parameters: `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"filename": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "文件名(相对于 workspace)"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "文件内容"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["filename", "content"]
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: openai.ToolTypeFunction,
|
||||||
|
Function: &openai.FunctionDefinition{
|
||||||
|
Name: "list_workspace",
|
||||||
|
Description: "列出工作区的所有文件",
|
||||||
|
Parameters: `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {}
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: openai.ToolTypeFunction,
|
||||||
|
Function: &openai.FunctionDefinition{
|
||||||
|
Name: "git_status",
|
||||||
|
Description: "查看 Git 工作区状态",
|
||||||
|
Parameters: `{"type": "object", "properties": {}}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: openai.ToolTypeFunction,
|
||||||
|
Function: &openai.FunctionDefinition{
|
||||||
|
Name: "git_diff",
|
||||||
|
Description: "查看文件变更",
|
||||||
|
Parameters: `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"filename": {"type": "string", "description": "文件名"}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: openai.ToolTypeFunction,
|
||||||
|
Function: &openai.FunctionDefinition{
|
||||||
|
Name: "git_commit",
|
||||||
|
Description: "提交更改",
|
||||||
|
Parameters: `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"message": {"type": "string", "description": "提交信息"}
|
||||||
|
},
|
||||||
|
"required": ["message"]
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
399
internal/room/tools/executor.go
Normal file
399
internal/room/tools/executor.go
Normal file
@ -0,0 +1,399 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sdaduanbilei/agent-team/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Executor struct {
|
||||||
|
workspaceDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewExecutor(workspaceDir string) *Executor {
|
||||||
|
return &Executor{workspaceDir: workspaceDir}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) safePath(filename string) (string, error) {
|
||||||
|
abs, err := filepath.Abs(filepath.Join(e.workspaceDir, filename))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(abs, e.workspaceDir) {
|
||||||
|
return "", fmt.Errorf("path traversal detected: %s", filename)
|
||||||
|
}
|
||||||
|
return abs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) Execute(toolCall llm.ToolCall) (string, error) {
|
||||||
|
switch toolCall.Function.Name {
|
||||||
|
case "glob":
|
||||||
|
return e.glob(toolCall.Function.Arguments)
|
||||||
|
case "grep":
|
||||||
|
return e.grep(toolCall.Function.Arguments)
|
||||||
|
case "read_file":
|
||||||
|
return e.readFile(toolCall.Function.Arguments)
|
||||||
|
case "edit_file":
|
||||||
|
return e.editFile(toolCall.Function.Arguments)
|
||||||
|
case "write_file":
|
||||||
|
return e.writeFile(toolCall.Function.Arguments)
|
||||||
|
case "list_workspace":
|
||||||
|
return e.listWorkspace()
|
||||||
|
case "git_status":
|
||||||
|
return e.gitStatus()
|
||||||
|
case "git_diff":
|
||||||
|
return e.gitDiff(toolCall.Function.Arguments)
|
||||||
|
case "git_commit":
|
||||||
|
return e.gitCommit(toolCall.Function.Arguments)
|
||||||
|
default:
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type GlobArgs struct {
|
||||||
|
Pattern string `json:"pattern"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) glob(args string) (string, error) {
|
||||||
|
var a GlobArgs
|
||||||
|
if err := json.Unmarshal([]byte(args), &a); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := e.safePath(a.Pattern)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []string
|
||||||
|
pattern := a.Pattern
|
||||||
|
|
||||||
|
if strings.HasPrefix(pattern, "**") {
|
||||||
|
baseDir := e.workspaceDir
|
||||||
|
ext := strings.TrimPrefix(pattern, "**/")
|
||||||
|
err := filepath.Walk(baseDir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
matched, _ := filepath.Match(ext, info.Name())
|
||||||
|
if matched {
|
||||||
|
abs, _ := filepath.Abs(path)
|
||||||
|
rel, _ := filepath.Rel(e.workspaceDir, abs)
|
||||||
|
result = append(result, rel)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
files, err := filepath.Glob(filepath.Join(e.workspaceDir, pattern))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, f := range files {
|
||||||
|
abs, err := filepath.Abs(f)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(abs, e.workspaceDir) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rel, _ := filepath.Rel(e.workspaceDir, abs)
|
||||||
|
result = append(result, rel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result) == 0 {
|
||||||
|
return "未找到匹配的文件", nil
|
||||||
|
}
|
||||||
|
return strings.Join(result, "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type GrepArgs struct {
|
||||||
|
Pattern string `json:"pattern"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Include string `json:"include"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) grep(args string) (string, error) {
|
||||||
|
var a GrepArgs
|
||||||
|
if err := json.Unmarshal([]byte(args), &a); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
searchDir := e.workspaceDir
|
||||||
|
if a.Path != "" {
|
||||||
|
searchDir, err = e.safePath(a.Path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
re, err := regexp.Compile(a.Pattern)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []string
|
||||||
|
err = filepath.Walk(searchDir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if a.Include != "" {
|
||||||
|
matched, _ := filepath.Match(a.Include, info.Name())
|
||||||
|
if !matched {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(string(content), "\n")
|
||||||
|
for i, line := range lines {
|
||||||
|
if re.MatchString(line) {
|
||||||
|
rel, _ := filepath.Rel(e.workspaceDir, path)
|
||||||
|
results = append(results, fmt.Sprintf("%s:%d: %s", rel, i+1, line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) == 0 {
|
||||||
|
return "未找到匹配的内容", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) > 100 {
|
||||||
|
results = results[:100]
|
||||||
|
results = append(results, "... (还有更多结果)")
|
||||||
|
}
|
||||||
|
return strings.Join(results, "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReadFileArgs struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) readFile(args string) (string, error) {
|
||||||
|
var a ReadFileArgs
|
||||||
|
if err := json.Unmarshal([]byte(args), &a); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.Limit == 0 {
|
||||||
|
a.Limit = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
fpath, err := e.safePath(a.Filename)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(fpath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(string(content), "\n")
|
||||||
|
if a.Offset >= len(lines) {
|
||||||
|
return "起始位置超出文件行数", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
end := a.Offset + a.Limit
|
||||||
|
if end > len(lines) {
|
||||||
|
end = len(lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := strings.Join(lines[a.Offset:end], "\n")
|
||||||
|
|
||||||
|
if end < len(lines) {
|
||||||
|
result += fmt.Sprintf("\n\n... (共 %d 行,当前显示 %d-%d)", len(lines), a.Offset+1, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditFileArgs struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
OldString string `json:"old_string"`
|
||||||
|
NewString string `json:"new_string"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) editFile(args string) (string, error) {
|
||||||
|
var a EditFileArgs
|
||||||
|
if err := json.Unmarshal([]byte(args), &a); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
fpath, err := e.safePath(a.Filename)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
original, err := os.ReadFile(fpath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(string(original), a.OldString) {
|
||||||
|
return "", fmt.Errorf("文件中未找到要替换的内容,请使用更精确的匹配字符串或使用 write_file 完整覆盖文件")
|
||||||
|
}
|
||||||
|
|
||||||
|
updated := strings.Replace(string(original), a.OldString, a.NewString, 1)
|
||||||
|
if err := os.WriteFile(fpath, []byte(updated), 0644); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("已更新文件: %s", a.Filename), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type WriteFileArgs struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) writeFile(args string) (string, error) {
|
||||||
|
var a WriteFileArgs
|
||||||
|
if err := json.Unmarshal([]byte(args), &a); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
fpath, err := e.safePath(a.Filename)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
os.MkdirAll(filepath.Dir(fpath), 0755)
|
||||||
|
|
||||||
|
exists := ""
|
||||||
|
if _, err := os.Stat(fpath); err == nil {
|
||||||
|
exists = " (已存在,已覆盖)"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(fpath, []byte(a.Content), 0644); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("已写入文件: %s%s", a.Filename, exists), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) listWorkspace() (string, error) {
|
||||||
|
var files []string
|
||||||
|
err := filepath.Walk(e.workspaceDir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel, _ := filepath.Rel(e.workspaceDir, path)
|
||||||
|
if !strings.HasPrefix(rel, ".") {
|
||||||
|
files = append(files, rel)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(files) == 0 {
|
||||||
|
return "工作区为空", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(files, "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) gitStatus() (string, error) {
|
||||||
|
cmd := exec.Command("git", "status", "--porcelain")
|
||||||
|
cmd.Dir = e.workspaceDir
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return "工作区干净,无待提交更改", nil
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type GitDiffArgs struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) gitDiff(args string) (string, error) {
|
||||||
|
var a GitDiffArgs
|
||||||
|
if err := json.Unmarshal([]byte(args), &a); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := ""
|
||||||
|
if a.Filename != "" {
|
||||||
|
fpath, err := e.safePath(a.Filename)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
filename, _ = filepath.Rel(e.workspaceDir, fpath)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
if filename != "" {
|
||||||
|
cmd = exec.Command("git", "diff", filename)
|
||||||
|
} else {
|
||||||
|
cmd = exec.Command("git", "diff")
|
||||||
|
}
|
||||||
|
cmd.Dir = e.workspaceDir
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return "无更改", nil
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type GitCommitArgs struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) gitCommit(args string) (string, error) {
|
||||||
|
var a GitCommitArgs
|
||||||
|
if err := json.Unmarshal([]byte(args), &a); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("git", "add", "-A")
|
||||||
|
cmd.Dir = e.workspaceDir
|
||||||
|
if _, err := cmd.Output(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd = exec.Command("git", "commit", "-m", a.Message)
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
118
internal/room/tools/executor_test.go
Normal file
118
internal/room/tools/executor_test.go
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGlob(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte("package test"), 0644)
|
||||||
|
os.WriteFile(filepath.Join(tmpDir, "test.ts"), []byte("const x = 1"), 0644)
|
||||||
|
os.MkdirAll(filepath.Join(tmpDir, "sub"), 0755)
|
||||||
|
os.WriteFile(filepath.Join(tmpDir, "sub", "nested.go"), []byte("package sub"), 0644)
|
||||||
|
|
||||||
|
exec := NewExecutor(tmpDir)
|
||||||
|
|
||||||
|
result, err := exec.glob(`{"pattern": "*.go"}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !contains(result, "test.go") {
|
||||||
|
t.Errorf("expected test.go in result, got: %s", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err = exec.glob(`{"pattern": "**/*.go"}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !contains(result, "test.go") || !contains(result, "sub/nested.go") {
|
||||||
|
t.Errorf("expected test.go and sub/nested.go, got: %s", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadFile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
content := "line1\nline2\nline3\nline4\nline5"
|
||||||
|
os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte(content), 0644)
|
||||||
|
|
||||||
|
exec := NewExecutor(tmpDir)
|
||||||
|
|
||||||
|
result, err := exec.readFile(`{"filename": "test.txt"}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !contains(result, "line1") {
|
||||||
|
t.Errorf("expected line1 in result, got: %s", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err = exec.readFile(`{"filename": "test.txt", "offset": 1, "limit": 2}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !contains(result, "line2") || !contains(result, "line3") {
|
||||||
|
t.Errorf("expected line2 and line3, got: %s", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEditFile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte("hello world"), 0644)
|
||||||
|
|
||||||
|
exec := NewExecutor(tmpDir)
|
||||||
|
|
||||||
|
result, err := exec.editFile(`{"filename": "test.txt", "old_string": "world", "new_string": "opencode"}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := os.ReadFile(filepath.Join(tmpDir, "test.txt"))
|
||||||
|
if string(data) != "hello opencode" {
|
||||||
|
t.Errorf("expected 'hello opencode', got: %s", string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = result
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteFile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
exec := NewExecutor(tmpDir)
|
||||||
|
|
||||||
|
result, err := exec.writeFile(`{"filename": "new.txt", "content": "new content"}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := os.ReadFile(filepath.Join(tmpDir, "new.txt"))
|
||||||
|
if string(data) != "new content" {
|
||||||
|
t.Errorf("expected 'new content', got: %s", string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = result
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListWorkspace(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
os.WriteFile(filepath.Join(tmpDir, "a.txt"), []byte("a"), 0644)
|
||||||
|
os.WriteFile(filepath.Join(tmpDir, "b.txt"), []byte("b"), 0644)
|
||||||
|
os.MkdirAll(filepath.Join(tmpDir, "sub"), 0755)
|
||||||
|
os.WriteFile(filepath.Join(tmpDir, "sub", "c.txt"), []byte("c"), 0644)
|
||||||
|
|
||||||
|
exec := NewExecutor(tmpDir)
|
||||||
|
|
||||||
|
result, err := exec.listWorkspace()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !contains(result, "a.txt") || !contains(result, "b.txt") || !contains(result, "sub/c.txt") {
|
||||||
|
t.Errorf("expected all files, got: %s", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(s, substr string) bool {
|
||||||
|
return len(s) > 0 && strings.Contains(s, substr)
|
||||||
|
}
|
||||||
@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/sdaduanbilei/agent-team/internal/agent"
|
"github.com/sdaduanbilei/agent-team/internal/agent"
|
||||||
"github.com/sdaduanbilei/agent-team/internal/llm"
|
"github.com/sdaduanbilei/agent-team/internal/llm"
|
||||||
"github.com/sdaduanbilei/agent-team/internal/prompt"
|
"github.com/sdaduanbilei/agent-team/internal/prompt"
|
||||||
|
"github.com/sdaduanbilei/agent-team/internal/room/tools"
|
||||||
"github.com/sdaduanbilei/agent-team/internal/skill"
|
"github.com/sdaduanbilei/agent-team/internal/skill"
|
||||||
"github.com/sdaduanbilei/agent-team/internal/store"
|
"github.com/sdaduanbilei/agent-team/internal/store"
|
||||||
"github.com/sdaduanbilei/agent-team/internal/user"
|
"github.com/sdaduanbilei/agent-team/internal/user"
|
||||||
@ -39,15 +40,15 @@ type Config struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Room struct {
|
type Room struct {
|
||||||
Config Config
|
Config Config
|
||||||
Dir string
|
Dir string
|
||||||
master *agent.Agent
|
master *agent.Agent
|
||||||
members map[string]*agent.Agent
|
members map[string]*agent.Agent
|
||||||
skillMeta []skill.Meta
|
skillMeta []skill.Meta
|
||||||
User *user.User
|
User *user.User
|
||||||
Status Status
|
Status Status
|
||||||
ActiveAgent string // for working status display
|
ActiveAgent string // for working status display
|
||||||
Broadcast func(Event) // set by api layer
|
Broadcast func(Event) // set by api layer
|
||||||
|
|
||||||
// master 的会话历史,保持多轮对话上下文
|
// master 的会话历史,保持多轮对话上下文
|
||||||
masterHistory []llm.Message
|
masterHistory []llm.Message
|
||||||
@ -71,6 +72,8 @@ type Room struct {
|
|||||||
Store *store.Store
|
Store *store.Store
|
||||||
currentGroupID int64 // 当前用户消息的 group_id
|
currentGroupID int64 // 当前用户消息的 group_id
|
||||||
|
|
||||||
|
ToolExecutor *tools.Executor
|
||||||
|
|
||||||
cancelFunc func()
|
cancelFunc func()
|
||||||
cancelMu sync.Mutex
|
cancelMu sync.Mutex
|
||||||
}
|
}
|
||||||
@ -90,31 +93,31 @@ const (
|
|||||||
EvtScheduleRun EventType = "schedule_run"
|
EvtScheduleRun EventType = "schedule_run"
|
||||||
EvtTokenUsage EventType = "token_usage"
|
EvtTokenUsage EventType = "token_usage"
|
||||||
EvtFileRead EventType = "file_read"
|
EvtFileRead EventType = "file_read"
|
||||||
EvtFileWorking EventType = "file_working" // file-llm 开始生成文件
|
EvtFileWorking EventType = "file_working" // file-llm 开始生成文件
|
||||||
EvtFileDone EventType = "file_done" // file-llm 文件生成完成
|
EvtFileDone EventType = "file_done" // file-llm 文件生成完成
|
||||||
)
|
)
|
||||||
|
|
||||||
type Event struct {
|
type Event struct {
|
||||||
Type EventType `json:"type"`
|
Type EventType `json:"type"`
|
||||||
RoomID string `json:"room_id"`
|
RoomID string `json:"room_id"`
|
||||||
Agent string `json:"agent,omitempty"`
|
Agent string `json:"agent,omitempty"`
|
||||||
Role string `json:"role,omitempty"` // master | member | challenge
|
Role string `json:"role,omitempty"` // master | member | challenge
|
||||||
Content string `json:"content,omitempty"`
|
Content string `json:"content,omitempty"`
|
||||||
Streaming bool `json:"streaming,omitempty"`
|
Streaming bool `json:"streaming,omitempty"`
|
||||||
From string `json:"from,omitempty"`
|
From string `json:"from,omitempty"`
|
||||||
To string `json:"to,omitempty"`
|
To string `json:"to,omitempty"`
|
||||||
Task string `json:"task,omitempty"`
|
Task string `json:"task,omitempty"`
|
||||||
Feedback string `json:"feedback,omitempty"`
|
Feedback string `json:"feedback,omitempty"`
|
||||||
Status Status `json:"status,omitempty"`
|
Status Status `json:"status,omitempty"`
|
||||||
ActiveAgent string `json:"active_agent,omitempty"`
|
ActiveAgent string `json:"active_agent,omitempty"`
|
||||||
Action string `json:"action,omitempty"`
|
Action string `json:"action,omitempty"`
|
||||||
Filename string `json:"filename,omitempty"`
|
Filename string `json:"filename,omitempty"`
|
||||||
Mode string `json:"mode,omitempty"`
|
Mode string `json:"mode,omitempty"`
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
PromptTokens int `json:"prompt_tokens,omitempty"`
|
PromptTokens int `json:"prompt_tokens,omitempty"`
|
||||||
CompletionTokens int `json:"completion_tokens,omitempty"`
|
CompletionTokens int `json:"completion_tokens,omitempty"`
|
||||||
TotalTokens int `json:"total_tokens,omitempty"`
|
TotalTokens int `json:"total_tokens,omitempty"`
|
||||||
NoStore bool `json:"-"` // 跳过 emit 中的自动 DB 存储(调用方已显式存储)
|
NoStore bool `json:"-"` // 跳过 emit 中的自动 DB 存储(调用方已显式存储)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProjectFile 项目模板中的单个文件
|
// ProjectFile 项目模板中的单个文件
|
||||||
|
|||||||
@ -52,10 +52,10 @@ func (r *Room) buildWorkflowStep() string {
|
|||||||
sb.WriteString("</workflow_progress>\n\n")
|
sb.WriteString("</workflow_progress>\n\n")
|
||||||
|
|
||||||
if minPendingPhase < 999 {
|
if minPendingPhase < 999 {
|
||||||
sb.WriteString(fmt.Sprintf("<next_action>\n当前阶段:phase %d\n", minPendingPhase))
|
sb.WriteString("<next_action>\n当前阶段:前置材料准备\n")
|
||||||
sb.WriteString(fmt.Sprintf("⚠️ 严格规则:只能分配 phase %d 的任务,系统会阻止跨阶段分配。\n", minPendingPhase))
|
sb.WriteString("待产出的前置材料:\n")
|
||||||
for _, f := range r.projectTemplate.Files {
|
for _, f := range r.projectTemplate.Files {
|
||||||
if f.IsDir || f.Dynamic || f.Phase != minPendingPhase {
|
if f.IsDir || f.Dynamic {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fpath := filepath.Join(r.Dir, "workspace", f.Path)
|
fpath := filepath.Join(r.Dir, "workspace", f.Path)
|
||||||
@ -68,24 +68,8 @@ func (r *Room) buildWorkflowStep() string {
|
|||||||
sb.WriteString(fmt.Sprintf("- 分配给 @%s:%s\n", f.Owner, f.Path))
|
sb.WriteString(fmt.Sprintf("- 分配给 @%s:%s\n", f.Owner, f.Path))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 显示后续阶段作为提示
|
|
||||||
for phase := minPendingPhase + 1; phase <= 6; phase++ {
|
|
||||||
var phaseFiles []string
|
|
||||||
for _, f := range r.projectTemplate.Files {
|
|
||||||
if f.IsDir || f.Dynamic || f.Phase != phase {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fpath := filepath.Join(r.Dir, "workspace", f.Path)
|
|
||||||
if _, err := os.Stat(fpath); os.IsNotExist(err) {
|
|
||||||
phaseFiles = append(phaseFiles, fmt.Sprintf("%s(@%s)", f.Path, f.Owner))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(phaseFiles) > 0 {
|
|
||||||
sb.WriteString(fmt.Sprintf("(后续 phase %d:%s — 当前阶段完成后自动解锁)\n", phase, strings.Join(phaseFiles, "、")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sb.WriteString("</next_action>\n")
|
sb.WriteString("</next_action>\n")
|
||||||
sb.WriteString(fmt.Sprintf("请只分配 phase %d 的任务。你负责的文件由系统自动发起独立调用,不要在回复中输出完整文档正文。", minPendingPhase))
|
sb.WriteString("请安排产出待完成的前置材料。你负责的文件由系统自动发起独立调用,不要在回复中输出完整文档正文。")
|
||||||
} else if dynDir, dynOwner, dynPhase := r.getDynamicFileInfo(); dynDir != "" {
|
} else if dynDir, dynOwner, dynPhase := r.getDynamicFileInfo(); dynDir != "" {
|
||||||
// 所有静态文件已完成,进入动态章节写作阶段
|
// 所有静态文件已完成,进入动态章节写作阶段
|
||||||
existingChapters := r.listChapterFiles(dynDir)
|
existingChapters := r.listChapterFiles(dynDir)
|
||||||
@ -94,7 +78,7 @@ func (r *Room) buildWorkflowStep() string {
|
|||||||
if len(existingChapters) > 0 {
|
if len(existingChapters) > 0 {
|
||||||
sb.WriteString("已完成章节:\n")
|
sb.WriteString("已完成章节:\n")
|
||||||
for _, ch := range existingChapters {
|
for _, ch := range existingChapters {
|
||||||
sb.WriteString(fmt.Sprintf(" [done] %s/%s\n", dynDir, ch))
|
sb.WriteString(fmt.Sprintf(" [done] %s\n", ch))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
isMasterDyn := r.master != nil && dynOwner == r.master.Config.Name
|
isMasterDyn := r.master != nil && dynOwner == r.master.Config.Name
|
||||||
@ -136,40 +120,42 @@ func (r *Room) currentMinPhase() int {
|
|||||||
return minPhase
|
return minPhase
|
||||||
}
|
}
|
||||||
|
|
||||||
// validatePhaseAssignments 校验任务分配是否符合 phase 顺序
|
// validatePhaseAssignments 轻量校验:只在分配写手(phase:2 任务)时检查前置材料(phase:1)是否全部就绪
|
||||||
// 返回被阻止的分配及原因
|
// 返回被阻止的分配及原因
|
||||||
func (r *Room) validatePhaseAssignments(assignments map[string]string) map[string]string {
|
func (r *Room) validatePhaseAssignments(assignments map[string]string) map[string]string {
|
||||||
if r.projectTemplate == nil {
|
if r.projectTemplate == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
minPhase := r.currentMinPhase()
|
|
||||||
if minPhase == 0 {
|
// 检查所有 phase:1 文件是否已完成
|
||||||
return nil // 所有静态文件已完成
|
allPhase1Done := true
|
||||||
|
var pendingPhase1 []string
|
||||||
|
for _, f := range r.projectTemplate.Files {
|
||||||
|
if f.IsDir || f.Dynamic || f.Phase != 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fpath := filepath.Join(r.Dir, "workspace", f.Path)
|
||||||
|
if _, err := os.Stat(fpath); os.IsNotExist(err) {
|
||||||
|
allPhase1Done = false
|
||||||
|
pendingPhase1 = append(pendingPhase1, fmt.Sprintf("%s(@%s)", f.Path, f.Owner))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if allPhase1Done {
|
||||||
|
return nil // 前置材料全部完成,不阻止任何分配
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 只阻止 phase:2(写手/章节)的分配,phase:1 内部不互相阻止
|
||||||
blocked := make(map[string]string)
|
blocked := make(map[string]string)
|
||||||
for name := range assignments {
|
for name := range assignments {
|
||||||
targetFile := r.findMemberTargetFile(name)
|
targetFile := r.findMemberTargetFile(name)
|
||||||
if targetFile == nil {
|
if targetFile == nil {
|
||||||
continue // 没有模板文件,不校验
|
continue
|
||||||
}
|
}
|
||||||
if targetFile.Phase > minPhase {
|
if targetFile.Phase > 1 {
|
||||||
// 该成员的目标文件 phase 大于当前最小未完成 phase
|
|
||||||
// 查找当前 phase 还有哪些文件未完成
|
|
||||||
var pendingInCurrentPhase []string
|
|
||||||
for _, f := range r.projectTemplate.Files {
|
|
||||||
if f.IsDir || f.Dynamic || f.Phase != minPhase {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fpath := filepath.Join(r.Dir, "workspace", f.Path)
|
|
||||||
if _, err := os.Stat(fpath); os.IsNotExist(err) {
|
|
||||||
pendingInCurrentPhase = append(pendingInCurrentPhase, fmt.Sprintf("%s(@%s)", f.Path, f.Owner))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
blocked[name] = fmt.Sprintf(
|
blocked[name] = fmt.Sprintf(
|
||||||
"《%s》属于 phase:%d,但 phase:%d 还有未完成文件:%s",
|
"《%s》属于 phase:%d,但前置材料尚未全部完成:%s",
|
||||||
targetFile.Path, targetFile.Phase, minPhase,
|
targetFile.Path, targetFile.Phase,
|
||||||
strings.Join(pendingInCurrentPhase, "、"))
|
strings.Join(pendingPhase1, "、"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return blocked
|
return blocked
|
||||||
@ -214,18 +200,31 @@ func (r *Room) getDynamicFileInfo() (dir, owner string, phase int) {
|
|||||||
return "", "", 0
|
return "", "", 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// listChapterFiles 列出动态目录下已有的章节文件
|
// listChapterFiles 列出 workspace 根目录下的章节文件(非模板静态文件的 .md 文件)
|
||||||
func (r *Room) listChapterFiles(dir string) []string {
|
func (r *Room) listChapterFiles(dir string) []string {
|
||||||
chDir := filepath.Join(r.Dir, "workspace", dir)
|
wsDir := filepath.Join(r.Dir, "workspace")
|
||||||
entries, err := os.ReadDir(chDir)
|
entries, err := os.ReadDir(wsDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// 收集模板静态文件名,用于排除
|
||||||
|
staticFiles := make(map[string]bool)
|
||||||
|
if r.projectTemplate != nil {
|
||||||
|
for _, f := range r.projectTemplate.Files {
|
||||||
|
if !f.IsDir && !f.Dynamic {
|
||||||
|
staticFiles[f.Path] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
var files []string
|
var files []string
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if !e.IsDir() && strings.HasSuffix(e.Name(), ".md") {
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
|
||||||
files = append(files, e.Name())
|
continue
|
||||||
}
|
}
|
||||||
|
if staticFiles[e.Name()] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
files = append(files, e.Name())
|
||||||
}
|
}
|
||||||
return files
|
return files
|
||||||
}
|
}
|
||||||
@ -233,7 +232,7 @@ func (r *Room) listChapterFiles(dir string) []string {
|
|||||||
// masterChapterFileCall 为 master 发起一次章节文档调用
|
// masterChapterFileCall 为 master 发起一次章节文档调用
|
||||||
func (r *Room) masterChapterFileCall(ctx context.Context, masterMsgs *[]llm.Message, dir string, chapterHint string) {
|
func (r *Room) masterChapterFileCall(ctx context.Context, masterMsgs *[]llm.Message, dir string, chapterHint string) {
|
||||||
r.setStatus(StatusWorking, r.master.Config.Name, "正在编写章节...")
|
r.setStatus(StatusWorking, r.master.Config.Name, "正在编写章节...")
|
||||||
r.emit(Event{Type: EvtFileWorking, Agent: r.master.Config.Name, Filename: dir + "/", Title: "章节"})
|
r.emit(Event{Type: EvtFileWorking, Agent: r.master.Config.Name, Filename: "章节", Title: "章节"})
|
||||||
|
|
||||||
existingChapters := r.listChapterFiles(dir)
|
existingChapters := r.listChapterFiles(dir)
|
||||||
var existingList string
|
var existingList string
|
||||||
@ -269,30 +268,29 @@ func (r *Room) masterChapterFileCall(ctx context.Context, masterMsgs *[]llm.Mess
|
|||||||
|
|
||||||
content := strings.TrimSpace(reply)
|
content := strings.TrimSpace(reply)
|
||||||
|
|
||||||
// 从内容中提取章节标题作为文件名
|
// 从内容中提取章节标题作为文件名(直接保存到 workspace 根目录,不创建子文件夹)
|
||||||
chapterFilename := r.extractChapterFilename(content, dir)
|
chapterFilename := r.extractChapterFilename(content, dir)
|
||||||
fullPath := dir + "/" + chapterFilename
|
|
||||||
|
|
||||||
if !strings.HasPrefix(content, "# ") {
|
if !strings.HasPrefix(content, "# ") {
|
||||||
content = "# " + strings.TrimSuffix(chapterFilename, ".md") + "\n\n" + content
|
content = "# " + strings.TrimSuffix(chapterFilename, ".md") + "\n\n" + content
|
||||||
}
|
}
|
||||||
|
|
||||||
r.saveWorkspace(fullPath, content)
|
r.saveWorkspace(chapterFilename, content)
|
||||||
docName := strings.TrimSuffix(chapterFilename, ".md")
|
docName := strings.TrimSuffix(chapterFilename, ".md")
|
||||||
r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: fullPath, Title: docName})
|
r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: chapterFilename, Title: docName})
|
||||||
|
|
||||||
if r.Store != nil {
|
if r.Store != nil {
|
||||||
r.Store.InsertMessage(&store.Message{
|
r.Store.InsertMessage(&store.Message{
|
||||||
RoomID: r.Config.Name, Agent: r.master.Config.Name, Role: "master",
|
RoomID: r.Config.Name, Agent: r.master.Config.Name, Role: "master",
|
||||||
Content: docName, Filename: fullPath, PartType: "document",
|
Content: docName, Filename: chapterFilename, PartType: "document",
|
||||||
GroupID: &r.currentGroupID,
|
GroupID: &r.currentGroupID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
r.emit(Event{Type: EvtFileDone, Agent: r.master.Config.Name, Filename: fullPath, Title: docName})
|
r.emit(Event{Type: EvtFileDone, Agent: r.master.Config.Name, Filename: chapterFilename, Title: docName})
|
||||||
|
|
||||||
// 发送完成状态到聊天
|
// 发送完成状态到聊天
|
||||||
statusMsg := fmt.Sprintf("《%s》已完成,保存到 %s。", docName, fullPath)
|
statusMsg := fmt.Sprintf("《%s》已完成,保存到 %s。", docName, chapterFilename)
|
||||||
r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master", Content: statusMsg, NoStore: true})
|
r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master", Content: statusMsg, NoStore: true})
|
||||||
if r.Store != nil {
|
if r.Store != nil {
|
||||||
r.Store.InsertMessage(&store.Message{
|
r.Store.InsertMessage(&store.Message{
|
||||||
@ -424,29 +422,14 @@ func (r *Room) findOwnerFiles(agentName string) []ProjectFile {
|
|||||||
return files
|
return files
|
||||||
}
|
}
|
||||||
|
|
||||||
// findPendingMasterFiles 查找当前最小未完成 phase 中 master 负责的待产出文件
|
// findPendingMasterFiles 查找 master 负责的所有待产出文件(不限 phase)
|
||||||
func (r *Room) findPendingMasterFiles() []ProjectFile {
|
func (r *Room) findPendingMasterFiles() []ProjectFile {
|
||||||
if r.projectTemplate == nil {
|
if r.projectTemplate == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
minPhase := 999
|
|
||||||
for _, f := range r.projectTemplate.Files {
|
|
||||||
if f.IsDir || f.Dynamic {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fpath := filepath.Join(r.Dir, "workspace", f.Path)
|
|
||||||
if _, err := os.Stat(fpath); os.IsNotExist(err) {
|
|
||||||
if f.Phase < minPhase {
|
|
||||||
minPhase = f.Phase
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if minPhase == 999 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var files []ProjectFile
|
var files []ProjectFile
|
||||||
for _, f := range r.projectTemplate.Files {
|
for _, f := range r.projectTemplate.Files {
|
||||||
if f.IsDir || f.Dynamic || f.Phase != minPhase || f.Owner != r.master.Config.Name {
|
if f.IsDir || f.Dynamic || f.Owner != r.master.Config.Name {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fpath := filepath.Join(r.Dir, "workspace", f.Path)
|
fpath := filepath.Join(r.Dir, "workspace", f.Path)
|
||||||
@ -469,11 +452,6 @@ func (r *Room) masterFileCall(ctx context.Context, masterMsgs *[]llm.Message, fi
|
|||||||
"FilePath": file.Path,
|
"FilePath": file.Path,
|
||||||
})
|
})
|
||||||
|
|
||||||
// TodoList 特殊格式要求
|
|
||||||
if strings.Contains(strings.ToLower(file.Path), "todolist") || strings.Contains(strings.ToLower(file.Path), "todo") {
|
|
||||||
filePrompt += "\n\n格式要求:每个任务项必须包含负责人,格式为 `- [ ] 任务描述 (@负责人)`。已完成的用 `- [x]`。"
|
|
||||||
}
|
|
||||||
|
|
||||||
if wsCtx := r.buildWorkspaceContext(); wsCtx != "" {
|
if wsCtx := r.buildWorkspaceContext(); wsCtx != "" {
|
||||||
filePrompt += "\n\n" + wsCtx
|
filePrompt += "\n\n" + wsCtx
|
||||||
}
|
}
|
||||||
@ -493,11 +471,6 @@ func (r *Room) masterFileCall(ctx context.Context, masterMsgs *[]llm.Message, fi
|
|||||||
content = "# " + docName + "\n\n" + content
|
content = "# " + docName + "\n\n" + content
|
||||||
}
|
}
|
||||||
|
|
||||||
// TodoList 特殊处理:去除文件末尾可能混入的 @分配 指令文本
|
|
||||||
if strings.Contains(strings.ToLower(file.Path), "todolist") || strings.Contains(strings.ToLower(file.Path), "todo") {
|
|
||||||
content = stripTrailingAssignments(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
r.saveWorkspace(file.Path, content)
|
r.saveWorkspace(file.Path, content)
|
||||||
r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: file.Path, Title: docName})
|
r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: file.Path, Title: docName})
|
||||||
|
|
||||||
@ -543,6 +516,114 @@ func (r *Room) findMemberTargetFile(name string) *ProjectFile {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseMasterUpdateIntent 从 master 回复中解析更新文件的意图,返回需要更新的已存在模板文件
|
||||||
|
func (r *Room) parseMasterUpdateIntent(reply string) []ProjectFile {
|
||||||
|
if r.projectTemplate == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// 检查 master 回复中是否提到了更新/修改某个已存在的模板文件
|
||||||
|
// 关键词:已更新、已修改、已调整、更新了、修改了
|
||||||
|
updateKeywords := []string{"已更新", "已修改", "已调整", "更新了", "修改了", "修改为", "改为", "调整为"}
|
||||||
|
hasUpdateIntent := false
|
||||||
|
for _, kw := range updateKeywords {
|
||||||
|
if strings.Contains(reply, kw) {
|
||||||
|
hasUpdateIntent = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasUpdateIntent {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var files []ProjectFile
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, f := range r.projectTemplate.Files {
|
||||||
|
if f.IsDir || f.Dynamic || f.Owner != r.master.Config.Name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fpath := filepath.Join(r.Dir, "workspace", f.Path)
|
||||||
|
if _, err := os.Stat(fpath); err != nil {
|
||||||
|
continue // 文件不存在,不是更新场景
|
||||||
|
}
|
||||||
|
// 检查 master 是否在回复中提到了这个文件
|
||||||
|
docName := strings.TrimSuffix(f.Path, ".md")
|
||||||
|
if strings.Contains(reply, docName) || strings.Contains(reply, "《"+docName+"》") {
|
||||||
|
if !seen[f.Path] {
|
||||||
|
seen[f.Path] = true
|
||||||
|
files = append(files, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
// masterFileUpdateCall 为 master 发起一次文件更新调用(更新已存在的文件)
|
||||||
|
func (r *Room) masterFileUpdateCall(ctx context.Context, masterMsgs *[]llm.Message, file ProjectFile, updateHint string) {
|
||||||
|
docName := strings.TrimSuffix(file.Path, ".md")
|
||||||
|
r.setStatus(StatusWorking, r.master.Config.Name, fmt.Sprintf("正在更新《%s》...", docName))
|
||||||
|
r.emit(Event{Type: EvtFileWorking, Agent: r.master.Config.Name, Filename: file.Path, Title: docName})
|
||||||
|
|
||||||
|
// 读取现有文件内容
|
||||||
|
currentContent := ""
|
||||||
|
fpath := filepath.Join(r.Dir, "workspace", file.Path)
|
||||||
|
if data, err := os.ReadFile(fpath); err == nil {
|
||||||
|
currentContent = string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
filePrompt := r.Prompt.Render("file_call_master_update", map[string]string{
|
||||||
|
"DocName": docName,
|
||||||
|
"FilePath": file.Path,
|
||||||
|
"UpdateHint": updateHint,
|
||||||
|
"CurrentContent": currentContent,
|
||||||
|
})
|
||||||
|
|
||||||
|
fileLLMMsg := llm.NewMsg("user", filePrompt)
|
||||||
|
*masterMsgs = append(*masterMsgs, fileLLMMsg)
|
||||||
|
|
||||||
|
reply, usage, err := r.master.ChatWithUsage(ctx, *masterMsgs, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[room %s] master file update call error for %s: %v", r.Config.Name, file.Path, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.emitUsage(r.master.Config.Name, usage)
|
||||||
|
|
||||||
|
content := strings.TrimSpace(reply)
|
||||||
|
if !strings.HasPrefix(content, "# ") {
|
||||||
|
content = "# " + docName + "\n\n" + content
|
||||||
|
}
|
||||||
|
|
||||||
|
r.saveWorkspace(file.Path, content)
|
||||||
|
r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: file.Path, Title: docName})
|
||||||
|
|
||||||
|
if r.Store != nil {
|
||||||
|
r.Store.InsertMessage(&store.Message{
|
||||||
|
RoomID: r.Config.Name, Agent: r.master.Config.Name, Role: "master",
|
||||||
|
Content: docName, Filename: file.Path, PartType: "document",
|
||||||
|
GroupID: &r.currentGroupID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
r.emit(Event{Type: EvtFileDone, Agent: r.master.Config.Name, Filename: file.Path, Title: docName})
|
||||||
|
|
||||||
|
// 发送完成状态到聊天
|
||||||
|
statusMsg := fmt.Sprintf("《%s》已更新。", docName)
|
||||||
|
r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master", Content: statusMsg, NoStore: true})
|
||||||
|
if r.Store != nil {
|
||||||
|
r.Store.InsertMessage(&store.Message{
|
||||||
|
RoomID: r.Config.Name, Agent: r.master.Config.Name, Role: "master",
|
||||||
|
Content: statusMsg, PartType: "text",
|
||||||
|
GroupID: &r.currentGroupID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
assistantMsg := llm.NewMsg("assistant", reply)
|
||||||
|
*masterMsgs = append(*masterMsgs, assistantMsg)
|
||||||
|
r.historyMu.Lock()
|
||||||
|
r.masterHistory = append(r.masterHistory, fileLLMMsg, assistantMsg)
|
||||||
|
r.historyMu.Unlock()
|
||||||
|
r.AppendHistory("master", r.master.Config.Name, reply)
|
||||||
|
}
|
||||||
|
|
||||||
// buildSkillSummary 为 master 构建简要的 skill 清单
|
// buildSkillSummary 为 master 构建简要的 skill 清单
|
||||||
func (r *Room) buildSkillSummary() string {
|
func (r *Room) buildSkillSummary() string {
|
||||||
if len(r.skillMeta) == 0 {
|
if len(r.skillMeta) == 0 {
|
||||||
|
|||||||
@ -80,6 +80,58 @@ func (r *Room) buildWorkspaceContext() string {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// syncTodoList 扫描 TodoList.md,自动将已产出文件的任务勾选为 [x]
|
||||||
|
// 同时修正路径:如果文件名带子目录前缀但实际文件在 workspace 根目录,自动去掉前缀
|
||||||
|
func (r *Room) syncTodoList() {
|
||||||
|
fpath := filepath.Join(r.Dir, "workspace", "TodoList.md")
|
||||||
|
data, err := os.ReadFile(fpath)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fileRe := regexp.MustCompile(`《(.+?)》`)
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
changed := false
|
||||||
|
for i, line := range lines {
|
||||||
|
// 修正路径:所有行(不限 [ ] / [x])中带子目录前缀的文件名,如果实际文件在根目录则去掉前缀
|
||||||
|
for _, m := range fileRe.FindAllStringSubmatch(line, -1) {
|
||||||
|
filename := m[1]
|
||||||
|
baseName := filepath.Base(filename)
|
||||||
|
if baseName != filename {
|
||||||
|
fpSub := filepath.Join(r.Dir, "workspace", filename)
|
||||||
|
fpFlat := filepath.Join(r.Dir, "workspace", baseName)
|
||||||
|
// 子目录下不存在但根目录下存在 → 修正路径
|
||||||
|
if _, err := os.Stat(fpSub); os.IsNotExist(err) {
|
||||||
|
if _, err := os.Stat(fpFlat); err == nil {
|
||||||
|
lines[i] = strings.Replace(lines[i], "《"+filename+"》", "《"+baseName+"》", 1)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 勾选:检查未完成任务对应的文件是否已存在
|
||||||
|
trimmed := strings.TrimSpace(lines[i])
|
||||||
|
if !strings.HasPrefix(trimmed, "- [ ]") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := fileRe.FindStringSubmatch(lines[i])
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filename := m[1]
|
||||||
|
fp := filepath.Join(r.Dir, "workspace", filename)
|
||||||
|
if _, err := os.Stat(fp); err == nil {
|
||||||
|
lines[i] = strings.Replace(lines[i], "- [ ]", "- [x]", 1)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
os.WriteFile(fpath, []byte(strings.Join(lines, "\n")), 0644)
|
||||||
|
r.emit(Event{Type: EvtWorkspaceFile, Filename: "TodoList.md", Action: "updated"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// hasTodoList 检查 workspace 中是否存在 TodoList.md
|
// hasTodoList 检查 workspace 中是否存在 TodoList.md
|
||||||
func (r *Room) hasTodoList() bool {
|
func (r *Room) hasTodoList() bool {
|
||||||
fpath := filepath.Join(r.Dir, "workspace", "TodoList.md")
|
fpath := filepath.Join(r.Dir, "workspace", "TodoList.md")
|
||||||
@ -87,6 +139,16 @@ func (r *Room) hasTodoList() bool {
|
|||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hasPendingTodo 检查 TodoList.md 中是否还有未完成的任务(`- [ ]`)
|
||||||
|
func (r *Room) hasPendingTodo() bool {
|
||||||
|
fpath := filepath.Join(r.Dir, "workspace", "TodoList.md")
|
||||||
|
data, err := os.ReadFile(fpath)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(string(data), "- [ ]")
|
||||||
|
}
|
||||||
|
|
||||||
// saveAgentOutput 统一处理成员产出的文件保存路由(旧路径兼容,无模板时使用)。
|
// saveAgentOutput 统一处理成员产出的文件保存路由(旧路径兼容,无模板时使用)。
|
||||||
// 返回 (filename, routed):routed=true 表示走了文件路由。
|
// 返回 (filename, routed):routed=true 表示走了文件路由。
|
||||||
func (r *Room) saveAgentOutput(name, finalReply, task string) (string, bool) {
|
func (r *Room) saveAgentOutput(name, finalReply, task string) (string, bool) {
|
||||||
@ -151,22 +213,20 @@ func (r *Room) saveAgentOutput(name, finalReply, task string) (string, bool) {
|
|||||||
if dynDir, dynOwner, _ := r.getDynamicFileInfo(); dynDir != "" && dynOwner == name && r.allStaticFilesDone() {
|
if dynDir, dynOwner, _ := r.getDynamicFileInfo(); dynDir != "" && dynOwner == name && r.allStaticFilesDone() {
|
||||||
if isDocument(finalReply) {
|
if isDocument(finalReply) {
|
||||||
chapterFilename := r.extractChapterFilename(finalReply, dynDir)
|
chapterFilename := r.extractChapterFilename(finalReply, dynDir)
|
||||||
fullPath := dynDir + "/" + chapterFilename
|
|
||||||
content := strings.TrimSpace(finalReply)
|
content := strings.TrimSpace(finalReply)
|
||||||
if !strings.HasPrefix(content, "# ") {
|
if !strings.HasPrefix(content, "# ") {
|
||||||
content = "# " + strings.TrimSuffix(chapterFilename, ".md") + "\n\n" + content
|
content = "# " + strings.TrimSuffix(chapterFilename, ".md") + "\n\n" + content
|
||||||
}
|
}
|
||||||
os.MkdirAll(filepath.Join(r.Dir, "workspace", dynDir), 0755)
|
r.saveWorkspace(chapterFilename, content)
|
||||||
r.saveWorkspace(fullPath, content)
|
|
||||||
if r.memberArtifacts == nil {
|
if r.memberArtifacts == nil {
|
||||||
r.memberArtifacts = make(map[string]string)
|
r.memberArtifacts = make(map[string]string)
|
||||||
}
|
}
|
||||||
r.memberArtifacts[name] = fullPath
|
r.memberArtifacts[name] = chapterFilename
|
||||||
docName := strings.TrimSuffix(chapterFilename, ".md")
|
docName := strings.TrimSuffix(chapterFilename, ".md")
|
||||||
r.emit(Event{Type: EvtArtifact, Agent: name, Filename: fullPath, Title: docName})
|
r.emit(Event{Type: EvtArtifact, Agent: name, Filename: chapterFilename, Title: docName})
|
||||||
r.emit(Event{Type: EvtFileDone, Agent: name, Filename: fullPath, Title: docName})
|
r.emit(Event{Type: EvtFileDone, Agent: name, Filename: chapterFilename, Title: docName})
|
||||||
r.emit(Event{Type: EvtTaskDone, Agent: name, Task: task})
|
r.emit(Event{Type: EvtTaskDone, Agent: name, Task: task})
|
||||||
return fullPath, true
|
return chapterFilename, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -285,6 +345,37 @@ func splitContentAndStatus(reply, filename string) (fileContent, statusMsg strin
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseTargetFileFromTask 从任务描述中解析《文件名》,匹配 workspace 已有文件(支持修订已有文件)
|
||||||
|
func (r *Room) parseTargetFileFromTask(memberName, task string) *ProjectFile {
|
||||||
|
re := regexp.MustCompile(`《(.+?)》`)
|
||||||
|
matches := re.FindAllStringSubmatch(task, -1)
|
||||||
|
for _, m := range matches {
|
||||||
|
filename := m[1]
|
||||||
|
if !strings.HasSuffix(filename, ".md") {
|
||||||
|
filename += ".md"
|
||||||
|
}
|
||||||
|
fpath := filepath.Join(r.Dir, "workspace", filename)
|
||||||
|
if _, err := os.Stat(fpath); err == nil {
|
||||||
|
// 文件存在 → 优先匹配模板定义(保留正确的 Owner/Phase)
|
||||||
|
if tf := r.matchTemplateFile(strings.TrimSuffix(filename, ".md")); tf != nil {
|
||||||
|
// 只有文件负责人才能写入
|
||||||
|
if tf.Owner == memberName {
|
||||||
|
return tf
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 模板中没有 → 构造虚拟 ProjectFile
|
||||||
|
return &ProjectFile{
|
||||||
|
Path: filename,
|
||||||
|
Owner: memberName,
|
||||||
|
Phase: 2,
|
||||||
|
Dynamic: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// applyDocumentEdit 解析并应用文档编辑指令
|
// applyDocumentEdit 解析并应用文档编辑指令
|
||||||
// 支持两种格式:
|
// 支持两种格式:
|
||||||
// 1. 全文替换:<<<REPLACE filename>>>\n新内容\n<<<END>>>
|
// 1. 全文替换:<<<REPLACE filename>>>\n新内容\n<<<END>>>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
# Project: Agent Team
|
# Project: Agent Team
|
||||||
Last updated: 2026-03-05
|
Last updated: 2026-03-10
|
||||||
|
|
||||||
## Pinned (关键约束)
|
## Pinned (关键约束)
|
||||||
- 一切皆 MD — agent 配置、soul、memory、tasks、history 全部是 Markdown 文件
|
- 一切皆 MD — agent 配置、soul、memory、tasks、history 全部是 Markdown 文件
|
||||||
@ -20,6 +20,11 @@ Last updated: 2026-03-05
|
|||||||
- 暂无
|
- 暂无
|
||||||
|
|
||||||
## Done
|
## Done
|
||||||
|
- 2026-03-10:Task 5 测试文件操作工具
|
||||||
|
- 创建 internal/room/tools/executor_test.go 测试文件
|
||||||
|
- 修复 executor.go glob 函数支持 `**/*.go` 递归匹配
|
||||||
|
- 代码变更:`files changed: 2`, `insertions: +154`, `deletions: -13`
|
||||||
|
- Evidence: internal/room/tools/executor_test.go (new), internal/room/tools/executor.go
|
||||||
- 2026-03-05:支持从 TEAM.md 读取团队元信息
|
- 2026-03-05:支持从 TEAM.md 读取团队元信息
|
||||||
- 修改 internal/hub/hub.go,添加 readTeamMeta 函数
|
- 修改 internal/hub/hub.go,添加 readTeamMeta 函数
|
||||||
- 安装团队时从 TEAM.md 解析 name/description/author
|
- 安装团队时从 TEAM.md 解析 name/description/author
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
1ee6643f-5997-464c-b8b1-95d34fe1f0a5
|
a79aa3b2-59fb-44e8-ab40-eb6da1803f3d
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
# 我的简介
|
# 我的简介
|
||||||
|
|
||||||
## 我是谁
|
## 我是谁
|
||||||
我是[
|
我是[你的名字],[职业/背景]。
|
||||||
|
|
||||||
## 工作风格
|
## 工作风格
|
||||||
- 喜欢[沟通方式]
|
- 喜欢[沟通方式]
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: 第五季
|
name: 第五季
|
||||||
description: 产品经理,擅长需求分析
|
description: 产品经理
|
||||||
provider: deepseek
|
provider: deepseek
|
||||||
model: deepseek-chat
|
model: deepseek-chat
|
||||||
api_key_env: DEEPSEEK_API_KEY
|
api_key_env: DEEPSEEK_API_KEY
|
||||||
|
|||||||
@ -33,7 +33,7 @@ function formatTime(ts: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ChatView() {
|
export function ChatView() {
|
||||||
const { activeRoomId, rooms, messages, sendMessage, user, tokenUsage, todoItems, workingFiles } = useStore()
|
const { activeRoomId, rooms, messages, sendMessage, user, tokenUsage, todoItems, workingFiles, workStartedAt } = useStore()
|
||||||
const [input, setInput] = useState('')
|
const [input, setInput] = useState('')
|
||||||
const [mentionQuery, setMentionQuery] = useState<string | null>(null)
|
const [mentionQuery, setMentionQuery] = useState<string | null>(null)
|
||||||
const [mentionIndex, setMentionIndex] = useState(0)
|
const [mentionIndex, setMentionIndex] = useState(0)
|
||||||
@ -193,6 +193,7 @@ export function ChatView() {
|
|||||||
todos={activeRoomId ? (todoItems[activeRoomId] || []) : []}
|
todos={activeRoomId ? (todoItems[activeRoomId] || []) : []}
|
||||||
room={room ? { status: room.status, activeAgent: room.activeAgent, action: room.action, master: room.master } : undefined}
|
room={room ? { status: room.status, activeAgent: room.activeAgent, action: room.action, master: room.master } : undefined}
|
||||||
workingFiles={activeRoomId ? (workingFiles[activeRoomId] || []) : []}
|
workingFiles={activeRoomId ? (workingFiles[activeRoomId] || []) : []}
|
||||||
|
startedAt={activeRoomId ? workStartedAt[activeRoomId] : undefined}
|
||||||
/>
|
/>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{/* @ Mention dropdown */}
|
{/* @ Mention dropdown */}
|
||||||
@ -471,7 +472,20 @@ function MarqueeText({ text, className }: { text: string; className?: string })
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AgentStatusBar({ todos, room, workingFiles = [] }: { todos: TodoItem[]; room?: { status: string; activeAgent?: string; action?: string; master: string }; workingFiles?: { agent: string; filename: string; title?: string }[] }) {
|
function ElapsedTimer({ startedAt }: { startedAt: number }) {
|
||||||
|
const [, setTick] = useState(0)
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setTick(t => t + 1), 1000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [])
|
||||||
|
const elapsed = Math.floor((Date.now() - startedAt) / 1000)
|
||||||
|
const m = Math.floor(elapsed / 60)
|
||||||
|
const s = elapsed % 60
|
||||||
|
const text = m > 0 ? `${m}m${s.toString().padStart(2, '0')}s` : `${s}s`
|
||||||
|
return <span className="text-xs opacity-50 tabular-nums">{text}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentStatusBar({ todos, room, workingFiles = [], startedAt }: { todos: TodoItem[]; room?: { status: string; activeAgent?: string; action?: string; master: string }; workingFiles?: { agent: string; filename: string; title?: string }[]; startedAt?: number }) {
|
||||||
// 只在 master 本人是活跃 agent 或正在思考时显示 master 状态
|
// 只在 master 本人是活跃 agent 或正在思考时显示 master 状态
|
||||||
const showMaster = room && (
|
const showMaster = room && (
|
||||||
room.status === 'thinking' ||
|
room.status === 'thinking' ||
|
||||||
@ -481,6 +495,7 @@ function AgentStatusBar({ todos, room, workingFiles = [] }: { todos: TodoItem[];
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 mb-2 px-1 py-1.5 overflow-hidden flex-wrap">
|
<div className="flex items-center gap-2 mb-2 px-1 py-1.5 overflow-hidden flex-wrap">
|
||||||
|
{startedAt && <ElapsedTimer startedAt={startedAt} />}
|
||||||
{/* 文件生成状态 */}
|
{/* 文件生成状态 */}
|
||||||
{workingFiles.map(f => (
|
{workingFiles.map(f => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -27,6 +27,7 @@ interface AppState {
|
|||||||
todoItems: Record<string, TodoItem[]>
|
todoItems: Record<string, TodoItem[]>
|
||||||
fileReaders: Record<string, Record<string, string[]>>
|
fileReaders: Record<string, Record<string, string[]>>
|
||||||
workingFiles: Record<string, { agent: string; filename: string; title?: string }[]>
|
workingFiles: Record<string, { agent: string; filename: string; title?: string }[]>
|
||||||
|
workStartedAt: Record<string, number> // roomId -> timestamp (ms)
|
||||||
|
|
||||||
setTheme: (theme: 'light' | 'dark') => void
|
setTheme: (theme: 'light' | 'dark') => void
|
||||||
toggleTheme: () => void
|
toggleTheme: () => void
|
||||||
@ -83,6 +84,7 @@ export const useStore = create<AppState>((set, get) => {
|
|||||||
todoItems: {},
|
todoItems: {},
|
||||||
fileReaders: {},
|
fileReaders: {},
|
||||||
workingFiles: {},
|
workingFiles: {},
|
||||||
|
workStartedAt: {},
|
||||||
|
|
||||||
setTheme: (theme) => {
|
setTheme: (theme) => {
|
||||||
applyTheme(theme)
|
applyTheme(theme)
|
||||||
@ -257,6 +259,13 @@ export const useStore = create<AppState>((set, get) => {
|
|||||||
: t
|
: t
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// 记录工作开始时间(thinking/working 时设置,pending 时清除)
|
||||||
|
const workStartedAt = { ...s.workStartedAt }
|
||||||
|
if (ev.status === 'pending') {
|
||||||
|
delete workStartedAt[roomId]
|
||||||
|
} else if (!workStartedAt[roomId]) {
|
||||||
|
workStartedAt[roomId] = Date.now()
|
||||||
|
}
|
||||||
// pending 时:清理所有残留 streaming 消息,只在所有任务都 done 后清空 todos
|
// pending 时:清理所有残留 streaming 消息,只在所有任务都 done 后清空 todos
|
||||||
if (ev.status === 'pending') {
|
if (ev.status === 'pending') {
|
||||||
const cleanMsgs = (s.messages[roomId] || []).map(m =>
|
const cleanMsgs = (s.messages[roomId] || []).map(m =>
|
||||||
@ -273,6 +282,7 @@ export const useStore = create<AppState>((set, get) => {
|
|||||||
messages: { ...s.messages, [roomId]: cleanMsgs },
|
messages: { ...s.messages, [roomId]: cleanMsgs },
|
||||||
todoItems: { ...s.todoItems, [roomId]: todos },
|
todoItems: { ...s.todoItems, [roomId]: todos },
|
||||||
workingFiles: { ...s.workingFiles, [roomId]: [] },
|
workingFiles: { ...s.workingFiles, [roomId]: [] },
|
||||||
|
workStartedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@ -280,6 +290,7 @@ export const useStore = create<AppState>((set, get) => {
|
|||||||
? { ...r, status: ev.status, activeAgent: ev.active_agent, action: ev.action }
|
? { ...r, status: ev.status, activeAgent: ev.active_agent, action: ev.action }
|
||||||
: r),
|
: r),
|
||||||
todoItems: { ...s.todoItems, [roomId]: todos },
|
todoItems: { ...s.todoItems, [roomId]: todos },
|
||||||
|
workStartedAt,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else if (ev.type === 'tasks_update') {
|
} else if (ev.type === 'tasks_update') {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user