diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index c5af72b49..7d3ace07e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -266,6 +266,7 @@ fn game_creator_codex_app_server_connection_error( ) -> platform_llm::LlmError { match game_creator_codex_app_server_error_http_status(info, field) { Some(401 | 403) => game_creator_codex_app_server_error_kind("unauthorized"), + Some(413) => game_creator_codex_app_server_error_kind("request-too-large"), Some(status_code) => platform_llm::LlmError::Upstream { status_code, message: "Codex app-server 连接上游失败".to_string(), @@ -303,6 +304,30 @@ fn game_creator_codex_app_server_error_detail_indicates_auth_failure( || detail.contains("http 403") } +fn game_creator_codex_app_server_error_detail_indicates_request_too_large( + error: &serde_json::Value, +) -> bool { + let Some(error) = error.as_object() else { + return false; + }; + let detail = ["message", "additionalDetails", "code"] + .into_iter() + .filter_map(|field| error.get(field).and_then(serde_json::Value::as_str)) + .collect::>() + .join(" ") + .to_ascii_lowercase(); + if detail.is_empty() { + return false; + } + detail.contains("413 payload too large") + || detail.contains("http 413") + || detail.contains("status 413") + || detail.contains("payload_too_large") + || detail.contains("payload too large") + || detail.contains("request too large") + || detail.contains("provider request too large") +} + fn game_creator_codex_app_server_error_detail_indicates_insufficient_mud_points( error: &serde_json::Value, ) -> bool { @@ -333,6 +358,9 @@ fn game_creator_codex_app_server_failed_turn_error( message: "泥点余额不足".to_string(), }; } + if game_creator_codex_app_server_error_detail_indicates_request_too_large(error) { + return game_creator_codex_app_server_error_kind("request-too-large"); + } if game_creator_codex_app_server_error_detail_indicates_auth_failure(error) { return game_creator_codex_app_server_error_kind("unauthorized"); } @@ -4787,6 +4815,12 @@ mod tests { "codex-app-server-error:unauthorized".to_string(), ), ), + ( + serde_json::json!({"httpConnectionFailed":{"httpStatusCode":413}}), + platform_llm::LlmError::InvalidRequest( + "codex-app-server-error:request-too-large".to_string(), + ), + ), ( serde_json::json!({"httpConnectionFailed":{"httpStatusCode":429}}), platform_llm::LlmError::Upstream { @@ -4847,6 +4881,31 @@ mod tests { } } + #[test] + fn codex_app_server_failed_turn_maps_request_too_large_details() { + for detail in [ + "HTTP 413 Payload Too Large", + "status 413", + "PAYLOAD_TOO_LARGE", + "provider request too large", + ] { + let error = game_creator_codex_app_server_failed_turn_error(&serde_json::json!({ + "status": "failed", + "error": { + "message": detail, + "additionalDetails": "private upstream diagnostics", + "codexErrorInfo": "other" + } + })); + assert_eq!( + error, + platform_llm::LlmError::InvalidRequest( + "codex-app-server-error:request-too-large".to_string(), + ) + ); + } + } + #[test] fn codex_app_server_failed_turn_maps_insufficient_mud_points_to_stable_upstream_error() { for detail in [ diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs index e59d90fb1..4585eee85 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs @@ -35,7 +35,9 @@ const SYSTEM_PROMPT: &str = r#" * 对每个 Component,直接完整返回其全部参数. * 有任何困难或者不确定把状态设为 NeedReview,说明中文原因。 * 纯结构节点可以返回空数组并标为 NoProblem。 +* 容器背景等推荐使用Simple + preserve_aspect: false 实现与node大小一致 * 面向用户的 reason 使用中文。 + "#; #[derive(Clone, Debug, Deserialize, JsonSchema)] diff --git a/apps/ai-game-creator-shell/src/components/error-report/ErrorReportDialog.tsx b/apps/ai-game-creator-shell/src/components/error-report/ErrorReportDialog.tsx index d22ea9902..216f04b61 100644 --- a/apps/ai-game-creator-shell/src/components/error-report/ErrorReportDialog.tsx +++ b/apps/ai-game-creator-shell/src/components/error-report/ErrorReportDialog.tsx @@ -4,6 +4,7 @@ import { ackClientErrorEventsWithRetry, type ClientErrorEvent, type DiagnosticLogFile, + getPendingClientErrorEvents, getStableErrorReportSubmissionId, readApplicationDiagnosticLogs, submitErrorReportBatch, @@ -12,10 +13,12 @@ import { ThemedModal } from '../modal/ThemedModal'; type ErrorReportDialogProps = { open: boolean; - events: ClientErrorEvent[]; onClose: () => void; + fallbackEvents?: ClientErrorEvent[]; }; +const emptyFallbackEvents: ClientErrorEvent[] = []; + function diagnosticLogLabel( logsReady: boolean, logsError: boolean, @@ -28,9 +31,12 @@ function diagnosticLogLabel( export function ErrorReportDialog({ open, - events, onClose, + fallbackEvents = emptyFallbackEvents, }: ErrorReportDialogProps) { + const [events, setEvents] = useState([]); + const [eventsReady, setEventsReady] = useState(false); + const [eventsError, setEventsError] = useState(false); const [selectedFingerprints, setSelectedFingerprints] = useState>( new Set(), ); @@ -45,29 +51,59 @@ export function ErrorReportDialog({ useEffect(() => { if (!open) return; - setSelectedFingerprints( - new Set(events.slice(0, 1).map((event) => event.fingerprint)), - ); + let disposed = false; + setEvents([]); + setEventsReady(false); + setEventsError(false); + setSelectedFingerprints(new Set()); setStatus(''); setLogsReady(false); setLogsError(false); + void getPendingClientErrorEvents() + .then((nextEvents) => { + if (disposed) return; + setEvents(nextEvents); + setSelectedFingerprints( + new Set(nextEvents.map((event) => event.fingerprint)), + ); + setEventsReady(true); + }) + .catch(() => { + if (disposed) return; + if (fallbackEvents.length) { + setEvents(fallbackEvents); + setSelectedFingerprints( + new Set(fallbackEvents.map((event) => event.fingerprint)), + ); + setEventsReady(true); + setStatus('最新错误读取失败,已使用打开通知时的快照'); + return; + } + setEventsError(true); + setEventsReady(true); + }); void readApplicationDiagnosticLogs() .then((nextLogs) => { + if (disposed) return; setLogs(nextLogs); setLogsReady(true); }) .catch(() => { + if (disposed) return; setLogs([]); setLogsError(true); setLogsReady(true); }); - }, [events, open]); + return () => { + disposed = true; + }; + }, [fallbackEvents, open]); const submit = useCallback(async () => { const selectedEvents = events.filter((event) => selectedFingerprints.has(event.fingerprint), ); - if (!selectedEvents.length || busy || !logsReady) return; + if (!selectedEvents.length || busy || !eventsReady || !logsReady) return; if (includeLogs && logsError) return; setBusy(true); setStatus('正在提交…'); @@ -92,6 +128,7 @@ export function ErrorReportDialog({ }, [ busy, description, + eventsReady, events, includeLogs, logs, @@ -124,7 +161,11 @@ export function ErrorReportDialog({
错误事件({events.length}) - {events.length ? ( + {!eventsReady &&

正在读取当前错误…

} + {eventsReady && eventsError && ( +

错误事件暂不可用,请关闭后重试。

+ )} + {eventsReady && !eventsError && events.length > 0 && (
    {events.map((event) => (
  • ))}
- ) : ( + )} + {eventsReady && !eventsError && !events.length && (

当前没有待报告的错误。

)}
@@ -191,11 +233,12 @@ export function ErrorReportDialog({ 取消 + +
+ + ); +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx index 5974e3ef7..14fde9869 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx @@ -1,7 +1,10 @@ -import { ChevronDown, Eye, EyeOff } from 'lucide-react'; +import { ChevronDown, Eye, EyeOff, Puzzle, Trash2 } from 'lucide-react'; import { + forwardRef, + type HTMLAttributes, type MouseEvent as ReactMouseEvent, useEffect, + useMemo, useRef, useState, } from 'react'; @@ -9,16 +12,25 @@ import { adjustMoveIndex, type MoveHandler, type NodeRendererProps, + type RowRendererProps, Tree, type TreeApi, } from 'react-arborist'; +import { + countUiNodeDescendants, + findUiNode, +} from '../../../features/ui-editor/treeUtils'; import type { Node as UiNode } from '../../../features/ui-editor/types/Node'; import type { NodeId } from '../../../features/ui-editor/types/NodeId'; import type { UIDesignImageId } from '../../../features/ui-editor/types/UIDesignImageId'; import type { UiNodeMoveRequest } from '../../../features/ui-editor/types/UiNodeMoveRequest'; import type { UiEditorNodeFocusRequest } from '../model'; import { UiNodeContextMenu } from './UiNodeContextMenu'; +import { + UiNodeDeleteConfirmModal, + type UiNodeDeleteRequest, +} from './UiNodeDeleteConfirmModal'; type UiTreePanelProps = { root: UiNode | null; @@ -36,6 +48,38 @@ type UiTreePanelProps = { className?: string; }; +const TreeListOuter = forwardRef< + HTMLDivElement, + HTMLAttributes +>(function TreeListOuter({ style, ...props }, ref) { + return ( +
+ ); +}); + +function TreeRowContainer({ + node, + innerRef, + attrs, + children, +}: RowRendererProps) { + return ( +
event.stopPropagation()} + onClick={node.handleClick} + > + {children} +
+ ); +} + function TreeRow({ node, style, @@ -44,11 +88,17 @@ function TreeRow({ isNodeVisible, onToggleNodeVisibility, onOpenContextMenu, + onRequestDelete, + canDelete, + deleteDisabled, }: NodeRendererProps & { onSelectNode: (id: NodeId) => void; isNodeVisible: (nodeId: NodeId) => boolean; onToggleNodeVisibility: (id: NodeId) => void; onOpenContextMenu: (event: ReactMouseEvent, id: NodeId) => void; + onRequestDelete: (id: NodeId) => void; + canDelete: boolean; + deleteDisabled: boolean; }) { const data = node.data; const nodeLabel = data.metadata.name || '未命名节点'; @@ -56,8 +106,8 @@ function TreeRow({ return (
{ @@ -88,23 +138,53 @@ function TreeRow({ /> ) : null} - {nodeLabel} - - {data.components.length} + + {nodeLabel} - + + + + +
); } @@ -127,11 +207,15 @@ export function UiTreePanel({ const treeApiRef = useRef | undefined>(undefined); const treeContainerRef = useRef(null); const [treeHeight, setTreeHeight] = useState(0); + const [treeWidth, setTreeWidth] = useState(0); const [contextMenu, setContextMenu] = useState<{ nodeId: NodeId; x: number; y: number; } | null>(null); + const [pendingDelete, setPendingDelete] = useState< + (UiNodeDeleteRequest & { treeId: UIDesignImageId }) | null + >(null); useEffect(() => { if (!focusRequest) return; @@ -150,6 +234,41 @@ export function UiTreePanel({ return () => resizeObserver.disconnect(); }, []); + const treeRows = useMemo(() => { + if (!root) return []; + const rows: Array<{ node: UiNode; depth: number }> = []; + const visit = (node: UiNode, depth: number) => { + rows.push({ node, depth }); + node.children.forEach((child) => visit(child, depth + 1)); + }; + visit(root, 0); + return rows; + }, [root]); + + const treeContentWidth = useMemo( + () => + treeRows.reduce((width, { node, depth }) => { + const labelWidth = Math.max( + 56, + (node.metadata.name || '未命名节点').length * 8, + ); + return Math.max(width, 16 + depth * 16 + 24 + labelWidth + 42 + 52); + }, 0), + [treeRows], + ); + + useEffect(() => { + const container = treeContainerRef.current; + if (!container) return; + const updateWidth = () => { + setTreeWidth(Math.max(treeContentWidth, container.clientWidth)); + }; + updateWidth(); + const resizeObserver = new ResizeObserver(updateWidth); + resizeObserver.observe(container); + return () => resizeObserver.disconnect(); + }, [treeContentWidth]); + const handleMove: MoveHandler = ({ dragIds, parentId, @@ -184,21 +303,55 @@ export function UiTreePanel({ const pageRootIds = new Set(root?.children.map((child) => child.id) ?? []); const contextTreeId = contextMenu ? treeIdForNode(contextMenu.nodeId) : null; + const requestDelete = (nodeId: NodeId) => { + if (!root || isLocked || nodeId === root.id || pageRootIds.has(nodeId)) { + return; + } + const treeId = treeIdForNode(nodeId); + const node = findUiNode(root, nodeId); + if (!treeId || !node) return; + const descendantCount = countUiNodeDescendants(node); + if (descendantCount === 0) { + onDeleteNode(treeId, nodeId); + return; + } + setPendingDelete({ + treeId, + nodeId, + nodeLabel: node.metadata.name || '未命名节点', + descendantCount, + }); + }; + + const confirmDelete = (nodeId: NodeId) => { + if (!pendingDelete || isLocked || pendingDelete.nodeId !== nodeId) { + return; + } + const { treeId } = pendingDelete; + setPendingDelete(null); + onDeleteNode(treeId, nodeId); + }; + return (
event.preventDefault()} > -
+
{root && treeHeight > 0 ? ( ref={treeApiRef} data={[root]} - width="100%" + width={treeWidth > 0 ? treeWidth : '100%'} + outerElementType={TreeListOuter} height={treeHeight} rowHeight={34} indent={16} openByDefault + renderRow={TreeRowContainer} selection={selectedNodeId ?? undefined} onSelect={(nodes) => { const selected = nodes[0]?.data; @@ -224,6 +377,12 @@ export function UiTreePanel({ }} isNodeVisible={isNodePreviewVisible} onToggleNodeVisibility={onToggleNodeVisibility} + onRequestDelete={requestDelete} + canDelete={ + props.node.data.id !== root.id && + !pageRootIds.has(props.node.data.id) + } + deleteDisabled={isLocked} onOpenContextMenu={(event, nodeId) => nodeId === root.id ? undefined @@ -252,9 +411,15 @@ export function UiTreePanel({ onClose={() => setContextMenu(null)} onInsertChild={(nodeId) => onInsertNode(contextTreeId, nodeId)} onInsertSibling={(nodeId) => onInsertNodeAfter(contextTreeId, nodeId)} - onDelete={(nodeId) => onDeleteNode(contextTreeId, nodeId)} + onDelete={requestDelete} /> ) : null} + setPendingDelete(null)} + onConfirm={confirmDelete} + disabled={isLocked} + />
); } diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowCompletionModal.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowCompletionModal.tsx new file mode 100644 index 000000000..fbda3f214 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowCompletionModal.tsx @@ -0,0 +1,40 @@ +import { ThemedModal } from '../../../components/modal/ThemedModal'; +import type { WorkflowCompletionNotice } from './workflowCompletionNotice'; +import { workflowStepLabel } from './workflowCompletionNotice'; + +export function WorkflowCompletionModal({ + notice, + onClose, +}: { + notice: WorkflowCompletionNotice | null; + onClose: () => void; +}) { + if (!notice) return null; + const stepLabel = workflowStepLabel(notice.step); + const outcomeLabel = notice.outcome === 'success' ? '完成' : '失败'; + return ( + +

+ {stepLabel} + {outcomeLabel} +

+

+ {notice.message} +

+
+ +
+
+ ); +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx index 75a1efce3..c923ca875 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx @@ -1,4 +1,6 @@ import { + CANVAS_ZOOM_IN_FACTOR, + CANVAS_ZOOM_OUT_FACTOR, type CanvasViewport, createPanDragState, type DragState, @@ -28,8 +30,14 @@ import type { Node } from '../../../../features/ui-editor/types/Node'; import type { NodeId } from '../../../../features/ui-editor/types/NodeId'; import type { UiEditorCanvasProjection } from '../../useUiEditorPage'; import { UiNodeContextMenu } from '../UiNodeContextMenu'; +import { + handlePreviewZoomKeyDown, + isPreviewZoomInteractiveTarget, + previewZoomUsesMetaModifier, +} from './previewZoomKeyboard'; import { type UiEditorRenderMode, UiTreeRenderer } from './UiTreeRenderer'; import { useNodeTransformInteraction } from './useNodeTransformInteraction'; +import { ZoomPercentageInput } from './ZoomPercentageInput'; export function PreviewWorkspace({ canvas, @@ -41,6 +49,8 @@ export function PreviewWorkspace({ const viewportRef = useRef({ x: 0, y: 0, scale: 0.5 }); const handledFocusRequestIdRef = useRef(null); const panRef = useRef | null>(null); + const previewFocusedRef = useRef(false); + const previewHoveredRef = useRef(false); const [viewport, setViewportState] = useState( viewportRef.current, ); @@ -150,6 +160,34 @@ export function PreviewWorkspace({ ); }, [logicalSize, setViewport]); + const scaleViewportFromCenter = useCallback( + (nextScale: number) => { + const element = viewportElementRef.current; + const width = element?.clientWidth || canvasSize.width; + const height = element?.clientHeight || canvasSize.height; + setViewport( + scaleViewportFromScreenPoint({ + viewport: viewportRef.current, + nextScale, + screenPoint: { x: width / 2, y: height / 2 }, + }), + ); + }, + [canvasSize.height, canvasSize.width, setViewport], + ); + + const resetToActualSize = useCallback(() => { + scaleViewportFromCenter(1); + }, [scaleViewportFromCenter]); + + const zoomIn = useCallback(() => { + scaleViewportFromCenter(viewportRef.current.scale * CANVAS_ZOOM_IN_FACTOR); + }, [scaleViewportFromCenter]); + + const zoomOut = useCallback(() => { + scaleViewportFromCenter(viewportRef.current.scale * CANVAS_ZOOM_OUT_FACTOR); + }, [scaleViewportFromCenter]); + useEffect(() => { const element = viewportElementRef.current; if (!element) return; @@ -224,6 +262,9 @@ export function PreviewWorkspace({ ), ); }; + const usesMetaModifier = previewZoomUsesMetaModifier( + window.navigator.platform, + ); const onKeyDown = (event: KeyboardEvent) => { if ( event.code === 'Space' && @@ -232,23 +273,21 @@ export function PreviewWorkspace({ ) { setSpaceHeld(true); } - if (!event.ctrlKey && !event.metaKey) return; - if (event.key === '0') { - event.preventDefault(); - fitToCanvas(); - } else if (event.key === '1' && logicalSize) { - event.preventDefault(); - const element = viewportElementRef.current; - const width = element?.clientWidth ?? 900; - const height = element?.clientHeight ?? 640; - setViewport( - scaleViewportFromScreenPoint({ - viewport: viewportRef.current, - nextScale: 1, - screenPoint: { x: width / 2, y: height / 2 }, - }), - ); - } + handlePreviewZoomKeyDown( + event, + { + hasZoomableViewport: logicalSize !== null, + isFocused: previewFocusedRef.current, + isHovered: previewHoveredRef.current, + usesMetaModifier, + }, + { + fit: fitToCanvas, + resetToActualSize, + zoomIn, + zoomOut, + }, + ); }; const onKeyUp = (event: KeyboardEvent) => { if (event.code === 'Space') setSpaceHeld(false); @@ -262,9 +301,12 @@ export function PreviewWorkspace({ window.removeEventListener('keyup', onKeyUp); window.removeEventListener('blur', onWindowBlur); }; - }, [fitToCanvas, logicalSize, setViewport]); + }, [fitToCanvas, logicalSize, resetToActualSize, zoomIn, zoomOut]); const handlePointerDown = (event: ReactPointerEvent) => { + if (event.button === 0 && !isPreviewZoomInteractiveTarget(event.target)) { + event.currentTarget.focus({ preventScroll: true }); + } if (event.button === 1 || (event.button === 0 && spaceHeld)) { event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); @@ -371,7 +413,27 @@ export function PreviewWorkspace({
{ + previewHoveredRef.current = true; + }} + onPointerLeave={() => { + previewHoveredRef.current = false; + }} + onFocus={() => { + previewFocusedRef.current = true; + }} + onBlur={(event) => { + if ( + !(event.relatedTarget instanceof Node) || + !event.currentTarget.contains(event.relatedTarget) + ) { + previewFocusedRef.current = false; + } + }} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} @@ -448,21 +510,20 @@ export function PreviewWorkspace({ { - const element = viewportElementRef.current; - const width = element?.clientWidth ?? canvasSize.width; - const height = element?.clientHeight ?? canvasSize.height; - setViewport( - scaleViewportFromScreenPoint({ - viewport: viewportRef.current, - nextScale, - screenPoint: { x: width / 2, y: height / 2 }, - }), - ); - }} + onScaleFromCenter={scaleViewportFromCenter} > {(actions) => ( -
+
{ + if ( + event.target instanceof Element && + event.target.closest('button') + ) { + event.preventDefault(); + } + }} + > + + actions.zoomToDisplayScale(Number(event.target.value) / 100) + } + /> + + actions.zoomToDisplayScale(percent / 100) + } + />
+ setReturnConfirmOpen(false)} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/uiEditorKeyboardShortcuts.ts b/apps/ai-game-creator-shell/src/view/ui-editor/uiEditorKeyboardShortcuts.ts new file mode 100644 index 000000000..90657f5b4 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/uiEditorKeyboardShortcuts.ts @@ -0,0 +1,82 @@ +import type { NodeId } from '../../features/ui-editor/types/NodeId'; +import type { UIDesignImageId } from '../../features/ui-editor/types/UIDesignImageId'; + +type DeleteResult = { ok: boolean } | undefined; + +export type UiEditorKeyboardActions = { + selectedNodeId: NodeId | null; + activeImageId: UIDesignImageId | null; + deleteNode: (nodeId: NodeId, treeId: UIDesignImageId) => DeleteResult; + historyUndo: () => boolean; + historyRedo: () => boolean; +}; + +function isEditableTarget(target: EventTarget | null) { + const element = target instanceof HTMLElement ? target : null; + return Boolean( + element?.isContentEditable || + element?.closest('input, textarea, select, [contenteditable="true"]'), + ); +} + +function isInteractiveTarget(target: EventTarget | null) { + const element = target instanceof Element ? target : null; + return Boolean( + (target instanceof HTMLElement && target.isContentEditable) || + element?.closest( + 'button, a, input, textarea, select, [contenteditable="true"], [role="button"], [role="dialog"], [aria-modal="true"]', + ), + ); +} + +function isModalTarget(target: EventTarget | null) { + const element = target instanceof Element ? target : null; + return Boolean(element?.closest('[role="dialog"], [aria-modal="true"]')); +} + +export function handleUiEditorKeyDown( + event: KeyboardEvent, + actions: UiEditorKeyboardActions, +) { + if (isModalTarget(event.target)) return; + + if ( + (event.key === 'Delete' || event.key === 'Backspace') && + !event.repeat && + !event.defaultPrevented && + !event.ctrlKey && + !event.metaKey && + !event.altKey && + !event.shiftKey && + !isInteractiveTarget(event.target) + ) { + if (actions.selectedNodeId && actions.activeImageId) { + const result = actions.deleteNode( + actions.selectedNodeId, + actions.activeImageId, + ); + if (result?.ok) { + event.preventDefault(); + event.stopPropagation(); + return; + } + } + } + if ( + event.repeat || + event.defaultPrevented || + isEditableTarget(event.target) || + (!event.ctrlKey && !event.metaKey) + ) { + return; + } + const isUndo = event.key.toLowerCase() === 'z' && !event.shiftKey; + const isRedo = + (event.key.toLowerCase() === 'z' && event.shiftKey) || + (event.ctrlKey && event.key.toLowerCase() === 'y'); + if (isUndo && actions.historyUndo()) { + event.preventDefault(); + } else if (isRedo && actions.historyRedo()) { + event.preventDefault(); + } +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts index b2c98484b..a580a652d 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts @@ -10,7 +10,10 @@ import { } from '../../features/ui-editor/importAdapter'; import { applyMergeResult } from '../../features/ui-editor/merge'; import { applyRecognitionResult } from '../../features/ui-editor/recognition'; -import type { StageStatusField } from '../../features/ui-editor/stageStatusOverview'; +import { + getStageStatusOverview, + type StageStatusField, +} from '../../features/ui-editor/stageStatusOverview'; import { collectUiNodeIds } from '../../features/ui-editor/treeUtils'; import type { BindingDTO } from '../../features/ui-editor/types/BindingDTO'; import type { ChildrenDisplayMode } from '../../features/ui-editor/types/ChildrenDisplayMode'; @@ -54,6 +57,10 @@ import { prerequisiteIssuesForStep, type UiEditorPrerequisiteIssue, } from './components/WorkflowChecks'; +import { + appendWorkflowCheckPrompt, + type WorkflowCompletionNotice, +} from './components/workflowCompletionNotice'; import { type PendingResourceRemoval, removalHasDownstreamReferences, @@ -175,6 +182,8 @@ export function useUiEditorSession( initialFurthestStepIndex = 0, ) { const editor = useUiEditorState(EMPTY_UI_EDITOR_STATE); + const editorDeleteNode = editor.deleteNode; + const editorUiTrees = editor.state.ui_trees; const replaceEditorState = editor.replaceState; const [isLoading, setIsLoading] = useState(Boolean(resourceId)); const [loadError, setLoadError] = useState(null); @@ -246,6 +255,19 @@ export function useUiEditorSession( const [hasSuggested, setHasSuggested] = useState(false); const [hasRecognized, setHasRecognized] = useState(false); const [hasBound, setHasBound] = useState(false); + const [completionNotice, setCompletionNotice] = + useState(null); + + function reportWorkflowCompletion( + step: UiEditorStepId, + outcome: WorkflowCompletionNotice['outcome'], + rawMessage: string, + setStatus: (message: string) => void, + ) { + const message = appendWorkflowCheckPrompt(rawMessage); + setStatus(message); + setCompletionNotice({ step, outcome, message }); + } useEffect(() => { setActiveStep(initialStep); @@ -437,6 +459,9 @@ export function useUiEditorSession( ); return next.size === current.size ? current : next; }); + setSelectedNodeId((current) => + current && !validNodeIds.has(current) ? null : current, + ); }, [editor.state.ui_trees]); const isNodePreviewVisible = useCallback( @@ -879,16 +904,26 @@ export function useUiEditorSession( return result; } - function deleteNode(nodeId: NodeId, treeId = activeImageId) { - if (!treeId) return; - const result = editor.deleteNode(treeId, nodeId); - if (!result.ok) { - setStatus('无法删除该节点。'); + const deleteNode = useCallback( + (nodeId: NodeId, treeId = activeImageId) => { + if (!treeId) return; + const tree = editorUiTrees.find( + (candidate) => candidate.src_ui_design === treeId, + ); + const location = tree ? findUiNodeLocation(tree.root, nodeId) : null; + const deletedNodeIds = location ? collectUiNodeIds(location.node) : null; + const result = editorDeleteNode(treeId, nodeId); + if (!result.ok) { + setStatus('无法删除该节点。'); + return result; + } + if (selectedNodeId !== null && deletedNodeIds?.has(selectedNodeId)) { + setSelectedNodeId(null); + } return result; - } - if (selectedNodeId === nodeId) setSelectedNodeId(null); - return result; - } + }, + [activeImageId, editorDeleteNode, editorUiTrees, selectedNodeId], + ); function selectSprite(id: SpriteAssetId) { setSelectedNodeId(null); @@ -939,6 +974,7 @@ export function useUiEditorSession( async function suggestUiDesignSemantics() { if (isSuggesting || isWorkflowBusy) return; + setCompletionNotice(null); setSuggestionStatus(null); setIsSuggesting(true); try { @@ -949,11 +985,19 @@ export function useUiEditorSession( ); editor.replaceState(applyUiDesignSuggestions(snapshot, suggestions)); setHasSuggested(true); - setSuggestionStatus(`已应用 ${suggestions.length} 条参考图语义建议。`); + reportWorkflowCompletion( + 'reference-analysis', + 'success', + `参考图分析完成:已应用 ${suggestions.length} 条参考图语义建议`, + setSuggestionStatus, + ); }); } catch (cause) { - setSuggestionStatus( + reportWorkflowCompletion( + 'reference-analysis', + 'failure', cause instanceof Error ? cause.message : String(cause), + setSuggestionStatus, ); } finally { setIsSuggesting(false); @@ -962,6 +1006,7 @@ export function useUiEditorSession( async function recognizeUi() { if (isRecognizing || isWorkflowBusy) return; + setCompletionNotice(null); setRecognitionStatus(null); setIsRecognizing(true); try { @@ -970,14 +1015,27 @@ export function useUiEditorSession( projectPath, state: snapshot, }); - editor.replaceState(applyRecognitionResult(snapshot, result)); + const nextState = applyRecognitionResult(snapshot, result); + editor.replaceState(nextState); setHasRecognized(true); setSelectedNodeId(null); - setRecognitionStatus(`已替换 ${result.ui_trees.length} 棵界面树。`); + const overview = getStageStatusOverview( + nextState.ui_trees, + 'layout_status', + ); + reportWorkflowCompletion( + 'structure-recognition', + 'success', + `界面结构识别完成:已替换 ${result.ui_trees.length} 棵界面树,待检查 ${overview.needsAttention} 项(必须修复 ${overview.blocked} 项)`, + setRecognitionStatus, + ); }); } catch (cause) { - setRecognitionStatus( + reportWorkflowCompletion( + 'structure-recognition', + 'failure', cause instanceof Error ? cause.message : String(cause), + setRecognitionStatus, ); } finally { setIsRecognizing(false); @@ -1005,6 +1063,7 @@ export function useUiEditorSession( async function bindComponents() { if (isBinding || isWorkflowBusy) return; + setCompletionNotice(null); setBindingStatus(null); setIsBinding(true); try { @@ -1032,13 +1091,21 @@ export function useUiEditorSession( history: index < batches.length - 1 ? 'skip' : 'record', }); } - setBindingStatus( - `组件绑定完成(${batches.length}/${batches.length})。`, + reportWorkflowCompletion( + 'visual-binding', + 'success', + `视觉素材绑定完成:已处理 ${batches.length}/${batches.length} 个批次`, + setBindingStatus, ); setHasBound(true); }); } catch (cause) { - setBindingStatus(cause instanceof Error ? cause.message : String(cause)); + reportWorkflowCompletion( + 'visual-binding', + 'failure', + cause instanceof Error ? cause.message : String(cause), + setBindingStatus, + ); } finally { setIsBinding(false); } @@ -1303,6 +1370,7 @@ export function useUiEditorSession( isBinding, hasBound, bindingStatus, + completionNotice, bindComponents, requestStepChange, continueToNextStep: () => { @@ -1310,6 +1378,7 @@ export function useUiEditorSession( }, confirmStepChange, cancelStepChange: () => setPendingWorkflowStepChange(null), + dismissCompletionNotice: () => setCompletionNotice(null), }, dialogs: { projectPath, diff --git a/apps/ai-game-creator-shell/tests/ErrorReportDialog.test.tsx b/apps/ai-game-creator-shell/tests/ErrorReportDialog.test.tsx new file mode 100644 index 000000000..cf8e05694 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/ErrorReportDialog.test.tsx @@ -0,0 +1,81 @@ +/** @vitest-environment jsdom */ + +import { cleanup, render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mockState = vi.hoisted(() => ({ + getPending: vi.fn(), + readLogs: vi.fn(), +})); + +vi.mock('../src/services/errorReporting', () => ({ + ackClientErrorEventsWithRetry: vi.fn(), + getPendingClientErrorEvents: mockState.getPending, + getStableErrorReportSubmissionId: vi.fn(), + readApplicationDiagnosticLogs: mockState.readLogs, + submitErrorReportBatch: vi.fn(), +})); + +vi.mock('../src/components/modal/ThemedModal', () => ({ + ThemedModal: ({ open, children }: { open: boolean; children: ReactNode }) => + open ?
{children}
: null, +})); + +import { ErrorReportDialog } from '../src/components/error-report/ErrorReportDialog'; + +const event = { + eventId: 'rust-error-1', + fingerprint: 'fingerprint-1', + source: 'test', + message: '打开面板时读取的错误', + occurredAt: '1', + count: 1, +}; + +describe('ErrorReportDialog', () => { + afterEach(() => { + cleanup(); + mockState.getPending.mockReset(); + mockState.readLogs.mockReset(); + }); + + it('只在面板打开时读取最新错误快照', async () => { + mockState.getPending.mockResolvedValue([event]); + mockState.readLogs.mockResolvedValue([]); + + const view = render(); + expect(mockState.getPending).not.toHaveBeenCalled(); + + view.rerender(); + + expect(await screen.findByText(event.message)).toBeTruthy(); + expect(mockState.getPending).toHaveBeenCalledTimes(1); + }); + + it('读取错误事件失败时显示不可用状态而不是空列表', async () => { + mockState.getPending.mockRejectedValue(new Error('读取失败')); + mockState.readLogs.mockResolvedValue([]); + + render(); + + expect( + await screen.findByText('错误事件暂不可用,请关闭后重试。'), + ).toBeTruthy(); + expect(screen.queryByText('当前没有待报告的错误。')).toBeNull(); + }); + + it('最新快照读取失败时使用打开通知时的事件快照', async () => { + mockState.getPending.mockRejectedValue(new Error('瞬时读取失败')); + mockState.readLogs.mockResolvedValue([]); + + render( + , + ); + + expect(await screen.findByText(event.message)).toBeTruthy(); + expect( + screen.getByText('最新错误读取失败,已使用打开通知时的快照'), + ).toBeTruthy(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/ErrorReportNotice.test.tsx b/apps/ai-game-creator-shell/tests/ErrorReportNotice.test.tsx index 37e80b642..1fbe7b85d 100644 --- a/apps/ai-game-creator-shell/tests/ErrorReportNotice.test.tsx +++ b/apps/ai-game-creator-shell/tests/ErrorReportNotice.test.tsx @@ -1,6 +1,12 @@ /** @vitest-environment jsdom */ -import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; const mockState = vi.hoisted(() => ({ @@ -8,6 +14,10 @@ const mockState = vi.hoisted(() => ({ listeners: [] as Array<() => void>, })); +const dialogState = vi.hoisted(() => ({ + fallbackEvents: [] as (typeof event)[], +})); + vi.mock('../src/services/errorReporting', () => ({ getPendingClientErrorEvents: mockState.getPending, subscribeClientErrorEvents: (listener: () => void) => { @@ -21,7 +31,16 @@ vi.mock('../src/services/errorReporting', () => ({ })); vi.mock('../src/components/error-report/ErrorReportDialog', () => ({ - ErrorReportDialog: () => null, + ErrorReportDialog: ({ + fallbackEvents, + open, + }: { + fallbackEvents?: (typeof event)[]; + open: boolean; + }) => { + dialogState.fallbackEvents = fallbackEvents ?? []; + return open ?
: null; + }, })); import { ErrorReportNotice } from '../src/components/error-report/ErrorReportNotice'; @@ -47,7 +66,7 @@ describe('ErrorReportNotice', () => { render(); - expect(await screen.findByText('发现 1 个问题')).toBeTruthy(); + expect(await screen.findByText('发现问题')).toBeTruthy(); expect(mockState.getPending).toHaveBeenCalledTimes(1); }); @@ -61,7 +80,19 @@ describe('ErrorReportNotice', () => { mockState.listeners[0]?.(); - expect(await screen.findByText('发现 1 个问题')).toBeTruthy(); + expect(await screen.findByText('发现问题')).toBeTruthy(); expect(mockState.getPending).toHaveBeenCalledTimes(2); }); + + it('打开报告时保留通知快照作为对话框 fallback', async () => { + mockState.getPending.mockResolvedValue([event]); + + render(); + await screen.findByText('发现问题'); + + fireEvent.click(screen.getByRole('button', { name: '查看并报告' })); + + expect(await screen.findByTestId('error-report-dialog')).toBeTruthy(); + expect(dialogState.fallbackEvents).toEqual([event]); + }); }); diff --git a/apps/ai-game-creator-shell/tests/ZoomPercentageInput.test.tsx b/apps/ai-game-creator-shell/tests/ZoomPercentageInput.test.tsx new file mode 100644 index 000000000..f02a71e3d --- /dev/null +++ b/apps/ai-game-creator-shell/tests/ZoomPercentageInput.test.tsx @@ -0,0 +1,67 @@ +// @vitest-environment jsdom + +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { ZoomPercentageInput } from '../src/view/ui-editor/components/preview/ZoomPercentageInput'; + +describe('ZoomPercentageInput', () => { + it('edits the displayed percentage and commits on blur without fitting', () => { + const onCommit = vi.fn(); + render(); + + const input = screen.getByRole('spinbutton', { name: /画布缩放/ }); + expect((input as HTMLInputElement).value).toBe('50'); + + fireEvent.focus(input); + fireEvent.change(input, { target: { value: '125' } }); + expect(onCommit).not.toHaveBeenCalled(); + fireEvent.blur(input); + + expect(onCommit).toHaveBeenCalledWith(125); + expect((input as HTMLInputElement).value).toBe('125'); + }); + + it.each([ + { value: '0', expected: 25 }, + { value: '999', expected: 200 }, + ])('clamps $value to $expected on blur', ({ value, expected }) => { + const onCommit = vi.fn(); + render(); + + const input = screen.getByRole('spinbutton', { name: /画布缩放/ }); + fireEvent.focus(input); + fireEvent.change(input, { target: { value } }); + fireEvent.blur(input); + + expect(onCommit).toHaveBeenCalledWith(expected); + expect((input as HTMLInputElement).value).toBe(String(expected)); + }); + + it('restores the current percentage when the draft is invalid', () => { + const onCommit = vi.fn(); + render(); + + const input = screen.getByRole('spinbutton', { name: /画布缩放/ }); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: '' } }); + fireEvent.blur(input); + + expect(onCommit).not.toHaveBeenCalled(); + expect((input as HTMLInputElement).value).toBe('80'); + }); + + it('tracks viewport updates while not editing', () => { + const onCommit = vi.fn(); + const view = render( + , + ); + const input = screen.getByRole('spinbutton', { name: /画布缩放/ }); + + view.rerender( + , + ); + + expect((input as HTMLInputElement).value).toBe('140'); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts index e40690510..6772d6555 100644 --- a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts +++ b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts @@ -695,6 +695,13 @@ describe('Agent Runtime Provider 状态投影', () => { true, ), ).toBe('陶泥儿智能创作 用量已达上限,请检查账户额度后重试'); + expect( + projectRuntimeVisibleError( + 'codex-app-server-error:request-too-large', + '陶泥儿智能创作', + true, + ), + ).toBe('陶泥儿智能创作 模型请求体过大,请减少参考图或上下文后重试'); expect( projectRuntimeVisibleError( 'codex-app-server-terminal-unknown: 等待 turn/completed 超时', diff --git a/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx b/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx new file mode 100644 index 000000000..908086f1d --- /dev/null +++ b/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx @@ -0,0 +1,203 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Node as UiNode } from '../src/features/ui-editor/types/Node'; +import type { UIDesignImage } from '../src/features/ui-editor/types/UIDesignImage'; +import { PreviewWorkspace } from '../src/view/ui-editor/components/preview/PreviewWorkspace'; +import type { UiEditorCanvasProjection } from '../src/view/ui-editor/useUiEditorPage'; + +class TestResizeObserver { + observe() {} + disconnect() {} + unobserve() {} +} + +const root: UiNode = { + id: 'root', + layout: { + transform: { + anchor_min: [0, 0], + anchor_max: [1, 1], + offset_min: [0, 0], + offset_max: [0, 0], + }, + custom_minimum_size: [0, 0], + size_flags_horizontal: 1, + size_flags_vertical: 1, + size_flags_stretch_ratio: 1, + container: 'None', + }, + metadata: { + name: '根节点', + description: '', + layout_status: 'NoProblem', + components_status: 'NoProblem', + allow_llm_edit_layout: true, + allow_llm_edit_component: true, + source: 'System', + }, + components: [], + children_display_mode: 'Stack', + children: [], +}; + +const activeImage: UIDesignImage = { + metadata: { + name: '测试界面', + description: '', + role: null, + slave_to: null, + }, + path: 'assets/page.png', + pixel_size: [1200, 800], + pixels_per_unit: 1, +}; + +function createCanvas( + overrides: Partial = {}, +): UiEditorCanvasProjection { + return { + isLocked: false, + activeImage, + activeImageId: 'page-1', + previewUrls: { 'page-1': 'data:image/png;base64,' }, + images: { 'page-1': activeImage }, + sprites: {}, + fontFaces: {}, + tree: { src_ui_design: 'page-1', root }, + selectedNode: null, + selectedNodeId: null, + keepChildrenUnchanged: false, + hiddenNodeIds: new Set(), + focusRequest: null, + status: null, + isNodePreviewVisible: vi.fn(() => true), + toggleNodePreviewVisibility: vi.fn(), + selectExclusiveChild: vi.fn(), + selectNode: vi.fn(), + clearNodeSelection: vi.fn(), + updateNodeTransform: vi.fn(), + insertNode: vi.fn(), + insertNodeAfter: vi.fn(), + deleteNode: vi.fn(), + openClearDialog: vi.fn(), + ...overrides, + }; +} + +function renderedScale(container: HTMLElement) { + const transform = ( + container.querySelector('.genarrative-image-canvas__world') as HTMLElement + ).style.transform; + const scale = /scale\(([^)]+)\)/.exec(transform)?.[1]; + if (!scale) throw new Error(`无法从 ${transform} 读取缩放值`); + return Number(scale); +} + +beforeEach(() => { + vi.stubGlobal('ResizeObserver', TestResizeObserver); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('PreviewWorkspace quick zoom', () => { + it('uses the toolbar zoom step while the preview is hovered', () => { + const rendered = render(); + const preview = screen.getByRole('region', { name: 'UI 预览画布' }); + const before = renderedScale(rendered.container); + + fireEvent.pointerEnter(preview); + const wasNotCancelled = fireEvent.keyDown(window, { + key: '=', + code: 'Equal', + ctrlKey: true, + cancelable: true, + }); + + expect(wasNotCancelled).toBe(false); + expect(renderedScale(rendered.container)).toBeCloseTo(before * 1.16); + }); + + it('requires hover or focus and ignores interactive targets', () => { + const rendered = render(); + const preview = screen.getByRole('region', { name: 'UI 预览画布' }); + const zoomButton = screen.getByRole('button', { name: '放大画布' }); + const initial = renderedScale(rendered.container); + + expect( + fireEvent.keyDown(window, { + key: '=', + ctrlKey: true, + cancelable: true, + }), + ).toBe(true); + expect(renderedScale(rendered.container)).toBe(initial); + + fireEvent.pointerEnter(preview); + expect( + fireEvent.keyDown(zoomButton, { + key: '=', + ctrlKey: true, + cancelable: true, + }), + ).toBe(true); + expect(renderedScale(rendered.container)).toBe(initial); + + fireEvent.pointerLeave(preview); + fireEvent.focus(preview); + fireEvent.keyDown(window, { + key: '-', + code: 'Minus', + ctrlKey: true, + cancelable: true, + }); + expect(renderedScale(rendered.container)).toBeCloseTo(initial * 0.86); + }); + + it('keeps actual-size and fit shortcuts within the preview scope', () => { + const rendered = render(); + const preview = screen.getByRole('region', { name: 'UI 预览画布' }); + const fitted = renderedScale(rendered.container); + + fireEvent.focus(preview); + fireEvent.keyDown(window, { + key: '1', + ctrlKey: true, + cancelable: true, + }); + expect(renderedScale(rendered.container)).toBe(1); + + fireEvent.keyDown(window, { + key: '0', + ctrlKey: true, + cancelable: true, + }); + expect(renderedScale(rendered.container)).toBe(fitted); + }); + + it('leaves browser zoom untouched when the preview has no content', () => { + render( + , + ); + + expect( + fireEvent.keyDown(window, { + key: '=', + ctrlKey: true, + cancelable: true, + }), + ).toBe(true); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/previewZoomKeyboard.test.ts b/apps/ai-game-creator-shell/tests/previewZoomKeyboard.test.ts new file mode 100644 index 000000000..5ef72b7db --- /dev/null +++ b/apps/ai-game-creator-shell/tests/previewZoomKeyboard.test.ts @@ -0,0 +1,176 @@ +// @vitest-environment jsdom + +import { fireEvent } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + handlePreviewZoomKeyDown, + type PreviewZoomKeyboardActions, + type PreviewZoomKeyboardContext, + previewZoomUsesMetaModifier, +} from '../src/view/ui-editor/components/preview/previewZoomKeyboard'; + +function createActions(): PreviewZoomKeyboardActions { + return { + fit: vi.fn(), + resetToActualSize: vi.fn(), + zoomIn: vi.fn(), + zoomOut: vi.fn(), + }; +} + +function dispatchShortcut({ + actions = createActions(), + context = {}, + event, + target, +}: { + actions?: PreviewZoomKeyboardActions; + context?: Partial; + event: KeyboardEventInit; + target?: HTMLElement; +}) { + const resolvedTarget = target ?? document.createElement('div'); + document.body.append(resolvedTarget); + const handler = vi.fn((keyboardEvent: KeyboardEvent) => + handlePreviewZoomKeyDown( + keyboardEvent, + { + hasZoomableViewport: true, + isFocused: false, + isHovered: true, + usesMetaModifier: false, + ...context, + }, + actions, + ), + ); + resolvedTarget.addEventListener('keydown', handler); + fireEvent.keyDown(resolvedTarget, event); + const keyboardEvent = handler.mock.calls[0]?.[0]; + return { actions, handler, keyboardEvent }; +} + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe('preview zoom keyboard shortcuts', () => { + it.each([ + { key: '+', code: 'Equal' }, + { key: '=', code: 'Equal' }, + { key: '+', code: 'NumpadAdd' }, + ])('zooms in for the supported main and numpad keys (%o)', (event) => { + const { actions, keyboardEvent } = dispatchShortcut({ + event: { ...event, ctrlKey: true, cancelable: true, repeat: true }, + }); + + expect(actions.zoomIn).toHaveBeenCalledTimes(1); + expect(keyboardEvent?.defaultPrevented).toBe(true); + }); + + it.each([ + { key: '-', code: 'Minus' }, + { key: '-', code: 'NumpadSubtract' }, + ])('zooms out for the supported main and numpad keys (%o)', (event) => { + const { actions, keyboardEvent } = dispatchShortcut({ + event: { ...event, ctrlKey: true, cancelable: true }, + }); + + expect(actions.zoomOut).toHaveBeenCalledTimes(1); + expect(keyboardEvent?.defaultPrevented).toBe(true); + }); + + it('does not treat underscore as zoom out', () => { + const { actions, keyboardEvent } = dispatchShortcut({ + event: { + key: '_', + code: 'Minus', + ctrlKey: true, + shiftKey: true, + cancelable: true, + }, + }); + + expect(actions.zoomOut).not.toHaveBeenCalled(); + expect(keyboardEvent?.defaultPrevented).toBe(false); + }); + + it.each([ + { key: '0', action: 'fit' as const }, + { key: '1', action: 'resetToActualSize' as const }, + ])('keeps the existing $key shortcut', ({ key, action }) => { + const { actions } = dispatchShortcut({ + event: { key, ctrlKey: true, cancelable: true }, + }); + + expect(actions[action]).toHaveBeenCalledTimes(1); + }); + + it('uses Cmd on Apple platforms and Ctrl elsewhere', () => { + expect(previewZoomUsesMetaModifier('MacIntel')).toBe(true); + expect(previewZoomUsesMetaModifier('iPad')).toBe(true); + expect(previewZoomUsesMetaModifier('Win32')).toBe(false); + + const appleActions = createActions(); + dispatchShortcut({ + actions: appleActions, + context: { usesMetaModifier: true }, + event: { key: '=', ctrlKey: true, cancelable: true }, + }); + expect(appleActions.zoomIn).not.toHaveBeenCalled(); + dispatchShortcut({ + actions: appleActions, + context: { usesMetaModifier: true }, + event: { key: '=', metaKey: true, cancelable: true }, + }); + expect(appleActions.zoomIn).toHaveBeenCalledTimes(1); + }); + + it.each([ + { isHovered: false, isFocused: false, hasZoomableViewport: true }, + { isHovered: true, isFocused: false, hasZoomableViewport: false }, + ])('leaves inactive preview shortcuts to the host (%o)', (context) => { + const { actions, keyboardEvent } = dispatchShortcut({ + context, + event: { key: '=', ctrlKey: true, cancelable: true }, + }); + + expect(actions.zoomIn).not.toHaveBeenCalled(); + expect(keyboardEvent?.defaultPrevented).toBe(false); + }); + + it('works while the preview is focused without being hovered', () => { + const { actions } = dispatchShortcut({ + context: { isFocused: true, isHovered: false }, + event: { key: '=', ctrlKey: true, cancelable: true }, + }); + + expect(actions.zoomIn).toHaveBeenCalledTimes(1); + }); + + it.each(['input', 'button', 'a'])('ignores interactive %s targets', (tag) => { + const target = document.createElement(tag); + if (target instanceof HTMLAnchorElement) target.href = '#preview'; + const { actions, keyboardEvent } = dispatchShortcut({ + target, + event: { key: '=', ctrlKey: true, cancelable: true }, + }); + + expect(actions.zoomIn).not.toHaveBeenCalled(); + expect(keyboardEvent?.defaultPrevented).toBe(false); + }); + + it('does not override an event already handled by another control', () => { + const target = document.createElement('div'); + target.addEventListener('keydown', (event) => event.preventDefault(), { + once: true, + }); + const { actions } = dispatchShortcut({ + target, + event: { key: '=', ctrlKey: true, cancelable: true }, + }); + + expect(actions.zoomIn).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/uiEditorKeyboardShortcuts.test.ts b/apps/ai-game-creator-shell/tests/uiEditorKeyboardShortcuts.test.ts new file mode 100644 index 000000000..f21c284db --- /dev/null +++ b/apps/ai-game-creator-shell/tests/uiEditorKeyboardShortcuts.test.ts @@ -0,0 +1,109 @@ +// @vitest-environment jsdom + +import { fireEvent } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { handleUiEditorKeyDown } from '../src/view/ui-editor/uiEditorKeyboardShortcuts'; + +describe('ui editor keyboard shortcuts', () => { + afterEach(() => { + document.body.replaceChildren(); + }); + + it.each([ + { key: 'z', ctrlKey: true }, + { key: 'z', ctrlKey: true, shiftKey: true }, + { key: 'y', ctrlKey: true }, + ])('does not change history from inside a modal (%o)', (shortcut) => { + const dialog = document.createElement('div'); + dialog.setAttribute('role', 'dialog'); + const button = document.createElement('button'); + dialog.append(button); + document.body.append(dialog); + const historyUndo = vi.fn(() => true); + const historyRedo = vi.fn(() => true); + const listener = (event: KeyboardEvent) => + handleUiEditorKeyDown(event, { + selectedNodeId: null, + activeImageId: null, + deleteNode: vi.fn(), + historyUndo, + historyRedo, + }); + window.addEventListener('keydown', listener); + + fireEvent.keyDown(button, shortcut); + + expect(historyUndo).not.toHaveBeenCalled(); + expect(historyRedo).not.toHaveBeenCalled(); + window.removeEventListener('keydown', listener); + }); + + it('keeps undo available from a non-modal button', () => { + const button = document.createElement('button'); + document.body.append(button); + const historyUndo = vi.fn(() => true); + const historyRedo = vi.fn(() => true); + const listener = (event: KeyboardEvent) => + handleUiEditorKeyDown(event, { + selectedNodeId: null, + activeImageId: null, + deleteNode: vi.fn(), + historyUndo, + historyRedo, + }); + window.addEventListener('keydown', listener); + + fireEvent.keyDown(button, { key: 'z', ctrlKey: true }); + + expect(historyUndo).toHaveBeenCalledTimes(1); + window.removeEventListener('keydown', listener); + }); + + it.each(['Delete', 'Backspace'])( + 'does not delete a node when the zoom slider has focus (%s)', + (key) => { + const slider = document.createElement('input'); + slider.type = 'range'; + document.body.append(slider); + const deleteNode = vi.fn(() => ({ ok: true })); + const listener = (event: KeyboardEvent) => + handleUiEditorKeyDown(event, { + selectedNodeId: 'node-1', + activeImageId: 'page-1', + deleteNode, + historyUndo: vi.fn(() => true), + historyRedo: vi.fn(() => true), + }); + window.addEventListener('keydown', listener); + + fireEvent.keyDown(slider, { key }); + + expect(deleteNode).not.toHaveBeenCalled(); + window.removeEventListener('keydown', listener); + }, + ); + + it('keeps node deletion available after zoom button focus is released', () => { + const zoomControls = document.createElement('div'); + const zoomIn = document.createElement('button'); + zoomControls.append(zoomIn); + document.body.append(zoomControls); + const deleteNode = vi.fn(() => ({ ok: true })); + const listener = (event: KeyboardEvent) => + handleUiEditorKeyDown(event, { + selectedNodeId: 'node-1', + activeImageId: 'page-1', + deleteNode, + historyUndo: vi.fn(() => true), + historyRedo: vi.fn(() => true), + }); + window.addEventListener('keydown', listener); + + fireEvent.click(zoomIn); + fireEvent.keyDown(window, { key: 'Delete' }); + + expect(deleteNode).toHaveBeenCalledWith('node-1', 'page-1'); + window.removeEventListener('keydown', listener); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts index 6c21bc416..2c4206167 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts @@ -473,6 +473,75 @@ describe('UiEditorPage', () => { expect(result.current.canvas.hiddenNodeIds.size).toBe(0); }); + it('clears selection when deleting a node removes the selected descendant', async () => { + const { result } = await renderLoadedSession(stateWithPages(['page'])); + const rootId = 'page-root'; + let parentId: string | undefined; + let childId: string | undefined; + act(() => { + parentId = result.current.input.insertNode(rootId, 'page')?.value; + childId = result.current.input.insertNode(parentId!, 'page')?.value; + result.current.input.selectNode(childId!); + }); + + act(() => result.current.input.deleteNode(parentId!, 'page')); + + expect(result.current.canvas.selectedNodeId).toBeNull(); + expect(result.current.history.canUndo).toBe(true); + }); + + it('deletes the selected node from the page with Delete', async () => { + const state = stateWithPages(['page']); + state.ui_trees[0]!.root.children = [node('page-child')]; + const stateStore: IUiDesignStateStore = { + load: vi.fn().mockResolvedValue({ revision: 0, state }), + save: vi.fn(), + generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')), + }; + + render( + createElement(UiEditorPage, { + projectPath: '/tmp/ui-editor-delete-keyboard', + resourceId: 'ui-resource', + stateStore, + }), + ); + + const child = await screen.findByText('page-child'); + fireEvent.click(child); + fireEvent.keyDown(window, { key: 'Delete' }); + + await waitFor(() => expect(screen.queryByText('page-child')).toBeNull()); + }); + + it('does not delete a selected node when Delete originates inside a dialog', async () => { + const state = stateWithPages(['page']); + state.ui_trees[0]!.root.children = [node('page-child')]; + const stateStore: IUiDesignStateStore = { + load: vi.fn().mockResolvedValue({ revision: 0, state }), + save: vi.fn(), + generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')), + }; + + render( + createElement(UiEditorPage, { + projectPath: '/tmp/ui-editor-delete-dialog', + resourceId: 'ui-resource', + stateStore, + }), + ); + + const child = await screen.findByText('page-child'); + fireEvent.click(child); + const dialog = document.createElement('div'); + dialog.setAttribute('role', 'dialog'); + document.body.appendChild(dialog); + fireEvent.keyDown(dialog, { key: 'Delete' }); + + expect(screen.queryAllByText('page-child').length).toBeGreaterThan(0); + dialog.remove(); + }); + it('keeps Inspector status highlighting separate from node navigation', async () => { const { result } = await renderLoadedSession(stateWithPages(['page'])); const rootId = 'page-root'; diff --git a/apps/ai-game-creator-shell/tests/uiTreeUtils.test.ts b/apps/ai-game-creator-shell/tests/uiTreeUtils.test.ts new file mode 100644 index 000000000..196077400 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/uiTreeUtils.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { + countUiNodeDescendants, + findUiNode, +} from '../src/features/ui-editor/treeUtils'; +import type { Node } from '../src/features/ui-editor/types/Node'; + +function node(id: string, children: Node[] = []): Node { + return { + id, + layout: {} as Node['layout'], + metadata: {} as Node['metadata'], + components: [], + children_display_mode: 'Stack', + children, + }; +} + +describe('ui tree utilities', () => { + it('finds a nested node and counts all descendants', () => { + const nested = node('nested', [node('leaf')]); + const root = node('root', [node('page'), nested]); + + expect(findUiNode(root, 'nested')).toBe(nested); + expect(findUiNode(root, 'missing')).toBeNull(); + expect(countUiNodeDescendants(nested)).toBe(1); + expect(countUiNodeDescendants(root)).toBe(3); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/workflowCompletionNotice.test.ts b/apps/ai-game-creator-shell/tests/workflowCompletionNotice.test.ts new file mode 100644 index 000000000..01016c4ae --- /dev/null +++ b/apps/ai-game-creator-shell/tests/workflowCompletionNotice.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { + appendWorkflowCheckPrompt, + workflowStepLabel, +} from '../src/view/ui-editor/components/workflowCompletionNotice'; + +describe('workflow completion notice helpers', () => { + it('appends the review prompt to a terminal status', () => { + expect(appendWorkflowCheckPrompt('已应用 3 条建议。')).toBe( + '已应用 3 条建议,请检查。', + ); + }); + + it('maps every workflow step to a user-facing label', () => { + expect(workflowStepLabel('reference-analysis')).toBe('分析参考图'); + expect(workflowStepLabel('structure-recognition')).toBe('识别界面结构'); + expect(workflowStepLabel('visual-binding')).toBe('绑定视觉素材'); + }); +}); diff --git a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md index 31b747b01..1b68df9fe 100644 --- a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md +++ b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md @@ -11,9 +11,11 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事 - 指纹计算可使用调用方的 page/action 及脱敏后的首个调用点作为进程内区分输入,但这些上下文不会作为事件字段上传;消息与 stack 在入池前统一脱敏,WebCrypto 失败时降级为稳定可读指纹,采集本身不得产生新的未处理拒绝。 - 客户端 API 自动采集只覆盖网络错误、408 和 5xx;预期的 4xx 登录/鉴权失败不进入错误报告池。 - Rust 侧通过 `app_log!` 将普通文本日志同时输出到 stderr 和 AppData `diagnostics/application.log`,超出 256 KiB 滚动到 `application.previous.log`;WebView 的 console 输出通过 `append_application_log` 镜像到同一 raw log,并在客户端桥接处再次脱敏;`read_diagnostic_logs` 只读取应用级日志。 -- 报告面板只由自动诊断通知中的“查看并报告”打开,不提供聊天命令、崩溃页按钮或其他手动入口;默认选中最新事件,其他事件可勾选。允许填写最多 2,000 字中文描述并取消日志附件;本版本不支持截图或任意文件附件。 +- 报告面板只由自动诊断通知中的“查看并报告”打开,不提供聊天命令、崩溃页按钮或其他手动入口;默认选中当前快照中的全部事件,用户可取消不想提交的事件。允许填写最多 2,000 字中文描述并取消日志附件;本版本不支持截图或任意文件附件。 +- 报告面板读取当前错误快照失败时,必须明确显示“错误事件暂不可用,请关闭后重试”,不能把失败误显示为“当前没有待报告的错误”。 +- 通知中的“查看并报告”打开面板时必须保留该次通知快照;最新快照读取瞬时失败时使用这份 fallback 继续展示和提交,不能因先清空通知而丢失用户刚看到的事件。 - Rust 是结构化错误队列的唯一真相源:`src-tauri/src/error_report/` 负责脱敏、调用点指纹、eventId、计数、100 条上限、5 秒聚合和 ack 生命周期;WebView 仅通过 Tauri bridge 上报、读取快照并保存短暂 React 展示状态,不维护第二份事件 Map。Runtime 错误上报统一使用 `source=agent-runtime`、`action=agent-runtime` 和 Agent ID 作为 page;包含 `kind=codex-app-server-*` 或 `codex-app-server-error:*` 的消息在入池前归一为稳定类别(例如 `codex-app-server-error:other`),不把 public summary 中的动态 fingerprint/长度作为分桶输入。Rust emit 只作为无状态唤醒,携带单调递增的 generation,不携带错误正文或事件 ID。 -- 错误事件先在当前进程内存池按 fingerprint 合并,经过 5 秒聚合后只发出一次非阻塞存在性唤醒;WebView 挂载、收到唤醒、重新获得焦点或恢复可见时都查询完整未 ack 快照,并在桥接暂时失败时做有限退避重试。通知支持“查看并报告”和“忽略”,同一 fingerprint 仅在新增时唤醒一次。通知不直接打开阻塞式报告面板。忽略只关闭当前 UI,不删除事件;提交成功后由 bridge ack/delete 选中事件。 +- 错误事件先在当前进程内存池按 fingerprint 合并,经过 5 秒聚合后只发出一次非阻塞存在性唤醒;WebView 挂载、收到唤醒、重新获得焦点或恢复可见时都查询完整未 ack 快照,并在桥接暂时失败时做有限退避重试。通知支持“查看并报告”和“忽略”,同一 fingerprint 仅在新增时唤醒一次。通知不直接打开阻塞式报告面板;用户打开报告面板后,面板再读取一次完整未 ack 快照,确保提交使用打开时的最新事件。忽略只关闭当前 UI,不删除事件;提交成功后由 bridge ack/delete 选中事件。 - 上传失败只在当前进程显示失败并允许用户再次提交,不跨重启恢复事件池,不后台自动重试。 ## HTTP 与存储 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index c9b3625d2..137517484 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -58,6 +58,10 @@ Runtime 确认卡与普通聊天确认卡必须共用“信息区 + 固定操作 ## 2026-08-19 UI Editor 节点右键菜单 +## 2026-09-05 UI Editor 节点树快捷删除 + +左侧 `UI Tree` 每个可删除节点行在右侧提供桌面端快捷删除按钮,眼睛与删除按钮组成固定宽度的右侧动作列;整棵树保留横向滚动,深层节点的缩进、完整名称和组件数随树内容一起滚动,不使用省略号截断。沿用右键菜单的页面根节点禁删规则。快捷删除与右键删除共用页面级 `requestDelete` 入口:叶子节点直接调用现有删除命令;包含后代节点时先打开模态确认,正文显示节点名称及将同时删除的后代节点数量,按钮为“取消 / 删除”。确认期间使用现有 `ThemedModal` 的模态行为,取消或完成后关闭弹窗。底层 `deleteNode` 命令继续保持无确认,以兼容键盘 `Delete` 及已有状态测试;锁定态下快捷按钮和右键删除均不可执行。 + ## 2026-08-20 UI Editor 最终预览互斥子节点 最终预览中,选中一个 `Exclusive` 父节点时,它的子节点切换条必须在该父节点自身的预览坐标空间内、紧贴节点上方悬浮;不得固定在预览容器左上角,也不得另行按屏幕坐标换算。点击 tab 必须显式选中对应子节点,不能按通用“切换可见性”语义把当前分支隐藏。`Exclusive` 父节点首次加载且尚未发生可见性操作时,必须默认且仅显示第一个直接子节点;用户手动隐藏全部直接子节点后必须保持全部隐藏,不得再次回退显示第一个子节点;空容器不显示子节点。切换条始终按内容宽度展开并允许溢出节点边界,不设最大宽度或内部滚动区域。从 `Exclusive` 切回 `Stack` 时必须清除全部直接子节点因互斥选择产生的隐藏状态并立即显示所有子节点,后代节点自身的独立隐藏状态保持不变。切换条仅改变现有子节点可见性状态,不能触发参考图重读、视口重新适配或预览树的异步重建。 @@ -1289,3 +1293,8 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过 - 配置文件新增 `schemaVersion: \"game-creator-config.v2\"`。新默认配置开启 `stream` 与受控联网;无版本旧配置在启动时补写 v2,旧 `codex_app_server` 路由仅在省略 `webSearchEnabled` 时按历史默认补为开启,显式 `false` 保留;Provider / Anthropic 路由未提供搜索覆盖时保持关闭,避免继承 DirectProject 默认。主配置按完整配置迁移;本地覆盖只补 schema 版本,不凭不完整 overlay 推断或写入 `agentMode` / 搜索布尔值。 - `/llm-status`、开发单 Agent 状态和项目 Agent 状态卡对 Codex 模式显示“流式开启 / 关闭”“受控联网开启 / 关闭”“Codex 原生 web_search 关闭”,不显示 API Key、URL、请求头、绝对路径或 Provider 原始错误正文。 - BDD 验收场景与测试映射:DirectProject 默认工具目录包含 `agc_web_search` 且原生搜索仍 disabled;未审核字段、越界数量和非公开 URL 在桥接端失败关闭;旧无版本配置迁移为 v2 且按路由得到正确默认;状态卡显示三态安全摘要。对应 Rust `configuration`、`direct_tools_mcp`、`direct_tool_bridge`、`codex_app_server` 定向测试及前端状态格式化 / AppSurface 测试。 + +## 2026-09-06 AGC LLM 代理请求体合同 + +- `/api/llm/responses` 与 `/api/llm/chat/completions` 的正式请求体上限为 `32 MiB`。两个路由必须显式配置 Axum `DefaultBodyLimit::max(LLM_REQUEST_MAX_BODY_BYTES)`;不能依赖 handler 内的 `Bytes / Json` 后置检查,否则 Axum 默认 `2 MiB` 会先拒绝 Direct Codex 携带图片工具结果的大上下文请求。超过 `32 MiB` 仍返回 `413 PAYLOAD_TOO_LARGE`。 +- Codex app-server 的 failed turn 需要把上游 / 连接层 HTTP 413、`PAYLOAD_TOO_LARGE` 和 provider proxy 的 `provider request too large` 映射为稳定分类 `codex-app-server-error:request-too-large`;用户可见文案固定为“模型请求体过大,请减少参考图或上下文后重试”,不得落入 `other` 或泛化成权限 / 安全策略错误。 diff --git a/docs/technical/【设计】UI编辑器工作流完成通知弹窗-2026-09-04.md b/docs/technical/【设计】UI编辑器工作流完成通知弹窗-2026-09-04.md new file mode 100644 index 000000000..5cdc52832 --- /dev/null +++ b/docs/technical/【设计】UI编辑器工作流完成通知弹窗-2026-09-04.md @@ -0,0 +1,40 @@ +# UI 编辑器工作流完成通知弹窗 + +## 目标 + +UI 编辑器的“分析参考图”“识别界面结构”“绑定视觉素材”三个工作流动作在每次运行结束后,用独立的阻塞通知弹窗明确反馈结果,避免仅依赖卡片内一行状态文本而被忽略。 + +## 交互约定 + +- 三个动作的每次运行在终态(成功或失败)时自动弹出一次通知。 +- 弹窗打开期间遮挡并阻塞工作台底层交互;关闭后恢复当前步骤,不自动切换步骤、不自动重跑。 +- 使用现有 `ThemedModal` 的普通关闭行为(遮罩、Esc 和关闭按钮均可关闭)。 +- 弹窗仅承载通知,不提供“继续”“重试”或其他业务操作。 +- 关闭弹窗后,工作流卡片继续显示同一条结果状态;重新运行产生新的终态时再次通知。 +- 绑定动作包含多个批次时,只在最终批次结束后通知一次。 + +## 文案 + +弹窗标题由步骤名和结果态组成,例如“识别界面结构完成”或“绑定视觉素材失败”。正文复用卡片状态文本,并逐条扩展结果信息,统一以“请检查”收尾。 + +成功状态的基线文案: + +- 分析参考图:保留已应用的语义建议数量;若现有状态可可靠取得问题/待确认数量,则一并展示。 +- 识别界面结构:保留替换的界面树数量,并展示识别结果中的待检查/必须修复数量(若可取得)。 +- 绑定视觉素材:保留现有 `B/B` 批次计数,改为用户可读的绑定结果。 + +失败状态保留实际错误文本,仅在弹窗标题中补充步骤和失败上下文,正文同样以“请检查”收尾。 + +## 实现边界 + +- 新增独立的工作流通知弹窗组件文件,组件只负责展示和关闭,不包含工作流领域规则或后端副作用。 +- 在 UI 编辑器页面/会话投影中维护临时通知状态,并在三个异步动作的成功与失败终态写入。 +- 不新增后端字段或公开契约;数量只能使用当前前端已有且可靠的数据。 + +## 验收 + +1. 三个动作成功和失败终态各弹出一次通知;绑定批次只弹最终一次。 +2. 弹窗打开时底层工作台不可操作,且无继续/重试等业务按钮。 +3. 弹窗可通过标准关闭方式退出;关闭后卡片状态仍可见。 +4. 每条成功文案保留原有数量信息并增加可用的检查数量,所有文案包含“请检查”。 +5. 重新运行后新的终态会再次弹出通知。 diff --git a/docs/【UI编辑器】撤销重做规范-2026-09-03.md b/docs/【UI编辑器】撤销重做规范-2026-09-03.md index f3912e000..86d7e3cd9 100644 --- a/docs/【UI编辑器】撤销重做规范-2026-09-03.md +++ b/docs/【UI编辑器】撤销重做规范-2026-09-03.md @@ -48,13 +48,19 @@ UI 编辑器支持撤销最近一次或多次作品编辑,并支持重做被 - 桌面端页面工具栏提供撤销和重做按钮。 - 非文本编辑目标聚焦编辑器时支持 `Cmd/Ctrl+Z` 撤销、`Cmd/Ctrl+Shift+Z` 和 `Ctrl+Y` 重做。 -- `input`、`textarea`、`select`、`contenteditable` 以及按钮/链接等控件交给浏览器原生行为,不拦截文本撤销。 +- `input`、`textarea`、`select`、`contenteditable` 等文本编辑控件交给浏览器原生行为,不拦截文本撤销;按钮/链接等非文本控件仍允许编辑器撤销/重做快捷键生效。 +- UI Editor 页面内选中节点后,支持不带修饰键的 `Delete` 删除节点及其子节点;快捷键复用 Inspector、树面板和右键菜单共用的 `deleteNode` 命令,因此沿用根节点/锁定禁删、单条历史记录、dirty 标记和后续保存语义。 +- `Delete` / `Backspace` 在 `input`、`textarea`、`select`、`contenteditable`、按钮和链接等交互控件聚焦时交给浏览器原生行为;无选中、根节点、锁定或目标不存在时不执行删除。长按重复事件不重复删除,成功处理后阻止默认行为和事件冒泡。 +- `Delete` / `Backspace` 在打开的对话框(`role="dialog"` 或 `aria-modal="true"`)内聚焦时交给对话框处理,不删除对话框背后的节点。 +- `Cmd/Ctrl+Z`、`Cmd/Ctrl+Shift+Z` 和 `Ctrl+Y` 在打开的对话框(`role="dialog"` 或 `aria-modal="true"`)内聚焦时暂停编辑器级撤销/重做,由对话框或浏览器原生行为处理。 - 无可撤销或重做记录时按钮禁用,并提供可访问名称。 ## 脏状态与选择 撤销和重做恢复的 State 继续参与现有 dirty 判定、保存和后端持久化。历史快照不包含当前设计图、节点选择、隐藏集合或视口;恢复后若当前选择已不存在,页面清理无效选择并保持安全空态。 +删除节点后,若当前选中节点是被删除节点或其任意后代,页面将选区清空为 `null`;删除成功不强制抢占焦点。该选择清理属于 UI 临时状态,不写入撤销历史。 + ## 验收标准 1. 单次字段编辑可撤销和重做。 @@ -67,3 +73,4 @@ UI 编辑器支持撤销最近一次或多次作品编辑,并支持重做被 8. 工具栏按钮、禁用态和桌面快捷键可用,文本控件保留原生撤销。 9. no-op、锁定、校验失败和不存在目标不进入历史。 10. 颜色选择器和九宫格边界拖动不会按每个 pointer move 写入 State。 +11. `Delete` / `Backspace` 仅在非交互控件聚焦且存在可删除选中节点时生效;删除整棵子树、可撤销、长按只处理一次,并清理被删除子树内的无效选区。 diff --git a/docs/【交互设计】预览画布缩放滑杆-2026-09-05.md b/docs/【交互设计】预览画布缩放滑杆-2026-09-05.md new file mode 100644 index 000000000..198d7788a --- /dev/null +++ b/docs/【交互设计】预览画布缩放滑杆-2026-09-05.md @@ -0,0 +1,26 @@ +# 预览画布缩放滑杆 + +## 交付目标 + +将 UI 编辑器预览区现有的缩放按钮替换为桌面端可拖动滑杆,同时保留快速缩小、放大和适配画布操作。 + +## 交互约定 + +- 滑杆选择范围为 25%–200%,步进 5%;快捷加减继续使用核心视口的现有缩放边界。 +- 滑杆位于预览区右下角固定工具栏;不提供移动端或窄窗口 fallback。 +- 保留 `−` 与 `+` 按钮。点击轨道可跳转,拖动滑块实时更新画布和百分比。 +- 缩放以视口中心为中心,继续支持 Ctrl/Cmd + 滚轮缩放。 +- 滑杆支持方向键、Home、End;百分比文本可点击编辑并在失焦时限制到有效范围。 +- 百分比文本使用数字输入框呈现;输入提交后同步视口缩放,适配画布保持独立按钮,点击百分比不会触发适配。 +- 缩放滑杆获得焦点时,Delete/Backspace 不触发 UI 节点快捷删除;缩放按钮鼠标点击不改变快捷键焦点。 +- Windows/Linux 使用 `Ctrl`,macOS 使用 `Cmd`;`+`、`=` 与 `NumpadAdd` 放大,`-` 与 `NumpadSubtract` 缩小,允许按键重复时连续缩放。 +- 快捷键直接复用现有 `+`/`−` 按钮动作:以视口中心为锚点,按 `×1.16`/`×0.86` 改变缩放。 +- 鼠标悬停在预览画布上,或预览画布已获得键盘焦点时,快捷键生效。预览画布需提供可聚焦语义与无障碍名称。 +- 事件来自输入框、文本编辑区、按钮、链接、缩放工具栏或其他可操作控件时不拦截;不向 iframe 子文档注入监听。 +- 仅在存在可缩放视口且确认命中快捷键时调用 `preventDefault()` 与 `stopPropagation()`,防止浏览器页面同时缩放。 +- 保留现有 `Ctrl/Cmd+0` 适配画布与 `Ctrl/Cmd+1` 恢复 100% 行为,不增加其他重置快捷键。缩放仍是当前预览实例的临时 UI 状态。 +- 保留快捷键与缩放焦点边界的组件级回归测试,不扩展端到端测试。 + +## 验收 + +桌面端可通过按钮、轨道点击、拖动和键盘改变缩放;百分比与画布同步,边界不会越界。组件级测试覆盖平台修饰键、主键盘与小键盘变体、按键重复、悬停/焦点作用域、可编辑与可操作目标放行、无内容放行、浏览器默认行为拦截和现有 `0`/`1` 回归;另运行前端定向类型检查、编码检查和 `git diff --check`。 diff --git a/packages/image-canvas-core/src/model.ts b/packages/image-canvas-core/src/model.ts index 3deeac6f8..b6b6c3430 100644 --- a/packages/image-canvas-core/src/model.ts +++ b/packages/image-canvas-core/src/model.ts @@ -4,6 +4,8 @@ export const CANVAS_WORLD_SIZE = 12000; export const CANVAS_WORLD_ORIGIN = CANVAS_WORLD_SIZE / 2; export const MIN_SCALE = 0.025; export const MAX_SCALE = 3.2; +export const CANVAS_ZOOM_IN_FACTOR = 1.16; +export const CANVAS_ZOOM_OUT_FACTOR = 0.86; export const CANVAS_DISPLAY_SCALE_BASE = 0.5; export const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 }; export const FIT_VIEW_PADDING = 10; diff --git a/packages/image-canvas-react/src/ZoomControls.tsx b/packages/image-canvas-react/src/ZoomControls.tsx index e9f853c26..53e88ccec 100644 --- a/packages/image-canvas-react/src/ZoomControls.tsx +++ b/packages/image-canvas-react/src/ZoomControls.tsx @@ -1,4 +1,6 @@ import { + CANVAS_ZOOM_IN_FACTOR, + CANVAS_ZOOM_OUT_FACTOR, canvasDisplayScaleToViewportScale, type CanvasViewport, formatCanvasDisplayScalePercent, @@ -27,8 +29,8 @@ export function ZoomControls({ const actions: CanvasZoomActions = { displayPercent: formatCanvasDisplayScalePercent(viewport.scale), fit: onFit, - zoomIn: () => onScaleFromCenter(viewport.scale * 1.16), - zoomOut: () => onScaleFromCenter(viewport.scale * 0.86), + zoomIn: () => onScaleFromCenter(viewport.scale * CANVAS_ZOOM_IN_FACTOR), + zoomOut: () => onScaleFromCenter(viewport.scale * CANVAS_ZOOM_OUT_FACTOR), zoomToDisplayScale: (displayScale) => onScaleFromCenter(canvasDisplayScaleToViewportScale(displayScale)), }; diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs index 6170d04ad..e9b526f2a 100644 --- a/server-rs/crates/api-server/src/llm/mod.rs +++ b/server-rs/crates/api-server/src/llm/mod.rs @@ -28,6 +28,8 @@ use crate::{ platform_errors::map_llm_error, request_context::RequestContext, state::AppState, }; +pub(crate) const LLM_REQUEST_MAX_BODY_BYTES: usize = 32 * 1024 * 1024; + pub(crate) mod icon_specs; #[cfg(test)] @@ -222,8 +224,7 @@ pub async fn proxy_llm_responses( headers: HeaderMap, body: Bytes, ) -> Result { - const MAX_REQUEST_BYTES: usize = 32 * 1024 * 1024; - if body.len() > MAX_REQUEST_BYTES { + if body.len() > LLM_REQUEST_MAX_BODY_BYTES { return Err(llm_error_response( &request_context, AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE) @@ -1187,6 +1188,77 @@ mod tests { ); } + #[tokio::test] + async fn llm_routes_accept_large_context_bodies_beyond_axum_default() { + let large_input = "x".repeat(2 * 1024 * 1024 + 1024); + let (state, user_id) = seed_authenticated_state(AppConfig::default()).await; + install_test_provisioned_router_credential( + &user_id, + "http://127.0.0.1:1".to_string(), + "fixture-key", + ); + let token = issue_access_token(&state, &user_id); + let app = build_router(state); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/llm/responses") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from(json!({ "input": large_input }).to_string())) + .expect("request should build"), + ) + .await + .expect("response should not hit the default body limit"); + assert_ne!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/llm/chat/completions") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "messages": [ + { "role": "user", "content": large_input } + ] + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("response should not hit the default body limit"); + assert_ne!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + } + + #[tokio::test] + async fn llm_responses_rejects_bodies_above_explicit_limit() { + let (state, user_id) = seed_authenticated_state(AppConfig::default()).await; + let token = issue_access_token(&state, &user_id); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/llm/responses") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from(vec![b'x'; LLM_REQUEST_MAX_BODY_BYTES + 1])) + .expect("request should build"), + ) + .await + .expect("oversized response should be returned"); + + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + } + #[tokio::test] async fn llm_chat_completions_streams_sse_payload() { let server_url = spawn_mock_server(vec![MockResponse { diff --git a/server-rs/crates/api-server/src/modules/platform.rs b/server-rs/crates/api-server/src/modules/platform.rs index 43492f64c..d457b795e 100644 --- a/server-rs/crates/api-server/src/modules/platform.rs +++ b/server-rs/crates/api-server/src/modules/platform.rs @@ -1,11 +1,16 @@ use axum::{ - Router, middleware, + Router, + extract::DefaultBodyLimit, + middleware, routing::{get, post}, }; use crate::{ auth::require_bearer_auth, - llm::{list_llm_models, proxy_llm_chat_completions, proxy_llm_responses}, + llm::{ + LLM_REQUEST_MAX_BODY_BYTES, list_llm_models, proxy_llm_chat_completions, + proxy_llm_responses, + }, state::AppState, volcengine_speech::{ get_volcengine_speech_config, stream_volcengine_asr, stream_volcengine_tts_bidirection, @@ -24,17 +29,21 @@ pub fn router(state: AppState) -> Router { ) .route( "/api/llm/chat/completions", - post(proxy_llm_chat_completions).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), + post(proxy_llm_chat_completions) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )) + .layer(DefaultBodyLimit::max(LLM_REQUEST_MAX_BODY_BYTES)), ) .route( "/api/llm/responses", - post(proxy_llm_responses).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), + post(proxy_llm_responses) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )) + .layer(DefaultBodyLimit::max(LLM_REQUEST_MAX_BODY_BYTES)), ) .route( "/api/speech/volcengine/config",