Files
moteva/frontend/src/ui/components/CanvasAgentComposer/index.tsx
T
root 2ede253b39 feat(clipboard): paste images and text from the system clipboard
Replace the internal single-node clipboard with real clipboard paste:
the prompt composer uploads pasted image files as references, and the
canvas pastes clipboard images as nodes and clipboard text as text
nodes anchored at the pointer, reading from the async clipboard API on
the context-menu paste action.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 01:54:56 +08:00

416 lines
15 KiB
TypeScript

"use client";
import {
ArrowUp,
Bot,
Check,
ChevronDown,
ChevronUp,
Globe2,
Image as ImageIcon,
ImagePlus,
Library,
Paperclip,
Plus,
Sparkles,
Square,
Video,
X
} from "lucide-react";
import { forwardRef, type ReactNode, useMemo, useRef, useState } from "react";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { useI18n } from "@/i18n/i18n";
import { PromptComposer, type PromptComposerHandle, type UploadedReferenceImage, type CanvasAnnotation, type AnnotationPreviewRequest } from "@/ui/components/PromptComposer";
export type ComposerMode = "agent" | "image";
export type ComposerImageQuality = "auto" | "high" | "medium" | "low";
export type ComposerImageRatio = "1:1" | "3:2" | "2:3" | "4:3" | "3:4" | "9:16" | "1:1-2k" | "16:9-2k" | "9:16-2k" | "16:9-4k" | "9:16-4k" | "auto";
export type ComposerImageSettings = {
quality: ComposerImageQuality;
ratio: ComposerImageRatio;
width: number;
height: number;
count: number;
};
export type CanvasComposerModel = {
id: string;
label: string;
description?: string;
duration?: string;
credits: number;
};
export type CanvasAgentComposerVariant = "panel" | "floating";
export type CanvasAgentComposerProps = {
text: string;
mode: ComposerMode;
models: CanvasComposerModel[];
selectedModel: CanvasComposerModel;
settings: ComposerImageSettings;
webSearchEnabled: boolean;
generating: boolean;
variant?: CanvasAgentComposerVariant;
references?: UploadedReferenceImage[];
annotations?: CanvasAnnotation[];
previewSlot?: ReactNode;
onTextChange: (text: string) => void;
onModeChange: (mode: ComposerMode) => void;
onSettingsChange: (settings: ComposerImageSettings) => void;
onModelChange: (modelId: string) => void;
onWebSearchChange: (enabled: boolean) => void;
onUploadFile: (file: File | null) => void | Promise<void>;
onReferencesChange: (references: UploadedReferenceImage[]) => void;
onReferenceRemoved: (reference: UploadedReferenceImage) => void;
onAnnotationsChange?: (annotations: CanvasAnnotation[]) => void;
onAnnotationPreviewChange?: (preview: AnnotationPreviewRequest | null) => void;
onSubmit: () => void;
onStop?: () => void;
};
const qualityOptions: Array<{ id: ComposerImageQuality; labelKey: string }> = [
{ id: "auto", labelKey: "qualityAuto" },
{ id: "high", labelKey: "qualityHigh" },
{ id: "medium", labelKey: "qualityMedium" },
{ id: "low", labelKey: "qualityLow" }
];
export const composerRatioOptions: Array<{ id: ComposerImageRatio; label: string; width: number; height: number }> = [
{ id: "1:1", label: "1:1", width: 1024, height: 1024 },
{ id: "3:2", label: "3:2", width: 1536, height: 1024 },
{ id: "2:3", label: "2:3", width: 1024, height: 1536 },
{ id: "4:3", label: "4:3", width: 1365, height: 1024 },
{ id: "3:4", label: "3:4", width: 1024, height: 1365 },
{ id: "9:16", label: "9:16", width: 1024, height: 1820 },
{ id: "1:1-2k", label: "1:1(2k)", width: 2048, height: 2048 },
{ id: "16:9-2k", label: "16:9(2k)", width: 2048, height: 1152 },
{ id: "9:16-2k", label: "9:16(2k)", width: 1152, height: 2048 },
{ id: "16:9-4k", label: "16:9(4k)", width: 4096, height: 2304 },
{ id: "9:16-4k", label: "9:16(4k)", width: 2304, height: 4096 },
{ id: "auto", label: "auto", width: 1024, height: 1024 }
];
export const defaultComposerImageSettings: ComposerImageSettings = {
quality: "auto",
ratio: "1:1",
width: 1024,
height: 1024,
count: 1
};
export function composerImageSize(settings: ComposerImageSettings) {
return `${Math.max(1, Math.round(settings.width))}x${Math.max(1, Math.round(settings.height))}`;
}
export function composerSettingsSummary(settings: ComposerImageSettings) {
const ratioLabel = composerRatioOptions.find((option) => option.id === settings.ratio)?.label ?? settings.ratio;
return `${qualityLabel(settings.quality)} · ${ratioLabel} · ${settings.count} img`;
}
function qualityLabel(quality: ComposerImageQuality) {
if (quality === "high") return "高";
if (quality === "medium") return "中";
if (quality === "low") return "低";
return "自动";
}
function hasComposerPromptText(value: string) {
return value.replace(/\u00a0/g, " ").trim().length > 0;
}
export const CanvasAgentComposer = forwardRef<PromptComposerHandle, CanvasAgentComposerProps>(function CanvasAgentComposer(
{
text,
mode,
models,
selectedModel,
settings,
webSearchEnabled,
generating,
variant = "panel",
references,
annotations,
previewSlot,
onTextChange,
onModeChange,
onSettingsChange,
onModelChange,
onWebSearchChange,
onUploadFile,
onReferencesChange,
onReferenceRemoved,
onAnnotationsChange,
onAnnotationPreviewChange,
onSubmit,
onStop
},
ref
) {
const { t } = useI18n();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [assetMenuOpen, setAssetMenuOpen] = useState(false);
const [modeMenuOpen, setModeMenuOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [modelOpen, setModelOpen] = useState(false);
const [upgradeVisible, setUpgradeVisible] = useState(true);
const sendCost = useMemo(() => Math.max(1, selectedModel.credits * Math.max(1, settings.count)), [selectedModel.credits, settings.count]);
const modeLabel = mode === "image" ? t("imageMode") : t("agentShort");
const ModeIcon = mode === "image" ? ImagePlus : Bot;
const canSubmit = hasComposerPromptText(text);
const patchSettings = (patch: Partial<ComposerImageSettings>) => {
onSettingsChange({ ...settings, ...patch });
};
const selectRatio = (ratio: ComposerImageRatio) => {
const nextRatio = composerRatioOptions.find((option) => option.id === ratio) ?? composerRatioOptions[0];
patchSettings({ ratio, width: nextRatio.width, height: nextRatio.height });
};
const composerCard = (
<div id={variant === "panel" ? "agent-chat-footer" : undefined} className={`assistant-composer ${mode === "image" ? "image-mode" : "agent-mode-active"} ${variant === "floating" ? "floating-composer" : ""}`}>
{mode === "image"}
<PromptComposer
ref={ref}
text={text}
placeholder={t("canvasPromptPlaceholder")}
ariaLabel={t("canvasPromptPlaceholder")}
className="compact"
references={references}
annotations={annotations}
removeLabel={t("removeReference")}
onTextChange={onTextChange}
onReferencesChange={onReferencesChange}
onReferenceRemoved={onReferenceRemoved}
onAnnotationsChange={onAnnotationsChange}
onAnnotationPreviewChange={onAnnotationPreviewChange}
onPasteFile={onUploadFile}
onSubmit={onSubmit}
/>
{previewSlot}
<div className="assistant-composer-toolbar">
<input
ref={fileInputRef}
type="file"
accept="image/*"
hidden
onChange={(event) => {
const file = event.target.files?.[0] ?? null;
event.currentTarget.value = "";
void onUploadFile(file);
setAssetMenuOpen(false);
}}
/>
<DropdownMenu open={assetMenuOpen} onOpenChange={setAssetMenuOpen}>
<DropdownMenuTrigger asChild>
<button className="composer-icon-button plus-button" type="button" aria-label={t("addAsset")}>
<Plus size={21} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="composer-menu-popover composer-asset-popover" align="start" side="top" sideOffset={10}>
<DropdownMenuItem onSelect={() => fileInputRef.current?.click()}>
<Paperclip size={18} />
<span>{t("uploadFile")}</span>
</DropdownMenuItem>
<DropdownMenuItem disabled>
<Library size={18} />
<span>{t("chooseFromLibrary")}</span>
</DropdownMenuItem>
<DropdownMenuItem
className="composer-switch-row"
onSelect={(event) => {
event.preventDefault();
onWebSearchChange(!webSearchEnabled);
}}
>
<Globe2 size={18} />
<span>{t("webSearch")}</span>
<Switch className="composer-web-switch" checked={webSearchEnabled} onClick={(event) => event.stopPropagation()} onCheckedChange={onWebSearchChange} />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu open={modeMenuOpen} onOpenChange={setModeMenuOpen}>
<DropdownMenuTrigger asChild>
<button className={`composer-mode-button ${modeMenuOpen ? "open" : ""}`} type="button" aria-label={t("agentMode")}>
<ModeIcon size={18} />
<span>{modeLabel}</span>
{modeMenuOpen ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="composer-menu-popover composer-mode-popover" align="start" side="top" sideOffset={10}>
<DropdownMenuItem
onSelect={() => {
onModeChange("agent");
setModeMenuOpen(false);
}}
>
<Bot size={18} />
<span>{t("agentShort")}</span>
{mode === "agent" && <Check size={16} />}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
onModeChange("image");
setModeMenuOpen(false);
}}
>
<ImagePlus size={18} />
<span>{t("imageMode")}</span>
{mode === "image" && <Check size={16} />}
</DropdownMenuItem>
<DropdownMenuItem disabled>
<Video size={18} />
<span>{t("modelVideoTab")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{mode === "image" && (
<Popover open={settingsOpen} onOpenChange={setSettingsOpen}>
<PopoverTrigger asChild>
<button className="composer-settings-chip" type="button" aria-label={t("imageSettings")}>
{composerSettingsSummary(settings)}
{settingsOpen ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
</PopoverTrigger>
<PopoverContent className="composer-settings-popover" align="end" sideOffset={12}>
<ComposerSettingsPanel settings={settings} onPatch={patchSettings} onSelectRatio={selectRatio} />
</PopoverContent>
</Popover>
)}
<div className="composer-spacer" />
<DropdownMenu open={modelOpen} onOpenChange={setModelOpen}>
<DropdownMenuTrigger asChild>
<button className="composer-model-button" type="button" aria-label={t("modelSelection")}>
<Sparkles size={18} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="composer-model-popover" align="end" side="top" sideOffset={12}>
{models.map((model) => (
<DropdownMenuItem
key={model.id}
onSelect={() => {
onModelChange(model.id);
setModelOpen(false);
}}
>
<Sparkles size={17} />
<span>{model.label}</span>
<small>{model.credits}</small>
{model.id === selectedModel.id && <Check size={16} />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<button
className={`send-button small ${generating ? "is-stopping" : ""} ${mode === "image" ? "with-cost" : ""}`}
type="button"
onClick={generating ? onStop : onSubmit}
disabled={!generating && !canSubmit}
aria-label={generating ? t("stopGeneration") : t("send")}
>
{generating ? (
<Square size={15} fill="currentColor" />
) : mode === "image" ? (
<span>{sendCost}</span>
) : (
<ArrowUp size={21} />
)}
</button>
</div>
</div>
);
if (variant === "floating") return composerCard;
return (
<div className={`assistant-composer-stack ${upgradeVisible ? "has-upgrade" : ""}`}>
{upgradeVisible && (
<div id="agent-chat-banner" className="composer-upgrade">
<div className="composer-upgrade-row">
<span className="composer-upgrade-icon" aria-hidden="true">
<Sparkles size={16} />
</span>
<span className="composer-upgrade-copy">{t("unlockModels")}</span>
<button type="button" aria-label={t("close")} onClick={() => setUpgradeVisible(false)}>
<X size={16} />
</button>
</div>
</div>
)}
{composerCard}
</div>
);
});
export function ComposerSettingsPanel({
settings,
onPatch,
onSelectRatio
}: {
settings: ComposerImageSettings;
onPatch: (patch: Partial<ComposerImageSettings>) => void;
onSelectRatio: (ratio: ComposerImageRatio) => void;
}) {
const { t } = useI18n();
return (
<div className="composer-settings-panel">
<section>
<h4>{t("quality")}</h4>
<div className="composer-quality-control">
{qualityOptions.map((option) => (
<button key={option.id} type="button" className={settings.quality === option.id ? "selected" : ""} onClick={() => onPatch({ quality: option.id })}>
{t(option.labelKey)}
</button>
))}
</div>
</section>
<section>
<h4>{t("imageSizeLabel")}</h4>
<div className="composer-dimensions">
<label>
<span>W</span>
<input value={settings.width} inputMode="numeric" onChange={(event) => onPatch({ width: Number(event.target.value) || 1024, ratio: "auto" })} />
</label>
<i aria-hidden="true"></i>
<label>
<span>H</span>
<input value={settings.height} inputMode="numeric" onChange={(event) => onPatch({ height: Number(event.target.value) || 1024, ratio: "auto" })} />
</label>
</div>
<div className="composer-ratio-grid">
{composerRatioOptions.map((option) => (
<button key={option.id} type="button" className={settings.ratio === option.id ? "selected" : ""} onClick={() => onSelectRatio(option.id)}>
<RatioGlyph ratio={option.id} />
<span>{option.label}</span>
</button>
))}
</div>
</section>
<section>
<h4>Image</h4>
<div className="composer-count-grid">
{Array.from({ length: 10 }, (_, index) => index + 1).map((count) => (
<button key={count} type="button" className={settings.count === count ? "selected" : ""} onClick={() => onPatch({ count })}>
{count} img
</button>
))}
</div>
</section>
</div>
);
}
function RatioGlyph({ ratio }: { ratio: ComposerImageRatio }) {
const portrait = ratio.startsWith("2:3") || ratio.startsWith("3:4") || ratio.startsWith("9:16");
const wide = ratio.startsWith("3:2") || ratio.startsWith("4:3") || ratio.startsWith("16:9");
return <i className={`ratio-glyph ${portrait ? "portrait" : wide ? "wide" : ""}`} aria-hidden="true" />;
}