From 910862fd06f437cf377dbff204f048b44991792f Mon Sep 17 00:00:00 2001 From: suzmii Date: Wed, 16 Sep 2026 17:39:23 +0800 Subject: [PATCH 1/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAGC=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E5=B9=B6=E8=AF=86=E5=88=ABUI=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1JSON?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 稳定工作台与窗口标题栏状态同步,消除重复更新循环 支持画布右键平移并保留左键框选及资源拖动,完善中断清理 解耦运行不可用提示与资源选中状态 复用原生UI状态校验区分UI设计JSON和普通JSON,接入现有编辑器与代码预览 补充前端和原生回归测试、规范及待验收记录,明确对话历史分页尚未修复 --- .../src-tauri/src/commands.rs | 16 +- .../src-tauri/src/resource_inspect.rs | 4 + .../src-tauri/src/ui_editor/persistence.rs | 214 +++++++++++- .../features/app-shell/WorkspaceLauncher.tsx | 21 +- .../resourceCanvasFocusModel.ts | 17 + .../resourceDocumentPreviewModel.ts | 10 +- .../ResourcePreviewMedia.tsx | 19 +- .../src/view/project-development/index.tsx | 136 ++++++-- .../resourceCardPreviewModel.ts | 31 +- .../useProjectResourceCardPreviews.ts | 1 + .../appSurface/project-development.suite.ts | 9 + .../resourceCanvasFloatingDismiss.test.tsx | 33 ++ .../tests/resourceCanvasManualLayout.test.tsx | 317 ++++++++++++++++++ .../resourceCardPreviewRealManifest.test.ts | 2 +- .../resourceDocumentPreviewModel.test.ts | 8 +- .../tests/resourceJsonCanvas.test.tsx | 205 +++++++++++ .../tests/resourceJsonPresentation.test.ts | 87 +++++ .../useProjectResourceCardPreviews.test.ts | 54 +++ .../tests/workspaceWindowSync.test.tsx | 111 ++++++ ...施计划】AGC画布交互稳定性修复-2026-09-16.md | 43 +++ ...实施计划】AGC资源JSON语义识别-2026-09-16.md | 27 ++ ...里程碑】AGC画布交互稳定性修复-2026-09-16.md | 26 ++ ...里程碑】AGC资源JSON语义识别-2026-09-16.md | 26 ++ docs/project-memory/shared-memory/pitfalls.md | 8 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 13 + 25 files changed, 1386 insertions(+), 52 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/resourceJsonCanvas.test.tsx create mode 100644 apps/ai-game-creator-shell/tests/resourceJsonPresentation.test.ts create mode 100644 apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx create mode 100644 docs/project-memory/plans/【实施计划】AGC画布交互稳定性修复-2026-09-16.md create mode 100644 docs/project-memory/plans/【实施计划】AGC资源JSON语义识别-2026-09-16.md create mode 100644 docs/project-memory/plans/【里程碑】AGC画布交互稳定性修复-2026-09-16.md create mode 100644 docs/project-memory/plans/【里程碑】AGC资源JSON语义识别-2026-09-16.md 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 ca97077f1..eee72252c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5008,7 +5008,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] 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/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index 4727bf323..9d2ac7aba 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'; @@ -198,12 +205,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(() => { @@ -213,7 +226,6 @@ export function WorkspaceLauncherShell({ readFailed: snapshotReadFailed, onOpenProject: openActiveProject, }); - return () => setActiveProjectRuns(null); }, [ activeTurns, currentProjectContext?.projectPath, @@ -221,6 +233,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/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/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 2438d7657..f041845b6 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 @@ -6043,6 +6043,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}`); }, ); 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..d45ed0575 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC画布交互稳定性修复-2026-09-16.md @@ -0,0 +1,43 @@ +# AGC 画布交互稳定性修复实施计划 + +- Date: 2026-09-16 +- Status: awaiting-runtime-acceptance +- Milestone: [画布交互稳定性修复](./【里程碑】AGC画布交互稳定性修复-2026-09-16.md) + +## 修改顺序与边界 + +1. 将临时诊断收敛成正式回归测试,覆盖窗口 Context 反馈、提示条件和鼠标/触摸板事件。 +2. 稳定工作台打开项目的转发回调,保持最新处理器语义,不改项目加载逻辑。 +3. 解耦运行提示与选择;在原有画布事件链加入右键平移、菜单边界和中断清理,不另建控制器。 +4. 执行定向验证并检查首次加载、平移和原有框选/卡片拖动回归。 + +## 验证 + +- 定向 Vitest:窗口工作台、画布交互、布局与原有导航用例。 +- AGC `tsc --noEmit`;`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。 +- 真实客户端首次进入和触摸板操作无法以 jsdom 代替;未实测时保持待验收。 + +## 风险与停止条件 + +窗口反馈测试必须有更新次数上限,避免未修复代码让测试失控。右键仅接管画布背景/卡片,不抢输入控件与浮层。新增问题只有影响本次验收才扩大范围;不根据猜测修改 B01/B02 的布局与动画。 + +回滚仅限本批局部补丁;不重置工作树,不覆盖其它修改。完成自动化验证后停在真实客户端验收,不推进额外功能。 + +## 问题状态 + +| 编号 | 问题 | 状态与证据 | +| --- | --- | --- | +| B01 | 刷新后首次进入画布元素抖动 | 已优化;用户在本轮反馈未再复现,按用户要求更新状态。不宣称所有布局/动画原因均已排除。 | +| B02 | 初次进入双指平移无效,整理后恢复 | 已优化;用户在本轮反馈未再复现,按用户要求更新状态。隔离组件连续平移通过。 | +| B03 | 快速平移触发更新深度错误 | 已修复已确认的窗口 Context 反馈循环,回归验证收敛;真实操作继续观察。 | +| B04 | 资源选中后运行不可用提示消失 | 已修复,提示与选择解耦,自动化验证通过。 | +| B05 | 对话记录偶发丢失 | 已定位历史分页卡点,尚未修改。只读复验用户提供的历史:558 条合法原始记录中有 44 条聊天消息;首屏原始 20 条只投影出一条助手消息,下一页原始 20 条没有聊天消息,按消息计算的游标不推进。已存用户提问和最终回答因此无法继续翻出;不能凭该文件排除其它未落盘记录。 | +| 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..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 e8d132d9f..41361097c 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,13 @@ # 踩坑与排障记录 +## JSON 卡片显示与 UI 编辑能力必须同源 + +JSON 的文本读取分支不等于卡面应该展示原始 State 摘要。卡片、缩略图及编辑器入口共同消费受控文本预览的 `uiDesignAssetId`;只有原生复用 UI 持久化合同校验 schema、完整 State 和项目/资产身份后才设置它。普通 JSON 保留 JSON 代码预览,不按 `kind: UI/ui` 或 schema 字符串片段猜测编辑能力。已有合法 UI State 的加载/保存不依赖 kind 精确大小写,但新建初始化仍保留正式 UI 资产门禁;缓存与项目切换须保留现有身份隔离。 + +## 窗口 Context 发布不得依赖每次渲染新建的业务回调 + +工作台向窗口标题栏发布运行项目时,若 effect 依赖普通函数派生的回调,发布 Context 会重新渲染工作台,进而再次发布并清理,形成更新深度循环。转发入口须稳定,并在提交阶段更新实际处理器引用;发布数据变化与卸载清理分开。回归测试必须组合真实窗口 Provider 和工作台消费者,只有独立画布测试无法覆盖这条反馈链;回归时用有界发布次数阻止测试失控。画布快速操作时暴露的更新深度错误,也须检查外层状态同步,不能直接归因于滚轮频率。 + ## Windows 已登记生图资产未刷新 Direct 工具桥会 canonicalize 项目根,事件中的路径可能带 `\\?\` / `\\?\UNC\`,而前端项目路径仍是普通盘符或 UNC。失效监听不能直接比较原始字符串;识别为同一项目后,用当前项目路径重读 manifest,保留项目切换与 revision 门禁。普通 `agc_generate_image` 成功提交也必须发出失效通知,不能依赖整轮 Agent 结束。回归需覆盖两种 Windows 前缀、其它项目事件拒收,以及 Agent 尚未结束和后续失败时已登记图片卡片仍可见。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index e861ab2f6..84214f5d2 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 执行。 From 1b010fb1f89f7470a0fa99c8ac7dbe5e500077f7 Mon Sep 17 00:00:00 2001 From: suzmii Date: Wed, 16 Sep 2026 18:46:47 +0800 Subject: [PATCH 2/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8DDirectProject=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=E5=8E=86=E5=8F=B2=E5=88=86=E9=A1=B5=E5=8D=A1=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 聊天历史按有效消息分页并返回原生游标,保留默认原始切片接口 工具和推理记录不占聊天页名额,逐行过滤避免传回大段工具输出 隔离重新加载和项目切换的迟到响应,补齐单飞、重试与消息去重 补充原生和真实App回归测试,增加人工只读日志重放入口 更新B05状态、规范与PR验收记录,不提交用户原始日志 --- .../src/agent/direct_project_history.rs | 236 +++++++++++++++++- .../src/agent/direct_thread_manager.rs | 1 + .../src-tauri/src/commands.rs | 20 +- apps/ai-game-creator-shell/src/App.tsx | 72 ++++-- .../project-workspace/directThreadEvents.ts | 51 +++- .../tests/appSurface/home.suite.ts | 4 +- .../appSurface/project-development.suite.ts | 1 + .../tests/directHistoryPagination.test.tsx | 232 +++++++++++++++++ .../tests/directThreadEvents.test.ts | 49 ++++ ...实施计划】AGC对话历史分页恢复-2026-09-16.md | 25 ++ ...施计划】AGC画布交互稳定性修复-2026-09-16.md | 4 +- ...里程碑】AGC对话历史分页恢复-2026-09-16.md | 19 ++ docs/project-memory/shared-memory/pitfalls.md | 4 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 3 + 14 files changed, 685 insertions(+), 36 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/directHistoryPagination.test.tsx create mode 100644 docs/project-memory/plans/【实施计划】AGC对话历史分页恢复-2026-09-16.md create mode 100644 docs/project-memory/plans/【里程碑】AGC对话历史分页恢复-2026-09-16.md 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 12650f798..54ce0c7be 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5404,19 +5404,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/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 9523363ac..ac1ae9a8b 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'; @@ -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; }); })() @@ -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,27 +12490,38 @@ 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) @@ -12510,18 +12531,21 @@ export function App({ 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/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/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 f041845b6..21ec594a1 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 @@ -9133,6 +9133,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..9b7b410a0 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directHistoryPagination.test.tsx @@ -0,0 +1,232 @@ +/** @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('首屏按消息加载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/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..97f4d2433 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC对话历史分页恢复-2026-09-16.md @@ -0,0 +1,25 @@ +# 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 草稿并本地提交。 + +## 边界与停止条件 + +不调整 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 index d45ed0575..f91406977 100644 --- a/docs/project-memory/plans/【实施计划】AGC画布交互稳定性修复-2026-09-16.md +++ b/docs/project-memory/plans/【实施计划】AGC画布交互稳定性修复-2026-09-16.md @@ -31,7 +31,7 @@ | B02 | 初次进入双指平移无效,整理后恢复 | 已优化;用户在本轮反馈未再复现,按用户要求更新状态。隔离组件连续平移通过。 | | B03 | 快速平移触发更新深度错误 | 已修复已确认的窗口 Context 反馈循环,回归验证收敛;真实操作继续观察。 | | B04 | 资源选中后运行不可用提示消失 | 已修复,提示与选择解耦,自动化验证通过。 | -| B05 | 对话记录偶发丢失 | 已定位历史分页卡点,尚未修改。只读复验用户提供的历史:558 条合法原始记录中有 44 条聊天消息;首屏原始 20 条只投影出一条助手消息,下一页原始 20 条没有聊天消息,按消息计算的游标不推进。已存用户提问和最终回答因此无法继续翻出;不能凭该文件排除其它未落盘记录。 | +| B05 | 对话记录偶发丢失 | 已修复日志复现的历史分页卡点:消息模式过滤后分页、原生游标与读取代次隔离。原生只读重放分 3 页取回全部 44 条现存消息,原文件未变;真实客户端待验收,不扩大为其它未落盘记录已恢复。 | | B06 | JSON 文档未正确识别展示 | 已按用户确认完成本地修复:合法 UI State 由原生完整校验,卡片显示 UI 设计并进入现有编辑器;普通 JSON 显示 JSON 并可代码预览。自动化验证通过,待重建原生客户端验收;详见 JSON 语义识别实施计划。 | | C01 | 右键平移,保留左键框选 | 已实现,卡片左键拖动、框选、指针取消/失焦/捕获丢失及控件边界测试通过。 | @@ -40,4 +40,4 @@ - 修复前新增回归测试能检出外壳重复发布、运行提示消失和右键无效;修复后窗口/画布定向测试通过,现有导航、框选、指针点击/取消、UI 编辑器返回平移和素材定位用例通过。 - AGC TypeScript、修改文件 ESLint、编码、文档索引及差异空白检查通过。 - 测试仍有既有 React 列表 key、旧用例 act/IPC 桩告警,未作为本批功能修复扩大范围。 -- 用户反馈 B01/B02 本轮未再复现,记为已优化;右键手感与其它真实客户端细节继续观察。对话历史分页尚未修复;JSON 双路径已本地修复并通过自动化验证,待真实客户端验收。本计划保持开放。 +- 用户反馈 B01/B02 本轮未再复现,记为已优化;右键手感与其它真实客户端细节继续观察。对话历史分页与 JSON 双路径均已本地修复并通过定向验证,待重建原生端后真实客户端验收。本计划保持开放。 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..a247be381 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC对话历史分页恢复-2026-09-16.md @@ -0,0 +1,19 @@ +# 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. 原始切片默认模式回归通过,用户日志只读重放可以取回全部现存消息;不把真实日志或对话正文提交到仓库。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 8cb5e234e..59f167beb 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,9 @@ # 踩坑与排障记录 +## 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 资产门禁;缓存与项目切换须保留现有身份隔离。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index e024c1788..990ee7353 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -32,6 +32,9 @@ ## 2026-09-16 DirectProject 回合展示唯一归属 +- 聊天历史使用 `read_direct_project_history_slice` 的 `messagesOnly: true` 模式,按有正文的 user/assistant 消息分页,默认 20 条;工具/推理原始记录不占聊天页名额、不进入聊天分页响应,也不从磁盘删除。接口省略该选项时维持原始 item 切片语义。响应给出明确的 `oldestItemId` 游标;消息投影不能重新发明分页位置。 +- 消息模式读取逐行过滤原始记录,不在内存中积累整份工具输出;正文、原始 ID 和信封时间原样保留。旧的无 ID 消息不能凭空生成身份,必要时向前扩展到已有消息 ID 边界;没有更早消息时结束分页。 +- 首屏和加载更早消息共用分页解析;重复点击只发一个请求,重叠消息按原始 ID 去重并保留当前显示版本。切项目、同项目重新加载及 A→B→A 的迟到响应不得覆盖当前消息、游标或加载状态;失败保留已有消息与分页位置并允许重试。 - 交付合同:实时消息、历史回读、工具详情与最终回复先归一为按 `clientTurnId` 唯一的回合,再渲染一次。用户消息始终保留;同一回合的正文、工具和耗时不能从消息、实时尾部、未归属尾部等多个出口重复展示。 - 归属来自 `direct-codex:{clientTurnId}:{role}`、文本流中保留的原始 item ID,以及项目历史内明确用户记录之后的 assistant 记录。先在已加载的完整消息集合中关联,再做可见分页;持久历史继续通过 canonical item 切片懒加载,`hasMore` 为真时,未加载回合的工具流不得漂到当前页尾部。禁止将第 N 个有工具回合配给第 N 条用户消息,禁止按文本长度、标点或时间窗猜测归属。缺身份的旧记录保留,不猜造其与其它回合的关联。 - 有回合流时正文与工具位置仅来自 item 边界与 `seq`,工具详情按该回合的 `callId` 关联;没有流时同一个回合容器显示历史消息与工具。整轮累计文本仅在活动回合尚无流和持久 assistant 时作兜底,不另建实时消息出口。 From 7f9dce5f79966ba2b34ac16d4124b953ef4691f2 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 09:48:19 +0800 Subject: [PATCH 3/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E4=B8=8D=E5=8F=AF=E7=94=A8=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 恢复资源选中状态下运行提示的正确显示条件 使源码契约测试与实际行为保持一致 --- .../src/view/project-development/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index e660c2c73..42a5e56a9 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -6260,7 +6260,8 @@ export default function ProjectDevelopmentView({ setMode('run'); } - const showRunUnavailableHint = !runAvailable && !uiEditorRoute; + const showRunUnavailableHint = + !runAvailable && selectedResourceIds.length === 0 && !uiEditorRoute; const renderResourceBookCard = useCallback( ( From 9d374a57b17bbd5a26aa5bf26668c64eaf2dc9d3 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 11:51:14 +0800 Subject: [PATCH 4/9] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E4=B8=8D=E5=8F=AF=E7=94=A8=E6=8F=90=E7=A4=BA=E7=9A=84=E8=BF=87?= =?UTF-8?q?=E6=9C=9F=E6=B5=8B=E8=AF=95=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 恢复运行提示与资源选择解耦的业务逻辑 更新界面源码契约测试,保留资源选中交互回归 补充运行提示及无障碍关联的验收口径 --- .../src/view/project-development/index.tsx | 3 +-- .../tests/appSurface/project-development.suite.ts | 2 +- .../plans/【实施计划】AGC画布交互稳定性修复-2026-09-16.md | 1 + 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 42a5e56a9..e660c2c73 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -6260,8 +6260,7 @@ export default function ProjectDevelopmentView({ setMode('run'); } - const showRunUnavailableHint = - !runAvailable && selectedResourceIds.length === 0 && !uiEditorRoute; + const showRunUnavailableHint = !runAvailable && !uiEditorRoute; const renderResourceBookCard = useCallback( ( 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 df0c7d567..fd69cc8b7 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 @@ -5979,7 +5979,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, diff --git a/docs/project-memory/plans/【实施计划】AGC画布交互稳定性修复-2026-09-16.md b/docs/project-memory/plans/【实施计划】AGC画布交互稳定性修复-2026-09-16.md index f91406977..9902abd0b 100644 --- a/docs/project-memory/plans/【实施计划】AGC画布交互稳定性修复-2026-09-16.md +++ b/docs/project-memory/plans/【实施计划】AGC画布交互稳定性修复-2026-09-16.md @@ -14,6 +14,7 @@ ## 验证 - 定向 Vitest:窗口工作台、画布交互、布局与原有导航用例。 +- 运行提示的源码契约与交互回归同时覆盖:提示仅取决于运行能力和 UI 编辑器路由,不依赖资源选择;选中资源后提示及 `aria-describedby` 保留,点击运行仍不能进入不可用视图。 - AGC `tsc --noEmit`;`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。 - 真实客户端首次进入和触摸板操作无法以 jsdom 代替;未实测时保持待验收。 From 036395c6c979dad4091031c6942c95219d125b2c Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 13:54:00 +0800 Subject: [PATCH 5/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E9=87=8D=E8=AF=BB=E5=90=8E=E6=97=A7=E6=B6=88=E6=81=AF=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0=E5=88=B0=E6=9C=AB=E5=B0=BE=E7=9A=84=E4=B9=B1=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 区分历史回读与实时消息来源,保留运行时所有权语义 防止旧页消息在历史刷新后追加到最新回复末尾 增加重新读取历史后再次翻页的顺序和去重回归 同步对话历史规范与实施计划 --- apps/ai-game-creator-shell/src/App.tsx | 7 ++-- apps/ai-game-creator-shell/src/app/types.ts | 2 + .../tests/directHistoryPagination.test.tsx | 37 +++++++++++++++++++ ...实施计划】AGC对话历史分页恢复-2026-09-16.md | 1 + ...里程碑】AGC对话历史分页恢复-2026-09-16.md | 1 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 1 + 6 files changed, 46 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index ac1ae9a8b..be33bfe93 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -626,7 +626,7 @@ function claimInitialSupervisorMessageForPage(projectPath: string, scope = '') { * 初始需求是**乐观插入**到 messages 的(latch 命中后先插一条 user 消息,再发起回合), * 而历史回读在 replace 分支里是无条件整体替换 —— 只要回读晚于乐观插入,那条用户消息 * 就会被冲掉(界面上看不到初始需求,但回合其实已经跑起来了)。 - * 这里把当前 messages 里"运行时拥有、且回读结果里没有"的消息保留在末尾(它们是最新的)。 + * 这里只把当前 messages 里"非历史来源、运行时拥有、且回读结果里没有"的消息保留在末尾。 */ function mergeLoadedConversationWithPendingRuntimeMessages( loaded: ChatMessage[], @@ -644,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) { @@ -4202,7 +4202,7 @@ export function App({ const conversationMessages = mergeProjectSupervisorConversation( resolvedProjectConversation.messages, supervisorConversation?.messages ?? [], - ); + ).map((message) => ({ ...message, fromHistory: true })); if ( conversationContainsProjectSupervisorResponseStream( supervisorConversation?.messages ?? [], @@ -12528,6 +12528,7 @@ export function App({ : ('assistant' as const), text: message.content, runtimeOwned: true, + fromHistory: true, messageId: message.messageId, updatedAt: message.updatedAt, })); 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/tests/directHistoryPagination.test.tsx b/apps/ai-game-creator-shell/tests/directHistoryPagination.test.tsx index 9b7b410a0..8c0f00a19 100644 --- a/apps/ai-game-creator-shell/tests/directHistoryPagination.test.tsx +++ b/apps/ai-game-creator-shell/tests/directHistoryPagination.test.tsx @@ -97,6 +97,43 @@ afterEach(() => { }); 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 diff --git a/docs/project-memory/plans/【实施计划】AGC对话历史分页恢复-2026-09-16.md b/docs/project-memory/plans/【实施计划】AGC对话历史分页恢复-2026-09-16.md index 97f4d2433..db90eebeb 100644 --- a/docs/project-memory/plans/【实施计划】AGC对话历史分页恢复-2026-09-16.md +++ b/docs/project-memory/plans/【实施计划】AGC对话历史分页恢复-2026-09-16.md @@ -10,6 +10,7 @@ 2. 工作台首屏与更早消息读取显式请求消息模式,消费游标;加载代次隔离、单飞与 ID 去重。 3. 合成记录测试复现原始工具页卡住的形状,覆盖旧无 ID、坏行、时间、失败/重复/切项目;临时目录只读重放用户日志。 4. 前端/原生定向测试、类型、Lint、编码、文档和差异检查通过后,更新问题表及 PR 草稿并本地提交。 +5. 在临时消息投影中标记历史来源,保留 Runtime 所有权语义;刷新合并时只保留非历史来源的待回读消息。补齐「先加载旧页,再 /history,再翻页」的顺序与去重回归,保持尚未落盘用户输入的保留逻辑。 ## 边界与停止条件 diff --git a/docs/project-memory/plans/【里程碑】AGC对话历史分页恢复-2026-09-16.md b/docs/project-memory/plans/【里程碑】AGC对话历史分页恢复-2026-09-16.md index a247be381..4b8e244c4 100644 --- a/docs/project-memory/plans/【里程碑】AGC对话历史分页恢复-2026-09-16.md +++ b/docs/project-memory/plans/【里程碑】AGC对话历史分页恢复-2026-09-16.md @@ -17,3 +17,4 @@ 2. 消息正文、原始 ID、时间和顺序保持不变,翻页能到达早期用户提问及最终回答,不重复。 3. 连点、请求失败重试、项目切换和同项目重新加载不会污染消息或游标。 4. 原始切片默认模式回归通过,用户日志只读重放可以取回全部现存消息;不把真实日志或对话正文提交到仓库。 +5. 加载更早消息后重新读取历史,旧页不出现在最新回复之后;再次翻页保持顺序且不重复。未落盘的实时用户输入不因历史刷新被丢弃。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 792e58a6c..4c39954dc 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -35,6 +35,7 @@ - 聊天历史使用 `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 时作兜底,不另建实时消息出口。 From cd9faae9b27bdadcc7a7575d7a3a7c5ca8691d37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AE=B5=E8=88=92=E5=BA=B7?= Date: Thu, 17 Sep 2026 14:56:56 +0800 Subject: [PATCH 6/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20agc=5Ftools=20?= =?UTF-8?q?=E5=AA=92=E4=BD=93=E8=B5=84=E6=BA=90=E6=8F=90=E7=A4=BA=E8=AF=8D?= =?UTF-8?q?=E4=B8=8A=E9=99=90=E5=B9=B6=E6=8C=89=20kind=20=E6=9A=B4?= =?UTF-8?q?=E9=9C=B2=20(#398)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 改动 - 提示词上限收敛为 `resource_edit_prompt_max_chars` 单一权威:`agc_tools` MCP 工具层、客户端受控工具桥与提交校验共用同一数字与拒绝文案,不再出现「schema 写 4000、真实上限按 kind 分」的分叉。 - `agc_create_or_derive_resource` 的 schema 逐 kind 声明 `prompt.maxLength`(background-music / sound-effect / video 与 character-animation),`prompt` 描述写明每个数字;`agc_edit_image` 继续用图片口径 32000。 - 工具桥不再用通用 4000 校验 prompt:图片编辑 4000~32000 的提示词不再被误报成「超出安全边界」。 - 源资源未登记时的错误文案补上可执行两步:先用 `agc_list_registered_assets` 选已有 localAssetId;只在项目里的文件先 `agc_list_project_files` 确认 `assetImportable=true`,再用 `agc_import_account_assets.localPaths` 登记后重试。`agc_remove_background` 复用同一条文案。 - skill 包 `agc-client-projection` 的 SKILL.md 与 projection-contract.md 写明四个上限,并说明超限要在本地收敛而不是原样重发;清单指纹同步到 `2026-08-26.18`(已合并 master 的抠图文案)。 - 前端 `resourceEditModel.ts` 补注释指向 Rust 权威函数,数字未动。 - decision-log、pitfalls 与《AI游戏创作智能体App实施计划》记录本轮口径;未改 External v1 契约 / OpenAPI、SpacetimeDB schema 与客户端 UI 行为。 ## 问题 | 编号 | 问题 | 本 PR 状态 | | --- | --- | --- | | P1 | 背景音乐提示词真实上限 140,但 schema 只写 4000、skill 未写明,agent 写一句正常长度描述必被硬拒 | 已修复:口径单源 + 逐 kind 暴露 + skill 写明 | | P2 | 音效 1900 与图片编辑 32000 的上限只在 Rust 内部生效,MCP 与工具桥各自硬编码或误报 | 已修复:三处共用同一函数,图片编辑不再误报安全边界 | | P3 | 源资源未登记时只报「不属于当前项目已登记资源」,模型会原地重试 | 已修复:文案给出 `agc_list_registered_assets` 与登记工具两步动作 | | P4 | 这两个工具此前没有端到端用例,同类漂移无法被门禁拦住 | 已补:MCP 工具层 → 真实工具桥 → 假平台的契约用例 + 两条按 kind 上限门禁 | ## 验证 - `agent::direct_tools_mcp::tests` 23 passed / 0 failed:含新增的按 kind 上限门禁、图片快速编辑契约(断言 `POST /api/editor/images/edits` 的路径、Bearer、Idempotency-Key、正文且不得回填 assetKind)、背景音乐契约(断言 `POST /api/editor/audios/background-music/generations` 的 `gptDescriptionPrompt` 与 `makeInstrumental`)、超限零桥请求、未登记源资源提示。 - `agent::direct_tool_bridge::tests` 18 passed / 7 failed;`agent::skill_pack` 4 passed;`npm run agc:skill-pack:check` 与 `skill-pack:test` 通过(version `2026-08-26.18`)。 - AGC `typecheck`(tsc + skill-pack check + check-config)、`resourceEditModel` 6 项前端测试、`npm run check:encoding`、`npm run check:doc-index`、`cargo fmt --check`、`git diff --check` 通过。 - 那 7 条(以及 `project::resource_editor` 45 条)失败与本 PR 无关:全部是 `tempfile::tempdir()` 的 `Windows 安全对象不属于当前用户`,已 stash 改动并用基线复跑确认失败集合一致。 - 未跑整包 AGC Rust 全量分片、`npm run lint` 聚合门禁与 CI;未做任何付费生成。工具 schema 与 skill 文案都在应用内,终端用户需重新构建客户端才能看到修复效果。 --------- Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com> Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/398 --- .../agc-skills/agc-client-projection/SKILL.md | 2 +- .../references/projection-contract.md | 2 + .../resources/agc-skills/manifest.json | 4 +- .../src-tauri/src/agent/direct_tool_bridge.rs | 99 ++- .../src-tauri/src/agent/direct_tools_mcp.rs | 785 +++++++++++++++++- .../src-tauri/src/project/resource_editor.rs | 40 +- .../project-development/resourceEditModel.ts | 6 + .../shared-memory/decision-log.md | 11 + docs/project-memory/shared-memory/pitfalls.md | 8 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 1 + 10 files changed, 904 insertions(+), 54 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md index 4d4862554..84fb51e53 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md @@ -13,7 +13,7 @@ Let the client derive projections from real disk changes and trusted tool result 2. Before using or deriving an existing registered asset, call `agc_list_registered_assets` and select its `localAssetId`. If the user points to an existing project file that is not listed, first call `agc_list_project_files`; only entries with `assetImportable=true` (recognized image, font, audio, video, document, or code files) may be passed to `agc_import_account_assets.localPaths`. Then re-read `agc_list_registered_assets`; never infer a source identity from a filename or fabricate a localAssetId. 3. Keep read scopes separate: `asset.list` is the current project manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is the authoritative canvas list. The account library is not the complete canvas list. 4. Use `canvas.asset_import` for safe account/canvas asset IDs or project-relative local paths. The client rechecks ownership and validates bytes; host absolute paths require native UI file-picker authorization. -5. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image. +5. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image. Keep `prompt` inside the per-kind limit that the client really enforces: background music at most 140 characters, sound effect at most 1900, video and character animation at most 4000. A longer prompt is rejected before submission, so write the short version first instead of retrying the same text. 6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and `assetName`. Optional `backgroundMode` is `complex` (semantic foreground segmentation; default) or `flat` (solid-colour background removal). Prefer `flat` when the background is known to be solid. Only `flat` accepts optional `screenColor`: `auto`, `#RRGGBB`, or omitted for automatic detection by the service. Do not select a colour on behalf of `auto`. The client requires the signed-in account, owns canvas/folder context and task identity, and returns only bounded queue state. 7. Preserve existing relative paths when a small edit is sufficient so client resource identities remain stable. 8. Do not edit `.agent/manifest.json`, revision counters, version records, resource IDs, canvas identities, source provenance, generation ledgers, or browser receipts by hand. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md index 87cba6b1a..63ddd9a82 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md @@ -14,4 +14,6 @@ Read scopes remain separate: `asset.list` is the current project's local manifes `agc_create_or_derive_resource` accepts only semantic intent. The client resolves `sourceLocalAssetId`, creates stable request identities, recovers matching pending operations, serializes paid submissions, writes supported media into the current canvas and same-name asset folder, validates downloaded bytes, commits the local manifest transaction, and returns redacted warnings. A tool error or timeout is not permission to generate again with a new identity. +`prompt` limits are per kind and are enforced before any paid submission: background music accepts 1-140 characters, sound effect 1-1900, video and character animation 1-4000, and image editing (`agc_edit_image`) 1-32000. The client composes the submitted request from a fixed prefix plus your prompt, so an over-limit prompt fails locally with the exact limit; shorten the text rather than resubmitting the same value. `agc_edit_image` remains the image path; this tool never generates or edits still images. + `agc_remove_background` accepts a registered image `sourceLocalAssetId`, `assetName`, and optional `backgroundMode` and `screenColor`. `complex` uses semantic segmentation to identify the foreground; `flat` removes a solid-colour background. Prefer `flat` when the background is known to be solid; omitting the mode selects `complex`. Only `flat` accepts a colour: `auto`, `#RRGGBB`, or omitted for automatic service detection. Never infer a concrete colour for `auto`. Empty or invalid values and colour without `flat` are rejected. The client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated External v1 `/api/external/v1/editor/images/background-removals` call. Mode and colour are part of request identity. Its result is bounded queue state; Codex must not poll internal workers, construct source URLs, or retry with a new identity after an uncertain response. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index c57ea3638..1f7b5c124 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": "agc-skill-pack.v1", - "version": "2026-08-26.17", + "version": "2026-08-26.18", "skills": [ { "name": "agc-game-production-workflow", @@ -123,7 +123,7 @@ "agents/openai.yaml", "references/projection-contract.md" ], - "sha256": "a929c27bc5b2b0bee0b7935e5c7b04ddbab1eb1804fe196f8c2537ad040ca5b1" + "sha256": "0700d4a7a18ee6151811f38786211ad416863f2e425fdc2ded67555a0a1923a1" } ] } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 705da3ecb..a517f1831 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -24,7 +24,6 @@ const DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_DIMENSION: u32 = 1024; const DIRECT_TOOL_BRIDGE_MAX_SEARCH_QUERY_CHARS: usize = 400; const DIRECT_TOOL_BRIDGE_MAX_SEARCH_RESULTS: usize = 5; const DIRECT_TOOL_BRIDGE_SEARCH_URL: &str = "https://www.bing.com/search?format=rss"; -const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_PROMPT_CHARS: usize = 4_000; const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_NAME_CHARS: usize = 120; const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_KIND_CHARS: usize = 80; const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_PAGE_SIZE: usize = 100; @@ -89,7 +88,7 @@ struct DirectToolBridgeRequest { } #[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum DirectResourceGenerationKind { +pub(crate) enum DirectResourceGenerationKind { Image, Video, CharacterAnimation, @@ -98,7 +97,7 @@ enum DirectResourceGenerationKind { } impl DirectResourceGenerationKind { - fn parse(value: &str) -> Result { + pub(crate) fn parse(value: &str) -> Result { match value { "image" => Ok(Self::Image), "video" => Ok(Self::Video), @@ -119,7 +118,7 @@ impl DirectResourceGenerationKind { } } - fn edit_kind(self) -> LocalProjectResourceEditKind { + pub(crate) fn edit_kind(self) -> LocalProjectResourceEditKind { match self { Self::Image => LocalProjectResourceEditKind::ImageReference, Self::Video => LocalProjectResourceEditKind::Video, @@ -128,6 +127,11 @@ impl DirectResourceGenerationKind { Self::BackgroundMusic => LocalProjectResourceEditKind::BackgroundMusic, } } + + /// 提示词上限只从客户端权威口径取值,工具桥与 MCP 层共用同一份数字。 + pub(crate) fn prompt_max_chars(self) -> usize { + resource_edit_prompt_max_chars(&self.edit_kind()) + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -1036,6 +1040,12 @@ fn bridge_account_asset_import_inputs( Ok((asset_ids, local_paths)) } +/// 源资源身份不在当前项目 manifest 时的统一提示。 +/// +/// 只报「不属于已登记资源」会让模型原地重试;这里必须把下一步可执行动作写清楚: +/// 已登记资源走 `agc_list_registered_assets`,只在项目里存在的文件先登记再重试。 +const DIRECT_TOOL_BRIDGE_UNREGISTERED_SOURCE_MESSAGE: &str = "sourceLocalAssetId 不是当前项目已登记资源:先调用 agc_list_registered_assets 选择已有 localAssetId;若目标图片只在项目里,先用 agc_list_project_files 确认它 assetImportable=true,再用 agc_import_account_assets.localPaths 登记后重试。"; + fn bridge_resource_generation_input( arguments: &Value, ) -> Result { @@ -1054,18 +1064,20 @@ fn bridge_resource_generation_input( "sourceLocalAssetId", DIRECT_TOOL_BRIDGE_MAX_RESOURCE_KIND_CHARS, )?; - let prompt = bridge_bounded_string( - arguments, - "prompt", - DIRECT_TOOL_BRIDGE_MAX_RESOURCE_PROMPT_CHARS, - )?; + // prompt 的形状校验只用信封级上限,真正生效的按 kind 上限由紧随其后的权威判定给出 + // 精确数字;否则通用 4000 会先于「图片编辑 32000 / 音效 1900」误报成安全边界错误。 + let prompt = bridge_bounded_string(arguments, "prompt", DIRECT_TOOL_BRIDGE_MAX_REQUEST_BYTES)?; let asset_name = bridge_bounded_string( arguments, "assetName", DIRECT_TOOL_BRIDGE_MAX_RESOURCE_NAME_CHARS, )?; - if kind == DirectResourceGenerationKind::BackgroundMusic && prompt.chars().count() > 140 { - return Err("背景音乐提示词必须在 1..=140 字符内".to_string()); + let prompt_max_chars = kind.prompt_max_chars(); + if prompt.chars().count() > prompt_max_chars { + return Err(resource_edit_prompt_limit_error( + &kind.edit_kind(), + prompt_max_chars, + )); } match (kind, mode, source_local_asset_id.as_ref()) { (DirectResourceGenerationKind::Image, DirectResourceGenerationMode::Create, _) => { @@ -1806,7 +1818,7 @@ async fn bridge_create_or_derive_resource( .iter() .find(|asset| asset.id == asset_id) .cloned() - .ok_or_else(|| "sourceLocalAssetId 不属于当前项目已登记资源".to_string()) + .ok_or_else(|| DIRECT_TOOL_BRIDGE_UNREGISTERED_SOURCE_MESSAGE.to_string()) }) .transpose()?; let prompt_sha256 = format!("{:x}", Sha256::digest(input.prompt.as_bytes())); @@ -1903,7 +1915,7 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val .assets .iter() .find(|asset| asset.id == source_asset_id) - .ok_or_else(|| "sourceLocalAssetId 不属于当前项目已登记资源".to_string())?; + .ok_or_else(|| DIRECT_TOOL_BRIDGE_UNREGISTERED_SOURCE_MESSAGE.to_string())?; if !source_asset.media_type.starts_with("image/") { return Err("抠图工具只接受当前项目已登记的图片资源".to_string()); } @@ -2795,6 +2807,67 @@ mod tests { .contains("x-genarrative-client:")); } + /// 按 kind 的提示词上限只来自客户端权威口径;超限必须在构造工具输入时就被拒绝, + /// 不能再出现写死的数字(2026-09-17 的背景音乐 140 就是写死在桥这一层的)。 + #[test] + fn bridge_resource_prompt_limits_follow_the_client_authority() { + for (kind, edit_kind) in [ + ( + "background-music", + LocalProjectResourceEditKind::BackgroundMusic, + ), + ("sound-effect", LocalProjectResourceEditKind::SoundEffect), + ("video", LocalProjectResourceEditKind::Video), + ( + "character-animation", + LocalProjectResourceEditKind::CharacterAnimation, + ), + ("image", LocalProjectResourceEditKind::ImageReference), + ] { + let authority = resource_edit_prompt_max_chars(&edit_kind); + let mode = if matches!( + edit_kind, + LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::CharacterAnimation + ) { + "derive" + } else { + "create" + }; + let mut arguments = json!({ + "kind": kind, + "mode": mode, + "prompt": "字".repeat(authority), + "assetName": "边界名称" + }); + if mode == "derive" { + arguments["sourceLocalAssetId"] = json!("registered-source"); + } + bridge_resource_generation_input(&arguments) + .unwrap_or_else(|error| panic!("{kind} 恰好等于上限必须通过:{error}")); + + arguments["prompt"] = json!("字".repeat(authority + 1)); + let error = match bridge_resource_generation_input(&arguments) { + Ok(_) => panic!("{kind} 超过按 kind 上限的提示词必须被拒绝"), + Err(error) => error, + }; + assert!( + error.contains(&authority.to_string()) && error.contains(kind_label(&edit_kind)), + "{kind} 的拒绝文案必须带上真实上限与类型:{error}" + ); + } + } + + fn kind_label(edit_kind: &LocalProjectResourceEditKind) -> &'static str { + match edit_kind { + LocalProjectResourceEditKind::BackgroundMusic => "背景音乐", + LocalProjectResourceEditKind::SoundEffect => "音效", + LocalProjectResourceEditKind::Video => "视频", + LocalProjectResourceEditKind::CharacterAnimation => "角色动画", + _ => "资源编辑", + } + } + #[test] fn bridge_argument_bounds_are_deterministic() { assert_eq!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 8b0cd12a0..9c769fc5e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -16,7 +16,6 @@ const DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES: usize = 2 * 1024 * 1024; const DIRECT_TOOLS_MCP_MAX_ART_BRIEF_CHARS: usize = 4_000; const DIRECT_TOOLS_MCP_MAX_IMAGE_PROMPT_CHARS: usize = 32_000; const DIRECT_TOOLS_MCP_MAX_SEARCH_QUERY_CHARS: usize = 400; -const DIRECT_TOOLS_MCP_MAX_RESOURCE_PROMPT_CHARS: usize = 4_000; const DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS: usize = 120; const DIRECT_TOOLS_MCP_MAX_WRITE_CONTENT_BYTES: usize = 1_500_000; const DIRECT_TOOLS_MCP_MAX_BRIDGE_RESPONSE_BYTES: usize = 32 * 1024 * 1024; @@ -103,6 +102,32 @@ async fn direct_tools_mcp_specs() -> Value { direct_tools_mcp_specs_for(controlled_web_search_enabled(), cocos_editor_available) } +/// 工具 kind(wire 值)对应的提示词上限。 +/// +/// 数字只来自客户端资源编辑权威口径(`resource_edit_prompt_max_chars`);未知 kind 直接 +/// panic,避免 schema 与真实校验静默漂移。 +fn resource_tool_prompt_max_chars(kind: &str) -> usize { + DirectResourceGenerationKind::parse(kind) + .unwrap_or_else(|error| panic!("{kind} 不是受支持的媒体资源类型:{error}")) + .prompt_max_chars() +} + +/// `agc_create_or_derive_resource` 顶层 `prompt.maxLength`:本工具所有受支持 kind 的上限最大值。 +/// +/// 仍然保留顶层上限,供忽略 `allOf` / `oneOf` 的调用方使用;逐 kind 的精确上限在分支里声明。 +fn resource_tool_prompt_schema_max_chars() -> usize { + [ + "background-music", + "sound-effect", + "video", + "character-animation", + ] + .into_iter() + .map(resource_tool_prompt_max_chars) + .max() + .unwrap_or(0) +} + fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_available: bool) -> Value { let tools = vec![ json!({ @@ -407,7 +432,8 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab "prompt": { "type": "string", "minLength": 1, - "maxLength": DIRECT_TOOLS_MCP_MAX_RESOURCE_PROMPT_CHARS + "maxLength": resource_tool_prompt_schema_max_chars(), + "description": "资源描述或改造要求。按 kind 有硬上限,超限会被客户端直接拒绝:background-music 最多 140 字符、sound-effect 最多 1900 字符、video / character-animation 最多 4000 字符。图片编辑走 agc_edit_image。" }, "assetName": { "type": "string", @@ -417,17 +443,47 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab } }, "required": ["kind", "mode", "prompt", "assetName"], - "oneOf": [ + "allOf": [ { - "properties": { - "mode": { "const": "create" }, - "kind": { "enum": ["video", "sound-effect", "background-music"] } - }, - "not": { "required": ["sourceLocalAssetId"] } + "oneOf": [ + { + "properties": { + "mode": { "const": "create" }, + "kind": { "enum": ["video", "sound-effect", "background-music"] } + }, + "not": { "required": ["sourceLocalAssetId"] } + }, + { + "properties": { "mode": { "const": "derive" } }, + "required": ["sourceLocalAssetId"] + } + ] }, { - "properties": { "mode": { "const": "derive" } }, - "required": ["sourceLocalAssetId"] + // 逐 kind 声明真实提示词上限,与 resource_edit_prompt_max_chars 同口径。 + "oneOf": [ + { + "properties": { + "kind": { "const": "background-music" }, + "prompt": { "maxLength": resource_tool_prompt_max_chars("background-music") } + }, + "required": ["kind"] + }, + { + "properties": { + "kind": { "const": "sound-effect" }, + "prompt": { "maxLength": resource_tool_prompt_max_chars("sound-effect") } + }, + "required": ["kind"] + }, + { + "properties": { + "kind": { "enum": ["video", "character-animation"] }, + "prompt": { "maxLength": resource_tool_prompt_max_chars("video") } + }, + "required": ["kind"] + } + ] } ], "additionalProperties": false @@ -850,25 +906,14 @@ fn validate_resource_generation_arguments(arguments: &Value) -> Result<(), Strin &["kind", "mode", "sourceLocalAssetId", "prompt", "assetName"], )?; let kind = bounded_tool_string(arguments, "kind", 80)?; - if ![ - "video", - "character-animation", - "sound-effect", - "background-music", - ] - .contains(&kind.as_str()) - { - return Err("工具参数 kind 不是受支持的媒体资源类型".to_string()); - } + let generation_kind = DirectResourceGenerationKind::parse(&kind)?; let mode = bounded_tool_string(arguments, "mode", 16)?; if !["create", "derive"].contains(&mode.as_str()) { return Err("工具参数 mode 必须是 create 或 derive".to_string()); } - let prompt = bounded_tool_string( - arguments, - "prompt", - DIRECT_TOOLS_MCP_MAX_RESOURCE_PROMPT_CHARS, - )?; + // prompt 的形状校验只用信封级上限,真正生效的按 kind 上限由紧随其后的权威判定给出 + // 精确数字;否则通用 4000 会先于按 kind 上限误报成安全边界错误。 + let prompt = bounded_tool_string(arguments, "prompt", DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES)?; bounded_tool_string( arguments, "assetName", @@ -878,8 +923,12 @@ fn validate_resource_generation_arguments(arguments: &Value) -> Result<(), Strin if source.is_some() { bounded_tool_string(arguments, "sourceLocalAssetId", 80)?; } - if kind == "background-music" && prompt.chars().count() > 140 { - return Err("背景音乐提示词必须在 1..=140 字符内".to_string()); + let prompt_max_chars = generation_kind.prompt_max_chars(); + if prompt.chars().count() > prompt_max_chars { + return Err(resource_edit_prompt_limit_error( + &generation_kind.edit_kind(), + prompt_max_chars, + )); } if kind == "character-animation" && mode == "create" { return Err("角色动画必须基于已登记图片资源派生".to_string()); @@ -2756,4 +2805,686 @@ mod tests { "显式 Codex 返回不能再往项目主对话写 legacy 行" ); } + + // --------------------------------------------------------------------------------------- + // 工具层 → 客户端受控工具桥 → 假平台:媒体工具契约的确定性验收。 + // + // 夹具只回答资源编辑链路真正会发的请求,任何未预期请求直接 panic;源图片的 + // binding 在这里预置,因为「上传票据 → OSS 表单上传 → 对象确认 → 登记项目资源」 + // 子链已有专门用例覆盖,本组只钉工具名 / 参数校验 / 出站请求契约。 + // --------------------------------------------------------------------------------------- + + const TOOL_CHAIN_CANVAS_PROJECT_ID: &str = "remote-canvas-project"; + const TOOL_CHAIN_ASSET_FOLDER_ID: &str = "remote-asset-folder"; + const TOOL_CHAIN_ACCESS_TOKEN: &str = "tool-chain-token"; + const TOOL_CHAIN_MEDIA_ROUTE: &str = "/generated/resource-edit-result"; + + fn tool_chain_png() -> Vec { + let mut bytes = Vec::new(); + image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 2, + 2, + image::Rgba([12, 34, 56, 255]), + )) + .write_to( + &mut std::io::Cursor::new(&mut bytes), + image::ImageFormat::Png, + ) + .expect("encode tool chain png"); + bytes + } + + fn tool_chain_mp3() -> Vec { + let mut bytes = b"ID3\x04\x00\x00\x00\x00\x00\x0a".to_vec(); + bytes.extend_from_slice(&[0_u8; 32]); + bytes + } + + fn tool_chain_image_asset(id: &str, local_path: &str) -> GameCreationAppAssetManifestEntry { + GameCreationAppAssetManifestEntry { + id: id.to_string(), + kind: "image".to_string(), + media_type: "image/png".to_string(), + local_path: local_path.to_string(), + image_sequence_frames: None, + image_sequence_duration_ms: None, + category: game_creation_app_asset_category_for_kind("image"), + tags: Vec::new(), + source: GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: Some(TOOL_CHAIN_CANVAS_PROJECT_ID.to_string()), + resource_id: Some("editor-resource-hero".to_string()), + asset_object_id: Some("assetobj-hero".to_string()), + task_id: Some("tool-chain-task".to_string()), + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + } + } + + fn tool_chain_accept(listener: &std::net::TcpListener) -> std::net::TcpStream { + listener + .set_nonblocking(true) + .expect("set tool chain listener nonblocking"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + loop { + match listener.accept() { + Ok((stream, _)) => { + stream + .set_nonblocking(false) + .expect("restore tool chain stream blocking mode"); + return stream; + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + std::time::Instant::now() < deadline, + "等待工具链夹具请求超时" + ); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + Err(error) => panic!("接受工具链夹具请求失败:{error}"), + } + } + } + + fn tool_chain_read_request(stream: &mut std::net::TcpStream) -> String { + use std::io::Read; + + stream + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .expect("set tool chain read timeout"); + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 4096]; + let (header_end, content_length) = loop { + let read = stream.read(&mut buffer).expect("read tool chain request"); + assert!(read > 0, "工具链夹具请求在请求头结束前关闭"); + bytes.extend_from_slice(&buffer[..read]); + let Some(header_end) = bytes.windows(4).position(|value| value == b"\r\n\r\n") else { + continue; + }; + let header_text = String::from_utf8_lossy(&bytes[..header_end]); + let content_length = header_text + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + break (header_end + 4, content_length); + }; + while bytes.len() < header_end + content_length { + let read = stream + .read(&mut buffer) + .expect("read tool chain request body"); + assert!(read > 0, "工具链夹具请求在请求体结束前关闭"); + bytes.extend_from_slice(&buffer[..read]); + } + String::from_utf8_lossy(&bytes).into_owned() + } + + fn tool_chain_write_json(stream: &mut std::net::TcpStream, status: &str, body: Value) { + use std::io::Write; + + let body = body.to_string(); + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len(), + ) + .expect("write tool chain json response"); + } + + fn tool_chain_write_media(stream: &mut std::net::TcpStream, media_type: &str, bytes: &[u8]) { + use std::io::Write; + + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: {media_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + bytes.len(), + ) + .expect("write tool chain media headers"); + stream + .write_all(bytes) + .expect("write tool chain media bytes"); + } + + /// 假平台:按固定次数回答资源编辑链路的请求,并回放收到的每个请求正文。 + fn tool_chain_spawn_platform( + listener: std::net::TcpListener, + expected_submission: &'static str, + media_type: &'static str, + media: Vec, + request_count: usize, + ) -> ( + std::thread::JoinHandle>, + std::sync::mpsc::Receiver, + ) { + let media_url = format!( + "http://{}{TOOL_CHAIN_MEDIA_ROUTE}", + listener.local_addr().expect("tool chain fixture address") + ); + let (sender, receiver) = std::sync::mpsc::channel(); + let handle = std::thread::spawn(move || { + let mut requests = Vec::new(); + for _ in 0..request_count { + let mut stream = tool_chain_accept(&listener); + let request = tool_chain_read_request(&mut stream); + let request_line = request.lines().next().unwrap_or_default().to_string(); + let route = request_line + .split_whitespace() + .nth(1) + .unwrap_or_default() + .to_string(); + sender.send(request.clone()).ok(); + requests.push(request); + if route.starts_with("/api/editor/projects") { + tool_chain_write_json( + &mut stream, + "200 OK", + json!({"data": {"projects": [{ + "projectId": TOOL_CHAIN_CANVAS_PROJECT_ID, + "title": "工具链远端画布" + }]}}), + ); + } else if route.starts_with("/api/editor/assets/library") { + tool_chain_write_json( + &mut stream, + "200 OK", + json!({"data": {"library": {"folders": [{ + "folderId": TOOL_CHAIN_ASSET_FOLDER_ID, + "label": "工具链远端目录" + }]}}}), + ); + } else if request_line.starts_with(expected_submission) { + tool_chain_write_json( + &mut stream, + "202 Accepted", + json!({"data": { + "operationId": "tool-chain-operation", + "status": "queued", + "pollAfterMs": 0 + }}), + ); + } else if route.starts_with("/api/runtime/external-generation/jobs/") { + tool_chain_write_json( + &mut stream, + "200 OK", + json!({"data": { + "status": "completed", + "result": {"resource": { + "resourceId": "editor-resource-derived", + "objectKey": "generated/resource-edit-result", + "assetObjectId": "assetobj-derived" + }} + }}), + ); + } else if route.starts_with("/api/assets/read-url") { + tool_chain_write_json( + &mut stream, + "200 OK", + json!({"data": {"read": {"signedUrl": media_url}}}), + ); + } else if route.starts_with(TOOL_CHAIN_MEDIA_ROUTE) { + tool_chain_write_media(&mut stream, media_type, &media); + } else { + panic!("工具链夹具收到未预期请求:{request_line}"); + } + } + requests + }); + (handle, receiver) + } + + /// 真实项目 + 真实工具桥 + 真实 Direct 回合身份。 + async fn tool_chain_start( + root: &Path, + ) -> ( + super::super::direct_tool_bridge::DirectToolBridge, + DirectTaonierActiveInvocationGuard, + ) { + let bridge = super::super::direct_tool_bridge::start_direct_tool_bridge(root, false) + .await + .expect("start tool chain bridge"); + let turn = DirectTaonierActiveInvocationGuard::enter(root, "tool-chain-turn") + .expect("arm tool chain direct turn"); + (bridge, turn) + } + + async fn tool_chain_call( + bridge: &super::super::direct_tool_bridge::DirectToolBridge, + root: &Path, + name: &str, + arguments: Value, + ) -> Value { + EXTERNAL_MCP_BRIDGE_URL + .scope( + bridge.url().to_string(), + handle_direct_tools_mcp_request( + root, + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { "name": name, "arguments": arguments } + }), + ), + ) + .await + .expect("mcp response") + } + + fn tool_chain_payload(result: &Value) -> Value { + serde_json::from_str( + result["result"]["content"][0]["text"] + .as_str() + .unwrap_or_else(|| panic!("工具返回不是文本包:{result}")), + ) + .unwrap_or_else(|error| panic!("工具返回不是 JSON:{error};{result}")) + } + + /// P1 门禁:工具 schema 的 per-kind 上限、MCP 校验、工具桥校验与客户端权威口径必须是同一个数字。 + #[test] + fn tool_prompt_limits_agree_with_the_client_authority() { + let specs = direct_tools_mcp_specs_for(false, false); + let resource_tool = specs["tools"] + .as_array() + .expect("tool array") + .iter() + .find(|tool| tool["name"] == "agc_create_or_derive_resource") + .expect("resource tool"); + let schema_branches = resource_tool["inputSchema"]["allOf"] + .as_array() + .and_then(|all_of| all_of.get(1)) + .and_then(|branch| branch["oneOf"].as_array()) + .expect("per-kind prompt limit branches"); + + for (tool_kind, expected_kind) in [ + ( + "background-music", + LocalProjectResourceEditKind::BackgroundMusic, + ), + ("sound-effect", LocalProjectResourceEditKind::SoundEffect), + ("video", LocalProjectResourceEditKind::Video), + ( + "character-animation", + LocalProjectResourceEditKind::CharacterAnimation, + ), + ] { + let authority = resource_edit_prompt_max_chars(&expected_kind); + let generation_kind = DirectResourceGenerationKind::parse(tool_kind) + .unwrap_or_else(|error| panic!("{tool_kind}: {error}")); + assert_eq!( + generation_kind.edit_kind(), + expected_kind, + "{tool_kind} 必须映射到同一客户端类型" + ); + assert_eq!( + generation_kind.prompt_max_chars(), + authority, + "{tool_kind} 的工具桥上限必须来自客户端权威口径" + ); + let branch_limit = schema_branches + .iter() + .find_map(|branch| { + let kind = &branch["properties"]["kind"]; + let covers_kind = kind["const"].as_str() == Some(tool_kind) + || kind["enum"].as_array().is_some_and(|values| { + values.iter().any(|value| value.as_str() == Some(tool_kind)) + }); + covers_kind.then(|| { + branch["properties"]["prompt"]["maxLength"] + .as_u64() + .expect("branch prompt maxLength") + }) + }) + .unwrap_or_else(|| panic!("{tool_kind} 缺少按 kind 声明的提示词上限")); + assert_eq!( + branch_limit as usize, authority, + "{tool_kind} 的 schema 上限必须等于真实生效上限" + ); + assert!( + resource_tool["inputSchema"]["properties"]["prompt"]["description"] + .as_str() + .is_some_and(|text| text.contains(&authority.to_string())), + "{tool_kind} 的上限必须写进 prompt 描述:{authority}" + ); + let over_limit = "字".repeat(authority + 1); + let error = validate_resource_generation_arguments(&json!({ + "kind": tool_kind, + "mode": "create", + "prompt": over_limit, + "assetName": "上限测试" + })) + .expect_err("超过按 kind 上限的提示词必须被拒绝"); + assert!( + error.contains(&authority.to_string()), + "{tool_kind} 的拒绝文案必须带上真实上限:{error}" + ); + } + + // 图片编辑走独立工具,其上限同样是图片类型的权威口径。 + let image_authority = + resource_edit_prompt_max_chars(&LocalProjectResourceEditKind::ImageReference); + assert_eq!(image_authority, DIRECT_TOOLS_MCP_MAX_IMAGE_PROMPT_CHARS); + let edit_tool = specs["tools"] + .as_array() + .expect("tool array") + .iter() + .find(|tool| tool["name"] == "agc_edit_image") + .expect("image edit tool"); + assert_eq!( + edit_tool["inputSchema"]["properties"]["prompt"]["maxLength"], + json!(image_authority) + ); + } + + /// 源资源未登记时必须给出可执行的下一步,而不是只报「不属于已登记资源」。 + #[tokio::test] + async fn unregistered_source_reports_the_registration_follow_up_tools() { + let temporary = crate::tests::canonical_test_tempdir("direct-tools-unregistered-source-"); + let root = temporary.path(); + init_local_game_project_at(root, "direct-tools-unregistered-source", "未登记源资源提示") + .expect("init project"); + let (bridge, _turn_arm) = tool_chain_start(root).await; + let _turn = bridge.begin_user_turn().expect("begin client turn"); + + let result = tool_chain_call( + &bridge, + root, + "agc_edit_image", + json!({ + "sourceLocalAssetId": "missing-image", + "prompt": "把这张图改成夜景", + "assetName": "缺失源图编辑版" + }), + ) + .await; + + assert_eq!(result["result"]["isError"], true, "{result}"); + let message = result["result"]["content"][0]["text"] + .as_str() + .expect("tool error text"); + assert!( + message.contains("agc_list_registered_assets"), + "未登记源资源必须指向已登记资源查询工具:{message}" + ); + assert!( + message.contains("agc_import_account_assets"), + "未登记源资源必须指向登记工具:{message}" + ); + } + + /// 工具层 → 桥 → 假平台:图片快速编辑必须真的落到站内 `/api/editor/images/edits`。 + #[tokio::test] + async fn edit_image_tool_reaches_the_platform_image_edit_route() { + let temporary = crate::tests::canonical_test_tempdir("direct-tools-edit-image-"); + let root = temporary.path(); + init_local_game_project_at(root, "direct-tools-edit-image", "图片快速编辑工具链") + .expect("init project"); + let source_bytes = tool_chain_png(); + std::fs::create_dir_all(root.join("assets")).expect("create assets dir"); + std::fs::write(root.join("assets/hero.png"), &source_bytes).expect("write source png"); + let mut manifest = read_existing_manifest_for_project(root).expect("read manifest"); + manifest + .assets + .push(tool_chain_image_asset("hero-image", "assets/hero.png")); + write_manifest(&root.join(".agent/manifest.json"), &manifest).expect("write manifest"); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind fixture"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let (server, _requests) = tool_chain_spawn_platform( + listener, + "POST /api/editor/images/edits ", + "image/png", + tool_chain_png(), + 6, + ); + let _session = crate::platform_session::install_test_platform_session( + "tool-chain-owner", + TOOL_CHAIN_ACCESS_TOKEN, + &base_url, + ); + let session = current_platform_session().expect("platform session"); + let access = + ExternalEditorBindingAccess::new(&base_url, &session.access_token, Some(&session)) + .expect("account access"); + let principal = external_editor_binding_principal(&access).expect("account principal"); + let project_binding = new_external_editor_project_binding( + &manifest.project_id, + &principal, + TOOL_CHAIN_CANVAS_PROJECT_ID, + TOOL_CHAIN_ASSET_FOLDER_ID, + unix_timestamp(), + ) + .expect("project binding"); + write_external_editor_project_binding_at(root, &project_binding) + .expect("write project binding"); + let source_identity = new_external_editor_source_identity( + "hero-image", + &format!("{:x}", Sha256::digest(&source_bytes)), + "image/png", + "image", + ) + .expect("source identity"); + let resource_binding = new_external_editor_resource_binding( + &manifest.project_id, + &principal, + TOOL_CHAIN_CANVAS_PROJECT_ID, + &source_identity, + Some("editor-resource-hero"), + "source/hero.png", + "assetobj-hero", + Some(2), + Some(2), + unix_timestamp(), + ) + .expect("resource binding"); + write_external_editor_resource_binding_at(root, &resource_binding) + .expect("write resource binding"); + + let (bridge, _turn_arm) = tool_chain_start(root).await; + let _turn = bridge.begin_user_turn().expect("begin client turn"); + let result = tool_chain_call( + &bridge, + root, + "agc_edit_image", + json!({ + "sourceLocalAssetId": "hero-image", + "prompt": "把这张角色图改成夜景霓虹配色", + "assetName": "英雄-夜霓虹" + }), + ) + .await; + let requests = server.join().expect("join tool chain fixture"); + + assert_eq!(result["result"]["isError"], false, "{result}"); + let payload = tool_chain_payload(&result); + assert_eq!(payload["status"], "completed", "{payload}"); + assert_eq!(payload["kind"], "image"); + assert_eq!(payload["mode"], "derive"); + assert_eq!( + payload["resource"]["referenceResourceIds"], + json!(["local-asset:hero-image"]) + ); + let local_path = payload["resource"]["localPath"] + .as_str() + .expect("derived local path"); + assert!( + root.join(local_path).is_file(), + "派生图片必须落盘:{local_path}" + ); + + assert_eq!(requests.len(), 6, "{requests:?}"); + let submission = requests + .iter() + .find(|request| request.starts_with("POST /api/editor/images/edits ")) + .unwrap_or_else(|| panic!("缺少图片编辑提交请求:{requests:?}")); + let submission_lower = submission.to_ascii_lowercase(); + assert!(submission_lower.contains(&format!( + "authorization: bearer {}", + TOOL_CHAIN_ACCESS_TOKEN.to_ascii_lowercase() + ))); + assert!(submission_lower.contains("idempotency-key:")); + assert!(submission.contains("\"sourceReferenceId\":\"editor-resource-hero\"")); + assert!(submission.contains("\"assetLabel\":\"英雄-夜霓虹\"")); + assert!(submission.contains(&format!("\"projectId\":\"{TOOL_CHAIN_CANVAS_PROJECT_ID}\""))); + assert!(submission.contains(&format!( + "\"assetFolderId\":\"{TOOL_CHAIN_ASSET_FOLDER_ID}\"" + ))); + assert!( + !submission.contains("assetKind"), + "图片编辑请求不得回填 assetKind:{submission}" + ); + assert!(requests.iter().any(|request| request + .starts_with("GET /api/runtime/external-generation/jobs/tool-chain-operation "))); + assert!(requests + .iter() + .any(|request| request.starts_with("GET /api/assets/read-url?"))); + } + + /// 超过已发布上限的背景音乐提示词必须在工具层就被拒绝,且一次桥请求都不发出。 + #[tokio::test] + async fn background_music_prompt_over_the_limit_is_rejected_before_any_bridge_call() { + let temporary = crate::tests::canonical_test_tempdir("direct-tools-bgm-limit-"); + let root = temporary.path(); + init_local_game_project_at(root, "direct-tools-bgm-limit", "背景音乐上限工具链") + .expect("init project"); + let authority = + resource_edit_prompt_max_chars(&LocalProjectResourceEditKind::BackgroundMusic); + + let result = EXTERNAL_MCP_BRIDGE_URL + .scope( + // 故意指向没有服务监听的 loopback 地址:一旦真的发出桥请求,报错文案会变成连接失败。 + "http://127.0.0.1:1/tool-dead".to_string(), + handle_direct_tools_mcp_request( + root, + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "agc_create_or_derive_resource", + "arguments": { + "kind": "background-music", + "mode": "create", + "prompt": "字".repeat(authority + 1), + "assetName": "紧张战斗背景音乐" + } + } + }), + ), + ) + .await + .expect("mcp response"); + + assert_eq!(result["result"]["isError"], true, "{result}"); + let message = result["result"]["content"][0]["text"] + .as_str() + .expect("tool error text"); + assert!( + message.contains(&authority.to_string()) && message.contains("背景音乐"), + "必须按发布上限拒绝并说明类型:{message}" + ); + assert!( + !message.contains("连接客户端受控工具桥失败"), + "上限拒绝必须发生在桥请求之前:{message}" + ); + } + + /// 工具层 → 桥 → 假平台:背景音乐 create 必须落到站内音频生成路由。 + #[tokio::test] + async fn background_music_tool_reaches_the_platform_audio_route() { + let temporary = crate::tests::canonical_test_tempdir("direct-tools-bgm-route-"); + let root = temporary.path(); + init_local_game_project_at(root, "direct-tools-bgm-route", "背景音乐工具链") + .expect("init project"); + let manifest = read_existing_manifest_for_project(root).expect("read manifest"); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind fixture"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let (server, _requests) = tool_chain_spawn_platform( + listener, + "POST /api/editor/audios/background-music/generations ", + "audio/mpeg", + tool_chain_mp3(), + 6, + ); + let _session = crate::platform_session::install_test_platform_session( + "tool-chain-owner", + TOOL_CHAIN_ACCESS_TOKEN, + &base_url, + ); + let session = current_platform_session().expect("platform session"); + let access = + ExternalEditorBindingAccess::new(&base_url, &session.access_token, Some(&session)) + .expect("account access"); + let principal = external_editor_binding_principal(&access).expect("account principal"); + let project_binding = new_external_editor_project_binding( + &manifest.project_id, + &principal, + TOOL_CHAIN_CANVAS_PROJECT_ID, + TOOL_CHAIN_ASSET_FOLDER_ID, + unix_timestamp(), + ) + .expect("project binding"); + write_external_editor_project_binding_at(root, &project_binding) + .expect("write project binding"); + + let (bridge, _turn_arm) = tool_chain_start(root).await; + let _turn = bridge.begin_user_turn().expect("begin client turn"); + let result = tool_chain_call( + &bridge, + root, + "agc_create_or_derive_resource", + json!({ + "kind": "background-music", + "mode": "create", + "prompt": "紧张但克制的八位机战斗循环,鼓点清晰", + "assetName": "紧张战斗背景音乐" + }), + ) + .await; + let requests = server.join().expect("join tool chain fixture"); + + assert_eq!(result["result"]["isError"], false, "{result}"); + let payload = tool_chain_payload(&result); + assert_eq!(payload["status"], "completed", "{payload}"); + assert_eq!(payload["kind"], "background-music"); + assert_eq!(payload["mode"], "create"); + assert_eq!(payload["resource"]["mediaType"], "audio/mpeg"); + + assert_eq!(requests.len(), 6, "{requests:?}"); + let submission = requests + .iter() + .find(|request| { + request.starts_with("POST /api/editor/audios/background-music/generations ") + }) + .unwrap_or_else(|| panic!("缺少背景音乐提交请求:{requests:?}")); + assert!(submission.to_ascii_lowercase().contains(&format!( + "authorization: bearer {}", + TOOL_CHAIN_ACCESS_TOKEN.to_ascii_lowercase() + ))); + assert!(submission.contains("\"makeInstrumental\":true")); + assert!(submission.contains("紧张但克制的八位机战斗循环,鼓点清晰")); + assert!(submission.contains("\"assetLabel\":\"紧张战斗背景音乐\"")); + let body: Value = serde_json::from_str( + submission + .split("\r\n\r\n") + .nth(1) + .unwrap_or_else(|| panic!("背景音乐提交缺少请求体:{submission}")), + ) + .expect("background music submission body"); + let composed = body["gptDescriptionPrompt"] + .as_str() + .expect("composed background music prompt"); + assert!( + composed.starts_with("生成新音频;目标:") && composed.chars().count() < 200, + "create 模式必须使用无源前缀并落在现役接口上限内:{composed}" + ); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index 77183a884..46bb81c24 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -728,7 +728,16 @@ fn validate_resource_edit_uuid(value: &str, label: &str) -> Result<(), String> { Ok(()) } -fn resource_edit_prompt_max_chars(edit_kind: &LocalProjectResourceEditKind) -> usize { +/// 资源编辑提示词上限的**唯一口径**。 +/// +/// 三个调用方都必须从这里取数,禁止各自写死数字: +/// 1. 本文件的提交校验(`normalize_resource_edit_prompt`); +/// 2. `agc_tools` MCP 工具层(`direct_tools_mcp.rs` 的参数校验与工具 schema); +/// 3. 客户端受控工具桥(`direct_tool_bridge.rs`)。 +/// +/// 客户端 UI 的 `resourceEditPromptMaxLength`(`resourceEditModel.ts`)是同一份口径的 +/// 前端镜像;改数字必须同时改这里、那里,以及工具 schema 里按 kind 声明 `maxLength`。 +pub(crate) fn resource_edit_prompt_max_chars(edit_kind: &LocalProjectResourceEditKind) -> usize { match edit_kind { LocalProjectResourceEditKind::BackgroundMusic => 140, LocalProjectResourceEditKind::SoundEffect => 1_900, @@ -739,6 +748,24 @@ fn resource_edit_prompt_max_chars(edit_kind: &LocalProjectResourceEditKind) -> u } } +/// 提示词超限的拒绝文案:与上限同一个口径,MCP 层、工具桥和提交校验复用同一条字符串, +/// 保证模型看到的数字就是真实生效的数字。 +pub(crate) fn resource_edit_prompt_limit_error( + edit_kind: &LocalProjectResourceEditKind, + max_chars: usize, +) -> String { + format!( + "{}资源编辑提示词必须在 1..={max_chars} 字符内", + match edit_kind { + LocalProjectResourceEditKind::BackgroundMusic => "背景音乐", + LocalProjectResourceEditKind::SoundEffect => "音效", + LocalProjectResourceEditKind::Video => "视频", + LocalProjectResourceEditKind::CharacterAnimation => "角色动画", + _ => "", + } + ) +} + fn normalize_resource_edit_prompt( edit_kind: &LocalProjectResourceEditKind, value: &str, @@ -746,16 +773,7 @@ fn normalize_resource_edit_prompt( let value = value.trim(); let max_chars = resource_edit_prompt_max_chars(edit_kind); if value.is_empty() || value.chars().count() > max_chars { - return Err(format!( - "{}资源编辑提示词必须在 1..={max_chars} 字符内", - match edit_kind { - LocalProjectResourceEditKind::BackgroundMusic => "背景音乐", - LocalProjectResourceEditKind::SoundEffect => "音效", - LocalProjectResourceEditKind::Video => "视频", - LocalProjectResourceEditKind::CharacterAnimation => "角色动画", - _ => "", - } - )); + return Err(resource_edit_prompt_limit_error(edit_kind, max_chars)); } if value .chars() diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts index ae4bfe73e..4d739bec6 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts @@ -224,6 +224,12 @@ export function defaultCharacterAnimationResourceName( return `${resourceBaseName(resource) || '资源'}-角色动画`; } +/** + * 资源编辑提示词上限:与 Rust `resource_edit_prompt_max_chars` + * (`src-tauri/src/project/resource_editor.rs`)逐值同口径,UI、资源编辑提交、 + * `agc_tools` MCP 工具层与客户端工具桥共用同一组数字。改这里必须同时改那里, + * 并按 kind 同步 `direct_tools_mcp.rs` 工具 schema 里的 `prompt.maxLength`。 + */ export function resourceEditPromptMaxLength( editKind: LocalProjectResourceEditKind, ) { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a4ca70a6f..bfe0bdeb7 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2,6 +2,17 @@ > 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。 > 当前口径:历史条目的旧路径、旧版本和已退役对象只用于追溯,不构成现行实现依据;如与当前代码或 `docs/README.md` 冲突,以当前代码和最新专题文档为准。 + +## 2026-09-17 `agc_tools` 媒体资源提示词上限收敛为单一口径,并按 kind 暴露给模型 + +- 背景:有人反馈「客户端没法由 agent 调用图片快速编辑功能以及背景音乐生成功能」。核查后工具本身都在(`agc_edit_image` / `agc_create_or_derive_resource`),图片快速编辑在 2026-09-14 的真实项目日志里也有成功记录;但存在三类真实缺陷:① `agc_create_or_derive_resource` 的 `prompt` 在 schema 里只声明 4000,真实上限却是按 kind 分的(背景音乐 140、音效 1900、视频/角色动画 4000、图片 32000),MCP 层还额外写死了一条 140 判断,模型从 schema 与 skill 都看不出 140/1900,写一句正常长度的背景音乐描述就当场被拒;② 客户端 UI 用同一口径但会截断并提示,agent 侧却只有硬拒,形成「UI 能做、agent 调不动」的观感;③ `sourceLocalAssetId` 不是已登记资源时只报「不属于当前项目已登记资源」,模型会原地重试而不会先登记。 +- 决策一(单一口径):提示词上限只由 `resource_edit_prompt_max_chars` 给出,MCP 工具层、客户端受控工具桥与提交校验全部从它取数;超限文案复用 `resource_edit_prompt_limit_error`,保证模型看到的数字就是真实生效的数字。传输层边界只在信封级生效,不再用一个更小的通用常量先于按 kind 上限误报。 +- 决策二(按 kind 暴露):`agc_create_or_derive_resource` 的 schema 用 `allOf[oneOf]` 逐 kind 声明 `prompt.maxLength`(background-music / sound-effect / video+character-animation),顶层 `maxLength` 等于各 kind 上限的最大值,`prompt` 描述里写明每个数字;`agc_edit_image` 继续用图片口径 32000。skill 包 `agc-client-projection`(SKILL.md 与 `references/projection-contract.md`)同步写明四个数字,并说明超限要在本地收敛而不是原样重发。 +- 决策三(可执行的前置提示):源资源未登记时统一返回「先用 `agc_list_registered_assets` 选已有 localAssetId;文件只在项目里时先用 `agc_list_project_files` 确认 `assetImportable=true`,再用 `agc_import_account_assets.localPaths` 登记后重试」。本轮不放开「已完成任务产物」在 agent 侧的隐式正规化:登记是带副作用与 revision 推进的事务,必须由模型显式发起。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`(上限与文案的唯一口径)、`agent/direct_tool_bridge.rs`(按 kind 判定与未登记源资源提示)、`agent/direct_tools_mcp.rs`(schema 与校验)、`resources/agc-skills/agc-client-projection/**` 与清单指纹(version `2026-08-26.18`)。**未改** `/api/external/v1` 契约与 OpenAPI、SpacetimeDB schema、前端 TS 侧 `resourceEditPromptMaxLength` 数字、客户端 UI 行为。 +- 验证方式:新增 `tool_prompt_limits_agree_with_the_client_authority`(四个 kind 的 schema 上限、MCP 校验与客户端权威口径同数字,超限文案带真实上限)、`bridge_resource_prompt_limits_follow_the_client_authority`(工具桥侧同类门禁,含图片编辑的 32000 边界)、`edit_image_tool_reaches_the_platform_image_edit_route` 与 `background_music_tool_reaches_the_platform_audio_route`(MCP 工具层 → 真实工具桥 → 假平台,断言 `/api/editor/images/edits` 与 `/api/editor/audios/background-music/generations` 的路径、Bearer、Idempotency-Key、正文与派生资源落盘,图片编辑正文不得回填 assetKind)、`background_music_prompt_over_the_limit_is_rejected_before_any_bridge_call`(超限在桥请求之前失败)、`unregistered_source_reports_the_registration_follow_up_tools`;`agent::direct_tools_mcp` 22 passed、`agent::skill_pack` 4 passed、`agent::direct_tool_bridge` 17 passed(7 条本机既有失败见下)、`npm run agc:skill-pack:check` 与 `skill-pack:test` 通过。本机 `tempfile::tempdir()` 归属校验失败导致的既有用例(`project::resource_editor` 45 条、`agent::direct_tool_bridge` 7 条)在本轮改动前后**同为失败**(stash 基线复跑确认),与本次无关。 +- 关联文档:[AI游戏创作智能体App实施计划](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)、[踩坑记录](pitfalls.md)。 + ## 2026-09-16 抠图模式与背景色契约 - External v1 抠图和 AGC `agc_remove_background` 支持 `complex`(语义分割识别前景)与 `flat`(纯色背景抠图);明确纯色背景优先 flat,模式缺省仍为 complex,主站前端保持现有行为。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 24a21eea8..8127774b5 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -12,6 +12,14 @@ JSON 的文本读取分支不等于卡面应该展示原始 State 摘要。卡 工作台向窗口标题栏发布运行项目时,若 effect 依赖普通函数派生的回调,发布 Context 会重新渲染工作台,进而再次发布并清理,形成更新深度循环。转发入口须稳定,并在提交阶段更新实际处理器引用;发布数据变化与卸载清理分开。回归测试必须组合真实窗口 Provider 和工作台消费者,只有独立画布测试无法覆盖这条反馈链;回归时用有界发布次数阻止测试失控。画布快速操作时暴露的更新深度错误,也须检查外层状态同步,不能直接归因于滚轮频率。 +## 2026-09-17 工具 schema 声明的上限与真实校验不一致,会表现成「agent 调不动这个功能」 + +- **现象**:用户反馈「客户端没法由 agent 调用图片快速编辑功能以及背景音乐生成功能」。查工具目录时两个工具都在(`agc_edit_image`、`agc_create_or_derive_resource`),图片快速编辑在真实项目日志里还有成功记录;但 agent 侧写一句正常长度的背景音乐描述就失败,而客户端 UI 用同一个提示词却只是被截断加提示。 +- **原因**:`agc_create_or_derive_resource.prompt` 在 MCP schema 里只声明 `maxLength: 4000`,真实上限按 kind 分(背景音乐 140 / 音效 1900 / 视频、角色动画 4000 / 图片 32000),MCP 层还额外写死一条 `kind == background-music && > 140` 的判断;skill 包没有任何一处写这两个数字。模型从 schema 与 skill 都无法得知 140,于是必然踩一次硬拒。同类隐患还有两处:客户端工具桥用通用 4000 校验 prompt,会把 4000 以上的图片编辑提示词误报成「超出安全边界」;按 kind 校验散落在 MCP 与桥两处,新增类型容易只改一处。 +- **处理**:上限收敛到 `resource_edit_prompt_max_chars` 单一权威(工具层、桥、提交校验共用),超限文案复用 `resource_edit_prompt_limit_error`;工具 schema 用 `allOf[oneOf]` 逐 kind 声明 `prompt.maxLength` 并在描述里写明数字;prompt 的传输层边界退到信封级,避免通用常量先于按 kind 上限报错;两端 skill 文档同步写明四个数字。新增 `tool_prompt_limits_agree_with_the_client_authority` 作为门禁:四类 kind 的 schema 上限、桥上限与权威口径必须同数字,且超限文案必须带真实上限。 +- **验证**:`cargo test --bin genarrative-ai-game-creator-shell -- --test-threads=1 agent::direct_tools_mcp::tests`(22 passed,含两条走 MCP 工具层 → 真实工具桥 → 假平台的媒体工具契约用例与一条超限零请求用例)、`agent::direct_tool_bridge::tests`(17 passed,含新增的按 kind 上限门禁;另有 7 条本机既有失败)、`agent::skill_pack`(4 passed)、`npm run agc:skill-pack:check`。本机 `tempfile::tempdir()` 归属校验失败会让 `project::resource_editor` 45 条与 `agent::direct_tool_bridge` 7 条既有用例失败,改动前后同为失败,不要据此误判回归。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`、`src-tauri/src/agent/direct_tool_bridge.rs`、`src-tauri/src/agent/direct_tools_mcp.rs`、`src-tauri/resources/agc-skills/agc-client-projection/`。 + ## 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 4c39954dc..7ead451b0 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -150,6 +150,7 @@ npm 游戏的可预览产物固定为对应 package 目录下的 `dist/index.htm ## 2026-08-24 Direct Codex 已登记资源查询与媒体生成语义工具 - `agc_tools` 新增 `agc_list_registered_assets` 与 `agc_create_or_derive_resource`。前者按 `kind / assetId / offset / limit` 有界查询客户端权威 manifest,并可显式返回角色动画正式序列帧的稳定 objectKey、assetObjectId 和尺寸;结果不包含完整 manifest、prompt、model、provider route、签名 URL、宿主路径或凭据。后者只接受 `kind / mode / sourceLocalAssetId / prompt / assetName`,`create` 仅允许无源视频、音效和背景音乐,`derive` 必须引用当前项目已登记的 localAssetId,角色动画固定为 derive。 +- `prompt` 上限按 `kind` 分别生效,且工具 schema、MCP 校验、客户端工具桥与提交校验共用同一权威口径(`resource_edit_prompt_max_chars`):背景音乐 140、音效 1900、视频与角色动画 4000、图片编辑 32000。schema 逐 kind 声明 `maxLength` 并在 `prompt` 描述里写明数字,超限必须在发起任何桥请求与付费提交之前失败并回报真实上限;`sourceLocalAssetId` 不是当前项目已登记资源时,错误文案必须直接给出 `agc_list_registered_assets` 与 `agc_list_project_files` → `agc_import_account_assets.localPaths` 两步后续动作。 - 项目路径、projectId、当前 revision、源文件路径与媒体类型、operationId、Idempotency-Key、登录态、项目锁、付费提交、轮询恢复、下载校验与 manifest 事务全部由客户端持有。模型不能提交或覆盖这些字段。同一 Direct `clientTurnId + 规范语义参数` 生成稳定 UUID v4 身份;单回合同参重试复用原 operation,不同请求串行且最多四项。跨回合存在完全匹配的 pending 账本时优先恢复原 operation,不能换键重发。 - 资源查询同时投影未完成 operation 的安全状态。媒体工具成功只返回 operation、本地相对路径、资源类型、Canvas/resource/asset/task 身份、正式序列帧以及脱敏后的 `warnings / sliceWarnings`;错误继续使用统一脱敏边界。客户端资源账本持久化 completed 结果的两类告警,committed replay 不能把历史告警伪装成空集合。 - 角色动画、视频、音效和背景音乐在构造新的远端请求前统一准备当前项目同名画布与素材目录上下文,并在端点支持时携带 `projectId / assetFolderId / canvasCompletion`。角色动画 placeholder 使用源图片真实宽高,避免非方形角色进入画布时失真;正式 resource/asset 与序列帧继续直接复用 External 返回身份,不从首帧伪造重复资源。已有冻结 request body 或已受理 operation 保持不变,不因本次升级重建请求或重复扣费。 From ebc5dfe7db0206b255ac164cbf62ee03b2d0c18c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=94=E4=BB=A4=E5=BC=98?= Date: Thu, 17 Sep 2026 14:58:21 +0800 Subject: [PATCH 7/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=AD=96=E5=88=92?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E4=B9=B1=E5=BA=8F=E5=B1=80=E9=83=A8=E4=BF=AE?= =?UTF-8?q?=E6=94=B9=E5=AF=BC=E8=87=B4=E7=9A=84=E4=B8=AD=E6=96=AD=20(#399)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按原文位置排序并顺序拼接补丁,避免替换偏移失效。 新增乱序、中文与长度变化的局部修改回归测试。 同步策划 Agent 批量局部修改技术说明。 Reviewed-on: http://genarrative-station/git/GenarrativeAI/Genarrative/pulls/399 --- .../src-tauri/src/agent/design_tools.rs | 40 +++++++++++++++++-- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 ++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs index 730d68004..ba4420164 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs @@ -380,11 +380,16 @@ pub(crate) fn execute_design_file_tool( } return Err(details.join("\n")); } - let mut updated = content.clone(); - for (index, start, end) in matches.into_iter().rev() { + matches.sort_unstable_by_key(|(_, start, _)| *start); + let mut updated = String::with_capacity(content.len()); + let mut cursor = 0; + for (index, start, end) in matches { let (_, new) = &normalized[index]; - updated.replace_range(start..end, new); + updated.push_str(&content[cursor..start]); + updated.push_str(new); + cursor = end; } + updated.push_str(&content[cursor..]); if updated == content { return Err(format!("没有产生修改:{display}")); } @@ -834,6 +839,35 @@ mod tests { assert!(!root.join("design_artifacts/notes").exists()); } + #[test] + fn patch_file_applies_out_of_order_edits_with_changing_utf8_lengths() { + let temp = test_root(); + let root = temp.path(); + execute_design_file_tool( + root, + "write_file", + &json!({"path":"notes/design.md","content":"开头\n甲\n保留一\n乙乙\n保留二\n丙\n结尾"}), + ) + .expect("write"); + execute_design_file_tool( + root, + "patch_file", + &json!({ + "path":"notes/design.md", + "edits":[ + {"old_text":"丙","new_text":"新的结论"}, + {"old_text":"甲","new_text":"扩展A"}, + {"old_text":"乙乙","new_text":"乙"} + ] + }), + ) + .expect("patch out of order"); + assert_eq!( + fs::read_to_string(root.join("design_artifacts/notes/design.md")).expect("read disk"), + "开头\n扩展A\n保留一\n乙\n保留二\n新的结论\n结尾" + ); + } + #[test] fn phase_context_injects_current_skill_only() { let resources = DesignResources::new(pack_root()).expect("pack"); diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 7ead451b0..dec89f82b 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1,5 +1,9 @@ # AI 游戏创作智能体 App 实施计划 +## 策划 Agent 批量局部修改 + +`patch_file` 的所有 edits 均匹配同一份原文件,参数顺序不影响结果。完成唯一匹配与不重叠校验后,按原文起点升序拼接未修改片段与替换文本,最后一次性写入;任一校验失败时不写文件。回归用例覆盖乱序 edits、中文内容与替换长度增减,并核对完整落盘内容。此行为仅属于策划 Agent 文件工具。 + ## 资源画布交互与工作台状态同步 - 工作台向窗口标题栏发布正在运行的项目时,输入未变化不得形成重复发布与清理的渲染循环;打开项目动作始终使用当前工作台处理逻辑,退出工作台后清除其标题栏状态。 From efdb364a5264a05fa61c4f764950d423a8c1f3cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 17 Sep 2026 15:54:07 +0800 Subject: [PATCH 8/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8E=A8=E7=90=86?= =?UTF-8?q?=E6=A1=A3=E5=BC=B9=E5=B1=82=E8=A2=AB=E8=A3=81=E5=89=AA=E9=97=AE?= =?UTF-8?q?=E9=A2=98=EF=BC=8C=E5=A2=9E=E5=8A=A0=20CSS=20=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E5=A3=B0=E6=98=8E=E4=BB=A5=E7=A1=AE=E4=BF=9D=E7=AA=84=E5=B8=83?= =?UTF-8?q?=E5=B1=80=E4=B8=8B=E5=BC=B9=E5=B1=82=E5=AE=8C=E6=95=B4=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/ai-game-creator-shell/src/styles.css | 21 +++++++++++++++++++ .../appSurface/project-development.suite.ts | 14 +++++++++++++ docs/project-memory/shared-memory/pitfalls.md | 9 ++++++++ 3 files changed, 44 insertions(+) diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 5b0352492..613ec51e4 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -8820,6 +8820,27 @@ iframe.preview-frame { overflow: hidden; } +/* 输入盒的弹层必须能溢出面板:控制排最左侧是「推理档」,它的菜单 + `.conversation-model-menu` 贴着触发钮右缘向左展开,窄布局(视口 ≤1000px 时对话面板 + 只有 280px 宽)下会伸到面板左侧之外;`.game-workbench-chat`、surface、conversation + 这三层 `overflow: hidden` 会沿着各自的溢出边界把它裁掉,档位文字正好落在被裁掉的 + 那半边,于是点开只能看到一个空盒子。所以这里让这三层不再裁切:菜单自身位置、 + 尺寸都不变,只是允许它盖到左侧面板上完整显示。消息列表自带 `overflow-y: auto` + (另一轴按规范计算为 auto),消息内容仍由列表自身裁剪。 */ +.game-workbench-chat:has(.project-supervisor-composer.is-direct-codex) { + overflow: visible; +} + +.game-workbench-chat .project-supervisor-surface.is-direct-codex { + overflow: visible; +} + +.game-workbench-chat + .project-supervisor-surface.is-direct-codex + .project-supervisor-conversation { + overflow: visible; +} + .game-workbench-chat .project-supervisor-message-list { height: 100%; min-height: 96px; 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..422aff807 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 @@ -5853,6 +5853,20 @@ export function registerProjectWorkbenchFoundationTests() { expect(styles).toMatch( /\.game-workbench-chat \.project-supervisor-conversation\s*\{[^}]*position:\s*relative[^}]*display:\s*block[^}]*height:\s*100%[^}]*min-height:\s*0[^}]*overflow:\s*hidden/s, ); + // 输入盒里的弹层不能被上面这条(连同 surface、聊天列共三层)裁掉:控制排最左侧是 + // 「推理档」,它的菜单贴着触发钮右缘向左展开,窄布局(视口 ≤1000px 时面板只有 + // 280px 宽)下会伸到面板左侧之外,档位文字正好落在被裁掉的那半边,点开只剩一个空 + // 盒子。所以 direct-codex 这三层的裁切必须放开;菜单位置和尺寸不变,真机几何 + // (整块可见、位置不动)由浏览器实测确认,这里只钉声明。 + expect(styles).toMatch( + /\.game-workbench-chat:has\(\s*\.project-supervisor-composer\.is-direct-codex\s*\)\s*\{[^}]*overflow:\s*visible/s, + ); + expect(styles).toMatch( + /\.game-workbench-chat \.project-supervisor-surface\.is-direct-codex\s*\{[^}]*overflow:\s*visible/s, + ); + expect(styles).toMatch( + /\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-conversation\s*\{[^}]*overflow:\s*visible/s, + ); expect(styles).toMatch( /\.game-workbench-chat \.project-supervisor-message-list\s*\{[^}]*height:\s*100%[^}]*min-height:\s*96px[^}]*overflow-y:\s*auto[^}]*padding-bottom:\s*12px[^}]*scroll-padding-bottom:\s*12px/s, ); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 74092e946..a70b45712 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,14 @@ # 踩坑与排障记录 +## 2026-09-17 AGC 输入盒的「推理档」弹层被祖先裁切:要放开裁切而不是挪弹层 + +- **现象**:窄窗口下(视口 ≤1000px 时右侧对话面板只有 280px 宽)点开输入盒右下角的「推理档」,弹层是个**空盒子**:档位文字(默认 / 低 / 中 / 高 / 最高)整片看不见,只剩一个方框。 +- **成因**:推理档是控制排里最靠左的弹层锚点,`.conversation-model-menu` 默认 `right: 0` 贴触发钮右缘**向左**展开;触发钮右边还压着模型选择、语音、发送三颗钮,所以 150px 宽的弹层在 280px 面板里会伸到面板左侧 42px 之外。`.game-workbench-chat`、`.project-supervisor-surface.is-direct-codex`、`.project-supervisor-conversation` 三层各自的 `overflow: hidden` 沿自己的溢出边界裁掉它,而档位文字起点才 14px(面板左内边距 5px + 按钮左内边距 9px),正好落在被裁掉的那半边。 +- **处理(用户指定口径)**:不挪弹层位置——只让 direct-codex 那三层不再裁切:`.game-workbench-chat:has(.project-supervisor-composer.is-direct-codex)`、`.game-workbench-chat .project-supervisor-surface.is-direct-codex`、`.game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-conversation` 三条 `overflow: visible`。弹层的 `right: 0`、尺寸和触发钮锚点全不变,只是允许它盖到左侧资源面板上完整显示。消息列表自带 `overflow-y: auto`(另一轴按规范计算为 auto),消息内容仍由列表自身裁剪。 +- **易错点**:① 把弹层改成 `left: 0` 或往右挪也能让它可见,但那是改变展开方向,弹层会跑到触发钮右边(用户明确否决);② 只放开最外层聊天列不够——surface 与 conversation 各自都会裁,三层必须同时放开;③ 只按宽度比大小会误判:280px 面板里控制排本身也超出(发送钮右侧溢出 22px,被窗口右缘吃掉),那不是本条的原因,别顺手去改控制排布局。 +- **验证**:`apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts` 的 `keeps the landscape workbench edge-to-edge with internal chat scrolling` 钉住三条 override 声明在场(删掉任一条即红)。真机几何用 playwright-cli 打开一份只含真实 `styles.css` 与真实 composer DOM 的最小复现页实测(视口 1000×700、面板 280px):弹层 rect 修复前后都是 `[-42, 108]`(位置未动),`elementFromPoint` 的命中区间从修复前的 `[2, 108]` 变成整块;档位文字在截图中完整可见。 +- **关联**:`apps/ai-game-creator-shell/src/styles.css`(`面板纵向布局(2026-07 Codex 风格改造)` 区块之后)、`apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts`。 + ## 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\...` 形式能区分真实路径。 From 1b579acf474031382b10efe01a44c2477bbf816f Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 17 Sep 2026 08:22:52 +0000 Subject: [PATCH 9/9] =?UTF-8?q?=E6=8F=90=E9=AB=98=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AFLLM=E9=BB=98=E8=AE=A4=E9=87=8D=E8=AF=95=E6=AC=A1?= =?UTF-8?q?=E6=95=B0=E5=88=B010=20=E5=86=85=E7=BD=AE=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E5=80=BC=20DEFAULT=5FGAME=5FCREATOR=5FLLM=5FMAX=5FRETRIES=20?= =?UTF-8?q?=E7=94=B1=202=20=E6=94=B9=E4=B8=BA=2010=20=E9=9A=8FApp=E5=88=86?= =?UTF-8?q?=E5=8F=91=E7=9A=84=E9=85=8D=E7=BD=AE=E6=A8=A1=E6=9D=BF=20game-c?= =?UTF-8?q?reator.config.json=20=E7=9A=84=20maxRetries=20=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E4=B8=BA=2010=20=E8=AE=BE=E7=BD=AE=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E6=A1=86=E8=8D=89=E7=A8=BF=E5=85=9C=E5=BA=95=E5=80=BC=20maxRet?= =?UTF-8?q?ries=20=E5=90=8C=E6=AD=A5=E4=B8=BA=2010?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/ai-game-creator-shell/game-creator.config.json | 2 +- apps/ai-game-creator-shell/src-tauri/src/main.rs | 2 +- .../src/features/runtime-config/RuntimeConfigDialog.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/game-creator.config.json b/apps/ai-game-creator-shell/game-creator.config.json index b6193445f..3c0005da3 100644 --- a/apps/ai-game-creator-shell/game-creator.config.json +++ b/apps/ai-game-creator-shell/game-creator.config.json @@ -15,7 +15,7 @@ "autoCompactTokenLimit": 64000, "toolOutputTokenLimit": 12000, "requestTimeoutMs": 180000, - "maxRetries": 2, + "maxRetries": 10, "retryBackoffMs": 500 }, "agentLlm": {} diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index fe77f4532..6a57de8e2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1561,7 +1561,7 @@ const DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT: &str = "high"; const DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS: u64 = 128_000; const DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT: u64 = 64_000; const DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT: u64 = 12_000; -const DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES: u32 = 2; +const DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES: u32 = 10; fn default_game_creator_agent_mode() -> String { GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string() diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx index a12f99ff8..16feb1f76 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx @@ -64,7 +64,7 @@ const defaultRuntimeConfigDraft: GameCreatorAppConfig = { autoCompactTokenLimit: 64000, toolOutputTokenLimit: 12000, requestTimeoutMs: 180000, - maxRetries: 2, + maxRetries: 10, retryBackoffMs: 500, }, agentLlm: {},