587 lines
18 KiB
TypeScript
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 ''
|
|
}
|