import type { CustomWorldGenerationProgress } from '../../../packages/shared/src/contracts/runtime'; import type { CreateVisualNovelSessionRequest } from '../../../packages/shared/src/contracts/visualNovel'; import type { CustomWorldStructuredAnchorEntry } from '../../services/customWorldAgentGenerationProgress'; export type VisualNovelEntryFormPayload = Omit< CreateVisualNovelSessionRequest, 'seedText' | 'sourceMode' | 'sourceAssetIds' > & { sourceMode: 'idea'; seedText: string; sourceAssetIds: string[]; ideaText: string; visualStyleId: VisualNovelStyleOptionId; visualStyleLabel: string; visualStylePrompt: string; }; type VisualNovelStyleOptionId = | 'cinematic-anime' | 'watercolor' | 'pixel-noir' | 'ink-fantasy' | 'soft-pastel' | 'dark-gothic'; export function buildVisualNovelEntryGenerationAnchorEntries( payload: VisualNovelEntryFormPayload | null | undefined, ): CustomWorldStructuredAnchorEntry[] { if (!payload) { return []; } return [ { id: 'visual-novel-idea', label: '一句话', value: payload.ideaText, }, { id: 'visual-novel-style', label: '视觉画风', value: payload.visualStyleLabel, }, ].filter((entry) => entry.value.trim()); } export function buildVisualNovelEntryGenerationProgress( startedAtMs: number | null, phase: 'generating' | 'ready' | 'failed', nowMs = Date.now(), ): CustomWorldGenerationProgress { const elapsedMs = startedAtMs ? Math.max(0, nowMs - startedAtMs) : 0; const timeline: [ { id: string; label: string; detail: string; weight: number; durationMs: number; }, { id: string; label: string; detail: string; weight: number; durationMs: number; }, { id: string; label: string; detail: string; weight: number; durationMs: number; }, ] = [ { id: 'visual-novel-session', label: '创建创作会话', detail: '写入一句话与视觉画风,准备生成视觉小说底稿。', weight: 24, durationMs: 5_000, }, { id: 'visual-novel-draft', label: '生成故事底稿', detail: '整理世界观、角色、场景和剧情阶段。', weight: 56, durationMs: 22_000, }, { id: 'visual-novel-ready', label: '准备草稿页', detail: '校验可编辑字段并进入草稿页。', weight: 20, durationMs: 4_000, }, ]; let elapsedBeforeStep = 0; const activeStepIndex = phase === 'ready' ? timeline.length - 1 : timeline.findIndex((step) => { const elapsedInStep = elapsedMs - elapsedBeforeStep; const isActive = elapsedInStep < step.durationMs; if (!isActive) { elapsedBeforeStep += step.durationMs; } return isActive; }); const normalizedActiveStepIndex = activeStepIndex >= 0 ? activeStepIndex : timeline.length - 1; const activeStep = timeline[normalizedActiveStepIndex] ?? timeline[0]; const activeElapsed = elapsedMs - timeline .slice(0, normalizedActiveStepIndex) .reduce((sum, step) => sum + step.durationMs, 0); const activeRatio = phase === 'ready' ? 1 : phase === 'failed' ? 0 : Math.max(0, Math.min(1, activeElapsed / activeStep.durationMs)); const completedWeight = timeline .slice(0, phase === 'ready' ? timeline.length : normalizedActiveStepIndex) .reduce((sum, step) => sum + step.weight, 0); const overallProgress = phase === 'ready' ? 100 : phase === 'failed' ? Math.max(1, completedWeight) : Math.min(98, completedWeight + activeStep.weight * activeRatio); return { phaseId: phase, phaseLabel: phase === 'ready' ? '生成完成' : phase === 'failed' ? '生成失败' : activeStep.label, phaseDetail: phase === 'ready' ? '视觉小说草稿已准备完成。' : phase === 'failed' ? '草稿生成失败,请返回入口页调整后重试。' : activeStep.detail, batchLabel: activeStep.label, overallProgress: Math.max(0, Math.min(100, Math.round(overallProgress))), completedWeight: Math.max(0, Math.min(100, Math.round(overallProgress))), totalWeight: 100, elapsedMs, estimatedRemainingMs: phase === 'ready' ? 0 : Math.max(0, 31_000 - elapsedMs), activeStepIndex: normalizedActiveStepIndex, steps: timeline.map((step, index) => { const isCompleted = phase === 'ready' || index < normalizedActiveStepIndex; const isActive = phase !== 'failed' && !isCompleted && index === normalizedActiveStepIndex; const status: 'completed' | 'active' | 'pending' = isCompleted ? 'completed' : isActive ? 'active' : 'pending'; return { id: step.id, label: step.label, detail: step.detail, completed: isCompleted ? 1 : isActive ? activeRatio : 0, total: 1, status, }; }), }; }