预览链去掉无消费者的摘要计算,并为预览读取加进程内 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:
@@ -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 0;AGC 全量 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 登记 PNG,29.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。
|
||||
|
||||
Reference in New Issue
Block a user