415 lines
15 KiB
TypeScript
415 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}
|
||
|
|
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" />;
|
||
|
|
}
|