917 lines
24 KiB
Vue
917 lines
24 KiB
Vue
<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>
|