feat: initial release v0.3.0
This commit is contained in:
347
src/lib/query/hooks/run-stream/event-parser.ts
Normal file
347
src/lib/query/hooks/run-stream/event-parser.ts
Normal 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' : '',
|
||||
}
|
||||
}
|
||||
211
src/lib/query/hooks/run-stream/recovered-run-subscription.ts
Normal file
211
src/lib/query/hooks/run-stream/recovered-run-subscription.ts
Normal 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
|
||||
}
|
||||
295
src/lib/query/hooks/run-stream/run-event-adapter.ts
Normal file
295
src/lib/query/hooks/run-stream/run-event-adapter.ts
Normal 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)
|
||||
}
|
||||
255
src/lib/query/hooks/run-stream/run-request-executor.ts
Normal file
255
src/lib/query/hooks/run-stream/run-request-executor.ts
Normal 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
|
||||
}
|
||||
79
src/lib/query/hooks/run-stream/run-stream-sse-body.ts
Normal file
79
src/lib/query/hooks/run-stream/run-stream-sse-body.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
322
src/lib/query/hooks/run-stream/run-stream-state-runtime.ts
Normal file
322
src/lib/query/hooks/run-stream/run-stream-state-runtime.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
124
src/lib/query/hooks/run-stream/run-stream-view.ts
Normal file
124
src/lib/query/hooks/run-stream/run-stream-view.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
58
src/lib/query/hooks/run-stream/snapshot.ts
Normal file
58
src/lib/query/hooks/run-stream/snapshot.ts
Normal 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 { }
|
||||
}
|
||||
586
src/lib/query/hooks/run-stream/state-machine.ts
Normal file
586
src/lib/query/hooks/run-stream/state-machine.ts
Normal 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 ''
|
||||
}
|
||||
92
src/lib/query/hooks/run-stream/types.ts
Normal file
92
src/lib/query/hooks/run-stream/types.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user