Files
waooplus/src/lib/query/hooks/run-stream/state-machine.ts
2026-03-08 17:10:06 +08:00

587 lines
18 KiB
TypeScript

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<string>()
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<string, RunStepState>
memo: Map<string, number>
visiting: Set<string>
}): 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<string, unknown>)
: event.payload || base.summary
base.payload = event.payload || base.payload
const finalizedSteps: Record<string, RunStepState> = { ...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<string, RunStepState> = { ...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<string, number>()
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<string>(),
})
const levelB = computeStepDependencyLevel({
stepId: b,
stepsById: base.stepsById,
memo: levelMemo,
visiting: new Set<string>(),
})
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 ''
}