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 = `🎨 ${label}`; 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 = `🎲${narrator.roll}${narrator.outcome}`; 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 = `${icon}${title}${subtitle}`; 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 = 'Генерация изображения в ComfyUI…'; 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 = ''; 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(); }