This commit is contained in:
2026-04-18 13:05:29 +08:00
parent 09d4c0c31b
commit 5032701c38
77 changed files with 8538 additions and 2413 deletions

View File

@@ -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>;
};

View File

@@ -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}`));

View File

@@ -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
)`,
],
},
];

View File

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

View File

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

View File

@@ -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],
);

View File

@@ -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' }),

View File

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

View File

@@ -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',

View File

@@ -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, {

View File

@@ -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);

View File

@@ -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)!;

View File

@@ -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');

View File

@@ -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;
}

View 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;
}

View File

@@ -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,
'观测台立在湿冷高处,铁质风标、旧灯架和被潮气侵白的石阶一起构成了压迫感很强的前景。',
);
});

View File

@@ -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' ||

View File

@@ -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) ||

View 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) || '还在收集你的世界锚点。';
}

View 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. judgementSummary1 到 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 为 trueconversationMode 必须优先判为 force_complete
2. 如果用户核心意图是修正旧方向userInputSignal 优先判为 correctionconversationMode 通常优先考虑 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,
};
}

View 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);
});

View 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;
}
}
}

View File

@@ -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>,