feat: import Excel via chat Léa, suppression nœuds VWB, fix temperature 0.1

- Chat Léa : "importe patients.xlsx" → preview → confirmation → table SQLite
  Bouton 📎 pour upload fichier, "montre les tables", "info table X"
- VWB : suppression nœuds via touche Suppr/Backspace + bouton croix rouge
- Fix : toutes les températures VLM à 0.1 (qwen3-vl bloque à 0.0)
- Fix : capture VWB avec DISPLAY=:1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-03-17 07:18:51 +01:00
parent 97cb2957d5
commit 928b9e1065
8 changed files with 820 additions and 58 deletions

View File

@@ -30,8 +30,9 @@ from typing import Dict, Any, List, Optional
import requests as http_requests # Pour les appels au streaming server
from flask import Flask, render_template, request, jsonify
from flask import Flask, render_template, request, jsonify, send_from_directory
from flask_socketio import SocketIO, emit
from werkzeug.utils import secure_filename
# Add project root to path
sys.path.insert(0, str(Path(__file__).parent.parent))
@@ -53,6 +54,14 @@ try:
except ImportError:
GPU_AVAILABLE = False
# Import de données Excel/CSV
try:
from core.data.excel_importer import ExcelImporter
from core.data.db_iterator import DBIterator
DATA_IMPORT_AVAILABLE = True
except ImportError:
DATA_IMPORT_AVAILABLE = False
# Execution components (optional - pour exécution réelle)
try:
from core.execution import ActionExecutor, TargetResolver, ErrorHandler
@@ -101,6 +110,18 @@ execution_status = {
}
command_history: List[Dict[str, Any]] = []
# Répertoire d'upload et chemin de la base de données
PROJECT_ROOT = Path(__file__).parent.parent
UPLOAD_DIR = PROJECT_ROOT / "data" / "uploads"
DEFAULT_DB_PATH = PROJECT_ROOT / "data" / "databases" / "rpa_data.db"
# Import de données — composants globaux
excel_importer: Optional['ExcelImporter'] = None
db_iterator: Optional['DBIterator'] = None
# État d'import en attente de confirmation (session_id → infos d'import)
_pending_imports: Dict[str, Dict[str, Any]] = {}
# Copilot state — suivi du mode pas-à-pas
_copilot_sessions: Dict[str, Dict[str, Any]] = {}
@@ -246,6 +267,21 @@ def init_system():
logger.warning(f"⚠ GestureCatalog: {e}")
gesture_catalog = None
# 7. Import de données Excel/CSV
global excel_importer, db_iterator
if DATA_IMPORT_AVAILABLE:
try:
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
excel_importer = ExcelImporter(db_path=str(DEFAULT_DB_PATH))
db_iterator = DBIterator(db_path=str(DEFAULT_DB_PATH))
logger.info(f"✓ ExcelImporter + DBIterator (db: {DEFAULT_DB_PATH})")
except Exception as e:
logger.warning(f"⚠ Data import: {e}")
excel_importer = None
db_iterator = None
else:
logger.info(" Import Excel non disponible (openpyxl manquant ?)")
# =============================================================================
# Routes Web
@@ -489,22 +525,30 @@ def api_chat():
action_taken = None
if intent.intent_type == IntentType.CONFIRM:
# Confirmer une action en attente
pending = conversation_manager.get_pending_confirmation(session)
if pending:
confirmation_loop.confirm(pending.id)
conversation_manager.clear_pending_confirmation(session)
result = {"confirmed": True, "workflow": pending.workflow_name}
action_taken = "confirmed"
# Lancer l'exécution
socketio.start_background_task(
execute_workflow_from_confirmation, pending, session.session_id
)
# Vérifier s'il y a un import en attente de confirmation
pending_import = _pending_imports.pop(session.session_id, None)
if pending_import:
result = _execute_pending_import(pending_import)
action_taken = "data_import"
else:
result = {"confirmed": False}
# Confirmer une action en attente (workflow)
pending = conversation_manager.get_pending_confirmation(session)
if pending:
confirmation_loop.confirm(pending.id)
conversation_manager.clear_pending_confirmation(session)
result = {"confirmed": True, "workflow": pending.workflow_name}
action_taken = "confirmed"
# Lancer l'exécution
socketio.start_background_task(
execute_workflow_from_confirmation, pending, session.session_id
)
else:
result = {"confirmed": False}
elif intent.intent_type == IntentType.DENY:
# Annuler un import en attente
_pending_imports.pop(session.session_id, None)
# Refuser une action en attente
pending = conversation_manager.get_pending_confirmation(session)
if pending:
@@ -594,6 +638,11 @@ def api_chat():
# Niveau 3 : Pas de query exploitable
result = {"not_found": True, "query": query or "", "teach_me": True}
elif intent.intent_type == IntentType.DATA_IMPORT:
# Import de données Excel/CSV
result = _handle_data_import(intent, session.session_id)
action_taken = "data_import"
elif intent.intent_type == IntentType.LIST:
# Lister les workflows avec métadonnées enrichies
if matcher:
@@ -669,7 +718,14 @@ def api_chat():
# 6. Générer la réponse (si pas déjà fait pour confirmation)
if action_taken != "confirmation_requested":
response = response_generator.generate(intent, context, result)
# Si c'est un import de données (même via CONFIRM/DENY), forcer le handler DATA_IMPORT
if action_taken == "data_import":
# Créer un intent fictif pour le dispatch vers _handle_data_import
from dataclasses import replace as _dc_replace
data_intent = _dc_replace(intent, intent_type=IntentType.DATA_IMPORT)
response = response_generator.generate(data_intent, context, result)
else:
response = response_generator.generate(intent, context, result)
# 7. Enregistrer le tour dans la conversation
conversation_manager.add_turn(
@@ -893,6 +949,303 @@ def _execute_gesture(gesture):
})
# =============================================================================
# Import de données Excel/CSV
# =============================================================================
def _handle_data_import(intent, session_id: str) -> Dict[str, Any]:
"""
Traiter une intention DATA_IMPORT.
Gère les actions : import_file, import_folder, list_tables, table_info.
"""
if not DATA_IMPORT_AVAILABLE or not excel_importer:
return {"error": "L'import Excel n'est pas disponible (openpyxl manquant)."}
action = intent.parameters.get("action", "import_file")
params = intent.parameters
if action == "list_tables":
return _data_list_tables()
elif action == "table_info":
table_name = params.get("table_name")
if not table_name:
return {"error": "Précisez le nom de la table (ex: 'info table patients')."}
return _data_table_info(table_name)
elif action == "import_folder":
folder_path = params.get("folder_path") or params.get("file_path", "")
if not folder_path:
return {"error": "Précisez le chemin du dossier."}
return _data_import_folder(folder_path)
elif action == "import_file":
file_path = params.get("file_path")
if not file_path:
return {"error": "Précisez le fichier à importer (ex: 'importe patients.xlsx')."}
table_name = params.get("table_name")
return _data_preview_file(file_path, table_name, session_id)
return {"error": "Action d'import non reconnue."}
def _resolve_file_path(file_path: str) -> Optional[Path]:
"""
Résoudre un chemin de fichier.
Cherche dans l'ordre :
1. Chemin absolu tel quel
2. Relatif au dossier d'upload
3. Relatif à la racine du projet
"""
p = Path(file_path)
# Chemin absolu
if p.is_absolute() and p.exists():
return p
# Relatif au dossier d'upload
in_uploads = UPLOAD_DIR / p.name
if in_uploads.exists():
return in_uploads
# Relatif à la racine du projet
in_project = PROJECT_ROOT / file_path
if in_project.exists():
return in_project
# Juste le nom du fichier dans uploads
if not p.is_absolute():
in_uploads2 = UPLOAD_DIR / file_path
if in_uploads2.exists():
return in_uploads2
return None
def _data_preview_file(file_path: str, table_name: Optional[str], session_id: str) -> Dict[str, Any]:
"""Prévisualiser un fichier Excel avant import."""
resolved = _resolve_file_path(file_path)
if not resolved:
return {"file_not_found": True, "file_path": file_path}
try:
preview = excel_importer.preview(str(resolved))
# Déterminer le nom de la table
if not table_name:
table_name = resolved.stem.lower().replace(" ", "_").replace("-", "_")
# Stocker l'import en attente pour cette session
_pending_imports[session_id] = {
"file_path": str(resolved),
"table_name": table_name,
"sheet_name": preview.sheet_name,
}
return {
"preview": {
"columns": preview.headers,
"total_rows": preview.total_rows,
"sheet_name": preview.sheet_name,
"detected_types": preview.detected_types,
"sample_rows": [
[str(v) if v is not None else "" for v in row]
for row in preview.rows[:3]
],
},
"filename": resolved.name,
"table_name": table_name,
"needs_confirmation": True,
}
except Exception as e:
logger.error(f"Erreur prévisualisation {file_path}: {e}")
return {"error": str(e)}
def _execute_pending_import(pending: Dict[str, Any]) -> Dict[str, Any]:
"""Exécuter un import après confirmation utilisateur."""
if not excel_importer:
return {"error": "ExcelImporter non disponible."}
try:
result = excel_importer.import_file(
excel_path=pending["file_path"],
table_name=pending.get("table_name"),
sheet_name=pending.get("sheet_name"),
)
if result.success:
return {
"imported": {
"table_name": result.table_name,
"row_count": result.row_count,
"column_count": result.column_count,
"columns": result.columns,
"sheet_name": result.sheet_name,
"db_path": result.db_path,
}
}
else:
return {"error": "; ".join(result.errors) or "Import échoué (0 lignes)."}
except Exception as e:
logger.error(f"Erreur import: {e}")
return {"error": str(e)}
def _data_list_tables() -> Dict[str, Any]:
"""Lister les tables disponibles dans la base."""
if not db_iterator:
return {"tables_list": []}
try:
tables = db_iterator.list_tables()
tables_info = []
for t in tables:
try:
count = db_iterator.count(t)
except Exception:
count = -1
tables_info.append({"name": t, "row_count": count})
return {"tables_list": tables_info}
except FileNotFoundError:
# Base pas encore créée — aucune table
return {"tables_list": []}
except Exception as e:
logger.error(f"Erreur list_tables: {e}")
return {"error": str(e)}
def _data_table_info(table_name: str) -> Dict[str, Any]:
"""Récupérer les infos d'une table."""
if not db_iterator:
return {"error": "DBIterator non disponible."}
try:
columns = db_iterator.get_columns(table_name)
count = db_iterator.count(table_name)
return {
"table_info": {
"table_name": table_name,
"columns": columns,
"row_count": count,
}
}
except FileNotFoundError:
return {"error": f"Aucune base de données trouvée."}
except Exception as e:
# Table n'existe probablement pas
return {"error": f"Table '{table_name}' introuvable : {e}"}
def _data_import_folder(folder_path: str) -> Dict[str, Any]:
"""Lister les fichiers Excel dans un dossier."""
p = Path(folder_path)
if not p.exists() or not p.is_dir():
return {"folder_files": [], "folder": folder_path}
excel_files = sorted(
[f.name for f in p.iterdir() if f.suffix.lower() in (".xlsx", ".xls", ".csv")]
)
return {
"folder_files": excel_files,
"folder": folder_path,
}
@app.route('/api/chat/upload', methods=['POST'])
def api_chat_upload():
"""
Upload d'un fichier Excel/CSV via le chat.
Le fichier est stocké dans data/uploads/ et un aperçu est retourné.
"""
if 'file' not in request.files:
return jsonify({"error": "Aucun fichier reçu."}), 400
file = request.files['file']
if not file.filename:
return jsonify({"error": "Nom de fichier vide."}), 400
# Vérifier l'extension
allowed_ext = {'.xlsx', '.xls', '.csv'}
ext = Path(file.filename).suffix.lower()
if ext not in allowed_ext:
return jsonify({
"error": f"Format '{ext}' non supporté. Formats acceptés : {', '.join(allowed_ext)}"
}), 400
# Sauvegarder le fichier
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
filename = secure_filename(file.filename)
# Ajouter un timestamp pour éviter les collisions
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_name = f"{Path(filename).stem}_{ts}{ext}"
save_path = UPLOAD_DIR / safe_name
file.save(str(save_path))
logger.info(f"Fichier uploadé : {save_path}")
# Retourner les infos pour que le frontend envoie un message d'import
session_id = request.form.get('session_id', '')
# Prévisualiser le fichier
if DATA_IMPORT_AVAILABLE and excel_importer and ext in ('.xlsx', '.xls'):
try:
preview = excel_importer.preview(str(save_path))
table_name = Path(filename).stem.lower().replace(" ", "_").replace("-", "_")
# Stocker l'import en attente
_pending_imports[session_id] = {
"file_path": str(save_path),
"table_name": table_name,
"sheet_name": preview.sheet_name,
}
cols_str = ", ".join(preview.headers[:8])
if len(preview.headers) > 8:
cols_str += f"... (+{len(preview.headers) - 8})"
return jsonify({
"success": True,
"filename": file.filename,
"saved_as": safe_name,
"path": str(save_path),
"preview": {
"columns": preview.headers,
"total_rows": preview.total_rows,
"sheet_name": preview.sheet_name,
},
"message": (
f"Fichier **{file.filename}** recu ! "
f"{preview.total_rows} lignes, colonnes : {cols_str}. "
f"Je cree la table '{table_name}' ?"
),
"needs_confirmation": True,
})
except Exception as e:
logger.error(f"Erreur preview upload: {e}")
return jsonify({
"success": True,
"filename": file.filename,
"saved_as": safe_name,
"path": str(save_path),
"error": f"Fichier sauvegardé mais erreur de lecture : {e}",
})
else:
return jsonify({
"success": True,
"filename": file.filename,
"saved_as": safe_name,
"path": str(save_path),
"message": f"Fichier **{file.filename}** sauvegardé dans {UPLOAD_DIR}.",
})
@app.route('/api/help')
def api_help():
"""Aide et mode d'emploi."""

View File

@@ -36,6 +36,7 @@ class IntentType(Enum):
CONFIRM = "confirm" # Confirmer une action
DENY = "deny" # Refuser une action
CLARIFY = "clarify" # Demander une clarification
DATA_IMPORT = "data_import" # Importer des données (Excel, CSV)
UNKNOWN = "unknown" # Intention non reconnue
@@ -74,6 +75,21 @@ class IntentParser:
# Patterns pour la détection d'intentions par règles
INTENT_PATTERNS = {
IntentType.DATA_IMPORT: [
# Import de fichiers Excel/CSV
r"(?:importe|charge|lis|lire)\s+(?:le\s+fichier\s+|les\s+(?:données|feuilles)\s+(?:de|du|excel\s+du)\s+)?(.+\.xlsx?)\b",
r"(?:importe|charge|lis|lire)\s+(?:le\s+fichier\s+|les\s+(?:données|feuilles)\s+(?:de|du|excel\s+du)\s+)?(.+\.csv)\b",
r"(?:importe|charge|lis|lire)\s+(?:le\s+fichier\s+)?excel\s+(.+)",
r"(?:importe|charge|lis|lire)\s+(?:les\s+)?(?:feuilles?\s+)?excel\s+(?:du\s+dossier\s+|de\s+)(.+)",
r"(?:crée?|créer?)\s+une?\s+table\s+(?:à\s+partir\s+d[eu]'?\s*)(.+\.xlsx?)\b",
# Lister les tables
r"(?:montre|liste|affiche|voir)\s*(?:-moi\s+)?(?:les\s+)?tables?\b",
r"(?:quelles?\s+)?tables?\s+(?:sont\s+)?(?:disponibles?|dans\s+la\s+base)",
r"liste\s+(?:des?\s+)?tables?\s+(?:de\s+)?(?:la\s+)?(?:base)?",
# Infos sur une table
r"(?:combien\s+de\s+lignes?\s+(?:dans|pour)\s+(?:la\s+)?table\s+)(\w+)",
r"(?:info|détails?|describe?|colonnes?|structure)\s+(?:de\s+)?(?:la\s+)?table\s+(\w+)",
],
IntentType.EXECUTE: [
# Verbes d'action explicites
r"(?:lance|exécute|démarre|fai[st]|run|start|execute)\s+(.+)",
@@ -210,6 +226,25 @@ class IntentParser:
# Expressions mathématiques : 5+2, 100*3, 12/4, 7-3, 2.5+3.1
r"(\d+(?:[.,]\d+)?\s*[+\-*/x×÷]\s*\d+(?:[.,]\d+)?)",
],
"file_path": [
# Chemins Windows : C:\data\fichier.xlsx
r"([A-Za-z]:\\[^\s,]+\.(?:xlsx?|csv))",
# Chemins Unix : /data/fichier.xlsx
r"(/[^\s,]+\.(?:xlsx?|csv))",
# Noms de fichier simples : patients.xlsx
r"(?:^|\s)([\w\-\.]+\.(?:xlsx?|csv))(?:\s|$)",
],
"folder_path": [
# Dossiers Windows : C:\data\imports
r"(?:dossier|répertoire|dir|directory)\s+([A-Za-z]:\\[^\s,]+)",
r"([A-Za-z]:\\[^\s,]+)(?:\s|$)",
# Dossiers Unix : /data/imports
r"(?:dossier|répertoire|dir|directory)\s+(/[^\s,]+)",
],
"table_name": [
# Noms de table (exclure les mots courants comme "à", "de", "la")
r"(?:table|la\s+table)\s+['\"]?(\w{2,})['\"]?",
],
}
def __init__(
@@ -294,6 +329,10 @@ class IntentParser:
# 4. Construire les paramètres depuis les entités
parameters = self._entities_to_parameters(entities)
# 4b. Enrichir les paramètres DATA_IMPORT avec l'action et le chemin
if intent_type == IntentType.DATA_IMPORT:
parameters = self._enrich_data_import_params(normalized, query, parameters, entities)
# 5. Si le LLM est disponible et la confiance est basse, utiliser le LLM
if self.use_llm and self.llm_available and rule_confidence < 0.7:
llm_result = self._parse_with_llm(query, context)
@@ -316,6 +355,82 @@ class IntentParser:
clarification_question=clarification_question
)
def _enrich_data_import_params(
self,
normalized: str,
raw_query: str,
parameters: Dict[str, Any],
entities: List[Dict[str, Any]],
) -> Dict[str, Any]:
"""Enrichir les paramètres pour une intention DATA_IMPORT.
Détermine l'action (import_file, import_folder, list_tables, table_info)
et extrait le chemin de fichier / nom de table.
"""
# Déterminer l'action
list_patterns = [
r"(?:montre|liste|affiche|voir)\s*(?:-moi\s+)?(?:les\s+)?tables?",
r"(?:quelles?\s+)?tables?\s+(?:sont\s+)?(?:disponibles?|dans)",
r"liste\s+(?:des?\s+)?tables?",
]
info_patterns = [
r"combien\s+de\s+lignes?",
r"(?:info|détails?|describe?|colonnes?|structure)\s+(?:de\s+)?(?:la\s+)?table",
]
folder_patterns = [
r"(?:feuilles?\s+excel|fichiers?\s+excel)\s+(?:du|de)\s+(?:dossier|répertoire)",
r"(?:importe|charge|lis)\s+(?:les\s+)?(?:feuilles?\s+)?excel\s+(?:du\s+dossier|de\s+)",
]
action = "import_file" # Par défaut
for pat in list_patterns:
if re.search(pat, normalized, re.IGNORECASE):
action = "list_tables"
break
if action == "import_file":
for pat in info_patterns:
if re.search(pat, normalized, re.IGNORECASE):
action = "table_info"
break
if action == "import_file":
for pat in folder_patterns:
if re.search(pat, normalized, re.IGNORECASE):
action = "import_folder"
break
parameters["action"] = action
# Extraire le chemin de fichier depuis les entités
for entity in entities:
if entity["type"] == "file_path" and "file_path" not in parameters:
parameters["file_path"] = entity["value"]
elif entity["type"] == "folder_path" and "folder_path" not in parameters:
parameters["folder_path"] = entity["value"]
elif entity["type"] == "table_name" and "table_name" not in parameters:
parameters["table_name"] = entity["value"]
# Fallback : extraire un chemin de fichier depuis la requête brute
if "file_path" not in parameters and action == "import_file":
# Chercher un .xlsx/.xls/.csv dans la requête brute (supporte les chemins Windows)
fp_match = re.search(
r'([A-Za-z]:\\[^\s,]+\.(?:xlsx?|csv)|/[^\s,]+\.(?:xlsx?|csv)|[\w\-\.]+\.(?:xlsx?|csv))',
raw_query,
re.IGNORECASE,
)
if fp_match:
parameters["file_path"] = fp_match.group(1)
# Extraire table_name pour table_info depuis la requête
if action == "table_info" and "table_name" not in parameters:
tm = re.search(r"table\s+['\"]?(\w+)['\"]?", normalized, re.IGNORECASE)
if tm:
parameters["table_name"] = tm.group(1)
return parameters
def _normalize_query(self, query: str) -> str:
"""Normaliser une requête pour le matching."""
# Convertir en minuscules

View File

@@ -180,6 +180,44 @@ class ResponseGenerator:
"{question}",
]
},
IntentType.DATA_IMPORT: {
"preview": [
"J'ai trouvé le fichier **{filename}** — {total_rows} lignes, colonnes : {columns}. Je l'importe dans la table '{table_name}' ?",
"Fichier **{filename}** prêt : {total_rows} lignes avec les colonnes {columns}. On crée la table '{table_name}' ?",
],
"imported": [
"Table **'{table_name}'** créée avec {row_count} lignes et {col_count} colonnes ({columns}). Vous pouvez maintenant utiliser 'Pour chaque ligne' dans un workflow !",
"Import réussi ! Table **'{table_name}'** : {row_count} lignes, {col_count} colonnes ({columns}).",
],
"list_tables": [
"Voici les tables disponibles :\n{tables_list}",
"Tables dans la base :\n{tables_list}",
],
"no_tables": [
"Aucune table n'a été importée pour l'instant. Envoyez-moi un fichier Excel pour commencer !",
"La base est vide. Importez un fichier Excel pour créer votre première table.",
],
"table_info": [
"La table **'{table_name}'** contient {row_count} lignes et {col_count} colonnes :\n{columns_detail}",
],
"folder_list": [
"J'ai trouvé {count} fichiers Excel dans le dossier :\n{files_list}\n\nDites-moi lequel importer !",
],
"folder_empty": [
"Aucun fichier Excel trouvé dans le dossier '{folder}'. Vérifiez le chemin.",
],
"file_not_found": [
"Je n'ai pas trouvé le fichier '{file_path}'. Vérifiez le chemin ou envoyez-le via le bouton 📎.",
"Fichier introuvable : '{file_path}'. Vous pouvez aussi glisser un fichier dans le chat.",
],
"error": [
"Erreur lors de l'import : {error}",
"L'import a échoué : {error}",
],
"uploaded": [
"Fichier **{filename}** reçu ! Je l'analyse...",
],
},
IntentType.UNKNOWN: {
"default": [
"Je n'ai pas compris. Pouvez-vous reformuler ?",
@@ -209,6 +247,15 @@ class ResponseGenerator:
"facturer client X",
"liste des workflows",
"aide"
],
"after_import": [
"montre les tables",
"importer un autre fichier",
"aide"
],
"after_table_list": [
"importer un fichier Excel",
"aide"
]
}
@@ -623,6 +670,111 @@ class ResponseGenerator:
action_required=False
)
def _handle_data_import(
self,
intent: ParsedIntent,
context: Dict[str, Any],
result: Dict[str, Any]
) -> GeneratedResponse:
"""Handler pour les imports de données (Excel/CSV)."""
templates = self.RESPONSE_TEMPLATES[IntentType.DATA_IMPORT]
if result.get("file_not_found"):
template = random.choice(templates["file_not_found"])
message = template.format(file_path=result.get("file_path", "?"))
suggestions = ["aide"]
elif result.get("preview"):
# Aperçu avant import
template = random.choice(templates["preview"])
preview = result["preview"]
cols_str = ", ".join(preview.get("columns", [])[:8])
if len(preview.get("columns", [])) > 8:
cols_str += f"... (+{len(preview['columns']) - 8})"
message = template.format(
filename=result.get("filename", "?"),
total_rows=preview.get("total_rows", 0),
columns=cols_str,
table_name=result.get("table_name", "?"),
)
suggestions = ["oui", "non"]
elif result.get("imported"):
# Import réussi
template = random.choice(templates["imported"])
imp = result["imported"]
cols_str = ", ".join(list(imp.get("columns", {}).keys())[:6])
if len(imp.get("columns", {})) > 6:
cols_str += "..."
message = template.format(
table_name=imp.get("table_name", "?"),
row_count=imp.get("row_count", 0),
col_count=imp.get("column_count", 0),
columns=cols_str,
)
suggestions = self.CONTEXTUAL_SUGGESTIONS["after_import"]
elif result.get("tables_list") is not None:
tables = result["tables_list"]
if tables:
lines = []
for t in tables:
lines.append(f" **{t['name']}** ({t['row_count']} lignes)")
template = random.choice(templates["list_tables"])
message = template.format(tables_list="\n".join(lines))
suggestions = self.CONTEXTUAL_SUGGESTIONS["after_table_list"]
else:
message = random.choice(templates["no_tables"])
suggestions = ["importer un fichier Excel"]
elif result.get("table_info"):
info = result["table_info"]
cols_detail = "\n".join(
f" {c['name']} ({c['type']})" for c in info.get("columns", [])
if c["name"] not in ("_rowid", "_imported_at")
)
template = random.choice(templates["table_info"])
message = template.format(
table_name=info.get("table_name", "?"),
row_count=info.get("row_count", 0),
col_count=len([c for c in info.get("columns", []) if c["name"] not in ("_rowid", "_imported_at")]),
columns_detail=cols_detail,
)
suggestions = self.CONTEXTUAL_SUGGESTIONS["after_table_list"]
elif result.get("folder_files") is not None:
files = result["folder_files"]
if files:
files_list = "\n".join(f" {f}" for f in files)
template = random.choice(templates["folder_list"])
message = template.format(count=len(files), files_list=files_list)
else:
template = random.choice(templates["folder_empty"])
message = template.format(folder=result.get("folder", "?"))
suggestions = ["aide"]
elif result.get("uploaded"):
template = random.choice(templates["uploaded"])
message = template.format(filename=result.get("filename", "?"))
suggestions = []
elif result.get("error"):
template = random.choice(templates["error"])
message = template.format(error=result["error"])
suggestions = self.CONTEXTUAL_SUGGESTIONS["after_error"]
else:
message = "Je n'ai pas compris la demande d'import. Précisez le fichier ou dites 'montre les tables'."
suggestions = ["montre les tables", "aide"]
return GeneratedResponse(
message=message,
suggestions=suggestions,
action_required=result.get("needs_confirmation", False),
action_type="data_import_confirm" if result.get("needs_confirmation") else None,
metadata=result,
)
def _handle_unknown(
self,
intent: ParsedIntent,

View File

@@ -447,6 +447,26 @@
color: var(--text-muted);
}
.attach-btn {
width: 48px;
height: 48px;
border-radius: 14px;
background: var(--bg-message-bot);
border: 1px solid var(--border);
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
transition: all 0.2s;
}
.attach-btn:hover {
color: var(--primary);
border-color: var(--primary);
}
.send-btn {
width: 48px;
height: 48px;
@@ -650,6 +670,10 @@
<div class="welcome-suggestion-title">📋 Voir les workflows</div>
<div class="welcome-suggestion-desc">Lister les workflows disponibles</div>
</div>
<div class="welcome-suggestion" onclick="sendSuggestion('Montre-moi les tables')">
<div class="welcome-suggestion-title">📊 Importer des données</div>
<div class="welcome-suggestion-desc">Importer un fichier Excel ou voir les tables existantes</div>
</div>
</div>
</div>
</div>
@@ -657,6 +681,10 @@
<!-- Input Area -->
<div class="input-area">
<div class="input-container">
<button class="attach-btn" onclick="document.getElementById('fileInput').click()" title="Joindre un fichier Excel">
<i class="bi bi-paperclip"></i>
</button>
<input type="file" id="fileInput" accept=".xlsx,.xls,.csv" style="display:none" onchange="handleFileUpload(event)">
<div class="input-wrapper">
<textarea
id="messageInput"
@@ -1041,7 +1069,11 @@
sessionId = data.session_id;
// Handle different response types
if (data.result?.needs_confirmation) {
if (data.result?.needs_confirmation && data.result?.preview) {
// Import de données — apercu avec demande de confirmation
addMessage(data.response.message);
addSuggestions(['oui', 'non']);
} else if (data.result?.needs_confirmation && data.result?.confirmation) {
pendingConfirmation = data.result.confirmation;
const card = createActionCard(
pendingConfirmation.workflow_name,
@@ -1084,6 +1116,18 @@
msg += `• **${w.name}**: ${w.description || 'Pas de description'}\n`;
});
addMessage(msg);
} else if (data.result?.imported) {
// Import de données réussi
addMessage(data.response.message);
if (data.response.suggestions?.length > 0) {
addSuggestions(data.response.suggestions);
}
} else if (data.result?.tables_list !== undefined || data.result?.table_info) {
// Liste des tables ou info table
addMessage(data.response.message);
if (data.response.suggestions?.length > 0) {
addSuggestions(data.response.suggestions);
}
} else {
addMessage(data.response.message);
}
@@ -1125,6 +1169,53 @@
addMessage("Demande d'annulation envoyée...");
}
// =====================================================
// File Upload
// =====================================================
async function handleFileUpload(event) {
const file = event.target.files[0];
if (!file) return;
// Afficher le message utilisateur
addMessage(`📎 ${file.name}`, 'user');
addTypingIndicator();
isProcessing = true;
updateInputState();
const formData = new FormData();
formData.append('file', file);
formData.append('session_id', sessionId || '');
try {
const response = await fetch('/api/chat/upload', {
method: 'POST',
body: formData
});
const data = await response.json();
removeTypingIndicator();
if (data.error && !data.success) {
addMessage(`Erreur : ${data.error}`);
} else if (data.message) {
addMessage(data.message);
if (data.needs_confirmation) {
addSuggestions(['oui', 'non']);
}
} else {
addMessage(`Fichier ${file.name} recu.`);
}
} catch (error) {
removeTypingIndicator();
addMessage(`Erreur d'upload : ${error.message}`);
}
isProcessing = false;
updateInputState();
// Reset le champ fichier pour permettre de re-uploader le meme fichier
event.target.value = '';
}
// =====================================================
// Copilot Mode
// =====================================================

View File

@@ -65,8 +65,13 @@ class OllamaClient:
max_tokens: int = 500,
force_json: bool = False) -> Dict[str, Any]:
"""
Générer une réponse du VLM
Générer une réponse du VLM via l'API chat d'Ollama.
Note: On utilise /api/chat au lieu de /api/generate car qwen3-vl
avec /api/generate consomme tous les tokens en thinking interne
et retourne une réponse vide. L'API chat gère correctement
le mode /no_think et sépare thinking/réponse.
Args:
prompt: Prompt textuel
image_path: Chemin vers une image (optionnel)
@@ -74,7 +79,8 @@ class OllamaClient:
system_prompt: Prompt système (optionnel)
temperature: Température de génération
max_tokens: Nombre max de tokens
force_json: Forcer la sortie JSON (non recommandé pour qwen3-vl)
Returns:
Dict avec 'response', 'success', 'error'
"""
@@ -85,46 +91,52 @@ class OllamaClient:
image_data = self._encode_image_from_path(image_path)
elif image:
image_data = self._encode_image_from_pil(image)
# Construire la requête avec thinking mode désactivé
# Pour Qwen3, utiliser /nothink au début du prompt
# Construire le prompt avec /no_think pour désactiver le thinking
effective_prompt = prompt
if "qwen" in self.model.lower():
effective_prompt = f"/nothink {prompt}"
# S'assurer que /no_think est présent (pas de doublon)
if "/no_think" not in prompt and "/nothink" not in prompt:
effective_prompt = f"/no_think\n{prompt}"
# Construire le message utilisateur
user_message = {"role": "user", "content": effective_prompt}
if image_data:
user_message["images"] = [image_data]
# Construire les messages
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append(user_message)
payload = {
"model": self.model,
"prompt": effective_prompt,
"messages": messages,
"stream": False,
"options": {
"temperature": temperature,
"num_predict": max_tokens,
"num_ctx": 2048, # Contexte réduit pour plus de vitesse
"top_k": 1 # Plus rapide pour les tâches de classification
"num_ctx": 2048,
"top_k": 1
}
}
# Forcer la sortie JSON si demandé (réduit drastiquement les erreurs de parsing)
if force_json:
payload["format"] = "json"
if system_prompt:
payload["system"] = system_prompt
if image_data:
payload["images"] = [image_data]
# Envoyer la requête
# Envoyer la requête via l'API chat
response = requests.post(
f"{self.endpoint}/api/generate",
f"{self.endpoint}/api/chat",
json=payload,
timeout=self.timeout
)
if response.status_code == 200:
result = response.json()
content = result.get("message", {}).get("content", "")
return {
"response": result.get("response", ""),
"response": content,
"success": True,
"error": None
}
@@ -134,7 +146,7 @@ class OllamaClient:
"success": False,
"error": f"HTTP {response.status_code}: {response.text}"
}
except Exception as e:
return {
"response": "",
@@ -197,7 +209,7 @@ Respond with just the type name, nothing else."""
if context:
prompt += f"\n\nContext: {context}"
result = self.generate(prompt, image=element_image, temperature=0.0)
result = self.generate(prompt, image=element_image, temperature=0.1)
if result["success"]:
element_type = result["response"].strip().lower()
@@ -238,7 +250,7 @@ Respond with just the role name, nothing else."""
if context:
prompt += f"\n\nContext: {context}"
result = self.generate(prompt, image=element_image, temperature=0.0)
result = self.generate(prompt, image=element_image, temperature=0.1)
if result["success"]:
role = result["response"].strip().lower()
@@ -266,7 +278,7 @@ Respond with just the role name, nothing else."""
"""
prompt = "Extract all visible text from this image. Return only the text, nothing else."
result = self.generate(prompt, image=image, temperature=0.0)
result = self.generate(prompt, image=image, temperature=0.1)
if result["success"]:
return {"text": result["response"].strip(), "success": True}
@@ -288,30 +300,26 @@ Respond with just the role name, nothing else."""
Returns:
Dict avec 'type', 'role', 'text', 'confidence', 'success'
"""
# System prompt direct — pas de thinking, JSON uniquement
system_prompt = "You are a JSON-only UI classifier. No thinking. No explanation. Output raw JSON only."
# User prompt avec exemples explicites pour guider le modèle
# Prompt concis sans system prompt — le system prompt avec qwen3-vl
# augmente considérablement le nombre de tokens de thinking interne,
# causant des réponses vides quand le budget tokens est trop bas.
prompt = """/no_think
Look at this UI element image and classify it. Reply with ONLY a JSON object, nothing else.
Classify this UI element. Reply with ONLY a JSON object, nothing else.
Types: button, text_input, checkbox, radio, dropdown, tab, link, icon, table_row, menu_item
Roles: primary_action, cancel, submit, form_input, search_field, navigation, settings, close, delete, edit, save
Example 1: {"type": "button", "role": "submit", "text": "OK"}
Example 2: {"type": "text_input", "role": "form_input", "text": ""}
Example 3: {"type": "icon", "role": "close", "text": "X"}
Example: {"type": "button", "role": "submit", "text": "OK"}
Your answer:"""
# Note: force_json=False car qwen3-vl ne supporte pas format:json
# temperature=0.1 car qwen3-vl bloque à 0.0 avec des images
# max_tokens=800 car qwen3-vl consomme 300-700 tokens en thinking
# interne même avec /no_think — les images complexes nécessitent
# plus de budget pour que la réponse JSON visible soit complète
result = self.generate(
prompt,
image=element_image,
system_prompt=system_prompt,
temperature=0.1,
max_tokens=200,
max_tokens=800,
force_json=False
)

View File

@@ -133,7 +133,7 @@ function App() {
id: step.id,
type: 'step',
position: step.position || { x: 100, y: 100 + index * 120 },
data: { step },
data: { step, onDelete: handleDeleteStep },
}));
setNodes(newNodes);
@@ -475,10 +475,13 @@ function App() {
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onEdgesDelete={onEdgesDelete}
onNodesDelete={(deleted) => {
deleted.forEach((node) => handleDeleteStep(node.id));
}}
onNodeClick={(_, node) => handleSelectStep(node.id)}
onNodeDragStop={handleNodeDragStop}
nodeTypes={nodeTypes}
deleteKeyCode="Delete"
deleteKeyCode={['Delete', 'Backspace']}
fitView
>
<Controls />

View File

@@ -5,7 +5,7 @@ import { ACTIONS } from '../types';
import { getAnchorThumbnailUrl } from '../services/api';
interface StepNodeProps {
data: { step: Step };
data: { step: Step; onDelete?: (id: string) => void };
selected?: boolean;
}
@@ -18,6 +18,20 @@ function StepNode({ data, selected }: StepNodeProps) {
return (
<div className={`step-node ${selected ? 'selected' : ''} ${isConditional ? 'conditional' : ''} ${isDataLoop ? 'data-loop' : ''} ${isImport ? 'data-import' : ''}`}>
{/* Bouton supprimer */}
{selected && (
<button
className="step-node-delete"
title="Supprimer (Suppr)"
onClick={(e) => {
e.stopPropagation();
data.onDelete?.(step.id);
}}
>
×
</button>
)}
{/* Entrée: haut */}
<Handle
type="target"

View File

@@ -415,6 +415,32 @@ body {
padding: 0.5rem;
font-size: 12px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
position: relative;
}
.step-node-delete {
position: absolute;
top: -8px;
right: -8px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #e53935;
color: white;
border: 2px solid white;
font-size: 14px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
}
.step-node-delete:hover {
background: #c62828;
transform: scale(1.1);
}
.step-node.selected {