feat: Enhance Kol Generation Service with web search and knowledge group support

- Added `EnableWebSearch` and `KnowledgeGroupIDs` fields to `KolGenerationSubmitRequest`.
- Updated `Submit` method in `KolGenerationService` to handle new request fields.
- Integrated web search and knowledge group validation based on card configuration.
- Introduced caching mechanisms in `KolPackageService`, `KolPromptService`, and `KolSubscriptionAdminService` to improve performance.
- Implemented knowledge context resolution in `KolGenerationWorker` to enrich prompts with relevant knowledge snippets.
- Added utility functions for handling card configuration in both backend and frontend.
- Created tests for knowledge extraction and rendering to ensure accuracy and reliability.
This commit is contained in:
2026-04-18 13:47:32 +08:00
parent 3ef0807456
commit b2605abd6a
27 changed files with 2051 additions and 171 deletions
@@ -8,7 +8,6 @@ import { Modal } from "ant-design-vue";
import {
buildKolPlaceholderToken,
createKolVariableDefinition,
findKolPlaceholderKeyAtPosition,
} from "@/lib/kol-placeholders";
const props = defineProps<{
@@ -29,6 +28,16 @@ const { t } = useI18n();
const aiDescription = ref("");
const textareaRef = ref<HTMLTextAreaElement | null>(null);
const highlightRef = ref<HTMLDivElement | null>(null);
let syncingScroll = false;
type PointerSelectionState = {
key: string | null;
pointerId: number;
startX: number;
startY: number;
};
let pointerSelectionState: PointerSelectionState | null = null;
const editorPlaceholder = "在这里输入提示词内容,可以使用 {{变量名}} 引用左侧定义的变量";
const highlightTokenRE = /\{\{\s*([^{}]+?)\s*\}\}/g;
@@ -45,9 +54,20 @@ const highlightedContent = computed(() => {
for (const match of text.matchAll(highlightTokenRE)) {
const matchIndex = match.index ?? 0;
const token = match[0] ?? "";
const key = match[1] ?? "";
const keyOffset = token.indexOf(key);
const prefix = keyOffset >= 0 ? token.slice(0, keyOffset) : token;
const suffix = keyOffset >= 0 ? token.slice(keyOffset + key.length) : "";
const keyStart = matchIndex + Math.max(keyOffset, 0);
html += escapeHtml(text.slice(lastIndex, matchIndex));
html += `<span class="token-highlight">${escapeHtml(token)}</span>`;
html += [
`<span class="token-highlight">`,
escapeHtml(prefix),
`<span class="token-highlight__content" data-kol-key="${escapeAttribute(key)}" data-kol-start="${keyStart}">${escapeHtml(key)}</span>`,
escapeHtml(suffix),
`</span>`,
].join("");
lastIndex = matchIndex + token.length;
}
@@ -107,7 +127,7 @@ function insertToken(token: string) {
setTimeout(() => {
el.focus();
el.setSelectionRange(start + token.length, start + token.length);
syncScroll();
syncScrollFromTextarea();
}, 0);
}
@@ -118,34 +138,280 @@ function handleAiGenerate() {
function handleContentInput(event: Event) {
emit("update:content", (event.target as HTMLTextAreaElement).value);
syncScroll();
syncScrollFromTextarea();
}
function handleCaretInteraction() {
if (!textareaRef.value) {
function handleHighlightPointerDown(event: PointerEvent) {
pointerSelectionState = {
key: getPlaceholderKeyFromTarget(event.target),
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
};
}
function handleHighlightPointerUp(event: PointerEvent) {
const textarea = textareaRef.value;
const highlight = highlightRef.value;
const pointerState = pointerSelectionState;
pointerSelectionState = null;
if (!textarea || !highlight || !pointerState || pointerState.pointerId !== event.pointerId) {
return;
}
requestAnimationFrame(() => {
const textarea = textareaRef.value;
if (!textarea) {
return;
if (hasOverlaySelection(highlight)) {
return;
}
const movedDistance =
Math.abs(event.clientX - pointerState.startX) + Math.abs(event.clientY - pointerState.startY);
if (movedDistance > 6) {
return;
}
event.preventDefault();
const cursorPos = getCaretIndexFromPoint(highlight, event);
const scrollTop = textarea.scrollTop;
const scrollLeft = textarea.scrollLeft;
textarea.setSelectionRange(cursorPos, cursorPos);
focusTextareaWithoutScroll(textarea);
restoreTextareaScroll(textarea, scrollTop, scrollLeft);
if (pointerState.key) {
emit("focus-variable", pointerState.key);
}
}
function handleHighlightPointerCancel() {
pointerSelectionState = null;
}
function getPlaceholderKeyFromTarget(target: EventTarget | null): string | null {
const targetElement =
target instanceof HTMLElement
? target
: target instanceof Text
? target.parentElement
: null;
return targetElement?.closest<HTMLElement>("[data-kol-key]")?.dataset.kolKey?.trim() || null;
}
function hasOverlaySelection(container: HTMLElement): boolean {
const selection = window.getSelection();
if (!selection || selection.isCollapsed) {
return false;
}
const anchorNode = selection.anchorNode;
const focusNode = selection.focusNode;
return !!anchorNode && !!focusNode && container.contains(anchorNode) && container.contains(focusNode);
}
function getCaretIndexFromPoint(container: HTMLElement, event: PointerEvent): number {
const browserIndex = getBrowserCaretIndex(container, event);
if (browserIndex !== null) {
return clampOffset(browserIndex, props.content.length);
}
const approximateIndex = getApproximateCaretIndex(container, event.clientX, event.clientY);
return clampOffset(approximateIndex, props.content.length);
}
function getBrowserCaretIndex(
container: HTMLElement,
event: PointerEvent,
): number | null {
if ("caretPositionFromPoint" in document) {
const position = document.caretPositionFromPoint(event.clientX, event.clientY);
const index = resolveCaretIndex(container, position?.offsetNode ?? null, position?.offset);
if (index !== null) {
return index;
}
}
if ("caretRangeFromPoint" in document) {
const range = document.caretRangeFromPoint(event.clientX, event.clientY);
const index = resolveCaretIndex(container, range?.startContainer ?? null, range?.startOffset);
if (index !== null) {
return index;
}
}
return null;
}
function resolveCaretIndex(
container: HTMLElement,
node: Node | null,
offset?: number,
): number | null {
if (!node || (node !== container && !container.contains(node))) {
return null;
}
const range = document.createRange();
range.setStart(container, 0);
range.setEnd(node, offset ?? 0);
return range.toString().length;
}
function getApproximateCaretIndex(
container: HTMLElement,
clientX: number,
clientY: number,
): number {
const textNodes = getTextNodes(container);
if (textNodes.length === 0) {
return 0;
}
const range = document.createRange();
let indexOffset = 0;
let bestIndex = 0;
let bestDistance = Number.POSITIVE_INFINITY;
let sawLineMatch = false;
for (const textNode of textNodes) {
const textLength = textNode.textContent?.length ?? 0;
for (let index = 0; index < textLength; index += 1) {
range.setStart(textNode, index);
range.setEnd(textNode, index + 1);
const rect = range.getBoundingClientRect();
if (!rect.width && !rect.height) {
continue;
}
const isSameLine = clientY >= rect.top && clientY <= rect.bottom;
const midpoint = rect.left + rect.width / 2;
if (isSameLine) {
sawLineMatch = true;
if (clientX <= midpoint) {
return indexOffset + index;
}
bestIndex = indexOffset + index + 1;
continue;
}
const deltaY =
clientY < rect.top
? rect.top - clientY
: clientY > rect.bottom
? clientY - rect.bottom
: 0;
const deltaX =
clientX < rect.left
? rect.left - clientX
: clientX > rect.right
? clientX - rect.right
: 0;
const distance = deltaY * deltaY + deltaX * deltaX;
if (distance < bestDistance) {
bestDistance = distance;
bestIndex = clientX <= midpoint ? indexOffset + index : indexOffset + index + 1;
}
}
const key = findKolPlaceholderKeyAtPosition(props.content, textarea.selectionStart ?? -1);
if (key) {
emit("focus-variable", key);
indexOffset += textLength;
}
if (sawLineMatch) {
return bestIndex;
}
return bestIndex;
}
function getTextNodes(container: HTMLElement): Text[] {
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
if (!(node instanceof Text)) {
return NodeFilter.FILTER_REJECT;
}
return node.textContent?.length ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
},
});
const nodes: Text[] = [];
while (walker.nextNode()) {
const currentNode = walker.currentNode;
if (currentNode instanceof Text) {
nodes.push(currentNode);
}
}
return nodes;
}
function clampOffset(offset: number, textLength: number): number {
if (!Number.isFinite(offset)) {
return 0;
}
return Math.min(Math.max(offset, 0), textLength);
}
function focusTextareaWithoutScroll(textarea: HTMLTextAreaElement) {
try {
textarea.focus({ preventScroll: true });
} catch {
textarea.focus();
}
}
function restoreTextareaScroll(
textarea: HTMLTextAreaElement,
scrollTop: number,
scrollLeft: number,
) {
textarea.scrollTop = scrollTop;
textarea.scrollLeft = scrollLeft;
syncScrollFromTextarea();
requestAnimationFrame(() => {
textarea.scrollTop = scrollTop;
textarea.scrollLeft = scrollLeft;
syncScrollFromTextarea();
});
}
function syncScroll() {
function syncScrollFromTextarea() {
if (!textareaRef.value || !highlightRef.value) {
return;
}
if (syncingScroll) {
return;
}
syncingScroll = true;
highlightRef.value.scrollTop = textareaRef.value.scrollTop;
highlightRef.value.scrollLeft = textareaRef.value.scrollLeft;
requestAnimationFrame(() => {
syncingScroll = false;
});
}
function syncScrollFromHighlight() {
if (!textareaRef.value || !highlightRef.value) {
return;
}
if (syncingScroll) {
return;
}
syncingScroll = true;
textareaRef.value.scrollTop = highlightRef.value.scrollTop;
textareaRef.value.scrollLeft = highlightRef.value.scrollLeft;
requestAnimationFrame(() => {
syncingScroll = false;
});
}
function escapeHtml(value: string): string {
@@ -154,6 +420,10 @@ function escapeHtml(value: string): string {
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function escapeAttribute(value: string): string {
return escapeHtml(value).replaceAll('"', "&quot;");
}
</script>
<template>
@@ -200,6 +470,10 @@ function escapeHtml(value: string): string {
class="prompt-highlight"
aria-hidden="true"
v-html="highlightedContent"
@pointerdown="handleHighlightPointerDown"
@pointerup="handleHighlightPointerUp"
@pointercancel="handleHighlightPointerCancel"
@scroll="syncScrollFromHighlight"
></div>
<textarea
ref="textareaRef"
@@ -207,9 +481,7 @@ function escapeHtml(value: string): string {
:value="content"
:placeholder="editorPlaceholder"
@input="handleContentInput"
@mouseup="handleCaretInteraction"
@keyup="handleCaretInteraction"
@scroll="syncScroll"
@scroll="syncScrollFromTextarea"
></textarea>
</div>
</div>
@@ -250,16 +522,21 @@ function escapeHtml(value: string): string {
.prompt-highlight {
position: absolute;
inset: 0;
z-index: 2;
overflow: auto;
padding: 24px;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: normal;
font-family: inherit;
font-size: 16px;
line-height: 1.6;
letter-spacing: normal;
color: #262626;
pointer-events: none;
user-select: none;
cursor: text;
user-select: text;
box-sizing: border-box;
}
.prompt-textarea {
@@ -273,9 +550,14 @@ function escapeHtml(value: string): string {
font-family: inherit;
font-size: 16px;
line-height: 1.6;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: normal;
letter-spacing: normal;
outline: none;
color: transparent;
caret-color: #262626;
box-sizing: border-box;
}
.prompt-textarea::placeholder {
@@ -284,7 +566,10 @@ function escapeHtml(value: string): string {
.prompt-highlight :deep(.token-highlight) {
color: #eb2f96;
font-weight: 600;
}
.prompt-highlight :deep(.token-highlight__content) {
cursor: text;
}
.prompt-highlight :deep(.editor-placeholder) {