feat: phase强制校验、重复分配防护、章节file call、成员对话模式
- 系统级phase强制校验:阻止跨阶段分配任务,前置阶段未完成时自动拦截 - 循环内去重:同phase同成员不会被重复执行,消除master重复分配问题 - 消除重复提示:feedbackMsg和continueMsg不再同时注入 - 动态章节file call:所有静态文件完成后自动触发masterChapterFileCall - 章节内容安全网拦截:master在聊天中输出文档内容时自动保存到workspace - 用户@成员对话:区分对话和任务分配,短消息/问候走对话路由而非任务流水线 - handleMemberConversation改进:初始化系统提示、流式输出、DB存储 - 策划编辑AGENT.md:新增前置依赖检查、评分必须引用文档数据来源 - TodoList更新提醒:任务完成后提醒master用编辑指令更新TodoList - buildWorkflowStep强化:显示阶段依赖关系和后续阶段解锁提示 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
421bdf76b6
commit
e6e8bd8ce1
@ -11,23 +11,22 @@ skills:
|
|||||||
|
|
||||||
网文编写团队的核心角色和总指挥。你的工作方式是:接收用户想法 → 协调团队自主完成所有前期工作 → 只把最终成果呈现给用户。
|
网文编写团队的核心角色和总指挥。你的工作方式是:接收用户想法 → 协调团队自主完成所有前期工作 → 只把最终成果呈现给用户。
|
||||||
|
|
||||||
## 输出类型说明(最重要)
|
## 输出规则(最重要)
|
||||||
|
|
||||||
系统会自动区分你的两种输出类型:
|
**你只负责交流和协调,不要在回复中输出完整文档正文。**
|
||||||
|
|
||||||
### 交流型输出(显示在聊天列表)
|
系统采用 Caller-Decided 架构:
|
||||||
- 与用户的对话、状态汇报、任务分配
|
- **你的回复** = 交流内容(规划、分配任务、汇报状态),显示在聊天列表
|
||||||
- **必须简短**:每条消息不超过 3-5 句话
|
- **文档产出** = 系统自动为你发起独立的「文档调用」,你不需要在回复中写
|
||||||
- 收到成员结果后不要评价、不要夸,直接推进下一步
|
|
||||||
|
|
||||||
### 文档型输出(自动保存到 workspace)
|
具体规则:
|
||||||
- 以 `# 标题` 开头的完整文档
|
1. **必须简短**:每条回复不超过 3-5 句话
|
||||||
- 系统自动保存,不需要你说明
|
2. **不要输出文档正文**:不要以 `# 标题` 开头输出大段内容
|
||||||
- **只输出文档正文**,不附加"以下是..."等说明文字
|
3. **用 @成员名 分配任务**:系统会自动执行
|
||||||
- 用户通过产物面板查看
|
4. 收到成员结果后不要评价、不要夸,直接推进下一步
|
||||||
|
|
||||||
### 增量修改(修改已有文档时使用)
|
### 增量修改(修改已有文档时使用)
|
||||||
当需要修改已有文档时,不要重写整个文档,使用编辑指令格式:
|
当需要修改已有文档时,使用编辑指令格式:
|
||||||
```
|
```
|
||||||
<<<EDIT 文件名.md>>>
|
<<<EDIT 文件名.md>>>
|
||||||
<<<FIND>>>
|
<<<FIND>>>
|
||||||
@ -60,24 +59,21 @@ Plan 模式下你**只能和用户简短对话**,严禁以下行为:
|
|||||||
|
|
||||||
## Build 模式工作流程
|
## Build 模式工作流程
|
||||||
|
|
||||||
### 第一步:产出《创作需求书》
|
### 第一步:确认方向
|
||||||
直接输出文档(以 # 创作需求书 开头),系统自动保存。然后一句话告知用户并立即分配任务。
|
简短告知用户你的理解和计划(2-3 句话),然后系统自动为你发起《创作需求书》等文档调用。
|
||||||
|
|
||||||
### 第二步:分配任务
|
### 第二步:分配调研任务
|
||||||
@搜索员 进行市场调研
|
@搜索员 进行市场调研
|
||||||
@合规审查员 评估题材方向的合规风险
|
@合规审查员 评估题材方向的合规风险
|
||||||
|
|
||||||
### 第三步:方案评审(收到调研和合规结果后)
|
### 第三步:方案评审(收到调研和合规结果后)
|
||||||
@策划编辑 评审故事方案的可行性并打分
|
@策划编辑 评审故事方案的可行性并打分
|
||||||
|
|
||||||
### 第四步:文档产出(收到评审后直接写)
|
### 第四步:文档产出
|
||||||
直接产出 3 份文档(你自己写):
|
系统自动为你发起文档调用,产出:主角小传、世界观与角色设定、故事大纲。
|
||||||
- `# 主角小传:[角色名]`
|
|
||||||
- `# 世界观与角色设定`
|
|
||||||
- `# 故事大纲`
|
|
||||||
|
|
||||||
### 第五步:文档评审
|
### 第五步:文档评审
|
||||||
@策划编辑 评审以上3份文档,按文档评审标准打分
|
@策划编辑 评审以上文档,按文档评审标准打分
|
||||||
- ≥ 85分:通过
|
- ≥ 85分:通过
|
||||||
- < 85分:用增量编辑格式修改后重新提交
|
- < 85分:用增量编辑格式修改后重新提交
|
||||||
|
|
||||||
|
|||||||
@ -17,9 +17,37 @@ skills: []
|
|||||||
2. **不需要写交流消息**:系统会自动生成包含总分的简短交流消息
|
2. **不需要写交流消息**:系统会自动生成包含总分的简短交流消息
|
||||||
3. **不写开场白**:不说"我来为您评审"之类的废话,直接出报告
|
3. **不写开场白**:不说"我来为您评审"之类的废话,直接出报告
|
||||||
|
|
||||||
|
## 前置依赖检查(最高优先级)
|
||||||
|
|
||||||
|
**评审前必须确认依赖文档已存在于 workspace 中**,否则拒绝评审:
|
||||||
|
|
||||||
|
### 故事方案评审(《故事方案评审.md》)
|
||||||
|
必须依赖以下 3 份文档,缺一不可:
|
||||||
|
- ✅ 《创作需求书》— 确认创作方向和核心设定
|
||||||
|
- ✅ 《市场调研报告》— 确认市场分析和竞品数据
|
||||||
|
- ✅ 《合规审查报告》— 确认合规风险评估
|
||||||
|
|
||||||
|
**如果 workspace 中缺少上述任何一份文档**,你必须:
|
||||||
|
1. 输出一句话说明缺少哪份文档:`缺少前置文档:《XXX》尚未完成,无法进行方案评审。请先完成前置工作。`
|
||||||
|
2. 不要凭空评审,不要编造数据,不要猜测内容
|
||||||
|
|
||||||
|
### 文档评审(《文档评审报告.md》)
|
||||||
|
必须依赖以下文档:
|
||||||
|
- ✅ 《主角小传》
|
||||||
|
- ✅ 《世界观与角色设定》
|
||||||
|
- ✅ 《故事大纲》
|
||||||
|
|
||||||
|
同样,缺少任何一份都不能评审。
|
||||||
|
|
||||||
|
## 评审原则
|
||||||
|
|
||||||
|
- **基于事实**:所有评分依据必须引用具体文档内容,不能凭感觉打分
|
||||||
|
- **引用原文**:评审意见中必须引用所评审文档的具体段落或设定
|
||||||
|
- **交叉验证**:市场评分必须参考《市场调研报告》的数据,合规评分必须参考《合规审查报告》的结论
|
||||||
|
|
||||||
## 核心职责
|
## 核心职责
|
||||||
|
|
||||||
1. **故事方案评审**:评估题材选择、核心创意、市场可行性
|
1. **故事方案评审**:基于需求书+调研报告+合规报告,评估方案可行性
|
||||||
2. **角色设定评审**:审查主角小传、配角设定的合理性和吸引力
|
2. **角色设定评审**:审查主角小传、配角设定的合理性和吸引力
|
||||||
3. **大纲结构评审**:检查故事节奏、冲突设计、高潮安排
|
3. **大纲结构评审**:检查故事节奏、冲突设计、高潮安排
|
||||||
4. **打分与迭代**:每次评审给出量化评分,低于85分必须给出改进方案
|
4. **打分与迭代**:每次评审给出量化评分,低于85分必须给出改进方案
|
||||||
@ -28,13 +56,13 @@ skills: []
|
|||||||
|
|
||||||
### 故事方案评审
|
### 故事方案评审
|
||||||
|
|
||||||
| 维度 | 分值 | 评分要点 |
|
| 维度 | 分值 | 评分要点 | 数据来源 |
|
||||||
|------|------|----------|
|
|------|------|----------|----------|
|
||||||
| 创意新颖度 | 20 | 与竞品的差异化程度,是否有独特卖点 |
|
| 创意新颖度 | 20 | 与竞品的差异化程度,是否有独特卖点 | 引用《市场调研报告》竞品分析 |
|
||||||
| 市场可行性 | 20 | 目标读者是否明确,题材热度是否合适 |
|
| 市场可行性 | 20 | 目标读者是否明确,题材热度是否合适 | 引用《市场调研报告》市场数据 |
|
||||||
| 核心矛盾 | 20 | 主线冲突是否清晰有力,能否撑起长篇 |
|
| 核心矛盾 | 20 | 主线冲突是否清晰有力,能否撑起长篇 | 引用《创作需求书》核心设定 |
|
||||||
| 金手指设定 | 20 | 是否有趣、有限制、有成长空间 |
|
| 金手指设定 | 20 | 是否有趣、有限制、有成长空间 | 引用《创作需求书》金手指设定 |
|
||||||
| 开篇吸引力 | 20 | 前三章能否抓住读者,钩子是否有效 |
|
| 合规与风险 | 20 | 内容合规性、题材敏感度 | 引用《合规审查报告》结论 |
|
||||||
|
|
||||||
### 文档评审(小传/世界观/大纲)
|
### 文档评审(小传/世界观/大纲)
|
||||||
|
|
||||||
@ -55,21 +83,31 @@ skills: []
|
|||||||
```markdown
|
```markdown
|
||||||
# 故事方案评审
|
# 故事方案评审
|
||||||
|
|
||||||
|
## 参考文档
|
||||||
|
|
||||||
|
- 《创作需求书》:[确认已读]
|
||||||
|
- 《市场调研报告》:[确认已读]
|
||||||
|
- 《合规审查报告》:[确认已读]
|
||||||
|
|
||||||
## 评分
|
## 评分
|
||||||
|
|
||||||
| 维度 | 得分 | 扣分原因 |
|
| 维度 | 得分 | 依据 |
|
||||||
|------|------|----------|
|
|------|------|------|
|
||||||
| ... | .../20 | ... |
|
| 创意新颖度 | .../20 | 据调研报告竞品分析:[引用具体内容]... |
|
||||||
|
| 市场可行性 | .../20 | 据调研报告市场数据:[引用具体内容]... |
|
||||||
|
| 核心矛盾 | .../20 | 据需求书核心设定:[引用具体内容]... |
|
||||||
|
| 金手指设定 | .../20 | 据需求书:[引用具体内容]... |
|
||||||
|
| 合规与风险 | .../20 | 据合规报告结论:[引用具体内容]... |
|
||||||
| **总分** | **xx/100** | |
|
| **总分** | **xx/100** | |
|
||||||
|
|
||||||
## 评审结论
|
## 评审结论
|
||||||
|
|
||||||
[通过(≥85分)/ 需修改(<85分)]
|
[通过(≥85分)/ 需修改(<85分)]
|
||||||
|
|
||||||
## 问题清单
|
## 问题清单(必须引用原文)
|
||||||
|
|
||||||
1. [问题描述] → [改进建议]
|
1. [引用原文段落] → [具体问题] → [改进建议]
|
||||||
2. [问题描述] → [改进建议]
|
2. [引用原文段落] → [具体问题] → [改进建议]
|
||||||
|
|
||||||
## 亮点
|
## 亮点
|
||||||
|
|
||||||
|
|||||||
748
internal/room/handle.go
Normal file
748
internal/room/handle.go
Normal file
@ -0,0 +1,748 @@
|
|||||||
|
package room
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sdaduanbilei/agent-team/internal/agent"
|
||||||
|
"github.com/sdaduanbilei/agent-team/internal/llm"
|
||||||
|
"github.com/sdaduanbilei/agent-team/internal/skill"
|
||||||
|
"github.com/sdaduanbilei/agent-team/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Load(roomDir string, agentsDir string, skillsDir string) (*Room, error) {
|
||||||
|
data, err := os.ReadFile(filepath.Join(roomDir, "room.md"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg, err := parseRoomConfig(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
r := &Room{Config: cfg, Dir: roomDir, members: make(map[string]*agent.Agent), Mode: "plan", Status: StatusPending}
|
||||||
|
|
||||||
|
projectRoot := filepath.Dir(agentsDir)
|
||||||
|
if data, err := os.ReadFile(filepath.Join(projectRoot, "SYSTEM.md")); err == nil {
|
||||||
|
r.systemRules = string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Master != "" {
|
||||||
|
var knowledgeDir string
|
||||||
|
if cfg.Team != "" {
|
||||||
|
kd := filepath.Join(agentsDir, cfg.Team, "knowledge")
|
||||||
|
if info, err := os.Stat(kd); err == nil && info.IsDir() {
|
||||||
|
knowledgeDir = kd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
agentPath := resolveAgentPath(agentsDir, cfg.Team, cfg.Master)
|
||||||
|
r.master, err = agent.Load(agentPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load master %s: %w", cfg.Master, err)
|
||||||
|
}
|
||||||
|
r.master.KnowledgeDir = knowledgeDir
|
||||||
|
|
||||||
|
for _, name := range cfg.Members {
|
||||||
|
a, err := agent.Load(resolveAgentPath(agentsDir, cfg.Team, name))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load member %s: %w", name, err)
|
||||||
|
}
|
||||||
|
a.KnowledgeDir = knowledgeDir
|
||||||
|
r.members[name] = a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Team != "" {
|
||||||
|
teamMDPath := filepath.Join(agentsDir, cfg.Team, "TEAM.md")
|
||||||
|
if teamData, err := os.ReadFile(teamMDPath); err == nil {
|
||||||
|
body := string(teamData)
|
||||||
|
if strings.HasPrefix(body, "---") {
|
||||||
|
parts := strings.SplitN(body, "---", 3)
|
||||||
|
if len(parts) >= 3 {
|
||||||
|
body = parts[2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pt := parseProjectTemplate(body); pt != nil {
|
||||||
|
r.projectTemplate = pt
|
||||||
|
log.Printf("[room %s] 已加载项目模板,包含 %d 个文件定义", cfg.Name, len(pt.Files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r.skillMeta, _ = skill.Discover(skillsDir)
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) emit(e Event) {
|
||||||
|
e.RoomID = r.Config.Name
|
||||||
|
if r.Broadcast != nil {
|
||||||
|
r.Broadcast(e)
|
||||||
|
}
|
||||||
|
if r.Store != nil && !e.NoStore {
|
||||||
|
gid := &r.currentGroupID
|
||||||
|
switch e.Type {
|
||||||
|
case EvtAgentMessage:
|
||||||
|
if !e.Streaming && e.Content != "" && e.Role != "tool_use" &&
|
||||||
|
!strings.HasPrefix(e.Content, "正在处理:") && !strings.HasPrefix(e.Content, "正在处理: ") {
|
||||||
|
r.Store.InsertMessage(&store.Message{
|
||||||
|
RoomID: r.Config.Name, Agent: e.Agent, Role: e.Role,
|
||||||
|
Content: e.Content, GroupID: gid,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
case EvtArtifact:
|
||||||
|
r.Store.InsertMessage(&store.Message{
|
||||||
|
RoomID: r.Config.Name, Agent: e.Agent, Role: "artifact",
|
||||||
|
Content: e.Title, Filename: e.Filename, Title: e.Title, GroupID: gid,
|
||||||
|
})
|
||||||
|
case EvtTaskAssign:
|
||||||
|
r.Store.InsertMessage(&store.Message{
|
||||||
|
RoomID: r.Config.Name, Agent: e.From, Role: "task_assign",
|
||||||
|
Content: e.Task, Title: e.To, GroupID: gid,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) emitUsage(agentName string, usage llm.Usage) {
|
||||||
|
if usage.TotalTokens > 0 {
|
||||||
|
r.emit(Event{
|
||||||
|
Type: EvtTokenUsage,
|
||||||
|
Agent: agentName,
|
||||||
|
PromptTokens: usage.PromptTokens,
|
||||||
|
CompletionTokens: usage.CompletionTokens,
|
||||||
|
TotalTokens: usage.TotalTokens,
|
||||||
|
})
|
||||||
|
if r.Store != nil {
|
||||||
|
r.Store.InsertTokenUsage(r.Config.Name, agentName, usage.PromptTokens, usage.CompletionTokens, usage.TotalTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) setMode(mode string) {
|
||||||
|
r.Mode = mode
|
||||||
|
r.emit(Event{Type: EvtModeChange, Mode: mode})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) setStatus(s Status, activeAgent, action string) {
|
||||||
|
r.Status = s
|
||||||
|
r.ActiveAgent = activeAgent
|
||||||
|
r.emit(Event{Type: EvtRoomStatus, Status: s, ActiveAgent: activeAgent, Action: action})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) Stop() {
|
||||||
|
r.cancelMu.Lock()
|
||||||
|
defer r.cancelMu.Unlock()
|
||||||
|
if r.cancelFunc != nil {
|
||||||
|
r.cancelFunc()
|
||||||
|
r.cancelFunc = nil
|
||||||
|
}
|
||||||
|
r.setStatus(StatusPending, "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppendHistory persists a message to today's history file.
|
||||||
|
func (r *Room) AppendHistory(role, agentName, content string) {
|
||||||
|
dir := filepath.Join(r.Dir, "history")
|
||||||
|
os.MkdirAll(dir, 0755)
|
||||||
|
filename := filepath.Join(dir, time.Now().Format("2006-01-02")+".md")
|
||||||
|
line := fmt.Sprintf("\n**[%s] %s** (%s)\n\n%s\n", time.Now().Format("15:04:05"), agentName, role, content)
|
||||||
|
f, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
f.WriteString(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// contextMaxTokens 是模型上下文窗口限制(留 8K 给输出)
|
||||||
|
const contextMaxTokens = 120000
|
||||||
|
|
||||||
|
// estimateTokens 估算消息列表的 token 数(中文约 1.5 token/字,英文约 0.75 token/word)
|
||||||
|
func estimateTokens(msgs []llm.Message) int {
|
||||||
|
total := 0
|
||||||
|
for _, m := range msgs {
|
||||||
|
// 粗略估算:每个字符约 1 token(中英混合取均值)
|
||||||
|
total += len([]rune(m.Content))
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// compressMessages 当 token 数超限时,保留 system prompt + 最近的消息,中间部分压缩为摘要
|
||||||
|
func compressMessages(msgs []llm.Message, maxTokens int) []llm.Message {
|
||||||
|
if len(msgs) <= 3 {
|
||||||
|
return msgs
|
||||||
|
}
|
||||||
|
est := estimateTokens(msgs)
|
||||||
|
if est <= maxTokens {
|
||||||
|
return msgs
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[context] 当前 %d tokens (估算), 超过 %d 限制, 压缩历史", est, maxTokens)
|
||||||
|
|
||||||
|
// 保留: system(第0条) + 最近的消息
|
||||||
|
system := msgs[0]
|
||||||
|
middle := msgs[1:]
|
||||||
|
|
||||||
|
// 从后往前保留消息,直到 token 数在限制内
|
||||||
|
// 留 system 的 token + 安全余量
|
||||||
|
sysTokens := len([]rune(system.Content))
|
||||||
|
budget := maxTokens - sysTokens - 2000 // 留 2K 余量
|
||||||
|
|
||||||
|
var kept []llm.Message
|
||||||
|
keptTokens := 0
|
||||||
|
for i := len(middle) - 1; i >= 0; i-- {
|
||||||
|
msgTokens := len([]rune(middle[i].Content))
|
||||||
|
if keptTokens+msgTokens > budget {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
kept = append([]llm.Message{middle[i]}, kept...)
|
||||||
|
keptTokens += msgTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
// 被截掉的部分生成摘要提示
|
||||||
|
droppedCount := len(middle) - len(kept)
|
||||||
|
if droppedCount > 0 {
|
||||||
|
summary := llm.NewMsg("user", fmt.Sprintf("[系统提示] 之前有 %d 条对话消息因上下文长度限制已被压缩。请基于当前可见的消息继续工作。", droppedCount))
|
||||||
|
result := make([]llm.Message, 0, 2+len(kept))
|
||||||
|
result = append(result, system, summary)
|
||||||
|
result = append(result, kept...)
|
||||||
|
log.Printf("[context] 压缩完成: 保留 %d 条, 丢弃 %d 条, 估算 %d tokens", len(kept), droppedCount, estimateTokens(result))
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return msgs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle processes a user message through master orchestration.
|
||||||
|
func (r *Room) Handle(ctx context.Context, userMsg string) error {
|
||||||
|
return r.HandleUserMessage(ctx, "user", userMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleUserMessage 处理用户消息。
|
||||||
|
func (r *Room) HandleUserMessage(ctx context.Context, userName, userMsg string) error {
|
||||||
|
if r.master == nil {
|
||||||
|
return fmt.Errorf("room has no master agent configured")
|
||||||
|
}
|
||||||
|
r.AppendHistory("user", userName, userMsg)
|
||||||
|
|
||||||
|
if r.Store != nil {
|
||||||
|
id, _ := r.Store.InsertMessage(&store.Message{
|
||||||
|
RoomID: r.Config.Name, Agent: userName, Role: "user", Content: userMsg,
|
||||||
|
})
|
||||||
|
r.currentGroupID = id
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
r.cancelMu.Lock()
|
||||||
|
r.cancelFunc = cancel
|
||||||
|
r.cancelMu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
r.cancelMu.Lock()
|
||||||
|
r.cancelFunc = nil
|
||||||
|
r.cancelMu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 检测用户直接 @agent
|
||||||
|
userAssignments := parseUserMentions(userMsg, r.members)
|
||||||
|
if len(userAssignments) > 0 {
|
||||||
|
// 区分对话和任务分配:短消息/问候视为对话
|
||||||
|
if len(userAssignments) == 1 {
|
||||||
|
for name, task := range userAssignments {
|
||||||
|
if isConversational(task) {
|
||||||
|
r.lastActiveMember = name
|
||||||
|
return r.handleMemberConversation(ctx, userName, task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r.handleDirectAssign(ctx, userAssignments)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build 模式下执行暂存任务
|
||||||
|
if r.Mode == "build" && len(r.pendingAssignments) > 0 {
|
||||||
|
log.Printf("[room %s] build 模式,执行 %d 个暂存任务", r.Config.Name, len(r.pendingAssignments))
|
||||||
|
assignments := r.pendingAssignments
|
||||||
|
r.pendingAssignments = nil
|
||||||
|
r.pendingPlanReply = ""
|
||||||
|
|
||||||
|
skillXML := skill.ToXML(r.skillMeta)
|
||||||
|
board := &SharedBoard{}
|
||||||
|
r.setStatus(StatusWorking, "", "")
|
||||||
|
r.runMembersParallel(ctx, assignments, board, skillXML)
|
||||||
|
r.runChallengeRound(ctx, board, skillXML)
|
||||||
|
|
||||||
|
r.setStatus(StatusPending, "", "")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build 模式下成员对话续接
|
||||||
|
if r.Mode == "build" && r.lastActiveMember != "" {
|
||||||
|
return r.handleMemberConversation(ctx, userName, userMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.setStatus(StatusThinking, "", "")
|
||||||
|
|
||||||
|
// 构建 system prompt
|
||||||
|
teamXML := r.buildTeamXML()
|
||||||
|
skillXML := skill.ToXML(r.skillMeta)
|
||||||
|
skillSummary := r.buildSkillSummary()
|
||||||
|
var userXML string
|
||||||
|
if r.User != nil {
|
||||||
|
userXML = r.User.BuildUserXML()
|
||||||
|
}
|
||||||
|
extraContext := userXML + "\n\n" + teamXML + "\n\n" + skillSummary
|
||||||
|
if r.systemRules != "" {
|
||||||
|
extraContext = r.systemRules + "\n\n" + extraContext
|
||||||
|
}
|
||||||
|
if projectCtx := r.buildProjectContext(r.master.Config.Name); projectCtx != "" {
|
||||||
|
extraContext = extraContext + "\n\n" + projectCtx
|
||||||
|
}
|
||||||
|
systemPrompt := r.master.BuildSystemPrompt(extraContext)
|
||||||
|
sysMsg := llm.NewMsg("system", systemPrompt+fmt.Sprintf("\n\n当前用户:%s\n当前模式:%s", userName, r.Mode))
|
||||||
|
|
||||||
|
r.historyMu.Lock()
|
||||||
|
if len(r.masterHistory) == 0 {
|
||||||
|
r.masterHistory = []llm.Message{sysMsg}
|
||||||
|
} else {
|
||||||
|
r.masterHistory[0] = sysMsg
|
||||||
|
}
|
||||||
|
r.masterHistory = append(r.masterHistory, llm.NewMsg("user", userMsg))
|
||||||
|
// token 感知的历史压缩(替代简单的消息数截断)
|
||||||
|
r.masterHistory = compressMessages(r.masterHistory, contextMaxTokens)
|
||||||
|
masterMsgs := make([]llm.Message, len(r.masterHistory))
|
||||||
|
copy(masterMsgs, r.masterHistory)
|
||||||
|
r.historyMu.Unlock()
|
||||||
|
|
||||||
|
// Master 规划循环
|
||||||
|
executedMembers := make(map[string]int) // 成员名 → 已执行的 phase,防止同 phase 重复分配
|
||||||
|
for iteration := 0; iteration < 12; iteration++ {
|
||||||
|
log.Printf("[room %s] master iteration %d, sending to LLM...", r.Config.Name, iteration)
|
||||||
|
|
||||||
|
if r.projectTemplate != nil {
|
||||||
|
if !r.masterCallerDecidedIteration(ctx, &masterMsgs, skillXML, iteration, executedMembers) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if !r.masterLegacyIteration(ctx, &masterMsgs, skillXML, iteration) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r.setStatus(StatusPending, "", "")
|
||||||
|
go r.updateMasterMemory(context.Background(), userMsg, masterMsgs)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// masterCallerDecidedIteration 执行一次 Caller-Decided 路径的 master 迭代。
|
||||||
|
// 返回 true 表示继续循环,false 表示 break。
|
||||||
|
func (r *Room) masterCallerDecidedIteration(ctx context.Context, masterMsgs *[]llm.Message, skillXML string, iteration int, executedMembers map[string]int) bool {
|
||||||
|
if iteration > 0 {
|
||||||
|
r.setStatus(StatusThinking, r.master.Config.Name, "正在规划...")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用前压缩 context
|
||||||
|
*masterMsgs = compressMessages(*masterMsgs, contextMaxTokens)
|
||||||
|
|
||||||
|
// CHAT CALL: 纯规划+分配
|
||||||
|
var masterReply strings.Builder
|
||||||
|
_, usage, err := r.master.ChatWithUsage(ctx, *masterMsgs, func(token string) {
|
||||||
|
masterReply.WriteString(token)
|
||||||
|
r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master", Content: token, Streaming: true})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[room %s] master chat error: %v", r.Config.Name, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
r.emitUsage(r.master.Config.Name, usage)
|
||||||
|
reply := masterReply.String()
|
||||||
|
log.Printf("[room %s] master chat reply (%d chars): %.100s...", r.Config.Name, len(reply), reply)
|
||||||
|
|
||||||
|
r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master", Content: "", Streaming: false})
|
||||||
|
|
||||||
|
// 存为 text part
|
||||||
|
if r.Store != nil {
|
||||||
|
r.Store.InsertMessage(&store.Message{
|
||||||
|
RoomID: r.Config.Name, Agent: r.master.Config.Name, Role: "master",
|
||||||
|
Content: reply, PartType: "text",
|
||||||
|
GroupID: &r.currentGroupID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
assistantMsg := llm.NewMsg("assistant", reply)
|
||||||
|
*masterMsgs = append(*masterMsgs, assistantMsg)
|
||||||
|
r.historyMu.Lock()
|
||||||
|
r.masterHistory = append(r.masterHistory, assistantMsg)
|
||||||
|
r.historyMu.Unlock()
|
||||||
|
r.AppendHistory("master", r.master.Config.Name, reply)
|
||||||
|
|
||||||
|
// 增量编辑
|
||||||
|
if editFile, edited := r.applyDocumentEdit(reply); edited {
|
||||||
|
editTitle := strings.TrimSuffix(editFile, ".md")
|
||||||
|
r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: editFile, Title: editTitle})
|
||||||
|
if r.Store != nil {
|
||||||
|
r.Store.InsertMessage(&store.Message{
|
||||||
|
RoomID: r.Config.Name, Agent: r.master.Config.Name, Role: "master",
|
||||||
|
Content: editTitle, Filename: editFile, PartType: "document",
|
||||||
|
GroupID: &r.currentGroupID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 安全网:如果 master 在聊天中直接输出了文档内容,自动拦截保存
|
||||||
|
if r.Mode == "build" && isDocument(reply) && r.allStaticFilesDone() {
|
||||||
|
if dynDir, dynOwner, _ := r.getDynamicFileInfo(); dynDir != "" && dynOwner == r.master.Config.Name {
|
||||||
|
chapterFilename := r.extractChapterFilename(reply, dynDir)
|
||||||
|
fullPath := dynDir + "/" + chapterFilename
|
||||||
|
content := strings.TrimSpace(reply)
|
||||||
|
if !strings.HasPrefix(content, "# ") {
|
||||||
|
content = "# " + strings.TrimSuffix(chapterFilename, ".md") + "\n\n" + content
|
||||||
|
}
|
||||||
|
r.saveWorkspace(fullPath, content)
|
||||||
|
docName := strings.TrimSuffix(chapterFilename, ".md")
|
||||||
|
r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: fullPath, 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: fullPath, PartType: "document",
|
||||||
|
GroupID: &r.currentGroupID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
log.Printf("[room %s] 拦截 master 聊天中的章节内容,保存到 %s", r.Config.Name, fullPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 @ 分配
|
||||||
|
allMentions := parseAssignments(reply)
|
||||||
|
assignments := make(map[string]string)
|
||||||
|
for name, task := range allMentions {
|
||||||
|
if _, isMember := r.members[name]; isMember {
|
||||||
|
assignments[name] = task
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 去重:过滤本轮循环中已执行过同 phase 任务的成员
|
||||||
|
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 {
|
||||||
|
blocked := r.validatePhaseAssignments(assignments)
|
||||||
|
if len(blocked) > 0 {
|
||||||
|
var blockedMsg strings.Builder
|
||||||
|
blockedMsg.WriteString("[系统] 以下任务被阻止,因为前置阶段尚未完成:\n")
|
||||||
|
for name, reason := range blocked {
|
||||||
|
blockedMsg.WriteString(fmt.Sprintf("- @%s: %s\n", name, reason))
|
||||||
|
delete(assignments, name)
|
||||||
|
}
|
||||||
|
blockedMsg.WriteString("\n请先完成当前阶段的工作,再推进下一阶段。")
|
||||||
|
r.emit(Event{Type: EvtAgentMessage, Agent: "system", Role: "master", Content: blockedMsg.String()})
|
||||||
|
log.Printf("[room %s] phase 校验阻止了 %d 个任务分配", r.Config.Name, len(blocked))
|
||||||
|
|
||||||
|
// 注入反馈让 master 知道被阻止了
|
||||||
|
phaseBlockMsg := llm.NewMsg("user", blockedMsg.String())
|
||||||
|
*masterMsgs = append(*masterMsgs, phaseBlockMsg)
|
||||||
|
r.historyMu.Lock()
|
||||||
|
r.masterHistory = append(r.masterHistory, phaseBlockMsg)
|
||||||
|
r.historyMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(assignments) > 0 && r.Mode != "build" {
|
||||||
|
r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master",
|
||||||
|
Content: "当前是 Plan 模式,无法执行任务。请切换到 Build 模式后发送消息开始执行。"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行成员任务
|
||||||
|
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{}
|
||||||
|
results := r.runMembersParallel(ctx, assignments, board, skillXML)
|
||||||
|
r.runChallengeRound(ctx, board, skillXML)
|
||||||
|
|
||||||
|
r.setStatus(StatusThinking, r.master.Config.Name, "正在审阅成员结果...")
|
||||||
|
var resultsStr strings.Builder
|
||||||
|
for memberName, result := range results {
|
||||||
|
resultsStr.WriteString(fmt.Sprintf("[%s] %s\n", memberName, result))
|
||||||
|
}
|
||||||
|
feedbackMsg := "Team results:\n" + resultsStr.String()
|
||||||
|
if boardCtx := board.ToContext(); boardCtx != "" {
|
||||||
|
feedbackMsg += "\n\nTeam board:\n" + boardCtx
|
||||||
|
}
|
||||||
|
if wsCtx := r.buildWorkspaceContext(); wsCtx != "" {
|
||||||
|
feedbackMsg += "\n\n" + wsCtx
|
||||||
|
}
|
||||||
|
if stepCtx := r.buildWorkflowStep(); stepCtx != "" {
|
||||||
|
feedbackMsg = stepCtx + "\n\n" + feedbackMsg
|
||||||
|
}
|
||||||
|
feedbackMsg += "\n\n请审查成员结果,然后用 @成员名 分配下一步任务。不要在回复中输出完整文档正文,你负责的文件由系统自动发起调用。\n注意:简短回复,不要重复你上一条消息的内容。"
|
||||||
|
|
||||||
|
// 提醒更新 TodoList
|
||||||
|
if r.hasTodoList() {
|
||||||
|
feedbackMsg += "\n\n<reminder>如果有任务完成,请用 <<<EDIT TodoList.md>>> 格式更新 TodoList,标记已完成的项目。</reminder>"
|
||||||
|
}
|
||||||
|
feedbackLLMMsg := llm.NewMsg("user", feedbackMsg)
|
||||||
|
*masterMsgs = append(*masterMsgs, feedbackLLMMsg)
|
||||||
|
r.historyMu.Lock()
|
||||||
|
r.masterHistory = append(r.masterHistory, feedbackLLMMsg)
|
||||||
|
r.historyMu.Unlock()
|
||||||
|
r.updateTasks(*masterMsgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FILE CALLS: master 负责的文件
|
||||||
|
pendingFiles := r.findPendingMasterFiles()
|
||||||
|
chapterWritten := false
|
||||||
|
if len(pendingFiles) > 0 && r.Mode == "build" {
|
||||||
|
for _, file := range pendingFiles {
|
||||||
|
r.masterFileCall(ctx, masterMsgs, file)
|
||||||
|
}
|
||||||
|
} else if r.Mode == "build" && r.allStaticFilesDone() {
|
||||||
|
// 所有静态文件完成,检查是否有动态章节需要写
|
||||||
|
if dynDir, dynOwner, _ := r.getDynamicFileInfo(); dynDir != "" && dynOwner == r.master.Config.Name {
|
||||||
|
// 用 master 的聊天回复作为章节规划提示
|
||||||
|
r.masterChapterFileCall(ctx, masterMsgs, dynDir, reply)
|
||||||
|
chapterWritten = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plan 模式下不循环,每次只回复一条等用户
|
||||||
|
if r.Mode != "build" {
|
||||||
|
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 := stepCtx + "\n\n文档已完成。请继续推进下一阶段。\n注意:简短回复,不要重复之前说过的内容。"
|
||||||
|
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 迭代(无项目模板)。
|
||||||
|
func (r *Room) masterLegacyIteration(ctx context.Context, masterMsgs *[]llm.Message, skillXML string, iteration int) bool {
|
||||||
|
if iteration > 0 {
|
||||||
|
r.setStatus(StatusWorking, r.master.Config.Name, "正在编写文档...")
|
||||||
|
}
|
||||||
|
// 调用前压缩 context
|
||||||
|
*masterMsgs = compressMessages(*masterMsgs, contextMaxTokens)
|
||||||
|
|
||||||
|
var masterReply strings.Builder
|
||||||
|
_, usage, err := r.master.ChatWithUsage(ctx, *masterMsgs, func(token string) {
|
||||||
|
masterReply.WriteString(token)
|
||||||
|
r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master", Content: token, Streaming: true})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[room %s] master chat error: %v", r.Config.Name, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
r.emitUsage(r.master.Config.Name, usage)
|
||||||
|
reply := masterReply.String()
|
||||||
|
log.Printf("[room %s] master reply (%d chars): %.100s...", r.Config.Name, len(reply), reply)
|
||||||
|
|
||||||
|
var savedDocTitles []string
|
||||||
|
persistContent := reply
|
||||||
|
|
||||||
|
if editFile, edited := r.applyDocumentEdit(reply); edited {
|
||||||
|
persistContent = fmt.Sprintf("已更新《%s》", strings.TrimSuffix(editFile, ".md"))
|
||||||
|
savedDocTitles = append(savedDocTitles, strings.TrimSuffix(editFile, ".md"))
|
||||||
|
} else if docs := splitDocuments(reply); len(docs) > 0 {
|
||||||
|
for _, doc := range docs {
|
||||||
|
title := extractTitle(doc)
|
||||||
|
filename := titleToFilename(title, r.master.Config.Name)
|
||||||
|
r.saveWorkspace(filename, doc)
|
||||||
|
r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: filename, Title: title})
|
||||||
|
savedDocTitles = append(savedDocTitles, title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(savedDocTitles) > 0 {
|
||||||
|
r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master", Content: persistContent, Streaming: false, Action: "replace"})
|
||||||
|
} else {
|
||||||
|
r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master", Content: "", Streaming: false})
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Store != nil && persistContent != "" {
|
||||||
|
r.Store.InsertMessage(&store.Message{
|
||||||
|
RoomID: r.Config.Name, Agent: r.master.Config.Name, Role: "master",
|
||||||
|
Content: persistContent, PartType: "text",
|
||||||
|
GroupID: &r.currentGroupID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
assistantMsg := llm.NewMsg("assistant", reply)
|
||||||
|
*masterMsgs = append(*masterMsgs, assistantMsg)
|
||||||
|
r.historyMu.Lock()
|
||||||
|
r.masterHistory = append(r.masterHistory, assistantMsg)
|
||||||
|
r.historyMu.Unlock()
|
||||||
|
r.AppendHistory("master", r.master.Config.Name, reply)
|
||||||
|
|
||||||
|
allMentions := parseAssignments(reply)
|
||||||
|
assignments := make(map[string]string)
|
||||||
|
for name, task := range allMentions {
|
||||||
|
if _, isMember := r.members[name]; isMember {
|
||||||
|
assignments[name] = task
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(assignments) == 0 {
|
||||||
|
if len(savedDocTitles) > 0 && r.Mode == "build" {
|
||||||
|
continueMsg := "文档已保存到 workspace。请根据工作流程,用 @成员名 分配下一步任务。不要重复输出文档内容。"
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Mode != "build" {
|
||||||
|
r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master",
|
||||||
|
Content: "当前是 Plan 模式,无法执行任务。请切换到 Build 模式后发送消息开始执行。"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
board := &SharedBoard{}
|
||||||
|
results := r.runMembersParallel(ctx, assignments, board, skillXML)
|
||||||
|
r.runChallengeRound(ctx, board, skillXML)
|
||||||
|
|
||||||
|
r.setStatus(StatusThinking, r.master.Config.Name, "正在审阅成员结果...")
|
||||||
|
var resultsStr strings.Builder
|
||||||
|
for memberName, result := range results {
|
||||||
|
resultsStr.WriteString(fmt.Sprintf("[%s] %s\n", memberName, result))
|
||||||
|
}
|
||||||
|
feedbackMsg := "Team results:\n" + resultsStr.String()
|
||||||
|
if wsFiles := r.listWorkspaceFiles(); len(wsFiles) > 0 {
|
||||||
|
feedbackMsg += "\n\n当前产出物文件:\n"
|
||||||
|
for _, f := range wsFiles {
|
||||||
|
feedbackMsg += "- " + f + "\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
feedbackMsg += "\n\n请审查结果并决定下一步行动。"
|
||||||
|
feedbackLLMMsg := llm.NewMsg("user", feedbackMsg)
|
||||||
|
*masterMsgs = append(*masterMsgs, feedbackLLMMsg)
|
||||||
|
r.historyMu.Lock()
|
||||||
|
r.masterHistory = append(r.masterHistory, feedbackLLMMsg)
|
||||||
|
r.historyMu.Unlock()
|
||||||
|
r.updateTasks(*masterMsgs)
|
||||||
|
return true // continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMemberConversation 处理用户与成员的对话续接
|
||||||
|
func (r *Room) handleMemberConversation(ctx context.Context, userName, userMsg string) error {
|
||||||
|
memberName := r.lastActiveMember
|
||||||
|
member, ok := r.members[memberName]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("member %s not found", memberName)
|
||||||
|
}
|
||||||
|
log.Printf("[room %s] 用户与 %s 对话", r.Config.Name, memberName)
|
||||||
|
r.setStatus(StatusWorking, member.Config.Name, "")
|
||||||
|
|
||||||
|
if r.memberConvos == nil {
|
||||||
|
r.memberConvos = make(map[string][]llm.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果该成员没有现有对话上下文,初始化系统提示
|
||||||
|
if len(r.memberConvos[memberName]) == 0 {
|
||||||
|
extraCtx := r.buildTeamXML()
|
||||||
|
if r.systemRules != "" {
|
||||||
|
extraCtx = r.systemRules + "\n\n" + extraCtx
|
||||||
|
}
|
||||||
|
if wsCtx := r.buildWorkspaceContext(); wsCtx != "" {
|
||||||
|
extraCtx += "\n\n" + wsCtx
|
||||||
|
}
|
||||||
|
systemPrompt := member.BuildSystemPrompt(extraCtx)
|
||||||
|
r.memberConvos[memberName] = []llm.Message{
|
||||||
|
llm.NewMsg("system", systemPrompt+"\n\n你现在在与用户直接对话。请正常回复用户的问题,不要重复执行之前的任务。"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r.memberConvos[memberName] = append(r.memberConvos[memberName], llm.NewMsg("user", userMsg))
|
||||||
|
|
||||||
|
var memberReply strings.Builder
|
||||||
|
_, usage, err := member.ChatWithUsage(ctx, r.memberConvos[memberName], func(token string) {
|
||||||
|
memberReply.WriteString(token)
|
||||||
|
r.emit(Event{Type: EvtAgentMessage, Agent: memberName, Role: "member", Content: token, Streaming: true})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
r.setStatus(StatusPending, "", "")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.emitUsage(memberName, usage)
|
||||||
|
result := memberReply.String()
|
||||||
|
|
||||||
|
// 结束流式
|
||||||
|
r.emit(Event{Type: EvtAgentMessage, Agent: memberName, Role: "member", Content: "", Streaming: false})
|
||||||
|
|
||||||
|
r.memberConvos[memberName] = append(r.memberConvos[memberName], llm.NewMsg("assistant", result))
|
||||||
|
r.AppendHistory("member", memberName, result)
|
||||||
|
|
||||||
|
// 存储到数据库
|
||||||
|
if r.Store != nil && strings.TrimSpace(result) != "" {
|
||||||
|
r.Store.InsertMessage(&store.Message{
|
||||||
|
RoomID: r.Config.Name, Agent: memberName, Role: "member",
|
||||||
|
Content: result, PartType: "text",
|
||||||
|
GroupID: &r.currentGroupID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_, routed := r.saveAgentOutput(memberName, result, "")
|
||||||
|
if routed {
|
||||||
|
r.lastActiveMember = ""
|
||||||
|
} else if !isDocument(result) {
|
||||||
|
// lastActiveMember 保持不变,用户可继续对话
|
||||||
|
} else {
|
||||||
|
r.lastActiveMember = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
r.setStatus(StatusPending, "", "")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDirectAssign 处理用户直接 @agent 指派的任务
|
||||||
|
func (r *Room) handleDirectAssign(ctx context.Context, assignments map[string]string) error {
|
||||||
|
skillXML := skill.ToXML(r.skillMeta)
|
||||||
|
board := &SharedBoard{}
|
||||||
|
|
||||||
|
r.runMembersParallel(ctx, assignments, board, skillXML)
|
||||||
|
r.runChallengeRound(ctx, board, skillXML)
|
||||||
|
|
||||||
|
r.setStatus(StatusPending, "", "")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
410
internal/room/members.go
Normal file
410
internal/room/members.go
Normal file
@ -0,0 +1,410 @@
|
|||||||
|
package room
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/sdaduanbilei/agent-team/internal/agent"
|
||||||
|
"github.com/sdaduanbilei/agent-team/internal/llm"
|
||||||
|
"github.com/sdaduanbilei/agent-team/internal/skill"
|
||||||
|
"github.com/sdaduanbilei/agent-team/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *Room) runMembersParallel(ctx context.Context, assignments map[string]string, board *SharedBoard, skillXML string) map[string]string {
|
||||||
|
results := make(map[string]string)
|
||||||
|
var mu sync.Mutex
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
tools := skill.ToTools(r.skillMeta)
|
||||||
|
|
||||||
|
for memberName, task := range assignments {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(name, t string) {
|
||||||
|
defer wg.Done()
|
||||||
|
member, ok := r.members[name]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.setStatus(StatusWorking, member.Config.Name, t)
|
||||||
|
r.emit(Event{Type: EvtTaskAssign, From: r.master.Config.Name, To: name, Task: t})
|
||||||
|
|
||||||
|
// 构建系统提示上下文
|
||||||
|
memberSystem, taskMsg, targetFile := r.buildMemberContext(name, t, board, tools, skillXML)
|
||||||
|
|
||||||
|
// 标记已查阅 workspace 文件
|
||||||
|
wsFiles := r.listWorkspaceFiles()
|
||||||
|
for i, f := range wsFiles {
|
||||||
|
r.emit(Event{Type: EvtFileRead, Agent: name, Filename: f})
|
||||||
|
if i == 0 {
|
||||||
|
r.setStatus(StatusWorking, name, fmt.Sprintf("正在阅读 %s ...", f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
memberMsgs := []llm.Message{
|
||||||
|
llm.NewMsg("system", memberSystem),
|
||||||
|
llm.NewMsg("user", taskMsg),
|
||||||
|
}
|
||||||
|
|
||||||
|
// tool calling 循环
|
||||||
|
finalReply := r.runToolLoop(ctx, name, member, &memberMsgs, tools, &mu, results)
|
||||||
|
|
||||||
|
if r.memberConvos == nil {
|
||||||
|
r.memberConvos = make(map[string][]llm.Message)
|
||||||
|
}
|
||||||
|
r.memberConvos[name] = memberMsgs
|
||||||
|
r.memberConvos[name] = append(r.memberConvos[name], llm.NewMsg("assistant", finalReply))
|
||||||
|
|
||||||
|
// Caller-Decided 输出路由
|
||||||
|
r.routeMemberOutput(ctx, name, t, member, finalReply, targetFile, board, &mu, results)
|
||||||
|
|
||||||
|
// 异步保存成员记忆
|
||||||
|
go r.updateMemberMemory(context.Background(), member, t, finalReply)
|
||||||
|
}(memberName, task)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// 检查成员间委派
|
||||||
|
r.runSecondRound(ctx, results, board, tools, skillXML, &mu)
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMemberContext 构建成员的系统提示和任务消息
|
||||||
|
func (r *Room) buildMemberContext(name, task string, board *SharedBoard, tools []llm.Tool, skillXML string) (system, taskMsg string, targetFile *ProjectFile) {
|
||||||
|
boardCtx := board.ToContext()
|
||||||
|
var extraCtx string
|
||||||
|
if len(tools) == 0 {
|
||||||
|
extraCtx = skillXML
|
||||||
|
}
|
||||||
|
if boardCtx != "" {
|
||||||
|
if extraCtx != "" {
|
||||||
|
extraCtx = boardCtx + "\n\n" + extraCtx
|
||||||
|
} else {
|
||||||
|
extraCtx = boardCtx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
teamCtx := r.buildTeamXML()
|
||||||
|
if teamCtx != "" {
|
||||||
|
if extraCtx != "" {
|
||||||
|
extraCtx = teamCtx + "\n\n" + extraCtx
|
||||||
|
} else {
|
||||||
|
extraCtx = teamCtx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.systemRules != "" {
|
||||||
|
if extraCtx != "" {
|
||||||
|
extraCtx = r.systemRules + "\n\n" + extraCtx
|
||||||
|
} else {
|
||||||
|
extraCtx = r.systemRules
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if projectCtx := r.buildProjectContext(name); projectCtx != "" {
|
||||||
|
if extraCtx != "" {
|
||||||
|
extraCtx = extraCtx + "\n\n" + projectCtx
|
||||||
|
} else {
|
||||||
|
extraCtx = projectCtx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
member := r.members[name]
|
||||||
|
system = member.BuildSystemPrompt(extraCtx)
|
||||||
|
|
||||||
|
taskMsg = task
|
||||||
|
log.Printf("[room %s] buildMemberContext for %s: projectTemplate=%v", r.Config.Name, name, r.projectTemplate != nil)
|
||||||
|
if wsCtx := r.buildWorkspaceContext(); wsCtx != "" {
|
||||||
|
taskMsg = task + "\n\n" + wsCtx
|
||||||
|
}
|
||||||
|
if r.memberArtifacts != nil {
|
||||||
|
if _, hasDoc := r.memberArtifacts[name]; hasDoc {
|
||||||
|
taskMsg += "\n\n<important>你之前已经产出过文档。请在原文档基础上进行补充和修改,不要重新写一份全新的文档。保留原有内容中仍然有效的部分,合并新的调研结果。</important>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Caller-Decided: 预先确定目标文件
|
||||||
|
targetFile = r.findMemberTargetFile(name)
|
||||||
|
if targetFile != nil {
|
||||||
|
docName := strings.TrimSuffix(targetFile.Path, ".md")
|
||||||
|
taskMsg += fmt.Sprintf("\n\n<file_output target=\"%s\">\n你需要产出文件《%s》。工作完成后,最终回复只输出 Markdown 正文(以 # %s 开头),不要包含交流内容。\n</file_output>", targetFile.Path, docName, docName)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// runToolLoop 执行 tool calling 循环,返回最终回复
|
||||||
|
func (r *Room) runToolLoop(ctx context.Context, name string, member *agent.Agent, memberMsgs *[]llm.Message, tools []llm.Tool, mu *sync.Mutex, results map[string]string) string {
|
||||||
|
var finalReply string
|
||||||
|
for round := 0; round < 10; round++ {
|
||||||
|
result, err := member.ChatWithTools(ctx, *memberMsgs, tools, nil)
|
||||||
|
if err != nil {
|
||||||
|
mu.Lock()
|
||||||
|
results[name] = fmt.Sprintf("[error] %v", err)
|
||||||
|
mu.Unlock()
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
r.emitUsage(name, result.Usage)
|
||||||
|
|
||||||
|
if len(result.ToolCalls) == 0 {
|
||||||
|
finalReply = result.Content
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
assistantMsg := llm.Message{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: result.Content,
|
||||||
|
ToolCalls: result.ToolCalls,
|
||||||
|
}
|
||||||
|
*memberMsgs = append(*memberMsgs, assistantMsg)
|
||||||
|
|
||||||
|
var toolNames []string
|
||||||
|
for _, tc := range result.ToolCalls {
|
||||||
|
toolNames = append(toolNames, tc.Function.Name)
|
||||||
|
}
|
||||||
|
r.emit(Event{Type: EvtAgentMessage, Agent: name, Role: "tool_use",
|
||||||
|
Content: strings.Join(toolNames, ", "), Streaming: true})
|
||||||
|
|
||||||
|
for _, tc := range result.ToolCalls {
|
||||||
|
toolResult := r.executeToolCall(tc)
|
||||||
|
*memberMsgs = append(*memberMsgs, llm.NewToolResultMsg(tc.ID, toolResult))
|
||||||
|
}
|
||||||
|
|
||||||
|
r.emit(Event{Type: EvtAgentMessage, Agent: name, Role: "tool_use",
|
||||||
|
Content: "thinking", Streaming: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭 tool_use 状态
|
||||||
|
r.emit(Event{Type: EvtAgentMessage, Agent: name, Role: "tool_use",
|
||||||
|
Content: "", Streaming: false})
|
||||||
|
|
||||||
|
// 强制无 tools 生成总结
|
||||||
|
if finalReply == "" {
|
||||||
|
*memberMsgs = append(*memberMsgs, llm.NewMsg("user", "请根据以上所有工具调用结果,直接输出完整的任务回复。不要再调用任何工具。"))
|
||||||
|
result, err := member.ChatWithTools(ctx, *memberMsgs, nil, nil)
|
||||||
|
if err == nil && result.Content != "" {
|
||||||
|
finalReply = result.Content
|
||||||
|
r.emitUsage(name, result.Usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if finalReply == "" {
|
||||||
|
finalReply = "[任务执行完成,但未产生文本回复]"
|
||||||
|
}
|
||||||
|
return finalReply
|
||||||
|
}
|
||||||
|
|
||||||
|
// routeMemberOutput 处理成员输出路由(file call 或 chat call)
|
||||||
|
func (r *Room) routeMemberOutput(ctx context.Context, name, task string, member *agent.Agent, finalReply string, targetFile *ProjectFile, board *SharedBoard, mu *sync.Mutex, results map[string]string) {
|
||||||
|
if targetFile != nil {
|
||||||
|
log.Printf("[room %s] FILE CALL for %s → %s (%d chars)", r.Config.Name, name, targetFile.Path, len(finalReply))
|
||||||
|
// FILE CALL: 直接保存到已知目标文件
|
||||||
|
content := strings.TrimSpace(finalReply)
|
||||||
|
if !strings.HasPrefix(content, "# ") {
|
||||||
|
content = "# " + strings.TrimSuffix(targetFile.Path, ".md") + "\n\n" + content
|
||||||
|
}
|
||||||
|
r.saveWorkspace(targetFile.Path, content)
|
||||||
|
docName := strings.TrimSuffix(targetFile.Path, ".md")
|
||||||
|
r.emit(Event{Type: EvtArtifact, Agent: name, Filename: targetFile.Path, Title: docName})
|
||||||
|
r.emit(Event{Type: EvtTaskDone, Agent: name, Task: task})
|
||||||
|
if r.memberArtifacts == nil {
|
||||||
|
r.memberArtifacts = make(map[string]string)
|
||||||
|
}
|
||||||
|
r.memberArtifacts[name] = targetFile.Path
|
||||||
|
|
||||||
|
if r.Store != nil {
|
||||||
|
r.Store.InsertMessage(&store.Message{
|
||||||
|
RoomID: r.Config.Name, Agent: name, Role: "member",
|
||||||
|
Content: docName, Filename: targetFile.Path, PartType: "document",
|
||||||
|
GroupID: &r.currentGroupID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文档已保存,发送静态完成消息到聊天
|
||||||
|
statusMsg := fmt.Sprintf("《%s》已完成,@master 请查阅。", docName)
|
||||||
|
r.emit(Event{Type: EvtAgentMessage, Agent: name, Role: "member", Content: statusMsg, NoStore: true})
|
||||||
|
if r.Store != nil {
|
||||||
|
r.Store.InsertMessage(&store.Message{
|
||||||
|
RoomID: r.Config.Name, Agent: name, Role: "member",
|
||||||
|
Content: statusMsg, PartType: "text",
|
||||||
|
GroupID: &r.currentGroupID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
results[name] = statusMsg
|
||||||
|
mu.Unlock()
|
||||||
|
r.AppendHistory("member", name, finalReply)
|
||||||
|
board.Add(name, statusMsg, "draft")
|
||||||
|
} else {
|
||||||
|
// CHAT CALL: 走旧路由(targetFile 为 nil)
|
||||||
|
log.Printf("[room %s] CHAT CALL for %s (no targetFile, %d chars)", r.Config.Name, name, len(finalReply))
|
||||||
|
savedPath, routed := r.saveAgentOutput(name, finalReply, task)
|
||||||
|
if routed {
|
||||||
|
if r.Store != nil {
|
||||||
|
title := extractTitle(finalReply)
|
||||||
|
r.Store.InsertMessage(&store.Message{
|
||||||
|
RoomID: r.Config.Name, Agent: name, Role: "member",
|
||||||
|
Content: title, Filename: savedPath, PartType: "document",
|
||||||
|
GroupID: &r.currentGroupID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
docTitle := extractTitle(finalReply)
|
||||||
|
statusMsg := fmt.Sprintf("《%s》已完成,@master 请查阅。", docTitle)
|
||||||
|
r.emit(Event{Type: EvtAgentMessage, Agent: name, Role: "member", Content: statusMsg, NoStore: true})
|
||||||
|
if r.Store != nil {
|
||||||
|
r.Store.InsertMessage(&store.Message{
|
||||||
|
RoomID: r.Config.Name, Agent: name, Role: "member",
|
||||||
|
Content: statusMsg, PartType: "text",
|
||||||
|
GroupID: &r.currentGroupID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
results[name] = statusMsg
|
||||||
|
mu.Unlock()
|
||||||
|
r.AppendHistory("member", name, finalReply)
|
||||||
|
board.Add(name, statusMsg, "draft")
|
||||||
|
} else {
|
||||||
|
mu.Lock()
|
||||||
|
results[name] = finalReply
|
||||||
|
mu.Unlock()
|
||||||
|
r.AppendHistory("member", name, finalReply)
|
||||||
|
if strings.TrimSpace(finalReply) != "" {
|
||||||
|
board.Add(name, finalReply, "draft")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runSecondRound 检查成员间委派,自动分派第二轮
|
||||||
|
func (r *Room) runSecondRound(ctx context.Context, results map[string]string, board *SharedBoard, tools []llm.Tool, skillXML string, mu *sync.Mutex) {
|
||||||
|
secondRound := make(map[string]string)
|
||||||
|
for _, result := range results {
|
||||||
|
subAssignments := parseAssignments(result)
|
||||||
|
for name, task := range subAssignments {
|
||||||
|
if _, isMember := r.members[name]; isMember {
|
||||||
|
if _, alreadyDone := results[name]; !alreadyDone {
|
||||||
|
secondRound[name] = task
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(secondRound) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[room %s] 检测到成员间委派,执行第二轮: %v", r.Config.Name, secondRound)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for memberName, task := range secondRound {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(name, t string) {
|
||||||
|
defer wg.Done()
|
||||||
|
member, ok := r.members[name]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.setStatus(StatusWorking, member.Config.Name, t)
|
||||||
|
r.emit(Event{Type: EvtTaskAssign, From: "team", To: name, Task: t})
|
||||||
|
|
||||||
|
memberSystem, taskMsg, _ := r.buildMemberContext(name, t, board, tools, skillXML)
|
||||||
|
wsFiles := r.listWorkspaceFiles()
|
||||||
|
for i, f := range wsFiles {
|
||||||
|
r.emit(Event{Type: EvtFileRead, Agent: name, Filename: f})
|
||||||
|
if i == 0 {
|
||||||
|
r.setStatus(StatusWorking, name, fmt.Sprintf("正在阅读 %s ...", f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
memberMsgs := []llm.Message{
|
||||||
|
llm.NewMsg("system", memberSystem),
|
||||||
|
llm.NewMsg("user", taskMsg),
|
||||||
|
}
|
||||||
|
|
||||||
|
finalReply := r.runToolLoop(ctx, name, member, &memberMsgs, tools, mu, results)
|
||||||
|
|
||||||
|
statusMsg, routed := r.saveAgentOutput(name, finalReply, t)
|
||||||
|
if routed {
|
||||||
|
mu.Lock()
|
||||||
|
results[name] = statusMsg
|
||||||
|
mu.Unlock()
|
||||||
|
r.AppendHistory("member", name, finalReply)
|
||||||
|
if strings.TrimSpace(statusMsg) != "" {
|
||||||
|
board.Add(name, statusMsg, "draft")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mu.Lock()
|
||||||
|
results[name] = finalReply
|
||||||
|
mu.Unlock()
|
||||||
|
r.AppendHistory("member", name, finalReply)
|
||||||
|
if strings.TrimSpace(finalReply) != "" {
|
||||||
|
board.Add(name, finalReply, "draft")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
go r.updateMemberMemory(context.Background(), member, t, finalReply)
|
||||||
|
}(memberName, task)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) runChallengeRound(ctx context.Context, board *SharedBoard, skillXML string) {
|
||||||
|
var challengers []string
|
||||||
|
for name, member := range r.members {
|
||||||
|
if member.Config.CanChallenge {
|
||||||
|
challengers = append(challengers, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(challengers) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
boardCtx := board.ToContext()
|
||||||
|
if boardCtx == "" && len(r.listWorkspaceFiles()) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, name := range challengers {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(n string) {
|
||||||
|
defer wg.Done()
|
||||||
|
member := r.members[n]
|
||||||
|
extraCtx := boardCtx + "\n\n" + skillXML
|
||||||
|
if wsCtx := r.buildWorkspaceContext(); wsCtx != "" {
|
||||||
|
extraCtx = wsCtx + "\n\n" + extraCtx
|
||||||
|
}
|
||||||
|
teamCtx := r.buildTeamXML()
|
||||||
|
if teamCtx != "" {
|
||||||
|
extraCtx = teamCtx + "\n\n" + extraCtx
|
||||||
|
}
|
||||||
|
if r.systemRules != "" {
|
||||||
|
extraCtx = r.systemRules + "\n\n" + extraCtx
|
||||||
|
}
|
||||||
|
if projectCtx := r.buildProjectContext(n); projectCtx != "" {
|
||||||
|
extraCtx = extraCtx + "\n\n" + projectCtx
|
||||||
|
}
|
||||||
|
memberSystem := member.BuildSystemPrompt(extraCtx)
|
||||||
|
memberMsgs := []llm.Message{
|
||||||
|
llm.NewMsg("system", memberSystem+"\n\n审阅 workspace 中的文档内容(而非看板摘要)。如果发现问题或需要质疑,请输出 CHALLENGE:你的具体意见。如果没有问题,输出 AGREE。注意:只评审你职责范围内的内容。禁止使用@提及任何人,禁止建议分配任务。"),
|
||||||
|
llm.NewMsg("user", "请审阅 workspace 中的文档并给出你的专业反馈。"),
|
||||||
|
}
|
||||||
|
var reply strings.Builder
|
||||||
|
_, usage, err := member.ChatWithUsage(ctx, memberMsgs, func(token string) {
|
||||||
|
reply.WriteString(token)
|
||||||
|
r.emit(Event{Type: EvtAgentMessage, Agent: n, Role: "challenge", Content: token, Streaming: true})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.emitUsage(n, usage)
|
||||||
|
r.emit(Event{Type: EvtAgentMessage, Agent: n, Role: "challenge", Content: "", Streaming: false, NoStore: true})
|
||||||
|
result := reply.String()
|
||||||
|
if r.Store != nil && result != "" {
|
||||||
|
r.Store.InsertMessage(&store.Message{
|
||||||
|
RoomID: r.Config.Name, Agent: n, Role: "challenge",
|
||||||
|
Content: result, PartType: "text",
|
||||||
|
GroupID: &r.currentGroupID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if strings.Contains(result, "CHALLENGE:") {
|
||||||
|
board.Add(n, result, "challenge")
|
||||||
|
r.AppendHistory("challenge", n, result)
|
||||||
|
}
|
||||||
|
}(name)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
128
internal/room/memory.go
Normal file
128
internal/room/memory.go
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
package room
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sdaduanbilei/agent-team/internal/agent"
|
||||||
|
"github.com/sdaduanbilei/agent-team/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *Room) updateMasterMemory(ctx context.Context, task string, msgs []llm.Message) {
|
||||||
|
if len([]rune(strings.TrimSpace(task))) < 10 {
|
||||||
|
log.Printf("[memory] 跳过短任务记忆: %q", task)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
summaryPrompt := fmt.Sprintf(`基于这个任务: %q
|
||||||
|
总结核心经验(最多3条 bullet points)。
|
||||||
|
如果这个任务没有值得记忆的经验(如简单问候、闲聊),只输出 SKIP。`, task)
|
||||||
|
memMsgs := append(msgs, llm.NewMsg("user", summaryPrompt))
|
||||||
|
summary, err := r.master.Chat(ctx, memMsgs, nil)
|
||||||
|
if err != nil || summary == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(summary) == "SKIP" {
|
||||||
|
log.Printf("[memory] LLM 判断跳过记忆: %q", task)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := time.Now().Format("2006-01") + ".md"
|
||||||
|
existing, _ := os.ReadFile(filepath.Join(r.master.Dir, "memory", filename))
|
||||||
|
taskTitle := task
|
||||||
|
if len([]rune(taskTitle)) > 50 {
|
||||||
|
taskTitle = string([]rune(taskTitle)[:50])
|
||||||
|
}
|
||||||
|
content := string(existing) + fmt.Sprintf("\n## %s — %s\n\n%s\n", time.Now().Format("2006-01-02"), taskTitle, summary)
|
||||||
|
r.master.SaveMemory(filename, content)
|
||||||
|
|
||||||
|
memFile := filepath.Join(r.master.Dir, "memory", filename)
|
||||||
|
if info, err := os.Stat(memFile); err == nil && info.Size() > 20*1024 {
|
||||||
|
log.Printf("[memory] 当月文件 %s 超过 20KB,触发压缩", filename)
|
||||||
|
go r.master.CompressMemory(context.Background())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompressAllMemory 压缩 room 中所有 agent(master + members)的记忆。
|
||||||
|
func (r *Room) CompressAllMemory(ctx context.Context) {
|
||||||
|
if r.master != nil {
|
||||||
|
if err := r.master.CompressMemory(ctx); err != nil {
|
||||||
|
log.Printf("[memory] master 压缩失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name, member := range r.members {
|
||||||
|
if err := member.CompressMemory(ctx); err != nil {
|
||||||
|
log.Printf("[memory] 成员 %s 压缩失败: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("[memory] 全部 agent 记忆压缩完成")
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateMemberMemory 为成员 agent 保存任务经验。
|
||||||
|
func (r *Room) updateMemberMemory(ctx context.Context, member *agent.Agent, task, result string) {
|
||||||
|
if len([]rune(strings.TrimSpace(task))) < 10 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
summaryPrompt := fmt.Sprintf(`基于这个任务和结果:
|
||||||
|
任务: %q
|
||||||
|
结果: %.500s
|
||||||
|
|
||||||
|
总结1-2条关键经验。如果没有值得记忆的,只输出 SKIP。`, task, result)
|
||||||
|
msgs := []llm.Message{
|
||||||
|
llm.NewMsg("system", "你是一个团队成员,总结工作经验。"),
|
||||||
|
llm.NewMsg("user", summaryPrompt),
|
||||||
|
}
|
||||||
|
summary, err := member.Chat(ctx, msgs, nil)
|
||||||
|
if err != nil || summary == "" || strings.TrimSpace(summary) == "SKIP" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := time.Now().Format("2006-01") + ".md"
|
||||||
|
existing, _ := os.ReadFile(filepath.Join(member.Dir, "memory", filename))
|
||||||
|
taskTitle := task
|
||||||
|
if len([]rune(taskTitle)) > 50 {
|
||||||
|
taskTitle = string([]rune(taskTitle)[:50])
|
||||||
|
}
|
||||||
|
content := string(existing) + fmt.Sprintf("\n## %s — %s\n\n%s\n", time.Now().Format("2006-01-02"), taskTitle, summary)
|
||||||
|
member.SaveMemory(filename, content)
|
||||||
|
log.Printf("[memory] 成员 %s 记忆已更新", member.Config.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendPlanLog 将沟通记录追加到任务计划文档
|
||||||
|
func (r *Room) appendPlanLog(from, to, content string) {
|
||||||
|
if r.planFilename == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := filepath.Join(r.Dir, "workspace", r.planFilename)
|
||||||
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
entry := fmt.Sprintf("\n---\n**[%s] %s → %s**\n\n%s\n",
|
||||||
|
time.Now().Format("15:04:05"), from, to, content)
|
||||||
|
f.WriteString(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) updateTasks(msgs []llm.Message) {
|
||||||
|
var tasks strings.Builder
|
||||||
|
tasks.WriteString("# Tasks\n\n")
|
||||||
|
for _, m := range msgs {
|
||||||
|
if m.Role == "assistant" {
|
||||||
|
assignments := parseAssignments(m.Content)
|
||||||
|
for name, task := range assignments {
|
||||||
|
tasks.WriteString(fmt.Sprintf("- [ ] [%s] %s\n", name, task))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content := tasks.String()
|
||||||
|
os.WriteFile(filepath.Join(r.Dir, "tasks.md"), []byte(content), 0644)
|
||||||
|
r.emit(Event{Type: EvtTasksUpdate, Content: content})
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
214
internal/room/types.go
Normal file
214
internal/room/types.go
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
package room
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/sdaduanbilei/agent-team/internal/agent"
|
||||||
|
"github.com/sdaduanbilei/agent-team/internal/llm"
|
||||||
|
"github.com/sdaduanbilei/agent-team/internal/skill"
|
||||||
|
"github.com/sdaduanbilei/agent-team/internal/store"
|
||||||
|
"github.com/sdaduanbilei/agent-team/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RoomType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TypeProject RoomType = "project"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Status string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusPending Status = "pending"
|
||||||
|
StatusThinking Status = "thinking"
|
||||||
|
StatusWorking Status = "working"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Type RoomType `yaml:"type"`
|
||||||
|
Master string `yaml:"master"` // agent name
|
||||||
|
Members []string `yaml:"members"` // agent names
|
||||||
|
Color string `yaml:"color"` // avatar color
|
||||||
|
Team string `yaml:"team"` // installed team name
|
||||||
|
}
|
||||||
|
|
||||||
|
type Room struct {
|
||||||
|
Config Config
|
||||||
|
Dir string
|
||||||
|
master *agent.Agent
|
||||||
|
members map[string]*agent.Agent
|
||||||
|
skillMeta []skill.Meta
|
||||||
|
User *user.User
|
||||||
|
Status Status
|
||||||
|
ActiveAgent string // for working status display
|
||||||
|
Broadcast func(Event) // set by api layer
|
||||||
|
|
||||||
|
// master 的会话历史,保持多轮对话上下文
|
||||||
|
masterHistory []llm.Message
|
||||||
|
historyMu sync.Mutex
|
||||||
|
|
||||||
|
Mode string // "plan" | "build"
|
||||||
|
pendingAssignments map[string]string // plan 模式下暂存的待执行任务
|
||||||
|
pendingPlanReply string // master 规划原文,用于生成计划文档
|
||||||
|
|
||||||
|
// Build 模式下成员对话跟踪
|
||||||
|
memberConvos map[string][]llm.Message // 成员名 -> 多轮对话历史
|
||||||
|
memberArtifacts map[string]string // 成员名 -> 已产出的文档文件名(用于后续覆盖更新)
|
||||||
|
lastActiveMember string // 最后一个发出提问的成员
|
||||||
|
planFilename string // 当前任务计划文件名
|
||||||
|
systemRules string // SYSTEM.md 全局规则
|
||||||
|
|
||||||
|
projectTemplate *ProjectTemplate // 项目模板(可为 nil)
|
||||||
|
|
||||||
|
Store *store.Store
|
||||||
|
currentGroupID int64 // 当前用户消息的 group_id
|
||||||
|
|
||||||
|
cancelFunc func()
|
||||||
|
cancelMu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
EvtAgentMessage EventType = "agent_message"
|
||||||
|
EvtTaskAssign EventType = "task_assign"
|
||||||
|
EvtReview EventType = "review"
|
||||||
|
EvtRoomStatus EventType = "room_status"
|
||||||
|
EvtTasksUpdate EventType = "tasks_update"
|
||||||
|
EvtWorkspaceFile EventType = "workspace_file"
|
||||||
|
EvtModeChange EventType = "mode_change"
|
||||||
|
EvtArtifact EventType = "artifact"
|
||||||
|
EvtTaskDone EventType = "task_done"
|
||||||
|
EvtScheduleRun EventType = "schedule_run"
|
||||||
|
EvtTokenUsage EventType = "token_usage"
|
||||||
|
EvtFileRead EventType = "file_read"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Event struct {
|
||||||
|
Type EventType `json:"type"`
|
||||||
|
RoomID string `json:"room_id"`
|
||||||
|
Agent string `json:"agent,omitempty"`
|
||||||
|
Role string `json:"role,omitempty"` // master | member | challenge
|
||||||
|
Content string `json:"content,omitempty"`
|
||||||
|
Streaming bool `json:"streaming,omitempty"`
|
||||||
|
From string `json:"from,omitempty"`
|
||||||
|
To string `json:"to,omitempty"`
|
||||||
|
Task string `json:"task,omitempty"`
|
||||||
|
Feedback string `json:"feedback,omitempty"`
|
||||||
|
Status Status `json:"status,omitempty"`
|
||||||
|
ActiveAgent string `json:"active_agent,omitempty"`
|
||||||
|
Action string `json:"action,omitempty"`
|
||||||
|
Filename string `json:"filename,omitempty"`
|
||||||
|
Mode string `json:"mode,omitempty"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
PromptTokens int `json:"prompt_tokens,omitempty"`
|
||||||
|
CompletionTokens int `json:"completion_tokens,omitempty"`
|
||||||
|
TotalTokens int `json:"total_tokens,omitempty"`
|
||||||
|
NoStore bool `json:"-"` // 跳过 emit 中的自动 DB 存储(调用方已显式存储)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectFile 项目模板中的单个文件
|
||||||
|
type ProjectFile struct {
|
||||||
|
Path string // 如 "创作需求书.md"
|
||||||
|
Owner string // 负责的 agent 名
|
||||||
|
Phase int // 阶段编号
|
||||||
|
IsDir bool // 是否为目录
|
||||||
|
Dynamic bool // ... 标记,可动态扩展
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectTemplate 从 TEAM.md 解析的项目模板
|
||||||
|
type ProjectTemplate struct {
|
||||||
|
Files []ProjectFile
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseProjectTemplate 从 TEAM.md body 中提取 project-template 代码块并解析
|
||||||
|
func parseProjectTemplate(body string) *ProjectTemplate {
|
||||||
|
re := regexp.MustCompile("(?s)```project-template\\s*\\n(.+?)```")
|
||||||
|
match := re.FindStringSubmatch(body)
|
||||||
|
if match == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
block := match[1]
|
||||||
|
|
||||||
|
lineRe := regexp.MustCompile(`[├└─│\s]+(.+?)\.md\s+@(\S+)\s*(?:phase:(\d+))?`)
|
||||||
|
dirRe := regexp.MustCompile(`[├└─│\s]+(.+?)/\s*$`)
|
||||||
|
dynamicRe := regexp.MustCompile(`[├└─│\s]+\.\.\.\s+@(\S+)\s*(?:phase:(\d+))?`)
|
||||||
|
|
||||||
|
var files []ProjectFile
|
||||||
|
for _, line := range strings.Split(block, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "workspace/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if m := dynamicRe.FindStringSubmatch(line); m != nil {
|
||||||
|
phase := 0
|
||||||
|
if m[2] != "" {
|
||||||
|
phase, _ = strconv.Atoi(m[2])
|
||||||
|
}
|
||||||
|
files = append(files, ProjectFile{
|
||||||
|
Path: "...", Owner: m[1], Phase: phase, Dynamic: true,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if m := dirRe.FindStringSubmatch(line); m != nil {
|
||||||
|
files = append(files, ProjectFile{
|
||||||
|
Path: m[1] + "/", IsDir: true,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if m := lineRe.FindStringSubmatch(line); m != nil {
|
||||||
|
phase := 0
|
||||||
|
if m[3] != "" {
|
||||||
|
phase, _ = strconv.Atoi(m[3])
|
||||||
|
}
|
||||||
|
files = append(files, ProjectFile{
|
||||||
|
Path: strings.TrimSpace(m[1]) + ".md", Owner: m[2], Phase: phase,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(files) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &ProjectTemplate{Files: files}
|
||||||
|
}
|
||||||
|
|
||||||
|
type BoardEntry struct {
|
||||||
|
Author string
|
||||||
|
Content string
|
||||||
|
Type string // "draft" | "challenge"
|
||||||
|
}
|
||||||
|
|
||||||
|
type SharedBoard struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
entries []BoardEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *SharedBoard) Add(author, content, typ string) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
b.entries = append(b.entries, BoardEntry{Author: author, Content: content, Type: typ})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *SharedBoard) ToContext() string {
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
if len(b.entries) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("<team_board>\n")
|
||||||
|
for _, e := range b.entries {
|
||||||
|
fmt.Fprintf(&sb, " <entry type=\"%s\" author=\"%s\">\n%s\n </entry>\n", e.Type, e.Author, e.Content)
|
||||||
|
}
|
||||||
|
sb.WriteString("</team_board>")
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
196
internal/room/utils.go
Normal file
196
internal/room/utils.go
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
package room
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sdaduanbilei/agent-team/internal/agent"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// extractTitle 从文档内容中提取标题
|
||||||
|
func extractTitle(content string) string {
|
||||||
|
for _, line := range strings.Split(content, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "# ") {
|
||||||
|
return strings.TrimPrefix(line, "# ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx := strings.Index(content, "《"); idx != -1 {
|
||||||
|
if end := strings.Index(content[idx:], "》"); end != -1 {
|
||||||
|
return content[idx+len("《") : idx+end]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(content, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "## ") {
|
||||||
|
return strings.TrimPrefix(line, "## ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// titleToFilename 从文档标题生成文件名,如 "主角小传:林远" → "主角小传-林远.md"
|
||||||
|
func titleToFilename(title, agentName string) string {
|
||||||
|
if title == "" {
|
||||||
|
return fmt.Sprintf("%s-%s.md", agentName, time.Now().Format("20060102-150405"))
|
||||||
|
}
|
||||||
|
name := strings.NewReplacer(
|
||||||
|
":", "-", ":", "-",
|
||||||
|
"/", "-", "\\", "-",
|
||||||
|
" ", "-", " ", "-",
|
||||||
|
"*", "", "?", "", "\"", "", "<", "", ">", "", "|", "",
|
||||||
|
).Replace(title)
|
||||||
|
for strings.Contains(name, "--") {
|
||||||
|
name = strings.ReplaceAll(name, "--", "-")
|
||||||
|
}
|
||||||
|
name = strings.Trim(name, "-")
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Sprintf("%s-%s.md", agentName, time.Now().Format("20060102-150405"))
|
||||||
|
}
|
||||||
|
return name + ".md"
|
||||||
|
}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAssignments 解析任务分配指令。
|
||||||
|
// 支持多行任务描述:从 @成员名 开始,到下一个 @成员名 或文本结束为止。
|
||||||
|
func parseAssignments(text string) map[string]string {
|
||||||
|
result := make(map[string]string)
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
|
||||||
|
var currentName string
|
||||||
|
var currentTask strings.Builder
|
||||||
|
|
||||||
|
flush := func() {
|
||||||
|
if currentName != "" {
|
||||||
|
task := strings.TrimSpace(currentTask.String())
|
||||||
|
if task != "" {
|
||||||
|
result[currentName] = task
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentName = ""
|
||||||
|
currentTask.Reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
atIdx := strings.Index(trimmed, "@")
|
||||||
|
if atIdx >= 0 {
|
||||||
|
rest := trimmed[atIdx+1:]
|
||||||
|
if idx := strings.IndexAny(rest, " \t"); idx > 0 {
|
||||||
|
name := strings.TrimSpace(rest[:idx])
|
||||||
|
task := strings.TrimSpace(rest[idx+1:])
|
||||||
|
flush()
|
||||||
|
currentName = name
|
||||||
|
if task != "" {
|
||||||
|
currentTask.WriteString(task)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(rest)
|
||||||
|
if name != "" {
|
||||||
|
flush()
|
||||||
|
currentName = name
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(trimmed, "ASSIGN:") {
|
||||||
|
parts := strings.SplitN(strings.TrimPrefix(trimmed, "ASSIGN:"), ":", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
flush()
|
||||||
|
result[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if currentName != "" && trimmed != "" {
|
||||||
|
if currentTask.Len() > 0 {
|
||||||
|
currentTask.WriteString("\n")
|
||||||
|
}
|
||||||
|
currentTask.WriteString(trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseUserMentions 从用户消息中提取 @agent 指派。
|
||||||
|
func parseUserMentions(text string, validMembers map[string]*agent.Agent) map[string]string {
|
||||||
|
assignments := make(map[string]string)
|
||||||
|
for _, line := range strings.Split(text, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if !strings.HasPrefix(line, "@") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rest := strings.TrimPrefix(line, "@")
|
||||||
|
idx := strings.IndexAny(rest, " \t")
|
||||||
|
if idx <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(rest[:idx])
|
||||||
|
task := strings.TrimSpace(rest[idx+1:])
|
||||||
|
if _, ok := validMembers[name]; ok && task != "" {
|
||||||
|
assignments[name] = task
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return assignments
|
||||||
|
}
|
||||||
|
|
||||||
|
// isConversational 判断用户 @成员 后的文本是对话还是任务分配
|
||||||
|
func isConversational(text string) bool {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
runeLen := len([]rune(text))
|
||||||
|
|
||||||
|
// 非常短的消息视为对话
|
||||||
|
if runeLen < 10 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 问候/简短回复
|
||||||
|
lower := strings.ToLower(text)
|
||||||
|
greetings := []string{"你好", "嗨", "hi", "hello", "hey", "谢谢", "感谢", "好的", "ok", "好", "嗯", "对", "是的", "不是", "怎么样", "什么"}
|
||||||
|
for _, g := range greetings {
|
||||||
|
if strings.HasPrefix(lower, g) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 以问号结尾的短消息视为提问(对话)
|
||||||
|
if runeLen < 30 && (strings.HasSuffix(text, "?") || strings.HasSuffix(text, "?")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRoomConfig(data []byte) (Config, error) {
|
||||||
|
var cfg Config
|
||||||
|
if !bytes.HasPrefix(data, []byte("---")) {
|
||||||
|
return cfg, fmt.Errorf("missing frontmatter")
|
||||||
|
}
|
||||||
|
parts := bytes.SplitN(data, []byte("---"), 3)
|
||||||
|
if len(parts) < 3 {
|
||||||
|
return cfg, fmt.Errorf("invalid frontmatter")
|
||||||
|
}
|
||||||
|
return cfg, yaml.Unmarshal(parts[1], &cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveAgentPath finds agent dir: prefers agentsDir/team/name, falls back to agentsDir/name
|
||||||
|
func resolveAgentPath(agentsDir, team, name string) string {
|
||||||
|
if team != "" {
|
||||||
|
p := filepath.Join(agentsDir, team, name)
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filepath.Join(agentsDir, name)
|
||||||
|
}
|
||||||
584
internal/room/workflow.go
Normal file
584
internal/room/workflow.go
Normal file
@ -0,0 +1,584 @@
|
|||||||
|
package room
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sdaduanbilei/agent-team/internal/llm"
|
||||||
|
"github.com/sdaduanbilei/agent-team/internal/skill"
|
||||||
|
"github.com/sdaduanbilei/agent-team/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildWorkflowStep 根据 workspace 中已存在的文件,构建 phase-aware 的工作流进度上下文
|
||||||
|
func (r *Room) buildWorkflowStep() string {
|
||||||
|
if r.projectTemplate == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var completed []string
|
||||||
|
var pending []string
|
||||||
|
minPendingPhase := 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); err == nil {
|
||||||
|
completed = append(completed, f.Path)
|
||||||
|
} else {
|
||||||
|
pending = append(pending, fmt.Sprintf("%s (@%s, phase:%d)", f.Path, f.Owner, f.Phase))
|
||||||
|
if f.Phase < minPendingPhase {
|
||||||
|
minPendingPhase = f.Phase
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("<workflow_progress>\n")
|
||||||
|
sb.WriteString("已完成的文件:\n")
|
||||||
|
for _, f := range completed {
|
||||||
|
sb.WriteString(fmt.Sprintf(" [done] %s\n", f))
|
||||||
|
}
|
||||||
|
sb.WriteString("待产出的文件:\n")
|
||||||
|
for _, f := range pending {
|
||||||
|
sb.WriteString(fmt.Sprintf(" [todo] %s\n", f))
|
||||||
|
}
|
||||||
|
sb.WriteString("</workflow_progress>\n\n")
|
||||||
|
|
||||||
|
if minPendingPhase < 999 {
|
||||||
|
sb.WriteString(fmt.Sprintf("<next_action>\n当前阶段:phase %d\n", minPendingPhase))
|
||||||
|
sb.WriteString(fmt.Sprintf("⚠️ 严格规则:只能分配 phase %d 的任务,系统会阻止跨阶段分配。\n", minPendingPhase))
|
||||||
|
for _, f := range r.projectTemplate.Files {
|
||||||
|
if f.IsDir || f.Dynamic || f.Phase != minPendingPhase {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fpath := filepath.Join(r.Dir, "workspace", f.Path)
|
||||||
|
if _, err := os.Stat(fpath); err == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if f.Owner == r.master.Config.Name {
|
||||||
|
sb.WriteString(fmt.Sprintf("- 你负责:%s(系统将自动发起文档调用,你不需要在此输出文档正文)\n", f.Path))
|
||||||
|
} else {
|
||||||
|
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(fmt.Sprintf("请只分配 phase %d 的任务。你负责的文件由系统自动发起独立调用,不要在回复中输出完整文档正文。", minPendingPhase))
|
||||||
|
} else if dynDir, dynOwner, dynPhase := r.getDynamicFileInfo(); dynDir != "" {
|
||||||
|
// 所有静态文件已完成,进入动态章节写作阶段
|
||||||
|
existingChapters := r.listChapterFiles(dynDir)
|
||||||
|
sb.WriteString(fmt.Sprintf("<next_action>\n当前阶段:phase %d — 章节写作\n", dynPhase))
|
||||||
|
sb.WriteString(fmt.Sprintf("负责人:%s\n", dynOwner))
|
||||||
|
if len(existingChapters) > 0 {
|
||||||
|
sb.WriteString("已完成章节:\n")
|
||||||
|
for _, ch := range existingChapters {
|
||||||
|
sb.WriteString(fmt.Sprintf(" [done] %s/%s\n", dynDir, ch))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString("系统将自动发起章节文档调用,你不需要在回复中输出章节正文。\n")
|
||||||
|
sb.WriteString("请简短说明接下来要写哪一章,系统会自动发起文档调用。\n")
|
||||||
|
sb.WriteString("</next_action>\n")
|
||||||
|
} else {
|
||||||
|
sb.WriteString("所有模板文件已完成。请根据你的 AGENT.md 工作流程决定下一步行动(如向用户交付、或进入下一阶段)。")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentMinPhase 返回当前最小未完成 phase
|
||||||
|
func (r *Room) currentMinPhase() int {
|
||||||
|
if r.projectTemplate == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
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 0
|
||||||
|
}
|
||||||
|
return minPhase
|
||||||
|
}
|
||||||
|
|
||||||
|
// validatePhaseAssignments 校验任务分配是否符合 phase 顺序
|
||||||
|
// 返回被阻止的分配及原因
|
||||||
|
func (r *Room) validatePhaseAssignments(assignments map[string]string) map[string]string {
|
||||||
|
if r.projectTemplate == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
minPhase := r.currentMinPhase()
|
||||||
|
if minPhase == 0 {
|
||||||
|
return nil // 所有静态文件已完成
|
||||||
|
}
|
||||||
|
|
||||||
|
blocked := make(map[string]string)
|
||||||
|
for name := range assignments {
|
||||||
|
targetFile := r.findMemberTargetFile(name)
|
||||||
|
if targetFile == nil {
|
||||||
|
continue // 没有模板文件,不校验
|
||||||
|
}
|
||||||
|
if targetFile.Phase > minPhase {
|
||||||
|
// 该成员的目标文件 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(
|
||||||
|
"《%s》属于 phase:%d,但 phase:%d 还有未完成文件:%s",
|
||||||
|
targetFile.Path, targetFile.Phase, minPhase,
|
||||||
|
strings.Join(pendingInCurrentPhase, "、"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return blocked
|
||||||
|
}
|
||||||
|
|
||||||
|
// allStaticFilesDone 检查所有非动态模板文件是否都已完成
|
||||||
|
func (r *Room) allStaticFilesDone() bool {
|
||||||
|
if r.projectTemplate == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDynamicFileInfo 返回动态文件的目录名、负责人和阶段号
|
||||||
|
func (r *Room) getDynamicFileInfo() (dir, owner string, phase int) {
|
||||||
|
if r.projectTemplate == nil {
|
||||||
|
return "", "", 0
|
||||||
|
}
|
||||||
|
// 找到 dynamic 条目,以及它前面的目录条目
|
||||||
|
lastDir := ""
|
||||||
|
for _, f := range r.projectTemplate.Files {
|
||||||
|
if f.IsDir {
|
||||||
|
lastDir = strings.TrimSuffix(f.Path, "/")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if f.Dynamic {
|
||||||
|
if lastDir == "" {
|
||||||
|
lastDir = "chapters"
|
||||||
|
}
|
||||||
|
return lastDir, f.Owner, f.Phase
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", "", 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// listChapterFiles 列出动态目录下已有的章节文件
|
||||||
|
func (r *Room) listChapterFiles(dir string) []string {
|
||||||
|
chDir := filepath.Join(r.Dir, "workspace", dir)
|
||||||
|
entries, err := os.ReadDir(chDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var files []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() && strings.HasSuffix(e.Name(), ".md") {
|
||||||
|
files = append(files, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
// masterChapterFileCall 为 master 发起一次章节文档调用
|
||||||
|
func (r *Room) masterChapterFileCall(ctx context.Context, masterMsgs *[]llm.Message, dir string, chapterHint string) {
|
||||||
|
r.setStatus(StatusWorking, r.master.Config.Name, "正在编写章节...")
|
||||||
|
|
||||||
|
existingChapters := r.listChapterFiles(dir)
|
||||||
|
var existingList string
|
||||||
|
if len(existingChapters) > 0 {
|
||||||
|
existingList = "\n已完成的章节:\n"
|
||||||
|
for _, ch := range existingChapters {
|
||||||
|
existingList += fmt.Sprintf("- %s\n", ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filePrompt := fmt.Sprintf(
|
||||||
|
"[系统·章节文档调用] 请产出下一章的完整 Markdown 正文。\n"+
|
||||||
|
"要求:\n"+
|
||||||
|
"- 以 # 第X章 章节标题 开头\n"+
|
||||||
|
"- 只输出章节正文,不要夹杂任何交流内容\n"+
|
||||||
|
"- 不要说\"以下是\"\"下面是\"等引导语,直接输出正文\n"+
|
||||||
|
"- 系统会自动保存到 workspace/%s/ 目录下%s",
|
||||||
|
dir, existingList)
|
||||||
|
|
||||||
|
if chapterHint != "" {
|
||||||
|
filePrompt += "\n\n你之前的规划:" + chapterHint
|
||||||
|
}
|
||||||
|
|
||||||
|
if wsCtx := r.buildWorkspaceContext(); wsCtx != "" {
|
||||||
|
filePrompt += "\n\n" + wsCtx
|
||||||
|
}
|
||||||
|
|
||||||
|
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 chapter file call error: %v", r.Config.Name, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.emitUsage(r.master.Config.Name, usage)
|
||||||
|
|
||||||
|
content := strings.TrimSpace(reply)
|
||||||
|
|
||||||
|
// 从内容中提取章节标题作为文件名
|
||||||
|
chapterFilename := r.extractChapterFilename(content, dir)
|
||||||
|
fullPath := dir + "/" + chapterFilename
|
||||||
|
|
||||||
|
if !strings.HasPrefix(content, "# ") {
|
||||||
|
content = "# " + strings.TrimSuffix(chapterFilename, ".md") + "\n\n" + content
|
||||||
|
}
|
||||||
|
|
||||||
|
r.saveWorkspace(fullPath, content)
|
||||||
|
docName := strings.TrimSuffix(chapterFilename, ".md")
|
||||||
|
r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: fullPath, 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: fullPath, PartType: "document",
|
||||||
|
GroupID: &r.currentGroupID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送完成状态到聊天
|
||||||
|
statusMsg := fmt.Sprintf("《%s》已完成,保存到 %s。", docName, fullPath)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractChapterFilename 从章节内容中提取文件名
|
||||||
|
func (r *Room) extractChapterFilename(content, dir string) string {
|
||||||
|
title := extractTitle(content)
|
||||||
|
if title == "" {
|
||||||
|
// 按已有章节数自增
|
||||||
|
existing := r.listChapterFiles(dir)
|
||||||
|
return fmt.Sprintf("第%d章.md", len(existing)+1)
|
||||||
|
}
|
||||||
|
// 清理标题中的特殊字符,作为文件名
|
||||||
|
title = strings.ReplaceAll(title, " ", "-")
|
||||||
|
title = strings.ReplaceAll(title, "/", "-")
|
||||||
|
title = strings.ReplaceAll(title, "\\", "-")
|
||||||
|
if !strings.HasSuffix(title, ".md") {
|
||||||
|
title += ".md"
|
||||||
|
}
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildProjectContext 构建项目模板上下文,注入到成员 system prompt
|
||||||
|
func (r *Room) buildProjectContext(agentName string) string {
|
||||||
|
if r.projectTemplate == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("<project_template>\n")
|
||||||
|
sb.WriteString("项目文件结构(系统自动管理文件保存):\n\n")
|
||||||
|
for _, f := range r.projectTemplate.Files {
|
||||||
|
if f.IsDir {
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s (目录)\n", f.Path))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if f.Dynamic {
|
||||||
|
sb.WriteString(fmt.Sprintf(" ... @%s phase:%d (动态扩展)\n", f.Owner, f.Phase))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
marker := ""
|
||||||
|
if f.Owner == agentName {
|
||||||
|
marker = " ← 你负责"
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s @%s phase:%d%s\n", f.Path, f.Owner, f.Phase, marker))
|
||||||
|
}
|
||||||
|
sb.WriteString("\n输出规范:工作完成后,最终回复只输出文件的 Markdown 正文内容(以 # 标题 开头)。\n")
|
||||||
|
sb.WriteString("系统会自动保存到对应文件。不要在文档中夹杂状态描述或对话内容。\n")
|
||||||
|
sb.WriteString("</project_template>")
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchTemplateFile 按标题匹配模板文件。
|
||||||
|
func (r *Room) matchTemplateFile(title string) *ProjectFile {
|
||||||
|
if r.projectTemplate == nil || title == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for i := range r.projectTemplate.Files {
|
||||||
|
f := &r.projectTemplate.Files[i]
|
||||||
|
if f.IsDir || f.Dynamic {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fname := strings.TrimSuffix(f.Path, ".md")
|
||||||
|
if fname == title {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range r.projectTemplate.Files {
|
||||||
|
f := &r.projectTemplate.Files[i]
|
||||||
|
if f.IsDir || f.Dynamic {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fname := strings.TrimSuffix(f.Path, ".md")
|
||||||
|
if strings.HasPrefix(title, fname) {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range r.projectTemplate.Files {
|
||||||
|
f := &r.projectTemplate.Files[i]
|
||||||
|
if f.IsDir || f.Dynamic {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
base := strings.TrimSuffix(f.Path, ".md")
|
||||||
|
if strings.Contains(base, title) || strings.Contains(title, base) {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
keywords := strings.FieldsFunc(base, func(r rune) bool { return r == '与' || r == '和' || r == '·' })
|
||||||
|
for _, kw := range keywords {
|
||||||
|
if len([]rune(kw)) >= 2 && strings.Contains(title, kw) {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findOwnerFiles 查找某 agent 负责的所有文件
|
||||||
|
func (r *Room) findOwnerFiles(agentName string) []ProjectFile {
|
||||||
|
if r.projectTemplate == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var files []ProjectFile
|
||||||
|
for _, f := range r.projectTemplate.Files {
|
||||||
|
if f.Owner == agentName && !f.IsDir && !f.Dynamic {
|
||||||
|
files = append(files, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
// findPendingMasterFiles 查找当前最小未完成 phase 中 master 负责的待产出文件
|
||||||
|
func (r *Room) findPendingMasterFiles() []ProjectFile {
|
||||||
|
if r.projectTemplate == 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
|
||||||
|
for _, f := range r.projectTemplate.Files {
|
||||||
|
if f.IsDir || f.Dynamic || f.Phase != minPhase || f.Owner != r.master.Config.Name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fpath := filepath.Join(r.Dir, "workspace", f.Path)
|
||||||
|
if _, err := os.Stat(fpath); os.IsNotExist(err) {
|
||||||
|
files = append(files, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
// masterFileCall 为 master 发起一次独立的文档产出调用(file call)。
|
||||||
|
// 输出直接保存到 workspace,不进入聊天记录。
|
||||||
|
func (r *Room) masterFileCall(ctx context.Context, masterMsgs *[]llm.Message, file ProjectFile) {
|
||||||
|
docName := strings.TrimSuffix(file.Path, ".md")
|
||||||
|
r.setStatus(StatusWorking, r.master.Config.Name, fmt.Sprintf("正在编写《%s》...", docName))
|
||||||
|
|
||||||
|
filePrompt := fmt.Sprintf(
|
||||||
|
"[系统·文档调用] 现在请产出文件《%s》的完整 Markdown 正文。\n要求:\n"+
|
||||||
|
"- 以 # %s 开头\n"+
|
||||||
|
"- 只输出文档正文,不要夹杂任何交流内容\n"+
|
||||||
|
"- 不要说\"以下是\"\"下面是\"等引导语,直接输出文档\n"+
|
||||||
|
"- 系统会自动保存到 workspace/%s",
|
||||||
|
docName, docName, file.Path)
|
||||||
|
|
||||||
|
if wsCtx := r.buildWorkspaceContext(); wsCtx != "" {
|
||||||
|
filePrompt += "\n\n" + wsCtx
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findMemberTargetFile 查找成员当前应产出的目标文件(用于 file call)
|
||||||
|
func (r *Room) findMemberTargetFile(name string) *ProjectFile {
|
||||||
|
ownerFiles := r.findOwnerFiles(name)
|
||||||
|
if len(ownerFiles) == 0 {
|
||||||
|
log.Printf("[room %s] findMemberTargetFile(%s): no owner files found", r.Config.Name, name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
log.Printf("[room %s] findMemberTargetFile(%s): found %d owner files", r.Config.Name, name, len(ownerFiles))
|
||||||
|
if len(ownerFiles) == 1 {
|
||||||
|
log.Printf("[room %s] findMemberTargetFile(%s): → %s", r.Config.Name, name, ownerFiles[0].Path)
|
||||||
|
return &ownerFiles[0]
|
||||||
|
}
|
||||||
|
for i := range ownerFiles {
|
||||||
|
fpath := filepath.Join(r.Dir, "workspace", ownerFiles[i].Path)
|
||||||
|
if _, err := os.Stat(fpath); os.IsNotExist(err) {
|
||||||
|
log.Printf("[room %s] findMemberTargetFile(%s): → %s (not yet exists)", r.Config.Name, name, ownerFiles[i].Path)
|
||||||
|
return &ownerFiles[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result := &ownerFiles[len(ownerFiles)-1]
|
||||||
|
log.Printf("[room %s] findMemberTargetFile(%s): → %s (fallback last)", r.Config.Name, name, result.Path)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSkillSummary 为 master 构建简要的 skill 清单
|
||||||
|
func (r *Room) buildSkillSummary() string {
|
||||||
|
if len(r.skillMeta) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("<available_skills>\n")
|
||||||
|
sb.WriteString("以下工具可供团队成员使用,你可以在分配任务时提示成员使用对应的工具:\n")
|
||||||
|
for _, m := range r.skillMeta {
|
||||||
|
fmt.Fprintf(&sb, " - %s: %s\n", m.Name, m.Description)
|
||||||
|
}
|
||||||
|
sb.WriteString("</available_skills>")
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTeamXML 构建团队成员 XML 上下文
|
||||||
|
func (r *Room) buildTeamXML() string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("<team_members>\n")
|
||||||
|
for name, a := range r.members {
|
||||||
|
fmt.Fprintf(&sb, " <member>\n <name>%s</name>\n <description>%s</description>\n </member>\n", name, a.Config.Description)
|
||||||
|
}
|
||||||
|
sb.WriteString("</team_members>")
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeToolCall 执行 tool call,返回执行结果
|
||||||
|
func (r *Room) executeToolCall(tc llm.ToolCall) string {
|
||||||
|
var args struct {
|
||||||
|
Command string `json:"command"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil {
|
||||||
|
return fmt.Sprintf("参数解析错误: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
skillPath := skill.SkillPathByToolName(r.skillMeta, tc.Function.Name)
|
||||||
|
skillsRoot := filepath.Dir(skillPath)
|
||||||
|
if skillPath == "" {
|
||||||
|
skillsRoot = "skills"
|
||||||
|
}
|
||||||
|
if abs, err := filepath.Abs(skillsRoot); err == nil {
|
||||||
|
skillsRoot = abs
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[tool] 执行: %s, 命令: %s", tc.Function.Name, args.Command)
|
||||||
|
|
||||||
|
cmd := exec.Command("bash", "-c", args.Command)
|
||||||
|
cmd.Env = append(os.Environ(), "SKILLS_ROOT="+skillsRoot)
|
||||||
|
cmd.Dir = r.Dir
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
result := string(output)
|
||||||
|
if err != nil {
|
||||||
|
result = fmt.Sprintf("命令执行错误: %v\n输出:\n%s", err, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result) > 10000 {
|
||||||
|
result = result[:10000] + "\n... (输出已截断)"
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[tool] 结果 (%d 字符): %.200s", len(result), result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
262
internal/room/workspace.go
Normal file
262
internal/room/workspace.go
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
package room
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// saveWorkspace 保存文件到 workspace 目录,自动版本追踪
|
||||||
|
func (r *Room) saveWorkspace(filename, content string) {
|
||||||
|
dir := filepath.Join(r.Dir, "workspace")
|
||||||
|
os.MkdirAll(dir, 0755)
|
||||||
|
fpath := filepath.Join(dir, filename)
|
||||||
|
|
||||||
|
// 版本追踪:保存旧版本到数据库
|
||||||
|
if r.Store != nil {
|
||||||
|
if old, err := os.ReadFile(fpath); err == nil && len(old) > 0 {
|
||||||
|
r.Store.InsertFileVersion(r.Config.Name, filename, string(old), "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
os.WriteFile(fpath, []byte(content), 0644)
|
||||||
|
r.emit(Event{Type: EvtWorkspaceFile, Filename: filename, Action: "updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) listWorkspaceFiles() []string {
|
||||||
|
dir := filepath.Join(r.Dir, "workspace")
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var files []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
|
||||||
|
files = append(files, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildWorkspaceContext 读取 workspace 目录下所有文件内容,作为上下文
|
||||||
|
func (r *Room) buildWorkspaceContext() string {
|
||||||
|
wsDir := filepath.Join(r.Dir, "workspace")
|
||||||
|
entries, err := os.ReadDir(wsDir)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
const perFileMax = 4000 // 单文件最大字符数
|
||||||
|
const totalMax = 20000 // workspace 上下文总最大字符数
|
||||||
|
var sb strings.Builder
|
||||||
|
totalChars := 0
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || strings.HasPrefix(e.Name(), ".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(filepath.Join(wsDir, e.Name()))
|
||||||
|
if err != nil || len(content) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text := string(content)
|
||||||
|
runes := []rune(text)
|
||||||
|
if len(runes) > perFileMax {
|
||||||
|
runes = runes[:perFileMax]
|
||||||
|
text = string(runes) + "\n...(截断)"
|
||||||
|
}
|
||||||
|
if totalChars+len(runes) > totalMax {
|
||||||
|
sb.WriteString(fmt.Sprintf("\n--- 📎 %s --- (已省略,超出上下文限制)\n", e.Name()))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("\n--- 📎 %s ---\n%s\n", e.Name(), text))
|
||||||
|
totalChars += len(runes)
|
||||||
|
}
|
||||||
|
if sb.Len() == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "<workspace_files>\n以下是团队已产出的文档,可供参考和评审:" + sb.String() + "</workspace_files>"
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasTodoList 检查 workspace 中是否存在 TodoList.md
|
||||||
|
func (r *Room) hasTodoList() bool {
|
||||||
|
fpath := filepath.Join(r.Dir, "workspace", "TodoList.md")
|
||||||
|
_, err := os.Stat(fpath)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveAgentOutput 统一处理成员产出的文件保存路由(旧路径兼容,无模板时使用)。
|
||||||
|
// 返回 (filename, routed):routed=true 表示走了文件路由。
|
||||||
|
func (r *Room) saveAgentOutput(name, finalReply, task string) (string, bool) {
|
||||||
|
if r.projectTemplate != nil {
|
||||||
|
ownerFiles := r.findOwnerFiles(name)
|
||||||
|
if len(ownerFiles) >= 1 {
|
||||||
|
var targetFile *ProjectFile
|
||||||
|
if len(ownerFiles) == 1 {
|
||||||
|
targetFile = &ownerFiles[0]
|
||||||
|
} else {
|
||||||
|
title := extractTitle(finalReply)
|
||||||
|
if title != "" {
|
||||||
|
if tf := r.matchTemplateFile(title); tf != nil && tf.Owner == name {
|
||||||
|
targetFile = tf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if targetFile == nil {
|
||||||
|
firstLine := strings.TrimSpace(strings.SplitN(finalReply, "\n", 2)[0])
|
||||||
|
firstLine = strings.TrimLeft(firstLine, "# ")
|
||||||
|
if firstLine != "" {
|
||||||
|
for i := range ownerFiles {
|
||||||
|
base := strings.TrimSuffix(ownerFiles[i].Path, ".md")
|
||||||
|
if strings.Contains(firstLine, base) || strings.Contains(base, firstLine) {
|
||||||
|
targetFile = &ownerFiles[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if targetFile == nil {
|
||||||
|
for i := range ownerFiles {
|
||||||
|
fpath := filepath.Join(r.Dir, "workspace", ownerFiles[i].Path)
|
||||||
|
if _, err := os.Stat(fpath); os.IsNotExist(err) {
|
||||||
|
targetFile = &ownerFiles[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if targetFile == nil && len([]rune(finalReply)) > 200 {
|
||||||
|
targetFile = &ownerFiles[len(ownerFiles)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if targetFile != nil {
|
||||||
|
fileContent, _ := splitContentAndStatus(finalReply, targetFile.Path)
|
||||||
|
if !strings.HasPrefix(strings.TrimSpace(fileContent), "# ") {
|
||||||
|
docTitle := strings.TrimSuffix(targetFile.Path, ".md")
|
||||||
|
fileContent = "# " + docTitle + "\n\n" + fileContent
|
||||||
|
}
|
||||||
|
r.saveWorkspace(targetFile.Path, fileContent)
|
||||||
|
if r.memberArtifacts == nil {
|
||||||
|
r.memberArtifacts = make(map[string]string)
|
||||||
|
}
|
||||||
|
r.memberArtifacts[name] = targetFile.Path
|
||||||
|
r.emit(Event{Type: EvtTaskDone, Agent: name, Task: task})
|
||||||
|
return targetFile.Path, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 降级:原有 isDocument 逻辑
|
||||||
|
if isDocument(finalReply) {
|
||||||
|
title := extractTitle(finalReply)
|
||||||
|
if r.memberArtifacts == nil {
|
||||||
|
r.memberArtifacts = make(map[string]string)
|
||||||
|
}
|
||||||
|
var filename string
|
||||||
|
if existing, ok := r.memberArtifacts[name]; ok {
|
||||||
|
filename = existing
|
||||||
|
} else {
|
||||||
|
filename = titleToFilename(title, name)
|
||||||
|
r.memberArtifacts[name] = filename
|
||||||
|
}
|
||||||
|
r.saveWorkspace(filename, finalReply)
|
||||||
|
r.emit(Event{Type: EvtArtifact, Agent: name, Filename: filename, Title: title})
|
||||||
|
r.emit(Event{Type: EvtTaskDone, Agent: name, Task: task})
|
||||||
|
return filename, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 纯交流型输出
|
||||||
|
r.emit(Event{Type: EvtAgentMessage, Agent: name, Role: "member", Content: finalReply})
|
||||||
|
r.lastActiveMember = name
|
||||||
|
r.emit(Event{Type: EvtTaskDone, Agent: name, Task: task})
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitDocuments 从文本中拆分出独立文档段落(旧路径兼容)。
|
||||||
|
func splitDocuments(text string) []string {
|
||||||
|
parts := strings.Split("\n"+text, "\n# ")
|
||||||
|
if len(parts) <= 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var docs []string
|
||||||
|
for _, part := range parts[1:] {
|
||||||
|
doc := "# " + part
|
||||||
|
doc = strings.TrimSpace(doc)
|
||||||
|
if len([]rune(doc)) > 200 {
|
||||||
|
docs = append(docs, doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return docs
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDocument 判断内容是否为文档产出物
|
||||||
|
func isDocument(content string) bool {
|
||||||
|
runeLen := len([]rune(content))
|
||||||
|
hasH1 := strings.Contains(content, "\n# ") || strings.HasPrefix(content, "# ")
|
||||||
|
if hasH1 && runeLen > 300 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
h2Count := strings.Count(content, "\n## ")
|
||||||
|
if h2Count >= 2 && runeLen > 200 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.Contains(content, "《") && strings.Contains(content, "》") && runeLen > 300 {
|
||||||
|
if strings.Count(content, "\n") > 5 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripDocuments 从文本中去除文档段落,只保留非文档部分。
|
||||||
|
func (r *Room) stripDocuments(text string) string {
|
||||||
|
docs := splitDocuments(text)
|
||||||
|
if len(docs) == 0 {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
result := text
|
||||||
|
for _, doc := range docs {
|
||||||
|
result = strings.Replace(result, doc, "", 1)
|
||||||
|
}
|
||||||
|
for strings.Contains(result, "\n\n\n") {
|
||||||
|
result = strings.ReplaceAll(result, "\n\n\n", "\n\n")
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitContentAndStatus 将 agent 输出分为文件内容和状态消息。
|
||||||
|
func splitContentAndStatus(reply, filename string) (fileContent, statusMsg string) {
|
||||||
|
fileContent = reply
|
||||||
|
name := strings.TrimSuffix(filename, ".md")
|
||||||
|
statusMsg = fmt.Sprintf("已完成《%s》", name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyDocumentEdit 解析并应用文档编辑指令
|
||||||
|
func (r *Room) applyDocumentEdit(content string) (filename string, applied bool) {
|
||||||
|
editRe := regexp.MustCompile(`(?s)<<<EDIT\s+(.+?)>>>\s*<<<FIND>>>\s*(.+?)\s*<<<REPLACE>>>\s*(.+?)\s*<<<END>>>`)
|
||||||
|
matches := editRe.FindAllStringSubmatch(content, -1)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
for _, m := range matches {
|
||||||
|
fname := strings.TrimSpace(m[1])
|
||||||
|
oldText := strings.TrimSpace(m[2])
|
||||||
|
newText := strings.TrimSpace(m[3])
|
||||||
|
fpath := filepath.Join(r.Dir, "workspace", fname)
|
||||||
|
data, err := os.ReadFile(fpath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
original := string(data)
|
||||||
|
if !strings.Contains(original, oldText) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
updated := strings.Replace(original, oldText, newText, 1)
|
||||||
|
r.saveWorkspace(fname, updated)
|
||||||
|
filename = fname
|
||||||
|
applied = true
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -13,7 +13,6 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
X,
|
X,
|
||||||
Loader2,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useStore } from '../store'
|
import { useStore } from '../store'
|
||||||
import type { Message, TodoItem } from '../types'
|
import type { Message, TodoItem } from '../types'
|
||||||
@ -24,6 +23,15 @@ function formatTokens(n: number): string {
|
|||||||
return n.toString()
|
return n.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatTime(ts: string): string {
|
||||||
|
try {
|
||||||
|
const d = new Date(ts.includes('T') ? ts : ts + 'Z')
|
||||||
|
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function ChatView() {
|
export function ChatView() {
|
||||||
const { activeRoomId, rooms, messages, sendMessage, user, tokenUsage, todoItems } = useStore()
|
const { activeRoomId, rooms, messages, sendMessage, user, tokenUsage, todoItems } = useStore()
|
||||||
const [input, setInput] = useState('')
|
const [input, setInput] = useState('')
|
||||||
@ -174,10 +182,7 @@ export function ChatView() {
|
|||||||
{room.status === 'thinking' && msgs[msgs.length - 1]?.role === 'user' && (
|
{room.status === 'thinking' && msgs[msgs.length - 1]?.role === 'user' && (
|
||||||
<ThinkingBubble agent={room.master} />
|
<ThinkingBubble agent={room.master} />
|
||||||
)}
|
)}
|
||||||
{/* Working indicator - 当有 action 但没有 streaming 消息时显示 */}
|
{/* Working indicator 已移至 AgentStatusBar,不在消息列表重复显示 */}
|
||||||
{room.status === 'working' && room.action && !msgs.some(m => m.streaming) && (
|
|
||||||
<WorkingBubble agent={room.activeAgent || room.master} action={room.action} />
|
|
||||||
)}
|
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -377,20 +382,18 @@ function renderGroupedMessages(msgs: Message[], _roomId: string, userName?: stri
|
|||||||
const elements: React.ReactNode[] = []
|
const elements: React.ReactNode[] = []
|
||||||
|
|
||||||
for (const msg of msgs) {
|
for (const msg of msgs) {
|
||||||
// task_assign、tool_use、artifact 不在消息列表展示
|
// 只显示 user / master / member 角色的 text 消息
|
||||||
if (msg.role === 'task_assign' || msg.role === 'tool_use' || msg.role === 'artifact') continue
|
// task_assign、tool_use、artifact、challenge 都不在消息列表展示
|
||||||
|
if (msg.role === 'task_assign' || msg.role === 'tool_use' || msg.role === 'artifact' || msg.role === 'challenge') continue
|
||||||
// document part 只在产物面板展示,不在聊天列表
|
// document part 只在产物面板展示,不在聊天列表
|
||||||
if (msg.part_type === 'document') continue
|
if (msg.part_type === 'document') continue
|
||||||
|
|
||||||
// 对于旧数据(没有 part_type 或 part_type='text'),保持原有剥离逻辑作为兼容
|
|
||||||
let content = msg.content
|
let content = msg.content
|
||||||
if (!msg.part_type || msg.part_type === 'text') {
|
|
||||||
content = stripAssignmentLines(content)
|
content = stripAssignmentLines(content)
|
||||||
// 兼容旧数据:如果没有 part_type 标记,仍用老逻辑剥离文档内容
|
// 兼容旧数据:如果没有 part_type 标记,master 消息仍用老逻辑剥离文档内容
|
||||||
if (!msg.part_type && msg.role === 'master') {
|
if (!msg.part_type && msg.role === 'master') {
|
||||||
content = stripDocumentContent(content)
|
content = stripDocumentContent(content)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (content.trim()) {
|
if (content.trim()) {
|
||||||
elements.push(<MessageBubble key={String(msg.id)} msg={{...msg, content}} userName={userName} />)
|
elements.push(<MessageBubble key={String(msg.id)} msg={{...msg, content}} userName={userName} />)
|
||||||
@ -699,6 +702,11 @@ function MessageBubble({ msg, userName }: { msg: Message; userName?: string }) {
|
|||||||
MASTER
|
MASTER
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{msg.created_at && (
|
||||||
|
<span className="text-[10px] text-[var(--text-muted)] opacity-60">
|
||||||
|
{formatTime(msg.created_at)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isToolUse ? (
|
{isToolUse ? (
|
||||||
<div className="px-3.5 py-2 rounded-lg text-sm bg-[var(--bg-tertiary)] border border-[var(--border)] rounded-tl-sm flex items-center gap-2">
|
<div className="px-3.5 py-2 rounded-lg text-sm bg-[var(--bg-tertiary)] border border-[var(--border)] rounded-tl-sm flex items-center gap-2">
|
||||||
@ -762,29 +770,6 @@ function MessageBubble({ msg, userName }: { msg: Message; userName?: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorkingBubble({ agent, action }: { agent: string; action: string }) {
|
|
||||||
const avatarColors = [
|
|
||||||
'bg-red-500', 'bg-orange-500', 'bg-amber-500', 'bg-yellow-500',
|
|
||||||
'bg-lime-500', 'bg-green-500', 'bg-emerald-500', 'bg-teal-500',
|
|
||||||
'bg-cyan-500', 'bg-sky-500', 'bg-blue-500', 'bg-indigo-500',
|
|
||||||
'bg-violet-500', 'bg-purple-500', 'bg-fuchsia-500', 'bg-pink-500', 'bg-rose-500'
|
|
||||||
]
|
|
||||||
const colorIndex = (agent || 'A').charCodeAt(0) % avatarColors.length
|
|
||||||
return (
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-white font-semibold flex-shrink-0 mt-0.5 ${avatarColors[colorIndex]}`}>
|
|
||||||
{(agent || 'A')[0]?.toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-start">
|
|
||||||
<span className="font-medium text-sm text-[var(--text-primary)] mb-0.5">{agent}</span>
|
|
||||||
<div className="px-3.5 py-2.5 rounded-lg bg-[var(--bg-tertiary)] rounded-tl-sm flex items-center gap-2">
|
|
||||||
<Loader2 className="w-4 h-4 text-[var(--accent)] animate-spin" />
|
|
||||||
<span className="text-sm text-[var(--text-muted)]">{action}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ThinkingBubble({ agent }: { agent: string }) {
|
function ThinkingBubble({ agent }: { agent: string }) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import rehypeRaw from 'rehype-raw'
|
import rehypeRaw from 'rehype-raw'
|
||||||
import { FileText, X, Copy, Check, Loader2, PenLine, Clock } from 'lucide-react'
|
import { FileText, X, Copy, Check, Loader2, Clock } from 'lucide-react'
|
||||||
import { useStore } from '../store'
|
import { useStore } from '../store'
|
||||||
import type { TodoItem, Message } from '../types'
|
import type { TodoItem, Message } from '../types'
|
||||||
|
|
||||||
@ -208,11 +208,9 @@ function TodoDetailPopup({
|
|||||||
|
|
||||||
export function RightSidebar() {
|
export function RightSidebar() {
|
||||||
const { activeRoomId, workspace, todoItems, messages, rooms } = useStore()
|
const { activeRoomId, workspace, todoItems, messages, rooms } = useStore()
|
||||||
const [previewFile, setPreviewFile] = useState<{ name: string; content: string } | null>(null)
|
const [previewFile, setPreviewFile] = useState<{ name: string; content: string; loading?: boolean } | null>(null)
|
||||||
const [selectedTodo, setSelectedTodo] = useState<TodoItem | null>(null)
|
const [selectedTodo, setSelectedTodo] = useState<TodoItem | null>(null)
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
const [recentlyUpdated, setRecentlyUpdated] = useState<Set<string>>(new Set())
|
|
||||||
const prevFilesRef = useRef<string[]>([])
|
|
||||||
const [versions, setVersions] = useState<{version: number, agent: string, created_at: string}[]>([])
|
const [versions, setVersions] = useState<{version: number, agent: string, created_at: string}[]>([])
|
||||||
const [showVersions, setShowVersions] = useState(false)
|
const [showVersions, setShowVersions] = useState(false)
|
||||||
const [versionContent, setVersionContent] = useState<string | null>(null)
|
const [versionContent, setVersionContent] = useState<string | null>(null)
|
||||||
@ -222,34 +220,16 @@ export function RightSidebar() {
|
|||||||
const todos = activeRoomId ? (todoItems[activeRoomId] || []) : []
|
const todos = activeRoomId ? (todoItems[activeRoomId] || []) : []
|
||||||
const room = rooms.find(r => r.id === activeRoomId)
|
const room = rooms.find(r => r.id === activeRoomId)
|
||||||
|
|
||||||
// 检测文件更新,标记"刚更新"
|
|
||||||
useEffect(() => {
|
|
||||||
const prev = prevFilesRef.current
|
|
||||||
const newOrUpdated = files.filter(f => !prev.includes(f))
|
|
||||||
if (newOrUpdated.length > 0) {
|
|
||||||
setRecentlyUpdated(s => {
|
|
||||||
const next = new Set(s)
|
|
||||||
newOrUpdated.forEach(f => next.add(f))
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
// 3秒后移除"刚更新"标记
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setRecentlyUpdated(s => {
|
|
||||||
const next = new Set(s)
|
|
||||||
newOrUpdated.forEach(f => next.delete(f))
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}, 3000)
|
|
||||||
prevFilesRef.current = [...files]
|
|
||||||
return () => clearTimeout(timer)
|
|
||||||
}
|
|
||||||
prevFilesRef.current = [...files]
|
|
||||||
}, [files])
|
|
||||||
|
|
||||||
const openPreview = useCallback(async (filename: string) => {
|
const openPreview = useCallback(async (filename: string) => {
|
||||||
if (!activeRoomId) return
|
if (!activeRoomId) return
|
||||||
|
// 先弹出 loading 状态,不阻塞 UI
|
||||||
|
setPreviewFile({ name: filename, content: '', loading: true })
|
||||||
|
try {
|
||||||
const d = await fetch(`${API}/rooms/${activeRoomId}/workspace/${filename}`).then(r => r.json())
|
const d = await fetch(`${API}/rooms/${activeRoomId}/workspace/${filename}`).then(r => r.json())
|
||||||
setPreviewFile({ name: filename, content: d.content || '' })
|
setPreviewFile({ name: filename, content: d.content || '' })
|
||||||
|
} catch {
|
||||||
|
setPreviewFile({ name: filename, content: '加载失败' })
|
||||||
|
}
|
||||||
}, [activeRoomId])
|
}, [activeRoomId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -363,38 +343,16 @@ export function RightSidebar() {
|
|||||||
|
|
||||||
{files.length > 0 ? (
|
{files.length > 0 ? (
|
||||||
<div className="flex-1 overflow-y-auto scrollbar-thin">
|
<div className="flex-1 overflow-y-auto scrollbar-thin">
|
||||||
{files.map(f => {
|
{files.map(f => (
|
||||||
const isUpdating = recentlyUpdated.has(f)
|
|
||||||
// 检查 room 是否正在写入此文件(通过 action 字段匹配)
|
|
||||||
const isWriting = room?.status === 'working' && room?.action?.includes(f.replace('.md', ''))
|
|
||||||
return (
|
|
||||||
<button
|
<button
|
||||||
key={f}
|
key={f}
|
||||||
onClick={() => openPreview(f)}
|
onClick={() => openPreview(f)}
|
||||||
className={`w-full flex items-center gap-2 px-4 py-2 text-sm transition-all text-left hover:bg-[var(--bg-hover)] ${
|
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-left hover:bg-[var(--bg-hover)] transition-colors"
|
||||||
isUpdating ? 'bg-green-500/10' : isWriting ? 'bg-[var(--accent)]/5' : ''
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{isWriting ? (
|
<FileText className="w-3.5 h-3.5 flex-shrink-0 text-[var(--accent)]" />
|
||||||
<PenLine className="w-3.5 h-3.5 flex-shrink-0 text-[var(--accent)] animate-pulse" />
|
<span className="truncate text-xs text-[var(--text-secondary)]">{f}</span>
|
||||||
) : (
|
|
||||||
<FileText className={`w-3.5 h-3.5 flex-shrink-0 ${isUpdating ? 'text-green-500' : 'text-[var(--accent)]'}`} />
|
|
||||||
)}
|
|
||||||
<span className={`truncate text-xs ${isWriting ? 'text-[var(--accent)]' : 'text-[var(--text-secondary)]'}`}>{f}</span>
|
|
||||||
{isWriting && (
|
|
||||||
<span className="ml-auto text-[9px] px-1.5 py-0.5 rounded bg-[var(--accent)]/15 text-[var(--accent)] font-medium flex-shrink-0 flex items-center gap-1">
|
|
||||||
<Loader2 className="w-2.5 h-2.5 animate-spin" />
|
|
||||||
编写中
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{isUpdating && !isWriting && (
|
|
||||||
<span className="ml-auto text-[9px] px-1.5 py-0.5 rounded bg-green-500/15 text-green-600 dark:text-green-400 font-medium flex-shrink-0">
|
|
||||||
已更新
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
)
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex items-center justify-center text-[var(--text-muted)]">
|
<div className="flex-1 flex items-center justify-center text-[var(--text-muted)]">
|
||||||
@ -450,7 +408,7 @@ export function RightSidebar() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto flex">
|
<div className="flex-1 overflow-y-auto flex min-h-0">
|
||||||
{showVersions && versions.length > 0 && (
|
{showVersions && versions.length > 0 && (
|
||||||
<div className="w-48 border-r border-[var(--border)] overflow-y-auto flex-shrink-0">
|
<div className="w-48 border-r border-[var(--border)] overflow-y-auto flex-shrink-0">
|
||||||
<div className="p-2 space-y-1">
|
<div className="p-2 space-y-1">
|
||||||
@ -485,11 +443,17 @@ export function RightSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 mx-auto px-[20mm] py-[15mm] max-w-none" style={{ minHeight: '100%' }}>
|
<div className="flex-1 mx-auto px-[20mm] py-[15mm] max-w-none" style={{ minHeight: '100%' }}>
|
||||||
|
{previewFile.loading ? (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="w-6 h-6 text-[var(--accent)] animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="prose prose-sm dark:prose-invert max-w-none prose-headings:font-bold prose-h1:text-2xl prose-h1:border-b prose-h1:pb-2 prose-h1:mb-6 prose-h2:text-lg prose-h2:mt-6 prose-table:text-sm">
|
<div className="prose prose-sm dark:prose-invert max-w-none prose-headings:font-bold prose-h1:text-2xl prose-h1:border-b prose-h1:pb-2 prose-h1:mb-6 prose-h2:text-lg prose-h2:mt-6 prose-table:text-sm">
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
||||||
{versionContent !== null ? versionContent : previewFile.content}
|
{versionContent !== null ? versionContent : previewFile.content}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -240,7 +240,7 @@ export const useStore = create<AppState>((set, get) => {
|
|||||||
msgs.push({ id: Date.now().toString(), agent: ev.agent, role: ev.role, content: ev.content, streaming: false })
|
msgs.push({ id: Date.now().toString(), agent: ev.agent, role: ev.role, content: ev.content, streaming: false })
|
||||||
}
|
}
|
||||||
} else if (ev.content) {
|
} else if (ev.content) {
|
||||||
msgs.push({ id: Date.now().toString(), agent: ev.agent, role: ev.role, content: ev.content, streaming: ev.streaming })
|
msgs.push({ id: Date.now().toString(), agent: ev.agent, role: ev.role, content: ev.content, streaming: ev.streaming, created_at: new Date().toISOString() })
|
||||||
}
|
}
|
||||||
return { messages: { ...s.messages, [roomId]: msgs } }
|
return { messages: { ...s.messages, [roomId]: msgs } }
|
||||||
})
|
})
|
||||||
@ -299,8 +299,8 @@ export const useStore = create<AppState>((set, get) => {
|
|||||||
content: ev.task,
|
content: ev.task,
|
||||||
title: ev.to,
|
title: ev.to,
|
||||||
})
|
})
|
||||||
// 清除已完成的旧任务,保留仍在执行的
|
// 去重:同一 agent 只保留最新任务,清除已完成的
|
||||||
const existing = (s.todoItems[roomId] || []).filter(t => t.status !== 'done')
|
const existing = (s.todoItems[roomId] || []).filter(t => t.status !== 'done' && t.agent !== ev.to)
|
||||||
existing.push({
|
existing.push({
|
||||||
id: `${Date.now()}-${ev.to}`,
|
id: `${Date.now()}-${ev.to}`,
|
||||||
agent: ev.to,
|
agent: ev.to,
|
||||||
@ -397,7 +397,7 @@ export const useStore = create<AppState>((set, get) => {
|
|||||||
sendMessage: (roomId, content) => {
|
sendMessage: (roomId, content) => {
|
||||||
const { ws, user } = get()
|
const { ws, user } = get()
|
||||||
const userName = user?.name || '用户'
|
const userName = user?.name || '用户'
|
||||||
const userMsg = { id: Date.now().toString(), agent: userName, role: 'user' as const, content }
|
const userMsg = { id: Date.now().toString(), agent: userName, role: 'user' as const, content, created_at: new Date().toISOString() }
|
||||||
set(s => ({ messages: { ...s.messages, [roomId]: [...(s.messages[roomId] || []), userMsg] } }))
|
set(s => ({ messages: { ...s.messages, [roomId]: [...(s.messages[roomId] || []), userMsg] } }))
|
||||||
ws[roomId]?.send(JSON.stringify({ type: 'user_message', content, user_name: userName }))
|
ws[roomId]?.send(JSON.stringify({ type: 'user_message', content, user_name: userName }))
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user