Ajoute l'assistant IA "Ruby" (Claude + Scenario) et corrige plusieurs bugs de la scène
Intègre un chat IA capable de manipuler la scène via les mêmes fonctions que l'éditeur manuel (objets, variables, déclencheurs, images générées), avec conversations multiples par écran façon Claude. Corrige au passage le rafraîchissement pjax hors-ordre, l'onglet IA/déclencheurs vide après sélection d'un objet, la comparaison de booléens dans les conditions, et le blocage du glisser-déposer hors du cadre caméra après un redimensionnement de fond par l'IA. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
ab6635eee0
commit
b07b231a61
+1
-1
@@ -1 +1 @@
|
||||
from . import auth, games, screens, flow, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, scenes, onboarding, triggers
|
||||
from . import auth, games, screens, flow, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, scenes, onboarding, triggers, assets, ia
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from . import upload_user_asset, serve_user_asset
|
||||
@@ -0,0 +1,15 @@
|
||||
from flask import g, send_from_directory, abort
|
||||
|
||||
import auth
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/assets/<int:user_id>/<path:filename>")
|
||||
def serve_user_asset(user_id, filename):
|
||||
"""403 si le compte connecté n'est pas le propriétaire — même esprit
|
||||
que l'isolation par propriétaire des projets (core/auth_guard.py) :
|
||||
"Mes assets" ne doit jamais fuiter d'un compte à l'autre."""
|
||||
if user_id != g.current_user["id"]:
|
||||
abort(403)
|
||||
return send_from_directory(auth.user_assets_dir(user_id), filename)
|
||||
@@ -0,0 +1,27 @@
|
||||
import os
|
||||
|
||||
from flask import g, request, jsonify, url_for
|
||||
|
||||
import auth
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/assets/upload", methods=["POST"])
|
||||
def upload_user_asset():
|
||||
""""Mes assets" (voir auth/user_assets_dir.py) : mirroir de
|
||||
routes/uploads/upload_file.py, mais stocké PAR COMPTE plutôt que par
|
||||
jeu, pour être réutilisable dans n'importe quel projet du même
|
||||
compte. Écriture disque + ligne base factorisées dans
|
||||
auth.save_user_asset (partagé avec la génération IA, voir
|
||||
ai/tools.py::_dispatch_add_generated_image)."""
|
||||
f = request.files.get("file")
|
||||
if not f or not f.filename:
|
||||
return jsonify({"error": "Aucun fichier reçu"}), 400
|
||||
ext = "".join(c for c in os.path.splitext(f.filename)[1].lower() if c.isalnum() or c == ".")[:10]
|
||||
user_id = g.current_user["id"]
|
||||
asset_id, filename = auth.save_user_asset(user_id, f.read(), ext, original_name=f.filename, source="upload")
|
||||
return jsonify({
|
||||
"id": asset_id,
|
||||
"url": url_for("serve_user_asset", user_id=user_id, filename=filename),
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
from flask import request, redirect, url_for
|
||||
from flask import request, redirect, render_template, url_for, jsonify
|
||||
|
||||
import db
|
||||
|
||||
@@ -9,7 +9,10 @@ from core.flask_app import app
|
||||
def create_global_var(slug):
|
||||
"""Depuis l'onglet "Variables" de l'éditeur de scène (screen_edit.html)
|
||||
— idempotent par nom (voir db.create_global_variable), donc sans
|
||||
risque en cas de double soumission."""
|
||||
risque en cas de double soumission. Réponse AJAX (voir
|
||||
static/js/scenes/global-variables.js) : renvoie le HTML de la LIGNE
|
||||
créée (templates/_global_variable_row.html, partagé avec le rendu
|
||||
initial de la page — jamais dupliqué), que le JS insère sans recharger."""
|
||||
name = (request.form.get("name") or "").strip()
|
||||
var_type = request.form.get("type") or "texte"
|
||||
default_value = request.form.get("default_value") or ""
|
||||
@@ -21,6 +24,14 @@ def create_global_var(slug):
|
||||
per_player = request.form.get("per_player", "1") == "1"
|
||||
if name:
|
||||
db.create_global_variable(slug, name, var_type, default_value, per_player=per_player)
|
||||
if request.headers.get("X-Requested-With") == "fetch":
|
||||
row_html = ""
|
||||
if name:
|
||||
variable = db.get_global_variable(slug, name)
|
||||
row_html = render_template(
|
||||
"_global_variable_row.html", v=variable, global_variable_types=db.GLOBAL_VARIABLE_TYPES,
|
||||
)
|
||||
return jsonify({"ok": True, "row_html": row_html})
|
||||
if screen_id:
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="variables"))
|
||||
return redirect(url_for("game_dashboard", slug=slug))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from flask import request, redirect, url_for
|
||||
from flask import request, redirect, url_for, jsonify
|
||||
|
||||
import db
|
||||
|
||||
@@ -7,8 +7,13 @@ from core.flask_app import app
|
||||
|
||||
@app.route("/game/<slug>/variables/<int:var_id>/delete", methods=["POST"])
|
||||
def global_var_delete(slug, var_id):
|
||||
"""Suppression en AJAX (voir static/js/scenes/global-variables.js) —
|
||||
même raison que global_var_edit.py. Redirection classique conservée
|
||||
en secours pour tout appelant non-JS."""
|
||||
screen_id = request.form.get("screen_id", type=int)
|
||||
db.delete_global_variable_by_id(slug, var_id)
|
||||
if request.headers.get("X-Requested-With") == "fetch":
|
||||
return jsonify({"ok": True})
|
||||
if screen_id:
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="variables"))
|
||||
return redirect(url_for("game_dashboard", slug=slug))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from flask import request, redirect, url_for
|
||||
from flask import request, redirect, url_for, jsonify
|
||||
|
||||
import db
|
||||
|
||||
@@ -7,10 +7,16 @@ from core.flask_app import app
|
||||
|
||||
@app.route("/game/<slug>/variables/<int:var_id>/edit", methods=["POST"])
|
||||
def global_var_edit(slug, var_id):
|
||||
"""Enregistrement en AJAX (voir static/js/scenes/global-variables.js) —
|
||||
demande explicite : modifier/supprimer une variable ne doit plus
|
||||
recharger toute la page. Redirection classique conservée en secours
|
||||
pour tout appelant non-JS (même patron que scene_object_add.py)."""
|
||||
var_type = request.form.get("type") or "texte"
|
||||
value = request.form.get("value") or ""
|
||||
screen_id = request.form.get("screen_id", type=int)
|
||||
db.update_global_variable(slug, var_id, var_type, value)
|
||||
if request.headers.get("X-Requested-With") == "fetch":
|
||||
return jsonify({"ok": True})
|
||||
if screen_id:
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="variables"))
|
||||
return redirect(url_for("game_dashboard", slug=slug))
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from . import ia_chat
|
||||
@@ -0,0 +1,85 @@
|
||||
from flask import g, jsonify, request
|
||||
|
||||
import ai
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
_CONVERSATION_NOT_FOUND = {"error": "conversation introuvable"}
|
||||
|
||||
|
||||
def _get_owned_conversation(slug, conversation_id):
|
||||
"""La portée par SLUG (db.connect(slug), voir screens/ia/) suffit
|
||||
déjà à isoler les conversations d'un jeu à l'autre — un id d'une
|
||||
autre base n'existe simplement pas ici, même patron que
|
||||
screens.get_scene_object. Renvoie None si absente."""
|
||||
return screens.get_ia_conversation(slug, conversation_id)
|
||||
|
||||
|
||||
@app.route("/game/<slug>/screens/<int:screen_id>/ia-conversations", methods=["GET"])
|
||||
def ia_conversations_list(slug, screen_id):
|
||||
"""Colonne de gauche de l'onglet "Générer avec l'IA" — toutes les
|
||||
conversations de cet écran, plus récente en premier."""
|
||||
return jsonify(screens.list_ia_conversations(slug, screen_id))
|
||||
|
||||
|
||||
@app.route("/game/<slug>/screens/<int:screen_id>/ia-conversations", methods=["POST"])
|
||||
def ia_conversations_create(slug, screen_id):
|
||||
""""+ Nouvelle conversation" — créée immédiatement, vide."""
|
||||
conversation_id = screens.create_ia_conversation(slug, screen_id)
|
||||
conversation = screens.get_ia_conversation(slug, conversation_id)
|
||||
return jsonify(conversation)
|
||||
|
||||
|
||||
@app.route("/game/<slug>/ia-conversations/<int:conversation_id>", methods=["DELETE"])
|
||||
def ia_conversation_delete(slug, conversation_id):
|
||||
if not _get_owned_conversation(slug, conversation_id):
|
||||
return jsonify(_CONVERSATION_NOT_FOUND), 404
|
||||
screens.delete_ia_conversation(slug, conversation_id)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.route("/game/<slug>/ia-conversations/<int:conversation_id>/messages", methods=["GET"])
|
||||
def ia_conversation_messages(slug, conversation_id):
|
||||
"""Historique complet d'UNE conversation — chargé par
|
||||
static/js/ia/ia-chat.js à sa sélection dans la colonne."""
|
||||
if not _get_owned_conversation(slug, conversation_id):
|
||||
return jsonify(_CONVERSATION_NOT_FOUND), 404
|
||||
return jsonify(screens.list_ia_chat_messages(slug, conversation_id))
|
||||
|
||||
|
||||
@app.route("/game/<slug>/ia-conversations/<int:conversation_id>/messages", methods=["POST"])
|
||||
def ia_conversation_send(slug, conversation_id):
|
||||
"""Envoie un message dans une conversation — body {message}. Persiste
|
||||
le message du créateur et la réponse, renvoie les deux.
|
||||
|
||||
ai.run_chat_turn lit l'historique déjà persisté et y ajoute le
|
||||
message courant EN MÉMOIRE seulement — persisté ici, APRÈS l'appel,
|
||||
jamais avant (sinon il apparaîtrait deux fois dans l'historique que
|
||||
la boucle envoie à Claude)."""
|
||||
conversation = _get_owned_conversation(slug, conversation_id)
|
||||
if not conversation:
|
||||
return jsonify(_CONVERSATION_NOT_FOUND), 404
|
||||
|
||||
message = (request.get_json(silent=True) or {}).get("message", "")
|
||||
message = message.strip() if isinstance(message, str) else ""
|
||||
if not message:
|
||||
return jsonify({"error": "message vide"}), 400
|
||||
|
||||
try:
|
||||
reply = ai.run_chat_turn(slug, conversation["screen_id"], conversation_id, g.current_user["id"], message)
|
||||
except (ai.AnthropicNotConfiguredError, ai.ScenarioNotConfiguredError, ai.ScenarioGenerationError) as e:
|
||||
reply = f"Configuration IA incomplète : {e}"
|
||||
except Exception:
|
||||
# Jamais le détail brut d'une erreur inattendue au créateur (même
|
||||
# politique que auth/send_email.py) — journalisée côté serveur
|
||||
# par le logger par défaut de Flask (DEBUG/erreur non interceptée).
|
||||
app.logger.exception("Erreur inattendue dans ai.run_chat_turn (%s, conversation %s)", slug, conversation_id)
|
||||
reply = "Une erreur inattendue est survenue — réessaie dans un instant."
|
||||
|
||||
user_message_id = screens.add_ia_chat_message(slug, conversation_id, "user", message)
|
||||
assistant_message_id = screens.add_ia_chat_message(slug, conversation_id, "assistant", reply)
|
||||
return jsonify({
|
||||
"user_message": {"id": user_message_id, "role": "user", "content": message},
|
||||
"assistant_message": {"id": assistant_message_id, "role": "assistant", "content": reply},
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import json
|
||||
|
||||
from flask import render_template, request, abort, g
|
||||
|
||||
import auth
|
||||
import db
|
||||
import screens
|
||||
|
||||
@@ -81,6 +82,10 @@ def render_scene_edit(slug, screen_id, game):
|
||||
# Images de fond CraftPix (screens.BACKGROUND_LIBRARY) : même règle
|
||||
# d'accès que les sprites animaux — réservées au rôle admin.
|
||||
background_library = screens.BACKGROUND_LIBRARY if g.current_user["role"] == "admin" else {}
|
||||
# "Mes assets" (voir auth/user_assets_dir.py) : par COMPTE, jamais
|
||||
# filtré par rôle contrairement à sprite_library/background_library
|
||||
# ci-dessus (rien à voir avec le catalogue Forge réservé à l'admin).
|
||||
user_assets = auth.list_user_assets(g.current_user["id"])
|
||||
|
||||
return render_template(
|
||||
"scene_edit.html",
|
||||
@@ -96,6 +101,7 @@ def render_scene_edit(slug, screen_id, game):
|
||||
sprite_library=sprite_library, sprite_library_families=sprite_library_families,
|
||||
sprite_library_json=json.dumps(sprite_library),
|
||||
background_library=background_library,
|
||||
user_assets=user_assets,
|
||||
element_animations_map_json=json.dumps(element_animations_map),
|
||||
element_orientation_values=screens.ELEMENT_ORIENTATION_VALUES,
|
||||
element_orientation_labels_json=json.dumps(screens.ELEMENT_ORIENTATION_LABELS),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from flask import request, redirect, url_for, jsonify
|
||||
from flask import g, request, redirect, url_for, jsonify, abort
|
||||
|
||||
import auth
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
@@ -27,12 +28,23 @@ def scene_object_add(slug, screen_id):
|
||||
kind = request.form.get("kind", "personnage")
|
||||
forge_character = request.form.get("forge_character")
|
||||
background_slug = request.form.get("background_slug")
|
||||
asset_id = request.form.get("asset_id", type=int)
|
||||
if forge_character:
|
||||
forbid_admin_only_character(forge_character)
|
||||
if background_slug:
|
||||
forbid_admin_only_background(background_slug)
|
||||
image_url = None
|
||||
if asset_id:
|
||||
# "Mes assets" (voir auth/user_assets_dir.py) : isolation stricte
|
||||
# par propriétaire, même esprit que les projets (core/auth_guard.py)
|
||||
# — jamais poser l'asset d'un autre compte, même en devinant son id.
|
||||
asset = auth.get_user_asset(asset_id)
|
||||
if not asset or asset["user_id"] != g.current_user["id"]:
|
||||
abort(403)
|
||||
image_url = url_for("serve_user_asset", user_id=asset["user_id"], filename=asset["filename"])
|
||||
new_id = screens.add_scene_object(
|
||||
slug, screen_id, kind=kind, forge_character=forge_character, background_slug=background_slug,
|
||||
slug, screen_id, kind=kind, forge_character=forge_character,
|
||||
background_slug=background_slug, image_url=image_url,
|
||||
)
|
||||
if request.headers.get("X-Requested-With") == "fetch":
|
||||
obj = screens.get_scene_object(slug, new_id)
|
||||
|
||||
@@ -96,17 +96,15 @@ def trigger_append_action(slug, object_id):
|
||||
DERNIÈRE feuille d'une chaîne. 404 si `after_id` introuvable ou si
|
||||
cette feuille a déjà un "then" (jamais écrasé par cette route — un
|
||||
nouvel appel doit d'abord recharger la chaîne à jour)."""
|
||||
obj = screens.get_scene_object(slug, object_id)
|
||||
if obj is None:
|
||||
if screens.get_scene_object(slug, object_id) is None:
|
||||
return jsonify({"error": "objet introuvable"}), 404
|
||||
body = request.get_json(silent=True) or {}
|
||||
after_id = body.get("after_id")
|
||||
if not isinstance(after_id, str) or not after_id.strip():
|
||||
return jsonify({"error": "after_id invalide"}), 400
|
||||
|
||||
rules = screens.resolve_collision_rules(obj)
|
||||
if not screens.set_then_action_in_rules(rules, after_id, body.get("action")):
|
||||
# Chemin d'écriture PARTAGÉ avec l'agent IA (voir ai/tools.py) —
|
||||
# ni cette route ni l'agent n'écrivent _collision_rules directement.
|
||||
if not screens.append_action_to_trigger(slug, object_id, after_id, body.get("action")):
|
||||
return jsonify({"error": "action précédente introuvable ou déjà chaînée"}), 404
|
||||
sanitized = screens.sanitize_collision_rules(rules)
|
||||
screens.set_scene_object_collision_rules(slug, object_id, sanitized)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
Reference in New Issue
Block a user