feat(M3): AI 续写/润色/摘要/问答、Hono 代理、模型管理窗口

- 新增 Hono 代理服务(server/index.ts,端口 3001),SSE 流式转发 Claude API
- 新增 src/lib/ai.ts:streamAI() 封装 fetch + ReadableStream + AbortController
- 新增 AI 面板(AiPanel.tsx):流式聊天问答、全文摘要、停止按钮
- 新增模型管理弹窗(ModelSettingsModal.tsx):增删模型、切换激活模型
- 编辑器新增 AI 续写()和面板切换()按钮
- 浮动工具栏新增润色(Wand2)和摘要(FileText)按钮,结果预览 Modal 支持一键替换
- appStore 新增 aiPanelOpen + toggleAiPanel
- vite.config.ts 添加 /api 代理到 localhost:3001
- index.css 添加 .ai-cursor 光标动画

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MikiVL 2026-05-03 00:03:50 +08:00
parent 56b59269f4
commit d632e9f6f7
14 changed files with 1808 additions and 9 deletions

1
.env.example Normal file
View File

@ -0,0 +1 @@
ANTHROPIC_API_KEY=sk-ant-your-key-here

913
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,14 +4,18 @@
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "concurrently -n client,server -c cyan,yellow \"vite\" \"tsx --watch server/index.ts\"",
"dev:client": "vite",
"dev:server": "tsx --watch server/index.ts",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.92.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@hono/node-server": "^2.0.1",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-popover": "^1.1.15",
@ -44,7 +48,9 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dexie": "^4.4.2", "dexie": "^4.4.2",
"dotenv": "^17.4.2",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"hono": "^4.12.16",
"lowlight": "^3.3.0", "lowlight": "^3.3.0",
"lucide-react": "^1.14.0", "lucide-react": "^1.14.0",
"react": "^19.2.5", "react": "^19.2.5",
@ -60,12 +66,14 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.5.0", "autoprefixer": "^10.5.0",
"concurrently": "^9.2.1",
"eslint": "^10.2.1", "eslint": "^10.2.1",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0", "globals": "^17.5.0",
"postcss": "^8.5.13", "postcss": "^8.5.13",
"tailwindcss": "^4.2.4", "tailwindcss": "^4.2.4",
"tsx": "^4.21.0",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"typescript-eslint": "^8.58.2", "typescript-eslint": "^8.58.2",
"vite": "^8.0.10" "vite": "^8.0.10"

192
server/index.ts Normal file
View File

@ -0,0 +1,192 @@
import 'dotenv/config'
import { Hono } from 'hono'
import { serve } from '@hono/node-server'
import { cors } from 'hono/cors'
import Anthropic from '@anthropic-ai/sdk'
import fs from 'node:fs'
import path from 'node:path'
const app = new Hono()
app.use('*', cors())
// ── Model config persistence ──────────────────────────────────────────────────
const MODELS_FILE = path.resolve('models.json')
type ModelConfig = {
id: string
name: string
apiKey: string
baseURL: string
modelId?: string
isActive: boolean
}
function readModels(): ModelConfig[] {
try {
return JSON.parse(fs.readFileSync(MODELS_FILE, 'utf-8')) as ModelConfig[]
} catch {
// First run: seed from .env
const defaults: ModelConfig[] = [
{
id: 'default',
name: 'Claude Sonnet 4.6',
apiKey: process.env.ANTHROPIC_API_KEY ?? '',
baseURL: process.env.ANTHROPIC_BASE_URL ?? 'https://api.anthropic.com',
isActive: true,
},
]
fs.writeFileSync(MODELS_FILE, JSON.stringify(defaults, null, 2))
return defaults
}
}
function writeModels(models: ModelConfig[]) {
fs.writeFileSync(MODELS_FILE, JSON.stringify(models, null, 2))
}
function getActiveClient(): Anthropic {
const models = readModels()
const active = models.find(m => m.isActive) ?? models[0]
return new Anthropic({ apiKey: active.apiKey, baseURL: active.baseURL })
}
function getActiveModel(): string {
const models = readModels()
const active = models.find(m => m.isActive) ?? models[0]
// model id stored in name field as identifier; use a known default if not specified
return active.modelId ?? 'claude-sonnet-4-6'
}
// ── Model CRUD endpoints ──────────────────────────────────────────────────────
app.get('/api/models', (c) => {
const models = readModels()
// strip apiKey from response for security
return c.json(models.map(({ apiKey: _, ...rest }) => rest))
})
app.post('/api/models', async (c) => {
const body = await c.req.json<{ name: string; apiKey: string; baseURL: string; modelId?: string }>()
const models = readModels()
const newModel: ModelConfig = {
id: crypto.randomUUID(),
name: body.name.trim(),
apiKey: body.apiKey.trim(),
baseURL: body.baseURL.trim(),
isActive: false,
...(body.modelId ? { modelId: body.modelId.trim() } : {}),
}
models.push(newModel)
writeModels(models)
const { apiKey: _, ...safe } = newModel
return c.json(safe)
})
app.patch('/api/models/:id/activate', (c) => {
const { id } = c.req.param()
const models = readModels()
models.forEach(m => { m.isActive = m.id === id })
writeModels(models)
return c.json({ ok: true })
})
app.delete('/api/models/:id', (c) => {
const { id } = c.req.param()
let models = readModels()
const target = models.find(m => m.id === id)
if (!target) return c.json({ error: 'not found' }, 404)
if (models.length === 1) return c.json({ error: 'cannot delete last model' }, 400)
const wasActive = target.isActive
models = models.filter(m => m.id !== id)
if (wasActive) models[0].isActive = true
writeModels(models)
return c.json({ ok: true })
})
// ── AI types ──────────────────────────────────────────────────────────────────
type AIStreamRequest = {
type: 'continue' | 'polish' | 'summarize' | 'chat'
noteContent: string
selection?: string
messages?: { role: 'user' | 'assistant'; content: string }[]
userMessage?: string
}
function buildMessages(req: AIStreamRequest): Anthropic.MessageParam[] {
const { type, noteContent, selection, messages, userMessage } = req
if (type === 'chat') {
const history: Anthropic.MessageParam[] = (messages ?? []).map(m => ({
role: m.role,
content: m.content,
}))
if (userMessage) history.push({ role: 'user', content: userMessage })
return history
}
if (type === 'continue') {
return [{ role: 'user', content: `请基于以下笔记内容,从最后一段自然地续写下去,风格保持一致,直接输出续写内容,不要重复原文:\n\n${noteContent}` }]
}
if (type === 'polish') {
return [{ role: 'user', content: `请润色以下文字,改善语法、流畅度和表达,保持原意,直接输出润色后的结果,不要有任何解释:\n\n${selection ?? noteContent}` }]
}
return [{ role: 'user', content: `请为以下内容提炼要点摘要,用简洁的中文输出,不超过 150 字:\n\n${selection ?? noteContent}` }]
}
function buildSystemPrompt(type: AIStreamRequest['type'], noteContent: string): string {
if (type === 'chat') {
return `你是一个智能笔记助手。用户正在阅读以下笔记,请根据笔记内容回答用户的问题,回答要简洁、准确:\n\n---\n${noteContent}\n---`
}
return '你是一个专业的写作助手,擅长中英文写作、润色和摘要。直接输出结果,不要加多余的说明。'
}
// ── Stream endpoint ───────────────────────────────────────────────────────────
app.post('/api/ai/stream', async (c) => {
const req = await c.req.json<AIStreamRequest>()
const aiClient = getActiveClient()
const modelId = getActiveModel()
const stream = await aiClient.messages.stream({
model: modelId,
max_tokens: 1024,
system: buildSystemPrompt(req.type, req.noteContent),
messages: buildMessages(req),
})
return new Response(
new ReadableStream({
async start(controller) {
const encoder = new TextEncoder()
try {
for await (const event of stream) {
if (
event.type === 'content_block_delta' &&
event.delta.type === 'text_delta'
) {
const data = JSON.stringify({ delta: event.delta.text })
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
}
}
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error'
controller.enqueue(encoder.encode(`data: {"error":"${msg}"}\n\n`))
} finally {
controller.close()
}
},
}),
{
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
}
)
})
serve({ fetch: app.fetch, port: 3001 }, () => {
console.log('AI proxy server running on http://localhost:3001')
})

13
server/tsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "es2022",
"module": "esnext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["node"],
"skipLibCheck": true,
"strict": true,
"noEmit": true
},
"include": ["index.ts"]
}

View File

@ -1,11 +1,12 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { Sidebar } from './components/sidebar/Sidebar' import { Sidebar } from './components/sidebar/Sidebar'
import { Editor } from './components/editor/Editor' import { Editor } from './components/editor/Editor'
import { AiPanel } from './components/ai/AiPanel'
import { useAppStore } from './stores/appStore' 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 } = useAppStore() const { loadAll, theme, focusMode, aiPanelOpen } = useAppStore()
useEffect(() => { useEffect(() => {
document.documentElement.setAttribute('data-theme', theme) document.documentElement.setAttribute('data-theme', theme)
@ -23,6 +24,7 @@ export default function App() {
<Sidebar /> <Sidebar />
</div> </div>
<Editor /> <Editor />
{aiPanelOpen && !focusMode && <AiPanel />}
</div> </div>
) )
} }

View File

@ -0,0 +1,206 @@
import { useState, useRef, useEffect } from 'react'
import { X, Send, Sparkles, Square, FileText } 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 }
export function AiPanel() {
const { toggleAiPanel, notes, activeNoteId } = useAppStore()
const activeNote = notes.find(n => n.id === activeNoteId)
const [messages, setMessages] = useState<Message[]>([])
const [input, setInput] = useState('')
const [streaming, setStreaming] = useState(false)
const abortRef = useRef<AbortController | null>(null)
const bottomRef = useRef<HTMLDivElement | null>(null)
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
const noteContent = activeNote
? `标题:${activeNote.title}\n\n${extractTextFromJSON(activeNote.content)}`
: ''
const send = async (text: string) => {
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('')
setStreaming(true)
const controller = new AbortController()
abortRef.current = controller
try {
await streamAI(
{
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,
}
return next
})
},
controller.signal,
)
} catch (e) {
if ((e as Error).name !== 'AbortError') {
setMessages(prev => {
const next = [...prev]
next[next.length - 1] = { role: 'assistant', content: '❌ 请求失败,请检查 API Key 和网络连接。' }
return next
})
}
} finally {
setStreaming(false)
abortRef.current = null
}
}
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)' }}
>
{/* Header */}
<div
className="flex items-center justify-between px-4 py-3 shrink-0"
style={{ borderBottom: '1px solid var(--border)' }}
>
<div className="flex items-center gap-2">
<Sparkles size={15} style={{ color: 'var(--accent)' }} />
<span className="font-semibold text-sm" style={{ color: 'var(--text)' }}>AI </span>
</div>
<button onClick={toggleAiPanel} className="toolbar-btn" title="关闭">
<X size={15} />
</button>
</div>
{/* Quick actions */}
<div className="px-3 py-2 shrink-0" style={{ borderBottom: '1px solid var(--border)' }}>
<button
onClick={summarize}
disabled={!activeNote || streaming}
className="w-full flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm transition-colors"
style={{
background: 'var(--bg-muted)',
border: '1px solid var(--border)',
color: activeNote ? 'var(--text)' : 'var(--text-faint)',
cursor: activeNote ? 'pointer' : 'not-allowed',
}}
onMouseEnter={e => { if (activeNote) e.currentTarget.style.background = 'var(--accent-subtle)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-muted)' }}
>
<FileText size={13} style={{ color: 'var(--accent)' }} />
</button>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-3 py-3 space-y-3">
{messages.length === 0 && (
<div className="flex flex-col items-center justify-center h-full gap-2 text-center">
<Sparkles size={28} style={{ color: 'var(--text-faint)', opacity: 0.5 }} />
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>
{activeNote ? '向 AI 提问,或点击上方按钮生成摘要' : '请先打开一篇笔记'}
</p>
</div>
)}
{messages.map((msg, i) => (
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div
className="max-w-[85%] rounded-2xl px-3 py-2 text-sm leading-relaxed whitespace-pre-wrap break-words"
style={{
background: msg.role === 'user' ? 'var(--accent)' : 'var(--bg-muted)',
color: msg.role === 'user' ? '#fff' : 'var(--text)',
borderBottomRightRadius: msg.role === 'user' ? 4 : undefined,
borderBottomLeftRadius: msg.role === 'assistant' ? 4 : undefined,
}}
>
{msg.content}
{streaming && i === messages.length - 1 && msg.role === 'assistant' && (
<span className="ai-cursor" />
)}
</div>
</div>
))}
<div ref={bottomRef} />
</div>
{/* Input */}
<div
className="px-3 py-2 shrink-0 flex items-end gap-2"
style={{ borderTop: '1px solid var(--border)' }}
>
<textarea
ref={textareaRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
send(input)
}
}}
placeholder={activeNote ? '向 AI 提问…' : '请先打开笔记'}
disabled={!activeNote}
rows={1}
className="flex-1 resize-none rounded-xl px-3 py-2 text-sm outline-none"
style={{
background: 'var(--bg-muted)',
border: '1px solid var(--border)',
color: 'var(--text)',
maxHeight: 120,
lineHeight: 1.5,
}}
/>
{streaming ? (
<button
onClick={stop}
className="shrink-0 flex items-center justify-center w-8 h-8 rounded-xl transition-colors"
style={{ background: '#ef4444', color: '#fff' }}
title="停止"
>
<Square size={13} fill="currentColor" />
</button>
) : (
<button
onClick={() => send(input)}
disabled={!input.trim() || !activeNote}
className="shrink-0 flex items-center justify-center w-8 h-8 rounded-xl transition-colors"
style={{
background: input.trim() && activeNote ? 'var(--accent)' : 'var(--bg-muted)',
color: input.trim() && activeNote ? '#fff' : 'var(--text-faint)',
}}
title="发送"
>
<Send size={13} />
</button>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,216 @@
import { useState, useEffect } from 'react'
import { X, Plus, Check, Trash2, Bot, ChevronDown, ChevronUp } from 'lucide-react'
type ModelInfo = {
id: string
name: string
baseURL: string
modelId?: string
isActive: boolean
}
export function ModelSettingsModal({ onClose }: { onClose: () => void }) {
const [models, setModels] = useState<ModelInfo[]>([])
const [loading, setLoading] = useState(true)
const [adding, setAdding] = useState(false)
const [form, setForm] = useState({ name: '', apiKey: '', baseURL: '', modelId: '' })
const [formError, setFormError] = useState('')
const [saving, setSaving] = useState(false)
const [expandedId, setExpandedId] = useState<string | null>(null)
const fetchModels = async () => {
try {
const res = await fetch('/api/models')
setModels(await res.json())
} finally {
setLoading(false)
}
}
useEffect(() => { fetchModels() }, [])
const activate = async (id: string) => {
await fetch(`/api/models/${id}/activate`, { method: 'PATCH' })
setModels(prev => prev.map(m => ({ ...m, isActive: m.id === id })))
}
const remove = async (id: string) => {
const res = await fetch(`/api/models/${id}`, { method: 'DELETE' })
if (res.ok) setModels(prev => {
const next = prev.filter(m => m.id !== id)
if (prev.find(m => m.id === id)?.isActive && next.length > 0) next[0].isActive = true
return next
})
}
const submit = async () => {
if (!form.name.trim()) { setFormError('请输入名称'); return }
if (!form.apiKey.trim()) { setFormError('请输入 API Key'); return }
if (!form.baseURL.trim()) { setFormError('请输入 Base URL'); return }
setFormError('')
setSaving(true)
try {
const res = await fetch('/api/models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
})
const model = await res.json()
setModels(prev => [...prev, model])
setForm({ name: '', apiKey: '', baseURL: '', modelId: '' })
setAdding(false)
} finally {
setSaving(false)
}
}
const activeModel = models.find(m => m.isActive)
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.45)' }}
onClick={onClose}
>
<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()}
>
{/* 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 gap-2">
<Bot size={16} style={{ color: 'var(--accent)' }} />
<span className="font-semibold text-sm" style={{ color: 'var(--text)' }}>AI </span>
</div>
<button onClick={onClose} className="toolbar-btn"><X size={15} /></button>
</div>
{/* Active model badge */}
{activeModel && (
<div className="px-5 py-3 shrink-0" style={{ borderBottom: '1px solid var(--border)', background: 'var(--bg-subtle)' }}>
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>使</p>
<p className="text-sm font-medium mt-0.5" style={{ color: 'var(--accent)' }}>{activeModel.name}</p>
{activeModel.baseURL && (
<p className="text-xs mt-0.5 truncate" style={{ color: 'var(--text-faint)' }}>{activeModel.baseURL}</p>
)}
</div>
)}
{/* Model list */}
<div className="flex-1 overflow-y-auto px-5 py-3 space-y-2">
{loading && <p className="text-sm text-center py-4" style={{ color: 'var(--text-faint)' }}></p>}
{models.map(model => (
<div
key={model.id}
className="rounded-xl"
style={{ border: `1px solid ${model.isActive ? 'var(--accent)' : 'var(--border)'}`, background: model.isActive ? 'var(--accent-subtle)' : 'var(--bg-subtle)' }}
>
<div className="flex items-center gap-3 px-4 py-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate" style={{ color: 'var(--text)' }}>{model.name}</span>
{model.isActive && (
<span className="text-xs px-1.5 py-0.5 rounded-full" style={{ background: 'var(--accent)', color: '#fff' }}>使</span>
)}
</div>
{model.modelId && (
<p className="text-xs mt-0.5 truncate" style={{ color: 'var(--text-faint)' }}>Model ID: {model.modelId}</p>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<button
className="toolbar-btn"
title="展开详情"
onClick={() => setExpandedId(expandedId === model.id ? null : model.id)}
>
{expandedId === model.id ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
</button>
{!model.isActive && (
<button
className="toolbar-btn"
title="设为当前模型"
onClick={() => activate(model.id)}
>
<Check size={13} style={{ color: 'var(--accent)' }} />
</button>
)}
{models.length > 1 && (
<button
className="toolbar-btn"
title="删除"
onClick={() => remove(model.id)}
>
<Trash2 size={13} style={{ color: '#ef4444' }} />
</button>
)}
</div>
</div>
{expandedId === model.id && (
<div className="px-4 pb-3" style={{ borderTop: '1px solid var(--border)' }}>
<p className="text-xs mt-2" style={{ color: 'var(--text-faint)' }}>Base URL</p>
<p className="text-xs mt-0.5 break-all" style={{ color: 'var(--text-muted)' }}>{model.baseURL || '—'}</p>
</div>
)}
</div>
))}
</div>
{/* Add model */}
<div className="px-5 py-3 shrink-0" style={{ borderTop: '1px solid var(--border)' }}>
{!adding ? (
<button
onClick={() => setAdding(true)}
className="w-full flex items-center justify-center gap-2 py-2 rounded-xl text-sm"
style={{ border: '1px dashed var(--border)', color: 'var(--text-muted)' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--accent)'; e.currentTarget.style.color = 'var(--accent)' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border)'; e.currentTarget.style.color = 'var(--text-muted)' }}
>
<Plus size={14} />
</button>
) : (
<div className="space-y-2">
<p className="text-xs font-semibold" style={{ color: 'var(--text-faint)' }}></p>
{[
{ key: 'name', label: '名称', placeholder: '如GPT-4o' },
{ key: 'apiKey', label: 'API Key', placeholder: 'sk-...' },
{ key: 'baseURL', label: 'Base URL', placeholder: 'https://api.openai.com/v1' },
{ key: 'modelId', label: 'Model ID选填', placeholder: 'gpt-4o' },
].map(f => (
<div key={f.key}>
<label className="text-xs" style={{ color: 'var(--text-faint)' }}>{f.label}</label>
<input
type={f.key === 'apiKey' ? 'password' : 'text'}
value={form[f.key as keyof typeof form]}
onChange={e => setForm(prev => ({ ...prev, [f.key]: e.target.value }))}
placeholder={f.placeholder}
className="w-full mt-0.5 px-3 py-1.5 rounded-lg text-sm outline-none"
style={{ background: 'var(--bg-muted)', border: '1px solid var(--border)', color: 'var(--text)' }}
onFocus={e => (e.currentTarget.style.borderColor = 'var(--accent)')}
onBlur={e => (e.currentTarget.style.borderColor = 'var(--border)')}
/>
</div>
))}
{formError && <p className="text-xs" style={{ color: '#ef4444' }}>{formError}</p>}
<div className="flex gap-2 pt-1">
<button
onClick={() => { setAdding(false); setFormError(''); setForm({ name: '', apiKey: '', baseURL: '', modelId: '' }) }}
className="flex-1 py-1.5 rounded-lg text-sm"
style={{ color: 'var(--text-muted)', background: 'var(--bg-muted)' }}
></button>
<button
onClick={submit}
disabled={saving}
className="flex-1 py-1.5 rounded-lg text-sm font-medium"
style={{ background: 'var(--accent)', color: '#fff' }}
>
{saving ? '保存中…' : '保存'}
</button>
</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@ -23,9 +23,11 @@ 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,
} 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'
import { streamAI } from '../../lib/ai'
import { WelcomeView } from './WelcomeView' import { WelcomeView } from './WelcomeView'
const lowlight = createLowlight(common) const lowlight = createLowlight(common)
@ -76,7 +78,7 @@ function SlashMenu({ items, selectedIndex, onSelect }: {
} }
export function Editor() { export function Editor() {
const { activeNoteId, notes, updateNote, toggleStar, focusMode, toggleFocusMode } = useAppStore() const { activeNoteId, notes, updateNote, toggleStar, focusMode, toggleFocusMode, toggleAiPanel, aiPanelOpen } = useAppStore()
const activeNote = notes.find(n => n.id === activeNoteId) const activeNote = notes.find(n => n.id === activeNoteId)
const [title, setTitle] = useState(activeNote?.title ?? '') const [title, setTitle] = useState(activeNote?.title ?? '')
@ -84,6 +86,21 @@ export function Editor() {
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'unsaved'>('saved') const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'unsaved'>('saved')
const [tagInput, setTagInput] = useState('') const [tagInput, setTagInput] = useState('')
// AI continue state
const [aiContinuing, setAiContinuing] = useState(false)
const aiAbortRef = useRef<AbortController | null>(null)
// AI modal state
const [aiModal, setAiModal] = useState<{
type: 'polish' | 'summarize'
from: number
to: number
selection: string
result: string
streaming: boolean
} | null>(null)
const aiModalAbortRef = useRef<AbortController | null>(null)
const [slashOpen, setSlashOpen] = useState(false) const [slashOpen, setSlashOpen] = useState(false)
const [slashQuery, setSlashQuery] = useState('') const [slashQuery, setSlashQuery] = useState('')
const [slashIndex, setSlashIndex] = useState(0) const [slashIndex, setSlashIndex] = useState(0)
@ -279,6 +296,49 @@ export function Editor() {
}, 500) }, 500)
} }
const handleAiContinue = async () => {
if (!editor || !activeNote) return
if (aiContinuing) { aiAbortRef.current?.abort(); return }
const controller = new AbortController()
aiAbortRef.current = controller
setAiContinuing(true)
try {
await streamAI(
{ type: 'continue', noteContent: editor.getText() },
(chunk) => { editor.commands.insertContent(chunk) },
controller.signal,
)
} catch (e) {
if ((e as Error).name === 'AbortError') return
} finally {
setAiContinuing(false)
aiAbortRef.current = null
}
}
const handleAiAction = async (type: 'polish' | 'summarize', from: number, to: number) => {
if (!editor || !activeNote) return
const selection = editor.state.doc.textBetween(from, to, '\n')
const controller = new AbortController()
aiModalAbortRef.current = controller
setAiModal({ type, from, to, selection, result: '', streaming: true })
try {
await streamAI(
{ type, noteContent: editor.getText(), selection },
(chunk) => {
setAiModal(prev => prev ? { ...prev, result: prev.result + chunk } : null)
},
controller.signal,
)
} catch (e) {
if ((e as Error).name !== 'AbortError') {
setAiModal(prev => prev ? { ...prev, result: '❌ 请求失败', streaming: false } : null)
return
}
}
setAiModal(prev => prev ? { ...prev, streaming: false } : null)
}
if (!activeNote || activeNoteId === '__welcome__') { if (!activeNote || activeNoteId === '__welcome__') {
return <WelcomeView /> return <WelcomeView />
} }
@ -297,6 +357,22 @@ export function Editor() {
className="flex-1 bg-transparent outline-none text-3xl font-bold min-w-0" className="flex-1 bg-transparent outline-none text-3xl font-bold min-w-0"
style={{ color: 'var(--text)', letterSpacing: '-0.02em' }} style={{ color: 'var(--text)', letterSpacing: '-0.02em' }}
/> />
<button
onClick={handleAiContinue}
title={aiContinuing ? '停止续写' : 'AI 续写'}
className="toolbar-btn shrink-0 mt-2"
>
{aiContinuing
? <Square size={14} fill="currentColor" style={{ color: 'var(--accent)' }} />
: <Zap size={14} style={{ color: 'var(--text-faint)' }} />}
</button>
<button
onClick={toggleAiPanel}
title="AI 助手"
className="toolbar-btn shrink-0 mt-2"
>
<Sparkles size={14} style={{ color: aiPanelOpen ? 'var(--accent)' : 'var(--text-faint)' }} />
</button>
<button <button
onClick={() => activeNoteId && toggleStar(activeNoteId)} onClick={() => activeNoteId && toggleStar(activeNoteId)}
title={activeNote.starred ? '取消收藏' : '收藏'} title={activeNote.starred ? '取消收藏' : '收藏'}
@ -364,7 +440,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} /> <FloatingToolbar editor={editor} onPolish={handleAiAction.bind(null, 'polish')} onSummarize={handleAiAction.bind(null, 'summarize')} />
</div> </div>
)} )}
@ -381,6 +457,25 @@ export function Editor() {
</div> </div>
</div> </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)
}}
/>
)}
{/* Footer */} {/* Footer */}
<div <div
className="flex items-center justify-between px-12 py-2 text-xs shrink-0" className="flex items-center justify-between px-12 py-2 text-xs shrink-0"
@ -395,8 +490,13 @@ export function Editor() {
) )
} }
function FloatingToolbar({ editor }: { editor: NonNullable<ReturnType<typeof useEditor>> }) { function FloatingToolbar({ editor, onPolish, onSummarize }: {
editor: NonNullable<ReturnType<typeof useEditor>>
onPolish: (from: number, to: number) => void
onSummarize: (from: number, to: number) => void
}) {
const [visible, setVisible] = useState(false) const [visible, setVisible] = useState(false)
const [selRange, setSelRange] = useState({ from: 0, to: 0 })
const [pos, setPos] = useState({ top: 0, left: 0 }) const [pos, setPos] = useState({ top: 0, left: 0 })
useEffect(() => { useEffect(() => {
@ -405,6 +505,7 @@ function FloatingToolbar({ editor }: { editor: NonNullable<ReturnType<typeof use
const { selection } = state const { selection } = state
if (selection.empty) { setVisible(false); return } if (selection.empty) { setVisible(false); return }
const { from, to } = selection const { from, to } = selection
setSelRange({ from, to })
const start = view.coordsAtPos(from) const start = view.coordsAtPos(from)
const end = view.coordsAtPos(to) const end = view.coordsAtPos(to)
const top = Math.min(start.top, end.top) - 48 const top = Math.min(start.top, end.top) - 48
@ -432,6 +533,9 @@ function FloatingToolbar({ editor }: { editor: NonNullable<ReturnType<typeof use
<ToolbarBtn title="H2" active={editor.isActive('heading', { level: 2 })} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}><Heading2 size={13} /></ToolbarBtn> <ToolbarBtn title="H2" active={editor.isActive('heading', { level: 2 })} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}><Heading2 size={13} /></ToolbarBtn>
<div className="toolbar-divider" /> <div className="toolbar-divider" />
<ToolbarBtn title="引用" active={editor.isActive('blockquote')} onClick={() => editor.chain().focus().toggleBlockquote().run()}><Quote size={13} /></ToolbarBtn> <ToolbarBtn title="引用" active={editor.isActive('blockquote')} onClick={() => editor.chain().focus().toggleBlockquote().run()}><Quote size={13} /></ToolbarBtn>
<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>
</div> </div>
) )
} }
@ -445,3 +549,54 @@ function ToolbarBtn({ children, active, title, onClick }: {
</button> </button>
) )
} }
function AiResultModal({ type, result, streaming, onInsert, onDiscard }: {
type: 'polish' | 'summarize'
result: string
streaming: boolean
onInsert: () => void
onDiscard: () => void
}) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.4)' }}
onClick={onDiscard}
>
<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()}
>
<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 摘要结果'}
</span>
</div>
<button onClick={onDiscard} className="toolbar-btn"><X size={14} /></button>
</div>
<div className="flex-1 overflow-y-auto px-5 py-4 text-sm leading-relaxed whitespace-pre-wrap" style={{ color: 'var(--text)' }}>
{result || <span style={{ color: 'var(--text-faint)' }}></span>}
{streaming && <span className="ai-cursor" />}
</div>
<div className="flex items-center justify-end gap-2 px-5 py-3 shrink-0" style={{ borderTop: '1px solid var(--border)' }}>
<button onClick={onDiscard} className="px-3 py-1.5 rounded-lg text-sm" style={{ color: 'var(--text-muted)' }}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg-muted)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}>
</button>
<button
onClick={onInsert}
disabled={!result || streaming}
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' ? '替换选中内容' : '插入摘要'}
</button>
</div>
</div>
</div>
)
}

View File

@ -3,8 +3,9 @@ import {
Search, Plus, Star, FileText, Folder, FolderOpen, Search, Plus, Star, FileText, Folder, FolderOpen,
ChevronRight, ChevronDown, ChevronRight, ChevronDown,
Trash2, Edit2, Moon, Sun, FolderPlus, Hash, BookOpen, Trash2, Edit2, Moon, Sun, FolderPlus, Hash, BookOpen,
ArrowUpDown, Check, X, ArrowUpDown, Check, X, Bot,
} from 'lucide-react' } from 'lucide-react'
import { ModelSettingsModal } from '../ai/ModelSettingsModal'
import { import {
DndContext, DragOverlay, useDraggable, useDroppable, DndContext, DragOverlay, useDraggable, useDroppable,
useSensor, useSensors, MouseSensor, TouchSensor, useSensor, useSensors, MouseSensor, TouchSensor,
@ -32,6 +33,7 @@ export function Sidebar() {
const [editingNoteId, setEditingNoteId] = useState<string | null>(null) const [editingNoteId, setEditingNoteId] = useState<string | null>(null)
const [noteEditValue, setNoteEditValue] = useState('') const [noteEditValue, setNoteEditValue] = useState('')
const [sortMenuOpen, setSortMenuOpen] = useState(false) const [sortMenuOpen, setSortMenuOpen] = useState(false)
const [modelModalOpen, setModelModalOpen] = useState(false)
const [contextMenu, setContextMenu] = useState<{ const [contextMenu, setContextMenu] = useState<{
type: 'note' | 'folder' type: 'note' | 'folder'
id: string id: string
@ -310,8 +312,7 @@ export function Sidebar() {
</div> </div>
{/* Context menu */} {/* Context menu */}
{contextMenu && ( {contextMenu && ( <div
<div
className="fixed z-50 rounded-lg py-1 shadow-xl" className="fixed z-50 rounded-lg py-1 shadow-xl"
style={{ top: contextMenu.y, left: contextMenu.x, background: 'var(--bg)', border: '1px solid var(--border)', minWidth: 160 }} style={{ top: contextMenu.y, left: contextMenu.x, background: 'var(--bg)', border: '1px solid var(--border)', minWidth: 160 }}
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
@ -338,8 +339,27 @@ export function Sidebar() {
)} )}
</div> </div>
)} )}
{/* Bottom toolbar */}
<div
className="px-3 py-2 shrink-0 flex items-center justify-between"
style={{ borderTop: '1px solid var(--border)' }}
>
<button
onClick={() => setModelModalOpen(true)}
className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg text-xs transition-colors"
style={{ color: 'var(--text-faint)' }}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-muted)'; e.currentTarget.style.color = 'var(--text)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-faint)' }}
>
<Bot size={13} />
AI
</button>
</div>
</aside> </aside>
{modelModalOpen && <ModelSettingsModal onClose={() => setModelModalOpen(false)} />}
{/* Drag overlay */} {/* Drag overlay */}
<DragOverlay> <DragOverlay>
{draggingNote && ( {draggingNote && (

View File

@ -269,3 +269,19 @@
.hljs-meta, .hljs-deletion { color: var(--hl-meta); } .hljs-meta, .hljs-deletion { color: var(--hl-meta); }
.hljs-emphasis { font-style: italic; } .hljs-emphasis { font-style: italic; }
.hljs-strong { font-weight: bold; } .hljs-strong { font-weight: bold; }
/* ── AI streaming cursor ── */
@keyframes ai-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.ai-cursor {
display: inline-block;
width: 2px;
height: 1em;
background: var(--accent);
margin-left: 2px;
vertical-align: text-bottom;
border-radius: 1px;
animation: ai-blink 0.8s ease infinite;
}

50
src/lib/ai.ts Normal file
View File

@ -0,0 +1,50 @@
export type AIStreamRequest = {
type: 'continue' | 'polish' | 'summarize' | 'chat'
noteContent: string
selection?: string
messages?: { role: 'user' | 'assistant'; content: string }[]
userMessage?: string
}
export async function streamAI(
payload: AIStreamRequest,
onChunk: (text: string) => void,
signal?: AbortSignal,
): Promise<void> {
const res = await fetch('/api/ai/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal,
})
if (!res.ok || !res.body) {
throw new Error(`AI request failed: ${res.status}`)
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
const lines = buf.split('\n')
buf = lines.pop() ?? ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const data = line.slice(6).trim()
if (data === '[DONE]') return
try {
const parsed = JSON.parse(data) as { delta?: string; error?: string }
if (parsed.error) throw new Error(parsed.error)
if (parsed.delta) onChunk(parsed.delta)
} catch {
// ignore malformed lines
}
}
}
}

View File

@ -10,6 +10,7 @@ interface AppState {
searchQuery: string searchQuery: string
theme: 'light' | 'dark' theme: 'light' | 'dark'
focusMode: boolean focusMode: boolean
aiPanelOpen: boolean
activeTag: string | null activeTag: string | null
sortBy: 'updatedAt' | 'createdAt' | 'title' sortBy: 'updatedAt' | 'createdAt' | 'title'
sortOrder: 'asc' | 'desc' sortOrder: 'asc' | 'desc'
@ -30,6 +31,7 @@ interface AppState {
setSearch: (q: string) => void setSearch: (q: string) => void
toggleTheme: () => void toggleTheme: () => void
toggleFocusMode: () => void toggleFocusMode: () => void
toggleAiPanel: () => void
setActiveTag: (tag: string | null) => void setActiveTag: (tag: string | null) => void
setSortBy: (by: 'updatedAt' | 'createdAt' | 'title') => void setSortBy: (by: 'updatedAt' | 'createdAt' | 'title') => void
setSortOrder: (order: 'asc' | 'desc') => void setSortOrder: (order: 'asc' | 'desc') => void
@ -45,6 +47,7 @@ export const useAppStore = create<AppState>((set, get) => ({
searchQuery: '', searchQuery: '',
theme: (localStorage.getItem('theme') as 'light' | 'dark') || 'light', theme: (localStorage.getItem('theme') as 'light' | 'dark') || 'light',
focusMode: false, focusMode: false,
aiPanelOpen: false,
activeTag: null, activeTag: null,
sortBy: 'updatedAt', sortBy: 'updatedAt',
sortOrder: 'desc', sortOrder: 'desc',
@ -143,6 +146,7 @@ export const useAppStore = create<AppState>((set, get) => ({
}, },
toggleFocusMode: () => set(s => ({ focusMode: !s.focusMode })), toggleFocusMode: () => set(s => ({ focusMode: !s.focusMode })),
toggleAiPanel: () => set(s => ({ aiPanelOpen: !s.aiPanelOpen })),
setActiveTag: (tag) => set({ activeTag: tag }), setActiveTag: (tag) => set({ activeTag: tag }),
setSortBy: (by) => set({ sortBy: by }), setSortBy: (by) => set({ sortBy: by }),
setSortOrder: (order) => set({ sortOrder: order }), setSortOrder: (order) => set({ sortOrder: order }),

View File

@ -4,4 +4,9 @@ import tailwindcss from '@tailwindcss/vite'
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
server: {
proxy: {
'/api': 'http://localhost:3001',
},
},
}) })