voyageai-cli 1.26.0 → 1.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2334,6 +2334,108 @@ select:focus { outline: none; border-color: var(--accent); }
2334
2334
  .chat-sources summary { cursor: pointer; }
2335
2335
  .chat-sources ul { margin: 4px 0 0 16px; padding: 0; }
2336
2336
  .chat-sources li { margin: 2px 0; }
2337
+ /* Markdown rendering in assistant messages */
2338
+ .chat-message.assistant .chat-message-content.rendered {
2339
+ white-space: normal;
2340
+ }
2341
+ .chat-message.assistant .chat-message-content.rendered p {
2342
+ margin: 0 0 8px 0;
2343
+ }
2344
+ .chat-message.assistant .chat-message-content.rendered p:last-child {
2345
+ margin-bottom: 0;
2346
+ }
2347
+ .chat-message.assistant .chat-message-content.rendered h1,
2348
+ .chat-message.assistant .chat-message-content.rendered h2,
2349
+ .chat-message.assistant .chat-message-content.rendered h3,
2350
+ .chat-message.assistant .chat-message-content.rendered h4 {
2351
+ margin: 12px 0 6px 0;
2352
+ font-weight: 600;
2353
+ color: var(--accent-text, #fff);
2354
+ }
2355
+ .chat-message.assistant .chat-message-content.rendered h1 { font-size: 1.3em; }
2356
+ .chat-message.assistant .chat-message-content.rendered h2 { font-size: 1.15em; }
2357
+ .chat-message.assistant .chat-message-content.rendered h3 { font-size: 1.05em; }
2358
+ .chat-message.assistant .chat-message-content.rendered code {
2359
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
2360
+ font-size: 0.9em;
2361
+ background: var(--bg-input, #112733);
2362
+ padding: 2px 5px;
2363
+ border-radius: 4px;
2364
+ }
2365
+ .chat-message.assistant .chat-message-content.rendered pre {
2366
+ background: var(--bg-input, #112733);
2367
+ border: 1px solid var(--border);
2368
+ border-radius: 8px;
2369
+ padding: 12px;
2370
+ margin: 8px 0;
2371
+ overflow-x: auto;
2372
+ white-space: pre;
2373
+ }
2374
+ .chat-message.assistant .chat-message-content.rendered pre code {
2375
+ background: none;
2376
+ padding: 0;
2377
+ border-radius: 0;
2378
+ font-size: 13px;
2379
+ line-height: 1.5;
2380
+ }
2381
+ .chat-message.assistant .chat-message-content.rendered ul,
2382
+ .chat-message.assistant .chat-message-content.rendered ol {
2383
+ margin: 6px 0;
2384
+ padding-left: 20px;
2385
+ }
2386
+ .chat-message.assistant .chat-message-content.rendered li {
2387
+ margin: 3px 0;
2388
+ }
2389
+ .chat-message.assistant .chat-message-content.rendered blockquote {
2390
+ border-left: 3px solid var(--accent, #00D4AA);
2391
+ margin: 8px 0;
2392
+ padding: 4px 12px;
2393
+ color: var(--text-muted);
2394
+ }
2395
+ .chat-message.assistant .chat-message-content.rendered a {
2396
+ color: var(--accent, #00D4AA);
2397
+ text-decoration: none;
2398
+ }
2399
+ .chat-message.assistant .chat-message-content.rendered a:hover {
2400
+ text-decoration: underline;
2401
+ }
2402
+ .chat-message.assistant .chat-message-content.rendered table {
2403
+ border-collapse: collapse;
2404
+ margin: 8px 0;
2405
+ font-size: 13px;
2406
+ width: 100%;
2407
+ }
2408
+ .chat-message.assistant .chat-message-content.rendered th,
2409
+ .chat-message.assistant .chat-message-content.rendered td {
2410
+ border: 1px solid var(--border);
2411
+ padding: 6px 10px;
2412
+ text-align: left;
2413
+ }
2414
+ .chat-message.assistant .chat-message-content.rendered th {
2415
+ background: var(--bg-input, #112733);
2416
+ font-weight: 600;
2417
+ }
2418
+ .chat-message.assistant .chat-message-content.rendered hr {
2419
+ border: none;
2420
+ border-top: 1px solid var(--border);
2421
+ margin: 12px 0;
2422
+ }
2423
+
2424
+ .chat-tool-calls {
2425
+ display: flex; flex-direction: column; gap: 4px;
2426
+ margin-bottom: 4px; align-self: flex-start; max-width: 85%;
2427
+ }
2428
+ .chat-tool-call {
2429
+ font-size: 12px; color: var(--text-muted);
2430
+ padding: 6px 12px; border-radius: 8px;
2431
+ background: var(--bg-card); border: 1px dashed var(--border);
2432
+ display: flex; align-items: center; gap: 6px;
2433
+ }
2434
+ .chat-tool-call .tool-icon { opacity: 0.5; font-size: 11px; }
2435
+ .chat-tool-call .tool-name { font-weight: 600; }
2436
+ .chat-tool-call .tool-time { opacity: 0.5; }
2437
+ .chat-tool-call.error { border-color: #e74c3c; }
2438
+ .chat-tool-call .tool-error { color: #e74c3c; font-size: 11px; }
2337
2439
  .chat-input-area {
2338
2440
  padding: 12px 16px;
2339
2441
  border-top: 1px solid var(--border);
@@ -4046,6 +4148,39 @@ Reranking models rescore initial search results to improve relevance ordering.</
4046
4148
  </select>
4047
4149
  </div>
4048
4150
  </div>
4151
+ <div class="settings-row" id="chatApiKeyRow" style="display:none">
4152
+ <div class="settings-label">
4153
+ <span class="settings-label-text">API Key</span>
4154
+ <span class="settings-label-hint">Provider API key (stored securely in OS keychain)</span>
4155
+ </div>
4156
+ <div class="settings-control" style="display:flex;gap:8px;align-items:center">
4157
+ <input
4158
+ type="password"
4159
+ class="settings-input"
4160
+ id="chatApiKey"
4161
+ placeholder="Enter your API key"
4162
+ style="flex:1;font-family:monospace;font-size:13px"
4163
+ >
4164
+ <button
4165
+ class="btn btn-secondary"
4166
+ id="chatApiKeyToggle"
4167
+ onclick="toggleChatApiKeyVisibility()"
4168
+ title="Show/hide API key"
4169
+ style="padding:8px 12px;min-width:auto"
4170
+ >
4171
+ 👁️
4172
+ </button>
4173
+ <button
4174
+ class="btn"
4175
+ id="chatApiKeySave"
4176
+ onclick="saveChatApiKey()"
4177
+ title="Save API key"
4178
+ style="padding:8px 12px;min-width:auto"
4179
+ >
4180
+ 💾
4181
+ </button>
4182
+ </div>
4183
+ </div>
4049
4184
  </div>
4050
4185
  <div class="settings-section">
4051
4186
  <div class="settings-section-title">Knowledge Base</div>
@@ -4085,6 +4220,18 @@ Reranking models rescore initial search results to improve relevance ordering.</
4085
4220
  <button class="settings-toggle active" id="chatRerank" type="button"></button>
4086
4221
  </div>
4087
4222
  </div>
4223
+ <div class="settings-row">
4224
+ <div class="settings-label">
4225
+ <span class="settings-label-text">Chat Mode</span>
4226
+ <span class="settings-label-hint">Pipeline: fixed RAG retrieval. Agent: LLM chooses which tools to call.</span>
4227
+ </div>
4228
+ <div class="settings-control">
4229
+ <select class="settings-select" id="chatMode">
4230
+ <option value="pipeline">Pipeline (fixed RAG)</option>
4231
+ <option value="agent">Agent (tool-calling)</option>
4232
+ </select>
4233
+ </div>
4234
+ </div>
4088
4235
  </div>
4089
4236
  <div class="settings-section">
4090
4237
  <div class="settings-section-title">Custom Instructions</div>
@@ -6497,6 +6644,8 @@ function initSettings() {
6497
6644
  }
6498
6645
  const chatModel = document.getElementById('chatModel');
6499
6646
  if (chatModel) chatModel.addEventListener('change', saveChatSettings);
6647
+ const chatMode = document.getElementById('chatMode');
6648
+ if (chatMode) chatMode.addEventListener('change', saveChatSettings);
6500
6649
  const chatMaxDocs = document.getElementById('chatMaxDocs');
6501
6650
  if (chatMaxDocs) chatMaxDocs.addEventListener('change', saveChatSettings);
6502
6651
 
@@ -6508,6 +6657,23 @@ function initSettings() {
6508
6657
  const chatSystemPrompt = document.getElementById('chatSystemPrompt');
6509
6658
  if (chatSystemPrompt) chatSystemPrompt.addEventListener('input', saveChatSettingsDebounced);
6510
6659
 
6660
+ // API key input listeners
6661
+ const chatApiKey = document.getElementById('chatApiKey');
6662
+ if (chatApiKey) {
6663
+ // Mark as modified when user types
6664
+ chatApiKey.addEventListener('input', () => {
6665
+ delete chatApiKey.dataset.isMasked;
6666
+ delete chatApiKey.dataset.actualKey;
6667
+ });
6668
+ // Save on Enter key
6669
+ chatApiKey.addEventListener('keydown', (e) => {
6670
+ if (e.key === 'Enter') {
6671
+ e.preventDefault();
6672
+ saveChatApiKey();
6673
+ }
6674
+ });
6675
+ }
6676
+
6511
6677
  // Set up settings sub-navigation
6512
6678
  setupSettingsNav();
6513
6679
  }
@@ -6881,10 +7047,29 @@ function initMultimodal() {
6881
7047
  const dropZone = document.getElementById('mmDropZone');
6882
7048
  const fileInput = document.getElementById('mmFileInput');
6883
7049
 
6884
- // Click to browse
6885
- dropZone.addEventListener('click', () => fileInput.click());
7050
+ // Click to browse — in Electron, use native dialog (more reliable);
7051
+ // in browser, use <input type="file"> with a re-trigger guard.
7052
+ let fileDialogOpen = false;
7053
+ dropZone.addEventListener('click', async () => {
7054
+ if (fileDialogOpen) return;
7055
+ fileDialogOpen = true;
7056
+ try {
7057
+ if (window.vai && window.vai.isElectron && window.vai.openImageDialog) {
7058
+ const result = await window.vai.openImageDialog();
7059
+ if (!result.canceled && result.dataUrl) {
7060
+ handleMultimodalImageFromData(result.dataUrl, result.name, result.size);
7061
+ }
7062
+ } else {
7063
+ fileInput.click();
7064
+ }
7065
+ } finally {
7066
+ setTimeout(() => { fileDialogOpen = false; }, 300);
7067
+ }
7068
+ });
6886
7069
  fileInput.addEventListener('change', (e) => {
7070
+ fileDialogOpen = false;
6887
7071
  if (e.target.files && e.target.files[0]) handleMultimodalImage(e.target.files[0]);
7072
+ fileInput.value = '';
6888
7073
  });
6889
7074
 
6890
7075
  // Drag and drop
@@ -6936,6 +7121,23 @@ function initMultimodal() {
6936
7121
  });
6937
7122
  }
6938
7123
 
7124
+ // Handle image from Electron native dialog (already has dataUrl)
7125
+ function handleMultimodalImageFromData(dataUrl, name, size) {
7126
+ mmImageData = dataUrl;
7127
+ const img = document.getElementById('mmPreviewImg');
7128
+ img.src = mmImageData;
7129
+ img.onload = () => {
7130
+ const info = document.getElementById('mmFileInfo');
7131
+ const sizeStr = size > 1024 * 1024
7132
+ ? (size / (1024 * 1024)).toFixed(1) + ' MB'
7133
+ : (size / 1024).toFixed(0) + ' KB';
7134
+ info.textContent = `${name} · ${img.naturalWidth}×${img.naturalHeight} · ${sizeStr}`;
7135
+ };
7136
+ document.getElementById('mmDropZone').style.display = 'none';
7137
+ document.getElementById('mmPreview').classList.add('visible');
7138
+ hideError('mmError');
7139
+ }
7140
+
6939
7141
  function handleMultimodalImage(file) {
6940
7142
  const VALID_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];
6941
7143
  if (!VALID_TYPES.includes(file.type)) {
@@ -7073,13 +7275,33 @@ function renderGalleryGrid() {
7073
7275
  const addSlot = document.createElement('div');
7074
7276
  addSlot.className = 'mm-gallery-slot';
7075
7277
  addSlot.innerHTML = '<span class="mm-slot-add">+</span>';
7076
- addSlot.addEventListener('click', () => {
7077
- document.getElementById('mmGalleryFileInput').click();
7278
+ addSlot.addEventListener('click', async () => {
7279
+ if (window._galleryDialogOpen) return;
7280
+ window._galleryDialogOpen = true;
7281
+ try {
7282
+ if (window.vai && window.vai.isElectron && window.vai.openImageDialog) {
7283
+ const result = await window.vai.openImageDialog();
7284
+ if (!result.canceled && result.dataUrl) {
7285
+ addGalleryImageFromData(result.dataUrl, result.name, result.size);
7286
+ }
7287
+ } else {
7288
+ document.getElementById('mmGalleryFileInput').click();
7289
+ }
7290
+ } finally {
7291
+ setTimeout(() => { window._galleryDialogOpen = false; }, 300);
7292
+ }
7078
7293
  });
7079
7294
  grid.appendChild(addSlot);
7080
7295
  }
7081
7296
  }
7082
7297
 
7298
+ // Add gallery image from Electron native dialog (already has dataUrl)
7299
+ function addGalleryImageFromData(dataUrl, name, size) {
7300
+ if (mmGalleryImages.length >= 6) return;
7301
+ mmGalleryImages.push({ dataUrl, name, size });
7302
+ renderGalleryGrid();
7303
+ }
7304
+
7083
7305
  function addGalleryImage(file) {
7084
7306
  const VALID_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];
7085
7307
  if (!VALID_TYPES.includes(file.type)) return;
@@ -7731,6 +7953,7 @@ function saveChatSettings() {
7731
7953
  maxDocs: parseInt(document.getElementById('chatMaxDocs').value) || 5,
7732
7954
  rerank: document.getElementById('chatRerank').classList.contains('active'),
7733
7955
  systemPrompt: document.getElementById('chatSystemPrompt').value.trim(),
7956
+ mode: document.getElementById('chatMode').value,
7734
7957
  };
7735
7958
  fetch('/api/chat/config', {
7736
7959
  method: 'POST',
@@ -7779,6 +8002,7 @@ async function loadChatConfig() {
7779
8002
  if (data.chat?.maxContextDocs) document.getElementById('chatMaxDocs').value = data.chat.maxContextDocs;
7780
8003
  if (data.chat?.rerank === false) document.getElementById('chatRerank').classList.remove('active');
7781
8004
  if (data.chat?.systemPrompt) document.getElementById('chatSystemPrompt').value = data.chat.systemPrompt;
8005
+ if (data.mode || data.chat?.mode) document.getElementById('chatMode').value = data.mode || data.chat?.mode || 'pipeline';
7782
8006
  updateChatStatus();
7783
8007
  // Show not-configured banner if incomplete
7784
8008
  const notConfigured = document.getElementById('chatNotConfigured');
@@ -7792,23 +8016,137 @@ async function loadChatConfig() {
7792
8016
  }
7793
8017
  }
7794
8018
 
8019
+ // ── Chat API Key Management ──
8020
+
8021
+ async function loadChatApiKey() {
8022
+ if (!window.vai?.llmKey) return;
8023
+ const apiKeyInput = document.getElementById('chatApiKey');
8024
+ if (!apiKeyInput) return;
8025
+
8026
+ try {
8027
+ const key = await window.vai.llmKey.get();
8028
+ if (key) {
8029
+ // Mask the key by default (show first 4 + last 4)
8030
+ const masked = key.slice(0, 4) + '•'.repeat(Math.max(0, key.length - 8)) + key.slice(-4);
8031
+ apiKeyInput.value = masked;
8032
+ apiKeyInput.dataset.actualKey = key;
8033
+ apiKeyInput.dataset.isMasked = 'true';
8034
+ apiKeyInput.type = 'password';
8035
+ } else {
8036
+ apiKeyInput.value = '';
8037
+ delete apiKeyInput.dataset.actualKey;
8038
+ delete apiKeyInput.dataset.isMasked;
8039
+ }
8040
+ } catch (err) {
8041
+ console.error('Failed to load LLM API key:', err);
8042
+ }
8043
+ }
8044
+
8045
+ function toggleChatApiKeyVisibility() {
8046
+ const apiKeyInput = document.getElementById('chatApiKey');
8047
+ const toggleBtn = document.getElementById('chatApiKeyToggle');
8048
+ if (!apiKeyInput || !toggleBtn) return;
8049
+
8050
+ if (apiKeyInput.dataset.isMasked === 'true' && apiKeyInput.dataset.actualKey) {
8051
+ // Show actual key
8052
+ apiKeyInput.value = apiKeyInput.dataset.actualKey;
8053
+ apiKeyInput.type = 'text';
8054
+ apiKeyInput.dataset.isMasked = 'false';
8055
+ toggleBtn.textContent = '🙈';
8056
+ toggleBtn.title = 'Hide API key';
8057
+ } else if (apiKeyInput.dataset.actualKey) {
8058
+ // Re-mask the key
8059
+ const key = apiKeyInput.dataset.actualKey;
8060
+ const masked = key.slice(0, 4) + '•'.repeat(Math.max(0, key.length - 8)) + key.slice(-4);
8061
+ apiKeyInput.value = masked;
8062
+ apiKeyInput.type = 'password';
8063
+ apiKeyInput.dataset.isMasked = 'true';
8064
+ toggleBtn.textContent = '👁️';
8065
+ toggleBtn.title = 'Show API key';
8066
+ } else {
8067
+ // No stored key, just toggle between text/password for new input
8068
+ apiKeyInput.type = apiKeyInput.type === 'password' ? 'text' : 'password';
8069
+ toggleBtn.textContent = apiKeyInput.type === 'password' ? '👁️' : '🙈';
8070
+ toggleBtn.title = apiKeyInput.type === 'password' ? 'Show API key' : 'Hide API key';
8071
+ }
8072
+ }
8073
+
8074
+ async function saveChatApiKey() {
8075
+ if (!window.vai?.llmKey) return;
8076
+ const apiKeyInput = document.getElementById('chatApiKey');
8077
+ const toggleBtn = document.getElementById('chatApiKeyToggle');
8078
+ if (!apiKeyInput) return;
8079
+
8080
+ let key = apiKeyInput.value.trim();
8081
+
8082
+ // If showing masked value and it hasn't been modified, no need to save
8083
+ if (apiKeyInput.dataset.isMasked === 'true' && apiKeyInput.dataset.actualKey) {
8084
+ const currentMasked = apiKeyInput.dataset.actualKey.slice(0, 4) + '•'.repeat(Math.max(0, apiKeyInput.dataset.actualKey.length - 8)) + apiKeyInput.dataset.actualKey.slice(-4);
8085
+ if (key === currentMasked) {
8086
+ flashSaved();
8087
+ return;
8088
+ }
8089
+ }
8090
+
8091
+ if (!key) {
8092
+ // Delete the key if empty
8093
+ try {
8094
+ await window.vai.llmKey.delete();
8095
+ delete apiKeyInput.dataset.actualKey;
8096
+ delete apiKeyInput.dataset.isMasked;
8097
+ flashSaved();
8098
+ } catch (err) {
8099
+ console.error('Failed to delete LLM API key:', err);
8100
+ alert('Failed to delete API key: ' + err.message);
8101
+ }
8102
+ return;
8103
+ }
8104
+
8105
+ try {
8106
+ await window.vai.llmKey.set(key);
8107
+ // Mask the newly saved key
8108
+ const masked = key.slice(0, 4) + '•'.repeat(Math.max(0, key.length - 8)) + key.slice(-4);
8109
+ apiKeyInput.value = masked;
8110
+ apiKeyInput.dataset.actualKey = key;
8111
+ apiKeyInput.dataset.isMasked = 'true';
8112
+ apiKeyInput.type = 'password';
8113
+ toggleBtn.textContent = '👁️';
8114
+ toggleBtn.title = 'Show API key';
8115
+ flashSaved();
8116
+ } catch (err) {
8117
+ console.error('Failed to save LLM API key:', err);
8118
+ alert('Failed to save API key: ' + err.message);
8119
+ }
8120
+ }
8121
+
7795
8122
  function updateChatStatus() {
7796
8123
  const provider = document.getElementById('chatProvider');
7797
8124
  const db = document.getElementById('chatDb').value;
7798
8125
  const collection = document.getElementById('chatCollection').value;
8126
+ const mode = document.getElementById('chatMode')?.value || 'pipeline';
7799
8127
  const providerLabel = provider.value
7800
8128
  ? provider.options[provider.selectedIndex].text
7801
8129
  : 'No provider';
7802
- document.getElementById('chatStatusProvider').textContent = providerLabel;
8130
+ const modeLabel = mode === 'agent' ? 'Agent' : 'Pipeline';
8131
+ document.getElementById('chatStatusProvider').textContent = `${providerLabel} (${modeLabel})`;
7803
8132
  document.getElementById('chatStatusDb').textContent =
7804
- (db && collection) ? `${db}.${collection}` : 'No database';
8133
+ (db && collection) ? `${db}.${collection}` : (mode === 'agent' ? 'Agent discovers' : 'No database');
7805
8134
  }
7806
8135
 
7807
8136
  async function chatProviderChanged() {
7808
8137
  updateChatStatus();
7809
8138
  const provider = document.getElementById('chatProvider').value;
7810
8139
  const modelSelect = document.getElementById('chatModel');
7811
-
8140
+ const apiKeyRow = document.getElementById('chatApiKeyRow');
8141
+
8142
+ // Show/hide API key row based on provider (not needed for Ollama)
8143
+ if (apiKeyRow) {
8144
+ apiKeyRow.style.display = (provider === 'anthropic' || provider === 'openai') ? 'flex' : 'none';
8145
+ if (provider === 'anthropic' || provider === 'openai') {
8146
+ await loadChatApiKey();
8147
+ }
8148
+ }
8149
+
7812
8150
  if (!provider) {
7813
8151
  modelSelect.innerHTML = '<option value="">Select provider first</option>';
7814
8152
  return;
@@ -7886,6 +8224,123 @@ function chatInputKeydown(e) {
7886
8224
  }
7887
8225
  }
7888
8226
 
8227
+ /**
8228
+ * Lightweight markdown-to-HTML renderer for assistant chat messages.
8229
+ * Handles: fenced code blocks, inline code, headings, bold, italic,
8230
+ * links, images, unordered/ordered lists, blockquotes, tables, and <hr>.
8231
+ * No external dependencies.
8232
+ */
8233
+ function renderMarkdown(md) {
8234
+ // Escape HTML to prevent XSS, then selectively render markdown
8235
+ function esc(s) {
8236
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
8237
+ }
8238
+
8239
+ // Extract fenced code blocks first (preserve content as-is)
8240
+ const codeBlocks = [];
8241
+ let text = md.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
8242
+ const idx = codeBlocks.length;
8243
+ codeBlocks.push(`<pre><code${lang ? ' data-lang="' + esc(lang) + '"' : ''}>${esc(code.replace(/\n$/, ''))}</code></pre>`);
8244
+ return '\x00CB' + idx + '\x00';
8245
+ });
8246
+
8247
+ // Escape remaining HTML
8248
+ text = esc(text);
8249
+
8250
+ // Restore code block placeholders
8251
+ text = text.replace(/\x00CB(\d+)\x00/g, (_, i) => codeBlocks[i]);
8252
+
8253
+ // Inline code (must come after escape but before other inline formatting)
8254
+ text = text.replace(/`([^`\n]+)`/g, '<code>$1</code>');
8255
+
8256
+ // Headings (must be at line start)
8257
+ text = text.replace(/^#### (.+)$/gm, '<h4>$1</h4>');
8258
+ text = text.replace(/^### (.+)$/gm, '<h3>$1</h3>');
8259
+ text = text.replace(/^## (.+)$/gm, '<h2>$1</h2>');
8260
+ text = text.replace(/^# (.+)$/gm, '<h1>$1</h1>');
8261
+
8262
+ // Horizontal rule
8263
+ text = text.replace(/^---+$/gm, '<hr>');
8264
+
8265
+ // Bold and italic
8266
+ text = text.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
8267
+ text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
8268
+ text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
8269
+
8270
+ // Links and images
8271
+ text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img alt="$1" src="$2" style="max-width:100%;border-radius:4px;">');
8272
+ text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
8273
+
8274
+ // Tables (pipe-delimited)
8275
+ text = text.replace(/((?:^\|.+\|$\n?)+)/gm, (block) => {
8276
+ const rows = block.trim().split('\n').filter(r => r.trim());
8277
+ if (rows.length < 2) return block;
8278
+ // Check if second row is separator
8279
+ const sep = rows[1];
8280
+ if (!/^\|[\s:-]+\|$/.test(sep.trim().replace(/\|/g, '|'))) return block;
8281
+ const headerCells = rows[0].split('|').filter(c => c.trim() !== '').map(c => c.trim());
8282
+ let html = '<table><thead><tr>' + headerCells.map(c => '<th>' + c + '</th>').join('') + '</tr></thead><tbody>';
8283
+ for (let i = 2; i < rows.length; i++) {
8284
+ const cells = rows[i].split('|').filter(c => c.trim() !== '').map(c => c.trim());
8285
+ html += '<tr>' + cells.map(c => '<td>' + c + '</td>').join('') + '</tr>';
8286
+ }
8287
+ html += '</tbody></table>';
8288
+ return html;
8289
+ });
8290
+
8291
+ // Blockquotes
8292
+ text = text.replace(/^&gt; (.+)$/gm, '<blockquote>$1</blockquote>');
8293
+ // Merge adjacent blockquotes
8294
+ text = text.replace(/<\/blockquote>\n<blockquote>/g, '\n');
8295
+
8296
+ // Unordered lists (lines starting with - or *)
8297
+ text = text.replace(/((?:^[\s]*[-*] .+$\n?)+)/gm, (block) => {
8298
+ const items = block.trim().split('\n').map(line => {
8299
+ const content = line.replace(/^[\s]*[-*] /, '');
8300
+ return '<li>' + content + '</li>';
8301
+ });
8302
+ return '<ul>' + items.join('') + '</ul>';
8303
+ });
8304
+
8305
+ // Ordered lists (lines starting with number.)
8306
+ text = text.replace(/((?:^[\s]*\d+\. .+$\n?)+)/gm, (block) => {
8307
+ const items = block.trim().split('\n').map(line => {
8308
+ const content = line.replace(/^[\s]*\d+\. /, '');
8309
+ return '<li>' + content + '</li>';
8310
+ });
8311
+ return '<ol>' + items.join('') + '</ol>';
8312
+ });
8313
+
8314
+ // Paragraphs: wrap remaining text lines that aren't already wrapped in block elements
8315
+ const blockTags = ['<h1', '<h2', '<h3', '<h4', '<hr', '<pre', '<ul', '<ol', '<table', '<blockquote'];
8316
+ const lines = text.split('\n');
8317
+ const result = [];
8318
+ let paraLines = [];
8319
+
8320
+ function flushPara() {
8321
+ if (paraLines.length > 0) {
8322
+ const content = paraLines.join('\n').trim();
8323
+ if (content) result.push('<p>' + content + '</p>');
8324
+ paraLines = [];
8325
+ }
8326
+ }
8327
+
8328
+ for (const line of lines) {
8329
+ const trimmed = line.trim();
8330
+ if (trimmed === '') {
8331
+ flushPara();
8332
+ } else if (blockTags.some(tag => trimmed.startsWith(tag))) {
8333
+ flushPara();
8334
+ result.push(line);
8335
+ } else {
8336
+ paraLines.push(line);
8337
+ }
8338
+ }
8339
+ flushPara();
8340
+
8341
+ return result.join('\n');
8342
+ }
8343
+
7889
8344
  function addChatMessage(role, content, sources) {
7890
8345
  const container = document.getElementById('chatMessages');
7891
8346
  const div = document.createElement('div');
@@ -7929,12 +8384,14 @@ async function sendChatMessage() {
7929
8384
  const maxDocs = parseInt(document.getElementById('chatMaxDocs').value) || 5;
7930
8385
  const rerank = document.getElementById('chatRerank').classList.contains('active');
7931
8386
  const systemPrompt = document.getElementById('chatSystemPrompt').value.trim() || undefined;
8387
+ const mode = document.getElementById('chatMode')?.value || 'pipeline';
8388
+ const isAgent = mode === 'agent';
7932
8389
 
7933
8390
  if (!provider) {
7934
8391
  addChatMessage('system-msg', 'Please select an LLM provider in <a href="#" onclick="openChatSettings();return false;">Chat Settings</a>.');
7935
8392
  return;
7936
8393
  }
7937
- if (!db || !collection) {
8394
+ if (!isAgent && (!db || !collection)) {
7938
8395
  addChatMessage('system-msg', 'Please configure a database and collection in <a href="#" onclick="openChatSettings();return false;">Chat Settings</a>.');
7939
8396
  return;
7940
8397
  }
@@ -7947,7 +8404,7 @@ async function sendChatMessage() {
7947
8404
  // Show typing indicator
7948
8405
  const typing = document.createElement('div');
7949
8406
  typing.className = 'chat-typing';
7950
- typing.textContent = 'Thinking';
8407
+ typing.textContent = isAgent ? 'Agent working' : 'Thinking';
7951
8408
  document.getElementById('chatMessages').appendChild(typing);
7952
8409
 
7953
8410
  // Disable input
@@ -7959,7 +8416,7 @@ async function sendChatMessage() {
7959
8416
  const res = await fetch('/api/chat/message', {
7960
8417
  method: 'POST',
7961
8418
  headers: { 'Content-Type': 'application/json' },
7962
- body: JSON.stringify({ query, db, collection, provider, model, maxDocs, rerank, systemPrompt }),
8419
+ body: JSON.stringify({ query, db, collection, provider, model, maxDocs, rerank, systemPrompt, mode }),
7963
8420
  });
7964
8421
 
7965
8422
  if (!res.ok) {
@@ -7976,6 +8433,7 @@ async function sendChatMessage() {
7976
8433
  let assistantDiv = null;
7977
8434
  let fullText = '';
7978
8435
  let sources = [];
8436
+ let toolCallsDiv = null;
7979
8437
 
7980
8438
  while (true) {
7981
8439
  const { done, value } = await reader.read();
@@ -7998,6 +8456,37 @@ async function sendChatMessage() {
7998
8456
  typing.textContent = `Retrieved ${data.docs?.length || 0} docs (${data.timeMs}ms)`;
7999
8457
  }
8000
8458
 
8459
+ if (currentEvent === 'tool_call') {
8460
+ if (!toolCallsDiv) {
8461
+ toolCallsDiv = document.createElement('div');
8462
+ toolCallsDiv.className = 'chat-tool-calls';
8463
+ document.getElementById('chatMessages').appendChild(toolCallsDiv);
8464
+ }
8465
+ const tc = document.createElement('div');
8466
+ tc.className = 'chat-tool-call' + (data.error ? ' error' : '');
8467
+ const icon = document.createElement('span');
8468
+ icon.className = 'tool-icon';
8469
+ icon.textContent = '\u2699';
8470
+ tc.appendChild(icon);
8471
+ const nameSpan = document.createElement('span');
8472
+ nameSpan.className = 'tool-name';
8473
+ nameSpan.textContent = data.name;
8474
+ tc.appendChild(nameSpan);
8475
+ const timeSpan = document.createElement('span');
8476
+ timeSpan.className = 'tool-time';
8477
+ timeSpan.textContent = (data.timeMs || 0) + 'ms';
8478
+ tc.appendChild(timeSpan);
8479
+ if (data.error) {
8480
+ const errSpan = document.createElement('span');
8481
+ errSpan.className = 'tool-error';
8482
+ errSpan.textContent = data.error;
8483
+ tc.appendChild(errSpan);
8484
+ }
8485
+ toolCallsDiv.appendChild(tc);
8486
+ typing.textContent = 'Calling ' + data.name + '...';
8487
+ document.getElementById('chatMessages').scrollTop = document.getElementById('chatMessages').scrollHeight;
8488
+ }
8489
+
8001
8490
  if (currentEvent === 'chunk') {
8002
8491
  if (!assistantDiv) {
8003
8492
  typing.remove();
@@ -8009,6 +8498,12 @@ async function sendChatMessage() {
8009
8498
  }
8010
8499
 
8011
8500
  if (currentEvent === 'done') {
8501
+ // Render accumulated text as markdown for assistant messages
8502
+ if (assistantDiv && fullText) {
8503
+ const contentEl = assistantDiv.querySelector('.chat-message-content');
8504
+ contentEl.innerHTML = renderMarkdown(fullText);
8505
+ contentEl.classList.add('rendered');
8506
+ }
8012
8507
  sources = data.sources || [];
8013
8508
  if (sources.length > 0 && assistantDiv) {
8014
8509
  const details = document.createElement('details');
@@ -8074,6 +8569,9 @@ window.switchSettingsSection = switchSettingsSection;
8074
8569
  window.updateChatStatus = updateChatStatus;
8075
8570
  window.chatInputKeydown = chatInputKeydown;
8076
8571
  window.chatProviderChanged = chatProviderChanged;
8572
+ window.loadChatApiKey = loadChatApiKey;
8573
+ window.toggleChatApiKeyVisibility = toggleChatApiKeyVisibility;
8574
+ window.saveChatApiKey = saveChatApiKey;
8077
8575
 
8078
8576
  // ── Start ──
8079
8577
  init();