import type { RunStepStatus, RunStreamEvent, RunStreamLane, RunStreamStatus, } from '@/lib/novel-promotion/run-stream/types' import type { RunState, RunStepState, StageViewStatus, } from './types' export function toTimestamp(ts: string | undefined, fallback: number): number { if (!ts) return fallback const parsed = Date.parse(ts) return Number.isFinite(parsed) ? parsed : fallback } function rankStepStatus(status: RunStepStatus): number { if (status === 'pending' || status === 'blocked') return 0 if (status === 'running') return 1 if (status === 'completed' || status === 'stale') return 2 return 3 } function rankRunStatus(status: RunStreamStatus): number { if (status === 'idle') return 0 if (status === 'running') return 1 if (status === 'completed') return 2 return 3 } function lockForwardStepStatus(prev: RunStepStatus, next: RunStepStatus): RunStepStatus { if (prev === 'failed') return prev if (prev === 'completed' && next !== 'stale') return prev if (prev === 'stale' && next !== 'failed') return prev return rankStepStatus(next) >= rankStepStatus(prev) ? next : prev } function lockForwardRunStatus(prev: RunStreamStatus, next: RunStreamStatus): RunStreamStatus { if (prev === 'completed' || prev === 'failed') return prev return rankRunStatus(next) >= rankRunStatus(prev) ? next : prev } function normalizeLane(value: unknown): RunStreamLane { return value === 'reasoning' ? 'reasoning' : 'text' } function splitThinkTaggedContent(input: string): { text: string; reasoning: string } { const thinkTagPattern = /<(think|thinking)\b[^>]*>([\s\S]*?)<\/\1>/gi const reasoningParts: string[] = [] let matched = false const stripped = input.replace(thinkTagPattern, (_fullMatch, _tagName: string, inner: string) => { matched = true const trimmed = inner.trim() if (trimmed) reasoningParts.push(trimmed) return '' }) if (!matched) { return { text: input, reasoning: '', } } return { text: stripped.trim(), reasoning: reasoningParts.join('\n\n').trim(), } } function mergeReasoningText(current: string, incoming: string): string { const next = incoming.trim() if (!next) return current const prev = current.trim() if (!prev) return next if (prev.includes(next)) return current return `${prev}\n\n${next}` } function normalizeThinkTaggedStepOutput(step: RunStepState) { if (!step.textOutput) return const parsed = splitThinkTaggedContent(step.textOutput) if (!parsed.reasoning) return step.textOutput = parsed.text step.reasoningOutput = mergeReasoningText(step.reasoningOutput, parsed.reasoning) } function parseStepIdentity(rawStepId: string): { canonicalStepId: string attempt: number } { const matched = rawStepId.match(/^(.*)_r([0-9]+)$/) if (!matched) { return { canonicalStepId: rawStepId, attempt: 1, } } const baseStepId = matched[1]?.trim() const attempt = Number.parseInt(matched[2] || '1', 10) if (!baseStepId || !Number.isFinite(attempt) || attempt < 2) { return { canonicalStepId: rawStepId, attempt: 1, } } return { canonicalStepId: baseStepId, attempt, } } function normalizeStepStatus(value: unknown): RunStepStatus { if ( value === 'running' || value === 'completed' || value === 'failed' || value === 'blocked' || value === 'stale' ) { return value } return 'pending' } function normalizeRunStatus(value: unknown): RunStreamStatus { if (value === 'running' || value === 'completed' || value === 'failed') return value return 'idle' } export function toStageViewStatus(status: RunStepStatus): StageViewStatus { if (status === 'running') return 'processing' if (status === 'pending') return 'pending' if (status === 'blocked') return 'blocked' if (status === 'stale') return 'stale' if (status === 'completed') return 'completed' return 'failed' } function readStringArray(value: unknown): string[] { if (!Array.isArray(value)) return [] const rows: string[] = [] for (const item of value) { if (typeof item !== 'string') continue const trimmed = item.trim() if (!trimmed) continue rows.push(trimmed) } return rows } function mergeStringArray(base: string[], incoming: string[]): string[] { if (incoming.length === 0) return base const seen = new Set() const next: string[] = [] for (const item of incoming) { if (seen.has(item)) continue seen.add(item) next.push(item) } return next } function readBool(value: unknown): boolean | null { if (value === true) return true if (value === false) return false return null } function buildDefaultStep(event: RunStreamEvent, now: number): RunStepState { const stepId = event.stepId || 'unknown_step' const stepAttempt = typeof event.stepAttempt === 'number' && Number.isFinite(event.stepAttempt) ? Math.max(1, Math.floor(event.stepAttempt)) : 1 const stepTitle = typeof event.stepTitle === 'string' && event.stepTitle.trim() ? event.stepTitle : stepId const stepIndex = typeof event.stepIndex === 'number' && Number.isFinite(event.stepIndex) ? Math.max(1, Math.floor(event.stepIndex)) : 1 const stepTotal = typeof event.stepTotal === 'number' && Number.isFinite(event.stepTotal) ? Math.max(stepIndex, Math.floor(event.stepTotal)) : stepIndex return { id: stepId, attempt: stepAttempt, title: stepTitle, stepIndex, stepTotal, status: 'pending', dependsOn: [], blockedBy: [], groupId: null, parallelKey: null, retryable: true, textOutput: '', reasoningOutput: '', textLength: 0, reasoningLength: 0, message: '', errorMessage: '', updatedAt: now, seqByLane: { text: 0, reasoning: 0, }, } } function resetStepForRetry(step: RunStepState, attempt: number) { step.attempt = attempt step.status = 'pending' step.textOutput = '' step.reasoningOutput = '' step.textLength = 0 step.reasoningLength = 0 step.message = '' step.errorMessage = '' step.blockedBy = [] step.seqByLane = { text: 0, reasoning: 0, } } function createInitialRunState(runId: string, now: number): RunState { return { runId, status: 'running', startedAt: now, updatedAt: now, terminalAt: null, errorMessage: '', summary: null, payload: null, stepsById: {}, stepOrder: [], activeStepId: null, selectedStepId: null, } } function computeStepDependencyLevel(params: { stepId: string stepsById: Record memo: Map visiting: Set }): number { const { stepId, stepsById, memo, visiting } = params const cached = memo.get(stepId) if (typeof cached === 'number') return cached const step = stepsById[stepId] if (!step) return 0 if (visiting.has(stepId)) { // Unexpected cycle: keep deterministic order without recursion loop. return Math.max(0, step.stepIndex - 1) } visiting.add(stepId) let level = 0 for (const dep of step.dependsOn) { const depLevel = computeStepDependencyLevel({ stepId: dep, stepsById, memo, visiting, }) level = Math.max(level, depLevel + 1) } visiting.delete(stepId) memo.set(stepId, level) return level } export function applyRunStreamEvent(prev: RunState | null, event: RunStreamEvent): RunState | null { const now = toTimestamp(event.ts, Date.now()) const runId = event.runId || prev?.runId || '' if (!runId) return prev const base: RunState = prev && prev.runId === runId ? { ...prev } : createInitialRunState(runId, now) const prevActiveStepId = base.activeStepId base.updatedAt = now if (event.event === 'run.start') { const nextStatus = normalizeRunStatus(event.status) base.status = lockForwardRunStatus(base.status, nextStatus === 'idle' ? 'running' : nextStatus) if (event.payload && typeof event.payload === 'object') { base.payload = event.payload } return base } if (event.event === 'run.complete') { base.status = lockForwardRunStatus(base.status, 'completed') base.summary = event.payload?.summary && typeof event.payload.summary === 'object' ? (event.payload.summary as Record) : event.payload || base.summary base.payload = event.payload || base.payload const finalizedSteps: Record = { ...base.stepsById } for (const stepId of base.stepOrder) { const currentStep = finalizedSteps[stepId] if (!currentStep) continue if (currentStep.status === 'completed' || currentStep.status === 'failed' || currentStep.status === 'stale') continue finalizedSteps[stepId] = { ...currentStep, status: 'completed', updatedAt: now, } } base.stepsById = finalizedSteps base.terminalAt = now return base } if (event.event === 'run.error') { base.status = lockForwardRunStatus(base.status, 'failed') const runErrorMessage = typeof event.message === 'string' ? event.message : base.errorMessage base.errorMessage = runErrorMessage // When only run.error is emitted (without step.error), mark unfinished steps failed // so the UI does not keep showing "processing" forever. const nextStepsById: Record = { ...base.stepsById } for (const stepId of base.stepOrder) { const currentStep = nextStepsById[stepId] if (!currentStep) continue if (currentStep.status === 'completed' || currentStep.status === 'failed') continue nextStepsById[stepId] = { ...currentStep, status: 'failed', errorMessage: currentStep.errorMessage || runErrorMessage, updatedAt: now, } } base.stepsById = nextStepsById base.terminalAt = now return base } const rawStepId = event.stepId if (!rawStepId) return base const stepIdentity = parseStepIdentity(rawStepId) const stepId = stepIdentity.canonicalStepId const incomingAttempt = typeof event.stepAttempt === 'number' && Number.isFinite(event.stepAttempt) ? Math.max(1, Math.floor(event.stepAttempt)) : stepIdentity.attempt const existingStep = base.stepsById[stepId] const step = existingStep ? { ...existingStep } : buildDefaultStep({ ...event, stepId, stepAttempt: incomingAttempt }, now) if (!Number.isFinite(step.attempt) || step.attempt < 1) { step.attempt = 1 } if (incomingAttempt < step.attempt) { return base } if (incomingAttempt > step.attempt) { resetStepForRetry(step, incomingAttempt) base.errorMessage = '' } step.updatedAt = now if (typeof event.stepTitle === 'string' && event.stepTitle.trim()) { step.title = event.stepTitle.trim() } if (typeof event.stepIndex === 'number' && Number.isFinite(event.stepIndex)) { step.stepIndex = Math.max(1, Math.floor(event.stepIndex)) } if (typeof event.stepTotal === 'number' && Number.isFinite(event.stepTotal)) { step.stepTotal = Math.max(step.stepIndex, Math.floor(event.stepTotal)) } if (Array.isArray(event.dependsOn)) { step.dependsOn = mergeStringArray(step.dependsOn, readStringArray(event.dependsOn)) } if (Array.isArray(event.blockedBy)) { step.blockedBy = mergeStringArray(step.blockedBy, readStringArray(event.blockedBy)) } if (typeof event.groupId === 'string' && event.groupId.trim()) { step.groupId = event.groupId.trim() } if (typeof event.parallelKey === 'string' && event.parallelKey.trim()) { step.parallelKey = event.parallelKey.trim() } if (typeof event.retryable === 'boolean') { step.retryable = event.retryable } if (event.event === 'step.start') { if (event.blockedBy && event.blockedBy.length > 0) { step.status = lockForwardStepStatus(step.status, 'blocked') } else { step.blockedBy = [] step.status = lockForwardStepStatus(step.status, 'running') base.status = lockForwardRunStatus(base.status, 'running') } } if (event.event === 'step.chunk') { const lane = normalizeLane(event.lane) const seq = typeof event.seq === 'number' && Number.isFinite(event.seq) ? Math.max(1, Math.floor(event.seq)) : null const lastSeq = step.seqByLane[lane] if (seq === null || seq > lastSeq) { if (step.status === 'completed') { // Late chunks can arrive after a premature step.complete event. // Reopen the step so UI does not show "completed" while output is still growing. step.status = 'running' } if (seq !== null) { step.seqByLane = { ...step.seqByLane, [lane]: seq, } } if (lane === 'reasoning') { const delta = typeof event.reasoningDelta === 'string' ? event.reasoningDelta : typeof event.textDelta === 'string' ? event.textDelta : '' if (delta) step.reasoningOutput += delta } else { const delta = typeof event.textDelta === 'string' ? event.textDelta : typeof event.reasoningDelta === 'string' ? event.reasoningDelta : '' if (delta) { step.textOutput += delta normalizeThinkTaggedStepOutput(step) } } } if (event.blockedBy && event.blockedBy.length > 0) { step.status = lockForwardStepStatus(step.status, 'blocked') } else { step.blockedBy = [] step.status = lockForwardStepStatus(step.status, 'running') base.status = lockForwardRunStatus(base.status, 'running') } step.textLength = step.textOutput.length step.reasoningLength = step.reasoningOutput.length } if (event.event === 'step.complete') { if (typeof event.text === 'string' && event.text.length >= step.textOutput.length) { step.textOutput = event.text normalizeThinkTaggedStepOutput(step) } if (typeof event.reasoning === 'string' && event.reasoning.length >= step.reasoningOutput.length) { step.reasoningOutput = event.reasoning } step.textLength = step.textOutput.length step.reasoningLength = step.reasoningOutput.length const normalizedStatus = normalizeStepStatus(event.status) if (normalizedStatus === 'stale') { step.status = lockForwardStepStatus(step.status, 'stale') } else { step.status = lockForwardStepStatus( step.status, normalizedStatus === 'failed' ? 'failed' : 'completed', ) } } if (event.event === 'step.error') { step.status = lockForwardStepStatus(step.status, 'failed') step.errorMessage = typeof event.message === 'string' ? event.message : step.errorMessage base.errorMessage = step.errorMessage || base.errorMessage } if (typeof event.message === 'string' && event.message) { step.message = event.message } const staleByPayload = readBool(event.payload?.stale) if (staleByPayload === true) { step.status = 'stale' } const blockedByFromEvent = Array.isArray(event.blockedBy) ? readStringArray(event.blockedBy) : [] const blockedByPayload = readStringArray(event.payload?.blockedBy) const blockedBy = blockedByPayload.length > 0 ? blockedByPayload : blockedByFromEvent if (blockedBy.length > 0) { step.blockedBy = blockedBy if (step.status !== 'failed' && step.status !== 'completed') { step.status = 'blocked' } } else if (event.event === 'step.start' || event.event === 'step.chunk') { step.blockedBy = [] } base.stepsById = { ...base.stepsById, [stepId]: step, } if (!base.stepOrder.includes(stepId)) { base.stepOrder = [...base.stepOrder, stepId] } const levelMemo = new Map() base.stepOrder = [...base.stepOrder].sort((a, b) => { const sa = base.stepsById[a] const sb = base.stepsById[b] if (!sa || !sb) return 0 const levelA = computeStepDependencyLevel({ stepId: a, stepsById: base.stepsById, memo: levelMemo, visiting: new Set(), }) const levelB = computeStepDependencyLevel({ stepId: b, stepsById: base.stepsById, memo: levelMemo, visiting: new Set(), }) if (levelA !== levelB) return levelA - levelB if (sa.stepIndex !== sb.stepIndex) return sa.stepIndex - sb.stepIndex return sa.id.localeCompare(sb.id) }) const runningSteps = Object.values(base.stepsById).filter((item) => item.status === 'running') if (runningSteps.length > 0) { const maxRunningStepIndex = Math.max(...runningSteps.map((item) => item.stepIndex)) const topCandidates = runningSteps .filter((item) => item.stepIndex === maxRunningStepIndex) .sort((a, b) => { if (a.updatedAt !== b.updatedAt) return b.updatedAt - a.updatedAt return a.id.localeCompare(b.id) }) const keepCurrentActive = base.activeStepId && topCandidates.some((item) => item.id === base.activeStepId) ? base.activeStepId : null base.activeStepId = keepCurrentActive || topCandidates[0]?.id || null } else { const allSteps = Object.values(base.stepsById) if (allSteps.length === 0) { base.activeStepId = null } else { const maxStepIndex = Math.max(...allSteps.map((item) => item.stepIndex)) const topCandidates = allSteps .filter((item) => item.stepIndex === maxStepIndex) .sort((a, b) => { if (a.updatedAt !== b.updatedAt) return b.updatedAt - a.updatedAt return a.id.localeCompare(b.id) }) base.activeStepId = topCandidates[0]?.id || null } } if ( !base.selectedStepId || !base.stepsById[base.selectedStepId] || base.selectedStepId === prevActiveStepId ) { base.selectedStepId = base.activeStepId } return base } export function getStageOutput(step: RunStepState | null) { if (!step) return '' if (step.reasoningOutput && step.textOutput) { return `【思考过程】\n${step.reasoningOutput}\n\n【最终结果】\n${step.textOutput}` } if (step.reasoningOutput) return `【思考过程】\n${step.reasoningOutput}` if (step.textOutput) return `【最终结果】\n${step.textOutput}` if (step.status === 'failed' && step.errorMessage) return `【错误】\n${step.errorMessage}` return '' }