Files
Genarrative/src/services/customWorldAgentGenerationProgress.ts
kdletters cbc27bad4a
Some checks failed
CI / verify (push) Has been cancelled
init with react+axum+spacetimedb
2026-04-26 18:06:23 +08:00

503 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
CustomWorldAgentOperationRecord,
CustomWorldAgentSessionSnapshot,
EightAnchorContent,
} from '../../packages/shared/src/contracts/customWorldAgent';
import type {
CustomWorldGenerationProgress,
CustomWorldGenerationStep,
} from '../../packages/shared/src/contracts/runtime';
import {
buildCustomWorldCreatorIntentFoundationText,
normalizeCustomWorldCreatorIntent,
} from './customWorldCreatorIntent';
export type CustomWorldStructuredAnchorEntry = {
id: string;
label: string;
value: string;
};
function normalizeAnchorText(value: string | null | undefined) {
return typeof value === 'string' ? value.trim() : '';
}
function buildEightAnchorFoundationText(anchorContent: EightAnchorContent) {
return [
['世界承诺', anchorContent.worldPromise],
['玩家幻想', anchorContent.playerFantasy],
['主题边界', anchorContent.themeBoundary],
['玩家切入口', anchorContent.playerEntryPoint],
['核心冲突', anchorContent.coreConflict],
['关键关系', anchorContent.keyRelationships],
['暗线与揭示', anchorContent.hiddenLines],
['标志元素', anchorContent.iconicElements],
]
.map(([label, value]) => {
const text = normalizeAnchorText(value);
return text ? `${label}${text}` : '';
})
.filter((line) => line)
.join('\n');
}
export function buildAgentDraftFoundationAnchorEntries(
session: CustomWorldAgentSessionSnapshot | null | undefined,
): CustomWorldStructuredAnchorEntry[] {
if (!session) {
return [];
}
const anchorContent = session.anchorContent;
return [
{
id: 'world-promise',
label: '世界承诺',
value: normalizeAnchorText(anchorContent.worldPromise),
},
{
id: 'player-fantasy',
label: '玩家幻想',
value: normalizeAnchorText(anchorContent.playerFantasy),
},
{
id: 'theme-boundary',
label: '主题边界',
value: normalizeAnchorText(anchorContent.themeBoundary),
},
{
id: 'player-entry-point',
label: '玩家切入口',
value: normalizeAnchorText(anchorContent.playerEntryPoint),
},
{
id: 'core-conflict',
label: '核心冲突',
value: normalizeAnchorText(anchorContent.coreConflict),
},
{
id: 'key-relationships',
label: '关键关系',
value: normalizeAnchorText(anchorContent.keyRelationships),
},
{
id: 'hidden-lines',
label: '暗线与揭示',
value: normalizeAnchorText(anchorContent.hiddenLines),
},
{
id: 'iconic-elements',
label: '标志元素',
value: normalizeAnchorText(anchorContent.iconicElements),
},
].filter((entry) => entry.value.trim());
}
type AgentDraftFoundationStepDefinition = {
id: string;
label: string;
detail: string;
matchers: string[];
minProgress: number;
expectedDurationMs: number;
};
type AgentDraftFoundationFailedStep = {
id: string;
label: string;
detail: string;
};
// 这里按真实服务端 phaseLabel 归并步骤,避免把草稿生成硬折成 4 个失真的阶段。
const AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS = [
{
id: 'queue',
label: '接收生成请求',
detail: '正在校验当前锚点并准备底稿编译链路。',
matchers: ['已接收请求'],
minProgress: 0,
expectedDurationMs: 3_000,
},
{
id: 'framework',
label: '整理世界骨架',
detail: '正在生成第一版世界框架、主题与核心冲突。',
matchers: ['整理世界骨架', '生成世界底稿'],
minProgress: 12,
expectedDurationMs: 25_000,
},
{
id: 'playable-outline',
label: '生成可扮演角色',
detail: '正在补出玩家视角角色的首轮名单与定位。',
matchers: ['生成可扮演角色'],
minProgress: 16,
expectedDurationMs: 18_000,
},
{
id: 'story-outline',
label: '生成场景角色',
detail: '正在整理关键 NPC、势力接口人与关系入口。',
matchers: ['生成场景角色'],
minProgress: 30,
expectedDurationMs: 45_000,
},
{
id: 'landmark-seed',
label: '生成关键场景',
detail: '正在补出关键场景、幕 NPC 与地点连接。',
matchers: ['生成关键场景'],
minProgress: 44,
expectedDurationMs: 36_000,
},
{
id: 'playable-detail',
label: '补全可扮演角色细节',
detail: '正在补全可扮演角色的叙事基础与档案细节。',
matchers: ['补全可扮演角色'],
minProgress: 66,
expectedDurationMs: 32_000,
},
{
id: 'story-detail',
label: '补全场景角色细节',
detail: '正在补全场景角色的叙事基础与档案细节。',
matchers: ['补全场景角色'],
minProgress: 84,
expectedDurationMs: 65_000,
},
{
id: 'finalize',
label: '编译世界底稿',
detail: '正在把分批生成结果汇总成第一版可浏览的世界底稿。',
matchers: ['编译世界底稿'],
minProgress: 97,
expectedDurationMs: 6_000,
},
{
id: 'role-visuals',
label: '生成角色主形象',
detail: '正在为关键角色补主形象预览资源。',
matchers: ['生成角色主形象'],
minProgress: 97,
expectedDurationMs: 85_000,
},
{
id: 'act-backgrounds',
label: '生成幕背景图',
detail: '正在为场景章节的每一幕补背景图预览资源。',
matchers: ['生成幕背景图'],
minProgress: 98,
expectedDurationMs: 85_000,
},
{
id: 'cards',
label: '编译草稿卡',
detail: '正在整理世界卡、角色卡、地点卡与详情结构。',
matchers: ['编译草稿卡'],
minProgress: 99,
expectedDurationMs: 15_000,
},
{
id: 'workspace',
label: '准备结果页',
detail: '正在写回草稿数据,并打开可继续完善的结果页。',
matchers: ['世界底稿已生成'],
minProgress: 100,
expectedDurationMs: 4_000,
},
] as const satisfies ReadonlyArray<AgentDraftFoundationStepDefinition>;
const AGENT_DRAFT_FOUNDATION_FAILED_STEP = {
id: 'failed',
label: '生成失败',
detail: '这一轮世界草稿没有编译完成,可以返回工作区补充设定后重试。',
} as const satisfies AgentDraftFoundationFailedStep;
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 resolveAgentDraftFoundationStepIndexByProgress(progress: number) {
let matchedIndex = 0;
for (
let index = 0;
index < AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS.length - 1;
index += 1
) {
const step = AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[index];
if (step && progress >= step.minProgress) {
matchedIndex = index;
}
}
return matchedIndex;
}
function resolveFailedProgress(
operation: CustomWorldAgentOperationRecord,
activeStepIndex: number,
) {
const progress = clampProgress(operation.progress);
if (operation.status !== 'failed') {
return progress;
}
if (progress < 100) {
return progress;
}
const activeStep =
AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[activeStepIndex] ??
AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[0];
return Math.max(0, Math.min(99, activeStep.minProgress));
}
function parseOperationUpdatedAtMs(
operation: CustomWorldAgentOperationRecord,
) {
const rawUpdatedAt = operation.updatedAt?.trim();
if (!rawUpdatedAt) {
return null;
}
const parsedMs = Date.parse(rawUpdatedAt);
return Number.isFinite(parsedMs) ? parsedMs : null;
}
function parseOperationStartedAtMs(
operation: CustomWorldAgentOperationRecord,
) {
const rawStartedAt = operation.startedAt?.trim();
if (!rawStartedAt) {
return null;
}
const parsedMs = Date.parse(rawStartedAt);
return Number.isFinite(parsedMs) ? parsedMs : null;
}
function resolveAgentDraftFoundationStepIndex(
operation: CustomWorldAgentOperationRecord,
) {
const progress = clampProgress(operation.progress);
const phaseLabel = operation.phaseLabel.trim();
if (operation.status === 'completed' || phaseLabel.includes('世界底稿已生成')) {
return AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS.length - 1;
}
for (
let index = AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS.length - 2;
index >= 0;
index -= 1
) {
const step = AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[index];
if (step?.matchers.some((matcher) => phaseLabel.includes(matcher))) {
return index;
}
}
return resolveAgentDraftFoundationStepIndexByProgress(progress);
}
function resolveAgentDraftFoundationFailedStep(
operation: CustomWorldAgentOperationRecord,
) {
if (operation.status !== 'failed') {
return null;
}
const phaseLabel = operation.phaseLabel.trim();
const phaseDetail = operation.phaseDetail.trim();
const error = operation.error?.trim() ?? '';
return {
id: AGENT_DRAFT_FOUNDATION_FAILED_STEP.id,
label:
phaseLabel ||
error ||
AGENT_DRAFT_FOUNDATION_FAILED_STEP.label,
detail:
phaseDetail ||
error ||
AGENT_DRAFT_FOUNDATION_FAILED_STEP.detail,
} satisfies AgentDraftFoundationFailedStep;
}
function buildAgentDraftFoundationSteps(
operation: CustomWorldAgentOperationRecord,
activeStepIndex: number,
) {
return AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS.map((step, index) => {
const isCompleted =
operation.status === 'completed' ||
(operation.status === 'failed'
? index < activeStepIndex
: index < activeStepIndex);
const isActive =
operation.status !== 'failed' && !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'],
activeStepIndex: number,
operationUpdatedAtMs: number | null,
) {
if (status === 'completed') {
return 0;
}
if (status === 'failed' || progress >= 100) {
return null;
}
const activeStep =
AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[activeStepIndex] ??
AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[0];
const nextStep =
AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[activeStepIndex + 1] ??
activeStep;
const phaseProgressRange = Math.max(
1,
nextStep.minProgress - activeStep.minProgress,
);
const phaseProgressRatio = Math.max(
0,
Math.min(0.95, (progress - activeStep.minProgress) / phaseProgressRange),
);
const phaseStartedAtMs = operationUpdatedAtMs ?? startedAtMs;
const currentPhaseElapsedMs = phaseStartedAtMs
? Math.max(0, nowMs - phaseStartedAtMs)
: 0;
const currentPhaseRemainingMs = Math.max(
0,
Math.round(
activeStep.expectedDurationMs * (1 - phaseProgressRatio) -
currentPhaseElapsedMs,
),
);
const followingStepsRemainingMs = AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS.slice(
activeStepIndex + 1,
).reduce((sum, step) => sum + step.expectedDurationMs, 0);
return currentPhaseRemainingMs + followingStepsRemainingMs;
}
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,
fallbackStartedAtMs: number | null,
nowMs = Date.now(),
): CustomWorldGenerationProgress | null {
if (!isDraftFoundationOperation(operation)) {
return null;
}
const activeStepIndex = resolveAgentDraftFoundationStepIndex(operation);
const overallProgress = resolveFailedProgress(operation, activeStepIndex);
// 中文注释:总耗时必须绑定服务端 operation 创建时间,避免刷新或前端重挂载后重新计时。
const startedAtMs = parseOperationStartedAtMs(operation) ?? fallbackStartedAtMs;
const elapsedMs = startedAtMs ? Math.max(0, nowMs - startedAtMs) : 0;
const estimatedRemainingMs = resolveEstimatedRemainingMs(
overallProgress,
startedAtMs,
nowMs,
operation.status,
activeStepIndex,
parseOperationUpdatedAtMs(operation),
);
const failedStep = resolveAgentDraftFoundationFailedStep(operation);
const activeStep =
failedStep ??
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 foundationText =
buildCustomWorldCreatorIntentFoundationText(creatorIntent).trim();
if (foundationText) {
return foundationText;
}
if (creatorIntent.rawSettingText.trim()) {
return creatorIntent.rawSettingText.trim();
}
}
const latestUserMessage = [...session.messages]
.reverse()
.find((message) => message.role === 'user' && message.text.trim());
const anchorSettingText = buildEightAnchorFoundationText(session.anchorContent);
return (
anchorSettingText ||
latestUserMessage?.text.trim() ||
'正在整理当前共创设定。'
);
}