agent-team/internal/room/workspace.go
sdaduanbilei 8cb4e041c8 fix
2026-03-09 17:38:43 +08:00

338 lines
10 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 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. 全文替换:<<<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
}