From 6d3b89c8421956e82d823b2af7ed9087b1d77776 Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Thu, 4 Jun 2026 17:25:30 +0800 Subject: [PATCH] feat(toolbar): add reverse prompt feature to image node toolbar for generating prompt text --- CHANGELOG.md | 4 ++ docs/content/docs/progress/pending-test.mdx | 1 + .../(user)/canvas/[id]/canvas-client-page.tsx | 54 +++++++++++++++++++ .../components/canvas-image-toolbar-tools.tsx | 16 ++++-- .../components/canvas-node-hover-toolbar.tsx | 4 +- 5 files changed, 75 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aeca33b..d35052a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +## v0.2.4 - 2026-06-04 + ++ [新增] 新增图片反推提示词功能。 + ## v0.2.3 - 2026-06-04 + [新增] 新增图片蒙版局部修改功能。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index baabda0..b947de5 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -7,6 +7,7 @@ description: 当前版本已实现但仍需人工验证的变更项 - 画布生成配置节点的“生成配置/组装提示词”改为在节点下方打开独立输入浮层;浮层支持输入 `@` 从已连接图片、文本、视频、音频中选择引用,引用会以图片缩略图或文本标记展示,图片引用可放大预览;生成时再按当前引用解析为实际素材编号,不再提供或读取输入排序。 - 图片节点悬浮工具栏新增“局部编辑”入口,打开后可在图片上用画笔/擦除工具绘制遮罩区域、填写局部修改要求,并通过图片编辑接口携带同尺寸 PNG mask 生成新图片节点;结果节点会放在原图右侧并自动连线,原图保持不变。 +- 图片节点悬浮工具栏新增“反推提示词”入口;点击后会在原图右侧创建包含反推预设提示词的文本节点,并继续创建一个已切到“文本”模式的生成配置节点,图片节点和文本节点都会自动连到该配置节点,配置节点会预填包含原图和文本节点引用的组装提示词并自动打开,用户可自行点击配置节点开始生成。 - 图片节点悬浮工具栏新增“复制提示词”、“放大”和“超分”入口,并在末尾增加 `...` 更多按钮;点击“复制提示词”会复制生成该图片的提示词,图片没有提示词时会提示暂无可复制内容;点击 `...` 后打开 Ant Design 风格的“自定义工具栏”弹窗,可在图片节点占位上预览悬浮工具栏,信息、删除、存素材、下载、编辑和图片工具都在同一个快捷工具列表中勾选配置,并可切换是否显示按钮文字,保存后写入本地配置;`...` 配置入口固定显示,预览工具栏下方提供常驻横向滚动控制条。“放大”可在弹窗中选择 1K/2K/4K 目标像素和高清插值、双线性、最近邻算法,按原图比例生成新图片节点,已达到的目标像素会禁用并提示无需放大,最高不超过 4K;“超分”当前只打开暂未实现弹窗。 - Canvas 资源节点会按当前生成上下文显示 `图片1`、`视频1`、`音频1`、`文本1` 角标;文本节点内容框和节点底部 prompt 面板输入 `@` 时应弹出已连接资源选择器,点击缩略图或文字可插入纯文本编号,并以蓝色 token 视觉高亮。 - 文本节点连接到生成配置节点时,`@` 候选和实际生成输入应读取该生成配置节点的上游参考资源;生成配置输入统计区域应可拖动整个配置节点,预览按钮和设置控件仍保持可点击。 diff --git a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx index 90ab1bf..4c24405 100644 --- a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx +++ b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx @@ -89,6 +89,12 @@ const CONNECTION_NODE_HIT_PADDING = 32; const NODE_STATUS_LOADING = "loading" as const; const NODE_STATUS_SUCCESS = "success" as const; const NODE_STATUS_ERROR = "error" as const; +const IMAGE_PROMPT_REVERSE_PRESET = `请根据参考图片反推一段适合用于 AI 生图的提示词。 + +要求: +1. 只输出提示词正文,不要解释。 +2. 覆盖主体、构图、风格、光线、色彩、材质、镜头和氛围。 +3. 尽量写成可直接用于生图模型的完整提示词。`; function createCanvasNode(type: CanvasNodeType, position: Position, metadata?: CanvasNodeMetadata): CanvasNodeData { const spec = getNodeSpec(type); @@ -1441,6 +1447,53 @@ function InfiniteCanvasPage() { [addAsset, message], ); + const createImageReversePromptNodes = useCallback( + (node: CanvasNodeData) => { + if (node.type !== CanvasNodeType.Image || !node.metadata?.content) { + message.warning("图片节点为空,无法反推提示词"); + return; + } + + const gap = 96; + const textSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Text]; + const configSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Config]; + const centerY = node.position.y + node.height / 2; + const textNode = { + ...createCanvasNode( + CanvasNodeType.Text, + { x: node.position.x + node.width + gap + textSpec.width / 2, y: centerY }, + { content: IMAGE_PROMPT_REVERSE_PRESET, prompt: IMAGE_PROMPT_REVERSE_PRESET, status: NODE_STATUS_SUCCESS, fontSize: 14 }, + ), + title: "反推提示词", + }; + const configNode = { + ...createCanvasNode( + CanvasNodeType.Config, + { x: textNode.position.x + textNode.width + gap + configSpec.width / 2, y: centerY }, + { + generationMode: "text", + model: effectiveConfig.textModel || effectiveConfig.model || defaultConfig.textModel, + count: 1, + composerContent: `参考图片:@[node:${node.id}]\n任务说明:@[node:${textNode.id}]`, + }, + ), + title: "反推提示词配置", + }; + + setNodes((prev) => [...prev, textNode, configNode]); + setConnections((prev) => [ + ...prev, + { id: nanoid(), fromNodeId: node.id, toNodeId: configNode.id }, + { id: nanoid(), fromNodeId: textNode.id, toNodeId: configNode.id }, + ]); + setSelectedNodeIds(new Set([configNode.id])); + setSelectedConnectionId(null); + setDialogNodeId(configNode.id); + setContextMenu(null); + }, + [effectiveConfig.model, effectiveConfig.textModel, message], + ); + const cropImageNode = useCallback(async (node: CanvasNodeData, crop: CanvasImageCropRect) => { if (!node.metadata?.content) return; const cropped = await cropDataUrl(node.metadata.content, crop); @@ -2387,6 +2440,7 @@ function InfiniteCanvasPage() { onSuperResolve={(node) => setSuperResolveNodeId(node.id)} onAngle={(node) => setAngleNodeId(node.id)} onViewImage={(node) => setPreviewNodeId(node.id)} + onReversePrompt={createImageReversePromptNodes} onRetry={(node) => void handleRetryNode(node)} onToggleFreeResize={(node) => toggleNodeFreeResize(node.id)} onDelete={(node) => deleteNodes(new Set([node.id]))} diff --git a/web/src/app/(user)/canvas/components/canvas-image-toolbar-tools.tsx b/web/src/app/(user)/canvas/components/canvas-image-toolbar-tools.tsx index 2cbee7d..22232b8 100644 --- a/web/src/app/(user)/canvas/components/canvas-image-toolbar-tools.tsx +++ b/web/src/app/(user)/canvas/components/canvas-image-toolbar-tools.tsx @@ -1,11 +1,11 @@ "use client"; import type { ReactNode } from "react"; -import { Brush, Camera, Copy, Lock, LockOpen, Maximize2, Scissors, Sparkles, Upload, ZoomIn } from "lucide-react"; +import { Brush, Camera, Copy, FileText, Lock, LockOpen, Maximize2, Scissors, Sparkles, Upload, ZoomIn } from "lucide-react"; import type { CanvasNodeData } from "../types"; -export type ImageNodeActionToolId = "copyPrompt" | "replace" | "resize" | "maskEdit" | "crop" | "upscale" | "superResolve" | "angle" | "view"; +export type ImageNodeActionToolId = "copyPrompt" | "reversePrompt" | "replace" | "resize" | "maskEdit" | "crop" | "upscale" | "superResolve" | "angle" | "view"; export type ImageQuickToolId = "info" | "delete" | "saveAsset" | "download" | "edit" | ImageNodeActionToolId; export type ImageToolHandlers = { @@ -18,6 +18,7 @@ export type ImageToolHandlers = { onAngle: (node: CanvasNodeData) => void; onViewImage: (node: CanvasNodeData) => void; onCopyPrompt: (node: CanvasNodeData) => void; + onReversePrompt: (node: CanvasNodeData) => void; }; export type ImageToolDefinition = { @@ -36,7 +37,7 @@ export type ImageQuickToolsConfig = { showLabels: boolean; }; -export const IMAGE_QUICK_TOOLS_STORAGE_KEY = "canvas-image-quick-tools-v4"; +export const IMAGE_QUICK_TOOLS_STORAGE_KEY = "canvas-image-quick-tools-v5"; const defaultBaseToolIds: ImageQuickToolId[] = ["info", "delete", "saveAsset", "download", "edit"]; @@ -50,6 +51,15 @@ export const imageToolDefinitions: ImageToolDefinition[] = [ icon: () => , run: (node, handlers) => handlers.onCopyPrompt(node), }, + { + id: "reversePrompt", + defaultVisible: true, + panelLabel: "反推提示词", + label: "反推提示词", + title: "创建反推提示词的文本和配置节点", + icon: () => , + run: (node, handlers) => handlers.onReversePrompt(node), + }, { id: "replace", defaultVisible: true, diff --git a/web/src/app/(user)/canvas/components/canvas-node-hover-toolbar.tsx b/web/src/app/(user)/canvas/components/canvas-node-hover-toolbar.tsx index 5ac9566..9d1c0d7 100644 --- a/web/src/app/(user)/canvas/components/canvas-node-hover-toolbar.tsx +++ b/web/src/app/(user)/canvas/components/canvas-node-hover-toolbar.tsx @@ -32,6 +32,7 @@ type CanvasNodeHoverToolbarProps = { onSuperResolve: (node: CanvasNodeData) => void; onAngle: (node: CanvasNodeData) => void; onViewImage: (node: CanvasNodeData) => void; + onReversePrompt: (node: CanvasNodeData) => void; onRetry: (node: CanvasNodeData) => void; onToggleFreeResize: (node: CanvasNodeData) => void; onDelete: (node: CanvasNodeData) => void; @@ -67,6 +68,7 @@ export function CanvasNodeHoverToolbar({ onSuperResolve, onAngle, onViewImage, + onReversePrompt, onRetry, onToggleFreeResize, onDelete, @@ -119,7 +121,7 @@ export function CanvasNodeHoverToolbar({ } copyText(prompt, "提示词已复制"); }; - const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onMaskEdit, onCrop, onUpscale, onSuperResolve, onAngle, onViewImage, onCopyPrompt: copyImagePrompt }); + const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onMaskEdit, onCrop, onUpscale, onSuperResolve, onAngle, onViewImage, onCopyPrompt: copyImagePrompt, onReversePrompt }); function openImageToolSettings() { onKeep(node.id);