From f4ca64f229ec0fd3100245c81119c22c07e27759 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Fri, 20 Feb 2026 22:52:56 +0100 Subject: [PATCH] feat(ai-chat): context picker + Anthropic API Rewrite AIChatPanel with: - Context picker: current note auto-added, search to add more notes, removable context pills, token estimation badge - Real Anthropic API integration via Vite dev server proxy (SSE streaming) - API key stored in localStorage, configurable via dialog - Model selector: Haiku 3.5, Sonnet 4, Opus 4 - Mock fallback when no API key is configured - Extracted useAIChat hook and ai-chat utilities for clean separation Decision: Using Vite middleware proxy for dev mode to avoid CORS issues with direct browser-to-Anthropic calls. Phase 2 will add Rust backend. Co-Authored-By: Claude Opus 4.6 --- package.json | 1 + pnpm-lock.yaml | 32 ++ src/components/AIChatPanel.tsx | 663 +++++++++++++++++---------------- src/components/Editor.tsx | 1 + src/hooks/useAIChat.ts | 108 ++++++ src/utils/ai-chat.ts | 190 ++++++++++ vite.config.ts | 90 ++++- 7 files changed, 770 insertions(+), 315 deletions(-) create mode 100644 src/hooks/useAIChat.ts create mode 100644 src/utils/ai-chat.ts diff --git a/package.json b/package.json index 32a3e674..f03f7a28 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { + "@anthropic-ai/sdk": "^0.78.0", "@blocknote/core": "^0.46.2", "@blocknote/mantine": "^0.46.2", "@blocknote/react": "^0.46.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ed68d7a..d3f43253 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@anthropic-ai/sdk': + specifier: ^0.78.0 + version: 0.78.0(zod@4.3.6) '@blocknote/core': specifier: ^0.46.2 version: 0.46.2(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0) @@ -156,6 +159,15 @@ packages: '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@anthropic-ai/sdk@0.78.0': + resolution: {integrity: sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@asamuzakjp/css-color@4.1.2': resolution: {integrity: sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==} @@ -2499,6 +2511,10 @@ packages: json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -3229,6 +3245,9 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} @@ -3528,6 +3547,12 @@ snapshots: '@adobe/css-tools@4.4.4': {} + '@anthropic-ai/sdk@0.78.0(zod@4.3.6)': + dependencies: + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 4.3.6 + '@asamuzakjp/css-color@4.1.2': dependencies: '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) @@ -5975,6 +6000,11 @@ snapshots: json-buffer@3.0.1: {} + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.28.6 + ts-algebra: 2.0.0 + json-schema-traverse@0.4.1: {} json-stable-stringify-without-jsonify@1.0.1: {} @@ -6939,6 +6969,8 @@ snapshots: trough@2.2.0: {} + ts-algebra@2.0.0: {} + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 diff --git a/src/components/AIChatPanel.tsx b/src/components/AIChatPanel.tsx index b9225c2e..c34cbab1 100644 --- a/src/components/AIChatPanel.tsx +++ b/src/components/AIChatPanel.tsx @@ -1,56 +1,28 @@ -import { useState, useRef, useEffect, useCallback } from 'react' +import { useState, useRef, useEffect, useMemo } from 'react' import type { VaultEntry } from '../types' -import { X, Plus, PaperPlaneRight, Copy, ArrowClockwise, TextIndent } from '@phosphor-icons/react' -import { Sparkle } from '@phosphor-icons/react' -import { countWords } from '../utils/wikilinks' +import { + X, Plus, PaperPlaneRight, Copy, ArrowClockwise, + TextIndent, Sparkle, Key, MagnifyingGlass, Minus, +} from '@phosphor-icons/react' +import { + type ChatMessage, getApiKey, setApiKey, + buildSystemPrompt, MODEL_OPTIONS, +} from '../utils/ai-chat' +import { useAIChat } from '../hooks/useAIChat' -interface ChatMessage { - role: 'user' | 'assistant' - content: string - id: string -} +// --- Sub-components --- interface AIChatPanelProps { entry: VaultEntry | null allContent: Record + entries?: VaultEntry[] onClose: () => void } -function countWikilinks(content: string): number { - const matches = content.match(/\[\[.*?\]\]/g) - return matches ? matches.length : 0 -} - -function generateMockResponse(message: string, entry: VaultEntry | null, content: string): string { - const title = entry?.title ?? 'Untitled' - const words = countWords(content) - const links = countWikilinks(content) - const lower = message.toLowerCase() - - if (lower.includes('summarize')) { - return `This note is about **${title}**. It covers the main concepts documented in your vault. The document contains ${words} words and links to ${links} related notes.` - } - if (lower.includes('expand')) { - return `Here are some ways to expand this note:\n\n1. Add more detail to the introduction\n2. Include related examples from your vault\n3. Connect it to your quarterly goals\n4. Add a summary section at the end` - } - if (lower.includes('fix grammar') || lower.includes('grammar')) { - return `I reviewed the document for grammar issues. The writing looks clean overall — I found no major errors. Consider varying sentence length for better readability.` - } - - const bodyStart = content.replace(/^---[\s\S]*?---\n?/, '').replace(/^# [^\n]*\n?/, '').trim() - const snippet = bodyStart.slice(0, 120) - return `Based on the content of **${title}**: ${snippet}...\n\nIs there a specific aspect you would like me to focus on?` -} - -let msgIdCounter = 0 -function nextId(): string { - return `msg-${++msgIdCounter}-${Date.now()}` -} - function TypingIndicator() { return (
-
+
@@ -59,298 +31,232 @@ function TypingIndicator() { ) } -export function AIChatPanel({ entry, allContent, onClose }: AIChatPanelProps) { - const [messages, setMessages] = useState([]) - const [input, setInput] = useState('') - const [isTyping, setIsTyping] = useState(false) - const [model, setModel] = useState('sonnet-4.6') - const messagesEndRef = useRef(null) - const textareaRef = useRef(null) +function ContextPill({ note, onRemove }: { note: VaultEntry; onRemove: () => void }) { + return ( + + {note.title} + + + ) +} - const content = entry ? (allContent[entry.path] ?? '') : '' - const wikilinkCount = countWikilinks(content) +function ContextSearchDropdown({ + entries, contextPaths, onAdd, onClose, +}: { + entries: VaultEntry[]; contextPaths: Set + onAdd: (entry: VaultEntry) => void; onClose: () => void +}) { + const [query, setQuery] = useState('') + const inputRef = useRef(null) + useEffect(() => { inputRef.current?.focus() }, []) - const scrollToBottom = useCallback(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) - }, []) + const filtered = useMemo(() => { + const q = query.toLowerCase() + return entries + .filter(e => !contextPaths.has(e.path)) + .filter(e => !q || e.title.toLowerCase().includes(q) || (e.isA ?? '').toLowerCase().includes(q)) + .slice(0, 8) + }, [entries, contextPaths, query]) + + return ( +
+
+ + setQuery(e.target.value)} + onKeyDown={e => e.key === 'Escape' && onClose()} placeholder="Search notes..." + className="flex-1 border-none bg-transparent text-foreground outline-none" + style={{ fontSize: 12, padding: '2px 0' }} /> +
+
+ {filtered.map(entry => ( + + ))} + {filtered.length === 0 && ( +
No matching notes
+ )} +
+
+ ) +} + +function ApiKeyDialog({ onClose }: { onClose: () => void }) { + const [key, setKey] = useState(getApiKey()) + return ( +
+
+

Anthropic API Key

+

+ Enter your Anthropic API key. Stored locally in your browser. +

+ setKey(e.target.value)} placeholder="sk-ant-..." + className="w-full border border-border bg-transparent text-foreground rounded" + style={{ fontSize: 13, padding: '8px 10px', outline: 'none', marginBottom: 12 }} /> +
+ + +
+
+
+ ) +} + +function AssistantMessage({ msg, onRetry }: { msg: ChatMessage; onRetry: () => void }) { + return ( +
+
$1').replace(/\n/g, '
'), + }} /> +
+ + + +
+
+ ) +} + +function StreamingContent({ content }: { content: string }) { + return ( +
+
$1').replace(/\n/g, '
'), + }} /> +
+ ) +} + +function UserBubble({ content }: { content: string }) { + return ( +
+
{content}
+
+ ) +} + +function formatTokens(n: number): string { + return n < 1000 ? String(n) : `${(n / 1000).toFixed(1)}k` +} + +const QUICK_ACTIONS = [ + { label: 'Summarize', message: 'Summarize this note' }, + { label: 'Expand', message: 'Expand this note with more detail' }, + { label: 'Fix grammar', message: 'Fix grammar and improve readability' }, +] + +// --- Context management hook --- + +function useContextNotes(entry: VaultEntry | null) { + const [contextNotes, setContextNotes] = useState([]) useEffect(() => { - scrollToBottom() - }, [messages, isTyping, scrollToBottom]) - - const sendMessage = useCallback((text: string) => { - if (!text.trim() || isTyping) return - - const userMsg: ChatMessage = { role: 'user', content: text.trim(), id: nextId() } - setMessages(prev => [...prev, userMsg]) - setInput('') - setIsTyping(true) - - setTimeout(() => { - const response = generateMockResponse(text, entry, content) - const assistantMsg: ChatMessage = { role: 'assistant', content: response, id: nextId() } - setMessages(prev => [...prev, assistantMsg]) - setIsTyping(false) - }, 1200) - }, [isTyping, entry, content]) - - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault() - sendMessage(input) + if (entry) { + setContextNotes(prev => prev.some(n => n.path === entry.path) ? prev : [entry, ...prev]) } - }, [input, sendMessage]) + }, [entry?.path]) // eslint-disable-line react-hooks/exhaustive-deps - const handleClearConversation = useCallback(() => { - setMessages([]) - setIsTyping(false) - }, []) + const addNote = (note: VaultEntry) => { + setContextNotes(prev => prev.some(n => n.path === note.path) ? prev : [...prev, note]) + } + const removeNote = (path: string) => { + setContextNotes(prev => prev.filter(n => n.path !== path)) + } + const paths = useMemo(() => new Set(contextNotes.map(n => n.path)), [contextNotes]) - const handleRetry = useCallback((msgIndex: number) => { - const userMsgIndex = msgIndex - 1 - if (userMsgIndex < 0) return - const userMsg = messages[userMsgIndex] - if (userMsg.role !== 'user') return + return { contextNotes, addNote, removeNote, paths } +} - setMessages(prev => prev.slice(0, msgIndex)) - setIsTyping(true) +// --- Main component --- - setTimeout(() => { - const response = generateMockResponse(userMsg.content, entry, content) - const assistantMsg: ChatMessage = { role: 'assistant', content: response, id: nextId() } - setMessages(prev => [...prev, assistantMsg]) - setIsTyping(false) - }, 1200) - }, [messages, entry, content]) +export function AIChatPanel({ entry, allContent, entries = [], onClose }: AIChatPanelProps) { + const [input, setInput] = useState('') + const [model, setModel] = useState(MODEL_OPTIONS[0].value) + const [showSearch, setShowSearch] = useState(false) + const [showApiKeyDialog, setShowApiKeyDialog] = useState(false) + const messagesEndRef = useRef(null) - const quickActions = [ - { label: 'Summarize', message: 'Summarize this note' }, - { label: 'Expand', message: 'Expand this note' }, - { label: 'Fix grammar', message: 'Fix grammar in this note' }, - ] + const ctx = useContextNotes(entry) + const chat = useAIChat(entry, allContent, ctx.contextNotes, model) + + const contextInfo = useMemo( + () => buildSystemPrompt(ctx.contextNotes, allContent, model), + [ctx.contextNotes, allContent, model], + ) + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [chat.messages, chat.isStreaming, chat.streamingContent]) + + const handleSend = () => { chat.sendMessage(input); setInput('') } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() } + } return (