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

403 lines
9.9 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 hub
import (
"archive/zip"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"gopkg.in/yaml.v3"
)
type Team struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Author string `yaml:"author"`
RepoURL string `yaml:"repo_url"`
Agents []string `yaml:"agents"`
Skills []string `yaml:"skills"`
InstalledAt string `yaml:"installed_at"`
UpdatedAt string `yaml:"updated_at"`
}
// Install clones a git repo (any URL) and installs the team.
func Install(repoRef, agentsDir, skillsDir, teamsDir string) (*Team, error) {
url := repoRef
if !strings.HasPrefix(repoRef, "http") && !strings.HasPrefix(repoRef, "git@") {
url = "https://github.com/" + repoRef
}
// Extract repo name for team name
repoName := extractRepoName(url)
tmp, err := os.MkdirTemp("", "agent-team-hub-*")
if err != nil {
return nil, err
}
defer os.RemoveAll(tmp)
cmd := exec.Command("git", "clone", "--depth=1", url, tmp)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("git clone: %w", err)
}
return installFromDir(tmp, repoName, url, agentsDir, skillsDir, teamsDir)
}
// InstallZIP extracts a ZIP file and installs the team.
func InstallZIP(zipPath, agentsDir, skillsDir, teamsDir string) (*Team, error) {
// Extract team name from zip filename
teamName := strings.TrimSuffix(filepath.Base(zipPath), ".zip")
teamName = strings.TrimSuffix(teamName, "-main")
teamName = strings.TrimSuffix(teamName, "-master")
tmp, err := os.MkdirTemp("", "agent-team-hub-*")
if err != nil {
return nil, err
}
defer os.RemoveAll(tmp)
// Extract ZIP
if err := ExtractZIP(zipPath, tmp); err != nil {
return nil, fmt.Errorf("extract zip: %w", err)
}
// Find the root directory (might be wrapped in a subfolder)
entries, _ := os.ReadDir(tmp)
if len(entries) == 1 && entries[0].IsDir() {
tmp = filepath.Join(tmp, entries[0].Name())
}
return installFromDir(tmp, teamName, "", agentsDir, skillsDir, teamsDir)
}
func installFromDir(tmp, teamName, repoURL, agentsDir, skillsDir, teamsDir string) (*Team, error) {
// Copy agents
installedAgents := []string{}
srcAgentsDir := filepath.Join(tmp, "agents")
if _, err := os.Stat(srcAgentsDir); err == nil {
// Copy to team-specific directory
dstAgentsDir := filepath.Join(agentsDir, teamName)
if err := CopyDir(srcAgentsDir, dstAgentsDir); err == nil {
entries, _ := os.ReadDir(srcAgentsDir)
for _, e := range entries {
if e.IsDir() {
installedAgents = append(installedAgents, e.Name())
}
}
}
}
// Copy skills
installedSkills := []string{}
srcSkillsDir := filepath.Join(tmp, "skills")
if _, err := os.Stat(srcSkillsDir); err == nil {
dstSkillsDir := filepath.Join(skillsDir, teamName)
if err := CopyDir(srcSkillsDir, dstSkillsDir); err == nil {
entries, _ := os.ReadDir(srcSkillsDir)
for _, e := range entries {
if e.IsDir() {
installedSkills = append(installedSkills, e.Name())
}
}
}
}
// Create knowledge dir
os.MkdirAll(filepath.Join(agentsDir, teamName, "knowledge"), 0755)
// Read TEAM.md for metadata
teamMeta := readTeamMeta(tmp)
// Create team record
team := &Team{
Name: teamMeta.Name,
Description: teamMeta.Description,
Author: teamMeta.Author,
RepoURL: repoURL,
Agents: installedAgents,
Skills: installedSkills,
InstalledAt: time.Now().Format("2006-01-02"),
}
// Save team record
teamDir := filepath.Join(teamsDir, teamName)
os.MkdirAll(teamDir, 0755)
team.Save(filepath.Join(teamDir, "team.yaml"))
return team, nil
}
// Update 从 git 仓库拉取最新版本并更新团队文件,保留 memory/ 等用户数据。
func Update(teamName, agentsDir, skillsDir, teamsDir string) (*Team, error) {
// 读取现有 team.yaml 获取 repo_url
teamFile := filepath.Join(teamsDir, teamName, "team.yaml")
existingData, err := os.ReadFile(teamFile)
if err != nil {
return nil, fmt.Errorf("团队 %s 不存在", teamName)
}
var existing Team
// 简单解析(复用 server.go 中的解析逻辑风格)
lines := strings.Split(string(existingData), "\n")
for _, line := range lines {
if strings.HasPrefix(line, "repo_url: ") {
existing.RepoURL = strings.TrimSpace(strings.TrimPrefix(line, "repo_url: "))
}
if strings.HasPrefix(line, "installed_at: ") {
existing.InstalledAt = strings.TrimSpace(strings.TrimPrefix(line, "installed_at: "))
}
}
if existing.RepoURL == "" {
return nil, fmt.Errorf("团队 %s 没有 repo_url无法更新可能是 ZIP 安装的)", teamName)
}
// Clone 最新版本到临时目录
tmp, err := os.MkdirTemp("", "agent-team-update-*")
if err != nil {
return nil, err
}
defer os.RemoveAll(tmp)
cmd := exec.Command("git", "clone", "--depth=1", existing.RepoURL, tmp)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("git clone: %w", err)
}
// 更新 agents逐个 agent 目录,跳过 memory/
srcAgentsDir := filepath.Join(tmp, "agents")
dstAgentsDir := filepath.Join(agentsDir, teamName)
updatedAgents := []string{}
if _, err := os.Stat(srcAgentsDir); err == nil {
entries, _ := os.ReadDir(srcAgentsDir)
for _, e := range entries {
if !e.IsDir() {
continue
}
updatedAgents = append(updatedAgents, e.Name())
srcAgent := filepath.Join(srcAgentsDir, e.Name())
dstAgent := filepath.Join(dstAgentsDir, e.Name())
if err := updateAgentDir(srcAgent, dstAgent); err != nil {
return nil, fmt.Errorf("更新 agent %s 失败: %w", e.Name(), err)
}
}
}
// 更新 knowledge/(合并,不删除用户已有文件)
srcKnowledge := filepath.Join(tmp, "knowledge")
dstKnowledge := filepath.Join(agentsDir, teamName, "knowledge")
if _, err := os.Stat(srcKnowledge); err == nil {
CopyDir(srcKnowledge, dstKnowledge)
}
// 更新 skills
updatedSkills := []string{}
srcSkillsDir := filepath.Join(tmp, "skills")
if _, err := os.Stat(srcSkillsDir); err == nil {
entries, _ := os.ReadDir(srcSkillsDir)
for _, e := range entries {
if e.IsDir() {
updatedSkills = append(updatedSkills, e.Name())
}
}
CopyDir(srcSkillsDir, filepath.Join(skillsDir, teamName))
}
// 读取新的 TEAM.md 元数据
teamMeta := readTeamMeta(tmp)
// 更新 team.yaml保留 installed_at
team := &Team{
Name: teamMeta.Name,
Description: teamMeta.Description,
Author: teamMeta.Author,
RepoURL: existing.RepoURL,
Agents: updatedAgents,
Skills: updatedSkills,
InstalledAt: existing.InstalledAt,
UpdatedAt: time.Now().Format("2006-01-02 15:04:05"),
}
team.Save(teamFile)
return team, nil
}
// updateAgentDir 更新单个 agent 目录:覆盖代码文件,保留 memory/
func updateAgentDir(src, dst string) error {
os.MkdirAll(dst, 0755)
entries, err := os.ReadDir(src)
if err != nil {
return err
}
for _, e := range entries {
srcPath := filepath.Join(src, e.Name())
dstPath := filepath.Join(dst, e.Name())
if e.IsDir() {
// 跳过 memory 目录,保留用户的记忆数据
if e.Name() == "memory" {
continue
}
if err := CopyDir(srcPath, dstPath); err != nil {
return err
}
} else {
data, err := os.ReadFile(srcPath)
if err != nil {
return err
}
if err := os.WriteFile(dstPath, data, 0644); err != nil {
return err
}
}
}
return nil
}
func ExtractZIP(zipPath, dest string) error {
r, err := zip.OpenReader(zipPath)
if err != nil {
return err
}
defer r.Close()
for _, f := range r.File {
fpath := filepath.Join(dest, f.Name)
if f.FileInfo().IsDir() {
os.MkdirAll(fpath, 0755)
continue
}
if err := os.MkdirAll(filepath.Dir(fpath), 0755); err != nil {
return err
}
outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
return err
}
rc, err := f.Open()
if err != nil {
outFile.Close()
return err
}
buf := make([]byte, 4096)
for {
n, err := rc.Read(buf)
if n > 0 {
if _, writeErr := outFile.Write(buf[:n]); writeErr != nil {
rc.Close()
outFile.Close()
return writeErr
}
}
if err != nil {
break
}
}
rc.Close()
outFile.Close()
}
return nil
}
func extractRepoName(url string) string {
parts := strings.Split(url, "/")
name := parts[len(parts)-1]
name = strings.TrimSuffix(name, ".git")
return name
}
func (t *Team) Save(path string) error {
content := fmt.Sprintf(`name: %s
description: %s
author: %s
repo_url: %s
agents:
%s
skills:
%s
installed_at: %s
updated_at: %s
`, t.Name, t.Description, t.Author, t.RepoURL, arrayToYAML(t.Agents), arrayToYAML(t.Skills), t.InstalledAt, t.UpdatedAt)
return os.WriteFile(path, []byte(content), 0644)
}
func arrayToYAML(arr []string) string {
if len(arr) == 0 {
return " []"
}
result := ""
for _, s := range arr {
result += fmt.Sprintf(" - %s\n", s)
}
return result
}
func CopyDir(src, dst string) error {
if _, err := os.Stat(src); os.IsNotExist(err) {
return nil // optional dir, skip
}
os.MkdirAll(dst, 0755)
entries, err := os.ReadDir(src)
if err != nil {
return err
}
for _, e := range entries {
srcPath := filepath.Join(src, e.Name())
dstPath := filepath.Join(dst, e.Name())
if e.IsDir() {
if err := CopyDir(srcPath, dstPath); err != nil {
return err
}
} else {
data, err := os.ReadFile(srcPath)
if err != nil {
return err
}
os.MkdirAll(filepath.Dir(dstPath), 0755)
if err := os.WriteFile(dstPath, data, 0644); err != nil {
return err
}
}
}
return nil
}
func readTeamMeta(tmp string) Team {
team := Team{}
teamFile := filepath.Join(tmp, "TEAM.md")
data, err := os.ReadFile(teamFile)
if err != nil {
return Team{Name: extractRepoName(tmp)}
}
content := string(data)
if strings.HasPrefix(content, "---") {
parts := strings.SplitN(content, "---", 3)
if len(parts) >= 3 {
yamlContent := parts[1]
yaml.Unmarshal([]byte(yamlContent), &team)
}
}
if team.Name == "" {
team.Name = extractRepoName(tmp)
}
if team.Description == "" {
team.Description = "团队描述"
}
return team
}