mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-24 15:24:06 +08:00
feat(agent): improve Agent panel interactions and enhance text model selection UI
This commit is contained in:
@@ -5,3 +5,6 @@ description: 当前版本已实现但仍需人工验证的变更项
|
||||
|
||||
# 待测试
|
||||
|
||||
- 右上角 `Agent` 按钮在面板已打开时再次点击会走与右侧收起图标一致的收起动画,不再立即卸载面板;需要验证打开、右上角再次点击收起、右侧收起图标收起和收起过程中再次打开都正常。
|
||||
- 右侧 `Agent` 面板默认打开宽度已调大;需要验证首次打开空间更宽,手动拖拽调整宽度仍正常。
|
||||
- 在线 `Agent` 输入框底部的文本模型控件改为无背景无阴影的轻量下拉,只展示图标、模型名和渠道,并移除了输入区上方分割线;需要验证能正常切换文本模型。
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [assistantCollapsed, setAssistantCollapsed] = useState(true);
|
||||
const [assistantMounted, setAssistantMounted] = useState(false);
|
||||
const [assistantClosing, setAssistantClosing] = useState(false);
|
||||
const [agentMode, setAgentMode] = useState<CanvasAgentMode>("online");
|
||||
const [agentUndoSnapshot, setAgentUndoSnapshot] = useState<CanvasAgentSnapshot | null>(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<ReturnType<typeof setTimeout> | 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);
|
||||
setAssistantClosing(true);
|
||||
agentCloseTimerRef.current = setTimeout(() => {
|
||||
agentCloseTimerRef.current = null;
|
||||
setAssistantMounted(false);
|
||||
setAssistantClosing(false);
|
||||
}, CANVAS_AGENT_PANEL_MOTION_MS);
|
||||
};
|
||||
|
||||
if (!projectLoaded) return <CanvasRefreshShell />;
|
||||
@@ -2665,8 +2685,8 @@ function InfiniteCanvasPage() {
|
||||
onPasteImage={pasteAssistantImage}
|
||||
agentMode={agentMode}
|
||||
onAgentModeChange={setAgentMode}
|
||||
onCollapseStart={() => setAssistantCollapsed(true)}
|
||||
onCollapse={() => setAssistantMounted(false)}
|
||||
closing={assistantClosing}
|
||||
onCollapse={closeAgent}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
@@ -173,7 +173,7 @@ export function AgentChatComposer({
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const canSubmit = !disabled && !sending && Boolean(prompt.trim() || attachments.length);
|
||||
return (
|
||||
<div className="border-t px-2 pb-2 pt-2" style={{ borderColor: theme.node.stroke }} onWheelCapture={(event) => event.stopPropagation()}>
|
||||
<div className="px-2 pb-2 pt-2" onWheelCapture={(event) => event.stopPropagation()}>
|
||||
<div className="rounded-[24px] border px-3 pb-3 pt-3 shadow-lg" style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke }}>
|
||||
{attachments.length ? (
|
||||
<div className="thin-scrollbar mb-2 flex gap-2 overflow-x-auto pb-1">
|
||||
|
||||
@@ -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<OnlineAgentTab>("chat");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [deleteChatIds, setDeleteChatIds] = useState<string[]>([]);
|
||||
const [onlineLogs, setOnlineLogs] = useState<OnlineAgentLog[]>([]);
|
||||
const [closing, setClosing] = useState(false);
|
||||
const [resizing, setResizing] = useState(false);
|
||||
const [removedReferenceIds, setRemovedReferenceIds] = useState<Set<string>>(new Set());
|
||||
const [localSessions, setLocalSessions] = useState<CanvasAssistantSession[]>(() => (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={
|
||||
<>
|
||||
<CanvasPromptLibrary onSelect={setPrompt} />
|
||||
<button type="button" className="max-w-[180px] truncate rounded-full px-2 py-1 text-xs opacity-60 transition hover:opacity-100" style={{ background: theme.node.fill, color: theme.node.text }} onClick={() => openConfigDialog(true)} title="配置文本模型">
|
||||
{activeModel || "配置模型"}
|
||||
</button>
|
||||
<AgentTextModelPicker config={effectiveConfig} value={effectiveConfig.textModel} onChange={(model) => 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 (
|
||||
<Select value={current} onValueChange={onChange}>
|
||||
<SelectTrigger
|
||||
hideChevron
|
||||
className="h-7 min-w-0 max-w-[220px] gap-1.5 border-0 bg-transparent px-1 py-0 text-xs font-normal shadow-none hover:bg-transparent hover:opacity-75 focus-visible:border-transparent focus-visible:ring-0 data-[state=open]:ring-0 dark:bg-transparent dark:hover:bg-transparent"
|
||||
title={current ? `${modelOptionName(current)} · ${resolveModelChannel(config, current).name}` : "选择文本模型"}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<AgentModelIcon model={current} />
|
||||
<span className="min-w-0 truncate">{current ? modelOptionName(current) : "选择文本模型"}</span>
|
||||
{current ? <span className="shrink-0 opacity-55">{resolveModelChannel(config, current).name}</span> : null}
|
||||
</SelectTrigger>
|
||||
<SelectContent data-canvas-no-zoom className="z-[1200] w-72 max-w-[calc(100vw-24px)]" position="popper" align="start" side="bottom" sideOffset={6} onPointerDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()}>
|
||||
{options.length ? (
|
||||
options.map((model) => (
|
||||
<SelectItem key={model} value={model} textValue={`${modelOptionName(model)} ${resolveModelChannel(config, model).name}`}>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<AgentModelIcon model={model} />
|
||||
<span className="min-w-0 flex-1 truncate">{modelOptionName(model)}</span>
|
||||
<span className="shrink-0 text-xs opacity-55">{resolveModelChannel(config, model).name}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem value="__empty_text_model__" disabled>
|
||||
暂无文本模型
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentModelIcon({ model }: { model: string }) {
|
||||
const icon = resolveModelIcon(modelOptionName(model));
|
||||
return icon ? <img src={icon} alt="" className="size-4 shrink-0 dark:invert" /> : <Cpu className="size-4 shrink-0 opacity-70" />;
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user