Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { type ChatMessage, useChatStream } from '@/hooks/useChatStream';
import { Square } from 'lucide-react';
import { useEffect, useState } from 'react';
import { MessageBubble } from './MessageBubble';
import { type ChatParams, ParamsPanel } from './ParamsPanel';
Expand Down Expand Up @@ -74,41 +73,35 @@ export function ChatPlayground({ modelId, modelDisplayName }: Props) {
}
};

const isEmpty = messages.length === 0 && !isStreaming && !error;

return (
<div className="max-w-3xl mx-auto flex flex-col h-[80vh] gap-4">
<header>
<h2 className="font-bold text-xl">Chat — {modelDisplayName}</h2>
<p className="text-xs text-slate-500">{modelId}</p>
<p
role="note"
className="mt-2 rounded border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900"
>
<span aria-hidden>⚠️ </span>
You are interacting with an AI. Replies may be inaccurate, biased, or fabricated and must
not be treated as professional advice. See the{' '}
<a className="underline font-medium" href="/transparency">
transparency page
</a>{' '}
for model provenance and limitations.
</p>
</header>
<>
<div className="chat-banner">
<span className="live">
<span className="dot" />
{modelDisplayName}
</span>
<span>SSE streaming</span>
</div>
Comment on lines +80 to +86

<p className="chat-note" role="note">
<span aria-hidden>⚠ </span>
Réponses générées par IA — potentiellement inexactes, biaisées ou fabriquées, à ne pas
traiter comme un avis professionnel. Voir la <a href="/transparency">démarche qualité</a>.
</p>

<ParamsPanel value={params} onChange={setParams} />

<div className="flex-1 overflow-y-auto rounded border border-slate-200 p-4 flex flex-col gap-3">
{messages.length === 0 && !isStreaming && !error && (
<div className="m-auto w-full max-w-md text-center">
<p className="text-sm text-slate-500">
Posez votre première question à {modelDisplayName}.
<div className="chat-body">
{isEmpty && (
<div className="chat-empty">
<p>
Posez votre première question à <em>{modelDisplayName}</em>.
</p>
<div className="mt-4 flex flex-col gap-2">
<div className="chat-empty-prompts">
{EXAMPLE_PROMPTS.map((p) => (
<button
key={p}
type="button"
onClick={() => setInputText(p)}
className="rounded border border-slate-200 px-3 py-2 text-left text-sm text-slate-600 hover:border-slate-400 hover:bg-slate-50"
>
<button type="button" key={p} onClick={() => setInputText(p)}>
{p}
</button>
))}
Expand All @@ -124,24 +117,16 @@ export function ChatPlayground({ modelId, modelDisplayName }: Props) {
/>
))}
{isStreaming && <MessageBubble speaker="assistant" content={assistantText} streaming />}
{error && <p className="text-rose-700 text-sm">Error: {error}</p>}
{error && <p className="chat-error">Erreur : {error}</p>}
</div>

<PromptInput
value={inputText}
onChange={setInputText}
onSubmit={handleSubmit}
disabled={isStreaming}
streaming={isStreaming}
onStop={stop}
/>
{isStreaming && (
<button
type="button"
onClick={stop}
className="self-end rounded border border-rose-500 px-3 py-1 text-sm text-rose-700"
>
<Square size={12} className="inline mr-1" /> Stop
</button>
)}
</div>
</>
);
}
41 changes: 19 additions & 22 deletions apps/cockpit-public/src/components/ChatPlayground/MessageBubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,30 +7,27 @@ interface Props {
}

export function MessageBubble({ speaker, content, streaming }: Props) {
const align =
speaker === 'user'
? 'ml-auto bg-slate-100 text-slate-900'
: 'mr-auto bg-emerald-50 text-slate-900';
// During the gap between request and first token (Cloudflare can buffer SSE
// for several seconds before any chunk reaches the client) show three
// pulsing dots so the user gets immediate feedback.
// Between the request and the first token, Cloudflare can buffer the SSE
// stream for several seconds — show pulsing dots so the user gets
// immediate feedback that something is happening.
const isThinking = streaming && !content;
return (
<div className={`max-w-[75%] rounded-lg p-3 ${align}`}>
{isThinking ? (
<span aria-label="thinking" className="inline-flex items-center gap-1 py-1">
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-thinking-dot [animation-delay:-0.32s]" />
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-thinking-dot [animation-delay:-0.16s]" />
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-thinking-dot" />
</span>
) : (
<>
<ReactMarkdown>{content}</ReactMarkdown>
{streaming && (
<span className="inline-block w-2 h-4 ml-1 bg-emerald-500 animate-pulse align-text-bottom" />
)}
</>
)}
<div className={`msg ${speaker}`}>
<div className={`who ${speaker}`}>{speaker === 'user' ? 'vous' : 'ailiance'}</div>
<div className="body">
{isThinking ? (
<span className="chat-thinking" aria-label="réflexion en cours">
<span />
<span />
<span />
</span>
) : (
<>
<ReactMarkdown>{content}</ReactMarkdown>
{streaming && <span className="cursor-blink" />}
</>
)}
</div>
</div>
);
}
29 changes: 14 additions & 15 deletions apps/cockpit-public/src/components/ChatPlayground/ParamsPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { ChevronDown, ChevronRight } from 'lucide-react';
import { useState } from 'react';

export interface ChatParams {
Expand All @@ -16,47 +15,47 @@ export function ParamsPanel({ value, onChange }: Props) {
const [open, setOpen] = useState(false);

return (
<div className="rounded border border-slate-200">
<div className="chat-params">
<button
type="button"
className="chat-params-toggle"
onClick={() => setOpen(!open)}
className="w-full flex items-center gap-2 p-3 text-sm font-medium"
aria-expanded={open}
>
{open ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
Parameters
<span className="chat-params-caret">{open ? '−' : '+'}</span>
Paramètres
</button>
{open && (
<div className="p-3 border-t border-slate-200 space-y-3">
<label className="block text-sm">
Temperature: <span className="font-mono">{value.temperature.toFixed(2)}</span>
<div className="chat-params-body">
<label>
<span>
Température <b>{value.temperature.toFixed(2)}</b>
</span>
<input
type="range"
min={0}
max={2}
step={0.05}
value={value.temperature}
onChange={(e) => onChange({ ...value, temperature: Number(e.target.value) })}
className="block w-full"
/>
</label>
<label className="block text-sm">
Max tokens
<label>
<span>Max tokens</span>
<input
type="number"
min={1}
max={4096}
value={value.max_tokens}
onChange={(e) => onChange({ ...value, max_tokens: Number(e.target.value) })}
className="block w-full rounded border border-slate-300 p-1 mt-1"
/>
</label>
<label className="block text-sm">
System prompt
<label>
<span>Prompt système</span>
<textarea
rows={2}
value={value.system_prompt}
onChange={(e) => onChange({ ...value, system_prompt: e.target.value })}
className="block w-full rounded border border-slate-300 p-1 mt-1"
/>
</label>
</div>
Expand Down
106 changes: 54 additions & 52 deletions apps/cockpit-public/src/components/ChatPlayground/PromptInput.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { Paperclip, Send, X } from 'lucide-react';
import { Paperclip, Send, Square, X } from 'lucide-react';
import { type ChangeEvent, type KeyboardEvent, useRef, useState } from 'react';

interface Props {
value: string;
onChange: (v: string) => void;
onSubmit: (text: string) => void;
disabled?: boolean;
/** A reply is currently streaming — the send button becomes a stop button. */
streaming?: boolean;
onStop?: () => void;
}

// Mirrors the gateway's /v1/files/extract supported list. Kept in sync
Expand Down Expand Up @@ -56,7 +58,7 @@ interface ExtractedAttachment {
truncated?: boolean;
}

export function PromptInput({ value, onChange, onSubmit, disabled }: Props) {
export function PromptInput({ value, onChange, onSubmit, streaming, onStop }: Props) {
const [attachment, setAttachment] = useState<ExtractedAttachment | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
Expand All @@ -70,15 +72,15 @@ export function PromptInput({ value, onChange, onSubmit, disabled }: Props) {
const userText = value.trim();
const intro = userText
? userText
: `Please analyze the attached ${attachment.format.toUpperCase()} file.`;
: `Analyse le fichier ${attachment.format.toUpperCase()} ci-joint.`;
const truncNote = attachment.truncated
? '\n\n[note: file was truncated by the server to fit prompt limits]'
? '\n\n[note : le fichier a été tronqué par le serveur pour tenir dans les limites du prompt]'
: '';
return `Attached file: ${attachment.filename}\n\n\`\`\`markdown\n${attachment.markdown}\n\`\`\`${truncNote}\n\n${intro}`;
return `Fichier joint : ${attachment.filename}\n\n\`\`\`markdown\n${attachment.markdown}\n\`\`\`${truncNote}\n\n${intro}`;
};

const handleSubmit = () => {
if (disabled) return;
if (streaming || uploading) return;
const payload = composePayload();
if (!payload) return;
onSubmit(payload);
Expand Down Expand Up @@ -107,7 +109,8 @@ export function PromptInput({ value, onChange, onSubmit, disabled }: Props) {
const resp = await fetch(EXTRACT_URL, { method: 'POST', body: form });
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
const msg = body?.detail?.message ?? body?.detail ?? `Upload failed (HTTP ${resp.status})`;
const msg =
body?.detail?.message ?? body?.detail ?? `Échec de l'envoi (HTTP ${resp.status})`;
setUploadError(String(msg));
return;
}
Expand All @@ -119,60 +122,48 @@ export function PromptInput({ value, onChange, onSubmit, disabled }: Props) {
truncated: body.metadata?.truncated === true,
});
} catch (err) {
setUploadError(err instanceof Error ? err.message : 'Upload failed');
setUploadError(err instanceof Error ? err.message : "Échec de l'envoi");
} finally {
setUploading(false);
}
};

const canSend = !disabled && !uploading && (value.trim().length > 0 || attachment !== null);
const canSend = !streaming && !uploading && (value.trim().length > 0 || attachment !== null);

return (
<div className="space-y-2">
<div className="chat-input-wrap">
{attachment && (
<div className="flex items-center gap-2 rounded border border-emerald-300 bg-emerald-50 px-3 py-2 text-xs text-emerald-900">
<span className="font-mono">{attachment.filename}</span>
<span className="rounded bg-emerald-200 px-1.5 py-0.5 text-[10px] uppercase tracking-wide">
{attachment.format}
<div className="chat-attach">
<span className="chat-attach-name">{attachment.filename}</span>
<span className="chat-attach-tag">{attachment.format}</span>
<span className="chat-attach-size">
{attachment.markdown.length.toLocaleString()} car.
</span>
<span className="text-emerald-700">
{attachment.markdown.length.toLocaleString()} chars
</span>
{attachment.truncated && (
<span className="rounded bg-amber-200 px-1.5 py-0.5 text-[10px] uppercase text-amber-900">
truncated
</span>
)}
{attachment.truncated && <span className="chat-attach-warn">tronqué</span>}
<button
type="button"
onClick={() => setAttachment(null)}
className="ml-auto rounded p-0.5 hover:bg-emerald-200"
aria-label="Remove attachment"
className="chat-attach-x"
aria-label="Retirer la pièce jointe"
>
<X size={12} />
</button>
</div>
)}
{uploadError && (
<p role="alert" className="text-xs text-rose-700">
<p role="alert" className="chat-error">
{uploadError}
</p>
)}
<div className="flex gap-2">
<input
ref={fileInputRef}
type="file"
accept={ACCEPT_TYPES}
onChange={handleFile}
className="hidden"
/>
<div className="chat-input">
<input ref={fileInputRef} type="file" accept={ACCEPT_TYPES} onChange={handleFile} hidden />
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={disabled || uploading}
aria-label="Attach a file"
title="Attach a file (PDF/DOCX/XLSX/PPTX/TXT/MD/HTML) or image (PNG/JPG/etc., OCR'd server-side)"
className="rounded border border-slate-300 bg-white px-3 text-slate-700 disabled:opacity-50 hover:bg-slate-100"
disabled={streaming || uploading}
className="chat-icon-btn"
aria-label="Joindre un fichier"
title="Joindre un fichier (PDF/DOCX/XLSX/PPTX/TXT/MD/HTML) ou une image (PNG/JPG/etc., OCR côté serveur)"
>
<Paperclip size={16} />
</button>
Expand All @@ -182,23 +173,34 @@ export function PromptInput({ value, onChange, onSubmit, disabled }: Props) {
onKeyDown={handleKeyDown}
placeholder={
uploading
? 'Extracting file…'
? 'Extraction du fichier…'
: attachment
? 'Ask a question about the attached file…'
: 'Type a message…'
? 'Posez une question sur le fichier joint…'
: 'Écrivez un message…'
}
rows={3}
disabled={disabled || uploading}
className="flex-1 min-w-0 rounded border border-slate-300 bg-white text-slate-900 placeholder:text-slate-400 p-2 resize-none focus:outline-none focus:ring-2 focus:ring-emerald-500"
rows={1}
disabled={streaming || uploading}
/>
<button
type="button"
onClick={handleSubmit}
disabled={!canSend}
className="rounded bg-emerald-600 px-4 text-white disabled:opacity-50"
>
<Send size={16} />
</button>
{streaming ? (
<button
type="button"
onClick={onStop}
className="chat-stop"
aria-label="Arrêter la génération"
>
<Square size={14} />
</button>
) : (
<button
type="button"
onClick={handleSubmit}
disabled={!canSend}
className="chat-send"
aria-label="Envoyer"
>
<Send size={16} />
</button>
)}
</div>
</div>
);
Expand Down
Loading
Loading