feat: initial release v0.3.0

This commit is contained in:
saturn
2026-03-08 03:15:27 +08:00
commit 881ed44996
1311 changed files with 225407 additions and 0 deletions

View File

@@ -0,0 +1,347 @@
import type { RunStreamEvent } from '@/lib/novel-promotion/run-stream/types'
import { TASK_EVENT_TYPE, TASK_SSE_EVENT_TYPE, type SSEEvent } from '@/lib/task/types'
import { resolveTaskErrorMessage as resolveUnifiedTaskErrorMessage } from '@/lib/task/error-message'
import type { RunResult } from './types'
export function parseSSEBlock(block: string): { event: string; data: string } | null {
const lines = block.split('\n')
let event = 'message'
const dataLines: string[] = []
for (const line of lines) {
if (!line) continue
if (line.startsWith('event:')) {
event = line.slice(6).trim()
continue
}
if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trim())
}
}
if (dataLines.length === 0) return null
return {
event,
data: dataLines.join('\n'),
}
}
export function toObject(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
return value as Record<string, unknown>
}
export function readTextField(payload: Record<string, unknown>, key: string): string | undefined {
const value = payload[key]
return typeof value === 'string' ? value : undefined
}
export function readStepField(payload: Record<string, unknown>, key: string): number | undefined {
const value = payload[key]
return typeof value === 'number' && Number.isFinite(value) ? Math.max(1, Math.floor(value)) : undefined
}
function readStringArrayField(payload: Record<string, unknown>, key: string): string[] {
const value = payload[key]
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 readBoolField(payload: Record<string, unknown>, key: string): boolean | undefined {
const value = payload[key]
if (value === true) return true
if (value === false) return false
return undefined
}
function normalizeLifecycleType(value: unknown): string | null {
if (typeof value !== 'string') return null
if (value === TASK_EVENT_TYPE.PROGRESS) return TASK_EVENT_TYPE.PROCESSING
if (
value === TASK_EVENT_TYPE.CREATED ||
value === TASK_EVENT_TYPE.PROCESSING ||
value === TASK_EVENT_TYPE.COMPLETED ||
value === TASK_EVENT_TYPE.FAILED
) {
return value
}
return null
}
function stageLooksCompleted(stage: string | undefined) {
if (!stage) return false
return (
stage === 'llm_completed' ||
stage === 'worker_llm_completed' ||
stage === 'worker_llm_complete' ||
stage === 'llm_proxy_persist' ||
stage === 'completed'
)
}
function stageLooksFailed(stage: string | undefined) {
if (!stage) return false
return stage === 'llm_error' || stage === 'worker_llm_error' || stage === 'error'
}
function resolveTaskErrorMessage(payload: Record<string, unknown>, fallback = 'task failed') {
return resolveUnifiedTaskErrorMessage(payload, fallback)
}
function extractTerminalPayload(payload: Record<string, unknown>) {
const result = toObject(payload.result)
if (Object.keys(result).length > 0) {
return result
}
return payload
}
export function mapTaskSSEEventToRunEvents(event: SSEEvent): RunStreamEvent[] {
const rawPayload = toObject(event.payload)
const payloadMeta = toObject(rawPayload.meta)
const runId = readTextField(rawPayload, 'runId')
|| readTextField(payloadMeta, 'runId')
|| (typeof event.taskId === 'string' ? event.taskId : '')
if (!runId) return []
const payload = rawPayload
const lifecycleType =
event.type === TASK_SSE_EVENT_TYPE.LIFECYCLE
? normalizeLifecycleType(payload.lifecycleType)
: null
const ts = typeof event.ts === 'string' ? event.ts : new Date().toISOString()
const flowStageTitle = readTextField(payload, 'flowStageTitle')
const flowStageIndex = readStepField(payload, 'flowStageIndex')
const flowStageTotal = readStepField(payload, 'flowStageTotal')
const rawStepId = readTextField(payload, 'stepId')
const rawStepTitle = readTextField(payload, 'stepTitle')
const stepId =
rawStepId ||
(
event.type === TASK_SSE_EVENT_TYPE.STREAM
? `step:${event.taskType || 'llm'}`
: undefined)
const stepAttempt = readStepField(payload, 'stepAttempt')
const stepTitle = rawStepTitle || flowStageTitle || undefined
const stepIndex = readStepField(payload, 'stepIndex') ?? flowStageIndex
const stepTotal = readStepField(payload, 'stepTotal') ?? flowStageTotal
const stage = readTextField(payload, 'stage')
const message = readTextField(payload, 'message')
const done = payload.done === true
const text = readTextField(payload, 'output') || readTextField(payload, 'text')
const reasoning = readTextField(payload, 'reasoning') || readTextField(payload, 'thinking')
const dependsOn = readStringArrayField(payload, 'dependsOn')
const blockedBy = readStringArrayField(payload, 'blockedBy')
const groupId = readTextField(payload, 'groupId')
const parallelKey = readTextField(payload, 'parallelKey')
const retryable = readBoolField(payload, 'retryable')
const stale = readBoolField(payload, 'stale')
if (event.type === TASK_SSE_EVENT_TYPE.STREAM) {
const stream = toObject(payload.stream)
const kind = stream.kind === 'reasoning' ? 'reasoning' : 'text'
const delta = typeof stream.delta === 'string' ? stream.delta : ''
if (!delta || !stepId) return []
const lane = stream.lane === 'reasoning' || kind === 'reasoning' ? 'reasoning' : 'text'
const seq =
typeof stream.seq === 'number' && Number.isFinite(stream.seq) ? Math.max(1, Math.floor(stream.seq)) : undefined
const streamStepAttempt =
typeof stream.attempt === 'number' && Number.isFinite(stream.attempt)
? Math.max(1, Math.floor(stream.attempt))
: undefined
return [{
runId,
event: 'step.chunk',
ts,
status: 'running',
stepId,
stepAttempt: stepAttempt ?? streamStepAttempt,
stepTitle,
stepIndex,
stepTotal,
dependsOn: dependsOn.length > 0 ? dependsOn : undefined,
blockedBy: blockedBy.length > 0 ? blockedBy : undefined,
groupId,
parallelKey,
retryable,
lane,
seq,
textDelta: lane === 'text' ? delta : undefined,
reasoningDelta: lane === 'reasoning' ? delta : undefined,
message,
}]
}
if (event.type !== TASK_SSE_EVENT_TYPE.LIFECYCLE) return []
const runEvents: RunStreamEvent[] = []
if (lifecycleType === TASK_EVENT_TYPE.CREATED) {
runEvents.push({
runId,
event: 'run.start',
ts,
status: 'running',
message,
payload,
})
return runEvents
}
if (lifecycleType === TASK_EVENT_TYPE.PROCESSING) {
if (stepId) {
runEvents.push({
runId,
event: 'step.start',
ts,
status: 'running',
stepId,
stepAttempt,
stepTitle,
stepIndex,
stepTotal,
dependsOn: dependsOn.length > 0 ? dependsOn : undefined,
blockedBy: blockedBy.length > 0 ? blockedBy : undefined,
groupId,
parallelKey,
retryable,
message,
})
if (done || stageLooksCompleted(stage)) {
runEvents.push({
runId,
event: 'step.complete',
ts,
status: stale ? 'stale' : 'completed',
stepId,
stepAttempt,
stepTitle,
stepIndex,
stepTotal,
dependsOn: dependsOn.length > 0 ? dependsOn : undefined,
blockedBy: blockedBy.length > 0 ? blockedBy : undefined,
groupId,
parallelKey,
retryable,
text,
reasoning,
message,
})
} else if (stageLooksFailed(stage)) {
runEvents.push({
runId,
event: 'step.error',
ts,
status: 'failed',
stepId,
stepAttempt,
stepTitle,
stepIndex,
stepTotal,
dependsOn: dependsOn.length > 0 ? dependsOn : undefined,
blockedBy: blockedBy.length > 0 ? blockedBy : undefined,
groupId,
parallelKey,
retryable,
message: resolveTaskErrorMessage(payload),
})
}
}
return runEvents
}
if (lifecycleType === TASK_EVENT_TYPE.COMPLETED) {
if (stepId) {
runEvents.push({
runId,
event: 'step.complete',
ts,
status: 'completed',
stepId,
stepAttempt,
stepTitle,
stepIndex,
stepTotal,
dependsOn: dependsOn.length > 0 ? dependsOn : undefined,
blockedBy: blockedBy.length > 0 ? blockedBy : undefined,
groupId,
parallelKey,
retryable,
text,
reasoning,
message,
})
}
runEvents.push({
runId,
event: 'run.complete',
ts,
status: 'completed',
message,
payload: extractTerminalPayload(payload),
})
return runEvents
}
if (lifecycleType === TASK_EVENT_TYPE.FAILED) {
const errorMessage = resolveTaskErrorMessage(payload)
if (stepId) {
runEvents.push({
runId,
event: 'step.error',
ts,
status: 'failed',
stepId,
stepAttempt,
stepTitle,
stepIndex,
stepTotal,
dependsOn: dependsOn.length > 0 ? dependsOn : undefined,
blockedBy: blockedBy.length > 0 ? blockedBy : undefined,
groupId,
parallelKey,
retryable,
message: errorMessage,
})
}
runEvents.push({
runId,
event: 'run.error',
ts,
status: 'failed',
message: errorMessage,
payload,
})
}
return runEvents
}
export function toTerminalRunResult(event: RunStreamEvent): RunResult | null {
if (event.event !== 'run.complete' && event.event !== 'run.error') return null
const summaryFromPayload =
event.payload &&
typeof event.payload.summary === 'object' &&
event.payload.summary
? (event.payload.summary as Record<string, unknown>)
: null
return {
runId: event.runId,
status: event.event === 'run.complete' ? 'completed' : 'failed',
summary:
event.event === 'run.complete'
? summaryFromPayload || event.payload || null
: summaryFromPayload,
payload: event.payload || null,
errorMessage: event.event === 'run.error' ? event.message || 'run failed' : '',
}
}

View File

@@ -0,0 +1,211 @@
import type { RunStreamEvent } from '@/lib/novel-promotion/run-stream/types'
import { toTerminalRunResult } from './event-parser'
import { fetchRunEventsPage, toRunStreamEventFromRunApi } from './run-event-adapter'
import { apiFetch } from '@/lib/api-fetch'
const POLL_INTERVAL_MS = 1500
const RUN_TERMINAL_RECONCILE_EMPTY_POLLS = 2
function toObject(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
return value as Record<string, unknown>
}
function readText(value: unknown): string {
return typeof value === 'string' ? value : ''
}
async function reconcileRunTerminalState(runId: string): Promise<{
status: 'completed' | 'failed'
message?: string
payload?: Record<string, unknown>
} | null> {
const response = await apiFetch(`/api/runs/${runId}`, {
method: 'GET',
cache: 'no-store',
})
if (!response.ok) return null
const snapshot = await response.json().catch(() => null)
const root = toObject(snapshot)
const run = toObject(root.run)
const status = readText(run.status)
if (status === 'completed') {
const output = toObject(run.output)
return {
status: 'completed',
payload: Object.keys(output).length > 0 ? output : run,
}
}
if (status === 'failed' || status === 'canceled') {
return {
status: 'failed',
message: readText(run.errorMessage) || `run ${status}`,
payload: run,
}
}
return null
}
type SubscribeRecoveredRunArgs = {
runId: string
taskStreamTimeoutMs: number
applyAndCapture: (event: RunStreamEvent) => void
onSettled: () => void
}
type Cleanup = () => void
export function subscribeRecoveredRun(args: SubscribeRecoveredRunArgs): Cleanup {
let settled = false
let polling = false
let afterSeq = 0
let emptyPollCount = 0
let idleTimeoutTimer: ReturnType<typeof setTimeout> | null = null
let pollTimer: ReturnType<typeof setInterval> | null = null
function cleanup() {
if (idleTimeoutTimer) {
clearTimeout(idleTimeoutTimer)
idleTimeoutTimer = null
}
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
function settle() {
if (settled) return
settled = true
cleanup()
args.onSettled()
}
function scheduleIdleTimeout() {
if (idleTimeoutTimer) {
clearTimeout(idleTimeoutTimer)
}
idleTimeoutTimer = setTimeout(() => {
if (settled) return
const timeoutMessage = `run stream timeout: ${args.runId}`
args.applyAndCapture({
runId: args.runId,
event: 'run.error',
ts: new Date().toISOString(),
status: 'failed',
message: timeoutMessage,
})
settle()
}, args.taskStreamTimeoutMs)
}
async function pollRunEvents() {
if (settled || polling) return
polling = true
try {
const rows = await fetchRunEventsPage({
runId: args.runId,
afterSeq,
})
let sawNewEvent = false
for (const row of rows) {
if (row.seq <= afterSeq) continue
sawNewEvent = true
if (row.seq > afterSeq + 1) {
const gapRows = await fetchRunEventsPage({
runId: args.runId,
afterSeq,
})
for (const gapRow of gapRows) {
if (gapRow.seq <= afterSeq) continue
scheduleIdleTimeout()
afterSeq = gapRow.seq
const gapEvent = toRunStreamEventFromRunApi({
runId: args.runId,
event: gapRow,
})
if (!gapEvent) continue
args.applyAndCapture(gapEvent)
if (toTerminalRunResult(gapEvent)) {
settle()
return
}
}
continue
}
scheduleIdleTimeout()
afterSeq = row.seq
const streamEvent = toRunStreamEventFromRunApi({
runId: args.runId,
event: row,
})
if (!streamEvent) continue
args.applyAndCapture(streamEvent)
if (toTerminalRunResult(streamEvent)) {
settle()
return
}
}
if (sawNewEvent) {
emptyPollCount = 0
} else {
emptyPollCount += 1
if (emptyPollCount >= RUN_TERMINAL_RECONCILE_EMPTY_POLLS) {
const reconciled = await reconcileRunTerminalState(args.runId)
if (reconciled) {
if (reconciled.status === 'completed') {
args.applyAndCapture({
runId: args.runId,
event: 'run.complete',
ts: new Date().toISOString(),
status: 'completed',
payload: reconciled.payload,
})
} else {
args.applyAndCapture({
runId: args.runId,
event: 'run.error',
ts: new Date().toISOString(),
status: 'failed',
message: reconciled.message || 'run failed',
payload: reconciled.payload,
})
}
settle()
return
}
emptyPollCount = 0
}
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
args.applyAndCapture({
runId: args.runId,
event: 'run.error',
ts: new Date().toISOString(),
status: 'failed',
message,
})
settle()
return
} finally {
polling = false
}
}
scheduleIdleTimeout()
pollTimer = setInterval(() => {
void pollRunEvents()
}, POLL_INTERVAL_MS)
void pollRunEvents()
return cleanup
}

View File

@@ -0,0 +1,295 @@
import type { RunStreamEvent } from '@/lib/novel-promotion/run-stream/types'
import { apiFetch } from '@/lib/api-fetch'
type JsonRecord = Record<string, unknown>
export type RunApiEvent = {
seq: number
eventType: string
stepKey?: string | null
attempt?: number | null
lane?: string | null
payload?: JsonRecord | null
createdAt?: string
}
function toObject(value: unknown): JsonRecord {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
return value as JsonRecord
}
function readText(value: unknown): string {
return typeof value === 'string' ? value : ''
}
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 readBool(value: unknown): boolean | undefined {
if (value === true) return true
if (value === false) return false
return undefined
}
function resolveErrorMessage(payload: JsonRecord, fallback: string): string {
const direct = readText(payload.message)
if (direct) return direct
const nested = readText(toObject(payload.error).message)
return nested || fallback
}
export function parseRunApiEventsPayload(payload: unknown): RunApiEvent[] {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return []
const root = payload as JsonRecord
if (!Array.isArray(root.events)) return []
const rows: RunApiEvent[] = []
for (const item of root.events) {
const row = toObject(item)
const seq = typeof row.seq === 'number' && Number.isFinite(row.seq)
? Math.max(1, Math.floor(row.seq))
: 0
if (seq <= 0) continue
rows.push({
seq,
eventType: readText(row.eventType),
stepKey: readText(row.stepKey) || null,
attempt:
typeof row.attempt === 'number' && Number.isFinite(row.attempt)
? Math.max(1, Math.floor(row.attempt))
: null,
lane: readText(row.lane) || null,
payload: toObject(row.payload),
createdAt: readText(row.createdAt) || undefined,
})
}
return rows
}
export function toRunStreamEventFromRunApi(params: {
runId: string
event: RunApiEvent
}): RunStreamEvent | null {
const payload = toObject(params.event.payload)
const stepId = typeof params.event.stepKey === 'string' ? params.event.stepKey : undefined
const stepAttempt =
typeof params.event.attempt === 'number' && Number.isFinite(params.event.attempt)
? Math.max(1, Math.floor(params.event.attempt))
: undefined
const stepTitle = readText(payload.stepTitle) || undefined
const stepIndex =
typeof payload.stepIndex === 'number' && Number.isFinite(payload.stepIndex)
? Math.max(1, Math.floor(payload.stepIndex))
: undefined
const stepTotal =
typeof payload.stepTotal === 'number' && Number.isFinite(payload.stepTotal)
? Math.max(stepIndex || 1, Math.floor(payload.stepTotal))
: undefined
const ts = readText(params.event.createdAt) || new Date().toISOString()
const message = readText(payload.message) || undefined
const dependsOn = readStringArray(payload.dependsOn)
const blockedBy = readStringArray(payload.blockedBy)
const groupId = readText(payload.groupId) || undefined
const parallelKey = readText(payload.parallelKey) || undefined
const retryable = readBool(payload.retryable)
const stale = readBool(payload.stale)
if (params.event.eventType === 'run.start') {
return {
runId: params.runId,
event: 'run.start',
ts,
status: 'running',
message,
payload,
}
}
if (params.event.eventType === 'run.complete') {
return {
runId: params.runId,
event: 'run.complete',
ts,
status: 'completed',
message,
payload,
}
}
if (params.event.eventType === 'run.error') {
return {
runId: params.runId,
event: 'run.error',
ts,
status: 'failed',
message: resolveErrorMessage(payload, 'run failed'),
payload,
}
}
if (params.event.eventType === 'run.canceled') {
return {
runId: params.runId,
event: 'run.error',
ts,
status: 'failed',
message: resolveErrorMessage(payload, 'run canceled'),
payload,
}
}
if (params.event.eventType === 'step.start') {
if (!stepId) return null
return {
runId: params.runId,
event: 'step.start',
ts,
status: 'running',
stepId,
stepAttempt,
stepTitle,
stepIndex,
stepTotal,
dependsOn: dependsOn.length > 0 ? dependsOn : undefined,
blockedBy: blockedBy.length > 0 ? blockedBy : undefined,
groupId,
parallelKey,
retryable,
message,
}
}
if (params.event.eventType === 'step.chunk') {
if (!stepId) return null
const stream = toObject(payload.stream)
const lane =
params.event.lane === 'reasoning' || stream.lane === 'reasoning' || stream.kind === 'reasoning'
? 'reasoning'
: 'text'
const delta = readText(stream.delta)
if (!delta) return null
const laneSeq =
typeof stream.seq === 'number' && Number.isFinite(stream.seq)
? Math.max(1, Math.floor(stream.seq))
: Math.max(1, Math.floor(params.event.seq))
return {
runId: params.runId,
event: 'step.chunk',
ts,
status: 'running',
stepId,
stepAttempt,
stepTitle,
stepIndex,
stepTotal,
dependsOn: dependsOn.length > 0 ? dependsOn : undefined,
blockedBy: blockedBy.length > 0 ? blockedBy : undefined,
groupId,
parallelKey,
retryable,
lane,
seq: laneSeq,
textDelta: lane === 'text' ? delta : undefined,
reasoningDelta: lane === 'reasoning' ? delta : undefined,
message,
}
}
if (params.event.eventType === 'step.complete') {
if (!stepId) return null
const text = readText(payload.text) || readText(payload.output) || undefined
const reasoning = readText(payload.reasoning) || undefined
return {
runId: params.runId,
event: 'step.complete',
ts,
stepId,
stepAttempt,
stepTitle,
stepIndex,
stepTotal,
status: stale ? 'stale' : 'completed',
dependsOn: dependsOn.length > 0 ? dependsOn : undefined,
blockedBy: blockedBy.length > 0 ? blockedBy : undefined,
groupId,
parallelKey,
retryable,
text,
reasoning,
message,
}
}
if (params.event.eventType === 'step.error') {
if (!stepId) return null
return {
runId: params.runId,
event: 'step.error',
ts,
status: 'failed',
stepId,
stepAttempt,
stepTitle,
stepIndex,
stepTotal,
dependsOn: dependsOn.length > 0 ? dependsOn : undefined,
blockedBy: blockedBy.length > 0 ? blockedBy : undefined,
groupId,
parallelKey,
retryable,
message: resolveErrorMessage(payload, 'step failed'),
payload,
}
}
return null
}
export async function fetchRunEventsPage(params: {
runId: string
afterSeq: number
limit?: number
}): Promise<RunApiEvent[]> {
const safeAfterSeq = Number.isFinite(params.afterSeq)
? Math.max(0, Math.floor(params.afterSeq))
: 0
const safeLimit = Number.isFinite(params.limit || 500)
? Math.min(Math.max(Math.floor(params.limit || 500), 1), 2000)
: 500
const response = await apiFetch(
`/api/runs/${params.runId}/events?afterSeq=${safeAfterSeq}&limit=${safeLimit}`,
{
method: 'GET',
cache: 'no-store',
},
)
if (!response.ok) {
const errorJson = await response.clone().json().catch(() => null)
const errorRoot = toObject(errorJson)
const errorMessage =
readText(toObject(errorRoot.error).message) ||
readText(errorRoot.message) ||
(await response.text().catch(() => ''))
if (errorMessage) {
throw new Error(`run events fetch failed (HTTP ${response.status}): ${errorMessage}`)
}
throw new Error(`run events fetch failed (HTTP ${response.status})`)
}
const payload = await response.json().catch(() => null)
return parseRunApiEventsPayload(payload)
}

View File

@@ -0,0 +1,255 @@
import type { MutableRefObject } from 'react'
import type { RunStreamEvent } from '@/lib/novel-promotion/run-stream/types'
import { isAsyncTaskResponse } from '@/lib/task/client'
import { resolveTaskErrorMessage } from '@/lib/task/error-message'
import { toObject, toTerminalRunResult } from './event-parser'
import { streamSSEBody } from './run-stream-sse-body'
import { fetchRunEventsPage, toRunStreamEventFromRunApi } from './run-event-adapter'
import type { RunResult } from './types'
import { apiFetch } from '@/lib/api-fetch'
type RunRequestExecutorArgs = {
endpointUrl: string
requestBody: Record<string, unknown>
controller: AbortController
taskStreamTimeoutMs: number
applyAndCapture: (streamEvent: RunStreamEvent) => void
finalResultRef: MutableRefObject<RunResult | null>
}
const POLL_INTERVAL_MS = 1500
const RUN_EVENTS_LIMIT = 500
const RUN_TERMINAL_RECONCILE_EMPTY_POLLS = 2
function readText(value: unknown): string {
return typeof value === 'string' ? value : ''
}
async function reconcileRunTerminalState(runId: string): Promise<RunResult | null> {
const response = await apiFetch(`/api/runs/${runId}`, {
method: 'GET',
cache: 'no-store',
})
if (!response.ok) return null
const snapshot = await response.json().catch(() => null)
const root = toObject(snapshot)
const run = toObject(root.run)
const status = readText(run.status)
if (status === 'completed') {
const output = toObject(run.output)
return {
runId,
status: 'completed',
summary: Object.keys(output).length > 0 ? output : null,
payload: Object.keys(output).length > 0 ? output : run,
errorMessage: '',
}
}
if (status === 'failed' || status === 'canceled') {
const errorMessage = readText(run.errorMessage) || `run ${status}`
return buildFailedResult(runId, errorMessage)
}
return null
}
function buildFailedResult(runId: string, errorMessage: string): RunResult {
return {
runId,
status: 'failed',
summary: null,
payload: null,
errorMessage,
}
}
async function waitRunEventsTerminal(args: {
runId: string
controller: AbortController
taskStreamTimeoutMs: number
applyAndCapture: (streamEvent: RunStreamEvent) => void
}): Promise<RunResult> {
let lastEventAt = Date.now()
let afterSeq = 0
let emptyPollCount = 0
while (true) {
if (args.controller.signal.aborted) {
return buildFailedResult(args.runId, 'aborted')
}
if (Date.now() - lastEventAt > args.taskStreamTimeoutMs) {
const timeoutMessage = `run stream timeout: ${args.runId}`
args.applyAndCapture({
runId: args.runId,
event: 'run.error',
ts: new Date().toISOString(),
status: 'failed',
message: timeoutMessage,
})
return buildFailedResult(args.runId, timeoutMessage)
}
const rows = await fetchRunEventsPage({
runId: args.runId,
afterSeq,
limit: RUN_EVENTS_LIMIT,
})
let sawNewEvent = false
for (const row of rows) {
if (row.seq <= afterSeq) continue
sawNewEvent = true
if (row.seq > afterSeq + 1) {
const gapRows = await fetchRunEventsPage({
runId: args.runId,
afterSeq,
limit: RUN_EVENTS_LIMIT,
})
if (gapRows.length > 0) {
for (const gapRow of gapRows) {
if (gapRow.seq <= afterSeq) continue
lastEventAt = Date.now()
afterSeq = gapRow.seq
const gapEvent = toRunStreamEventFromRunApi({
runId: args.runId,
event: gapRow,
})
if (!gapEvent) continue
args.applyAndCapture(gapEvent)
const gapTerminal = toTerminalRunResult(gapEvent)
if (gapTerminal) {
return { ...gapTerminal, runId: args.runId }
}
}
}
continue
}
lastEventAt = Date.now()
afterSeq = row.seq
const streamEvent = toRunStreamEventFromRunApi({
runId: args.runId,
event: row,
})
if (!streamEvent) continue
args.applyAndCapture(streamEvent)
const terminalResult = toTerminalRunResult(streamEvent)
if (terminalResult) {
return {
...terminalResult,
runId: args.runId,
}
}
}
if (sawNewEvent) {
emptyPollCount = 0
} else {
emptyPollCount += 1
if (emptyPollCount >= RUN_TERMINAL_RECONCILE_EMPTY_POLLS) {
const reconciled = await reconcileRunTerminalState(args.runId)
if (reconciled) {
if (reconciled.status === 'completed') {
args.applyAndCapture({
runId: args.runId,
event: 'run.complete',
ts: new Date().toISOString(),
status: 'completed',
payload: reconciled.payload || reconciled.summary || undefined,
})
} else {
args.applyAndCapture({
runId: args.runId,
event: 'run.error',
ts: new Date().toISOString(),
status: 'failed',
message: reconciled.errorMessage,
})
}
return reconciled
}
emptyPollCount = 0
}
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
}
}
export async function executeRunRequest(args: RunRequestExecutorArgs): Promise<RunResult> {
try {
const response = await apiFetch(args.endpointUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(args.requestBody),
signal: args.controller.signal,
})
if (!response.ok) {
const jsonPayload = await response.clone().json().catch(() => null)
if (jsonPayload && typeof jsonPayload === 'object') {
throw new Error(resolveTaskErrorMessage(jsonPayload as Record<string, unknown>, `HTTP ${response.status}`))
}
const message = await response.text().catch(() => '')
throw new Error(message || `HTTP ${response.status}`)
}
const contentType = response.headers.get('content-type') || ''
if (contentType.includes('text/event-stream') && response.body) {
await streamSSEBody({
responseBody: response.body,
applyAndCapture: args.applyAndCapture,
})
} else {
const data = await response.json().catch(() => null)
if (isAsyncTaskResponse(data)) {
const asyncPayload = toObject(data)
const runId =
typeof asyncPayload.runId === 'string' && asyncPayload.runId.trim()
? asyncPayload.runId.trim()
: ''
if (!runId) {
throw new Error('async task response missing runId')
}
const result = await waitRunEventsTerminal({
runId,
controller: args.controller,
taskStreamTimeoutMs: args.taskStreamTimeoutMs,
applyAndCapture: args.applyAndCapture,
})
args.finalResultRef.current = result
return result
}
const payload = toObject(data)
const success = payload.success !== false
const runId = typeof payload.runId === 'string' ? payload.runId : ''
const result: RunResult = {
runId,
status: success ? 'completed' : 'failed',
summary: payload,
payload,
errorMessage: success ? '' : (typeof payload.message === 'string' ? payload.message : 'run failed'),
}
args.finalResultRef.current = result
return result
}
} catch (error) {
if ((error as Error).name === 'AbortError') {
const aborted = args.finalResultRef.current || buildFailedResult('', 'aborted')
args.finalResultRef.current = aborted
return aborted
}
const message = error instanceof Error ? error.message : String(error)
args.finalResultRef.current = buildFailedResult('', message)
throw error
}
const fallback = args.finalResultRef.current || buildFailedResult('', 'stream closed without terminal event')
args.finalResultRef.current = fallback
return fallback
}

View File

@@ -0,0 +1,79 @@
import type { RunStreamEvent } from '@/lib/novel-promotion/run-stream/types'
import { parseSSEBlock } from './event-parser'
function toStreamEvent(data: Record<string, unknown>, parsedEvent: string): RunStreamEvent {
const dependsOn = Array.isArray(data.dependsOn)
? data.dependsOn.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
: undefined
const blockedBy = Array.isArray(data.blockedBy)
? data.blockedBy.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
: undefined
return {
runId: typeof data.runId === 'string' ? data.runId : '',
event: parsedEvent as RunStreamEvent['event'],
ts: typeof data.ts === 'string' ? data.ts : new Date().toISOString(),
status: (data.status as RunStreamEvent['status']) || undefined,
stepId: typeof data.stepId === 'string' ? data.stepId : undefined,
stepAttempt: typeof data.stepAttempt === 'number' ? Math.max(1, Math.floor(data.stepAttempt)) : undefined,
stepTitle: typeof data.stepTitle === 'string' ? data.stepTitle : undefined,
stepIndex: typeof data.stepIndex === 'number' ? data.stepIndex : undefined,
stepTotal: typeof data.stepTotal === 'number' ? data.stepTotal : undefined,
lane: data.lane === 'reasoning' ? 'reasoning' : data.lane === 'text' ? 'text' : undefined,
seq: typeof data.seq === 'number' ? data.seq : undefined,
textDelta: typeof data.textDelta === 'string' ? data.textDelta : undefined,
reasoningDelta: typeof data.reasoningDelta === 'string' ? data.reasoningDelta : undefined,
text: typeof data.text === 'string' ? data.text : undefined,
reasoning: typeof data.reasoning === 'string' ? data.reasoning : undefined,
message: typeof data.message === 'string' ? data.message : undefined,
dependsOn,
blockedBy,
groupId: typeof data.groupId === 'string' ? data.groupId : undefined,
parallelKey: typeof data.parallelKey === 'string' ? data.parallelKey : undefined,
retryable: typeof data.retryable === 'boolean' ? data.retryable : undefined,
payload: (() => {
const payload =
typeof data.payload === 'object' && data.payload ? (data.payload as Record<string, unknown>) : null
const summary =
typeof data.summary === 'object' && data.summary ? (data.summary as Record<string, unknown>) : null
if (!summary) return payload
return {
...(payload || {}),
summary,
}
})(),
}
}
export async function streamSSEBody(args: {
responseBody: ReadableStream<Uint8Array>
applyAndCapture: (streamEvent: RunStreamEvent) => void
}) {
const reader = args.responseBody.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
while (true) {
const idx = buffer.indexOf('\n\n')
if (idx === -1) break
const block = buffer.slice(0, idx)
buffer = buffer.slice(idx + 2)
const parsed = parseSSEBlock(block)
if (!parsed) continue
let data: Record<string, unknown> = {}
try {
data = JSON.parse(parsed.data) as Record<string, unknown>
} catch {
continue
}
args.applyAndCapture(toStreamEvent(data, parsed.event))
}
}
}

View File

@@ -0,0 +1,322 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { RunStreamEvent } from '@/lib/novel-promotion/run-stream/types'
import { applyRunStreamEvent } from './state-machine'
import { clearRunSnapshot, loadRunSnapshot, saveRunSnapshot } from './snapshot'
import { subscribeRecoveredRun } from './recovered-run-subscription'
import { executeRunRequest } from './run-request-executor'
import { deriveRunStreamView } from './run-stream-view'
import type { RunResult, RunState, RunStreamView, UseRunStreamStateOptions } from './types'
import { apiFetch } from '@/lib/api-fetch'
export type {
RunResult,
RunState,
RunStepState,
UseRunStreamStateOptions,
} from './types'
const TASK_STREAM_TIMEOUT_MS = 1000 * 60 * 30
const PROBE_COOLDOWN_MS = 60_000
const probedScopes = new Map<string, number>()
export function useRunStreamState<TParams extends Record<string, unknown>>(
options: UseRunStreamStateOptions<TParams>,
): RunStreamView {
const {
projectId,
endpoint,
storageKeyPrefix,
storageScopeKey,
buildRequestBody,
validateParams,
resolveActiveRunId,
} = options
const [runState, setRunState] = useState<RunState | null>(null)
const runStateRef = useRef<RunState | null>(null)
const [clock, setClock] = useState(() => Date.now())
const [isLiveRunning, setIsLiveRunning] = useState(false)
const [isRecoveredRunning, setIsRecoveredRunning] = useState(false)
const abortRef = useRef<AbortController | null>(null)
const finalResultRef = useRef<RunResult | null>(null)
const hydratedStorageKeyRef = useRef<string | null>(null)
const resolveActiveRunIdRef = useRef(resolveActiveRunId)
const storageKey = useMemo(() => {
if (storageScopeKey) {
return `${storageKeyPrefix}:${projectId}:${storageScopeKey}`
}
return `${storageKeyPrefix}:${projectId}`
}, [projectId, storageKeyPrefix, storageScopeKey])
const applyEvent = useCallback((event: RunStreamEvent) => {
setRunState((prev) => applyRunStreamEvent(prev, event))
}, [])
useEffect(() => {
runStateRef.current = runState
}, [runState])
useEffect(() => {
resolveActiveRunIdRef.current = resolveActiveRunId
}, [resolveActiveRunId])
useEffect(() => {
if (!projectId) return
if (hydratedStorageKeyRef.current === storageKey) return
hydratedStorageKeyRef.current = storageKey
const snapshotRunState = loadRunSnapshot(storageKey)
if (!snapshotRunState) return
setRunState(snapshotRunState)
if (snapshotRunState.status === 'running') {
setIsRecoveredRunning(true)
}
}, [projectId, storageKey])
useEffect(() => {
if (!projectId || !resolveActiveRunIdRef.current) return
const lastProbed = probedScopes.get(storageKey)
if (lastProbed && Date.now() - lastProbed < PROBE_COOLDOWN_MS) return
probedScopes.set(storageKey, Date.now())
if (runStateRef.current) return
const existingSnapshot = loadRunSnapshot(storageKey)
if (existingSnapshot) return
let cancelled = false
void (async () => {
const activeRunId = await resolveActiveRunIdRef.current?.({
projectId,
storageScopeKey,
}).catch(() => null)
if (cancelled || !activeRunId) return
const now = Date.now()
setRunState((prev) => {
if (prev) return prev
return {
runId: activeRunId,
status: 'running',
startedAt: now,
updatedAt: now,
terminalAt: null,
errorMessage: '',
summary: null,
payload: null,
stepsById: {},
stepOrder: [],
activeStepId: null,
selectedStepId: null,
}
})
setIsRecoveredRunning(true)
})()
return () => {
cancelled = true
}
}, [projectId, storageKey, storageScopeKey])
useEffect(() => {
if (!projectId || !isRecoveredRunning || isLiveRunning) return
const runId = runState?.runId || ''
if (!runId || runState?.status !== 'running') return
return subscribeRecoveredRun({
runId,
taskStreamTimeoutMs: TASK_STREAM_TIMEOUT_MS,
applyAndCapture: applyEvent,
onSettled: () => {
setIsRecoveredRunning(false)
},
})
}, [
applyEvent,
isLiveRunning,
isRecoveredRunning,
projectId,
runState?.runId,
runState?.status,
])
useEffect(() => {
if (!isRecoveredRunning) return
if (!runState) {
setIsRecoveredRunning(false)
return
}
if (runState.status === 'completed' || runState.status === 'failed') {
setIsRecoveredRunning(false)
}
}, [isRecoveredRunning, runState, runState?.status])
useEffect(() => {
if (!projectId) return
saveRunSnapshot(storageKey, runState)
}, [projectId, runState, storageKey])
const run = useCallback(
async (params: TParams): Promise<RunResult> => {
if (!projectId) {
throw new Error('projectId is required')
}
validateParams?.(params)
abortRef.current?.abort()
setIsRecoveredRunning(false)
setIsLiveRunning(true)
const controller = new AbortController()
abortRef.current = controller
finalResultRef.current = null
try {
const requestBody = buildRequestBody(params)
return await executeRunRequest({
endpointUrl: endpoint(projectId),
requestBody,
controller,
taskStreamTimeoutMs: TASK_STREAM_TIMEOUT_MS,
applyAndCapture: applyEvent,
finalResultRef,
})
} finally {
if (abortRef.current === controller) {
abortRef.current = null
}
setIsLiveRunning(false)
}
},
[
applyEvent,
buildRequestBody,
endpoint,
projectId,
validateParams,
],
)
const retryStep = useCallback(async (params: {
stepId: string
modelOverride?: string
reason?: string
}): Promise<RunResult> => {
const runId = runStateRef.current?.runId || ''
if (!runId) {
throw new Error('runId is required')
}
const stepId = params.stepId.trim()
if (!stepId) {
throw new Error('stepId is required')
}
const response = await apiFetch(
`/api/runs/${runId}/steps/${encodeURIComponent(stepId)}/retry`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
modelOverride: params.modelOverride || undefined,
reason: params.reason || undefined,
}),
},
)
const payload = await response.json().catch(() => null)
if (!response.ok) {
const errorMessage =
payload && typeof payload === 'object' && typeof (payload as { error?: { message?: unknown } }).error?.message === 'string'
? (payload as { error: { message: string } }).error.message
: 'retry step failed'
throw new Error(errorMessage)
}
applyEvent({
runId,
event: 'run.start',
ts: new Date().toISOString(),
status: 'running',
message: 'retrying failed step',
})
setIsRecoveredRunning(true)
return {
runId,
status: 'running',
summary: null,
payload: payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : null,
errorMessage: '',
}
}, [applyEvent])
const stop = useCallback(() => {
const runningRunId = runState?.status === 'running' ? runState.runId : ''
if (runningRunId) {
void apiFetch(`/api/runs/${runningRunId}/cancel`, {
method: 'POST',
}).catch(() => null)
applyEvent({
runId: runningRunId,
event: 'run.error',
ts: new Date().toISOString(),
status: 'failed',
message: 'aborted',
})
}
abortRef.current?.abort()
abortRef.current = null
setIsLiveRunning(false)
}, [applyEvent, runState?.runId, runState?.status])
const reset = useCallback(() => {
stop()
setRunState(null)
finalResultRef.current = null
setIsRecoveredRunning(false)
clearRunSnapshot(storageKey)
}, [storageKey, stop])
useEffect(() => {
const timer = window.setInterval(() => setClock(Date.now()), 500)
return () => window.clearInterval(timer)
}, [])
const view = useMemo(() => {
return deriveRunStreamView({
runState,
isLiveRunning,
clock,
})
}, [clock, isLiveRunning, runState])
const selectStep = useCallback((stepId: string) => {
setRunState((prev) => {
if (!prev || !prev.stepsById[stepId]) return prev
return {
...prev,
selectedStepId: stepId,
}
})
}, [])
return {
runState,
runId: runState?.runId || '',
status: runState?.status || 'idle',
isRunning: isLiveRunning,
isRecoveredRunning,
isVisible: view.isVisible,
errorMessage: runState?.errorMessage || '',
summary: runState?.summary || null,
payload: runState?.payload || null,
stages: view.stages,
orderedSteps: view.orderedSteps,
activeStepId: view.activeStepId,
selectedStep: view.selectedStep,
outputText: view.outputText,
overallProgress: view.overallProgress,
activeMessage: view.activeMessage,
run: run as (params: Record<string, unknown>) => Promise<RunResult>,
retryStep,
stop,
reset,
selectStep,
}
}

View File

@@ -0,0 +1,124 @@
import { getStageOutput, toStageViewStatus } from './state-machine'
import type { RunStageView, RunState, RunStepState } from './types'
export type DerivedRunStreamView = {
orderedSteps: RunStepState[]
activeStepId: string | null
selectedStep: RunStepState | null
outputText: string
stages: RunStageView[]
overallProgress: number
activeMessage: string
isVisible: boolean
}
export function deriveRunStreamView(args: {
runState: RunState | null
isLiveRunning: boolean
clock: number
}): DerivedRunStreamView {
const { runState, isLiveRunning, clock } = args
const orderedSteps = runState
? runState.stepOrder
.map((id) => runState.stepsById[id])
.filter((item): item is RunStepState => !!item)
: []
const activeStepId = runState?.activeStepId || orderedSteps[orderedSteps.length - 1]?.id || null
const activeStep =
activeStepId && runState?.stepsById[activeStepId]
? runState.stepsById[activeStepId]
: orderedSteps[orderedSteps.length - 1] || null
const selectedStepId = runState?.selectedStepId || activeStepId
const selectedStep =
selectedStepId && runState?.stepsById[selectedStepId]
? runState.stepsById[selectedStepId]
: orderedSteps[orderedSteps.length - 1] || null
const outputText = (() => {
const stepOutput = getStageOutput(selectedStep)
if (stepOutput) return stepOutput
if (runState?.status === 'failed' && runState.errorMessage) {
return `【错误】\n${runState.errorMessage}`
}
return ''
})()
const stages: RunStageView[] = orderedSteps.map((step) => ({
id: step.id,
title: step.title,
subtitle: (() => {
const relationText =
step.status === 'blocked' && step.blockedBy.length > 0
? `等待: ${step.blockedBy.join(', ')}`
: step.dependsOn.length > 0
? `依赖: ${step.dependsOn.join(', ')}`
: ''
const parallelText = step.groupId && step.parallelKey
? `并行组: ${step.groupId}/${step.parallelKey}`
: ''
const parts = [relationText, parallelText, step.message || ''].filter(Boolean)
return parts.length > 0 ? parts.join(' | ') : undefined
})(),
status: toStageViewStatus(step.status),
attempt: step.attempt,
retryable: step.retryable,
progress:
step.status === 'completed'
? 100
: step.status === 'stale'
? 100
: step.status === 'blocked'
? 0
: step.status === 'running'
? Math.max(2, Math.min(99, step.textLength > 0 || step.reasoningLength > 0 ? 15 : 2))
: 0,
}))
const overallProgress =
stages.length === 0
? 0
: stages.reduce((sum, stage) => {
if (stage.status === 'completed') return sum + 100
if (stage.status === 'stale') return sum + 100
if (stage.status === 'blocked') return sum
if (stage.status === 'failed') return sum
return sum + (stage.progress || 0)
}, 0) / stages.length
const activeMessage = !activeStep
? runState?.status === 'failed'
? runState.errorMessage
: 'progress.runtime.waitingExecution'
: activeStep.errorMessage
? activeStep.errorMessage
: activeStep.status === 'completed'
? 'progress.runtime.llm.completed'
: activeStep.status === 'failed'
? 'progress.runtime.llm.failed'
: activeStep.status === 'blocked'
? activeStep.blockedBy.length > 0
? `等待依赖步骤: ${activeStep.blockedBy.join(', ')}`
: 'progress.runtime.waitingExecution'
: activeStep.status === 'stale'
? '结果已过期,请按需重试'
: activeStep.message || 'progress.runtime.llm.processing'
void clock
const isVisible = !!runState && (
isLiveRunning ||
runState.status === 'running' ||
runState.status === 'failed'
)
return {
orderedSteps,
activeStepId,
selectedStep,
outputText,
stages,
overallProgress,
activeMessage,
isVisible,
}
}

View File

@@ -0,0 +1,58 @@
import type { RunState } from './types'
export const SNAPSHOT_TTL_MS = 1000 * 60 * 60 * 6
type RunSnapshot = {
savedAt: number
runState: RunState
}
export function loadRunSnapshot(storageKey: string): RunState | null {
if (typeof window === 'undefined') return null
try {
const raw = window.sessionStorage.getItem(storageKey)
if (!raw) return null
const parsed = JSON.parse(raw) as RunSnapshot
if (!parsed || typeof parsed !== 'object') {
window.sessionStorage.removeItem(storageKey)
return null
}
if (typeof parsed.savedAt !== 'number' || Date.now() - parsed.savedAt > SNAPSHOT_TTL_MS) {
window.sessionStorage.removeItem(storageKey)
return null
}
const snapshotRunState = parsed.runState
if (!snapshotRunState || typeof snapshotRunState !== 'object' || typeof snapshotRunState.runId !== 'string') {
window.sessionStorage.removeItem(storageKey)
return null
}
return snapshotRunState
} catch {
try {
window.sessionStorage.removeItem(storageKey)
} catch { }
return null
}
}
export function saveRunSnapshot(storageKey: string, runState: RunState | null) {
if (typeof window === 'undefined') return
try {
if (!runState) {
window.sessionStorage.removeItem(storageKey)
return
}
const snapshot: RunSnapshot = {
savedAt: Date.now(),
runState,
}
window.sessionStorage.setItem(storageKey, JSON.stringify(snapshot))
} catch { }
}
export function clearRunSnapshot(storageKey: string) {
if (typeof window === 'undefined') return
try {
window.sessionStorage.removeItem(storageKey)
} catch { }
}

View File

@@ -0,0 +1,586 @@
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 ''
}

View File

@@ -0,0 +1,92 @@
import type { RunStepStatus, RunStreamLane, RunStreamStatus } from '@/lib/novel-promotion/run-stream/types'
export type RunStepState = {
id: string
attempt: number
title: string
stepIndex: number
stepTotal: number
status: RunStepStatus
dependsOn: string[]
blockedBy: string[]
groupId: string | null
parallelKey: string | null
retryable: boolean
textOutput: string
reasoningOutput: string
textLength: number
reasoningLength: number
message: string
errorMessage: string
updatedAt: number
seqByLane: Record<RunStreamLane, number>
}
export type RunState = {
runId: string
status: RunStreamStatus
startedAt: number
updatedAt: number
terminalAt: number | null
errorMessage: string
summary: Record<string, unknown> | null
payload: Record<string, unknown> | null
stepsById: Record<string, RunStepState>
stepOrder: string[]
activeStepId: string | null
selectedStepId: string | null
}
export type RunResult = {
runId: string
status: RunStreamStatus
summary: Record<string, unknown> | null
payload: Record<string, unknown> | null
errorMessage: string
}
export type StageViewStatus = 'pending' | 'queued' | 'processing' | 'completed' | 'failed' | 'blocked' | 'stale'
export type RunStageView = {
id: string
title: string
subtitle?: string
status: StageViewStatus
progress: number
attempt?: number
retryable?: boolean
}
export type UseRunStreamStateOptions<TParams extends Record<string, unknown>> = {
projectId: string
endpoint: (projectId: string) => string
storageKeyPrefix: string
storageScopeKey?: string
buildRequestBody: (params: TParams) => Record<string, unknown>
validateParams?: (params: TParams) => void
resolveActiveRunId?: (context: { projectId: string; storageScopeKey?: string }) => Promise<string | null>
}
export type RunStreamView = {
runState: RunState | null
runId: string
status: RunStreamStatus | 'idle'
isRunning: boolean
isRecoveredRunning: boolean
isVisible: boolean
errorMessage: string
summary: Record<string, unknown> | null
payload: Record<string, unknown> | null
stages: RunStageView[]
orderedSteps: RunStepState[]
activeStepId: string | null
selectedStep: RunStepState | null
outputText: string
overallProgress: number
activeMessage: string
run: (params: Record<string, unknown>) => Promise<RunResult>
retryStep: (params: { stepId: string; modelOverride?: string; reason?: string }) => Promise<RunResult>
stop: () => void
reset: () => void
selectStep: (stepId: string) => void
}