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 r.Prompt.Render("workspace_header", map[string]string{ "FileList": 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 } } } // 动态章节:检查成员是否为动态文件负责人(如写手负责章节) if dynDir, dynOwner, _ := r.getDynamicFileInfo(); dynDir != "" && dynOwner == name && r.allStaticFilesDone() { if isDocument(finalReply) { chapterFilename := r.extractChapterFilename(finalReply, dynDir) fullPath := dynDir + "/" + chapterFilename content := strings.TrimSpace(finalReply) if !strings.HasPrefix(content, "# ") { content = "# " + strings.TrimSuffix(chapterFilename, ".md") + "\n\n" + content } os.MkdirAll(filepath.Join(r.Dir, "workspace", dynDir), 0755) r.saveWorkspace(fullPath, content) if r.memberArtifacts == nil { r.memberArtifacts = make(map[string]string) } r.memberArtifacts[name] = fullPath docName := strings.TrimSuffix(chapterFilename, ".md") r.emit(Event{Type: EvtArtifact, Agent: name, Filename: fullPath, Title: docName}) r.emit(Event{Type: EvtFileDone, Agent: name, Filename: fullPath, Title: docName}) r.emit(Event{Type: EvtTaskDone, Agent: name, Task: task}) return fullPath, 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 } // stripTrailingAssignments 去除文档末尾混入的 @分配 指令段落 // 用于 TodoList 等文档文件,防止 master 将工作流指令写入文件正文 func stripTrailingAssignments(content string) string { lines := strings.Split(content, "\n") cutAt := -1 for i, line := range lines { trimmed := strings.TrimSpace(line) // 分隔线 "---" 后面跟的是工作流指令,截断 if trimmed == "---" { // 检查后续是否有 @ 分配或非任务内容 rest := strings.Join(lines[i+1:], "\n") if strings.Contains(rest, "@") || strings.Contains(rest, "Phase") { cutAt = i break } } // 独立的 @成员名 开头的行(非任务列表项) if strings.HasPrefix(trimmed, "@") && !strings.HasPrefix(trimmed, "@主编") { // 检查是否在列表项中(任务列表有 - [ ] 前缀) cutAt = i break } } if cutAt >= 0 { return strings.TrimRight(strings.Join(lines[:cutAt], "\n"), "\n ") } return content } // 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 解析并应用文档编辑指令 // 支持两种格式: // 1. 全文替换:<<>>\n新内容\n<<>> // 2. 局部替换:<<>><<>>旧内容<<>>新内容<<>> func (r *Room) applyDocumentEdit(content string) (filename string, applied bool) { // 格式1: 全文替换(优先处理) replaceRe := regexp.MustCompile(`(?s)<<>>\s*\n(.*?)\n?<<>>`) replaceMatches := replaceRe.FindAllStringSubmatch(content, -1) for _, m := range replaceMatches { fname := strings.TrimSpace(m[1]) newContent := strings.TrimSpace(m[2]) if newContent == "" { continue } r.saveWorkspace(fname, newContent) filename = fname applied = true } if applied { return } // 格式2: 局部替换 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 }