diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 39f334b1..d226574b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -15,6 +15,8 @@ Laputa is a personal knowledge and life management desktop app. It reads a vault | Build | Vite | 7.3.1 | | Backend language | Rust (edition 2021) | 1.77.2 | | Frontmatter parsing | gray_matter | 0.2 | +| AI | Anthropic Claude API (Haiku 3.5 default) | - | +| MCP | @modelcontextprotocol/sdk | 1.0 | | Tests | Vitest (unit), Playwright (E2E), cargo test (Rust) | - | | Package manager | pnpm | - | @@ -30,19 +32,22 @@ Laputa is a personal knowledge and life management desktop app. It reads a vault │ │ ├── Sidebar (navigation + filters) │ │ │ │ ├── NoteList (filtered note list) │ │ │ │ ├── Editor (BlockNote + tabs + diff) │ │ -│ │ │ └── Inspector (metadata + relationships) │ │ +│ │ │ ├── Inspector (metadata + relationships) │ │ +│ │ │ └── AIChatPanel (AI assistant + context) │ │ │ │ ├── StatusBar (footer info) │ │ │ │ └── Modals (QuickOpen, CreateNote, CommitDialog) │ │ │ │ │ │ -│ └──────────────┬───────────────────────────────────────┘ │ -│ │ Tauri IPC (invoke) │ -│ ┌──────────────▼───────────────────────────────────────┐ │ -│ │ Rust Backend │ │ -│ │ lib.rs → 9 Tauri commands │ │ -│ │ vault.rs → file scanning, frontmatter parsing │ │ -│ │ frontmatter.rs → YAML manipulation │ │ -│ │ git.rs → git log, diff, commit, push │ │ -│ └──────────────────────────────────────────────────────┘ │ +│ └──────────────┬──────────┬──────────────────────────┘ │ +│ │ │ │ +│ Tauri IPC│ Vite Proxy / WS │ +│ ┌──────────────▼────┐ ┌──▼───────────────────────────┐ │ +│ │ Rust Backend │ │ External Services │ │ +│ │ lib.rs → 10 cmds │ │ Anthropic API (Claude) │ │ +│ │ vault.rs │ │ MCP Server (ws://9710) │ │ +│ │ frontmatter.rs │ │ │ │ +│ │ git.rs │ └──────────────────────────────┘ │ +│ │ ai_chat.rs │ │ +│ └───────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ``` @@ -52,13 +57,13 @@ Laputa is a personal knowledge and life management desktop app. It reads a vault ┌────────┬─────────────┬─────────────────────────┬────────────┐ │Sidebar │ Note List │ Editor │ Inspector │ │(250px) │ (300px) │ (flex-1) │ (280px) │ -│ │ │ │ │ -│ All │ [Search] │ [Tab Bar] │ Properties │ -│ Favs │ [Type Pill] │ [Breadcrumb Bar] │ Relations │ -│ │ │ │ Backlinks │ -│Projects│ Note 1 │ # My Note │ Git History│ -│Experim.│ Note 2 │ │ │ -│Respons.│ Note 3 │ Content here... │ │ +│ │ │ │ OR │ +│ All │ [Search] │ [Tab Bar] │ AI Chat │ +│ Favs │ [Type Pill] │ [Breadcrumb Bar] │ │ +│ │ │ │ Context │ +│Projects│ Note 1 │ # My Note │ Messages │ +│Experim.│ Note 2 │ │ Actions │ +│Respons.│ Note 3 │ Content here... │ Input │ │Procedu.│ ... │ │ │ │People │ │ │ │ │Events │ │ │ │ @@ -71,10 +76,106 @@ Laputa is a personal knowledge and life management desktop app. It reads a vault - **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Favorites) and collapsible section groups (Projects, Experiments, Responsibilities, etc.) - **Note List** (200-500px, resizable): Filtered list of notes matching the sidebar selection. Shows snippets, modified dates, and relationship groups. - **Editor** (flex, fills remaining space): Tab bar, breadcrumb bar with word count and modified indicator, BlockNote editor with wikilink support. Can toggle to diff view for modified files. -- **Inspector** (200-500px or 40px collapsed): Frontmatter properties (editable), relationships, backlinks, git history. Collapses to a thin icon strip. +- **Inspector / AI Chat** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, backlinks, git history) and AI Chat panel. The Sparkle icon in the breadcrumb bar toggles between them. Panels are separated by `ResizeHandle` components that support drag-to-resize. +## AI Chat System + +### Architecture + +The AI chat feature has three layers: + +1. **Frontend** (`AIChatPanel` + `useAIChat` hook) — UI and state management +2. **API Proxy** (Vite middleware in dev, Rust `ai_chat` command in Tauri) — routes to Anthropic +3. **MCP Server** (`mcp-server/`) — vault operation tools for AI assistants + +### Data Flow + +``` +User types message in AIChatPanel + → useAIChat.sendMessage(text) + → buildSystemPrompt(contextNotes, allContent, model) + → Assembles selected notes as system context + → Estimates tokens, truncates if needed + → streamChat(messages, systemPrompt, model, callbacks) + → POST /api/ai/chat (Vite proxy → Anthropic API) + → SSE stream parsed, chunks dispatched to onChunk callback + → UI updates in real-time as tokens arrive + → On completion: message added to conversation history +``` + +### Context Picker + +The context picker controls which notes are sent to the AI as context: + +- **Current note** is auto-added when the panel opens +- **Add button** opens a search dropdown to select additional notes +- **Token estimation** shows approximate context size (~4 chars/token) +- **Truncation** kicks in when context exceeds 60% of model limit (108k tokens) +- Context pills show selected notes with remove buttons + +### API Key Management + +- Stored in `localStorage` under key `laputa:anthropic-api-key` +- Configurable via the key icon in the AI Chat header +- When no key is set, falls back to mock responses for testing + +### Models + +| Model | ID | Use case | +|-------|----|----------| +| Haiku 3.5 | `claude-3-5-haiku-20241022` | Fast, cheap — default | +| Sonnet 4 | `claude-sonnet-4-20250514` | Balanced | +| Opus 4 | `claude-opus-4-20250514` | Most capable | + +### MCP Server + +The MCP server (`mcp-server/`) exposes vault operations as tools: + +| Tool | Description | +|------|-------------| +| `open_note` | Open and read a note by path | +| `read_note` | Read note content (alias) | +| `create_note` | Create new note with frontmatter | +| `search_notes` | Search by title or content | +| `append_to_note` | Append text to a note | + +**Transports:** +- **stdio** — standard MCP transport (`node mcp-server/index.js`) +- **WebSocket** — live bridge for app integration (`node mcp-server/ws-bridge.js`, port 9710) + +### WebSocket Bridge + +The WebSocket bridge (`useMcpBridge` hook) enables real-time vault operations from the frontend: + +``` +Frontend (useMcpBridge) ←→ ws://localhost:9710 ←→ ws-bridge.js ←→ vault.js +``` + +Protocol: JSON-RPC-like with `{id, tool, args}` requests and `{id, result}` responses. + +### Rust Backend (Tauri) + +The `ai_chat` Tauri command (`src-tauri/src/ai_chat.rs`) provides a non-streaming alternative: +- Uses `reqwest` to call the Anthropic Messages API directly +- API key from `ANTHROPIC_API_KEY` environment variable +- Returns full response (not streamed) +- Used in production Tauri builds where Vite proxy is unavailable + +### Files + +| File | Purpose | +|------|---------| +| `src/components/AIChatPanel.tsx` | Main UI: context bar, messages, input, quick actions | +| `src/hooks/useAIChat.ts` | Chat state: messages, streaming, send/retry/clear | +| `src/hooks/useMcpBridge.ts` | WebSocket client for MCP vault tool calls | +| `src/utils/ai-chat.ts` | API client, token estimation, context builder | +| `src-tauri/src/ai_chat.rs` | Rust Anthropic API client (non-streaming) | +| `mcp-server/index.js` | MCP server entry (stdio transport) | +| `mcp-server/vault.js` | Vault file operations | +| `mcp-server/ws-bridge.js` | WebSocket bridge server | + ## Data Flow ### Startup Sequence @@ -138,12 +239,13 @@ All commands are defined in `src-tauri/src/lib.rs` and registered via `tauri::ge | `get_file_diff` | `vault_path, path` | `String` (unified diff) | `git::get_file_diff()` | | `git_commit` | `vault_path, message` | `String` | `git::git_commit()` | | `git_push` | `vault_path` | `String` | `git::git_push()` | +| `ai_chat` | `request: AiChatRequest` | `AiChatResponse` | `ai_chat::send_chat()` | All commands return `Result`. Errors are serialized as JSON error objects to the frontend. ## Mock Layer -When running outside Tauri (browser at `localhost:5173`), `src/mock-tauri.ts` provides a transparent mock layer: +When running outside Tauri (browser at `localhost:5201`), `src/mock-tauri.ts` provides a transparent mock layer: ```typescript // In hooks, the pattern is always: @@ -155,22 +257,25 @@ if (isTauri()) { ``` The mock layer includes: -- **12 sample entries** across all entity types (Project, Responsibility, Procedure, Experiment, Note, Person, Event, Topic) +- **15 sample entries** across all entity types (Project, Responsibility, Procedure, Experiment, Note, Person, Event, Topic, Essay) - **Full markdown content** with realistic frontmatter for each entry - **Mock git history, modified files, and diff output** +- **Mock AI chat responses** with context-aware answers (summarize, expand, grammar) - `addMockEntry()` and `updateMockContent()` for runtime updates This means the entire UI can be developed and tested in Chrome without the Rust backend. ## State Management -No Redux or global context. State lives in the root `App.tsx` and two custom hooks: +No Redux or global context. State lives in the root `App.tsx` and custom hooks: | State owner | State | Purpose | |-------------|-------|---------| -| `App.tsx` | `selection`, panel widths, dialog visibility, toast | UI state | +| `App.tsx` | `selection`, panel widths, dialog visibility, toast, `showAIChat` | UI state | | `useVaultLoader` | `entries`, `allContent`, `modifiedFiles` | Vault data | | `useNoteActions` | `tabs`, `activeTabPath` | Open tabs and note operations | +| `useAIChat` | `messages`, `isStreaming`, `streamingContent` | AI conversation state | +| `useMcpBridge` | `connected`, tool methods | MCP WebSocket connection | Data flows unidirectionally: `App` passes data and callbacks as props to child components. No child-to-child communication — everything goes through `App`. diff --git a/src/App.tsx b/src/App.tsx index 1704b0ae..7c5bf1b7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,6 @@ import { useCallback, useEffect, useState } from 'react' import { Sidebar } from './components/Sidebar' import { NoteList } from './components/NoteList' import { Editor } from './components/Editor' -import { AIChatPanel } from './components/AIChatPanel' import { ResizeHandle } from './components/ResizeHandle' import { CreateNoteDialog } from './components/CreateNoteDialog' import { QuickOpenPalette } from './components/QuickOpenPalette' diff --git a/src/components/AIChatPanel.tsx b/src/components/AIChatPanel.tsx index c34cbab1..e6957aaf 100644 --- a/src/components/AIChatPanel.tsx +++ b/src/components/AIChatPanel.tsx @@ -206,7 +206,7 @@ function useContextNotes(entry: VaultEntry | null) { export function AIChatPanel({ entry, allContent, entries = [], onClose }: AIChatPanelProps) { const [input, setInput] = useState('') - const [model, setModel] = useState(MODEL_OPTIONS[0].value) + const [model, setModel] = useState(MODEL_OPTIONS[0].value) const [showSearch, setShowSearch] = useState(false) const [showApiKeyDialog, setShowApiKeyDialog] = useState(false) const messagesEndRef = useRef(null) diff --git a/src/components/Editor.test.tsx b/src/components/Editor.test.tsx index dcce96a9..313f523d 100644 --- a/src/components/Editor.test.tsx +++ b/src/components/Editor.test.tsx @@ -47,6 +47,8 @@ const mockEntry: VaultEntry = { modifiedAt: 1700000000, createdAt: null, fileSize: 1024, + snippet: '', + relationships: {}, } const mockContent = `--- diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index d24eb5d5..9250fcab 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -12,7 +12,6 @@ import { ResizeHandle } from './ResizeHandle' import { TabBar } from './TabBar' import { BreadcrumbBar } from './BreadcrumbBar' import { useEditorTheme } from '../hooks/useTheme' -import { cn } from '@/lib/utils' import { splitFrontmatter, preProcessWikilinks, injectWikilinks, countWords } from '../utils/wikilinks' import './Editor.css' import './EditorTheme.css' @@ -235,7 +234,7 @@ export const Editor = memo(function Editor({ applyBlocks(withWikilinks) } if (result && typeof (result as any).then === 'function') { - (result as Promise).then(handleBlocks) + (result as unknown as Promise).then(handleBlocks) } else { handleBlocks(result as any[]) } diff --git a/src/components/Inspector.test.tsx b/src/components/Inspector.test.tsx index 9f7423cc..d3a1cc63 100644 --- a/src/components/Inspector.test.tsx +++ b/src/components/Inspector.test.tsx @@ -17,6 +17,8 @@ const mockEntry: VaultEntry = { modifiedAt: 1707900000, createdAt: null, fileSize: 1024, + snippet: '', + relationships: {}, } const mockContent = `--- @@ -50,6 +52,8 @@ const referrerEntry: VaultEntry = { modifiedAt: 1707900000, createdAt: null, fileSize: 200, + snippet: '', + relationships: {}, } const allContent: Record = { diff --git a/src/components/Inspector.tsx b/src/components/Inspector.tsx index 115b01b6..e265d517 100644 --- a/src/components/Inspector.tsx +++ b/src/components/Inspector.tsx @@ -70,7 +70,7 @@ function resolveRefType(ref: string, entries: VaultEntry[]): string | undefined if (fileStem === target.split('/').pop()) return true return false }) - return match?.isA + return match?.isA ?? undefined } function RelationshipGroup({ label, refs, entries, onNavigate }: { label: string; refs: string[]; entries: VaultEntry[]; onNavigate: (target: string) => void }) { diff --git a/src/components/NoteList.test.tsx b/src/components/NoteList.test.tsx index db3ddd36..b07d369f 100644 --- a/src/components/NoteList.test.tsx +++ b/src/components/NoteList.test.tsx @@ -131,7 +131,7 @@ describe('NoteList', () => { }) it('shows entity pinned at top with grouped children', () => { - const { container } = render( + render( ) // Entity title appears in header and pinned card diff --git a/src/components/NoteList.tsx b/src/components/NoteList.tsx index 54133d2d..4284b961 100644 --- a/src/components/NoteList.tsx +++ b/src/components/NoteList.tsx @@ -261,8 +261,8 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF const renderItem = useCallback((entry: VaultEntry, isPinned = false) => { const isSelected = selectedNote?.path === entry.path && !isPinned - const typeColor = getTypeColor(entry.isA) - const typeLightColor = getTypeLightColor(entry.isA) + const typeColor = getTypeColor(entry.isA ?? 'Note') + const typeLightColor = getTypeLightColor(entry.isA ?? 'Note') const TypeIcon = getTypeIcon(entry.isA) return (
{isEntityView ? (() => { const entity = selection.entry - const entityTypeColor = getTypeColor(entity.isA) - const entityLightColor = getTypeLightColor(entity.isA) + const entityTypeColor = getTypeColor(entity.isA ?? 'Note') + const entityLightColor = getTypeLightColor(entity.isA ?? 'Note') const EntityIcon = getTypeIcon(entity.isA) return (
diff --git a/src/components/Sidebar.test.tsx b/src/components/Sidebar.test.tsx index 0f53b9e0..1d79cb5f 100644 --- a/src/components/Sidebar.test.tsx +++ b/src/components/Sidebar.test.tsx @@ -18,6 +18,8 @@ const mockEntries: VaultEntry[] = [ modifiedAt: 1700000000, createdAt: null, fileSize: 1024, + snippet: '', + relationships: {}, }, { path: '/vault/responsibility/grow-newsletter.md', @@ -33,6 +35,8 @@ const mockEntries: VaultEntry[] = [ modifiedAt: 1700000000, createdAt: null, fileSize: 512, + snippet: '', + relationships: {}, }, { path: '/vault/experiment/stock-screener.md', @@ -48,6 +52,8 @@ const mockEntries: VaultEntry[] = [ modifiedAt: 1700000000, createdAt: null, fileSize: 256, + snippet: '', + relationships: {}, }, { path: '/vault/procedure/weekly-essays.md', @@ -63,6 +69,8 @@ const mockEntries: VaultEntry[] = [ modifiedAt: 1700000000, createdAt: null, fileSize: 128, + snippet: '', + relationships: {}, }, { path: '/vault/topic/software-development.md', @@ -78,6 +86,8 @@ const mockEntries: VaultEntry[] = [ modifiedAt: 1700000000, createdAt: null, fileSize: 256, + snippet: '', + relationships: {}, }, { path: '/vault/topic/trading.md', @@ -93,6 +103,8 @@ const mockEntries: VaultEntry[] = [ modifiedAt: 1700000000, createdAt: null, fileSize: 180, + snippet: '', + relationships: {}, }, { path: '/vault/person/alice.md', @@ -108,6 +120,8 @@ const mockEntries: VaultEntry[] = [ modifiedAt: 1700000000, createdAt: null, fileSize: 100, + snippet: '', + relationships: {}, }, { path: '/vault/event/kickoff.md', @@ -123,6 +137,8 @@ const mockEntries: VaultEntry[] = [ modifiedAt: 1700000000, createdAt: null, fileSize: 200, + snippet: '', + relationships: {}, }, ] diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 610bc981..684eddfe 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -48,7 +48,7 @@ export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onS setCollapsed((prev) => ({ ...prev, [type]: !prev[type] })) } - const getSectionColor = (entry: VaultEntry) => getTypeColor(entry.isA) + const getSectionColor = (entry: VaultEntry) => getTypeColor(entry.isA ?? 'Note') const isActive = (sel: SidebarSelection): boolean => { if (selection.kind !== sel.kind) return false @@ -165,7 +165,7 @@ export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onS style={{ padding: '4px 16px 4px 28px', ...(isActive(isTopic ? { kind: 'topic', entry } : { kind: 'entity', entry }) && { - backgroundColor: getTypeLightColor(entry.isA), + backgroundColor: getTypeLightColor(entry.isA ?? 'Note'), color: getSectionColor(entry), }), }} diff --git a/src/mock-tauri.ts b/src/mock-tauri.ts index 3330e246..4ea57959 100644 --- a/src/mock-tauri.ts +++ b/src/mock-tauri.ts @@ -832,10 +832,19 @@ const mockHandlers: Record any> = { git_push: () => { return 'Everything up-to-date' }, - ai_chat: (args: { request: { messages: any[]; model?: string } }) => { + ai_chat: (args: { request: { messages: any[]; model?: string; system?: string } }) => { const lastMsg = args.request.messages[args.request.messages.length - 1]?.content ?? '' + const lower = lastMsg.toLowerCase() + let content = `I can help you with that. Could you provide more details about what you'd like to know?` + if (lower.includes('summarize')) { + content = `Here's a summary of the note:\n\n**Key Points:**\n- The note covers the main topic and its related concepts\n- It includes actionable items and references to other notes\n- Several wiki-links connect it to the broader knowledge base\n\nWould you like me to expand on any of these points?` + } else if (lower.includes('expand')) { + content = `Here are suggestions to expand this note:\n\n1. **Add context** — Include background information\n2. **Link related notes** — Connect to [[related topics]]\n3. **Add examples** — Include concrete examples\n4. **Update status** — Reflect current progress` + } else if (lower.includes('grammar')) { + content = `Grammar review complete. The writing is clear and well-structured. Minor suggestions:\n\n- Consider varying sentence lengths for better rhythm\n- A few passive constructions could be made active` + } return { - content: `[Mock] Responding to: "${lastMsg.slice(0, 80)}"`, + content, model: args.request.model ?? 'claude-3-5-haiku-20241022', stop_reason: 'end_turn', } diff --git a/src/utils/ai-chat.ts b/src/utils/ai-chat.ts index 5fe30e8c..df789e76 100644 --- a/src/utils/ai-chat.ts +++ b/src/utils/ai-chat.ts @@ -25,7 +25,7 @@ export function estimateTokens(text: string | number): number { const DEFAULT_CONTEXT_LIMIT = 180_000 -export function getContextLimit(model: string): number { +export function getContextLimit(_model: string): number { return DEFAULT_CONTEXT_LIMIT }