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:
@@ -38,7 +38,7 @@ type reportSection struct {
|
||||
//
|
||||
// 全程把人可读的 Markdown 进度与正文经 sundynix.streams.<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) }()
|
||||
|
||||
topic, _ := t.Meta[contract.MetaTopic].(string)
|
||||
@@ -50,9 +50,12 @@ func (o *Orchestrator) handleReport(ctx context.Context, t *contract.Task) error
|
||||
topic = "未命名报告"
|
||||
}
|
||||
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")
|
||||
endPlan := tr.span("plan", "plan", "规划大纲")
|
||||
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)))
|
||||
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")
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -74,16 +77,26 @@ func (o *Orchestrator) handleReport(ctx context.Context, t *contract.Task) error
|
||||
|
||||
// 渲染真实 Word 文档。
|
||||
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 != "" {
|
||||
endRender("docx 已落盘:"+path, nil)
|
||||
o.emit(t.ID, "---\n✅ 报告已生成 Word 文档,可点击上方「下载 Word」保存。\n")
|
||||
log.Printf("[report] task %s 完成,docx=%s", t.ID, path)
|
||||
} else {
|
||||
endRender("渲染服务不可用", fmt.Errorf("render unavailable"))
|
||||
o.emit(t.ID, "---\n⚠️ Word 渲染未完成(渲染服务不可用),以上为报告正文。\n")
|
||||
}
|
||||
o.breaker.Report(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
func kbSuffix(kb string) string {
|
||||
if kb == "" {
|
||||
return "(不挂知识库)"
|
||||
}
|
||||
return ",知识库 " + kb
|
||||
}
|
||||
|
||||
// planOutline 让模型规划 3–5 章大纲;模型不可用/解析失败则用通用兜底大纲。
|
||||
func (o *Orchestrator) planOutline(ctx context.Context, topic string) reportOutline {
|
||||
fallback := reportOutline{Title: topic, Sections: []string{"背景与现状", "核心分析", "结论与建议"}}
|
||||
@@ -110,7 +123,7 @@ func (o *Orchestrator) planOutline(ctx context.Context, topic string) reportOutl
|
||||
}
|
||||
|
||||
// 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))
|
||||
sem := make(chan struct{}, reportFanout)
|
||||
var wg sync.WaitGroup
|
||||
@@ -120,7 +133,11 @@ func (o *Orchestrator) writeSections(ctx context.Context, topic, kb string, head
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
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)
|
||||
}
|
||||
wg.Wait()
|
||||
@@ -128,8 +145,11 @@ func (o *Orchestrator) writeSections(ctx context.Context, topic, kb string, head
|
||||
}
|
||||
|
||||
// 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)
|
||||
if refs != "" {
|
||||
tr.info(node, "section", "检索参考资料", truncate(strings.ReplaceAll(refs, "\n", " "), 120))
|
||||
}
|
||||
if !o.pool.Ready() {
|
||||
if refs != "" {
|
||||
return "(模型未配置,以下为检索到的参考资料)\n" + refs
|
||||
|
||||
Reference in New Issue
Block a user