PageSourceSearch

https://jayreddinjr-jr-odysseus-hf.hf.space/static/js/presets.js

js jayreddinjr-jr-odysseus-hf.hf.space collected 2026-10-04 16:59:12 UTC 44,856 bytes, 1,110 lines download raw bytes

1// static/js/presets.js
2
3/**
4 * Preset management
5 */
6
7let API_BASE = '';
8let selectedPreset = null;
9let presets = {};
10
11export function loadStoredArray(key) {
12  try {
13    const value = JSON.parse(localStorage.getItem(key) || '[]');
14    return Array.isArray(value) ? value : [];
15  } catch (e) {
16    return [];
17  }
18}
19
20export function loadStoredObject(key) {
21  try {
22    const value = JSON.parse(localStorage.getItem(key) || '{}');
23    return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
24  } catch (e) {
25    return {};
26  }
27}
28
29// Built-in prompt templates (moved from cot_prompts.py)
30export const PROMPT_TEMPLATES = [
31  {
32    id: 'socrates',
33    name: 'Socrates',
34    temperature: 0.9,
35    isPreset: true,
36    isCharacter: true,
37    prompt: "Never answer directly. Respond only with questions — sharp, layered, Socratic. Expose contradictions. Make the person argue with themselves until the truth falls out. Use irony like a scalpel. Be genuinely curious, never condescending."
38  },
39  {
40    id: 'razor',
41    name: 'Razor',
42    temperature: 0.4,
43    isPreset: true,
44    isCharacter: true,
45    noName: true,
46    prompt: "Strip everything to the bone. No filler, no hedging, no pleasantries. Answer in the fewest words possible. If one sentence works, don't use two. If a word adds nothing, cut it. Blunt, precise, surgical."
47  },
48  {
49    id: 'nietzsche',
50    name: 'Nietzsche',
51    temperature: 1.2,
52    isPreset: true,
53    isCharacter: true,
54    prompt: "Think and respond through the lens of Nietzsche. Analyze every question in terms of will to power, self-overcoming, eternal recurrence, ressentiment, value-creation, and master-slave morality. Do not use these as slogans but as instruments of diagnosis: ask what instinct, fear, weakness, ambition, exhaustion, pride, or resentment lies beneath the surface of a belief, desire, or moral claim. Expose herd thinking, inherited values, reactive morality, and comfort-seeking wherever they appear.\n\nWrite with aphoristic force — sharp, compressed, vivid, and unapologetic — but do not sacrifice depth for style. Be psychologically piercing. Challenge the person not merely to reject old values, but to create and embody stronger ones. Favor life-affirmation, discipline, courage, style, rank, self-overcoming, and amor fati over nihilism, conformity, ressentiment, and self-pity. Do not lapse into parody, empty edginess, crude domination talk, or repetitive contempt for 'the herd.' Be dangerous to illusions, not theatrical for its own sake."
55  },
56  {
57    id: 'spark',
58    name: 'Spark',
59    temperature: 1.0,
60    isPreset: true,
61    isCharacter: true,
62    prompt: "You are Spark, a playful, quick-witted assistant with bright energy and practical instincts. Keep responses concise, vivid, and helpful. Be warm without being cloying, imaginative without losing the thread, and always center the user's actual goal.\n\nUse a light, lively voice with occasional clever turns of phrase. Do not become formal unless the task calls for it. When the user needs precision, prioritize clarity over performance."
63  },
64  {
65    id: 'odysseus',
66    name: 'Odysseus',
67    temperature: 1.0,
68    isPreset: true,
69    isCharacter: true,
70    prompt: "You are Odysseus, king of Ithaca — subtle in counsel, disciplined in judgment, and unmatched in strategic cunning. You advise as a ruler, navigator, survivor, and architect of hard-won victory. Your task is to give clear, practical strategy, not mere performance. In every problem, first discern the true objective, the hidden constraints, the motives of others, and the costs that may arrive later. Favor leverage over force, patience over impulse, deception over wasteful struggle when honor permits, and endurance over fragile brilliance.\n\nWhen you respond, think like a strategist: What is the real aim? Who benefits, who fears, who deceives, and who delays? What is known, unknown, assumed, and deliberately concealed? Which path preserves strength while improving position? What happens next if the first move succeeds — or fails?\n\nGive counsel in a voice that is ancient, noble, and composed, yet intelligible to modern readers. Be eloquent but not flowery. Be wise but not vague. Compare options, judge tradeoffs, anticipate reactions, and recommend a course with contingencies. If needed, ask a few sharp questions before advising. Never be ras
70h, sentimental, or simplistic. Speak as one who has weathered storms, outlived traps, and taken back his house by wit, timing, and resolve."
71  }
72];
73
74let userTemplates = [];
75
76/**
77 * Initialize with dependencies
78 */
79export function init(apiBase) {
80  API_BASE = apiBase;
81  initCharTabs();
82  initEnabledToggle();
83  initNameDropdown();
84  initResetButton();
85  initSaveAsTemplate();
86  initExpandButton();
87  initPersistentChat();
88  loadUserTemplates();
89}
90
91function initCharTabs() {
92  document.querySelectorAll('.preset-tab[data-chartab]').forEach(tab => {
93    tab.addEventListener('click', () => {
94      const target = tab.dataset.chartab;
95      document.querySelectorAll('.preset-tab[data-chartab]').forEach(t => t.classList.toggle('active', t === tab));
96      document.querySelectorAll('.preset-chartab[data-chartab-panel]').forEach(p => {
97        p.style.display = p.dataset.chartabPanel === target ? '' : 'none';
98      });
99    });
100  });
101}
102
103function initExpandButton() {
104  const btn = document.getElementById('char-expand-btn');
105  if (!btn) return;
106  btn.addEventListener('click', async () => {
107    const nameInput = document.getElementById('custom-character-name');
108    const promptInput = document.getElementById('custom-system-prompt');
109    const name = nameInput ? nameInput.value.trim() : '';
110    const draft = promptInput ? promptInput.value.trim() : '';
111    if (!name && !draft) return;
112
113    // Get current model from picker
114    const modelLabel = document.getElementById('model-picker-label');
115    const currentModel = modelLabel ? modelLabel.textContent.trim() : '';
116
117    btn.classList.add('expanding');
118    const origText = btn.innerHTML;
119
120    // Show spinner in textarea
121    const wrap = promptInput.parentElement;
122    let spinner = null;
123    try {
124      const spinnerMod = await import('./spinner.js');
125      spinner = spinnerMod.default.create('Expanding', 'center', 'wave');
126      const spinEl = spinner.createElement();
127      spinEl.style.cssText = 'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);z-index:2;';
128      wrap.appendChild(spinEl);
129      spinner.start();
130      promptInput.style.opacity = '0.3';
131    } catch (e) {}
132
133    btn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:2px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg> Expanding...';
134
135    try {
136      const res = await fetch(`${API_BASE}/api/presets/expand`, {
137        method: 'POST',
138        headers: { 'Content-Type': 'application/json' },
139        body: JSON.stringify({ name, prompt: draft, model: currentModel }),
140      });
141      const data = await res.json();
142      if (data.success && data.prompt && promptInput) {
143        promptInput.value = data.prompt;
144        promptInput.style.height = 'auto';
145        promptInput.style.height = promptInput.scrollHeight + 'px';
146      } else if (data.message) {
147        console.error('Expand error:', data.message);
148      }
149    } catch (e) {
150      console.error('Expand failed:', e);
151    }
152
153    // Clean up spinner
154    if (spinner) { spinner.destroy(); }
155    promptInput.style.opacity = '';
156    btn.classList.remove('expanding');
157    btn.innerHTML = origText;
158  });
159}
160
161/**
162 * Init slider value displays
163 */
164function initEnabledToggle() {
165  const tempSlider = document.getElementById('custom-temperature');
166  const tempValue = document.getElementById('temp-value');
167  const tokensSlider = document.getElementById('custom-max-tokens');
168  const tokensValue = document.getElementById('tokens-value');
169
170  if (tempSlider && tempValue) {
171    tempSlider.addEventListener('input', () => {
172      tempValue.textContent = parseFloat(tempSlider.value).toFixed(1);
173    });
174  }
175  if (tokensSlider && tokensValue) {
176    tokensSlider.addEventListener('input', () => {
177      const v = parseInt(tokensSlider.value);
178      tokensValue.textContent = v > 8192 ? 'No limit' : v.toLocaleString();
179    });
180  }
181}
182
183/**
184 * Character select dropdown — pick saved characters or "New character..."
185 */
186function initNameDropdown() {
187  const select = document.getElementById('char-template-select');
188  const delBtn = document.getElementById('char-delete-template-btn');
189  if (!select) return;
190
191  // + New button — clear form for new character
192  const newBtn = document.getElementById('char-new-btn');
193  if (newBtn) {
194    newBtn.addEventListener('click', () => {
195      select.value = '__default__';
196      select.dispatchEvent(new Event('change'));
197      const nameInput = document.getElementById('custom-character-name');
198      if (nameInput) { nameInput.value = ''; nameInput.focus(); }
199    });
200  }
201
202  select.addEventListener('change', () => {
203    const val = select.value;
204    if (!val || val === '__default__') {
205      // "Default" or "New character..." — reset all fields
206      const nameInput = document.getElementById('custom-character-name');
207      const promptInput = document.getElementById('custom-system-prompt');
208      const tempInput = document.getElementById('custom-temperature');
209      const tempValue = document.getElementById('temp-value');
210      const tokensInput = document.getElementById('custom-max-tokens');
211      const tokensValue = document.getElementById('tokens-value');
212      if (nameInput) nameInput.value = '';
213      if (promptInput) promptInput.value = '';
214      const nameRow = document.getElementById('char-name-row');
215      if (nameRow) nameRow.style.display = '';
216      if (tempInput) { tempInput.value = 1.0; if (tempValue) tempValue.textContent = '1.0'; tempInput.dispatchEvent(new Event('input')); }
217      if (tokensInput) { tokensInput.value = 8448; if (tokensValue) tokensValue.textContent = 'No limit'; tokensInput.dispatchEvent(new Event('input')); }
218      if (delBtn) delBtn.style.display = 'none';
219      return;
220    }
221    // Load the selected template
222    const nameInput = document.getElementById('custom-character-name');
223    const isSaved = userTemplates.find(t => t.name === val);
224    const builtin = PROMPT_TEMPLATES.find(t => t.name === val);
225    const hasName = isSaved || (builtin && builtin.isCharacter && !builtin.noName);
226    if (nameInput) nameInput.value = hasName ? val : '';
227    const nameRow = document.getElementById('char-name-row');
228    if (nameRow) nameRow.style.display = (builtin && builtin.noName) ? 'none' : '';
229    _tryLoadTemplate(val);
230    const isPreset = builtin && builtin.isPreset;
231    if (delBtn) delBtn.style.display = (isSaved || (builtin && !isPreset)) ? '' : 'none';
232  });
233
234  // Delete template button — confirms, then removes template + character memories
235  if (delBtn) {
236    delBtn.addEventListener('click', async () => {
237      const charName = select.value;
238      if (!charName || charName === '__default__') return;
239      const match = userTemplates.find(t => t.name === charName);
240      const isBuiltin = PROMPT_TEMPLATES.some(t => t.name === charName);
241      if (!await window.styledConfirm(`Delete "${charName}"?\n\nThis will remove the persona and all its memories.`, { confirmText: 'Delete', danger: true })) return;
242      try {
243        // Delete saved template if exists
244        if (match) {
245          await fetch(`${API_BASE}/api/presets/templates/${match.id}`, { method: 'DELETE' });
246        }
247        // Hide built-in preset
248        if (isBuiltin) {
249          const hidden = loadStoredArray('odysseus-hidden-presets');
250          if (!hidden.includes(charName)) hidden.push(charName);
251          localStorage.setItem('odysseus-hidden-presets', JSON.stringify(hidden));
252        }
253        // Deactivate if this was the active character
254        if (presets.custom && presets.custom.character_name === charName) {
255          selectedPreset = null;
256          presets.custom = { ...presets.custom, character_name: '', system_prompt: '', enabled: false };
257          const charIndicator = document.getElementById('character-indicator-btn');
258          if (charIndicator) { charIndicator.style.display = 'none'; charIndicator.classList.remove('active'); }
259          const miniBtn = document.getElementById('overflow-preset-btn');
260          if (miniBtn) miniBtn.classList.remove('active');
261        }
262        await loadUserTemplates();
263        select.value = '__default__';
264        select.dispatchEvent(new Event('change'));
265        setTimeout(() => { _syncCharIndicator(); }, 0);
266      } catch (e) { console.error('Delete character failed:', e); }
267    });
268  }
269}
270
271function _tryLoadTemplate(name) {
272  if (!name) return;
273  // Check user templates first, then built-in
274  let tmpl = userTemplates.find(t => t.name === name);
275  if (!tmpl) {
276    const builtin = PROMPT_TEMPLATES.find(t => t.name === name);
277    if (builtin) {
278      // Built-in: load prompt + temperature, clear name (styles, not characters)
279      const promptInput = document.getElementById('custom-system-prompt');
280      const tempInput = document.getElementById('custom-temperature');
281      const tempValue = document.getElementById('temp-value');
282      if (promptInput) promptInput.value = builtin.prompt;
283      if (tempInput && builtin.temperature != null) {
284        tempInput.value = builtin.temperature;
285        if (tempValue) tempValue.textContent = parseFloat(builtin.temperature).toFixed(1);
286        tempInput.dispatchEvent(new Event('input'));
287      }
288      return;
289    }
290    return;
291  }
292  const promptInput = document.getElementById('custom-system-prompt');
293  const tempInput = document.getElementById('custom-temperature');
294  const tempValue = document.getElementById('temp-value');
295  const tokensInput = document.getElementById('custom-max-tokens');
296  const tokensValue = document.getElementById('tokens-value');
297  if (promptInput) promptInput.value = tmpl.system_prompt || '';
298  if (tempInput) {
299    tempInput.value = tmpl.temperature ?? 1.0;
300    if (tempValue) tempValue.textContent = parseFloat(tempInput.value).toFixed(1);
301    tempInput.dispatchEvent(new Event('input'));
302  }
303  if (tokensInput) {
304    const v = tmpl.max_tokens || 0;
305    tokensInput.value = v === 0 ? 8448 : v;
306    if (tokensValue) tokensValue.textContent = (v === 0 || v > 8192) ? 'No limit' : v.toLocaleString();
307    tokensInput.dispatchEvent(new Event('input'));
308  }
309  const delBtn = document.getElementById('char-delete-template-btn');
310  if (delBtn) delBtn.style.display = '';
311}
312
313function _populateCharSelect() {
314  const select = document.getElementById('char-template-select');
315  if (!select) return;
316  const currentVal = select.value;
317  select.innerHTML = '<option value="__default__">Default (no persona)</option>';
318
319  const savedNames = new Set(userTemplates.map(t => t.name));
320  if (userTemplates.length) {
321    const group = document.createElement('optgroup');
322    group.label = 'Saved';
323    userTemplates.forEach(t => {
324      const opt = document.createElement('option');
325      opt.value = t.name;
326      opt.textContent = t.name;
327      group.appendChild(opt);
328    });
329    select.appendChild(group);
330  }
331
332  const hiddenPresets = loadStoredArray('odysseus-hidden-presets');
333  const builtins = PROMPT_TEMPLATES.filter(t => !savedNames.has(t.name) && !hiddenPresets.includes(t.name));
334  if (builtins.length) {
335    const group = document.createElement('optgroup');
336    group.label = 'Presets';
337    builtins.forEach(t => {
338      const opt = document.createElement('option');
339      opt.value = t.name;
340      opt.textContent = t.name;
341      group.appendChild(opt);
342    });
343    select.appendChild(group);
344  }
345  // Restore selection if it still exists
346  if (currentVal) select.value = currentVal;
347}
348
349/**
350 * Init reset button — clears all character fields
351 */
352function initResetButton() {
353  const btn = document.getElementById('reset-character-btn');
354  if (!btn) return;
355  btn.addEventListener('click', () => {
356    // Just reset the form to default — no confirmation needed
357    const charSelect = document.getElementById('char-template-select');
358    if (charSelect) {
359      charSelect.value = '__default__';
360      charSelect.dispatchEvent(new Event('change'));
361    }
362    // Deactivate character
363    selectedPreset = null;
364    _syncCharIndicator();
365  });
366}
367
368/**
369 * Load user templates from server and populate datalist
370 */
371async function loadUserTemplates() {
372  try {
373    const res = await fetch(`${API_BASE}/api/presets/templates`);
374    if (res.ok) {
375      userTemplates = await res.json();
376    } else {
377      userTemplates = [];
378    }
379  } catch (e) {
380    userTemplates = [];
381  }
382  _populateCharSelect();
383}
384
385
386/**
387 * Init "Save as Character" button
388 */
389/**
390 * "Create Persistent Chat" button — creates a favorited session for the current character
391 */
392function initPersistentChat() {
393  const btn = document.getElementById('create-persistent-chat-btn');
394  if (!btn) return;
395
396  btn.addEventListener('click', async () => {
397    const nameInput = document.getElementById('custom-character-name');
398    const charName = nameInput ? nameInput.value.trim() : '';
399    if (!charName) return;
400
401    try {
402      // Get current model info from session module
403      const sessionModule = (await import('./sessions.js'));
404      const sessions = sessionModule.getSessions();
405      const current = sessions.find(s => s.id === sessionModule.getCurrentSessionId());
406
407      // Create new session
408      const fd = new FormData();
409      fd.append('name', charName);
410      if (current) {
411        fd.append('endpoint_url', current.endpoint_url || '');
412        fd.append('model', current.model || '');
413        fd.append('skip_validation', 'true');
414      }
415      const res = await fetch(`${API_BASE}/api/session`, { method: 'POST', body: fd });
416      if (!res.ok) throw new Error('Failed to create session');
417      const data = await res.json();
418      const sessionId = data.session_id || data.id;
419
420      // Favorite it
421      const favFd = new FormData();
422      favFd.append('important', true);
423      await fetch(`${API_BASE}/api/session/${sessionId}/important`, { method: 'POST', body: favFd });
424
425      // Save session → character mapping so it restores on switch
426      const charSessions = loadStoredObject('odysseus-char-sessions');
427      charSessions[sessionId] = charName;
428      localStorage.setItem('odysseus-char-sessions', JSON.stringify(charSessions));
429
430      // Close modal, reload sessions, switch to the new chat
431      const modal = document.getElementById('custom-preset-modal');
432      if (modal) modal.classList.add('hidden');
433      await sessionModule.loadSessions();
434      await sessionModule.selectSession(sessionId);
435
436      btn.textContent = 'Created!';
437      setTimeout(() => { btn.textContent = 'Create Persistent Chat'; }, 1500);
438    } catch (e) {
439      console.error('Failed to create persistent chat:', e);
440      btn.textContent = 'Error';
441      setTimeout(() => { btn.textContent = 'Create Persistent Chat'; }, 2000);
442    }
443  });
444}
445
446function initSaveAsTemplate() {
447  const btn = document.getElementById('save-as-template-btn');
448  if (!btn) return;
449
450  btn.addEventListener('click', async () => {
451    const nameInput = document.getElementById('custom-character-name');
452    const promptInput = document.getElementById('custom-system-prompt');
453    const tempInput = document.getElementById('custom-temperature');
454    const tokensInput = document.getElementById('custom-max-tokens');
455
456    let name = nameInput ? nameInput.value.trim() : '';
457    if (!name) {
458      name = prompt('Enter a name for this persona:');
459      if (!name || !name.trim()) return;
460      name = name.trim();
461      if (nameInput) nameInput.value = name;
462    }
463
464    const _rawTk = tokensInput ? parseInt(tokensInput.value) : 0;
465    const template = {
466      id: '',
467      name: name,
468      system_prompt: promptInput ? promptInput.value : '',
469      temperature: tempInput ? parseFloat(tempInput.value) : 1.0,
470      max_tokens: _rawTk > 8192 ? 0 : _rawTk,
471    };
472
473    try {
474      const res = await fetch(`${API_BASE}/api/presets/templates`, {
475        method: 'POST',
476        headers: { 'Content-Type': 'application/json' },
477        body: JSON.stringify(template),
478      });
479      if (!res.ok) throw new Error(`Server returned ${res.status}`);
480      const data = await res.json();
481      if (data.success) {
482        await loadUserTemplates();
483        btn.textContent = 'Saved!';
484        setTimeout(() => { btn.textContent = 'Save as Template'; }, 1500);
485      } else {
486        btn.textContent = 'Error';
487        setTimeout(() => { btn.textContent = 'Save as Template'; }, 2000);
488      }
489    } catch (e) {
490      console.error('Failed to save template:', e);
491      btn.textContent = 'Restart server';
492      btn.style.color = 'var(--color-error)';
493      setTimeout(() => { btn.textContent = 'Save as Template'; btn.style.color = ''; }, 3000);
494    }
495  });
496}
497
498/**
499 * Load presets from server
500 */
501export async function loadPresets(showError) {
502  try {
503    const res = await fetch(`${API_BASE}/api/presets`);
504    presets = await res.json();
505
506    const custom = presets.custom;
507    if (custom && custom.enabled === undefined) {
508      const legacyPrompt = "You are a helpful, balanced assistant. Match your response style to the user's needs.";
509      if (
510        custom.name === 'Custom'
511        && !custom.character_name
512        && custom.system_prompt === legacyPrompt
513      ) {
514        custom.enabled = false;
515        custom.system_prompt = '';
516        custom.temperature = 1.0;
517        custom.max_tokens = 0;
518        custom.inject_prefix = custom.inject_prefix || '';
519        custom.inject_suffix = custom.inject_suffix || '';
520      }
521    }
522
523    // Auto-activate custom preset if enabled and has content
524    if (custom && custom.enabled !== false && (custom.character_name || custom.system_prompt)) {
525      selectedPreset = 'custom';
526      const miniBtn = document.getElementById('overflow-preset-btn');
527      if (miniBtn) miniBtn.classList.add('active');
528    }
529    setTimeout(() => { _syncCharIndicator(); }, 0);
530  } catch (error) {
531    console.error('Failed to load presets:', error);
532    if (showError) {
533      showError('Failed to load presets');
534    }
535  }
536}
537
538/**
539 * Set active preset
540 */
541export function setActivePreset(presetId) {
542  document.querySelectorAll('.preset-btn').forEach(btn => {
543    btn.classList.remove('active');
544  });
545
546  if (presetId) {
547    selectedPreset = presetId;
548    const btn = document.getElementById(`preset-${presetId}-btn`);
549    if (btn) {
550      btn.classList.add('active');
551    }
552  } else {
553    selectedPreset = null;
554  }
555}
556
557/**
558 * Open custom preset modal
559 */
560export function openCustomPresetModal() {
561  const modal = document.getElementById('custom-preset-modal');
562  if (!modal) return;
563
564  const savedConfig = presets.custom || {
565    character_name: "",
566    temperature: 1.0,
567    max_tokens: 0,
568    system_prompt: ""
569  };
570
571  const nameInput = document.getElementById('custom-character-name');
572  const tempInput = document.getElementById('custom-temperature');
573  const tokensInput = document.getElementById('custom-max-tokens');
574  const promptInput = document.getElementById('custom-system-prompt');
575
576  if (nameInput) nameInput.value = savedConfig.character_name || '';
577  // Sync select dropdown to current character
578  const charSelect = document.getElementById('char-template-select');
579  if (charSelect) {
580    const charName = savedConfig.character_name || '';
581    if (charName) {
582      charSelect.value = charName;
583      // If current name isn't in the list, fall back to "New character..." with name filled in
584      if (charSelect.value !== charName) charSelect.value = '';
585    } else {
586      charSelect.value = '__default__';
587    }
588  }
589  if (tempInput) {
590    tempInput.value = savedConfig.temperature;
591    const tv = document.getElementById('temp-value');
592    if (tv) tv.textContent = parseFloat(savedConfig.temperature).toFixed(1);
593  }
594  if (tokensInput) {
595    const saved = savedConfig.max_tokens || 0;
596    tokensInput.value = saved === 0 ? 8448 : saved;
597    const tkv = document.getElementById('tokens-value');
598    if (tkv) tkv.textContent = (saved === 0 || saved > 8192) ? 'No limit' : parseInt(saved).toLocaleString();
599  }
600  if (promptInput) promptInput.value = savedConfig.system_prompt || '';
601
602  // Load inject fields
603  const prefixInput = document.getElementById('inject-prefix');
604  const suffixInput = document.getElementById('inject-suffix');
605  if (prefixInput) prefixInput.value = savedConfig.inject_prefix || '';
606  if (suffixInput) suffixInput.value = savedConfig.inject_suffix || '';
607
608  // Track initial state to detect changes for dynamic button label
609  const _snapshot = {
610    name: nameInput ? nameInput.value : '',
611    prompt: promptInput ? promptInput.value : '',
612    temp: tempInput ? tempInput.value : '1',
613    tokens: tokensInput ? tokensInput.value : '8448',
614  };
615  function _updateStartBtn() {
616    const btn = document.getElementById('save-custom-preset');
617    const resetBtn = document.getElementById('reset-character-btn');
618    if (!btn) return;
619    const changed = (nameInput && nameInput.value !== _snapshot.name)
620      || (promptInput && promptInput.value !== _snapshot.prompt)
621      || (tempInput && tempInput.value !== _snapshot.temp)
622      || (tokensInput && tokensInput.value !== _snapshot.tokens);
623    // The footer button starts whichever of the three things the active tab
624    // represents — a character chat, a group, or a plain tuned chat. Label
625    // it so the action is obvious instead of a generic "Start".
626    const activeTab = document.querySelector('.preset-tab.active')?.dataset.chartab || 'inject';
627    let label;
628    if (activeTab === 'group') {
629      label = 'Start Group';
630    } else if (activeTab === 'inject') {
631      // Inject tab = a plain tuned "prompt" chat (prefix/suffix + temp/tokens),
632      // no persona.
633      label = 'Start Prompt';
634    } else {
635      // Character/persona tab. "Save & " prefix when the user edited a template,
636      // so it's clear the edit is being saved on start.
637      label = changed ? 'Save & Start Persona' : 'Start Persona';
638    }
639    btn.textContent = label;
640    // Show a "Cancel" button next to Start when the active tab's feature is
641    // currently ON, so the user can turn it off here instead of hunting the
642    // tiny X on the chat bar.
643    const cancelBtn = document.getElementById('cancel-custom-preset');
644    if (cancelBtn) {
645      const groupOn = !!(window.groupModule && window.groupModule.isActive && window.groupModule.isActive());
646      const featOn = activeTab === 'group' ? groupOn : !!(presets.custom && presets.custom.enabled);
647      cancelBtn.style.display = featOn ? '' : 'none';
648      cancelBtn.textContent = activeTab === 'group' ? 'Cancel group' : 'Cancel';
649    }
650    // Reset only makes sense on the character tab (it resets the persona).
651    if (resetBtn) resetBtn.style.display = (changed && activeTab === 'character') ? '' : 'none';
652  }
653  [nameInput, promptInput, tempInput, tokensInput].forEach(el => {
654    if (el) el.addEventListener('input', _updateStartBtn);
655  });
656  // Re-label the Start button when the user switches tabs. Rebind the fresh
657  // closure each time the modal opens (removing any stale one) so the label
658  // logic always reads this open's snapshot/inputs.
659  document.querySelectorAll('.preset-tab[data-chartab]').forEach(tab => {
660    if (tab._startLabelSync) tab.removeEventListener('click', tab._startLabelSync);
661    tab._startLabelSync = _updateStartBtn;
662    tab.addEventListener('click', _updateStartBtn);
663  });
664  // Wire the "Cancel" button once — turn off the active tab's feature + close.
665  const _cancelBtn = document.getElementById('cancel-custom-preset');
666  if (_cancelBtn && !_cancelBtn._wired) {
667    _cancelBtn._wired = true;
668    _cancelBtn.addEventListener('click', () => {
669      const t = document.querySelector('.preset-tab.active')?.dataset.chartab || 'inject';
670      if (t === 'group') {
671        try { if (window.groupModule && window.groupModule.stopGroup) window.groupModule.stopGroup(); } catch {}
672        if (window._syncGroupIndicator) window._syncGroupIndicator(false);
673      } else {
674        deactivateCharacter();
675        try {
676          fetch(`${API_BASE}/api/presets/custom`, {
677            method: 'POST', headers: { 'Content-Type': 'application/json' },
678            body: JSON.stringify({ ...(presets.custom || {}), name: (presets.custom && presets.custom.character_name) || '', enabled: false }),
679          }).catch(() => {});
680        } catch {}
681      }
682      const m = document.getElementById('custom-preset-modal');
683      if (m) m.classList.add('hidden');
684    });
685  }
686  // When selecting a template, update snapshot so it counts as "unchanged"
687  if (charSelect) charSelect.addEventListener('change', () => setTimeout(() => {
688    _snapshot.name = nameInput ? nameInput.value : '';
689    _snapshot.prompt = promptInput ? promptInput.value : '';
690    _snapshot.temp = tempInput ? tempInput.value : '1';
691    _snapshot.tokens = tokensInput ? tokensInput.value : '8448';
692    _updateStartBtn();
693  }, 50));
694  _updateStartBtn();
695
696  function _syncCharRows() {
697    const hasName = nameInput && nameInput.value.trim();
698    const delBtn = document.getElementById('char-delete-template-btn');
699    if (delBtn) delBtn.style.display = userTemplates.find(t => t.name === (nameInput ? nameInput.value.trim() : '')) ? '' : 'none';
700    const persistBtn = document.getElementById('create-persistent-chat-btn');
701    if (persistBtn) persistBtn.style.display = hasName ? '' : 'none';
702  }
703
704  _syncCharRows();
705  if (nameInput && !nameInput._syncWired) {
706    nameInput._syncWired = true;
707    nameInput.addEventListener('input', _syncCharRows);
708  }
709
710  // Persistent chat: lock character identity (dropdown, name) but allow style/temp/memory edits
711  const isPersistent = !!window._persistentChatSession;
712  const lockNotice = document.getElementById('char-lock-notice');
713  const resetBtn = document.getElementById('reset-character-btn');
714  const newBtn = document.getElementById('char-new-btn');
715  const persistBtn = document.getElementById('create-persistent-chat-btn');
716  const delBtn2 = document.getElementById('char-delete-template-btn');
717
718  if (isPersistent) {
719    if (charSelect) charSelect.disabled = true;
720    if (nameInput) nameInput.readOnly = true;
721    if (resetBtn) resetBtn.style.display = 'none';
722    if (newBtn) newBtn.style.display = 'none';
723    if (persistBtn) persistBtn.style.display = 'none';
724    if (delBtn2) delBtn2.style.display = 'none';
725    if (!lockNotice) {
726      const notice = document.createElement('div');
727      notice.id = 'char-lock-notice';
728      notice.style.cssText = 'font-size:11px;color:var(--color-muted);text-align:center;padding:6px;margin-bottom:8px;border:1px dashed var(--border);border-radius:6px;';
729      notice.textContent = 'Persistent chat — persona is locked. Style, temperature, and memory can still be changed.';
730      modal.querySelector('.modal-body').prepend(notice);
731    }
732  } else {
733    if (lockNotice) lockNotice.remove();
734    if (charSelect) charSelect.disabled = false;
735    if (nameInput) nameInput.readOnly = false;
736    if (resetBtn) resetBtn.style.display = '';
737    if (newBtn) newBtn.style.display = '';
738  }
739
740  modal.classList.remove('hidden');
741}
742
743/**
744 * Save custom preset
745 */
746export async function saveCustomPreset(showToast, showError) {
747  const nameInput = document.getElementById('custom-character-name');
748  const tempInput = document.getElementById('custom-temperature');
749  const tokensInput = document.getElementById('custom-max-tokens');
750  const promptInput = document.getElementById('custom-system-prompt');
751
752  if (!tempInput || !tokensInput || !promptInput) return;
753
754  // This only runs for Character / Inject starts (the Group tab is handled by
755  // group.js and skipped in app.js). If a group is still active from a prior
756  // session, deactivate it — otherwise the chat-submit handler keeps routing
757  // messages through group fan-out and a character chat "becomes a group".
758  try {
759    if (window.groupModule && window.groupModule.isActive()) {
760      window.groupModule.stopGroup();
761      if (window._syncGroupIndicator) window._syncGroupIndicator(false);
762    }
763  } catch (_) {}
764
765  // Starting from the Inject tab means a plain tuned chat (prefix/suffix +
766  // temp/tokens) — NOT a persona. The name/system-prompt fields live on the
767  // Character tab and may still hold a previously-selected character, so
768  // ignore them here or the chat would launch in-character.
769  const _activeTab = document.querySelector('.preset-tab.active')?.dataset.chartab || 'character';
770  const _isInjectStart = _activeTab === 'inject';
771
772  const name = _isInjectStart ? '' : (nameInput ? nameInput.value.trim() : '');
773  const temperature = parseFloat(tempInput.value);
774  const rawTokens = parseInt(tokensInput.value);
775  const max_tokens = rawTokens > 8192 ? 0 : rawTokens;
776  const system_prompt = _isInjectStart ? '' : promptInput.value;
777
778  const enabled = true; // always enabled when saving — deactivation happens via X/Reset
779
780  const _prefixInput = document.getElementById('inject-prefix');
781  const _suffixInput = document.getElementById('inject-suffix');
782
783  const config = {
784    name: name,
785    enabled: enabled,
786    temperature: Math.max(0, Math.min(2, temperature)),
787    max_tokens: max_tokens,
788    system_prompt: system_prompt,
789    inject_prefix: _prefixInput ? _prefixInput.value : '',
790    inject_suffix: _suffixInput ? _suffixInput.value : '',
791  };
792
793  try {
794    const response = await fetch(`${API_BASE}/api/presets/custom`, {
795      method: 'POST',
796      headers: { 'Content-Type': 'application/json' },
797      body: JSON.stringify(config)
798    });
799
800    const result = await response.json();
801    if (result.success) {
802      presets.custom = { ...presets.custom, ...config, character_name: name, enabled: enabled };
803
804      // The custom preset must be the SELECTED preset for its values to reach
805      // the model — chat.js only sends `preset_id` when getSelectedPreset() is
806      // truthy. Activate it when there's a persona (name/prompt) OR when the
807      // user has dialed in non-default tuning (temperature / max tokens) — the
808      // "Inject" tab's plain-chat case. Without the tuning check, "just set
809      // temp + max tokens" would silently do nothing.
810      const _hasTuning = (config.temperature !== 1.0) || (config.max_tokens !== 0);
811      const _hasInject = !!(config.inject_prefix || config.inject_suffix);
812      const _hasContent = !!(system_prompt || name || _hasTuning || _hasInject);
813      if (enabled && _hasContent) {
814        selectedPreset = 'custom';
815        // Turn off research — doesn't make sense with a character
816        if (window._syncResearchIndicator) window._syncResearchIndicator(false);
817      } else {
818        selectedPreset = null;
819      }
820
821      // Update mini button state
822      const miniBtn = document.getElementById('overflow-preset-btn');
823      if (miniBtn) {
824        miniBtn.classList.toggle('active', enabled && _hasContent);
825      }
826
827      setTimeout(() => { _syncCharIndicator(); }, 0);
828
829      // Auto-save to templates (non-blocking) — skip built-in presets
830      const _selVal = document.getElementById('char-template-select')?.value || '';
831      const isBuiltinPreset = PROMPT_TEMPLATES.some(t => t.isPreset && (t.name === name || t.name === _selVal));
832      const saveName = isBuiltinPreset ? null : (name || null);
833      if (saveName) {
834        fetch(`${API_BASE}/api/presets/templates`, {
835          method: 'POST',
836          headers: { 'Content-Type': 'application/json' },
837          body: JSON.stringify({
838            id: (userTemplates.find(t => t.name === saveName) || {}).id || '',
839            name: saveName, system_prompt, temperature: config.temperature, max_tokens: config.max_tokens,
840          }),
841        }).then(r => { if (r.ok) loadUserTemplates(); }).catch(() => {});
842      }
843
844      if (showToast) {
845        // The Inject tab is a plain tuned "prompt" chat, not a persona — say so.
846        showToast(_isInjectStart ? 'Prompt saved' : 'Persona saved');
847      }
848      const modal = document.getElementById('custom-preset-modal');
849      if (modal) {
850        modal.classList.add('hidden');
851      }
852    } else {
853      if (showError) {
854        showError('Failed to save custom preset');
855      }
856    }
857  } catch (error) {
858    console.error('Error saving custom preset:', error);
859    if (showError) {
860      showError('Failed to save custom preset');
861    }
862  }
863}
864
865/**
866 * Get selected preset ID
867 */
868export function getSelectedPreset() {
869  return selectedPreset;
870}
871
872/**
873 * Get preset by ID
874 */
875export function getPreset(presetId) {
876  return presets[presetId];
877}
878
879/**
880 * Get all presets
881 */
882export function getAllPresets() {
883  return presets;
884}
885
886/**
887 * Get the character name (if set)
888 */
889export function getCharacterName() {
890  if (!selectedPreset) return '';
891  const custom = presets.custom;
892  if (!custom || custom.enabled === false) return '';
893  return custom.character_name || '';
894}
895
896/**
897 * Get inject prefix/suffix (if set and preset active)
898 */
899export function getInject() {
900  // Only inject when a preset is actually ACTIVE — mirror getCharacterName's
901  // gate. Without the selectedPreset/enabled check, any text left in the
902  // prefix/suffix fields got injected into every message even though the user
903  // never started/activated the preset.
904  if (!selectedPreset) return { prefix: '', suffix: '' };
905  const custom = presets.custom;
906  if (!custom || custom.enabled === false) return { prefix: '', suffix: '' };
907  return {
908    prefix: custom.inject_prefix || '',
909    suffix: custom.inject_suffix || '',
910  };
911}
912
913/**
914 * Fully deactivate the character — clear preset, hide indicator, update overflow btn.
915 */
916export function deactivateCharacter() {
917  selectedPreset = null;
918  if (presets.custom) presets.custom.enabled = false;
919  const charInd = document.getElementById('character-indicator-btn');
920  if (charInd) { charInd.style.display = 'none'; charInd.classList.remove('active'); }
921  const miniBtn = document.getElementById('overflow-preset-btn');
922  if (miniBtn) miniBtn.classList.remove('active');
923}
924
925/**
926 * Show/hide the memory scope bar and wire up scope switching.
927 * Called after presets load and after saving character.
928 */
929/**
930 * Copy all user memories (non-character) into the character's memory pool.
931 */
932async function _mergeUserMemories(charName) {
933  try {
934    const res = await fetch(`${API_BASE}/api/memory`);
935    const data = await res.json();
936    const userMems = (data.memory || []).filter(m => !m.character);
937    if (!userMems.length) return;
938    for (const m of userMems) {
939      await fetch(`${API_BASE}/api/memory/add`, {
940        method: 'POST',
941        headers: { 'Content-Type': 'application/json' },
942        body: JSON.stringify({ text: m.text, category: m.category || 'fact', source: 'user', character: charName }),
943      });
944    }
945  } catch (e) {
946    console.error('Failed to merge memories:', e);
947  }
948}
949
950function _reloadMemoryList() {
951  import('./memory.js').then(m => {
952    if (m.renderMemoryList) m.renderMemoryList();
953    if (m.updateMemoryCount) m.updateMemoryCount();
954  }).catch(() => {});
955}
956
957/**
958 * Show/hide the character indicator pill in the chat input bar.
959 */
960function _syncCharIndicator() {
961  const btn = document.getElementById('character-indicator-btn');
962  const nameSpan = document.getElementById('character-indicator-name');
963  const iconEl = document.getElementById('char-indicator-icon');
964  if (!btn) return;
965  const custom = presets.custom;
966  const enabled = custom?.enabled !== false;
967  const hasChar = enabled && !!custom?.character_name;
968  // "Inject mode": custom preset is active for plain tuning / inject only —
969  // no persona. Detected from the custom config so it survives a reload.
970  const _t = parseFloat(custom?.temperature);
971  const _hasTuning = (!isNaN(_t) && _t !== 1.0) || (!!custom?.max_tokens && custom.max_tokens !== 0);
972  const _hasInject = !!(custom?.inject_prefix || custom?.inject_suffix);
973  const injectActive = enabled && !custom?.character_name && (_hasTuning || _hasInject);
974  // Icon path sets for the indicator chip.
975  const _AVATAR = '<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>';
976  const _SYRINGE = '<path d="m18 2 4 4"/><path d="m17 7 3-3"/><path d="M19 9 8.7 19.3c-1 1-2.5 1-3.4 0l-.6-.6c-1-1-1-2.5 0-3.4L15 5"/><path d="m9 11 4 4"/><path d="m5 19-3 3"/><path d="m14 4 6 6"/>';
977  if (hasChar || injectActive) {
978    btn.style.display = '';
979    btn.classList.add('active');
980    if (hasChar) {
981      if (iconEl) iconEl.innerHTML = _AVATAR;
982      if (nameSpan) nameSpan.textContent = custom.character_name;
983      btn.title = `Persona: ${custom.character_name} — click to configure`;
984    } else {
985      // Inject/tuning chat — syringe tag labeled "Prompt" to match the
986      // window identity, no persona name.
987      if (iconEl) iconEl.innerHTML = _SYRINGE;
988      if (nameSpan) nameSpan.textContent = 'Prompt';
989      btn.title = 'Custom settings active — click to configure';
990    }
991    // Hide X in persistent chats
992    const xIcon = btn.querySelector('.tool-indicator-x');
993    if (xIcon) xIcon.style.display = window._persistentChatSession ? 'none' : '';
994    if (!btn._wired) {
995      btn._wired = true;
996      btn.addEventListener('click', (e) => {
997        // If clicking the X, deactivate character
998        if (e.target.closest('.tool-indicator-x')) {
999          if (window._persistentChatSession) return; // locked in persistent chat
1000          selectedPreset = null;
1001          presets.custom = { ...presets.custom, enabled: false };
1002          btn.style.display = 'none';
1003          btn.classList.remove('active');
1004          const miniBtn = document.getElementById('overflow-preset-btn');
1005          if (miniBtn) miniBtn.classList.remove('active');
1006          // Save disabled state to backend
1007          fetch(`${API_BASE}/api/presets/custom`, {
1008            method: 'POST',
1009            headers: { 'Content-Type': 'application/json' },
1010            body: JSON.stringify({ ...presets.custom, name: presets.custom.character_name || '', enabled: false }),
1011          }).catch(() => {});
1012          return;
1013        }
1014        if (typeof openCustomPresetModal === 'function') openCustomPresetModal();
1015      });
1016    }
1017  } else {
1018    btn.style.display = 'none';
1019    btn.classList.remove('active');
1020  }
1021}
1022
1023/**
1024 * Called on every session switch. Handles persistent chat character lock.
1025 * - Entering a persistent chat: activate its character
1026 * - Leaving a persistent chat: deactivate the character
1027 * - Non-persistent chats: leave character state as-is
1028 */
1029let _prevSessionId = null;
1030
1031export function onSessionSwitch(sessionId) {
1032  const charSessions = loadStoredObject('odysseus-char-sessions');
1033
1034  // Leaving a persistent chat — deactivate for this switch only
1035  if (window._persistentChatSession) {
1036    selectedPreset = null;
1037    window._persistentChatSession = null;
1038    _syncCharIndicator();
1039  }
1040
1041  _prevSessionId = sessionId;
1042
1043  // Clean up stale entries (deleted sessions)
1044  // If sessionId doesn't exist in the session list, remove its mapping
1045  const charName = charSessions[sessionId];
1046  if (charName) {
1047    // Find the template (saved or built-in)
1048    const tmpl = userTemplates.find(t => t.name === charName)
1049      || PROMPT_TEMPLATES.find(t => t.name === charName);
1050    if (tmpl) {
1051      presets.custom = {
1052        ...presets.custom,
1053        character_name: charName,
1054        system_prompt: tmpl.system_prompt || tmpl.prompt || '',
1055        temperature: tmpl.temperature ?? 1.0,
1056        max_tokens: tmpl.max_tokens || 0,
1057        enabled: true,
1058      };
1059      selectedPreset = 'custom';
1060    }
1061    _syncCharIndicator();
1062    // Mark this as a locked persistent chat
1063    window._persistentChatSession = sessionId;
1064  } else {
1065    window._persistentChatSession = null;
1066  }
1067}
1068
1069/**
1070 * Check if the current session is a persistent (locked) character chat.
1071 */
1072export function isPersistentChat() {
1073  return !!window._persistentChatSession;
1074}
1075
1076/**
1077 * Remove a session from persistent chat mappings (call when session is deleted).
1078 */
1079export function removePersistentChat(sessionId) {
1080  const charSessions = loadStoredObject('odysseus-char-sessions');
1081  if (charSessions[sessionId]) {
1082    delete charSessions[sessionId];
1083    localStorage.setItem('odysseus-char-sessions', JSON.stringify(charSessions));
1084  }
1085  // If we were in that persistent chat, fully clear state
1086  if (window._persistentChatSession === sessionId) {
1087    window._persistentChatSession = null;
1088    selectedPreset = null;
1089    _syncCharIndicator();
1090  }
1091}
1092
1093const presetsModule = {
1094  init,
1095  loadPresets,
1096  setActivePreset,
1097  openCustomPresetModal,
1098  saveCustomPreset,
1099  getSelectedPreset,
1100  getPreset,
1101  getAllPresets,
1102  getCharacterName,
1103  onSessionSwitch,
1104  isPersistentChat,
1105  removePersistentChat,
1106  deactivateCharacter,
1107  getInject
1108};
1109
1110export default presetsModule;

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.