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:
parent
d632e9f6f7
commit
aa1430d4bf
@ -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}` }]
|
||||
}
|
||||
|
||||
|
||||
15
src/App.tsx
15
src/App.tsx
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)',
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,23 +459,25 @@ export function Editor() {
|
||||
</div>
|
||||
|
||||
{/* AI Result Modal */}
|
||||
{aiModal && (
|
||||
<AiResultModal
|
||||
type={aiModal.type}
|
||||
result={aiModal.result}
|
||||
streaming={aiModal.streaming}
|
||||
onInsert={() => {
|
||||
if (editor) {
|
||||
editor.chain().focus().insertContentAt({ from: aiModal.from, to: aiModal.to }, aiModal.result).run()
|
||||
}
|
||||
setAiModal(null)
|
||||
}}
|
||||
onDiscard={() => {
|
||||
aiModalAbortRef.current?.abort()
|
||||
setAiModal(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{aiModal && (
|
||||
<AiResultModal
|
||||
type={aiModal.type}
|
||||
result={aiModal.result}
|
||||
streaming={aiModal.streaming}
|
||||
onInsert={() => {
|
||||
if (editor) {
|
||||
editor.chain().focus().insertContentAt({ from: aiModal.from, to: aiModal.to }, aiModal.result).run()
|
||||
}
|
||||
setAiModal(null)
|
||||
}}
|
||||
onDiscard={() => {
|
||||
aiModalAbortRef.current?.abort()
|
||||
setAiModal(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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' {
|
||||
|
||||
@ -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>
|
||||
|
||||
{modelModalOpen && <ModelSettingsModal onClose={() => setModelModalOpen(false)} />}
|
||||
<AnimatePresence>
|
||||
{modelModalOpen && <ModelSettingsModal onClose={() => setModelModalOpen(false)} />}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Drag overlay */}
|
||||
<DragOverlay>
|
||||
|
||||
@ -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 }[]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user