Go backend: - LLM client with DeepSeek/Kimi/Ollama/OpenAI support (OpenAI-compat) - Agent loader: AGENT.md frontmatter, SOUL.md, memory read/write - Skill system following agentskills.io standard - Room orchestration: master assign→execute→review loop with streaming - Hub: GitHub repo clone and team package install - Echo HTTP server with WebSocket and full REST API React frontend: - Discord-style 3-panel layout with Tailwind v4 - Zustand store with WebSocket streaming message handling - Chat view: streaming messages, role styles, right panel, drawer buttons - Agent MD editor with Monaco Editor (AGENT.md + SOUL.md) - Market page for GitHub team install/publish Docs: - plan.md with full progress tracking and next steps Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
297 lines
7.6 KiB
Go
297 lines
7.6 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
|
|
"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/skill"
|
|
)
|
|
|
|
type Server struct {
|
|
e *echo.Echo
|
|
agentsDir string
|
|
skillsDir string
|
|
roomsDir string
|
|
rooms map[string]*room.Room
|
|
mu sync.RWMutex
|
|
clients map[string]map[*websocket.Conn]bool // roomID -> conns
|
|
clientsMu sync.Mutex
|
|
upgrader websocket.Upgrader
|
|
}
|
|
|
|
func New(agentsDir, skillsDir, roomsDir string) *Server {
|
|
s := &Server{
|
|
e: echo.New(),
|
|
agentsDir: agentsDir,
|
|
skillsDir: skillsDir,
|
|
roomsDir: roomsDir,
|
|
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.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.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.GET("/skills", s.listSkills)
|
|
g.GET("/skills/:name", s.getSkill)
|
|
g.POST("/hub/install", s.hubInstall)
|
|
g.GET("/rooms/:id/workspace", s.listWorkspace)
|
|
g.GET("/rooms/:id/tasks", s.getTasks)
|
|
g.GET("/rooms/:id/history", s.listHistory)
|
|
|
|
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.Broadcast = func(ev room.Event) { s.broadcast(ev.RoomID, ev) }
|
|
s.rooms[e.Name()] = r
|
|
}
|
|
}
|
|
|
|
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"`
|
|
}
|
|
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
|
|
}
|
|
go r.Handle(context.Background(), 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"`
|
|
}
|
|
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})
|
|
}
|
|
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
|
|
}
|
|
dir := filepath.Join(s.roomsDir, cfg.Name)
|
|
os.MkdirAll(filepath.Join(dir, "workspace"), 0755)
|
|
os.MkdirAll(filepath.Join(dir, "history"), 0755)
|
|
|
|
content := "---\nname: " + cfg.Name + "\ntype: " + string(cfg.Type) + "\nmaster: " + cfg.Master + "\nmembers:\n"
|
|
for _, m := range cfg.Members {
|
|
content += " - " + m + "\n"
|
|
}
|
|
content += "---\n"
|
|
os.WriteFile(filepath.Join(dir, "room.md"), []byte(content), 0644)
|
|
|
|
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) 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) listSkills(c echo.Context) error {
|
|
metas, _ := skill.Discover(s.skillsDir)
|
|
return c.JSON(200, metas)
|
|
}
|
|
|
|
func (s *Server) getSkill(c echo.Context) error {
|
|
name := c.Param("name")
|
|
sk, err := skill.Load(filepath.Join(s.skillsDir, name))
|
|
if err != nil {
|
|
return c.JSON(404, map[string]string{"error": "not found"})
|
|
}
|
|
return c.JSON(200, sk)
|
|
}
|
|
|
|
func (s *Server) hubInstall(c echo.Context) error {
|
|
var body struct {
|
|
Repo string `json:"repo"`
|
|
}
|
|
if err := c.Bind(&body); err != nil {
|
|
return err
|
|
}
|
|
if err := hub.Install(body.Repo, s.agentsDir, s.skillsDir); err != nil {
|
|
return c.JSON(500, map[string]string{"error": err.Error()})
|
|
}
|
|
s.loadRooms()
|
|
return c.JSON(200, map[string]string{"status": "installed"})
|
|
}
|
|
|
|
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)
|
|
}
|