Files
geo/apps/admin-web/src/components/kol/KolPromptEditor.vue
T
2026-05-09 09:40:17 +08:00

917 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import {
DownOutlined,
EyeOutlined,
LeftOutlined,
SaveOutlined,
SendOutlined,
} from '@ant-design/icons-vue'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { message } from 'ant-design-vue'
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import EditorAiAssistPanel from '@/components/editor-common/EditorAiAssistPanel.vue'
import { kolManageApi } from '@/lib/api'
import { formatError } from '@/lib/errors'
import { mergeKolCardConfig, parseKolCardConfig } from '@/lib/kol-card-config'
import {
getKolVariableInitialValue,
normalizeKolVariableDefinition,
syncKolVariablesWithContent,
} from '@/lib/kol-placeholders'
import { buildKolPlatformOptions } from '@/lib/kol-platform-options'
import type {
KolAssistRequest,
KolAssistStreamEvent,
KolCardConfig,
KolVariableDefinition,
} from '@geo/shared-types'
import KolDynamicForm from './KolDynamicForm.vue'
import KolPromptEditArea from './KolPromptEditArea.vue'
import KolVariableConfig from './KolVariableConfig.vue'
import KolVariablePanel from './KolVariablePanel.vue'
const props = defineProps<{
promptId: number
}>()
type AiOptimizeStatus = 'idle' | 'generating' | 'completed' | 'error'
type EditorAiAssistPlacement = 'bottom' | 'top'
type EditorAiAssistBoundary = {
left: number
top: number
right: number
bottom: number
}
type PromptSelectionAiSnapshot = {
start: number
end: number
selectedText: string
x: number
y: number
width: number
placement: EditorAiAssistPlacement
boundary: EditorAiAssistBoundary
}
type PromptSelectionAiPanelState = PromptSelectionAiSnapshot & {
open: boolean
}
const emit = defineEmits<{
back: []
}>()
const { t } = useI18n()
const queryClient = useQueryClient()
function createDefaultPromptSelectionAiPanelState(): PromptSelectionAiPanelState {
return {
open: false,
x: 0,
y: 0,
width: 0,
placement: 'bottom',
boundary: { left: 0, top: 0, right: 0, bottom: 0 },
start: 0,
end: 0,
selectedText: '',
}
}
const content = ref('')
const variables = ref<KolVariableDefinition[]>([])
const previewOpen = ref(false)
const previewValues = ref<Record<string, any>>({})
const aiBusy = ref(false)
const selectionAiPanel = ref<PromptSelectionAiPanelState>(
createDefaultPromptSelectionAiPanelState(),
)
const selectionAiInstruction = ref('')
const selectionAiPreview = ref('')
const selectionAiError = ref('')
const selectionAiStatus = ref<AiOptimizeStatus>('idle')
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 metaCollapsed = ref(false)
function toggleMetaCollapsed() {
metaCollapsed.value = !metaCollapsed.value
}
const { data: promptDetail } = useQuery({
queryKey: computed(() => ['kol', 'prompt', props.promptId]),
queryFn: () => kolManageApi.getPrompt(props.promptId),
})
const platformOptions = computed(() => {
const options = buildKolPlatformOptions(t('kol.manage.platformHintDefault'))
const currentValue = metadataForm.platform_hint.trim()
if (currentValue && !options.some((option) => option.value === currentValue)) {
return [{ label: currentValue, value: currentValue }, ...options]
}
return options
})
const selectionAiStreaming = computed(() => selectionAiStatus.value === 'generating')
const selectionAiHasPreview = computed(() => selectionAiPreview.value.trim().length > 0)
const selectionAiUiText = computed(() => ({
title: t('article.editor.aiOptimize.title'),
promptPlaceholder: t('kol.manage.editor.aiOptimizePromptPlaceholder'),
generating: t('article.editor.aiOptimize.generating'),
failed: t('article.editor.aiOptimize.messages.failed'),
disclaimer: t('kol.manage.editor.aiOptimizeDisclaimer'),
regenerate: t('article.editor.aiOptimize.regenerate'),
close: t('article.editor.aiOptimize.close'),
replace: t('kol.manage.editor.aiOptimizeReplace'),
}))
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 ?? []).map((variable) =>
normalizeKolVariableDefinition(variable),
)
cardConfig.value = { ...nextCardConfig }
}
},
{ immediate: true },
)
const publishMutation = useMutation({
mutationFn: (payload: ReturnType<typeof buildPromptPayload>) =>
kolManageApi.publishPrompt(props.promptId, payload),
onSuccess: () => {
message.success(t('kol.manage.editor.publish') + '成功')
invalidatePromptQueries()
},
onError: (error) => {
message.error(formatError(error))
},
})
const saveMutation = useMutation({
mutationFn: (payload: ReturnType<typeof buildPromptPayload>) =>
kolManageApi.savePrompt(props.promptId, payload),
onSuccess: () => {
message.success(t('common.save') + '成功')
invalidatePromptQueries()
},
onError: (error) => {
message.error(formatError(error))
},
})
let aiAssistAbortController: AbortController | null = null
let selectionAiAbortController: AbortController | null = null
function applyAssistResult(event: KolAssistStreamEvent) {
const nextContent = (event.content ?? '').trim()
if (!nextContent) {
return
}
content.value = nextContent
variables.value = syncKolVariablesWithContent(
nextContent,
event.schema?.variables ?? variables.value,
)
}
function handleAssistStreamEvent(event: KolAssistStreamEvent): 'completed' | 'error' | null {
if (event.type === 'start') {
aiBusy.value = true
return null
}
if (event.type === 'delta') {
aiBusy.value = true
if (typeof event.content === 'string') {
content.value = event.content
} else if (typeof event.delta === 'string' && event.delta) {
content.value += event.delta
}
return null
}
if (event.type === 'completed') {
aiBusy.value = false
applyAssistResult(event)
message.success('AI 处理完成')
return 'completed'
}
if (event.type === 'error') {
aiBusy.value = false
if (typeof event.content === 'string' && event.content.trim()) {
content.value = event.content
}
message.error(event.error || 'AI 处理失败')
return 'error'
}
return null
}
function stopAiAssistStream() {
aiAssistAbortController?.abort()
aiAssistAbortController = null
aiBusy.value = false
}
async function runAiAssistStream(payload: KolAssistRequest) {
stopAiAssistStream()
const controller = new AbortController()
aiAssistAbortController = controller
aiBusy.value = true
let terminalState: 'completed' | 'error' | null = null
try {
await kolManageApi.streamAssist(
payload,
{
onEvent(event) {
terminalState = handleAssistStreamEvent(event)
},
onClose() {
if (aiAssistAbortController === controller && terminalState === null) {
aiBusy.value = false
}
},
},
controller.signal,
)
if (!controller.signal.aborted && terminalState === null) {
aiBusy.value = false
message.error('AI 处理失败')
}
} catch (error) {
if (controller.signal.aborted) {
return
}
aiBusy.value = false
message.error(formatError(error))
} finally {
if (aiAssistAbortController === controller) {
aiAssistAbortController = null
}
}
}
async function handleAiGenerate(description: string) {
await runAiAssistStream({
mode: 'generate',
description,
prompt_id: props.promptId,
})
}
function resetSelectionAiOutput() {
selectionAiPreview.value = ''
selectionAiError.value = ''
selectionAiStatus.value = 'idle'
}
function stopSelectionAiStream() {
selectionAiAbortController?.abort()
selectionAiAbortController = null
if (selectionAiStatus.value === 'generating') {
selectionAiStatus.value = selectionAiHasPreview.value ? 'completed' : 'idle'
selectionAiError.value = ''
}
}
function closeSelectionAiPanel() {
stopSelectionAiStream()
selectionAiPanel.value = createDefaultPromptSelectionAiPanelState()
selectionAiInstruction.value = ''
resetSelectionAiOutput()
}
function handleOpenSelectionAiPanel(snapshot: PromptSelectionAiSnapshot) {
stopAiAssistStream()
stopSelectionAiStream()
selectionAiPanel.value = {
open: true,
...snapshot,
}
selectionAiInstruction.value = ''
resetSelectionAiOutput()
}
function handleSelectionAiStreamEvent(event: KolAssistStreamEvent) {
if (event.type === 'start') {
selectionAiStatus.value = 'generating'
selectionAiError.value = ''
return
}
if (event.type === 'delta') {
selectionAiStatus.value = 'generating'
selectionAiError.value = ''
if (typeof event.content === 'string') {
selectionAiPreview.value = event.content
} else if (typeof event.delta === 'string') {
selectionAiPreview.value += event.delta
}
return
}
if (event.type === 'completed') {
selectionAiStatus.value = 'completed'
selectionAiError.value = ''
if (typeof event.content === 'string') {
selectionAiPreview.value = event.content
}
return
}
if (event.type === 'error') {
selectionAiStatus.value = selectionAiHasPreview.value ? 'completed' : 'error'
selectionAiError.value = event.error || t('article.editor.aiOptimize.messages.failed')
if (typeof event.content === 'string' && event.content.trim()) {
selectionAiPreview.value = event.content
}
if (!selectionAiHasPreview.value) {
message.error(selectionAiError.value)
}
}
}
async function generateSelectionAiPreview() {
if (!selectionAiPanel.value.open) {
return
}
const instruction = selectionAiInstruction.value.trim()
if (!instruction) {
message.warning(t('article.editor.aiOptimize.messages.promptRequired'))
return
}
stopSelectionAiStream()
selectionAiPreview.value = ''
selectionAiError.value = ''
selectionAiStatus.value = 'generating'
const syncedVariables = syncVariablesFromContent()
const controller = new AbortController()
selectionAiAbortController = controller
try {
await kolManageApi.streamAssist(
{
mode: 'optimize',
description: instruction,
current_content: selectionAiPanel.value.selectedText,
schema: { variables: syncedVariables },
prompt_id: props.promptId,
},
{
onEvent: handleSelectionAiStreamEvent,
},
controller.signal,
)
if (!controller.signal.aborted && selectionAiStatus.value === 'generating') {
selectionAiStatus.value = selectionAiHasPreview.value ? 'completed' : 'error'
}
} catch (error) {
if (controller.signal.aborted) {
return
}
selectionAiStatus.value = selectionAiHasPreview.value ? 'completed' : 'error'
selectionAiError.value = formatError(error)
if (!selectionAiHasPreview.value) {
message.error(selectionAiError.value)
}
} finally {
if (selectionAiAbortController === controller) {
selectionAiAbortController = null
}
}
}
function applySelectionAiReplacement() {
const replacement = selectionAiPreview.value.trim()
if (!selectionAiPanel.value.open || !replacement) {
if (!replacement) {
message.warning(t('article.editor.aiOptimize.messages.empty'))
}
return
}
const target = { ...selectionAiPanel.value }
const currentSelectedText = content.value.slice(target.start, target.end)
if (currentSelectedText !== target.selectedText) {
message.warning(t('article.editor.aiOptimize.messages.selectionChanged'))
return
}
const nextContent =
content.value.slice(0, target.start) + replacement + content.value.slice(target.end)
content.value = nextContent
variables.value = syncKolVariablesWithContent(nextContent, variables.value)
closeSelectionAiPanel()
}
function buildPromptPayload(nextVariables: KolVariableDefinition[] = variables.value) {
return {
name: metadataForm.name.trim(),
platform_hint: metadataForm.platform_hint,
sort_order: promptDetail.value?.sort_order,
content: content.value,
schema: { variables: nextVariables },
card_config: mergeKolCardConfig(cardConfig.value, {
allow_web_search: metadataForm.allow_web_search,
allow_user_knowledge: metadataForm.allow_user_knowledge,
}),
}
}
function syncVariablesFromContent(): KolVariableDefinition[] {
const syncedVariables = syncKolVariablesWithContent(content.value, variables.value)
variables.value = syncedVariables
return syncedVariables
}
function buildPreviewValues(nextVariables: KolVariableDefinition[]): Record<string, any> {
const nextValues: Record<string, any> = {}
nextVariables.forEach((variable) => {
const fieldKey = variable.key?.trim() || variable.id
nextValues[fieldKey] = getKolVariableInitialValue(variable)
})
return nextValues
}
function invalidatePromptQueries() {
void queryClient.invalidateQueries({ queryKey: ['kol', 'prompt', props.promptId] })
void queryClient.invalidateQueries({ queryKey: ['kol', 'packages'] })
void queryClient.invalidateQueries({
queryKey: ['kol', 'packages', promptDetail.value?.package_id, 'prompts'],
})
}
function validateBeforeSubmit(): boolean {
if (!metadataForm.name.trim()) {
message.warning(t('common.inputPlease') + t('common.title'))
return false
}
if (!content.value.trim()) {
message.warning('请输入提示词内容')
return false
}
return true
}
function handleSave() {
const syncedVariables = syncVariablesFromContent()
if (!validateBeforeSubmit()) {
return
}
saveMutation.mutate(buildPromptPayload(syncedVariables))
}
function handleSaveShortcut(event: KeyboardEvent) {
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 's') {
return
}
event.preventDefault()
event.stopPropagation()
if (event.repeat || saveMutation.isPending.value || publishMutation.isPending.value) {
return
}
handleSave()
}
function handlePublish() {
const syncedVariables = syncVariablesFromContent()
if (!validateBeforeSubmit()) {
return
}
publishMutation.mutate(buildPromptPayload(syncedVariables))
}
function handlePreview() {
const syncedVariables = syncVariablesFromContent()
previewValues.value = buildPreviewValues(syncedVariables)
previewOpen.value = true
}
async function handleFocusVariable(key: string) {
syncVariablesFromContent()
await nextTick()
variableConfigRef.value?.focusVariable(key, { focusInput: false })
}
function getSelectPopupContainer(triggerNode: HTMLElement) {
return triggerNode.parentElement ?? triggerNode
}
function statusTagColor(status?: string): string {
if (status === 'active') return 'green'
if (status === 'archived') return 'orange'
return 'default'
}
function statusLabel(status?: string): string {
if (status === 'active') return t('kol.manage.status.active')
if (status === 'archived') return t('kol.manage.status.archived')
return t('kol.manage.status.draft')
}
function handleWindowPointerDown(event: PointerEvent) {
if (!(selectionAiPanel.value.open && selectionAiStatus.value === 'idle')) {
return
}
const target = event.target
if (
target instanceof Element &&
(target.closest('.editor-ai-assist') || target.closest('.prompt-selection-ai-action'))
) {
return
}
closeSelectionAiPanel()
}
onMounted(() => {
window.addEventListener('keydown', handleSaveShortcut)
window.addEventListener('pointerdown', handleWindowPointerDown)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleSaveShortcut)
window.removeEventListener('pointerdown', handleWindowPointerDown)
stopAiAssistStream()
stopSelectionAiStream()
})
</script>
<template>
<div class="kol-prompt-editor">
<div class="editor-topbar">
<button class="back-btn" type="button" @click="emit('back')">
<LeftOutlined />
<span>{{ t('common.back') }}</span>
</button>
</div>
<div class="editor-header">
<div class="header-title">
<div class="title-row">
<span class="prompt-name">
{{ metadataForm.name || promptDetail?.name || t('common.loading') }}
</span>
<a-tag v-if="promptDetail" :color="statusTagColor(promptDetail.status)">
{{ statusLabel(promptDetail.status) }}
</a-tag>
</div>
<span class="prompt-subtitle">{{ t('kol.manage.editor.basicInfo') }}</span>
</div>
<div class="header-actions">
<a-button @click="handlePreview">
<template #icon><EyeOutlined /></template>
{{ t('kol.manage.editor.preview') }}
</a-button>
<a-button
:loading="saveMutation.isPending.value"
title="Ctrl/Cmd + S"
aria-keyshortcuts="Control+S Meta+S"
@click="handleSave"
>
<template #icon><SaveOutlined /></template>
{{ t('common.save') }}
</a-button>
<a-button type="primary" :loading="publishMutation.isPending.value" @click="handlePublish">
<template #icon><SendOutlined /></template>
{{ t('kol.manage.editor.publish') }}
</a-button>
</div>
</div>
<div class="editor-meta-card" :class="{ 'editor-meta-card--collapsed': metaCollapsed }">
<button
type="button"
class="editor-section-title editor-section-title--toggle"
:aria-expanded="!metaCollapsed"
@click="toggleMetaCollapsed"
>
<DownOutlined
class="editor-section-title__chevron"
:class="{ 'editor-section-title__chevron--collapsed': metaCollapsed }"
/>
<span>{{ t('kol.manage.editor.basicInfo') }}</span>
</button>
<a-form v-show="!metaCollapsed" layout="vertical" class="editor-meta-form">
<a-form-item :label="t('common.title')" required>
<a-input
v-model:value="metadataForm.name"
:placeholder="t('common.inputPlease') + t('common.title')"
/>
</a-form-item>
<a-form-item :label="t('kol.manage.platformHint')">
<a-select
v-model:value="metadataForm.platform_hint"
:options="platformOptions"
option-filter-prop="label"
show-search
: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>
<div class="editor-body">
<KolVariablePanel />
<KolPromptEditArea
v-model:content="content"
v-model:variables="variables"
:ai-busy="aiBusy"
:selection-ai-open="selectionAiPanel.open"
@ai-generate="handleAiGenerate"
@ai-optimize-selection="handleOpenSelectionAiPanel"
@focus-variable="handleFocusVariable"
/>
<KolVariableConfig ref="variableConfigRef" v-model:variables="variables" />
</div>
<a-modal
v-model:open="previewOpen"
:title="t('kol.manage.editor.preview')"
:footer="null"
width="600px"
>
<KolDynamicForm v-model:modelValue="previewValues" :variables="variables" />
</a-modal>
<EditorAiAssistPanel
v-if="selectionAiPanel.open"
v-model:prompt="selectionAiInstruction"
:x="selectionAiPanel.x"
:y="selectionAiPanel.y"
:width="selectionAiPanel.width"
:placement="selectionAiPanel.placement"
:boundary="selectionAiPanel.boundary"
:status="selectionAiStatus"
:streaming="selectionAiStreaming"
:has-preview="selectionAiHasPreview"
:error="selectionAiError"
:title="selectionAiUiText.title"
:prompt-placeholder="selectionAiUiText.promptPlaceholder"
:continue-placeholder="selectionAiUiText.promptPlaceholder"
:loading-label="selectionAiUiText.generating"
:error-label="selectionAiUiText.failed"
:disclaimer="selectionAiUiText.disclaimer"
:regenerate-label="selectionAiUiText.regenerate"
:discard-label="selectionAiUiText.close"
:apply-label="selectionAiUiText.replace"
@submit="generateSelectionAiPreview"
@close="closeSelectionAiPanel"
@reset="resetSelectionAiOutput"
@apply="applySelectionAiReplacement"
>
<template #preview>
<pre class="kol-prompt-editor__ai-preview">{{ selectionAiPreview }}</pre>
</template>
</EditorAiAssistPanel>
</div>
</template>
<style scoped>
.kol-prompt-editor {
height: 100%;
display: flex;
flex-direction: column;
}
.editor-topbar {
padding: 12px 16px;
}
.back-btn {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 0;
border: 0;
background: transparent;
color: #111827;
font-size: 16px;
font-weight: 700;
cursor: pointer;
}
.back-btn :deep(.anticon) {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(144, 157, 181, 0.24);
}
.editor-header {
margin: 0 16px;
padding: 16px 20px;
border: 1px solid #f0f0f0;
border-radius: 16px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
background: #fff;
}
.header-title {
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.title-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.prompt-name {
font-size: 18px;
font-weight: 600;
color: #111827;
}
.prompt-subtitle {
font-size: 13px;
color: #667085;
}
.header-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
justify-content: flex-end;
}
.editor-meta-card {
margin: 12px 16px 0;
padding: 16px 20px 4px;
border: 1px solid #f0f0f0;
border-radius: 16px;
background: #fff;
}
.editor-meta-card.editor-meta-card--collapsed {
padding-bottom: 16px;
}
.editor-section-title {
margin: 0 0 16px;
font-size: 14px;
font-weight: 600;
color: #111827;
}
.editor-section-title--toggle {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
color: inherit;
font: inherit;
}
.editor-meta-card--collapsed .editor-section-title--toggle {
margin-bottom: 0;
}
.editor-section-title--toggle:hover {
color: #1677ff;
}
.editor-section-title__chevron {
font-size: 12px;
transition: transform 0.2s ease;
}
.editor-section-title__chevron--collapsed {
transform: rotate(-90deg);
}
.editor-meta-form {
display: grid;
grid-template-columns: minmax(240px, 1.4fr) minmax(200px, 1fr);
gap: 16px;
align-items: start;
}
.editor-body {
flex: 1;
display: flex;
overflow: hidden;
margin: 12px 16px 16px;
border: 1px solid #f0f0f0;
border-radius: 16px;
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;
}
.kol-prompt-editor__ai-preview {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
color: #111827;
font: inherit;
line-height: 1.8;
}
@media (max-width: 1200px) {
.editor-meta-form {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 900px) {
.editor-header {
flex-direction: column;
align-items: flex-start;
}
.header-actions {
width: 100%;
justify-content: flex-start;
}
.editor-meta-form {
grid-template-columns: 1fr;
}
.editor-body {
flex-direction: column;
}
}
</style>