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, }, { id: 'visual-novel-target', label: '生成目标', value: '可编辑并可试玩的视觉小说草稿', }, ].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: string; label: string; detail: string; weight: number; durationMs: number; }, { id: string; label: string; detail: string; weight: number; durationMs: number; }, ] = [ { id: 'visual-novel-intent', label: '理解一句话创意', detail: '提取核心题材、视觉画风、玩家身份和互动叙事目标。', weight: 16, durationMs: 6_000, }, { id: 'visual-novel-world', label: '扩展世界观', detail: '生成世界背景、故事前提、文学风格和玩家角色。', weight: 22, durationMs: 10_000, }, { id: 'visual-novel-cast-scenes', label: '设计角色与场景', detail: '补齐主要角色、可生成立绘的外观描述和 opening 场景。', weight: 28, durationMs: 16_000, }, { id: 'visual-novel-opening', label: '生成开场与选择', detail: '写入开场旁白、首句对白、剧情阶段和 2 到 4 个初始选择。', weight: 24, durationMs: 10_000, }, { id: 'visual-novel-ready', label: '准备草稿页', detail: '校验可编辑字段并进入结果页,后续可保存作品和试玩。', weight: 10, durationMs: 3_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); const estimatedTotalMs = timeline.reduce( (sum, step) => sum + step.durationMs, 0, ); return { phaseId: phase === 'generating' ? activeStep.id : 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, estimatedTotalMs - 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, }; }), }; }