package api import ( "context" "encoding/json" "fmt" "io" "net/http" "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/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 } func New(agentsDir, skillsDir, roomsDir, usersDir, teamsDir string) *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 }}, } s.loadUser() s.loadRooms() s.routes() return s } func (s *Server) Start(addr string) error { return s.e.Start(addr) } 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.DELETE("/teams/:name", s.deleteTeam) g.GET("/rooms/:id/workspace", s.listWorkspace) g.GET("/rooms/:id/workspace/:file", s.getWorkspaceFile) g.GET("/rooms/:id/tasks", s.getTasks) g.GET("/rooms/:id/history", s.listHistory) g.GET("/rooms/:id/messages", s.getMessages) 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.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"` } if json.Unmarshal(msg, &ev) != nil || ev.Type != "user_message" { continue } s.mu.RLock() r := s.rooms[roomID] s.mu.RUnlock() if r == nil { continue } userName := ev.UserName if userName == "" { userName = s.user.GetName() } go r.HandleUserMessage(context.Background(), userName, ev.Content) } 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"` } var list []roomInfo for id, r := range s.rooms { 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}) } 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.TypeDept } 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) } s.mu.Lock() s.rooms[cfg.Name] = r s.mu.Unlock() 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 s.rooms[id] = newRoom 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() { 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) 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) getMessages(c echo.Context) error { id := c.Param("id") 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 // Format: "\n**[HH:MM:SS] agentName** (role)\n\ncontent\n" blocks := strings.Split(string(data), "\n**[") for i, block := range blocks { if block == "" { continue } // block starts with "HH:MM:SS] agentName** (role)\n\ncontent" headerEnd := strings.Index(block, "\n\n") if headerEnd < 0 { continue } header := block[:headerEnd] content := strings.TrimSpace(block[headerEnd+2:]) // header: "HH:MM:SS] agentName** (role)" bracketEnd := strings.Index(header, "] ") if bracketEnd < 0 { continue } rest := header[bracketEnd+2:] // "agentName** (role)" starIdx := strings.Index(rest, "**") if starIdx < 0 { continue } agentName := rest[:starIdx] roleStr := strings.TrimSpace(rest[starIdx+2:]) // " (role)" 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) 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, }) } 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, }) } func (s *Server) getTeamAgentFile(c echo.Context) error { teamName := c.Param("name") agentName := c.Param("agent") fileName := c.Param("file") 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 := c.Param("agent") fileName := c.Param("file") 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() { 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) 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 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, "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 }