This commit is contained in:
2026-04-21 10:30:12 +08:00
parent ae28dab032
commit 13bc79306f
49 changed files with 3691 additions and 1357 deletions

View File

@@ -353,8 +353,37 @@ export async function generateCustomWorldProfile(
input: GenerateCustomWorldProfileInput | string,
options: GenerateCustomWorldProfileOptions = {},
): Promise<CustomWorldProfile> {
const aiClient = await loadLegacyAiModule();
return aiClient.generateCustomWorldProfile(input, options);
const normalizedInput =
typeof input === 'string'
? {
settingText: input,
}
: input;
if (typeof window === 'undefined') {
const aiClient = await loadLegacyAiModule();
return aiClient.generateCustomWorldProfile(normalizedInput, options);
}
if (options.signal?.aborted) {
throw options.signal.reason instanceof Error
? options.signal.reason
: new Error('世界生成已中断。');
}
const profile = await requestPostJson<CustomWorldProfile>(
`${RUNTIME_API_BASE}/custom-world/profile`,
normalizedInput,
'生成自定义世界失败',
);
if (options.signal?.aborted) {
throw options.signal.reason instanceof Error
? options.signal.reason
: new Error('世界生成已中断。');
}
return profile;
}
export async function generateCustomWorldSceneImage(

View File

@@ -1,33 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
AUTH_STATE_EVENT,
ApiClientError,
clearStoredAccessToken,
fetchWithApiAuth,
getStoredAccessToken,
requestJson,
setStoredAccessToken,
} from './apiClient';
function createMemoryStorage() {
const values = new Map<string, string>();
return {
getItem(key: string) {
return values.has(key) ? values.get(key)! : null;
},
setItem(key: string, value: string) {
values.set(key, value);
},
removeItem(key: string) {
values.delete(key);
},
clear() {
values.clear();
},
};
}
function createResponseMock(params: {
status: number;
body?: string;
@@ -54,50 +33,18 @@ function createResponseMock(params: {
describe('apiClient', () => {
const fetchMock = vi.fn();
const dispatchEventMock = vi.fn();
beforeEach(() => {
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('window', {
localStorage: createMemoryStorage(),
dispatchEvent: vi.fn(),
dispatchEvent: dispatchEventMock,
});
fetchMock.mockReset();
clearStoredAccessToken();
dispatchEventMock.mockReset();
});
it('attaches auth headers and clears stale tokens on unauthorized responses', async () => {
setStoredAccessToken('jwt-token');
fetchMock
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
.mockResolvedValueOnce(createResponseMock({ status: 401 }));
const response = await fetchWithApiAuth('/api/protected', { method: 'GET' });
expect(response.status).toBe(401);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenCalledWith(
'/api/protected',
expect.objectContaining({
credentials: 'same-origin',
headers: expect.objectContaining({
Authorization: 'Bearer jwt-token',
'x-genarrative-response-envelope': 'v1',
}),
}),
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'/api/auth/refresh',
expect.objectContaining({
method: 'POST',
credentials: 'same-origin',
}),
);
expect(getStoredAccessToken()).toBe('');
});
it('refreshes the access token once and retries the original request', async () => {
setStoredAccessToken('expired-token');
it('refreshes cookie session once and retries the original request', async () => {
fetchMock
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
.mockResolvedValueOnce(
@@ -106,7 +53,7 @@ describe('apiClient', () => {
body: JSON.stringify({
ok: true,
data: {
token: 'fresh-token',
ok: true,
},
error: null,
meta: {
@@ -138,41 +85,115 @@ describe('apiClient', () => {
);
expect(result).toEqual({ value: 7 });
expect(getStoredAccessToken()).toBe('fresh-token');
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'/api/runtime/protected',
expect.objectContaining({
credentials: 'same-origin',
headers: expect.objectContaining({
'x-genarrative-response-envelope': 'v1',
}),
}),
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'/api/auth/refresh',
expect.objectContaining({
method: 'POST',
credentials: 'same-origin',
}),
);
expect(fetchMock).toHaveBeenNthCalledWith(
3,
'/api/runtime/protected',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer fresh-token',
}),
credentials: 'same-origin',
}),
);
expect(dispatchEventMock).toHaveBeenCalledTimes(1);
expect(dispatchEventMock).toHaveBeenCalledWith(
expect.objectContaining({
type: AUTH_STATE_EVENT,
}),
);
});
it('does not refresh or emit auth changes for 401 responses without auth context', async () => {
it('does not emit auth change events when 401 probe requests opt into silent mode', async () => {
fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 }));
const response = await fetchWithApiAuth(
'/api/auth/me',
{
method: 'GET',
},
{
notifyAuthStateChange: false,
skipRefresh: true,
},
);
expect(response.status).toBe(401);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(dispatchEventMock).not.toHaveBeenCalled();
});
it('emits auth change events when refresh fails on protected requests', async () => {
fetchMock
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
.mockResolvedValueOnce(createResponseMock({ status: 401 }));
const response = await fetchWithApiAuth('/api/runtime/protected', {
method: 'GET',
});
expect(response.status).toBe(401);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(dispatchEventMock).toHaveBeenCalledTimes(1);
});
it('accepts refresh responses that only acknowledge renewed cookie state', async () => {
fetchMock
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
.mockResolvedValueOnce(
createResponseMock({
status: 200,
body: JSON.stringify({
ok: true,
data: {
ok: true,
},
error: null,
meta: {
apiVersion: '2026-04-08',
},
}),
}),
)
.mockResolvedValueOnce(
createResponseMock({
status: 200,
body: JSON.stringify({
ok: true,
data: {
value: 9,
},
error: null,
meta: {
apiVersion: '2026-04-08',
},
}),
}),
);
const result = await requestJson<{ value: number }>(
'/api/runtime/protected',
expect.objectContaining({
credentials: 'same-origin',
}),
{ method: 'GET' },
'读取受保护数据失败',
);
expect(window.dispatchEvent).not.toHaveBeenCalled();
expect(result).toEqual({ value: 9 });
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('retries transient get requests before unwrapping the response envelope', async () => {

View File

@@ -7,8 +7,8 @@ import {
parseApiErrorMessage,
unwrapApiResponse,
} from '../../packages/shared/src/http';
import type { AuthRefreshResponse } from '../../packages/shared/src/contracts/auth';
const ACCESS_TOKEN_KEY = 'genarrative.auth.access-token.v1';
export const AUTH_STATE_EVENT = 'genarrative-auth-state-changed';
const REQUEST_ID_HEADER = 'x-request-id';
const API_VERSION_HEADER = 'x-api-version';
@@ -30,6 +30,8 @@ export type ApiRequestOptions = {
skipAuth?: boolean;
omitEnvelopeHeader?: boolean;
skipRefresh?: boolean;
// 会话探测类请求需要静默处理 401避免 AuthGate 因自发广播再次触发 hydrate。
notifyAuthStateChange?: boolean;
};
type ResolvedRetryOptions = {
@@ -48,10 +50,6 @@ type ParsedApiErrorShape = {
meta: Partial<ApiMeta>;
};
type RefreshTokenResponse = {
token: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
@@ -311,11 +309,7 @@ export class ApiClientError extends Error {
}
}
function canUseLocalStorage() {
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
}
function emitAuthStateChange() {
export function emitAuthStateChange() {
if (typeof window === 'undefined') {
return;
}
@@ -330,72 +324,18 @@ function emitAuthStateChange() {
}
}
export function getStoredAccessToken() {
if (!canUseLocalStorage()) {
return '';
}
return window.localStorage.getItem(ACCESS_TOKEN_KEY)?.trim() || '';
}
export function setStoredAccessToken(
token: string,
options: {
emit?: boolean;
} = {},
) {
if (!canUseLocalStorage()) {
return;
}
const nextToken = token.trim();
const previousToken = getStoredAccessToken();
if (nextToken) {
window.localStorage.setItem(ACCESS_TOKEN_KEY, nextToken);
} else {
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
}
// 只有登录态令牌真的发生变化时才广播事件,避免无意义的鉴权重算。
if (options.emit !== false && previousToken !== nextToken) {
emitAuthStateChange();
}
}
export function clearStoredAccessToken(
options: {
emit?: boolean;
} = {},
) {
if (!canUseLocalStorage()) {
return;
}
const previousToken = getStoredAccessToken();
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
// 未登录态下重复清空 token 不应触发状态刷新,否则会放大 401 循环。
if (options.emit !== false && previousToken) {
emitAuthStateChange();
}
}
function withAuthorizationHeaders(
function withApiHeaders(
headers?: HeadersInit,
options: Pick<ApiRequestOptions, 'omitEnvelopeHeader' | 'skipAuth'> = {},
options: Pick<ApiRequestOptions, 'omitEnvelopeHeader'> = {},
) {
const nextHeaders = normalizeHeaders(headers);
const token = getStoredAccessToken();
if (token && !options.skipAuth) {
nextHeaders.Authorization = `Bearer ${token}`;
}
if (!options.omitEnvelopeHeader) {
nextHeaders[API_RESPONSE_ENVELOPE_HEADER] = API_RESPONSE_ENVELOPE_VERSION;
}
return nextHeaders;
}
let refreshAccessTokenPromise: Promise<string> | null = null;
let refreshAccessTokenPromise: Promise<void> | null = null;
async function refreshAccessToken() {
if (refreshAccessTokenPromise) {
@@ -412,24 +352,19 @@ async function refreshAccessToken() {
});
if (!response.ok) {
clearStoredAccessToken();
throw await buildApiClientError(response, '刷新登录状态失败');
}
const responseText = await response.text();
const payload = responseText
? unwrapApiResponse<RefreshTokenResponse>(
JSON.parse(responseText) as RefreshTokenResponse,
? unwrapApiResponse<AuthRefreshResponse>(
JSON.parse(responseText) as AuthRefreshResponse,
)
: null;
if (!payload?.token?.trim()) {
clearStoredAccessToken();
if (payload?.ok !== true) {
throw new Error('刷新登录状态失败');
}
setStoredAccessToken(payload.token, { emit: false });
return payload.token;
})();
try {
@@ -446,25 +381,20 @@ export async function fetchWithApiAuth(
) {
const method = (init.method ?? 'GET').toUpperCase();
const retry = resolveRetryOptions(method, options.retry);
const shouldNotifyAuthStateChange = options.notifyAuthStateChange !== false;
let attempt = 0;
let refreshAttempted = false;
for (;;) {
try {
const requestHeaders = withAuthorizationHeaders(init.headers, options);
const hasAuthHeader = Boolean(
requestHeaders.Authorization?.trim() ||
requestHeaders.authorization?.trim(),
);
const response = await fetch(input, {
credentials: 'same-origin',
...init,
headers: requestHeaders,
headers: withApiHeaders(init.headers, options),
});
if (
response.status === 401 &&
hasAuthHeader &&
!options.skipAuth &&
!options.skipRefresh &&
!refreshAttempted
@@ -472,12 +402,19 @@ export async function fetchWithApiAuth(
try {
await refreshAccessToken();
refreshAttempted = true;
if (shouldNotifyAuthStateChange) {
emitAuthStateChange();
}
continue;
} catch {
clearStoredAccessToken();
if (shouldNotifyAuthStateChange) {
emitAuthStateChange();
}
}
} else if (response.status === 401 && !options.skipAuth) {
if (shouldNotifyAuthStateChange) {
emitAuthStateChange();
}
} else if (response.status === 401) {
clearStoredAccessToken();
}
if (!shouldRetryResponse(response.status, attempt, retry)) {

View File

@@ -1,15 +1,20 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { requestJsonMock } = vi.hoisted(() => ({
requestJsonMock: vi.fn(),
const apiClientMocks = vi.hoisted(() => ({
emitAuthStateChange: vi.fn(),
requestJson: vi.fn(),
}));
import {
ApiClientError,
clearStoredAccessToken,
getStoredAccessToken,
setStoredAccessToken,
} from './apiClient';
vi.mock('./apiClient', async () => {
const actual = await vi.importActual<typeof import('./apiClient')>('./apiClient');
return {
...actual,
emitAuthStateChange: apiClientMocks.emitAuthStateChange,
requestJson: apiClientMocks.requestJson,
};
});
import { ApiClientError } from './apiClient';
import {
authEntryWithStoredCredentials,
bindWechatPhone,
@@ -22,49 +27,34 @@ import {
getAuthRiskBlocks,
getAuthSessions,
getCaptchaChallengeFromError,
getCurrentAuthUser,
liftAuthRiskBlock,
loginWithPhoneCode,
logoutAllAuthSessions,
revokeAuthSession,
sendPhoneLoginCode,
startWechatLogin,
} from './authService';
function createMemoryStorage() {
const values = new Map<string, string>();
function createWindowMock(overrides: Record<string, unknown> = {}) {
return {
getItem(key: string) {
return values.has(key) ? values.get(key)! : null;
dispatchEvent: vi.fn(),
location: {
pathname: '/',
hash: '',
search: '',
assign: vi.fn(),
},
setItem(key: string, value: string) {
values.set(key, value);
},
removeItem(key: string) {
values.delete(key);
},
clear() {
values.clear();
history: {
replaceState: vi.fn(),
},
...overrides,
};
}
vi.mock('./apiClient', async () => {
const actual = await vi.importActual<typeof import('./apiClient')>('./apiClient');
return {
...actual,
requestJson: requestJsonMock,
};
});
describe('authService auto auth', () => {
describe('authService', () => {
beforeEach(() => {
vi.stubGlobal('window', {
localStorage: createMemoryStorage(),
dispatchEvent: vi.fn(),
});
requestJsonMock.mockReset();
clearStoredAccessToken();
vi.clearAllMocks();
vi.stubGlobal('window', createWindowMock());
});
it('creates credentials that match current username/password constraints', () => {
@@ -75,9 +65,8 @@ describe('authService auto auth', () => {
expect(credentials.password.length).toBeGreaterThanOrEqual(6);
});
it('stores jwt after auth entry without persisting guest credentials locally', async () => {
requestJsonMock.mockResolvedValue({
token: 'jwt-token-value',
it('auth entry trims guest credentials and emits auth state changes', async () => {
apiClientMocks.requestJson.mockResolvedValue({
user: {
id: 'user_1',
username: 'guest_abc123abc123',
@@ -95,8 +84,7 @@ describe('authService auto auth', () => {
});
expect(user.username).toBe('guest_abc123abc123');
expect(getStoredAccessToken()).toBe('jwt-token-value');
expect(requestJsonMock).toHaveBeenCalledWith(
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/entry',
expect.objectContaining({
body: JSON.stringify({
@@ -106,11 +94,11 @@ describe('authService auto auth', () => {
}),
'登录失败',
);
expect(apiClientMocks.emitAuthStateChange).toHaveBeenCalledTimes(1);
});
it('creates a fresh guest credential pair for auto auth when a session is missing', async () => {
requestJsonMock.mockResolvedValue({
token: 'jwt-restored',
apiClientMocks.requestJson.mockResolvedValue({
user: {
id: 'user_saved',
username: 'guest_saveduser01',
@@ -124,7 +112,7 @@ describe('authService auto auth', () => {
const result = await ensureAutoAuthUser();
const authEntryBody = JSON.parse(
requestJsonMock.mock.calls[0]?.[1]?.body as string,
apiClientMocks.requestJson.mock.calls[0]?.[1]?.body as string,
) as {
username: string;
password: string;
@@ -136,19 +124,11 @@ describe('authService auto auth', () => {
/^auto_[a-z0-9]{24}_[a-z0-9]{8}$/u,
);
expect(authEntryBody).toEqual(result.credentials);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/auth/entry',
expect.objectContaining({
method: 'POST',
body: expect.any(String),
}),
'登录失败',
);
expect(apiClientMocks.requestJson).toHaveBeenCalledTimes(1);
});
it('deduplicates concurrent auto auth requests', async () => {
requestJsonMock.mockResolvedValue({
token: 'jwt-auto',
apiClientMocks.requestJson.mockResolvedValue({
user: {
id: 'user_auto',
username: 'guest_auto',
@@ -165,19 +145,12 @@ describe('authService auto auth', () => {
ensureAutoAuthUser(),
]);
expect(requestJsonMock).toHaveBeenCalledTimes(1);
expect(apiClientMocks.requestJson).toHaveBeenCalledTimes(1);
expect(firstResult).toEqual(secondResult);
const authEntryBody = JSON.parse(
requestJsonMock.mock.calls[0]?.[1]?.body as string,
) as {
username: string;
password: string;
};
expect(authEntryBody).toEqual(firstResult.credentials);
});
it('sends phone login code through the new auth endpoint', async () => {
requestJsonMock.mockResolvedValue({
it('sends phone login code through the auth endpoint', async () => {
apiClientMocks.requestJson.mockResolvedValue({
ok: true,
cooldownSeconds: 60,
expiresInSeconds: 300,
@@ -187,7 +160,7 @@ describe('authService auto auth', () => {
const result = await sendPhoneLoginCode(' 138 0013 8000 ');
expect(result.cooldownSeconds).toBe(60);
expect(requestJsonMock).toHaveBeenCalledWith(
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/phone/send-code',
expect.objectContaining({
body: JSON.stringify({
@@ -199,28 +172,6 @@ describe('authService auto auth', () => {
);
});
it('sends phone change code with the correct scene', async () => {
requestJsonMock.mockResolvedValue({
ok: true,
cooldownSeconds: 60,
expiresInSeconds: 300,
providerRequestId: 'mock-request-id',
});
await sendPhoneLoginCode('13900139000', 'change_phone');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/auth/phone/send-code',
expect.objectContaining({
body: JSON.stringify({
phone: '13900139000',
scene: 'change_phone',
}),
}),
'发送验证码失败',
);
});
it('extracts captcha challenge details from api errors', () => {
expect(getCaptchaChallengeFromError(new Error('plain error'))).toBeNull();
@@ -246,9 +197,8 @@ describe('authService auto auth', () => {
});
});
it('stores jwt after phone login', async () => {
requestJsonMock.mockResolvedValue({
token: 'phone-jwt-token',
it('emits auth state changes after phone login', async () => {
apiClientMocks.requestJson.mockResolvedValue({
user: {
id: 'user_phone',
username: '138****8000',
@@ -263,8 +213,7 @@ describe('authService auto auth', () => {
const user = await loginWithPhoneCode('13800138000', '123456');
expect(user.username).toBe('138****8000');
expect(getStoredAccessToken()).toBe('phone-jwt-token');
expect(requestJsonMock).toHaveBeenCalledWith(
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/phone/login',
expect.objectContaining({
body: JSON.stringify({
@@ -274,11 +223,11 @@ describe('authService auto auth', () => {
}),
'登录失败',
);
expect(apiClientMocks.emitAuthStateChange).toHaveBeenCalledTimes(1);
});
it('binds wechat phone and stores jwt after activation', async () => {
requestJsonMock.mockResolvedValue({
token: 'wechat-bind-token',
it('emits auth state changes after wechat bind activation', async () => {
apiClientMocks.requestJson.mockResolvedValue({
user: {
id: 'user_wechat',
username: '138****8000',
@@ -293,22 +242,11 @@ describe('authService auto auth', () => {
const user = await bindWechatPhone('13800138000', '123456');
expect(user.wechatBound).toBe(true);
expect(getStoredAccessToken()).toBe('wechat-bind-token');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/auth/wechat/bind-phone',
expect.objectContaining({
body: JSON.stringify({
phone: '13800138000',
code: '123456',
}),
}),
'绑定手机号失败',
);
expect(apiClientMocks.emitAuthStateChange).toHaveBeenCalledTimes(1);
});
it('changes phone number without replacing the stored access token', async () => {
setStoredAccessToken('active-token');
requestJsonMock.mockResolvedValue({
it('changes phone number without emitting a global auth state refresh', async () => {
apiClientMocks.requestJson.mockResolvedValue({
user: {
id: 'user_phone',
username: '139****9000',
@@ -323,41 +261,29 @@ describe('authService auto auth', () => {
const user = await changePhoneNumber('13900139000', '123456');
expect(user.phoneNumberMasked).toBe('139****9000');
expect(getStoredAccessToken()).toBe('active-token');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/auth/phone/change',
expect.objectContaining({
body: JSON.stringify({
phone: '13900139000',
code: '123456',
}),
}),
'更换手机号失败',
);
expect(apiClientMocks.emitAuthStateChange).not.toHaveBeenCalled();
});
it('starts wechat login by navigating to backend authorization url', async () => {
const assignMock = vi.fn();
vi.stubGlobal('window', {
localStorage: createMemoryStorage(),
dispatchEvent: vi.fn(),
location: {
pathname: '/',
hash: '',
search: '',
assign: assignMock,
},
history: {
replaceState: vi.fn(),
},
});
requestJsonMock.mockResolvedValue({
vi.stubGlobal(
'window',
createWindowMock({
location: {
pathname: '/',
hash: '',
search: '',
assign: assignMock,
},
}),
);
apiClientMocks.requestJson.mockResolvedValue({
authorizationUrl: '/api/auth/wechat/callback?mock_code=wx-user&state=state123',
});
await startWechatLogin();
expect(requestJsonMock).toHaveBeenCalledWith(
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/wechat/start?redirectPath=%2F',
expect.objectContaining({
method: 'GET',
@@ -370,14 +296,14 @@ describe('authService auto auth', () => {
});
it('loads available login methods for the unauthenticated login screen', async () => {
requestJsonMock.mockResolvedValue({
apiClientMocks.requestJson.mockResolvedValue({
availableLoginMethods: ['phone', 'wechat'],
});
const result = await getAuthLoginOptions();
expect(result.availableLoginMethods).toEqual(['phone', 'wechat']);
expect(requestJsonMock).toHaveBeenCalledWith(
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/login-options',
expect.objectContaining({
method: 'GET',
@@ -386,20 +312,22 @@ describe('authService auto auth', () => {
);
});
it('consumes auth callback hash and stores token', () => {
it('consumes auth callback hash without trying to persist tokens locally', () => {
const replaceStateMock = vi.fn();
vi.stubGlobal('window', {
localStorage: createMemoryStorage(),
dispatchEvent: vi.fn(),
location: {
pathname: '/',
search: '',
hash: '#auth_provider=wechat&auth_token=wx-token&auth_binding_status=pending_bind_phone',
},
history: {
replaceState: replaceStateMock,
},
});
vi.stubGlobal(
'window',
createWindowMock({
location: {
pathname: '/',
search: '',
hash: '#auth_provider=wechat&auth_binding_status=pending_bind_phone',
assign: vi.fn(),
},
history: {
replaceState: replaceStateMock,
},
}),
);
const result = consumeAuthCallbackResult();
@@ -408,12 +336,36 @@ describe('authService auto auth', () => {
bindingStatus: 'pending_bind_phone',
error: null,
});
expect(getStoredAccessToken()).toBe('wx-token');
expect(apiClientMocks.emitAuthStateChange).not.toHaveBeenCalled();
expect(replaceStateMock).toHaveBeenCalledWith(null, '', '/');
});
it('gets current auth user with silent auth-state notification settings', async () => {
apiClientMocks.requestJson.mockResolvedValue({
user: null,
availableLoginMethods: ['phone'],
});
const result = await getCurrentAuthUser();
expect(result).toEqual({
user: null,
availableLoginMethods: ['phone'],
});
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/me',
expect.objectContaining({
method: 'GET',
}),
'读取当前用户失败',
{
notifyAuthStateChange: false,
},
);
});
it('loads auth sessions from account center endpoint', async () => {
requestJsonMock.mockResolvedValue({
apiClientMocks.requestJson.mockResolvedValue({
sessions: [
{
sessionId: 'usess_1',
@@ -432,17 +384,10 @@ describe('authService auto auth', () => {
const sessions = await getAuthSessions();
expect(sessions).toHaveLength(1);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/auth/sessions',
expect.objectContaining({
method: 'GET',
}),
'读取登录设备失败',
);
});
it('loads recent auth audit logs', async () => {
requestJsonMock.mockResolvedValue({
apiClientMocks.requestJson.mockResolvedValue({
logs: [
{
id: 'audit_1',
@@ -459,17 +404,10 @@ describe('authService auto auth', () => {
const logs = await getAuthAuditLogs();
expect(logs).toHaveLength(1);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/auth/audit-logs',
expect.objectContaining({
method: 'GET',
}),
'读取账号操作记录失败',
);
});
it('loads current risk blocks', async () => {
requestJsonMock.mockResolvedValue({
apiClientMocks.requestJson.mockResolvedValue({
blocks: [
{
scopeType: 'phone',
@@ -484,23 +422,16 @@ describe('authService auto auth', () => {
const blocks = await getAuthRiskBlocks();
expect(blocks).toHaveLength(1);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/auth/risk-blocks',
expect.objectContaining({
method: 'GET',
}),
'读取安全状态失败',
);
});
it('lifts a risk block by scope type', async () => {
requestJsonMock.mockResolvedValue({
apiClientMocks.requestJson.mockResolvedValue({
ok: true,
});
await liftAuthRiskBlock('phone');
expect(requestJsonMock).toHaveBeenCalledWith(
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/risk-blocks/phone/lift',
expect.objectContaining({
method: 'POST',
@@ -509,37 +440,20 @@ describe('authService auto auth', () => {
);
});
it('revokes a remote auth session by id', async () => {
requestJsonMock.mockResolvedValue({
ok: true,
});
await revokeAuthSession('usess_123');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/auth/sessions/usess_123/revoke',
expect.objectContaining({
method: 'POST',
}),
'移除登录设备失败',
);
});
it('clears local auth state after logout all sessions', async () => {
setStoredAccessToken('stale-token');
requestJsonMock.mockResolvedValue({
it('emits auth change after logout all sessions', async () => {
apiClientMocks.requestJson.mockResolvedValue({
ok: true,
});
await logoutAllAuthSessions();
expect(requestJsonMock).toHaveBeenCalledWith(
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/logout-all',
expect.objectContaining({
method: 'POST',
}),
'退出全部设备失败',
);
expect(getStoredAccessToken()).toBe('');
expect(apiClientMocks.emitAuthStateChange).toHaveBeenCalledTimes(1);
});
});

View File

@@ -23,9 +23,8 @@ import type {
} from '../../packages/shared/src/contracts/auth';
import {
ApiClientError,
clearStoredAccessToken,
emitAuthStateChange,
requestJson,
setStoredAccessToken,
} from './apiClient';
export type { AuthUser } from '../../packages/shared/src/contracts/auth';
@@ -117,7 +116,7 @@ export function createAutoAuthCredentials(): AutoAuthCredentials {
}
export function clearAuthSession() {
clearStoredAccessToken();
emitAuthStateChange();
}
export async function sendPhoneLoginCode(
@@ -160,7 +159,7 @@ export async function loginWithPhoneCode(phone: string, code: string) {
'登录失败',
);
setStoredAccessToken(response.token);
emitAuthStateChange();
return response.user;
}
@@ -178,7 +177,7 @@ export async function bindWechatPhone(phone: string, code: string) {
'绑定手机号失败',
);
setStoredAccessToken(response.token);
emitAuthStateChange();
return response.user;
}
@@ -233,7 +232,7 @@ export async function authEntry(username: string, password: string) {
'登录失败',
);
setStoredAccessToken(response.token);
emitAuthStateChange();
return response.user;
}
@@ -279,19 +278,14 @@ export function consumeAuthCallbackResult(): ConsumedAuthCallback | null {
}
const params = new URLSearchParams(hash);
const authToken = params.get('auth_token');
const authError = params.get('auth_error');
const providerValue = params.get('auth_provider');
const bindingStatus = params.get('auth_binding_status');
if (!authToken && !authError) {
if (!bindingStatus && !authError && !providerValue) {
return null;
}
if (authToken) {
setStoredAccessToken(authToken);
}
if (typeof window.history?.replaceState === 'function') {
window.history.replaceState(
null,
@@ -314,6 +308,10 @@ export async function getCurrentAuthUser(): Promise<AuthSessionSnapshot> {
method: 'GET',
},
'读取当前用户失败',
{
// 会话恢复阶段允许 401 作为“未登录”信号,不应再广播一次全局鉴权事件。
notifyAuthStateChange: false,
},
);
return {

View File

@@ -777,3 +777,85 @@ test('embedded legacy result profile keeps result-page settings in runtime chara
'守灯会值夜人,对外总像比别人更冷静一步。',
);
});
test('embedded legacy result profile uses latest draft role collection when legacy role ids drift', () => {
const profile = buildCustomWorldProfileFromAgentDraft({
...session,
draftProfile: {
...session.draftProfile,
legacyResultProfile: buildLegacyResultProfile(),
storyNpcs: [
{
id: 'story-npc-latest-1',
name: '林教授',
title: '深海学院导师',
role: '场景关键角色',
publicIdentity: '研究古代海洋遗迹的资深学者。',
publicMask: '总是先观察,再给出判断。',
currentPressure: '必须在遗迹崩塌前带出关键样本。',
hiddenHook: '他知道遗迹深处那扇门为何会苏醒。',
relationToPlayer: '最早愿意共享海图的人',
threadIds: ['thread-1'],
summary: '他像学者,也像提前看见灾变的人。',
imageSrc:
'/generated-characters/story-npc-latest-1/visual/asset-latest/master.png',
generatedVisualAssetId: 'asset-latest-story',
},
],
sceneChapters: [
{
id: 'scene-chapter-latest-1',
sceneId: 'landmark-1',
sceneName: '回潮旧灯塔',
title: '灯塔新章',
summary: '围绕林教授推进的新章节。',
linkedThreadIds: ['thread-1'],
linkedLandmarkIds: ['landmark-1'],
acts: [
{
id: 'scene-act-latest-1',
title: '第一幕',
summary: '先接林教授的入口信息。',
stageCoverage: ['opening'],
backgroundImageSrc:
'/generated-custom-world-scenes/landmark-1/scene-act-latest-1/scene.png',
backgroundAssetId: 'scene-asset-latest',
encounterNpcIds: ['story-npc-latest-1'],
primaryNpcId: 'story-npc-latest-1',
linkedThreadIds: ['thread-1'],
actGoal: '接住新的入口信息',
transitionHook: '向下一幕推进。',
advanceRule: 'after_primary_contact',
},
],
},
],
landmarks: [
{
...session.draftProfile.landmarks[0],
imageSrc: '/generated-custom-world-scenes/landmark-1/latest-scene.png',
},
],
},
});
expect(profile?.storyNpcs).toHaveLength(1);
expect(profile?.storyNpcs[0]?.id).toBe('story-npc-latest-1');
expect(profile?.storyNpcs[0]?.name).toBe('林教授');
expect(profile?.storyNpcs[0]?.imageSrc).toBe(
'/generated-characters/story-npc-latest-1/visual/asset-latest/master.png',
);
expect(profile?.storyNpcs[0]?.generatedVisualAssetId).toBe(
'asset-latest-story',
);
expect(profile?.storyNpcs[0]?.narrativeProfile).toBeFalsy();
expect(profile?.landmarks[0]?.imageSrc).toBe(
'/generated-custom-world-scenes/landmark-1/latest-scene.png',
);
expect(profile?.sceneChapterBlueprints?.[0]?.acts[0]?.primaryNpcId).toBe(
'story-npc-latest-1',
);
expect(profile?.sceneChapterBlueprints?.[0]?.acts[0]?.backgroundImageSrc).toBe(
'/generated-custom-world-scenes/landmark-1/scene-act-latest-1/scene.png',
);
});

View File

@@ -147,6 +147,13 @@ type AdaptedDraftLandmark = {
connections: never[];
};
type AdaptedDraftCamp = {
name: string;
description: string;
dangerLevel: string;
imageSrc?: string;
};
function adaptDraftLandmarks(value: unknown, storyNpcIdSet: Set<string>) {
return toRecordArray(value)
.map((record, index) => {
@@ -178,108 +185,225 @@ function adaptDraftLandmarks(value: unknown, storyNpcIdSet: Set<string>) {
.filter(Boolean) as AdaptedDraftLandmark[];
}
function mergeDraftRoleAssetsIntoProfile(
baseProfile: CustomWorldProfile,
draftRoles: AdaptedDraftCharacter[],
roleKind: 'playable' | 'story',
) {
const draftRoleById = new Map(draftRoles.map((role) => [role.id, role]));
const currentRoles =
roleKind === 'playable' ? baseProfile.playableNpcs : baseProfile.storyNpcs;
const mergedRoles = currentRoles.map((role) => {
const draftRole = draftRoleById.get(role.id);
if (!draftRole) {
return role;
}
function adaptDraftCamp(value: unknown): AdaptedDraftCamp | null {
if (!isRecord(value)) {
return null;
}
return {
...role,
imageSrc: draftRole.imageSrc ?? role.imageSrc,
generatedVisualAssetId:
draftRole.generatedVisualAssetId ?? role.generatedVisualAssetId,
generatedAnimationSetId:
draftRole.generatedAnimationSetId ?? role.generatedAnimationSetId,
animationMap: draftRole.animationMap ?? role.animationMap,
};
});
if (roleKind === 'playable') {
return {
...baseProfile,
playableNpcs: mergedRoles,
} satisfies CustomWorldProfile;
const name = toText(value.name);
const description = toText(value.description);
if (!name && !description) {
return null;
}
return {
...baseProfile,
storyNpcs: mergedRoles,
} satisfies CustomWorldProfile;
name: name || '开局据点',
description: description || '开局落脚点仍待继续精修。',
dangerLevel:
toText(value.dangerLevel) || toText(value.mood) || 'medium',
imageSrc: toText(value.imageSrc) || undefined,
} satisfies AdaptedDraftCamp;
}
function mergeDraftSceneAssetsIntoProfile(
baseProfile: CustomWorldProfile,
draftSceneChapters: CustomWorldProfile['sceneChapterBlueprints'],
draftLandmarks: AdaptedDraftLandmark[],
function normalizeMatchText(value: unknown) {
return toText(value).toLocaleLowerCase();
}
function findRecordMatchIndex(
records: Record<string, unknown>[],
matcher: (record: Record<string, unknown>) => boolean,
usedIndexes: Set<number>,
) {
const normalizedDraftSceneChapters = draftSceneChapters ?? [];
const draftSceneChapterBySceneId = new Map(
normalizedDraftSceneChapters.map((chapter) => [chapter.sceneId, chapter]),
const matchedIndex = records.findIndex(
(record, index) => !usedIndexes.has(index) && matcher(record),
);
const draftLandmarkById = new Map(draftLandmarks.map((entry) => [entry.id, entry]));
if (matchedIndex >= 0) {
usedIndexes.add(matchedIndex);
}
return matchedIndex;
}
const nextCamp = baseProfile.camp
? {
...baseProfile.camp,
imageSrc: baseProfile.camp.imageSrc,
}
: baseProfile.camp;
function mergeDraftRolesIntoProfileRecord(params: {
baseRoles: unknown;
draftRoles: AdaptedDraftCharacter[];
}) {
const baseRoles = toRecordArray(params.baseRoles);
if (params.draftRoles.length <= 0) {
return baseRoles;
}
const nextLandmarks = baseProfile.landmarks.map((landmark) => {
const draftLandmark = draftLandmarkById.get(landmark.id);
const usedIndexes = new Set<number>();
// 当前 draft 才是最新角色集合legacy 只负责为同一对象补运行时富字段,
// 不能再让旧列表继续主导结果页,否则会把新角色主图和新对象列表吞掉。
return params.draftRoles.map((draftRole) => {
let matchedIndex = findRecordMatchIndex(
baseRoles,
(record) => toText(record.id) === draftRole.id,
usedIndexes,
);
if (matchedIndex < 0) {
matchedIndex = findRecordMatchIndex(
baseRoles,
(record) => normalizeMatchText(record.name) === normalizeMatchText(draftRole.name),
usedIndexes,
);
}
const baseRole = matchedIndex >= 0 ? baseRoles[matchedIndex] : null;
const baseImageSrc = toText(baseRole?.imageSrc) || undefined;
const baseGeneratedVisualAssetId =
toText(baseRole?.generatedVisualAssetId) || undefined;
const baseGeneratedAnimationSetId =
toText(baseRole?.generatedAnimationSetId) || undefined;
return {
...landmark,
imageSrc: draftLandmark?.imageSrc ?? landmark.imageSrc,
};
...(baseRole ?? {}),
...draftRole,
imageSrc: draftRole.imageSrc ?? baseImageSrc,
generatedVisualAssetId:
draftRole.generatedVisualAssetId ?? baseGeneratedVisualAssetId,
generatedAnimationSetId:
draftRole.generatedAnimationSetId ?? baseGeneratedAnimationSetId,
animationMap:
draftRole.animationMap ??
(isRecord(baseRole?.animationMap) ? baseRole?.animationMap : undefined),
} satisfies Record<string, unknown>;
});
}
function mergeDraftLandmarksIntoProfileRecord(params: {
baseLandmarks: unknown;
draftLandmarks: AdaptedDraftLandmark[];
}) {
const baseLandmarks = toRecordArray(params.baseLandmarks);
if (params.draftLandmarks.length <= 0) {
return baseLandmarks;
}
const usedIndexes = new Set<number>();
const mergedLandmarks = params.draftLandmarks.map((draftLandmark) => {
let matchedIndex = findRecordMatchIndex(
baseLandmarks,
(record) => toText(record.id) === draftLandmark.id,
usedIndexes,
);
if (matchedIndex < 0) {
matchedIndex = findRecordMatchIndex(
baseLandmarks,
(record) =>
normalizeMatchText(record.name) === normalizeMatchText(draftLandmark.name),
usedIndexes,
);
}
const baseLandmark = matchedIndex >= 0 ? baseLandmarks[matchedIndex] : null;
const baseImageSrc = toText(baseLandmark?.imageSrc) || undefined;
return {
...(baseLandmark ?? {}),
id: draftLandmark.id,
name: draftLandmark.name,
description: draftLandmark.description,
dangerLevel: draftLandmark.dangerLevel,
imageSrc: draftLandmark.imageSrc ?? baseImageSrc,
sceneNpcIds:
draftLandmark.sceneNpcIds.length > 0
? draftLandmark.sceneNpcIds
: toStringArray(baseLandmark?.sceneNpcIds),
} satisfies Record<string, unknown>;
});
const nextSceneChapterBlueprints =
normalizedDraftSceneChapters.length > 0
? baseProfile.sceneChapterBlueprints?.map((chapter) => {
const draftChapter = draftSceneChapterBySceneId.get(chapter.sceneId);
if (!draftChapter) {
return chapter;
}
const remainingLegacyLandmarks = baseLandmarks.filter(
(_entry, index) => !usedIndexes.has(index),
);
const draftActById = new Map(
draftChapter.acts.map((act) => [act.id, act]),
);
return [...mergedLandmarks, ...remainingLegacyLandmarks];
}
return {
...chapter,
acts: chapter.acts.map((act) => {
const draftAct = draftActById.get(act.id);
if (!draftAct) {
return act;
}
function mergeDraftSceneChaptersIntoProfileRecord(params: {
baseSceneChapters: unknown;
draftSceneChapters: CustomWorldProfile['sceneChapterBlueprints'];
}) {
const baseSceneChapters = toRecordArray(params.baseSceneChapters);
const draftSceneChapters = params.draftSceneChapters ?? [];
if (draftSceneChapters.length <= 0) {
return baseSceneChapters;
}
return {
...act,
backgroundImageSrc:
draftAct.backgroundImageSrc ?? act.backgroundImageSrc,
backgroundAssetId:
draftAct.backgroundAssetId ?? act.backgroundAssetId,
};
}),
};
}) ?? normalizedDraftSceneChapters
: baseProfile.sceneChapterBlueprints;
const usedChapterIndexes = new Set<number>();
return draftSceneChapters.map((draftChapter) => {
let matchedChapterIndex = findRecordMatchIndex(
baseSceneChapters,
(record) => toText(record.sceneId) === draftChapter.sceneId,
usedChapterIndexes,
);
if (matchedChapterIndex < 0) {
matchedChapterIndex = findRecordMatchIndex(
baseSceneChapters,
(record) =>
normalizeMatchText(record.title) === normalizeMatchText(draftChapter.title),
usedChapterIndexes,
);
}
const baseChapter =
matchedChapterIndex >= 0 ? baseSceneChapters[matchedChapterIndex] : null;
const baseActs = toRecordArray(baseChapter?.acts);
const usedActIndexes = new Set<number>();
const mergedActs = draftChapter.acts.map((draftAct) => {
let matchedActIndex = findRecordMatchIndex(
baseActs,
(record) => toText(record.id) === draftAct.id,
usedActIndexes,
);
if (matchedActIndex < 0) {
matchedActIndex = findRecordMatchIndex(
baseActs,
(record) =>
normalizeMatchText(record.title) === normalizeMatchText(draftAct.title),
usedActIndexes,
);
}
const baseAct = matchedActIndex >= 0 ? baseActs[matchedActIndex] : null;
const baseBackgroundImageSrc =
toText(baseAct?.backgroundImageSrc) || undefined;
const baseBackgroundAssetId =
toText(baseAct?.backgroundAssetId) || undefined;
return {
...(baseAct ?? {}),
...draftAct,
backgroundImageSrc: draftAct.backgroundImageSrc ?? baseBackgroundImageSrc,
backgroundAssetId: draftAct.backgroundAssetId ?? baseBackgroundAssetId,
} satisfies Record<string, unknown>;
});
return {
...(baseChapter ?? {}),
...draftChapter,
acts: mergedActs,
} satisfies Record<string, unknown>;
});
}
function mergeDraftCampIntoProfileRecord(params: {
baseCamp: unknown;
draftCamp: AdaptedDraftCamp | null;
}) {
if (!params.draftCamp) {
return isRecord(params.baseCamp) ? params.baseCamp : undefined;
}
const baseCamp = isRecord(params.baseCamp) ? params.baseCamp : null;
const baseImageSrc = toText(baseCamp?.imageSrc) || undefined;
return {
...baseProfile,
camp: nextCamp,
landmarks: nextLandmarks,
sceneChapterBlueprints: nextSceneChapterBlueprints,
} satisfies CustomWorldProfile;
...(baseCamp ?? {}),
...params.draftCamp,
imageSrc: params.draftCamp.imageSrc ?? baseImageSrc,
} satisfies Record<string, unknown>;
}
function toStageCoverage(value: unknown) {
@@ -396,25 +520,36 @@ export function buildCustomWorldProfileFromAgentDraft(
storyNpcIdSet,
landmarkIdSet,
);
const draftCamp = adaptDraftCamp(draftProfile.camp);
const legacyResultProfile = normalizeCustomWorldProfileRecord(
draftProfile.legacyResultProfile,
);
if (legacyResultProfile) {
const mergedPlayableProfile = mergeDraftRoleAssetsIntoProfile(
legacyResultProfile,
playableNpcs,
'playable',
);
const mergedStoryProfile = mergeDraftRoleAssetsIntoProfile(
mergedPlayableProfile,
storyNpcs,
'story',
);
return mergeDraftSceneAssetsIntoProfile(
mergedStoryProfile,
draftSceneChapterBlueprints,
adaptedLandmarks,
);
const mergedProfile = normalizeCustomWorldProfileRecord({
...legacyResultProfile,
playableNpcs: mergeDraftRolesIntoProfileRecord({
baseRoles: legacyResultProfile.playableNpcs,
draftRoles: playableNpcs,
}),
storyNpcs: mergeDraftRolesIntoProfileRecord({
baseRoles: legacyResultProfile.storyNpcs,
draftRoles: storyNpcs,
}),
landmarks: mergeDraftLandmarksIntoProfileRecord({
baseLandmarks: legacyResultProfile.landmarks,
draftLandmarks: adaptedLandmarks,
}),
camp: mergeDraftCampIntoProfileRecord({
baseCamp: legacyResultProfile.camp,
draftCamp,
}),
sceneChapterBlueprints: mergeDraftSceneChaptersIntoProfileRecord({
baseSceneChapters: legacyResultProfile.sceneChapterBlueprints,
draftSceneChapters: draftSceneChapterBlueprints,
}),
});
return mergedProfile ?? legacyResultProfile;
}
const normalized = normalizeCustomWorldProfileRecord({
@@ -435,14 +570,12 @@ export function buildCustomWorldProfileFromAgentDraft(
playableNpcs,
storyNpcs,
landmarks: adaptedLandmarks,
camp: isRecord(draftProfile.camp)
camp: draftCamp
? {
name: toText(draftProfile.camp.name),
description: toText(draftProfile.camp.description),
dangerLevel:
toText(draftProfile.camp.dangerLevel) ||
toText(draftProfile.camp.mood),
imageSrc: toText(draftProfile.camp.imageSrc) || undefined,
name: draftCamp.name,
description: draftCamp.description,
dangerLevel: draftCamp.dangerLevel,
imageSrc: draftCamp.imageSrc,
}
: undefined,
sceneChapterBlueprints: draftSceneChapterBlueprints,

View File

@@ -295,8 +295,8 @@ const AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS = [
},
{
id: 'workspace',
label: '准备精修工作区',
detail: '正在写回草稿数据,并切回可继续精修的工作区。',
label: '准备结果页',
detail: '正在写回草稿数据,并打开可继续完善的结果页。',
matchers: ['世界底稿已生成'],
minProgress: 100,
},
@@ -324,7 +324,8 @@ function resolveAgentDraftFoundationStepIndexByProgress(progress: number) {
index < AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS.length - 1;
index += 1
) {
if (progress >= AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[index].minProgress) {
const step = AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[index];
if (step && progress >= step.minProgress) {
matchedIndex = index;
}
}
@@ -348,7 +349,7 @@ function resolveAgentDraftFoundationStepIndex(
index -= 1
) {
const step = AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[index];
if (step.matchers.some((matcher) => phaseLabel.includes(matcher))) {
if (step?.matchers.some((matcher) => phaseLabel.includes(matcher))) {
return index;
}
}

View File

@@ -1,172 +0,0 @@
import type {
PlatformBrowseHistoryEntry,
PlatformBrowseHistoryWriteEntry,
} from '../../packages/shared/src/contracts/runtime';
import type { AuthUser } from './authService';
export type { PlatformBrowseHistoryEntry, PlatformBrowseHistoryWriteEntry };
const HISTORY_STORAGE_KEY_PREFIX = 'genarrative.platform.browse-history.v1';
const HISTORY_SYNC_KEY_PREFIX = 'genarrative.platform.browse-history.synced.v1';
const MAX_HISTORY_ENTRIES = 20;
function canUseLocalStorage() {
return (
typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
);
}
function buildHistoryStorageKey(user: AuthUser | null | undefined) {
const accountId = user?.id?.trim() || user?.username?.trim() || 'guest';
return `${HISTORY_STORAGE_KEY_PREFIX}:${accountId}`;
}
function buildHistorySyncKey(user: AuthUser | null | undefined) {
const accountId = user?.id?.trim() || user?.username?.trim() || 'guest';
return `${HISTORY_SYNC_KEY_PREFIX}:${accountId}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function readString(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeHistoryEntry(
value: unknown,
): PlatformBrowseHistoryEntry | null {
if (!isRecord(value)) {
return null;
}
const ownerUserId = readString(value.ownerUserId);
const profileId = readString(value.profileId);
const worldName = readString(value.worldName);
const visitedAt = readString(value.visitedAt);
if (!ownerUserId || !profileId || !worldName || !visitedAt) {
return null;
}
return {
ownerUserId,
profileId,
worldName,
subtitle: readString(value.subtitle),
summaryText: readString(value.summaryText),
coverImageSrc: readString(value.coverImageSrc) || null,
themeMode:
(readString(
value.themeMode,
) as PlatformBrowseHistoryEntry['themeMode']) || 'mythic',
authorDisplayName: readString(value.authorDisplayName) || '玩家',
visitedAt,
};
}
function sortHistoryEntries(entries: PlatformBrowseHistoryEntry[]) {
return [...entries].sort((left, right) => {
return (
new Date(right.visitedAt).getTime() - new Date(left.visitedAt).getTime()
);
});
}
export function readPlatformBrowseHistory(user: AuthUser | null | undefined) {
if (!canUseLocalStorage()) {
return [] as PlatformBrowseHistoryEntry[];
}
const raw = window.localStorage.getItem(buildHistoryStorageKey(user));
if (!raw?.trim()) {
return [] as PlatformBrowseHistoryEntry[];
}
try {
const parsed = JSON.parse(raw) as unknown[];
if (!Array.isArray(parsed)) {
return [] as PlatformBrowseHistoryEntry[];
}
return sortHistoryEntries(
parsed
.map((entry) => normalizeHistoryEntry(entry))
.filter((entry): entry is PlatformBrowseHistoryEntry => Boolean(entry)),
).slice(0, MAX_HISTORY_ENTRIES);
} catch {
return [] as PlatformBrowseHistoryEntry[];
}
}
export function writePlatformBrowseHistory(
user: AuthUser | null | undefined,
entry: PlatformBrowseHistoryWriteEntry,
) {
if (!canUseLocalStorage()) {
return [] as PlatformBrowseHistoryEntry[];
}
const nextEntry: PlatformBrowseHistoryEntry = {
ownerUserId: entry.ownerUserId.trim(),
profileId: entry.profileId.trim(),
worldName: entry.worldName.trim(),
subtitle: entry.subtitle?.trim() || '',
summaryText: entry.summaryText?.trim() || '',
coverImageSrc: entry.coverImageSrc?.trim() || null,
themeMode: entry.themeMode || 'mythic',
authorDisplayName: entry.authorDisplayName?.trim() || '玩家',
visitedAt: entry.visitedAt?.trim() || new Date().toISOString(),
};
const deduped = readPlatformBrowseHistory(user).filter(
(current) =>
!(
current.ownerUserId === nextEntry.ownerUserId &&
current.profileId === nextEntry.profileId
),
);
const nextEntries = sortHistoryEntries([nextEntry, ...deduped]).slice(
0,
MAX_HISTORY_ENTRIES,
);
window.localStorage.setItem(
buildHistoryStorageKey(user),
JSON.stringify(nextEntries),
);
return nextEntries;
}
export function clearPlatformBrowseHistory(user: AuthUser | null | undefined) {
if (!canUseLocalStorage()) {
return;
}
window.localStorage.removeItem(buildHistoryStorageKey(user));
window.localStorage.removeItem(buildHistorySyncKey(user));
}
export function hasPendingPlatformBrowseHistoryMigration(
user: AuthUser | null | undefined,
) {
if (!canUseLocalStorage()) {
return false;
}
return (
readPlatformBrowseHistory(user).length > 0 &&
window.localStorage.getItem(buildHistorySyncKey(user)) !== '1'
);
}
export function markPlatformBrowseHistoryMigrated(
user: AuthUser | null | undefined,
) {
if (!canUseLocalStorage()) {
return;
}
window.localStorage.setItem(buildHistorySyncKey(user), '1');
}

View File

@@ -2,21 +2,11 @@ import {
getNpcDisclosureStage,
getNpcWarmthStage,
} from '../data/npcInteractions';
import {
buildFallbackQuestIntent,
compileQuestIntentToQuest,
evaluateQuestOpportunity,
} from '../data/questFlow';
import { evaluateQuestOpportunity } from '../data/questFlow';
import type { Encounter, GameState, QuestLogEntry } from '../types';
import type { QuestGenerationContext } from './aiTypes';
import { requestJson } from './apiClient';
import { requestChatMessageContent } from './llmClient';
import { parseJsonResponseText } from './llmParsers';
import {
buildQuestIntentPrompt,
QUEST_INTENT_SYSTEM_PROMPT,
} from './questPrompt';
import type { QuestIntent, QuestPreviewRequest } from './questTypes';
import type { QuestPreviewRequest } from './questTypes';
import {
buildFallbackActorNarrativeProfile,
normalizeActorNarrativeProfile,
@@ -24,37 +14,6 @@ import {
import { buildThemePackFromWorldProfile } from './storyEngine/themePack';
import { buildFallbackWorldStoryGraph } from './storyEngine/worldStoryGraph';
const QUEST_DIRECTOR_TIMEOUT_MS = 12000;
function coerceString(value: unknown, fallback: string) {
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
}
function coerceQuestTitle(value: unknown, fallback: string) {
const title = coerceString(value, fallback)
.replace(/["']/gu, '')
.replace(/[,.!?;:].*$/u, '')
.trim();
if (title.length <= 12) {
return title;
}
return fallback.length <= 12 ? fallback : fallback.slice(0, 10);
}
function coerceStringArray(value: unknown, fallback: string[]) {
if (!Array.isArray(value)) {
return fallback;
}
const items = value
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter(Boolean);
return items.length > 0 ? items : fallback;
}
function resolveIssuerNarrativeProfile(state: GameState, encounter: Encounter) {
if (encounter.narrativeProfile) {
return encounter.narrativeProfile;
@@ -87,73 +46,6 @@ function resolveIssuerNarrativeProfile(state: GameState, encounter: Encounter) {
);
}
function sanitizeQuestIntent(
rawIntent: unknown,
fallback: QuestIntent,
): QuestIntent {
if (!rawIntent || typeof rawIntent !== 'object') {
return fallback;
}
const intent = rawIntent as Record<string, unknown>;
return {
title: coerceQuestTitle(intent.title, fallback.title),
description: coerceString(intent.description, fallback.description),
summary: coerceString(intent.summary, fallback.summary),
narrativeType:
typeof intent.narrativeType === 'string' &&
[
'bounty',
'escort',
'investigation',
'retrieval',
'relationship',
'trial',
].includes(intent.narrativeType)
? (intent.narrativeType as QuestIntent['narrativeType'])
: fallback.narrativeType,
dramaticNeed: coerceString(intent.dramaticNeed, fallback.dramaticNeed),
issuerGoal: coerceString(intent.issuerGoal, fallback.issuerGoal),
playerHook: coerceString(intent.playerHook, fallback.playerHook),
worldReason: coerceString(intent.worldReason, fallback.worldReason),
recommendedObjectiveKinds: coerceStringArray(
intent.recommendedObjectiveKinds,
fallback.recommendedObjectiveKinds,
).filter((kind) =>
[
'defeat_hostile_npc',
'inspect_treasure',
'spar_with_npc',
'talk_to_npc',
'reach_scene',
'deliver_item',
].includes(kind),
) as QuestIntent['recommendedObjectiveKinds'],
urgency:
typeof intent.urgency === 'string' &&
['low', 'medium', 'high'].includes(intent.urgency)
? (intent.urgency as QuestIntent['urgency'])
: fallback.urgency,
intimacy:
typeof intent.intimacy === 'string' &&
['transactional', 'cooperative', 'trust_based'].includes(intent.intimacy)
? (intent.intimacy as QuestIntent['intimacy'])
: fallback.intimacy,
rewardTheme:
typeof intent.rewardTheme === 'string' &&
['currency', 'resource', 'relationship', 'intel', 'rare_item'].includes(
intent.rewardTheme,
)
? (intent.rewardTheme as QuestIntent['rewardTheme'])
: fallback.rewardTheme,
followupHooks: coerceStringArray(
intent.followupHooks,
fallback.followupHooks,
),
};
}
export function buildQuestGenerationContextFromState(params: {
state: GameState;
encounter: Encounter;
@@ -235,67 +127,13 @@ export async function generateQuestForNpcEncounter(params: {
return null;
}
const fallbackIntent = buildFallbackQuestIntent(request);
if (typeof window !== 'undefined') {
try {
return await requestJson<QuestLogEntry | null>(
'/api/runtime/quests/generate',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
},
'任务生成失败',
);
} catch (error) {
console.warn(
'[QuestDirector] backend quest generation failed, using deterministic fallback',
error,
);
return compileQuestIntentToQuest(
{
...request,
origin: 'fallback_builder',
},
fallbackIntent,
);
}
}
try {
const content = await requestChatMessageContent(
QUEST_INTENT_SYSTEM_PROMPT,
buildQuestIntentPrompt({
context: request.context!,
scene: request.scene,
opportunity,
}),
{
timeoutMs: QUEST_DIRECTOR_TIMEOUT_MS,
debugLabel: 'quest-intent',
},
);
const parsed = parseJsonResponseText(content) as { intent?: unknown };
const intent = sanitizeQuestIntent(parsed.intent, fallbackIntent);
return compileQuestIntentToQuest(
{
...request,
origin: 'ai_compiled',
},
intent,
);
} catch (error) {
console.warn(
'[QuestDirector] falling back to deterministic quest intent',
error,
);
return compileQuestIntentToQuest(
{
...request,
origin: 'fallback_builder',
},
fallbackIntent,
);
}
return requestJson<QuestLogEntry | null>(
'/api/runtime/quests/generate',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
},
'任务生成失败',
);
}

View File

@@ -1,134 +1,24 @@
import {buildRuntimeItemAiIntent} from '../data/runtimeItemNarrative';
import type {
RuntimeItemAiIntent,
RuntimeItemGenerationContext,
RuntimeItemPlan,
} from '../types';
import { requestJson } from './apiClient';
import {requestChatMessageContent} from './llmClient';
import {parseJsonResponseText} from './llmParsers';
import {
buildRuntimeItemIntentPrompt,
RUNTIME_ITEM_INTENT_SYSTEM_PROMPT,
} from './runtimeItemAiPrompt';
const RUNTIME_ITEM_INTENT_TIMEOUT_MS = 9000;
function coerceString(value: unknown, fallback: string) {
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
}
function coerceStringArray(value: unknown, fallback: string[], limit: number) {
if (!Array.isArray(value)) {
return fallback;
}
const normalized = value
.map(item => (typeof item === 'string' ? item.trim() : ''))
.filter(Boolean)
.slice(0, limit);
return normalized.length > 0 ? normalized : fallback;
}
function sanitizeRuntimeItemAiIntent(
rawIntent: unknown,
fallback: RuntimeItemAiIntent,
): RuntimeItemAiIntent {
if (!rawIntent || typeof rawIntent !== 'object') {
return fallback;
}
const intent = rawIntent as Record<string, unknown>;
const desiredFunctionalBias = coerceStringArray(
intent.desiredFunctionalBias,
fallback.desiredFunctionalBias,
2,
).filter(
(
item,
): item is RuntimeItemAiIntent['desiredFunctionalBias'][number] =>
['heal', 'mana', 'cooldown', 'guard', 'damage'].includes(item),
);
const tone = coerceString(intent.tone, fallback.tone);
return {
shortNameSeed: coerceString(intent.shortNameSeed, fallback.shortNameSeed),
sourcePhrase: coerceString(intent.sourcePhrase, fallback.sourcePhrase),
reasonToAppear: coerceString(intent.reasonToAppear, fallback.reasonToAppear),
relationHooks: coerceStringArray(intent.relationHooks, fallback.relationHooks, 2),
desiredBuildTags: coerceStringArray(intent.desiredBuildTags, fallback.desiredBuildTags, 3),
desiredFunctionalBias:
desiredFunctionalBias.length > 0
? desiredFunctionalBias
: fallback.desiredFunctionalBias,
tone: ['grim', 'mysterious', 'martial', 'ritual', 'survival'].includes(tone)
? (tone as RuntimeItemAiIntent['tone'])
: fallback.tone,
visibleClue: coerceString(intent.visibleClue, fallback.visibleClue ?? ''),
witnessMark: coerceString(intent.witnessMark, fallback.witnessMark ?? ''),
unfinishedBusiness: coerceString(
intent.unfinishedBusiness,
fallback.unfinishedBusiness ?? '',
),
hiddenHook: coerceString(intent.hiddenHook, fallback.hiddenHook ?? ''),
reactionHooks: coerceStringArray(
intent.reactionHooks,
fallback.reactionHooks ?? [],
4,
),
namingPattern: coerceString(intent.namingPattern, fallback.namingPattern ?? ''),
};
}
export async function generateRuntimeItemAiIntents(params: {
context: RuntimeItemGenerationContext;
plans: RuntimeItemPlan[];
}) {
const fallbackIntents = params.plans.map(plan =>
buildRuntimeItemAiIntent(params.context, plan),
);
if (typeof window !== 'undefined') {
try {
const response = await requestJson<{
intents?: unknown[];
}>(
'/api/runtime/items/runtime-intent',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
},
'运行时物品意图生成失败',
);
const rawIntents = Array.isArray(response.intents) ? response.intents : [];
return params.plans.map((_, index) =>
sanitizeRuntimeItemAiIntent(rawIntents[index], fallbackIntents[index]!),
);
} catch (error) {
console.warn(
'[runtimeItemAiDirector] backend intent generation failed, using deterministic fallback',
error,
);
return fallbackIntents;
}
}
const content = await requestChatMessageContent(
RUNTIME_ITEM_INTENT_SYSTEM_PROMPT,
buildRuntimeItemIntentPrompt(params),
const response = await requestJson<{
intents?: RuntimeItemAiIntent[];
}>(
'/api/runtime/items/runtime-intent',
{
timeoutMs: RUNTIME_ITEM_INTENT_TIMEOUT_MS,
debugLabel: 'runtime-item-intent',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
},
'运行时物品意图生成失败',
);
const parsed = parseJsonResponseText(content) as {
intents?: unknown[];
};
const rawIntents = Array.isArray(parsed.intents) ? parsed.intents : [];
return params.plans.map((_, index) =>
sanitizeRuntimeItemAiIntent(rawIntents[index], fallbackIntents[index]!),
);
return Array.isArray(response.intents) ? response.intents : [];
}

View File

@@ -18,6 +18,7 @@ import {
buildStoryMomentFromRuntimeOptions,
getRuntimeClientVersion,
getRuntimeSessionId,
getRuntimeStoryState,
isServerRuntimeFunctionId,
isTask5RuntimeFunctionId,
resolveRuntimeStoryAction,
@@ -75,6 +76,7 @@ describe('runtimeStoryService', () => {
optionText: '继续交谈',
},
},
snapshot: undefined,
}),
}),
'执行运行时动作失败',
@@ -129,6 +131,7 @@ describe('runtimeStoryService', () => {
itemId: 'focus-tonic',
},
},
snapshot: undefined,
}),
}),
'执行运行时动作失败',
@@ -136,6 +139,80 @@ describe('runtimeStoryService', () => {
);
});
it('submits runtime state resolution with snapshot context to the server', async () => {
requestJsonMock.mockResolvedValue({
sessionId: 'runtime-main',
serverVersion: 4,
viewModel: {
player: {
hp: 100,
maxHp: 100,
mana: 20,
maxMana: 20,
},
encounter: null,
companions: [],
availableOptions: [],
status: {
inBattle: false,
npcInteractionActive: false,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
},
},
presentation: {
actionText: '',
resultText: '',
storyText: '服务端故事',
options: [],
},
patches: [],
snapshot: {
version: 2,
savedAt: '2026-04-08T00:00:00.000Z',
bottomTab: 'adventure',
gameState: {},
currentStory: null,
},
});
await getRuntimeStoryState({
sessionId: 'runtime-main',
clientVersion: 7,
snapshot: {
gameState: { currentScene: 'Story' } as never,
bottomTab: 'adventure',
currentStory: {
text: '本地故事',
options: [],
} as never,
},
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/story/state/resolve',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
sessionId: 'runtime-main',
clientVersion: 7,
snapshot: {
gameState: {
currentScene: 'Story',
},
bottomTab: 'adventure',
currentStory: {
text: '本地故事',
options: [],
},
},
}),
}),
'读取运行时故事状态失败',
expect.any(Object),
);
});
it('keeps disabled runtime options when rebuilding a story moment', () => {
const story = buildStoryMomentFromRuntimeOptions({
storyText: '服务端返回的新故事',

View File

@@ -1,7 +1,9 @@
import type {
RuntimeStoryActionRequest,
RuntimeStoryActionResponse,
RuntimeStoryChoicePayload,
RuntimeStoryOptionView,
RuntimeStoryStateRequest,
ServerRuntimeFunctionId,
Task5RuntimeFunctionId,
} from '../../packages/shared/src/contracts/story';
@@ -44,6 +46,10 @@ export type RuntimeStoryResponse = RuntimeStoryActionResponse<
StoryMoment
>;
export type { RuntimeStoryChoicePayload };
export type RuntimeStorySnapshotRequest = RuntimeStoryStateRequest<
HydratedGameState,
StoryMoment
>['snapshot'];
function requestRuntimeStoryJson<T>(
path: string,
@@ -170,15 +176,35 @@ export function resolveRuntimeStoryMoment(params: {
}
export async function getRuntimeStoryState(
sessionId: string,
params: {
sessionId: string;
clientVersion?: number;
snapshot?: RuntimeStorySnapshotRequest;
},
options: RuntimeStoryServiceOptions = {},
) {
const response = await requestRuntimeStoryJson<RuntimeStoryResponse>(
`/state/${encodeURIComponent(sessionId || DEFAULT_SESSION_ID)}`,
{ method: 'GET' },
'读取运行时故事状态失败',
options,
);
const normalizedSessionId = params.sessionId || DEFAULT_SESSION_ID;
const response = params.snapshot
? await requestRuntimeStoryJson<RuntimeStoryResponse>(
'/state/resolve',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId: normalizedSessionId,
clientVersion: params.clientVersion,
snapshot: params.snapshot,
} satisfies RuntimeStoryStateRequest),
},
'读取运行时故事状态失败',
options,
)
: await requestRuntimeStoryJson<RuntimeStoryResponse>(
`/state/${encodeURIComponent(normalizedSessionId)}`,
{ method: 'GET' },
'读取运行时故事状态失败',
options,
);
return {
...response,
@@ -195,6 +221,7 @@ export async function resolveRuntimeStoryAction(
option: Pick<StoryOption, 'functionId' | 'actionText'>;
targetId?: string;
payload?: RuntimeStoryChoicePayload;
snapshot?: RuntimeStorySnapshotRequest;
},
options: RuntimeStoryServiceOptions = {},
) {
@@ -215,7 +242,8 @@ export async function resolveRuntimeStoryAction(
...(params.payload ?? {}),
},
},
}),
snapshot: params.snapshot,
} satisfies RuntimeStoryActionRequest),
},
'执行运行时动作失败',
options,