From 2846968cf78d9e32bfeeefe1e466cca652d262d8 Mon Sep 17 00:00:00 2001 From: liangxu Date: Sat, 25 Apr 2026 22:41:40 +0800 Subject: [PATCH] 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) --- .../src/components/ArticleEditorCanvas.vue | 19 +- .../editor-common/EditorAiAssistPanel.vue | 351 ++++++-- .../src/components/kol/KolPromptEditArea.vue | 836 +++++++++++++++++- .../src/components/kol/KolPromptEditor.vue | 302 ++++++- apps/admin-web/src/i18n/messages/en-US.ts | 5 +- apps/admin-web/src/i18n/messages/zh-CN.ts | 5 +- apps/admin-web/src/views/KolManageView.vue | 1 - .../internal/tenant/app/kol_assist_runtime.go | 7 +- .../tenant/app/kol_assist_runtime_test.go | 14 + 9 files changed, 1428 insertions(+), 112 deletions(-) diff --git a/apps/admin-web/src/components/ArticleEditorCanvas.vue b/apps/admin-web/src/components/ArticleEditorCanvas.vue index 426dad4..a1cebfd 100644 --- a/apps/admin-web/src/components/ArticleEditorCanvas.vue +++ b/apps/admin-web/src/components/ArticleEditorCanvas.vue @@ -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" diff --git a/apps/admin-web/src/components/editor-common/EditorAiAssistPanel.vue b/apps/admin-web/src/components/editor-common/EditorAiAssistPanel.vue index af4239a..95c0cb4 100644 --- a/apps/admin-web/src/components/editor-common/EditorAiAssistPanel.vue +++ b/apps/admin-web/src/components/editor-common/EditorAiAssistPanel.vue @@ -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(null); +const resultContentRef = ref(null); const promptInputRef = ref(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(() => ({ 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; + } }); @@ -179,7 +352,7 @@ onBeforeUnmount(() => {
-
+
@@ -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 { diff --git a/apps/admin-web/src/components/kol/KolPromptEditArea.vue b/apps/admin-web/src/components/kol/KolPromptEditArea.vue index 9034a23..8d40921 100644 --- a/apps/admin-web/src/components/kol/KolPromptEditArea.vue +++ b/apps/admin-web/src/components/kol/KolPromptEditArea.vue @@ -1,5 +1,5 @@ {{ t('kol.manage.editor.aiGenerate') }} - - {{ t('kol.manage.editor.aiOptimize') }} - AI 正在处理中...
-
+
+
+ + +