feat: 完善创作工作台与用户诊断支持 (#295)

* feat(canvas): 参考素材 - 增加移除按钮并删除对应连线

* feat(canvas): 完善节点面板交互与拖拽反馈

* fix(canvas): 画布交互与小地图 - 统一浮层层级并完善节点识别

* feat(prompt-optimizer): 增加 AI 提示词优化器及宿主接入

* feat(canvas): 画布 - 增加自动整理节点按钮并补充文档

* chore(web): 前端 - 修正提示词优化器格式

* chore(backend): 后端 - 修正 Go 格式检查

* feat(workspace): 完善诊断导出与本地排障支持

* chore(web): 格式化诊断导出文件

* fix(web-test): 兼容 Bun 测试运行时

* fix(web-test): 对齐模型目录协议断言
This commit is contained in:
QAyong
2026-08-25 13:49:15 +08:00
committed by GitHub
parent 625a97560b
commit cf6c78d540
70 changed files with 6863 additions and 1708 deletions

View File

@@ -4,6 +4,7 @@ import { Crosshair, FolderOpen, ImageIcon, Images, PanelLeftClose, Plus, Search,
import { FloatingDock, type FloatingDockEntry } from "@/components/ui/aceternity/floating-dock";
import { CachedResourceImage } from "@/components/cached-resource-image";
import { useCanvasOverlayLayer } from "@/components/canvas/canvas-overlay-layer";
import { aceternityMotion } from "@/lib/aceternity-motion";
import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
import { canvasThemes } from "@/lib/canvas-theme";
@@ -52,6 +53,7 @@ type CanvasAssetTrayProps = {
export function CanvasAssetTray({ assetImages, canvasImages, showLibrary = true, activeNodeId, onInsertAssetImage, onFocusCanvasImage }: CanvasAssetTrayProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const reducedMotion = useReducedMotion();
const { bringToFront, zIndex } = useCanvasOverlayLayer("asset-tray", "var(--z-panel)");
const rootRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [tab, setTab] = useState<TrayTab>(() => showLibrary ? "library" : "canvas");
@@ -151,18 +153,11 @@ export function CanvasAssetTray({ assetImages, canvasImages, showLibrary = true,
useEffect(() => {
if (!open) return;
const closeTray = (event: PointerEvent) => {
const target = event.target instanceof Node ? event.target : null;
if (target && rootRef.current?.contains(target)) return;
setOpen(false);
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("pointerdown", closeTray, true);
document.addEventListener("keydown", closeOnEscape);
return () => {
document.removeEventListener("pointerdown", closeTray, true);
document.removeEventListener("keydown", closeOnEscape);
};
}, [open]);
@@ -173,12 +168,15 @@ export function CanvasAssetTray({ assetImages, canvasImages, showLibrary = true,
label: open ? "收起素材空间" : `打开素材空间,共 ${(showLibrary ? assetImages.length : 0) + canvasImages.length} 项`,
icon: <span className="relative"><Images /><span className="absolute -right-1.5 -top-1.5 min-w-3 rounded-full px-0.5 text-center text-[var(--fs-nano)] font-bold leading-3" style={{ background: theme.accent.primary, color: theme.accent.onPrimary }}>{(showLibrary ? assetImages.length : 0) + canvasImages.length}</span></span>,
active: open,
onClick: () => setOpen((value) => !value),
onClick: () => {
bringToFront();
setOpen((value) => !value);
},
},
];
return (
<div ref={rootRef} data-canvas-no-zoom className="relative z-[var(--z-panel)]" onPointerDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onWheel={(event) => event.stopPropagation()}>
<div ref={rootRef} data-canvas-no-zoom className="relative" style={{ zIndex }} onPointerDownCapture={bringToFront} onFocusCapture={bringToFront} onPointerDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onWheel={(event) => event.stopPropagation()}>
<AnimatePresence>
{open ? (
<motion.aside

View File

@@ -1,9 +1,10 @@
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useLayoutEffect, useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode, type RefObject } from "react";
import { useEffect, useLayoutEffect, useRef, useState, type PointerEvent as ReactPointerEvent, type RefObject } from "react";
import { createPortal } from "react-dom";
import { LoaderCircle, ScanFace, SquareDashedMousePointer, X } from "lucide-react";
import { CanvasNodeEmotionPanel, type CanvasEmotionCharacter, type CanvasImageEmotionPayload } from "@/components/canvas/canvas-node-emotion-panel";
import { CanvasNodePanelOverlay } from "@/components/canvas/canvas-workspace-overlays";
import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface";
import { aceternityMotion } from "@/lib/aceternity-motion";
import { canvasThemes } from "@/lib/canvas-theme";
@@ -11,7 +12,7 @@ import { buildEmotionImageArtifacts, buildEmotionPrompt, neutralEmotionPreset, t
import { detectCanvasFaces } from "@/lib/canvas/canvas-face-detection";
import { subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport";
import { useThemeStore } from "@/stores/use-theme-store";
import type { CanvasNodeData, ViewportTransform } from "@/types/canvas";
import type { CanvasNodeData, Position, ViewportTransform } from "@/types/canvas";
type WorkspaceStatus = "detecting" | "selecting" | "manual" | "editing" | "generating" | "error";
@@ -19,11 +20,13 @@ type CanvasEmotionWorkspaceProps = {
node: CanvasNodeData;
viewport: ViewportTransform;
containerRef: RefObject<HTMLDivElement | null>;
dragOffset?: Position | null;
isDragging?: boolean;
onClose: () => void;
onConfirm: (payload: CanvasImageEmotionPayload) => void;
};
export function CanvasEmotionWorkspace({ node, viewport, containerRef, onClose, onConfirm }: CanvasEmotionWorkspaceProps) {
export function CanvasEmotionWorkspace({ node, viewport, containerRef, dragOffset, isDragging = false, onClose, onConfirm }: CanvasEmotionWorkspaceProps) {
const dataUrl = node.metadata?.content || "";
const [status, setStatus] = useState<WorkspaceStatus>("detecting");
const [faces, setFaces] = useState<CanvasFaceBox[]>([]);
@@ -134,7 +137,7 @@ export function CanvasEmotionWorkspace({ node, viewport, containerRef, onClose,
<SelectionToolbar node={node} viewport={viewport} containerRef={containerRef} status={status} faceCount={faces.length} error={error} onManualSelect={beginManualSelection} onClose={onClose} />
<AnimatePresence>
{activeCharacter && (status === "editing" || status === "generating") ? (
<EmotionPanelOverlay node={node} viewport={viewport} containerRef={containerRef}>
<CanvasNodePanelOverlay node={node} viewport={viewport} containerRef={containerRef} panelWidth={580} panelHeight={303} dragOffset={dragOffset} isDragging={isDragging}>
<CanvasNodeEmotionPanel
dataUrl={dataUrl}
imageWidth={imageSize.width}
@@ -153,7 +156,7 @@ export function CanvasEmotionWorkspace({ node, viewport, containerRef, onClose,
onClose={onClose}
onConfirm={() => void confirmGeneration()}
/>
</EmotionPanelOverlay>
</CanvasNodePanelOverlay>
) : null}
</AnimatePresence>
</>,
@@ -391,16 +394,6 @@ function SelectionToolbar({
);
}
function EmotionPanelOverlay({ node, viewport, containerRef, children }: { node: CanvasNodeData; viewport: ViewportTransform; containerRef: RefObject<HTMLDivElement | null>; children: ReactNode }) {
const panelRef = useRef<HTMLDivElement>(null);
useScreenAnchor(panelRef, node, viewport, containerRef, (next, container) => panelScreenRect(node, next, container, panelRef.current));
return (
<div ref={panelRef} data-canvas-no-zoom className="absolute z-[var(--z-modal-overlay)] w-[580px] max-w-[calc(100%_-_24px)]" style={{ left: 12, top: 76 }} onPointerDown={(event) => event.stopPropagation()}>
{children}
</div>
);
}
function useScreenAnchor(
ref: RefObject<HTMLElement | null>,
node: CanvasNodeData,
@@ -457,19 +450,6 @@ function toolbarScreenRect(node: CanvasNodeData, viewport: ViewportTransform, co
return { left, top };
}
function panelScreenRect(node: CanvasNodeData, viewport: ViewportTransform, container: HTMLDivElement, element: HTMLElement | null) {
const width = Math.min(element?.offsetWidth || 580, Math.max(320, container.clientWidth - 24));
const height = element?.offsetHeight || 303;
const nodeRect = imageScreenRect(node, viewport, node.metadata?.naturalWidth || node.width, node.metadata?.naturalHeight || node.height);
const viewportLeft = container.scrollLeft;
const viewportTop = container.scrollTop;
const left = clamp(nodeRect.left + nodeRect.width / 2 - width / 2, viewportLeft + 12, Math.max(viewportLeft + 12, viewportLeft + container.clientWidth - width - 12));
const below = nodeRect.top + nodeRect.height + 10;
const above = nodeRect.top - height - 10;
const top = below + height <= viewportTop + container.clientHeight - 12 ? below : above;
return { left, top: clamp(top, viewportTop + 72, Math.max(viewportTop + 72, viewportTop + container.clientHeight - height - 12)) };
}
function sameFace(left: CanvasFaceBox, right: CanvasFaceBox) {
return left.id === right.id || (Math.abs(left.x - right.x) < 1 && Math.abs(left.y - right.y) < 1 && Math.abs(left.width - right.width) < 1 && Math.abs(left.height - right.height) < 1);
}

View File

@@ -1,20 +1,28 @@
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { CachedResourceImage } from "@/components/cached-resource-image";
import { canvasThemes } from "@/lib/canvas-theme";
import { isFrameNode, isNodeHiddenByCollapsedFrame } from "@/lib/canvas/canvas-frame";
import { buildLibTVImagePreviewUrl } from "@/lib/canvas/libtv-import";
import { getNodeLabel } from "@/lib/canvas/node-registry/node-registry";
import { subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasNodeType, type CanvasNodeData, type ViewportTransform } from "@/types/canvas";
const MINIMAP_WIDTH = 240;
const MINIMAP_HEIGHT = 160;
const MINIMAP_IMAGE_PREVIEW_LIMIT = 24;
export function Minimap({ nodes, viewport, viewportSize, canvasContainerRef, onViewportPreviewChange, onViewportChange }: { nodes: CanvasNodeData[]; viewport: ViewportTransform; viewportSize: { width: number; height: number }; canvasContainerRef?: RefObject<HTMLDivElement | null>; onViewportPreviewChange?: (viewport: ViewportTransform) => void; onViewportChange: (viewport: ViewportTransform) => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const containerRef = useRef<HTMLDivElement>(null);
const viewportRectRef = useRef<HTMLDivElement>(null);
const liveViewportRef = useRef(viewport);
const [isDragging, setIsDragging] = useState(false);
const width = 240;
const height = 160;
const width = MINIMAP_WIDTH;
const height = MINIMAP_HEIGHT;
const displayNodes = useMemo(() => nodes.filter((node) => !isNodeHiddenByCollapsedFrame(node, nodes)), [nodes]);
const showImagePreviews = useMemo(() => displayNodes.filter((node) => node.type === CanvasNodeType.Image && Boolean(getImagePreviewSource(node) || node.metadata?.storageKey)).length <= MINIMAP_IMAGE_PREVIEW_LIMIT, [displayNodes]);
const { worldBounds, scale, offset } = useMemo(() => {
if (!displayNodes.length) {
@@ -151,6 +159,8 @@ export function Minimap({ nodes, viewport, viewportSize, canvasContainerRef, onV
const pos = toMinimap(node.position.x, node.position.y);
const frame = isFrameNode(node);
const color = node.type === CanvasNodeType.Image ? "#10b981" : node.type === CanvasNodeType.Video ? "#f97316" : node.type === CanvasNodeType.Audio ? "#a855f7" : node.type === CanvasNodeType.Config ? "#60a5fa" : node.type === CanvasNodeType.Skill ? "#818cf8" : frame ? theme.frame.stroke : theme.node.muted;
const imagePreviewSource = showImagePreviews && node.type === CanvasNodeType.Image ? getImagePreviewSource(node) : "";
const nodeLabel = node.title?.trim() || getNodeLabel(node.type);
return (
<div
key={node.id}
@@ -164,7 +174,30 @@ export function Minimap({ nodes, viewport, viewportSize, canvasContainerRef, onV
border: frame ? `1px solid ${color}` : undefined,
opacity: frame ? 0.95 : 0.8,
}}
/>
>
<div className="absolute inset-0 overflow-hidden rounded-[1px]">
{imagePreviewSource || (showImagePreviews && node.type === CanvasNodeType.Image && node.metadata?.storageKey) ? (
<CachedResourceImage
storageKey={node.metadata?.storageKey}
src={imagePreviewSource}
alt=""
loading="lazy"
decoding="async"
draggable={false}
className="size-full object-cover"
fallback={null}
/>
) : null}
</div>
<span
title={nodeLabel}
aria-label={nodeLabel}
className="pointer-events-none absolute left-0 top-0 z-[1] max-w-[7.5rem] -translate-y-1/2 truncate whitespace-nowrap rounded-[2px] border px-1 py-px text-[9px] font-medium leading-[1.15] shadow-sm"
style={{ color: theme.node.text, background: theme.toolbar.panel, borderColor: theme.toolbar.border }}
>
{nodeLabel}
</span>
</div>
);
})}
<div ref={viewportRectRef} className="pointer-events-none absolute rounded-[var(--r-xs)]" style={{ left: viewportRect.x, top: viewportRect.y, width: viewportRect.w, height: viewportRect.h, background: `${theme.node.activeStroke}12`, boxShadow: `inset 0 0 0 1px ${theme.node.activeStroke}66` }} />
@@ -172,3 +205,9 @@ export function Minimap({ nodes, viewport, viewportSize, canvasContainerRef, onV
</div>
);
}
function getImagePreviewSource(node: CanvasNodeData) {
if (node.type !== CanvasNodeType.Image) return "";
const content = node.metadata?.content || "";
return node.metadata?.previewContent || (node.metadata?.importSource?.provider === "libtv" ? buildLibTVImagePreviewUrl(content) : content);
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { ArrowUp, AtSign, Boxes, ChevronDown, FileText, ImageIcon, ImagePlus, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video } from "lucide-react";
import { ArrowUp, AtSign, Boxes, ChevronDown, FileText, ImageIcon, ImagePlus, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video, WandSparkles, X } from "lucide-react";
import { Button, Image as AntImage, InputNumber, Modal, Tooltip } from "antd";
import { ModelPicker } from "@/components/model-picker";
@@ -19,8 +19,12 @@ import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
import { CanvasVideoPromptTools } from "./canvas-video-prompt-tools";
import { CanvasPresetPicker, type CanvasPromptPreset } from "./canvas-preset-picker";
import { CanvasPortraitTexturePopover } from "./canvas-portrait-texture-popover";
import { CanvasPromptOptimizerDrawer } from "./canvas-prompt-optimizer-drawer";
import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData, type CanvasNodeMetadata, type CanvasWorkspaceMode } from "@/types/canvas";
import { canvasResourceMentionToken, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { promptOptimizerPlugin, PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimizer";
import { createPluginHostContext } from "@/services/plugin-host";
import { usePluginStore } from "@/stores/use-plugin-store";
export type CanvasNodeGenerationMode = CanvasGenerationMode;
@@ -31,6 +35,9 @@ type CanvasNodePromptPanelProps = {
onConfigChange: (nodeId: string, patch: Partial<CanvasNodeMetadata>) => void;
onGenerate: (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => void;
mentionReferences?: CanvasResourceReference[];
onRemoveReference?: (nodeId: string, reference: CanvasResourceReference) => void;
onClose?: () => void;
onNodeMouseDown?: (event: ReactPointerEvent, nodeId: string) => void;
onImageSettingsOpenChange?: (open: boolean) => void;
workspaceMode?: CanvasWorkspaceMode;
};
@@ -46,11 +53,12 @@ const PROMPT_EDITOR_VERTICAL_PADDING = 12;
const PROMPT_EDITOR_EXPANDED_VERTICAL_PADDING = 20;
const PROMPT_EDITOR_MAX_LINES = 8;
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, mentionReferences = [], onImageSettingsOpenChange, workspaceMode = "professional" }: CanvasNodePromptPanelProps) {
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, mentionReferences = [], onRemoveReference, onClose, onNodeMouseDown, onImageSettingsOpenChange, workspaceMode = "professional" }: CanvasNodePromptPanelProps) {
const globalConfig = useEffectiveConfig();
const themeName = useThemeStore((state) => state.theme);
const theme = canvasThemes[themeName];
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
const promptOptimizerInstallation = usePluginStore((state) => state.installations.find((item) => item.manifest.id === PROMPT_OPTIMIZER_PLUGIN_ID));
const simpleMode = workspaceMode === "simple";
const mode = defaultMode(node.type);
const hasTextContent = node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim());
@@ -65,6 +73,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
const [manualPromptHeight, setManualPromptHeight] = useState<number | null>(null);
const [manualExpandedPromptHeight, setManualExpandedPromptHeight] = useState<number | null>(null);
const [paramsExpanded, setParamsExpanded] = useState(false); // #98 决策2:B区参数区折叠状态(手风琴)
const [promptOptimizerOpen, setPromptOptimizerOpen] = useState(false);
const activeReferences = mentionReferences.filter((item) => item.active && item.kind !== "skill");
const requirements: ModelRequirements = {
capability: mode,
@@ -92,6 +101,10 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
}, mode),
};
const config = buildNodeConfig(globalConfig, node, mode, requirements);
const promptOptimizerProvider = useMemo(() => {
if (!promptOptimizerInstallation?.enabled || !promptOptimizerPlugin.createPromptOptimizer) return null;
return promptOptimizerPlugin.createPromptOptimizer(createPluginHostContext(promptOptimizerPlugin, promptOptimizerInstallation, globalConfig));
}, [globalConfig, promptOptimizerInstallation]);
const generationCount = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
const priceChannel = resolveModelChannel(config, config.model);
const credits = requestCreditCost({
@@ -125,6 +138,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
const expandedComposerHeight = clampPromptHeight(manualExpandedPromptHeight ?? expandedPromptContentHeight + (activeReferenceCount ? PROMPT_REFERENCE_SHELF_HEIGHT : 0), expandedPromptBounds);
const isSubmitDisabled = !isRunning && !prompt.trim();
const canExpandPrompt = mode === "image" || mode === "video";
const canOptimizePrompt = Boolean(promptOptimizerProvider) && canExpandPrompt;
const isPortraitTexture = mode === "image" && Boolean(node.metadata?.portraitTexture);
useEffect(() => {
@@ -183,7 +197,15 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
};
const renderComposerHeader = (expanded: boolean) => (
<div className="canvas-node-composer-header">
<div
className="canvas-node-composer-header cursor-grab select-none active:cursor-grabbing"
data-canvas-node-drag-handle
title="拖动节点"
onPointerDown={(event) => {
const target = event.target instanceof Element ? event.target : null;
if (!target?.closest("button, input, textarea, select, a, [contenteditable=\"true\"], [data-canvas-no-drag]")) onNodeMouseDown?.(event, node.id);
}}
>
{isPortraitTexture ? (
<CanvasPortraitTexturePopover value={node.metadata?.portraitTexture} placement={expanded ? "topRight" : "topLeft"} onChange={(portraitTexture) => onConfigChange(node.id, { portraitTexture })} />
) : (
@@ -195,6 +217,20 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
</div>
)}
{!simpleMode ? <CanvasPresetPicker mode={mode} skillReferences={skillReferences} open={expanded ? expandedPresetOpen : presetOpen} onOpenChange={expanded ? setExpandedPresetOpen : setPresetOpen} onSelect={applyPreset} dense /> : null}
{canOptimizePrompt ? (
<Tooltip title="用 AI 优化提示词">
<button
type="button"
className="canvas-node-composer-icon-button inline-flex h-6 shrink-0 items-center gap-1 rounded-md px-1.5 transition-[background-color,filter] hover:brightness-125 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 motion-reduce:hover:translate-y-0"
style={{ background: controlSurface, color: theme.node.text, outlineColor: monochromeAccent }}
onClick={() => setPromptOptimizerOpen(true)}
aria-label="优化提示词"
>
<WandSparkles className="size-3" />
<span className="hidden text-[var(--fs-micro)] font-medium sm:inline">优化</span>
</button>
</Tooltip>
) : null}
<div className="ml-auto flex shrink-0 items-center justify-end gap-1">
{activeReferenceCount ? <ComposerPill theme={theme} icon={<Boxes className="size-2.5" />} label={`参考 ${activeReferenceCount}`} /> : null}
{!expanded && canExpandPrompt ? (
@@ -210,6 +246,19 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
</button>
</Tooltip>
) : null}
{!expanded && onClose ? (
<Tooltip title="关闭">
<button
type="button"
className="canvas-node-composer-icon-button grid size-6 shrink-0 place-items-center rounded-md transition-[background-color,filter] hover:brightness-125 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 motion-reduce:hover:translate-y-0"
style={{ background: controlSurface, color: theme.node.text, outlineColor: monochromeAccent }}
onClick={onClose}
aria-label="关闭创作面板"
>
<X className="size-3" />
</button>
</Tooltip>
) : null}
</div>
</div>
);
@@ -318,7 +367,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
return (
<>
<div className="canvas-node-composer-editor" style={{ height }}>
<ConnectedReferenceShelf references={mentionReferences} theme={theme} onInsert={insertPromptReference} />
<ConnectedReferenceShelf references={mentionReferences} theme={theme} onInsert={insertPromptReference} onRemove={(reference) => onRemoveReference?.(node.id, reference)} />
<CanvasResourceMentionTextarea
value={prompt}
references={mentionReferences}
@@ -345,13 +394,26 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
};
return (
<div
className="canvas-node-composer"
style={composerSurfaceStyle}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
<CanvasPromptOptimizerDrawer
open={promptOptimizerOpen}
prompt={prompt}
generationMode={mode === "image" || mode === "video" ? mode : "image"}
targetModel={modelOptionName(config.model) || config.model}
targetProtocol={priceChannel.modelCosts?.find((item) => item.model === modelOptionName(config.model))?.protocol || priceChannel.interfaceType}
config={globalConfig}
optimizerModel={globalConfig.textModel}
references={activeReferences}
provider={promptOptimizerProvider}
onClose={() => setPromptOptimizerOpen(false)}
onApply={(nextPrompt) => updatePrompt(nextPrompt)}
>
<div
className="canvas-node-composer"
style={composerSurfaceStyle}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
>
{renderComposerHeader(false)}
{renderPromptEditor(false)}
@@ -409,7 +471,9 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
<div className="shrink-0">{renderComposerControls(true)}</div>
</div>
</Modal>
</div>
</div>
</CanvasPromptOptimizerDrawer>
);
}
@@ -436,7 +500,7 @@ function modeDisplayName(mode: CanvasNodeGenerationMode) {
return "文本";
}
function ConnectedReferenceShelf({ references, theme, onInsert }: { references: CanvasResourceReference[]; theme: CanvasTheme; onInsert: (reference: CanvasResourceReference) => void }) {
function ConnectedReferenceShelf({ references, theme, onInsert, onRemove }: { references: CanvasResourceReference[]; theme: CanvasTheme; onInsert: (reference: CanvasResourceReference) => void; onRemove?: (reference: CanvasResourceReference) => void }) {
const activeReferences = references.filter((item) => item.active && item.kind !== "skill");
const [imagePreview, setImagePreview] = useState<CanvasResourceReference | null>(null);
if (!activeReferences.length) return null;
@@ -462,6 +526,22 @@ function ConnectedReferenceShelf({ references, theme, onInsert }: { references:
<AtSign className="size-2.5" />
<span>{reference.label}</span>
</button>
{onRemove ? (
<button
type="button"
className="canvas-node-reference-remove"
style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke, color: theme.node.text }}
title="移除参考并删除连接"
aria-label={`移除参考 ${reference.label}`}
onClick={(event) => {
event.stopPropagation();
onRemove(reference);
}}
onPointerDown={(event) => event.stopPropagation()}
>
<X className="size-3" />
</button>
) : null}
</span>
);
})}

View File

@@ -0,0 +1,53 @@
import { createContext, useCallback, useContext, useMemo, useState, type MouseEventHandler, type PointerEventHandler, type ReactNode, type WheelEventHandler } from "react";
type CanvasOverlayLayerContextValue = {
activeOverlayId: string | null;
bringToFront: (overlayId: string) => void;
};
const CanvasOverlayLayerContext = createContext<CanvasOverlayLayerContextValue | null>(null);
export function CanvasOverlayLayerProvider({ children }: { children: ReactNode }) {
const [activeOverlayId, setActiveOverlayId] = useState<string | null>(null);
const bringToFront = useCallback((overlayId: string) => {
setActiveOverlayId((current) => (current === overlayId ? current : overlayId));
}, []);
const value = useMemo(() => ({ activeOverlayId, bringToFront }), [activeOverlayId, bringToFront]);
return <CanvasOverlayLayerContext.Provider value={value}>{children}</CanvasOverlayLayerContext.Provider>;
}
export function useCanvasOverlayLayer(overlayId: string, fallbackZIndex: string) {
const context = useContext(CanvasOverlayLayerContext);
const bringToFrontFromContext = context?.bringToFront;
const bringToFront = useCallback(() => bringToFrontFromContext?.(overlayId), [bringToFrontFromContext, overlayId]);
const zIndex = context?.activeOverlayId === overlayId ? "var(--z-canvas-overlay-active)" : fallbackZIndex;
return { bringToFront, zIndex };
}
export function CanvasOverlayLayerContainer({
overlayId,
fallbackZIndex,
className,
children,
onMouseDown,
onPointerDown,
onWheel,
}: {
overlayId: string;
fallbackZIndex: string;
className?: string;
children: ReactNode;
onMouseDown?: MouseEventHandler<HTMLDivElement>;
onPointerDown?: PointerEventHandler<HTMLDivElement>;
onWheel?: WheelEventHandler<HTMLDivElement>;
}) {
const { bringToFront, zIndex } = useCanvasOverlayLayer(overlayId, fallbackZIndex);
return (
<div data-canvas-no-zoom className={className} style={{ zIndex }} onPointerDownCapture={bringToFront} onFocusCapture={bringToFront} onMouseDown={onMouseDown} onPointerDown={onPointerDown} onWheel={onWheel}>
{children}
</div>
);
}

View File

@@ -0,0 +1,824 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { Alert, App, Button, Dropdown, Input, Popover, Tag, Typography } from "antd";
import { Bubble, Sender, type BubbleItemType } from "@ant-design/x";
import { ArrowUp, Check, ChevronDown, FileText, Image as ImageIcon, LoaderCircle, Music2, Sparkles, UserRound, Video, X } from "lucide-react";
import { ModelPicker } from "@/components/model-picker";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import type { PromptOptimizationMode, PromptOptimizationResult, PromptOptimizerProvider } from "@/lib/plugins/plugin-types";
import type { AiConfig } from "@/stores/use-config-store";
type CanvasPromptOptimizerDrawerProps = {
open: boolean;
children: ReactNode;
prompt: string;
generationMode: "image" | "video";
targetModel: string;
targetProtocol?: string;
config: AiConfig;
optimizerModel: string;
references: CanvasResourceReference[];
provider: PromptOptimizerProvider | null;
onClose: () => void;
onApply: (prompt: string) => void;
};
const modeOptions: Array<{ label: string; value: PromptOptimizationMode }> = [
{ label: "扩展想法", value: "expand" },
{ label: "精修已有", value: "refine" },
{ label: "强化风格", value: "style" },
{ label: "适配模型", value: "model-adapt" },
{ label: "结合参考", value: "reference" },
];
const modeDescriptions: Record<PromptOptimizationMode, string> = {
expand: "从一句模糊想法补齐主体、场景、构图、光线和镜头等可执行细节。",
refine: "尽量保留原意,只修正表达并补充必要细节,适合已经有初稿的提示词。",
style: "保留主体和动作,重点强化电影感、材质、色彩、光影或指定艺术风格。",
"model-adapt": "根据当前生成模型的习惯调整提示词结构和描述重点,减少模型不易理解的表达。",
reference: "结合已连接的参考图或文本,补充一致性、外观、构图和关联关系。",
};
type PanelSize = { width: number; height: number };
type PanelOffset = { x: number; y: number };
type PanelPosition = { left: number; top: number };
type ResizeEdges = { top?: boolean; right?: boolean; bottom?: boolean; left?: boolean };
const PANEL_MARGIN = 12;
const PANEL_MIN_WIDTH = 300;
const PANEL_MIN_HEIGHT = 360;
const PANEL_MAX_WIDTH = 760;
const PANEL_MAX_HEIGHT = 780;
function clampPanelValue(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), Math.max(min, max));
}
function getPanelResizeBounds() {
const viewportWidth = typeof window === "undefined" ? 1024 : window.innerWidth;
const viewportHeight = typeof window === "undefined" ? 768 : window.innerHeight;
const maxWidth = Math.min(PANEL_MAX_WIDTH, Math.max(0, viewportWidth - PANEL_MARGIN * 2));
const maxHeight = Math.min(PANEL_MAX_HEIGHT, Math.max(0, viewportHeight - PANEL_MARGIN * 2));
return {
minWidth: Math.min(PANEL_MIN_WIDTH, maxWidth),
maxWidth,
minHeight: Math.min(PANEL_MIN_HEIGHT, maxHeight),
maxHeight,
};
}
function getInitialPanelSize(): PanelSize {
const bounds = getPanelResizeBounds();
return {
width: clampPanelValue(420, bounds.minWidth, bounds.maxWidth),
height: clampPanelValue(620, bounds.minHeight, bounds.maxHeight),
};
}
function getInitialPanelPosition(panelSize: PanelSize): PanelPosition {
if (typeof window === "undefined") return { left: PANEL_MARGIN, top: PANEL_MARGIN };
const anchor = [...document.querySelectorAll<HTMLElement>(".canvas-node-composer, .creation-chat-composer")]
.map((element) => ({ element, rect: element.getBoundingClientRect() }))
.find(({ element, rect }) => rect.width > 0 && rect.height > 0 && getComputedStyle(element).visibility !== "hidden");
if (!anchor) return { left: PANEL_MARGIN, top: PANEL_MARGIN };
const { rect } = anchor;
const left = clampPanelValue(rect.left + (rect.width - panelSize.width) / 2, PANEL_MARGIN, window.innerWidth - PANEL_MARGIN - panelSize.width);
const aboveTop = rect.top - panelSize.height - PANEL_MARGIN;
const belowTop = rect.bottom + PANEL_MARGIN;
const hasRoomBelow = belowTop + panelSize.height <= window.innerHeight - PANEL_MARGIN;
const top = aboveTop >= PANEL_MARGIN ? aboveTop : hasRoomBelow ? belowTop : aboveTop;
return {
left,
top: clampPanelValue(top, PANEL_MARGIN, window.innerHeight - PANEL_MARGIN - panelSize.height),
};
}
export function CanvasPromptOptimizerDrawer({ open, children, prompt, generationMode, targetModel, targetProtocol, config, optimizerModel, references, provider, onClose, onApply }: CanvasPromptOptimizerDrawerProps) {
const { message } = App.useApp();
const abortRef = useRef<AbortController | null>(null);
const chatRef = useRef<HTMLDivElement | null>(null);
const [mode, setMode] = useState<PromptOptimizationMode>("expand");
const [activeOptimizerModel, setActiveOptimizerModel] = useState("");
const [draftPrompt, setDraftPrompt] = useState(prompt);
const [submittedPrompt, setSubmittedPrompt] = useState("");
const [selectedReferenceIds, setSelectedReferenceIds] = useState<string[]>([]);
const [result, setResult] = useState<PromptOptimizationResult | null>(null);
const [selectedPrompt, setSelectedPrompt] = useState("");
const [working, setWorking] = useState(false);
const [error, setError] = useState("");
const [streaming, setStreaming] = useState(false);
const [panelSize, setPanelSize] = useState<PanelSize>(getInitialPanelSize);
const [panelOffset, setPanelOffset] = useState<PanelOffset>({ x: 0, y: 0 });
const [panelInteracting, setPanelInteracting] = useState(false);
const panelOffsetRef = useRef(panelOffset);
const panelPositionRef = useRef<PanelPosition | null>(null);
const wasOpenRef = useRef(false);
const interactionCleanupRef = useRef<(() => void) | null>(null);
panelOffsetRef.current = panelOffset;
const activeReferences = useMemo(() => references.filter((reference) => reference.active), [references]);
const selectedReferences = useMemo(() => activeReferences.filter((reference) => selectedReferenceIds.includes(reference.id)), [activeReferences, selectedReferenceIds]);
const optimizationReferences = mode === "reference" ? selectedReferences : activeReferences;
const referenceCount = mode === "reference" ? selectedReferences.length : activeReferences.length;
const textContext = useMemo(
() => optimizationReferences.filter((reference) => (reference.kind === "text" || reference.kind === "character") && reference.text?.trim()).map((reference) => ({ title: reference.title || reference.label, text: reference.text!.trim() })),
[optimizationReferences],
);
const imageContext = useMemo(
() =>
optimizationReferences
.filter((reference) => (reference.kind === "image" || reference.kind === "character") && reference.previewUrl && /^(data:|https?:\/\/)/i.test(reference.previewUrl))
.map((reference) => ({ title: reference.title || reference.label, url: reference.previewUrl! })),
[optimizationReferences],
);
useEffect(() => {
if (!open) return;
abortRef.current?.abort();
setDraftPrompt(prompt);
setSubmittedPrompt(prompt.trim());
setActiveOptimizerModel(optimizerModel);
setSelectedReferenceIds(references.filter((reference) => reference.active).map((reference) => reference.id));
setResult(null);
setSelectedPrompt("");
setError("");
setWorking(false);
setStreaming(false);
}, [open, optimizerModel, prompt]);
useLayoutEffect(() => {
if (open && !wasOpenRef.current) {
panelPositionRef.current = null;
const initialPosition = getInitialPanelPosition(panelSize);
panelPositionRef.current = initialPosition;
const initialOffset = { x: initialPosition.left, y: initialPosition.top };
panelOffsetRef.current = initialOffset;
setPanelOffset(initialOffset);
}
wasOpenRef.current = open;
}, [open, panelSize]);
useEffect(
() => () => {
abortRef.current?.abort();
interactionCleanupRef.current?.();
},
[],
);
useEffect(() => {
if (open) return;
interactionCleanupRef.current?.();
}, [open]);
const syncPanelPositionToViewport = useCallback(() => {
const popoverRoot = document.querySelector<HTMLElement>(".canvas-prompt-optimizer-popover");
if (!popoverRoot) return;
const rect = popoverRoot.getBoundingClientRect();
const currentOffset = panelOffsetRef.current;
const baseLeft = rect.left - currentOffset.x;
const baseTop = rect.top - currentOffset.y;
const currentPosition = panelPositionRef.current ?? { left: rect.left, top: rect.top };
const nextPosition = {
left: clampPanelValue(currentPosition.left, PANEL_MARGIN, window.innerWidth - PANEL_MARGIN - rect.width),
top: clampPanelValue(currentPosition.top, PANEL_MARGIN, window.innerHeight - PANEL_MARGIN - rect.height),
};
panelPositionRef.current = nextPosition;
const nextOffset = {
x: nextPosition.left - baseLeft,
y: nextPosition.top - baseTop,
};
setPanelOffset((current) => {
panelOffsetRef.current = nextOffset;
return nextOffset.x === current.x && nextOffset.y === current.y ? current : nextOffset;
});
}, []);
useEffect(() => {
if (!open) return;
const handleViewportResize = () => {
const bounds = getPanelResizeBounds();
setPanelSize((current) => ({
width: clampPanelValue(current.width, bounds.minWidth, bounds.maxWidth),
height: clampPanelValue(current.height, bounds.minHeight, bounds.maxHeight),
}));
requestAnimationFrame(syncPanelPositionToViewport);
};
window.addEventListener("resize", handleViewportResize);
return () => window.removeEventListener("resize", handleViewportResize);
}, [open, syncPanelPositionToViewport]);
useEffect(() => {
if (!open || panelInteracting) return;
let frame = requestAnimationFrame(() => {
frame = requestAnimationFrame(syncPanelPositionToViewport);
});
return () => cancelAnimationFrame(frame);
}, [open, panelInteracting, panelSize.height, panelSize.width, syncPanelPositionToViewport]);
useEffect(() => {
if (!open) return;
const frame = requestAnimationFrame(() => {
const scrollBox = chatRef.current?.querySelector<HTMLElement>(".ant-bubble-list-scroll-box");
if (scrollBox) scrollBox.scrollTop = scrollBox.scrollHeight;
});
return () => cancelAnimationFrame(frame);
}, [open, submittedPrompt, working, result, error]);
const beginPanelInteraction = (event: ReactPointerEvent<HTMLElement>, kind: "drag" | "resize", edges: ResizeEdges = {}) => {
if (event.button !== 0) return;
const popoverRoot = event.currentTarget.closest<HTMLElement>(".canvas-prompt-optimizer-popover");
if (!popoverRoot) return;
event.preventDefault();
event.stopPropagation();
interactionCleanupRef.current?.();
const startX = event.clientX;
const startY = event.clientY;
const startSize = panelSize;
const startRect = popoverRoot.getBoundingClientRect();
const startPosition = panelPositionRef.current ?? { left: startRect.left, top: startRect.top };
panelPositionRef.current = startPosition;
const basePosition = {
left: startRect.left - panelOffsetRef.current.x,
top: startRect.top - panelOffsetRef.current.y,
};
const interactionTarget = event.currentTarget;
const pointerId = event.pointerId;
const previousUserSelect = document.body.style.userSelect;
const previousCursor = document.body.style.cursor;
const horizontalResize = Boolean(edges.left || edges.right);
const verticalResize = Boolean(edges.top || edges.bottom);
const cursor = kind === "drag" ? "grabbing" : horizontalResize && verticalResize ? (edges.top === edges.left ? "nwse-resize" : "nesw-resize") : horizontalResize ? "ew-resize" : "ns-resize";
let frameId: number | null = null;
let pendingPosition: PanelPosition | null = null;
let pendingSize: PanelSize | null = null;
let latestSize = startSize;
const commitPanelLayout = (position: PanelPosition, size?: PanelSize) => {
const nextOffset = {
x: position.left - basePosition.left,
y: position.top - basePosition.top,
};
panelPositionRef.current = position;
panelOffsetRef.current = nextOffset;
popoverRoot.style.setProperty("translate", `${nextOffset.x}px ${nextOffset.y}px`);
if (size) {
latestSize = size;
popoverRoot.style.setProperty("--canvas-prompt-optimizer-width", `${size.width}px`);
popoverRoot.style.setProperty("--canvas-prompt-optimizer-height", `${size.height}px`);
}
};
const flushPendingLayout = () => {
if (frameId !== null) {
cancelAnimationFrame(frameId);
frameId = null;
}
if (!pendingPosition && !pendingSize) return;
const nextPosition = pendingPosition ?? panelPositionRef.current ?? startPosition;
const nextSize = pendingSize;
pendingPosition = null;
pendingSize = null;
commitPanelLayout(nextPosition, nextSize || undefined);
};
const schedulePanelLayout = (position: PanelPosition, size?: PanelSize) => {
pendingPosition = position;
pendingSize = size || pendingSize;
if (frameId !== null) return;
frameId = requestAnimationFrame(() => {
frameId = null;
if (!pendingPosition && !pendingSize) return;
const nextPosition = pendingPosition ?? panelPositionRef.current ?? startPosition;
const nextSize = pendingSize;
pendingPosition = null;
pendingSize = null;
commitPanelLayout(nextPosition, nextSize || undefined);
});
};
document.body.style.userSelect = "none";
document.body.style.cursor = cursor;
try {
interactionTarget.setPointerCapture(pointerId);
} catch {
// 某些浏览器在指针已被外层捕获时会拒绝重复捕获,窗口级监听仍可继续完成拖动。
}
setPanelInteracting(true);
const handleMove = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId !== pointerId) return;
moveEvent.preventDefault();
const deltaX = moveEvent.clientX - startX;
const deltaY = moveEvent.clientY - startY;
if (kind === "drag") {
schedulePanelLayout({
left: clampPanelValue(startPosition.left + deltaX, PANEL_MARGIN, window.innerWidth - PANEL_MARGIN - startRect.width),
top: clampPanelValue(startPosition.top + deltaY, PANEL_MARGIN, window.innerHeight - PANEL_MARGIN - startRect.height),
});
return;
}
const bounds = getPanelResizeBounds();
let nextWidth = startSize.width;
let nextHeight = startSize.height;
if (edges.right) {
nextWidth = clampPanelValue(startSize.width + deltaX, bounds.minWidth, Math.min(bounds.maxWidth, window.innerWidth - PANEL_MARGIN - startPosition.left));
}
if (edges.left) {
nextWidth = clampPanelValue(startSize.width - deltaX, bounds.minWidth, Math.min(bounds.maxWidth, startPosition.left + startSize.width - PANEL_MARGIN));
}
if (edges.bottom) {
nextHeight = clampPanelValue(startSize.height + deltaY, bounds.minHeight, Math.min(bounds.maxHeight, window.innerHeight - PANEL_MARGIN - startPosition.top));
}
if (edges.top) {
nextHeight = clampPanelValue(startSize.height - deltaY, bounds.minHeight, Math.min(bounds.maxHeight, startPosition.top + startSize.height - PANEL_MARGIN));
}
const nextPosition = {
left: clampPanelValue(edges.left ? startPosition.left + startSize.width - nextWidth : startPosition.left, PANEL_MARGIN, window.innerWidth - PANEL_MARGIN - nextWidth),
top: clampPanelValue(edges.top ? startPosition.top + startSize.height - nextHeight : startPosition.top, PANEL_MARGIN, window.innerHeight - PANEL_MARGIN - nextHeight),
};
schedulePanelLayout(nextPosition, { width: nextWidth, height: nextHeight });
};
const cleanup = () => {
window.removeEventListener("pointermove", handleMove);
window.removeEventListener("pointerup", cleanup);
window.removeEventListener("pointercancel", cleanup);
flushPendingLayout();
try {
if (interactionTarget.hasPointerCapture(pointerId)) interactionTarget.releasePointerCapture(pointerId);
} catch {
// 指针已由浏览器释放时无需再次释放。
}
document.body.style.userSelect = previousUserSelect;
document.body.style.cursor = previousCursor;
setPanelOffset((current) => {
const next = panelOffsetRef.current;
return current.x === next.x && current.y === next.y ? current : { ...next };
});
if (kind === "resize") setPanelSize(latestSize);
setPanelInteracting(false);
if (interactionCleanupRef.current === cleanup) interactionCleanupRef.current = null;
};
interactionCleanupRef.current = cleanup;
window.addEventListener("pointermove", handleMove, { passive: false });
window.addEventListener("pointerup", cleanup);
window.addEventListener("pointercancel", cleanup);
};
const nudgePanel = (deltaX: number, deltaY: number) => {
const popoverRoot = document.querySelector<HTMLElement>(".canvas-prompt-optimizer-popover");
if (!popoverRoot) {
setPanelOffset((current) => ({ x: current.x + deltaX, y: current.y + deltaY }));
return;
}
const rect = popoverRoot.getBoundingClientRect();
const currentPosition = panelPositionRef.current ?? { left: rect.left, top: rect.top };
const currentOffset = panelOffsetRef.current;
const baseLeft = rect.left - currentOffset.x;
const baseTop = rect.top - currentOffset.y;
const nextPosition = {
left: clampPanelValue(currentPosition.left + deltaX, PANEL_MARGIN, window.innerWidth - PANEL_MARGIN - rect.width),
top: clampPanelValue(currentPosition.top + deltaY, PANEL_MARGIN, window.innerHeight - PANEL_MARGIN - rect.height),
};
const nextOffset = {
x: nextPosition.left - baseLeft,
y: nextPosition.top - baseTop,
};
panelPositionRef.current = nextPosition;
panelOffsetRef.current = nextOffset;
setPanelOffset(nextOffset);
};
const handlePanelMoveKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
const step = event.shiftKey ? 48 : 16;
if (event.key === "ArrowLeft") nudgePanel(-step, 0);
else if (event.key === "ArrowRight") nudgePanel(step, 0);
else if (event.key === "ArrowUp") nudgePanel(0, -step);
else if (event.key === "ArrowDown") nudgePanel(0, step);
else return;
event.preventDefault();
};
const runOptimization = async (requestedPrompt = draftPrompt) => {
const promptValue = requestedPrompt.trim();
if (!provider || working || !promptValue || (mode === "reference" && !selectedReferences.length)) return;
const controller = new AbortController();
abortRef.current?.abort();
abortRef.current = controller;
setWorking(true);
setStreaming(false);
setError("");
setSubmittedPrompt(promptValue);
setDraftPrompt("");
setResult(null);
setSelectedPrompt("");
try {
const optimized = await provider.optimize(
{
prompt: promptValue,
mode,
generationMode,
targetModel,
targetProtocol,
optimizerModel: activeOptimizerModel || undefined,
context: { texts: textContext, images: imageContext },
},
{
signal: controller.signal,
onDelta: () => setStreaming(true),
},
);
if (controller.signal.aborted) return;
setResult(optimized);
setSelectedPrompt(optimized.optimizedPrompt);
} catch (reason) {
if (controller.signal.aborted) return;
setDraftPrompt(promptValue);
setError(reason instanceof Error ? reason.message : "提示词优化失败,请稍后重试");
} finally {
if (!controller.signal.aborted) {
setWorking(false);
setStreaming(false);
}
}
};
const applyPrompt = () => {
const value = selectedPrompt.trim();
if (!value) return;
onApply(value);
onClose();
message.success("提示词已采用并回填到当前输入框");
};
const modeMenuItems = modeOptions.map((option) => ({
key: option.value,
label: (
<span className="canvas-prompt-optimizer-mode-option">
<span>{option.label}</span>
<small>{modeDescriptions[option.value]}</small>
</span>
),
}));
const bubbleItems: BubbleItemType[] = [
{
key: "assistant-intro",
role: "ai",
content: (
<div className="canvas-prompt-optimizer-bubble is-assistant-bubble">
<div className="canvas-prompt-optimizer-message-meta">
<span>提示词助手</span>
<span>当前模式:{modeOptions.find((option) => option.value === mode)?.label}</span>
</div>
<p>{modeDescriptions[mode]}</p>
{mode === "reference" ? (
<ReferenceSelection
references={activeReferences}
selectedReferences={selectedReferences}
onToggle={(referenceId) => setSelectedReferenceIds((current) => (current.includes(referenceId) ? current.filter((id) => id !== referenceId) : [...current, referenceId]))}
/>
) : activeReferences.length ? (
<div className="canvas-prompt-optimizer-chat-reference">
<span>将结合参考</span>
<div className="flex min-w-0 flex-wrap gap-1.5">
{activeReferences.map((reference) => (
<Tag key={reference.id}>{reference.title || reference.label}</Tag>
))}
</div>
</div>
) : null}
</div>
),
},
];
if (submittedPrompt) {
bubbleItems.push({
key: "user-prompt",
role: "user",
content: (
<div className="canvas-prompt-optimizer-user-content">
<div className="canvas-prompt-optimizer-message-meta">
<span>你</span>
<span>{submittedPrompt.length} 字</span>
</div>
<div className="canvas-prompt-optimizer-bubble is-user-bubble">{submittedPrompt}</div>
</div>
),
});
}
if (working) {
bubbleItems.push({
key: "working",
role: "ai",
content: (
<div className="canvas-prompt-optimizer-bubble is-assistant-bubble is-working" role="status">
<LoaderCircle className="size-3.5 animate-spin motion-reduce:animate-none" />
<span>{streaming ? "正在接收优化结果…" : "正在整理你的想法…"}</span>
</div>
),
});
}
if (error) {
bubbleItems.push({
key: "error",
role: "system",
content: <Alert className="canvas-prompt-optimizer-alert" type="error" showIcon message="优化失败" description={error} />,
});
}
if (result) {
bubbleItems.push({
key: "result",
role: "ai",
content: (
<div className="canvas-prompt-optimizer-bubble is-assistant-bubble is-result-bubble">
<div className="canvas-prompt-optimizer-message-meta">
<span>提示词助手</span>
{result.modelProfile ? (
<Tag color="blue" bordered={false}>
已按 {result.modelProfile.label} 适配
</Tag>
) : null}
</div>
<div className="canvas-prompt-optimizer-result-heading">
<span>我建议这样写</span>
<Button type="primary" size="small" icon={<Check className="size-3.5" />} onClick={applyPrompt} disabled={!selectedPrompt.trim()} aria-label="采用优化后的提示词">
采用
</Button>
</div>
<Input.TextArea
className="canvas-prompt-optimizer-textarea canvas-prompt-optimizer-result-textarea"
value={selectedPrompt}
onChange={(event) => setSelectedPrompt(event.target.value)}
autoSize={{ minRows: 4, maxRows: 10 }}
aria-label="优化后的提示词"
/>
{result.negativePrompt ? (
<div className="canvas-prompt-optimizer-chat-detail">
<span>建议规避</span>
<p>{result.negativePrompt}</p>
</div>
) : null}
{result.changes.length ? <ResultList title="我做了什么" items={result.changes} /> : null}
{result.assumptions.length ? <ResultList title="需要你确认" items={result.assumptions} warning /> : null}
{result.variants.length ? (
<div className="canvas-prompt-optimizer-subsection">
<div className="canvas-prompt-optimizer-field-label">
<span>备选版本</span>
</div>
<div className="space-y-2">
{result.variants.map((variant) => {
const selected = selectedPrompt === variant.prompt;
return (
<button
key={`${variant.label}-${variant.prompt}`}
type="button"
className={`canvas-prompt-optimizer-variant ${selected ? "is-selected" : ""}`}
onClick={() => setSelectedPrompt(variant.prompt)}
aria-pressed={selected}
>
<span className="mb-1 flex items-center gap-1.5 text-[var(--fs-micro)] font-medium text-foreground/72">
{selected ? <Check className="size-3.5 text-primary" /> : null}
{variant.label}
</span>
<span className="line-clamp-3 text-[var(--fs-body)] leading-5 text-foreground/70">{variant.prompt}</span>
</button>
);
})}
</div>
</div>
) : null}
</div>
),
});
}
const popoverRootStyle = {
"--canvas-prompt-optimizer-width": `${panelSize.width}px`,
"--canvas-prompt-optimizer-height": `${panelSize.height}px`,
translate: `${panelOffset.x}px ${panelOffset.y}px`,
} as CSSProperties;
const content = (
<div className={`canvas-prompt-optimizer-panel${panelInteracting ? " is-interacting" : ""}`} data-canvas-no-zoom>
<div
className="canvas-prompt-optimizer-header"
onPointerDown={(event) => {
if ((event.target as Element | null)?.closest("button")) return;
beginPanelInteraction(event, "drag");
}}
>
<div className="canvas-prompt-optimizer-header-main canvas-prompt-optimizer-drag-region" role="button" tabIndex={0} aria-label="移动提示词优化面板" title="拖动移动面板,方向键也可以移动" onKeyDown={handlePanelMoveKeyDown}>
<span className="canvas-prompt-optimizer-icon">
<Sparkles className="size-4" aria-hidden="true" />
</span>
<div className="canvas-prompt-optimizer-header-title">
<div className="canvas-prompt-optimizer-header-title-row">
<Typography.Text strong>AI 提示词优化</Typography.Text>
<span className="canvas-prompt-optimizer-mode-badge">{generationMode === "image" ? "图片" : "视频"}</span>
<span className="canvas-prompt-optimizer-context" title={targetModel || "未配置模型"}>
· {targetModel || "未配置模型"}
{referenceCount ? ` · 参考 ${referenceCount}` : ""}
</span>
</div>
</div>
</div>
<button type="button" className="canvas-prompt-optimizer-close" onPointerDown={(event) => event.stopPropagation()} onClick={onClose} aria-label="关闭提示词优化">
<X className="size-4" aria-hidden="true" />
</button>
</div>
<div ref={chatRef} className="canvas-prompt-optimizer-chat-shell">
<Bubble.List
className="canvas-prompt-optimizer-chat thin-scrollbar"
items={bubbleItems}
autoScroll={false}
aria-live="polite"
role={{
ai: {
placement: "start",
variant: "borderless",
avatar: (
<span className="canvas-prompt-optimizer-message-avatar">
<Sparkles className="size-3.5" aria-hidden="true" />
</span>
),
},
user: { placement: "end", variant: "borderless" },
system: { placement: "start", variant: "borderless" },
}}
/>
</div>
<div className="canvas-prompt-optimizer-composer">
<Sender
className="canvas-prompt-optimizer-sender"
value={draftPrompt}
onChange={(value) => setDraftPrompt(value)}
onSubmit={(value) => void runOptimization(value)}
placeholder="继续描述你的画面想法,Enter 发送"
autoSize={{ minRows: 2, maxRows: 6 }}
disabled={working}
suffix={false}
submitType="enter"
footer={
<div className="canvas-prompt-optimizer-composer-toolbar">
<div className="canvas-prompt-optimizer-composer-leading">
<Dropdown
trigger={["click"]}
placement="topLeft"
classNames={{ root: "canvas-prompt-optimizer-mode-dropdown" }}
menu={{
items: modeMenuItems,
selectedKeys: [mode],
onClick: ({ key }) => setMode(key as PromptOptimizationMode),
}}
>
<button type="button" className="canvas-prompt-optimizer-mode-trigger" aria-label="选择优化方式" aria-haspopup="menu">
<span>{modeOptions.find((option) => option.value === mode)?.label}</span>
<ChevronDown className="size-3" aria-hidden="true" />
</button>
</Dropdown>
</div>
<div className="flex min-w-0 items-center gap-2">
<ModelPicker
config={config}
value={activeOptimizerModel}
onChange={setActiveOptimizerModel}
capability="text"
placeholder="选择优化模型"
showSelectedPrice={false}
variant="creation"
className="canvas-prompt-optimizer-model-picker"
popoverClassName="canvas-prompt-optimizer-model-popover"
/>
<button
type="button"
className="canvas-prompt-optimizer-send"
onClick={() => void runOptimization()}
disabled={!provider || working || !draftPrompt.trim() || (mode === "reference" && !selectedReferences.length)}
aria-label={working ? "正在优化" : "发送优化请求"}
>
{working ? <LoaderCircle className="size-3.5 animate-spin motion-reduce:animate-none" /> : <ArrowUp className="size-4" strokeWidth={2.35} aria-hidden="true" />}
</button>
</div>
</div>
}
aria-label="输入提示词优化请求"
/>
</div>
<div className="canvas-prompt-optimizer-resize-handle is-edge-top" aria-hidden="true" onPointerDown={(event) => beginPanelInteraction(event, "resize", { top: true })} />
<div className="canvas-prompt-optimizer-resize-handle is-edge-right" aria-hidden="true" onPointerDown={(event) => beginPanelInteraction(event, "resize", { right: true })} />
<div className="canvas-prompt-optimizer-resize-handle is-edge-bottom" aria-hidden="true" onPointerDown={(event) => beginPanelInteraction(event, "resize", { bottom: true })} />
<div className="canvas-prompt-optimizer-resize-handle is-edge-left" aria-hidden="true" onPointerDown={(event) => beginPanelInteraction(event, "resize", { left: true })} />
<div className="canvas-prompt-optimizer-resize-handle is-corner-top-left" aria-hidden="true" onPointerDown={(event) => beginPanelInteraction(event, "resize", { top: true, left: true })} />
<div className="canvas-prompt-optimizer-resize-handle is-corner-top-right" aria-hidden="true" onPointerDown={(event) => beginPanelInteraction(event, "resize", { top: true, right: true })} />
<div className="canvas-prompt-optimizer-resize-handle is-corner-bottom-left" aria-hidden="true" onPointerDown={(event) => beginPanelInteraction(event, "resize", { bottom: true, left: true })} />
</div>
);
return (
<Popover
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) onClose();
}}
trigger={[]}
placement="top"
arrow={false}
autoAdjustOverflow
motion={{ motionName: "" }}
styles={{ root: popoverRootStyle }}
content={content}
classNames={{ root: "canvas-prompt-optimizer-popover", container: "canvas-prompt-optimizer-popover-surface", content: "canvas-prompt-optimizer-popover-content" }}
>
{children}
</Popover>
);
}
function ResultList({ title, items, warning = false }: { title: string; items: string[]; warning?: boolean }) {
return (
<div className="canvas-prompt-optimizer-subsection">
<div className="canvas-prompt-optimizer-field-label">
<span>{title}</span>
</div>
<ul className={`canvas-prompt-optimizer-list ${warning ? "is-warning" : ""}`}>
{items.map((item) => (
<li key={item} className="list-disc pl-1 marker:text-foreground/35">
{item}
</li>
))}
</ul>
</div>
);
}
function ReferenceSelection({ references, selectedReferences, onToggle }: { references: CanvasResourceReference[]; selectedReferences: CanvasResourceReference[]; onToggle: (referenceId: string) => void }) {
return (
<div className="canvas-prompt-optimizer-reference-selection">
<div className="canvas-prompt-optimizer-reference-heading">
<span>参考内容</span>
<span>{references.length ? `已选 ${selectedReferences.length}/${references.length}` : "当前上下文未连接参考"}</span>
</div>
{references.length ? (
<div className="canvas-prompt-optimizer-reference-list" role="group" aria-label="本次优化使用的参考内容">
{references.map((reference) => {
const selected = selectedReferences.some((item) => item.id === reference.id);
return (
<button
key={reference.id}
type="button"
className={`canvas-prompt-optimizer-reference-chip ${selected ? "is-selected" : ""}`}
aria-pressed={selected}
onClick={() => onToggle(reference.id)}
title={`${selected ? "取消使用" : "使用"} ${reference.title || reference.label}`}
>
<ReferencePreview reference={reference} />
<span className="canvas-prompt-optimizer-reference-chip-label">{reference.title || reference.label}</span>
{selected ? <Check className="size-3.5" aria-hidden="true" /> : null}
</button>
);
})}
</div>
) : (
<p className="canvas-prompt-optimizer-reference-empty">请先添加参考图、角色或文本,再使用此模式。</p>
)}
</div>
);
}
function ReferencePreview({ reference }: { reference: CanvasResourceReference }) {
if ((reference.kind === "image" || reference.kind === "character") && reference.previewUrl) {
return <img className="canvas-prompt-optimizer-reference-chip-preview" src={reference.previewUrl} alt="" />;
}
const Icon = reference.kind === "text" ? FileText : reference.kind === "video" ? Video : reference.kind === "audio" ? Music2 : reference.kind === "character" ? UserRound : ImageIcon;
return (
<span className="canvas-prompt-optimizer-reference-chip-icon">
<Icon className="size-3.5" aria-hidden="true" />
</span>
);
}

View File

@@ -3,6 +3,7 @@ import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode,
import { ChevronRight, Clapperboard, Image as ImageIcon, List, Music2, Pencil, Video, WandSparkles, X } from "lucide-react";
import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface";
import { useCanvasOverlayLayer } from "@/components/canvas/canvas-overlay-layer";
import { canvasThemes } from "@/lib/canvas-theme";
import { aceternityMotion } from "@/lib/aceternity-motion";
import { subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport";
@@ -87,10 +88,21 @@ export function CanvasSelectionToolbar({ anchorRef, containerRef, count, childre
);
}
export function CanvasNodePanelOverlay({ node, viewport, containerRef, panelWidth, panelHeight = 190, children }: { node: CanvasNodeData; viewport: ViewportTransform; containerRef: RefObject<HTMLDivElement | null>; panelWidth?: number; panelHeight?: number; children: ReactNode }) {
type NodePanelPlacement = "above" | "below";
// 拖动时保留当前上下方向,并允许短暂越过安全边界,避免面板在临界位置反复翻转。
const NODE_PANEL_PLACEMENT_BUFFER = 32;
export function CanvasNodePanelOverlay({ node, viewport, containerRef, panelWidth, panelHeight = 190, dragOffset, isDragging = false, children }: { node: CanvasNodeData; viewport: ViewportTransform; containerRef: RefObject<HTMLDivElement | null>; panelWidth?: number; panelHeight?: number; dragOffset?: Position | null; isDragging?: boolean; children: ReactNode }) {
const panelRef = useRef<HTMLDivElement>(null);
const placementRef = useRef<NodePanelPlacement | null>(null);
const { bringToFront, zIndex } = useCanvasOverlayLayer(`node-panel:${node.id}`, "var(--z-modal-overlay)");
const initialWidth = resolveNodePanelWidth(node, viewport, panelWidth);
const initialPosition = getNodePanelPosition(node, viewport, { width: containerRef.current?.clientWidth || 0, height: containerRef.current?.clientHeight || 0 }, initialWidth, panelHeight);
const initialPosition = getNodePanelPosition(node, viewport, { width: containerRef.current?.clientWidth || 0, height: containerRef.current?.clientHeight || 0 }, initialWidth, panelHeight, dragOffset);
useLayoutEffect(() => {
bringToFront();
}, [bringToFront]);
useLayoutEffect(() => {
const container = containerRef.current;
@@ -98,8 +110,26 @@ export function CanvasNodePanelOverlay({ node, viewport, containerRef, panelWidt
if (!container || !panel) return;
const update = (nextViewport: ViewportTransform) => {
const nextWidth = resolveNodePanelWidth(node, nextViewport, panelWidth);
const nextHeight = panel.offsetHeight || panelHeight;
panel.style.width = `${nextWidth}px`;
const position = getNodePanelPosition(node, nextViewport, { width: container.clientWidth, height: container.clientHeight }, nextWidth, panel.offsetHeight || panelHeight);
panel.style.removeProperty("height");
const position = getNodePanelPosition(
node,
nextViewport,
{ width: container.clientWidth, height: container.clientHeight },
nextWidth,
nextHeight,
dragOffset,
isDragging ? placementRef.current : null,
isDragging ? NODE_PANEL_PLACEMENT_BUFFER : 0,
);
if (isDragging && placementRef.current && placementRef.current !== position.placement) {
panel.classList.remove("canvas-node-panel-placement-change");
// 强制重排后重新加 class,确保每次上下翻转都能重新播放虚化动画。
void panel.offsetWidth;
panel.classList.add("canvas-node-panel-placement-change");
}
placementRef.current = isDragging ? position.placement : null;
panel.style.left = `${position.left}px`;
panel.style.top = `${position.top}px`;
};
@@ -112,15 +142,17 @@ export function CanvasNodePanelOverlay({ node, viewport, containerRef, panelWidt
resizeObserver.disconnect();
unsubscribeViewport();
};
}, [containerRef, node.height, node.id, node.position.x, node.position.y, node.width, panelHeight, panelWidth, viewport]);
}, [containerRef, dragOffset?.x, dragOffset?.y, isDragging, node.height, node.id, node.position.x, node.position.y, node.width, panelHeight, panelWidth, viewport]);
return (
<div
ref={panelRef}
data-canvas-no-zoom
className="thin-scrollbar absolute z-[var(--z-modal-overlay)] max-w-[calc(100%_-_24px)] overflow-y-auto"
style={{ left: initialPosition.left, top: initialPosition.top, width: initialWidth, maxHeight: "calc(100% - 84px)" }}
className="thin-scrollbar absolute max-w-[calc(100%_-_24px)] overflow-y-auto"
style={{ left: initialPosition.left, top: initialPosition.top, width: initialWidth, maxHeight: "calc(100% - 84px)", zIndex }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDownCapture={bringToFront}
onFocusCapture={bringToFront}
onPointerDown={(event) => event.stopPropagation()}
>
{children}
@@ -137,11 +169,16 @@ export function CanvasConnectionCreateMenu({ pending, viewport, viewportSize, co
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const reducedMotion = useReducedMotion();
const menuRef = useRef<HTMLDivElement>(null);
const { bringToFront, zIndex } = useCanvasOverlayLayer("connection-create-menu", "var(--z-modal-overlay)");
const menuWidth = 248;
const menuHeight = canCreateDrawing ? 420 : 376;
const gap = 12;
const initialPosition = getConnectionMenuPosition(pending.position, viewport, viewportSize, menuWidth, menuHeight, gap);
useLayoutEffect(() => {
bringToFront();
}, [bringToFront]);
useLayoutEffect(() => {
const container = containerRef.current;
const menu = menuRef.current;
@@ -163,11 +200,13 @@ export function CanvasConnectionCreateMenu({ pending, viewport, viewportSize, co
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.97, rotateX: 2 }}
animate={{ opacity: 1, y: 0, scale: 1, rotateX: 0 }}
transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }}
className="aceternity-floating-panel absolute z-[var(--z-modal-overlay)] w-[248px] origin-top-left overflow-hidden rounded-[var(--r-2xl)] border p-2 backdrop-blur-2xl"
className="aceternity-floating-panel absolute w-[248px] origin-top-left overflow-hidden rounded-[var(--r-2xl)] border p-2 backdrop-blur-2xl"
data-canvas-no-zoom
data-connection-create-menu
style={{ left: initialPosition.left, top: initialPosition.top, background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text }}
style={{ left: initialPosition.left, top: initialPosition.top, zIndex, background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDownCapture={bringToFront}
onFocusCapture={bringToFront}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="absolute inset-x-8 top-0 h-px" style={{ background: `linear-gradient(90deg, transparent, ${theme.toolbar.border}, transparent)` }} />
@@ -217,20 +256,33 @@ function getConnectionMenuPosition(position: Position, viewport: ViewportTransfo
};
}
function getNodePanelPosition(node: CanvasNodeData, viewport: ViewportTransform, viewportSize: { width: number; height: number }, panelWidth: number, panelHeight: number) {
function getNodePanelPosition(node: CanvasNodeData, viewport: ViewportTransform, viewportSize: { width: number; height: number }, panelWidth: number, panelHeight: number, dragOffset?: Position | null, lockedPlacement?: NodePanelPlacement | null, placementBuffer = 0) {
const gap = 10;
const margin = 12;
const topBoundary = 72;
const nodeCenterX = viewport.x + (node.position.x + node.width / 2) * viewport.k;
const nodeTop = viewport.y + node.position.y * viewport.k;
const nodeBottom = viewport.y + (node.position.y + node.height) * viewport.k;
const offsetX = dragOffset?.x || 0;
const offsetY = dragOffset?.y || 0;
const nodeCenterX = viewport.x + (node.position.x + offsetX + node.width / 2) * viewport.k;
const nodeTop = viewport.y + (node.position.y + offsetY) * viewport.k;
const nodeBottom = viewport.y + (node.position.y + offsetY + node.height) * viewport.k;
const maxLeft = Math.max(margin, viewportSize.width - panelWidth - margin);
const left = clamp(nodeCenterX - panelWidth / 2, margin, maxLeft);
const belowTop = nodeBottom + gap;
const aboveTop = nodeTop - panelHeight - gap;
const preferredTop = belowTop + panelHeight <= viewportSize.height - margin ? belowTop : aboveTop;
const canFitBelow = belowTop + panelHeight <= viewportSize.height - margin;
const canFitAbove = aboveTop >= topBoundary;
let placement: NodePanelPlacement;
if (lockedPlacement === "below") {
placement = !canFitBelow && belowTop + panelHeight > viewportSize.height - margin + placementBuffer && canFitAbove ? "above" : "below";
} else if (lockedPlacement === "above") {
placement = !canFitAbove && aboveTop < topBoundary - placementBuffer && canFitBelow ? "below" : "above";
} else {
placement = canFitBelow ? "below" : "above";
}
const preferredTop = placement === "below" ? belowTop : aboveTop;
return {
left,
top: clamp(preferredTop, topBoundary, Math.max(topBoundary, viewportSize.height - panelHeight - margin)),
placement,
};
}

View File

@@ -1,6 +1,6 @@
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useRef, useState, type RefObject } from "react";
import { Compass, Focus, HelpCircle, Minus, Plus } from "lucide-react";
import { Compass, Focus, HelpCircle, LayoutTemplate, Minus, Plus } from "lucide-react";
import { FloatingDock, type FloatingDockEntry } from "@/components/ui/aceternity/floating-dock";
import { aceternityMotion } from "@/lib/aceternity-motion";
@@ -12,7 +12,8 @@ import { useThemeStore } from "@/stores/use-theme-store";
type CanvasZoomControlsProps = {
scale: number;
onScaleChange: (scale: number) => void;
onReset: () => void;
onFitContent: () => void;
onAutoArrange?: () => void;
isMiniMapOpen: boolean;
onToggleMiniMap: () => void;
onOpenShortcuts: () => void;
@@ -21,7 +22,7 @@ type CanvasZoomControlsProps = {
const QUICK_ZOOM_LEVELS = [0.25, 0.5, 1, 2] as const;
export function CanvasZoomControls({ scale, onScaleChange, onReset, isMiniMapOpen, onToggleMiniMap, onOpenShortcuts, containerRef }: CanvasZoomControlsProps) {
export function CanvasZoomControls({ scale, onScaleChange, onFitContent, onAutoArrange, isMiniMapOpen, onToggleMiniMap, onOpenShortcuts, containerRef }: CanvasZoomControlsProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const rootRef = useRef<HTMLDivElement>(null);
const liveScaleRef = useRef(scale);
@@ -70,7 +71,8 @@ export function CanvasZoomControls({ scale, onScaleChange, onReset, isMiniMapOpe
const items: FloatingDockEntry[] = [
{ id: "zoom-minimap", label: isMiniMapOpen ? "关闭小地图" : "打开小地图", icon: <Compass />, active: isMiniMapOpen, onClick: onToggleMiniMap },
{ id: "zoom-fit", label: "适应全部内容", icon: <Focus />, onClick: onReset },
{ id: "zoom-fit", label: "适应画布", icon: <Focus />, onClick: onFitContent },
...(onAutoArrange ? [{ id: "zoom-auto-arrange", label: "自动整理节点", icon: <LayoutTemplate />, onClick: onAutoArrange }] : []),
{ kind: "separator", id: "zoom-separator" },
{ id: "zoom-out", label: "缩小画布", icon: <Minus />, onClick: () => commitScale(liveScaleRef.current - 0.1) },
{

View File

@@ -6,9 +6,12 @@ import { createModelChannel, useConfigStore } from "@/stores/use-config-store";
import { navigateToSettings } from "@/lib/settings-navigation";
import { useLocalDreaminaModelBootstrap } from "@/stores/use-local-dreamina-model-store";
import { useLocalRuntimeBootstrap } from "@/stores/use-local-runtime-store";
import { initializeClientDiagnostics, setDiagnosticUserScope } from "@/services/diagnostics/client-diagnostics";
import { useUserStore } from "@/stores/use-user-store";
export function ClientRootInit({ children }: { children: ReactNode }) {
const config = useConfigStore((state) => state.config);
const userId = useUserStore((state) => state.user?.id || "");
const localRuntimeConfigured = config.channels.some((channel) => channel.transport === "local-runtime" && channel.enabled !== false);
useLocalRuntimeBootstrap(localRuntimeConfigured);
useLocalDreaminaModelBootstrap();
@@ -16,6 +19,14 @@ export function ClientRootInit({ children }: { children: ReactNode }) {
const handledConfigParams = useRef(false);
const updateConfig = useConfigStore((state) => state.updateConfig);
useEffect(() => {
initializeClientDiagnostics();
}, []);
useEffect(() => {
setDiagnosticUserScope(userId);
}, [userId]);
useEffect(() => {
const interactiveSelector = 'button, [role="button"], a, [class*="card"], [class*="Card"]';
const blurPointerFocus = (event: PointerEvent) => {

View File

@@ -10,7 +10,7 @@ type LobeIconComponent = ComponentType<SVGProps<SVGSVGElement> & { size?: number
// 只允许按需加载 Mono 组件。这里不能使用 eager glob:模型 Logo 目录有数百个 provider 模块,
// eager 会让每次进入工作区都发起数百个开发模块请求,即使用户从未打开 Logo 选择器。
const iconModules = import.meta.glob("../../node_modules/@lobehub/icons/es/*/components/Mono.js", { import: "default" });
const iconModules = import.meta.glob?.("../../node_modules/@lobehub/icons/es/*/components/Mono.js", { import: "default" }) || {};
const iconLoaders = Object.fromEntries(
Object.entries(iconModules)
.map(([path, loader]) => [path.match(/\/([^/]+)\/components\/Mono\.js$/)?.[1], loader])
@@ -43,11 +43,14 @@ function loadIcon(icon?: string) {
}
export function ModelLogo({ icon, size = 18, className }: { icon?: string; size?: number; className?: string }) {
const [Icon, setIcon] = useState<LobeIconComponent | undefined>(() => icon ? iconRegistry.get(icon) : undefined);
const [Icon, setIcon] = useState<LobeIconComponent | undefined>(() => (icon ? iconRegistry.get(icon) : undefined));
useEffect(() => {
let cancelled = false;
setIcon(icon ? iconRegistry.get(icon) : undefined);
if (!icon || !iconLoaders[icon]) return () => { cancelled = true; };
if (!icon || !iconLoaders[icon])
return () => {
cancelled = true;
};
void loadIcon(icon).then((loaded) => {
if (!cancelled) setIcon(loaded);
});

View File

@@ -1,4 +1,4 @@
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent } from "react";
import { Check, ChevronDown, Coins } from "lucide-react";
import { Popover } from "antd";
@@ -19,6 +19,7 @@ type ModelPickerProps = {
onChange: (model: string) => void;
capability?: ModelCapability;
className?: string;
popoverClassName?: string;
fullWidth?: boolean;
placeholder?: string;
onMissingConfig?: () => void;
@@ -28,13 +29,14 @@ type ModelPickerProps = {
showConfiguredModelName?: boolean;
};
export function ModelPicker({ config, value, onChange, capability, className, fullWidth = false, placeholder = "选择模型", onMissingConfig, showSelectedPrice = true, variant = "default", requirements, showConfiguredModelName = false }: ModelPickerProps) {
export function ModelPicker({ config, value, onChange, capability, className, popoverClassName, fullWidth = false, placeholder = "选择模型", onMissingConfig, showSelectedPrice = true, variant = "default", requirements, showConfiguredModelName = false }: ModelPickerProps) {
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
const pickerId = useId();
// 双保险:即使 store merge 写出非法 theme,这里也兜底到 dark,避免 "reading 'node'" 崩溃
const rawTheme = useThemeStore((state) => state.theme);
const theme = (canvasThemes[rawTheme as keyof typeof canvasThemes] ?? canvasThemes.dark) as CanvasTheme;
const [open, setOpen] = useState(false);
const [triggerWidth, setTriggerWidth] = useState<number | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const options = useMemo(() => Array.from(new Set(selectableModelsByCapability(config, capability).filter(Boolean))), [capability, config]);
@@ -65,6 +67,16 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
const [routeQuote, setRouteQuote] = useState<LogicalModelQuote | undefined>();
const creationVariant = variant === "creation";
useLayoutEffect(() => {
const trigger = triggerRef.current;
if (!trigger) return;
const updateTriggerWidth = () => setTriggerWidth(Math.ceil(trigger.getBoundingClientRect().width));
updateTriggerWidth();
const observer = new ResizeObserver(updateTriggerWidth);
observer.observe(trigger);
return () => observer.disconnect();
}, [className, fullWidth, showSelectedPrice, variant, value]);
useEffect(() => {
if (!showSelectedPrice || !creditsEnabled || !quoteRequest) {
setRouteQuote(undefined);
@@ -139,7 +151,11 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
ref={menuRef}
data-canvas-no-zoom
className={cn("canvas-model-picker-menu max-w-[calc(100vw-24px)]", creationVariant ? "creation-model-picker-menu w-[360px]" : "w-[var(--panel-width-compact)]")}
style={{ background: theme.node.panel, color: theme.node.text }}
style={{
background: theme.node.panel,
color: theme.node.text,
"--canvas-model-picker-trigger-width": triggerWidth ? String(triggerWidth) + "px" : undefined,
} as CSSProperties}
role="listbox"
aria-label={placeholder}
onKeyDown={handleMenuKeyDown}
@@ -209,7 +225,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
arrow={false}
content={content}
classNames={{
root: cn("canvas-model-picker-popover", creationVariant && "creation-model-picker-popover"),
root: cn("canvas-model-picker-popover", creationVariant && "creation-model-picker-popover", popoverClassName),
container: cn("canvas-composer-popover-surface", creationVariant && "creation-model-picker-surface"),
content: "canvas-composer-popover-content",
}}