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>
This commit is contained in:
@@ -1,10 +1,94 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import type { Step, ActionType } from '../types';
|
||||
import { ACTIONS } from '../types';
|
||||
import { getAnchorThumbnailUrl } from '../services/api';
|
||||
import { getAnchorThumbnailUrl, getAnchorOriginalUrl } from '../services/api';
|
||||
import AIModelSelector from './AIModelSelector';
|
||||
import type { AITaskType } from '../services/ollamaService';
|
||||
|
||||
/**
|
||||
* Aperçu de la miniature d'une ancre avec tooltip d'agrandissement au survol.
|
||||
* - Affiche la miniature compressée (80×50)
|
||||
* - Au hover : tooltip flottant avec l'image originale (max 600px de large)
|
||||
* - Positionnement intelligent (à droite si place, sinon à gauche, idem haut/bas)
|
||||
* - Fallback sur thumbnail si l'image originale n'est pas accessible (onError)
|
||||
*/
|
||||
function AnchorPreview({ anchorId }: { anchorId: string }) {
|
||||
const [hover, setHover] = useState(false);
|
||||
const [tooltipStyle, setTooltipStyle] = useState<React.CSSProperties>({});
|
||||
const [largeSrc, setLargeSrc] = useState<string>(getAnchorOriginalUrl(anchorId));
|
||||
const thumbRef = useRef<HTMLImageElement | null>(null);
|
||||
|
||||
// Recalcule la position du tooltip à chaque entrée souris pour éviter les débordements.
|
||||
const handleEnter = () => {
|
||||
const el = thumbRef.current;
|
||||
if (!el) {
|
||||
setHover(true);
|
||||
return;
|
||||
}
|
||||
const rect = el.getBoundingClientRect();
|
||||
const TOOLTIP_W = 620; // 600 image + ~20 padding/borders
|
||||
const TOOLTIP_H = 480;
|
||||
const MARGIN = 12;
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
// Horizontal : à droite de la miniature si possible, sinon à gauche
|
||||
let left = rect.right + MARGIN;
|
||||
if (left + TOOLTIP_W > vw - MARGIN) {
|
||||
left = rect.left - TOOLTIP_W - MARGIN;
|
||||
}
|
||||
if (left < MARGIN) {
|
||||
left = MARGIN; // dernier recours : collé à gauche de la fenêtre
|
||||
}
|
||||
|
||||
// Vertical : aligné sur le haut de la miniature, mais clampé dans la fenêtre
|
||||
let top = rect.top;
|
||||
if (top + TOOLTIP_H > vh - MARGIN) {
|
||||
top = vh - TOOLTIP_H - MARGIN;
|
||||
}
|
||||
if (top < MARGIN) {
|
||||
top = MARGIN;
|
||||
}
|
||||
|
||||
setTooltipStyle({ left: `${left}px`, top: `${top}px` });
|
||||
setHover(true);
|
||||
};
|
||||
|
||||
const handleLeave = () => setHover(false);
|
||||
|
||||
// Si l'image originale 404 (anciennes ancres sans fichier original), fallback sur thumbnail
|
||||
const handleLargeError = () => {
|
||||
if (largeSrc !== getAnchorThumbnailUrl(anchorId)) {
|
||||
setLargeSrc(getAnchorThumbnailUrl(anchorId));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
ref={thumbRef}
|
||||
src={getAnchorThumbnailUrl(anchorId)}
|
||||
alt="Ancre"
|
||||
className="anchor-thumb-zoomable"
|
||||
onMouseEnter={handleEnter}
|
||||
onMouseLeave={handleLeave}
|
||||
/>
|
||||
{hover && (
|
||||
<div className="anchor-tooltip" style={tooltipStyle} role="tooltip">
|
||||
<img
|
||||
src={largeSrc}
|
||||
alt="Ancre (taille originale)"
|
||||
onError={handleLargeError}
|
||||
/>
|
||||
<div className="anchor-tooltip-caption">
|
||||
Ancre {anchorId.slice(0, 12)}…
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
step: Step | null;
|
||||
onUpdateParams: (id: string, params: Record<string, unknown>) => void;
|
||||
@@ -294,6 +378,64 @@ export default function PropertiesPanel({ step, onUpdateParams, onDelete }: Prop
|
||||
</>
|
||||
);
|
||||
|
||||
case 'extract_text_scroll':
|
||||
return (
|
||||
<>
|
||||
<div className="prop-field">
|
||||
<label>Nom de la variable</label>
|
||||
<input
|
||||
type="text"
|
||||
value={String(params.variable_name || '')}
|
||||
onChange={(e) => updateParam('variable_name', e.target.value)}
|
||||
placeholder="texte_dpi_complet"
|
||||
/>
|
||||
<small style={{ color: '#666', display: 'block', marginTop: 4 }}>
|
||||
Concatène le texte haut + bas de page (Ctrl+End / Ctrl+Home automatiques).
|
||||
</small>
|
||||
</div>
|
||||
<div className="prop-field">
|
||||
<label>Méthode d'extraction</label>
|
||||
<select
|
||||
value="ocr"
|
||||
disabled
|
||||
onChange={() => { /* readonly v1 */ }}
|
||||
>
|
||||
<option value="ocr">OCR (image)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="prop-field">
|
||||
<label>Pause après scroll (ms)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={100}
|
||||
value={
|
||||
params.scroll_pause_ms !== undefined && params.scroll_pause_ms !== null && params.scroll_pause_ms !== ''
|
||||
? Number(params.scroll_pause_ms)
|
||||
: ''
|
||||
}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
updateParam('scroll_pause_ms', v === '' ? undefined : Number(v));
|
||||
}}
|
||||
placeholder="500"
|
||||
/>
|
||||
<small style={{ color: '#666', display: 'block', marginTop: 4 }}>
|
||||
Délai après Ctrl+End avant la 2ᵉ capture. Défaut 500ms (Wikipedia). DPI Citrix lent : 1500-2000ms.
|
||||
</small>
|
||||
</div>
|
||||
<div className="prop-field">
|
||||
<label>Regex de filtrage (optionnel)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={String(params.regex_filter || '')}
|
||||
onChange={(e) => updateParam('regex_filter', e.target.value)}
|
||||
placeholder="Ex: \d{4}-\d{2}-\d{2}"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'extract_table':
|
||||
return (
|
||||
<>
|
||||
@@ -307,25 +449,24 @@ export default function PropertiesPanel({ step, onUpdateParams, onDelete }: Prop
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-field">
|
||||
<label>Format de sortie</label>
|
||||
<select
|
||||
value={String(params.output_format || 'json')}
|
||||
onChange={(e) => updateParam('output_format', e.target.value)}
|
||||
>
|
||||
<option value="json">JSON</option>
|
||||
<option value="csv">CSV</option>
|
||||
<option value="array">Array</option>
|
||||
</select>
|
||||
<label>Filtre regex (optionnel)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={String(params.pattern || '')}
|
||||
onChange={(e) => updateParam('pattern', e.target.value)}
|
||||
placeholder="ex: ^25\d{6}$ (IPP) ou ^[A-Z][a-z]+$ (noms)"
|
||||
/>
|
||||
<small className="field-hint">Regex Python — seuls les tokens OCR qui matchent sont retournés. Vide = tous les tokens.</small>
|
||||
</div>
|
||||
<div className="prop-field checkbox">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(params.include_headers)}
|
||||
onChange={(e) => updateParam('include_headers', e.target.checked)}
|
||||
/>
|
||||
Inclure les en-têtes
|
||||
</label>
|
||||
<div className="prop-field">
|
||||
<label>Nombre max d'entrées (optionnel)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={params.limit !== undefined && params.limit !== null && params.limit !== '' ? Number(params.limit) : ''}
|
||||
onChange={(e) => updateParam('limit', e.target.value === '' ? '' : Number(e.target.value))}
|
||||
placeholder="20"
|
||||
/>
|
||||
<small className="field-hint">0 ou vide = sans limite.</small>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -952,6 +1093,15 @@ export default function PropertiesPanel({ step, onUpdateParams, onDelete }: Prop
|
||||
/>
|
||||
<small className="field-hint">Utilisez {'${step_id.result}'} pour injecter le résultat d'une étape précédente</small>
|
||||
</div>
|
||||
<div className="prop-field">
|
||||
<label>Variable de sortie</label>
|
||||
<input
|
||||
type="text"
|
||||
value={String(params.output_var || '')}
|
||||
onChange={(e) => updateParam('output_var', e.target.value)}
|
||||
placeholder="resume_patient"
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-field">
|
||||
<label>Modèle Ollama (optionnel)</label>
|
||||
<input
|
||||
@@ -1510,7 +1660,7 @@ export default function PropertiesPanel({ step, onUpdateParams, onDelete }: Prop
|
||||
<label>Ancre visuelle (obligatoire)</label>
|
||||
{step.anchor_id ? (
|
||||
<div className="anchor-preview">
|
||||
<img src={getAnchorThumbnailUrl(step.anchor_id)} alt="Ancre" />
|
||||
<AnchorPreview anchorId={step.anchor_id} />
|
||||
<span className="anchor-ok">✓ Définie</span>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1526,7 +1676,7 @@ export default function PropertiesPanel({ step, onUpdateParams, onDelete }: Prop
|
||||
<div className="prop-anchor">
|
||||
<label>Ancre visuelle (optionnelle)</label>
|
||||
<div className="anchor-preview">
|
||||
<img src={getAnchorThumbnailUrl(step.anchor_id)} alt="Ancre" />
|
||||
<AnchorPreview anchorId={step.anchor_id} />
|
||||
<span className="anchor-ok">✓ Définie</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -101,6 +101,11 @@ function StepNode({ data, selected }: StepNodeProps) {
|
||||
/>
|
||||
|
||||
<div className="step-node-header">
|
||||
{typeof step.order === 'number' && Number.isFinite(step.order) && (
|
||||
<span className="step-node-order" title={`Étape ${step.order + 1}`}>
|
||||
{step.order + 1}
|
||||
</span>
|
||||
)}
|
||||
<span className="step-icon">{action?.icon || '?'}</span>
|
||||
<span className="step-label">{action?.label || step.action_type}</span>
|
||||
</div>
|
||||
|
||||
@@ -57,8 +57,8 @@ function extractWorkflowVariables(steps: Step[]): WorkflowVariable[] {
|
||||
for (const step of steps) {
|
||||
const p = step.parameters || {};
|
||||
|
||||
// Producteurs : output_variable ou variable_name
|
||||
const outVar = (p.output_variable || p.variable_name) as string | undefined;
|
||||
// Producteurs : output_variable, output_var ou variable_name
|
||||
const outVar = (p.output_variable || p.output_var || p.variable_name) as string | undefined;
|
||||
if (outVar && typeof outVar === 'string') {
|
||||
const existing = vars.get(outVar);
|
||||
if (existing) {
|
||||
@@ -71,7 +71,7 @@ function extractWorkflowVariables(steps: Step[]): WorkflowVariable[] {
|
||||
// Consommateurs : chercher {{var}} dans toutes les valeurs string des params
|
||||
for (const val of Object.values(p)) {
|
||||
if (typeof val === 'string') {
|
||||
for (const match of val.matchAll(/\{\{(\w+)\}\}/g)) {
|
||||
for (const match of val.matchAll(/\{\{(\w+)(?:\.[^}]*)?\}\}/g)) {
|
||||
const varName = match[1];
|
||||
const existing = vars.get(varName);
|
||||
if (existing) {
|
||||
|
||||
@@ -137,6 +137,15 @@ export function getAnchorThumbnailUrl(anchorId: string): string {
|
||||
return `${API_BASE}/anchor/${anchorId}/thumbnail`;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL de l'image originale (non compressée) d'une ancre.
|
||||
* Endpoint Flask : GET /api/anchor-images/{anchor_id}/original
|
||||
* (cf. visual_workflow_builder/backend/api/anchor_images.py)
|
||||
*/
|
||||
export function getAnchorOriginalUrl(anchorId: string): string {
|
||||
return `/api/anchor-images/${anchorId}/original`;
|
||||
}
|
||||
|
||||
// Execution
|
||||
export async function startExecution(
|
||||
workflowId?: string,
|
||||
@@ -360,12 +369,18 @@ export async function exportForLea(
|
||||
}
|
||||
|
||||
// Bibliothèque de captures — persistance serveur
|
||||
//
|
||||
// Format v2 (avril 2026) : le PNG HD vit sur disque côté backend, le JSON ne
|
||||
// contient que les métadonnées + un thumbnail JPEG 640x360 q85 pour la grille.
|
||||
// L'affichage plein écran utilise `fullImageUrl` (PNG HD).
|
||||
export interface CaptureLibraryItem {
|
||||
id: string;
|
||||
capture: Capture;
|
||||
timestamp: string;
|
||||
sessionId?: string;
|
||||
favorite?: boolean;
|
||||
format?: 'v2'; // v2 = PNG HD côté backend
|
||||
fullImageUrl?: string; // chemin GET vers le PNG HD (v2 uniquement)
|
||||
}
|
||||
|
||||
export async function getCaptureLibrary(): Promise<{
|
||||
@@ -379,3 +394,42 @@ export async function saveCaptureLibrary(library: CaptureLibraryItem[]): Promise
|
||||
}> {
|
||||
return request('POST', '/capture/library', { library });
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload une capture HD (PNG) dans la bibliothèque.
|
||||
* Le serveur stocke le PNG sur disque, génère le thumbnail 640x360 et retourne
|
||||
* l'item complet avec `fullImageUrl` pointant vers le PNG.
|
||||
*/
|
||||
export async function uploadCaptureLibraryItem(params: {
|
||||
pngBlob: Blob;
|
||||
id?: string;
|
||||
sessionId?: string;
|
||||
timestamp?: string;
|
||||
favorite?: boolean;
|
||||
}): Promise<{ item: CaptureLibraryItem }> {
|
||||
const form = new FormData();
|
||||
form.append('file', params.pngBlob, `${params.id || 'capture'}.png`);
|
||||
if (params.id) form.append('id', params.id);
|
||||
if (params.sessionId) form.append('sessionId', params.sessionId);
|
||||
if (params.timestamp) form.append('timestamp', params.timestamp);
|
||||
if (params.favorite !== undefined) form.append('favorite', String(params.favorite));
|
||||
|
||||
const response = await fetch(`${API_BASE}/capture/library/upload`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || "Échec upload capture");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteCaptureLibraryItem(id: string): Promise<{ id: string }> {
|
||||
const response = await fetch(`${API_BASE}/capture/library/${id}`, { method: 'DELETE' });
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || "Échec suppression capture");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,12 @@
|
||||
*/
|
||||
|
||||
import type { Capture } from '../types';
|
||||
import { getCaptureLibrary, saveCaptureLibrary } from './api';
|
||||
import {
|
||||
getCaptureLibrary,
|
||||
saveCaptureLibrary,
|
||||
uploadCaptureLibraryItem,
|
||||
deleteCaptureLibraryItem,
|
||||
} from './api';
|
||||
|
||||
export interface LibraryItem {
|
||||
id: string;
|
||||
@@ -31,6 +36,8 @@ export interface LibraryItem {
|
||||
timestamp: Date | string; // JSON.parse ne restaure pas les Date
|
||||
sessionId?: string;
|
||||
favorite?: boolean;
|
||||
format?: 'v2';
|
||||
fullImageUrl?: string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'captureLibrary_v2';
|
||||
@@ -177,7 +184,12 @@ export function saveLibrary(library: LibraryItem[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Sauvegarder sur le backend (fire-and-forget, pas de blocage)
|
||||
// Sauvegarder sur le backend (fire-and-forget, pas de blocage).
|
||||
// ATTENTION : pour les items v2, le backend a déjà reçu le PNG HD via
|
||||
// /capture/library/upload. Ici on ne fait que synchroniser les métadonnées
|
||||
// (timestamp, favorite, ordre, etc.). Les champs format/fullImageUrl doivent
|
||||
// être préservés pour que le backend reconnaisse les items v2 et n'aille pas
|
||||
// les purger.
|
||||
saveCaptureLibrary(library.map((item) => ({
|
||||
id: item.id,
|
||||
capture: item.capture,
|
||||
@@ -186,15 +198,118 @@ export function saveLibrary(library: LibraryItem[]): void {
|
||||
: String(item.timestamp),
|
||||
sessionId: item.sessionId,
|
||||
favorite: item.favorite,
|
||||
format: item.format,
|
||||
fullImageUrl: item.fullImageUrl,
|
||||
}))).catch((e) => {
|
||||
console.warn('[CaptureLibrary] Échec sauvegarde backend', e);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit une base64 PNG (sans préfixe data:) en Blob, pour upload multipart.
|
||||
*/
|
||||
function base64PngToBlob(base64Png: string): Blob {
|
||||
const raw = atob(base64Png);
|
||||
const len = raw.length;
|
||||
const bytes = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
bytes[i] = raw.charCodeAt(i);
|
||||
}
|
||||
return new Blob([bytes], { type: 'image/png' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute une capture HD à la bibliothèque (format v2).
|
||||
*
|
||||
* Upload le PNG HD au backend (source de vérité) et retourne un LibraryItem
|
||||
* avec `fullImageUrl` pour l'affichage plein écran. Le `capture.screenshot_base64`
|
||||
* du LibraryItem est REMPLACÉ par le thumbnail 640x360 q85 généré par le serveur,
|
||||
* de sorte que la grille reste légère sans perdre la qualité plein écran.
|
||||
*
|
||||
* Si l'upload échoue, on retombe sur l'ancien comportement (compression locale)
|
||||
* pour ne pas perdre la capture.
|
||||
*/
|
||||
export async function addCaptureToLibrary(
|
||||
capture: Capture,
|
||||
meta: { id?: string; sessionId?: string; favorite?: boolean } = {}
|
||||
): Promise<LibraryItem> {
|
||||
const id = meta.id || `cap_${Date.now()}`;
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
try {
|
||||
const blob = base64PngToBlob(capture.screenshot_base64);
|
||||
const { item } = await uploadCaptureLibraryItem({
|
||||
pngBlob: blob,
|
||||
id,
|
||||
sessionId: meta.sessionId,
|
||||
timestamp,
|
||||
favorite: meta.favorite,
|
||||
});
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
capture: {
|
||||
...capture,
|
||||
// La base64 stockée localement = thumbnail 640x360 q85 (pour la grille)
|
||||
// L'affichage plein écran passera par item.fullImageUrl
|
||||
screenshot_base64: item.capture.screenshot_base64 ?? capture.screenshot_base64,
|
||||
width: item.capture.width ?? capture.width,
|
||||
height: item.capture.height ?? capture.height,
|
||||
},
|
||||
timestamp,
|
||||
sessionId: meta.sessionId,
|
||||
favorite: meta.favorite ?? false,
|
||||
format: 'v2',
|
||||
fullImageUrl: item.fullImageUrl,
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn('[CaptureLibrary] Upload HD échoué, fallback compression locale', e);
|
||||
// Fallback : compression locale comme avant — entrée legacy v1, pas de plein écran HD
|
||||
const compressed = await compressThumbnail(capture.screenshot_base64);
|
||||
return {
|
||||
id,
|
||||
capture: { ...capture, screenshot_base64: compressed },
|
||||
timestamp,
|
||||
sessionId: meta.sessionId,
|
||||
favorite: meta.favorite ?? false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime une capture de la bibliothèque (PNG HD côté serveur si v2).
|
||||
*/
|
||||
export async function removeCaptureFromLibrary(id: string, isV2: boolean): Promise<void> {
|
||||
if (isV2) {
|
||||
try {
|
||||
await deleteCaptureLibraryItem(id);
|
||||
} catch (e) {
|
||||
console.warn('[CaptureLibrary] Échec suppression backend', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit l'URL plein écran pour un LibraryItem.
|
||||
* - Format v2 : pointe vers le PNG HD servi par le backend
|
||||
* - Format legacy v1 : retombe sur la base64 stockée (pixélisée)
|
||||
*/
|
||||
export function getLibraryItemFullSrc(item: LibraryItem): string {
|
||||
if (item.format === 'v2' && item.fullImageUrl) {
|
||||
return item.fullImageUrl;
|
||||
}
|
||||
// Legacy v1 : data URL base64
|
||||
const b64 = item.capture.screenshot_base64;
|
||||
return b64.startsWith('data:') ? b64 : `data:image/png;base64,${b64}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compresse une image base64 (PNG ou JPEG) en JPEG basse qualité pour la
|
||||
* bibliothèque. Retourne la base64 JPEG sans le préfixe data: URL.
|
||||
* Fallback : renvoie la base64 d'origine si la compression échoue.
|
||||
*
|
||||
* NOTE (avril 2026) : conservé pour le fallback de addCaptureToLibrary.
|
||||
* Pour les nouvelles captures, préférer addCaptureToLibrary qui upload le HD.
|
||||
*/
|
||||
export async function compressThumbnail(base64Png: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
|
||||
@@ -499,6 +499,25 @@ body {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
/* Badge numéro d'ordre du step (1-indexed) */
|
||||
.step-node-order {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
padding: 0 6px;
|
||||
border-radius: 11px;
|
||||
background: var(--primary);
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
/* Tooltip documentation outil */
|
||||
.step-node-tooltip {
|
||||
position: absolute;
|
||||
@@ -838,6 +857,56 @@ body {
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Miniature cliquable/zoomable : indique au survol qu'un agrandissement est dispo */
|
||||
.anchor-thumb-zoomable {
|
||||
cursor: zoom-in;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.anchor-thumb-zoomable:hover {
|
||||
border-color: var(--primary) !important;
|
||||
box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.25);
|
||||
}
|
||||
|
||||
/* Tooltip flottant agrandi (position absolue dans le viewport) */
|
||||
.anchor-tooltip {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
background: var(--bg-paper);
|
||||
border: 2px solid var(--primary);
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||
pointer-events: none; /* le hover reste sur la miniature, pas sur le tooltip */
|
||||
max-width: 620px;
|
||||
max-height: 480px;
|
||||
}
|
||||
|
||||
.anchor-tooltip img {
|
||||
display: block;
|
||||
max-width: 600px;
|
||||
max-height: 420px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-radius: 3px;
|
||||
/* damier pour distinguer le fond transparent éventuel d'une zone réelle blanche */
|
||||
background-image:
|
||||
linear-gradient(45deg, #eee 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #eee 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #eee 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #eee 75%);
|
||||
background-size: 12px 12px;
|
||||
background-position: 0 0, 0 6px, 6px -6px, -6px 0;
|
||||
}
|
||||
|
||||
.anchor-tooltip-caption {
|
||||
margin-top: 4px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.anchor-ok {
|
||||
color: var(--success);
|
||||
font-size: 0.85rem;
|
||||
|
||||
@@ -50,6 +50,7 @@ export type ActionType =
|
||||
| 'drag_drop_anchor'
|
||||
| 'keyboard_shortcut'
|
||||
| 'extract_text'
|
||||
| 'extract_text_scroll'
|
||||
| 'extract_table'
|
||||
| 'screenshot_evidence'
|
||||
| 'visual_condition'
|
||||
@@ -125,8 +126,14 @@ export const ACTIONS: ActionDefinition[] = [
|
||||
{ name: 'output_var', type: 'string', description: 'Nom de la variable de sortie (ex: texte_motif). Réutilisable via {{nom}}.' },
|
||||
{ name: 'paragraph', type: 'boolean', description: 'Regrouper en paragraphes (true) ou lignes brutes (false)' }
|
||||
] },
|
||||
{ type: 'extract_table', label: 'Extraire tableau', icon: '📊', description: 'Extrait un tableau structuré depuis la zone de l\'ancre.', category: 'data', needsAnchor: true, params: [
|
||||
{ name: 'variable_name', type: 'string', description: 'Nom de la variable pour stocker le tableau' }
|
||||
{ type: 'extract_text_scroll', label: 'Extraire texte (scroll auto)', icon: '📜', description: 'Capture haut + bas de page automatiquement (Ctrl+End / Ctrl+Home) puis OCR. Concatène le tout dans une seule variable. Idéal pour pages longues type DPI où le contenu est sous le scroll.', category: 'data', needsAnchor: false, params: [
|
||||
{ name: 'variable_name', type: 'string', description: 'Nom de la variable de sortie (ex: texte_dpi_complet). Contient haut + bas concaténés.' },
|
||||
{ name: 'scroll_pause_ms', type: 'number', description: 'Pause après Ctrl+End avant la 2e capture (ms). Défaut 500 (Wikipedia). DPI Citrix lent : 1500-2000.' }
|
||||
] },
|
||||
{ type: 'extract_table', label: 'Extraire tableau', icon: '📊', description: 'Extrait une liste structurée (top→bottom) depuis l\'écran via OCR + filtre regex. Retourne une liste de strings utilisable en boucle ou templating. Idéal pour récupérer une liste d\'IPP, de codes, de noms depuis un tableau.', category: 'data', needsAnchor: true, params: [
|
||||
{ name: 'variable_name', type: 'string', description: 'Nom de la variable pour stocker la liste extraite (réutilisable via {{nom}})' },
|
||||
{ name: 'pattern', type: 'string', description: 'Regex Python optionnelle pour filtrer les tokens OCR (ex: ^25\\d{6}$ pour des IPP). Vide = tous les tokens.' },
|
||||
{ name: 'limit', type: 'number', description: 'Nombre max d\'entrées retournées (0 ou vide = sans limite)' }
|
||||
] },
|
||||
{ type: 'screenshot_evidence', label: 'Capture preuve', icon: '📸', description: 'Prend une capture d\'écran comme preuve d\'exécution.', category: 'data', needsAnchor: false, params: [
|
||||
{ name: 'filename', type: 'string', description: 'Nom du fichier image de sortie' }
|
||||
@@ -233,6 +240,7 @@ export const ACTIONS: ActionDefinition[] = [
|
||||
{ type: 'llm_generate', label: 'Générer texte', icon: '✍️', description: 'Génère du texte libre à partir d\'un prompt et contexte.', category: 'llm', needsAnchor: false, params: [
|
||||
{ name: 'prompt', type: 'string', description: 'Prompt de génération' },
|
||||
{ name: 'context', type: 'string', description: 'Contexte additionnel' },
|
||||
{ name: 'output_var', type: 'string', description: 'Variable de sortie (ex: resume_patient)' },
|
||||
{ name: 'model', type: 'string', description: 'Modèle Ollama' }
|
||||
] },
|
||||
|
||||
|
||||
Reference in New Issue
Block a user