Files
ttstd-infinite-canvas/web/src/components/canvas/canvas-prompt-optimizer-drawer.tsx

831 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { App, Button, Dropdown, Input, Popover, Tag, Typography } from "antd";
import { Callout } from "@/components/ui/product/callout";
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 contentRef = 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 = contentRef.current?.closest<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 = contentRef.current?.closest<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: (
<Callout className="canvas-prompt-optimizer-alert" tone="error" title="优化失败">
{error}
</Callout>
),
});
}
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 ref={contentRef} 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>
);
}