feat: refine UI, improve UX, optimize the analysis pipeline, and add character standing positions

This commit is contained in:
saturn
2026-04-02 17:39:16 +08:00
parent c3e74c228a
commit 9703714b69
153 changed files with 4472 additions and 1088 deletions

View 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,
}

View File

@@ -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)

View File

@@ -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 { }
}