scorpio e6e8bd8ce1 feat: phase强制校验、重复分配防护、章节file call、成员对话模式
- 系统级phase强制校验:阻止跨阶段分配任务,前置阶段未完成时自动拦截
- 循环内去重:同phase同成员不会被重复执行,消除master重复分配问题
- 消除重复提示:feedbackMsg和continueMsg不再同时注入
- 动态章节file call:所有静态文件完成后自动触发masterChapterFileCall
- 章节内容安全网拦截:master在聊天中输出文档内容时自动保存到workspace
- 用户@成员对话:区分对话和任务分配,短消息/问候走对话路由而非任务流水线
- handleMemberConversation改进:初始化系统提示、流式输出、DB存储
- 策划编辑AGENT.md:新增前置依赖检查、评分必须引用文档数据来源
- TodoList更新提醒:任务完成后提醒master用编辑指令更新TodoList
- buildWorkflowStep强化:显示阶段依赖关系和后续阶段解锁提示

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:16:08 +08:00

197 lines
4.9 KiB
Go
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/sdaduanbilei/agent-team/internal/agent"
"gopkg.in/yaml.v3"
)
// extractTitle 从文档内容中提取标题
func extractTitle(content string) string {
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "# ") {
return strings.TrimPrefix(line, "# ")
}
}
if idx := strings.Index(content, "《"); idx != -1 {
if end := strings.Index(content[idx:], "》"); end != -1 {
return content[idx+len("《") : idx+end]
}
}
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "## ") {
return strings.TrimPrefix(line, "## ")
}
}
return ""
}
// titleToFilename 从文档标题生成文件名,如 "主角小传:林远" → "主角小传-林远.md"
func titleToFilename(title, agentName string) string {
if title == "" {
return fmt.Sprintf("%s-%s.md", agentName, time.Now().Format("20060102-150405"))
}
name := strings.NewReplacer(
"", "-", ":", "-",
"/", "-", "\\", "-",
" ", "-", " ", "-",
"*", "", "?", "", "\"", "", "<", "", ">", "", "|", "",
).Replace(title)
for strings.Contains(name, "--") {
name = strings.ReplaceAll(name, "--", "-")
}
name = strings.Trim(name, "-")
if name == "" {
return fmt.Sprintf("%s-%s.md", agentName, time.Now().Format("20060102-150405"))
}
return name + ".md"
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// parseAssignments 解析任务分配指令。
// 支持多行任务描述:从 @成员名 开始,到下一个 @成员名 或文本结束为止。
func parseAssignments(text string) map[string]string {
result := make(map[string]string)
lines := strings.Split(text, "\n")
var currentName string
var currentTask strings.Builder
flush := func() {
if currentName != "" {
task := strings.TrimSpace(currentTask.String())
if task != "" {
result[currentName] = task
}
}
currentName = ""
currentTask.Reset()
}
for _, line := range lines {
trimmed := strings.TrimSpace(line)
atIdx := strings.Index(trimmed, "@")
if atIdx >= 0 {
rest := trimmed[atIdx+1:]
if idx := strings.IndexAny(rest, " \t"); idx > 0 {
name := strings.TrimSpace(rest[:idx])
task := strings.TrimSpace(rest[idx+1:])
flush()
currentName = name
if task != "" {
currentTask.WriteString(task)
}
continue
}
name := strings.TrimSpace(rest)
if name != "" {
flush()
currentName = name
continue
}
}
if strings.HasPrefix(trimmed, "ASSIGN:") {
parts := strings.SplitN(strings.TrimPrefix(trimmed, "ASSIGN:"), ":", 2)
if len(parts) == 2 {
flush()
result[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
continue
}
if currentName != "" && trimmed != "" {
if currentTask.Len() > 0 {
currentTask.WriteString("\n")
}
currentTask.WriteString(trimmed)
}
}
flush()
return result
}
// parseUserMentions 从用户消息中提取 @agent 指派。
func parseUserMentions(text string, validMembers map[string]*agent.Agent) map[string]string {
assignments := make(map[string]string)
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "@") {
continue
}
rest := strings.TrimPrefix(line, "@")
idx := strings.IndexAny(rest, " \t")
if idx <= 0 {
continue
}
name := strings.TrimSpace(rest[:idx])
task := strings.TrimSpace(rest[idx+1:])
if _, ok := validMembers[name]; ok && task != "" {
assignments[name] = task
}
}
return assignments
}
// isConversational 判断用户 @成员 后的文本是对话还是任务分配
func isConversational(text string) bool {
text = strings.TrimSpace(text)
runeLen := len([]rune(text))
// 非常短的消息视为对话
if runeLen < 10 {
return true
}
// 问候/简短回复
lower := strings.ToLower(text)
greetings := []string{"你好", "嗨", "hi", "hello", "hey", "谢谢", "感谢", "好的", "ok", "好", "嗯", "对", "是的", "不是", "怎么样", "什么"}
for _, g := range greetings {
if strings.HasPrefix(lower, g) {
return true
}
}
// 以问号结尾的短消息视为提问(对话)
if runeLen < 30 && (strings.HasSuffix(text, "?") || strings.HasSuffix(text, "")) {
return true
}
return false
}
func parseRoomConfig(data []byte) (Config, error) {
var cfg Config
if !bytes.HasPrefix(data, []byte("---")) {
return cfg, fmt.Errorf("missing frontmatter")
}
parts := bytes.SplitN(data, []byte("---"), 3)
if len(parts) < 3 {
return cfg, fmt.Errorf("invalid frontmatter")
}
return cfg, yaml.Unmarshal(parts[1], &cfg)
}
// resolveAgentPath finds agent dir: prefers agentsDir/team/name, falls back to agentsDir/name
func resolveAgentPath(agentsDir, team, name string) string {
if team != "" {
p := filepath.Join(agentsDir, team, name)
if _, err := os.Stat(p); err == nil {
return p
}
}
return filepath.Join(agentsDir, name)
}