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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
53
web/src/components/canvas/canvas-overlay-layer.tsx
Normal file
53
web/src/components/canvas/canvas-overlay-layer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
824
web/src/components/canvas/canvas-prompt-optimizer-drawer.tsx
Normal file
824
web/src/components/canvas/canvas-prompt-optimizer-drawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) },
|
||||
{
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user