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%
8.9 KB · 172 lines tsx
Raw Blame History
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';7import { useUI } from '@/stores/ui';89interface SpeechRecognitionLike { start(): void; stop(): void; lang: string; interimResults: boolean; continuous: boolean; onresult: ((e: { results: ArrayLike<ArrayLike<{ transcript: string }>> }) => void) | null; onend: (() => void) | null }10declare global { interface Window { webkitSpeechRecognition?: new () => SpeechRecognitionLike; SpeechRecognition?: new () => SpeechRecognitionLike } }1112export function Composer({ conversationId, streaming, onSend, onStop, deep, onToggleDeep, course, onCourseChange, courses, disabled }: {13  conversationId: string | null;14  streaming: boolean;15  onSend: (text: string, attachments: string[]) => void;16  onStop: () => void;17  deep: boolean;18  onToggleDeep: () => void;19  course: string;20  onCourseChange: (c: string) => void;21  courses: string[];22  disabled?: boolean;23}) {24  const [text, setText] = useState('');25  const [files, setFiles] = useState<UploadedFile[]>([]);26  const [uploading, setUploading] = useState(false);27  const [drag, setDrag] = useState(false);28  const [error, setError] = useState<string | null>(null);29  const [listening, setListening] = useState(false);30  const taRef = useRef<HTMLTextAreaElement>(null);31  const fileRef = useRef<HTMLInputElement>(null);32  const recRef = useRef<SpeechRecognitionLike | null>(null);33  const speechOk = typeof window !== 'undefined' && !!(window.SpeechRecognition || window.webkitSpeechRecognition);34  const draft = useUI((st) => st.draft);35  const setDraft = useUI((st) => st.setDraft);36  useEffect(() => {37    if (draft) {38      setText(draft);39      setDraft('');40      setTimeout(() => { taRef.current?.focus(); taRef.current?.setSelectionRange(taRef.current.value.length, taRef.current.value.length); }, 50);41    }42  }, [draft, setDraft]);4344  useEffect(() => {45    const ta = taRef.current;46    if (!ta) return;47    ta.style.height = 'auto';48    ta.style.height = Math.min(ta.scrollHeight, 200) + 'px';49  }, [text]);5051  const upload = async (list: FileList | File[]) => {52    setError(null);53    setUploading(true);54    try {55      for (const f of Array.from(list)) {56        const fd = new FormData();57        fd.append('file', f);58        if (conversationId) fd.append('conversation_id', conversationId);59        const up = await api<UploadedFile>('/files', { method: 'POST', body: fd });60        setFiles((prev) => [...prev, up]);61      }62    } catch (e) {63      setError(e instanceof Error ? e.message : 'Téléversement impossible.');64    } finally {65      setUploading(false);66    }67  };6869  const send = () => {70    const t = text.trim();71    if (!t || streaming || disabled) return;72    onSend(t, files.map((f) => f.file_id));73    setText('');74    setFiles([]);75  };7677  const onKey = (e: KeyboardEvent<HTMLTextAreaElement>) => {78    if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {79      e.preventDefault();80      send();81    }82  };8384  const onDrop = (e: DragEvent) => {85    e.preventDefault();86    setDrag(false);87    if (e.dataTransfer.files?.length) void upload(e.dataTransfer.files);88  };8990  const toggleMic = () => {91    if (listening) {92      recRef.current?.stop();93      setListening(false);94      return;95    }96    const Ctor = window.SpeechRecognition || window.webkitSpeechRecognition;97    if (!Ctor) return;98    const rec = new Ctor();99    rec.lang = 'fr-CA';100    rec.interimResults = false;101    rec.continuous = true;102    rec.onresult = (ev) => {103      const parts: string[] = [];104      for (let i = 0; i < ev.results.length; i++) parts.push(ev.results[i][0].transcript);105      setText((t) => (t ? t + ' ' : '') + parts.join(' ').trim());106    };107    rec.onend = () => setListening(false);108    recRef.current = rec;109    rec.start();110    setListening(true);111  };112113  return (114    <div className="px-2.5 sm:px-6 pb-[calc(6px+var(--safe-bottom))] pt-2 bg-gradient-to-t from-[#f4f7f9] via-[#f4f7f9]/95 to-transparent">115      <div116        onDragOver={(e) => { e.preventDefault(); setDrag(true); }}117        onDragLeave={() => setDrag(false)}118        onDrop={onDrop}119        className={cn('mx-auto max-w-[900px] rounded-[22px] border bg-white shadow-float transition-colors focus-within:border-uqo-blue/60', drag ? 'border-uqo-green ring-2 ring-uqo-green/30' : 'border-neutral-line')}120      >121        {(files.length > 0 || error) && (122          <div className="flex flex-wrap gap-2 px-3 pt-3">123            {files.map((f) => (124              <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">125                <FileText size={13} /> <span className="max-w-[160px] truncate">{f.filename}</span> <span className="text-neutral-muted">{fmtBytes(f.size)}</span>126                <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>127              </span>128            ))}129            {error && <span className="text-xs text-semantic-error self-center">{error}</span>}130          </div>131        )}132        <textarea133          ref={taRef}134          value={text}135          onChange={(e: ChangeEvent<HTMLTextAreaElement>) => setText(e.target.value)}136          onKeyDown={onKey}137          rows={1}138          placeholder={disabled ? 'Connexion requise' : window.innerWidth < 640 ? 'Pose ta question au tuteur…' : 'Pose ta question… (Entrée pour envoyer, Maj+Entrée pour une nouvelle ligne)'}139          aria-label="Message"140          disabled={disabled}141          className="w-full resize-none bg-transparent px-4 pt-3 pb-1 text-[16px] leading-relaxed outline-none placeholder:text-neutral-muted/70 max-h-[200px] scroll-thin"142        />143        <div className="flex items-center gap-1 px-2 pb-2">144          <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)} />145          <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)">146            <Paperclip size={19} className={uploading ? 'animate-pulse' : ''} />147          </button>148          {speechOk && (149            <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">150              {listening ? <MicOff size={19} /> : <Mic size={19} />}151            </button>152          )}153          <select value={course} onChange={(e) => onCourseChange(e.target.value)} aria-label="Cours" disabled={!!conversationId}154            className="h-9 rounded-full border border-neutral-line bg-neutral-surface px-2.5 text-xs font-semibold text-uqo-blue-dark disabled:opacity-70">155            {courses.map((c) => <option key={c} value={c}>{c}</option>)}156          </select>157          <button onClick={onToggleDeep} className={cn('h-9 px-2.5 rounded-full text-xs font-semibold inline-flex items-center gap-1 border transition', deep ? 'bg-uqo-blue text-white border-uqo-blue' : 'border-neutral-line bg-neutral-surface text-neutral-muted hover:bg-white')} title="Réflexion approfondie : modèle plus puissant, plus lent" aria-pressed={deep}>158            <Brain size={14} /> <span className="hidden sm:inline">Réflexion approfondie</span><span className="sm:hidden">Approfondi</span>159          </button>160          <div className="flex-1" />161          {streaming ? (162            <button onClick={onStop} className="h-11 w-11 inline-flex items-center justify-center rounded-full bg-neutral-text text-white shadow-soft active:scale-95 transition" aria-label="Arrêter"><Square size={16} /></button>163          ) : (164            <button onClick={send} disabled={!text.trim() || disabled} className="h-11 w-11 inline-flex items-center justify-center rounded-full bg-uqo-gradient text-white shadow-soft active:scale-95 transition disabled:bg-none disabled:bg-neutral-line disabled:text-neutral-muted disabled:shadow-none" aria-label="Envoyer"><ArrowUp size={19} /></button>165          )}166        </div>167      </div>168      <p className="mx-auto max-w-[900px] mt-1.5 text-center text-[10.5px] leading-tight text-neutral-muted px-2"><span className="sm:hidden">Outil pédagogique — vérifie les calculs importants.</span><span className="hidden sm:inline">Outil pédagogique. UQO-Chat peut se tromper : vérifie les calculs importants. Ne constitue pas une évaluation professionnelle.</span></p>169    </div>170  );171}172