diff --git a/CHANGELOG.md b/CHANGELOG.md index d0ebbde..7d55fbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,15 +2,16 @@ ## Unreleased -+ [优化] 本地 Agent 连接说明明确区分插件 / 手动 MCP 才会增加 Codex token 消耗,直接运行 Agent 不受影响。 ++ [新增] 画布空白区域支持双击打开节点选择菜单,并在点击位置创建节点。 ++ [优化] 本地 Agent 连接说明明确区分插件 / 手动 MCP 才会增加 Codex token 消耗。 + [优化] 优化本地 Agent 连接说明,区分 Codex 插件启动和直接运行 Agent 两种方式。 -+ [新增] Agent 对话消息改用 streamdown 流式渲染,提升长回复与 Markdown 内容展示效果。 -+ [新增] Agent 新增画布、工作台、提示词库和素材等站点级工具,支持读取配置、触发生成与新增素材。 ++ [新增] Agent 对话消息改用 streamdown 流式渲染,提升Markdown 内容展示效果。 ++ [新增] Agent 新增画布、工作台、提示词库和素材等站点级工具。 + [新增] Agent 面板改为全站常驻右侧栏,开关时同步推动顶栏和页面内容,并保留独立入口按钮。 -+ [新增] Agent 新增 `site_navigate` 工具,支持从对话中直接跳转首页、画布、工作台、素材和配置等页面。 -+ [新增] Agent 对话运行中支持一键停止,发送按钮切换为红色停止按钮并中断当前 Codex turn。 -+ [调整] Codex 会话改为站点级连续线程,不再按画布 ID 分隔,跨页面和跨画布保持同一上下文。 -+ [调整] 移除仅前端调用 OpenAI responses 接口的在线画布助手旧逻辑,统一走 MCP + 本地 Codex 链路。 ++ [新增] Agent 新增 `site_navigate` 工具,支持页面跳转。 ++ [新增] Agent 对话运行中支持一键停止,中断当前 Codex turn。 ++ [调整] Codex 会话改为站点级连续线程,跨页面和跨画布保持同一上下文。 ++ [调整] 移除仅前端调用 OpenAI responses 接口,统一走 MCP + 本地 Codex 链路。 + [新增] 画布节点支持统一维护名称字段,默认显示在节点上方,并可直接双击名称编辑。 + [新增] 画布新增组节点,支持节点拖入/拆出分组、拖拽高亮吸附和移动组时带动子节点。 + [调整] 画布节点顶部工具条改为点击选中节点后显示,避免鼠标经过节点时频繁弹出。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 58939cb..53a1cac 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,7 @@ description: 当前版本已实现但仍需人工验证的变更项 # 待测试 +- 画布节点创建:鼠标左键双击画布空白区域会在点击位置打开节点选择菜单,可创建文本、图片、视频、音频、生成配置和组节点;双击已有节点或连线不应触发菜单。 - 本地 Agent 连接说明:新增提醒说明只有安装 Codex 插件或手动添加 MCP 后才会增加 Codex token 消耗,直接运行 `npx -y @basketikun/canvas-agent` 不会安装 MCP;需验证文案位置清晰。 - 本地 Agent 连接说明:画布面板和配置页 Codex Tab 改为区分“Codex 插件启动画布”和“直接运行 Agent 后网页连接”两种方式,需验证文案和布局清晰。 - 全站 Agent:Agent 面板从画布页抽离为全站常驻面板,挂在全局布局右侧,开/关只挤压左侧内容不遮挡;顶栏和画布工具条各有一个开关按钮,需验证在首页、生图、视频、素材、提示词库、画布等页面都能打开面板并保持 SSE 连接不断开。 diff --git a/web/src/components/canvas/infinite-canvas.tsx b/web/src/components/canvas/infinite-canvas.tsx index 2d9550e..2a0fd0a 100644 --- a/web/src/components/canvas/infinite-canvas.tsx +++ b/web/src/components/canvas/infinite-canvas.tsx @@ -11,12 +11,13 @@ type InfiniteCanvasProps = { onViewportChange: (viewport: ViewportTransform) => void; onCanvasMouseDown?: (event: React.PointerEvent) => void; onCanvasDeselect?: () => void; + onCanvasDoubleClick?: (event: React.MouseEvent) => void; onContextMenu?: (event: React.MouseEvent) => void; onDrop?: (event: React.DragEvent) => void; children: React.ReactNode; }; -export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines", onViewportChange, onCanvasMouseDown, onCanvasDeselect, onContextMenu, onDrop, children }: InfiniteCanvasProps) { +export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines", onViewportChange, onCanvasMouseDown, onCanvasDeselect, onCanvasDoubleClick, onContextMenu, onDrop, children }: InfiniteCanvasProps) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const panState = useRef({ isPanning: false, @@ -116,6 +117,12 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines } }; + const handleDoubleClick = (event: React.MouseEvent) => { + const target = event.target instanceof Element ? event.target : null; + if (target?.closest("[data-canvas-no-zoom],[data-node-id],[data-connection-id]")) return; + onCanvasDoubleClick?.(event); + }; + useEffect(() => { const handlePointerMove = (event: PointerEvent) => { if (!panState.current.isPanning) return; @@ -171,6 +178,7 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines className="relative h-full w-full cursor-grab select-none overflow-hidden" style={{ background: theme.canvas.background }} onPointerDown={handlePointerDown} + onDoubleClick={handleDoubleClick} onWheel={handleWheel} onContextMenu={onContextMenu} onDragOver={(event) => event.preventDefault()} diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index f7a5d8c..4ac80a2 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { ChangeEvent as ReactChangeEvent, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react"; import { useNavigate, useParams, useSearchParams } from "react-router-dom"; -import { BookOpen, Bot, Home, ImageIcon, Images, List, Menu, Music2, Plus, Redo2, Settings2, Trash2, Undo2, Upload, Video } from "lucide-react"; +import { BookOpen, Bot, Group, Home, ImageIcon, Images, List, Menu, Music2, Plus, Redo2, Settings2, Trash2, Undo2, Upload, Video, X } from "lucide-react"; import { saveAs } from "file-saver"; import { requestEdit, requestGeneration, requestImageQuestion } from "@/services/api/image"; @@ -212,6 +212,33 @@ function ConnectionCreateOption({ theme, icon, title, description, onClick }: { ); } +function NodeCreateMenu({ position, onCreate, onClose }: { position: Position; onCreate: (type: CanvasNodeType) => void; onClose: () => void }) { + const theme = canvasThemes[useThemeStore((state) => state.theme)]; + return ( +
event.stopPropagation()} + > +
+ 选择节点 + +
+
+ } title="文本" onClick={() => onCreate(CanvasNodeType.Text)} /> + } title="图片" onClick={() => onCreate(CanvasNodeType.Image)} /> + } title="视频" onClick={() => onCreate(CanvasNodeType.Video)} /> + } title="音频" onClick={() => onCreate(CanvasNodeType.Audio)} /> + } title="生成配置" onClick={() => onCreate(CanvasNodeType.Config)} /> + } title="组" onClick={() => onCreate(CanvasNodeType.Group)} /> +
+
+ ); +} + function InfiniteCanvasPage() { const { message, modal } = App.useApp(); const params = useParams<{ id: string }>(); @@ -281,6 +308,7 @@ function InfiniteCanvasPage() { const [mouseWorld, setMouseWorld] = useState({ x: 0, y: 0 }); const [selectionBox, setSelectionBox] = useState(null); const [contextMenu, setContextMenu] = useState(null); + const [nodeCreatePosition, setNodeCreatePosition] = useState(null); const [runningNodeId, setRunningNodeId] = useState(null); const [isMiniMapOpen, setIsMiniMapOpen] = useState(false); const [backgroundMode, setBackgroundMode] = useState("lines"); @@ -1033,6 +1061,7 @@ function InfiniteCanvasPage() { const handleCanvasMouseDown = useCallback( (event: ReactPointerEvent) => { setContextMenu(null); + setNodeCreatePosition(null); if (pendingConnectionCreateRef.current) cancelPendingConnectionCreate(); if (event.button !== 0) return; @@ -1433,6 +1462,7 @@ function InfiniteCanvasPage() { setSelectedNodeIds(new Set()); setSelectedConnectionId(null); setContextMenu(null); + setNodeCreatePosition(null); setSelectionBox(null); setConnecting(null); setHoveredNodeId(null); @@ -2505,6 +2535,10 @@ function InfiniteCanvasPage() { }} onCanvasMouseDown={handleCanvasMouseDown} onCanvasDeselect={deselectCanvas} + onCanvasDoubleClick={(event) => { + setContextMenu(null); + setNodeCreatePosition(screenToCanvas(event.clientX, event.clientY)); + }} onContextMenu={preventCanvasContextMenu} onDrop={handleDrop} > @@ -2643,6 +2677,16 @@ function InfiniteCanvasPage() { /> ) : null} {pendingConnectionCreate ? createConnectedNode(type, pendingConnectionCreate)} onClose={cancelPendingConnectionCreate} /> : null} + {nodeCreatePosition ? ( + { + createNode(type, nodeCreatePosition); + setNodeCreatePosition(null); + }} + onClose={() => setNodeCreatePosition(null)} + /> + ) : null}