agent-team/internal/room/workspace.go
scorpio e6e8bd8ce1 feat: phase强制校验、重复分配防护、章节file call、成员对话模式
- 系统级phase强制校验:阻止跨阶段分配任务,前置阶段未完成时自动拦截
- 循环内去重:同phase同成员不会被重复执行,消除master重复分配问题
- 消除重复提示:feedbackMsg和continueMsg不再同时注入
- 动态章节file call:所有静态文件完成后自动触发masterChapterFileCall
- 章节内容安全网拦截:master在聊天中输出文档内容时自动保存到workspace
- 用户@成员对话:区分对话和任务分配,短消息/问候走对话路由而非任务流水线
- handleMemberConversation改进:初始化系统提示、流式输出、DB存储
- 策划编辑AGENT.md:新增前置依赖检查、评分必须引用文档数据来源
- TodoList更新提醒:任务完成后提醒master用编辑指令更新TodoList
- buildWorkflowStep强化:显示阶段依赖关系和后续阶段解锁提示

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:16:08 +08:00

263 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package room
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
// saveWorkspace 保存文件到 workspace 目录,自动版本追踪
func (r *Room) saveWorkspace(filename, content string) {
dir := filepath.Join(r.Dir, "workspace")
os.MkdirAll(dir, 0755)
fpath := filepath.Join(dir, filename)
// 版本追踪:保存旧版本到数据库
if r.Store != nil {
if old, err := os.ReadFile(fpath); err == nil && len(old) > 0 {
r.Store.InsertFileVersion(r.Config.Name, filename, string(old), "")
}
}
os.WriteFile(fpath, []byte(content), 0644)
r.emit(Event{Type: EvtWorkspaceFile, Filename: filename, Action: "updated"})
}
func (r *Room) listWorkspaceFiles() []string {
dir := filepath.Join(r.Dir, "workspace")
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var files []string
for _, e := range entries {
if !e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
files = append(files, e.Name())
}
}
return files
}
// buildWorkspaceContext 读取 workspace 目录下所有文件内容,作为上下文
func (r *Room) buildWorkspaceContext() string {
wsDir := filepath.Join(r.Dir, "workspace")
entries, err := os.ReadDir(wsDir)
if err != nil {
return ""
}
const perFileMax = 4000 // 单文件最大字符数
const totalMax = 20000 // workspace 上下文总最大字符数
var sb strings.Builder
totalChars := 0
for _, e := range entries {
if e.IsDir() || strings.HasPrefix(e.Name(), ".") {
continue
}
content, err := os.ReadFile(filepath.Join(wsDir, e.Name()))
if err != nil || len(content) == 0 {
continue
}
text := string(content)
runes := []rune(text)
if len(runes) > perFileMax {
runes = runes[:perFileMax]
text = string(runes) + "\n...(截断)"
}
if totalChars+len(runes) > totalMax {
sb.WriteString(fmt.Sprintf("\n--- 📎 %s --- (已省略,超出上下文限制)\n", e.Name()))
continue
}
sb.WriteString(fmt.Sprintf("\n--- 📎 %s ---\n%s\n", e.Name(), text))
totalChars += len(runes)
}
if sb.Len() == 0 {
return ""
}
return "<workspace_files>\n以下是团队已产出的文档可供参考和评审" + sb.String() + "</workspace_files>"
}
// hasTodoList 检查 workspace 中是否存在 TodoList.md
func (r *Room) hasTodoList() bool {
fpath := filepath.Join(r.Dir, "workspace", "TodoList.md")
_, err := os.Stat(fpath)
return err == nil
}
// saveAgentOutput 统一处理成员产出的文件保存路由(旧路径兼容,无模板时使用)。
// 返回 (filename, routed)routed=true 表示走了文件路由。
func (r *Room) saveAgentOutput(name, finalReply, task string) (string, bool) {
if r.projectTemplate != nil {
ownerFiles := r.findOwnerFiles(name)
if len(ownerFiles) >= 1 {
var targetFile *ProjectFile
if len(ownerFiles) == 1 {
targetFile = &ownerFiles[0]
} else {
title := extractTitle(finalReply)
if title != "" {
if tf := r.matchTemplateFile(title); tf != nil && tf.Owner == name {
targetFile = tf
}
}
if targetFile == nil {
firstLine := strings.TrimSpace(strings.SplitN(finalReply, "\n", 2)[0])
firstLine = strings.TrimLeft(firstLine, "# ")
if firstLine != "" {
for i := range ownerFiles {
base := strings.TrimSuffix(ownerFiles[i].Path, ".md")
if strings.Contains(firstLine, base) || strings.Contains(base, firstLine) {
targetFile = &ownerFiles[i]
break
}
}
}
}
if targetFile == nil {
for i := range ownerFiles {
fpath := filepath.Join(r.Dir, "workspace", ownerFiles[i].Path)
if _, err := os.Stat(fpath); os.IsNotExist(err) {
targetFile = &ownerFiles[i]
break
}
}
}
if targetFile == nil && len([]rune(finalReply)) > 200 {
targetFile = &ownerFiles[len(ownerFiles)-1]
}
}
if targetFile != nil {
fileContent, _ := splitContentAndStatus(finalReply, targetFile.Path)
if !strings.HasPrefix(strings.TrimSpace(fileContent), "# ") {
docTitle := strings.TrimSuffix(targetFile.Path, ".md")
fileContent = "# " + docTitle + "\n\n" + fileContent
}
r.saveWorkspace(targetFile.Path, fileContent)
if r.memberArtifacts == nil {
r.memberArtifacts = make(map[string]string)
}
r.memberArtifacts[name] = targetFile.Path
r.emit(Event{Type: EvtTaskDone, Agent: name, Task: task})
return targetFile.Path, true
}
}
}
// 降级:原有 isDocument 逻辑
if isDocument(finalReply) {
title := extractTitle(finalReply)
if r.memberArtifacts == nil {
r.memberArtifacts = make(map[string]string)
}
var filename string
if existing, ok := r.memberArtifacts[name]; ok {
filename = existing
} else {
filename = titleToFilename(title, name)
r.memberArtifacts[name] = filename
}
r.saveWorkspace(filename, finalReply)
r.emit(Event{Type: EvtArtifact, Agent: name, Filename: filename, Title: title})
r.emit(Event{Type: EvtTaskDone, Agent: name, Task: task})
return filename, true
}
// 纯交流型输出
r.emit(Event{Type: EvtAgentMessage, Agent: name, Role: "member", Content: finalReply})
r.lastActiveMember = name
r.emit(Event{Type: EvtTaskDone, Agent: name, Task: task})
return "", false
}
// splitDocuments 从文本中拆分出独立文档段落(旧路径兼容)。
func splitDocuments(text string) []string {
parts := strings.Split("\n"+text, "\n# ")
if len(parts) <= 1 {
return nil
}
var docs []string
for _, part := range parts[1:] {
doc := "# " + part
doc = strings.TrimSpace(doc)
if len([]rune(doc)) > 200 {
docs = append(docs, doc)
}
}
return docs
}
// isDocument 判断内容是否为文档产出物
func isDocument(content string) bool {
runeLen := len([]rune(content))
hasH1 := strings.Contains(content, "\n# ") || strings.HasPrefix(content, "# ")
if hasH1 && runeLen > 300 {
return true
}
h2Count := strings.Count(content, "\n## ")
if h2Count >= 2 && runeLen > 200 {
return true
}
if strings.Contains(content, "《") && strings.Contains(content, "》") && runeLen > 300 {
if strings.Count(content, "\n") > 5 {
return true
}
}
return false
}
// stripDocuments 从文本中去除文档段落,只保留非文档部分。
func (r *Room) stripDocuments(text string) string {
docs := splitDocuments(text)
if len(docs) == 0 {
return text
}
result := text
for _, doc := range docs {
result = strings.Replace(result, doc, "", 1)
}
for strings.Contains(result, "\n\n\n") {
result = strings.ReplaceAll(result, "\n\n\n", "\n\n")
}
return strings.TrimSpace(result)
}
// splitContentAndStatus 将 agent 输出分为文件内容和状态消息。
func splitContentAndStatus(reply, filename string) (fileContent, statusMsg string) {
fileContent = reply
name := strings.TrimSuffix(filename, ".md")
statusMsg = fmt.Sprintf("已完成《%s》", name)
return
}
// applyDocumentEdit 解析并应用文档编辑指令
func (r *Room) applyDocumentEdit(content string) (filename string, applied bool) {
editRe := regexp.MustCompile(`(?s)<<<EDIT\s+(.+?)>>>\s*<<<FIND>>>\s*(.+?)\s*<<<REPLACE>>>\s*(.+?)\s*<<<END>>>`)
matches := editRe.FindAllStringSubmatch(content, -1)
if len(matches) == 0 {
return "", false
}
for _, m := range matches {
fname := strings.TrimSpace(m[1])
oldText := strings.TrimSpace(m[2])
newText := strings.TrimSpace(m[3])
fpath := filepath.Join(r.Dir, "workspace", fname)
data, err := os.ReadFile(fpath)
if err != nil {
continue
}
original := string(data)
if !strings.Contains(original, oldText) {
continue
}
updated := strings.Replace(original, oldText, newText, 1)
r.saveWorkspace(fname, updated)
filename = fname
applied = true
}
return
}