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:
parent
56b59269f4
commit
d632e9f6f7
1
.env.example
Normal file
1
.env.example
Normal file
@ -0,0 +1 @@
|
|||||||
|
ANTHROPIC_API_KEY=sk-ant-your-key-here
|
||||||
913
package-lock.json
generated
913
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@ -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
192
server/index.ts
Normal 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
13
server/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
206
src/components/ai/AiPanel.tsx
Normal file
206
src/components/ai/AiPanel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
216
src/components/ai/ModelSettingsModal.tsx
Normal file
216
src/components/ai/ModelSettingsModal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -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 && (
|
||||||
|
|||||||
@ -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
50
src/lib/ai.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 }),
|
||||||
|
|||||||
@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user