Python 64.6%
TypeScript 33.7%
CSS 0.8%
1import { useEffect, useMemo, useRef, useState } from 'react';2import { Check, Copy, Download, Loader2, Play, RotateCcw, Send } from 'lucide-react';3import type { ToolCallView } from '@/lib/types';4import { api } from '@/lib/api';5import { fmtDuration } from '@/lib/format';6import { cn } from '@/lib/cn';7import { useUI } from '@/stores/ui';8import { ArtifactChips } from './artifact-chips';910interface RunOut { stdout?: string; stderr?: string; exit_code?: number; duration_ms?: number; artifacts?: { file_id: string; filename: string; type: string }[] }11type Tab = 'code' | 'output' | 'figures';1213let hlPromise: Promise<(code: string) => string> | null = null;14function highlighter() {15 if (!hlPromise) {16 hlPromise = (async () => {17 const [{ createHighlighterCore }, { createJavaScriptRegexEngine }] = await Promise.all([import('shiki/core'), import('shiki/engine/javascript')]);18 const hl = await createHighlighterCore({ themes: [import('shiki/themes/github-dark.mjs')], langs: [import('shiki/langs/python.mjs')], engine: createJavaScriptRegexEngine() });19 return (code: string) => hl.codeToHtml(code, { lang: 'python', theme: 'github-dark' });20 })();21 }22 return hlPromise;23}2425export function PythonExecCard({ call, conversationId }: { call: ToolCallView; conversationId: string }) {26 const p = call.payload as { code?: string; description?: string; stdout?: string; stderr?: string; exit_code?: number; duration_ms?: number; artifacts?: ToolCallView['artifacts'] };27 const initialArtifacts = (p.artifacts || call.artifacts || []) as NonNullable<ToolCallView['artifacts']>;28 const [tab, setTab] = useState<Tab>(initialArtifacts.some((a) => a.type === 'image') ? 'figures' : p.stdout || p.stderr ? 'output' : 'code');29 const [code, setCode] = useState(p.code || '');30 const [editing, setEditing] = useState(false);31 const [html, setHtml] = useState('');32 const [running, setRunning] = useState(false);33 const [out, setOut] = useState<RunOut>({ stdout: p.stdout, stderr: p.stderr, exit_code: p.exit_code, duration_ms: p.duration_ms, artifacts: initialArtifacts.filter((a) => a.file_id).map((a) => ({ file_id: a.file_id!, filename: a.filename || '', type: a.type })) });34 const [copied, setCopied] = useState(false);35 const taRef = useRef<HTMLTextAreaElement>(null);36 const setDraft = useUI((s) => s.setDraft);37 const dirty = code !== (p.code || '');3839 useEffect(() => {40 let alive = true;41 highlighter().then((f) => alive && setHtml(f(code))).catch(() => undefined);42 return () => { alive = false; };43 }, [code]);4445 const lines = useMemo(() => code.split('\n').length, [code]);46 const images = (out.artifacts || []).filter((a) => a.type === 'image');47 const others = (out.artifacts || []).filter((a) => a.type !== 'image');4849 const run = async () => {50 setRunning(true);51 try {52 const r = await api<RunOut>('/tools/python/run', { method: 'POST', body: JSON.stringify({ code, conversation_id: conversationId }) });53 setOut(r);54 setTab(r.artifacts?.some((a) => a.type === 'image') ? 'figures' : 'output');55 } catch (e) {56 setOut({ stderr: e instanceof Error ? e.message : 'Erreur', exit_code: 1 });57 setTab('output');58 } finally {59 setRunning(false);60 }61 };62 const copy = async () => { await navigator.clipboard.writeText(code); setCopied(true); setTimeout(() => setCopied(false), 1200); };63 const download = () => {64 const blob = new Blob([code], { type: 'text/x-python' });65 const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'calcul.py'; a.click(); URL.revokeObjectURL(a.href);66 };67 const askTutor = () => setDraft(`À propos du code Python ci-dessus (${p.description || 'calcul'}) : `);6869 const TabBtn = ({ id, label, count }: { id: Tab; label: string; count?: number }) => (70 <button onClick={() => setTab(id)} className={cn('h-9 px-3 rounded-lg text-sm font-medium inline-flex items-center gap-1.5', tab === id ? 'bg-white text-uqo-blue-dark shadow-card' : 'text-neutral-muted hover:text-neutral-text')} role="tab" aria-selected={tab === id}>71 {label}{count ? <span className="text-[10px] rounded-full bg-uqo-blue-light text-uqo-blue px-1.5">{count}</span> : null}72 </button>73 );7475 return (76 <div className="space-y-2">77 {p.description && <p className="text-sm text-neutral-text">{p.description}</p>}78 <div className="flex items-center justify-between gap-2 flex-wrap">79 <div className="inline-flex gap-1 rounded-xl bg-neutral-surface p-1" role="tablist">80 <TabBtn id="code" label="Code" />81 <TabBtn id="output" label="Sortie" />82 <TabBtn id="figures" label="Graphiques" count={images.length} />83 </div>84 <div className="flex items-center gap-1">85 <button onClick={() => setEditing((v) => !v)} className={cn('h-9 px-2.5 rounded-lg text-xs font-medium inline-flex items-center gap-1 border', editing ? 'bg-uqo-blue text-white border-uqo-blue' : 'border-neutral-line text-neutral-text hover:bg-neutral-surface')}>86 {editing ? 'Terminer' : 'Modifier'}87 </button>88 {dirty && <button onClick={() => setCode(p.code || '')} className="h-9 w-9 inline-flex items-center justify-center rounded-lg border border-neutral-line text-neutral-muted hover:bg-neutral-surface" title="Rétablir le code original"><RotateCcw size={14} /></button>}89 <button onClick={run} disabled={running} className="h-9 px-3 rounded-lg text-xs font-semibold inline-flex items-center gap-1.5 bg-uqo-green text-white hover:bg-[#67a51b] disabled:opacity-60">90 {running ? <Loader2 size={14} className="animate-spin" /> : <Play size={14} />} {running ? 'Exécution…' : dirty ? 'Exécuter ma version' : 'Ré-exécuter'}91 </button>92 </div>93 </div>9495 {tab === 'code' && (96 <div className="rounded-xl overflow-hidden border border-[#1f2d3d] bg-[#0f1b2a]">97 <div className="flex items-center justify-between px-3 py-1.5 bg-[#16233a] text-[#b7c4d1] text-xs">98 <span className="font-mono">python · {lines} lignes{dirty ? ' · modifié' : ''}</span>99 <div className="flex gap-1">100 <button onClick={copy} className="h-8 px-2 rounded-md hover:bg-white/10 inline-flex items-center gap-1">{copied ? <Check size={13} className="text-uqo-green" /> : <Copy size={13} />} Copier</button>101 <button onClick={download} className="h-8 px-2 rounded-md hover:bg-white/10 inline-flex items-center gap-1"><Download size={13} /> .py</button>102 <button onClick={askTutor} className="h-8 px-2 rounded-md hover:bg-white/10 inline-flex items-center gap-1 text-uqo-green"><Send size={13} /> Demander au tuteur</button>103 </div>104 </div>105 {editing ? (106 <textarea ref={taRef} value={code} onChange={(e) => setCode(e.target.value)} spellCheck={false}107 onKeyDown={(e) => { if (e.key === 'Tab') { e.preventDefault(); const t = e.currentTarget; const s = t.selectionStart; setCode(code.slice(0, s) + ' ' + code.slice(t.selectionEnd)); requestAnimationFrame(() => t.setSelectionRange(s + 4, s + 4)); } }}108 className="w-full min-h-[220px] max-h-[520px] bg-[#0f1b2a] text-[#e6edf3] font-mono text-[13px] leading-relaxed p-3 outline-none resize-y scroll-thin" />109 ) : html ? (110 <div className="overflow-auto max-h-[520px] scroll-thin text-[13px] leading-relaxed [&_pre]:!bg-transparent [&_pre]:p-3 [&_pre]:m-0" dangerouslySetInnerHTML={{ __html: html }} />111 ) : (112 <pre className="p-3 m-0 text-[#e6edf3] font-mono text-[13px] overflow-auto max-h-[520px] scroll-thin"><code>{code}</code></pre>113 )}114 </div>115 )}116117 {tab === 'output' && (118 <div className="rounded-xl border border-neutral-line bg-[#0b1522] text-[#d5dde5] font-mono text-xs">119 <div className="flex items-center justify-between px-3 py-1.5 border-b border-[#1f2d3d] text-[#8aa0b5]">120 <span>{out.exit_code === 0 || out.exit_code === undefined ? '✓ terminé' : `✗ code de sortie ${out.exit_code}`}</span>121 {out.duration_ms ? <span>{fmtDuration(out.duration_ms)}</span> : null}122 </div>123 <div className="p-3 max-h-[420px] overflow-auto scroll-thin">124 {out.stdout ? <pre className="whitespace-pre-wrap">{out.stdout}</pre> : !out.stderr && <span className="text-[#8aa0b5]">(aucune sortie texte)</span>}125 {out.stderr && <pre className="whitespace-pre-wrap text-[#ff9aa2] mt-2">{out.stderr}</pre>}126 </div>127 {others.length > 0 && <div className="p-3 border-t border-[#1f2d3d] bg-white"><ArtifactChips artifacts={others.map((a) => ({ ...a, preview: undefined }))} /></div>}128 </div>129 )}130131 {tab === 'figures' && (132 <div className="grid gap-3 sm:grid-cols-2">133 {images.length === 0 && <p className="text-sm text-neutral-muted">Aucun graphique produit par ce code.</p>}134 {images.map((a) => (135 <figure key={a.file_id} className="rounded-xl border border-neutral-line overflow-hidden bg-white sm:col-span-2">136 <img src={`/api/v1/files/${a.file_id}`} alt={a.filename || 'graphique'} className="w-full max-h-[480px] object-contain" loading="lazy" />137 <figcaption className="flex items-center justify-between px-3 py-2 text-xs text-neutral-muted border-t border-neutral-line">138 <span className="truncate">{a.filename}</span>139 <a href={`/api/v1/files/${a.file_id}?download=1`} className="inline-flex items-center gap-1 text-uqo-blue"><Download size={13} /> PNG</a>140 </figcaption>141 </figure>142 ))}143 </div>144 )}145 </div>146 );147}148