429 lines
13 KiB
Go
429 lines
13 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 r.Prompt.Render("workspace_header", map[string]string{
|
||
"FileList": sb.String(),
|
||
})
|
||
}
|
||
|
||
// syncTodoList 扫描 TodoList.md,自动将已产出文件的任务勾选为 [x]
|
||
// 同时修正路径:如果文件名带子目录前缀但实际文件在 workspace 根目录,自动去掉前缀
|
||
func (r *Room) syncTodoList() {
|
||
fpath := filepath.Join(r.Dir, "workspace", "TodoList.md")
|
||
data, err := os.ReadFile(fpath)
|
||
if err != nil {
|
||
return
|
||
}
|
||
|
||
fileRe := regexp.MustCompile(`《(.+?)》`)
|
||
lines := strings.Split(string(data), "\n")
|
||
changed := false
|
||
for i, line := range lines {
|
||
// 修正路径:所有行(不限 [ ] / [x])中带子目录前缀的文件名,如果实际文件在根目录则去掉前缀
|
||
for _, m := range fileRe.FindAllStringSubmatch(line, -1) {
|
||
filename := m[1]
|
||
baseName := filepath.Base(filename)
|
||
if baseName != filename {
|
||
fpSub := filepath.Join(r.Dir, "workspace", filename)
|
||
fpFlat := filepath.Join(r.Dir, "workspace", baseName)
|
||
// 子目录下不存在但根目录下存在 → 修正路径
|
||
if _, err := os.Stat(fpSub); os.IsNotExist(err) {
|
||
if _, err := os.Stat(fpFlat); err == nil {
|
||
lines[i] = strings.Replace(lines[i], "《"+filename+"》", "《"+baseName+"》", 1)
|
||
changed = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 勾选:检查未完成任务对应的文件是否已存在
|
||
trimmed := strings.TrimSpace(lines[i])
|
||
if !strings.HasPrefix(trimmed, "- [ ]") {
|
||
continue
|
||
}
|
||
m := fileRe.FindStringSubmatch(lines[i])
|
||
if m == nil {
|
||
continue
|
||
}
|
||
filename := m[1]
|
||
fp := filepath.Join(r.Dir, "workspace", filename)
|
||
if _, err := os.Stat(fp); err == nil {
|
||
lines[i] = strings.Replace(lines[i], "- [ ]", "- [x]", 1)
|
||
changed = true
|
||
}
|
||
}
|
||
if changed {
|
||
os.WriteFile(fpath, []byte(strings.Join(lines, "\n")), 0644)
|
||
r.emit(Event{Type: EvtWorkspaceFile, Filename: "TodoList.md", Action: "updated"})
|
||
}
|
||
}
|
||
|
||
// hasTodoList 检查 workspace 中是否存在 TodoList.md
|
||
func (r *Room) hasTodoList() bool {
|
||
fpath := filepath.Join(r.Dir, "workspace", "TodoList.md")
|
||
_, err := os.Stat(fpath)
|
||
return err == nil
|
||
}
|
||
|
||
// hasPendingTodo 检查 TodoList.md 中是否还有未完成的任务(`- [ ]`)
|
||
func (r *Room) hasPendingTodo() bool {
|
||
fpath := filepath.Join(r.Dir, "workspace", "TodoList.md")
|
||
data, err := os.ReadFile(fpath)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
return strings.Contains(string(data), "- [ ]")
|
||
}
|
||
|
||
// 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)
|
||
content := strings.TrimSpace(finalReply)
|
||
if !strings.HasPrefix(content, "# ") {
|
||
content = "# " + strings.TrimSuffix(chapterFilename, ".md") + "\n\n" + content
|
||
}
|
||
r.saveWorkspace(chapterFilename, content)
|
||
if r.memberArtifacts == nil {
|
||
r.memberArtifacts = make(map[string]string)
|
||
}
|
||
r.memberArtifacts[name] = chapterFilename
|
||
docName := strings.TrimSuffix(chapterFilename, ".md")
|
||
r.emit(Event{Type: EvtArtifact, Agent: name, Filename: chapterFilename, Title: docName})
|
||
r.emit(Event{Type: EvtFileDone, Agent: name, Filename: chapterFilename, Title: docName})
|
||
r.emit(Event{Type: EvtTaskDone, Agent: name, Task: task})
|
||
return chapterFilename, 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
|
||
}
|
||
|
||
// parseTargetFileFromTask 从任务描述中解析《文件名》,匹配 workspace 已有文件(支持修订已有文件)
|
||
func (r *Room) parseTargetFileFromTask(memberName, task string) *ProjectFile {
|
||
re := regexp.MustCompile(`《(.+?)》`)
|
||
matches := re.FindAllStringSubmatch(task, -1)
|
||
for _, m := range matches {
|
||
filename := m[1]
|
||
if !strings.HasSuffix(filename, ".md") {
|
||
filename += ".md"
|
||
}
|
||
fpath := filepath.Join(r.Dir, "workspace", filename)
|
||
if _, err := os.Stat(fpath); err == nil {
|
||
// 文件存在 → 优先匹配模板定义(保留正确的 Owner/Phase)
|
||
if tf := r.matchTemplateFile(strings.TrimSuffix(filename, ".md")); tf != nil {
|
||
// 只有文件负责人才能写入
|
||
if tf.Owner == memberName {
|
||
return tf
|
||
}
|
||
continue
|
||
}
|
||
// 模板中没有 → 构造虚拟 ProjectFile
|
||
return &ProjectFile{
|
||
Path: filename,
|
||
Owner: memberName,
|
||
Phase: 2,
|
||
Dynamic: true,
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// applyDocumentEdit 解析并应用文档编辑指令
|
||
// 支持两种格式:
|
||
// 1. 全文替换:<<<REPLACE filename>>>\n新内容\n<<<END>>>
|
||
// 2. 局部替换:<<<EDIT filename>>><<<FIND>>>旧内容<<<REPLACE>>>新内容<<<END>>>
|
||
func (r *Room) applyDocumentEdit(content string) (filename string, applied bool) {
|
||
// 格式1: 全文替换(优先处理)
|
||
replaceRe := regexp.MustCompile(`(?s)<<<REPLACE\s+(.+?)>>>\s*\n(.*?)\n?<<<END>>>`)
|
||
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)<<<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
|
||
}
|
||
|
||
|