完成资源聚焦阶段四
Project CI / Repository checks (pull_request) Successful in 1m16s
Project CI / Frontend tests (pull_request) Failing after 2m37s
Project CI / Backend tests (pull_request) Successful in 3m29s
Project CI / Native shell tests (pull_request) Successful in 11m21s

新增项目文档与媒体资源安全读取命令
补齐Markdown、扩展美术媒体与音频聚焦展示
增加安全边界与工作台回归测试
同步PRD、技术方案与共享决策
This commit is contained in:
2026-08-03 17:20:14 +08:00
parent 2c65878b60
commit d374f3292a
11 changed files with 1211 additions and 26 deletions
@@ -1177,6 +1177,66 @@ pub(crate) fn read_local_project_image_preview(
load_local_project_image_preview(root, &normalized_path)
}
#[tauri::command]
pub(crate) fn read_local_project_text_preview(
project_path: String,
relative_path: String,
) -> Result<LocalProjectTextPreview, String> {
let root = Path::new(project_path.trim());
enforce_project_auto_permission_policy(root, "file.read")?;
let normalized_path = normalize_relative_path(relative_path.trim())?;
let manifest = read_manifest(&root.join(".agent/manifest.json"))?;
let is_registered_document = manifest.assets.iter().any(|asset| {
asset.local_path == normalized_path
&& is_supported_project_text_resource(&asset.local_path, &asset.media_type)
}) || manifest.tasks.iter().any(|task| {
task.status == GameCreationAppTaskStatus::Completed
&& task.artifacts.iter().any(|path| path == &normalized_path)
&& is_supported_project_text_resource(&normalized_path, "")
});
if !is_registered_document {
return Err("只能读取当前项目已登记的文档资源".to_string());
}
load_local_project_text_preview(root, &normalized_path)
}
#[tauri::command]
pub(crate) fn read_local_project_media_preview(
project_path: String,
relative_path: String,
category: String,
) -> Result<LocalProjectMediaPreview, String> {
let root = Path::new(project_path.trim());
enforce_project_auto_permission_policy(root, "file.read")?;
let normalized_path = normalize_relative_path(relative_path.trim())?;
let manifest = read_manifest(&root.join(".agent/manifest.json"))?;
let kind = match category.trim() {
"art" => ProjectMediaPreviewKind::Art,
"audio" => ProjectMediaPreviewKind::Audio,
_ => return Err("媒体预览类别只支持 art 或 audio".to_string()),
};
let is_registered_media = manifest.assets.iter().any(|asset| {
asset.local_path == normalized_path
&& match kind {
ProjectMediaPreviewKind::Art => {
is_supported_project_art_media_resource(&asset.local_path, &asset.media_type)
}
ProjectMediaPreviewKind::Audio => {
is_supported_project_audio_resource(&asset.local_path, &asset.media_type)
}
}
}) || (kind == ProjectMediaPreviewKind::Art
&& manifest.tasks.iter().any(|task| {
task.status == GameCreationAppTaskStatus::Completed
&& task.artifacts.iter().any(|path| path == &normalized_path)
&& is_supported_project_art_media_resource(&normalized_path, "")
}));
if !is_registered_media {
return Err("只能预览当前项目已登记的媒体资源".to_string());
}
load_local_project_media_preview(root, &normalized_path, kind)
}
#[tauri::command]
pub(crate) fn write_local_project_file(
project_path: String,
@@ -204,7 +204,10 @@ fn validate_agent_runtime_inspection_path(
Ok(())
}
fn validate_agent_runtime_inspection_ancestors(root: &Path, path: &Path) -> Result<(), String> {
pub(crate) fn validate_agent_runtime_inspection_ancestors(
root: &Path,
path: &Path,
) -> Result<(), String> {
let relative = path
.strip_prefix(root)
.map_err(|_| "image.inspect 图片路径超出项目目录".to_string())?;
@@ -450,7 +453,7 @@ fn metadata_is_windows_reparse_point(_metadata: &fs::Metadata) -> bool {
}
#[cfg(unix)]
fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool {
pub(crate) fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool {
use std::os::unix::fs::MetadataExt;
left.dev() == right.dev()
&& left.ino() == right.ino()
@@ -463,12 +466,12 @@ fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool {
}
#[cfg(not(unix))]
fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool {
pub(crate) fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool {
left.len() == right.len() && left.modified().ok() == right.modified().ok()
}
#[cfg(unix)]
fn same_open_file_identity(
pub(crate) fn same_open_file_identity(
_left_file: &fs::File,
left: &fs::Metadata,
_right_file: &fs::File,
@@ -479,7 +482,7 @@ fn same_open_file_identity(
}
#[cfg(windows)]
fn same_open_file_identity(
pub(crate) fn same_open_file_identity(
left_file: &fs::File,
_left: &fs::Metadata,
right_file: &fs::File,
@@ -489,7 +492,7 @@ fn same_open_file_identity(
}
#[cfg(not(any(unix, windows)))]
fn same_open_file_identity(
pub(crate) fn same_open_file_identity(
_left_file: &fs::File,
left: &fs::Metadata,
_right_file: &fs::File,
@@ -72,6 +72,7 @@ mod project;
mod provider_handoff;
mod provider_retry;
mod repository_context;
mod resource_inspect;
mod runner;
mod swarm_cli;
mod tool_plan_handoff;
@@ -101,6 +102,7 @@ use preview::*;
use process_session::*;
use project::*;
use repository_context::*;
use resource_inspect::*;
use runner::*;
use swarm_cli::*;
use user_input::*;
@@ -2348,6 +2350,8 @@ fn main() {
list_local_project_files,
read_local_project_file,
read_local_project_image_preview,
read_local_project_text_preview,
read_local_project_media_preview,
write_local_project_file,
delete_local_project_file,
read_local_game_memory,
@@ -0,0 +1,441 @@
use crate::image_inspect::{
same_open_file_identity, same_open_file_snapshot, validate_agent_runtime_inspection_ancestors,
};
use crate::project::{
normalize_relative_path, open_project_snapshot_regular_file,
reject_sensitive_project_file_read, resolve_local_project_path,
};
use base64::Engine as _;
use serde::Serialize;
use std::io::Read;
use std::path::Path;
const PROJECT_TEXT_PREVIEW_MAX_FILE_BYTES: u64 = 2 * 1024 * 1024;
const PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES: u64 = 32 * 1024 * 1024;
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LocalProjectTextPreview {
pub(crate) path: String,
pub(crate) media_type: String,
pub(crate) byte_len: u64,
pub(crate) content: String,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LocalProjectMediaPreview {
pub(crate) path: String,
pub(crate) media_type: String,
pub(crate) byte_len: u64,
pub(crate) data_url: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ProjectMediaPreviewKind {
Art,
Audio,
}
pub(crate) fn is_supported_project_text_resource(path: &str, media_type: &str) -> bool {
let media_type = media_type.trim().to_ascii_lowercase();
matches!(
path_extension(path).as_deref(),
Some("md" | "markdown" | "mdx" | "txt" | "json" | "yaml" | "yml" | "toml")
) && (media_type.is_empty()
|| media_type.starts_with("text/")
|| media_type.contains("json")
|| media_type.contains("yaml")
|| matches!(
media_type.as_str(),
"项目文档" | "application/toml" | "application/mdx"
))
}
pub(crate) fn is_supported_project_art_media_resource(path: &str, media_type: &str) -> bool {
let media_type = media_type.trim().to_ascii_lowercase();
matches!(
path_extension(path).as_deref(),
Some("gif" | "svg" | "avif" | "bmp" | "mp4" | "webm" | "mov")
) || media_type.starts_with("video/")
|| media_type == "image/svg+xml"
}
pub(crate) fn is_supported_project_audio_resource(path: &str, media_type: &str) -> bool {
let media_type = media_type.trim().to_ascii_lowercase();
matches!(
path_extension(path).as_deref(),
Some("mp3" | "wav" | "ogg" | "m4a" | "aac" | "flac" | "opus")
) || media_type.starts_with("audio/")
}
pub(crate) fn load_local_project_text_preview(
root: &Path,
relative_path: &str,
) -> Result<LocalProjectTextPreview, String> {
let normalized = normalize_relative_path(relative_path.trim())?;
reject_sensitive_project_file_read(&normalized)?;
let media_type = project_text_media_type(&normalized)
.ok_or_else(|| "文档预览只支持 Markdown、文本、JSON、YAML 和 TOML".to_string())?;
let bytes = read_stable_project_resource(
root,
&normalized,
PROJECT_TEXT_PREVIEW_MAX_FILE_BYTES,
"项目文档",
)?;
let content =
String::from_utf8(bytes).map_err(|_| "文档预览只支持 UTF-8 编码的文本文件".to_string())?;
Ok(LocalProjectTextPreview {
path: normalized,
media_type: media_type.to_string(),
byte_len: content.len() as u64,
content,
})
}
pub(crate) fn load_local_project_media_preview(
root: &Path,
relative_path: &str,
kind: ProjectMediaPreviewKind,
) -> Result<LocalProjectMediaPreview, String> {
let normalized = normalize_relative_path(relative_path.trim())?;
reject_sensitive_project_file_read(&normalized)?;
let bytes = read_stable_project_resource(
root,
&normalized,
PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES,
"项目媒体资源",
)?;
if bytes.is_empty() {
return Err("媒体文件为空,无法预览".to_string());
}
let media_type = detect_project_media_type(&normalized, &bytes, kind)?;
Ok(LocalProjectMediaPreview {
path: normalized,
media_type: media_type.to_string(),
byte_len: bytes.len() as u64,
data_url: format!(
"data:{media_type};base64,{}",
base64::engine::general_purpose::STANDARD.encode(bytes)
),
})
}
fn read_stable_project_resource(
root: &Path,
normalized: &str,
max_bytes: u64,
label: &str,
) -> Result<Vec<u8>, String> {
let absolute = resolve_local_project_path(root, normalized)?;
validate_agent_runtime_inspection_ancestors(root, &absolute)?;
let (mut file, initial_metadata) = open_project_snapshot_regular_file(&absolute, label)?;
if initial_metadata.len() > max_bytes {
return Err(format!("{label}不能超过 {} MiB", max_bytes / 1024 / 1024));
}
let mut bytes = Vec::with_capacity(initial_metadata.len() as usize);
file.by_ref()
.take(max_bytes + 1)
.read_to_end(&mut bytes)
.map_err(|error| format!("读取{label}失败:{normalized}: {error}"))?;
if bytes.len() as u64 > max_bytes {
return Err(format!("{label}不能超过 {} MiB", max_bytes / 1024 / 1024));
}
let final_metadata = file
.metadata()
.map_err(|error| format!("复核{label}失败:{normalized}: {error}"))?;
if initial_metadata.len() != bytes.len() as u64
|| final_metadata.len() != bytes.len() as u64
|| !same_open_file_snapshot(&initial_metadata, &final_metadata)
{
return Err(format!("{label}读取期间发生漂移:{normalized}"));
}
let (reopened, reopened_metadata) = open_project_snapshot_regular_file(&absolute, label)?;
if !same_open_file_identity(&file, &initial_metadata, &reopened, &reopened_metadata)? {
return Err(format!("{label}路径读取期间发生替换:{normalized}"));
}
Ok(bytes)
}
fn project_text_media_type(path: &str) -> Option<&'static str> {
match path_extension(path).as_deref()? {
"md" | "markdown" | "mdx" => Some("text/markdown"),
"txt" => Some("text/plain"),
"json" => Some("application/json"),
"yaml" | "yml" => Some("application/yaml"),
"toml" => Some("application/toml"),
_ => None,
}
}
fn detect_project_media_type(
path: &str,
bytes: &[u8],
kind: ProjectMediaPreviewKind,
) -> Result<&'static str, String> {
if kind == ProjectMediaPreviewKind::Art && path_extension(path).as_deref() == Some("svg") {
validate_safe_svg(bytes)?;
return Ok("image/svg+xml");
}
if kind == ProjectMediaPreviewKind::Art {
if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
return Ok("image/gif");
}
if bytes.starts_with(b"BM") {
return Ok("image/bmp");
}
if is_avif(bytes) {
return Ok("image/avif");
}
if is_iso_base_media(bytes) {
return Ok(if path_extension(path).as_deref() == Some("mov") {
"video/quicktime"
} else {
"video/mp4"
});
}
if bytes.starts_with(&[0x1a, 0x45, 0xdf, 0xa3]) {
return Ok("video/webm");
}
return Err("美术媒体预览只支持 GIF、安全 SVG、AVIF、BMP、MP4、WebM 或 MOV".to_string());
}
if looks_like_id3(bytes) || looks_like_mp3_frame(bytes) {
Ok("audio/mpeg")
} else if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WAVE" {
Ok("audio/wav")
} else if bytes.starts_with(b"OggS") {
Ok("audio/ogg")
} else if bytes.starts_with(b"fLaC") {
Ok("audio/flac")
} else if is_avif(bytes) {
Err("音乐音效文件签名与登记类型不一致".to_string())
} else if is_iso_base_media(bytes) {
Ok("audio/mp4")
} else if looks_like_aac_adts(bytes) {
Ok("audio/aac")
} else {
Err("音乐音效预览只支持 MP3、WAV、OGG、M4A、AAC、FLAC 或 Opus".to_string())
}
}
fn validate_safe_svg(bytes: &[u8]) -> Result<(), String> {
let text = std::str::from_utf8(bytes).map_err(|_| "SVG 必须使用 UTF-8 编码".to_string())?;
let lower = text.to_ascii_lowercase();
if !lower.contains("<svg") {
return Err("SVG 内容无效,无法预览".to_string());
}
let forbidden = [
"<script",
"<style",
"<foreignobject",
"<image",
"<!doctype",
"<!entity",
"&#",
"javascript:",
"file:",
"@import",
];
let external_url_probe = lower
.replace("http://www.w3.org/2000/svg", "")
.replace("http://www.w3.org/1999/xlink", "");
if forbidden.iter().any(|value| lower.contains(value))
|| external_url_probe.contains("http://")
|| external_url_probe.contains("https://")
|| contains_svg_event_handler(&lower)
|| contains_unsafe_svg_href(&lower)
|| contains_unsafe_svg_url(&lower)
{
return Err("SVG 包含脚本或外部资源引用,无法安全预览".to_string());
}
Ok(())
}
fn contains_unsafe_svg_href(text: &str) -> bool {
let mut remaining = text;
while let Some(index) = remaining.find("href") {
let after_name = &remaining[index + 4..];
let Some(after_equals) = after_name.trim_start().strip_prefix('=') else {
remaining = after_name;
continue;
};
let value = after_equals.trim_start();
let value = value
.strip_prefix('\'')
.or_else(|| value.strip_prefix('"'))
.unwrap_or(value)
.trim_start();
if !value.starts_with('#') {
return true;
}
remaining = after_name;
}
false
}
fn contains_unsafe_svg_url(text: &str) -> bool {
let mut remaining = text;
while let Some(index) = remaining.find("url(") {
let value = remaining[index + 4..].trim_start();
let value = value
.strip_prefix('\'')
.or_else(|| value.strip_prefix('"'))
.unwrap_or(value)
.trim_start();
if !value.starts_with('#') {
return true;
}
remaining = &remaining[index + 4..];
}
false
}
fn contains_svg_event_handler(text: &str) -> bool {
let bytes = text.as_bytes();
let mut index = 0usize;
while index + 3 < bytes.len() {
if bytes[index].is_ascii_whitespace() && bytes[index + 1..].starts_with(b"on") {
let mut cursor = index + 3;
while cursor < bytes.len() && bytes[cursor].is_ascii_alphabetic() {
cursor += 1;
}
while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
cursor += 1;
}
if cursor < bytes.len() && bytes[cursor] == b'=' {
return true;
}
}
index += 1;
}
false
}
fn is_iso_base_media(bytes: &[u8]) -> bool {
bytes.len() >= 12 && &bytes[4..8] == b"ftyp"
}
fn is_avif(bytes: &[u8]) -> bool {
is_iso_base_media(bytes)
&& (&bytes[8..12] == b"avif"
|| &bytes[8..12] == b"avis"
|| bytes[8..].windows(4).any(|brand| brand == b"avif"))
}
fn looks_like_mp3_frame(bytes: &[u8]) -> bool {
bytes.len() >= 4
&& bytes[0] == 0xff
&& bytes[1] & 0xe0 == 0xe0
&& bytes[1] & 0x06 != 0
&& bytes[2] & 0xf0 != 0xf0
&& bytes[2] & 0x0c != 0x0c
}
fn looks_like_id3(bytes: &[u8]) -> bool {
if bytes.len() < 10 || !bytes.starts_with(b"ID3") || bytes[3] == 0xff || bytes[4] == 0xff {
return false;
}
let size_bytes = &bytes[6..10];
if size_bytes.iter().any(|byte| byte & 0x80 != 0) {
return false;
}
let tag_size = size_bytes
.iter()
.fold(0usize, |size, byte| (size << 7) | usize::from(*byte));
10usize
.checked_add(tag_size)
.is_some_and(|required| required <= bytes.len())
}
fn looks_like_aac_adts(bytes: &[u8]) -> bool {
bytes.len() >= 2 && bytes[0] == 0xff && bytes[1] & 0xf6 == 0xf0
}
fn path_extension(path: &str) -> Option<String> {
Path::new(path)
.extension()
.and_then(|extension| extension.to_str())
.map(str::to_ascii_lowercase)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn text_preview_requires_utf8_and_a_supported_extension() {
let root = tempfile::tempdir().expect("temp root");
fs::create_dir_all(root.path().join("docs")).expect("docs dir");
fs::write(root.path().join("docs/design.md"), "# 设计\n\n正文").expect("markdown");
fs::write(root.path().join("docs/legacy.txt"), [0xff, 0xfe]).expect("legacy text");
fs::write(root.path().join("docs/page.html"), "<h1>unsafe</h1>").expect("html");
let preview =
load_local_project_text_preview(root.path(), "docs/design.md").expect("load markdown");
assert_eq!(preview.media_type, "text/markdown");
assert!(preview.content.contains("正文"));
assert!(load_local_project_text_preview(root.path(), "docs/legacy.txt").is_err());
assert!(load_local_project_text_preview(root.path(), "docs/page.html").is_err());
}
#[test]
fn media_preview_accepts_safe_svg_and_rejects_active_svg() {
let root = tempfile::tempdir().expect("temp root");
fs::create_dir_all(root.path().join("assets")).expect("assets dir");
fs::write(
root.path().join("assets/icon.svg"),
"<svg xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M0 0\"/></svg>",
)
.expect("svg");
fs::write(
root.path().join("assets/active.svg"),
"<svg xmlns=\"http://www.w3.org/2000/svg\" onload=\"alert(1)\"/>",
)
.expect("active svg");
fs::write(
root.path().join("assets/external.svg"),
"<svg xmlns=\"http://www.w3.org/2000/svg\"><use href = \"https://example.com/icon.svg#x\"/></svg>",
)
.expect("external svg");
let preview = load_local_project_media_preview(
root.path(),
"assets/icon.svg",
ProjectMediaPreviewKind::Art,
)
.expect("safe svg");
assert_eq!(preview.media_type, "image/svg+xml");
assert!(preview.data_url.starts_with("data:image/svg+xml;base64,"));
assert!(load_local_project_media_preview(
root.path(),
"assets/active.svg",
ProjectMediaPreviewKind::Art,
)
.is_err());
assert!(load_local_project_media_preview(
root.path(),
"assets/external.svg",
ProjectMediaPreviewKind::Art,
)
.is_err());
}
#[cfg(unix)]
#[test]
fn resource_preview_rejects_symlink_and_hardlink_files() {
use std::os::unix::fs::symlink;
let root = tempfile::tempdir().expect("temp root");
let outside = tempfile::tempdir().expect("outside");
fs::create_dir_all(root.path().join("docs")).expect("docs dir");
let source = outside.path().join("source.md");
fs::write(&source, "secret").expect("source");
symlink(&source, root.path().join("docs/link.md")).expect("symlink");
fs::hard_link(&source, root.path().join("docs/hard.md")).expect("hardlink");
assert!(load_local_project_text_preview(root.path(), "docs/link.md").is_err());
assert!(load_local_project_text_preview(root.path(), "docs/hard.md").is_err());
}
}
@@ -6090,3 +6090,107 @@ fn local_project_image_preview_obeys_auto_file_read_policy() {
fs::remove_dir_all(root).ok();
}
#[test]
fn local_project_resource_previews_require_registered_safe_resources() {
let root = unique_project_path();
init_local_game_project_at(&root, "resource-preview-policy", "资源预览策略项目")
.expect("project init");
fs::create_dir_all(root.join("assets")).expect("asset dir");
fs::create_dir_all(root.join("game")).expect("game dir");
fs::write(root.join("game/design.md"), "# 玩法设计\n\n安全正文").expect("project document");
fs::write(
root.join("assets/icon.svg"),
"<svg xmlns=\"http://www.w3.org/2000/svg\"><circle cx=\"2\" cy=\"2\" r=\"2\"/></svg>",
)
.expect("svg resource");
fs::write(
root.join("assets/bgm.mp3"),
[b'I', b'D', b'3', 4, 0, 0, 0, 0, 0, 0],
)
.expect("audio resource");
fs::write(root.join("game/unregistered.md"), "不应读取").expect("unregistered document");
let source = || GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
canvas_project_id: None,
resource_id: None,
asset_object_id: None,
task_id: None,
prompt: None,
model: None,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
};
register_local_asset_at(
&root,
"game/design.md",
"design-document",
"text/markdown",
"generated",
source(),
)
.expect("register document");
register_local_asset_at(
&root,
"assets/icon.svg",
"icon",
"image/svg+xml",
"generated",
source(),
)
.expect("register svg");
register_local_asset_at(
&root,
"assets/bgm.mp3",
"bgm",
"audio/mpeg",
"generated",
source(),
)
.expect("register audio");
let document = read_local_project_text_preview(
root.to_string_lossy().into_owned(),
"game/design.md".to_string(),
)
.expect("read registered document");
assert_eq!(document.media_type, "text/markdown");
assert!(document.content.contains("安全正文"));
let svg = read_local_project_media_preview(
root.to_string_lossy().into_owned(),
"assets/icon.svg".to_string(),
"art".to_string(),
)
.expect("read registered svg");
assert_eq!(svg.media_type, "image/svg+xml");
let audio = read_local_project_media_preview(
root.to_string_lossy().into_owned(),
"assets/bgm.mp3".to_string(),
"audio".to_string(),
)
.expect("read registered audio");
assert_eq!(audio.media_type, "audio/mpeg");
let unregistered_error = read_local_project_text_preview(
root.to_string_lossy().into_owned(),
"game/unregistered.md".to_string(),
)
.expect_err("unregistered document rejected");
assert!(unregistered_error.contains("已登记的文档资源"));
assert!(read_local_project_text_preview(
root.to_string_lossy().into_owned(),
"../outside.md".to_string(),
)
.is_err());
assert!(read_local_project_media_preview(
root.to_string_lossy().into_owned(),
"assets/bgm.mp3".to_string(),
"art".to_string(),
)
.is_err());
fs::remove_dir_all(root).ok();
}
+89 -4
View File
@@ -4160,11 +4160,21 @@ iframe.preview-frame {
gap: 6px;
min-height: 0;
padding: 18px 20px 24px;
overflow: auto;
overflow: hidden;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.game-resource-focus--document .game-resource-focus-body,
.game-resource-focus--art .game-resource-focus-body,
.game-resource-focus--audio .game-resource-focus-body {
grid-template-rows: minmax(0, 1fr) auto;
}
.game-resource-focus--version .game-resource-focus-body {
overflow: auto;
}
.game-resource-focus-close {
display: grid;
width: 28px;
@@ -4234,6 +4244,66 @@ iframe.preview-frame {
place-items: center;
}
.game-resource-media-preview,
.game-resource-audio-preview {
position: relative;
display: grid;
min-width: 0;
min-height: 0;
margin-bottom: 8px;
overflow: hidden;
border: 1px solid #ead8cf;
border-radius: 12px;
background: #faf7f5;
place-items: center;
}
.game-resource-media-preview {
background: linear-gradient(45deg, #f1ebe7 25%, transparent 25%),
linear-gradient(-45deg, #f1ebe7 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #f1ebe7 75%),
linear-gradient(-45deg, transparent 75%, #f1ebe7 75%), #faf7f5;
background-position:
0 0,
0 8px,
8px -8px,
-8px 0;
background-size: 16px 16px;
}
.game-resource-media-preview img,
.game-resource-media-preview video {
display: block;
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.game-resource-media-preview video {
width: 100%;
height: 100%;
background: #211d1b;
}
.game-resource-audio-preview {
align-content: center;
padding: 28px;
background: linear-gradient(145deg, #fffaf6, #f5e7df);
}
.game-resource-audio-preview audio {
width: min(620px, 100%);
}
.game-resource-media-preview p,
.game-resource-audio-preview p {
margin: 0;
padding: 20px;
color: #92776c;
font-size: 12px;
text-align: center;
}
.game-resource-image-preview img {
position: absolute;
inset: 0;
@@ -4252,8 +4322,8 @@ iframe.preview-frame {
}
.game-resource-document-body {
height: max-content;
min-height: 220px;
min-width: 0;
min-height: 0;
margin-top: 6px;
padding: 14px;
border-radius: 10px;
@@ -4261,8 +4331,10 @@ iframe.preview-frame {
color: #765c51;
font-size: 12px;
line-height: 1.6;
overflow: hidden;
overflow: auto;
overflow-wrap: anywhere;
overscroll-behavior: contain;
scrollbar-gutter: stable;
user-select: text;
}
@@ -4344,6 +4416,19 @@ iframe.preview-frame {
text-decoration: underline;
}
.game-resource-document-link-text {
color: #8f5e49;
text-decoration: underline dotted;
}
.game-resource-document-image-placeholder {
display: inline-block;
padding: 0.2em 0.45em;
border-radius: 5px;
background: #eaded7;
color: #806559;
}
.game-resource-document-body table {
width: 100%;
border-collapse: collapse;
@@ -50,15 +50,15 @@ import {
type ProjectResourceGraphReadModel,
} from './resourceDependencyGraphModel';
import { ResourceDependencyOverlay } from './ResourceDependencyOverlay';
import { useProjectResourceCanvasLayout } from './useProjectResourceCanvasLayout';
import {
type ProjectAgentResultSummary,
type ProjectAttachmentResult,
type ProjectResource,
type ProjectResourceCategory,
type ProjectVersionResourceSummary,
projectResourcesFromReadModels,
type ProjectVersionResourceSummary,
} from './resourceProjectionModel';
import { useProjectResourceCanvasLayout } from './useProjectResourceCanvasLayout';
export type {
ProjectAgentResultSummary,
@@ -77,6 +77,20 @@ type LocalProjectImagePreview = {
dataUrl: string;
};
type LocalProjectTextPreview = {
path: string;
mediaType: string;
byteLen: number;
content: string;
};
type LocalProjectMediaPreview = {
path: string;
mediaType: string;
byteLen: number;
dataUrl: string;
};
type ImagePreviewState =
| { status: 'idle'; resourceId: null }
| { status: 'loading'; resourceId: string }
@@ -87,6 +101,26 @@ type ImagePreviewState =
}
| { status: 'failed'; resourceId: string; error: string };
type TextPreviewState =
| { status: 'idle'; resourceId: null }
| { status: 'loading'; resourceId: string }
| {
status: 'loaded';
resourceId: string;
preview: LocalProjectTextPreview;
}
| { status: 'failed'; resourceId: string; error: string };
type MediaPreviewState =
| { status: 'idle'; resourceId: null }
| { status: 'loading'; resourceId: string }
| {
status: 'loaded';
resourceId: string;
preview: LocalProjectMediaPreview;
}
| { status: 'failed'; resourceId: string; error: string };
export type ProjectAgentRuntimeSummary = {
group: GameCreationAppAgentGroup;
label: string;
@@ -178,6 +212,70 @@ function isRasterImageResource(resource: ProjectResource) {
);
}
function isExtendedArtMediaResource(resource: ProjectResource) {
const mediaType = resource.mediaType.toLowerCase();
return (
resource.category === 'art' &&
(mediaType === 'image/svg+xml' ||
mediaType.startsWith('video/') ||
/\.(gif|svg|avif|bmp|mp4|webm|mov)$/iu.test(resource.path))
);
}
function mediaPreviewErrorMessage(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes('项目权限策略要求用户确认')) {
return '当前项目策略要求先确认读取资源';
}
if (message.includes('项目权限策略拒绝执行')) {
return '当前项目策略不允许读取资源';
}
if (message.includes('不能超过')) {
return message;
}
if (message.includes('UTF-8') || message.includes('只支持')) {
return message;
}
if (message.includes('脚本或外部资源引用')) {
return message;
}
if (message.includes('发生漂移') || message.includes('发生替换')) {
return '资源读取期间发生变化,请关闭后重试';
}
return '资源暂时无法读取,请关闭后重试';
}
function formatMediaDuration(duration: number | null) {
if (duration === null || !Number.isFinite(duration) || duration < 0) {
return '载入后显示';
}
const totalSeconds = Math.floor(duration);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
function SafeProjectMarkdown({ content }: { content: string }) {
return (
<ReactMarkdown
skipHtml
remarkPlugins={[remarkGfm]}
components={{
a: ({ children }) => (
<span className="game-resource-document-link-text">{children}</span>
),
img: ({ alt }) => (
<span className="game-resource-document-image-placeholder">
{alt ? `图片:${alt}` : '文档图片已省略'}
</span>
),
}}
>
{content}
</ReactMarkdown>
);
}
function imagePreviewErrorMessage(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes('项目权限策略要求用户确认')) {
@@ -323,6 +421,15 @@ export default function ProjectDevelopmentView({
status: 'idle',
resourceId: null,
});
const [textPreview, setTextPreview] = useState<TextPreviewState>({
status: 'idle',
resourceId: null,
});
const [mediaPreview, setMediaPreview] = useState<MediaPreviewState>({
status: 'idle',
resourceId: null,
});
const [mediaDuration, setMediaDuration] = useState<number | null>(null);
const resourceCanvasRef = useRef<HTMLDivElement>(null);
const resourceFocusRef = useRef<HTMLElement>(null);
const resourceListScrollRef = useRef({ left: 0, top: 0 });
@@ -556,6 +663,10 @@ export default function ProjectDevelopmentView({
const focusedResourceIsImage = Boolean(
focusedResource && isRasterImageResource(focusedResource),
);
const focusedResourceIsExtendedArtMedia = Boolean(
focusedResource && isExtendedArtMediaResource(focusedResource),
);
const focusedResourceIsAudio = focusedResource?.category === 'audio';
const hasRegisteredArtImageAssets = manifest.assets.some(
(asset) =>
asset.kind === 'art-spritesheet' && asset.mediaType.startsWith('image/'),
@@ -594,6 +705,20 @@ export default function ProjectDevelopmentView({
const FocusIcon = focusedResource
? categoryIcons[focusedResource.category]
: FileText;
const focusedPreviewMediaType =
focusedResource &&
mediaPreview.status === 'loaded' &&
mediaPreview.resourceId === focusedResource.id
? mediaPreview.preview.mediaType
: focusedResource &&
imagePreview.status === 'loaded' &&
imagePreview.resourceId === focusedResource.id
? imagePreview.preview.mediaType
: focusedResource &&
textPreview.status === 'loaded' &&
textPreview.resourceId === focusedResource.id
? textPreview.preview.mediaType
: focusedResource?.mediaType;
useEffect(() => {
if (embeddedPreviewUrl) {
@@ -667,6 +792,118 @@ export default function ProjectDevelopmentView({
};
}, [focusedResource, focusedResourceIsImage, projectPath]);
useEffect(() => {
if (!focusedResource || focusedResource.category !== 'document') {
setTextPreview({ status: 'idle', resourceId: null });
return undefined;
}
if (focusedResource.content !== undefined) {
setTextPreview({
status: 'loaded',
resourceId: focusedResource.id,
preview: {
path: focusedResource.path,
mediaType: focusedResource.mediaType,
byteLen: new TextEncoder().encode(focusedResource.content).byteLength,
content: focusedResource.content,
},
});
return undefined;
}
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setTextPreview({
status: 'failed',
resourceId: focusedResource.id,
error: '文档预览需要在客户端内打开',
});
return undefined;
}
let cancelled = false;
setTextPreview({ status: 'loading', resourceId: focusedResource.id });
void invoke<LocalProjectTextPreview>('read_local_project_text_preview', {
projectPath,
relativePath: focusedResource.path,
})
.then((preview) => {
if (!cancelled) {
setTextPreview({
status: 'loaded',
resourceId: focusedResource.id,
preview,
});
}
})
.catch((error: unknown) => {
if (!cancelled) {
setTextPreview({
status: 'failed',
resourceId: focusedResource.id,
error: mediaPreviewErrorMessage(error),
});
}
});
return () => {
cancelled = true;
};
}, [focusedResource, projectPath]);
useEffect(() => {
if (
!focusedResource ||
(!focusedResourceIsExtendedArtMedia && !focusedResourceIsAudio)
) {
setMediaPreview({ status: 'idle', resourceId: null });
setMediaDuration(null);
return undefined;
}
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setMediaPreview({
status: 'failed',
resourceId: focusedResource.id,
error: '媒体预览需要在客户端内打开',
});
return undefined;
}
let cancelled = false;
setMediaDuration(null);
setMediaPreview({ status: 'loading', resourceId: focusedResource.id });
void invoke<LocalProjectMediaPreview>('read_local_project_media_preview', {
projectPath,
relativePath: focusedResource.path,
category: focusedResource.category,
})
.then((preview) => {
if (!cancelled) {
setMediaPreview({
status: 'loaded',
resourceId: focusedResource.id,
preview,
});
}
})
.catch((error: unknown) => {
if (!cancelled) {
setMediaPreview({
status: 'failed',
resourceId: focusedResource.id,
error: mediaPreviewErrorMessage(error),
});
}
});
return () => {
cancelled = true;
};
}, [
focusedResource,
focusedResourceIsAudio,
focusedResourceIsExtendedArtMedia,
projectPath,
]);
useLayoutEffect(() => {
if (focusedResource) {
resourceFocusRef.current?.focus({ preventScroll: true });
@@ -849,6 +1086,101 @@ export default function ProjectDevelopmentView({
)}
</div>
) : null}
{focusedResourceIsExtendedArtMedia ? (
<div
className="game-resource-media-preview"
aria-label={`${focusedResource.label} 美术媒体预览`}
>
{mediaPreview.status === 'loaded' &&
mediaPreview.resourceId === focusedResource.id ? (
mediaPreview.preview.mediaType.startsWith('video/') ? (
<video
src={mediaPreview.preview.dataUrl}
controls
preload="metadata"
aria-label={`${focusedResource.label} 视频预览`}
onError={() =>
setMediaPreview({
status: 'failed',
resourceId: focusedResource.id,
error: '视频内容无法解码,请重新生成或替换该资源',
})
}
/>
) : (
<img
src={mediaPreview.preview.dataUrl}
alt={`${focusedResource.label} 美术媒体预览`}
onError={() =>
setMediaPreview({
status: 'failed',
resourceId: focusedResource.id,
error: '美术资源无法解码,请重新生成或替换该资源',
})
}
/>
)
) : mediaPreview.status === 'failed' &&
mediaPreview.resourceId === focusedResource.id ? (
<p role="alert">{mediaPreview.error}</p>
) : (
<p role="status"></p>
)}
</div>
) : null}
{focusedResourceIsAudio ? (
<div
className="game-resource-audio-preview"
aria-label={`${focusedResource.label} 音频预览`}
>
{mediaPreview.status === 'loaded' &&
mediaPreview.resourceId === focusedResource.id ? (
<audio
src={mediaPreview.preview.dataUrl}
controls
preload="metadata"
aria-label={`${focusedResource.label} 音频播放器`}
onLoadedMetadata={(event) =>
setMediaDuration(event.currentTarget.duration)
}
onDurationChange={(event) =>
setMediaDuration(event.currentTarget.duration)
}
onError={() =>
setMediaPreview({
status: 'failed',
resourceId: focusedResource.id,
error: '音频内容无法解码,请重新生成或替换该资源',
})
}
/>
) : mediaPreview.status === 'failed' &&
mediaPreview.resourceId === focusedResource.id ? (
<p role="alert">{mediaPreview.error}</p>
) : (
<p role="status"></p>
)}
</div>
) : null}
{focusedResource.category === 'document' ? (
<div className="game-resource-document-body">
{textPreview.status === 'loaded' &&
textPreview.resourceId === focusedResource.id ? (
textPreview.preview.content.trim() ? (
<SafeProjectMarkdown
content={textPreview.preview.content}
/>
) : (
<p role="status"></p>
)
) : textPreview.status === 'failed' &&
textPreview.resourceId === focusedResource.id ? (
<p role="alert">{textPreview.error}</p>
) : (
<p role="status"></p>
)}
</div>
) : null}
<dl className="game-resource-focus-metadata">
<div>
<dt></dt>
@@ -856,7 +1188,7 @@ export default function ProjectDevelopmentView({
</div>
<div>
<dt></dt>
<dd>{focusedResource.mediaType}</dd>
<dd>{focusedPreviewMediaType}</dd>
</div>
<div>
<dt></dt>
@@ -868,6 +1200,12 @@ export default function ProjectDevelopmentView({
<dd>{focusedResource.taskTitle}</dd>
</div>
) : null}
{focusedResourceIsAudio ? (
<div>
<dt></dt>
<dd>{formatMediaDuration(mediaDuration)}</dd>
</div>
) : null}
{focusedResource.version ? (
<>
<div>
@@ -884,13 +1222,6 @@ export default function ProjectDevelopmentView({
</>
) : null}
</dl>
{focusedResource.category === 'document' ? (
<div className="game-resource-document-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{focusedResource.content ?? '文档内容尚未载入'}
</ReactMarkdown>
</div>
) : null}
</div>
</section>
) : mode === 'resources' ? (
@@ -836,6 +836,153 @@ export function registerProjectWorkbenchFoundationTests() {
expect(restoredCard?.getAttribute('aria-pressed')).toBe('true');
});
it('loads registered documents, art media, and audio inside the central focus state', async () => {
const manifest = createGameCreationAppManifest(
'workbench-resource-media',
'资源媒体测试',
);
manifest.assets.push(
{
id: 'design-document',
kind: 'design-document',
mediaType: 'text/markdown',
localPath: 'game/design.md',
source: { kind: 'generated' },
},
{
id: 'art-svg',
kind: 'icon',
mediaType: 'image/svg+xml',
localPath: 'assets/icon.svg',
source: { kind: 'generated' },
},
{
id: 'audio-bgm',
kind: 'bgm',
mediaType: 'audio/mpeg',
localPath: 'assets/bgm.mp3',
source: { kind: 'generated' },
},
);
let layoutRevision = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: layoutRevision,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutRevision += 1;
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: layoutRevision,
positions: args?.positions,
updatedAt: layoutRevision,
},
};
}
if (command === 'read_local_project_text_preview') {
expect(args).toMatchObject({
projectPath: '/tmp/workbench-resource-media',
relativePath: 'game/design.md',
});
return {
path: 'game/design.md',
mediaType: 'text/markdown',
byteLen: 64,
content:
'# 本地设计文档\n\n[外部链接](https://example.com)\n\n![远程图片](https://example.com/image.png)\n\n<script>window.pwned = true</script>',
};
}
if (command === 'read_local_project_media_preview') {
if (args?.category === 'art') {
return {
path: 'assets/icon.svg',
mediaType: 'image/svg+xml',
byteLen: 48,
dataUrl: 'data:image/svg+xml;base64,PHN2Zy8+',
};
}
return {
path: 'assets/bgm.mp3',
mediaType: 'audio/mpeg',
byteLen: 1024,
dataUrl: 'data:audio/mpeg;base64,SUQz',
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: '资源媒体测试',
projectPath: '/tmp/workbench-resource-media',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
fireEvent.click(await screen.findByRole('button', { name: /design\.md/ }));
const documentFocus = await screen.findByRole('region', {
name: 'design.md',
});
expect(
within(documentFocus).getByRole('heading', { name: '本地设计文档' }),
).not.toBeNull();
expect(documentFocus.querySelector('script')).toBeNull();
expect(documentFocus.querySelector('a')).toBeNull();
expect(within(documentFocus).getByText('图片:远程图片')).not.toBeNull();
fireEvent.click(
within(documentFocus).getByRole('button', { name: '收起资源' }),
);
fireEvent.click(screen.getByRole('button', { name: /icon\.svg/ }));
const artPreview = await screen.findByLabelText('icon.svg 美术媒体预览');
await waitFor(() => {
expect(artPreview.querySelector('img')?.getAttribute('src')).toBe(
'data:image/svg+xml;base64,PHN2Zy8+',
);
});
fireEvent.click(
within(screen.getByRole('region', { name: 'icon.svg' })).getByRole(
'button',
{ name: '收起资源' },
),
);
fireEvent.click(screen.getByRole('button', { name: /bgm\.mp3/ }));
const audio = (await screen.findByLabelText(
'bgm.mp3 音频播放器',
)) as HTMLAudioElement;
expect(audio.controls).toBe(true);
expect(audio.getAttribute('src')).toBe('data:audio/mpeg;base64,SUQz');
expect(screen.getByText('载入后显示')).not.toBeNull();
expect(invoke).toHaveBeenCalledWith(
'read_local_project_media_preview',
expect.objectContaining({ category: 'audio' }),
);
});
it('renders, filters, highlights, and destroys the resource dependency overlay without pointer previews', async () => {
const manifest = createGameCreationAppManifest(
'workbench-resource-graph',
@@ -123,13 +123,14 @@ completed -> starting(nextSlice)
idle -> focused(document|art|audio|version) -> idle
```
- 文档:在中央画布展开并独立滚动。
- 美术/音频:进入对应媒体聚焦状态,当前只展示已有预览或资源元数据
- 文档:合法 Agent 文本回执直接使用对话投影内容;项目文件只允许读取当前 manifest 已登记资产或已完成任务产物中的 Markdown、文本、JSON、YAML、TOML,必须经过 `file.read` auto 权限、相对路径、项目边界、普通文件、符号链接 / 硬链接、读取漂移、2 MiB、UTF-8 与扩展名白名单校验。正文使用不执行 HTML、不加载远程图片、不产生可点击外链的安全 Markdown 渲染,并在中央画布独立滚动;读取失败显示错误空态
- 美术PNG、JPEG、WEBP 继续使用图片魔数与像素边界预览;GIF、SVG、AVIF、BMP、MP4、WebM、MOV 通过新增受控媒体读取链路按文件签名校验后在中央画布放大聚焦。SVG 额外拒绝脚本、事件处理器、外部资源引用和实体声明;视频使用内置播放控件。读取失败显示错误空态
- 音频:只读取 manifest 已登记音频或已成功导入且登记到 manifest 的附件,按文件签名接受 MP3、WAV、OGG / Opus、M4A、AAC、FLAC;聚焦态展示实际格式、浏览器解码后的时长以及带播放进度和暂停能力的内置播放器。音频任务声明中的未登记路径继续不得读取或播放。
- 版本:只展示正式版本 read model 的身份与修订元数据;版本引用高亮和替换留给后续切片。
- mentor 最新决定:资源聚焦不提供工具栏,也不提供工具侧边栏。
- 点击资源后,中央主视窗从 `resources.list` 切换为 `resources.focused.document / art / audio / version`,左侧平台导航、右侧 Supervisor 对话和底部 Agent 状态栏保持原位;聚焦容器只包含标题、资源主体、必要元数据与右上角收起按钮,不使用页面级浮层或可拖动标题栏。
- 退出聚焦后恢复进入前的搜索条件、dependency / type 布局模式、资源画布滚动位置和选中资源;这些只属于当前前端会话,不写入布局 sidecar。
- 阶段不新增项目文件读取命令、美术编辑、音频播放 / 编辑、版本替换或运行模块
- 阶段四只新增上述受控读取与媒体展示,不新增资源聚焦工具栏 / 工具侧边栏,不新增美术编辑、音频编辑 / 替换、资源重新生成、不可变版本写入或运行模块。飞书原需求中“编辑并生成新资源”的条件项仍暂缓,不能只打开画板却缺少回写、`referenceResourceIds` 血缘登记、新资源自动选中与邻近布局的完整闭环
### 4.4 历史成果与当前状态
@@ -273,7 +274,7 @@ type UpdateProjectResourceCanvasLayoutResult =
### 5.3 资源类型与替换兼容性(P1)
实现状态(2026-08-03):当前资源投影已收口到固定的“文档 -> 项目版本 -> 美术资源 -> 音乐音效资源”四区。文档只接收 Markdown / 文本 / JSON / YAML 等正式项目文档和合法 Agent 文本回执;项目版本只接收显式 `ProjectVersionResourceSummary` read model,未知任务产物不得兜底为版本;美术接收图片、SVG、动画和视频类产物;音频只接收 manifest 已登记音频资产或已成功导入的音频附件,任务声明中的未登记音频路径不冒充正式音频资源。无法识别的二进制任务产物和附件不进入资源画布。
实现状态(2026-08-03):当前资源投影已收口到固定的“文档 -> 项目版本 -> 美术资源 -> 音乐音效资源”四区。文档只接收 Markdown / 文本 / JSON / YAML 等正式项目文档和合法 Agent 文本回执;项目版本只接收显式 `ProjectVersionResourceSummary` read model,未知任务产物不得兜底为版本;美术接收图片、SVG、动画和视频类产物;音频只接收 manifest 已登记音频资产或已成功导入并登记到 manifest 的音频附件,任务声明中的未登记音频路径不冒充正式音频资源。无法识别的二进制任务产物和附件不进入资源画布。阶段四已为本地文档、安全 SVG / 扩展图片 / 视频和音频补齐受控读取、中央聚焦、失败空态与媒体播放;这些都是只读表现层,不改变资源投影或 manifest 真相。
资源身份固定使用 manifest asset ID、正式 version ID、Agent ID + run ID 或已导入资源稳定路径;显示标题、来源文案变化不得改变 `resourceId`,从而避免布局、依赖边、选择和聚焦状态因改名失效。
@@ -425,7 +426,7 @@ type ProjectAgentMudPointAttribution = {
## 8. 非目标
- 本切片不实现资源卡手动拖动,也不实现资源聚焦工具栏、资源聚焦工具侧边栏、后续项目文件读取、美术编辑、音频播放 / 编辑、资源替换、不可变迭代版本写入、运行模块扩展、测试切片、数值参数或泥点归因;已实现的资源关系图只提供 Rust 只读拓扑与前端派生展示,不建立新的资源业务真相。
- 本切片不实现资源卡手动拖动,也不实现资源聚焦工具栏、资源聚焦工具侧边栏、美术编辑、音频编辑 / 替换、资源重新生成、资源替换、不可变迭代版本写入、运行模块扩展、测试切片、数值参数或泥点归因;已实现的资源读取和关系图只提供 Rust 只读模型与前端派生展示,不建立新的资源业务真相。
- 本切片不持久化资源聚焦状态、画布缩放 / 平移、搜索条件、筛选条件或当前 mode;聚焦退出时的列表上下文恢复只限当前前端会话,这些状态如需跨重启保存必须另行扩展合同,不能塞入 `game-creator-resource-layout.v1`
- 不修改 SpacetimeDB schema。
- 不开放普通用户 Agent.md/Skill。
@@ -1,5 +1,13 @@
# 决策记录
## 2026-08-03 资源聚焦阶段四采用只读受控文档与媒体链路
- 背景:阶段一至三已经完成资源卡禁拖、固定四类资源投影与中央聚焦容器,但只有 PNG / JPEG / WEBP 和合法 Agent 文本回执具备真实主体预览;本地文档、SVG / 视频与音频仍只有路径和元数据。飞书需求同时把“编辑并生成新资源”写为条件项,而当前仓库尚未具备从画板返回后的血缘登记与自动选中闭环。
- 决策:本地文档和媒体统一通过 Tauri 只读命令消费当前 manifest / 已完成任务登记范围,执行 `file.read` auto 权限、路径边界、普通文件、链接、大小、读取漂移与文件身份复核;文本限白名单格式和 UTF-8,Markdown 不执行 HTML、不加载远程图片、不提供活动外链;媒体按文件签名校验,SVG 拒绝活动内容与外部引用,音视频使用 WebView 原生控件。Agent 文本回执继续直接使用对话投影。当前不增加半成品美术编辑按钮,必须等画板回写、保留原资源、`referenceResourceIds`、新资源自动选中和邻近自动布局可一次闭环时再开放。
- 边界:不修改 manifest、SpacetimeDB、资源投影身份、布局 sidecar、资源卡拖动或 External Editor API;不提供资源聚焦工具栏 / 工具侧边栏,不做音频编辑 / 替换或美术资源重生成。
- 验证方式:Rust 单测与命令测试覆盖 UTF-8 / 扩展名、文件签名、活动 SVG、符号链接 / 硬链接、未登记资源、类别错配和权限;AppSurface 覆盖本地 Markdown 安全渲染、SVG data URL、音频播放器和中央聚焦状态;追加 shell typecheck、Rust 定向测试、编码检查与 `git diff --check`
- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md``docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
## 2026-08-03 资源卡手动拖动暂缓并完成固定资源投影与中央聚焦
- 背景:飞书《陶泥儿GameAgent-V1.0 项目开发界面需求》曾要求资源卡可拖动,并由后续 PRD、技术方案和实现扩展为手动布局 CAS、依赖线拖动预览与性能验收;mentor 最新决定明确资源卡暂时禁止拖动,资源聚焦也不需要工具栏或工具侧边栏。
@@ -349,6 +349,7 @@ game-project/
- Agent loop 中美术组 `Asset` 和音乐组 `SFX` 会读取 `.agent/manifest.json`;图片生成先通过 External Editor API 项目与素材库接口准备同名画布会话,再调用 `/api/external/v1/editor/images/generations`,携带 `projectId``assetFolderId``assetLabel``generationInputs.artSpec``canvasCompletion`,随后通过 `/api/external/v1/assets/read-url` 换签下载到受控本地 `assets/` 路径,登记为 `canvas` 来源资产并追加 `canvas.asset_generate` 本地索引记录。API Key 不写入项目文件、agent.db、trace、manifest 或日志;未配置 Key 或生成失败时,图片产物型任务保持阻塞/失败,不能以文字计划完成。音乐组仍只建议同步已有音频资源,不调用图片生成接口。
- `canvas.asset_import` 当前作为最小真实链路:导入项目目录内已有文件为 `canvas` 来源资产,并要求记录画板项目 ID 以及 resourceId 或 assetObjectId。
- 项目工作台点击已登记图片时必须在中央主视窗的资源聚焦状态中直接渲染图片,而不是只展示路径与 MIME。图片通过受控 Tauri 命令从项目 `assets/` / `game/` 读取,只允许 manifest 已登记资产或已完成任务产物,并复用 `file.read` auto 权限、图片魔数、文件大小、像素尺寸、普通文件、路径漂移和符号链接校验后以 data URL 返回;首版只支持 PNG、JPEG、WEBP,不向 WebView 暴露任意本机文件协议或绝对路径。
- 2026-08-03 阶段四在上述图片链路外新增 `read_local_project_text_preview``read_local_project_media_preview`。前者只接收当前 manifest 已登记文档或已完成任务中的 Markdown / 文本 / JSON / YAML / TOML,限制 2 MiB 与 UTF-8;Agent 文本回执继续直接消费合法对话投影,不反查本地路径。后者的美术分支接收 GIF、安全 SVG、AVIF、BMP、MP4、WebM、MOV,音频分支只接收 manifest 已登记的 MP3、WAV、OGG / Opus、M4A、AAC、FLAC,二进制媒体限制 32 MiB。两条命令统一执行 `file.read` auto 权限、规范化相对路径、项目边界、敏感路径、普通文件、父目录链接、硬链接、读取漂移和重开身份复核;媒体按文件签名而非只按扩展名或 MIME 建立 data URL,SVG 额外拒绝活动内容与外部引用。
- `canvas.export_import` 复用 `/editor/canvas` 已有素材导出 ZIP 格式,读取根 `metadata.json`、复制 `images/` / `media/` / `sequences/` 到本地项目 `assets/canvas-imports/`,再按导出层登记为 `canvas` 来源资产;导出包不保存真实 resourceId 时,使用 `canvas-export:<file>` 作为可追踪 assetObjectId,不伪造后端资源行。
## GameAgent V1.0 项目开发工作台首版界面
@@ -360,7 +361,7 @@ game-project/
- 页面骨架固定为左侧现有全局导航、中间主视窗、右侧陶泥儿对话和底部子 Agent 状态栏;不新建第二套客户端或平行项目页。
- 中间主视窗提供 `资源管理 / 运行` 切换。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled``aria-disabled`;完成后才允许进入运行表现层。切回资源管理只修改前端展示态,不伪造后端预览暂停结果。
- 资源管理从当前 `GameCreationAppManifest`、合法 Agent 文本回执、已导入附件和显式项目版本 read model 派生资源,固定按文档、项目版本、美术资源、音乐音效资源分区;未知任务产物不再兜底为版本,任务声明中的未登记音频也不冒充正式音频。`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。
- 资源卡支持点击聚焦、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar2026-07-31 起 dependency 模式增加不持久化的原生 SVG 关系图层。2026-08-03 mentor 决定暂缓资源卡拖动,当前卡片不挂载 Pointer Down / Move / Up / Cancel 拖动入口,只允许自动布局和点击聚焦。聚焦态替换中央主视窗内容,保留左侧导航、右侧对话和底部 Agent 状态栏,退出后恢复搜索、布局模式、滚动位置与选中资源;不提供工具栏、工具侧边栏或可拖动标题栏。阶段不新增项目文件读取、美术编辑、音频播放 / 编辑、版本替换或运行模块。
- 资源卡支持点击聚焦、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar2026-07-31 起 dependency 模式增加不持久化的原生 SVG 关系图层。2026-08-03 mentor 决定暂缓资源卡拖动,当前卡片不挂载 Pointer Down / Move / Up / Cancel 拖动入口,只允许自动布局和点击聚焦。聚焦态替换中央主视窗内容,保留左侧导航、右侧对话和底部 Agent 状态栏,退出后恢复搜索、布局模式、滚动位置与选中资源;不提供工具栏、工具侧边栏或可拖动标题栏。阶段四已补齐安全本地文档、扩展美术媒体与音频聚焦,正文独立滚动,视频 / 音频使用内置媒体控件,失败显示空态;美术编辑、音频编辑 / 替换、版本替换或运行模块仍不在本阶段
- 运行表现层首版直接嵌入当前项目的 loopback 游戏画面,并展示上一项 / 暂停继续 / 下一项切片控制、素材信息和数值微调面板。`preview.start` 启动本地 server 后把真实 URL 回写工作台,`preview.open` 只激活客户端内运行视图,不再调用系统浏览器;切片、参数调整和自然语言新增调节项首版仍只保留本地 UI 草稿,不修改代码或 manifest。
- 右侧继续复用现有 Project Supervisor 会话、Runtime 澄清和确认链路;输入区展示 `严格审批 / 风险审批 / 无需审批` 独立面板。P0 只有严格审批可选;风险审批和无需审批保持视觉不可用但允许点击查看原因,不替代 Runtime 的逐动作权限、确认、sandbox 或 reconciliation 门禁。风险 Rank 算法记录在 `docs/project-memory/todos/【待解决】AI游戏创作高风险审批Rank-2026-07-20.md`,前端不得自行计算。
- 底部状态栏默认展示策划、美术、程序 3 组,并允许在同一栏展开数值、音频、发布组;状态来自 manifest 与当前 Supervisor run 的 Runtime,悬停显示当前任务与进度。累计泥点必须等待后端计费归因投影;Agent.md 编辑和自定义 Skill 在来源审核、版本、权限、sandbox 与回滚合同完备前不向普通用户开放。