Files
makelore/src/lib/game-asset-selection-message.ts
2026-07-29 17:22:35 +08:00

167 lines
8.0 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 { RawMessage } from '@/types/chat';
import { extractText } from '@/pages/Chat/message-utils';
export const GAME_ASSET_SELECTION_MARKER = '<!-- niancode:game-asset-selection -->';
export const GAME_ASSET_REVIEW_MARKER_PREFIX = '<!-- niancode:game-asset-review ';
export const GAME_ASSET_REVIEW_ACTION_MARKER_PREFIX = '<!-- niancode:game-asset-review-action ';
export const GAME_ASSET_REVIEW_ACTIONS_MARKER_PREFIX = '<!-- niancode:game-asset-review-actions ';
export const GAME_ASSET_CONFIRMATION_PREFIX = '我确认选择以下游戏素材:';
export type GameAssetReviewInvocation = {
invocationId: string;
candidateIds: string[];
};
export type GameAssetReviewAction = 'approve' | 'discard' | 'replace';
function parseJsonMarker<T>(text: string, prefix: string): T | null {
const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = text.match(new RegExp(`${escapedPrefix}(\\{[\\s\\S]*?\\})\\s*-->`));
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(/<!-- niancode:game-asset-review \{[\s\S]*?\} -->/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;
}