import { sessionId, currentPersona, dom } from './state.js'; import { parseImagePromptFromContent, copyToClipboard, splitSdPromptForCopy } from './utils.js'; 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 renderChoices(wrapper, choices) { if (!choices?.length) return; const row = document.createElement('div'); row.className = 'choice-row'; for (const c of choices) { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'choice-btn'; btn.textContent = c.label; btn.addEventListener('click', () => sendMessage(c.label, true)); row.appendChild(btn); } wrapper.appendChild(row); } function renderDebugBlocks(wrapper, blocks) { if (!blocks?.length) return; for (const b of blocks) { if (!b?.text) continue; if (b.type === 'narrator_injection') { 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); } } } export function updateQuestPanel(quests) { const list = document.getElementById('questList'); if (!list) return; list.innerHTML = ''; for (const q of quests) { const el = document.createElement('div'); el.className = `quest-item quest-${q.status}`; el.textContent = (q.status === 'done' ? '✅ ' : q.status === 'failed' ? '❌ ' : '🔸 ') + q.title; list.appendChild(el); } } 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 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); 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); wrapper.appendChild(actions); } 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 => { 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, ); }); } 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 (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 from final text if (bubble) { bubble.textContent = bubble.textContent.replace(IMAGE_PROMPT_RE, '').trim(); } 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 (data.choices?.length && bubble) renderChoices(bubble.parentElement, data.choices); if (data.debug) renderDebugBlocks(bubble?.parentElement || dom.messagesEl, data.debug); if (data.affinity !== undefined) updateAffinityDisplay(data.affinity); if (data.quests?.length) updateQuestPanel(data.quests); const { loadSessions } = await import('./sessions.js'); loadSessions(); } } } } export function addMessage( role, content = '', imagePrompt = null, imagePath = null, messageId = null, imagePromptAlt = null, imagePathAlt = 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'; bubble.textContent = displayContent; wrapper.appendChild(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) { bubble.textContent = originalText; originalText = null; translateBtn.textContent = '🌐 RU'; return; } originalText = 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(); bubble.textContent = 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, 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; addMessage('user', isNarratorChoice ? `[${text}]` : text); 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(); }