1// static/js/group.js 2// Group Chat â multi-model conversations (parallel or round-robin) 3 4import uiModule from './ui.js'; 5import markdownModule from './markdown.js'; 6import chatRenderer from './chatRenderer.js'; 7import spinnerModule from './spinner.js'; 8import { providerLogo } from './providers.js'; 9import { PROMPT_TEMPLATES, getUserTemplates } from './presets.js'; 10import { sortModelObjects } from './modelSort.js'; 11import Storage from './storage.js'; 12 13let API_BASE = ''; 14let _active = false; 15let _models = []; // [{mid, display, url, endpointId}] 16let _participantSessions = []; // session IDs for each model 17const _groupParticipants = []; // module-level participants list 18let _abortControllers = []; 19let _mode = 'round-robin'; // 'parallel' or 'round-robin' 20let _roundRobinIdx = 0; 21let _parentSessionId = null; 22const GROUP_STATE_KEY = 'odysseus-group-state'; 23 24export function init(apiBase) { 25 API_BASE = apiBase; 26 // Initialize Group tab inside Characters modal 27 setTimeout(_initGroupTab, 500); 28} 29 30function _initGroupTab() { 31 const participantsEl = document.getElementById('group-participants'); 32 const addBtn = document.getElementById('group-add-btn'); 33 const startBtn = document.getElementById('save-custom-preset'); // main footer "Start" button 34 const modeBtn = document.getElementById('group-mode-btn'); 35 if (!participantsEl || !addBtn) return; 36 37 // _groupParticipants is at module scope 38 let _modelsCache = null; 39 40 async function _getModels() { 41 if (_modelsCache) return _modelsCache; 42 let items = (window.modelsModule && window.modelsModule.getCachedItems) ? window.modelsModule.getCachedItems() : []; 43 if (!items || items.length === 0) { 44 try { 45 const res = await fetch(API_BASE + '/api/models', { credentials: 'same-origin' }); 46 items = (await res.json()).items || []; 47 } catch (e) {} 48 } 49 const result = []; 50 const seen = new Set(); 51 items.forEach(item => { 52 if (item.offline) return; 53 (item.models || []).concat(item.models_extra || []).forEach((mid, i) => { 54 if (seen.has(mid)) return; 55 seen.add(mid); 56 const display = ((item.models_display || []).concat(item.models_extra_display || []))[i] || mid; 57 result.push({ mid, display: display.split('/').pop(), url: item.url, endpointId: item.endpoint_id }); 58 }); 59 }); 60 _modelsCache = sortModelObjects(result); 61 return _modelsCache; 62 } 63 64 function _render() { 65 participantsEl.innerHTML = '';
66 _groupParticipants.forEach((p, idx) => { 67 const row = document.createElement('div'); 68 row.style.cssText = 'display:flex;align-items:center;gap:6px;padding:4px 6px;background:color-mix(in srgb, var(--fg) 3%, transparent);border-radius:6px;'; 69 const label = p.character ? p.character.name : (p.model ? p.model.display : '?'); 70 const sublabel = p.model ? p.model.display : ''; 71 row.innerHTML = ` 72 <span style="flex:1;min-width:0;"> 73 <span style="font-size:12px;font-weight:500;">${uiModule.esc(label)}</span> 74 ${sublabel && sublabel !== label ? '<span style="font-size:10px;opacity:0.35;margin-left:4px;">' + uiModule.esc(sublabel) + '</span>' : ''} 75 </span> 76 <button style="background:none;border:none;color:var(--fg);opacity:0.5;cursor:pointer;font-size:16px;padding:0 4px;line-height:1;position:relative;top:-4px;" data-idx="${idx}" title="Remove">×</button> 77 `; 78 row.querySelector('button').addEventListener('click', () => { _groupParticipants.splice(idx, 1); _render(); }); 79 participantsEl.appendChild(row); 80 }); 81 // startBtn is shared â don't disable it 82 } 83 84 addBtn.addEventListener('click', async () => { 85 const [models, characters] = await Promise.all([_getModels(), _getCharacterList()]); 86 87 const picker = document.createElement('div'); 88 picker.style.cssText = 'display:flex;gap:4px;align-items:center;'; 89 90 const charSel = document.createElement('select'); 91 charSel.className = 'preset-input'; 92 // add an identifier that this is a character selection 93 charSel.dataset.selectionType = "character" 94 charSel.style.cssText = 'font-size:11px;flex:1;height:26px;'; 95 charSel.innerHTML = '<option value="">Empty...</option>' + 96 characters.map(c => '<option value="' + c.id + '">' + uiModule.esc(c.name) + '</option>').join(''); 97 98 const modelSel = document.createElement('select'); 99 modelSel.className = 'preset-input'; 100 // add an identifier that this is a model selection 101 modelSel.dataset.selectionType = "model" 102 modelSel.style.cssText = 'font-size:11px;flex:1;height:26px;'; 103 modelSel.innerHTML = '<option value="">Modelâ¦</option>' + 104 models.map(m => '<option value="' + m.mid + '">' + uiModule.esc(m.display) + '</option>').join(''); 105 106 // Auto-add when model is selected 107 modelSel.addEventListener('change', () => { 108 if (!modelSel.value) return; 109 if (_groupParticipants.length >= 8) { uiModule.showToast('Max 8'); return; } 110 const entry = { character: null, model: null }; 111 entry.model = models.find(m => m.mid === modelSel.value) || null; 112 if (charSel.value) entry.character = characters.find(c => c.id === charSel.value) || null; 113 _groupParticipants.push(entry); 114 picker.remove(); 115 _render(); 116 }); 117 118 picker.appendChild(charSel); 119 picker.appendChild(modelSel); 120 participantsEl.appendChild(picker); 121 }); 122 123 // Mode toggle â same style as Compare's parallel button 124 if (modeBtn) { 125 const ICON_PAR = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg>'; 126 const ICON_SEQ = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="8" y1="6" x2="20" y2="6"/><line x1="8" y1="12" x2="20" y2="12"/><line x1="8" y1="18" x2="20" y2="18"/><circle cx="4" cy="6" r="1.5" fill="currentColor"/><circle cx="4" cy="12" r="1.5" fill="currentColor"/><circle cx="4" cy="18" r="1.5" fill="currentColor"/></svg>'; 127 modeBtn.addEventListener('click', () => { 128 _mode = _mode === 'parallel' ? 'round-robin' : 'parallel'; 129 modeBtn.classList.toggle('active', _mode === 'parallel'); 130 modeBtn.innerHTML = (_mode === 'parallel' ? ICON_PAR : ICON_SEQ) + '<span class="compare-toggle-label">' + (_mode === 'parallel' ? 'Parallel' : 'Sequential') + '</span>'; 131 }); 132 } 133 134 // Hook into the main "Start" button â only act when Group tab is active 135 if (startBtn) startBtn.addEventListener('click', async () => { 136 const activeTab = document.querySelector('.preset-tab.active'); 137 if (!activeTab || activeTab.dataset.chartab !== 'group') return; 138 // Get default model from current session as fallback 139 const _defaultModel = (window.sessionModule && window.sessionModule.getSessions) ? 140 (() => { 141 const s = window.sessionModule.getSessions().find(x => x.id === window.sessionModule.getCurrentSessionId()); 142 if (s) return { mid: s.model, display: s.model.split('/').pop(), url: s.endpoint_url, endpointId: '' }; 143 return null; 144 })() : null; 145 146 const picked = _groupParticipants.map(p => { 147 let m = p.model ? { ...p.model } : (_defaultModel ? { ..._defaultModel } : null); 148 if (!m || !m.url) { 149 console.warn('[group] Participant has no valid model:', p); 150 return null; 151 } 152 if (p.character) m.character = { characterId: p.character.id, characterName: p.character.name, characterPrompt: p.character.prompt }; 153 return m; 154 }
154).filter(Boolean); 155 156 if (picked.length < 2) { uiModule.showToast('Need at least 2 participants â add models or characters'); return; } 157 158 const modal = document.getElementById('custom-preset-modal'); 159 if (modal) modal.classList.add('hidden'); 160 161 setActive(true); 162 if (window._syncGroupIndicator) window._syncGroupIndicator(true); 163 if (window.sessionModule) window.sessionModule.setCurrentSessionId(null); 164 const box = document.getElementById('chat-history'); 165 if (box) box.innerHTML = ''; 166 167 await startGroup(picked, 'group-' + Date.now()); 168 169 // Auto-save as preset if 2+ participants 170 if (picked.length >= 2) { 171 const presetData = { 172 id: 'grp-' + Date.now(), 173 name: picked.map(p => p._groupName || p.character?.characterName || p.display).join(' & '), 174 mode: _mode, 175 participants: picked.map(p => ({ 176 modelId: p.mid, 177 modelDisplay: p.display, 178 characterId: p.character?.characterId || null, 179 characterName: p.character?.characterName || null, 180 })), 181 }; 182 try { 183 const existing = await fetch(API_BASE + '/api/presets/groups', { credentials: 'same-origin' }).then(r => r.json()); 184 const groups = existing.groups || []; 185 // Don't duplicate if same participants 186 const sig = presetData.participants.map(p => p.modelId + ':' + (p.characterId || '')).sort().join(','); 187 const exists = groups.some(g => (g.participants || []).map(p => p.modelId + ':' + (p.characterId || '')).sort().join(',') === sig); 188 if (!exists) { 189 groups.push(presetData); 190 await fetch(API_BASE + '/api/presets/groups', { 191 method: 'POST', credentials: 'same-origin', 192 headers: { 'Content-Type': 'application/json' }, 193 body: JSON.stringify({ groups }), 194 }); 195 } 196 } catch (e) {} 197 } 198 199 uiModule.showToast('Group chat ready â ' + picked.length + ' participants'); 200 }); 201 202 const groupTab = document.querySelector('.preset-tab[data-chartab="group"]'); 203 // whenever a user navigates to the Group tab 204 if (groupTab) groupTab.addEventListener('click', () => { 205 _modelsCache = null; 206 if (startBtn) startBtn.textContent = 'Start Group'; 207 _loadGroupPresets(); 208 209 const isGroupTabUnInitialized = 210 _groupParticipants.length === 0 && participantsEl.children.length === 0; 211 212 if (isGroupTabUnInitialized) { 213 setTimeout(() => addBtn.click(), 100); 214 } else { 215 // queue this asynchronously since repopulating the selection drop-downs 216 // do not need to be visible right away; it can be safely delayed before 217 // the next event loop 218 queueMicrotask(() => { 219 repopulateExistingSelections(); 220 }) 221 } 222 }); 223 224 async function repopulateExistingSelections() { 225 const EMPTY = ""; 226 227 const characterSelections = participantsEl.querySelectorAll("select.preset-input[data-selection-type=character]"); 228 const modelSelections = participantsEl.querySelectorAll("select.preset-input[data-selection-type=model]"); 229 230 if (characterSelections.length !== 0) { 231 const characters = await _getCharacterList(); 232 233 characterSelections.forEach((characterSelection) => { 234 235 const chosenCharacter = characterSelection.value; 236 const isChosenCharacterExisting = chosenCharacter !== EMPTY 237 && characters.findIndex((char) => char.id === chosenCharacter) !== -1; 238 239 characterSelection.innerHTML = '<option value="">Empty...</option>' + 240 characters.map(c => '<option value="' + c.id + '">' + uiModule.esc(c.name) + '</option>').join(''); 241 if (isChosenCharacterExisting) { 242 characterSelection.value = chosenCharacter; 243 } 244 }); 245 } 246 247 if (modelSelections.length !== 0) { 248 const models = await _getModels(); 249 250 modelSelections.forEach((modelSelection) => { 251 const chosenModel = modelSelection.value; 252 const isChosenModelExisting = chosenModel !== EMPTY 253 && models.findIndex((model) => model.mid === chosenModel) !== -1; 254 255 modelSelection.innerHTML = '<option value="">Modelâ¦</option>' + 256 models.map(m => '<option value="' + m.mid + '">' + uiModule.esc(m.display) + '</option>').join(''); 257 if (isChosenModelExisting) { 258 modelSelection.value = chosenModel; 259 } 260 }); 261 } 262 } 263 264 // Load and render saved group presets 265 async function _loadGroupPresets() { 266 try { 267 const res = await fetch(API_BASE + '/api/presets/groups', { credentials: 'same-origin' }); 268 const data = await res.json(); 269 const groups = data.groups || []; 270 // Render presets above participant list
271 let presetsDiv = document.getElementById('group-presets-list'); 272 if (!presetsDiv) { 273 presetsDiv = document.createElement('div'); 274 presetsDiv.id = 'group-presets-list'; 275 presetsDiv.style.cssText = 'display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px;'; 276 participantsEl.parentNode.insertBefore(presetsDiv, participantsEl); 277 } 278 presetsDiv.innerHTML = ''; 279 if (groups.length === 0) return; 280 groups.forEach((g, idx) => { 281 const chip = document.createElement('button'); 282 chip.className = 'preset-save-btn'; 283 chip.style.cssText = 'padding:3px 10px;font-size:11px;background:color-mix(in srgb, var(--fg) 5%, transparent);border:1px solid var(--border);'; 284 const chipLabel = document.createElement('span'); 285 chipLabel.textContent = g.name || 'Group ' + (idx + 1); 286 chip.appendChild(chipLabel); 287 const chipX = document.createElement('span'); 288 chipX.textContent = ' \u00d7'; 289 chipX.style.cssText = 'opacity:0.4;margin-left:4px;cursor:pointer;'; 290 chipX.addEventListener('click', (ev) => { 291 ev.stopPropagation(); 292 groups.splice(idx, 1); 293 fetch(API_BASE + '/api/presets/groups', { 294 method: 'POST', credentials: 'same-origin', 295 headers: { 'Content-Type': 'application/json' }, 296 body: JSON.stringify({ groups }), 297 }).then(() => _loadGroupPresets()); 298 }); 299 chip.appendChild(chipX); 300 chip.title = (g.participants || []).map(p => p.characterName || p.modelDisplay || '?').join(', '); 301 chip.addEventListener('click', async () => { 302 // Load preset participants 303 const [models, chars] = await Promise.all([_getModels(), _getCharacterList()]); 304 _groupParticipants.length = 0; 305 (g.participants || []).forEach(p => { 306 const model = models.find(m => m.mid === p.modelId) || models[0]; 307 const entry = { model: model || null, character: null }; 308 if (p.characterId) { 309 entry.character = chars.find(c => c.id === p.characterId) || null; 310 } 311 if (entry.model) _groupParticipants.push(entry); 312 }); 313 _mode = g.mode || 'parallel'; 314 _render(); 315 }); 316 // Long-press / right-click to delete 317 chip.addEventListener('contextmenu', async (e) => { 318 e.preventDefault(); 319 if (await window.styledConfirm('Delete preset "' + (g.name || 'Group') + '"?', { confirmText: 'Delete', danger: true })) { 320 groups.splice(idx, 1); 321 fetch(API_BASE + '/api/presets/groups', { 322 method: 'POST', credentials: 'same-origin', 323 headers: { 'Content-Type': 'application/json' }, 324 body: JSON.stringify({ groups }), 325 }).then(() => _loadGroupPresets()); 326 } 327 }); 328 presetsDiv.appendChild(chip); 329 }); 330 } catch (e) { console.warn('[group] Failed to load presets:', e); } 331 } 332 // Restore button text when switching away from Group tab 333 document.querySelectorAll('.preset-tab[data-chartab]').forEach(tab => { 334 if (tab.dataset.chartab !== 'group') { 335 tab.addEventListener('click', () => { 336 if (startBtn) startBtn.textContent = 'Start'; 337 }); 338 } 339 }); 340} 341 342async function _getCharacterList() { 343 // Built-in characters from PROMPT_TEMPLATES 344 const chars = PROMPT_TEMPLATES.filter(t => t.isCharacter).map(t => ({ 345 id: t.id, name: t.name, prompt: t.prompt, 346 })); 347 // Load user templates and wait for them before returning. 348 // The endpoint returns a JSON array directly (not {templates:[...]}). 349 // All user templates are personas by definition â no isCharacter filter needed. 350 try { 351 const r = await fetch(API_BASE + '/api/presets/templates', { credentials: 'same-origin' }); 352 const data = await r.json(); 353 const templates = Array.isArray(data) ? data : (data.templates || []); 354
355 templates.forEach(t => { 356 if (t.id && t.name && !chars.find(c => c.id === t.id)) { 357 chars.push({ id: t.id, name: t.name, prompt: t.system_prompt || t.prompt || '' }); 358 } 359 }); 360 } catch (e) {} 361 362 // Also merge in-memory templates from presets.js â these may include 363 // newly created characters whose async save-to-API hasn't completed yet. 364 const memTemplates = getUserTemplates(); 365 366 if (Array.isArray(memTemplates)) { 367 memTemplates.forEach(t => { 368 if (t.id && t.name && !chars.find(c => c.id === t.id)) { 369 chars.push({ id: t.id, name: t.name, prompt: t.system_prompt || t.prompt || '' }); 370 } 371 }); 372 } 373 374 return chars; 375} 376 377export function isActive() { return _active; } 378export function setActive(v) { _active = v; } 379export function getMode() { return _mode; } 380export function setMode(m) { _mode = m; } 381 382// ââ Model Picker âââââââââââââââââââââââââââââââââââââ 383 384export async function showModelPicker() { 385 return new Promise((resolve) => { 386 const overlay = document.createElement('div'); 387 overlay.className = 'modal'; 388 overlay.id = 'group-model-picker'; 389 390 const content = document.createElement('div'); 391 content.className = 'modal-content'; 392 content.style.width = 'min(480px, 92vw)'; 393 394 // Header 395 const header = document.createElement('div'); 396 header.className = 'modal-header'; 397 header.innerHTML = '<h4>Group Chat â Select Models</h4>'; 398 const closeBtn = document.createElement('button'); 399 closeBtn.className = 'close-btn'; 400 closeBtn.innerHTML = '✖'; 401 closeBtn.addEventListener('click', () => { overlay.remove(); resolve(null); }); 402 header.appendChild(closeBtn); 403 404 // Body 405 const body = document.createElement('div'); 406 body.className = 'modal-body'; 407 408 // Mode toggle 409 const modeRow = document.createElement('div'); 410 modeRow.style.cssText = 'display:flex;gap:8px;margin-bottom:10px;align-items:center;font-size:12px;'; 411 modeRow.innerHTML = ` 412 <label style="display:flex;align-items:center;gap:4px;cursor:pointer;"> 413 <input type="radio" name="group-mode" value="parallel" ${_mode === 'parallel' ? 'checked' : ''}> All respond 414 </label> 415 <label style="display:flex;align-items:center;gap:4px;cursor:pointer;"> 416 <input type="radio" name="group-mode" value="round-robin" ${_mode === 'round-robin' ? 'checked' : ''}> Round-robin 417 </label> 418 `; 419 body.appendChild(modeRow); 420 421 // Search 422 const search = document.createElement('input'); 423 search.type = 'text'; 424 search.placeholder = 'Filter modelsâ¦'; 425 search.className = 'memory-search-input'; 426 search.style.marginBottom = '8px'; 427 body.appendChild(search); 428 429 // Model list 430 const list = document.createElement('div'); 431 list.style.cssText = 'max-height:300px;overflow-y:auto;display:flex;flex-direction:column;gap:2px;'; 432 body.appendChild(list); 433 434 // Selected count + start button 435 const footer = document.createElement('div'); 436 footer.style.cssText = 'display:flex;align-items:center;justify-content:space-between;margin-top:10px;'; 437 footer.innerHTML = ` 438 <span id="group-selected-count" style="font-size:11px;opacity:0.5;">0 selected</span> 439 <button id="group-start-btn" class="btn-primary" disabled style="padding:6px 16px;font-size:12px;">Start Group Chat</button> 440 `; 441 body.appendChild(footer); 442 443 content.appendChild(header); 444 content.appendChild(body); 445 overlay.appendChild(content); 446 overlay.style.display = 'flex'; 447 document.body.appendChild(overlay); 448 449 // Get all available models â try cached first, fetch if empty 450 const selected = new Set(); 451 let _cachedModels = null; 452 async function getAllModels() { 453 if (_cachedModels) return _cachedModels; 454 let items = (window.modelsModule && window.modelsModule.getCachedItems) ? window.modelsModule.getCachedItems() : []; 455 // Fallback: fetch from API if cache is empty 456 if (!items || items.length === 0) { 457 try { 458 const res = await fetch(API_BASE + '/api/models', { credentials: 'same-origin' }); 459 const data = await res.json(); 460 items = data.items || []; 461 } catch (e) { console.warn('[group] Failed to fetch models:', e); } 462 } 463 const result = []; 464 const seen = new Set();
465 items.forEach(item => { 466 if (item.offline) return; 467 (item.models || []).concat(item.models_extra || []).forEach((mid, i) => { 468 if (seen.has(mid)) return; 469 seen.add(mid); 470 const display = ((item.models_display || []).concat(item.models_extra_display || []))[i] || mid; 471 result.push({ mid, display: display.split('/').pop(), url: item.url, endpointId: item.endpoint_id, epName: item.endpoint_name || '' }); 472 }); 473 }); 474 _cachedModels = sortModelObjects(result); 475 return _cachedModels; 476 } 477 478 async function render(filter) { 479 list.innerHTML = '<div style="opacity:0.4;padding:8px;font-size:12px;">Loading modelsâ¦</div>'; 480 const all = await getAllModels(); 481 const q = (filter || '').toLowerCase(); 482 all.forEach(m => { 483 if (q && !m.mid.toLowerCase().includes(q) && !m.display.toLowerCase().includes(q) && !m.epName.toLowerCase().includes(q)) return; 484 const row = document.createElement('div'); 485 row.className = 'memory-item'; 486 row.style.cssText = 'padding:6px 8px;cursor:pointer;' + (selected.has(m.mid) ? 'background:color-mix(in srgb, var(--accent, var(--red)) 12%, transparent);' : ''); 487 const logo = providerLogo(m.mid); 488 row.innerHTML = ` 489 <input type="checkbox" ${selected.has(m.mid) ? 'checked' : ''} style="margin-right:6px;"> 490 ${logo ? '<span style="opacity:0.5;margin-right:4px;">' + logo + '</span>' : ''} 491 <span style="flex:1;font-size:12px;">${uiModule.esc(m.display)}</span> 492 <span style="font-size:10px;opacity:0.3;">${uiModule.esc(m.epName)}</span> 493 `; 494 row.addEventListener('click', (e) => { 495 if (e.target.tagName === 'INPUT') return; 496 const cb = row.querySelector('input[type=checkbox]'); 497 cb.checked = !cb.checked; 498 cb.dispatchEvent(new Event('change')); 499 }); 500 row.querySelector('input').addEventListener('change', (e) => { 501 if (e.target.checked) { 502 if (selected.size >= 8) { e.target.checked = false; uiModule.showToast('Max 8 models'); return; } 503 selected.add(m.mid); 504 } else { 505 selected.delete(m.mid); 506 } 507 document.getElementById('group-selected-count').textContent = selected.size + ' selected'; 508 document.getElementById('group-start-btn').disabled = selected.size < 2; 509 row.style.background = selected.has(m.mid) ? 'color-mix(in srgb, var(--accent, var(--red)) 12%, transparent)' : ''; 510 }); 511 list.appendChild(row); 512 }); 513 } 514 515 search.addEventListener('input', () => render(search.value)); 516 render(); 517 518 // Mode toggle 519 modeRow.querySelectorAll('input[name=group-mode]').forEach(r => { 520 r.addEventListener('change', () => { _mode = r.value; }); 521 }); 522 523 // Start button 524 document.getElementById('group-start-btn').addEventListener('click', async () => { 525 const all = await getAllModels(); 526 const picked = all.filter(m => selected.has(m.mid)); 527 528 // Step 2: Character assignment 529 body.innerHTML = ''; 530 const stepTitle = document.createElement('div'); 531 stepTitle.style.cssText = 'font-size:12px;opacity:0.5;margin-bottom:8px;'; 532 stepTitle.textContent = 'Assign characters (optional)'; 533 body.appendChild(stepTitle); 534 535 // Build character options 536 const characters = await _getCharacterList(); 537 const assignments = {}; // mid -> {characterId, characterName, characterPrompt} 538 539 for (const m of picked) { 540 const row = document.createElement('div'); 541 row.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border);'; 542 const logo = providerLogo(m.mid); 543 row.innerHTML = ` 544 ${logo ? '<span style="opacity:0.5;">' + logo + '</span>' : ''} 545 <span style="flex:1;font-size:12px;font-weight:500;">${uiModule.esc(m.display)}</span> 546 `; 547 const sel = document.createElement('select'); 548 sel.style.cssText = 'font-size:11px;padding:3px 6px;border-radius:4px;border:1px solid var(--border);background:var(--bg);color:var(--fg);max-width:140px;'; 549 let optsHtml = '<option value="">No character</option>';
550 characters.forEach(c => { 551 optsHtml += `<option value="${c.id}">${uiModule.esc(c.name)}</option>`; 552 }); 553 sel.innerHTML = optsHtml; 554 sel.addEventListener('change', () => { 555 if (sel.value) { 556 const ch = characters.find(c => c.id === sel.value); 557 assignments[m.mid] = { characterId: ch.id, characterName: ch.name, characterPrompt: ch.prompt }; 558 } else { 559 delete assignments[m.mid]; 560 } 561 }); 562 row.appendChild(sel); 563 body.appendChild(row); 564 } 565 566 // Go button 567 const goBtn = document.createElement('button'); 568 goBtn.className = 'btn-primary'; 569 goBtn.style.cssText = 'margin-top:10px;padding:6px 16px;font-size:12px;width:100%;'; 570 goBtn.textContent = 'Start Group Chat'; 571 goBtn.addEventListener('click', () => { 572 // Attach character info to picked models 573 picked.forEach(m => { 574 if (assignments[m.mid]) { 575 m.character = assignments[m.mid]; 576 } 577 }); 578 overlay.remove(); 579 resolve(picked); 580 }); 581 body.appendChild(goBtn); 582 }); 583 584 // Click outside to close 585 overlay.addEventListener('click', (e) => { if (e.target === overlay) { overlay.remove(); resolve(null); } }); 586 search.focus(); 587 }); 588} 589 590// ââ Start / Stop âââââââââââââââââââââââââââââââââââââ 591 592export async function startGroup(models, parentSessionId) { 593 _models = models; 594 _active = true; 595 _roundRobinIdx = 0; 596 _participantSessions = []; 597 598 // Create a real parent session for persistence 599 const groupName = '[GRP] ' + models.map(m => m._groupName || m.character?.characterName || m.display).join(', '); 600 try { 601 const pfd = new FormData(); 602 pfd.append('name', groupName); 603 pfd.append('endpoint_url', models[0].url); 604 pfd.append('model', models[0].mid); 605 pfd.append('skip_validation', 'true'); 606 if (models[0].endpointId) pfd.append('endpoint_id', models[0].endpointId); 607 const pres = await fetch(`${API_BASE}/api/session`, { method: 'POST', body: pfd, credentials: 'same-origin' }); 608 const pdata = await pres.json(); 609 _parentSessionId = pdata.id; 610 // Register as group session for sidebar icon 611 try { 612 const storedGroupSessions = Storage.getJSON('odysseus-group-sessions', []); 613 const gids = Array.isArray(storedGroupSessions) ? storedGroupSessions : []; 614 if (!gids.includes(_parentSessionId)) { gids.push(_parentSessionId); localStorage.setItem('odysseus-group-sessions', JSON.stringify(gids)); } 615 } catch (e) {} 616 } catch (e) { 617 console.error('[group] Failed to create parent session:', e); 618 _parentSessionId = parentSessionId || 'group-' + Date.now(); 619 } 620 621 // Create a hidden session per model 622 for (const m of models) { 623 try { 624 const fd = new FormData(); 625 fd.append('name', `[GRP] ${m.display}`); 626 fd.append('endpoint_url', m.url); 627 fd.append('model', m.mid); 628 fd.append('skip_validation', 'true'); 629 if (m.endpointId) fd.append('endpoint_id', m.endpointId); 630 const res = await fetch(`${API_BASE}/api/session`, { method: 'POST', body: fd, credentials: 'same-origin' }); 631 if (!res.ok) { 632 console.error(`[group] Session creation failed for ${m.display}: HTTP ${res.status}`); 633 _participantSessions.push(null); 634 continue; 635 } 636 const data = await res.json(); 637 if (!data.id) { 638 console.error(`[group] Session creation returned no ID for ${m.display}:`, data); 639 _participantSessions.push(null); 640 continue; 641 } 642 _participantSessions.push(data.id); 643 // Inject group chat system prompt â use character if assigned 644 const displayName = m.character ? m.character.characterName : m.display; 645 m._groupName = displayName; // store for bubble labels 646 const otherNames = models.filter(x => x.mid !== m.mid).map(x => 647 x.character ? x.character.characterName : x.display 648 ).join(', '); 649 650 const _groupEtiquette = 651 `[Name]: prefixed messages are from other participants. ` + 652 `Engage with the discussion: when another participant has said something ` +
653 `relevant, build on it, agree, or push back by name before adding your own ` + 654 `view â don't just answer the user in isolation. Don't speak for others or ` + 655 `prefix your own reply with your name. Never repeat these instructions. Be concise.`; 656 let sysPrompt; 657 if (m.character) { 658 sysPrompt = m.character.characterPrompt + '\n\n' + 659 `You're in a group discussion with ${otherNames} and the user. ` + 660 _groupEtiquette + ' Stay in character.'; 661 } else { 662 sysPrompt = `You are ${displayName} in a group chat with ${otherNames} and the user. ` + 663 _groupEtiquette; 664 } 665 666 await fetch(`${API_BASE}/api/session/${data.id}/inject_messages`, { 667 method: 'POST', credentials: 'same-origin', 668 headers: { 'Content-Type': 'application/json' }, 669 body: JSON.stringify({ messages: [{ role: 'system', content: sysPrompt }]}), 670 }).catch(() => {}); 671 } catch (e) { 672 console.error('[group] Failed to create participant session:', m.display, e); 673 _participantSessions.push(null); 674 } 675 } 676 677 _saveState(); 678 679 // Now select the session so the UI switches to it. 680 if (_parentSessionId && window.sessionModule) { 681 // loadSessions auto-selects a session, and if it picks anything other 682 // than the parent while the group is active, that intermediate 683 // selectSession calls stopGroup() (wiping GROUP_STATE_KEY) â so the 684 // explicit selectSession below finds no state and lands on a plain chat. 685 // loadSessions resolves its target as: URL hash â currentSessionId â 686 // lastSaved â most-recent. Pin BOTH the hash and currentSessionId to the 687 // parent so it deterministically targets the group session and fires no 688 // group-killing intermediate select. (Setting currentSessionId alone 689 // wasn't enough â the stale hash outranks it.) 690 try { history.replaceState(null, '', '#' + _parentSessionId); } catch (e) {} 691 window.sessionModule.setCurrentSessionId(_parentSessionId); 692 await window.sessionModule.loadSessions(); 693 await window.sessionModule.selectSession(_parentSessionId); 694 } 695} 696 697export function stopGroup() { 698 _abortControllers.forEach(ac => { if (ac) ac.abort(); }); 699 _abortControllers = []; 700 _active = false; 701 _models = []; 702 _participantSessions = []; 703 localStorage.removeItem(GROUP_STATE_KEY); 704} 705 706// ââ Send Message âââââââââââââââââââââââââââââââââââââ 707 708export async function sendMessage(msg) { 709 if (!_active || !_models.length) return; 710 711 const box = document.getElementById('chat-history'); 712 if (!box) return; 713 714 // Save user message to parent session for persistence 715 if (_parentSessionId) { 716 fetch(`${API_BASE}/api/session/${_parentSessionId}/inject_messages`, { 717 method: 'POST', credentials: 'same-origin', 718 headers: { 'Content-Type': 'application/json' }, 719 body: JSON.stringify({ messages: [{ role: 'user', content: msg }] }), 720 }).catch(() => {}); 721 } 722 723 if (_mode === 'parallel') { 724 await _sendParallel(msg, box); 725 } else { 726 await _sendRoundRobin(msg, box); 727 } 728} 729 730function _createGroupBubble(model, box) { 731 const wrap = document.createElement('div'); 732 wrap.className = 'msg msg-ai msg-group'; 733 wrap.style.position = 'relative'; 734 735 // Role label â use character name if assigned, otherwise model name 736 const roleLabel = model._groupName || (model.character ? model.character.characterName : chatRenderer.shortModel(model.mid)); 737 const roleTs = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); 738 wrap.innerHTML = `<div class="role">${uiModule.esc(roleLabel)} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`; 739 chatRenderer.applyModelColor(wrap.querySelector('.role'), model.mid); 740 741 // Spinner â identical to chat.js line 3062 742 const spinner = spinnerModule.create('Generating response', 'right'); 743 const bodyDiv = wrap.querySelector('.body'); 744 bodyDiv.appendChild(spinner.createElement()); 745 spinner.start(); 746 wrap._spinner = spinner; 747 748 box.appendChild(wrap); 749 return wrap; 750} 751 752async function _sendParallel(msg, box) { 753 const holders = _models.map(m => _createGroupBubble(m, box)); 754 uiModule.scrollHistory(); 755 756 // Stream all models in parallel 757 _abortControllers = _models.map(() => new AbortController()); 758 const results = await Promise.allSettled(_models.map((m, i) => 759 _streamToHolder(i, _participantSessions[i], msg, holders[i], _abortControllers[i]) 760 )); 761 _abortControllers = []; 762 763 // They answered simultaneously so they couldn't react this turn, but inject 764 // each response into the others' sessions so they're aware of each other on 765 // the next message and can remark on it. 766 await _syncAllResponses(holders); 767} 768 769async function _sendRoundRobin(msg, box) { 770 // Randomize who goes first each message â shuffle participant indices 771 // (FisherâYates) instead of a fixed rotation, so the order varies turn to 772 // turn. Each model still takes its turn seeing all responses already given 773 // this round (and prior rounds, via the cross-session injection below), so 774 // later responders can react to earlier ones. 775 const order = _models.map((_, i) => i); 776 for (let i = order.length - 1; i > 0; i--) { 777 const j = Math.floor(Math.random() * (i + 1)); 778 [order[i], order[j]] = [order[j], order[i]]; 779 } 780 for (let turn = 0; turn < order.length; turn++) { 781 const idx = order[turn]; 782 const m = _models[idx]; 783
784 const wrap = _createGroupBubble(m, box); 785 uiModule.scrollHistory(); 786 787 const ac = new AbortController(); 788 _abortControllers = [ac]; 789 await _streamToHolder(idx, _participantSessions[idx], msg, wrap, ac); 790 _abortControllers = []; 791 792 // After each response, inject it into all OTHER participant sessions 793 const response = wrap.dataset.raw || ''; 794 if (response) { 795 for (let j = 0; j < _participantSessions.length; j++) { 796 if (j === idx || !_participantSessions[j]) continue; 797 try { 798 await fetch(`${API_BASE}/api/session/${_participantSessions[j]}/inject_messages`, { 799 method: 'POST', 800 credentials: 'same-origin', 801 headers: { 'Content-Type': 'application/json' }, 802 body: JSON.stringify({ messages: [{ 803 role: 'user', 804 content: `[${m._groupName || m.display}]: ${response}` 805 }]}), 806 }); 807 } catch (e) { console.warn('[group] sync failed:', e); } 808 } 809 } 810 } 811 // Order is randomized per-message now, so _roundRobinIdx no longer drives 812 // turn order; left in state for backward compat only. 813 _saveState(); 814} 815 816/** After parallel responses, inject each model's response into all other sessions. */ 817async function _syncAllResponses(holders) { 818 for (let i = 0; i < holders.length; i++) { 819 const response = holders[i].dataset.raw || ''; 820 if (!response) continue; 821 const model = _models[i]; 822 for (let j = 0; j < _participantSessions.length; j++) { 823 if (j === i || !_participantSessions[j]) continue; 824 try { 825 await fetch(`${API_BASE}/api/session/${_participantSessions[j]}/inject_messages`, { 826 method: 'POST', 827 credentials: 'same-origin', 828 headers: { 'Content-Type': 'application/json' }, 829 body: JSON.stringify({ messages: [{ 830 role: 'user', 831 content: `[${model._groupName || model.display}]: ${response}` 832 }]}), 833 }); 834 } catch (e) { /* silent */ } 835 } 836 } 837} 838 839async function _streamToHolder(modelIdx, sessionId, msg, holderEl, abortCtrl) { 840 if (!sessionId) { 841 holderEl.querySelector('.body').innerHTML = '<i style="opacity:0.5;">[Session creation failed]</i>'; 842 return; 843 } 844 845 const fd = new FormData(); 846 fd.append('message', msg); 847 fd.append('session', sessionId); 848 849 let accumulated = ''; 850 let _buffer = ''; 851 let _firstToken = true; 852 const bodyEl = holderEl.querySelector('.body'); 853 854 try { 855 const res = await fetch(`${API_BASE}/api/chat_stream`, { 856 method: 'POST', 857 body: fd, 858 credentials: 'same-origin', 859 signal: abortCtrl.signal, 860 }); 861 const reader = res.body.getReader(); 862 const decoder = new TextDecoder(); 863 864 while (true) { 865 const { done, value } = await reader.read(); 866 if (done) break; 867 _buffer += decoder.decode(value, { stream: true }); 868 869 // Process complete lines 870 const lines = _buffer.split('\n'); 871 _buffer = lines.pop(); // keep incomplete last line 872 873 for (const line of lines) { 874 if (!line.startsWith('data: ')) continue; 875 if (line === 'data: [DONE]') continue; 876 877 try { 878 const json = JSON.parse(line.slice(6)); 879 880 // Text delta (OpenAI format) 881 if (json.choices?.[0]?.delta?.content) { 882 if (_firstToken) { _firstToken = false; if (holderEl._spinner) { holderEl._spinner.destroy(); delete holderEl._spinner; } bodyEl.innerHTML = ''; } 883 accumulated += json.choices[0].delta.content; 884 bodyEl.innerHTML = markdownModule.processWithThinking( 885 markdownModule.squashOutsideCode(accumulated) 886 ); 887 uiModule.scrollHistory(); 888 } 889 // Text delta (Odysseus format) 890 else if (json.delta !== undefined) { 891 if (_firstToken) { _firstToken = false; if (holderEl._spinner) { holderEl._spinner.destroy(); delete holderEl._spinner; } bodyEl.innerHTML = ''; } 892 // Handle thinking tags from vLLM 893 let _d = json.delta; 894 if (json.thinking) { 895 if (!accumulated.includes('<think>')) _d = '<think>' + _d; 896 } else if (accumulated.includes('<think>') && !accumulated.includes('</think>')) { 897 _d = '</think>' + _d; 898 } 899 accumulated += _d; 900 bodyEl.innerHTML = markdownModule.processWithThinking( 901 markdownModule.squashOutsideCode(accumulated) 902 ); 903 uiModule.scrollHistory(); 904 } 905 // Agent tool events 906 else if (json.type === 'tool_start') { 907 const toolDiv = document.createElement('div'); 908 toolDiv.className = 'agent-tool-event'; 909 toolDiv.style.cssText = 'font-size:11px;opacity:0.5;padding:2px 0;font-family:monospace;'; 910 toolDiv.textContent = `â ${json.tool || 'tool'}${json.command ? ': ' + json.command.substring(0, 60) : ''}`; 911 bodyEl.appendChild(toolDiv); 912 } 913 else if (json.type === 'tool_output') { 914 const outDiv = document.createElement('div'); 915 outDiv.className = 'agent-tool-output'; 916 outDiv.style.cssText = 'font-size:10px;opacity:0.4;padding:2px 0;font-family:monospace;max-height:60px;overflow:hidden;'; 917 outDiv.textContent = (json.output || '').substring(0, 200); 918 bodyEl.appendChild(outDiv); 919 } 920 // Generated image 921 else if (json.type === 'generated_image' && json.url) { 922 const safeImageUrl = chatRenderer.safeDisplayImageSrc(json.url); 923 if (safeImageUrl) { 924 const img = document.createElement('img'); 925 img.src = safeImageUrl; 926 img.style.cssText = 'max-width:100%;border-radius:8px;margin:8px 0;'; 927 img.loading = 'lazy'; 928 bodyEl.appendChild(img); 929 } 930 } 931 // Error 932 else if (json.error) { 933 const errDiv = document.createElement('div'); 934 errDiv.style.cssText = 'color:var(--color-error);font-style:italic;padding:4px 0;'; 935 errDiv.textContent = `[Error: ${json.error}]`; 936 bodyEl.appendChild(errDiv); 937 } 938 } catch (e) { /* skip unparseable */ } 939 } 940 } 941 } catch (e) { 942 if (e.name === 'AbortError') return; 943 console.error('[group] Stream error:', e); 944 bodyEl.innerHTML += '<div style="color:var(--color-error);font-style:italic;">[Stream error]</div>'; 945 } 946 947 // Final render with footer 948 if (accumulated) { 949 bodyEl.innerHTML = markdownModule.processWithThinking( 950 markdownModule.squashOutsideCode(accumulated) 951 ); 952 if (window.hljs) holderEl.querySelectorAll('pre code').forEach(b => window.hljs.highlightElement(b)); 953 if (markdownModule.renderMermaid) markdownModule.renderMermaid(holderEl); 954 holderEl.appendChild(chatRenderer.createMsgFooter(holderEl)); 955 } else if (!bodyEl.querySelector('.agent-tool-event') && !bodyEl.querySelector('img')) { 956 bodyEl.innerHTML = '<i style="opacity:0.5;">[No response]</i>'; 957 } 958 959 holderEl.dataset.raw = accumulated; 960 holderEl.dataset.groupModel = _models[modelIdx].mid; 961 962 // Save response to parent session for persistence 963 if (accumulated && _parentSessionId) { 964 const gName = _models[modelIdx]._groupName || _models[modelIdx].display; 965 fetch(`${API_BASE}/api/session/${_parentSessionId}/inject_messages`, { 966 method: 'POST', credentials: 'same-origin', 967 headers: { 'Content-Type': 'application/json' }, 968 body: JSON.stringify({ messages: [{ 969 role: 'assistant', content: accumulated,
970 metadata: { group_model: gName, model: _models[modelIdx].mid } 971 }]}), 972 }).catch(() => {}); 973 } 974} 975 976// ââ State Persistence ââââââââââââââââââââââââââââââââ 977 978function _saveState() { 979 try { 980 localStorage.setItem(GROUP_STATE_KEY, JSON.stringify({ 981 active: _active, 982 mode: _mode, 983 models: _models, 984 participantSessions: _participantSessions, 985 parentSessionId: _parentSessionId, 986 roundRobinIdx: _roundRobinIdx, 987 })); 988 } catch (e) {} 989} 990 991export function restoreState(sessionId) { 992 try { 993 const s = JSON.parse(localStorage.getItem(GROUP_STATE_KEY) || 'null'); 994 if (s && s.active && s.parentSessionId === sessionId) { 995 _active = true; 996 _mode = s.mode || 'parallel'; 997 _models = s.models || []; 998 _participantSessions = s.participantSessions || []; 999 _parentSessionId = s.parentSessionId; 1000 _roundRobinIdx = s.roundRobinIdx || 0; 1001 return true; 1002 } 1003 } catch (e) {} 1004 return false; 1005} 1006 1007export function getModels() { return _models; } 1008export function getModelCount() { return _models.length; } 1009 1010const groupModule = { 1011 init, isActive, setActive, getMode, setMode, showModelPicker, 1012 startGroup, stopGroup, sendMessage, restoreState, 1013 getModels, getModelCount, 1014}; 1015 1016export default groupModule; 1017window.groupModule = groupModule;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.