Initial commit — ClawChat v1.0.0
This commit is contained in:
70
src/components/Chat.tsx
Normal file
70
src/components/Chat.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { ChatMessageComponent } from './ChatMessage';
|
||||
import { ChatInput } from './ChatInput';
|
||||
import { TypingIndicator } from './TypingIndicator';
|
||||
import type { ChatMessage, ConnectionStatus } from '../types';
|
||||
import { Bot } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
messages: ChatMessage[];
|
||||
isGenerating: boolean;
|
||||
status: ConnectionStatus;
|
||||
onSend: (text: string, attachments?: Array<{ mimeType: string; fileName: string; content: string }>) => void;
|
||||
onAbort: () => void;
|
||||
}
|
||||
|
||||
function hasVisibleContent(msg: ChatMessage): boolean {
|
||||
if (msg.role === 'user') return true;
|
||||
if (msg.blocks.length === 0) return !!msg.content;
|
||||
// Show all assistant messages — tool-only ones render as compact inline
|
||||
return msg.blocks.some(b =>
|
||||
(b.type === 'text' && b.text.trim()) ||
|
||||
b.type === 'thinking' ||
|
||||
b.type === 'tool_use' ||
|
||||
b.type === 'tool_result'
|
||||
);
|
||||
}
|
||||
|
||||
function hasStreamedText(messages: ChatMessage[]): boolean {
|
||||
if (messages.length === 0) return false;
|
||||
const last = messages[messages.length - 1];
|
||||
if (last.role !== 'assistant') return false;
|
||||
return last.blocks.some(b => b.type === 'text' && b.text.trim().length > 0) || (last.content?.trim().length > 0);
|
||||
}
|
||||
|
||||
export function Chat({ messages, isGenerating, status, onSend, onAbort }: Props) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages, isGenerating]);
|
||||
|
||||
const showTyping = isGenerating && !hasStreamedText(messages);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-4xl mx-auto py-4">
|
||||
{messages.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-[60vh] text-zinc-500">
|
||||
<div className="relative mb-6">
|
||||
<div className="absolute -inset-4 rounded-3xl bg-gradient-to-r from-cyan-400/10 via-indigo-500/10 to-violet-500/10 blur-2xl" />
|
||||
<div className="relative flex h-16 w-16 items-center justify-center rounded-3xl border border-white/8 bg-zinc-800/40">
|
||||
<Bot className="h-8 w-8 text-cyan-200" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-lg text-zinc-200 font-semibold">ClawChat</div>
|
||||
<div className="text-sm mt-1 text-zinc-500">Envoie un message pour commencer</div>
|
||||
</div>
|
||||
)}
|
||||
{messages.filter(hasVisibleContent).map(msg => (
|
||||
<ChatMessageComponent key={msg.id} message={msg} />
|
||||
))}
|
||||
{showTyping && <TypingIndicator />}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
<ChatInput onSend={onSend} onAbort={onAbort} isGenerating={isGenerating} disabled={status !== 'connected'} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
266
src/components/ChatInput.tsx
Normal file
266
src/components/ChatInput.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Send, Square, Paperclip, X, FileText } from 'lucide-react';
|
||||
|
||||
interface FileAttachment {
|
||||
id: string;
|
||||
file: File;
|
||||
base64: string; // raw base64 (no data: prefix)
|
||||
mimeType: string;
|
||||
preview?: string; // data url thumbnail for images
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onSend: (text: string, attachments?: Array<{ mimeType: string; fileName: string; content: string }>) => void;
|
||||
onAbort: () => void;
|
||||
isGenerating: boolean;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
const MAX_BASE64_CHARS = 300 * 1024; // ~225KB real, well under 512KB WS limit (JSON overhead + base64 bloat)
|
||||
const MAX_IMAGE_PIXELS = 1280; // Max dimension for resize
|
||||
|
||||
function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const dataUrl = reader.result as string;
|
||||
const base64 = dataUrl.split(',')[1] || '';
|
||||
resolve(base64);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function compressImage(file: File, maxBase64Chars: number): Promise<{ base64: string; mimeType: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(file);
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
let { width, height } = img;
|
||||
// Downscale if needed
|
||||
if (width > MAX_IMAGE_PIXELS || height > MAX_IMAGE_PIXELS) {
|
||||
const ratio = Math.min(MAX_IMAGE_PIXELS / width, MAX_IMAGE_PIXELS / height);
|
||||
width = Math.round(width * ratio);
|
||||
height = Math.round(height * ratio);
|
||||
}
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
// Try JPEG at decreasing quality until base64 length is under limit
|
||||
for (let q = 0.85; q >= 0.2; q -= 0.05) {
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', q);
|
||||
const b64 = dataUrl.split(',')[1] || '';
|
||||
if (b64.length <= maxBase64Chars) {
|
||||
return resolve({ base64: b64, mimeType: 'image/jpeg' });
|
||||
}
|
||||
}
|
||||
// Last resort: further downscale
|
||||
const scale = 0.5;
|
||||
canvas.width = Math.round(width * scale);
|
||||
canvas.height = Math.round(height * scale);
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.3);
|
||||
resolve({ base64: dataUrl.split(',')[1] || '', mimeType: 'image/jpeg' });
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
|
||||
export function ChatInput({ onSend, onAbort, isGenerating, disabled }: Props) {
|
||||
const [text, setText] = useState('');
|
||||
const [files, setFiles] = useState<FileAttachment[]>([]);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 200) + 'px';
|
||||
}
|
||||
}, [text]);
|
||||
|
||||
const addFiles = useCallback(async (fileList: FileList | File[]) => {
|
||||
const newFiles: FileAttachment[] = [];
|
||||
for (const file of Array.from(fileList)) {
|
||||
if (file.size > 20 * 1024 * 1024) continue; // 20MB max
|
||||
const isImage = file.type.startsWith('image/');
|
||||
let base64: string;
|
||||
let mimeType: string;
|
||||
if (isImage) {
|
||||
// Compress images to fit WS payload limit
|
||||
const compressed = await compressImage(file, MAX_BASE64_CHARS);
|
||||
base64 = compressed.base64;
|
||||
mimeType = compressed.mimeType;
|
||||
} else {
|
||||
base64 = await fileToBase64(file);
|
||||
mimeType = file.type || 'application/octet-stream';
|
||||
}
|
||||
newFiles.push({
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
file,
|
||||
base64,
|
||||
mimeType,
|
||||
preview: isImage ? `data:${mimeType};base64,${base64}` : undefined,
|
||||
});
|
||||
}
|
||||
setFiles(prev => [...prev, ...newFiles]);
|
||||
}, []);
|
||||
|
||||
const removeFile = useCallback((id: string) => {
|
||||
setFiles(prev => prev.filter(f => f.id !== id));
|
||||
}, []);
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmed = text.trim();
|
||||
if ((!trimmed && files.length === 0) || disabled) return;
|
||||
const attachments = files.length > 0 ? files.map(f => ({
|
||||
mimeType: f.mimeType,
|
||||
fileName: f.file.name,
|
||||
content: f.base64,
|
||||
})) : undefined;
|
||||
onSend(trimmed || ' ', attachments);
|
||||
setText('');
|
||||
setFiles([]);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
const pastedFiles: File[] = [];
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile();
|
||||
if (file) pastedFiles.push(file);
|
||||
}
|
||||
}
|
||||
if (pastedFiles.length > 0) {
|
||||
e.preventDefault();
|
||||
addFiles(pastedFiles);
|
||||
}
|
||||
}, [addFiles]);
|
||||
|
||||
// Drag & drop handlers on the wrapper
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
}, []);
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
}, []);
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
if (e.dataTransfer.files.length > 0) {
|
||||
addFiles(e.dataTransfer.files);
|
||||
}
|
||||
}, [addFiles]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="border-t border-white/8 bg-[#1a1a20]/60 backdrop-blur-xl p-4"
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className={`rounded-3xl border bg-[#232329]/40 p-3 shadow-[0_0_0_1px_rgba(255,255,255,0.03)] transition-colors ${isDragOver ? 'border-cyan-400/40 bg-cyan-400/5' : 'border-white/8'}`}>
|
||||
{/* File previews */}
|
||||
{files.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-3 px-1">
|
||||
{files.map(f => (
|
||||
<div key={f.id} className="group relative flex items-center gap-2 rounded-2xl border border-white/8 bg-zinc-800/50 px-3 py-2 text-xs text-zinc-400">
|
||||
{f.preview ? (
|
||||
<img src={f.preview} alt="" className="h-8 w-8 rounded-lg object-cover" />
|
||||
) : (
|
||||
<FileText size={16} className="text-zinc-500 shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0 max-w-[120px]">
|
||||
<div className="truncate text-zinc-300">{f.file.name}</div>
|
||||
<div className="text-[10px] text-zinc-500">{formatSize(f.file.size)}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeFile(f.id)}
|
||||
className="absolute -top-1.5 -right-1.5 h-5 w-5 rounded-full bg-zinc-700 border border-white/10 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-500/80"
|
||||
>
|
||||
<X size={10} className="text-zinc-200" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-3">
|
||||
{/* File picker button */}
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={disabled}
|
||||
className="shrink-0 h-11 w-11 rounded-2xl border border-white/8 bg-zinc-800/30 flex items-center justify-center text-zinc-400 hover:text-cyan-300 hover:bg-white/5 transition-colors disabled:opacity-30"
|
||||
title="Joindre un fichier"
|
||||
>
|
||||
<Paperclip size={18} />
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => { if (e.target.files) addFiles(e.target.files); e.target.value = ''; }}
|
||||
accept="image/*,.pdf,.txt,.md,.json,.csv,.log,.py,.js,.ts,.tsx,.jsx,.html,.css,.yaml,.yml,.xml,.sql,.sh,.env,.toml"
|
||||
/>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
placeholder="Écris un message…"
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
className="flex-1 bg-transparent resize-none rounded-2xl border border-white/8 bg-zinc-900/35 px-4 py-3 text-sm text-zinc-300 placeholder:text-zinc-500 outline-none focus:ring-2 focus:ring-cyan-400/30 transition-all max-h-[200px]"
|
||||
/>
|
||||
{isGenerating ? (
|
||||
<button
|
||||
onClick={onAbort}
|
||||
className="shrink-0 h-11 px-4 rounded-2xl border border-red-500/20 bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Square size={16} />
|
||||
<span className="text-sm hidden sm:inline">Stop</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={(!text.trim() && files.length === 0) || disabled}
|
||||
className="shrink-0 h-11 px-5 rounded-2xl bg-gradient-to-r from-cyan-500/80 via-indigo-500/70 to-violet-500/80 text-zinc-900 font-semibold text-sm hover:opacity-90 shadow-[0_8px_24px_rgba(34,211,238,0.1)] disabled:opacity-30 disabled:shadow-none transition-all flex items-center gap-2"
|
||||
>
|
||||
<Send size={16} />
|
||||
<span className="hidden sm:inline">Envoyer</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
245
src/components/ChatMessage.tsx
Normal file
245
src/components/ChatMessage.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import type { ChatMessage as ChatMessageType, MessageBlock } from '../types';
|
||||
import { ThinkingBlock } from './ThinkingBlock';
|
||||
import { ToolCall } from './ToolCall';
|
||||
import { Bot, User, Wrench } from 'lucide-react';
|
||||
// ChevronDown, ChevronRight, Wrench still used by InternalOnlyMessage
|
||||
|
||||
function formatTimestamp(ts: number): string {
|
||||
const date = new Date(ts);
|
||||
const now = new Date();
|
||||
const time = date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
const isToday = date.toDateString() === now.toDateString();
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const isYesterday = date.toDateString() === yesterday.toDateString();
|
||||
|
||||
if (isToday) return time;
|
||||
if (isYesterday) return `Hier ${time}`;
|
||||
return `${date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })} ${time}`;
|
||||
}
|
||||
|
||||
/** Guess a language hint from content patterns */
|
||||
function guessLanguage(lines: string[]): string {
|
||||
const joined = lines.join('\n');
|
||||
if (/^import .+ from ['"]/.test(joined) || /^export (function|const|default|class|interface|type) /.test(joined) || /React\./.test(joined) || /<\w+[\s/>]/.test(joined) && /className=/.test(joined)) return 'tsx';
|
||||
if (/^(import|export|const|let|var|function|class|interface|type) /.test(joined) || /=>\s*{/.test(joined) || /: (string|number|boolean|any)\b/.test(joined)) return 'typescript';
|
||||
if (/^(use |fn |let mut |pub |impl |struct |enum |mod |crate::)/.test(joined) || /-> (Self|Result|Option|Vec|String|bool|i32|u32)/.test(joined)) return 'rust';
|
||||
if (/^(def |class |import |from .+ import |if __name__)/.test(joined) || /self\.\w+/.test(joined) && !/this\./.test(joined)) return 'python';
|
||||
if (/^\s*(server|location|upstream|proxy_pass|listen \d)/.test(joined)) return 'nginx';
|
||||
if (/^\[.*\]\s*$/.test(lines[0] || '') && /=/.test(joined)) return 'ini';
|
||||
if (/^(apiVersion|kind|metadata|spec):/.test(joined)) return 'yaml';
|
||||
if (/^\{/.test(joined.trim()) && /\}$/.test(joined.trim())) return 'json';
|
||||
if (/^#!\/(bin|usr)/.test(joined) || /^\s*(if \[|then|fi|echo |export |source )/.test(joined)) return 'bash';
|
||||
if (/^(<!DOCTYPE|<html|<div|<head|<body)/.test(joined)) return 'html';
|
||||
if (/^\.\w+\s*\{|^@(media|keyframes|import)/.test(joined)) return 'css';
|
||||
if (/^(SELECT|INSERT|CREATE|ALTER|DROP|UPDATE) /i.test(joined)) return 'sql';
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Detect if a block of lines looks like code */
|
||||
function looksLikeCode(lines: string[]): boolean {
|
||||
if (lines.length < 2) return false;
|
||||
let codeSignals = 0;
|
||||
const patterns = [
|
||||
/^(import|export|const|let|var|function|class|interface|type|enum|struct|fn|pub|use|def|from|module|package|namespace)\s/,
|
||||
/[{};]\s*$/,
|
||||
/^\s*(if|else|for|while|return|match|switch|case|break|continue)\b/,
|
||||
/^\s*(\/\/|#|\/\*|\*)/,
|
||||
/[├└│┬─]──/,
|
||||
/^\s+\w+\(.*\)/,
|
||||
/^\s*<\/?[A-Z]\w*/,
|
||||
/=>\s*[{(]/,
|
||||
/\.\w+\(.*\)\s*[;,]?\s*$/,
|
||||
];
|
||||
for (const line of lines) {
|
||||
for (const pat of patterns) {
|
||||
if (pat.test(line)) { codeSignals++; break; }
|
||||
}
|
||||
}
|
||||
return codeSignals / lines.length > 0.3;
|
||||
}
|
||||
|
||||
/** Auto-wrap unformatted code/terminal output in fenced code blocks */
|
||||
function autoFormatText(text: string): string {
|
||||
// Already has code fences — leave as-is
|
||||
if (text.includes('```')) return text;
|
||||
|
||||
const lines = text.split('\n');
|
||||
|
||||
// If most of the text looks like code, wrap the whole thing
|
||||
const nonEmptyLines = lines.filter(l => l.trim());
|
||||
if (nonEmptyLines.length >= 3 && looksLikeCode(nonEmptyLines)) {
|
||||
const lang = guessLanguage(nonEmptyLines);
|
||||
return '```' + lang + '\n' + text + '\n```';
|
||||
}
|
||||
|
||||
// Otherwise, detect contiguous code blocks within prose
|
||||
const result: string[] = [];
|
||||
let codeBuffer: string[] = [];
|
||||
|
||||
const flushCode = () => {
|
||||
if (codeBuffer.length >= 3 && looksLikeCode(codeBuffer)) {
|
||||
const lang = guessLanguage(codeBuffer);
|
||||
result.push('```' + lang);
|
||||
result.push(...codeBuffer);
|
||||
result.push('```');
|
||||
} else {
|
||||
result.push(...codeBuffer);
|
||||
}
|
||||
codeBuffer = [];
|
||||
};
|
||||
|
||||
const isCodeLine = (line: string): boolean => {
|
||||
return /^[\s]+(import|export|const|let|var|function|return|if|else|for)/.test(line)
|
||||
|| /[{};]\s*$/.test(line)
|
||||
|| /^\s*(\/\/|#)/.test(line)
|
||||
|| /[├└│┬─]──/.test(line)
|
||||
|| /^\s+\w+\(.*\)/.test(line);
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
if (isCodeLine(line) || (codeBuffer.length > 0 && (line.trim() === '' || /^\s{2,}/.test(line)))) {
|
||||
codeBuffer.push(line);
|
||||
} else {
|
||||
flushCode();
|
||||
result.push(line);
|
||||
}
|
||||
}
|
||||
flushCode();
|
||||
|
||||
return result.join('\n');
|
||||
}
|
||||
|
||||
function getTextBlocks(blocks: MessageBlock[]): MessageBlock[] {
|
||||
return blocks.filter(b => b.type === 'text' && b.text.trim());
|
||||
}
|
||||
|
||||
function getInternalBlocks(blocks: MessageBlock[]): MessageBlock[] {
|
||||
return blocks.filter(b => b.type === 'thinking' || b.type === 'tool_use' || b.type === 'tool_result');
|
||||
}
|
||||
|
||||
function renderTextBlocks(blocks: MessageBlock[]) {
|
||||
return getTextBlocks(blocks).map((block, i) => (
|
||||
<div key={`text-${i}`} className="markdown-body">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>
|
||||
{autoFormatText((block as any).text)}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
function renderInternalBlocks(blocks: MessageBlock[]) {
|
||||
const elements: React.ReactElement[] = [];
|
||||
const internals = getInternalBlocks(blocks);
|
||||
for (let i = 0; i < internals.length; i++) {
|
||||
const block = internals[i];
|
||||
if (block.type === 'thinking') {
|
||||
elements.push(<ThinkingBlock key={`int-${i}`} text={block.text} />);
|
||||
} else if (block.type === 'tool_use') {
|
||||
const nextBlock = internals[i + 1];
|
||||
const result = nextBlock?.type === 'tool_result' ? nextBlock.content : undefined;
|
||||
elements.push(<ToolCall key={`int-${i}`} name={block.name} input={block.input} result={result} />);
|
||||
if (result !== undefined) i++;
|
||||
} else if (block.type === 'tool_result') {
|
||||
elements.push(<ToolCall key={`int-${i}`} name={block.name || 'tool'} result={block.content} />);
|
||||
}
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
|
||||
function InternalsSummary({ blocks }: { blocks: MessageBlock[] }) {
|
||||
const internals = getInternalBlocks(blocks);
|
||||
if (internals.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-1">
|
||||
{renderInternalBlocks(blocks)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Message with ONLY internal blocks (no text for the user) */
|
||||
function InternalOnlyMessage({ message }: { message: ChatMessageType }) {
|
||||
return (
|
||||
<div className="animate-fade-in flex gap-3 px-4 py-1">
|
||||
<div className="shrink-0 mt-0.5 flex h-6 w-6 items-center justify-center rounded-xl border border-white/5 bg-zinc-800/30">
|
||||
<Wrench className="h-3 w-3 text-zinc-500" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="space-y-1">
|
||||
{renderInternalBlocks(message.blocks)}
|
||||
</div>
|
||||
{message.timestamp && (
|
||||
<div className="mt-0.5 text-[10px] text-zinc-600">
|
||||
{formatTimestamp(message.timestamp)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatMessageComponent({ message }: { message: ChatMessageType }) {
|
||||
const isUser = message.role === 'user';
|
||||
|
||||
// Assistant message with no text content — only tool calls / thinking
|
||||
if (!isUser && message.blocks.length > 0) {
|
||||
const textBlocks = getTextBlocks(message.blocks);
|
||||
const hasText = textBlocks.length > 0 || (message.isStreaming && message.content?.trim());
|
||||
if (!hasText && !message.isStreaming) {
|
||||
return <InternalOnlyMessage message={message} />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`animate-fade-in flex gap-3 px-4 py-2 ${isUser ? 'flex-row-reverse' : ''}`}>
|
||||
{/* Avatar */}
|
||||
<div className="shrink-0 mt-1 flex h-9 w-9 items-center justify-center rounded-2xl border border-white/8 bg-zinc-800/40">
|
||||
{isUser
|
||||
? <User className="h-4 w-4 text-violet-200" />
|
||||
: <Bot className="h-4 w-4 text-cyan-200" />
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Bubble */}
|
||||
<div className={`min-w-0 max-w-[80%] ${isUser ? 'text-right' : ''}`}>
|
||||
<div className={`inline-block text-left rounded-3xl px-4 py-3 text-sm leading-relaxed border border-white/8 max-w-full overflow-hidden ${
|
||||
isUser
|
||||
? 'bg-gradient-to-b from-zinc-800/70 to-zinc-900/70 text-zinc-200'
|
||||
: 'bg-zinc-800/40 text-zinc-300 shadow-[0_0_0_1px_rgba(255,255,255,0.03)]'
|
||||
}`}>
|
||||
{/* User-visible text */}
|
||||
{message.blocks.length > 0 ? renderTextBlocks(message.blocks) : (
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>
|
||||
{autoFormatText(message.content)}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Streaming dots */}
|
||||
{message.isStreaming && (
|
||||
<div className="flex gap-1 mt-2">
|
||||
<span className="bounce-dot w-1.5 h-1.5 rounded-full bg-gradient-to-r from-cyan-300/80 to-violet-400/80 inline-block" />
|
||||
<span className="bounce-dot w-1.5 h-1.5 rounded-full bg-gradient-to-r from-cyan-300/80 to-violet-400/80 inline-block" />
|
||||
<span className="bounce-dot w-1.5 h-1.5 rounded-full bg-gradient-to-r from-cyan-300/80 to-violet-400/80 inline-block" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tool calls & thinking (inline) */}
|
||||
{!isUser && <InternalsSummary blocks={message.blocks} />}
|
||||
</div>
|
||||
{message.timestamp && (
|
||||
<div className={`mt-1 text-[11px] text-zinc-500 ${isUser ? 'text-right pr-2' : 'pl-2'}`}>
|
||||
{formatTimestamp(message.timestamp)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
src/components/Header.tsx
Normal file
70
src/components/Header.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Menu, Bot, Sparkles } from 'lucide-react';
|
||||
import type { ConnectionStatus, Session } from '../types';
|
||||
|
||||
interface Props {
|
||||
status: ConnectionStatus;
|
||||
sessionKey: string;
|
||||
onToggleSidebar: () => void;
|
||||
activeSessionData?: Session;
|
||||
}
|
||||
|
||||
export function Header({ status, sessionKey, onToggleSidebar, activeSessionData }: Props) {
|
||||
const sessionLabel = sessionKey.split(':').pop() || sessionKey;
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="h-14 border-b border-white/8 bg-[#232329]/90 backdrop-blur-xl flex items-center px-4 gap-3 shrink-0">
|
||||
<button onClick={onToggleSidebar} className="lg:hidden p-2 rounded-2xl hover:bg-white/5 text-zinc-400 transition-colors">
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-2xl border border-white/8 bg-zinc-800/40">
|
||||
<Bot className="h-4 w-4 text-cyan-200" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-zinc-300 text-sm tracking-wide">ClawChat</span>
|
||||
<Sparkles className="h-3.5 w-3.5 text-cyan-300/60" />
|
||||
</div>
|
||||
<span className="text-xs text-zinc-500 truncate block">{sessionLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{status === 'connected' ? (
|
||||
<div className="flex items-center gap-2 rounded-2xl border border-white/8 bg-zinc-800/30 px-3 py-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-cyan-300/80 shadow-[0_0_12px_rgba(34,211,238,0.6)]" />
|
||||
<span className="text-xs text-zinc-300 hidden sm:inline">Connecté</span>
|
||||
</div>
|
||||
) : status === 'connecting' ? (
|
||||
<div className="flex items-center gap-2 rounded-2xl border border-white/8 bg-zinc-800/30 px-3 py-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-yellow-400/80 pulse-dot" />
|
||||
<span className="text-xs text-zinc-300 hidden sm:inline">Connexion…</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 rounded-2xl border border-white/8 bg-zinc-800/30 px-3 py-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-red-400/80" />
|
||||
<span className="text-xs text-zinc-300 hidden sm:inline">Déconnecté</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{(() => {
|
||||
const ctx = activeSessionData?.contextTokens;
|
||||
const total = activeSessionData?.totalTokens || 0;
|
||||
if (!ctx) return null;
|
||||
const pct = Math.min(100, (total / ctx) * 100);
|
||||
const barColor = pct > 95 ? 'bg-red-500' : pct > 80 ? 'bg-amber-500' : 'bg-gradient-to-r from-cyan-400 to-violet-500';
|
||||
return (
|
||||
<div className="px-4 py-1.5 bg-[#232329]/60 border-b border-white/8 flex items-center gap-3">
|
||||
<div className="flex-1 h-[5px] rounded-full bg-white/5 overflow-hidden">
|
||||
<div className={`h-full rounded-full transition-all duration-500 ${barColor}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-[11px] text-zinc-400 tabular-nums shrink-0 whitespace-nowrap">
|
||||
{(total / 1000).toFixed(1)}k / {(ctx / 1000).toFixed(0)}k tokens
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
91
src/components/Sidebar.tsx
Normal file
91
src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { MessageSquare, X, Sparkles } from 'lucide-react';
|
||||
import type { Session } from '../types';
|
||||
|
||||
interface Props {
|
||||
sessions: Session[];
|
||||
activeSession: string;
|
||||
onSwitch: (key: string) => void;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ sessions, activeSession, onSwitch, open, onClose }: Props) {
|
||||
return (
|
||||
<>
|
||||
{open && <div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 lg:hidden" onClick={onClose} />}
|
||||
<aside className={`fixed lg:relative top-0 left-0 h-full w-72 bg-[#1e1e24]/95 border-r border-white/8 z-50 transform transition-transform lg:translate-x-0 ${open ? 'translate-x-0' : '-translate-x-full'} flex flex-col backdrop-blur-xl`}>
|
||||
<div className="h-14 flex items-center justify-between px-4 border-b border-white/8">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-1.5 rounded-xl bg-gradient-to-r from-cyan-400/15 to-violet-500/15 blur-lg" />
|
||||
<div className="relative flex h-8 w-8 items-center justify-center rounded-xl border border-white/8 bg-zinc-800/50">
|
||||
<Sparkles className="h-4 w-4 text-cyan-200" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-semibold text-sm text-zinc-200 tracking-wide">Sessions</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="lg:hidden p-1.5 rounded-xl hover:bg-white/5 text-zinc-400 transition-colors">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto py-2 px-2">
|
||||
{sessions.length === 0 && (
|
||||
<div className="px-3 py-8 text-center text-zinc-500 text-sm">Aucune session</div>
|
||||
)}
|
||||
{sessions.map(s => {
|
||||
const isActive = s.key === activeSession;
|
||||
return (
|
||||
<button
|
||||
key={s.key}
|
||||
onClick={() => { onSwitch(s.key); onClose(); }}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-2xl text-left text-sm transition-all mb-1 ${
|
||||
isActive
|
||||
? 'bg-white/5 text-cyan-200 border border-white/8 shadow-[0_0_12px_rgba(34,211,238,0.08)]'
|
||||
: s.isActive
|
||||
? 'bg-violet-500/5 text-violet-200 border border-violet-500/15 shadow-[0_0_10px_rgba(168,85,247,0.06)]'
|
||||
: 'text-zinc-400 hover:bg-white/5 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="relative">
|
||||
<MessageSquare size={15} className={isActive ? 'text-cyan-300/70' : s.isActive ? 'text-violet-400/70' : ''} />
|
||||
{s.isActive && (
|
||||
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-violet-400 shadow-[0_0_8px_rgba(168,85,247,0.7)] animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="flex-1 truncate">{s.label || s.key}</span>
|
||||
{s.messageCount != null && (
|
||||
<span className={`text-[11px] px-2 py-0.5 rounded-full shrink-0 ${isActive ? 'bg-cyan-400/10 text-cyan-300' : 'bg-white/5 text-zinc-500'}`}>
|
||||
{s.messageCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(() => {
|
||||
if (!s.contextTokens) return null;
|
||||
const pct = Math.min(100, ((s.totalTokens || 0) / s.contextTokens) * 100);
|
||||
const barColor = pct > 95 ? 'bg-red-500' : pct > 80 ? 'bg-amber-500' : 'bg-gradient-to-r from-cyan-400 to-violet-500';
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<div className="flex-1 h-[3px] rounded-full bg-white/5 overflow-hidden">
|
||||
<div className={`h-full rounded-full ${barColor}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-[9px] text-zinc-500 tabular-nums shrink-0">{Math.round(pct)}%</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Footer glow dots */}
|
||||
<div className="px-4 py-3 border-t border-white/8 flex items-center justify-center gap-2">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-violet-300/60 shadow-[0_0_10px_rgba(168,85,247,0.5)]" />
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-cyan-300/60 shadow-[0_0_10px_rgba(34,211,238,0.5)]" />
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-indigo-300/50 shadow-[0_0_10px_rgba(99,102,241,0.4)]" />
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
24
src/components/ThinkingBlock.tsx
Normal file
24
src/components/ThinkingBlock.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronRight, ChevronDown, Brain } from 'lucide-react';
|
||||
|
||||
export function ThinkingBlock({ text }: { text: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="my-2">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="inline-flex items-center gap-1.5 rounded-2xl border border-white/8 bg-zinc-800/35 px-3 py-1.5 text-xs text-violet-300 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<Brain size={13} />
|
||||
<span className="font-medium">Réflexion</span>
|
||||
{open ? <ChevronDown size={12} className="ml-1 text-zinc-500" /> : <ChevronRight size={12} className="ml-1 text-zinc-500" />}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="mt-2 rounded-2xl border border-white/8 bg-zinc-800/25 p-3 text-sm italic text-zinc-400 whitespace-pre-wrap max-h-96 overflow-y-auto">
|
||||
{text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
202
src/components/ToolCall.tsx
Normal file
202
src/components/ToolCall.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { ChevronRight, ChevronDown, Terminal, Globe, Search, FileText, Wrench, Code, Database, Image, MessageSquare, Brain, Cpu } from 'lucide-react';
|
||||
import hljs from 'highlight.js/lib/common';
|
||||
|
||||
type ToolColor = { border: string; bg: string; text: string; icon: string; glow: string; expandBorder: string; expandBg: string };
|
||||
|
||||
const toolColors: Record<string, ToolColor> = {
|
||||
exec: { border: 'border-amber-500/30', bg: 'bg-amber-500/10', text: 'text-amber-300', icon: 'text-amber-400', glow: 'shadow-[0_0_8px_rgba(245,158,11,0.15)]', expandBorder: 'border-amber-500/20', expandBg: 'bg-amber-950/20' },
|
||||
web_search: { border: 'border-emerald-500/30', bg: 'bg-emerald-500/10', text: 'text-emerald-300', icon: 'text-emerald-400', glow: 'shadow-[0_0_8px_rgba(16,185,129,0.15)]', expandBorder: 'border-emerald-500/20', expandBg: 'bg-emerald-950/20' },
|
||||
web_fetch: { border: 'border-emerald-500/30', bg: 'bg-emerald-500/10', text: 'text-emerald-300', icon: 'text-emerald-400', glow: 'shadow-[0_0_8px_rgba(16,185,129,0.15)]', expandBorder: 'border-emerald-500/20', expandBg: 'bg-emerald-950/20' },
|
||||
Read: { border: 'border-sky-500/30', bg: 'bg-sky-500/10', text: 'text-sky-300', icon: 'text-sky-400', glow: 'shadow-[0_0_8px_rgba(14,165,233,0.15)]', expandBorder: 'border-sky-500/20', expandBg: 'bg-sky-950/20' },
|
||||
read: { border: 'border-sky-500/30', bg: 'bg-sky-500/10', text: 'text-sky-300', icon: 'text-sky-400', glow: 'shadow-[0_0_8px_rgba(14,165,233,0.15)]', expandBorder: 'border-sky-500/20', expandBg: 'bg-sky-950/20' },
|
||||
Write: { border: 'border-violet-500/30', bg: 'bg-violet-500/10', text: 'text-violet-300', icon: 'text-violet-400', glow: 'shadow-[0_0_8px_rgba(139,92,246,0.15)]', expandBorder: 'border-violet-500/20', expandBg: 'bg-violet-950/20' },
|
||||
write: { border: 'border-violet-500/30', bg: 'bg-violet-500/10', text: 'text-violet-300', icon: 'text-violet-400', glow: 'shadow-[0_0_8px_rgba(139,92,246,0.15)]', expandBorder: 'border-violet-500/20', expandBg: 'bg-violet-950/20' },
|
||||
Edit: { border: 'border-violet-500/30', bg: 'bg-violet-500/10', text: 'text-violet-300', icon: 'text-violet-400', glow: 'shadow-[0_0_8px_rgba(139,92,246,0.15)]', expandBorder: 'border-violet-500/20', expandBg: 'bg-violet-950/20' },
|
||||
edit: { border: 'border-violet-500/30', bg: 'bg-violet-500/10', text: 'text-violet-300', icon: 'text-violet-400', glow: 'shadow-[0_0_8px_rgba(139,92,246,0.15)]', expandBorder: 'border-violet-500/20', expandBg: 'bg-violet-950/20' },
|
||||
browser: { border: 'border-cyan-500/30', bg: 'bg-cyan-500/10', text: 'text-cyan-300', icon: 'text-cyan-400', glow: 'shadow-[0_0_8px_rgba(6,182,212,0.15)]', expandBorder: 'border-cyan-500/20', expandBg: 'bg-cyan-950/20' },
|
||||
image: { border: 'border-pink-500/30', bg: 'bg-pink-500/10', text: 'text-pink-300', icon: 'text-pink-400', glow: 'shadow-[0_0_8px_rgba(236,72,153,0.15)]', expandBorder: 'border-pink-500/20', expandBg: 'bg-pink-950/20' },
|
||||
message: { border: 'border-indigo-500/30', bg: 'bg-indigo-500/10', text: 'text-indigo-300', icon: 'text-indigo-400', glow: 'shadow-[0_0_8px_rgba(99,102,241,0.15)]', expandBorder: 'border-indigo-500/20', expandBg: 'bg-indigo-950/20' },
|
||||
memory_search: { border: 'border-rose-500/30', bg: 'bg-rose-500/10', text: 'text-rose-300', icon: 'text-rose-400', glow: 'shadow-[0_0_8px_rgba(244,63,94,0.15)]', expandBorder: 'border-rose-500/20', expandBg: 'bg-rose-950/20' },
|
||||
memory_get: { border: 'border-rose-500/30', bg: 'bg-rose-500/10', text: 'text-rose-300', icon: 'text-rose-400', glow: 'shadow-[0_0_8px_rgba(244,63,94,0.15)]', expandBorder: 'border-rose-500/20', expandBg: 'bg-rose-950/20' },
|
||||
cron: { border: 'border-orange-500/30', bg: 'bg-orange-500/10', text: 'text-orange-300', icon: 'text-orange-400', glow: 'shadow-[0_0_8px_rgba(249,115,22,0.15)]', expandBorder: 'border-orange-500/20', expandBg: 'bg-orange-950/20' },
|
||||
sessions_spawn: { border: 'border-teal-500/30', bg: 'bg-teal-500/10', text: 'text-teal-300', icon: 'text-teal-400', glow: 'shadow-[0_0_8px_rgba(20,184,166,0.15)]', expandBorder: 'border-teal-500/20', expandBg: 'bg-teal-950/20' },
|
||||
};
|
||||
|
||||
const defaultColor: ToolColor = { border: 'border-zinc-500/30', bg: 'bg-zinc-500/10', text: 'text-zinc-300', icon: 'text-zinc-400', glow: 'shadow-[0_0_8px_rgba(161,161,170,0.1)]', expandBorder: 'border-zinc-500/20', expandBg: 'bg-zinc-800/25' };
|
||||
|
||||
function getColor(name: string): ToolColor {
|
||||
return toolColors[name] || defaultColor;
|
||||
}
|
||||
|
||||
const toolIcons: Record<string, React.ReactNode> = {
|
||||
exec: <Terminal size={13} />,
|
||||
web_search: <Globe size={13} />,
|
||||
web_fetch: <Globe size={13} />,
|
||||
search: <Search size={13} />,
|
||||
Read: <FileText size={13} />,
|
||||
read: <FileText size={13} />,
|
||||
Write: <Code size={13} />,
|
||||
write: <Code size={13} />,
|
||||
Edit: <Code size={13} />,
|
||||
edit: <Code size={13} />,
|
||||
browser: <Globe size={13} />,
|
||||
image: <Image size={13} />,
|
||||
message: <MessageSquare size={13} />,
|
||||
database: <Database size={13} />,
|
||||
memory_search: <Brain size={13} />,
|
||||
memory_get: <Brain size={13} />,
|
||||
cron: <Cpu size={13} />,
|
||||
sessions_spawn: <Cpu size={13} />,
|
||||
};
|
||||
|
||||
function getToolIcon(name: string) {
|
||||
return toolIcons[name] || <Wrench size={13} />;
|
||||
}
|
||||
|
||||
function truncateResult(result: string, maxLen = 120): string {
|
||||
if (!result) return '';
|
||||
return truncate(result, maxLen);
|
||||
}
|
||||
|
||||
/** Check if text looks like structured content worth highlighting */
|
||||
function isStructured(text: string): boolean {
|
||||
const lines = text.split('\n');
|
||||
if (lines.length < 2) return false;
|
||||
const trimmed = text.trim();
|
||||
// JSON
|
||||
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) return true;
|
||||
// Code patterns
|
||||
const codePatterns = [/^(import|export|const|let|var|function|class|fn|pub|use|def|from)\s/, /[{};]\s*$/, /^\s*(if|else|for|while|return)\b/, /^\s*(\/\/|#|\/\*)/, /=>\s*[{(]/, /^\s*<\/?[A-Z]/];
|
||||
let hits = 0;
|
||||
for (const line of lines) {
|
||||
for (const pat of codePatterns) {
|
||||
if (pat.test(line)) { hits++; break; }
|
||||
}
|
||||
}
|
||||
if (hits / lines.length > 0.2) return true;
|
||||
// Terminal output (paths, errors, commands)
|
||||
const termPatterns = [/^[/~]/, /^\s*\$\s/, /^[A-Z_]+=/, /error|warning|failed/i, /\.\w{1,4}:\d+/, /├|└|│/];
|
||||
let termHits = 0;
|
||||
for (const line of lines) {
|
||||
for (const pat of termPatterns) {
|
||||
if (pat.test(line)) { termHits++; break; }
|
||||
}
|
||||
}
|
||||
return termHits / lines.length > 0.3;
|
||||
}
|
||||
|
||||
/** Highlight code using highlight.js, returns HTML string or null */
|
||||
function highlightCode(text: string): string | null {
|
||||
if (!text || !isStructured(text)) return null;
|
||||
try {
|
||||
const result = hljs.highlightAuto(text);
|
||||
return result.value;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function HighlightedPre({ text, className }: { text: string; className: string }) {
|
||||
const highlighted = useMemo(() => highlightCode(text), [text]);
|
||||
|
||||
if (highlighted) {
|
||||
return (
|
||||
<pre className={className}>
|
||||
<code className="hljs" dangerouslySetInnerHTML={{ __html: highlighted }} />
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
return <pre className={className}>{text}</pre>;
|
||||
}
|
||||
|
||||
function getContextHint(name: string, input: any): string | null {
|
||||
if (!input || typeof input !== 'object') return null;
|
||||
switch (name) {
|
||||
case 'exec':
|
||||
return input.command ? truncate(input.command, 60) : null;
|
||||
case 'Read': case 'read':
|
||||
case 'Write': case 'write':
|
||||
case 'Edit': case 'edit':
|
||||
return input.file_path || input.path || null;
|
||||
case 'web_search':
|
||||
return input.query ? truncate(input.query, 50) : null;
|
||||
case 'web_fetch':
|
||||
return input.url ? truncate(input.url, 60) : null;
|
||||
case 'browser':
|
||||
return input.action || null;
|
||||
case 'message':
|
||||
return input.action ? `${input.action}${input.target ? ' → ' + input.target : ''}` : null;
|
||||
case 'memory_search':
|
||||
return input.query ? truncate(input.query, 50) : null;
|
||||
case 'memory_get':
|
||||
return input.path || null;
|
||||
case 'cron':
|
||||
return input.action || null;
|
||||
case 'sessions_spawn':
|
||||
return input.task ? truncate(input.task, 50) : null;
|
||||
case 'image':
|
||||
return input.prompt ? truncate(input.prompt, 50) : null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
const clean = s.replace(/\n/g, ' ').trim();
|
||||
return clean.length <= max ? clean : clean.slice(0, max) + '…';
|
||||
}
|
||||
|
||||
export function ToolCall({ name, input, result }: { name: string; input?: any; result?: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const c = getColor(name);
|
||||
|
||||
const inputStr = input ? (typeof input === 'string' ? input : JSON.stringify(input, null, 2)) : '';
|
||||
const hint = getContextHint(name, input);
|
||||
|
||||
return (
|
||||
<div className="my-2">
|
||||
{/* Tool use badge */}
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-2xl border ${c.border} ${c.bg} ${c.glow} px-3 py-1.5 text-xs ${c.text} hover:brightness-125 transition-all max-w-full`}
|
||||
>
|
||||
<span className={c.icon}>{getToolIcon(name)}</span>
|
||||
<span className="font-mono font-semibold shrink-0">{name}</span>
|
||||
{hint && <span className="opacity-60 truncate font-mono text-[11px]">{hint}</span>}
|
||||
{open ? <ChevronDown size={12} className="ml-1 opacity-60" /> : <ChevronRight size={12} className="ml-1 opacity-60" />}
|
||||
</button>
|
||||
|
||||
{/* Result summary (always visible if result exists) */}
|
||||
{result && !open && (
|
||||
<div className="mt-1 text-[11px] text-zinc-400 pl-2 truncate max-w-md">
|
||||
{truncateResult(result)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expanded content */}
|
||||
{open && (
|
||||
<div className={`mt-2 rounded-2xl border ${c.expandBorder} ${c.expandBg} p-3 space-y-2`}>
|
||||
{inputStr && (
|
||||
<div>
|
||||
<div className={`text-[11px] ${c.text} opacity-70 mb-1 font-medium`}>Paramètres</div>
|
||||
<HighlightedPre
|
||||
text={inputStr}
|
||||
className="text-xs bg-[#1a1a20]/60 border border-white/5 p-2.5 rounded-xl overflow-x-auto text-zinc-300 font-mono"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{result && (
|
||||
<div>
|
||||
<div className={`text-[11px] ${c.text} opacity-70 mb-1 font-medium`}>Résultat</div>
|
||||
<HighlightedPre
|
||||
text={result}
|
||||
className="text-xs bg-[#1a1a20]/60 border border-white/5 p-2.5 rounded-xl overflow-x-auto text-zinc-300 max-h-64 overflow-y-auto font-mono"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
src/components/TypingIndicator.tsx
Normal file
19
src/components/TypingIndicator.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Bot } from 'lucide-react';
|
||||
|
||||
export function TypingIndicator() {
|
||||
return (
|
||||
<div className="animate-fade-in flex items-start gap-3 px-4 py-3">
|
||||
<div className="shrink-0 flex h-9 w-9 items-center justify-center rounded-2xl border border-white/10 bg-zinc-900/60">
|
||||
<Bot className="h-4 w-4 text-cyan-200" />
|
||||
</div>
|
||||
<div className="rounded-3xl border border-white/10 bg-zinc-900/55 px-4 py-3 shadow-[0_0_0_1px_rgba(255,255,255,0.03)]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="bounce-dot h-2 w-2 rounded-full bg-gradient-to-r from-cyan-300/80 to-violet-400/80" />
|
||||
<span className="bounce-dot h-2 w-2 rounded-full bg-gradient-to-r from-cyan-300/80 to-violet-400/80" />
|
||||
<span className="bounce-dot h-2 w-2 rounded-full bg-gradient-to-r from-cyan-300/80 to-violet-400/80" />
|
||||
<span className="ml-2 text-xs text-zinc-400">Thinking…</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user