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