Compare commits
16 Commits
325931aabc
...
27ebe43e54
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27ebe43e54 | ||
|
|
c2e80304d6 | ||
|
|
79db35c995 | ||
|
|
08ae311f3a | ||
|
|
2ffc6e64c1 | ||
|
|
2ade39d58f | ||
|
|
8ce1072b0c | ||
|
|
cffaedb297 | ||
|
|
dbf74dfbab | ||
|
|
42c9ac54c1 | ||
|
|
b8169347a4 | ||
|
|
c26875cc15 | ||
|
|
5faf8aa35e | ||
|
|
bf66b269d4 | ||
|
|
dff9f90bf2 | ||
|
|
dab36ca327 |
1182
package-lock.json
generated
1182
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -60,11 +60,13 @@
|
||||
"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",
|
||||
|
||||
@ -153,7 +153,7 @@ app.post('/api/ai/stream', async (c) => {
|
||||
|
||||
const stream = await aiClient.messages.stream({
|
||||
model: modelId,
|
||||
max_tokens: 1024,
|
||||
max_tokens: 4096,
|
||||
system: buildSystemPrompt(req.type, req.noteContent),
|
||||
messages: buildMessages(req),
|
||||
})
|
||||
|
||||
29
src/App.tsx
29
src/App.tsx
@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Sidebar } from './components/sidebar/Sidebar'
|
||||
import { Editor } from './components/editor/Editor'
|
||||
import { AiPanel } from './components/ai/AiPanel'
|
||||
@ -7,6 +7,7 @@ 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)
|
||||
@ -31,10 +32,34 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full overflow-hidden" style={{ background: 'var(--bg)' }}>
|
||||
<div className={`sidebar-panel${focusMode ? ' hidden' : ''}`} style={{ width: 260 }}>
|
||||
<>
|
||||
{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>
|
||||
{aiPanelOpen && <AiPanel />}
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
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'
|
||||
@ -233,7 +234,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 whitespace-pre-wrap break-words"
|
||||
className="max-w-[85%] rounded-2xl px-3 py-2 text-sm leading-relaxed break-words"
|
||||
style={{
|
||||
background: msg.role === 'user' ? 'var(--accent)' : 'var(--bg-muted)',
|
||||
color: msg.role === 'user' ? '#fff' : 'var(--text)',
|
||||
@ -241,7 +242,27 @@ export function AiPanel() {
|
||||
borderBottomLeftRadius: msg.role === 'assistant' ? 4 : undefined,
|
||||
}}
|
||||
>
|
||||
{msg.content}
|
||||
{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}
|
||||
{streaming && i === messages.length - 1 && msg.role === 'assistant' && (
|
||||
<span className="ai-cursor" />
|
||||
)}
|
||||
|
||||
@ -18,6 +18,7 @@ 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 {
|
||||
@ -188,8 +189,31 @@ 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={f.key === 'apiKey' ? 'password' : 'text'}
|
||||
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}
|
||||
@ -198,6 +222,7 @@ export function ModelSettingsModal({ onClose }: { onClose: () => void }) {
|
||||
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>}
|
||||
|
||||
@ -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,7 +184,14 @@ 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
|
||||
}
|
||||
}
|
||||
@ -313,6 +320,9 @@ 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
|
||||
}
|
||||
@ -345,12 +355,12 @@ export function Editor() {
|
||||
return <WelcomeView />
|
||||
}
|
||||
|
||||
const readingTime = Math.max(1, Math.ceil(wordCount / 250))
|
||||
const readingTime = wordCount > 0 ? Math.max(1, Math.ceil(wordCount / 250)) : 0
|
||||
|
||||
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 max-w-3xl mx-auto w-full">
|
||||
<div className={`px-12 pt-10 pb-0 mx-auto w-full${focusMode ? ' max-w-2xl' : ' max-w-3xl'}`}>
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
value={title}
|
||||
@ -370,14 +380,14 @@ export function Editor() {
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleAiPanel}
|
||||
title="AI 助手"
|
||||
title={aiPanelOpen ? '关闭 AI 助手 (⌘⇧J)' : '打开 AI 助手 (⌘⇧J)'}
|
||||
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
|
||||
@ -391,7 +401,7 @@ export function Editor() {
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleFocusMode}
|
||||
title={focusMode ? '退出专注模式 (Esc)' : '专注模式'}
|
||||
title={focusMode ? '退出专注模式 (Esc 或 ⌘\\)' : '专注模式 (⌘\\)'}
|
||||
className="toolbar-btn shrink-0 mt-2"
|
||||
>
|
||||
{focusMode
|
||||
@ -402,7 +412,7 @@ export function Editor() {
|
||||
</div>
|
||||
|
||||
{/* Tag row */}
|
||||
<div className="px-12 pb-2 max-w-3xl mx-auto w-full">
|
||||
<div className={`px-12 pb-2 mx-auto w-full${focusMode ? ' max-w-2xl' : ' max-w-3xl'}`}>
|
||||
<div className="flex items-center flex-wrap gap-1.5 min-h-[26px]">
|
||||
{activeNote.tags.map(tag => (
|
||||
<span
|
||||
@ -451,7 +461,7 @@ export function Editor() {
|
||||
|
||||
{/* Editor scroll */}
|
||||
<div className="flex-1 overflow-y-auto editor-scroll relative px-12 pt-4 pb-4">
|
||||
<div className="max-w-3xl mx-auto w-full relative">
|
||||
<div className={`mx-auto w-full relative${focusMode ? ' max-w-2xl' : ' max-w-3xl'}`}>
|
||||
<EditorContent editor={editor} />
|
||||
|
||||
{slashOpen && filteredSlash.length > 0 && (
|
||||
@ -488,7 +498,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} 字 · 约 {readingTime} 分钟阅读</span>
|
||||
<span>{wordCount} 字{wordCount > 0 ? ` · 约 ${readingTime} 分钟阅读` : ''}</span>
|
||||
<span style={{ color: saveStatus === 'saved' ? 'var(--text-faint)' : 'var(--accent)' }}>
|
||||
{saveStatus === 'saving' ? '保存中…' : saveStatus === 'unsaved' ? '未保存' : '已自动保存'}
|
||||
</span>
|
||||
|
||||
@ -44,6 +44,7 @@ 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 } }),
|
||||
@ -289,7 +290,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 }}
|
||||
style={{ background: 'var(--bg)', border: '1px solid var(--border)', minWidth: 140, left: 'auto' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{SORT_OPTIONS.map(opt => (
|
||||
@ -332,7 +333,7 @@ export function Sidebar() {
|
||||
if (e.key === 'Escape') setEditingNoteId(null)
|
||||
}}
|
||||
onClick={() => setActiveNote(note.id)}
|
||||
onDelete={() => deleteNote(note.id)}
|
||||
onDelete={() => setDeleteConfirm(note.id)}
|
||||
onToggleStar={() => toggleStar(note.id)}
|
||||
onTagClick={(tag) => setActiveTag(activeTag === tag ? null : tag)}
|
||||
onContextMenu={(e) => {
|
||||
@ -367,7 +368,7 @@ export function Sidebar() {
|
||||
if (n) { setNoteEditValue(n.title); setEditingNoteId(contextMenu.id) }
|
||||
setContextMenu(null)
|
||||
}} />
|
||||
<CtxItem icon={<Trash2 size={13} />} label="删除笔记" danger onClick={async () => { await deleteNote(contextMenu.id); setContextMenu(null) }} />
|
||||
<CtxItem icon={<Trash2 size={13} />} label="删除笔记" danger onClick={() => { setDeleteConfirm(contextMenu.id); setContextMenu(null) }} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@ -424,6 +425,36 @@ 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>
|
||||
)
|
||||
}
|
||||
@ -551,8 +582,9 @@ function DraggableNoteItem({ note, active, editing, editValue, editRef, activeTa
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{hovered ? (
|
||||
<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)' }}
|
||||
@ -571,13 +603,14 @@ function DraggableNoteItem({ note, active, editing, editValue, editRef, activeTa
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs shrink-0 mt-0.5" style={{ color: 'var(--text-faint)' }}>
|
||||
<span className="text-xs" style={{ color: 'var(--text-faint)' }}>
|
||||
{formatDate(note.updatedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{note.tags.length > 0 && (
|
||||
<div className="flex gap-1 mt-1 flex-wrap">
|
||||
{note.tags.slice(0, 3).map(tag => (
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
@ -135,8 +136,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; }
|
||||
.ProseMirror th { background: var(--bg-muted); font-weight: 600; }
|
||||
.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); }
|
||||
|
||||
/* Placeholder */
|
||||
.ProseMirror p.is-editor-empty:first-child::before {
|
||||
@ -286,6 +287,51 @@
|
||||
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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user