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 = new Set(); export function projectResourceCardPreviewEvictionIdentities( entries: readonly ProjectResourceCardPreviewCacheEntry[], protectedIdentity: string | null, /** * 当前**实际停留在视口内**的 identity 集合(调用方按几何判定后传入)。 * * 淘汰分两轮,顺序是这条函数的核心契约: * 1. **先只淘汰不在视口内的条目**(按传入顺序,即 LRU 顺序); * 2. 只有当"**剩余条目全部仍在视口内**且依旧超预算"时,才回退到对全表按 LRU 淘汰。 * * 为什么必须这样:条目顺序是「最近一次被请求」的顺序,而停在屏幕上不动的卡片不会产生 * 新的请求 —— 于是**恰恰是用户正看着的那几张排在队首被首选淘汰**,表现出来就是 * "图片自己消失又回来"。第一轮把可见卡排除在外,正是消除这个闪烁。 * * ⚠️ 第 2 轮回退**不可省略**:否则"可见即永不淘汰"会让缓存无界增长。 * 该回退由用例钉住(全可见且超预算时必须仍能淘汰)。 */ visibleIdentities: ReadonlySet = NO_VISIBLE_PREVIEW_IDENTITIES, ): string[] { let retainedCount = entries.length; let retainedBytes = entries.reduce( (total, entry) => total + normalizedProjectResourceCardPreviewRetainedBytes(entry.retainedBytes), 0, ); const evicted = new Set(); 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, ): 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'); }