1437 lines
40 KiB
Go
1437 lines
40 KiB
Go
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
|
||
}
|