1
This commit is contained in:
@@ -2503,6 +2503,34 @@ test('custom world works endpoint returns draft sessions and published worlds to
|
||||
|
||||
assert.equal(publishResponse.status, 200);
|
||||
|
||||
const publishMutationResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world-library/world-published/publish`,
|
||||
withBearer(entry.token, {
|
||||
method: 'POST',
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(publishMutationResponse.status, 200);
|
||||
|
||||
const draftOnlyResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world-library/world-draft-only`,
|
||||
withBearer(entry.token, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
profile: {
|
||||
id: 'world-draft-only',
|
||||
name: '旧兼容草稿',
|
||||
subtitle: '仍保留在作品库,但不再进入创作中心',
|
||||
summary: '这个条目用来验证阶段三不会继续把 library draft 当主草稿展示。',
|
||||
playableNpcs: [{ id: 'hero-draft', name: '旧草稿角色' }],
|
||||
landmarks: [{ id: 'port-draft', name: '旧草稿地点' }],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(draftOnlyResponse.status, 200);
|
||||
|
||||
const worksResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world/works`,
|
||||
{
|
||||
@@ -2542,6 +2570,10 @@ test('custom world works endpoint returns draft sessions and published worlds to
|
||||
item.canEnterWorld === true,
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
worksPayload.items.some((item) => item.profileId === 'world-draft-only'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3038,6 +3070,117 @@ test('custom world agent update_draft_card action updates draft profile and card
|
||||
);
|
||||
});
|
||||
|
||||
test('custom world agent sync_result_profile action writes result snapshot back over http', async () => {
|
||||
await withTestServer(
|
||||
'custom-world-agent-sync-result-profile-http',
|
||||
async ({ baseUrl, context }) => {
|
||||
installTestCustomWorldAgentSingleTurnLlm(context);
|
||||
const entry = await authEntry(
|
||||
baseUrl,
|
||||
'cw_agent_sync_result',
|
||||
'secret123',
|
||||
);
|
||||
const session = await createObjectRefiningCustomWorldAgentSession({
|
||||
baseUrl,
|
||||
token: entry.token,
|
||||
});
|
||||
|
||||
const actionResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}/actions`,
|
||||
withBearer(entry.token, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
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 actionPayload = (await actionResponse.json()) as {
|
||||
operation: {
|
||||
operationId: string;
|
||||
status: string;
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(actionResponse.status, 200);
|
||||
assert.equal(actionPayload.operation.status, 'queued');
|
||||
|
||||
await waitForCustomWorldAgentOperation({
|
||||
baseUrl,
|
||||
token: entry.token,
|
||||
sessionId: session.sessionId,
|
||||
operationId: actionPayload.operation.operationId,
|
||||
expectedStatus: 'completed',
|
||||
});
|
||||
|
||||
const sessionResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${entry.token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const sessionPayload = (await sessionResponse.json()) as {
|
||||
draftProfile: {
|
||||
name?: string;
|
||||
summary?: string;
|
||||
legacyResultProfile?: {
|
||||
name?: string;
|
||||
playerGoal?: string;
|
||||
};
|
||||
} | null;
|
||||
};
|
||||
|
||||
assert.equal(sessionResponse.status, 200);
|
||||
assert.equal(sessionPayload.draftProfile?.name, '潮雾列岛·结果页回写版');
|
||||
assert.equal(
|
||||
sessionPayload.draftProfile?.summary,
|
||||
'结果页里的最新世界概述已经回写到当前草稿。',
|
||||
);
|
||||
assert.equal(
|
||||
sessionPayload.draftProfile?.legacyResultProfile?.name,
|
||||
'潮雾列岛·结果页回写版',
|
||||
);
|
||||
assert.equal(
|
||||
sessionPayload.draftProfile?.legacyResultProfile?.playerGoal,
|
||||
'查清沉船夜与假航灯背后的操盘链。',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('custom world agent generate_characters action appends character cards over http', async () => {
|
||||
await withTestServer(
|
||||
'custom-world-agent-phase4-generate-characters-http',
|
||||
|
||||
@@ -39,6 +39,10 @@ const actionSchema = z.discriminatedUnion('action', [
|
||||
)
|
||||
.min(1),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal('sync_result_profile'),
|
||||
profile: z.record(z.string(), z.unknown()),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal('generate_characters'),
|
||||
count: z.number().int().min(1).max(3),
|
||||
|
||||
229
server-node/src/scripts/syncCustomWorldSavedProfileAssets.ts
Normal file
229
server-node/src/scripts/syncCustomWorldSavedProfileAssets.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { createDatabase } from '../db.js';
|
||||
import { RuntimeRepository } from '../repositories/runtimeRepository.js';
|
||||
import { loadConfig } from '../config.js';
|
||||
import { CustomWorldAgentSessionStore } from '../services/customWorldAgentSessionStore.js';
|
||||
|
||||
type RecordValue = Record<string, unknown>;
|
||||
|
||||
function toText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is RecordValue {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function toRecordArray(value: unknown) {
|
||||
return Array.isArray(value)
|
||||
? value.filter((entry): entry is RecordValue => isRecord(entry))
|
||||
: [];
|
||||
}
|
||||
|
||||
function resolvePublicAssetPath(publicDir: string, imageSrc: unknown) {
|
||||
const normalizedImageSrc = toText(imageSrc);
|
||||
if (!normalizedImageSrc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return path.join(publicDir, normalizedImageSrc.replace(/^\/+/u, ''));
|
||||
}
|
||||
|
||||
async function ensureSquareRoleImage(publicDir: string, imageSrc: unknown) {
|
||||
const assetPath = resolvePublicAssetPath(publicDir, imageSrc);
|
||||
if (!assetPath || !fs.existsSync(assetPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metadata = await sharp(assetPath).metadata();
|
||||
if (
|
||||
typeof metadata.width === 'number' &&
|
||||
typeof metadata.height === 'number' &&
|
||||
metadata.width === metadata.height
|
||||
) {
|
||||
return {
|
||||
imageSrc: toText(imageSrc),
|
||||
updated: false,
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
};
|
||||
}
|
||||
|
||||
const squaredBuffer = await sharp(assetPath)
|
||||
.resize(1024, 1024, {
|
||||
fit: 'cover',
|
||||
position: 'attention',
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
fs.writeFileSync(assetPath, squaredBuffer);
|
||||
|
||||
return {
|
||||
imageSrc: toText(imageSrc),
|
||||
updated: true,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const userId = 'user_02b1dea4e951b13fe53db236560bdf28';
|
||||
const sessionId = 'custom-world-agent-session-019e192a4060d18b92df127f1dafe8ae';
|
||||
const profileId = 'custom-world-mo744tca-深海奇境';
|
||||
const config = loadConfig({
|
||||
projectRoot: path.resolve(process.cwd(), '..'),
|
||||
});
|
||||
const db = await createDatabase(config);
|
||||
|
||||
try {
|
||||
const runtimeRepository = new RuntimeRepository(db);
|
||||
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
||||
const session = await sessionStore.getSnapshot(userId, sessionId);
|
||||
if (!session || !isRecord(session.draftProfile)) {
|
||||
throw new Error('未找到目标世界草稿 session,无法同步历史保存档案。');
|
||||
}
|
||||
|
||||
const savedProfileEntry = (
|
||||
await runtimeRepository.listCustomWorldProfiles(userId)
|
||||
).find((entry) => entry.profileId === profileId);
|
||||
if (!savedProfileEntry) {
|
||||
throw new Error('未找到目标 saved profile,无法同步历史保存档案。');
|
||||
}
|
||||
|
||||
const draftProfile = session.draftProfile;
|
||||
const nextProfile = JSON.parse(
|
||||
JSON.stringify(savedProfileEntry.profile),
|
||||
) as RecordValue;
|
||||
|
||||
const draftPlayableById = new Map(
|
||||
toRecordArray(draftProfile.playableNpcs).map((entry) => [toText(entry.id), entry] as const),
|
||||
);
|
||||
const draftStoryById = new Map(
|
||||
toRecordArray(draftProfile.storyNpcs).map((entry) => [toText(entry.id), entry] as const),
|
||||
);
|
||||
const draftLandmarkById = new Map(
|
||||
toRecordArray(draftProfile.landmarks).map((entry) => [toText(entry.id), entry] as const),
|
||||
);
|
||||
const draftSceneChapterBySceneId = new Map(
|
||||
toRecordArray(draftProfile.sceneChapters).map((entry) => [toText(entry.sceneId), entry] as const),
|
||||
);
|
||||
|
||||
const playableNpcs = toRecordArray(nextProfile.playableNpcs).map((role) => {
|
||||
const draftRole = draftPlayableById.get(toText(role.id));
|
||||
if (!draftRole) {
|
||||
return role;
|
||||
}
|
||||
|
||||
return {
|
||||
...role,
|
||||
imageSrc: toText(draftRole.imageSrc) || role.imageSrc,
|
||||
generatedVisualAssetId:
|
||||
toText(draftRole.generatedVisualAssetId) || role.generatedVisualAssetId,
|
||||
};
|
||||
});
|
||||
|
||||
const storyNpcs = toRecordArray(nextProfile.storyNpcs).map((role) => {
|
||||
const draftRole = draftStoryById.get(toText(role.id));
|
||||
if (!draftRole) {
|
||||
return role;
|
||||
}
|
||||
|
||||
return {
|
||||
...role,
|
||||
imageSrc: toText(draftRole.imageSrc) || role.imageSrc,
|
||||
generatedVisualAssetId:
|
||||
toText(draftRole.generatedVisualAssetId) || role.generatedVisualAssetId,
|
||||
};
|
||||
});
|
||||
|
||||
const landmarks = toRecordArray(nextProfile.landmarks).map((landmark) => {
|
||||
const draftLandmark = draftLandmarkById.get(toText(landmark.id));
|
||||
const draftSceneChapter = draftSceneChapterBySceneId.get(toText(landmark.id));
|
||||
const firstActImageSrc =
|
||||
toRecordArray(draftSceneChapter?.acts)
|
||||
.map((act) => toText(act.backgroundImageSrc))
|
||||
.find(Boolean) || '';
|
||||
|
||||
return {
|
||||
...landmark,
|
||||
imageSrc:
|
||||
toText(draftLandmark?.imageSrc) ||
|
||||
firstActImageSrc ||
|
||||
toText(landmark.imageSrc) ||
|
||||
undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const sceneChapterBlueprints = toRecordArray(
|
||||
nextProfile.sceneChapterBlueprints,
|
||||
).map((chapter) => {
|
||||
const draftChapter = draftSceneChapterBySceneId.get(toText(chapter.sceneId));
|
||||
if (!draftChapter) {
|
||||
return chapter;
|
||||
}
|
||||
|
||||
const draftActById = new Map(
|
||||
toRecordArray(draftChapter.acts).map((act) => [toText(act.id), act] as const),
|
||||
);
|
||||
|
||||
return {
|
||||
...chapter,
|
||||
acts: toRecordArray(chapter.acts).map((act) => {
|
||||
const draftAct = draftActById.get(toText(act.id));
|
||||
if (!draftAct) {
|
||||
return act;
|
||||
}
|
||||
|
||||
return {
|
||||
...act,
|
||||
backgroundImageSrc:
|
||||
toText(draftAct.backgroundImageSrc) || act.backgroundImageSrc,
|
||||
backgroundAssetId:
|
||||
toText(draftAct.backgroundAssetId) || act.backgroundAssetId,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const roleImageUpdates = await Promise.all(
|
||||
[...playableNpcs, ...storyNpcs].map((role) =>
|
||||
ensureSquareRoleImage(config.publicDir, role.imageSrc),
|
||||
),
|
||||
);
|
||||
|
||||
nextProfile.playableNpcs = playableNpcs;
|
||||
nextProfile.storyNpcs = storyNpcs;
|
||||
nextProfile.landmarks = landmarks;
|
||||
nextProfile.sceneChapterBlueprints = sceneChapterBlueprints;
|
||||
|
||||
const updatedEntry = await runtimeRepository.upsertCustomWorldProfile(
|
||||
userId,
|
||||
profileId,
|
||||
nextProfile,
|
||||
savedProfileEntry.authorDisplayName || '玩家',
|
||||
);
|
||||
|
||||
const summary = {
|
||||
profileId,
|
||||
syncedPlayableCount: playableNpcs.length,
|
||||
syncedStoryCount: storyNpcs.length,
|
||||
syncedLandmarkCount: landmarks.length,
|
||||
syncedSceneChapterCount: sceneChapterBlueprints.length,
|
||||
squareRoleImagesUpdated: roleImageUpdates.filter((entry) => entry?.updated).length,
|
||||
coverImageSrc: updatedEntry.entry.coverImageSrc,
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user