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%
5.7 KB · 111 lines tsx
Raw Blame History
1import { useEffect, useState } from 'react';2import { Check, Copy, Pencil, Play, Square } from 'lucide-react';3import { api } from '@/lib/api';4import { fmtDuration } from '@/lib/format';5import { Button } from '@/components/ui/button';67let highlighterPromise: Promise<{ codeToHtml: (code: string, o: { lang: string; theme: string }) => string }> | null = null;8/** Fine-grained shiki bundle: only the languages students actually meet (keeps the PWA small). */9async function getHighlighter() {10  if (!highlighterPromise) {11    highlighterPromise = (async () => {12      const [{ createHighlighterCore }, { createJavaScriptRegexEngine }] = await Promise.all([13        import('shiki/core'),14        import('shiki/engine/javascript'),15      ]);16      const hl = await createHighlighterCore({17        themes: [import('shiki/themes/github-dark.mjs')],18        langs: [import('shiki/langs/python.mjs'), import('shiki/langs/json.mjs'), import('shiki/langs/bash.mjs'), import('shiki/langs/sql.mjs'), import('shiki/langs/javascript.mjs')],19        engine: createJavaScriptRegexEngine(),20      });21      const loaded = new Set(hl.getLoadedLanguages());22      return { codeToHtml: (code: string, o: { lang: string; theme: string }) => hl.codeToHtml(code, { lang: loaded.has(o.lang) ? o.lang : 'text', theme: o.theme }) };23    })();24  }25  return highlighterPromise;26}2728interface RunOut { stdout?: string; stderr?: string; exit_code?: number; duration_ms?: number; artifacts?: { file_id: string; filename: string; type: string }[] }2930export function CodeBlock({ code, lang, conversationId, streaming }: { code: string; lang: string; conversationId?: string; streaming?: boolean }) {31  const [html, setHtml] = useState<string>('');32  const [copied, setCopied] = useState(false);33  const [editing, setEditing] = useState(false);34  const [draft, setDraft] = useState(code);35  const [running, setRunning] = useState(false);36  const [out, setOut] = useState<RunOut | null>(null);37  const runnable = lang === 'python' || lang === 'py';3839  useEffect(() => {40    if (streaming) return;41    let alive = true;42    getHighlighter().then((h) => alive && setHtml(h.codeToHtml(editing ? draft : code, { lang: lang || 'text', theme: 'github-dark' }))).catch(() => undefined);43    return () => {44      alive = false;45    };46  }, [code, draft, editing, lang, streaming]);4748  const copy = async () => {49    await navigator.clipboard.writeText(editing ? draft : code);50    setCopied(true);51    setTimeout(() => setCopied(false), 1200);52  };5354  const run = async () => {55    setRunning(true);56    setOut(null);57    try {58      const r = await api<RunOut>('/tools/python/run', { method: 'POST', body: JSON.stringify({ code: editing ? draft : code, conversation_id: conversationId }) });59      setOut(r);60    } catch (e) {61      setOut({ stderr: e instanceof Error ? e.message : 'Erreur', exit_code: 1 });62    } finally {63      setRunning(false);64    }65  };6667  return (68    <div className="my-2 rounded-lg overflow-hidden border border-[#1f2d3d] bg-[#0f1b2a] text-[13px]">69      <div className="flex items-center justify-between px-3 py-1.5 bg-[#16233a] text-[#b7c4d1]">70        <span className="font-mono text-xs uppercase tracking-wide">{lang || 'code'}</span>71        <div className="flex items-center gap-1">72          {runnable && !streaming && (73            <>74              <button onClick={() => setEditing((v) => !v)} className="h-8 px-2 rounded-md hover:bg-white/10 inline-flex items-center gap-1" title="Modifier">75                <Pencil size={14} /> <span className="hidden sm:inline">Modifier</span>76              </button>77              <button onClick={run} disabled={running} className="h-8 px-2 rounded-md hover:bg-white/10 inline-flex items-center gap-1 text-uqo-green" title="Exécuter">78                {running ? <Square size={14} className="animate-pulse" /> : <Play size={14} />} <span className="hidden sm:inline">Exécuter</span>79              </button>80            </>81          )}82          <button onClick={copy} className="h-8 px-2 rounded-md hover:bg-white/10 inline-flex items-center gap-1" title="Copier">83            {copied ? <Check size={14} className="text-uqo-green" /> : <Copy size={14} />} <span className="hidden sm:inline">{copied ? 'Copié' : 'Copier'}</span>84          </button>85        </div>86      </div>87      {editing ? (88        <textarea value={draft} onChange={(e) => setDraft(e.target.value)} spellCheck={false}89          className="w-full min-h-[160px] bg-[#0f1b2a] text-[#e6edf3] font-mono text-[13px] p-3 outline-none resize-y" />90      ) : html ? (91        <div className="overflow-x-auto scroll-thin [&_pre]:!bg-transparent [&_pre]:p-3 [&_pre]:m-0" dangerouslySetInnerHTML={{ __html: html }} />92      ) : (93        <pre className="p-3 m-0 text-[#e6edf3] font-mono overflow-x-auto scroll-thin"><code>{code}</code></pre>94      )}95      {out && (96        <div className="border-t border-[#1f2d3d] bg-[#0b1522] p-3 font-mono text-xs text-[#d5dde5] space-y-2">97          <div className="text-[#8aa0b5]">Sortie {out.exit_code === 0 ? '✓' : `(code ${out.exit_code})`} {out.duration_ms ? `· ${fmtDuration(out.duration_ms)}` : ''}</div>98          {out.stdout && <pre className="whitespace-pre-wrap">{out.stdout}</pre>}99          {out.stderr && <pre className="whitespace-pre-wrap text-[#ff9aa2]">{out.stderr}</pre>}100          {out.artifacts?.filter((a) => a.type === 'image').map((a) => (101            <img key={a.file_id} src={`/api/v1/files/${a.file_id}`} alt={a.filename} className="rounded-md max-h-[360px] bg-white" />102          ))}103          {out.artifacts?.filter((a) => a.type !== 'image').map((a) => (104            <Button key={a.file_id} size="sm" variant="secondary" onClick={() => window.open(`/api/v1/files/${a.file_id}?download=1`, '_blank')}>{a.filename}</Button>105          ))}106        </div>107      )}108    </div>109  );110}111