diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 7b182874..8797a67a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -76,11 +76,7 @@ "Bash(npx vitest:*)", "Bash(do echo:*)", "Bash(do gh:*)", - "Bash(npx tsc:*)", - "Bash(git stash:*)", - "Bash(git rm:*)", - "Bash(turbo typecheck:*)", - "Bash(npx turbo:*)" + "Bash(git tag:*)" ] } } diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b3e1b709..2de3a28c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,7 +4,6 @@ on: push: tags: - 'v*' - workflow_dispatch: permissions: contents: write diff --git a/CLAUDE.md b/CLAUDE.md index 2b6b32ad..b04b3f64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,13 +15,17 @@ ``` apps/ desktop/ # Electron app (main, preload, renderer) - docs-site/ # VitePress documentation + web/ # Next.js marketing site + docs packages/ + ai-core/ # Provider-agnostic AI: streaming, LLM providers, context builder core/ # Domain logic + markdown parsing + command-registry/ # Command palette registry + plugin-api/ # Plugin system interfaces storage-core/ # Storage interfaces (pure TS) storage-sqlite/ # SQLite adapter (peerDep for better-sqlite3) licensing/ # License validation product-config/ # Product configuration + sync-core/ # Sync engine ``` ## Commands @@ -67,6 +71,12 @@ Pattern for workspace packages with native deps: } ``` +## Type Version Alignment + +`@types/react` is pinned to `18.3.27` via `pnpm.overrides` in root `package.json`. This prevents type mismatches when packages like `lucide-react` resolve a different `@types/react` version than the app uses. + +**If you see `'X' cannot be used as a JSX component` errors:** Check that `pnpm.overrides` in root `package.json` still pins `@types/react` to match the React version used by `apps/desktop`. + ## Testing - `pnpm test` runs all tests **except** storage-sqlite (safe to run always) @@ -138,6 +148,13 @@ git pull origin develop git checkout -b feature/my-feature ``` +**Keep branch in sync (do this daily or before pushing):** + +```bash +git fetch origin develop +git rebase origin/develop +``` + **Creating PR:** ```bash @@ -153,6 +170,16 @@ git pull origin develop git branch -d feature/my-feature ``` +### Branch Hygiene (Critical) + +Long-lived branches cause painful merge conflicts. Follow these rules: + +- **Rebase daily:** `git fetch origin develop && git rebase origin/develop` before starting work each day +- **Small PRs:** Prefer 3 small PRs over 1 large one. Split by layer (types → logic → UI) +- **Max branch lifetime:** 2-3 days. If work takes longer, split into incremental PRs +- **Don't touch unrelated files:** Avoid changes to `package.json`, lockfiles, or `apps/web` unless that's the PR's purpose — these are high-conflict files +- **Rebase before pushing:** Always rebase against latest develop before `git push` to catch conflicts early + ### Commit Messages Use conventional commits: @@ -251,6 +278,41 @@ case 'tag': - [ ] Sidebar uses `useNavigation()` hook - [ ] No implicit flags (`!== null`) +## AI Architecture + +The AI system lives in `packages/ai-core` with a provider-agnostic, streaming-first design. + +``` +Renderer (AiPanel) → IPC → Main (ipc-ai.ts) → AIService → ProviderRegistry → Provider → SSE stream + ← batched LLMEvents (text/error/done) ← +``` + +**Key packages and files:** + +- `packages/ai-core/` — LLMProvider interface, ProviderRegistry, AnthropicProvider, ContextBuilder, AIService +- `apps/desktop/src/main/ai/ipc-ai.ts` — IPC bridge, 50ms batched event streaming +- `apps/desktop/src/preload/index.ts` — `window.readied.ai` API (chat, onEvent, cancel) +- `apps/desktop/src/renderer/components/ai/AiPanel.tsx` — Chat UI with streaming + +**Adding a new LLM provider:** + +1. Create `packages/ai-core/src/providers/my-provider.ts` implementing `LLMProvider` +2. Register in `ProviderRegistry` at `apps/desktop/src/main/ai/ipc-ai.ts` +3. Add option to `apps/desktop/src/renderer/pages/settings/sections/AiSection.tsx` + +**Key types:** + +- `LLMEvent` — Protocol: `text` (delta), `error` (with code), `done`, `tool_call`, `tool_result` +- `ChatOptions` — Provider, model, messages, tools, maxTokens +- `LLMProvider` — `chat(options): AsyncGenerator` + `models()` + `validateKey()` + +**Rules:** + +- **No SDK dependencies in ai-core:** Providers use native `fetch` + SSE parsing +- **Streaming only:** No request/response pattern — everything streams via `LLMEvent` +- **Single panel instance:** Both Cmd+K and Sparkles button toggle the same AiPanel in App.tsx via CustomEvent (`readied:ai:toggle-panel`) +- **Settings store is source of truth:** API key, model, and provider come from Zustand settings store (`selectAi` selector), not plugin config + ## Documentation - **Architecture decisions:** `plan.md` diff --git a/apps/desktop/src/main/ai/built-in-tools.ts b/apps/desktop/src/main/ai/built-in-tools.ts new file mode 100644 index 00000000..c82d0a0e --- /dev/null +++ b/apps/desktop/src/main/ai/built-in-tools.ts @@ -0,0 +1,140 @@ +// apps/desktop/src/main/ai/built-in-tools.ts +import type { ToolRegistry } from '@readied/ai-core'; + +/** + * Register built-in AI tools for note operations. + * + * Read tools (auto-execute): search_notes, read_note, list_notebooks + * Write tools (require confirmation): create_note, insert_text, replace_selection + * + * insert_text and replace_selection execute in the renderer (they need editor access). + * They delegate via IPC: main → renderer → main. + */ +export function registerBuiltInTools( + registry: ToolRegistry, + deps: { + searchNotes: ( + query: string, + limit?: number + ) => Promise>; + readNote: (id: string) => Promise<{ id: string; title: string; content: string } | null>; + listNotebooks: () => Promise>; + createNote: (title: string, content: string, notebookId?: string) => Promise<{ id: string }>; + } +): void { + registry.register({ + name: 'search_notes', + description: 'Search notes by keyword query. Returns matching note IDs, titles, and snippets.', + parameters: { + type: 'object', + properties: { + query: { type: 'string', description: 'Search query' }, + limit: { type: 'number', description: 'Max results (default 10)' }, + }, + required: ['query'], + }, + requiresConfirmation: false, + execute: async args => { + const results = await deps.searchNotes(args.query as string, (args.limit as number) ?? 10); + return { ok: true, content: JSON.stringify(results) }; + }, + }); + + registry.register({ + name: 'read_note', + description: 'Read the full content of a note by its ID.', + parameters: { + type: 'object', + properties: { + id: { type: 'string', description: 'Note ID' }, + }, + required: ['id'], + }, + requiresConfirmation: false, + execute: async args => { + const note = await deps.readNote(args.id as string); + if (!note) return { ok: false, content: 'Note not found', error: 'Note not found' }; + return { ok: true, content: JSON.stringify(note) }; + }, + }); + + registry.register({ + name: 'list_notebooks', + description: 'List all notebooks with their names and note counts.', + parameters: { + type: 'object', + properties: {}, + }, + requiresConfirmation: false, + execute: async () => { + const notebooks = await deps.listNotebooks(); + return { ok: true, content: JSON.stringify(notebooks) }; + }, + }); + + registry.register({ + name: 'create_note', + description: 'Create a new note in a notebook.', + parameters: { + type: 'object', + properties: { + title: { type: 'string', description: 'Note title' }, + content: { type: 'string', description: 'Note content in markdown' }, + notebookId: { + type: 'string', + description: 'Target notebook ID (optional, uses default)', + }, + }, + required: ['title', 'content'], + }, + requiresConfirmation: true, + execute: async args => { + const result = await deps.createNote( + args.title as string, + args.content as string, + args.notebookId as string | undefined + ); + return { ok: true, content: JSON.stringify(result) }; + }, + }); + + registry.register({ + name: 'insert_text', + description: 'Insert text into the current note at the cursor position or at the end.', + parameters: { + type: 'object', + properties: { + text: { type: 'string', description: 'Text to insert' }, + position: { + type: 'string', + description: "Where to insert: 'cursor' (default) or 'end'", + }, + }, + required: ['text'], + }, + requiresConfirmation: true, + rendererOnly: true, + execute: async () => { + // Execution handled by ipc-ai.ts via executeToolInRenderer + return { ok: false, content: 'Not reachable', error: 'Renderer tool called without IPC' }; + }, + }); + + registry.register({ + name: 'replace_selection', + description: 'Replace the currently selected text in the editor.', + parameters: { + type: 'object', + properties: { + text: { type: 'string', description: 'Replacement text' }, + }, + required: ['text'], + }, + requiresConfirmation: true, + rendererOnly: true, + execute: async () => { + // Execution handled by ipc-ai.ts via executeToolInRenderer + return { ok: false, content: 'Not reachable', error: 'Renderer tool called without IPC' }; + }, + }); +} diff --git a/apps/desktop/src/main/ai/ipc-ai.ts b/apps/desktop/src/main/ai/ipc-ai.ts index 87dba269..f545a005 100644 --- a/apps/desktop/src/main/ai/ipc-ai.ts +++ b/apps/desktop/src/main/ai/ipc-ai.ts @@ -1,14 +1,26 @@ // apps/desktop/src/main/ai/ipc-ai.ts import { ipcMain, app, dialog } from 'electron'; import { readFile, writeFile } from 'node:fs/promises'; -import type { AIService, ChatHandle } from '@readied/ai-core'; +import type { AIService, ChatHandle, ToolChatHandle, ToolCall } from '@readied/ai-core'; +import type { ToolRegistry } from '@readied/ai-core'; const BATCH_INTERVAL_MS = 50; // Per-window active handle tracking -const activeHandles = new Map>(); +const activeHandles = new Map>(); -export function registerAIHandlers(service: AIService): void { +// Pending tool confirmations: requestId -> callId -> resolve function +const pendingConfirmations = new Map void>>(); +const CONFIRM_TIMEOUT_MS = 60_000; + +// Pending renderer tool results: callId -> resolve function +const pendingRendererResults = new Map< + string, + (result: { ok: boolean; content: string; error?: string }) => void +>(); +const RENDERER_TOOL_TIMEOUT_MS = 30_000; + +export function registerAIHandlers(service: AIService, toolRegistry: ToolRegistry): void { // ─── Streaming chat ───────────────────────────────────── ipcMain.handle( 'ai:chat', @@ -24,11 +36,49 @@ export function registerAIHandlers(service: AIService): void { model: string; providerConfig: { apiKey?: string; baseUrl?: string }; maxResponseTokens?: number; + tools?: boolean; } ) => { const windowId = event.sender.id; + const toolDefs = request.tools ? toolRegistry.getDefinitions() : []; + + // Build executeTool callback that closes over requestId (set after handle creation) + let requestId = ''; + const executeTool = async (call: ToolCall) => { + const tool = toolRegistry.get(call.name); + if (!tool) { + return { + ok: false, + content: `Unknown tool: ${call.name}`, + error: `Unknown tool: ${call.name}`, + }; + } + + if (tool.requiresConfirmation) { + event.sender.send('ai:event', requestId, { + type: 'tool_confirm_needed', + callId: call.id, + }); + const approved = await waitForConfirmation(requestId, call.id); + if (!approved) { + return { ok: false, content: 'Tool execution cancelled by user', error: 'Cancelled' }; + } + } - const handle = service.chat(request); + // Renderer-only tools delegate execution to the renderer process via IPC + if (tool.rendererOnly) { + return executeToolInRenderer(event.sender, requestId, call.id, call.name, call.args); + } + + return tool.execute(call.args); + }; + + const handle = + toolDefs.length > 0 + ? service.chatWithTools({ ...request, tools: toolDefs, executeTool }) + : service.chat(request); + + requestId = handle.requestId; // Track handle if (!activeHandles.has(windowId)) { @@ -36,7 +86,6 @@ export function registerAIHandlers(service: AIService): void { } activeHandles.get(windowId)!.set(handle.requestId, handle); - // Start consuming stream with batching consumeStream(event.sender, handle); return { requestId: handle.requestId }; @@ -44,14 +93,13 @@ export function registerAIHandlers(service: AIService): void { ); // ─── Cancel ───────────────────────────────────────────── - ipcMain.handle('ai:cancel', (_event, requestId: string) => { - for (const handles of activeHandles.values()) { - const handle = handles.get(requestId); - if (handle) { - handle.abort(); - handles.delete(requestId); - return; - } + ipcMain.handle('ai:cancel', (event, requestId: string) => { + const windowId = event.sender.id; + const handles = activeHandles.get(windowId); + const handle = handles?.get(requestId); + if (handle) { + handle.abort(); + handles!.delete(requestId); } }); @@ -59,11 +107,9 @@ export function registerAIHandlers(service: AIService): void { ipcMain.handle( 'ai:validate', async (_event, config: { provider: string; apiKey?: string; baseUrl?: string }) => { - // Access provider directly from registry via service - // For now, simple validation using a no-op chat try { const handle = service.chat({ - query: 'test', + query: 'Say "ok".', history: [], relevantNotes: [], mode: 'chat', @@ -72,8 +118,18 @@ export function registerAIHandlers(service: AIService): void { providerConfig: { apiKey: config.apiKey, baseUrl: config.baseUrl }, maxResponseTokens: 1, }); - // Immediately abort — we just want to verify the connection - handle.abort(); + // Consume stream to actually trigger the provider call + for await (const event of handle.events) { + if (event.type === 'error') { + handle.abort(); + return { ok: false, error: `${event.code}: ${event.error}` }; + } + // Got any successful event — provider is reachable + if (event.type === 'text' || event.type === 'done') { + handle.abort(); + return { ok: true }; + } + } return { ok: true }; } catch (err) { return { ok: false, error: err instanceof Error ? err.message : String(err) }; @@ -112,6 +168,43 @@ export function registerAIHandlers(service: AIService): void { } }); + // ─── Tool confirmation ────────────────────────────────── + ipcMain.handle( + 'ai:tool-confirm', + (event, requestId: string, callId: string, approved: boolean) => { + // Verify the sender owns this request + const windowId = event.sender.id; + if (!activeHandles.get(windowId)?.has(requestId)) return; + + const resolve = pendingConfirmations.get(requestId)?.get(callId); + if (resolve) { + resolve(approved); + pendingConfirmations.get(requestId)!.delete(callId); + } + } + ); + + // ─── Renderer tool result ────────────────────────────── + ipcMain.handle( + 'ai:tool-renderer-result', + ( + event, + requestId: string, + callId: string, + result: { ok: boolean; content: string; error?: string } + ) => { + // Verify the sender owns this request + const windowId = event.sender.id; + if (!activeHandles.get(windowId)?.has(requestId)) return; + + const resolve = pendingRendererResults.get(callId); + if (resolve) { + resolve(result); + pendingRendererResults.delete(callId); + } + } + ); + // ─── Cleanup on window destroy ────────────────────────── app.on('browser-window-created', (_event, window) => { window.webContents.on('destroyed', () => { @@ -124,9 +217,54 @@ export function registerAIHandlers(service: AIService): void { }); } +// ─── Confirmation helper ───────────────────────────────── + +function waitForConfirmation(requestId: string, callId: string): Promise { + return new Promise(resolve => { + if (!pendingConfirmations.has(requestId)) { + pendingConfirmations.set(requestId, new Map()); + } + pendingConfirmations.get(requestId)!.set(callId, resolve); + + // Timeout auto-rejects + setTimeout(() => { + const pending = pendingConfirmations.get(requestId)?.get(callId); + if (pending) { + pendingConfirmations.get(requestId)!.delete(callId); + resolve(false); + } + }, CONFIRM_TIMEOUT_MS); + }); +} + +// ─── Execute tool in renderer ──────────────────────────── + +export function executeToolInRenderer( + sender: Electron.WebContents, + requestId: string, + callId: string, + toolName: string, + args: Record +): Promise<{ ok: boolean; content: string; error?: string }> { + return new Promise(resolve => { + pendingRendererResults.set(callId, resolve); + sender.send('ai:tool-execute-in-renderer', requestId, callId, toolName, args); + + setTimeout(() => { + if (pendingRendererResults.has(callId)) { + pendingRendererResults.delete(callId); + resolve({ ok: false, content: 'Renderer tool timed out', error: 'Timeout' }); + } + }, RENDERER_TOOL_TIMEOUT_MS); + }); +} + // ─── Stream consumer with batching ──────────────────────── -async function consumeStream(sender: Electron.WebContents, handle: ChatHandle): Promise { +async function consumeStream( + sender: Electron.WebContents, + handle: ChatHandle | ToolChatHandle +): Promise { let textBuffer = ''; let flushTimer: ReturnType | null = null; diff --git a/apps/desktop/src/main/ai/setup.ts b/apps/desktop/src/main/ai/setup.ts index 259099c9..b36908a7 100644 --- a/apps/desktop/src/main/ai/setup.ts +++ b/apps/desktop/src/main/ai/setup.ts @@ -1,9 +1,10 @@ // apps/desktop/src/main/ai/setup.ts import { net } from 'electron'; -import { ProviderRegistry, AnthropicProvider, AIServiceImpl } from '@readied/ai-core'; +import { ProviderRegistry, AnthropicProvider, AIServiceImpl, ToolRegistry } from '@readied/ai-core'; import type { AIService, FetchFn } from '@readied/ai-core'; let service: AIService | null = null; +let toolRegistryInstance: ToolRegistry | null = null; export function createAIService(): AIService { if (service) return service; @@ -14,3 +15,10 @@ export function createAIService(): AIService { service = new AIServiceImpl(registry); return service; } + +export function getToolRegistry(): ToolRegistry { + if (!toolRegistryInstance) { + toolRegistryInstance = new ToolRegistry(); + } + return toolRegistryInstance; +} diff --git a/apps/desktop/src/main/handlers/shareHandlers.ts b/apps/desktop/src/main/handlers/shareHandlers.ts index a681f89b..c58f01b6 100644 --- a/apps/desktop/src/main/handlers/shareHandlers.ts +++ b/apps/desktop/src/main/handlers/shareHandlers.ts @@ -20,7 +20,15 @@ export function registerShareHandlers(deps: ShareHandlerDependencies): void { 'share:create', async ( _event, - input: { noteId: string; title: string; content: string } + input: { + noteId: string; + title: string; + content: string; + tags?: string[]; + backlinks?: Array<{ noteId: string; title: string }>; + wordCount?: number; + notebookName?: string; + } ): Promise<{ success: boolean; url?: string; slug?: string; error?: string }> => { try { const result = await apiClient.shareNote(input); diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index f982c3ff..986d5974 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -76,7 +76,8 @@ import { registerLicenseHandlers } from './handlers/licenseHandlers.js'; import { registerShareHandlers } from './handlers/shareHandlers.js'; import { scanPlugins } from './pluginScanner.js'; import { startPluginWatcher, stopPluginWatcher } from './pluginWatcher.js'; -import { createAIService } from './ai/setup.js'; +import { createAIService, getToolRegistry } from './ai/setup.js'; +import { registerBuiltInTools } from './ai/built-in-tools.js'; import { registerAIHandlers as registerAIHandlersNew } from './ai/ipc-ai.js'; // Database and repository (initialized on app ready) @@ -2326,7 +2327,42 @@ app registerGitHandlers(); // Git operations for git-backed notebooks registerPluginConfigHandlers(); registerPluginDiscoveryHandlers(); - registerAIHandlersNew(createAIService()); + registerAIHandlersNew(createAIService(), getToolRegistry()); + + // Register built-in AI tools with database access + if (noteRepository && notebookRepository) { + const noteRepo = noteRepository; + const nbRepo = notebookRepository; + registerBuiltInTools(getToolRegistry(), { + searchNotes: async (query, limit) => { + const notes = await noteRepo.search(query, limit); + return notes.map(n => ({ + id: n.id, + title: n.title, + snippet: n.content.slice(0, 200), + })); + }, + readNote: async id => { + const note = await noteRepo.get(createNoteId(id)); + if (!note) return null; + return { id: note.id, title: note.title, content: note.content }; + }, + listNotebooks: async () => { + const notebooks = await nbRepo.getAll(); + return notebooks.map(nb => ({ id: nb.id, name: nb.name, noteCount: 0 })); + }, + createNote: async (title, content, notebookId) => { + const result = await createNoteOperation( + { content: `# ${title}\n\n${content}`, notebookId }, + noteRepo + ); + if (!result.ok) { + throw new Error('Failed to create note'); + } + return { id: result.data.id }; + }, + }); + } // Start plugin hot-reload watcher in dev mode if (process.env.NODE_ENV === 'development' && dataPaths) { diff --git a/apps/desktop/src/main/services/apiClient.ts b/apps/desktop/src/main/services/apiClient.ts index 109b416f..70251063 100644 --- a/apps/desktop/src/main/services/apiClient.ts +++ b/apps/desktop/src/main/services/apiClient.ts @@ -548,6 +548,10 @@ export class ApiClient { noteId: string; title: string; content: string; + tags?: string[]; + backlinks?: Array<{ noteId: string; title: string }>; + wordCount?: number; + notebookName?: string; }): Promise<{ slug: string; url: string }> { return this.request<{ slug: string; url: string }>('/share', { method: 'POST', diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index d4f7ed6b..c04daf72 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -631,6 +631,10 @@ export interface ReadiedAPI { noteId: string; title: string; content: string; + tags?: string[]; + backlinks?: Array<{ noteId: string; title: string }>; + wordCount?: number; + notebookName?: string; }) => Promise<{ success: boolean; url?: string; slug?: string; error?: string }>; /** Remove a shared note */ delete: (slug: string) => Promise<{ success: boolean; error?: string }>; @@ -734,6 +738,7 @@ export interface ReadiedAPI { model: string; providerConfig: { apiKey?: string; baseUrl?: string }; maxResponseTokens?: number; + tools?: boolean; }) => Promise<{ requestId: string }>; /** Listen for streaming AI events */ onEvent: (cb: (requestId: string, event: unknown) => void) => () => void; @@ -751,6 +756,18 @@ export interface ReadiedAPI { ) => Promise<{ ok: true; filePath: string } | { ok: false; error: string }>; /** Import an AI command preset from a user-chosen file */ importPreset: () => Promise<{ ok: true; content: string } | { ok: false; error: string }>; + /** Confirm or reject a tool execution */ + confirmTool: (requestId: string, callId: string, approved: boolean) => Promise; + /** Listen for renderer-executed tool requests from main */ + onToolExecuteRequest: ( + cb: (requestId: string, callId: string, toolName: string, args: unknown) => void + ) => () => void; + /** Send renderer tool execution result back to main */ + sendToolResult: ( + requestId: string, + callId: string, + result: { ok: boolean; content: string; error?: string } + ) => Promise; }; pluginConfig: { /** Get a single config value for a plugin */ @@ -1047,6 +1064,28 @@ const api: ReadiedAPI = { validate: config => ipcRenderer.invoke('ai:validate', config), exportPreset: presetJson => ipcRenderer.invoke('ai:exportPreset', presetJson), importPreset: () => ipcRenderer.invoke('ai:importPreset'), + confirmTool: (requestId: string, callId: string, approved: boolean) => + ipcRenderer.invoke('ai:tool-confirm', requestId, callId, approved), + onToolExecuteRequest: ( + cb: (requestId: string, callId: string, toolName: string, args: unknown) => void + ) => { + const handler = ( + _event: Electron.IpcRendererEvent, + requestId: string, + callId: string, + toolName: string, + args: unknown + ) => cb(requestId, callId, toolName, args); + ipcRenderer.on('ai:tool-execute-in-renderer', handler); + return () => { + ipcRenderer.removeListener('ai:tool-execute-in-renderer', handler); + }; + }, + sendToolResult: ( + requestId: string, + callId: string, + result: { ok: boolean; content: string; error?: string } + ) => ipcRenderer.invoke('ai:tool-renderer-result', requestId, callId, result), }, pluginConfig: { get: (pluginId, key) => ipcRenderer.invoke('pluginConfig:get', pluginId, key), diff --git a/apps/desktop/src/renderer/components/NoteEditor.tsx b/apps/desktop/src/renderer/components/NoteEditor.tsx index f5f0b273..a95f89b2 100644 --- a/apps/desktop/src/renderer/components/NoteEditor.tsx +++ b/apps/desktop/src/renderer/components/NoteEditor.tsx @@ -8,6 +8,7 @@ import { useScrollSync } from '../hooks/useScrollSync'; import { useManualTags } from '../hooks/useManualTags'; import { useEmbedResolver } from '../hooks/useEmbedResolver'; import { useBacklinks } from '../hooks/useLinks'; +import { useNotebook } from '../hooks/useNotebooks'; import type { MarkdownEditorHandle } from './MarkdownEditor'; import type { MarkdownPreviewHandle, ToolbarVisibility } from './editor'; import { ImageLightbox } from './ImageLightbox'; @@ -107,6 +108,7 @@ export function NoteEditor({ const [revisionHistoryOpen, setRevisionHistoryOpen] = useState(false); const { data: backlinks } = useBacklinks(note?.id ?? null); const backlinksCount = backlinks?.length ?? 0; + const { data: notebook } = useNotebook(note?.notebookId ?? null); // Lightbox state for embedded images const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null); @@ -168,13 +170,17 @@ export function NoteEditor({ noteId: note.id, title: note.title, content: note.content, + tags: note.tags, + wordCount: note.wordCount, + notebookName: notebook?.name ?? '', + backlinks: (backlinks ?? []).map(bl => ({ noteId: bl.noteId, title: bl.noteTitle })), }); if (result.success) { showToast('Link copied to clipboard'); } else { showToast(result.error || 'Failed to share note', 'error'); } - }, [note, showToast]); + }, [note, notebook, backlinks, showToast]); // Handle title change const handleTitleChange = useCallback( diff --git a/apps/desktop/src/renderer/components/ai/AiPanel.tsx b/apps/desktop/src/renderer/components/ai/AiPanel.tsx index eb1d0b3d..930e0bdb 100644 --- a/apps/desktop/src/renderer/components/ai/AiPanel.tsx +++ b/apps/desktop/src/renderer/components/ai/AiPanel.tsx @@ -3,6 +3,7 @@ import { X, Send, Trash2, ArrowDownToLine, BookOpen, MessageSquare } from 'lucid import type { ChatMessage, NoteContext, AiPanelMode, LLMEvent } from '@readied/ai-core'; import { useSettingsStore, selectAi } from '../../stores/settings'; import { AiMessage } from './AiMessage'; +import { ToolCallBlock } from './ToolCallBlock'; /** Pre-filled command to auto-execute on mount (used by ai:summarize, ai:rewrite, ai:tweet) */ export interface AiInitialCommand { @@ -73,6 +74,20 @@ export function AiPanel({ const messagesEndRef = useRef(null); const inputRef = useRef(null); const activeRequestRef = useRef(null); + const commandActiveRef = useRef(false); + + // Tool call tracking + const [toolCalls, setToolCalls] = useState< + Map< + string, + { + name: string; + args: Record; + status: 'pending_confirmation' | 'executing' | 'complete' | 'rejected' | 'error'; + result?: { ok: boolean; content: string; error?: string }; + } + > + >(new Map()); // Auto-scroll to bottom when messages change useEffect(() => { @@ -89,11 +104,51 @@ export function AiPanel({ setMode(initialMode); }, [initialMode]); + // Listen for renderer-executed tool requests from main process + useEffect(() => { + const cleanup = window.readied.ai.onToolExecuteRequest( + async (requestId: string, callId: string, toolName: string, args: unknown) => { + const toolArgs = args as Record; + try { + if (toolName === 'insert_text') { + const text = toolArgs.text as string; + insertAtCursor(text); + await window.readied.ai.sendToolResult(requestId, callId, { + ok: true, + content: `Inserted ${text.length} characters at cursor`, + }); + } else if (toolName === 'replace_selection' && replaceSelection) { + const text = toolArgs.text as string; + replaceSelection(text); + await window.readied.ai.sendToolResult(requestId, callId, { + ok: true, + content: `Replaced selection with ${text.length} characters`, + }); + } else { + await window.readied.ai.sendToolResult(requestId, callId, { + ok: false, + content: `Unknown renderer tool: ${toolName}`, + error: `Unknown renderer tool: ${toolName}`, + }); + } + } catch (err) { + await window.readied.ai.sendToolResult(requestId, callId, { + ok: false, + content: err instanceof Error ? err.message : String(err), + error: err instanceof Error ? err.message : String(err), + }); + } + } + ); + return cleanup; + }, [insertAtCursor, replaceSelection]); + // Subscribe to AI streaming events useEffect(() => { const cleanup = window.readied.ai.onEvent((requestId: string, rawEvent: unknown) => { - // Only process events for the active request + // Only process events for the active request; skip when a command listener owns the stream if (requestId !== activeRequestRef.current) return; + if (commandActiveRef.current) return; const event = rawEvent as LLMEvent; @@ -115,16 +170,85 @@ export function AiPanel({ }); break; - case 'error': - setError(formatErrorMessage(event)); - setLoading(false); - activeRequestRef.current = null; + case 'error': { + const errorEvent = event as LLMEvent & { type: 'error'; retryable?: boolean }; + if (errorEvent.retryable) { + // Transient retry — show message but don't tear down the stream + setError(formatErrorMessage(event)); + } else { + setError(formatErrorMessage(event)); + setLoading(false); + activeRequestRef.current = null; + } break; + } case 'done': setLoading(false); activeRequestRef.current = null; break; + + case 'tool_call': + setToolCalls(prev => { + const next = new Map(prev); + const e = event as LLMEvent & { + type: 'tool_call'; + id: string; + name: string; + args: unknown; + }; + next.set(e.id, { + name: e.name, + args: (e.args as Record) ?? {}, + status: 'executing', + }); + return next; + }); + break; + + case 'tool_confirm_needed' as string: + setToolCalls(prev => { + const next = new Map(prev); + const e = event as unknown as { callId: string }; + const existing = next.get(e.callId); + if (existing) { + next.set(e.callId, { ...existing, status: 'pending_confirmation' }); + } + return next; + }); + break; + + case 'tool_executing' as string: + setToolCalls(prev => { + const next = new Map(prev); + const e = event as unknown as { + call: { id: string; name: string; args: Record }; + }; + const existing = next.get(e.call.id); + if (existing) { + next.set(e.call.id, { ...existing, status: 'executing' }); + } else { + next.set(e.call.id, { name: e.call.name, args: e.call.args, status: 'executing' }); + } + return next; + }); + break; + + case 'tool_complete' as string: + setToolCalls(prev => { + const next = new Map(prev); + const e = event as unknown as { + call: { id: string }; + result: { ok: boolean; content: string; error?: string }; + }; + const existing = next.get(e.call.id); + if (existing) { + const status = e.result.ok ? 'complete' : 'error'; + next.set(e.call.id, { ...existing, status, result: e.result }); + } + return next; + }); + break; } }); @@ -159,6 +283,7 @@ export function AiPanel({ // Track the output target for when the response arrives const commandOutputTarget = initialCommand.outputTarget; let accumulatedText = ''; + commandActiveRef.current = true; // Set up a one-time listener for this command's events const commandCleanup = window.readied.ai.onEvent((requestId: string, rawEvent: unknown) => { @@ -188,6 +313,7 @@ export function AiPanel({ setLoading(false); activeRequestRef.current = null; onCommandExecuted?.(); + commandActiveRef.current = false; commandCleanup(); break; @@ -225,6 +351,7 @@ export function AiPanel({ setLoading(false); activeRequestRef.current = null; onCommandExecuted?.(); + commandActiveRef.current = false; commandCleanup(); break; } @@ -345,6 +472,7 @@ export function AiPanel({ model, providerConfig: { apiKey }, maxResponseTokens: 2048, + tools: true, }); activeRequestRef.current = requestId; } catch (err) { @@ -373,6 +501,24 @@ export function AiPanel({ [handleSubmit] ); + const handleToolConfirm = useCallback((callId: string) => { + if (activeRequestRef.current) { + window.readied.ai.confirmTool(activeRequestRef.current, callId, true); + } + }, []); + + const handleToolReject = useCallback((callId: string) => { + if (activeRequestRef.current) { + window.readied.ai.confirmTool(activeRequestRef.current, callId, false); + setToolCalls(prev => { + const next = new Map(prev); + const existing = next.get(callId); + if (existing) next.set(callId, { ...existing, status: 'rejected' }); + return next; + }); + } + }, []); + const handleClear = useCallback(() => { // Cancel any active request if (activeRequestRef.current) { @@ -382,6 +528,7 @@ export function AiPanel({ setMessages([]); setError(null); setContextCount(0); + setToolCalls(new Map()); setLoading(false); }, []); @@ -457,6 +604,18 @@ export function AiPanel({ content={typeof msg.content === 'string' ? msg.content : ''} /> ))} + {toolCalls.size > 0 && + Array.from(toolCalls.entries()).map(([callId, tc]) => ( + handleToolConfirm(callId)} + onReject={() => handleToolReject(callId)} + /> + ))} {loading && messages[messages.length - 1]?.role !== 'assistant' && (
AI
diff --git a/apps/desktop/src/renderer/components/ai/ToolCallBlock.tsx b/apps/desktop/src/renderer/components/ai/ToolCallBlock.tsx new file mode 100644 index 00000000..35495657 --- /dev/null +++ b/apps/desktop/src/renderer/components/ai/ToolCallBlock.tsx @@ -0,0 +1,116 @@ +import { useState } from 'react'; +import { + Search, + FileText, + FolderOpen, + PenLine, + Replace, + FilePlus, + Loader2, + CheckCircle, + XCircle, + ChevronDown, + ChevronRight, +} from 'lucide-react'; + +type ToolCallStatus = 'pending_confirmation' | 'executing' | 'complete' | 'rejected' | 'error'; + +interface ToolCallBlockProps { + name: string; + args: Record; + status: ToolCallStatus; + result?: { ok: boolean; content: string; error?: string }; + onConfirm?: () => void; + onReject?: () => void; +} + +const TOOL_ICONS: Record = { + search_notes: Search, + read_note: FileText, + list_notebooks: FolderOpen, + insert_text: PenLine, + replace_selection: Replace, + create_note: FilePlus, +}; + +export function ToolCallBlock({ + name, + args, + status, + result, + onConfirm, + onReject, +}: ToolCallBlockProps) { + const [expanded, setExpanded] = useState(false); + const Icon = TOOL_ICONS[name] ?? Search; + + return ( +
+
setExpanded(prev => !prev)} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setExpanded(prev => !prev); + } + }} + aria-expanded={expanded} + > +
+ {status === 'executing' ? ( + + ) : status === 'complete' && result?.ok ? ( + + ) : status === 'error' || (status === 'complete' && !result?.ok) ? ( + + ) : ( + + )} + {name} + + {status === 'pending_confirmation' && '— needs approval'} + {status === 'executing' && '— running...'} + {status === 'rejected' && '— cancelled'} + +
+
+ {expanded ? : } +
+
+ + {status === 'pending_confirmation' && ( +
+ + +
+ )} + + {expanded && ( +
+ {Object.keys(args).length > 0 && ( +
+
Args
+
{JSON.stringify(args, null, 2)}
+
+ )} + {result && ( +
+
Result
+
+                {result.content.slice(0, 500)}
+                {result.content.length > 500 ? '...' : ''}
+              
+
+ )} +
+ )} +
+ ); +} diff --git a/apps/desktop/src/renderer/stores/settings/settingsStore.ts b/apps/desktop/src/renderer/stores/settings/settingsStore.ts index a87706e2..b71c73a0 100644 --- a/apps/desktop/src/renderer/stores/settings/settingsStore.ts +++ b/apps/desktop/src/renderer/stores/settings/settingsStore.ts @@ -93,8 +93,6 @@ function migrateSettings(persisted: unknown, version: number): { settings: Setti }; } - settings = mutable as SettingsSchema; - // Migration: v2 -> v3 (add provider field to AI settings) if (version < 3) { mutable = { @@ -104,6 +102,7 @@ function migrateSettings(persisted: unknown, version: number): { settings: Setti }; } + settings = mutable as SettingsSchema; return { settings }; } diff --git a/apps/desktop/src/renderer/styles/ai-panel.css b/apps/desktop/src/renderer/styles/ai-panel.css index 2ab0b164..75f1f77c 100644 --- a/apps/desktop/src/renderer/styles/ai-panel.css +++ b/apps/desktop/src/renderer/styles/ai-panel.css @@ -206,3 +206,106 @@ opacity: 0.4; cursor: not-allowed; } + +/* Tool Call Block */ +.ai-tool-call { + margin: 0.5rem 1rem; + border: 1px solid var(--border-strong); + border-radius: 0.5rem; + overflow: hidden; + font-size: 0.8125rem; +} + +.ai-tool-call-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0.75rem; + background: var(--bg-hover); + cursor: pointer; + user-select: none; +} + +.ai-tool-call-left { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.ai-tool-call-name { + font-weight: 500; + color: var(--text-primary); +} + +.ai-tool-call-status { + color: var(--text-tertiary); + font-size: 0.75rem; +} + +.ai-tool-call-spinning { + animation: ai-spin 1s linear infinite; +} + +@keyframes ai-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.ai-tool-call-success { + color: var(--success, #22c55e); +} + +.ai-tool-call-error { + color: var(--error, #ef4444); +} + +.ai-tool-call-actions { + display: flex; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-top: 1px solid var(--border-strong); +} + +.ai-tool-call-btn { + padding: 0.25rem 0.75rem; + border-radius: 0.375rem; + border: 1px solid var(--border-strong); + background: var(--bg-tertiary); + color: var(--text-primary); + font-size: 0.75rem; + cursor: pointer; +} + +.ai-tool-call-btn.approve { + background: var(--accent, #5eead4); + color: var(--bg-primary, #000); + border-color: transparent; +} + +.ai-tool-call-details { + padding: 0.5rem 0.75rem; + border-top: 1px solid var(--border-strong); +} + +.ai-tool-call-label { + font-size: 0.6875rem; + text-transform: uppercase; + color: var(--text-tertiary); + margin-bottom: 0.25rem; +} + +.ai-tool-call-details pre { + margin: 0; + font-size: 0.75rem; + white-space: pre-wrap; + overflow-wrap: break-word; + color: var(--text-secondary); +} + +.ai-tool-call-args + .ai-tool-call-result { + margin-top: 0.5rem; +} diff --git a/apps/web/app/(marketing)/auth/verify/AuthVerifyContent.tsx b/apps/web/app/(marketing)/auth/verify/AuthVerifyContent.tsx index 9bd6f74b..02dcf990 100644 --- a/apps/web/app/(marketing)/auth/verify/AuthVerifyContent.tsx +++ b/apps/web/app/(marketing)/auth/verify/AuthVerifyContent.tsx @@ -39,8 +39,8 @@ export default function AuthVerifyContent() {
-

Invalid Link

-

+

Invalid Link

+

This verification link is incomplete or has expired. Please request a new magic link from the Readied app.

@@ -60,9 +60,9 @@ export default function AuthVerifyContent() {
{!showFallback ? ( <> -
-

Opening Readied...

-

The app should open automatically. Hang tight.

+
+

Opening Readied...

+

The app should open automatically. Hang tight.

) : ( <> @@ -79,8 +79,8 @@ export default function AuthVerifyContent() {
-

Almost there

-

+

Almost there

+

The app didn't open automatically. Try clicking the button below.

@@ -91,15 +91,15 @@ export default function AuthVerifyContent() { Open in Readied -
-

+
+

Opened this on the wrong device?

-

+

Open this same link on the device where Readied is installed. The magic link is valid for 15 minutes.

-

+

Don't have Readied yet?{' '} Download now diff --git a/apps/web/app/(marketing)/changelog/page.tsx b/apps/web/app/(marketing)/changelog/page.tsx index 7deb70bd..db0891c2 100644 --- a/apps/web/app/(marketing)/changelog/page.tsx +++ b/apps/web/app/(marketing)/changelog/page.tsx @@ -86,7 +86,7 @@ export default async function ChangelogPage() { {/* Timeline */}

@@ -129,7 +129,7 @@ export default async function ChangelogPage() { {release.changes.map((change, ci) => (
  • We're building something good. Leave your email to get notified.

    - + Coming Soon
    diff --git a/apps/web/app/(marketing)/pricing/page.tsx b/apps/web/app/(marketing)/pricing/page.tsx index 8cd65d48..f381ca5e 100644 --- a/apps/web/app/(marketing)/pricing/page.tsx +++ b/apps/web/app/(marketing)/pricing/page.tsx @@ -14,6 +14,7 @@ import { Card } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { BorderBeam } from '@/components/magicui/border-beam'; +import { NumberTicker } from '@/components/magicui/number-ticker'; import { Accordion, AccordionItem, @@ -26,8 +27,9 @@ export default function PricingPage() { const { plans, guarantees, trialDays, trialDescription } = config; const proPricing = plans.pro.pricing!; - const monthlyLabel = proPricing.intervals.monthly.label; - const annualLabel = proPricing.intervals.annual.label; + // Extract numeric values from price labels for NumberTicker + const monthlyPrice = proPricing.intervals.monthly.amountCents / 100; + const annualPrice = proPricing.intervals.annual.amountCents / 100; const faqs = [ { q: 'What if you stop developing Readied?', a: guarantees.freeTierForever.description }, @@ -75,7 +77,7 @@ export default function PricingPage() {
      {plans.free.features.map((f, i) => (
    • - + {f} @@ -107,13 +109,27 @@ export default function PricingPage() {
    {plans.pro.name}
    - - {monthlyLabel} - +
    + $ + + /mo +
    or - - {annualLabel} - +
    + $ + + + /year + +
    Save {proPricing.annualSavings} @@ -133,7 +149,7 @@ export default function PricingPage() { ))} -
    +
    {trialDescription}
    diff --git a/apps/web/app/docs/[[...slug]]/page.tsx b/apps/web/app/docs/[[...slug]]/page.tsx index 4db6a23e..64a54b4d 100644 --- a/apps/web/app/docs/[[...slug]]/page.tsx +++ b/apps/web/app/docs/[[...slug]]/page.tsx @@ -1,9 +1,31 @@ import { source } from '@/lib/source'; import { notFound } from 'next/navigation'; import { DocsPage, DocsBody, DocsTitle, DocsDescription } from 'fumadocs-ui/page'; -import { useMDXComponents } from '@/mdx-components'; +import defaultMdxComponents from 'fumadocs-ui/mdx'; +import { Card, Cards } from 'fumadocs-ui/components/card'; +import { Callout } from 'fumadocs-ui/components/callout'; +import { Step, Steps } from 'fumadocs-ui/components/steps'; +import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; +import { Accordion, Accordions } from 'fumadocs-ui/components/accordion'; +import { File, Folder, Files } from 'fumadocs-ui/components/files'; +import { TypeTable } from 'fumadocs-ui/components/type-table'; -const mdxComponents = useMDXComponents({}); +const mdxComponents = { + ...defaultMdxComponents, + Card, + Cards, + Callout, + Step, + Steps, + Tab, + Tabs, + Accordion, + Accordions, + File, + Folder, + Files, + TypeTable, +}; export default async function Page(props: { params: Promise<{ slug?: string[] }> }) { const params = await props.params; diff --git a/apps/web/components/Footer.tsx b/apps/web/components/Footer.tsx index 22d94f2e..d29d3eac 100644 --- a/apps/web/components/Footer.tsx +++ b/apps/web/components/Footer.tsx @@ -68,7 +68,7 @@ export default function Footer() {
    {/* Gradient separator */}
    -
    +
    @@ -129,11 +129,25 @@ export default function Footer() {
    {/* Bottom bar */} -
    -
    +
    +
    © {year} Readied. Built with ♥ in Argentina. +
    + {socialLinks.map(social => ( + + {social.icon} + + ))} +
    diff --git a/apps/web/components/Navbar.tsx b/apps/web/components/Navbar.tsx index beb9d048..6d0bd4e1 100644 --- a/apps/web/components/Navbar.tsx +++ b/apps/web/components/Navbar.tsx @@ -94,7 +94,7 @@ export default function Navbar() {
    {/* Floating pill navbar */}