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:
co-authored by
Claude Sonnet 4.6
parent
56b59269f4
commit
d632e9f6f7
+192
@@ -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')
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["index.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user