// ---------- Onglet "Générer avec l'IA" — chat scopé à L'ÉCRAN OUVERT ---------- // Persiste la conversation (routes/ia/ia_chat.py) et pilote l'agent // Ruby (Claude Sonnet 5) via des tools qui appellent les mêmes fonctions // que l'éditeur manuel (voir ai/tools.py) — ce que l'IA pose doit donc // apparaître ICI sans recharger la page, sinon on croit que ça ne marche // pas (demande explicite) : voir iaChatRefreshAfterTurn() plus bas. // // Plusieurs CONVERSATIONS par écran (demande explicite : gérer/créer/ // supprimer des fils, comme claude.ai) — _iaActiveConversationId retient // laquelle est affichée dans le fil de droite. var _iaActiveConversationId = null; function iaChatEmptyStateHtml() { return ( '
' + '' + 'Décris ce que tu veux à Ruby' + 'Des images, des dialogues, une variable, ou un écran complet — décris-le en une phrase et envoie.' + '
' ); } // #iaChatLog défile lui-même (voir static/style.css — maquette validée : // header/body/footer par colonne, seul le BODY de chaque colonne défile, // jamais l'onglet entier). function iaChatScrollToBottom() { var log = document.getElementById('iaChatLog'); if (log) log.scrollTop = log.scrollHeight; } // "Ruby" — identité visuelle de l'IA (demande explicite) : même mark que // Forge (static/branding/forge-logo.svg), recoloré rouge/rose pâle (voir // static/branding/ruby-logo.svg), jamais un logo différent. function iaChatSenderHtml(isUser) { if (isUser) return 'Toi'; return ' Ruby'; } function iaChatRowHtml(message) { var isUser = message.role === 'user'; var row = document.createElement('div'); row.className = 'iaChatRow ' + (isUser ? 'iaChatRow-user' : 'iaChatRow-assistant'); var sender = document.createElement('div'); sender.className = 'iaChatSender'; sender.innerHTML = iaChatSenderHtml(isUser); var bubble = document.createElement('div'); bubble.className = 'iaChatBubble'; bubble.textContent = message.content; row.appendChild(sender); row.appendChild(bubble); return row; } // Affichée à la place de la réponse le temps que Ruby/les outils // travaillent (peut prendre plusieurs secondes, un écran complet enchaîne // plusieurs appels d'outils) — sans ça, un créateur qui ne voit rien // bouger clique à nouveau, pensant que ça n'a pas marché. function iaChatLoaderRowHtml() { var row = document.createElement('div'); row.className = 'iaChatRow iaChatRow-assistant'; var sender = document.createElement('div'); sender.className = 'iaChatSender'; sender.innerHTML = iaChatSenderHtml(false); var bubble = document.createElement('div'); bubble.className = 'iaChatBubble iaChatTyping'; bubble.innerHTML = ''; row.appendChild(sender); row.appendChild(bubble); return row; } function iaChatAutoGrow(textarea) { textarea.style.height = 'auto'; textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px'; } // Un tour peut poser des objets/variables/déclencheurs (voir ai/tools.py) // — jamais su à l'avance lesquels depuis la seule réponse texte, donc on // rafraîchit systématiquement les 3 zones que l'IA peut modifier : // Déclencheurs (déjà un composant JS, triggerLoadAll() suffit), Variables // et la scène (tous deux rendus côté serveur — on re-télécharge CETTE // MÊME page et on ne récupère que ces deux fragments, jamais un // rechargement visible/perte de l'onglet IA en cours). function iaChatRefreshAfterTurn() { if (typeof triggerLoadAll === 'function') triggerLoadAll(); fetch(window.location.href) .then(function (r) { return r.text(); }) .then(function (html) { var doc = new DOMParser().parseFromString(html, 'text/html'); var newCanvasFrame = doc.querySelector('.canvasFrame'); var oldCanvasFrame = document.querySelector('.canvasFrame'); if (newCanvasFrame && oldCanvasFrame) { oldCanvasFrame.innerHTML = newCanvasFrame.innerHTML; // Bug corrigé ("plus moyen de rien poser hors du champ de la // caméra") : SCENE_WIDTH/SCENE_HEIGHT (posées une seule fois à // l'ouverture de la page, voir templates/scene_edit.html) ne sont // jamais rejouées par ce rafraîchissement partiel — un fond // agrandi par l'IA (le "monde" devient plus grand que la caméra // nominale) laissait ces globales à leur ANCIENNE valeur, et le // glisser-déposer (scene-editor.js) continuait de tout borner à // l'ancien cadre. #canvas vient d'être remplacé avec sa VRAIE // taille à jour (voir world_width/world_height, // routes/scenes/scene_edit_view.py) — on la relit directement // dessus plutôt que de rejouer un script inline. var canvas = document.getElementById('canvas'); if (canvas) { var newWidth = parseFloat(canvas.style.width); var newHeight = parseFloat(canvas.style.height); if (!isNaN(newWidth)) window.SCENE_WIDTH = newWidth; if (!isNaN(newHeight)) window.SCENE_HEIGHT = newHeight; } } var newVariablesPanel = doc.getElementById('variablesTabPanel'); var oldVariablesPanel = document.getElementById('variablesTabPanel'); if (newVariablesPanel && oldVariablesPanel) oldVariablesPanel.innerHTML = newVariablesPanel.innerHTML; // Relance les aperçus animés (voir personnage-preview.js) — un // personnage tout juste posé par l'IA reste sur sa frame statique // (ELEMENT_ANIMATIONS_MAP n'est mis à jour qu'au vrai rechargement // de page), léger mais sans conséquence fonctionnelle. if (typeof startAllPersonnagePreviews === 'function') startAllPersonnagePreviews(); }); } function iaChatSend() { var input = document.getElementById('iaChatInput'); var sendBtn = document.getElementById('iaChatSendBtn'); if (sendBtn.disabled) return; // déjà une requête en cours — jamais spammer l'IA var message = (input.value || '').trim(); if (!message || !_iaActiveConversationId) return; var log = document.getElementById('iaChatLog'); var emptyState = log.querySelector('.iaChatEmpty'); if (emptyState) emptyState.remove(); // Le message du créateur s'affiche TOUT DE SUITE (avant même la // requête) — sinon, tant que l'IA travaille, l'écran semble ignorer ce // qu'on vient de taper. log.appendChild(iaChatRowHtml({ role: 'user', content: message })); var loaderRow = iaChatLoaderRowHtml(); log.appendChild(loaderRow); iaChatScrollToBottom(); input.value = ''; iaChatAutoGrow(input); input.disabled = true; sendBtn.disabled = true; var conversationId = _iaActiveConversationId; fetch('/game/' + GAME_SLUG + '/ia-conversations/' + conversationId + '/messages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: message }), }) .then(function (r) { return r.json(); }) .then(function (json) { loaderRow.remove(); if (json.assistant_message) log.appendChild(iaChatRowHtml(json.assistant_message)); iaChatScrollToBottom(); iaChatRefreshAfterTurn(); // Le titre de la conversation vient de se dériver de ce premier // message (voir screens/ia/add_ia_chat_message.py) — recharge la // colonne pour l'afficher sans attendre un changement d'onglet. iaChatLoadConversationsList(conversationId); }) .catch(function () { loaderRow.remove(); log.appendChild(iaChatRowHtml({ role: 'assistant', content: 'Erreur réseau — réessaie dans un instant.' })); iaChatScrollToBottom(); }) .finally(function () { input.disabled = false; sendBtn.disabled = false; input.focus(); }); } function iaChatInputKeydown(event) { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); iaChatSend(); } } // ---------- Colonne "Conversations" (CRUD, comme claude.ai) ---------- function iaChatConversationItemHtml(conversation) { var active = conversation.id === _iaActiveConversationId; return ( '' ); } function _iaEscapeHtml(text) { var div = document.createElement('div'); div.textContent = text || ''; return div.innerHTML; } function iaChatRenderConversationsList(conversations) { var list = document.getElementById('iaConversationsList'); if (!list) return; list.innerHTML = conversations.map(iaChatConversationItemHtml).join(''); } function iaChatLoadConversationsList(activeIdOverride) { fetch('/game/' + GAME_SLUG + '/screens/' + SCREEN_ID + '/ia-conversations') .then(function (r) { return r.json(); }) .then(function (conversations) { if (activeIdOverride) _iaActiveConversationId = activeIdOverride; iaChatRenderConversationsList(conversations); }); } function iaChatLoadMessages(conversationId) { var log = document.getElementById('iaChatLog'); log.innerHTML = ''; fetch('/game/' + GAME_SLUG + '/ia-conversations/' + conversationId + '/messages') .then(function (r) { return r.json(); }) .then(function (messages) { if (!messages || !messages.length) { log.innerHTML = iaChatEmptyStateHtml(); return; } messages.forEach(function (message) { log.appendChild(iaChatRowHtml(message)); }); iaChatScrollToBottom(); }); } function iaChatSelectConversation(conversationId) { _iaActiveConversationId = conversationId; document.querySelectorAll('.iaConversationItem').forEach(function (el) { el.classList.remove('active'); }); var item = document.querySelector('.iaConversationItem[data-conversation-id="' + conversationId + '"]'); if (item) item.classList.add('active'); iaChatLoadMessages(conversationId); } function iaChatCreateConversation() { fetch('/game/' + GAME_SLUG + '/screens/' + SCREEN_ID + '/ia-conversations', { method: 'POST' }) .then(function (r) { return r.json(); }) .then(function (conversation) { _iaActiveConversationId = conversation.id; iaChatLoadConversationsList(conversation.id); var log = document.getElementById('iaChatLog'); log.innerHTML = iaChatEmptyStateHtml(); document.getElementById('iaChatInput').focus(); }); } function iaChatDeleteConversation(event, conversationId) { event.stopPropagation(); // jamais sélectionner la conversation qu'on supprime if (!confirm('Supprimer cette conversation ? Cette action est irréversible.')) return; fetch('/game/' + GAME_SLUG + '/ia-conversations/' + conversationId, { method: 'DELETE' }) .then(function () { return fetch('/game/' + GAME_SLUG + '/screens/' + SCREEN_ID + '/ia-conversations'); }) .then(function (r) { return r.json(); }) .then(function (conversations) { if (conversationId === _iaActiveConversationId) { if (conversations.length) { _iaActiveConversationId = conversations[0].id; iaChatLoadMessages(_iaActiveConversationId); iaChatRenderConversationsList(conversations); } else { // Plus aucune conversation — en recrée une tout de suite (le // fil de droite doit toujours pouvoir recevoir un message). iaChatCreateConversation(); } } else { iaChatRenderConversationsList(conversations); } }); } // Même patron que initTriggersTab() (trigger-editor.js) : appelé // directement, pas de DOMContentLoaded — rejoué par pjax.js à chaque // remplacement de
. function initIaTab() { var body = document.getElementById('iaTabPanelBody'); if (!body) return; // Maquette validée (Artifact "Maquette Ruby") : une div pleine // hauteur/largeur de l'onglet, 2 colonnes (gauche 40%, droite 60%), // chacune header/body/footer. Les deux zones non précisées (footer // gauche, header droite) restent vides pour l'instant. body.innerHTML = '
' + '
' + '
' + '' + 'Ruby' + '
' + '
' + '' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '' + '' + '
' + '
' + '
' + '
'; fetch('/game/' + GAME_SLUG + '/screens/' + SCREEN_ID + '/ia-conversations') .then(function (r) { return r.json(); }) .then(function (conversations) { if (!conversations.length) { // Premier passage sur cet écran : aucune conversation encore — // en crée une tout de suite, jamais un onglet vide sans rien à // cliquer. iaChatCreateConversation(); return; } _iaActiveConversationId = conversations[0].id; // la plus récente iaChatRenderConversationsList(conversations); iaChatLoadMessages(_iaActiveConversationId); }); } initIaTab();