feat(observability): 执行可视化 — 节点级实时轨迹(运行·观测)

把任务执行做成可观测:Dispatcher 在每个节点/阶段发结构化 ExecEvent,
经独立 NATS 通道回流,前端逐节点点亮(状态/耗时/工具入参产出)。

- shared: contract.ExecEvent + ExecSubject(sundynix.exec.<id>,与 Token 流分流);
  bus.PublishExec/CompleteExec/SubscribeExec(core NATS,复用结束头)
- dispatcher: execTracer(自增 Seq 保序 + span 自动计耗时);
  Orchestrator 加 ExecSink;通用图(init 召回 / 各 tool 入参→产出 / prompt / model
  首token+token数)与报告编排(规划大纲 / 各章并行 start-end / 渲染)全程埋点
- gateway: SubscribeExec + GET /tasks/:id/exec SSE(与 token 流并行)
- desktop: streamExec + deriveNodes(按 node 归并 start/end/error/info);
  复用组件 ExecTrace(竖向轨道,按 kind 着色,运行中脉冲灯);
  新 RunsView(运行·观测:轨迹+输出双栏);BottomDrawer 轨迹/工具调用 tab 接真实数据;
  ReportView 加执行轨迹栏;左导航「运行」置就绪

实测:
- 报告任务 /exec:规划(2680ms,4章) → 4 章并行(seq 交错,各~7-8s 重叠=真并行,
  每章带 docs 知识库检索预览+成稿字数) → 渲染(docx 落盘)
- 通用图 /exec:tool:kb_search(678ms,入参→Milvus 产出) → prompt(2消息) →
  model(首token 860ms / 4 tokens)
- 浏览器(Preview):报告页执行轨迹逐节点点亮、章节带耗时/字数/检索片段,完成后下载 Word

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-12 14:29:28 +08:00
parent ba8c6b3c43
commit cdc5b3a847
19 changed files with 587 additions and 63 deletions
+15 -2
View File
@@ -7,9 +7,10 @@ import { StudioView } from "./studio/StudioView";
import { MemoryView } from "./views/MemoryView"; import { MemoryView } from "./views/MemoryView";
import { KbView } from "./views/KbView"; import { KbView } from "./views/KbView";
import { ReportView } from "./views/ReportView"; import { ReportView } from "./views/ReportView";
import { RunsView } from "./views/RunsView";
import { Home } from "./views/Home"; import { Home } from "./views/Home";
import { Placeholder } from "./views/Placeholder"; import { Placeholder } from "./views/Placeholder";
import { submitTask, streamTokens, type Identity } from "./lib/api"; import { submitTask, streamTokens, streamExec, type Identity } from "./lib/api";
import type { TaskDsl } from "./lib/dsl"; import type { TaskDsl } from "./lib/dsl";
import { emptyRun, type RunState } from "./lib/run"; import { emptyRun, type RunState } from "./lib/run";
@@ -28,11 +29,14 @@ export default function App() {
const [run, setRun] = useState<RunState>(emptyRun); const [run, setRun] = useState<RunState>(emptyRun);
const closeRef = useRef<(() => void) | null>(null); const closeRef = useRef<(() => void) | null>(null);
const execCloseRef = useRef<(() => void) | null>(null);
const onRun = useCallback( const onRun = useCallback(
async (dsl: TaskDsl) => { async (dsl: TaskDsl) => {
closeRef.current?.(); closeRef.current?.();
execCloseRef.current?.();
const t0 = Date.now(); const t0 = Date.now();
setRun({ phase: "submitting", output: "", events: [{ t: 0, label: "提交任务" }] }); setRun({ phase: "submitting", output: "", events: [{ t: 0, label: "提交任务" }], exec: [] });
try { try {
const taskId = await submitTask(dsl, identity); const taskId = await submitTask(dsl, identity);
let first = true; let first = true;
@@ -42,6 +46,13 @@ export default function App() {
taskId, taskId,
events: [...r.events, { t: Date.now() - t0, label: `已发布 ${taskId}` }], events: [...r.events, { t: Date.now() - t0, label: `已发布 ${taskId}` }],
})); }));
// 执行轨迹(运行·观测):与 token 流并行订阅,逐节点点亮。
execCloseRef.current = streamExec(
taskId,
(ev) => setRun((r) => ({ ...r, exec: [...r.exec, ev] })),
() => {},
() => {},
);
closeRef.current = streamTokens( closeRef.current = streamTokens(
taskId, taskId,
(tok) => (tok) =>
@@ -84,6 +95,8 @@ export default function App() {
<KbView /> <KbView />
) : view === "report" ? ( ) : view === "report" ? (
<ReportView identity={identity} /> <ReportView identity={identity} />
) : view === "runs" ? (
<RunsView run={run} />
) : view === "memory" ? ( ) : view === "memory" ? (
<MemoryView identity={identity} /> <MemoryView identity={identity} />
) : ( ) : (
@@ -0,0 +1,92 @@
import { deriveNodes, type NodeTrace, type RunPhase } from "../lib/run";
import type { ExecEvent } from "../lib/api";
// 各节点类别的图标与配色(与后端 ExecEvent.kind 对应)。
const KIND: Record<string, { icon: string; cls: string; name: string }> = {
system: { icon: "▸", cls: "text-slate-400", name: "系统" },
memory: { icon: "◇", cls: "text-violet-300", name: "记忆召回" },
tool: { icon: "⚙", cls: "text-amber-300", name: "工具调用" },
prompt: { icon: "▤", cls: "text-sky-300", name: "提示词" },
model: { icon: "✦", cls: "text-emerald-300", name: "模型推理" },
plan: { icon: "◷", cls: "text-cyan-300", name: "规划" },
section: { icon: "¶", cls: "text-indigo-300", name: "章节" },
render: { icon: "▦", cls: "text-rose-300", name: "渲染" },
};
function meta(kind: string) {
return KIND[kind] ?? { icon: "•", cls: "text-slate-400", name: kind };
}
function StatusDot({ status }: { status: NodeTrace["status"] }) {
if (status === "running")
return <span className="h-2.5 w-2.5 animate-pulse rounded-full bg-cyan-400 shadow-[0_0_8px_rgba(34,211,238,0.8)]" />;
if (status === "done") return <span className="h-2.5 w-2.5 rounded-full bg-emerald-400" />;
if (status === "error") return <span className="h-2.5 w-2.5 rounded-full bg-rose-500" />;
return <span className="h-2.5 w-2.5 rounded-full bg-slate-600" />;
}
// ExecTrace 把执行事件流渲染为竖向轨道:每个节点一颗灯,实时点亮 + 耗时 + 入参/产出。
export function ExecTrace({
events,
phase,
compact,
}: {
events: ExecEvent[];
phase?: RunPhase;
compact?: boolean;
}) {
const nodes = deriveNodes(events);
if (nodes.length === 0) {
return (
<div className="flex h-full items-center justify-center px-4 py-8 text-center text-xs text-slate-600">
/
<br />
Word
</div>
);
}
const total = nodes.reduce((s, n) => s + (n.ms ?? 0), 0);
return (
<div className={compact ? "" : "p-1"}>
{!compact && (
<div className="mb-2 flex items-center gap-3 px-1 text-[11px] text-slate-500">
<span>{nodes.length} </span>
<span>·</span>
<span> {total} ms</span>
{phase === "streaming" && <span className="text-cyan-400"> </span>}
{phase === "done" && <span className="text-emerald-400"> </span>}
{phase === "error" && <span className="text-rose-400"> </span>}
</div>
)}
<ol className="relative ml-2 space-y-2 border-l border-line pl-4">
{nodes.map((n) => {
const m = meta(n.kind);
return (
<li key={n.node} className="relative">
<span className="absolute -left-[22px] top-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-ink-900">
<StatusDot status={n.status} />
</span>
<div className="rounded-lg border border-line bg-ink-950/60 px-3 py-2">
<div className="flex items-center gap-2">
<span className={`text-sm leading-none ${m.cls}`}>{m.icon}</span>
<span className="text-xs font-medium text-slate-200">{n.label}</span>
<span className={`rounded px-1.5 py-0.5 text-[9px] ${m.cls} bg-white/5`}>{m.name}</span>
{n.ms != null && n.ms > 0 && (
<span className="ml-auto font-mono text-[10px] text-slate-500">{n.ms} ms</span>
)}
{n.status === "running" && <span className="ml-auto text-[10px] text-cyan-400"></span>}
</div>
{n.detail && <p className="mt-1 break-words text-[11px] leading-relaxed text-slate-400">{n.detail}</p>}
{n.notes.map((note, i) => (
<p key={i} className="mt-1 break-words border-l border-line pl-2 text-[11px] text-slate-500">
{note}
</p>
))}
</div>
</li>
);
})}
</ol>
</div>
);
}
+32
View File
@@ -47,6 +47,38 @@ export function streamTokens(
return () => es.close(); return () => es.close();
} }
// 执行轨迹事件(与后端 contract.ExecEvent 对应):运行·观测的节点级实时事件。
export interface ExecEvent {
seq: number;
ts: number;
node: string; // init / tool:wiki_search / prompt / model / plan / section:0 / render / task / compile
kind: string; // memory|tool|prompt|model|plan|section|render|system
phase: string; // start|end|error|info
label: string;
detail?: string;
ms?: number;
}
// streamExec: 订阅 SSE /api/v1/tasks/:id/exec —— 与 token 流并行,逐节点点亮执行轨迹。
export function streamExec(
taskId: string,
onEvent: (ev: ExecEvent) => void,
onDone: () => void,
onError?: (e: unknown) => void,
): () => void {
const es = new EventSource(`${GATEWAY}/api/v1/tasks/${taskId}/exec`);
es.addEventListener("exec", (e) => onEvent(JSON.parse((e as MessageEvent).data) as ExecEvent));
es.addEventListener("done", () => {
es.close();
onDone();
});
es.onerror = (e) => {
es.close();
onError?.(e);
};
return () => es.close();
}
// 入库进度事件(与后端 contract.IngestEvent 对应)。 // 入库进度事件(与后端 contract.IngestEvent 对应)。
export interface IngestEvent { export interface IngestEvent {
stage: string; stage: string;
+54 -1
View File
@@ -1,4 +1,6 @@
// 运行状态 —— 跨 Studio 与底部抽屉共享。 // 运行状态 —— 跨 Studio 与底部抽屉共享。
import type { ExecEvent } from "./api";
export type RunPhase = "idle" | "submitting" | "streaming" | "done" | "error"; export type RunPhase = "idle" | "submitting" | "streaming" | "done" | "error";
export interface RunEvent { export interface RunEvent {
@@ -11,7 +13,58 @@ export interface RunState {
taskId?: string; taskId?: string;
output: string; output: string;
events: RunEvent[]; events: RunEvent[];
exec: ExecEvent[]; // 后端回流的节点级执行轨迹(运行·观测)
error?: string; error?: string;
} }
export const emptyRun: RunState = { phase: "idle", output: "", events: [] }; export const emptyRun: RunState = { phase: "idle", output: "", events: [], exec: [] };
// ---- 执行轨迹派生:把扁平 ExecEvent 流归并为按节点聚合的轨迹 ----
export type NodeStatus = "running" | "done" | "error" | "info";
export interface NodeTrace {
node: string;
kind: string; // memory|tool|prompt|model|plan|section|render|system
label: string;
status: NodeStatus;
ms?: number;
detail?: string;
notes: string[]; // info 子事件(如检索到的参考资料预览)
order: number;
}
// deriveNodes 把事件流按 node 归并:start→runningend→done(带耗时)error→errorinfo→点事件/附注。
export function deriveNodes(events: ExecEvent[]): NodeTrace[] {
const map = new Map<string, NodeTrace>();
let order = 0;
for (const e of events) {
let n = map.get(e.node);
if (!n) {
n = { node: e.node, kind: e.kind, label: e.label, status: "info", notes: [], order: order++ };
map.set(e.node, n);
}
if (e.label) n.label = e.label;
n.kind = e.kind;
switch (e.phase) {
case "start":
if (n.status !== "done" && n.status !== "error") n.status = "running";
break;
case "end":
n.status = "done";
n.ms = e.ms;
if (e.detail) n.detail = e.detail;
break;
case "error":
n.status = "error";
n.ms = e.ms;
if (e.detail) n.detail = e.detail;
break;
case "info":
if (e.detail) n.notes.push(e.detail);
else if (e.label) n.notes.push(e.label);
break;
}
}
return [...map.values()].sort((a, b) => a.order - b.order);
}
@@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import type { RunState } from "../lib/run"; import { deriveNodes, type RunState } from "../lib/run";
import { ExecTrace } from "../components/ExecTrace";
type Tab = "output" | "trace" | "tools" | "cite" | "eval"; type Tab = "output" | "trace" | "tools" | "cite" | "eval";
const TABS: Array<{ key: Tab; label: string }> = [ const TABS: Array<{ key: Tab; label: string }> = [
@@ -43,24 +44,14 @@ export function BottomDrawer({ run }: { run: RunState }) {
</button> </button>
</div> </div>
{open && ( {open && (
<div className="h-40 overflow-auto p-3 text-xs"> <div className="h-44 overflow-auto p-3 text-xs">
{tab === "output" && ( {tab === "output" && (
<pre className="whitespace-pre-wrap font-mono leading-relaxed text-emerald-300"> <pre className="whitespace-pre-wrap font-mono leading-relaxed text-emerald-300">
{run.output || "在编排页搭图 → 运行,模型注入画像与历史后流式作答,token 在此呈现。"} {run.output || "在编排页搭图 → 运行,模型注入画像与历史后流式作答,token 在此呈现。"}
</pre> </pre>
)} )}
{tab === "trace" && ( {tab === "trace" && <ExecTrace events={run.exec} phase={run.phase} />}
<ul className="space-y-1 text-slate-400"> {tab === "tools" && <ToolCalls run={run} />}
{run.events.length === 0 && <li className="text-slate-600"></li>}
{run.events.map((e, i) => (
<li key={i}>
<span className="text-slate-600">+{e.t}ms</span> · {e.label}
</li>
))}
{run.events.length > 0 && <li className="mt-2 text-[11px] text-slate-600"></li>}
</ul>
)}
{tab === "tools" && <p className="text-slate-600"> sundynix.tools.* /</p>}
{tab === "cite" && <p className="text-slate-600">RAG + + </p>} {tab === "cite" && <p className="text-slate-600">RAG + + </p>}
{tab === "eval" && <p className="text-slate-600"> / harness eval</p>} {tab === "eval" && <p className="text-slate-600"> / harness eval</p>}
</div> </div>
@@ -68,3 +59,32 @@ export function BottomDrawer({ run }: { run: RunState }) {
</div> </div>
); );
} }
// ToolCalls:从执行事件里筛出工具调用节点,逐条展示入参 → 产出 + 耗时/状态。
function ToolCalls({ run }: { run: RunState }) {
const tools = deriveNodes(run.exec).filter((n) => n.kind === "tool");
if (tools.length === 0) {
return <p className="text-slate-600">/ sundynix.tools.* </p>;
}
return (
<ul className="space-y-1.5">
{tools.map((t) => (
<li key={t.node} className="rounded-lg border border-line bg-ink-950/60 px-3 py-2">
<div className="flex items-center gap-2">
<span className="text-amber-300"></span>
<span className="font-mono text-[11px] text-slate-200">{t.node.replace(/^tool:/, "")}</span>
<span
className={`rounded px-1.5 py-0.5 text-[9px] ${
t.status === "error" ? "bg-rose-500/15 text-rose-300" : t.status === "running" ? "bg-cyan-500/15 text-cyan-300" : "bg-emerald-500/15 text-emerald-300"
}`}
>
{t.status === "error" ? "失败" : t.status === "running" ? "调用中" : "成功"}
</span>
{t.ms != null && t.ms > 0 && <span className="ml-auto font-mono text-[10px] text-slate-500">{t.ms} ms</span>}
</div>
{t.detail && <p className="mt-1 break-words text-[11px] leading-relaxed text-slate-400">{t.detail}</p>}
</li>
))}
</ul>
);
}
@@ -21,7 +21,7 @@ const ITEMS: Item[] = [
{ key: "studio", label: "编排", icon: "◆", group: "BUILD", ready: true }, { key: "studio", label: "编排", icon: "◆", group: "BUILD", ready: true },
{ key: "kb", label: "知识库", icon: "▣", group: "BUILD", ready: true }, { key: "kb", label: "知识库", icon: "▣", group: "BUILD", ready: true },
{ key: "report", label: "报告", icon: "▦", group: "BUILD", ready: true }, { key: "report", label: "报告", icon: "▦", group: "BUILD", ready: true },
{ key: "runs", label: "运行", icon: "▸", group: "RUN" }, { key: "runs", label: "运行", icon: "▸", group: "RUN", ready: true },
{ key: "memory", label: "记忆", icon: "◇", group: "MANAGE", ready: true }, { key: "memory", label: "记忆", icon: "◇", group: "MANAGE", ready: true },
{ key: "market", label: "市场", icon: "⌧", group: "MANAGE" }, { key: "market", label: "市场", icon: "⌧", group: "MANAGE" },
{ key: "admin", label: "管理", icon: "⚙", group: "MANAGE" }, { key: "admin", label: "管理", icon: "⚙", group: "MANAGE" },
@@ -1,5 +1,6 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { generateReport, streamTokens, reportDownloadUrl, type Identity } from "../lib/api"; import { generateReport, streamTokens, streamExec, reportDownloadUrl, type Identity, type ExecEvent } from "../lib/api";
import { ExecTrace } from "../components/ExecTrace";
type Phase = "idle" | "running" | "done" | "error"; type Phase = "idle" | "running" | "done" | "error";
@@ -10,22 +11,32 @@ export function ReportView({ identity }: { identity: Identity }) {
const [kb, setKb] = useState(""); const [kb, setKb] = useState("");
const [phase, setPhase] = useState<Phase>("idle"); const [phase, setPhase] = useState<Phase>("idle");
const [out, setOut] = useState(""); const [out, setOut] = useState("");
const [exec, setExec] = useState<ExecEvent[]>([]);
const [taskId, setTaskId] = useState(""); const [taskId, setTaskId] = useState("");
const [err, setErr] = useState(""); const [err, setErr] = useState("");
const closeRef = useRef<(() => void) | null>(null); const closeRef = useRef<(() => void) | null>(null);
const execCloseRef = useRef<(() => void) | null>(null);
const running = phase === "running"; const running = phase === "running";
const onGenerate = async () => { const onGenerate = async () => {
if (!topic.trim() || running) return; if (!topic.trim() || running) return;
closeRef.current?.(); closeRef.current?.();
execCloseRef.current?.();
setPhase("running"); setPhase("running");
setOut(""); setOut("");
setExec([]);
setErr(""); setErr("");
setTaskId(""); setTaskId("");
try { try {
const id = await generateReport(identity, topic.trim(), kb.trim() || undefined); const id = await generateReport(identity, topic.trim(), kb.trim() || undefined);
setTaskId(id); setTaskId(id);
execCloseRef.current = streamExec(
id,
(ev) => setExec((xs) => [...xs, ev]),
() => {},
() => {},
);
closeRef.current = streamTokens( closeRef.current = streamTokens(
id, id,
(tok) => setOut((o) => o + tok), (tok) => setOut((o) => o + tok),
@@ -95,11 +106,19 @@ export function ReportView({ identity }: { identity: Identity }) {
</div> </div>
</div> </div>
{/* 实时进度 / 正文 */} {/* 执行轨迹 + 报告正文 */}
<div className="flex min-h-0 flex-1 flex-col rounded-xl border border-line bg-ink-900 shadow-card"> <div className="grid min-h-0 flex-1 grid-cols-[340px_1fr] gap-4">
<section className="flex min-h-0 flex-col rounded-xl border border-line bg-ink-900 shadow-card">
<div className="border-b border-line px-4 py-2.5 text-[11px] font-medium text-slate-400"></div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<ExecTrace events={exec} phase={running ? "streaming" : phase === "done" ? "done" : phase === "error" ? "error" : "idle"} />
</div>
</section>
<section className="flex min-h-0 flex-col rounded-xl border border-line bg-ink-900 shadow-card">
<div className="flex items-center gap-2 border-b border-line px-4 py-2.5 text-[11px] text-slate-500"> <div className="flex items-center gap-2 border-b border-line px-4 py-2.5 text-[11px] text-slate-500">
<span className={`h-2 w-2 rounded-full ${running ? "animate-pulse bg-cyan-400" : phase === "done" ? "bg-emerald-400" : "bg-slate-600"}`} /> <span className={`h-2 w-2 rounded-full ${running ? "animate-pulse bg-cyan-400" : phase === "done" ? "bg-emerald-400" : "bg-slate-600"}`} />
· {taskId || "未开始"} · {taskId || "未开始"}
</div> </div>
<div className="min-h-0 flex-1 overflow-y-auto p-4"> <div className="min-h-0 flex-1 overflow-y-auto p-4">
{out ? ( {out ? (
@@ -110,6 +129,7 @@ export function ReportView({ identity }: { identity: Identity }) {
</div> </div>
)} )}
</div> </div>
</section>
</div> </div>
</div> </div>
); );
@@ -0,0 +1,57 @@
import { ExecTrace } from "../components/ExecTrace";
import { deriveNodes, type RunState } from "../lib/run";
// 运行·观测:把最近一次运行的执行轨迹实时可视化(节点逐个点亮 + 工具入参/产出 + 耗时),
// 右侧并列模型输出。数据来自 Studio 运行时订阅的 sundynix.exec.<id> 事件流。
export function RunsView({ run }: { run: RunState }) {
const nodes = deriveNodes(run.exec);
const tools = nodes.filter((n) => n.kind === "tool");
const phaseText =
run.phase === "streaming" ? "执行中" : run.phase === "done" ? "完成" : run.phase === "error" ? "出错" : run.phase === "submitting" ? "提交中" : "就绪";
const phaseCls =
run.phase === "streaming" ? "text-cyan-400" : run.phase === "done" ? "text-emerald-400" : run.phase === "error" ? "text-rose-400" : "text-slate-500";
return (
<div className="flex h-full min-h-0 flex-col gap-4 overflow-hidden p-6">
<header className="flex items-center gap-3">
<div>
<h1 className="text-lg font-semibold text-slate-100"> · </h1>
<p className="mt-1 text-xs text-slate-500">
/////
</p>
</div>
<div className="ml-auto flex items-center gap-4 text-xs">
<span className="text-slate-500">
<span className="font-mono text-slate-300">{run.taskId ?? "—"}</span>
</span>
<span className={phaseCls}> {phaseText}</span>
<span className="text-slate-500">{nodes.length} · {tools.length} </span>
</div>
</header>
<div className="grid min-h-0 flex-1 grid-cols-[1.2fr_1fr] gap-4">
{/* 执行轨迹 */}
<section className="flex min-h-0 flex-col rounded-xl border border-line bg-ink-900 shadow-card">
<div className="border-b border-line px-4 py-2.5 text-[11px] font-medium text-slate-400"></div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<ExecTrace events={run.exec} phase={run.phase} />
</div>
</section>
{/* 模型输出 */}
<section className="flex min-h-0 flex-col rounded-xl border border-line bg-ink-900 shadow-card">
<div className="border-b border-line px-4 py-2.5 text-[11px] font-medium text-slate-400"></div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
{run.output ? (
<pre className="whitespace-pre-wrap break-words font-sans text-sm leading-relaxed text-slate-300">{run.output}</pre>
) : (
<div className="flex h-full items-center justify-center text-center text-xs text-slate-600">
<br />
</div>
)}
</div>
</section>
</div>
</div>
);
}
+2 -2
View File
@@ -36,8 +36,8 @@ func main() {
log.Printf("[dispatcher] subscribe model config: %v", err) log.Printf("[dispatcher] subscribe model config: %v", err)
} }
// sub 同时作为 Token 回流出口(TokenSinkMCP 工具调用出口(ToolCaller)。 // sub 同时作为 Token 回流出口(TokenSinkMCP 工具调用出口(ToolCaller与执行事件出口(ExecSink
orch, err := eino.NewOrchestrator(pool, breaker, sub, sub) orch, err := eino.NewOrchestrator(pool, breaker, sub, sub, sub)
if err != nil { if err != nil {
log.Fatalf("[dispatcher] build eino graph: %v", err) log.Fatalf("[dispatcher] build eino graph: %v", err)
} }
+34 -7
View File
@@ -2,6 +2,7 @@ package eino
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"strings" "strings"
@@ -30,7 +31,7 @@ type RunCtx struct {
// //
// 工具/检索节点按拓扑序真实调用 MCPsundynix.tools.go.*),结果注入模型上下文。 // 工具/检索节点按拓扑序真实调用 MCPsundynix.tools.go.*),结果注入模型上下文。
// 分支/并行节点暂未编译(TODOcompose.Branch / fan-out)。 // 分支/并行节点暂未编译(TODOcompose.Branch / fan-out)。
func (o *Orchestrator) compileFlow(ctx context.Context, t *contract.Task) (compose.Runnable[*contract.Task, *schema.Message], error) { func (o *Orchestrator) compileFlow(ctx context.Context, t *contract.Task, tr *execTracer) (compose.Runnable[*contract.Task, *schema.Message], error) {
plan := dsl.Compile(t.Graph) // 系统提示词 / 用户输入 / 默认兜底 plan := dsl.Compile(t.Graph) // 系统提示词 / 用户输入 / 默认兜底
flow, _ := dsl.Parse(t.Graph) flow, _ := dsl.Parse(t.Graph)
@@ -41,13 +42,17 @@ func (o *Orchestrator) compileFlow(ctx context.Context, t *contract.Task) (compo
func(ctx context.Context, task *contract.Task) (*RunCtx, error) { func(ctx context.Context, task *contract.Task) (*RunCtx, error) {
uid, _ := task.Meta[contract.MetaUserID].(string) uid, _ := task.Meta[contract.MetaUserID].(string)
sid, _ := task.Meta[contract.MetaSessionID].(string) sid, _ := task.Meta[contract.MetaSessionID].(string)
end := tr.span("init", "memory", "召回画像与历史")
profile := o.fetchMemory(ctx, uid, plan.Query)
history := o.fetchHistory(ctx, sid)
end(fmt.Sprintf("画像 %d 字 · 历史 %d 条", len([]rune(profile)), len(history)), nil)
return &RunCtx{ return &RunCtx{
UserID: uid, UserID: uid,
SessionID: sid, SessionID: sid,
System: plan.System, System: plan.System,
Query: plan.Query, Query: plan.Query,
Profile: o.fetchMemory(ctx, uid, plan.Query), Profile: profile,
History: o.fetchHistory(ctx, sid), History: history,
}, nil }, nil
})); err != nil { })); err != nil {
return nil, err return nil, err
@@ -64,7 +69,7 @@ func (o *Orchestrator) compileFlow(ctx context.Context, t *contract.Task) (compo
} }
key := fmt.Sprintf("tool_%d", idx) key := fmt.Sprintf("tool_%d", idx)
idx++ idx++
if err := g.AddLambdaNode(key, compose.InvokableLambda(o.makeToolNode(t.ID, tool, args))); err != nil { if err := g.AddLambdaNode(key, compose.InvokableLambda(o.makeToolNode(t.ID, tool, args, tr))); err != nil {
return nil, err return nil, err
} }
if err := g.AddEdge(prev, key); err != nil { if err := g.AddEdge(prev, key); err != nil {
@@ -75,7 +80,12 @@ func (o *Orchestrator) compileFlow(ctx context.Context, t *contract.Task) (compo
} }
// prompt:黑板 → []*schema.Message(系统提示词 + 画像 + 工具产出 + 历史 + 用户输入)。 // prompt:黑板 → []*schema.Message(系统提示词 + 画像 + 工具产出 + 历史 + 用户输入)。
if err := g.AddLambdaNode("prompt", compose.InvokableLambda(buildMessages)); err != nil { if err := g.AddLambdaNode("prompt", compose.InvokableLambda(
func(ctx context.Context, rc *RunCtx) ([]*schema.Message, error) {
msgs, err := buildMessages(ctx, rc)
tr.info("prompt", "prompt", "组装提示词", fmt.Sprintf("%d 条消息 · 工具产出 %d 段", len(msgs), len(rc.ToolOut)))
return msgs, err
})); err != nil {
return nil, err return nil, err
} }
if err := g.AddEdge(prev, "prompt"); err != nil { if err := g.AddEdge(prev, "prompt"); err != nil {
@@ -101,9 +111,11 @@ func (o *Orchestrator) compileFlow(ctx context.Context, t *contract.Task) (compo
} }
// makeToolNode 返回一个真实调用 MCP 工具的图节点:把结果增补进黑板,失败降级不阻断。 // makeToolNode 返回一个真实调用 MCP 工具的图节点:把结果增补进黑板,失败降级不阻断。
func (o *Orchestrator) makeToolNode(taskID, tool string, args map[string]any) func(context.Context, *RunCtx) (*RunCtx, error) { func (o *Orchestrator) makeToolNode(taskID, tool string, args map[string]any, tr *execTracer) func(context.Context, *RunCtx) (*RunCtx, error) {
node := "tool:" + tool
return func(ctx context.Context, rc *RunCtx) (*RunCtx, error) { return func(ctx context.Context, rc *RunCtx) (*RunCtx, error) {
if o.tools == nil { if o.tools == nil {
tr.info(node, "tool", "工具 "+tool, "工具总线未接入,跳过")
return rc, nil return rc, nil
} }
// 未显式带查询词则注入当前用户输入,便于检索类工具。 // 未显式带查询词则注入当前用户输入,便于检索类工具。
@@ -114,19 +126,34 @@ func (o *Orchestrator) makeToolNode(taskID, tool string, args map[string]any) fu
if call["q"] == nil && call["query"] == nil { if call["q"] == nil && call["query"] == nil {
call["q"] = rc.Query call["q"] = rc.Query
} }
end := tr.span(node, "tool", "调用工具 "+tool)
cctx, cancel := context.WithTimeout(ctx, toolCallTimeout) cctx, cancel := context.WithTimeout(ctx, toolCallTimeout)
defer cancel() defer cancel()
res, err := o.tools.CallTool(cctx, contract.ToolSubjectGo(tool), &contract.ToolCall{ res, err := o.tools.CallTool(cctx, contract.ToolSubjectGo(tool), &contract.ToolCall{
Tool: tool, TaskID: taskID, Args: call, Tool: tool, TaskID: taskID, Args: call,
}) })
if err != nil || res == nil || !res.OK || res.Content == "" { if err != nil {
end("调用失败,降级跳过", err)
return rc, nil
}
if res == nil || !res.OK || res.Content == "" {
end("无结果,降级跳过", nil)
return rc, nil // 工具不可用/无结果 → 降级跳过 return rc, nil // 工具不可用/无结果 → 降级跳过
} }
end("入参 "+previewArgs(call)+" → 产出 "+truncate(res.Content, 160), nil)
rc.ToolOut = append(rc.ToolOut, "["+tool+"] "+res.Content) rc.ToolOut = append(rc.ToolOut, "["+tool+"] "+res.Content)
return rc, nil return rc, nil
} }
} }
// previewArgs 把工具入参压成一行短预览。
func previewArgs(args map[string]any) string {
if data, err := json.Marshal(args); err == nil {
return truncate(string(data), 120)
}
return ""
}
// buildMessages 把黑板组装为发给模型的消息序列。 // buildMessages 把黑板组装为发给模型的消息序列。
func buildMessages(_ context.Context, rc *RunCtx) ([]*schema.Message, error) { func buildMessages(_ context.Context, rc *RunCtx) ([]*schema.Message, error) {
var sys strings.Builder var sys strings.Builder
+68
View File
@@ -0,0 +1,68 @@
package eino
import (
"encoding/json"
"sync/atomic"
"time"
"github.com/sundynix/sundynix-shared/contract"
)
// ExecSink 是执行可视化事件的回流出口(由 NATS bus 实现):
// 把节点/阶段生命周期事件发到 sundynix.exec.<id>,供"运行·观测"实时点亮轨迹。
type ExecSink interface {
PublishExec(taskID string, data []byte) error
CompleteExec(taskID string) error
}
// execTracer 为一个任务发结构化执行事件,自增 Seq 保序、span 自动计耗时。
type execTracer struct {
sink ExecSink
task string
seq int32
}
// tracer 为某任务建一个事件发射器(sink 为空时所有方法变空操作)。
func (o *Orchestrator) tracer(taskID string) *execTracer {
return &execTracer{sink: o.exec, task: taskID}
}
func (e *execTracer) emit(node, kind, phase, label, detail string, ms int64) {
if e == nil || e.sink == nil {
return
}
ev := contract.ExecEvent{
Seq: int(atomic.AddInt32(&e.seq, 1)), TS: time.Now().UnixMilli(),
Node: node, Kind: kind, Phase: phase, Label: label, Detail: detail, MS: ms,
}
if data, err := json.Marshal(&ev); err == nil {
_ = e.sink.PublishExec(e.task, data)
}
}
// info 发一条瞬时事件(无耗时)。
func (e *execTracer) info(node, kind, label, detail string) {
e.emit(node, kind, "info", label, detail, 0)
}
// span 发 start,并返回一个结束函数:调用时按 err 发 end / error,附带耗时。
func (e *execTracer) span(node, kind, label string) func(detail string, err error) {
e.emit(node, kind, "start", label, "", 0)
t0 := time.Now()
return func(detail string, err error) {
ms := time.Since(t0).Milliseconds()
if err != nil {
e.emit(node, kind, "error", label, err.Error(), ms)
return
}
e.emit(node, kind, "end", label, detail, ms)
}
}
// done 关闭该任务的执行事件流(让 SSE 客户端收尾)。
func (e *execTracer) done() {
if e == nil || e.sink == nil {
return
}
_ = e.sink.CompleteExec(e.task)
}
@@ -5,6 +5,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"io" "io"
"log" "log"
"strings" "strings"
@@ -38,11 +39,13 @@ type Orchestrator struct {
breaker *harness.CircuitBreaker breaker *harness.CircuitBreaker
sink TokenSink sink TokenSink
tools ToolCaller tools ToolCaller
exec ExecSink
} }
// NewOrchestrator 持有依赖;图按任务的 DSL 在 Handle 内动态编译。 // NewOrchestrator 持有依赖;图按任务的 DSL 在 Handle 内动态编译。
func NewOrchestrator(pool *llm.Pool, breaker *harness.CircuitBreaker, sink TokenSink, tools ToolCaller) (*Orchestrator, error) { // exec 为执行可视化事件出口(可为 nil,则不发轨迹事件)。
return &Orchestrator{pool: pool, breaker: breaker, sink: sink, tools: tools}, nil func NewOrchestrator(pool *llm.Pool, breaker *harness.CircuitBreaker, sink TokenSink, tools ToolCaller, exec ExecSink) (*Orchestrator, error) {
return &Orchestrator{pool: pool, breaker: breaker, sink: sink, tools: tools, exec: exec}, nil
} }
// Handle 消费一个任务:按 DSL 编译 Eino 图并执行,把 Token 流回流到 sundynix.streams.<id>。 // Handle 消费一个任务:按 DSL 编译 Eino 图并执行,把 Token 流回流到 sundynix.streams.<id>。
@@ -51,22 +54,30 @@ func (o *Orchestrator) Handle(ctx context.Context, t *contract.Task) error {
log.Printf("[eino] circuit open, drop task %s", t.ID) log.Printf("[eino] circuit open, drop task %s", t.ID)
return nil return nil
} }
tr := o.tracer(t.ID)
defer tr.done()
// 报告生成走专用多步编排(规划→分章并行检索撰写→汇聚→渲染 Word),而非通用对话图。 // 报告生成走专用多步编排(规划→分章并行检索撰写→汇聚→渲染 Word),而非通用对话图。
if intent, _ := t.Meta[contract.MetaIntent].(string); intent == contract.IntentReport { if intent, _ := t.Meta[contract.MetaIntent].(string); intent == contract.IntentReport {
return o.handleReport(ctx, t) return o.handleReport(ctx, t, tr)
} }
log.Printf("[eino] task %s received (graph=%d bytes), compiling DSL → Eino graph...", t.ID, len(t.Graph)) log.Printf("[eino] task %s received (graph=%d bytes), compiling DSL → Eino graph...", t.ID, len(t.Graph))
tr.info("task", "system", "任务受理", fmt.Sprintf("DSL %d 字节,编译 Eino 图", len(t.Graph)))
run, err := o.compileFlow(ctx, t) endCompile := tr.span("compile", "system", "编译 Eino 图")
run, err := o.compileFlow(ctx, t, tr)
if err != nil { if err != nil {
endCompile("", err)
log.Printf("[eino] task %s compile error: %v", t.ID, err) log.Printf("[eino] task %s compile error: %v", t.ID, err)
_ = o.sink.CompleteStream(t.ID) _ = o.sink.CompleteStream(t.ID)
o.breaker.Report(false) o.breaker.Report(false)
return err return err
} }
endCompile("图编译完成", nil)
stream, err := run.Stream(ctx, t) stream, err := run.Stream(ctx, t)
if err != nil { if err != nil {
tr.emit("model", "model", "error", "模型推理", err.Error(), 0)
log.Printf("[eino] task %s graph error: %v", t.ID, err) log.Printf("[eino] task %s graph error: %v", t.ID, err)
_ = o.sink.CompleteStream(t.ID) _ = o.sink.CompleteStream(t.ID)
o.breaker.Report(false) o.breaker.Report(false)
@@ -76,6 +87,7 @@ func (o *Orchestrator) Handle(ctx context.Context, t *contract.Task) error {
n := 0 n := 0
var answer strings.Builder var answer strings.Builder
t0 := time.Now()
for { for {
chunk, rerr := stream.Recv() chunk, rerr := stream.Recv()
if errors.Is(rerr, io.EOF) { if errors.Is(rerr, io.EOF) {
@@ -88,6 +100,9 @@ func (o *Orchestrator) Handle(ctx context.Context, t *contract.Task) error {
if chunk == nil || chunk.Content == "" { if chunk == nil || chunk.Content == "" {
continue continue
} }
if n == 0 {
tr.emit("model", "model", "start", "模型流式推理", fmt.Sprintf("首 token %dms", time.Since(t0).Milliseconds()), 0)
}
if perr := o.sink.PublishToken(t.ID, []byte(chunk.Content)); perr != nil { if perr := o.sink.PublishToken(t.ID, []byte(chunk.Content)); perr != nil {
log.Printf("[eino] publish token failed: %v", perr) log.Printf("[eino] publish token failed: %v", perr)
break break
@@ -95,6 +110,7 @@ func (o *Orchestrator) Handle(ctx context.Context, t *contract.Task) error {
answer.WriteString(chunk.Content) answer.WriteString(chunk.Content)
n++ n++
} }
tr.emit("model", "model", "end", "模型流式推理", fmt.Sprintf("%d tokens / %d 字", n, len([]rune(answer.String()))), time.Since(t0).Milliseconds())
if cerr := o.sink.CompleteStream(t.ID); cerr != nil { if cerr := o.sink.CompleteStream(t.ID); cerr != nil {
log.Printf("[eino] complete stream failed: %v", cerr) log.Printf("[eino] complete stream failed: %v", cerr)
+25 -5
View File
@@ -38,7 +38,7 @@ type reportSection struct {
// //
// 全程把人可读的 Markdown 进度与正文经 sundynix.streams.<id> 流回客户端; // 全程把人可读的 Markdown 进度与正文经 sundynix.streams.<id> 流回客户端;
// 最终调 mcp-go 的 report_render 落盘 docx,客户端凭 task_id 下载。 // 最终调 mcp-go 的 report_render 落盘 docx,客户端凭 task_id 下载。
func (o *Orchestrator) handleReport(ctx context.Context, t *contract.Task) error { func (o *Orchestrator) handleReport(ctx context.Context, t *contract.Task, tr *execTracer) error {
defer func() { _ = o.sink.CompleteStream(t.ID) }() defer func() { _ = o.sink.CompleteStream(t.ID) }()
topic, _ := t.Meta[contract.MetaTopic].(string) topic, _ := t.Meta[contract.MetaTopic].(string)
@@ -50,9 +50,12 @@ func (o *Orchestrator) handleReport(ctx context.Context, t *contract.Task) error
topic = "未命名报告" topic = "未命名报告"
} }
log.Printf("[report] task %s 生成报告: topic=%q kb=%q", t.ID, topic, kb) log.Printf("[report] task %s 生成报告: topic=%q kb=%q", t.ID, topic, kb)
tr.info("task", "system", "报告任务受理", fmt.Sprintf("主题:%s%s", topic, kbSuffix(kb)))
o.emit(t.ID, "> 正在规划大纲…\n\n") o.emit(t.ID, "> 正在规划大纲…\n\n")
endPlan := tr.span("plan", "plan", "规划大纲")
outline := o.planOutline(ctx, topic) outline := o.planOutline(ctx, topic)
endPlan(fmt.Sprintf("%d 章:%s", len(outline.Sections), strings.Join(outline.Sections, " / ")), nil)
o.emit(t.ID, fmt.Sprintf("**报告大纲**%d 章)\n", len(outline.Sections))) o.emit(t.ID, fmt.Sprintf("**报告大纲**%d 章)\n", len(outline.Sections)))
for i, s := range outline.Sections { for i, s := range outline.Sections {
@@ -64,7 +67,7 @@ func (o *Orchestrator) handleReport(ctx context.Context, t *contract.Task) error
o.emit(t.ID, "\n> 正在并行撰写各章…\n\n") o.emit(t.ID, "\n> 正在并行撰写各章…\n\n")
} }
sections := o.writeSections(ctx, topic, kb, outline.Sections) sections := o.writeSections(ctx, topic, kb, outline.Sections, tr)
// 把完整报告正文流式呈现给客户端。 // 把完整报告正文流式呈现给客户端。
o.emit(t.ID, "\n---\n\n# "+firstNonEmpty(outline.Title, topic)+"\n\n") o.emit(t.ID, "\n---\n\n# "+firstNonEmpty(outline.Title, topic)+"\n\n")
@@ -74,16 +77,26 @@ func (o *Orchestrator) handleReport(ctx context.Context, t *contract.Task) error
// 渲染真实 Word 文档。 // 渲染真实 Word 文档。
o.emit(t.ID, "> 正在渲染 Word 文档…\n\n") o.emit(t.ID, "> 正在渲染 Word 文档…\n\n")
endRender := tr.span("render", "render", "渲染 Word 文档")
if path := o.renderReport(ctx, t.ID, firstNonEmpty(outline.Title, topic), sections); path != "" { if path := o.renderReport(ctx, t.ID, firstNonEmpty(outline.Title, topic), sections); path != "" {
endRender("docx 已落盘:"+path, nil)
o.emit(t.ID, "---\n✅ 报告已生成 Word 文档,可点击上方「下载 Word」保存。\n") o.emit(t.ID, "---\n✅ 报告已生成 Word 文档,可点击上方「下载 Word」保存。\n")
log.Printf("[report] task %s 完成,docx=%s", t.ID, path) log.Printf("[report] task %s 完成,docx=%s", t.ID, path)
} else { } else {
endRender("渲染服务不可用", fmt.Errorf("render unavailable"))
o.emit(t.ID, "---\n⚠️ Word 渲染未完成(渲染服务不可用),以上为报告正文。\n") o.emit(t.ID, "---\n⚠️ Word 渲染未完成(渲染服务不可用),以上为报告正文。\n")
} }
o.breaker.Report(true) o.breaker.Report(true)
return nil return nil
} }
func kbSuffix(kb string) string {
if kb == "" {
return "(不挂知识库)"
}
return ",知识库 " + kb
}
// planOutline 让模型规划 3–5 章大纲;模型不可用/解析失败则用通用兜底大纲。 // planOutline 让模型规划 3–5 章大纲;模型不可用/解析失败则用通用兜底大纲。
func (o *Orchestrator) planOutline(ctx context.Context, topic string) reportOutline { func (o *Orchestrator) planOutline(ctx context.Context, topic string) reportOutline {
fallback := reportOutline{Title: topic, Sections: []string{"背景与现状", "核心分析", "结论与建议"}} fallback := reportOutline{Title: topic, Sections: []string{"背景与现状", "核心分析", "结论与建议"}}
@@ -110,7 +123,7 @@ func (o *Orchestrator) planOutline(ctx context.Context, topic string) reportOutl
} }
// writeSections 各章节并行撰写(有界并发),结果按原顺序返回。 // writeSections 各章节并行撰写(有界并发),结果按原顺序返回。
func (o *Orchestrator) writeSections(ctx context.Context, topic, kb string, headings []string) []reportSection { func (o *Orchestrator) writeSections(ctx context.Context, topic, kb string, headings []string, tr *execTracer) []reportSection {
out := make([]reportSection, len(headings)) out := make([]reportSection, len(headings))
sem := make(chan struct{}, reportFanout) sem := make(chan struct{}, reportFanout)
var wg sync.WaitGroup var wg sync.WaitGroup
@@ -120,7 +133,11 @@ func (o *Orchestrator) writeSections(ctx context.Context, topic, kb string, head
defer wg.Done() defer wg.Done()
sem <- struct{}{} sem <- struct{}{}
defer func() { <-sem }() defer func() { <-sem }()
out[i] = reportSection{Heading: h, Body: o.writeSection(ctx, topic, kb, h)} node := fmt.Sprintf("section:%d", i)
end := tr.span(node, "section", fmt.Sprintf("第%d章 %s", i+1, h))
body := o.writeSection(ctx, topic, kb, h, tr, node)
end(fmt.Sprintf("成稿 %d 字", len([]rune(body))), nil)
out[i] = reportSection{Heading: h, Body: body}
}(i, h) }(i, h)
} }
wg.Wait() wg.Wait()
@@ -128,8 +145,11 @@ func (o *Orchestrator) writeSections(ctx context.Context, topic, kb string, head
} }
// writeSection 撰写一章:先 RAG 检索参考资料(若挂了知识库),再让模型成稿。 // writeSection 撰写一章:先 RAG 检索参考资料(若挂了知识库),再让模型成稿。
func (o *Orchestrator) writeSection(ctx context.Context, topic, kb, heading string) string { func (o *Orchestrator) writeSection(ctx context.Context, topic, kb, heading string, tr *execTracer, node string) string {
refs := o.retrieve(ctx, kb, topic+" "+heading) refs := o.retrieve(ctx, kb, topic+" "+heading)
if refs != "" {
tr.info(node, "section", "检索参考资料", truncate(strings.ReplaceAll(refs, "\n", " "), 120))
}
if !o.pool.Ready() { if !o.pool.Ready() {
if refs != "" { if refs != "" {
return "(模型未配置,以下为检索到的参考资料)\n" + refs return "(模型未配置,以下为检索到的参考资料)\n" + refs
@@ -53,6 +53,16 @@ func (s *Subscriber) CompleteStream(taskID string) error {
return s.inner.CompleteStream(taskID) return s.inner.CompleteStream(taskID)
} }
// PublishExec / CompleteExec 让 Subscriber 满足 eino.ExecSink
// 把执行轨迹事件回流到 sundynix.exec.<taskID>(与 Token 流分开)。
func (s *Subscriber) PublishExec(taskID string, data []byte) error {
return s.inner.PublishExec(taskID, data)
}
func (s *Subscriber) CompleteExec(taskID string) error {
return s.inner.CompleteExec(taskID)
}
// CallTool 让 Subscriber 满足 eino.ToolCaller,经 NATS request-reply 调起第 5 层 MCP 工具。 // CallTool 让 Subscriber 满足 eino.ToolCaller,经 NATS request-reply 调起第 5 层 MCP 工具。
func (s *Subscriber) CallTool(ctx context.Context, subject string, call *contract.ToolCall) (*contract.ToolResult, error) { func (s *Subscriber) CallTool(ctx context.Context, subject string, call *contract.ToolCall) (*contract.ToolResult, error) {
return s.inner.CallTool(ctx, subject, call) return s.inner.CallTool(ctx, subject, call)
@@ -91,6 +91,45 @@ func (h *Handler) StreamTask(c *gin.Context) {
}) })
} }
// StreamExec: 订阅 sundynix.exec.<task_id>,以 SSE 把执行轨迹事件推给客户端(运行·观测)。
// 与 StreamTasktoken 流)并行:前端同时连两路,token 走输出、exec 走轨迹/工具面板。
func (h *Handler) StreamExec(c *gin.Context) {
taskID := c.Param("id")
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
events := make(chan []byte, 256)
done := make(chan struct{})
unsub, err := h.bus.SubscribeExec(taskID,
func(ev []byte) {
select {
case events <- ev:
default: // 背压保护:客户端过慢则丢弃,避免阻塞 NATS 回调
}
},
func() { close(done) },
)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
defer func() { _ = unsub() }()
c.Stream(func(w io.Writer) bool {
select {
case ev := <-events:
c.SSEvent("exec", string(ev))
return true
case <-done:
c.SSEvent("done", taskID)
return false
case <-c.Request.Context().Done():
return false
}
})
}
// SetMemory: 写入/更新一条用户偏好记忆,经 NATS 调 mcp-go 的 memory_upsert 工具。 // SetMemory: 写入/更新一条用户偏好记忆,经 NATS 调 mcp-go 的 memory_upsert 工具。
// 桌面端"偏好记忆面板"可用它让用户显式登记/纠正模型对自己的记忆。 // 桌面端"偏好记忆面板"可用它让用户显式登记/纠正模型对自己的记忆。
func (h *Handler) SetMemory(c *gin.Context) { func (h *Handler) SetMemory(c *gin.Context) {
@@ -44,6 +44,11 @@ func (b *Bus) SubscribeTokens(taskID string, onToken func([]byte), onDone func()
return b.inner.SubscribeTokens(taskID, onToken, onDone) return b.inner.SubscribeTokens(taskID, onToken, onDone)
} }
// SubscribeExec 订阅 sundynix.exec.<taskID> 的执行轨迹事件(用于"运行·观测"SSE)。
func (b *Bus) SubscribeExec(taskID string, onEvent func([]byte), onDone func()) (func() error, error) {
return b.inner.SubscribeExec(taskID, onEvent, onDone)
}
// CallTool 经 NATS 同步调用一个 MCP 工具(用于网关侧写偏好记忆等)。 // CallTool 经 NATS 同步调用一个 MCP 工具(用于网关侧写偏好记忆等)。
func (b *Bus) CallTool(ctx context.Context, subject string, call *contract.ToolCall) (*contract.ToolResult, error) { func (b *Bus) CallTool(ctx context.Context, subject string, call *contract.ToolCall) (*contract.ToolResult, error) {
return b.inner.CallTool(ctx, subject, call) return b.inner.CallTool(ctx, subject, call)
@@ -22,6 +22,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus) *gin.Engine {
{ {
api.POST("/tasks", h.SubmitTask) // 1. 解析 DSL 并 Publish 到 NATS api.POST("/tasks", h.SubmitTask) // 1. 解析 DSL 并 Publish 到 NATS
api.GET("/tasks/:id/stream", h.StreamTask) // 4. SSE/WS 回流 Token Stream api.GET("/tasks/:id/stream", h.StreamTask) // 4. SSE/WS 回流 Token Stream
api.GET("/tasks/:id/exec", h.StreamExec) // 4b. SSE 回流执行轨迹事件(运行·观测)
api.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert api.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert
api.POST("/kb/ingest", h.KbIngest) // 知识库入库(文本,→ mcp-go kb_ingest api.POST("/kb/ingest", h.KbIngest) // 知识库入库(文本,→ mcp-go kb_ingest
api.POST("/kb/ingest_file", h.KbIngestFile) // 文件入库(docx/xlsx/pdf… 异步) api.POST("/kb/ingest_file", h.KbIngestFile) // 文件入库(docx/xlsx/pdf… 异步)
+31
View File
@@ -134,6 +134,37 @@ func (b *Bus) SubscribeTokens(taskID string, onToken func([]byte), onDone func()
return sub.Unsubscribe, nil return sub.Unsubscribe, nil
} }
// ---- 执行可视化事件(core NATS,与 Token 流分流)----
// PublishExec 把一条执行事件(JSON)发到 sundynix.exec.<taskID>。
func (b *Bus) PublishExec(taskID string, data []byte) error {
return b.nc.Publish(contract.ExecSubject(taskID), data)
}
// CompleteExec 发送执行事件流结束信号(空体 + 结束头)。
func (b *Bus) CompleteExec(taskID string) error {
msg := nats.NewMsg(contract.ExecSubject(taskID))
msg.Header.Set(contract.HeaderStreamEnd, "1")
return b.nc.PublishMsg(msg)
}
// SubscribeExec 订阅某 task 的执行事件流。每条事件触发 onEvent;结束触发 onDone。
func (b *Bus) SubscribeExec(taskID string, onEvent func([]byte), onDone func()) (unsub func() error, err error) {
sub, err := b.nc.Subscribe(contract.ExecSubject(taskID), func(m *nats.Msg) {
if m.Header.Get(contract.HeaderStreamEnd) == "1" {
onDone()
return
}
data := make([]byte, len(m.Data))
copy(data, m.Data)
onEvent(data)
})
if err != nil {
return nil, fmt.Errorf("subscribe exec: %w", err)
}
return sub.Unsubscribe, nil
}
// ---- MCP 工具调用(core NATS request-reply---- // ---- MCP 工具调用(core NATS request-reply----
// CallTool 同步调用一个 MCP 工具:发到 subject,阻塞等待应答。 // CallTool 同步调用一个 MCP 工具:发到 subject,阻塞等待应答。
+20
View File
@@ -47,6 +47,26 @@ const (
func ConfigGetSubject(kind string) string { return "sundynix.config." + kind + ".get" } func ConfigGetSubject(kind string) string { return "sundynix.config." + kind + ".get" }
func ConfigUpdatedSubject(kind string) string { return "sundynix.config." + kind + ".updated" } func ConfigUpdatedSubject(kind string) string { return "sundynix.config." + kind + ".updated" }
// SubjectExec 是执行可视化事件的回流前缀;实际 sundynix.exec.<task_id>。
// 与 Token 流(sundynix.streams.<id>)分流:Token 是零拷贝字节,Exec 是结构化节点事件。
const SubjectExec = "sundynix.exec"
// ExecSubject 返回某任务的执行事件回流主题。
func ExecSubject(id string) string { return SubjectExec + "." + id }
// ExecEvent 是一次任务执行中某节点/阶段的生命周期事件(经 sundynix.exec.<id> 回流给 UI
// 用于"运行·观测"的实时轨迹:节点点亮、工具调用入参/产出、各阶段耗时)。
type ExecEvent struct {
Seq int `json:"seq"` // 任务内自增序号(保序)
TS int64 `json:"ts"` // unix 毫秒
Node string `json:"node"` // 稳定节点 idinit / tool:wiki_search / prompt / model / plan / section:0 / render
Kind string `json:"kind"` // 归类着色:memory|tool|prompt|model|plan|section|render|system
Phase string `json:"phase"` // start|end|error|info
Label string `json:"label"` // 人读标题
Detail string `json:"detail,omitempty"` // 入参/产出/计数预览
MS int64 `json:"ms,omitempty"` // end 事件的耗时(毫秒)
}
// IngestEvent 是入库流水线的实时进度事件(经 sundynix.streams.<job_id> 回流给 UI)。 // IngestEvent 是入库流水线的实时进度事件(经 sundynix.streams.<job_id> 回流给 UI)。
type IngestEvent struct { type IngestEvent struct {
Stage string `json:"stage"` // 解析/切块/向量化/写Milvus/写Bleve/完成/失败 Stage string `json:"stage"` // 解析/切块/向量化/写Milvus/写Bleve/完成/失败