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:
@@ -126,6 +126,38 @@ export interface VaultDoc {
|
|||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 我的 Agent 编排(服务端保存,owner 隔离)----
|
||||||
|
export interface AgentInfo {
|
||||||
|
name: string;
|
||||||
|
graph: string; // {nodes,edges} JSON
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAgents(id: Identity): Promise<AgentInfo[]> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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 节点选择)。
|
// listChatModels: GET /api/v1/admin/models?kind=chat —— 已登记的对话模型名(供编排 Agent 节点选择)。
|
||||||
export async function listChatModels(): Promise<string[]> {
|
export async function listChatModels(): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -13,31 +13,17 @@ import {
|
|||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
import "@xyflow/react/dist/style.css";
|
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 { nodeTypes, type NodeStatus } from "./TypedNode";
|
||||||
import { Inspector } from "./Inspector";
|
import { Inspector } from "./Inspector";
|
||||||
import { NODE_KINDS, NODE_ORDER } from "./nodeCatalog";
|
import { NODE_KINDS, NODE_ORDER } from "./nodeCatalog";
|
||||||
import { exportDsl, validate, type Issue, type TaskDsl } from "../lib/dsl";
|
import { exportDsl, validate, type Issue, type TaskDsl } from "../lib/dsl";
|
||||||
import type { RunPhase } from "../lib/run";
|
import type { RunPhase } from "../lib/run";
|
||||||
import { listKb, listChatModels, type Identity } from "../lib/api";
|
import { listKb, listChatModels, listAgents, saveAgent, deleteAgent, type Identity, type AgentInfo } from "../lib/api";
|
||||||
import { Button, Input, useToast } from "../ui";
|
import { Button, Input, cn, useToast } from "../ui";
|
||||||
|
|
||||||
let seq = 0;
|
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 → 输出)。
|
// buildExample:一张可直接运行的示例图(输入 → 检索本人 default 库 → Agent → 输出)。
|
||||||
function buildExample(): { nodes: Node[]; edges: Edge[] } {
|
function buildExample(): { nodes: Node[]; edges: Edge[] } {
|
||||||
const mk = (id: string, kind: string, x: number, cfg: Record<string, unknown>): Node => ({
|
const mk = (id: string, kind: string, x: number, cfg: Record<string, unknown>): 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 }) {
|
export function StudioView({ onRun, phase, identity }: { onRun: (dsl: TaskDsl) => void; phase: RunPhase; identity: Identity }) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
||||||
@@ -70,15 +56,19 @@ export function StudioView({ onRun, phase, identity }: { onRun: (dsl: TaskDsl) =
|
|||||||
const [issues, setIssues] = useState<Issue[] | null>(null);
|
const [issues, setIssues] = useState<Issue[] | null>(null);
|
||||||
const [kbOpts, setKbOpts] = useState<string[]>([]);
|
const [kbOpts, setKbOpts] = useState<string[]>([]);
|
||||||
const [modelOpts, setModelOpts] = useState<string[]>([]);
|
const [modelOpts, setModelOpts] = useState<string[]>([]);
|
||||||
const [tplName, setTplName] = useState("");
|
const [agents, setAgents] = useState<AgentInfo[]>([]);
|
||||||
const [tpls, setTpls] = useState<Template[]>(loadTpls());
|
const [name, setName] = useState("");
|
||||||
|
|
||||||
// 载入真实知识库 / 对话模型作为节点下拉选项。
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listKb(identity).then((ks) => setKbOpts(ks.map((k) => k.name))).catch(() => {});
|
listKb(identity).then((ks) => setKbOpts(ks.map((k) => k.name))).catch(() => {});
|
||||||
listChatModels().then(setModelOpts).catch(() => {});
|
listChatModels().then(setModelOpts).catch(() => {});
|
||||||
}, [identity]);
|
}, [identity]);
|
||||||
|
|
||||||
|
const refreshAgents = useCallback(() => {
|
||||||
|
listAgents(identity).then(setAgents).catch(() => {});
|
||||||
|
}, [identity]);
|
||||||
|
useEffect(() => refreshAgents(), [refreshAgents]);
|
||||||
|
|
||||||
const dynamicOptions = useMemo(() => ({ kb: kbOpts, model: modelOpts }), [kbOpts, modelOpts]);
|
const dynamicOptions = useMemo(() => ({ kb: kbOpts, model: modelOpts }), [kbOpts, modelOpts]);
|
||||||
|
|
||||||
const onConnect = useCallback((c: Connection) => setEdges((es) => addEdge(c, es)), [setEdges]);
|
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(
|
const loadGraph = useCallback(
|
||||||
(g: { nodes: Node[]; edges: Edge[] }) => {
|
(g: { nodes: Node[]; edges: Edge[] }) => {
|
||||||
setNodes(g.nodes.map((n) => ({ ...n, data: { ...n.data, status: "idle" as NodeStatus } })));
|
setNodes((g.nodes ?? []).map((n) => ({ ...n, data: { ...n.data, status: "idle" as NodeStatus } })));
|
||||||
setEdges(g.edges);
|
setEdges(g.edges ?? []);
|
||||||
setSelId(null);
|
setSelId(null);
|
||||||
setIssues(null);
|
setIssues(null);
|
||||||
// 让自增 id 越过已载入节点,避免碰撞。
|
for (const n of g.nodes ?? []) {
|
||||||
for (const n of g.nodes) {
|
|
||||||
const m = /^n(\d+)$/.exec(n.id);
|
const m = /^n(\d+)$/.exec(n.id);
|
||||||
if (m) seq = Math.max(seq, Number(m[1]));
|
if (m) seq = Math.max(seq, Number(m[1]));
|
||||||
}
|
}
|
||||||
@@ -137,23 +126,38 @@ export function StudioView({ onRun, phase, identity }: { onRun: (dsl: TaskDsl) =
|
|||||||
setIssues(null);
|
setIssues(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveTpl = () => {
|
const save = async () => {
|
||||||
const name = tplName.trim();
|
const nm = name.trim();
|
||||||
if (!name) {
|
if (!nm) return toast.push("error", "先填编排名");
|
||||||
toast.push("error", "先填模板名");
|
if (nodes.length === 0) return toast.push("error", "画布为空");
|
||||||
return;
|
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(() => {
|
useEffect(() => {
|
||||||
const status: NodeStatus =
|
const status: NodeStatus =
|
||||||
phase === "streaming" || phase === "submitting" ? "running" : phase === "done" ? "done" : phase === "error" ? "error" : "idle";
|
phase === "streaming" || phase === "submitting" ? "running" : phase === "done" ? "done" : phase === "error" ? "error" : "idle";
|
||||||
@@ -171,8 +175,9 @@ export function StudioView({ onRun, phase, identity }: { onRun: (dsl: TaskDsl) =
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
{/* 左节点面板 */}
|
{/* 左:节点面板 + 我的编排 */}
|
||||||
<div className="w-40 shrink-0 overflow-auto border-r border-line bg-ink-900 p-2">
|
<div className="flex w-44 shrink-0 flex-col border-r border-line bg-ink-900">
|
||||||
|
<div className="overflow-auto p-2">
|
||||||
<div className="mb-1 px-1 text-[11px] font-semibold text-slate-500">节点</div>
|
<div className="mb-1 px-1 text-[11px] font-semibold text-slate-500">节点</div>
|
||||||
{NODE_ORDER.map((kind) => {
|
{NODE_ORDER.map((kind) => {
|
||||||
const k = NODE_KINDS[kind];
|
const k = NODE_KINDS[kind];
|
||||||
@@ -188,6 +193,25 @@ export function StudioView({ onRun, phase, identity }: { onRun: (dsl: TaskDsl) =
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col border-t border-line p-2">
|
||||||
|
<div className="mb-1 flex items-center gap-1 px-1 text-[11px] font-semibold text-slate-500">
|
||||||
|
<Workflow className="h-3.5 w-3.5" /> 我的编排 {agents.length > 0 && `(${agents.length})`}
|
||||||
|
</div>
|
||||||
|
<ul className="min-h-0 flex-1 space-y-0.5 overflow-auto">
|
||||||
|
{agents.length === 0 && <li className="px-1 text-[11px] text-slate-600">保存后在此列出,跨会话可载入。</li>}
|
||||||
|
{agents.map((a) => (
|
||||||
|
<li key={a.name} className="group flex items-center gap-1 rounded hover:bg-ink-800">
|
||||||
|
<button onClick={() => openAgent(a)} className="flex-1 truncate px-2 py-1.5 text-left text-xs text-slate-300" title={`载入「${a.name}」`}>
|
||||||
|
{a.name}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => removeAgent(a.name)} className="px-1 text-slate-600 opacity-0 hover:text-danger group-hover:opacity-100" title="删除">
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 中画布 */}
|
{/* 中画布 */}
|
||||||
<div className="relative flex-1 bg-ink-950">
|
<div className="relative flex-1 bg-ink-950">
|
||||||
@@ -206,33 +230,10 @@ export function StudioView({ onRun, phase, identity }: { onRun: (dsl: TaskDsl) =
|
|||||||
清空
|
清空
|
||||||
</Button>
|
</Button>
|
||||||
<span className="mx-1 h-4 w-px bg-line" />
|
<span className="mx-1 h-4 w-px bg-line" />
|
||||||
<Input className="h-8 w-28" value={tplName} onChange={(e) => setTplName(e.target.value)} placeholder="模板名" />
|
<Input className="h-8 w-32" value={name} onChange={(e) => setName(e.target.value)} placeholder="编排名" />
|
||||||
<Button size="sm" icon={Save} onClick={saveTpl}>
|
<Button size="sm" variant="primary" icon={Save} onClick={save} disabled={nodes.length === 0}>
|
||||||
保存
|
保存
|
||||||
</Button>
|
</Button>
|
||||||
{tpls.length > 0 && (
|
|
||||||
<div className="relative">
|
|
||||||
<select
|
|
||||||
className="h-8 rounded-md border border-line bg-ink-800 px-2 pr-6 text-xs text-slate-300 focus:border-brand focus:outline-none"
|
|
||||||
value=""
|
|
||||||
onChange={(e) => {
|
|
||||||
const t = tpls.find((x) => x.name === e.target.value);
|
|
||||||
if (t) {
|
|
||||||
loadGraph(t);
|
|
||||||
setTplName(t.name);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">载入模板…</option>
|
|
||||||
{tpls.map((t) => (
|
|
||||||
<option key={t.name} value={t.name}>
|
|
||||||
{t.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<FolderOpen className="pointer-events-none absolute right-1.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<span className="ml-auto text-[11px] text-slate-500">
|
<span className="ml-auto text-[11px] text-slate-500">
|
||||||
{nodes.length} 节点 · {edges.length} 连线
|
{nodes.length} 节点 · {edges.length} 连线
|
||||||
</span>
|
</span>
|
||||||
@@ -264,7 +265,7 @@ export function StudioView({ onRun, phase, identity }: { onRun: (dsl: TaskDsl) =
|
|||||||
{issues && issues.length > 0 && (
|
{issues && issues.length > 0 && (
|
||||||
<div className="absolute bottom-2 left-2 max-w-md rounded-lg border border-line bg-ink-850 p-2 text-[11px] shadow-card">
|
<div className="absolute bottom-2 left-2 max-w-md rounded-lg border border-line bg-ink-850 p-2 text-[11px] shadow-card">
|
||||||
{issues.map((i, idx) => (
|
{issues.map((i, idx) => (
|
||||||
<div key={idx} className={i.level === "error" ? "text-danger" : "text-warn"}>
|
<div key={idx} className={cn(i.level === "error" ? "text-danger" : "text-warn")}>
|
||||||
{i.level === "error" ? "✗" : "⚠"} {i.msg}
|
{i.level === "error" ? "✗" : "⚠"} {i.msg}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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.POST("/kb/note", h.KbSaveNote) // 新建/编辑笔记(落库 + 按 doc 重入库)
|
||||||
api.GET("/kb/graph", h.KbGraph) // 知识图谱三元组(→ mcp-go kb_graph,Neo4j)
|
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.POST("/reports", h.GenerateReport) // 报告生成(intent=report 任务 → Dispatcher 专用编排)
|
||||||
api.GET("/reports/:id/download", h.DownloadReport) // 下载渲染好的 Word(.docx)
|
api.GET("/reports/:id/download", h.DownloadReport) // 下载渲染好的 Word(.docx)
|
||||||
api.GET("/health", h.Health) // 依赖健康聚合(顶栏五盏灯)
|
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
|
}).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 阅读 + 双链)。
|
// Doc 是入库的一份原始文档/笔记(供 Obsidian 式"文库"浏览:列表 + Markdown 阅读 + 双链)。
|
||||||
// 表名 sundynix_doc。(owner,kb,name) 唯一;按 owner 隔离。
|
// 表名 sundynix_doc。(owner,kb,name) 唯一;按 owner 隔离。
|
||||||
type Doc struct {
|
type Doc struct {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func OpenPostgres(dsn string) *Postgres {
|
|||||||
log.Printf("[store] postgres 不可用,降级运行(不持久化): %v", err)
|
log.Printf("[store] postgres 不可用,降级运行(不持久化): %v", err)
|
||||||
return &Postgres{}
|
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)
|
log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err)
|
||||||
return &Postgres{}
|
return &Postgres{}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user