PageSourceSearch

https://odysseus-lxcq.onrender.com/static/js/group.js

js odysseus-lxcq.onrender.com collected 2026-10-03 11:44:26 UTC 43,568 bytes, 1,017 lines download raw bytes

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">&times;</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 = '&#x2716;';
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.