From b7a659cdfed02f9147a2a250227e0b233815dad1 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 8 Sep 2026 16:19:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20AGC=20=E8=81=8A=E5=A4=A9=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E7=B4=A0=E6=9D=90=E4=B8=8E=E8=BF=90=E8=A1=8C=E7=94=BB?= =?UTF-8?q?=E9=9D=A2=E7=BB=93=E6=9E=84=E5=8C=96=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增通用素材引用编辑器与结构化引用协议 - 三个聊天入口接入 @ 候选和素材选择面板 - 资源卡新增 @引用 入口 - 运行画面新增区域点选与 runtime-region 引用 - Rust 按 manifest 二次校验并生成安全投影 - 补充前端与 Rust 定向测试 --- .../src-tauri/src/agent.rs | 2 + .../src/agent/direct_codex_references.rs | 326 ++++++++ .../src-tauri/src/agent/direct_runtime.rs | 26 +- .../src-tauri/src/preview.rs | 107 +++ apps/ai-game-creator-shell/src/App.tsx | 71 +- .../LocalGamePreviewFrame.tsx | 145 +++- .../ProjectSupervisorView.tsx | 40 +- .../ProjectWorkspaceChatPane.tsx | 33 +- .../ResourceReferenceChip.tsx | 48 ++ .../ResourceReferenceInput.tsx | 714 ++++++++++++++++++ .../ResourceReferenceNode.tsx | 91 +++ .../SupervisorChatOnlyView.tsx | 40 +- .../project-workspace/resourceReferences.ts | 153 ++++ apps/ai-game-creator-shell/src/styles.css | 450 +++++++++++ .../src/view/project-development/index.tsx | 83 ++ .../tests/resourceReferenceInput.test.tsx | 184 +++++ .../【功能说明】AGC聊天素材引用-2026-09-08.md | 27 + 17 files changed, 2480 insertions(+), 60 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_references.rs create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceChip.tsx create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceNode.tsx create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts create mode 100644 apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx create mode 100644 docs/【功能说明】AGC聊天素材引用-2026-09-08.md diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index cc37945ca..aeab6c9ab 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -16,6 +16,7 @@ mod direct_codex_attachments; mod direct_codex_audit; mod direct_project_history; mod direct_project_turn_history; +mod direct_codex_references; mod direct_runtime; mod direct_tool_bridge; mod direct_tools_mcp; @@ -42,6 +43,7 @@ pub(crate) use direct_codex_attachments::*; pub(crate) use direct_codex_audit::*; pub(crate) use direct_project_history::*; pub(crate) use direct_project_turn_history::*; +pub(crate) use direct_codex_references::*; pub(crate) use direct_runtime::*; pub(crate) use direct_tool_bridge::*; pub(crate) use direct_tools_mcp::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_references.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_references.rs new file mode 100644 index 000000000..c8a3e07b2 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_references.rs @@ -0,0 +1,326 @@ +use super::*; + +pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32; +const MAX_DIRECT_CODEX_REFERENCE_ID_CHARS: usize = 200; +const MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS: usize = 160; +const MAX_DIRECT_CODEX_REFERENCE_SOURCE_CHARS: usize = 32; +const MAX_DIRECT_CODEX_REFERENCE_ELEMENT_CHARS: usize = 80; +const MAX_DIRECT_CODEX_REFERENCE_TEXT_CHARS: usize = 240; + +const DIRECT_CODEX_REFERENCE_HEADER: &str = + "[本轮用户引用素材:以下均为当前项目已确认的安全引用。请使用稳定资源 ID 和项目相对路径读取,不要读取或输出其它路径。]"; + +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase", tag = "type")] +pub(crate) enum DirectCodexTurnReference { + #[serde(rename = "resource")] + Resource(DirectCodexResourceReference), + #[serde(rename = "runtime-region")] + RuntimeRegion(DirectCodexRuntimeRegionReference), +} + +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DirectCodexResourceReference { + pub(crate) resource_id: String, + #[serde(default)] + pub(crate) label: Option, + #[serde(default)] + pub(crate) source: Option, +} + +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DirectCodexRuntimeRegionReference { + #[serde(default)] + pub(crate) label: Option, + #[serde(default)] + pub(crate) run_id: Option, + #[serde(default)] + pub(crate) version_id: Option, + #[serde(default)] + pub(crate) element_tag: Option, + #[serde(default)] + pub(crate) element_role: Option, + #[serde(default)] + pub(crate) text: Option, + #[serde(default)] + pub(crate) width: Option, + #[serde(default)] + pub(crate) height: Option, + #[serde(default)] + pub(crate) resource_ids: Vec, +} + +fn sanitize_reference_text(value: &str, max_chars: usize) -> Option { + let sanitized = value + .trim() + .chars() + .filter(|character| !character.is_control()) + .take(max_chars) + .collect::(); + if sanitized.is_empty() { + None + } else { + Some(sanitized) + } +} + +fn sanitize_reference_label(value: Option<&str>) -> Option { + value.and_then(|value| sanitize_reference_text(value, MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS)) +} + +fn sanitize_reference_source(value: Option<&str>) -> Option { + let value = sanitize_reference_text(value?, MAX_DIRECT_CODEX_REFERENCE_SOURCE_CHARS)?; + if value + .chars() + .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')) + { + Some(value) + } else { + None + } +} + +fn sanitize_reference_element(value: Option<&str>) -> Option { + let value = sanitize_reference_text(value?, MAX_DIRECT_CODEX_REFERENCE_ELEMENT_CHARS)?; + if value + .chars() + .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')) + { + Some(value) + } else { + None + } +} + +fn sanitize_reference_dimension(value: Option) -> Option { + value + .filter(|value| value.is_finite() && *value > 0.0) + .map(|value| value.round().clamp(1.0, 100_000.0) as u32) +} + +fn asset_display_label(asset: &GameCreationAppAssetManifestEntry) -> String { + let basename = asset + .local_path + .rsplit(['/', '\\']) + .next() + .unwrap_or(&asset.id); + let without_extension = basename + .rsplit_once('.') + .map(|(name, _)| name) + .unwrap_or(basename) + .trim(); + if without_extension.is_empty() { + asset.id.clone() + } else { + without_extension + .chars() + .filter(|character| !character.is_control()) + .take(MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS) + .collect() + } +} + +fn validate_resource_reference_id(value: &str) -> Result { + let resource_id = value.trim(); + if resource_id.is_empty() + || resource_id.chars().count() > MAX_DIRECT_CODEX_REFERENCE_ID_CHARS + || resource_id.chars().any(char::is_control) + { + return Err("引用的素材 ID 无效,请移除后重新选择".to_string()); + } + Ok(resource_id.to_string()) +} + +fn render_resource_reference_line( + manifest: &GameCreationAppManifest, + reference: &DirectCodexResourceReference, +) -> Result { + let resource_id = validate_resource_reference_id(&reference.resource_id)?; + let asset = manifest + .assets + .iter() + .find(|asset| asset.id == resource_id) + .ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?; + let local_path = sanitize_attachment_local_path(&asset.local_path) + .ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?; + let label = sanitize_reference_label(reference.label.as_deref()) + .unwrap_or_else(|| asset_display_label(asset)); + let source = sanitize_reference_source(reference.source.as_deref()) + .unwrap_or_else(|| "unknown".to_string()); + Ok(format!( + "- 素材 ID:{resource_id};名称:{label};类型:{};媒体类型:{};项目路径:{local_path};来源:{source}", + asset.kind, asset.media_type + )) +} + +fn render_runtime_region_reference_line( + manifest: &GameCreationAppManifest, + reference: &DirectCodexRuntimeRegionReference, +) -> Result { + let label = sanitize_reference_label(reference.label.as_deref()) + .unwrap_or_else(|| "运行画面区域".to_string()); + let run_id = sanitize_reference_source(reference.run_id.as_deref()); + let version_id = sanitize_reference_source(reference.version_id.as_deref()); + let element_tag = sanitize_reference_element(reference.element_tag.as_deref()); + let element_role = sanitize_reference_element(reference.element_role.as_deref()); + let text = reference + .text + .as_deref() + .and_then(|value| sanitize_reference_text(value, MAX_DIRECT_CODEX_REFERENCE_TEXT_CHARS)); + let width = sanitize_reference_dimension(reference.width); + let height = sanitize_reference_dimension(reference.height); + + let mut related_resource_ids = Vec::new(); + for resource_id in &reference.resource_ids { + let resource_id = validate_resource_reference_id(resource_id)?; + if !manifest.assets.iter().any(|asset| asset.id == resource_id) { + return Err("运行画面引用的素材已变化,请重新点选".to_string()); + } + related_resource_ids.push(resource_id); + } + + let mut parts = vec![format!("名称:{label}")]; + if let Some(run_id) = run_id { + parts.push(format!("运行标识:{run_id}")); + } + if let Some(version_id) = version_id { + parts.push(format!("版本标识:{version_id}")); + } + if let Some(element_tag) = element_tag { + parts.push(format!("元素:{element_tag}")); + } + if let Some(element_role) = element_role { + parts.push(format!("角色:{element_role}")); + } + if let Some(text) = text { + parts.push(format!("文本摘要:{text}")); + } + if let (Some(width), Some(height)) = (width, height) { + parts.push(format!("尺寸:{width}x{height}")); + } + if !related_resource_ids.is_empty() { + parts.push(format!("关联素材 ID:{}", related_resource_ids.join(","))); + } + Ok(format!("- 运行画面区域:{}", parts.join(";"))) +} + +pub(crate) fn render_direct_codex_references_section( + root: &Path, + references: &[DirectCodexTurnReference], +) -> Result, String> { + if references.is_empty() { + return Ok(None); + } + if references.len() > MAX_DIRECT_CODEX_REFERENCES { + return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材")); + } + let manifest = read_manifest_for_project(root)?; + let mut lines = Vec::with_capacity(references.len()); + for reference in references { + lines.push(match reference { + DirectCodexTurnReference::Resource(reference) => { + render_resource_reference_line(&manifest, reference)? + } + DirectCodexTurnReference::RuntimeRegion(reference) => { + render_runtime_region_reference_line(&manifest, reference)? + } + }); + } + Ok(Some( + std::iter::once(DIRECT_CODEX_REFERENCE_HEADER.to_string()) + .chain(lines) + .collect::>() + .join("\n"), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture_project() -> tempfile::TempDir { + let directory = tempfile::tempdir().expect("temp project"); + let root = directory.path(); + std::fs::create_dir_all(root.join(".agent")).expect("create agent dir"); + let mut manifest = new_game_creation_app_manifest("project-1", "测试项目"); + manifest.assets.push(GameCreationAppAssetManifestEntry { + id: "asset-hero".to_string(), + kind: "character".to_string(), + media_type: "image/png".to_string(), + local_path: "assets/hero.png".to_string(), + source: GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Uploaded, + 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(), + }, + image_sequence_frames: None, + image_sequence_duration_ms: None, + }); + write_manifest(&root.join(".agent/manifest.json"), &manifest).expect("write manifest"); + directory + } + + #[test] + fn resource_reference_uses_manifest_identity_and_never_accepts_client_paths() { + let project = fixture_project(); + let reference: DirectCodexTurnReference = serde_json::from_str( + r#"{"type":"resource","resourceId":"asset-hero","label":"主角","source":"asset-picker","localPath":"C:\\secret.png"}"#, + ) + .expect("reference json"); + let section = render_direct_codex_references_section( + project.path(), + std::slice::from_ref(&reference), + ) + .expect("render") + .expect("section"); + assert!(section.contains("素材 ID:asset-hero")); + assert!(section.contains("名称:主角")); + assert!(section.contains("项目路径:assets/hero.png")); + assert!(!section.contains("C:\\secret.png")); + } + + #[test] + fn deleted_resource_fails_closed() { + let project = fixture_project(); + let reference: DirectCodexTurnReference = serde_json::from_str( + r#"{"type":"resource","resourceId":"asset-missing","label":"不存在"}"#, + ) + .expect("reference json"); + let error = render_direct_codex_references_section( + project.path(), + std::slice::from_ref(&reference), + ) + .expect_err("missing resource"); + assert_eq!(error, "引用的素材已不存在,请移除后重新选择"); + } + + #[test] + fn runtime_region_keeps_only_safe_summary_and_existing_resource_ids() { + let project = fixture_project(); + let reference: DirectCodexTurnReference = serde_json::from_str( + r#"{"type":"runtime-region","label":"开始按钮","runId":"run-1","elementTag":"button","elementRole":"button","text":"开始游戏","width":120.4,"height":40.2,"resourceIds":["asset-hero"],"html":"