Build and deploy / test-python (push) Successful in 11m12s
Build and deploy / test-js (push) Successful in 53s
Build and deploy / lint-python (push) Successful in 3m56s
Build and deploy / lint-js (push) Successful in 3m1s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 3m58s
Prepare la scission a venir entre l'editeur Jeu 2D et le futur editeur Support de formation (voir docs/plan/PLAN.md), sans toucher a l'architecture en couches existante : - screens/ renomme en game_engine/ (nom clair pour le moteur du jeu 2D, avant l'arrivee d'un second "moteur" cote document) : ~85 imports corriges, contrat import-linter mis a jour, meme forme de couches. - routes/, scripts/, static/, templates/, tests/ : tout ce qui est propre au jeu 2D deplace dans un sous-dossier game/ de chacun (routes/game/, static/game/, templates/game/, tests/game/, scripts/game/) ; ce qui est partage par le site (auth, onboarding, dashboard, uploads, db/) reste a la racine de chaque dossier. Un sous-dossier document/ (vide) cree dans chacun pour le futur chantier. - styles/ volontairement inchange : les 3 fichiers sources sont concatenes en un seul static/style.css charge par tout le site, scinder leur CONTENU (editeur vs partage) serait un refactor CSS distinct, pas un deplacement mecanique. - Chaine d'export SCORM (publish/build_scorm_package.py) mise a jour en profondeur : copie des assets, URLs d'icones relatives a static/style.css (qui ne bouge pas), manifeste, wrapper SCORM. - Deux regressions d'un sweep de renommage anterieur corrigees au passage (screens.js/screens/scene-objects incorrectement convertis en game_engine.js/game_engine/scene-objects dans des commentaires). - Effet de bord Windows decouvert et corrige : git mv + Path.write_text convertissent des fichiers en CRLF (core.autocrlf=true) - ~189 fichiers normalises en LF. - .eslintrc.json/package.json : uniquement les chemins de glob mis a jour (static/game/js/...) ; la preparation eslint-plugin-unicorn du lot 7 reste volontairement non committee (package-lock.json restaure a la version precedente). Verifications : ruff, mypy --strict (391 fichiers), vulture, bandit, lint-imports tous verts ; 591/591 tests Python, 276/276 tests JS ; demarrage serveur + requetes HTTP manuelles confirmant que les assets deplaces repondent en 200 au nouvel emplacement et 404 a l'ancien. SKIP=djlint : backlog H021 (styles inline) deja documente comme dette assumee dans CODE_QUALITY.md section 6, aucun template touche par ce commit au-dela d'un deplacement de fichier. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
334 lines
14 KiB
JavaScript
334 lines
14 KiB
JavaScript
// ---------- 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 (
|
|
'<div class="iaChatEmpty">'
|
|
+ '<img src="/static/branding/ruby-logo.svg" alt="" class="iaChatAvatar iaChatAvatar-large">'
|
|
+ '<strong>Décris ce que tu veux à Ruby</strong>'
|
|
+ '<span>Des images, des dialogues, une variable, ou un écran complet — décris-le en une phrase et envoie.</span>'
|
|
+ '</div>'
|
|
);
|
|
}
|
|
|
|
// #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 '<img src="/static/branding/ruby-logo.svg" alt="" class="iaChatAvatar"> 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 = '<span></span><span></span><span></span>';
|
|
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/game/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 = Number.parseFloat(canvas.style.width);
|
|
const newHeight = Number.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 game_engine/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 (
|
|
`<button type="button" class="iaConversationItem${active ? ' active' : ''}" `
|
|
+ `data-conversation-id="${conversation.id}" onclick="iaChatSelectConversation(${conversation.id})">`
|
|
+ `<span class="iaConversationTitle">${_iaEscapeHtml(conversation.title)}</span>`
|
|
+ '<span class="iaConversationDelete" title="Supprimer" '
|
|
+ `onclick="iaChatDeleteConversation(event, ${conversation.id})">🗑️</span>`
|
|
+ '</button>'
|
|
);
|
|
}
|
|
|
|
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?.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 <main>.
|
|
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 = '<div class="iaTabLayout">'
|
|
+ '<div class="iaCol iaCol-left">'
|
|
+ '<div class="iaColHeader">'
|
|
+ '<img src="/static/branding/ruby-logo.svg" alt="" class="iaHeaderLogo">'
|
|
+ '<span class="iaHeaderName">Ruby</span>'
|
|
+ '</div>'
|
|
+ '<div class="iaColBody">'
|
|
+ '<button type="button" class="button is-small primary iaNewConvBtn" onclick="iaChatCreateConversation()">+ Nouvelle conversation</button>'
|
|
+ '<div class="iaConversationsList" id="iaConversationsList"></div>'
|
|
+ '</div>'
|
|
+ '<div class="iaColFooter"></div>'
|
|
+ '</div>'
|
|
+ '<div class="iaCol iaCol-right">'
|
|
+ '<div class="iaColHeader"></div>'
|
|
+ '<div class="iaColBody">'
|
|
+ '<div class="iaChatLog" id="iaChatLog"></div>'
|
|
+ '</div>'
|
|
+ '<div class="iaColFooter">'
|
|
+ '<div class="iaChatInputBar">'
|
|
+ '<textarea id="iaChatInput" rows="1" placeholder="Décris ce que tu veux à Ruby…" '
|
|
+ 'oninput="iaChatAutoGrow(this)" onkeydown="iaChatInputKeydown(event)"></textarea>'
|
|
+ '<button type="button" class="iaChatSendBtn" id="iaChatSendBtn" onclick="iaChatSend()" title="Envoyer" aria-label="Envoyer">➤</button>'
|
|
+ '</div>'
|
|
+ '</div>'
|
|
+ '</div>'
|
|
+ '</div>';
|
|
|
|
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();
|