import crypto from 'node:crypto'; import type { CreatorIntentReadiness, CustomWorldAgentMessage, CustomWorldAgentOperationRecord, CustomWorldAgentSessionSnapshot, CustomWorldAgentStage, CustomWorldAssetCoverageSummary, CustomWorldDraftCardSummary, CustomWorldPendingClarification, CustomWorldSuggestedAction, EightAnchorContent, } from '../../../packages/shared/src/contracts/customWorldAgent.js'; import type { CustomWorldSessionRecord as LegacyCustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js'; import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js'; import { buildPendingClarifications, evaluateCreatorIntentReadiness, resolveCreatorIntentStage, } from './customWorldAgentClarificationService.js'; import { normalizeCreatorIntentRecord, } from './customWorldAgentIntentExtractionService.js'; import { rebuildRoleAssetCoverage } from './customWorldAgentRoleAssetStateService.js'; import { buildAnchorPackFromEightAnchorContent, buildCreatorIntentFromEightAnchorContent, buildDraftSummaryFromEightAnchorContent, buildDraftTitleFromEightAnchorContent, buildEightAnchorContentFromCreatorIntent, createEmptyEightAnchorContent, estimateProgressPercentFromAnchorContent, normalizeEightAnchorContent, } from './eightAnchorCompatibilityService.js'; export const CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX = 'custom-world-agent-session-'; export type CustomWorldAgentSessionRecord = { sessionId: string; userId: string; seedText: string; currentTurn: number; anchorContent: EightAnchorContent; progressPercent: number; lastAssistantReply: string | null; stage: CustomWorldAgentStage; focusCardId: string | null; creatorIntent: Record | null; creatorIntentReadiness: CreatorIntentReadiness; anchorPack: Record | null; lockState: Record | null; draftProfile: Record | null; messages: CustomWorldAgentMessage[]; draftCards: CustomWorldDraftCardSummary[]; pendingClarifications: CustomWorldPendingClarification[]; suggestedActions: CustomWorldSuggestedAction[]; recommendedReplies: string[]; qualityFindings: Array<{ id: string; severity: 'info' | 'warning' | 'blocker'; code: string; targetId?: string | null; message: string; }>; assetCoverage: CustomWorldAssetCoverageSummary; operations: CustomWorldAgentOperationRecord[]; checkpoints: Array<{ checkpointId: string; createdAt: string; label: string; }>; createdAt: string; updatedAt: string; }; type CreateSessionInput = { seedText?: string; welcomeMessage: string; currentTurn?: number; anchorContent?: EightAnchorContent; progressPercent?: number; lastAssistantReply?: string | null; pendingClarifications: CustomWorldAgentSessionRecord['pendingClarifications']; creatorIntent?: CustomWorldAgentSessionRecord['creatorIntent']; creatorIntentReadiness?: CreatorIntentReadiness; anchorPack?: CustomWorldAgentSessionRecord['anchorPack']; draftProfile?: CustomWorldAgentSessionRecord['draftProfile']; stage?: CustomWorldAgentStage; suggestedActions: CustomWorldSuggestedAction[]; recommendedReplies?: string[]; }; function cloneRecord(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } function toRecord(value: unknown) { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null; } function toText(value: unknown) { return typeof value === 'string' ? value.trim() : ''; } function isStage(value: unknown): value is CustomWorldAgentStage { return ( value === 'collecting_intent' || value === 'clarifying' || value === 'foundation_review' || value === 'object_refining' || value === 'visual_refining' || value === 'long_tail_review' || value === 'ready_to_publish' || value === 'published' || value === 'error' ); } function isAgentSessionRecord( value: unknown, ): value is CustomWorldAgentSessionRecord { const record = toRecord(value); if (!record) { return false; } return ( typeof record.sessionId === 'string' && record.sessionId.startsWith(CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX) && typeof record.userId === 'string' && isStage(record.stage) && Array.isArray(record.messages) && Array.isArray(record.operations) && typeof record.createdAt === 'string' && typeof record.updatedAt === 'string' ); } function isCreatorIntentReadiness( value: unknown, ): value is CreatorIntentReadiness { const record = toRecord(value); if (!record) { return false; } return ( typeof record.isReady === 'boolean' && Array.isArray(record.completedKeys) && Array.isArray(record.missingKeys) ); } function mapLegacyClarificationTargetKey(id: string) { if (id === 'world_hook') return 'world_hook'; if (id === 'player_premise') return 'player_premise'; if (id === 'theme_and_tone' || id === 'tone_boundary') { return 'theme_and_tone'; } if (id === 'core_conflict') return 'core_conflict'; if (id === 'relationship_seed' || id === 'relationship_hook') { return 'relationship_seed'; } if (id === 'iconic_element' || id === 'iconic_elements') { return 'iconic_element'; } return null; } function hasUserInput(record: CustomWorldAgentSessionRecord) { return ( Boolean(record.seedText.trim()) || record.messages.some( (message) => message.role === 'user' && message.text.trim(), ) ); } function buildCompatibleCreatorIntent(record: CustomWorldAgentSessionRecord) { const compatibleAnchorIntent = buildCreatorIntentFromEightAnchorContent( normalizeEightAnchorContent( (record as Record).anchorContent ?? null, ), ); if ( compatibleAnchorIntent && (compatibleAnchorIntent.worldHook || compatibleAnchorIntent.rawSettingText || compatibleAnchorIntent.playerPremise || compatibleAnchorIntent.openingSituation || compatibleAnchorIntent.coreConflicts.length > 0 || compatibleAnchorIntent.keyCharacters.length > 0 || compatibleAnchorIntent.iconicElements.length > 0) ) { return compatibleAnchorIntent; } return normalizeCreatorIntentRecord(record.creatorIntent); } function buildCompatibleCurrentTurn(record: CustomWorldAgentSessionRecord) { if (typeof (record as Record).currentTurn === 'number') { return Math.max( 0, Math.round((record as Record).currentTurn as number), ); } return record.messages.filter((message) => message.role === 'user').length; } function buildCompatibleAnchorContent(record: CustomWorldAgentSessionRecord) { const normalized = normalizeEightAnchorContent( (record as Record).anchorContent ?? null, ); if ( normalized.worldPromise || normalized.playerFantasy || normalized.themeBoundary || normalized.playerEntryPoint || normalized.coreConflict || normalized.keyRelationships.length > 0 || normalized.hiddenLines || normalized.iconicElements ) { return normalized; } return buildEightAnchorContentFromCreatorIntent( buildCompatibleCreatorIntent(record), ); } function buildCompatibleProgressPercent(record: CustomWorldAgentSessionRecord) { const rawProgress = (record as Record).progressPercent; if (typeof rawProgress === 'number' && Number.isFinite(rawProgress)) { return Math.max(0, Math.min(100, Math.round(rawProgress))); } if ( record.stage === 'foundation_review' || record.stage === 'object_refining' || record.stage === 'visual_refining' || record.stage === 'long_tail_review' || record.stage === 'ready_to_publish' || record.stage === 'published' ) { return 100; } return estimateProgressPercentFromAnchorContent( buildCompatibleAnchorContent(record), ); } function buildCompatibleLastAssistantReply(record: CustomWorldAgentSessionRecord) { const existingReply = (record as Record).lastAssistantReply; if (typeof existingReply === 'string') { return existingReply; } const lastAssistantMessage = [...record.messages] .reverse() .find((message) => message.role === 'assistant' && message.text.trim()); return lastAssistantMessage?.text ?? null; } function buildCompatibleReadiness(record: CustomWorldAgentSessionRecord) { if ( isCreatorIntentReadiness( (record as Record).creatorIntentReadiness, ) ) { return record.creatorIntentReadiness; } return evaluateCreatorIntentReadiness( normalizeCreatorIntentRecord(record.creatorIntent), ); } function buildCompatiblePendingClarifications( record: CustomWorldAgentSessionRecord, ) { const normalizedIntent = normalizeCreatorIntentRecord(record.creatorIntent); const readiness = buildCompatibleReadiness(record); const legacyClarifications = Array.isArray(record.pendingClarifications) ? record.pendingClarifications : []; const nextClarifications = legacyClarifications .map((entry, index) => { const targetKey = mapLegacyClarificationTargetKey(entry.id); if (!targetKey) { return null; } return { id: entry.id || targetKey, label: entry.label || '待补充问题', question: entry.question || '', targetKey, priority: typeof entry.priority === 'number' ? entry.priority : index + 1, answer: entry.answer, } satisfies CustomWorldPendingClarification; }) .filter((entry): entry is CustomWorldPendingClarification => Boolean(entry?.question), ) .slice(0, 3); if (nextClarifications.length > 0) { return nextClarifications; } return buildPendingClarifications(normalizedIntent, readiness); } function buildCompatibleDraftProfile( record: CustomWorldAgentSessionRecord, ) { const anchorContent = buildCompatibleAnchorContent(record); const existingDraftProfile = toRecord(record.draftProfile); const hasFoundationContent = Boolean( existingDraftProfile && (typeof existingDraftProfile.name === 'string' || Array.isArray(existingDraftProfile.playableNpcs) || Array.isArray(existingDraftProfile.landmarks) || Array.isArray(existingDraftProfile.factions) || Array.isArray(existingDraftProfile.threads) || Array.isArray(existingDraftProfile.chapters)), ); if (hasFoundationContent) { return { ...existingDraftProfile, name: toText(existingDraftProfile?.name) || toText(existingDraftProfile?.title) || buildDraftTitleFromEightAnchorContent(anchorContent), summary: toText(existingDraftProfile?.summary) || buildDraftSummaryFromEightAnchorContent(anchorContent), }; } return { ...(existingDraftProfile ?? {}), title: toText(existingDraftProfile?.title) || buildDraftTitleFromEightAnchorContent(anchorContent), summary: toText(existingDraftProfile?.summary) || buildDraftSummaryFromEightAnchorContent(anchorContent), }; } function buildCompatibleSuggestedActions(params: { record: CustomWorldAgentSessionRecord; stage: CustomWorldAgentStage; readiness: CreatorIntentReadiness; draftProfile: Record; }) { if (params.record.suggestedActions.length > 0) { return params.record.suggestedActions; } const actions: CustomWorldSuggestedAction[] = [ { id: 'request_summary', type: 'request_summary', label: params.stage === 'object_refining' || params.stage === 'visual_refining' ? '总结当前世界底稿' : '总结当前设定', }, ]; const playableNpcs = Array.isArray(params.draftProfile.playableNpcs) ? params.draftProfile.playableNpcs : []; const storyNpcs = Array.isArray(params.draftProfile.storyNpcs) ? params.draftProfile.storyNpcs : []; const landmarks = Array.isArray(params.draftProfile.landmarks) ? params.draftProfile.landmarks : []; if (params.stage === 'foundation_review' && params.readiness.isReady) { actions.push({ id: 'draft_foundation', type: 'draft_foundation', label: '整理一版世界底稿', }); return actions; } if (params.stage === 'object_refining' || params.stage === 'visual_refining') { const firstCharacter = toRecord([...playableNpcs, ...storyNpcs][0]); const firstLandmark = toRecord(landmarks[0]); actions.push({ id: 'refine_world', type: 'refine_focus_target', label: '先看世界总卡', targetId: 'world-foundation', }); if (firstCharacter) { actions.push({ id: `refine-character-${toText(firstCharacter.id) || 'seed'}`, type: 'refine_focus_target', label: `精修角色:${toText(firstCharacter.name) || '关键角色'}`, targetId: toText(firstCharacter.id) || null, }); } if (firstLandmark) { actions.push({ id: `refine-landmark-${toText(firstLandmark.id) || 'seed'}`, type: 'refine_focus_target', label: `继续补地点:${toText(firstLandmark.name) || '关键地点'}`, targetId: toText(firstLandmark.id) || null, }); } } return actions; } function normalizeRecommendedReplies(value: unknown) { if (!Array.isArray(value)) { return []; } return value .map((item) => toText(item)) .filter(Boolean) .slice(0, 3); } function buildCompatibleAssetCoverage( record: CustomWorldAgentSessionRecord, draftProfile: Record, ) { const derivedCoverage = rebuildRoleAssetCoverage(draftProfile); const existingCoverage = toRecord(record.assetCoverage); const sceneAssets = derivedCoverage.sceneAssets.length > 0 ? derivedCoverage.sceneAssets : Array.isArray(existingCoverage?.sceneAssets) ? existingCoverage.sceneAssets : []; const allSceneAssetsReady = derivedCoverage.sceneAssets.length > 0 ? derivedCoverage.allSceneAssetsReady : typeof existingCoverage?.allSceneAssetsReady === 'boolean' ? existingCoverage.allSceneAssetsReady : false; return { ...derivedCoverage, sceneAssets, allSceneAssetsReady, } satisfies CustomWorldAssetCoverageSummary; } function applyCompatibility(record: CustomWorldAgentSessionRecord) { const creatorIntent = buildCompatibleCreatorIntent(record); const currentTurn = buildCompatibleCurrentTurn(record); const anchorContent = buildCompatibleAnchorContent(record); const progressPercent = buildCompatibleProgressPercent(record); const lastAssistantReply = buildCompatibleLastAssistantReply(record); const creatorIntentReadiness = progressPercent >= 100 ? { isReady: true, completedKeys: [ 'world_hook', 'player_premise', 'theme_and_tone', 'core_conflict', 'relationship_seed', 'iconic_element', ], missingKeys: [], } : evaluateCreatorIntentReadiness(creatorIntent); const stage = record.stage === 'object_refining' || record.stage === 'visual_refining' || record.stage === 'long_tail_review' || record.stage === 'ready_to_publish' || record.stage === 'published' ? record.stage : progressPercent >= 100 ? ('foundation_review' as const) : resolveCreatorIntentStage({ hasUserInput: hasUserInput(record), readiness: creatorIntentReadiness, }); const pendingClarifications = buildCompatiblePendingClarifications({ ...record, creatorIntent, creatorIntentReadiness, }); const draftProfile = buildCompatibleDraftProfile(record); return { ...record, currentTurn, anchorContent, progressPercent, lastAssistantReply, stage, creatorIntent, creatorIntentReadiness, anchorPack: record.anchorPack && Object.keys(record.anchorPack).length > 0 ? record.anchorPack : buildAnchorPackFromEightAnchorContent(anchorContent, progressPercent), draftProfile, pendingClarifications, suggestedActions: buildCompatibleSuggestedActions({ record, stage, readiness: creatorIntentReadiness, draftProfile, }), assetCoverage: buildCompatibleAssetCoverage(record, draftProfile), recommendedReplies: normalizeRecommendedReplies( (record as Record).recommendedReplies, ), } satisfies CustomWorldAgentSessionRecord; } function toSnapshot( record: CustomWorldAgentSessionRecord, ): CustomWorldAgentSessionSnapshot { return { sessionId: record.sessionId, currentTurn: record.currentTurn, anchorContent: cloneRecord(record.anchorContent), progressPercent: record.progressPercent, lastAssistantReply: record.lastAssistantReply, stage: record.stage, focusCardId: record.focusCardId, creatorIntent: cloneRecord(record.creatorIntent), creatorIntentReadiness: cloneRecord(record.creatorIntentReadiness), anchorPack: cloneRecord(record.anchorPack), lockState: cloneRecord(record.lockState), draftProfile: cloneRecord(record.draftProfile), messages: cloneRecord(record.messages), draftCards: cloneRecord(record.draftCards), pendingClarifications: cloneRecord(record.pendingClarifications), suggestedActions: cloneRecord(record.suggestedActions), recommendedReplies: cloneRecord(record.recommendedReplies), qualityFindings: cloneRecord(record.qualityFindings), assetCoverage: cloneRecord(record.assetCoverage), updatedAt: record.updatedAt, }; } export class CustomWorldAgentSessionStore { constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {} private async persist(record: CustomWorldAgentSessionRecord) { await this.runtimeRepository.upsertCustomWorldSession( record.userId, record.sessionId, record as unknown as LegacyCustomWorldSessionRecord, ); return cloneRecord(record); } private async mutate( userId: string, sessionId: string, mutateFn: (record: CustomWorldAgentSessionRecord) => void, ) { const current = await this.get(userId, sessionId); if (!current) { return null; } const nextRecord = cloneRecord(current); mutateFn(nextRecord); nextRecord.updatedAt = new Date().toISOString(); return this.persist(nextRecord); } async create(userId: string, input: CreateSessionInput) { const sessionId = `${CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX}${crypto.randomBytes(16).toString('hex')}`; const now = new Date().toISOString(); const welcomeMessage: CustomWorldAgentMessage = { id: `message-${crypto.randomBytes(8).toString('hex')}`, role: 'assistant', kind: 'chat', text: input.welcomeMessage, createdAt: now, relatedOperationId: null, }; const record: CustomWorldAgentSessionRecord = { sessionId, userId, seedText: input.seedText?.trim() ?? '', currentTurn: Math.max(0, Math.round(input.currentTurn ?? 0)), anchorContent: normalizeEightAnchorContent( input.anchorContent ?? createEmptyEightAnchorContent(), ), progressPercent: Math.max( 0, Math.min(100, Math.round(input.progressPercent ?? 0)), ), lastAssistantReply: input.lastAssistantReply ?? input.welcomeMessage, stage: input.stage ?? 'collecting_intent', focusCardId: null, creatorIntent: cloneRecord(input.creatorIntent ?? {}), creatorIntentReadiness: input.creatorIntentReadiness ?? { isReady: false, completedKeys: [], missingKeys: [], }, anchorPack: cloneRecord(input.anchorPack ?? {}), lockState: {}, draftProfile: cloneRecord(input.draftProfile ?? {}), messages: [welcomeMessage], draftCards: [], pendingClarifications: cloneRecord(input.pendingClarifications), suggestedActions: cloneRecord(input.suggestedActions), recommendedReplies: cloneRecord(input.recommendedReplies ?? []), qualityFindings: [], assetCoverage: rebuildRoleAssetCoverage(input.draftProfile ?? {}), operations: [], checkpoints: [], createdAt: now, updatedAt: now, }; const compatibleRecord = applyCompatibility(record); await this.persist(compatibleRecord); return cloneRecord(compatibleRecord); } async list(userId: string) { const records = await this.runtimeRepository.listCustomWorldSessions(userId); return records .filter((record) => isAgentSessionRecord(record)) .map((record) => cloneRecord(applyCompatibility(record))) .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); } async get(userId: string, sessionId: string) { if (!sessionId.trim()) { return null; } const record = await this.runtimeRepository.getCustomWorldSession( userId, sessionId, ); if (!isAgentSessionRecord(record)) { return null; } return cloneRecord(applyCompatibility(record)); } async getSnapshot(userId: string, sessionId: string) { const record = await this.get(userId, sessionId); return record ? toSnapshot(record) : null; } async appendMessage( userId: string, sessionId: string, message: CustomWorldAgentMessage, ) { return this.mutate(userId, sessionId, (record) => { record.messages.push(cloneRecord(message)); }); } async replaceDerivedState( userId: string, sessionId: string, patch: Partial< Pick< CustomWorldAgentSessionRecord, | 'currentTurn' | 'anchorContent' | 'progressPercent' | 'lastAssistantReply' | 'stage' | 'creatorIntent' | 'creatorIntentReadiness' | 'anchorPack' | 'lockState' | 'draftProfile' | 'pendingClarifications' | 'suggestedActions' | 'recommendedReplies' | 'draftCards' | 'qualityFindings' | 'focusCardId' | 'assetCoverage' > >, ) { return this.mutate(userId, sessionId, (record) => { if (typeof patch.currentTurn === 'number') { record.currentTurn = Math.max(0, Math.round(patch.currentTurn)); } if (patch.anchorContent !== undefined) { record.anchorContent = normalizeEightAnchorContent(patch.anchorContent); } if (typeof patch.progressPercent === 'number') { record.progressPercent = Math.max( 0, Math.min(100, Math.round(patch.progressPercent)), ); } if (patch.lastAssistantReply !== undefined) { record.lastAssistantReply = patch.lastAssistantReply; } if (patch.stage) { record.stage = patch.stage; } if (patch.focusCardId !== undefined) { record.focusCardId = patch.focusCardId; } if (patch.creatorIntent !== undefined) { record.creatorIntent = cloneRecord(patch.creatorIntent); } if (patch.creatorIntentReadiness !== undefined) { record.creatorIntentReadiness = cloneRecord( patch.creatorIntentReadiness, ); } if (patch.anchorPack !== undefined) { record.anchorPack = cloneRecord(patch.anchorPack); } if (patch.lockState !== undefined) { record.lockState = cloneRecord(patch.lockState); } if (patch.draftProfile !== undefined) { record.draftProfile = cloneRecord(patch.draftProfile); } if (patch.pendingClarifications !== undefined) { record.pendingClarifications = cloneRecord(patch.pendingClarifications); } if (patch.suggestedActions !== undefined) { record.suggestedActions = cloneRecord(patch.suggestedActions); } if (patch.recommendedReplies !== undefined) { record.recommendedReplies = cloneRecord(patch.recommendedReplies); } if (patch.draftCards !== undefined) { record.draftCards = cloneRecord(patch.draftCards); } if (patch.qualityFindings !== undefined) { record.qualityFindings = cloneRecord(patch.qualityFindings); } if (patch.assetCoverage !== undefined) { record.assetCoverage = cloneRecord(patch.assetCoverage); } }); } async createOperation( userId: string, sessionId: string, operation: CustomWorldAgentOperationRecord, ) { return this.mutate(userId, sessionId, (record) => { record.operations.push(cloneRecord(operation)); }); } async getOperation(userId: string, sessionId: string, operationId: string) { const record = await this.get(userId, sessionId); if (!record) { return null; } const operation = record.operations.find( (item) => item.operationId === operationId, ); return operation ? cloneRecord(operation) : null; } async updateOperation( userId: string, sessionId: string, operationId: string, patch: Partial, ) { return this.mutate(userId, sessionId, (record) => { const operation = record.operations.find( (item) => item.operationId === operationId, ); if (!operation) { return; } if (patch.type) { operation.type = patch.type; } if (patch.status) { operation.status = patch.status; } if (patch.phaseLabel) { operation.phaseLabel = patch.phaseLabel; } if (patch.phaseDetail) { operation.phaseDetail = patch.phaseDetail; } if (typeof patch.progress === 'number') { operation.progress = patch.progress; } if (patch.error !== undefined) { operation.error = patch.error; } }); } async appendCheckpoint( userId: string, sessionId: string, input: { checkpointId?: string; label: string; }, ) { return this.mutate(userId, sessionId, (record) => { record.checkpoints.push({ checkpointId: input.checkpointId || `checkpoint-${crypto.randomBytes(8).toString('hex')}`, createdAt: new Date().toISOString(), label: input.label, }); }); } async listDraftCards(userId: string, sessionId: string) { const record = await this.get(userId, sessionId); return record ? cloneRecord(record.draftCards) : null; } }