Why: T2 of the interaction foundation — activity cards as first-class state objects behind the right-rail inbox (proposal D25): upload ingest cards (SR-A5 failures explicit, artifact retrievable), HITL projection cards (double-presence, ref-linked two-way sync), squad orchestration cards and the mock #upload demo of the full card lifecycle. Evidence: go test ./... green across 14 packages incl. new cardsvc suite (lifecycle, action labels and idempotency, pin/dismiss, owner isolation, resolve-by-ref idempotency) and updated migration test (v6 activity_cards, both dialects); events flow through the session event buffer (persist, seq, replay); receipts appended to the conversation timeline on card actions. Impact: additive; cards disabled (SetCards nil) degenerates to the T0 baseline; upload cards persist at project level per the v1 inbox boundary (conversation-scoped inbox; project-level aggregation is follow-up). Refs: modules/documents docs/proposals/2026-09-30-ui-inbox-command-workspace.md
207 lines
11 KiB
Go
207 lines
11 KiB
Go
package runtime
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"agent-system-backend/internal/squadadapter"
|
||
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, "#doc"):
|
||
rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Index: 0, Text: "准备生成文档……"})
|
||
parts = append(parts, core.Part{Type: core.PartThinking, Text: "准备生成文档……", Process: true})
|
||
content := "# 演示文档\n\n这是 mock runner 生成的示例工件。\n\n## 要点\n\n- 由 #doc 指令触发\n- 写入 artifacts 并可在右栏查看\n"
|
||
art, err := rc.WriteArtifact("demo-报告.md", "document", "text/markdown", []byte(content))
|
||
if err == nil {
|
||
parts = append(parts, core.Part{Type: core.PartArtifactRef, Artifact: &core.ArtifactRef{ArtifactID: art.ID, Name: art.Name, Kind: art.Kind}})
|
||
}
|
||
addText("文档已生成,见右侧 Artifacts 标签(也可点击消息中的工件引用)。")
|
||
|
||
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, "#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 / #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 truncateForJSON(s string, n int) string {
|
||
r := []rune(s)
|
||
if len(r) <= n {
|
||
return s
|
||
}
|
||
return string(r[:n]) + "…"
|
||
}
|