import type { RawMessage } from '@/types/chat'; import { extractText } from '@/pages/Chat/message-utils'; export const GAME_ASSET_SELECTION_MARKER = ''; export const GAME_ASSET_REVIEW_MARKER_PREFIX = '`)); if (!match?.[1]) return null; try { return JSON.parse(match[1]) as T; } catch { return null; } } function normalizeIds(value: unknown): string[] { if (!Array.isArray(value)) return []; return [...new Set(value.filter((item): item is string => typeof item === 'string').map((item) => item.trim()).filter(Boolean))]; } export function parseGameAssetReviewInvocation(message: RawMessage): GameAssetReviewInvocation | null { if (message.role !== 'assistant') return null; const text = extractText(message); const parsed = parseJsonMarker<{ invocationId?: unknown; candidateIds?: unknown }>(text, GAME_ASSET_REVIEW_MARKER_PREFIX); if (!parsed || typeof parsed.invocationId !== 'string' || !parsed.invocationId.trim()) return null; return { invocationId: parsed.invocationId.trim(), candidateIds: normalizeIds(parsed.candidateIds), }; } export function buildGameAssetReviewMarker(invocationId: string, candidateIds: string[]): string { return `${GAME_ASSET_REVIEW_MARKER_PREFIX}${JSON.stringify({ invocationId, candidateIds })} -->`; } export function hasGameAssetSelectionMarker(message: RawMessage): boolean { if (message.role !== 'assistant') return false; const text = extractText(message); return text.includes(GAME_ASSET_SELECTION_MARKER) || text.includes(GAME_ASSET_REVIEW_MARKER_PREFIX); } /** * Compatibility signal for an Agent response that contains a concrete preview * list but forgot to emit the machine-readable review marker. Keep this * intentionally narrow: a preview-file/table label plus stable candidate ids * is required, so ordinary asset discussion does not create an approval desk. */ export function hasGameAssetReviewContent(message: RawMessage): boolean { if (message.role !== 'assistant') return false; const text = extractText(message); if (text.includes(GAME_ASSET_SELECTION_MARKER) || text.includes(GAME_ASSET_REVIEW_MARKER_PREFIX)) return true; const hasPreviewEvidence = /预览文件|预览图|preview(?:path|file)|assets\/(?:review-previews|previews)/i.test(text); const hasStableCandidateIds = /\bC-\d{3,}\b/i.test(text); return hasPreviewEvidence && hasStableCandidateIds; } export function stripGameAssetSelectionMarker(text: string): string { return text .replaceAll(GAME_ASSET_SELECTION_MARKER, '') .replace(//g, '') .trim(); } export function buildGameAssetConfirmationMessage(assets: Array<{ id: string; name: string }>): string { const selection = assets.map((asset) => `「${asset.name}」(${asset.id})`).join('、'); return `${GAME_ASSET_CONFIRMATION_PREFIX}${selection}。请按这条用户确认更新 ASSET_PLAN.md 的选定记录。`; } export function buildGameAssetReviewActionMessage(input: { invocationId: string; asset: { id: string; name: string }; action: GameAssetReviewAction; }): string { const actionText = input.action === 'approve' ? '纳入开发' : input.action === 'replace' ? '舍弃并重新寻找同用途素材' : '舍弃,不再推荐'; return [ `游戏素材审核:${actionText}`, `素材:${input.asset.name}(${input.asset.id})`, `审核批次:${input.invocationId}`, input.action === 'approve' ? '请把它记录为已确认素材,并在 ASSET_PLAN.md 中保留来源、授权和接入说明。' : input.action === 'replace' ? '请把当前素材记录为舍弃,禁止后续再次提交,并重新搜索满足同一用途的新素材。' : '请把当前素材记录为舍弃,禁止后续再次提交。', `${GAME_ASSET_REVIEW_ACTION_MARKER_PREFIX}${JSON.stringify({ invocationId: input.invocationId, assetId: input.asset.id, action: input.action })} -->`, ].join('\n'); } export function buildGameAssetReviewBatchMessage(input: { invocationId: string; decisions: Array<{ asset: { id: string; name: string }; action: GameAssetReviewAction }>; }): string { const grouped = { approve: input.decisions.filter((item) => item.action === 'approve'), discard: input.decisions.filter((item) => item.action === 'discard'), replace: input.decisions.filter((item) => item.action === 'replace'), }; const format = (items: Array<{ asset: { id: string; name: string } }>): string => ( items.length > 0 ? items.map((item) => `「${item.asset.name}」(${item.asset.id})`).join('、') : '无' ); const markerDecisions = input.decisions.map((item) => [item.asset.id, item.action]); return [ '游戏素材审核结果(本轮一次性提交)', `审核批次:${input.invocationId}`, `纳入开发:${format(grouped.approve)}`, `舍弃:${format(grouped.discard)}`, `不合适,重新寻找:${format(grouped.replace)}`, '请按以上用户决定一次性更新 ASSET_PLAN.md:纳入开发的记录为已确认素材;舍弃的素材禁止再次提交;需要重新寻找的素材保留原用途并搜索替代品。不要把这些决定拆成多轮询问。', `${GAME_ASSET_REVIEW_ACTIONS_MARKER_PREFIX}${JSON.stringify({ invocationId: input.invocationId, decisions: markerDecisions })} -->`, ].join('\n'); } export function parseGameAssetReviewAction(message: RawMessage): { invocationId: string; assetId: string; action: GameAssetReviewAction; } | null { if (message.role !== 'user') return null; const parsed = parseJsonMarker<{ invocationId?: unknown; assetId?: unknown; action?: unknown }>(extractText(message), GAME_ASSET_REVIEW_ACTION_MARKER_PREFIX); if (!parsed || typeof parsed.invocationId !== 'string' || typeof parsed.assetId !== 'string') return null; if (parsed.action !== 'approve' && parsed.action !== 'discard' && parsed.action !== 'replace') return null; return { invocationId: parsed.invocationId, assetId: parsed.assetId, action: parsed.action }; } export function parseGameAssetReviewActions(message: RawMessage): { invocationId: string; decisions: Array<{ assetId: string; action: GameAssetReviewAction }>; } | null { if (message.role !== 'user') return null; const parsed = parseJsonMarker<{ invocationId?: unknown; decisions?: unknown }>(extractText(message), GAME_ASSET_REVIEW_ACTIONS_MARKER_PREFIX); if (!parsed || typeof parsed.invocationId !== 'string' || !Array.isArray(parsed.decisions)) return null; const decisions = parsed.decisions .filter((item): item is [unknown, unknown] => Array.isArray(item) && item.length === 2) .map(([assetId, action]) => ({ assetId, action })) .filter((item): item is { assetId: string; action: GameAssetReviewAction } => ( typeof item.assetId === 'string' && (item.action === 'approve' || item.action === 'discard' || item.action === 'replace') )); if (decisions.length === 0) return null; return { invocationId: parsed.invocationId, decisions }; } export function findGameAssetRoundConfirmation( messages: RawMessage[], roundMessageIndex: number, ): RawMessage | null { for (let index = roundMessageIndex + 1; index < messages.length; index += 1) { const message = messages[index]; if (hasGameAssetSelectionMarker(message)) return null; if (message?.role === 'user' && extractText(message).trim().startsWith(GAME_ASSET_CONFIRMATION_PREFIX)) { return message; } } return null; }