Compare commits
4 Commits
4e23828dae
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f3c85901e | |||
| ef382274fe | |||
| 17aec9a721 | |||
| 2d3c944699 |
+13
-5
@@ -7,7 +7,10 @@
|
||||
# ollama.com. Joignable uniquement sur le réseau Docker interne.
|
||||
OLLAMA_BASE_URL=http://ollama:11434
|
||||
OLLAMA_API_KEY= # SECRET — clé API Ollama cloud
|
||||
OLLAMA_MODEL=qwen3:cloud # modèle cloud (suffixe :cloud), tool-calling
|
||||
# Modèle cloud (suffixe :cloud), doit supporter le tool-calling. Choisir un tag
|
||||
# VALIDE listé sur ollama.com (ex. qwen3-coder:480b-cloud, gpt-oss:120b-cloud).
|
||||
# Requiert Ollama >= 0.30 (image bumpée). Vérifier : `ollama pull <tag>`.
|
||||
OLLAMA_MODEL=qwen3-coder:480b-cloud
|
||||
|
||||
# ── MCP n8n : NATIF (instance n8n ≥ 2.18.4) ────────────────────────────
|
||||
# Pas de conteneur dédié : n8n sert son propre MCP. Activer côté instance
|
||||
@@ -19,13 +22,18 @@ OLLAMA_MODEL=qwen3:cloud # modèle cloud (suffixe :cloud), tool-call
|
||||
MCP_N8N_URL=http://n8n:5678/mcp-server/http # ou https://<n8n-public>/mcp-server/http
|
||||
MCP_N8N_AUTH_TOKEN= # SECRET — "MCP Access Token" n8n (Bearer)
|
||||
|
||||
# ── MCP Portainer (portainer/portainer-mcp) ────────────────────────────
|
||||
PORTAINER_URL=https://portainer:9443 # interne uniquement
|
||||
PORTAINER_MCP_AUTH_TOKEN= # SECRET — token Portainer à portée RESTREINTE
|
||||
# ── MCP Portainer (passerelle HTTP portainer/portainer-mcp) ────────────
|
||||
PORTAINER_URL=http://portainer:9000 # API Portainer, interne (même hôte)
|
||||
# Secret de PASSERELLE (front-gate) : même valeur côté backend et côté serveur
|
||||
# MCP. Envoyé en Authorization: Bearer. N'autorise que l'accès à la passerelle.
|
||||
PORTAINER_MCP_AUTH_TOKEN= # SECRET — secret de passerelle (au choix)
|
||||
# Clé API Portainer de l'utilisateur `chlova` (RESTREINTE) : envoyée en header
|
||||
# X-Portainer-API-Key. C'est elle qui cloisonne l'accès réel de CHLOVA.
|
||||
PORTAINER_API_KEY= # SECRET — Access token Portainer de chlova
|
||||
# Phase 1 : DOIT rester true (le boot échoue sinon). Phase 2 : peut passer false
|
||||
# pour autoriser les écritures Portainer (sous gatekeeper + review).
|
||||
PORTAINER_READ_ONLY=true
|
||||
MCP_PORTAINER_URL=http://mcp-portainer:3000
|
||||
MCP_PORTAINER_URL=http://mcp-portainer:17717/mcp
|
||||
|
||||
# ── Surface Telegram (OPTIONNELLE) ─────────────────────────────────────
|
||||
# Si vide, la surface Telegram n'est pas démarrée. Le backend exige alors une
|
||||
|
||||
@@ -6,6 +6,54 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.35.0] — 2026-06-23 — UI responsive (mobile)
|
||||
### Fixed
|
||||
- **Header** : `flex-wrap` + paddings/marges réduits sur petit écran, « · N outils »
|
||||
masqué sous `sm` (ne déborde plus).
|
||||
- **Barre chat** : `input` en `min-w-0`, boutons `shrink-0` compactés
|
||||
(`px-2.5 sm:px-3`), label « Envoyer » masqué sous `sm` → tient sur mobile.
|
||||
- **Review** : actions Approuver/Refuser pleine largeur empilées sous `sm`.
|
||||
- Web : typecheck + build OK.
|
||||
|
||||
## [0.34.2] — 2026-06-23 — bump Ollama 0.30.10 (modèles :cloud)
|
||||
### Fixed
|
||||
- `ollama/ollama:0.6.8` → **`0.30.10`** : 0.6.8 refuse les modèles `:cloud`
|
||||
(HTTP 412 « requires a newer version of Ollama »). Bump dans
|
||||
`docker-compose.prod.yml` et `docker-compose.yml`.
|
||||
### Changed
|
||||
- `.env.example` : `OLLAMA_MODEL` → choisir un tag `:cloud` valide (ex.
|
||||
`qwen3-coder:480b-cloud`), note sur Ollama >= 0.30.
|
||||
|
||||
## [0.34.1] — 2026-06-23 — stack chlova déployé (placeholders), notes runbook
|
||||
### Added
|
||||
- Stack `chlova` **déployé** en GitOps sur `local` (id 10) : `ollama` running,
|
||||
`backend` + `mcp-portainer` en attente des vraies valeurs (placeholders).
|
||||
- `docs/deploy.md` : note « stack pré-créé » (éditer les 7 vars → Update) et
|
||||
contrainte `PORTAINER_MCP_AUTH_TOKEN` **≥ 32 caractères** (sinon le serveur
|
||||
MCP Portainer refuse de démarrer — constaté au déploiement).
|
||||
|
||||
## [0.34.0] — 2026-06-23 — intégration réelle passerelle MCP Portainer
|
||||
### Fixed
|
||||
- **Image MCP Portainer** : `portainer/portainer-mcp:0.6.0` (tag inexistant)
|
||||
→ **`2.42.6`** (confirmé Docker Hub). Découvert au déploiement réel (pull 404).
|
||||
- **Modèle d'auth de la passerelle MCP Portainer** corrigé : le serveur HTTP
|
||||
(port **17717**, endpoint **`/mcp`**) attend `Authorization: Bearer <secret de
|
||||
passerelle>` **et** `X-Portainer-API-Key: <clé API restreinte>`. Le backend
|
||||
n'envoyait que le Bearer.
|
||||
### Added
|
||||
- `config.portainerApiKey` (`PORTAINER_API_KEY`, SECRET) : clé API Portainer
|
||||
restreinte de `chlova`, envoyée en `X-Portainer-API-Key` — c'est elle qui
|
||||
cloisonne l'accès. `PORTAINER_MCP_AUTH_TOKEN` devient le secret de passerelle.
|
||||
- `McpServerConfig.extraHeaders` : headers additionnels par serveur MCP.
|
||||
### Changed
|
||||
- `docker-compose.prod.yml` : `mcp-portainer` reconfiguré (2.42.6, :17717,
|
||||
`PORTAINER_MCP_ALLOWED_HOSTS`, plaintext HTTP interne, `PORTAINER_TLS_VERIFY=0`,
|
||||
`PORTAINER_READ_ONLY`). **`socket-proxy` supprimé** (la passerelle parle à
|
||||
l'API Portainer, plus aucun socket Docker monté = surface réduite).
|
||||
`MCP_PORTAINER_URL` → `:17717/mcp`. Backend reçoit `PORTAINER_API_KEY`.
|
||||
- `.env.example` + `docs/deploy.md` (§3a, tableau variables) mis à jour.
|
||||
- Typecheck vert, 78 tests verts.
|
||||
|
||||
## [0.33.0] — 2026-06-23 — infra locale déployée (gitea + n8n-chlova)
|
||||
### Added
|
||||
- **`infra/gitea/docker-compose.yml`** : serveur git Gitea 1.26.4 sur l'hôte
|
||||
|
||||
+18
-6
@@ -58,13 +58,17 @@ git push gitea main # auth : <admin> + token de l'étape 1.3
|
||||
> Le MCP Portainer n'expose pas la gestion users/tokens : étapes UI. Principe :
|
||||
> CHLOVA n'accède qu'à ses ressources, tokens à portée minimale.
|
||||
|
||||
### 3a. Portainer — user `chlova` + token
|
||||
### 3a. Portainer — user `chlova` + clé API + secret de passerelle
|
||||
1. **Users → Add user** : `chlova`, non-admin.
|
||||
2. **Environments → local → Access** : rôle le plus bas suffisant. *(CE = RBAC
|
||||
par environnement, pas par stack ; le vrai verrou Phase 1 reste
|
||||
`PORTAINER_READ_ONLY=true`.)*
|
||||
par environnement, pas par stack ; verrous additionnels :
|
||||
`PORTAINER_READ_ONLY=true` côté serveur MCP **et** la clé API étant celle de
|
||||
`chlova`, l'accès réel est borné à ce que `chlova` peut voir.)*
|
||||
3. Connecté en `chlova` → **My account → Access tokens → Add token** →
|
||||
`PORTAINER_MCP_AUTH_TOKEN`.
|
||||
`PORTAINER_API_KEY` (envoyé en `X-Portainer-API-Key`, cloisonne l'accès).
|
||||
4. **`PORTAINER_MCP_AUTH_TOKEN`** = secret de **passerelle** au choix, **≥ 32
|
||||
caractères** (`openssl rand -hex 32`) sinon le serveur MCP refuse de démarrer.
|
||||
Même valeur dans le stack `mcp-portainer` et côté backend.
|
||||
|
||||
### 3b. n8n-chlova — MCP token
|
||||
1. `https://n8n-chlova.pogoo.app` → créer le compte propriétaire.
|
||||
@@ -96,13 +100,21 @@ l'opérateur, jamais par l'agent).
|
||||
| `OLLAMA_API_KEY` | clé Ollama cloud | **oui** |
|
||||
| `OLLAMA_MODEL` | `qwen3:cloud` | non |
|
||||
| `MCP_N8N_AUTH_TOKEN` | token MCP n8n-chlova (§3b) | **oui** |
|
||||
| `PORTAINER_MCP_AUTH_TOKEN` | token user chlova (§3a) | **oui** |
|
||||
| `PORTAINER_MCP_AUTH_TOKEN` | secret de passerelle (§3a.4) | **oui** |
|
||||
| `PORTAINER_API_KEY` | clé API Portainer de chlova (§3a.3) | **oui** |
|
||||
| `CHLOVA_ADMIN_USER` | ex. `kantin` | non |
|
||||
| `CHLOVA_ADMIN_PASSWORD_HASH` | (§4) | **oui** |
|
||||
| `CHLOVA_TOTP_SECRET` | (§4) | **oui** |
|
||||
| `CHLOVA_JWT_SECRET` | (§4) | **oui** |
|
||||
|
||||
**Stacks → Add stack → Git repository** :
|
||||
> **Stack pré-créé.** Le stack `chlova` est déjà déployé en GitOps (depuis
|
||||
> `http://gitea:3000/k.petit/chlova.git`, `infra/docker-compose.prod.yml`) avec
|
||||
> des **placeholders** `CHANGEME` sur les 7 secrets. Les conteneurs `backend` et
|
||||
> `mcp-portainer` redémarrent en boucle tant que les vraies valeurs ne sont pas
|
||||
> mises. **Pour finaliser : Stacks → chlova → Editor / Environment variables →
|
||||
> remplacer les 7 valeurs → Update the stack** (pas besoin de recréer).
|
||||
|
||||
Sinon, pour (re)créer à la main — **Stacks → Add stack → Git repository** :
|
||||
- Repository URL = `http://gitea:3000/<admin>/chlova.git` (clone interne) ou
|
||||
`https://git.pogoo.app/<admin>/chlova.git`.
|
||||
- Authentication = user gitea + token (§1.3).
|
||||
|
||||
@@ -20,7 +20,7 @@ name: chlova
|
||||
services:
|
||||
# ── Ollama : proxy authentifié vers les modèles cloud (ollama.com) ──────
|
||||
ollama:
|
||||
image: ollama/ollama:0.6.8
|
||||
image: ollama/ollama:0.30.10 # >= 0.30 requis pour les modèles :cloud
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
OLLAMA_API_KEY: ${OLLAMA_API_KEY:?OLLAMA_API_KEY requis}
|
||||
@@ -32,44 +32,20 @@ services:
|
||||
- chlova-egress
|
||||
# AUCUN port publié : Ollama n'a pas d'auth native, jamais exposé.
|
||||
|
||||
# ── socket-proxy : accès Docker filtré, LECTURE SEULE (Phase 1) ─────────
|
||||
socket-proxy:
|
||||
image: tecnativa/docker-socket-proxy:0.3.0
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
CONTAINERS: 1
|
||||
IMAGES: 1
|
||||
NETWORKS: 1
|
||||
VOLUMES: 1
|
||||
SERVICES: 1
|
||||
TASKS: 1
|
||||
NODES: 1
|
||||
INFO: 1
|
||||
VERSION: 1
|
||||
POST: 0
|
||||
EXEC: 0
|
||||
AUTH: 0
|
||||
SECRETS: 0
|
||||
CONFIGS: 0
|
||||
BUILD: 0
|
||||
COMMIT: 0
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
networks:
|
||||
- chlova-internal
|
||||
# AUCUN port publié.
|
||||
|
||||
# ── MCP Portainer (portainer/portainer-mcp) — read-only en Phase 1 ──────
|
||||
# ── MCP Portainer (passerelle HTTP portainer/portainer-mcp) — read-only P1 ─
|
||||
# Parle à l'API Portainer (pas au socket Docker). Le backend s'y connecte en
|
||||
# HTTP sur :17717/mcp avec le secret de passerelle (Bearer) + la clé API
|
||||
# restreinte de chlova (X-Portainer-API-Key).
|
||||
mcp-portainer:
|
||||
image: portainer/portainer-mcp:0.6.0
|
||||
image: portainer/portainer-mcp:2.42.6
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PORTAINER_URL: ${PORTAINER_URL:-http://portainer:9000} # interne : Portainer sur le même hôte (local), réseau proxy
|
||||
PORTAINER_MCP_AUTH_TOKEN: ${PORTAINER_MCP_AUTH_TOKEN:?requis} # token user chlova restreint
|
||||
PORTAINER_READ_ONLY: ${PORTAINER_READ_ONLY:-true} # P1 : NE PAS passer à false
|
||||
DOCKER_HOST: tcp://socket-proxy:2375
|
||||
depends_on:
|
||||
- socket-proxy
|
||||
PORTAINER_MCP_AUTH_TOKEN: ${PORTAINER_MCP_AUTH_TOKEN:?requis} # secret de passerelle (partagé avec le backend)
|
||||
PORTAINER_MCP_ALLOWED_HOSTS: mcp-portainer:17717 # hôte par lequel le backend appelle
|
||||
PORTAINER_MCP_DANGEROUSLY_ALLOW_PLAINTEXT_HTTP: "1" # HTTP interne (réseau Docker privé)
|
||||
PORTAINER_TLS_VERIFY: "0" # Portainer interne en HTTP : pas de TLS
|
||||
PORTAINER_READ_ONLY: ${PORTAINER_READ_ONLY:-true} # P1 : GET/HEAD seulement (défense en profondeur)
|
||||
networks:
|
||||
- chlova-internal # joignable par le backend
|
||||
- proxy # joint le serveur Portainer (http://portainer:9000)
|
||||
@@ -95,9 +71,10 @@ services:
|
||||
# — MCP n8n (natif, interne via réseau proxy) —
|
||||
MCP_N8N_URL: ${MCP_N8N_URL:-http://n8n-chlova:5678/mcp-server/http}
|
||||
MCP_N8N_AUTH_TOKEN: ${MCP_N8N_AUTH_TOKEN:?requis}
|
||||
# — MCP Portainer (sidecar) —
|
||||
MCP_PORTAINER_URL: ${MCP_PORTAINER_URL:-http://mcp-portainer:3000}
|
||||
PORTAINER_MCP_AUTH_TOKEN: ${PORTAINER_MCP_AUTH_TOKEN:?requis}
|
||||
# — MCP Portainer (passerelle) —
|
||||
MCP_PORTAINER_URL: ${MCP_PORTAINER_URL:-http://mcp-portainer:17717/mcp}
|
||||
PORTAINER_MCP_AUTH_TOKEN: ${PORTAINER_MCP_AUTH_TOKEN:?requis} # secret de passerelle (= côté sidecar)
|
||||
PORTAINER_API_KEY: ${PORTAINER_API_KEY:?requis} # clé API Portainer restreinte de chlova
|
||||
PORTAINER_READ_ONLY: ${PORTAINER_READ_ONLY:-true}
|
||||
# — Alertes (Phase 3) : vide = log-only —
|
||||
ALERT_WEBHOOK_URL: ${ALERT_WEBHOOK_URL:-}
|
||||
|
||||
@@ -12,7 +12,7 @@ name: chlova
|
||||
services:
|
||||
# ── Ollama : proxy authentifié vers les modèles cloud (ollama.com) ──────
|
||||
ollama:
|
||||
image: ollama/ollama:0.6.8 # TODO épingler le digest
|
||||
image: ollama/ollama:0.30.10 # >= 0.30 requis pour les modèles :cloud
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Clé du proxy cloud — injectée depuis .env, jamais en dur.
|
||||
|
||||
@@ -35,9 +35,14 @@ const schema = z.object({
|
||||
mcpN8nUrl: z.string().url(),
|
||||
mcpN8nAuthToken: nonEmpty, // SECRET
|
||||
|
||||
// MCP Portainer
|
||||
// MCP Portainer (passerelle HTTP portainer/portainer-mcp)
|
||||
mcpPortainerUrl: z.string().url(),
|
||||
// Secret de PASSERELLE (front-gate) partagé avec le serveur MCP : envoyé en
|
||||
// `Authorization: Bearer`. N'autorise QUE l'accès à la passerelle.
|
||||
portainerMcpAuthToken: nonEmpty, // SECRET
|
||||
// Clé API Portainer de l'utilisateur `chlova` (restreinte) : envoyée en header
|
||||
// `X-Portainer-API-Key`. C'est ELLE qui cloisonne ce que CHLOVA peut voir/faire.
|
||||
portainerApiKey: nonEmpty, // SECRET
|
||||
// Verrou Phase 1 : lecture seule. `false` est refusé tant que la Phase 2
|
||||
// n'est pas explicitement activée (voir assertReadOnlyPhase()).
|
||||
portainerReadOnly: z
|
||||
@@ -99,6 +104,7 @@ const SECRET_KEYS = new Set<keyof Config>([
|
||||
"ollamaApiKey",
|
||||
"mcpN8nAuthToken",
|
||||
"portainerMcpAuthToken",
|
||||
"portainerApiKey",
|
||||
"telegramBotToken",
|
||||
"alertWebhookUrl",
|
||||
"adminPasswordHash",
|
||||
@@ -118,6 +124,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
mcpN8nAuthToken: env.MCP_N8N_AUTH_TOKEN,
|
||||
mcpPortainerUrl: env.MCP_PORTAINER_URL,
|
||||
portainerMcpAuthToken: env.PORTAINER_MCP_AUTH_TOKEN,
|
||||
portainerApiKey: env.PORTAINER_API_KEY,
|
||||
portainerReadOnly: env.PORTAINER_READ_ONLY,
|
||||
telegramBotToken: env.TELEGRAM_BOT_TOKEN,
|
||||
telegramAllowedUserIds: env.TELEGRAM_ALLOWED_USER_IDS,
|
||||
|
||||
@@ -49,7 +49,9 @@ async function main(): Promise<void> {
|
||||
await registry.connect({
|
||||
name: "portainer",
|
||||
url: cfg.mcpPortainerUrl,
|
||||
authToken: cfg.portainerMcpAuthToken,
|
||||
authToken: cfg.portainerMcpAuthToken, // secret de passerelle (Bearer)
|
||||
// Clé API Portainer restreinte de `chlova` : cloisonne l'accès réel.
|
||||
extraHeaders: { "X-Portainer-API-Key": cfg.portainerApiKey },
|
||||
});
|
||||
|
||||
// ── Outils + Guard, selon la phase ──────────────────────────────────────
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface McpServerConfig {
|
||||
name: string;
|
||||
url: string;
|
||||
authToken: string;
|
||||
/** Headers additionnels (ex. X-Portainer-API-Key pour la passerelle Portainer). */
|
||||
extraHeaders?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ConnectedServer {
|
||||
@@ -36,7 +38,10 @@ export class McpRegistry {
|
||||
async connect(cfg: McpServerConfig): Promise<void> {
|
||||
const transport = new StreamableHTTPClientTransport(new URL(cfg.url), {
|
||||
requestInit: {
|
||||
headers: { authorization: `Bearer ${cfg.authToken}` },
|
||||
headers: {
|
||||
authorization: `Bearer ${cfg.authToken}`,
|
||||
...cfg.extraHeaders,
|
||||
},
|
||||
},
|
||||
});
|
||||
const client = new Client({ name: `chlova-${cfg.name}`, version: "0.1.0" });
|
||||
|
||||
@@ -12,8 +12,9 @@ const fullEnv = (): NodeJS.ProcessEnv => ({
|
||||
OLLAMA_MODEL: "qwen3:cloud",
|
||||
MCP_N8N_URL: "http://mcp-n8n:3000",
|
||||
MCP_N8N_AUTH_TOKEN: "secret-n8n",
|
||||
MCP_PORTAINER_URL: "http://mcp-portainer:3000",
|
||||
PORTAINER_MCP_AUTH_TOKEN: "secret-portainer",
|
||||
MCP_PORTAINER_URL: "http://mcp-portainer:17717/mcp",
|
||||
PORTAINER_MCP_AUTH_TOKEN: "secret-gate",
|
||||
PORTAINER_API_KEY: "secret-portainer-apikey",
|
||||
PORTAINER_READ_ONLY: "true",
|
||||
TELEGRAM_BOT_TOKEN: "secret-tg",
|
||||
TELEGRAM_ALLOWED_USER_IDS: "111, 222",
|
||||
|
||||
+6
-5
@@ -14,8 +14,8 @@ function Shell() {
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh flex flex-col">
|
||||
<header className="flex items-center gap-2 border-b border-border bg-surface px-4 py-2">
|
||||
<span className="font-bold tracking-wide text-accent glow mr-2">CHLOVA</span>
|
||||
<header className="flex flex-wrap items-center gap-x-2 gap-y-1 border-b border-border bg-surface px-3 sm:px-4 py-2">
|
||||
<span className="font-bold tracking-wide text-accent glow mr-1 sm:mr-2">CHLOVA</span>
|
||||
<nav className="flex gap-1">
|
||||
<NavLink to="/chat" className={link}>
|
||||
<MessageSquare size={16} /> Chat
|
||||
@@ -27,10 +27,11 @@ function Shell() {
|
||||
)}
|
||||
</NavLink>
|
||||
</nav>
|
||||
<span className="ml-auto flex items-center gap-1.5 text-xs text-muted" title="Phase · outils">
|
||||
<Cpu size={14} /> {phase || "…"} · {tools} outils
|
||||
<span className="ml-auto flex items-center gap-1.5 text-xs text-muted whitespace-nowrap" title={`Phase ${phase} · ${tools} outils`}>
|
||||
<Cpu size={14} /> {phase || "…"}
|
||||
<span className="hidden sm:inline">· {tools} outils</span>
|
||||
</span>
|
||||
<button onClick={logout} className="ml-3 text-muted hover:text-fg cursor-pointer" aria-label="Déconnexion" title="Déconnexion">
|
||||
<button onClick={logout} className="ml-1 sm:ml-3 shrink-0 text-muted hover:text-fg cursor-pointer" aria-label="Déconnexion" title="Déconnexion">
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@@ -100,20 +100,20 @@ export function Chat() {
|
||||
<div ref={bottom} />
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} className="flex items-center gap-2 border-t border-border bg-surface p-3">
|
||||
<form onSubmit={submit} className="flex items-center gap-1.5 border-t border-border bg-surface p-2 sm:p-3">
|
||||
{speech.ttsSupported && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleSpeak}
|
||||
aria-label={speakReplies ? "Couper la voix" : "Activer la voix"}
|
||||
title={speakReplies ? "Voix activée" : "Voix coupée"}
|
||||
className={`rounded-md border px-3 py-2 cursor-pointer ring-accent ${speakReplies ? "border-accent text-accent" : "border-border text-muted"}`}
|
||||
className={`shrink-0 rounded-md border px-2.5 sm:px-3 py-2 cursor-pointer ring-accent ${speakReplies ? "border-accent text-accent" : "border-border text-muted"}`}
|
||||
>
|
||||
{speakReplies ? <Volume2 size={18} /> : <VolumeX size={18} />}
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
className="flex-1 rounded-md bg-surface-2 border border-border px-3 py-2 text-fg placeholder:text-muted ring-accent"
|
||||
className="flex-1 min-w-0 rounded-md bg-surface-2 border border-border px-3 py-2 text-fg placeholder:text-muted ring-accent"
|
||||
placeholder={speech.listening ? "Écoute…" : "Message…"}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
@@ -124,7 +124,7 @@ export function Chat() {
|
||||
type="button"
|
||||
onClick={mic}
|
||||
aria-label={speech.listening ? "Arrêter le micro" : "Parler"}
|
||||
className={`rounded-md border px-3 py-2 cursor-pointer ring-accent ${speech.listening ? "border-accent text-accent animate-pulse" : "border-border text-muted"}`}
|
||||
className={`shrink-0 rounded-md border px-2.5 sm:px-3 py-2 cursor-pointer ring-accent ${speech.listening ? "border-accent text-accent animate-pulse" : "border-border text-muted"}`}
|
||||
>
|
||||
{speech.listening ? <Square size={18} /> : <Mic size={18} />}
|
||||
</button>
|
||||
@@ -135,7 +135,7 @@ export function Chat() {
|
||||
onClick={toggleHandsFree}
|
||||
aria-label={speech.handsFree ? "Couper les mains libres" : "Activer les mains libres"}
|
||||
title="Mains libres (wake-word CHLOVA)"
|
||||
className={`rounded-md border px-3 py-2 cursor-pointer ring-accent ${speech.handsFree ? "border-accent text-accent" : "border-border text-muted"}`}
|
||||
className={`shrink-0 rounded-md border px-2.5 sm:px-3 py-2 cursor-pointer ring-accent ${speech.handsFree ? "border-accent text-accent" : "border-border text-muted"}`}
|
||||
>
|
||||
<Radio size={18} />
|
||||
</button>
|
||||
@@ -144,9 +144,9 @@ export function Chat() {
|
||||
type="submit"
|
||||
disabled={busy || !input.trim()}
|
||||
aria-label="Envoyer"
|
||||
className="flex items-center gap-1.5 rounded-md bg-accent px-4 py-2 font-medium text-bg disabled:opacity-50 ring-accent cursor-pointer"
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-md bg-accent px-3 sm:px-4 py-2 font-medium text-bg disabled:opacity-50 ring-accent cursor-pointer"
|
||||
>
|
||||
<Send size={16} /> Envoyer
|
||||
<Send size={16} /> <span className="hidden sm:inline">Envoyer</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -39,16 +39,16 @@ export function Review() {
|
||||
expire {new Date(a.expiresAt).toISOString().slice(0, 10)}
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex gap-2">
|
||||
<div className="flex gap-2 w-full sm:w-auto sm:ml-auto">
|
||||
<button
|
||||
onClick={() => void approve(a.id)}
|
||||
className="flex items-center gap-1 rounded-md bg-success/15 text-success border border-success/40 px-3 py-1 text-sm cursor-pointer ring-accent"
|
||||
className="flex flex-1 sm:flex-none items-center justify-center gap-1 rounded-md bg-success/15 text-success border border-success/40 px-3 py-1 text-sm cursor-pointer ring-accent"
|
||||
>
|
||||
<Check size={15} /> Approuver
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onRefuse(a.id)}
|
||||
className="flex items-center gap-1 rounded-md bg-danger/15 text-danger border border-danger/50 px-3 py-1 text-sm cursor-pointer ring-accent"
|
||||
className="flex flex-1 sm:flex-none items-center justify-center gap-1 rounded-md bg-danger/15 text-danger border border-danger/50 px-3 py-1 text-sm cursor-pointer ring-accent"
|
||||
>
|
||||
<X size={15} /> Refuser
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user