feat(image): integrate PoloX image editing workflows

Add WaveSpeed image editing and image tools plugins, canvas image editing workflows, layer selection, and Cloud Agent image tools with approval-safe transient references.
This commit is contained in:
Codex
2026-09-18 20:34:28 +08:00
parent 69456db2d5
commit 6ee9d1dcb2
34 changed files with 2537 additions and 75 deletions

View File

@@ -0,0 +1,131 @@
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { Button, Input, Modal, Tag } from "antd";
import { Layers3, Plus, RotateCcw, X } from "lucide-react";
import { ModelPicker } from "@/components/model-picker";
import type { AiConfig } from "@/stores/use-config-store";
import { defaultImageParamsForModel } from "@/lib/model-selection";
export type CanvasImageLayerDecompositionPayload = {
prompt: string;
regions?: Array<[number, number, number, number]>;
generationConfig?: Partial<Pick<AiConfig, "model" | "imageModel" | "size" | "quality">>;
};
const DEFAULT_PROMPT = "将图片拆分为可独立编辑的图层:识别主要主体、前景、背景和重要物体,每个图层单独输出,保持原图外观和边缘细节,使用透明背景,不要合并不同图层。";
export function CanvasNodeLayerDecompositionDialog({
dataUrl,
open,
config,
onClose,
onConfirm,
}: {
dataUrl: string;
open: boolean;
config: AiConfig;
onClose: () => void;
onConfirm: (payload: CanvasImageLayerDecompositionPayload) => void;
}) {
const [prompt, setPrompt] = useState(DEFAULT_PROMPT);
const [generationConfig, setGenerationConfig] = useState<AiConfig>(config);
const [regions, setRegions] = useState<Array<[number, number, number, number]>>([]);
const [drawing, setDrawing] = useState<{ x: number; y: number } | null>(null);
const [draft, setDraft] = useState<[number, number, number, number] | null>(null);
const imageFrameRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
setPrompt(DEFAULT_PROMPT);
setGenerationConfig(config);
setRegions([]);
setDrawing(null);
setDraft(null);
}, [config, dataUrl, open]);
const point = (event: ReactPointerEvent<HTMLDivElement>) => {
const rect = imageFrameRef.current?.getBoundingClientRect();
if (!rect) return null;
return {
x: Math.max(0, Math.min(1000, ((event.clientX - rect.left) / Math.max(1, rect.width)) * 1000)),
y: Math.max(0, Math.min(1000, ((event.clientY - rect.top) / Math.max(1, rect.height)) * 1000)),
};
};
const startBox = (event: ReactPointerEvent<HTMLDivElement>) => {
const next = point(event);
if (!next) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
setDrawing(next);
setDraft([next.x, next.y, next.x, next.y]);
};
const moveBox = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!drawing) return;
const next = point(event);
if (!next) return;
setDraft([Math.min(drawing.x, next.x), Math.min(drawing.y, next.y), Math.max(drawing.x, next.x), Math.max(drawing.y, next.y)]);
};
const finishBox = () => {
if (!draft) return;
const [x1, y1, x2, y2] = draft;
if (x2 - x1 >= 2 && y2 - y1 >= 2) setRegions((current) => [...current, [Math.round(x1), Math.round(y1), Math.round(x2), Math.round(y2)]]);
setDrawing(null);
setDraft(null);
};
const selectedPrompt = regions.length
? `${prompt.trim()}\n\n重点处理用户框选的区域,并分别输出这些区域中的主体为独立透明 PNG 图层。选区坐标(图像 0-1000 坐标系):${regions.map((region, index) => `区域${index + 1} <bbox>${region.join(" ")}</bbox>`).join(";")}`
: prompt.trim();
return (
<Modal open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} centered destroyOnHidden width={900} title="AI 图层拆分">
<div className="grid gap-5 md:grid-cols-[minmax(0,1fr)_320px]">
<div className="grid min-h-[340px] place-items-center overflow-hidden rounded-xl bg-black/5 p-3 dark:bg-white/[0.04]">
<div ref={imageFrameRef} className="relative inline-block max-h-[60vh] max-w-full select-none" onPointerDown={startBox} onPointerMove={moveBox} onPointerUp={finishBox} onPointerCancel={finishBox}>
<img src={dataUrl} alt="待拆分图片" className="block max-h-[60vh] max-w-full object-contain" draggable={false} />
<div className="pointer-events-none absolute inset-0">
{regions.map((region, index) => <RegionBox key={`${region.join("-")}-${index}`} region={region} label={index + 1} />)}
{draft ? <RegionBox region={draft} label={regions.length + 1} draft /> : null}
</div>
</div>
</div>
<div className="flex flex-col gap-4">
<div>
<h3 className="text-lg font-semibold">拆分图片图层</h3>
<p className="mt-1 text-sm opacity-60">可在图片上拖拽框选对象,AI 会返回多个透明背景图层,并在画布中自动排列为独立节点。</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Tag color={regions.length ? "blue" : "default"}>{regions.length ? `已框选 ${regions.length} 个区域` : "未框选,按描述拆分"}</Tag>
{regions.length ? <Button size="small" icon={<RotateCcw className="size-3.5" />} onClick={() => setRegions([])}>清除选区</Button> : <span className="text-xs opacity-55"><Plus className="mr-1 inline size-3" />在左侧图片上拖动添加选区</span>}
</div>
<Input.TextArea rows={7} value={prompt} placeholder="例如:分别提取人物、产品、前景装饰和背景" onChange={(event) => setPrompt(event.target.value)} />
<div className="space-y-2">
<div className="text-sm font-medium opacity-75">图层拆分模型</div>
<ModelPicker
config={generationConfig}
value={generationConfig.imageModel || generationConfig.model}
capability="image"
fullWidth
showSelectedPrice={false}
onChange={(model) => setGenerationConfig((current) => ({ ...current, model, imageModel: model, ...defaultImageParamsForModel(current, model) }))}
/>
</div>
<div className="mt-auto flex justify-end gap-2">
<Button icon={<X className="size-4" />} onClick={onClose}>取消</Button>
<Button type="primary" icon={<Layers3 className="size-4" />} disabled={!selectedPrompt} onClick={() => onConfirm({ prompt: selectedPrompt, regions, generationConfig: { model: generationConfig.model, imageModel: generationConfig.imageModel, size: generationConfig.size, quality: generationConfig.quality } })}>
开始拆分
</Button>
</div>
</div>
</div>
</Modal>
);
}
function RegionBox({ region, label, draft = false }: { region: [number, number, number, number]; label: number; draft?: boolean }) {
const [x1, y1, x2, y2] = region;
return <div className={`absolute rounded-sm border-2 ${draft ? "border-dashed border-blue-500 bg-blue-500/10" : "border-solid border-amber-400 bg-amber-400/10"}`} style={{ left: `${x1 / 10}%`, top: `${y1 / 10}%`, width: `${(x2 - x1) / 10}%`, height: `${(y2 - y1) / 10}%` }}><span className="absolute -left-0.5 -top-0.5 grid size-5 -translate-y-1/2 -translate-x-1/2 place-items-center rounded-full bg-amber-400 text-[11px] font-semibold text-black shadow">{label}</span></div>;
}