167 lines
8.0 KiB
TypeScript
167 lines
8.0 KiB
TypeScript
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;
|
||
}
|