init with react+axum+spacetimedb
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-26 18:06:23 +08:00
commit cbc27bad4a
20199 changed files with 883714 additions and 0 deletions

View 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';

View 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,
};

View 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,
};

View File

@@ -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();
});
});

View 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 };

View 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,
};

View 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([
'稿骨',
'稿步',
'稿识',
'稿魄',
'稿契',
'稿澜',
]);
});

View 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,
};

View 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;
}

View 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,
});
}

View 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,
};