agent-team/internal/room/workspace.go

429 lines
13 KiB
Go
Raw Permalink 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(),
})
}
// 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
}