Initial commit — ClawChat v1.0.0
This commit is contained in:
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_GATEWAY_WS_URL=ws://localhost:18789
|
||||
VITE_GATEWAY_TOKEN=your-gateway-token-here
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.local
|
||||
*.local
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
78
README.md
Normal 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.**
|
||||
|
||||

|
||||
|
||||
## ✨ 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
23
eslint.config.js
Normal 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
13
index.html
Normal 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
5954
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
package.json
Normal file
49
package.json
Normal 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
1
public/vite.svg
Normal 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
42
src/App.css
Normal 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
26
src/App.tsx
Normal 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
1
src/assets/react.svg
Normal 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
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>
|
||||
);
|
||||
}
|
||||
317
src/hooks/useGateway.ts
Normal file
317
src/hooks/useGateway.ts
Normal 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
86
src/index.css
Normal 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
128
src/lib/gateway.ts
Normal 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
15
src/lib/utils.ts
Normal 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
10
src/main.tsx
Normal 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
36
src/types/index.ts
Normal 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
28
tsconfig.app.json
Normal 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
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
tsconfig.node.json
Normal file
26
tsconfig.node.json
Normal 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
7
vite.config.ts
Normal 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()],
|
||||
})
|
||||
Reference in New Issue
Block a user