Files
2026-06-05 14:57:15 +03:00

953 lines
37 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { sessionId, currentPersona, dom } from './state.js';
import { parseImagePromptFromContent, copyToClipboard, splitSdPromptForCopy } from './utils.js';
import {
attachFormatToggle,
initBubbleContent,
applyDiceOverrideToBubble,
isPlayerChoiceContent,
} from './rpFormat.js';
let _pendingUserBubble = null;
let _selectedQuestId = null;
let _questsCache = [];
export async function initChat(options = {}) {
if (!sessionId) return;
const payload = { message: '', session_id: sessionId };
if (options.first_mes_override?.trim()) payload.first_mes_override = options.first_mes_override.trim();
const res = await fetch('/chat/init', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
if (!res.ok) return;
const data = await res.json();
if (data.first_mes) addMessage('assistant', data.first_mes);
}
export function updateEmptyState() {
const hasMessages = dom.messagesEl.querySelector('.message');
dom.emptyState?.classList.toggle('hidden', !!hasMessages);
}
function createImagePromptBlockSingle(label, promptText) {
const block = document.createElement('div');
block.className = 'image-prompt-block';
const header = document.createElement('div');
header.className = 'image-prompt-header';
header.innerHTML = `<span>🎨 ${label}</span>`;
const copyBtn = document.createElement('button');
copyBtn.type = 'button';
copyBtn.className = 'copy-prompt-btn';
copyBtn.textContent = 'Копировать';
copyBtn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const full = textEl.textContent?.trim() || promptText || '';
const ok = await copyToClipboard(splitSdPromptForCopy(full));
copyBtn.textContent = ok ? 'Скопировано' : 'Ошибка';
setTimeout(() => { copyBtn.textContent = 'Копировать'; }, 1500);
});
header.appendChild(copyBtn);
const regenBtn = document.createElement('button');
regenBtn.type = 'button';
regenBtn.className = 'copy-prompt-btn';
regenBtn.textContent = '🖼 Перегенерировать';
regenBtn.addEventListener('click', async () => {
const wrapper = block.closest('.message');
regenBtn.disabled = true;
regenBtn.textContent = '⏳…';
wrapper?.querySelectorAll('.chat-image-wrap, .chat-image, .image-error').forEach(el => el.remove());
showImageGenerating(wrapper);
try {
const res = await fetch('/images/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, prompt: promptText }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || res.statusText);
removeImageGenerating(wrapper);
appendChatImage(wrapper, data.image_path);
} catch (e) {
removeImageGenerating(wrapper);
const err = document.createElement('div');
err.className = 'image-error';
err.textContent = '🖼 ' + e.message;
wrapper?.appendChild(err);
} finally {
regenBtn.disabled = false;
regenBtn.textContent = '🖼 Перегенерировать';
}
});
header.appendChild(regenBtn);
const textEl = document.createElement('span');
textEl.className = 'prompt-text';
textEl.textContent = promptText;
block.appendChild(header);
block.appendChild(textEl);
return block;
}
export function createImagePromptBlock(promptText, promptAlt = null) {
const wrap = document.createElement('div');
wrap.className = 'image-prompt-blocks';
wrap.appendChild(createImagePromptBlockSingle('SD prompt', promptText));
const alt = (promptAlt || '').trim();
const main = (promptText || '').trim();
if (alt && alt !== main) {
wrap.appendChild(createImagePromptBlockSingle('SD prompt (только теги)', promptAlt));
}
return wrap;
}
/** Replace or create tag + optional hybrid prompt blocks under a message. */
export function ensureImagePromptBlocks(wrapper, tagPrompt, altPrompt = null) {
if (!wrapper || !tagPrompt) return;
wrapper.querySelector('.image-prompt-blocks')?.remove();
wrapper.querySelectorAll('.image-prompt-block').forEach(el => el.remove());
wrapper.appendChild(createImagePromptBlock(tagPrompt, altPrompt || null));
}
const OUTCOME_CLASS = {
'critical failure': 'outcome-crit-fail',
'failure': 'outcome-fail',
'success': 'outcome-success',
'critical success': 'outcome-crit-success',
};
function buildNarratorEl(narrator) {
const wrapper = document.createElement('div');
wrapper.className = 'message narrator';
const label = document.createElement('div');
label.className = 'label';
label.textContent = '📖 Рассказчик';
wrapper.appendChild(label);
const bubble = document.createElement('div');
bubble.className = 'bubble';
if (narrator.roll != null) {
const diceBlock = document.createElement('div');
diceBlock.className = `dice-block ${OUTCOME_CLASS[narrator.outcome] || ''}`;
diceBlock.innerHTML = `<span class="dice-icon">🎲</span><span class="dice-roll">${narrator.roll}</span><span class="dice-outcome">${narrator.outcome}</span>`;
bubble.appendChild(diceBlock);
}
const textEl = document.createElement('div');
textEl.className = 'narrator-text';
textEl.textContent = narrator.text;
bubble.appendChild(textEl);
wrapper.appendChild(bubble);
return wrapper;
}
function renderNarratorMessage(narrator) {
const el = buildNarratorEl(narrator);
dom.messagesEl.appendChild(el);
dom.messagesEl.scrollTop = dom.messagesEl.scrollHeight;
return el;
}
export function removeChoiceRows(wrapper) {
wrapper?.querySelectorAll('.choice-row').forEach(el => el.remove());
}
export function ensureMessageActionsLast(wrapper) {
const actions = wrapper?.querySelector('.message-actions');
if (actions) wrapper.appendChild(actions);
}
function renderNewArcRollButton(wrapper, choice) {
removeChoiceRows(wrapper);
const row = document.createElement('div');
row.className = 'choice-row choice-row-new-arc';
const panel = document.createElement('div');
panel.className = 'new-arc-roll';
const hdr = document.createElement('div');
hdr.className = 'new-arc-roll-header';
hdr.textContent = choice?.label || 'Начать новую арку';
panel.appendChild(hdr);
const hint = document.createElement('div');
hint.className = 'new-arc-roll-hint';
hint.textContent = 'Арка завершена. Можно продолжить эпилог или начать новую — кто ходит первым после инъекта рассказчика?';
panel.appendChild(hint);
const split = document.createElement('div');
split.className = 'new-arc-roll-split';
const mkHalf = (side, icon, title, subtitle, first) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `new-arc-half new-arc-half-${side}`;
btn.innerHTML = `<span class="new-arc-half-icon">${icon}</span><span class="new-arc-half-title">${title}</span><span class="new-arc-half-sub">${subtitle}</span>`;
btn.addEventListener('click', () => {
removeChoiceRows(wrapper);
sendMessage(choice?.label || 'Начать новую арку', {
isNarratorChoice: true,
newArcFirst: first,
});
});
return btn;
};
split.appendChild(mkHalf('user', '👤', 'Игрок', 'первый ход после инъекта', 'user'));
split.appendChild(mkHalf('character', '🤖', 'Персонаж', 'открывает новую арку', 'character'));
panel.appendChild(split);
row.appendChild(panel);
wrapper.appendChild(row);
ensureMessageActionsLast(wrapper);
}
export function renderChoices(wrapper, choices) {
if (!choices?.length || !wrapper) return;
removeChoiceRows(wrapper);
const newArc = choices.find(c => c?.type === 'new_arc_roll');
if (newArc) {
renderNewArcRollButton(wrapper, newArc);
return;
}
const isPlot = c => c?.source === 'plot_beat' || c?.source === 'plot_step';
const plotChoices = choices.filter(isPlot);
const otherChoices = choices.filter(c => !isPlot(c));
const row = document.createElement('div');
row.className = 'choice-row';
const appendBtn = (container, c) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = isPlot(c) ? 'choice-btn choice-btn-plot' : 'choice-btn';
const label = c.label || '';
btn.textContent = label;
if (c.beat_title) btn.title = c.beat_title;
btn.addEventListener('click', () => {
removeChoiceRows(wrapper);
sendMessage(label, true);
});
container.appendChild(btn);
};
const appendSection = (items, { plot } = {}) => {
if (!items.length) return;
const section = document.createElement('div');
section.className = plot ? 'choice-section choice-section-plot' : 'choice-section';
if (plot) {
const hdr = document.createElement('div');
hdr.className = 'choice-section-label';
const title = items[0].beat_title || 'Сюжетный beat';
hdr.textContent = `📜 ${title}`;
section.appendChild(hdr);
const inj = (items[0].beat_injection || '').trim();
if (inj) {
const teaser = document.createElement('div');
teaser.className = 'choice-beat-teaser';
teaser.textContent = inj.length > 220 ? `${inj.slice(0, 217)}` : inj;
section.appendChild(teaser);
}
} else if (plotChoices.length) {
const hdr = document.createElement('div');
hdr.className = 'choice-section-label choice-section-label-generic';
hdr.textContent = '🔘 Дальше';
section.appendChild(hdr);
}
const btns = document.createElement('div');
btns.className = 'choice-section-btns';
for (const c of items) appendBtn(btns, c);
section.appendChild(btns);
row.appendChild(section);
};
appendSection(plotChoices, { plot: true });
appendSection(otherChoices);
wrapper.appendChild(row);
ensureMessageActionsLast(wrapper);
}
function restoreChoicesFromHistory(messages) {
const visible = messages.filter(m => m.role !== 'system');
if (!visible.length) return;
const last = visible[visible.length - 1];
if (last.role !== 'assistant' || !last.choices_json) return;
let choices = [];
try {
choices = JSON.parse(last.choices_json);
} catch { return; }
if (!choices?.length) return;
const wrapper = dom.messagesEl.querySelector(
`.message.assistant[data-message-id="${last.id}"]`,
);
if (wrapper) renderChoices(wrapper, choices);
}
function finalizeAssistantMessage(wrapper, messageId, choices) {
if (!wrapper || !messageId) return;
wrapper.dataset.messageId = String(messageId);
attachMessageActions(wrapper, Number(messageId), 'assistant');
if (choices?.length) renderChoices(wrapper, choices);
else ensureMessageActionsLast(wrapper);
}
function showNarratorActivityHint(wrapper, meta) {
if (!wrapper || !meta) return;
wrapper.querySelector('.narrator-activity-hint')?.remove();
const parts = [];
if (meta.post_ok === false && meta.pre_ok === false) {
parts.push('⚠️ Narrator LLM не ответил — проверьте ROUTER_KEY / RPG_NARRATOR_MODEL');
} else {
if (meta.dice) parts.push('🎲 бросок');
if (meta.directives_count > 0) parts.push(`📋 ${meta.directives_count} указаний`);
if (meta.choices_count > 0) parts.push(`🔘 ${meta.choices_count} выборов`);
if (meta.status_quo) parts.push('🌍 status_quo');
if (meta.beats_replenished) parts.push(`📜 +${meta.beats_replenished} beats`);
if (meta.step_advanced) parts.push('📜 шаг +1');
if (meta.arc_completed) parts.push('🏁 арка завершена');
if (meta.new_arc_rolled) parts.push('📖 новая арка');
if (meta.story_step) parts.push(`📜 ${meta.story_step}`);
if (meta.rp_language) parts.push(`🌐 ${meta.rp_language}`);
if (meta.arc_pruned) parts.push(`🧹 ${meta.arc_pruned} beat`);
if (meta.facts_added) parts.push(`📌 +${meta.facts_added} фактов`);
}
if (!parts.length) return;
const el = document.createElement('div');
el.className = 'narrator-activity-hint';
el.textContent = `📖 Narrator: ${parts.join(' · ')}`;
wrapper.appendChild(el);
ensureMessageActionsLast(wrapper);
}
function renderDebugBlocks(wrapper, blocks) {
if (!blocks?.length) return;
for (const b of blocks) {
if (!b?.text) continue;
if (b.type === 'narrator_injection' || b.type === 'status_quo') {
const w = document.createElement('div');
w.className = 'message narrator';
const lbl = document.createElement('div');
lbl.className = 'label';
lbl.textContent = '📖 Рассказчик';
const bub = document.createElement('div');
bub.className = 'bubble';
bub.textContent = b.text;
w.appendChild(lbl);
w.appendChild(bub);
dom.messagesEl.appendChild(w);
}
}
}
function questIdEq(a, b) {
return Number(a) === Number(b);
}
function syncQuestActionButtons() {
const doneBtn = document.getElementById('questBtnDone');
const failBtn = document.getElementById('questBtnFail');
const active = _selectedQuestId != null
&& _questsCache.some(q => questIdEq(q.id, _selectedQuestId) && q.status === 'active');
if (doneBtn) doneBtn.disabled = !active;
if (failBtn) failBtn.disabled = !active;
}
export function updateQuestPanel(quests, storyArc = null) {
const list = document.getElementById('questList');
const actions = document.getElementById('questPanelActions');
const header = document.getElementById('questPanelHeader');
if (!list) return;
_questsCache = quests || [];
list.innerHTML = '';
if (header && storyArc) {
const steps = storyArc.steps || [];
const idx = Number(storyArc.current_step_index ?? 0);
const cur = steps.length ? Math.min(idx + 1, steps.length) : 0;
const title = storyArc.title || 'Арка';
const status = storyArc.status === 'completed' ? ' · завершена' : '';
header.textContent = `Арка: ${title} · шаг ${cur}/${steps.length || '?'}${status}`;
header.classList.remove('hidden');
} else if (header) {
header.textContent = '';
header.classList.add('hidden');
}
if (!_questsCache.length) {
_selectedQuestId = null;
syncQuestActionButtons();
return;
}
if (_selectedQuestId != null && !_questsCache.some(
q => questIdEq(q.id, _selectedQuestId) && q.status === 'active',
)) {
_selectedQuestId = null;
}
for (const q of _questsCache) {
const el = document.createElement('div');
el.className = `quest-item quest-${q.status}`;
if (questIdEq(q.id, _selectedQuestId)) el.classList.add('quest-selected');
if (q.status === 'done') el.classList.add('quest-done');
if (q.status === 'failed') el.classList.add('quest-failed');
el.dataset.questId = String(q.id);
el.textContent = (q.status === 'done' ? '✅ ' : q.status === 'failed' ? '❌ ' : '🔸 ') + q.title;
if (q.status === 'active') {
el.addEventListener('click', () => {
const qid = Number(q.id);
_selectedQuestId = questIdEq(_selectedQuestId, qid) ? null : qid;
updateQuestPanel(_questsCache);
});
}
list.appendChild(el);
}
syncQuestActionButtons();
}
async function patchSelectedQuest(status) {
if (!sessionId || _selectedQuestId == null) {
alert('Сначала выберите активный квест (🔸) в списке.');
return;
}
const q = _questsCache.find(x => questIdEq(x.id, _selectedQuestId));
if (!q || q.status !== 'active') return;
const verb = status === 'done' ? 'завершить' : 'отметить провалом';
if (!confirm(`${verb.charAt(0).toUpperCase() + verb.slice(1)} квест?\n\n«${q.title}»`)) return;
const doneBtn = document.getElementById('questBtnDone');
const failBtn = document.getElementById('questBtnFail');
doneBtn && (doneBtn.disabled = true);
failBtn && (failBtn.disabled = true);
try {
const res = await fetch(`/sessions/${sessionId}/quests/${Number(_selectedQuestId)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
alert(err.detail || res.statusText);
return;
}
_selectedQuestId = null;
const listRes = await fetch(`/sessions/${sessionId}/quests`);
if (listRes.ok) updateQuestPanel(await listRes.json());
} finally {
syncQuestActionButtons();
}
}
export function initQuestPanel() {
const panel = document.getElementById('questPanel');
if (!panel || panel.dataset.questBound === '1') return;
panel.dataset.questBound = '1';
panel.addEventListener('click', (e) => {
if (e.target.id === 'questBtnDone') {
e.preventDefault();
patchSelectedQuest('done');
} else if (e.target.id === 'questBtnFail') {
e.preventDefault();
patchSelectedQuest('failed');
}
});
}
export function updateAffinityDisplay(affinity) {
const el = dom.affinityDisplay;
if (!el) return;
el.classList.remove('hidden');
const hearts = affinity >= 10 ? '❤️❤️❤️' : affinity >= 5 ? '❤️❤️' : affinity >= 1 ? '❤️' : affinity <= -5 ? '💔' : '🤍';
el.textContent = `${hearts} ${affinity > 0 ? '+' : ''}${affinity}`;
el.className = `affinity-display ${affinity > 5 ? 'affinity-high' : affinity < -3 ? 'affinity-low' : ''}`;
}
export function updateStatsDisplay(stats) {
const el = dom.statsDisplay;
if (!el || !stats) return;
const lust = Number(stats.lust ?? 0);
const stamina = Number(stats.stamina ?? 10);
const tension = Number(stats.tension ?? 0);
el.textContent = `🔥${lust}${stamina} 😰${tension}`;
el.className = 'stats-display';
if (stamina <= 2 || tension >= 8) el.classList.add('stats-critical');
else if (stamina <= 4 || tension >= 6) el.classList.add('stats-warn');
el.classList.remove('hidden');
}
export function hideStatsDisplay() {
dom.statsDisplay?.classList.add('hidden');
}
export function appendChatImage(wrapper, imagePath, label = '') {
if (!imagePath) return;
const figure = document.createElement('figure');
figure.className = 'chat-image-wrap';
if (label) {
const cap = document.createElement('figcaption');
cap.className = 'chat-image-label';
cap.textContent = label;
figure.appendChild(cap);
}
const img = document.createElement('img');
img.className = 'chat-image';
img.src = imagePath;
figure.appendChild(img);
wrapper.appendChild(figure);
}
export function showImageGenerating(wrapper) {
if (!wrapper || wrapper.querySelector('.image-generating')) return;
const el = document.createElement('div');
el.className = 'image-generating';
el.setAttribute('role', 'status');
el.innerHTML = '<span class="image-generating-spinner" aria-hidden="true"></span><span class="image-generating-text">Генерация изображения в ComfyUI…</span>';
wrapper.appendChild(el);
dom.messagesEl.scrollTop = dom.messagesEl.scrollHeight;
}
export function removeImageGenerating(wrapper) {
wrapper?.querySelector('.image-generating')?.remove();
}
function attachMessageActions(wrapper, messageId, role) {
if (!messageId) return;
wrapper.dataset.messageId = String(messageId);
wrapper.querySelector('.message-actions')?.remove();
const actions = document.createElement('div');
actions.className = 'message-actions';
const editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.textContent = '✏️';
editBtn.title = 'Редактировать';
editBtn.addEventListener('click', () => startEditMessage(wrapper, messageId));
actions.appendChild(editBtn);
if (role === 'assistant') {
const regenBtn = document.createElement('button');
regenBtn.type = 'button';
regenBtn.textContent = '🔄';
regenBtn.title = 'Перегенерировать';
regenBtn.addEventListener('click', () => regenerateMessage(messageId, wrapper));
actions.appendChild(regenBtn);
}
const branchBtn = document.createElement('button');
branchBtn.type = 'button';
branchBtn.textContent = '🌿';
branchBtn.title = 'Ветка отсюда';
branchBtn.addEventListener('click', () => forkFromMessage(messageId));
actions.appendChild(branchBtn);
const delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.textContent = '🗑';
delBtn.title = 'Удалить сообщение и всё после него';
delBtn.addEventListener('click', () => deleteMessageAndAfter(messageId, wrapper));
actions.appendChild(delBtn);
wrapper.appendChild(actions);
ensureMessageActionsLast(wrapper);
}
async function deleteMessageAndAfter(messageId, wrapper) {
if (!sessionId || !messageId) return;
if (!confirm('Удалить это сообщение и всю переписку после него?')) return;
const res = await fetch(`/chat/messages/${messageId}`, { method: 'DELETE' });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
alert(err.detail || res.statusText);
return;
}
await reloadChatFromServer(sessionId);
const listRes = await fetch(`/sessions/${sessionId}/quests`);
if (listRes.ok) updateQuestPanel(await listRes.json());
}
async function startEditMessage(wrapper, messageId) {
const bubble = wrapper.querySelector('.bubble');
if (!bubble || wrapper.querySelector('.bubble-edit')) return;
const original = bubble.textContent;
const ta = document.createElement('textarea');
ta.className = 'bubble-edit';
ta.value = original;
bubble.replaceWith(ta);
wrapper.querySelector('.message-actions')?.remove();
const saveRow = document.createElement('div');
saveRow.className = 'message-actions';
const saveBtn = document.createElement('button');
saveBtn.textContent = 'Сохранить';
const cancelBtn = document.createElement('button');
cancelBtn.textContent = 'Отмена';
saveRow.appendChild(saveBtn);
saveRow.appendChild(cancelBtn);
wrapper.appendChild(saveRow);
cancelBtn.addEventListener('click', () => reloadChatFromServer(sessionId));
saveBtn.addEventListener('click', async () => {
const role = wrapper.classList.contains('user') ? 'user' : 'assistant';
const doTruncate = confirm(role === 'user' ? 'Удалить все сообщения после этого? (рекомендуется)' : 'Удалить все сообщения после этого?');
const res = await fetch(`/chat/messages/${messageId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: ta.value.trim(), truncate_after: doTruncate }),
});
if (!res.ok) { alert('Ошибка сохранения'); return; }
await reloadChatFromServer(sessionId);
const { loadSessions } = await import('./sessions.js');
loadSessions();
});
}
async function regenerateMessage(messageId, wrapper) {
if (!sessionId) return;
wrapper?.remove();
showTyping();
dom.sendBtn.disabled = true;
try {
const res = await fetch('/chat/regenerate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, message_id: messageId }),
});
if (!res.ok) throw new Error('Ошибка: ' + res.status);
removeTyping();
await consumeStream(res);
} catch (err) {
removeTyping();
addMessage('assistant', '⚠️ ' + err.message);
} finally {
dom.sendBtn.disabled = false;
}
}
async function forkFromMessage(messageId) {
if (!sessionId) return;
const res = await fetch(`/sessions/${sessionId}/fork`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ until_message_id: messageId }),
});
const data = await res.json();
if (!res.ok) { alert(data.detail || 'Ошибка'); return; }
const { switchSession, loadSessions } = await import('./sessions.js');
await switchSession(data.session_id);
await loadSessions();
}
export async function reloadChatFromServer(id) {
const sid = id || sessionId;
if (!sid) return;
const histRes = await fetch(`/chat/history/${sid}`);
if (!histRes.ok) return;
const messages = await histRes.json();
clearMessages();
messages.filter(m => m.role !== 'system').forEach(m => {
if (m.role === 'narrator') {
try {
const data = typeof m.content === 'string' ? JSON.parse(m.content) : m.content;
if (data?.text) renderNarratorMessage(data);
} catch {
const plain = (m.content || '').trim();
if (plain) renderNarratorMessage({ text: plain });
}
return;
}
addMessage(
m.role === 'user' ? 'user' : 'assistant',
m.content,
m.image_prompt,
m.image_path ? `/static/${m.image_path}` : null,
m.id,
m.image_prompt_alt,
m.image_path_alt ? `/static/${m.image_path_alt}` : null,
m.role === 'user' ? m.action_resolution : null,
);
});
restoreChoicesFromHistory(messages);
}
const IMAGE_PROMPT_RE = /\[IMAGE_PROMPT:.*?\]/gs;
async function consumeStream(res) {
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let bubble = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
let data;
try { data = JSON.parse(line.slice(6)); } catch { continue; }
// Narrator arrives BEFORE chunks — render immediately
if (data.narrator) {
renderNarratorMessage(data.narrator);
if (
_pendingUserBubble
&& data.narrator.roll != null
&& data.narrator.original_intent
&& !isPlayerChoiceContent(_pendingUserBubble.dataset.raw || '')
) {
const resolution = {
intent_text: data.narrator.original_intent,
resolution_text: data.narrator.text,
roll: data.narrator.roll,
outcome: data.narrator.outcome,
};
_pendingUserBubble._diceResolution = resolution;
applyDiceOverrideToBubble(_pendingUserBubble, resolution);
_pendingUserBubble.closest('.message')?.classList.add('has-dice-override');
}
}
if (data.chunk !== undefined) {
if (!bubble) {
bubble = addMessage('assistant', '');
bubble.classList.add('typing-active');
}
bubble.textContent += data.chunk;
dom.messagesEl.scrollTop = dom.messagesEl.scrollHeight;
}
if (data.image_generating && bubble) {
bubble.classList.remove('typing-active');
const wrapper = bubble.parentElement;
if (data.image_prompt) {
ensureImagePromptBlocks(
wrapper,
data.image_prompt,
data.image_prompt_alt || null,
);
}
showImageGenerating(wrapper);
dom.messagesEl.scrollTop = dom.messagesEl.scrollHeight;
}
if (data.done) {
const wrapper = bubble?.parentElement;
removeImageGenerating(wrapper);
bubble?.classList.remove('typing-active');
// Strip IMAGE_PROMPT tag; apply server-side OOC strip if provided
if (bubble) {
const fromServer = data.assistant_content;
const cleaned = (fromServer ?? bubble.textContent)
.replace(IMAGE_PROMPT_RE, '')
.trim();
initBubbleContent(bubble, cleaned, { formatted: true });
}
if (data.image_prompt && wrapper) {
ensureImagePromptBlocks(
wrapper,
data.image_prompt,
data.image_prompt_alt || null,
);
}
if (data.image_path && wrapper) {
appendChatImage(wrapper, data.image_path, '');
}
if (data.image_error && wrapper) {
const err = document.createElement('div');
err.className = 'image-error';
err.textContent = '🖼 ' + data.image_error;
wrapper.appendChild(err);
}
if (bubble?.parentElement && data.assistant_message_id) {
finalizeAssistantMessage(
bubble.parentElement,
data.assistant_message_id,
data.choices,
);
} else if (data.choices?.length) {
const choiceHost = bubble?.parentElement
|| [...dom.messagesEl.querySelectorAll('.message.narrator')].pop()
|| [...dom.messagesEl.querySelectorAll('.message.assistant')].pop();
if (choiceHost) renderChoices(choiceHost, data.choices);
}
if (data.debug) renderDebugBlocks(bubble?.parentElement || dom.messagesEl, data.debug);
if (data.narrator_meta && bubble?.parentElement) {
showNarratorActivityHint(bubble.parentElement, data.narrator_meta);
}
if (data.affinity !== undefined) updateAffinityDisplay(data.affinity);
if (data.narrative_stats) updateStatsDisplay(data.narrative_stats);
if (data.quests !== undefined) {
updateQuestPanel(data.quests, data.story_arc ?? null);
}
_pendingUserBubble = null;
const { loadSessions } = await import('./sessions.js');
loadSessions();
}
}
}
}
export function addMessage(
role,
content = '',
imagePrompt = null,
imagePath = null,
messageId = null,
imagePromptAlt = null,
imagePathAlt = null,
actionResolution = null,
) {
updateEmptyState();
const wrapper = document.createElement('div');
wrapper.className = `message ${role}`;
const label = document.createElement('div');
label.className = 'label';
label.textContent = role === 'user' ? 'Вы' : 'AI';
wrapper.appendChild(label);
let displayContent = content;
let prompt = imagePrompt;
if (role === 'assistant' && !prompt) {
const parsed = parseImagePromptFromContent(content);
displayContent = parsed.text;
prompt = parsed.prompt;
}
const bubble = document.createElement('div');
bubble.className = 'bubble';
initBubbleContent(bubble, displayContent, {
formatted: !!displayContent,
actionResolution: role === 'user' ? actionResolution : null,
});
wrapper.appendChild(bubble);
if (role === 'user' && actionResolution?.resolution_text) {
wrapper.classList.add('has-dice-override');
}
if (displayContent) {
attachFormatToggle(wrapper, bubble);
}
if (role === 'assistant') {
const translateBtn = document.createElement('button');
translateBtn.type = 'button';
translateBtn.className = 'translate-btn';
translateBtn.textContent = '🌐 RU';
let originalText = null;
translateBtn.addEventListener('click', async () => {
if (originalText !== null) {
initBubbleContent(bubble, originalText, { formatted: true });
originalText = null;
translateBtn.textContent = '🌐 RU';
return;
}
originalText = bubble.dataset.raw ?? bubble.textContent;
translateBtn.disabled = true;
translateBtn.textContent = '…';
try {
const res = await fetch('/translate/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: originalText }),
});
if (!res.ok) throw new Error(res.statusText);
const data = await res.json();
initBubbleContent(bubble, data.translated, { formatted: true });
bubble.dataset.raw = data.translated;
translateBtn.textContent = '↩ Оригинал';
} catch {
originalText = null;
translateBtn.textContent = '⚠️';
setTimeout(() => { translateBtn.textContent = '🌐 RU'; }, 2000);
}
translateBtn.disabled = false;
});
wrapper.appendChild(translateBtn);
}
if (prompt) wrapper.appendChild(createImagePromptBlock(prompt, imagePromptAlt));
if (imagePath) appendChatImage(wrapper, imagePath, imagePathAlt ? 'Теги' : '');
if (imagePathAlt) appendChatImage(wrapper, imagePathAlt, 'Гибрид');
attachMessageActions(wrapper, messageId, role);
dom.messagesEl.appendChild(wrapper);
dom.messagesEl.scrollTop = dom.messagesEl.scrollHeight;
return bubble;
}
export function showTyping() {
const typing = document.createElement('div');
typing.className = 'typing';
typing.id = 'typing';
typing.innerHTML = '<span></span><span></span><span></span>';
dom.messagesEl.appendChild(typing);
dom.messagesEl.scrollTop = dom.messagesEl.scrollHeight;
}
export function removeTyping() {
document.getElementById('typing')?.remove();
}
export function clearMessages() {
dom.messagesEl.innerHTML = '';
if (dom.emptyState) {
dom.messagesEl.appendChild(dom.emptyState);
dom.emptyState.classList.remove('hidden');
}
}
export async function sendMessage(text, choiceOpts = false) {
if (typeof text !== 'string') text = dom.inputEl.value.trim();
if (!text || !sessionId) return;
let isNarratorChoice = false;
let newArcFirst = null;
if (typeof choiceOpts === 'boolean') {
isNarratorChoice = choiceOpts;
} else if (choiceOpts && typeof choiceOpts === 'object') {
isNarratorChoice = !!choiceOpts.isNarratorChoice;
newArcFirst = choiceOpts.newArcFirst || null;
}
dom.inputEl.value = '';
dom.inputEl.style.height = 'auto';
dom.sendBtn.disabled = true;
let userContent = text;
if (isNarratorChoice && newArcFirst) {
const who = newArcFirst === 'user' ? 'игрок' : 'персонаж';
userContent = `[Player chose: Начать новую арку — первый ход: ${who}]`;
} else if (isNarratorChoice) {
userContent = `[Player chose: ${text}]`;
}
dom.messagesEl.querySelectorAll('.message.assistant, .message.narrator').forEach(w => removeChoiceRows(w));
_pendingUserBubble = addMessage('user', userContent);
showTyping();
try {
const res = await fetch('/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: text,
session_id: sessionId,
is_narrator_choice: isNarratorChoice,
new_arc_first: newArcFirst,
}),
});
if (!res.ok) throw new Error('Ошибка сервера: ' + res.status);
removeTyping();
await consumeStream(res);
} catch (err) {
removeTyping();
addMessage('assistant', '⚠️ Ошибка: ' + err.message);
} finally {
dom.sendBtn.disabled = false;
dom.inputEl.focus();
}
}
export async function clearHistory() {
if (!sessionId) return;
await fetch(`/chat/${sessionId}`, { method: 'DELETE' });
clearMessages();
}