This commit is contained in:
49
src/services/rpg-creation/index.ts
Normal file
49
src/services/rpg-creation/index.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export {
|
||||
createRpgCreationSession,
|
||||
executeRpgCreationAction,
|
||||
getRpgCreationCardDetail,
|
||||
getRpgCreationOperation,
|
||||
getRpgCreationSession,
|
||||
rpgCreationAgentClient,
|
||||
sendRpgCreationMessage,
|
||||
streamRpgCreationMessage,
|
||||
} from './rpgCreationAgentClient';
|
||||
export { rpgCreationAssetClient } from './rpgCreationAssetClient';
|
||||
export {
|
||||
generateRpgWorldCoverImage,
|
||||
generateRpgWorldLandmark,
|
||||
generateRpgWorldPlayableNpc,
|
||||
generateRpgWorldSceneImage,
|
||||
generateRpgWorldSceneNpc,
|
||||
generateRpgWorldStoryNpc,
|
||||
uploadRpgWorldCoverImage,
|
||||
} from './rpgCreationAssetClient';
|
||||
export type {
|
||||
CustomWorldGenerationProgress,
|
||||
GenerateCustomWorldProfileInput,
|
||||
GenerateCustomWorldProfileOptions,
|
||||
} from './rpgCreationGenerationClient';
|
||||
export {
|
||||
generateCustomWorldProfile as generateLegacyCustomWorldProfile,
|
||||
generateRpgWorldProfile,
|
||||
} from './rpgCreationGenerationClient';
|
||||
export {
|
||||
deleteRpgWorldProfile,
|
||||
getRpgWorldGalleryDetail,
|
||||
listRpgWorldGallery,
|
||||
listRpgWorldLibrary,
|
||||
publishRpgWorldProfile,
|
||||
rpgCreationLibraryClient,
|
||||
unpublishRpgWorldProfile,
|
||||
upsertRpgWorldProfile,
|
||||
} from './rpgCreationLibraryClient';
|
||||
export {
|
||||
buildRpgCreationPreviewFromResultPreview,
|
||||
buildRpgCreationPreviewFromSession,
|
||||
rpgCreationPreviewAdapter,
|
||||
} from './rpgCreationPreviewAdapter';
|
||||
export {
|
||||
deleteRpgCreationAgentSession,
|
||||
listRpgCreationWorks,
|
||||
rpgCreationWorkClient,
|
||||
} from './rpgCreationWorkClient';
|
||||
141
src/services/rpg-creation/rpgCreationAgentClient.ts
Normal file
141
src/services/rpg-creation/rpgCreationAgentClient.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type {
|
||||
CreateRpgAgentSessionRequest,
|
||||
CreateRpgAgentSessionResponse,
|
||||
GetRpgAgentCardDetailResponse,
|
||||
RpgAgentDraftCardDetail,
|
||||
RpgAgentOperationRecord,
|
||||
RpgAgentSessionSnapshot,
|
||||
SendRpgAgentMessageRequest,
|
||||
} from '../../../packages/shared/src';
|
||||
import type { RpgAgentActionRequest } from '../../../packages/shared/src';
|
||||
import type { TextStreamOptions } from '../aiTypes';
|
||||
import { readCreationAgentSessionFromSse } from '../creation-agent';
|
||||
import {
|
||||
openRpgCreationSsePost,
|
||||
requestRpgCreationPostJson,
|
||||
} from './rpgCreationRequestHelpers';
|
||||
import { requestRpgCreationRuntimeJson } from './rpgCreationRuntimeClient';
|
||||
|
||||
const RPG_AGENT_API_BASE = '/custom-world/agent/sessions';
|
||||
const CREATION_SESSION_START_TIMEOUT_MS = 15000;
|
||||
|
||||
export async function createRpgCreationSession(
|
||||
payload: CreateRpgAgentSessionRequest,
|
||||
) {
|
||||
return requestRpgCreationRuntimeJson<CreateRpgAgentSessionResponse>(
|
||||
RPG_AGENT_API_BASE,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'创建世界共创会话失败',
|
||||
{
|
||||
timeoutMs: CREATION_SESSION_START_TIMEOUT_MS,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function getRpgCreationSession(sessionId: string) {
|
||||
return requestRpgCreationRuntimeJson<RpgAgentSessionSnapshot>(
|
||||
`${RPG_AGENT_API_BASE}/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取世界共创会话失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendRpgCreationMessage(
|
||||
sessionId: string,
|
||||
payload: SendRpgAgentMessageRequest,
|
||||
) {
|
||||
return requestRpgCreationPostJson<{ operation: RpgAgentOperationRecord }>(
|
||||
`/api/runtime${RPG_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/messages`,
|
||||
payload,
|
||||
'发送共创消息失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function streamRpgCreationMessage(
|
||||
sessionId: string,
|
||||
payload: SendRpgAgentMessageRequest,
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
const response = await openRpgCreationSsePost(
|
||||
`/api/runtime${RPG_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/messages/stream`,
|
||||
payload,
|
||||
'发送共创消息失败',
|
||||
options.signal,
|
||||
);
|
||||
|
||||
return readCreationAgentSessionFromSse<RpgAgentSessionSnapshot>(response, {
|
||||
...options,
|
||||
fallbackMessage: '发送共创消息失败',
|
||||
incompleteMessage: '共创消息流式结果不完整',
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeRpgCreationAction(
|
||||
sessionId: string,
|
||||
payload: RpgAgentActionRequest,
|
||||
) {
|
||||
return requestRpgCreationRuntimeJson<{ operation: RpgAgentOperationRecord }>(
|
||||
`${RPG_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/actions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'执行共创操作失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function getRpgCreationOperation(
|
||||
sessionId: string,
|
||||
operationId: string,
|
||||
): Promise<RpgAgentOperationRecord> {
|
||||
const response = await requestRpgCreationRuntimeJson<
|
||||
{
|
||||
operation?: RpgAgentOperationRecord;
|
||||
data?: RpgAgentOperationRecord;
|
||||
} & Partial<RpgAgentOperationRecord>
|
||||
>(
|
||||
`${RPG_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/operations/${encodeURIComponent(operationId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取共创操作状态失败',
|
||||
);
|
||||
|
||||
return (response.operation ?? response.data ?? response) as RpgAgentOperationRecord;
|
||||
}
|
||||
|
||||
export async function getRpgCreationCardDetail(
|
||||
sessionId: string,
|
||||
cardId: string,
|
||||
) {
|
||||
const response = await requestRpgCreationRuntimeJson<GetRpgAgentCardDetailResponse>(
|
||||
`${RPG_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/cards/${encodeURIComponent(cardId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取草稿卡详情失败',
|
||||
);
|
||||
|
||||
return response.card as RpgAgentDraftCardDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作包 D 开始让 RPG 创作 Agent client 持有真实请求实现,
|
||||
* 旧 `aiService.ts` 仅保留兼容导出,避免主链请求继续回流到通用服务文件。
|
||||
*/
|
||||
export const rpgCreationAgentClient = {
|
||||
createSession: createRpgCreationSession,
|
||||
getSession: getRpgCreationSession,
|
||||
sendMessage: sendRpgCreationMessage,
|
||||
streamMessage: streamRpgCreationMessage,
|
||||
executeAction: executeRpgCreationAction,
|
||||
getOperation: getRpgCreationOperation,
|
||||
getCardDetail: getRpgCreationCardDetail,
|
||||
};
|
||||
116
src/services/rpg-creation/rpgCreationAssetClient.ts
Normal file
116
src/services/rpg-creation/rpgCreationAssetClient.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { CustomWorldSceneImageRequest, CustomWorldSceneImageResult } from '../aiTypes';
|
||||
import {
|
||||
generateCustomWorldCoverImage,
|
||||
uploadCustomWorldCoverImage,
|
||||
} from '../customWorldCoverAssetService';
|
||||
import { requestJson } from '../apiClient';
|
||||
import type {
|
||||
CustomWorldLandmark,
|
||||
CustomWorldNpc,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
} from '../../types';
|
||||
import { requestRpgCreationPostJson } from './rpgCreationRequestHelpers';
|
||||
|
||||
const RPG_CREATION_ASSET_API_BASE = '/api/custom-world';
|
||||
|
||||
export async function generateRpgWorldSceneImage(
|
||||
payload: CustomWorldSceneImageRequest,
|
||||
) {
|
||||
return requestJson<CustomWorldSceneImageResult>(
|
||||
`${RPG_CREATION_ASSET_API_BASE}/scene-image`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'生成自定义世界场景图失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateRpgWorldSceneNpc(payload: {
|
||||
profile: CustomWorldProfile;
|
||||
landmarkId: string;
|
||||
}) {
|
||||
const response = await requestRpgCreationPostJson<{ npc: CustomWorldNpc }>(
|
||||
`${RPG_CREATION_ASSET_API_BASE}/scene-npc`,
|
||||
payload,
|
||||
'生成场景 NPC 失败',
|
||||
);
|
||||
|
||||
return response.npc;
|
||||
}
|
||||
|
||||
async function requestRpgWorldEntity<T>(
|
||||
payload: {
|
||||
profile: CustomWorldProfile;
|
||||
kind: 'playable' | 'story' | 'landmark';
|
||||
},
|
||||
fallbackMessage: string,
|
||||
) {
|
||||
return requestRpgCreationPostJson<{
|
||||
kind: 'playable' | 'story' | 'landmark';
|
||||
entity: T;
|
||||
}>(`${RPG_CREATION_ASSET_API_BASE}/entity`, payload, fallbackMessage);
|
||||
}
|
||||
|
||||
export async function generateRpgWorldPlayableNpc(payload: {
|
||||
profile: CustomWorldProfile;
|
||||
}) {
|
||||
const response = await requestRpgWorldEntity<CustomWorldPlayableNpc>(
|
||||
{
|
||||
...payload,
|
||||
kind: 'playable',
|
||||
},
|
||||
'生成可扮演角色失败',
|
||||
);
|
||||
|
||||
return response.entity;
|
||||
}
|
||||
|
||||
export async function generateRpgWorldStoryNpc(payload: {
|
||||
profile: CustomWorldProfile;
|
||||
}) {
|
||||
const response = await requestRpgWorldEntity<CustomWorldNpc>(
|
||||
{
|
||||
...payload,
|
||||
kind: 'story',
|
||||
},
|
||||
'生成场景角色失败',
|
||||
);
|
||||
|
||||
return response.entity;
|
||||
}
|
||||
|
||||
export async function generateRpgWorldLandmark(payload: {
|
||||
profile: CustomWorldProfile;
|
||||
}) {
|
||||
const response = await requestRpgWorldEntity<CustomWorldLandmark>(
|
||||
{
|
||||
...payload,
|
||||
kind: 'landmark',
|
||||
},
|
||||
'生成场景失败',
|
||||
);
|
||||
|
||||
return response.entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作包 D 把结果页与编辑器依赖的资产请求迁入 RPG 创作域 client,
|
||||
* 保留封面资产服务的既有边界,不把逻辑重新塞回 `aiService.ts`。
|
||||
*/
|
||||
export const rpgCreationAssetClient = {
|
||||
generateSceneImage: generateRpgWorldSceneImage,
|
||||
generateSceneNpc: generateRpgWorldSceneNpc,
|
||||
generatePlayableNpc: generateRpgWorldPlayableNpc,
|
||||
generateStoryNpc: generateRpgWorldStoryNpc,
|
||||
generateLandmark: generateRpgWorldLandmark,
|
||||
generateCoverImage: generateCustomWorldCoverImage,
|
||||
uploadCoverImage: uploadCustomWorldCoverImage,
|
||||
};
|
||||
|
||||
export {
|
||||
generateCustomWorldCoverImage as generateRpgWorldCoverImage,
|
||||
uploadCustomWorldCoverImage as uploadRpgWorldCoverImage,
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { requestJsonMock } = vi.hoisted(() => ({
|
||||
requestJsonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
import { generateRpgWorldProfile } from './rpgCreationGenerationClient';
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
describe('rpgCreationGenerationClient', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
requestJsonMock.mockResolvedValue({
|
||||
id: 'custom-world-1',
|
||||
name: '测试世界',
|
||||
subtitle: '副标题',
|
||||
summary: '概述',
|
||||
tone: '基调',
|
||||
playerGoal: '目标',
|
||||
settingText: '设定',
|
||||
});
|
||||
});
|
||||
|
||||
it('posts world generation to the runtime custom world profile route', async () => {
|
||||
await generateRpgWorldProfile('一个被灵潮反复改写地形的边境世界');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world/profile',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
'生成自定义世界失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects immediately when the caller aborts before sending', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error('手动中断生成'));
|
||||
|
||||
await expect(
|
||||
generateRpgWorldProfile('一个会被中断的世界', {
|
||||
signal: controller.signal,
|
||||
}),
|
||||
).rejects.toThrow('手动中断生成');
|
||||
|
||||
expect(requestJsonMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
68
src/services/rpg-creation/rpgCreationGenerationClient.ts
Normal file
68
src/services/rpg-creation/rpgCreationGenerationClient.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type {
|
||||
CustomWorldGenerationProgress,
|
||||
GenerateCustomWorldProfileInput,
|
||||
GenerateCustomWorldProfileOptions,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { requestJson } from '../apiClient';
|
||||
|
||||
type LegacyAiModule = typeof import('../ai');
|
||||
|
||||
let legacyAiModulePromise: Promise<LegacyAiModule> | null = null;
|
||||
|
||||
async function loadLegacyAiModule() {
|
||||
if (!legacyAiModulePromise) {
|
||||
legacyAiModulePromise = import('../ai');
|
||||
}
|
||||
|
||||
return legacyAiModulePromise;
|
||||
}
|
||||
|
||||
export async function generateRpgWorldProfile(
|
||||
input: GenerateCustomWorldProfileInput | string,
|
||||
options: GenerateCustomWorldProfileOptions = {},
|
||||
): Promise<CustomWorldProfile> {
|
||||
const normalizedInput =
|
||||
typeof input === 'string'
|
||||
? {
|
||||
settingText: input,
|
||||
}
|
||||
: input;
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.generateCustomWorldProfile(normalizedInput, options);
|
||||
}
|
||||
|
||||
if (options.signal?.aborted) {
|
||||
throw options.signal.reason instanceof Error
|
||||
? options.signal.reason
|
||||
: new Error('世界生成已中断。');
|
||||
}
|
||||
|
||||
const profile = await requestJson<CustomWorldProfile>(
|
||||
'/api/runtime/custom-world/profile',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(normalizedInput),
|
||||
},
|
||||
'生成自定义世界失败',
|
||||
);
|
||||
|
||||
if (options.signal?.aborted) {
|
||||
throw options.signal.reason instanceof Error
|
||||
? options.signal.reason
|
||||
: new Error('世界生成已中断。');
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
export type {
|
||||
CustomWorldGenerationProgress,
|
||||
GenerateCustomWorldProfileInput,
|
||||
GenerateCustomWorldProfileOptions,
|
||||
};
|
||||
|
||||
export { generateRpgWorldProfile as generateCustomWorldProfile };
|
||||
154
src/services/rpg-creation/rpgCreationLibraryClient.ts
Normal file
154
src/services/rpg-creation/rpgCreationLibraryClient.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import type {
|
||||
CustomWorldGalleryDetailResponse,
|
||||
CustomWorldGalleryResponse,
|
||||
CustomWorldLibraryMutationResponse,
|
||||
CustomWorldLibraryResponse,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import {
|
||||
requestPublicRpgCreationRuntimeJson,
|
||||
requestRpgCreationRuntimeJson,
|
||||
type RpgCreationRuntimeRequestOptions,
|
||||
} from './rpgCreationRuntimeClient';
|
||||
|
||||
export async function listRpgWorldLibrary(
|
||||
options: RpgCreationRuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgCreationRuntimeJson<
|
||||
CustomWorldLibraryResponse<CustomWorldProfile>
|
||||
>(
|
||||
'/custom-world-library',
|
||||
{ method: 'GET' },
|
||||
'读取自定义世界库失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function upsertRpgWorldProfile(
|
||||
profile: CustomWorldProfile,
|
||||
request: {
|
||||
sourceAgentSessionId?: string | null;
|
||||
} = {},
|
||||
options: RpgCreationRuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgCreationRuntimeJson<
|
||||
CustomWorldLibraryMutationResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profile.id)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
profile,
|
||||
sourceAgentSessionId: request.sourceAgentSessionId ?? null,
|
||||
}),
|
||||
},
|
||||
'保存自定义世界失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteRpgWorldProfile(
|
||||
profileId: string,
|
||||
options: RpgCreationRuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgCreationRuntimeJson<
|
||||
CustomWorldLibraryResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'DELETE' },
|
||||
'删除自定义世界失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function publishRpgWorldProfile(
|
||||
profileId: string,
|
||||
options: RpgCreationRuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgCreationRuntimeJson<
|
||||
CustomWorldLibraryMutationResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profileId)}/publish`,
|
||||
{ method: 'POST' },
|
||||
'发布自定义世界失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function unpublishRpgWorldProfile(
|
||||
profileId: string,
|
||||
options: RpgCreationRuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgCreationRuntimeJson<
|
||||
CustomWorldLibraryMutationResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profileId)}/unpublish`,
|
||||
{ method: 'POST' },
|
||||
'下架自定义世界失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function listRpgWorldGallery(
|
||||
options: RpgCreationRuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestPublicRpgCreationRuntimeJson<CustomWorldGalleryResponse>(
|
||||
'/custom-world-gallery',
|
||||
{ method: 'GET' },
|
||||
'读取作品广场失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function getRpgWorldGalleryDetail(
|
||||
ownerUserId: string,
|
||||
profileId: string,
|
||||
options: RpgCreationRuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestPublicRpgCreationRuntimeJson<
|
||||
CustomWorldGalleryDetailResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-gallery/${encodeURIComponent(ownerUserId)}/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'GET' },
|
||||
'读取作品详情失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return response.entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作包 D 把作品库与作品广场请求迁入 RPG 创作域 client,
|
||||
* 后续前端调用优先从这里进入,不再反向依赖通用存储聚合层。
|
||||
*/
|
||||
export const rpgCreationLibraryClient = {
|
||||
listLibrary: listRpgWorldLibrary,
|
||||
upsertProfile: upsertRpgWorldProfile,
|
||||
deleteProfile: deleteRpgWorldProfile,
|
||||
publishProfile: publishRpgWorldProfile,
|
||||
unpublishProfile: unpublishRpgWorldProfile,
|
||||
listGallery: listRpgWorldGallery,
|
||||
getGalleryDetail: getRpgWorldGalleryDetail,
|
||||
};
|
||||
231
src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts
Normal file
231
src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import {
|
||||
buildRpgCreationPreviewFromResultPreview,
|
||||
buildRpgCreationPreviewFromSession,
|
||||
} from './rpgCreationPreviewAdapter';
|
||||
|
||||
const sessionWithPreview: CustomWorldAgentSessionSnapshot = {
|
||||
sessionId: 'session-preview-1',
|
||||
currentTurn: 3,
|
||||
anchorContent: {
|
||||
worldPromise: null,
|
||||
playerFantasy: null,
|
||||
themeBoundary: null,
|
||||
playerEntryPoint: null,
|
||||
coreConflict: null,
|
||||
keyRelationships: null,
|
||||
hiddenLines: null,
|
||||
iconicElements: null,
|
||||
},
|
||||
progressPercent: 100,
|
||||
lastAssistantReply: '第一版世界底稿已经准备好了。',
|
||||
stage: 'object_refining',
|
||||
focusCardId: null,
|
||||
creatorIntent: null,
|
||||
creatorIntentReadiness: {
|
||||
isReady: true,
|
||||
completedKeys: [],
|
||||
missingKeys: [],
|
||||
},
|
||||
anchorPack: null,
|
||||
lockState: null,
|
||||
draftProfile: {
|
||||
id: 'draft-profile-1',
|
||||
settingText: '草稿 profile 直接进入游戏。',
|
||||
name: '只作为 fallback 的本地草稿名',
|
||||
subtitle: 'fallback',
|
||||
summary: 'fallback',
|
||||
tone: 'fallback',
|
||||
playerGoal: 'fallback',
|
||||
templateWorldType: 'WUXIA',
|
||||
majorFactions: [],
|
||||
coreConflicts: [],
|
||||
attributeSchema: {
|
||||
id: 'schema:draft:test',
|
||||
worldId: 'custom:草稿',
|
||||
schemaVersion: 1,
|
||||
schemaName: '草稿六维',
|
||||
generatedFrom: {
|
||||
worldType: 'CUSTOM',
|
||||
worldName: '只作为 fallback 的本地草稿名',
|
||||
settingSummary: '草稿 profile 直接进入游戏。',
|
||||
tone: 'fallback',
|
||||
conflictCore: '验证草稿直读链路',
|
||||
},
|
||||
slots: [
|
||||
{
|
||||
slotId: 'axis_a',
|
||||
name: '稿骨',
|
||||
definition: '草稿承压维度。',
|
||||
positiveSignals: ['承压'],
|
||||
negativeSignals: ['虚浮'],
|
||||
combatUseText: '顶住正面压力。',
|
||||
socialUseText: '稳住对话姿态。',
|
||||
explorationUseText: '维持探索状态。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_b',
|
||||
name: '稿步',
|
||||
definition: '草稿换位维度。',
|
||||
positiveSignals: ['灵动'],
|
||||
negativeSignals: ['迟滞'],
|
||||
combatUseText: '快速换位。',
|
||||
socialUseText: '顺势接话。',
|
||||
explorationUseText: '穿越复杂路径。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_c',
|
||||
name: '稿识',
|
||||
definition: '草稿洞察维度。',
|
||||
positiveSignals: ['洞察'],
|
||||
negativeSignals: ['误判'],
|
||||
combatUseText: '看破破绽。',
|
||||
socialUseText: '识别隐藏动机。',
|
||||
explorationUseText: '整理线索。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_d',
|
||||
name: '稿魄',
|
||||
definition: '草稿推进维度。',
|
||||
positiveSignals: ['果断'],
|
||||
negativeSignals: ['犹疑'],
|
||||
combatUseText: '推进突破口。',
|
||||
socialUseText: '关键时刻定调。',
|
||||
explorationUseText: '面对未知继续前探。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_e',
|
||||
name: '稿契',
|
||||
definition: '草稿关系维度。',
|
||||
positiveSignals: ['协同'],
|
||||
negativeSignals: ['疏离'],
|
||||
combatUseText: '形成协同收益。',
|
||||
socialUseText: '建立信任交换。',
|
||||
explorationUseText: '从关系打开线索。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_f',
|
||||
name: '稿澜',
|
||||
definition: '草稿续航维度。',
|
||||
positiveSignals: ['回稳'],
|
||||
negativeSignals: ['紊乱'],
|
||||
combatUseText: '久战不乱。',
|
||||
socialUseText: '情绪稳定。',
|
||||
explorationUseText: '长线保持行动力。',
|
||||
},
|
||||
],
|
||||
},
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'draft-playable-1',
|
||||
name: '草稿角色',
|
||||
title: '直读测试',
|
||||
role: '可扮演角色',
|
||||
description: '从 draftProfile 直接进入角色选择页。',
|
||||
backstory: '草稿角色的背景不经过 resultPreview 转换。',
|
||||
personality: '直接、清醒',
|
||||
motivation: '验证草稿直读链路',
|
||||
combatStyle: '以直读链路破局',
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: ['来自草稿'],
|
||||
tags: ['draft-profile'],
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
imageSrc: '/generated-characters/draft-playable-1/portrait.png',
|
||||
},
|
||||
],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
landmarks: [],
|
||||
},
|
||||
messages: [],
|
||||
draftCards: [
|
||||
{
|
||||
id: 'world-foundation',
|
||||
kind: 'world',
|
||||
title: '世界底稿',
|
||||
subtitle: '阶段三预览',
|
||||
summary: '测试服务端 result preview 优先级。',
|
||||
status: 'warning',
|
||||
linkedIds: [],
|
||||
warningCount: 0,
|
||||
},
|
||||
],
|
||||
pendingClarifications: [],
|
||||
suggestedActions: [],
|
||||
recommendedReplies: [],
|
||||
qualityFindings: [],
|
||||
assetCoverage: {
|
||||
roleAssets: [],
|
||||
sceneAssets: [],
|
||||
allRoleAssetsReady: false,
|
||||
allSceneAssetsReady: false,
|
||||
},
|
||||
resultPreview: {
|
||||
source: 'session_preview',
|
||||
preview: {
|
||||
id: 'preview-profile-1',
|
||||
settingText: '被海雾吞没的旧航路群岛',
|
||||
name: '服务端结果预览',
|
||||
subtitle: '优先于前端 fallback',
|
||||
summary: '结果页应该优先消费 session.resultPreview。',
|
||||
tone: '压抑、潮湿、悬疑',
|
||||
playerGoal: '查清沉船与禁航区异动的真相。',
|
||||
templateWorldType: 'WUXIA',
|
||||
majorFactions: ['守灯会', '航运公会'],
|
||||
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
landmarks: [],
|
||||
generationMode: 'full',
|
||||
generationStatus: 'complete',
|
||||
sessionId: 'session-preview-1',
|
||||
},
|
||||
generatedAt: '2026-04-21T10:00:00.000Z',
|
||||
qualityFindings: [],
|
||||
blockers: [],
|
||||
},
|
||||
updatedAt: '2026-04-21T10:00:00.000Z',
|
||||
};
|
||||
|
||||
test('buildRpgCreationPreviewFromResultPreview normalizes server preview envelope', () => {
|
||||
const profile = buildRpgCreationPreviewFromResultPreview(
|
||||
sessionWithPreview.resultPreview,
|
||||
);
|
||||
|
||||
expect(profile?.name).toBe('服务端结果预览');
|
||||
expect(profile?.subtitle).toBe('优先于前端 fallback');
|
||||
expect(profile?.id).toBe('preview-profile-1');
|
||||
expect(profile?.settingText).toBe('被海雾吞没的旧航路群岛');
|
||||
});
|
||||
|
||||
test('buildRpgCreationPreviewFromSession prefers server result preview', () => {
|
||||
const profile = buildRpgCreationPreviewFromSession(sessionWithPreview);
|
||||
|
||||
expect(profile?.name).toBe('服务端结果预览');
|
||||
expect(profile?.summary).toBe('结果页应该优先消费 session.resultPreview。');
|
||||
expect(profile?.id).toBe('preview-profile-1');
|
||||
});
|
||||
|
||||
test('buildRpgCreationPreviewFromSession does not require resultPreview', () => {
|
||||
const profile = buildRpgCreationPreviewFromSession({
|
||||
...sessionWithPreview,
|
||||
resultPreview: null,
|
||||
});
|
||||
|
||||
expect(profile?.name).toBe('只作为 fallback 的本地草稿名');
|
||||
expect(profile?.playableNpcs[0]?.imageSrc).toBe(
|
||||
'/generated-characters/draft-playable-1/portrait.png',
|
||||
);
|
||||
expect(profile?.attributeSchema.slots.map((slot) => slot.name)).toEqual([
|
||||
'稿骨',
|
||||
'稿步',
|
||||
'稿识',
|
||||
'稿魄',
|
||||
'稿契',
|
||||
'稿澜',
|
||||
]);
|
||||
});
|
||||
35
src/services/rpg-creation/rpgCreationPreviewAdapter.ts
Normal file
35
src/services/rpg-creation/rpgCreationPreviewAdapter.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export function buildCustomWorldProfileFromResultPreview(
|
||||
resultPreview:
|
||||
| CustomWorldAgentSessionSnapshot['resultPreview']
|
||||
| null
|
||||
| undefined,
|
||||
): CustomWorldProfile | null {
|
||||
return normalizeCustomWorldProfileRecord(resultPreview?.preview ?? null);
|
||||
}
|
||||
|
||||
export function buildCustomWorldProfileFromAgentSession(
|
||||
session: CustomWorldAgentSessionSnapshot | null | undefined,
|
||||
): CustomWorldProfile | null {
|
||||
return (
|
||||
buildCustomWorldProfileFromResultPreview(session?.resultPreview) ??
|
||||
normalizeCustomWorldProfileRecord(session?.draftProfile ?? null)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 这是工作包 A 提供的新命名兼容层。
|
||||
* 主入口保持命名稳定,优先消费服务端 resultPreview,缺失时回退到 draftProfile。
|
||||
*/
|
||||
export const rpgCreationPreviewAdapter = {
|
||||
buildPreviewFromSession: buildCustomWorldProfileFromAgentSession,
|
||||
buildPreviewFromResultPreview: buildCustomWorldProfileFromResultPreview,
|
||||
};
|
||||
|
||||
export {
|
||||
buildCustomWorldProfileFromAgentSession as buildRpgCreationPreviewFromSession,
|
||||
buildCustomWorldProfileFromResultPreview as buildRpgCreationPreviewFromResultPreview,
|
||||
};
|
||||
43
src/services/rpg-creation/rpgCreationRequestHelpers.ts
Normal file
43
src/services/rpg-creation/rpgCreationRequestHelpers.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { parseApiErrorMessage } from '../../../packages/shared/src/http';
|
||||
import { fetchWithApiAuth, requestJson } from '../apiClient';
|
||||
|
||||
export async function requestRpgCreationPostJson<T>(
|
||||
url: string,
|
||||
payload: unknown,
|
||||
fallbackMessage: string,
|
||||
) {
|
||||
return requestJson<T>(
|
||||
url,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
fallbackMessage,
|
||||
);
|
||||
}
|
||||
|
||||
export async function openRpgCreationSsePost(
|
||||
url: string,
|
||||
payload: unknown,
|
||||
fallbackMessage: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const response = await fetchWithApiAuth(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
throw new Error(parseApiErrorMessage(responseText, fallbackMessage));
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('streaming response body is unavailable');
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
62
src/services/rpg-creation/rpgCreationRuntimeClient.ts
Normal file
62
src/services/rpg-creation/rpgCreationRuntimeClient.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
const RUNTIME_API_BASE = '/api/runtime';
|
||||
const RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 180,
|
||||
maxDelayMs: 480,
|
||||
};
|
||||
const RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 240,
|
||||
maxDelayMs: 640,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
|
||||
export type RpgCreationRuntimeRequestOptions = {
|
||||
signal?: AbortSignal;
|
||||
retry?: ApiRetryOptions;
|
||||
skipAuth?: boolean;
|
||||
skipRefresh?: boolean;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export function requestRpgCreationRuntimeJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: RpgCreationRuntimeRequestOptions = {},
|
||||
) {
|
||||
const method = (init.method ?? 'GET').toUpperCase();
|
||||
const retry =
|
||||
options.retry ??
|
||||
(method === 'GET' ? RUNTIME_READ_RETRY : RUNTIME_WRITE_RETRY);
|
||||
|
||||
return requestJson<T>(
|
||||
`${RUNTIME_API_BASE}${path}`,
|
||||
{
|
||||
...init,
|
||||
signal: options.signal,
|
||||
},
|
||||
fallbackMessage,
|
||||
{
|
||||
retry,
|
||||
skipAuth: options.skipAuth,
|
||||
skipRefresh: options.skipRefresh,
|
||||
timeoutMs: options.timeoutMs,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function requestPublicRpgCreationRuntimeJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: RpgCreationRuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRpgCreationRuntimeJson<T>(path, init, fallbackMessage, {
|
||||
...options,
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
});
|
||||
}
|
||||
30
src/services/rpg-creation/rpgCreationWorkClient.ts
Normal file
30
src/services/rpg-creation/rpgCreationWorkClient.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { ListRpgCreationWorksResponse } from '../../../packages/shared/src';
|
||||
import { requestRpgCreationRuntimeJson } from './rpgCreationRuntimeClient';
|
||||
|
||||
export async function listRpgCreationWorks() {
|
||||
const response = await requestRpgCreationRuntimeJson<ListRpgCreationWorksResponse>(
|
||||
'/custom-world/works',
|
||||
{ method: 'GET' },
|
||||
'读取创作作品列表失败',
|
||||
);
|
||||
|
||||
return Array.isArray(response?.items) ? response.items : [];
|
||||
}
|
||||
|
||||
export async function deleteRpgCreationAgentSession(sessionId: string) {
|
||||
const response = await requestRpgCreationRuntimeJson<ListRpgCreationWorksResponse>(
|
||||
`/custom-world/agent/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{ method: 'DELETE' },
|
||||
'删除 RPG 草稿失败',
|
||||
);
|
||||
|
||||
return Array.isArray(response?.items) ? response.items : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作包 D 把作品列表请求正式迁入 RPG 创作域 client。
|
||||
*/
|
||||
export const rpgCreationWorkClient = {
|
||||
deleteAgentSession: deleteRpgCreationAgentSession,
|
||||
listWorks: listRpgCreationWorks,
|
||||
};
|
||||
Reference in New Issue
Block a user