feat(studio): 完善编排 —— 检索接本人知识库 + 真实模型下拉 + 模板/示例

把编排从"演示桩"接到真实平台:检索节点查本人 owner 隔离的知识库,节点下拉用真实数据。

- dispatcher:makeToolNode 用 task user_id 给检索类工具的 kb 加 owner 前缀("uid/kb"),
  编排里的「检索(RAG)」节点真正命中本人知识库(与隔离对齐)。
- 前端 StudioView:加 identity,载入 /kb/list 与 chat 模型作为「检索.kb」「Agent.model」下拉真值;
  Inspector 支持 dynamicOptions(无真值时提示去创建)。
- 编辑体验:示例(一键加载 输入→检索→Agent→输出 可运行图)/ 清空 / 模板名+保存(localStorage,
  含布局)/ 载入下拉;ReactFlow deleteKeyCode 支持 Del/Backspace 删节点。

验证:示例图运行 → gateway 发布任务 → dispatcher 编译 → mcp-go 日志 `tool=wiki_search
args=[kb:wt/default ...]`(kb 已按 owner 作用域)→ 命中本人库 → DeepSeek 流式作答;
底部抽屉 完成 ✓ · 工具调用 1。tsc+vite+dispatcher build 通过;重建 .app。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-13 16:14:54 +08:00
parent a222ca5f9e
commit 337d4d7619
5 changed files with 172 additions and 35 deletions
+1 -1
View File
@@ -124,7 +124,7 @@ export default function App() {
{view === "home" ? (
<Home onSelect={setView} />
) : view === "studio" ? (
<StudioView onRun={onRun} phase={run.phase} />
<StudioView onRun={onRun} phase={run.phase} identity={identity} />
) : view === "kb" ? (
<KbView identity={identity} />
) : view === "report" ? (
+11
View File
@@ -126,6 +126,17 @@ export interface VaultDoc {
content: string;
}
// listChatModels: GET /api/v1/admin/models?kind=chat —— 已登记的对话模型名(供编排 Agent 节点选择)。
export async function listChatModels(): Promise<string[]> {
try {
const res = await fetch(`${GATEWAY}/api/v1/admin/models?kind=chat`);
const data = (await res.json()) as { models?: Array<{ model: string }> };
return (data.models ?? []).map((m) => m.model);
} catch {
return [];
}
}
// listVault: GET /api/v1/kb/vault —— 某知识库的原始文档(Obsidian 式文库浏览)。
export async function listVault(id: Identity, kb: string): Promise<VaultDoc[]> {
const res = await fetch(`${GATEWAY}/api/v1/kb/vault?kb=${encodeURIComponent(kb)}`, { headers: idHeaders(id) });
@@ -9,10 +9,12 @@ export function Inspector({
node,
onChange,
onDelete,
dynamicOptions,
}: {
node: Node | null;
onChange: (id: string, patch: Record<string, unknown>) => void;
onDelete: (id: string) => void;
dynamicOptions?: Record<string, string[]>; // 运行时选项(如 kb=真实知识库、model=已登记模型)
}) {
if (!node) {
return (
@@ -48,11 +50,18 @@ export function Inspector({
{f.label}
{f.required && <span className="text-rose-400"> *</span>}
{f.type === "select" ? (
<select className={inputCls} value={String(v ?? "")} onChange={(e) => setConfig(f.key, e.target.value)}>
{f.options?.map((o) => (
<option key={o}>{o}</option>
))}
</select>
(() => {
const dyn = dynamicOptions?.[f.key];
const opts = dyn && dyn.length ? dyn : f.options ?? [];
return (
<select className={inputCls} value={String(v ?? "")} onChange={(e) => setConfig(f.key, e.target.value)}>
{dyn && dyn.length === 0 && <option value="">/</option>}
{opts.map((o) => (
<option key={o}>{o}</option>
))}
</select>
);
})()
) : f.type === "textarea" ? (
<textarea
className={`${inputCls} h-16 resize-none`}
@@ -13,28 +13,73 @@ import {
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { Play, ShieldCheck } from "lucide-react";
import { Play, ShieldCheck, Sparkles, Trash2, Save, FolderOpen } 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 { Button } from "../ui";
import { listKb, listChatModels, type Identity } from "../lib/api";
import { Button, Input, useToast } from "../ui";
let seq = 0;
// 编排 Studio:左节点面板 · 中画布 · 右检查器 · 顶工具栏。
export function StudioView({
onRun,
phase,
}: {
onRun: (dsl: TaskDsl) => void;
phase: RunPhase;
}) {
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<string, unknown>): Node => ({
id,
type: "typed",
position: { x, y: 140 },
data: { kind, label: NODE_KINDS[kind].label, config: { ...NODE_KINDS[kind].defaults, ...cfg }, status: "idle" as NodeStatus },
});
return {
nodes: [
mk("ex-in", "input", 40, { text: "sundynix-agentix 的架构是怎样的?" }),
mk("ex-rag", "retriever", 300, { kb: "default", topK: 4 }),
mk("ex-agent", "agent", 560, { system: "你是知识库问答助手,依据检索到的资料严谨作答。" }),
mk("ex-out", "output", 820, {}),
],
edges: [
{ id: "ex-e1", source: "ex-in", target: "ex-rag" },
{ id: "ex-e2", source: "ex-rag", target: "ex-agent" },
{ id: "ex-e3", source: "ex-agent", target: "ex-out" },
],
};
}
// 编排 Studio:左节点面板 · 中画布 · 右检查器 · 顶工具栏(运行/校验/模板)。
export function StudioView({ onRun, phase, identity }: { onRun: (dsl: TaskDsl) => void; phase: RunPhase; identity: Identity }) {
const toast = useToast();
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const [selId, setSelId] = useState<string | null>(null);
const [issues, setIssues] = useState<Issue[] | null>(null);
const [kbOpts, setKbOpts] = useState<string[]>([]);
const [modelOpts, setModelOpts] = useState<string[]>([]);
const [tplName, setTplName] = useState("");
const [tpls, setTpls] = useState<Template[]>(loadTpls());
// 载入真实知识库 / 对话模型作为节点下拉选项。
useEffect(() => {
listKb(identity).then((ks) => setKbOpts(ks.map((k) => k.name))).catch(() => {});
listChatModels().then(setModelOpts).catch(() => {});
}, [identity]);
const dynamicOptions = useMemo(() => ({ kb: kbOpts, model: modelOpts }), [kbOpts, modelOpts]);
const onConnect = useCallback((c: Connection) => setEdges((es) => addEdge(c, es)), [setEdges]);
@@ -70,16 +115,48 @@ export function StudioView({
[setNodes, setEdges],
);
// 运行状态映射到节点徽标:流式中全部 running,完成 done,空闲清除。
const loadGraph = useCallback(
(g: { nodes: Node[]; edges: Edge[] }) => {
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) {
const m = /^n(\d+)$/.exec(n.id);
if (m) seq = Math.max(seq, Number(m[1]));
}
},
[setNodes, setEdges],
);
const clear = () => {
setNodes([]);
setEdges([]);
setSelId(null);
setIssues(null);
};
const saveTpl = () => {
const name = tplName.trim();
if (!name) {
toast.push("error", "先填模板名");
return;
}
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";
phase === "streaming" || phase === "submitting" ? "running" : phase === "done" ? "done" : phase === "error" ? "error" : "idle";
setNodes((ns) => ns.map((n) => ({ ...n, data: { ...n.data, status } })));
}, [phase, setNodes]);
@@ -114,23 +191,54 @@ export function StudioView({
{/* 中画布 */}
<div className="relative flex-1 bg-ink-950">
<div className="absolute left-0 right-0 top-0 z-10 flex items-center gap-2 border-b border-line bg-ink-900/90 px-2 py-1.5 backdrop-blur">
<div className="absolute left-0 right-0 top-0 z-10 flex flex-wrap items-center gap-1.5 border-b border-line bg-ink-900/90 px-2 py-1.5 backdrop-blur">
<Button variant="primary" size="sm" icon={Play} onClick={run} disabled={running || nodes.length === 0}>
{running ? "运行中…" : "运行"}
</Button>
<Button size="sm" icon={ShieldCheck} onClick={() => setIssues(validate(nodes, edges))}>
</Button>
<span className="ml-1 text-[11px] text-slate-500">
<span className="mx-1 h-4 w-px bg-line" />
<Button size="sm" icon={Sparkles} onClick={() => loadGraph(buildExample())}>
</Button>
<Button size="sm" icon={Trash2} onClick={clear} disabled={nodes.length === 0}>
</Button>
<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="模板名" />
<Button size="sm" icon={Save} onClick={saveTpl}>
</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">
{nodes.length} · {edges.length} 线
</span>
{issues && (
<span className="ml-auto text-[11px]">
{issues.length === 0 ? (
<span className="text-success"> </span>
) : (
<span className="text-warn">{issues.length} </span>
)}
<span className="text-[11px]">
{issues.length === 0 ? <span className="text-success"> </span> : <span className="text-warn">{issues.length} </span>}
</span>
)}
</div>
@@ -145,6 +253,7 @@ export function StudioView({
onConnect={onConnect}
onNodeClick={(_, n) => setSelId(n.id)}
onPaneClick={() => setSelId(null)}
deleteKeyCode={["Backspace", "Delete"]}
fitView
>
<Background color="#242b3c" gap={18} />
@@ -165,7 +274,7 @@ export function StudioView({
{/* 右检查器 */}
<div className="w-72 shrink-0 border-l border-line bg-ink-900">
<Inspector node={selected} onChange={patchNode} onDelete={deleteNode} />
<Inspector node={selected} onChange={patchNode} onDelete={deleteNode} dynamicOptions={dynamicOptions} />
</div>
</div>
);
+10 -2
View File
@@ -69,7 +69,8 @@ func (o *Orchestrator) compileFlow(ctx context.Context, t *contract.Task, tr *ex
}
key := fmt.Sprintf("tool_%d", idx)
idx++
if err := g.AddLambdaNode(key, compose.InvokableLambda(o.makeToolNode(t.ID, tool, args, tr))); err != nil {
uid, _ := t.Meta[contract.MetaUserID].(string)
if err := g.AddLambdaNode(key, compose.InvokableLambda(o.makeToolNode(t.ID, tool, args, tr, uid))); err != nil {
return nil, err
}
if err := g.AddEdge(prev, key); err != nil {
@@ -111,7 +112,8 @@ func (o *Orchestrator) compileFlow(ctx context.Context, t *contract.Task, tr *ex
}
// makeToolNode 返回一个真实调用 MCP 工具的图节点:把结果增补进黑板,失败降级不阻断。
func (o *Orchestrator) makeToolNode(taskID, tool string, args map[string]any, tr *execTracer) func(context.Context, *RunCtx) (*RunCtx, error) {
// uid 非空时把检索类工具的 kb 锁进 owner 作用域("uid/kb"),使编排检索命中本人知识库。
func (o *Orchestrator) makeToolNode(taskID, tool string, args map[string]any, tr *execTracer, uid string) func(context.Context, *RunCtx) (*RunCtx, error) {
node := "tool:" + tool
return func(ctx context.Context, rc *RunCtx) (*RunCtx, error) {
if o.tools == nil {
@@ -126,6 +128,12 @@ func (o *Orchestrator) makeToolNode(taskID, tool string, args map[string]any, tr
if call["q"] == nil && call["query"] == nil {
call["q"] = rc.Query
}
// 检索类工具的 kb 按 owner 作用域,对齐知识库隔离(前端只发库名)。
if uid != "" {
if kbv, ok := call["kb"].(string); ok && kbv != "" && !strings.Contains(kbv, "/") {
call["kb"] = uid + "/" + kbv
}
}
end := tr.span(node, "tool", "调用工具 "+tool)
cctx, cancel := context.WithTimeout(ctx, toolCallTimeout)
defer cancel()