127 lines
5.5 KiB
TypeScript
127 lines
5.5 KiB
TypeScript
import type { PhaseEvent } from '../types/workbench';
|
||
|
||
/** 后端 phase 事件载荷(与 StreamEvent['phase'] 结构兼容,保持 mapper 独立可测) */
|
||
export type PhaseEventPayload = {
|
||
phase_id?: string;
|
||
message?: string;
|
||
role?: string;
|
||
step_index?: number;
|
||
total_steps?: number;
|
||
progress?: number;
|
||
};
|
||
|
||
/** phase_id → 图标/短标签映射(与 bizdev-cli/main.go 的 CLI 映射保持一致) */
|
||
export const PHASE_META: Record<string, { icon: string; label: string }> = {
|
||
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: '任务异常' },
|
||
document_parsing: { icon: '📄', label: '文档解析' },
|
||
document_summarizing: { icon: '📝', label: '正文总结' },
|
||
};
|
||
|
||
/** 收口型 phase:不追加新节点,仅把当前 running 条目置为 completed */
|
||
const CLOSE_PHASES = new Set(['step_completed', 'stage_completed']);
|
||
|
||
/** 从 step key 还原 phase_id(去掉 "#<idx>" 后缀) */
|
||
function phaseIdOfStep(step: string): string {
|
||
const idx = step.indexOf('#');
|
||
return idx >= 0 ? step.slice(0, idx) : step;
|
||
}
|
||
|
||
/**
|
||
* settleStep 把处于 completed 终态的条目标签归一化为静态措辞(短标签 + "已完成"),
|
||
* 避免收口后 label 冻结成"正在…(NN%)"进度中叙述。
|
||
* 与后端 taskrun.NormalizeSettledStep 语义对齐:failed 有意保留原文案(错误气泡承载细节),
|
||
* pending / running 不改动。
|
||
*/
|
||
export function settleStep(e: PhaseEvent): PhaseEvent {
|
||
if (e.status !== 'completed') return e;
|
||
const short = PHASE_META[phaseIdOfStep(e.step)]?.label ?? '任务推进';
|
||
// 保留"(第 x/y 步)"结构后缀(多步任务需区分进步骤),丢弃百分比与进度中叙述。
|
||
const idxSuffix = e.label.match(/(第 \d+\/\d+ 步)/)?.[0] ?? '';
|
||
const label = short + idxSuffix + '已完成';
|
||
if (e.label === label) return e; // 幂等
|
||
return { ...e, label };
|
||
}
|
||
|
||
function decorateLabel(base: string, phase: PhaseEventPayload): string {
|
||
const { step_index, total_steps, progress } = phase;
|
||
if (step_index && step_index > 0 && total_steps && total_steps > 0) {
|
||
return `${base}(第 ${step_index}/${total_steps} 步)`;
|
||
}
|
||
if (progress && progress > 0) {
|
||
return `${base}(${progress}%)`;
|
||
}
|
||
return base;
|
||
}
|
||
|
||
/** 条目 key:同 phase 且无有效 step_index 时刷新文案;带 step_index 的重复 phase 各占一节点 */
|
||
function stepKeyOf(phase: PhaseEventPayload): string {
|
||
const idx = phase.step_index ?? 0;
|
||
return idx > 0 ? `${phase.phase_id}#${idx}` : `${phase.phase_id}`;
|
||
}
|
||
|
||
/**
|
||
* applyPhaseEvent 把一条后端 phase 事件折叠进 PhaseEventStrip 序列(纯函数)。
|
||
* 进度求真:仅由真实 phase 事件驱动,无任何人为进度推演。
|
||
*/
|
||
export function applyPhaseEvent(strip: PhaseEvent[], phase?: PhaseEventPayload): PhaseEvent[] {
|
||
if (!phase?.phase_id) return strip;
|
||
|
||
const meta = PHASE_META[phase.phase_id] ?? { icon: '⏳', label: '任务推进' };
|
||
// 进度求真:优先展示后端下发的真实进度文案,短标签仅作缺省
|
||
const detail = phase.message?.trim() || undefined;
|
||
const base = detail || meta.label;
|
||
const label = decorateLabel(base, phase);
|
||
const key = stepKeyOf(phase);
|
||
const role = phase.role?.trim() || undefined;
|
||
|
||
// 已存在的条目:刷新文案;非失败事件再次到达时,failed 条目恢复 running(重试/续跑重新执行该步骤)
|
||
const existingIdx = strip.findIndex((e) => e.step === key);
|
||
if (existingIdx >= 0) {
|
||
return strip.map((e, i) => {
|
||
if (i !== existingIdx) return e;
|
||
const nextRole = role || e.role;
|
||
if (e.status === 'failed' && phase.phase_id !== 'step_failed') {
|
||
return { ...e, label, detail, role: nextRole, status: 'running' as const };
|
||
}
|
||
// 已收口条目被重复事件刷新文案时,保持 label 与 status 语义一致。
|
||
return settleStep({ ...e, label, detail, role: nextRole });
|
||
});
|
||
}
|
||
|
||
// 步骤失败:把当前 running 条目置为 failed(红色视觉态);无 running 条目时追加失败节点
|
||
if (phase.phase_id === 'step_failed') {
|
||
if (strip.some((e) => e.status === 'running')) {
|
||
return strip.map((e) =>
|
||
e.status === 'running'
|
||
? { ...e, status: 'failed' as const, detail: detail ?? e.detail, role: role || e.role }
|
||
: e,
|
||
);
|
||
}
|
||
return [...strip, { step: key, icon: meta.icon, label, detail, role, status: 'failed' as const }];
|
||
}
|
||
|
||
// 收口型 phase:running 条目置 completed,不追加新节点
|
||
if (CLOSE_PHASES.has(phase.phase_id)) {
|
||
return strip.map((e) => (e.status === 'running' ? settleStep({ ...e, status: 'completed' as const }) : e));
|
||
}
|
||
|
||
// 新 phase:先行条目全部置 completed,追加当前节点为 running
|
||
const settled = strip.map((e) =>
|
||
e.status === 'running' ? settleStep({ ...e, status: 'completed' as const }) : e
|
||
);
|
||
return [
|
||
...settled,
|
||
{ step: key, icon: meta.icon, label, detail, role, status: 'running' as const },
|
||
];
|
||
} |