feat(studio): Agent 编排服务端保存 + 我的编排列表(owner 隔离)
编排好的 Agent 现在可命名保存到服务端、跨会话可见;左侧「我的编排」列出本人全部。
- store: sundynix_agent 表(owner+name 唯一,Graph=React Flow {nodes,edges} JSON 含布局,
UpdatedAt);ListAgents(最近在前)/SaveAgent(OnConflict 覆盖图+时间)/DeleteAgent。AutoMigrate +Agent。
- gateway: GET/POST/DELETE /api/v1/agents(owner 隔离,身份取自 X-User-ID)。
- 前端:api listAgents/saveAgent/deleteAgent;StudioView 左面板下半区「我的编排(N)」列出本人编排,
点击载入(含布局)、悬停删除;工具栏 编排名+保存(服务端),去掉 localStorage 模板。
验证:curl 保存「合同审查流程」→ wt 列表含之,alice 列表为空(隔离)。Preview:示例图填名「尽调问答
Agent」保存 → 左「我的编排(2)」即时出现两条、可点载入。tsc+vite+gateway build 通过;重建 .app。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AgentList: GET /api/v1/agents —— 当前用户保存的全部 Agent 编排(owner 隔离,最近在前)。
|
||||
func (h *Handler) AgentList(c *gin.Context) {
|
||||
rows, err := h.db.ListAgents(c.Request.Context(), userID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, gin.H{"name": r.Name, "graph": r.Graph, "updated_at": r.UpdatedAt})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"agents": out})
|
||||
}
|
||||
|
||||
// AgentSave: POST /api/v1/agents {name, graph} —— 保存/更新一份编排(graph 为 {nodes,edges} JSON)。
|
||||
func (h *Handler) AgentSave(c *gin.Context) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Graph string `json:"graph"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Name) == "" || strings.TrimSpace(body.Graph) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name/graph required"})
|
||||
return
|
||||
}
|
||||
if err := h.db.SaveAgent(c.Request.Context(), userID(c), strings.TrimSpace(body.Name), body.Graph); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"name": strings.TrimSpace(body.Name)})
|
||||
}
|
||||
|
||||
// AgentDelete: DELETE /api/v1/agents?name= —— 删除一份编排。
|
||||
func (h *Handler) AgentDelete(c *gin.Context) {
|
||||
name := strings.TrimSpace(c.Query("name"))
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name required"})
|
||||
return
|
||||
}
|
||||
if err := h.db.DeleteAgent(c.Request.Context(), userID(c), name); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
@@ -34,6 +34,9 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus) *gin.Engine {
|
||||
api.POST("/kb/note", h.KbSaveNote) // 新建/编辑笔记(落库 + 按 doc 重入库)
|
||||
api.GET("/kb/graph", h.KbGraph) // 知识图谱三元组(→ mcp-go kb_graph,Neo4j)
|
||||
|
||||
api.GET("/agents", h.AgentList) // 我的 Agent 编排列表(owner 隔离)
|
||||
api.POST("/agents", h.AgentSave) // 保存/更新编排
|
||||
api.DELETE("/agents", h.AgentDelete) // 删除编排
|
||||
api.POST("/reports", h.GenerateReport) // 报告生成(intent=report 任务 → Dispatcher 专用编排)
|
||||
api.GET("/reports/:id/download", h.DownloadReport) // 下载渲染好的 Word(.docx)
|
||||
api.GET("/health", h.Health) // 依赖健康聚合(顶栏五盏灯)
|
||||
|
||||
@@ -45,6 +45,47 @@ func (p *Postgres) EnsureKB(ctx context.Context, owner, name, kind string) error
|
||||
}).Create(&KB{Owner: owner, Name: name, Kind: kind}).Error
|
||||
}
|
||||
|
||||
// Agent 是一份保存的 Agent 编排(React Flow 图 JSON,按 owner 隔离)。
|
||||
// 表名 sundynix_agent。(owner,name) 唯一 —— 同一用户下编排名不重复。
|
||||
type Agent struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Owner string `gorm:"size:64;uniqueIndex:idx_agent_on"`
|
||||
Name string `gorm:"size:128;uniqueIndex:idx_agent_on"`
|
||||
Graph string `gorm:"type:text"` // {nodes,edges} 的 JSON(含布局)
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (Agent) TableName() string { return "sundynix_agent" }
|
||||
|
||||
// ListAgents 返回某 owner 的全部编排(最近更新在前)。
|
||||
func (p *Postgres) ListAgents(ctx context.Context, owner string) ([]Agent, error) {
|
||||
if p.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var rows []Agent
|
||||
err := p.db.WithContext(ctx).Where("owner = ?", owner).Order("updated_at desc").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// SaveAgent 新建/更新一份编排(owner+name 唯一,重名覆盖图与更新时间)。
|
||||
func (p *Postgres) SaveAgent(ctx context.Context, owner, name, graph string) error {
|
||||
if p.db == nil {
|
||||
return errStoreDisabled
|
||||
}
|
||||
return p.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "owner"}, {Name: "name"}},
|
||||
DoUpdates: clause.Assignments(map[string]any{"graph": graph, "updated_at": time.Now()}),
|
||||
}).Create(&Agent{Owner: owner, Name: name, Graph: graph, UpdatedAt: time.Now()}).Error
|
||||
}
|
||||
|
||||
// DeleteAgent 删除某 owner 的一份编排。
|
||||
func (p *Postgres) DeleteAgent(ctx context.Context, owner, name string) error {
|
||||
if p.db == nil {
|
||||
return errStoreDisabled
|
||||
}
|
||||
return p.db.WithContext(ctx).Where("owner = ? AND name = ?", owner, name).Delete(&Agent{}).Error
|
||||
}
|
||||
|
||||
// Doc 是入库的一份原始文档/笔记(供 Obsidian 式"文库"浏览:列表 + Markdown 阅读 + 双链)。
|
||||
// 表名 sundynix_doc。(owner,kb,name) 唯一;按 owner 隔离。
|
||||
type Doc struct {
|
||||
|
||||
@@ -34,7 +34,7 @@ func OpenPostgres(dsn string) *Postgres {
|
||||
log.Printf("[store] postgres 不可用,降级运行(不持久化): %v", err)
|
||||
return &Postgres{}
|
||||
}
|
||||
if err := db.AutoMigrate(&User{}, &Task{}, &LLMModel{}, &KB{}, &Doc{}); err != nil {
|
||||
if err := db.AutoMigrate(&User{}, &Task{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}); err != nil {
|
||||
log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err)
|
||||
return &Postgres{}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user