Block Editor增加AI结果弹窗卡片,用于显示翻译和摘要

This commit is contained in:
jingrow 2026-06-14 22:49:52 +08:00
parent dce881eefa
commit 4218ae70ba
3 changed files with 729 additions and 2 deletions

View File

@ -26,6 +26,7 @@ import ColumnsOverlay from './menus/ColumnsOverlay.vue'
import AIInputBar from './menus/AIInputBar.vue'
import AIReviewBar from './menus/AIReviewBar.vue'
import AIReviewPopover from './menus/AIReviewPopover.vue'
import AIResultCard from './menus/AIResultCard.vue'
import ThinkingDots from '@/shared/components/ThinkingDots.vue'
// AI
@ -133,6 +134,7 @@ const {
editorInstanceId,
requestAIAction,
requestAIGeneration,
requestAICardAction,
syncSelectionToAI,
} = useAICommand(editorRef, {
fieldname: computed(() => props.df?.fieldname || ''),
@ -353,6 +355,25 @@ const _handleAudioPicker = (e: Event) => {
// AI
// AI translate / summarize
/** 需要以独立卡片展示的 AI 动作类型 */
const CARD_ACTIONS = new Set(['translate', 'summarize'])
const cardVisible = ref(false)
const cardStyle = ref<Record<string, string>>({})
const cardTitle = ref('')
const cardContent = ref('')
const cardLoading = ref(false)
/** 保存触发卡片时的选区范围,用于 replace / insert-below 操作 */
const cardSelection = ref<{ from: number; to: number } | null>(null)
/** 卡片标题映射 */
const CARD_TITLE_MAP: Record<string, string> = {
translate: 'Translation',
summarize: 'Summary',
}
/** 处理 AI 动作请求(来自 BubbleMenu 或 Slash 命令) */
const _handleAIAction = async (e: Event) => {
const detail = (e as CustomEvent).detail
@ -363,9 +384,58 @@ const _handleAIAction = async (e: Event) => {
const { action, params } = detail
if (!action) return
//
const editor = editorRef.value
const selectedText = editor && !editor.state.selection.empty
if (!editor) return
// translate / summarize
if (CARD_ACTIONS.has(action)) {
const selectedText = !editor.state.selection.empty
? editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to, ' ', '\n')
: ''
if (!selectedText) return
//
const { from, to } = editor.state.selection
cardSelection.value = { from, to }
cardTitle.value = CARD_TITLE_MAP[action] || 'Result'
cardContent.value = ''
cardLoading.value = true
//
try {
const coords = editor.view.coordsAtPos(to)
cardStyle.value = {
position: 'fixed',
top: `${coords.bottom + 4}px`,
left: `${Math.max(coords.left, 16)}px`,
}
} catch {
cardStyle.value = {}
}
cardVisible.value = true
await requestAICardAction(action, params, {
onChunk: (chunk) => {
cardContent.value += chunk
},
onDone: () => {
cardLoading.value = false
},
onError: (msg) => {
cardLoading.value = false
console.error('[BE AI Card]', msg)
},
onThinkingStart: () => { cardLoading.value = true },
onThinkingEnd: () => { /* streaming 开始后 loading 仍为 true 直到 onDone */ },
})
return
}
// AI improve / extend / shorten
//
const selectedText = !editor.state.selection.empty
? editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to, ' ', '\n')
: ''
@ -533,6 +603,32 @@ function handleAICancel() {
editorRef.value?.chain().focus().run()
}
//
/** 用卡片结果替换选中文本 */
function handleCardReplace() {
const editor = editorRef.value
const sel = cardSelection.value
if (!editor || !sel || !cardContent.value) return
editor.chain().focus().deleteRange({ from: sel.from, to: sel.to }).insertContent(cardContent.value).run()
cardVisible.value = false
}
/** 在选区后插入卡片结果 */
function handleCardInsertBelow() {
const editor = editorRef.value
const sel = cardSelection.value
if (!editor || !sel || !cardContent.value) return
editor.chain().focus().insertContentAt(sel.to, '\n' + cardContent.value).run()
cardVisible.value = false
}
/** 关闭卡片 */
function handleCardDismiss() {
cardVisible.value = false
editorRef.value?.chain().focus().run()
}
// Tab/Esc
function handleReviewKeydown(e: KeyboardEvent) {
@ -689,6 +785,18 @@ onBeforeUnmount(() => {
@reject="handlePopoverReject"
@close="reviewClearActiveSegment"
/>
<!-- AI 结果卡片translate / summarize -->
<AIResultCard
:visible="cardVisible"
:title="cardTitle"
:content="cardContent"
:loading="cardLoading"
:style="cardStyle"
:t="t"
@replace="handleCardReplace"
@insert-below="handleCardInsertBelow"
@dismiss="handleCardDismiss"
/>
<!-- Toolbar Toggle -->
<button
v-if="editorRef && isEditable"

View File

@ -214,12 +214,159 @@ export function useAICommand(
})
}
// ── 卡片模式 AI 动作(不写入编辑器,流式输出到回调) ────────────────────
/** 卡片模式回调接口 */
interface AICardCallbacks {
/** 每个 SSE delta chunk */
onChunk: (chunk: string) => void
/** 流式完成 */
onDone: (fullContent: string) => void
/** 出错 */
onError: (error: string) => void
/** 请求发出后触发(显示 thinking */
onThinkingStart?: () => void
/** 首个 chunk 到达或出错时触发 */
onThinkingEnd?: () => void
}
/**
* AI SSE
* translate / summarize "参考信息"
*/
async function requestAICardAction(
action: AIActionType,
params?: Record<string, string>,
callbacks?: AICardCallbacks,
): Promise<void> {
const editor = editorRef.value
if (!editor) return
const selectedText = getSelectedText()
if (!selectedText) {
callbacks?.onError?.('请先选中要处理的文本')
return
}
const config = AI_ACTION_REGISTRY.find(a => a.id === action)
if (!config) {
callbacks?.onError?.(`未知的 AI 动作: ${action}`)
return
}
// 构建提示词
let prompt = config.prompt
if (action === 'translate' && params?.target_lang) {
prompt = prompt.replace('{target_lang}', params.target_lang)
}
const fullPrompt = prompt + selectedText
const fullContent = JSON.stringify(editor.getJSON())
// Thinking 指示器
let thinkingActive = false
function startThinking() {
thinkingActive = true
callbacks?.onThinkingStart?.()
}
function endThinking() {
if (!thinkingActive) return
thinkingActive = false
callbacks?.onThinkingEnd?.()
}
try {
startThinking()
const response = await fetch('/api/action/jingrow.ai.api.editor_ai.block_editor_action', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: fullPrompt,
action,
context: {
selectedText,
fullContent,
fieldname: unref(fieldContext?.fieldname) || '',
pagetype: unref(fieldContext?.pagetype) || '',
pagename: unref(fieldContext?.pagename) || '',
},
}),
})
if (!response.ok) {
let errMsg = `AI 请求失败 (${response.status})`
try {
const errData = await response.json()
errMsg = errData?.message || errMsg
} catch { /* ignore */ }
endThinking()
callbacks?.onError?.(errMsg)
return
}
const reader = response.body!.getReader()
const decoder = new TextDecoder()
let buffer = ''
let accumulated = ''
let currentEvent = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) {
currentEvent = ''
continue
}
if (trimmed.startsWith('event: ')) {
currentEvent = trimmed.slice(7)
continue
}
if (!trimmed.startsWith('data: ')) continue
const dataStr = trimmed.slice(6)
try {
const parsed = JSON.parse(dataStr)
if (currentEvent === 'delta' || parsed.delta) {
const chunk = parsed.delta || ''
accumulated += chunk
// 首个 chunk结束 thinking
endThinking()
callbacks?.onChunk?.(chunk)
} else if (currentEvent === 'error' || parsed.error) {
endThinking()
callbacks?.onError?.(parsed.error || 'AI 处理出错')
}
} catch { /* ignore non-JSON lines */ }
}
}
endThinking()
if (accumulated) {
callbacks?.onDone?.(accumulated)
}
} catch (e: any) {
endThinking()
callbacks?.onError?.(e.message || 'AI 调用异常')
}
}
return {
editorInstanceId,
getSelectedText,
getContextBefore,
requestAIAction,
requestAIGeneration,
requestAICardAction,
syncSelectionToAI,
getAIActions,
getAIActionsByGroup,

View File

@ -0,0 +1,472 @@
<script setup lang="ts">
/**
* AIResultCard.vue Notion 风格独立结果卡片
*
* 用于 translate / summarize "参考信息" AI 动作
* 原文保持不变结果在独立浮动卡片中流式展示
* 用户可自行选择替换选区插入到下方复制关闭
*/
import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue'
import ThinkingDots from '@/shared/components/ThinkingDots.vue'
const props = withDefaults(defineProps<{
/** 是否显示 */
visible?: boolean
/** 卡片标题(如 "Translation" / "Summary" */
title?: string
/** 流式内容(实时更新) */
content?: string
/** 是否正在加载thinking 或 streaming 中) */
loading?: boolean
/** 卡片的固定定位样式 */
style?: Record<string, string>
/** 国际化函数 */
t?: (key: string) => string
}>(), {
visible: false,
title: 'Result',
content: '',
loading: false,
style: () => ({}),
t: (key: string) => key,
})
const emit = defineEmits<{
/** 用结果替换选中文本 */
replace: []
/** 在选区后插入新段落 */
'insert-below': []
/** 复制到剪贴板 */
copy: []
/** 关闭卡片 */
dismiss: []
}>()
//
const cardRef = ref<HTMLElement | null>(null)
const copied = ref(false)
// Markdown ////
const renderedHTML = computed(() => {
if (!props.content) return ''
return simpleMarkdownToHTML(props.content)
})
function simpleMarkdownToHTML(text: string): string {
const lines = text.split('\n')
const html: string[] = []
let inUL = false
let inOL = false
for (const line of lines) {
const trimmed = line.trim()
// Close lists if line doesn't continue them
if (inUL && !/^[-*]\s/.test(trimmed)) {
html.push('</ul>')
inUL = false
}
if (inOL && !/^\d+\.\s/.test(trimmed)) {
html.push('</ol>')
inOL = false
}
if (!trimmed) {
html.push('<br/>')
continue
}
// Unordered list
const ulMatch = trimmed.match(/^[-*]\s+(.+)$/)
if (ulMatch) {
if (!inUL) { html.push('<ul>'); inUL = true }
html.push(`<li>${inlineFormat(ulMatch[1])}</li>`)
continue
}
// Ordered list
const olMatch = trimmed.match(/^\d+\.\s+(.+)$/)
if (olMatch) {
if (!inOL) { html.push('<ol>'); inOL = true }
html.push(`<li>${inlineFormat(olMatch[1])}</li>`)
continue
}
// Normal paragraph
html.push(`<p>${inlineFormat(trimmed)}</p>`)
}
if (inUL) html.push('</ul>')
if (inOL) html.push('</ol>')
return html.join('')
}
/** 行内格式bold, italic, inline code */
function inlineFormat(text: string): string {
return text
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
}
//
async function handleCopy() {
try {
await navigator.clipboard.writeText(props.content)
copied.value = true
setTimeout(() => { copied.value = false }, 1500)
} catch {
// fallback
const ta = document.createElement('textarea')
ta.value = props.content
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
copied.value = true
setTimeout(() => { copied.value = false }, 1500)
}
}
//
const adjustedStyle = ref<Record<string, string>>({})
watch(() => props.visible, async (visible) => {
if (!visible) {
adjustedStyle.value = {}
return
}
await nextTick()
const el = cardRef.value
if (!el) {
adjustedStyle.value = { ...props.style }
return
}
//
adjustedStyle.value = {
position: 'fixed',
top: '0px',
left: '-9999px',
opacity: '0',
pointerEvents: 'none',
}
await nextTick()
const elW = el.offsetWidth
const elH = el.offsetHeight
const PAD = 16
const vw = window.innerWidth
const vh = window.innerHeight
const anchorTop = parseFloat(props.style.top) || 0
const anchorLeft = parseFloat(props.style.left) || 0
//
let left = anchorLeft
if (left + elW > vw - PAD) left = vw - PAD - elW
if (left < PAD) left = PAD
//
let top = anchorTop + 8
if (top + elH > vh - PAD) top = anchorTop - elH - 8
if (top < PAD) top = PAD
adjustedStyle.value = {
position: 'fixed',
top: `${top}px`,
left: `${left}px`,
}
})
//
function handleMousedown(e: MouseEvent) {
if (!props.visible) return
const el = cardRef.value
if (!el) return
const target = e.target as Node
if (!el.contains(target)) {
emit('dismiss')
}
}
document.addEventListener('mousedown', handleMousedown, true)
onBeforeUnmount(() => {
document.removeEventListener('mousedown', handleMousedown, true)
})
// Esc
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && props.visible) {
emit('dismiss')
}
}
document.addEventListener('keydown', handleKeydown)
onBeforeUnmount(() => {
document.removeEventListener('keydown', handleKeydown)
})
</script>
<template>
<Teleport to="body">
<Transition name="ai-card">
<div
v-if="visible"
ref="cardRef"
class="ai-result-card"
:style="adjustedStyle"
>
<!-- 头部标题 + 关闭按钮 -->
<div class="arc-header">
<div class="arc-title">
<svg class="arc-title-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
{{ t(title) }}
</div>
<button
class="arc-close"
:title="t('关闭')"
@mousedown.prevent="$emit('dismiss')"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<!-- 内容区流式文本 -->
<div class="arc-body">
<!-- Thinking 状态 -->
<div v-if="loading && !content" class="arc-thinking">
<ThinkingDots :dot-size="5" :gap="3" color="#8b5cf6" />
</div>
<!-- 流式内容 -->
<div
v-else
class="arc-content"
v-html="renderedHTML"
/>
<!-- Streaming 中追加 thinking -->
<div v-if="loading && content" class="arc-streaming-dots">
<ThinkingDots :dot-size="4" :gap="2" color="#9ca3af" :duration="1" />
</div>
</div>
<!-- 操作按钮栏 -->
<div v-if="content" class="arc-actions">
<button
class="arc-btn arc-btn-secondary"
:title="t('替换选中文本')"
@mousedown.prevent="$emit('replace')"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
</svg>
{{ t('替换') }}
</button>
<button
class="arc-btn arc-btn-secondary"
:title="t('插入到下方')"
@mousedown.prevent="$emit('insert-below')"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19" />
<polyline points="19 12 12 19 5 12" />
</svg>
{{ t('插入下方') }}
</button>
<button
class="arc-btn arc-btn-secondary"
:title="t('复制')"
@mousedown.prevent="handleCopy"
>
<svg v-if="!copied" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#16a34a" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
{{ copied ? t('已复制') : t('复制') }}
</button>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
/* ── 卡片容器 ── */
.ai-result-card {
position: fixed;
z-index: 1000;
width: clamp(320px, 44vw, 480px);
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px;
box-shadow: 0 8px 30px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.04);
padding: 14px;
animation: ai-card-in 0.2s ease-out both;
}
@keyframes ai-card-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ── 头部 ── */
.arc-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.arc-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: #374151;
}
.arc-title-icon {
color: #8b5cf6;
flex-shrink: 0;
}
.arc-close {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
color: #9ca3af;
cursor: pointer;
transition: all 0.12s ease;
}
.arc-close:hover {
background: #f3f4f6;
color: #374151;
}
/* ── 内容区 ── */
.arc-body {
max-height: 320px;
overflow-y: auto;
margin-bottom: 10px;
padding: 10px 12px;
background: #f9fafb;
border-radius: 8px;
border: 1px solid #f3f4f6;
}
.arc-thinking {
display: flex;
align-items: center;
justify-content: center;
min-height: 48px;
}
.arc-content {
font-size: 14px;
line-height: 1.65;
color: #1f2937;
word-break: break-word;
}
.arc-content :deep(p) {
margin: 0 0 6px;
}
.arc-content :deep(p:last-child) {
margin-bottom: 0;
}
.arc-content :deep(ul),
.arc-content :deep(ol) {
padding-left: 20px;
margin: 4px 0;
}
.arc-content :deep(li) {
margin: 2px 0;
}
.arc-content :deep(code) {
background: #f1f5f9;
border-radius: 3px;
padding: 1px 5px;
font-size: 0.875em;
color: #cf222e;
}
.arc-content :deep(strong) {
font-weight: 600;
}
.arc-streaming-dots {
display: inline-flex;
margin-left: 4px;
vertical-align: middle;
}
/* ── 操作按钮栏 ── */
.arc-actions {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.arc-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 12px;
border: 1px solid transparent;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: all 0.12s ease;
line-height: 1;
}
.arc-btn:active {
transform: scale(0.96);
}
.arc-btn-secondary {
background: #f3f4f6;
border-color: #e5e7eb;
color: #374151;
}
.arc-btn-secondary:hover {
background: #e5e7eb;
border-color: #d1d5db;
}
/* ── 过渡动画 ── */
.ai-card-enter-active,
.ai-card-leave-active {
transition: opacity 0.18s ease, transform 0.18s ease;
}
.ai-card-enter-from,
.ai-card-leave-to {
opacity: 0;
transform: translateY(6px);
}
</style>