"use client"; import { useEffect, useMemo, useState } from "react"; import { ArrowUp, History, ImageIcon, LoaderCircle, MessageSquare, PanelRightClose, Plus, RotateCcw, Settings2, Sparkles, Trash2, X } from "lucide-react"; import { Button, Modal, Tooltip } from "antd"; import { motion } from "motion/react"; import { ImageGenerationPending } from "@/components/image-generation-pending"; import { ModelPicker } from "@/components/model-picker"; import { useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; import { CreditSymbol, requestCreditCost } from "@/constant/credits"; import { canvasThemes } from "@/lib/canvas-theme"; import { nanoid } from "nanoid"; import { cn } from "@/lib/utils"; import { requestEdit, requestGeneration, requestImageQuestion, type ChatCompletionMessage } from "@/services/api/image"; import { imageToDataUrl, uploadImage } from "@/services/image-storage"; import { useAssetStore } from "@/stores/use-asset-store"; import { useThemeStore } from "@/stores/use-theme-store"; import { imageReferenceLabel } from "@/lib/image-reference-prompt"; import type { ReferenceImage } from "@/types/image"; import { DiaTextReveal } from "@/components/ui/dia-text-reveal"; import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover"; import { CanvasPromptLibrary } from "./canvas-prompt-library"; import { CanvasNodeType, type CanvasAssistantImage, type CanvasAssistantMessage, type CanvasAssistantReference, type CanvasAssistantSession, type CanvasNodeData } from "../types"; type AssistantMode = "ask" | "image"; const PANEL_MOTION_MS = 500; const PANEL_MOTION_SECONDS = PANEL_MOTION_MS / 1000; type CanvasAssistantPanelProps = { nodes: CanvasNodeData[]; selectedNodeIds: Set; sessions: CanvasAssistantSession[]; activeSessionId: string | null; onSelectNodeIds: (ids: Set) => void; onSessionsChange: (sessions: CanvasAssistantSession[], activeSessionId: string | null) => void; onInsertImage: (image: CanvasAssistantImage) => void; onInsertText: (text: string) => void; onPasteImage: (file: File) => void; onCollapseStart: () => void; onCollapse: () => void; }; export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeSessionId, onSelectNodeIds, onSessionsChange, onInsertImage, onInsertText, onPasteImage, onCollapseStart, onCollapse }: CanvasAssistantPanelProps) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const effectiveConfig = useEffectiveConfig(); const modelCosts = useConfigStore((state) => state.publicSettings?.modelChannel.modelCosts); const cleanupImages = useAssetStore((state) => state.cleanupImages); const updateConfig = useConfigStore((state) => state.updateConfig); const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady); const openConfigDialog = useConfigStore((state) => state.openConfigDialog); const [width, setWidth] = useState(390); const [view, setView] = useState<"chat" | "history">("chat"); const [mode, setMode] = useState("image"); const [prompt, setPrompt] = useState(""); const [isRunning, setIsRunning] = useState(false); const [checkedChatIds, setCheckedChatIds] = useState([]); const [deleteChatIds, setDeleteChatIds] = 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()])); const [localActiveSessionId, setLocalActiveSessionId] = useState(activeSessionId); useEffect(() => { if (!sessions.length) return; setLocalSessions(sessions); setLocalActiveSessionId(activeSessionId); }, [activeSessionId, sessions]); useEffect(() => { onSessionsChange(localSessions, localActiveSessionId); }, [localActiveSessionId, localSessions, onSessionsChange]); const safeSessions = localSessions.length ? localSessions : [createSession()]; const activeSession = useMemo(() => safeSessions.find((session) => session.id === localActiveSessionId) || safeSessions[0] || null, [localActiveSessionId, safeSessions]); const historySessions = safeSessions.filter((session) => session.messages.length > 0); const messages = activeSession?.messages || []; const hasMessages = messages.length > 0; const selectedNodeKey = useMemo(() => Array.from(selectedNodeIds).sort().join(","), [selectedNodeIds]); const allSelectedReferences = useMemo(() => buildAssistantReferences(nodes, selectedNodeIds), [nodes, selectedNodeIds]); const selectedReferences = useMemo(() => allSelectedReferences.filter((item) => !removedReferenceIds.has(item.id)), [allSelectedReferences, removedReferenceIds]); const iconButtonStyle = { color: theme.node.muted }; useEffect(() => { setRemovedReferenceIds(new Set()); }, [selectedNodeKey]); const updateSession = (sessionId: string, updater: (session: CanvasAssistantSession) => CanvasAssistantSession) => { setLocalSessions((prev) => prev.map((session) => (session.id === sessionId ? updater(session) : session))); }; const appendMessage = (sessionId: string, message: CanvasAssistantMessage) => { updateSession(sessionId, (session) => ({ ...session, title: session.messages.length ? session.title : message.text.slice(0, 18) || "新对话", messages: [...session.messages, message], updatedAt: new Date().toISOString(), })); }; const updateMessage = (sessionId: string, messageId: string, patch: Partial) => { updateSession(sessionId, (session) => ({ ...session, messages: session.messages.map((message) => (message.id === messageId ? { ...message, ...patch } : message)), updatedAt: new Date().toISOString(), })); }; const startChatSession = () => { if (activeSession && activeSession.messages.length === 0) { setLocalActiveSessionId(activeSession.id); return; } const session = createSession(); setLocalSessions((prev) => [session, ...prev]); setLocalActiveSessionId(session.id); }; const removeSessions = (ids: string[]) => { const next = safeSessions.filter((session) => !ids.includes(session.id)); if (!next.length) { const session = createSession(); setLocalSessions([session]); setLocalActiveSessionId(session.id); } else { setLocalSessions(next); setLocalActiveSessionId(localActiveSessionId && ids.includes(localActiveSessionId) ? next[0].id : localActiveSessionId); } cleanupImages({ sessions: next }); setCheckedChatIds((prev) => prev.filter((id) => !ids.includes(id))); }; const clearSessions = () => { const session = createSession(); setLocalSessions([session]); setLocalActiveSessionId(session.id); setCheckedChatIds([]); cleanupImages({ sessions: [session] }); }; const sendMessage = async (text: string, nextMode: AssistantMode, history: CanvasAssistantMessage[], savedReferences?: CanvasAssistantReference[]) => { const requestConfig = { ...effectiveConfig, model: nextMode === "image" ? effectiveConfig.imageModel || effectiveConfig.model : effectiveConfig.textModel || effectiveConfig.model }; if (!isAiConfigReady(requestConfig, requestConfig.model)) { openConfigDialog(true); return; } const session = activeSession || createSession(); if (!activeSession) { setLocalSessions([session]); setLocalActiveSessionId(session.id); } const refs = savedReferences || selectedReferences; const userMessage: CanvasAssistantMessage = { id: nanoid(), role: "user", mode: nextMode, text, references: refs }; const assistantId = nanoid(); appendMessage(session.id, userMessage); appendMessage(session.id, { id: assistantId, role: "assistant", mode: nextMode, text: nextMode === "image" ? "正在生成图片" : "正在回答", isLoading: true }); setPrompt(""); setIsRunning(true); try { if (nextMode === "image") { const referenceImages: ReferenceImage[] = await Promise.all( refs.filter((item) => item.dataUrl).map(async (item) => ({ id: item.id, name: `${item.title}.png`, type: "image/png", dataUrl: await imageToDataUrl(item), storageKey: item.storageKey })), ); const images = referenceImages.length ? await requestEdit(requestConfig, text, referenceImages) : await requestGeneration(requestConfig, text); const storedImages = await Promise.all(images.map((image) => uploadImage(image.dataUrl))); updateMessage(session.id, assistantId, { text: `生成了 ${storedImages.length} 张图片`, images: storedImages.map((image, index) => ({ id: images[index].id, dataUrl: image.url, storageKey: image.storageKey, prompt: text })), isLoading: false, }); return; } const answer = await requestImageQuestion(requestConfig, await buildChatMessages([...history, userMessage]), (streamed) => { updateMessage(session.id, assistantId, { text: streamed, isLoading: false }); }); updateMessage(session.id, assistantId, { text: answer, isLoading: false }); } catch (error) { updateMessage(session.id, assistantId, { text: error instanceof Error ? error.message : "操作失败", isLoading: false }); } finally { setIsRunning(false); } }; const submit = async () => { const text = prompt.trim(); if (!text || isRunning) return; await sendMessage(text, mode, messages); }; const retryMessage = (message: CanvasAssistantMessage) => { const index = messages.findIndex((item) => item.id === message.id); const userIndex = messages.slice(0, index).findLastIndex((item) => item.role === "user"); const user = messages[userIndex]; if (user) void sendMessage(user.text, user.mode, messages.slice(0, userIndex), user.references); }; const startResize = () => { const move = (event: MouseEvent) => setWidth(Math.min(760, Math.max(320, window.innerWidth - event.clientX))); const stop = () => { setResizing(false); document.body.style.cursor = ""; document.body.style.userSelect = ""; document.removeEventListener("mousemove", move); document.removeEventListener("mouseup", stop); }; setResizing(true); document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; document.addEventListener("mousemove", move); document.addEventListener("mouseup", stop); }; const collapse = () => { setClosing(true); onCollapseStart(); window.setTimeout(onCollapse, PANEL_MOTION_MS); }; return ( } >

将删除 {deleteChatIds.length} 条对话记录,此操作不可撤销。

); } function AssistantComposer({ mode, prompt, isRunning, references, config, onModeChange, onPromptChange, onSubmit, onConfigChange, onMissingConfig, onRemoveReference, onPasteImage, modelCosts, }: { mode: AssistantMode; prompt: string; isRunning: boolean; references: CanvasAssistantReference[]; config: AiConfig; onModeChange: (mode: AssistantMode) => void; onPromptChange: (prompt: string) => void; onSubmit: () => void; onConfigChange: (key: keyof AiConfig, value: string) => void; onMissingConfig: () => void; onRemoveReference: (id: string) => void; onPasteImage: (file: File) => void; modelCosts?: { model: string; credits: number }[]; }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const activeModel = mode === "image" ? config.imageModel || config.model : config.textModel || config.model; const credits = requestCreditCost({ channelMode: config.channelMode, modelCosts, model: activeModel, count: mode === "image" ? config.count : 1 }); return (
event.stopPropagation()}> {references.length ? (
{references.map((item, index) => ( onRemoveReference(item.id)} /> ))}
) : null}