1
This commit is contained in:
@@ -12,6 +12,8 @@ import type { AppConfig } from './config.ts';
|
||||
import { prepareEventStreamResponse } from './http.ts';
|
||||
import { requestIdMiddleware } from './middleware/requestId.ts';
|
||||
import { createAppContext } from './server.ts';
|
||||
import { CustomWorldAgentOrchestrator } from './services/customWorldAgentOrchestrator.js';
|
||||
import { createTestCustomWorldAgentSingleTurnLlmClient } from './services/customWorldAgentTestHelpers.js';
|
||||
import { httpRequest, type TestRequestInit } from './testHttp.ts';
|
||||
|
||||
type TestConfigOverrides = Partial<
|
||||
@@ -29,6 +31,16 @@ type TestConfigOverrides = Partial<
|
||||
|
||||
type TestAppContext = Awaited<ReturnType<typeof createAppContext>>;
|
||||
|
||||
function installTestCustomWorldAgentSingleTurnLlm(context: TestAppContext) {
|
||||
context.customWorldAgentOrchestrator = new CustomWorldAgentOrchestrator(
|
||||
context.customWorldAgentSessions,
|
||||
null,
|
||||
{
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createTestConfig(
|
||||
testName: string,
|
||||
overrides: TestConfigOverrides = {},
|
||||
@@ -1698,6 +1710,37 @@ test('authenticated missing routes return unified not found errors', async () =>
|
||||
});
|
||||
});
|
||||
|
||||
test('public runtime assets are served from the app root', async () => {
|
||||
const publicDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'genarrative-public-static-'),
|
||||
);
|
||||
const generatedDir = path.join(
|
||||
publicDir,
|
||||
'generated-character-drafts',
|
||||
'test-npc',
|
||||
'visual',
|
||||
'visual-draft-1',
|
||||
);
|
||||
fs.mkdirSync(generatedDir, { recursive: true });
|
||||
const imagePath = path.join(generatedDir, 'candidate-01.png');
|
||||
fs.writeFileSync(imagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
||||
|
||||
await withTestServer(
|
||||
'public-runtime-assets',
|
||||
async ({ baseUrl }) => {
|
||||
const response = await httpRequest(
|
||||
`${baseUrl}/generated-character-drafts/test-npc/visual/visual-draft-1/candidate-01.png`,
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get('content-type'), 'image/png');
|
||||
},
|
||||
{
|
||||
publicDir,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('stream responses also carry api version and route metadata headers', async () => {
|
||||
const app = express();
|
||||
app.use(requestIdMiddleware);
|
||||
@@ -2146,6 +2189,129 @@ test('custom worlds stay private until published and then appear in the public g
|
||||
});
|
||||
});
|
||||
|
||||
test('deleting a custom world uses soft delete and hides the work from library and gallery', async () => {
|
||||
await withTestServer(
|
||||
'custom-world-soft-delete',
|
||||
async ({ baseUrl, context }) => {
|
||||
const owner = await authEntry(baseUrl, 'soft_delete_owner', 'secret123');
|
||||
const viewer = await authEntry(
|
||||
baseUrl,
|
||||
'soft_delete_viewer',
|
||||
'secret123',
|
||||
);
|
||||
|
||||
const upsertResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world-library/world-soft-delete`,
|
||||
withBearer(owner.token, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
profile: {
|
||||
id: 'world-soft-delete',
|
||||
name: '潮雾裂港',
|
||||
subtitle: '被旧航灯切开的海港',
|
||||
summary: '用于验证作品删除软删除逻辑的测试世界。',
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
landmarks: [],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(upsertResponse.status, 200);
|
||||
|
||||
const publishResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world-library/world-soft-delete/publish`,
|
||||
withBearer(owner.token, {
|
||||
method: 'POST',
|
||||
}),
|
||||
);
|
||||
assert.equal(publishResponse.status, 200);
|
||||
|
||||
const deleteResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world-library/world-soft-delete`,
|
||||
withBearer(owner.token, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
);
|
||||
const deletePayload = (await deleteResponse.json()) as {
|
||||
entries: Array<unknown>;
|
||||
};
|
||||
|
||||
assert.equal(deleteResponse.status, 200);
|
||||
assert.deepEqual(deletePayload.entries, []);
|
||||
|
||||
const ownerLibraryResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world-library`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${owner.token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const ownerLibraryPayload = (await ownerLibraryResponse.json()) as {
|
||||
entries: Array<unknown>;
|
||||
};
|
||||
|
||||
assert.equal(ownerLibraryResponse.status, 200);
|
||||
assert.deepEqual(ownerLibraryPayload.entries, []);
|
||||
|
||||
const galleryResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world-gallery`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${viewer.token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const galleryPayload = (await galleryResponse.json()) as {
|
||||
entries: Array<unknown>;
|
||||
};
|
||||
|
||||
assert.equal(galleryResponse.status, 200);
|
||||
assert.deepEqual(galleryPayload.entries, []);
|
||||
|
||||
const galleryDetailResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world-gallery/${encodeURIComponent(owner.user.id)}/${encodeURIComponent('world-soft-delete')}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${viewer.token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(galleryDetailResponse.status, 404);
|
||||
|
||||
const persistedRows = await context.db.query<{
|
||||
profileId: string;
|
||||
visibility: string;
|
||||
publishedAt: string | null;
|
||||
deletedAt: string | null;
|
||||
payload: {
|
||||
name?: string;
|
||||
};
|
||||
}>(
|
||||
`SELECT profile_id AS "profileId",
|
||||
visibility,
|
||||
published_at AS "publishedAt",
|
||||
deleted_at AS "deletedAt",
|
||||
payload_json AS payload
|
||||
FROM custom_world_profiles
|
||||
WHERE user_id = $1
|
||||
AND profile_id = $2`,
|
||||
[owner.user.id, 'world-soft-delete'],
|
||||
);
|
||||
|
||||
assert.equal(persistedRows.rows.length, 1);
|
||||
assert.equal(persistedRows.rows[0]?.profileId, 'world-soft-delete');
|
||||
assert.equal(persistedRows.rows[0]?.visibility, 'draft');
|
||||
assert.equal(persistedRows.rows[0]?.publishedAt, null);
|
||||
assert.ok(persistedRows.rows[0]?.deletedAt);
|
||||
assert.equal(persistedRows.rows[0]?.payload?.name, '潮雾裂港');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('custom world works endpoint returns draft sessions and published worlds together', async () => {
|
||||
await withTestServer('custom-world-works', async ({ baseUrl }) => {
|
||||
const entry = await authEntry(baseUrl, 'cw_works', 'secret123');
|
||||
@@ -2231,107 +2397,111 @@ test('custom world works endpoint returns draft sessions and published worlds to
|
||||
});
|
||||
|
||||
test('custom world agent session accepts messages and exposes completed operations', async () => {
|
||||
await withTestServer('custom-world-agent-messages', async ({ baseUrl }) => {
|
||||
const entry = await authEntry(baseUrl, 'cw_agent', 'secret123');
|
||||
await withTestServer(
|
||||
'custom-world-agent-messages',
|
||||
async ({ baseUrl, context }) => {
|
||||
installTestCustomWorldAgentSingleTurnLlm(context);
|
||||
const entry = await authEntry(baseUrl, 'cw_agent', 'secret123');
|
||||
|
||||
const createResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world/agent/sessions`,
|
||||
withBearer(entry.token, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
seedText: '一个围绕灯塔与沉船秘术的边境世界。',
|
||||
const createResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world/agent/sessions`,
|
||||
withBearer(entry.token, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
seedText: '一个围绕灯塔与沉船秘术的边境世界。',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const created = (await createResponse.json()) as {
|
||||
session: {
|
||||
sessionId: string;
|
||||
messages: Array<{ role: string }>;
|
||||
);
|
||||
const created = (await createResponse.json()) as {
|
||||
session: {
|
||||
sessionId: string;
|
||||
messages: Array<{ role: string }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(createResponse.status, 200);
|
||||
assert.equal(created.session.messages[0]?.role, 'assistant');
|
||||
assert.equal(createResponse.status, 200);
|
||||
assert.equal(created.session.messages[0]?.role, 'assistant');
|
||||
|
||||
const messageResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}/messages`,
|
||||
withBearer(entry.token, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
clientMessageId: 'client-1',
|
||||
text: '玩家是一个被迫回到故乡灯塔的失职守望者。',
|
||||
focusCardId: null,
|
||||
selectedCardIds: [],
|
||||
const messageResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}/messages`,
|
||||
withBearer(entry.token, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
clientMessageId: 'client-1',
|
||||
text: '玩家是一个被迫回到故乡灯塔的失职守望者。',
|
||||
focusCardId: null,
|
||||
selectedCardIds: [],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const messagePayload = (await messageResponse.json()) as {
|
||||
operation: {
|
||||
operationId: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
);
|
||||
const messagePayload = (await messageResponse.json()) as {
|
||||
operation: {
|
||||
operationId: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(messageResponse.status, 200);
|
||||
assert.equal(messagePayload.operation.status, 'queued');
|
||||
assert.equal(messagePayload.operation.progress, 10);
|
||||
assert.equal(messageResponse.status, 200);
|
||||
assert.equal(messagePayload.operation.status, 'queued');
|
||||
assert.equal(messagePayload.operation.progress, 10);
|
||||
|
||||
let operationText = '';
|
||||
let operationText = '';
|
||||
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
const operationResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}/operations/${encodeURIComponent(messagePayload.operation.operationId)}`,
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
const operationResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}/operations/${encodeURIComponent(messagePayload.operation.operationId)}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${entry.token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(operationResponse.status, 200);
|
||||
operationText = await operationResponse.text();
|
||||
|
||||
if (/"status":"completed"/u.test(operationText)) {
|
||||
break;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
|
||||
assert.match(operationText, /"status":"completed"/u);
|
||||
assert.match(operationText, /"progress":100/u);
|
||||
|
||||
const sessionResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${entry.token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(operationResponse.status, 200);
|
||||
operationText = await operationResponse.text();
|
||||
const sessionPayload = (await sessionResponse.json()) as {
|
||||
stage: string;
|
||||
creatorIntent: {
|
||||
playerPremise?: string | null;
|
||||
} | null;
|
||||
messages: Array<{ role: string; text: string }>;
|
||||
pendingClarifications: Array<{ question: string }>;
|
||||
};
|
||||
|
||||
if (/"status":"completed"/u.test(operationText)) {
|
||||
break;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
|
||||
assert.match(operationText, /"status":"completed"/u);
|
||||
assert.match(operationText, /"progress":100/u);
|
||||
|
||||
const sessionResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${entry.token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const sessionPayload = (await sessionResponse.json()) as {
|
||||
stage: string;
|
||||
creatorIntent: {
|
||||
playerPremise?: string | null;
|
||||
} | null;
|
||||
messages: Array<{ role: string; text: string }>;
|
||||
pendingClarifications: Array<{ question: string }>;
|
||||
};
|
||||
|
||||
assert.equal(sessionResponse.status, 200);
|
||||
assert.equal(sessionPayload.stage, 'clarifying');
|
||||
assert.ok(
|
||||
sessionPayload.messages.some((message) => message.role === 'user'),
|
||||
);
|
||||
assert.ok(
|
||||
sessionPayload.messages.some((message) => message.role === 'assistant'),
|
||||
);
|
||||
assert.match(
|
||||
sessionPayload.creatorIntent?.playerPremise ?? '',
|
||||
/玩家|守望者/u,
|
||||
);
|
||||
assert.ok(sessionPayload.pendingClarifications.length > 0);
|
||||
});
|
||||
assert.equal(sessionResponse.status, 200);
|
||||
assert.equal(sessionPayload.stage, 'clarifying');
|
||||
assert.ok(
|
||||
sessionPayload.messages.some((message) => message.role === 'user'),
|
||||
);
|
||||
assert.ok(
|
||||
sessionPayload.messages.some((message) => message.role === 'assistant'),
|
||||
);
|
||||
assert.match(
|
||||
sessionPayload.creatorIntent?.playerPremise ?? '',
|
||||
/玩家|守望者/u,
|
||||
);
|
||||
assert.ok(sessionPayload.pendingClarifications.length > 0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('custom world agent missing session returns 404', async () => {
|
||||
@@ -2433,7 +2603,8 @@ test('custom world agent operation can fail and expose failed status', async ()
|
||||
test('custom world agent draft_foundation action generates draft cards and card detail over http', async () => {
|
||||
await withTestServer(
|
||||
'custom-world-agent-phase3-http',
|
||||
async ({ baseUrl }) => {
|
||||
async ({ baseUrl, context }) => {
|
||||
installTestCustomWorldAgentSingleTurnLlm(context);
|
||||
const entry = await authEntry(baseUrl, 'cw_agent_phase3', 'secret123');
|
||||
const readySession = await createReadyCustomWorldAgentSession({
|
||||
baseUrl,
|
||||
@@ -2568,7 +2739,10 @@ test('custom world agent draft_foundation action rejects not-ready sessions over
|
||||
|
||||
assert.equal(actionResponse.status, 400);
|
||||
assert.equal(actionPayload.error.code, 'BAD_REQUEST');
|
||||
assert.match(actionPayload.error.message, /foundation_review|ready/u);
|
||||
assert.match(
|
||||
actionPayload.error.message,
|
||||
/progressPercent >= 100|draft_foundation/u,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -2577,6 +2751,7 @@ test('custom world agent update_draft_card action updates draft profile and card
|
||||
await withTestServer(
|
||||
'custom-world-agent-phase4-update-http',
|
||||
async ({ baseUrl, context }) => {
|
||||
installTestCustomWorldAgentSingleTurnLlm(context);
|
||||
const entry = await authEntry(
|
||||
baseUrl,
|
||||
'cw_agent_phase4_update',
|
||||
@@ -2718,6 +2893,7 @@ test('custom world agent generate_characters action appends character cards over
|
||||
await withTestServer(
|
||||
'custom-world-agent-phase4-generate-characters-http',
|
||||
async ({ baseUrl, context }) => {
|
||||
installTestCustomWorldAgentSingleTurnLlm(context);
|
||||
const entry = await authEntry(baseUrl, 'cw_agent_p4_ch', 'secret123');
|
||||
const session = await createObjectRefiningCustomWorldAgentSession({
|
||||
baseUrl,
|
||||
@@ -2817,6 +2993,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 }) => {
|
||||
installTestCustomWorldAgentSingleTurnLlm(context);
|
||||
const entry = await authEntry(baseUrl, 'cw_agent_p4_lm', 'secret123');
|
||||
const session = await createObjectRefiningCustomWorldAgentSession({
|
||||
baseUrl,
|
||||
@@ -3305,14 +3482,11 @@ test('profile browse history supports batch sync, dedupe ordering, isolation and
|
||||
'2026-04-16T12:00:00.000Z',
|
||||
);
|
||||
|
||||
const viewerHistoryResponse = await httpRequest(
|
||||
browseHistoryUrl,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${viewer.token}`,
|
||||
},
|
||||
const viewerHistoryResponse = await httpRequest(browseHistoryUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${viewer.token}`,
|
||||
},
|
||||
);
|
||||
});
|
||||
const viewerHistoryPayload = (await viewerHistoryResponse.json()) as {
|
||||
entries: Array<{
|
||||
profileId: string;
|
||||
@@ -3346,14 +3520,11 @@ test('profile browse history supports batch sync, dedupe ordering, isolation and
|
||||
['world-1', 'world-2'],
|
||||
);
|
||||
|
||||
const authorHistoryResponse = await httpRequest(
|
||||
browseHistoryUrl,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${author.token}`,
|
||||
},
|
||||
const authorHistoryResponse = await httpRequest(browseHistoryUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${author.token}`,
|
||||
},
|
||||
);
|
||||
});
|
||||
const authorHistoryPayload = (await authorHistoryResponse.json()) as {
|
||||
entries: Array<unknown>;
|
||||
};
|
||||
@@ -3374,14 +3545,11 @@ test('profile browse history supports batch sync, dedupe ordering, isolation and
|
||||
assert.equal(clearResponse.status, 200);
|
||||
assert.deepEqual(clearPayload.entries, []);
|
||||
|
||||
const clearedHistoryResponse = await httpRequest(
|
||||
browseHistoryUrl,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${viewer.token}`,
|
||||
},
|
||||
const clearedHistoryResponse = await httpRequest(browseHistoryUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${viewer.token}`,
|
||||
},
|
||||
);
|
||||
});
|
||||
const clearedHistoryPayload = (await clearedHistoryResponse.json()) as {
|
||||
entries: Array<unknown>;
|
||||
};
|
||||
|
||||
@@ -146,6 +146,12 @@ export function createApp(context: AppContext) {
|
||||
withRouteMeta({ routeVersion: '2026-04-08' }),
|
||||
createRuntimeRoutes(context),
|
||||
);
|
||||
app.use(
|
||||
express.static(context.config.publicDir, {
|
||||
fallthrough: true,
|
||||
index: false,
|
||||
}),
|
||||
);
|
||||
|
||||
app.use((request, _response, next) => {
|
||||
next(notFound(`接口不存在:${request.method} ${request.originalUrl}`));
|
||||
|
||||
@@ -299,4 +299,21 @@ export const databaseMigrations: readonly DatabaseMigration[] = [
|
||||
ON user_browse_history (user_id, visited_at DESC)`,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '20260417_013_custom_world_profile_soft_delete',
|
||||
name: 'custom world profile soft delete',
|
||||
statements: [
|
||||
`ALTER TABLE custom_world_profiles
|
||||
ADD COLUMN IF NOT EXISTS deleted_at TEXT`,
|
||||
`CREATE INDEX IF NOT EXISTS custom_world_profiles_user_deleted_updated_idx
|
||||
ON custom_world_profiles (user_id, deleted_at, updated_at DESC)`,
|
||||
`CREATE INDEX IF NOT EXISTS custom_world_profiles_gallery_live_idx
|
||||
ON custom_world_profiles (
|
||||
visibility,
|
||||
deleted_at,
|
||||
published_at DESC,
|
||||
updated_at DESC
|
||||
)`,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -6,6 +6,7 @@ import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import express from 'express';
|
||||
import { PNG } from 'pngjs';
|
||||
|
||||
import type { AppConfig } from '../../config.js';
|
||||
import { createCharacterAssetRoutes } from './characterAssetRoutes.js';
|
||||
@@ -16,6 +17,31 @@ const PNG_BUFFER = Buffer.from(
|
||||
);
|
||||
const MP4_BUFFER = Buffer.from('mock-video');
|
||||
|
||||
function createGreenScreenFixturePngBuffer() {
|
||||
const png = new PNG({ width: 2, height: 1 });
|
||||
|
||||
png.data[0] = 0;
|
||||
png.data[1] = 255;
|
||||
png.data[2] = 0;
|
||||
png.data[3] = 255;
|
||||
|
||||
png.data[4] = 220;
|
||||
png.data[5] = 48;
|
||||
png.data[6] = 72;
|
||||
png.data[7] = 255;
|
||||
|
||||
return PNG.sync.write(png);
|
||||
}
|
||||
|
||||
function readPngAlphaValues(buffer: Buffer) {
|
||||
const png = PNG.sync.read(buffer);
|
||||
return Array.from({ length: png.width * png.height }, (_, index) => {
|
||||
return png.data[index * 4 + 3] ?? 0;
|
||||
});
|
||||
}
|
||||
|
||||
const GREEN_SCREEN_PNG_BUFFER = createGreenScreenFixturePngBuffer();
|
||||
|
||||
function createTestConfig(
|
||||
projectRoot: string,
|
||||
dashScopeBaseUrl: string,
|
||||
@@ -165,7 +191,7 @@ test('character visual generation converts public reference images into data url
|
||||
if (req.method === 'GET' && url.pathname === '/downloads/visual.png') {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
res.end(PNG_BUFFER);
|
||||
res.end(GREEN_SCREEN_PNG_BUFFER);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -219,6 +245,7 @@ test('character visual generation converts public reference images into data url
|
||||
|
||||
const savedDraftPath = path.join(tempRoot, 'public', payload.drafts[0]!.imageSrc.slice(1));
|
||||
assert.equal(fs.existsSync(savedDraftPath), true);
|
||||
assert.deepEqual(readPngAlphaValues(fs.readFileSync(savedDraftPath)), [0, 255]);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -404,6 +431,110 @@ test('character workflow cache skips rewriting unchanged payloads', async () =>
|
||||
);
|
||||
});
|
||||
|
||||
test('character animation publish returns frame dimensions in animation map', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-animation-publish-'));
|
||||
|
||||
await withAssetRouteServer(
|
||||
createTestConfig(tempRoot, 'http://127.0.0.1:9/api/v1'),
|
||||
async (assetBaseUrl) => {
|
||||
const response = await fetch(`${assetBaseUrl}/api/assets/character-animation/publish`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
characterId: 'harbor-guide',
|
||||
visualAssetId: 'visual-1',
|
||||
updateCharacterOverride: false,
|
||||
animations: {
|
||||
run: {
|
||||
framesDataUrls: [`data:image/png;base64,${PNG_BUFFER.toString('base64')}`],
|
||||
fps: 12,
|
||||
loop: true,
|
||||
frameWidth: 144,
|
||||
frameHeight: 192,
|
||||
previewVideoPath: '/generated-character-drafts/harbor-guide/animation/run/preview.mp4',
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const payload = (await response.json()) as {
|
||||
animationMap: Record<
|
||||
string,
|
||||
{
|
||||
frameWidth?: number;
|
||||
frameHeight?: number;
|
||||
fps?: number;
|
||||
loop?: boolean;
|
||||
previewVideoPath?: string;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
assert.equal(payload.animationMap.run?.frameWidth, 144);
|
||||
assert.equal(payload.animationMap.run?.frameHeight, 192);
|
||||
assert.equal(payload.animationMap.run?.fps, 12);
|
||||
assert.equal(payload.animationMap.run?.loop, true);
|
||||
assert.equal(
|
||||
payload.animationMap.run?.previewVideoPath,
|
||||
'/generated-character-drafts/harbor-guide/animation/run/preview.mp4',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('character visual publish removes green screen before saving master and previews', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-visual-publish-'));
|
||||
const publicDir = path.join(tempRoot, 'public');
|
||||
fs.mkdirSync(publicDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(publicDir, 'draft.png'), GREEN_SCREEN_PNG_BUFFER);
|
||||
|
||||
await withAssetRouteServer(
|
||||
createTestConfig(tempRoot, 'http://127.0.0.1:9/api/v1'),
|
||||
async (assetBaseUrl) => {
|
||||
const response = await fetch(`${assetBaseUrl}/api/assets/character-visual/publish`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
characterId: 'harbor-guide',
|
||||
sourceMode: 'image-to-image',
|
||||
promptText: '潮雾港向导',
|
||||
selectedPreviewSource: '/draft.png',
|
||||
previewSources: ['/draft.png'],
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
updateCharacterOverride: false,
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const payload = (await response.json()) as {
|
||||
portraitPath: string;
|
||||
};
|
||||
|
||||
const savedMasterPath = path.join(tempRoot, 'public', payload.portraitPath.slice(1));
|
||||
const savedPreviewPath = path.join(
|
||||
tempRoot,
|
||||
'public',
|
||||
'generated-characters',
|
||||
'harbor-guide',
|
||||
'visual',
|
||||
path.basename(path.dirname(savedMasterPath)),
|
||||
'preview-1.png',
|
||||
);
|
||||
|
||||
assert.equal(fs.existsSync(savedMasterPath), true);
|
||||
assert.equal(fs.existsSync(savedPreviewPath), true);
|
||||
assert.deepEqual(readPngAlphaValues(fs.readFileSync(savedMasterPath)), [0, 255]);
|
||||
assert.deepEqual(readPngAlphaValues(fs.readFileSync(savedPreviewPath)), [0, 255]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
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');
|
||||
@@ -524,3 +655,318 @@ test('character animation image-to-video flow uploads a public visual source and
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('character animation non-loop image-to-video uses first and last master frames', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-kf2v-'));
|
||||
const publicDir = path.join(tempRoot, 'public');
|
||||
fs.mkdirSync(publicDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(publicDir, 'visual.png'), PNG_BUFFER);
|
||||
|
||||
let videoSynthesisPayloadText = '';
|
||||
|
||||
await withHttpServer(
|
||||
(dashScopeBaseUrl) => async (req, res) => {
|
||||
const url = new URL(req.url || '/', dashScopeBaseUrl);
|
||||
|
||||
if (
|
||||
req.method === 'POST' &&
|
||||
url.pathname === '/api/v1/services/aigc/image2video/video-synthesis'
|
||||
) {
|
||||
videoSynthesisPayloadText = (await readRequestBody(req)).toString('utf8');
|
||||
sendJson(res, {
|
||||
output: {
|
||||
task_id: 'video-task-kf2v-1',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/v1/tasks/video-task-kf2v-1') {
|
||||
sendJson(res, {
|
||||
output: {
|
||||
task_status: 'SUCCEEDED',
|
||||
video_url: `${dashScopeBaseUrl}/downloads/preview.mp4`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/downloads/preview.mp4') {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'video/mp4');
|
||||
res.end(MP4_BUFFER);
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 404;
|
||||
res.end('not found');
|
||||
},
|
||||
async (dashScopeBaseUrl) => {
|
||||
const config = createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`);
|
||||
await withAssetRouteServer(config, async (assetBaseUrl) => {
|
||||
const response = await fetch(
|
||||
`${assetBaseUrl}/api/assets/character-animation/generate`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
characterId: 'harbor-guide',
|
||||
strategy: 'image-to-video',
|
||||
animation: 'attack',
|
||||
promptText: '短促挥击后收招',
|
||||
characterBriefText: '旧港守望者',
|
||||
visualSource: '/visual.png',
|
||||
referenceImageDataUrls: [],
|
||||
referenceVideoDataUrls: [],
|
||||
frameCount: 8,
|
||||
fps: 8,
|
||||
durationSeconds: 4,
|
||||
loop: false,
|
||||
useChromaKey: true,
|
||||
resolution: '720P',
|
||||
imageSequenceModel: 'wan2.7-image-pro',
|
||||
videoModel: 'wan2.7-i2v',
|
||||
referenceVideoModel: 'wan2.7-r2v',
|
||||
motionTransferModel: 'wan2.2-animate-move',
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
const videoPayload = JSON.parse(videoSynthesisPayloadText) as {
|
||||
model: string;
|
||||
input: {
|
||||
first_frame_url?: string;
|
||||
last_frame_url?: string;
|
||||
};
|
||||
parameters: {
|
||||
resolution?: string;
|
||||
};
|
||||
};
|
||||
assert.equal(videoPayload.model, 'wan2.2-kf2v-flash');
|
||||
assert.match(videoPayload.input.first_frame_url ?? '', /^data:image\/png;base64,/u);
|
||||
assert.match(videoPayload.input.last_frame_url ?? '', /^data:image\/png;base64,/u);
|
||||
assert.equal(videoPayload.parameters.resolution, '480P');
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('character animation loop image-to-video uses wan2.6-i2v-flash with img_url only', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-i2v-loop-'));
|
||||
const publicDir = path.join(tempRoot, 'public');
|
||||
fs.mkdirSync(publicDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(publicDir, 'visual.png'), PNG_BUFFER);
|
||||
|
||||
let videoSynthesisPayloadText = '';
|
||||
|
||||
await withHttpServer(
|
||||
(dashScopeBaseUrl) => async (req, res) => {
|
||||
const url = new URL(req.url || '/', dashScopeBaseUrl);
|
||||
|
||||
if (
|
||||
req.method === 'POST' &&
|
||||
url.pathname === '/api/v1/services/aigc/video-generation/video-synthesis'
|
||||
) {
|
||||
videoSynthesisPayloadText = (await readRequestBody(req)).toString('utf8');
|
||||
sendJson(res, {
|
||||
output: {
|
||||
task_id: 'video-task-i2v-loop-1',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/v1/tasks/video-task-i2v-loop-1') {
|
||||
sendJson(res, {
|
||||
output: {
|
||||
task_status: 'SUCCEEDED',
|
||||
video_url: `${dashScopeBaseUrl}/downloads/preview.mp4`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/downloads/preview.mp4') {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'video/mp4');
|
||||
res.end(MP4_BUFFER);
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 404;
|
||||
res.end('not found');
|
||||
},
|
||||
async (dashScopeBaseUrl) => {
|
||||
const config = createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`);
|
||||
await withAssetRouteServer(config, async (assetBaseUrl) => {
|
||||
const response = await fetch(
|
||||
`${assetBaseUrl}/api/assets/character-animation/generate`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
characterId: 'harbor-guide',
|
||||
strategy: 'image-to-video',
|
||||
animation: 'run',
|
||||
promptText: '稳定循环奔跑',
|
||||
characterBriefText: '旧港守望者',
|
||||
visualSource: '/visual.png',
|
||||
referenceImageDataUrls: [],
|
||||
referenceVideoDataUrls: [],
|
||||
frameCount: 8,
|
||||
fps: 8,
|
||||
durationSeconds: 4,
|
||||
loop: true,
|
||||
useChromaKey: true,
|
||||
resolution: '720P',
|
||||
imageSequenceModel: 'wan2.7-image-pro',
|
||||
videoModel: 'wan2.6-i2v-flash',
|
||||
referenceVideoModel: 'wan2.7-r2v',
|
||||
motionTransferModel: 'wan2.2-animate-move',
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
const videoPayload = JSON.parse(videoSynthesisPayloadText) as {
|
||||
model: string;
|
||||
input: {
|
||||
img_url?: string;
|
||||
first_frame_url?: string;
|
||||
last_frame_url?: string;
|
||||
};
|
||||
parameters: {
|
||||
audio?: boolean;
|
||||
resolution?: string;
|
||||
};
|
||||
};
|
||||
assert.equal(videoPayload.model, 'wan2.6-i2v-flash');
|
||||
assert.match(videoPayload.input.img_url ?? '', /^data:image\/png;base64,/u);
|
||||
assert.equal(videoPayload.input.first_frame_url, undefined);
|
||||
assert.equal(videoPayload.input.last_frame_url, undefined);
|
||||
assert.equal(videoPayload.parameters.audio, false);
|
||||
assert.equal(videoPayload.parameters.resolution, '720P');
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('character animation reference-to-video can use only reference image media', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-r2v-'));
|
||||
const publicDir = path.join(tempRoot, 'public');
|
||||
fs.mkdirSync(publicDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(publicDir, 'visual.png'), PNG_BUFFER);
|
||||
|
||||
let videoSynthesisPayloadText = '';
|
||||
|
||||
await withHttpServer(
|
||||
(dashScopeBaseUrl) => async (req, res) => {
|
||||
const url = new URL(req.url || '/', dashScopeBaseUrl);
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/v1/uploads') {
|
||||
sendJson(res, {
|
||||
data: {
|
||||
upload_host: `${dashScopeBaseUrl}/upload`,
|
||||
upload_dir: 'uploads/test-dir',
|
||||
policy: 'policy',
|
||||
signature: 'signature',
|
||||
oss_access_key_id: 'oss-key',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/upload') {
|
||||
await readRequestBody(req);
|
||||
res.statusCode = 200;
|
||||
res.end('ok');
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
req.method === 'POST' &&
|
||||
url.pathname === '/api/v1/services/aigc/video-generation/video-synthesis'
|
||||
) {
|
||||
videoSynthesisPayloadText = (await readRequestBody(req)).toString('utf8');
|
||||
sendJson(res, {
|
||||
output: {
|
||||
task_id: 'video-task-r2v-1',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/v1/tasks/video-task-r2v-1') {
|
||||
sendJson(res, {
|
||||
output: {
|
||||
task_status: 'SUCCEEDED',
|
||||
video_url: `${dashScopeBaseUrl}/downloads/preview.mp4`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/downloads/preview.mp4') {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'video/mp4');
|
||||
res.end(MP4_BUFFER);
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 404;
|
||||
res.end('not found');
|
||||
},
|
||||
async (dashScopeBaseUrl) => {
|
||||
const config = createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`);
|
||||
await withAssetRouteServer(config, async (assetBaseUrl) => {
|
||||
const response = await fetch(
|
||||
`${assetBaseUrl}/api/assets/character-animation/generate`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
characterId: 'harbor-guide',
|
||||
strategy: 'reference-to-video',
|
||||
animation: 'run',
|
||||
promptText: '稳定循环奔跑',
|
||||
characterBriefText: '旧港守望者',
|
||||
visualSource: '/visual.png',
|
||||
referenceImageDataUrls: [],
|
||||
referenceVideoDataUrls: [],
|
||||
frameCount: 8,
|
||||
fps: 8,
|
||||
durationSeconds: 4,
|
||||
loop: true,
|
||||
useChromaKey: true,
|
||||
resolution: '720P',
|
||||
imageSequenceModel: 'wan2.7-image-pro',
|
||||
videoModel: 'wan2.7-i2v',
|
||||
referenceVideoModel: 'wan2.7-r2v',
|
||||
motionTransferModel: 'wan2.2-animate-move',
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
const videoPayload = JSON.parse(videoSynthesisPayloadText) as {
|
||||
input: {
|
||||
media: Array<{ type: string; url: string }>;
|
||||
};
|
||||
};
|
||||
assert.equal(videoPayload.input.media[0]?.type, 'reference_image');
|
||||
assert.match(videoPayload.input.media[0]?.url ?? '', /^oss:\/\/uploads\/test-dir\//u);
|
||||
assert.equal(videoPayload.input.media.length, 1);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,7 +7,8 @@ import http, {
|
||||
import https from 'node:https';
|
||||
import path from 'node:path';
|
||||
|
||||
import { type NextFunction, type Request, type Response,Router } from 'express';
|
||||
import { type NextFunction, type Request, type Response, Router } from 'express';
|
||||
import { PNG } from 'pngjs';
|
||||
|
||||
import {
|
||||
buildMasterPrompt,
|
||||
@@ -32,6 +33,7 @@ const CHARACTER_ANIMATION_TEMPLATES_PATH = '/api/assets/character-animation/temp
|
||||
const DEFAULT_DASHSCOPE_BASE_URL = 'https://dashscope.aliyuncs.com/api/v1';
|
||||
const DEFAULT_CHARACTER_VISUAL_MODEL = 'wan2.7-image-pro';
|
||||
const DEFAULT_CHARACTER_VIDEO_MODEL = 'wan2.2-kf2v-flash';
|
||||
const DEFAULT_CHARACTER_LOOP_VIDEO_MODEL = 'wan2.6-i2v-flash';
|
||||
const DEFAULT_CHARACTER_REFERENCE_VIDEO_MODEL = 'wan2.7-r2v';
|
||||
const DEFAULT_CHARACTER_MOTION_TRANSFER_MODEL = 'wan2.2-animate-move';
|
||||
const DASHSCOPE_IMAGE_TASK_POLL_INTERVAL_MS = 2500;
|
||||
@@ -107,6 +109,67 @@ type DecodedMediaPayload = {
|
||||
extension: string;
|
||||
};
|
||||
|
||||
function applyGreenScreenAlphaToPngBuffer(buffer: Buffer) {
|
||||
try {
|
||||
const png = PNG.sync.read(buffer);
|
||||
const pixels = png.data;
|
||||
let changed = false;
|
||||
|
||||
for (let index = 0; index < pixels.length; index += 4) {
|
||||
const red = pixels[index] ?? 0;
|
||||
const green = pixels[index + 1] ?? 0;
|
||||
const blue = pixels[index + 2] ?? 0;
|
||||
const alpha = pixels[index + 3] ?? 0;
|
||||
const greenRatio = green / Math.max(1, red + blue);
|
||||
|
||||
if (alpha === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const greenLead = green - Math.max(red, blue);
|
||||
if (green <= 72 || greenLead <= 20 || greenRatio <= 0.72) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let nextAlpha = Math.min(alpha, Math.max(0, 255 - greenLead * 6));
|
||||
|
||||
if (green > 120 && greenLead > 48 && greenRatio > 1.12) {
|
||||
nextAlpha = 0;
|
||||
}
|
||||
|
||||
if (nextAlpha === alpha) {
|
||||
continue;
|
||||
}
|
||||
|
||||
pixels[index + 3] = nextAlpha;
|
||||
if (nextAlpha > 0) {
|
||||
pixels[index + 1] = Math.min(
|
||||
green,
|
||||
Math.max(red, blue) + Math.max(6, Math.round(greenLead * 0.18)),
|
||||
);
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed ? PNG.sync.write(png) : buffer;
|
||||
} catch {
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
function applyChromaKeyToMediaPayload(payload: DecodedMediaPayload) {
|
||||
if (payload.mimeType !== 'image/png' && payload.extension !== 'png') {
|
||||
return payload;
|
||||
}
|
||||
|
||||
return {
|
||||
...payload,
|
||||
buffer: applyGreenScreenAlphaToPngBuffer(payload.buffer),
|
||||
mimeType: 'image/png',
|
||||
extension: 'png',
|
||||
} satisfies DecodedMediaPayload;
|
||||
}
|
||||
|
||||
type CharacterPromptBundle = {
|
||||
visualPromptText: string;
|
||||
animationPromptText: string;
|
||||
@@ -355,6 +418,92 @@ function sanitizeCharacterPromptBundle(
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeAnimationPromptText(value: string, maxLength: number) {
|
||||
return value
|
||||
.replace(/\s+/gu, ' ')
|
||||
.replace(/血浆|喷血|鲜血|断肢|斩首|裸体|裸露|色情|性交/gu, '')
|
||||
.replace(/死亡|死去|击杀/gu, '倒地结束')
|
||||
.replace(/受击|受伤/gu, '失衡')
|
||||
.replace(/砍杀|斩击/gu, '挥击')
|
||||
.trim()
|
||||
.slice(0, maxLength);
|
||||
}
|
||||
|
||||
function buildCompactAnimationCharacterBrief(value: string) {
|
||||
const normalized = sanitizeAnimationPromptText(value, 160);
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return normalized
|
||||
.split(/[\/|\n,,。;;]+/u)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 4)
|
||||
.join(',');
|
||||
}
|
||||
|
||||
function isInappropriateContentMessage(value: string) {
|
||||
return /finappropriate-content|inappropriate content|不适当内容|违规内容/iu.test(
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
async function proxyJsonRequestWithPromptFallback(params: {
|
||||
urlString: string;
|
||||
apiKey: string;
|
||||
buildBody: (prompt: string) => Record<string, unknown>;
|
||||
primaryPrompt: string;
|
||||
fallbackPrompt?: string;
|
||||
extraHeaders?: Record<string, string>;
|
||||
}) {
|
||||
const firstResponse = await proxyJsonRequest(
|
||||
params.urlString,
|
||||
params.apiKey,
|
||||
params.buildBody(params.primaryPrompt),
|
||||
params.extraHeaders,
|
||||
);
|
||||
|
||||
if (firstResponse.statusCode >= 200 && firstResponse.statusCode < 300) {
|
||||
return {
|
||||
response: firstResponse,
|
||||
prompt: params.primaryPrompt,
|
||||
moderationFallbackApplied: false,
|
||||
};
|
||||
}
|
||||
|
||||
const fallbackPrompt = params.fallbackPrompt?.trim() ?? '';
|
||||
const errorMessage = extractApiErrorMessage(
|
||||
firstResponse.bodyText,
|
||||
'视频生成请求失败。',
|
||||
);
|
||||
|
||||
if (
|
||||
!fallbackPrompt ||
|
||||
fallbackPrompt === params.primaryPrompt ||
|
||||
!isInappropriateContentMessage(errorMessage)
|
||||
) {
|
||||
return {
|
||||
response: firstResponse,
|
||||
prompt: params.primaryPrompt,
|
||||
moderationFallbackApplied: false,
|
||||
};
|
||||
}
|
||||
|
||||
const secondResponse = await proxyJsonRequest(
|
||||
params.urlString,
|
||||
params.apiKey,
|
||||
params.buildBody(fallbackPrompt),
|
||||
params.extraHeaders,
|
||||
);
|
||||
|
||||
return {
|
||||
response: secondResponse,
|
||||
prompt: fallbackPrompt,
|
||||
moderationFallbackApplied: true,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCharacterPromptBundleUserPrompt(params: {
|
||||
roleKind: string;
|
||||
characterBriefText: string;
|
||||
@@ -463,13 +612,24 @@ async function writeJsonObjectFile(
|
||||
}
|
||||
|
||||
function decodeMediaDataUrl(dataUrl: string): DecodedMediaPayload {
|
||||
const matched = /^data:([^;]+);base64,(.+)$/u.exec(dataUrl);
|
||||
const matched = /^data:([^,]+),(.+)$/u.exec(dataUrl);
|
||||
if (!matched) {
|
||||
throw new Error('不支持的媒体数据,要求使用 Base64 Data URL。');
|
||||
}
|
||||
|
||||
const mimeType = matched[1];
|
||||
const metadata = matched[1];
|
||||
const base64Payload = matched[2];
|
||||
const metadataParts = metadata
|
||||
.split(';')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
const mimeType = metadataParts[0] ?? 'application/octet-stream';
|
||||
const isBase64 = metadataParts.some((item) => item.toLowerCase() === 'base64');
|
||||
|
||||
if (!isBase64) {
|
||||
throw new Error('不支持的媒体数据,要求使用 Base64 Data URL。');
|
||||
}
|
||||
|
||||
const extension = (() => {
|
||||
switch (mimeType) {
|
||||
case 'image/jpeg':
|
||||
@@ -484,6 +644,8 @@ function decodeMediaDataUrl(dataUrl: string): DecodedMediaPayload {
|
||||
return 'mov';
|
||||
case 'video/x-msvideo':
|
||||
return 'avi';
|
||||
case 'video/webm':
|
||||
return 'webm';
|
||||
default:
|
||||
return mimeType.split('/')[1] ?? 'bin';
|
||||
}
|
||||
@@ -552,6 +714,15 @@ async function resolveMediaSourcePayload(
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveCharacterVisualPayload(
|
||||
rootDir: string,
|
||||
source: string,
|
||||
): Promise<DecodedMediaPayload> {
|
||||
return applyChromaKeyToMediaPayload(
|
||||
await resolveMediaSourcePayload(rootDir, source),
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveMediaSourceAsDataUrl(
|
||||
rootDir: string,
|
||||
source: string,
|
||||
@@ -982,19 +1153,32 @@ function buildNpcAnimationPrompt(options: {
|
||||
animation: string;
|
||||
promptText: string;
|
||||
useChromaKey: boolean;
|
||||
loop: boolean;
|
||||
characterBriefText?: string;
|
||||
actionTemplateId?: string;
|
||||
}) {
|
||||
const characterBrief = buildCompactAnimationCharacterBrief(
|
||||
options.characterBriefText ?? '',
|
||||
);
|
||||
const actionDetailText = sanitizeAnimationPromptText(options.promptText, 140);
|
||||
const loopRule = options.loop
|
||||
? '这是循环动作,直接进入动作循环中段,不要开场静止站桩,不要把主参考图原样作为第一帧。'
|
||||
: '这是非循环动作,首帧和尾帧都要回到参考主图角色形象,中段完成动作变化。';
|
||||
|
||||
if (options.actionTemplateId) {
|
||||
return buildVideoActionPrompt({
|
||||
actionTemplate: getActionTemplateById(
|
||||
options.actionTemplateId as Parameters<typeof getActionTemplateById>[0],
|
||||
),
|
||||
actionDetailText: options.promptText,
|
||||
useChromaKey: options.useChromaKey,
|
||||
characterBrief:
|
||||
options.characterBriefText?.trim() || `${options.animation} 动作角色`,
|
||||
});
|
||||
return [
|
||||
buildVideoActionPrompt({
|
||||
actionTemplate: getActionTemplateById(
|
||||
options.actionTemplateId as Parameters<typeof getActionTemplateById>[0],
|
||||
),
|
||||
actionDetailText,
|
||||
useChromaKey: options.useChromaKey,
|
||||
characterBrief: characterBrief || `${options.animation} 动作角色`,
|
||||
}),
|
||||
loopRule,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
return [
|
||||
@@ -1004,15 +1188,50 @@ function buildNpcAnimationPrompt(options: {
|
||||
options.useChromaKey
|
||||
? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。'
|
||||
: '背景简洁纯净,无复杂场景。',
|
||||
options.characterBriefText?.trim()
|
||||
? `角色设定:${options.characterBriefText.trim()}`
|
||||
characterBrief
|
||||
? `角色设定:${characterBrief}`
|
||||
: '',
|
||||
options.promptText.trim(),
|
||||
actionDetailText,
|
||||
loopRule,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function buildFallbackModerationSafeAnimationPrompt(options: {
|
||||
animation: string;
|
||||
loop: boolean;
|
||||
useChromaKey: boolean;
|
||||
}) {
|
||||
return [
|
||||
`单人全身角色动作视频,动作主题是 ${options.animation}。`,
|
||||
'角色固定为同一人,右向斜侧身,镜头稳定,轮廓清楚。',
|
||||
options.loop
|
||||
? '循环动作直接进入稳定循环,不要静止开场,不要定格首帧。'
|
||||
: '非循环动作首尾回到角色标准站姿,中段完成动作变化。',
|
||||
options.useChromaKey
|
||||
? '背景为纯绿色绿幕,无其他人物和场景元素。'
|
||||
: '背景简洁纯净。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function getLowestSupportedVideoResolution(model: string, fallback: string) {
|
||||
switch (model) {
|
||||
case 'wan2.6-i2v-flash':
|
||||
case 'wan2.6-i2v':
|
||||
case 'wan2.6-i2v-us':
|
||||
return '720P';
|
||||
case 'wan2.2-kf2v-flash':
|
||||
case 'wan2.2-i2v-flash':
|
||||
case 'wan2.5-i2v-preview':
|
||||
return '480P';
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerateCharacterPromptBundle(
|
||||
config: AppConfig,
|
||||
req: IncomingMessage & { body?: unknown },
|
||||
@@ -1318,7 +1537,7 @@ async function handleGenerateCharacterVisuals(
|
||||
const imageSrc = await writeDraftBinaryFile(
|
||||
rootDir,
|
||||
path.posix.join(draftRelativeDir, fileName),
|
||||
imageResponse.body,
|
||||
applyGreenScreenAlphaToPngBuffer(imageResponse.body),
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -1475,6 +1694,7 @@ async function handleGenerateCharacterAnimation(
|
||||
Number.isFinite(body.durationSeconds)
|
||||
? Math.max(1, Math.min(8, Math.round(body.durationSeconds)))
|
||||
: 4;
|
||||
const loop = body.loop === true;
|
||||
const useChromaKey = body.useChromaKey !== false;
|
||||
const resolution =
|
||||
typeof body.resolution === 'string' && body.resolution.trim()
|
||||
@@ -1487,15 +1707,28 @@ async function handleGenerateCharacterAnimation(
|
||||
: runtimeEnv.DASHSCOPE_CHARACTER_IMAGE_SEQUENCE_MODEL ||
|
||||
runtimeEnv.DASHSCOPE_CHARACTER_VISUAL_MODEL ||
|
||||
DEFAULT_CHARACTER_VISUAL_MODEL;
|
||||
const videoModel =
|
||||
const requestedVideoModel =
|
||||
typeof body.videoModel === 'string' && body.videoModel.trim()
|
||||
? body.videoModel.trim()
|
||||
: runtimeEnv.DASHSCOPE_CHARACTER_VIDEO_MODEL ||
|
||||
DEFAULT_CHARACTER_VIDEO_MODEL;
|
||||
const loopVideoModel =
|
||||
runtimeEnv.DASHSCOPE_CHARACTER_LOOP_VIDEO_MODEL ||
|
||||
(requestedVideoModel === 'wan2.2-kf2v-flash'
|
||||
? DEFAULT_CHARACTER_LOOP_VIDEO_MODEL
|
||||
: requestedVideoModel) ||
|
||||
DEFAULT_CHARACTER_LOOP_VIDEO_MODEL;
|
||||
const keyframeVideoModel =
|
||||
runtimeEnv.DASHSCOPE_CHARACTER_KEYFRAME_VIDEO_MODEL ||
|
||||
DEFAULT_CHARACTER_VIDEO_MODEL;
|
||||
const videoModel =
|
||||
strategy === 'image-to-video' ? (loop ? loopVideoModel : keyframeVideoModel) : requestedVideoModel;
|
||||
const durationSeconds =
|
||||
videoModel === 'wan2.2-kf2v-flash' ? 5 : requestedDurationSeconds;
|
||||
const normalizedResolution =
|
||||
videoModel === 'wan2.2-kf2v-flash' ? '480P' : resolution;
|
||||
const normalizedResolution = getLowestSupportedVideoResolution(
|
||||
videoModel,
|
||||
videoModel === 'wan2.2-kf2v-flash' ? '480P' : resolution,
|
||||
);
|
||||
const referenceVideoModel =
|
||||
typeof body.referenceVideoModel === 'string' &&
|
||||
body.referenceVideoModel.trim()
|
||||
@@ -1707,13 +1940,21 @@ async function handleGenerateCharacterAnimation(
|
||||
animation,
|
||||
promptText,
|
||||
useChromaKey,
|
||||
loop,
|
||||
characterBriefText,
|
||||
actionTemplateId,
|
||||
});
|
||||
const fallbackPrompt = buildFallbackModerationSafeAnimationPrompt({
|
||||
animation,
|
||||
loop,
|
||||
useChromaKey,
|
||||
});
|
||||
activePrompt = finalPrompt;
|
||||
activeModel = videoModel;
|
||||
const isKf2vFlash = videoModel === 'wan2.2-kf2v-flash';
|
||||
const visualInputRef = isKf2vFlash
|
||||
const isWan26I2vFlash = videoModel === 'wan2.6-i2v-flash';
|
||||
const visualInputRef =
|
||||
isKf2vFlash || isWan26I2vFlash
|
||||
? await resolveMediaSourceAsDataUrl(rootDir, visualSource)
|
||||
: await uploadFileToDashScope(
|
||||
baseUrl,
|
||||
@@ -1722,9 +1963,12 @@ async function handleGenerateCharacterAnimation(
|
||||
`${characterId}-${animation}-visual`,
|
||||
await resolveMediaSourcePayload(rootDir, visualSource),
|
||||
);
|
||||
const lastFrameRef = lastFrameImageDataUrl
|
||||
const resolvedLastFrameSource = !loop
|
||||
? lastFrameImageDataUrl || visualSource
|
||||
: '';
|
||||
const lastFrameRef = resolvedLastFrameSource
|
||||
? isKf2vFlash
|
||||
? await resolveMediaSourceAsDataUrl(rootDir, lastFrameImageDataUrl)
|
||||
? await resolveMediaSourceAsDataUrl(rootDir, resolvedLastFrameSource)
|
||||
: await uploadFileToDashScope(
|
||||
baseUrl,
|
||||
apiKey,
|
||||
@@ -1732,47 +1976,59 @@ async function handleGenerateCharacterAnimation(
|
||||
`${characterId}-${animation}-last-frame`,
|
||||
await resolveMediaSourcePayload(
|
||||
rootDir,
|
||||
lastFrameImageDataUrl,
|
||||
resolvedLastFrameSource,
|
||||
),
|
||||
)
|
||||
: '';
|
||||
const inputPayload =
|
||||
isKf2vFlash
|
||||
const createVideoRequestBody = (prompt: string) => ({
|
||||
model: videoModel,
|
||||
input: isKf2vFlash
|
||||
? {
|
||||
prompt: finalPrompt,
|
||||
prompt,
|
||||
first_frame_url: visualInputRef,
|
||||
...(lastFrameRef ? { last_frame_url: lastFrameRef } : {}),
|
||||
}
|
||||
: {
|
||||
prompt: finalPrompt,
|
||||
media: [
|
||||
{ type: 'first_frame', url: visualInputRef },
|
||||
...(lastFrameRef
|
||||
? [{ type: 'last_frame', url: lastFrameRef }]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
: isWan26I2vFlash
|
||||
? {
|
||||
prompt,
|
||||
img_url: visualInputRef,
|
||||
}
|
||||
: {
|
||||
prompt,
|
||||
media: [
|
||||
{ type: 'first_frame', url: visualInputRef },
|
||||
...(lastFrameRef
|
||||
? [{ type: 'last_frame', url: lastFrameRef }]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
duration: durationSeconds,
|
||||
resolution: normalizedResolution,
|
||||
...(isKf2vFlash
|
||||
? { prompt_extend: true, watermark: false }
|
||||
: {}),
|
||||
...(isWan26I2vFlash ? { audio: false } : {}),
|
||||
},
|
||||
});
|
||||
const videoSynthesisEndpoint = isKf2vFlash
|
||||
? `${baseUrl}/services/aigc/image2video/video-synthesis`
|
||||
: `${baseUrl}/services/aigc/video-generation/video-synthesis`;
|
||||
|
||||
const createTaskResponse = await proxyJsonRequest(
|
||||
videoSynthesisEndpoint,
|
||||
apiKey,
|
||||
{
|
||||
model: videoModel,
|
||||
input: inputPayload,
|
||||
parameters: {
|
||||
duration: durationSeconds,
|
||||
resolution: normalizedResolution,
|
||||
...(isKf2vFlash ? { prompt_extend: true, watermark: false } : {}),
|
||||
const { response: createTaskResponse, prompt: submittedPrompt } =
|
||||
await proxyJsonRequestWithPromptFallback({
|
||||
urlString: videoSynthesisEndpoint,
|
||||
apiKey,
|
||||
buildBody: createVideoRequestBody,
|
||||
primaryPrompt: finalPrompt,
|
||||
fallbackPrompt,
|
||||
extraHeaders: {
|
||||
'X-DashScope-Async': 'enable',
|
||||
'X-DashScope-OssResourceResolve': 'enable',
|
||||
},
|
||||
},
|
||||
{
|
||||
'X-DashScope-Async': 'enable',
|
||||
'X-DashScope-OssResourceResolve': 'enable',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
activePrompt = submittedPrompt;
|
||||
|
||||
if (
|
||||
createTaskResponse.statusCode < 200 ||
|
||||
@@ -1809,7 +2065,7 @@ async function handleGenerateCharacterAnimation(
|
||||
animation,
|
||||
strategy,
|
||||
model: videoModel,
|
||||
prompt: finalPrompt,
|
||||
prompt: submittedPrompt,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
});
|
||||
@@ -1859,7 +2115,7 @@ async function handleGenerateCharacterAnimation(
|
||||
model: videoModel,
|
||||
strategy,
|
||||
animation,
|
||||
prompt: finalPrompt,
|
||||
prompt: submittedPrompt,
|
||||
createdAt: new Date().toISOString(),
|
||||
videoUrl,
|
||||
},
|
||||
@@ -1877,7 +2133,7 @@ async function handleGenerateCharacterAnimation(
|
||||
animation,
|
||||
strategy,
|
||||
model: videoModel,
|
||||
prompt: finalPrompt,
|
||||
prompt: submittedPrompt,
|
||||
createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
result: {
|
||||
@@ -1891,7 +2147,7 @@ async function handleGenerateCharacterAnimation(
|
||||
taskId,
|
||||
strategy: 'image-to-video',
|
||||
model: videoModel,
|
||||
prompt: finalPrompt,
|
||||
prompt: submittedPrompt,
|
||||
previewVideoPath,
|
||||
});
|
||||
return;
|
||||
@@ -1923,6 +2179,7 @@ async function handleGenerateCharacterAnimation(
|
||||
animation,
|
||||
promptText,
|
||||
useChromaKey,
|
||||
loop,
|
||||
characterBriefText,
|
||||
});
|
||||
activePrompt = finalPrompt;
|
||||
@@ -2081,8 +2338,8 @@ async function handleGenerateCharacterAnimation(
|
||||
}
|
||||
|
||||
if (strategy === 'reference-to-video') {
|
||||
const uploadedReferenceUrls = await Promise.all([
|
||||
...referenceImageDataUrls.map(async (source, index) =>
|
||||
const uploadedReferenceImages = await Promise.all(
|
||||
referenceImageDataUrls.map(async (source, index) =>
|
||||
uploadFileToDashScope(
|
||||
baseUrl,
|
||||
apiKey,
|
||||
@@ -2091,7 +2348,9 @@ async function handleGenerateCharacterAnimation(
|
||||
await resolveMediaSourcePayload(rootDir, source),
|
||||
),
|
||||
),
|
||||
...referenceVideoDataUrls.map(async (source, index) =>
|
||||
);
|
||||
const uploadedReferenceVideos = await Promise.all(
|
||||
referenceVideoDataUrls.map(async (source, index) =>
|
||||
uploadFileToDashScope(
|
||||
baseUrl,
|
||||
apiKey,
|
||||
@@ -2100,9 +2359,13 @@ async function handleGenerateCharacterAnimation(
|
||||
await resolveMediaSourcePayload(rootDir, source),
|
||||
),
|
||||
),
|
||||
]);
|
||||
);
|
||||
|
||||
if (uploadedReferenceUrls.length === 0) {
|
||||
if (
|
||||
!visualUrl &&
|
||||
uploadedReferenceImages.length === 0 &&
|
||||
uploadedReferenceVideos.length === 0
|
||||
) {
|
||||
sendJson(res, 400, {
|
||||
error: { message: '参考生视频至少需要一张参考图或一段参考视频。' },
|
||||
});
|
||||
@@ -2113,6 +2376,7 @@ async function handleGenerateCharacterAnimation(
|
||||
animation,
|
||||
promptText,
|
||||
useChromaKey,
|
||||
loop,
|
||||
characterBriefText,
|
||||
});
|
||||
activePrompt = finalPrompt;
|
||||
@@ -2124,11 +2388,24 @@ async function handleGenerateCharacterAnimation(
|
||||
model: referenceVideoModel,
|
||||
input: {
|
||||
prompt: finalPrompt,
|
||||
reference_urls: [visualUrl, ...uploadedReferenceUrls],
|
||||
media: [
|
||||
{ type: 'reference_image', url: visualUrl },
|
||||
...uploadedReferenceImages.map((url) => ({
|
||||
type: 'reference_image' as const,
|
||||
url,
|
||||
})),
|
||||
...uploadedReferenceVideos.map((url) => ({
|
||||
type: 'reference_video' as const,
|
||||
url,
|
||||
})),
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
duration: durationSeconds,
|
||||
resolution,
|
||||
resolution: getLowestSupportedVideoResolution(
|
||||
referenceVideoModel,
|
||||
resolution,
|
||||
),
|
||||
prompt_optimizer: true,
|
||||
},
|
||||
},
|
||||
@@ -2688,7 +2965,7 @@ async function handlePublishCharacterVisual(
|
||||
);
|
||||
await mkdir(visualDir, { recursive: true });
|
||||
|
||||
const masterPayload = await resolveMediaSourcePayload(
|
||||
const masterPayload = await resolveCharacterVisualPayload(
|
||||
rootDir,
|
||||
selectedPreviewSource,
|
||||
);
|
||||
@@ -2697,7 +2974,7 @@ async function handlePublishCharacterVisual(
|
||||
|
||||
const previewImagePaths: string[] = [];
|
||||
for (let index = 0; index < previewSources.length; index += 1) {
|
||||
const previewPayload = await resolveMediaSourcePayload(
|
||||
const previewPayload = await resolveCharacterVisualPayload(
|
||||
rootDir,
|
||||
previewSources[index] ?? '',
|
||||
);
|
||||
@@ -2904,6 +3181,11 @@ async function handlePublishCharacterAnimation(
|
||||
startFrame: 1,
|
||||
extension: frameExtension,
|
||||
basePath,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
fps,
|
||||
loop,
|
||||
...(previewVideoPath ? { previewVideoPath } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -428,7 +428,8 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
landmark_count AS "landmarkCount"
|
||||
FROM custom_world_profiles
|
||||
WHERE user_id = $1
|
||||
AND profile_id = $2`,
|
||||
AND profile_id = $2
|
||||
AND deleted_at IS NULL`,
|
||||
[userId, profileId],
|
||||
);
|
||||
|
||||
@@ -887,6 +888,7 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
landmark_count AS "landmarkCount"
|
||||
FROM custom_world_profiles
|
||||
WHERE user_id = $1
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT $2`,
|
||||
[userId, MAX_CUSTOM_WORLD_PROFILES],
|
||||
@@ -923,6 +925,7 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
ON CONFLICT (user_id, profile_id) DO UPDATE SET
|
||||
payload_json = EXCLUDED.payload_json,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
deleted_at = NULL,
|
||||
author_display_name = EXCLUDED.author_display_name,
|
||||
world_name = EXCLUDED.world_name,
|
||||
subtitle = EXCLUDED.subtitle,
|
||||
@@ -959,10 +962,17 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
}
|
||||
|
||||
async deleteCustomWorldProfile(userId: string, profileId: string) {
|
||||
const deletedAt = new Date().toISOString();
|
||||
await this.db.query(
|
||||
`DELETE FROM custom_world_profiles
|
||||
WHERE user_id = $1 AND profile_id = $2`,
|
||||
[userId, profileId],
|
||||
`UPDATE custom_world_profiles
|
||||
SET deleted_at = $1,
|
||||
updated_at = $1,
|
||||
visibility = 'draft',
|
||||
published_at = NULL
|
||||
WHERE user_id = $2
|
||||
AND profile_id = $3
|
||||
AND deleted_at IS NULL`,
|
||||
[deletedAt, userId, profileId],
|
||||
);
|
||||
|
||||
return this.listCustomWorldProfiles(userId);
|
||||
@@ -1172,6 +1182,7 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
landmark_count AS "landmarkCount"
|
||||
FROM custom_world_profiles
|
||||
WHERE visibility = 'published'
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY published_at DESC, updated_at DESC
|
||||
LIMIT $1`,
|
||||
[MAX_PUBLIC_CUSTOM_WORLD_PROFILES],
|
||||
@@ -1202,7 +1213,8 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
FROM custom_world_profiles
|
||||
WHERE user_id = $1
|
||||
AND profile_id = $2
|
||||
AND visibility = 'published'`,
|
||||
AND visibility = 'published'
|
||||
AND deleted_at IS NULL`,
|
||||
[ownerUserId, profileId],
|
||||
);
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ const createSessionSchema = z.object({
|
||||
const sendMessageSchema = z.object({
|
||||
clientMessageId: z.string().trim().min(1),
|
||||
text: z.string().trim().min(1),
|
||||
quickFillRequested: z.boolean().optional().default(false),
|
||||
focusCardId: z.string().trim().nullable().optional().default(null),
|
||||
selectedCardIds: z.array(z.string().trim().min(1)).optional().default([]),
|
||||
});
|
||||
@@ -134,6 +135,28 @@ export function createCustomWorldAgentRoutes(context: AppContext) {
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/sessions/:sessionId/messages/stream',
|
||||
routeMeta({ operation: 'runtime.customWorldAgent.streamMessage' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const sessionId = readParam(request.params.sessionId);
|
||||
if (!sessionId) {
|
||||
throw badRequest('sessionId is required');
|
||||
}
|
||||
|
||||
const payload = sendMessageSchema.parse(
|
||||
request.body,
|
||||
) as SendCustomWorldAgentMessageRequest;
|
||||
await context.customWorldAgentOrchestrator.streamMessage({
|
||||
request,
|
||||
response,
|
||||
userId: request.userId!,
|
||||
sessionId,
|
||||
payload,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/sessions/:sessionId/actions',
|
||||
routeMeta({ operation: 'runtime.customWorldAgent.executeAction' }),
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
CustomWorldFoundationDraftLandmark,
|
||||
CustomWorldFoundationDraftProfile,
|
||||
CustomWorldFoundationDraftThread,
|
||||
EightAnchorContent,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js';
|
||||
import {
|
||||
@@ -36,6 +37,13 @@ import {
|
||||
type CustomWorldCreatorIntentRecord,
|
||||
normalizeCreatorIntentRecord,
|
||||
} from './customWorldAgentIntentExtractionService.js';
|
||||
import {
|
||||
buildCreatorIntentFromEightAnchorContent,
|
||||
buildDraftSummaryFromEightAnchorContent,
|
||||
buildDraftTitleFromEightAnchorContent,
|
||||
buildEightAnchorFoundationText,
|
||||
normalizeEightAnchorContent,
|
||||
} from './eightAnchorCompatibilityService.js';
|
||||
import type { UpstreamLlmClient } from './llmClient.js';
|
||||
|
||||
function toText(value: unknown) {
|
||||
@@ -923,7 +931,15 @@ function sanitizeJsonLikeText(text: string) {
|
||||
function buildFoundationGenerationSeedText(params: {
|
||||
intent: CustomWorldCreatorIntentRecord;
|
||||
anchorPack: unknown;
|
||||
anchorContent?: EightAnchorContent | null;
|
||||
}) {
|
||||
const anchorText = params.anchorContent
|
||||
? buildEightAnchorFoundationText(params.anchorContent)
|
||||
: '';
|
||||
if (anchorText) {
|
||||
return anchorText;
|
||||
}
|
||||
|
||||
const anchorRecord = toRecord(params.anchorPack);
|
||||
const anchorSummary = toText(anchorRecord?.creatorIntentSummary);
|
||||
if (anchorSummary) {
|
||||
@@ -1574,12 +1590,14 @@ async function buildFoundationDraftProfileWithLlm(params: {
|
||||
llmClient: UpstreamLlmClient;
|
||||
creatorIntent: CustomWorldCreatorIntentRecord;
|
||||
anchorPack: unknown;
|
||||
anchorContent?: EightAnchorContent | null;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: DraftProgressCallback;
|
||||
}) {
|
||||
const settingText = buildFoundationGenerationSeedText({
|
||||
intent: params.creatorIntent,
|
||||
anchorPack: params.anchorPack,
|
||||
anchorContent: params.anchorContent,
|
||||
});
|
||||
|
||||
await emitDraftProgress(params.onProgress, {
|
||||
@@ -1720,22 +1738,14 @@ export class CustomWorldAgentFoundationDraftService {
|
||||
private generateFallbackDraft(params: {
|
||||
creatorIntent: unknown;
|
||||
anchorPack: unknown;
|
||||
anchorContent?: EightAnchorContent | null;
|
||||
}): CustomWorldFoundationDraftProfile {
|
||||
const intent = normalizeCreatorIntentRecord(params.creatorIntent) ?? {
|
||||
sourceMode: 'freeform' as const,
|
||||
rawSettingText: '',
|
||||
worldHook: '',
|
||||
themeKeywords: [],
|
||||
toneDirectives: [],
|
||||
playerPremise: '',
|
||||
openingSituation: '',
|
||||
coreConflicts: [],
|
||||
keyFactions: [],
|
||||
keyCharacters: [],
|
||||
keyLandmarks: [],
|
||||
iconicElements: [],
|
||||
forbiddenDirectives: [],
|
||||
};
|
||||
const normalizedAnchorContent = normalizeEightAnchorContent(
|
||||
params.anchorContent,
|
||||
);
|
||||
const intent =
|
||||
normalizeCreatorIntentRecord(params.creatorIntent) ??
|
||||
buildCreatorIntentFromEightAnchorContent(normalizedAnchorContent);
|
||||
const anchorPack = toRecord(params.anchorPack);
|
||||
const worldHook =
|
||||
clampText(intent.worldHook || intent.rawSettingText, 72) ||
|
||||
@@ -1757,6 +1767,8 @@ export class CustomWorldAgentFoundationDraftService {
|
||||
openingSituation,
|
||||
coreConflict: coreConflicts[0] || '',
|
||||
});
|
||||
const anchorDraftTitle =
|
||||
buildDraftTitleFromEightAnchorContent(normalizedAnchorContent);
|
||||
const factions = buildFactions({
|
||||
intent,
|
||||
coreConflicts,
|
||||
@@ -1815,7 +1827,10 @@ export class CustomWorldAgentFoundationDraftService {
|
||||
);
|
||||
|
||||
return {
|
||||
name: worldName,
|
||||
name:
|
||||
anchorDraftTitle && anchorDraftTitle !== '未命名草稿'
|
||||
? anchorDraftTitle
|
||||
: worldName,
|
||||
subtitle:
|
||||
clampText(
|
||||
[
|
||||
@@ -1845,6 +1860,7 @@ export class CustomWorldAgentFoundationDraftService {
|
||||
openingSituation,
|
||||
iconicElements,
|
||||
sourceAnchorSummary:
|
||||
buildDraftSummaryFromEightAnchorContent(normalizedAnchorContent) ||
|
||||
toText(anchorPack?.creatorIntentSummary) ||
|
||||
buildDraftSummaryFromIntent(intent) ||
|
||||
summary,
|
||||
@@ -1854,10 +1870,15 @@ export class CustomWorldAgentFoundationDraftService {
|
||||
async generate(params: {
|
||||
creatorIntent: unknown;
|
||||
anchorPack: unknown;
|
||||
anchorContent?: EightAnchorContent | null;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: DraftProgressCallback;
|
||||
}): Promise<CustomWorldFoundationDraftProfile> {
|
||||
const intent = normalizeCreatorIntentRecord(params.creatorIntent);
|
||||
const intent =
|
||||
normalizeCreatorIntentRecord(params.creatorIntent) ??
|
||||
buildCreatorIntentFromEightAnchorContent(
|
||||
normalizeEightAnchorContent(params.anchorContent),
|
||||
);
|
||||
|
||||
if (!this.llmClient || !intent) {
|
||||
return this.generateFallbackDraft(params);
|
||||
@@ -1867,6 +1888,7 @@ export class CustomWorldAgentFoundationDraftService {
|
||||
llmClient: this.llmClient,
|
||||
creatorIntent: intent,
|
||||
anchorPack: params.anchorPack,
|
||||
anchorContent: params.anchorContent,
|
||||
signal: params.signal,
|
||||
onProgress: params.onProgress,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import crypto from 'node:crypto';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import type {
|
||||
CreateCustomWorldAgentSessionRequest,
|
||||
@@ -14,6 +15,7 @@ import type {
|
||||
SendCustomWorldAgentMessageResponse,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
import { badRequest, notFound } from '../errors.js';
|
||||
import { prepareEventStreamResponse } from '../http.js';
|
||||
import { CustomWorldAgentAssetBridgeService } from './customWorldAgentAssetBridgeService.js';
|
||||
import { CustomWorldAgentChangeSummaryService } from './customWorldAgentChangeSummaryService.js';
|
||||
import {
|
||||
@@ -31,7 +33,6 @@ import { CustomWorldAgentEntityGenerationService } from './customWorldAgentEntit
|
||||
import { CustomWorldAgentFoundationDraftService } from './customWorldAgentFoundationDraftService.js';
|
||||
import {
|
||||
buildAnchorPackFromIntent,
|
||||
buildCreatorIntentDisplayText,
|
||||
buildDraftSummaryFromIntent,
|
||||
buildDraftTitleFromIntent,
|
||||
createEmptyCreatorIntentRecord,
|
||||
@@ -49,11 +50,16 @@ import {
|
||||
type CustomWorldAgentSessionRecord,
|
||||
CustomWorldAgentSessionStore,
|
||||
} from './customWorldAgentSessionStore.js';
|
||||
import {
|
||||
buildAnchorPackFromEightAnchorContent,
|
||||
buildCreatorIntentFromEightAnchorContent,
|
||||
buildEightAnchorContentFromCreatorIntent,
|
||||
estimateProgressPercentFromAnchorContent,
|
||||
} from './eightAnchorCompatibilityService.js';
|
||||
import { EightAnchorSingleTurnService } from './eightAnchorSingleTurnService.js';
|
||||
import type { UpstreamLlmClient } from './llmClient.js';
|
||||
|
||||
const PHASE2_FORCE_FAIL_TOKEN = '__phase1_force_fail__';
|
||||
const AUTO_COMPLETE_PATTERN = /自动补全|默认方案|帮我补全/u;
|
||||
|
||||
function truncateText(value: string, maxLength: number) {
|
||||
if (value.length <= maxLength) {
|
||||
return value;
|
||||
@@ -137,46 +143,10 @@ function buildSuggestedActions(
|
||||
return actions;
|
||||
}
|
||||
|
||||
function buildAutoCompletePatch(intent: CustomWorldCreatorIntentRecord) {
|
||||
return {
|
||||
worldHook:
|
||||
intent.worldHook ||
|
||||
intent.rawSettingText ||
|
||||
'一个被未知异象改变秩序的边境世界。',
|
||||
playerPremise: intent.playerPremise || '玩家是被卷入核心危机的返乡者。',
|
||||
openingSituation:
|
||||
intent.openingSituation || '开局时,玩家正抵达危机爆发的现场。',
|
||||
themeKeywords:
|
||||
intent.themeKeywords.length > 0 ? intent.themeKeywords : ['奇幻'],
|
||||
toneDirectives:
|
||||
intent.toneDirectives.length > 0 ? intent.toneDirectives : ['悬疑'],
|
||||
coreConflicts:
|
||||
intent.coreConflicts.length > 0
|
||||
? intent.coreConflicts
|
||||
: ['旧秩序与新威胁正在争夺世界的未来。'],
|
||||
keyCharacters:
|
||||
intent.keyCharacters.length > 0
|
||||
? intent.keyCharacters
|
||||
: [
|
||||
{
|
||||
id: 'auto-key-character-1',
|
||||
name: '未命名关键人物',
|
||||
role: '关键关系',
|
||||
publicMask: '看似能帮助玩家的人。',
|
||||
hiddenHook: '掌握一条会改变局势的暗线。',
|
||||
relationToPlayer: '旧识',
|
||||
notes: '自动补全,可继续修改。',
|
||||
},
|
||||
],
|
||||
iconicElements:
|
||||
intent.iconicElements.length > 0 ? intent.iconicElements : ['失落信标'],
|
||||
};
|
||||
}
|
||||
|
||||
function buildOperation(type: CustomWorldAgentOperationRecord['type']) {
|
||||
const phaseDetail =
|
||||
type === 'draft_foundation'
|
||||
? '正在把已确认锚点编成第一版世界底稿。'
|
||||
? '正在把已确认设定编成第一版世界底稿。'
|
||||
: type === 'update_draft_card'
|
||||
? '正在把这次设定改动写回草稿。'
|
||||
: type === 'generate_characters'
|
||||
@@ -184,10 +154,10 @@ function buildOperation(type: CustomWorldAgentOperationRecord['type']) {
|
||||
: type === 'generate_landmarks'
|
||||
? '正在围绕当前底稿补出新地点。'
|
||||
: type === 'generate_role_assets'
|
||||
? '正在准备角色资产工坊入口。'
|
||||
: type === 'sync_role_assets'
|
||||
? '正在把角色资产结果写回世界草稿。'
|
||||
: '正在整理这一轮新增的世界锚点。';
|
||||
? '正在准备角色资产工坊入口。'
|
||||
: type === 'sync_role_assets'
|
||||
? '正在把角色资产结果写回世界草稿。'
|
||||
: '正在整理这一轮新增的世界设定。';
|
||||
|
||||
return {
|
||||
operationId: `operation-${crypto.randomBytes(10).toString('hex')}`,
|
||||
@@ -223,20 +193,10 @@ function buildRoleAssetSyncResultText(params: {
|
||||
return `已把「${params.roleName}」的角色资产写回草稿,当前状态:${params.assetStatusLabel}。`;
|
||||
}
|
||||
|
||||
function getRecentUserMessages(session: CustomWorldAgentSessionRecord) {
|
||||
return session.messages
|
||||
.filter((message) => message.role === 'user')
|
||||
.map((message) => message.text.trim())
|
||||
.filter(Boolean)
|
||||
.slice(-12);
|
||||
}
|
||||
|
||||
function buildQuestionLines(
|
||||
pendingClarifications: CustomWorldPendingClarification[],
|
||||
) {
|
||||
return pendingClarifications.map(
|
||||
(entry, index) => `${index + 1}. ${entry.question}`,
|
||||
);
|
||||
return pendingClarifications.map((entry) => entry.question.trim());
|
||||
}
|
||||
|
||||
function composeAssistantReply(params: {
|
||||
@@ -250,7 +210,7 @@ function composeAssistantReply(params: {
|
||||
return [
|
||||
params.openingText,
|
||||
params.isReady
|
||||
? '最小锚点已经齐备。'
|
||||
? '当前设定已经齐备。'
|
||||
: questionLines.slice(0, 1).join('\n'),
|
||||
].join('\n');
|
||||
}
|
||||
@@ -311,227 +271,6 @@ function buildWelcomeMessage(params: {
|
||||
});
|
||||
}
|
||||
|
||||
function buildAssistantMessage(params: {
|
||||
latestUserText: string;
|
||||
relatedOperationId: string;
|
||||
intent: CustomWorldCreatorIntentRecord;
|
||||
pendingClarifications: CustomWorldPendingClarification[];
|
||||
isReady: boolean;
|
||||
}) {
|
||||
return {
|
||||
id: `message-${crypto.randomBytes(8).toString('hex')}`,
|
||||
role: 'assistant',
|
||||
kind: params.isReady ? 'summary' : 'clarification',
|
||||
text: composeAssistantReply({
|
||||
openingText: `收到:${truncateText(params.latestUserText, 88)}`,
|
||||
intent: params.intent,
|
||||
pendingClarifications: params.pendingClarifications,
|
||||
isReady: params.isReady,
|
||||
}),
|
||||
createdAt: new Date().toISOString(),
|
||||
relatedOperationId: params.relatedOperationId,
|
||||
} satisfies CustomWorldAgentMessage;
|
||||
}
|
||||
|
||||
function buildAgentSystemPrompt(params: {
|
||||
isReady: boolean;
|
||||
hasAnyAnchors: boolean;
|
||||
}) {
|
||||
const baseInstructions = [
|
||||
'你是一个专业的RPG游戏剧情策划,通过对话帮助用户补全结构化世界锚点。',
|
||||
'',
|
||||
'# 核心原则',
|
||||
'- 像创作者搭档,不要写系统说明,不要列规则,不要提到 JSON 或后端',
|
||||
'- 用中文自然回复,语气专业但友好',
|
||||
'- 不要重复追问用户已经明确回答过的信息',
|
||||
'- 每次只聚焦一个关键问题,帮助用户高效推进',
|
||||
'',
|
||||
'# 输出格式',
|
||||
'必须输出严格的 JSON 格式:{“reply”:”...”,”recommendedReplies”:[“...”,”...”,”...”]}',
|
||||
'',
|
||||
];
|
||||
|
||||
if (params.isReady) {
|
||||
return [
|
||||
...baseInstructions,
|
||||
'# 当前阶段:设定已齐备',
|
||||
'',
|
||||
'## reply 字段要求',
|
||||
'- 第一段:明确回应并收住用户刚刚给出的具体设定',
|
||||
'- 第二段:明确告诉用户关键设定已经足够,可以生成第一版游戏草稿了',
|
||||
'- 最后:自然询问是否现在开始生成草稿',
|
||||
'- 整体要短,聚焦推进',
|
||||
'',
|
||||
'## recommendedReplies 字段要求',
|
||||
'- 必须正好 3 条',
|
||||
'- 每条都是用户下一句可以直接发送的话',
|
||||
'- 第 1 条:表达开始生成草稿(例如:”现在开始生成草稿”)',
|
||||
'- 第 2 条:让 Agent 总结当前设定(例如:”先总结一下当前设定”)',
|
||||
'- 第 3 条:继续补充设定内容(例如:”我还想再补充一点”)',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// When anchors are empty, use inspirational questioning strategy
|
||||
if (!params.hasAnyAnchors) {
|
||||
return [
|
||||
...baseInstructions,
|
||||
'# 当前阶段:初始启发',
|
||||
'',
|
||||
'## reply 字段要求',
|
||||
'- 第一段:如果用户刚进入对话还没说话,用欢迎语气开场(例如:”想创造一个什么样的世界?”)',
|
||||
'- 第一段:如果用户已经说了话,简短回应用户的输入',
|
||||
'- 第二段:提出一个开放性、启发性的问题,帮助用户构思世界的核心概念',
|
||||
'- 问题应该是高层次的,关于世界类型、主题、核心理念,而不是具体细节',
|
||||
'- 例如:世界的整体风格、故事的核心主题、想传达的感觉',
|
||||
'- 避免过早询问具体设定细节(如魔法系统、科技水平等)',
|
||||
'',
|
||||
'## recommendedReplies 字段要求',
|
||||
'- 必须正好 3 条',
|
||||
'- 3 条都是对当前问题的不同方向的回答',
|
||||
'- 每条回答应该代表一种不同的世界类型或主题方向',
|
||||
'- 回答要具体但不过于详细,给用户启发和选择空间',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
return [
|
||||
...baseInstructions,
|
||||
'# 当前阶段:收集设定中',
|
||||
'',
|
||||
'## reply 字段要求',
|
||||
'- 第一段:明确回应并收住用户上一次给出的具体落地设定(不能只说”收到”)',
|
||||
'- 第二段:固定只追问 1 个当前最关键、最能推进游戏设定的问题',
|
||||
'- 这个问题必须帮助你更快拿到作品最核心的设定信息',
|
||||
'- 必要时给一个很短的示例,帮助用户高效回答',
|
||||
'',
|
||||
'## recommendedReplies 字段要求',
|
||||
'- 必须正好 3 条',
|
||||
'- 3 条都必须是对当前这一个问题的直接回答',
|
||||
'- 不允许继续提问',
|
||||
'- 不允许写成”你先帮我””继续问我”这种让 Agent 行动的句子',
|
||||
'- 回答要尽量具体,优先提供能推进作品设定的核心信息',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function buildAgentUserPrompt(params: {
|
||||
session: CustomWorldAgentSessionRecord;
|
||||
latestUserText: string;
|
||||
intent: CustomWorldCreatorIntentRecord;
|
||||
pendingClarifications: CustomWorldPendingClarification[];
|
||||
isReady: boolean;
|
||||
}) {
|
||||
const recentMessages = params.session.messages
|
||||
.slice(-18)
|
||||
.map((message) => `${message.role}: ${message.text}`)
|
||||
.join('\n');
|
||||
const pendingQuestions = params.pendingClarifications
|
||||
.slice(0, 1)
|
||||
.map((entry) => `${entry.label}: ${entry.question}`)
|
||||
.join('\n');
|
||||
|
||||
return [
|
||||
'# 当前结构化世界锚点',
|
||||
buildCreatorIntentDisplayText(params.intent) || '暂无',
|
||||
'',
|
||||
`# 锚点是否齐备`,
|
||||
params.isReady ? '是' : '否',
|
||||
'',
|
||||
pendingQuestions ? `# 待确认问题\n${pendingQuestions}\n` : '',
|
||||
'# 最近对话',
|
||||
recentMessages || '暂无',
|
||||
'',
|
||||
'# 用户最新输入',
|
||||
params.latestUserText,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function parseAssistantTurnJson(text: string) {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as {
|
||||
reply?: unknown;
|
||||
recommendedReplies?: unknown;
|
||||
};
|
||||
const reply = typeof parsed.reply === 'string' ? parsed.reply.trim() : '';
|
||||
const recommendedReplies = Array.isArray(parsed.recommendedReplies)
|
||||
? parsed.recommendedReplies
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter(Boolean)
|
||||
.slice(0, 3)
|
||||
: [];
|
||||
|
||||
return {
|
||||
reply,
|
||||
recommendedReplies,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
reply: '',
|
||||
recommendedReplies: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildFallbackRecommendedReplies(params: {
|
||||
pendingClarifications: CustomWorldPendingClarification[];
|
||||
isReady: boolean;
|
||||
}) {
|
||||
if (params.isReady) {
|
||||
return ['现在开始生成草稿', '先总结一下当前设定', '我还想再补充一点'];
|
||||
}
|
||||
|
||||
const nextQuestion = params.pendingClarifications[0];
|
||||
if (!nextQuestion) {
|
||||
return ['继续', '给我一个默认方案', '先总结一下'];
|
||||
}
|
||||
|
||||
if (nextQuestion.targetKey === 'world_hook') {
|
||||
return [
|
||||
'一个被潮雾切开的列岛世界。',
|
||||
'一个旧神遗产复苏的边境世界。',
|
||||
'一个灯塔决定航路生死的海雾世界。',
|
||||
];
|
||||
}
|
||||
|
||||
if (nextQuestion.targetKey === 'player_premise') {
|
||||
return [
|
||||
'玩家是被迫返乡的失职守灯人。',
|
||||
'玩家是背着旧案回来的流亡航海士。',
|
||||
'玩家是被逐出组织的前探路员。',
|
||||
];
|
||||
}
|
||||
|
||||
if (nextQuestion.targetKey === 'theme_and_tone') {
|
||||
return [
|
||||
'整体偏冷峻、潮湿、悬疑。',
|
||||
'气质偏压迫、克制、带一点宿命感。',
|
||||
'我想要浪漫外壳下的阴冷悬疑。',
|
||||
];
|
||||
}
|
||||
|
||||
if (nextQuestion.targetKey === 'core_conflict') {
|
||||
return [
|
||||
'核心冲突是旧航路解释权之争。',
|
||||
'主要危机是被封印的灾难正在重演。',
|
||||
'核心矛盾是守旧势力和新秩序正面冲突。',
|
||||
];
|
||||
}
|
||||
|
||||
if (nextQuestion.targetKey === 'relationship_seed') {
|
||||
return [
|
||||
'关键人物是玩家的旧友兼宿敌。',
|
||||
'她表面帮助玩家,其实另有立场。',
|
||||
'关键钩子是玩家必须再次相信曾经背叛自己的人。',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'标志性元素是潮雾钟声。',
|
||||
'标志性规则是夜里不能出海。',
|
||||
'地标意象是永不熄灭的盐火灯塔。',
|
||||
];
|
||||
}
|
||||
|
||||
function buildFoundationDraftAssistantMessage(params: {
|
||||
relatedOperationId: string;
|
||||
draftProfile: unknown;
|
||||
@@ -555,31 +294,6 @@ function buildFoundationDraftAssistantMessage(params: {
|
||||
} satisfies CustomWorldAgentMessage;
|
||||
}
|
||||
|
||||
function buildObjectRefiningAssistantMessage(params: {
|
||||
latestUserText: string;
|
||||
relatedOperationId: string;
|
||||
draftProfile: unknown;
|
||||
}) {
|
||||
const profile = normalizeFoundationDraftProfile(params.draftProfile);
|
||||
const leadCharacter = profile?.playableNpcs[0];
|
||||
const leadLandmark = profile?.landmarks[0];
|
||||
|
||||
return {
|
||||
id: `message-${crypto.randomBytes(8).toString('hex')}`,
|
||||
role: 'assistant',
|
||||
kind: 'summary',
|
||||
text: [
|
||||
`我先把你这轮补充挂回当前底稿语境里:${truncateText(params.latestUserText, 88)}`,
|
||||
'',
|
||||
profile?.summary || '当前底稿仍然保留,你可以继续围绕已有卡片精修。',
|
||||
'',
|
||||
`现在更适合直接看卡继续收紧内容${leadCharacter ? `,角色建议先看「${leadCharacter.name}」` : ''}${leadLandmark ? `,地点建议先看「${leadLandmark.name}」` : ''}。`,
|
||||
].join('\n'),
|
||||
createdAt: new Date().toISOString(),
|
||||
relatedOperationId: params.relatedOperationId,
|
||||
} satisfies CustomWorldAgentMessage;
|
||||
}
|
||||
|
||||
function buildActionResultMessage(params: {
|
||||
relatedOperationId: string;
|
||||
text: string;
|
||||
@@ -594,6 +308,19 @@ function buildActionResultMessage(params: {
|
||||
} satisfies CustomWorldAgentMessage;
|
||||
}
|
||||
|
||||
function writeSseEvent(
|
||||
response: Response,
|
||||
event: string,
|
||||
data: unknown,
|
||||
) {
|
||||
if (response.writableEnded) {
|
||||
return;
|
||||
}
|
||||
|
||||
response.write(`event: ${event}\n`);
|
||||
response.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
}
|
||||
|
||||
export class CustomWorldAgentOrchestrator {
|
||||
private readonly foundationDraftService: CustomWorldAgentFoundationDraftService;
|
||||
|
||||
@@ -605,9 +332,14 @@ export class CustomWorldAgentOrchestrator {
|
||||
|
||||
private readonly assetBridgeService: CustomWorldAgentAssetBridgeService;
|
||||
|
||||
private readonly eightAnchorSingleTurnService: EightAnchorSingleTurnService;
|
||||
|
||||
constructor(
|
||||
private readonly sessionStore: CustomWorldAgentSessionStore,
|
||||
private readonly llmClient: UpstreamLlmClient | null = null,
|
||||
llmClient: UpstreamLlmClient | null = null,
|
||||
options: {
|
||||
singleTurnLlmClient?: UpstreamLlmClient | null;
|
||||
} = {},
|
||||
) {
|
||||
this.foundationDraftService = new CustomWorldAgentFoundationDraftService(
|
||||
llmClient,
|
||||
@@ -618,6 +350,9 @@ export class CustomWorldAgentOrchestrator {
|
||||
);
|
||||
this.changeSummaryService = new CustomWorldAgentChangeSummaryService();
|
||||
this.assetBridgeService = new CustomWorldAgentAssetBridgeService();
|
||||
this.eightAnchorSingleTurnService = new EightAnchorSingleTurnService(
|
||||
(options.singleTurnLlmClient ?? llmClient) ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
async createSession(
|
||||
@@ -634,59 +369,35 @@ export class CustomWorldAgentOrchestrator {
|
||||
: {};
|
||||
const creatorIntent = mergeCreatorIntentRecord(baseIntent, seedPatch);
|
||||
const derivedState = buildDerivedState(creatorIntent, Boolean(seedText));
|
||||
const anchorContent = buildEightAnchorContentFromCreatorIntent(creatorIntent);
|
||||
const progressPercent = seedText
|
||||
? estimateProgressPercentFromAnchorContent(anchorContent)
|
||||
: 0;
|
||||
const fallbackWelcomeMessage = buildWelcomeMessage({
|
||||
seedText,
|
||||
intent: creatorIntent,
|
||||
pendingClarifications: derivedState.pendingClarifications,
|
||||
isReady: derivedState.readiness.isReady,
|
||||
});
|
||||
const initialAssistantTurn = await this.generateAssistantTurn({
|
||||
session: {
|
||||
sessionId: 'preview',
|
||||
userId,
|
||||
seedText,
|
||||
stage: derivedState.stage,
|
||||
focusCardId: null,
|
||||
creatorIntent,
|
||||
creatorIntentReadiness: derivedState.readiness,
|
||||
anchorPack: derivedState.anchorPack,
|
||||
lockState: {},
|
||||
draftProfile: derivedState.draftProfile,
|
||||
messages: [],
|
||||
draftCards: [],
|
||||
pendingClarifications: derivedState.pendingClarifications,
|
||||
suggestedActions: derivedState.suggestedActions,
|
||||
recommendedReplies: [],
|
||||
qualityFindings: [],
|
||||
assetCoverage: {
|
||||
roleAssets: [],
|
||||
sceneAssets: [],
|
||||
allRoleAssetsReady: false,
|
||||
allSceneAssetsReady: false,
|
||||
},
|
||||
operations: [],
|
||||
checkpoints: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
latestUserText: seedText,
|
||||
fallbackReply: fallbackWelcomeMessage,
|
||||
intent: creatorIntent,
|
||||
pendingClarifications: derivedState.pendingClarifications,
|
||||
isReady: derivedState.readiness.isReady,
|
||||
});
|
||||
|
||||
const record = await this.sessionStore.create(userId, {
|
||||
seedText,
|
||||
welcomeMessage: initialAssistantTurn.reply,
|
||||
welcomeMessage: fallbackWelcomeMessage,
|
||||
currentTurn: 0,
|
||||
anchorContent,
|
||||
progressPercent,
|
||||
lastAssistantReply: fallbackWelcomeMessage,
|
||||
creatorIntent,
|
||||
creatorIntentReadiness: derivedState.readiness,
|
||||
anchorPack: derivedState.anchorPack,
|
||||
anchorPack: buildAnchorPackFromEightAnchorContent(
|
||||
anchorContent,
|
||||
progressPercent,
|
||||
),
|
||||
draftProfile: derivedState.draftProfile,
|
||||
pendingClarifications: derivedState.pendingClarifications,
|
||||
stage: derivedState.stage,
|
||||
stage: progressPercent >= 100 ? 'foundation_review' : 'collecting_intent',
|
||||
suggestedActions: derivedState.suggestedActions,
|
||||
recommendedReplies: initialAssistantTurn.recommendedReplies,
|
||||
recommendedReplies: [],
|
||||
});
|
||||
|
||||
return (await this.sessionStore.getSnapshot(
|
||||
@@ -723,6 +434,7 @@ export class CustomWorldAgentOrchestrator {
|
||||
sessionId,
|
||||
operationId: operation.operationId,
|
||||
latestUserText: trimmedText,
|
||||
quickFillRequested: Boolean(payload.quickFillRequested),
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -730,6 +442,68 @@ export class CustomWorldAgentOrchestrator {
|
||||
};
|
||||
}
|
||||
|
||||
async streamMessage(params: {
|
||||
request: Request;
|
||||
response: Response;
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
payload: SendCustomWorldAgentMessageRequest;
|
||||
}) {
|
||||
const session = await this.sessionStore.get(params.userId, params.sessionId);
|
||||
if (!session) {
|
||||
throw notFound('custom world agent session not found');
|
||||
}
|
||||
|
||||
prepareEventStreamResponse(params.request, params.response);
|
||||
|
||||
const trimmedText = params.payload.text.trim();
|
||||
const userMessage = buildUserMessage(
|
||||
trimmedText,
|
||||
params.payload.clientMessageId,
|
||||
);
|
||||
await this.sessionStore.appendMessage(
|
||||
params.userId,
|
||||
params.sessionId,
|
||||
userMessage,
|
||||
);
|
||||
|
||||
let latestReplyText = '';
|
||||
|
||||
try {
|
||||
const nextSession = await this.applyMessageTurn({
|
||||
userId: params.userId,
|
||||
sessionId: params.sessionId,
|
||||
latestUserText: trimmedText,
|
||||
quickFillRequested: Boolean(params.payload.quickFillRequested),
|
||||
relatedOperationId: null,
|
||||
onReplyUpdate: (text) => {
|
||||
if (!text.trim() || text === latestReplyText) {
|
||||
return;
|
||||
}
|
||||
|
||||
latestReplyText = text;
|
||||
writeSseEvent(params.response, 'reply_delta', {
|
||||
text,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
writeSseEvent(params.response, 'session', {
|
||||
session: nextSession,
|
||||
});
|
||||
writeSseEvent(params.response, 'done', {
|
||||
ok: true,
|
||||
});
|
||||
} catch (error) {
|
||||
writeSseEvent(params.response, 'error', {
|
||||
message:
|
||||
error instanceof Error ? error.message : 'stream custom world message failed',
|
||||
});
|
||||
} finally {
|
||||
params.response.end();
|
||||
}
|
||||
}
|
||||
|
||||
async executeAction(
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
@@ -741,14 +515,8 @@ export class CustomWorldAgentOrchestrator {
|
||||
}
|
||||
|
||||
if (payload.action === 'draft_foundation') {
|
||||
if (session.stage !== 'foundation_review') {
|
||||
throw badRequest(
|
||||
'draft_foundation is only available during foundation_review',
|
||||
);
|
||||
}
|
||||
|
||||
if (!session.creatorIntentReadiness.isReady) {
|
||||
throw badRequest('draft_foundation requires a ready session');
|
||||
if (session.progressPercent < 100) {
|
||||
throw badRequest('draft_foundation requires progressPercent >= 100');
|
||||
}
|
||||
|
||||
const operation = buildOperation('draft_foundation');
|
||||
@@ -919,57 +687,151 @@ export class CustomWorldAgentOrchestrator {
|
||||
return this.draftCompiler.getDraftCardDetail(session.draftProfile, cardId);
|
||||
}
|
||||
|
||||
private async generateAssistantTurn(params: {
|
||||
session: CustomWorldAgentSessionRecord;
|
||||
private async applyMessageTurn(params: {
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
latestUserText: string;
|
||||
fallbackReply: string;
|
||||
intent: CustomWorldCreatorIntentRecord;
|
||||
pendingClarifications: CustomWorldPendingClarification[];
|
||||
isReady: boolean;
|
||||
quickFillRequested: boolean;
|
||||
relatedOperationId?: string | null;
|
||||
onReplyUpdate?: (text: string) => void;
|
||||
}) {
|
||||
const fallbackReplies = buildFallbackRecommendedReplies({
|
||||
pendingClarifications: params.pendingClarifications,
|
||||
isReady: params.isReady,
|
||||
const latestSession = (await this.sessionStore.get(
|
||||
params.userId,
|
||||
params.sessionId,
|
||||
)) as CustomWorldAgentSessionRecord | null;
|
||||
if (!latestSession) {
|
||||
throw new Error('custom world agent session not found');
|
||||
}
|
||||
|
||||
const shouldPreserveDraftStage =
|
||||
(latestSession.stage === 'object_refining' ||
|
||||
latestSession.stage === 'visual_refining') &&
|
||||
latestSession.draftCards.length > 0;
|
||||
|
||||
const assistantTurn = await this.eightAnchorSingleTurnService.streamTurn(
|
||||
{
|
||||
currentTurn: latestSession.currentTurn + 1,
|
||||
progressPercent: latestSession.progressPercent,
|
||||
quickFillRequested: params.quickFillRequested,
|
||||
currentAnchorContent: latestSession.anchorContent,
|
||||
chatHistory: latestSession.messages
|
||||
.filter(
|
||||
(message): message is CustomWorldAgentMessage =>
|
||||
(message.role === 'user' || message.role === 'assistant') &&
|
||||
Boolean(message.text.trim()),
|
||||
)
|
||||
.map((message) => ({
|
||||
role: message.role,
|
||||
content: message.text,
|
||||
})),
|
||||
},
|
||||
{
|
||||
onReplyUpdate: params.onReplyUpdate,
|
||||
},
|
||||
);
|
||||
const nextCreatorIntent = buildCreatorIntentFromEightAnchorContent(
|
||||
assistantTurn.nextAnchorContent,
|
||||
);
|
||||
const progressPercent = Math.max(
|
||||
0,
|
||||
Math.min(100, Math.round(assistantTurn.progressPercent)),
|
||||
);
|
||||
const creatorIntentReadiness =
|
||||
progressPercent >= 100
|
||||
? {
|
||||
isReady: true,
|
||||
completedKeys: [
|
||||
'world_hook',
|
||||
'player_premise',
|
||||
'theme_and_tone',
|
||||
'core_conflict',
|
||||
'relationship_seed',
|
||||
'iconic_element',
|
||||
],
|
||||
missingKeys: [],
|
||||
}
|
||||
: evaluateCreatorIntentReadiness(nextCreatorIntent);
|
||||
const derivedState = buildDerivedState(nextCreatorIntent, true);
|
||||
const preservedStage =
|
||||
latestSession.stage === 'visual_refining'
|
||||
? ('visual_refining' as const)
|
||||
: ('object_refining' as const);
|
||||
const shouldStayInDraftStage =
|
||||
shouldPreserveDraftStage && progressPercent >= 100;
|
||||
const nextStage = shouldStayInDraftStage
|
||||
? preservedStage
|
||||
: derivedState.stage;
|
||||
const assistantMessage = {
|
||||
id: `message-${crypto.randomBytes(8).toString('hex')}`,
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: assistantTurn.replyText,
|
||||
createdAt: new Date().toISOString(),
|
||||
relatedOperationId: params.relatedOperationId ?? null,
|
||||
} satisfies CustomWorldAgentMessage;
|
||||
|
||||
await this.sessionStore.replaceDerivedState(params.userId, params.sessionId, {
|
||||
currentTurn: latestSession.currentTurn + 1,
|
||||
anchorContent: assistantTurn.nextAnchorContent,
|
||||
progressPercent,
|
||||
lastAssistantReply: assistantTurn.replyText,
|
||||
stage: nextStage,
|
||||
focusCardId: shouldStayInDraftStage ? latestSession.focusCardId : null,
|
||||
creatorIntent: nextCreatorIntent,
|
||||
creatorIntentReadiness,
|
||||
anchorPack: buildAnchorPackFromEightAnchorContent(
|
||||
assistantTurn.nextAnchorContent,
|
||||
progressPercent,
|
||||
),
|
||||
draftProfile: shouldStayInDraftStage
|
||||
? latestSession.draftProfile
|
||||
: progressPercent >= 100
|
||||
? {
|
||||
title: buildDraftTitleFromIntent(nextCreatorIntent),
|
||||
summary: buildDraftSummaryFromIntent(nextCreatorIntent),
|
||||
}
|
||||
: derivedState.draftProfile,
|
||||
draftCards: shouldStayInDraftStage ? latestSession.draftCards : [],
|
||||
assetCoverage: shouldStayInDraftStage
|
||||
? latestSession.assetCoverage
|
||||
: rebuildRoleAssetCoverage(
|
||||
progressPercent >= 100
|
||||
? {
|
||||
title: buildDraftTitleFromIntent(nextCreatorIntent),
|
||||
summary: buildDraftSummaryFromIntent(nextCreatorIntent),
|
||||
}
|
||||
: derivedState.draftProfile,
|
||||
),
|
||||
pendingClarifications:
|
||||
progressPercent >= 100 ? [] : derivedState.pendingClarifications,
|
||||
suggestedActions: shouldStayInDraftStage
|
||||
? buildSuggestedActions({
|
||||
stage: preservedStage,
|
||||
isReady: true,
|
||||
draftProfile: latestSession.draftProfile,
|
||||
draftCards: latestSession.draftCards,
|
||||
})
|
||||
: progressPercent >= 100
|
||||
? [
|
||||
{
|
||||
id: 'draft_foundation',
|
||||
type: 'draft_foundation',
|
||||
label: '生成游戏设定草稿',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
recommendedReplies: [],
|
||||
});
|
||||
await this.sessionStore.appendMessage(
|
||||
params.userId,
|
||||
params.sessionId,
|
||||
assistantMessage,
|
||||
);
|
||||
|
||||
if (!this.llmClient) {
|
||||
return {
|
||||
reply: params.fallbackReply,
|
||||
recommendedReplies: fallbackReplies,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await this.llmClient.requestMessageContent({
|
||||
systemPrompt: buildAgentSystemPrompt({
|
||||
isReady: params.isReady,
|
||||
hasAnyAnchors: hasMeaningfulCreatorIntentRecord(params.intent),
|
||||
}),
|
||||
userPrompt: buildAgentUserPrompt({
|
||||
session: params.session,
|
||||
latestUserText: params.latestUserText,
|
||||
intent: params.intent,
|
||||
pendingClarifications: params.pendingClarifications,
|
||||
isReady: params.isReady,
|
||||
}),
|
||||
timeoutMs: 60000,
|
||||
debugLabel: 'custom-world-agent-chat-turn',
|
||||
});
|
||||
const parsed = parseAssistantTurnJson(content);
|
||||
|
||||
return {
|
||||
reply: parsed.reply || params.fallbackReply,
|
||||
recommendedReplies:
|
||||
parsed.recommendedReplies.length === 3
|
||||
? parsed.recommendedReplies
|
||||
: fallbackReplies,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
reply: params.fallbackReply,
|
||||
recommendedReplies: fallbackReplies,
|
||||
};
|
||||
}
|
||||
return (await this.sessionStore.getSnapshot(
|
||||
params.userId,
|
||||
params.sessionId,
|
||||
)) as CustomWorldAgentSessionSnapshot;
|
||||
}
|
||||
|
||||
private async processDraftFoundationOperation(params: {
|
||||
@@ -983,7 +845,7 @@ export class CustomWorldAgentOrchestrator {
|
||||
await this.sessionStore.updateOperation(userId, sessionId, operationId, {
|
||||
status: 'running',
|
||||
phaseLabel: '生成世界底稿',
|
||||
phaseDetail: '正在根据已确认锚点编译第一版世界结构。',
|
||||
phaseDetail: '正在根据已确认设定编译第一版世界结构。',
|
||||
progress: 38,
|
||||
});
|
||||
|
||||
@@ -997,16 +859,22 @@ export class CustomWorldAgentOrchestrator {
|
||||
throw new Error('custom world agent session not found');
|
||||
}
|
||||
|
||||
if (
|
||||
latestSession.stage !== 'foundation_review' ||
|
||||
!latestSession.creatorIntentReadiness.isReady
|
||||
) {
|
||||
throw new Error('session is not ready for draft_foundation');
|
||||
if (latestSession.progressPercent < 100) {
|
||||
throw new Error('session progressPercent is below 100');
|
||||
}
|
||||
|
||||
const creatorIntent = buildCreatorIntentFromEightAnchorContent(
|
||||
latestSession.anchorContent,
|
||||
);
|
||||
const anchorPack = buildAnchorPackFromEightAnchorContent(
|
||||
latestSession.anchorContent,
|
||||
latestSession.progressPercent,
|
||||
);
|
||||
|
||||
const draftProfile = await this.foundationDraftService.generate({
|
||||
creatorIntent: latestSession.creatorIntent,
|
||||
anchorPack: latestSession.anchorPack,
|
||||
creatorIntent,
|
||||
anchorPack,
|
||||
anchorContent: latestSession.anchorContent,
|
||||
onProgress: async (progress) => {
|
||||
await this.sessionStore.updateOperation(
|
||||
userId,
|
||||
@@ -1040,6 +908,8 @@ export class CustomWorldAgentOrchestrator {
|
||||
|
||||
await this.sessionStore.replaceDerivedState(userId, sessionId, {
|
||||
stage: nextStage,
|
||||
creatorIntent,
|
||||
anchorPack,
|
||||
draftProfile: draftProfile as unknown as Record<string, unknown>,
|
||||
draftCards,
|
||||
assetCoverage,
|
||||
@@ -1070,7 +940,7 @@ export class CustomWorldAgentOrchestrator {
|
||||
await this.sessionStore.updateOperation(userId, sessionId, operationId, {
|
||||
status: 'failed',
|
||||
phaseLabel: '底稿生成失败',
|
||||
phaseDetail: '这一轮没有成功把锚点编成世界底稿。',
|
||||
phaseDetail: '这一轮没有成功把设定编成世界底稿。',
|
||||
progress: 100,
|
||||
error:
|
||||
error instanceof Error ? error.message : 'draft foundation failed',
|
||||
@@ -1596,14 +1466,23 @@ export class CustomWorldAgentOrchestrator {
|
||||
sessionId: string;
|
||||
operationId: string;
|
||||
latestUserText: string;
|
||||
quickFillRequested: boolean;
|
||||
}) {
|
||||
const { userId, sessionId, operationId, latestUserText } = params;
|
||||
const {
|
||||
userId,
|
||||
sessionId,
|
||||
operationId,
|
||||
latestUserText,
|
||||
quickFillRequested,
|
||||
} = params;
|
||||
|
||||
try {
|
||||
await this.sessionStore.updateOperation(userId, sessionId, operationId, {
|
||||
status: 'running',
|
||||
phaseLabel: '提取世界锚点',
|
||||
phaseDetail: '正在把这轮自然语言补充整理成结构化创作意图。',
|
||||
phaseLabel: quickFillRequested ? '补全剩余设定' : '整理当前设定',
|
||||
phaseDetail: quickFillRequested
|
||||
? '正在基于当前方向补齐剩余设定。'
|
||||
: '正在把这轮输入沉淀成新的完整设定。',
|
||||
progress: 45,
|
||||
});
|
||||
|
||||
@@ -1621,107 +1500,27 @@ export class CustomWorldAgentOrchestrator {
|
||||
throw new Error('custom world agent session not found');
|
||||
}
|
||||
|
||||
const currentIntent =
|
||||
normalizeCreatorIntentRecord(latestSession.creatorIntent) ??
|
||||
createEmptyCreatorIntentRecord('freeform');
|
||||
const recentMessages = getRecentUserMessages(latestSession).slice(0, -1);
|
||||
const intentPatch = extractCreatorIntentPatch({
|
||||
currentIntent,
|
||||
latestUserMessage: latestUserText,
|
||||
recentMessages,
|
||||
});
|
||||
const nextIntent = mergeCreatorIntentRecord(
|
||||
currentIntent,
|
||||
AUTO_COMPLETE_PATTERN.test(latestUserText)
|
||||
? {
|
||||
...intentPatch,
|
||||
...buildAutoCompletePatch(currentIntent),
|
||||
}
|
||||
: intentPatch,
|
||||
);
|
||||
const derivedState = buildDerivedState(nextIntent, true);
|
||||
const shouldPreserveDraftStage =
|
||||
(latestSession.stage === 'object_refining' ||
|
||||
latestSession.stage === 'visual_refining') &&
|
||||
latestSession.draftCards.length > 0;
|
||||
const preservedStage =
|
||||
latestSession.stage === 'visual_refining'
|
||||
? ('visual_refining' as const)
|
||||
: ('object_refining' as const);
|
||||
const nextSuggestedActions = shouldPreserveDraftStage
|
||||
? buildSuggestedActions({
|
||||
stage: preservedStage,
|
||||
isReady: true,
|
||||
draftProfile: latestSession.draftProfile,
|
||||
draftCards: latestSession.draftCards,
|
||||
})
|
||||
: derivedState.suggestedActions;
|
||||
|
||||
await this.sessionStore.replaceDerivedState(userId, sessionId, {
|
||||
stage: shouldPreserveDraftStage ? preservedStage : derivedState.stage,
|
||||
creatorIntent: nextIntent,
|
||||
creatorIntentReadiness: derivedState.readiness,
|
||||
anchorPack: derivedState.anchorPack,
|
||||
draftProfile: shouldPreserveDraftStage
|
||||
? latestSession.draftProfile
|
||||
: derivedState.draftProfile,
|
||||
pendingClarifications: shouldPreserveDraftStage
|
||||
? latestSession.pendingClarifications
|
||||
: derivedState.pendingClarifications,
|
||||
suggestedActions: nextSuggestedActions,
|
||||
draftCards: shouldPreserveDraftStage
|
||||
? latestSession.draftCards
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const fallbackAssistantMessage = shouldPreserveDraftStage
|
||||
? buildObjectRefiningAssistantMessage({
|
||||
latestUserText,
|
||||
relatedOperationId: operationId,
|
||||
draftProfile: latestSession.draftProfile,
|
||||
})
|
||||
: buildAssistantMessage({
|
||||
latestUserText,
|
||||
relatedOperationId: operationId,
|
||||
intent: nextIntent,
|
||||
pendingClarifications: derivedState.pendingClarifications,
|
||||
isReady: derivedState.readiness.isReady,
|
||||
});
|
||||
const assistantTurn = shouldPreserveDraftStage
|
||||
? {
|
||||
reply: fallbackAssistantMessage.text,
|
||||
recommendedReplies: [] as string[],
|
||||
}
|
||||
: await this.generateAssistantTurn({
|
||||
session: latestSession,
|
||||
latestUserText,
|
||||
fallbackReply: fallbackAssistantMessage.text,
|
||||
intent: nextIntent,
|
||||
pendingClarifications: derivedState.pendingClarifications,
|
||||
isReady: derivedState.readiness.isReady,
|
||||
});
|
||||
const assistantMessage = {
|
||||
...fallbackAssistantMessage,
|
||||
text: assistantTurn.reply,
|
||||
};
|
||||
const recommendedReplies = assistantTurn.recommendedReplies;
|
||||
await this.sessionStore.appendMessage(
|
||||
await this.applyMessageTurn({
|
||||
userId,
|
||||
sessionId,
|
||||
assistantMessage,
|
||||
);
|
||||
await this.sessionStore.replaceDerivedState(userId, sessionId, {
|
||||
recommendedReplies,
|
||||
latestUserText,
|
||||
quickFillRequested,
|
||||
relatedOperationId: operationId,
|
||||
});
|
||||
|
||||
await this.sessionStore.updateOperation(userId, sessionId, operationId, {
|
||||
status: 'completed',
|
||||
phaseLabel: '锚点已更新',
|
||||
phaseLabel: '设定已更新',
|
||||
phaseDetail: shouldPreserveDraftStage
|
||||
? '这轮补充已挂回当前底稿语境,现有草稿卡保持可继续浏览。'
|
||||
: derivedState.readiness.isReady
|
||||
? '最小锚点已齐备,可以进入下一阶段。'
|
||||
: '这一轮的创作锚点和澄清问题已经同步完成。',
|
||||
: quickFillRequested
|
||||
? '剩余设定已补全,现在可以进入游戏设定草稿生成。'
|
||||
: '这一轮的设定更新已经完成。',
|
||||
progress: 100,
|
||||
error: null,
|
||||
});
|
||||
@@ -1729,7 +1528,7 @@ export class CustomWorldAgentOrchestrator {
|
||||
await this.sessionStore.updateOperation(userId, sessionId, operationId, {
|
||||
status: 'failed',
|
||||
phaseLabel: '处理失败',
|
||||
phaseDetail: '这一轮消息没有成功沉淀为创作锚点。',
|
||||
phaseDetail: '这一轮消息没有成功沉淀为当前设定。',
|
||||
progress: 100,
|
||||
error:
|
||||
error instanceof Error ? error.message : 'process message failed',
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from './customWorldAgentIntentExtractionService.js';
|
||||
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
|
||||
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
|
||||
import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js';
|
||||
import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js';
|
||||
|
||||
function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
|
||||
@@ -178,7 +179,9 @@ test('phase2 clarification service only keeps the top highest leverage gap', ()
|
||||
test('phase2 orchestrator advances session to foundation_review when minimal anchors are complete', async () => {
|
||||
const runtimeRepository = createRuntimeRepositoryStub();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
});
|
||||
const userId = 'user-phase2-ready';
|
||||
|
||||
const createdSession = await orchestrator.createSession(userId, {
|
||||
@@ -193,6 +196,9 @@ test('phase2 orchestrator advances session to foundation_review when minimal anc
|
||||
),
|
||||
/列岛世界/u,
|
||||
);
|
||||
assert.ok(
|
||||
createdSession.messages[0]?.text.includes('1.') === false,
|
||||
);
|
||||
|
||||
const message1 = await orchestrator.submitMessage(
|
||||
userId,
|
||||
@@ -246,7 +252,7 @@ test('phase2 orchestrator advances session to foundation_review when minimal anc
|
||||
snapshot?.messages.some(
|
||||
(message) =>
|
||||
message.role === 'assistant' &&
|
||||
message.text.includes('最小锚点已经齐备'),
|
||||
/进入下一阶段|生成游戏设定草稿/u.test(message.text),
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -254,7 +260,9 @@ test('phase2 orchestrator advances session to foundation_review when minimal anc
|
||||
test('phase2 work summaries compile draft title and summary from creator intent', async () => {
|
||||
const runtimeRepository = createRuntimeRepositoryStub();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
});
|
||||
const userId = 'user-phase2-summary';
|
||||
|
||||
const createdSession = await orchestrator.createSession(userId, {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { CustomWorldSessionRecord } from '../../../packages/shared/src/cont
|
||||
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
|
||||
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
|
||||
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
|
||||
import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js';
|
||||
import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js';
|
||||
|
||||
function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
|
||||
@@ -151,7 +152,9 @@ async function createReadySession(
|
||||
test('phase3 ready session can execute draft_foundation and expose card detail', async () => {
|
||||
const runtimeRepository = createRuntimeRepositoryStub();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
});
|
||||
const userId = 'user-phase3-draft';
|
||||
const readySession = await createReadySession(orchestrator, userId);
|
||||
|
||||
@@ -209,7 +212,9 @@ test('phase3 ready session can execute draft_foundation and expose card detail',
|
||||
test('phase3 draft_foundation rejects not-ready session', async () => {
|
||||
const runtimeRepository = createRuntimeRepositoryStub();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
});
|
||||
const userId = 'user-phase3-not-ready';
|
||||
const createdSession = await orchestrator.createSession(userId, {
|
||||
seedText: '一个被潮雾切开的列岛世界。',
|
||||
@@ -220,14 +225,16 @@ test('phase3 draft_foundation rejects not-ready session', async () => {
|
||||
orchestrator.executeAction(userId, createdSession.sessionId, {
|
||||
action: 'draft_foundation',
|
||||
}),
|
||||
/ready session|foundation_review/u,
|
||||
/progressPercent >= 100|draft_foundation/u,
|
||||
);
|
||||
});
|
||||
|
||||
test('phase3 work summaries prefer compiled foundation draft fields', async () => {
|
||||
const runtimeRepository = createRuntimeRepositoryStub();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
});
|
||||
const userId = 'user-phase3-summary';
|
||||
const readySession = await createReadySession(orchestrator, userId);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js
|
||||
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
|
||||
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
|
||||
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
|
||||
import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js';
|
||||
import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js';
|
||||
|
||||
function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
|
||||
@@ -161,7 +162,9 @@ async function createObjectRefiningSession(
|
||||
test('phase4 update_draft_card writes back draft profile and recompiles summaries', async () => {
|
||||
const runtimeRepository = createRuntimeRepositoryStub();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
});
|
||||
const userId = 'user-phase4-edit';
|
||||
const session = await createObjectRefiningSession(orchestrator, userId);
|
||||
const characterCard = session.draftCards.find((card) => card.kind === 'character');
|
||||
@@ -220,7 +223,9 @@ test('phase4 update_draft_card writes back draft profile and recompiles summarie
|
||||
test('phase4 generate_characters appends story npcs and updates work summary counts', async () => {
|
||||
const runtimeRepository = createRuntimeRepositoryStub();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
});
|
||||
const userId = 'user-phase4-characters';
|
||||
const session = await createObjectRefiningSession(orchestrator, userId);
|
||||
const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile)!;
|
||||
@@ -274,7 +279,9 @@ test('phase4 generate_characters appends story npcs and updates work summary cou
|
||||
test('phase4 generate_landmarks appends new landmark cards and checkpoints', async () => {
|
||||
const runtimeRepository = createRuntimeRepositoryStub();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
});
|
||||
const userId = 'user-phase4-landmarks';
|
||||
const session = await createObjectRefiningSession(orchestrator, userId);
|
||||
const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile)!;
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js
|
||||
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
|
||||
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
|
||||
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
|
||||
import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js';
|
||||
|
||||
function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
|
||||
const sessionsByUser = new Map<
|
||||
@@ -160,7 +161,9 @@ async function createObjectRefiningSession(
|
||||
test('phase5 generate_role_assets only allows a single role and moves session into visual_refining', async () => {
|
||||
const runtimeRepository = createRuntimeRepositoryStub();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
});
|
||||
const userId = 'user-phase5-generate-role-assets';
|
||||
const session = await createObjectRefiningSession(orchestrator, userId);
|
||||
const characterIds = session.draftCards
|
||||
@@ -201,7 +204,9 @@ test('phase5 generate_role_assets only allows a single role and moves session in
|
||||
test('phase5 sync_role_assets writes fields back, updates coverage and recompiles character cards', async () => {
|
||||
const runtimeRepository = createRuntimeRepositoryStub();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
});
|
||||
const userId = 'user-phase5-sync-role-assets';
|
||||
const session = await createObjectRefiningSession(orchestrator, userId);
|
||||
const characterCard = session.draftCards.find((card) => card.kind === 'character');
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import type {
|
||||
CustomWorldAssetCoverageSummary,
|
||||
CreatorIntentReadiness,
|
||||
CustomWorldAgentMessage,
|
||||
CustomWorldAgentOperationRecord,
|
||||
CustomWorldAgentSessionSnapshot,
|
||||
CustomWorldAgentStage,
|
||||
CustomWorldAssetCoverageSummary,
|
||||
CustomWorldDraftCardSummary,
|
||||
CustomWorldPendingClarification,
|
||||
CustomWorldSuggestedAction,
|
||||
EightAnchorContent,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
import type { CustomWorldSessionRecord as LegacyCustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
|
||||
@@ -19,15 +20,19 @@ import {
|
||||
resolveCreatorIntentStage,
|
||||
} from './customWorldAgentClarificationService.js';
|
||||
import {
|
||||
buildAnchorPackFromIntent,
|
||||
buildDraftSummaryFromIntent,
|
||||
buildDraftTitleFromIntent,
|
||||
createEmptyCreatorIntentRecord,
|
||||
extractCreatorIntentPatch,
|
||||
mergeCreatorIntentRecord,
|
||||
normalizeCreatorIntentRecord,
|
||||
} from './customWorldAgentIntentExtractionService.js';
|
||||
import { rebuildRoleAssetCoverage } from './customWorldAgentRoleAssetStateService.js';
|
||||
import {
|
||||
buildAnchorPackFromEightAnchorContent,
|
||||
buildCreatorIntentFromEightAnchorContent,
|
||||
buildDraftSummaryFromEightAnchorContent,
|
||||
buildDraftTitleFromEightAnchorContent,
|
||||
buildEightAnchorContentFromCreatorIntent,
|
||||
createEmptyEightAnchorContent,
|
||||
estimateProgressPercentFromAnchorContent,
|
||||
normalizeEightAnchorContent,
|
||||
} from './eightAnchorCompatibilityService.js';
|
||||
|
||||
export const CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX =
|
||||
'custom-world-agent-session-';
|
||||
@@ -36,6 +41,10 @@ export type CustomWorldAgentSessionRecord = {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
seedText: string;
|
||||
currentTurn: number;
|
||||
anchorContent: EightAnchorContent;
|
||||
progressPercent: number;
|
||||
lastAssistantReply: string | null;
|
||||
stage: CustomWorldAgentStage;
|
||||
focusCardId: string | null;
|
||||
creatorIntent: Record<string, unknown> | null;
|
||||
@@ -69,6 +78,10 @@ export type CustomWorldAgentSessionRecord = {
|
||||
type CreateSessionInput = {
|
||||
seedText?: string;
|
||||
welcomeMessage: string;
|
||||
currentTurn?: number;
|
||||
anchorContent?: EightAnchorContent;
|
||||
progressPercent?: number;
|
||||
lastAssistantReply?: string | null;
|
||||
pendingClarifications: CustomWorldAgentSessionRecord['pendingClarifications'];
|
||||
creatorIntent?: CustomWorldAgentSessionRecord['creatorIntent'];
|
||||
creatorIntentReadiness?: CreatorIntentReadiness;
|
||||
@@ -169,20 +182,95 @@ function hasUserInput(record: CustomWorldAgentSessionRecord) {
|
||||
}
|
||||
|
||||
function buildCompatibleCreatorIntent(record: CustomWorldAgentSessionRecord) {
|
||||
const existingIntent =
|
||||
normalizeCreatorIntentRecord(record.creatorIntent) ??
|
||||
createEmptyCreatorIntentRecord('freeform');
|
||||
const compatibleAnchorIntent = buildCreatorIntentFromEightAnchorContent(
|
||||
normalizeEightAnchorContent(
|
||||
(record as Record<string, unknown>).anchorContent ?? null,
|
||||
),
|
||||
);
|
||||
|
||||
if (!record.seedText.trim()) {
|
||||
return existingIntent;
|
||||
if (
|
||||
compatibleAnchorIntent &&
|
||||
(compatibleAnchorIntent.worldHook ||
|
||||
compatibleAnchorIntent.rawSettingText ||
|
||||
compatibleAnchorIntent.playerPremise ||
|
||||
compatibleAnchorIntent.openingSituation ||
|
||||
compatibleAnchorIntent.coreConflicts.length > 0 ||
|
||||
compatibleAnchorIntent.keyCharacters.length > 0 ||
|
||||
compatibleAnchorIntent.iconicElements.length > 0)
|
||||
) {
|
||||
return compatibleAnchorIntent;
|
||||
}
|
||||
|
||||
const seedPatch = extractCreatorIntentPatch({
|
||||
currentIntent: existingIntent,
|
||||
latestUserMessage: record.seedText,
|
||||
});
|
||||
return normalizeCreatorIntentRecord(record.creatorIntent);
|
||||
}
|
||||
|
||||
return mergeCreatorIntentRecord(existingIntent, seedPatch);
|
||||
function buildCompatibleCurrentTurn(record: CustomWorldAgentSessionRecord) {
|
||||
if (typeof (record as Record<string, unknown>).currentTurn === 'number') {
|
||||
return Math.max(
|
||||
0,
|
||||
Math.round((record as Record<string, unknown>).currentTurn as number),
|
||||
);
|
||||
}
|
||||
|
||||
return record.messages.filter((message) => message.role === 'user').length;
|
||||
}
|
||||
|
||||
function buildCompatibleAnchorContent(record: CustomWorldAgentSessionRecord) {
|
||||
const normalized = normalizeEightAnchorContent(
|
||||
(record as Record<string, unknown>).anchorContent ?? null,
|
||||
);
|
||||
|
||||
if (
|
||||
normalized.worldPromise ||
|
||||
normalized.playerFantasy ||
|
||||
normalized.themeBoundary ||
|
||||
normalized.playerEntryPoint ||
|
||||
normalized.coreConflict ||
|
||||
normalized.keyRelationships.length > 0 ||
|
||||
normalized.hiddenLines ||
|
||||
normalized.iconicElements
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return buildEightAnchorContentFromCreatorIntent(
|
||||
buildCompatibleCreatorIntent(record),
|
||||
);
|
||||
}
|
||||
|
||||
function buildCompatibleProgressPercent(record: CustomWorldAgentSessionRecord) {
|
||||
const rawProgress = (record as Record<string, unknown>).progressPercent;
|
||||
if (typeof rawProgress === 'number' && Number.isFinite(rawProgress)) {
|
||||
return Math.max(0, Math.min(100, Math.round(rawProgress)));
|
||||
}
|
||||
|
||||
if (
|
||||
record.stage === 'foundation_review' ||
|
||||
record.stage === 'object_refining' ||
|
||||
record.stage === 'visual_refining' ||
|
||||
record.stage === 'long_tail_review' ||
|
||||
record.stage === 'ready_to_publish' ||
|
||||
record.stage === 'published'
|
||||
) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
return estimateProgressPercentFromAnchorContent(
|
||||
buildCompatibleAnchorContent(record),
|
||||
);
|
||||
}
|
||||
|
||||
function buildCompatibleLastAssistantReply(record: CustomWorldAgentSessionRecord) {
|
||||
const existingReply = (record as Record<string, unknown>).lastAssistantReply;
|
||||
if (typeof existingReply === 'string') {
|
||||
return existingReply;
|
||||
}
|
||||
|
||||
const lastAssistantMessage = [...record.messages]
|
||||
.reverse()
|
||||
.find((message) => message.role === 'assistant' && message.text.trim());
|
||||
|
||||
return lastAssistantMessage?.text ?? null;
|
||||
}
|
||||
|
||||
function buildCompatibleReadiness(record: CustomWorldAgentSessionRecord) {
|
||||
@@ -239,8 +327,8 @@ function buildCompatiblePendingClarifications(
|
||||
|
||||
function buildCompatibleDraftProfile(
|
||||
record: CustomWorldAgentSessionRecord,
|
||||
creatorIntent: ReturnType<typeof buildCompatibleCreatorIntent>,
|
||||
) {
|
||||
const anchorContent = buildCompatibleAnchorContent(record);
|
||||
const existingDraftProfile = toRecord(record.draftProfile);
|
||||
const hasFoundationContent = Boolean(
|
||||
existingDraftProfile &&
|
||||
@@ -258,20 +346,21 @@ function buildCompatibleDraftProfile(
|
||||
name:
|
||||
toText(existingDraftProfile?.name) ||
|
||||
toText(existingDraftProfile?.title) ||
|
||||
buildDraftTitleFromIntent(creatorIntent),
|
||||
buildDraftTitleFromEightAnchorContent(anchorContent),
|
||||
summary:
|
||||
toText(existingDraftProfile?.summary) ||
|
||||
buildDraftSummaryFromIntent(creatorIntent),
|
||||
buildDraftSummaryFromEightAnchorContent(anchorContent),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...(existingDraftProfile ?? {}),
|
||||
title:
|
||||
toText(existingDraftProfile?.title) || buildDraftTitleFromIntent(creatorIntent),
|
||||
toText(existingDraftProfile?.title) ||
|
||||
buildDraftTitleFromEightAnchorContent(anchorContent),
|
||||
summary:
|
||||
toText(existingDraftProfile?.summary) ||
|
||||
buildDraftSummaryFromIntent(creatorIntent),
|
||||
buildDraftSummaryFromEightAnchorContent(anchorContent),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -381,35 +470,58 @@ function buildCompatibleAssetCoverage(
|
||||
|
||||
function applyCompatibility(record: CustomWorldAgentSessionRecord) {
|
||||
const creatorIntent = buildCompatibleCreatorIntent(record);
|
||||
const creatorIntentReadiness = evaluateCreatorIntentReadiness(creatorIntent);
|
||||
const currentTurn = buildCompatibleCurrentTurn(record);
|
||||
const anchorContent = buildCompatibleAnchorContent(record);
|
||||
const progressPercent = buildCompatibleProgressPercent(record);
|
||||
const lastAssistantReply = buildCompatibleLastAssistantReply(record);
|
||||
const creatorIntentReadiness =
|
||||
progressPercent >= 100
|
||||
? {
|
||||
isReady: true,
|
||||
completedKeys: [
|
||||
'world_hook',
|
||||
'player_premise',
|
||||
'theme_and_tone',
|
||||
'core_conflict',
|
||||
'relationship_seed',
|
||||
'iconic_element',
|
||||
],
|
||||
missingKeys: [],
|
||||
}
|
||||
: evaluateCreatorIntentReadiness(creatorIntent);
|
||||
const stage =
|
||||
record.stage === 'collecting_intent' ||
|
||||
record.stage === 'clarifying' ||
|
||||
record.stage === 'foundation_review'
|
||||
? resolveCreatorIntentStage({
|
||||
hasUserInput: hasUserInput(record),
|
||||
readiness: creatorIntentReadiness,
|
||||
})
|
||||
: record.stage;
|
||||
record.stage === 'object_refining' ||
|
||||
record.stage === 'visual_refining' ||
|
||||
record.stage === 'long_tail_review' ||
|
||||
record.stage === 'ready_to_publish' ||
|
||||
record.stage === 'published'
|
||||
? record.stage
|
||||
: progressPercent >= 100
|
||||
? ('foundation_review' as const)
|
||||
: resolveCreatorIntentStage({
|
||||
hasUserInput: hasUserInput(record),
|
||||
readiness: creatorIntentReadiness,
|
||||
});
|
||||
const pendingClarifications = buildCompatiblePendingClarifications({
|
||||
...record,
|
||||
creatorIntent,
|
||||
creatorIntentReadiness,
|
||||
});
|
||||
const draftProfile = buildCompatibleDraftProfile(record, creatorIntent);
|
||||
const draftProfile = buildCompatibleDraftProfile(record);
|
||||
|
||||
return {
|
||||
...record,
|
||||
currentTurn,
|
||||
anchorContent,
|
||||
progressPercent,
|
||||
lastAssistantReply,
|
||||
stage,
|
||||
creatorIntent,
|
||||
creatorIntentReadiness,
|
||||
anchorPack:
|
||||
record.anchorPack && Object.keys(record.anchorPack).length > 0
|
||||
? record.anchorPack
|
||||
: buildAnchorPackFromIntent(creatorIntent, {
|
||||
completedKeys: creatorIntentReadiness.completedKeys,
|
||||
missingKeys: creatorIntentReadiness.missingKeys,
|
||||
}),
|
||||
: buildAnchorPackFromEightAnchorContent(anchorContent, progressPercent),
|
||||
draftProfile,
|
||||
pendingClarifications,
|
||||
suggestedActions: buildCompatibleSuggestedActions({
|
||||
@@ -430,6 +542,10 @@ function toSnapshot(
|
||||
): CustomWorldAgentSessionSnapshot {
|
||||
return {
|
||||
sessionId: record.sessionId,
|
||||
currentTurn: record.currentTurn,
|
||||
anchorContent: cloneRecord(record.anchorContent),
|
||||
progressPercent: record.progressPercent,
|
||||
lastAssistantReply: record.lastAssistantReply,
|
||||
stage: record.stage,
|
||||
focusCardId: record.focusCardId,
|
||||
creatorIntent: cloneRecord(record.creatorIntent),
|
||||
@@ -491,6 +607,15 @@ export class CustomWorldAgentSessionStore {
|
||||
sessionId,
|
||||
userId,
|
||||
seedText: input.seedText?.trim() ?? '',
|
||||
currentTurn: Math.max(0, Math.round(input.currentTurn ?? 0)),
|
||||
anchorContent: normalizeEightAnchorContent(
|
||||
input.anchorContent ?? createEmptyEightAnchorContent(),
|
||||
),
|
||||
progressPercent: Math.max(
|
||||
0,
|
||||
Math.min(100, Math.round(input.progressPercent ?? 0)),
|
||||
),
|
||||
lastAssistantReply: input.lastAssistantReply ?? input.welcomeMessage,
|
||||
stage: input.stage ?? 'collecting_intent',
|
||||
focusCardId: null,
|
||||
creatorIntent: cloneRecord(input.creatorIntent ?? {}),
|
||||
@@ -567,6 +692,10 @@ export class CustomWorldAgentSessionStore {
|
||||
patch: Partial<
|
||||
Pick<
|
||||
CustomWorldAgentSessionRecord,
|
||||
| 'currentTurn'
|
||||
| 'anchorContent'
|
||||
| 'progressPercent'
|
||||
| 'lastAssistantReply'
|
||||
| 'stage'
|
||||
| 'creatorIntent'
|
||||
| 'creatorIntentReadiness'
|
||||
@@ -584,6 +713,21 @@ export class CustomWorldAgentSessionStore {
|
||||
>,
|
||||
) {
|
||||
return this.mutate(userId, sessionId, (record) => {
|
||||
if (typeof patch.currentTurn === 'number') {
|
||||
record.currentTurn = Math.max(0, Math.round(patch.currentTurn));
|
||||
}
|
||||
if (patch.anchorContent !== undefined) {
|
||||
record.anchorContent = normalizeEightAnchorContent(patch.anchorContent);
|
||||
}
|
||||
if (typeof patch.progressPercent === 'number') {
|
||||
record.progressPercent = Math.max(
|
||||
0,
|
||||
Math.min(100, Math.round(patch.progressPercent)),
|
||||
);
|
||||
}
|
||||
if (patch.lastAssistantReply !== undefined) {
|
||||
record.lastAssistantReply = patch.lastAssistantReply;
|
||||
}
|
||||
if (patch.stage) {
|
||||
record.stage = patch.stage;
|
||||
}
|
||||
|
||||
321
server-node/src/services/customWorldAgentTestHelpers.ts
Normal file
321
server-node/src/services/customWorldAgentTestHelpers.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import type { EightAnchorContent } from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
import type { UpstreamLlmClient } from './llmClient.js';
|
||||
import {
|
||||
extractCreatorIntentPatch,
|
||||
mergeCreatorIntentRecord,
|
||||
} from './customWorldAgentIntentExtractionService.js';
|
||||
import {
|
||||
buildCreatorIntentFromEightAnchorContent,
|
||||
buildEightAnchorContentFromCreatorIntent,
|
||||
createEmptyEightAnchorContent,
|
||||
estimateProgressPercentFromAnchorContent,
|
||||
normalizeEightAnchorContent,
|
||||
} from './eightAnchorCompatibilityService.js';
|
||||
|
||||
type TestChatMessage = {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
};
|
||||
|
||||
function toText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function shouldReplaceWorldPromise(params: {
|
||||
latestUserText: string;
|
||||
hasExistingWorldPromise: boolean;
|
||||
}) {
|
||||
if (!params.hasExistingWorldPromise) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return /(世界一句话|一句话概括|世界设定|这个世界|题材|主题|风格|改成|改为|换成)/u.test(
|
||||
params.latestUserText,
|
||||
);
|
||||
}
|
||||
|
||||
function buildAutoCompletePatch(intent: ReturnType<
|
||||
typeof buildCreatorIntentFromEightAnchorContent
|
||||
>) {
|
||||
return {
|
||||
worldHook:
|
||||
intent.worldHook ||
|
||||
intent.rawSettingText ||
|
||||
'一个被未知异象改变秩序的边境世界。',
|
||||
playerPremise: intent.playerPremise || '玩家是被卷入核心危机的返乡者。',
|
||||
openingSituation:
|
||||
intent.openingSituation || '开局时,玩家正抵达危机爆发的现场。',
|
||||
themeKeywords:
|
||||
intent.themeKeywords.length > 0 ? intent.themeKeywords : ['奇幻'],
|
||||
toneDirectives:
|
||||
intent.toneDirectives.length > 0 ? intent.toneDirectives : ['悬疑'],
|
||||
coreConflicts:
|
||||
intent.coreConflicts.length > 0
|
||||
? intent.coreConflicts
|
||||
: ['旧秩序与新威胁正在争夺世界的未来。'],
|
||||
keyCharacters:
|
||||
intent.keyCharacters.length > 0
|
||||
? intent.keyCharacters
|
||||
: [
|
||||
{
|
||||
id: 'auto-key-character-1',
|
||||
name: '未命名关键人物',
|
||||
role: '关键关系',
|
||||
publicMask: '看似能帮助玩家的人。',
|
||||
hiddenHook: '掌握一条会改变局势的暗线。',
|
||||
relationToPlayer: '旧识',
|
||||
notes: '自动补全,可继续修改。',
|
||||
},
|
||||
],
|
||||
iconicElements:
|
||||
intent.iconicElements.length > 0 ? intent.iconicElements : ['失落信标'],
|
||||
};
|
||||
}
|
||||
|
||||
function buildReplyText(params: {
|
||||
nextAnchorContent: EightAnchorContent;
|
||||
progressPercent: number;
|
||||
quickFillRequested: boolean;
|
||||
latestUserText: string;
|
||||
}) {
|
||||
if (params.quickFillRequested || params.progressPercent >= 100) {
|
||||
return '这一版已经收住了,现在可以直接生成游戏设定草稿。';
|
||||
}
|
||||
|
||||
if (/(改成|改为|换成|不是)/u.test(params.latestUserText)) {
|
||||
return '我已经按你刚刚修正后的方向重收了一版,现在这条主线会更稳。';
|
||||
}
|
||||
|
||||
if (!params.nextAnchorContent.worldPromise?.hook) {
|
||||
return '方向我先接住了一点。这个世界最抓人的那句核心设定,你想怎么钉住?';
|
||||
}
|
||||
|
||||
if (!params.nextAnchorContent.playerFantasy?.playerRole) {
|
||||
return '世界底色已经有了。你最想让玩家以什么身份卷进来?';
|
||||
}
|
||||
|
||||
if (!params.nextAnchorContent.playerEntryPoint?.openingProblem) {
|
||||
return '大方向先稳住了。故事开场时,玩家先撞上的麻烦是什么?';
|
||||
}
|
||||
|
||||
if (!params.nextAnchorContent.coreConflict?.surfaceConflicts.length) {
|
||||
return '现在气质和身份都更清楚了。接下来最值得钉住的,是这个世界正在爆开的主要冲突。';
|
||||
}
|
||||
|
||||
return '这轮信息我已经收进当前版本里了,你可以继续往下补,也可以让我顺着这条线继续收束。';
|
||||
}
|
||||
|
||||
function extractJsonBlock(text: string, marker: string) {
|
||||
const markerIndex = text.indexOf(marker);
|
||||
if (markerIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let startIndex = markerIndex + marker.length;
|
||||
while (startIndex < text.length && /\s/u.test(text[startIndex] ?? '')) {
|
||||
startIndex += 1;
|
||||
}
|
||||
|
||||
const firstCharacter = text[startIndex];
|
||||
if (firstCharacter !== '{' && firstCharacter !== '[') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const closingCharacter = firstCharacter === '{' ? '}' : ']';
|
||||
let depth = 0;
|
||||
let insideString = false;
|
||||
let escaping = false;
|
||||
|
||||
for (let index = startIndex; index < text.length; index += 1) {
|
||||
const character = text[index] ?? '';
|
||||
|
||||
if (insideString) {
|
||||
if (escaping) {
|
||||
escaping = false;
|
||||
continue;
|
||||
}
|
||||
if (character === '\\') {
|
||||
escaping = true;
|
||||
continue;
|
||||
}
|
||||
if (character === '"') {
|
||||
insideString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '"') {
|
||||
insideString = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === firstCharacter) {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === closingCharacter) {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return text.slice(startIndex, index + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parsePromptInput(text: string) {
|
||||
const anchorJson = extractJsonBlock(text, '当前完整设定结构:');
|
||||
const chatJson = extractJsonBlock(text, '用户聊天记录:');
|
||||
|
||||
const currentAnchorContent = anchorJson
|
||||
? normalizeEightAnchorContent(JSON.parse(anchorJson))
|
||||
: createEmptyEightAnchorContent();
|
||||
const chatHistory = chatJson
|
||||
? (JSON.parse(chatJson) as TestChatMessage[])
|
||||
: [];
|
||||
const quickFillRequested =
|
||||
text.includes('是否要求自动补全:是') ||
|
||||
text.includes('conversationMode: force_complete') ||
|
||||
text.includes('用户刚刚主动要求你自动补全剩余设定');
|
||||
|
||||
return {
|
||||
currentAnchorContent,
|
||||
chatHistory,
|
||||
quickFillRequested,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTestEightAnchorTurn(params: {
|
||||
currentAnchorContent: EightAnchorContent;
|
||||
chatHistory: TestChatMessage[];
|
||||
quickFillRequested: boolean;
|
||||
}) {
|
||||
const latestUserText =
|
||||
[...params.chatHistory]
|
||||
.reverse()
|
||||
.find((entry) => entry.role === 'user' && entry.content.trim())?.content ??
|
||||
'';
|
||||
const currentIntent = buildCreatorIntentFromEightAnchorContent(
|
||||
params.currentAnchorContent,
|
||||
);
|
||||
const intentPatch = extractCreatorIntentPatch({
|
||||
currentIntent,
|
||||
latestUserMessage: latestUserText,
|
||||
recentMessages: params.chatHistory
|
||||
.filter((entry) => entry.role === 'user')
|
||||
.slice(-6, -1)
|
||||
.map((entry) => entry.content),
|
||||
});
|
||||
const mergedIntent = mergeCreatorIntentRecord(
|
||||
currentIntent,
|
||||
params.quickFillRequested
|
||||
? {
|
||||
...intentPatch,
|
||||
...buildAutoCompletePatch(currentIntent),
|
||||
}
|
||||
: intentPatch,
|
||||
);
|
||||
|
||||
if (
|
||||
!shouldReplaceWorldPromise({
|
||||
latestUserText,
|
||||
hasExistingWorldPromise: Boolean(currentIntent.worldHook),
|
||||
})
|
||||
) {
|
||||
mergedIntent.worldHook = currentIntent.worldHook;
|
||||
}
|
||||
|
||||
const nextAnchorContent = buildEightAnchorContentFromCreatorIntent(mergedIntent);
|
||||
const progressPercent = params.quickFillRequested
|
||||
? 100
|
||||
: estimateProgressPercentFromAnchorContent(nextAnchorContent);
|
||||
|
||||
return {
|
||||
replyText: buildReplyText({
|
||||
nextAnchorContent,
|
||||
progressPercent,
|
||||
quickFillRequested: params.quickFillRequested,
|
||||
latestUserText,
|
||||
}),
|
||||
progressPercent,
|
||||
nextAnchorContent,
|
||||
};
|
||||
}
|
||||
|
||||
function buildStateInferenceFromPrompt(text: string) {
|
||||
const { chatHistory, quickFillRequested } = parsePromptInput(text);
|
||||
const latestUserText =
|
||||
[...chatHistory]
|
||||
.reverse()
|
||||
.find((entry) => entry.role === 'user' && entry.content.trim())?.content ??
|
||||
'';
|
||||
const correction = /(改成|改为|换成|不是|别走|不要)/u.test(latestUserText);
|
||||
const delegate = /(你来|你帮我|默认方案|自动补全|按你觉得合理)/u.test(
|
||||
latestUserText,
|
||||
);
|
||||
|
||||
if (quickFillRequested) {
|
||||
return {
|
||||
userInputSignal: delegate ? 'delegate' : 'normal',
|
||||
driftRisk: correction ? 'high' : 'medium',
|
||||
conversationMode: 'force_complete',
|
||||
judgementSummary: '用户希望系统直接补完,这一轮应优先补齐剩余设定并结束收集阶段。',
|
||||
};
|
||||
}
|
||||
|
||||
if (correction) {
|
||||
return {
|
||||
userInputSignal: 'correction',
|
||||
driftRisk: 'high',
|
||||
conversationMode: 'repair_direction',
|
||||
judgementSummary: '用户正在修正旧方向,正式生成时要让修正后的版本直接接管当前语境。',
|
||||
};
|
||||
}
|
||||
|
||||
if (latestUserText.length < 20) {
|
||||
return {
|
||||
userInputSignal: delegate ? 'delegate' : 'sparse',
|
||||
driftRisk: 'low',
|
||||
conversationMode: 'bootstrap',
|
||||
judgementSummary: '这轮新增信息较少,正式生成时应先低压力接住方向,再只推进一个最好回答的问题。',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
userInputSignal: latestUserText.length >= 40 ? 'rich' : 'normal',
|
||||
driftRisk: 'low',
|
||||
conversationMode: 'expand',
|
||||
judgementSummary: '这轮是在顺着现有方向继续补充,正式生成时应吸收新增细节并往前推进一步。',
|
||||
};
|
||||
}
|
||||
|
||||
export function createTestCustomWorldAgentSingleTurnLlmClient() {
|
||||
return {
|
||||
requestMessageContent: async (params) => {
|
||||
if (params.systemPrompt.includes('创作状态识别器')) {
|
||||
return JSON.stringify(buildStateInferenceFromPrompt(params.userPrompt));
|
||||
}
|
||||
|
||||
const promptInput = parsePromptInput(
|
||||
[params.systemPrompt, params.userPrompt].join('\n\n'),
|
||||
);
|
||||
return JSON.stringify(buildTestEightAnchorTurn(promptInput));
|
||||
},
|
||||
streamMessageContent: async (params) => {
|
||||
const promptInput = parsePromptInput(
|
||||
[params.systemPrompt, params.userPrompt].join('\n\n'),
|
||||
);
|
||||
const output = buildTestEightAnchorTurn(promptInput);
|
||||
const jsonText = JSON.stringify({
|
||||
replyText: output.replyText,
|
||||
progressPercent: output.progressPercent,
|
||||
nextAnchorContent: output.nextAnchorContent,
|
||||
});
|
||||
|
||||
params.onUpdate?.(jsonText);
|
||||
return jsonText;
|
||||
},
|
||||
} as UpstreamLlmClient;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { generateCustomWorldEntity } from './customWorldEntityGenerationService.js';
|
||||
import type { UpstreamLlmClient } from './llmClient.js';
|
||||
|
||||
function createProfile() {
|
||||
return {
|
||||
name: '裂潮边城',
|
||||
settingText: '裂潮重新逼近边城,旧封桥令也被重新翻出。',
|
||||
summary: '一座在裂潮与旧案之间摇摇欲坠的边城。',
|
||||
tone: '紧绷、克制、暗流涌动',
|
||||
playerGoal: '查清封桥旧令背后的真正操盘者',
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'playable-1',
|
||||
name: '沈砺',
|
||||
title: '灰炬向导',
|
||||
role: '边路同行者',
|
||||
description: '熟悉裂潮边路的向导。',
|
||||
visualDescription: '灰斗篷和旧路标是他最显眼的识别点。',
|
||||
actionDescription: '先试探风向,再用短弓牵制。',
|
||||
sceneVisualDescription: '他常在旧边路哨点出现。',
|
||||
backstory: '曾在旧撤离线里失去整支同行队。',
|
||||
personality: '谨慎寡言。',
|
||||
motivation: '想查清旧撤离线再次失控的原因。',
|
||||
combatStyle: '短弓牵制后贴近补刀。',
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: ['旧撤离线'],
|
||||
tags: ['裂潮', '向导'],
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
{
|
||||
id: 'story-1',
|
||||
name: '梁砺',
|
||||
title: '断桥巡守',
|
||||
role: '巡守',
|
||||
description: '守着旧桥与哨火的人。',
|
||||
visualDescription: '披着旧制巡守外袍,枪柄磨损很重。',
|
||||
actionDescription: '先立枪封路,再逼近压线。',
|
||||
sceneVisualDescription: '多出现在断桥和潮湿石阶附近。',
|
||||
backstory: '旧案爆发时,他是最后一个封桥的人。',
|
||||
personality: '直接、警觉。',
|
||||
motivation: '不想再让封桥旧案被人利用。',
|
||||
combatStyle: '长枪压线。',
|
||||
initialAffinity: 6,
|
||||
relationshipHooks: ['断桥'],
|
||||
tags: ['巡守'],
|
||||
},
|
||||
],
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-1',
|
||||
name: '旧潮栈桥',
|
||||
description: '裂潮来时最先响起铁索声的旧栈桥。',
|
||||
visualDescription: '铁索、旧桩和盐雾一起压在栈桥上。',
|
||||
dangerLevel: 'medium',
|
||||
sceneNpcIds: ['story-1'],
|
||||
connections: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
test('generateCustomWorldEntity returns role-side visual descriptions from the same model response', async () => {
|
||||
const llmClient = {
|
||||
requestMessageContent: async () =>
|
||||
JSON.stringify({
|
||||
playableNpc: {
|
||||
name: '顾潮音',
|
||||
title: '潮港校灯人',
|
||||
role: '边港同行者',
|
||||
description: '在港区高处替玩家校正风向与路标的人。',
|
||||
visualDescription:
|
||||
'深蓝防潮外套压着风痕,腰侧悬着校灯尺和短刃,整个人像常年站在高处看潮线的人。',
|
||||
actionDescription:
|
||||
'先借高处观察和校灯信号牵制敌人,再突然贴近切断退路。',
|
||||
sceneVisualDescription:
|
||||
'他第一次出现的高台边缘挂着潮湿风旗,脚下是被盐雾浸白的木板和仍亮着的旧校灯。',
|
||||
backstory: '曾负责港区夜航校灯,后被卷进旧案。',
|
||||
personality: '沉稳、寡言、观察细。',
|
||||
motivation: '想在港区秩序彻底失控前找到还能守住的线。',
|
||||
combatStyle: '高差观察后快速切入。',
|
||||
initialAffinity: 24,
|
||||
relationshipHooks: ['夜航校灯', '旧港案'],
|
||||
tags: ['港区', '校灯'],
|
||||
publicSummary: '港区里很少有人比他更熟悉夜里的风向。',
|
||||
chapterTeasers: ['他盯风向比盯人更久。', '旧港案在他身上没过去。', '他一直在等某个信号。', '他还藏着最后一次校灯记录。'],
|
||||
chapterContents: ['他总先校风向。', '旧港案改变了他的站位。', '他真正守的是港区里还没断的线。', '最后那份校灯记录能指向操盘者。'],
|
||||
skills: [
|
||||
{ name: '校灯试探', summary: '先用灯信号试探敌我位置。', style: '起手压制' },
|
||||
{ name: '斜坡切入', summary: '借高差快速贴近改线。', style: '机动周旋' },
|
||||
{ name: '潮线封口', summary: '看准潮线后一口气断掉退路。', style: '爆发终结' },
|
||||
],
|
||||
initialItems: [
|
||||
{ name: '校灯尺', category: '武器', quantity: 1, rarity: 'rare', description: '兼具校灯与近战功能。', tags: ['港区'] },
|
||||
{ name: '旧港图片', category: '专属物品', quantity: 1, rarity: 'rare', description: '记着他自己的旧线路。', tags: ['旧案'] },
|
||||
{ name: '潮雾止血包', category: '消耗品', quantity: 2, rarity: 'uncommon', description: '港区常备。', tags: ['补给'] },
|
||||
],
|
||||
},
|
||||
}),
|
||||
} as UpstreamLlmClient;
|
||||
|
||||
const result = await generateCustomWorldEntity(llmClient, {
|
||||
profile: createProfile(),
|
||||
kind: 'playable',
|
||||
});
|
||||
|
||||
assert.equal(result.kind, 'playable');
|
||||
assert.equal(
|
||||
result.entity.visualDescription,
|
||||
'深蓝防潮外套压着风痕,腰侧悬着校灯尺和短刃,整个人像常年站在高处看潮线的人。',
|
||||
);
|
||||
assert.equal(
|
||||
result.entity.actionDescription,
|
||||
'先借高处观察和校灯信号牵制敌人,再突然贴近切断退路。',
|
||||
);
|
||||
assert.equal(
|
||||
result.entity.sceneVisualDescription,
|
||||
'他第一次出现的高台边缘挂着潮湿风旗,脚下是被盐雾浸白的木板和仍亮着的旧校灯。',
|
||||
);
|
||||
});
|
||||
|
||||
test('generateCustomWorldEntity returns landmark visual descriptions from the same model response', async () => {
|
||||
const llmClient = {
|
||||
requestMessageContent: async () =>
|
||||
JSON.stringify({
|
||||
landmark: {
|
||||
name: '回潮观测台',
|
||||
description: '能俯瞰旧港和裂潮边缘的新观测点。',
|
||||
visualDescription:
|
||||
'观测台立在湿冷高处,铁质风标、旧灯架和被潮气侵白的石阶一起构成了压迫感很强的前景。',
|
||||
dangerLevel: 'high',
|
||||
sceneNpcNames: ['梁砺'],
|
||||
connections: [
|
||||
{
|
||||
targetLandmarkName: '旧潮栈桥',
|
||||
relativePosition: 'forward',
|
||||
summary: '沿风雨走廊可直接回到旧潮栈桥',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
} as UpstreamLlmClient;
|
||||
|
||||
const result = await generateCustomWorldEntity(llmClient, {
|
||||
profile: createProfile(),
|
||||
kind: 'landmark',
|
||||
});
|
||||
|
||||
assert.equal(result.kind, 'landmark');
|
||||
assert.equal(
|
||||
result.entity.visualDescription,
|
||||
'观测台立在湿冷高处,铁质风标、旧灯架和被潮气侵白的石阶一起构成了压迫感很强的前景。',
|
||||
);
|
||||
});
|
||||
@@ -15,6 +15,9 @@ type ParsedRole = {
|
||||
title: string;
|
||||
role: string;
|
||||
description: string;
|
||||
visualDescription: string;
|
||||
actionDescription: string;
|
||||
sceneVisualDescription: string;
|
||||
backstory: string;
|
||||
personality: string;
|
||||
motivation: string;
|
||||
@@ -34,6 +37,7 @@ type ParsedLandmark = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
visualDescription: string;
|
||||
dangerLevel: string;
|
||||
sceneNpcIds: string[];
|
||||
connections: ParsedLandmarkConnection[];
|
||||
@@ -220,6 +224,9 @@ function normalizeRole(value: unknown): ParsedRole | null {
|
||||
title: title || role || '角色',
|
||||
role,
|
||||
description: toText(record.description),
|
||||
visualDescription: toText(record.visualDescription),
|
||||
actionDescription: toText(record.actionDescription),
|
||||
sceneVisualDescription: toText(record.sceneVisualDescription),
|
||||
backstory: toText(record.backstory),
|
||||
personality: toText(record.personality),
|
||||
motivation: toText(record.motivation),
|
||||
@@ -275,6 +282,7 @@ function normalizeLandmark(value: unknown): ParsedLandmark | null {
|
||||
id,
|
||||
name,
|
||||
description: toText(record.description),
|
||||
visualDescription: toText(record.visualDescription),
|
||||
dangerLevel: toText(record.dangerLevel, 'medium'),
|
||||
sceneNpcIds: toStringArray(record.sceneNpcIds, 12),
|
||||
connections,
|
||||
@@ -326,7 +334,11 @@ function buildRoleReferenceText(roles: ParsedRole[], emptyText: string) {
|
||||
role.backstory || '未写'
|
||||
} / 性格:${role.personality || '未写'} / 动机:${
|
||||
role.motivation || '未写'
|
||||
} / 标签:${role.tags.join('、') || '暂无'}`,
|
||||
} / 形象:${role.visualDescription || '未写'} / 动作表现:${
|
||||
role.actionDescription || '未写'
|
||||
} / 场景画面:${role.sceneVisualDescription || '未写'} / 标签:${
|
||||
role.tags.join('、') || '暂无'
|
||||
}`,
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
@@ -361,7 +373,9 @@ function buildLandmarkReferenceText(profile: ParsedProfile) {
|
||||
|
||||
return `${index + 1}. ${landmark.name} / 危险度:${
|
||||
landmark.dangerLevel || 'medium'
|
||||
} / 描述:${landmark.description || '未写'} / 场景角色:${
|
||||
} / 描述:${landmark.description || '未写'} / 画面:${
|
||||
landmark.visualDescription || '未写'
|
||||
} / 场景角色:${
|
||||
sceneNpcNames || '暂无'
|
||||
} / 连接:${connectionNames || '暂无'}`;
|
||||
})
|
||||
@@ -437,6 +451,24 @@ function buildFallbackRoleDraft(
|
||||
: `长期活跃于当前世界暗面,能补足场景视角的关键角色。`,
|
||||
60,
|
||||
),
|
||||
visualDescription: clampText(
|
||||
kind === 'playable'
|
||||
? `他保留着适合长期同行的鲜明外形识别点,服装、装备和体态都能直接看出其职责、出身和会如何与玩家并肩行动。`
|
||||
: `他身上带着与当前局势强绑定的外观痕迹,衣着、器具和整体气质会暴露其长期活动环境与所站的位置。`,
|
||||
96,
|
||||
),
|
||||
actionDescription: clampText(
|
||||
kind === 'playable'
|
||||
? '动作表现偏向协作推进与稳定压制,起手克制,发力明确,收招干净。'
|
||||
: '动作表现偏向试探、牵制与借势,节奏谨慎,但关键时刻会突然加重攻击或位移。',
|
||||
72,
|
||||
),
|
||||
sceneVisualDescription: clampText(
|
||||
profile.landmarks[0]?.description
|
||||
? `他的主要活动空间与${profile.landmarks[0].name}相连,场景里能看到${profile.landmarks[0].description}`
|
||||
: `他的主要活动空间与${profile.name}当前冲突线直接相关,环境里会留下势力痕迹、旧装置和仍在运转的局势线索。`,
|
||||
96,
|
||||
),
|
||||
backstory: clampText(
|
||||
`他与${profile.name}当前正在扩张的冲突链紧密相连,知道一些还未公开的内情。`,
|
||||
80,
|
||||
@@ -535,6 +567,10 @@ function buildFallbackLandmarkDraft(profile: ParsedProfile) {
|
||||
`承接${profile.name}当前主冲突的一处关键新场景,适合继续向外扩张世界关系网。`,
|
||||
72,
|
||||
),
|
||||
visualDescription: clampText(
|
||||
`这里延续${profile.name}当前主冲突的视觉气质,能看到明确的空间层次、可站立地面、核心建筑或地貌,以及仍在运转的局势痕迹。`,
|
||||
88,
|
||||
),
|
||||
dangerLevel: 'medium',
|
||||
sceneNpcNames,
|
||||
connections: targetLandmarkNames.map((targetLandmarkName, index) => ({
|
||||
@@ -560,6 +596,9 @@ function buildPlayablePrompt(profile: ParsedProfile) {
|
||||
'- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复定位。',
|
||||
'- 必须保留明确的协作价值、成长空间和入队理由。',
|
||||
'- 不要生成泛用模板角色,必须让角色与当前世界的具体势力、地点、冲突或禁忌发生绑定。',
|
||||
'- visualDescription 只写与角色设定相关的外形、服装、材质、武器、体态、色彩和识别特征,禁止写角色以外的周边环境等与角色不想管的设定。',
|
||||
'- actionDescription 只写这个角色的动作表现与战斗气质,不要写镜头切换或参数。',
|
||||
'- sceneVisualDescription 只写该角色首次登场或主要活动区域的场景画面感,不要写“提示词”字样。',
|
||||
'- 只返回 JSON,不要输出解释或 Markdown。',
|
||||
'JSON 结构:',
|
||||
'{',
|
||||
@@ -568,6 +607,9 @@ function buildPlayablePrompt(profile: ParsedProfile) {
|
||||
' "title": "称号",',
|
||||
' "role": "身份",',
|
||||
' "description": "一句到两句定位描述",',
|
||||
' "visualDescription": "角色形象描述",',
|
||||
' "actionDescription": "动作表现描述",',
|
||||
' "sceneVisualDescription": "角色关联场景画面描述",',
|
||||
' "backstory": "背景经历",',
|
||||
' "personality": "性格特点",',
|
||||
' "motivation": "当前动机",',
|
||||
@@ -608,6 +650,9 @@ function buildStoryPrompt(profile: ParsedProfile) {
|
||||
'- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复定位。',
|
||||
'- 必须像能直接进入游戏的场景角色,而不是抽象设定条目。',
|
||||
'- 角色应与具体场景、关系链或局势变化发生绑定。',
|
||||
'- visualDescription 只写与角色设定匹配的外形、服装、材质、武器、体态、色彩和识别特征,不要写“提示词”、镜头参数或构图规则。',
|
||||
'- actionDescription 只写这个角色的动作表现与战斗气质,不要写镜头切换或参数。',
|
||||
'- sceneVisualDescription 只写该角色首次登场或主要活动区域的场景画面感,不要写“提示词”字样。',
|
||||
'- 只返回 JSON,不要输出解释或 Markdown。',
|
||||
'JSON 结构:',
|
||||
'{',
|
||||
@@ -616,6 +661,9 @@ function buildStoryPrompt(profile: ParsedProfile) {
|
||||
' "title": "称号",',
|
||||
' "role": "身份",',
|
||||
' "description": "一句到两句定位描述",',
|
||||
' "visualDescription": "角色形象描述",',
|
||||
' "actionDescription": "动作表现描述",',
|
||||
' "sceneVisualDescription": "角色关联场景画面描述",',
|
||||
' "backstory": "背景经历",',
|
||||
' "personality": "性格特点",',
|
||||
' "motivation": "当前动机",',
|
||||
@@ -656,12 +704,14 @@ function buildLandmarkPrompt(profile: ParsedProfile) {
|
||||
'- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复。',
|
||||
'- 必须给出适合出现在这个新场景里的 sceneNpcNames,且只能从已有场景角色里选择至少 3 个名字。',
|
||||
'- 必须给出 connections,且 targetLandmarkName 只能引用已有场景名,不要连向自己。',
|
||||
'- visualDescription 只写这个场景的空间层次、地面、主体建筑或自然景观、氛围、色彩和可见装置,不要写“提示词”、镜头参数或构图规则。',
|
||||
'- 只返回 JSON,不要输出解释或 Markdown。',
|
||||
'JSON 结构:',
|
||||
'{',
|
||||
' "landmark": {',
|
||||
' "name": "场景名",',
|
||||
' "description": "场景描述",',
|
||||
' "visualDescription": "场景画面描述",',
|
||||
' "dangerLevel": "low|medium|high|extreme",',
|
||||
' "sceneNpcNames": ["场景角色1", "场景角色2", "场景角色3"],',
|
||||
' "connections": [',
|
||||
@@ -737,6 +787,21 @@ function sanitizeGeneratedRole(
|
||||
toText(record?.description, fallbackDraft.description),
|
||||
120,
|
||||
),
|
||||
visualDescription: clampText(
|
||||
toText(record?.visualDescription, fallbackDraft.visualDescription),
|
||||
180,
|
||||
),
|
||||
actionDescription: clampText(
|
||||
toText(record?.actionDescription, fallbackDraft.actionDescription),
|
||||
140,
|
||||
),
|
||||
sceneVisualDescription: clampText(
|
||||
toText(
|
||||
record?.sceneVisualDescription,
|
||||
fallbackDraft.sceneVisualDescription,
|
||||
),
|
||||
180,
|
||||
),
|
||||
backstory: clampText(toText(record?.backstory, fallbackDraft.backstory), 260),
|
||||
personality: clampText(
|
||||
toText(record?.personality, fallbackDraft.personality),
|
||||
@@ -962,6 +1027,10 @@ function sanitizeGeneratedLandmark(rawValue: unknown, profile: ParsedProfile) {
|
||||
toText(record?.description, fallbackDraft.description),
|
||||
140,
|
||||
),
|
||||
visualDescription: clampText(
|
||||
toText(record?.visualDescription, fallbackDraft.visualDescription),
|
||||
180,
|
||||
),
|
||||
dangerLevel: (() => {
|
||||
const level = toText(record?.dangerLevel, fallbackDraft.dangerLevel);
|
||||
return level === 'low' ||
|
||||
|
||||
@@ -21,6 +21,10 @@ import type {
|
||||
CustomWorldAgentSessionRecord,
|
||||
CustomWorldAgentSessionStore,
|
||||
} from './customWorldAgentSessionStore.js';
|
||||
import {
|
||||
buildDraftSummaryFromEightAnchorContent,
|
||||
buildDraftTitleFromEightAnchorContent,
|
||||
} from './eightAnchorCompatibilityService.js';
|
||||
|
||||
function toText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
@@ -64,6 +68,7 @@ function resolveDraftTitle(session: CustomWorldAgentSessionRecord) {
|
||||
|
||||
return (
|
||||
draftProfile?.name ||
|
||||
buildDraftTitleFromEightAnchorContent(session.anchorContent) ||
|
||||
buildDraftTitleFromIntent(intent) ||
|
||||
toText(session.draftProfile?.title) ||
|
||||
truncateText(session.seedText, 18) ||
|
||||
@@ -78,6 +83,7 @@ function resolveDraftSummary(session: CustomWorldAgentSessionRecord) {
|
||||
|
||||
return (
|
||||
draftProfile?.summary ||
|
||||
buildDraftSummaryFromEightAnchorContent(session.anchorContent) ||
|
||||
compiledSummary ||
|
||||
toText(session.draftProfile?.summary) ||
|
||||
truncateText(session.seedText, 72) ||
|
||||
|
||||
593
server-node/src/services/eightAnchorCompatibilityService.ts
Normal file
593
server-node/src/services/eightAnchorCompatibilityService.ts
Normal file
@@ -0,0 +1,593 @@
|
||||
import type {
|
||||
CoreConflictValue,
|
||||
EightAnchorContent,
|
||||
HiddenLineValue,
|
||||
IconicElementValue,
|
||||
KeyRelationshipValue,
|
||||
PlayerEntryPointValue,
|
||||
PlayerFantasyValue,
|
||||
ThemeBoundaryValue,
|
||||
WorldPromiseValue,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
import {
|
||||
buildAnchorPackFromIntent,
|
||||
createEmptyCreatorIntentRecord,
|
||||
type CreatorCharacterSeedRecord,
|
||||
type CustomWorldCreatorIntentRecord,
|
||||
} from './customWorldAgentIntentExtractionService.js';
|
||||
|
||||
function toText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function toStringArray(value: unknown, maxCount = 8) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...new Set(value.map((item) => toText(item)).filter(Boolean))].slice(
|
||||
0,
|
||||
maxCount,
|
||||
);
|
||||
}
|
||||
|
||||
function compactLines(items: Array<string | null | undefined>) {
|
||||
return items.map((item) => toText(item)).filter(Boolean).join(';');
|
||||
}
|
||||
|
||||
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 createId(prefix: string, index: number) {
|
||||
return `${prefix}-${index + 1}`;
|
||||
}
|
||||
|
||||
function splitRelationshipPair(value: string) {
|
||||
const segments = value
|
||||
.split(/[、//&|]/u)
|
||||
.map((item) => item.trim())
|
||||
.flatMap((item) => item.split(/(?:与|和)/u))
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const meaningful = segments.filter(
|
||||
(item) => item !== '玩家' && item !== '主角' && item !== '我',
|
||||
);
|
||||
|
||||
return {
|
||||
leadName: meaningful[0] || segments[0] || '',
|
||||
relationToPlayer:
|
||||
segments.length >= 2 ? segments.join(' / ') : value.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeWorldPromise(value: unknown): WorldPromiseValue | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
const nextValue = {
|
||||
hook: toText(item.hook),
|
||||
differentiator: toText(item.differentiator),
|
||||
desiredExperience: toText(item.desiredExperience),
|
||||
} satisfies WorldPromiseValue;
|
||||
|
||||
return Object.values(nextValue).some(Boolean) ? nextValue : null;
|
||||
}
|
||||
|
||||
function normalizePlayerFantasy(value: unknown): PlayerFantasyValue | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
const nextValue = {
|
||||
playerRole: toText(item.playerRole),
|
||||
corePursuit: toText(item.corePursuit),
|
||||
fearOfLoss: toText(item.fearOfLoss),
|
||||
} satisfies PlayerFantasyValue;
|
||||
|
||||
return Object.values(nextValue).some(Boolean) ? nextValue : null;
|
||||
}
|
||||
|
||||
function normalizeThemeBoundary(value: unknown): ThemeBoundaryValue | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
const nextValue = {
|
||||
toneKeywords: toStringArray(item.toneKeywords, 8),
|
||||
aestheticDirectives: toStringArray(item.aestheticDirectives, 8),
|
||||
forbiddenDirectives: toStringArray(item.forbiddenDirectives, 8),
|
||||
} satisfies ThemeBoundaryValue;
|
||||
|
||||
return Object.values(nextValue).some((entry) => entry.length > 0)
|
||||
? nextValue
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizePlayerEntryPoint(value: unknown): PlayerEntryPointValue | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
const nextValue = {
|
||||
openingIdentity: toText(item.openingIdentity),
|
||||
openingProblem: toText(item.openingProblem),
|
||||
entryMotivation: toText(item.entryMotivation),
|
||||
} satisfies PlayerEntryPointValue;
|
||||
|
||||
return Object.values(nextValue).some(Boolean) ? nextValue : null;
|
||||
}
|
||||
|
||||
function normalizeCoreConflict(value: unknown): CoreConflictValue | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
const nextValue = {
|
||||
surfaceConflicts: toStringArray(item.surfaceConflicts, 6),
|
||||
hiddenCrisis: toText(item.hiddenCrisis),
|
||||
firstTouchedConflict: toText(item.firstTouchedConflict),
|
||||
} satisfies CoreConflictValue;
|
||||
|
||||
return (
|
||||
nextValue.surfaceConflicts.length > 0 ||
|
||||
nextValue.hiddenCrisis ||
|
||||
nextValue.firstTouchedConflict
|
||||
)
|
||||
? nextValue
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeRelationship(value: unknown): KeyRelationshipValue | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
const nextValue = {
|
||||
pairs: toText(item.pairs),
|
||||
relationshipType: toText(item.relationshipType),
|
||||
secretOrCost: toText(item.secretOrCost),
|
||||
} satisfies KeyRelationshipValue;
|
||||
|
||||
return Object.values(nextValue).some(Boolean) ? nextValue : null;
|
||||
}
|
||||
|
||||
function normalizeHiddenLines(value: unknown): HiddenLineValue | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
const nextValue = {
|
||||
hiddenTruths: toStringArray(item.hiddenTruths, 6),
|
||||
misdirectionHints: toStringArray(item.misdirectionHints, 6),
|
||||
revealPacing: toText(item.revealPacing),
|
||||
} satisfies HiddenLineValue;
|
||||
|
||||
return (
|
||||
nextValue.hiddenTruths.length > 0 ||
|
||||
nextValue.misdirectionHints.length > 0 ||
|
||||
nextValue.revealPacing
|
||||
)
|
||||
? nextValue
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeIconicElements(value: unknown): IconicElementValue | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
const nextValue = {
|
||||
iconicMotifs: toStringArray(item.iconicMotifs, 8),
|
||||
institutionsOrArtifacts: toStringArray(item.institutionsOrArtifacts, 8),
|
||||
hardRules: toStringArray(item.hardRules, 8),
|
||||
} satisfies IconicElementValue;
|
||||
|
||||
return (
|
||||
nextValue.iconicMotifs.length > 0 ||
|
||||
nextValue.institutionsOrArtifacts.length > 0 ||
|
||||
nextValue.hardRules.length > 0
|
||||
)
|
||||
? nextValue
|
||||
: null;
|
||||
}
|
||||
|
||||
export function createEmptyEightAnchorContent(): EightAnchorContent {
|
||||
return {
|
||||
worldPromise: null,
|
||||
playerFantasy: null,
|
||||
themeBoundary: null,
|
||||
playerEntryPoint: null,
|
||||
coreConflict: null,
|
||||
keyRelationships: [],
|
||||
hiddenLines: null,
|
||||
iconicElements: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeEightAnchorContent(value: unknown): EightAnchorContent {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return createEmptyEightAnchorContent();
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
worldPromise: normalizeWorldPromise(item.worldPromise),
|
||||
playerFantasy: normalizePlayerFantasy(item.playerFantasy),
|
||||
themeBoundary: normalizeThemeBoundary(item.themeBoundary),
|
||||
playerEntryPoint: normalizePlayerEntryPoint(item.playerEntryPoint),
|
||||
coreConflict: normalizeCoreConflict(item.coreConflict),
|
||||
keyRelationships: Array.isArray(item.keyRelationships)
|
||||
? item.keyRelationships
|
||||
.map((entry) => normalizeRelationship(entry))
|
||||
.filter((entry): entry is KeyRelationshipValue => Boolean(entry))
|
||||
.slice(0, 4)
|
||||
: [],
|
||||
hiddenLines: normalizeHiddenLines(item.hiddenLines),
|
||||
iconicElements: normalizeIconicElements(item.iconicElements),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildEightAnchorContentFromCreatorIntent(
|
||||
intent: CustomWorldCreatorIntentRecord | null | undefined,
|
||||
): EightAnchorContent {
|
||||
if (!intent) {
|
||||
return createEmptyEightAnchorContent();
|
||||
}
|
||||
|
||||
const themeBoundary =
|
||||
intent.themeKeywords.length > 0 ||
|
||||
intent.toneDirectives.length > 0 ||
|
||||
intent.forbiddenDirectives.length > 0
|
||||
? {
|
||||
toneKeywords: [...intent.themeKeywords],
|
||||
aestheticDirectives: [...intent.toneDirectives],
|
||||
forbiddenDirectives: [...intent.forbiddenDirectives],
|
||||
}
|
||||
: null;
|
||||
|
||||
const firstCharacter = intent.keyCharacters[0] ?? null;
|
||||
|
||||
return normalizeEightAnchorContent({
|
||||
worldPromise:
|
||||
intent.worldHook || intent.rawSettingText
|
||||
? {
|
||||
hook: intent.worldHook,
|
||||
differentiator: intent.rawSettingText,
|
||||
desiredExperience: compactLines([
|
||||
intent.themeKeywords[0],
|
||||
intent.toneDirectives[0],
|
||||
]),
|
||||
}
|
||||
: null,
|
||||
playerFantasy:
|
||||
intent.playerPremise || intent.coreConflicts[0]
|
||||
? {
|
||||
playerRole: intent.playerPremise,
|
||||
corePursuit: intent.coreConflicts[0] ?? '',
|
||||
fearOfLoss: firstCharacter?.hiddenHook ?? '',
|
||||
}
|
||||
: null,
|
||||
themeBoundary,
|
||||
playerEntryPoint:
|
||||
intent.playerPremise || intent.openingSituation
|
||||
? {
|
||||
openingIdentity: intent.playerPremise,
|
||||
openingProblem: intent.openingSituation,
|
||||
entryMotivation: intent.coreConflicts[0] ?? '',
|
||||
}
|
||||
: null,
|
||||
coreConflict:
|
||||
intent.coreConflicts.length > 0
|
||||
? {
|
||||
surfaceConflicts: intent.coreConflicts.slice(0, 3),
|
||||
hiddenCrisis: intent.keyCharacters[0]?.hiddenHook ?? '',
|
||||
firstTouchedConflict: intent.coreConflicts[0] ?? '',
|
||||
}
|
||||
: null,
|
||||
keyRelationships: intent.keyCharacters.map((entry) => ({
|
||||
pairs: compactLines([
|
||||
entry.name,
|
||||
entry.relationToPlayer ? `与玩家 ${entry.relationToPlayer}` : '',
|
||||
]),
|
||||
relationshipType: entry.role,
|
||||
secretOrCost: entry.hiddenHook,
|
||||
})),
|
||||
hiddenLines:
|
||||
intent.keyCharacters.some((entry) => entry.hiddenHook) ||
|
||||
intent.forbiddenDirectives.length > 0
|
||||
? {
|
||||
hiddenTruths: intent.keyCharacters
|
||||
.map((entry) => entry.hiddenHook)
|
||||
.filter(Boolean)
|
||||
.slice(0, 3),
|
||||
misdirectionHints: intent.forbiddenDirectives.slice(0, 3),
|
||||
revealPacing: '',
|
||||
}
|
||||
: null,
|
||||
iconicElements:
|
||||
intent.iconicElements.length > 0
|
||||
? {
|
||||
iconicMotifs: intent.iconicElements.slice(0, 4),
|
||||
institutionsOrArtifacts: [],
|
||||
hardRules: intent.forbiddenDirectives.slice(0, 3),
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildCreatorIntentFromEightAnchorContent(
|
||||
anchorContent: EightAnchorContent,
|
||||
): CustomWorldCreatorIntentRecord {
|
||||
const nextIntent = createEmptyCreatorIntentRecord('freeform');
|
||||
const normalizedContent = normalizeEightAnchorContent(anchorContent);
|
||||
const keyCharacters: CreatorCharacterSeedRecord[] =
|
||||
normalizedContent.keyRelationships.map((entry, index) => {
|
||||
const parsedPair = splitRelationshipPair(entry.pairs);
|
||||
|
||||
return {
|
||||
id: createId('creator-character', index),
|
||||
name: parsedPair.leadName || `关键人物${index + 1}`,
|
||||
role: entry.relationshipType,
|
||||
publicMask: '',
|
||||
hiddenHook: entry.secretOrCost,
|
||||
relationToPlayer: parsedPair.relationToPlayer,
|
||||
notes: '',
|
||||
};
|
||||
});
|
||||
|
||||
const worldHook = compactLines([
|
||||
normalizedContent.worldPromise?.hook,
|
||||
normalizedContent.worldPromise?.differentiator,
|
||||
]);
|
||||
const playerPremise = compactLines([
|
||||
normalizedContent.playerFantasy?.playerRole,
|
||||
normalizedContent.playerEntryPoint?.openingIdentity,
|
||||
]);
|
||||
const openingSituation = compactLines([
|
||||
normalizedContent.playerEntryPoint?.openingProblem,
|
||||
normalizedContent.playerEntryPoint?.entryMotivation,
|
||||
]);
|
||||
const coreConflicts = [
|
||||
...(normalizedContent.coreConflict?.surfaceConflicts ?? []),
|
||||
normalizedContent.coreConflict?.hiddenCrisis ?? '',
|
||||
].filter(Boolean);
|
||||
const iconicElements = [
|
||||
...(normalizedContent.iconicElements?.iconicMotifs ?? []),
|
||||
...(normalizedContent.iconicElements?.institutionsOrArtifacts ?? []),
|
||||
].filter(Boolean);
|
||||
const forbiddenDirectives = [
|
||||
...(normalizedContent.themeBoundary?.forbiddenDirectives ?? []),
|
||||
...(normalizedContent.iconicElements?.hardRules ?? []),
|
||||
].filter(Boolean);
|
||||
|
||||
return {
|
||||
...nextIntent,
|
||||
rawSettingText: compactLines([
|
||||
normalizedContent.worldPromise?.differentiator,
|
||||
normalizedContent.playerFantasy?.corePursuit,
|
||||
normalizedContent.hiddenLines?.hiddenTruths[0],
|
||||
]),
|
||||
worldHook,
|
||||
themeKeywords: normalizedContent.themeBoundary?.toneKeywords ?? [],
|
||||
toneDirectives: normalizedContent.themeBoundary?.aestheticDirectives ?? [],
|
||||
playerPremise,
|
||||
openingSituation,
|
||||
coreConflicts: [...new Set(coreConflicts)].slice(0, 6),
|
||||
keyCharacters,
|
||||
iconicElements: [...new Set(iconicElements)].slice(0, 8),
|
||||
forbiddenDirectives: [...new Set(forbiddenDirectives)].slice(0, 8),
|
||||
} satisfies CustomWorldCreatorIntentRecord;
|
||||
}
|
||||
|
||||
function scoreFilledField(filled: boolean, score: number) {
|
||||
return filled ? score : 0;
|
||||
}
|
||||
|
||||
export function estimateProgressPercentFromAnchorContent(
|
||||
anchorContent: EightAnchorContent,
|
||||
) {
|
||||
const normalized = normalizeEightAnchorContent(anchorContent);
|
||||
const progress =
|
||||
scoreFilledField(Boolean(normalized.worldPromise?.hook), 14) +
|
||||
scoreFilledField(Boolean(normalized.playerFantasy?.playerRole), 12) +
|
||||
scoreFilledField(
|
||||
Boolean(
|
||||
normalized.themeBoundary?.toneKeywords.length ||
|
||||
normalized.themeBoundary?.aestheticDirectives.length,
|
||||
),
|
||||
12,
|
||||
) +
|
||||
scoreFilledField(
|
||||
Boolean(normalized.playerEntryPoint?.openingProblem),
|
||||
12,
|
||||
) +
|
||||
scoreFilledField(
|
||||
Boolean(normalized.coreConflict?.surfaceConflicts.length),
|
||||
16,
|
||||
) +
|
||||
scoreFilledField(normalized.keyRelationships.length > 0, 14) +
|
||||
scoreFilledField(
|
||||
Boolean(
|
||||
normalized.hiddenLines?.hiddenTruths.length ||
|
||||
normalized.hiddenLines?.revealPacing,
|
||||
),
|
||||
8,
|
||||
) +
|
||||
scoreFilledField(
|
||||
Boolean(
|
||||
normalized.iconicElements?.iconicMotifs.length ||
|
||||
normalized.iconicElements?.institutionsOrArtifacts.length,
|
||||
),
|
||||
12,
|
||||
);
|
||||
|
||||
return Math.max(0, Math.min(100, Math.round(progress)));
|
||||
}
|
||||
|
||||
export function buildAnchorPackFromEightAnchorContent(
|
||||
anchorContent: EightAnchorContent,
|
||||
progressPercent: number,
|
||||
) {
|
||||
const creatorIntent = buildCreatorIntentFromEightAnchorContent(anchorContent);
|
||||
|
||||
return buildAnchorPackFromIntent(creatorIntent, {
|
||||
completedKeys: progressPercent >= 100 ? ['eight_anchor_minimum_loop'] : [],
|
||||
missingKeys: progressPercent >= 100 ? [] : ['eight_anchor_minimum_loop'],
|
||||
});
|
||||
}
|
||||
|
||||
export function buildEightAnchorFoundationText(anchorContent: EightAnchorContent) {
|
||||
const normalized = normalizeEightAnchorContent(anchorContent);
|
||||
|
||||
return [
|
||||
normalized.worldPromise
|
||||
? `世界承诺:${compactLines([
|
||||
normalized.worldPromise.hook,
|
||||
normalized.worldPromise.differentiator,
|
||||
normalized.worldPromise.desiredExperience,
|
||||
])}`
|
||||
: '',
|
||||
normalized.playerFantasy
|
||||
? `玩家幻想:${compactLines([
|
||||
normalized.playerFantasy.playerRole,
|
||||
normalized.playerFantasy.corePursuit,
|
||||
normalized.playerFantasy.fearOfLoss,
|
||||
])}`
|
||||
: '',
|
||||
normalized.themeBoundary
|
||||
? `主题边界:${compactLines([
|
||||
normalized.themeBoundary.toneKeywords.join('、'),
|
||||
normalized.themeBoundary.aestheticDirectives.join('、'),
|
||||
normalized.themeBoundary.forbiddenDirectives.join('、'),
|
||||
])}`
|
||||
: '',
|
||||
normalized.playerEntryPoint
|
||||
? `玩家切入口:${compactLines([
|
||||
normalized.playerEntryPoint.openingIdentity,
|
||||
normalized.playerEntryPoint.openingProblem,
|
||||
normalized.playerEntryPoint.entryMotivation,
|
||||
])}`
|
||||
: '',
|
||||
normalized.coreConflict
|
||||
? `核心冲突:${compactLines([
|
||||
normalized.coreConflict.surfaceConflicts.join('、'),
|
||||
normalized.coreConflict.hiddenCrisis,
|
||||
normalized.coreConflict.firstTouchedConflict,
|
||||
])}`
|
||||
: '',
|
||||
normalized.keyRelationships.length > 0
|
||||
? `关键关系:${normalized.keyRelationships
|
||||
.map((entry) =>
|
||||
compactLines([
|
||||
entry.pairs,
|
||||
entry.relationshipType,
|
||||
entry.secretOrCost,
|
||||
]),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(';')}`
|
||||
: '',
|
||||
normalized.hiddenLines
|
||||
? `暗线与揭示:${compactLines([
|
||||
normalized.hiddenLines.hiddenTruths.join('、'),
|
||||
normalized.hiddenLines.misdirectionHints.join('、'),
|
||||
normalized.hiddenLines.revealPacing,
|
||||
])}`
|
||||
: '',
|
||||
normalized.iconicElements
|
||||
? `标志元素:${compactLines([
|
||||
normalized.iconicElements.iconicMotifs.join('、'),
|
||||
normalized.iconicElements.institutionsOrArtifacts.join('、'),
|
||||
normalized.iconicElements.hardRules.join('、'),
|
||||
])}`
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildDraftTitleFromEightAnchorContent(
|
||||
anchorContent: EightAnchorContent,
|
||||
) {
|
||||
const normalized = normalizeEightAnchorContent(anchorContent);
|
||||
const candidate = clampText(
|
||||
normalized.worldPromise?.hook ||
|
||||
normalized.worldPromise?.differentiator ||
|
||||
normalized.iconicElements?.iconicMotifs[0] ||
|
||||
normalized.playerFantasy?.playerRole ||
|
||||
'',
|
||||
24,
|
||||
);
|
||||
|
||||
return candidate || '未命名草稿';
|
||||
}
|
||||
|
||||
export function buildDraftSummaryFromEightAnchorContent(
|
||||
anchorContent: EightAnchorContent,
|
||||
) {
|
||||
const normalized = normalizeEightAnchorContent(anchorContent);
|
||||
const summary = [
|
||||
compactLines([
|
||||
normalized.worldPromise?.hook,
|
||||
normalized.worldPromise?.differentiator,
|
||||
normalized.worldPromise?.desiredExperience,
|
||||
]),
|
||||
compactLines([
|
||||
normalized.playerFantasy?.playerRole,
|
||||
normalized.playerFantasy?.corePursuit,
|
||||
normalized.playerFantasy?.fearOfLoss,
|
||||
]),
|
||||
compactLines([
|
||||
normalized.playerEntryPoint?.openingIdentity,
|
||||
normalized.playerEntryPoint?.openingProblem,
|
||||
normalized.playerEntryPoint?.entryMotivation,
|
||||
]),
|
||||
compactLines([
|
||||
normalized.coreConflict?.surfaceConflicts.join('、'),
|
||||
normalized.coreConflict?.hiddenCrisis,
|
||||
normalized.coreConflict?.firstTouchedConflict,
|
||||
]),
|
||||
normalized.keyRelationships.length > 0
|
||||
? normalized.keyRelationships
|
||||
.map((entry) =>
|
||||
compactLines([
|
||||
entry.pairs,
|
||||
entry.relationshipType,
|
||||
entry.secretOrCost,
|
||||
]),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(';')
|
||||
: '',
|
||||
compactLines([
|
||||
normalized.iconicElements?.iconicMotifs.join('、'),
|
||||
normalized.iconicElements?.institutionsOrArtifacts.join('、'),
|
||||
normalized.iconicElements?.hardRules.join('、'),
|
||||
]),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
|
||||
return clampText(summary, 180) || '还在收集你的世界锚点。';
|
||||
}
|
||||
784
server-node/src/services/eightAnchorPromptBuilder.ts
Normal file
784
server-node/src/services/eightAnchorPromptBuilder.ts
Normal file
@@ -0,0 +1,784 @@
|
||||
import type {
|
||||
EightAnchorContent,
|
||||
HiddenLineValue,
|
||||
IconicElementValue,
|
||||
KeyRelationshipValue,
|
||||
ThemeBoundaryValue,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
import {
|
||||
createEmptyEightAnchorContent,
|
||||
normalizeEightAnchorContent,
|
||||
} from './eightAnchorCompatibilityService.js';
|
||||
|
||||
export type PromptUserInputSignal =
|
||||
| 'rich'
|
||||
| 'normal'
|
||||
| 'sparse'
|
||||
| 'correction'
|
||||
| 'delegate';
|
||||
|
||||
export type PromptDriftRisk = 'low' | 'medium' | 'high';
|
||||
|
||||
export type PromptConversationMode =
|
||||
| 'bootstrap'
|
||||
| 'expand'
|
||||
| 'compress'
|
||||
| 'repair_direction'
|
||||
| 'force_complete'
|
||||
| 'closing';
|
||||
|
||||
export type PromptDynamicState = {
|
||||
currentTurn: number;
|
||||
progressPercent: number;
|
||||
userInputSignal: PromptUserInputSignal;
|
||||
driftRisk: PromptDriftRisk;
|
||||
quickFillRequested: boolean;
|
||||
conversationMode: PromptConversationMode;
|
||||
judgementSummary: string;
|
||||
};
|
||||
|
||||
export type PromptDynamicStateInference = {
|
||||
userInputSignal?: unknown;
|
||||
driftRisk?: unknown;
|
||||
conversationMode?: unknown;
|
||||
judgementSummary?: unknown;
|
||||
};
|
||||
|
||||
const BASE_SYSTEM_PROMPT = `你是一个负责共创游戏世界设定的专业策划。
|
||||
|
||||
你正在和用户一起共创一个游戏世界。每一轮你都必须读取:
|
||||
1. 当前完整设定结构
|
||||
2. 用户聊天记录
|
||||
|
||||
然后输出:
|
||||
1. 一版新的完整设定结构
|
||||
2. 当前 progress 百分比
|
||||
3. 一段直接回复用户的话
|
||||
|
||||
你必须把“新的完整设定结构”视为下一轮的唯一有效版本。
|
||||
你的输出会直接覆盖上一版设定结构。
|
||||
|
||||
你不是在做局部 patch。
|
||||
你不是在做解释报告。
|
||||
你不是在给开发者写分析。
|
||||
你是在同时完成:
|
||||
1. 世界设定更新
|
||||
2. 当前推进程度判断
|
||||
3. 对用户的共创回复`;
|
||||
|
||||
const GLOBAL_HARD_RULES = `全局硬约束:
|
||||
|
||||
1. 必须输出完整的设定结构,而不是只输出变化部分。
|
||||
2. 新的设定结构会直接覆盖旧内容,因此不得随意丢失仍然成立的重要信息。
|
||||
3. 如果用户明确修正旧设定,必须在新的设定结构中直接体现修正结果。
|
||||
4. 如果用户输入信息不足,可以保留上一版中仍然成立的内容。
|
||||
5. progressPercent 最低为 0,不允许为负数。
|
||||
6. replyText 会直接发送给用户,因此要自然、直接、可继续聊天。
|
||||
7. 不要输出额外解释,不要输出 markdown 代码块,不要输出开发备注。
|
||||
8. replyText 不要写成长篇策划文,不要展开大段世界观百科。
|
||||
9. replyText 默认只推进当前最关键的一步,不要同时抛出很多话题。
|
||||
10. replyText 不要提及“八锚点”“锚点”“结构字段”“框架字段”等内部概念词。
|
||||
11. 你输出的 JSON 必须可以被直接解析。
|
||||
12. 输出字段顺序必须固定为:replyText、progressPercent、nextAnchorContent。`;
|
||||
|
||||
const MODE_RULES: Record<PromptConversationMode, string> = {
|
||||
bootstrap: `当前模式:bootstrap
|
||||
|
||||
目标:
|
||||
1. 先把世界的基本方向抓住
|
||||
2. 不要一次塞太多新设定
|
||||
3. 回复要降低用户开口压力
|
||||
|
||||
本轮行为要求:
|
||||
1. 优先从用户输入里抓世界方向、玩家视角、主题边界的线索
|
||||
2. 如果用户信息很少,不要强行把整套结构一次补满
|
||||
3. replyText 要像共创搭档,而不是像审问
|
||||
4. 默认只推进一个最关键的问题方向
|
||||
5. 如果用户刚开口,优先给“被理解感”,再轻轻推进下一步
|
||||
6. 可以用一句很短的话先确认你抓到的核心方向,再提一个最好回答的问题
|
||||
7. 不要把问题问得像表单采集,不要一口气追问多个维度
|
||||
|
||||
用户体验要求:
|
||||
1. 让用户觉得“现在很容易继续往下说”
|
||||
2. 不要制造被考试、被拷问、被策划问卷追着跑的感觉
|
||||
3. replyText 最好短、稳、可接话
|
||||
4. 如果用户信息很少,也不要显得冷淡或机械`,
|
||||
expand: `当前模式:expand
|
||||
|
||||
目标:
|
||||
1. 在保持现有方向的前提下,把设定结构逐步补全
|
||||
2. 尽量让一轮输入覆盖多个关键维度
|
||||
|
||||
本轮行为要求:
|
||||
1. 继续保留上一版里仍成立的设定
|
||||
2. 优先把用户本轮输入映射进多个关键维度,而不是只更新一个字段
|
||||
3. replyText 要明确体现“你已经理解了哪些内容”
|
||||
4. 不要突然大幅改写已经成形的世界
|
||||
5. 如果用户这一轮给了多条有效信息,replyText 应先把这些信息自然串起来,再决定下一步
|
||||
6. 可以适度替用户整理,但不要把回复写成总结报告
|
||||
7. 默认继续往前推一步,不要在还没必要时突然收束或突然跳到成稿感
|
||||
|
||||
用户体验要求:
|
||||
1. 让用户感到“我刚说的内容都被接住了”
|
||||
2. 回复里可以带一点顺势整理感,但不要太像会议纪要
|
||||
3. 不要无视用户刚提供的高价值细节
|
||||
4. 不要让用户觉得系统在自顾自重写世界`,
|
||||
compress: `当前模式:compress
|
||||
|
||||
目标:
|
||||
1. 开始收束当前设定
|
||||
2. 减少无效发散
|
||||
3. 让 progress 更接近可进入下一阶段
|
||||
|
||||
本轮行为要求:
|
||||
1. 新的设定结构优先保留稳定内容,不要无端重写
|
||||
2. 对用户本轮输入做高密度吸收
|
||||
3. replyText 要更聚焦,不要绕圈
|
||||
4. 默认只推进当前最影响 completion 的一步
|
||||
5. 如果用户还在补细节,优先把细节挂回现有骨架,而不是继续开新分支
|
||||
6. 可以适度提醒“还差哪类关键空位”,但不要把回复写成 checklist
|
||||
7. 如果已有信息足够,replyText 可以更像“确认并收束”,少一点继续发散式追问
|
||||
|
||||
用户体验要求:
|
||||
1. 让用户感觉世界正在变得更稳,而不是越来越散
|
||||
2. 让推进感更明确,但不要显得催促
|
||||
3. 回复语气应更笃定一些,减少反复横跳
|
||||
4. 不要把用户刚补进来的细节又冲淡掉`,
|
||||
repair_direction: `当前模式:repair_direction
|
||||
|
||||
目标:
|
||||
1. 处理用户对既有设定的修正
|
||||
2. 避免世界方向飘散或自相矛盾
|
||||
|
||||
本轮行为要求:
|
||||
1. 如果用户明确改口,新的设定结构必须体现修正后的方向
|
||||
2. 对已经不再成立的旧设定,不要机械保留
|
||||
3. progressPercent 可以停滞,也可以小幅回落,但不能为负
|
||||
4. replyText 要承认用户的修正,并顺着修正后的方向继续聊
|
||||
5. 先处理“改掉什么”,再决定“往哪里继续推”
|
||||
6. 不要一边口头承认用户修正,一边在设定结构里偷偷留住旧方向
|
||||
7. 如果修正幅度很大,replyText 可以帮助用户确认新方向已经接管当前语境
|
||||
|
||||
用户体验要求:
|
||||
1. 让用户感到“我刚刚的纠偏真的生效了”
|
||||
2. 不要和用户辩论旧方案为什么也行
|
||||
3. 不要表现出对修正的不情愿
|
||||
4. 回复要体现重心已经切到新方向,而不是停留在旧世界观惯性里`,
|
||||
force_complete: `当前模式:force_complete
|
||||
|
||||
目标:
|
||||
1. 基于当前方向直接补齐剩余设定
|
||||
2. 生成一版尽量完整、可进入下一阶段的设定结构
|
||||
3. 结束当前收集阶段
|
||||
|
||||
本轮行为要求:
|
||||
1. 尽量保留已经形成的世界方向
|
||||
2. 对明显缺失的关键维度进行合理补全
|
||||
3. 不要继续拉长聊天,不要再追问用户
|
||||
4. progressPercent 直接输出为 100
|
||||
5. replyText 要自然引导用户点击“生成游戏设定草稿”
|
||||
6. 补全时要优先做“顺着已有方向补齐”,而不是突然换题材、换气质、换主冲突
|
||||
7. 可以让结果更完整,但不要补得过满、过死、过像定稿圣经
|
||||
8. replyText 更像阶段完成提示,不再像继续采集信息的对话
|
||||
|
||||
用户体验要求:
|
||||
1. 让用户感到“系统已经帮我把能补的补好了”
|
||||
2. 不要在这一步突然冒出很多陌生设定把用户吓出戏
|
||||
3. 回复要有完成感,但不要太官话
|
||||
4. 清楚告诉用户下一步可以做什么`,
|
||||
closing: `当前模式:closing
|
||||
|
||||
目标:
|
||||
1. 尽量形成一版可用的设定底子
|
||||
2. 不再继续发散新世界观
|
||||
|
||||
本轮行为要求:
|
||||
1. 优先收束,而不是扩写
|
||||
2. 不要大改已经成形的核心设定
|
||||
3. progressPercent 接近完成时,replyText 要更像确认与推进
|
||||
4. 如果用户没有大改方向,尽量让下一版内容更稳定
|
||||
5. 可以轻微补足缺口,但不要再大开新支线
|
||||
6. replyText 应减少探索式措辞,增加“已经基本成形”的稳定感
|
||||
7. 如果只差少量空位,优先把这些空位自然补平,而不是重新打开大话题
|
||||
|
||||
用户体验要求:
|
||||
1. 让用户感觉作品已经快成了,而不是还在无穷试探
|
||||
2. 回复可以更像确认和轻推,不要继续像前期那样频繁试探
|
||||
3. 保持留白感,不要把所有东西都一次说死
|
||||
4. 让用户自然过渡到下一阶段,而不是突然被切断对话`,
|
||||
};
|
||||
|
||||
const USER_SIGNAL_RULES: Record<PromptUserInputSignal, string> = {
|
||||
rich: `本轮用户输入信息密度高。
|
||||
请尽量从这一轮里提取多个锚点,不要只更新单一方向。
|
||||
如果一条输入同时影响世界方向、冲突和关系,请在新的完整设定结构中一起体现。`,
|
||||
normal: `本轮用户输入为正常补充。
|
||||
请优先顺着当前方向稳定更新,不要主动扩写太多新设定。`,
|
||||
sparse: `本轮用户输入较少或较虚。
|
||||
请保留上一版中仍然成立的内容,不要为了凑完整度而强行发明过多新设定。
|
||||
replyText 要让用户容易继续往下说。`,
|
||||
correction: `本轮用户在修正或推翻旧设定。
|
||||
请优先吸收修正,不要机械复读旧版本。
|
||||
新的完整设定结构必须以修正后的方向为准。`,
|
||||
delegate: `本轮用户把部分决定权交给你。
|
||||
你可以在 replyText 中给出有限度的建议,但不要突然补满整套设定。
|
||||
新的完整设定结构仍应尽量建立在已有世界方向上,而不是完全重做。`,
|
||||
};
|
||||
|
||||
const QUICK_FILL_EXTRA_RULES = `用户刚刚主动要求你自动补全剩余设定。
|
||||
|
||||
这表示用户接受你基于当前方向自动补完剩余设定。
|
||||
|
||||
本轮要求:
|
||||
1. 不要再继续提问
|
||||
2. 直接输出一版尽量完整的设定结构
|
||||
3. progressPercent 直接输出为 100
|
||||
4. replyText 要告诉用户现在可以进入“生成游戏设定草稿”`;
|
||||
|
||||
const STATE_INFERENCE_SYSTEM_PROMPT = `你是正式生成世界设定前的一步“创作状态识别器”。
|
||||
你的职责不是直接生成新设定,而是先判断:下一轮正式生成应该用什么推进策略,尤其要判断 replyText 应该更偏确认、吸收、收束、纠偏,还是启发式提问。
|
||||
|
||||
你必须综合以下信息判断:
|
||||
1. 当前轮次 currentTurn
|
||||
2. 当前完成度 progressPercent
|
||||
3. 用户是否要求自动补全 quickFillRequested
|
||||
4. 当前完整设定结构
|
||||
5. 最近聊天记录,尤其是最近 1 到 3 轮用户消息
|
||||
|
||||
你需要输出 4 个字段:
|
||||
1. userInputSignal:只能是 rich / normal / sparse / correction / delegate
|
||||
2. driftRisk:只能是 low / medium / high
|
||||
3. conversationMode:只能是 bootstrap / expand / compress / repair_direction / force_complete / closing
|
||||
4. judgementSummary:1 到 2 句中文,概括你为什么这样判断,以及正式生成时最该注意什么
|
||||
|
||||
请按下面的语义判断。
|
||||
|
||||
一、userInputSignal 定义
|
||||
1. rich
|
||||
- 用户这一轮给了多条可直接落地的有效信息
|
||||
- 这些信息可能同时覆盖世界方向、玩家处境、开局事件、冲突、关系、标志元素中的多个
|
||||
- 正式生成时应优先高密度吸收,不要只更新一个点
|
||||
|
||||
2. normal
|
||||
- 用户在顺着当前方向做正常补充
|
||||
- 信息量中等,有明确新增内容,但没有明显推翻旧方向,也没有把决定权交给系统
|
||||
- 正式生成时应稳定推进并自然接住用户内容
|
||||
|
||||
3. sparse
|
||||
- 用户输入很短、很虚、很笼统,或几乎没有新增有效事实
|
||||
- 例如只有一个题材词、一个气质词、一句很概括的话、一个很短的倾向表达
|
||||
- 这种情况下,正式生成阶段的 replyText 应优先采用启发式提问
|
||||
- 启发式提问的要求是:只问一个最容易回答、最能推动落地设计的问题
|
||||
|
||||
4. correction
|
||||
- 用户这轮核心动作是在修正、替换、推翻、重定向旧设定
|
||||
- 即使文字不长,只要主意图是“之前那个不对,现在改成这个”,也应优先判为 correction
|
||||
- correction 的优先级高于 rich 和 normal
|
||||
|
||||
5. delegate
|
||||
- 用户把部分决定权交给系统
|
||||
- 例如“你来定”“你帮我补”“按你觉得合理的来”“先给我一个默认方案”
|
||||
- delegate 关注的是授权关系,不只是信息多寡
|
||||
|
||||
二、driftRisk 定义
|
||||
1. low
|
||||
- 当前轮输入与已有方向基本一致
|
||||
- 没有明显改口或冲突
|
||||
|
||||
2. medium
|
||||
- 当前轮带来一定方向变化或扩张
|
||||
- 还没有明显推翻旧方向,但如果处理不好,容易让设定开始发散
|
||||
|
||||
3. high
|
||||
- 用户明确纠偏、改口、替换方向,或最近多轮反复修正
|
||||
- 这时最重要的是防止旧方向重新回流到正式生成结果里
|
||||
|
||||
三、conversationMode 选择原则
|
||||
1. bootstrap
|
||||
- 适用于前期、信息少、核心方向未稳定
|
||||
- replyText 更适合低压力确认和单点启发
|
||||
|
||||
2. expand
|
||||
- 适用于方向已成形,正在顺着现有路线继续补充
|
||||
- replyText 更适合总结已接住的内容并往前推一步
|
||||
|
||||
3. compress
|
||||
- 适用于中后段,已有骨架,需要开始收束
|
||||
- replyText 更适合聚焦最关键缺口,而不是继续开支线
|
||||
|
||||
4. repair_direction
|
||||
- 适用于用户正在纠偏
|
||||
- replyText 更适合先承认修正,再沿修正后的方向继续推进
|
||||
|
||||
5. force_complete
|
||||
- 适用于用户明确要求自动补全
|
||||
- replyText 不再提问,而应给出完成感和下一步引导
|
||||
|
||||
6. closing
|
||||
- 适用于接近完成但并非强制一键补全
|
||||
- replyText 更像确认与收束,而不是前期式探索
|
||||
|
||||
四、优先级规则
|
||||
1. 如果 quickFillRequested 为 true,conversationMode 必须优先判为 force_complete
|
||||
2. 如果用户核心意图是修正旧方向,userInputSignal 优先判为 correction,conversationMode 通常优先考虑 repair_direction
|
||||
3. 如果用户核心意图是授权系统替他补完,userInputSignal 优先判为 delegate
|
||||
4. 只有在没有明显纠偏、也没有明确自动补全要求时,才主要依据 currentTurn、progressPercent 和信息密度,在 bootstrap / expand / compress / closing 之间选择
|
||||
|
||||
五、关于 replyText 风格的专门判断要求
|
||||
1. 如果用户输入较少、较虚或不够落地,正式生成阶段的 replyText 应采用启发式提问
|
||||
2. 启发式提问一次最多只能提 1 个问题,不能连问两个或更多
|
||||
3. 启发式提问必须问“最能推动当前设计落地”的那个问题,而不是泛泛而谈
|
||||
4. 如果用户输入已经足够 rich,就不要再机械提问,优先吸收和推进
|
||||
5. 如果用户在 correction 或 delegate 状态下,replyText 是否提问要服从更高目标:纠偏生效或代为补全,不要机械套 sparse 的问法
|
||||
|
||||
六、关于 replyText 用语的硬约束
|
||||
1. replyText 禁止提及内部结构名、锚点名、字段名、schema 名、框架词
|
||||
2. 禁止出现这类内部表达:世界承诺、玩家幻想、主题边界、玩家入口、核心冲突、关键关系、隐藏线、标志元素、字段、结构、模块、八锚点
|
||||
3. replyText 只能用通俗、直接、面向创作沟通的语言回应用户
|
||||
4. replyText 应该围绕用户正在讨论的具体内容来落地,比如身份、开场处境、冲突、人物关系、地点、规则、气质,而不是抽象谈结构
|
||||
5. judgementSummary 可以简洁提到“这轮更适合启发式提问”或“这轮应优先吸收修正”,但也不要堆内部术语
|
||||
|
||||
七、关于 judgementSummary 的写法
|
||||
1. 必须简洁,不要写成长篇分析
|
||||
2. 必须直接服务于下一轮正式生成
|
||||
3. 最好同时包含两层信息:
|
||||
- 为什么这么判断
|
||||
- 正式生成时最该优先做什么,或最该避免什么
|
||||
|
||||
八、硬性约束
|
||||
1. 只能输出 JSON,不能输出解释、代码块或额外说明
|
||||
2. 不能发明上下文里不存在的设定事实
|
||||
3. 你的任务是“判断生成策略”,不是“代替正式生成直接写新设定”
|
||||
4. 即使信息不完全,也必须在给定枚举里选出最合理的一组状态
|
||||
5. judgementSummary 必须是中文
|
||||
6. 输出值必须严格落在给定枚举中`;
|
||||
|
||||
const STATE_INFERENCE_OUTPUT_CONTRACT = `请严格按以下 JSON 结构输出,不要输出其他文字:
|
||||
{
|
||||
"userInputSignal": "normal",
|
||||
"driftRisk": "low",
|
||||
"conversationMode": "expand",
|
||||
"judgementSummary": ""
|
||||
}`;
|
||||
|
||||
const OUTPUT_CONTRACT_REMINDER = `请严格按以下 JSON 结构输出,不要输出其他文字:
|
||||
{
|
||||
"replyText": "",
|
||||
"progressPercent": 0,
|
||||
"nextAnchorContent": {
|
||||
"worldPromise": {
|
||||
"hook": "",
|
||||
"differentiator": "",
|
||||
"desiredExperience": ""
|
||||
},
|
||||
"playerFantasy": {
|
||||
"playerRole": "",
|
||||
"corePursuit": "",
|
||||
"fearOfLoss": ""
|
||||
},
|
||||
"themeBoundary": {
|
||||
"toneKeywords": [],
|
||||
"aestheticDirectives": [],
|
||||
"forbiddenDirectives": []
|
||||
},
|
||||
"playerEntryPoint": {
|
||||
"openingIdentity": "",
|
||||
"openingProblem": "",
|
||||
"entryMotivation": ""
|
||||
},
|
||||
"coreConflict": {
|
||||
"surfaceConflicts": [],
|
||||
"hiddenCrisis": "",
|
||||
"firstTouchedConflict": ""
|
||||
},
|
||||
"keyRelationships": [
|
||||
{
|
||||
"pairs": "",
|
||||
"relationshipType": "",
|
||||
"secretOrCost": ""
|
||||
}
|
||||
],
|
||||
"hiddenLines": {
|
||||
"hiddenTruths": [],
|
||||
"misdirectionHints": [],
|
||||
"revealPacing": ""
|
||||
},
|
||||
"iconicElements": {
|
||||
"iconicMotifs": [],
|
||||
"institutionsOrArtifacts": [],
|
||||
"hardRules": []
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
function toJson(value: unknown) {
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function toText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function getLatestUserText(
|
||||
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>,
|
||||
) {
|
||||
return (
|
||||
[...chatHistory]
|
||||
.reverse()
|
||||
.find((entry) => entry.role === 'user' && entry.content.trim())?.content ??
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
function includesAny(text: string, patterns: RegExp[]) {
|
||||
return patterns.some((pattern) => pattern.test(text));
|
||||
}
|
||||
|
||||
function isPromptUserInputSignal(
|
||||
value: unknown,
|
||||
): value is PromptUserInputSignal {
|
||||
return (
|
||||
value === 'rich' ||
|
||||
value === 'normal' ||
|
||||
value === 'sparse' ||
|
||||
value === 'correction' ||
|
||||
value === 'delegate'
|
||||
);
|
||||
}
|
||||
|
||||
function isPromptDriftRisk(value: unknown): value is PromptDriftRisk {
|
||||
return value === 'low' || value === 'medium' || value === 'high';
|
||||
}
|
||||
|
||||
function isPromptConversationMode(
|
||||
value: unknown,
|
||||
): value is PromptConversationMode {
|
||||
return (
|
||||
value === 'bootstrap' ||
|
||||
value === 'expand' ||
|
||||
value === 'compress' ||
|
||||
value === 'repair_direction' ||
|
||||
value === 'force_complete' ||
|
||||
value === 'closing'
|
||||
);
|
||||
}
|
||||
|
||||
export function detectUserInputSignal(
|
||||
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>,
|
||||
): PromptUserInputSignal {
|
||||
const latestUserText = getLatestUserText(chatHistory).trim();
|
||||
|
||||
if (!latestUserText) {
|
||||
return 'sparse';
|
||||
}
|
||||
|
||||
if (includesAny(latestUserText, [/(不是|改成|改为|换成|重来|推翻|修正)/u])) {
|
||||
return 'correction';
|
||||
}
|
||||
|
||||
if (includesAny(latestUserText, [/(你帮我想|你来定|你决定|你补完)/u])) {
|
||||
return 'delegate';
|
||||
}
|
||||
|
||||
const segments = latestUserText
|
||||
.split(/[。!?;\n]/u)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (latestUserText.length <= 10 || segments.length <= 1) {
|
||||
return 'sparse';
|
||||
}
|
||||
|
||||
if (segments.length >= 3 || latestUserText.length >= 60) {
|
||||
return 'rich';
|
||||
}
|
||||
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
function summarizeDynamicState(
|
||||
state: Pick<
|
||||
PromptDynamicState,
|
||||
'userInputSignal' | 'driftRisk' | 'conversationMode'
|
||||
>,
|
||||
) {
|
||||
return `输入信号=${state.userInputSignal},漂移风险=${state.driftRisk},本轮模式=${state.conversationMode}。正式生成时按这组状态执行。`;
|
||||
}
|
||||
|
||||
function isThemeBoundaryFilled(value: ThemeBoundaryValue | null) {
|
||||
return Boolean(
|
||||
value &&
|
||||
(value.toneKeywords.length > 0 ||
|
||||
value.aestheticDirectives.length > 0 ||
|
||||
value.forbiddenDirectives.length > 0),
|
||||
);
|
||||
}
|
||||
|
||||
function isRelationshipsFilled(value: KeyRelationshipValue[]) {
|
||||
return value.length > 0;
|
||||
}
|
||||
|
||||
function isHiddenLinesFilled(value: HiddenLineValue | null) {
|
||||
return Boolean(
|
||||
value &&
|
||||
(value.hiddenTruths.length > 0 ||
|
||||
value.misdirectionHints.length > 0 ||
|
||||
value.revealPacing),
|
||||
);
|
||||
}
|
||||
|
||||
function isIconicElementsFilled(value: IconicElementValue | null) {
|
||||
return Boolean(
|
||||
value &&
|
||||
(value.iconicMotifs.length > 0 ||
|
||||
value.institutionsOrArtifacts.length > 0 ||
|
||||
value.hardRules.length > 0),
|
||||
);
|
||||
}
|
||||
|
||||
export function detectDriftRisk(params: {
|
||||
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
anchorContent: EightAnchorContent;
|
||||
progressPercent: number;
|
||||
}) {
|
||||
const latestUserText = getLatestUserText(params.chatHistory).trim();
|
||||
const recentUserMessages = params.chatHistory
|
||||
.filter((entry) => entry.role === 'user')
|
||||
.slice(-3)
|
||||
.map((entry) => entry.content.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const correctionCount = recentUserMessages.filter((entry) =>
|
||||
/(不是|改成|改为|换成|推翻|重来|修正)/u.test(entry),
|
||||
).length;
|
||||
|
||||
if (
|
||||
correctionCount >= 2 ||
|
||||
(params.progressPercent >= 65 &&
|
||||
/(不是|改成|改为|换成|重来|推翻)/u.test(latestUserText))
|
||||
) {
|
||||
return 'high' as const;
|
||||
}
|
||||
|
||||
const normalizedContent = normalizeEightAnchorContent(params.anchorContent);
|
||||
const filledCount = [
|
||||
Boolean(normalizedContent.worldPromise),
|
||||
Boolean(normalizedContent.playerFantasy),
|
||||
isThemeBoundaryFilled(normalizedContent.themeBoundary),
|
||||
Boolean(normalizedContent.playerEntryPoint),
|
||||
Boolean(normalizedContent.coreConflict),
|
||||
isRelationshipsFilled(normalizedContent.keyRelationships),
|
||||
isHiddenLinesFilled(normalizedContent.hiddenLines),
|
||||
isIconicElementsFilled(normalizedContent.iconicElements),
|
||||
].filter(Boolean).length;
|
||||
|
||||
if (filledCount >= 3 && latestUserText.length >= 40) {
|
||||
return 'medium' as const;
|
||||
}
|
||||
|
||||
return 'low' as const;
|
||||
}
|
||||
|
||||
export function pickConversationMode(params: {
|
||||
currentTurn: number;
|
||||
progressPercent: number;
|
||||
userInputSignal: PromptUserInputSignal;
|
||||
driftRisk: PromptDriftRisk;
|
||||
quickFillRequested: boolean;
|
||||
}) {
|
||||
if (params.quickFillRequested) {
|
||||
return 'force_complete' as const;
|
||||
}
|
||||
|
||||
if (
|
||||
params.userInputSignal === 'correction' ||
|
||||
params.driftRisk === 'high'
|
||||
) {
|
||||
return 'repair_direction' as const;
|
||||
}
|
||||
|
||||
if (params.progressPercent >= 85 || params.currentTurn >= 15) {
|
||||
return 'closing' as const;
|
||||
}
|
||||
|
||||
if (params.currentTurn > 10 || params.progressPercent >= 65) {
|
||||
return 'compress' as const;
|
||||
}
|
||||
|
||||
if (params.currentTurn <= 10 && params.progressPercent < 65) {
|
||||
return 'expand' as const;
|
||||
}
|
||||
|
||||
return 'bootstrap' as const;
|
||||
}
|
||||
|
||||
function buildRuleBasedPromptDynamicState(input: {
|
||||
currentTurn: number;
|
||||
progressPercent: number;
|
||||
quickFillRequested: boolean;
|
||||
currentAnchorContent: EightAnchorContent;
|
||||
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
}): PromptDynamicState {
|
||||
const userInputSignal = detectUserInputSignal(input.chatHistory);
|
||||
const driftRisk = detectDriftRisk({
|
||||
chatHistory: input.chatHistory,
|
||||
anchorContent: input.currentAnchorContent,
|
||||
progressPercent: input.progressPercent,
|
||||
});
|
||||
|
||||
const conversationMode = pickConversationMode({
|
||||
currentTurn: input.currentTurn,
|
||||
progressPercent: input.progressPercent,
|
||||
userInputSignal,
|
||||
driftRisk,
|
||||
quickFillRequested: input.quickFillRequested,
|
||||
});
|
||||
|
||||
return {
|
||||
currentTurn: input.currentTurn,
|
||||
progressPercent: input.progressPercent,
|
||||
userInputSignal,
|
||||
driftRisk,
|
||||
quickFillRequested: input.quickFillRequested,
|
||||
conversationMode,
|
||||
judgementSummary: summarizeDynamicState({
|
||||
userInputSignal,
|
||||
driftRisk,
|
||||
conversationMode,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPromptDynamicState(input: {
|
||||
currentTurn: number;
|
||||
progressPercent: number;
|
||||
quickFillRequested: boolean;
|
||||
currentAnchorContent: EightAnchorContent;
|
||||
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
}, inference?: PromptDynamicStateInference | null): PromptDynamicState {
|
||||
const fallbackState = buildRuleBasedPromptDynamicState(input);
|
||||
|
||||
if (!inference) {
|
||||
return fallbackState;
|
||||
}
|
||||
|
||||
const userInputSignal = isPromptUserInputSignal(inference.userInputSignal)
|
||||
? inference.userInputSignal
|
||||
: fallbackState.userInputSignal;
|
||||
const driftRisk = isPromptDriftRisk(inference.driftRisk)
|
||||
? inference.driftRisk
|
||||
: fallbackState.driftRisk;
|
||||
const conversationMode = isPromptConversationMode(inference.conversationMode)
|
||||
? inference.conversationMode
|
||||
: fallbackState.conversationMode;
|
||||
const judgementSummary =
|
||||
toText(inference.judgementSummary) ||
|
||||
summarizeDynamicState({
|
||||
userInputSignal,
|
||||
driftRisk,
|
||||
conversationMode,
|
||||
});
|
||||
|
||||
return {
|
||||
currentTurn: input.currentTurn,
|
||||
progressPercent: input.progressPercent,
|
||||
userInputSignal,
|
||||
driftRisk,
|
||||
quickFillRequested: input.quickFillRequested,
|
||||
conversationMode,
|
||||
judgementSummary,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPromptDynamicStateInferencePrompt(input: {
|
||||
currentTurn: number;
|
||||
progressPercent: number;
|
||||
quickFillRequested: boolean;
|
||||
currentAnchorContent: EightAnchorContent;
|
||||
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
}) {
|
||||
const currentAnchorContent =
|
||||
normalizeEightAnchorContent(input.currentAnchorContent) ??
|
||||
createEmptyEightAnchorContent();
|
||||
|
||||
return {
|
||||
systemPrompt: [
|
||||
STATE_INFERENCE_SYSTEM_PROMPT,
|
||||
STATE_INFERENCE_OUTPUT_CONTRACT,
|
||||
].join('\n\n'),
|
||||
userPrompt: [
|
||||
`当前轮次:${input.currentTurn}`,
|
||||
`当前完成度:${input.progressPercent}`,
|
||||
`是否要求自动补全:${input.quickFillRequested ? '是' : '否'}`,
|
||||
renderCurrentAnchorContext(currentAnchorContent),
|
||||
renderChatHistoryContext(input.chatHistory),
|
||||
].join('\n\n'),
|
||||
};
|
||||
}
|
||||
|
||||
function renderDynamicStateContext(dynamicState: PromptDynamicState) {
|
||||
return `上一轮预判得到的创作状态如下。
|
||||
正式生成时必须把它作为本轮策略输入直接执行,不要重新另起一套判断。
|
||||
|
||||
创作状态:
|
||||
- userInputSignal: ${dynamicState.userInputSignal}
|
||||
- driftRisk: ${dynamicState.driftRisk}
|
||||
- conversationMode: ${dynamicState.conversationMode}
|
||||
- judgementSummary: ${dynamicState.judgementSummary}`;
|
||||
}
|
||||
|
||||
function renderCurrentAnchorContext(anchorContent: EightAnchorContent) {
|
||||
return `当前完整设定结构如下。
|
||||
你必须把它视为上一版有效世界底子。
|
||||
|
||||
如果用户没有否定其中某部分内容,且该部分仍然成立,可以继续保留。
|
||||
如果用户明确修正了某部分内容,新的完整设定结构必须体现修正后的版本。
|
||||
|
||||
当前完整设定结构:
|
||||
${toJson(normalizeEightAnchorContent(anchorContent))}`;
|
||||
}
|
||||
|
||||
function renderChatHistoryContext(
|
||||
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>,
|
||||
) {
|
||||
return `以下是用户聊天记录。
|
||||
请重点理解最近几轮里用户新增、修正、强调的设定信息。
|
||||
不要把早期已经被用户否定的内容继续当成最终结论。
|
||||
|
||||
用户聊天记录:
|
||||
${toJson(chatHistory)}`;
|
||||
}
|
||||
|
||||
export function buildEightAnchorSingleTurnPrompt(input: {
|
||||
currentTurn: number;
|
||||
progressPercent: number;
|
||||
quickFillRequested: boolean;
|
||||
currentAnchorContent: EightAnchorContent;
|
||||
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
dynamicState?: PromptDynamicStateInference | PromptDynamicState | null;
|
||||
}) {
|
||||
const currentAnchorContent =
|
||||
normalizeEightAnchorContent(input.currentAnchorContent) ??
|
||||
createEmptyEightAnchorContent();
|
||||
const dynamicState = buildPromptDynamicState({
|
||||
...input,
|
||||
currentAnchorContent,
|
||||
}, input.dynamicState);
|
||||
|
||||
return {
|
||||
prompt: [
|
||||
BASE_SYSTEM_PROMPT,
|
||||
GLOBAL_HARD_RULES,
|
||||
MODE_RULES[dynamicState.conversationMode],
|
||||
USER_SIGNAL_RULES[dynamicState.userInputSignal],
|
||||
dynamicState.quickFillRequested ? QUICK_FILL_EXTRA_RULES : null,
|
||||
renderDynamicStateContext(dynamicState),
|
||||
renderCurrentAnchorContext(currentAnchorContent),
|
||||
renderChatHistoryContext(input.chatHistory),
|
||||
OUTPUT_CONTRACT_REMINDER,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n'),
|
||||
dynamicState,
|
||||
};
|
||||
}
|
||||
420
server-node/src/services/eightAnchorSingleTurnService.test.ts
Normal file
420
server-node/src/services/eightAnchorSingleTurnService.test.ts
Normal file
@@ -0,0 +1,420 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js';
|
||||
import { EightAnchorSingleTurnService } from './eightAnchorSingleTurnService.js';
|
||||
import type { UpstreamLlmClient } from './llmClient.js';
|
||||
|
||||
test('eight anchor single turn service updates anchors from model output', async () => {
|
||||
const service = new EightAnchorSingleTurnService(
|
||||
createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
);
|
||||
|
||||
const result = await service.runTurn({
|
||||
currentTurn: 2,
|
||||
progressPercent: 18,
|
||||
quickFillRequested: false,
|
||||
currentAnchorContent: {
|
||||
worldPromise: {
|
||||
hook: '一个被潮雾改写航线秩序的群岛世界。',
|
||||
differentiator: '',
|
||||
desiredExperience: '',
|
||||
},
|
||||
playerFantasy: null,
|
||||
themeBoundary: null,
|
||||
playerEntryPoint: null,
|
||||
coreConflict: null,
|
||||
keyRelationships: [],
|
||||
hiddenLines: null,
|
||||
iconicElements: null,
|
||||
},
|
||||
chatHistory: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '现在世界底色有了,你最想让玩家以什么身份卷进来?',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content:
|
||||
'玩家是被迫返乡的守灯人继承人,开场时刚回到港口就发现禁航区亮起了假航灯。',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.ok(result.nextAnchorContent.worldPromise?.hook);
|
||||
assert.match(
|
||||
result.nextAnchorContent.playerFantasy?.playerRole ?? '',
|
||||
/守灯人继承人/u,
|
||||
);
|
||||
assert.match(
|
||||
result.nextAnchorContent.playerEntryPoint?.openingProblem ?? '',
|
||||
/假航灯/u,
|
||||
);
|
||||
assert.ok(result.progressPercent >= 20);
|
||||
assert.ok(result.replyText.length > 0);
|
||||
});
|
||||
|
||||
test('eight anchor single turn service forces completion from model output when quick fill is requested', async () => {
|
||||
const service = new EightAnchorSingleTurnService(
|
||||
createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
);
|
||||
|
||||
const result = await service.runTurn({
|
||||
currentTurn: 6,
|
||||
progressPercent: 62,
|
||||
quickFillRequested: true,
|
||||
currentAnchorContent: {
|
||||
worldPromise: {
|
||||
hook: '一个被潮雾改写航线秩序的群岛世界。',
|
||||
differentiator: '所有人都要向旧灯塔借路。',
|
||||
desiredExperience: '压抑、悬疑',
|
||||
},
|
||||
playerFantasy: {
|
||||
playerRole: '玩家是被迫返乡的守灯人继承人。',
|
||||
corePursuit: '查清沉船夜背后的真相。',
|
||||
fearOfLoss: '',
|
||||
},
|
||||
themeBoundary: null,
|
||||
playerEntryPoint: null,
|
||||
coreConflict: null,
|
||||
keyRelationships: [],
|
||||
hiddenLines: null,
|
||||
iconicElements: null,
|
||||
},
|
||||
chatHistory: [
|
||||
{
|
||||
role: 'user',
|
||||
content: '请直接一键补全剩余设定。',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(result.progressPercent, 100);
|
||||
assert.ok(result.nextAnchorContent.coreConflict);
|
||||
assert.ok(result.nextAnchorContent.keyRelationships.length > 0);
|
||||
assert.match(result.replyText, /生成游戏设定草稿/u);
|
||||
});
|
||||
|
||||
test('eight anchor single turn service keeps the current anchors unchanged when llm is unavailable', async () => {
|
||||
const service = new EightAnchorSingleTurnService();
|
||||
const currentAnchorContent = {
|
||||
worldPromise: {
|
||||
hook: '一个被潮雾改写航线秩序的群岛世界。',
|
||||
differentiator: '所有人都要向旧灯塔借路。',
|
||||
desiredExperience: '压抑、悬疑',
|
||||
},
|
||||
playerFantasy: null,
|
||||
themeBoundary: null,
|
||||
playerEntryPoint: null,
|
||||
coreConflict: null,
|
||||
keyRelationships: [],
|
||||
hiddenLines: null,
|
||||
iconicElements: null,
|
||||
};
|
||||
|
||||
const result = await service.runTurn({
|
||||
currentTurn: 2,
|
||||
progressPercent: 24,
|
||||
quickFillRequested: false,
|
||||
currentAnchorContent,
|
||||
chatHistory: [
|
||||
{
|
||||
role: 'user',
|
||||
content: '玩家是被迫返乡的守灯人继承人。',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.nextAnchorContent, currentAnchorContent);
|
||||
assert.equal(result.progressPercent, 24);
|
||||
assert.match(result.replyText, /保留上一版/u);
|
||||
});
|
||||
|
||||
test('eight anchor single turn service runs state inference before formal generation and injects it into the next prompt', async () => {
|
||||
const inferenceCalls: Array<{
|
||||
debugLabel?: string;
|
||||
systemPrompt: string;
|
||||
userPrompt: string;
|
||||
}> = [];
|
||||
const streamCalls: Array<{
|
||||
debugLabel?: string;
|
||||
systemPrompt: string;
|
||||
userPrompt: string;
|
||||
}> = [];
|
||||
const streamedReplyUpdates: string[] = [];
|
||||
const llmClient = {
|
||||
requestMessageContent: async (params) => {
|
||||
inferenceCalls.push({
|
||||
debugLabel: params.debugLabel,
|
||||
systemPrompt: params.systemPrompt,
|
||||
userPrompt: params.userPrompt,
|
||||
});
|
||||
|
||||
if (params.debugLabel === 'custom-world-eight-anchor-state-inference') {
|
||||
return JSON.stringify({
|
||||
userInputSignal: 'correction',
|
||||
driftRisk: 'high',
|
||||
conversationMode: 'repair_direction',
|
||||
judgementSummary:
|
||||
'用户正在修正既有方向,正式生成时要优先吸收修正并避免沿用旧设定。',
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error('formal generation should use streamMessageContent');
|
||||
},
|
||||
streamMessageContent: async (params) => {
|
||||
streamCalls.push({
|
||||
debugLabel: params.debugLabel,
|
||||
systemPrompt: params.systemPrompt,
|
||||
userPrompt: params.userPrompt,
|
||||
});
|
||||
|
||||
params.onUpdate?.('{"replyText":"我先按你修正后的');
|
||||
params.onUpdate?.(
|
||||
'{"replyText":"我先按你修正后的方向收住了,现在这套悬念会更稳一些。',
|
||||
);
|
||||
|
||||
return JSON.stringify({
|
||||
nextAnchorContent: {
|
||||
worldPromise: {
|
||||
hook: '一个以旧航线骗局为核心悬念的群岛世界。',
|
||||
differentiator: '假航灯会改写整片海域的生路判断。',
|
||||
desiredExperience: '压迫、悬疑、潮湿',
|
||||
},
|
||||
playerFantasy: {
|
||||
playerRole: '玩家是返乡的守灯人继承人。',
|
||||
corePursuit: '查清旧航线骗局的源头。',
|
||||
fearOfLoss: '失去家族仅剩的航线名誉。',
|
||||
},
|
||||
themeBoundary: {
|
||||
toneKeywords: ['压迫'],
|
||||
aestheticDirectives: ['潮湿群岛'],
|
||||
forbiddenDirectives: [],
|
||||
},
|
||||
playerEntryPoint: {
|
||||
openingIdentity: '返乡继承人',
|
||||
openingProblem: '港口重新亮起假航灯',
|
||||
entryMotivation: '阻止更多船只误入禁航区',
|
||||
},
|
||||
coreConflict: {
|
||||
surfaceConflicts: ['假航灯骗局重新启动'],
|
||||
hiddenCrisis: '有人借旧航线秩序收割整座群岛',
|
||||
firstTouchedConflict: '玩家返乡当晚就撞上假航灯',
|
||||
},
|
||||
keyRelationships: [
|
||||
{
|
||||
pairs: '玩家 vs 旧港校灯人',
|
||||
relationshipType: '旧识互疑',
|
||||
secretOrCost: '对方知道家族旧案',
|
||||
},
|
||||
],
|
||||
hiddenLines: {
|
||||
hiddenTruths: ['假航灯背后藏着旧案延续'],
|
||||
misdirectionHints: ['表面像海盗所为'],
|
||||
revealPacing: '先见异常,再见旧案,再见操盘者',
|
||||
},
|
||||
iconicElements: {
|
||||
iconicMotifs: ['假航灯', '潮雾'],
|
||||
institutionsOrArtifacts: ['旧灯塔'],
|
||||
hardRules: ['错误航灯会把船引向死路'],
|
||||
},
|
||||
},
|
||||
progressPercent: 58,
|
||||
replyText: '我先按你修正后的方向收住了,现在这套悬念会更稳一些。',
|
||||
});
|
||||
},
|
||||
} as UpstreamLlmClient;
|
||||
const service = new EightAnchorSingleTurnService(llmClient);
|
||||
|
||||
const result = await service.streamTurn(
|
||||
{
|
||||
currentTurn: 4,
|
||||
progressPercent: 44,
|
||||
quickFillRequested: false,
|
||||
currentAnchorContent: {
|
||||
worldPromise: {
|
||||
hook: '一个被潮雾改写航线秩序的群岛世界。',
|
||||
differentiator: '',
|
||||
desiredExperience: '',
|
||||
},
|
||||
playerFantasy: null,
|
||||
themeBoundary: null,
|
||||
playerEntryPoint: null,
|
||||
coreConflict: null,
|
||||
keyRelationships: [],
|
||||
hiddenLines: null,
|
||||
iconicElements: null,
|
||||
},
|
||||
chatHistory: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '我们先把世界方向定住,你最想强调哪种悬念?',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: '不是海怪方向,改成旧航线骗局,假航灯才是这世界真正的危险。',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
onReplyUpdate: (text) => {
|
||||
streamedReplyUpdates.push(text);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(inferenceCalls.length, 1);
|
||||
assert.equal(streamCalls.length, 1);
|
||||
assert.equal(
|
||||
inferenceCalls[0]?.debugLabel,
|
||||
'custom-world-eight-anchor-state-inference',
|
||||
);
|
||||
assert.equal(
|
||||
streamCalls[0]?.debugLabel,
|
||||
'custom-world-eight-anchor-single-turn',
|
||||
);
|
||||
assert.match(
|
||||
streamCalls[0]?.systemPrompt ?? '',
|
||||
/userInputSignal: correction/u,
|
||||
);
|
||||
assert.match(
|
||||
streamCalls[0]?.systemPrompt ?? '',
|
||||
/conversationMode: repair_direction/u,
|
||||
);
|
||||
assert.match(
|
||||
streamCalls[0]?.systemPrompt ?? '',
|
||||
/用户正在修正既有方向/u,
|
||||
);
|
||||
assert.deepEqual(streamedReplyUpdates, [
|
||||
'我先按你修正后的',
|
||||
'我先按你修正后的方向收住了,现在这套悬念会更稳一些。',
|
||||
]);
|
||||
assert.equal(result.progressPercent, 58);
|
||||
assert.match(result.replyText, /修正后的方向/u);
|
||||
});
|
||||
|
||||
test('eight anchor single turn service falls back to rule-based state when inference fails and still completes formal generation', async () => {
|
||||
const inferenceCalls: Array<{
|
||||
debugLabel?: string;
|
||||
systemPrompt: string;
|
||||
}> = [];
|
||||
const streamCalls: Array<{
|
||||
debugLabel?: string;
|
||||
systemPrompt: string;
|
||||
userPrompt: string;
|
||||
}> = [];
|
||||
const llmClient = {
|
||||
requestMessageContent: async (params) => {
|
||||
inferenceCalls.push({
|
||||
debugLabel: params.debugLabel,
|
||||
systemPrompt: params.systemPrompt,
|
||||
});
|
||||
|
||||
throw new Error('state inference failed');
|
||||
},
|
||||
streamMessageContent: async (params) => {
|
||||
streamCalls.push({
|
||||
debugLabel: params.debugLabel,
|
||||
systemPrompt: params.systemPrompt,
|
||||
userPrompt: params.userPrompt,
|
||||
});
|
||||
|
||||
return JSON.stringify({
|
||||
nextAnchorContent: {
|
||||
worldPromise: {
|
||||
hook: '一个被潮雾改写航线秩序的群岛世界。',
|
||||
differentiator: '所有人都要向旧灯塔借路。',
|
||||
desiredExperience: '压抑、悬疑',
|
||||
},
|
||||
playerFantasy: {
|
||||
playerRole: '玩家是被迫返乡的守灯人继承人。',
|
||||
corePursuit: '查清沉船夜背后的真相。',
|
||||
fearOfLoss: '',
|
||||
},
|
||||
themeBoundary: {
|
||||
toneKeywords: ['压抑'],
|
||||
aestheticDirectives: ['潮雾群岛'],
|
||||
forbiddenDirectives: [],
|
||||
},
|
||||
playerEntryPoint: {
|
||||
openingIdentity: '返乡继承人',
|
||||
openingProblem: '港口重新亮起假航灯',
|
||||
entryMotivation: '堵住灾难扩散',
|
||||
},
|
||||
coreConflict: {
|
||||
surfaceConflicts: ['禁航区异动'],
|
||||
hiddenCrisis: '旧航线秩序正在被人篡改',
|
||||
firstTouchedConflict: '返乡第一晚就撞上假航灯',
|
||||
},
|
||||
keyRelationships: [
|
||||
{
|
||||
pairs: '玩家 vs 港区旧识',
|
||||
relationshipType: '彼此试探',
|
||||
secretOrCost: '对方知道旧沉船夜的真相碎片',
|
||||
},
|
||||
],
|
||||
hiddenLines: {
|
||||
hiddenTruths: ['旧沉船夜不是意外'],
|
||||
misdirectionHints: ['所有线索都先指向海盗'],
|
||||
revealPacing: '先异常,再旧案,再真凶',
|
||||
},
|
||||
iconicElements: {
|
||||
iconicMotifs: ['假航灯'],
|
||||
institutionsOrArtifacts: ['旧灯塔'],
|
||||
hardRules: ['错误航灯会直接改写生路判断'],
|
||||
},
|
||||
},
|
||||
progressPercent: 64,
|
||||
replyText: '我先顺着你这轮修正把设定收住了,接下来可以继续往冲突和关系上补。',
|
||||
});
|
||||
},
|
||||
} as UpstreamLlmClient;
|
||||
const service = new EightAnchorSingleTurnService(llmClient);
|
||||
|
||||
const result = await service.runTurn({
|
||||
currentTurn: 3,
|
||||
progressPercent: 40,
|
||||
quickFillRequested: false,
|
||||
currentAnchorContent: {
|
||||
worldPromise: {
|
||||
hook: '一个被潮雾改写航线秩序的群岛世界。',
|
||||
differentiator: '',
|
||||
desiredExperience: '',
|
||||
},
|
||||
playerFantasy: null,
|
||||
themeBoundary: null,
|
||||
playerEntryPoint: null,
|
||||
coreConflict: null,
|
||||
keyRelationships: [],
|
||||
hiddenLines: null,
|
||||
iconicElements: null,
|
||||
},
|
||||
chatHistory: [
|
||||
{
|
||||
role: 'user',
|
||||
content: '不是海怪,改成旧航线骗局。',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(inferenceCalls.length, 1);
|
||||
assert.equal(streamCalls.length, 1);
|
||||
assert.equal(
|
||||
inferenceCalls[0]?.debugLabel,
|
||||
'custom-world-eight-anchor-state-inference',
|
||||
);
|
||||
assert.equal(
|
||||
streamCalls[0]?.debugLabel,
|
||||
'custom-world-eight-anchor-single-turn',
|
||||
);
|
||||
assert.match(
|
||||
streamCalls[0]?.systemPrompt ?? '',
|
||||
/userInputSignal: correction/u,
|
||||
);
|
||||
assert.match(
|
||||
streamCalls[0]?.systemPrompt ?? '',
|
||||
/conversationMode: repair_direction/u,
|
||||
);
|
||||
assert.equal(result.progressPercent, 64);
|
||||
assert.match(result.replyText, /修正/u);
|
||||
});
|
||||
322
server-node/src/services/eightAnchorSingleTurnService.ts
Normal file
322
server-node/src/services/eightAnchorSingleTurnService.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
import type { EightAnchorContent } from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js';
|
||||
import {
|
||||
createEmptyEightAnchorContent,
|
||||
normalizeEightAnchorContent,
|
||||
} from './eightAnchorCompatibilityService.js';
|
||||
import {
|
||||
buildEightAnchorSingleTurnPrompt,
|
||||
buildPromptDynamicState,
|
||||
buildPromptDynamicStateInferencePrompt,
|
||||
} from './eightAnchorPromptBuilder.js';
|
||||
import type { UpstreamLlmClient } from './llmClient.js';
|
||||
|
||||
type SingleTurnChatMessage = {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type SingleTurnModelOutput = {
|
||||
nextAnchorContent: EightAnchorContent;
|
||||
progressPercent: number;
|
||||
replyText: string;
|
||||
};
|
||||
|
||||
function toText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizeOutputValue(value: unknown) {
|
||||
return normalizeEightAnchorContent(value ?? createEmptyEightAnchorContent());
|
||||
}
|
||||
|
||||
function clampProgressPercent(value: unknown) {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
|
||||
function decodeEscapedCharacter(
|
||||
value: string,
|
||||
input: string,
|
||||
index: number,
|
||||
): { decoded: string; nextIndex: number } | null {
|
||||
if (value === '"' || value === '\\' || value === '/') {
|
||||
return {
|
||||
decoded: value,
|
||||
nextIndex: index + 1,
|
||||
};
|
||||
}
|
||||
if (value === 'b') {
|
||||
return {
|
||||
decoded: '\b',
|
||||
nextIndex: index + 1,
|
||||
};
|
||||
}
|
||||
if (value === 'f') {
|
||||
return {
|
||||
decoded: '\f',
|
||||
nextIndex: index + 1,
|
||||
};
|
||||
}
|
||||
if (value === 'n') {
|
||||
return {
|
||||
decoded: '\n',
|
||||
nextIndex: index + 1,
|
||||
};
|
||||
}
|
||||
if (value === 'r') {
|
||||
return {
|
||||
decoded: '\r',
|
||||
nextIndex: index + 1,
|
||||
};
|
||||
}
|
||||
if (value === 't') {
|
||||
return {
|
||||
decoded: '\t',
|
||||
nextIndex: index + 1,
|
||||
};
|
||||
}
|
||||
if (value === 'u') {
|
||||
const hex = input.slice(index + 1, index + 5);
|
||||
if (!/^[\da-fA-F]{4}$/u.test(hex)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
decoded: String.fromCharCode(Number.parseInt(hex, 16)),
|
||||
nextIndex: index + 5,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
decoded: value,
|
||||
nextIndex: index + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function extractReplyTextFromPartialJson(text: string) {
|
||||
const keyIndex = text.indexOf('"replyText"');
|
||||
if (keyIndex < 0) {
|
||||
return {
|
||||
text: '',
|
||||
started: false,
|
||||
completed: false,
|
||||
};
|
||||
}
|
||||
|
||||
const colonIndex = text.indexOf(':', keyIndex);
|
||||
if (colonIndex < 0) {
|
||||
return {
|
||||
text: '',
|
||||
started: false,
|
||||
completed: false,
|
||||
};
|
||||
}
|
||||
|
||||
let stringStartIndex = colonIndex + 1;
|
||||
while (
|
||||
stringStartIndex < text.length &&
|
||||
/\s/u.test(text[stringStartIndex] ?? '')
|
||||
) {
|
||||
stringStartIndex += 1;
|
||||
}
|
||||
|
||||
if (text[stringStartIndex] !== '"') {
|
||||
return {
|
||||
text: '',
|
||||
started: false,
|
||||
completed: false,
|
||||
};
|
||||
}
|
||||
|
||||
let cursor = stringStartIndex + 1;
|
||||
let decoded = '';
|
||||
|
||||
while (cursor < text.length) {
|
||||
const character = text[cursor] ?? '';
|
||||
if (character === '"') {
|
||||
return {
|
||||
text: decoded,
|
||||
started: true,
|
||||
completed: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (character === '\\') {
|
||||
const escaped = decodeEscapedCharacter(
|
||||
text[cursor + 1] ?? '',
|
||||
text,
|
||||
cursor + 1,
|
||||
);
|
||||
if (!escaped) {
|
||||
break;
|
||||
}
|
||||
decoded += escaped.decoded;
|
||||
cursor = escaped.nextIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
decoded += character;
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
text: decoded,
|
||||
started: true,
|
||||
completed: false,
|
||||
};
|
||||
}
|
||||
|
||||
function buildUnavailableOutput(
|
||||
input: {
|
||||
progressPercent: number;
|
||||
currentAnchorContent: EightAnchorContent;
|
||||
},
|
||||
reason: 'unavailable' | 'failed',
|
||||
) {
|
||||
return {
|
||||
nextAnchorContent: normalizeEightAnchorContent(input.currentAnchorContent),
|
||||
progressPercent: Math.max(0, Math.min(100, Math.round(input.progressPercent))),
|
||||
replyText:
|
||||
reason === 'unavailable'
|
||||
? '当前模型不可用,这一轮设定先保留上一版。你可以稍后重试。'
|
||||
: '这一轮设定还没成功更新,我先保留上一版。你可以再发一次,我继续接着收。',
|
||||
} satisfies SingleTurnModelOutput;
|
||||
}
|
||||
|
||||
export class EightAnchorSingleTurnService {
|
||||
constructor(private readonly llmClient?: UpstreamLlmClient) {}
|
||||
|
||||
private async resolveDynamicState(input: {
|
||||
currentTurn: number;
|
||||
progressPercent: number;
|
||||
quickFillRequested: boolean;
|
||||
currentAnchorContent: EightAnchorContent;
|
||||
chatHistory: SingleTurnChatMessage[];
|
||||
}) {
|
||||
const fallbackState = buildPromptDynamicState(input);
|
||||
|
||||
if (!this.llmClient) {
|
||||
return fallbackState;
|
||||
}
|
||||
|
||||
const { systemPrompt, userPrompt } =
|
||||
buildPromptDynamicStateInferencePrompt(input);
|
||||
|
||||
try {
|
||||
const content = await this.llmClient.requestMessageContent({
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
timeoutMs: 45000,
|
||||
debugLabel: 'custom-world-eight-anchor-state-inference',
|
||||
});
|
||||
const parsed = parseJsonResponseText(content) as {
|
||||
userInputSignal?: unknown;
|
||||
driftRisk?: unknown;
|
||||
conversationMode?: unknown;
|
||||
judgementSummary?: unknown;
|
||||
};
|
||||
|
||||
return buildPromptDynamicState(input, parsed);
|
||||
} catch {
|
||||
return fallbackState;
|
||||
}
|
||||
}
|
||||
|
||||
async runTurn(input: {
|
||||
currentTurn: number;
|
||||
progressPercent: number;
|
||||
quickFillRequested: boolean;
|
||||
currentAnchorContent: EightAnchorContent;
|
||||
chatHistory: SingleTurnChatMessage[];
|
||||
}) {
|
||||
return this.streamTurn(input);
|
||||
}
|
||||
|
||||
async streamTurn(
|
||||
input: {
|
||||
currentTurn: number;
|
||||
progressPercent: number;
|
||||
quickFillRequested: boolean;
|
||||
currentAnchorContent: EightAnchorContent;
|
||||
chatHistory: SingleTurnChatMessage[];
|
||||
},
|
||||
options: {
|
||||
onReplyUpdate?: (text: string) => void;
|
||||
} = {},
|
||||
) {
|
||||
const normalizedInput = {
|
||||
...input,
|
||||
currentAnchorContent: normalizeEightAnchorContent(input.currentAnchorContent),
|
||||
chatHistory: input.chatHistory.slice(-16),
|
||||
};
|
||||
|
||||
if (!this.llmClient) {
|
||||
const unavailableOutput = buildUnavailableOutput(
|
||||
normalizedInput,
|
||||
'unavailable',
|
||||
);
|
||||
options.onReplyUpdate?.(unavailableOutput.replyText);
|
||||
return unavailableOutput;
|
||||
}
|
||||
|
||||
const dynamicState = await this.resolveDynamicState(normalizedInput);
|
||||
const { prompt } = buildEightAnchorSingleTurnPrompt({
|
||||
...normalizedInput,
|
||||
dynamicState,
|
||||
});
|
||||
let latestReplyText = '';
|
||||
|
||||
try {
|
||||
const content = await this.llmClient.streamMessageContent({
|
||||
systemPrompt: prompt,
|
||||
userPrompt: '请按约定输出这一轮的 JSON。',
|
||||
timeoutMs: 60000,
|
||||
debugLabel: 'custom-world-eight-anchor-single-turn',
|
||||
onUpdate: (partialText) => {
|
||||
const replyProgress = extractReplyTextFromPartialJson(partialText);
|
||||
if (
|
||||
replyProgress.started &&
|
||||
replyProgress.text !== latestReplyText
|
||||
) {
|
||||
latestReplyText = replyProgress.text;
|
||||
options.onReplyUpdate?.(latestReplyText);
|
||||
}
|
||||
},
|
||||
});
|
||||
const parsed = parseJsonResponseText(content) as {
|
||||
nextAnchorContent?: unknown;
|
||||
progressPercent?: unknown;
|
||||
replyText?: unknown;
|
||||
};
|
||||
const nextAnchorContent = normalizeOutputValue(parsed.nextAnchorContent);
|
||||
const progressPercent = normalizedInput.quickFillRequested
|
||||
? 100
|
||||
: clampProgressPercent(parsed.progressPercent);
|
||||
const replyText =
|
||||
toText(parsed.replyText) ||
|
||||
buildUnavailableOutput(normalizedInput, 'failed').replyText;
|
||||
if (replyText !== latestReplyText) {
|
||||
options.onReplyUpdate?.(replyText);
|
||||
}
|
||||
|
||||
return {
|
||||
nextAnchorContent,
|
||||
progressPercent,
|
||||
replyText,
|
||||
} satisfies SingleTurnModelOutput;
|
||||
} catch {
|
||||
const unavailableOutput = buildUnavailableOutput(
|
||||
normalizedInput,
|
||||
'failed',
|
||||
);
|
||||
if (unavailableOutput.replyText !== latestReplyText) {
|
||||
options.onReplyUpdate?.(unavailableOutput.replyText);
|
||||
}
|
||||
return unavailableOutput;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -309,6 +309,92 @@ export class UpstreamLlmClient {
|
||||
return content;
|
||||
}
|
||||
|
||||
async streamMessageContent(params: {
|
||||
systemPrompt: string;
|
||||
userPrompt: string;
|
||||
model?: string;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
debugLabel?: string;
|
||||
onUpdate?: (text: string) => void;
|
||||
}) {
|
||||
const response = await this.requestCompletion(
|
||||
{
|
||||
model: params.model,
|
||||
stream: true,
|
||||
messages: [
|
||||
{ role: 'system', content: params.systemPrompt },
|
||||
{ role: 'user', content: params.userPrompt },
|
||||
],
|
||||
},
|
||||
{
|
||||
signal: params.signal,
|
||||
timeoutMs: params.timeoutMs,
|
||||
debugLabel: params.debugLabel,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.body) {
|
||||
throw upstreamError('LLM 流式响应体不可用');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let accumulatedText = '';
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
while (buffer.includes('\n\n')) {
|
||||
const boundary = buffer.indexOf('\n\n');
|
||||
const eventBlock = buffer.slice(0, boundary);
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
|
||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line.startsWith('data:')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = line.slice(5).trim();
|
||||
if (!data || data === '[DONE]') {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data) as {
|
||||
choices?: Array<{
|
||||
delta?: {
|
||||
content?: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
const delta = parsed.choices?.[0]?.delta?.content;
|
||||
if (typeof delta === 'string' && delta.length > 0) {
|
||||
accumulatedText += delta;
|
||||
params.onUpdate?.(accumulatedText);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed SSE frames from the upstream model.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const content = accumulatedText.trim();
|
||||
if (!content) {
|
||||
throw upstreamError('LLM 返回内容为空');
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
async forwardCompletion(
|
||||
request: ExpressRequest,
|
||||
body: Record<string, unknown>,
|
||||
|
||||
Reference in New Issue
Block a user