diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 00994e3..a1ac3bd 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,8 @@ description: 当前版本已实现但仍需人工验证的变更项 # 待测试 +- Canvas 资源节点会按当前生成上下文显示 `图片1`、`视频1`、`音频1`、`文本1` 角标;文本节点内容框、节点底部 prompt 面板和生成配置预览文本编辑框输入 `@` 时应弹出已连接资源选择器,点击缩略图或文字可插入纯文本编号,并以蓝色 token 视觉高亮。 +- 文本节点连接到生成配置节点时,`@` 候选和实际生成输入应读取该生成配置节点的上游参考资源;生成配置输入统计区域应可拖动整个配置节点,预览按钮和设置控件仍保持可点击。 - 配置弹窗改为“配置与用户偏好”并放大为可滚动弹窗;本地直连支持配置生图、视频、文本、音频四类可选模型列表和默认模型,新建画布生图和配置节点会读取“画布默认生图张数”,需要验证远程渠道和本地直连模型列表都能正确显示。 - 画布音频节点底部生成面板改为音频提示词、音频模型下拉和 OpenAI Speech 参数设置,支持 `voice`、`response_format`、`speed`、`instructions` 并通过 `/audio/speech` 生成音频节点;需要验证本地直连和云端渠道的生成、重试、下载和刷新恢复。 - 画布左上角菜单和右上角状态栏新增“文档”入口,会使用 `NEXT_PUBLIC_DOC_URL` 配置的地址并在新标签打开文档站;需要验证登录和未登录状态下顶部入口都可见。 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 7906904..37cd2b0 100644 --- a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx +++ b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx @@ -39,6 +39,7 @@ import { CanvasToolbar } from "../components/canvas-toolbar"; import { AssetPickerModal, type AssetPickerTab, type InsertAssetPayload } from "../components/asset-picker-modal"; import { CanvasZoomControls } from "../components/canvas-zoom-controls"; import { useCanvasStore } from "../stores/use-canvas-store"; +import { buildCanvasResourceReferences, buildInputMentionReferences, buildNodeMentionReferences } from "../utils/canvas-resource-references"; import { CanvasNodeType, type CanvasAssistantImage, @@ -525,7 +526,8 @@ function InfiniteCanvasPage() { const padding = CONNECTION_NODE_HIT_PADDING / scale; const handleRadius = CONNECTION_HANDLE_HIT_RADIUS / scale; let isNearNode = false; - let best: { nodeId: string; priority: number } | null = null; + let bestNodeId: string | null = null; + let bestPriority = Number.POSITIVE_INFINITY; [...nodesRef.current] .filter((node) => !isHiddenBatchChild(node, nodesRef.current)) @@ -543,10 +545,13 @@ function InfiniteCanvasPage() { if (node.id === current.nodeId || !normalizeConnection(current.nodeId, node.id, nodesRef.current, current.handleType)) return; const priority = hitsInside ? 0 : hitsHandle ? 1 : 2; - if (!best || priority < best.priority) best = { nodeId: node.id, priority }; + if (priority < bestPriority) { + bestNodeId = node.id; + bestPriority = priority; + } }); - return { nodeId: best?.nodeId || null, isNearNode }; + return { nodeId: bestNodeId, isNearNode }; }, [screenToCanvas], ); @@ -617,6 +622,19 @@ function InfiniteCanvasPage() { }); return map; }, [connections, nodes]); + const resourceContextNodeId = dialogNodeId || activeNodeId; + const canvasResourceReferences = useMemo(() => buildCanvasResourceReferences(nodes, connections, resourceContextNodeId), [connections, nodes, resourceContextNodeId]); + const resourceReferenceByNodeId = useMemo(() => new Map(canvasResourceReferences.map((reference) => [reference.nodeId, reference])), [canvasResourceReferences]); + const mentionReferencesByNodeId = useMemo(() => { + const map = new Map>(); + nodes.forEach((node) => map.set(node.id, buildNodeMentionReferences(node, nodes, connections))); + return map; + }, [connections, nodes]); + const configMentionReferencesById = useMemo(() => { + const map = new Map>(); + configInputsById.forEach((inputs, nodeId) => map.set(nodeId, buildInputMentionReferences(inputs))); + return map; + }, [configInputsById]); const createNode = useCallback( (type: CanvasNodeType, position?: Position) => { @@ -2190,10 +2208,13 @@ function InfiniteCanvasPage() { batchRecovering={collapsingBatchIds.has(node.id)} batchMotion={batchMotionById.get(node.id)} showImageInfo={showImageInfo} + resourceLabel={resourceReferenceByNodeId.get(node.id)} + mentionReferences={mentionReferencesByNodeId.get(node.id) || []} renderPanel={(panelNode) => ( { 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 e31bf07..408996e 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 @@ -3,7 +3,7 @@ import type { CSSProperties } from "react"; import { useState } from "react"; import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Edit3, Eye, Image as ImageIcon, LoaderCircle, MessageSquare, Music2, Play, Video } from "lucide-react"; -import { App, Button, Empty, Input, Modal, Segmented } from "antd"; +import { App, Button, Empty, Modal, Segmented } from "antd"; import { ModelPicker } from "@/components/model-picker"; import { defaultConfig, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; @@ -14,21 +14,24 @@ import { seedanceReferenceLabel } from "@/lib/seedance-video"; import { useThemeStore } from "@/stores/use-theme-store"; import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover"; import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover"; +import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea"; import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover"; import type { NodeGenerationInput } from "./canvas-node-generation"; import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata } from "../types"; +import type { CanvasResourceReference } from "../utils/canvas-resource-references"; type CanvasConfigNodePanelProps = { node: CanvasNodeData; isRunning: boolean; inputSummary: { textCount: number; imageCount: number; videoCount: number; audioCount: number }; inputs: NodeGenerationInput[]; + mentionReferences?: CanvasResourceReference[]; onConfigChange: (nodeId: string, patch: Partial) => void; onTextInputChange: (nodeId: string, content: string) => void; onGenerate: (nodeId: string) => void; }; -export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, onConfigChange, onTextInputChange, onGenerate }: CanvasConfigNodePanelProps) { +export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, mentionReferences = [], onConfigChange, onTextInputChange, onGenerate }: CanvasConfigNodePanelProps) { const { message } = App.useApp(); const [previewOpen, setPreviewOpen] = useState(false); const [editingTextId, setEditingTextId] = useState(null); @@ -46,6 +49,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o const imageInputs = inputs.filter((input) => input.type === "image"); const videoInputs = inputs.filter((input) => input.type === "video"); const audioInputs = inputs.filter((input) => input.type === "audio"); + const editingMentionReferences = editingTextId ? mentionReferences.filter((reference) => reference.nodeId !== editingTextId) : mentionReferences; const hasAnyInput = Boolean(inputSummary.textCount || inputSummary.imageCount || inputSummary.videoCount || inputSummary.audioCount); const canGenerate = mode === "audio" ? inputSummary.textCount > 0 : hasAnyInput; @@ -125,12 +129,12 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o -
event.stopPropagation()}> +
- @@ -228,7 +232,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o 收起
- setEditingText(event.target.value)} /> +