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:
@@ -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("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
}
|
||||
|
||||
function escapeAttribute(value: string): string {
|
||||
return escapeHtml(value).replaceAll('"', """);
|
||||
}
|
||||
</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) {
|
||||
|
||||
Reference in New Issue
Block a user