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
@@ -145,6 +145,7 @@ type ImageContextMenuAction =
type ImagePickerAction = "insert" | "replace";
type AiOptimizeStatus = "idle" | "generating" | "completed" | "error";
type EditorAiAssistPlacement = "bottom" | "top";
type TableContextMenuState = {
open: boolean;
@@ -175,6 +176,7 @@ type AiOptimizePanelState = {
x: number;
y: number;
width: number;
placement: EditorAiAssistPlacement;
from: number;
to: number;
selectedText: string;
@@ -255,6 +257,7 @@ function createDefaultAiOptimizePanelState(): AiOptimizePanelState {
x: 0,
y: 0,
width: 0,
placement: "bottom",
from: 0,
to: 0,
selectedText: "",
@@ -941,20 +944,29 @@ function resolveTextSelectionSnapshot(ctx: Ctx): TextSelectionSnapshot | null {
};
}
function resolveAiOptimizePanelFrame(anchorRect: DOMRect): { x: number; y: number; width: number } {
function resolveAiOptimizePanelFrame(anchorRect: DOMRect): { x: number; y: number; width: number; placement: EditorAiAssistPlacement } {
const viewportPadding = 16;
const panelPadding = 24;
const maxWidth = Math.max(240, window.innerWidth - viewportPadding * 2);
const preferredWidth = 1120;
const surfaceRect = surfaceRef.value?.getBoundingClientRect();
const boundary = surfaceRect ?? new DOMRect(0, 0, window.innerWidth, window.innerHeight);
const widthFromSurface = surfaceRect ? Math.max(240, surfaceRect.width - panelPadding * 2) : preferredWidth;
const width = Math.min(maxWidth, preferredWidth, widthFromSurface);
const availableBelow = boundary.bottom - anchorRect.bottom - AI_OPTIMIZE_PANEL_GAP - viewportPadding;
const availableAbove = anchorRect.top - boundary.top - AI_OPTIMIZE_PANEL_GAP - viewportPadding;
const placement: EditorAiAssistPlacement =
availableBelow >= 320 || availableBelow >= availableAbove ? "bottom" : "top";
return {
x: anchorRect.left + anchorRect.width / 2,
y: Math.max(viewportPadding, anchorRect.bottom + AI_OPTIMIZE_PANEL_GAP),
y:
placement === "top"
? Math.min(boundary.bottom - viewportPadding, anchorRect.top - AI_OPTIMIZE_PANEL_GAP)
: Math.max(boundary.top + viewportPadding, anchorRect.bottom + AI_OPTIMIZE_PANEL_GAP),
width,
placement,
};
}
@@ -981,6 +993,7 @@ function openAiOptimizePanel(ctx: Ctx): void {
x: frame.x,
y: frame.y,
width: frame.width,
placement: frame.placement,
from: snapshot.from,
to: snapshot.to,
selectedText: snapshot.text,
@@ -1975,6 +1988,8 @@ function runTableContextAction(action: TableContextMenuAction): void {
:x="aiOptimizePanel.x"
:y="aiOptimizePanel.y"
:width="aiOptimizePanel.width"
:placement="aiOptimizePanel.placement"
:boundary-element="surfaceRef"
:status="aiOptimizeStatus"
:streaming="aiOptimizeStreaming"
:has-preview="aiOptimizeHasPreview"
@@ -3,11 +3,21 @@ import { CheckOutlined, CloseOutlined, DeleteOutlined, LoadingOutlined, RedoOutl
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, type CSSProperties } from "vue";
type EditorAiAssistStatus = "idle" | "generating" | "completed" | "error";
type EditorAiAssistBoundary = {
left: number;
top: number;
right: number;
bottom: number;
};
type EditorAiAssistPlacement = "bottom" | "top";
const props = defineProps<{
x: number;
y: number;
width: number;
boundary?: EditorAiAssistBoundary | null;
boundaryElement?: HTMLElement | null;
placement?: EditorAiAssistPlacement;
prompt: string;
status: EditorAiAssistStatus;
streaming: boolean;
@@ -34,14 +44,24 @@ const emit = defineEmits<{
}>();
const IDLE_MAX_WIDTH = 760;
const PANEL_OFFSET = 16;
const MIN_PANEL_WIDTH = 240;
const RESULT_CHROME_HEIGHT = 176;
const panelRef = ref<HTMLElement | null>(null);
const resultContentRef = ref<HTMLElement | null>(null);
const promptInputRef = ref<HTMLInputElement | null>(null);
const frame = ref({
x: props.x,
y: props.y,
width: props.width,
maxHeight: 480,
resultMaxHeight: 300,
});
let resizeObserver: ResizeObserver | null = null;
let mutationObserver: MutationObserver | null = null;
let syncFrameId = 0;
let scrollFrameId = 0;
const promptValue = computed({
get: () => props.prompt,
@@ -58,12 +78,86 @@ const panelStyle = computed<CSSProperties>(() => ({
left: `${frame.value.x}px`,
top: `${frame.value.y}px`,
width: `${frame.value.width}px`,
}));
maxHeight: `${frame.value.maxHeight}px`,
"--editor-ai-assist-result-max-height": `${frame.value.resultMaxHeight}px`,
} as CSSProperties));
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function normalizeBoundary(rawBoundary: EditorAiAssistBoundary | null | undefined): EditorAiAssistBoundary {
const viewportBoundary = {
left: 0,
top: 0,
right: window.innerWidth,
bottom: window.innerHeight,
};
if (!rawBoundary) {
return viewportBoundary;
}
const left = clamp(rawBoundary.left, 0, window.innerWidth);
const top = clamp(rawBoundary.top, 0, window.innerHeight);
const right = clamp(rawBoundary.right, left, window.innerWidth);
const bottom = clamp(rawBoundary.bottom, top, window.innerHeight);
if (right - left < 1 || bottom - top < 1) {
return viewportBoundary;
}
return { left, top, right, bottom };
}
function resolveBoundary(): EditorAiAssistBoundary {
if (props.boundaryElement) {
const rect = props.boundaryElement.getBoundingClientRect();
return normalizeBoundary({
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
});
}
return normalizeBoundary(props.boundary);
}
function updateFrame(nextFrame: typeof frame.value): void {
const current = frame.value;
const hasChanged =
Math.abs(current.x - nextFrame.x) > 0.5 ||
Math.abs(current.y - nextFrame.y) > 0.5 ||
Math.abs(current.width - nextFrame.width) > 0.5 ||
Math.abs(current.maxHeight - nextFrame.maxHeight) > 0.5 ||
Math.abs(current.resultMaxHeight - nextFrame.resultMaxHeight) > 0.5;
if (hasChanged) {
frame.value = nextFrame;
}
}
function scrollResultContentToLatest(): void {
if (!(props.streaming || props.status === "generating")) {
return;
}
if (scrollFrameId) {
cancelAnimationFrame(scrollFrameId);
}
scrollFrameId = requestAnimationFrame(() => {
scrollFrameId = 0;
const resultContent = resultContentRef.value;
if (resultContent) {
resultContent.scrollTop = resultContent.scrollHeight;
}
});
}
function handleResultContentChanged(): void {
schedulePanelFrameSync();
scrollResultContentToLatest();
}
function syncPanelFrame(): void {
void nextTick(() => {
const panel = panelRef.value;
@@ -71,17 +165,55 @@ function syncPanelFrame(): void {
return;
}
const offset = 16;
const maxWidth = Math.max(240, window.innerWidth - offset * 2);
const boundary = resolveBoundary();
const availableWidth = Math.max(0, boundary.right - boundary.left - PANEL_OFFSET * 2);
const maxWidth = Math.max(160, availableWidth);
const minWidth = Math.min(MIN_PANEL_WIDTH, maxWidth);
const width =
props.status === "idle"
? Math.min(props.width, maxWidth, IDLE_MAX_WIDTH)
: Math.min(props.width, maxWidth);
frame.value = {
x: clamp(props.x - width / 2, offset, Math.max(offset, window.innerWidth - width - offset)),
y: clamp(props.y, offset, Math.max(offset, window.innerHeight - panel.offsetHeight - offset)),
? clamp(Math.min(props.width, IDLE_MAX_WIDTH), minWidth, maxWidth)
: clamp(props.width, minWidth, maxWidth);
const placement = props.placement ?? "bottom";
const anchorY = clamp(props.y, boundary.top + PANEL_OFFSET, boundary.bottom - PANEL_OFFSET);
const availableHeight = Math.max(
120,
placement === "top"
? anchorY - boundary.top - PANEL_OFFSET
: boundary.bottom - anchorY - PANEL_OFFSET,
);
const panelHeight = Math.min(panel.offsetHeight || availableHeight, availableHeight);
const resultMaxHeight = Math.max(
48,
availableHeight - RESULT_CHROME_HEIGHT,
);
const panelTop = placement === "top" ? anchorY - panelHeight : anchorY;
updateFrame({
x: clamp(
props.x - width / 2,
boundary.left + PANEL_OFFSET,
Math.max(boundary.left + PANEL_OFFSET, boundary.right - width - PANEL_OFFSET),
),
y: clamp(
panelTop,
boundary.top + PANEL_OFFSET,
Math.max(boundary.top + PANEL_OFFSET, boundary.bottom - panelHeight - PANEL_OFFSET),
),
width,
};
maxHeight: availableHeight,
resultMaxHeight,
});
scrollResultContentToLatest();
});
}
function schedulePanelFrameSync(): void {
if (syncFrameId) {
cancelAnimationFrame(syncFrameId);
}
syncFrameId = requestAnimationFrame(() => {
syncFrameId = 0;
syncPanelFrame();
});
}
@@ -100,6 +232,23 @@ function handleWindowResize(): void {
syncPanelFrame();
}
function refreshResultContentObserver(): void {
void nextTick(() => {
mutationObserver?.disconnect();
const resultContent = resultContentRef.value;
if (!resultContent) {
return;
}
mutationObserver?.observe(resultContent, {
childList: true,
characterData: true,
subtree: true,
});
scrollResultContentToLatest();
});
}
watch(
() => ({
x: props.x,
@@ -108,9 +257,13 @@ watch(
status: props.status,
hasPreview: props.hasPreview,
error: props.error,
boundary: props.boundary,
boundaryElement: props.boundaryElement,
placement: props.placement,
}),
() => {
syncPanelFrame();
refreshResultContentObserver();
focusPromptInput();
},
{
@@ -121,12 +274,32 @@ watch(
onMounted(() => {
window.addEventListener("resize", handleWindowResize);
mutationObserver = new MutationObserver(handleResultContentChanged);
if ("ResizeObserver" in window) {
resizeObserver = new ResizeObserver(schedulePanelFrameSync);
if (panelRef.value) {
resizeObserver.observe(panelRef.value);
}
}
refreshResultContentObserver();
syncPanelFrame();
focusPromptInput();
});
onBeforeUnmount(() => {
window.removeEventListener("resize", handleWindowResize);
mutationObserver?.disconnect();
mutationObserver = null;
resizeObserver?.disconnect();
resizeObserver = null;
if (syncFrameId) {
cancelAnimationFrame(syncFrameId);
syncFrameId = 0;
}
if (scrollFrameId) {
cancelAnimationFrame(scrollFrameId);
scrollFrameId = 0;
}
});
</script>
@@ -179,7 +352,7 @@ onBeforeUnmount(() => {
<div v-else class="editor-ai-assist__result">
<div class="editor-ai-assist__result-box">
<div class="editor-ai-assist__result-content">
<div ref="resultContentRef" class="editor-ai-assist__result-content">
<slot v-if="hasPreview" name="preview"></slot>
<div v-else-if="streaming" class="editor-ai-assist__placeholder">
<LoadingOutlined />
@@ -269,22 +442,38 @@ onBeforeUnmount(() => {
position: fixed;
z-index: 1300;
max-width: min(1120px, calc(100vw - 32px));
/* Removed overflow: hidden to prevent shadow clipping */
}
.editor-ai-assist__shell {
display: flex;
flex-direction: column;
max-height: inherit;
min-height: 0;
}
.editor-ai-assist__bar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
border-radius: 999px;
background: #ffffff;
padding: 8px 8px 8px 16px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(17, 24, 39, 0.08);
box-shadow:
0 8px 32px rgb(17 17 17 / 28%), 0 0 0 1px rgba(15, 23, 42, 0.05);
0 16px 48px -4px rgba(17, 24, 39, 0.12),
0 6px 20px -4px rgba(17, 24, 39, 0.08);
transition: all 0.2s ease;
}
.editor-ai-assist__bar:focus-within {
border-color: rgba(37, 99, 235, 0.3);
box-shadow:
0 12px 32px -4px rgba(37, 99, 235, 0.12),
0 4px 16px -4px rgba(37, 99, 235, 0.06),
0 0 0 3px rgba(37, 99, 235, 0.1);
}
.editor-ai-assist__bar-icon {
@@ -295,8 +484,8 @@ onBeforeUnmount(() => {
}
.editor-ai-assist__bar-label {
color: #101828;
font-size: 15px;
color: #111827;
font-size: 14px;
font-weight: 700;
white-space: nowrap;
}
@@ -307,44 +496,46 @@ onBeforeUnmount(() => {
border: none;
outline: none;
background: transparent;
color: #344054;
color: #111827;
font-size: 14px;
font-weight: 500;
}
.editor-ai-assist__bar-input::placeholder {
color: #98a2b3;
color: #9ca3af;
font-weight: 400;
}
.editor-ai-assist__bar-actions {
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
}
.editor-ai-assist__divider {
width: 1px;
height: 16px;
margin: 0 4px;
background: #eaecf0;
background: #e5e7eb;
}
.editor-ai-assist__icon-button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
width: 30px;
height: 30px;
border: none;
border-radius: 999px;
border-radius: 8px;
background: transparent;
color: #667085;
color: #6b7280;
cursor: pointer;
transition: all 0.2s ease;
}
.editor-ai-assist__icon-button:hover {
background: #f2f4f7;
color: #344054;
background: #f3f4f6;
color: #111827;
}
.editor-ai-assist__send-button {
@@ -354,42 +545,62 @@ onBeforeUnmount(() => {
width: 32px;
height: 32px;
border: none;
border-radius: 999px;
background: #245bdb;
border-radius: 10px;
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
color: #ffffff;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(36, 91, 219, 0.28);
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.25);
}
.editor-ai-assist__send-button:hover {
background: #1d4fc4;
background: linear-gradient(135deg, #1d4ed8 0%, #1e3a8a 100%);
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.35);
transform: translateY(-1px);
}
.editor-ai-assist__send-button:active {
transform: translateY(0);
}
.editor-ai-assist__result {
width: 100%;
max-height: inherit;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 16px;
background: #ffffff;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(17, 24, 39, 0.08);
box-shadow:
0 8px 32px rgb(17 17 17 / 28%), 0 0 0 1px rgba(15, 23, 42, 0.05);
0 16px 48px -4px rgba(17, 24, 39, 0.12),
0 6px 20px -4px rgba(17, 24, 39, 0.08);
}
.editor-ai-assist__result-box {
flex: 1 1 auto;
display: flex;
min-height: 0;
flex-direction: column;
margin: 12px;
overflow: hidden;
border: 1px solid #eef2f6;
border: 1px solid rgba(17, 24, 39, 0.06);
border-radius: 12px;
background: #f8fafc;
background: #f9fafb;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.02);
}
.editor-ai-assist__result-content {
min-height: 80px;
max-height: 300px;
flex: 1 1 auto;
min-height: min(80px, var(--editor-ai-assist-result-max-height, 80px));
max-height: var(--editor-ai-assist-result-max-height, 300px);
padding: 16px;
overflow-y: auto;
color: #101828;
font-size: 15px;
color: #111827;
font-size: 14px;
line-height: 1.6;
}
@@ -397,7 +608,7 @@ onBeforeUnmount(() => {
display: flex;
align-items: center;
gap: 8px;
color: #667085;
color: #6b7280;
}
.editor-ai-assist__placeholder--error {
@@ -405,13 +616,14 @@ onBeforeUnmount(() => {
}
.editor-ai-assist__result-footer {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
border-top: 1px solid rgba(226, 232, 240, 0.6);
border-top: 1px solid rgba(229, 231, 235, 0.8);
background: rgba(255, 255, 255, 0.5);
color: #94a3b8;
color: #9ca3af;
font-size: 12px;
}
@@ -422,7 +634,7 @@ onBeforeUnmount(() => {
}
.editor-ai-assist__result-badges :deep(svg) {
color: #52c41a;
color: #10b981;
}
.editor-ai-assist__thumb {
@@ -435,6 +647,7 @@ onBeforeUnmount(() => {
}
.editor-ai-assist__toolbar {
flex: 0 0 auto;
display: flex;
flex-direction: column;
padding: 0 16px 16px;
@@ -445,7 +658,7 @@ onBeforeUnmount(() => {
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
color: #64748b;
color: #6b7280;
font-size: 13px;
}
@@ -459,10 +672,11 @@ onBeforeUnmount(() => {
color: inherit;
cursor: pointer;
font-weight: 500;
transition: color 0.2s ease;
}
.editor-ai-assist__back-button:hover {
color: #334155;
color: #111827;
}
.editor-ai-assist__history {
@@ -487,9 +701,16 @@ onBeforeUnmount(() => {
flex: 1;
align-items: center;
padding: 4px 4px 4px 16px;
border: 1px solid #e2e8f0;
border-radius: 20px;
background: #f8fafc;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #ffffff;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.02);
transition: all 0.2s ease;
}
.editor-ai-assist__continue:focus-within {
border-color: rgba(37, 99, 235, 0.4);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.02), 0 0 0 2px rgba(37, 99, 235, 0.1);
}
.editor-ai-assist__continue input {
@@ -497,7 +718,7 @@ onBeforeUnmount(() => {
border: none;
outline: none;
background: transparent;
color: #334155;
color: #111827;
font-size: 13px;
}
@@ -508,10 +729,16 @@ onBeforeUnmount(() => {
width: 28px;
height: 28px;
border: none;
border-radius: 999px;
background: #e8efff;
color: #245bdb;
border-radius: 8px;
background: #eff6ff;
color: #2563eb;
cursor: pointer;
transition: all 0.2s ease;
}
.editor-ai-assist__continue-send:hover {
background: #dbeafe;
color: #1d4ed8;
}
.editor-ai-assist__toolbar-actions {
@@ -525,27 +752,35 @@ onBeforeUnmount(() => {
align-items: center;
gap: 6px;
padding: 6px 14px;
border: none;
border: 1px solid rgba(17, 24, 39, 0.05);
border-radius: 8px;
background: transparent;
color: #475569;
background: #ffffff;
color: #4b5563;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
}
.editor-ai-assist__toolbar-button:hover {
background: #f1f5f9;
background: #f9fafb;
color: #111827;
border-color: rgba(17, 24, 39, 0.1);
}
.editor-ai-assist__toolbar-button--primary {
background: #245bdb;
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
border: none;
color: #ffffff;
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.2);
}
.editor-ai-assist__toolbar-button--primary:hover {
background: #1d4fc4;
background: linear-gradient(135deg, #1d4ed8 0%, #1e3a8a 100%);
color: #ffffff;
box-shadow: 0 4px 10px rgba(37, 99, 235, 0.3);
transform: translateY(-1px);
}
@media (max-width: 768px) {
@@ -555,7 +790,7 @@ onBeforeUnmount(() => {
.editor-ai-assist__bar {
flex-wrap: wrap;
border-radius: 24px;
border-radius: 16px;
}
.editor-ai-assist__bar-input {
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({
const controller = new AbortController();
selectionAiAbortController = controller;
try {
await kolManageApi.streamAssist(
{
mode: "optimize",
current_content: content.value,
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;
+4 -1
View File
@@ -274,7 +274,10 @@ const enUS = {
preview: "Preview form",
aiGenerate: "AI Generate",
aiOptimize: "AI Optimize",
aiPlaceholder: "Describe the prompt effect you want, and AI will generate or optimize it...",
aiPlaceholder: "Describe the prompt effect you want, and AI will generate it...",
aiOptimizePromptPlaceholder: "For example: make this prompt clearer, keep variable placeholders, and reduce ambiguity.",
aiOptimizeDisclaimer: "AI output is for reference only. Review it before replacing the selected prompt text.",
aiOptimizeReplace: "Replace selection",
},
variable: {
input: "Input",
+4 -1
View File
@@ -274,7 +274,10 @@ const zhCN = {
preview: "预览表单",
aiGenerate: "AI 生成",
aiOptimize: "AI 优化",
aiPlaceholder: "描述你想要的 prompt 效果,AI 帮你生成 / 优化...",
aiPlaceholder: "描述你想要的 prompt 效果,AI 帮你生成...",
aiOptimizePromptPlaceholder: "例如:让这段提示词更清晰,保留变量占位符,减少歧义。",
aiOptimizeDisclaimer: "AI 生成内容仅供参考,请确认后再替换选中片段。",
aiOptimizeReplace: "替换选中内容",
},
variable: {
input: "输入框",
@@ -419,7 +419,6 @@ function packageStatusLabel(status: string) {
min-height: 0;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.manage-content--editor {
@@ -115,8 +115,13 @@ func BuildKolAssistUserPrompt(req AssistRequest) (string, error) {
}
schemaJSON = string(payload)
}
instruction := strings.TrimSpace(req.Description)
if instruction == "" {
instruction = "Make the prompt clearer, more complete, and easier for another AI to follow."
}
return fmt.Sprintf(
"Optimize the following prompt. Keep variable placeholders intact. Current schema: %s\n\nPROMPT:\n%s",
"Optimize the following prompt according to the instruction. Keep variable placeholders intact.\n\nInstruction:\n%s\n\nCurrent schema: %s\n\nPROMPT:\n%s",
instruction,
schemaJSON,
req.CurrentContent,
), nil
@@ -72,6 +72,20 @@ func TestParseKolAssistResponseSupportsStringVariables(t *testing.T) {
require.Equal(t, "城市", result.Schema.Variables[0].Key)
}
func TestBuildKolAssistUserPromptIncludesOptimizeInstruction(t *testing.T) {
t.Parallel()
prompt, err := BuildKolAssistUserPrompt(AssistRequest{
Mode: kolAssistModeOptimize,
Description: "让表达更清晰,但保留变量",
CurrentContent: "围绕 {{城市}} 写一段内容",
})
require.NoError(t, err)
require.Contains(t, prompt, "Instruction:\n让表达更清晰,但保留变量")
require.Contains(t, prompt, "PROMPT:\n围绕 {{城市}} 写一段内容")
}
func TestExtractKolAssistContentFromPartialJSON(t *testing.T) {
t.Parallel()