910862fd06
稳定工作台与窗口标题栏状态同步,消除重复更新循环 支持画布右键平移并保留左键框选及资源拖动,完善中断清理 解耦运行不可用提示与资源选中状态 复用原生UI状态校验区分UI设计JSON和普通JSON,接入现有编辑器与代码预览 补充前端和原生回归测试、规范及待验收记录,明确对话历史分页尚未修复
464 lines
17 KiB
TypeScript
464 lines
17 KiB
TypeScript
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');
|
||
}
|