Fixed RPG

This commit is contained in:
2026-05-29 08:52:33 +03:00
parent e5c0df308f
commit 600ad78f05
24 changed files with 2804 additions and 144 deletions
+400 -30
View File
@@ -1,6 +1,13 @@
import { sessionId, setSessionId, setCurrentPersona, currentPersona, dom } from './state.js';
import { clearMessages, addMessage, initChat } from './chat.js';
import { highlightPersona } from './personas.js';
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');
@@ -8,6 +15,38 @@ function escapeTitle(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();
@@ -16,16 +55,49 @@ export async function loadSessions() {
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 = `
<div class="s-title">${escapeTitle(s.title || 'Новый чат')}</div>
<div class="s-row">
<div class="s-title" title="Двойной клик — переименовать">${escapeTitle(s.title || 'Новый чат')}</div>
${s.rpg_enabled ? '<span class="s-badge">RPG</span>' : ''}
${dateStr ? `<span class="s-date">${dateStr}</span>` : ''}
</div>
<div class="s-companion">С: ${escapeTitle(personaName)}</div>
<div class="s-preview">${escapeTitle(s.last_message_preview || '')}</div>
<div class="s-meta">${s.message_count} сообщ.</div>
<button class="s-del" type="button">🗑</button>
`;
item.addEventListener('click', () => switchSession(s.session_id));
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) createNewChat();
if (s.session_id === sessionId) openNewChatWizard();
else loadSessions();
});
dom.sessionList.appendChild(item);
@@ -41,37 +113,335 @@ export async function switchSession(id) {
export async function loadChatHistory(id) {
const sessionRes = await fetch(`/sessions/${id}`);
let session = null;
if (sessionRes.ok) {
const s = await sessionRes.json();
dom.headerTitle.textContent = s.title || 'Новый чат';
if (s.persona_id) {
setCurrentPersona(s.persona_id);
highlightPersona(s.persona_id);
session = await sessionRes.json();
if (session.persona_id) {
setCurrentPersona(session.persona_id);
highlightPersona(session.persona_id);
}
applySessionUi(session);
}
const histRes = await fetch(`/chat/history/${id}`);
if (!histRes.ok) return;
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 */ }
const messages = await histRes.json();
clearMessages();
messages.filter(m => m.role !== 'system').forEach(m => {
addMessage(
m.role === 'user' ? 'user' : 'assistant',
m.content,
m.image_prompt,
m.image_path ? `/static/${m.image_path}` : null,
);
});
await reloadChatFromServer(id);
}
export async function createNewChat() {
setSessionId('sess_' + Math.random().toString(36).slice(2, 10));
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 = `<span class="emoji">${p.emoji || '🤖'}</span>${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();
dom.headerTitle.textContent = 'Новый чат';
highlightPersona(currentPersona);
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();
loadSessions();
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('<br>');
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() {
@@ -79,8 +449,8 @@ export async function initSessions() {
if (sessionId) {
const check = await fetch(`/sessions/${sessionId}`);
if (check.ok) await switchSession(sessionId);
else createNewChat();
else openNewChatWizard();
} else {
createNewChat();
openNewChatWizard();
}
}