362 lines
10 KiB
TypeScript
362 lines
10 KiB
TypeScript
import type {
|
||
CreateMatch3DSessionRequest,
|
||
ExecuteMatch3DActionRequest,
|
||
Match3DActionResponse,
|
||
Match3DAgentMessageResponse,
|
||
Match3DAgentSessionSnapshot,
|
||
Match3DAnchorItemResponse,
|
||
Match3DCreatorConfig,
|
||
Match3DSessionResponse,
|
||
SendMatch3DMessageRequest,
|
||
} from '../../../packages/shared/src/contracts/match3dAgent';
|
||
import type { TextStreamOptions } from '../aiTypes';
|
||
|
||
const MOCK_RESPONSE_DELAY_MS = 180;
|
||
const MATCH3D_SESSION_PREFIX = 'match3d-session';
|
||
|
||
const DEFAULT_MATCH3D_CONFIG: Match3DCreatorConfig = {
|
||
themeText: '缤纷玩具',
|
||
clearCount: 12,
|
||
difficulty: 4,
|
||
};
|
||
|
||
let match3dSessionCounter = 0;
|
||
const mockSessions = new Map<string, Match3DAgentSessionSnapshot>();
|
||
|
||
function delay(ms = MOCK_RESPONSE_DELAY_MS) {
|
||
return new Promise<void>((resolve) => globalThis.setTimeout(resolve, ms));
|
||
}
|
||
|
||
function nowIso() {
|
||
return new Date().toISOString();
|
||
}
|
||
|
||
function createMessage(
|
||
sessionId: string,
|
||
role: Match3DAgentMessageResponse['role'],
|
||
text: string,
|
||
kind: Match3DAgentMessageResponse['kind'] = 'chat',
|
||
): Match3DAgentMessageResponse {
|
||
return {
|
||
id: `${sessionId}-message-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||
role,
|
||
kind,
|
||
text,
|
||
createdAt: nowIso(),
|
||
};
|
||
}
|
||
|
||
function buildAnchor(
|
||
key: string,
|
||
label: string,
|
||
value: string,
|
||
): Match3DAnchorItemResponse {
|
||
return {
|
||
key,
|
||
label,
|
||
value,
|
||
status: value.trim() ? 'confirmed' : 'missing',
|
||
};
|
||
}
|
||
|
||
function buildAnchorPack(config: Partial<Match3DCreatorConfig>) {
|
||
return {
|
||
theme: buildAnchor('theme', '题材主题', config.themeText ?? ''),
|
||
clearCount: buildAnchor(
|
||
'clearCount',
|
||
'需要消除次数',
|
||
typeof config.clearCount === 'number' ? String(config.clearCount) : '',
|
||
),
|
||
difficulty: buildAnchor(
|
||
'difficulty',
|
||
'难度',
|
||
typeof config.difficulty === 'number' ? String(config.difficulty) : '',
|
||
),
|
||
};
|
||
}
|
||
|
||
function normalizePositiveInteger(value: unknown) {
|
||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||
return null;
|
||
}
|
||
|
||
const normalized = Math.floor(value);
|
||
return normalized > 0 ? normalized : null;
|
||
}
|
||
|
||
function normalizeDifficulty(value: unknown) {
|
||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||
return null;
|
||
}
|
||
|
||
return Math.max(1, Math.min(10, Math.round(value)));
|
||
}
|
||
|
||
function buildConfigFromPartial(
|
||
partial: Partial<Match3DCreatorConfig>,
|
||
): Match3DCreatorConfig | null {
|
||
const themeText = partial.themeText?.trim();
|
||
const clearCount = normalizePositiveInteger(partial.clearCount);
|
||
const difficulty = normalizeDifficulty(partial.difficulty);
|
||
|
||
if (!themeText || !clearCount || !difficulty) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
themeText,
|
||
referenceImageSrc: partial.referenceImageSrc ?? null,
|
||
clearCount,
|
||
difficulty,
|
||
};
|
||
}
|
||
|
||
function parseConfigFromText(
|
||
text: string,
|
||
current: Partial<Match3DCreatorConfig>,
|
||
): Partial<Match3DCreatorConfig> {
|
||
const next = { ...current };
|
||
const trimmedText = text.trim();
|
||
|
||
const themeMatch =
|
||
trimmedText.match(/(?:题材|主题)[::\s]*([\u4e00-\u9fa5A-Za-z0-9_-]{2,24})/u) ??
|
||
trimmedText.match(/(?:想做|做成|选择|使用)([\u4e00-\u9fa5A-Za-z0-9_-]{2,24})(?:题材|主题)/u);
|
||
const clearCountMatch =
|
||
trimmedText.match(/(?:消除|次数)[::\s]*(\d+)/u) ??
|
||
trimmedText.match(/(\d+)\s*(?:次消除|次)/u);
|
||
const difficultyMatch =
|
||
trimmedText.match(/(?:难度)[::\s]*(10|[1-9])/u) ??
|
||
trimmedText.match(/(?:难一点|困难)/u);
|
||
|
||
if (themeMatch?.[1]) {
|
||
next.themeText = themeMatch[1].trim();
|
||
}
|
||
|
||
if (clearCountMatch?.[1]) {
|
||
next.clearCount = Number(clearCountMatch[1]);
|
||
}
|
||
|
||
if (difficultyMatch?.[1]) {
|
||
next.difficulty = Number(difficultyMatch[1]);
|
||
} else if (difficultyMatch?.[0]) {
|
||
next.difficulty = 7;
|
||
}
|
||
|
||
if (!next.themeText && trimmedText.length >= 2 && trimmedText.length <= 24) {
|
||
next.themeText = trimmedText;
|
||
}
|
||
|
||
return next;
|
||
}
|
||
|
||
function resolveSessionProgress(config: Partial<Match3DCreatorConfig>) {
|
||
const completed = [
|
||
Boolean(config.themeText?.trim()),
|
||
Boolean(normalizePositiveInteger(config.clearCount)),
|
||
Boolean(normalizeDifficulty(config.difficulty)),
|
||
].filter(Boolean).length;
|
||
|
||
return Math.round((completed / 3) * 100);
|
||
}
|
||
|
||
function buildAssistantReply(config: Partial<Match3DCreatorConfig>) {
|
||
const missing: string[] = [];
|
||
if (!config.themeText?.trim()) {
|
||
missing.push('题材主题');
|
||
}
|
||
if (!normalizePositiveInteger(config.clearCount)) {
|
||
missing.push('需要消除次数');
|
||
}
|
||
if (!normalizeDifficulty(config.difficulty)) {
|
||
missing.push('难度');
|
||
}
|
||
|
||
if (missing.length === 0) {
|
||
const readyConfig = buildConfigFromPartial(config) ?? DEFAULT_MATCH3D_CONFIG;
|
||
return `已确认:${readyConfig.themeText}题材,消除 ${readyConfig.clearCount} 次,共 ${readyConfig.clearCount * 3} 件物品,难度 ${readyConfig.difficulty}。可以生成结果页。`;
|
||
}
|
||
|
||
return `还需要确认:${missing.join('、')}。`;
|
||
}
|
||
|
||
function updateSessionConfig(
|
||
session: Match3DAgentSessionSnapshot,
|
||
partialConfig: Partial<Match3DCreatorConfig>,
|
||
) {
|
||
const progressPercent = resolveSessionProgress(partialConfig);
|
||
const config = buildConfigFromPartial(partialConfig);
|
||
|
||
return {
|
||
...session,
|
||
progressPercent,
|
||
stage: 'collecting_config',
|
||
anchorPack: buildAnchorPack(partialConfig),
|
||
config,
|
||
updatedAt: nowIso(),
|
||
} satisfies Match3DAgentSessionSnapshot;
|
||
}
|
||
|
||
function ensureMockSession(sessionId: string) {
|
||
const session = mockSessions.get(sessionId);
|
||
if (!session) {
|
||
throw new Error('抓大鹅创作会话不存在,请重新开始创作。');
|
||
}
|
||
|
||
return session;
|
||
}
|
||
|
||
function buildDraft(config: Match3DCreatorConfig) {
|
||
return {
|
||
gameName: `${config.themeText}抓大鹅`,
|
||
themeText: config.themeText,
|
||
summaryText: `${config.themeText}题材的经典三消收纳关卡。`,
|
||
tags: [config.themeText, '抓大鹅', '消除'].slice(0, 3),
|
||
coverImageSrc: config.referenceImageSrc ?? null,
|
||
clearCount: config.clearCount,
|
||
difficulty: config.difficulty,
|
||
totalItemCount: config.clearCount * 3,
|
||
};
|
||
}
|
||
|
||
export async function createMatch3DCreationSession(
|
||
payload: CreateMatch3DSessionRequest = {},
|
||
): Promise<Match3DSessionResponse> {
|
||
await delay();
|
||
|
||
match3dSessionCounter += 1;
|
||
const sessionId = `${MATCH3D_SESSION_PREFIX}-${match3dSessionCounter}`;
|
||
const partialConfig: Partial<Match3DCreatorConfig> = {
|
||
themeText: payload.themeText ?? payload.seedText,
|
||
referenceImageSrc: payload.referenceImageSrc ?? null,
|
||
clearCount: payload.clearCount,
|
||
difficulty: payload.difficulty,
|
||
};
|
||
const now = nowIso();
|
||
const session: Match3DAgentSessionSnapshot = updateSessionConfig(
|
||
{
|
||
sessionId,
|
||
currentTurn: 0,
|
||
progressPercent: 0,
|
||
stage: 'collecting_config',
|
||
anchorPack: buildAnchorPack(partialConfig),
|
||
config: null,
|
||
draft: null,
|
||
messages: [
|
||
createMessage(
|
||
sessionId,
|
||
'assistant',
|
||
'先确认题材、需要消除次数和难度。也可以直接说“自动配置”。',
|
||
),
|
||
],
|
||
lastAssistantReply: null,
|
||
updatedAt: now,
|
||
},
|
||
partialConfig,
|
||
);
|
||
|
||
mockSessions.set(sessionId, session);
|
||
return { session };
|
||
}
|
||
|
||
export async function getMatch3DCreationSession(sessionId: string) {
|
||
await delay(80);
|
||
return { session: ensureMockSession(sessionId) };
|
||
}
|
||
|
||
export async function streamMatch3DCreationMessage(
|
||
sessionId: string,
|
||
payload: SendMatch3DMessageRequest,
|
||
options: TextStreamOptions = {},
|
||
): Promise<Match3DAgentSessionSnapshot> {
|
||
await delay(120);
|
||
const session = ensureMockSession(sessionId);
|
||
const text = payload.text.trim();
|
||
const currentConfig: Partial<Match3DCreatorConfig> = session.config ?? {
|
||
themeText: session.anchorPack.theme.value,
|
||
clearCount: Number(session.anchorPack.clearCount.value) || undefined,
|
||
difficulty: Number(session.anchorPack.difficulty.value) || undefined,
|
||
};
|
||
const nextConfig =
|
||
payload.quickFillRequested || /自动配置/u.test(text)
|
||
? {
|
||
...DEFAULT_MATCH3D_CONFIG,
|
||
themeText: currentConfig.themeText || DEFAULT_MATCH3D_CONFIG.themeText,
|
||
}
|
||
: parseConfigFromText(text, currentConfig);
|
||
const userMessage = {
|
||
id: payload.clientMessageId,
|
||
role: 'user',
|
||
kind: 'chat',
|
||
text,
|
||
createdAt: nowIso(),
|
||
} satisfies Match3DAgentMessageResponse;
|
||
const assistantReply = buildAssistantReply(nextConfig);
|
||
|
||
options.onUpdate?.(assistantReply.slice(0, Math.ceil(assistantReply.length / 2)));
|
||
await delay(80);
|
||
options.onUpdate?.(assistantReply);
|
||
await delay(80);
|
||
|
||
const nextSession = updateSessionConfig(
|
||
{
|
||
...session,
|
||
currentTurn: session.currentTurn + 1,
|
||
messages: [
|
||
...session.messages,
|
||
userMessage,
|
||
createMessage(sessionId, 'assistant', assistantReply),
|
||
],
|
||
lastAssistantReply: assistantReply,
|
||
},
|
||
{
|
||
...nextConfig,
|
||
referenceImageSrc:
|
||
payload.referenceImageSrc ?? currentConfig.referenceImageSrc ?? null,
|
||
},
|
||
);
|
||
|
||
mockSessions.set(sessionId, nextSession);
|
||
return nextSession;
|
||
}
|
||
|
||
export async function executeMatch3DCreationAction(
|
||
sessionId: string,
|
||
payload: ExecuteMatch3DActionRequest,
|
||
): Promise<Match3DActionResponse> {
|
||
await delay(220);
|
||
const session = ensureMockSession(sessionId);
|
||
|
||
if (payload.action !== 'match3d_compile_draft') {
|
||
throw new Error('未知抓大鹅创作操作。');
|
||
}
|
||
|
||
const config = session.config ?? buildConfigFromPartial(DEFAULT_MATCH3D_CONFIG);
|
||
if (!config) {
|
||
throw new Error('请先确认题材、需要消除次数和难度。');
|
||
}
|
||
|
||
const nextSession = {
|
||
...session,
|
||
stage: 'draft_ready',
|
||
progressPercent: 100,
|
||
config,
|
||
draft: buildDraft(config),
|
||
lastAssistantReply: '抓大鹅草稿已准备完成。',
|
||
messages: [
|
||
...session.messages,
|
||
createMessage(sessionId, 'assistant', '抓大鹅草稿已准备完成。', 'summary'),
|
||
],
|
||
updatedAt: nowIso(),
|
||
} satisfies Match3DAgentSessionSnapshot;
|
||
|
||
mockSessions.set(sessionId, nextSession);
|
||
return { session: nextSession };
|
||
}
|
||
|
||
export const match3dCreationClient = {
|
||
createSession: createMatch3DCreationSession,
|
||
getSession: getMatch3DCreationSession,
|
||
streamMessage: streamMatch3DCreationMessage,
|
||
executeAction: executeMatch3DCreationAction,
|
||
};
|