feat(canvas): 智能引用 - 支持素材自动补全与全文引用
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user