diff --git a/agents/writing-team/主编/AGENT.md b/agents/writing-team/主编/AGENT.md index 2f97f6e..4000379 100644 --- a/agents/writing-team/主编/AGENT.md +++ b/agents/writing-team/主编/AGENT.md @@ -11,23 +11,22 @@ skills: 网文编写团队的核心角色和总指挥。你的工作方式是:接收用户想法 → 协调团队自主完成所有前期工作 → 只把最终成果呈现给用户。 -## 输出类型说明(最重要) +## 输出规则(最重要) -系统会自动区分你的两种输出类型: +**你只负责交流和协调,不要在回复中输出完整文档正文。** -### 交流型输出(显示在聊天列表) -- 与用户的对话、状态汇报、任务分配 -- **必须简短**:每条消息不超过 3-5 句话 -- 收到成员结果后不要评价、不要夸,直接推进下一步 +系统采用 Caller-Decided 架构: +- **你的回复** = 交流内容(规划、分配任务、汇报状态),显示在聊天列表 +- **文档产出** = 系统自动为你发起独立的「文档调用」,你不需要在回复中写 -### 文档型输出(自动保存到 workspace) -- 以 `# 标题` 开头的完整文档 -- 系统自动保存,不需要你说明 -- **只输出文档正文**,不附加"以下是..."等说明文字 -- 用户通过产物面板查看 +具体规则: +1. **必须简短**:每条回复不超过 3-5 句话 +2. **不要输出文档正文**:不要以 `# 标题` 开头输出大段内容 +3. **用 @成员名 分配任务**:系统会自动执行 +4. 收到成员结果后不要评价、不要夸,直接推进下一步 ### 增量修改(修改已有文档时使用) -当需要修改已有文档时,不要重写整个文档,使用编辑指令格式: +当需要修改已有文档时,使用编辑指令格式: ``` <<>> <<>> @@ -60,24 +59,21 @@ Plan 模式下你**只能和用户简短对话**,严禁以下行为: ## Build 模式工作流程 -### 第一步:产出《创作需求书》 -直接输出文档(以 # 创作需求书 开头),系统自动保存。然后一句话告知用户并立即分配任务。 +### 第一步:确认方向 +简短告知用户你的理解和计划(2-3 句话),然后系统自动为你发起《创作需求书》等文档调用。 -### 第二步:分配任务 +### 第二步:分配调研任务 @搜索员 进行市场调研 @合规审查员 评估题材方向的合规风险 ### 第三步:方案评审(收到调研和合规结果后) @策划编辑 评审故事方案的可行性并打分 -### 第四步:文档产出(收到评审后直接写) -直接产出 3 份文档(你自己写): -- `# 主角小传:[角色名]` -- `# 世界观与角色设定` -- `# 故事大纲` +### 第四步:文档产出 +系统自动为你发起文档调用,产出:主角小传、世界观与角色设定、故事大纲。 ### 第五步:文档评审 -@策划编辑 评审以上3份文档,按文档评审标准打分 +@策划编辑 评审以上文档,按文档评审标准打分 - ≥ 85分:通过 - < 85分:用增量编辑格式修改后重新提交 diff --git a/agents/writing-team/策划编辑/AGENT.md b/agents/writing-team/策划编辑/AGENT.md index 7f8111c..79e0a72 100644 --- a/agents/writing-team/策划编辑/AGENT.md +++ b/agents/writing-team/策划编辑/AGENT.md @@ -17,9 +17,37 @@ skills: [] 2. **不需要写交流消息**:系统会自动生成包含总分的简短交流消息 3. **不写开场白**:不说"我来为您评审"之类的废话,直接出报告 +## 前置依赖检查(最高优先级) + +**评审前必须确认依赖文档已存在于 workspace 中**,否则拒绝评审: + +### 故事方案评审(《故事方案评审.md》) +必须依赖以下 3 份文档,缺一不可: +- ✅ 《创作需求书》— 确认创作方向和核心设定 +- ✅ 《市场调研报告》— 确认市场分析和竞品数据 +- ✅ 《合规审查报告》— 确认合规风险评估 + +**如果 workspace 中缺少上述任何一份文档**,你必须: +1. 输出一句话说明缺少哪份文档:`缺少前置文档:《XXX》尚未完成,无法进行方案评审。请先完成前置工作。` +2. 不要凭空评审,不要编造数据,不要猜测内容 + +### 文档评审(《文档评审报告.md》) +必须依赖以下文档: +- ✅ 《主角小传》 +- ✅ 《世界观与角色设定》 +- ✅ 《故事大纲》 + +同样,缺少任何一份都不能评审。 + +## 评审原则 + +- **基于事实**:所有评分依据必须引用具体文档内容,不能凭感觉打分 +- **引用原文**:评审意见中必须引用所评审文档的具体段落或设定 +- **交叉验证**:市场评分必须参考《市场调研报告》的数据,合规评分必须参考《合规审查报告》的结论 + ## 核心职责 -1. **故事方案评审**:评估题材选择、核心创意、市场可行性 +1. **故事方案评审**:基于需求书+调研报告+合规报告,评估方案可行性 2. **角色设定评审**:审查主角小传、配角设定的合理性和吸引力 3. **大纲结构评审**:检查故事节奏、冲突设计、高潮安排 4. **打分与迭代**:每次评审给出量化评分,低于85分必须给出改进方案 @@ -28,13 +56,13 @@ skills: [] ### 故事方案评审 -| 维度 | 分值 | 评分要点 | -|------|------|----------| -| 创意新颖度 | 20 | 与竞品的差异化程度,是否有独特卖点 | -| 市场可行性 | 20 | 目标读者是否明确,题材热度是否合适 | -| 核心矛盾 | 20 | 主线冲突是否清晰有力,能否撑起长篇 | -| 金手指设定 | 20 | 是否有趣、有限制、有成长空间 | -| 开篇吸引力 | 20 | 前三章能否抓住读者,钩子是否有效 | +| 维度 | 分值 | 评分要点 | 数据来源 | +|------|------|----------|----------| +| 创意新颖度 | 20 | 与竞品的差异化程度,是否有独特卖点 | 引用《市场调研报告》竞品分析 | +| 市场可行性 | 20 | 目标读者是否明确,题材热度是否合适 | 引用《市场调研报告》市场数据 | +| 核心矛盾 | 20 | 主线冲突是否清晰有力,能否撑起长篇 | 引用《创作需求书》核心设定 | +| 金手指设定 | 20 | 是否有趣、有限制、有成长空间 | 引用《创作需求书》金手指设定 | +| 合规与风险 | 20 | 内容合规性、题材敏感度 | 引用《合规审查报告》结论 | ### 文档评审(小传/世界观/大纲) @@ -55,21 +83,31 @@ skills: [] ```markdown # 故事方案评审 +## 参考文档 + +- 《创作需求书》:[确认已读] +- 《市场调研报告》:[确认已读] +- 《合规审查报告》:[确认已读] + ## 评分 -| 维度 | 得分 | 扣分原因 | -|------|------|----------| -| ... | .../20 | ... | +| 维度 | 得分 | 依据 | +|------|------|------| +| 创意新颖度 | .../20 | 据调研报告竞品分析:[引用具体内容]... | +| 市场可行性 | .../20 | 据调研报告市场数据:[引用具体内容]... | +| 核心矛盾 | .../20 | 据需求书核心设定:[引用具体内容]... | +| 金手指设定 | .../20 | 据需求书:[引用具体内容]... | +| 合规与风险 | .../20 | 据合规报告结论:[引用具体内容]... | | **总分** | **xx/100** | | ## 评审结论 [通过(≥85分)/ 需修改(<85分)] -## 问题清单 +## 问题清单(必须引用原文) -1. [问题描述] → [改进建议] -2. [问题描述] → [改进建议] +1. [引用原文段落] → [具体问题] → [改进建议] +2. [引用原文段落] → [具体问题] → [改进建议] ## 亮点 diff --git a/internal/room/handle.go b/internal/room/handle.go new file mode 100644 index 0000000..47eab80 --- /dev/null +++ b/internal/room/handle.go @@ -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如果有任务完成,请用 <<>> 格式更新 TodoList,标记已完成的项目。" + } + 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 +} diff --git a/internal/room/members.go b/internal/room/members.go new file mode 100644 index 0000000..0035670 --- /dev/null +++ b/internal/room/members.go @@ -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你之前已经产出过文档。请在原文档基础上进行补充和修改,不要重新写一份全新的文档。保留原有内容中仍然有效的部分,合并新的调研结果。" + } + } + // Caller-Decided: 预先确定目标文件 + targetFile = r.findMemberTargetFile(name) + if targetFile != nil { + docName := strings.TrimSuffix(targetFile.Path, ".md") + taskMsg += fmt.Sprintf("\n\n\n你需要产出文件《%s》。工作完成后,最终回复只输出 Markdown 正文(以 # %s 开头),不要包含交流内容。\n", 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() +} diff --git a/internal/room/memory.go b/internal/room/memory.go new file mode 100644 index 0000000..741b598 --- /dev/null +++ b/internal/room/memory.go @@ -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}) +} diff --git a/internal/room/room.go b/internal/room/room.go deleted file mode 100644 index f671c44..0000000 --- a/internal/room/room.go +++ /dev/null @@ -1,2016 +0,0 @@ -package room - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "log" - "os" - "os/exec" - "path/filepath" - "regexp" - "strconv" - "strings" - "sync" - "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" - "github.com/sdaduanbilei/agent-team/internal/user" - "gopkg.in/yaml.v3" -) - -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 context.CancelFunc - 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"` -} - -// 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 { - // 提取 ```project-template ... ``` 代码块 - 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("\n") - for _, e := range b.entries { - fmt.Fprintf(&sb, " \n%s\n \n", e.Type, e.Author, e.Content) - } - sb.WriteString("") - return sb.String() -} - -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} - - // 读取全局系统规则(项目根目录的 SYSTEM.md) - // agentsDir 的父目录就是项目根目录 - 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 - } - } - - // 加载项目模板(如果有 TEAM.md) - if cfg.Team != "" { - teamMDPath := filepath.Join(agentsDir, cfg.Team, "TEAM.md") - if teamData, err := os.ReadFile(teamMDPath); err == nil { - // 跳过 frontmatter,提取 body - 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) - } - // 持久化非 streaming 消息到 DB - if r.Store != nil { - gid := &r.currentGroupID - switch e.Type { - case EvtAgentMessage: - // 跳过 streaming、空内容、临时状态消息、tool_use 状态 - 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) -} - -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 - - // 构建 tool 定义 - 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}) - - boardCtx := board.ToContext() - // 有 tools 时不注入 skill XML(避免 LLM 用 XML 格式而不是 function calling) - 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 - } - } - memberSystem := member.BuildSystemPrompt(extraCtx) - // 将 workspace 文件内容注入任务消息,让成员能看到已有文档 - taskMsg := t - if wsCtx := r.buildWorkspaceContext(); wsCtx != "" { - taskMsg = t + "\n\n" + wsCtx - } - // 如果该成员已有文档产出,提示在原文档基础上修改 - if r.memberArtifacts != nil { - if _, hasDoc := r.memberArtifacts[name]; hasDoc { - taskMsg += "\n\n你之前已经产出过文档。请在原文档基础上进行补充和修改,不要重新写一份全新的文档。保留原有内容中仍然有效的部分,合并新的调研结果。" - } - } - // 标记该成员已查阅所有 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 循环:最多 10 轮 - 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) - - // 没有 tool calls,正常结束 - if len(result.ToolCalls) == 0 { - finalReply = result.Content - break - } - - // 有 tool calls,执行并将结果反馈 - // 先把 assistant 的 tool_call 消息加入历史 - assistantMsg := llm.Message{ - Role: "assistant", - Content: result.Content, - ToolCalls: result.ToolCalls, - } - memberMsgs = append(memberMsgs, assistantMsg) - - // 发一条 tool_use 状态消息(带 streaming 标记,前端显示动画) - 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}) - - // tool calling 循环耗尽后仍无文本回复,强制无 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 = "[任务执行完成,但未产生文本回复]" - } - - 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)) - - // 文件保存路由:模板路由成功时,生成简短交流消息 - savedPath, routed := r.saveAgentOutput(name, finalReply, t) - if routed { - // 文档已保存 → 存 document part 到数据库 - 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, - }) - } - // 生成简短交流消息 → 存 text part - briefMsg := r.generateBriefMessage(ctx, member, name, finalReply, t) - if briefMsg != "" { - r.emit(Event{Type: EvtAgentMessage, Agent: name, Role: "member", Content: briefMsg}) - if r.Store != nil { - r.Store.InsertMessage(&store.Message{ - RoomID: r.Config.Name, Agent: name, Role: "member", - Content: briefMsg, PartType: "text", - GroupID: &r.currentGroupID, - }) - } - } - mu.Lock() - results[name] = briefMsg - mu.Unlock() - r.AppendHistory("member", name, finalReply) - if strings.TrimSpace(briefMsg) != "" { - board.Add(name, briefMsg, "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() - - // 检查成员结果中是否有 @其他成员 的委派请求,自动分派第二轮 - 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 { - log.Printf("[room %s] 检测到成员间委派,执行第二轮: %v", r.Config.Name, secondRound) - var wg2 sync.WaitGroup - for memberName, task := range secondRound { - wg2.Add(1) - go func(name, t string) { - defer wg2.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}) - - 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 - } - } - memberSystem := member.BuildSystemPrompt(extraCtx) - taskMsg := t - if wsCtx := r.buildWorkspaceContext(); wsCtx != "" { - taskMsg = t + "\n\n" + wsCtx - } - // 如果该成员已有文档产出,提示在原文档基础上修改 - if r.memberArtifacts != nil { - if _, hasDoc := r.memberArtifacts[name]; hasDoc { - taskMsg += "\n\n你之前已经产出过文档。请在原文档基础上进行补充和修改,不要重新写一份全新的文档。保留原有内容中仍然有效的部分,合并新的调研结果。" - } - } - // 标记该成员已查阅所有 workspace 文件,更新状态 - wsFiles2 := r.listWorkspaceFiles() - for i, f := range wsFiles2 { - 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), - } - - 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) - r.emit(Event{Type: EvtAgentMessage, Agent: name, Role: "tool_use", Content: "", 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}) - } - r.emit(Event{Type: EvtAgentMessage, Agent: name, Role: "tool_use", Content: "", Streaming: false}) - - 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 = "[任务执行完成,但未产生文本回复]" - } - - // 文件保存路由:模板路由成功时,results/board 只存简短摘要 - 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) - } - wg2.Wait() - } - - return results -} - -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() - // 没有看板内容且没有 workspace 文件,跳过 challenge - 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 - // 注入 workspace 文件内容,让 challenger 能看到实际文档 - 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}) - result := reply.String() - // 持久化完整的 challenge 回复 - if r.Store != nil && result != "" { - gid := &r.currentGroupID - r.Store.InsertMessage(&store.Message{ - RoomID: r.Config.Name, Agent: n, Role: "challenge", - Content: result, GroupID: gid, - }) - } - if strings.Contains(result, "CHALLENGE:") { - board.Add(n, result, "challenge") - r.AppendHistory("challenge", n, result) - } - }(name) - } - wg.Wait() -} - -// Handle processes a user message through master orchestration. -func (r *Room) Handle(ctx context.Context, userMsg string) error { - return r.HandleUserMessage(ctx, "user", userMsg) -} - -// parseUserMentions 从用户消息中提取 @agent 指派。 -// 返回指派 map 和去除 @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 { - // 只有 @name 没有任务内容,跳过 - continue - } - name := strings.TrimSpace(rest[:idx]) - task := strings.TrimSpace(rest[idx+1:]) - if _, ok := validMembers[name]; ok && task != "" { - assignments[name] = task - } - } - return assignments -} - -// HandleUserMessage 处理用户消息。 -// 如果用户消息中包含 @agent,直接将任务分配给对应 agent,不经过 master。 -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) - - // 持久化 user 消息,记录 group_id - 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 { - return r.handleDirectAssign(ctx, userAssignments) - } - - // Build 模式下,如果有 plan 阶段暂存的待执行任务,直接执行 - 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 != "" { - memberName := r.lastActiveMember - member, ok := r.members[memberName] - if !ok { - return fmt.Errorf("member %s not found", memberName) - } - log.Printf("[room %s] build 模式,将用户回复转发给 %s", r.Config.Name, memberName) - r.setStatus(StatusWorking, member.Config.Name, "") - - // 追加用户回复到成员对话历史 - if r.memberConvos == nil { - r.memberConvos = make(map[string][]llm.Message) - } - r.memberConvos[memberName] = append(r.memberConvos[memberName], llm.NewMsg("user", userMsg)) - - // 追加沟通记录到任务计划文档 - r.appendPlanLog(userName, memberName, userMsg) - - // 让成员继续对话 - var memberReply strings.Builder - _, usage, err := member.ChatWithUsage(ctx, r.memberConvos[memberName], func(token string) { - memberReply.WriteString(token) - }) - if err != nil { - r.setStatus(StatusPending, "", "") - return err - } - r.emitUsage(memberName, usage) - result := memberReply.String() - r.memberConvos[memberName] = append(r.memberConvos[memberName], llm.NewMsg("assistant", result)) - r.AppendHistory("member", memberName, result) - - // 追加成员回复到任务计划文档 - r.appendPlanLog(memberName, userName, result) - - // 智能判断输出类型(含项目模板路由) - _, routed := r.saveAgentOutput(memberName, result, "") - if routed { - r.lastActiveMember = "" // 文档产出,对话结束 - } else if !isDocument(result) { - // lastActiveMember 保持不变,用户可以继续回复 - } else { - r.lastActiveMember = "" // 文档产出,对话结束 - } - - r.setStatus(StatusPending, "", "") - return nil - } - - r.setStatus(StatusThinking, "", "") - - // 构建 system prompt - teamXML := r.buildTeamXML() - skillXML := skill.ToXML(r.skillMeta) // 成员执行任务时使用 - // master 只需要知道 skill 名称和描述,不需要具体调用方式 - 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 - } - // 注入项目模板上下文到 master - 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() - // 始终更新 system prompt(可能 SOUL.md 改了) - if len(r.masterHistory) == 0 { - r.masterHistory = []llm.Message{sysMsg} - } else { - r.masterHistory[0] = sysMsg - } - r.masterHistory = append(r.masterHistory, llm.NewMsg("user", userMsg)) - - // 限制历史长度,保留 system + 最近 20 轮对话 - if len(r.masterHistory) > 41 { - r.masterHistory = append(r.masterHistory[:1], r.masterHistory[len(r.masterHistory)-40:]...) - } - - masterMsgs := make([]llm.Message, len(r.masterHistory)) - copy(masterMsgs, r.masterHistory) - r.historyMu.Unlock() - - // Master 规划循环 - for iteration := 0; iteration < 12; iteration++ { - log.Printf("[room %s] master iteration %d, sending to LLM...", r.Config.Name, iteration) - // Before the master ChatWithUsage call - if iteration > 0 { - r.setStatus(StatusWorking, r.master.Config.Name, "正在编写文档...") - } - var masterReply strings.Builder - docStreamCut := false // 文件驱动模式:检测到文档后停止向前端 streaming - _, usage, err := r.master.ChatWithUsage(ctx, masterMsgs, func(token string) { - masterReply.WriteString(token) - // 文件驱动模式:检测到 # 标题 开头的文档后,停止向前端发送 - if r.projectTemplate != nil && !docStreamCut { - text := masterReply.String() - trimmed := strings.TrimSpace(text) - if strings.HasPrefix(trimmed, "# ") || strings.Contains(text, "\n# ") { - docStreamCut = true - // 提取文档标题,更新工作状态 - title := extractTitle(text) - if title != "" { - r.setStatus(StatusWorking, r.master.Config.Name, fmt.Sprintf("正在编写《%s》...", title)) - } else { - r.setStatus(StatusWorking, r.master.Config.Name, "正在编写文档...") - } - return - } - } - if !docStreamCut { - 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 err - } - 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 - var savedDocFilenames []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")) - savedDocFilenames = append(savedDocFilenames, editFile) - } else if docs := splitDocuments(reply); len(docs) > 0 { - for _, doc := range docs { - title := extractTitle(doc) - var filename string - if r.projectTemplate != nil { - if tf := r.matchTemplateFile(title); tf != nil { - filename = tf.Path - } - } - if filename == "" { - 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) - savedDocFilenames = append(savedDocFilenames, filename) - } - // 计算持久化内容(去掉文档部分) - if r.projectTemplate != nil { - stripped := r.stripDocuments(reply) - if stripped == "" { - var summary strings.Builder - for _, t := range savedDocTitles { - summary.WriteString(fmt.Sprintf("已完成《%s》\n", t)) - } - stripped = strings.TrimSpace(summary.String()) - } - persistContent = stripped - } - } - - // 发送 streaming 结束信号 - if docStreamCut { - // 文件驱动模式:streaming 被截断,用 replace 替换前端已累积的内容 - r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master", Content: persistContent, Streaming: false, Action: "replace"}) - } else if len(savedDocTitles) > 0 { - // 有文档但未被截断(非文件驱动模式),replace 替换 - 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}) - } - - // Part 模型存储:文档和交流分开存 - if r.Store != nil { - gid := &r.currentGroupID - // 文档部分 → document part - for i, title := range savedDocTitles { - filename := "" - if i < len(savedDocFilenames) { - filename = savedDocFilenames[i] - } - r.Store.InsertMessage(&store.Message{ - RoomID: r.Config.Name, Agent: r.master.Config.Name, Role: "master", - Content: title, Filename: filename, PartType: "document", - GroupID: gid, - }) - } - // 交流部分 → text part - if persistContent != "" { - r.Store.InsertMessage(&store.Message{ - RoomID: r.Config.Name, Agent: r.master.Config.Name, Role: "master", - Content: persistContent, PartType: "text", - GroupID: gid, - }) - } - } - 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 r.Mode == "build" && r.projectTemplate != nil { - // 检查是否还有待产出的非动态文件 - hasPending := 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) { - hasPending = true - break - } - } - - // 判断是否需要自动 continue: - // - 有待产出文件 → 一定 continue(工作流没做完) - // - 本轮有文档产出 → continue(刚产出文档,推进下一步) - // - 都没有 → break(master 自己选择了停下来等用户,尊重它的决定) - if hasPending || len(savedDocTitles) > 0 { - stepCtx := r.buildWorkflowStep() - log.Printf("[room %s] 工作流未完成(pending=%v, docs=%d),提示 master 继续", r.Config.Name, hasPending, len(savedDocTitles)) - var continueMsg string - if stepCtx != "" { - continueMsg = stepCtx + "\n\n请继续推进。用 @成员名 分配任务,或自己产出文档(以 # 标题 开头)。不要重复已保存的文档内容。" - } else { - continueMsg = "文档已保存。请根据工作流程决定下一步行动。不要重复已保存的文档内容。" - } - continueLLMMsg := llm.NewMsg("user", continueMsg) - masterMsgs = append(masterMsgs, continueLLMMsg) - r.historyMu.Lock() - r.masterHistory = append(r.masterHistory, continueLLMMsg) - r.historyMu.Unlock() - continue - } - // 所有固定文件已完成,本轮也没新文档 → master 自己选择了停下(如等用户确认) - log.Printf("[room %s] 固定文件已完成,master 无新产出无分配,break 等用户", r.Config.Name) - } else if len(savedDocTitles) > 0 && r.Mode == "build" { - // 无 projectTemplate 但有文档产出,原逻辑 - log.Printf("[room %s] master 产出了文档但未分配任务,提示继续", r.Config.Name) - var fileList strings.Builder - for _, t := range savedDocTitles { - fileList.WriteString(fmt.Sprintf("- %s\n", t)) - } - wsFiles := r.listWorkspaceFiles() - for _, f := range wsFiles { - fileList.WriteString(fmt.Sprintf("- %s\n", f)) - } - continueMsg := fmt.Sprintf("文档已保存到 workspace:\n%s\n请根据工作流程,用 @成员名 分配下一步任务。不要重复输出文档内容。", fileList.String()) - continueLLMMsg := llm.NewMsg("user", continueMsg) - masterMsgs = append(masterMsgs, continueLLMMsg) - r.historyMu.Lock() - r.masterHistory = append(r.masterHistory, continueLLMMsg) - r.historyMu.Unlock() - continue - } - // 没有分配给任何成员的任务,master 直接回复了用户 - break - } - - // Plan 模式下不执行任务,提示用户切换到 Build 模式 - if r.Mode != "build" { - log.Printf("[room %s] plan 模式,拦截 %d 个任务分配", r.Config.Name, len(assignments)) - r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master", - Content: "当前是 Plan 模式,无法执行任务。请切换到 Build 模式后发送消息开始执行。"}) - break - } - - // 并行执行成员任务 - board := &SharedBoard{} - results := r.runMembersParallel(ctx, assignments, board, skillXML) - - // 质疑轮 - r.runChallengeRound(ctx, board, skillXML) - - // 将结果反馈给 master 审查 - 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)) - } - boardCtx := board.ToContext() - feedbackMsg := "Team results:\n" + resultsStr.String() - if boardCtx != "" { - feedbackMsg += "\n\nTeam board:\n" + boardCtx - } - // 文件驱动模式:注入 workspace 文件内容供 master 审阅 - if r.projectTemplate != nil { - if wsCtx := r.buildWorkspaceContext(); wsCtx != "" { - feedbackMsg += "\n\n" + wsCtx - } - } else { - // 附加当前产出物文件列表 - if wsFiles := r.listWorkspaceFiles(); len(wsFiles) > 0 { - feedbackMsg += "\n\n当前产出物文件:\n" - for _, f := range wsFiles { - feedbackMsg += "- 📎 " + f + "\n" - } - } - } - // 注入工作流步骤跟踪 - if r.projectTemplate != nil { - if stepCtx := r.buildWorkflowStep(); stepCtx != "" { - feedbackMsg = stepCtx + "\n\n" + feedbackMsg - } - } - feedbackMsg += "\n\n请审查以上成员结果,然后**立即行动**(二选一):\n1. 用 @成员名 分配下一步任务\n2. 自己直接输出文档(以 # 标题 开头,系统自动保存)\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) - } - - r.setStatus(StatusPending, "", "") - - // Auto-update master memory after task completion - go r.updateMasterMemory(context.Background(), userMsg, masterMsgs) - - return nil -} - -// handleDirectAssign 处理用户直接 @agent 指派的任务,跳过 master 规划。 -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 -} - -func (r *Room) updateMasterMemory(ctx context.Context, task string, msgs []llm.Message) { - // 过滤无意义任务:内容太短(如"你好"、"hi") - 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 - } - - // LLM 判断无值得记忆的内容 - 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) - - // 自动压缩:当月文件 > 20KB 时触发 - 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 - } - - // 限制 result 输入长度,节省 token - resultRunes := []rune(result) - if len(resultRunes) > 500 { - result = string(resultRunes[:500]) - } - - summaryPrompt := fmt.Sprintf(`基于这个任务和结果,总结核心经验(最多3条 bullet points)。 -如果没有值得记忆的经验,只输出 SKIP。 - -任务: %s -结果: %s`, 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) -} - -// 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) -} - -// splitDocuments 从文本中拆分出独立文档段落。 -// 以 \n# 分割,只保留长度超过 200 字符的段落作为文档。 -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 判断内容是否为文档产出物(而非对话/提问)。 -// 文档特征:包含 markdown 标题且内容较长。 -func isDocument(content string) bool { - runeLen := len([]rune(content)) - // 有 # 标题 且 超过 300 字 - 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 -} - -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 任务内容 - name := strings.TrimSpace(rest[:idx]) - task := strings.TrimSpace(rest[idx+1:]) - flush() - currentName = name - if task != "" { - currentTask.WriteString(task) - } - continue - } - // @name 单独出现,任务内容在后续行 - name := strings.TrimSpace(rest) - if name != "" { - flush() - currentName = name - continue - } - } - // ASSIGN:成员名:任务描述(向后兼容) - 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 -} - -// 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) - } - - // 设置 SKILLS_ROOT 环境变量(指向 skills/ 根目录) - skillPath := skill.SkillPathByToolName(r.skillMeta, tc.Function.Name) - skillsRoot := filepath.Dir(skillPath) // skill 的父目录即 skills/ - 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) - } - - // 限制输出长度,避免 token 爆炸 - if len(result) > 10000 { - result = result[:10000] + "\n... (输出已截断)" - } - - log.Printf("[tool] 结果 (%d 字符): %.200s", len(result), result) - return result -} - -// buildSkillSummary 为 master 构建简要的 skill 清单(只有名称和描述,不含调用方式) -func (r *Room) buildSkillSummary() string { - if len(r.skillMeta) == 0 { - return "" - } - var sb strings.Builder - sb.WriteString("\n") - sb.WriteString("以下工具可供团队成员使用,你可以在分配任务时提示成员使用对应的工具:\n") - for _, m := range r.skillMeta { - fmt.Fprintf(&sb, " - %s: %s\n", m.Name, m.Description) - } - sb.WriteString("") - return sb.String() -} - -// 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("\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("\n\n") - - // 找到当前最小 phase 的待办任务,给出具体指令 - if minPendingPhase < 999 { - sb.WriteString(fmt.Sprintf("\n当前阶段:phase %d\n", minPendingPhase)) - for _, f := range r.projectTemplate.Files { - if f.IsDir || f.Dynamic { - continue - } - if 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)) - } - } - sb.WriteString("\n") - sb.WriteString("请按上述指令推进。文档型输出只输出文档正文,系统自动保存。不要重复已完成的文档内容。") - } else { - // 所有固定文件已完成 - sb.WriteString("所有模板文件已完成。请根据你的 AGENT.md 工作流程决定下一步行动(如向用户交付、或进入下一阶段)。") - } - - return sb.String() -} - -// matchTemplateFile 按标题匹配模板文件。 -// "创作需求书" -> "创作需求书.md" -// "主角小传:林远" -> "主角小传.md"(前缀匹配) -func (r *Room) matchTemplateFile(title string) *ProjectFile { - if r.projectTemplate == nil || title == "" { - return nil - } - // 精确匹配(去掉 .md 后缀比较) - 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 - } - } - // 前缀匹配(如 "主角小传:林远" 匹配 "主角小传.md") - 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 -} - -// splitContentAndStatus 将 agent 输出分为文件内容和状态消息。 -// 整个输出作为文件内容,自动生成状态摘要。 -func splitContentAndStatus(reply, filename string) (fileContent, statusMsg string) { - fileContent = reply - name := strings.TrimSuffix(filename, ".md") - statusMsg = fmt.Sprintf("已完成《%s》", name) - return -} - -// buildProjectContext 构建项目模板上下文,注入到成员 system prompt -func (r *Room) buildProjectContext(agentName string) string { - if r.projectTemplate == nil { - return "" - } - var sb strings.Builder - sb.WriteString("\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("") - return sb.String() -} - -// saveAgentOutput 统一处理成员产出的文件保存路由。 -// 优先走项目模板路由,降级走原有 isDocument 逻辑。 -// 返回 (statusMsg, routed):routed=true 表示走了模板路由,statusMsg 是短摘要。 -func (r *Room) saveAgentOutput(name, finalReply, task string) (string, bool) { - if r.projectTemplate != nil { - // 按 owner 查找该成员负责的文件 - ownerFiles := r.findOwnerFiles(name) - if len(ownerFiles) >= 1 { - var targetFile *ProjectFile - if len(ownerFiles) == 1 { - // 只负责一个文件,直接路由 - targetFile = &ownerFiles[0] - } else { - // 负责多个文件,多级匹配策略 - title := extractTitle(finalReply) - - // 策略1:标题精确匹配模板文件 - if title != "" { - if tf := r.matchTemplateFile(title); tf != nil && tf.Owner == name { - targetFile = tf - } - } - - // 策略2:用回复开头文本模糊匹配文件名(LLM 可能不写 # 前缀) - 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 - } - } - } - } - - // 策略3:匹配尚未产出的文件(优先 phase 小的) - 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 - } - } - } - - // 策略4:内容够长(>200字),强制路由到最近的模板文件(覆盖更新) - if targetFile == nil && len([]rune(finalReply)) > 200 { - // 取最后一个文件(phase 大的)作为更新目标 - 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) - // 不再 emit EvtAgentMessage,交给调用方通过 generateBriefMessage 生成 - 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 逻辑,也走 Part 模型 - 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}) - // 返回 routed=true,让调用方通过 generateBriefMessage 生成简短消息 - 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 -} - -// generateBriefMessage 成员完成文档后,追加一次短 LLM 调用生成包含关键结论的交流消息 -func (r *Room) generateBriefMessage(ctx context.Context, ag *agent.Agent, name, docContent, task string) string { - title := extractTitle(docContent) - prompt := fmt.Sprintf( - "你刚完成了文档《%s》(任务:%s)。\n"+ - "请用1句话简短告知团队完成情况,包含关键结论。末尾写 @master 请查阅。\n"+ - "只输出这一句话,不要其他内容。", title, task) - msgs := []llm.Message{ - llm.NewMsg("system", "你是"+name+",用一句话简短汇报工作结果。"), - llm.NewMsg("user", prompt), - } - reply, _, err := ag.ChatWithUsage(ctx, msgs, nil) - if err != nil { - return fmt.Sprintf("《%s》已完成,@master 请查阅。", title) - } - return strings.TrimSpace(reply) -} - -// applyDocumentEdit 解析并应用文档编辑指令 -// 格式:<<>> -// -// <<>> -// 要替换的旧文本 -// <<>> -// 新文本 -// <<>> -func (r *Room) applyDocumentEdit(content string) (filename string, applied bool) { - editRe := regexp.MustCompile(`(?s)<<>>\s*<<>>\s*(.+?)\s*<<>>\s*(.+?)\s*<<>>`) - 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) // saveWorkspace 内部会自动保存旧版本 - filename = fname - applied = true - } - return -} - -func (r *Room) buildTeamXML() string { - var sb strings.Builder - sb.WriteString("\n") - for name, a := range r.members { - fmt.Fprintf(&sb, " \n %s\n %s\n \n", name, a.Config.Description) - } - sb.WriteString("") - return sb.String() -} - -// 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) 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) - // 通知前端 workspace 文件更新 - 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 "" - } - var sb strings.Builder - 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 - } - // 限制单个文件最大 8000 字符 - text := string(content) - if len([]rune(text)) > 8000 { - text = string([]rune(text)[:8000]) + "\n...(截断)" - } - sb.WriteString(fmt.Sprintf("\n--- 📎 %s ---\n%s\n", e.Name(), text)) - } - if sb.Len() == 0 { - return "" - } - return "\n以下是团队已产出的文档,可供参考和评审:" + sb.String() + "" -} - -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}) -} - -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) -} diff --git a/internal/room/types.go b/internal/room/types.go new file mode 100644 index 0000000..3c20f41 --- /dev/null +++ b/internal/room/types.go @@ -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("\n") + for _, e := range b.entries { + fmt.Fprintf(&sb, " \n%s\n \n", e.Type, e.Author, e.Content) + } + sb.WriteString("") + return sb.String() +} diff --git a/internal/room/utils.go b/internal/room/utils.go new file mode 100644 index 0000000..fec0d94 --- /dev/null +++ b/internal/room/utils.go @@ -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) +} diff --git a/internal/room/workflow.go b/internal/room/workflow.go new file mode 100644 index 0000000..e9a0aa3 --- /dev/null +++ b/internal/room/workflow.go @@ -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("\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("\n\n") + + if minPendingPhase < 999 { + sb.WriteString(fmt.Sprintf("\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("\n") + sb.WriteString(fmt.Sprintf("请只分配 phase %d 的任务。你负责的文件由系统自动发起独立调用,不要在回复中输出完整文档正文。", minPendingPhase)) + } else if dynDir, dynOwner, dynPhase := r.getDynamicFileInfo(); dynDir != "" { + // 所有静态文件已完成,进入动态章节写作阶段 + existingChapters := r.listChapterFiles(dynDir) + sb.WriteString(fmt.Sprintf("\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("\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("\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("") + 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("\n") + sb.WriteString("以下工具可供团队成员使用,你可以在分配任务时提示成员使用对应的工具:\n") + for _, m := range r.skillMeta { + fmt.Fprintf(&sb, " - %s: %s\n", m.Name, m.Description) + } + sb.WriteString("") + return sb.String() +} + +// buildTeamXML 构建团队成员 XML 上下文 +func (r *Room) buildTeamXML() string { + var sb strings.Builder + sb.WriteString("\n") + for name, a := range r.members { + fmt.Fprintf(&sb, " \n %s\n %s\n \n", name, a.Config.Description) + } + sb.WriteString("") + 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 +} diff --git a/internal/room/workspace.go b/internal/room/workspace.go new file mode 100644 index 0000000..9ac528e --- /dev/null +++ b/internal/room/workspace.go @@ -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 "\n以下是团队已产出的文档,可供参考和评审:" + sb.String() + "" +} + +// 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)<<>>\s*<<>>\s*(.+?)\s*<<>>\s*(.+?)\s*<<>>`) + 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 +} + + diff --git a/main b/main index 5167757..4a991a7 100755 Binary files a/main and b/main differ diff --git a/web/src/components/ChatView.tsx b/web/src/components/ChatView.tsx index 4d08178..e9be131 100644 --- a/web/src/components/ChatView.tsx +++ b/web/src/components/ChatView.tsx @@ -13,7 +13,6 @@ import { FileText, ExternalLink, X, - Loader2, } from 'lucide-react' import { useStore } from '../store' import type { Message, TodoItem } from '../types' @@ -24,6 +23,15 @@ function formatTokens(n: number): string { 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() { const { activeRoomId, rooms, messages, sendMessage, user, tokenUsage, todoItems } = useStore() const [input, setInput] = useState('') @@ -174,10 +182,7 @@ export function ChatView() { {room.status === 'thinking' && msgs[msgs.length - 1]?.role === 'user' && ( )} - {/* Working indicator - 当有 action 但没有 streaming 消息时显示 */} - {room.status === 'working' && room.action && !msgs.some(m => m.streaming) && ( - - )} + {/* Working indicator 已移至 AgentStatusBar,不在消息列表重复显示 */}
@@ -377,19 +382,17 @@ function renderGroupedMessages(msgs: Message[], _roomId: string, userName?: stri const elements: React.ReactNode[] = [] for (const msg of msgs) { - // task_assign、tool_use、artifact 不在消息列表展示 - if (msg.role === 'task_assign' || msg.role === 'tool_use' || msg.role === 'artifact') continue + // 只显示 user / master / member 角色的 text 消息 + // task_assign、tool_use、artifact、challenge 都不在消息列表展示 + if (msg.role === 'task_assign' || msg.role === 'tool_use' || msg.role === 'artifact' || msg.role === 'challenge') continue // document part 只在产物面板展示,不在聊天列表 if (msg.part_type === 'document') continue - // 对于旧数据(没有 part_type 或 part_type='text'),保持原有剥离逻辑作为兼容 let content = msg.content - if (!msg.part_type || msg.part_type === 'text') { - content = stripAssignmentLines(content) - // 兼容旧数据:如果没有 part_type 标记,仍用老逻辑剥离文档内容 - if (!msg.part_type && msg.role === 'master') { - content = stripDocumentContent(content) - } + content = stripAssignmentLines(content) + // 兼容旧数据:如果没有 part_type 标记,master 消息仍用老逻辑剥离文档内容 + if (!msg.part_type && msg.role === 'master') { + content = stripDocumentContent(content) } if (content.trim()) { @@ -699,6 +702,11 @@ function MessageBubble({ msg, userName }: { msg: Message; userName?: string }) { MASTER )} + {msg.created_at && ( + + {formatTime(msg.created_at)} + + )} {isToolUse ? (
@@ -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 ( -
-
- {(agent || 'A')[0]?.toUpperCase()} -
-
- {agent} -
- - {action} -
-
-
- ) -} function ThinkingBubble({ agent }: { agent: string }) { return ( diff --git a/web/src/components/RightSidebar.tsx b/web/src/components/RightSidebar.tsx index 307d9e4..b1373c4 100644 --- a/web/src/components/RightSidebar.tsx +++ b/web/src/components/RightSidebar.tsx @@ -1,8 +1,8 @@ -import { useState, useEffect, useCallback, useRef } from 'react' +import { useState, useEffect, useCallback } from 'react' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' 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 type { TodoItem, Message } from '../types' @@ -208,11 +208,9 @@ function TodoDetailPopup({ export function RightSidebar() { 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(null) const [copied, setCopied] = useState(false) - const [recentlyUpdated, setRecentlyUpdated] = useState>(new Set()) - const prevFilesRef = useRef([]) const [versions, setVersions] = useState<{version: number, agent: string, created_at: string}[]>([]) const [showVersions, setShowVersions] = useState(false) const [versionContent, setVersionContent] = useState(null) @@ -222,34 +220,16 @@ export function RightSidebar() { const todos = activeRoomId ? (todoItems[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) => { if (!activeRoomId) return - const d = await fetch(`${API}/rooms/${activeRoomId}/workspace/${filename}`).then(r => r.json()) - setPreviewFile({ name: filename, content: d.content || '' }) + // 先弹出 loading 状态,不阻塞 UI + setPreviewFile({ name: filename, content: '', loading: true }) + try { + const d = await fetch(`${API}/rooms/${activeRoomId}/workspace/${filename}`).then(r => r.json()) + setPreviewFile({ name: filename, content: d.content || '' }) + } catch { + setPreviewFile({ name: filename, content: '加载失败' }) + } }, [activeRoomId]) useEffect(() => { @@ -363,38 +343,16 @@ export function RightSidebar() { {files.length > 0 ? (
- {files.map(f => { - const isUpdating = recentlyUpdated.has(f) - // 检查 room 是否正在写入此文件(通过 action 字段匹配) - const isWriting = room?.status === 'working' && room?.action?.includes(f.replace('.md', '')) - return ( - - ) - })} + {files.map(f => ( + + ))}
) : (
@@ -450,7 +408,7 @@ export function RightSidebar() {
-
+
{showVersions && versions.length > 0 && (
@@ -485,11 +443,17 @@ export function RightSidebar() {
)}
-
- - {versionContent !== null ? versionContent : previewFile.content} - -
+ {previewFile.loading ? ( +
+ +
+ ) : ( +
+ + {versionContent !== null ? versionContent : previewFile.content} + +
+ )}
diff --git a/web/src/store.ts b/web/src/store.ts index 836f219..6661df2 100644 --- a/web/src/store.ts +++ b/web/src/store.ts @@ -240,7 +240,7 @@ export const useStore = create((set, get) => { msgs.push({ id: Date.now().toString(), agent: ev.agent, role: ev.role, content: ev.content, streaming: false }) } } 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 } } }) @@ -299,8 +299,8 @@ export const useStore = create((set, get) => { content: ev.task, title: ev.to, }) - // 清除已完成的旧任务,保留仍在执行的 - const existing = (s.todoItems[roomId] || []).filter(t => t.status !== 'done') + // 去重:同一 agent 只保留最新任务,清除已完成的 + const existing = (s.todoItems[roomId] || []).filter(t => t.status !== 'done' && t.agent !== ev.to) existing.push({ id: `${Date.now()}-${ev.to}`, agent: ev.to, @@ -397,7 +397,7 @@ export const useStore = create((set, get) => { sendMessage: (roomId, content) => { const { ws, user } = get() 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] } })) ws[roomId]?.send(JSON.stringify({ type: 'user_message', content, user_name: userName })) },