From aa1430d4bfc53a13a9d3c5c3d0b77d50cf010f04 Mon Sep 17 00:00:00 2001 From: MikiVL Date: Sun, 3 May 2026 00:28:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(M4):=20=E7=BB=86=E8=8A=82=E6=89=93?= =?UTF-8?q?=E7=A3=A8=E2=80=94=E2=80=94=E5=8F=AF=E6=8B=96=E6=8B=BD/?= =?UTF-8?q?=E7=BC=A9=E6=94=BE=20AI=20=E6=B5=AE=E7=AA=97=E3=80=81=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=E5=8A=A8=E7=94=BB=E3=80=81=E5=85=A8=E5=B1=80=E5=BF=AB?= =?UTF-8?q?=E6=8D=B7=E9=94=AE=E3=80=81=E7=BF=BB=E8=AF=91=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AI 助手改为可自由拖拽移动的浮窗(position: fixed,默认右下角) - 支持 8 方向拖拽缩放(N/S/E/W + 四角),尺寸 280–720 × 320–900px - 弹窗(ModelSettingsModal、AiResultModal)加入 framer-motion 缩放动画 - App.tsx 注册全局快捷键:Cmd+\ 专注模式、Cmd+Shift+J AI 面板、Cmd+N 新建笔记 - 浮动工具栏新增"翻译成英文"按钮(Languages 图标),流式预览后可替换 - 服务端 buildMessages 增加 translate 分支 - AI 面板 textarea 随输入自动扩展高度(最多 5 行) - 欢迎页功能一览补充 AI 续写/润色/摘要/问答四卡片及三条快捷键 Co-Authored-By: Claude Sonnet 4.6 --- server/index.ts | 6 +- src/App.tsx | 15 +- src/components/ai/AiPanel.tsx | 186 ++++++++++++++++++----- src/components/ai/ModelSettingsModal.tsx | 17 ++- src/components/editor/Editor.tsx | 71 +++++---- src/components/editor/WelcomeView.tsx | 25 ++- src/components/sidebar/Sidebar.tsx | 5 +- src/lib/ai.ts | 2 +- 8 files changed, 249 insertions(+), 78 deletions(-) diff --git a/server/index.ts b/server/index.ts index fb4b4c0..f53048d 100644 --- a/server/index.ts +++ b/server/index.ts @@ -104,7 +104,7 @@ app.delete('/api/models/:id', (c) => { // ── AI types ────────────────────────────────────────────────────────────────── type AIStreamRequest = { - type: 'continue' | 'polish' | 'summarize' | 'chat' + type: 'continue' | 'polish' | 'summarize' | 'translate' | 'chat' noteContent: string selection?: string messages?: { role: 'user' | 'assistant'; content: string }[] @@ -131,6 +131,10 @@ function buildMessages(req: AIStreamRequest): Anthropic.MessageParam[] { return [{ role: 'user', content: `请润色以下文字,改善语法、流畅度和表达,保持原意,直接输出润色后的结果,不要有任何解释:\n\n${selection ?? noteContent}` }] } + if (type === 'translate') { + return [{ role: 'user', content: `请将以下文字翻译成英文,保持原文风格和格式,直接输出翻译结果,不要有任何解释:\n\n${selection ?? noteContent}` }] + } + return [{ role: 'user', content: `请为以下内容提炼要点摘要,用简洁的中文输出,不超过 150 字:\n\n${selection ?? noteContent}` }] } diff --git a/src/App.tsx b/src/App.tsx index 927e259..f0e98a6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,7 +6,7 @@ import { useAppStore } from './stores/appStore' import { seedIfEmpty, deduplicateDB } from './db' export default function App() { - const { loadAll, theme, focusMode, aiPanelOpen } = useAppStore() + const { loadAll, theme, focusMode, aiPanelOpen, toggleFocusMode, toggleAiPanel, createNote, activeFolderId } = useAppStore() useEffect(() => { document.documentElement.setAttribute('data-theme', theme) @@ -18,13 +18,24 @@ export default function App() { .then(() => loadAll()) }, []) + useEffect(() => { + const handler = (e: KeyboardEvent) => { + const mod = e.metaKey || e.ctrlKey + if (mod && e.key === '\\') { e.preventDefault(); toggleFocusMode() } + if (mod && e.shiftKey && e.key === 'J') { e.preventDefault(); toggleAiPanel() } + if (mod && e.key === 'n') { e.preventDefault(); createNote(activeFolderId as string | null) } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [toggleFocusMode, toggleAiPanel, createNote, activeFolderId]) + return (
- {aiPanelOpen && !focusMode && } + {aiPanelOpen && }
) } diff --git a/src/components/ai/AiPanel.tsx b/src/components/ai/AiPanel.tsx index 1d3ef39..951aa20 100644 --- a/src/components/ai/AiPanel.tsx +++ b/src/components/ai/AiPanel.tsx @@ -1,10 +1,30 @@ -import { useState, useRef, useEffect } from 'react' -import { X, Send, Sparkles, Square, FileText } from 'lucide-react' +import { useState, useRef, useEffect, useCallback } from 'react' +import { X, Send, Sparkles, Square, FileText, GripHorizontal } from 'lucide-react' import { useAppStore } from '../../stores/appStore' import { streamAI } from '../../lib/ai' import { extractTextFromJSON } from '../../lib/utils' type Message = { role: 'user' | 'assistant'; content: string } +type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw' + +const DEFAULT_W = 360 +const DEFAULT_H = 520 +const MIN_W = 280 +const MIN_H = 320 +const MAX_W = 720 +const MAX_H = 900 +const HANDLE = 6 // resize handle thickness px + +const RESIZE_HANDLES: { dir: ResizeDir; cursor: string; style: React.CSSProperties }[] = [ + { dir: 'n', cursor: 'ns-resize', style: { top: 0, left: HANDLE, right: HANDLE, height: HANDLE } }, + { dir: 's', cursor: 'ns-resize', style: { bottom: 0, left: HANDLE, right: HANDLE, height: HANDLE } }, + { dir: 'e', cursor: 'ew-resize', style: { right: 0, top: HANDLE, bottom: HANDLE, width: HANDLE } }, + { dir: 'w', cursor: 'ew-resize', style: { left: 0, top: HANDLE, bottom: HANDLE, width: HANDLE } }, + { dir: 'nw', cursor: 'nwse-resize', style: { top: 0, left: 0, width: HANDLE * 2, height: HANDLE * 2 } }, + { dir: 'ne', cursor: 'nesw-resize', style: { top: 0, right: 0, width: HANDLE * 2, height: HANDLE * 2 } }, + { dir: 'sw', cursor: 'nesw-resize', style: { bottom: 0, left: 0, width: HANDLE * 2, height: HANDLE * 2 } }, + { dir: 'se', cursor: 'nwse-resize', style: { bottom: 0, right: 0, width: HANDLE * 2, height: HANDLE * 2 } }, +] export function AiPanel() { const { toggleAiPanel, notes, activeNoteId } = useAppStore() @@ -17,10 +37,75 @@ export function AiPanel() { const bottomRef = useRef(null) const textareaRef = useRef(null) + const [pos, setPos] = useState(() => ({ + x: window.innerWidth - DEFAULT_W - 24, + y: window.innerHeight - DEFAULT_H - 24, + })) + const [size, setSize] = useState({ w: DEFAULT_W, h: DEFAULT_H }) + + // refs for drag/resize so callbacks don't go stale + const posRef = useRef(pos) + const sizeRef = useRef(size) + useEffect(() => { posRef.current = pos }, [pos]) + useEffect(() => { sizeRef.current = size }, [size]) + useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages]) + const onDragStart = useCallback((e: React.MouseEvent) => { + e.preventDefault() + const startX = e.clientX, startY = e.clientY + const { x: origX, y: origY } = posRef.current + const { w, h } = sizeRef.current + + const onMove = (ev: MouseEvent) => { + const nextX = Math.max(0, Math.min(window.innerWidth - w, origX + ev.clientX - startX)) + const nextY = Math.max(0, Math.min(window.innerHeight - h, origY + ev.clientY - startY)) + setPos({ x: nextX, y: nextY }) + } + const onUp = () => { + window.removeEventListener('mousemove', onMove) + window.removeEventListener('mouseup', onUp) + } + window.addEventListener('mousemove', onMove) + window.addEventListener('mouseup', onUp) + }, []) + + const onResizeStart = useCallback((e: React.MouseEvent, dir: ResizeDir) => { + e.preventDefault() + e.stopPropagation() + const startX = e.clientX, startY = e.clientY + const { x: origX, y: origY } = posRef.current + const { w: origW, h: origH } = sizeRef.current + + const onMove = (ev: MouseEvent) => { + const dx = ev.clientX - startX + const dy = ev.clientY - startY + let newX = origX, newY = origY, newW = origW, newH = origH + + if (dir.includes('e')) newW = Math.max(MIN_W, Math.min(MAX_W, origW + dx)) + if (dir.includes('s')) newH = Math.max(MIN_H, Math.min(MAX_H, origH + dy)) + if (dir.includes('w')) { + newW = Math.max(MIN_W, Math.min(MAX_W, origW - dx)) + newX = origX + origW - newW + } + if (dir.includes('n')) { + newH = Math.max(MIN_H, Math.min(MAX_H, origH - dy)) + newY = origY + origH - newH + } + + setPos({ x: Math.max(0, newX), y: Math.max(0, newY) }) + setSize({ w: newW, h: newH }) + } + const onUp = () => { + window.removeEventListener('mousemove', onMove) + window.removeEventListener('mouseup', onUp) + } + window.addEventListener('mousemove', onMove) + window.addEventListener('mouseup', onUp) + }, []) + const noteContent = activeNote ? `标题:${activeNote.title}\n\n${extractTextFromJSON(activeNote.content)}` : '' @@ -29,29 +114,20 @@ export function AiPanel() { if (!text.trim() || streaming) return const userMsg: Message = { role: 'user', content: text.trim() } const assistantMsg: Message = { role: 'assistant', content: '' } - setMessages(prev => [...prev, userMsg, assistantMsg]) setInput('') + if (textareaRef.current) textareaRef.current.style.height = 'auto' setStreaming(true) const controller = new AbortController() abortRef.current = controller - try { await streamAI( - { - type: 'chat', - noteContent, - messages: messages.concat(userMsg), - userMessage: userMsg.content, - }, + { type: 'chat', noteContent, messages: messages.concat(userMsg), userMessage: userMsg.content }, (chunk) => { setMessages(prev => { const next = [...prev] - next[next.length - 1] = { - ...next[next.length - 1], - content: next[next.length - 1].content + chunk, - } + next[next.length - 1] = { ...next[next.length - 1], content: next[next.length - 1].content + chunk } return next }) }, @@ -71,30 +147,55 @@ export function AiPanel() { } } - const summarize = () => { - if (!noteContent) return - send('请为当前笔记生成一份简洁的摘要。') - } - - const stop = () => { - abortRef.current?.abort() - } - return (
- {/* Header */} + {/* Resize handles */} + {RESIZE_HANDLES.map(({ dir, cursor, style }) => ( +
onResizeStart(e, dir)} + style={{ position: 'absolute', zIndex: 10, cursor, ...style }} + /> + ))} + + {/* Header / drag handle */}
- + + AI 助手
-
@@ -102,9 +203,9 @@ export function AiPanel() { {/* Quick actions */}