创作数据流程收束
This commit is contained in:
@@ -8,7 +8,6 @@ import { errorHandler } from './middleware/errorHandler.js';
|
||||
import { requestIdMiddleware } from './middleware/requestId.js';
|
||||
import { responseEnvelopeMiddleware } from './middleware/responseEnvelope.js';
|
||||
import { createCharacterAssetRoutes } from './modules/assets/characterAssetRoutes.js';
|
||||
import { createQwenSpriteRoutes } from './modules/assets/qwenSpriteRoutes.js';
|
||||
import { createEditorRoutes } from './modules/editor/editorRoutes.js';
|
||||
import { createStoryActionRoutes } from './modules/story/storyActionRoutes.js';
|
||||
import { createAuthRoutes } from './routes/authRoutes.js';
|
||||
@@ -119,18 +118,6 @@ export function createApp(context: AppContext) {
|
||||
createCharacterAssetRoutes(context.config, context.llmClient),
|
||||
),
|
||||
);
|
||||
app.use(
|
||||
scopeToPrefixes(
|
||||
['/api/assets/qwen-sprite'],
|
||||
withRouteMeta({ routeVersion: '2026-04-08', operation: 'assets.qwen' }),
|
||||
),
|
||||
);
|
||||
app.use(
|
||||
scopeToPrefixes(
|
||||
['/api/assets/qwen-sprite'],
|
||||
createQwenSpriteRoutes(context.config),
|
||||
),
|
||||
);
|
||||
app.use(
|
||||
'/api/auth',
|
||||
withRouteMeta({ routeVersion: '2026-04-08' }),
|
||||
|
||||
@@ -12,7 +12,6 @@ import { UserSessionRepository } from './repositories/userSessionRepository.js';
|
||||
import { CaptchaChallengeStore } from './services/captchaChallengeStore.js';
|
||||
import { CustomWorldAgentOrchestrator } from './services/customWorldAgentOrchestrator.js';
|
||||
import { CustomWorldAgentSessionStore } from './services/customWorldAgentSessionStore.js';
|
||||
import { CustomWorldSessionStore } from './services/customWorldSessionStore.js';
|
||||
import { UpstreamLlmClient } from './services/llmClient.js';
|
||||
import type { SmsVerificationService } from './services/smsVerificationService.js';
|
||||
import type { WechatAuthService } from './services/wechatAuthService.js';
|
||||
@@ -30,7 +29,6 @@ export type AppContext = {
|
||||
userSessionRepository: UserSessionRepository;
|
||||
runtimeRepository: RuntimeRepository;
|
||||
llmClient: UpstreamLlmClient;
|
||||
customWorldSessions: CustomWorldSessionStore;
|
||||
customWorldAgentSessions: CustomWorldAgentSessionStore;
|
||||
customWorldAgentOrchestrator: CustomWorldAgentOrchestrator;
|
||||
smsVerificationService: SmsVerificationService;
|
||||
|
||||
@@ -1,907 +0,0 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import http, {
|
||||
type IncomingMessage,
|
||||
type RequestOptions,
|
||||
type ServerResponse,
|
||||
} from 'node:http';
|
||||
import https from 'node:https';
|
||||
import path from 'node:path';
|
||||
|
||||
import { Router, type NextFunction, type Request, type Response } from 'express';
|
||||
import type { AppConfig } from '../../config.js';
|
||||
|
||||
const QWEN_SPRITE_MASTER_GENERATE_PATH = '/api/assets/qwen-sprite/master';
|
||||
const QWEN_SPRITE_SHEET_GENERATE_PATH = '/api/assets/qwen-sprite/sheet';
|
||||
const QWEN_SPRITE_FRAME_REPAIR_PATH = '/api/assets/qwen-sprite/frame-repair';
|
||||
const QWEN_SPRITE_SAVE_PATH = '/api/assets/qwen-sprite/save';
|
||||
const DEFAULT_DASHSCOPE_BASE_URL = 'https://dashscope.aliyuncs.com/api/v1';
|
||||
const DEFAULT_QWEN_IMAGE_MODEL = 'qwen-image-2.0';
|
||||
|
||||
function readJsonBody(req: IncomingMessage & { body?: unknown }) {
|
||||
const parsedBody = req.body;
|
||||
if (parsedBody && typeof parsedBody === 'object' && !Array.isArray(parsedBody)) {
|
||||
return Promise.resolve(parsedBody as Record<string, unknown>);
|
||||
}
|
||||
|
||||
return new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const raw =
|
||||
Buffer.concat(chunks)
|
||||
.toString('utf8')
|
||||
.replace(/^\uFEFF/u, '') || '{}';
|
||||
resolve(JSON.parse(raw));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function sendJson(res: ServerResponse, statusCode: number, payload: unknown) {
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function isRecordValue(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.every((item) => typeof item === 'string' && item.trim().length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveRuntimeEnv(config: AppConfig) {
|
||||
return config.rawEnv;
|
||||
}
|
||||
|
||||
function normalizeDashScopeBaseUrl(value: string) {
|
||||
return value.replace(/\/$/u, '');
|
||||
}
|
||||
|
||||
function extractApiErrorMessage(responseText: string, fallbackMessage: string) {
|
||||
if (!responseText.trim()) {
|
||||
return fallbackMessage;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(responseText) as {
|
||||
code?: string;
|
||||
message?: string;
|
||||
error?: { message?: string };
|
||||
};
|
||||
if (
|
||||
typeof parsed.error?.message === 'string' &&
|
||||
parsed.error.message.trim()
|
||||
) {
|
||||
return parsed.error.message;
|
||||
}
|
||||
if (typeof parsed.message === 'string' && parsed.message.trim()) {
|
||||
return parsed.message;
|
||||
}
|
||||
if (typeof parsed.code === 'string' && parsed.code.trim()) {
|
||||
return `${fallbackMessage} (${parsed.code})`;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to raw text.
|
||||
}
|
||||
|
||||
return responseText;
|
||||
}
|
||||
|
||||
function sanitizePathSegment(value: string) {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-_]+/gu, '-')
|
||||
.replace(/-+/gu, '-')
|
||||
.replace(/^-|-$/gu, '');
|
||||
|
||||
return normalized || 'asset';
|
||||
}
|
||||
|
||||
function createTimestampId(prefix: string) {
|
||||
return `${prefix}-${Date.now()}`;
|
||||
}
|
||||
|
||||
function requestTextResponse(
|
||||
urlString: string,
|
||||
options: {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
bodyText?: string;
|
||||
} = {},
|
||||
) {
|
||||
return new Promise<{
|
||||
statusCode: number;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
bodyText: string;
|
||||
}>((resolve, reject) => {
|
||||
const url = new URL(urlString);
|
||||
const transport = url.protocol === 'https:' ? https : http;
|
||||
const payload = options.bodyText;
|
||||
const requestOptions: RequestOptions = {
|
||||
protocol: url.protocol,
|
||||
hostname: url.hostname,
|
||||
port: url.port ? Number(url.port) : undefined,
|
||||
path: `${url.pathname}${url.search}`,
|
||||
method: options.method ?? 'GET',
|
||||
headers: {
|
||||
...(options.headers ?? {}),
|
||||
...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
const request = transport.request(requestOptions, (upstreamRes) => {
|
||||
const chunks: Buffer[] = [];
|
||||
upstreamRes.on('data', (chunk) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
upstreamRes.on('end', () => {
|
||||
resolve({
|
||||
statusCode: upstreamRes.statusCode ?? 502,
|
||||
headers: upstreamRes.headers,
|
||||
bodyText: Buffer.concat(chunks).toString('utf8'),
|
||||
});
|
||||
});
|
||||
upstreamRes.on('error', reject);
|
||||
});
|
||||
|
||||
request.on('error', reject);
|
||||
if (payload) {
|
||||
request.write(payload);
|
||||
}
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
function requestBinaryResponse(
|
||||
urlString: string,
|
||||
options: {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
} = {},
|
||||
) {
|
||||
return new Promise<{
|
||||
statusCode: number;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
body: Buffer;
|
||||
}>((resolve, reject) => {
|
||||
const url = new URL(urlString);
|
||||
const transport = url.protocol === 'https:' ? https : http;
|
||||
const requestOptions: RequestOptions = {
|
||||
protocol: url.protocol,
|
||||
hostname: url.hostname,
|
||||
port: url.port ? Number(url.port) : undefined,
|
||||
path: `${url.pathname}${url.search}`,
|
||||
method: options.method ?? 'GET',
|
||||
headers: options.headers ?? {},
|
||||
};
|
||||
|
||||
const request = transport.request(requestOptions, (upstreamRes) => {
|
||||
const chunks: Buffer[] = [];
|
||||
upstreamRes.on('data', (chunk) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
upstreamRes.on('end', () => {
|
||||
resolve({
|
||||
statusCode: upstreamRes.statusCode ?? 502,
|
||||
headers: upstreamRes.headers,
|
||||
body: Buffer.concat(chunks),
|
||||
});
|
||||
});
|
||||
upstreamRes.on('error', reject);
|
||||
});
|
||||
|
||||
request.on('error', reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
function proxyJsonRequest(
|
||||
urlString: string,
|
||||
apiKey: string,
|
||||
body: Record<string, unknown>,
|
||||
) {
|
||||
return requestTextResponse(urlString, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
bodyText: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function collectStringsByKey(
|
||||
value: unknown,
|
||||
targetKey: string,
|
||||
results: string[],
|
||||
) {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => collectStringsByKey(item, targetKey, results));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isRecordValue(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const directValue = value[targetKey];
|
||||
if (typeof directValue === 'string' && directValue.trim()) {
|
||||
results.push(directValue.trim());
|
||||
}
|
||||
|
||||
Object.values(value).forEach((nestedValue) =>
|
||||
collectStringsByKey(nestedValue, targetKey, results),
|
||||
);
|
||||
}
|
||||
|
||||
function extractImageUrls(payload: Record<string, unknown>) {
|
||||
const results: string[] = [];
|
||||
collectStringsByKey(payload.output, 'image', results);
|
||||
collectStringsByKey(payload.output, 'url', results);
|
||||
return [...new Set(results)];
|
||||
}
|
||||
|
||||
function parseDataUrl(source: string) {
|
||||
const matched = /^data:(image\/[^;]+);base64,(.+)$/u.exec(source);
|
||||
if (!matched) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mimeType = matched[1];
|
||||
const base64Payload = matched[2];
|
||||
const extension = (() => {
|
||||
switch (mimeType) {
|
||||
case 'image/jpeg':
|
||||
return 'jpg';
|
||||
case 'image/webp':
|
||||
return 'webp';
|
||||
default:
|
||||
return 'png';
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
buffer: Buffer.from(base64Payload, 'base64'),
|
||||
extension,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveImageSourcePayload(rootDir: string, source: string) {
|
||||
const parsedDataUrl = parseDataUrl(source);
|
||||
if (parsedDataUrl) {
|
||||
return parsedDataUrl;
|
||||
}
|
||||
|
||||
if (!source.startsWith('/')) {
|
||||
throw new Error('图像来源必须是 Data URL 或 public 目录 URL。');
|
||||
}
|
||||
|
||||
const normalizedSource = path.posix.normalize(source).replace(/^\/+/u, '');
|
||||
const absolutePath = path.resolve(
|
||||
rootDir,
|
||||
'public',
|
||||
...normalizedSource.split('/'),
|
||||
);
|
||||
const publicRoot = path.resolve(rootDir, 'public');
|
||||
|
||||
if (!absolutePath.startsWith(publicRoot)) {
|
||||
throw new Error('图像来源路径越界。');
|
||||
}
|
||||
|
||||
const buffer = await readFile(absolutePath);
|
||||
const extension = path.extname(absolutePath).replace(/^\./u, '') || 'png';
|
||||
|
||||
return {
|
||||
buffer,
|
||||
extension,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveImageSourceAsDataUrl(rootDir: string, source: string) {
|
||||
if (/^data:image\/[^;]+;base64,/u.test(source)) {
|
||||
return source;
|
||||
}
|
||||
|
||||
const payload = await resolveImageSourcePayload(rootDir, source);
|
||||
const mimeType = (() => {
|
||||
switch (payload.extension) {
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
return 'image/jpeg';
|
||||
case 'webp':
|
||||
return 'image/webp';
|
||||
default:
|
||||
return 'image/png';
|
||||
}
|
||||
})();
|
||||
|
||||
return `data:${mimeType};base64,${payload.buffer.toString('base64')}`;
|
||||
}
|
||||
|
||||
async function writeDraftImageFile(
|
||||
rootDir: string,
|
||||
relativePath: string,
|
||||
buffer: Buffer,
|
||||
) {
|
||||
const absolutePath = path.resolve(rootDir, 'public', ...relativePath.split('/'));
|
||||
await mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await writeFile(absolutePath, buffer);
|
||||
return `/${relativePath}`;
|
||||
}
|
||||
|
||||
async function generateQwenImages(
|
||||
config: AppConfig,
|
||||
input: {
|
||||
kind: 'master' | 'sheet' | 'repair';
|
||||
promptText: string;
|
||||
negativePrompt: string;
|
||||
model: string;
|
||||
size: string;
|
||||
promptExtend: boolean;
|
||||
seed?: number;
|
||||
candidateCount: number;
|
||||
referenceImages: string[];
|
||||
},
|
||||
) {
|
||||
const rootDir = config.projectRoot;
|
||||
const runtimeEnv = resolveRuntimeEnv(config);
|
||||
const baseUrl = normalizeDashScopeBaseUrl(
|
||||
runtimeEnv.DASHSCOPE_BASE_URL || DEFAULT_DASHSCOPE_BASE_URL,
|
||||
);
|
||||
const apiKey = runtimeEnv.DASHSCOPE_API_KEY || '';
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error('服务端缺少 DASHSCOPE_API_KEY,无法调用 Qwen-Image。');
|
||||
}
|
||||
|
||||
const content = [
|
||||
...(await Promise.all(
|
||||
input.referenceImages
|
||||
.slice(0, 3)
|
||||
.map(async (image) => ({ image: await resolveImageSourceAsDataUrl(rootDir, image) })),
|
||||
)),
|
||||
{ text: input.promptText },
|
||||
];
|
||||
|
||||
const requestPayload: Record<string, unknown> = {
|
||||
model: input.model || DEFAULT_QWEN_IMAGE_MODEL,
|
||||
input: {
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content,
|
||||
},
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
n: Math.max(1, Math.min(6, input.candidateCount)),
|
||||
negative_prompt: input.negativePrompt,
|
||||
prompt_extend: input.promptExtend,
|
||||
watermark: false,
|
||||
size: input.size,
|
||||
...(typeof input.seed === 'number' && Number.isFinite(input.seed)
|
||||
? { seed: input.seed }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
|
||||
const response = await proxyJsonRequest(
|
||||
`${baseUrl}/services/aigc/multimodal-generation/generation`,
|
||||
apiKey,
|
||||
requestPayload,
|
||||
);
|
||||
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw new Error(
|
||||
extractApiErrorMessage(response.bodyText, 'Qwen-Image 生成失败。'),
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(response.bodyText) as Record<string, unknown>;
|
||||
const imageUrls = extractImageUrls(parsed);
|
||||
|
||||
if (imageUrls.length === 0) {
|
||||
throw new Error('Qwen-Image 未返回可下载的图片结果。');
|
||||
}
|
||||
|
||||
const draftId = createTimestampId(`qwen-${input.kind}`);
|
||||
const relativeDir = path.posix.join(
|
||||
'generated-qwen-sprites',
|
||||
'_drafts',
|
||||
input.kind,
|
||||
draftId,
|
||||
);
|
||||
|
||||
const drafts = await Promise.all(
|
||||
imageUrls.map(async (imageUrl, index) => {
|
||||
const binaryResponse = await requestBinaryResponse(imageUrl);
|
||||
if (
|
||||
binaryResponse.statusCode < 200 ||
|
||||
binaryResponse.statusCode >= 300
|
||||
) {
|
||||
throw new Error(`下载生成图片失败(${binaryResponse.statusCode})。`);
|
||||
}
|
||||
|
||||
const imageSrc = await writeDraftImageFile(
|
||||
rootDir,
|
||||
path.posix.join(relativeDir, `candidate-${String(index + 1).padStart(2, '0')}.png`),
|
||||
binaryResponse.body,
|
||||
);
|
||||
|
||||
return {
|
||||
id: `${draftId}-${index + 1}`,
|
||||
label: `${input.kind === 'master' ? '主图' : input.kind === 'sheet' ? '精灵表' : '修帧'} ${index + 1}`,
|
||||
imageSrc,
|
||||
remoteUrl: imageUrl,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
await writeFile(
|
||||
path.resolve(rootDir, 'public', ...relativeDir.split('/'), 'job.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
draftId,
|
||||
kind: input.kind,
|
||||
model: input.model,
|
||||
size: input.size,
|
||||
promptText: input.promptText,
|
||||
negativePrompt: input.negativePrompt,
|
||||
promptExtend: input.promptExtend,
|
||||
seed: input.seed,
|
||||
candidateCount: input.candidateCount,
|
||||
referenceImageCount: input.referenceImages.length,
|
||||
drafts,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + '\n',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
return {
|
||||
draftId,
|
||||
drafts,
|
||||
model: input.model,
|
||||
size: input.size,
|
||||
promptText: input.promptText,
|
||||
negativePrompt: input.negativePrompt,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleGenerateMaster(
|
||||
config: AppConfig,
|
||||
req: IncomingMessage & { body?: unknown },
|
||||
res: ServerResponse,
|
||||
) {
|
||||
if (req.method !== 'POST') {
|
||||
sendJson(res, 405, { error: { message: 'Method Not Allowed' } });
|
||||
return;
|
||||
}
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await readJsonBody(req);
|
||||
} catch {
|
||||
sendJson(res, 400, { error: { message: 'Invalid JSON body' } });
|
||||
return;
|
||||
}
|
||||
|
||||
const promptText =
|
||||
typeof body.promptText === 'string' ? body.promptText.trim() : '';
|
||||
const negativePrompt =
|
||||
typeof body.negativePrompt === 'string' ? body.negativePrompt.trim() : '';
|
||||
const model =
|
||||
typeof body.model === 'string' && body.model.trim()
|
||||
? body.model.trim()
|
||||
: DEFAULT_QWEN_IMAGE_MODEL;
|
||||
const size =
|
||||
typeof body.size === 'string' && body.size.trim()
|
||||
? body.size.trim()
|
||||
: '1024*1024';
|
||||
const promptExtend = body.promptExtend !== false;
|
||||
const candidateCount =
|
||||
typeof body.candidateCount === 'number' && Number.isFinite(body.candidateCount)
|
||||
? body.candidateCount
|
||||
: 1;
|
||||
const seed =
|
||||
typeof body.seed === 'number' && Number.isFinite(body.seed)
|
||||
? body.seed
|
||||
: undefined;
|
||||
const referenceImages = isStringArray(body.referenceImages)
|
||||
? body.referenceImages
|
||||
: [];
|
||||
|
||||
if (!promptText) {
|
||||
sendJson(res, 400, { error: { message: 'promptText is required.' } });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await generateQwenImages(config, {
|
||||
kind: 'master',
|
||||
promptText,
|
||||
negativePrompt,
|
||||
model,
|
||||
size,
|
||||
promptExtend,
|
||||
seed,
|
||||
candidateCount,
|
||||
referenceImages,
|
||||
});
|
||||
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
...result,
|
||||
});
|
||||
} catch (error) {
|
||||
sendJson(res, 500, {
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : '生成主图失败。',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerateSheet(
|
||||
config: AppConfig,
|
||||
req: IncomingMessage & { body?: unknown },
|
||||
res: ServerResponse,
|
||||
) {
|
||||
if (req.method !== 'POST') {
|
||||
sendJson(res, 405, { error: { message: 'Method Not Allowed' } });
|
||||
return;
|
||||
}
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await readJsonBody(req);
|
||||
} catch {
|
||||
sendJson(res, 400, { error: { message: 'Invalid JSON body' } });
|
||||
return;
|
||||
}
|
||||
|
||||
const promptText =
|
||||
typeof body.promptText === 'string' ? body.promptText.trim() : '';
|
||||
const negativePrompt =
|
||||
typeof body.negativePrompt === 'string' ? body.negativePrompt.trim() : '';
|
||||
const model =
|
||||
typeof body.model === 'string' && body.model.trim()
|
||||
? body.model.trim()
|
||||
: DEFAULT_QWEN_IMAGE_MODEL;
|
||||
const size =
|
||||
typeof body.size === 'string' && body.size.trim()
|
||||
? body.size.trim()
|
||||
: '1024*1024';
|
||||
const promptExtend = body.promptExtend !== false;
|
||||
const candidateCount =
|
||||
typeof body.candidateCount === 'number' && Number.isFinite(body.candidateCount)
|
||||
? body.candidateCount
|
||||
: 1;
|
||||
const seed =
|
||||
typeof body.seed === 'number' && Number.isFinite(body.seed)
|
||||
? body.seed
|
||||
: undefined;
|
||||
const referenceImages = isStringArray(body.referenceImages)
|
||||
? body.referenceImages
|
||||
: [];
|
||||
|
||||
if (!promptText) {
|
||||
sendJson(res, 400, { error: { message: 'promptText is required.' } });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await generateQwenImages(config, {
|
||||
kind: 'sheet',
|
||||
promptText,
|
||||
negativePrompt,
|
||||
model,
|
||||
size,
|
||||
promptExtend,
|
||||
seed,
|
||||
candidateCount,
|
||||
referenceImages,
|
||||
});
|
||||
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
...result,
|
||||
});
|
||||
} catch (error) {
|
||||
sendJson(res, 500, {
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : '生成精灵表失败。',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRepairFrame(
|
||||
config: AppConfig,
|
||||
req: IncomingMessage & { body?: unknown },
|
||||
res: ServerResponse,
|
||||
) {
|
||||
if (req.method !== 'POST') {
|
||||
sendJson(res, 405, { error: { message: 'Method Not Allowed' } });
|
||||
return;
|
||||
}
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await readJsonBody(req);
|
||||
} catch {
|
||||
sendJson(res, 400, { error: { message: 'Invalid JSON body' } });
|
||||
return;
|
||||
}
|
||||
|
||||
const promptText =
|
||||
typeof body.promptText === 'string' ? body.promptText.trim() : '';
|
||||
const negativePrompt =
|
||||
typeof body.negativePrompt === 'string' ? body.negativePrompt.trim() : '';
|
||||
const model =
|
||||
typeof body.model === 'string' && body.model.trim()
|
||||
? body.model.trim()
|
||||
: DEFAULT_QWEN_IMAGE_MODEL;
|
||||
const size =
|
||||
typeof body.size === 'string' && body.size.trim()
|
||||
? body.size.trim()
|
||||
: '512*512';
|
||||
const promptExtend = body.promptExtend !== false;
|
||||
const seed =
|
||||
typeof body.seed === 'number' && Number.isFinite(body.seed)
|
||||
? body.seed
|
||||
: undefined;
|
||||
const referenceImages = isStringArray(body.referenceImages)
|
||||
? body.referenceImages
|
||||
: [];
|
||||
|
||||
if (!promptText) {
|
||||
sendJson(res, 400, { error: { message: 'promptText is required.' } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (referenceImages.length === 0) {
|
||||
sendJson(res, 400, {
|
||||
error: { message: '至少需要一张参考图来修复帧。' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await generateQwenImages(config, {
|
||||
kind: 'repair',
|
||||
promptText,
|
||||
negativePrompt,
|
||||
model,
|
||||
size,
|
||||
promptExtend,
|
||||
seed,
|
||||
candidateCount: 1,
|
||||
referenceImages,
|
||||
});
|
||||
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
...result,
|
||||
repairedFrame: result.drafts[0] ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
sendJson(res, 500, {
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : '修帧失败。',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveAsset(
|
||||
rootDir: string,
|
||||
req: IncomingMessage & { body?: unknown },
|
||||
res: ServerResponse,
|
||||
) {
|
||||
if (req.method !== 'POST') {
|
||||
sendJson(res, 405, { error: { message: 'Method Not Allowed' } });
|
||||
return;
|
||||
}
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await readJsonBody(req);
|
||||
} catch {
|
||||
sendJson(res, 400, { error: { message: 'Invalid JSON body' } });
|
||||
return;
|
||||
}
|
||||
|
||||
const assetKey =
|
||||
typeof body.assetKey === 'string' ? sanitizePathSegment(body.assetKey) : '';
|
||||
const actionKey =
|
||||
typeof body.actionKey === 'string' ? sanitizePathSegment(body.actionKey) : '';
|
||||
const masterSource =
|
||||
typeof body.masterSource === 'string' ? body.masterSource.trim() : '';
|
||||
const sheetSource =
|
||||
typeof body.sheetSource === 'string' ? body.sheetSource.trim() : '';
|
||||
const framesDataUrls = isStringArray(body.framesDataUrls)
|
||||
? body.framesDataUrls
|
||||
: [];
|
||||
const metadata = isRecordValue(body.metadata) ? body.metadata : {};
|
||||
const prompts = isRecordValue(body.prompts) ? body.prompts : {};
|
||||
|
||||
if (!assetKey) {
|
||||
sendJson(res, 400, { error: { message: 'assetKey is required.' } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!actionKey) {
|
||||
sendJson(res, 400, { error: { message: 'actionKey is required.' } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sheetSource) {
|
||||
sendJson(res, 400, { error: { message: 'sheetSource is required.' } });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const assetId = createTimestampId('qwen-sprite');
|
||||
const relativeDir = path.posix.join(
|
||||
'generated-qwen-sprites',
|
||||
assetKey,
|
||||
actionKey,
|
||||
assetId,
|
||||
);
|
||||
const absoluteDir = path.resolve(rootDir, 'public', ...relativeDir.split('/'));
|
||||
await mkdir(path.join(absoluteDir, 'frames'), { recursive: true });
|
||||
|
||||
let masterImagePath: string | null = null;
|
||||
if (masterSource) {
|
||||
const payload = await resolveImageSourcePayload(rootDir, masterSource);
|
||||
masterImagePath = await writeDraftImageFile(
|
||||
rootDir,
|
||||
path.posix.join(relativeDir, `master.${payload.extension}`),
|
||||
payload.buffer,
|
||||
);
|
||||
}
|
||||
|
||||
const sheetPayload = await resolveImageSourcePayload(rootDir, sheetSource);
|
||||
const sheetImagePath = await writeDraftImageFile(
|
||||
rootDir,
|
||||
path.posix.join(relativeDir, `sheet.${sheetPayload.extension}`),
|
||||
sheetPayload.buffer,
|
||||
);
|
||||
|
||||
const framePaths: string[] = [];
|
||||
for (let index = 0; index < framesDataUrls.length; index += 1) {
|
||||
const framePayload = await resolveImageSourcePayload(
|
||||
rootDir,
|
||||
framesDataUrls[index] ?? '',
|
||||
);
|
||||
const framePath = await writeDraftImageFile(
|
||||
rootDir,
|
||||
path.posix.join(
|
||||
relativeDir,
|
||||
'frames',
|
||||
`frame-${String(index + 1).padStart(2, '0')}.${framePayload.extension}`,
|
||||
),
|
||||
framePayload.buffer,
|
||||
);
|
||||
framePaths.push(framePath);
|
||||
}
|
||||
|
||||
await writeFile(
|
||||
path.join(absoluteDir, 'metadata.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
assetId,
|
||||
assetKey,
|
||||
actionKey,
|
||||
masterImagePath,
|
||||
sheetImagePath,
|
||||
framePaths,
|
||||
metadata,
|
||||
prompts,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + '\n',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
assetId,
|
||||
assetDir: `/${relativeDir}`,
|
||||
masterImagePath,
|
||||
sheetImagePath,
|
||||
framePaths,
|
||||
saveMessage: '已保存到 public/generated-qwen-sprites。',
|
||||
});
|
||||
} catch (error) {
|
||||
sendJson(res, 500, {
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : '保存精灵表资产失败。',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function toExpressHandler(
|
||||
handler: (
|
||||
request: IncomingMessage & { body?: unknown },
|
||||
response: ServerResponse,
|
||||
) => Promise<void> | void,
|
||||
) {
|
||||
return (request: Request, response: Response, next: NextFunction) => {
|
||||
Promise.resolve(
|
||||
handler(
|
||||
request as Request & IncomingMessage & { body?: unknown },
|
||||
response as Response & ServerResponse,
|
||||
),
|
||||
).catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
export function createQwenSpriteRoutes(config: AppConfig) {
|
||||
const router = Router();
|
||||
|
||||
router.use((request, response, next) => {
|
||||
if (
|
||||
request.path !== '/api/assets' &&
|
||||
!request.path.startsWith('/api/assets/')
|
||||
) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.assetsApiEnabled) {
|
||||
response.status(403).json({
|
||||
error: {
|
||||
message: '资产工具接口当前未启用。',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
router.use(
|
||||
QWEN_SPRITE_MASTER_GENERATE_PATH,
|
||||
toExpressHandler((request, response) =>
|
||||
handleGenerateMaster(config, request, response),
|
||||
),
|
||||
);
|
||||
router.use(
|
||||
QWEN_SPRITE_SHEET_GENERATE_PATH,
|
||||
toExpressHandler((request, response) =>
|
||||
handleGenerateSheet(config, request, response),
|
||||
),
|
||||
);
|
||||
router.use(
|
||||
QWEN_SPRITE_FRAME_REPAIR_PATH,
|
||||
toExpressHandler((request, response) =>
|
||||
handleRepairFrame(config, request, response),
|
||||
),
|
||||
);
|
||||
router.use(
|
||||
QWEN_SPRITE_SAVE_PATH,
|
||||
toExpressHandler((request, response) =>
|
||||
handleSaveAsset(config.projectRoot, request, response),
|
||||
),
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -3,8 +3,6 @@ import { z } from 'zod';
|
||||
|
||||
import type { ListCustomWorldWorksResponse } from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
import type {
|
||||
AnswerCustomWorldSessionQuestionRequest,
|
||||
CreateCustomWorldSessionRequest,
|
||||
CustomWorldGalleryDetailResponse,
|
||||
CustomWorldGalleryResponse,
|
||||
CustomWorldLibraryMutationResponse,
|
||||
@@ -21,7 +19,6 @@ import type {
|
||||
SavedGameSnapshotInput,
|
||||
} from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import {
|
||||
CUSTOM_WORLD_GENERATION_MODES,
|
||||
PLATFORM_THEMES,
|
||||
} from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import type {
|
||||
@@ -41,7 +38,6 @@ import { badRequest, notFound } from '../errors.js';
|
||||
import {
|
||||
asyncHandler,
|
||||
jsonClone,
|
||||
prepareEventStreamResponse,
|
||||
sendApiResponse,
|
||||
} from '../http.js';
|
||||
import { requireJwtAuth } from '../middleware/auth.js';
|
||||
@@ -67,7 +63,6 @@ import {
|
||||
npcRecruitDialogueRequestSchema,
|
||||
} from '../services/chatService.js';
|
||||
import { generateCustomWorldEntity } from '../services/customWorldEntityGenerationService.js';
|
||||
import { generateCustomWorldProfile } from '../services/customWorldGenerationService.js';
|
||||
import { generateSceneNpcForLandmark } from '../services/customWorldSceneNpcGenerationService.js';
|
||||
import { listCustomWorldWorkSummaries } from '../services/customWorldWorkSummaryService.js';
|
||||
import { generateQuestForNpcEncounter } from '../services/questService.js';
|
||||
@@ -133,17 +128,6 @@ const customWorldEntitySchema = z.object({
|
||||
kind: z.enum(['playable', 'story', 'landmark']),
|
||||
});
|
||||
|
||||
const customWorldSessionSchema = z.object({
|
||||
settingText: z.string().trim().min(1),
|
||||
creatorIntent: jsonObjectSchema.nullable().optional().default(null),
|
||||
generationMode: z.enum(CUSTOM_WORLD_GENERATION_MODES).default('fast'),
|
||||
});
|
||||
|
||||
const customWorldAnswerSchema = z.object({
|
||||
questionId: z.string().trim().min(1),
|
||||
answer: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
const runtimeItemIntentSchema = z.object({
|
||||
context: jsonObjectSchema,
|
||||
plans: z.array(jsonObjectSchema),
|
||||
@@ -792,128 +776,6 @@ export function createRuntimeRoutes(context: AppContext) {
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/custom-world/sessions',
|
||||
routeMeta({ operation: 'runtime.customWorldSession.create' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = customWorldSessionSchema.parse(
|
||||
request.body,
|
||||
) as CreateCustomWorldSessionRequest;
|
||||
sendApiResponse(
|
||||
response,
|
||||
await context.customWorldSessions.create(
|
||||
request.userId!,
|
||||
payload.settingText,
|
||||
payload.creatorIntent,
|
||||
payload.generationMode,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/runtime/custom-world/sessions/:sessionId',
|
||||
routeMeta({ operation: 'runtime.customWorldSession.get' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const session = await context.customWorldSessions.get(
|
||||
request.userId!,
|
||||
readParam(request.params.sessionId),
|
||||
);
|
||||
if (!session) {
|
||||
throw notFound('custom world session not found');
|
||||
}
|
||||
sendApiResponse(response, session);
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/custom-world/sessions/:sessionId/answers',
|
||||
routeMeta({ operation: 'runtime.customWorldSession.answer' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = customWorldAnswerSchema.parse(
|
||||
request.body,
|
||||
) as AnswerCustomWorldSessionQuestionRequest;
|
||||
const session = await context.customWorldSessions.answer(
|
||||
request.userId!,
|
||||
readParam(request.params.sessionId),
|
||||
payload.questionId,
|
||||
payload.answer,
|
||||
);
|
||||
if (!session) {
|
||||
throw notFound('custom world session not found');
|
||||
}
|
||||
sendApiResponse(response, session);
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/runtime/custom-world/sessions/:sessionId/generate/stream',
|
||||
routeMeta({ operation: 'runtime.customWorldSession.generateStream' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const session = await context.customWorldSessions.get(
|
||||
request.userId!,
|
||||
readParam(request.params.sessionId),
|
||||
);
|
||||
if (!session) {
|
||||
throw notFound('custom world session not found');
|
||||
}
|
||||
|
||||
prepareEventStreamResponse(request, response);
|
||||
const controller = new AbortController();
|
||||
|
||||
request.on('close', () => {
|
||||
controller.abort();
|
||||
});
|
||||
|
||||
const writeEvent = (event: string, payload: Record<string, unknown>) => {
|
||||
response.write(`event: ${event}\n`);
|
||||
response.write(`data: ${JSON.stringify(payload)}\n\n`);
|
||||
};
|
||||
|
||||
writeEvent('progress', { phase: 'preparing', progress: 10 });
|
||||
await context.customWorldSessions.updateStatus(
|
||||
request.userId!,
|
||||
readParam(request.params.sessionId),
|
||||
'generating',
|
||||
);
|
||||
writeEvent('progress', { phase: 'requesting_llm', progress: 45 });
|
||||
|
||||
try {
|
||||
const profile = await generateCustomWorldProfile(context, session, {
|
||||
signal: controller.signal,
|
||||
onProgress: (progress) => {
|
||||
writeEvent(
|
||||
'progress',
|
||||
progress as unknown as Record<string, unknown>,
|
||||
);
|
||||
},
|
||||
});
|
||||
await context.customWorldSessions.setResult(
|
||||
request.userId!,
|
||||
readParam(request.params.sessionId),
|
||||
profile,
|
||||
);
|
||||
writeEvent('progress', { phase: 'completed', progress: 100 });
|
||||
writeEvent('result', { profile });
|
||||
writeEvent('done', { ok: true });
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'custom world generation failed';
|
||||
await context.customWorldSessions.updateStatus(
|
||||
request.userId!,
|
||||
readParam(request.params.sessionId),
|
||||
'generation_error',
|
||||
message,
|
||||
);
|
||||
writeEvent('error', { message });
|
||||
} finally {
|
||||
response.end();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/items/runtime-intent',
|
||||
routeMeta({ operation: 'runtime.items.intent' }),
|
||||
|
||||
@@ -16,7 +16,6 @@ import { CaptchaChallengeStore } from './services/captchaChallengeStore.js';
|
||||
import { CustomWorldAgentAutoAssetService } from './services/customWorldAgentAutoAssetService.js';
|
||||
import { CustomWorldAgentOrchestrator } from './services/customWorldAgentOrchestrator.js';
|
||||
import { CustomWorldAgentSessionStore } from './services/customWorldAgentSessionStore.js';
|
||||
import { CustomWorldSessionStore } from './services/customWorldSessionStore.js';
|
||||
import { UpstreamLlmClient } from './services/llmClient.js';
|
||||
import { createSmsVerificationService } from './services/smsVerificationService.js';
|
||||
import { createWechatAuthService } from './services/wechatAuthService.js';
|
||||
@@ -113,7 +112,6 @@ export async function createAppContext(config: AppConfig = loadConfig()) {
|
||||
userSessionRepository: new UserSessionRepository(db),
|
||||
runtimeRepository,
|
||||
llmClient: new UpstreamLlmClient(config, logger),
|
||||
customWorldSessions: new CustomWorldSessionStore(runtimeRepository),
|
||||
customWorldAgentSessions,
|
||||
customWorldAgentOrchestrator: new CustomWorldAgentOrchestrator(
|
||||
customWorldAgentSessions,
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { AppContext } from '../context.js';
|
||||
import {
|
||||
type CustomWorldGenerationProgress,
|
||||
generateCustomWorldProfileFromOrchestrator,
|
||||
type GenerateCustomWorldProfileInput,
|
||||
} from '../modules/ai/customWorldOrchestrator.js';
|
||||
import type { CustomWorldSession } from './customWorldSessionStore.js';
|
||||
|
||||
export async function generateCustomWorldProfile(
|
||||
context: AppContext,
|
||||
session: CustomWorldSession,
|
||||
options: {
|
||||
onProgress?: (progress: CustomWorldGenerationProgress) => void;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
) {
|
||||
const input = {
|
||||
settingText: session.settingText,
|
||||
creatorIntent: session.creatorIntent,
|
||||
generationMode: session.generationMode,
|
||||
} satisfies GenerateCustomWorldProfileInput;
|
||||
|
||||
const profile = await generateCustomWorldProfileFromOrchestrator(
|
||||
context.llmClient,
|
||||
input,
|
||||
{
|
||||
onProgress: options.onProgress,
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
|
||||
return JSON.parse(JSON.stringify(profile)) as Record<string, unknown>;
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import type { JsonObject } from '../../../packages/shared/src/contracts/common.js';
|
||||
import type {
|
||||
CustomWorldGenerationMode,
|
||||
CustomWorldQuestion,
|
||||
CustomWorldSessionRecord,
|
||||
CustomWorldSessionStatus,
|
||||
} from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
|
||||
|
||||
export type CustomWorldSession = {
|
||||
sessionId: string;
|
||||
status: CustomWorldSessionStatus;
|
||||
settingText: string;
|
||||
creatorIntent: JsonObject | null;
|
||||
generationMode: CustomWorldGenerationMode;
|
||||
questions: CustomWorldQuestion[];
|
||||
result?: JsonObject;
|
||||
lastError?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
function cloneSession(session: CustomWorldSession) {
|
||||
return JSON.parse(JSON.stringify(session)) as CustomWorldSession;
|
||||
}
|
||||
|
||||
function toSessionRecord(session: CustomWorldSession): CustomWorldSessionRecord {
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
status: session.status,
|
||||
settingText: session.settingText,
|
||||
creatorIntent: session.creatorIntent,
|
||||
generationMode: session.generationMode,
|
||||
questions: session.questions,
|
||||
result: session.result,
|
||||
lastError: session.lastError,
|
||||
createdAt: session.createdAt,
|
||||
updatedAt: session.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function toSession(record: CustomWorldSessionRecord) {
|
||||
return cloneSession({
|
||||
sessionId: record.sessionId,
|
||||
status: record.status,
|
||||
settingText: record.settingText,
|
||||
creatorIntent: record.creatorIntent ?? null,
|
||||
generationMode: record.generationMode,
|
||||
questions: record.questions,
|
||||
result: record.result,
|
||||
lastError: record.lastError,
|
||||
createdAt: record.createdAt,
|
||||
updatedAt: record.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
function hasPendingQuestion(questions: CustomWorldQuestion[]) {
|
||||
return questions.some((question) => !question.answer?.trim());
|
||||
}
|
||||
|
||||
function buildClarificationQuestions(
|
||||
settingText: string,
|
||||
creatorIntent: JsonObject | null,
|
||||
) {
|
||||
const questions: CustomWorldQuestion[] = [];
|
||||
const worldHook =
|
||||
typeof creatorIntent?.worldHook === 'string' ? creatorIntent.worldHook.trim() : '';
|
||||
const playerPremise =
|
||||
typeof creatorIntent?.playerPremise === 'string' ? creatorIntent.playerPremise.trim() : '';
|
||||
const openingSituation =
|
||||
typeof creatorIntent?.openingSituation === 'string'
|
||||
? creatorIntent.openingSituation.trim()
|
||||
: '';
|
||||
const coreConflicts = Array.isArray(creatorIntent?.coreConflicts)
|
||||
? creatorIntent.coreConflicts
|
||||
: [];
|
||||
|
||||
if (!worldHook && settingText.trim().length < 24) {
|
||||
questions.push({
|
||||
id: 'world_hook',
|
||||
label: '世界核心',
|
||||
question: '请用一句话补充这个世界最核心的命题或独特卖点。',
|
||||
});
|
||||
}
|
||||
if (!playerPremise) {
|
||||
questions.push({
|
||||
id: 'player_premise',
|
||||
label: '玩家身份',
|
||||
question: '玩家在这个世界里是什么身份、立场或来历?',
|
||||
});
|
||||
}
|
||||
if (!openingSituation) {
|
||||
questions.push({
|
||||
id: 'opening_situation',
|
||||
label: '开局处境',
|
||||
question: '故事开局时,玩家正处于什么局面?',
|
||||
});
|
||||
}
|
||||
if (coreConflicts.length === 0) {
|
||||
questions.push({
|
||||
id: 'core_conflict',
|
||||
label: '核心冲突',
|
||||
question: '这个世界当前最核心的冲突、危机或悬念是什么?',
|
||||
});
|
||||
}
|
||||
|
||||
return questions;
|
||||
}
|
||||
|
||||
export class CustomWorldSessionStore {
|
||||
constructor(
|
||||
private readonly runtimeRepository: RuntimeRepositoryPort,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
userId: string,
|
||||
settingText: string,
|
||||
creatorIntent: JsonObject | null,
|
||||
generationMode: CustomWorldGenerationMode,
|
||||
) {
|
||||
const sessionId = `custom-world-session-${crypto.randomBytes(16).toString('hex')}`;
|
||||
const now = new Date().toISOString();
|
||||
const session: CustomWorldSession = {
|
||||
sessionId,
|
||||
status: 'ready_to_generate',
|
||||
settingText,
|
||||
creatorIntent,
|
||||
generationMode,
|
||||
questions: buildClarificationQuestions(settingText, creatorIntent),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
if (hasPendingQuestion(session.questions)) {
|
||||
session.status = 'clarifying';
|
||||
}
|
||||
|
||||
await this.runtimeRepository.upsertCustomWorldSession(
|
||||
userId,
|
||||
sessionId,
|
||||
toSessionRecord(session),
|
||||
);
|
||||
return cloneSession(session);
|
||||
}
|
||||
|
||||
async list(userId: string) {
|
||||
const sessions = await this.runtimeRepository.listCustomWorldSessions(userId);
|
||||
return sessions.map((session) => toSession(session));
|
||||
}
|
||||
|
||||
async get(userId: string, sessionId: string) {
|
||||
const session = await this.runtimeRepository.getCustomWorldSession(
|
||||
userId,
|
||||
sessionId,
|
||||
);
|
||||
return session ? toSession(session) : null;
|
||||
}
|
||||
|
||||
async answer(
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
questionId: string,
|
||||
answer: string,
|
||||
) {
|
||||
const session = await this.get(userId, sessionId);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const question = session.questions.find((item) => item.id === questionId);
|
||||
if (!question) {
|
||||
return null;
|
||||
}
|
||||
|
||||
question.answer = answer;
|
||||
session.status = hasPendingQuestion(session.questions)
|
||||
? 'clarifying'
|
||||
: 'ready_to_generate';
|
||||
session.updatedAt = new Date().toISOString();
|
||||
await this.runtimeRepository.upsertCustomWorldSession(
|
||||
userId,
|
||||
sessionId,
|
||||
toSessionRecord(session),
|
||||
);
|
||||
return cloneSession(session);
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
status: CustomWorldSessionStatus,
|
||||
lastError = '',
|
||||
) {
|
||||
const session = await this.get(userId, sessionId);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
session.status = status;
|
||||
session.lastError = lastError || undefined;
|
||||
session.updatedAt = new Date().toISOString();
|
||||
await this.runtimeRepository.upsertCustomWorldSession(
|
||||
userId,
|
||||
sessionId,
|
||||
toSessionRecord(session),
|
||||
);
|
||||
return cloneSession(session);
|
||||
}
|
||||
|
||||
async setResult(userId: string, sessionId: string, result: JsonObject) {
|
||||
const session = await this.get(userId, sessionId);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
session.status = 'completed';
|
||||
session.lastError = undefined;
|
||||
session.result = JSON.parse(JSON.stringify(result)) as JsonObject;
|
||||
session.updatedAt = new Date().toISOString();
|
||||
await this.runtimeRepository.upsertCustomWorldSession(
|
||||
userId,
|
||||
sessionId,
|
||||
toSessionRecord(session),
|
||||
);
|
||||
return cloneSession(session);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user