feat(vwb-v3): Architecture Thin Client fonctionnelle

API = Source de vérité unique (SQLite + Flask)
- Backend: API v3 avec session, workflow, capture, execute
- Frontend: Vanilla TypeScript, pas de state local
- Contrats stricts pour les actions RPA
- Drag & drop pour réorganiser les étapes
- Insertion d'étapes entre deux existantes
- Bibliothèque de captures (sessionStorage)
- Exécution avec coordonnées statiques (pyautogui)

Fonctionne mais fragile (coordonnées fixes, pas de détection visuelle)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Dom
2026-01-23 12:07:13 +01:00
parent a9a53991bc
commit 858e6007f9
23 changed files with 6813 additions and 6 deletions

View File

@@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VWB v3 - Thin Client</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<header>
<h1>Visual Workflow Builder v3</h1>
<span class="badge">Thin Client - API = Verite</span>
</header>
<main>
<!-- Colonne gauche: Workflows -->
<aside class="sidebar">
<section class="panel">
<h2>Workflows</h2>
<form id="create-workflow-form">
<input type="text" placeholder="Nouveau workflow..." required />
<button type="submit">+</button>
</form>
<div id="workflow-list"></div>
</section>
</aside>
<!-- Zone centrale: Canvas/Etapes -->
<section class="main-content">
<div class="toolbar">
<h3 id="workflow-name">Aucun workflow</h3>
<div id="action-buttons"></div>
</div>
<!-- Zone scrollable pour les étapes -->
<div class="steps-container">
<h4>Étapes du workflow</h4>
<div id="steps-list"></div>
</div>
</section>
<!-- Zone de capture (colonne séparée) -->
<section class="capture-section">
<div class="capture-zone">
<div class="capture-header">
<h4>Capture d'écran</h4>
<button id="btn-toggle-capture" class="toggle-btn">Réduire</button>
</div>
<div id="capture-content">
<div class="capture-controls">
<button id="btn-capture" class="capture-btn">Capturer</button>
<div class="timer-control">
<select id="capture-delay">
<option value="0">Immédiat</option>
<option value="3">3s</option>
<option value="5">5s</option>
<option value="10">10s</option>
</select>
<button id="btn-capture-timer" class="capture-btn secondary">Avec délai</button>
</div>
</div>
<div id="timer-countdown" class="timer-countdown hidden"></div>
<div id="capture-preview"></div>
<button id="btn-fullscreen" class="fullscreen-btn hidden">Ouvrir en plein écran</button>
<!-- Bibliothèque de captures -->
<div class="capture-library">
<h5>Bibliothèque <span id="library-count">(0)</span></h5>
<div id="capture-library-list"></div>
</div>
</div>
</div>
</section>
<!-- Colonne droite: Propriétés et Execution -->
<aside class="sidebar right">
<section class="panel">
<h2>Étape sélectionnée</h2>
<div id="selected-step">
<p class="empty">Sélectionnez une étape</p>
</div>
</section>
<section class="panel">
<h2>Exécution</h2>
<div id="execution-status">
<p>Prêt à exécuter</p>
<button id="btn-start" class="primary">Démarrer</button>
</div>
</section>
</aside>
</main>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,944 @@
{
"name": "vwb-thin-client",
"version": "3.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "vwb-thin-client",
"version": "3.0.0",
"devDependencies": {
"typescript": "^5.3.0",
"vite": "^5.0.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
"cpu": [
"mips64el"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz",
"integrity": "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz",
"integrity": "sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz",
"integrity": "sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz",
"integrity": "sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz",
"integrity": "sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz",
"integrity": "sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz",
"integrity": "sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz",
"integrity": "sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz",
"integrity": "sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz",
"integrity": "sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz",
"integrity": "sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==",
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz",
"integrity": "sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==",
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz",
"integrity": "sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz",
"integrity": "sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz",
"integrity": "sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==",
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz",
"integrity": "sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==",
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz",
"integrity": "sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==",
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz",
"integrity": "sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz",
"integrity": "sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz",
"integrity": "sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz",
"integrity": "sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openharmony"
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz",
"integrity": "sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz",
"integrity": "sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz",
"integrity": "sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz",
"integrity": "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
]
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
"dev": true,
"hasInstallScript": true,
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.21.5",
"@esbuild/android-arm": "0.21.5",
"@esbuild/android-arm64": "0.21.5",
"@esbuild/android-x64": "0.21.5",
"@esbuild/darwin-arm64": "0.21.5",
"@esbuild/darwin-x64": "0.21.5",
"@esbuild/freebsd-arm64": "0.21.5",
"@esbuild/freebsd-x64": "0.21.5",
"@esbuild/linux-arm": "0.21.5",
"@esbuild/linux-arm64": "0.21.5",
"@esbuild/linux-ia32": "0.21.5",
"@esbuild/linux-loong64": "0.21.5",
"@esbuild/linux-mips64el": "0.21.5",
"@esbuild/linux-ppc64": "0.21.5",
"@esbuild/linux-riscv64": "0.21.5",
"@esbuild/linux-s390x": "0.21.5",
"@esbuild/linux-x64": "0.21.5",
"@esbuild/netbsd-x64": "0.21.5",
"@esbuild/openbsd-x64": "0.21.5",
"@esbuild/sunos-x64": "0.21.5",
"@esbuild/win32-arm64": "0.21.5",
"@esbuild/win32-ia32": "0.21.5",
"@esbuild/win32-x64": "0.21.5"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/rollup": {
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz",
"integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==",
"dev": true,
"dependencies": {
"@types/estree": "1.0.8"
},
"bin": {
"rollup": "dist/bin/rollup"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.56.0",
"@rollup/rollup-android-arm64": "4.56.0",
"@rollup/rollup-darwin-arm64": "4.56.0",
"@rollup/rollup-darwin-x64": "4.56.0",
"@rollup/rollup-freebsd-arm64": "4.56.0",
"@rollup/rollup-freebsd-x64": "4.56.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.56.0",
"@rollup/rollup-linux-arm-musleabihf": "4.56.0",
"@rollup/rollup-linux-arm64-gnu": "4.56.0",
"@rollup/rollup-linux-arm64-musl": "4.56.0",
"@rollup/rollup-linux-loong64-gnu": "4.56.0",
"@rollup/rollup-linux-loong64-musl": "4.56.0",
"@rollup/rollup-linux-ppc64-gnu": "4.56.0",
"@rollup/rollup-linux-ppc64-musl": "4.56.0",
"@rollup/rollup-linux-riscv64-gnu": "4.56.0",
"@rollup/rollup-linux-riscv64-musl": "4.56.0",
"@rollup/rollup-linux-s390x-gnu": "4.56.0",
"@rollup/rollup-linux-x64-gnu": "4.56.0",
"@rollup/rollup-linux-x64-musl": "4.56.0",
"@rollup/rollup-openbsd-x64": "4.56.0",
"@rollup/rollup-openharmony-arm64": "4.56.0",
"@rollup/rollup-win32-arm64-msvc": "4.56.0",
"@rollup/rollup-win32-ia32-msvc": "4.56.0",
"@rollup/rollup-win32-x64-gnu": "4.56.0",
"@rollup/rollup-win32-x64-msvc": "4.56.0",
"fsevents": "~2.3.2"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
"rollup": "^4.20.0"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^18.0.0 || >=20.0.0",
"less": "*",
"lightningcss": "^1.21.0",
"sass": "*",
"sass-embedded": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.4.0"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"less": {
"optional": true
},
"lightningcss": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
}
}
}
}
}

View File

@@ -0,0 +1,15 @@
{
"name": "vwb-thin-client",
"version": "3.0.0",
"description": "Visual Workflow Builder - Thin Client (API = Verite)",
"type": "module",
"scripts": {
"dev": "vite --port 3001",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"typescript": "^5.3.0",
"vite": "^5.0.0"
}
}

View File

@@ -0,0 +1,228 @@
/**
* API Client VWB v3 - Thin Client
* Toutes les interactions avec le backend passent par ce module.
* L'API est la SOURCE DE VERITE UNIQUE.
*/
import type { AppState, Workflow, Step, Execution, Capture } from './types';
const API_BASE = '/api/v3';
async function request<T>(
method: string,
endpoint: string,
body?: unknown
): Promise<T> {
const options: RequestInit = {
method,
headers: {
'Content-Type': 'application/json'
}
};
if (body) {
options.body = JSON.stringify(body);
}
const response = await fetch(`${API_BASE}${endpoint}`, options);
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Erreur API');
}
return data;
}
// ============================================================
// Session
// ============================================================
export async function getState(): Promise<AppState> {
const data = await request<{
success: boolean;
session: AppState['session'];
workflow: AppState['workflow'];
execution: AppState['execution'];
workflows_list: AppState['workflows_list'];
}>('GET', '/session/state');
return {
session: data.session,
workflow: data.workflow,
execution: data.execution,
workflows_list: data.workflows_list
};
}
export async function selectWorkflow(workflowId: string): Promise<{
session: AppState['session'];
workflow: Workflow;
}> {
return request('POST', `/session/select-workflow/${workflowId}`);
}
export async function selectStep(stepId: string): Promise<{
session: AppState['session'];
step: Step;
}> {
return request('POST', `/session/select-step/${stepId}`);
}
export async function clearSession(): Promise<{ session: AppState['session'] }> {
return request('POST', '/session/clear');
}
// ============================================================
// Workflow CRUD
// ============================================================
export async function createWorkflow(
name: string,
description?: string
): Promise<{ workflow: Workflow; session: AppState['session'] }> {
return request('POST', '/workflow/create', { name, description });
}
export async function getWorkflow(workflowId: string): Promise<{ workflow: Workflow }> {
return request('GET', `/workflow/${workflowId}`);
}
export async function deleteWorkflow(workflowId: string): Promise<{
deleted_id: string;
session: AppState['session'];
}> {
return request('DELETE', `/workflow/${workflowId}`);
}
// ============================================================
// Steps
// ============================================================
export async function addStep(
workflowId: string,
actionType: string,
options?: {
position?: { x: number; y: number };
parameters?: Record<string, unknown>;
label?: string;
insertAfter?: string; // ID de l'étape après laquelle insérer
}
): Promise<{
workflow: Workflow;
step: Step;
needs_anchor: boolean;
session: AppState['session'];
}> {
return request('POST', `/workflow/${workflowId}/step`, {
action_type: actionType,
position: options?.position,
parameters: options?.parameters,
label: options?.label,
insert_after: options?.insertAfter
});
}
export async function updateStep(
workflowId: string,
stepId: string,
updates: {
action_type?: string;
position?: { x: number; y: number };
parameters?: Record<string, unknown>;
label?: string;
anchor_id?: string | null;
}
): Promise<{ workflow: Workflow; step: Step }> {
return request('PUT', `/workflow/${workflowId}/step/${stepId}`, updates);
}
export async function deleteStep(
workflowId: string,
stepId: string
): Promise<{ workflow: Workflow; session: AppState['session'] }> {
return request('DELETE', `/workflow/${workflowId}/step/${stepId}`);
}
export async function reorderSteps(
workflowId: string,
stepIds: string[]
): Promise<{ workflow: Workflow }> {
return request('POST', `/workflow/${workflowId}/reorder`, { step_ids: stepIds });
}
// ============================================================
// Capture
// ============================================================
export async function captureScreen(): Promise<{ capture: Capture }> {
return request('POST', '/capture/screen');
}
export async function selectAnchor(
stepId: string,
bbox: { x: number; y: number; width: number; height: number },
description?: string,
screenshotBase64?: string // Capture optionnelle
): Promise<{
workflow: Workflow;
step: Step;
anchor: import('./types').VisualAnchor;
}> {
return request('POST', '/capture/select', {
step_id: stepId,
bbox,
description,
screenshot_base64: screenshotBase64
});
}
export function getAnchorImageUrl(anchorId: string): string {
return `${API_BASE}/anchor/${anchorId}/image`;
}
export function getAnchorThumbnailUrl(anchorId: string): string {
return `${API_BASE}/anchor/${anchorId}/thumbnail`;
}
// ============================================================
// Execution
// ============================================================
export async function startExecution(workflowId?: string): Promise<{
execution: Execution;
session: AppState['session'];
}> {
return request('POST', '/execute/start', workflowId ? { workflow_id: workflowId } : {});
}
export async function pauseExecution(): Promise<{ execution: Execution }> {
return request('POST', '/execute/pause');
}
export async function resumeExecution(): Promise<{ execution: Execution }> {
return request('POST', '/execute/resume');
}
export async function stopExecution(): Promise<{
execution: Execution;
session: AppState['session'];
}> {
return request('POST', '/execute/stop');
}
export async function getExecutionStatus(): Promise<{
is_running: boolean;
is_paused: boolean;
execution: Execution | null;
session: AppState['session'];
}> {
return request('GET', '/execute/status');
}
export async function getExecutionHistory(workflowId?: string): Promise<{
executions: Execution[];
}> {
const query = workflowId ? `?workflow_id=${workflowId}` : '';
return request('GET', `/execute/history${query}`);
}

View File

@@ -0,0 +1,232 @@
/**
* VWB v3 - Thin Client
* Point d'entrée principal
*
* PRINCIPE: L'API est la SOURCE DE VERITE UNIQUE
* Ce frontend NE FAIT QU'AFFICHER ce que l'API retourne.
*/
import * as api from './api';
import * as ui from './ui';
import type { AppState, ActionType, Capture } from './types';
// État local minimal (juste pour le polling et la capture en cours)
let pollingInterval: number | null = null;
let currentCapture: Capture | null = null;
async function loadState(): Promise<void> {
try {
const state = await api.getState();
ui.render(state);
} catch (error) {
ui.showError((error as Error).message);
}
}
async function init(): Promise<void> {
// Initialiser l'UI avec les callbacks
ui.initUI({
// Workflows
onCreateWorkflow: async (name) => {
try {
await api.createWorkflow(name);
await loadState();
} catch (error) {
ui.showError((error as Error).message);
}
},
onSelectWorkflow: async (id) => {
try {
await api.selectWorkflow(id);
await loadState();
} catch (error) {
ui.showError((error as Error).message);
}
},
onDeleteWorkflow: async (id) => {
try {
await api.deleteWorkflow(id);
await loadState();
} catch (error) {
ui.showError((error as Error).message);
}
},
// Steps
onAddStep: async (actionType: ActionType, insertAfter?: string) => {
try {
const state = await api.getState();
if (!state.session.active_workflow_id) {
ui.showError('Aucun workflow actif');
return;
}
await api.addStep(state.session.active_workflow_id, actionType, {
insertAfter
});
await loadState();
} catch (error) {
ui.showError((error as Error).message);
}
},
onSelectStep: async (id) => {
try {
await api.selectStep(id);
await loadState();
} catch (error) {
ui.showError((error as Error).message);
}
},
onDeleteStep: async (id) => {
try {
const state = await api.getState();
if (!state.session.active_workflow_id) return;
await api.deleteStep(state.session.active_workflow_id, id);
await loadState();
} catch (error) {
ui.showError((error as Error).message);
}
},
onReorderSteps: async (stepIds) => {
console.log('📝 onReorderSteps appelé avec:', stepIds);
try {
const state = await api.getState();
console.log('État récupéré, active_workflow_id:', state.session.active_workflow_id);
if (!state.session.active_workflow_id) {
console.log('Pas de workflow actif, abandon');
return;
}
console.log('Appel API reorderSteps...');
await api.reorderSteps(state.session.active_workflow_id, stepIds);
console.log('API reorderSteps terminé, rechargement état...');
await loadState();
console.log('État rechargé');
} catch (error) {
console.error('Erreur dans onReorderSteps:', error);
ui.showError((error as Error).message);
}
},
onUpdateStepParams: async (id, params) => {
try {
const state = await api.getState();
if (!state.session.active_workflow_id) return;
// Traiter le cas des touches clavier
if ('keys' in params && typeof params.keys === 'string') {
params.keys = (params.keys as string).split('+').map(k => k.trim());
}
await api.updateStep(state.session.active_workflow_id, id, { parameters: params });
await loadState();
} catch (error) {
ui.showError((error as Error).message);
}
},
// Capture
onCaptureScreen: async () => {
try {
const result = await api.captureScreen();
currentCapture = result.capture;
ui.renderCapture(currentCapture);
} catch (error) {
ui.showError((error as Error).message);
}
},
onSelectAnchor: async (bbox, screenshotBase64) => {
try {
const state = await api.getState();
if (!state.session.selected_step_id) {
ui.showError('Sélectionnez une étape d\'abord');
return;
}
await api.selectAnchor(state.session.selected_step_id, bbox, undefined, screenshotBase64);
await loadState();
ui.showInfo('Ancre créée avec succès');
} catch (error) {
ui.showError((error as Error).message);
}
},
// Execution
onStartExecution: async () => {
try {
await api.startExecution();
startPolling();
await loadState();
} catch (error) {
ui.showError((error as Error).message);
}
},
onPauseExecution: async () => {
try {
await api.pauseExecution();
await loadState();
} catch (error) {
ui.showError((error as Error).message);
}
},
onResumeExecution: async () => {
try {
await api.resumeExecution();
await loadState();
} catch (error) {
ui.showError((error as Error).message);
}
},
onStopExecution: async () => {
try {
await api.stopExecution();
stopPolling();
await loadState();
} catch (error) {
ui.showError((error as Error).message);
}
}
});
// Charger l'état initial
await loadState();
}
function startPolling(): void {
if (pollingInterval) return;
pollingInterval = window.setInterval(async () => {
try {
const status = await api.getExecutionStatus();
ui.render({
session: status.session,
workflow: null, // On garde le workflow actuel
execution: status.execution,
workflows_list: []
} as AppState);
// Arrêter le polling si l'exécution est terminée
if (!status.is_running) {
stopPolling();
await loadState(); // Recharger l'état complet
}
} catch (error) {
console.error('Polling error:', error);
}
}, 500);
}
function stopPolling(): void {
if (pollingInterval) {
clearInterval(pollingInterval);
pollingInterval = null;
}
}
// Démarrer l'application
document.addEventListener('DOMContentLoaded', init);

View File

@@ -0,0 +1,125 @@
/**
* Types VWB v3 - Thin Client
* Ces types reflètent EXACTEMENT ce que l'API retourne.
* L'API est la source de vérité unique.
*/
export interface Session {
active_workflow_id: string | null;
selected_step_id: string | null;
active_execution_id: string | null;
has_capture: boolean;
}
export interface BoundingBox {
x: number;
y: number;
width: number;
height: number;
}
export interface VisualAnchor {
id: string;
image_url: string | null;
thumbnail_url: string | null;
bounding_box: BoundingBox | null;
screen_resolution: { width: number; height: number } | null;
description: string | null;
confidence_threshold: number;
created_at: string;
}
export interface Step {
id: string;
workflow_id: string;
action_type: string; // SOURCE DE VERITE UNIQUE
order: number;
position: { x: number; y: number };
parameters: Record<string, unknown>;
anchor_id: string | null;
anchor: VisualAnchor | null;
label: string;
created_at: string;
updated_at: string;
}
export interface Workflow {
id: string;
name: string;
description: string;
created_at: string;
updated_at: string;
steps: Step[];
step_count: number;
}
export interface WorkflowSummary {
id: string;
name: string;
step_count: number;
updated_at: string;
}
export interface ExecutionStepResult {
step_id: string;
status: 'pending' | 'running' | 'success' | 'error' | 'skipped';
started_at: string | null;
ended_at: string | null;
duration_ms: number | null;
error_message: string | null;
evidence_url: string | null;
output: Record<string, unknown>;
}
export interface Execution {
id: string;
workflow_id: string;
status: 'pending' | 'running' | 'paused' | 'completed' | 'error' | 'cancelled';
started_at: string | null;
ended_at: string | null;
current_step_index: number;
total_steps: number;
completed_steps: number;
failed_steps: number;
error_message: string | null;
progress: number;
step_results: ExecutionStepResult[];
}
export interface AppState {
session: Session;
workflow: Workflow | null;
execution: Execution | null;
workflows_list: WorkflowSummary[];
}
export interface Capture {
screenshot_base64: string;
width: number;
height: number;
timestamp: string;
}
// Types d'actions VWB supportés
export type ActionType =
| 'click_anchor'
| 'double_click_anchor'
| 'right_click_anchor'
| 'type_text'
| 'wait_for_anchor'
| 'keyboard_shortcut'
| 'scroll_to_anchor'
| 'extract_text'
| 'screenshot_evidence';
export const ACTION_LABELS: Record<ActionType, string> = {
'click_anchor': 'Clic sur ancre',
'double_click_anchor': 'Double-clic sur ancre',
'right_click_anchor': 'Clic droit sur ancre',
'type_text': 'Saisir texte',
'wait_for_anchor': 'Attendre ancre',
'keyboard_shortcut': 'Raccourci clavier',
'scroll_to_anchor': 'Défiler vers ancre',
'extract_text': 'Extraire texte',
'screenshot_evidence': 'Capture preuve'
};

View File

@@ -0,0 +1,872 @@
/**
* UI VWB v3 - Thin Client
* Ce module AFFICHE ce que l'API retourne.
* Pas de logique complexe, pas d'état local.
*/
import type { AppState, Step, Workflow, Execution, Capture, ActionType } from './types';
import { ACTION_LABELS } from './types';
// Éléments DOM
let $workflowList: HTMLElement;
let $workflowName: HTMLElement;
let $stepsList: HTMLElement;
let $selectedStep: HTMLElement;
let $executionStatus: HTMLElement;
let $capturePreview: HTMLElement;
let $actionButtons: HTMLElement;
let $timerCountdown: HTMLElement;
let $libraryList: HTMLElement;
let $libraryCount: HTMLElement;
// Bibliothèque de captures (en mémoire session)
let captureLibrary: Array<{ id: string; capture: import('./types').Capture; timestamp: Date }> = [];
// Callbacks pour les actions utilisateur
export type UICallbacks = {
onCreateWorkflow: (name: string) => void;
onSelectWorkflow: (id: string) => void;
onDeleteWorkflow: (id: string) => void;
onAddStep: (actionType: ActionType, insertAfter?: string) => void;
onSelectStep: (id: string) => void;
onDeleteStep: (id: string) => void;
onUpdateStepParams: (id: string, params: Record<string, unknown>) => void;
onReorderSteps: (stepIds: string[]) => void;
onCaptureScreen: () => void;
onSelectAnchor: (bbox: { x: number; y: number; width: number; height: number }, screenshotBase64?: string) => void;
onStartExecution: () => void;
onPauseExecution: () => void;
onResumeExecution: () => void;
onStopExecution: () => void;
};
// Variable pour stocker l'ID de l'étape après laquelle insérer
let insertAfterStepId: string | null = null;
let callbacks: UICallbacks;
export function initUI(cb: UICallbacks): void {
callbacks = cb;
// Récupérer les éléments DOM
$workflowList = document.getElementById('workflow-list')!;
$workflowName = document.getElementById('workflow-name')!;
$stepsList = document.getElementById('steps-list')!;
$selectedStep = document.getElementById('selected-step')!;
$executionStatus = document.getElementById('execution-status')!;
$capturePreview = document.getElementById('capture-preview')!;
$actionButtons = document.getElementById('action-buttons')!;
$timerCountdown = document.getElementById('timer-countdown')!;
$libraryList = document.getElementById('capture-library-list')!;
$libraryCount = document.getElementById('library-count')!;
// Boutons d'action
setupActionButtons();
// Formulaire nouveau workflow
const createForm = document.getElementById('create-workflow-form') as HTMLFormElement;
createForm?.addEventListener('submit', (e) => {
e.preventDefault();
const input = createForm.querySelector('input') as HTMLInputElement;
if (input.value.trim()) {
callbacks.onCreateWorkflow(input.value.trim());
input.value = '';
}
});
// Boutons execution
document.getElementById('btn-start')?.addEventListener('click', () => callbacks.onStartExecution());
document.getElementById('btn-pause')?.addEventListener('click', () => callbacks.onPauseExecution());
document.getElementById('btn-resume')?.addEventListener('click', () => callbacks.onResumeExecution());
document.getElementById('btn-stop')?.addEventListener('click', () => callbacks.onStopExecution());
// Capture immédiate
document.getElementById('btn-capture')?.addEventListener('click', () => callbacks.onCaptureScreen());
// Capture avec délai
document.getElementById('btn-capture-timer')?.addEventListener('click', () => {
const delay = parseInt((document.getElementById('capture-delay') as HTMLSelectElement).value);
startCaptureTimer(delay);
});
// Bouton plein écran
document.getElementById('btn-fullscreen')?.addEventListener('click', openFullscreenModal);
// Bouton toggle capture
const $toggleBtn = document.getElementById('btn-toggle-capture');
const $captureContent = document.getElementById('capture-content');
$toggleBtn?.addEventListener('click', () => {
const isCollapsed = $captureContent?.classList.toggle('collapsed');
if ($toggleBtn) {
$toggleBtn.textContent = isCollapsed ? 'Afficher' : 'Réduire';
}
});
// Créer la modal plein écran
createFullscreenModal();
// Charger la bibliothèque depuis sessionStorage
loadLibraryFromStorage();
}
function startCaptureTimer(seconds: number): void {
if (seconds === 0) {
callbacks.onCaptureScreen();
return;
}
let remaining = seconds;
$timerCountdown.classList.remove('hidden');
$timerCountdown.textContent = String(remaining);
const interval = setInterval(() => {
remaining--;
if (remaining > 0) {
$timerCountdown.textContent = String(remaining);
} else {
clearInterval(interval);
$timerCountdown.classList.add('hidden');
callbacks.onCaptureScreen();
}
}, 1000);
}
function loadLibraryFromStorage(): void {
try {
const stored = sessionStorage.getItem('captureLibrary');
if (stored) {
captureLibrary = JSON.parse(stored);
renderLibrary();
}
} catch (e) {
console.error('Erreur chargement bibliothèque:', e);
}
}
function saveLibraryToStorage(): void {
try {
sessionStorage.setItem('captureLibrary', JSON.stringify(captureLibrary));
} catch (e) {
console.error('Erreur sauvegarde bibliothèque:', e);
}
}
export function addToLibrary(capture: import('./types').Capture): void {
const id = `cap_${Date.now()}`;
captureLibrary.unshift({ id, capture, timestamp: new Date() });
// Garder max 20 captures
if (captureLibrary.length > 20) {
captureLibrary = captureLibrary.slice(0, 20);
}
saveLibraryToStorage();
renderLibrary();
}
function renderLibrary(): void {
$libraryCount.textContent = `(${captureLibrary.length})`;
if (captureLibrary.length === 0) {
$libraryList.innerHTML = '<p class="empty">Aucune capture</p>';
return;
}
$libraryList.innerHTML = captureLibrary.map(item => `
<div class="library-item" data-id="${item.id}">
<img src="data:image/png;base64,${item.capture.screenshot_base64}" alt="Capture" />
<button class="delete-lib" data-delete="${item.id}">×</button>
</div>
`).join('');
// Événements
$libraryList.querySelectorAll('.library-item').forEach(el => {
el.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (target.classList.contains('delete-lib')) {
const id = target.dataset.delete!;
captureLibrary = captureLibrary.filter(c => c.id !== id);
saveLibraryToStorage();
renderLibrary();
} else {
const id = (el as HTMLElement).dataset.id!;
const item = captureLibrary.find(c => c.id === id);
if (item) {
renderCapture(item.capture, false); // false = ne pas ré-ajouter à la bibliothèque
}
}
});
});
}
// Modal plein écran
let $fullscreenModal: HTMLElement;
let currentFullscreenCapture: import('./types').Capture | null = null;
function createFullscreenModal(): void {
$fullscreenModal = document.createElement('div');
$fullscreenModal.className = 'fullscreen-modal hidden';
$fullscreenModal.innerHTML = `
<button class="close-btn">Fermer (Echap)</button>
<div class="modal-content">
<img id="fullscreen-image" src="" alt="Capture plein écran" />
<div id="fullscreen-overlay"></div>
</div>
<p class="instructions">Dessinez un rectangle pour sélectionner l'ancre visuelle</p>
`;
document.body.appendChild($fullscreenModal);
// Fermer avec le bouton ou Echap
$fullscreenModal.querySelector('.close-btn')?.addEventListener('click', closeFullscreenModal);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !$fullscreenModal.classList.contains('hidden')) {
closeFullscreenModal();
}
});
}
function openFullscreenModal(): void {
if (!currentFullscreenCapture) return;
const img = $fullscreenModal.querySelector('#fullscreen-image') as HTMLImageElement;
img.src = `data:image/png;base64,${currentFullscreenCapture.screenshot_base64}`;
$fullscreenModal.classList.remove('hidden');
// Setup selection tool pour le mode plein écran
setTimeout(() => setupFullscreenSelectionTool(), 100);
}
function closeFullscreenModal(): void {
$fullscreenModal.classList.add('hidden');
}
function setupFullscreenSelectionTool(): void {
const img = $fullscreenModal.querySelector('#fullscreen-image') as HTMLImageElement;
const overlay = $fullscreenModal.querySelector('#fullscreen-overlay') as HTMLElement;
if (!img || !overlay) return;
let isSelecting = false;
let startX = 0, startY = 0;
let imgRect: DOMRect;
// Supprimer les anciens listeners
const newImg = img.cloneNode(true) as HTMLImageElement;
img.parentNode?.replaceChild(newImg, img);
newImg.addEventListener('mousedown', (e) => {
e.preventDefault();
imgRect = newImg.getBoundingClientRect();
startX = e.clientX - imgRect.left;
startY = e.clientY - imgRect.top;
isSelecting = true;
overlay.style.display = 'block';
overlay.style.left = startX + 'px';
overlay.style.top = startY + 'px';
overlay.style.width = '0';
overlay.style.height = '0';
});
const onMouseMove = (e: MouseEvent) => {
if (!isSelecting) return;
const currentX = e.clientX - imgRect.left;
const currentY = e.clientY - imgRect.top;
const width = currentX - startX;
const height = currentY - startY;
overlay.style.left = (width < 0 ? currentX : startX) + 'px';
overlay.style.top = (height < 0 ? currentY : startY) + 'px';
overlay.style.width = Math.abs(width) + 'px';
overlay.style.height = Math.abs(height) + 'px';
};
const onMouseUp = (e: MouseEvent) => {
if (!isSelecting) return;
isSelecting = false;
const endX = e.clientX - imgRect.left;
const endY = e.clientY - imgRect.top;
const scaleX = newImg.naturalWidth / newImg.width;
const scaleY = newImg.naturalHeight / newImg.height;
const bbox = {
x: Math.round(Math.min(startX, endX) * scaleX),
y: Math.round(Math.min(startY, endY) * scaleY),
width: Math.round(Math.abs(endX - startX) * scaleX),
height: Math.round(Math.abs(endY - startY) * scaleY)
};
if (bbox.width > 10 && bbox.height > 10) {
closeFullscreenModal();
// Envoyer la capture courante avec la sélection
const screenshotBase64 = currentFullscreenCapture?.screenshot_base64;
callbacks.onSelectAnchor(bbox, screenshotBase64);
} else {
overlay.style.display = 'none';
}
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}
const AVAILABLE_ACTIONS: ActionType[] = [
'click_anchor',
'double_click_anchor',
'type_text',
'wait_for_anchor',
'keyboard_shortcut'
];
function setupActionButtons(): void {
$actionButtons.innerHTML = AVAILABLE_ACTIONS.map(action => `
<button class="action-btn" data-action="${action}">
${ACTION_LABELS[action]}
</button>
`).join('');
$actionButtons.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const action = target.dataset.action as ActionType;
if (action) {
callbacks.onAddStep(action);
}
});
}
// Menu contextuel pour insérer une étape
let $insertMenu: HTMLElement | null = null;
function showInsertStepMenu(anchorEl: HTMLElement, insertAfterId: string): void {
// Supprimer le menu existant
hideInsertStepMenu();
// Créer le menu
$insertMenu = document.createElement('div');
$insertMenu.className = 'insert-menu';
$insertMenu.innerHTML = `
<div class="insert-menu-header">Insérer après l'étape:</div>
${AVAILABLE_ACTIONS.map(action => `
<button class="insert-menu-item" data-action="${action}">
${ACTION_LABELS[action]}
</button>
`).join('')}
`;
// Positionner le menu
const rect = anchorEl.getBoundingClientRect();
$insertMenu.style.position = 'fixed';
$insertMenu.style.left = `${rect.left}px`;
$insertMenu.style.top = `${rect.bottom + 5}px`;
document.body.appendChild($insertMenu);
// Event listeners
$insertMenu.querySelectorAll('.insert-menu-item').forEach(btn => {
btn.addEventListener('click', () => {
const action = (btn as HTMLElement).dataset.action as ActionType;
callbacks.onAddStep(action, insertAfterId);
hideInsertStepMenu();
});
});
// Fermer si clic ailleurs
setTimeout(() => {
document.addEventListener('click', handleOutsideClick);
}, 10);
}
function handleOutsideClick(e: MouseEvent): void {
if ($insertMenu && !$insertMenu.contains(e.target as Node)) {
hideInsertStepMenu();
}
}
function hideInsertStepMenu(): void {
if ($insertMenu) {
$insertMenu.remove();
$insertMenu = null;
document.removeEventListener('click', handleOutsideClick);
}
}
export function render(state: AppState): void {
renderWorkflowList(state);
renderWorkflow(state.workflow);
renderSelectedStep(state);
renderExecution(state.execution);
}
function renderWorkflowList(state: AppState): void {
if (state.workflows_list.length === 0) {
$workflowList.innerHTML = '<p class="empty">Aucun workflow</p>';
return;
}
$workflowList.innerHTML = state.workflows_list.map(wf => `
<div class="workflow-item ${state.session.active_workflow_id === wf.id ? 'active' : ''}"
data-id="${wf.id}">
<span class="name">${escapeHtml(wf.name)}</span>
<span class="count">${wf.step_count} étapes</span>
<button class="delete-btn" data-delete="${wf.id}" title="Supprimer">×</button>
</div>
`).join('');
// Event listeners
$workflowList.querySelectorAll('.workflow-item').forEach(el => {
el.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (target.classList.contains('delete-btn')) {
const id = target.dataset.delete!;
if (confirm('Supprimer ce workflow ?')) {
callbacks.onDeleteWorkflow(id);
}
} else {
const id = (el as HTMLElement).dataset.id!;
callbacks.onSelectWorkflow(id);
}
});
});
}
// Variables pour le drag & drop
let draggedStepId: string | null = null;
let currentWorkflowSteps: string[] = [];
function renderWorkflow(workflow: Workflow | null): void {
if (!workflow) {
$workflowName.textContent = 'Aucun workflow sélectionné';
$stepsList.innerHTML = '<p class="empty">Sélectionnez ou créez un workflow</p>';
return;
}
$workflowName.textContent = workflow.name;
if (workflow.steps.length === 0) {
$stepsList.innerHTML = '<p class="empty">Ajoutez des étapes avec les boutons ci-dessus</p>';
return;
}
// Stocker l'ordre actuel des étapes
currentWorkflowSteps = workflow.steps.map(s => s.id);
// Générer HTML avec drag & drop
let html = '';
workflow.steps.forEach((step, index) => {
html += `
<div class="step-item" data-id="${step.id}" draggable="true">
<span class="drag-handle" title="Glisser pour réorganiser">⋮⋮</span>
<span class="order">${index + 1}</span>
<span class="type">${ACTION_LABELS[step.action_type as ActionType] || step.action_type}</span>
<span class="label">${escapeHtml(step.label)}</span>
${step.anchor ? '<span class="anchor-badge">Ancre</span>' : ''}
<button class="delete-btn" data-delete="${step.id}" title="Supprimer">×</button>
</div>
<div class="insert-step-btn" data-insert-after="${step.id}" title="Insérer une étape ici">
<span class="insert-icon">+</span>
</div>
`;
});
$stepsList.innerHTML = html;
// Event listeners pour les étapes (clic et drag & drop)
$stepsList.querySelectorAll('.step-item').forEach(el => {
const stepEl = el as HTMLElement;
// Clic pour sélectionner ou supprimer
stepEl.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (target.classList.contains('delete-btn')) {
const id = target.dataset.delete!;
callbacks.onDeleteStep(id);
} else if (!target.classList.contains('drag-handle')) {
const id = stepEl.dataset.id!;
callbacks.onSelectStep(id);
}
});
// Drag & Drop
stepEl.addEventListener('dragstart', (e) => {
draggedStepId = stepEl.dataset.id!;
stepEl.classList.add('dragging');
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
}
});
stepEl.addEventListener('dragend', () => {
stepEl.classList.remove('dragging');
draggedStepId = null;
// Supprimer tous les indicateurs
$stepsList.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
});
stepEl.addEventListener('dragover', (e) => {
e.preventDefault();
if (!draggedStepId || draggedStepId === stepEl.dataset.id) return;
// Supprimer les autres indicateurs
$stepsList.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
// Ajouter l'indicateur sur cet élément
stepEl.classList.add('drag-over');
});
stepEl.addEventListener('dragleave', () => {
stepEl.classList.remove('drag-over');
});
stepEl.addEventListener('drop', (e) => {
console.log('🎯 DROP EVENT TRIGGERED on', stepEl.dataset.id);
e.preventDefault();
stepEl.classList.remove('drag-over');
if (!draggedStepId || draggedStepId === stepEl.dataset.id) {
console.log('Drop ignoré: draggedStepId=', draggedStepId, 'targetId=', stepEl.dataset.id);
return;
}
const targetId = stepEl.dataset.id!;
console.log('Drop event: draggedStepId=', draggedStepId, 'targetId=', targetId);
console.log('currentWorkflowSteps avant:', [...currentWorkflowSteps]);
// Calculer le nouvel ordre
const newOrder = [...currentWorkflowSteps];
const draggedIndex = newOrder.indexOf(draggedStepId);
const targetIndex = newOrder.indexOf(targetId);
console.log('draggedIndex=', draggedIndex, 'targetIndex=', targetIndex);
if (draggedIndex !== -1 && targetIndex !== -1 && draggedIndex !== targetIndex) {
// Retirer l'élément glissé
newOrder.splice(draggedIndex, 1);
// L'insérer à la position cible
// Quand on déplace vers le bas: on veut aller APRÈS l'élément cible
// Quand on déplace vers le haut: on veut prendre la place de l'élément cible
// Dans les deux cas après suppression, targetIndex est la bonne position
newOrder.splice(targetIndex, 0, draggedStepId);
console.log('newOrder après réorganisation:', newOrder);
// Appeler le callback pour réordonner
try {
console.log('Appel de callbacks.onReorderSteps...');
callbacks.onReorderSteps(newOrder);
console.log('Callback appelé avec succès');
} catch (err) {
console.error('Erreur lors du callback onReorderSteps:', err);
}
} else {
console.log('Pas de changement nécessaire');
}
});
});
// Event listeners pour les boutons d'insertion
$stepsList.querySelectorAll('.insert-step-btn').forEach(el => {
el.addEventListener('click', () => {
const insertAfter = (el as HTMLElement).dataset.insertAfter!;
showInsertStepMenu(el as HTMLElement, insertAfter);
});
});
}
function renderSelectedStep(state: AppState): void {
const selectedId = state.session.selected_step_id;
// Highlight selected step
$stepsList.querySelectorAll('.step-item').forEach(el => {
el.classList.toggle('selected', (el as HTMLElement).dataset.id === selectedId);
});
if (!selectedId || !state.workflow) {
$selectedStep.innerHTML = '<p class="empty">Sélectionnez une étape</p>';
return;
}
const step = state.workflow.steps.find(s => s.id === selectedId);
if (!step) {
$selectedStep.innerHTML = '<p class="empty">Étape non trouvée</p>';
return;
}
$selectedStep.innerHTML = `
<div class="step-details">
<h4>${ACTION_LABELS[step.action_type as ActionType] || step.action_type}</h4>
<p><strong>ID:</strong> ${step.id}</p>
<p><strong>Type:</strong> ${step.action_type}</p>
${renderStepParams(step)}
${step.anchor ? renderAnchor(step) : renderNeedAnchor(step)}
</div>
`;
// Event listener pour les paramètres
const paramsForm = $selectedStep.querySelector('.params-form');
paramsForm?.addEventListener('submit', (e) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const formData = new FormData(form);
const params: Record<string, unknown> = {};
formData.forEach((value, key) => {
params[key] = value;
});
callbacks.onUpdateStepParams(step.id, params);
});
}
function renderStepParams(step: Step): string {
const actionType = step.action_type as ActionType;
if (actionType === 'type_text') {
return `
<form class="params-form">
<label>
Texte à saisir:
<input type="text" name="text" value="${escapeHtml(String(step.parameters.text || ''))}" />
</label>
<button type="submit">Mettre à jour</button>
</form>
`;
}
if (actionType === 'wait_for_anchor') {
return `
<form class="params-form">
<label>
Timeout (ms):
<input type="number" name="timeout_ms" value="${step.parameters.timeout_ms || 5000}" />
</label>
<button type="submit">Mettre à jour</button>
</form>
`;
}
if (actionType === 'keyboard_shortcut') {
return `
<form class="params-form">
<label>
Touches (séparées par +):
<input type="text" name="keys" value="${escapeHtml((step.parameters.keys as string[] || []).join('+'))}" />
</label>
<button type="submit">Mettre à jour</button>
</form>
`;
}
return '<p class="no-params">Pas de paramètres supplémentaires</p>';
}
function renderAnchor(step: Step): string {
const anchor = step.anchor!;
return `
<div class="anchor-info">
<h5>Ancre visuelle</h5>
${anchor.thumbnail_url ? `<img src="${anchor.thumbnail_url}" alt="Ancre" class="anchor-thumb" />` : ''}
<p>${anchor.description || 'Aucune description'}</p>
<p class="coords">Position: (${anchor.bounding_box?.x}, ${anchor.bounding_box?.y})</p>
</div>
`;
}
function renderNeedAnchor(step: Step): string {
const needsAnchor = ['click_anchor', 'double_click_anchor', 'right_click_anchor', 'wait_for_anchor', 'scroll_to_anchor']
.includes(step.action_type);
if (!needsAnchor) return '';
return `
<div class="need-anchor">
<p class="warning">Cette étape nécessite une ancre visuelle</p>
<button id="btn-select-anchor">Capturer et sélectionner</button>
</div>
`;
}
function renderExecution(execution: Execution | null): void {
if (!execution) {
$executionStatus.innerHTML = `
<p>Prêt à exécuter</p>
<div class="exec-buttons">
<button id="btn-start" class="primary">Démarrer</button>
</div>
`;
// Attacher l'événement au nouveau bouton
document.getElementById('btn-start')?.addEventListener('click', () => {
console.log('Démarrer cliqué');
callbacks.onStartExecution();
});
return;
}
const statusLabels: Record<string, string> = {
'pending': 'En attente',
'running': 'En cours',
'paused': 'En pause',
'completed': 'Terminé',
'error': 'Erreur',
'cancelled': 'Annulé'
};
$executionStatus.innerHTML = `
<div class="exec-info">
<p class="status status-${execution.status}">${statusLabels[execution.status]}</p>
<div class="progress-bar">
<div class="progress" style="width: ${execution.progress}%"></div>
</div>
<p class="progress-text">${execution.completed_steps}/${execution.total_steps} étapes</p>
${execution.error_message ? `<p class="error">${escapeHtml(execution.error_message)}</p>` : ''}
</div>
<div class="exec-buttons">
${execution.status === 'running' ? '<button id="btn-pause">Pause</button>' : ''}
${execution.status === 'paused' ? '<button id="btn-resume">Reprendre</button>' : ''}
${['running', 'paused'].includes(execution.status) ? '<button id="btn-stop" class="danger">Arrêter</button>' : ''}
${['completed', 'error', 'cancelled'].includes(execution.status) ? '<button id="btn-start" class="primary">Relancer</button>' : ''}
</div>
`;
// Re-attacher les événements
document.getElementById('btn-start')?.addEventListener('click', () => callbacks.onStartExecution());
document.getElementById('btn-pause')?.addEventListener('click', () => callbacks.onPauseExecution());
document.getElementById('btn-resume')?.addEventListener('click', () => callbacks.onResumeExecution());
document.getElementById('btn-stop')?.addEventListener('click', () => callbacks.onStopExecution());
}
export function renderCapture(capture: Capture, addToLib: boolean = true): void {
currentFullscreenCapture = capture;
$capturePreview.innerHTML = `
<div class="capture-container">
<img src="data:image/png;base64,${capture.screenshot_base64}"
alt="Capture d'écran"
id="capture-image" />
<div id="selection-overlay"></div>
</div>
<p class="capture-info">${capture.width}x${capture.height} - Dessinez un rectangle ou utilisez le mode plein écran</p>
`;
// Afficher le bouton plein écran
const $btnFullscreen = document.getElementById('btn-fullscreen');
if ($btnFullscreen) {
$btnFullscreen.classList.remove('hidden');
}
// Ajouter à la bibliothèque (sauf si on charge depuis la bibliothèque)
if (addToLib) {
addToLibrary(capture);
}
// Attendre que l'image soit chargée
const img = document.getElementById('capture-image') as HTMLImageElement;
if (img) {
img.onload = () => {
console.log('Image chargée, setup selection tool');
setupSelectionTool();
};
// Si déjà chargée (cache)
if (img.complete) {
console.log('Image déjà en cache');
setupSelectionTool();
}
}
}
function setupSelectionTool(): void {
const img = document.getElementById('capture-image') as HTMLImageElement;
const overlay = document.getElementById('selection-overlay')!;
const container = img?.parentElement;
if (!img || !overlay || !container) {
console.error('Selection tool: éléments manquants', { img: !!img, overlay: !!overlay, container: !!container });
return;
}
console.log('Selection tool initialisé');
let isSelecting = false;
let startX = 0, startY = 0;
let imgRect: DOMRect;
// Mousedown sur l'image pour démarrer la sélection
img.addEventListener('mousedown', (e) => {
e.preventDefault();
imgRect = img.getBoundingClientRect();
startX = e.clientX - imgRect.left;
startY = e.clientY - imgRect.top;
isSelecting = true;
overlay.style.display = 'block';
overlay.style.left = startX + 'px';
overlay.style.top = startY + 'px';
overlay.style.width = '0';
overlay.style.height = '0';
console.log('Sélection démarrée à', startX, startY);
});
// Mousemove sur le document pour suivre même en dehors de l'image
document.addEventListener('mousemove', (e) => {
if (!isSelecting) return;
const currentX = e.clientX - imgRect.left;
const currentY = e.clientY - imgRect.top;
const width = currentX - startX;
const height = currentY - startY;
overlay.style.left = (width < 0 ? currentX : startX) + 'px';
overlay.style.top = (height < 0 ? currentY : startY) + 'px';
overlay.style.width = Math.abs(width) + 'px';
overlay.style.height = Math.abs(height) + 'px';
});
// Mouseup sur le document pour terminer
document.addEventListener('mouseup', (e) => {
if (!isSelecting) return;
isSelecting = false;
const endX = e.clientX - imgRect.left;
const endY = e.clientY - imgRect.top;
// Calculer les coordonnées réelles (ratio image affichée / taille réelle)
const scaleX = img.naturalWidth / img.width;
const scaleY = img.naturalHeight / img.height;
const bbox = {
x: Math.round(Math.min(startX, endX) * scaleX),
y: Math.round(Math.min(startY, endY) * scaleY),
width: Math.round(Math.abs(endX - startX) * scaleX),
height: Math.round(Math.abs(endY - startY) * scaleY)
};
console.log('Sélection terminée, bbox:', bbox);
if (bbox.width > 10 && bbox.height > 10) {
console.log('Envoi de la sélection au backend...');
// Envoyer la capture courante avec la sélection
const screenshotBase64 = currentFullscreenCapture?.screenshot_base64;
callbacks.onSelectAnchor(bbox, screenshotBase64);
} else {
console.log('Sélection trop petite, ignorée');
overlay.style.display = 'none';
}
});
}
function escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
export function showError(message: string): void {
alert(`Erreur: ${message}`);
}
export function showInfo(message: string): void {
console.log(`Info: ${message}`);
}

View File

@@ -0,0 +1,830 @@
/* VWB v3 - Thin Client Styles */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
color: #eee;
min-height: 100vh;
}
header {
background: #16213e;
padding: 1rem 2rem;
display: flex;
align-items: center;
gap: 1rem;
border-bottom: 1px solid #0f3460;
}
header h1 {
font-size: 1.5rem;
font-weight: 600;
}
.badge {
background: #e94560;
color: white;
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.75rem;
font-weight: 500;
}
main {
display: grid;
grid-template-columns: 220px 1fr 320px 300px;
gap: 1rem;
padding: 1rem;
height: calc(100vh - 60px);
overflow: hidden;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 1rem;
}
.panel {
background: #16213e;
border-radius: 8px;
padding: 1rem;
border: 1px solid #0f3460;
}
.panel h2 {
font-size: 1rem;
margin-bottom: 1rem;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Formulaire création workflow */
#create-workflow-form {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
#create-workflow-form input {
flex: 1;
padding: 0.5rem;
border: 1px solid #0f3460;
border-radius: 4px;
background: #1a1a2e;
color: #eee;
}
#create-workflow-form button {
padding: 0.5rem 1rem;
background: #e94560;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
/* Liste workflows */
#workflow-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.workflow-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: #1a1a2e;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
}
.workflow-item:hover {
background: #0f3460;
}
.workflow-item.active {
background: #0f3460;
border-left: 3px solid #e94560;
}
.workflow-item .name {
flex: 1;
font-weight: 500;
}
.workflow-item .count {
font-size: 0.75rem;
color: #94a3b8;
}
.workflow-item .delete-btn {
background: transparent;
border: none;
color: #94a3b8;
cursor: pointer;
font-size: 1.25rem;
line-height: 1;
padding: 0 0.25rem;
}
.workflow-item .delete-btn:hover {
color: #e94560;
}
/* Zone centrale */
.main-content {
display: flex;
flex-direction: column;
gap: 1rem;
overflow: hidden;
min-width: 0;
}
/* Section capture */
.capture-section {
display: flex;
flex-direction: column;
overflow: hidden;
}
.capture-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.capture-header h4 {
margin: 0;
color: #94a3b8;
}
.toggle-btn {
padding: 0.25rem 0.5rem;
background: #0f3460;
color: #94a3b8;
border: 1px solid #1a5276;
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
}
.toggle-btn:hover {
background: #1a5276;
}
#capture-content {
flex: 1;
overflow-y: auto;
}
#capture-content.collapsed {
display: none;
}
.toolbar {
background: #16213e;
padding: 1rem;
border-radius: 8px;
border: 1px solid #0f3460;
}
.toolbar h3 {
margin-bottom: 1rem;
}
#action-buttons {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.action-btn {
padding: 0.5rem 1rem;
background: #0f3460;
color: white;
border: 1px solid #1a5276;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
}
.action-btn:hover {
background: #1a5276;
border-color: #2471a3;
}
/* Liste des étapes */
.steps-container {
background: #16213e;
border-radius: 8px;
padding: 1rem;
border: 1px solid #0f3460;
flex: 1;
overflow-y: auto;
min-height: 200px;
}
.steps-container h4 {
margin-bottom: 1rem;
color: #94a3b8;
position: sticky;
top: 0;
background: #16213e;
padding-bottom: 0.5rem;
}
#steps-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.step-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: #1a1a2e;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
border: 2px solid transparent;
}
.step-item:hover {
background: #0f3460;
}
.step-item.selected {
border-color: #e94560;
background: #0f3460;
}
.step-item .order {
width: 24px;
height: 24px;
background: #0f3460;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 600;
}
.step-item .type {
font-weight: 500;
color: #3498db;
}
.step-item .label {
flex: 1;
color: #94a3b8;
font-size: 0.875rem;
}
.step-item .anchor-badge {
font-size: 0.625rem;
background: #27ae60;
color: white;
padding: 0.125rem 0.5rem;
border-radius: 1rem;
}
/* Drag & Drop */
.step-item .drag-handle {
cursor: grab;
color: #64748b;
font-size: 1rem;
padding: 0 0.25rem;
user-select: none;
}
.step-item .drag-handle:hover {
color: #94a3b8;
}
.step-item .drag-handle:active {
cursor: grabbing;
}
.step-item.dragging {
opacity: 0.5;
border: 2px dashed #3498db;
}
.step-item.drag-over {
border-top: 3px solid #e94560;
margin-top: -3px;
}
.step-item[draggable="true"] {
cursor: default;
}
/* Bouton d'insertion entre les étapes */
.insert-step-btn {
display: flex;
align-items: center;
justify-content: center;
height: 20px;
margin: 2px 0;
cursor: pointer;
opacity: 0.3;
transition: opacity 0.2s;
}
.insert-step-btn:hover {
opacity: 1;
}
.insert-step-btn .insert-icon {
width: 20px;
height: 20px;
background: #3498db;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: bold;
}
.insert-step-btn:hover .insert-icon {
background: #2980b9;
transform: scale(1.1);
}
/* Menu contextuel d'insertion */
.insert-menu {
background: #16213e;
border: 1px solid #0f3460;
border-radius: 8px;
padding: 0.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 100;
min-width: 180px;
}
.insert-menu-header {
font-size: 0.75rem;
color: #94a3b8;
padding: 0.25rem 0.5rem;
margin-bottom: 0.25rem;
}
.insert-menu-item {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
background: transparent;
border: none;
color: #eee;
text-align: left;
cursor: pointer;
border-radius: 4px;
font-size: 0.875rem;
}
.insert-menu-item:hover {
background: #0f3460;
}
/* Zone capture */
.capture-zone {
background: #16213e;
border-radius: 8px;
padding: 1rem;
border: 1px solid #0f3460;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.capture-btn {
padding: 0.75rem 1.5rem;
background: #27ae60;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
margin-bottom: 1rem;
}
.capture-btn:hover {
background: #2ecc71;
}
#capture-preview {
margin-top: 1rem;
}
#capture-preview .capture-container {
position: relative;
display: inline-block;
max-width: 100%;
overflow: hidden;
}
#capture-preview img {
max-width: 100%;
max-height: 250px;
border-radius: 4px;
display: block;
cursor: crosshair;
object-fit: contain;
}
#selection-overlay {
position: absolute;
border: 2px dashed #e94560;
background: rgba(233, 69, 96, 0.3);
pointer-events: none;
display: none;
z-index: 10;
}
.capture-info {
margin-top: 0.5rem;
font-size: 0.75rem;
color: #94a3b8;
}
.capture-hint {
font-size: 0.8rem;
color: #94a3b8;
margin-bottom: 1rem;
}
.capture-zone h4 {
margin-bottom: 0.75rem;
color: #94a3b8;
}
.capture-controls {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1rem;
align-items: center;
}
.timer-control {
display: flex;
align-items: center;
gap: 0.5rem;
}
.timer-control label {
font-size: 0.875rem;
color: #94a3b8;
}
.timer-control select {
padding: 0.5rem;
background: #1a1a2e;
border: 1px solid #0f3460;
border-radius: 4px;
color: #eee;
}
.capture-btn.secondary {
background: #3498db;
}
.capture-btn.secondary:hover {
background: #2980b9;
}
.timer-countdown {
font-size: 3rem;
font-weight: bold;
text-align: center;
color: #e94560;
padding: 2rem;
animation: pulse 1s infinite;
}
.timer-countdown.hidden {
display: none;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.fullscreen-btn {
margin-top: 0.5rem;
padding: 0.5rem 1rem;
background: #0f3460;
color: white;
border: 1px solid #1a5276;
border-radius: 4px;
cursor: pointer;
}
.fullscreen-btn:hover {
background: #1a5276;
}
.fullscreen-btn.hidden {
display: none;
}
/* Bibliothèque de captures */
.capture-library {
background: #1a1a2e;
border-radius: 4px;
padding: 0.75rem;
margin-top: 0.75rem;
max-height: 120px;
overflow-y: auto;
}
.capture-library h5 {
margin-bottom: 0.5rem;
color: #94a3b8;
font-size: 0.8rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
#library-count {
font-size: 0.75rem;
color: #64748b;
}
#capture-library-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.library-item {
position: relative;
cursor: pointer;
border: 2px solid transparent;
border-radius: 4px;
transition: border-color 0.2s;
}
.library-item:hover {
border-color: #3498db;
}
.library-item.selected {
border-color: #e94560;
}
.library-item img {
width: 60px;
height: 45px;
object-fit: cover;
border-radius: 2px;
}
.library-item .delete-lib {
position: absolute;
top: -8px;
right: -8px;
width: 20px;
height: 20px;
background: #e74c3c;
color: white;
border: none;
border-radius: 50%;
cursor: pointer;
font-size: 12px;
line-height: 1;
display: none;
}
.library-item:hover .delete-lib {
display: block;
}
/* Modal plein écran */
.fullscreen-modal {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.95);
z-index: 1000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.fullscreen-modal.hidden {
display: none;
}
.fullscreen-modal .close-btn {
position: absolute;
top: 20px;
right: 20px;
padding: 0.75rem 1.5rem;
background: #e74c3c;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
z-index: 1001;
}
.fullscreen-modal .modal-content {
position: relative;
max-width: 95vw;
max-height: 90vh;
}
.fullscreen-modal img {
max-width: 100%;
max-height: 85vh;
cursor: crosshair;
}
.fullscreen-modal #fullscreen-overlay {
position: absolute;
border: 3px dashed #e94560;
background: rgba(233, 69, 96, 0.3);
pointer-events: none;
display: none;
}
.fullscreen-modal .instructions {
color: #94a3b8;
margin-top: 1rem;
text-align: center;
}
/* Panneau étape sélectionnée */
#selected-step {
font-size: 0.875rem;
}
.step-details h4 {
margin-bottom: 0.75rem;
color: #3498db;
}
.step-details p {
margin-bottom: 0.5rem;
}
.params-form {
margin-top: 1rem;
}
.params-form label {
display: block;
margin-bottom: 0.75rem;
}
.params-form input {
width: 100%;
padding: 0.5rem;
margin-top: 0.25rem;
background: #1a1a2e;
border: 1px solid #0f3460;
border-radius: 4px;
color: #eee;
}
.params-form button {
padding: 0.5rem 1rem;
background: #3498db;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.anchor-info {
margin-top: 1rem;
padding: 0.75rem;
background: #1a1a2e;
border-radius: 4px;
}
.anchor-info h5 {
margin-bottom: 0.5rem;
color: #27ae60;
}
.anchor-thumb {
max-width: 100%;
border-radius: 4px;
margin: 0.5rem 0;
}
.need-anchor {
margin-top: 1rem;
padding: 0.75rem;
background: rgba(231, 76, 60, 0.2);
border: 1px solid #e74c3c;
border-radius: 4px;
}
.warning {
color: #e74c3c;
margin-bottom: 0.5rem;
}
/* Panneau exécution */
#execution-status {
font-size: 0.875rem;
}
.exec-info {
margin-bottom: 1rem;
}
.status {
font-weight: 600;
margin-bottom: 0.5rem;
}
.status-running { color: #3498db; }
.status-paused { color: #f39c12; }
.status-completed { color: #27ae60; }
.status-error { color: #e74c3c; }
.status-cancelled { color: #95a5a6; }
.progress-bar {
height: 8px;
background: #1a1a2e;
border-radius: 4px;
overflow: hidden;
margin: 0.5rem 0;
}
.progress {
height: 100%;
background: #3498db;
transition: width 0.3s;
}
.progress-text {
font-size: 0.75rem;
color: #94a3b8;
}
.exec-buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.exec-buttons button {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
}
.exec-buttons .primary {
background: #27ae60;
color: white;
}
.exec-buttons .danger {
background: #e74c3c;
color: white;
}
/* Utilitaires */
.empty {
color: #94a3b8;
font-style: italic;
text-align: center;
padding: 1rem;
}
.error {
color: #e74c3c;
font-size: 0.875rem;
}

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"isolatedModules": true,
"skipLibCheck": true,
"esModuleInterop": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
"include": ["src/**/*"]
}

View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
export default defineConfig({
server: {
port: 3001,
proxy: {
'/api': {
target: 'http://localhost:5001',
changeOrigin: true
}
}
}
})