package taskrun import ( "fmt" "regexp" "strings" "chat-agent-system/internal/stream" ) // stepIndexSuffixRe 匹配 decoratePhaseLabel 产生的"(第 x/y 步)"结构后缀。 var stepIndexSuffixRe = regexp.MustCompile(`(第 \d+/\d+ 步)`) // PhaseStepSnapshot 折叠后的单步进度(与前端 PhaseEvent 字段对齐)。 type PhaseStepSnapshot struct { Step string `json:"step"` Icon string `json:"icon"` Label string `json:"label"` Status string `json:"status"` // pending | running | completed | failed Detail string `json:"detail,omitempty"` } // PhaseSnapshot 写入 chat_messages.metadata.task_phase_snapshot,供刷新后零请求重建卡片。 type PhaseSnapshot struct { Steps []PhaseStepSnapshot `json:"steps"` Label string `json:"label,omitempty"` DurationMs int64 `json:"duration_ms,omitempty"` Status string `json:"status,omitempty"` Title string `json:"title,omitempty"` } type phaseMeta struct { Icon string Label string } var phaseMetaTable = map[string]phaseMeta{ "squad_assembly": {Icon: "👥", Label: "组建小队"}, "knowledge_retrieval": {Icon: "📚", Label: "知识检索"}, "knowledge_retrieved": {Icon: "📚", Label: "知识命中"}, "step_executing": {Icon: "⚙️", Label: "专员执行"}, "step_completed": {Icon: "✅", Label: "步骤完成"}, "quality_gate": {Icon: "🛡️", Label: "质量门禁"}, "advisor_briefing": {Icon: "💡", Label: "参谋简报"}, "stage_transition": {Icon: "🚀", Label: "阶段跃迁"}, "stage_started": {Icon: "🚀", Label: "阶段启动"}, "stage_completed": {Icon: "🎉", Label: "阶段完成"}, "step_failed": {Icon: "⚠️", Label: "步骤异常"}, "task_failed": {Icon: "⚠️", Label: "任务异常"}, } var closePhases = map[string]struct{}{ "step_completed": {}, "stage_completed": {}, } // BuildPhaseSnapshot 把 run 的 StoredEvents 折叠为可持久化快照(与前端 applyPhaseEvent 语义对齐)。 func BuildPhaseSnapshot(events []StoredEvent, durationMs int64, status, title string) *PhaseSnapshot { steps, label := FoldPhaseEvents(events) if len(steps) == 0 && strings.TrimSpace(label) == "" && durationMs <= 0 && strings.TrimSpace(title) == "" { if strings.TrimSpace(status) == "" { return nil } } // 终态时把仍 running 的步骤收口为 completed / failed,避免刷新后卡片卡在旋转态。 if isTerminalTaskStatus(status) { fail := status == StatusFailed || status == StatusFinalizingFailed || status == StatusQualityFailed || status == StatusInterrupted for i := range steps { if steps[i].Status == "running" { if fail { steps[i].Status = "failed" } else { steps[i].Status = "completed" } steps[i] = NormalizeSettledStep(steps[i]) } } } // 写出快照前再统一归一化 completed 步骤 label:applyPhasePayload 各收口分支已归一化, // 此处作为防御性兜底,确保任何路径落库的 completed 步骤文案都与终态一致(幂等)。 for i := range steps { steps[i] = NormalizeSettledStep(steps[i]) } return &PhaseSnapshot{ Steps: steps, Label: label, DurationMs: durationMs, Status: status, Title: strings.TrimSpace(title), } } func isTerminalTaskStatus(status string) bool { _, ok := terminalStatuses[status] return ok } // FoldPhaseEvents 纯折叠:phase 事件 → 步骤序列 + 最近阶段文案。 func FoldPhaseEvents(events []StoredEvent) ([]PhaseStepSnapshot, string) { strip := make([]PhaseStepSnapshot, 0, 8) label := "" for _, se := range events { ev := se.Event if ev.Type != stream.EventPhase || ev.Phase == nil || ev.Phase.PhaseID == "" { continue } phase := ev.Phase if msg := strings.TrimSpace(phase.Message); msg != "" { label = msg } strip = applyPhasePayload(strip, phase) } return strip, label } func applyPhasePayload(strip []PhaseStepSnapshot, phase *stream.PhasePayload) []PhaseStepSnapshot { meta, ok := phaseMetaTable[phase.PhaseID] if !ok { meta = phaseMeta{Icon: "⏳", Label: "任务推进"} } detail := strings.TrimSpace(phase.Message) base := detail if base == "" { base = meta.Label } label := decoratePhaseLabel(base, phase) key := phaseStepKey(phase) if idx := indexOfStep(strip, key); idx >= 0 { cur := strip[idx] cur.Label = label if detail != "" { cur.Detail = detail } if cur.Status == "failed" && phase.PhaseID != "step_failed" { cur.Status = "running" } // 已收口步骤被重复事件刷新文案时,保持 label 与 status 语义一致。 strip[idx] = NormalizeSettledStep(cur) return strip } if phase.PhaseID == "step_failed" { if hasRunning(strip) { out := make([]PhaseStepSnapshot, len(strip)) copy(out, strip) for i := range out { if out[i].Status == "running" { out[i].Status = "failed" if detail != "" { out[i].Detail = detail } // 失败节点有意保留原步骤文案(红色图标 + 错误气泡承载细节),不归一化 label。 } } return out } return append(strip, PhaseStepSnapshot{ Step: key, Icon: meta.Icon, Label: label, Status: "failed", Detail: detail, }) } if _, close := closePhases[phase.PhaseID]; close { out := make([]PhaseStepSnapshot, len(strip)) copy(out, strip) for i := range out { if out[i].Status == "running" { out[i].Status = "completed" out[i] = NormalizeSettledStep(out[i]) } } return out } settled := make([]PhaseStepSnapshot, len(strip)) copy(settled, strip) for i := range settled { if settled[i].Status == "running" { settled[i].Status = "completed" settled[i] = NormalizeSettledStep(settled[i]) } } return append(settled, PhaseStepSnapshot{ Step: key, Icon: meta.Icon, Label: label, Status: "running", Detail: detail, }) } // phaseIDOfStep 从 step key 还原出 phase_id(去掉 "#" 后缀)。 func phaseIDOfStep(step string) string { if i := strings.IndexByte(step, '#'); i >= 0 { return step[:i] } return step } // NormalizeSettledStep 把处于 completed 终态的步骤标签归一化为静态措辞。 // // 收口时若只翻转 Status 而不动 Label,label 会永久冻结成"正在…(NN%)""(第 x/y 步)" // 这类进度中叙述,导致卡片即便步骤已完成仍读起来像"还在跑"(缺陷见消息 #4424)。 // 这里统一改写成 phaseMetaTable 短标签 + "已完成",Detail 保留原始叙述供审计。 // // 仅处理 completed:failed 步骤有意保留原步骤文案(红色图标 + 错误气泡承载失败细节); // pending / running 不改动。导出供回填脚本复用,避免逻辑漂移。 func NormalizeSettledStep(step PhaseStepSnapshot) PhaseStepSnapshot { if step.Status != "completed" { return step } short := phaseMetaTable[phaseIDOfStep(step.Step)].Label if short == "" { short = "任务推进" } const suffix = "已完成" // 保留"(第 x/y 步)"结构后缀(多步任务需区分进步骤),丢弃百分比与进度中叙述。 label := short + suffix if loc := stepIndexSuffixRe.FindStringIndex(step.Label); loc != nil { label = short + step.Label[loc[0]:loc[1]] + suffix } if step.Label == label { return step // 幂等 } step.Label = label return step } func phaseStepKey(phase *stream.PhasePayload) string { if phase.StepIndex > 0 { return fmt.Sprintf("%s#%d", phase.PhaseID, phase.StepIndex) } return phase.PhaseID } func decoratePhaseLabel(base string, phase *stream.PhasePayload) string { if phase.StepIndex > 0 && phase.TotalSteps > 0 { return fmt.Sprintf("%s(第 %d/%d 步)", base, phase.StepIndex, phase.TotalSteps) } if phase.Progress > 0 { return fmt.Sprintf("%s(%d%%)", base, phase.Progress) } return base } func indexOfStep(strip []PhaseStepSnapshot, key string) int { for i := range strip { if strip[i].Step == key { return i } } return -1 } func hasRunning(strip []PhaseStepSnapshot) bool { for i := range strip { if strip[i].Status == "running" { return true } } return false }