- 数据层: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>
403 lines
9.9 KiB
Go
403 lines
9.9 KiB
Go
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
|
||
}
|