Initial commit: img-infinite-canvas AI design workbench MVP
Moteva-style AI design workbench replica. Home prompt creates a project; the project page provides an infinite canvas with node drag, zoom/pan, a design chat panel, history replay, image asset upload, and project save/regenerate. - Backend: go-zero, DDD layering, sqlc/pgx, optional PostgreSQL; memory or Redis cache; asynq job queue; MinIO/S3/R2/OSS object storage; sky-valley/pi agent runtime adapter. - Frontend: Next.js App Router + Vite artifact build, TypeScript, i18n, shadcn/ui components, auth (OTP/Turnstile/Google/WeChat). - Deploy: Docker Compose and k3s manifests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
"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" />;
|
||||
}
|
||||
Reference in New Issue
Block a user