feat(M4): 细节打磨——可拖拽/缩放 AI 浮窗、弹窗动画、全局快捷键、翻译功能

- AI 助手改为可自由拖拽移动的浮窗(position: fixed,默认右下角)
- 支持 8 方向拖拽缩放(N/S/E/W + 四角),尺寸 280–720 × 320–900px
- 弹窗(ModelSettingsModal、AiResultModal)加入 framer-motion 缩放动画
- App.tsx 注册全局快捷键:Cmd+\ 专注模式、Cmd+Shift+J AI 面板、Cmd+N 新建笔记
- 浮动工具栏新增"翻译成英文"按钮(Languages 图标),流式预览后可替换
- 服务端 buildMessages 增加 translate 分支
- AI 面板 textarea 随输入自动扩展高度(最多 5 行)
- 欢迎页功能一览补充 AI 续写/润色/摘要/问答四卡片及三条快捷键

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MikiVL 2026-05-03 00:28:01 +08:00
parent d632e9f6f7
commit aa1430d4bf
8 changed files with 249 additions and 78 deletions

View File

@ -104,7 +104,7 @@ app.delete('/api/models/:id', (c) => {
// ── AI types ──────────────────────────────────────────────────────────────────
type AIStreamRequest = {
type: 'continue' | 'polish' | 'summarize' | 'chat'
type: 'continue' | 'polish' | 'summarize' | 'translate' | 'chat'
noteContent: string
selection?: string
messages?: { role: 'user' | 'assistant'; content: string }[]
@ -131,6 +131,10 @@ function buildMessages(req: AIStreamRequest): Anthropic.MessageParam[] {
return [{ role: 'user', content: `请润色以下文字,改善语法、流畅度和表达,保持原意,直接输出润色后的结果,不要有任何解释:\n\n${selection ?? noteContent}` }]
}
if (type === 'translate') {
return [{ role: 'user', content: `请将以下文字翻译成英文,保持原文风格和格式,直接输出翻译结果,不要有任何解释:\n\n${selection ?? noteContent}` }]
}
return [{ role: 'user', content: `请为以下内容提炼要点摘要,用简洁的中文输出,不超过 150 字:\n\n${selection ?? noteContent}` }]
}

View File

@ -6,7 +6,7 @@ import { useAppStore } from './stores/appStore'
import { seedIfEmpty, deduplicateDB } from './db'
export default function App() {
const { loadAll, theme, focusMode, aiPanelOpen } = useAppStore()
const { loadAll, theme, focusMode, aiPanelOpen, toggleFocusMode, toggleAiPanel, createNote, activeFolderId } = useAppStore()
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
@ -18,13 +18,24 @@ export default function App() {
.then(() => loadAll())
}, [])
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const mod = e.metaKey || e.ctrlKey
if (mod && e.key === '\\') { e.preventDefault(); toggleFocusMode() }
if (mod && e.shiftKey && e.key === 'J') { e.preventDefault(); toggleAiPanel() }
if (mod && e.key === 'n') { e.preventDefault(); createNote(activeFolderId as string | null) }
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [toggleFocusMode, toggleAiPanel, createNote, activeFolderId])
return (
<div className="flex h-screen w-full overflow-hidden" style={{ background: 'var(--bg)' }}>
<div className={`sidebar-panel${focusMode ? ' hidden' : ''}`} style={{ width: 260 }}>
<Sidebar />
</div>
<Editor />
{aiPanelOpen && !focusMode && <AiPanel />}
{aiPanelOpen && <AiPanel />}
</div>
)
}

View File

@ -1,10 +1,30 @@
import { useState, useRef, useEffect } from 'react'
import { X, Send, Sparkles, Square, FileText } from 'lucide-react'
import { useState, useRef, useEffect, useCallback } from 'react'
import { X, Send, Sparkles, Square, FileText, GripHorizontal } from 'lucide-react'
import { useAppStore } from '../../stores/appStore'
import { streamAI } from '../../lib/ai'
import { extractTextFromJSON } from '../../lib/utils'
type Message = { role: 'user' | 'assistant'; content: string }
type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
const DEFAULT_W = 360
const DEFAULT_H = 520
const MIN_W = 280
const MIN_H = 320
const MAX_W = 720
const MAX_H = 900
const HANDLE = 6 // resize handle thickness px
const RESIZE_HANDLES: { dir: ResizeDir; cursor: string; style: React.CSSProperties }[] = [
{ dir: 'n', cursor: 'ns-resize', style: { top: 0, left: HANDLE, right: HANDLE, height: HANDLE } },
{ dir: 's', cursor: 'ns-resize', style: { bottom: 0, left: HANDLE, right: HANDLE, height: HANDLE } },
{ dir: 'e', cursor: 'ew-resize', style: { right: 0, top: HANDLE, bottom: HANDLE, width: HANDLE } },
{ dir: 'w', cursor: 'ew-resize', style: { left: 0, top: HANDLE, bottom: HANDLE, width: HANDLE } },
{ dir: 'nw', cursor: 'nwse-resize', style: { top: 0, left: 0, width: HANDLE * 2, height: HANDLE * 2 } },
{ dir: 'ne', cursor: 'nesw-resize', style: { top: 0, right: 0, width: HANDLE * 2, height: HANDLE * 2 } },
{ dir: 'sw', cursor: 'nesw-resize', style: { bottom: 0, left: 0, width: HANDLE * 2, height: HANDLE * 2 } },
{ dir: 'se', cursor: 'nwse-resize', style: { bottom: 0, right: 0, width: HANDLE * 2, height: HANDLE * 2 } },
]
export function AiPanel() {
const { toggleAiPanel, notes, activeNoteId } = useAppStore()
@ -17,10 +37,75 @@ export function AiPanel() {
const bottomRef = useRef<HTMLDivElement | null>(null)
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
const [pos, setPos] = useState(() => ({
x: window.innerWidth - DEFAULT_W - 24,
y: window.innerHeight - DEFAULT_H - 24,
}))
const [size, setSize] = useState({ w: DEFAULT_W, h: DEFAULT_H })
// refs for drag/resize so callbacks don't go stale
const posRef = useRef(pos)
const sizeRef = useRef(size)
useEffect(() => { posRef.current = pos }, [pos])
useEffect(() => { sizeRef.current = size }, [size])
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
const onDragStart = useCallback((e: React.MouseEvent) => {
e.preventDefault()
const startX = e.clientX, startY = e.clientY
const { x: origX, y: origY } = posRef.current
const { w, h } = sizeRef.current
const onMove = (ev: MouseEvent) => {
const nextX = Math.max(0, Math.min(window.innerWidth - w, origX + ev.clientX - startX))
const nextY = Math.max(0, Math.min(window.innerHeight - h, origY + ev.clientY - startY))
setPos({ x: nextX, y: nextY })
}
const onUp = () => {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}, [])
const onResizeStart = useCallback((e: React.MouseEvent, dir: ResizeDir) => {
e.preventDefault()
e.stopPropagation()
const startX = e.clientX, startY = e.clientY
const { x: origX, y: origY } = posRef.current
const { w: origW, h: origH } = sizeRef.current
const onMove = (ev: MouseEvent) => {
const dx = ev.clientX - startX
const dy = ev.clientY - startY
let newX = origX, newY = origY, newW = origW, newH = origH
if (dir.includes('e')) newW = Math.max(MIN_W, Math.min(MAX_W, origW + dx))
if (dir.includes('s')) newH = Math.max(MIN_H, Math.min(MAX_H, origH + dy))
if (dir.includes('w')) {
newW = Math.max(MIN_W, Math.min(MAX_W, origW - dx))
newX = origX + origW - newW
}
if (dir.includes('n')) {
newH = Math.max(MIN_H, Math.min(MAX_H, origH - dy))
newY = origY + origH - newH
}
setPos({ x: Math.max(0, newX), y: Math.max(0, newY) })
setSize({ w: newW, h: newH })
}
const onUp = () => {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}, [])
const noteContent = activeNote
? `标题:${activeNote.title}\n\n${extractTextFromJSON(activeNote.content)}`
: ''
@ -29,29 +114,20 @@ export function AiPanel() {
if (!text.trim() || streaming) return
const userMsg: Message = { role: 'user', content: text.trim() }
const assistantMsg: Message = { role: 'assistant', content: '' }
setMessages(prev => [...prev, userMsg, assistantMsg])
setInput('')
if (textareaRef.current) textareaRef.current.style.height = 'auto'
setStreaming(true)
const controller = new AbortController()
abortRef.current = controller
try {
await streamAI(
{
type: 'chat',
noteContent,
messages: messages.concat(userMsg),
userMessage: userMsg.content,
},
{ type: 'chat', noteContent, messages: messages.concat(userMsg), userMessage: userMsg.content },
(chunk) => {
setMessages(prev => {
const next = [...prev]
next[next.length - 1] = {
...next[next.length - 1],
content: next[next.length - 1].content + chunk,
}
next[next.length - 1] = { ...next[next.length - 1], content: next[next.length - 1].content + chunk }
return next
})
},
@ -71,30 +147,55 @@ export function AiPanel() {
}
}
const summarize = () => {
if (!noteContent) return
send('请为当前笔记生成一份简洁的摘要。')
}
const stop = () => {
abortRef.current?.abort()
}
return (
<div
className="flex flex-col h-full shrink-0"
style={{ width: 320, borderLeft: '1px solid var(--border)', background: 'var(--bg-subtle)' }}
style={{
position: 'fixed',
left: pos.x,
top: pos.y,
width: size.w,
height: size.h,
zIndex: 200,
display: 'flex',
flexDirection: 'column',
background: 'var(--bg)',
border: '1px solid var(--border)',
borderRadius: 16,
boxShadow: '0 8px 40px rgba(0,0,0,0.18)',
overflow: 'hidden',
}}
>
{/* Header */}
{/* Resize handles */}
{RESIZE_HANDLES.map(({ dir, cursor, style }) => (
<div
key={dir}
onMouseDown={e => onResizeStart(e, dir)}
style={{ position: 'absolute', zIndex: 10, cursor, ...style }}
/>
))}
{/* Header / drag handle */}
<div
onMouseDown={onDragStart}
className="flex items-center justify-between px-4 py-3 shrink-0"
style={{ borderBottom: '1px solid var(--border)' }}
style={{
borderBottom: '1px solid var(--border)',
cursor: 'grab',
userSelect: 'none',
background: 'var(--bg-subtle)',
}}
>
<div className="flex items-center gap-2">
<Sparkles size={15} style={{ color: 'var(--accent)' }} />
<GripHorizontal size={13} style={{ color: 'var(--text-faint)' }} />
<Sparkles size={14} style={{ color: 'var(--accent)' }} />
<span className="font-semibold text-sm" style={{ color: 'var(--text)' }}>AI </span>
</div>
<button onClick={toggleAiPanel} className="toolbar-btn" title="关闭">
<button
onMouseDown={e => e.stopPropagation()}
onClick={toggleAiPanel}
className="toolbar-btn"
title="关闭"
>
<X size={15} />
</button>
</div>
@ -102,9 +203,9 @@ export function AiPanel() {
{/* Quick actions */}
<div className="px-3 py-2 shrink-0" style={{ borderBottom: '1px solid var(--border)' }}>
<button
onClick={summarize}
onClick={() => send('请为当前笔记生成一份简洁的摘要。')}
disabled={!activeNote || streaming}
className="w-full flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm transition-colors"
className="w-full flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm"
style={{
background: 'var(--bg-muted)',
border: '1px solid var(--border)',
@ -158,13 +259,17 @@ export function AiPanel() {
<textarea
ref={textareaRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
send(input)
onChange={e => {
setInput(e.target.value)
const el = textareaRef.current
if (el) {
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
}
}}
onKeyDown={e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(input) }
}}
placeholder={activeNote ? '向 AI 提问…' : '请先打开笔记'}
disabled={!activeNote}
rows={1}
@ -175,12 +280,15 @@ export function AiPanel() {
color: 'var(--text)',
maxHeight: 120,
lineHeight: 1.5,
overflow: 'auto',
}}
onFocus={e => (e.currentTarget.style.borderColor = 'var(--accent)')}
onBlur={e => (e.currentTarget.style.borderColor = 'var(--border)')}
/>
{streaming ? (
<button
onClick={stop}
className="shrink-0 flex items-center justify-center w-8 h-8 rounded-xl transition-colors"
onClick={() => abortRef.current?.abort()}
className="shrink-0 flex items-center justify-center w-8 h-8 rounded-xl"
style={{ background: '#ef4444', color: '#fff' }}
title="停止"
>
@ -190,7 +298,7 @@ export function AiPanel() {
<button
onClick={() => send(input)}
disabled={!input.trim() || !activeNote}
className="shrink-0 flex items-center justify-center w-8 h-8 rounded-xl transition-colors"
className="shrink-0 flex items-center justify-center w-8 h-8 rounded-xl"
style={{
background: input.trim() && activeNote ? 'var(--accent)' : 'var(--bg-muted)',
color: input.trim() && activeNote ? '#fff' : 'var(--text-faint)',

View File

@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'
import { motion } from 'framer-motion'
import { X, Plus, Check, Trash2, Bot, ChevronDown, ChevronUp } from 'lucide-react'
type ModelInfo = {
@ -67,15 +68,23 @@ export function ModelSettingsModal({ onClose }: { onClose: () => void }) {
const activeModel = models.find(m => m.isActive)
return (
<div
<motion.div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.45)' }}
onClick={onClose}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
>
<div
<motion.div
className="rounded-2xl shadow-2xl flex flex-col"
style={{ background: 'var(--bg)', border: '1px solid var(--border)', width: 480, maxHeight: '80vh' }}
onClick={e => e.stopPropagation()}
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
transition={{ duration: 0.18, ease: [0.4, 0, 0.2, 1] }}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 shrink-0" style={{ borderBottom: '1px solid var(--border)' }}>
@ -210,7 +219,7 @@ export function ModelSettingsModal({ onClose }: { onClose: () => void }) {
</div>
)}
</div>
</div>
</div>
</motion.div>
</motion.div>
)
}

View File

@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { useEditor, EditorContent } from '@tiptap/react'
import { BubbleMenu } from '@tiptap/extension-bubble-menu'
import StarterKit from '@tiptap/starter-kit'
@ -23,7 +24,7 @@ import {
Highlighter, List, ListOrdered, Quote,
Heading1, Heading2, Heading3, Minus, CheckSquare, Table as TableIcon,
Type, Star, ImageIcon, Maximize2, Minimize2, Hash, X,
Sparkles, Zap, Wand2, FileText as FileTextIcon, Square,
Sparkles, Zap, Wand2, FileText as FileTextIcon, Square, Languages,
} from 'lucide-react'
import { useAppStore } from '../../stores/appStore'
import { countWords } from '../../lib/utils'
@ -92,7 +93,7 @@ export function Editor() {
// AI modal state
const [aiModal, setAiModal] = useState<{
type: 'polish' | 'summarize'
type: 'polish' | 'summarize' | 'translate'
from: number
to: number
selection: string
@ -316,7 +317,7 @@ export function Editor() {
}
}
const handleAiAction = async (type: 'polish' | 'summarize', from: number, to: number) => {
const handleAiAction = async (type: 'polish' | 'summarize' | 'translate', from: number, to: number) => {
if (!editor || !activeNote) return
const selection = editor.state.doc.textBetween(from, to, '\n')
const controller = new AbortController()
@ -440,7 +441,7 @@ export function Editor() {
{/* Floating toolbar */}
{editor && (
<div style={{ position: 'fixed', zIndex: 100, pointerEvents: 'none' }}>
<FloatingToolbar editor={editor} onPolish={handleAiAction.bind(null, 'polish')} onSummarize={handleAiAction.bind(null, 'summarize')} />
<FloatingToolbar editor={editor} onPolish={handleAiAction.bind(null, 'polish')} onSummarize={handleAiAction.bind(null, 'summarize')} onTranslate={handleAiAction.bind(null, 'translate')} />
</div>
)}
@ -458,6 +459,7 @@ export function Editor() {
</div>
{/* AI Result Modal */}
<AnimatePresence>
{aiModal && (
<AiResultModal
type={aiModal.type}
@ -475,6 +477,7 @@ export function Editor() {
}}
/>
)}
</AnimatePresence>
{/* Footer */}
<div
@ -490,10 +493,11 @@ export function Editor() {
)
}
function FloatingToolbar({ editor, onPolish, onSummarize }: {
function FloatingToolbar({ editor, onPolish, onSummarize, onTranslate }: {
editor: NonNullable<ReturnType<typeof useEditor>>
onPolish: (from: number, to: number) => void
onSummarize: (from: number, to: number) => void
onTranslate: (from: number, to: number) => void
}) {
const [visible, setVisible] = useState(false)
const [selRange, setSelRange] = useState({ from: 0, to: 0 })
@ -536,6 +540,7 @@ function FloatingToolbar({ editor, onPolish, onSummarize }: {
<div className="toolbar-divider" />
<ToolbarBtn title="AI 润色" active={false} onClick={() => onPolish(selRange.from, selRange.to)}><Wand2 size={13} style={{ color: 'var(--accent)' }} /></ToolbarBtn>
<ToolbarBtn title="AI 摘要" active={false} onClick={() => onSummarize(selRange.from, selRange.to)}><FileTextIcon size={13} style={{ color: 'var(--accent)' }} /></ToolbarBtn>
<ToolbarBtn title="翻译成英文" active={false} onClick={() => onTranslate(selRange.from, selRange.to)}><Languages size={13} style={{ color: 'var(--accent)' }} /></ToolbarBtn>
</div>
)
}
@ -551,28 +556,36 @@ function ToolbarBtn({ children, active, title, onClick }: {
}
function AiResultModal({ type, result, streaming, onInsert, onDiscard }: {
type: 'polish' | 'summarize'
type: 'polish' | 'summarize' | 'translate'
result: string
streaming: boolean
onInsert: () => void
onDiscard: () => void
}) {
return (
<div
<motion.div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.4)' }}
onClick={onDiscard}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
>
<div
<motion.div
className="rounded-2xl shadow-2xl flex flex-col"
style={{ background: 'var(--bg)', border: '1px solid var(--border)', width: 520, maxHeight: '70vh' }}
onClick={e => e.stopPropagation()}
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
transition={{ duration: 0.18, ease: [0.4, 0, 0.2, 1] }}
>
<div className="flex items-center justify-between px-5 py-3 shrink-0" style={{ borderBottom: '1px solid var(--border)' }}>
<div className="flex items-center gap-2">
<Sparkles size={14} style={{ color: 'var(--accent)' }} />
<span className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
{type === 'polish' ? 'AI 润色结果' : 'AI 摘要结果'}
{type === 'polish' ? 'AI 润色结果' : type === 'translate' ? 'AI 翻译结果' : 'AI 摘要结果'}
</span>
</div>
<button onClick={onDiscard} className="toolbar-btn"><X size={14} /></button>
@ -593,10 +606,10 @@ function AiResultModal({ type, result, streaming, onInsert, onDiscard }: {
className="px-3 py-1.5 rounded-lg text-sm font-medium"
style={{ background: result && !streaming ? 'var(--accent)' : 'var(--bg-muted)', color: result && !streaming ? '#fff' : 'var(--text-faint)' }}
>
{type === 'polish' ? '替换选中内容' : '插入摘要'}
{type === 'polish' ? '替换选中内容' : type === 'translate' ? '替换选中内容' : '插入摘要'}
</button>
</div>
</div>
</div>
</motion.div>
</motion.div>
)
}

View File

@ -2,7 +2,7 @@ import { useState } from 'react'
import {
FileText, Folder, Slash, MousePointer, Save,
Search, Moon, Plus, Hash, Star, Image, Code2,
BookOpen, Maximize2,
BookOpen, Maximize2, Zap, Wand2, Bot,
} from 'lucide-react'
import { useAppStore } from '../../stores/appStore'
@ -72,6 +72,26 @@ const FEATURES = [
title: '深色模式',
desc: '点击左上角图标一键切换亮色 / 暗色主题',
},
{
icon: <Zap size={18} />,
title: 'AI 续写',
desc: '点击标题旁 ⚡ 按钮AI 基于当前上下文流式续写下一段',
},
{
icon: <Wand2 size={18} />,
title: 'AI 润色',
desc: '选中文字 → 浮动工具栏 → 润色,流式预览后一键替换原文',
},
{
icon: <FileText size={18} />,
title: 'AI 摘要',
desc: '选中段落生成片段摘要,或在 AI 面板一键生成全文摘要',
},
{
icon: <Bot size={18} />,
title: 'AI 问答',
desc: '点击 ✨ 打开 AI 助手面板,针对当前笔记内容自由提问',
},
]
const SHORTCUTS: { mac: string[]; win: string[]; desc: string }[] = [
@ -83,6 +103,9 @@ const SHORTCUTS: { mac: string[]; win: string[]; desc: string }[] = [
{ mac: ['⌘', '⇧', 'Z'], win: ['Ctrl', 'Y'], desc: '重做' },
{ mac: ['/'], win: ['/'], desc: '命令菜单' },
{ mac: ['Esc'], win: ['Esc'], desc: '退出专注模式' },
{ mac: ['⌘', 'N'], win: ['Ctrl', 'N'], desc: '新建笔记' },
{ mac: ['⌘', '\\'], win: ['Ctrl', '\\'], desc: '切换专注模式' },
{ mac: ['⌘', '⇧', 'J'], win: ['Ctrl', '⇧', 'J'], desc: '打开 AI 助手' },
]
function detectOS(): 'mac' | 'win' {

View File

@ -1,4 +1,5 @@
import { useState, useRef, useEffect } from 'react'
import { AnimatePresence } from 'framer-motion'
import {
Search, Plus, Star, FileText, Folder, FolderOpen,
ChevronRight, ChevronDown,
@ -358,7 +359,9 @@ export function Sidebar() {
</div>
</aside>
<AnimatePresence>
{modelModalOpen && <ModelSettingsModal onClose={() => setModelModalOpen(false)} />}
</AnimatePresence>
{/* Drag overlay */}
<DragOverlay>

View File

@ -1,5 +1,5 @@
export type AIStreamRequest = {
type: 'continue' | 'polish' | 'summarize' | 'chat'
type: 'continue' | 'polish' | 'summarize' | 'translate' | 'chat'
noteContent: string
selection?: string
messages?: { role: 'user' | 'assistant'; content: string }[]