- 系统级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>
263 lines
7.5 KiB
Go
263 lines
7.5 KiB
Go
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
|
||
}
|
||
|
||
|