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) {
@@ -10,9 +10,10 @@ import KolVariableConfig from "./KolVariableConfig.vue";
import KolPromptEditArea from "./KolPromptEditArea.vue";
import KolDynamicForm from "./KolDynamicForm.vue";
import { kolManageApi } from "@/lib/api";
import type { KolVariableDefinition } from "@geo/shared-types";
import type { KolCardConfig, KolVariableDefinition } from "@geo/shared-types";
import { formatError } from "@/lib/errors";
import { buildKolPlatformOptions } from "@/lib/kol-platform-options";
import { mergeKolCardConfig, parseKolCardConfig } from "@/lib/kol-card-config";
import { syncKolVariablesWithContent } from "@/lib/kol-placeholders";
const props = defineProps<{
@@ -31,10 +32,15 @@ const variables = ref<KolVariableDefinition[]>([]);
const previewOpen = ref(false);
const previewValues = ref<Record<string, any>>({});
const aiTaskId = ref<string | null>(null);
const variableConfigRef = ref<{ focusVariable: (key: string) => void } | null>(null);
const cardConfig = ref<KolCardConfig>({});
const variableConfigRef = ref<{
focusVariable: (key: string, options?: { focusInput?: boolean }) => void;
} | null>(null);
const metadataForm = reactive({
name: "",
platform_hint: "通用",
allow_web_search: false,
allow_user_knowledge: true,
});
const { data: promptDetail } = useQuery({
@@ -55,10 +61,16 @@ watch(
() => promptDetail.value,
(detail) => {
if (detail) {
const nextCardConfig = detail.latest_card_config_json ?? {};
const parsedCardConfig = parseKolCardConfig(nextCardConfig);
metadataForm.name = detail.name;
metadataForm.platform_hint = detail.platform_hint || "通用";
metadataForm.allow_web_search = parsedCardConfig.allow_web_search;
metadataForm.allow_user_knowledge = parsedCardConfig.allow_user_knowledge;
content.value = detail.latest_prompt_content ?? "";
variables.value = detail.latest_schema_json?.variables ?? [];
cardConfig.value = { ...nextCardConfig };
}
},
{ immediate: true }
@@ -148,7 +160,10 @@ function buildPromptPayload(nextVariables: KolVariableDefinition[] = variables.v
sort_order: promptDetail.value?.sort_order,
content: content.value,
schema: { variables: nextVariables },
card_config: {},
card_config: mergeKolCardConfig(cardConfig.value, {
allow_web_search: metadataForm.allow_web_search,
allow_user_knowledge: metadataForm.allow_user_knowledge,
}),
};
}
@@ -214,7 +229,7 @@ function handlePreview() {
async function handleFocusVariable(key: string) {
syncVariablesFromContent();
await nextTick();
variableConfigRef.value?.focusVariable(key);
variableConfigRef.value?.focusVariable(key, { focusInput: false });
}
function getSelectPopupContainer(triggerNode: HTMLElement) {
@@ -288,6 +303,20 @@ function statusLabel(status?: string): string {
:get-popup-container="getSelectPopupContainer"
/>
</a-form-item>
<a-form-item label="生成文章时允许联网搜索">
<div class="editor-switch-field">
<a-switch v-model:checked="metadataForm.allow_web_search" />
<span class="editor-switch-hint">默认关闭关闭后用户在生成页无法启用联网搜索</span>
</div>
</a-form-item>
<a-form-item label="生成文章时允许用户使用自己的知识库">
<div class="editor-switch-field">
<a-switch v-model:checked="metadataForm.allow_user_knowledge" />
<span class="editor-switch-hint">默认开启关闭后用户在生成页无法选择自己的知识库</span>
</div>
</a-form-item>
</a-form>
</div>
@@ -432,6 +461,19 @@ function statusLabel(status?: string): string {
background: #f0f0f0;
}
.editor-switch-field {
display: flex;
align-items: center;
gap: 12px;
min-height: 32px;
}
.editor-switch-hint {
color: #667085;
font-size: 13px;
line-height: 1.5;
}
@media (max-width: 1200px) {
.editor-meta-form {
grid-template-columns: 1fr 1fr;
@@ -27,6 +27,10 @@ const componentTypes: Array<{ type: KolVariableType }> = [
{ type: "checkbox" },
];
type FocusVariableOptions = {
focusInput?: boolean;
};
function updateVariable(index: number, updates: Partial<KolVariableDefinition>) {
const newVariables = [...props.variables];
newVariables[index] = { ...newVariables[index], ...updates };
@@ -66,7 +70,7 @@ function setCardRef(key: string, el: Element | ComponentPublicInstance | null) {
cardRefs.delete(key);
}
async function focusVariable(key: string) {
async function focusVariable(key: string, options: FocusVariableOptions = {}) {
activeVariableKey.value = key;
if (activeHighlightTimer !== null) {
@@ -93,6 +97,10 @@ async function focusVariable(key: string) {
behavior: "smooth",
});
if (options.focusInput === false) {
return;
}
requestAnimationFrame(() => {
const input = card.querySelector("input");
if (input instanceof HTMLInputElement) {