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:
@@ -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" ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user