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 ────────────────────────────────────────────────────────────────── // ── AI types ──────────────────────────────────────────────────────────────────
type AIStreamRequest = { type AIStreamRequest = {
type: 'continue' | 'polish' | 'summarize' | 'chat' type: 'continue' | 'polish' | 'summarize' | 'translate' | 'chat'
noteContent: string noteContent: string
selection?: string selection?: string
messages?: { role: 'user' | 'assistant'; content: 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}` }] 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}` }] 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' import { seedIfEmpty, deduplicateDB } from './db'
export default function App() { export default function App() {
const { loadAll, theme, focusMode, aiPanelOpen } = useAppStore() const { loadAll, theme, focusMode, aiPanelOpen, toggleFocusMode, toggleAiPanel, createNote, activeFolderId } = useAppStore()
useEffect(() => { useEffect(() => {
document.documentElement.setAttribute('data-theme', theme) document.documentElement.setAttribute('data-theme', theme)
@ -18,13 +18,24 @@ export default function App() {
.then(() => loadAll()) .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 ( return (
<div className="flex h-screen w-full overflow-hidden" style={{ background: 'var(--bg)' }}> <div className="flex h-screen w-full overflow-hidden" style={{ background: 'var(--bg)' }}>
<div className={`sidebar-panel${focusMode ? ' hidden' : ''}`} style={{ width: 260 }}> <div className={`sidebar-panel${focusMode ? ' hidden' : ''}`} style={{ width: 260 }}>
<Sidebar /> <Sidebar />
</div> </div>
<Editor /> <Editor />
{aiPanelOpen && !focusMode && <AiPanel />} {aiPanelOpen && <AiPanel />}
</div> </div>
) )
} }

View File

@ -1,10 +1,30 @@
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useEffect, useCallback } from 'react'
import { X, Send, Sparkles, Square, FileText } from 'lucide-react' import { X, Send, Sparkles, Square, FileText, GripHorizontal } from 'lucide-react'
import { useAppStore } from '../../stores/appStore' import { useAppStore } from '../../stores/appStore'
import { streamAI } from '../../lib/ai' import { streamAI } from '../../lib/ai'
import { extractTextFromJSON } from '../../lib/utils' import { extractTextFromJSON } from '../../lib/utils'
type Message = { role: 'user' | 'assistant'; content: string } 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() { export function AiPanel() {
const { toggleAiPanel, notes, activeNoteId } = useAppStore() const { toggleAiPanel, notes, activeNoteId } = useAppStore()
@ -17,10 +37,75 @@ export function AiPanel() {
const bottomRef = useRef<HTMLDivElement | null>(null) const bottomRef = useRef<HTMLDivElement | null>(null)
const textareaRef = useRef<HTMLTextAreaElement | 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(() => { useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages]) }, [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 const noteContent = activeNote
? `标题:${activeNote.title}\n\n${extractTextFromJSON(activeNote.content)}` ? `标题:${activeNote.title}\n\n${extractTextFromJSON(activeNote.content)}`
: '' : ''
@ -29,29 +114,20 @@ export function AiPanel() {
if (!text.trim() || streaming) return if (!text.trim() || streaming) return
const userMsg: Message = { role: 'user', content: text.trim() } const userMsg: Message = { role: 'user', content: text.trim() }
const assistantMsg: Message = { role: 'assistant', content: '' } const assistantMsg: Message = { role: 'assistant', content: '' }
setMessages(prev => [...prev, userMsg, assistantMsg]) setMessages(prev => [...prev, userMsg, assistantMsg])
setInput('') setInput('')
if (textareaRef.current) textareaRef.current.style.height = 'auto'
setStreaming(true) setStreaming(true)
const controller = new AbortController() const controller = new AbortController()
abortRef.current = controller abortRef.current = controller
try { try {
await streamAI( await streamAI(
{ { type: 'chat', noteContent, messages: messages.concat(userMsg), userMessage: userMsg.content },
type: 'chat',
noteContent,
messages: messages.concat(userMsg),
userMessage: userMsg.content,
},
(chunk) => { (chunk) => {
setMessages(prev => { setMessages(prev => {
const next = [...prev] const next = [...prev]
next[next.length - 1] = { next[next.length - 1] = { ...next[next.length - 1], content: next[next.length - 1].content + chunk }
...next[next.length - 1],
content: next[next.length - 1].content + chunk,
}
return next return next
}) })
}, },
@ -71,30 +147,55 @@ export function AiPanel() {
} }
} }
const summarize = () => {
if (!noteContent) return
send('请为当前笔记生成一份简洁的摘要。')
}
const stop = () => {
abortRef.current?.abort()
}
return ( return (
<div <div
className="flex flex-col h-full shrink-0" style={{
style={{ width: 320, borderLeft: '1px solid var(--border)', background: 'var(--bg-subtle)' }} 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 <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" 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"> <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> <span className="font-semibold text-sm" style={{ color: 'var(--text)' }}>AI </span>
</div> </div>
<button onClick={toggleAiPanel} className="toolbar-btn" title="关闭"> <button
onMouseDown={e => e.stopPropagation()}
onClick={toggleAiPanel}
className="toolbar-btn"
title="关闭"
>
<X size={15} /> <X size={15} />
</button> </button>
</div> </div>
@ -102,9 +203,9 @@ export function AiPanel() {
{/* Quick actions */} {/* Quick actions */}
<div className="px-3 py-2 shrink-0" style={{ borderBottom: '1px solid var(--border)' }}> <div className="px-3 py-2 shrink-0" style={{ borderBottom: '1px solid var(--border)' }}>
<button <button
onClick={summarize} onClick={() => send('请为当前笔记生成一份简洁的摘要。')}
disabled={!activeNote || streaming} 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={{ style={{
background: 'var(--bg-muted)', background: 'var(--bg-muted)',
border: '1px solid var(--border)', border: '1px solid var(--border)',
@ -158,13 +259,17 @@ export function AiPanel() {
<textarea <textarea
ref={textareaRef} ref={textareaRef}
value={input} value={input}
onChange={e => setInput(e.target.value)} onChange={e => {
onKeyDown={e => { setInput(e.target.value)
if (e.key === 'Enter' && !e.shiftKey) { const el = textareaRef.current
e.preventDefault() if (el) {
send(input) 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 提问…' : '请先打开笔记'} placeholder={activeNote ? '向 AI 提问…' : '请先打开笔记'}
disabled={!activeNote} disabled={!activeNote}
rows={1} rows={1}
@ -175,12 +280,15 @@ export function AiPanel() {
color: 'var(--text)', color: 'var(--text)',
maxHeight: 120, maxHeight: 120,
lineHeight: 1.5, lineHeight: 1.5,
overflow: 'auto',
}} }}
onFocus={e => (e.currentTarget.style.borderColor = 'var(--accent)')}
onBlur={e => (e.currentTarget.style.borderColor = 'var(--border)')}
/> />
{streaming ? ( {streaming ? (
<button <button
onClick={stop} onClick={() => abortRef.current?.abort()}
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: '#ef4444', color: '#fff' }} style={{ background: '#ef4444', color: '#fff' }}
title="停止" title="停止"
> >
@ -190,7 +298,7 @@ export function AiPanel() {
<button <button
onClick={() => send(input)} onClick={() => send(input)}
disabled={!input.trim() || !activeNote} 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={{ style={{
background: input.trim() && activeNote ? 'var(--accent)' : 'var(--bg-muted)', background: input.trim() && activeNote ? 'var(--accent)' : 'var(--bg-muted)',
color: input.trim() && activeNote ? '#fff' : 'var(--text-faint)', color: input.trim() && activeNote ? '#fff' : 'var(--text-faint)',

View File

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

View File

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

View File

@ -2,7 +2,7 @@ import { useState } from 'react'
import { import {
FileText, Folder, Slash, MousePointer, Save, FileText, Folder, Slash, MousePointer, Save,
Search, Moon, Plus, Hash, Star, Image, Code2, Search, Moon, Plus, Hash, Star, Image, Code2,
BookOpen, Maximize2, BookOpen, Maximize2, Zap, Wand2, Bot,
} from 'lucide-react' } from 'lucide-react'
import { useAppStore } from '../../stores/appStore' import { useAppStore } from '../../stores/appStore'
@ -72,6 +72,26 @@ const FEATURES = [
title: '深色模式', title: '深色模式',
desc: '点击左上角图标一键切换亮色 / 暗色主题', 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 }[] = [ 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: ['⌘', '⇧', 'Z'], win: ['Ctrl', 'Y'], desc: '重做' },
{ mac: ['/'], win: ['/'], desc: '命令菜单' }, { mac: ['/'], win: ['/'], desc: '命令菜单' },
{ mac: ['Esc'], win: ['Esc'], 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' { function detectOS(): 'mac' | 'win' {

View File

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

View File

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