Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts
T
suzmii 910862fd06 修复AGC画布交互并识别UI设计JSON
稳定工作台与窗口标题栏状态同步,消除重复更新循环
支持画布右键平移并保留左键框选及资源拖动,完善中断清理
解耦运行不可用提示与资源选中状态
复用原生UI状态校验区分UI设计JSON和普通JSON,接入现有编辑器与代码预览
补充前端和原生回归测试、规范及待验收记录,明确对话历史分页尚未修复
2026-09-16 17:39:23 +08:00

464 lines
17 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 ProjectResource,
projectResourceDisplayKind,
} from './resourceProjectionModel';
export const PROJECT_RESOURCE_CARD_PREVIEW_CONCURRENCY = 3;
/**
* 终态缓存条目上限。
*
* 从 `48` 提到 `72` 的依据(真机 `What do u wanna do kitten` 实测,不是估算):
* - 该项目「UI 交互」栏目有 **51 张**可预览卡,而原上限 48 **小于一栏的规模** ⇒ 滚满该栏目
* 必然触发驱逐(每多加载 1 张淘汰 1 张),且被淘汰的正是"停在屏幕上不动"的卡;
* - 取 **72**:覆盖 51 张并留约 40% 余量,同时按真机单张均值 591 KB 外推 ≈ **43 MiB**
* **仍在既有的 64 MiB 字节预算之内**(真机 52 张 blob 合计仅 29.32 MiB,用掉 45.8%)。
*
* 因此这次调整**不动字节预算**、也**不是"把上限放大到任意大"** —— 字节侧的绑定约束没有放松,
* 只是把"条目数"这一侧从"小于一栏"提到"能装下一栏"。
*/
export const PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT = 72;
export const PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT = 64 * 1024 * 1024;
export const PROJECT_RESOURCE_CARD_PREVIEW_QUEUE_LIMIT = 96;
export const PROJECT_RESOURCE_CARD_PREVIEW_ACTIVE_QUEUE_RESERVE =
PROJECT_RESOURCE_CARD_PREVIEW_CONCURRENCY;
export type ProjectResourceCardPreviewKind =
| 'raster-image'
| 'media-image'
| 'video'
| 'audio'
| 'code'
| 'document'
| 'version'
| 'placeholder';
export type ProjectResourceImageDimensions = {
pixelWidth: number;
pixelHeight: number;
};
export type ProjectResourceCardPreviewPayload = {
path: string;
mediaType: string;
byteLen: number;
pixelWidth?: number;
pixelHeight?: number;
/**
* 这张图是否**真的**带 alpha 通道(原生侧头部级判据,见 `image_inspect.rs` 的
* `detect_raster_image_has_alpha`):PNG 颜色类型 4/6 或 `tRNS`、WebP 的 alpha 标志为 true
* JPEG 恒 false。
*
* 资源卡的棋盘格底只按它铺(`data-preview-has-alpha='true'`),不再按「预览分支是图片」
* 无条件铺 —— 否则 AI 把棋盘格画进像素里的不透明图会与卡面棋盘格叠在一起,
* 验收时无法区分「真透明底」与「假棋盘格」。
*
* 只有 `read_local_project_image_preview` 这条图像读取链路会给出该字段;文本 / 媒体预览的
* payload 没有它(`undefined`),必须与 `false` 同档处理:不知道就不铺棋盘格。
*/
hasAlpha?: boolean;
sourceUrl?: string;
content?: string;
/** 原生侧完整校验过的 UI State 资产身份,不由前端解析 JSON 推断。 */
uiDesignAssetId?: string;
};
export type ProjectResourceCardPreviewTransportPayload = Omit<
ProjectResourceCardPreviewPayload,
'sourceUrl'
> & {
dataUrl?: string;
};
export function projectResourceCardPreviewImageDimensions(
preview: Pick<
ProjectResourceCardPreviewPayload,
'pixelWidth' | 'pixelHeight'
>,
): ProjectResourceImageDimensions | null {
const { pixelWidth, pixelHeight } = preview;
if (pixelWidth === undefined && pixelHeight === undefined) {
return null;
}
if (
typeof pixelWidth !== 'number' ||
typeof pixelHeight !== 'number' ||
!Number.isSafeInteger(pixelWidth) ||
!Number.isSafeInteger(pixelHeight) ||
pixelWidth <= 0 ||
pixelHeight <= 0
) {
throw new Error('图片预览像素尺寸无效');
}
return { pixelWidth, pixelHeight };
}
export type ProjectResourceCardPreviewCacheEntry = {
identity: string;
retainedBytes: number;
};
export type ProjectResourceCardPreviewState =
| { status: 'idle' }
| { status: 'loading' }
| {
status: 'loaded';
preview: ProjectResourceCardPreviewPayload;
}
| { status: 'failed'; error: string; retryable: boolean };
export const IDLE_PROJECT_RESOURCE_CARD_PREVIEW = {
status: 'idle',
} as const satisfies ProjectResourceCardPreviewState;
function normalizedProjectResourceCardPreviewRetainedBytes(
retainedBytes: number,
) {
return Number.isSafeInteger(retainedBytes) && retainedBytes >= 0
? retainedBytes
: PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT + 1;
}
/**
* "没有任何可见性信息"的空集:调用方给不出可见集合时用它,语义等价于**全部条目都不在视口内**
* (于是退化为纯 LRU)。线上只有 `useProjectResourceCardPreviews` 一处调用,且总会算出真实的
* 可见集合;这个默认值只服务类型与测试的可读性,不是线上路径。
*/
const NO_VISIBLE_PREVIEW_IDENTITIES: ReadonlySet<string> = new Set<string>();
export function projectResourceCardPreviewEvictionIdentities(
entries: readonly ProjectResourceCardPreviewCacheEntry[],
protectedIdentity: string | null,
/**
* 当前**实际停留在视口内**的 identity 集合(调用方按几何判定后传入)。
*
* 淘汰分两轮,顺序是这条函数的核心契约:
* 1. **先只淘汰不在视口内的条目**(按传入顺序,即 LRU 顺序);
* 2. 只有当"**剩余条目全部仍在视口内**且依旧超预算"时,才回退到对全表按 LRU 淘汰。
*
* 为什么必须这样:条目顺序是「最近一次被请求」的顺序,而停在屏幕上不动的卡片不会产生
* 新的请求 —— 于是**恰恰是用户正看着的那几张排在队首被首选淘汰**,表现出来就是
* "图片自己消失又回来"。第一轮把可见卡排除在外,正是消除这个闪烁。
*
* ⚠️ 第 2 轮回退**不可省略**:否则"可见即永不淘汰"会让缓存无界增长。
* 该回退由用例钉住(全可见且超预算时必须仍能淘汰)。
*/
visibleIdentities: ReadonlySet<string> = NO_VISIBLE_PREVIEW_IDENTITIES,
): string[] {
let retainedCount = entries.length;
let retainedBytes = entries.reduce(
(total, entry) =>
total +
normalizedProjectResourceCardPreviewRetainedBytes(entry.retainedBytes),
0,
);
const evicted = new Set<string>();
const overBudget = () =>
retainedCount > PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT ||
retainedBytes > PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT;
const evict = (entry: ProjectResourceCardPreviewCacheEntry) => {
evicted.add(entry.identity);
retainedCount -= 1;
retainedBytes -= normalizedProjectResourceCardPreviewRetainedBytes(
entry.retainedBytes,
);
};
// 第一轮:只动视口外的条目,可见卡一律跳过。
for (const entry of entries) {
if (!overBudget()) {
break;
}
if (entry.identity === protectedIdentity) {
continue;
}
if (visibleIdentities.has(entry.identity)) {
continue;
}
evict(entry);
}
// 第二轮(回退):仍超预算时可见卡不再豁免 —— 防止"可见即永不淘汰"造成无界内存。
if (overBudget()) {
for (const entry of entries) {
if (!overBudget()) {
break;
}
if (entry.identity === protectedIdentity) {
continue;
}
if (evicted.has(entry.identity)) {
continue;
}
evict(entry);
}
}
if (overBudget() && protectedIdentity) {
const protectedEntry = entries.find(
(entry) => entry.identity === protectedIdentity,
);
if (protectedEntry && !evicted.has(protectedEntry.identity)) {
evicted.add(protectedEntry.identity);
}
}
return Array.from(evicted);
}
/** `read_local_project_media_preview` 只认这两个线上取值,与画布栏目轴无关。 */
export type ProjectResourceMediaPreviewCategory = 'art' | 'audio';
const rasterImageExtension = /\.(png|jpe?g|webp)$/iu;
const extendedImageExtension = /\.(gif|svg|avif|bmp)$/iu;
const videoExtension = /\.(mp4|webm|mov)$/iu;
/** Markdown 文档:卡面显示前几行,并做轻量标记清理。 */
const markdownExtension = /\.(md|markdown|mdx)$/iu;
/**
* 代码文件扩展名(**只看路径,不读文件内容**)。
*
* 与 `resourceProjectionModel` 的 `gameCodeExtension` 是两份口径,刻意不复用:
* 那份用于**筛选与归属**,改动会波及画布栏目与计数;这份只决定**卡面怎么画**。
* 这里按用户口径把 `.yaml` / `.toml` / `.xml` / `.html` / `.css` / `.sql`
* 一并算代码。JSON 留在文本读取通道,由原生内容识别区分普通 JSON 和 UI 设计,
* 不能像源码卡一样跳过预取;卡面不显示 JSON 正文。
*/
const cardCodeExtension =
/\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|rs|py|go|java|kt|kts|cs|cpp|cc|cxx|c|h|hpp|swift|php|rb|lua|sh|bash|zsh|ps1|psm1|ya?ml|toml|xml|html?|css|scss|less|sql|graphql|gql|vue|svelte)$/iu;
/** 纯文本:按纯文本处理,显示前几行但不做标记清理。 */
const plainTextExtension =
/\.(txt|text|csv|tsv|log|ini|conf|cfg|properties|env)$/iu;
/**
* 取路径末段的扩展名(小写、不含点)。取不到(无扩展名)时返回 `null`。
*/
export function projectResourcePathExtension(path: string): string | null {
const fileName = path.split(/[\\/]/u).filter(Boolean).pop() ?? '';
const matched = /\.([a-z0-9]+)$/iu.exec(fileName.trim());
return matched ? matched[1]!.toLowerCase() : null;
}
export function isProjectResourceJson(
resource: Pick<ProjectResource, 'path' | 'mediaType'>,
): boolean {
return (
projectResourcePathExtension(resource.path) === 'json' ||
(projectResourcePathExtension(resource.path) === null &&
resource.mediaType.toLowerCase().includes('json'))
);
}
/** 读取结果必须仍属于当前卡片;元数据中的 UI 标签本身不能授予编辑入口。 */
export function projectResourceJsonPresentation(
resource: ProjectResource,
preview: ProjectResourceCardPreviewState | null | undefined,
): 'json' | 'ui-design' | null {
if (!isProjectResourceJson(resource)) return null;
return resource.manifestAssetId &&
preview?.status === 'loaded' &&
preview.preview.path === resource.path &&
preview.preview.uiDesignAssetId === resource.manifestAssetId
? 'ui-design'
: 'json';
}
/** 代码文件的类型标签(如 `.ts` → `TS`);不是代码文件时返回 `null`。 */
export function projectResourceCodeTypeLabel(path: string): string | null {
const trimmed = path.trim();
if (!cardCodeExtension.test(trimmed)) {
return null;
}
const extension = projectResourcePathExtension(trimmed);
return extension ? extension.toUpperCase() : null;
}
export function projectResourceCardPreviewKind(
resource: ProjectResource,
): ProjectResourceCardPreviewKind {
if (resource.subtype === 'project-version') {
return 'version';
}
if (resource.subtype === 'agent-result') {
return 'document';
}
// Markdown / 代码在扩展名这一层就分流,不再依赖上游登记类型:
// JSON 使用文档读取通道完成原生语义识别;源码文件按扩展名分流。
if (markdownExtension.test(resource.path)) {
return 'document';
}
if (cardCodeExtension.test(resource.path)) {
return 'code';
}
const kind = projectResourceDisplayKind(resource);
if (kind === 'code') {
return 'code';
}
if (kind === 'document') {
return 'document';
}
if (kind === 'audio') {
return 'audio';
}
const mediaType = resource.mediaType.trim().toLowerCase();
if (mediaType.startsWith('video/') || videoExtension.test(resource.path)) {
return 'video';
}
if (
['image/png', 'image/jpeg', 'image/jpg', 'image/webp'].includes(
mediaType,
) ||
rasterImageExtension.test(resource.path)
) {
return 'raster-image';
}
if (
mediaType.startsWith('image/') ||
extendedImageExtension.test(resource.path)
) {
return 'media-image';
}
// 纯文本兜底:`.txt` / `.csv` / `.log` 这类文件在上游没有类型结论(判成 `null`),
// 卡面按纯文本预览比占位图标更有信息量。放在最后,不改动任何已有分支的结论。
if (kind === null && plainTextExtension.test(resource.path)) {
return 'document';
}
return 'placeholder';
}
/**
* 文档类卡片(`document` / `code`)的卡面分流结论。
*
* 三种落点:
* - `markdown`:显示前几行,预览前做轻量标记清理;
* - `code`:**完全不读内容**,卡面改画代码图标 + 类型标签;
* - `plain-text`:显示前几行,不做标记清理。
*/
export type ProjectResourceCardPreviewVariant =
| 'markdown'
| 'code'
| 'plain-text'
| null;
export function projectResourceCardPreviewVariant(
resource: ProjectResource,
): ProjectResourceCardPreviewVariant {
if (resource.subtype === 'agent-result') {
return 'markdown';
}
if (markdownExtension.test(resource.path)) {
return 'markdown';
}
if (cardCodeExtension.test(resource.path)) {
return 'code';
}
return projectResourceCardPreviewKind(resource) === 'document'
? 'plain-text'
: null;
}
/**
* 卡面是否预取正文:代码卡只用路径画图标,不为卡面占读取槽;显式详情请求单独放行。
*
* 与 `projectResourceCardPreviewVariant` 同源,避免"卡面不画内容、却仍在后台读内容"的分叉。
*/
export function projectResourceCardPreviewReadsContent(
resource: ProjectResource,
): boolean {
return projectResourceCardPreviewVariant(resource) !== 'code';
}
/**
* `read_local_project_media_preview` 的线上 `category` 入参:只按文件类型分美术 / 音频两支。
*
* 该参数在原生侧只接受 `art` / `audio`(见 `commands.rs` 的
* `read_local_project_media_preview_at`),它是**文件读取分支**,不是画布分区栏目。
* `ProjectResource.category` 自 6 类资产分类轴落地后已经是画布栏目(`unclassified` /
* `ui-interaction` / …),把它当线上分类传给原生侧会被直接拒绝
* (`媒体预览类别只支持 art 或 audio`),扩展名图片、视频与音频卡片因此全部读不出预览。
* 这里从卡片预览类型重新派生,保持与分区栏目解耦。
*/
export function projectResourceMediaPreviewCategory(
resource: ProjectResource,
): ProjectResourceMediaPreviewCategory {
return projectResourceCardPreviewKind(resource) === 'audio' ? 'audio' : 'art';
}
export function projectResourceCardPreviewIdentity(input: {
projectPath: string;
projectId: string;
previewVersion?: string;
resource: ProjectResource;
}) {
return JSON.stringify([
input.projectPath,
input.projectId,
input.resource.id,
input.resource.category,
input.resource.path,
input.resource.mediaType,
input.previewVersion ?? '',
]);
}
/** 卡面文档预览的最大行数(超出由 CSS 省略号截断)。 */
export const PROJECT_RESOURCE_CARD_PREVIEW_LINE_LIMIT = 3;
/** 单行预览的最大字符数,超出截断成省略号。 */
export const PROJECT_RESOURCE_CARD_PREVIEW_LINE_LENGTH = 72;
/**
* Markdown 的**轻量**清理:只去掉标记符号,不是渲染器。
*
* - 去掉围栏标记行(``` / ~~~),保留代码内容本身;
* - 去掉行首标题 `#`、引用 `>` 与列表符号(保留缩进层级);
* - 去掉强调 / 行内代码 / 图片 / 链接的标记符号,保留可见文字;
* - **不折叠换行** —— 卡面要的是"前几行",压成一行就看不出结构了。
*/
function stripMarkdownMarkers(content: string): string {
return content
.replace(/^\s*(?:```|~~~).*$/gmu, '')
.replace(/^\s{0,3}#{1,6}\s*/gmu, '')
.replace(/^\s{0,3}>\s?/gmu, '')
.replace(/^\s*[-+*]\s+/gmu, '')
.replace(/!\[([^\]]*)\]\([^)]*\)/gu, '$1')
.replace(/\[([^\]]+)\]\([^)]*\)/gu, '$1')
.replace(/`{1,3}([^`]*)`{1,3}/gu, '$1')
.replace(/\*\*([^*]+)\*\*/gu, '$1')
.replace(/__([^_]+)__/gu, '$1')
.replace(/(^|[^*])\*([^*\n]+)\*/gu, '$1$2')
.replace(/(^|[^_])_([^_\n]+)_/gu, '$1$2');
}
/**
* 文档卡面的前几行预览文本。
*
* `isMarkdown` 为 `true` 时先做轻量标记清理;纯文本原样输出。
* 逐行去掉多余空白(缩进保留最多 2 个空格)并按 `PROJECT_RESOURCE_CARD_PREVIEW_LINE_LENGTH`
* 单行截断,最多取 `PROJECT_RESOURCE_CARD_PREVIEW_LINE_LIMIT` 行。
*/
export function projectResourceDocumentPreviewText(
content: string,
isMarkdown: boolean,
): string {
const normalized = isMarkdown ? stripMarkdownMarkers(content) : content;
const lines: string[] = [];
for (const rawLine of normalized.split(/\r?\n/u)) {
const line = rawLine.replace(/\t/gu, ' ').replace(/\s+$/u, '');
const trimmed = line.trimStart();
if (!trimmed) {
continue;
}
const indent = line.slice(0, line.length - trimmed.length).slice(0, 2);
const text = `${indent}${trimmed}`;
lines.push(
text.length > PROJECT_RESOURCE_CARD_PREVIEW_LINE_LENGTH
? `${text.slice(0, PROJECT_RESOURCE_CARD_PREVIEW_LINE_LENGTH)}…`
: text,
);
if (lines.length >= PROJECT_RESOURCE_CARD_PREVIEW_LINE_LIMIT) {
break;
}
}
return lines.join('\n');
}