spb/chat-spboucher-ai
Public
1"use client";2import { useEffect, useRef, useState, type KeyboardEvent } from "react";3import { ArrowUp, Square, Paperclip, Mic, MicOff, Wrench, X, FileText, Image as ImageIcon, SlidersHorizontal } from "lucide-react";4import { ModelPicker } from "./model-picker";5import type { AttachmentDTO, RoutingMode } from "@/lib/client/types";6import { usePrefs, setPrefs } from "@/lib/client/prefs";7import { api } from "@/lib/client/fetcher";8import { cn, toast, Toggle } from "../ui";9import { useModels } from "@/lib/client/use-models";1011export interface ComposerSubmit { text: string; attachments: AttachmentDTO[]; modelId: string | null; routingMode: RoutingMode; effort?: "low" | "medium" | "high" | "xhigh" | "max"; toolsEnabled: boolean }1213export function Composer({ onSubmit, onStop, streaming, conversationId, projectId, placeholder, initialModel, autoFocus = true }: { onSubmit: (s: ComposerSubmit) => void; onStop: () => void; streaming: boolean; conversationId: string | null; projectId?: string | null; placeholder?: string; initialModel?: string | null; autoFocus?: boolean }) {14 const prefs = usePrefs(); const { chat, defaultModel, byId } = useModels();15 const [text, setText] = useState(""); const [atts, setAtts] = useState<AttachmentDTO[]>([]); const [uploading, setUploading] = useState(false); const [rec, setRec] = useState<MediaRecorder | null>(null); const [transcribing, setTranscribing] = useState(false); const [opts, setOpts] = useState(false);16 const ta = useRef<HTMLTextAreaElement>(null); const fileRef = useRef<HTMLInputElement>(null);17 const modelId = prefs.modelId && byId(prefs.modelId)?.available ? prefs.modelId : (initialModel && byId(initialModel)?.available ? initialModel : defaultModel && byId(defaultModel)?.available ? defaultModel : chat[0]?.id ?? null);18 const model = byId(modelId);19 const supportsEffort = model && (/^(gpt-5|gpt-6|o\d)/.test(model.id) && !/chat-latest$/.test(model.id) || /-4-6|-4-7|-4-8|opus-5|sonnet-5|haiku-5|fable/.test(model.id));2021 useEffect(() => { const el = ta.current; if (!el) return; el.style.height = "0px"; el.style.height = Math.min(el.scrollHeight, 260) + "px"; }, [text]);22 useEffect(() => { if (autoFocus && window.innerWidth > 768) ta.current?.focus(); }, [autoFocus, conversationId]);2324 function submit() {25 if (streaming) return;26 if (!text.trim() && !atts.length) return;27 if (prefs.routingMode === "manual" && !modelId) { toast("Choisis un modèle", "error"); return; }28 onSubmit({ text, attachments: atts, modelId, routingMode: prefs.routingMode, effort: prefs.effort, toolsEnabled: prefs.toolsEnabled });29 setText(""); setAtts([]);30 }31 function onKey(e: KeyboardEvent<HTMLTextAreaElement>) {32 if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing && prefs.sendWithEnter && window.innerWidth > 768) { e.preventDefault(); submit(); }33 else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); submit(); }34 }35 async function upload(files: FileList | File[]) {36 const list = Array.from(files); if (!list.length) return;37 setUploading(true);38 try {39 const fd = new FormData(); for (const f of list) fd.append("file", f); if (conversationId) fd.append("conversationId", conversationId); if (projectId) fd.append("projectId", projectId);40 const r = await api<{ attachments: AttachmentDTO[] }>("/api/attachments", { method: "POST", body: fd });41 setAtts(a => [...a, ...r.attachments]);42 } catch (e) { toast((e as Error).message, "error"); } finally { setUploading(false); }43 }44 async function toggleMic() {45 if (rec) { rec.stop(); return; }46 try {47 const stream = await navigator.mediaDevices.getUserMedia({ audio: true });48 const mime = MediaRecorder.isTypeSupported("audio/webm") ? "audio/webm" : "audio/mp4";49 const r = new MediaRecorder(stream, { mimeType: mime }); const chunks: Blob[] = []; const t0 = Date.now();50 r.ondataavailable = e => chunks.push(e.data);51 r.onstop = async () => {52 stream.getTracks().forEach(t => t.stop()); setRec(null); setTranscribing(true);53 try { const fd = new FormData(); fd.append("audio", new File(chunks, `voice.${mime.includes("webm") ? "webm" : "m4a"}`, { type: mime })); fd.append("durationSec", String((Date.now() - t0) / 1000)); const res = await api<{ text: string }>("/api/stt", { method: "POST", body: fd }); setText(t => (t ? t + " " : "") + res.text); ta.current?.focus(); }54 catch (e) { toast((e as Error).message, "error"); } finally { setTranscribing(false); }55 };56 r.start(); setRec(r);57 } catch { toast("Micro indisponible", "error"); }58 }59 return (60 <div className="mx-auto w-full max-w-[var(--reading-w)] px-3 pb-3 md:px-6 md:pb-5">61 <div onDragOver={e => e.preventDefault()} onDrop={e => { e.preventDefault(); void upload(e.dataTransfer.files); }} className={cn("rounded-2xl border bg-surface shadow-soft transition-colors focus-within:border-border-strong", streaming ? "border-border" : "border-border")}>62 {atts.length > 0 && <div className="flex flex-wrap gap-2 px-3 pt-3">{atts.map(a => <span key={a.id} className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-surface-2 px-2 py-1 text-[12px]">{a.mime.startsWith("image/") ? <ImageIcon className="h-3.5 w-3.5 text-accent" /> : <FileText className="h-3.5 w-3.5 text-accent-2" />}<span className="max-w-40 truncate">{a.filename}</span>{a.pages ? <span className="text-muted">{a.pages} p.</span> : null}<button onClick={() => setAtts(x => x.filter(y => y.id !== a.id))} className="text-muted hover:text-fg"><X className="h-3 w-3" /></button></span>)}</div>}63 <textarea ref={ta} value={text} onChange={e => setText(e.target.value)} onKeyDown={onKey} onPaste={e => { const files = Array.from(e.clipboardData.files); if (files.length) { e.preventDefault(); void upload(files); } }} rows={1} placeholder={placeholder ?? "Pose une question, colle un document, décris une tâche…"} className="block w-full resize-none bg-transparent px-4 pt-3.5 pb-2 text-[15px] leading-relaxed outline-none placeholder:text-muted" style={{ maxHeight: 260 }} />64 <div className="flex items-center gap-1 px-2 pb-2">65 <input ref={fileRef} type="file" multiple hidden accept="image/*,.pdf,.docx,.xlsx,.csv,.txt,.md,.json,audio/*" onChange={e => { if (e.target.files) void upload(e.target.files); e.target.value = ""; }} />66 <button type="button" onClick={() => fileRef.current?.click()} disabled={uploading} title="Joindre (images, PDF, DOCX, XLSX, CSV, TXT)" className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-fg-2 hover:bg-surface-2 hover:text-fg disabled:opacity-50"><Paperclip className={cn("h-4 w-4", uploading && "animate-pulse")} /></button>67 <ModelPicker compact value={modelId} mode={prefs.routingMode} onChange={v => setPrefs({ modelId: v.modelId, routingMode: v.mode })} />68 <button type="button" onClick={() => setPrefs({ toolsEnabled: !prefs.toolsEnabled })} title={prefs.toolsEnabled ? "Outils activés (web, Python, documents, images, mémoire)" : "Outils désactivés"} className={cn("inline-flex h-8 items-center gap-1 rounded-lg px-2 text-[12px] transition-colors", prefs.toolsEnabled ? "text-accent bg-accent/10" : "text-muted hover:bg-surface-2")}><Wrench className="h-3.5 w-3.5" /><span className="hidden sm:inline">Outils</span></button>69 <button type="button" onClick={() => setOpts(o => !o)} title="Options du modèle" className={cn("inline-flex h-8 w-8 items-center justify-center rounded-lg transition-colors", opts ? "bg-surface-2 text-fg" : "text-fg-2 hover:bg-surface-2 hover:text-fg")}><SlidersHorizontal className="h-4 w-4" /></button>70 <div className="flex-1" />71 <button type="button" onClick={toggleMic} disabled={transcribing} title="Dictée vocale" className={cn("inline-flex h-8 w-8 items-center justify-center rounded-lg transition-colors", rec ? "bg-danger/15 text-danger animate-pulse" : "text-fg-2 hover:bg-surface-2 hover:text-fg", transcribing && "opacity-50")}>{rec ? <MicOff className="h-4 w-4" /> : <Mic className="h-4 w-4" />}</button>72 {streaming ? <button type="button" onClick={onStop} className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-fg text-bg hover:opacity-90" title="Arrêter"><Square className="h-3.5 w-3.5 fill-current" /></button>73 : <button type="button" onClick={submit} disabled={!text.trim() && !atts.length} className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-accent text-accent-ink transition-opacity hover:brightness-110 disabled:opacity-30" title="Envoyer"><ArrowUp className="h-4 w-4" /></button>}74 </div>75 {opts && <div className="flex flex-wrap items-center gap-4 border-t border-border px-4 py-2.5 text-[12px] text-fg-2">76 <label className="flex items-center gap-2">Effort {supportsEffort ? <select value={prefs.effort ?? ""} onChange={e => setPrefs({ effort: (e.target.value || undefined) as never })} className="input !w-auto !py-0.5 !px-2 text-[12px]"><option value="">par défaut</option><option value="low">faible</option><option value="medium">moyen</option><option value="high">élevé</option><option value="xhigh">très élevé</option><option value="max">maximal</option></select> : <span className="text-muted">non pris en charge par ce modèle</span>}</label>77 <label className="flex items-center gap-2">Afficher le raisonnement <Toggle checked={prefs.showReasoning} onChange={v => setPrefs({ showReasoning: v })} /></label>78 <label className="flex items-center gap-2">Entrée envoie <Toggle checked={prefs.sendWithEnter} onChange={v => setPrefs({ sendWithEnter: v })} /></label>79 </div>}80 </div>81 <div className="mt-1.5 hidden text-center text-[11px] text-muted md:block">Les réponses peuvent contenir des erreurs ; vérifie les faits importants. {model?.pricing?.input != null ? `${model.displayName} · ${model.pricing.input} $ / ${model.pricing.output} $ par M jetons` : ""}</div>82 </div>83 );84}85