Why: Task 10 replaces Backend's independent Markdown-to-Word production path with the reviewed canonical parser, embedded organization-default candidate, and Word adapter. Evidence: Canonical parity, deterministic package, routing, safety, cancellation, comparison, persistence, affected-package race, full test, vet, build, and static build checks passed locally. Impact: Ordinary workspace renders now persist Markdown, deterministic DOCX, manifest, and archive with hashes; controlled/project/review/approved routes fail closed to RDM, and legacy Go rendering is comparison-only. Refs: Unified Document Renderer Task 10; Renderer d449b9f779f2f968f8ac556b89940a6c9aaf3fdc
335 lines
18 KiB
Go
335 lines
18 KiB
Go
package runtime
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"agent-system-backend/internal/docgen"
|
||
"agent-system-backend/internal/squadadapter"
|
||
"agent-system-backend/internal/wfcompile"
|
||
"agent-system-backend/internal/workflow"
|
||
core "agent-system-core"
|
||
)
|
||
|
||
func firstNonEmpty0(vals ...string) string {
|
||
for _, v := range vals {
|
||
if strings.TrimSpace(v) != "" {
|
||
return v
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// MockRunner 按脚本回放事件流(P1 骨架闭环与验收基线;不调用任何模型)。
|
||
// 用户消息中的指令词触发不同演示路径,其余走默认脚本(思考→工具→回复)。
|
||
type MockRunner struct{}
|
||
|
||
func (m *MockRunner) Name() string { return "mock" }
|
||
|
||
func (m *MockRunner) Execute(rc RunCtx) error {
|
||
// 取最后一条用户消息文本
|
||
user := ""
|
||
if hist := rc.History(); len(hist) > 0 {
|
||
last := hist[len(hist)-1]
|
||
if last.Role == core.RoleUser {
|
||
for _, p := range last.Parts {
|
||
if p.Type == core.PartText {
|
||
user = p.Text
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
var parts []core.Part
|
||
addText := func(t string) {
|
||
parts = append(parts, core.Part{Type: core.PartText, Text: t})
|
||
}
|
||
// mock 路径同样产出上下文观测(演示可解释性入口;真实路径由 ctxasm 产出)。
|
||
rc.SetContextObservation(core.ContextObservation{
|
||
Items: []string{"system(画像)", "history(mock)", "attachments(0)"},
|
||
EstChars: len(user) + 128,
|
||
})
|
||
|
||
switch {
|
||
case strings.Contains(user, "#hitl"):
|
||
resp, err := rc.AskHITL(core.HITLCard{
|
||
Question: "演示:采用哪种输出格式?", Kind: core.HITLKindChoice,
|
||
Choices: []core.HITLChoice{
|
||
{ID: "md", Label: "Markdown 文档", Description: "可版本管理的轻量文档", Impact: "写入 artifacts,可在右栏查看与继续编辑", Recommended: true},
|
||
{ID: "docx", Label: "Word 文档", Description: "正式交付格式", Impact: "生成 docx 工件,后续修订走版本对比"},
|
||
{ID: "both", Label: "两种都要", Description: "同时产出", Impact: "生成两个工件"},
|
||
},
|
||
AllowFreeInput: true, FreeInputHint: "也可以直接输入你的要求(输入 / 开头则转新命令)",
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
answer := resp.FreeText
|
||
if resp.ChoiceID != "" {
|
||
answer = "选项 " + resp.ChoiceID
|
||
}
|
||
parts = append(parts, core.Part{Type: core.PartHITL, Hitl: &core.HITLSummary{Question: "演示:采用哪种输出格式?", Resolved: true, Answer: answer}})
|
||
addText(fmt.Sprintf("收到你的回答:%s。已按该选择继续执行。", answer))
|
||
|
||
case strings.Contains(user, "#agents"):
|
||
rc.UpdatePlan(core.PlanSnapshot{Title: "多代理演示计划", Steps: []core.PlanStep{
|
||
{ID: "s1", Text: "检索资料", Status: "in_progress"},
|
||
{ID: "s2", Text: "撰写摘要", Status: "pending"},
|
||
{ID: "s3", Text: "汇总输出", Status: "pending"},
|
||
}})
|
||
rc.UpdateTodos(core.TodoList{Items: []core.TodoItem{
|
||
{ID: "t1", Text: "派生两个子代理", Status: "in_progress"},
|
||
{ID: "t2", Text: "等待结果", Status: "pending"},
|
||
}})
|
||
rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Index: 0, Text: "任务需要并行检索,派生子代理……"})
|
||
parts = append(parts, core.Part{Type: core.PartThinking, Text: "任务需要并行检索,派生子代理……", Process: true})
|
||
r1, _ := rc.SpawnSubagent("资料检索员", "检索项目知识与历史会话", "知识检索")
|
||
r2, _ := rc.SpawnSubagent("撰稿员", "根据检索结果撰写摘要", "写作")
|
||
rc.UpdateTodos(core.TodoList{Items: []core.TodoItem{
|
||
{ID: "t1", Text: "派生两个子代理", Status: "done"},
|
||
{ID: "t2", Text: "等待结果", Status: "done"},
|
||
}})
|
||
rc.UpdatePlan(core.PlanSnapshot{Title: "多代理演示计划", Steps: []core.PlanStep{
|
||
{ID: "s1", Text: "检索资料", Status: "done"},
|
||
{ID: "s2", Text: "撰写摘要", Status: "done"},
|
||
{ID: "s3", Text: "汇总输出", Status: "in_progress"},
|
||
}})
|
||
addText(fmt.Sprintf("两个子代理已完成:%s(%s);%s(%s)。可在右栏 Agents 标签查看任务树与中间过程。",
|
||
r1.Summary, r1.Status, r2.Summary, r2.Status))
|
||
rc.UpdatePlan(core.PlanSnapshot{Title: "多代理演示计划", Steps: []core.PlanStep{
|
||
{ID: "s1", Text: "检索资料", Status: "done"},
|
||
{ID: "s2", Text: "撰写摘要", Status: "done"},
|
||
{ID: "s3", Text: "汇总输出", Status: "done"},
|
||
}})
|
||
|
||
case strings.Contains(user, "#upload"):
|
||
// 收件箱卡片流演示(提案 T2):摄取卡生命周期 + 冲突对比卡(待处理)。
|
||
addText("📎 已上传 临床前动物试验方案-草案.pdf(演示)——上传即构建记忆:先给相关性结论,解析进度在右栏卡片。")
|
||
card, _ := rc.CreateCard(core.ActivityCard{Kind: core.CardKindProgress, Title: "临床前动物试验方案-草案.pdf", Status: core.CardActive, Tone: "info",
|
||
Body: []core.UINode{{Type: core.UIProgress, Progress: &core.UIProgressSpec{Label: "解析中", Value: 0.25}}}})
|
||
rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Index: 0, Text: "记忆构建管道:相关性 ✓(命中:试验样本量、随访周期)→ 拆解 → 打标签 → 切片入库……"})
|
||
parts = append(parts, core.Part{Type: core.PartThinking, Text: "记忆构建管道:相关性 ✓ → 拆解 → 打标签 → 切片入库", Process: true})
|
||
card.Body = []core.UINode{
|
||
{Type: core.UIProgress, Progress: &core.UIProgressSpec{Label: "切片 24/24", Value: 1, Tone: "ok"}},
|
||
{Type: core.UIKV, KV: [][2]string{{"快通道", "✓ 38s"}, {"慢通道", "✓ 3m12s"}, {"追溯候选边", "6 条"}}},
|
||
{Type: core.UIList, Title: "标签", Items: []string{"验证确认(跨阶段引用)", "technical ×1", "regulatory ×1"}},
|
||
}
|
||
card.Status = core.CardDone
|
||
card.Tone = "ok"
|
||
_ = rc.SetCardStatus(card.ID, core.CardDone, "ok", "")
|
||
_ = rc.Emit(core.EvCardUpdated, core.CardPayload{Card: card})
|
||
cmp, _ := rc.CreateCard(core.ActivityCard{Kind: core.CardKindCompare, Title: "项目执行周期不一致 · 目标已基线", Status: core.CardActionRequired, Tone: "danger",
|
||
Body: []core.UINode{{Type: core.UICompare, Compare: &core.UICompareSpec{
|
||
Left: core.UICompareSide{Title: "基线 V1.2 · 已评审", Tone: "danger", Nodes: []core.UINode{{Type: core.UIMarkdown, Markdown: "12 个月(立项到注册)"}}},
|
||
Right: core.UICompareSide{Title: "新资料 · 待确认", Tone: "ok", Nodes: []core.UINode{{Type: core.UIMarkdown, Markdown: "18 个月(含临床试验)"}}},
|
||
}}},
|
||
Actions: []core.UIAction{{ID: "escalate", Label: "发起变更流程", Style: "primary"}, {ID: "keep", Label: "维持原样", Style: "danger"}},
|
||
})
|
||
parts = append(parts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: "cardlink", Data: []core.UINode{{Type: core.UIMarkdown, Markdown: fmt.Sprintf("↗ 解析完成见卡片;冲突处理卡 `card:%s` 已进右栏收件箱(待处理)", cmp.ID)}}}})
|
||
|
||
case strings.Contains(user, "#cardflow"):
|
||
// 卡流机制演示(CF1/D39):产物卡显式路由——中栏结论摘要 + 引用锚点,全文进收件箱。
|
||
rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Index: 0, Text: "生成会议纪要……"})
|
||
parts = append(parts, core.Part{Type: core.PartThinking, Text: "生成会议纪要……", Process: true})
|
||
body := "# 会议纪要(演示)\n\n## 决议\n\n1. 注册路径走 FDA 510(k)\n2. 样机 B 版冻结\n3. 供应商切换延后一轮\n\n## 待办\n\n- 法规组输出对比表\n- 结构组更新 BOM\n"
|
||
card, _ := rc.SubmitCard(core.ActivityCard{
|
||
Kind: core.CardKindResult, Title: "会议纪要 · 第 12 次周会", Status: core.CardDone, Tone: "ok",
|
||
Body: []core.UINode{
|
||
{Type: core.UIMarkdown, Markdown: body},
|
||
{Type: core.UIKV, KV: [][2]string{{"摘要", "三项决议、两项待办;注册路径定为 510(k)"}}},
|
||
},
|
||
})
|
||
addText("已完成会议纪要:三项决议、两项待办,注册路径定为 510(k)。全文见右侧卡片,可在收件箱置顶或收纳。")
|
||
parts = append(parts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: card.ID}})
|
||
|
||
case strings.Contains(user, "#cardstream"):
|
||
// 流式产卡演示(CF-S/D44-D45):card_begin → append×N → card_commit,
|
||
// 期间收件箱可见 active 卡随 card.updated 增量刷新。
|
||
rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Index: 0, Text: "流式生成周报……"})
|
||
parts = append(parts, core.Part{Type: core.PartThinking, Text: "流式生成周报……", Process: true})
|
||
if _, err := rc.BeginStreamCard(core.ActivityCard{
|
||
Kind: core.CardKindResult, Title: "项目周报 · 流式", Tone: "ok",
|
||
Body: []core.UINode{{Type: core.UIKV, KV: [][2]string{{"摘要", "三段式流式产卡演示:进展 / 风险 / 下周"}}}},
|
||
}); err != nil {
|
||
addText("收件箱未启用或流式卡开始失败(" + err.Error() + "),内容直接写在对话中。")
|
||
} else {
|
||
for _, seg := range []string{
|
||
"## 进展\n\n- 卡流三段式工具上线\n- 组合验证全绿\n",
|
||
"## 风险\n\n- Gitea 主机偶发中断(重试可恢复)\n",
|
||
"## 下周\n\n- artifact 统一(CF-A)\n- 跨会话收件箱(CF3)\n",
|
||
} {
|
||
if aerr := rc.AppendStreamCard(seg); aerr != nil {
|
||
break
|
||
}
|
||
}
|
||
if done, cerr := rc.CommitStreamCard(); cerr == nil {
|
||
addText("周报已流式生成完毕,全文见右侧卡片。")
|
||
parts = append(parts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: done.ID}})
|
||
} else {
|
||
addText("周报流式收尾失败:" + cerr.Error())
|
||
}
|
||
}
|
||
|
||
case strings.Contains(user, "#doc"):
|
||
rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Index: 0, Text: "准备生成文档……"})
|
||
parts = append(parts, core.Part{Type: core.PartThinking, Text: "准备生成文档……", Process: true})
|
||
// 普通工作区演示与真实 Agent 路径共用 canonical renderer。Markdown 先落
|
||
// 工件,再保存 DOCX、manifest 和带哈希清单的完整归档。
|
||
md := "# 演示会议纪要\n\n这是 mock runner 生成的示例 Word 工件。\n\n## 基本信息\n\n| 项目 | 内容 |\n|---|---|\n| 指令 | #doc |\n| 生成器 | docgen |\n\n## 要点\n\n1. 由 #doc 指令触发\n2. 带 Word 排版(标题/表格/编号列表)\n3. 对话内以交付物卡片呈现,点击右栏预览\n"
|
||
run := rc.Run()
|
||
frozenAt := run.CreatedAt.UTC()
|
||
if frozenAt.IsZero() {
|
||
frozenAt = time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)
|
||
}
|
||
requester := string(run.Owner)
|
||
if requester == "" {
|
||
requester = "mock-runner"
|
||
}
|
||
delivery, derr := docgen.Generate(rc.Context(), docgen.Request{
|
||
DocumentID: "mock-demo-minutes", Title: "demo-会议纪要", DocumentType: "general", Language: "zh-CN",
|
||
Markdown: []byte(md), Mode: "preview", FrozenAt: frozenAt, RequestedBy: requester,
|
||
})
|
||
if derr != nil {
|
||
addText("演示文档生成失败:" + derr.Error())
|
||
break
|
||
}
|
||
var written []core.Artifact
|
||
for _, item := range []docgen.Artifact{delivery.Markdown, delivery.DOCX, delivery.Manifest, delivery.Archive} {
|
||
kind := "document"
|
||
if item.MIME == "application/json" {
|
||
kind = "data"
|
||
}
|
||
art, err := rc.WriteArtifact(item.Name, kind, item.MIME, item.Bytes)
|
||
if err != nil {
|
||
addText("演示文档工件保存失败:" + err.Error())
|
||
break
|
||
}
|
||
written = append(written, art)
|
||
}
|
||
if len(written) == 4 {
|
||
art := written[len(written)-1]
|
||
addText("Markdown、Word、渲染清单和完整归档已生成;清单列出可复核 SHA-256。点击下方交付卡片下载归档,或在工件面板查看单项。")
|
||
// 交付物钉尾(内容部件,与 llm.go 契约一致):卡片在最终正文之后。
|
||
parts = append(parts, core.Part{Type: core.PartArtifactRef, Artifact: &core.ArtifactRef{ArtifactID: art.ID, Name: art.Name, Kind: art.Kind}})
|
||
// 工件门面卡演示(CF-A/D47):registry 工具大结果的统一呈现——
|
||
// 摘要 + 来源 kv + doc-ref(正文权威在工件);收件箱关闭时 SubmitCard
|
||
// 返回空 ID,跳过卡部件。
|
||
if fc, ferr := rc.SubmitCard(artifactFacadeCard("render_document", art, "演示文档包已生成(Markdown + DOCX + manifest)")); ferr == nil && fc.ID != "" {
|
||
parts = append(parts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: fc.ID}})
|
||
}
|
||
}
|
||
|
||
case strings.Contains(user, "#squad"):
|
||
rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Index: 0, Text: "调用 Agent-Squad 引擎编排……"})
|
||
parts = append(parts, core.Part{Type: core.PartThinking, Text: "调用 Agent-Squad 引擎编排……", Process: true})
|
||
goal := strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(user, "#squad", ""), "#hitl", ""))
|
||
if eff := rc.Settings(); !eff.GetBool("runtime.squad", true) {
|
||
addText("小队编排已被设置禁用(runtime.squad)。")
|
||
rc.SetAssistantParts(parts)
|
||
return nil
|
||
}
|
||
dataDir := rc.Settings().GetString("storage.dataDir", "./data")
|
||
summary, err := squadadapter.Orchestrate(rc, dataDir, firstNonEmpty0(goal, "生成一份关于演示目标的简要报告"))
|
||
if err != nil {
|
||
addText("小队编排结束(" + err.Error() + ")。摘要:" + summary)
|
||
} else {
|
||
addText("Agent-Squad 编排完成:" + summary + "。步骤与子代理见右栏「计划」「代理」。")
|
||
}
|
||
|
||
case strings.Contains(user, "#workflow"):
|
||
// Q3 WP2:演示工作流同走编译图(纯宿主步,不依赖模型后端)。
|
||
res, err := (CompiledWorkflowRunner{}).runLaunch(rc, workflow.Launch{Def: mockWorkflow()}, wfcompile.RunOptions{})
|
||
if err != nil {
|
||
if res.Summary != "" {
|
||
addText(res.Summary)
|
||
}
|
||
addText("演示工作流没有跑完:" + err.Error())
|
||
break
|
||
}
|
||
parts = append(parts, res.Parts...)
|
||
|
||
case strings.Contains(user, "#background"):
|
||
// 慢通道演示(任务快慢双速,SR-B10):快通道先反馈,后台子代理
|
||
// 进度进收件箱卡片,完成后结果回投该卡并发起汇报回合。
|
||
addText("✅ 快通道:材料已接收,先给结论——这是 mock 演示任务。深度解析已放慢通道后台执行:进度看右栏收件箱卡片,完成后自动回投结果并汇报。")
|
||
if sp, ok := rc.(interface {
|
||
SpawnBackground(agentID, name, task, hint string) (core.ID, error)
|
||
}); ok {
|
||
if _, err := sp.SpawnBackground("agent.sub.general", "深度解析员", "对上传材料逐页解析并生成要点摘要(mock 后台演示)", "演示任务"); err != nil {
|
||
addText("后台启动失败:" + err.Error())
|
||
}
|
||
} else {
|
||
addText("当前运行上下文不支持后台派发(演示不可用)")
|
||
}
|
||
|
||
case strings.Contains(user, "#error"):
|
||
return fmt.Errorf("mock: 演示失败路径(#error 指令)")
|
||
|
||
default:
|
||
// 默认脚本:思考 → 工具调用(知识检索)→ 回复(流式逐字)。
|
||
rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Index: 0, Text: "理解问题,先检索项目知识……"})
|
||
parts = append(parts, core.Part{Type: core.PartThinking, Text: "理解问题,先检索项目知识……", Process: true})
|
||
rc.Emit(core.EvRunPhase, core.RunPhasePayload{Phase: core.PhaseActing})
|
||
callID := "call_mock_1"
|
||
rc.Emit(core.EvToolCall, core.ToolCallPayload{CallID: callID, Name: "knowledge_search", Arguments: `{"query":"` + truncateForJSON(user, 40) + `"}`})
|
||
res := rc.ExecuteTool(core.ToolCallRequest{CallID: callID, Name: "knowledge_search", Arguments: []byte(`{"query":"演示"}`)})
|
||
rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: callID, Status: res.Status, Summary: truncateForJSON(res.Output, 120), Truncated: res.Truncated})
|
||
parts = append(parts,
|
||
core.Part{Type: core.PartToolCall, Process: true, ToolCall: &core.ToolCallPart{ID: callID, Name: "knowledge_search", Arguments: `{"query":"演示"}`}},
|
||
core.Part{Type: core.PartToolResult, Process: true, ToolResult: &core.ToolResultPart{CallID: callID, Status: res.Status, Summary: truncateForJSON(res.Output, 120)}},
|
||
)
|
||
rc.Emit(core.EvRunPhase, core.RunPhasePayload{Phase: core.PhaseResponding})
|
||
reply := fmt.Sprintf("(mock 回复)你说:%s\n\n这是 mock runner 的脚本化回复——未调用真实模型。可发送 #hitl / #agents / #doc / #workflow / #error 触发对应演示路径。", user)
|
||
idx := 0
|
||
for _, r := range reply {
|
||
rc.Emit(core.EvMessageDelta, core.MessageDeltaPayload{Index: idx, Text: string(r)})
|
||
time.Sleep(8 * time.Millisecond)
|
||
}
|
||
addText(reply)
|
||
}
|
||
rc.SetAssistantParts(parts)
|
||
return nil
|
||
}
|
||
|
||
// MockSubagent 子代理脚本回放:进度事件 + 摘要结果。
|
||
type MockSubagent struct{}
|
||
|
||
func (m *MockSubagent) Name() string { return "mock-subagent" }
|
||
|
||
func (m *MockSubagent) Execute(rc RunCtx) error {
|
||
rc.Emit(core.EvAgentProgress, core.AgentProgressPayload{AgentRun: rc.Run().ID, Note: "开始子任务……", Phase: core.PhaseThinking})
|
||
time.Sleep(150 * time.Millisecond)
|
||
rc.Emit(core.EvAgentProgress, core.AgentProgressPayload{AgentRun: rc.Run().ID, Note: "处理中……", Phase: core.PhaseActing})
|
||
time.Sleep(150 * time.Millisecond)
|
||
|
||
task := rc.Subtask()
|
||
rc.SetAssistantParts([]core.Part{{Type: core.PartText, Text: fmt.Sprintf("# 子代理产出(mock)\n\n任务:%s\n\n- 已按子代理角色完成处理\n- 本文本将作为小队步骤的交付物写入工作区\n- 任务树与中间过程可在右栏与弹窗查看\n", firstNonEmpty0(task, "(未提供任务描述)"))}})
|
||
return nil
|
||
}
|
||
|
||
func mockWorkflow() core.WorkflowDef {
|
||
return core.WorkflowDef{
|
||
ID: "mock.workflow", Title: "演示工作流",
|
||
Steps: []core.WorkflowStep{
|
||
{ID: "length", Title: "篇幅", Kind: "hitl", Hitl: &core.HITLCardTemplate{
|
||
Question: "要多长?", Kind: core.HITLKindChoice, AllowFreeInput: false,
|
||
Choices: []core.HITLChoice{
|
||
{ID: "short", Label: "短", Description: "一段", Impact: "只写一段"},
|
||
{ID: "long", Label: "长", Description: "几段", Impact: "会写得更长"},
|
||
},
|
||
}},
|
||
{ID: "save", Title: "成稿", Kind: "artifact", Artifact: "mock-brief.md", Prompt: "篇幅:{{steps.length.output}}\n{{input}}"},
|
||
{ID: "note", Title: "收件", Kind: "card", Card: &core.ActivityCardTemplate{Kind: core.CardKindResult, Title: "演示工作流完成"}},
|
||
},
|
||
}
|
||
}
|
||
|
||
func truncateForJSON(s string, n int) string {
|
||
r := []rune(s)
|
||
if len(r) <= n {
|
||
return s
|
||
}
|
||
return string(r[:n]) + "…"
|
||
}
|