Files
geo/apps/admin-web/src/components/kol/KolPromptEditArea.vue
T
root 3ef0807456 fix: Enhance Kol Variable Rendering and Management
- Updated the regex for placeholder matching to support more flexible key formats.
- Refactored variable storage to allow both ID and key lookups in rendering.
- Improved schema validation to check for duplicate keys and enforce non-empty keys.
- Added new utility functions for variable value lookup and display name generation.
- Enhanced tests to cover new key-based variable scenarios.
- Refactored KolPrompt repository to simplify prompt storage and management, including the removal of obsolete revision handling.
- Introduced new API endpoints for saving, activating, and archiving prompts.
- Added new utility functions for handling Kol placeholders and platform options in the admin web.
- Implemented database migrations to simplify prompt storage structure and ensure data integrity.
2026-04-18 00:47:57 +08:00

319 lines
7.4 KiB
Vue

<script setup lang="ts">
import { computed, h, ref } from "vue";
import { useI18n } from "vue-i18n";
import { ExperimentOutlined, ThunderboltOutlined, LoadingOutlined } from "@ant-design/icons-vue";
import type { KolVariableDefinition, KolVariableType } from "@geo/shared-types";
import { Modal } from "ant-design-vue";
import {
buildKolPlaceholderToken,
createKolVariableDefinition,
findKolPlaceholderKeyAtPosition,
} from "@/lib/kol-placeholders";
const props = defineProps<{
content: string;
variables: KolVariableDefinition[];
aiTaskId: string | null;
}>();
const emit = defineEmits<{
(e: "update:content", value: string): void;
(e: "update:variables", value: KolVariableDefinition[]): void;
(e: "ai-generate", description: string): void;
(e: "ai-optimize"): void;
(e: "focus-variable", key: string): void;
}>();
const { t } = useI18n();
const aiDescription = ref("");
const textareaRef = ref<HTMLTextAreaElement | null>(null);
const highlightRef = ref<HTMLDivElement | null>(null);
const editorPlaceholder = "在这里输入提示词内容,可以使用 {{变量名}} 引用左侧定义的变量";
const highlightTokenRE = /\{\{\s*([^{}]+?)\s*\}\}/g;
const highlightedContent = computed(() => {
const text = props.content ?? "";
if (!text) {
return `<span class="editor-placeholder">${escapeHtml(editorPlaceholder)}</span>`;
}
let html = "";
let lastIndex = 0;
for (const match of text.matchAll(highlightTokenRE)) {
const matchIndex = match.index ?? 0;
const token = match[0] ?? "";
html += escapeHtml(text.slice(lastIndex, matchIndex));
html += `<span class="token-highlight">${escapeHtml(token)}</span>`;
lastIndex = matchIndex + token.length;
}
html += escapeHtml(text.slice(lastIndex));
return html;
});
function onDrop(event: DragEvent) {
event.preventDefault();
const type = event.dataTransfer?.getData("application/kol-variable-type") as KolVariableType;
if (!type) return;
const labelValue = ref("");
Modal.confirm({
title: t("kol.manage.variable.label"),
content: () => {
return h("div", { style: { marginTop: "16px" } }, [
h("input", {
class: "ant-input",
placeholder: t("common.inputPlease"),
value: labelValue.value,
onInput: (e: Event) => {
labelValue.value = (e.target as HTMLInputElement).value;
},
}),
]);
},
onOk: () => {
const key = labelValue.value.trim() || t(`kol.manage.variable.${type}`);
const existingVariable = props.variables.find((variable) => variable.key === key);
if (!existingVariable) {
const newVar: KolVariableDefinition = createKolVariableDefinition(key, {
type,
});
emit("update:variables", [...props.variables, newVar]);
}
insertToken(buildKolPlaceholderToken(key));
},
});
}
function insertToken(token: string) {
if (!textareaRef.value) return;
const el = textareaRef.value;
const start = el.selectionStart;
const end = el.selectionEnd;
const text = props.content;
const newContent = text.substring(0, start) + token + text.substring(end);
emit("update:content", newContent);
// Set cursor position after update
setTimeout(() => {
el.focus();
el.setSelectionRange(start + token.length, start + token.length);
syncScroll();
}, 0);
}
function handleAiGenerate() {
if (!aiDescription.value.trim()) return;
emit("ai-generate", aiDescription.value);
}
function handleContentInput(event: Event) {
emit("update:content", (event.target as HTMLTextAreaElement).value);
syncScroll();
}
function handleCaretInteraction() {
if (!textareaRef.value) {
return;
}
requestAnimationFrame(() => {
const textarea = textareaRef.value;
if (!textarea) {
return;
}
const key = findKolPlaceholderKeyAtPosition(props.content, textarea.selectionStart ?? -1);
if (key) {
emit("focus-variable", key);
}
});
}
function syncScroll() {
if (!textareaRef.value || !highlightRef.value) {
return;
}
highlightRef.value.scrollTop = textareaRef.value.scrollTop;
highlightRef.value.scrollLeft = textareaRef.value.scrollLeft;
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
</script>
<template>
<div class="kol-prompt-edit-area" @dragover.prevent @drop="onDrop">
<div class="ai-assist-bar">
<a-input
v-model:value="aiDescription"
class="ai-input"
:placeholder="t('kol.manage.editor.aiPlaceholder')"
:disabled="!!aiTaskId"
@press-enter="handleAiGenerate"
>
<template #prefix>
<ExperimentOutlined style="color: #722ed1" />
</template>
</a-input>
<a-button
type="primary"
class="ai-btn"
:loading="!!aiTaskId"
@click="handleAiGenerate"
>
<template #icon><ThunderboltOutlined /></template>
{{ t('kol.manage.editor.aiGenerate') }}
</a-button>
<a-button
class="ai-btn"
:disabled="!content || !!aiTaskId"
@click="emit('ai-optimize')"
>
{{ t('kol.manage.editor.aiOptimize') }}
</a-button>
</div>
<div class="editor-container">
<div v-if="aiTaskId" class="ai-loading-overlay">
<div class="loading-content">
<LoadingOutlined class="loading-icon" />
<span>AI 正在思考中...</span>
</div>
</div>
<div
ref="highlightRef"
class="prompt-highlight"
aria-hidden="true"
v-html="highlightedContent"
></div>
<textarea
ref="textareaRef"
class="prompt-textarea"
:value="content"
:placeholder="editorPlaceholder"
@input="handleContentInput"
@mouseup="handleCaretInteraction"
@keyup="handleCaretInteraction"
@scroll="syncScroll"
></textarea>
</div>
</div>
</template>
<style scoped>
.kol-prompt-edit-area {
flex: 1;
display: flex;
flex-direction: column;
background: #fff;
min-width: 0;
}
.ai-assist-bar {
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
display: flex;
gap: 8px;
background: #f9f0ff;
}
.ai-input {
flex: 1;
}
.ai-btn {
flex-shrink: 0;
}
.editor-container {
flex: 1;
position: relative;
display: flex;
overflow: hidden;
}
.prompt-highlight {
position: absolute;
inset: 0;
overflow: auto;
padding: 24px;
white-space: pre-wrap;
word-break: break-word;
font-family: inherit;
font-size: 16px;
line-height: 1.6;
color: #262626;
pointer-events: none;
user-select: none;
}
.prompt-textarea {
position: relative;
z-index: 1;
flex: 1;
border: none;
resize: none;
padding: 24px;
background: transparent;
font-family: inherit;
font-size: 16px;
line-height: 1.6;
outline: none;
color: transparent;
caret-color: #262626;
}
.prompt-textarea::placeholder {
color: transparent;
}
.prompt-highlight :deep(.token-highlight) {
color: #eb2f96;
font-weight: 600;
}
.prompt-highlight :deep(.editor-placeholder) {
color: #bfbfbf;
}
.ai-loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.7);
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
}
.loading-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: #722ed1;
}
.loading-icon {
font-size: 32px;
}
</style>