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.