// ---------- 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. let _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() { const 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) { const isUser = message.role === 'user'; const row = document.createElement('div'); row.className = `iaChatRow ${isUser ? 'iaChatRow-user' : 'iaChatRow-assistant'}`; const sender = document.createElement('div'); sender.className = 'iaChatSender'; sender.innerHTML = iaChatSenderHtml(isUser); const 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() { const row = document.createElement('div'); row.className = 'iaChatRow iaChatRow-assistant'; const sender = document.createElement('div'); sender.className = 'iaChatSender'; sender.innerHTML = iaChatSenderHtml(false); const 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((r) => r.text()) .then((html) => { const doc = new DOMParser().parseFromString(html, 'text/html'); const newCanvasFrame = doc.querySelector('.canvasFrame'); const 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. const canvas = document.getElementById('canvas'); if (canvas) { const newWidth = parseFloat(canvas.style.width); const newHeight = parseFloat(canvas.style.height); if (!Number.isNaN(newWidth)) window.SCENE_WIDTH = newWidth; if (!Number.isNaN(newHeight)) window.SCENE_HEIGHT = newHeight; } } const newVariablesPanel = doc.getElementById('variablesTabPanel'); const 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() { const input = document.getElementById('iaChatInput'); const sendBtn = document.getElementById('iaChatSendBtn'); if (sendBtn.disabled) return; // déjà une requête en cours — jamais spammer l'IA const message = (input.value || '').trim(); if (!message || !_iaActiveConversationId) return; const log = document.getElementById('iaChatLog'); const 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 })); const loaderRow = iaChatLoaderRowHtml(); log.appendChild(loaderRow); iaChatScrollToBottom(); input.value = ''; iaChatAutoGrow(input); input.disabled = true; sendBtn.disabled = true; const conversationId = _iaActiveConversationId; fetch(`/game/${GAME_SLUG}/ia-conversations/${conversationId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message }), }) .then((r) => r.json()) .then((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(() => { loaderRow.remove(); log.appendChild(iaChatRowHtml({ role: 'assistant', content: 'Erreur réseau — réessaie dans un instant.' })); iaChatScrollToBottom(); }) .finally(() => { 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) { const active = conversation.id === _iaActiveConversationId; return ( `' ); } function _iaEscapeHtml(text) { const div = document.createElement('div'); div.textContent = text || ''; return div.innerHTML; } function iaChatRenderConversationsList(conversations) { const 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((r) => r.json()) .then((conversations) => { if (activeIdOverride) _iaActiveConversationId = activeIdOverride; iaChatRenderConversationsList(conversations); }); } function iaChatLoadMessages(conversationId) { const log = document.getElementById('iaChatLog'); log.innerHTML = ''; fetch(`/game/${GAME_SLUG}/ia-conversations/${conversationId}/messages`) .then((r) => r.json()) .then((messages) => { if (!messages || !messages.length) { log.innerHTML = iaChatEmptyStateHtml(); return; } messages.forEach((message) => { log.appendChild(iaChatRowHtml(message)); }); iaChatScrollToBottom(); }); } function iaChatSelectConversation(conversationId) { _iaActiveConversationId = conversationId; document.querySelectorAll('.iaConversationItem').forEach((el) => { el.classList.remove('active'); }); const 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((r) => r.json()) .then((conversation) => { _iaActiveConversationId = conversation.id; iaChatLoadConversationsList(conversation.id); const 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 (!window.confirm('Supprimer cette conversation ? Cette action est irréversible.')) return; fetch(`/game/${GAME_SLUG}/ia-conversations/${conversationId}`, { method: 'DELETE' }) .then(() => fetch(`/game/${GAME_SLUG}/screens/${SCREEN_ID}/ia-conversations`)) .then((r) => r.json()) .then((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() { const 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((r) => r.json()) .then((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();