diff --git a/sundynix-desktop/frontend/src/lib/api.ts b/sundynix-desktop/frontend/src/lib/api.ts index e8ca819..4708d4d 100644 --- a/sundynix-desktop/frontend/src/lib/api.ts +++ b/sundynix-desktop/frontend/src/lib/api.ts @@ -126,6 +126,38 @@ export interface VaultDoc { content: string; } +// ---- 我的 Agent 编排(服务端保存,owner 隔离)---- +export interface AgentInfo { + name: string; + graph: string; // {nodes,edges} JSON + updated_at?: string; +} + +export async function listAgents(id: Identity): Promise { + const res = await fetch(`${GATEWAY}/api/v1/agents`, { headers: idHeaders(id) }); + const data = (await res.json()) as { agents?: AgentInfo[]; error?: string }; + if (!res.ok) throw new Error(data.error ?? `list agents failed: ${res.status}`); + return data.agents ?? []; +} + +export async function saveAgent(id: Identity, name: string, graph: string): Promise { + const res = await fetch(`${GATEWAY}/api/v1/agents`, { + method: "POST", + headers: { "Content-Type": "application/json", ...idHeaders(id) }, + body: JSON.stringify({ name, graph }), + }); + const data = (await res.json()) as { name?: string; error?: string }; + if (!res.ok || !data.name) throw new Error(data.error ?? `save agent failed: ${res.status}`); +} + +export async function deleteAgent(id: Identity, name: string): Promise { + const res = await fetch(`${GATEWAY}/api/v1/agents?name=${encodeURIComponent(name)}`, { method: "DELETE", headers: idHeaders(id) }); + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(data.error ?? `delete agent failed: ${res.status}`); + } +} + // listChatModels: GET /api/v1/admin/models?kind=chat —— 已登记的对话模型名(供编排 Agent 节点选择)。 export async function listChatModels(): Promise { try { diff --git a/sundynix-desktop/frontend/src/studio/StudioView.tsx b/sundynix-desktop/frontend/src/studio/StudioView.tsx index 6a16da0..7ed791b 100644 --- a/sundynix-desktop/frontend/src/studio/StudioView.tsx +++ b/sundynix-desktop/frontend/src/studio/StudioView.tsx @@ -13,31 +13,17 @@ import { } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; -import { Play, ShieldCheck, Sparkles, Trash2, Save, FolderOpen } from "lucide-react"; +import { Play, ShieldCheck, Sparkles, Trash2, Save, Workflow } from "lucide-react"; import { nodeTypes, type NodeStatus } from "./TypedNode"; import { Inspector } from "./Inspector"; import { NODE_KINDS, NODE_ORDER } from "./nodeCatalog"; import { exportDsl, validate, type Issue, type TaskDsl } from "../lib/dsl"; import type { RunPhase } from "../lib/run"; -import { listKb, listChatModels, type Identity } from "../lib/api"; -import { Button, Input, useToast } from "../ui"; +import { listKb, listChatModels, listAgents, saveAgent, deleteAgent, type Identity, type AgentInfo } from "../lib/api"; +import { Button, Input, cn, useToast } from "../ui"; let seq = 0; -interface Template { - name: string; - nodes: Node[]; - edges: Edge[]; -} -const TPL_KEY = "sundynix.studio.templates"; -function loadTpls(): Template[] { - try { - return JSON.parse(localStorage.getItem(TPL_KEY) || "[]"); - } catch { - return []; - } -} - // buildExample:一张可直接运行的示例图(输入 → 检索本人 default 库 → Agent → 输出)。 function buildExample(): { nodes: Node[]; edges: Edge[] } { const mk = (id: string, kind: string, x: number, cfg: Record): Node => ({ @@ -61,7 +47,7 @@ function buildExample(): { nodes: Node[]; edges: Edge[] } { }; } -// 编排 Studio:左节点面板 · 中画布 · 右检查器 · 顶工具栏(运行/校验/模板)。 +// 编排 Studio:左(节点面板 + 我的编排) · 中画布 · 右检查器 · 顶工具栏。 export function StudioView({ onRun, phase, identity }: { onRun: (dsl: TaskDsl) => void; phase: RunPhase; identity: Identity }) { const toast = useToast(); const [nodes, setNodes, onNodesChange] = useNodesState([]); @@ -70,15 +56,19 @@ export function StudioView({ onRun, phase, identity }: { onRun: (dsl: TaskDsl) = const [issues, setIssues] = useState(null); const [kbOpts, setKbOpts] = useState([]); const [modelOpts, setModelOpts] = useState([]); - const [tplName, setTplName] = useState(""); - const [tpls, setTpls] = useState(loadTpls()); + const [agents, setAgents] = useState([]); + const [name, setName] = useState(""); - // 载入真实知识库 / 对话模型作为节点下拉选项。 useEffect(() => { listKb(identity).then((ks) => setKbOpts(ks.map((k) => k.name))).catch(() => {}); listChatModels().then(setModelOpts).catch(() => {}); }, [identity]); + const refreshAgents = useCallback(() => { + listAgents(identity).then(setAgents).catch(() => {}); + }, [identity]); + useEffect(() => refreshAgents(), [refreshAgents]); + const dynamicOptions = useMemo(() => ({ kb: kbOpts, model: modelOpts }), [kbOpts, modelOpts]); const onConnect = useCallback((c: Connection) => setEdges((es) => addEdge(c, es)), [setEdges]); @@ -117,12 +107,11 @@ export function StudioView({ onRun, phase, identity }: { onRun: (dsl: TaskDsl) = const loadGraph = useCallback( (g: { nodes: Node[]; edges: Edge[] }) => { - setNodes(g.nodes.map((n) => ({ ...n, data: { ...n.data, status: "idle" as NodeStatus } }))); - setEdges(g.edges); + setNodes((g.nodes ?? []).map((n) => ({ ...n, data: { ...n.data, status: "idle" as NodeStatus } }))); + setEdges(g.edges ?? []); setSelId(null); setIssues(null); - // 让自增 id 越过已载入节点,避免碰撞。 - for (const n of g.nodes) { + for (const n of g.nodes ?? []) { const m = /^n(\d+)$/.exec(n.id); if (m) seq = Math.max(seq, Number(m[1])); } @@ -137,23 +126,38 @@ export function StudioView({ onRun, phase, identity }: { onRun: (dsl: TaskDsl) = setIssues(null); }; - const saveTpl = () => { - const name = tplName.trim(); - if (!name) { - toast.push("error", "先填模板名"); - return; + const save = async () => { + const nm = name.trim(); + if (!nm) return toast.push("error", "先填编排名"); + if (nodes.length === 0) return toast.push("error", "画布为空"); + try { + await saveAgent(identity, nm, JSON.stringify({ nodes, edges })); + toast.push("success", `已保存编排「${nm}」`); + refreshAgents(); + } catch (e) { + toast.push("error", (e as Error).message); + } + }; + + const openAgent = (a: AgentInfo) => { + try { + loadGraph(JSON.parse(a.graph)); + setName(a.name); + } catch { + toast.push("error", "编排数据损坏,无法载入"); + } + }; + + const removeAgent = async (nm: string) => { + try { + await deleteAgent(identity, nm); + refreshAgents(); + toast.push("success", `已删除「${nm}」`); + } catch (e) { + toast.push("error", (e as Error).message); } - if (nodes.length === 0) { - toast.push("error", "画布为空"); - return; - } - const next = [...tpls.filter((t) => t.name !== name), { name, nodes, edges }]; - setTpls(next); - localStorage.setItem(TPL_KEY, JSON.stringify(next)); - toast.push("success", `已保存模板「${name}」`); }; - // 运行状态映射到节点徽标。 useEffect(() => { const status: NodeStatus = phase === "streaming" || phase === "submitting" ? "running" : phase === "done" ? "done" : phase === "error" ? "error" : "idle"; @@ -171,22 +175,42 @@ export function StudioView({ onRun, phase, identity }: { onRun: (dsl: TaskDsl) = return (
- {/* 左节点面板 */} -
-
节点
- {NODE_ORDER.map((kind) => { - const k = NODE_KINDS[kind]; - return ( - - ); - })} + {/* 左:节点面板 + 我的编排 */} +
+
+
节点
+ {NODE_ORDER.map((kind) => { + const k = NODE_KINDS[kind]; + return ( + + ); + })} +
+
+
+ 我的编排 {agents.length > 0 && `(${agents.length})`} +
+
    + {agents.length === 0 &&
  • 保存后在此列出,跨会话可载入。
  • } + {agents.map((a) => ( +
  • + + +
  • + ))} +
+
{/* 中画布 */} @@ -206,33 +230,10 @@ export function StudioView({ onRun, phase, identity }: { onRun: (dsl: TaskDsl) = 清空 - setTplName(e.target.value)} placeholder="模板名" /> - - {tpls.length > 0 && ( -
- - -
- )} {nodes.length} 节点 · {edges.length} 连线 @@ -264,7 +265,7 @@ export function StudioView({ onRun, phase, identity }: { onRun: (dsl: TaskDsl) = {issues && issues.length > 0 && (
{issues.map((i, idx) => ( -
+
{i.level === "error" ? "✗" : "⚠"} {i.msg}
))} diff --git a/sundynix-gateway/internal/handler/agent.go b/sundynix-gateway/internal/handler/agent.go new file mode 100644 index 0000000..778767f --- /dev/null +++ b/sundynix-gateway/internal/handler/agent.go @@ -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"}) +} diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index 3f3e32d..18b5a77 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -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) // 依赖健康聚合(顶栏五盏灯) diff --git a/sundynix-gateway/internal/store/model.go b/sundynix-gateway/internal/store/model.go index a4eb907..2247227 100644 --- a/sundynix-gateway/internal/store/model.go +++ b/sundynix-gateway/internal/store/model.go @@ -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 { diff --git a/sundynix-gateway/internal/store/pgsql.go b/sundynix-gateway/internal/store/pgsql.go index 44589a6..f2bf07b 100644 --- a/sundynix-gateway/internal/store/pgsql.go +++ b/sundynix-gateway/internal/store/pgsql.go @@ -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{} }