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

@@ -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) {