diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs index 130c01549..ed9e5e576 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -482,9 +482,38 @@ pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result Result, String> { + read_direct_project_history_entries_filtered_at(root, None, false) +} + +fn is_direct_project_chat_message(item: &Value) -> bool { + matches!( + item.get("role").and_then(Value::as_str), + Some("user" | "assistant") + ) && item + .get("content") + .and_then(Value::as_array) + .is_some_and(|parts| { + parts.iter().any(|part| { + part.get("text") + .and_then(Value::as_str) + .is_some_and(|text| !text.is_empty()) + }) + }) +} + +/// 消息模式逐行丢弃工具输出,只保留聊天正文,避免 40 MiB 工具日志被整表积累或发给 UI。 +fn read_direct_project_history_entries_filtered_at( + root: &Path, + before_item_id: Option<&str>, + messages_only: bool, +) -> Result, String> { let path = history_path(root); if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? { - return Ok(Vec::new()); + return if before_item_id.is_some() { + Err("DirectProject 历史游标对应的文件已不存在".to_string()) + } else { + Ok(Vec::new()) + }; } let file = File::open(&path) .map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?; @@ -519,6 +548,12 @@ fn read_direct_project_history_entries_at(root: &Path) -> Result Result Err(format!("DirectProject 历史中不存在 item:{item_id}")), + None => Ok(items), + } +} + +pub(crate) fn read_direct_project_chat_items_slice_at( + root: &Path, + before_item_id: Option<&str>, + limit: usize, +) -> Result<(Vec, bool, BTreeMap), String> { + let entries = read_direct_project_history_entries_filtered_at(root, before_item_id, true)?; + let mut start = entries.len().saturating_sub(limit.clamp(1, 200)); + // 旧消息可能没有 ID:保留原文,并向前扩到可寻址的已有 ID,不能制造原始消息身份。 + while start > 0 + && entries[start] + .0 + .get("id") + .and_then(Value::as_str) + .is_none_or(str::is_empty) + { + start -= 1; + } + let timestamps = entries[start..] + .iter() + .filter_map(|(item, at)| { + let id = item.get("id").and_then(Value::as_str)?; + (*at > 0).then(|| (id.to_string(), *at)) + }) + .collect(); + Ok(( + entries + .into_iter() + .skip(start) + .map(|(item, _)| item) + .collect(), + start > 0, + timestamps, + )) } pub(crate) fn read_direct_project_history_items_slice_at( @@ -640,6 +713,165 @@ mod tests { const RESPONSE_ITEM_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"user","id":"codex-item-2","content":[{"type":"input_text","text":"再加一个按钮"}]}}"#; const RESPONSE_ASSISTANT_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"已完成"}]}}"#; + fn write_items(root: &std::path::Path, items: &[Value]) { + let lines = items + .iter() + .enumerate() + .map(|(index, item)| { + json!({"type": "response_item", "payload": item, "recordedAt": 1000 + index}) + .to_string() + }) + .collect::>(); + write_history_lines(root, &lines.iter().map(String::as_str).collect::>()); + } + + #[test] + fn chat_pages_skip_tool_only_tail_and_gaps_without_losing_messages_or_times() { + let root = init_history_project("message-pages"); + let mut raw = Vec::new(); + let mut expected = Vec::new(); + for n in 0..44 { + let item = json!({ + "id": format!("message-{n}"), "type": "message", + "role": if n == 0 || n == 38 { "user" } else { "assistant" }, + "content": [{"type": "output_text", "text": format!("消息 {n}")}], + }); + expected.push(item.clone()); + raw.push(item); + for tool in 0..25 { + raw.push(json!({ + "id": format!("tool-{n}-{tool}"), "type": "function_call_output", + "output": "工具结果不应占聊天页名额", + })); + } + } + write_items(root.path(), &raw); + let path = history_path(root.path()); + let before = std::fs::read(&path).unwrap(); + let (old_page, _, _) = + super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap(); + assert!(old_page + .iter() + .all(|item| item["type"] == "function_call_output")); + let mut cursor = None; + let mut all = Vec::new(); + let mut sizes = Vec::new(); + loop { + let (mut page, more, timestamps) = + super::read_direct_project_chat_items_slice_at(root.path(), cursor.as_deref(), 20) + .unwrap(); + sizes.push(page.len()); + for item in &page { + let index = raw.iter().position(|raw| raw["id"] == item["id"]).unwrap(); + assert_eq!( + timestamps[item["id"].as_str().unwrap()], + 1000 + index as u64 + ); + } + let next = page + .first() + .and_then(|item| item["id"].as_str()) + .map(str::to_string); + page.append(&mut all); + all = page; + if !more { + break; + } + assert_ne!(next, cursor); + cursor = next; + assert!(sizes.len() < 10); + } + assert_eq!(sizes, vec![20, 20, 4]); + assert_eq!(all, expected); + assert_eq!(std::fs::read(&path).unwrap(), before); + } + + #[test] + fn chat_pages_handle_empty_content_internal_context_and_missing_ids() { + let root = init_history_project("message-page-boundary"); + write_items( + root.path(), + &[ + json!({"id":"u", "role":"user", "content":[{"text":"第一条"}]}), + json!({"role":"assistant", "content":[{"text":"无ID的旧消息"}]}), + json!({"id":"a", "role":"assistant", "content":[{"text":"最后一条"}]}), + json!({"id":"empty", "role":"assistant", "content":[{"text":""}]}), + json!({"id":"internal", "role":"user", "content":[{"text":"内部"}]}), + json!({"id":"reason", "type":"reasoning", "content":[{"text":"推理"}]}), + ], + ); + let (page, more, _) = + super::read_direct_project_chat_items_slice_at(root.path(), None, 1).unwrap(); + assert_eq!(page[0]["id"], "a"); + assert!(more); + let (page, more, _) = + super::read_direct_project_chat_items_slice_at(root.path(), Some("a"), 1).unwrap(); + assert_eq!(page.len(), 2); + assert_eq!(page[0]["id"], "u"); + assert!(page[1].get("id").is_none()); + assert!(!more); + assert!( + super::read_direct_project_chat_items_slice_at(root.path(), Some("missing"), 20) + .is_err() + ); + write_items( + root.path(), + &[json!({"id":"tool", "type":"function_call", "arguments":"{}"})], + ); + let (page, more, _) = + super::read_direct_project_chat_items_slice_at(root.path(), None, 20).unwrap(); + assert!(page.is_empty()); + assert!(!more); + } + + #[test] + #[ignore = "人工只读诊断:通过 AGC_HISTORY_REPLAY_SOURCE 提供原始历史文件"] + fn replay_external_chat_history_pages_without_mutating_source() { + let source = std::env::var_os("AGC_HISTORY_REPLAY_SOURCE").expect("provide replay source"); + let before = std::fs::read(&source).expect("read source"); + let root = init_history_project("external-history-replay"); + let path = history_path(root.path()); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, &before).unwrap(); + let expected = + super::read_direct_project_history_entries_filtered_at(root.path(), None, true) + .expect("read messages"); + let mut cursor = None; + let mut all = Vec::new(); + let mut pages = 0; + loop { + let (mut items, more, _) = + super::read_direct_project_chat_items_slice_at(root.path(), cursor.as_deref(), 20) + .expect("read page"); + let next = items + .first() + .and_then(|item| item["id"].as_str()) + .map(str::to_string); + items.append(&mut all); + all = items; + pages += 1; + if !more { + break; + } + assert!(next.is_some() && next != cursor, "cursor must advance"); + assert!(pages <= expected.len() + 1, "pagination must terminate"); + cursor = next; + } + assert!( + all.iter().eq(expected.iter().map(|(item, _)| item)), + "message order and content must match" + ); + assert!( + std::fs::read(&source).unwrap() == before, + "source must remain unchanged" + ); + eprintln!( + "history replay: messages={}, pages={pages}, users={}", + all.len(), + all.iter().filter(|item| item["role"] == "user").count() + ); + } + #[test] fn history_timestamps_survive_reload_and_idempotent_append_without_changing_raw_items() { let root = init_history_project("history-time"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs index e3bc497ea..1bec45d0a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs @@ -59,6 +59,7 @@ pub(crate) struct DirectThreadHistorySlice { pub(crate) items: Vec, pub(crate) has_more: bool, pub(crate) item_timestamps: std::collections::BTreeMap, + pub(crate) oldest_item_id: Option, } #[derive(Clone, Debug)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index a62bfd568..f5724810b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5035,7 +5035,21 @@ pub(crate) fn read_local_project_text_preview_at( return Err("只能读取当前项目已登记的文档资源".to_string()); } cancellation.check()?; - load_local_project_text_preview_with_cancellation(root, &normalized_path, cancellation) + let mut preview = + load_local_project_text_preview_with_cancellation(root, &normalized_path, cancellation)?; + if normalized_path.to_ascii_lowercase().ends_with(".json") { + preview.ui_design_asset_id = manifest.assets.iter().find_map(|asset| { + (asset.local_path == normalized_path + && ui_editor::persistence::is_valid_ui_design_json( + &preview.content, + &manifest.project_id, + &asset.id, + )) + .then(|| asset.id.clone()) + }); + } + cancellation.check()?; + Ok(preview) } #[tauri::command] @@ -5401,19 +5415,29 @@ pub(crate) async fn read_direct_project_history_slice( project_path: String, before_item_id: Option, limit: Option, + messages_only: Option, ) -> Result { tauri::async_runtime::spawn_blocking(move || { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.read")?; - let (items, has_more, item_timestamps) = read_direct_project_history_items_slice_at( - root, - before_item_id.as_deref(), - limit.unwrap_or(20), - )?; + let read_slice = if messages_only.unwrap_or(false) { + read_direct_project_chat_items_slice_at + } else { + read_direct_project_history_items_slice_at + }; + let (items, has_more, item_timestamps) = + read_slice(root, before_item_id.as_deref(), limit.unwrap_or(20))?; + let oldest_item_id = items + .first() + .and_then(|item| item.get("id")) + .and_then(serde_json::Value::as_str) + .filter(|id| !id.is_empty()) + .map(str::to_string); Ok(DirectThreadHistorySlice { items, has_more, item_timestamps, + oldest_item_id, }) }) .await diff --git a/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs b/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs index 690c95b45..d7ca4b4ba 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs @@ -24,6 +24,9 @@ pub(crate) struct LocalProjectTextPreview { pub(crate) media_type: String, pub(crate) byte_len: u64, pub(crate) content: String, + /// 仅由已登记资源的原生 UI State 校验设置;前端不根据正文猜测编辑能力。 + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) ui_design_asset_id: Option, } #[derive(Debug, Eq, PartialEq, Serialize)] @@ -163,6 +166,7 @@ pub(crate) fn load_local_project_text_preview_with_cancellation( media_type: media_type.to_string(), byte_len: content.len() as u64, content, + ui_design_asset_id: None, }) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs index 73b55d9ce..a566a9225 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs @@ -158,7 +158,7 @@ pub(crate) fn load_ui_design_state_at( let root = Path::new(input.project_path.trim()); let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?; let asset_id = required_identifier(&input.asset_id, "assetId")?; - let asset = ui_design_asset(root, &expected_project_id, &asset_id)?; + let asset = registered_json_asset(root, &expected_project_id, &asset_id)?; let document = read_ui_design_document(root, &asset.local_path, &expected_project_id, &asset_id)?; validate_document(&document, &expected_project_id, &asset_id)?; @@ -175,7 +175,7 @@ pub(crate) fn generate_ui_design_code_at( let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?; let asset_id = required_identifier(&input.asset_id, "assetId")?; let _lock = acquire_project_write_lock(root, "ui_design.code_generate")?; - let asset = ui_design_asset(root, &expected_project_id, &asset_id)?; + let asset = registered_json_asset(root, &expected_project_id, &asset_id)?; let document = read_ui_design_document_locked(root, &asset.local_path, &expected_project_id, &asset_id)?; let (content, tree_exports, node_count) = render_ui_design_state_js(&document.state)?; @@ -225,9 +225,9 @@ pub(crate) fn save_ui_design_state_at( let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?; let asset_id = required_identifier(&input.asset_id, "assetId")?; - let preflight_asset = ui_design_asset(root, &expected_project_id, &asset_id)?; + let preflight_asset = registered_json_asset(root, &expected_project_id, &asset_id)?; let _lock = acquire_project_write_lock(root, "ui_design.state_save")?; - let asset = ui_design_asset(root, &expected_project_id, &asset_id)?; + let asset = registered_json_asset(root, &expected_project_id, &asset_id)?; if asset.local_path != preflight_asset.local_path { return Err("UI 设计资源在保存锁获取期间发生变化,请重试".to_string()); } @@ -311,6 +311,19 @@ fn ui_design_asset( root: &Path, expected_project_id: &str, asset_id: &str, +) -> Result { + let asset = registered_json_asset(root, expected_project_id, asset_id)?; + // 新状态初始化仍是显式 UI 创建动作,不能因放开已有设计的登记标签而覆盖普通 JSON。 + if asset.kind != "UI" || asset.media_type != "application/json" { + return Err("目标资源不是 UI 设计 JSON 资产".to_string()); + } + Ok(asset) +} + +fn registered_json_asset( + root: &Path, + expected_project_id: &str, + asset_id: &str, ) -> Result { let manifest = read_existing_manifest_for_project(root)?; if manifest.project_id != expected_project_id { @@ -321,8 +334,10 @@ fn ui_design_asset( .into_iter() .find(|asset| asset.id == asset_id) .ok_or_else(|| "UI 设计资源不存在".to_string())?; - if asset.kind != "UI" || asset.media_type != "application/json" { - return Err("目标资源不是 UI 设计 JSON 资产".to_string()); + if !asset.local_path.to_ascii_lowercase().ends_with(".json") + || !is_supported_project_text_resource(&asset.local_path, &asset.media_type) + { + return Err("目标资源不是已登记的 JSON 资产".to_string()); } normalize_relative_path(&asset.local_path)?; Ok(asset) @@ -393,10 +408,27 @@ fn read_ui_design_document_path(path: &Path) -> Result bool { + parse_ui_design_document(content.as_bytes()) + .and_then(|document| validate_document(&document, project_id, asset_id)) + .is_ok() +} + +fn parse_ui_design_document(bytes: &[u8]) -> Result { + if bytes.len() > UI_DESIGN_STATE_MAX_BYTES { + return Err(format!( + "UI 设计 State 超过 {UI_DESIGN_STATE_MAX_BYTES} 字节上限" + )); + } + let value: serde_json::Value = serde_json::from_slice(bytes) + .map_err(|error| format!("解析 UI 设计 State 失败:{error}"))?; let document: PersistedUiDesignState = serde_json::from_value(value.clone()) - .map_err(|error| format!("解析 UI 设计 State 契约失败:{}: {error}", path.display()))?; + .map_err(|error| format!("解析 UI 设计 State 契约失败:{error}"))?; let canonical: serde_json::Value = serde_json::from_slice(&serialize_ui_design_document(&document)?) .map_err(|error| format!("序列化 UI 设计 State 契约失败:{error}"))?; @@ -847,6 +879,170 @@ mod tests { } } + #[test] + fn json_preview_recognition_requires_canonical_state_and_resource_identity() { + let document = serde_json::to_value(empty_document(PROJECT_ID, "design")).unwrap(); + let content = document.to_string(); + assert!(is_valid_ui_design_json(&content, PROJECT_ID, "design")); + assert!(!is_valid_ui_design_json( + &content, + "other-project", + "design" + )); + assert!(!is_valid_ui_design_json( + &content, + PROJECT_ID, + "other-asset" + )); + for content in ["{broken", "{}", "[]", r#"{"type":"UI"}"#] { + assert!(!is_valid_ui_design_json(content, PROJECT_ID, "design")); + } + for (pointer, value) in [ + ("/schemaVersion", serde_json::json!("unknown-schema")), + ("/revision", serde_json::json!(9_007_199_254_740_992u64)), + ("/state/ui_trees", serde_json::json!([{}])), + ] { + let mut invalid = document.clone(); + *invalid.pointer_mut(pointer).unwrap() = value; + assert!(!is_valid_ui_design_json( + &invalid.to_string(), + PROJECT_ID, + "design" + )); + } + let mut unknown = document.clone(); + unknown["state"]["unknown"] = serde_json::json!(true); + assert!(!is_valid_ui_design_json( + &unknown.to_string(), + PROJECT_ID, + "design" + )); + let invalid_state = PersistedUiDesignState { + state: state_with_unavailable_image("../outside.png"), + ..empty_document(PROJECT_ID, "design") + }; + assert!(!is_valid_ui_design_json( + &serde_json::to_string(&invalid_state).unwrap(), + PROJECT_ID, + "design", + )); + assert!(!is_valid_ui_design_json( + &" ".repeat(UI_DESIGN_STATE_MAX_BYTES + 1), + PROJECT_ID, + "design", + )); + } + + #[test] + fn json_preview_and_editor_accept_valid_state_without_rewriting_asset_kind() { + for kind in ["UI", "ui", "ui-design", "document"] { + let (directory, asset_id) = fixture(); + crate::project::mutate_manifest_at(directory.path(), |manifest| { + manifest + .assets + .iter_mut() + .find(|asset| asset.id == asset_id) + .unwrap() + .kind = kind.to_string(); + Ok(()) + }) + .unwrap(); + let path = directory.path().join("ui/design.json"); + let before = fs::read(&path).unwrap(); + let preview = read_local_project_text_preview_at( + directory.path().to_str().unwrap(), + "ui/design.json", + &crate::resource_preview_scheduler::ProjectResourcePreviewScopeCancellation::uncancelled(), + ).unwrap(); + assert_eq!( + preview.ui_design_asset_id.as_deref(), + Some(asset_id.as_str()) + ); + assert_eq!(fs::read(&path).unwrap(), before); + let loaded = load_ui_design_state_at(LoadUiDesignStateInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + asset_id: asset_id.clone(), + }) + .unwrap(); + assert_eq!(loaded.revision, 0); + let saved = save_ui_design_state_at(input( + directory.path(), + &asset_id, + 0, + state_with_unavailable_image("assets/page.png"), + )) + .unwrap(); + assert!(matches!( + saved, + SaveUiDesignStateResult::Saved { revision: 1, .. } + )); + assert_eq!( + read_existing_manifest_for_project(directory.path()) + .unwrap() + .assets + .iter() + .find(|asset| asset.id == asset_id) + .unwrap() + .kind, + kind, + ); + } + } + + #[test] + fn ordinary_or_foreign_json_preview_never_grants_ui_editing_or_overwrites_content() { + let (directory, asset_id) = fixture(); + crate::project::mutate_manifest_at(directory.path(), |manifest| { + manifest + .assets + .iter_mut() + .find(|asset| asset.id == asset_id) + .unwrap() + .kind = "document".to_string(); + Ok(()) + }) + .unwrap(); + let path = directory.path().join("ui/design.json"); + let foreign = serde_json::to_string(&empty_document("other-project", &asset_id)).unwrap(); + for content in [r#"{"ordinary":true}"#, "{broken", foreign.as_str()] { + fs::write(&path, content).unwrap(); + let preview = read_local_project_text_preview_at( + directory.path().to_str().unwrap(), + "ui/design.json", + &crate::resource_preview_scheduler::ProjectResourcePreviewScopeCancellation::uncancelled(), + ).unwrap(); + assert_eq!(preview.ui_design_asset_id, None); + assert_eq!(preview.content, content); + assert!(load_ui_design_state_at(LoadUiDesignStateInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + asset_id: asset_id.clone(), + }) + .is_err()); + assert!(save_ui_design_state_at(input( + directory.path(), + &asset_id, + 0, + empty_document(PROJECT_ID, &asset_id).state, + )) + .is_err()); + assert!( + initialize_ui_design_state_at(directory.path(), PROJECT_ID, &asset_id).is_err() + ); + assert_eq!(fs::read_to_string(&path).unwrap(), content); + } + fs::write( + directory.path().join("ui/unregistered.json"), + serde_json::to_string(&empty_document(PROJECT_ID, &asset_id)).unwrap(), + ) + .unwrap(); + assert!(read_local_project_text_preview_at( + directory.path().to_str().unwrap(), "ui/unregistered.json", + &crate::resource_preview_scheduler::ProjectResourcePreviewScopeCancellation::uncancelled(), + ).is_err()); + } + fn state_with_unavailable_image(path: &str) -> State { serde_json::from_value(serde_json::json!({ "ui_trees": [], diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 9523363ac..be33bfe93 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -239,10 +239,11 @@ import { DeveloperProjectPanels } from './features/project-workspace/DeveloperPr import { DeveloperRuntimePanels } from './features/project-workspace/DeveloperRuntimePanels'; import { type DirectThreadConsumeResult, - directThreadHistoryItemsToMessages, + directThreadHistoryPage, type DirectThreadHistorySlice, type DirectThreadSubscriptionBootstrap, isDirectTurnInProgress, + prependDirectHistoryMessages, } from './features/project-workspace/directThreadEvents'; import { normalizeDirectTimestamp } from './features/project-workspace/directTurnPresentation'; import type { DirectCodexUserContentPart } from './features/project-workspace/generated'; @@ -625,7 +626,7 @@ function claimInitialSupervisorMessageForPage(projectPath: string, scope = '') { * 初始需求是**乐观插入**到 messages 的(latch 命中后先插一条 user 消息,再发起回合), * 而历史回读在 replace 分支里是无条件整体替换 —— 只要回读晚于乐观插入,那条用户消息 * 就会被冲掉(界面上看不到初始需求,但回合其实已经跑起来了)。 - * 这里把当前 messages 里"运行时拥有、且回读结果里没有"的消息保留在末尾(它们是最新的)。 + * 这里只把当前 messages 里"非历史来源、运行时拥有、且回读结果里没有"的消息保留在末尾。 */ function mergeLoadedConversationWithPendingRuntimeMessages( loaded: ChatMessage[], @@ -643,7 +644,7 @@ function mergeLoadedConversationWithPendingRuntimeMessages( loaded.map((message) => `${message.role}\u0000${message.text}`), ); const pending = current.filter((message) => { - if (!message.runtimeOwned) { + if (!message.runtimeOwned || message.fromHistory) { return false; } if (message.messageId) { @@ -1958,7 +1959,7 @@ export function App({ ); const [directHistoryHasMore, setDirectHistoryHasMore] = useState(false); const directHistoryOldestItemIdRef = useRef(null); - const directHistoryLoadingRef = useRef(false); + const directHistoryLoadingRef = useRef(null); const [pendingCommand, setPendingCommand] = useState( null, ); @@ -2151,6 +2152,7 @@ export function App({ function resetProjectSupervisorState() { projectSupervisorHistoryLoadVersionRef.current += 1; + directHistoryLoadingRef.current = null; projectSupervisorRuntimeResumeProjectPathRef.current = null; projectSupervisorSessionIdRef.current = null; projectSupervisorRuntimeRef.current = null; @@ -4009,6 +4011,8 @@ export function App({ } const loadVersion = projectSupervisorHistoryLoadVersionRef.current + 1; projectSupervisorHistoryLoadVersionRef.current = loadVersion; + if (directCodexProductRuntime) + directHistoryLoadingRef.current = loadVersion; try { // V2 projects do not have a Supervisor run or legacy conversation. Probe the // V2 authority first; a missing V2 session returns null and preserves the @@ -4105,6 +4109,7 @@ export function App({ : await readProjectSupervisorActiveSession(invoke, nextProjectPath); let runtimeError = ''; let loadedDirectHistoryHasMore = false; + let loadedDirectHistoryCursor: string | null = null; const projectConversation = directCodexProductRuntime ? (() => { return invoke( @@ -4112,16 +4117,16 @@ export function App({ { projectPath: nextProjectPath, limit: CONVERSATION_INITIAL_VISIBLE_COUNT, + messagesOnly: true, }, ).then((slice) => { - loadedDirectHistoryHasMore = slice.hasMore; + const page = directThreadHistoryPage(slice); + loadedDirectHistoryHasMore = page.hasMore; + loadedDirectHistoryCursor = page.cursor; return { path: nextProjectPath, agentId: null, - messages: directThreadHistoryItemsToMessages( - slice.items, - slice.itemTimestamps, - ), + messages: page.messages, } satisfies LocalConversationResult; }); })() @@ -4197,7 +4202,7 @@ export function App({ const conversationMessages = mergeProjectSupervisorConversation( resolvedProjectConversation.messages, supervisorConversation?.messages ?? [], - ); + ).map((message) => ({ ...message, fromHistory: true })); if ( conversationContainsProjectSupervisorResponseStream( supervisorConversation?.messages ?? [], @@ -4218,9 +4223,7 @@ export function App({ setProjectSupervisorRuntimeError(runtimeError || resumeError); if (directCodexProductRuntime) { setDirectHistoryHasMore(loadedDirectHistoryHasMore); - directHistoryOldestItemIdRef.current = - conversationMessages.find((message) => message.messageId) - ?.messageId ?? null; + directHistoryOldestItemIdRef.current = loadedDirectHistoryCursor; } setMessages((current) => { // replace 分支同样不能丢掉尚未落盘的运行时消息(初始需求)。 @@ -4281,6 +4284,10 @@ export function App({ : workspaceStatus, ); // Keep the default greeting when history is missing or blocked. + } finally { + if (directHistoryLoadingRef.current === loadVersion) { + directHistoryLoadingRef.current = null; + } } } @@ -4396,6 +4403,9 @@ export function App({ setAgentRunHistoryFiles([]); setAgentRuntimeById({}); setMessages(conversationMessages); + // 只有新项目确实打开后才丢弃旧分页位置;打开失败时旧会话仍可继续翻页。 + directHistoryOldestItemIdRef.current = null; + setDirectHistoryHasMore(false); setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); savedConversationProjectPathRef.current = openedProject.projectPath; savedConversationCountRef.current = conversationMessages.length; @@ -12480,48 +12490,63 @@ export function App({ : null; async function showEarlierConversationMessages() { + if (hiddenConversationCount > 0) { + setConversationVisibleCount((current) => + Math.min(messages.length, current + CONVERSATION_VISIBLE_STEP), + ); + return; + } if (directCodexProductRuntime && directHistoryHasMore) { const invoke = resolveTauriInvoke(); const projectPath = localProject?.projectPath; - if (invoke && projectPath && !directHistoryLoadingRef.current) { - directHistoryLoadingRef.current = true; + if (invoke && projectPath && directHistoryLoadingRef.current === null) { + const loadVersion = projectSupervisorHistoryLoadVersionRef.current; + const beforeItemId = directHistoryOldestItemIdRef.current; + directHistoryLoadingRef.current = loadVersion; + const isCurrentLoad = () => + manifestRefreshMountedRef.current && + localProjectPathRef.current === projectPath && + projectSupervisorHistoryLoadVersionRef.current === loadVersion; try { const slice = await invoke( 'read_direct_project_history_slice', { projectPath, - beforeItemId: directHistoryOldestItemIdRef.current, + beforeItemId, limit: CONVERSATION_VISIBLE_STEP, + messagesOnly: true, }, ); - if (localProjectPathRef.current !== projectPath) { + if (!isCurrentLoad()) { return; } - const older = directThreadHistoryItemsToMessages( - slice.items, - slice.itemTimestamps, - ).map((message) => ({ + const page = directThreadHistoryPage(slice, beforeItemId); + const older = page.messages.map((message) => ({ role: message.role === 'user' ? ('user' as const) : ('assistant' as const), text: message.content, runtimeOwned: true, + fromHistory: true, messageId: message.messageId, updatedAt: message.updatedAt, })); - setMessages((current) => [...older, ...current]); + setMessages((current) => + prependDirectHistoryMessages(current, older), + ); setConversationVisibleCount((current) => current + older.length); - setDirectHistoryHasMore(slice.hasMore); - directHistoryOldestItemIdRef.current = - older.find((message) => message.messageId)?.messageId ?? - directHistoryOldestItemIdRef.current; + setDirectHistoryHasMore(page.hasMore); + directHistoryOldestItemIdRef.current = page.cursor; } catch (error) { + if (!isCurrentLoad()) return; setWorkspaceStatus( `读取更早的对话历史失败:${error instanceof Error ? error.message : String(error)}`, ); } finally { - directHistoryLoadingRef.current = false; + if (directHistoryLoadingRef.current === loadVersion) { + directHistoryLoadingRef.current = null; + } } } return; diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 88594678d..6fb26adc6 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -999,6 +999,8 @@ export interface ChatMessage { agentId?: string | null; updatedAt?: number; runtimeOwned?: boolean; + /** 来自历史回读,不作为尚未落盘的实时消息追加到新历史页末尾。 */ + fromHistory?: boolean; } export type DesignAgentInput = diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index c40dca2e4..d7c064561 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -1,4 +1,11 @@ -import { Fragment, useCallback, useEffect, useRef, useState } from 'react'; +import { + Fragment, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; import { createPortal } from 'react-dom'; import { launcherNotifications } from '../../app/constants'; @@ -201,12 +208,18 @@ export function WorkspaceLauncherShell({ */ const manifestMergeNoticeScopeRef = useRef(null); + // 打开项目由创建流程提供,其函数引用随渲染变化;标题栏只持有稳定的转发入口, + // 否则发布 Context 会再次触发工作台 effect,形成发布/清理循环。 + const openProjectRef = useRef(openProject); + useLayoutEffect(() => { + openProjectRef.current = openProject; + }, [openProject]); const openActiveProject = useCallback( (nextProjectPath: string) => { setProjectPath(nextProjectPath); - void openProject(nextProjectPath, 'open'); + void openProjectRef.current(nextProjectPath, 'open'); }, - [openProject, setProjectPath], + [setProjectPath], ); useEffect(() => { @@ -216,7 +229,6 @@ export function WorkspaceLauncherShell({ readFailed: snapshotReadFailed, onOpenProject: openActiveProject, }); - return () => setActiveProjectRuns(null); }, [ activeTurns, currentProjectContext?.projectPath, @@ -224,6 +236,7 @@ export function WorkspaceLauncherShell({ setActiveProjectRuns, snapshotReadFailed, ]); + useEffect(() => () => setActiveProjectRuns(null), [setActiveProjectRuns]); useEffect(() => { const projectPath = currentProjectContext?.projectPath ?? null; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts index 6c1cad229..32180fcff 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts @@ -1,4 +1,7 @@ -import type { LocalConversationMessageRecord } from '../../app/types'; +import type { + ChatMessage, + LocalConversationMessageRecord, +} from '../../app/types'; export type DirectThreadRawEvent = { seq: number; @@ -22,8 +25,54 @@ export type DirectThreadHistorySlice = { items: unknown[]; hasMore: boolean; itemTimestamps?: Record; + oldestItemId?: string | null; }; +/** 游标取原始响应,而非过滤后的聊天消息;拒绝不能前进的页,避免静默反复回读。 */ +export function directThreadHistoryPage( + slice: DirectThreadHistorySlice, + previousCursor: string | null = null, +) { + const first = slice.items[0]; + const firstId = + first && typeof first === 'object' && 'id' in first + ? (first as { id?: unknown }).id + : null; + const cursor = + slice.oldestItemId ?? + (typeof firstId === 'string' && firstId ? firstId : null); + if (slice.hasMore && (!cursor || cursor === previousCursor)) { + throw new Error('对话历史分页游标未前进,请重新读取项目历史'); + } + return { + messages: directThreadHistoryItemsToMessages( + slice.items, + slice.itemTimestamps, + ), + hasMore: slice.hasMore, + cursor, + }; +} + +/** 保留当前实时/已显示版本;原始身份相同的回读消息不能插入第二次。 */ +export function prependDirectHistoryMessages( + current: readonly ChatMessage[], + older: readonly ChatMessage[], +): ChatMessage[] { + const ids = new Set( + current.flatMap((message) => + message.messageId ? [message.messageId] : [], + ), + ); + const additions = older.filter((message) => { + if (!message.messageId) return true; + if (ids.has(message.messageId)) return false; + ids.add(message.messageId); + return true; + }); + return [...additions, ...current]; +} + export function directThreadHistoryItemsToMessages( items: unknown[], itemTimestamps: Readonly> = {}, diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts index a2c24def2..92ba12136 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts @@ -37,6 +37,23 @@ export function isResourceCanvasInteractionTarget( return Boolean(target?.closest(RESOURCE_CANVAS_INTERACTION_SELECTOR)); } +/** 抓手可从卡面发起,但不能抢走输入、媒体控件或画布浮层的交互。 */ +export function isResourceCanvasPanTarget( + target: Element | null | undefined, +): boolean { + if (!target) return false; + if (target.closest('[contenteditable="true"], .game-resource-filter-panel')) { + return false; + } + if (!isResourceCanvasInteractionTarget(target)) return true; + return Boolean( + target.closest('.game-resource-card') && + !target.closest( + 'button:not(.game-resource-card-select), input, textarea, select, a, audio, video', + ), + ); +} + /** * 画布浮层里有自己滚动区的那几个:落在它们里面的滚轮归浮层,画布不得消费。 * diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceDocumentPreviewModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceDocumentPreviewModel.ts index c0bc85c78..390d08c15 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceDocumentPreviewModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceDocumentPreviewModel.ts @@ -1,4 +1,5 @@ import { + isProjectResourceJson, projectResourceCardPreviewKind, projectResourcePathExtension, } from '../../view/project-development/resourceCardPreviewModel'; @@ -65,11 +66,14 @@ export function resourceDocumentPreviewMarkdown( resource: ProjectResource, content: string, ) { - if (projectResourceCardPreviewKind(resource) !== 'code') { + const isJson = isProjectResourceJson(resource); + if (projectResourceCardPreviewKind(resource) !== 'code' && !isJson) { return content; } - const language = - CODE_LANGUAGES[projectResourcePathExtension(resource.path) ?? ''] ?? 'text'; + const language = isJson + ? 'json' + : (CODE_LANGUAGES[projectResourcePathExtension(resource.path) ?? ''] ?? + 'text'); // 围栏长于源码里的任意反引号串,代码生成模板中的 Markdown 不能提前闭合代码块。 const longestRun = (content.match(/`+/g) ?? []).reduce( (length, run) => Math.max(length, run.length), diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourcePreviewMedia.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourcePreviewMedia.tsx index 637ab9103..281ce517c 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/ResourcePreviewMedia.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourcePreviewMedia.tsx @@ -1,10 +1,17 @@ -import { FileCode2, Image as ImageIcon, Music2, Video } from 'lucide-react'; +import { + FileCode2, + Image as ImageIcon, + Music2, + SlidersHorizontal, + Video, +} from 'lucide-react'; import { useEffect, useRef } from 'react'; import { projectResourceCardPreviewKind, type ProjectResourceCardPreviewState, projectResourceCardPreviewVariant, + projectResourceJsonPresentation, } from './resourceCardPreviewModel'; import type { ProjectResource } from './resourceProjectionModel'; @@ -90,6 +97,16 @@ export function ResourcePreviewMedia({ const sourceUrl = preview.status === 'loaded' ? (preview.preview.sourceUrl ?? null) : null; const visual = (() => { + const jsonPresentation = resource + ? projectResourceJsonPresentation(resource, preview) + : null; + if (jsonPresentation) { + return jsonPresentation === 'ui-design' ? ( + + ) : ( + + ); + } if (sourceUrl && (kind === 'raster-image' || kind === 'media-image')) { return ( { + if (jsonPresentation) { + return ( + + {jsonPresentation === 'ui-design' ? ( + + ); + } if ((kind === 'raster-image' || kind === 'media-image') && sourceUrl) { return ( - {resourceCategoryLabel} + {cardTypeLabel} {lineage ? ( // 文字是给人看的关系,`data-resource-lineage` 是给端到端验收的稳定判据 @@ -1342,6 +1367,7 @@ function ResourceBookScene({ onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerCancel} + onLostPointerCapture={onPointerCancel} >
>(new Set()); const resourceCanvasPanRef = useRef<{ pointerId: number; + captureTarget: HTMLElement; category: ResourceBookTarget; startClientX: number; startClientY: number; @@ -3363,15 +3391,14 @@ export default function ProjectDevelopmentView({ ); const cancelResourceCanvasPan = useCallback(() => { - const pan = resourceCanvasPanRef.current; - if (!pan) { - return; - } - const canvas = resourceCanvasRef.current; - if (canvas?.hasPointerCapture?.(pan.pointerId)) { - canvas.releasePointerCapture?.(pan.pointerId); - } + const pans = [resourceCanvasPanRef.current, resourceBookMainPanRef.current]; resourceCanvasPanRef.current = null; + resourceBookMainPanRef.current = null; + for (const pan of pans) { + if (pan?.captureTarget.hasPointerCapture?.(pan.pointerId)) { + pan.captureTarget.releasePointerCapture(pan.pointerId); + } + } }, []); const cancelResourceCardDrag = useCallback(() => { @@ -4749,6 +4776,8 @@ export default function ProjectDevelopmentView({ }; const handleBlur = () => { resourceCanvasSpacePanRef.current = false; + cancelResourceCanvasPan(); + setResourceCanvasMarquee(null); }; window.addEventListener('keydown', handleKeyDown); window.addEventListener('keyup', handleKeyUp); @@ -4758,7 +4787,7 @@ export default function ProjectDevelopmentView({ window.removeEventListener('keyup', handleKeyUp); window.removeEventListener('blur', handleBlur); }; - }, []); + }, [cancelResourceCanvasPan]); const handleResourceBookWheel = useCallback( (event: ReactWheelEvent | WheelEvent) => { @@ -4855,7 +4884,10 @@ export default function ProjectDevelopmentView({ const handleResourceBookMainPointerDown = useCallback( (event: ReactPointerEvent) => { - if (resourceBookView !== 'main' || event.button !== 0) { + if ( + resourceBookView !== 'main' || + (event.button !== 0 && event.button !== 2) + ) { return; } const target = event.target as HTMLElement; @@ -4871,6 +4903,7 @@ export default function ProjectDevelopmentView({ resourceBookTransitionControllerRef.current.settle(); resourceBookMainPanRef.current = { pointerId: event.pointerId, + captureTarget: event.currentTarget, startClientX: event.clientX, startClientY: event.clientY, startViewport: resourceBookMainViewportRef.current, @@ -5096,11 +5129,19 @@ export default function ProjectDevelopmentView({ const canvasTarget = resourceBookOpensAllResources ? RESOURCE_BOOK_ALL_TARGET : activePageCategory; - if (!canvasTarget || event.button > 1) { + if (!canvasTarget || event.button > 2) { return; } const target = event.target as HTMLElement; - if (isResourceCanvasInteractionTarget(target)) { + const isPan = + event.button === 2 || + event.button === 1 || + resourceCanvasSpacePanRef.current; + if ( + event.button === 2 + ? !isResourceCanvasPanTarget(target) + : isResourceCanvasInteractionTarget(target) + ) { return; } // 点选态下空白处的左键不起框选、也不清画布焦点/选中:点选只认资源卡, @@ -5114,12 +5155,13 @@ export default function ProjectDevelopmentView({ return; } resourceBookTransitionControllerRef.current.settle(); - // 与美术画布一致:中键或按住空格是平移,左键在空白处是框选。 - if (event.button === 1 || resourceCanvasSpacePanRef.current) { + // 右键平移,保留中键/空格抓手;空白处左键继续框选。 + if (isPan) { event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); resourceCanvasPanRef.current = { pointerId: event.pointerId, + captureTarget: event.currentTarget, category: canvasTarget, startClientX: event.clientX, startClientY: event.clientY, @@ -5292,12 +5334,22 @@ export default function ProjectDevelopmentView({ const openResourceUiEditor = useCallback( (resource: ProjectResource) => { - if (resource.subtype === 'ui-prototype') { + const identity = resourceCardPreviews.identityByResourceId.get( + resource.id, + ); + const jsonPresentation = projectResourceJsonPresentation( + resource, + identity ? resourceCardPreviews.previews.get(identity) : null, + ); + if (resource.subtype === 'ui-prototype' && jsonPresentation === null) { void openUiDesignEditor(resource); return; } - if (resource.manifestAssetId === null) { - setResourceWorkbenchNotice('该 UI 资源缺少有效的正式资产身份'); + if ( + resource.manifestAssetId === null || + jsonPresentation !== 'ui-design' + ) { + setResourceWorkbenchNotice('该资源尚未通过 UI 设计 JSON 校验'); return; } canvasOpenEpochRef.current += 1; @@ -5318,7 +5370,13 @@ export default function ProjectDevelopmentView({ : {}), }); }, - [advanceFocusGeneration, manifest.assets, openUiDesignEditor], + [ + advanceFocusGeneration, + manifest.assets, + openUiDesignEditor, + resourceCardPreviews.identityByResourceId, + resourceCardPreviews.previews, + ], ); /** @@ -6202,8 +6260,7 @@ export default function ProjectDevelopmentView({ setMode('run'); } - const showRunUnavailableHint = - !runAvailable && selectedResourceIds.length === 0 && !uiEditorRoute; + const showRunUnavailableHint = !runAvailable && !uiEditorRoute; const renderResourceBookCard = useCallback( ( @@ -7457,9 +7514,16 @@ export default function ProjectDevelopmentView({ : null, [manifest, selectedResource], ); + const selectedResourceJsonPresentation = selectedResource + ? projectResourceJsonPresentation( + selectedResource, + selectedResourceCardPreview, + ) + : null; const selectedResourceOpensUiEditor = - selectedResource?.subtype === 'UI' || - selectedResource?.subtype === 'ui-prototype'; + selectedResourceJsonPresentation === 'ui-design' || + (selectedResourceJsonPresentation === null && + selectedResource?.subtype === 'ui-prototype'); const selectedToolbarStyle = selectedResourceLayer ? resolveSelectedToolbarStyle({ @@ -7847,10 +7911,24 @@ export default function ProjectDevelopmentView({ className={`game-resource-manager game-resource-book-manager game-resource-book-manager--${resourceBookState.view} game-resource-book-manager--${resourceBookState.phase}`} data-resource-book-view={resourceBookState.view} data-resource-book-transition={resourceBookState.phase} + onContextMenu={(event) => { + const target = event.target as Element; + if ( + event.button === 2 && + event.currentTarget.contains(target) && + target.closest( + '.game-resource-book-scene, .game-resource-canvas, .game-resource-book-main', + ) && + isResourceCanvasPanTarget(target) + ) { + event.preventDefault(); + } + }} onPointerDownCapture={(event) => { if ( event.currentTarget.dataset.resourceBookTransition !== 'idle' && + event.button === 0 && (event.target as Element).closest('.game-resource-card') ) { event.preventDefault(); @@ -7924,7 +8002,8 @@ export default function ProjectDevelopmentView({ 预览 ) : null} - {selectedResourceOpensUiEditor ? ( + {selectedResource && + selectedResourceOpensUiEditor ? ( {sortMode === 'dependency' && dependencyRelationshipDescriptions.length > 0 ? ( diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts index c97057461..979015c52 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts @@ -58,6 +58,8 @@ export type ProjectResourceCardPreviewPayload = { hasAlpha?: boolean; sourceUrl?: string; content?: string; + /** 原生侧完整校验过的 UI State 资产身份,不由前端解析 JSON 推断。 */ + uiDesignAssetId?: string; }; export type ProjectResourceCardPreviewTransportPayload = Omit< @@ -219,7 +221,8 @@ const markdownExtension = /\.(md|markdown|mdx)$/iu; * 与 `resourceProjectionModel` 的 `gameCodeExtension` 是两份口径,刻意不复用: * 那份用于**筛选与归属**,改动会波及画布栏目与计数;这份只决定**卡面怎么画**。 * 这里按用户口径把 `.yaml` / `.toml` / `.xml` / `.html` / `.css` / `.sql` - * 一并算代码;JSON 规格属于文档预览。 + * 一并算代码。JSON 留在文本读取通道,由原生内容识别区分普通 JSON 和 UI 设计, + * 不能像源码卡一样跳过预取;卡面不显示 JSON 正文。 */ const cardCodeExtension = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|rs|py|go|java|kt|kts|cs|cpp|cc|cxx|c|h|hpp|swift|php|rb|lua|sh|bash|zsh|ps1|psm1|ya?ml|toml|xml|html?|css|scss|less|sql|graphql|gql|vue|svelte)$/iu; @@ -236,6 +239,30 @@ export function projectResourcePathExtension(path: string): string | null { return matched ? matched[1]!.toLowerCase() : null; } +export function isProjectResourceJson( + resource: Pick, +): boolean { + return ( + projectResourcePathExtension(resource.path) === 'json' || + (projectResourcePathExtension(resource.path) === null && + resource.mediaType.toLowerCase().includes('json')) + ); +} + +/** 读取结果必须仍属于当前卡片;元数据中的 UI 标签本身不能授予编辑入口。 */ +export function projectResourceJsonPresentation( + resource: ProjectResource, + preview: ProjectResourceCardPreviewState | null | undefined, +): 'json' | 'ui-design' | null { + if (!isProjectResourceJson(resource)) return null; + return resource.manifestAssetId && + preview?.status === 'loaded' && + preview.preview.path === resource.path && + preview.preview.uiDesignAssetId === resource.manifestAssetId + ? 'ui-design' + : 'json'; +} + /** 代码文件的类型标签(如 `.ts` → `TS`);不是代码文件时返回 `null`。 */ export function projectResourceCodeTypeLabel(path: string): string | null { const trimmed = path.trim(); @@ -256,7 +283,7 @@ export function projectResourceCardPreviewKind( return 'document'; } // Markdown / 代码在扩展名这一层就分流,不再依赖上游登记类型: - // 上游把 JSON 规格登记成「文档」,卡面按文档预览;代码文件按扩展名分流。 + // JSON 使用文档读取通道完成原生语义识别;源码文件按扩展名分流。 if (markdownExtension.test(resource.path)) { return 'document'; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts index f175264ba..729658ceb 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts @@ -167,6 +167,7 @@ function materializeProjectResourceCardPreview( pixelHeight: transport.pixelHeight, hasAlpha: transport.hasAlpha, content: transport.content, + uiDesignAssetId: transport.uiDesignAssetId, }, retainedBytes: transport.content === undefined diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 01e5f217d..a1ab75aad 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -2450,7 +2450,7 @@ export function registerHomeProjectCreationTests() { }; } if (command === 'read_direct_project_history_slice') { - expect(args).toEqual({ projectPath, limit: 20 }); + expect(args).toEqual({ projectPath, limit: 20, messagesOnly: true }); return { items: [...persistedMessages], hasMore: false }; } if (command === 'append_local_conversation_message') { @@ -2536,7 +2536,7 @@ export function registerHomeProjectCreationTests() { return manifest; } if (command === 'read_direct_project_history_slice') { - expect(args).toEqual({ projectPath, limit: 20 }); + expect(args).toEqual({ projectPath, limit: 20, messagesOnly: true }); return { items: [...persistedMessages], hasMore: false }; } if (command === 'append_local_permission_log') { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index f751841f6..21c3f481c 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -5985,7 +5985,7 @@ export function registerProjectWorkbenchFoundationTests() { expect(chatWalletSlots).toHaveLength(1); expect(projectDevelopmentSource).toMatch(/walletEntry=\{walletEntry\}/); expect(projectDevelopmentSource).toMatch( - /const showRunUnavailableHint\s*=\s*!runAvailable\s*&&\s*selectedResourceIds\.length === 0\s*&&\s*!uiEditorRoute/s, + /const showRunUnavailableHint\s*=\s*!runAvailable\s*&&\s*!uiEditorRoute\s*;/s, ); expect(projectDevelopmentSource).toMatch( /aria-describedby=\{\s*showRunUnavailableHint\s*\?\s*'run-unavailable-hint'\s*:\s*undefined\s*\}/s, @@ -6049,6 +6049,15 @@ export function registerProjectWorkbenchFoundationTests() { }, }; } + if (command === 'read_local_project_text_preview') { + return { + path: 'assets/ui-design.json', + mediaType: 'application/json', + byteLen: 2, + content: '{}', + uiDesignAssetId: 'ui-design-resource', + }; + } throw new Error(`unexpected invoke ${command}`); }, ); @@ -9130,6 +9139,7 @@ export function registerProjectSupervisorSurfaceTests() { expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { projectPath, limit: 20, + messagesOnly: true, }), ); // 默认任务占位行也不能触发专业 Agent 历史的批量读取。 diff --git a/apps/ai-game-creator-shell/tests/directHistoryPagination.test.tsx b/apps/ai-game-creator-shell/tests/directHistoryPagination.test.tsx new file mode 100644 index 000000000..8c0f00a19 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directHistoryPagination.test.tsx @@ -0,0 +1,269 @@ +/** @vitest-environment jsdom */ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { DirectThreadHistorySlice } from '../src/features/project-workspace/directThreadEvents'; +import { + act, + App, + createGameCreationAppManifest, + fireEvent, + React, + render, + screen, + setComposerText, +} from './appSurface/harness'; + +const projectPath = '/tmp/direct-message-pages'; +const manifest = createGameCreationAppManifest( + 'direct-message-pages', + '历史分页', +); +const messages = Array.from({ length: 26 }, (_, index) => ({ + id: `direct-codex:turn-${Math.floor(index / 2)}:${index % 2 ? 'assistant' : 'user'}`, + type: 'message', + role: index % 2 ? 'assistant' : 'user', + content: [ + { + type: index % 2 ? 'output_text' : 'input_text', + text: `历史正文 ${index}`, + }, + ], +})); +function page( + items: typeof messages, + hasMore: boolean, +): DirectThreadHistorySlice { + return { items, hasMore, oldestItemId: items[0]?.id ?? null }; +} +function deferred() { + let resolve!: (value: DirectThreadHistorySlice) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} +function install( + read: ( + args: Record, + ) => DirectThreadHistorySlice | Promise, +) { + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_direct_project_history_slice') { + expect(args?.messagesOnly).toBe(true); + return read(args!); + } + if (command === 'read_project_permission_policy') + return { + path: '.agent/policy.json', + policy: { deniedCommands: [], confirmCommands: [] }, + }; + if (command === 'get_local_game_manifest') return manifest; + if (command === 'read_game_creator_app_config') + return { + config: { + selectedModelId: 'quality', + selectedModelIsDefault: true, + llm: { customEnabled: false }, + }, + }; + if ( + command === 'read_direct_tool_calls' || + command === 'read_direct_turn_stream' || + command === 'list_game_creator_direct_active_turns' + ) + return []; + return null; + }, + ); + window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; + return invoke; +} +function mount(path = projectPath) { + return render( + , + ); +} +const earlier = () => screen.getByRole('button', { name: /显示更早/ }); +afterEach(() => { + delete window.__TAURI__; +}); + +describe('Direct 聊天历史分页集成', () => { + it('加载旧页后重读历史回到最新页,再翻页仍按原顺序且不重复', async () => { + const invoke = install((args) => + args.beforeItemId + ? page(messages.slice(0, 6), false) + : page(messages.slice(6), true), + ); + mount(); + await screen.findByText('历史正文 25'); + fireEvent.click(earlier()); + await screen.findByText('历史正文 0'); + await setComposerText(screen.getByLabelText('陶泥儿对话内容'), '/history'); + fireEvent.click(screen.getByRole('button', { name: '发送' })); + await act(async () => { + await Promise.resolve(); + }); + expect(screen.queryByText('历史正文 0')).toBeNull(); + expect(screen.getByText('历史正文 25')).not.toBeNull(); + fireEvent.click(earlier()); + await screen.findByText('历史正文 0'); + for (let index = 0; index < messages.length; index += 1) { + expect(screen.getAllByText(`历史正文 ${index}`)).toHaveLength(1); + if (index > 0) { + expect( + screen + .getByText(`历史正文 ${index - 1}`) + .compareDocumentPosition(screen.getByText(`历史正文 ${index}`)) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).not.toBe(0); + } + } + expect( + invoke.mock.calls.filter( + ([command]) => command === 'read_direct_project_history_slice', + ), + ).toHaveLength(4); + }); + + it('首屏按消息加载20条,更早消息使用原生游标,原始工具记录不占页', async () => { + const invoke = install((args) => + args.beforeItemId + ? page(messages.slice(0, 6), false) + : page(messages.slice(6), true), + ); + mount(); + await screen.findByText('历史正文 25'); + expect(screen.queryByText('历史正文 0')).toBeNull(); + fireEvent.click(earlier()); + await screen.findByText('历史正文 0'); + expect(screen.queryByRole('button', { name: /显示更早/ })).toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { + projectPath, + beforeItemId: messages[6]!.id, + limit: 20, + messagesOnly: true, + }); + }); + + it('重复点击单飞,失败保留游标可重试,重叠消息不重复', async () => { + const pending = deferred(); + let attempts = 0; + const invoke = install((args) => { + if (!args.beforeItemId) return page(messages.slice(6), true); + attempts += 1; + return attempts === 1 + ? pending.promise + : page(messages.slice(0, 8), false); + }); + mount(); + await screen.findByText('历史正文 25'); + fireEvent.click(earlier()); + fireEvent.click(earlier()); + expect(attempts).toBe(1); + await act(async () => pending.reject(new Error('模拟读取失败'))); + expect(screen.getByText('历史正文 25')).not.toBeNull(); + fireEvent.click(earlier()); + await screen.findByText('历史正文 0'); + expect(screen.getAllByText('历史正文 6')).toHaveLength(1); + expect(screen.getAllByText('历史正文 7')).toHaveLength(1); + const loads = invoke.mock.calls.filter( + ([command, args]) => + command === 'read_direct_project_history_slice' && args?.beforeItemId, + ); + expect(loads.map(([, args]) => args?.beforeItemId)).toEqual([ + messages[6]!.id, + messages[6]!.id, + ]); + }); + + it('同项目重新加载使旧翻页失效,旧 finally 不解除新请求的单飞', async () => { + const old = deferred(); + const fresh = deferred(); + let fullLoads = 0; + let olderLoads = 0; + const recent = messages.slice(6).map((item) => ({ + ...item, + content: [{ type: 'output_text', text: `重读 ${item.content[0]!.text}` }], + })); + install((args) => { + if (!args.beforeItemId) { + fullLoads += 1; + return page(fullLoads === 1 ? messages.slice(6) : recent, true); + } + olderLoads += 1; + return olderLoads === 1 ? old.promise : fresh.promise; + }); + mount(); + await screen.findByText('历史正文 25'); + fireEvent.click(earlier()); + await setComposerText(screen.getByLabelText('陶泥儿对话内容'), '/history'); + fireEvent.click(screen.getByRole('button', { name: '发送' })); + await screen.findByText('重读 历史正文 25'); + fireEvent.click(earlier()); + await act(async () => + old.resolve( + page( + [ + { + ...messages[0]!, + id: 'stale', + content: [{ type: 'input_text', text: '过期消息' }], + }, + ], + false, + ), + ), + ); + expect(screen.queryByText('过期消息')).toBeNull(); + fireEvent.click(earlier()); + expect(olderLoads).toBe(2); + await act(async () => fresh.resolve(page(messages.slice(0, 6), false))); + await screen.findByText('历史正文 0'); + }); + + it('离开项目并重进后,旧请求不能污染新实例', async () => { + const old = deferred(); + const invoke = install((args) => + args.beforeItemId ? old.promise : page(messages.slice(6), true), + ); + const first = mount(); + await screen.findByText('历史正文 25'); + fireEvent.click(earlier()); + first.unmount(); + const other = mount('/tmp/another-project'); + await screen.findByText('历史正文 25'); + other.unmount(); + mount(); + await screen.findByText('历史正文 25'); + await act(async () => + old.resolve( + page( + [ + { + ...messages[0]!, + id: 'stale', + content: [{ type: 'input_text', text: '旧项目迟到消息' }], + }, + ], + false, + ), + ), + ); + expect(screen.queryByText('旧项目迟到消息')).toBeNull(); + expect(earlier()).not.toBeNull(); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'read_direct_project_history_slice', + ), + ).toHaveLength(4); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts b/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts index 128cdf135..ff7849dfc 100644 --- a/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts @@ -2,10 +2,59 @@ import { describe, expect, it } from 'vitest'; import { directThreadHistoryItemsToMessages, + directThreadHistoryPage, isDirectTurnInProgress, + prependDirectHistoryMessages, } from '../src/features/project-workspace/directThreadEvents'; describe('Direct 回合状态与历史时间', () => { + it('游标来自原始切片,不从没有聊天消息的工具页倒推', () => { + const page = directThreadHistoryPage( + { + items: [{ id: 'tool-older', type: 'function_call_output' }], + hasMore: true, + oldestItemId: 'tool-older', + }, + 'message-newer', + ); + expect(page.messages).toEqual([]); + expect(page.cursor).toBe('tool-older'); + }); + it('空历史结束,无 ID 或不前进的非终页明确失败而不循环', () => { + expect(directThreadHistoryPage({ items: [], hasMore: false })).toEqual({ + messages: [], + cursor: null, + hasMore: false, + }); + expect(() => directThreadHistoryPage({ items: [], hasMore: true })).toThrow( + '游标未前进', + ); + expect(() => + directThreadHistoryPage( + { + items: [{ id: 'same' }], + hasMore: true, + oldestItemId: 'same', + }, + 'same', + ), + ).toThrow('游标未前进'); + }); + it('重叠页按原始 ID 去重,保留当前正文,旧无 ID 消息不删除', () => { + const current = [ + { role: 'assistant' as const, text: '完整正文', messageId: 'a' }, + ]; + const old = { role: 'user' as const, text: '用户输入', messageId: 'u' }; + expect( + prependDirectHistoryMessages(current, [ + old, + old, + { role: 'assistant', text: '旧快照', messageId: 'a' }, + { role: 'assistant', text: '无身份旧消息' }, + ]), + ).toEqual([old, { role: 'assistant', text: '无身份旧消息' }, ...current]); + }); + it('终态和空状态不恢复为活动回合', () => { for (const status of [ 'completed', diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx index f3a58a319..22018450b 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx @@ -14,6 +14,7 @@ import { canDismissResourceCanvasQuickEdit, isResourceCanvasHostOverlayOpen, isResourceCanvasInteractionTarget, + isResourceCanvasPanTarget, isResourceCanvasWheelOverlayTarget, resolveResourceCanvasFloatingPanelDismissOpen, resolveResourceCanvasFloatingPanelOpen, @@ -41,6 +42,38 @@ const RESOURCE_FOCUS_SOURCE_LAYER: CanvasLayer = { }; describe('resourceCanvasFocusModel', () => { + test('右键抓手允许卡面和选中按钮,不抢媒体控件、编辑器及浮层', () => { + document.body.innerHTML = ` +
+
+ + + +
+
+
筛选
+
操作
+
+ `; + for (const id of ['blank', 'card', 'select']) { + expect(isResourceCanvasPanTarget(document.getElementById(id))).toBe(true); + } + for (const id of [ + 'play', + 'video', + 'input', + 'editor', + 'filter', + 'toolbar', + 'title', + ]) { + expect(isResourceCanvasPanTarget(document.getElementById(id))).toBe( + false, + ); + } + expect(isResourceCanvasPanTarget(null)).toBe(false); + }); + test('点在资源卡、交互控件与画布浮层里时不清画布焦点', () => { document.body.innerHTML = `
diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx index 49b9c3dfa..e1de7b91e 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx @@ -340,6 +340,323 @@ afterEach(() => { vi.restoreAllMocks(); }); +async function mountPointerWorkbench( + target: 'main' | 'character' | 'all' = 'character', +) { + const projectId = 'pointer-workbench'; + const projectPath = '/tmp/pointer-workbench'; + const tauri = installLayoutTauri({ + projectIdsByPath: { [projectPath]: projectId }, + }); + render( + , + ); + await settleFocusChain(); + const manager = document.querySelector( + '.game-resource-book-manager', + )!; + vi.spyOn(manager, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 800, + bottom: 600, + width: 800, + height: 600, + toJSON: () => ({}), + } as DOMRect); + act(() => window.dispatchEvent(new Event('resize'))); + if (target !== 'main') { + fireEvent.click( + screen.getByRole('button', { + name: target === 'all' ? '打开所有资源' : '打开角色与对象', + }), + ); + } + await settleFocusChain(); + const surface = manager.querySelector( + target === 'main' + ? '.game-resource-book-main' + : '.game-resource-book-scene', + )!; + const capture = new Set(); + Object.defineProperties(surface, { + setPointerCapture: { + configurable: true, + value: vi.fn((id: number) => capture.add(id)), + }, + hasPointerCapture: { + configurable: true, + value: (id: number) => capture.has(id), + }, + releasePointerCapture: { + configurable: true, + value: vi.fn((id: number) => capture.delete(id)), + }, + }); + const world = manager.querySelector( + '.game-resource-book-scene-world', + )!; + const viewport = () => + Array.from(world.style.transform.matchAll(/-?\d+(?:\.\d+)?/g), (m) => + Number(m[0]), + ); + return { manager, surface, world, viewport, tauri }; +} + +describe('资源画布指针与运行提示', () => { + it('资源卡左键拖动仍提交手动坐标,不平移视口', async () => { + const { manager, viewport, tauri } = await mountPointerWorkbench(); + const card = manager.querySelector( + '.is-expanded .game-resource-card[data-resource-card-id="asset:pointer-a"]', + )!; + const before = viewport(); + const writes = tauri.layoutWrites.length; + fireEvent.pointerDown(card, { + pointerId: 30, + button: 0, + clientX: 100, + clientY: 100, + }); + fireEvent.pointerMove(card, { + pointerId: 30, + buttons: 1, + clientX: 180, + clientY: 140, + }); + fireEvent.pointerUp(card, { + pointerId: 30, + button: 0, + clientX: 180, + clientY: 140, + }); + await waitFor(() => + expect(tauri.layoutWrites.length).toBeGreaterThan(writes), + ); + expect(tauri.layoutWrites.at(-1)?.positions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'asset:pointer-a', + manuallyPlaced: true, + }), + ]), + ); + expect(viewport()).toEqual(before); + expect( + document.querySelector('.genarrative-image-canvas__selection-overlay'), + ).toBeNull(); + }); + + it('资源选中不移除运行不可用提示或改变运行能力', async () => { + const { manager } = await mountPointerWorkbench(); + const hint = '首个可运行原型尚未完成,运行视图暂不可用'; + expect(screen.getByText(hint)).not.toBeNull(); + fireEvent.click( + manager.querySelector( + '.is-expanded [data-resource-id="asset:pointer-a"]', + )!, + ); + expect(screen.getByText(hint)).not.toBeNull(); + const run = screen.getByRole('tab', { name: '运行' }); + expect(run.getAttribute('data-unavailable')).toBe('true'); + expect(run.getAttribute('aria-describedby')).toBe('run-unavailable-hint'); + fireEvent.click(run); + expect(run.getAttribute('aria-selected')).toBe('false'); + }); + + it.each(['main', 'character', 'all'] as const)( + '%s 的空白处支持右键平移且不写资源布局', + async (target) => { + const { surface, viewport, tauri } = await mountPointerWorkbench(target); + const before = viewport(); + const writes = tauri.layoutWrites.length; + fireEvent.pointerDown(surface, { + pointerId: 31, + button: 2, + clientX: 100, + clientY: 100, + }); + fireEvent.pointerMove(surface, { + pointerId: 31, + buttons: 2, + clientX: 170, + clientY: 140, + }); + expect(viewport()).toEqual([before[0]! + 70, before[1]! + 40, before[2]]); + expect(fireEvent.contextMenu(surface, { button: 2 })).toBe(false); + fireEvent.pointerUp(surface, { + pointerId: 31, + button: 2, + clientX: 170, + clientY: 140, + }); + expect( + document.querySelector('.genarrative-image-canvas__selection-overlay'), + ).toBeNull(); + expect(tauri.layoutWrites).toHaveLength(writes); + }, + ); + + it('资源卡右键平移保留选中,左键空白拖动仍框选', async () => { + const { manager, surface, viewport, tauri } = await mountPointerWorkbench(); + const card = manager.querySelector( + '.is-expanded [data-resource-id="asset:pointer-a"]', + )!; + fireEvent.click(card); + const before = viewport(); + const writes = tauri.layoutWrites.length; + fireEvent.pointerDown(card, { + pointerId: 32, + button: 2, + clientX: 100, + clientY: 100, + }); + fireEvent.pointerMove(surface, { + pointerId: 32, + buttons: 2, + clientX: 150, + clientY: 160, + }); + fireEvent.pointerUp(surface, { + pointerId: 32, + button: 2, + clientX: 150, + clientY: 160, + }); + expect(viewport()).toEqual([before[0]! + 50, before[1]! + 60, before[2]]); + expect(card.getAttribute('aria-pressed')).toBe('true'); + expect(tauri.layoutWrites).toHaveLength(writes); + const panned = viewport(); + fireEvent.pointerDown(surface, { + pointerId: 33, + button: 0, + clientX: 10, + clientY: 10, + }); + fireEvent.pointerMove(surface, { + pointerId: 33, + buttons: 1, + clientX: 780, + clientY: 580, + }); + expect( + document.querySelector('.genarrative-image-canvas__selection-overlay'), + ).not.toBeNull(); + expect(selectedResourceIdsInDom()).toContain('asset:pointer-a'); + expect(viewport()).toEqual(panned); + fireEvent.pointerUp(surface, { pointerId: 33, button: 0 }); + expect( + document.querySelector('.genarrative-image-canvas__selection-overlay'), + ).toBeNull(); + }); + + it.each(['cancel', 'capture', 'blur'] as const)( + '%s 后右键平移不会继续跟随指针', + async (reason) => { + const { surface, viewport } = await mountPointerWorkbench(); + const before = viewport(); + fireEvent.pointerDown(surface, { + pointerId: 34, + button: 2, + clientX: 100, + clientY: 100, + }); + fireEvent.pointerMove(surface, { + pointerId: 34, + buttons: 2, + clientX: 110, + clientY: 120, + }); + const moved = viewport(); + expect(moved).toEqual([before[0]! + 10, before[1]! + 20, before[2]]); + if (reason === 'cancel') + fireEvent.pointerCancel(surface, { pointerId: 34 }); + else if (reason === 'capture') + fireEvent.lostPointerCapture(surface, { pointerId: 34 }); + else act(() => window.dispatchEvent(new Event('blur'))); + fireEvent.pointerMove(surface, { + pointerId: 34, + clientX: 300, + clientY: 300, + }); + expect(viewport()).toEqual(moved); + }, + ); + + it('总览失焦终止右键平移,子画布切换释放实际捕获节点', async () => { + const { surface, viewport } = await mountPointerWorkbench('main'); + const before = viewport(); + fireEvent.pointerDown(surface, { + pointerId: 36, + button: 2, + clientX: 10, + clientY: 10, + }); + fireEvent.pointerMove(surface, { + pointerId: 36, + buttons: 2, + clientX: 40, + clientY: 40, + }); + const moved = viewport(); + expect(moved).toEqual([before[0]! + 30, before[1]! + 30, before[2]]); + act(() => window.dispatchEvent(new Event('blur'))); + expect(surface.releasePointerCapture).toHaveBeenCalledWith(36); + fireEvent.pointerMove(surface, { + pointerId: 36, + clientX: 100, + clientY: 100, + }); + expect(viewport()).toEqual(moved); + fireEvent.pointerDown(surface, { + pointerId: 37, + button: 2, + clientX: 10, + clientY: 10, + }); + fireEvent.click(screen.getByRole('button', { name: '打开角色与对象' })); + expect(surface.releasePointerCapture).toHaveBeenCalledWith(37); + }); + + it('控件右键不被画布接管,初次打开不整理也能连续双指平移', async () => { + const { manager, surface, viewport } = await mountPointerWorkbench(); + const zoom = screen.getByRole('button', { name: '放大画布' }); + const before = viewport(); + fireEvent.pointerDown(zoom, { + pointerId: 35, + button: 2, + clientX: 100, + clientY: 100, + }); + fireEvent.pointerMove(surface, { + pointerId: 35, + buttons: 2, + clientX: 200, + clientY: 200, + }); + expect(viewport()).toEqual(before); + expect(fireEvent.contextMenu(zoom, { button: 2 })).toBe(true); + await act(async () => { + for (let i = 0; i < 100; i++) { + fireEvent.wheel(manager, { deltaX: 3, deltaY: 6 }); + } + }); + expect(viewport()).toEqual([before[0]! - 300, before[1]! - 600, before[2]]); + }); +}); + describe('资源画布手动重排口径', () => { it('hook:rederiveNow 按 rederive 策略重算自动坐标并写回一次', async () => { const projectId = 'manual-rederive-project'; diff --git a/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts b/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts index bd46086ea..127bcc9db 100644 --- a/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts @@ -206,7 +206,7 @@ describe('真机 manifest 取证:占位卡片计数与栏目分布', () => { // `artKind`(其中含 `ui`)而被判成 art,于是角标显示「图片」,而预览调度按 art 走 // 图像分支、又因 mediaType 不是图像而兜底成 placeholder —— 卡片永不发起读取, // 表现为「标着图片却只有占位图标」。类型判定改为 mediaType/扩展名优先、kind 只做 - // 兜底后,它们正确落文档分支,卡面渲染 JSON 文本摘要。 + // 兜底后,它们进入文本读取通道;卡面再消费原生识别结果显示 UI 设计或 JSON。 expect(byPreviewKind).toEqual({ 'raster-image': 52, document: 8, diff --git a/apps/ai-game-creator-shell/tests/resourceDocumentPreviewModel.test.ts b/apps/ai-game-creator-shell/tests/resourceDocumentPreviewModel.test.ts index c89d79e99..00a6bd53e 100644 --- a/apps/ai-game-creator-shell/tests/resourceDocumentPreviewModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceDocumentPreviewModel.test.ts @@ -40,19 +40,21 @@ describe('resourceDocumentPreviewMarkdown', () => { ['game/main.ts', 'typescript'], ['game/main.js', 'javascript'], ['game/main.py', 'python'], + ['assets/data.json', 'json'], + ['assets/DESIGN.JSON', 'json'], ])('%s 包为 %s 代码块', (path, language) => { expect( resourceDocumentPreviewMarkdown(resource(path), ' source\n\n\n'), ).toBe(`\`\`\`${language}\n source\n\n\n\`\`\``); }); - it('JSON 规格按文档原文预览,不包成代码块', () => { + it('JSON 规格按原文代码块预览,不把字段值当 Markdown', () => { expect( resourceDocumentPreviewMarkdown( resource('assets/data.json'), - ' source\n\n\n', + '{ "text": "# 不应成为标题" }\n', ), - ).toBe(' source\n\n\n'); + ).toBe('```json\n{ "text": "# 不应成为标题" }\n```'); }); it('正文包含 Markdown 围栏时不会逃出代码块', () => { diff --git a/apps/ai-game-creator-shell/tests/resourceJsonCanvas.test.tsx b/apps/ai-game-creator-shell/tests/resourceJsonCanvas.test.tsx new file mode 100644 index 000000000..7e04d094e --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceJsonCanvas.test.tsx @@ -0,0 +1,205 @@ +/** @vitest-environment jsdom */ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + createGameCreationAppManifest, + fireEvent, + installResizeObserverStub, + ProjectDevelopmentView, + React, + render, + screen, + waitFor, + within, +} from './appSurface/harness'; + +vi.mock('@tauri-apps/api/core', async () => ({ + ...(await vi.importActual( + '@tauri-apps/api/core', + )), + invoke: (command: string, args?: Record) => + window.__TAURI__!.core.invoke(command, args), +})); + +const projectId = 'json-canvas'; +const projectPath = '/tmp/json-canvas'; +const emptyState = { + ui_trees: [], + ui_design_images: {}, + sprite_assets: {}, + font_assets: {}, +}; + +async function mountJsonCanvas(kind = 'ui', rejectPreview = false) { + installResizeObserverStub(); + const manifest = createGameCreationAppManifest(projectId, 'JSON 资源测试'); + manifest.assets = [ + { id: 'design', kind, localPath: 'ui/design.json' }, + { id: 'ordinary', kind: 'UI', localPath: 'assets/ordinary.json' }, + { id: 'spoof', kind: 'UI', localPath: 'ui/spoof.json' }, + ].map((asset) => ({ + ...asset, + mediaType: 'application/json', + category: 'document' as const, + source: { kind: 'generated' as const }, + })); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + const ids = (args?.resources as Array<{ resourceId: string }>).map( + (item) => item.resourceId, + ); + return { + resourceIds: ids, + referenceEdges: [], + taskFlows: [], + producerAssignments: [], + dependencyDepths: ids.map((resourceId) => ({ + resourceId, + dependencyDepth: 0, + })), + connectionIndex: ids.map((resourceId) => ({ + resourceId, + upstreamReferenceResourceIds: [], + downstreamReferenceResourceIds: [], + referenceEdgeIds: [], + taskFlowIds: [], + })), + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: [], + producerMappingTruncated: false, + }; + } + if ( + command === 'read_local_project_resource_canvas_layout' || + command === 'update_local_project_resource_canvas_layout' + ) { + const layout = { + schemaVersion: 'game-creator-resource-layout.v1', + projectId, + mode: args?.mode, + revision: Number(args?.expectedRevision ?? 0) + 1, + positions: args?.positions ?? [], + updatedAt: 1, + }; + return command.startsWith('update_') + ? { status: 'updated', layout } + : layout; + } + if (command === 'read_local_project_text_preview') { + if (rejectPreview) throw new Error('文档读取失败'); + const path = args?.relativePath; + return { + path, + mediaType: 'application/json', + byteLen: 20, + content: + path === 'assets/ordinary.json' + ? '{"text":"# 不应当作标题"}' + : JSON.stringify({ + schemaVersion: 'game-creator-ui-design-state.v1', + state: emptyState, + }), + ...(path === 'ui/design.json' ? { uiDesignAssetId: 'design' } : {}), + }; + } + if (command === 'load_ui_design_state') + return { revision: 0, state: emptyState }; + if ( + command === 'list_pending_local_project_resource_edits' || + command === 'list_local_project_asset_generations' + ) + return []; + throw new Error(`unexpected command ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; + const onManifestChange = vi.fn(); + render( + 对话
} + onManifestChange={onManifestChange} + />, + ); + fireEvent.click(await screen.findByRole('button', { name: '打开文档' })); + const card = (id: string) => + document.querySelector( + `.game-resource-book-scene-card.is-expanded [data-resource-card-id="asset:${id}"]`, + )!; + await waitFor(() => + expect(card('design')?.getAttribute('data-preview-status')).toBe( + rejectPreview ? 'failed' : 'loaded', + ), + ); + return { card, invoke, onManifestChange }; +} + +afterEach(() => { + delete window.__TAURI__; +}); + +describe('JSON 画布卡片与入口', () => { + it.each(['ui', 'document'])( + '合法 State 的 %s 登记显示 UI 设计,进入现有编辑器且不修改 manifest', + async (kind) => { + const { card, invoke, onManifestChange } = await mountJsonCanvas(kind); + const design = card('design'); + expect(design.getAttribute('data-json-presentation')).toBe('ui-design'); + expect(within(design).getByText('UI 设计')).not.toBeNull(); + expect(within(design).getByText('UI 编辑器')).not.toBeNull(); + expect(design.textContent).not.toContain('schemaVersion'); + fireEvent.click(design.querySelector('button')!); + fireEvent.click(await screen.findByRole('button', { name: 'UI 编辑器' })); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('load_ui_design_state', { + input: { + projectPath, + expectedProjectId: projectId, + assetId: 'design', + }, + }), + ); + expect(onManifestChange).not.toHaveBeenCalled(); + }, + ); + + it('普通 JSON 和伪 UI 文本保持 JSON 展示,无编辑器入口,详情按代码渲染', async () => { + const { card } = await mountJsonCanvas(); + for (const id of ['ordinary', 'spoof']) { + const element = card(id); + expect(element.getAttribute('data-json-presentation')).toBe('json'); + expect( + element.querySelector('[data-resource-type="JSON"]'), + ).not.toBeNull(); + expect(element.textContent).not.toContain('schemaVersion'); + fireEvent.click(element.querySelector('button')!); + expect(screen.queryByRole('button', { name: 'UI 编辑器' })).toBeNull(); + } + fireEvent.click(card('ordinary').querySelector('button')!); + fireEvent.click(await screen.findByRole('button', { name: '预览' })); + const dialog = await screen.findByRole('dialog', { name: '文档预览' }); + await waitFor(() => + expect(dialog.querySelector('pre code')?.textContent).toContain( + '{"text":"# 不应当作标题"}', + ), + ); + expect( + within(dialog).queryByRole('heading', { name: '不应当作标题' }), + ).toBeNull(); + }); + + it('JSON 读取失败不授予编辑入口,仍可查看明确的读取错误', async () => { + const { card } = await mountJsonCanvas('UI', true); + fireEvent.click(card('design').querySelector('button')!); + expect(screen.queryByRole('button', { name: 'UI 编辑器' })).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '预览' })); + expect(await screen.findByRole('alert')).not.toBeNull(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceJsonPresentation.test.ts b/apps/ai-game-creator-shell/tests/resourceJsonPresentation.test.ts new file mode 100644 index 000000000..ecc685ed7 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceJsonPresentation.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; + +import { + type ProjectResourceCardPreviewState, + projectResourceJsonPresentation, +} from '../src/view/project-development/resourceCardPreviewModel'; +import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel'; + +const resource: ProjectResource = { + id: 'asset:json', + manifestAssetId: 'json', + path: 'ui/design.json', + label: '设计', + mediaType: 'application/json', + subtype: 'UI', + category: 'document', + sourceLabel: '', + taskTitle: null, + producerTaskId: null, + externalResourceId: null, + referenceResourceIds: [], + dependencies: [], + dependencyDepth: 0, +}; +const verified: ProjectResourceCardPreviewState = { + status: 'loaded', + preview: { + path: resource.path, + mediaType: 'application/json', + byteLen: 2, + content: '{}', + uiDesignAssetId: 'json', + }, +}; + +describe('JSON 卡片呈现', () => { + it.each(['UI', 'ui', 'ui-design', 'document'])( + '合法识别不依赖 %s 标签', + (subtype) => { + expect( + projectResourceJsonPresentation({ ...resource, subtype }, verified), + ).toBe('ui-design'); + expect( + projectResourceJsonPresentation( + { ...resource, subtype }, + { + ...verified, + preview: { ...verified.preview, uiDesignAssetId: undefined }, + }, + ), + ).toBe('json'); + }, + ); + it('原生读取未完成、失败或身份不匹配时保持普通 JSON,不推断编辑能力', () => { + for (const preview of [ + null, + { status: 'loading' }, + { status: 'failed', error: '读取失败', retryable: true }, + ] as const) { + expect(projectResourceJsonPresentation(resource, preview)).toBe('json'); + } + expect( + projectResourceJsonPresentation( + { ...resource, manifestAssetId: 'other' }, + verified, + ), + ).toBe('json'); + expect( + projectResourceJsonPresentation( + { ...resource, path: 'other.json' }, + verified, + ), + ).toBe('json'); + expect( + projectResourceJsonPresentation( + { ...resource, manifestAssetId: null }, + verified, + ), + ).toBe('json'); + expect( + projectResourceJsonPresentation( + { ...resource, path: 'image.png', mediaType: 'image/png' }, + verified, + ), + ).toBeNull(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts index 6e821454f..01876526e 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts @@ -141,6 +141,60 @@ afterEach(() => { }); describe('useProjectResourceCardPreviews', () => { + it('JSON 识别结果经过现有预取缓存保留,换项目后不复用旧编辑能力', async () => { + const json = resource('design', { + path: 'ui/design.json', + subtype: 'ui', + mediaType: 'application/json', + }); + const invoke = vi.fn( + async (_command: string, args?: Record) => ({ + path: json.path, + mediaType: json.mediaType, + byteLen: 2, + content: '{}', + ...(args?.projectPath === '/tmp/first-project' + ? { uiDesignAssetId: 'design' } + : {}), + }), + ); + window.__TAURI__ = { core: { invoke } }; + const resources = [json]; + const canvasRef = { current: document.createElement('div') }; + const { result, rerender } = renderHook( + ({ projectPath }) => + useProjectResourceCardPreviews({ + projectPath, + projectId: projectPath, + mode: 'dependency', + resources, + canvasRef, + eagerPreviewLimit: 12, + }), + { initialProps: { projectPath: '/tmp/first-project' } }, + ); + const identity = () => result.current.identityByResourceId.get(json.id)!; + await waitFor(() => + expect(result.current.previews.get(identity())).toMatchObject({ + status: 'loaded', + preview: { uiDesignAssetId: 'design' }, + }), + ); + rerender({ projectPath: '/tmp/second-project' }); + await waitFor(() => + expect(result.current.previews.get(identity())?.status).toBe('loaded'), + ); + const state = result.current.previews.get(identity()); + expect( + state?.status === 'loaded' && state.preview.uiDesignAssetId, + ).toBeUndefined(); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'read_local_project_text_preview', + ), + ).toHaveLength(2); + }); + it('代码卡不预取正文,显式详情复用文本预览队列与缓存', async () => { const code = resource('code', { path: 'game/main.ts', diff --git a/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx b/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx new file mode 100644 index 000000000..457f61417 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx @@ -0,0 +1,111 @@ +/** @vitest-environment jsdom */ +import type { ReactNode } from 'react'; +import { useCallback } from 'react'; +import { vi } from 'vitest'; + +import { WindowChrome } from '../src/components/WindowChrome'; +import { + useWindowChrome, + type WindowChromeActiveProjectRuns, + WindowChromeContext, +} from '../src/components/windowChromeContext'; +import { WorkspaceLauncherShell } from '../src/features/app-shell/WorkspaceLauncher'; +import { + act, + expect, + it, + React, + render, + testAuthUser, +} from './appSurface/harness'; + +const homeProjectOverride = vi.hoisted(() => ({ + openProject: null as + | null + | ((path: string, mode: 'open' | 'create') => Promise), +})); + +vi.mock('../src/features/app-shell/useHomeProjectCreation', async () => { + const actual = await vi.importActual< + typeof import('../src/features/app-shell/useHomeProjectCreation') + >('../src/features/app-shell/useHomeProjectCreation'); + return { + ...actual, + useHomeProjectCreation( + ...args: Parameters + ) { + const result = actual.useHomeProjectCreation(...args); + return homeProjectOverride.openProject + ? { ...result, openProject: homeProjectOverride.openProject } + : result; + }, + }; +}); + +it('真实窗口与工作台状态同步收敛,回调读取最新处理器且卸载才清理', async () => { + const publications: WindowChromeActiveProjectRuns[] = []; + let cleanups = 0; + // 仍经过真实 WindowChrome 的 setState/Context;上限只防止回归时测试无限循环。 + function BoundedWindowBridge({ children }: { children: ReactNode }) { + const chrome = useWindowChrome(); + const { setActiveProjectRuns } = chrome; + const publish = useCallback( + (next: WindowChromeActiveProjectRuns | null) => { + if (next) publications.push(next); + else cleanups += 1; + if (publications.length < 12) setActiveProjectRuns(next); + }, + [setActiveProjectRuns], + ); + return ( + + {children} + + ); + } + const supervisor = () => null; + const view = (displayName = '测试用户') => ( + + + undefined} + initialView="projects" + ProjectSupervisor={supervisor} + /> + + + ); + delete window.__TAURI__; + const rendered = render(view()); + try { + await act(async () => { + await Promise.resolve(); + }); + // 无原生 invoke 时 active-turn Hook 会把初始快照归一为空数组一次。 + expect(publications).toHaveLength(2); + expect(cleanups).toBe(0); + expect(new Set(publications.map((item) => item.onOpenProject)).size).toBe( + 1, + ); + const openProject = publications[0]!.onOpenProject!; + const latestOpen = vi.fn(async () => undefined); + homeProjectOverride.openProject = latestOpen; + rendered.rerender(view('更改显示名')); + expect(publications).toHaveLength(2); + act(() => openProject('/tmp/window-latest-project')); + expect(latestOpen).toHaveBeenCalledWith( + '/tmp/window-latest-project', + 'open', + ); + expect(publications).toHaveLength(2); + expect(cleanups).toBe(0); + rendered.unmount(); + expect(cleanups).toBe(1); + } finally { + homeProjectOverride.openProject = null; + rendered.unmount(); + } +}); diff --git a/docs/project-memory/plans/【实施计划】AGC对话历史分页恢复-2026-09-16.md b/docs/project-memory/plans/【实施计划】AGC对话历史分页恢复-2026-09-16.md new file mode 100644 index 000000000..db90eebeb --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC对话历史分页恢复-2026-09-16.md @@ -0,0 +1,26 @@ +# AGC 对话历史分页恢复实施计划 + +- Date: 2026-09-16 +- Status: awaiting-runtime-acceptance +- Milestone: [AGC 对话历史分页恢复](./【里程碑】AGC对话历史分页恢复-2026-09-16.md) + +## 实施 + +1. 原生历史读取复用逐行解析,增加消息模式,过滤后分页并返回已有原始消息 ID 游标;保持原始接口默认行为和路径权限。 +2. 工作台首屏与更早消息读取显式请求消息模式,消费游标;加载代次隔离、单飞与 ID 去重。 +3. 合成记录测试复现原始工具页卡住的形状,覆盖旧无 ID、坏行、时间、失败/重复/切项目;临时目录只读重放用户日志。 +4. 前端/原生定向测试、类型、Lint、编码、文档和差异检查通过后,更新问题表及 PR 草稿并本地提交。 +5. 在临时消息投影中标记历史来源,保留 Runtime 所有权语义;刷新合并时只保留非历史来源的待回读消息。补齐「先加载旧页,再 /history,再翻页」的顺序与去重回归,保持尚未落盘用户输入的保留逻辑。 + +## 边界与停止条件 + +不调整 Direct 消息呈现归属、不修未证明的写入丢失、不上传日志、不触碰用户项目。必要 API 变化仅为本地 IPC 可选参数和游标字段;无 OpenAPI、SpacetimeDB 或持久化迁移。远程推送/PR/WIP 操作仍待额外确认。 + +## 验收证据 + +- 历史消息模型、回合呈现及分页集成共 24 个前端测试通过;包含真实 App 的首屏/更早页、单飞、失败重试、重叠消息、同项目重新加载及离开再进入的迟到响应。 +- 16 个历史原生测试通过;人工日志重放用例在 CI 默认忽略,已在本地单独执行通过。 +- 使用用户提供的原始日志运行修复后的原生读取:44 条现存聊天消息(含 2 条用户消息)分 3 页取回,逐项内容与顺序一致,原文件字节未变;未在仓库保存原始日志。 +- 工作台/Direct 恢复与画布导航的 8 个定向回归通过。真实客户端重新进入与向上翻页尚待用户验收;本次涉及 Rust IPC,需重新构建并启动原生端。 +- 与前面画布/JSON 修复联合复验:119 个前端定向测试、30 个原生测试通过;AGC TypeScript、修改文件 ESLint、编码、文档索引和差异检查通过。 +- 状态只覆盖日志中已经证明的分页卡页,不据此宣称其它可能的未落盘消息也已恢复。 diff --git a/docs/project-memory/plans/【实施计划】AGC画布交互稳定性修复-2026-09-16.md b/docs/project-memory/plans/【实施计划】AGC画布交互稳定性修复-2026-09-16.md new file mode 100644 index 000000000..9902abd0b --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC画布交互稳定性修复-2026-09-16.md @@ -0,0 +1,44 @@ +# AGC 画布交互稳定性修复实施计划 + +- Date: 2026-09-16 +- Status: awaiting-runtime-acceptance +- Milestone: [画布交互稳定性修复](./【里程碑】AGC画布交互稳定性修复-2026-09-16.md) + +## 修改顺序与边界 + +1. 将临时诊断收敛成正式回归测试,覆盖窗口 Context 反馈、提示条件和鼠标/触摸板事件。 +2. 稳定工作台打开项目的转发回调,保持最新处理器语义,不改项目加载逻辑。 +3. 解耦运行提示与选择;在原有画布事件链加入右键平移、菜单边界和中断清理,不另建控制器。 +4. 执行定向验证并检查首次加载、平移和原有框选/卡片拖动回归。 + +## 验证 + +- 定向 Vitest:窗口工作台、画布交互、布局与原有导航用例。 +- 运行提示的源码契约与交互回归同时覆盖:提示仅取决于运行能力和 UI 编辑器路由,不依赖资源选择;选中资源后提示及 `aria-describedby` 保留,点击运行仍不能进入不可用视图。 +- AGC `tsc --noEmit`;`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。 +- 真实客户端首次进入和触摸板操作无法以 jsdom 代替;未实测时保持待验收。 + +## 风险与停止条件 + +窗口反馈测试必须有更新次数上限,避免未修复代码让测试失控。右键仅接管画布背景/卡片,不抢输入控件与浮层。新增问题只有影响本次验收才扩大范围;不根据猜测修改 B01/B02 的布局与动画。 + +回滚仅限本批局部补丁;不重置工作树,不覆盖其它修改。完成自动化验证后停在真实客户端验收,不推进额外功能。 + +## 问题状态 + +| 编号 | 问题 | 状态与证据 | +| --- | --- | --- | +| B01 | 刷新后首次进入画布元素抖动 | 已优化;用户在本轮反馈未再复现,按用户要求更新状态。不宣称所有布局/动画原因均已排除。 | +| B02 | 初次进入双指平移无效,整理后恢复 | 已优化;用户在本轮反馈未再复现,按用户要求更新状态。隔离组件连续平移通过。 | +| B03 | 快速平移触发更新深度错误 | 已修复已确认的窗口 Context 反馈循环,回归验证收敛;真实操作继续观察。 | +| B04 | 资源选中后运行不可用提示消失 | 已修复,提示与选择解耦,自动化验证通过。 | +| B05 | 对话记录偶发丢失 | 已修复日志复现的历史分页卡点:消息模式过滤后分页、原生游标与读取代次隔离。原生只读重放分 3 页取回全部 44 条现存消息,原文件未变;真实客户端待验收,不扩大为其它未落盘记录已恢复。 | +| B06 | JSON 文档未正确识别展示 | 已按用户确认完成本地修复:合法 UI State 由原生完整校验,卡片显示 UI 设计并进入现有编辑器;普通 JSON 显示 JSON 并可代码预览。自动化验证通过,待重建原生客户端验收;详见 JSON 语义识别实施计划。 | +| C01 | 右键平移,保留左键框选 | 已实现,卡片左键拖动、框选、指针取消/失焦/捕获丢失及控件边界测试通过。 | + +## 已取得证据与剩余门禁 + +- 修复前新增回归测试能检出外壳重复发布、运行提示消失和右键无效;修复后窗口/画布定向测试通过,现有导航、框选、指针点击/取消、UI 编辑器返回平移和素材定位用例通过。 +- AGC TypeScript、修改文件 ESLint、编码、文档索引及差异空白检查通过。 +- 测试仍有既有 React 列表 key、旧用例 act/IPC 桩告警,未作为本批功能修复扩大范围。 +- 用户反馈 B01/B02 本轮未再复现,记为已优化;右键手感与其它真实客户端细节继续观察。对话历史分页与 JSON 双路径均已本地修复并通过定向验证,待重建原生端后真实客户端验收。本计划保持开放。 diff --git a/docs/project-memory/plans/【实施计划】AGC资源JSON语义识别-2026-09-16.md b/docs/project-memory/plans/【实施计划】AGC资源JSON语义识别-2026-09-16.md new file mode 100644 index 000000000..5c2947540 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC资源JSON语义识别-2026-09-16.md @@ -0,0 +1,27 @@ +# AGC 资源 JSON 语义识别实施计划 + +- Date: 2026-09-16 +- Status: awaiting-runtime-acceptance +- Milestone: [AGC 资源 JSON 语义识别](./【里程碑】AGC资源JSON语义识别-2026-09-16.md) + +## 实施边界 + +1. 原生 UI 持久化模块抽取可复用的内容解析与只读识别;文本预览返回可选的已验证 UI 资产身份。 +2. 保留新设计初始化的严格 UI 资产门禁;已有 State 的加载/保存/生成按登记资源和真实文档校验,不依赖标签精确大小写。 +3. 前端预览缓存透传识别结果;工作台卡片与编辑器入口消费同一结果,普通 JSON 详情使用代码块。 +4. 单元、组件与原生测试覆盖合法/普通/损坏/跨身份/未登记和保存边界;保持先前画布修复。 + +## 验证与停止条件 + +- 定向 Vitest、Tauri persistence/resource preview 定向 Rust 测试、AGC TypeScript、修改文件 ESLint、编码、文档索引及 `git diff --check`。 +- 不读取或修改用户项目原文件,不将日志或真实对话作为仓库测试夹具。 +- 原生构建/真实客户端受环境限制时记录实际证据,不能以 TS 测试代替原生验证。 +- 完成上述范围后停止;B05 已有分页卡点证据,本轮不顺带修改历史合同。 + +## 验证结果 + +- 92 个前端定向测试通过,覆盖原生结果驱动的卡片/编辑器路由、普通 JSON 代码预览、伪 UI 文本拒绝、读取失败、缓存切项目,以及原有画布导航与指针回归。 +- 14 个 UI 持久化原生测试通过,包含有效 State 对多种登记标签的识别/加载/保存、未知 schema/字段与坏结构拒绝、项目/资产身份、普通 JSON 防覆盖及原有恢复/CAS 边界。 +- 原生测试在 macOS 默认 `/var` 临时路径触发既有拒绝符号链接门禁;改用真实 `/private/tmp` 后通过,未放宽产品路径安全校验。 +- AGC TypeScript、修改文件 ESLint 已通过。测试仍有既有 React key/act 告警及 Rust 未使用代码警告,不影响本批断言。 +- 真实客户端的图片/UI State 体验仍待验收。本次包含 Rust 预览字段,必须重新构建并启动原生端,不能仅刷新前端就认为识别结果已更新。 diff --git a/docs/project-memory/plans/【里程碑】AGC对话历史分页恢复-2026-09-16.md b/docs/project-memory/plans/【里程碑】AGC对话历史分页恢复-2026-09-16.md new file mode 100644 index 000000000..4b8e244c4 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC对话历史分页恢复-2026-09-16.md @@ -0,0 +1,20 @@ +# AGC 对话历史分页恢复 + +- Version: 1 +- Status: reviewed +- Date: 2026-09-16 +- Parent Spec: [AGC 实施计划:DirectProject 回合展示唯一归属](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md) + +## 范围与评审 + +用户已要求修复 B05。已用用户提供的原始日志只读复验:原始切片被工具/推理填满时,聊天投影为空,消息游标不推进。修复聊天读取与分页,不改历史写入、不删除记录、不修改模型上下文,不扩大到其它尚无证据的对话丢失原因。 + +采用已有切片接口的显式消息模式与原生游标。原始模式缺省行为保持不变,旧无 ID 消息保留;前端以项目和加载代次隔离结果。本轮不包含远程写入。 + +## 验收 + +1. 工具/推理密集、末尾无消息、纯工具历史均不产生空页死循环。 +2. 消息正文、原始 ID、时间和顺序保持不变,翻页能到达早期用户提问及最终回答,不重复。 +3. 连点、请求失败重试、项目切换和同项目重新加载不会污染消息或游标。 +4. 原始切片默认模式回归通过,用户日志只读重放可以取回全部现存消息;不把真实日志或对话正文提交到仓库。 +5. 加载更早消息后重新读取历史,旧页不出现在最新回复之后;再次翻页保持顺序且不重复。未落盘的实时用户输入不因历史刷新被丢弃。 diff --git a/docs/project-memory/plans/【里程碑】AGC画布交互稳定性修复-2026-09-16.md b/docs/project-memory/plans/【里程碑】AGC画布交互稳定性修复-2026-09-16.md new file mode 100644 index 000000000..de82fa9ef --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC画布交互稳定性修复-2026-09-16.md @@ -0,0 +1,26 @@ +# AGC 画布交互稳定性修复 + +- Version: 1 +- Status: reviewed +- Date: 2026-09-16 +- Parent Spec: [AGC 实施计划:资源画布交互与工作台状态同步](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md) + +## 目标与范围 + +完成工作台标题栏状态更新循环、运行提示随选择消失、右键平移三项本地修复,保持左键框选与卡片拖动。不修改布局算法、持久化和后端;不开远程 Issue/PR、不推送。 + +## 评审 + +依据已完成的源码追踪、客户端错误日志及有界复现自检:状态归属仍在窗口与工作台原有边界内;交互修改只影响画布手势;没有数据迁移、权限或 API 变化。用户已确认右键平移及保留左键框选,并授权先尝试本地修复。真实客户端首次进入抖动与触摸板平移仍需另行验收。 + +## 验收 + +1. 工作台发布窗口状态后收敛;重复渲染不持续更新或清理,打开项目回调使用最新处理器,卸载清理有效。 +2. 选中资源后运行不可用提示仍在,运行能力不变。 +3. 子画布空白处及卡片右键平移不改变选择或坐标;左键框选与卡片拖动保留;总览支持右键平移。 +4. 指针取消、失去捕获、窗口失焦终止平移;控件与浮层保留原有交互。 +5. 连续平移测试、定向测试、类型检查、编码与文档门禁完成并记录限制;B01/B02 仅在真实复测后决定关闭。 + +## 依赖 + +当前源码与已安装测试依赖;真实 AGC 客户端验收环境。没有外部写操作依赖。 diff --git a/docs/project-memory/plans/【里程碑】AGC资源JSON语义识别-2026-09-16.md b/docs/project-memory/plans/【里程碑】AGC资源JSON语义识别-2026-09-16.md new file mode 100644 index 000000000..119c04f27 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC资源JSON语义识别-2026-09-16.md @@ -0,0 +1,26 @@ +# AGC 资源 JSON 语义识别 + +- Version: 1 +- Status: reviewed +- Date: 2026-09-16 +- Parent Spec: [AGC 实施计划:文档与代码素材预览](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md) + +## 目标与边界 + +按用户确认同时支持普通 JSON 与 UI 设计 JSON:前者显示 JSON 并可代码预览,后者显示 UI 设计并进入原有编辑器。原生侧复用现有文档校验,前端不推断正式状态。不修改 manifest 分类,不迁移用户文件,不改历史分页,不做远程写入。 + +## 评审 + +已核对原生受控文本预览、UI State 持久化合同和工作台卡片/编辑器路由。复用完整读取结果识别,未知 schema/坏结构/身份不符均不授予编辑入口;已有 UI 资产的大小写标签不应阻断真实合法 State。新增可选预览字段只承载原生识别结果;不引入新的状态文件或平行编辑器。 + +## 验收标准 + +1. 普通 JSON 显示 JSON,不渲染卡面原文,不出现 UI 编辑器入口,显式预览为 JSON 代码块。 +2. 完整合法且身份匹配的 UI State 显示 UI 设计并进入现有编辑器;大小写标签或文档标签不影响内容识别。 +3. 损坏 JSON、伪 schema、未知字段、坏 State、跨项目/资产、未登记文件不能获得 UI 编辑入口;原文查看或读取错误仍可见。 +4. 识别不产生写入;普通 JSON 不能通过编辑器保存或初始化被覆盖。有效已有设计仍支持原有 CAS 保存。 +5. 预览缓存能传递原生结果并随资源身份失效;原有卡片、框选/平移和图片预览回归通过。 + +## 依赖 + +本地前端与 Tauri 源码、现有测试依赖;真实客户端体验单独验收。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 74092e946..24a21eea8 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,17 @@ # 踩坑与排障记录 +## DirectProject 历史不能按工具条目切页再按消息推进游标 + +原始 `response_item` 历史同时含用户/助手消息、推理与工具输出。若原生每次取 20 个原始条目、前端过滤聊天消息后再找最旧 ID,纯工具页会让消息集合为空且游标不动,看起来历史丢失。聊天读取固定显式请求 `messagesOnly: true`,原生逐行过滤后按消息分页并返回 `oldestItemId`;默认原始模式留给原始条目消费者。无 ID 旧消息保留并扩展到可寻址边界,不能造 ID。前端保留项目与读取代次、单飞及 ID 去重,旧请求的成功、失败与 finally 都不能覆盖新读取;真实日志只在临时目录只读重放,不能提交正文夹具。 + +## JSON 卡片显示与 UI 编辑能力必须同源 + +JSON 的文本读取分支不等于卡面应该展示原始 State 摘要。卡片、缩略图及编辑器入口共同消费受控文本预览的 `uiDesignAssetId`;只有原生复用 UI 持久化合同校验 schema、完整 State 和项目/资产身份后才设置它。普通 JSON 保留 JSON 代码预览,不按 `kind: UI/ui` 或 schema 字符串片段猜测编辑能力。已有合法 UI State 的加载/保存不依赖 kind 精确大小写,但新建初始化仍保留正式 UI 资产门禁;缓存与项目切换须保留现有身份隔离。 + +## 窗口 Context 发布不得依赖每次渲染新建的业务回调 + +工作台向窗口标题栏发布运行项目时,若 effect 依赖普通函数派生的回调,发布 Context 会重新渲染工作台,进而再次发布并清理,形成更新深度循环。转发入口须稳定,并在提交阶段更新实际处理器引用;发布数据变化与卸载清理分开。回归测试必须组合真实窗口 Provider 和工作台消费者,只有独立画布测试无法覆盖这条反馈链;回归时用有界发布次数阻止测试失控。画布快速操作时暴露的更新深度错误,也须检查外层状态同步,不能直接归因于滚轮频率。 + ## 2026-09-16 从 Codex 里启动 AGC 客户端会看到被重定向的 `%APPDATA%` - **现象**:在 Codex 会话里用 `Start-Process` 启动 `genarrative-ai-game-creator-shell.exe` 做排障时,子进程写 `C:\Users\\AppData\Roaming\world.genarrative.ai-game-creator\...` 的内容会落到 `C:\Users\\AppData\Local\Packages\OpenAI.Codex_2p2nqsd0c76g0\LocalCache\Roaming\...`;同一个 `Test-Path` / `Get-ChildItem` 命中的是重定向视图,只有 `\\?\C:\Users\...` 形式能区分真实路径。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 43706740d..4c39954dc 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1,5 +1,14 @@ # AI 游戏创作智能体 App 实施计划 +## 资源画布交互与工作台状态同步 + +- 工作台向窗口标题栏发布正在运行的项目时,输入未变化不得形成重复发布与清理的渲染循环;打开项目动作始终使用当前工作台处理逻辑,退出工作台后清除其标题栏状态。 +- 资源子画布(含「所有资源」)保留空白处左键框选、资源卡左键选中/拖动、触摸板双指平移及捏合缩放;右键按住空白处或资源卡拖动时平移画布,不改变资源选择与布局。中键和空格抓手继续可用。总览保留既有左键平移,并支持右键平移。 +- 画布接管的右键手势不弹出原生菜单;输入框、媒体操作、工具条和独立浮层不被画布抢占。指针取消、捕获丢失或窗口失焦后终止平移,不能继续跟随指针。 +- 运行不可用提示只取决于运行能力与 UI 编辑器状态,不因资源选中、取消选中或框选而消失,且不改变运行入口的真实可用性。 +- 本次边界不包含布局算法、持久化坐标、预览读取预算或后端契约调整。首次进入抖动与平移异常须在更新循环消除后单独实测,不能仅凭状态循环修复宣称已解决。 +- 验收包含真实窗口 Context 与工作台的状态同步回归、运行提示与选中并存、左右键分流、平移中断及连续滚轮事件;真实客户端首次进入与触摸板手感为独立人工验收项。 + ## 资源卡选中工具栏与导出 - 共享选中工具栏按实际显示的快速编辑、编辑动作、改造、导出与宿主动作组生成分隔线;空组不产生分隔线,不依赖宿主 CSS 隐藏重复线。 @@ -8,6 +17,10 @@ ## 文档与代码素材预览 +- JSON 资源的卡片按内容语义分流:普通 JSON 显示 JSON 图标/标签,详情按 JSON 代码块显示;UI 设计 JSON 显示 UI 设计标识并提供现有 UI 编辑器入口,不再把 State 原文铺在卡面上。功能分类、manifest kind 和资产身份不因识别而改写。 +- UI 识别由原生文本预览在已登记、受控、完整读取的同一份 UTF-8 内容上完成,复用编辑器的 `game-creator-ui-design-state.v1` 契约解析、canonical 校验、revision/State 校验及项目/资产身份校验。前端只消费识别结果,不按文件名、kind 大小写或正文片段自行认定 UI;普通、损坏、未知 schema、跨项目或跨资产 JSON 不获得 UI 编辑能力。 +- JSON 为完成内容识别继续走现有受限预取队列、并发/容量和 2 MiB 原生文本读取上限。识别失败不写文件、不生成空 State、不做格式迁移;能读取的原文仍可按 JSON 查看,读取失败仍展示原有错误。非 JSON 代码卡继续仅按用户详情请求读取。 +- 编辑器加载/保存/代码生成复核当前项目的已登记 JSON 资产及完整文档,不以 kind 必须精确等于 `UI` 阻断合法已有设计;创建新 UI 状态仍要求正式 UI 资源,不能借普通 JSON 预览初始化或覆盖文件。现有 State 保存锁、CAS、revision 与恢复边界不变。 - 文档与代码素材选中工具栏提供「预览」,打开独立、可滚动的只读弹窗;关闭、切换素材或项目后不残留旧内容。加载中、空文件与读取失败分别呈现,允许重试可重试的错误。 - 复用资源预览队列、身份缓存、项目 scope、失效与权限校验,继续调用 `read_local_project_text_preview`。代码卡不做可见性预取,仅用户显式打开详情时读取;不新增 IPC,不扩大可读取文件范围,不增加编辑/保存能力。 - 文档正文统一使用现有 Markdown 渲染器;代码文件以按扩展名标注语言的 Markdown 围栏代码块呈现。围栏必须长于正文内的反引号串,正文空行与缩进保持原样,不把源码当 Markdown 正文或 HTML 执行。 @@ -19,6 +32,10 @@ ## 2026-09-16 DirectProject 回合展示唯一归属 +- 聊天历史使用 `read_direct_project_history_slice` 的 `messagesOnly: true` 模式,按有正文的 user/assistant 消息分页,默认 20 条;工具/推理原始记录不占聊天页名额、不进入聊天分页响应,也不从磁盘删除。接口省略该选项时维持原始 item 切片语义。响应给出明确的 `oldestItemId` 游标;消息投影不能重新发明分页位置。 +- 消息模式读取逐行过滤原始记录,不在内存中积累整份工具输出;正文、原始 ID 和信封时间原样保留。旧的无 ID 消息不能凭空生成身份,必要时向前扩展到已有消息 ID 边界;没有更早消息时结束分页。 +- 首屏和加载更早消息共用分页解析;重复点击只发一个请求,重叠消息按原始 ID 去重并保留当前显示版本。切项目、同项目重新加载及 A→B→A 的迟到响应不得覆盖当前消息、游标或加载状态;失败保留已有消息与分页位置并允许重试。 +- 历史读取来源与 Runtime 所有权分开记录;从磁盘分页读出的消息不能当作未落盘实时消息保留到新页末尾。重新读取历史时回到最新页,旧页仍可从原生游标再次向前加载;真正尚未回读到的实时消息继续保留,不改原始日志、时间或内容。 - 交付合同:实时消息、历史回读、工具详情与最终回复先归一为按 `clientTurnId` 唯一的回合,再渲染一次。用户消息始终保留;同一回合的正文、工具和耗时不能从消息、实时尾部、未归属尾部等多个出口重复展示。 - 归属来自 `direct-codex:{clientTurnId}:{role}`、文本流中保留的原始 item ID,以及项目历史内明确用户记录之后的 assistant 记录。先在已加载的完整消息集合中关联,再做可见分页;持久历史继续通过 canonical item 切片懒加载,`hasMore` 为真时,未加载回合的工具流不得漂到当前页尾部。禁止将第 N 个有工具回合配给第 N 条用户消息,禁止按文本长度、标点或时间窗猜测归属。缺身份的旧记录保留,不猜造其与其它回合的关联。 - 有回合流时正文与工具位置仅来自 item 边界与 `seq`,工具详情按该回合的 `callId` 关联;没有流时同一个回合容器显示历史消息与工具。整轮累计文本仅在活动回合尚无流和持久 assistant 时作兜底,不另建实时消息出口。