Build and deploy / test-python (push) Successful in 11m18s
Build and deploy / test-js (push) Successful in 1m16s
Build and deploy / lint-python (push) Successful in 4m24s
Build and deploy / lint-js (push) Successful in 2m51s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Successful in 5m30s
Corrige les findings SonarQube (via SonarLint IDE, fichier par fichier) sur ~24 fichiers static/js/ : parseFloat/parseInt -> Number.*, .replace(/x/g,y) -> .replaceAll, .indexOf() -> .includes()/.startsWith(), getAttribute/setAttribute -> .dataset, tableaux -> Set, x && x.y -> x?.y (verifie site par site), extraction de template litteraux imbriques, ternaires imbriquees, refactors de complexite cognitive (S3776) via tables de dispatch, Object.hasOwn, .at(), et deduplication de fonctions identiques (S4144). Deux exceptions S2486 documentees/corrigees (filter-repeater-rows.js) et un cas S2703 de partage inter-scripts complete (_collisionWizard, trigger-editor.js <-> collision-rules-editor.js). Details complets dans CODE_QUALITY.md section 5. Restaure aussi le job CI "sonarqube" (non-bloquant) dans .gitea/workflows/deploy.yml maintenant que l'instance prod est operationnelle. Suites vertes : 276/276 JS (node --test), 591/591 Python (pytest). SKIP=djlint sur ce commit : hook djlint bloquant sur le backlog H021 (styles inline, 49 occurrences/6 templates) deja documente comme dette assumee non traitee dans CODE_QUALITY.md section 6, aucun rapport avec ce commit (aucun template touche ici) - valide explicitement avec l'utilisateur avant de contourner ce hook. 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/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 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 (
|
|
`<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();
|