Initial commit — ClawChat v1.0.0

This commit is contained in:
Nicolas Varrot
2026-02-11 00:48:43 +00:00
commit 1f8ff9ae0a
30 changed files with 7862 additions and 0 deletions

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
VITE_GATEWAY_WS_URL=ws://localhost:18789
VITE_GATEWAY_TOKEN=your-gateway-token-here

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
dist/
.env
.env.local
*.local

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Nicolas Varrot
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

78
README.md Normal file
View File

@@ -0,0 +1,78 @@
# 🦞 ClawChat
**A sleek, dark-themed webchat UI for [OpenClaw](https://github.com/MarlBurroW/openclaw) — monitor sessions, stream responses, and inspect tool calls in real-time.**
![ClawChat Screenshot](https://via.placeholder.com/800x450?text=ClawChat+Screenshot)
## ✨ Features
- 🌑 **Dark neon theme** — easy on the eyes, built with Tailwind CSS v4
- 📊 **Token progress bars** — track token usage per session in real-time
- 🔧 **Tool call badges** — expandable panels with syntax-highlighted JSON
- 📋 **Session sidebar** — browse active sessions with live activity indicators
- 📝 **Markdown rendering** — full GFM support with code highlighting
- 📎 **File upload** — attach files to your messages
-**Streaming responses** — watch the AI think in real-time
## 🚀 Quick Start
### Prerequisites
- **Node.js 18+**
- **OpenClaw gateway** running and accessible
### Installation
```bash
git clone https://github.com/MarlBurroW/clawchat.git
cd clawchat
npm install
cp .env.example .env
```
Edit `.env` with your gateway details:
```env
VITE_GATEWAY_WS_URL=ws://localhost:18789
VITE_GATEWAY_TOKEN=your-gateway-token-here
```
Start the dev server:
```bash
npm run dev
```
### Production
```bash
npm run build
npx vite preview
```
Or serve the `dist/` folder with nginx, Caddy, or any static file server.
## ⚙️ Configuration
| Variable | Description | Default |
|---|---|---|
| `VITE_GATEWAY_WS_URL` | WebSocket URL of the OpenClaw gateway | `ws://<hostname>:18789` |
| `VITE_GATEWAY_TOKEN` | Authentication token for the gateway | *(required)* |
## 🛠 Tech Stack
- [React](https://react.dev/) 19
- [Vite](https://vite.dev/) 7
- [Tailwind CSS](https://tailwindcss.com/) v4
- [Radix UI](https://www.radix-ui.com/) primitives
- [highlight.js](https://highlightjs.org/) via rehype-highlight
- [Lucide React](https://lucide.dev/) icons
- [react-markdown](https://github.com/remarkjs/react-markdown) with GFM
## 📄 License
[MIT](LICENSE) © Nicolas Varrot
## 🔗 Links
- [OpenClaw](https://github.com/MarlBurroW/openclaw) — the AI agent platform ClawChat connects to

23
eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Marlbot Chat</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🤖</text></svg>" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5954
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

49
package.json Normal file
View File

@@ -0,0 +1,49 @@
{
"name": "clawchat",
"version": "1.0.0",
"description": "A sleek, dark-themed webchat UI for OpenClaw — monitor sessions, stream responses, and inspect tool calls in real-time.",
"type": "module",
"repository": {
"type": "git",
"url": "https://github.com/MarlBurroW/clawchat.git"
},
"license": "MIT",
"author": "Nicolas Varrot",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.18",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}

1
public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

42
src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

26
src/App.tsx Normal file
View File

@@ -0,0 +1,26 @@
import { useState } from 'react';
import { useGateway } from './hooks/useGateway';
import { Header } from './components/Header';
import { Sidebar } from './components/Sidebar';
import { Chat } from './components/Chat';
export default function App() {
const { status, messages, sessions, activeSession, isGenerating, sendMessage, abort, switchSession } = useGateway();
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="h-dvh flex bg-[#1e1e24] text-zinc-300 bg-[radial-gradient(ellipse_at_top,rgba(255,255,255,0.02),transparent_50%),radial_gradient(ellipse_at_bottom_right,rgba(99,102,241,0.04),transparent_50%)]">
<Sidebar
sessions={sessions}
activeSession={activeSession}
onSwitch={switchSession}
open={sidebarOpen}
onClose={() => setSidebarOpen(false)}
/>
<div className="flex-1 flex flex-col min-w-0">
<Header status={status} sessionKey={activeSession} onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} activeSessionData={sessions.find(s => s.key === activeSession)} />
<Chat messages={messages} isGenerating={isGenerating} status={status} onSend={sendMessage} onAbort={abort} />
</div>
</div>
);
}

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

70
src/components/Chat.tsx Normal file
View 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>
);
}

View 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>
);
}

View 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
View 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>
);
})()}
</>
);
}

View 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>
</>
);
}

View 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
View 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>
);
}

View 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>
);
}

317
src/hooks/useGateway.ts Normal file
View File

@@ -0,0 +1,317 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { GatewayClient } from '../lib/gateway';
import { genIdempotencyKey } from '../lib/utils';
import type { ChatMessage, MessageBlock, ConnectionStatus, Session } from '../types';
function extractText(message: any): string {
if (!message) return '';
const content = message.content;
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content
.filter((b: any) => b.type === 'text' && typeof b.text === 'string')
.map((b: any) => b.text)
.join('\n');
}
return '';
}
export function useGateway() {
const clientRef = useRef<GatewayClient | null>(null);
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [sessions, setSessions] = useState<Session[]>([]);
const [activeSession, setActiveSession] = useState('agent:main:main');
const [isGenerating, setIsGenerating] = useState(false);
const messagesRef = useRef(messages);
messagesRef.current = messages;
const activeSessionRef = useRef(activeSession);
activeSessionRef.current = activeSession;
const currentRunIdRef = useRef<string | null>(null);
const [activeSessions, setActiveSessions] = useState<Set<string>>(new Set());
useEffect(() => {
const client = new GatewayClient();
clientRef.current = client;
client.onStatus((s) => {
setStatus(s);
if (s === 'connected') {
loadSessions();
loadHistory(activeSessionRef.current);
}
});
client.onEvent((event, payload) => {
if (event === 'agent') {
// Tool stream events
handleAgentEvent(payload);
return;
}
if (event !== 'chat') return;
const { state, runId, message, errorMessage, sessionKey: evtSession } = payload;
// Track active/inactive sessions globally
if (evtSession) {
if (state === 'delta') {
setActiveSessions(prev => {
if (prev.has(evtSession)) return prev;
const next = new Set(prev);
next.add(evtSession);
return next;
});
} else if (state === 'final' || state === 'error' || state === 'aborted') {
setActiveSessions(prev => {
if (!prev.has(evtSession)) return prev;
const next = new Set(prev);
next.delete(evtSession);
return next;
});
}
}
if (evtSession !== activeSessionRef.current) return;
if (state === 'delta') {
const text = extractText(message);
currentRunIdRef.current = runId;
setMessages(prev => {
const last = prev[prev.length - 1];
if (last && last.role === 'assistant' && last.isStreaming && last.runId === runId) {
// Update text block but preserve tool/thinking blocks
const updated = { ...last };
updated.content = text;
const nonTextBlocks = updated.blocks.filter(b => b.type !== 'text');
updated.blocks = [...nonTextBlocks, { type: 'text' as const, text }];
return [...prev.slice(0, -1), updated];
}
// Create new streaming message
const msg: ChatMessage = {
id: runId + '-' + Date.now(),
role: 'assistant',
content: text,
timestamp: Date.now(),
blocks: [{ type: 'text', text }],
isStreaming: true,
runId,
};
return [...prev, msg];
});
} else if (state === 'final') {
currentRunIdRef.current = null;
setIsGenerating(false);
// Reload full history to get the proper final messages with tool calls etc.
loadHistory(activeSessionRef.current);
} else if (state === 'error') {
currentRunIdRef.current = null;
setIsGenerating(false);
setMessages(prev => {
const last = prev[prev.length - 1];
if (last && last.role === 'assistant' && last.isStreaming && last.runId === runId) {
return [...prev.slice(0, -1), { ...last, isStreaming: false }];
}
return [...prev, {
id: 'error-' + Date.now(),
role: 'assistant' as const,
content: `Error: ${errorMessage || 'unknown error'}`,
timestamp: Date.now(),
blocks: [{ type: 'text' as const, text: `Error: ${errorMessage || 'unknown error'}` }],
}];
});
} else if (state === 'aborted') {
currentRunIdRef.current = null;
setIsGenerating(false);
setMessages(prev => {
const last = prev[prev.length - 1];
if (last && last.role === 'assistant' && last.isStreaming) {
return [...prev.slice(0, -1), { ...last, isStreaming: false }];
}
return prev;
});
}
});
client.connect();
return () => client.disconnect();
}, []);
const handleAgentEvent = useCallback((payload: any) => {
// Handle tool stream events from agent stream
if (payload?.stream !== 'tool') return;
const data = payload.data ?? {};
const phase = data.phase;
const toolCallId = data.toolCallId;
const name = data.name || 'tool';
if (!toolCallId) return;
setMessages(prev => {
const last = prev[prev.length - 1];
if (!last || last.role !== 'assistant' || !last.isStreaming) return prev;
const updated = { ...last, blocks: [...last.blocks] };
if (phase === 'start') {
updated.blocks.push({
type: 'tool_use' as const,
name,
input: data.args,
id: toolCallId,
});
} else if (phase === 'result') {
const result = typeof data.result === 'string' ? data.result : JSON.stringify(data.result, null, 2);
updated.blocks.push({
type: 'tool_result' as const,
content: result?.slice(0, 500) || '',
toolUseId: toolCallId,
name,
});
}
return [...prev.slice(0, -1), updated];
});
}, []);
const loadSessions = useCallback(async () => {
try {
const res = await clientRef.current?.send('sessions.list', {});
if (res?.sessions) {
setSessions(res.sessions.map((s: any) => ({
key: s.key || s.sessionKey,
label: s.label || s.key || s.sessionKey,
messageCount: s.messageCount,
totalTokens: s.totalTokens,
contextTokens: s.contextTokens,
inputTokens: s.inputTokens,
outputTokens: s.outputTokens,
})));
}
} catch {}
}, []);
const loadHistory = useCallback(async (sessionKey: string) => {
try {
const res = await clientRef.current?.send('chat.history', { sessionKey, limit: 100 });
if (res?.messages) {
const rawMsgs: any[] = res.messages;
const msgs: ChatMessage[] = rawMsgs.map((m: any, i: number) => {
const blocks: MessageBlock[] = [];
if (m.content) {
if (Array.isArray(m.content)) {
for (const block of m.content) {
if (block.type === 'text') blocks.push({ type: 'text', text: block.text });
else if (block.type === 'thinking') blocks.push({ type: 'thinking', text: block.thinking || block.text || '' });
// Anthropic format
else if (block.type === 'tool_use') blocks.push({ type: 'tool_use', name: block.name, input: block.input, id: block.id });
else if (block.type === 'tool_result') blocks.push({ type: 'tool_result', content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content, null, 2), toolUseId: block.tool_use_id });
// OpenClaw gateway format (toolCall / toolResult)
else if (block.type === 'toolCall') blocks.push({ type: 'tool_use', name: block.name, input: block.arguments || block.input, id: block.id });
else if (block.type === 'toolResult') blocks.push({ type: 'tool_result', content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content, null, 2), toolUseId: block.toolCallId || block.tool_use_id, name: block.name });
}
} else if (typeof m.content === 'string') {
blocks.push({ type: 'text', text: m.content });
}
}
// Map gateway roles to our simplified roles
const role: 'user' | 'assistant' = m.role === 'user' ? 'user' : 'assistant';
// toolResult role messages: convert text blocks to tool_result blocks, then merge into previous assistant
if (m.role === 'toolResult') {
const toolBlocks: MessageBlock[] = blocks.map(b => {
if (b.type === 'text') {
return { type: 'tool_result' as const, content: b.text, toolUseId: m.toolCallId };
}
return b;
});
return {
id: m.id || `hist-${i}`,
role: 'assistant' as const,
content: '',
timestamp: m.timestamp || Date.now(),
blocks: toolBlocks,
isToolResult: true,
};
}
return {
id: m.id || `hist-${i}`,
role,
content: blocks.filter(b => b.type === 'text').map(b => (b as any).text).join(''),
timestamp: m.timestamp || Date.now(),
blocks,
};
});
// Merge toolResult messages into their preceding assistant message
const merged: ChatMessage[] = [];
for (const msg of msgs) {
if ((msg as any).isToolResult && merged.length > 0 && merged[merged.length - 1].role === 'assistant') {
merged[merged.length - 1] = {
...merged[merged.length - 1],
blocks: [...merged[merged.length - 1].blocks, ...msg.blocks],
};
} else if ((msg as any).isToolResult) {
// Orphan toolResult — skip or show as assistant
// skip it
} else {
merged.push(msg);
}
}
setMessages(merged);
}
} catch {}
}, []);
const sendMessage = useCallback(async (text: string, attachments?: Array<{ mimeType: string; fileName: string; content: string }>) => {
const userMsg: ChatMessage = {
id: 'user-' + Date.now(),
role: 'user',
content: text,
timestamp: Date.now(),
blocks: [{ type: 'text', text }],
};
setMessages(prev => [...prev, userMsg]);
setIsGenerating(true);
try {
await clientRef.current?.send('chat.send', {
sessionKey: activeSessionRef.current,
message: text,
deliver: false,
idempotencyKey: genIdempotencyKey(),
...(attachments && attachments.length > 0 ? { attachments } : {}),
});
} catch (e) {
setIsGenerating(false);
}
}, []);
const abort = useCallback(async () => {
try {
await clientRef.current?.send('chat.abort', { sessionKey: activeSessionRef.current });
} catch {}
setIsGenerating(false);
}, []);
const switchSession = useCallback((key: string) => {
setActiveSession(key);
activeSessionRef.current = key;
setMessages([]);
loadHistory(key);
}, [loadHistory]);
// Periodic session refresh every 30s
useEffect(() => {
if (status !== 'connected') return;
const interval = setInterval(loadSessions, 30000);
return () => clearInterval(interval);
}, [status, loadSessions]);
// Merge active state into sessions
const enrichedSessions = sessions.map(s => ({
...s,
isActive: activeSessions.has(s.key),
}));
return { status, messages, sessions: enrichedSessions, activeSession, isGenerating, sendMessage, abort, switchSession, loadSessions };
}

86
src/index.css Normal file
View File

@@ -0,0 +1,86 @@
@import "tailwindcss";
@import "highlight.js/styles/base16/material-palenight.min.css";
* {
scrollbar-width: thin;
scrollbar-color: #52525b #27272a;
}
body {
margin: 0;
background: #1e1e24;
color: #d4d4d8;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
@keyframes bounce-dot {
0%, 80%, 100% { transform: translateY(0); opacity: 0.45; }
40% { transform: translateY(-4px); opacity: 1; }
}
.animate-fade-in {
animation: fade-in 0.3s ease-out;
}
.pulse-dot {
animation: pulse-dot 2s ease-in-out infinite;
}
.bounce-dot {
animation: bounce-dot 0.9s infinite ease-in-out;
}
.bounce-dot:nth-child(1) { animation-delay: 0s; }
.bounce-dot:nth-child(2) { animation-delay: 0.12s; }
.bounce-dot:nth-child(3) { animation-delay: 0.24s; }
/* Markdown styles */
.markdown-body pre {
background: #1a1a20 !important;
border: 1px solid rgba(255,255,255,0.06);
border-radius: 12px;
padding: 16px;
overflow-x: auto;
margin: 8px 0;
font-size: 0.82em;
line-height: 1.6;
max-width: 100%;
box-sizing: border-box;
}
.markdown-body pre code {
white-space: pre;
word-break: normal;
overflow-wrap: normal;
}
.markdown-body code {
background: rgba(255,255,255,0.07);
padding: 2px 6px;
border-radius: 6px;
font-size: 0.85em;
}
/* Override highlight.js theme bg to match */
.hljs {
background: #1a1a20 !important;
}
.markdown-body p { margin: 4px 0; }
.markdown-body ul, .markdown-body ol { margin: 4px 0; padding-left: 20px; }
.markdown-body blockquote { border-left: 3px solid rgba(34,211,238,0.4); padding-left: 12px; margin: 8px 0; opacity: 0.8; }
.markdown-body h1, .markdown-body h2, .markdown-body h3 { margin: 12px 0 4px; }
.markdown-body a { color: #67e8f9; text-decoration: underline; }
.markdown-body table { border-collapse: collapse; margin: 8px 0; }
.markdown-body th, .markdown-body td { border: 1px solid rgba(255,255,255,0.08); padding: 6px 12px; }
.markdown-body th { background: rgba(255,255,255,0.04); }
.markdown-body img { max-width: 100%; border-radius: 8px; }

128
src/lib/gateway.ts Normal file
View File

@@ -0,0 +1,128 @@
import { genId } from './utils';
export type GatewayEventHandler = (event: string, payload: any) => void;
export type GatewayResponseHandler = (id: string, ok: boolean, payload: any) => void;
const WS_URL = import.meta.env.VITE_GATEWAY_WS_URL || `ws://${window.location.hostname}:18789`;
const AUTH_TOKEN = import.meta.env.VITE_GATEWAY_TOKEN || '';
export class GatewayClient {
private ws: WebSocket | null = null;
private pendingRequests = new Map<string, { resolve: (v: any) => void; reject: (e: any) => void }>();
private eventHandlers: GatewayEventHandler[] = [];
private _onStatus: (s: 'disconnected' | 'connecting' | 'connected') => void = () => {};
private reconnectTimer: any = null;
private connected = false;
onStatus(fn: (s: 'disconnected' | 'connecting' | 'connected') => void) {
this._onStatus = fn;
}
onEvent(fn: GatewayEventHandler) {
this.eventHandlers.push(fn);
return () => { this.eventHandlers = this.eventHandlers.filter(h => h !== fn); };
}
connect() {
if (this.ws) return;
this._onStatus('connecting');
this.ws = new WebSocket(WS_URL);
this.ws.onopen = () => { console.log('[GW] WS open'); };
this.ws.onmessage = (ev) => {
let msg: any;
try { msg = JSON.parse(ev.data); } catch { console.log('[GW] parse error', ev.data); return; }
console.log('[GW] msg:', msg.type, msg.event || msg.id || '', msg.ok);
if (msg.type === 'event') {
if (msg.event === 'connect.challenge') {
this.handleChallenge();
} else {
for (const h of this.eventHandlers) h(msg.event, msg.payload);
}
} else if (msg.type === 'res') {
const pending = this.pendingRequests.get(msg.id);
if (pending) {
this.pendingRequests.delete(msg.id);
if (msg.ok) pending.resolve(msg.payload);
else pending.reject(msg.payload || msg.error);
}
}
};
this.ws.onclose = (ev) => {
console.log('[GW] WS close:', ev.code, ev.reason);
this.ws = null;
this.connected = false;
this._onStatus('disconnected');
this.pendingRequests.forEach(p => p.reject(new Error('disconnected')));
this.pendingRequests.clear();
this.scheduleReconnect();
};
this.ws.onerror = (e) => { console.log('[GW] WS error', e); };
}
private handleChallenge() {
const id = genId('connect');
this.request(id, 'connect', {
minProtocol: 3,
maxProtocol: 3,
client: { id: 'webchat', version: '1.0.0', platform: 'web', mode: 'webchat' },
role: 'operator',
scopes: ['operator.read', 'operator.write'],
caps: [],
commands: [],
permissions: {},
auth: { token: AUTH_TOKEN },
locale: 'fr-FR',
userAgent: 'clawchat/1.0.0',
}).then((res) => {
console.log('[GW] connected!', res);
this.connected = true;
this._onStatus('connected');
}).catch((err) => {
console.log('[GW] connect failed:', err);
this.disconnect();
});
}
private scheduleReconnect() {
if (this.reconnectTimer) return;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
}, 3000);
}
disconnect() {
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
if (this.ws) { this.ws.close(); this.ws = null; }
this.connected = false;
this._onStatus('disconnected');
}
request(id: string, method: string, params: any): Promise<any> {
return new Promise((resolve, reject) => {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
return reject(new Error('not connected'));
}
this.pendingRequests.set(id, { resolve, reject });
this.ws.send(JSON.stringify({ type: 'req', id, method, params }));
setTimeout(() => {
if (this.pendingRequests.has(id)) {
this.pendingRequests.delete(id);
reject(new Error('timeout'));
}
}, 30000);
});
}
async send(method: string, params: any): Promise<any> {
const id = genId('req');
return this.request(id, method, params);
}
get isConnected() { return this.connected; }
}

15
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,15 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
let counter = 0;
export function genId(prefix = 'req') {
return `${prefix}-${++counter}-${Date.now()}`;
}
export function genIdempotencyKey() {
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

36
src/types/index.ts Normal file
View File

@@ -0,0 +1,36 @@
export interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: number;
blocks: MessageBlock[];
isStreaming?: boolean;
runId?: string;
}
export type MessageBlock =
| { type: 'text'; text: string }
| { type: 'thinking'; text: string }
| { type: 'tool_use'; name: string; input: any; id?: string }
| { type: 'tool_result'; content: string; toolUseId?: string; name?: string };
export interface Session {
key: string;
label?: string;
messageCount?: number;
isActive?: boolean;
totalTokens?: number;
contextTokens?: number;
inputTokens?: number;
outputTokens?: number;
}
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected';
export interface GatewayState {
status: ConnectionStatus;
sessions: Session[];
activeSession: string;
messages: ChatMessage[];
isGenerating: boolean;
}

28
tsconfig.app.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

7
vite.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
})