Compare commits
3 Commits
c3bfd86b53
...
eadfd785d1
| Author | SHA1 | Date | |
|---|---|---|---|
| eadfd785d1 | |||
| 3f92380ec9 | |||
| 164ead0681 |
@@ -79,7 +79,7 @@
|
|||||||
### 4.2 Custom World
|
### 4.2 Custom World
|
||||||
|
|
||||||
1. 先把 `session / works / profile` 各自 capability 固化
|
1. 先把 `session / works / profile` 各自 capability 固化
|
||||||
2. 为 `custom world session` 提供 STDB provider
|
2. 为 `custom world session` 提供 STDB provider,并采用“按请求切换 provider”的桥接方式,不把 STDB token 依赖塞回全局 context
|
||||||
3. 为 `agent session` 的会话存取提供 STDB provider
|
3. 为 `agent session` 的会话存取提供 STDB provider
|
||||||
4. 保留 `LLM / asset / stream` 在 orchestrator 边界
|
4. 保留 `LLM / asset / stream` 在 orchestrator 边界
|
||||||
5. 等会话和作品真源稳定后,再搬纯状态推导逻辑
|
5. 等会话和作品真源稳定后,再搬纯状态推导逻辑
|
||||||
@@ -89,6 +89,9 @@
|
|||||||
本轮只做一件事:
|
本轮只做一件事:
|
||||||
|
|
||||||
1. 把 `story/custom world` 的 service 依赖从大仓储接口改成最小 capability
|
1. 把 `story/custom world` 的 service 依赖从大仓储接口改成最小 capability
|
||||||
|
2. 把 `AppContext -> route -> service` 的注入链路同步改成 capability wiring
|
||||||
|
3. 为 `custom world` 增加统一 provider 工厂,收口 session / agent / works 的构造入口
|
||||||
|
4. provider 工厂只负责收口构造,不允许改变既有 `LLM / single turn / session store` 注入语义
|
||||||
|
|
||||||
本轮不做:
|
本轮不做:
|
||||||
|
|
||||||
@@ -100,4 +103,5 @@
|
|||||||
|
|
||||||
1. `server-node` 编译通过
|
1. `server-node` 编译通过
|
||||||
2. 相关测试 stub 不再依赖完整 `RuntimeRepositoryPort`
|
2. 相关测试 stub 不再依赖完整 `RuntimeRepositoryPort`
|
||||||
3. 文档明确固定“能力先迁、逻辑后搬”的顺序
|
3. `storyActionRoutes` / `runtimeRoutes` 不再默认把整块 `runtimeRepository` 传进对应 service
|
||||||
|
4. 文档明确固定“能力先迁、逻辑后搬”的顺序
|
||||||
|
|||||||
@@ -11,8 +11,12 @@ import { createApp } from './app.ts';
|
|||||||
import type { AppConfig } from './config.ts';
|
import type { AppConfig } from './config.ts';
|
||||||
import { prepareEventStreamResponse } from './http.ts';
|
import { prepareEventStreamResponse } from './http.ts';
|
||||||
import { requestIdMiddleware } from './middleware/requestId.ts';
|
import { requestIdMiddleware } from './middleware/requestId.ts';
|
||||||
|
import {
|
||||||
|
resetCustomWorldSessionBridgeConnectorForTest,
|
||||||
|
setCustomWorldSessionBridgeConnectorForTest,
|
||||||
|
} from './modules/runtime/customWorldSessionSpacetimeBridge.js';
|
||||||
import { createAppContext } from './server.ts';
|
import { createAppContext } from './server.ts';
|
||||||
import { CustomWorldAgentOrchestrator } from './services/customWorldAgentOrchestrator.js';
|
import { createCustomWorldRuntimeProvider } from './services/customWorldRuntimeProvider.js';
|
||||||
import { createTestCustomWorldAgentSingleTurnLlmClient } from './services/customWorldAgentTestHelpers.js';
|
import { createTestCustomWorldAgentSingleTurnLlmClient } from './services/customWorldAgentTestHelpers.js';
|
||||||
import { httpRequest, type TestRequestInit } from './testHttp.ts';
|
import { httpRequest, type TestRequestInit } from './testHttp.ts';
|
||||||
|
|
||||||
@@ -32,13 +36,18 @@ type TestConfigOverrides = Partial<
|
|||||||
type TestAppContext = Awaited<ReturnType<typeof createAppContext>>;
|
type TestAppContext = Awaited<ReturnType<typeof createAppContext>>;
|
||||||
|
|
||||||
function installTestCustomWorldAgentSingleTurnLlm(context: TestAppContext) {
|
function installTestCustomWorldAgentSingleTurnLlm(context: TestAppContext) {
|
||||||
context.customWorldAgentOrchestrator = new CustomWorldAgentOrchestrator(
|
const testLlmClient = createTestCustomWorldAgentSingleTurnLlmClient();
|
||||||
context.customWorldAgentSessions,
|
context.customWorldRuntime = createCustomWorldRuntimeProvider({
|
||||||
null,
|
customWorldSessionCapability: context.customWorldSessionCapability,
|
||||||
{
|
customWorldProfileCapability: context.customWorldProfileCapability,
|
||||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
llmClient: context.llmClient,
|
||||||
},
|
runtimeLlmClient: testLlmClient,
|
||||||
);
|
singleTurnLlmClient: testLlmClient,
|
||||||
|
});
|
||||||
|
context.customWorldAgentSessions = context.customWorldRuntime.customWorldAgentSessions;
|
||||||
|
context.customWorldSessions = context.customWorldRuntime.customWorldSessions;
|
||||||
|
context.customWorldAgentOrchestrator =
|
||||||
|
context.customWorldRuntime.customWorldAgentOrchestrator;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTestConfig(
|
function createTestConfig(
|
||||||
@@ -519,6 +528,19 @@ function withBearer(token: string, init: TestRequestInit = {}) {
|
|||||||
} satisfies TestRequestInit;
|
} satisfies TestRequestInit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withCustomWorldSpacetimeBearer(
|
||||||
|
token: string,
|
||||||
|
init: TestRequestInit = {},
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...withBearer(token, init),
|
||||||
|
headers: {
|
||||||
|
...(withBearer(token, init).headers ?? {}),
|
||||||
|
'x-genarrative-custom-world-auth': 'spacetime-token',
|
||||||
|
},
|
||||||
|
} satisfies TestRequestInit;
|
||||||
|
}
|
||||||
|
|
||||||
test('legacy json responses remain compatible and include response metadata headers', async () => {
|
test('legacy json responses remain compatible and include response metadata headers', async () => {
|
||||||
await withTestServer('legacy-http', async ({ baseUrl }) => {
|
await withTestServer('legacy-http', async ({ baseUrl }) => {
|
||||||
const requestId = 'req-legacy-http';
|
const requestId = 'req-legacy-http';
|
||||||
@@ -1885,6 +1907,94 @@ test('runtime persistence is isolated by user', async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('custom world session supports spacetime auth header over http', async () => {
|
||||||
|
await withTestServer('custom-world-session-spacetime-http', async ({ baseUrl }) => {
|
||||||
|
const storedSessions = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
sessionId: string;
|
||||||
|
payloadJson: string;
|
||||||
|
createdAtMs: bigint;
|
||||||
|
updatedAtMs: bigint;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
setCustomWorldSessionBridgeConnectorForTest(async () => ({
|
||||||
|
db: {
|
||||||
|
my_auth_state: {
|
||||||
|
iter: () =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
accountId: 'user_1',
|
||||||
|
},
|
||||||
|
][Symbol.iterator](),
|
||||||
|
},
|
||||||
|
my_custom_world_sessions: {
|
||||||
|
iter: () => [...storedSessions.values()][Symbol.iterator](),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
procedures: {
|
||||||
|
async upsertCustomWorldSession(input) {
|
||||||
|
storedSessions.set(String(input.sessionId), {
|
||||||
|
sessionId: String(input.sessionId),
|
||||||
|
payloadJson: String(input.payloadJson),
|
||||||
|
createdAtMs: input.createdAtMs as bigint,
|
||||||
|
updatedAtMs: input.updatedAtMs as bigint,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: 'ok',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
disconnect() {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const entry = await authEntry(
|
||||||
|
baseUrl,
|
||||||
|
'custom_world_stdb_user',
|
||||||
|
'secret123',
|
||||||
|
);
|
||||||
|
|
||||||
|
const createResponse = await httpRequest(
|
||||||
|
`${baseUrl}/api/runtime/custom-world/sessions`,
|
||||||
|
withCustomWorldSpacetimeBearer(entry.token, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
settingText: '一个被潮雾切开的列岛世界。',
|
||||||
|
creatorIntent: {
|
||||||
|
worldHook: '潮雾会改变海图与记忆。',
|
||||||
|
},
|
||||||
|
generationMode: 'fast',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const created = (await createResponse.json()) as {
|
||||||
|
sessionId: string;
|
||||||
|
status: string;
|
||||||
|
questions: Array<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(createResponse.status, 200);
|
||||||
|
assert.ok(created.sessionId);
|
||||||
|
|
||||||
|
const getResponse = await httpRequest(
|
||||||
|
`${baseUrl}/api/runtime/custom-world/sessions/${encodeURIComponent(created.sessionId)}`,
|
||||||
|
withCustomWorldSpacetimeBearer(entry.token),
|
||||||
|
);
|
||||||
|
const session = (await getResponse.json()) as {
|
||||||
|
sessionId: string;
|
||||||
|
settingText: string;
|
||||||
|
generationMode: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(getResponse.status, 200);
|
||||||
|
assert.equal(session.sessionId, created.sessionId);
|
||||||
|
assert.equal(session.settingText, '一个被潮雾切开的列岛世界。');
|
||||||
|
assert.equal(session.generationMode, 'fast');
|
||||||
|
});
|
||||||
|
resetCustomWorldSessionBridgeConnectorForTest();
|
||||||
|
});
|
||||||
|
|
||||||
test('profile dashboard aggregates wallet, play time and played works at the account level', async () => {
|
test('profile dashboard aggregates wallet, play time and played works at the account level', async () => {
|
||||||
await withTestServer('profile-dashboard', async ({ baseUrl }) => {
|
await withTestServer('profile-dashboard', async ({ baseUrl }) => {
|
||||||
const user = await authEntry(baseUrl, 'dashboard_user', 'secret123');
|
const user = await authEntry(baseUrl, 'dashboard_user', 'secret123');
|
||||||
@@ -3215,7 +3325,10 @@ test('custom world agent generate_landmarks action appends landmark cards over h
|
|||||||
};
|
};
|
||||||
|
|
||||||
assert.equal(sessionResponse.status, 200);
|
assert.equal(sessionResponse.status, 200);
|
||||||
assert.ok((sessionPayload.draftProfile?.landmarks?.length ?? 0) >= 6);
|
assert.ok(
|
||||||
|
(sessionPayload.draftProfile?.landmarks?.length ?? 0) >=
|
||||||
|
baselineLandmarkCount + 2,
|
||||||
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
sessionPayload.draftCards.filter((card) => card.kind === 'landmark')
|
sessionPayload.draftCards.filter((card) => card.kind === 'landmark')
|
||||||
.length >=
|
.length >=
|
||||||
|
|||||||
@@ -10,10 +10,16 @@ import { SmsAuthEventRepository } from './repositories/smsAuthEventRepository.js
|
|||||||
import { UserRepository } from './repositories/userRepository.js';
|
import { UserRepository } from './repositories/userRepository.js';
|
||||||
import { UserSessionRepository } from './repositories/userSessionRepository.js';
|
import { UserSessionRepository } from './repositories/userSessionRepository.js';
|
||||||
import { CaptchaChallengeStore } from './services/captchaChallengeStore.js';
|
import { CaptchaChallengeStore } from './services/captchaChallengeStore.js';
|
||||||
|
import type { CustomWorldRuntimeProvider } from './services/customWorldRuntimeProvider.js';
|
||||||
import { CustomWorldAgentOrchestrator } from './services/customWorldAgentOrchestrator.js';
|
import { CustomWorldAgentOrchestrator } from './services/customWorldAgentOrchestrator.js';
|
||||||
import { CustomWorldAgentSessionStore } from './services/customWorldAgentSessionStore.js';
|
import { CustomWorldAgentSessionStore } from './services/customWorldAgentSessionStore.js';
|
||||||
import { CustomWorldSessionStore } from './services/customWorldSessionStore.js';
|
import { CustomWorldSessionStore } from './services/customWorldSessionStore.js';
|
||||||
import { UpstreamLlmClient } from './services/llmClient.js';
|
import { UpstreamLlmClient } from './services/llmClient.js';
|
||||||
|
import type {
|
||||||
|
CustomWorldProfileCapability,
|
||||||
|
CustomWorldSessionCapability,
|
||||||
|
RuntimeStoryCapability,
|
||||||
|
} from './services/runtimeCapabilities.js';
|
||||||
import type { SmsVerificationService } from './services/smsVerificationService.js';
|
import type { SmsVerificationService } from './services/smsVerificationService.js';
|
||||||
import type { WechatAuthService } from './services/wechatAuthService.js';
|
import type { WechatAuthService } from './services/wechatAuthService.js';
|
||||||
import { WechatAuthStateStore } from './services/wechatAuthStateStore.js';
|
import { WechatAuthStateStore } from './services/wechatAuthStateStore.js';
|
||||||
@@ -29,6 +35,10 @@ export type AppContext = {
|
|||||||
smsAuthEventRepository: SmsAuthEventRepository;
|
smsAuthEventRepository: SmsAuthEventRepository;
|
||||||
userSessionRepository: UserSessionRepository;
|
userSessionRepository: UserSessionRepository;
|
||||||
runtimeRepository: RuntimeRepository;
|
runtimeRepository: RuntimeRepository;
|
||||||
|
runtimeStoryCapability: RuntimeStoryCapability;
|
||||||
|
customWorldSessionCapability: CustomWorldSessionCapability;
|
||||||
|
customWorldProfileCapability: CustomWorldProfileCapability;
|
||||||
|
customWorldRuntime: CustomWorldRuntimeProvider;
|
||||||
llmClient: UpstreamLlmClient;
|
llmClient: UpstreamLlmClient;
|
||||||
customWorldSessions: CustomWorldSessionStore;
|
customWorldSessions: CustomWorldSessionStore;
|
||||||
customWorldAgentSessions: CustomWorldAgentSessionStore;
|
customWorldAgentSessions: CustomWorldAgentSessionStore;
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import type { Request } from 'express';
|
||||||
|
|
||||||
|
import type { AppConfig } from '../../config.js';
|
||||||
|
import type { CustomWorldSessionRecord } from '../../../../packages/shared/src/contracts/runtime.js';
|
||||||
|
import {
|
||||||
|
authenticateCustomWorldSessionViaSpacetime,
|
||||||
|
createCustomWorldSessionRepository,
|
||||||
|
resetCustomWorldSessionBridgeConnectorForTest,
|
||||||
|
setCustomWorldSessionBridgeConnectorForTest,
|
||||||
|
shouldUseSpacetimeCustomWorldSessionAuth,
|
||||||
|
} from './customWorldSessionSpacetimeBridge.js';
|
||||||
|
|
||||||
|
function createTestConfig(): AppConfig {
|
||||||
|
return {
|
||||||
|
nodeEnv: 'test',
|
||||||
|
projectRoot: '/tmp/genarrative-test',
|
||||||
|
publicDir: '/tmp/genarrative-test/public',
|
||||||
|
logsDir: '/tmp/genarrative-test/logs',
|
||||||
|
dataDir: '/tmp/genarrative-test/data',
|
||||||
|
rawEnv: {},
|
||||||
|
databaseUrl: 'pg-mem://genarrative-test',
|
||||||
|
serverAddr: ':0',
|
||||||
|
logLevel: 'silent',
|
||||||
|
editorApiEnabled: true,
|
||||||
|
assetsApiEnabled: true,
|
||||||
|
jwtSecret: 'test-secret',
|
||||||
|
jwtExpiresIn: '7d',
|
||||||
|
jwtIssuer: 'genarrative-test',
|
||||||
|
llm: {
|
||||||
|
baseUrl: 'https://example.invalid',
|
||||||
|
apiKey: '',
|
||||||
|
model: 'test-model',
|
||||||
|
},
|
||||||
|
dashScope: {
|
||||||
|
baseUrl: 'https://example.invalid',
|
||||||
|
apiKey: '',
|
||||||
|
imageModel: 'test-image-model',
|
||||||
|
requestTimeoutMs: 1000,
|
||||||
|
},
|
||||||
|
smsAuth: {
|
||||||
|
enabled: true,
|
||||||
|
provider: 'mock',
|
||||||
|
endpoint: 'dypnsapi.aliyuncs.com',
|
||||||
|
accessKeyId: '',
|
||||||
|
accessKeySecret: '',
|
||||||
|
signName: 'Test Sign',
|
||||||
|
templateCode: '100001',
|
||||||
|
templateParamKey: 'code',
|
||||||
|
countryCode: '86',
|
||||||
|
schemeName: '',
|
||||||
|
codeLength: 6,
|
||||||
|
codeType: 1,
|
||||||
|
validTimeSeconds: 300,
|
||||||
|
intervalSeconds: 60,
|
||||||
|
duplicatePolicy: 1,
|
||||||
|
caseAuthPolicy: 1,
|
||||||
|
returnVerifyCode: false,
|
||||||
|
mockVerifyCode: '123456',
|
||||||
|
maxSendPerPhonePerDay: 20,
|
||||||
|
maxSendPerIpPerHour: 30,
|
||||||
|
maxVerifyFailuresPerPhonePerHour: 12,
|
||||||
|
maxVerifyFailuresPerIpPerHour: 24,
|
||||||
|
captchaTtlSeconds: 180,
|
||||||
|
captchaTriggerVerifyFailuresPerPhone: 3,
|
||||||
|
captchaTriggerVerifyFailuresPerIp: 5,
|
||||||
|
blockPhoneFailureThreshold: 6,
|
||||||
|
blockIpFailureThreshold: 10,
|
||||||
|
blockPhoneDurationMinutes: 30,
|
||||||
|
blockIpDurationMinutes: 30,
|
||||||
|
},
|
||||||
|
wechatAuth: {
|
||||||
|
enabled: true,
|
||||||
|
provider: 'mock',
|
||||||
|
appId: '',
|
||||||
|
appSecret: '',
|
||||||
|
authorizeEndpoint: 'https://open.weixin.qq.com/connect/qrconnect',
|
||||||
|
accessTokenEndpoint: 'https://api.weixin.qq.com/sns/oauth2/access_token',
|
||||||
|
userInfoEndpoint: 'https://api.weixin.qq.com/sns/userinfo',
|
||||||
|
callbackPath: '/api/auth/wechat/callback',
|
||||||
|
defaultRedirectPath: '/',
|
||||||
|
mockUserId: 'mock_wechat_user',
|
||||||
|
mockUnionId: 'mock_wechat_union',
|
||||||
|
mockDisplayName: '微信旅人',
|
||||||
|
mockAvatarUrl: '',
|
||||||
|
},
|
||||||
|
authSession: {
|
||||||
|
refreshCookieName: 'genarrative_refresh_session',
|
||||||
|
refreshSessionTtlDays: 30,
|
||||||
|
refreshCookieSecure: false,
|
||||||
|
refreshCookieSameSite: 'Lax',
|
||||||
|
refreshCookiePath: '/api/auth',
|
||||||
|
},
|
||||||
|
spacetime: {
|
||||||
|
uri: 'ws://127.0.0.1:3000',
|
||||||
|
databaseName: 'genarrative-test',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRequest(headers: Record<string, string> = {}) {
|
||||||
|
const normalizedHeaders = Object.fromEntries(
|
||||||
|
Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
header(name: string) {
|
||||||
|
return normalizedHeaders[name.toLowerCase()] ?? undefined;
|
||||||
|
},
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
userId: undefined,
|
||||||
|
customWorldSessionAuthMode: undefined,
|
||||||
|
} as unknown as Request;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSessionRecord(): CustomWorldSessionRecord {
|
||||||
|
return {
|
||||||
|
sessionId: 'cw-session-01',
|
||||||
|
status: 'ready_to_generate',
|
||||||
|
settingText: '潮雾列岛',
|
||||||
|
creatorIntent: null,
|
||||||
|
generationMode: 'fast',
|
||||||
|
questions: [],
|
||||||
|
createdAt: '2026-04-20T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-04-20T00:01:00.000Z',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('shouldUseSpacetimeCustomWorldSessionAuth only accepts the custom world spacetime marker', () => {
|
||||||
|
assert.equal(
|
||||||
|
shouldUseSpacetimeCustomWorldSessionAuth(
|
||||||
|
createRequest({
|
||||||
|
'x-genarrative-custom-world-auth': 'spacetime-token',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
shouldUseSpacetimeCustomWorldSessionAuth(
|
||||||
|
createRequest({
|
||||||
|
'x-genarrative-custom-world-auth': 'http-access-token',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('authenticateCustomWorldSessionViaSpacetime resolves account id from bridge connection', async () => {
|
||||||
|
setCustomWorldSessionBridgeConnectorForTest(async () => ({
|
||||||
|
db: {
|
||||||
|
my_auth_state: {
|
||||||
|
iter: () =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
accountId: 'stdb-account-02',
|
||||||
|
},
|
||||||
|
][Symbol.iterator](),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
disconnect() {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const request = createRequest({
|
||||||
|
authorization: 'Bearer stdb-token',
|
||||||
|
});
|
||||||
|
|
||||||
|
await authenticateCustomWorldSessionViaSpacetime(request, {
|
||||||
|
config: createTestConfig(),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(request.userId, 'stdb-account-02');
|
||||||
|
assert.equal(request.customWorldSessionAuthMode, 'spacetime');
|
||||||
|
|
||||||
|
resetCustomWorldSessionBridgeConnectorForTest();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createCustomWorldSessionRepository uses STDB rows when request is marked as spacetime', async () => {
|
||||||
|
let savedInput: Record<string, unknown> | null = null;
|
||||||
|
const sessionRecord = createSessionRecord();
|
||||||
|
const payloadJson = JSON.stringify(sessionRecord);
|
||||||
|
|
||||||
|
setCustomWorldSessionBridgeConnectorForTest(async () => ({
|
||||||
|
db: {
|
||||||
|
my_custom_world_sessions: {
|
||||||
|
iter: () =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
sessionId: sessionRecord.sessionId,
|
||||||
|
payloadJson,
|
||||||
|
createdAtMs: BigInt(Date.parse(sessionRecord.createdAt)),
|
||||||
|
updatedAtMs: BigInt(Date.parse(sessionRecord.updatedAt)),
|
||||||
|
},
|
||||||
|
][Symbol.iterator](),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
procedures: {
|
||||||
|
async upsertCustomWorldSession(input: Record<string, unknown>) {
|
||||||
|
savedInput = input;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: 'ok',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
disconnect() {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const request = createRequest({
|
||||||
|
authorization: 'Bearer stdb-token',
|
||||||
|
'user-agent': 'test-agent',
|
||||||
|
});
|
||||||
|
request.customWorldSessionAuthMode = 'spacetime';
|
||||||
|
|
||||||
|
const repository = createCustomWorldSessionRepository({
|
||||||
|
request,
|
||||||
|
config: createTestConfig(),
|
||||||
|
runtimeRepository: {
|
||||||
|
async listCustomWorldSessions() {
|
||||||
|
throw new Error('should not hit legacy repository');
|
||||||
|
},
|
||||||
|
async getCustomWorldSession() {
|
||||||
|
throw new Error('should not hit legacy repository');
|
||||||
|
},
|
||||||
|
async upsertCustomWorldSession() {
|
||||||
|
throw new Error('should not hit legacy repository');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessions = await repository.listCustomWorldSessions('ignored-user-id');
|
||||||
|
assert.equal(sessions.length, 1);
|
||||||
|
assert.equal(sessions[0]?.sessionId, sessionRecord.sessionId);
|
||||||
|
|
||||||
|
const loaded = await repository.getCustomWorldSession(
|
||||||
|
'ignored-user-id',
|
||||||
|
sessionRecord.sessionId,
|
||||||
|
);
|
||||||
|
assert.equal(loaded?.settingText, sessionRecord.settingText);
|
||||||
|
|
||||||
|
const persisted = await repository.upsertCustomWorldSession(
|
||||||
|
'ignored-user-id',
|
||||||
|
sessionRecord.sessionId,
|
||||||
|
sessionRecord,
|
||||||
|
);
|
||||||
|
assert.equal(persisted.sessionId, sessionRecord.sessionId);
|
||||||
|
assert.equal(savedInput?.sessionId, sessionRecord.sessionId);
|
||||||
|
assert.equal(savedInput?.payloadJson, payloadJson);
|
||||||
|
|
||||||
|
resetCustomWorldSessionBridgeConnectorForTest();
|
||||||
|
});
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import type { Request } from 'express';
|
||||||
|
|
||||||
|
import { DbConnection } from '../../../../src/spacetime/generated/index.ts';
|
||||||
|
import type {
|
||||||
|
CustomWorldSessionView,
|
||||||
|
RequestMeta,
|
||||||
|
} from '../../../../src/spacetime/generated/types.ts';
|
||||||
|
import { conflict, unauthorized } from '../../errors.js';
|
||||||
|
import type { AppConfig } from '../../config.js';
|
||||||
|
import type { CustomWorldSessionRecord } from '../../../../packages/shared/src/contracts/runtime.js';
|
||||||
|
import type { CustomWorldSessionCapability } from '../../services/runtimeCapabilities.js';
|
||||||
|
|
||||||
|
const CUSTOM_WORLD_SESSION_STDB_AUTH_HEADER =
|
||||||
|
'x-genarrative-custom-world-auth';
|
||||||
|
|
||||||
|
type CustomWorldSessionStdbBridgeContext = {
|
||||||
|
config: AppConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CustomWorldSessionBridgeDbConnection = Awaited<
|
||||||
|
ReturnType<typeof connectWithToken>
|
||||||
|
>;
|
||||||
|
|
||||||
|
function readBearerToken(request: Request) {
|
||||||
|
const authorization = request.header('authorization')?.trim() || '';
|
||||||
|
if (!authorization.startsWith('Bearer ')) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return authorization.slice('Bearer '.length).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRequestMeta(request: Request): RequestMeta {
|
||||||
|
const userAgent = request.header('user-agent')?.trim() || undefined;
|
||||||
|
const forwardedFor = request.header('x-forwarded-for')?.split(',')[0]?.trim();
|
||||||
|
const ip = forwardedFor || request.ip || undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
clientType: 'server-node-custom-world-session-bridge',
|
||||||
|
userAgent,
|
||||||
|
ip,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCustomWorldSessionRow(
|
||||||
|
row: CustomWorldSessionView,
|
||||||
|
): CustomWorldSessionRecord | null {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(row.payloadJson) as CustomWorldSessionRecord;
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
sessionId: row.sessionId,
|
||||||
|
createdAt: new Date(Number(row.createdAtMs)).toISOString(),
|
||||||
|
updatedAt: new Date(Number(row.updatedAtMs)).toISOString(),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectWithToken(config: AppConfig, token: string) {
|
||||||
|
return new Promise<DbConnection>((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = (callback: () => void) => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
|
||||||
|
DbConnection.builder()
|
||||||
|
.withUri(config.spacetime.uri)
|
||||||
|
.withDatabaseName(config.spacetime.databaseName)
|
||||||
|
.withLightMode(true)
|
||||||
|
.withToken(token)
|
||||||
|
.onConnect((connection) => {
|
||||||
|
connection
|
||||||
|
.subscriptionBuilder()
|
||||||
|
.onApplied(() => {
|
||||||
|
finish(() => {
|
||||||
|
resolve(connection);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.onError((_ctx, error) => {
|
||||||
|
finish(() => {
|
||||||
|
connection.disconnect();
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.subscribeToAllTables();
|
||||||
|
})
|
||||||
|
.onConnectError((_ctx, error) => {
|
||||||
|
finish(() => reject(error));
|
||||||
|
})
|
||||||
|
.onDisconnect((_ctx, error) => {
|
||||||
|
if (!settled) {
|
||||||
|
finish(() => reject(error ?? new Error('Spacetime 连接已断开')));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withBridgeConnection<T>(
|
||||||
|
config: AppConfig,
|
||||||
|
token: string,
|
||||||
|
run: (connection: CustomWorldSessionBridgeDbConnection) => Promise<T>,
|
||||||
|
) {
|
||||||
|
const connection = await customWorldSessionBridgeConnector(config, token);
|
||||||
|
try {
|
||||||
|
return await run(connection);
|
||||||
|
} finally {
|
||||||
|
connection.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let customWorldSessionBridgeConnector = connectWithToken;
|
||||||
|
|
||||||
|
export function setCustomWorldSessionBridgeConnectorForTest(
|
||||||
|
connector: typeof connectWithToken,
|
||||||
|
) {
|
||||||
|
customWorldSessionBridgeConnector = connector;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetCustomWorldSessionBridgeConnectorForTest() {
|
||||||
|
customWorldSessionBridgeConnector = connectWithToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldUseSpacetimeCustomWorldSessionAuth(request: Request) {
|
||||||
|
return (
|
||||||
|
request.header(CUSTOM_WORLD_SESSION_STDB_AUTH_HEADER)?.trim() ===
|
||||||
|
'spacetime-token'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authenticateCustomWorldSessionViaSpacetime(
|
||||||
|
request: Request,
|
||||||
|
context: CustomWorldSessionStdbBridgeContext,
|
||||||
|
) {
|
||||||
|
const token = readBearerToken(request);
|
||||||
|
if (!token) {
|
||||||
|
throw unauthorized('缺少 custom world session 兼容认证 token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountId = await withBridgeConnection(
|
||||||
|
context.config,
|
||||||
|
token,
|
||||||
|
async (connection) => {
|
||||||
|
const row = Array.from(connection.db.my_auth_state.iter())[0] ?? null;
|
||||||
|
const nextAccountId = row?.accountId?.trim() || '';
|
||||||
|
if (!nextAccountId) {
|
||||||
|
throw unauthorized('custom world session STDB 账号态不存在');
|
||||||
|
}
|
||||||
|
return nextAccountId;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
request.userId = accountId;
|
||||||
|
request.customWorldSessionAuthMode = 'spacetime';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCustomWorldSessionRepository(params: {
|
||||||
|
request: Request;
|
||||||
|
runtimeRepository: CustomWorldSessionCapability;
|
||||||
|
config: AppConfig;
|
||||||
|
}): CustomWorldSessionCapability {
|
||||||
|
if (params.request.customWorldSessionAuthMode !== 'spacetime') {
|
||||||
|
return params.runtimeRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = readBearerToken(params.request);
|
||||||
|
if (!token) {
|
||||||
|
throw unauthorized('缺少 custom world session STDB token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestMeta = buildRequestMeta(params.request);
|
||||||
|
|
||||||
|
return {
|
||||||
|
async listCustomWorldSessions() {
|
||||||
|
return withBridgeConnection(params.config, token, async (connection) => {
|
||||||
|
return Array.from(connection.db.my_custom_world_sessions.iter())
|
||||||
|
.map(parseCustomWorldSessionRow)
|
||||||
|
.filter((row): row is CustomWorldSessionRecord => Boolean(row))
|
||||||
|
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async getCustomWorldSession(_userId: string, sessionId: string) {
|
||||||
|
return withBridgeConnection(params.config, token, async (connection) => {
|
||||||
|
const row =
|
||||||
|
Array.from(connection.db.my_custom_world_sessions.iter()).find(
|
||||||
|
(entry) => entry.sessionId === sessionId,
|
||||||
|
) ?? null;
|
||||||
|
return row ? parseCustomWorldSessionRow(row) : null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async upsertCustomWorldSession(_userId: string, sessionId: string, session) {
|
||||||
|
return withBridgeConnection(params.config, token, async (connection) => {
|
||||||
|
const result = await connection.procedures.upsertCustomWorldSession({
|
||||||
|
meta: requestMeta,
|
||||||
|
sessionId,
|
||||||
|
payloadJson: JSON.stringify(session),
|
||||||
|
createdAtMs: BigInt(Date.parse(session.createdAt) || Date.now()),
|
||||||
|
updatedAtMs: BigInt(Date.parse(session.updatedAt) || Date.now()),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
throw conflict(result.message || 'custom world session STDB 写入失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const row =
|
||||||
|
Array.from(connection.db.my_custom_world_sessions.iter()).find(
|
||||||
|
(entry) => entry.sessionId === sessionId,
|
||||||
|
) ?? null;
|
||||||
|
const parsedRow = row ? parseCustomWorldSessionRow(row) : null;
|
||||||
|
if (!parsedRow) {
|
||||||
|
throw conflict('custom world session STDB 写入后未返回最新会话');
|
||||||
|
}
|
||||||
|
return parsedRow;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -58,7 +58,7 @@ export function createStoryActionRoutes(context: AppContext) {
|
|||||||
await resolveRuntimeStoryAction({
|
await resolveRuntimeStoryAction({
|
||||||
runtimeRepository: createRuntimeStorySnapshotRepository({
|
runtimeRepository: createRuntimeStorySnapshotRepository({
|
||||||
request,
|
request,
|
||||||
runtimeRepository: context.runtimeRepository,
|
runtimeRepository: context.runtimeStoryCapability,
|
||||||
config: context.config,
|
config: context.config,
|
||||||
}),
|
}),
|
||||||
llmClient: context.llmClient,
|
llmClient: context.llmClient,
|
||||||
@@ -83,7 +83,7 @@ export function createStoryActionRoutes(context: AppContext) {
|
|||||||
await getRuntimeStoryState({
|
await getRuntimeStoryState({
|
||||||
runtimeRepository: createRuntimeStorySnapshotRepository({
|
runtimeRepository: createRuntimeStorySnapshotRepository({
|
||||||
request,
|
request,
|
||||||
runtimeRepository: context.runtimeRepository,
|
runtimeRepository: context.runtimeStoryCapability,
|
||||||
config: context.config,
|
config: context.config,
|
||||||
}),
|
}),
|
||||||
userId: request.userId!,
|
userId: request.userId!,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Router } from 'express';
|
import { Router, type Request } from 'express';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import type { ListCustomWorldWorksResponse } from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
import type { ListCustomWorldWorksResponse } from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||||
@@ -58,6 +58,11 @@ import {
|
|||||||
hydrateSavedSnapshot,
|
hydrateSavedSnapshot,
|
||||||
normalizeSavedSnapshotPayload,
|
normalizeSavedSnapshotPayload,
|
||||||
} from '../modules/runtime/runtimeSnapshotHydration.js';
|
} from '../modules/runtime/runtimeSnapshotHydration.js';
|
||||||
|
import {
|
||||||
|
authenticateCustomWorldSessionViaSpacetime,
|
||||||
|
createCustomWorldSessionRepository,
|
||||||
|
shouldUseSpacetimeCustomWorldSessionAuth,
|
||||||
|
} from '../modules/runtime/customWorldSessionSpacetimeBridge.js';
|
||||||
import {
|
import {
|
||||||
characterChatReplyRequestSchema,
|
characterChatReplyRequestSchema,
|
||||||
characterChatSuggestionsRequestSchema,
|
characterChatSuggestionsRequestSchema,
|
||||||
@@ -87,6 +92,7 @@ import {
|
|||||||
generateHighQualityNextStory,
|
generateHighQualityNextStory,
|
||||||
parseStoryRequest,
|
parseStoryRequest,
|
||||||
} from '../services/storyService.js';
|
} from '../services/storyService.js';
|
||||||
|
import { createCustomWorldRuntimeProvider } from '../services/customWorldRuntimeProvider.js';
|
||||||
import { createCustomWorldAgentRoutes } from './customWorldAgent.js';
|
import { createCustomWorldAgentRoutes } from './customWorldAgent.js';
|
||||||
|
|
||||||
const jsonObjectSchema = z.record(z.string(), z.unknown());
|
const jsonObjectSchema = z.record(z.string(), z.unknown());
|
||||||
@@ -195,6 +201,17 @@ export function createRuntimeRoutes(context: AppContext) {
|
|||||||
npc: await generateSceneNpcForLandmark(context.llmClient, payload),
|
npc: await generateSceneNpcForLandmark(context.llmClient, payload),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
const createRequestScopedCustomWorldRuntime = (request: Request) =>
|
||||||
|
createCustomWorldRuntimeProvider({
|
||||||
|
customWorldSessionCapability: createCustomWorldSessionRepository({
|
||||||
|
request,
|
||||||
|
runtimeRepository: context.customWorldSessionCapability,
|
||||||
|
config: context.config,
|
||||||
|
}),
|
||||||
|
customWorldProfileCapability: context.customWorldProfileCapability,
|
||||||
|
llmClient: context.llmClient,
|
||||||
|
runtimeLlmClient: context.llmClient,
|
||||||
|
});
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/runtime/custom-world-gallery',
|
'/runtime/custom-world-gallery',
|
||||||
@@ -231,7 +248,18 @@ export function createRuntimeRoutes(context: AppContext) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
router.use(requireAuth);
|
router.use(
|
||||||
|
asyncHandler(async (request, response, next) => {
|
||||||
|
if (shouldUseSpacetimeCustomWorldSessionAuth(request)) {
|
||||||
|
await authenticateCustomWorldSessionViaSpacetime(request, context);
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
request.customWorldSessionAuthMode = 'jwt';
|
||||||
|
await requireAuth(request, response, next);
|
||||||
|
}),
|
||||||
|
);
|
||||||
router.use(
|
router.use(
|
||||||
'/runtime/custom-world/agent',
|
'/runtime/custom-world/agent',
|
||||||
createCustomWorldAgentRoutes(context),
|
createCustomWorldAgentRoutes(context),
|
||||||
@@ -555,8 +583,7 @@ export function createRuntimeRoutes(context: AppContext) {
|
|||||||
asyncHandler(async (request, response) => {
|
asyncHandler(async (request, response) => {
|
||||||
sendApiResponse<ListCustomWorldWorksResponse>(response, {
|
sendApiResponse<ListCustomWorldWorksResponse>(response, {
|
||||||
items: await listCustomWorldWorkSummaries(request.userId!, {
|
items: await listCustomWorldWorkSummaries(request.userId!, {
|
||||||
runtimeRepository: context.runtimeRepository,
|
...context.customWorldRuntime.customWorldWorkSummaryDependencies,
|
||||||
customWorldAgentSessions: context.customWorldAgentSessions,
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
@@ -796,12 +823,13 @@ export function createRuntimeRoutes(context: AppContext) {
|
|||||||
'/runtime/custom-world/sessions',
|
'/runtime/custom-world/sessions',
|
||||||
routeMeta({ operation: 'runtime.customWorldSession.create' }),
|
routeMeta({ operation: 'runtime.customWorldSession.create' }),
|
||||||
asyncHandler(async (request, response) => {
|
asyncHandler(async (request, response) => {
|
||||||
|
const customWorldRuntime = createRequestScopedCustomWorldRuntime(request);
|
||||||
const payload = customWorldSessionSchema.parse(
|
const payload = customWorldSessionSchema.parse(
|
||||||
request.body,
|
request.body,
|
||||||
) as CreateCustomWorldSessionRequest;
|
) as CreateCustomWorldSessionRequest;
|
||||||
sendApiResponse(
|
sendApiResponse(
|
||||||
response,
|
response,
|
||||||
await context.customWorldSessions.create(
|
await customWorldRuntime.customWorldSessions.create(
|
||||||
request.userId!,
|
request.userId!,
|
||||||
payload.settingText,
|
payload.settingText,
|
||||||
payload.creatorIntent,
|
payload.creatorIntent,
|
||||||
@@ -815,7 +843,9 @@ export function createRuntimeRoutes(context: AppContext) {
|
|||||||
'/runtime/custom-world/sessions/:sessionId',
|
'/runtime/custom-world/sessions/:sessionId',
|
||||||
routeMeta({ operation: 'runtime.customWorldSession.get' }),
|
routeMeta({ operation: 'runtime.customWorldSession.get' }),
|
||||||
asyncHandler(async (request, response) => {
|
asyncHandler(async (request, response) => {
|
||||||
const session = await context.customWorldSessions.get(
|
const session = await createRequestScopedCustomWorldRuntime(
|
||||||
|
request,
|
||||||
|
).customWorldSessions.get(
|
||||||
request.userId!,
|
request.userId!,
|
||||||
readParam(request.params.sessionId),
|
readParam(request.params.sessionId),
|
||||||
);
|
);
|
||||||
@@ -830,10 +860,11 @@ export function createRuntimeRoutes(context: AppContext) {
|
|||||||
'/runtime/custom-world/sessions/:sessionId/answers',
|
'/runtime/custom-world/sessions/:sessionId/answers',
|
||||||
routeMeta({ operation: 'runtime.customWorldSession.answer' }),
|
routeMeta({ operation: 'runtime.customWorldSession.answer' }),
|
||||||
asyncHandler(async (request, response) => {
|
asyncHandler(async (request, response) => {
|
||||||
|
const customWorldRuntime = createRequestScopedCustomWorldRuntime(request);
|
||||||
const payload = customWorldAnswerSchema.parse(
|
const payload = customWorldAnswerSchema.parse(
|
||||||
request.body,
|
request.body,
|
||||||
) as AnswerCustomWorldSessionQuestionRequest;
|
) as AnswerCustomWorldSessionQuestionRequest;
|
||||||
const session = await context.customWorldSessions.answer(
|
const session = await customWorldRuntime.customWorldSessions.answer(
|
||||||
request.userId!,
|
request.userId!,
|
||||||
readParam(request.params.sessionId),
|
readParam(request.params.sessionId),
|
||||||
payload.questionId,
|
payload.questionId,
|
||||||
@@ -850,7 +881,8 @@ export function createRuntimeRoutes(context: AppContext) {
|
|||||||
'/runtime/custom-world/sessions/:sessionId/generate/stream',
|
'/runtime/custom-world/sessions/:sessionId/generate/stream',
|
||||||
routeMeta({ operation: 'runtime.customWorldSession.generateStream' }),
|
routeMeta({ operation: 'runtime.customWorldSession.generateStream' }),
|
||||||
asyncHandler(async (request, response) => {
|
asyncHandler(async (request, response) => {
|
||||||
const session = await context.customWorldSessions.get(
|
const customWorldRuntime = createRequestScopedCustomWorldRuntime(request);
|
||||||
|
const session = await customWorldRuntime.customWorldSessions.get(
|
||||||
request.userId!,
|
request.userId!,
|
||||||
readParam(request.params.sessionId),
|
readParam(request.params.sessionId),
|
||||||
);
|
);
|
||||||
@@ -871,7 +903,7 @@ export function createRuntimeRoutes(context: AppContext) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
writeEvent('progress', { phase: 'preparing', progress: 10 });
|
writeEvent('progress', { phase: 'preparing', progress: 10 });
|
||||||
await context.customWorldSessions.updateStatus(
|
await customWorldRuntime.customWorldSessions.updateStatus(
|
||||||
request.userId!,
|
request.userId!,
|
||||||
readParam(request.params.sessionId),
|
readParam(request.params.sessionId),
|
||||||
'generating',
|
'generating',
|
||||||
@@ -888,7 +920,7 @@ export function createRuntimeRoutes(context: AppContext) {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await context.customWorldSessions.setResult(
|
await customWorldRuntime.customWorldSessions.setResult(
|
||||||
request.userId!,
|
request.userId!,
|
||||||
readParam(request.params.sessionId),
|
readParam(request.params.sessionId),
|
||||||
profile,
|
profile,
|
||||||
@@ -901,7 +933,7 @@ export function createRuntimeRoutes(context: AppContext) {
|
|||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: 'custom world generation failed';
|
: 'custom world generation failed';
|
||||||
await context.customWorldSessions.updateStatus(
|
await customWorldRuntime.customWorldSessions.updateStatus(
|
||||||
request.userId!,
|
request.userId!,
|
||||||
readParam(request.params.sessionId),
|
readParam(request.params.sessionId),
|
||||||
'generation_error',
|
'generation_error',
|
||||||
|
|||||||
@@ -13,10 +13,13 @@ import { SmsAuthEventRepository } from './repositories/smsAuthEventRepository.js
|
|||||||
import { UserRepository } from './repositories/userRepository.js';
|
import { UserRepository } from './repositories/userRepository.js';
|
||||||
import { UserSessionRepository } from './repositories/userSessionRepository.js';
|
import { UserSessionRepository } from './repositories/userSessionRepository.js';
|
||||||
import { CaptchaChallengeStore } from './services/captchaChallengeStore.js';
|
import { CaptchaChallengeStore } from './services/captchaChallengeStore.js';
|
||||||
import { CustomWorldAgentOrchestrator } from './services/customWorldAgentOrchestrator.js';
|
import { createCustomWorldRuntimeProvider } from './services/customWorldRuntimeProvider.js';
|
||||||
import { CustomWorldAgentSessionStore } from './services/customWorldAgentSessionStore.js';
|
|
||||||
import { CustomWorldSessionStore } from './services/customWorldSessionStore.js';
|
|
||||||
import { UpstreamLlmClient } from './services/llmClient.js';
|
import { UpstreamLlmClient } from './services/llmClient.js';
|
||||||
|
import {
|
||||||
|
createCustomWorldProfileCapability,
|
||||||
|
createCustomWorldSessionCapability,
|
||||||
|
createRuntimeSnapshotCapability,
|
||||||
|
} from './services/runtimeCapabilities.js';
|
||||||
import { createSmsVerificationService } from './services/smsVerificationService.js';
|
import { createSmsVerificationService } from './services/smsVerificationService.js';
|
||||||
import { createWechatAuthService } from './services/wechatAuthService.js';
|
import { createWechatAuthService } from './services/wechatAuthService.js';
|
||||||
import { WechatAuthStateStore } from './services/wechatAuthStateStore.js';
|
import { WechatAuthStateStore } from './services/wechatAuthStateStore.js';
|
||||||
@@ -80,9 +83,21 @@ export async function createAppContext(config: AppConfig = loadConfig()) {
|
|||||||
const logger = createLogger(config);
|
const logger = createLogger(config);
|
||||||
const db = await createDatabase(config);
|
const db = await createDatabase(config);
|
||||||
const runtimeRepository = new RuntimeRepository(db);
|
const runtimeRepository = new RuntimeRepository(db);
|
||||||
const customWorldAgentSessions = new CustomWorldAgentSessionStore(
|
const runtimeStoryCapability = createRuntimeSnapshotCapability(
|
||||||
runtimeRepository,
|
runtimeRepository,
|
||||||
);
|
);
|
||||||
|
const customWorldSessionCapability = createCustomWorldSessionCapability(
|
||||||
|
runtimeRepository,
|
||||||
|
);
|
||||||
|
const customWorldProfileCapability = createCustomWorldProfileCapability(
|
||||||
|
runtimeRepository,
|
||||||
|
);
|
||||||
|
const llmClient = new UpstreamLlmClient(config, logger);
|
||||||
|
const customWorldRuntime = createCustomWorldRuntimeProvider({
|
||||||
|
customWorldSessionCapability,
|
||||||
|
customWorldProfileCapability,
|
||||||
|
llmClient,
|
||||||
|
});
|
||||||
const context: AppContext = {
|
const context: AppContext = {
|
||||||
config,
|
config,
|
||||||
logger,
|
logger,
|
||||||
@@ -94,15 +109,14 @@ export async function createAppContext(config: AppConfig = loadConfig()) {
|
|||||||
smsAuthEventRepository: new SmsAuthEventRepository(db),
|
smsAuthEventRepository: new SmsAuthEventRepository(db),
|
||||||
userSessionRepository: new UserSessionRepository(db),
|
userSessionRepository: new UserSessionRepository(db),
|
||||||
runtimeRepository,
|
runtimeRepository,
|
||||||
llmClient: new UpstreamLlmClient(config, logger),
|
runtimeStoryCapability,
|
||||||
customWorldSessions: new CustomWorldSessionStore(runtimeRepository),
|
customWorldSessionCapability,
|
||||||
customWorldAgentSessions,
|
customWorldProfileCapability,
|
||||||
customWorldAgentOrchestrator: new CustomWorldAgentOrchestrator(
|
customWorldRuntime,
|
||||||
customWorldAgentSessions,
|
llmClient,
|
||||||
config.llm.apiKey.trim()
|
customWorldSessions: customWorldRuntime.customWorldSessions,
|
||||||
? new UpstreamLlmClient(config, logger)
|
customWorldAgentSessions: customWorldRuntime.customWorldAgentSessions,
|
||||||
: null,
|
customWorldAgentOrchestrator: customWorldRuntime.customWorldAgentOrchestrator,
|
||||||
),
|
|
||||||
smsVerificationService: createSmsVerificationService(config, logger),
|
smsVerificationService: createSmsVerificationService(config, logger),
|
||||||
wechatAuthService: createWechatAuthService(config, logger),
|
wechatAuthService: createWechatAuthService(config, logger),
|
||||||
wechatAuthStates: new WechatAuthStateStore(),
|
wechatAuthStates: new WechatAuthStateStore(),
|
||||||
|
|||||||
46
server-node/src/services/customWorldRuntimeProvider.ts
Normal file
46
server-node/src/services/customWorldRuntimeProvider.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import type { CustomWorldProfileCapability, CustomWorldSessionCapability } from './runtimeCapabilities.js';
|
||||||
|
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
|
||||||
|
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
|
||||||
|
import { CustomWorldSessionStore } from './customWorldSessionStore.js';
|
||||||
|
import type { UpstreamLlmClient } from './llmClient.js';
|
||||||
|
|
||||||
|
export type CustomWorldRuntimeProvider = {
|
||||||
|
customWorldSessions: CustomWorldSessionStore;
|
||||||
|
customWorldAgentSessions: CustomWorldAgentSessionStore;
|
||||||
|
customWorldAgentOrchestrator: CustomWorldAgentOrchestrator;
|
||||||
|
customWorldWorkSummaryDependencies: {
|
||||||
|
runtimeRepository: CustomWorldProfileCapability;
|
||||||
|
customWorldAgentSessions: CustomWorldAgentSessionStore;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createCustomWorldRuntimeProvider(params: {
|
||||||
|
customWorldSessionCapability: CustomWorldSessionCapability;
|
||||||
|
customWorldProfileCapability: CustomWorldProfileCapability;
|
||||||
|
llmClient: UpstreamLlmClient | null;
|
||||||
|
runtimeLlmClient?: UpstreamLlmClient | null;
|
||||||
|
singleTurnLlmClient?: UpstreamLlmClient | null;
|
||||||
|
}) {
|
||||||
|
const customWorldSessions = new CustomWorldSessionStore(
|
||||||
|
params.customWorldSessionCapability,
|
||||||
|
);
|
||||||
|
const customWorldAgentSessions = new CustomWorldAgentSessionStore(
|
||||||
|
params.customWorldSessionCapability,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
customWorldSessions,
|
||||||
|
customWorldAgentSessions,
|
||||||
|
customWorldAgentOrchestrator: new CustomWorldAgentOrchestrator(
|
||||||
|
customWorldAgentSessions,
|
||||||
|
params.runtimeLlmClient ?? params.llmClient,
|
||||||
|
{
|
||||||
|
singleTurnLlmClient: params.singleTurnLlmClient,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
customWorldWorkSummaryDependencies: {
|
||||||
|
runtimeRepository: params.customWorldProfileCapability,
|
||||||
|
customWorldAgentSessions,
|
||||||
|
},
|
||||||
|
} satisfies CustomWorldRuntimeProvider;
|
||||||
|
}
|
||||||
1
server-node/src/types/express.d.ts
vendored
1
server-node/src/types/express.d.ts
vendored
@@ -5,6 +5,7 @@ declare global {
|
|||||||
requestStartedAt: number;
|
requestStartedAt: number;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
runtimeStoryAuthMode?: 'jwt' | 'spacetime';
|
runtimeStoryAuthMode?: 'jwt' | 'spacetime';
|
||||||
|
customWorldSessionAuthMode?: 'jwt' | 'spacetime';
|
||||||
auth?: {
|
auth?: {
|
||||||
userId: string;
|
userId: string;
|
||||||
tokenVersion: number;
|
tokenVersion: number;
|
||||||
|
|||||||
@@ -51,12 +51,18 @@ import type {
|
|||||||
StoryRequestOptions,
|
StoryRequestOptions,
|
||||||
TextStreamOptions,
|
TextStreamOptions,
|
||||||
} from './aiTypes';
|
} from './aiTypes';
|
||||||
import { fetchWithApiAuth, requestJson } from './apiClient';
|
import {
|
||||||
|
fetchWithApiAuth,
|
||||||
|
getStoredAccessToken,
|
||||||
|
getStoredSpacetimeToken,
|
||||||
|
requestJson,
|
||||||
|
} from './apiClient';
|
||||||
import { type CharacterChatTargetStatus } from './characterChatPrompt';
|
import { type CharacterChatTargetStatus } from './characterChatPrompt';
|
||||||
import { parseLineListContent } from './llmParsers';
|
import { parseLineListContent } from './llmParsers';
|
||||||
|
|
||||||
const RUNTIME_API_BASE = '/api/runtime';
|
const RUNTIME_API_BASE = '/api/runtime';
|
||||||
const CUSTOM_WORLD_API_BASE = '/api';
|
const CUSTOM_WORLD_API_BASE = '/api';
|
||||||
|
const CUSTOM_WORLD_STDB_AUTH_HEADER = 'x-genarrative-custom-world-auth';
|
||||||
|
|
||||||
type LegacyAiModule = typeof import('./ai');
|
type LegacyAiModule = typeof import('./ai');
|
||||||
|
|
||||||
@@ -86,6 +92,29 @@ async function requestPostJson<T>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withCustomWorldAuthHeaders(headers?: HeadersInit) {
|
||||||
|
const nextHeaders =
|
||||||
|
headers instanceof Headers
|
||||||
|
? Object.fromEntries(headers.entries())
|
||||||
|
: Array.isArray(headers)
|
||||||
|
? Object.fromEntries(headers)
|
||||||
|
: { ...(headers ?? {}) };
|
||||||
|
const httpAccessToken = getStoredAccessToken();
|
||||||
|
const spacetimeToken = getStoredSpacetimeToken();
|
||||||
|
const bearerToken = spacetimeToken || httpAccessToken;
|
||||||
|
|
||||||
|
if (bearerToken) {
|
||||||
|
nextHeaders.Authorization = `Bearer ${bearerToken}`;
|
||||||
|
}
|
||||||
|
if (spacetimeToken) {
|
||||||
|
nextHeaders[CUSTOM_WORLD_STDB_AUTH_HEADER] = 'spacetime-token';
|
||||||
|
} else if (httpAccessToken) {
|
||||||
|
nextHeaders[CUSTOM_WORLD_STDB_AUTH_HEADER] = 'http-access-token';
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextHeaders;
|
||||||
|
}
|
||||||
|
|
||||||
async function requestPlainText(
|
async function requestPlainText(
|
||||||
url: string,
|
url: string,
|
||||||
payload: unknown,
|
payload: unknown,
|
||||||
@@ -434,6 +463,11 @@ export async function streamCustomWorldSessionGeneration(
|
|||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
|
headers: withCustomWorldAuthHeaders(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
skipAuth: true,
|
||||||
|
skipRefresh: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -634,10 +668,16 @@ export async function createCustomWorldSession(payload: {
|
|||||||
`${RUNTIME_API_BASE}/custom-world/sessions`,
|
`${RUNTIME_API_BASE}/custom-world/sessions`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: withCustomWorldAuthHeaders({
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}),
|
||||||
body: JSON.stringify(payload satisfies CreateCustomWorldSessionRequest),
|
body: JSON.stringify(payload satisfies CreateCustomWorldSessionRequest),
|
||||||
},
|
},
|
||||||
'创建自定义世界会话失败',
|
'创建自定义世界会话失败',
|
||||||
|
{
|
||||||
|
skipAuth: true,
|
||||||
|
skipRefresh: true,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -847,8 +887,13 @@ export async function getCustomWorldSession(sessionId: string) {
|
|||||||
`${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}`,
|
`${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}`,
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
headers: withCustomWorldAuthHeaders(),
|
||||||
},
|
},
|
||||||
'读取自定义世界会话失败',
|
'读取自定义世界会话失败',
|
||||||
|
{
|
||||||
|
skipAuth: true,
|
||||||
|
skipRefresh: true,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -860,12 +905,18 @@ export async function answerCustomWorldSessionQuestion(
|
|||||||
`${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}/answers`,
|
`${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}/answers`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: withCustomWorldAuthHeaders({
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}),
|
||||||
body: JSON.stringify(
|
body: JSON.stringify(
|
||||||
payload satisfies AnswerCustomWorldSessionQuestionRequest,
|
payload satisfies AnswerCustomWorldSessionQuestionRequest,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
'提交自定义世界补充设定失败',
|
'提交自定义世界补充设定失败',
|
||||||
|
{
|
||||||
|
skipAuth: true,
|
||||||
|
skipRefresh: true,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user