预览链去掉无消费者的摘要计算,并为预览读取加进程内 manifest 缓存

- 资源卡预览不再计算 SHA-256:`AgentRuntimeInspectionImage.sha256` 改为 `Option<String>`,读取函数新增 `include_sha256` 开关,门禁、分块读取、漂移与重开身份复核、签名与尺寸校验仍只有一份实现,只在末尾按调用方决定是否算摘要。预览路径传 `false`(它的 `LocalProjectImagePreview` 从来不消费摘要),Agent `image.inspect` 路径继续传 `true`。
- 取摘要改为 `sha256_digest() -> Result`,缺摘要失败关闭;`project_gates.rs` 提前算成 `image_sha256` 再进判定闭包,避免退化成「空摘要 == 记录里的空摘要」而静默通过视觉检查复核;`runtime_tools/media.rs` 改用仓库既有的 `match { Err => return failed observation }` 风格,不引入新的 panic 面。
- 新增 `read_manifest_cached_for_preview`,只接到 `read_local_project_image_preview_at`、`read_local_project_text_preview_at`、`read_local_project_media_preview_at` 三个预览命令,消掉一次画布装载中「每张卡片各读并解析一遍同一份 manifest」的重复开销。`read_manifest` 本体、`read_manifest_for_project` 和写入路径的安装后回读一字未动。
- 缓存命中判据每次重新获取当前文件身份:路径复核 + 非符号链接或 reparse point + 普通文件 + 长度 + mtime + 文件身份(Windows `(volume serial, file index)`,Unix `(dev, ino)`);失败一律不入缓存,错误语义与 `read_manifest` 完全一致。为此放开 `metadata_is_windows_reparse_point` 为 `pub(crate)` 并新增跨平台 `open_file_identity_key`。
- 写入侧硬做失效:`write_manifest_with_lock_hook` 成功写入后调用 `forget_preview_manifest`。只靠长度 + mtime + 身份堵不住同长度原地改写,显式失效后正确性不再依赖时间戳粒度,读取侧身份复核保留为兜底。
- 收益如实记录:真机项目实测 52 次 manifest 读加解析约 10 ms 量级,并省下约 3.15 MiB 重复磁盘读取。这是消除重复工作的正确改动,但不是性能救命项;用户感知的加载耗时大头仍在资源卡图片解码。
- 决策记录补「AGC 资源卡预览维持 data URL 加纯内存 LRU,不引入 Tauri asset 协议」:正式否决该候选路径,写明原始合同原文(不向 WebView 暴露任意本机文件协议或绝对路径)、打通所需配置(`assetProtocol` 加 `protocol-asset` feature 加 scope 只能 `**/*`)、会绕过的全部门禁(`file.read` 权限、项目边界、登记复核、敏感路径、父目录链接、硬链接、读取漂移与重开身份、按魔术字类型校验、尺寸上限、取消语义与全局 3 permit),以及收益与代价不成比例;同时否决磁盘缩略图缓存。
- `pitfalls.md` 补同题排障口径,含判据陷阱:把「每次启动重读」当成本瓶颈是错的,真机 52 张 PNG 分层实测 Rust 加 JS 合计仅约 0.3 s。
This commit is contained in:
2026-09-11 00:57:33 +08:00
parent f7831348c6
commit 5e29b1bb57
7 changed files with 228 additions and 22 deletions
@@ -1114,6 +1114,9 @@ pub(in crate::agent) fn ui_prototype_visual_inspection_blocker_detail_at_locked(
let image = images
.pop()
.ok_or_else(|| "UI 原型图片读取结果为空".to_string())?;
// 摘要缺失必须失败关闭:视觉检查审计按摘要证明「检查过的就是当前这张图」,
// 不能退化成空摘要比较,否则一条 sha256 为空的记录就能通过复核。
let image_sha256 = image.sha256_digest()?.to_string();
let (records, scan_truncated) =
read_agent_db_records_bounded(root, AGENT_RUNTIME_ACTION_HISTORY_MAX_DB_BYTES)?;
let matching = records.iter().rev().find(|record| {
@@ -1139,13 +1142,13 @@ pub(in crate::agent) fn ui_prototype_visual_inspection_blocker_detail_at_locked(
&& items[0].get("path").and_then(serde_json::Value::as_str)
== Some(expected_path)
&& items[0].get("sha256").and_then(serde_json::Value::as_str)
== Some(image.sha256.as_str())
== Some(image_sha256.as_str())
})
});
let Some(record) = matching else {
return Ok(Some(format!(
"expectedPath={expected_path} · currentSha256={} · requiredInspection=image.inspect · inspectionRunId={} · scanTruncated={scan_truncated}",
image.sha256,
image_sha256,
required_run_id.unwrap_or("latest-current-image")
)));
};
@@ -429,16 +429,26 @@ pub(in crate::agent) async fn observe_agent_runtime_image_inspect(
.as_deref()
.map(|value| sanitize_agent_runtime_text(value, 160))
.filter(|value| !value.trim().is_empty());
let image_metadata = images
.iter()
.map(|image| {
serde_json::json!({
"path": image.relative_path,
"sha256": image.sha256,
"bytes": image.byte_len,
})
})
.collect::<Vec<_>>();
// 视觉检查审计按 sha256 记录并复核「检查过的就是当前这张图」,摘要缺失必须失败关闭。
let mut image_metadata = Vec::with_capacity(images.len());
for image in &images {
let sha256 = match image.sha256_digest() {
Ok(sha256) => sha256,
Err(error) => {
return AgentRuntimeToolObservation {
tool: "image.inspect".to_string(),
status: "failed".to_string(),
summary: sanitize_agent_runtime_text(&error, 240),
detail: None,
};
}
};
image_metadata.push(serde_json::json!({
"path": image.relative_path,
"sha256": sha256,
"bytes": image.byte_len,
}));
}
let validation_profile =
ui_prototype_inspection.then_some(AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE);
let passed = ui_prototype_assessment
@@ -4363,7 +4363,9 @@ pub(crate) fn read_local_project_image_preview_at(
enforce_project_auto_permission_policy(root, "file.read")?;
cancellation.check()?;
let normalized_path = normalize_relative_path(relative_path.trim())?;
let manifest = read_manifest(&root.join(".agent/manifest.json"))?;
// 资源画布会对每张卡片各读一次 manifest;这条读取只做「是否已登记」判定,
// 走带完整文件身份复核的进程内缓存,避免每次预览重复读取并解析整个 manifest。
let manifest = read_manifest_cached_for_preview(&root.join(".agent/manifest.json"))?;
cancellation.check()?;
let is_registered_asset = manifest
.assets
@@ -4405,7 +4407,9 @@ pub(crate) fn read_local_project_text_preview_at(
enforce_project_auto_permission_policy(root, "file.read")?;
cancellation.check()?;
let normalized_path = normalize_relative_path(relative_path.trim())?;
let manifest = read_manifest(&root.join(".agent/manifest.json"))?;
// 资源画布会对每张卡片各读一次 manifest;这条读取只做「是否已登记」判定,
// 走带完整文件身份复核的进程内缓存,避免每次预览重复读取并解析整个 manifest。
let manifest = read_manifest_cached_for_preview(&root.join(".agent/manifest.json"))?;
cancellation.check()?;
let is_registered_document = manifest.assets.iter().any(|asset| {
asset.local_path == normalized_path
@@ -4454,7 +4458,9 @@ pub(crate) fn read_local_project_media_preview_at(
enforce_project_auto_permission_policy(root, "file.read")?;
cancellation.check()?;
let normalized_path = normalize_relative_path(relative_path.trim())?;
let manifest = read_manifest(&root.join(".agent/manifest.json"))?;
// 资源画布会对每张卡片各读一次 manifest;这条读取只做「是否已登记」判定,
// 走带完整文件身份复核的进程内缓存,避免每次预览重复读取并解析整个 manifest。
let manifest = read_manifest_cached_for_preview(&root.join(".agent/manifest.json"))?;
cancellation.check()?;
let kind = match category.trim() {
"art" => ProjectMediaPreviewKind::Art,
@@ -31,7 +31,10 @@ pub(crate) struct LocalProjectImagePreview {
pub(crate) struct AgentRuntimeInspectionImage {
pub(crate) relative_path: String,
pub(crate) sha256: String,
/// 只有 Agent 视觉检查(`image.inspect`)需要内容摘要:它要把摘要写进动作审计并按摘要
/// 复核「检查过的就是当前这张图」。资源卡预览只需要字节、媒体类型和像素尺寸,摘要没有
/// 任何消费者,因此预览路径不计算它。取摘要必须走 [`Self::sha256_digest`],缺摘要即失败关闭。
pub(crate) sha256: Option<String>,
pub(crate) byte_len: u64,
pub(crate) media_type: &'static str,
pixel_width: u32,
@@ -48,6 +51,15 @@ impl AgentRuntimeInspectionImage {
)
}
pub(crate) fn sha256_digest(&self) -> Result<&str, String> {
self.sha256.as_deref().ok_or_else(|| {
format!(
"image.inspect 缺少内容摘要:{};摘要只在视觉检查读取路径上计算",
self.relative_path
)
})
}
fn data_url_with_cancellation(
&self,
cancellation: &ProjectResourcePreviewScopeCancellation,
@@ -83,8 +95,12 @@ pub(crate) fn load_local_project_image_preview_with_cancellation(
let absolute = resolve_local_project_path(root, &normalized)?;
validate_agent_runtime_inspection_ancestors(root, &absolute)?;
cancellation.check()?;
let image =
read_agent_runtime_inspection_image_with_cancellation(&absolute, normalized, cancellation)?;
let image = read_agent_runtime_inspection_image_with_cancellation(
&absolute,
normalized,
cancellation,
false,
)?;
cancellation.check()?;
Ok(LocalProjectImagePreview {
path: image.relative_path.clone(),
@@ -273,13 +289,21 @@ fn read_agent_runtime_inspection_image(
path,
relative_path,
&ProjectResourcePreviewScopeCancellation::uncancelled(),
true,
)
}
/// 单次受控读取:门禁、分块读取、漂移与替换复核、签名 / 尺寸校验对两条调用方完全一致,
/// 只有内容摘要按 `include_sha256` 分叉。
///
/// `include_sha256 = true` 是 Agent 视觉检查路径(`image.inspect`):摘要要写进动作审计,并在
/// `project_gates` 里按摘要复核「检查过的就是当前这张图」。资源卡预览路径传 `false`,因为它的
/// `LocalProjectImagePreview` 从来不消费摘要,只为它多算一遍全长 SHA-256 是纯开销。
fn read_agent_runtime_inspection_image_with_cancellation(
path: &Path,
relative_path: String,
cancellation: &ProjectResourcePreviewScopeCancellation,
include_sha256: bool,
) -> Result<AgentRuntimeInspectionImage, String> {
cancellation.check()?;
let (mut file, initial_metadata) = open_project_snapshot_regular_file(path, "视觉检查图片")?;
@@ -360,7 +384,7 @@ fn read_agent_runtime_inspection_image_with_cancellation(
));
}
cancellation.check()?;
let sha256 = format!("{:x}", Sha256::digest(&bytes));
let sha256 = include_sha256.then(|| format!("{:x}", Sha256::digest(&bytes)));
Ok(AgentRuntimeInspectionImage {
relative_path,
sha256,
@@ -582,17 +606,40 @@ fn runtime_path_component(value: &str, fallback: &str) -> String {
}
#[cfg(windows)]
fn metadata_is_windows_reparse_point(metadata: &fs::Metadata) -> bool {
pub(crate) fn metadata_is_windows_reparse_point(metadata: &fs::Metadata) -> bool {
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
}
#[cfg(not(windows))]
fn metadata_is_windows_reparse_point(_metadata: &fs::Metadata) -> bool {
pub(crate) fn metadata_is_windows_reparse_point(_metadata: &fs::Metadata) -> bool {
false
}
/// 已打开句柄的跨平台文件身份:Windows 用 `(volume serial, file index)`Unix 用 `(dev, ino)`。
/// 资源卡预览的 manifest 缓存用它判断「路径指向的还是同一个文件」。
pub(crate) fn open_file_identity_key(file: &fs::File) -> Result<(u64, u64), String> {
#[cfg(windows)]
{
let (volume_serial_number, file_index) = windows_file_identity(file)?;
Ok((u64::from(volume_serial_number), file_index))
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let metadata = file
.metadata()
.map_err(|error| format!("读取文件身份元数据失败:{error}"))?;
Ok((metadata.dev(), metadata.ino()))
}
#[cfg(not(any(unix, windows)))]
{
let _ = file;
Err("当前平台不支持文件身份判定".to_string())
}
}
#[cfg(unix)]
pub(crate) fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool {
use std::os::unix::fs::MetadataExt;
@@ -1,6 +1,8 @@
use super::*;
use super::filesystem::validate_portable_project_path_component;
use crate::image_inspect::{metadata_is_windows_reparse_point, open_file_identity_key};
use std::collections::VecDeque;
const MANIFEST_LOCK_WAIT_ATTEMPTS: usize = 500;
const MANIFEST_LOCK_WAIT_MILLIS: u64 = 10;
@@ -1439,6 +1441,118 @@ fn remove_manifest_backup(path: &Path) -> Result<(), String> {
}
}
/// 资源卡预览专用的 manifest 读取:命中进程内缓存时跳过重复的整文件读取与 JSON 解析。
///
/// 背景:一次资源画布装载会对每张卡片各读一次 manifest(52 张图 = 52 次),实测约 3.15 MiB
/// 重复字节与 52 次 `serde_json` 解析。命中判定必须**每个命中都重新取一次当前文件身份**
/// (路径元数据 + 普通文件/符号链接复核 + `(volume, file index)` 或 `(dev, ino)`),长度与修改
/// 时间也必须一致,否则回落到 [`read_manifest`] 重新读取。失败一律不入缓存,错误语义与
/// [`read_manifest`] 完全一致。
///
/// 这条缓存只服务「这个资源是否已登记」这一层判断;它不放松 `file.read` 权限、项目边界、
/// 敏感路径、普通文件或读取漂移门禁 —— 那些门禁分别在 [`crate::commands::read_local_project_image_preview_at`]
/// 与真实字节读取路径上执行,且真实读取从不经过本缓存。
pub(crate) fn read_manifest_cached_for_preview(
path: &Path,
) -> Result<GameCreationAppManifest, String> {
if let Some(cached) = cached_preview_manifest_if_unchanged(path) {
return Ok(cached);
}
let manifest = read_manifest(path)?;
remember_preview_manifest(path, &manifest);
Ok(manifest)
}
#[derive(Clone)]
struct CachedPreviewManifest {
len: u64,
modified: Option<SystemTime>,
identity: Option<(u64, u64)>,
manifest: GameCreationAppManifest,
}
/// 缓存容量只需覆盖「同时打开的项目数」,取 8 已远大于实际并发。
const PREVIEW_MANIFEST_CACHE_LIMIT: usize = 8;
fn preview_manifest_cache() -> &'static Mutex<VecDeque<(String, CachedPreviewManifest)>> {
static CACHE: OnceLock<Mutex<VecDeque<(String, CachedPreviewManifest)>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(VecDeque::new()))
}
/// 写入 manifest 后主动丢弃该路径的预览缓存:命中的后备判据是「长度 + mtime + 文件身份」,
/// 同长度的原地改写(两次 `write_manifest` 落在同一 mtime 粒度内)不能只靠它兜住,
/// 所以真正的写入侧必须显式失效。读取侧的身份复核仍然保留,用来兜住绕过本函数的改写。
fn forget_preview_manifest(path: &Path) {
let key = path.to_string_lossy().into_owned();
let Ok(mut cache) = preview_manifest_cache().lock() else {
return;
};
if let Some(index) = cache.iter().position(|(cached_key, _)| cached_key == &key) {
cache.remove(index);
}
}
fn cached_preview_manifest_if_unchanged(path: &Path) -> Option<GameCreationAppManifest> {
let key = path.to_string_lossy().into_owned();
let metadata = fs::symlink_metadata(path).ok()?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
return None;
}
if metadata_is_windows_reparse_point(&metadata) {
return None;
}
let len = metadata.len();
let modified = metadata.modified().ok();
let identity = fs::File::open(path)
.ok()
.and_then(|file| open_file_identity_key(&file).ok());
let mut cache = preview_manifest_cache().lock().ok()?;
let index = cache
.iter()
.position(|(cached_key, _)| cached_key == &key)?;
let cached = &cache[index].1;
if cached.len != len || cached.modified != modified || cached.identity != identity {
cache.remove(index);
return None;
}
let manifest = cached.manifest.clone();
// 命中即提升到尾部,保持最近使用顺序。
let entry = cache.remove(index)?;
cache.push_back(entry);
Some(manifest)
}
fn remember_preview_manifest(path: &Path, manifest: &GameCreationAppManifest) {
let key = path.to_string_lossy().into_owned();
let Ok(metadata) = fs::symlink_metadata(path) else {
return;
};
if metadata.file_type().is_symlink() || !metadata.is_file() {
return;
}
let identity = fs::File::open(path)
.ok()
.and_then(|file| open_file_identity_key(&file).ok());
let Ok(mut cache) = preview_manifest_cache().lock() else {
return;
};
if let Some(index) = cache.iter().position(|(cached_key, _)| cached_key == &key) {
cache.remove(index);
}
cache.push_back((
key,
CachedPreviewManifest {
len: metadata.len(),
modified: metadata.modified().ok(),
identity,
manifest: manifest.clone(),
},
));
while cache.len() > PREVIEW_MANIFEST_CACHE_LIMIT {
cache.pop_front();
}
}
pub(crate) fn read_manifest(path: &Path) -> Result<GameCreationAppManifest, String> {
let backup_path = manifest_backup_path(path);
let _ = prepare_game_creator_private_path_for_read(path, false, "manifest")?;
@@ -1595,7 +1709,11 @@ where
}
let _write_lock = acquire_manifest_write_lock(path)?;
after_lock();
write_manifest_locked(path, manifest, allowed_version_removals)
let result = write_manifest_locked(path, manifest, allowed_version_removals);
if result.is_ok() {
forget_preview_manifest(path);
}
result
}
fn write_manifest_locked(
@@ -8297,3 +8297,16 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- **标签库**`packages/shared/src/contracts/gameCreationAppAssetTagLibrary.ts` 纯派生(沿用写入路径的 `normalizeGameCreationAppAssetTags` 归一化,按"使用次数降序 → 标签 `zh-CN` 升序"稳定排序并带 `assetIds`),**不新增持久化字段**;画布按标签筛选与 `category` 筛选可叠加,且与 `@` 面板、参考图弹窗的筛选状态互相独立。
- **素材重命名前端**:入口挂在资源卡选中工具条的 `extraActions`(与"分类与标签"并列),严格按 `invoke('rename_local_project_asset', { input: { projectPath, assetId, newFileName } })` 三字段调用(Rust 侧 `deny_unknown_fields`),成功后复用既有 `reloadManifestAfterAssetCommand` 刷新卡片与 `@` 面板显示名。上文记录的"重命名只落 Rust 侧"的**已知中间状态到此结束**:前端调用方已落地。
- **验证**`typecheck`(含 `check-config`exit 0AGC 全量 1092 passed / 4 skipped / 0 failed;共享组件 1385 passed`cargo check --all-targets` 通过;编码检查与 `git diff --check` 干净。
## 2026-09-11 AGC 资源卡预览维持 data URL + 纯内存 LRU,不引入 Tauri asset 协议
- **背景**:用户反馈「图片是 `assets/` 里的本地不可变素材,为什么每次启动客户端都要重新加载一轮预览」。排查确认现象属真:预览终态缓存(`48` 项 / `64 MiB`)只存在于 `useProjectResourceCardPreviews` 的 React 内存里,Rust 侧 `ProjectResourcePreviewReadManager` 只登记「谁在读」、从不缓存字节,全仓无 localStorage / IndexedDB / 磁盘预览缓存;进程退出即全部丢弃,下次进画布按 `IntersectionObserver` + `eagerPreviewLimit: 12` 重新读、base64、走 IPC、解码。**由此产生一条反复被重提的候选方案**:改用 Tauri `asset:` 协议 + `convertFileSrc` 直接给 WebView 文件 URL,以绕开 base64 IPC 并吃 WebView 自己的 HTTP 磁盘缓存。本条目把这个候选正式否决,避免后续 Agent 再次提出。
- **决策一:不改持久化策略,`asset:` 协议路线不采纳。** 原始合同是 [`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`](../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md) 的「**Rust data URL 只承担一次 IPC 传输,前端立即按实际解码字节建立 Blob URL 并丢弃 base64 字符串;不向 WebView 暴露任意本机文件协议或绝对路径**」,PRD §3.3.2 同口径要求「**Tauri 返回的 data URL 只允许作为 IPC 临时载体**」且「项目边界、manifest / 任务登记、`file.read` 策略、普通文件 / 链接、签名、尺寸和读取漂移门禁不变」。给 WebView 一个指向项目文件的 URL 与这两条正面冲突。
- **决策二:技术上要打通需要什么(记录在案,便于日后评估而非立即实施)。**`apps/ai-game-creator-shell/src-tauri/tauri.conf.json` 需新增 `app.security.assetProtocol = { enable: true, scope: ... }`,否则 WebView 报 "asset protocol not configured to allow the path";② `apps/ai-game-creator-shell/src-tauri/Cargo.toml``tauri = { version = "2.11.2", features = [] }` 需加 `protocol-asset` feature(该 feature 存在且 `protocol-asset = ["http-range"]`);③ CSP 里 `img-src` / `media-src` 已有 `asset:`,这部分无需改动。
- **决策三:会绕过哪些门禁(逐条,这是否决的实质理由)。** 项目根 `projectPath` 是用户运行时选定的任意目录,而 `assetProtocol.scope` 是**静态 glob**,只能写成 `**/*`(Tauri 官方文档对该形态明确标注 "use with extreme care")—— 等于把**整机文件读权限**交给 WebView,并一次性绕过 `read_local_project_image_preview_at` / `read_local_project_text_preview_at` / `read_local_project_media_preview_at` 上的全部校验:`file.read` auto 权限策略(`enforce_project_auto_permission_policy`)、`normalize_relative_path` 项目边界、`assets/``game/` 前缀限制、manifest 资产 / 已完成任务产物**登记复核**、`reject_sensitive_project_file_read` 敏感路径、普通文件与父目录链接 / reparse point 复核(`validate_agent_runtime_inspection_ancestors`)、`nlink == 1` 硬链接复核、读取漂移与重开身份复核(`same_open_file_snapshot` / `same_open_file_identity`)、**按魔术字而非扩展名的类型校验**、单文件与像素尺寸上限,以及**取消语义**(`asset:` 请求不携带 `scopeId` / `requestId``cancel_local_project_resource_preview_scope` 与全局 3 permit 管理器对它将完全失效)。
- **决策四:收益与代价不成比例。** 真机项目(52 张 manifest 登记 PNG29.32 MiB)分层实测:52 个文件冷读 ≈ 13 ms、base64 编码 23 ms、SHA-256 17 ms、JS 侧 `atob` + `Uint8Array` 拷贝(照抄 `materializeProjectResourceCardPreview`137 ms,即 Rust + JS 合计 ≈ 0.3 s;**用户感知到的「一轮一轮加载」主因不在重读,而在资源卡 `<img>` 解码 37.2 MPx(解码后位图 148.6 MiB)且未声明 `decoding="async"`**。用整机文件读权限 + 全部门禁作废去换这 0.3 s,不是可接受的交换。同理**不引入磁盘缩略图缓存**:仓库已在 `pitfalls.md`「generated 图片重复下载不要改成服务端本地磁盘缓存」确立过「优先走签名 + `Cache-Control`,不要把内容落到本地磁盘缓存」的价值观。
- **决策五:本轮只做合同内的小优化。** ① 资源卡 `<img>``decoding="async"`(解码移出主线程,收益最大);② Rust 侧为**预览路径**新增带完整文件身份复核的进程内 manifest 缓存(`read_manifest_cached_for_preview`,键 = 路径 + 长度 + mtime + `(volume serial, file index)` / `(dev, ino)`),`read_manifest` 本身与写入路径的后回读**保持每次都真读**;③ 热预取只喂当前可见栏目;④ 预览链上无消费者的 `Sha256::digest``include_sha256` 分叉掉,Agent `image.inspect` 路径继续计算摘要。**不把 Blob 缓存提升到项目级**(会与 PRD §3.3.2 的 scope epoch 隔离合同冲突,需改 PRD 并重审取消语义),**不新增 manifest 字段 / sidecar / SpacetimeDB 表**。
- **影响范围**`apps/ai-game-creator-shell/src/view/project-development/{index.tsx,useProjectResourceCardPreviews.ts}``src-tauri/src/image_inspect.rs``src-tauri/src/project/manifest.rs``src-tauri/src/commands.rs``src-tauri/src/agent/runtime_tools/media.rs``src-tauri/src/agent/runtime_actions/project_gates.rs``tauri.conf.json``Cargo.toml` **本次不动**(asset 协议所需配置仅作为分析结论记录)。
- **验证方式**:Rust 定向测试覆盖预览读取、登记 / 未登记与权限边界、`image.inspect` 摘要仍写入审计;AGC 子集(基线 1167 passed / 4 skipped / 0 failed+ 共享组件(1385)不得回归;`typecheck``npm run check:encoding``git diff --check` 干净。真机 52 张 PNG 的分层耗时用只读脚本在 `$env:TEMP` 复测,不落仓库。
- **关联文档**`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md``docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md``docs/project-memory/shared-memory/pitfalls.md`
@@ -1226,6 +1226,15 @@
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "mud points"``npm run test -- src/services/bark-battle-creation/barkBattleCreationClient.test.ts``cargo test -p api-server --manifest-path server-rs/Cargo.toml resolves_mud_point_cost initial_generation_slot_cost_splits_creation_entry_total_cost -- --nocapture`
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``server-rs/crates/api-server/src/creation_entry_config.rs``server-rs/crates/api-server/src/puzzle/handlers.rs``server-rs/crates/api-server/src/match3d/draft.rs``server-rs/crates/api-server/src/bark_battle.rs``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 资源卡预览每次启动重新加载不要改成 Tauri asset 协议(2026-09-11
- 现象:用户反馈「图片是 `<项目>/assets/` 里的本地不可变素材,为什么每次启动客户端资源画布的卡片预览都要重新加载一轮」。现象属真,且不是 bug。
- 原因(设计如此,三处原文):预览终态缓存只是 `useProjectResourceCardPreviews` 里的 React 内存 LRU`48` 项 / `64 MiB`),Rust 侧 `ProjectResourcePreviewReadManager` 只登记「谁在读」、从不缓存字节,全仓无 localStorage / IndexedDB / 磁盘预览缓存,进程退出即全丢。口径来自 PRD §3.3.2「**Tauri 返回的 data URL 只允许作为 IPC 临时载体**,进入 React 状态前必须转换为可撤销的 Blob URL」与技术方案「**Rust data URL 只承担一次 IPC 传输……不向 WebView 暴露任意本机文件协议或绝对路径**」——`assets/` 里的文件是**不可变素材源文件**,不是可直接交给 WebView 的 URL。
- 判据陷阱(最容易走错的一步):把「每次启动重读」当成本瓶颈。真机项目(52 张 manifest 登记 PNG / 29.32 MiB)分层实测,52 个文件冷读 ≈ 13 ms、base64 23 ms、SHA-256 17 ms、JS 侧 `atob` + `Uint8Array` 137 ms**Rust + JS 合计只有 ≈ 0.3 s**;用户实际看到的「一轮一轮加载」主因是资源卡 `<img>` 要解码 **37.2 MPx**(解码后 RGBA 位图 148.6 MiB)却没有 `decoding="async"`。先量再改,别用「省掉一次重读」的名义动安全链路。
- 处理:**不引入 `asset:` 协议,不引入磁盘缩略图缓存,不把 Blob 缓存提升到项目级。** 若日后仍有人提 asset 协议,先看这三条硬约束:① `apps/ai-game-creator-shell/src-tauri/tauri.conf.json` 当前**没有** `app.security.assetProtocol` 块,`apps/ai-game-creator-shell/src-tauri/Cargo.toml``tauri = { version = "2.11.2", features = [] }` 也**没开** `protocol-asset`(该 feature 存在,`protocol-asset = ["http-range"]`);② `scope` 是静态 glob,而 `projectPath` 是运行时任意目录,只能写 `**/*`Tauri 官方文档标注 "use with extreme care"),等于把**整机文件读权限**交给 WebView;③ 它会一次性绕过 `file.read` auto 权限、`normalize_relative_path` 项目边界、`assets/` / `game/` 前缀、manifest 登记复核、敏感路径、父目录链接 / reparse point、硬链接、读取漂移与重开身份、按魔术字的类型校验、尺寸上限,以及**取消语义**(`asset:` 请求不带 `scopeId` / `requestId``cancel_local_project_resource_preview_scope` 与全局 3 permit 管理器失效)。合同内可做的只有:`<img>``decoding="async"`、预览路径的 manifest 进程内缓存(`read_manifest_cached_for_preview`,命中也要复核路径 + 长度 + mtime + 文件身份)、热预取只喂当前可见栏目、按 `include_sha256` 分叉掉预览链上无消费者的摘要。
- 验证:Rust 定向测试覆盖预览读取 / 登记 / 权限边界与 `image.inspect` 摘要;AGC 子集(基线 1167 passed / 4 skipped / 0 failed)与共享组件(1385)不回归;编码检查与 `git diff --check` 干净。耗时用只读脚本在 `$env:TEMP` 复测并对齐上表口径,**不往仓库里留探针**。
- 关联:`apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts``apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts``apps/ai-game-creator-shell/src-tauri/src/{image_inspect.rs,resource_preview_scheduler.rs,commands.rs}``apps/ai-game-creator-shell/src-tauri/{tauri.conf.json,Cargo.toml}``docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md``docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md``docs/project-memory/shared-memory/decision-log.md`
## generated 图片重复下载不要改成服务端本地磁盘缓存
- 现象:同一张 OSS generated 图片每次展示都重新从 OSS 拉取,或者完整 OSS 私有 URL 裸请求返回 403。