44406b72db
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>
585 lines
20 KiB
TypeScript
585 lines
20 KiB
TypeScript
"use client";
|
|
|
|
import { ChevronDown, ChevronUp, Download, Eye, EyeOff, Frame, Image as ImageIcon, Layers, Lock, Type, Unlock, X } from "lucide-react";
|
|
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
|
import type { AgentMessage, CanvasNode, CanvasViewport } from "@/domain/design";
|
|
import { useI18n } from "@/i18n/i18n";
|
|
import { thumbnailImageUrl } from "@/ui/lib/imageDelivery";
|
|
import { ShapeNodePreview } from "@/ui/pages/CanvasWorkspace/canvas/ShapeNodePreview";
|
|
import { isBooleanShapeGroupNode, isBooleanShapeOperandNode, isShapeNode } from "@/ui/pages/CanvasWorkspace/canvas/shapeNode";
|
|
|
|
type LayerPanelItem =
|
|
| {
|
|
type: "node";
|
|
node: CanvasNode;
|
|
}
|
|
| {
|
|
type: "booleanGroup";
|
|
node: CanvasNode;
|
|
layers: CanvasNode[];
|
|
}
|
|
| {
|
|
type: "group";
|
|
id: string;
|
|
nodes: CanvasNode[];
|
|
layers: CanvasNode[];
|
|
};
|
|
|
|
type Translate = (key: string, values?: Record<string, string | number>) => string;
|
|
|
|
export function CanvasGrid({ viewport }: { viewport: CanvasViewport }) {
|
|
const gridSize = 48 * viewport.k;
|
|
return (
|
|
<div
|
|
className="canvas-grid"
|
|
style={{
|
|
backgroundSize: `${gridSize}px ${gridSize}px`,
|
|
backgroundPosition: `${viewport.x % gridSize}px ${viewport.y % gridSize}px`
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function CanvasGeneratedFilesPanel({
|
|
nodes,
|
|
messages,
|
|
selectedIds,
|
|
onSelect,
|
|
onDownload,
|
|
onClose
|
|
}: {
|
|
nodes: CanvasNode[];
|
|
messages: AgentMessage[];
|
|
selectedIds: Set<string>;
|
|
onSelect: (nodeId: string) => void;
|
|
onDownload: (node: CanvasNode) => void;
|
|
onClose: () => void;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const generatedFileURLs = useMemo(() => generatedFileURLSet(messages), [messages]);
|
|
const files = useMemo(() => nodes.filter((node) => isGeneratedFileNode(node, generatedFileURLs)).reverse(), [generatedFileURLs, nodes]);
|
|
|
|
return (
|
|
<aside className="canvas-generated-files-panel" onPointerDown={(event) => event.stopPropagation()}>
|
|
<header>
|
|
<strong>{t("generatedFiles")}</strong>
|
|
<button type="button" aria-label={t("close")} onClick={onClose}>
|
|
<X size={19} />
|
|
</button>
|
|
</header>
|
|
<div className="generated-files-list">
|
|
{files.length === 0 ? (
|
|
<div className="generated-files-empty">
|
|
<ImageIcon size={54} />
|
|
<span>{t("generatedFilesEmpty")}</span>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{files.map((node) => (
|
|
<div
|
|
key={node.id}
|
|
className={`generated-file-row ${selectedIds.has(node.id) ? "selected" : ""}`}
|
|
onClick={() => onSelect(node.id)}
|
|
role="button"
|
|
tabIndex={0}
|
|
onKeyDown={(event) => {
|
|
if (event.key !== "Enter" && event.key !== " ") return;
|
|
event.preventDefault();
|
|
onSelect(node.id);
|
|
}}
|
|
>
|
|
<span className="generated-file-thumb">
|
|
<img src={thumbnailImageUrl(node.content)} alt={node.title} draggable={false} />
|
|
</span>
|
|
<span className="generated-file-name">{layerTitle(node, t)}</span>
|
|
<button
|
|
type="button"
|
|
className="generated-file-download"
|
|
aria-label={t("download")}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onDownload(node);
|
|
}}
|
|
>
|
|
<Download size={19} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
<div className="generated-files-end">{t("generatedFilesEnd")}</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
export function CanvasLayerPanel({
|
|
nodes,
|
|
selectedIds,
|
|
hiddenNodeIds,
|
|
lockedNodeIds,
|
|
onSelect,
|
|
onSelectMany,
|
|
onRenameNode,
|
|
onToggleHidden,
|
|
onToggleLocked,
|
|
onToggleHiddenMany,
|
|
onToggleLockedMany,
|
|
onClose
|
|
}: {
|
|
nodes: CanvasNode[];
|
|
selectedIds: Set<string>;
|
|
hiddenNodeIds: Set<string>;
|
|
lockedNodeIds: Set<string>;
|
|
onSelect: (nodeId: string) => void;
|
|
onSelectMany: (nodeIds: string[]) => void;
|
|
onRenameNode: (nodeId: string, title: string) => void;
|
|
onToggleHidden: (nodeId: string) => void;
|
|
onToggleLocked: (nodeId: string) => void;
|
|
onToggleHiddenMany: (nodeIds: string[]) => void;
|
|
onToggleLockedMany: (nodeIds: string[]) => void;
|
|
onClose: () => void;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const layers = useMemo(() => buildLayerPanelItems(nodes), [nodes]);
|
|
|
|
return (
|
|
<aside className="canvas-layer-panel" onPointerDown={(event) => event.stopPropagation()}>
|
|
<header>
|
|
<strong>{t("layers")}</strong>
|
|
<button type="button" aria-label={t("close")} onClick={onClose}>
|
|
<X size={17} />
|
|
</button>
|
|
</header>
|
|
<section className="layer-history-section">
|
|
<button type="button">
|
|
<span>{t("layerHistory")}</span>
|
|
<ChevronUp size={14} />
|
|
</button>
|
|
<div className="layer-history-empty">
|
|
<ImageIcon size={58} />
|
|
<span>{t("layerNoHistory")}</span>
|
|
</div>
|
|
</section>
|
|
<div className="layer-list">
|
|
{layers.length === 0 ? (
|
|
<div className="layer-list-empty">{t("layerNoLayers")}</div>
|
|
) : (
|
|
layers.map((item) => {
|
|
if (item.type === "booleanGroup") {
|
|
const selected = selectedIds.has(item.node.id);
|
|
const hidden = hiddenNodeIds.has(item.node.id);
|
|
const locked = lockedNodeIds.has(item.node.id);
|
|
return (
|
|
<Fragment key={item.node.id}>
|
|
<div className={`layer-row layer-group-row boolean-layer-group-row ${selected ? "selected" : ""} ${hidden ? "is-hidden" : ""}`} onClick={() => onSelect(item.node.id)}>
|
|
<ChevronDown size={14} />
|
|
<span className="layer-thumb group-thumb boolean-group-thumb">
|
|
{layerThumb(item.node)}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="layer-name"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onSelect(item.node.id);
|
|
}}
|
|
>
|
|
<strong>{layerTitle(item.node, t)}</strong>
|
|
<small>{t("booleanOperation")}</small>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`layer-control ${locked ? "active" : ""}`}
|
|
aria-label={locked ? t("contextUnlock") : t("contextLock")}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onToggleLocked(item.node.id);
|
|
}}
|
|
>
|
|
{locked ? <Lock size={15} /> : <Unlock size={15} />}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`layer-control ${hidden ? "active" : ""}`}
|
|
aria-label={hidden ? t("contextShow") : t("contextHide")}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onToggleHidden(item.node.id);
|
|
}}
|
|
>
|
|
{hidden ? <EyeOff size={15} /> : <Eye size={15} />}
|
|
</button>
|
|
</div>
|
|
{item.layers.map((node) => (
|
|
<LayerNodeRow
|
|
key={node.id}
|
|
node={node}
|
|
nested
|
|
selected={selected}
|
|
hidden={hiddenNodeIds.has(node.id)}
|
|
locked={lockedNodeIds.has(node.id)}
|
|
controlsVisible={false}
|
|
onSelect={() => onSelect(item.node.id)}
|
|
onRenameNode={onRenameNode}
|
|
onToggleHidden={onToggleHidden}
|
|
onToggleLocked={onToggleLocked}
|
|
/>
|
|
))}
|
|
</Fragment>
|
|
);
|
|
}
|
|
|
|
if (item.type === "group") {
|
|
const nodeIds = item.nodes.map((node) => node.id);
|
|
const selected = nodeIds.some((nodeId) => selectedIds.has(nodeId));
|
|
const hidden = nodeIds.every((nodeId) => hiddenNodeIds.has(nodeId));
|
|
const locked = nodeIds.every((nodeId) => lockedNodeIds.has(nodeId));
|
|
return (
|
|
<Fragment key={item.id}>
|
|
<div className={`layer-row layer-group-row ${selected ? "selected" : ""} ${hidden ? "is-hidden" : ""}`} onClick={() => onSelectMany(nodeIds)}>
|
|
<span className="layer-thumb group-thumb">
|
|
<Layers size={17} />
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="layer-name"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onSelectMany(nodeIds);
|
|
}}
|
|
>
|
|
<strong>{t("layerGroup")}</strong>
|
|
<small>{t("layerItemsCount", { count: item.nodes.length })}</small>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`layer-control ${locked ? "active" : ""}`}
|
|
aria-label={locked ? t("contextUnlock") : t("contextLock")}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onToggleLockedMany(nodeIds);
|
|
}}
|
|
>
|
|
{locked ? <Lock size={15} /> : <Unlock size={15} />}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`layer-control ${hidden ? "active" : ""}`}
|
|
aria-label={hidden ? t("contextShow") : t("contextHide")}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onToggleHiddenMany(nodeIds);
|
|
}}
|
|
>
|
|
{hidden ? <EyeOff size={15} /> : <Eye size={15} />}
|
|
</button>
|
|
</div>
|
|
{item.layers.map((node) => (
|
|
<LayerNodeRow
|
|
key={node.id}
|
|
node={node}
|
|
nested
|
|
selected={selectedIds.has(node.id)}
|
|
hidden={hiddenNodeIds.has(node.id)}
|
|
locked={lockedNodeIds.has(node.id)}
|
|
onSelect={onSelect}
|
|
onRenameNode={onRenameNode}
|
|
onToggleHidden={onToggleHidden}
|
|
onToggleLocked={onToggleLocked}
|
|
/>
|
|
))}
|
|
</Fragment>
|
|
);
|
|
}
|
|
|
|
const node = item.node;
|
|
return (
|
|
<LayerNodeRow
|
|
key={node.id}
|
|
node={node}
|
|
selected={selectedIds.has(node.id)}
|
|
hidden={hiddenNodeIds.has(node.id)}
|
|
locked={lockedNodeIds.has(node.id)}
|
|
onSelect={onSelect}
|
|
onRenameNode={onRenameNode}
|
|
onToggleHidden={onToggleHidden}
|
|
onToggleLocked={onToggleLocked}
|
|
/>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
function LayerNodeRow({
|
|
node,
|
|
selected,
|
|
hidden,
|
|
locked,
|
|
nested = false,
|
|
onSelect,
|
|
onRenameNode,
|
|
onToggleHidden,
|
|
onToggleLocked,
|
|
controlsVisible = true
|
|
}: {
|
|
node: CanvasNode;
|
|
selected: boolean;
|
|
hidden: boolean;
|
|
locked: boolean;
|
|
nested?: boolean;
|
|
onSelect: (nodeId: string) => void;
|
|
onRenameNode: (nodeId: string, title: string) => void;
|
|
onToggleHidden: (nodeId: string) => void;
|
|
onToggleLocked: (nodeId: string) => void;
|
|
controlsVisible?: boolean;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
const [editingName, setEditingName] = useState(false);
|
|
const [draftName, setDraftName] = useState(node.title);
|
|
const title = layerTitle(node, t);
|
|
const canRename = node.type === "image" && !locked;
|
|
|
|
useEffect(() => {
|
|
if (!editingName) setDraftName(node.title);
|
|
}, [editingName, node.title]);
|
|
|
|
useEffect(() => {
|
|
if (!editingName) return;
|
|
const input = inputRef.current;
|
|
if (!input) return;
|
|
input.focus();
|
|
input.select();
|
|
}, [editingName]);
|
|
|
|
const startEditingName = () => {
|
|
if (!canRename) return;
|
|
setDraftName(node.title.trim() || title);
|
|
setEditingName(true);
|
|
};
|
|
|
|
const cancelEditingName = () => {
|
|
setDraftName(node.title);
|
|
setEditingName(false);
|
|
};
|
|
|
|
const commitEditingName = () => {
|
|
const nextName = draftName.trim();
|
|
if (!nextName || nextName === node.title.trim()) {
|
|
cancelEditingName();
|
|
return;
|
|
}
|
|
onRenameNode(node.id, nextName);
|
|
setEditingName(false);
|
|
};
|
|
|
|
return (
|
|
<div className={`layer-row ${nested ? "nested" : ""} ${selected ? "selected" : ""} ${hidden ? "is-hidden" : ""}`} onClick={() => onSelect(node.id)}>
|
|
<span className="layer-thumb">{layerThumb(node)}</span>
|
|
{editingName ? (
|
|
<span className="layer-name layer-name-editing" onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
|
|
<input
|
|
ref={inputRef}
|
|
value={draftName}
|
|
placeholder={t("imageTitlePlaceholder")}
|
|
aria-label={t("renameImageNode")}
|
|
onChange={(event) => setDraftName(event.target.value)}
|
|
onBlur={commitEditingName}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
commitEditingName();
|
|
}
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
cancelEditingName();
|
|
}
|
|
}}
|
|
/>
|
|
</span>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="layer-name"
|
|
aria-label={canRename ? t("renameImageNode") : undefined}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onSelect(node.id);
|
|
}}
|
|
onDoubleClick={(event) => {
|
|
event.stopPropagation();
|
|
startEditingName();
|
|
}}
|
|
>
|
|
<strong>{title}</strong>
|
|
<small>{layerKindLabel(node, t)}</small>
|
|
</button>
|
|
)}
|
|
{controlsVisible ? (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className={`layer-control ${locked ? "active" : ""}`}
|
|
aria-label={locked ? t("contextUnlock") : t("contextLock")}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onToggleLocked(node.id);
|
|
}}
|
|
>
|
|
{locked ? <Lock size={15} /> : <Unlock size={15} />}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`layer-control ${hidden ? "active" : ""}`}
|
|
aria-label={hidden ? t("contextShow") : t("contextHide")}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onToggleHidden(node.id);
|
|
}}
|
|
>
|
|
{hidden ? <EyeOff size={15} /> : <Eye size={15} />}
|
|
</button>
|
|
</>
|
|
) : (
|
|
<span className="layer-controls-placeholder" />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function buildLayerPanelItems(nodes: CanvasNode[]): LayerPanelItem[] {
|
|
const groups = new Map<string, CanvasNode[]>();
|
|
nodes.forEach((node) => {
|
|
if (!node.parentId) return;
|
|
groups.set(node.parentId, [...(groups.get(node.parentId) ?? []), node]);
|
|
});
|
|
|
|
const emittedGroups = new Set<string>();
|
|
const items: LayerPanelItem[] = [];
|
|
[...nodes].reverse().forEach((node) => {
|
|
if (isBooleanShapeOperandNode(node, nodes)) return;
|
|
if (isBooleanShapeGroupNode(node)) {
|
|
items.push({
|
|
type: "booleanGroup",
|
|
node,
|
|
layers: [...(groups.get(node.id) ?? [])].reverse()
|
|
});
|
|
return;
|
|
}
|
|
if (!node.parentId) {
|
|
items.push({ type: "node", node });
|
|
return;
|
|
}
|
|
if (emittedGroups.has(node.parentId)) return;
|
|
const groupNodes = groups.get(node.parentId) ?? [node];
|
|
emittedGroups.add(node.parentId);
|
|
items.push({
|
|
type: "group",
|
|
id: node.parentId,
|
|
nodes: groupNodes,
|
|
layers: [...groupNodes].reverse()
|
|
});
|
|
});
|
|
return items;
|
|
}
|
|
|
|
function layerTitle(node: CanvasNode, t: Translate) {
|
|
const title = node.title.trim();
|
|
if (title) return title;
|
|
if (node.type === "text") return node.content.trim().slice(0, 24) || t("textNode");
|
|
if (node.type === "frame" && node.layerRole === "shape") return t("shapeTool");
|
|
if (node.type === "frame") return t("frameNode");
|
|
if (node.type === "image") return t("imageNode");
|
|
return t("canvasUntitled");
|
|
}
|
|
|
|
function layerKindLabel(node: CanvasNode, t: Translate) {
|
|
if (node.type === "text") return t("textNode");
|
|
if (node.type === "frame" && node.layerRole === "shape") return t("shapeTool");
|
|
if (node.type === "frame") return t("frameNode");
|
|
if (node.type === "image") return t("imageNode");
|
|
return node.type;
|
|
}
|
|
|
|
function layerThumb(node: CanvasNode) {
|
|
if (isBooleanShapeGroupNode(node) || isShapeNode(node)) {
|
|
return <ShapeNodePreview node={node} />;
|
|
}
|
|
if (node.type === "image" && isImageUrl(node.content)) {
|
|
return <img src={thumbnailImageUrl(node.content)} alt="" draggable={false} />;
|
|
}
|
|
if (node.type === "text") return <Type size={17} />;
|
|
if (node.type === "frame") return <Frame size={17} />;
|
|
return <ImageIcon size={17} />;
|
|
}
|
|
|
|
function isGeneratedFileNode(node: CanvasNode, generatedFileURLs: Set<string>) {
|
|
return (
|
|
node.type === "image" &&
|
|
isImageUrl(node.content) &&
|
|
node.status !== "generating" &&
|
|
node.status !== "uploading" &&
|
|
node.status !== "error" &&
|
|
!nonModelGeneratedLayerRoles.has(node.layerRole ?? "") &&
|
|
assetURLSetHas(generatedFileURLs, node.content)
|
|
);
|
|
}
|
|
|
|
function isImageUrl(value: string) {
|
|
return /^(https?:\/\/|data:image\/|blob:|\/)/.test(value.trim());
|
|
}
|
|
|
|
const nonModelGeneratedLayerRoles = new Set(["pen-stroke", "background-removed", "clean-image", "mockup-print", "merge-layer", "manual-frame", "image-generator"]);
|
|
|
|
function generatedFileURLSet(messages: AgentMessage[]) {
|
|
const urls = new Set<string>();
|
|
messages.forEach((message) => {
|
|
if (message.name !== "generate_media" && message.title !== "generate_media" && message.type !== "tool_use") return;
|
|
try {
|
|
const events = JSON.parse(message.content) as Array<{
|
|
event_data?: {
|
|
artifact?: Array<{
|
|
image_url?: string;
|
|
}>;
|
|
};
|
|
}>;
|
|
events.forEach((event) => {
|
|
event.event_data?.artifact?.forEach((artifact) => {
|
|
if (artifact.image_url) addAssetURLKeys(urls, artifact.image_url);
|
|
});
|
|
});
|
|
} catch {
|
|
// Tool messages that are not artifact payloads are ignored here.
|
|
}
|
|
});
|
|
return urls;
|
|
}
|
|
|
|
function assetURLSetHas(items: Set<string>, value: string) {
|
|
return assetURLKeys(value).some((key) => items.has(key));
|
|
}
|
|
|
|
function addAssetURLKeys(items: Set<string>, value: string) {
|
|
assetURLKeys(value).forEach((key) => items.add(key));
|
|
}
|
|
|
|
function assetURLKeys(value: string) {
|
|
const trimmed = value.trim();
|
|
if (!trimmed || trimmed.startsWith("data:image/") || trimmed.startsWith("blob:")) return [];
|
|
const keys = new Set<string>([trimmed]);
|
|
const queryIndex = trimmed.indexOf("?");
|
|
if (queryIndex > 0) keys.add(trimmed.slice(0, queryIndex));
|
|
try {
|
|
const url = new URL(trimmed, window.location.origin);
|
|
keys.add(url.pathname);
|
|
if (url.search) keys.add(`${url.pathname}${url.search}`);
|
|
} catch {
|
|
// Keep exact and queryless keys above.
|
|
}
|
|
return Array.from(keys);
|
|
}
|