Spaces:
Running
Running
| import React, { useState, useRef, useEffect } from "react"; | |
| import { Send, Code, Lightbulb, Flame, ChevronDown, Cpu, Square, Bot, MessageCircle, ImagePlus, X, FolderOpen } from "lucide-react"; | |
| import { filesToAttachments, mergeAttachments, MAX_IMAGES } from "../lib/imageAttachments"; | |
| const MODES = [ | |
| { id: "tech", label: "Tech", Icon: Code }, | |
| { id: "creatif", label: "Créatif", Icon: Lightbulb }, | |
| { id: "brutal", label: "Brutal", Icon: Flame }, | |
| ]; | |
| export const ChatComposer = ({ | |
| mode, onChangeMode, | |
| modelPreference, onChangeModelPreference, availableModels, | |
| useAgentTools, onChangeUseAgentTools, | |
| agentProjectPath, onChangeAgentProjectPath, agentStatus, | |
| onSend, onCancel, disabled, streaming, | |
| }) => { | |
| const [value, setValue] = useState(""); | |
| const [attachments, setAttachments] = useState([]); | |
| const [openPicker, setOpenPicker] = useState(null); | |
| const [dragOver, setDragOver] = useState(false); | |
| const textareaRef = useRef(null); | |
| const pickerRef = useRef(null); | |
| const modelPickerRef = useRef(null); | |
| const fileInputRef = useRef(null); | |
| const models = availableModels?.length | |
| ? availableModels | |
| : [{ id: "auto", label: "Auto" }]; | |
| const sortedModels = [...models].sort((a, b) => { | |
| if (a.id === "auto") return -1; | |
| if (b.id === "auto") return 1; | |
| if (a.uncensored && !b.uncensored) return -1; | |
| if (!a.uncensored && b.uncensored) return 1; | |
| return 0; | |
| }); | |
| useEffect(() => { | |
| const el = textareaRef.current; | |
| if (!el) return; | |
| el.style.height = "auto"; | |
| el.style.height = Math.min(el.scrollHeight, 180) + "px"; | |
| }, [value]); | |
| useEffect(() => { | |
| if (!openPicker) return; | |
| const onClick = (e) => { | |
| const ref = openPicker === "mode" ? pickerRef : modelPickerRef; | |
| if (ref.current && !ref.current.contains(e.target)) setOpenPicker(null); | |
| }; | |
| document.addEventListener("mousedown", onClick); | |
| return () => document.removeEventListener("mousedown", onClick); | |
| }, [openPicker]); | |
| const togglePicker = (id) => setOpenPicker((prev) => (prev === id ? null : id)); | |
| const addFiles = async (files) => { | |
| const incoming = await filesToAttachments(files); | |
| if (!incoming.length) return; | |
| setAttachments((prev) => mergeAttachments(prev, incoming)); | |
| }; | |
| const submit = () => { | |
| const v = value.trim(); | |
| if ((!v && !attachments.length) || disabled) return; | |
| onSend(v, attachments.map((a) => a.base64), attachments.map((a) => a.mediaType)); | |
| setValue(""); | |
| setAttachments([]); | |
| }; | |
| const currentMode = MODES.find((m) => m.id === mode) || MODES[0]; | |
| const CurrentIcon = currentMode.Icon; | |
| const currentModel = models.find((m) => m.id === (modelPreference || "auto")) || models[0]; | |
| const modelShort = currentModel.id === "auto" | |
| ? "Auto" | |
| : (currentModel.label || currentModel.id).split("(")[0].trim().slice(0, 18); | |
| return ( | |
| <div className={`mode-${mode} w-full`}> | |
| <div | |
| data-testid="chat-composer" | |
| className="emo-composer" | |
| data-drag={dragOver ? "true" : "false"} | |
| onDragOver={(e) => { | |
| e.preventDefault(); | |
| setDragOver(true); | |
| }} | |
| onDragLeave={() => setDragOver(false)} | |
| onDrop={(e) => { | |
| e.preventDefault(); | |
| setDragOver(false); | |
| addFiles(e.dataTransfer?.files); | |
| }} | |
| > | |
| {/* Top toolbar: mode + model + chat/agent toggle */} | |
| <div className="emo-composer-toolbar"> | |
| <div className="emo-composer-toolbar-inner"> | |
| <div className="emo-segment emo-composer-segment" role="group" aria-label="Mode conversation"> | |
| <button | |
| type="button" | |
| data-testid="agent-mode-toggle" | |
| className="emo-segment-btn" | |
| data-active={!useAgentTools ? "true" : "false"} | |
| onClick={() => onChangeUseAgentTools?.(false)} | |
| title="Mode chat — sans agent local" | |
| > | |
| <MessageCircle size={13} /> | |
| <span className="hidden sm:inline">Chat</span> | |
| </button> | |
| <button | |
| type="button" | |
| className="emo-segment-btn" | |
| data-active={useAgentTools ? "true" : "false"} | |
| data-accent={useAgentTools ? "true" : "false"} | |
| onClick={() => onChangeUseAgentTools?.(true)} | |
| title="Mode agent — fichiers sur ton PC" | |
| > | |
| <Bot size={13} /> | |
| <span className="hidden sm:inline">Agent</span> | |
| </button> | |
| </div> | |
| <div | |
| className="emo-composer-picker emo-composer-picker-mode" | |
| ref={pickerRef} | |
| data-open={openPicker === "mode" ? "true" : undefined} | |
| > | |
| <button | |
| type="button" | |
| data-testid="mode-picker-trigger" | |
| onClick={() => togglePicker("mode")} | |
| className="emo-btn-ghost emo-composer-picker-btn flex items-center gap-1.5 px-2.5 py-1.5 text-xs" | |
| title={currentMode.label} | |
| aria-expanded={openPicker === "mode"} | |
| aria-haspopup="listbox" | |
| > | |
| <CurrentIcon size={12} style={{ color: "var(--mode-color)" }} /> | |
| <span className="emo-composer-picker-label">{currentMode.label}</span> | |
| <ChevronDown size={11} className={`transition-transform ${openPicker === "mode" ? "rotate-180" : ""}`} /> | |
| </button> | |
| {openPicker === "mode" && ( | |
| <div data-testid="mode-picker-menu" className="emo-dropdown" role="listbox"> | |
| {MODES.map((m) => { | |
| const Icon = m.Icon; | |
| const active = m.id === mode; | |
| return ( | |
| <button | |
| key={m.id} | |
| data-testid={`mode-picker-${m.id}`} | |
| onClick={() => { | |
| onChangeMode?.(m.id); | |
| setOpenPicker(null); | |
| }} | |
| className="emo-dropdown-item" | |
| data-active={active ? "true" : "false"} | |
| > | |
| <Icon size={13} style={{ color: active ? "var(--mode-color)" : "var(--emo-text-muted)" }} /> | |
| {m.label} | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| </div> | |
| <div | |
| className="emo-composer-picker emo-composer-picker-model" | |
| ref={modelPickerRef} | |
| data-open={openPicker === "model" ? "true" : undefined} | |
| > | |
| <button | |
| type="button" | |
| data-testid="model-picker-trigger" | |
| onClick={() => togglePicker("model")} | |
| className="emo-btn-ghost emo-composer-picker-btn flex items-center gap-1.5 px-2.5 py-1.5 text-xs" | |
| title={currentModel.label || "Modèle"} | |
| aria-expanded={openPicker === "model"} | |
| aria-haspopup="listbox" | |
| > | |
| <Cpu size={12} /> | |
| <span className="emo-composer-picker-label emo-composer-picker-label-model">{modelShort}</span> | |
| <ChevronDown size={11} className={`transition-transform ${openPicker === "model" ? "rotate-180" : ""}`} /> | |
| </button> | |
| {openPicker === "model" && ( | |
| <div data-testid="model-picker-menu" className="emo-dropdown emo-dropdown--align-end w-64" role="listbox"> | |
| {sortedModels.map((m) => { | |
| const active = m.id === (modelPreference || "auto"); | |
| return ( | |
| <button | |
| key={m.id} | |
| data-testid={`model-picker-${m.id.replace(/[:/]/g, "-")}`} | |
| onClick={() => { | |
| onChangeModelPreference?.(m.id); | |
| setOpenPicker(null); | |
| }} | |
| className="emo-dropdown-item" | |
| data-active={active ? "true" : "false"} | |
| > | |
| <Cpu size={13} style={{ color: active ? "var(--emo-link)" : "var(--emo-text-muted)" }} /> | |
| <span className="truncate flex-1">{m.label || m.id}</span> | |
| {m.uncensored && ( | |
| <span className="text-[9px] px-1 py-0.5 rounded flex-shrink-0 emo-alert-warning"> | |
| libre | |
| </span> | |
| )} | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| {useAgentTools && ( | |
| <div className="px-3 pb-1 flex flex-col gap-1"> | |
| <div className="flex items-center gap-2 text-[11px] text-secondary-em"> | |
| <FolderOpen size={12} className="shrink-0 opacity-70" /> | |
| <input | |
| type="text" | |
| value={agentProjectPath || ""} | |
| onChange={(e) => onChangeAgentProjectPath?.(e.target.value)} | |
| placeholder="Dossier projet agent (ex. C:\Users\admin\Desktop\JarvisProjects\mon_projet)" | |
| className="flex-1 min-w-0 bg-transparent border-b border-[var(--emo-border)] py-0.5 font-code text-[10px] outline-none focus:border-[var(--emo-accent)]" | |
| spellCheck={false} | |
| /> | |
| </div> | |
| {agentStatus ? ( | |
| <p className="text-[10px] text-[var(--emo-accent)] pl-5 truncate" title={agentStatus}> | |
| {agentStatus} | |
| </p> | |
| ) : null} | |
| </div> | |
| )} | |
| {attachments.length > 0 && ( | |
| <div className="emo-attachment-strip" data-testid="composer-attachments"> | |
| {attachments.map((a) => ( | |
| <div key={a.preview} className="emo-attachment-chip"> | |
| <img src={a.preview} alt={a.name || "Image"} /> | |
| {a.name && ( | |
| <span className="emo-attachment-chip-name">{a.name}</span> | |
| )} | |
| <button | |
| type="button" | |
| onClick={() => setAttachments((prev) => prev.filter((x) => x.preview !== a.preview))} | |
| className="emo-attachment-remove" | |
| aria-label="Retirer l'image" | |
| > | |
| <X size={11} /> | |
| </button> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| {/* Textarea */} | |
| <div className="emo-composer-body"> | |
| <textarea | |
| ref={textareaRef} | |
| data-testid="composer-textarea" | |
| value={value} | |
| onChange={(e) => setValue(e.target.value)} | |
| onPaste={(e) => { | |
| const items = e.clipboardData?.items; | |
| if (!items) return; | |
| const imageFiles = []; | |
| for (const item of items) { | |
| if (item.type.startsWith("image/")) { | |
| const f = item.getAsFile(); | |
| if (f) imageFiles.push(f); | |
| } | |
| } | |
| if (imageFiles.length) { | |
| e.preventDefault(); | |
| addFiles(imageFiles); | |
| } | |
| }} | |
| onKeyDown={(e) => { | |
| if (e.key === "Enter" && !e.shiftKey) { | |
| e.preventDefault(); | |
| submit(); | |
| } | |
| }} | |
| rows={1} | |
| placeholder={ | |
| streaming | |
| ? "Réponse en cours…" | |
| : attachments.length | |
| ? "Décris l'image ou pose une question…" | |
| : "Écris un message — glisse ou colle une image" | |
| } | |
| disabled={disabled && !streaming} | |
| className="emo-composer-textarea disabled:opacity-60" | |
| /> | |
| </div> | |
| <input | |
| ref={fileInputRef} | |
| type="file" | |
| accept="image/*" | |
| multiple | |
| className="hidden" | |
| onChange={(e) => { | |
| addFiles(e.target.files); | |
| e.target.value = ""; | |
| }} | |
| /> | |
| {/* Footer: attach + send */} | |
| <div className="emo-composer-footer"> | |
| <button | |
| type="button" | |
| data-testid="composer-image-btn" | |
| onClick={() => fileInputRef.current?.click()} | |
| disabled={attachments.length >= MAX_IMAGES || (disabled && !streaming)} | |
| className="emo-icon-btn" | |
| title={`Joindre une image (${attachments.length}/${MAX_IMAGES})`} | |
| > | |
| <ImagePlus size={15} /> | |
| </button> | |
| <div className="flex items-center gap-2"> | |
| {streaming ? ( | |
| <button | |
| type="button" | |
| data-testid="stop-stream-btn" | |
| onClick={() => onCancel?.()} | |
| className="emo-btn-ghost flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium" | |
| title="Arrêter" | |
| > | |
| <Square size={13} fill="currentColor" /> | |
| <span className="hidden sm:inline">Arrêter</span> | |
| </button> | |
| ) : ( | |
| <button | |
| data-testid="send-message-btn" | |
| onClick={submit} | |
| disabled={disabled || (!value.trim() && !attachments.length)} | |
| className="emo-send-btn" | |
| title="Envoyer" | |
| > | |
| <Send size={15} /> | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export default ChatComposer; | |