This commit is contained in:
2026-04-21 00:48:17 +08:00
parent 75944b1f1f
commit effe0355bd
19 changed files with 2897 additions and 180 deletions

View File

@@ -16,6 +16,8 @@ import type {
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
import { badRequest, notFound } from '../errors.js';
import { prepareEventStreamResponse } from '../http.js';
import { normalizeCustomWorldProfile } from '../modules/custom-world/runtimeProfile.js';
import type { CustomWorldProfile } from '../modules/custom-world/runtimeTypes.js';
import { CustomWorldAgentAssetBridgeService } from './customWorldAgentAssetBridgeService.js';
import { CustomWorldAgentAutoAssetService } from './customWorldAgentAutoAssetService.js';
import { CustomWorldAgentChangeSummaryService } from './customWorldAgentChangeSummaryService.js';
@@ -150,6 +152,8 @@ function buildOperation(type: CustomWorldAgentOperationRecord['type']) {
? '正在把已确认设定编成第一版世界底稿。'
: type === 'update_draft_card'
? '正在把这次设定改动写回草稿。'
: type === 'sync_result_profile'
? '正在把结果页里的世界快照同步回当前草稿。'
: type === 'generate_characters'
? '正在围绕当前底稿补出新角色。'
: type === 'generate_landmarks'
@@ -194,6 +198,27 @@ function buildRoleAssetSyncResultText(params: {
return `已把「${params.roleName}」的角色资产写回草稿,当前状态:${params.assetStatusLabel}`;
}
function syncResultProfileIntoDraftProfile(params: {
currentDraftProfile: Record<string, unknown> | null | undefined;
resultProfile: CustomWorldProfile;
}) {
const currentDraftProfile = params.currentDraftProfile ?? {};
const resultProfile = params.resultProfile;
return {
// 阶段一只回写基础摘要和完整 legacy 快照,避免把结果页的运行时结构反向拆回 foundation draft。
...currentDraftProfile,
name: resultProfile.name,
subtitle: resultProfile.subtitle,
summary: resultProfile.summary,
tone: resultProfile.tone,
playerGoal: resultProfile.playerGoal,
majorFactions: resultProfile.majorFactions,
coreConflicts: resultProfile.coreConflicts,
legacyResultProfile: resultProfile as unknown as Record<string, unknown>,
} satisfies Record<string, unknown>;
}
function buildQuestionLines(
pendingClarifications: CustomWorldPendingClarification[],
) {
@@ -548,6 +573,7 @@ export class CustomWorldAgentOrchestrator {
if (
payload.action === 'update_draft_card' ||
payload.action === 'sync_result_profile' ||
payload.action === 'generate_characters' ||
payload.action === 'generate_landmarks' ||
payload.action === 'generate_role_assets' ||
@@ -595,6 +621,32 @@ export class CustomWorldAgentOrchestrator {
};
}
if (payload.action === 'sync_result_profile') {
const normalizedProfile = normalizeCustomWorldProfile(
payload.profile,
'',
);
if (!normalizedProfile) {
throw badRequest('sync_result_profile requires a valid profile');
}
const operation = buildOperation('sync_result_profile');
await this.sessionStore.createOperation(userId, sessionId, operation);
void this.processSyncResultProfileOperation({
userId,
sessionId,
operationId: operation.operationId,
payload: {
...payload,
profile: normalizedProfile as unknown as Record<string, unknown>,
},
});
return {
operation,
};
}
if (payload.action === 'generate_characters') {
if (payload.count < 1 || payload.count > 3) {
throw badRequest('generate_characters count must be between 1 and 3');
@@ -1113,6 +1165,97 @@ export class CustomWorldAgentOrchestrator {
}
}
private async processSyncResultProfileOperation(params: {
userId: string;
sessionId: string;
operationId: string;
payload: Extract<
CustomWorldAgentActionRequest,
{ action: 'sync_result_profile' }
>;
}) {
const { userId, sessionId, operationId, payload } = params;
try {
await this.sessionStore.updateOperation(userId, sessionId, operationId, {
status: 'running',
phaseLabel: '同步结果页快照',
phaseDetail: '正在把结果页里的最新世界结构写回当前草稿。',
progress: 36,
});
const latestSession = (await this.sessionStore.get(
userId,
sessionId,
)) as CustomWorldAgentSessionRecord | null;
if (!latestSession) {
throw new Error('custom world agent session not found');
}
const resultProfile = payload.profile as unknown as CustomWorldProfile;
const nextDraftProfile = syncResultProfileIntoDraftProfile({
currentDraftProfile: latestSession.draftProfile,
resultProfile,
});
await this.sessionStore.updateOperation(userId, sessionId, operationId, {
phaseLabel: '重编译草稿摘要',
phaseDetail: '正在刷新草稿卡摘要、资产覆盖和结果页恢复快照。',
progress: 72,
});
const nextDraftCards = this.draftCompiler.compileDraftCards(nextDraftProfile);
const assetCoverage = rebuildRoleAssetCoverage(nextDraftProfile);
const nextStage =
latestSession.stage === 'visual_refining'
? ('visual_refining' as const)
: ('object_refining' as const);
const nextSuggestedActions = buildSuggestedActions({
stage: nextStage,
isReady: true,
draftProfile: nextDraftProfile,
draftCards: nextDraftCards,
});
await this.sessionStore.replaceDerivedState(userId, sessionId, {
stage: nextStage,
draftProfile: nextDraftProfile,
draftCards: nextDraftCards,
assetCoverage,
suggestedActions: nextSuggestedActions,
recommendedReplies: [],
});
await this.sessionStore.appendCheckpoint(userId, sessionId, {
label: '同步结果页编辑',
});
await this.sessionStore.appendMessage(
userId,
sessionId,
buildActionResultMessage({
relatedOperationId: operationId,
text: '结果页里的最新世界结构已经同步回当前草稿。',
}),
);
await this.sessionStore.updateOperation(userId, sessionId, operationId, {
status: 'completed',
phaseLabel: '结果页快照已同步',
phaseDetail: '当前结果页编辑内容已经并回 Agent 草稿主链。',
progress: 100,
error: null,
});
} catch (error) {
await this.sessionStore.updateOperation(userId, sessionId, operationId, {
status: 'failed',
phaseLabel: '结果页同步失败',
phaseDetail: '这一轮结果页编辑没有成功写回当前草稿。',
progress: 100,
error:
error instanceof Error ? error.message : 'sync result profile failed',
});
}
}
private async processGenerateCharactersOperation(params: {
userId: string;
sessionId: string;

View File

@@ -227,6 +227,200 @@ test('phase4 update_draft_card writes back draft profile and recompiles summarie
);
});
test('phase4 sync_result_profile writes result-page snapshot back into session draft chain', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
});
const userId = 'user-phase4-sync-result-profile';
const session = await createObjectRefiningSession(orchestrator, userId);
const response = await orchestrator.executeAction(userId, session.sessionId, {
action: 'sync_result_profile',
profile: {
id: `agent-draft-${session.sessionId}`,
settingText: '被海雾吞没的旧航路群岛',
name: '潮雾列岛·结果页精修版',
subtitle: '旧灯塔与失控航路',
summary: '结果页已经把世界概述继续往沉船夜暗线收紧。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船夜与假航灯的真正操盘者。',
templateWorldType: 'WUXIA',
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
attributeSchema: {
id: 'schema:test',
worldId: 'CUSTOM',
schemaVersion: 1,
schemaName: '测试',
generatedFrom: {
worldType: 'CUSTOM',
worldName: '潮雾列岛·结果页精修版',
settingSummary: '测试',
tone: '测试',
conflictCore: '测试',
},
slots: [],
},
playableNpcs: [],
storyNpcs: [],
items: [],
landmarks: [],
generationMode: 'full',
generationStatus: 'complete',
},
});
const operation = await waitForOperation(
orchestrator,
userId,
session.sessionId,
response.operation.operationId,
);
const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId);
const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile);
const draftRecord = snapshot?.draftProfile as Record<string, unknown> | null;
const legacyResultProfile = draftRecord?.legacyResultProfile as
| Record<string, unknown>
| undefined;
assert.equal(operation?.status, 'completed');
assert.equal(profile?.name, '潮雾列岛·结果页精修版');
assert.equal(
profile?.summary,
'结果页已经把世界概述继续往沉船夜暗线收紧。',
);
assert.equal(legacyResultProfile?.name, '潮雾列岛·结果页精修版');
assert.equal(
legacyResultProfile?.playerGoal,
'查清沉船夜与假航灯的真正操盘者。',
);
assert.ok(
snapshot?.messages.some(
(message) =>
message.kind === 'action_result' &&
message.text.includes('结果页里的最新世界结构已经同步回当前草稿'),
),
);
});
test('phase4 sync_result_profile keeps existing foundation structure while updating summary snapshot', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
});
const userId = 'user-phase4-sync-result-profile-structure';
const session = await createObjectRefiningSession(orchestrator, userId);
const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile);
const baselinePlayableName = baselineProfile?.playableNpcs[0]?.name;
const baselineStoryName = baselineProfile?.storyNpcs[0]?.name;
const baselineLandmarkName = baselineProfile?.landmarks[0]?.name;
assert.ok(baselinePlayableName);
assert.ok(baselineStoryName);
assert.ok(baselineLandmarkName);
const response = await orchestrator.executeAction(userId, session.sessionId, {
action: 'sync_result_profile',
profile: {
id: `agent-draft-${session.sessionId}`,
settingText: '被海雾吞没的旧航路群岛',
name: '潮雾列岛·结果页精修版',
subtitle: '旧灯塔与失控航路',
summary: '结果页已经把世界概述继续往沉船夜暗线收紧。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船夜与假航灯的真正操盘者。',
templateWorldType: 'WUXIA',
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
attributeSchema: {
id: 'schema:test',
worldId: 'CUSTOM',
schemaVersion: 1,
schemaName: '测试',
generatedFrom: {
worldType: 'CUSTOM',
worldName: '潮雾列岛·结果页精修版',
settingSummary: '测试',
tone: '测试',
conflictCore: '测试',
},
slots: [],
},
playableNpcs: [
{
id: 'playable-runtime-only',
name: '结果页临时角色',
title: '运行时角色',
role: '测试角色',
description: '不应该直接覆盖 foundation draft。',
backstory: '仅用于验证 sync 边界。',
personality: '谨慎',
motivation: '验证同步边界',
combatStyle: '观察',
initialAffinity: 0,
relationshipHooks: [],
tags: [],
},
],
storyNpcs: [
{
id: 'story-runtime-only',
name: '结果页临时场景角色',
title: '运行时场景角色',
role: '测试角色',
description: '不应该直接覆盖 foundation draft。',
backstory: '仅用于验证 sync 边界。',
personality: '克制',
motivation: '验证同步边界',
combatStyle: '观察',
initialAffinity: 0,
relationshipHooks: [],
tags: [],
},
],
items: [],
landmarks: [
{
id: 'landmark-runtime-only',
name: '结果页临时地点',
description: '不应该直接覆盖 foundation draft。',
dangerLevel: '低',
sceneNpcIds: [],
connections: [],
},
],
generationMode: 'full',
generationStatus: 'complete',
},
});
const operation = await waitForOperation(
orchestrator,
userId,
session.sessionId,
response.operation.operationId,
);
const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId);
const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile);
const draftRecord = snapshot?.draftProfile as Record<string, unknown> | null;
const legacyResultProfile = draftRecord?.legacyResultProfile as
| Record<string, unknown>
| undefined;
assert.equal(operation?.status, 'completed');
assert.equal(profile?.name, '潮雾列岛·结果页精修版');
assert.equal(profile?.playableNpcs[0]?.name, baselinePlayableName);
assert.equal(profile?.storyNpcs[0]?.name, baselineStoryName);
assert.equal(profile?.landmarks[0]?.name, baselineLandmarkName);
assert.equal(legacyResultProfile?.name, '潮雾列岛·结果页精修版');
assert.equal(
(legacyResultProfile?.playableNpcs as Array<{ name?: string }> | undefined)?.[0]
?.name,
'结果页临时角色',
);
});
test('phase4 generate_characters appends story npcs and updates work summary counts', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
@@ -323,3 +517,33 @@ test('phase4 generate_landmarks appends new landmark cards and checkpoints', asy
);
assert.ok((latestSessionRecord?.checkpoints.length ?? 0) >= 2);
});
test('phase4 work summaries exclude library draft entries after phase3 downgrade', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
});
const userId = 'user-phase4-work-summary-phase3';
const session = await createObjectRefiningSession(orchestrator, userId);
await runtimeRepository.upsertCustomWorldProfile(userId, 'library-draft-1', {
id: 'library-draft-1',
name: '旧兼容草稿',
subtitle: '仍保留在作品库',
summary: '不应该继续出现在创作中心 works 聚合里。',
playableNpcs: [],
landmarks: [],
});
const workItems = await listCustomWorldWorkSummaries(userId, {
runtimeRepository,
customWorldAgentSessions: sessionStore,
});
assert.ok(workItems.some((item) => item.sessionId === session.sessionId));
assert.equal(
workItems.some((item) => item.profileId === 'library-draft-1'),
false,
);
});

View File

@@ -171,6 +171,12 @@ function isLibraryEntry(
);
}
function isPublishedLibraryEntry(
value: unknown,
): value is CustomWorldLibraryEntry<CustomWorldProfileRecord> {
return isLibraryEntry(value) && value.visibility === 'published';
}
export async function listCustomWorldWorkSummaries(
userId: string,
dependencies: {
@@ -216,8 +222,10 @@ export async function listCustomWorldWorkSummaries(
};
});
const publishedItems: CustomWorldWorkSummary[] = profiles.map((profile) => {
const libraryEntry = isLibraryEntry(profile) ? profile : null;
const publishedItems: CustomWorldWorkSummary[] = profiles
.filter((profile) => isPublishedLibraryEntry(profile))
.map((profile) => {
const libraryEntry = profile;
const profileRecord = (
libraryEntry?.profile ?? profile
) as CustomWorldProfileRecord & Record<string, unknown>;
@@ -237,59 +245,55 @@ export async function listCustomWorldWorkSummaries(
(entry) => Boolean(toText(entry.generatedAnimationSetId)),
).length;
return {
workId: `published:${toText(profileRecord.id) || updatedAt}`,
sourceType: 'published_profile',
status: 'published',
title:
(libraryEntry ? toText(libraryEntry.worldName) : '') ||
toText(profileRecord.name) ||
'未命名世界',
subtitle:
(libraryEntry ? toText(libraryEntry.subtitle) : '') ||
toText(profileRecord.subtitle) ||
'已保存作品',
summary:
(libraryEntry ? toText(libraryEntry.summaryText) : '') ||
toText(profileRecord.summary) ||
'这个世界已经可以直接进入体验。',
coverImageSrc:
(libraryEntry ? libraryEntry.coverImageSrc : null) ||
coverPresentation.imageSrc,
coverRenderMode: coverPresentation.renderMode,
coverCharacterImageSrcs: coverPresentation.characterImageSrcs,
updatedAt,
publishedAt:
(libraryEntry ? toText(libraryEntry.publishedAt) : '') ||
toText(profileRecord.publishedAt) ||
return {
workId: `published:${toText(profileRecord.id) || updatedAt}`,
sourceType: 'published_profile',
status: 'published',
title:
toText(libraryEntry.worldName) ||
toText(profileRecord.name) ||
'未命名世界',
subtitle:
toText(libraryEntry.subtitle) ||
toText(profileRecord.subtitle) ||
'已保存作品',
summary:
toText(libraryEntry.summaryText) ||
toText(profileRecord.summary) ||
'这个世界已经可以直接进入体验。',
coverImageSrc: libraryEntry.coverImageSrc || coverPresentation.imageSrc,
coverRenderMode: coverPresentation.renderMode,
coverCharacterImageSrcs: coverPresentation.characterImageSrcs,
updatedAt,
stage: 'published',
stageLabel: '已发布',
playableNpcCount:
(libraryEntry?.playableNpcCount ?? 0) > 0
? libraryEntry!.playableNpcCount
: playableNpcs.length,
landmarkCount:
(libraryEntry?.landmarkCount ?? 0) > 0
? libraryEntry!.landmarkCount
: landmarks.length,
roleVisualReadyCount,
roleAnimationReadyCount,
roleAssetSummaryLabel:
roleAnimationReadyCount > 0
? `动作已就绪 ${roleAnimationReadyCount}`
: roleVisualReadyCount > 0
? `主图已就绪 ${roleVisualReadyCount}`
: null,
sessionId: null,
profileId:
(libraryEntry ? toText(libraryEntry.profileId) : '') ||
toText(profileRecord.id) ||
null,
canResume: false,
canEnterWorld: true,
};
});
publishedAt:
toText(libraryEntry.publishedAt) ||
toText(profileRecord.publishedAt) ||
updatedAt,
stage: 'published',
stageLabel: '已发布',
playableNpcCount:
libraryEntry.playableNpcCount > 0
? libraryEntry.playableNpcCount
: playableNpcs.length,
landmarkCount:
libraryEntry.landmarkCount > 0
? libraryEntry.landmarkCount
: landmarks.length,
roleVisualReadyCount,
roleAnimationReadyCount,
roleAssetSummaryLabel:
roleAnimationReadyCount > 0
? `动作已就绪 ${roleAnimationReadyCount}`
: roleVisualReadyCount > 0
? `主图已就绪 ${roleVisualReadyCount}`
: null,
sessionId: null,
profileId:
toText(libraryEntry.profileId) || toText(profileRecord.id) || null,
canResume: false,
canEnterWorld: true,
};
});
return [...draftItems, ...publishedItems].sort((left, right) =>
right.updatedAt.localeCompare(left.updatedAt),