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:
2026-04-25 22:41:40 +08:00
parent 2862aff72b
commit 2846968cf7
9 changed files with 1428 additions and 112 deletions
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;