import { sessionId, setSessionId, setCurrentPersona, currentPersona, dom, setRpgEnabled, } from './state.js'; import { clearMessages, addMessage, initChat, updateQuestPanel, updateAffinityDisplay, reloadChatFromServer, } from './chat.js'; import { highlightPersona, personaIndex, loadPersonas } from './personas.js'; import { initWizard, GENRE_LABELS, bindGenreGrid, resetGenreGrid, formatSessionDate, } from './utils.js'; function escapeTitle(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; } let newChatPersonaId = currentPersona; const newChatGenres = new Set(); const chatSettingsGenres = new Set(); export function applySessionUi(session) { if (!session) return; dom.headerTitle.textContent = session.title || 'Новый чат'; const rpgOn = !!session.rpg_enabled; setRpgEnabled(rpgOn); document.getElementById('rpgBadge')?.classList.toggle('hidden', !rpgOn); let settings = { quests: true, affinity: true }; try { settings = { ...settings, ...JSON.parse(session.rpg_settings_json || '{}') }; } catch { /* ignore */ } document.getElementById('questPanel')?.classList.toggle('hidden', !rpgOn || !settings.quests); if (rpgOn && settings.affinity) { updateAffinityDisplay(session.affinity ?? 0); dom.affinityDisplay?.classList.remove('hidden'); } else { dom.affinityDisplay?.classList.add('hidden'); } if (rpgOn && settings.quests) { fetch(`/sessions/${session.session_id}/quests`) .then(r => r.ok ? r.json() : []) .then(q => updateQuestPanel(q)) .catch(() => {}); } } export async function loadSessions() { const res = await fetch('/sessions/'); const sessions = await res.json(); dom.sessionList.innerHTML = ''; sessions.forEach(s => { const item = document.createElement('div'); item.className = 'session-item' + (s.session_id === sessionId ? ' active' : ''); const personaName = personaIndex.get(s.persona_id)?.name || s.persona_id || 'default'; const dateStr = formatSessionDate(s.updated_at); item.innerHTML = `
${escapeTitle(s.title || 'Новый чат')}
${s.rpg_enabled ? 'RPG' : ''} ${dateStr ? `${dateStr}` : ''}
С: ${escapeTitle(personaName)}
${escapeTitle(s.last_message_preview || '')}
${s.message_count} сообщ.
`; item.addEventListener('click', (e) => { if (e.target.closest('.s-del') || item.querySelector('.s-title')?.isContentEditable) return; switchSession(s.session_id); }); const titleEl = item.querySelector('.s-title'); titleEl.addEventListener('dblclick', (e) => { e.stopPropagation(); titleEl.contentEditable = 'true'; titleEl.focus(); }); titleEl.addEventListener('blur', async () => { titleEl.contentEditable = 'false'; const t = titleEl.textContent.trim(); if (t && t !== (s.title || 'Новый чат')) { await fetch(`/sessions/${s.session_id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: t }), }); if (s.session_id === sessionId) dom.headerTitle.textContent = t; loadSessions(); } }); titleEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); titleEl.blur(); } }); item.querySelector('.s-del').addEventListener('click', async (e) => { e.stopPropagation(); await fetch(`/sessions/${s.session_id}`, { method: 'DELETE' }); if (s.session_id === sessionId) openNewChatWizard(); else loadSessions(); }); dom.sessionList.appendChild(item); }); } export async function switchSession(id) { setSessionId(id); clearMessages(); await loadSessions(); await loadChatHistory(id); } export async function loadChatHistory(id) { const sessionRes = await fetch(`/sessions/${id}`); let session = null; if (sessionRes.ok) { session = await sessionRes.json(); if (session.persona_id) { setCurrentPersona(session.persona_id); highlightPersona(session.persona_id); } applySessionUi(session); } try { const blobRes = await fetch(`/chat/system/${id}`); if (blobRes.ok) { const blob = await blobRes.json(); const parts = []; if (blob.system_prompt) parts.push(blob.system_prompt); if (blob.status_quo) parts.push(`--- Status quo ---\n${blob.status_quo}\n---`); if (blob.facts_json) parts.push(`facts_json: ${blob.facts_json}`); if (blob.plot_arc_json) parts.push(`plot_arc_json: ${blob.plot_arc_json}`); dom.systemBlobContent.textContent = parts.filter(Boolean).join('\n\n') || '—'; } } catch { /* ignore */ } await reloadChatFromServer(id); } async function bootstrapRpg(sid, personaId, genreValue, settings) { await fetch(`/sessions/${sid}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rpg_enabled: true, genre: genreValue, rpg_settings_json: JSON.stringify(settings), }), }); const res = await fetch('/chat/rpg/bootstrap', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sid, persona_id: personaId, genre: genreValue }), }); if (res.ok) { const data = await res.json(); if (data.quests) updateQuestPanel(data.quests); if (data.plot_arc) { const title = data.plot_arc.title || ''; const hint = data.plot_arc.next_beat_hint || ''; if (title || hint) addMessage('assistant', `📖 ${title}${hint ? '\n' + hint : ''}`); } } } function fillNewChatPersonaGrid() { const grid = document.getElementById('newChatPersonaGrid'); if (!grid) return; grid.innerHTML = ''; newChatPersonaId = currentPersona; for (const p of personaIndex.values()) { const card = document.createElement('button'); card.type = 'button'; card.className = 'persona-pick-card' + (p.persona_id === newChatPersonaId ? ' selected' : ''); card.dataset.id = p.persona_id; card.innerHTML = `${p.emoji || '🤖'}${p.name}`; card.addEventListener('click', () => { newChatPersonaId = p.persona_id; grid.querySelectorAll('.persona-pick-card').forEach(c => { c.classList.toggle('selected', c.dataset.id === newChatPersonaId); }); }); grid.appendChild(card); } } function updateNewChatGenresLabel() { const el = document.getElementById('newChatGenresLabel'); const nextBtn = document.getElementById('newChatNext'); const labels = [...newChatGenres].map(g => GENRE_LABELS[g] || g); if (el) { if (labels.length) { el.textContent = `Выбрано: ${labels.join(' + ')}`; el.classList.remove('hidden'); } else { el.classList.add('hidden'); } } const isRpg = document.querySelector('input[name="newChatRpg"]:checked')?.value === '1'; if (nextBtn && isRpg) { const wizard = newChatModalEl?.querySelector('.modal-wizard'); const onStep3 = wizard?.querySelector('.wizard-page[data-step="3"]')?.classList.contains('active'); if (onStep3) nextBtn.disabled = newChatGenres.size === 0; } } const newChatModalEl = document.getElementById('newChatModal'); let newChatWizard; function isNewChatRpg() { return document.querySelector('input[name="newChatRpg"]:checked')?.value === '1'; } function syncNewChatStep3() { const plain = document.getElementById('newChatPlainStep'); const rpg = document.getElementById('newChatRpgStep'); if (isNewChatRpg()) { plain?.classList.add('hidden'); rpg?.classList.remove('hidden'); } else { plain?.classList.remove('hidden'); rpg?.classList.add('hidden'); } } export function openNewChatWizard() { fillNewChatPersonaGrid(); resetGenreGrid(document.getElementById('newChatGenreGrid'), newChatGenres); updateNewChatGenresLabel(); document.querySelector('input[name="newChatRpg"][value="0"]')?.click(); document.getElementById('newChatTitle').value = ''; syncNewChatStep3(); newChatWizard?.reset(); newChatModalEl?.classList.add('open'); } export async function createNewChatFromWizard() { const sid = 'sess_' + Math.random().toString(36).slice(2, 10); setSessionId(sid); setCurrentPersona(newChatPersonaId); clearMessages(); const customTitle = document.getElementById('newChatTitle')?.value.trim(); const rpg = isNewChatRpg(); await fetch(`/sessions/${sid}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ persona_id: newChatPersonaId, rpg_enabled: rpg }), }); if (customTitle) { await fetch(`/sessions/${sid}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: customTitle }), }); dom.headerTitle.textContent = customTitle; } else { const pName = personaIndex.get(newChatPersonaId)?.name || newChatPersonaId; dom.headerTitle.textContent = rpg ? `${pName} — RPG` : `${pName} — новый чат`; } highlightPersona(newChatPersonaId); await initChat(); if (rpg) { const genreValue = [...newChatGenres].join(',') || 'adventure'; const settings = { dice: document.getElementById('ncSettingDice')?.checked ?? true, narrator: document.getElementById('ncSettingNarrator')?.checked ?? true, quests: document.getElementById('ncSettingQuests')?.checked ?? true, affinity: document.getElementById('ncSettingAffinity')?.checked ?? true, choices: document.getElementById('ncSettingChoices')?.checked ?? true, }; await bootstrapRpg(sid, newChatPersonaId, genreValue, settings); } await reloadChatFromServer(sid); newChatModalEl?.classList.remove('open'); const sessionRes = await fetch(`/sessions/${sid}`); if (sessionRes.ok) applySessionUi(await sessionRes.json()); await loadSessions(); } export function initNewChatWizard() { if (!newChatModalEl) return; newChatWizard = initWizard(newChatModalEl.querySelector('.modal-wizard'), { totalSteps: 3, onStepChange(step) { syncNewChatStep3(); const nextBtn = document.getElementById('newChatNext'); if (step === 3 && isNewChatRpg()) { nextBtn.disabled = newChatGenres.size === 0; } else { nextBtn.disabled = false; } }, validateStep(step) { if (step === 1 && !newChatPersonaId) { alert('Выбери персонажа'); return false; } if (step === 3 && isNewChatRpg() && newChatGenres.size === 0) { alert('Выбери хотя бы один жанр'); return false; } return true; }, }); bindGenreGrid(document.getElementById('newChatGenreGrid'), newChatGenres, updateNewChatGenresLabel); document.getElementById('newChatCancel')?.addEventListener('click', () => { newChatModalEl.classList.remove('open'); newChatWizard.reset(); }); document.getElementById('newChatCreate')?.addEventListener('click', createNewChatFromWizard); } function updateChatSettingsGenresLabel() { const el = document.getElementById('chatSettingsGenresLabel'); const labels = [...chatSettingsGenres].map(g => GENRE_LABELS[g] || g); if (!el) return; if (labels.length) { el.textContent = `Выбрано: ${labels.join(' + ')}`; el.classList.remove('hidden'); } else { el.classList.add('hidden'); } } function loadRpgSettingsToDom(prefix, settings) { document.getElementById(`${prefix}SettingDice`).checked = settings.dice !== false; document.getElementById(`${prefix}SettingNarrator`).checked = settings.narrator !== false; document.getElementById(`${prefix}SettingQuests`).checked = settings.quests !== false; document.getElementById(`${prefix}SettingAffinity`).checked = settings.affinity !== false; document.getElementById(`${prefix}SettingChoices`).checked = settings.choices !== false; } function readRpgSettingsFromDom(prefix) { return { dice: document.getElementById(`${prefix}SettingDice`)?.checked ?? true, narrator: document.getElementById(`${prefix}SettingNarrator`)?.checked ?? true, quests: document.getElementById(`${prefix}SettingQuests`)?.checked ?? true, affinity: document.getElementById(`${prefix}SettingAffinity`)?.checked ?? true, choices: document.getElementById(`${prefix}SettingChoices`)?.checked ?? true, }; } export async function openChatSettings() { if (!sessionId) return; const res = await fetch(`/sessions/${sessionId}`); if (!res.ok) return; const s = await res.json(); document.getElementById('chatSettingsTitle').value = s.title || ''; const rpgOn = !!s.rpg_enabled; document.getElementById('chatSettingsRpg').checked = rpgOn; document.getElementById('chatSettingsRpgBlock').classList.toggle('hidden', !rpgOn); chatSettingsGenres.clear(); (s.genre || 'adventure').split(',').forEach(g => { const t = g.trim(); if (t) chatSettingsGenres.add(t); }); resetGenreGrid(document.getElementById('chatSettingsGenreGrid'), chatSettingsGenres); document.getElementById('chatSettingsGenreGrid')?.querySelectorAll('.genre-btn').forEach(btn => { if (chatSettingsGenres.has(btn.dataset.genre)) btn.classList.add('selected'); }); updateChatSettingsGenresLabel(); let settings = {}; try { settings = JSON.parse(s.rpg_settings_json || '{}'); } catch { /* ignore */ } loadRpgSettingsToDom('cs', settings); let phase = ''; try { const arc = JSON.parse(s.plot_arc_json || '{}'); phase = arc.phase || ''; } catch { /* ignore */ } document.getElementById('chatSettingsMeta').innerHTML = [ `Симпатия: ${s.affinity ?? 0}`, s.genre ? `Жанр: ${(s.genre || '').split(',').map(g => GENRE_LABELS[g.trim()] || g).join(' + ')}` : '', phase ? `Фаза арки: ${phase}` : '', ].filter(Boolean).join('
'); document.getElementById('chatSettingsModal').classList.add('open'); } export function initChatSettings() { bindGenreGrid( document.getElementById('chatSettingsGenreGrid'), chatSettingsGenres, updateChatSettingsGenresLabel, ); document.getElementById('chatSettingsRpg')?.addEventListener('change', (e) => { document.getElementById('chatSettingsRpgBlock').classList.toggle('hidden', !e.target.checked); }); document.getElementById('chatSettingsCancel')?.addEventListener('click', () => { document.getElementById('chatSettingsModal').classList.remove('open'); }); document.getElementById('chatSettingsSave')?.addEventListener('click', async () => { if (!sessionId) return; const title = document.getElementById('chatSettingsTitle').value.trim(); const rpgOn = document.getElementById('chatSettingsRpg').checked; const genreValue = [...chatSettingsGenres].join(',') || 'adventure'; const settings = readRpgSettingsFromDom('cs'); const body = { title: title || undefined, rpg_enabled: rpgOn, genre: genreValue, rpg_settings_json: JSON.stringify(settings), }; await fetch(`/sessions/${sessionId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (rpgOn) { const sessionRes = await fetch(`/sessions/${sessionId}`); const s = sessionRes.ok ? await sessionRes.json() : {}; let arc = {}; try { arc = JSON.parse(s.plot_arc_json || '{}'); } catch { /* ignore */ } if (!arc || !Object.keys(arc).length) { await bootstrapRpg(sessionId, currentPersona, genreValue, settings); } } document.getElementById('chatSettingsModal').classList.remove('open'); const updated = await (await fetch(`/sessions/${sessionId}`)).json(); applySessionUi(updated); dom.headerTitle.textContent = updated.title || 'Новый чат'; await loadSessions(); }); } export async function initSessions() { await loadSessions(); if (sessionId) { const check = await fetch(`/sessions/${sessionId}`); if (check.ok) await switchSession(sessionId); else openNewChatWizard(); } else { openNewChatWizard(); } }