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

This commit is contained in:
ddCat
2026-09-09 22:48:17 +08:00
7 changed files with 404 additions and 18 deletions

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) => {
@@ -310,7 +392,12 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
props.onDrop?.(event as unknown as React.DragEvent<HTMLTextAreaElement>);
}}
onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter" && (event.nativeEvent.isComposing || composingRef.current || event.keyCode === 229)) return;
if (event.key === "Enter" && (event.nativeEvent.isComposing || composingRef.current || event.keyCode === 229)) 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 || event.keyCode === 229)) return;
if (event.key === "Enter" && (event.nativeEvent.isComposing || composingRef.current || event.keyCode === 229)) 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>
);