创作数据流程收束

This commit is contained in:
2026-04-21 09:44:17 +08:00
parent effe0355bd
commit 3614e1f5a2
93 changed files with 1794 additions and 8651 deletions

View File

@@ -10,11 +10,7 @@ import type {
SendCustomWorldAgentMessageRequest,
} from '../../packages/shared/src/contracts/customWorldAgent';
import type {
AnswerCustomWorldSessionQuestionRequest,
CreateCustomWorldSessionRequest,
CustomWorldGenerationProgress,
CustomWorldSessionRecord,
CustomWorldSessionSummary,
GenerateCustomWorldProfileInput,
GenerateCustomWorldProfileOptions,
} from '../../packages/shared/src/contracts/runtime';
@@ -357,190 +353,8 @@ export async function generateCustomWorldProfile(
input: GenerateCustomWorldProfileInput | string,
options: GenerateCustomWorldProfileOptions = {},
): Promise<CustomWorldProfile> {
const normalizedInput =
typeof input === 'string'
? {
settingText: input,
creatorIntent: null,
generationMode: 'full' as const,
}
: {
settingText: input.settingText,
creatorIntent: input.creatorIntent ?? null,
generationMode:
input.generationMode === 'fast'
? ('fast' as const)
: ('full' as const),
};
const session = await createCustomWorldSession({
settingText: normalizedInput.settingText,
creatorIntent: normalizedInput.creatorIntent as Record<
string,
unknown
> | null,
generationMode: normalizedInput.generationMode,
});
const fallbackAnswerMap: Record<string, string> = {
world_hook:
typeof normalizedInput.creatorIntent?.worldHook === 'string' &&
normalizedInput.creatorIntent.worldHook.trim()
? normalizedInput.creatorIntent.worldHook.trim()
: normalizedInput.settingText.trim().slice(0, 120) ||
'这是一个围绕失衡秩序展开的世界。',
player_premise:
typeof normalizedInput.creatorIntent?.playerPremise === 'string' &&
normalizedInput.creatorIntent.playerPremise.trim()
? normalizedInput.creatorIntent.playerPremise.trim()
: '玩家是一名被卷入局势中心的行动者。',
opening_situation:
typeof normalizedInput.creatorIntent?.openingSituation === 'string' &&
normalizedInput.creatorIntent.openingSituation.trim()
? normalizedInput.creatorIntent.openingSituation.trim()
: '故事开局时,玩家正身处风暴边缘,必须立刻判断立场与风险。',
core_conflict:
Array.isArray(normalizedInput.creatorIntent?.coreConflicts) &&
normalizedInput.creatorIntent.coreConflicts.length > 0
? normalizedInput.creatorIntent.coreConflicts
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter(Boolean)
.join('')
: '旧秩序与新威胁正在同时逼近,各方都在争夺主动权。',
};
for (const question of session.questions ?? []) {
if (question.answer?.trim()) {
continue;
}
const answer =
fallbackAnswerMap[question.id] || normalizedInput.settingText.trim();
await answerCustomWorldSessionQuestion(session.sessionId, {
questionId: question.id,
answer,
});
}
return streamCustomWorldSessionGeneration(session.sessionId, options);
}
export async function streamCustomWorldSessionGeneration(
sessionId: string,
options: GenerateCustomWorldProfileOptions = {},
): Promise<CustomWorldProfile> {
const response = await fetchWithApiAuth(
`${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}/generate/stream`,
{
method: 'GET',
signal: options.signal,
},
);
if (!response.ok) {
const responseText = await response.text();
throw new Error(parseApiErrorMessage(responseText, '生成自定义世界失败'));
}
if (!response.body) {
throw new Error('自定义世界生成流不可用');
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let latestProfile: Record<string, unknown> | null = null;
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
while (buffer.includes('\n\n')) {
const boundary = buffer.indexOf('\n\n');
const eventBlock = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
let eventName = '';
for (const rawLine of eventBlock.split(/\r?\n/u)) {
const line = rawLine.trim();
if (!line) {
continue;
}
if (line.startsWith('event:')) {
eventName = line.slice(6).trim();
continue;
}
if (!line.startsWith('data:')) {
continue;
}
const payloadText = line.slice(5).trim();
if (!payloadText) {
continue;
}
const payload = JSON.parse(payloadText) as Record<string, unknown>;
if (eventName === 'progress') {
if (
typeof payload.phaseId === 'string' &&
typeof payload.phaseLabel === 'string' &&
typeof payload.phaseDetail === 'string' &&
typeof payload.overallProgress === 'number' &&
Array.isArray(payload.steps)
) {
options.onProgress?.(
payload as unknown as CustomWorldGenerationProgress,
);
} else {
options.onProgress?.({
phaseId: 'finalize',
phaseLabel:
typeof payload.phase === 'string'
? payload.phase
: 'generating',
phaseDetail:
typeof payload.phase === 'string'
? payload.phase
: 'generating',
overallProgress:
typeof payload.progress === 'number'
? payload.progress / 100
: 0,
completedWeight:
typeof payload.progress === 'number' ? payload.progress : 0,
totalWeight: 100,
elapsedMs: 0,
estimatedRemainingMs: null,
activeStepIndex: 0,
steps: [],
});
}
}
if (
eventName === 'result' &&
payload.profile &&
typeof payload.profile === 'object'
) {
latestProfile = payload.profile as Record<string, unknown>;
}
if (eventName === 'error') {
throw new Error(
typeof payload.message === 'string'
? payload.message
: '生成自定义世界失败',
);
}
}
}
}
if (!latestProfile) {
throw new Error('自定义世界生成未返回结果');
}
return latestProfile as unknown as CustomWorldProfile;
const aiClient = await loadLegacyAiModule();
return aiClient.generateCustomWorldProfile(input, options);
}
export async function generateCustomWorldSceneImage(
@@ -625,22 +439,6 @@ export async function generateCustomWorldLandmark(payload: {
return response.entity;
}
export async function createCustomWorldSession(payload: {
settingText: string;
creatorIntent?: Record<string, unknown> | null;
generationMode: 'fast' | 'full';
}) {
return requestJson<CustomWorldSessionSummary>(
`${RUNTIME_API_BASE}/custom-world/sessions`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload satisfies CreateCustomWorldSessionRequest),
},
'创建自定义世界会话失败',
);
}
export async function listCustomWorldWorks() {
const response = await requestJson<ListCustomWorldWorksResponse>(
`${RUNTIME_API_BASE}/custom-world/works`,
@@ -842,33 +640,6 @@ export async function getCustomWorldAgentCardDetail(
return response.card as CustomWorldDraftCardDetail;
}
export async function getCustomWorldSession(sessionId: string) {
return requestJson<CustomWorldSessionRecord>(
`${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}`,
{
method: 'GET',
},
'读取自定义世界会话失败',
);
}
export async function answerCustomWorldSessionQuestion(
sessionId: string,
payload: { questionId: string; answer: string },
) {
return requestJson<CustomWorldSessionSummary>(
`${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}/answers`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(
payload satisfies AnswerCustomWorldSessionQuestionRequest,
),
},
'提交自定义世界补充设定失败',
);
}
export async function streamCharacterPanelChatReply(
world: WorldType,
playerCharacter: Character,

View File

@@ -9,8 +9,6 @@ import {
} from '../../packages/shared/src/http';
const ACCESS_TOKEN_KEY = 'genarrative.auth.access-token.v1';
const AUTO_AUTH_USERNAME_KEY = 'genarrative.auth.auto-username.v1';
const AUTO_AUTH_PASSWORD_KEY = 'genarrative.auth.auto-password.v1';
export const AUTH_STATE_EVENT = 'genarrative-auth-state-changed';
const REQUEST_ID_HEADER = 'x-request-id';
const API_VERSION_HEADER = 'x-api-version';
@@ -376,46 +374,6 @@ export function clearStoredAccessToken(
}
}
export function getStoredAutoAuthCredentials() {
if (!canUseLocalStorage()) {
return null;
}
const username = window.localStorage.getItem(AUTO_AUTH_USERNAME_KEY)?.trim() || '';
const password = window.localStorage.getItem(AUTO_AUTH_PASSWORD_KEY)?.trim() || '';
if (!username || !password) {
return null;
}
return {
username,
password,
};
}
export function setStoredAutoAuthCredentials(credentials: {
username: string;
password: string;
}) {
if (!canUseLocalStorage()) {
return;
}
window.localStorage.setItem(AUTO_AUTH_USERNAME_KEY, credentials.username.trim());
window.localStorage.setItem(AUTO_AUTH_PASSWORD_KEY, credentials.password.trim());
}
export function clearStoredAutoAuthCredentials() {
if (!canUseLocalStorage()) {
return;
}
window.localStorage.removeItem(AUTO_AUTH_USERNAME_KEY);
window.localStorage.removeItem(AUTO_AUTH_PASSWORD_KEY);
emitAuthStateChange();
}
function withAuthorizationHeaders(
headers?: HeadersInit,
options: Pick<ApiRequestOptions, 'omitEnvelopeHeader' | 'skipAuth'> = {},

View File

@@ -7,9 +7,7 @@ const { requestJsonMock } = vi.hoisted(() => ({
import {
ApiClientError,
clearStoredAccessToken,
clearStoredAutoAuthCredentials,
getStoredAccessToken,
getStoredAutoAuthCredentials,
setStoredAccessToken,
} from './apiClient';
import {
@@ -67,7 +65,6 @@ describe('authService auto auth', () => {
});
requestJsonMock.mockReset();
clearStoredAccessToken();
clearStoredAutoAuthCredentials();
});
it('creates credentials that match current username/password constraints', () => {
@@ -78,7 +75,7 @@ describe('authService auto auth', () => {
expect(credentials.password.length).toBeGreaterThanOrEqual(6);
});
it('stores jwt and auto credentials after auth entry', async () => {
it('stores jwt after auth entry without persisting guest credentials locally', async () => {
requestJsonMock.mockResolvedValue({
token: 'jwt-token-value',
user: {
@@ -99,10 +96,6 @@ describe('authService auto auth', () => {
expect(user.username).toBe('guest_abc123abc123');
expect(getStoredAccessToken()).toBe('jwt-token-value');
expect(getStoredAutoAuthCredentials()).toEqual({
username: 'guest_abc123abc123',
password: 'auto_secret_password',
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/auth/entry',
expect.objectContaining({
@@ -115,9 +108,7 @@ describe('authService auto auth', () => {
);
});
it('reuses stored auto credentials before generating a new account', async () => {
window.localStorage.setItem('genarrative.auth.auto-username.v1', 'guest_saveduser01');
window.localStorage.setItem('genarrative.auth.auto-password.v1', 'auto_saved_password');
it('creates a fresh guest credential pair for auto auth when a session is missing', async () => {
requestJsonMock.mockResolvedValue({
token: 'jwt-restored',
user: {
@@ -132,16 +123,24 @@ describe('authService auto auth', () => {
});
const result = await ensureAutoAuthUser();
const authEntryBody = JSON.parse(
requestJsonMock.mock.calls[0]?.[1]?.body as string,
) as {
username: string;
password: string;
};
expect(result.user.username).toBe('guest_saveduser01');
expect(result.credentials).toEqual({
username: 'guest_saveduser01',
password: 'auto_saved_password',
});
expect(result.credentials.username).toMatch(/^guest_[a-z0-9]{12}$/u);
expect(result.credentials.password).toMatch(
/^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),
}),
'登录失败',
);
@@ -168,7 +167,13 @@ describe('authService auto auth', () => {
expect(requestJsonMock).toHaveBeenCalledTimes(1);
expect(firstResult).toEqual(secondResult);
expect(getStoredAutoAuthCredentials()).toEqual(firstResult.credentials);
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 () => {

View File

@@ -24,11 +24,8 @@ import type {
import {
ApiClientError,
clearStoredAccessToken,
clearStoredAutoAuthCredentials,
getStoredAutoAuthCredentials,
requestJson,
setStoredAccessToken,
setStoredAutoAuthCredentials,
} from './apiClient';
export type { AuthUser } from '../../packages/shared/src/contracts/auth';
@@ -121,7 +118,6 @@ export function createAutoAuthCredentials(): AutoAuthCredentials {
export function clearAuthSession() {
clearStoredAccessToken();
clearStoredAutoAuthCredentials();
}
export async function sendPhoneLoginCode(
@@ -249,14 +245,12 @@ export async function authEntryWithStoredCredentials(
normalizedCredentials.username,
normalizedCredentials.password,
);
setStoredAutoAuthCredentials(normalizedCredentials);
return user;
}
export async function ensureAutoAuthUser() {
pendingAutoAuthUser ??= (async () => {
const credentials =
getStoredAutoAuthCredentials() ?? createAutoAuthCredentials();
const credentials = createAutoAuthCredentials();
const user = await authEntryWithStoredCredentials(credentials);
return {

View File

@@ -1,54 +0,0 @@
import { WorldType } from '../types';
const ATTRIBUTE_LABELS = {
strength: 'Strength',
agility: 'Agility',
intelligence: 'Intelligence',
spirit: 'Spirit',
} as const;
const RESOURCE_LABELS = {
hp: 'HP',
mp: 'MP',
maxHp: '生命上限',
maxMp: '灵力上限',
damage: 'Damage',
guard: 'Guard',
range: 'Range',
cooldown: 'Cooldown',
manaCost: '灵力消耗',
} as const;
export function buildThemedSkillName(_profile: unknown, style: string, index = 0) {
return `${style || 'skill'}-${index + 1}`;
}
export function buildCustomCampSceneName(profile: { name?: string; camp?: { name?: string | null } | null } | null | undefined) {
return profile?.camp?.name?.trim() || (profile?.name ? `${profile.name}归舍` : '归舍');
}
export function getAttributeLabelsForWorld(_worldType: WorldType | null) {
return ATTRIBUTE_LABELS;
}
export function getResourceLabelsForWorld(_worldType: WorldType | null) {
return RESOURCE_LABELS;
}
export function buildThemedItemName(_profile: unknown, category: string, rarity: string, seedKey: string) {
return `${category}-${rarity}-${seedKey}`;
}
export function buildThemedItemDescription(_profile: unknown, category: string, rarity: string, seedKey: string) {
return `${category}-${rarity}-${seedKey} description`;
}
export function inferCustomItemMechanics() {
return {
tags: [],
equipmentSlotId: null,
statProfile: null,
useProfile: null,
value: 0,
};
}

View File

@@ -1,6 +0,0 @@
export function getTypewriterDelay(char: string) {
if (/[?!]/u.test(char)) return 240;
if (/[,;:]/u.test(char)) return 150;
if (/\s/u.test(char)) return 45;
return 90;
}