Files
ChatAIBot/static/js/chat.js
T
2026-06-04 08:05:06 +03:00

858 lines
33 KiB
JavaScript
Raw 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);
}
export function renderChoices(wrapper, choices) {
if (!choices?.length || !wrapper) return;
removeChoiceRows(wrapper);
const plotChoices = choices.filter(c => c?.source === 'plot_beat');
const otherChoices = choices.filter(c => c?.source !== 'plot_beat');
const row = document.createElement('div');
row.className = 'choice-row';
const appendBtn = (container, c) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = c.source === 'plot_beat' ? '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.beat_mode === 'after_dice') parts.push('📜 beat (d20)');
if (meta.beat_mode === 'llm') parts.push('📜 beat (AI)');
if (meta.beat_mode === 'stuck_recovery') parts.push('📜 beat (recovery)');
if (meta.beat_mode === 'trigger') parts.push('📜 beat (keywords)');
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) {
const list = document.getElementById('questList');
const actions = document.getElementById('questPanelActions');
if (!list) return;
_questsCache = quests || [];
list.innerHTML = '';
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 { /* ignore bad narrator payload */ }
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 && bubble) {
renderChoices(bubble.parentElement, 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?.length) updateQuestPanel(data.quests);
_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, isNarratorChoice = false) {
if (typeof text !== 'string') text = dom.inputEl.value.trim();
if (!text || !sessionId) return;
dom.inputEl.value = '';
dom.inputEl.style.height = 'auto';
dom.sendBtn.disabled = true;
const userContent = isNarratorChoice ? `[Player chose: ${text}]` : text;
dom.messagesEl.querySelectorAll('.message.assistant').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 }),
});
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();
}