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