feat(kol): add selection-based AI optimize for prompt editor
Trigger an AI optimize panel from a text selection in the KOL prompt editor, accept a user instruction, and stream a preview before replacing the selected fragment. Extend the shared editor AI assist panel with boundary-aware placement so it can be hosted inside scoped surfaces, and update the backend optimize prompt to honor the user instruction. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from "vue";
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
|
||||
import { message } from "ant-design-vue";
|
||||
@@ -9,6 +9,7 @@ import KolVariablePanel from "./KolVariablePanel.vue";
|
||||
import KolVariableConfig from "./KolVariableConfig.vue";
|
||||
import KolPromptEditArea from "./KolPromptEditArea.vue";
|
||||
import KolDynamicForm from "./KolDynamicForm.vue";
|
||||
import EditorAiAssistPanel from "@/components/editor-common/EditorAiAssistPanel.vue";
|
||||
import { kolManageApi } from "@/lib/api";
|
||||
import type { KolAssistRequest, KolAssistStreamEvent, KolCardConfig, KolVariableDefinition } from "@geo/shared-types";
|
||||
import { formatError } from "@/lib/errors";
|
||||
@@ -24,6 +25,30 @@ 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: [];
|
||||
}>();
|
||||
@@ -31,11 +56,30 @@ const emit = defineEmits<{
|
||||
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;
|
||||
@@ -60,6 +104,18 @@ const platformOptions = computed(() => {
|
||||
}
|
||||
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,
|
||||
@@ -107,6 +163,7 @@ const saveMutation = useMutation({
|
||||
});
|
||||
|
||||
let aiAssistAbortController: AbortController | null = null;
|
||||
let selectionAiAbortController: AbortController | null = null;
|
||||
|
||||
function applyAssistResult(event: KolAssistStreamEvent) {
|
||||
const nextContent = (event.content ?? "").trim();
|
||||
@@ -212,14 +269,155 @@ async function handleAiGenerate(description: string) {
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAiOptimize() {
|
||||
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();
|
||||
await runAiAssistStream({
|
||||
mode: "optimize",
|
||||
current_content: content.value,
|
||||
schema: { variables: syncedVariables },
|
||||
prompt_id: props.promptId,
|
||||
});
|
||||
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) {
|
||||
@@ -281,6 +479,20 @@ function handleSave() {
|
||||
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()) {
|
||||
@@ -317,8 +529,32 @@ function statusLabel(status?: string): string {
|
||||
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>
|
||||
|
||||
@@ -346,7 +582,12 @@ onBeforeUnmount(() => {
|
||||
<template #icon><EyeOutlined /></template>
|
||||
{{ t('kol.manage.editor.preview') }}
|
||||
</a-button>
|
||||
<a-button :loading="saveMutation.isPending.value" @click="handleSave">
|
||||
<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>
|
||||
@@ -399,8 +640,9 @@ onBeforeUnmount(() => {
|
||||
v-model:content="content"
|
||||
v-model:variables="variables"
|
||||
:ai-busy="aiBusy"
|
||||
:selection-ai-open="selectionAiPanel.open"
|
||||
@ai-generate="handleAiGenerate"
|
||||
@ai-optimize="handleAiOptimize"
|
||||
@ai-optimize-selection="handleOpenSelectionAiPanel"
|
||||
@focus-variable="handleFocusVariable"
|
||||
/>
|
||||
<KolVariableConfig
|
||||
@@ -420,6 +662,37 @@ onBeforeUnmount(() => {
|
||||
v-model:modelValue="previewValues"
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -547,6 +820,15 @@ onBeforeUnmount(() => {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user