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 'fix: polish AI chat — Aperture default, SVG icons' (#156) from fix/ai-chat-polish into main

scott d42a6cc0 b6bd84d3

+60 -48
+15
src/css/app.css
··· 6783 6783 accent-color: var(--color-accent); 6784 6784 } 6785 6785 6786 + .ai-chat-advanced { 6787 + margin-top: var(--space-xs); 6788 + font-size: 0.8125rem; 6789 + color: var(--color-text-muted); 6790 + } 6791 + 6792 + .ai-chat-advanced summary { 6793 + cursor: pointer; 6794 + user-select: none; 6795 + } 6796 + 6797 + .ai-chat-advanced .ai-chat-settings-field { 6798 + margin-top: var(--space-xs); 6799 + } 6800 + 6786 6801 /* Message area */ 6787 6802 6788 6803 .ai-chat-messages {
+24 -26
src/docs/ai-chat.ts
··· 17 17 18 18 export interface ChatConfig { 19 19 endpoint: string; 20 - apiKey: string; 21 20 model: string; 22 21 maxTokens: number; 23 22 } ··· 32 31 // ── Config helpers ───────────────────────────────────────────────────── 33 32 34 33 const LS_ENDPOINT = 'tools-ai-endpoint'; 35 - const LS_API_KEY = 'tools-ai-apikey'; 36 34 const LS_MODEL = 'tools-ai-model'; 37 35 36 + const DEFAULT_ENDPOINT = 'http://ai'; 38 37 const DEFAULT_MODEL = 'claude-sonnet-4-20250514'; 39 38 const DEFAULT_MAX_TOKENS = 4096; 40 39 41 40 export function loadConfig(): ChatConfig { 42 41 return { 43 - endpoint: localStorage.getItem(LS_ENDPOINT) || '', 44 - apiKey: localStorage.getItem(LS_API_KEY) || '', 42 + endpoint: localStorage.getItem(LS_ENDPOINT) || DEFAULT_ENDPOINT, 45 43 model: localStorage.getItem(LS_MODEL) || DEFAULT_MODEL, 46 44 maxTokens: DEFAULT_MAX_TOKENS, 47 45 }; ··· 49 47 50 48 export function saveConfig(cfg: Partial<ChatConfig>): void { 51 49 if (cfg.endpoint !== undefined) localStorage.setItem(LS_ENDPOINT, cfg.endpoint); 52 - if (cfg.apiKey !== undefined) localStorage.setItem(LS_API_KEY, cfg.apiKey); 53 50 if (cfg.model !== undefined) localStorage.setItem(LS_MODEL, cfg.model); 54 51 } 55 52 ··· 130 127 const headers: Record<string, string> = { 131 128 'Content-Type': 'application/json', 132 129 }; 133 - if (config.apiKey) { 134 - headers['Authorization'] = `Bearer ${config.apiKey}`; 135 - } 136 130 137 131 const body = JSON.stringify({ 138 132 model: config.model, ··· 237 231 const headers: Record<string, string> = { 238 232 'Content-Type': 'application/json', 239 233 }; 240 - if (config.apiKey) { 241 - headers['Authorization'] = `Bearer ${config.apiKey}`; 242 - } 243 234 244 235 const res = await fetch(url, { 245 236 method: 'POST', ··· 309 300 closeBtn: HTMLButtonElement; 310 301 settingsPanel: HTMLElement; 311 302 endpointInput: HTMLInputElement; 312 - apiKeyInput: HTMLInputElement; 313 303 modelSelect: HTMLSelectElement; 314 304 contextToggle: HTMLInputElement; 315 305 } { ··· 325 315 <span class="ai-chat-model-badge" id="ai-model-badge"></span> 326 316 </div> 327 317 <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> 318 + <button class="btn-icon ai-chat-settings-btn" id="ai-chat-settings-btn" title="Settings"> 319 + <svg class="tb-icon" viewBox="0 0 16 16" style="width:14px;height:14px" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="2.5"/><path d="M8 1.5v2M8 12.5v2M1.5 8h2M12.5 8h2M3.1 3.1l1.4 1.4M11.5 11.5l1.4 1.4M3.1 12.9l1.4-1.4M11.5 4.5l1.4-1.4"/></svg> 320 + </button> 321 + <button class="btn-icon ai-chat-clear-btn" id="ai-chat-clear-btn" title="Clear chat"> 322 + <svg class="tb-icon" viewBox="0 0 16 16" style="width:14px;height:14px" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 4h10M6 4V3h4v1M4 4v9a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4"/></svg> 323 + </button> 324 + <button class="btn-icon ai-chat-close" id="ai-chat-close" title="Close"> 325 + <svg class="tb-icon" viewBox="0 0 16 16" style="width:14px;height:14px" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="4" y1="4" x2="12" y2="12"/><line x1="12" y1="4" x2="4" y2="12"/></svg> 326 + </button> 331 327 </div> 332 328 </div> 333 329 334 330 <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 331 <div class="ai-chat-settings-field"> 344 332 <label for="ai-model">Model</label> 345 333 <select id="ai-model"> ··· 354 342 Include document context 355 343 </label> 356 344 </div> 345 + <details class="ai-chat-advanced"> 346 + <summary>Advanced</summary> 347 + <div class="ai-chat-settings-field"> 348 + <label for="ai-endpoint">Endpoint</label> 349 + <input type="text" id="ai-endpoint" placeholder="http://ai" spellcheck="false" autocomplete="off"> 350 + </div> 351 + </details> 357 352 </div> 358 353 359 354 <div class="ai-chat-messages" id="ai-chat-messages"> 360 355 <div class="ai-chat-empty" id="ai-chat-empty"> 361 - <div class="ai-chat-empty-icon">&#10024;</div> 356 + <div class="ai-chat-empty-icon"> 357 + <svg viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 4.5h18a1.5 1.5 0 0 1 1.5 1.5v10.5a1.5 1.5 0 0 1-1.5 1.5H7.5L3 22.5V6a1.5 1.5 0 0 1 1.5-1.5z"/><circle cx="8.25" cy="11.25" r="0.75" fill="currentColor" stroke="none"/><circle cx="12" cy="11.25" r="0.75" fill="currentColor" stroke="none"/><circle cx="15.75" cy="11.25" r="0.75" fill="currentColor" stroke="none"/></svg> 358 + </div> 362 359 <div class="ai-chat-empty-text">Ask anything about your document</div> 363 360 <div class="ai-chat-empty-hint">The AI can see your document content when context is enabled</div> 364 361 </div> ··· 376 373 <button class="ai-chat-send" id="ai-chat-send" title="Send (Enter)"> 377 374 <svg viewBox="0 0 16 16" width="16" height="16"><path d="M1 8l6-6v4h8v4H7v4z" transform="rotate(-90 8 8)" fill="currentColor"/></svg> 378 375 </button> 379 - <button class="ai-chat-stop" id="ai-chat-stop" title="Stop generating" style="display:none">&#9632;</button> 376 + <button class="ai-chat-stop" id="ai-chat-stop" title="Stop generating" style="display:none"> 377 + <svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><rect x="3" y="3" width="10" height="10" rx="1.5"/></svg> 378 + </button> 380 379 </div> 381 380 </div> 382 381 `; ··· 392 391 closeBtn: container.querySelector('#ai-chat-close') as HTMLButtonElement, 393 392 settingsPanel: container.querySelector('#ai-chat-settings') as HTMLElement, 394 393 endpointInput: container.querySelector('#ai-endpoint') as HTMLInputElement, 395 - apiKeyInput: container.querySelector('#ai-apikey') as HTMLInputElement, 396 394 modelSelect: container.querySelector('#ai-model') as HTMLSelectElement, 397 395 contextToggle: container.querySelector('#ai-context-toggle') as HTMLInputElement, 398 396 };
+7 -3
src/docs/index.html
··· 34 34 <!-- Suggesting mode toggle --> 35 35 <div class="suggesting-toggle" id="suggesting-toggle"> 36 36 <button class="suggesting-toggle-btn" id="btn-suggesting" title="Toggle suggesting mode"> 37 - <span class="suggesting-toggle-icon">&#9998;</span> 37 + <span class="suggesting-toggle-icon"><svg class="tb-icon" viewBox="0 0 16 16" style="width:14px;height:14px" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M11.5 2.5l2 2-9 9H2.5v-2z"/><path d="M9.5 4.5l2 2"/></svg></span> 38 38 <span class="suggesting-toggle-label" id="suggesting-label">Editing</span> 39 39 </button> 40 40 </div> ··· 45 45 <svg class="tb-icon" viewBox="0 0 16 16" style="width:16px;height:16px"><line x1="2" y1="3" x2="14" y2="3"/><line x1="4" y1="6.5" x2="14" y2="6.5"/><line x1="6" y1="10" x2="14" y2="10"/><line x1="2" y1="13.5" x2="14" y2="13.5"/></svg> 46 46 </button> 47 47 <!-- Version history --> 48 - <button class="btn-icon" id="btn-history" title="Version history">&#128339;</button> 48 + <button class="btn-icon" id="btn-history" title="Version history"> 49 + <svg class="tb-icon" viewBox="0 0 16 16" style="width:16px;height:16px" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><path d="M8 4.5V8l2.5 1.5"/></svg> 50 + </button> 49 51 <!-- AI Chat --> 50 52 <button class="btn-icon" id="btn-ai-chat" title="AI Chat (Cmd+Shift+L)"> 51 53 <svg class="tb-icon" viewBox="0 0 16 16" style="width:16px;height:16px" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h12a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H5l-3 3V4a1 1 0 0 1 1-1z"/><circle cx="5.5" cy="7.5" r="0.5" fill="currentColor" stroke="none"/><circle cx="8" cy="7.5" r="0.5" fill="currentColor" stroke="none"/><circle cx="10.5" cy="7.5" r="0.5" fill="currentColor" stroke="none"/></svg> 52 54 </button> 53 55 <!-- Share button --> 54 - <button class="btn-icon" id="btn-share" title="Share document">&#128279;</button> 56 + <button class="btn-icon" id="btn-share" title="Share document"> 57 + <svg class="tb-icon" viewBox="0 0 16 16" style="width:16px;height:16px" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="3.5" r="2"/><circle cx="4" cy="8" r="2"/><circle cx="12" cy="12.5" r="2"/><line x1="5.8" y1="9" x2="10.2" y2="11.5"/><line x1="10.2" y1="4.5" x2="5.8" y2="7"/></svg> 58 + </button> 55 59 <div class="save-indicator saved" id="save-indicator"> 56 60 <span class="save-dot save-dot--saved"></span> 57 61 <span id="save-text">Saved</span>
+3 -4
src/docs/main.ts
··· 2358 2358 2359 2359 // Populate settings from saved config 2360 2360 chatUI.endpointInput.value = chatConfig.endpoint; 2361 - chatUI.apiKeyInput.value = chatConfig.apiKey; 2362 2361 2363 2362 // Set model dropdown to match config (or custom) 2364 2363 const knownModel = MODEL_OPTIONS.find((m) => m.id === chatConfig.model); ··· 2422 2421 2423 2422 chatConfig = { 2424 2423 endpoint: chatUI.endpointInput.value.trim(), 2425 - apiKey: chatUI.apiKeyInput.value.trim(), 2426 2424 model: model || 'claude-sonnet-4-20250514', 2427 2425 maxTokens: chatConfig.maxTokens, 2428 2426 }; ··· 2431 2429 } 2432 2430 2433 2431 chatUI.endpointInput.addEventListener('change', persistSettings); 2434 - chatUI.apiKeyInput.addEventListener('change', persistSettings); 2435 2432 chatUI.modelSelect.addEventListener('change', () => { 2436 2433 const customInput = chatUI.container.querySelector('#ai-model-custom') as HTMLInputElement; 2437 2434 customInput.style.display = chatUI.modelSelect.value === '__custom' ? '' : 'none'; ··· 2528 2525 chatState.error = null; 2529 2526 chatUI.messageList.innerHTML = ` 2530 2527 <div class="ai-chat-empty" id="ai-chat-empty"> 2531 - <div class="ai-chat-empty-icon">&#10024;</div> 2528 + <div class="ai-chat-empty-icon"> 2529 + <svg viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 4.5h18a1.5 1.5 0 0 1 1.5 1.5v10.5a1.5 1.5 0 0 1-1.5 1.5H7.5L3 22.5V6a1.5 1.5 0 0 1 1.5-1.5z"/><circle cx="8.25" cy="11.25" r="0.75" fill="currentColor" stroke="none"/><circle cx="12" cy="11.25" r="0.75" fill="currentColor" stroke="none"/><circle cx="15.75" cy="11.25" r="0.75" fill="currentColor" stroke="none"/></svg> 2530 + </div> 2532 2531 <div class="ai-chat-empty-text">Ask anything about your document</div> 2533 2532 <div class="ai-chat-empty-hint">The AI can see your document content when context is enabled</div> 2534 2533 </div>
+11 -15
tests/ai-chat.test.ts
··· 33 33 34 34 it('loadConfig returns defaults when localStorage is empty', () => { 35 35 const cfg = loadConfig(); 36 - expect(cfg.endpoint).toBe(''); 37 - expect(cfg.apiKey).toBe(''); 36 + expect(cfg.endpoint).toBe('http://ai'); 38 37 expect(cfg.model).toBe('claude-sonnet-4-20250514'); 39 38 expect(cfg.maxTokens).toBe(4096); 40 39 }); 41 40 42 41 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'); 42 + saveConfig({ endpoint: 'http://custom', model: 'gpt-4o' }); 43 + expect(localStorage.getItem('tools-ai-endpoint')).toBe('http://custom'); 46 44 expect(localStorage.getItem('tools-ai-model')).toBe('gpt-4o'); 47 45 }); 48 46 49 47 it('loadConfig reads saved values', () => { 50 - saveConfig({ endpoint: 'http://ai', apiKey: 'key123', model: 'custom-model' }); 48 + saveConfig({ endpoint: 'http://custom', model: 'custom-model' }); 51 49 const cfg = loadConfig(); 52 - expect(cfg.endpoint).toBe('http://ai'); 53 - expect(cfg.apiKey).toBe('key123'); 50 + expect(cfg.endpoint).toBe('http://custom'); 54 51 expect(cfg.model).toBe('custom-model'); 55 52 }); 56 53 57 54 it('saveConfig only updates provided fields', () => { 58 - saveConfig({ endpoint: 'http://ai', model: 'gpt-4o' }); 59 - saveConfig({ apiKey: 'new-key' }); 55 + saveConfig({ endpoint: 'http://custom', model: 'gpt-4o' }); 56 + saveConfig({ model: 'gpt-4o-mini' }); 60 57 const cfg = loadConfig(); 61 - expect(cfg.endpoint).toBe('http://ai'); 62 - expect(cfg.apiKey).toBe('new-key'); 63 - expect(cfg.model).toBe('gpt-4o'); 58 + expect(cfg.endpoint).toBe('http://custom'); 59 + expect(cfg.model).toBe('gpt-4o-mini'); 64 60 }); 65 61 66 62 it('isConfigured returns false with empty endpoint', () => { 67 - expect(isConfigured({ endpoint: '', apiKey: '', model: 'm', maxTokens: 1024 })).toBe(false); 63 + expect(isConfigured({ endpoint: '', model: 'm', maxTokens: 1024 })).toBe(false); 68 64 }); 69 65 70 66 it('isConfigured returns true with endpoint set', () => { 71 - expect(isConfigured({ endpoint: 'http://ai', apiKey: '', model: 'm', maxTokens: 1024 })).toBe(true); 67 + expect(isConfigured({ endpoint: 'http://ai', model: 'm', maxTokens: 1024 })).toBe(true); 72 68 }); 73 69 }); 74 70