agent-team/internal/api/server.go
sdaduanbilei 8cb4e041c8 fix
2026-03-09 17:38:43 +08:00

1437 lines
40 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 api
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/labstack/echo/v4"
"github.com/sdaduanbilei/agent-team/internal/hub"
"github.com/sdaduanbilei/agent-team/internal/room"
"github.com/sdaduanbilei/agent-team/internal/scheduler"
"github.com/sdaduanbilei/agent-team/internal/skill"
"github.com/sdaduanbilei/agent-team/internal/store"
"github.com/sdaduanbilei/agent-team/internal/user"
)
type Server struct {
e *echo.Echo
agentsDir string
skillsDir string
roomsDir string
usersDir string
teamsDir string
rooms map[string]*room.Room
user *user.User
mu sync.RWMutex
clients map[string]map[*websocket.Conn]bool // roomID -> conns
clientsMu sync.Mutex
upgrader websocket.Upgrader
scheduler *scheduler.Scheduler
store *store.Store
}
func New(agentsDir, skillsDir, roomsDir, usersDir, teamsDir string, st *store.Store) *Server {
s := &Server{
e: echo.New(),
agentsDir: agentsDir,
skillsDir: skillsDir,
roomsDir: roomsDir,
usersDir: usersDir,
teamsDir: teamsDir,
rooms: make(map[string]*room.Room),
clients: make(map[string]map[*websocket.Conn]bool),
upgrader: websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }},
store: st,
}
s.loadUser()
s.loadRooms()
s.initScheduler()
s.routes()
return s
}
func (s *Server) Start(addr string) error {
go s.scheduler.Start(context.Background())
return s.e.Start(addr)
}
func (s *Server) initScheduler() {
s.scheduler = scheduler.New(s.roomsDir)
s.scheduler.Broadcast = func(roomID, eventType, message, userName string) {
s.broadcast(roomID, room.Event{
Type: room.EvtScheduleRun,
RoomID: roomID,
Content: message,
Agent: userName,
})
}
if s.store != nil {
s.scheduler.Store = &storeAdapter{s.store}
}
s.syncSchedulerRooms()
}
// storeAdapter 适配 store.Store 到 scheduler.ScheduleStore 接口
type storeAdapter struct {
st *store.Store
}
func (a *storeAdapter) GetAllEnabled() ([]scheduler.StoreSchedule, error) {
list, err := a.st.GetAllEnabled()
if err != nil {
return nil, err
}
result := make([]scheduler.StoreSchedule, len(list))
for i, s := range list {
result[i] = scheduler.StoreSchedule{
ID: s.ID, RoomID: s.RoomID, Cron: s.Cron, Message: s.Message,
UserName: s.UserName, Enabled: s.Enabled, Once: s.Once,
CreatedAt: s.CreatedAt, LastRunAt: s.LastRunAt,
}
}
return result, nil
}
func (a *storeAdapter) UpdateLastRun(id, lastRunAt string) error {
return a.st.UpdateLastRun(id, lastRunAt)
}
func (a *storeAdapter) DisableSchedule(id string) error {
return a.st.DisableSchedule(id)
}
func (s *Server) syncSchedulerRooms() {
s.mu.RLock()
rooms := make(map[string]scheduler.RoomHandler, len(s.rooms))
for id, r := range s.rooms {
rooms[id] = r
}
s.mu.RUnlock()
s.scheduler.SetRooms(rooms)
}
func (s *Server) routes() {
s.e.Static("/", "web/dist")
g := s.e.Group("/api")
g.GET("/rooms", s.listRooms)
g.POST("/rooms", s.createRoom)
g.PUT("/rooms/:id/team", s.setRoomTeam)
g.GET("/rooms/:id/team-doc", s.getRoomTeamDoc)
g.PUT("/rooms/:id/team-doc", s.saveRoomTeamDoc)
g.GET("/rooms/:id/agents/:agent/files/:file", s.getRoomAgentFile)
g.PUT("/rooms/:id/agents/:agent/files/:file", s.saveRoomAgentFile)
g.GET("/agents", s.listAgents)
g.GET("/agents/:name/files/:file", s.readAgentFile)
g.PUT("/agents/:name/files/:file", s.writeAgentFile)
g.POST("/agents", s.createAgent)
g.DELETE("/agents/:name", s.deleteAgent)
g.POST("/hub/install", s.hubInstall)
g.POST("/hub/install-zip", s.hubInstallZIP)
g.GET("/teams", s.listTeams)
g.GET("/teams/:name", s.getTeam)
g.GET("/teams/:name/agents/:agent/files/*file", s.getTeamAgentFile)
g.PUT("/teams/:name/agents/:agent/files/*file", s.saveTeamAgentFile)
g.GET("/teams/:name/knowledge", s.listTeamKnowledge)
g.GET("/teams/:name/knowledge/:file", s.getTeamKnowledgeFile)
g.PUT("/teams/:name/knowledge/:file", s.saveTeamKnowledgeFile)
g.POST("/teams/:name/update", s.updateTeam)
g.DELETE("/teams/:name", s.deleteTeam)
g.GET("/skills", s.listSkills)
g.POST("/skills/install-zip", s.installSkillZIP)
g.GET("/rooms/:id/schedules", s.listSchedules)
g.POST("/rooms/:id/schedules", s.createSchedule)
g.PUT("/rooms/:id/schedules/:sid", s.updateSchedule)
g.DELETE("/rooms/:id/schedules/:sid", s.deleteSchedule)
g.POST("/rooms/:id/schedules/:sid/run", s.runScheduleNow)
g.POST("/rooms/:id/compress-memory", s.compressMemory)
g.GET("/rooms/:id/workspace", s.listWorkspace)
g.GET("/rooms/:id/workspace/:file", s.getWorkspaceFile)
g.GET("/rooms/:id/workspace/:file/versions", s.getFileVersions)
g.GET("/rooms/:id/workspace/:file/versions/:version", s.getFileVersion)
g.POST("/rooms/:id/workspace/:file/revert/:version", s.revertFileVersion)
g.PUT("/rooms/:id/workspace/:file", s.putWorkspaceFile)
g.GET("/rooms/:id/tasks", s.getTasks)
g.GET("/rooms/:id/history", s.listHistory)
g.GET("/rooms/:id/messages", s.getMessages)
g.GET("/rooms/:id/token-usage", s.getTokenUsage)
g.GET("/user", s.getUser)
g.GET("/user/profile", s.getUserProfile)
g.PUT("/user/profile", s.saveUserProfile)
g.PUT("/user/config", s.saveUserConfig)
s.e.GET("/ws/:roomID", s.wsHandler)
}
func (s *Server) loadRooms() {
entries, _ := os.ReadDir(s.roomsDir)
for _, e := range entries {
if !e.IsDir() {
continue
}
r, err := room.Load(filepath.Join(s.roomsDir, e.Name()), s.agentsDir, s.skillsDir, room.WithStore(s.store))
if err != nil {
continue
}
r.User = s.user
r.Store = s.store
r.Broadcast = func(ev room.Event) { s.broadcast(ev.RoomID, ev) }
s.rooms[e.Name()] = r
}
}
func (s *Server) loadUser() {
u, err := user.Load(s.usersDir)
if err != nil {
u = &user.User{
Config: user.Config{
Name: "用户",
Provider: "deepseek",
Model: "deepseek-chat",
APIKeyEnv: "DEEPSEEK_API_KEY",
AvatarColor: "#5865F2",
},
Profile: "# 我的简介\n\n## 我是谁\n介绍你的身份、职业背景\n\n## 工作风格\n- 喜欢的沟通方式\n- 重视的点\n- 不喜欢的内容\n",
Dir: filepath.Join(s.usersDir, user.DefaultUser),
}
os.MkdirAll(u.Dir, 0755)
os.WriteFile(filepath.Join(u.Dir, "USER.md"), []byte("---\nname: 用户\ndescription: \nprovider: deepseek\nmodel: deepseek-chat\napi_key_env: DEEPSEEK_API_KEY\navatar_color: \"#5865F2\"\n---\n"), 0644)
os.WriteFile(filepath.Join(u.Dir, "PROFILE.md"), []byte(u.Profile), 0644)
}
s.user = u
}
func (s *Server) broadcast(roomID string, ev room.Event) {
s.clientsMu.Lock()
defer s.clientsMu.Unlock()
data, _ := json.Marshal(ev)
for conn := range s.clients[roomID] {
conn.WriteMessage(websocket.TextMessage, data)
}
}
func (s *Server) wsHandler(c echo.Context) error {
roomID := c.Param("roomID")
conn, err := s.upgrader.Upgrade(c.Response(), c.Request(), nil)
if err != nil {
return err
}
s.clientsMu.Lock()
if s.clients[roomID] == nil {
s.clients[roomID] = make(map[*websocket.Conn]bool)
}
s.clients[roomID][conn] = true
s.clientsMu.Unlock()
defer func() {
s.clientsMu.Lock()
delete(s.clients[roomID], conn)
s.clientsMu.Unlock()
conn.Close()
}()
for {
_, msg, err := conn.ReadMessage()
if err != nil {
break
}
var ev struct {
Type string `json:"type"`
Content string `json:"content"`
UserName string `json:"user_name"`
Mode string `json:"mode"`
}
if json.Unmarshal(msg, &ev) != nil {
continue
}
s.mu.RLock()
r := s.rooms[roomID]
s.mu.RUnlock()
if r == nil {
continue
}
if ev.Type == "stop" {
r.Stop()
continue
}
if ev.Type == "set_mode" && (ev.Mode == "plan" || ev.Mode == "build") {
r.Mode = ev.Mode
s.broadcast(roomID, room.Event{Type: room.EvtModeChange, RoomID: roomID, Mode: ev.Mode})
continue
}
if ev.Type != "user_message" {
continue
}
userName := ev.UserName
if userName == "" {
userName = s.user.GetName()
}
go func() {
if err := r.HandleUserMessage(context.Background(), userName, ev.Content); err != nil {
log.Printf("[room %s] HandleUserMessage error: %v", roomID, err)
}
}()
}
return nil
}
// --- REST handlers ---
func (s *Server) listRooms(c echo.Context) error {
s.mu.RLock()
defer s.mu.RUnlock()
type roomInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Status room.Status `json:"status"`
Master string `json:"master"`
Members []string `json:"members"`
Color string `json:"color"`
Team string `json:"team"`
Mode string `json:"mode"`
}
var list []roomInfo
for id, r := range s.rooms {
mode := r.Mode
if mode == "" {
mode = "plan"
}
list = append(list, roomInfo{ID: id, Name: r.Config.Name, Type: string(r.Config.Type), Status: r.Status, Master: r.Config.Master, Members: r.Config.Members, Color: r.Config.Color, Team: r.Config.Team, Mode: mode})
}
return c.JSON(200, list)
}
func (s *Server) createRoom(c echo.Context) error {
var cfg room.Config
if err := c.Bind(&cfg); err != nil {
return err
}
if cfg.Type == "" {
cfg.Type = room.TypeProject
}
dir := filepath.Join(s.roomsDir, cfg.Name)
os.MkdirAll(filepath.Join(dir, "workspace"), 0755)
os.MkdirAll(filepath.Join(dir, "history"), 0755)
s.writeRoomFile(dir, cfg)
r, err := room.Load(dir, s.agentsDir, s.skillsDir, room.WithStore(s.store))
if err != nil {
return c.JSON(500, map[string]string{"error": err.Error()})
}
r.Broadcast = func(ev room.Event) { s.broadcast(ev.RoomID, ev) }
r.Store = s.store
s.mu.Lock()
s.rooms[cfg.Name] = r
s.mu.Unlock()
s.syncSchedulerRooms()
return c.JSON(201, map[string]string{"id": cfg.Name})
}
func (s *Server) writeRoomFile(dir string, cfg room.Config) {
content := "---\nname: " + cfg.Name + "\ntype: " + string(cfg.Type) + "\nmaster: " + cfg.Master + "\ncolor: " + cfg.Color + "\nteam: " + cfg.Team + "\nmembers:\n"
for _, m := range cfg.Members {
content += " - " + m + "\n"
}
content += "---\n"
os.WriteFile(filepath.Join(dir, "room.md"), []byte(content), 0644)
}
func (s *Server) setRoomTeam(c echo.Context) error {
id := c.Param("id")
var body struct {
Team string `json:"team"`
}
if err := c.Bind(&body); err != nil {
return err
}
// Load team agents
teamDir := filepath.Join(s.teamsDir, body.Team)
team, err := loadTeam(filepath.Join(teamDir, "team.yaml"))
if err != nil {
return c.JSON(404, map[string]string{"error": "team not found"})
}
s.mu.Lock()
r := s.rooms[id]
if r == nil {
s.mu.Unlock()
return c.JSON(404, map[string]string{"error": "room not found"})
}
// Find master and members from team agents by reading AGENT.md
master := ""
var members []string
for _, agentName := range team.Agents {
agentMD, err := os.ReadFile(filepath.Join(s.agentsDir, body.Team, agentName, "AGENT.md"))
if err == nil && strings.Contains(string(agentMD), "role: master") {
master = agentName
} else {
members = append(members, agentName)
}
}
if master == "" && len(team.Agents) > 0 {
master = team.Agents[0]
members = team.Agents[1:]
}
cfg := r.Config
cfg.Team = body.Team
cfg.Master = master
cfg.Members = members
s.writeRoomFile(r.Dir, cfg)
// 将 agent 配置SOUL.md、AGENT.md和 TEAM.md 写入 SQLite
if s.store != nil {
for _, agentName := range team.Agents {
agentDir := filepath.Join(s.agentsDir, body.Team, agentName)
if soul, err := os.ReadFile(filepath.Join(agentDir, "SOUL.md")); err == nil {
s.store.SetAgentConfig(id, agentName, "soul", string(soul))
}
if agentMD, err := os.ReadFile(filepath.Join(agentDir, "AGENT.md")); err == nil {
// 只存正文部分(去掉 frontmatter 后的内容)
s.store.SetAgentConfig(id, agentName, "agent", string(agentMD))
}
}
if teamMD, err := os.ReadFile(filepath.Join(s.agentsDir, body.Team, "TEAM.md")); err == nil {
s.store.SetAgentConfig(id, "__team__", "team_doc", string(teamMD))
}
}
newRoom, err := room.Load(r.Dir, s.agentsDir, s.skillsDir, room.WithStore(s.store))
if err != nil {
s.mu.Unlock()
return c.JSON(500, map[string]string{"error": err.Error()})
}
newRoom.Broadcast = func(ev room.Event) { s.broadcast(ev.RoomID, ev) }
newRoom.User = s.user
newRoom.Store = s.store
s.rooms[id] = newRoom
s.mu.Unlock()
s.syncSchedulerRooms()
return c.JSON(200, map[string]interface{}{
"team": body.Team,
"master": master,
"members": members,
})
}
func (s *Server) getRoomTeamDoc(c echo.Context) error {
id := c.Param("id")
if s.store == nil {
return c.JSON(200, map[string]string{"content": ""})
}
content, err := s.store.GetAgentConfig(id, "__team__", "team_doc")
if err != nil {
return c.JSON(200, map[string]string{"content": ""})
}
return c.JSON(200, map[string]string{"content": content})
}
func (s *Server) saveRoomTeamDoc(c echo.Context) error {
id := c.Param("id")
var body struct {
Content string `json:"content"`
}
if err := c.Bind(&body); err != nil {
return err
}
if s.store == nil {
return c.JSON(500, map[string]string{"error": "no store"})
}
if err := s.store.SetAgentConfig(id, "__team__", "team_doc", body.Content); err != nil {
return c.JSON(500, map[string]string{"error": err.Error()})
}
// 重新加载 room 以应用新模板
s.reloadRoom(id)
return c.JSON(200, map[string]string{"status": "ok"})
}
// getRoomAgentFile 读取 room 维度的 agent 配置文件(优先 SQLite降级文件系统
func (s *Server) getRoomAgentFile(c echo.Context) error {
id := c.Param("id")
agentName := c.Param("agent")
fileType := c.Param("file") // "soul" | "agent"
// 优先从 SQLite 读取
if s.store != nil {
content, err := s.store.GetAgentConfig(id, agentName, fileType)
if err == nil && content != "" {
return c.JSON(200, map[string]string{"content": content})
}
}
// 降级:从文件系统读取
s.mu.RLock()
r := s.rooms[id]
s.mu.RUnlock()
if r != nil && r.Config.Team != "" {
var filename string
switch fileType {
case "soul":
filename = "SOUL.md"
case "agent":
filename = "AGENT.md"
}
if filename != "" {
fpath := filepath.Join(s.agentsDir, r.Config.Team, agentName, filename)
if data, err := os.ReadFile(fpath); err == nil {
content := string(data)
// 顺便写入 DB下次就不用再读文件了
if s.store != nil {
s.store.SetAgentConfig(id, agentName, fileType, content)
}
return c.JSON(200, map[string]string{"content": content})
}
}
}
return c.JSON(200, map[string]string{"content": ""})
}
// saveRoomAgentFile 保存 room 维度的 agent 配置文件(到 SQLite
func (s *Server) saveRoomAgentFile(c echo.Context) error {
id := c.Param("id")
agentName := c.Param("agent")
fileType := c.Param("file") // "soul" | "agent"
var body struct {
Content string `json:"content"`
}
if err := c.Bind(&body); err != nil {
return err
}
if s.store == nil {
return c.JSON(500, map[string]string{"error": "no store"})
}
if err := s.store.SetAgentConfig(id, agentName, fileType, body.Content); err != nil {
return c.JSON(500, map[string]string{"error": err.Error()})
}
// 重新加载 room 以应用新配置
s.reloadRoom(id)
return c.JSON(200, map[string]string{"status": "ok"})
}
// reloadRoom 重新加载指定 room
func (s *Server) reloadRoom(id string) {
s.mu.Lock()
old := s.rooms[id]
if old == nil {
s.mu.Unlock()
return
}
newRoom, err := room.Load(old.Dir, s.agentsDir, s.skillsDir, room.WithStore(s.store))
if err != nil {
s.mu.Unlock()
return
}
newRoom.Broadcast = func(ev room.Event) { s.broadcast(ev.RoomID, ev) }
newRoom.User = s.user
newRoom.Store = s.store
s.rooms[id] = newRoom
s.mu.Unlock()
}
func (s *Server) listAgents(c echo.Context) error {
entries, _ := os.ReadDir(s.agentsDir)
type agentInfo struct {
Name string `json:"name"`
}
var list []agentInfo
for _, e := range entries {
if e.IsDir() {
list = append(list, agentInfo{Name: e.Name()})
}
}
return c.JSON(200, list)
}
func (s *Server) readAgentFile(c echo.Context) error {
name := c.Param("name")
file := c.Param("file") // AGENT.md or SOUL.md
data, err := os.ReadFile(filepath.Join(s.agentsDir, name, file))
if err != nil {
return c.JSON(404, map[string]string{"error": "not found"})
}
return c.JSON(200, map[string]string{"content": string(data)})
}
func (s *Server) writeAgentFile(c echo.Context) error {
name := c.Param("name")
file := c.Param("file")
var body struct {
Content string `json:"content"`
}
if err := c.Bind(&body); err != nil {
return err
}
dir := filepath.Join(s.agentsDir, name)
os.MkdirAll(dir, 0755)
return os.WriteFile(filepath.Join(dir, file), []byte(body.Content), 0644)
}
func (s *Server) createAgent(c echo.Context) error {
var body struct {
Name string `json:"name"`
}
if err := c.Bind(&body); err != nil {
return err
}
dir := filepath.Join(s.agentsDir, body.Name)
os.MkdirAll(filepath.Join(dir, "memory"), 0755)
agentMD := "---\nname: " + body.Name + "\ndescription: \nprovider: deepseek\nmodel: deepseek-chat\napi_key_env: DEEPSEEK_API_KEY\nskills: []\n---\n"
os.WriteFile(filepath.Join(dir, "AGENT.md"), []byte(agentMD), 0644)
os.WriteFile(filepath.Join(dir, "SOUL.md"), []byte("You are "+body.Name+"."), 0644)
return c.JSON(201, map[string]string{"name": body.Name})
}
func (s *Server) deleteAgent(c echo.Context) error {
name := c.Param("name")
return os.RemoveAll(filepath.Join(s.agentsDir, name))
}
func (s *Server) hubInstall(c echo.Context) error {
var body struct {
Repo string `json:"repo"`
}
if err := c.Bind(&body); err != nil {
return err
}
team, err := hub.Install(body.Repo, s.agentsDir, s.skillsDir, s.teamsDir)
if err != nil {
return c.JSON(500, map[string]string{"error": err.Error()})
}
s.loadRooms()
return c.JSON(200, team)
}
func (s *Server) hubInstallZIP(c echo.Context) error {
file, err := c.FormFile("file")
if err != nil {
return c.JSON(400, map[string]string{"error": "no file uploaded"})
}
src, err := file.Open()
if err != nil {
return err
}
defer src.Close()
tmpFile := filepath.Join(os.TempDir(), file.Filename)
dst, err := os.Create(tmpFile)
if err != nil {
return err
}
defer os.Remove(tmpFile)
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
return err
}
team, err := hub.InstallZIP(tmpFile, s.agentsDir, s.skillsDir, s.teamsDir)
if err != nil {
return c.JSON(500, map[string]string{"error": err.Error()})
}
s.loadRooms()
return c.JSON(200, team)
}
func (s *Server) listWorkspace(c echo.Context) error {
id := c.Param("id")
dir := filepath.Join(s.roomsDir, id, "workspace")
entries, _ := os.ReadDir(dir)
var files []string
for _, e := range entries {
if !e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
files = append(files, e.Name())
}
}
return c.JSON(200, files)
}
func (s *Server) getTasks(c echo.Context) error {
id := c.Param("id")
data, _ := os.ReadFile(filepath.Join(s.roomsDir, id, "tasks.md"))
return c.JSON(200, map[string]string{"content": string(data)})
}
func (s *Server) listHistory(c echo.Context) error {
id := c.Param("id")
entries, _ := os.ReadDir(filepath.Join(s.roomsDir, id, "history"))
var files []string
for _, e := range entries {
files = append(files, e.Name())
}
return c.JSON(200, files)
}
func (s *Server) createSkill(c echo.Context) error {
var body struct {
Name string `json:"name"`
Content string `json:"content"`
}
if err := c.Bind(&body); err != nil {
return err
}
dir := filepath.Join(s.skillsDir, body.Name)
os.MkdirAll(dir, 0755)
content := body.Content
if content == "" {
content = "---\nname: " + body.Name + "\ndescription: \n---\n\n# " + body.Name + "\n\n描述这个 skill 的用途和使用步骤。\n"
}
return os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0644)
}
func (s *Server) listSkills(c echo.Context) error {
metas, _ := skill.Discover(s.skillsDir)
type skillInfo struct {
Name string `json:"name"`
Description string `json:"description"`
}
var result []skillInfo
for _, m := range metas {
result = append(result, skillInfo{Name: m.Name, Description: m.Description})
}
return c.JSON(200, result)
}
func (s *Server) installSkillZIP(c echo.Context) error {
file, err := c.FormFile("file")
if err != nil {
return c.JSON(400, map[string]string{"error": "未上传文件"})
}
src, err := file.Open()
if err != nil {
return err
}
defer src.Close()
tmpFile := filepath.Join(os.TempDir(), file.Filename)
dst, err := os.Create(tmpFile)
if err != nil {
return err
}
defer os.Remove(tmpFile)
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
return err
}
dst.Close()
// 解压 zip
tmp, err := os.MkdirTemp("", "skill-install-*")
if err != nil {
return err
}
defer os.RemoveAll(tmp)
if err := hub.ExtractZIP(tmpFile, tmp); err != nil {
return c.JSON(500, map[string]string{"error": "解压失败: " + err.Error()})
}
// 如果 zip 内只有一个有效子目录(忽略 __MACOSX 和隐藏目录),进入该子目录
entries, _ := os.ReadDir(tmp)
var validEntries []os.DirEntry
for _, e := range entries {
if e.IsDir() && !strings.HasPrefix(e.Name(), ".") && e.Name() != "__MACOSX" {
validEntries = append(validEntries, e)
}
}
root := tmp
if len(validEntries) == 1 {
root = filepath.Join(tmp, validEntries[0].Name())
}
// 从 SKILL.md 读取 skill 名称
skillData, err := os.ReadFile(filepath.Join(root, "SKILL.md"))
if err != nil {
// 再尝试在子目录中查找
for _, e := range validEntries {
p := filepath.Join(tmp, e.Name(), "SKILL.md")
if d, err2 := os.ReadFile(p); err2 == nil {
skillData = d
root = filepath.Join(tmp, e.Name())
err = nil
break
}
}
if err != nil {
return c.JSON(400, map[string]string{"error": "zip 中未找到 SKILL.md"})
}
}
meta, err := skill.ParseMeta(skillData)
if err != nil {
return c.JSON(400, map[string]string{"error": "SKILL.md 格式错误: " + err.Error()})
}
skillName := meta.Name
if skillName == "" {
// 从 zip 文件名推断
skillName = strings.TrimSuffix(filepath.Base(file.Filename), ".zip")
}
// 复制到 skills 目录
dstDir := filepath.Join(s.skillsDir, skillName)
if err := hub.CopyDir(root, dstDir); err != nil {
return c.JSON(500, map[string]string{"error": "安装失败: " + err.Error()})
}
// 如果指定了团队,更新 team.yaml 的 skills 列表
teamName := c.FormValue("team")
if teamName != "" {
teamFile := filepath.Join(s.teamsDir, teamName, "team.yaml")
if team, err := loadTeam(teamFile); err == nil {
found := false
for _, sk := range team.Skills {
if sk == skillName {
found = true
break
}
}
if !found {
team.Skills = append(team.Skills, skillName)
team.Save(teamFile)
}
}
}
return c.JSON(200, map[string]string{"name": skillName, "status": "ok"})
}
func (s *Server) getWorkspaceFile(c echo.Context) error {
id := c.Param("id")
file := c.Param("file")
data, err := os.ReadFile(filepath.Join(s.roomsDir, id, "workspace", file))
if err != nil {
return c.JSON(404, map[string]string{"error": "not found"})
}
return c.JSON(200, map[string]string{"content": string(data)})
}
func (s *Server) putWorkspaceFile(c echo.Context) error {
id := c.Param("id")
file := c.Param("file")
var body struct {
Content string `json:"content"`
}
if err := c.Bind(&body); err != nil {
return err
}
dir := filepath.Join(s.roomsDir, id, "workspace")
os.MkdirAll(dir, 0755)
if err := os.WriteFile(filepath.Join(dir, file), []byte(body.Content), 0644); err != nil {
return err
}
return c.JSON(200, map[string]string{"status": "ok"})
}
func (s *Server) getMessages(c echo.Context) error {
id := c.Param("id")
if s.store != nil {
limit := 200
offset := 0
if v := c.QueryParam("limit"); v != "" {
fmt.Sscanf(v, "%d", &limit)
}
if v := c.QueryParam("offset"); v != "" {
fmt.Sscanf(v, "%d", &offset)
}
dbMsgs, err := s.store.GetMessages(id, limit, offset)
if err != nil {
return c.JSON(500, map[string]string{"error": err.Error()})
}
type msgJSON struct {
ID int64 `json:"id"`
Agent string `json:"agent"`
Role string `json:"role"`
Content string `json:"content"`
Filename string `json:"filename,omitempty"`
Title string `json:"title,omitempty"`
GroupID *int64 `json:"group_id,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
PartType string `json:"part_type,omitempty"`
}
result := make([]msgJSON, 0, len(dbMsgs))
for _, m := range dbMsgs {
result = append(result, msgJSON{
ID: m.ID, Agent: m.Agent, Role: m.Role, Content: m.Content,
Filename: m.Filename, Title: m.Title, GroupID: m.GroupID, CreatedAt: m.CreatedAt,
PartType: m.PartType,
})
}
return c.JSON(200, result)
}
// 回退:从 markdown 文件解析(旧逻辑)
historyFile := filepath.Join(s.roomsDir, id, "history", time.Now().Format("2006-01-02")+".md")
data, err := os.ReadFile(historyFile)
if err != nil {
return c.JSON(200, []interface{}{})
}
type msg struct {
ID string `json:"id"`
Agent string `json:"agent"`
Role string `json:"role"`
Content string `json:"content"`
}
var msgs []msg
blocks := strings.Split(string(data), "\n**[")
for i, block := range blocks {
if block == "" {
continue
}
headerEnd := strings.Index(block, "\n\n")
if headerEnd < 0 {
continue
}
header := block[:headerEnd]
content := strings.TrimSpace(block[headerEnd+2:])
bracketEnd := strings.Index(header, "] ")
if bracketEnd < 0 {
continue
}
rest := header[bracketEnd+2:]
starIdx := strings.Index(rest, "**")
if starIdx < 0 {
continue
}
agentName := rest[:starIdx]
roleStr := strings.TrimSpace(rest[starIdx+2:])
roleStr = strings.TrimPrefix(roleStr, "(")
roleStr = strings.TrimSuffix(roleStr, ")")
if agentName == "" {
agentName = "unknown"
}
if roleStr == "" {
roleStr = "member"
}
msgs = append(msgs, msg{ID: fmt.Sprintf("%d", i), Agent: agentName, Role: roleStr, Content: content})
}
return c.JSON(200, msgs)
}
func (s *Server) getTokenUsage(c echo.Context) error {
id := c.Param("id")
if s.store == nil {
return c.JSON(200, map[string]int{"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0})
}
stats, err := s.store.GetRoomTokenStats(id)
if err != nil {
return c.JSON(500, map[string]string{"error": err.Error()})
}
return c.JSON(200, map[string]int{
"prompt_tokens": stats.PromptTokens,
"completion_tokens": stats.CompletionTokens,
"total_tokens": stats.TotalTokens,
})
}
func (s *Server) getUser(c echo.Context) error {
if s.user == nil {
return c.JSON(404, map[string]string{"error": "user not found"})
}
return c.JSON(200, map[string]interface{}{
"name": s.user.GetName(),
"provider": s.user.GetProvider(),
"model": s.user.GetModel(),
"api_key_env": s.user.GetAPIKeyEnv(),
"avatar_color": s.user.GetAvatarColor(),
"description": s.user.Config.Description,
"has_profile": s.user.Profile != "",
})
}
func (s *Server) getUserProfile(c echo.Context) error {
if s.user == nil {
return c.JSON(404, map[string]string{"error": "user not found"})
}
return c.JSON(200, map[string]string{"content": s.user.Profile})
}
func (s *Server) saveUserProfile(c echo.Context) error {
if s.user == nil {
return c.JSON(404, map[string]string{"error": "user not found"})
}
var body struct {
Content string `json:"content"`
}
if err := c.Bind(&body); err != nil {
return err
}
if err := s.user.SaveProfile(body.Content); err != nil {
return err
}
return c.JSON(200, map[string]string{"status": "ok"})
}
func (s *Server) saveUserConfig(c echo.Context) error {
if s.user == nil {
return c.JSON(404, map[string]string{"error": "user not found"})
}
var body struct {
Name string `json:"name"`
Description string `json:"description"`
Provider string `json:"provider"`
Model string `json:"model"`
APIKeyEnv string `json:"api_key_env"`
AvatarColor string `json:"avatar_color"`
}
if err := c.Bind(&body); err != nil {
return err
}
cfg := s.user.Config
if body.Name != "" {
cfg.Name = body.Name
}
if body.Description != "" {
cfg.Description = body.Description
}
if body.Provider != "" {
cfg.Provider = body.Provider
}
if body.Model != "" {
cfg.Model = body.Model
}
if body.APIKeyEnv != "" {
cfg.APIKeyEnv = body.APIKeyEnv
}
if body.AvatarColor != "" {
cfg.AvatarColor = body.AvatarColor
}
if err := s.user.SaveConfig(cfg); err != nil {
return err
}
return c.JSON(200, map[string]string{"status": "ok"})
}
// --- Teams API ---
func (s *Server) listTeams(c echo.Context) error {
entries, err := os.ReadDir(s.teamsDir)
if err != nil {
return c.JSON(200, []interface{}{})
}
var teams []interface{}
for _, e := range entries {
if !e.IsDir() {
continue
}
team, err := loadTeam(filepath.Join(s.teamsDir, e.Name(), "team.yaml"))
if err != nil {
continue
}
teams = append(teams, map[string]interface{}{
"id": e.Name(),
"name": team.Name,
"description": team.Description,
"author": team.Author,
"repo_url": team.RepoURL,
"agents": team.Agents,
"skills": team.Skills,
"installed_at": team.InstalledAt,
"updated_at": team.UpdatedAt,
})
}
return c.JSON(200, teams)
}
func (s *Server) getTeam(c echo.Context) error {
name := c.Param("name")
team, err := loadTeam(filepath.Join(s.teamsDir, name, "team.yaml"))
if err != nil {
return c.JSON(404, map[string]string{"error": "team not found"})
}
return c.JSON(200, map[string]interface{}{
"name": team.Name,
"description": team.Description,
"author": team.Author,
"repo_url": team.RepoURL,
"agents": team.Agents,
"skills": team.Skills,
"installed_at": team.InstalledAt,
"updated_at": team.UpdatedAt,
})
}
func (s *Server) getTeamAgentFile(c echo.Context) error {
teamName := c.Param("name")
agentName, _ := url.PathUnescape(c.Param("agent"))
fileName := strings.TrimPrefix(c.Param("*"), "/")
path := filepath.Join(s.agentsDir, teamName, agentName, fileName)
data, err := os.ReadFile(path)
if err != nil {
return c.JSON(404, map[string]string{"error": "file not found"})
}
return c.JSON(200, map[string]string{"content": string(data)})
}
func (s *Server) saveTeamAgentFile(c echo.Context) error {
teamName := c.Param("name")
agentName, _ := url.PathUnescape(c.Param("agent"))
fileName := strings.TrimPrefix(c.Param("*"), "/")
path := filepath.Join(s.agentsDir, teamName, agentName, fileName)
var body struct {
Content string `json:"content"`
}
if err := c.Bind(&body); err != nil {
return err
}
os.MkdirAll(filepath.Dir(path), 0755)
if err := os.WriteFile(path, []byte(body.Content), 0644); err != nil {
return err
}
return c.JSON(200, map[string]string{"status": "ok"})
}
func (s *Server) listTeamKnowledge(c echo.Context) error {
name := c.Param("name")
dir := filepath.Join(s.agentsDir, name, "knowledge")
entries, err := os.ReadDir(dir)
if err != nil {
return c.JSON(200, []string{})
}
var files []string
for _, e := range entries {
if !e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
files = append(files, e.Name())
}
}
return c.JSON(200, files)
}
func (s *Server) getTeamKnowledgeFile(c echo.Context) error {
name := c.Param("name")
fileName := c.Param("file")
path := filepath.Join(s.agentsDir, name, "knowledge", fileName)
data, err := os.ReadFile(path)
if err != nil {
return c.JSON(404, map[string]string{"error": "file not found"})
}
return c.JSON(200, map[string]string{"content": string(data)})
}
func (s *Server) saveTeamKnowledgeFile(c echo.Context) error {
name := c.Param("name")
fileName := c.Param("file")
path := filepath.Join(s.agentsDir, name, "knowledge", fileName)
var body struct {
Content string `json:"content"`
}
if err := c.Bind(&body); err != nil {
return err
}
os.MkdirAll(filepath.Dir(path), 0755)
if err := os.WriteFile(path, []byte(body.Content), 0644); err != nil {
return err
}
return c.JSON(200, map[string]string{"status": "ok"})
}
func (s *Server) updateTeam(c echo.Context) error {
name := c.Param("name")
team, err := hub.Update(name, s.agentsDir, s.skillsDir, s.teamsDir)
if err != nil {
return c.JSON(400, map[string]string{"error": err.Error()})
}
// 重新加载使用该团队的 rooms
s.mu.Lock()
for id, r := range s.rooms {
if r.Config.Team == name {
reloaded, err := room.Load(r.Dir, s.agentsDir, s.skillsDir, room.WithStore(s.store))
if err != nil {
log.Printf("[update] 重新加载 room %s 失败: %v", id, err)
continue
}
reloaded.Broadcast = func(ev room.Event) { s.broadcast(ev.RoomID, ev) }
reloaded.User = s.user
reloaded.Store = s.store
s.rooms[id] = reloaded
}
}
s.mu.Unlock()
s.syncSchedulerRooms()
return c.JSON(200, map[string]interface{}{
"name": team.Name,
"description": team.Description,
"updated_at": team.UpdatedAt,
"agents": team.Agents,
"skills": team.Skills,
})
}
func (s *Server) deleteTeam(c echo.Context) error {
name := c.Param("name")
// Delete team directory
if err := os.RemoveAll(filepath.Join(s.teamsDir, name)); err != nil {
return err
}
// Delete agents
if err := os.RemoveAll(filepath.Join(s.agentsDir, name)); err != nil {
// Continue even if error
}
return c.JSON(200, map[string]string{"status": "ok"})
}
func (s *Server) compressMemory(c echo.Context) error {
id := c.Param("id")
s.mu.RLock()
r := s.rooms[id]
s.mu.RUnlock()
if r == nil {
return c.JSON(404, map[string]string{"error": "room not found"})
}
go r.CompressAllMemory(context.Background())
return c.JSON(200, map[string]string{"status": "compressing"})
}
// --- Schedule API ---
func (s *Server) listSchedules(c echo.Context) error {
id := c.Param("id")
if s.store != nil {
list, err := s.store.ListSchedules(id)
if err != nil {
return c.JSON(500, map[string]string{"error": err.Error()})
}
if list == nil {
list = []store.Schedule{}
}
return c.JSON(200, list)
}
schedules, err := s.scheduler.LoadSchedules(id)
if err != nil {
return c.JSON(500, map[string]string{"error": err.Error()})
}
if schedules == nil {
schedules = []scheduler.Schedule{}
}
return c.JSON(200, schedules)
}
func (s *Server) createSchedule(c echo.Context) error {
id := c.Param("id")
if s.store != nil {
var sch store.Schedule
if err := c.Bind(&sch); err != nil {
return c.JSON(400, map[string]string{"error": err.Error()})
}
sch.ID = fmt.Sprintf("%d", time.Now().UnixNano())
sch.RoomID = id
sch.Enabled = true
sch.CreatedAt = time.Now().Format(time.RFC3339)
if sch.UserName == "" {
sch.UserName = "scheduler"
}
// 验证 cron
if !sch.Once {
if _, err := scheduler.ParseCron(sch.Cron); err != nil {
return c.JSON(400, map[string]string{"error": "无效的 cron 表达式: " + err.Error()})
}
}
if err := s.store.InsertSchedule(&sch); err != nil {
return c.JSON(400, map[string]string{"error": err.Error()})
}
return c.JSON(201, sch)
}
var sch scheduler.Schedule
if err := c.Bind(&sch); err != nil {
return c.JSON(400, map[string]string{"error": err.Error()})
}
sch.ID = fmt.Sprintf("%d", time.Now().UnixNano())
sch.RoomID = id
sch.Enabled = true
if err := s.scheduler.AddSchedule(id, sch); err != nil {
return c.JSON(400, map[string]string{"error": err.Error()})
}
return c.JSON(201, sch)
}
func (s *Server) updateSchedule(c echo.Context) error {
id := c.Param("id")
sid := c.Param("sid")
if s.store != nil {
var sch store.Schedule
if err := c.Bind(&sch); err != nil {
return c.JSON(400, map[string]string{"error": err.Error()})
}
sch.ID = sid
sch.RoomID = id
if sch.UserName == "" {
sch.UserName = "scheduler"
}
if !sch.Once {
if _, err := scheduler.ParseCron(sch.Cron); err != nil {
return c.JSON(400, map[string]string{"error": "无效的 cron 表达式: " + err.Error()})
}
}
if err := s.store.UpdateSchedule(&sch); err != nil {
return c.JSON(400, map[string]string{"error": err.Error()})
}
return c.JSON(200, map[string]string{"status": "ok"})
}
var sch scheduler.Schedule
if err := c.Bind(&sch); err != nil {
return c.JSON(400, map[string]string{"error": err.Error()})
}
if err := s.scheduler.UpdateSchedule(id, sid, sch); err != nil {
return c.JSON(400, map[string]string{"error": err.Error()})
}
return c.JSON(200, map[string]string{"status": "ok"})
}
func (s *Server) deleteSchedule(c echo.Context) error {
id := c.Param("id")
sid := c.Param("sid")
if s.store != nil {
if err := s.store.DeleteSchedule(id, sid); err != nil {
return c.JSON(404, map[string]string{"error": err.Error()})
}
return c.JSON(200, map[string]string{"status": "ok"})
}
if err := s.scheduler.DeleteSchedule(id, sid); err != nil {
return c.JSON(404, map[string]string{"error": err.Error()})
}
return c.JSON(200, map[string]string{"status": "ok"})
}
func (s *Server) runScheduleNow(c echo.Context) error {
id := c.Param("id")
sid := c.Param("sid")
if err := s.scheduler.RunNow(context.Background(), id, sid); err != nil {
return c.JSON(400, map[string]string{"error": err.Error()})
}
return c.JSON(200, map[string]string{"status": "triggered"})
}
func (s *Server) getFileVersions(c echo.Context) error {
id := c.Param("id")
file := c.Param("file")
if s.store == nil {
return c.JSON(200, []interface{}{})
}
versions, err := s.store.GetFileVersions(id, file)
if err != nil {
return c.JSON(500, map[string]string{"error": err.Error()})
}
type versionJSON struct {
Version int `json:"version"`
Agent string `json:"agent"`
CreatedAt string `json:"created_at"`
}
result := make([]versionJSON, 0, len(versions))
for _, v := range versions {
result = append(result, versionJSON{
Version: v.Version, Agent: v.Agent, CreatedAt: v.CreatedAt,
})
}
return c.JSON(200, result)
}
func (s *Server) getFileVersion(c echo.Context) error {
id := c.Param("id")
file := c.Param("file")
versionStr := c.Param("version")
var version int
fmt.Sscanf(versionStr, "%d", &version)
if s.store == nil {
return c.JSON(404, map[string]string{"error": "no store"})
}
v, err := s.store.GetFileVersion(id, file, version)
if err != nil {
return c.JSON(404, map[string]string{"error": "version not found"})
}
return c.JSON(200, map[string]string{
"content": v.Content,
"agent": v.Agent,
"created_at": v.CreatedAt,
})
}
func (s *Server) revertFileVersion(c echo.Context) error {
id := c.Param("id")
file := c.Param("file")
versionStr := c.Param("version")
var version int
fmt.Sscanf(versionStr, "%d", &version)
if s.store == nil {
return c.JSON(500, map[string]string{"error": "no store"})
}
v, err := s.store.GetFileVersion(id, file, version)
if err != nil {
return c.JSON(404, map[string]string{"error": "version not found"})
}
// 保存当前版本到 file_versions
fpath := filepath.Join(s.roomsDir, id, "workspace", file)
if current, err := os.ReadFile(fpath); err == nil && len(current) > 0 {
s.store.InsertFileVersion(id, file, string(current), "")
}
// 写入回退内容
dir := filepath.Join(s.roomsDir, id, "workspace")
os.MkdirAll(dir, 0755)
if err := os.WriteFile(fpath, []byte(v.Content), 0644); err != nil {
return c.JSON(500, map[string]string{"error": err.Error()})
}
return c.JSON(200, map[string]string{"status": "ok"})
}
func loadTeam(path string) (*hub.Team, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
team := &hub.Team{}
lines := strings.Split(string(data), "\n")
var currentField string
for _, line := range lines {
if strings.HasPrefix(line, "name: ") {
team.Name = strings.TrimSpace(strings.TrimPrefix(line, "name: "))
currentField = ""
} else if strings.HasPrefix(line, "description: ") {
team.Description = strings.TrimSpace(strings.TrimPrefix(line, "description: "))
currentField = ""
} else if strings.HasPrefix(line, "author: ") {
team.Author = strings.TrimSpace(strings.TrimPrefix(line, "author: "))
currentField = ""
} else if strings.HasPrefix(line, "repo_url: ") {
team.RepoURL = strings.TrimSpace(strings.TrimPrefix(line, "repo_url: "))
currentField = ""
} else if strings.HasPrefix(line, "installed_at: ") {
team.InstalledAt = strings.TrimSpace(strings.TrimPrefix(line, "installed_at: "))
currentField = ""
} else if strings.HasPrefix(line, "updated_at: ") {
team.UpdatedAt = strings.TrimSpace(strings.TrimPrefix(line, "updated_at: "))
currentField = ""
} else if strings.HasPrefix(line, "agents:") {
currentField = "agents"
} else if strings.HasPrefix(line, "skills:") {
currentField = "skills"
} else if strings.HasPrefix(line, " - ") {
item := strings.TrimSpace(strings.TrimPrefix(line, " - "))
if currentField == "agents" {
team.Agents = append(team.Agents, item)
} else if currentField == "skills" {
team.Skills = append(team.Skills, item)
}
}
}
return team, nil
}