feat: refine UI, improve UX, optimize the analysis pipeline, and add character standing positions
This commit is contained in:
90
src/lib/query/hooks/run-stream/recovery-probe.ts
Normal file
90
src/lib/query/hooks/run-stream/recovery-probe.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
|
||||
const PROBE_SUCCESS_COOLDOWN_MS = 60_000
|
||||
const PROBE_RETRY_INTERVAL_MS = 2_000
|
||||
const successfulProbeScopes = new Map<string, number>()
|
||||
|
||||
type RecoveryProbeContext = {
|
||||
projectId: string
|
||||
storageScopeKey?: string
|
||||
}
|
||||
|
||||
type StartRecoveryProbeArgs = {
|
||||
projectId: string
|
||||
storageKey: string
|
||||
storageScopeKey?: string
|
||||
hasRunState: () => boolean
|
||||
resolveActiveRunId: (context: RecoveryProbeContext) => Promise<string | null>
|
||||
onRecovered: (runId: string) => void
|
||||
}
|
||||
|
||||
function scheduleProbe(
|
||||
callback: () => void,
|
||||
delayMs: number,
|
||||
): ReturnType<typeof setTimeout> {
|
||||
return globalThis.setTimeout(callback, delayMs)
|
||||
}
|
||||
|
||||
export function startRecoveryProbe(args: StartRecoveryProbeArgs): () => void {
|
||||
let cancelled = false
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const clearRetryTimer = () => {
|
||||
if (retryTimer) {
|
||||
globalThis.clearTimeout(retryTimer)
|
||||
retryTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleRetry = (delayMs: number) => {
|
||||
if (cancelled || args.hasRunState()) return
|
||||
clearRetryTimer()
|
||||
retryTimer = scheduleProbe(() => {
|
||||
void probe()
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
const probe = async () => {
|
||||
if (cancelled || args.hasRunState()) return
|
||||
|
||||
const lastSuccessAt = successfulProbeScopes.get(args.storageKey)
|
||||
if (lastSuccessAt) {
|
||||
const cooldownRemainingMs =
|
||||
PROBE_SUCCESS_COOLDOWN_MS - (Date.now() - lastSuccessAt)
|
||||
if (cooldownRemainingMs > 0) {
|
||||
scheduleRetry(cooldownRemainingMs)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const activeRunId = await args.resolveActiveRunId({
|
||||
projectId: args.projectId,
|
||||
storageScopeKey: args.storageScopeKey,
|
||||
}).catch(() => null)
|
||||
|
||||
if (cancelled || args.hasRunState()) return
|
||||
|
||||
if (!activeRunId) {
|
||||
scheduleRetry(PROBE_RETRY_INTERVAL_MS)
|
||||
return
|
||||
}
|
||||
|
||||
successfulProbeScopes.set(args.storageKey, Date.now())
|
||||
args.onRecovered(activeRunId)
|
||||
}
|
||||
|
||||
void probe()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearRetryTimer()
|
||||
}
|
||||
}
|
||||
|
||||
export const recoveryProbeTestUtils = {
|
||||
clearSuccessfulProbeScopes() {
|
||||
successfulProbeScopes.clear()
|
||||
},
|
||||
PROBE_RETRY_INTERVAL_MS,
|
||||
PROBE_SUCCESS_COOLDOWN_MS,
|
||||
}
|
||||
@@ -3,12 +3,12 @@
|
||||
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'
|
||||
import { startRecoveryProbe } from './recovery-probe'
|
||||
|
||||
export type {
|
||||
RunResult,
|
||||
@@ -18,8 +18,6 @@ export type {
|
||||
} 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>,
|
||||
@@ -40,7 +38,6 @@ export function useRunStreamState<TParams extends Record<string, unknown>>(
|
||||
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) {
|
||||
@@ -61,60 +58,40 @@ export function useRunStreamState<TParams extends Record<string, unknown>>(
|
||||
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
|
||||
}
|
||||
return startRecoveryProbe({
|
||||
projectId,
|
||||
storageKey,
|
||||
storageScopeKey,
|
||||
hasRunState: () => runStateRef.current !== null,
|
||||
resolveActiveRunId: (context) =>
|
||||
resolveActiveRunIdRef.current?.(context) ?? Promise.resolve(null),
|
||||
onRecovered: (activeRunId) => {
|
||||
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)
|
||||
},
|
||||
})
|
||||
}, [projectId, storageKey, storageScopeKey])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -150,11 +127,6 @@ export function useRunStreamState<TParams extends Record<string, unknown>>(
|
||||
}
|
||||
}, [isRecoveredRunning, runState, runState?.status])
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return
|
||||
saveRunSnapshot(storageKey, runState)
|
||||
}, [projectId, runState, storageKey])
|
||||
|
||||
const run = useCallback(
|
||||
async (params: TParams): Promise<RunResult> => {
|
||||
if (!projectId) {
|
||||
@@ -270,8 +242,7 @@ export function useRunStreamState<TParams extends Record<string, unknown>>(
|
||||
setRunState(null)
|
||||
finalResultRef.current = null
|
||||
setIsRecoveredRunning(false)
|
||||
clearRunSnapshot(storageKey)
|
||||
}, [storageKey, stop])
|
||||
}, [stop])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setClock(Date.now()), 500)
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
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 { }
|
||||
}
|
||||
Reference in New Issue
Block a user