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("/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) 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) 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() defer s.mu.Unlock() r := s.rooms[id] if r == nil { 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) newRoom, err := room.Load(r.Dir, s.agentsDir, s.skillsDir) if err != nil { 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.syncSchedulerRooms() return c.JSON(200, map[string]interface{}{ "team": body.Team, "master": master, "members": members, }) } 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) 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 }