feat(canvas): 智能引用 - 支持素材自动补全与全文引用

This commit is contained in:
kinoko
2026-09-09 14:26:04 +08:00
parent 83d9622fd0
commit 26a7bcf987
7 changed files with 402 additions and 16 deletions

View File

@@ -5,6 +5,12 @@ description: 当前项目已实现的核心工作台能力与使用入口
# 功能清单
## 画布智能引用
节点提示词面板提供“智能引用”入口,支持 AutoLink 开关和“一键引用全文”。输入已连接素材的名称、标签或素材栏序号后,按 Tab 或点击光标旁的候选项插入引用;全文引用会批量转换匹配到的指代,没有可转换内容时按钮禁用。
匹配仅覆盖当前已连接且激活的画布素材,跳过技能、素材库条目、重名歧义和已有引用。支持 `图1`、`图片1`、`image1`、`image 1` 及独立序号;普通数字文本如“约2秒”保持原样。AutoLink 默认在节点面板开启,开关为面板临时状态。
## 画布抓手与框选
画布底部主工具栏用一个胶囊开关切换「区域选择」和「抓手工具」。默认是区域选择,空白处拖动框选节点;切到抓手后拖动平移画布。按住空格或中键仍可临时平移,不改变当前工具。

View File

@@ -5,6 +5,13 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
# 待测试
## 画布智能引用
- [ ] 在简洁、专业模式及展开提示词面板中打开智能引用,检查明暗主题、弹层宽度和开关状态。
- [ ] 输入素材名称、`image1` 或独立序号,按 Tab 和点击候选项均能插入正确引用;关闭 AutoLink 后不再提示。
- [ ] 一键引用全文保留重复指代,跳过已有引用、重名和“约2秒”等普通文本;无匹配时按钮禁用。
- [ ] 长提示词滚动、移动光标、中文输入和切换节点后,引用插入位置正确;素材原有双击预览正常。
## v1.2.8.rc1 候选版本发布
- 发布版本与镜像标签分别为 `v1.2.8.rc1` 和 `1.2.8.rc1`;GitHub Release 应标记为预发布,不能被当作正式版本推荐,也不覆盖已发布的 `latest`。

View File

@@ -1,7 +1,7 @@
import { Button, Image as AntImage, InputNumber, Modal } from "antd";
import { Button, Image as AntImage, InputNumber, Modal, Popover } from "antd";
import { Tooltip } from "@/components/ui/base/tooltip";
import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { ArrowUp, Boxes, ChevronDown, FileText, ImageIcon, ImagePlus, LoaderCircle, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video, WandSparkles, X } from "lucide-react";
import { ArrowUp, AtSign, Boxes, ChevronDown, FileText, ImageIcon, ImagePlus, Link2, LoaderCircle, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video, WandSparkles, X } from "lucide-react";
import { ModelPicker } from "@/components/model-picker";
import { defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
@@ -23,7 +23,7 @@ import { CanvasPresetPicker, type CanvasPromptPreset } from "./canvas-preset-pic
import { CanvasPortraitTexturePopover } from "./canvas-portrait-texture-popover";
import { CanvasPromptOptimizerDrawer } from "./canvas-prompt-optimizer-drawer";
import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData, type CanvasNodeMetadata, type CanvasWorkspaceMode } from "@/types/canvas";
import { canvasResourceMentionToken, normalizeCanvasNodeMentionTokens, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { autoMentionCanvasResourceReferences, canvasResourceMentionToken, normalizeCanvasNodeMentionTokens, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { promptOptimizerPlugin, PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimizer";
import { createPluginHostContext } from "@/services/plugin-host";
import { usePluginStore } from "@/stores/use-plugin-store";
@@ -79,6 +79,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
const [manualExpandedPromptHeight, setManualExpandedPromptHeight] = useState<number | null>(null);
const [paramsExpanded, setParamsExpanded] = useState(false); // #98 决策2:B区参数区折叠状态(手风琴)
const [promptOptimizerOpen, setPromptOptimizerOpen] = useState(false);
const [autoLinkEnabled, setAutoLinkEnabled] = useState(true);
const resolvedMentionReferences = useResolvedCanvasResourceReferences(mentionReferences);
const normalizedSavedPrompt = useMemo(() => normalizeCanvasNodeMentionTokens(savedPrompt, mentionReferences), [mentionReferences, savedPrompt]);
const activeReferences = resolvedMentionReferences.filter((item) => item.active && item.kind !== "skill");
@@ -159,6 +160,8 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
const canExpandPrompt = mode === "image" || mode === "video";
const canOptimizePrompt = Boolean(promptOptimizerProvider) && canExpandPrompt;
const isPortraitTexture = mode === "image" && Boolean(node.metadata?.portraitTexture);
const autoMentionedPrompt = useMemo(() => autoMentionCanvasResourceReferences(prompt, resolvedMentionReferences), [prompt, resolvedMentionReferences]);
const canAutoMention = autoMentionedPrompt !== prompt;
useEffect(() => {
setPrompt(normalizedSavedPrompt);
@@ -339,6 +342,14 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
<span className="min-w-0 truncate px-2 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>
{activeReferenceCount ? `已连接 ${activeReferenceCount} 个素材` : "将使用默认模型与参数"}
</span>
<ReferenceToolsPopover
canAutoMention={canAutoMention}
autoLinkEnabled={autoLinkEnabled}
onAutoMention={() => updatePrompt(autoMentionedPrompt)}
onAutoLinkEnabledChange={setAutoLinkEnabled}
accent={monochromeAccent}
compact
/>
{renderSubmitButton(expanded)}
</div>
) : (
@@ -360,6 +371,14 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
/>
</div>
<div className="ml-auto flex min-w-0 shrink-0 items-center gap-1">
<ReferenceToolsPopover
canAutoMention={canAutoMention}
autoLinkEnabled={autoLinkEnabled}
onAutoMention={() => updatePrompt(autoMentionedPrompt)}
onAutoLinkEnabledChange={setAutoLinkEnabled}
accent={monochromeAccent}
compact={!expanded}
/>
{mode === "text" ? (
<Tooltip title={`文本生成份数(默认 1,可在生成配置中调整)`}>
<InputNumber
@@ -411,6 +430,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
references={resolvedMentionReferences}
includeAssetLibrary
onChange={updatePrompt}
autoLinkEnabled={autoLinkEnabled}
onContentSizeChange={expanded ? setExpandedPromptContentHeight : setPromptContentHeight}
containerClassName="min-h-0 flex-1"
className={expanded
@@ -515,6 +535,59 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
);
}
function ReferenceToolsPopover({ canAutoMention, autoLinkEnabled, onAutoMention, onAutoLinkEnabledChange, accent, compact }: { canAutoMention: boolean; autoLinkEnabled: boolean; onAutoMention: () => void; onAutoLinkEnabledChange: (enabled: boolean) => void; accent: string; compact: boolean }) {
return (
<Popover
trigger="click"
placement="topRight"
rootClassName="canvas-reference-tools-popover"
arrow={false}
align={{ offset: [0, -8] }}
styles={{ root: { width: "min(280px, calc(100vw - 24px))" }, container: { width: "100%" }, content: { width: "100%", padding: 12 } }}
content={
<div className="space-y-3">
<div>
<div className="text-sm font-medium">智能引用</div>
<div className="mt-1 text-xs text-black/50 dark:text-white/50">输入素材序号或名称后按 Tab,可快速引用</div>
</div>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm"><Link2 className="size-3.5" />AutoLink</div>
<button
type="button"
role="switch"
aria-checked={autoLinkEnabled}
aria-label={autoLinkEnabled ? "关闭 AutoLink" : "开启 AutoLink"}
className={`relative h-5 w-9 rounded-full transition-colors ${autoLinkEnabled ? "" : "bg-black/20 dark:bg-white/20"}`}
style={autoLinkEnabled ? { background: accent } : undefined}
onClick={() => onAutoLinkEnabledChange(!autoLinkEnabled)}
>
<span className={`absolute left-0.5 top-0.5 size-4 rounded-full bg-white shadow-sm transition-transform ${autoLinkEnabled ? "translate-x-4" : "translate-x-0"}`} />
</button>
</div>
<button
type="button"
className="flex w-full items-center justify-center gap-1.5 rounded-md bg-[var(--canvas-composer-control-surface)] px-2 py-1.5 text-sm transition-colors hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-45"
disabled={!canAutoMention}
onClick={onAutoMention}
>
<AtSign className="size-3.5" />一键引用全文
</button>
</div>
}
>
<button
type="button"
className={`canvas-node-composer-settings-trigger canvas-node-composer-reference-tools-trigger inline-flex shrink-0 items-center gap-1 ${compact ? "is-compact" : ""}`}
aria-label="打开智能引用"
title="智能引用"
>
<SlidersHorizontal className="size-3" />
{!compact ? <span>引用</span> : null}
</button>
</Popover>
);
}
function GenerationModeIcon({ mode }: { mode: CanvasNodeGenerationMode }) {
if (mode === "image") return <ImagePlus className="size-3" />;
if (mode === "video") return <Video className="size-3" />;
@@ -543,10 +616,11 @@ function ConnectedReferenceShelf({ references, theme, onInsert, onRemove }: { re
<>
<div className="canvas-node-composer-references" role="group" aria-label="已连接素材">
<div className="canvas-node-composer-references-track thin-scrollbar">
{activeReferences.map((reference) => {
{activeReferences.map((reference, index) => {
const canPreview = Boolean(reference.previewUrl) && (reference.kind === "image" || reference.kind === "character" || reference.kind === "video");
return (
<span key={reference.id} className="canvas-node-reference-chip">
<span className="canvas-node-reference-order" aria-hidden>{index + 1}</span>
<button
type="button"
className="canvas-node-reference-preview"

View File

@@ -6,7 +6,7 @@ import { ArrowLeft, ChevronRight, FileText, Folder, Image as ImageIcon, Music2,
import { canvasThemes } from "@/lib/canvas-theme";
import { ASSET_CATEGORY_LABELS } from "@/lib/asset-category";
import { useThemeStore } from "@/stores/use-theme-store";
import { buildAssetMentionReferences, canvasResourceMentionToken, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { buildAssetMentionReferences, canvasResourceMentionToken, findCanvasResourceAutoLinkMatch, type CanvasResourceAutoLinkMatch, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { useAssetStore, type AssetCategory } from "@/stores/use-asset-store";
import { CanvasNodeType } from "@/types/canvas";
import { useResolvedCanvasResourceReferences } from "./use-resolved-canvas-resource-references";
@@ -46,10 +46,11 @@ type Props = Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "onChange" | "val
includeAssetLibrary?: boolean;
activeDropReferenceId?: string | null;
onReferenceFilesDrop?: (reference: CanvasResourceReference, files: File[]) => void;
autoLinkEnabled?: boolean;
};
export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Props>(function CanvasResourceMentionTextarea(
{ value, references, onChange, onSubmit, onKeyDown, className, containerClassName, style, highlightLabels = true, mentionMenuWidth = 320, sendOnEnter = true, onContentSizeChange, includeAssetLibrary = false, activeDropReferenceId, onReferenceFilesDrop, ...props },
{ value, references, onChange, onSubmit, onKeyDown, className, containerClassName, style, highlightLabels = true, mentionMenuWidth = 320, sendOnEnter = true, onContentSizeChange, includeAssetLibrary = false, activeDropReferenceId, onReferenceFilesDrop, autoLinkEnabled = false, ...props },
forwardedRef,
) {
const rawTheme = useThemeStore((state) => state.theme);
@@ -60,9 +61,13 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
const editorRef = useRef<HTMLDivElement | null>(null);
const composingRef = useRef(false);
const pendingSelectionRef = useRef<number | null>(null);
const pendingScrollTopRef = useRef<number | null>(null);
const lastRenderedValueRef = useRef("");
const [mention, setMention] = useState<MentionState | null>(null);
const [activeIndex, setActiveIndex] = useState(-1);
const [autoLinkCursor, setAutoLinkCursor] = useState<number | null>(null);
const autoLinkSuggestionRef = useRef<HTMLButtonElement | null>(null);
const [autoLinkPosition, setAutoLinkPosition] = useState<{ left: number; top: number } | null>(null);
const [nativeDropReferenceId, setNativeDropReferenceId] = useState<string | null>(null);
const [previewReference, setPreviewReference] = useState<CanvasResourceReference | null>(null);
const canvasReferences = useResolvedCanvasResourceReferences(references);
@@ -81,6 +86,7 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
return [...activeCanvasReferences, ...assetReferences.filter((item) => value.includes(canvasResourceMentionToken(item)))];
}, [activeCanvasReferences, assetReferences, highlightLabels, value]);
const useRichEditor = Boolean(activeReferences.length);
const autoLinkMatch = useMemo<CanvasResourceAutoLinkMatch | null>(() => autoLinkEnabled && autoLinkCursor !== null ? findCanvasResourceAutoLinkMatch(value, autoLinkCursor, activeCanvasReferences) : null, [activeCanvasReferences, autoLinkCursor, autoLinkEnabled, value]);
const reportContentSize = useCallback((element: HTMLElement | null) => {
if (!element || !onContentSizeChange) return;
const previous = { height: element.style.height, minHeight: element.style.minHeight, maxHeight: element.style.maxHeight, overflow: element.style.overflow };
@@ -96,6 +102,10 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
onContentSizeChange(height);
}, [onContentSizeChange]);
useLayoutEffect(() => {
if (!useRichEditor) pendingScrollTopRef.current = null;
}, [useRichEditor, value]);
useLayoutEffect(() => {
if (!useRichEditor) return;
const editor = editorRef.current;
@@ -108,13 +118,44 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
return;
}
const selection = pendingSelectionRef.current ?? (isFocused ? getEditableSelection(editor)?.start ?? null : null);
const scrollTop = pendingScrollTopRef.current;
renderEditableContent(editor, value, activeReferences);
lastRenderedValueRef.current = value;
if (isFocused && selection !== null) setEditableSelection(editor, selection);
pendingSelectionRef.current = null;
reportContentSize(editor);
if (scrollTop !== null) editor.scrollTop = scrollTop;
pendingScrollTopRef.current = null;
}, [activeReferences, reportContentSize, useRichEditor, value]);
useLayoutEffect(() => {
const anchor = useRichEditor ? editorRef.current : textareaRef.current;
const suggestion = autoLinkSuggestionRef.current;
if (!autoLinkMatch || !anchor || !suggestion) {
setAutoLinkPosition(null);
return;
}
const updatePosition = () => {
const caret = mentionCaretRect(anchor, autoLinkMatch.end);
const bounds = suggestion.getBoundingClientRect();
setAutoLinkPosition({
left: clamp(caret.right + 8, 12, window.innerWidth - bounds.width - 12),
top: clamp(caret.bottom + 4, 12, window.innerHeight - bounds.height - 12),
});
};
updatePosition();
const observer = new ResizeObserver(updatePosition);
observer.observe(anchor);
observer.observe(suggestion);
window.addEventListener("resize", updatePosition);
window.addEventListener("scroll", updatePosition, true);
return () => {
observer.disconnect();
window.removeEventListener("resize", updatePosition);
window.removeEventListener("scroll", updatePosition, true);
};
}, [autoLinkMatch, useRichEditor, value]);
useLayoutEffect(() => {
const editor = editorRef.current;
if (!editor) return;
@@ -142,20 +183,27 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
const focusEditor = (selectionStart?: number) => {
requestAnimationFrame(() => {
if (useRichEditor) {
const editor = editorRef.current;
if (!editor) return;
editor.focus();
const editor = editorRef.current;
if (editor) {
const scrollTop = editor.scrollTop;
editor.focus({ preventScroll: true });
if (typeof selectionStart === "number") setEditableSelection(editor, selectionStart);
editor.scrollTop = scrollTop;
return;
}
textareaRef.current?.focus();
if (typeof selectionStart === "number") textareaRef.current?.setSelectionRange(selectionStart, selectionStart);
const textarea = textareaRef.current;
if (!textarea) return;
const scrollTop = textarea.scrollTop;
textarea.focus({ preventScroll: true });
if (typeof selectionStart === "number") textarea.setSelectionRange(selectionStart, selectionStart);
textarea.scrollTop = scrollTop;
});
};
const updateValue = (next: string, selectionStart?: number) => {
if (typeof selectionStart === "number") pendingSelectionRef.current = selectionStart;
const editor = editorRef.current ?? textareaRef.current;
pendingScrollTopRef.current = editor?.scrollTop ?? null;
onChange(next);
if (typeof selectionStart === "number") focusEditor(selectionStart);
};
@@ -166,6 +214,7 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
};
const syncMention = (nextValue: string, cursor: number) => {
setAutoLinkCursor(cursor);
const prefix = nextValue.slice(0, cursor);
const match = /@([^\s@,.;:!?,。;:!?、)\]}】)]*)$/.exec(prefix);
if (!match || !availableReferences.length) {
@@ -190,13 +239,42 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
const replaceEditableSelection = (insertText: string) => {
const currentValue = editorRef.current ? serializeEditableValue(editorRef.current) : value;
const selection = getEditableSelection(editorRef.current) || { start: currentValue.length, end: currentValue.length };
const textarea = textareaRef.current;
const selection = getEditableSelection(editorRef.current)
|| (textarea ? { start: textarea.selectionStart, end: textarea.selectionEnd } : null)
|| { start: currentValue.length, end: currentValue.length };
const next = `${currentValue.slice(0, selection.start)}${insertText}${currentValue.slice(selection.end)}`;
const cursor = selection.start + insertText.length;
updateValue(next, cursor);
syncMention(next, cursor);
};
const insertAutoLink = (match: CanvasResourceAutoLinkMatch) => {
const currentValue = editorRef.current ? serializeEditableValue(editorRef.current) : value;
const insertText = `${canvasResourceMentionToken(match.reference)} `;
const next = `${currentValue.slice(0, match.start)}${insertText}${currentValue.slice(match.end)}`;
updateValue(next, match.start + insertText.length);
setAutoLinkCursor(match.start + insertText.length);
};
const autoLinkSuggestion = autoLinkMatch ? createPortal(
<button
ref={autoLinkSuggestionRef}
type="button"
data-canvas-no-zoom
className="fixed z-[var(--z-tooltip)] inline-flex max-w-[min(360px,calc(100vw-24px))] items-center gap-1.5 rounded-md border border-current/15 px-2 py-1 text-[var(--fs-micro)] shadow-sm"
style={{ ...autoLinkPosition, visibility: autoLinkPosition ? "visible" : "hidden", background: theme.node.panel, color: theme.node.text }}
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => { event.preventDefault(); event.stopPropagation(); }}
onClick={(event) => { event.stopPropagation(); insertAutoLink(autoLinkMatch); }}
aria-label={`引用${autoLinkMatch.reference.label}`}
>
<span className="truncate">引用「{autoLinkMatch.query}」→ @{autoLinkMatch.reference.label}</span>
<kbd className="shrink-0 rounded border border-current/20 px-1 font-mono text-[var(--fs-micro)]">Tab</kbd>
</button>,
document.body,
) : null;
const syncEditableValue = () => {
if (composingRef.current) return;
const editor = editorRef.current;
@@ -213,8 +291,12 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
const syncEditableMentionFromSelection = () => {
const editor = editorRef.current;
if (!editor) return;
const cursor = getEditableSelection(editor)?.start;
if (typeof cursor === "number") syncMention(serializeEditableValue(editor), cursor);
const selection = getEditableSelection(editor);
if (!selection || selection.start !== selection.end) {
setAutoLinkCursor(null);
return;
}
syncMention(serializeEditableValue(editor), selection.start);
};
const referenceForDropTarget = (target: EventTarget | null) => {
@@ -311,6 +393,11 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
}}
onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter" && (event.nativeEvent.isComposing || composingRef.current)) return;
if (autoLinkMatch && event.key === "Tab" && !event.shiftKey && !event.nativeEvent.isComposing && !composingRef.current) {
event.preventDefault();
insertAutoLink(autoLinkMatch);
return;
}
if (mention && candidates.length) {
if (event.key === "ArrowDown") {
event.preventDefault();
@@ -371,6 +458,7 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
onScroll={(event) => props.onScroll?.(event as unknown as React.UIEvent<HTMLTextAreaElement>)}
onFocus={(event) => props.onFocus?.(event as unknown as React.FocusEvent<HTMLTextAreaElement>)}
onBlur={(event) => {
setAutoLinkCursor(null);
if (event.relatedTarget instanceof Element && event.relatedTarget.closest("[data-canvas-resource-mention-menu]")) return;
window.setTimeout(() => {
if (document.activeElement?.closest("[data-canvas-resource-mention-menu]")) return;
@@ -380,6 +468,7 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
}}
>
</div>
{autoLinkSuggestion}
{menu}
{previewReference ? <InlineReferencePreview reference={previewReference} onClose={() => setPreviewReference(null)} /> : null}
</div>
@@ -410,10 +499,21 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
}}
onCompositionEnd={(event) => {
composingRef.current = false;
syncMention(event.currentTarget.value, event.currentTarget.selectionStart);
props.onCompositionEnd?.(event);
}}
onSelect={(event) => {
const textarea = event.currentTarget;
setAutoLinkCursor(textarea.selectionStart === textarea.selectionEnd ? textarea.selectionStart : null);
props.onSelect?.(event);
}}
onKeyDown={(event) => {
if (event.key === "Enter" && (event.nativeEvent.isComposing || composingRef.current)) return;
if (autoLinkMatch && event.key === "Tab" && !event.shiftKey && !event.nativeEvent.isComposing && !composingRef.current) {
event.preventDefault();
insertAutoLink(autoLinkMatch);
return;
}
if (mention && candidates.length) {
if (event.key === "ArrowDown") {
event.preventDefault();
@@ -456,6 +556,7 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
props.onWheel?.(event);
}}
onBlur={(event) => {
setAutoLinkCursor(null);
if (event.relatedTarget instanceof Element && event.relatedTarget.closest("[data-canvas-resource-mention-menu]")) return;
window.setTimeout(() => {
if (document.activeElement?.closest("[data-canvas-resource-mention-menu]")) return;
@@ -464,6 +565,7 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
props.onBlur?.(event);
}}
/>
{autoLinkSuggestion}
{menu}
</div>
);

View File

@@ -51,6 +51,105 @@ export function normalizeCanvasNodeMentionTokens(prompt: string, references: Can
}, prompt);
}
/** 将提示词内已连接素材的名称替换为对应引用;同名的每处指代都会保留为独立引用。 */
export function autoMentionCanvasResourceReferences(prompt: string, references: CanvasResourceReference[]) {
const referenceByName = new Map<string, CanvasResourceReference | null>();
references
.filter((reference) => reference.active && !reference.assetId && reference.kind !== "skill")
.forEach((reference, index) => {
canvasResourceMentionNames(reference, index, true).forEach((name) => {
if (!referenceByName.has(name)) {
referenceByName.set(name, reference);
return;
}
if (referenceByName.get(name)?.id !== reference.id) referenceByName.set(name, null);
});
});
const names = [...referenceByName]
.filter(([, reference]) => Boolean(reference))
.map(([name]) => name)
.sort((left, right) => right.length - left.length);
if (!names.length) return prompt;
const matcher = new RegExp(`(^|[^A-Za-z0-9_@])(${names.map(escapeRegExp).join("|")})(?![A-Za-z0-9_])`, "gu");
return prompt
.split(/(@\[[^\]]+\]|@[^\s@,.;:!?,。;:!?、)\]}】)]+)/gu)
.map((part) => {
if (part.startsWith("@")) return part;
return part.replace(matcher, (match, prefix: string, name: string, offset: number, source: string) => {
const nextCharacter = source[offset + match.length];
if (/^\d+$/u.test(name) && ((prefix && !/[\s,.!?;:,。!?;:、)\]}】)]/u.test(prefix)) || (nextCharacter && !/[\s,.!?;:,。!?;:、)\]}】)]/u.test(nextCharacter)))) return match;
const separator = needsAutoMentionSeparator(nextCharacter) ? " " : "";
return `${prefix}${canvasResourceMentionToken(referenceByName.get(name)!)}${separator}`;
});
})
.join("");
}
export type CanvasResourceAutoLinkMatch = {
reference: CanvasResourceReference;
start: number;
end: number;
query: string;
};
export function findCanvasResourceAutoLinkMatch(prompt: string, cursor: number, references: CanvasResourceReference[]): CanvasResourceAutoLinkMatch | null {
if (cursor <= 0 || cursor > prompt.length) return null;
const prefix = prompt.slice(0, cursor);
if (/@(?:\[[^\]]*|[^\s@,.;:!?,。;:!?、)\]}】)]*)$/u.test(prefix)) return null;
const aliases = new Map<string, CanvasResourceReference | null>();
references
.filter((reference) => reference.active && !reference.assetId && reference.kind !== "skill")
.forEach((reference, index) => {
canvasResourceMentionNames(reference, index, true).forEach((name) => {
if (!aliases.has(name)) aliases.set(name, reference);
else if (aliases.get(name)?.id !== reference.id) aliases.set(name, null);
});
});
const candidates = [...aliases]
.filter(([, reference]) => Boolean(reference))
.map(([name, reference]) => ({ name, reference: reference!, isBareOrder: /^\d+$/u.test(name) }))
.sort((left, right) => right.name.length - left.name.length);
for (const candidate of candidates) {
if (!prefix.endsWith(candidate.name)) continue;
const start = cursor - candidate.name.length;
const previous = prompt[start - 1];
const next = prompt[cursor];
if (previous === "@") continue;
if (next && /[A-Za-z0-9_]/u.test(next)) continue;
if (candidate.isBareOrder && previous && !/[\s,.!?;:,。!?;:、)\]}】)]/u.test(previous)) continue;
if (candidate.isBareOrder && next && !/[\s,.!?;:,。!?;:、)\]}】)]/u.test(next)) continue;
if (/^[A-Za-z0-9_]+$/u.test(candidate.name) && previous && /[A-Za-z0-9_]/u.test(previous)) continue;
return { reference: candidate.reference, start, end: cursor, query: candidate.name };
}
return null;
}
function canvasResourceMentionNames(reference: CanvasResourceReference, index: number, includeBareNumber: boolean) {
const names = new Set([reference.label.trim(), reference.title.trim()].filter(Boolean));
const order = index + 1;
names.add(`图片${order}`);
names.add(`图${order}`);
names.add(`image${order}`);
names.add(`image ${order}`);
const imageLabelMatch = /^图片(\d+)$/u.exec(reference.label.trim());
if (imageLabelMatch) {
names.add(`图${imageLabelMatch[1]}`);
names.add(`image${imageLabelMatch[1]}`);
names.add(`image ${imageLabelMatch[1]}`);
}
if (includeBareNumber) names.add(String(order));
return names;
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function needsAutoMentionSeparator(nextCharacter: string | undefined) {
return Boolean(nextCharacter && !/[\s,.!?;:,。!?;:、)\]}】)]/u.test(nextCharacter));
}
const CANVAS_RESOURCE_MENTION_BOUNDARY = /(?:$|\s|[,.!?;:,。!?;:、)\]}】)])/;
function canvasResourceReferenceMentionTokens(reference: CanvasResourceReference) {

View File

@@ -9370,6 +9370,24 @@
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--foreground) 10%, transparent);
}
.canvas-node-reference-order {
position: absolute;
z-index: 1;
top: 2px;
left: 2px;
display: grid;
width: 16px;
height: 16px;
place-items: center;
border-radius: var(--r-full);
background: var(--canvas-composer-surface);
color: var(--foreground);
font-size: var(--fs-micro);
font-weight: 600;
line-height: 1;
pointer-events: none;
}
.canvas-node-reference-preview {
position: relative;
width: var(--canvas-reference-chip-size, 44px);
@@ -9480,6 +9498,34 @@
font-weight: 400 !important;
}
.canvas-node-composer-reference-tools-trigger {
width: auto !important;
min-width: 28px !important;
justify-content: center !important;
}
.canvas-node-composer-reference-tools-trigger.is-compact {
width: 28px !important;
padding-inline: 0 !important;
}
.canvas-reference-tools-popover {
width: 280px !important;
max-width: calc(100vw - 24px) !important;
}
.canvas-reference-tools-popover .ant-popover-content,
.canvas-reference-tools-popover .ant-popover-inner,
.canvas-reference-tools-popover .ant-popover-inner-content {
box-sizing: border-box;
width: 100% !important;
max-width: calc(100vw - 24px);
}
.canvas-reference-tools-popover .ant-popover-inner-content {
padding: 0 !important;
}
.canvas-node-composer-submit {
display: inline-flex !important;
min-width: var(--space-8) !important;

View File

@@ -1,10 +1,62 @@
import { describe, expect, test } from "bun:test";
import { applyCanvasConnectionPromptSync, buildAssetMentionReferences, buildCanvasNodeMentionReferenceMap, buildNodeMentionReferences, buildOrderedCanvasResourceReferences, canvasResourceMentionToken, collectUpstreamVideoNodes, imageGenerationReferenceConnections } from "../src/lib/canvas/canvas-resource-references";
import { autoMentionCanvasResourceReferences, findCanvasResourceAutoLinkMatch, type CanvasResourceReference, applyCanvasConnectionPromptSync, buildAssetMentionReferences, buildCanvasNodeMentionReferenceMap, buildNodeMentionReferences, buildOrderedCanvasResourceReferences, canvasResourceMentionToken, collectUpstreamVideoNodes, imageGenerationReferenceConnections } from "../src/lib/canvas/canvas-resource-references";
import { canvasNodeToAsset } from "../src/lib/canvas/canvas-node-asset";
import { buildNodeGenerationInputs } from "../src/components/canvas/canvas-node-generation";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../src/types/canvas";
const smartReferences: CanvasResourceReference[] = [
{ id: "image-6", nodeId: "image-6", kind: "image", label: "图片6", title: "宫门全景", active: true },
{ id: "character", nodeId: "character", kind: "character", label: "角色1", title: "唐妙菱", active: true },
];
describe("canvas smart references", () => {
test("converts repeated names and aliases without rewriting existing mentions", () => {
expect(autoMentionCanvasResourceReferences("图片6在前景,图6退后;宫门全景可见。唐妙菱看向图片60。", smartReferences))
.toBe("@图片6 在前景,@图片6 退后;@图片6 可见。@角色1 看向图片60。");
const existing = "已有 @图片6 和 @[node:image-6]、@[asset:宫门全景]。";
expect(autoMentionCanvasResourceReferences(existing, smartReferences)).toBe(existing);
expect(autoMentionCanvasResourceReferences("请使用 2。不要改约2秒。", smartReferences)).toBe("请使用 @角色1。不要改约2秒。");
});
test("returns exact replacement offsets for names and shelf orders", () => {
for (const alias of ["唐妙菱", "角色1", "2", "image2", "image 2"]) {
const prompt = `请使用 ${alias},然后继续`;
expect(findCanvasResourceAutoLinkMatch(prompt, 4 + alias.length, smartReferences))
.toEqual({ reference: smartReferences[1], start: 4, end: 4 + alias.length, query: alias });
}
});
test("skips inactive, library and skill references and ambiguous names", () => {
const excluded: CanvasResourceReference[] = [
{ ...smartReferences[0], active: false },
{ ...smartReferences[1], assetId: "library" },
{ ...smartReferences[1], kind: "skill" },
];
expect(autoMentionCanvasResourceReferences("宫门全景 唐妙菱 1", excluded)).toBe("宫门全景 唐妙菱 1");
expect(findCanvasResourceAutoLinkMatch("唐妙菱", 3, excluded)).toBeNull();
const ambiguous = [smartReferences[0], { ...smartReferences[1], title: "宫门全景" }];
expect(autoMentionCanvasResourceReferences("宫门全景", ambiguous)).toBe("宫门全景");
expect(findCanvasResourceAutoLinkMatch("宫门全景", 4, ambiguous)).toBeNull();
});
test("does not complete partial identifiers, existing tokens or ordinary numbers", () => {
for (const [prompt, cursor] of [
["@图片6", 4], ["@[node:宫门全景", 10], ["@前缀宫门全景", 7],
["myimage2", 8], ["图片60", 3], ["2秒", 1], ["约2", 2], ["20", 1],
["", 0], ["2", 5],
] as const) {
expect(findCanvasResourceAutoLinkMatch(prompt, cursor, smartReferences)).toBeNull();
}
});
test("escapes regular expression characters and preserves explicit mention tokens", () => {
const reference = { ...smartReferences[0], title: "场景(夜)+", mentionToken: "@[node:image-6]" };
expect(autoMentionCanvasResourceReferences("场景(夜)+出现", [reference])).toBe("@[node:image-6] 出现");
expect(findCanvasResourceAutoLinkMatch(reference.title, reference.title.length, [reference])?.reference).toBe(reference);
});
});
function videoNode(id: string): CanvasNodeData {
return {
id,