SPB Git forge

spb/uqo-chat

Public
14commits 1branches 0releases
1.4 MBsize
maindefault branch
17 days agolast push
Python 64.6% TypeScript 33.7% CSS 0.8%
9.5 KB · 148 lines tsx
Raw Blame History
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