@@ -277,7 +277,9 @@ async function waitForCustomWorldAgentOperation(params: {
|
||||
assert.equal(operationResponse.status, 200);
|
||||
operationText = await operationResponse.text();
|
||||
|
||||
if (new RegExp(`"status":"${params.expectedStatus}"`, 'u').test(operationText)) {
|
||||
if (
|
||||
new RegExp(`"status":"${params.expectedStatus}"`, 'u').test(operationText)
|
||||
) {
|
||||
return operationText;
|
||||
}
|
||||
|
||||
@@ -1827,6 +1829,164 @@ test('runtime persistence is isolated by user', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('profile dashboard aggregates wallet, play time and played works at the account level', async () => {
|
||||
await withTestServer('profile-dashboard', async ({ baseUrl }) => {
|
||||
const user = await authEntry(baseUrl, 'dashboard_user', 'secret123');
|
||||
|
||||
const firstSaveResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/save/snapshot`,
|
||||
withBearer(user.token, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
savedAt: '2026-04-16T08:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
currentStory: null,
|
||||
gameState: {
|
||||
worldType: 'CUSTOM',
|
||||
playerCurrency: 120,
|
||||
runtimeStats: {
|
||||
playTimeMs: 5400000,
|
||||
},
|
||||
customWorldProfile: {
|
||||
id: 'world-aurora',
|
||||
name: '裂潮边城',
|
||||
summary: '潮声与城线之间的冷铁边疆。',
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assert.equal(firstSaveResponse.status, 200);
|
||||
|
||||
const secondSaveResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/save/snapshot`,
|
||||
withBearer(user.token, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
savedAt: '2026-04-16T09:30:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
currentStory: null,
|
||||
gameState: {
|
||||
worldType: 'CUSTOM',
|
||||
playerCurrency: 86,
|
||||
runtimeStats: {
|
||||
playTimeMs: 7200000,
|
||||
},
|
||||
customWorldProfile: {
|
||||
id: 'world-aurora',
|
||||
name: '裂潮边城',
|
||||
summary: '潮声与城线之间的冷铁边疆。',
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assert.equal(secondSaveResponse.status, 200);
|
||||
|
||||
const thirdSaveResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/save/snapshot`,
|
||||
withBearer(user.token, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
savedAt: '2026-04-16T10:15:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
currentStory: null,
|
||||
gameState: {
|
||||
worldType: 'WUXIA',
|
||||
playerCurrency: 86,
|
||||
runtimeStats: {
|
||||
playTimeMs: 900000,
|
||||
},
|
||||
currentScenePreset: {
|
||||
name: '江湖新章',
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assert.equal(thirdSaveResponse.status, 200);
|
||||
|
||||
const dashboardResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/profile/dashboard`,
|
||||
withBearer(user.token),
|
||||
);
|
||||
const dashboardPayload = (await dashboardResponse.json()) as {
|
||||
walletBalance: number;
|
||||
totalPlayTimeMs: number;
|
||||
playedWorldCount: number;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
assert.equal(dashboardResponse.status, 200);
|
||||
assert.equal(dashboardPayload.walletBalance, 86);
|
||||
assert.equal(dashboardPayload.totalPlayTimeMs, 8100000);
|
||||
assert.equal(dashboardPayload.playedWorldCount, 2);
|
||||
assert.equal(dashboardPayload.updatedAt, '2026-04-16T10:15:00.000Z');
|
||||
|
||||
const legacyDashboardResponse = await httpRequest(
|
||||
`${baseUrl}/api/profile/dashboard`,
|
||||
withBearer(user.token),
|
||||
);
|
||||
const legacyDashboardPayload = (await legacyDashboardResponse.json()) as {
|
||||
walletBalance: number;
|
||||
totalPlayTimeMs: number;
|
||||
playedWorldCount: number;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
assert.equal(legacyDashboardResponse.status, 200);
|
||||
assert.deepEqual(legacyDashboardPayload, dashboardPayload);
|
||||
|
||||
const walletLedgerResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/profile/wallet-ledger`,
|
||||
withBearer(user.token),
|
||||
);
|
||||
const walletLedgerPayload = (await walletLedgerResponse.json()) as {
|
||||
entries: Array<{
|
||||
amountDelta: number;
|
||||
balanceAfter: number;
|
||||
sourceType: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
assert.equal(walletLedgerResponse.status, 200);
|
||||
assert.equal(walletLedgerPayload.entries.length, 2);
|
||||
assert.equal(walletLedgerPayload.entries[0]?.amountDelta, -34);
|
||||
assert.equal(walletLedgerPayload.entries[0]?.balanceAfter, 86);
|
||||
assert.equal(walletLedgerPayload.entries[0]?.sourceType, 'snapshot_sync');
|
||||
assert.equal(walletLedgerPayload.entries[1]?.amountDelta, 120);
|
||||
|
||||
const playStatsResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/profile/play-stats`,
|
||||
withBearer(user.token),
|
||||
);
|
||||
const playStatsPayload = (await playStatsResponse.json()) as {
|
||||
totalPlayTimeMs: number;
|
||||
playedWorks: Array<{
|
||||
worldKey: string;
|
||||
worldTitle: string;
|
||||
lastObservedPlayTimeMs: number;
|
||||
}>;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
assert.equal(playStatsResponse.status, 200);
|
||||
assert.equal(playStatsPayload.totalPlayTimeMs, 8100000);
|
||||
assert.equal(playStatsPayload.updatedAt, '2026-04-16T10:15:00.000Z');
|
||||
assert.equal(playStatsPayload.playedWorks.length, 2);
|
||||
assert.equal(playStatsPayload.playedWorks[0]?.worldKey, 'builtin:WUXIA');
|
||||
assert.equal(playStatsPayload.playedWorks[0]?.worldTitle, '江湖新章');
|
||||
assert.equal(
|
||||
playStatsPayload.playedWorks[1]?.worldKey,
|
||||
'custom:world-aurora',
|
||||
);
|
||||
assert.equal(
|
||||
playStatsPayload.playedWorks[1]?.lastObservedPlayTimeMs,
|
||||
7200000,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('custom worlds stay private until published and then appear in the public gallery', async () => {
|
||||
await withTestServer('custom-world-gallery', async ({ baseUrl }) => {
|
||||
const owner = await authEntry(baseUrl, 'gallery_owner', 'secret123');
|
||||
@@ -1929,7 +2089,10 @@ test('custom worlds stay private until published and then appear in the public g
|
||||
assert.equal(galleryAfterPublish.status, 200);
|
||||
assert.equal(galleryAfterPayload.entries.length, 1);
|
||||
assert.equal(galleryAfterPayload.entries[0]?.worldName, '裂桥前线');
|
||||
assert.equal(galleryAfterPayload.entries[0]?.authorDisplayName, 'gallery_owner');
|
||||
assert.equal(
|
||||
galleryAfterPayload.entries[0]?.authorDisplayName,
|
||||
'gallery_owner',
|
||||
);
|
||||
|
||||
const galleryDetail = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world-gallery/${encodeURIComponent(galleryAfterPayload.entries[0]?.ownerUserId || '')}/${encodeURIComponent(galleryAfterPayload.entries[0]?.profileId || '')}`,
|
||||
@@ -1975,9 +2138,10 @@ test('custom worlds stay private until published and then appear in the public g
|
||||
},
|
||||
},
|
||||
);
|
||||
const galleryAfterUnpublishPayload = (await galleryAfterUnpublish.json()) as {
|
||||
entries: unknown[];
|
||||
};
|
||||
const galleryAfterUnpublishPayload =
|
||||
(await galleryAfterUnpublish.json()) as {
|
||||
entries: unknown[];
|
||||
};
|
||||
assert.deepEqual(galleryAfterUnpublishPayload.entries, []);
|
||||
});
|
||||
});
|
||||
@@ -2413,12 +2577,18 @@ test('custom world agent update_draft_card action updates draft profile and card
|
||||
await withTestServer(
|
||||
'custom-world-agent-phase4-update-http',
|
||||
async ({ baseUrl, context }) => {
|
||||
const entry = await authEntry(baseUrl, 'cw_agent_phase4_update', 'secret123');
|
||||
const entry = await authEntry(
|
||||
baseUrl,
|
||||
'cw_agent_phase4_update',
|
||||
'secret123',
|
||||
);
|
||||
const session = await createObjectRefiningCustomWorldAgentSession({
|
||||
baseUrl,
|
||||
token: entry.token,
|
||||
});
|
||||
const worldCard = session.draftCards.find((card) => card.kind === 'world');
|
||||
const worldCard = session.draftCards.find(
|
||||
(card) => card.kind === 'world',
|
||||
);
|
||||
|
||||
assert.ok(worldCard);
|
||||
|
||||
@@ -2526,7 +2696,8 @@ test('custom world agent update_draft_card action updates draft profile and card
|
||||
assert.equal(cardDetailResponse.status, 200);
|
||||
assert.ok(
|
||||
cardDetailPayload.card.sections.some(
|
||||
(section) => section.label === '标题' && section.value === '潮雾列岛·回潮版',
|
||||
(section) =>
|
||||
section.label === '标题' && section.value === '潮雾列岛·回潮版',
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2547,11 +2718,7 @@ test('custom world agent generate_characters action appends character cards over
|
||||
await withTestServer(
|
||||
'custom-world-agent-phase4-generate-characters-http',
|
||||
async ({ baseUrl, context }) => {
|
||||
const entry = await authEntry(
|
||||
baseUrl,
|
||||
'cw_agent_p4_ch',
|
||||
'secret123',
|
||||
);
|
||||
const entry = await authEntry(baseUrl, 'cw_agent_p4_ch', 'secret123');
|
||||
const session = await createObjectRefiningCustomWorldAgentSession({
|
||||
baseUrl,
|
||||
token: entry.token,
|
||||
@@ -2621,7 +2788,8 @@ test('custom world agent generate_characters action appends character cards over
|
||||
assert.equal(sessionResponse.status, 200);
|
||||
assert.ok((sessionPayload.draftProfile?.storyNpcs?.length ?? 0) >= 2);
|
||||
assert.ok(
|
||||
sessionPayload.draftCards.filter((card) => card.kind === 'character').length >=
|
||||
sessionPayload.draftCards.filter((card) => card.kind === 'character')
|
||||
.length >=
|
||||
baselineCharacterCount + 2,
|
||||
);
|
||||
assert.ok(sessionPayload.focusCardId);
|
||||
@@ -2649,11 +2817,7 @@ test('custom world agent generate_landmarks action appends landmark cards over h
|
||||
await withTestServer(
|
||||
'custom-world-agent-phase4-generate-landmarks-http',
|
||||
async ({ baseUrl, context }) => {
|
||||
const entry = await authEntry(
|
||||
baseUrl,
|
||||
'cw_agent_p4_lm',
|
||||
'secret123',
|
||||
);
|
||||
const entry = await authEntry(baseUrl, 'cw_agent_p4_lm', 'secret123');
|
||||
const session = await createObjectRefiningCustomWorldAgentSession({
|
||||
baseUrl,
|
||||
token: entry.token,
|
||||
@@ -2723,7 +2887,8 @@ test('custom world agent generate_landmarks action appends landmark cards over h
|
||||
assert.equal(sessionResponse.status, 200);
|
||||
assert.ok((sessionPayload.draftProfile?.landmarks?.length ?? 0) >= 6);
|
||||
assert.ok(
|
||||
sessionPayload.draftCards.filter((card) => card.kind === 'landmark').length >=
|
||||
sessionPayload.draftCards.filter((card) => card.kind === 'landmark')
|
||||
.length >=
|
||||
baselineLandmarkCount + 2,
|
||||
);
|
||||
assert.ok(sessionPayload.focusCardId);
|
||||
@@ -3053,3 +3218,175 @@ test('runtime snapshot persistence returns hydrated snapshots for frontend resto
|
||||
assert.equal(loadPayload.gameState.playerMaxHp, 170);
|
||||
});
|
||||
});
|
||||
|
||||
test('profile browse history supports batch sync, dedupe ordering, isolation and clear', async () => {
|
||||
await withTestServer('profile-browse-history', async ({ baseUrl }) => {
|
||||
const viewer = await authEntry(baseUrl, 'browse_viewer', 'secret123');
|
||||
const author = await authEntry(baseUrl, 'browse_author', 'secret123');
|
||||
const browseHistoryUrl = `${baseUrl}/api/runtime/profile/browse-history`;
|
||||
|
||||
const createResponse = await httpRequest(
|
||||
browseHistoryUrl,
|
||||
withBearer(viewer.token, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
ownerUserId: author.user.id,
|
||||
profileId: 'world-1',
|
||||
worldName: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summaryText: '第一次浏览记录',
|
||||
coverImageSrc: '/covers/world-1.png',
|
||||
themeMode: 'tide',
|
||||
authorDisplayName: '潮汐作者',
|
||||
visitedAt: '2026-04-16T10:00:00.000Z',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const createPayload = (await createResponse.json()) as {
|
||||
entries: Array<{
|
||||
profileId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
assert.equal(createResponse.status, 200);
|
||||
assert.deepEqual(
|
||||
createPayload.entries.map((entry) => entry.profileId),
|
||||
['world-1'],
|
||||
);
|
||||
|
||||
const batchResponse = await httpRequest(
|
||||
browseHistoryUrl,
|
||||
withBearer(viewer.token, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
entries: [
|
||||
{
|
||||
ownerUserId: author.user.id,
|
||||
profileId: 'world-2',
|
||||
worldName: '灰潮港',
|
||||
subtitle: '海雾中的残灯码头',
|
||||
summaryText: '第二条浏览记录',
|
||||
coverImageSrc: '/covers/world-2.png',
|
||||
themeMode: 'mythic',
|
||||
authorDisplayName: '潮汐作者',
|
||||
visitedAt: '2026-04-16T11:00:00.000Z',
|
||||
},
|
||||
{
|
||||
ownerUserId: author.user.id,
|
||||
profileId: 'world-1',
|
||||
worldName: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summaryText: '第二次浏览后更新',
|
||||
coverImageSrc: '/covers/world-1-updated.png',
|
||||
themeMode: 'tide',
|
||||
authorDisplayName: '潮汐作者',
|
||||
visitedAt: '2026-04-16T12:00:00.000Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const batchPayload = (await batchResponse.json()) as {
|
||||
entries: Array<{
|
||||
profileId: string;
|
||||
summaryText: string;
|
||||
visitedAt: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
assert.equal(batchResponse.status, 200);
|
||||
assert.deepEqual(
|
||||
batchPayload.entries.map((entry) => entry.profileId),
|
||||
['world-1', 'world-2'],
|
||||
);
|
||||
assert.equal(batchPayload.entries[0]?.summaryText, '第二次浏览后更新');
|
||||
assert.equal(
|
||||
batchPayload.entries[0]?.visitedAt,
|
||||
'2026-04-16T12:00:00.000Z',
|
||||
);
|
||||
|
||||
const viewerHistoryResponse = await httpRequest(
|
||||
browseHistoryUrl,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${viewer.token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const viewerHistoryPayload = (await viewerHistoryResponse.json()) as {
|
||||
entries: Array<{
|
||||
profileId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
assert.equal(viewerHistoryResponse.status, 200);
|
||||
assert.deepEqual(
|
||||
viewerHistoryPayload.entries.map((entry) => entry.profileId),
|
||||
['world-1', 'world-2'],
|
||||
);
|
||||
|
||||
const legacyViewerHistoryResponse = await httpRequest(
|
||||
`${baseUrl}/api/profile/browse-history`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${viewer.token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const legacyViewerHistoryPayload =
|
||||
(await legacyViewerHistoryResponse.json()) as {
|
||||
entries: Array<{
|
||||
profileId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
assert.equal(legacyViewerHistoryResponse.status, 200);
|
||||
assert.deepEqual(
|
||||
legacyViewerHistoryPayload.entries.map((entry) => entry.profileId),
|
||||
['world-1', 'world-2'],
|
||||
);
|
||||
|
||||
const authorHistoryResponse = await httpRequest(
|
||||
browseHistoryUrl,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${author.token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const authorHistoryPayload = (await authorHistoryResponse.json()) as {
|
||||
entries: Array<unknown>;
|
||||
};
|
||||
|
||||
assert.equal(authorHistoryResponse.status, 200);
|
||||
assert.deepEqual(authorHistoryPayload.entries, []);
|
||||
|
||||
const clearResponse = await httpRequest(
|
||||
browseHistoryUrl,
|
||||
withBearer(viewer.token, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
);
|
||||
const clearPayload = (await clearResponse.json()) as {
|
||||
entries: Array<unknown>;
|
||||
};
|
||||
|
||||
assert.equal(clearResponse.status, 200);
|
||||
assert.deepEqual(clearPayload.entries, []);
|
||||
|
||||
const clearedHistoryResponse = await httpRequest(
|
||||
browseHistoryUrl,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${viewer.token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const clearedHistoryPayload = (await clearedHistoryResponse.json()) as {
|
||||
entries: Array<unknown>;
|
||||
};
|
||||
|
||||
assert.equal(clearedHistoryResponse.status, 200);
|
||||
assert.deepEqual(clearedHistoryPayload.entries, []);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import express from 'express';
|
||||
import pinoHttp from 'pino-http';
|
||||
|
||||
import type { AppContext } from './context.js';
|
||||
import { notFound } from './errors.js';
|
||||
import { buildApiLogContext, withRouteMeta } from './http.js';
|
||||
import { errorHandler } from './middleware/errorHandler.js';
|
||||
import { requestIdMiddleware } from './middleware/requestId.js';
|
||||
@@ -12,7 +13,6 @@ import { createEditorRoutes } from './modules/editor/editorRoutes.js';
|
||||
import { createStoryActionRoutes } from './modules/story/storyActionRoutes.js';
|
||||
import { createAuthRoutes } from './routes/authRoutes.js';
|
||||
import { createRuntimeRoutes } from './routes/runtimeRoutes.js';
|
||||
import { notFound } from './errors.js';
|
||||
|
||||
function matchesRoutePrefix(
|
||||
request: express.Request,
|
||||
@@ -114,7 +114,10 @@ export function createApp(context: AppContext) {
|
||||
),
|
||||
);
|
||||
app.use(
|
||||
scopeToPrefixes(['/api/assets'], createCharacterAssetRoutes(context.config)),
|
||||
scopeToPrefixes(
|
||||
['/api/assets'],
|
||||
createCharacterAssetRoutes(context.config, context.llmClient),
|
||||
),
|
||||
);
|
||||
app.use(
|
||||
scopeToPrefixes(
|
||||
|
||||
@@ -110,6 +110,8 @@ test('createDatabase applies runtime baseline migrations for pg-mem', async () =
|
||||
'20260409_008_auth_risk_blocks',
|
||||
'20260413_009_custom_world_sessions',
|
||||
'20260414_010_custom_world_gallery_metadata',
|
||||
'20260416_011_profile_dashboard_tables',
|
||||
'20260416_012_user_browse_history',
|
||||
],
|
||||
);
|
||||
|
||||
@@ -126,8 +128,12 @@ test('createDatabase applies runtime baseline migrations for pg-mem', async () =
|
||||
'sms_auth_events',
|
||||
'user_sessions',
|
||||
'custom_world_sessions',
|
||||
'profile_dashboard_state',
|
||||
'profile_played_worlds',
|
||||
'profile_wallet_ledger',
|
||||
'save_snapshots',
|
||||
'runtime_settings',
|
||||
'user_browse_history',
|
||||
'custom_world_profiles'
|
||||
)
|
||||
ORDER BY table_name`,
|
||||
@@ -141,10 +147,14 @@ test('createDatabase applies runtime baseline migrations for pg-mem', async () =
|
||||
'auth_risk_blocks',
|
||||
'custom_world_profiles',
|
||||
'custom_world_sessions',
|
||||
'profile_dashboard_state',
|
||||
'profile_played_worlds',
|
||||
'profile_wallet_ledger',
|
||||
'runtime_settings',
|
||||
'save_snapshots',
|
||||
'schema_migrations',
|
||||
'sms_auth_events',
|
||||
'user_browse_history',
|
||||
'user_sessions',
|
||||
'users',
|
||||
],
|
||||
@@ -158,9 +168,7 @@ test('createDatabase rejects non-postgresql database urls', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
createDatabase(
|
||||
createTestConfig(
|
||||
'mysql://root:root@127.0.0.1:3306/genarrative',
|
||||
),
|
||||
createTestConfig('mysql://root:root@127.0.0.1:3306/genarrative'),
|
||||
),
|
||||
/DATABASE_URL 只支持 PostgreSQL 连接串或 pg-mem 测试连接/u,
|
||||
);
|
||||
|
||||
@@ -234,4 +234,69 @@ export const databaseMigrations: readonly DatabaseMigration[] = [
|
||||
ON custom_world_profiles (visibility, published_at DESC, updated_at DESC)`,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '20260416_011_profile_dashboard_tables',
|
||||
name: 'profile dashboard tables',
|
||||
statements: [
|
||||
`CREATE TABLE IF NOT EXISTS profile_dashboard_state (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
wallet_balance INTEGER NOT NULL DEFAULT 0,
|
||||
total_play_time_ms BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS profile_wallet_ledger (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
amount_delta INTEGER NOT NULL,
|
||||
balance_after INTEGER NOT NULL,
|
||||
source_type TEXT NOT NULL,
|
||||
source_key TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS profile_wallet_ledger_user_source_key_idx
|
||||
ON profile_wallet_ledger (user_id, source_key)`,
|
||||
`CREATE INDEX IF NOT EXISTS profile_wallet_ledger_user_created_idx
|
||||
ON profile_wallet_ledger (user_id, created_at DESC)`,
|
||||
`CREATE TABLE IF NOT EXISTS profile_played_worlds (
|
||||
user_id TEXT NOT NULL,
|
||||
world_key TEXT NOT NULL,
|
||||
owner_user_id TEXT,
|
||||
profile_id TEXT,
|
||||
world_type TEXT,
|
||||
world_title TEXT NOT NULL DEFAULT '',
|
||||
world_subtitle TEXT NOT NULL DEFAULT '',
|
||||
first_played_at TEXT NOT NULL,
|
||||
last_played_at TEXT NOT NULL,
|
||||
last_observed_play_time_ms BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (user_id, world_key),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS profile_played_worlds_user_last_played_idx
|
||||
ON profile_played_worlds (user_id, last_played_at DESC)`,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '20260416_012_user_browse_history',
|
||||
name: 'user browse history',
|
||||
statements: [
|
||||
`CREATE TABLE IF NOT EXISTS user_browse_history (
|
||||
user_id TEXT NOT NULL,
|
||||
owner_user_id TEXT NOT NULL,
|
||||
profile_id TEXT NOT NULL,
|
||||
world_name TEXT NOT NULL DEFAULT '',
|
||||
subtitle TEXT NOT NULL DEFAULT '',
|
||||
summary_text TEXT NOT NULL DEFAULT '',
|
||||
cover_image_src TEXT,
|
||||
theme_mode TEXT NOT NULL DEFAULT 'mythic',
|
||||
author_display_name TEXT NOT NULL DEFAULT '玩家',
|
||||
visited_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, owner_user_id, profile_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS user_browse_history_user_visited_idx
|
||||
ON user_browse_history (user_id, visited_at DESC)`,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
@@ -209,6 +209,12 @@ test('character visual generation converts public reference images into data url
|
||||
};
|
||||
};
|
||||
const content = createPayload.input.messages[0]?.content ?? [];
|
||||
assert.match(content[0]?.text ?? '', /右向斜侧身/u);
|
||||
assert.match(content[0]?.text ?? '', /纯绿色绿幕/u);
|
||||
assert.match(content[0]?.text ?? '', /1:1 正方形画幅|1:1 正方形画布/u);
|
||||
assert.match(content[0]?.text ?? '', /2 到 3 头身/u);
|
||||
assert.match(content[0]?.text ?? '', /不要把主题词自动扩写成背景建筑/u);
|
||||
assert.doesNotMatch(content[0]?.text ?? '', /水母国王/u);
|
||||
assert.match(content[1]?.image ?? '', /^data:image\/png;base64,/u);
|
||||
|
||||
const savedDraftPath = path.join(tempRoot, 'public', payload.drafts[0]!.imageSrc.slice(1));
|
||||
@@ -218,6 +224,186 @@ test('character visual generation converts public reference images into data url
|
||||
);
|
||||
});
|
||||
|
||||
test('character prompt bundle generation falls back to local defaults when llm client is unavailable', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-prompt-bundle-'));
|
||||
|
||||
await withAssetRouteServer(createTestConfig(tempRoot, 'http://127.0.0.1:9/api/v1'), async (assetBaseUrl) => {
|
||||
const response = await fetch(`${assetBaseUrl}/api/assets/character-prompts/generate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
roleKind: 'story',
|
||||
characterName: '港口向导',
|
||||
roleTitle: '潮灯守望者',
|
||||
roleLabel: '旧港引路人',
|
||||
description: '熟悉黑潮与暗礁,身上带着潮雾气息。',
|
||||
backstory: '常年守在废弃灯塔附近,为误入者指路。',
|
||||
personality: '冷静克制,但会在关键时刻出手。',
|
||||
motivation: '想守住最后一段仍能靠岸的航道。',
|
||||
combatStyle: '短刀与信号灯配合,动作利落。',
|
||||
tags: ['潮雾', '守望', '引路'],
|
||||
characterBriefText: '角色名称:港口向导\n角色头衔:潮灯守望者\n世界身份:旧港引路人',
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const payload = (await response.json()) as {
|
||||
source: string;
|
||||
visualPromptText: string;
|
||||
animationPromptText: string;
|
||||
scenePromptText: string;
|
||||
};
|
||||
|
||||
assert.equal(payload.source, 'fallback');
|
||||
assert.match(payload.visualPromptText, /港口向导/u);
|
||||
assert.match(payload.visualPromptText, /右向斜侧身/u);
|
||||
assert.match(payload.visualPromptText, /纯绿色绿幕/u);
|
||||
assert.match(payload.visualPromptText, /2 到 3 头身/u);
|
||||
assert.match(payload.animationPromptText, /动作/u);
|
||||
assert.match(payload.scenePromptText, /场景/u);
|
||||
});
|
||||
});
|
||||
|
||||
test('character workflow cache persists unsaved studio state', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-workflow-cache-'));
|
||||
|
||||
await withAssetRouteServer(createTestConfig(tempRoot, 'http://127.0.0.1:9/api/v1'), async (assetBaseUrl) => {
|
||||
const saveResponse = await fetch(`${assetBaseUrl}/api/assets/character-workflow-cache`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
characterId: 'harbor-guide',
|
||||
visualPromptText: '潮雾港守望者',
|
||||
animationPromptText: '短刀起手,收招利落',
|
||||
visualDrafts: [
|
||||
{
|
||||
id: 'draft-1',
|
||||
label: '候选 1',
|
||||
imageSrc: '/generated-character-drafts/harbor-guide/draft-1.png',
|
||||
width: 1024,
|
||||
height: 1536,
|
||||
},
|
||||
],
|
||||
selectedVisualDraftId: 'draft-1',
|
||||
selectedAnimation: 'idle',
|
||||
imageSrc: '/generated-characters/harbor-guide/visual/visual-1/master.png',
|
||||
generatedVisualAssetId: 'visual-1',
|
||||
generatedAnimationSetId: 'animation-set-1',
|
||||
animationMap: {
|
||||
idle: {
|
||||
basePath: '/generated-animations/harbor-guide/animation-set-1/idle',
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(saveResponse.status, 200);
|
||||
|
||||
const readResponse = await fetch(
|
||||
`${assetBaseUrl}/api/assets/character-workflow-cache/harbor-guide`,
|
||||
);
|
||||
assert.equal(readResponse.status, 200);
|
||||
|
||||
const payload = (await readResponse.json()) as {
|
||||
cache: {
|
||||
characterId: string;
|
||||
selectedVisualDraftId: string;
|
||||
generatedVisualAssetId?: string;
|
||||
animationMap?: Record<string, { basePath?: string }>;
|
||||
} | null;
|
||||
};
|
||||
|
||||
assert.equal(payload.cache?.characterId, 'harbor-guide');
|
||||
assert.equal(payload.cache?.selectedVisualDraftId, 'draft-1');
|
||||
assert.equal(payload.cache?.generatedVisualAssetId, 'visual-1');
|
||||
assert.equal(
|
||||
payload.cache?.animationMap?.idle?.basePath,
|
||||
'/generated-animations/harbor-guide/animation-set-1/idle',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('character workflow cache skips rewriting unchanged payloads', async () => {
|
||||
const tempRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'genarrative-character-workflow-cache-stable-'),
|
||||
);
|
||||
|
||||
await withAssetRouteServer(
|
||||
createTestConfig(tempRoot, 'http://127.0.0.1:9/api/v1'),
|
||||
async (assetBaseUrl) => {
|
||||
const payload = {
|
||||
characterId: 'harbor-guide',
|
||||
visualPromptText: '潮雾港守望者',
|
||||
animationPromptText: '短刀起手,收招利落',
|
||||
visualDrafts: [
|
||||
{
|
||||
id: 'draft-1',
|
||||
label: '候选 1',
|
||||
imageSrc: '/generated-character-drafts/harbor-guide/draft-1.png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
],
|
||||
selectedVisualDraftId: 'draft-1',
|
||||
selectedAnimation: 'idle',
|
||||
imageSrc: '/generated-characters/harbor-guide/visual/visual-1/master.png',
|
||||
generatedVisualAssetId: 'visual-1',
|
||||
generatedAnimationSetId: 'animation-set-1',
|
||||
animationMap: {
|
||||
idle: {
|
||||
basePath: '/generated-animations/harbor-guide/animation-set-1/idle',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const firstSaveResponse = await fetch(
|
||||
`${assetBaseUrl}/api/assets/character-workflow-cache`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
assert.equal(firstSaveResponse.status, 200);
|
||||
const firstSavePayload = (await firstSaveResponse.json()) as {
|
||||
cache: {
|
||||
updatedAt: string;
|
||||
};
|
||||
};
|
||||
|
||||
const secondSaveResponse = await fetch(
|
||||
`${assetBaseUrl}/api/assets/character-workflow-cache`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
assert.equal(secondSaveResponse.status, 200);
|
||||
const secondSavePayload = (await secondSaveResponse.json()) as {
|
||||
saveMessage: string;
|
||||
cache: {
|
||||
updatedAt: string;
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(secondSavePayload.saveMessage, '角色形象生成缓存无变化。');
|
||||
assert.equal(
|
||||
secondSavePayload.cache.updatedAt,
|
||||
firstSavePayload.cache.updatedAt,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('character animation image-to-video flow uploads a public visual source and submits the resolved oss url', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-video-'));
|
||||
const publicDir = path.join(tempRoot, 'public');
|
||||
|
||||
@@ -7,7 +7,7 @@ import http, {
|
||||
import https from 'node:https';
|
||||
import path from 'node:path';
|
||||
|
||||
import { Router, type NextFunction, type Request, type Response } from 'express';
|
||||
import { type NextFunction, type Request, type Response,Router } from 'express';
|
||||
|
||||
import {
|
||||
buildMasterPrompt,
|
||||
@@ -15,8 +15,12 @@ import {
|
||||
getActionTemplateById,
|
||||
} from '../../../../packages/shared/src/assets/qwenSprite.js';
|
||||
import { parseApiErrorMessage } from '../../../../packages/shared/src/http.js';
|
||||
import { parseJsonResponseText } from '../../../../packages/shared/src/llm/parsers.js';
|
||||
import type { AppConfig } from '../../config.js';
|
||||
import type { UpstreamLlmClient } from '../../services/llmClient.js';
|
||||
|
||||
const CHARACTER_PROMPT_BUNDLE_GENERATE_PATH = '/api/assets/character-prompts/generate';
|
||||
const CHARACTER_WORKFLOW_CACHE_PATH = '/api/assets/character-workflow-cache';
|
||||
const CHARACTER_VISUAL_GENERATE_PATH = '/api/assets/character-visual/generate';
|
||||
const CHARACTER_VISUAL_PUBLISH_PATH = '/api/assets/character-visual/publish';
|
||||
const CHARACTER_VISUAL_JOBS_PATH = '/api/assets/character-visual/jobs/';
|
||||
@@ -34,6 +38,24 @@ const DASHSCOPE_IMAGE_TASK_POLL_INTERVAL_MS = 2500;
|
||||
const DASHSCOPE_VIDEO_TASK_POLL_INTERVAL_MS = 15000;
|
||||
const DASHSCOPE_IMAGE_TASK_TIMEOUT_MS = 180000;
|
||||
const DASHSCOPE_VIDEO_TASK_TIMEOUT_MS = 420000;
|
||||
const CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT = `你是 RPG 角色资产提示词编译器。
|
||||
你会收到一个角色设定摘要,请为当前项目生成 3 段可直接交给资产生成模型的中文提示词。
|
||||
你必须只输出一个 JSON 对象,不要输出 Markdown、代码块、注释或解释。
|
||||
输出格式必须严格为:
|
||||
{
|
||||
"visualPromptText": "角色主图提示词",
|
||||
"animationPromptText": "角色动作提示词",
|
||||
"scenePromptText": "角色关联场景提示词"
|
||||
}
|
||||
|
||||
硬性约束:
|
||||
- 所有字段都必须是自然中文。
|
||||
- visualPromptText 用于角色主图候选,必须是角色标准设定图而不是场景海报,突出单人全身、右向斜侧身站姿、脚底完整可见、服装武器轮廓稳定、纯绿色绿幕背景、1:1 画幅。
|
||||
- visualPromptText 里的主题词只能落在角色自身的服装、发型、材质、纹样、饰品、武器和发光细节上,不要自动补出建筑、风景、漂浮物、烟雾或其他角色以外的场景元素。
|
||||
- visualPromptText 要明确“身体整体朝右,但保留少量正面信息”,避免生成完全 90 度纯右视图。
|
||||
- animationPromptText 用于角色动作试片,必须突出发力方式、动作气质、连贯性、同一角色一致性,不要写镜头切换。
|
||||
- scenePromptText 用于该角色关联的场景背景,必须突出角色首次登场或主活动区域的环境气质与空间结构,适配横版 RPG 场景。
|
||||
- 三段提示词都要可直接使用,不要编号,不要加字段名解释,不要输出负面提示词。`;
|
||||
|
||||
const BUILT_IN_MOTION_TEMPLATES = [
|
||||
{
|
||||
@@ -85,6 +107,83 @@ type DecodedMediaPayload = {
|
||||
extension: string;
|
||||
};
|
||||
|
||||
type CharacterPromptBundle = {
|
||||
visualPromptText: string;
|
||||
animationPromptText: string;
|
||||
scenePromptText: string;
|
||||
source: 'llm' | 'fallback';
|
||||
model: string | null;
|
||||
};
|
||||
|
||||
type CharacterAssetWorkflowCacheRecord = {
|
||||
characterId: string;
|
||||
visualPromptText: string;
|
||||
animationPromptText: string;
|
||||
visualDrafts: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
imageSrc: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}>;
|
||||
selectedVisualDraftId: string;
|
||||
selectedAnimation: string;
|
||||
imageSrc?: string;
|
||||
generatedVisualAssetId?: string;
|
||||
generatedAnimationSetId?: string;
|
||||
animationMap?: Record<string, unknown> | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
function serializeWorkflowCacheComparableValue(
|
||||
value: CharacterAssetWorkflowCacheRecord | Record<string, unknown>,
|
||||
) {
|
||||
const visualDrafts = Array.isArray(value.visualDrafts)
|
||||
? value.visualDrafts
|
||||
.map((item) => {
|
||||
if (!isRecordValue(item)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: typeof item.id === 'string' ? item.id : '',
|
||||
label: typeof item.label === 'string' ? item.label : '',
|
||||
imageSrc: typeof item.imageSrc === 'string' ? item.imageSrc : '',
|
||||
width: typeof item.width === 'number' ? item.width : 0,
|
||||
height: typeof item.height === 'number' ? item.height : 0,
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
return JSON.stringify({
|
||||
characterId: typeof value.characterId === 'string' ? value.characterId : '',
|
||||
visualPromptText:
|
||||
typeof value.visualPromptText === 'string' ? value.visualPromptText : '',
|
||||
animationPromptText:
|
||||
typeof value.animationPromptText === 'string'
|
||||
? value.animationPromptText
|
||||
: '',
|
||||
visualDrafts,
|
||||
selectedVisualDraftId:
|
||||
typeof value.selectedVisualDraftId === 'string'
|
||||
? value.selectedVisualDraftId
|
||||
: '',
|
||||
selectedAnimation:
|
||||
typeof value.selectedAnimation === 'string' ? value.selectedAnimation : '',
|
||||
imageSrc: typeof value.imageSrc === 'string' ? value.imageSrc : '',
|
||||
generatedVisualAssetId:
|
||||
typeof value.generatedVisualAssetId === 'string'
|
||||
? value.generatedVisualAssetId
|
||||
: '',
|
||||
generatedAnimationSetId:
|
||||
typeof value.generatedAnimationSetId === 'string'
|
||||
? value.generatedAnimationSetId
|
||||
: '',
|
||||
animationMap: isRecordValue(value.animationMap) ? value.animationMap : null,
|
||||
});
|
||||
}
|
||||
|
||||
function readJsonBody(req: IncomingMessage & { body?: unknown }) {
|
||||
const parsedBody = req.body;
|
||||
if (parsedBody && typeof parsedBody === 'object' && !Array.isArray(parsedBody)) {
|
||||
@@ -154,6 +253,144 @@ function sanitizePathSegment(value: string) {
|
||||
return normalized || 'asset';
|
||||
}
|
||||
|
||||
function clampPromptSeedText(value: unknown, maxLength: number) {
|
||||
if (typeof value !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return value.replace(/\s+/gu, ' ').trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function buildFallbackCharacterPromptBundle(params: {
|
||||
characterName: string;
|
||||
roleKind: string;
|
||||
roleTitle: string;
|
||||
roleLabel: string;
|
||||
description: string;
|
||||
backstory: string;
|
||||
personality: string;
|
||||
motivation: string;
|
||||
combatStyle: string;
|
||||
tags: string[];
|
||||
}) {
|
||||
const roleAnchor =
|
||||
[params.roleTitle, params.roleLabel].filter(Boolean).join(' / ') ||
|
||||
(params.roleKind === 'playable' ? '可扮演角色' : '场景角色');
|
||||
const characterAnchor = params.characterName || '该角色';
|
||||
const descriptionAnchor =
|
||||
params.description || params.backstory || params.personality || '气质鲜明';
|
||||
const combatAnchor = params.combatStyle || params.motivation || '动作发力清晰';
|
||||
const tagAnchor =
|
||||
params.tags.length > 0 ? `保留 ${params.tags.join('、')} 的识别点。` : '';
|
||||
|
||||
return {
|
||||
visualPromptText: [
|
||||
`${characterAnchor},${roleAnchor}。`,
|
||||
'单人全身,2D 横版 RPG 角色标准设定图,1:1 正方形画幅,头身比控制在 2 到 3 头身,右向斜侧身站立,身体整体朝右但保留少量正面信息,脚底完整可见,服装、发型、武器和轮廓稳定清楚。',
|
||||
`外观气质围绕:${descriptionAnchor}。`,
|
||||
combatAnchor ? `战斗识别点:${combatAnchor}。` : '',
|
||||
tagAnchor,
|
||||
'背景固定为纯绿色绿幕,不带建筑、风景、漂浮物和其他场景元素,方便自动抠像,不做正面立绘,不做完全 90 度纯右视图,不做夸张透视。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
animationPromptText: [
|
||||
`${characterAnchor}的核心动作试片。`,
|
||||
'保持同一角色的服装、发型、武器和体型一致,镜头稳定,侧身朝右,动作连贯。',
|
||||
combatAnchor ? `动作气质参考:${combatAnchor}。` : '',
|
||||
params.personality ? `角色气质补充:${params.personality}。` : '',
|
||||
'发力起手明确,过程干净,收招利落,避免漂移和变形。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
scenePromptText: [
|
||||
`${characterAnchor}关联主场景,适合作为首次登场区域或常驻活动空间。`,
|
||||
'16:9 横版 RPG 场景背景,上下分区清楚,上半部分表现中远景氛围,下半部分是可站立地面。',
|
||||
`场景叙事气质围绕:${descriptionAnchor}。`,
|
||||
params.backstory ? `背景线索可参考:${params.backstory}。` : '',
|
||||
params.motivation ? `环境中可埋入与当前目标相关的暗示:${params.motivation}。` : '',
|
||||
'整体风格克制统一,适合剧情探索与战斗底图。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
source: 'fallback' as const,
|
||||
model: null,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizePromptBundleValue(
|
||||
value: unknown,
|
||||
fallback: string,
|
||||
maxLength: number,
|
||||
) {
|
||||
const normalized = clampPromptSeedText(value, maxLength);
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
function sanitizeCharacterPromptBundle(
|
||||
value: unknown,
|
||||
fallback: CharacterPromptBundle,
|
||||
model: string,
|
||||
) {
|
||||
const record = isRecordValue(value) ? value : {};
|
||||
|
||||
return {
|
||||
visualPromptText: sanitizePromptBundleValue(
|
||||
record.visualPromptText,
|
||||
fallback.visualPromptText,
|
||||
280,
|
||||
),
|
||||
animationPromptText: sanitizePromptBundleValue(
|
||||
record.animationPromptText,
|
||||
fallback.animationPromptText,
|
||||
280,
|
||||
),
|
||||
scenePromptText: sanitizePromptBundleValue(
|
||||
record.scenePromptText,
|
||||
fallback.scenePromptText,
|
||||
320,
|
||||
),
|
||||
source: 'llm' as const,
|
||||
model: model.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCharacterPromptBundleUserPrompt(params: {
|
||||
roleKind: string;
|
||||
characterBriefText: string;
|
||||
characterName: string;
|
||||
roleTitle: string;
|
||||
roleLabel: string;
|
||||
description: string;
|
||||
backstory: string;
|
||||
personality: string;
|
||||
motivation: string;
|
||||
combatStyle: string;
|
||||
tags: string[];
|
||||
}) {
|
||||
return [
|
||||
'请根据下面的角色卡摘要,编译一组默认资产提示词。',
|
||||
'提示词用于当前项目的角色主图、动作试片和角色关联场景背景。',
|
||||
'请保留该角色的身份识别点、气质、战斗方式与世界感,不要空泛套模板。',
|
||||
'',
|
||||
`角色类型:${params.roleKind === 'playable' ? '可扮演角色' : '场景角色'}`,
|
||||
params.characterName ? `角色名称:${params.characterName}` : '',
|
||||
params.roleTitle ? `角色头衔:${params.roleTitle}` : '',
|
||||
params.roleLabel ? `世界身份:${params.roleLabel}` : '',
|
||||
params.description ? `角色描述:${params.description}` : '',
|
||||
params.backstory ? `角色背景:${params.backstory}` : '',
|
||||
params.personality ? `角色性格:${params.personality}` : '',
|
||||
params.motivation ? `角色动机:${params.motivation}` : '',
|
||||
params.combatStyle ? `战斗风格:${params.combatStyle}` : '',
|
||||
params.tags.length > 0 ? `角色标签:${params.tags.join('、')}` : '',
|
||||
'',
|
||||
'角色卡全文:',
|
||||
params.characterBriefText,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function createTimestampId(prefix: string) {
|
||||
return `${prefix}-${Date.now()}`;
|
||||
}
|
||||
@@ -173,6 +410,16 @@ function getJobRecordPath(
|
||||
);
|
||||
}
|
||||
|
||||
function getCharacterWorkflowCachePath(rootDir: string, characterId: string) {
|
||||
return path.resolve(
|
||||
rootDir,
|
||||
'public',
|
||||
'generated-character-drafts',
|
||||
sanitizePathSegment(characterId),
|
||||
'workflow-cache.json',
|
||||
);
|
||||
}
|
||||
|
||||
async function writeJobRecord(
|
||||
rootDir: string,
|
||||
kind: 'visual' | 'animation',
|
||||
@@ -766,6 +1013,103 @@ function buildNpcAnimationPrompt(options: {
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
async function handleGenerateCharacterPromptBundle(
|
||||
config: AppConfig,
|
||||
req: IncomingMessage & { body?: unknown },
|
||||
res: ServerResponse,
|
||||
llmClient?: UpstreamLlmClient | null,
|
||||
) {
|
||||
if (req.method !== 'POST') {
|
||||
sendJson(res, 405, { error: { message: 'Method Not Allowed' } });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await readJsonBody(req);
|
||||
const roleKind =
|
||||
typeof body.roleKind === 'string' && body.roleKind.trim()
|
||||
? body.roleKind.trim()
|
||||
: 'story';
|
||||
const characterBriefText = clampPromptSeedText(body.characterBriefText, 2400);
|
||||
const characterName = clampPromptSeedText(body.characterName, 40);
|
||||
const roleTitle = clampPromptSeedText(body.roleTitle, 60);
|
||||
const roleLabel = clampPromptSeedText(body.roleLabel, 60);
|
||||
const description = clampPromptSeedText(body.description, 240);
|
||||
const backstory = clampPromptSeedText(body.backstory, 320);
|
||||
const personality = clampPromptSeedText(body.personality, 180);
|
||||
const motivation = clampPromptSeedText(body.motivation, 180);
|
||||
const combatStyle = clampPromptSeedText(body.combatStyle, 180);
|
||||
const tags = isStringArray(body.tags)
|
||||
? body.tags.map((item) => clampPromptSeedText(item, 24)).filter(Boolean).slice(0, 8)
|
||||
: [];
|
||||
|
||||
if (!characterBriefText) {
|
||||
sendJson(res, 400, {
|
||||
error: { message: '生成默认提示词前需要提供角色设定摘要。' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackBundle = buildFallbackCharacterPromptBundle({
|
||||
characterName,
|
||||
roleKind,
|
||||
roleTitle,
|
||||
roleLabel,
|
||||
description,
|
||||
backstory,
|
||||
personality,
|
||||
motivation,
|
||||
combatStyle,
|
||||
tags,
|
||||
});
|
||||
const llmApiKey =
|
||||
typeof config.llm?.apiKey === 'string' ? config.llm.apiKey.trim() : '';
|
||||
const llmModel =
|
||||
typeof config.llm?.model === 'string' ? config.llm.model : '';
|
||||
|
||||
if (!llmClient || !llmApiKey) {
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
...fallbackBundle,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const responseText = await llmClient.requestMessageContent({
|
||||
systemPrompt: CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT,
|
||||
userPrompt: buildCharacterPromptBundleUserPrompt({
|
||||
roleKind,
|
||||
characterBriefText,
|
||||
characterName,
|
||||
roleTitle,
|
||||
roleLabel,
|
||||
description,
|
||||
backstory,
|
||||
personality,
|
||||
motivation,
|
||||
combatStyle,
|
||||
tags,
|
||||
}),
|
||||
debugLabel: 'character-prompt-bundle',
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
...sanitizeCharacterPromptBundle(
|
||||
parseJsonResponseText(responseText),
|
||||
fallbackBundle,
|
||||
llmModel,
|
||||
),
|
||||
});
|
||||
} catch {
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
...fallbackBundle,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function writeDraftBinaryFile(
|
||||
rootDir: string,
|
||||
relativePath: string,
|
||||
@@ -847,7 +1191,7 @@ async function handleGenerateCharacterVisuals(
|
||||
const size =
|
||||
typeof body.size === 'string' && body.size.trim()
|
||||
? body.size.trim()
|
||||
: '1024*1536';
|
||||
: '1024*1024';
|
||||
|
||||
if (sourceMode === 'image-to-image' && referenceImageDataUrls.length === 0) {
|
||||
sendJson(res, 400, {
|
||||
@@ -982,7 +1326,7 @@ async function handleGenerateCharacterVisuals(
|
||||
label: `候选 ${index + 1}`,
|
||||
imageSrc,
|
||||
width: 1024,
|
||||
height: 1536,
|
||||
height: 1024,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -2081,6 +2425,198 @@ function handleListAnimationTemplates(
|
||||
});
|
||||
}
|
||||
|
||||
async function handleGetCharacterWorkflowCache(
|
||||
config: AppConfig,
|
||||
req: IncomingMessage & { originalUrl?: string },
|
||||
res: ServerResponse,
|
||||
) {
|
||||
if (req.method !== 'GET') {
|
||||
sendJson(res, 405, { error: { message: 'Method Not Allowed' } });
|
||||
return;
|
||||
}
|
||||
|
||||
const rawUrl = req.originalUrl ?? req.url ?? '';
|
||||
const characterId = decodeURIComponent(
|
||||
rawUrl.slice(rawUrl.lastIndexOf('/') + 1),
|
||||
).trim();
|
||||
|
||||
if (!characterId) {
|
||||
sendJson(res, 400, { error: { message: 'characterId is required.' } });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const cache = (await readJsonObjectFile(
|
||||
getCharacterWorkflowCachePath(config.projectRoot, characterId),
|
||||
)) as CharacterAssetWorkflowCacheRecord | Record<string, never>;
|
||||
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
cache:
|
||||
isRecordValue(cache) && typeof cache.characterId === 'string'
|
||||
? cache
|
||||
: null,
|
||||
});
|
||||
} catch (error) {
|
||||
sendJson(res, 500, {
|
||||
error: {
|
||||
message:
|
||||
error instanceof Error ? error.message : '读取角色形象生成缓存失败。',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveCharacterWorkflowCache(
|
||||
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 characterId =
|
||||
typeof body.characterId === 'string' ? body.characterId.trim() : '';
|
||||
if (!characterId) {
|
||||
sendJson(res, 400, { error: { message: 'characterId is required.' } });
|
||||
return;
|
||||
}
|
||||
|
||||
const visualDrafts = Array.isArray(body.visualDrafts)
|
||||
? body.visualDrafts
|
||||
.map((item, index) => {
|
||||
if (!isRecordValue(item)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const imageSrc =
|
||||
typeof item.imageSrc === 'string' ? item.imageSrc.trim() : '';
|
||||
if (!imageSrc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id =
|
||||
typeof item.id === 'string' && item.id.trim()
|
||||
? item.id.trim()
|
||||
: `${characterId}-draft-${index + 1}`;
|
||||
const label =
|
||||
typeof item.label === 'string' && item.label.trim()
|
||||
? item.label.trim()
|
||||
: `候选 ${index + 1}`;
|
||||
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
imageSrc,
|
||||
width:
|
||||
typeof item.width === 'number' && Number.isFinite(item.width)
|
||||
? item.width
|
||||
: 1024,
|
||||
height:
|
||||
typeof item.height === 'number' && Number.isFinite(item.height)
|
||||
? item.height
|
||||
: 1536,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(
|
||||
item,
|
||||
): item is CharacterAssetWorkflowCacheRecord['visualDrafts'][number] =>
|
||||
Boolean(item),
|
||||
)
|
||||
: [];
|
||||
|
||||
const cacheFilePath = getCharacterWorkflowCachePath(
|
||||
config.projectRoot,
|
||||
characterId,
|
||||
);
|
||||
const payloadBase = {
|
||||
characterId,
|
||||
visualPromptText: clampPromptSeedText(body.visualPromptText, 280),
|
||||
animationPromptText: clampPromptSeedText(body.animationPromptText, 280),
|
||||
visualDrafts,
|
||||
selectedVisualDraftId:
|
||||
typeof body.selectedVisualDraftId === 'string'
|
||||
? body.selectedVisualDraftId.trim()
|
||||
: '',
|
||||
selectedAnimation:
|
||||
typeof body.selectedAnimation === 'string'
|
||||
? body.selectedAnimation.trim()
|
||||
: 'idle',
|
||||
imageSrc:
|
||||
typeof body.imageSrc === 'string' && body.imageSrc.trim()
|
||||
? body.imageSrc.trim()
|
||||
: undefined,
|
||||
generatedVisualAssetId:
|
||||
typeof body.generatedVisualAssetId === 'string' &&
|
||||
body.generatedVisualAssetId.trim()
|
||||
? body.generatedVisualAssetId.trim()
|
||||
: undefined,
|
||||
generatedAnimationSetId:
|
||||
typeof body.generatedAnimationSetId === 'string' &&
|
||||
body.generatedAnimationSetId.trim()
|
||||
? body.generatedAnimationSetId.trim()
|
||||
: undefined,
|
||||
animationMap: isRecordValue(body.animationMap) ? body.animationMap : null,
|
||||
};
|
||||
|
||||
try {
|
||||
const existingCache = (await readJsonObjectFile(cacheFilePath)) as
|
||||
| CharacterAssetWorkflowCacheRecord
|
||||
| Record<string, never>;
|
||||
const comparablePayload = serializeWorkflowCacheComparableValue(payloadBase);
|
||||
const comparableExisting = serializeWorkflowCacheComparableValue(
|
||||
existingCache,
|
||||
);
|
||||
|
||||
if (
|
||||
isRecordValue(existingCache) &&
|
||||
typeof existingCache.characterId === 'string' &&
|
||||
comparableExisting === comparablePayload
|
||||
) {
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
cache: existingCache,
|
||||
saveMessage: '角色形象生成缓存无变化。',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: CharacterAssetWorkflowCacheRecord = {
|
||||
...payloadBase,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await writeJsonObjectFile(
|
||||
cacheFilePath,
|
||||
payload as unknown as Record<string, unknown>,
|
||||
);
|
||||
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
cache: payload,
|
||||
saveMessage: '角色形象生成缓存已更新。',
|
||||
});
|
||||
} catch (error) {
|
||||
sendJson(res, 500, {
|
||||
error: {
|
||||
message:
|
||||
error instanceof Error ? error.message : '保存角色形象生成缓存失败。',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublishCharacterVisual(
|
||||
config: AppConfig,
|
||||
req: IncomingMessage & { body?: unknown },
|
||||
@@ -2126,7 +2662,7 @@ async function handlePublishCharacterVisual(
|
||||
const height =
|
||||
typeof body.height === 'number' && Number.isFinite(body.height)
|
||||
? body.height
|
||||
: 1536;
|
||||
: 1024;
|
||||
const updateCharacterOverride = body.updateCharacterOverride !== false;
|
||||
|
||||
if (!characterId) {
|
||||
@@ -2443,7 +2979,10 @@ function toExpressHandler(
|
||||
};
|
||||
}
|
||||
|
||||
export function createCharacterAssetRoutes(config: AppConfig) {
|
||||
export function createCharacterAssetRoutes(
|
||||
config: AppConfig,
|
||||
llmClient?: UpstreamLlmClient | null,
|
||||
) {
|
||||
const router = Router();
|
||||
|
||||
router.use((request, response, next) => {
|
||||
@@ -2466,6 +3005,21 @@ export function createCharacterAssetRoutes(config: AppConfig) {
|
||||
next();
|
||||
});
|
||||
|
||||
router.use(
|
||||
CHARACTER_WORKFLOW_CACHE_PATH,
|
||||
toExpressHandler((request, response) => {
|
||||
if (request.method === 'GET') {
|
||||
return handleGetCharacterWorkflowCache(config, request, response);
|
||||
}
|
||||
return handleSaveCharacterWorkflowCache(config, request, response);
|
||||
}),
|
||||
);
|
||||
router.use(
|
||||
CHARACTER_PROMPT_BUNDLE_GENERATE_PATH,
|
||||
toExpressHandler((request, response) =>
|
||||
handleGenerateCharacterPromptBundle(config, request, response, llmClient),
|
||||
),
|
||||
);
|
||||
router.use(
|
||||
CHARACTER_VISUAL_GENERATE_PATH,
|
||||
toExpressHandler((request, response) =>
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type { QueryResultRow } from 'pg';
|
||||
|
||||
import type {
|
||||
CustomWorldProfileRecord,
|
||||
PlatformBrowseHistoryEntry,
|
||||
PlatformBrowseHistoryWriteEntry,
|
||||
ProfileDashboardSummary,
|
||||
ProfilePlayedWorkSummary,
|
||||
ProfilePlayStatsResponse,
|
||||
ProfileWalletLedgerEntry,
|
||||
RuntimeSettings,
|
||||
SavedGameSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import {
|
||||
type CustomWorldSessionRecord,
|
||||
type CustomWorldGalleryCard,
|
||||
type CustomWorldLibraryEntry,
|
||||
type CustomWorldPublicationStatus,
|
||||
type CustomWorldSessionRecord,
|
||||
DEFAULT_MUSIC_VOLUME,
|
||||
SAVE_SNAPSHOT_VERSION,
|
||||
} from '../../../packages/shared/src/contracts/runtime.js';
|
||||
@@ -72,12 +80,62 @@ type CustomWorldCardRow = QueryResultRow & {
|
||||
landmarkCount: number;
|
||||
};
|
||||
|
||||
type PlatformBrowseHistoryRow = QueryResultRow & {
|
||||
ownerUserId: string;
|
||||
profileId: string;
|
||||
worldName: string;
|
||||
subtitle: string;
|
||||
summaryText: string;
|
||||
coverImageSrc: string | null;
|
||||
themeMode: PlatformBrowseHistoryEntry['themeMode'];
|
||||
authorDisplayName: string;
|
||||
visitedAt: string;
|
||||
};
|
||||
|
||||
type ProfileDashboardStateRow = QueryResultRow & {
|
||||
walletBalance: number;
|
||||
totalPlayTimeMs: number | string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type ProfileWalletLedgerRow = QueryResultRow & {
|
||||
id: string;
|
||||
amountDelta: number;
|
||||
balanceAfter: number;
|
||||
sourceType: ProfileWalletLedgerEntry['sourceType'];
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type ProfilePlayedWorldRow = QueryResultRow & {
|
||||
worldKey: string;
|
||||
ownerUserId: string | null;
|
||||
profileId: string | null;
|
||||
worldType: string | null;
|
||||
worldTitle: string;
|
||||
worldSubtitle: string;
|
||||
firstPlayedAt: string;
|
||||
lastPlayedAt: string;
|
||||
lastObservedPlayTimeMs: number | string;
|
||||
};
|
||||
|
||||
type ProfileWorldSnapshotMeta = {
|
||||
worldKey: string;
|
||||
ownerUserId: string | null;
|
||||
profileId: string | null;
|
||||
worldType: string | null;
|
||||
worldTitle: string;
|
||||
worldSubtitle: string;
|
||||
};
|
||||
|
||||
export type RuntimeRepositoryPort = {
|
||||
getSnapshot(userId: string): Promise<SavedSnapshot | null>;
|
||||
putSnapshot(
|
||||
userId: string,
|
||||
payload: Omit<SavedSnapshot, 'version'>,
|
||||
): Promise<SavedSnapshot>;
|
||||
getProfileDashboard(userId: string): Promise<ProfileDashboardSummary>;
|
||||
listProfileWalletLedger(userId: string): Promise<ProfileWalletLedgerEntry[]>;
|
||||
getProfilePlayStats(userId: string): Promise<ProfilePlayStatsResponse>;
|
||||
deleteSnapshot(userId: string): Promise<void>;
|
||||
getSettings(userId: string): Promise<RuntimeSettings>;
|
||||
putSettings(
|
||||
@@ -87,6 +145,14 @@ export type RuntimeRepositoryPort = {
|
||||
listCustomWorldProfiles(
|
||||
userId: string,
|
||||
): Promise<CustomWorldLibraryEntry<CustomWorldProfileRecord>[]>;
|
||||
listPlatformBrowseHistory(
|
||||
userId: string,
|
||||
): Promise<PlatformBrowseHistoryEntry[]>;
|
||||
upsertPlatformBrowseHistoryEntries(
|
||||
userId: string,
|
||||
entries: PlatformBrowseHistoryWriteEntry[],
|
||||
): Promise<PlatformBrowseHistoryEntry[]>;
|
||||
clearPlatformBrowseHistory(userId: string): Promise<void>;
|
||||
upsertCustomWorldProfile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
@@ -100,9 +166,7 @@ export type RuntimeRepositoryPort = {
|
||||
userId: string,
|
||||
profileId: string,
|
||||
): Promise<CustomWorldLibraryEntry<CustomWorldProfileRecord>[]>;
|
||||
listCustomWorldSessions(
|
||||
userId: string,
|
||||
): Promise<CustomWorldSessionRecord[]>;
|
||||
listCustomWorldSessions(userId: string): Promise<CustomWorldSessionRecord[]>;
|
||||
getCustomWorldSession(
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
@@ -168,7 +232,9 @@ function toCustomWorldLibraryEntry(
|
||||
? row.playableNpcCount
|
||||
: fallbackMetadata.playableNpcCount,
|
||||
landmarkCount:
|
||||
row.landmarkCount > 0 ? row.landmarkCount : fallbackMetadata.landmarkCount,
|
||||
row.landmarkCount > 0
|
||||
? row.landmarkCount
|
||||
: fallbackMetadata.landmarkCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -192,13 +258,159 @@ function toCustomWorldGalleryCard(
|
||||
};
|
||||
}
|
||||
|
||||
function toPlatformBrowseHistoryEntry(
|
||||
row: PlatformBrowseHistoryRow,
|
||||
): PlatformBrowseHistoryEntry {
|
||||
return {
|
||||
ownerUserId: row.ownerUserId,
|
||||
profileId: row.profileId,
|
||||
worldName: row.worldName || '未命名世界',
|
||||
subtitle: row.subtitle || '',
|
||||
summaryText: row.summaryText || '',
|
||||
coverImageSrc: row.coverImageSrc || null,
|
||||
themeMode: row.themeMode || 'mythic',
|
||||
authorDisplayName: row.authorDisplayName || '玩家',
|
||||
visitedAt: row.visitedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function readString(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizePlatformBrowseHistoryWriteEntry(
|
||||
entry: PlatformBrowseHistoryWriteEntry,
|
||||
): PlatformBrowseHistoryEntry | null {
|
||||
const ownerUserId = readString(entry.ownerUserId);
|
||||
const profileId = readString(entry.profileId);
|
||||
const worldName = readString(entry.worldName);
|
||||
|
||||
if (!ownerUserId || !profileId || !worldName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const visitedAt = readString(entry.visitedAt) || new Date().toISOString();
|
||||
|
||||
return {
|
||||
ownerUserId,
|
||||
profileId,
|
||||
worldName,
|
||||
subtitle: readString(entry.subtitle),
|
||||
summaryText: readString(entry.summaryText),
|
||||
coverImageSrc: readString(entry.coverImageSrc) || null,
|
||||
themeMode:
|
||||
(readString(
|
||||
entry.themeMode,
|
||||
) as PlatformBrowseHistoryEntry['themeMode']) || 'mythic',
|
||||
authorDisplayName: readString(entry.authorDisplayName) || '玩家',
|
||||
visitedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function readFiniteNumber(value: unknown) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsedValue = Number(value);
|
||||
return Number.isFinite(parsedValue) ? parsedValue : 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function normalizeDashboardNumber(value: unknown) {
|
||||
return Math.max(0, Math.round(readFiniteNumber(value)));
|
||||
}
|
||||
|
||||
function buildBuiltinWorldTitle(worldType: string) {
|
||||
switch (worldType) {
|
||||
case 'WUXIA':
|
||||
return '武侠世界';
|
||||
case 'XIANXIA':
|
||||
return '仙侠世界';
|
||||
default:
|
||||
return '叙事世界';
|
||||
}
|
||||
}
|
||||
|
||||
function resolveProfileWorldSnapshotMeta(
|
||||
snapshot: SavedSnapshot,
|
||||
): ProfileWorldSnapshotMeta | null {
|
||||
const gameState = asRecord(snapshot.gameState);
|
||||
if (!gameState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const customWorldProfile = asRecord(gameState.customWorldProfile);
|
||||
if (customWorldProfile) {
|
||||
const profileId = readString(customWorldProfile.id);
|
||||
const worldTitle =
|
||||
readString(customWorldProfile.name) ||
|
||||
readString(customWorldProfile.title);
|
||||
if (profileId || worldTitle) {
|
||||
return {
|
||||
worldKey: profileId ? `custom:${profileId}` : `custom:${worldTitle}`,
|
||||
ownerUserId: null,
|
||||
profileId: profileId || null,
|
||||
worldType: 'CUSTOM',
|
||||
worldTitle: worldTitle || '自定义世界',
|
||||
worldSubtitle:
|
||||
readString(customWorldProfile.summary) ||
|
||||
readString(customWorldProfile.settingText),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const worldType = readString(gameState.worldType);
|
||||
if (!worldType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentScenePreset = asRecord(gameState.currentScenePreset);
|
||||
const worldTitle =
|
||||
readString(currentScenePreset?.name) || buildBuiltinWorldTitle(worldType);
|
||||
|
||||
return {
|
||||
worldKey: `builtin:${worldType}`,
|
||||
ownerUserId: null,
|
||||
profileId: null,
|
||||
worldType,
|
||||
worldTitle,
|
||||
worldSubtitle:
|
||||
readString(currentScenePreset?.summary) ||
|
||||
readString(currentScenePreset?.description),
|
||||
};
|
||||
}
|
||||
|
||||
function toProfilePlayedWorkSummary(
|
||||
row: ProfilePlayedWorldRow,
|
||||
): ProfilePlayedWorkSummary {
|
||||
return {
|
||||
worldKey: row.worldKey,
|
||||
ownerUserId: row.ownerUserId,
|
||||
profileId: row.profileId,
|
||||
worldType: row.worldType,
|
||||
worldTitle: row.worldTitle,
|
||||
worldSubtitle: row.worldSubtitle,
|
||||
firstPlayedAt: row.firstPlayedAt,
|
||||
lastPlayedAt: row.lastPlayedAt,
|
||||
lastObservedPlayTimeMs: normalizeDashboardNumber(
|
||||
row.lastObservedPlayTimeMs,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
constructor(private readonly db: AppDatabase) {}
|
||||
|
||||
private async findCustomWorldProfileEntry(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
) {
|
||||
private async findCustomWorldProfileEntry(userId: string, profileId: string) {
|
||||
const result = await this.db.query<CustomWorldEntryRow>(
|
||||
`SELECT user_id AS "ownerUserId",
|
||||
profile_id AS "profileId",
|
||||
@@ -224,6 +436,164 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
return row ? toCustomWorldLibraryEntry(row) : null;
|
||||
}
|
||||
|
||||
private async getProfileDashboardState(userId: string) {
|
||||
const result = await this.db.query<ProfileDashboardStateRow>(
|
||||
`SELECT wallet_balance AS "walletBalance",
|
||||
total_play_time_ms AS "totalPlayTimeMs",
|
||||
updated_at AS "updatedAt"
|
||||
FROM profile_dashboard_state
|
||||
WHERE user_id = $1`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async findProfilePlayedWorld(userId: string, worldKey: string) {
|
||||
const result = await this.db.query<ProfilePlayedWorldRow>(
|
||||
`SELECT world_key AS "worldKey",
|
||||
owner_user_id AS "ownerUserId",
|
||||
profile_id AS "profileId",
|
||||
world_type AS "worldType",
|
||||
world_title AS "worldTitle",
|
||||
world_subtitle AS "worldSubtitle",
|
||||
first_played_at AS "firstPlayedAt",
|
||||
last_played_at AS "lastPlayedAt",
|
||||
last_observed_play_time_ms AS "lastObservedPlayTimeMs"
|
||||
FROM profile_played_worlds
|
||||
WHERE user_id = $1
|
||||
AND world_key = $2`,
|
||||
[userId, worldKey],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async upsertProfileDashboardState(
|
||||
userId: string,
|
||||
state: {
|
||||
walletBalance: number;
|
||||
totalPlayTimeMs: number;
|
||||
updatedAt: string;
|
||||
},
|
||||
) {
|
||||
await this.db.query(
|
||||
`INSERT INTO profile_dashboard_state (
|
||||
user_id,
|
||||
wallet_balance,
|
||||
total_play_time_ms,
|
||||
updated_at
|
||||
) VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
wallet_balance = EXCLUDED.wallet_balance,
|
||||
total_play_time_ms = EXCLUDED.total_play_time_ms,
|
||||
updated_at = EXCLUDED.updated_at`,
|
||||
[userId, state.walletBalance, state.totalPlayTimeMs, state.updatedAt],
|
||||
);
|
||||
}
|
||||
|
||||
private async syncProfileDashboardFromSnapshot(
|
||||
userId: string,
|
||||
snapshot: SavedSnapshot,
|
||||
) {
|
||||
const state = (await this.getProfileDashboardState(userId)) ?? {
|
||||
walletBalance: 0,
|
||||
totalPlayTimeMs: 0,
|
||||
updatedAt: snapshot.savedAt,
|
||||
};
|
||||
const syncedAt = snapshot.savedAt || new Date().toISOString();
|
||||
const gameState = asRecord(snapshot.gameState);
|
||||
const nextWalletBalance = normalizeDashboardNumber(
|
||||
gameState?.playerCurrency,
|
||||
);
|
||||
let nextTotalPlayTimeMs = normalizeDashboardNumber(state.totalPlayTimeMs);
|
||||
|
||||
if (nextWalletBalance !== state.walletBalance) {
|
||||
const amountDelta = nextWalletBalance - state.walletBalance;
|
||||
await this.db.query(
|
||||
`INSERT INTO profile_wallet_ledger (
|
||||
id,
|
||||
user_id,
|
||||
amount_delta,
|
||||
balance_after,
|
||||
source_type,
|
||||
source_key,
|
||||
created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (user_id, source_key) DO NOTHING`,
|
||||
[
|
||||
randomUUID(),
|
||||
userId,
|
||||
amountDelta,
|
||||
nextWalletBalance,
|
||||
'snapshot_sync',
|
||||
`snapshot:${syncedAt}:wallet:${nextWalletBalance}`,
|
||||
syncedAt,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
const worldMeta = resolveProfileWorldSnapshotMeta(snapshot);
|
||||
if (worldMeta) {
|
||||
const currentPlayTimeMs = normalizeDashboardNumber(
|
||||
asRecord(gameState?.runtimeStats)?.playTimeMs,
|
||||
);
|
||||
const currentWorld = await this.findProfilePlayedWorld(
|
||||
userId,
|
||||
worldMeta.worldKey,
|
||||
);
|
||||
const incrementalPlayTimeMs = Math.max(
|
||||
0,
|
||||
currentPlayTimeMs -
|
||||
normalizeDashboardNumber(currentWorld?.lastObservedPlayTimeMs ?? 0),
|
||||
);
|
||||
|
||||
nextTotalPlayTimeMs += incrementalPlayTimeMs;
|
||||
await this.db.query(
|
||||
`INSERT INTO profile_played_worlds (
|
||||
user_id,
|
||||
world_key,
|
||||
owner_user_id,
|
||||
profile_id,
|
||||
world_type,
|
||||
world_title,
|
||||
world_subtitle,
|
||||
first_played_at,
|
||||
last_played_at,
|
||||
last_observed_play_time_ms
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8, $9)
|
||||
ON CONFLICT (user_id, world_key) DO UPDATE SET
|
||||
owner_user_id = EXCLUDED.owner_user_id,
|
||||
profile_id = EXCLUDED.profile_id,
|
||||
world_type = EXCLUDED.world_type,
|
||||
world_title = EXCLUDED.world_title,
|
||||
world_subtitle = EXCLUDED.world_subtitle,
|
||||
last_played_at = EXCLUDED.last_played_at,
|
||||
last_observed_play_time_ms = GREATEST(
|
||||
profile_played_worlds.last_observed_play_time_ms,
|
||||
EXCLUDED.last_observed_play_time_ms
|
||||
)`,
|
||||
[
|
||||
userId,
|
||||
worldMeta.worldKey,
|
||||
worldMeta.ownerUserId,
|
||||
worldMeta.profileId,
|
||||
worldMeta.worldType,
|
||||
worldMeta.worldTitle,
|
||||
worldMeta.worldSubtitle,
|
||||
syncedAt,
|
||||
currentPlayTimeMs,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
await this.upsertProfileDashboardState(userId, {
|
||||
walletBalance: nextWalletBalance,
|
||||
totalPlayTimeMs: nextTotalPlayTimeMs,
|
||||
updatedAt: syncedAt,
|
||||
});
|
||||
}
|
||||
|
||||
async getSnapshot(userId: string) {
|
||||
const result = await this.db.query<SnapshotRow>(
|
||||
`SELECT version,
|
||||
@@ -288,18 +658,89 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
);
|
||||
|
||||
const row = result.rows[0];
|
||||
|
||||
return {
|
||||
const persistedSnapshot = {
|
||||
version: row.version,
|
||||
savedAt: row.savedAt,
|
||||
gameState: row.gameState,
|
||||
bottomTab: row.bottomTab,
|
||||
currentStory: row.currentStory,
|
||||
} satisfies SavedSnapshot;
|
||||
|
||||
await this.syncProfileDashboardFromSnapshot(userId, persistedSnapshot);
|
||||
|
||||
return persistedSnapshot;
|
||||
}
|
||||
|
||||
async getProfileDashboard(userId: string) {
|
||||
const state = await this.getProfileDashboardState(userId);
|
||||
const playedWorldsResult = await this.db.query<{ count: string }>(
|
||||
`SELECT COUNT(*)::text AS count
|
||||
FROM profile_played_worlds
|
||||
WHERE user_id = $1`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
return {
|
||||
walletBalance: normalizeDashboardNumber(state?.walletBalance ?? 0),
|
||||
totalPlayTimeMs: normalizeDashboardNumber(state?.totalPlayTimeMs ?? 0),
|
||||
playedWorldCount:
|
||||
Number.parseInt(playedWorldsResult.rows[0]?.count ?? '0', 10) || 0,
|
||||
updatedAt: state?.updatedAt ?? null,
|
||||
} satisfies ProfileDashboardSummary;
|
||||
}
|
||||
|
||||
async listProfileWalletLedger(userId: string) {
|
||||
const result = await this.db.query<ProfileWalletLedgerRow>(
|
||||
`SELECT id,
|
||||
amount_delta AS "amountDelta",
|
||||
balance_after AS "balanceAfter",
|
||||
source_type AS "sourceType",
|
||||
created_at AS "createdAt"
|
||||
FROM profile_wallet_ledger
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
id: row.id,
|
||||
amountDelta: row.amountDelta,
|
||||
balanceAfter: row.balanceAfter,
|
||||
sourceType: row.sourceType,
|
||||
createdAt: row.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async getProfilePlayStats(userId: string) {
|
||||
const state = await this.getProfileDashboardState(userId);
|
||||
const result = await this.db.query<ProfilePlayedWorldRow>(
|
||||
`SELECT world_key AS "worldKey",
|
||||
owner_user_id AS "ownerUserId",
|
||||
profile_id AS "profileId",
|
||||
world_type AS "worldType",
|
||||
world_title AS "worldTitle",
|
||||
world_subtitle AS "worldSubtitle",
|
||||
first_played_at AS "firstPlayedAt",
|
||||
last_played_at AS "lastPlayedAt",
|
||||
last_observed_play_time_ms AS "lastObservedPlayTimeMs"
|
||||
FROM profile_played_worlds
|
||||
WHERE user_id = $1
|
||||
ORDER BY last_played_at DESC`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
return {
|
||||
totalPlayTimeMs: normalizeDashboardNumber(state?.totalPlayTimeMs ?? 0),
|
||||
playedWorks: result.rows.map((row) => toProfilePlayedWorkSummary(row)),
|
||||
updatedAt: state?.updatedAt ?? null,
|
||||
} satisfies ProfilePlayStatsResponse;
|
||||
}
|
||||
|
||||
async deleteSnapshot(userId: string) {
|
||||
await this.db.query(`DELETE FROM save_snapshots WHERE user_id = $1`, [userId]);
|
||||
await this.db.query(`DELETE FROM save_snapshots WHERE user_id = $1`, [
|
||||
userId,
|
||||
]);
|
||||
}
|
||||
|
||||
async getSettings(userId: string) {
|
||||
@@ -339,6 +780,95 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
} satisfies RuntimeSettings;
|
||||
}
|
||||
|
||||
async listPlatformBrowseHistory(userId: string) {
|
||||
const result = await this.db.query<PlatformBrowseHistoryRow>(
|
||||
`SELECT owner_user_id AS "ownerUserId",
|
||||
profile_id AS "profileId",
|
||||
world_name AS "worldName",
|
||||
subtitle,
|
||||
summary_text AS "summaryText",
|
||||
cover_image_src AS "coverImageSrc",
|
||||
theme_mode AS "themeMode",
|
||||
author_display_name AS "authorDisplayName",
|
||||
visited_at AS "visitedAt"
|
||||
FROM user_browse_history
|
||||
WHERE user_id = $1
|
||||
ORDER BY visited_at DESC`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
return result.rows.map((row) => toPlatformBrowseHistoryEntry(row));
|
||||
}
|
||||
|
||||
async upsertPlatformBrowseHistoryEntries(
|
||||
userId: string,
|
||||
entries: PlatformBrowseHistoryWriteEntry[],
|
||||
) {
|
||||
const dedupedEntries = [
|
||||
...new Map(
|
||||
entries
|
||||
.map((entry) => normalizePlatformBrowseHistoryWriteEntry(entry))
|
||||
.filter((entry): entry is PlatformBrowseHistoryEntry =>
|
||||
Boolean(entry),
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
new Date(right.visitedAt).getTime() -
|
||||
new Date(left.visitedAt).getTime(),
|
||||
)
|
||||
.map(
|
||||
(entry) =>
|
||||
[`${entry.ownerUserId}:${entry.profileId}`, entry] as const,
|
||||
),
|
||||
).values(),
|
||||
];
|
||||
|
||||
for (const entry of dedupedEntries) {
|
||||
await this.db.query(
|
||||
`INSERT INTO user_browse_history (
|
||||
user_id,
|
||||
owner_user_id,
|
||||
profile_id,
|
||||
world_name,
|
||||
subtitle,
|
||||
summary_text,
|
||||
cover_image_src,
|
||||
theme_mode,
|
||||
author_display_name,
|
||||
visited_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (user_id, owner_user_id, profile_id) DO UPDATE SET
|
||||
world_name = EXCLUDED.world_name,
|
||||
subtitle = EXCLUDED.subtitle,
|
||||
summary_text = EXCLUDED.summary_text,
|
||||
cover_image_src = EXCLUDED.cover_image_src,
|
||||
theme_mode = EXCLUDED.theme_mode,
|
||||
author_display_name = EXCLUDED.author_display_name,
|
||||
visited_at = EXCLUDED.visited_at`,
|
||||
[
|
||||
userId,
|
||||
entry.ownerUserId,
|
||||
entry.profileId,
|
||||
entry.worldName,
|
||||
entry.subtitle,
|
||||
entry.summaryText,
|
||||
entry.coverImageSrc,
|
||||
entry.themeMode,
|
||||
entry.authorDisplayName,
|
||||
entry.visitedAt,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return this.listPlatformBrowseHistory(userId);
|
||||
}
|
||||
|
||||
async clearPlatformBrowseHistory(userId: string) {
|
||||
await this.db.query(`DELETE FROM user_browse_history WHERE user_id = $1`, [
|
||||
userId,
|
||||
]);
|
||||
}
|
||||
|
||||
async listCustomWorldProfiles(userId: string) {
|
||||
const result = await this.db.query<CustomWorldEntryRow>(
|
||||
`SELECT user_id AS "ownerUserId",
|
||||
@@ -514,7 +1044,10 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
profileId: string,
|
||||
authorDisplayName: string,
|
||||
) {
|
||||
const existingEntry = await this.findCustomWorldProfileEntry(userId, profileId);
|
||||
const existingEntry = await this.findCustomWorldProfileEntry(
|
||||
userId,
|
||||
profileId,
|
||||
);
|
||||
if (!existingEntry) {
|
||||
return null;
|
||||
}
|
||||
@@ -569,7 +1102,10 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
profileId: string,
|
||||
authorDisplayName: string,
|
||||
) {
|
||||
const existingEntry = await this.findCustomWorldProfileEntry(userId, profileId);
|
||||
const existingEntry = await this.findCustomWorldProfileEntry(
|
||||
userId,
|
||||
profileId,
|
||||
);
|
||||
if (!existingEntry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,12 @@ import type {
|
||||
CustomWorldGalleryResponse,
|
||||
CustomWorldLibraryMutationResponse,
|
||||
CustomWorldLibraryResponse,
|
||||
PlatformBrowseHistoryBatchSyncRequest,
|
||||
PlatformBrowseHistoryResponse,
|
||||
PlatformBrowseHistoryWriteEntry,
|
||||
ProfileDashboardSummary,
|
||||
ProfilePlayStatsResponse,
|
||||
ProfileWalletLedgerResponse,
|
||||
RuntimeSettings,
|
||||
SavedGameSnapshotInput,
|
||||
} from '../../../packages/shared/src/contracts/runtime.js';
|
||||
@@ -52,10 +58,10 @@ import {
|
||||
npcChatDialogueRequestSchema,
|
||||
npcRecruitDialogueRequestSchema,
|
||||
} from '../services/chatService.js';
|
||||
import { generateCustomWorldEntity } from '../services/customWorldEntityGenerationService.js';
|
||||
import { generateCustomWorldProfile } from '../services/customWorldGenerationService.js';
|
||||
import {
|
||||
listCustomWorldWorkSummaries,
|
||||
} from '../services/customWorldWorkSummaryService.js';
|
||||
import { generateSceneNpcForLandmark } from '../services/customWorldSceneNpcGenerationService.js';
|
||||
import { listCustomWorldWorkSummaries } from '../services/customWorldWorkSummaryService.js';
|
||||
import { generateQuestForNpcEncounter } from '../services/questService.js';
|
||||
import { generateRuntimeItemIntents } from '../services/runtimeItemService.js';
|
||||
import {
|
||||
@@ -82,10 +88,36 @@ const settingsSchema = z.object({
|
||||
musicVolume: z.number().min(0).max(1),
|
||||
});
|
||||
|
||||
const platformBrowseHistoryEntrySchema = z.object({
|
||||
ownerUserId: z.string().trim().min(1),
|
||||
profileId: z.string().trim().min(1),
|
||||
worldName: z.string().trim().min(1),
|
||||
subtitle: z.string().trim().optional().default(''),
|
||||
summaryText: z.string().trim().optional().default(''),
|
||||
coverImageSrc: z.string().trim().nullable().optional().default(null),
|
||||
themeMode: z.string().trim().optional().default('mythic'),
|
||||
authorDisplayName: z.string().trim().optional().default('玩家'),
|
||||
visitedAt: z.string().trim().optional().default(''),
|
||||
});
|
||||
|
||||
const platformBrowseHistoryBatchSchema = z.object({
|
||||
entries: z.array(platformBrowseHistoryEntrySchema).max(100),
|
||||
});
|
||||
|
||||
const customWorldProfileSchema = z.object({
|
||||
profile: jsonObjectSchema,
|
||||
});
|
||||
|
||||
const customWorldSceneNpcSchema = z.object({
|
||||
profile: jsonObjectSchema,
|
||||
landmarkId: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
const customWorldEntitySchema = z.object({
|
||||
profile: jsonObjectSchema,
|
||||
kind: z.enum(['playable', 'story', 'landmark']),
|
||||
});
|
||||
|
||||
const customWorldSessionSchema = z.object({
|
||||
settingText: z.string().trim().min(1),
|
||||
creatorIntent: jsonObjectSchema.nullable().optional().default(null),
|
||||
@@ -125,6 +157,29 @@ async function resolveAuthDisplayName(context: AppContext, userId: string) {
|
||||
export function createRuntimeRoutes(context: AppContext) {
|
||||
const router = Router();
|
||||
const requireAuth = requireJwtAuth(context.config, context.userRepository);
|
||||
const routeCompatPaths = (path: string) => [
|
||||
path,
|
||||
`/runtime${path}`,
|
||||
] as const;
|
||||
const handleCustomWorldEntityGeneration = asyncHandler(async (request, response) => {
|
||||
const payload = customWorldEntitySchema.parse(request.body) as {
|
||||
profile: Record<string, unknown>;
|
||||
kind: 'playable' | 'story' | 'landmark';
|
||||
};
|
||||
sendApiResponse(
|
||||
response,
|
||||
await generateCustomWorldEntity(context.llmClient, payload),
|
||||
);
|
||||
});
|
||||
const handleCustomWorldSceneNpcGeneration = asyncHandler(async (request, response) => {
|
||||
const payload = customWorldSceneNpcSchema.parse(request.body) as {
|
||||
profile: Record<string, unknown>;
|
||||
landmarkId: string;
|
||||
};
|
||||
sendApiResponse(response, {
|
||||
npc: await generateSceneNpcForLandmark(context.llmClient, payload),
|
||||
});
|
||||
});
|
||||
|
||||
router.use(requireAuth);
|
||||
router.use(
|
||||
@@ -132,6 +187,129 @@ export function createRuntimeRoutes(context: AppContext) {
|
||||
createCustomWorldAgentRoutes(context),
|
||||
);
|
||||
|
||||
routeCompatPaths('/profile/dashboard').forEach((path, index) => {
|
||||
router.get(
|
||||
path,
|
||||
routeMeta({
|
||||
operation:
|
||||
index === 0
|
||||
? 'profile.dashboard.get'
|
||||
: 'profile.dashboard.get.compat',
|
||||
}),
|
||||
asyncHandler(async (request, response) => {
|
||||
sendApiResponse<ProfileDashboardSummary>(
|
||||
response,
|
||||
await context.runtimeRepository.getProfileDashboard(request.userId!),
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
routeCompatPaths('/profile/wallet-ledger').forEach((path, index) => {
|
||||
router.get(
|
||||
path,
|
||||
routeMeta({
|
||||
operation:
|
||||
index === 0
|
||||
? 'profile.walletLedger.list'
|
||||
: 'profile.walletLedger.list.compat',
|
||||
}),
|
||||
asyncHandler(async (request, response) => {
|
||||
sendApiResponse<ProfileWalletLedgerResponse>(response, {
|
||||
entries: await context.runtimeRepository.listProfileWalletLedger(
|
||||
request.userId!,
|
||||
),
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
routeCompatPaths('/profile/play-stats').forEach((path, index) => {
|
||||
router.get(
|
||||
path,
|
||||
routeMeta({
|
||||
operation:
|
||||
index === 0
|
||||
? 'profile.playStats.get'
|
||||
: 'profile.playStats.get.compat',
|
||||
}),
|
||||
asyncHandler(async (request, response) => {
|
||||
sendApiResponse<ProfilePlayStatsResponse>(
|
||||
response,
|
||||
await context.runtimeRepository.getProfilePlayStats(request.userId!),
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
routeCompatPaths('/profile/browse-history').forEach((path, index) => {
|
||||
router.get(
|
||||
path,
|
||||
routeMeta({
|
||||
operation:
|
||||
index === 0
|
||||
? 'profile.browseHistory.list'
|
||||
: 'profile.browseHistory.list.compat',
|
||||
}),
|
||||
asyncHandler(async (request, response) => {
|
||||
sendApiResponse<PlatformBrowseHistoryResponse>(response, {
|
||||
entries: await context.runtimeRepository.listPlatformBrowseHistory(
|
||||
request.userId!,
|
||||
),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
path,
|
||||
routeMeta({
|
||||
operation:
|
||||
index === 0
|
||||
? 'profile.browseHistory.upsert'
|
||||
: 'profile.browseHistory.upsert.compat',
|
||||
}),
|
||||
asyncHandler(async (request, response) => {
|
||||
const rawBody =
|
||||
request.body && typeof request.body === 'object' ? request.body : {};
|
||||
const payload = (
|
||||
'entries' in rawBody
|
||||
? platformBrowseHistoryBatchSchema.parse(rawBody)
|
||||
: platformBrowseHistoryEntrySchema.parse(rawBody)
|
||||
) as
|
||||
| PlatformBrowseHistoryBatchSyncRequest
|
||||
| PlatformBrowseHistoryWriteEntry;
|
||||
|
||||
const entries = 'entries' in payload ? payload.entries : [payload];
|
||||
|
||||
sendApiResponse<PlatformBrowseHistoryResponse>(response, {
|
||||
entries:
|
||||
await context.runtimeRepository.upsertPlatformBrowseHistoryEntries(
|
||||
request.userId!,
|
||||
entries,
|
||||
),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
path,
|
||||
routeMeta({
|
||||
operation:
|
||||
index === 0
|
||||
? 'profile.browseHistory.clear'
|
||||
: 'profile.browseHistory.clear.compat',
|
||||
}),
|
||||
asyncHandler(async (request, response) => {
|
||||
await context.runtimeRepository.clearPlatformBrowseHistory(
|
||||
request.userId!,
|
||||
);
|
||||
sendApiResponse<PlatformBrowseHistoryResponse>(response, {
|
||||
entries: [],
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
router.post(
|
||||
'/llm/chat/completions',
|
||||
routeMeta({ operation: 'runtime.llm.chatCompletionsProxy' }),
|
||||
@@ -150,6 +328,30 @@ export function createRuntimeRoutes(context: AppContext) {
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/custom-world/entity',
|
||||
routeMeta({ operation: 'runtime.customWorld.entity' }),
|
||||
handleCustomWorldEntityGeneration,
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/custom-world/entity',
|
||||
routeMeta({ operation: 'runtime.customWorld.entity.compat' }),
|
||||
handleCustomWorldEntityGeneration,
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/custom-world/scene-npc',
|
||||
routeMeta({ operation: 'runtime.customWorld.sceneNpc' }),
|
||||
handleCustomWorldSceneNpcGeneration,
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/runtime/custom-world/scene-npc',
|
||||
routeMeta({ operation: 'runtime.customWorld.sceneNpc.compat' }),
|
||||
handleCustomWorldSceneNpcGeneration,
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/runtime/save/snapshot',
|
||||
routeMeta({ operation: 'runtime.snapshot.get' }),
|
||||
@@ -237,14 +439,11 @@ export function createRuntimeRoutes(context: AppContext) {
|
||||
'/runtime/custom-world-library',
|
||||
routeMeta({ operation: 'runtime.customWorldLibrary.list' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
sendApiResponse(
|
||||
response,
|
||||
{
|
||||
entries: await context.runtimeRepository.listCustomWorldProfiles(
|
||||
request.userId!,
|
||||
),
|
||||
} satisfies CustomWorldLibraryResponse,
|
||||
);
|
||||
sendApiResponse(response, {
|
||||
entries: await context.runtimeRepository.listCustomWorldProfiles(
|
||||
request.userId!,
|
||||
),
|
||||
} satisfies CustomWorldLibraryResponse);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -252,12 +451,10 @@ export function createRuntimeRoutes(context: AppContext) {
|
||||
'/runtime/custom-world-gallery',
|
||||
routeMeta({ operation: 'runtime.customWorldGallery.list' }),
|
||||
asyncHandler(async (_request, response) => {
|
||||
sendApiResponse(
|
||||
response,
|
||||
{
|
||||
entries: await context.runtimeRepository.listPublishedCustomWorldGallery(),
|
||||
} satisfies CustomWorldGalleryResponse,
|
||||
);
|
||||
sendApiResponse(response, {
|
||||
entries:
|
||||
await context.runtimeRepository.listPublishedCustomWorldGallery(),
|
||||
} satisfies CustomWorldGalleryResponse);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -280,12 +477,9 @@ export function createRuntimeRoutes(context: AppContext) {
|
||||
throw notFound('public custom world not found');
|
||||
}
|
||||
|
||||
sendApiResponse(
|
||||
response,
|
||||
{
|
||||
entry,
|
||||
} satisfies CustomWorldGalleryDetailResponse,
|
||||
);
|
||||
sendApiResponse(response, {
|
||||
entry,
|
||||
} satisfies CustomWorldGalleryDetailResponse);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -322,15 +516,12 @@ export function createRuntimeRoutes(context: AppContext) {
|
||||
if (!profileId) {
|
||||
throw badRequest('profileId is required');
|
||||
}
|
||||
sendApiResponse(
|
||||
response,
|
||||
{
|
||||
entries: await context.runtimeRepository.deleteCustomWorldProfile(
|
||||
request.userId!,
|
||||
profileId,
|
||||
),
|
||||
} satisfies CustomWorldLibraryResponse,
|
||||
);
|
||||
sendApiResponse(response, {
|
||||
entries: await context.runtimeRepository.deleteCustomWorldProfile(
|
||||
request.userId!,
|
||||
profileId,
|
||||
),
|
||||
} satisfies CustomWorldLibraryResponse);
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
1050
server-node/src/services/customWorldEntityGenerationService.ts
Normal file
1050
server-node/src/services/customWorldEntityGenerationService.ts
Normal file
File diff suppressed because it is too large
Load Diff
586
server-node/src/services/customWorldSceneNpcGenerationService.ts
Normal file
586
server-node/src/services/customWorldSceneNpcGenerationService.ts
Normal file
@@ -0,0 +1,586 @@
|
||||
import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js';
|
||||
import { badRequest } from '../errors.js';
|
||||
import type { UpstreamLlmClient } from './llmClient.js';
|
||||
|
||||
type SceneNpcGenerationInput = {
|
||||
profile: Record<string, unknown>;
|
||||
landmarkId: string;
|
||||
};
|
||||
|
||||
type ParsedStoryNpc = {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
role: string;
|
||||
description: string;
|
||||
personality: string;
|
||||
motivation: string;
|
||||
relationshipHooks: string[];
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
type ParsedLandmark = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
dangerLevel: string;
|
||||
sceneNpcIds: string[];
|
||||
};
|
||||
|
||||
type ParsedProfile = {
|
||||
name: string;
|
||||
settingText: string;
|
||||
storyNpcs: ParsedStoryNpc[];
|
||||
landmarks: ParsedLandmark[];
|
||||
};
|
||||
|
||||
type GeneratedNpcDraft = {
|
||||
name: string;
|
||||
title: string;
|
||||
role: string;
|
||||
description: string;
|
||||
backstory: string;
|
||||
personality: string;
|
||||
motivation: string;
|
||||
combatStyle: string;
|
||||
initialAffinity: number;
|
||||
relationshipHooks: string[];
|
||||
tags: string[];
|
||||
publicSummary: string;
|
||||
chapterTeasers: string[];
|
||||
chapterContents: string[];
|
||||
skills: Array<{
|
||||
name: string;
|
||||
summary: string;
|
||||
style: string;
|
||||
}>;
|
||||
initialItems: Array<{
|
||||
name: string;
|
||||
category: string;
|
||||
quantity: number;
|
||||
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
|
||||
description: string;
|
||||
tags: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
function toRecord(value: unknown) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function toText(value: unknown, fallback = '') {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function toStringArray(value: unknown, maxCount = 12) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.map((item) => toText(item))
|
||||
.filter(Boolean)
|
||||
.slice(0, maxCount);
|
||||
}
|
||||
|
||||
function clampText(value: string, maxLength: number) {
|
||||
const normalized = value.replace(/\s+/gu, ' ').trim();
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
if (normalized.length <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`;
|
||||
}
|
||||
|
||||
function slugify(value: string) {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5]+/gu, '-')
|
||||
.replace(/^-+|-+$/gu, '');
|
||||
|
||||
return normalized || 'entry';
|
||||
}
|
||||
|
||||
function createStableId(prefix: string, label: string, seed: string) {
|
||||
return `${prefix}-${slugify(label || prefix)}-${seed}`;
|
||||
}
|
||||
|
||||
function dedupeStrings(values: string[], maxCount = 8) {
|
||||
return [...new Set(values.map((value) => value.trim()).filter(Boolean))].slice(
|
||||
0,
|
||||
maxCount,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeStoryNpc(value: unknown): ParsedStoryNpc | null {
|
||||
const record = toRecord(value);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = toText(record.id);
|
||||
const name = toText(record.name);
|
||||
if (!id || !name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = toText(record.title);
|
||||
const role = toText(record.role, title || '场景角色');
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
title: title || role || '场景角色',
|
||||
role,
|
||||
description: toText(record.description),
|
||||
personality: toText(record.personality),
|
||||
motivation: toText(record.motivation),
|
||||
relationshipHooks: toStringArray(record.relationshipHooks, 6),
|
||||
tags: toStringArray(record.tags, 8),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLandmark(value: unknown): ParsedLandmark | null {
|
||||
const record = toRecord(value);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = toText(record.id);
|
||||
const name = toText(record.name);
|
||||
if (!id || !name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description: toText(record.description),
|
||||
dangerLevel: toText(record.dangerLevel, '中'),
|
||||
sceneNpcIds: toStringArray(record.sceneNpcIds, 12),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProfile(value: unknown): ParsedProfile {
|
||||
const record = toRecord(value);
|
||||
if (!record) {
|
||||
throw badRequest('profile is required');
|
||||
}
|
||||
|
||||
const storyNpcs = Array.isArray(record.storyNpcs)
|
||||
? record.storyNpcs.map(normalizeStoryNpc).filter((item): item is ParsedStoryNpc => item !== null)
|
||||
: [];
|
||||
const landmarks = Array.isArray(record.landmarks)
|
||||
? record.landmarks.map(normalizeLandmark).filter((item): item is ParsedLandmark => item !== null)
|
||||
: [];
|
||||
|
||||
return {
|
||||
name: toText(record.name, '自定义世界'),
|
||||
settingText: toText(record.settingText),
|
||||
storyNpcs,
|
||||
landmarks,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureUniqueName(name: string, existingNames: string[]) {
|
||||
const normalizedName = name.trim() || '新场景角色';
|
||||
if (!existingNames.includes(normalizedName)) {
|
||||
return normalizedName;
|
||||
}
|
||||
|
||||
let index = 2;
|
||||
let nextName = `${normalizedName}${index}`;
|
||||
while (existingNames.includes(nextName)) {
|
||||
index += 1;
|
||||
nextName = `${normalizedName}${index}`;
|
||||
}
|
||||
return nextName;
|
||||
}
|
||||
|
||||
function buildFallbackDraft(
|
||||
profile: ParsedProfile,
|
||||
landmark: ParsedLandmark,
|
||||
sceneNpcs: ParsedStoryNpc[],
|
||||
): GeneratedNpcDraft {
|
||||
const tags = dedupeStrings([
|
||||
landmark.name,
|
||||
landmark.dangerLevel,
|
||||
...sceneNpcs.flatMap((npc) => npc.tags),
|
||||
], 4);
|
||||
|
||||
return {
|
||||
name: `${landmark.name}来客`,
|
||||
title: `${landmark.name}的观察者`,
|
||||
role: `${landmark.name}的观察者`,
|
||||
description: `长期活动于${landmark.name},熟悉这里的局势与暗线,能为玩家提供新的观察角度。`,
|
||||
backstory: `他在${landmark.name}扎根已久,对这片区域的危险节奏、人物流动与隐藏冲突有自己的判断。`,
|
||||
personality: '谨慎、敏锐,先观察再表态。',
|
||||
motivation: `希望借玩家之手改变${landmark.name}当前逐渐失衡的局面。`,
|
||||
combatStyle: '偏向控场与试探,不轻易暴露底牌。',
|
||||
initialAffinity: 6,
|
||||
relationshipHooks: dedupeStrings([
|
||||
`与${landmark.name}局势深度绑定`,
|
||||
sceneNpcs[0] ? `对${sceneNpcs[0].name}保持长期观察` : '对玩家保持试探',
|
||||
'愿意交换情报,但保留关键秘密',
|
||||
], 3),
|
||||
tags,
|
||||
publicSummary: `一名活跃于${landmark.name}的关键观察者。`,
|
||||
chapterTeasers: [
|
||||
'他知道这片区域最近正在发生什么。',
|
||||
'他与此地某个旧事件有直接牵连。',
|
||||
'他真正想推动的局面并不只是自保。',
|
||||
'他手里握有改变关系网的最后筹码。',
|
||||
],
|
||||
chapterContents: [
|
||||
`他常年在${landmark.name}周边活动,对人和事的变化极为敏感。`,
|
||||
`多年前的一次变故把他和${landmark.name}牢牢绑在了一起。`,
|
||||
`他表面克制,实际上一直在寻找扭转局面的机会。`,
|
||||
'他保留着一张只会在局势逼近临界点时才动用的底牌。',
|
||||
],
|
||||
skills: [
|
||||
{
|
||||
name: '试探起手',
|
||||
summary: '以低风险方式摸清对手意图。',
|
||||
style: '试探压制',
|
||||
},
|
||||
{
|
||||
name: '地形借势',
|
||||
summary: `借助${landmark.name}环境制造主动权。`,
|
||||
style: '环境协同',
|
||||
},
|
||||
{
|
||||
name: '暗线反制',
|
||||
summary: '在关键回合揭示隐藏准备,打乱对方节奏。',
|
||||
style: '后手翻盘',
|
||||
},
|
||||
],
|
||||
initialItems: [
|
||||
{
|
||||
name: '随身兵装',
|
||||
category: '武器',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '常备的近身防护装备。',
|
||||
tags: ['自定义', landmark.name],
|
||||
},
|
||||
{
|
||||
name: '区域通行物',
|
||||
category: '道具',
|
||||
quantity: 1,
|
||||
rarity: 'uncommon',
|
||||
description: `能在${landmark.name}一带快速周转的私人物件。`,
|
||||
tags: ['自定义'],
|
||||
},
|
||||
{
|
||||
name: '情报残页',
|
||||
category: '专属物品',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '记录着部分隐藏线索与往事片段。',
|
||||
tags: ['线索'],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildPrompt(
|
||||
profile: ParsedProfile,
|
||||
landmark: ParsedLandmark,
|
||||
sceneNpcs: ParsedStoryNpc[],
|
||||
otherNpcs: ParsedStoryNpc[],
|
||||
) {
|
||||
const sceneNpcSummary = sceneNpcs.length
|
||||
? sceneNpcs
|
||||
.map(
|
||||
(npc, index) =>
|
||||
`${index + 1}. ${npc.name} / ${npc.title || npc.role} / ${npc.description || '无描述'} / 性格:${npc.personality || '未写'} / 动机:${npc.motivation || '未写'}`,
|
||||
)
|
||||
.join('\n')
|
||||
: '当前场景还没有已加入 NPC。';
|
||||
|
||||
const reserveNpcSummary = otherNpcs.length
|
||||
? otherNpcs
|
||||
.slice(0, 8)
|
||||
.map(
|
||||
(npc, index) =>
|
||||
`${index + 1}. ${npc.name} / ${npc.title || npc.role} / ${npc.description || '无描述'}`,
|
||||
)
|
||||
.join('\n')
|
||||
: '暂无其他场景角色参考。';
|
||||
|
||||
const landmarkSummary = profile.landmarks
|
||||
.slice(0, 10)
|
||||
.map(
|
||||
(entry, index) =>
|
||||
`${index + 1}. ${entry.name} / 危险度:${entry.dangerLevel || '中'} / ${entry.description || '无描述'}`,
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
return [
|
||||
`世界名:${profile.name}`,
|
||||
`世界设定:${profile.settingText || '未提供额外设定文本。'}`,
|
||||
`当前目标场景:${landmark.name}`,
|
||||
`场景描述:${landmark.description || '未填写'}`,
|
||||
`危险度:${landmark.dangerLevel || '中'}`,
|
||||
`当前场景已加入 NPC:\n${sceneNpcSummary}`,
|
||||
`其他可参考 NPC:\n${reserveNpcSummary}`,
|
||||
`世界内其他场景概览:\n${landmarkSummary}`,
|
||||
'请生成 1 名适合加入当前场景的新 NPC。',
|
||||
'要求:',
|
||||
'- 必须与当前场景气质、危险度、已有 NPC 分工互补,不要和已有 NPC 重复。',
|
||||
'- 角色要像真正可落地到游戏里的场景角色,不要写成抽象设定。',
|
||||
'- 关系钩子、技能、初始物品都要可直接进入编辑器。',
|
||||
'- 返回 JSON,不要额外解释。',
|
||||
'JSON 结构:',
|
||||
'{',
|
||||
' "npc": {',
|
||||
' "name": "角色名",',
|
||||
' "title": "头衔",',
|
||||
' "role": "身份",',
|
||||
' "description": "一句到两句角色描述",',
|
||||
' "backstory": "背景",',
|
||||
' "personality": "性格",',
|
||||
' "motivation": "动机",',
|
||||
' "combatStyle": "战斗风格",',
|
||||
' "initialAffinity": 6,',
|
||||
' "relationshipHooks": ["关系钩子1", "关系钩子2", "关系钩子3"],',
|
||||
' "tags": ["标签1", "标签2", "标签3"],',
|
||||
' "publicSummary": "公开背景摘要",',
|
||||
' "chapterTeasers": ["表层来意", "旧事裂痕", "隐藏执念", "最终底牌"],',
|
||||
' "chapterContents": ["对应正文1", "对应正文2", "对应正文3", "对应正文4"],',
|
||||
' "skills": [',
|
||||
' { "name": "技能1", "summary": "说明", "style": "风格" },',
|
||||
' { "name": "技能2", "summary": "说明", "style": "风格" },',
|
||||
' { "name": "技能3", "summary": "说明", "style": "风格" }',
|
||||
' ],',
|
||||
' "initialItems": [',
|
||||
' { "name": "物品1", "category": "分类", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },',
|
||||
' { "name": "物品2", "category": "分类", "quantity": 1, "rarity": "uncommon", "description": "说明", "tags": ["标签"] },',
|
||||
' { "name": "物品3", "category": "分类", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] }',
|
||||
' ]',
|
||||
' }',
|
||||
'}',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function sanitizeGeneratedNpc(
|
||||
rawValue: unknown,
|
||||
profile: ParsedProfile,
|
||||
landmark: ParsedLandmark,
|
||||
fallbackDraft: GeneratedNpcDraft,
|
||||
) {
|
||||
const record = toRecord(rawValue);
|
||||
const existingNames = profile.storyNpcs.map((npc) => npc.name);
|
||||
const seed = Date.now().toString(36);
|
||||
const chapterTitles = ['表层来意', '旧事裂痕', '隐藏执念', '最终底牌'];
|
||||
const chapterThresholds = [6, 12, 18, 24];
|
||||
const relationshipHooks = dedupeStrings(
|
||||
toStringArray(record?.relationshipHooks, 6).concat(
|
||||
fallbackDraft.relationshipHooks,
|
||||
),
|
||||
4,
|
||||
);
|
||||
const tags = dedupeStrings(
|
||||
toStringArray(record?.tags, 8).concat(fallbackDraft.tags, landmark.name),
|
||||
6,
|
||||
);
|
||||
const chapterTeasers = toStringArray(record?.chapterTeasers, 4);
|
||||
const chapterContents = toStringArray(record?.chapterContents, 4);
|
||||
const skillRecords = Array.isArray(record?.skills) ? record?.skills : [];
|
||||
const itemRecords = Array.isArray(record?.initialItems) ? record?.initialItems : [];
|
||||
|
||||
const draft: GeneratedNpcDraft = {
|
||||
name: ensureUniqueName(
|
||||
toText(record?.name, fallbackDraft.name),
|
||||
existingNames,
|
||||
),
|
||||
title: toText(record?.title, fallbackDraft.title),
|
||||
role: toText(record?.role, toText(record?.title, fallbackDraft.role)),
|
||||
description: clampText(
|
||||
toText(record?.description, fallbackDraft.description),
|
||||
120,
|
||||
),
|
||||
backstory: clampText(toText(record?.backstory, fallbackDraft.backstory), 260),
|
||||
personality: clampText(
|
||||
toText(record?.personality, fallbackDraft.personality),
|
||||
100,
|
||||
),
|
||||
motivation: clampText(
|
||||
toText(record?.motivation, fallbackDraft.motivation),
|
||||
120,
|
||||
),
|
||||
combatStyle: clampText(
|
||||
toText(record?.combatStyle, fallbackDraft.combatStyle),
|
||||
100,
|
||||
),
|
||||
initialAffinity:
|
||||
typeof record?.initialAffinity === 'number' &&
|
||||
Number.isFinite(record.initialAffinity)
|
||||
? Math.min(12, Math.max(1, Math.round(record.initialAffinity)))
|
||||
: fallbackDraft.initialAffinity,
|
||||
relationshipHooks,
|
||||
tags,
|
||||
publicSummary: clampText(
|
||||
toText(record?.publicSummary, fallbackDraft.publicSummary),
|
||||
120,
|
||||
),
|
||||
chapterTeasers:
|
||||
chapterTeasers.length === 4
|
||||
? chapterTeasers
|
||||
: fallbackDraft.chapterTeasers.slice(0, 4),
|
||||
chapterContents:
|
||||
chapterContents.length === 4
|
||||
? chapterContents
|
||||
: fallbackDraft.chapterContents.slice(0, 4),
|
||||
skills:
|
||||
skillRecords.length >= 3
|
||||
? skillRecords.slice(0, 3).map((skill, index) => {
|
||||
const skillRecord = toRecord(skill);
|
||||
const fallbackSkill =
|
||||
fallbackDraft.skills[index] ?? fallbackDraft.skills[0];
|
||||
return {
|
||||
name: clampText(
|
||||
toText(skillRecord?.name, fallbackSkill?.name || `技能${index + 1}`),
|
||||
20,
|
||||
),
|
||||
summary: clampText(
|
||||
toText(skillRecord?.summary, fallbackSkill?.summary || ''),
|
||||
60,
|
||||
),
|
||||
style: clampText(
|
||||
toText(skillRecord?.style, fallbackSkill?.style || ''),
|
||||
20,
|
||||
),
|
||||
};
|
||||
})
|
||||
: fallbackDraft.skills,
|
||||
initialItems:
|
||||
itemRecords.length >= 3
|
||||
? itemRecords.slice(0, 3).map((item, index) => {
|
||||
const itemRecord = toRecord(item);
|
||||
const fallbackItem =
|
||||
fallbackDraft.initialItems[index] ?? fallbackDraft.initialItems[0];
|
||||
const rarity = toText(itemRecord?.rarity, fallbackItem?.rarity || 'rare');
|
||||
return {
|
||||
name: clampText(
|
||||
toText(itemRecord?.name, fallbackItem?.name || `物品${index + 1}`),
|
||||
20,
|
||||
),
|
||||
category: clampText(
|
||||
toText(itemRecord?.category, fallbackItem?.category || '道具'),
|
||||
16,
|
||||
),
|
||||
quantity:
|
||||
typeof itemRecord?.quantity === 'number' &&
|
||||
Number.isFinite(itemRecord.quantity)
|
||||
? Math.min(9, Math.max(1, Math.round(itemRecord.quantity)))
|
||||
: fallbackItem?.quantity || 1,
|
||||
rarity:
|
||||
rarity === 'common' ||
|
||||
rarity === 'uncommon' ||
|
||||
rarity === 'rare' ||
|
||||
rarity === 'epic' ||
|
||||
rarity === 'legendary'
|
||||
? rarity
|
||||
: fallbackItem?.rarity || 'rare',
|
||||
description: clampText(
|
||||
toText(itemRecord?.description, fallbackItem?.description || ''),
|
||||
80,
|
||||
),
|
||||
tags: dedupeStrings(
|
||||
toStringArray(itemRecord?.tags, 4).concat(
|
||||
fallbackItem?.tags ?? [],
|
||||
),
|
||||
4,
|
||||
),
|
||||
};
|
||||
})
|
||||
: fallbackDraft.initialItems,
|
||||
};
|
||||
|
||||
return {
|
||||
id: createStableId('story-npc', draft.name, seed),
|
||||
name: draft.name,
|
||||
title: draft.title || draft.role,
|
||||
role: draft.role || draft.title,
|
||||
description: draft.description,
|
||||
backstory: draft.backstory,
|
||||
personality: draft.personality,
|
||||
motivation: draft.motivation,
|
||||
combatStyle: draft.combatStyle,
|
||||
initialAffinity: draft.initialAffinity,
|
||||
relationshipHooks: draft.relationshipHooks,
|
||||
relations: [],
|
||||
tags: draft.tags,
|
||||
backstoryReveal: {
|
||||
publicSummary: draft.publicSummary,
|
||||
chapters: chapterTitles.map((title, index) => ({
|
||||
id: ['surface', 'scar', 'hidden', 'final'][index],
|
||||
title,
|
||||
affinityRequired: chapterThresholds[index],
|
||||
teaser:
|
||||
draft.chapterTeasers[index] ?? fallbackDraft.chapterTeasers[index] ?? '',
|
||||
content:
|
||||
draft.chapterContents[index] ??
|
||||
fallbackDraft.chapterContents[index] ??
|
||||
'',
|
||||
contextSnippet: '',
|
||||
})),
|
||||
},
|
||||
skills: draft.skills.map((skill, index) => ({
|
||||
id: createStableId('skill', `${draft.name}-${skill.name}`, `${seed}-${index + 1}`),
|
||||
name: skill.name,
|
||||
summary: skill.summary,
|
||||
style: skill.style,
|
||||
})),
|
||||
initialItems: draft.initialItems.map((item, index) => ({
|
||||
id: createStableId('item', `${draft.name}-${item.name}`, `${seed}-${index + 1}`),
|
||||
name: item.name,
|
||||
category: item.category,
|
||||
quantity: item.quantity,
|
||||
rarity: item.rarity,
|
||||
description: item.description,
|
||||
tags: item.tags,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateSceneNpcForLandmark(
|
||||
llmClient: UpstreamLlmClient,
|
||||
input: SceneNpcGenerationInput,
|
||||
) {
|
||||
const profile = normalizeProfile(input.profile);
|
||||
const landmark = profile.landmarks.find((entry) => entry.id === input.landmarkId);
|
||||
if (!landmark) {
|
||||
throw badRequest('landmark not found');
|
||||
}
|
||||
|
||||
const storyNpcById = new Map(profile.storyNpcs.map((npc) => [npc.id, npc]));
|
||||
const sceneNpcs = landmark.sceneNpcIds
|
||||
.map((npcId) => storyNpcById.get(npcId))
|
||||
.filter((npc): npc is ParsedStoryNpc => Boolean(npc));
|
||||
const otherNpcs = profile.storyNpcs.filter(
|
||||
(npc) => !landmark.sceneNpcIds.includes(npc.id),
|
||||
);
|
||||
const fallbackDraft = buildFallbackDraft(profile, landmark, sceneNpcs);
|
||||
|
||||
try {
|
||||
const content = await llmClient.requestMessageContent({
|
||||
systemPrompt:
|
||||
'你是游戏世界编辑器的角色生成器。你必须只返回可解析 JSON,不要输出解释、前言或 markdown 代码块之外的额外内容。',
|
||||
userPrompt: buildPrompt(profile, landmark, sceneNpcs, otherNpcs),
|
||||
debugLabel: 'custom-world-scene-npc',
|
||||
});
|
||||
const parsed = parseJsonResponseText(content);
|
||||
const parsedRecord = toRecord(parsed);
|
||||
const npcRecord = parsedRecord?.npc ?? parsed;
|
||||
return sanitizeGeneratedNpc(npcRecord, profile, landmark, fallbackDraft);
|
||||
} catch {
|
||||
return sanitizeGeneratedNpc(fallbackDraft, profile, landmark, fallbackDraft);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { AppContext } from '../context.js';
|
||||
import { type AppConfig } from '../config.js';
|
||||
import type { AppContext } from '../context.js';
|
||||
import { generateSceneImage } from './sceneImageService.js';
|
||||
|
||||
const PNG_BUFFER = Buffer.from(
|
||||
@@ -24,7 +24,7 @@ function createTestConfig(
|
||||
dashScope: {
|
||||
baseUrl: dashScopeBaseUrl,
|
||||
apiKey: 'test-dashscope-key',
|
||||
imageModel: 'wan2.7-image',
|
||||
imageModel: 'wan2.2-t2i-flash',
|
||||
requestTimeoutMs: 5_000,
|
||||
},
|
||||
} as AppConfig;
|
||||
@@ -92,11 +92,8 @@ async function withHttpServer<T>(
|
||||
}
|
||||
}
|
||||
|
||||
test('generateSceneImage uploads a public reference image as a data url and saves the generated scene', async () => {
|
||||
test('generateSceneImage uses wan2.2-t2i-flash text-to-image payload and saves the generated scene', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-scene-image-'));
|
||||
const publicDir = path.join(tempRoot, 'public');
|
||||
fs.mkdirSync(path.join(publicDir, 'scene_bg'), { recursive: true });
|
||||
fs.writeFileSync(path.join(publicDir, 'scene_bg', 'reference-layout.png'), PNG_BUFFER);
|
||||
|
||||
const capturedRequests: Array<{
|
||||
pathname: string;
|
||||
@@ -164,7 +161,6 @@ test('generateSceneImage uploads a public reference image as a data url and save
|
||||
profileId: 'world-1',
|
||||
landmarkName: '旧港灯塔',
|
||||
landmarkId: 'landmark-1',
|
||||
referenceImageSrc: '/scene_bg/reference-layout.png',
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
@@ -177,6 +173,7 @@ test('generateSceneImage uploads a public reference image as a data url and save
|
||||
assert.ok(createRequest?.bodyText);
|
||||
|
||||
const createPayload = JSON.parse(createRequest.bodyText) as {
|
||||
model: string;
|
||||
input: {
|
||||
messages: Array<{
|
||||
content: Array<{ text?: string; image?: string }>;
|
||||
@@ -188,8 +185,9 @@ test('generateSceneImage uploads a public reference image as a data url and save
|
||||
};
|
||||
|
||||
const content = createPayload.input.messages[0]?.content ?? [];
|
||||
assert.equal(createPayload.model, 'wan2.2-t2i-flash');
|
||||
assert.equal(content[0]?.text, '海雾港口像素风场景');
|
||||
assert.match(content[1]?.image ?? '', /^data:image\/png;base64,/u);
|
||||
assert.equal(content.length, 1);
|
||||
assert.equal(createPayload.parameters.negative_prompt, '模糊');
|
||||
|
||||
const savedImagePath = path.join(tempRoot, 'public', result.imageSrc.slice(1));
|
||||
@@ -197,3 +195,105 @@ test('generateSceneImage uploads a public reference image as a data url and save
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('generateSceneImage uses qwen-image-2.0 edit flow when a reference image is provided', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-scene-image-'));
|
||||
const publicDir = path.join(tempRoot, 'public');
|
||||
fs.mkdirSync(path.join(publicDir, 'scene_bg'), { recursive: true });
|
||||
fs.writeFileSync(path.join(publicDir, 'scene_bg', 'reference-layout.png'), PNG_BUFFER);
|
||||
|
||||
const capturedRequests: Array<{
|
||||
pathname: string;
|
||||
bodyText?: string;
|
||||
}> = [];
|
||||
|
||||
await withHttpServer(
|
||||
(baseUrl) => async (req, res) => {
|
||||
const url = new URL(req.url || '/', baseUrl);
|
||||
const bodyText =
|
||||
req.method === 'POST' ? (await readRequestBody(req)).toString('utf8') : undefined;
|
||||
capturedRequests.push({
|
||||
pathname: url.pathname,
|
||||
bodyText,
|
||||
});
|
||||
|
||||
if (
|
||||
req.method === 'POST' &&
|
||||
url.pathname === '/api/v1/services/aigc/multimodal-generation/generation'
|
||||
) {
|
||||
sendJson(res, {
|
||||
output: {
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
image: `${baseUrl}/downloads/reference-scene.png`,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/downloads/reference-scene.png') {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
res.end(PNG_BUFFER);
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 404;
|
||||
res.end('not found');
|
||||
},
|
||||
async (dashScopeBaseUrl) => {
|
||||
const context = {
|
||||
config: createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`),
|
||||
} as AppContext;
|
||||
|
||||
const result = await generateSceneImage(context, {
|
||||
prompt: '废墟月台像素风场景',
|
||||
negativePrompt: '模糊',
|
||||
size: '1280*720',
|
||||
worldName: '碎轨边境',
|
||||
profileId: 'world-2',
|
||||
landmarkName: '裂轨月台',
|
||||
landmarkId: 'landmark-2',
|
||||
referenceImageSrc: '/scene_bg/reference-layout.png',
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.model, 'qwen-image-2.0');
|
||||
assert.match(result.taskId, /^scene-edit-/u);
|
||||
assert.equal(
|
||||
capturedRequests.some(
|
||||
(entry) => entry.pathname === '/api/v1/tasks/scene-task-1',
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
const createRequest = capturedRequests.find(
|
||||
(entry) =>
|
||||
entry.pathname === '/api/v1/services/aigc/multimodal-generation/generation',
|
||||
);
|
||||
assert.ok(createRequest?.bodyText);
|
||||
|
||||
const createPayload = JSON.parse(createRequest.bodyText) as {
|
||||
model: string;
|
||||
input: {
|
||||
messages: Array<{
|
||||
content: Array<{ text?: string; image?: string }>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
const content = createPayload.input.messages[0]?.content ?? [];
|
||||
assert.equal(createPayload.model, 'qwen-image-2.0');
|
||||
assert.match(content[0]?.image ?? '', /^data:image\/png;base64,/u);
|
||||
assert.equal(content[1]?.text, '废墟月台像素风场景');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -19,6 +19,8 @@ export const sceneImageSchema = z.object({
|
||||
landmarkId: z.string().trim().optional().default(''),
|
||||
referenceImageSrc: z.string().trim().optional().default(''),
|
||||
});
|
||||
const TEXT_TO_IMAGE_SCENE_MODEL = 'wan2.2-t2i-flash';
|
||||
const REFERENCE_IMAGE_SCENE_MODEL = 'qwen-image-2.0';
|
||||
|
||||
function parseImageDataUrl(source: string) {
|
||||
const matched = /^data:(image\/[^;]+);base64,(.+)$/u.exec(source);
|
||||
@@ -122,40 +124,72 @@ function extractImageUrls(payload: Record<string, unknown>) {
|
||||
return [...new Set(urls)];
|
||||
}
|
||||
|
||||
function ensurePayload(
|
||||
payload: z.infer<typeof sceneImageSchema>,
|
||||
defaultModel: string,
|
||||
) {
|
||||
if (!payload.landmarkName && !payload.landmarkId) {
|
||||
throw badRequest('landmarkName 或 landmarkId 至少要提供一个');
|
||||
async function createSceneImageTask(params: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
payload: z.infer<typeof sceneImageSchema>;
|
||||
}) {
|
||||
const { baseUrl, apiKey, payload } = params;
|
||||
const response = await fetch(`${baseUrl}/services/aigc/image-generation/generation`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'X-DashScope-Async': 'enable',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: payload.model,
|
||||
input: {
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ text: payload.prompt }],
|
||||
},
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
n: 1,
|
||||
size: payload.size,
|
||||
prompt_extend: true,
|
||||
watermark: false,
|
||||
...(payload.negativePrompt
|
||||
? { negative_prompt: payload.negativePrompt }
|
||||
: {}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false as const,
|
||||
errorMessage: extractApiErrorMessage(
|
||||
responseText,
|
||||
'创建场景图片生成任务失败',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...payload,
|
||||
model: payload.model || defaultModel,
|
||||
ok: true as const,
|
||||
payload: JSON.parse(responseText) as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateSceneImage(
|
||||
context: AppContext,
|
||||
input: z.infer<typeof sceneImageSchema>,
|
||||
) {
|
||||
const payload = ensurePayload(input, context.config.dashScope.imageModel);
|
||||
const baseUrl = context.config.dashScope.baseUrl.replace(/\/+$/u, '');
|
||||
const referenceImage = payload.referenceImageSrc
|
||||
? await resolveReferenceImageAsDataUrl(
|
||||
context.config.projectRoot,
|
||||
payload.referenceImageSrc,
|
||||
)
|
||||
: '';
|
||||
const createResponse = await fetch(
|
||||
`${baseUrl}/services/aigc/image-generation/generation`,
|
||||
async function createSceneImageFromReference(params: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
payload: z.infer<typeof sceneImageSchema>;
|
||||
referenceImage: string;
|
||||
}) {
|
||||
const { baseUrl, apiKey, payload, referenceImage } = params;
|
||||
const response = await fetch(
|
||||
`${baseUrl}/services/aigc/multimodal-generation/generation`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${context.config.dashScope.apiKey}`,
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'X-DashScope-Async': 'enable',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: payload.model,
|
||||
@@ -163,10 +197,7 @@ export async function generateSceneImage(
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ text: payload.prompt },
|
||||
...(referenceImage ? [{ image: referenceImage }] : []),
|
||||
],
|
||||
content: [{ image: referenceImage }, { text: payload.prompt }],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -182,56 +213,65 @@ export async function generateSceneImage(
|
||||
}),
|
||||
},
|
||||
);
|
||||
const createText = await createResponse.text();
|
||||
if (!createResponse.ok) {
|
||||
throw badRequest(
|
||||
extractApiErrorMessage(createText, '创建场景图片生成任务失败'),
|
||||
);
|
||||
}
|
||||
|
||||
const createPayload = JSON.parse(createText) as Record<string, unknown>;
|
||||
const taskId = extractTaskId(createPayload);
|
||||
if (!taskId) {
|
||||
throw badRequest('场景图片生成任务未返回 task_id');
|
||||
}
|
||||
|
||||
const deadline = Date.now() + context.config.dashScope.requestTimeoutMs;
|
||||
let imageUrl = '';
|
||||
let actualPrompt = '';
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const pollResponse = await fetch(`${baseUrl}/tasks/${taskId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${context.config.dashScope.apiKey}`,
|
||||
},
|
||||
});
|
||||
const pollText = await pollResponse.text();
|
||||
if (!pollResponse.ok) {
|
||||
throw badRequest(
|
||||
extractApiErrorMessage(pollText, '查询场景图片任务失败'),
|
||||
);
|
||||
}
|
||||
|
||||
const pollPayload = JSON.parse(pollText) as Record<string, unknown>;
|
||||
const status = findFirstStringByKey(pollPayload, 'task_status').trim();
|
||||
if (status === 'SUCCEEDED') {
|
||||
imageUrl = extractImageUrls(pollPayload)[0] ?? '';
|
||||
actualPrompt = findFirstStringByKey(pollPayload, 'actual_prompt').trim();
|
||||
break;
|
||||
}
|
||||
if (status === 'FAILED' || status === 'UNKNOWN') {
|
||||
throw badRequest(
|
||||
extractApiErrorMessage(pollText, '场景图片生成任务失败'),
|
||||
);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false as const,
|
||||
errorMessage: extractApiErrorMessage(
|
||||
responseText,
|
||||
'创建参考图场景编辑任务失败',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const responsePayload = JSON.parse(responseText) as Record<string, unknown>;
|
||||
const imageUrl = extractImageUrls(responsePayload)[0] ?? '';
|
||||
if (!imageUrl) {
|
||||
throw badRequest('场景图片生成超时或未返回图片地址');
|
||||
return {
|
||||
ok: false as const,
|
||||
errorMessage: '参考图场景编辑未返回图片地址',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
imageUrl,
|
||||
actualPrompt: findFirstStringByKey(responsePayload, 'actual_prompt').trim(),
|
||||
taskId: `scene-edit-${Date.now()}`,
|
||||
};
|
||||
}
|
||||
|
||||
function ensurePayload(
|
||||
payload: z.infer<typeof sceneImageSchema>,
|
||||
_defaultModel: string,
|
||||
) {
|
||||
if (!payload.landmarkName && !payload.landmarkId) {
|
||||
throw badRequest('landmarkName 或 landmarkId 至少要提供一个');
|
||||
}
|
||||
|
||||
const referenceImageSrc =
|
||||
typeof payload.referenceImageSrc === 'string'
|
||||
? payload.referenceImageSrc.trim()
|
||||
: '';
|
||||
|
||||
return {
|
||||
...payload,
|
||||
referenceImageSrc,
|
||||
model: referenceImageSrc
|
||||
? REFERENCE_IMAGE_SCENE_MODEL
|
||||
: TEXT_TO_IMAGE_SCENE_MODEL,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveSceneImageAsset(params: {
|
||||
context: AppContext;
|
||||
payload: z.infer<typeof sceneImageSchema>;
|
||||
imageUrl: string;
|
||||
taskId: string;
|
||||
actualPrompt: string;
|
||||
}) {
|
||||
const { context, payload, imageUrl, taskId, actualPrompt } = params;
|
||||
const imageResponse = await fetch(imageUrl);
|
||||
if (!imageResponse.ok) {
|
||||
throw badRequest('下载生成图片失败');
|
||||
@@ -295,3 +335,99 @@ export async function generateSceneImage(
|
||||
actualPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateSceneImage(
|
||||
context: AppContext,
|
||||
input: z.infer<typeof sceneImageSchema>,
|
||||
) {
|
||||
const payload = ensurePayload(input, context.config.dashScope.imageModel);
|
||||
const baseUrl = context.config.dashScope.baseUrl.replace(/\/+$/u, '');
|
||||
const referenceImage = payload.referenceImageSrc.trim()
|
||||
? await resolveReferenceImageAsDataUrl(
|
||||
context.config.projectRoot,
|
||||
payload.referenceImageSrc,
|
||||
)
|
||||
: '';
|
||||
|
||||
if (referenceImage) {
|
||||
const referenceResult = await createSceneImageFromReference({
|
||||
baseUrl,
|
||||
apiKey: context.config.dashScope.apiKey,
|
||||
payload,
|
||||
referenceImage,
|
||||
});
|
||||
|
||||
if (!referenceResult.ok) {
|
||||
throw badRequest(referenceResult.errorMessage);
|
||||
}
|
||||
|
||||
return saveSceneImageAsset({
|
||||
context,
|
||||
payload,
|
||||
imageUrl: referenceResult.imageUrl,
|
||||
taskId: referenceResult.taskId,
|
||||
actualPrompt: referenceResult.actualPrompt,
|
||||
});
|
||||
}
|
||||
|
||||
const createTaskResult = await createSceneImageTask({
|
||||
baseUrl,
|
||||
apiKey: context.config.dashScope.apiKey,
|
||||
payload,
|
||||
});
|
||||
|
||||
if (!createTaskResult.ok) {
|
||||
throw badRequest(createTaskResult.errorMessage);
|
||||
}
|
||||
|
||||
const createPayload = createTaskResult.payload;
|
||||
const taskId = extractTaskId(createPayload);
|
||||
if (!taskId) {
|
||||
throw badRequest('场景图片生成任务未返回 task_id');
|
||||
}
|
||||
|
||||
const deadline = Date.now() + context.config.dashScope.requestTimeoutMs;
|
||||
let imageUrl = '';
|
||||
let actualPrompt = '';
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const pollResponse = await fetch(`${baseUrl}/tasks/${taskId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${context.config.dashScope.apiKey}`,
|
||||
},
|
||||
});
|
||||
const pollText = await pollResponse.text();
|
||||
if (!pollResponse.ok) {
|
||||
throw badRequest(
|
||||
extractApiErrorMessage(pollText, '查询场景图片任务失败'),
|
||||
);
|
||||
}
|
||||
|
||||
const pollPayload = JSON.parse(pollText) as Record<string, unknown>;
|
||||
const status = findFirstStringByKey(pollPayload, 'task_status').trim();
|
||||
if (status === 'SUCCEEDED') {
|
||||
imageUrl = extractImageUrls(pollPayload)[0] ?? '';
|
||||
actualPrompt = findFirstStringByKey(pollPayload, 'actual_prompt').trim();
|
||||
break;
|
||||
}
|
||||
if (status === 'FAILED' || status === 'UNKNOWN') {
|
||||
throw badRequest(
|
||||
extractApiErrorMessage(pollText, '场景图片生成任务失败'),
|
||||
);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
|
||||
if (!imageUrl) {
|
||||
throw badRequest('场景图片生成超时或未返回图片地址');
|
||||
}
|
||||
|
||||
return saveSceneImageAsset({
|
||||
context,
|
||||
payload,
|
||||
imageUrl,
|
||||
taskId,
|
||||
actualPrompt,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user