339 lines
10 KiB
TypeScript
339 lines
10 KiB
TypeScript
import { Router } from 'express';
|
|
import { z } from 'zod';
|
|
|
|
import type { ListCustomWorldWorksResponse } from '../../../../packages/shared/src/contracts/customWorldAgent.js';
|
|
import type {
|
|
CustomWorldGalleryDetailResponse,
|
|
CustomWorldGalleryResponse,
|
|
CustomWorldLibraryMutationResponse,
|
|
CustomWorldLibraryResponse,
|
|
} from '../../../../packages/shared/src/contracts/runtime.js';
|
|
import type { AppContext } from '../../context.js';
|
|
import { badRequest, conflict, notFound } from '../../errors.js';
|
|
import { asyncHandler, jsonClone, sendApiResponse } from '../../http.js';
|
|
import { requireJwtAuth } from '../../middleware/auth.js';
|
|
import { routeMeta } from '../../middleware/routeMeta.js';
|
|
import { CustomWorldAgentPublishingService } from '../../services/customWorldAgentPublishingService.js';
|
|
|
|
const jsonObjectSchema = z.record(z.string(), z.unknown());
|
|
|
|
const customWorldProfileSchema = z.object({
|
|
profile: jsonObjectSchema,
|
|
});
|
|
|
|
export const RPG_WORLD_LIBRARY_ROUTE_BASE_PATH =
|
|
'/api/runtime/custom-world-library';
|
|
export const RPG_WORLD_GALLERY_ROUTE_BASE_PATH =
|
|
'/api/runtime/custom-world-gallery';
|
|
export const RPG_WORLD_WORKS_ROUTE_BASE_PATH =
|
|
'/api/runtime/custom-world/works';
|
|
const AGENT_DRAFT_PROFILE_ID_PREFIX = 'agent-draft-';
|
|
|
|
function readParam(param: string | string[] | undefined) {
|
|
return Array.isArray(param) ? param[0]?.trim() || '' : param?.trim() || '';
|
|
}
|
|
|
|
function toText(value: unknown) {
|
|
return typeof value === 'string' ? value.trim() : '';
|
|
}
|
|
|
|
function resolveAgentSessionIdFromProfileId(profileId: string) {
|
|
if (!profileId.startsWith(AGENT_DRAFT_PROFILE_ID_PREFIX)) {
|
|
return null;
|
|
}
|
|
|
|
const sessionId = profileId.slice(AGENT_DRAFT_PROFILE_ID_PREFIX.length).trim();
|
|
return sessionId || null;
|
|
}
|
|
|
|
function resolvePublishedWorldName(profile: unknown) {
|
|
const profileRecord =
|
|
profile && typeof profile === 'object' && !Array.isArray(profile)
|
|
? (profile as Record<string, unknown>)
|
|
: null;
|
|
|
|
return toText(profileRecord?.name) || '当前世界';
|
|
}
|
|
|
|
async function syncAgentSessionPublishedState(params: {
|
|
context: AppContext;
|
|
userId: string;
|
|
sessionId: string;
|
|
worldName: string;
|
|
qualityFindings: Array<{
|
|
id: string;
|
|
severity: 'info' | 'warning' | 'blocker';
|
|
code: string;
|
|
targetId?: string | null;
|
|
message: string;
|
|
}>;
|
|
}) {
|
|
const publishedQualityFindings = params.qualityFindings.filter(
|
|
(entry) => entry.severity !== 'blocker',
|
|
);
|
|
const publishedState = {
|
|
stage: 'published' as const,
|
|
qualityFindings: publishedQualityFindings,
|
|
};
|
|
|
|
await params.context.customWorldAgentSessions.replaceDerivedState(
|
|
params.userId,
|
|
params.sessionId,
|
|
publishedState,
|
|
);
|
|
await params.context.customWorldAgentSessions.appendCheckpoint(
|
|
params.userId,
|
|
params.sessionId,
|
|
{
|
|
label: `发布世界 ${params.worldName}`,
|
|
snapshot: publishedState,
|
|
},
|
|
);
|
|
await params.context.customWorldAgentSessions.appendMessage(
|
|
params.userId,
|
|
params.sessionId,
|
|
{
|
|
id: `message-${Date.now().toString(36)}-library-publish`,
|
|
role: 'assistant',
|
|
kind: 'action_result',
|
|
text:
|
|
publishedQualityFindings.length > 0
|
|
? `世界「${params.worldName}」已发布,并保留 ${publishedQualityFindings.length} 条 warning 供后续继续优化。`
|
|
: `世界「${params.worldName}」已正式发布,可以进入作品库与世界入口。`,
|
|
createdAt: new Date().toISOString(),
|
|
relatedOperationId: null,
|
|
},
|
|
);
|
|
}
|
|
|
|
async function resolveAuthDisplayName(context: AppContext, userId: string) {
|
|
const user = await context.userRepository.findById(userId);
|
|
if (!user) {
|
|
throw notFound('user not found');
|
|
}
|
|
|
|
return user.displayName?.trim() || '玩家';
|
|
}
|
|
|
|
export function createRpgWorldLibraryRoutes(context: AppContext) {
|
|
const router = Router();
|
|
const requireAuth = requireJwtAuth(context.config, context.userRepository);
|
|
const publishingService = new CustomWorldAgentPublishingService(
|
|
context.rpgWorldProfileRepository,
|
|
);
|
|
|
|
router.get(
|
|
'/runtime/custom-world-gallery',
|
|
routeMeta({ operation: 'runtime.customWorldGallery.list' }),
|
|
asyncHandler(async (_request, response) => {
|
|
sendApiResponse(response, {
|
|
entries:
|
|
await context.rpgWorldLibraryRepository.listPublishedCustomWorldGallery(),
|
|
} satisfies CustomWorldGalleryResponse);
|
|
}),
|
|
);
|
|
|
|
router.get(
|
|
'/runtime/custom-world-gallery/:ownerUserId/:profileId',
|
|
routeMeta({ operation: 'runtime.customWorldGallery.detail' }),
|
|
asyncHandler(async (request, response) => {
|
|
const ownerUserId = readParam(request.params.ownerUserId);
|
|
const profileId = readParam(request.params.profileId);
|
|
if (!ownerUserId || !profileId) {
|
|
throw badRequest('ownerUserId and profileId are required');
|
|
}
|
|
|
|
const entry =
|
|
await context.rpgWorldLibraryRepository.getPublishedCustomWorldGalleryDetail(
|
|
ownerUserId,
|
|
profileId,
|
|
);
|
|
if (!entry) {
|
|
throw notFound('public custom world not found');
|
|
}
|
|
|
|
sendApiResponse(response, {
|
|
entry,
|
|
} satisfies CustomWorldGalleryDetailResponse);
|
|
}),
|
|
);
|
|
|
|
router.get(
|
|
'/runtime/custom-world/works',
|
|
requireAuth,
|
|
routeMeta({ operation: 'runtime.customWorldWorks.list' }),
|
|
asyncHandler(async (request, response) => {
|
|
sendApiResponse<ListCustomWorldWorksResponse>(response, {
|
|
items: await context.rpgWorldWorkSummaryService.list(request.userId!),
|
|
});
|
|
}),
|
|
);
|
|
|
|
router.get(
|
|
'/runtime/custom-world-library',
|
|
requireAuth,
|
|
routeMeta({ operation: 'runtime.customWorldLibrary.list' }),
|
|
asyncHandler(async (request, response) => {
|
|
sendApiResponse(response, {
|
|
entries: await context.rpgWorldLibraryRepository.listCustomWorldProfiles(
|
|
request.userId!,
|
|
),
|
|
} satisfies CustomWorldLibraryResponse);
|
|
}),
|
|
);
|
|
|
|
router.put(
|
|
'/runtime/custom-world-library/:profileId',
|
|
requireAuth,
|
|
routeMeta({ operation: 'runtime.customWorldLibrary.upsert' }),
|
|
asyncHandler(async (request, response) => {
|
|
const profileId = readParam(request.params.profileId);
|
|
if (!profileId) {
|
|
throw badRequest('profileId is required');
|
|
}
|
|
|
|
const payload = customWorldProfileSchema.parse(request.body);
|
|
const authorDisplayName = await resolveAuthDisplayName(
|
|
context,
|
|
request.userId!,
|
|
);
|
|
sendApiResponse(
|
|
response,
|
|
await context.rpgWorldLibraryRepository.upsertCustomWorldProfile(
|
|
request.userId!,
|
|
profileId,
|
|
jsonClone(payload.profile),
|
|
authorDisplayName,
|
|
),
|
|
);
|
|
}),
|
|
);
|
|
|
|
router.delete(
|
|
'/runtime/custom-world-library/:profileId',
|
|
requireAuth,
|
|
routeMeta({ operation: 'runtime.customWorldLibrary.delete' }),
|
|
asyncHandler(async (request, response) => {
|
|
const profileId = readParam(request.params.profileId);
|
|
if (!profileId) {
|
|
throw badRequest('profileId is required');
|
|
}
|
|
|
|
sendApiResponse(response, {
|
|
entries: await context.rpgWorldLibraryRepository.deleteCustomWorldProfile(
|
|
request.userId!,
|
|
profileId,
|
|
),
|
|
} satisfies CustomWorldLibraryResponse);
|
|
}),
|
|
);
|
|
|
|
router.post(
|
|
'/runtime/custom-world-library/:profileId/publish',
|
|
requireAuth,
|
|
routeMeta({ operation: 'runtime.customWorldLibrary.publish' }),
|
|
asyncHandler(async (request, response) => {
|
|
const profileId = readParam(request.params.profileId);
|
|
if (!profileId) {
|
|
throw badRequest('profileId is required');
|
|
}
|
|
|
|
const authorDisplayName = await resolveAuthDisplayName(
|
|
context,
|
|
request.userId!,
|
|
);
|
|
const agentSessionId = resolveAgentSessionIdFromProfileId(profileId);
|
|
if (agentSessionId) {
|
|
const agentSession = await context.customWorldAgentSessions.get(
|
|
request.userId!,
|
|
agentSessionId,
|
|
);
|
|
|
|
if (agentSession) {
|
|
try {
|
|
publishingService.buildPublishReadiness({
|
|
sessionId: agentSessionId,
|
|
draftProfile: agentSession.draftProfile,
|
|
qualityFindings: agentSession.qualityFindings,
|
|
});
|
|
} catch (error) {
|
|
throw conflict(
|
|
error instanceof Error
|
|
? error.message
|
|
: '当前世界还没有通过发布校验。',
|
|
);
|
|
}
|
|
|
|
const publishResult = await publishingService.publishSessionDraft({
|
|
userId: request.userId!,
|
|
authorDisplayName,
|
|
sessionId: agentSessionId,
|
|
draftProfile:
|
|
(agentSession.draftProfile ?? {}) as Record<string, unknown>,
|
|
qualityFindings: agentSession.qualityFindings,
|
|
});
|
|
await syncAgentSessionPublishedState({
|
|
context,
|
|
userId: request.userId!,
|
|
sessionId: agentSessionId,
|
|
worldName: resolvePublishedWorldName(publishResult.publishedProfile),
|
|
qualityFindings: agentSession.qualityFindings,
|
|
});
|
|
sendApiResponse(
|
|
response,
|
|
publishResult.mutation satisfies CustomWorldLibraryMutationResponse,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const mutation = await context.rpgWorldLibraryRepository.publishCustomWorldProfile(
|
|
request.userId!,
|
|
profileId,
|
|
authorDisplayName,
|
|
);
|
|
if (!mutation) {
|
|
throw notFound('custom world not found');
|
|
}
|
|
|
|
sendApiResponse(
|
|
response,
|
|
mutation satisfies CustomWorldLibraryMutationResponse,
|
|
);
|
|
}),
|
|
);
|
|
|
|
router.post(
|
|
'/runtime/custom-world-library/:profileId/unpublish',
|
|
requireAuth,
|
|
routeMeta({ operation: 'runtime.customWorldLibrary.unpublish' }),
|
|
asyncHandler(async (request, response) => {
|
|
const profileId = readParam(request.params.profileId);
|
|
if (!profileId) {
|
|
throw badRequest('profileId is required');
|
|
}
|
|
|
|
const authorDisplayName = await resolveAuthDisplayName(
|
|
context,
|
|
request.userId!,
|
|
);
|
|
const mutation =
|
|
await context.rpgWorldLibraryRepository.unpublishCustomWorldProfile(
|
|
request.userId!,
|
|
profileId,
|
|
authorDisplayName,
|
|
);
|
|
if (!mutation) {
|
|
throw notFound('custom world not found');
|
|
}
|
|
|
|
sendApiResponse(
|
|
response,
|
|
mutation satisfies CustomWorldLibraryMutationResponse,
|
|
);
|
|
}),
|
|
);
|
|
|
|
return router;
|
|
}
|