SPB Git

spb/uqo-chat

Public
8.1 KB · 162 lines tsx
1import { useEffect, useRef, useState, type ChangeEvent, type DragEvent, type KeyboardEvent } from 'react';2import { ArrowUp, Brain, Mic, MicOff, Paperclip, Square, X, FileText } from 'lucide-react';3import { api } from '@/lib/api';4import type { UploadedFile } from '@/lib/types';5import { cn } from '@/lib/cn';6import { fmtBytes } from '@/lib/format';78interface SpeechRecognitionLike { start(): void; stop(): void; lang: string; interimResults: boolean; continuous: boolean; onresult: ((e: { results: ArrayLike<ArrayLike<{ transcript: string }>> }) => void) | null; onend: (() => void) | null }9declare global { interface Window { webkitSpeechRecognition?: new () => SpeechRecognitionLike; SpeechRecognition?: new () => SpeechRecognitionLike } }1011export function Composer({ conversationId, streaming, onSend, onStop, deep, onToggleDeep, course, onCourseChange, courses, disabled }: {12  conversationId: string | null;13  streaming: boolean;14  onSend: (text: string, attachments: string[]) => void;15  onStop: () => void;16  deep: boolean;17  onToggleDeep: () => void;18  course: string;19  onCourseChange: (c: string) => void;20  courses: string[];21  disabled?: boolean;22}) {23  const [text, setText] = useState('');24  const [files, setFiles] = useState<UploadedFile[]>([]);25  const [uploading, setUploading] = useState(false);26  const [drag, setDrag] = useState(false);27  const [error, setError] = useState<string | null>(null);28  const [listening, setListening] = useState(false);29  const taRef = useRef<HTMLTextAreaElement>(null);30  const fileRef = useRef<HTMLInputElement>(null);31  const recRef = useRef<SpeechRecognitionLike | null>(null);32  const speechOk = typeof window !== 'undefined' && !!(window.SpeechRecognition || window.webkitSpeechRecognition);3334  useEffect(() => {35    const ta = taRef.current;36    if (!ta) return;37    ta.style.height = 'auto';38    ta.style.height = Math.min(ta.scrollHeight, 200) + 'px';39  }, [text]);4041  const upload = async (list: FileList | File[]) => {42    setError(null);43    setUploading(true);44    try {45      for (const f of Array.from(list)) {46        const fd = new FormData();47        fd.append('file', f);48        if (conversationId) fd.append('conversation_id', conversationId);49        const up = await api<UploadedFile>('/files', { method: 'POST', body: fd });50        setFiles((prev) => [...prev, up]);51      }52    } catch (e) {53      setError(e instanceof Error ? e.message : 'Téléversement impossible.');54    } finally {55      setUploading(false);56    }57  };5859  const send = () => {60    const t = text.trim();61    if (!t || streaming || disabled) return;62    onSend(t, files.map((f) => f.file_id));63    setText('');64    setFiles([]);65  };6667  const onKey = (e: KeyboardEvent<HTMLTextAreaElement>) => {68    if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {69      e.preventDefault();70      send();71    }72  };7374  const onDrop = (e: DragEvent) => {75    e.preventDefault();76    setDrag(false);77    if (e.dataTransfer.files?.length) void upload(e.dataTransfer.files);78  };7980  const toggleMic = () => {81    if (listening) {82      recRef.current?.stop();83      setListening(false);84      return;85    }86    const Ctor = window.SpeechRecognition || window.webkitSpeechRecognition;87    if (!Ctor) return;88    const rec = new Ctor();89    rec.lang = 'fr-CA';90    rec.interimResults = false;91    rec.continuous = true;92    rec.onresult = (ev) => {93      const parts: string[] = [];94      for (let i = 0; i < ev.results.length; i++) parts.push(ev.results[i][0].transcript);95      setText((t) => (t ? t + ' ' : '') + parts.join(' ').trim());96    };97    rec.onend = () => setListening(false);98    recRef.current = rec;99    rec.start();100    setListening(true);101  };102103  return (104    <div className="px-3 sm:px-6 pb-[calc(8px+var(--safe-bottom))] pt-2 bg-gradient-to-t from-neutral-bg via-neutral-bg to-transparent">105      <div106        onDragOver={(e) => { e.preventDefault(); setDrag(true); }}107        onDragLeave={() => setDrag(false)}108        onDrop={onDrop}109        className={cn('mx-auto max-w-[900px] rounded-2xl border bg-white shadow-card transition-colors', drag ? 'border-uqo-green ring-2 ring-uqo-green/30' : 'border-neutral-line')}110      >111        {(files.length > 0 || error) && (112          <div className="flex flex-wrap gap-2 px-3 pt-3">113            {files.map((f) => (114              <span key={f.file_id} className="inline-flex items-center gap-1.5 rounded-lg bg-uqo-blue-light text-uqo-blue-dark text-xs px-2 py-1.5">115                <FileText size={13} /> <span className="max-w-[160px] truncate">{f.filename}</span> <span className="text-neutral-muted">{fmtBytes(f.size)}</span>116                <button onClick={() => setFiles((p) => p.filter((x) => x.file_id !== f.file_id))} aria-label="Retirer" className="h-6 w-6 inline-flex items-center justify-center rounded hover:bg-white/60"><X size={12} /></button>117              </span>118            ))}119            {error && <span className="text-xs text-semantic-error self-center">{error}</span>}120          </div>121        )}122        <textarea123          ref={taRef}124          value={text}125          onChange={(e: ChangeEvent<HTMLTextAreaElement>) => setText(e.target.value)}126          onKeyDown={onKey}127          rows={1}128          placeholder={disabled ? 'Connexion requise' : 'Pose ta question… (Entrée pour envoyer, Maj+Entrée pour une nouvelle ligne)'}129          aria-label="Message"130          disabled={disabled}131          className="w-full resize-none bg-transparent px-4 pt-3 pb-1 text-[16px] outline-none placeholder:text-neutral-muted/70 max-h-[200px] scroll-thin"132        />133        <div className="flex items-center gap-1 px-2 pb-2">134          <input ref={fileRef} type="file" multiple hidden accept=".xlsx,.xls,.csv,.pdf,.png,.jpg,.jpeg,.webp,.txt,.md,.docx,.json" onChange={(e) => e.target.files && upload(e.target.files)} />135          <button onClick={() => fileRef.current?.click()} disabled={uploading || disabled} className="h-11 w-11 inline-flex items-center justify-center rounded-xl text-neutral-muted hover:bg-neutral-surface" aria-label="Joindre un fichier" title="Joindre un fichier (xlsx, csv, pdf, image)">136            <Paperclip size={19} className={uploading ? 'animate-pulse' : ''} />137          </button>138          {speechOk && (139            <button onClick={toggleMic} disabled={disabled} className={cn('h-11 w-11 inline-flex items-center justify-center rounded-xl hover:bg-neutral-surface', listening ? 'text-semantic-error' : 'text-neutral-muted')} aria-label="Dictée vocale">140              {listening ? <MicOff size={19} /> : <Mic size={19} />}141            </button>142          )}143          <select value={course} onChange={(e) => onCourseChange(e.target.value)} aria-label="Cours" disabled={!!conversationId}144            className="h-9 rounded-lg border border-neutral-line bg-white px-2 text-xs font-medium text-uqo-blue-dark disabled:opacity-70">145            {courses.map((c) => <option key={c} value={c}>{c}</option>)}146          </select>147          <button onClick={onToggleDeep} className={cn('h-9 px-2.5 rounded-lg text-xs font-medium inline-flex items-center gap-1 border', deep ? 'bg-uqo-blue text-white border-uqo-blue' : 'border-neutral-line text-neutral-muted hover:bg-neutral-surface')} title="Réflexion approfondie : modèle plus puissant, plus lent" aria-pressed={deep}>148            <Brain size={14} /> <span className="hidden sm:inline">Réflexion approfondie</span><span className="sm:hidden">Approfondi</span>149          </button>150          <div className="flex-1" />151          {streaming ? (152            <button onClick={onStop} className="h-11 w-11 inline-flex items-center justify-center rounded-xl bg-neutral-text text-white" aria-label="Arrêter"><Square size={16} /></button>153          ) : (154            <button onClick={send} disabled={!text.trim() || disabled} className="h-11 w-11 inline-flex items-center justify-center rounded-xl bg-uqo-blue text-white disabled:bg-neutral-line disabled:text-neutral-muted" aria-label="Envoyer"><ArrowUp size={19} /></button>155          )}156        </div>157      </div>158      <p className="mx-auto max-w-[900px] mt-1.5 text-center text-[11px] text-neutral-muted px-2">Outil pédagogique. UQO-Chat peut se tromper : vérifie les calculs importants. Ne constitue pas une évaluation professionnelle.</p>159    </div>160  );161}162
Files · cd08d441f7c277e8fedd6f3d3be82f02b44553ba
Menu

Theme