From c3b1ba2943802819c4696ed12ead384a1821231d Mon Sep 17 00:00:00 2001 From: stupid-h4er Date: Sun, 31 May 2026 00:54:54 +0800 Subject: [PATCH] feat: label image references in generation --- docs/pending-test.md | 1 + .../components/canvas-assistant-panel.tsx | 22 +++++++++++----- .../components/canvas-config-node-panel.tsx | 3 ++- web/src/app/(user)/image/page.tsx | 25 +++++++++++++++++-- web/src/lib/image-reference-prompt.ts | 12 +++++++++ web/src/services/api/image.ts | 4 ++- 6 files changed, 57 insertions(+), 10 deletions(-) create mode 100644 web/src/lib/image-reference-prompt.ts diff --git a/docs/pending-test.md b/docs/pending-test.md index 2db3ddf..94a9502 100644 --- a/docs/pending-test.md +++ b/docs/pending-test.md @@ -1,5 +1,6 @@ # 待测试 +- 生图工作台和画布生图会把参考图按当前顺序显示为 `图片1`、`图片2` 等编号,并在图生图请求的实际提示词中注入编号说明;需要验证 `/image` 参考图排序、画布配置节点输入顺序和画布助手参考图编号一致。 - GPT Image 生图请求会在前端把 `9:16`、`16:9` 等比例转换成合法 `WIDTHxHEIGHT` 尺寸,并在非法尺寸时直接显示中文错误,避免上游返回 `invalid_value Invalid size`。 - Docker 部署时,`DATABASE_DSN=data/infinite-canvas.db` 会在存在 `/app/data` 挂载目录时自动归一到 `/app/data/infinite-canvas.db`,需要验证后台模型配置不会再因为工作目录变为 `/app/web` 而读到空库。 - Seedance 参考视频被火山判定包含真人或隐私信息时,前端错误摘要会提示改用不含真人的视频、官方允许的模型产物或已授权的 `asset://` 素材;参考素材上传目录改为跟随 SQLite 数据目录,并补充公开素材的 HEAD 访问。 diff --git a/web/src/app/(user)/canvas/components/canvas-assistant-panel.tsx b/web/src/app/(user)/canvas/components/canvas-assistant-panel.tsx index 8860bee..0b10338 100644 --- a/web/src/app/(user)/canvas/components/canvas-assistant-panel.tsx +++ b/web/src/app/(user)/canvas/components/canvas-assistant-panel.tsx @@ -16,6 +16,7 @@ import { requestEdit, requestGeneration, requestImageQuestion, type ChatCompleti import { imageToDataUrl, uploadImage } from "@/services/image-storage"; import { useAssetStore } from "@/stores/use-asset-store"; import { useThemeStore } from "@/stores/use-theme-store"; +import { imageReferenceLabel } from "@/lib/image-reference-prompt"; import type { ReferenceImage } from "@/types/image"; import { DiaTextReveal } from "@/components/ui/dia-text-reveal"; import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover"; @@ -398,8 +399,8 @@ function AssistantComposer({
event.stopPropagation()}> {references.length ? (
- {references.map((item) => ( - onRemoveReference(item.id)} /> + {references.map((item, index) => ( + onRemoveReference(item.id)} /> ))}
) : null} @@ -584,20 +585,23 @@ function AssistantHistory({ function MessageReferences({ message }: { message: CanvasAssistantMessage }) { return (
- {message.references?.map((item) => ( - + {message.references?.map((item, index, references) => ( + ))}
); } -function AssistantReferenceChip({ item, onRemove }: { item: CanvasAssistantReference; onRemove?: () => void }) { +function AssistantReferenceChip({ item, label, onRemove }: { item: CanvasAssistantReference; label?: string; onRemove?: () => void }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const text = (item.text || item.title).replace(/\s+/g, " ").trim().slice(0, 1) || "文"; return (
{item.dataUrl ? ( - + + + {label ? {label} : null} + ) : ( {text} @@ -618,6 +622,12 @@ function AssistantReferenceChip({ item, onRemove }: { item: CanvasAssistantRefer ); } +function assistantImageReferenceLabel(references: CanvasAssistantReference[], index: number) { + if (!references[index]?.dataUrl) return undefined; + const imageIndex = references.slice(0, index + 1).filter((item) => item.dataUrl).length - 1; + return imageIndex >= 0 ? imageReferenceLabel(imageIndex) : undefined; +} + function nodeToReference(node: CanvasNodeData): CanvasAssistantReference | null { if (node.type === CanvasNodeType.Image && node.metadata?.content) { return { id: node.id, type: node.type, title: node.title, dataUrl: node.metadata.content, storageKey: node.metadata.storageKey }; diff --git a/web/src/app/(user)/canvas/components/canvas-config-node-panel.tsx b/web/src/app/(user)/canvas/components/canvas-config-node-panel.tsx index f910ed7..4116989 100644 --- a/web/src/app/(user)/canvas/components/canvas-config-node-panel.tsx +++ b/web/src/app/(user)/canvas/components/canvas-config-node-panel.tsx @@ -9,6 +9,7 @@ import { ModelPicker } from "@/components/model-picker"; import { defaultConfig, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; import { CreditSymbol, requestCreditCost } from "@/constant/credits"; import { canvasThemes } from "@/lib/canvas-theme"; +import { imageReferenceLabel } from "@/lib/image-reference-prompt"; import { seedanceReferenceLabel } from "@/lib/seedance-video"; import { useThemeStore } from "@/stores/use-theme-store"; import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover"; @@ -304,7 +305,7 @@ function ImageSortCard({
{input.title} - {seedanceReferenceLabel("image", imageIndex)} + {imageReferenceLabel(imageIndex)} onMove(input, offset)} />
diff --git a/web/src/app/(user)/image/page.tsx b/web/src/app/(user)/image/page.tsx index 34be005..44e06f5 100644 --- a/web/src/app/(user)/image/page.tsx +++ b/web/src/app/(user)/image/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { BookOpen, CheckSquare, ClipboardPaste, Download, FolderPlus, History, ImagePlus, LoaderCircle, PenLine, Plus, SlidersHorizontal, Sparkles, Trash2, Upload } from "lucide-react"; +import { ArrowLeft, ArrowRight, BookOpen, CheckSquare, ClipboardPaste, Download, FolderPlus, History, ImagePlus, LoaderCircle, PenLine, Plus, SlidersHorizontal, Sparkles, Trash2, Upload } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { App, Button, Checkbox, Drawer, Empty, Image, Input, Modal, Tag, Typography } from "antd"; import localforage from "localforage"; @@ -11,6 +11,7 @@ import { ModelPicker } from "@/components/model-picker"; import { PromptSelectDialog } from "@/components/prompts/prompt-select-dialog"; import { AssetPickerModal, type InsertAssetPayload } from "@/app/(user)/canvas/components/asset-picker-modal"; import { canvasThemes } from "@/lib/canvas-theme"; +import { imageReferenceLabel } from "@/lib/image-reference-prompt"; import { useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; import { useThemeStore } from "@/stores/use-theme-store"; import { nanoid } from "nanoid"; @@ -373,9 +374,11 @@ export default function ImagePage() { event.currentTarget.scrollLeft += event.deltaY; }} > - {references.map((item) => ( + {references.map((item, index) => (
{item.name} + {imageReferenceLabel(index)} + setReferences((value) => moveListItem(value, index, offset))} />
+ ); +} + function buildLog({ prompt, model, diff --git a/web/src/lib/image-reference-prompt.ts b/web/src/lib/image-reference-prompt.ts new file mode 100644 index 0000000..3b99ba4 --- /dev/null +++ b/web/src/lib/image-reference-prompt.ts @@ -0,0 +1,12 @@ +import type { ReferenceImage } from "@/types/image"; + +export function imageReferenceLabel(index: number) { + return `图片${index + 1}`; +} + +export function buildImageReferencePromptText(prompt: string, references: ReferenceImage[]) { + const text = prompt.trim(); + if (!references.length) return text; + const labels = references.map((_, index) => imageReferenceLabel(index)); + return `参考图片编号:${labels.join("、")}。请按这些编号理解提示词中的图片引用。\n\n${text}`; +} diff --git a/web/src/services/api/image.ts b/web/src/services/api/image.ts index 87319e2..3bc5b8f 100644 --- a/web/src/services/api/image.ts +++ b/web/src/services/api/image.ts @@ -4,6 +4,7 @@ import { buildApiUrl, type AiConfig } from "@/stores/use-config-store"; import { useUserStore } from "@/stores/use-user-store"; import { nanoid } from "nanoid"; import { dataUrlToFile } from "@/lib/image-utils"; +import { buildImageReferencePromptText } from "@/lib/image-reference-prompt"; import { imageToDataUrl } from "@/services/image-storage"; import type { ReferenceImage } from "@/types/image"; @@ -223,9 +224,10 @@ export async function requestEdit(config: AiConfig, prompt: string, references: const n = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1))); const quality = normalizeQuality(config.quality); const requestSize = resolveRequestSize(quality, config.size); + const requestPrompt = buildImageReferencePromptText(prompt, references); const formData = new FormData(); formData.set("model", config.model); - formData.set("prompt", withSystemPrompt(config, prompt)); + formData.set("prompt", withSystemPrompt(config, requestPrompt)); formData.set("n", String(n)); formData.set("response_format", "b64_json"); if (quality) {