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 "\n以下是团队已产出的文档,可供参考和评审:" + 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
}
}
}
// 降级:原有 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)<<>>\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
}