Compare commits

..

No commits in common. "27ebe43e540dc1e0d271e4774d014a7d09e96932" and "325931aabc104bb2a27ffdc997e040df32fa001d" have entirely different histories.

9 changed files with 60 additions and 1398 deletions

1182
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -60,13 +60,11 @@
"pdfjs-dist": "^5.7.284",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-markdown": "^10.1.0",
"tailwind-merge": "^3.5.0",
"zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.4",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",

View File

@ -153,7 +153,7 @@ app.post('/api/ai/stream', async (c) => {
const stream = await aiClient.messages.stream({
model: modelId,
max_tokens: 4096,
max_tokens: 1024,
system: buildSystemPrompt(req.type, req.noteContent),
messages: buildMessages(req),
})

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect } from 'react'
import { Sidebar } from './components/sidebar/Sidebar'
import { Editor } from './components/editor/Editor'
import { AiPanel } from './components/ai/AiPanel'
@ -7,7 +7,6 @@ import { seedIfEmpty, deduplicateDB } from './db'
export default function App() {
const { loadAll, theme, focusMode, aiPanelOpen, toggleFocusMode, toggleAiPanel, createNote, activeFolderId } = useAppStore()
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
@ -32,34 +31,10 @@ export default function App() {
return (
<div className="flex h-screen w-full overflow-hidden" style={{ background: 'var(--bg)' }}>
<>
{mobileSidebarOpen && (
<div
className="fixed inset-0 z-30"
style={{ background: 'rgba(0,0,0,0.4)' }}
onClick={() => setMobileSidebarOpen(false)}
/>
)}
<div
className={`sidebar-panel${focusMode ? ' hidden' : ''}`}
style={{ width: 260 }}
data-mobile-open={mobileSidebarOpen}
>
<Sidebar />
</div>
{!focusMode && (
<button
className="mobile-menu-btn"
onClick={() => setMobileSidebarOpen(v => !v)}
aria-label="打开菜单"
>
</button>
)}
</>
<div className="flex-1 min-w-0 h-full flex flex-col editor-main">
<Editor />
<div className={`sidebar-panel${focusMode ? ' hidden' : ''}`} style={{ width: 260 }}>
<Sidebar />
</div>
<Editor />
{aiPanelOpen && <AiPanel />}
</div>
)

View File

@ -1,6 +1,5 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { X, Send, Sparkles, Square, FileText, GripHorizontal } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import { useAppStore } from '../../stores/appStore'
import { streamAI } from '../../lib/ai'
import { extractTextFromJSON } from '../../lib/utils'
@ -234,7 +233,7 @@ export function AiPanel() {
{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 break-words"
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)',
@ -242,27 +241,7 @@ export function AiPanel() {
borderBottomLeftRadius: msg.role === 'assistant' ? 4 : undefined,
}}
>
{msg.role === 'assistant' ? (
<div className="prose prose-sm max-w-none">
<ReactMarkdown
components={{
p: ({children}) => <p className="mb-1 last:mb-0">{children}</p>,
ul: ({children}) => <ul className="list-disc pl-4 mb-1">{children}</ul>,
ol: ({children}) => <ol className="list-decimal pl-4 mb-1">{children}</ol>,
li: ({children}) => <li className="mb-0.5">{children}</li>,
pre: ({children}) => (
<pre className="rounded-lg p-3 my-2 overflow-x-auto text-xs" style={{background:'var(--bg-muted)'}}>
{children}
</pre>
),
code: ({className, children}) => (
<code className={`px-1 rounded text-xs ${className ?? ''}`} style={{background:'var(--bg-muted)'}}>{children}</code>
),
strong: ({children}) => <strong style={{color:'var(--text)'}}>{children}</strong>,
}}
>{msg.content}</ReactMarkdown>
</div>
) : msg.content}
{msg.content}
{streaming && i === messages.length - 1 && msg.role === 'assistant' && (
<span className="ai-cursor" />
)}

View File

@ -18,7 +18,6 @@ export function ModelSettingsModal({ onClose }: { onClose: () => void }) {
const [formError, setFormError] = useState('')
const [saving, setSaving] = useState(false)
const [expandedId, setExpandedId] = useState<string | null>(null)
const [showKey, setShowKey] = useState(false)
const fetchModels = async () => {
try {
@ -189,40 +188,16 @@ export function ModelSettingsModal({ onClose }: { onClose: () => void }) {
].map(f => (
<div key={f.key}>
<label className="text-xs" style={{ color: 'var(--text-faint)' }}>{f.label}</label>
{f.key === 'apiKey' ? (
<div className="relative">
<input
type={showKey ? 'text' : 'password'}
autoComplete="off"
value={form.apiKey}
onChange={e => setForm(prev => ({ ...prev, apiKey: e.target.value }))}
placeholder={f.placeholder}
className="w-full mt-0.5 px-3 py-1.5 rounded-lg text-sm outline-none pr-12"
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)')}
/>
<button
type="button"
onClick={() => setShowKey(v => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-xs"
style={{ color: 'var(--text-faint)' }}
>
{showKey ? '隐藏' : '显示'}
</button>
</div>
) : (
<input
type="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)')}
/>
)}
<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>}

View File

@ -49,7 +49,7 @@ const SLASH_ITEMS = [
const url = window.prompt('图片地址URL')
if (url) e?.chain().focus().setImage({ src: url }).run()
}},
{ label: '分割线', desc: '插入水平分割线', icon: <Minus size={14} />, action: (e: ReturnType<typeof useEditor>) => e?.chain().focus().setHorizontalRule().run() },
{ label: '分割线', desc: '水平线', icon: <Minus size={14} />, action: (e: ReturnType<typeof useEditor>) => e?.chain().focus().setHorizontalRule().run() },
{ label: '表格', desc: '插入表格', icon: <TableIcon size={14} />, action: (e: ReturnType<typeof useEditor>) => e?.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
]
@ -184,14 +184,7 @@ export function Editor() {
return true
}
if (event.key === 'Escape') {
if (slashStartPos.current !== null && editor) {
const { state, dispatch } = editor.view
const { from } = state.selection
const tr = state.tr.delete(slashStartPos.current, from)
dispatch(tr)
}
setSlashOpen(false)
slashStartPos.current = null
return true
}
}
@ -320,9 +313,6 @@ export function Editor() {
} catch (e) {
if ((e as Error).name === 'AbortError') return
} finally {
if (editor) {
editor.commands.setTextSelection(editor.state.doc.content.size)
}
setAiContinuing(false)
aiAbortRef.current = null
}
@ -355,12 +345,12 @@ export function Editor() {
return <WelcomeView />
}
const readingTime = wordCount > 0 ? Math.max(1, Math.ceil(wordCount / 250)) : 0
const readingTime = Math.max(1, Math.ceil(wordCount / 250))
return (
<div className="flex-1 flex flex-col min-w-0 h-full" style={{ background: 'var(--bg)' }}>
{/* Title row */}
<div className={`px-12 pt-10 pb-0 mx-auto w-full${focusMode ? ' max-w-2xl' : ' max-w-3xl'}`}>
<div className="px-12 pt-10 pb-0 max-w-3xl mx-auto w-full">
<div className="flex items-start gap-2">
<input
value={title}
@ -380,14 +370,14 @@ export function Editor() {
</button>
<button
onClick={toggleAiPanel}
title={aiPanelOpen ? '关闭 AI 助手 (⌘⇧J)' : '打开 AI 助手 (⌘⇧J)'}
title="AI 助手"
className="toolbar-btn shrink-0 mt-2"
>
<Sparkles size={14} style={{ color: aiPanelOpen ? 'var(--accent)' : 'var(--text-faint)' }} />
</button>
<button
onClick={() => activeNoteId && toggleStar(activeNoteId)}
title={activeNote.starred ? '取消收藏' : '收藏笔记'}
title={activeNote.starred ? '取消收藏' : '收藏'}
className="toolbar-btn shrink-0 mt-2"
>
<Star
@ -401,7 +391,7 @@ export function Editor() {
</div>
<button
onClick={toggleFocusMode}
title={focusMode ? '退出专注模式 (Esc 或 ⌘\\)' : '专注模式 (⌘\\)'}
title={focusMode ? '退出专注模式 (Esc)' : '专注模式'}
className="toolbar-btn shrink-0 mt-2"
>
{focusMode
@ -412,7 +402,7 @@ export function Editor() {
</div>
{/* Tag row */}
<div className={`px-12 pb-2 mx-auto w-full${focusMode ? ' max-w-2xl' : ' max-w-3xl'}`}>
<div className="px-12 pb-2 max-w-3xl mx-auto w-full">
<div className="flex items-center flex-wrap gap-1.5 min-h-[26px]">
{activeNote.tags.map(tag => (
<span
@ -461,7 +451,7 @@ export function Editor() {
{/* Editor scroll */}
<div className="flex-1 overflow-y-auto editor-scroll relative px-12 pt-4 pb-4">
<div className={`mx-auto w-full relative${focusMode ? ' max-w-2xl' : ' max-w-3xl'}`}>
<div className="max-w-3xl mx-auto w-full relative">
<EditorContent editor={editor} />
{slashOpen && filteredSlash.length > 0 && (
@ -498,7 +488,7 @@ export function Editor() {
className="flex items-center justify-between px-12 py-2 text-xs shrink-0"
style={{ borderTop: '1px solid var(--border)', color: 'var(--text-faint)', background: 'var(--bg)' }}
>
<span>{wordCount} {wordCount > 0 ? ` · 约 ${readingTime} 分钟阅读` : ''}</span>
<span>{wordCount} · {readingTime} </span>
<span style={{ color: saveStatus === 'saved' ? 'var(--text-faint)' : 'var(--accent)' }}>
{saveStatus === 'saving' ? '保存中…' : saveStatus === 'unsaved' ? '未保存' : '已自动保存'}
</span>

View File

@ -44,7 +44,6 @@ export function Sidebar() {
y: number
} | null>(null)
const [draggingNoteId, setDraggingNoteId] = useState<string | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 6 } }),
@ -290,7 +289,7 @@ export function Sidebar() {
{sortMenuOpen && (
<div
className="absolute right-0 top-full mt-1 rounded-lg py-1 z-50 shadow-xl"
style={{ background: 'var(--bg)', border: '1px solid var(--border)', minWidth: 140, left: 'auto' }}
style={{ background: 'var(--bg)', border: '1px solid var(--border)', minWidth: 140 }}
onClick={e => e.stopPropagation()}
>
{SORT_OPTIONS.map(opt => (
@ -333,7 +332,7 @@ export function Sidebar() {
if (e.key === 'Escape') setEditingNoteId(null)
}}
onClick={() => setActiveNote(note.id)}
onDelete={() => setDeleteConfirm(note.id)}
onDelete={() => deleteNote(note.id)}
onToggleStar={() => toggleStar(note.id)}
onTagClick={(tag) => setActiveTag(activeTag === tag ? null : tag)}
onContextMenu={(e) => {
@ -368,7 +367,7 @@ export function Sidebar() {
if (n) { setNoteEditValue(n.title); setEditingNoteId(contextMenu.id) }
setContextMenu(null)
}} />
<CtxItem icon={<Trash2 size={13} />} label="删除笔记" danger onClick={() => { setDeleteConfirm(contextMenu.id); setContextMenu(null) }} />
<CtxItem icon={<Trash2 size={13} />} label="删除笔记" danger onClick={async () => { await deleteNote(contextMenu.id); setContextMenu(null) }} />
</>
)}
</div>
@ -425,36 +424,6 @@ export function Sidebar() {
</div>
)}
</DragOverlay>
{deleteConfirm && (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.4)' }}
onClick={() => setDeleteConfirm(null)}
>
<div
className="rounded-2xl shadow-2xl p-5 flex flex-col gap-4"
style={{ background: 'var(--bg)', border: '1px solid var(--border)', width: 320 }}
onClick={e => e.stopPropagation()}
>
<p className="text-sm font-semibold" style={{ color: 'var(--text)' }}></p>
<p className="text-xs" style={{ color: 'var(--muted)' }}></p>
<div className="flex justify-end gap-2">
<button
onClick={() => setDeleteConfirm(null)}
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={async () => { await deleteNote(deleteConfirm); setDeleteConfirm(null) }}
className="px-3 py-1.5 rounded-lg text-sm font-medium"
style={{ background: '#ef4444', color: '#fff' }}
></button>
</div>
</div>
</div>
)}
</DndContext>
)
}
@ -582,34 +551,32 @@ function DraggableNoteItem({ note, active, editing, editValue, editRef, activeTa
</span>
)}
</div>
<div className="flex items-center gap-0.5 shrink-0">
{hovered ? (
<>
<button
className="toolbar-btn"
style={{ width: 20, height: 20, color: note.starred ? '#f59e0b' : 'var(--text-faint)' }}
title={note.starred ? '取消收藏' : '收藏'}
onClick={(e) => { e.stopPropagation(); onToggleStar() }}
>
<Star size={12} fill={note.starred ? '#f59e0b' : 'none'} />
</button>
<button
className="toolbar-btn"
style={{ width: 20, height: 20, color: '#ef4444', opacity: 0.8 }}
title="删除笔记"
onClick={(e) => { e.stopPropagation(); onDelete() }}
onMouseEnter={e => (e.currentTarget.style.opacity = '1')}
onMouseLeave={e => (e.currentTarget.style.opacity = '0.8')}
>
<Trash2 size={12} />
</button>
</>
) : (
<span className="text-xs" style={{ color: 'var(--text-faint)' }}>
{formatDate(note.updatedAt)}
</span>
)}
</div>
{hovered ? (
<div className="flex items-center gap-0.5 shrink-0">
<button
className="toolbar-btn"
style={{ width: 20, height: 20, color: note.starred ? '#f59e0b' : 'var(--text-faint)' }}
title={note.starred ? '取消收藏' : '收藏'}
onClick={(e) => { e.stopPropagation(); onToggleStar() }}
>
<Star size={12} fill={note.starred ? '#f59e0b' : 'none'} />
</button>
<button
className="toolbar-btn"
style={{ width: 20, height: 20, color: '#ef4444', opacity: 0.8 }}
title="删除笔记"
onClick={(e) => { e.stopPropagation(); onDelete() }}
onMouseEnter={e => (e.currentTarget.style.opacity = '1')}
onMouseLeave={e => (e.currentTarget.style.opacity = '0.8')}
>
<Trash2 size={12} />
</button>
</div>
) : (
<span className="text-xs shrink-0 mt-0.5" style={{ color: 'var(--text-faint)' }}>
{formatDate(note.updatedAt)}
</span>
)}
</div>
{note.tags.length > 0 && (
<div className="flex gap-1 mt-1 flex-wrap">

View File

@ -1,5 +1,4 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@layer base {
:root {
@ -136,8 +135,8 @@
/* Table */
.ProseMirror table { border-collapse: collapse; width: 100%; margin: 0.75em 0; font-size: 0.9em; }
.ProseMirror th, .ProseMirror td { border: 1px solid var(--border); padding: 0.5em 0.75em; text-align: left; min-width: 80px; }
.ProseMirror th { background: var(--bg-muted); font-weight: 600; color: var(--text); border-bottom: 2px solid var(--border); }
.ProseMirror th, .ProseMirror td { border: 1px solid var(--border); padding: 0.5em 0.75em; text-align: left; }
.ProseMirror th { background: var(--bg-muted); font-weight: 600; }
/* Placeholder */
.ProseMirror p.is-editor-empty:first-child::before {
@ -287,51 +286,6 @@
animation: ai-blink 0.8s ease infinite;
}
/* 移动端响应式 */
.mobile-menu-btn {
display: none;
}
@media (max-width: 640px) {
.mobile-menu-btn {
display: flex;
position: fixed;
top: 12px;
left: 12px;
z-index: 40;
width: 36px;
height: 36px;
align-items: center;
justify-content: center;
border-radius: 8px;
font-size: 18px;
background: var(--bg-subtle);
border: 1px solid var(--border);
color: var(--text);
cursor: pointer;
}
.sidebar-panel {
position: fixed !important;
top: 0;
left: 0;
bottom: 0;
z-index: 35;
transform: translateX(-100%);
transition: transform 0.22s ease;
box-shadow: 4px 0 24px rgba(0,0,0,0.3);
}
.sidebar-panel[data-mobile-open="true"] {
transform: translateX(0);
}
.editor-main {
margin-left: 0 !important;
padding-left: 48px;
}
}
/* ── Print / PDF export ── */
@media print {
aside, [data-ai-panel], .floating-toolbar, .editor-toolbar,