Files
moteva/frontend/src/ui/components/CanvasAgentComposer/index.tsx
T

416 lines
15 KiB
TypeScript
Raw Normal View History

"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" />;
}