From c4c9f33e25c82d6faa4c9d1f67f3ffc3e8ae1931 Mon Sep 17 00:00:00 2001 From: Tino Ehrich Date: Fri, 19 Dec 2025 15:36:49 +0100 Subject: [PATCH 1/6] feat(ai-chat): add AI chat panel with Claude integration - Two-column layout with resizable panel (15-60%, persisted) - Token validation via Tauri backend (bypasses CORS) - Streaming responses with real-time updates - Markdown rendering with Mermaid diagram support Commands (type / for autocomplete menu): - /clear - Reset conversation - /summarise - Generate document summary - /model - Select model (haiku-4.5, sonnet-4.5, opus-4.5) - /export - Export chat as markdown in new tab UI: - Ctrl+A toggle, Escape close, Enter send - Purple AI button (customizable in settings) - Model stored in localStorage with validation --- AGENTS.md | 48 ++ bun.lock | 13 +- packages/app/package.json | 1 + packages/app/src-tauri/Cargo.lock | 29 + packages/app/src-tauri/Cargo.toml | 4 +- packages/app/src-tauri/src/lib.rs | 203 +++++ packages/app/src/App.tsx | 48 +- packages/app/src/components/ai-chat-panel.tsx | 795 ++++++++++++++++++ packages/app/src/components/file-header.tsx | 15 + .../app/src/components/settings-modal.tsx | 2 + packages/app/src/services/claude-client.ts | 179 ++++ packages/app/src/stores/ai-chat-store.ts | 252 ++++++ packages/app/src/stores/app-store.ts | 1 + packages/app/src/styles/ai-chat.css | 536 ++++++++++++ packages/app/src/types.ts | 4 + packages/app/src/utils.ts | 2 + packages/shared/styles/theme.css | 2 + 17 files changed, 2121 insertions(+), 13 deletions(-) create mode 100644 packages/app/src/components/ai-chat-panel.tsx create mode 100644 packages/app/src/services/claude-client.ts create mode 100644 packages/app/src/stores/ai-chat-store.ts create mode 100644 packages/app/src/styles/ai-chat.css diff --git a/AGENTS.md b/AGENTS.md index ae5d6de..c233952 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -225,6 +225,54 @@ bun run watch # Watch mode --- +## AI Chat (Desktop App) + +Built-in Claude integration for document analysis and Q&A. + +### Commands + +Type `/` in the input field to show command menu. Navigate with arrow keys, select with Enter. + +| Command | Description | +|---------|-------------| +| `/clear` | Reset conversation history | +| `/summarise` | Generate comprehensive document summary | +| `/model` | Select AI model (opens submenu) | +| `/export` | Export chat as markdown in new tab | + +### Command Menu Pattern + +Commands are defined in `ai-chat-panel.tsx`: + +```typescript +interface Command { + name: string; // Command name (without /) + description: string; // Shown in menu + hasSubmenu?: boolean; // Opens nested selection + action?: () => void; // Direct execution +} +``` + +**Menu behavior:** +- Shows when input starts with `/` +- Filters as user types more characters +- Arrow keys navigate, Tab autocompletes, Enter executes, Escape closes +- Commands with `hasSubmenu: true` open a nested selection (e.g., model picker) + +### Models + +Available via `/model` command: + +| Short Name | Full Name | API ID | +|------------|-----------|--------| +| haiku-4.5 | Claude Haiku 4.5 | `claude-haiku-4-5` | +| sonnet-4.5 | Claude Sonnet 4.5 | `claude-sonnet-4-5` | +| opus-4.5 | Claude Opus 4.5 | `claude-opus-4-5` | + +**State:** Model selection persisted in localStorage (`md-ai-model`). + +--- + ## Versioning When creating a new version: diff --git a/bun.lock b/bun.lock index ed3c59d..5761738 100644 --- a/bun.lock +++ b/bun.lock @@ -7,8 +7,9 @@ }, "packages/app": { "name": "@md/app", - "version": "0.10.0", + "version": "0.11.1", "dependencies": { + "@anthropic-ai/sdk": "^0.71.2", "@md/shared": "workspace:*", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.4.2", @@ -34,7 +35,7 @@ }, "packages/extension": { "name": "@md/extension", - "version": "0.1.0", + "version": "0.2.2", "dependencies": { "@md/shared": "workspace:*", "@types/turndown": "^5.0.6", @@ -60,6 +61,8 @@ "packages": { "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.71.2", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ=="], + "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], "@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="], @@ -90,6 +93,8 @@ "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w=="], + "@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="], + "@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="], "@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="], @@ -554,6 +559,8 @@ "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "katex": ["katex@0.16.27", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw=="], @@ -672,6 +679,8 @@ "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + "ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="], "turndown": ["turndown@7.2.2", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ=="], diff --git a/packages/app/package.json b/packages/app/package.json index 0eb6c25..f4c8521 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -17,6 +17,7 @@ }, "license": "MIT", "dependencies": { + "@anthropic-ai/sdk": "^0.71.2", "@md/shared": "workspace:*", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.4.2", diff --git a/packages/app/src-tauri/Cargo.lock b/packages/app/src-tauri/Cargo.lock index 861c393..75ee060 100644 --- a/packages/app/src-tauri/Cargo.lock +++ b/packages/app/src-tauri/Cargo.lock @@ -1081,6 +1081,21 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -1156,6 +1171,7 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -2146,6 +2162,7 @@ dependencies = [ "base64 0.22.1", "chrono", "dirs 5.0.1", + "futures", "notify", "notify-debouncer-mini", "parking_lot", @@ -2158,6 +2175,7 @@ dependencies = [ "tauri-plugin-fs", "tauri-plugin-opener", "tauri-plugin-single-instance", + "tokio-stream", ] [[package]] @@ -4469,6 +4487,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.17" diff --git a/packages/app/src-tauri/Cargo.toml b/packages/app/src-tauri/Cargo.toml index 58dfb5a..755f958 100644 --- a/packages/app/src-tauri/Cargo.toml +++ b/packages/app/src-tauri/Cargo.toml @@ -31,4 +31,6 @@ parking_lot = "0.12" chrono = "0.4" base64 = "0.22" tauri-plugin-single-instance = "2.3.6" -reqwest = { version = "0.12", features = ["blocking"] } +reqwest = { version = "0.12", features = ["blocking", "stream", "json"] } +futures = "0.3" +tokio-stream = "0.1" diff --git a/packages/app/src-tauri/src/lib.rs b/packages/app/src-tauri/src/lib.rs index 022e028..7573be5 100644 --- a/packages/app/src-tauri/src/lib.rs +++ b/packages/app/src-tauri/src/lib.rs @@ -10,6 +10,7 @@ use std::{ }; use tauri::{AppHandle, Emitter, Manager}; use chrono::Local; +use futures::StreamExt; const MAX_HISTORY: usize = 20; const CONFIG_FILE: &str = "config.json"; @@ -449,6 +450,206 @@ fn get_changelog_path(app: AppHandle) -> Result { Err("Changelog not found".to_string()) } +// ============================================================================ +// Claude API +// ============================================================================ + +#[derive(Debug, Deserialize)] +struct ChatMessage { + role: String, + content: String, +} + +#[derive(Debug, Deserialize)] +struct ChatRequest { + token: String, + model: String, + messages: Vec, + system_prompt: String, +} + +#[derive(Debug, Serialize, Clone)] +struct ChatStreamEvent { + event_type: String, // "start", "delta", "done", "error" + content: Option, + error: Option, +} + +/// Validate a Claude API token +#[tauri::command] +async fn validate_claude_token(token: String) -> Result { + let is_oauth = token.contains("sk-ant-oat"); + + let client = reqwest::Client::new(); + let mut request = client + .post("https://api.anthropic.com/v1/messages") + .header("Content-Type", "application/json") + .header("anthropic-version", "2023-06-01"); + + if is_oauth { + request = request + .header("Authorization", format!("Bearer {}", token)) + .header("anthropic-beta", "oauth-2025-04-20"); + } else { + request = request.header("x-api-key", &token); + } + + let body = serde_json::json!({ + "model": "claude-haiku-4-5", + "max_tokens": 1, + "messages": [{"role": "user", "content": "hi"}] + }); + + let response = request + .json(&body) + .send() + .await + .map_err(|e| format!("Network error: {}", e))?; + + if response.status().is_success() { + Ok(true) + } else { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + Err(format!("API error {}: {}", status, body)) + } +} + +/// Stream a chat response from Claude +#[tauri::command] +async fn stream_claude_chat( + app: AppHandle, + request: ChatRequest, + request_id: String, +) -> Result<(), String> { + let is_oauth = request.token.contains("sk-ant-oat"); + + let client = reqwest::Client::new(); + let mut http_request = client + .post("https://api.anthropic.com/v1/messages") + .header("Content-Type", "application/json") + .header("anthropic-version", "2023-06-01"); + + if is_oauth { + http_request = http_request + .header("Authorization", format!("Bearer {}", request.token)) + .header("anthropic-beta", "oauth-2025-04-20"); + } else { + http_request = http_request.header("x-api-key", &request.token); + } + + // Build messages array for API + let messages: Vec = request.messages + .iter() + .filter(|m| m.role == "user" || m.role == "assistant") + .map(|m| serde_json::json!({ + "role": m.role, + "content": m.content + })) + .collect(); + + let body = serde_json::json!({ + "model": request.model, + "max_tokens": 4096, + "system": request.system_prompt, + "messages": messages, + "stream": true + }); + + let event_name = format!("claude-stream-{}", request_id); + + // Emit start event + let _ = app.emit(&event_name, ChatStreamEvent { + event_type: "start".to_string(), + content: None, + error: None, + }); + + let response = http_request + .json(&body) + .send() + .await + .map_err(|e| { + let _ = app.emit(&event_name, ChatStreamEvent { + event_type: "error".to_string(), + content: None, + error: Some(format!("Network error: {}", e)), + }); + format!("Network error: {}", e) + })?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + let error_msg = format!("API error {}: {}", status, body); + let _ = app.emit(&event_name, ChatStreamEvent { + event_type: "error".to_string(), + content: None, + error: Some(error_msg.clone()), + }); + return Err(error_msg); + } + + // Stream the response + let mut stream = response.bytes_stream(); + let mut buffer = String::new(); + + while let Some(chunk_result) = stream.next().await { + match chunk_result { + Ok(chunk) => { + let chunk_str = String::from_utf8_lossy(&chunk); + buffer.push_str(&chunk_str); + + // Process complete SSE events from buffer + while let Some(event_end) = buffer.find("\n\n") { + let event_data = buffer[..event_end].to_string(); + buffer = buffer[event_end + 2..].to_string(); + + // Parse SSE event + for line in event_data.lines() { + if let Some(data) = line.strip_prefix("data: ") { + if data == "[DONE]" { + continue; + } + + // Parse JSON + if let Ok(json) = serde_json::from_str::(data) { + // Extract text delta + if json["type"] == "content_block_delta" { + if let Some(text) = json["delta"]["text"].as_str() { + let _ = app.emit(&event_name, ChatStreamEvent { + event_type: "delta".to_string(), + content: Some(text.to_string()), + error: None, + }); + } + } + } + } + } + } + } + Err(e) => { + let _ = app.emit(&event_name, ChatStreamEvent { + event_type: "error".to_string(), + content: None, + error: Some(format!("Stream error: {}", e)), + }); + return Err(format!("Stream error: {}", e)); + } + } + } + + // Emit done event + let _ = app.emit(&event_name, ChatStreamEvent { + event_type: "done".to_string(), + content: None, + error: None, + }); + + Ok(()) +} + // ============================================================================ // Plugin Setup // ============================================================================ @@ -491,6 +692,8 @@ pub fn run() { read_image_base64, get_file_dir, fetch_url, + validate_claude_token, + stream_claude_chat, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index b1f6bd1..315151c 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -92,6 +92,16 @@ import { HelpModal } from "./components/help-modal"; import { UrlInputModal } from "./components/url-input-modal"; import { ReleaseNotification } from "./components/release-notification"; import { PageOverviewModal, preRenderThumbnails } from "./components/page-overview-modal"; +import { AiChatPanel } from "./components/ai-chat-panel"; +import { + showAiChat, + setShowAiChat, + toggleAiChat, + aiChatWidth, + setAiChatWidth, + aiChatResizing, + DEFAULT_WIDTH_PERCENT, +} from "./stores/ai-chat-store"; // Marked is instantiated per-render to avoid stacking extensions @@ -181,6 +191,7 @@ function App() { setMarkdownFontFamily(cfg.markdown_font_family || "JetBrains Mono"); setDarkColors({ ...DEFAULT_DARK_COLORS, ...cfg.dark_colors }); setLightColors({ ...DEFAULT_LIGHT_COLORS, ...cfg.light_colors }); + setAiChatWidth(cfg.ai_chat_width || DEFAULT_WIDTH_PERCENT); document.documentElement.setAttribute("data-theme", cfg.theme); // Sync light class with theme for index.html styles if (cfg.theme === "light") { @@ -460,6 +471,10 @@ function App() { e.preventDefault(); toggleSidebar(); break; + case "a": + e.preventDefault(); + toggleAiChat(); + break; case ",": e.preventDefault(); if (!showSettings()) { @@ -614,6 +629,8 @@ function App() { setSearchQuery(""); } else if (showPageOverview()) { setShowPageOverview(false); + } else if (showAiChat()) { + setShowAiChat(false); } else if (showRawMarkdown()) { setContent(originalContent()); setShowRawMarkdown(false); @@ -1236,9 +1253,16 @@ function App() { setShowRawMarkdown(false); } + // Save AI chat width to config + async function saveAiChatWidth(width: number) { + const newConfig = { ...config(), ai_chat_width: width }; + setConfig(newConfig); + await invoke("save_config", { config: newConfig }); + } + return (
setShowUrlModal(true)} onLoadFile={loadFile} onLoadDraft={loadDraft} /> -
- - { editorApi = api; }} - onArticleRef={setArticleElement} - /> -
+
+
+ + { editorApi = api; }} + onArticleRef={setArticleElement} + /> +
+ + +
void | Promise; + submenuItems?: { id: string; label: string; action: () => void }[]; +} + +const SUMMARISE_PROMPT = `Please provide a comprehensive summary of this document. Include: + +1. **Main Purpose**: What is this document about? (1-2 sentences) +2. **Key Points**: The most important information, organized by topic +3. **Structure Overview**: How the document is organized (sections, flow) +4. **Notable Details**: Any critical details, warnings, or action items +5. **Audience**: Who this document appears to be written for + +Keep the summary concise but complete. Use bullet points for clarity. If the document contains code, technical specs, or diagrams, briefly note what they cover without reproducing them.`; + +// Markdown renderer for assistant messages +const md = new MarkdownIt({ + html: true, + linkify: true, + breaks: true, +}); + +// Store mermaid code by message ID to avoid data attribute issues +const mermaidCodeMap = new Map(); + +// Custom fence renderer for mermaid diagrams +md.renderer.rules.fence = (tokens, idx) => { + const token = tokens[idx]; + const code = token.content; + const lang = (token.info || "").trim().split(/\s+/)[0]; + + if (lang === "mermaid") { + // Use content hash as stable ID + const hash = btoa(code).substring(0, 16).replace(/[+/=]/g, 'x'); + const id = `ai-mermaid-${hash}`; + mermaidCodeMap.set(id, code); + return `
`; + } + + // Default code block rendering + const escaped = escapeHtml(code); + if (lang) { + return `
${escaped}
`; + } + return `
${escaped}
`; +}; + +interface AiChatPanelProps { + onSaveWidth: (width: number) => void; +} + +// ============================================================================ +// Token Setup Component +// ============================================================================ + +function TokenSetup() { + const [inputToken, setInputToken] = createSignal(""); + + // Validate stored token on mount + onMount(async () => { + const storedToken = aiToken(); + if (storedToken && !aiTokenValid()) { + setAiTokenValidating(true); + const result = await validateToken(storedToken); + setAiTokenValidating(false); + + if (result.valid) { + setAiTokenValid(true); + } else { + // Token expired/invalid, clear it + setAiToken(null); + } + } + }); + + async function handleSubmit(e: Event) { + e.preventDefault(); + const token = inputToken().trim(); + if (!token) return; + + setAiTokenValidating(true); + setAiTokenError(null); + + const result = await validateToken(token); + + setAiTokenValidating(false); + + if (result.valid) { + setAiToken(token); + setAiTokenValid(true); + } else { + setAiTokenError(result.error || "Invalid token"); + } + } + + // Show loading if we're validating a stored token + const isValidatingStored = () => aiTokenValidating() && aiToken() && !inputToken(); + + return ( +
+ +
🔑
+

Connect to Claude

+

Enter your Anthropic API key or OAuth token to start chatting.

+ +
+ setInputToken(e.currentTarget.value)} + disabled={aiTokenValidating()} + autofocus + /> + + +
{aiTokenError()}
+
+ + +
+ +

+ Get an API key from{" "} + + console.anthropic.com + +

+ + }> +
+

Connecting...

+

Validating your saved token

+
+
+ ); +} + +// ============================================================================ +// Message Component +// ============================================================================ + +interface MessageProps { + message: typeof aiMessages extends () => (infer T)[] ? T : never; +} + +function Message(props: MessageProps) { + let containerRef: HTMLDivElement | undefined; + let contentRef: HTMLDivElement | undefined; + let lastRenderedContent = ""; + let hasMermaidRendered = false; + + // Update markdown content when it changes + createEffect(() => { + const content = props.message.content; + const isStreaming = props.message.isStreaming; + + if (!contentRef || !content) return; + + // Skip update if mermaid is rendered and content hasn't changed + if (hasMermaidRendered && content === lastRenderedContent) return; + + // During streaming, always update. After streaming, only update if content changed + if (isStreaming || content !== lastRenderedContent) { + contentRef.innerHTML = md.render(content); + lastRenderedContent = content; + + // Reset mermaid flag during streaming + if (isStreaming) { + hasMermaidRendered = false; + } + } + }); + + // Process mermaid diagrams after render + createEffect(() => { + const msgContent = props.message.content; + const isStreaming = props.message.isStreaming; + + // Only process when we have content and not streaming + if (!msgContent || isStreaming || !containerRef) return; + + // Small delay to ensure DOM is updated + setTimeout(() => { + // Find all mermaid placeholders + const diagrams = containerRef.querySelectorAll('.mermaid-diagram'); + + diagrams.forEach(async (el) => { + if (el.querySelector('svg')) return; // Already rendered + + const code = mermaidCodeMap.get(el.id); + if (!code) return; + + try { + const { svg } = await mermaid.render(el.id + '-svg', code); + el.innerHTML = svg; + el.classList.remove('mermaid-loading'); + hasMermaidRendered = true; + } catch (err) { + console.error('Mermaid render error:', err); + el.innerHTML = `
Diagram error: ${err}
`; + el.classList.remove('mermaid-loading'); + } + }); + }, 100); + }); + + return ( +
+ + + + + + + {props.message.content}
+ }> +
+ + +
+ ); +} + +// ============================================================================ +// Chat View Component +// ============================================================================ + +function ChatView() { + const [input, setInput] = createSignal(""); + const [showCommandMenu, setShowCommandMenu] = createSignal(false); + const [selectedCommandIndex, setSelectedCommandIndex] = createSignal(0); + const [showModelSubmenu, setShowModelSubmenu] = createSignal(false); + const [selectedModelIndex, setSelectedModelIndex] = createSignal(0); + let messagesEndRef: HTMLDivElement | undefined; + let inputRef: HTMLTextAreaElement | undefined; + + // Command definitions (needs to be inside ChatView for access to sendMessage) + const commands: Command[] = [ + { name: "clear", description: "Reset conversation" }, + { name: "summarise", description: "Summarise the document" }, + { name: "model", description: "Select AI model", hasSubmenu: true }, + { name: "export", description: "Export chat as markdown" }, + ]; + + // Filter commands based on input + const filteredCommands = () => { + const text = input().toLowerCase(); + if (!text.startsWith("/")) return []; + const query = text.slice(1); + return commands.filter(cmd => cmd.name.startsWith(query)); + }; + + // Auto-scroll to bottom when messages change + createEffect(() => { + const messages = aiMessages(); + if (messages.length > 0 && messagesEndRef) { + messagesEndRef.scrollIntoView({ behavior: "smooth" }); + } + }); + + // Show/hide command menu based on input + createEffect(() => { + const text = input(); + if (text.startsWith("/") && !showModelSubmenu()) { + const filtered = filteredCommands(); + if (filtered.length > 0) { + setShowCommandMenu(true); + // Reset selection if out of bounds + if (selectedCommandIndex() >= filtered.length) { + setSelectedCommandIndex(0); + } + } else { + setShowCommandMenu(false); + } + } else if (!showModelSubmenu()) { + setShowCommandMenu(false); + } + }); + + // Focus input on mount + onMount(() => { + inputRef?.focus(); + }); + + function getDocumentContext() { + const file = currentFile(); + const draftId = currentDraftId(); + let filename: string | undefined; + + if (file) { + filename = getFilename(file); + } else if (draftId) { + const draft = getDraft(draftId); + filename = draft?.sourceTitle || `Draft ${draftId}`; + } + + return { + content: content(), + filename, + }; + } + + async function sendMessage(text: string) { + addUserMessage(text); + setAiLoading(true); + addAssistantMessage("", true); + + const token = aiToken(); + if (!token) return; + + await streamChat( + token, + aiModel(), + aiMessages(), + getDocumentContext(), + { + onText: (_delta, fullText) => { + updateLastAssistantMessage(fullText, true); + }, + onDone: (fullText) => { + updateLastAssistantMessage(fullText, false); + setAiLoading(false); + }, + onError: (error) => { + updateLastAssistantMessage(`Error: ${error.message}`, false); + setAiLoading(false); + }, + }, + getAbortSignal() + ); + } + + function executeCommand(commandName: string) { + switch (commandName) { + case "clear": + clearMessages(); + break; + case "summarise": + sendMessage(SUMMARISE_PROMPT); + break; + case "model": + setShowCommandMenu(false); + setShowModelSubmenu(true); + setSelectedModelIndex(AI_MODELS.findIndex(m => m.id === aiModel())); + return; // Don't clear input yet + case "export": + exportChatAsMarkdown(); + break; + } + setInput(""); + setShowCommandMenu(false); + } + + function exportChatAsMarkdown() { + const messages = aiMessages(); + if (messages.length === 0) { + addAssistantMessage("Nothing to export. Start a conversation first."); + return; + } + + // Build markdown content + const lines: string[] = [ + "# AI Chat Export", + "", + `*Exported on ${new Date().toLocaleString()}*`, + "", + "---", + "", + ]; + + for (const msg of messages) { + if (msg.role === "user") { + lines.push("## 👤 You", "", msg.content, "", "---", ""); + } else if (msg.role === "assistant") { + lines.push("## 🤖 Assistant", "", msg.content, "", "---", ""); + } + } + + const markdownContent = lines.join("\n"); + + // Create new draft and switch to it + const draftId = createDraft({ url: "", title: "AI Chat Export" }); + updateDraft(draftId, markdownContent); + setCurrentFile(null); + setCurrentDraftId(draftId); + setContent(markdownContent); + + addAssistantMessage("Chat exported to a new tab."); + } + + function selectModel(modelId: AiModel) { + setAiModel(modelId); + const model = AI_MODELS.find(m => m.id === modelId); + addAssistantMessage(`Model switched to **${model?.name || modelId}**`); + setInput(""); + setShowModelSubmenu(false); + } + + async function handleSubmit(e?: Event) { + e?.preventDefault(); + const text = input().trim(); + if (!text || aiLoading()) return; + + // If command menu is showing, execute selected command + if (showCommandMenu() && filteredCommands().length > 0) { + const selected = filteredCommands()[selectedCommandIndex()]; + if (selected) { + executeCommand(selected.name); + return; + } + } + + // If model submenu is showing, select the model + if (showModelSubmenu()) { + const selected = AI_MODELS[selectedModelIndex()]; + if (selected) { + selectModel(selected.id); + return; + } + } + + // Handle direct commands that match exactly + if (text.startsWith("/")) { + const command = text.toLowerCase().slice(1); + const exactMatch = commands.find(cmd => cmd.name === command); + if (exactMatch) { + executeCommand(exactMatch.name); + return; + } + } + + // Regular message + setInput(""); + await sendMessage(text); + } + + function handleKeyDown(e: KeyboardEvent) { + // Handle command menu navigation + if (showCommandMenu()) { + const filtered = filteredCommands(); + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedCommandIndex(i => Math.min(i + 1, filtered.length - 1)); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedCommandIndex(i => Math.max(i - 1, 0)); + return; + } + if (e.key === "Tab") { + e.preventDefault(); + const selected = filtered[selectedCommandIndex()]; + if (selected) { + setInput(`/${selected.name}`); + } + return; + } + if (e.key === "Escape") { + e.preventDefault(); + setShowCommandMenu(false); + setInput(""); + return; + } + } + + // Handle model submenu navigation + if (showModelSubmenu()) { + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedModelIndex(i => Math.min(i + 1, AI_MODELS.length - 1)); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedModelIndex(i => Math.max(i - 1, 0)); + return; + } + if (e.key === "Escape") { + e.preventDefault(); + setShowModelSubmenu(false); + setInput(""); + return; + } + } + + // Enter to send/select, Shift+Enter for newline + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + } + + function handleStop() { + abortCurrentRequest(); + setAiLoading(false); + } + + return ( +
+ {/* Messages */} +
+ +
+
💬
+

Ask me anything about your document!

+

+ Type / for commands +

+
+
+ + + {(message) => } + + +
+
+ + {/* Input with command menu */} +
+ {/* Command menu */} + 0}> +
+ + {(cmd, index) => ( +
executeCommand(cmd.name)} + onMouseEnter={() => setSelectedCommandIndex(index())} + > + /{cmd.name} + {cmd.description} +
+ )} +
+
+
+ + {/* Model submenu */} + +
+
Select Model
+ + {(model, index) => ( +
selectModel(model.id)} + onMouseEnter={() => setSelectedModelIndex(index())} + > + {model.shortName} + {model.name} + + + +
+ )} +
+
+
+ +
+