emo-online-api / emo /frontend /src /components /ChatComposer.jsx
Emo Online
Deploy Emo API
dd87944
Raw
History Blame Contribute Delete
13.8 kB
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;