- 系统级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>
197 lines
4.9 KiB
Go
197 lines
4.9 KiB
Go
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)
|
||
}
|