scorpio d6df056687 feat: Part 模型 + 文件版本追踪 + 写手团队工作流 v2
- 数据层:messages 表增加 part_type 字段,新建 file_versions 表支持版本追踪
- 后端:saveWorkspace 版本追踪、saveAgentOutput 源头分离、generateBriefMessage 成员简报
- 后端:applyDocumentEdit 增量编辑、buildWorkflowStep phase-aware 工作流引擎
- API:文件版本查询/回退接口
- 前端:part_type 驱动渲染,产物面板版本历史
- 新增写手团队(主编/搜索员/策划编辑/合规审查员)配置
- store 模块、scheduler 模块、web-search skill

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

283 lines
7.8 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 agent
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/sdaduanbilei/agent-team/internal/llm"
"gopkg.in/yaml.v3"
)
type Config struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Provider string `yaml:"provider"`
Model string `yaml:"model"`
BaseURL string `yaml:"base_url"`
APIKeyEnv string `yaml:"api_key_env"`
Skills []string `yaml:"skills"`
CanChallenge bool `yaml:"can_challenge"`
}
type Agent struct {
Config Config
AgentDoc string // AGENT.md 正文frontmatter 之后的内容)
Soul string // SOUL.md 人设
Dir string // agents/<name>/
KnowledgeDir string // agents/{team}/knowledge/ 目录路径
client *llm.Client
}
func Load(dir string) (*Agent, error) {
agentMD, err := os.ReadFile(filepath.Join(dir, "AGENT.md"))
if err != nil {
return nil, err
}
cfg, body, err := parseFrontmatterWithBody(agentMD)
if err != nil {
return nil, fmt.Errorf("parse AGENT.md: %w", err)
}
soul, _ := os.ReadFile(filepath.Join(dir, "SOUL.md"))
if cfg.Provider == "" {
cfg.Provider = "deepseek"
}
if cfg.APIKeyEnv == "" {
cfg.APIKeyEnv = "DEEPSEEK_API_KEY"
}
client, err := llm.New(cfg.Provider, cfg.Model, cfg.BaseURL, cfg.APIKeyEnv)
if err != nil {
return nil, err
}
return &Agent{Config: cfg, AgentDoc: body, Soul: string(soul), Dir: dir, client: client}, nil
}
func parseFrontmatterWithBody(data []byte) (Config, string, 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")
}
if err := yaml.Unmarshal(parts[1], &cfg); err != nil {
return cfg, "", err
}
body := strings.TrimSpace(string(parts[2]))
return cfg, body, nil
}
// memoryMaxChars 是注入 prompt 的记忆硬限制(字符数)
const memoryMaxChars = 4000
// Memory 智能加载记忆:优先 core.md然后当月文件硬限制 4000 字符。
func (a *Agent) Memory() string {
memDir := filepath.Join(a.Dir, "memory")
if _, err := os.Stat(memDir); err != nil {
return ""
}
var sb strings.Builder
// 1. 优先加载 core.md核心经验
if data, err := os.ReadFile(filepath.Join(memDir, "core.md")); err == nil {
sb.Write(data)
sb.WriteString("\n")
}
// 2. 加载当月文件
currentMonth := time.Now().Format("2006-01") + ".md"
if data, err := os.ReadFile(filepath.Join(memDir, currentMonth)); err == nil {
sb.Write(data)
sb.WriteString("\n")
}
// 3. 硬限制截断
result := sb.String()
runes := []rune(result)
if len(runes) > memoryMaxChars {
result = string(runes[:memoryMaxChars]) + "\n...(记忆已截断)"
}
return result
}
// SaveMemory appends/updates a memory file.
func (a *Agent) SaveMemory(filename, content string) error {
memDir := filepath.Join(a.Dir, "memory")
os.MkdirAll(memDir, 0755)
return os.WriteFile(filepath.Join(memDir, filename), []byte(content), 0644)
}
// Chat sends messages and streams response tokens via onToken callback.
func (a *Agent) Chat(ctx context.Context, msgs []llm.Message, onToken func(string)) (string, error) {
result, err := a.client.Stream(ctx, msgs, onToken)
if err != nil {
return "", err
}
return result.Content, nil
}
// ChatWithUsage 同 Chat 但额外返回 token 用量。
func (a *Agent) ChatWithUsage(ctx context.Context, msgs []llm.Message, onToken func(string)) (string, llm.Usage, error) {
result, err := a.client.Stream(ctx, msgs, onToken)
if err != nil {
return "", llm.Usage{}, err
}
return result.Content, result.Usage, nil
}
// ChatWithTools 支持 tool calling 的对话
func (a *Agent) ChatWithTools(ctx context.Context, msgs []llm.Message, tools []llm.Tool, onToken func(string)) (*llm.StreamResult, error) {
return a.client.StreamWithTools(ctx, msgs, tools, onToken)
}
// knowledgeMaxChars 是注入 prompt 的知识库硬限制(字符数)
const knowledgeMaxChars = 8000
// Knowledge 读取团队知识库目录下的所有 .md 文件,拼接返回,硬限制 8000 字符。
func (a *Agent) Knowledge() string {
if a.KnowledgeDir == "" {
return ""
}
entries, err := os.ReadDir(a.KnowledgeDir)
if err != nil {
return ""
}
var sb strings.Builder
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".md") {
data, _ := os.ReadFile(filepath.Join(a.KnowledgeDir, e.Name()))
if len(data) > 0 {
sb.WriteString("### " + e.Name() + "\n")
sb.Write(data)
sb.WriteString("\n\n")
}
}
}
result := sb.String()
runes := []rune(result)
if len(runes) > knowledgeMaxChars {
result = string(runes[:knowledgeMaxChars]) + "\n...(知识库已截断)"
}
return result
}
// CompressMemory 压缩非当月的记忆文件LLM 摘要后追加到 core.md原文件移到 archive/。
func (a *Agent) CompressMemory(ctx context.Context) error {
memDir := filepath.Join(a.Dir, "memory")
entries, err := os.ReadDir(memDir)
if err != nil {
return err
}
currentMonth := time.Now().Format("2006-01") + ".md"
var toCompress []string
for _, e := range entries {
name := e.Name()
// 只处理月份文件YYYY-MM.md跳过 core.md、当月文件、archive 目录
if !strings.HasSuffix(name, ".md") || name == "core.md" || name == currentMonth {
continue
}
// 检查是否是月份格式
if len(name) == 10 && name[4] == '-' {
toCompress = append(toCompress, name)
}
}
if len(toCompress) == 0 {
return nil
}
archiveDir := filepath.Join(memDir, "archive")
os.MkdirAll(archiveDir, 0755)
for _, filename := range toCompress {
data, err := os.ReadFile(filepath.Join(memDir, filename))
if err != nil || len(data) == 0 {
continue
}
// LLM 压缩
content := string(data)
if len([]rune(content)) > 2000 {
content = string([]rune(content)[:2000])
}
compressPrompt := fmt.Sprintf(`将以下记忆压缩为 3-5 条最核心的经验教训,每条一行 bullet point只保留最有价值的内容
%s`, content)
msgs := []llm.Message{
llm.NewMsg("system", "你是一个记忆压缩助手。将详细记录压缩为核心经验。"),
llm.NewMsg("user", compressPrompt),
}
summary, err := a.Chat(ctx, msgs, nil)
if err != nil || summary == "" {
continue
}
// 追加到 core.md
coreFile := filepath.Join(memDir, "core.md")
existing, _ := os.ReadFile(coreFile)
coreContent := string(existing)
if coreContent == "" {
coreContent = "# 核心经验\n"
}
coreContent += fmt.Sprintf("\n## %s 经验摘要\n\n%s\n", strings.TrimSuffix(filename, ".md"), summary)
os.WriteFile(coreFile, []byte(coreContent), 0644)
// 原文件移到 archive/
os.Rename(filepath.Join(memDir, filename), filepath.Join(archiveDir, filename))
}
return nil
}
// BuildSystemPrompt 构建完整的 system prompt。
// SOUL.md = 人设性格AGENT.md 正文 = 功能描述/工作流程,均实时读取支持热更新。
func (a *Agent) BuildSystemPrompt(extraContext string) string {
// 实时读取 SOUL.md
soul := a.Soul
if data, err := os.ReadFile(filepath.Join(a.Dir, "SOUL.md")); err == nil {
soul = string(data)
}
// 实时读取 AGENT.md 正文
agentDoc := a.AgentDoc
if data, err := os.ReadFile(filepath.Join(a.Dir, "AGENT.md")); err == nil {
if _, body, err := parseFrontmatterWithBody(data); err == nil && body != "" {
agentDoc = body
}
}
var sb strings.Builder
sb.WriteString(soul)
if agentDoc != "" {
sb.WriteString("\n\n")
sb.WriteString(agentDoc)
}
// 知识库注入(在 memory 之前)
if knowledge := a.Knowledge(); knowledge != "" {
sb.WriteString("\n\n<knowledge>\n")
sb.WriteString(knowledge)
sb.WriteString("</knowledge>")
}
if mem := a.Memory(); mem != "" {
sb.WriteString("\n\n<memory>\n")
sb.WriteString(mem)
sb.WriteString("</memory>")
}
if extraContext != "" {
sb.WriteString("\n\n")
sb.WriteString(extraContext)
}
return sb.String()
}