// ---------- 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();