diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 38daa60..4fa50b9 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,3 +5,6 @@ description: 当前版本已实现但仍需人工验证的变更项 # 待测试 +- 右上角 `Agent` 按钮在面板已打开时再次点击会走与右侧收起图标一致的收起动画,不再立即卸载面板;需要验证打开、右上角再次点击收起、右侧收起图标收起和收起过程中再次打开都正常。 +- 右侧 `Agent` 面板默认打开宽度已调大;需要验证首次打开空间更宽,手动拖拽调整宽度仍正常。 +- 在线 `Agent` 输入框底部的文本模型控件改为无背景无阴影的轻量下拉,只展示图标、模型名和渠道,并移除了输入区上方分割线;需要验证能正常切换文本模型。 diff --git a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx index 3e748e4..11fb699 100644 --- a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx +++ b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx @@ -26,7 +26,7 @@ import { NODE_DEFAULT_SIZE, getNodeSpec } from "../constants"; import { ActiveConnectionPath, ConnectionPath } from "../components/canvas-connections"; import { CanvasConfigComposer } from "../components/canvas-config-composer"; import { CanvasConfigNodePanel } from "../components/canvas-config-node-panel"; -import { CanvasAssistantPanel } from "../components/canvas-assistant-panel"; +import { CANVAS_AGENT_PANEL_MOTION_MS, CanvasAssistantPanel } from "../components/canvas-assistant-panel"; import { CanvasNodeContextMenu } from "../components/canvas-context-menu"; import { CanvasNodeAngleDialog, type CanvasImageAngleParams } from "../components/canvas-node-angle-dialog"; import { CanvasNodeCropDialog, type CanvasImageCropRect } from "../components/canvas-node-crop-dialog"; @@ -291,6 +291,7 @@ function InfiniteCanvasPage() { const [previewNodeId, setPreviewNodeId] = useState(null); const [assistantCollapsed, setAssistantCollapsed] = useState(true); const [assistantMounted, setAssistantMounted] = useState(false); + const [assistantClosing, setAssistantClosing] = useState(false); const [agentMode, setAgentMode] = useState("online"); const [agentUndoSnapshot, setAgentUndoSnapshot] = useState(null); const [titleEditing, setTitleEditing] = useState(false); @@ -308,6 +309,7 @@ function InfiniteCanvasPage() { const connectingParamsRef = useRef(connectingParams); const connectionTargetNodeIdRef = useRef(connectionTargetNodeId); const selectionBoxRef = useRef(selectionBox); + const agentCloseTimerRef = useRef | null>(null); const pendingConnectionCreateRef = useRef(pendingConnectionCreate); const createHistoryEntry = useCallback( @@ -393,6 +395,13 @@ function InfiniteCanvasPage() { }; }, [activeChatId, backgroundMode, chatSessions, connections, createHistoryEntry, nodes, projectLoaded, showImageInfo]); + useEffect( + () => () => { + if (agentCloseTimerRef.current) clearTimeout(agentCloseTimerRef.current); + }, + [], + ); + useEffect(() => { if (!projectLoaded || historyPausedRef.current) return; updateProject(projectId, { nodes, connections, chatSessions, activeChatId, backgroundMode, showImageInfo }); @@ -2338,13 +2347,24 @@ function InfiniteCanvasPage() { const assistantOpen = assistantMounted && !assistantCollapsed; const openAgent = (mode: CanvasAgentMode = agentMode) => { + if (agentCloseTimerRef.current) { + clearTimeout(agentCloseTimerRef.current); + agentCloseTimerRef.current = null; + } setAgentMode(mode); setAssistantMounted(true); + setAssistantClosing(false); setAssistantCollapsed(false); }; const closeAgent = () => { + if (!assistantMounted || assistantClosing) return; setAssistantCollapsed(true); - setAssistantMounted(false); + setAssistantClosing(true); + agentCloseTimerRef.current = setTimeout(() => { + agentCloseTimerRef.current = null; + setAssistantMounted(false); + setAssistantClosing(false); + }, CANVAS_AGENT_PANEL_MOTION_MS); }; if (!projectLoaded) return ; @@ -2665,8 +2685,8 @@ function InfiniteCanvasPage() { onPasteImage={pasteAssistantImage} agentMode={agentMode} onAgentModeChange={setAgentMode} - onCollapseStart={() => setAssistantCollapsed(true)} - onCollapse={() => setAssistantMounted(false)} + closing={assistantClosing} + onCollapse={closeAgent} /> ) : null} diff --git a/web/src/app/(user)/canvas/components/canvas-agent-chat-ui.tsx b/web/src/app/(user)/canvas/components/canvas-agent-chat-ui.tsx index bb8fcf8..b227931 100644 --- a/web/src/app/(user)/canvas/components/canvas-agent-chat-ui.tsx +++ b/web/src/app/(user)/canvas/components/canvas-agent-chat-ui.tsx @@ -173,7 +173,7 @@ export function AgentChatComposer({ const fileInputRef = useRef(null); const canSubmit = !disabled && !sending && Boolean(prompt.trim() || attachments.length); return ( -
event.stopPropagation()}> +
event.stopPropagation()}>
{attachments.length ? (
diff --git a/web/src/app/(user)/canvas/components/canvas-assistant-panel.tsx b/web/src/app/(user)/canvas/components/canvas-assistant-panel.tsx index ec12fd7..a8065ae 100644 --- a/web/src/app/(user)/canvas/components/canvas-assistant-panel.tsx +++ b/web/src/app/(user)/canvas/components/canvas-assistant-panel.tsx @@ -2,11 +2,11 @@ import { useEffect, useMemo, useRef, useState } from "react"; import copyToClipboard from "copy-to-clipboard"; -import { Bot, Copy, History, PanelRightClose, Plus, Settings2, Trash2, X } from "lucide-react"; +import { Bot, Copy, Cpu, History, PanelRightClose, Plus, Settings2, Trash2, X } from "lucide-react"; import { Button, Modal, Switch, Tooltip } from "antd"; import { motion } from "motion/react"; -import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; +import { modelOptionName, resolveModelChannel, selectableModelsByCapability, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; import { canvasThemes } from "@/lib/canvas-theme"; import { nanoid } from "nanoid"; import { requestImageQuestion, type ChatCompletionMessage } from "@/services/api/image"; @@ -16,6 +16,7 @@ import { useThemeStore } from "@/stores/use-theme-store"; import { useUserStore } from "@/stores/use-user-store"; import { imageReferenceLabel } from "@/lib/image-reference-prompt"; import { DiaTextReveal } from "@/components/ui/dia-text-reveal"; +import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select"; import { CanvasPromptLibrary } from "./canvas-prompt-library"; import { AgentChatComposer, AgentChatMessage, AgentModeSwitch, AgentPanelTabs, AgentWorkingMessage, type CanvasAgentChatMessage, type CanvasAgentMode } from "./canvas-agent-chat-ui"; import { CanvasLocalAgentPanel } from "./canvas-local-agent-panel"; @@ -23,8 +24,8 @@ import { CanvasNodeType, type CanvasAssistantMessage, type CanvasAssistantRefere import { useCanvasAgentStore } from "../stores/use-canvas-agent-store"; import { summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "../utils/canvas-agent-ops"; -const PANEL_MOTION_MS = 500; -const PANEL_MOTION_SECONDS = PANEL_MOTION_MS / 1000; +export const CANVAS_AGENT_PANEL_MOTION_MS = 500; +const PANEL_MOTION_SECONDS = CANVAS_AGENT_PANEL_MOTION_MS / 1000; const ONLINE_AGENT_MAX_STEPS = 4; const ONLINE_AGENT_PROMPT = '你是 Infinite Canvas 网页内置在线画布助手。你只能返回 JSON,不要 Markdown,不要解释。格式:{"reply":"给用户看的中文说明","ops":[...]}。reply 只能说明“准备执行/等待确认”,不能说“已完成/已删除/已连接/已调整”,因为工具操作需要用户确认后才会执行。工具执行结果返回后,你要判断任务是否完成;完成时返回 ops:[],未完成时返回下一步 ops。ops 可用类型:add_node、update_node、delete_node、delete_connections、connect_nodes、set_viewport、select_nodes、run_generation。add_node 支持 nodeType: text/image/config/video/audio,position:{x,y},metadata。delete_node 必须带 id/ids,或用 nodeType:"config" 删除全部生成配置节点。delete_connections 可用 all:true 删除全部连线。文本内容放 metadata.content。用户要求生图、生成文字、视频或音频时,不要直接生成最终内容,要创建提示词文本节点、config 节点、connect_nodes,并追加 run_generation 触发画布已有生成工具;config 节点 metadata 至少包含 generationMode、composerContent、prompt、status:"idle",composerContent/prompt 用 @[node:id] 引用提示词节点或参考节点。只输出能直接 JSON.parse 的对象。'; @@ -46,26 +47,26 @@ type CanvasAssistantPanelProps = { onPasteImage: (file: File) => void; agentMode: CanvasAgentMode; onAgentModeChange: (mode: CanvasAgentMode) => void; - onCollapseStart: () => void; + closing: boolean; onCollapse: () => void; }; -export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, sessions, activeSessionId, onSelectNodeIds, onSessionsChange, onApplyOps, canUndoOps, onUndoOps, onPasteImage, agentMode, onAgentModeChange, onCollapseStart, onCollapse }: CanvasAssistantPanelProps) { +export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, sessions, activeSessionId, onSelectNodeIds, onSessionsChange, onApplyOps, canUndoOps, onUndoOps, onPasteImage, agentMode, onAgentModeChange, closing, onCollapse }: CanvasAssistantPanelProps) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const user = useUserStore((state) => state.user); const effectiveConfig = useEffectiveConfig(); const cleanupImages = useAssetStore((state) => state.cleanupImages); const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady); const openConfigDialog = useConfigStore((state) => state.openConfigDialog); + const updateConfig = useConfigStore((state) => state.updateConfig); const confirmTools = useCanvasAgentStore((state) => state.confirmTools); const setAgentState = useCanvasAgentStore((state) => state.setAgentState); - const [width, setWidth] = useState(390); + const [width, setWidth] = useState(520); const [view, setView] = useState("chat"); const [prompt, setPrompt] = useState(""); const [isRunning, setIsRunning] = useState(false); const [deleteChatIds, setDeleteChatIds] = useState([]); const [onlineLogs, setOnlineLogs] = useState([]); - const [closing, setClosing] = useState(false); const [resizing, setResizing] = useState(false); const [removedReferenceIds, setRemovedReferenceIds] = useState>(new Set()); const [localSessions, setLocalSessions] = useState(() => (sessions.length ? sessions : [createSession()])); @@ -282,9 +283,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, session }; const collapse = () => { - setClosing(true); - onCollapseStart(); - window.setTimeout(onCollapse, PANEL_MOTION_MS); + onCollapse(); }; const onlineContent = ( @@ -398,9 +397,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, session left={ <> - + updateConfig("textModel", model)} /> } /> @@ -488,6 +485,59 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, session ); } +function AgentTextModelPicker({ config, value, onChange }: { config: AiConfig; value: string; onChange: (model: string) => void }) { + const options = useMemo(() => Array.from(new Set([value, ...selectableModelsByCapability(config, "text")].filter(Boolean))), [config, value]); + const current = value || ""; + return ( + + ); +} + +function AgentModelIcon({ model }: { model: string }) { + const icon = resolveModelIcon(modelOptionName(model)); + return icon ? : ; +} + +function resolveModelIcon(model: string) { + const name = model.toLowerCase(); + if (name.includes("claude") || name.includes("anthropic")) return "/icons/claude.svg"; + if (name.includes("gemini") || name.includes("google")) return "/icons/gemini.svg"; + if (name.includes("gpt") || name.includes("openai")) return "/icons/openai.svg"; + if (name.includes("grok")) return "/icons/grok.svg"; + if (name.includes("deepseek")) return "/icons/deepseek.svg"; + if (name.includes("glm")) return "/icons/glm.svg"; + return ""; +} + function AssistantHistory({ sessions, activeSession,