Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'feat(docs): add AI chat panel with Aperture and OpenRouter support' (#152) from feat/ai-chat-panel into main

scott 3bf68af8 3c4d705a

+1172
+321
src/css/app.css
··· 6656 6656 z-index: 1; 6657 6657 user-select: none; 6658 6658 } 6659 + 6660 + /* ── AI Chat Sidebar ─────────────────────────────────────────────────── */ 6661 + 6662 + .ai-chat-sidebar { 6663 + position: relative; 6664 + width: 360px; 6665 + border-left: 1px solid var(--color-border); 6666 + background: var(--color-surface); 6667 + display: flex; 6668 + flex-direction: column; 6669 + overflow: hidden; 6670 + flex-shrink: 0; 6671 + } 6672 + 6673 + .ai-chat-header { 6674 + display: flex; 6675 + align-items: center; 6676 + justify-content: space-between; 6677 + padding: var(--space-sm) var(--space-md); 6678 + border-bottom: 1px solid var(--color-border); 6679 + gap: var(--space-sm); 6680 + } 6681 + 6682 + .ai-chat-header-left { 6683 + display: flex; 6684 + align-items: center; 6685 + gap: var(--space-sm); 6686 + min-width: 0; 6687 + } 6688 + 6689 + .ai-chat-title { 6690 + font-size: 0.875rem; 6691 + font-weight: 600; 6692 + font-family: var(--font-body); 6693 + color: var(--color-text); 6694 + white-space: nowrap; 6695 + } 6696 + 6697 + .ai-chat-model-badge { 6698 + font-size: 0.7rem; 6699 + color: var(--color-text-muted); 6700 + background: var(--color-hover); 6701 + padding: 1px 6px; 6702 + border-radius: var(--radius-sm); 6703 + white-space: nowrap; 6704 + overflow: hidden; 6705 + text-overflow: ellipsis; 6706 + max-width: 120px; 6707 + } 6708 + 6709 + .ai-chat-header-actions { 6710 + display: flex; 6711 + align-items: center; 6712 + gap: 2px; 6713 + } 6714 + 6715 + .ai-chat-close { 6716 + font-size: 1.25rem; 6717 + color: var(--color-text-muted); 6718 + } 6719 + 6720 + /* Settings panel */ 6721 + 6722 + .ai-chat-settings { 6723 + padding: var(--space-sm) var(--space-md); 6724 + border-bottom: 1px solid var(--color-border); 6725 + background: var(--color-bg); 6726 + display: flex; 6727 + flex-direction: column; 6728 + gap: var(--space-sm); 6729 + } 6730 + 6731 + .ai-chat-settings-field { 6732 + display: flex; 6733 + flex-direction: column; 6734 + gap: 2px; 6735 + } 6736 + 6737 + .ai-chat-settings-field label { 6738 + font-size: 0.75rem; 6739 + font-weight: 500; 6740 + color: var(--color-text-muted); 6741 + } 6742 + 6743 + .ai-chat-hint { 6744 + font-weight: 400; 6745 + opacity: 0.7; 6746 + } 6747 + 6748 + .ai-chat-settings-field input[type="text"], 6749 + .ai-chat-settings-field input[type="password"], 6750 + .ai-chat-settings-field select { 6751 + font-size: 0.8125rem; 6752 + padding: 4px 8px; 6753 + border: 1px solid var(--color-border); 6754 + border-radius: var(--radius-sm); 6755 + background: var(--color-surface); 6756 + color: var(--color-text); 6757 + font-family: ui-monospace, 'SF Mono', monospace; 6758 + } 6759 + 6760 + .ai-chat-settings-field select { 6761 + font-family: var(--font-body); 6762 + } 6763 + 6764 + .ai-chat-model-custom { 6765 + margin-top: 4px; 6766 + } 6767 + 6768 + .ai-chat-context-row { 6769 + flex-direction: row; 6770 + align-items: center; 6771 + } 6772 + 6773 + .ai-chat-toggle-label { 6774 + display: flex; 6775 + align-items: center; 6776 + gap: var(--space-xs); 6777 + font-size: 0.8125rem; 6778 + color: var(--color-text); 6779 + cursor: pointer; 6780 + } 6781 + 6782 + .ai-chat-toggle-label input[type="checkbox"] { 6783 + accent-color: var(--color-accent); 6784 + } 6785 + 6786 + /* Message area */ 6787 + 6788 + .ai-chat-messages { 6789 + flex: 1; 6790 + overflow-y: auto; 6791 + padding: var(--space-sm); 6792 + display: flex; 6793 + flex-direction: column; 6794 + gap: var(--space-sm); 6795 + } 6796 + 6797 + .ai-chat-empty { 6798 + display: flex; 6799 + flex-direction: column; 6800 + align-items: center; 6801 + justify-content: center; 6802 + flex: 1; 6803 + text-align: center; 6804 + gap: var(--space-xs); 6805 + padding: var(--space-xl); 6806 + } 6807 + 6808 + .ai-chat-empty-icon { 6809 + font-size: 2rem; 6810 + opacity: 0.5; 6811 + } 6812 + 6813 + .ai-chat-empty-text { 6814 + font-size: 0.9375rem; 6815 + font-weight: 500; 6816 + color: var(--color-text); 6817 + } 6818 + 6819 + .ai-chat-empty-hint { 6820 + font-size: 0.8125rem; 6821 + color: var(--color-text-muted); 6822 + max-width: 240px; 6823 + } 6824 + 6825 + /* Chat bubbles */ 6826 + 6827 + .ai-chat-bubble { 6828 + padding: var(--space-sm) var(--space-md); 6829 + border-radius: var(--radius-md); 6830 + font-size: 0.875rem; 6831 + line-height: 1.55; 6832 + word-break: break-word; 6833 + max-width: 100%; 6834 + } 6835 + 6836 + .ai-chat-bubble--user { 6837 + background: var(--color-accent); 6838 + color: white; 6839 + align-self: flex-end; 6840 + border-bottom-right-radius: 4px; 6841 + max-width: 85%; 6842 + } 6843 + 6844 + .ai-chat-bubble--assistant { 6845 + background: var(--color-hover); 6846 + color: var(--color-text); 6847 + align-self: flex-start; 6848 + border-bottom-left-radius: 4px; 6849 + } 6850 + 6851 + .ai-chat-bubble--streaming .ai-chat-typing { 6852 + display: inline-block; 6853 + width: 6px; 6854 + height: 14px; 6855 + background: var(--color-text-muted); 6856 + border-radius: 1px; 6857 + animation: ai-blink 0.8s step-end infinite; 6858 + vertical-align: text-bottom; 6859 + } 6860 + 6861 + @keyframes ai-blink { 6862 + 50% { opacity: 0; } 6863 + } 6864 + 6865 + /* Code blocks in AI responses */ 6866 + 6867 + .ai-code-block { 6868 + background: var(--color-bg); 6869 + border: 1px solid var(--color-border); 6870 + border-radius: var(--radius-sm); 6871 + padding: var(--space-sm); 6872 + margin: var(--space-xs) 0; 6873 + overflow-x: auto; 6874 + font-family: ui-monospace, 'SF Mono', monospace; 6875 + font-size: 0.8125rem; 6876 + line-height: 1.4; 6877 + white-space: pre-wrap; 6878 + } 6879 + 6880 + .ai-inline-code { 6881 + background: var(--color-bg); 6882 + border: 1px solid var(--color-border); 6883 + border-radius: 3px; 6884 + padding: 1px 4px; 6885 + font-family: ui-monospace, 'SF Mono', monospace; 6886 + font-size: 0.85em; 6887 + } 6888 + 6889 + /* Input area */ 6890 + 6891 + .ai-chat-input-area { 6892 + padding: var(--space-sm) var(--space-md); 6893 + border-top: 1px solid var(--color-border); 6894 + background: var(--color-surface); 6895 + } 6896 + 6897 + .ai-chat-input-row { 6898 + display: flex; 6899 + align-items: flex-end; 6900 + gap: var(--space-xs); 6901 + } 6902 + 6903 + .ai-chat-input { 6904 + flex: 1; 6905 + resize: none; 6906 + border: 1px solid var(--color-border); 6907 + border-radius: var(--radius-md); 6908 + padding: 8px 12px; 6909 + font-size: 0.875rem; 6910 + font-family: var(--font-body); 6911 + line-height: 1.4; 6912 + background: var(--color-bg); 6913 + color: var(--color-text); 6914 + min-height: 36px; 6915 + max-height: 120px; 6916 + transition: border-color var(--transition-fast); 6917 + } 6918 + 6919 + .ai-chat-input:focus { 6920 + outline: none; 6921 + border-color: var(--color-accent); 6922 + } 6923 + 6924 + .ai-chat-input::placeholder { 6925 + color: var(--color-text-muted); 6926 + } 6927 + 6928 + .ai-chat-send, 6929 + .ai-chat-stop { 6930 + flex-shrink: 0; 6931 + width: 36px; 6932 + height: 36px; 6933 + border: none; 6934 + border-radius: var(--radius-md); 6935 + cursor: pointer; 6936 + display: flex; 6937 + align-items: center; 6938 + justify-content: center; 6939 + transition: background var(--transition-fast), opacity var(--transition-fast); 6940 + } 6941 + 6942 + .ai-chat-send { 6943 + background: var(--color-accent); 6944 + color: white; 6945 + } 6946 + 6947 + .ai-chat-send:hover { 6948 + opacity: 0.85; 6949 + } 6950 + 6951 + .ai-chat-send:disabled { 6952 + opacity: 0.4; 6953 + cursor: default; 6954 + } 6955 + 6956 + .ai-chat-stop { 6957 + background: var(--color-error, oklch(0.65 0.2 25)); 6958 + color: white; 6959 + font-size: 1rem; 6960 + } 6961 + 6962 + .ai-chat-stop:hover { 6963 + opacity: 0.85; 6964 + } 6965 + 6966 + .zen-mode .ai-chat-sidebar { 6967 + display: none !important; 6968 + } 6969 + 6970 + @media (max-width: 768px) { 6971 + .ai-chat-sidebar { 6972 + position: fixed; 6973 + top: 0; 6974 + right: 0; 6975 + bottom: 0; 6976 + width: 100%; 6977 + z-index: 200; 6978 + } 6979 + }
+449
src/docs/ai-chat.ts
··· 1 + /** 2 + * AI Chat Panel — conversational AI sidebar for docs. 3 + * 4 + * Connects to Aperture gateway or OpenRouter (OpenAI-compatible /chat/completions). 5 + * Runs client-side only: the Tools server never sees document content (E2EE preserved). 6 + * Supports streaming responses via SSE. 7 + */ 8 + 9 + // ── Types ────────────────────────────────────────────────────────────── 10 + 11 + export interface ChatMessage { 12 + role: 'user' | 'assistant' | 'system'; 13 + content: string; 14 + /** Timestamp ms */ 15 + ts: number; 16 + } 17 + 18 + export interface ChatConfig { 19 + endpoint: string; 20 + apiKey: string; 21 + model: string; 22 + maxTokens: number; 23 + } 24 + 25 + export interface ChatState { 26 + messages: ChatMessage[]; 27 + loading: boolean; 28 + error: string | null; 29 + abortController: AbortController | null; 30 + } 31 + 32 + // ── Config helpers ───────────────────────────────────────────────────── 33 + 34 + const LS_ENDPOINT = 'tools-ai-endpoint'; 35 + const LS_API_KEY = 'tools-ai-apikey'; 36 + const LS_MODEL = 'tools-ai-model'; 37 + 38 + const DEFAULT_MODEL = 'claude-sonnet-4-20250514'; 39 + const DEFAULT_MAX_TOKENS = 4096; 40 + 41 + export function loadConfig(): ChatConfig { 42 + return { 43 + endpoint: localStorage.getItem(LS_ENDPOINT) || '', 44 + apiKey: localStorage.getItem(LS_API_KEY) || '', 45 + model: localStorage.getItem(LS_MODEL) || DEFAULT_MODEL, 46 + maxTokens: DEFAULT_MAX_TOKENS, 47 + }; 48 + } 49 + 50 + export function saveConfig(cfg: Partial<ChatConfig>): void { 51 + if (cfg.endpoint !== undefined) localStorage.setItem(LS_ENDPOINT, cfg.endpoint); 52 + if (cfg.apiKey !== undefined) localStorage.setItem(LS_API_KEY, cfg.apiKey); 53 + if (cfg.model !== undefined) localStorage.setItem(LS_MODEL, cfg.model); 54 + } 55 + 56 + export function isConfigured(cfg: ChatConfig): boolean { 57 + return cfg.endpoint.length > 0; 58 + } 59 + 60 + // ── Popular models for dropdown ──────────────────────────────────────── 61 + 62 + export interface ModelOption { 63 + id: string; 64 + label: string; 65 + provider: string; 66 + } 67 + 68 + export const MODEL_OPTIONS: ModelOption[] = [ 69 + { id: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4', provider: 'Anthropic' }, 70 + { id: 'claude-haiku-4-20250414', label: 'Claude Haiku 4', provider: 'Anthropic' }, 71 + { id: 'gpt-4o', label: 'GPT-4o', provider: 'OpenAI' }, 72 + { id: 'gpt-4o-mini', label: 'GPT-4o Mini', provider: 'OpenAI' }, 73 + { id: 'google/gemini-2.5-flash-preview', label: 'Gemini 2.5 Flash', provider: 'Google' }, 74 + { id: 'anthropic/claude-sonnet-4', label: 'Claude Sonnet 4 (OR)', provider: 'OpenRouter' }, 75 + { id: 'meta-llama/llama-4-maverick', label: 'Llama 4 Maverick', provider: 'OpenRouter' }, 76 + ]; 77 + 78 + // ── State ────────────────────────────────────────────────────────────── 79 + 80 + export function createChatState(): ChatState { 81 + return { 82 + messages: [], 83 + loading: false, 84 + error: null, 85 + abortController: null, 86 + }; 87 + } 88 + 89 + // ── System prompt ────────────────────────────────────────────────────── 90 + 91 + export function buildSystemMessage(docTitle: string, docContext: string): string { 92 + const parts = [ 93 + 'You are a helpful writing assistant embedded in a document editor.', 94 + 'Be concise and direct. Use markdown formatting where helpful.', 95 + ]; 96 + if (docTitle) parts.push(`The document is titled "${docTitle}".`); 97 + if (docContext) { 98 + const trimmed = docContext.length > 8000 99 + ? docContext.slice(0, 8000) + '\n\n[...truncated]' 100 + : docContext; 101 + parts.push(`Here is the current document content:\n\n---\n${trimmed}\n---`); 102 + } 103 + return parts.join('\n'); 104 + } 105 + 106 + // ── API call (streaming) ─────────────────────────────────────────────── 107 + 108 + /** 109 + * Send chat completion request with SSE streaming. 110 + * Calls `onChunk` for each content delta and `onDone` when complete. 111 + */ 112 + export async function streamChat( 113 + config: ChatConfig, 114 + messages: ChatMessage[], 115 + systemPrompt: string, 116 + callbacks: { 117 + onChunk: (text: string) => void; 118 + onDone: (fullText: string) => void; 119 + onError: (error: string) => void; 120 + }, 121 + abortSignal?: AbortSignal, 122 + ): Promise<void> { 123 + const url = `${config.endpoint.replace(/\/$/, '')}/chat/completions`; 124 + 125 + const apiMessages: Array<{ role: string; content: string }> = [ 126 + { role: 'system', content: systemPrompt }, 127 + ...messages.map((m) => ({ role: m.role, content: m.content })), 128 + ]; 129 + 130 + const headers: Record<string, string> = { 131 + 'Content-Type': 'application/json', 132 + }; 133 + if (config.apiKey) { 134 + headers['Authorization'] = `Bearer ${config.apiKey}`; 135 + } 136 + 137 + const body = JSON.stringify({ 138 + model: config.model, 139 + messages: apiMessages, 140 + max_tokens: config.maxTokens, 141 + stream: true, 142 + }); 143 + 144 + let response: Response; 145 + try { 146 + response = await fetch(url, { 147 + method: 'POST', 148 + headers, 149 + body, 150 + signal: abortSignal, 151 + }); 152 + } catch (err: unknown) { 153 + if ((err as Error).name === 'AbortError') return; 154 + callbacks.onError(`Failed to connect to AI endpoint: ${(err as Error).message}`); 155 + return; 156 + } 157 + 158 + if (!response.ok) { 159 + let detail = response.statusText; 160 + try { 161 + const errBody = await response.json(); 162 + detail = errBody.error?.message || errBody.error || detail; 163 + } catch { /* ignore */ } 164 + callbacks.onError(`AI request failed (${response.status}): ${detail}`); 165 + return; 166 + } 167 + 168 + // Parse SSE stream 169 + const reader = response.body?.getReader(); 170 + if (!reader) { 171 + callbacks.onError('No response body'); 172 + return; 173 + } 174 + 175 + const decoder = new TextDecoder(); 176 + let fullText = ''; 177 + let buffer = ''; 178 + 179 + try { 180 + while (true) { 181 + const { done, value } = await reader.read(); 182 + if (done) break; 183 + 184 + buffer += decoder.decode(value, { stream: true }); 185 + const lines = buffer.split('\n'); 186 + buffer = lines.pop() || ''; 187 + 188 + for (const line of lines) { 189 + if (!line.startsWith('data: ')) continue; 190 + const data = line.slice(6).trim(); 191 + if (data === '[DONE]') continue; 192 + 193 + try { 194 + const parsed = JSON.parse(data); 195 + const delta = parsed.choices?.[0]?.delta?.content; 196 + if (delta) { 197 + fullText += delta; 198 + callbacks.onChunk(delta); 199 + } 200 + } catch { /* skip malformed chunks */ } 201 + } 202 + } 203 + } catch (err: unknown) { 204 + if ((err as Error).name === 'AbortError') return; 205 + callbacks.onError(`Stream interrupted: ${(err as Error).message}`); 206 + return; 207 + } 208 + 209 + // If no streaming data received, try to parse as non-streaming response 210 + if (!fullText && buffer) { 211 + try { 212 + const parsed = JSON.parse(buffer); 213 + fullText = parsed.choices?.[0]?.message?.content || ''; 214 + if (fullText) callbacks.onChunk(fullText); 215 + } catch { /* ignore */ } 216 + } 217 + 218 + callbacks.onDone(fullText); 219 + } 220 + 221 + /** 222 + * Non-streaming fallback for endpoints that don't support SSE. 223 + */ 224 + export async function sendChat( 225 + config: ChatConfig, 226 + messages: ChatMessage[], 227 + systemPrompt: string, 228 + abortSignal?: AbortSignal, 229 + ): Promise<string> { 230 + const url = `${config.endpoint.replace(/\/$/, '')}/chat/completions`; 231 + 232 + const apiMessages: Array<{ role: string; content: string }> = [ 233 + { role: 'system', content: systemPrompt }, 234 + ...messages.map((m) => ({ role: m.role, content: m.content })), 235 + ]; 236 + 237 + const headers: Record<string, string> = { 238 + 'Content-Type': 'application/json', 239 + }; 240 + if (config.apiKey) { 241 + headers['Authorization'] = `Bearer ${config.apiKey}`; 242 + } 243 + 244 + const res = await fetch(url, { 245 + method: 'POST', 246 + headers, 247 + body: JSON.stringify({ 248 + model: config.model, 249 + messages: apiMessages, 250 + max_tokens: config.maxTokens, 251 + }), 252 + signal: abortSignal, 253 + }); 254 + 255 + if (!res.ok) { 256 + let detail = res.statusText; 257 + try { 258 + const errBody = await res.json(); 259 + detail = errBody.error?.message || errBody.error || detail; 260 + } catch { /* ignore */ } 261 + throw new Error(`AI request failed (${res.status}): ${detail}`); 262 + } 263 + 264 + const data = await res.json(); 265 + return data.choices?.[0]?.message?.content || ''; 266 + } 267 + 268 + // ── DOM rendering ────────────────────────────────────────────────────── 269 + 270 + /** Escape HTML entities for safe rendering */ 271 + function escapeHtml(str: string): string { 272 + return str 273 + .replace(/&/g, '&amp;') 274 + .replace(/</g, '&lt;') 275 + .replace(/>/g, '&gt;') 276 + .replace(/"/g, '&quot;'); 277 + } 278 + 279 + /** Simple markdown-ish rendering: code blocks, inline code, bold, italic, links */ 280 + export function renderMarkdown(text: string): string { 281 + // Code blocks 282 + let html = escapeHtml(text); 283 + html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, lang, code) => { 284 + return `<pre class="ai-code-block" data-lang="${lang}"><code>${code.trim()}</code></pre>`; 285 + }); 286 + // Inline code 287 + html = html.replace(/`([^`]+)`/g, '<code class="ai-inline-code">$1</code>'); 288 + // Bold 289 + html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); 290 + // Italic 291 + html = html.replace(/\*(.+?)\*/g, '<em>$1</em>'); 292 + // Line breaks 293 + html = html.replace(/\n/g, '<br>'); 294 + return html; 295 + } 296 + 297 + /** 298 + * Create the AI chat sidebar DOM structure. 299 + * Returns refs to key elements for event wiring. 300 + */ 301 + export function createChatSidebar(): { 302 + container: HTMLElement; 303 + messageList: HTMLElement; 304 + input: HTMLTextAreaElement; 305 + sendBtn: HTMLButtonElement; 306 + stopBtn: HTMLButtonElement; 307 + clearBtn: HTMLButtonElement; 308 + settingsBtn: HTMLButtonElement; 309 + closeBtn: HTMLButtonElement; 310 + settingsPanel: HTMLElement; 311 + endpointInput: HTMLInputElement; 312 + apiKeyInput: HTMLInputElement; 313 + modelSelect: HTMLSelectElement; 314 + contextToggle: HTMLInputElement; 315 + } { 316 + const container = document.createElement('div'); 317 + container.className = 'ai-chat-sidebar'; 318 + container.id = 'ai-chat-sidebar'; 319 + container.style.display = 'none'; 320 + 321 + container.innerHTML = ` 322 + <div class="ai-chat-header"> 323 + <div class="ai-chat-header-left"> 324 + <span class="ai-chat-title">AI Chat</span> 325 + <span class="ai-chat-model-badge" id="ai-model-badge"></span> 326 + </div> 327 + <div class="ai-chat-header-actions"> 328 + <button class="btn-icon ai-chat-settings-btn" id="ai-chat-settings-btn" title="Settings">&#9881;</button> 329 + <button class="btn-icon ai-chat-clear-btn" id="ai-chat-clear-btn" title="Clear chat">&#128465;</button> 330 + <button class="btn-icon ai-chat-close" id="ai-chat-close" title="Close">&times;</button> 331 + </div> 332 + </div> 333 + 334 + <div class="ai-chat-settings" id="ai-chat-settings" style="display:none"> 335 + <div class="ai-chat-settings-field"> 336 + <label for="ai-endpoint">Endpoint</label> 337 + <input type="text" id="ai-endpoint" placeholder="http://ai or https://openrouter.ai/api/v1" spellcheck="false" autocomplete="off"> 338 + </div> 339 + <div class="ai-chat-settings-field"> 340 + <label for="ai-apikey">API Key <span class="ai-chat-hint">(optional for Aperture)</span></label> 341 + <input type="password" id="ai-apikey" placeholder="sk-..." spellcheck="false" autocomplete="off"> 342 + </div> 343 + <div class="ai-chat-settings-field"> 344 + <label for="ai-model">Model</label> 345 + <select id="ai-model"> 346 + ${MODEL_OPTIONS.map((m) => `<option value="${escapeHtml(m.id)}">${escapeHtml(m.label)} — ${escapeHtml(m.provider)}</option>`).join('\n ')} 347 + <option value="__custom">Custom model ID...</option> 348 + </select> 349 + <input type="text" id="ai-model-custom" class="ai-chat-model-custom" placeholder="model-name" style="display:none" spellcheck="false"> 350 + </div> 351 + <div class="ai-chat-settings-field ai-chat-context-row"> 352 + <label class="ai-chat-toggle-label"> 353 + <input type="checkbox" id="ai-context-toggle" checked> 354 + Include document context 355 + </label> 356 + </div> 357 + </div> 358 + 359 + <div class="ai-chat-messages" id="ai-chat-messages"> 360 + <div class="ai-chat-empty" id="ai-chat-empty"> 361 + <div class="ai-chat-empty-icon">&#10024;</div> 362 + <div class="ai-chat-empty-text">Ask anything about your document</div> 363 + <div class="ai-chat-empty-hint">The AI can see your document content when context is enabled</div> 364 + </div> 365 + </div> 366 + 367 + <div class="ai-chat-input-area"> 368 + <div class="ai-chat-input-row"> 369 + <textarea 370 + class="ai-chat-input" 371 + id="ai-chat-input" 372 + placeholder="Ask about your document..." 373 + rows="1" 374 + spellcheck="true" 375 + ></textarea> 376 + <button class="ai-chat-send" id="ai-chat-send" title="Send (Enter)"> 377 + <svg viewBox="0 0 16 16" width="16" height="16"><path d="M1 8l6-6v4h8v4H7v4z" transform="rotate(-90 8 8)" fill="currentColor"/></svg> 378 + </button> 379 + <button class="ai-chat-stop" id="ai-chat-stop" title="Stop generating" style="display:none">&#9632;</button> 380 + </div> 381 + </div> 382 + `; 383 + 384 + return { 385 + container, 386 + messageList: container.querySelector('#ai-chat-messages')!, 387 + input: container.querySelector('#ai-chat-input') as HTMLTextAreaElement, 388 + sendBtn: container.querySelector('#ai-chat-send') as HTMLButtonElement, 389 + stopBtn: container.querySelector('#ai-chat-stop') as HTMLButtonElement, 390 + clearBtn: container.querySelector('#ai-chat-clear-btn') as HTMLButtonElement, 391 + settingsBtn: container.querySelector('#ai-chat-settings-btn') as HTMLButtonElement, 392 + closeBtn: container.querySelector('#ai-chat-close') as HTMLButtonElement, 393 + settingsPanel: container.querySelector('#ai-chat-settings') as HTMLElement, 394 + endpointInput: container.querySelector('#ai-endpoint') as HTMLInputElement, 395 + apiKeyInput: container.querySelector('#ai-apikey') as HTMLInputElement, 396 + modelSelect: container.querySelector('#ai-model') as HTMLSelectElement, 397 + contextToggle: container.querySelector('#ai-context-toggle') as HTMLInputElement, 398 + }; 399 + } 400 + 401 + /** 402 + * Render a single message bubble and append it to the message list. 403 + */ 404 + export function appendMessage(list: HTMLElement, msg: ChatMessage): HTMLElement { 405 + // Hide empty state 406 + const empty = list.querySelector('.ai-chat-empty'); 407 + if (empty) (empty as HTMLElement).style.display = 'none'; 408 + 409 + const bubble = document.createElement('div'); 410 + bubble.className = `ai-chat-bubble ai-chat-bubble--${msg.role}`; 411 + bubble.innerHTML = msg.role === 'assistant' ? renderMarkdown(msg.content) : escapeHtml(msg.content).replace(/\n/g, '<br>'); 412 + list.appendChild(bubble); 413 + list.scrollTop = list.scrollHeight; 414 + return bubble; 415 + } 416 + 417 + /** 418 + * Create a streaming assistant bubble (content updates in-place). 419 + */ 420 + export function appendStreamingBubble(list: HTMLElement): { 421 + el: HTMLElement; 422 + update: (html: string) => void; 423 + } { 424 + const empty = list.querySelector('.ai-chat-empty'); 425 + if (empty) (empty as HTMLElement).style.display = 'none'; 426 + 427 + const bubble = document.createElement('div'); 428 + bubble.className = 'ai-chat-bubble ai-chat-bubble--assistant ai-chat-bubble--streaming'; 429 + bubble.innerHTML = '<span class="ai-chat-typing"></span>'; 430 + list.appendChild(bubble); 431 + list.scrollTop = list.scrollHeight; 432 + 433 + return { 434 + el: bubble, 435 + update(html: string) { 436 + bubble.innerHTML = html; 437 + bubble.classList.remove('ai-chat-bubble--streaming'); 438 + list.scrollTop = list.scrollHeight; 439 + }, 440 + }; 441 + } 442 + 443 + /** 444 + * Auto-resize textarea to fit content (up to a max height). 445 + */ 446 + export function autoResizeTextarea(textarea: HTMLTextAreaElement): void { 447 + textarea.style.height = 'auto'; 448 + textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px'; 449 + }
+2
src/docs/index.html
··· 46 46 </button> 47 47 <!-- Version history --> 48 48 <button class="btn-icon" id="btn-history" title="Version history">&#128339;</button> 49 + <!-- AI Chat --> 50 + <button class="btn-icon" id="btn-ai-chat" title="AI Chat (Cmd+Shift+L)">&#10024;</button> 49 51 <!-- Share button --> 50 52 <button class="btn-icon" id="btn-share" title="Share document">&#128279;</button> 51 53 <div class="save-indicator saved" id="save-indicator">
+194
src/docs/main.ts
··· 58 58 import { extractHeadings, OutlineState } from './outline.js'; 59 59 import { TableToolbarState } from './table-toolbar.js'; 60 60 import { LinkPreviewState, truncateUrl, computeTooltipPosition } from './link-preview.js'; 61 + import { 62 + createChatSidebar, createChatState, loadConfig, saveConfig, isConfigured, 63 + buildSystemMessage, streamChat, appendMessage, appendStreamingBubble, 64 + autoResizeTextarea, renderMarkdown, MODEL_OPTIONS, 65 + type ChatMessage, type ChatConfig, 66 + } from './ai-chat.js'; 61 67 import { ZenModeState, ZEN_STORAGE_KEY, ZEN_CLASS, ZEN_TRANSITION_MS } from './zen-mode.js'; 62 68 import { SLASH_COMMAND_ITEMS, SlashMenuState, filterCommands, PLACEHOLDER_EMPTY, PLACEHOLDER_BLOCK } from './slash-menu.js'; 63 69 import { createSlashCommands, getCommandExecutor } from './extensions/slash-commands.js'; ··· 2314 2320 } catch { return []; } 2315 2321 }, 2316 2322 }); 2323 + 2324 + // ── AI Chat Panel ──────────────────────────────────────────────────────── 2325 + 2326 + const chatUI = createChatSidebar(); 2327 + const mainContent = $('main-content'); 2328 + mainContent.appendChild(chatUI.container); 2329 + 2330 + const chatState = createChatState(); 2331 + let chatConfig = loadConfig(); 2332 + 2333 + // Populate settings from saved config 2334 + chatUI.endpointInput.value = chatConfig.endpoint; 2335 + chatUI.apiKeyInput.value = chatConfig.apiKey; 2336 + 2337 + // Set model dropdown to match config (or custom) 2338 + const knownModel = MODEL_OPTIONS.find((m) => m.id === chatConfig.model); 2339 + if (knownModel) { 2340 + chatUI.modelSelect.value = chatConfig.model; 2341 + } else if (chatConfig.model) { 2342 + chatUI.modelSelect.value = '__custom'; 2343 + const customInput = chatUI.container.querySelector('#ai-model-custom') as HTMLInputElement; 2344 + customInput.style.display = ''; 2345 + customInput.value = chatConfig.model; 2346 + } 2347 + 2348 + function updateModelBadge(): void { 2349 + const badge = chatUI.container.querySelector('#ai-model-badge') as HTMLElement; 2350 + const opt = MODEL_OPTIONS.find((m) => m.id === chatConfig.model); 2351 + badge.textContent = opt ? opt.label : chatConfig.model.split('/').pop() || ''; 2352 + } 2353 + updateModelBadge(); 2354 + 2355 + // Show settings on first open if not configured 2356 + let settingsShownOnce = false; 2357 + 2358 + function toggleChatPanel(): void { 2359 + const isOpen = chatUI.container.style.display !== 'none'; 2360 + if (isOpen) { 2361 + chatUI.container.style.display = 'none'; 2362 + $('btn-ai-chat').classList.remove('active'); 2363 + } else { 2364 + chatUI.container.style.display = ''; 2365 + $('btn-ai-chat').classList.add('active'); 2366 + if (!isConfigured(chatConfig) && !settingsShownOnce) { 2367 + chatUI.settingsPanel.style.display = ''; 2368 + settingsShownOnce = true; 2369 + } 2370 + chatUI.input.focus(); 2371 + } 2372 + } 2373 + 2374 + $('btn-ai-chat').addEventListener('click', toggleChatPanel); 2375 + chatUI.closeBtn.addEventListener('click', toggleChatPanel); 2376 + 2377 + // Keyboard shortcut: Cmd+Shift+L 2378 + document.addEventListener('keydown', (e) => { 2379 + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'l') { 2380 + e.preventDefault(); 2381 + toggleChatPanel(); 2382 + } 2383 + }); 2384 + 2385 + // Settings toggle 2386 + chatUI.settingsBtn.addEventListener('click', () => { 2387 + const panel = chatUI.settingsPanel; 2388 + panel.style.display = panel.style.display === 'none' ? '' : 'none'; 2389 + }); 2390 + 2391 + // Save settings on change 2392 + function persistSettings(): void { 2393 + const model = chatUI.modelSelect.value === '__custom' 2394 + ? (chatUI.container.querySelector('#ai-model-custom') as HTMLInputElement).value.trim() 2395 + : chatUI.modelSelect.value; 2396 + 2397 + chatConfig = { 2398 + endpoint: chatUI.endpointInput.value.trim(), 2399 + apiKey: chatUI.apiKeyInput.value.trim(), 2400 + model: model || 'claude-sonnet-4-20250514', 2401 + maxTokens: chatConfig.maxTokens, 2402 + }; 2403 + saveConfig(chatConfig); 2404 + updateModelBadge(); 2405 + } 2406 + 2407 + chatUI.endpointInput.addEventListener('change', persistSettings); 2408 + chatUI.apiKeyInput.addEventListener('change', persistSettings); 2409 + chatUI.modelSelect.addEventListener('change', () => { 2410 + const customInput = chatUI.container.querySelector('#ai-model-custom') as HTMLInputElement; 2411 + customInput.style.display = chatUI.modelSelect.value === '__custom' ? '' : 'none'; 2412 + persistSettings(); 2413 + }); 2414 + (chatUI.container.querySelector('#ai-model-custom') as HTMLInputElement).addEventListener('change', persistSettings); 2415 + 2416 + // Auto-resize input 2417 + chatUI.input.addEventListener('input', () => autoResizeTextarea(chatUI.input)); 2418 + 2419 + // Send message 2420 + async function sendMessage(): Promise<void> { 2421 + const text = chatUI.input.value.trim(); 2422 + if (!text || chatState.loading) return; 2423 + 2424 + if (!isConfigured(chatConfig)) { 2425 + chatUI.settingsPanel.style.display = ''; 2426 + chatUI.endpointInput.focus(); 2427 + return; 2428 + } 2429 + 2430 + // Add user message 2431 + const userMsg: ChatMessage = { role: 'user', content: text, ts: Date.now() }; 2432 + chatState.messages.push(userMsg); 2433 + appendMessage(chatUI.messageList, userMsg); 2434 + 2435 + chatUI.input.value = ''; 2436 + chatUI.input.style.height = ''; 2437 + chatUI.sendBtn.style.display = 'none'; 2438 + chatUI.stopBtn.style.display = ''; 2439 + chatState.loading = true; 2440 + chatState.error = null; 2441 + 2442 + // Build context 2443 + const docTitle = (titleInput as HTMLInputElement).value.trim() || 'Untitled'; 2444 + const includeContext = chatUI.contextToggle.checked; 2445 + const docText = includeContext ? editor.getText() : ''; 2446 + const systemPrompt = buildSystemMessage(docTitle, docText); 2447 + 2448 + // Streaming response 2449 + const abortController = new AbortController(); 2450 + chatState.abortController = abortController; 2451 + const bubble = appendStreamingBubble(chatUI.messageList); 2452 + let fullText = ''; 2453 + 2454 + await streamChat( 2455 + chatConfig, 2456 + chatState.messages, 2457 + systemPrompt, 2458 + { 2459 + onChunk(chunk) { 2460 + fullText += chunk; 2461 + bubble.update(renderMarkdown(fullText)); 2462 + }, 2463 + onDone(text) { 2464 + if (text) { 2465 + chatState.messages.push({ role: 'assistant', content: text, ts: Date.now() }); 2466 + } 2467 + }, 2468 + onError(err) { 2469 + chatState.error = err; 2470 + bubble.el.classList.add('ai-chat-bubble--error'); 2471 + bubble.update(`<span class="ai-chat-error">${err}</span>`); 2472 + }, 2473 + }, 2474 + abortController.signal, 2475 + ); 2476 + 2477 + chatState.loading = false; 2478 + chatState.abortController = null; 2479 + chatUI.sendBtn.style.display = ''; 2480 + chatUI.stopBtn.style.display = 'none'; 2481 + } 2482 + 2483 + chatUI.sendBtn.addEventListener('click', sendMessage); 2484 + chatUI.input.addEventListener('keydown', (e) => { 2485 + if (e.key === 'Enter' && !e.shiftKey) { 2486 + e.preventDefault(); 2487 + sendMessage(); 2488 + } 2489 + }); 2490 + 2491 + // Stop generating 2492 + chatUI.stopBtn.addEventListener('click', () => { 2493 + chatState.abortController?.abort(); 2494 + chatState.loading = false; 2495 + chatUI.sendBtn.style.display = ''; 2496 + chatUI.stopBtn.style.display = 'none'; 2497 + }); 2498 + 2499 + // Clear chat 2500 + chatUI.clearBtn.addEventListener('click', () => { 2501 + chatState.messages = []; 2502 + chatState.error = null; 2503 + chatUI.messageList.innerHTML = ` 2504 + <div class="ai-chat-empty" id="ai-chat-empty"> 2505 + <div class="ai-chat-empty-icon">&#10024;</div> 2506 + <div class="ai-chat-empty-text">Ask anything about your document</div> 2507 + <div class="ai-chat-empty-hint">The AI can see your document content when context is enabled</div> 2508 + </div> 2509 + `; 2510 + });
+206
tests/ai-chat.test.ts
··· 1 + // @vitest-environment jsdom 2 + import { describe, it, expect, vi, beforeEach } from 'vitest'; 3 + import { 4 + createChatState, 5 + loadConfig, 6 + saveConfig, 7 + isConfigured, 8 + buildSystemMessage, 9 + renderMarkdown, 10 + autoResizeTextarea, 11 + MODEL_OPTIONS, 12 + type ChatConfig, 13 + } from '../src/docs/ai-chat.js'; 14 + 15 + // Mock localStorage for jsdom (which doesn't provide a full impl) 16 + const store: Record<string, string> = {}; 17 + const mockLS = { 18 + getItem: (key: string) => store[key] ?? null, 19 + setItem: (key: string, val: string) => { store[key] = val; }, 20 + removeItem: (key: string) => { delete store[key]; }, 21 + clear: () => { for (const k of Object.keys(store)) delete store[k]; }, 22 + get length() { return Object.keys(store).length; }, 23 + key: (i: number) => Object.keys(store)[i] ?? null, 24 + }; 25 + Object.defineProperty(globalThis, 'localStorage', { value: mockLS, writable: true }); 26 + 27 + // ── Config ───────────────────────────────────────────────────────────── 28 + 29 + describe('AI Chat — Config', () => { 30 + beforeEach(() => { 31 + mockLS.clear(); 32 + }); 33 + 34 + it('loadConfig returns defaults when localStorage is empty', () => { 35 + const cfg = loadConfig(); 36 + expect(cfg.endpoint).toBe(''); 37 + expect(cfg.apiKey).toBe(''); 38 + expect(cfg.model).toBe('claude-sonnet-4-20250514'); 39 + expect(cfg.maxTokens).toBe(4096); 40 + }); 41 + 42 + it('saveConfig persists to localStorage', () => { 43 + saveConfig({ endpoint: 'http://ai', apiKey: 'sk-test', model: 'gpt-4o' }); 44 + expect(localStorage.getItem('tools-ai-endpoint')).toBe('http://ai'); 45 + expect(localStorage.getItem('tools-ai-apikey')).toBe('sk-test'); 46 + expect(localStorage.getItem('tools-ai-model')).toBe('gpt-4o'); 47 + }); 48 + 49 + it('loadConfig reads saved values', () => { 50 + saveConfig({ endpoint: 'http://ai', apiKey: 'key123', model: 'custom-model' }); 51 + const cfg = loadConfig(); 52 + expect(cfg.endpoint).toBe('http://ai'); 53 + expect(cfg.apiKey).toBe('key123'); 54 + expect(cfg.model).toBe('custom-model'); 55 + }); 56 + 57 + it('saveConfig only updates provided fields', () => { 58 + saveConfig({ endpoint: 'http://ai', model: 'gpt-4o' }); 59 + saveConfig({ apiKey: 'new-key' }); 60 + const cfg = loadConfig(); 61 + expect(cfg.endpoint).toBe('http://ai'); 62 + expect(cfg.apiKey).toBe('new-key'); 63 + expect(cfg.model).toBe('gpt-4o'); 64 + }); 65 + 66 + it('isConfigured returns false with empty endpoint', () => { 67 + expect(isConfigured({ endpoint: '', apiKey: '', model: 'm', maxTokens: 1024 })).toBe(false); 68 + }); 69 + 70 + it('isConfigured returns true with endpoint set', () => { 71 + expect(isConfigured({ endpoint: 'http://ai', apiKey: '', model: 'm', maxTokens: 1024 })).toBe(true); 72 + }); 73 + }); 74 + 75 + // ── State ────────────────────────────────────────────────────────────── 76 + 77 + describe('AI Chat — State', () => { 78 + it('createChatState returns clean initial state', () => { 79 + const state = createChatState(); 80 + expect(state.messages).toEqual([]); 81 + expect(state.loading).toBe(false); 82 + expect(state.error).toBeNull(); 83 + expect(state.abortController).toBeNull(); 84 + }); 85 + }); 86 + 87 + // ── System prompt ────────────────────────────────────────────────────── 88 + 89 + describe('AI Chat — buildSystemMessage', () => { 90 + it('includes base instructions with empty title and context', () => { 91 + const msg = buildSystemMessage('', ''); 92 + expect(msg).toContain('writing assistant'); 93 + expect(msg).not.toContain('titled'); 94 + expect(msg).not.toContain('document content'); 95 + }); 96 + 97 + it('includes document title when provided', () => { 98 + const msg = buildSystemMessage('My Report', ''); 99 + expect(msg).toContain('My Report'); 100 + expect(msg).toContain('titled'); 101 + }); 102 + 103 + it('includes document context when provided', () => { 104 + const msg = buildSystemMessage('Title', 'Hello world document text'); 105 + expect(msg).toContain('Hello world document text'); 106 + expect(msg).toContain('document content'); 107 + }); 108 + 109 + it('truncates very long context', () => { 110 + const long = 'x'.repeat(10000); 111 + const msg = buildSystemMessage('Title', long); 112 + expect(msg).toContain('[...truncated]'); 113 + expect(msg.length).toBeLessThan(long.length + 500); 114 + }); 115 + 116 + it('does not truncate short context', () => { 117 + const short = 'Some short text'; 118 + const msg = buildSystemMessage('', short); 119 + expect(msg).toContain(short); 120 + expect(msg).not.toContain('[...truncated]'); 121 + }); 122 + }); 123 + 124 + // ── Markdown rendering ───────────────────────────────────────────────── 125 + 126 + describe('AI Chat — renderMarkdown', () => { 127 + it('escapes HTML entities', () => { 128 + expect(renderMarkdown('<script>alert("xss")</script>')).not.toContain('<script>'); 129 + expect(renderMarkdown('<script>')).toContain('&lt;script&gt;'); 130 + }); 131 + 132 + it('renders bold text', () => { 133 + const html = renderMarkdown('This is **bold** text'); 134 + expect(html).toContain('<strong>bold</strong>'); 135 + }); 136 + 137 + it('renders italic text', () => { 138 + const html = renderMarkdown('This is *italic* text'); 139 + expect(html).toContain('<em>italic</em>'); 140 + }); 141 + 142 + it('renders inline code', () => { 143 + const html = renderMarkdown('Use `console.log`'); 144 + expect(html).toContain('<code class="ai-inline-code">console.log</code>'); 145 + }); 146 + 147 + it('renders code blocks', () => { 148 + const html = renderMarkdown('```js\nconst x = 1;\n```'); 149 + expect(html).toContain('<pre class="ai-code-block"'); 150 + expect(html).toContain('const x = 1;'); 151 + }); 152 + 153 + it('converts newlines to <br>', () => { 154 + const html = renderMarkdown('line 1\nline 2'); 155 + expect(html).toContain('line 1<br>line 2'); 156 + }); 157 + 158 + it('handles empty string', () => { 159 + expect(renderMarkdown('')).toBe(''); 160 + }); 161 + 162 + it('handles text with no markdown', () => { 163 + const html = renderMarkdown('plain text'); 164 + expect(html).toBe('plain text'); 165 + }); 166 + }); 167 + 168 + // ── Model options ────────────────────────────────────────────────────── 169 + 170 + describe('AI Chat — MODEL_OPTIONS', () => { 171 + it('has at least 3 model options', () => { 172 + expect(MODEL_OPTIONS.length).toBeGreaterThanOrEqual(3); 173 + }); 174 + 175 + it('each option has id, label, and provider', () => { 176 + for (const opt of MODEL_OPTIONS) { 177 + expect(opt.id).toBeTruthy(); 178 + expect(opt.label).toBeTruthy(); 179 + expect(opt.provider).toBeTruthy(); 180 + } 181 + }); 182 + 183 + it('includes Claude and GPT options', () => { 184 + const ids = MODEL_OPTIONS.map((m) => m.id); 185 + expect(ids.some((id) => id.includes('claude'))).toBe(true); 186 + expect(ids.some((id) => id.includes('gpt'))).toBe(true); 187 + }); 188 + }); 189 + 190 + // ── autoResizeTextarea ───────────────────────────────────────────────── 191 + 192 + describe('AI Chat — autoResizeTextarea', () => { 193 + it('sets height based on scrollHeight (capped at 120px)', () => { 194 + const textarea = document.createElement('textarea'); 195 + Object.defineProperty(textarea, 'scrollHeight', { value: 60, configurable: true }); 196 + autoResizeTextarea(textarea); 197 + expect(textarea.style.height).toBe('60px'); 198 + }); 199 + 200 + it('caps height at 120px', () => { 201 + const textarea = document.createElement('textarea'); 202 + Object.defineProperty(textarea, 'scrollHeight', { value: 200, configurable: true }); 203 + autoResizeTextarea(textarea); 204 + expect(textarea.style.height).toBe('120px'); 205 + }); 206 + });