Integrate role asset studio into custom world agent flow

This commit is contained in:
2026-04-14 20:16:41 +08:00
parent 0981d6ee1b
commit bc2999ffb9
118 changed files with 31211 additions and 1232 deletions

View File

@@ -0,0 +1,711 @@
import crypto from 'node:crypto';
import type {
CustomWorldAssetCoverageSummary,
CreatorIntentReadiness,
CustomWorldAgentMessage,
CustomWorldAgentOperationRecord,
CustomWorldAgentSessionSnapshot,
CustomWorldAgentStage,
CustomWorldDraftCardSummary,
CustomWorldPendingClarification,
CustomWorldSuggestedAction,
} 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 {
buildAnchorPackFromIntent,
buildDraftSummaryFromIntent,
buildDraftTitleFromIntent,
createEmptyCreatorIntentRecord,
extractCreatorIntentPatch,
mergeCreatorIntentRecord,
normalizeCreatorIntentRecord,
} from './customWorldAgentIntentExtractionService.js';
import { rebuildRoleAssetCoverage } from './customWorldAgentRoleAssetStateService.js';
export const CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX =
'custom-world-agent-session-';
export type CustomWorldAgentSessionRecord = {
sessionId: string;
userId: string;
seedText: string;
stage: CustomWorldAgentStage;
focusCardId: string | null;
creatorIntent: Record<string, unknown> | null;
creatorIntentReadiness: CreatorIntentReadiness;
anchorPack: Record<string, unknown> | null;
lockState: Record<string, unknown> | null;
draftProfile: Record<string, unknown> | 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;
pendingClarifications: CustomWorldAgentSessionRecord['pendingClarifications'];
creatorIntent?: CustomWorldAgentSessionRecord['creatorIntent'];
creatorIntentReadiness?: CreatorIntentReadiness;
anchorPack?: CustomWorldAgentSessionRecord['anchorPack'];
draftProfile?: CustomWorldAgentSessionRecord['draftProfile'];
stage?: CustomWorldAgentStage;
suggestedActions: CustomWorldSuggestedAction[];
recommendedReplies?: string[];
};
function cloneRecord<T>(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<string, unknown>)
: 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 existingIntent =
normalizeCreatorIntentRecord(record.creatorIntent) ??
createEmptyCreatorIntentRecord('freeform');
if (!record.seedText.trim()) {
return existingIntent;
}
const seedPatch = extractCreatorIntentPatch({
currentIntent: existingIntent,
latestUserMessage: record.seedText,
});
return mergeCreatorIntentRecord(existingIntent, seedPatch);
}
function buildCompatibleReadiness(record: CustomWorldAgentSessionRecord) {
if (
isCreatorIntentReadiness(
(record as Record<string, unknown>).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,
creatorIntent: ReturnType<typeof buildCompatibleCreatorIntent>,
) {
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) ||
buildDraftTitleFromIntent(creatorIntent),
summary:
toText(existingDraftProfile?.summary) ||
buildDraftSummaryFromIntent(creatorIntent),
};
}
return {
...(existingDraftProfile ?? {}),
title:
toText(existingDraftProfile?.title) || buildDraftTitleFromIntent(creatorIntent),
summary:
toText(existingDraftProfile?.summary) ||
buildDraftSummaryFromIntent(creatorIntent),
};
}
function buildCompatibleSuggestedActions(params: {
record: CustomWorldAgentSessionRecord;
stage: CustomWorldAgentStage;
readiness: CreatorIntentReadiness;
draftProfile: Record<string, unknown>;
}) {
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<string, unknown>,
) {
const derivedCoverage = rebuildRoleAssetCoverage(draftProfile);
const existingCoverage = toRecord(record.assetCoverage);
const sceneAssets = Array.isArray(existingCoverage?.sceneAssets)
? existingCoverage.sceneAssets
: [];
const allSceneAssetsReady =
typeof existingCoverage?.allSceneAssetsReady === 'boolean'
? existingCoverage.allSceneAssetsReady
: false;
return {
...derivedCoverage,
sceneAssets,
allSceneAssetsReady,
} satisfies CustomWorldAssetCoverageSummary;
}
function applyCompatibility(record: CustomWorldAgentSessionRecord) {
const creatorIntent = buildCompatibleCreatorIntent(record);
const creatorIntentReadiness = evaluateCreatorIntentReadiness(creatorIntent);
const stage =
record.stage === 'collecting_intent' ||
record.stage === 'clarifying' ||
record.stage === 'foundation_review'
? resolveCreatorIntentStage({
hasUserInput: hasUserInput(record),
readiness: creatorIntentReadiness,
})
: record.stage;
const pendingClarifications = buildCompatiblePendingClarifications({
...record,
creatorIntent,
creatorIntentReadiness,
});
const draftProfile = buildCompatibleDraftProfile(record, creatorIntent);
return {
...record,
stage,
creatorIntent,
creatorIntentReadiness,
anchorPack:
record.anchorPack && Object.keys(record.anchorPack).length > 0
? record.anchorPack
: buildAnchorPackFromIntent(creatorIntent, {
completedKeys: creatorIntentReadiness.completedKeys,
missingKeys: creatorIntentReadiness.missingKeys,
}),
draftProfile,
pendingClarifications,
suggestedActions: buildCompatibleSuggestedActions({
record,
stage,
readiness: creatorIntentReadiness,
draftProfile,
}),
assetCoverage: buildCompatibleAssetCoverage(record, draftProfile),
recommendedReplies: normalizeRecommendedReplies(
(record as Record<string, unknown>).recommendedReplies,
),
} satisfies CustomWorldAgentSessionRecord;
}
function toSnapshot(
record: CustomWorldAgentSessionRecord,
): CustomWorldAgentSessionSnapshot {
return {
sessionId: record.sessionId,
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() ?? '',
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,
| 'stage'
| 'creatorIntent'
| 'creatorIntentReadiness'
| 'anchorPack'
| 'lockState'
| 'draftProfile'
| 'pendingClarifications'
| 'suggestedActions'
| 'recommendedReplies'
| 'draftCards'
| 'qualityFindings'
| 'focusCardId'
| 'assetCoverage'
>
>,
) {
return this.mutate(userId, sessionId, (record) => {
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<CustomWorldAgentOperationRecord>,
) {
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;
}
}