131
src/services/customWorldAgentGenerationProgress.test.ts
Normal file
131
src/services/customWorldAgentGenerationProgress.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type {
|
||||
CustomWorldAgentOperationRecord,
|
||||
CustomWorldAgentSessionSnapshot,
|
||||
} from '../../packages/shared/src/contracts/customWorldAgent';
|
||||
import {
|
||||
buildAgentDraftFoundationGenerationProgress,
|
||||
buildAgentDraftFoundationSettingText,
|
||||
isDraftFoundationOperationRunning,
|
||||
} from './customWorldAgentGenerationProgress';
|
||||
|
||||
const baseOperation: CustomWorldAgentOperationRecord = {
|
||||
operationId: 'operation-1',
|
||||
type: 'draft_foundation',
|
||||
status: 'running',
|
||||
phaseLabel: '生成世界底稿',
|
||||
phaseDetail: '正在根据已确认锚点编译第一版世界结构。',
|
||||
progress: 38,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const baseSession: CustomWorldAgentSessionSnapshot = {
|
||||
sessionId: 'session-1',
|
||||
stage: 'foundation_review',
|
||||
focusCardId: null,
|
||||
creatorIntent: {
|
||||
sourceMode: 'card',
|
||||
worldHook: '海雾、旧灯塔和失控航路交织的边缘群岛',
|
||||
themeKeywords: ['海雾', '灯塔', '旧航路'],
|
||||
toneDirectives: ['压抑', '悬疑'],
|
||||
playerPremise: '玩家刚回到群岛,准备调查父亲沉船的真相。',
|
||||
openingSituation: '首夜就有陌生船只在禁航区点灯。',
|
||||
coreConflicts: ['航运公会与守灯会争夺航路控制权'],
|
||||
keyFactions: [],
|
||||
keyCharacters: [],
|
||||
keyLandmarks: [],
|
||||
iconicElements: ['会移动的海雾'],
|
||||
forbiddenDirectives: [],
|
||||
rawSettingText: '',
|
||||
},
|
||||
creatorIntentReadiness: {
|
||||
isReady: true,
|
||||
completedKeys: [],
|
||||
missingKeys: [],
|
||||
},
|
||||
anchorPack: null,
|
||||
lockState: null,
|
||||
draftProfile: null,
|
||||
messages: [
|
||||
{
|
||||
id: 'message-1',
|
||||
role: 'user',
|
||||
kind: 'chat',
|
||||
text: '我想做一个被海雾吞没的旧航路世界。',
|
||||
createdAt: '2026-04-14T10:00:00.000Z',
|
||||
relatedOperationId: null,
|
||||
},
|
||||
],
|
||||
draftCards: [],
|
||||
pendingClarifications: [],
|
||||
suggestedActions: [],
|
||||
recommendedReplies: [],
|
||||
qualityFindings: [],
|
||||
assetCoverage: {
|
||||
roleAssets: [],
|
||||
sceneAssets: [],
|
||||
allRoleAssetsReady: false,
|
||||
allSceneAssetsReady: false,
|
||||
},
|
||||
updatedAt: '2026-04-14T10:00:00.000Z',
|
||||
};
|
||||
|
||||
test('maps running draft_foundation operation to legacy generation progress', () => {
|
||||
const progress = buildAgentDraftFoundationGenerationProgress(
|
||||
baseOperation,
|
||||
1_000,
|
||||
5_000,
|
||||
);
|
||||
|
||||
expect(progress).not.toBeNull();
|
||||
expect(progress?.phaseId).toBe('foundation');
|
||||
expect(progress?.batchLabel).toBe('生成世界底稿');
|
||||
expect(progress?.overallProgress).toBe(38);
|
||||
expect(progress?.elapsedMs).toBe(4_000);
|
||||
expect(progress?.estimatedRemainingMs).toBeGreaterThan(0);
|
||||
expect(progress?.steps.map((step) => step.status)).toEqual([
|
||||
'completed',
|
||||
'active',
|
||||
'pending',
|
||||
'pending',
|
||||
]);
|
||||
expect(isDraftFoundationOperationRunning(baseOperation)).toBe(true);
|
||||
});
|
||||
|
||||
test('marks all legacy progress steps complete when draft foundation finishes', () => {
|
||||
const progress = buildAgentDraftFoundationGenerationProgress(
|
||||
{
|
||||
...baseOperation,
|
||||
status: 'completed',
|
||||
phaseLabel: '世界底稿已生成',
|
||||
phaseDetail: '第一版世界底稿和 6 张草稿卡已经整理完成。',
|
||||
progress: 100,
|
||||
},
|
||||
1_000,
|
||||
5_000,
|
||||
);
|
||||
|
||||
expect(progress?.phaseId).toBe('workspace');
|
||||
expect(progress?.estimatedRemainingMs).toBe(0);
|
||||
expect(progress?.steps.every((step) => step.status === 'completed')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('builds readable draft setting text from creator intent first', () => {
|
||||
const settingText = buildAgentDraftFoundationSettingText(baseSession);
|
||||
|
||||
expect(settingText).toContain('世界核心');
|
||||
expect(settingText).toContain('玩家开局');
|
||||
expect(settingText).toContain('标志元素');
|
||||
});
|
||||
|
||||
test('falls back to latest user message when creator intent is unavailable', () => {
|
||||
const settingText = buildAgentDraftFoundationSettingText({
|
||||
...baseSession,
|
||||
creatorIntent: null,
|
||||
});
|
||||
|
||||
expect(settingText).toBe('我想做一个被海雾吞没的旧航路世界。');
|
||||
});
|
||||
210
src/services/customWorldAgentGenerationProgress.ts
Normal file
210
src/services/customWorldAgentGenerationProgress.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import type {
|
||||
CustomWorldAgentOperationRecord,
|
||||
CustomWorldAgentSessionSnapshot,
|
||||
} from '../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type {
|
||||
CustomWorldGenerationProgress,
|
||||
CustomWorldGenerationStep,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
buildCustomWorldCreatorIntentDisplayText,
|
||||
buildCustomWorldCreatorIntentGenerationText,
|
||||
normalizeCustomWorldCreatorIntent,
|
||||
} from './customWorldCreatorIntent';
|
||||
|
||||
const AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS = [
|
||||
{
|
||||
id: 'queue',
|
||||
label: '接收生成请求',
|
||||
detail: '正在锁定当前已确认的世界锚点与草稿范围。',
|
||||
},
|
||||
{
|
||||
id: 'foundation',
|
||||
label: '生成世界底稿',
|
||||
detail: '正在根据世界核心、关系种子与冲突线编排第一版世界结构。',
|
||||
},
|
||||
{
|
||||
id: 'cards',
|
||||
label: '编译草稿卡',
|
||||
detail: '正在整理世界卡、角色卡与地点卡的摘要和详情。',
|
||||
},
|
||||
{
|
||||
id: 'workspace',
|
||||
label: '准备精修工作区',
|
||||
detail: '正在写回草稿数据,并切回可继续精修的工作区。',
|
||||
},
|
||||
] as const satisfies ReadonlyArray<{
|
||||
id: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
}>;
|
||||
|
||||
function clampProgress(progress: number | null | undefined) {
|
||||
if (typeof progress !== 'number' || Number.isNaN(progress)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(100, Math.round(progress)));
|
||||
}
|
||||
|
||||
function resolveAgentDraftFoundationStepIndex(
|
||||
operation: CustomWorldAgentOperationRecord,
|
||||
) {
|
||||
const progress = clampProgress(operation.progress);
|
||||
const phaseLabel = operation.phaseLabel.trim();
|
||||
|
||||
if (
|
||||
operation.status === 'completed' ||
|
||||
phaseLabel.includes('世界底稿已生成') ||
|
||||
progress >= 90
|
||||
) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
if (phaseLabel.includes('编译草稿卡') || progress >= 60) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (phaseLabel.includes('生成世界底稿') || progress >= 25) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function buildAgentDraftFoundationSteps(
|
||||
operation: CustomWorldAgentOperationRecord,
|
||||
activeStepIndex: number,
|
||||
) {
|
||||
return AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS.map((step, index) => {
|
||||
const isCompleted =
|
||||
operation.status === 'completed' || index < activeStepIndex;
|
||||
const isActive = !isCompleted && index === activeStepIndex;
|
||||
|
||||
return {
|
||||
id: step.id,
|
||||
label: step.label,
|
||||
detail: step.detail,
|
||||
completed: isCompleted ? 1 : 0,
|
||||
total: 1,
|
||||
status: isCompleted
|
||||
? 'completed'
|
||||
: isActive
|
||||
? 'active'
|
||||
: 'pending',
|
||||
} satisfies CustomWorldGenerationStep;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveEstimatedRemainingMs(
|
||||
progress: number,
|
||||
startedAtMs: number | null,
|
||||
nowMs: number,
|
||||
status: CustomWorldAgentOperationRecord['status'],
|
||||
) {
|
||||
if (status === 'completed') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!startedAtMs || progress <= 0 || progress >= 100) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const elapsedMs = Math.max(0, nowMs - startedAtMs);
|
||||
const progressFraction = progress / 100;
|
||||
|
||||
return Math.max(
|
||||
0,
|
||||
Math.round(elapsedMs / progressFraction - elapsedMs),
|
||||
);
|
||||
}
|
||||
|
||||
export function isDraftFoundationOperation(
|
||||
operation: CustomWorldAgentOperationRecord | null | undefined,
|
||||
): operation is CustomWorldAgentOperationRecord {
|
||||
return Boolean(operation && operation.type === 'draft_foundation');
|
||||
}
|
||||
|
||||
export function isDraftFoundationOperationRunning(
|
||||
operation: CustomWorldAgentOperationRecord | null | undefined,
|
||||
) {
|
||||
return (
|
||||
isDraftFoundationOperation(operation) &&
|
||||
(operation.status === 'queued' || operation.status === 'running')
|
||||
);
|
||||
}
|
||||
|
||||
export function buildAgentDraftFoundationGenerationProgress(
|
||||
operation: CustomWorldAgentOperationRecord | null | undefined,
|
||||
startedAtMs: number | null,
|
||||
nowMs = Date.now(),
|
||||
): CustomWorldGenerationProgress | null {
|
||||
if (!isDraftFoundationOperation(operation)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const overallProgress = clampProgress(operation.progress);
|
||||
const activeStepIndex = resolveAgentDraftFoundationStepIndex(operation);
|
||||
const elapsedMs = startedAtMs ? Math.max(0, nowMs - startedAtMs) : 0;
|
||||
const estimatedRemainingMs = resolveEstimatedRemainingMs(
|
||||
overallProgress,
|
||||
startedAtMs,
|
||||
nowMs,
|
||||
operation.status,
|
||||
);
|
||||
const activeStep =
|
||||
AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[activeStepIndex] ??
|
||||
AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[0];
|
||||
|
||||
return {
|
||||
phaseId: activeStep.id,
|
||||
phaseLabel: operation.phaseLabel || activeStep.label,
|
||||
phaseDetail: operation.phaseDetail || activeStep.detail,
|
||||
batchLabel: activeStep.label,
|
||||
overallProgress,
|
||||
completedWeight: overallProgress,
|
||||
totalWeight: 100,
|
||||
elapsedMs,
|
||||
estimatedRemainingMs,
|
||||
activeStepIndex,
|
||||
steps: buildAgentDraftFoundationSteps(operation, activeStepIndex),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAgentDraftFoundationSettingText(
|
||||
session: CustomWorldAgentSessionSnapshot | null | undefined,
|
||||
) {
|
||||
if (!session) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const creatorIntent = normalizeCustomWorldCreatorIntent(
|
||||
session.creatorIntent,
|
||||
'freeform',
|
||||
);
|
||||
|
||||
if (creatorIntent) {
|
||||
const displayText =
|
||||
buildCustomWorldCreatorIntentDisplayText(creatorIntent).trim();
|
||||
const generationText =
|
||||
buildCustomWorldCreatorIntentGenerationText(creatorIntent).trim();
|
||||
|
||||
if (displayText) {
|
||||
return displayText;
|
||||
}
|
||||
|
||||
if (generationText) {
|
||||
return generationText;
|
||||
}
|
||||
|
||||
if (creatorIntent.rawSettingText.trim()) {
|
||||
return creatorIntent.rawSettingText.trim();
|
||||
}
|
||||
}
|
||||
|
||||
const latestUserMessage = [...session.messages]
|
||||
.reverse()
|
||||
.find((message) => message.role === 'user' && message.text.trim());
|
||||
|
||||
return latestUserMessage?.text.trim() ?? '正在整理当前共创设定。';
|
||||
}
|
||||
Reference in New Issue
Block a user