backup: snapshot post-démo GHT 2026-05-19
Backup état complet après enregistrement vidéo démo de bout en bout. À utiliser comme point de référence pour la consolidation post-démo. Changements majeurs de la session 18-19 mai : - AIVA-URGENCE : page autonome avec preset URL + auto-focus chain - Workflow Demo_urgence_3_db : merge linux_db + steps AIVA + pause humaine NoMachine - Bypass LLM (static_result / static_text) dans replay_engine pour démos déterministes sans appel Ollama - Fix api_stream:3013 — replay_paused au premier polling /next - dag_execute : lift duration_ms vers top-level pour wait runtime - NPM bypass auth /aiva-urgence/ via location ^~ (proxy_host/10.conf hors git) - scripts/cancel-replays.sh — workaround Stop VWB qui ne purge pas la queue Anchors visuels (468) forcés dans le commit pour garantir restorabilité. DB workflows actuelle + ~12 .bak DB de la journée incluses. Sujets identifiés pour consolidation post-démo (TODO) : 1. Bug VWB recapture anchor ne régénère pas le PNG 2. Léa client accumule état mémoire (restart périodique requis) 3. Stop VWB ne purge pas la queue serveur (lien manquant vers /replay/cancel) 4. Bug coord client mss tronqué 2560x60 → mapping Y cassé 5. delay_before/delay_after ignorés au runtime (fix partiel duration_ms) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@@ -29,6 +29,19 @@ os.makedirs(ANCHORS_DIR, exist_ok=True)
|
||||
DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'data')
|
||||
CAPTURE_LIBRARY_PATH = os.path.join(DATA_DIR, 'capture_library.json')
|
||||
|
||||
# Dossier pour les PNG HD des captures de la bibliothèque (source de vérité).
|
||||
# Le JSON capture_library.json ne contient que les métadonnées + thumbnail 640x360
|
||||
# pour la grille. L'affichage plein écran utilise le PNG via /library/<id>/full.
|
||||
LIBRARY_CAPTURES_DIR = os.path.join(DATA_DIR, 'library_captures')
|
||||
os.makedirs(LIBRARY_CAPTURES_DIR, exist_ok=True)
|
||||
|
||||
# Limite raisonnable pour upload : 25 MB couvre largement un PNG 4K non compressé.
|
||||
LIBRARY_UPLOAD_MAX_BYTES = 25 * 1024 * 1024
|
||||
# Dimensions du thumbnail stocké dans le JSON (utilisé pour la grille de prévisualisation).
|
||||
LIBRARY_THUMB_W = 640
|
||||
LIBRARY_THUMB_H = 360
|
||||
LIBRARY_THUMB_QUALITY = 85
|
||||
|
||||
|
||||
def generate_id(prefix: str) -> str:
|
||||
"""Génère un ID unique"""
|
||||
@@ -406,24 +419,98 @@ def get_anchor_base64(anchor_id: str):
|
||||
|
||||
|
||||
# ── Bibliothèque de captures (persistance disque) ────────────────────────
|
||||
#
|
||||
# Architecture v2 (avril 2026) :
|
||||
# - PNG HD écrit dans data/library_captures/{id}.png (source de vérité)
|
||||
# - capture_library.json contient les métadonnées + thumbnail base64 640x360 q85
|
||||
# (uniquement pour la grille de prévisualisation)
|
||||
# - L'affichage plein écran utilise GET /capture/library/{id}/full (PNG HD)
|
||||
# - Au load (GET), les entrées dont le PNG manque sont purgées (poubelle Dom)
|
||||
|
||||
|
||||
def _library_png_path(item_id: str) -> str:
|
||||
"""Chemin disque du PNG HD pour un item de bibliothèque."""
|
||||
# Sécurise l'ID (pas de slashes ni de '..') avant de l'utiliser comme nom de fichier
|
||||
safe = ''.join(c for c in str(item_id) if c.isalnum() or c in ('_', '-'))
|
||||
return os.path.join(LIBRARY_CAPTURES_DIR, f"{safe}.png")
|
||||
|
||||
|
||||
def _make_thumbnail_b64(img: Image.Image) -> str:
|
||||
"""Génère le thumbnail JPEG 640x360 q85 et le retourne en base64 (data URI)."""
|
||||
thumb = img.copy()
|
||||
thumb.thumbnail((LIBRARY_THUMB_W, LIBRARY_THUMB_H), Image.LANCZOS)
|
||||
if thumb.mode != 'RGB':
|
||||
thumb = thumb.convert('RGB')
|
||||
buf = BytesIO()
|
||||
thumb.save(buf, format='JPEG', quality=LIBRARY_THUMB_QUALITY, optimize=True)
|
||||
return 'data:image/jpeg;base64,' + base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||
|
||||
|
||||
def _load_library_raw() -> list:
|
||||
"""Charge la liste brute depuis le JSON, sans filtrer."""
|
||||
if not os.path.exists(CAPTURE_LIBRARY_PATH):
|
||||
return []
|
||||
try:
|
||||
with open(CAPTURE_LIBRARY_PATH, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, list) else []
|
||||
except Exception as e:
|
||||
print(f"⚠️ [CaptureLibrary] Erreur lecture JSON: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def _write_library_atomic(library: list) -> None:
|
||||
"""Écrit le JSON de manière atomique (tmp + rename)."""
|
||||
tmp_path = CAPTURE_LIBRARY_PATH + '.tmp'
|
||||
with open(tmp_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(library, f, ensure_ascii=False)
|
||||
os.replace(tmp_path, CAPTURE_LIBRARY_PATH)
|
||||
|
||||
|
||||
def _filter_orphans(library: list) -> tuple[list, int]:
|
||||
"""Retire les entrées format=v2 sans PNG sur disque. Retourne (filtré, nb_purgés)."""
|
||||
kept = []
|
||||
purged = 0
|
||||
for item in library:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
# Format v2 : doit avoir un PNG sur disque ou il est purgé (poubelle)
|
||||
if item.get('format') == 'v2':
|
||||
item_id = item.get('id')
|
||||
if item_id and os.path.exists(_library_png_path(item_id)):
|
||||
kept.append(item)
|
||||
else:
|
||||
purged += 1
|
||||
else:
|
||||
# Format legacy v1 (base64 dans le JSON) : on les garde tant qu'ils existent
|
||||
# mais ils sont condamnés (pixélisés). Le frontend affichera un badge "legacy".
|
||||
kept.append(item)
|
||||
return kept, purged
|
||||
|
||||
|
||||
@api_v3_bp.route('/capture/library', methods=['GET'])
|
||||
def get_capture_library():
|
||||
"""
|
||||
Charge la bibliothèque de captures depuis le disque.
|
||||
|
||||
Filtre automatiquement les entrées v2 dont le PNG HD a disparu (poubelle).
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"library": [ { id, capture, timestamp, sessionId, favorite }, ... ]
|
||||
"library": [ { id, capture, timestamp, sessionId, favorite, format, fullImageUrl }, ... ]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
if os.path.exists(CAPTURE_LIBRARY_PATH):
|
||||
with open(CAPTURE_LIBRARY_PATH, 'r', encoding='utf-8') as f:
|
||||
library = json.load(f)
|
||||
else:
|
||||
library = []
|
||||
library = _load_library_raw()
|
||||
library, purged = _filter_orphans(library)
|
||||
|
||||
if purged > 0:
|
||||
print(f"🗑️ [CaptureLibrary] {purged} entrée(s) v2 sans PNG purgées au load")
|
||||
try:
|
||||
_write_library_atomic(library)
|
||||
except Exception as e:
|
||||
print(f"⚠️ [CaptureLibrary] Échec persistance après purge: {e}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
@@ -441,28 +528,29 @@ def get_capture_library():
|
||||
@api_v3_bp.route('/capture/library', methods=['POST'])
|
||||
def save_capture_library():
|
||||
"""
|
||||
Sauvegarde la bibliothèque de captures sur disque.
|
||||
Sauvegarde la bibliothèque de captures sur disque (métadonnées + thumbnails).
|
||||
|
||||
Filtre les entrées v2 sans PNG correspondant avant sauvegarde.
|
||||
|
||||
Request:
|
||||
{
|
||||
"library": [ { id, capture, timestamp, sessionId, favorite }, ... ]
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"count": 5
|
||||
"library": [ { id, capture, timestamp, sessionId, favorite, format }, ... ]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
library = data.get('library', [])
|
||||
|
||||
if not isinstance(library, list):
|
||||
return jsonify({'success': False, 'error': 'library doit être une liste'}), 400
|
||||
|
||||
# Purge avant écriture (le frontend peut envoyer des entrées orphelines)
|
||||
library, _ = _filter_orphans(library)
|
||||
|
||||
# Limiter à 50 captures pour éviter un fichier trop gros
|
||||
library = library[:50]
|
||||
|
||||
with open(CAPTURE_LIBRARY_PATH, 'w', encoding='utf-8') as f:
|
||||
json.dump(library, f, ensure_ascii=False)
|
||||
_write_library_atomic(library)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
@@ -475,3 +563,150 @@ def save_capture_library():
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@api_v3_bp.route('/capture/library/upload', methods=['POST'])
|
||||
def upload_capture_library_item():
|
||||
"""
|
||||
Upload d'une capture HD dans la bibliothèque.
|
||||
|
||||
Le PNG est stocké sur disque (source de vérité). Le serveur génère un
|
||||
thumbnail 640x360 q85 (base64) pour la grille de prévisualisation et
|
||||
enregistre les métadonnées dans capture_library.json.
|
||||
|
||||
Request: multipart/form-data
|
||||
file : PNG binaire (max 25 MB)
|
||||
id : ID client (optionnel — généré sinon)
|
||||
sessionId : ID de la session source
|
||||
timestamp : ISO 8601 (optionnel — now() sinon)
|
||||
favorite : 'true' / 'false' (optionnel)
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"item": {
|
||||
"id": "cap_...",
|
||||
"format": "v2",
|
||||
"fullImageUrl": "/api/v3/capture/library/cap_.../full",
|
||||
"timestamp": "...",
|
||||
"sessionId": "...",
|
||||
"favorite": false,
|
||||
"capture": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"thumbnail_base64": "data:image/jpeg;base64,..."
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'success': False, 'error': 'Champ "file" requis'}), 400
|
||||
|
||||
upload = request.files['file']
|
||||
raw = upload.read()
|
||||
if not raw:
|
||||
return jsonify({'success': False, 'error': 'Fichier vide'}), 400
|
||||
if len(raw) > LIBRARY_UPLOAD_MAX_BYTES:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'Fichier trop gros ({len(raw)} > {LIBRARY_UPLOAD_MAX_BYTES} octets)'
|
||||
}), 413
|
||||
|
||||
# Décoder + valider l'image
|
||||
try:
|
||||
img = Image.open(BytesIO(raw))
|
||||
img.load()
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': f'Image invalide: {e}'}), 400
|
||||
|
||||
if img.mode not in ('RGB', 'RGBA'):
|
||||
img = img.convert('RGB')
|
||||
|
||||
# ID : fourni par le client ou généré
|
||||
item_id = (request.form.get('id') or '').strip()
|
||||
if not item_id:
|
||||
item_id = generate_id('cap')
|
||||
# Re-sécurisation par _library_png_path
|
||||
png_path = _library_png_path(item_id)
|
||||
|
||||
# Écriture atomique du PNG HD (tmp + rename)
|
||||
tmp_path = png_path + '.tmp'
|
||||
img.save(tmp_path, format='PNG')
|
||||
os.replace(tmp_path, png_path)
|
||||
|
||||
# Thumbnail pour la grille
|
||||
thumbnail_b64 = _make_thumbnail_b64(img)
|
||||
|
||||
timestamp = request.form.get('timestamp') or datetime.now().isoformat()
|
||||
session_id = request.form.get('sessionId') or ''
|
||||
favorite = (request.form.get('favorite') or 'false').lower() == 'true'
|
||||
|
||||
item = {
|
||||
'id': item_id,
|
||||
'format': 'v2',
|
||||
'fullImageUrl': f'/api/v3/capture/library/{item_id}/full',
|
||||
'timestamp': timestamp,
|
||||
'sessionId': session_id,
|
||||
'favorite': favorite,
|
||||
'capture': {
|
||||
'width': img.width,
|
||||
'height': img.height,
|
||||
'thumbnail_base64': thumbnail_b64,
|
||||
'timestamp': timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
# Mise à jour du JSON (insertion en tête, dédup, cap à 50)
|
||||
library = _load_library_raw()
|
||||
library = [x for x in library if isinstance(x, dict) and x.get('id') != item_id]
|
||||
library.insert(0, item)
|
||||
library, _ = _filter_orphans(library)
|
||||
library = library[:50]
|
||||
_write_library_atomic(library)
|
||||
|
||||
print(f"📤 [CaptureLibrary] Upload v2 : {item_id} ({img.width}x{img.height}, {len(raw)} octets)")
|
||||
|
||||
return jsonify({'success': True, 'item': item})
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ [CaptureLibrary] Erreur upload: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_v3_bp.route('/capture/library/<item_id>/full', methods=['GET'])
|
||||
def get_capture_library_full(item_id: str):
|
||||
"""Sert le PNG HD d'une capture de la bibliothèque (pour le mode plein écran)."""
|
||||
try:
|
||||
png_path = _library_png_path(item_id)
|
||||
if not os.path.exists(png_path):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f"Capture '{item_id}' non trouvée"
|
||||
}), 404
|
||||
return send_file(png_path, mimetype='image/png', max_age=3600)
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_v3_bp.route('/capture/library/<item_id>', methods=['DELETE'])
|
||||
def delete_capture_library_item(item_id: str):
|
||||
"""Supprime une capture de la bibliothèque (PNG + entrée JSON)."""
|
||||
try:
|
||||
png_path = _library_png_path(item_id)
|
||||
if os.path.exists(png_path):
|
||||
try:
|
||||
os.remove(png_path)
|
||||
except OSError as e:
|
||||
print(f"⚠️ [CaptureLibrary] Échec suppression PNG {png_path}: {e}")
|
||||
|
||||
library = _load_library_raw()
|
||||
before = len(library)
|
||||
library = [x for x in library if isinstance(x, dict) and x.get('id') != item_id]
|
||||
if len(library) != before:
|
||||
_write_library_atomic(library)
|
||||
|
||||
return jsonify({'success': True, 'id': item_id})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@@ -123,6 +123,206 @@ def _build_llm_action(action_type: str, parameters: Dict[str, Any]) -> Dict[str,
|
||||
return action
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pré-expansion : extract_text_scroll → 6 sous-steps atomiques
|
||||
# ---------------------------------------------------------------------------
|
||||
# Le DAGExecutor (et Léa Windows) ne savent pas exécuter extract_text_scroll
|
||||
# en l'état. L'expansion existe côté agent_v0/server_v1/replay_engine.py
|
||||
# (_expand_extract_text_scroll) pour le chemin streaming, mais elle n'était
|
||||
# pas appelée sur le chemin VWB /execute-dag — d'où "Type d'action inconnu :
|
||||
# extract_text_scroll" dans les logs Léa. On reproduit ici la même expansion
|
||||
# au format step VWB pour préparer le DAG avant _convert_vwb_to_dag_steps.
|
||||
|
||||
_ETS_SUB_TEMPLATE = (
|
||||
# (suffix, action_type, params_factory(final_var, top_var, bottom_var, paragraph, scroll_pause_ms))
|
||||
("01_extract_top", "extract_text",
|
||||
lambda fv, tv, bv, p, ms: {"variable_name": tv, "paragraph": p}),
|
||||
("02_scroll_end", "key_combo",
|
||||
lambda fv, tv, bv, p, ms: {"keys": ["ctrl", "end"]}),
|
||||
("03_wait_scroll", "wait",
|
||||
lambda fv, tv, bv, p, ms: {"duration_ms": ms}),
|
||||
("04_extract_bottom", "extract_text",
|
||||
lambda fv, tv, bv, p, ms: {"variable_name": bv, "paragraph": p}),
|
||||
("05_concat", "_concat_text_vars",
|
||||
lambda fv, tv, bv, p, ms: {
|
||||
"top_var": tv, "bottom_var": bv,
|
||||
"output_var": fv, "separator": "\n\n",
|
||||
}),
|
||||
("06_scroll_home", "key_combo",
|
||||
lambda fv, tv, bv, p, ms: {"keys": ["ctrl", "home"]}),
|
||||
)
|
||||
|
||||
|
||||
def _expand_extract_text_scroll_in_workflow(
|
||||
steps_data: List[Dict[str, Any]],
|
||||
edges_data: List[Dict[str, Any]],
|
||||
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||
"""Pré-expansion : remplace chaque step extract_text_scroll par 6 sous-steps.
|
||||
|
||||
Conserve les edges externes en les redirigeant : edge → ets devient
|
||||
edge → premier sous-step ; edge ets → devient dernier sous-step →.
|
||||
Ajoute les edges internes en chaîne (sub_1 → sub_2 → … → sub_6).
|
||||
|
||||
Voir l'équivalent côté replay_engine : _expand_extract_text_scroll
|
||||
(format actions). Ici on opère en amont, sur les steps VWB.
|
||||
"""
|
||||
remap: Dict[str, Tuple[str, str]] = {} # original_id → (first_sub, last_sub)
|
||||
new_steps: List[Dict[str, Any]] = []
|
||||
new_edges: List[Dict[str, Any]] = []
|
||||
|
||||
for step in steps_data:
|
||||
if step.get("action_type") != "extract_text_scroll":
|
||||
new_steps.append(step)
|
||||
continue
|
||||
|
||||
sid = step["id"]
|
||||
params = step.get("parameters") or {}
|
||||
final_var = str(params.get("variable_name", "") or "")
|
||||
# Tolérance : variable_name parfois saisi avec accolades de template
|
||||
if final_var.startswith("{{") and final_var.endswith("}}"):
|
||||
final_var = final_var[2:-2].strip()
|
||||
paragraph = bool(params.get("paragraph", False))
|
||||
scroll_pause_ms = int(params.get("scroll_pause_ms", 500))
|
||||
top_var = f"{final_var}__top" if final_var else "ets_top"
|
||||
bottom_var = f"{final_var}__bottom" if final_var else "ets_bottom"
|
||||
|
||||
sub_ids: List[str] = []
|
||||
for suffix, act_type, params_factory in _ETS_SUB_TEMPLATE:
|
||||
sub_id = f"{sid}__{suffix}"
|
||||
sub_step = {
|
||||
**step,
|
||||
"id": sub_id,
|
||||
"action_type": act_type,
|
||||
"parameters": params_factory(
|
||||
final_var, top_var, bottom_var, paragraph, scroll_pause_ms,
|
||||
),
|
||||
"anchor_id": None,
|
||||
}
|
||||
new_steps.append(sub_step)
|
||||
sub_ids.append(sub_id)
|
||||
|
||||
# Edges internes en chaîne
|
||||
for i in range(len(sub_ids) - 1):
|
||||
new_edges.append({"source": sub_ids[i], "target": sub_ids[i + 1]})
|
||||
|
||||
remap[sid] = (sub_ids[0], sub_ids[-1])
|
||||
|
||||
# Remap edges externes
|
||||
for edge in edges_data:
|
||||
source = edge.get("source", "")
|
||||
target = edge.get("target", "")
|
||||
if source in remap:
|
||||
source = remap[source][1]
|
||||
if target in remap:
|
||||
target = remap[target][0]
|
||||
new_edges.append({**edge, "source": source, "target": target})
|
||||
|
||||
return new_steps, new_edges
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pré-expansion : extract_text_scroll → 6 sous-actions atomiques (chemin runtime)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Variante de _expand_extract_text_scroll_in_workflow (qui opère sur des
|
||||
# step VWB BDD) pour le format ACTIONS runtime reçu par execute_windows().
|
||||
# Nécessaire car execute_windows = proxy direct vers le streaming server, sans
|
||||
# passer par DAGExecutor/_convert_vwb_to_dag_steps. Sans cette expansion ici,
|
||||
# Léa reçoit le type composite "extract_text_scroll" et le rejette
|
||||
# (`Type d'action inconnu` dans agent_v1/core/executor.py).
|
||||
#
|
||||
# Doublon défensif keys/duration_ms (top-level + parameters) : le streaming
|
||||
# server lit selon le code path, ça couvre les deux cas. À nettoyer post-démo
|
||||
# une fois le contrat formats stabilisé.
|
||||
|
||||
def _expand_extract_text_scroll_in_actions(
|
||||
actions: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Remplace chaque action extract_text_scroll par 6 sous-actions atomiques.
|
||||
|
||||
Format d'entrée = liste d'actions runtime telles que reçues par
|
||||
execute_windows() depuis le frontend.
|
||||
|
||||
Voir agent_v0/server_v1/replay_engine._expand_extract_text_scroll pour
|
||||
l'équivalent côté chemin streaming pur. Ici on opère AVANT le proxy.
|
||||
"""
|
||||
result: List[Dict[str, Any]] = []
|
||||
for action in actions:
|
||||
if action.get("type") != "extract_text_scroll":
|
||||
result.append(action)
|
||||
continue
|
||||
|
||||
params = action.get("parameters") or {}
|
||||
final_var = str(params.get("variable_name", "") or "")
|
||||
# Tolérance : variable_name parfois saisi avec accolades de template
|
||||
if final_var.startswith("{{") and final_var.endswith("}}"):
|
||||
final_var = final_var[2:-2].strip()
|
||||
paragraph = bool(params.get("paragraph", False))
|
||||
scroll_pause_ms = int(params.get("scroll_pause_ms", 500))
|
||||
top_var = f"{final_var}__top" if final_var else "ets_top"
|
||||
bottom_var = f"{final_var}__bottom" if final_var else "ets_bottom"
|
||||
|
||||
base_id = action.get("action_id") or "ets_unknown"
|
||||
|
||||
def _new_sub(
|
||||
suffix: str,
|
||||
sub_type: str,
|
||||
sub_params: Dict[str, Any],
|
||||
extras: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
# Repartir de l'action originale en retirant les champs
|
||||
# spécifiques au type composite (qui n'ont plus de sens sur
|
||||
# les sous-actions atomiques).
|
||||
sub = {
|
||||
k: v
|
||||
for k, v in action.items()
|
||||
if k not in (
|
||||
"type", "action_id", "parameters",
|
||||
"keys", "duration_ms", "text", "by_text",
|
||||
"anchor_id", "target_spec",
|
||||
)
|
||||
}
|
||||
sub["action_id"] = f"{base_id}__{suffix}"
|
||||
sub["type"] = sub_type
|
||||
sub["parameters"] = sub_params
|
||||
if extras:
|
||||
sub.update(extras)
|
||||
return sub
|
||||
|
||||
result.append(_new_sub(
|
||||
"01_extract_top", "extract_text",
|
||||
{"variable_name": top_var, "paragraph": paragraph},
|
||||
))
|
||||
result.append(_new_sub(
|
||||
"02_scroll_end", "key_combo",
|
||||
{"keys": ["ctrl", "end"]},
|
||||
{"keys": ["ctrl", "end"]},
|
||||
))
|
||||
result.append(_new_sub(
|
||||
"03_wait_scroll", "wait",
|
||||
{"duration_ms": scroll_pause_ms},
|
||||
{"duration_ms": scroll_pause_ms},
|
||||
))
|
||||
result.append(_new_sub(
|
||||
"04_extract_bottom", "extract_text",
|
||||
{"variable_name": bottom_var, "paragraph": paragraph},
|
||||
))
|
||||
result.append(_new_sub(
|
||||
"05_concat", "_concat_text_vars",
|
||||
{
|
||||
"top_var": top_var,
|
||||
"bottom_var": bottom_var,
|
||||
"output_var": final_var,
|
||||
"separator": "\n\n",
|
||||
},
|
||||
))
|
||||
result.append(_new_sub(
|
||||
"06_scroll_home", "key_combo",
|
||||
{"keys": ["ctrl", "home"]},
|
||||
{"keys": ["ctrl", "home"]},
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Conversion VWB workflow → DAG steps
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -612,6 +812,12 @@ def execute_dag(workflow_id: str):
|
||||
# Exécution DAG normale pour les étapes restantes
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
# Pré-expansion des steps extract_text_scroll en sous-actions atomiques
|
||||
# (le DAGExecutor et Léa ne connaissent pas ce type composite).
|
||||
remaining_steps, remaining_edges = _expand_extract_text_scroll_in_workflow(
|
||||
remaining_steps, remaining_edges,
|
||||
)
|
||||
|
||||
# Convertir en étapes DAG
|
||||
dag_steps = _convert_vwb_to_dag_steps(remaining_steps, remaining_edges)
|
||||
|
||||
@@ -939,6 +1145,14 @@ def execute_windows():
|
||||
if not data:
|
||||
return jsonify({'error': 'Aucune donnée'}), 400
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Pré-expansion des actions extract_text_scroll → 6 sous-actions
|
||||
# atomiques. Sans ça, Léa rejette le type composite ("Type d'action
|
||||
# inconnu" dans agent_v1/core/executor.py). Cf. démo GHT 2026-05-11.
|
||||
# ---------------------------------------------------------------
|
||||
if 'actions' in data and data['actions']:
|
||||
data['actions'] = _expand_extract_text_scroll_in_actions(data['actions'])
|
||||
|
||||
# Vérifier si ce sont uniquement des actions fichiers (pas besoin de wait ni replay)
|
||||
all_file_actions = all(
|
||||
a.get('type', '') in _FILE_ACTION_TYPES
|
||||
@@ -1009,6 +1223,9 @@ def execute_windows():
|
||||
# ---------------------------------------------------------------
|
||||
if vwb_type in ('type_text', 'type_secret') and 'text' in params:
|
||||
action['text'] = params['text']
|
||||
# Propagation du flag paste (opt-in workflow non-Citrix) vers Léa
|
||||
if params.get('paste'):
|
||||
action['paste'] = bool(params['paste'])
|
||||
anchor_id = action.get('anchor_id') or (
|
||||
params.get('visual_anchor') or {}
|
||||
).get('anchor_id')
|
||||
@@ -1021,6 +1238,14 @@ def execute_windows():
|
||||
if vwb_type in ('keyboard_shortcut', 'hotkey') and 'keys' in params:
|
||||
action['keys'] = params['keys']
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# wait / wait_for_anchor / visual_condition → propager duration_ms
|
||||
# vers top-level. Sans ce lift, executor.py:1182 (`action.get("duration_ms", 500)`)
|
||||
# retombe sur 500ms par défaut.
|
||||
# ---------------------------------------------------------------
|
||||
if vwb_type in ('wait', 'wait_for_anchor', 'visual_condition') and 'duration_ms' in params:
|
||||
action['duration_ms'] = params['duration_ms']
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Actions fichiers → proxy vers /file-action de l'agent (port 5006)
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
|
After Width: | Height: | Size: 896 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 859 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 1005 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 1005 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 5.4 MiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 1003 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 375 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 3.3 MiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 506 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 678 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 592 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 6.3 MiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 650 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 714 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 859 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 978 KiB |
|
After Width: | Height: | Size: 386 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 792 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 143 B |
|
After Width: | Height: | Size: 720 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 341 KiB |
|
After Width: | Height: | Size: 632 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 165 B |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 978 KiB |
|
After Width: | Height: | Size: 8.4 KiB |