From 0ec26843d2f206db1d46d1140a442fe0195feed4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AF=92=E5=AF=92?= <2596194220@qq.com> Date: Tue, 23 Jun 2026 12:22:27 +0800 Subject: [PATCH] Improve canvas toolbar and image generation flow --- src/app/api/canvas/image-file/route.ts | 86 +++++ .../canvas/canvas-editor-client.tsx | 306 ++++++++++++++++-- src/components/canvas/canvas-toolbar.tsx | 79 +++-- src/lib/canvas/generation.ts | 22 +- 4 files changed, 432 insertions(+), 61 deletions(-) create mode 100644 src/app/api/canvas/image-file/route.ts diff --git a/src/app/api/canvas/image-file/route.ts b/src/app/api/canvas/image-file/route.ts new file mode 100644 index 0000000..a4014a1 --- /dev/null +++ b/src/app/api/canvas/image-file/route.ts @@ -0,0 +1,86 @@ +import { NextResponse } from "next/server"; +import { + getGeneratedImagePathFromUrl, + readPersistedImageFile, +} from "@/lib/server/storage/image-file-storage"; + +export const runtime = "nodejs"; + +export async function POST(request: Request) { + const payload = (await request.json().catch(() => ({}))) as { + filePath?: string; + imageUrl?: string; + }; + const imageUrl = payload.imageUrl?.trim(); + const filePath = resolveReadableImagePath(imageUrl, payload.filePath?.trim()); + + if (filePath) { + const buffer = await readPersistedImageFile(filePath); + if (!buffer) { + return NextResponse.json({ error: "图片文件不存在" }, { status: 404 }); + } + + return createImageResponse(buffer, getImageContentType(filePath)); + } + + if (!imageUrl) { + return NextResponse.json({ error: "图片地址不可读取" }, { status: 400 }); + } + + const remoteImage = await readRemoteImage(imageUrl); + if (!remoteImage) { + return NextResponse.json({ error: "远程图片不可访问" }, { status: 502 }); + } + + return createImageResponse(remoteImage.buffer, remoteImage.contentType); +} + +function createImageResponse(buffer: Buffer, contentType: string) { + return new Response(buffer, { + headers: { + "Cache-Control": "no-store", + "Content-Type": contentType, + }, + }); +} + +function resolveReadableImagePath(imageUrl?: string, filePath?: string) { + if (filePath?.startsWith("s3://")) return filePath; + if (imageUrl) return getGeneratedImagePathFromUrl(imageUrl); + return null; +} + +async function readRemoteImage(imageUrl: string) { + if (!isAllowedRemoteImageUrl(imageUrl)) return null; + + const response = await fetch(imageUrl, { cache: "no-store" }); + if (!response.ok) return null; + + const contentType = response.headers.get("content-type") || "image/png"; + if (!contentType.startsWith("image/")) return null; + + return { + buffer: Buffer.from(await response.arrayBuffer()), + contentType, + }; +} + +function isAllowedRemoteImageUrl(imageUrl: string) { + try { + const url = new URL(imageUrl); + return ( + url.protocol === "https:" && + url.hostname === "file.hanhan.ltd" && + url.pathname.startsWith("/imagegen-tools/generated/") + ); + } catch { + return false; + } +} + +function getImageContentType(filePath: string) { + const extension = filePath.split(".").pop()?.toLowerCase(); + if (extension === "jpg" || extension === "jpeg") return "image/jpeg"; + if (extension === "webp") return "image/webp"; + return "image/png"; +} diff --git a/src/components/canvas/canvas-editor-client.tsx b/src/components/canvas/canvas-editor-client.tsx index 3f426b5..eff08ca 100644 --- a/src/components/canvas/canvas-editor-client.tsx +++ b/src/components/canvas/canvas-editor-client.tsx @@ -9,6 +9,9 @@ import type { import { FileText, ImageIcon, X } from "lucide-react"; import { toast, Toaster } from "sonner"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; import { CanvasConnections } from "@/components/canvas/canvas-connections"; import { CanvasNode } from "@/components/canvas/canvas-node"; import { CanvasNodeInspector } from "@/components/canvas/canvas-node-inspector"; @@ -98,6 +101,40 @@ type StoredCanvasJob = { requestStartedAt: number; }; +type CanvasSettingsTab = "model" | "interface"; + +type CanvasSettings = { + hideToolbarText: boolean; + modelName: string; + baseUrl: string; + apiKeySource: string; +}; + +const canvasSettingsStorageKey = "imagegen:canvas:settings"; +const defaultCanvasSettings: CanvasSettings = { + hideToolbarText: false, + modelName: "gpt-image-2-2k", + baseUrl: "OPENAI_BASE_URL", + apiKeySource: "OPENAI_API_KEY", +}; + +function getInitialCanvasSettings() { + if (typeof window === "undefined") return defaultCanvasSettings; + + try { + const raw = window.localStorage.getItem(canvasSettingsStorageKey); + if (!raw) return defaultCanvasSettings; + const stored = JSON.parse(raw) as Partial; + return { + ...defaultCanvasSettings, + ...stored, + hideToolbarText: Boolean(stored.hideToolbarText), + }; + } catch { + return defaultCanvasSettings; + } +} + export function CanvasEditorClient({ projectId }: { projectId: string }) { const router = useRouter(); const containerRef = useRef(null); @@ -127,6 +164,11 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { const [showLibrary, setShowLibrary] = useState(false); const [showImportDialog, setShowImportDialog] = useState(false); const [showShortcutsDialog, setShowShortcutsDialog] = useState(false); + const [showSettingsDialog, setShowSettingsDialog] = useState(false); + const [settingsTab, setSettingsTab] = useState("model"); + const [canvasSettings, setCanvasSettings] = useState( + getInitialCanvasSettings, + ); const [importJson, setImportJson] = useState(""); const [isImporting, setIsImporting] = useState(false); const [selectionMode, setSelectionMode] = useState(false); @@ -139,6 +181,17 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { projectRef.current = project; }, [project]); + useEffect(() => { + try { + window.localStorage.setItem( + canvasSettingsStorageKey, + JSON.stringify(canvasSettings), + ); + } catch { + // Local persistence is a convenience; the canvas should keep working. + } + }, [canvasSettings]); + useEffect(() => { let cancelled = false; @@ -923,31 +976,35 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { setIsGenerating(true); await saveProject(projectWithLoadingNode); - const formData = new FormData(); - formData.append("mode", resolved.imageNodes.length ? "edit" : "generate"); - formData.append("prompt", resolved.prompt); - formData.append("model", resolved.config.model); - formData.append("size", resolved.config.size); - formData.append("quality", resolved.config.quality); - formData.append("outputFormat", resolved.config.outputFormat); - formData.append("preserveIdentity", String(resolved.config.preserveIdentity)); - - for (const [index, imageNode] of resolved.imageNodes.entries()) { - const imageMeta = imageNode.metadata as { imageUrl?: string }; - if (imageMeta.imageUrl) { - const file = await imageUrlToFile( - imageMeta.imageUrl, - `canvas-reference-${index + 1}.png`, - ); - formData.append("image", file); - } - } - const clientRequestId = createClientRequestId(); const requestStartedAt = getNowMs(); setJob(null); try { + const formData = new FormData(); + formData.append("mode", resolved.imageNodes.length ? "edit" : "generate"); + formData.append("prompt", resolved.prompt); + formData.append("model", resolved.config.model); + formData.append("size", resolved.config.size); + formData.append("quality", resolved.config.quality); + formData.append("outputFormat", resolved.config.outputFormat); + formData.append("preserveIdentity", String(resolved.config.preserveIdentity)); + + for (const [index, imageNode] of resolved.imageNodes.entries()) { + const imageMeta = imageNode.metadata as { + filePath?: string; + imageUrl?: string; + }; + if (imageMeta.imageUrl) { + const file = await imageUrlToFile( + imageMeta.imageUrl, + `canvas-reference-${index + 1}.png`, + imageMeta.filePath, + ); + formData.append("image", file); + } + } + const response = await fetch("/api/images", { method: "POST", headers: { "x-client-request-id": clientRequestId }, @@ -1213,6 +1270,7 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { if (event.key === "Escape") { setShowShortcutsDialog(false); setShowImportDialog(false); + setShowSettingsDialog(false); setActiveConnection(null); setSelectedConnectionId(null); setSelectionBox(null); @@ -1275,6 +1333,7 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { canDeleteSelection={canDeleteSelection} canRedo={historyAvailability.canRedo} canUndo={historyAvailability.canUndo} + hideDockLabels={canvasSettings.hideToolbarText} isGenerating={isGenerating} isSaving={isSaving} scale={viewport.k} @@ -1296,6 +1355,7 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { onResetView={() => updateViewport({ x: 0, y: 0, k: 1 })} onToggleLibrary={() => setShowLibrary((current) => !current)} onToggleSelectionMode={() => setSelectionMode((current) => !current)} + onOpenSettings={() => setShowSettingsDialog(true)} onShowShortcuts={() => setShowShortcutsDialog(true)} onCopyJson={() => void copyCanvasJson()} onImportJson={() => setShowImportDialog(true)} @@ -1455,6 +1515,14 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { open={showShortcutsDialog} onClose={() => setShowShortcutsDialog(false)} /> + setShowSettingsDialog(false)} + onTabChange={setSettingsTab} + /> ); } @@ -1730,15 +1798,203 @@ function ShortcutDialog({ ); } +const SETTINGS_TABS = [ + { + id: "model", + title: "大模型配置", + description: "模型、接口和密钥来源", + }, + { + id: "interface", + title: "界面设置", + description: "画布操作体验", + }, +] satisfies Array<{ + id: CanvasSettingsTab; + title: string; + description: string; +}>; + +function CanvasSettingsDialog({ + activeTab, + open, + settings, + onChange, + onClose, + onTabChange, +}: { + activeTab: CanvasSettingsTab; + open: boolean; + settings: CanvasSettings; + onChange: (settings: CanvasSettings) => void; + onClose: () => void; + onTabChange: (tab: CanvasSettingsTab) => void; +}) { + if (!open) return null; + + const patchSettings = (patch: Partial) => { + onChange({ ...settings, ...patch }); + }; + + return ( +
{ + if (event.target === event.currentTarget) onClose(); + }} + > +
+ +
+
+
+

+ {activeTab === "model" ? "大模型配置" : "界面设置"} +

+

+ {activeTab === "model" + ? "当前为本地界面配置记录,实际请求仍读取服务端环境变量。" + : "调整画布工具栏在不同窗口尺寸下的展示方式。"} +

+
+ +
+ + {activeTab === "model" ? ( +
+
+ + + patchSettings({ modelName: event.target.value }) + } + /> +
+
+ + + patchSettings({ baseUrl: event.target.value }) + } + /> +
+
+ + + patchSettings({ apiKeySource: event.target.value }) + } + /> +
+
+ 服务端生成接口目前使用 .env 中的 OPENAI_API_KEY 和 + OPENAI_BASE_URL;这里先作为配置面板入口保留。 +
+
+ ) : ( +
+
+
+
+ 操作栏隐藏文字 +
+
+ 开启后底部操作栏仅显示图标,文字通过悬浮提示显示。 +
+
+ + patchSettings({ hideToolbarText: checked }) + } + /> +
+
+ )} +
+
+
+ ); +} + function isShortcutSeparator(key: string) { return key === "+" || key === "/"; } -async function imageUrlToFile(url: string, filename: string) { - const response = await fetch(url, { cache: "no-store" }); - if (!response.ok) { - throw new Error(`读取图片失败:HTTP ${response.status}`); +async function imageUrlToFile(url: string, filename: string, filePath?: string) { + const serverResponse = await fetch("/api/canvas/image-file", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ filePath, imageUrl: url }), + }); + if (serverResponse.ok) { + const blob = await serverResponse.blob(); + return new File([blob], filename, { + type: blob.type || "image/png", + }); } + + const resolvedUrl = new URL(url, window.location.origin).toString(); + let response: Response; + + try { + response = await fetch(resolvedUrl, { cache: "no-store" }); + } catch { + throw new Error(`读取参考图片失败:无法访问 ${url}`); + } + + if (!response.ok) { + throw new Error(`读取参考图片失败:HTTP ${response.status} ${url}`); + } + const blob = await response.blob(); return new File([blob], filename, { type: blob.type || "image/png", @@ -1798,7 +2054,7 @@ function inferConnectionType( const to = nodes.find((node) => node.id === toNodeId); if (to?.type === "config") return "config"; if (to?.type === "prompt") return from?.type === "image" ? "reference" : "prompt"; - if (to?.type === "image") return from?.type === "config" ? "config" : "generated"; + if (to?.type === "image") return "generated"; return undefined; } diff --git a/src/components/canvas/canvas-toolbar.tsx b/src/components/canvas/canvas-toolbar.tsx index c7fd7fc..ad685a0 100644 --- a/src/components/canvas/canvas-toolbar.tsx +++ b/src/components/canvas/canvas-toolbar.tsx @@ -14,6 +14,7 @@ import { Minus, Plus, Redo2, + Settings, Sparkles, SquareDashedMousePointer, Trash2, @@ -36,6 +37,7 @@ type CanvasToolbarProps = { canUndo: boolean; canRedo: boolean; canDeleteSelection: boolean; + hideDockLabels: boolean; onBack: () => void; onAddNode: (type: CanvasNodeType) => void; onUndo: () => void; @@ -53,6 +55,7 @@ type CanvasToolbarProps = { onToggleLibrary: () => void; onToggleSelectionMode: () => void; onShowShortcuts: () => void; + onOpenSettings: () => void; }; export function CanvasToolbar({ @@ -66,6 +69,7 @@ export function CanvasToolbar({ canUndo, canRedo, canDeleteSelection, + hideDockLabels, onBack, onAddNode, onUndo, @@ -83,35 +87,44 @@ export function CanvasToolbar({ onToggleLibrary, onToggleSelectionMode, onShowShortcuts, + onOpenSettings, }: CanvasToolbarProps) { return ( <>
-
+
-
{title}
-
+
{title}
+
无限画布
-
+
+
-
+
-
+
{Math.round(scale * 100)}%
-
+
@@ -206,50 +220,59 @@ export function CanvasToolbar({ /> } label="撤回" onClick={onUndo} /> } label="重做" onClick={onRedo} /> } label="文本" onClick={() => onAddNode("prompt")} /> } label="图片" onClick={() => onAddNode("image")} /> } label="配置" onClick={() => onAddNode("config")} /> } label="上传" onClick={onUploadMaterial} /> } label="素材库" onClick={onToggleLibrary} /> } label="复制JSON" onClick={onCopyJson} /> } label="导入JSON" onClick={onImportJson} @@ -259,6 +282,7 @@ export function CanvasToolbar({ } label="删除" onClick={onDeleteSelection} @@ -268,6 +292,7 @@ export function CanvasToolbar({ } label="清空" onClick={onClearCanvas} @@ -308,6 +333,7 @@ function DockButton({ active = false, danger = false, disabled = false, + hideLabel = false, onClick, }: { icon: React.ReactNode; @@ -315,12 +341,13 @@ function DockButton({ active?: boolean; danger?: boolean; disabled?: boolean; + hideLabel?: boolean; onClick: () => void; }) { return ( ); } function DockDivider() { - return
; + return
; } diff --git a/src/lib/canvas/generation.ts b/src/lib/canvas/generation.ts index 848542a..ff8cc9e 100644 --- a/src/lib/canvas/generation.ts +++ b/src/lib/canvas/generation.ts @@ -79,19 +79,16 @@ export function resolveCanvasGenerationInput( const configNode = scopedNodes.find((node) => node.type === "config") ?? nodes.find((node) => node.type === "config"); - const imageNodes = collectImageNodes( + const imageNodes = collectReferencedImageNodes( scopedNodes, resolvedMentions?.referencedNodeIds ?? [], ).filter((node) => node.id !== promptNode.id); - const fallbackImageNodes = imageNodes.length - ? imageNodes - : collectImageNodes(nodes, []).filter((node) => node.id !== promptNode.id); return { promptNode, configNode, - imageNode: fallbackImageNodes[0], - imageNodes: fallbackImageNodes.slice(0, 16), + imageNode: imageNodes[0], + imageNodes: imageNodes.slice(0, 16), prompt, config: { ...defaultCanvasConfig, @@ -121,16 +118,13 @@ function formatConnectedPrompts( .join("\n\n"); } -function collectImageNodes(nodes: CanvasNode[], preferredNodeIds: string[]) { - const preferred = preferredNodeIds +function collectReferencedImageNodes( + nodes: CanvasNode[], + referencedNodeIds: string[], +) { + return referencedNodeIds .map((id) => nodes.find((node) => node.id === id)) .filter(isImageWithUrl); - const preferredIds = new Set(preferred.map((node) => node.id)); - const remaining = nodes - .filter(isImageWithUrl) - .filter((node) => !preferredIds.has(node.id)); - - return [...preferred, ...remaining]; } function isImageWithUrl(node: CanvasNode | undefined): node is CanvasNode {