Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b3ba04b5b | ||
|
|
5c5180336b | ||
|
|
73bf863e40 | ||
|
|
06d0e354fe | ||
|
|
a01fba6602 | ||
|
|
54ebf2365c | ||
|
|
24eaa7f9df | ||
|
|
8e3eb3a9d7 | ||
|
|
01ce8b8ec3 | ||
|
|
5dd6809ef0 | ||
|
|
7b8bc52650 | ||
|
|
1758c3b6d2 | ||
|
|
6669633902 | ||
|
|
a65d282c0b | ||
|
|
524b43cc40 | ||
|
|
4e0b27c4d7 | ||
|
|
c6d7acfd99 | ||
|
|
bf941c71ec | ||
|
|
dcec510451 | ||
|
|
cb468b0f3c | ||
|
|
6713510ce9 | ||
|
|
bf5b32de16 | ||
|
|
2a1780b43d | ||
|
|
be05ec99b5 | ||
|
|
3a42bc034d | ||
|
|
27e7664ef0 | ||
|
|
ccbf1d3269 | ||
|
|
0ca14739f9 | ||
|
|
d9192ef355 | ||
|
|
4c9bfd576c | ||
|
|
20898cade7 | ||
|
|
2c12639499 | ||
|
|
7d2b2067ef | ||
|
|
0b52e6ee86 | ||
|
|
cafac69008 | ||
|
|
871875b8e5 | ||
|
|
e5ae3e48d6 | ||
|
|
76de3dd75e | ||
|
|
687427381d | ||
|
|
e418d87802 | ||
|
|
a1f3690085 | ||
|
|
0a7bed10bf | ||
|
|
32996ce9aa |
+17
-1
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
|||||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
> **En cours de développement** : les changements à venir sont listés dans la section
|
> **En cours de développement** : les changements à venir sont listés dans la section
|
||||||
> [Unreleased](#unreleased). La dernière version livrée est **2.80.2**.
|
> [Unreleased](#unreleased). La dernière version livrée est **2.81.2**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -14,6 +14,22 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [2.81.2] — 2026-10-28
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
|
||||||
|
- **Panneau Spark / console (PR #35)** — style console du panneau, statut « Connecté » intégré au sous-titre de l'en-tête (barre séparée supprimée), texte sous la zone de saisie supprimé, composer identique à la console (pilule, pièces jointes 4 max, textarea auto-extensible, micro dictée, envoi rond bleu), resynchronisation du domaine à chaque ouverture / changement de stockage / retour console, URL console avec `?domain=` ; la console honore `?domain=` valide (persisté avant chargement des discussions, PR agent-commun #7).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.81.1] — 2026-10-28
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.81.0] — 2026-10-28
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [2.80.2] — 2026-10-27
|
## [2.80.2] — 2026-10-27
|
||||||
|
|
||||||
### Corrigé
|
### Corrigé
|
||||||
|
|||||||
+3
-3
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -977,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 Changelog
|
||||||
|
|
||||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.80.2).
|
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.81.1).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Projet : ObsiGate | Version : 2.80.2 | Dernière mise à jour : Septembre 2026*
|
*Projet : ObsiGate | Version : 2.81.1 | Dernière mise à jour : Septembre 2026*
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -1152,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 Changelog
|
||||||
|
|
||||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.80.2).
|
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.81.1).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Project: ObsiGate | Version: 2.80.2 | Last updated: September 2026*
|
*Project: ObsiGate | Version: 2.81.1 | Last updated: September 2026*
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ TAGS_METADATA: list[dict[str, str]] = [
|
|||||||
{"name": "Webhooks", "description": "HTTP callbacks signed with HMAC-SHA256 for file events."},
|
{"name": "Webhooks", "description": "HTTP callbacks signed with HMAC-SHA256 for file events."},
|
||||||
{"name": "Conflicts", "description": "Detect and resolve Syncthing sync-conflict files."},
|
{"name": "Conflicts", "description": "Detect and resolve Syncthing sync-conflict files."},
|
||||||
{"name": "Admin", "description": "Admin-only system monitoring: stats, audit log, backup stats and live stream."},
|
{"name": "Admin", "description": "Admin-only system monitoring: stats, audit log, backup stats and live stream."},
|
||||||
|
{"name": "Coffres Secrets", "description": "Secret vaults: encrypted coffres, secrets, consumers, grants and distribution — metadata only, values are never exposed."},
|
||||||
{"name": "Plugins", "description": "Install, enable and manage user plugins."},
|
{"name": "Plugins", "description": "Install, enable and manage user plugins."},
|
||||||
{"name": "Push", "description": "Web Push (VAPID) subscription management and test notifications."},
|
{"name": "Push", "description": "Web Push (VAPID) subscription management and test notifications."},
|
||||||
{"name": "Duplicates", "description": "Duplicate-note detection and confirmed merge (#166)."},
|
{"name": "Duplicates", "description": "Duplicate-note detection and confirmed merge (#166)."},
|
||||||
|
|||||||
@@ -34,10 +34,10 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from backend.auth.middleware import require_admin, require_auth
|
|
||||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||||
|
|
||||||
from backend.audit import get_recent_entries, log_coffre_event
|
from backend.audit import get_recent_entries, log_coffre_event
|
||||||
|
from backend.auth.middleware import require_admin, require_auth
|
||||||
from backend.coffre_crypto import (
|
from backend.coffre_crypto import (
|
||||||
CryptoIndisponibleError,
|
CryptoIndisponibleError,
|
||||||
KEKAbsenteError,
|
KEKAbsenteError,
|
||||||
|
|||||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.80.2"
|
version = "2.81.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.80.2"
|
version = "2.81.2"
|
||||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||||
authors = ["Bruno Charest"]
|
authors = ["Bruno Charest"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||||
"productName": "ObsiGate",
|
"productName": "ObsiGate",
|
||||||
"version": "2.80.2",
|
"version": "2.81.2",
|
||||||
"identifier": "com.obsigate.desktop",
|
"identifier": "com.obsigate.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../frontend",
|
"frontendDist": "../frontend",
|
||||||
|
|||||||
@@ -0,0 +1,390 @@
|
|||||||
|
# Coffre Secrets ObsiGate — Déploiement et mise en service (Phase 5, issue #23)
|
||||||
|
|
||||||
|
Document de déploiement du coffre Secrets, issu des Phases 1a/1b
|
||||||
|
(issues #17, #18), 2 (issue #20), 3 (issue #21, côté agent-commun)
|
||||||
|
et 4 (issue #22, côté TikTak, Flowdeck, Kanbante).
|
||||||
|
|
||||||
|
**Règles de gouvernance (non négociables) :** travail en branche +
|
||||||
|
pull request, jamais sur `main`, jamais de merge, de release, de tag,
|
||||||
|
de fermeture d'issue ni de changement de label sans le go explicite
|
||||||
|
de Bruno. Ce document ne décrit aucune valeur réelle : les exemples
|
||||||
|
utilisent des valeurs fictives (`TEST-VALUE-XXXX`) et aucune KEK
|
||||||
|
n'est jamais écrite ici, ni dans un journal, ni dans une note, ni
|
||||||
|
dans le dépôt.
|
||||||
|
|
||||||
|
Documents compagnons (même branche de référence, Phase 2) :
|
||||||
|
|
||||||
|
- `docs/COFFRE-contrat-distribution.md` — contrat
|
||||||
|
`POST /api/coffres/distribuer` pour Spark, TikTak, Flowdeck,
|
||||||
|
Kanbante (authentification par consommateur, contextes, codes
|
||||||
|
d'erreur stables).
|
||||||
|
- `docs/COFFRE-sauvegarde-restauration.md` — sauvegarde
|
||||||
|
(`data/coffres.json` + `data/coffres_valeurs.json`, 0600) et
|
||||||
|
restauration sur instance vierge, perte de KEK.
|
||||||
|
|
||||||
|
## 1. Prérequis et variables d'environnement
|
||||||
|
|
||||||
|
### 1.1 Dépendance
|
||||||
|
|
||||||
|
- `cryptography>=44.0` dans `backend/requirements.txt` (dépendance
|
||||||
|
Phase 1a). Sans elle, les opérations de chiffrement répondent
|
||||||
|
503 `crypto_indisponible` : c'est un mode dégradé explicite, pas
|
||||||
|
un repli en clair. Il n'y a jamais de stockage ni de distribution
|
||||||
|
de valeur en clair.
|
||||||
|
- Chiffrement par enveloppe AES-256-GCM : KEK (256 bits) → DEK par
|
||||||
|
coffre → valeur du secret. Un tag GCM invalide refuse le
|
||||||
|
déchiffrement (testé : `tests/test_coffre_secrets.py`,
|
||||||
|
`test_tag_invalide_refuse`).
|
||||||
|
|
||||||
|
### 1.2 Variables
|
||||||
|
|
||||||
|
| Variable | Rôle | Défaut |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `OBSIGATE_SECRETS_ENABLED` | Activation du coffre. Tant qu'elle n'est pas à `true`, **toutes** les routes coffre répondent 503 `secrets_desactives`. | désactivé |
|
||||||
|
| `OBSIGATE_KEK` | KEK, base64 de 32 octets (ou hex 64 caractères). **Prioritaire** sur le fichier si les deux sont définis. | absente |
|
||||||
|
| `OBSIGATE_KEK_FILE` | Chemin d'un fichier contenant la KEK, **en 0600, hors dépôt et hors arborescence des vaults**. Un fichier trop permissif (droits groupe/autre) est refusé explicitement (`KEKInvalideError`). | absent |
|
||||||
|
| `OBSIGATE_DATA_DIR` | Répertoire des données (`data/` par défaut) : `coffres.json`, `coffres_valeurs.json` et l'historique `api_keys.json`. | `data` |
|
||||||
|
|
||||||
|
La KEK est générée une seule fois (admin, coffres activés) via
|
||||||
|
`POST /api/coffres/admin/generer-kek` ; ObsiGate **ne la stocke
|
||||||
|
jamais**. Au moment de sa génération : la copier immédiatement
|
||||||
|
dans un gestionnaire de mots de passe ou sur un support hors ligne,
|
||||||
|
hors du serveur ; vérifier la copie en la relisant depuis le
|
||||||
|
gestionnaire, jamais depuis l'écran qui ne l'affichera plus.
|
||||||
|
Ne jamais la coller dans le dépôt, dans une note ObsiGate, dans le
|
||||||
|
même dossier que `data/`, ni dans un journal.
|
||||||
|
|
||||||
|
**Recommandation de déploiement : préférer `OBSIGATE_KEK_FILE`**
|
||||||
|
(fichier 0600) à `OBSIGATE_KEK` en variable d'environnement, pour
|
||||||
|
éviter que la KEK apparaisse dans un historique de processus ou un
|
||||||
|
dump d'environnement. Si les deux sont définis, `OBSIGATE_KEK`
|
||||||
|
l'emporte — vérifier qu'aucune ancienne valeur ne traîne dans
|
||||||
|
l'environnement au moment de basculer sur le fichier.
|
||||||
|
|
||||||
|
### 1.3 Ce que la sauvegarde contient (rappel)
|
||||||
|
|
||||||
|
Deux fichiers seulement portent le coffre, tous deux en 0600 :
|
||||||
|
|
||||||
|
- `data/coffres.json` : métadonnées des coffres, DEK chiffrées,
|
||||||
|
consommateurs (hashes SHA-256 des jetons seulement, jamais le
|
||||||
|
jeton en clair), octrois, métadonnées des secrets ;
|
||||||
|
- `data/coffres_valeurs.json` : versions chiffrées des secrets.
|
||||||
|
|
||||||
|
Copiés seuls, ils ne donnent accès à aucune valeur. La KEK ne fait
|
||||||
|
pas partie de la sauvegarde : elle est copiée à part, hors serveur.
|
||||||
|
Sauvegarder les deux fichiers et la KEK au même endroit annulerait
|
||||||
|
la protection du chiffrement.
|
||||||
|
|
||||||
|
## 2. Décisions ouvertes de la spec — tranchées par défaut
|
||||||
|
|
||||||
|
Les décisions ouvertes de la spec (§15, §16), rappelées dans
|
||||||
|
l'issue #23, sont confirmées comme tranchées selon les défauts
|
||||||
|
recommandés, en attendant une décision contraire explicite de
|
||||||
|
Bruno. Aucune d'elles ne nécessite de changement de code pour la
|
||||||
|
mise en service décrite ci-dessous :
|
||||||
|
|
||||||
|
| Décision | Choix confirmé par défaut |
|
||||||
|
| --- | --- |
|
||||||
|
| Glyphe du nœud Secrets | Cadenas U+1F510 (🔐), distinct de la maison du Home et du diamant du Partage. Le nœud est un nœud virtuel sous le Home de l'utilisateur courant seulement (voir §4). |
|
||||||
|
| Stockage de la KEK | Par fichier 0600, hors dépôt (`OBSIGATE_KEK_FILE`) ; `OBSIGATE_KEK` reste supportée comme solution de secours. |
|
||||||
|
| Expiration des octrois | 90 jours par défaut à la création, renouvelable (création d'un nouvel octroi). Un octroi expiré refuse la distribution (`octroi_expire`, 403) ; un octroi révoqué refuse dès l'appel suivant (`octroi_revoque`, 403). |
|
||||||
|
| Copie manuelle | Presse-papiers seul, sans affichage de la valeur. Patron supporté : consommateur temporaire créé par un admin puis révoqué immédiatement, auditée comme distribution de contexte `copie_manuelle`. **Aucun endpoint dédié de copie par session n'est ajouté** : il contredirait la règle « distribution réservée aux consommateurs » et exigerait une ré-authentification qui n'existe pas encore — décision volontaire, à trancher avec Bruno avant toute implémentation. |
|
||||||
|
| Coffres partagés | Phase 2 ultérieure. En mise en service : coffres personnels d'abord, plus le coffre d'instance « ObsiGate interne » (slug `obsigate-interne`, admin seulement) issu de l'import. |
|
||||||
|
|
||||||
|
## 3. Mise en service pas à pas
|
||||||
|
|
||||||
|
**Ordre impératif.** Chaque étape est validée avant de passer à la
|
||||||
|
suivante. En particulier : **les lecteurs internes sont branchés
|
||||||
|
sur le coffre AVANT toute purge du clair** (étape 4 avant étape 7),
|
||||||
|
et la purge n'a lieu qu'après validation complète (étape 6).
|
||||||
|
|
||||||
|
### Étape 0 — Préparer, coffre encore désactivé
|
||||||
|
|
||||||
|
1. Déployer le code coffre (Phases 1–2) sur l'instance, **sans
|
||||||
|
activer le flag** : `OBSIGATE_SECRETS_ENABLED` reste absent ou à
|
||||||
|
`false`. Vérifier que toutes les routes `/api/coffres/*`
|
||||||
|
répondent 503.
|
||||||
|
2. Sauvegarder l'état actuel : copie de `data/` (dont
|
||||||
|
`api_keys.json`) et de la configuration, hors serveur.
|
||||||
|
3. Générer la KEK (voir §1.2) et la copier hors serveur. Ne pas
|
||||||
|
encore la référencer dans la configuration de l'instance si
|
||||||
|
l'étape 1 n'est pas terminée — une KEK présente sans flag actif
|
||||||
|
ne distribue rien, mais sa présence doit rester traçable.
|
||||||
|
|
||||||
|
### Étape 1 — Créer les consommateurs et les octrois
|
||||||
|
|
||||||
|
Avant d'activer le flag, préparer les consommateurs nommés, un par
|
||||||
|
application cliente (convention de nommage, non imposée par le
|
||||||
|
code — l'autorisation porte sur le jeton, pas sur le nom) :
|
||||||
|
|
||||||
|
| Consommateur | Usage | Contexte de distribution |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `spark` | Agent Spark (agent-commun, Phase 3) | `contexte_type = "mission"`, `contexte_id` = id de mission (forme imbriquée `contexte: {type, id}` acceptée) |
|
||||||
|
| `tiktak` | Synchronisation TikTak (Phase 4) | `contexte_type = "tiktak_sync"`, `contexte_id` = id utilisateur |
|
||||||
|
| `flowdeck` | Connecteurs Flowdeck (Phase 4) | Contexte workspace, vérifié côté Flowdeck (voir §5) |
|
||||||
|
| `kanbante` | Webhooks Kanbante (Phase 4) | Contexte webhook, distribution à chaque appel puis oubli |
|
||||||
|
|
||||||
|
Création en une seule opération avec les octrois :
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/coffres/consommateurs/avec-octrois
|
||||||
|
{"nom": "tiktak", "octrois": [{"coffre_slug": "...", "secret_nom": "..."}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `secret_nom` absent = tout le coffre (octroi coffre-wide).
|
||||||
|
- Le jeton (`csk_…`) n'est rendu **qu'une seule fois**, à la
|
||||||
|
création. Le noter immédiatement dans le coffre de
|
||||||
|
configuration de l'application cliente concernée, jamais dans
|
||||||
|
une note ObsiGate ni dans le dépôt. Il n'est jamais relisté :
|
||||||
|
`GET /api/coffres/consommateurs` ne renvoie que `id`, `nom`,
|
||||||
|
`jeton_prefixe`, `statut`, `cree_par`, `cree_le`.
|
||||||
|
- La révocation est immédiate : `DELETE
|
||||||
|
/api/coffres/consommateurs/{id}` révoque toutes les
|
||||||
|
distributions de ce consommateur, sans toucher aux autres.
|
||||||
|
Une mission Spark déjà démarrée conserve son environnement
|
||||||
|
jusqu'à sa fin ; la mission suivante repasse en `acces_requis`.
|
||||||
|
|
||||||
|
À ce stade, les consommateurs et octrois peuvent être créés alors
|
||||||
|
que les secrets n'existent pas encore : les octrois pointent vers
|
||||||
|
des coffres/secrets à créer aux étapes suivantes. Vérifier la
|
||||||
|
liste des consommateurs et des octrois avant d'activer le flag.
|
||||||
|
|
||||||
|
### Étape 2 — Activer le flag
|
||||||
|
|
||||||
|
1. Définir `OBSIGATE_KEK_FILE` (fichier 0600, hors dépôt) puis
|
||||||
|
`OBSIGATE_SECRETS_ENABLED=true`.
|
||||||
|
2. Redémarrer l'instance.
|
||||||
|
3. Vérifier : la vue Secrets s'ouvre ; `GET /api/coffres` répond
|
||||||
|
200 (liste vide ou coffres existants) ; sans KEK valide, les
|
||||||
|
routes répondent 503 `kek_absente` — dans ce cas, corriger la
|
||||||
|
KEK avant toute création de secret.
|
||||||
|
|
||||||
|
### Étape 3 — Importer l'existant (admin seulement)
|
||||||
|
|
||||||
|
Bouton « Importer l'existant » de la vue Secrets (admin
|
||||||
|
seulement), en deux temps séparés :
|
||||||
|
|
||||||
|
1. **Aperçu** : `GET /api/coffres/migration/api-keys` liste les
|
||||||
|
candidats de `data/api_keys.json` (clés IA et clés d'outils).
|
||||||
|
2. **Import** : `POST /api/coffres/migration/api-keys/import`
|
||||||
|
copie les entrées avec valeur vers le coffre d'instance
|
||||||
|
« ObsiGate interne » (slug `obsigate-interne`), chiffrées.
|
||||||
|
Un secret déjà présent n'est **jamais écrasé**.
|
||||||
|
`api_keys.json` n'est **pas modifié** par l'import.
|
||||||
|
|
||||||
|
Vérifier après import : les secrets importés apparaissent dans la
|
||||||
|
vue Secrets avec `has_value = true`, et aucune valeur n'est
|
||||||
|
visible (métadonnées seules : nom, type, version, dates, octrois
|
||||||
|
actifs — jamais de valeur, jamais de texte chiffré, jamais de
|
||||||
|
nonce).
|
||||||
|
|
||||||
|
### Étape 4 — Brancher les lecteurs internes AVANT toute purge
|
||||||
|
|
||||||
|
**Point de vigilance principal de la mise en service.** Les
|
||||||
|
lecteurs actuels d'ObsiGate (`backend/ai.py`,
|
||||||
|
`backend/tools/secrets.py`) lisent encore `data/api_keys.json`
|
||||||
|
directement ; ils ne sont pas encore branchés sur le coffre
|
||||||
|
(reste à faire explicite des Phases 2–5). Tant que ce branchement
|
||||||
|
n'est pas livré et validé, **purger le clair priverait l'IA et les
|
||||||
|
outils de leurs clés**.
|
||||||
|
|
||||||
|
Ordre à respecter :
|
||||||
|
|
||||||
|
1. Ne pas purger.
|
||||||
|
2. Livrer (en branche + PR, jamais mergé sans go) le branchement
|
||||||
|
des lecteurs internes sur une distribution depuis le coffre
|
||||||
|
d'instance, puis le valider sur l'instance : l'IA et les outils
|
||||||
|
fonctionnent avec `api_keys.json` rendu temporairement
|
||||||
|
inaccessible en lecture (test de coupure contrôlée), ou à
|
||||||
|
défaut avec un secret de test distribué depuis le coffre.
|
||||||
|
3. Seulement après cette validation, passer à l'étape 7.
|
||||||
|
|
||||||
|
Si Bruno accepte une interruption temporaire de l'IA/outils, le
|
||||||
|
documenter explicitement avant la purge ; sinon, la purge attend
|
||||||
|
le branchement.
|
||||||
|
|
||||||
|
### Étape 5 — Brancher les applications clientes
|
||||||
|
|
||||||
|
Pour chaque application (branches dédiées, jamais mergées sans
|
||||||
|
go), appliquer le patron commun **distribuer → utiliser →
|
||||||
|
oublier** : le serveur distribue à chaque opération, utilise la
|
||||||
|
valeur en mémoire, puis l'oublie ; aucune valeur en cache, aucune
|
||||||
|
persistance. Si ObsiGate est indisponible, l'opération échoue
|
||||||
|
proprement, sans repli sur une ancienne valeur.
|
||||||
|
|
||||||
|
- **Spark (agent-commun, Phase 3)** : identités référencées
|
||||||
|
`coffre:<slug>/<nom>` (slug 3–64, nom 3–128, minuscules /
|
||||||
|
chiffres / `-` / `_`), sans colonne valeur ; le LLM ne voit que
|
||||||
|
la référence et un accès oui/non. Sans octroi, la mission passe
|
||||||
|
en `acces_requis` en nommant l'accès manquant ; avec octroi et
|
||||||
|
mission approuvée (enveloppe budgétaire), distribution puis
|
||||||
|
injection en variable d'environnement de l'exécuteur, jamais
|
||||||
|
dans le prompt, jamais dans les étapes d'activité. Fin de
|
||||||
|
mission = environnement détruit.
|
||||||
|
- **TikTak (Phase 4)** : référence seule stockée ; distribution
|
||||||
|
serveur à chaque synchro ; masquage côté Android (ni référence
|
||||||
|
complète ni valeur dans les réponses vues par une clé `tck_`).
|
||||||
|
Les configurations existantes sont marquées `a_resaisir = 1`
|
||||||
|
(migration 019 : colonne en clair supprimée, référence vide) :
|
||||||
|
il n'y a **pas de conversion automatique** — chaque
|
||||||
|
configuration doit être re-saisie par référence.
|
||||||
|
- **Flowdeck (Phase 4)** : `workspace_id` + référence sur les
|
||||||
|
connecteurs ; distribution en mémoire ; isolation A vs B
|
||||||
|
testée (un workspace ne distribue pas avec l'octroi d'un autre).
|
||||||
|
Les connecteurs natifs Gitea/GitHub et les clés LLM ne sont pas
|
||||||
|
basculés ; pas de sélecteur UI (API seulement).
|
||||||
|
- **Kanbante (Phase 4)** : secret webhook migré en référence,
|
||||||
|
distribuée à chaque webhook puis oubliée ; client générique
|
||||||
|
réutilisable. Les jetons forge chiffrés Fernet ne sont **pas**
|
||||||
|
migrés (distribution par appel requise, incompatible avec le
|
||||||
|
cache `ForgeRegistry`) ; les secrets webhook historiques en
|
||||||
|
clair restent acceptés par compatibilité, sans migration
|
||||||
|
automatique.
|
||||||
|
|
||||||
|
### Étape 6 — Valider (avant purge)
|
||||||
|
|
||||||
|
Exécuter la checklist de validation de l'instance réelle (§4).
|
||||||
|
Tant qu'un point échoue, ne pas purger et ne pas considérer la
|
||||||
|
mise en service comme terminée.
|
||||||
|
|
||||||
|
### Étape 7 — Purger, seulement après validation
|
||||||
|
|
||||||
|
`POST /api/coffres/migration/api-keys/purger`, **après
|
||||||
|
confirmation explicite** (`confirmer = true`), et uniquement pour
|
||||||
|
les noms migrés. Sans confirmation, rien n'est supprimé — jamais
|
||||||
|
automatiquement.
|
||||||
|
|
||||||
|
Après purge, vérifier :
|
||||||
|
|
||||||
|
- `api_keys.json` ne contient plus les noms purgés ;
|
||||||
|
- l'IA et les outils fonctionnent toujours (lecteurs branchés,
|
||||||
|
étape 4) ;
|
||||||
|
- une distribution de test via un consommateur existant aboutit
|
||||||
|
(succès seul vérifié, valeur ni affichée ni consignée).
|
||||||
|
|
||||||
|
### Étape 8 — Sauvegarder et tester la restauration
|
||||||
|
|
||||||
|
1. Sauvegarder `data/coffres.json` + `data/coffres_valeurs.json`
|
||||||
|
(0600) par la sauvegarde habituelle du serveur, et la KEK à
|
||||||
|
part, hors serveur (§1.3).
|
||||||
|
2. Rejouer la restauration sur une instance vierge (procédure
|
||||||
|
`docs/COFFRE-sauvegarde-restauration.md` §3 : arrêt de la
|
||||||
|
cible, copie des deux fichiers en 0600, même KEK, redémarrage,
|
||||||
|
vérification `has_value = true` + distribution de test +
|
||||||
|
`distribution_ok` dans le journal). La suite automatisée
|
||||||
|
(`tests/test_coffre_secrets.py`, classe
|
||||||
|
`TestRestaurationVierge`) simule cette restauration ; elle ne
|
||||||
|
remplace pas un essai sur instance réelle.
|
||||||
|
3. En cas de perte de KEK : perte d'accès assumée, par
|
||||||
|
construction — aucun moyen de récupérer les valeurs. Recréer
|
||||||
|
les coffres avec une nouvelle KEK, régénérer les valeurs côté
|
||||||
|
fournisseur, révoquer les anciennes côté fournisseur, recréer
|
||||||
|
consommateurs et octrois. Ne jamais tenter de « réparer » les
|
||||||
|
anciens fichiers avec une nouvelle KEK (le déchiffrement
|
||||||
|
échouera sur tag GCM invalide, ce qui est attendu).
|
||||||
|
|
||||||
|
## 4. Checklist de validation par Bruno sur instance réelle
|
||||||
|
|
||||||
|
Validation exigée par l'issue #23 avant tout usage réel. Chaque
|
||||||
|
point est validé sur capture, comme pour les autres livraisons :
|
||||||
|
|
||||||
|
- [ ] **Sidebar** : le nœud « Secrets » (cadenas U+1F510)
|
||||||
|
apparaît sous le Home de l'utilisateur courant seulement, et
|
||||||
|
pas sous les autres vaults ; cliquer dessus ouvre la vue
|
||||||
|
Secrets sans naviguer dans les fichiers.
|
||||||
|
- [ ] **Secret de test** : création d'un secret de test
|
||||||
|
(valeur fictive, ex. `TEST-VALUE-XXXX`) dans un coffre
|
||||||
|
personnel, puis lecture : métadonnées seules, aucune valeur
|
||||||
|
affichée, aucun texte chiffré, aucun nonce.
|
||||||
|
- [ ] **Journal d'audit** : après une distribution de test, le
|
||||||
|
journal contient `distribution_ok` pour la référence, et un
|
||||||
|
`grep` du journal sur la valeur de test ne renvoie **rien**
|
||||||
|
(ni valeur, ni texte chiffré, ni nonce, ni jeton).
|
||||||
|
- [ ] **Autorisations** : un admin ne voit pas le coffre
|
||||||
|
personnel d'autrui ; une ACL `*` sur un vault ne donne aucun
|
||||||
|
droit sur un coffre ; une session utilisateur, même admin, ne
|
||||||
|
distribue pas (401 `consommateur_invalide`).
|
||||||
|
- [ ] **Distribution** : contexte manquant refusé (400
|
||||||
|
`contexte_manquant`) ; octroi révoqué refusé dès l'appel
|
||||||
|
suivant (403 `octroi_revoque`) ; octroi expiré refusé (403
|
||||||
|
`octroi_expire`) ; secret expiré refusé (410 `secret_expire`).
|
||||||
|
- [ ] **Import / purge** : l'import ne modifie pas
|
||||||
|
`api_keys.json` et n'écrase pas un secret existant ; la purge
|
||||||
|
sans confirmation explicite ne supprime rien.
|
||||||
|
- [ ] **Restauration** : sauvegarde + KEK externe restaurées sur
|
||||||
|
une instance vierge, distributions fonctionnelles.
|
||||||
|
- [ ] **Intégrations** : mission Spark en `acces_requis` sans
|
||||||
|
octroi, puis injection exécuteur sans trace dans les étapes ;
|
||||||
|
synchro TikTak sans valeur côté Android ; isolation workspace
|
||||||
|
Flowdeck (A ne distribue pas avec l'octroi de B).
|
||||||
|
- [ ] **Modale** : la modale de création de secret n'est plus
|
||||||
|
coupée en haut (`.obsigate-modal` bornée au viewport, corps
|
||||||
|
seul défilant, en-tête et pied toujours visibles).
|
||||||
|
|
||||||
|
## 5. Limites connues à la mise en service
|
||||||
|
|
||||||
|
À lire avant de déclarer la mise en service terminée :
|
||||||
|
|
||||||
|
- **Lecteurs internes non branchés** (`ai.py`,
|
||||||
|
`tools/secrets.py` lisent encore `api_keys.json` en clair) :
|
||||||
|
purger avant de les brancher priverait l'IA/outils de leurs
|
||||||
|
clés (§3, étape 4).
|
||||||
|
- **Endpoint dédié de copie manuelle par session non ajouté**
|
||||||
|
(décision volontaire, §2) : la copie manuelle passe par un
|
||||||
|
consommateur temporaire admin, presse-papiers seul.
|
||||||
|
- **Isolation workspace Flowdeck non contrôlée côté ObsiGate** :
|
||||||
|
`/distribuer` n'autorise aujourd'hui que les octrois de type
|
||||||
|
`consommateur` ; l'isolation workspace repose sur le serveur
|
||||||
|
Flowdeck lui-même (il ne demande que les références de son
|
||||||
|
workspace, avec son propre jeton). Un contrôle serveur strict
|
||||||
|
reste à spécifier si Bruno l'exige.
|
||||||
|
- **Identités agent-commun** : détenteur libre hors coffre
|
||||||
|
conservé (historique) ; révocation Identités non propagée vers
|
||||||
|
ObsiGate (pas d'endpoint) ; exécuteurs CLI synchrones
|
||||||
|
uniquement.
|
||||||
|
- **TikTak** : « sélecteur » = saisie validée, pas de liste
|
||||||
|
déroulante (aucune API de listage côté ObsiGate) ;
|
||||||
|
configurations existantes à re-saisir, pas de conversion
|
||||||
|
automatique.
|
||||||
|
- **Flowdeck** : natifs Gitea/GitHub et clés LLM non basculés ;
|
||||||
|
pas de sélecteur UI (API seulement) ; `workspace_id` pas encore
|
||||||
|
branché depuis AgentEngine.
|
||||||
|
- **Kanbante** : jetons forge chiffrés Fernet non migrés ;
|
||||||
|
pas de migration automatique des secrets webhook historiques.
|
||||||
|
- **Recherche / graphe / index** : par construction, un coffre
|
||||||
|
n'est pas un vault de notes — il ne passe ni par l'indexeur, ni
|
||||||
|
par la recherche, ni par le graphe, et aucun outil MCP de secret
|
||||||
|
n'existe. Aucune métadonnée de secret ne doit apparaître dans
|
||||||
|
ces index (vérification par revue de code, pas de suite
|
||||||
|
automatisée dédiée — voir le bilan Phase 5).
|
||||||
|
|
||||||
|
## 6. Repli (rollback)
|
||||||
|
|
||||||
|
- Désactiver le flag (`OBSIGATE_SECRETS_ENABLED` absent ou
|
||||||
|
`false`) : toutes les routes coffre répondent 503, les fichiers
|
||||||
|
`coffres.json` / `coffres_valeurs.json` restent intacts et
|
||||||
|
re-distribueront dès réactivation avec la même KEK.
|
||||||
|
- Révoquer un consommateur (`DELETE
|
||||||
|
/api/coffres/consommateurs/{id}`) coupe immédiatement ses
|
||||||
|
distributions, sans toucher aux autres consommateurs ni aux
|
||||||
|
secrets.
|
||||||
|
- Ne jamais « réparer » des fichiers chiffrés avec une nouvelle
|
||||||
|
KEK, ne jamais restaurer `coffres.json` sans
|
||||||
|
`coffres_valeurs.json` (et inversement), ne jamais copier la
|
||||||
|
KEK au même endroit que la sauvegarde.
|
||||||
|
|
||||||
|
## 7. Références
|
||||||
|
|
||||||
|
- Spec « Coffre de secrets ObsiGate » §6 (autorisations), §7/§10/§11
|
||||||
|
(API), §13/§14 (sauvegarde/restauration), §15/§16 (tests,
|
||||||
|
déploiement).
|
||||||
|
- Issues ObsiGate : #17 (Phase 1a), #18 (Phase 1b), #19 (Phase 1c
|
||||||
|
UI), #20 (Phase 2), #21 (Phase 3, Spark), #22 (Phase 4, TikTak /
|
||||||
|
Flowdeck / Kanbante), #23 (Phase 5, ce document).
|
||||||
|
- Suites de tests : `tests/test_coffre_secrets.py` (ObsiGate, 35
|
||||||
|
tests), `tests/test_identites_coffre_phase3.py` (agent-commun,
|
||||||
|
16 tests), `tests/test_coffre_reference.py` (TikTak, 17 fonctions
|
||||||
|
/ 26 cas), `tests/test_coffre_reference_flowdeck.py` (Flowdeck,
|
||||||
|
16 tests), `tests/test_coffre_client.py` (Kanbante, 11 fonctions
|
||||||
|
/ 25 cas). Voir le bilan Phase 5 pour la matrice de couverture
|
||||||
|
complète et les manques.
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# ObsiGate — Roadmap
|
# ObsiGate — Roadmap
|
||||||
|
|
||||||
> **Version :** 2.80.2 | **Dernière mise à jour :** 2026-10-27
|
> **Version :** 2.81.2 | **Dernière mise à jour :** 2026-10-28
|
||||||
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||||
> vers les fonctionnalités livrées.
|
> vers les fonctionnalités livrées.
|
||||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||||
|
|||||||
+89
-17
@@ -37,16 +37,19 @@
|
|||||||
display: flex; align-items: center; gap: 9px;
|
display: flex; align-items: center; gap: 9px;
|
||||||
padding: 13px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
|
padding: 13px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
|
||||||
}
|
}
|
||||||
.draco-panneau-hd .titre { font-weight: 700; font-size: 15px; }
|
.draco-panneau-titres { min-width: 0; flex: 1; }
|
||||||
.draco-panneau-hd .soustitre { font-size: 12px; color: var(--p-doux); }
|
.draco-panneau-hd .titre { font-weight: 700; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.draco-panneau-hd .soustitre {
|
||||||
|
font-size: 12px; color: var(--p-doux); display: flex; align-items: center; gap: 6px;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.draco-panneau-hd .soustitre .point { width: 8px; height: 8px; border-radius: 50%; background: #b9bdb2; flex: none; }
|
||||||
|
.draco-panneau-hd .soustitre.ok .point { background: #2ea36b; }
|
||||||
|
.draco-panneau-hd .soustitre.err .point { background: #cf4b32; }
|
||||||
.draco-panneau-hd .fermer {
|
.draco-panneau-hd .fermer {
|
||||||
margin-left: auto; border: 0; background: var(--p-agent); color: var(--p-texte);
|
margin-left: auto; border: 0; background: var(--p-agent); color: var(--p-texte);
|
||||||
border-radius: 8px; width: 30px; height: 30px; cursor: pointer; font-size: 14px;
|
border-radius: 8px; width: 30px; height: 30px; cursor: pointer; font-size: 14px; flex: none;
|
||||||
}
|
}
|
||||||
.draco-panneau-etat { display: flex; align-items: center; gap: 7px; font-size: 12.5px; }
|
|
||||||
.draco-panneau-etat .point { width: 9px; height: 9px; border-radius: 50%; background: #b9bdb2; flex: none; }
|
|
||||||
.draco-panneau-etat.ok .point { background: #2ea36b; }
|
|
||||||
.draco-panneau-etat.err .point { background: #cf4b32; }
|
|
||||||
/* Barre des discussions (façon Muse) */
|
/* Barre des discussions (façon Muse) */
|
||||||
.draco-disc-bar {
|
.draco-disc-bar {
|
||||||
display: flex; align-items: center; gap: 7px;
|
display: flex; align-items: center; gap: 7px;
|
||||||
@@ -112,18 +115,87 @@
|
|||||||
.draco-msg.user { align-self: flex-end; background: #2f6fed; color: #fff; border-bottom-right-radius: 4px; }
|
.draco-msg.user { align-self: flex-end; background: #2f6fed; color: #fff; border-bottom-right-radius: 4px; }
|
||||||
.draco-msg.agent { align-self: flex-start; background: var(--p-agent); border-bottom-left-radius: 4px; }
|
.draco-msg.agent { align-self: flex-start; background: var(--p-agent); border-bottom-left-radius: 4px; }
|
||||||
.draco-msg.sys { align-self: center; background: none; color: var(--p-doux); font-size: 12.5px; text-align: center; max-width: 96%; }
|
.draco-msg.sys { align-self: center; background: none; color: var(--p-doux); font-size: 12.5px; text-align: center; max-width: 96%; }
|
||||||
.draco-panneau-ft { border-top: 1px solid var(--p-bord); padding: 11px 12px; background: var(--p-surface); }
|
.draco-panneau-ft { padding: 10px 12px 11px; background: var(--p-fond); }
|
||||||
.draco-panneau-form { display: flex; gap: 8px; }
|
/* Puces de suggestions, comme la console : au-dessus du composer. */
|
||||||
.draco-panneau-form input {
|
.draco-puces {
|
||||||
flex: 1; border: 1px solid var(--p-chip-bord); border-radius: 10px; padding: 9px 11px;
|
display: flex; flex-wrap: wrap; gap: 7px; justify-content: center; padding: 0 2px 8px;
|
||||||
font: inherit; background: var(--p-champ); color: var(--p-texte);
|
|
||||||
}
|
}
|
||||||
.draco-panneau-form button {
|
.draco-puces button {
|
||||||
border: 0; border-radius: 10px; background: var(--p-accent); color: #fff;
|
border: 1px solid var(--p-chip-bord); background: var(--p-chip); color: var(--p-texte);
|
||||||
padding: 9px 14px; font: inherit; font-weight: 600; cursor: pointer;
|
border-radius: 999px; padding: 5px 12px; font: 600 12.5px system-ui; cursor: pointer;
|
||||||
|
}
|
||||||
|
.draco-puces button:hover { background: var(--p-chip-survol); }
|
||||||
|
/* ── Composer façon console (pilule) ─────────────────────────
|
||||||
|
* +, textarea auto-extensible, micro ou envoi rond bleu ↑ ;
|
||||||
|
* pendant la dictée, la rangée d'enregistrement remplace la saisie. */
|
||||||
|
.draco-composer {
|
||||||
|
display: flex; flex-direction: column; align-items: stretch; gap: 6px;
|
||||||
|
background: var(--p-surface); border: 1px solid var(--p-bord);
|
||||||
|
border-radius: 24px; padding: 8px 10px;
|
||||||
|
box-shadow: 0 1px 4px rgb(0 0 0 / .05);
|
||||||
|
}
|
||||||
|
.draco-composer [hidden] { display: none !important; }
|
||||||
|
.draco-composer-ligne { display: flex; align-items: flex-end; gap: 6px; }
|
||||||
|
.draco-composer textarea {
|
||||||
|
flex: 1; border: 0; background: none; resize: none; outline: none;
|
||||||
|
padding: 8px 2px; max-height: 152px; overflow-y: auto; line-height: 1.45;
|
||||||
|
min-height: calc(1.45em + 16px); font: inherit; color: var(--p-texte);
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.draco-composer textarea::-webkit-scrollbar { width: 0; height: 0; display: none; }
|
||||||
|
.draco-composer .composer-btn {
|
||||||
|
width: 36px; height: 36px; border-radius: 50%; border: 0; flex: none;
|
||||||
|
background: var(--p-chip); color: var(--p-texte); font-size: 17px;
|
||||||
|
display: flex; align-items: center; justify-content: center; padding: 0; cursor: pointer;
|
||||||
|
}
|
||||||
|
.draco-composer .composer-btn:hover { background: var(--p-chip-survol); }
|
||||||
|
.draco-composer .composer-btn.danger { color: var(--p-danger); }
|
||||||
|
.draco-composer .composer-envoi {
|
||||||
|
width: 36px; height: 36px; border-radius: 50%; border: 0; flex: none;
|
||||||
|
background: #2f6fed; color: #fff; font-size: 17px; font-weight: 700;
|
||||||
|
display: flex; align-items: center; justify-content: center; padding: 0; cursor: pointer;
|
||||||
|
}
|
||||||
|
.draco-composer .composer-envoi:disabled { opacity: .5; }
|
||||||
|
.draco-composer .composer-btn .ic, .draco-composer .composer-envoi .ic { width: 19px; height: 19px; }
|
||||||
|
.draco-composer-pieces { display: flex; flex-wrap: wrap; gap: 8px; padding: 2px 2px 0; }
|
||||||
|
.draco-composer-pieces .piece-carte {
|
||||||
|
position: relative; display: flex; align-items: center; gap: 8px;
|
||||||
|
background: var(--p-chip); border: 1px solid var(--p-chip-bord);
|
||||||
|
border-radius: 12px; padding: 5px 9px 5px 5px; max-width: 230px;
|
||||||
|
}
|
||||||
|
.draco-composer-pieces .piece-carte img { width: 42px; height: 42px; object-fit: cover; border-radius: 8px; display: block; }
|
||||||
|
.draco-composer-pieces .piece-nom { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.draco-composer-pieces .piece-doc-ext {
|
||||||
|
width: 42px; height: 42px; display: flex; align-items: center; justify-content: center;
|
||||||
|
background: var(--p-chip); border: 1px solid var(--p-chip-bord); border-radius: 8px;
|
||||||
|
}
|
||||||
|
.draco-composer-pieces .piece-ext { font-weight: 700; font-size: 10px; letter-spacing: .04em; text-transform: uppercase; opacity: .75; }
|
||||||
|
.draco-composer-pieces .piece-retirer {
|
||||||
|
position: absolute; top: -7px; right: -7px; width: 20px; height: 20px;
|
||||||
|
border-radius: 50%; border: 0; background: var(--p-texte); color: var(--p-fond);
|
||||||
|
font-size: 10px; line-height: 1; padding: 0; cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.draco-composer-enreg { display: flex; align-items: center; gap: 10px; padding: 0 2px; }
|
||||||
|
.draco-composer-enreg .enreg-temps { font-variant-numeric: tabular-nums; font-weight: 700; min-width: 36px; }
|
||||||
|
.draco-composer-enreg .enreg-texte {
|
||||||
|
flex: 1; min-width: 0; color: var(--p-doux); font-size: 13px;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.draco-composer-enreg .enreg-onde { display: flex; align-items: center; gap: 3px; height: 26px; flex: none; }
|
||||||
|
.draco-composer-enreg .enreg-onde span { width: 4px; height: 30%; border-radius: 2px; background: #2f6fed; }
|
||||||
|
.draco-composer.en-cours .enreg-onde span { animation: onde 1.05s ease-in-out infinite; }
|
||||||
|
.draco-composer-enreg .enreg-onde span:nth-child(3n+2) { animation-delay: .14s; }
|
||||||
|
.draco-composer-enreg .enreg-onde span:nth-child(3n) { animation-delay: .28s; }
|
||||||
|
@keyframes onde { 0%, 100% { height: 22%; } 50% { height: 96%; } }
|
||||||
|
/* Pièces jointes dans une bulle utilisateur */
|
||||||
|
.draco-msg-pieces { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 7px; }
|
||||||
|
.draco-msg-pieces img { max-width: 150px; max-height: 120px; border-radius: 10px; display: block; }
|
||||||
|
.draco-msg-pieces .piece-fichier {
|
||||||
|
display: inline-flex; align-items: center; gap: 7px;
|
||||||
|
background: var(--p-chip); border: 1px solid var(--p-chip-bord); color: var(--p-texte);
|
||||||
|
border-radius: 9px; padding: 6px 9px; font-size: 12px;
|
||||||
}
|
}
|
||||||
.draco-panneau-form button:disabled { opacity: .5; cursor: default; }
|
|
||||||
.draco-panneau-note { font-size: 11.5px; color: var(--p-faint); margin-top: 8px; text-align: center; }
|
|
||||||
.draco-missions { align-self: stretch; background: var(--p-surface); border: 1px solid var(--p-bord); border-radius: 12px; padding: 10px 12px; }
|
.draco-missions { align-self: stretch; background: var(--p-surface); border: 1px solid var(--p-bord); border-radius: 12px; padding: 10px 12px; }
|
||||||
.draco-missions h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: var(--p-doux); margin: 0 0 6px; }
|
.draco-missions h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: var(--p-doux); margin: 0 0 6px; }
|
||||||
.draco-missions label { display: block; font-size: 12px; font-weight: 600; color: var(--p-doux); margin: 8px 0 3px; }
|
.draco-missions label { display: block; font-size: 12px; font-weight: 600; color: var(--p-doux); margin: 8px 0 3px; }
|
||||||
|
|||||||
+332
-23
@@ -40,7 +40,10 @@
|
|||||||
var convKind = null; // 'principale' | 'parallele'
|
var convKind = null; // 'principale' | 'parallele'
|
||||||
var convTitre = "";
|
var convTitre = "";
|
||||||
var etatConnu = false;
|
var etatConnu = false;
|
||||||
|
var domaineCharge = null; // domaine des discussions affichées
|
||||||
|
var consoleUrlCharge = null; // URL (disc+domaine+app) de l'iframe console
|
||||||
var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
|
var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
|
||||||
|
var microEl = null, piecesEl = null, fichierEl = null;
|
||||||
var titreEl = null, tiroirEl = null;
|
var titreEl = null, tiroirEl = null;
|
||||||
var versionEl = null, versionConnue = "", versionMaj = 0;
|
var versionEl = null, versionConnue = "", versionMaj = 0;
|
||||||
|
|
||||||
@@ -68,11 +71,16 @@
|
|||||||
return correspondance[h] || null;
|
return correspondance[h] || null;
|
||||||
}
|
}
|
||||||
function urlConsole() {
|
function urlConsole() {
|
||||||
|
/* Le domaine de l'hôte est passé explicitement : la console
|
||||||
|
* vit sur une autre origine, son localStorage ne voit jamais
|
||||||
|
* celui de l'app hôte — sans ce paramètre, elle rouvrait
|
||||||
|
* toujours le domaine de sa propre origine, pas celui du site. */
|
||||||
var params = [];
|
var params = [];
|
||||||
var id = convId || convStockee();
|
var id = convId || convStockee();
|
||||||
if (id) params.push("disc=" + encodeURIComponent(id));
|
if (id) params.push("disc=" + encodeURIComponent(id));
|
||||||
var app = appHote();
|
var app = appHote();
|
||||||
if (app) params.push("app=" + encodeURIComponent(app));
|
if (app) params.push("app=" + encodeURIComponent(app));
|
||||||
|
params.push("domain=" + encodeURIComponent(domaine()));
|
||||||
return API + "/" + (params.length ? "?" + params.join("&") : "");
|
return API + "/" + (params.length ? "?" + params.join("&") : "");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +155,15 @@
|
|||||||
|
|
||||||
function ouvrirConsole() {
|
function ouvrirConsole() {
|
||||||
assurerSurimpression();
|
assurerSurimpression();
|
||||||
if (!surCadre.getAttribute("src")) surCadre.src = urlConsole();
|
/* L'iframe n'est rechargée que si sa cible a changé
|
||||||
|
* (discussion, domaine ou app) : même cible = état conservé,
|
||||||
|
* cible différente = la console s'ajuste, au lieu de garder
|
||||||
|
* la discussion du premier chargement. */
|
||||||
|
var url = urlConsole();
|
||||||
|
if (!surCadre.getAttribute("src") || url !== consoleUrlCharge) {
|
||||||
|
surCadre.src = url;
|
||||||
|
consoleUrlCharge = url;
|
||||||
|
}
|
||||||
if (surOuverte) return;
|
if (surOuverte) return;
|
||||||
surOuverte = true;
|
surOuverte = true;
|
||||||
/* Naissance à la géométrie du panneau (ancrée à droite), puis
|
/* Naissance à la géométrie du panneau (ancrée à droite), puis
|
||||||
@@ -175,7 +191,12 @@
|
|||||||
setTimeout(terminer, 380); // filet si transitionend ne vient pas
|
setTimeout(terminer, 380); // filet si transitionend ne vient pas
|
||||||
surEl.style.width = largeurPanneau() + "px";
|
surEl.style.width = largeurPanneau() + "px";
|
||||||
}
|
}
|
||||||
function modeSidebarConsole() { retrecirConsole(); }
|
function modeSidebarConsole() {
|
||||||
|
retrecirConsole();
|
||||||
|
/* Retour au panneau : si le domaine a changé pendant que la
|
||||||
|
* console occupait tout l'écran, le panneau s'ajuste aussi. */
|
||||||
|
resynchroniserDomaine();
|
||||||
|
}
|
||||||
function fermerAgentConsole() { fermer(); retrecirConsole(); }
|
function fermerAgentConsole() { fermer(); retrecirConsole(); }
|
||||||
|
|
||||||
/* ── Thème : suivre l'app hôte ─────────────────────────────── */
|
/* ── Thème : suivre l'app hôte ─────────────────────────────── */
|
||||||
@@ -246,13 +267,10 @@
|
|||||||
'<div class="draco-poignee" title="Glisser pour régler la largeur — double-clic : largeur par défaut"></div>' +
|
'<div class="draco-poignee" title="Glisser pour régler la largeur — double-clic : largeur par défaut"></div>' +
|
||||||
'<div class="draco-panneau-hd">' +
|
'<div class="draco-panneau-hd">' +
|
||||||
' <span style="font-size:19px;color:#7c5cd6">✦</span>' +
|
' <span style="font-size:19px;color:#7c5cd6">✦</span>' +
|
||||||
' <div><div class="titre">Spark — agent commun</div>' +
|
' <div class="draco-panneau-titres"><div class="titre">Spark — agent commun</div>' +
|
||||||
' <div class="soustitre">Service d\'agents dracodev</div></div>' +
|
' <div class="soustitre"><span class="point"></span><span class="txt">Connexion au service…</span></div></div>' +
|
||||||
' <button type="button" class="fermer" aria-label="Fermer le panneau">✕</button>' +
|
' <button type="button" class="fermer" aria-label="Fermer le panneau">✕</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="draco-panneau-hd draco-panneau-etat" style="padding-top:8px;padding-bottom:8px">' +
|
|
||||||
' <span class="point"></span><span class="txt">Connexion au service…</span>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="draco-disc-bar">' +
|
'<div class="draco-disc-bar">' +
|
||||||
' <span class="d-titre">Discussion principale</span>' +
|
' <span class="d-titre">Discussion principale</span>' +
|
||||||
' <button type="button" class="d-btn" data-disc="liste">☰ Discussions</button>' +
|
' <button type="button" class="d-btn" data-disc="liste">☰ Discussions</button>' +
|
||||||
@@ -266,17 +284,40 @@
|
|||||||
' <button type="button" data-outil="missions">🚀 Missions</button>' +
|
' <button type="button" data-outil="missions">🚀 Missions</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="draco-panneau-msgs"></div>' +
|
'<div class="draco-panneau-msgs"></div>' +
|
||||||
'<div class="draco-panneau-ft"><form class="draco-panneau-form">' +
|
'<div class="draco-panneau-ft">' +
|
||||||
' <input type="text" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark">' +
|
' <div class="draco-puces">' +
|
||||||
' <button type="submit">Envoyer</button></form>' +
|
' <button type="button" data-sugg="resume">Résumer ma journée</button>' +
|
||||||
' <div class="draco-panneau-note">Conversation réelle, sous-agents (J3) et missions longues (J5) vers les exécuteurs configurés ; toute écriture dans les apps passe par une approbation.</div>' +
|
' <button type="button" data-sugg="projets">Où en sont mes projets ?</button>' +
|
||||||
|
' <button type="button" data-sugg="chercher">Chercher partout…</button>' +
|
||||||
|
' </div>' +
|
||||||
|
' <form class="draco-panneau-form draco-composer">' +
|
||||||
|
' <div class="draco-composer-pieces" hidden></div>' +
|
||||||
|
' <div class="draco-composer-ligne">' +
|
||||||
|
' <button type="button" class="composer-btn" data-comp="plus" title="Joindre une image ou un document" aria-label="Joindre une image ou un document"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14"/><path d="M5 12h14"/></svg></button>' +
|
||||||
|
' <textarea rows="1" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark"></textarea>' +
|
||||||
|
' <input type="file" accept="image/*,.pdf,.docx,.odt,.txt,.md,.html,.csv" multiple hidden>' +
|
||||||
|
' <button type="button" class="composer-btn" data-comp="micro" title="Dictée vocale" aria-label="Dictée vocale" hidden><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><path d="M12 19v3"/></svg></button>' +
|
||||||
|
' <button type="submit" class="composer-envoi" title="Envoyer" aria-label="Envoyer" hidden><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20V4.5"/><path d="m5.5 11 6.5-6.5 6.5 6.5"/></svg></button>' +
|
||||||
|
' </div>' +
|
||||||
|
' <div class="draco-composer-enreg" hidden>' +
|
||||||
|
' <button type="button" class="composer-btn danger" data-comp="enreg-annuler" title="Annuler la dictée" aria-label="Annuler la dictée"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M10 11v6"/><path d="M14 11v6"/></svg></button>' +
|
||||||
|
' <span class="enreg-temps">0:00</span>' +
|
||||||
|
' <div class="enreg-onde" aria-hidden="true"></div>' +
|
||||||
|
' <span class="enreg-texte"></span>' +
|
||||||
|
' <button type="button" class="composer-btn" data-comp="enreg-stop" title="Arrêter et corriger la transcription" aria-label="Arrêter la dictée"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="6.5" y="6.5" width="11" height="11" rx="2.5"/></svg></button>' +
|
||||||
|
' <button type="button" class="composer-envoi" data-comp="enreg-envoyer" title="Arrêter et envoyer" aria-label="Arrêter et envoyer"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20V4.5"/><path d="m5.5 11 6.5-6.5 6.5 6.5"/></svg></button>' +
|
||||||
|
' </div>' +
|
||||||
|
' </form>' +
|
||||||
' <div class="draco-panneau-version" style="margin-top:6px;font-size:11.5px;opacity:.6" hidden></div>' +
|
' <div class="draco-panneau-version" style="margin-top:6px;font-size:11.5px;opacity:.6" hidden></div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
document.body.appendChild(root);
|
document.body.appendChild(root);
|
||||||
msgsEl = root.querySelector(".draco-panneau-msgs");
|
msgsEl = root.querySelector(".draco-panneau-msgs");
|
||||||
etatEl = root.querySelector(".draco-panneau-etat");
|
etatEl = root.querySelector(".draco-panneau-hd .soustitre");
|
||||||
inputEl = root.querySelector("input");
|
inputEl = root.querySelector(".draco-composer textarea");
|
||||||
sendBtn = root.querySelector("button[type=submit]");
|
sendBtn = root.querySelector(".draco-composer button[type=submit]");
|
||||||
|
microEl = root.querySelector('[data-comp="micro"]');
|
||||||
|
piecesEl = root.querySelector(".draco-composer-pieces");
|
||||||
|
fichierEl = root.querySelector(".draco-composer input[type=file]");
|
||||||
titreEl = root.querySelector(".d-titre");
|
titreEl = root.querySelector(".d-titre");
|
||||||
tiroirEl = root.querySelector(".draco-discussions");
|
tiroirEl = root.querySelector(".draco-discussions");
|
||||||
versionEl = root.querySelector(".draco-panneau-version");
|
versionEl = root.querySelector(".draco-panneau-version");
|
||||||
@@ -288,6 +329,42 @@
|
|||||||
root.querySelector('[data-outil="approbations"]').addEventListener("click", chargerApprobations);
|
root.querySelector('[data-outil="approbations"]').addEventListener("click", chargerApprobations);
|
||||||
root.querySelector('[data-outil="missions"]').addEventListener("click", ouvrirMissions);
|
root.querySelector('[data-outil="missions"]').addEventListener("click", ouvrirMissions);
|
||||||
root.querySelector("form").addEventListener("submit", envoyer);
|
root.querySelector("form").addEventListener("submit", envoyer);
|
||||||
|
inputEl.addEventListener("input", majComposer);
|
||||||
|
inputEl.addEventListener("keydown", function (ev) {
|
||||||
|
if (ev.key === "Enter" && !ev.shiftKey) { ev.preventDefault(); envoyerComposer(); }
|
||||||
|
});
|
||||||
|
root.querySelector('[data-comp="plus"]').addEventListener("click", function () { fichierEl.click(); });
|
||||||
|
fichierEl.addEventListener("change", function (ev) {
|
||||||
|
ajouterFichiers(ev.target.files);
|
||||||
|
ev.target.value = "";
|
||||||
|
});
|
||||||
|
microEl.addEventListener("click", demarrerDictee);
|
||||||
|
root.querySelector('[data-comp="enreg-stop"]').addEventListener("click", function () { arreterDictee("stop"); });
|
||||||
|
root.querySelector('[data-comp="enreg-annuler"]').addEventListener("click", function () { arreterDictee("annuler"); });
|
||||||
|
root.querySelector('[data-comp="enreg-envoyer"]').addEventListener("click", function () { arreterDictee("envoyer"); });
|
||||||
|
Array.prototype.forEach.call(root.querySelectorAll("[data-sugg]"), function (b) {
|
||||||
|
b.addEventListener("click", function () {
|
||||||
|
var mode = b.getAttribute("data-sugg");
|
||||||
|
if (mode === "resume") {
|
||||||
|
envoyerMessage("Résume ma journée : mes tâches du jour, les cartes en Révision et l'activité récente.", []);
|
||||||
|
} else if (mode === "projets") {
|
||||||
|
envoyerMessage("Où en sont mes projets ? Fais le point sur l'état des projets et du board.", []);
|
||||||
|
} else {
|
||||||
|
inputEl.value = "Cherche « » partout : ";
|
||||||
|
majComposer();
|
||||||
|
inputEl.focus();
|
||||||
|
inputEl.setSelectionRange(9, 9);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
/* Domaine changé dans un autre onglet pendant que le panneau
|
||||||
|
* existe : l'événement storage ne part que vers les autres
|
||||||
|
* documents, donc ici il arrive bien. Le changement dans le
|
||||||
|
* même onglet est rattrapé à chaque ouverture (ouvrir()). */
|
||||||
|
window.addEventListener("storage", function (ev) {
|
||||||
|
if (ev.key === "draco_domain") resynchroniserDomaine();
|
||||||
|
});
|
||||||
|
majComposer();
|
||||||
brancherPoignee(root.querySelector(".draco-poignee"));
|
brancherPoignee(root.querySelector(".draco-poignee"));
|
||||||
appliquerTheme();
|
appliquerTheme();
|
||||||
surveillerTheme();
|
surveillerTheme();
|
||||||
@@ -326,8 +403,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function majEtat(cls, texte) {
|
function majEtat(cls, texte) {
|
||||||
etatEl.className = "draco-panneau-hd draco-panneau-etat " + cls;
|
/* Statut intégré au titre (sous-titre de l'en-tête) : plus de
|
||||||
etatEl.querySelector(".txt").textContent = texte;
|
* barre d'état séparée. Le point vert/rouge/gris reste lisible
|
||||||
|
* sans prendre une rangée entière. */
|
||||||
|
if (!etatEl) return;
|
||||||
|
etatEl.className = "soustitre " + cls;
|
||||||
|
var t = etatEl.querySelector(".txt");
|
||||||
|
if (t) t.textContent = texte;
|
||||||
}
|
}
|
||||||
function majTitre() {
|
function majTitre() {
|
||||||
titreEl.textContent = convTitre || "Discussion principale";
|
titreEl.textContent = convTitre || "Discussion principale";
|
||||||
@@ -400,9 +482,21 @@
|
|||||||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); });
|
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Recharge la discussion du domaine courant quand il a changé
|
||||||
|
* depuis le dernier chargement (ouverture du panneau, autre
|
||||||
|
* onglet, retour du mode console). Sans changement : rien —
|
||||||
|
* on ne casse jamais une conversation en cours pour rien. */
|
||||||
|
function resynchroniserDomaine() {
|
||||||
|
if (!root || domaine() === domaineCharge) return;
|
||||||
|
msgsEl.innerHTML = "";
|
||||||
|
tiroirEl.hidden = true;
|
||||||
|
demarrerDiscussions();
|
||||||
|
}
|
||||||
|
|
||||||
function demarrerDiscussions() {
|
function demarrerDiscussions() {
|
||||||
chargerListeDiscussions()
|
chargerListeDiscussions()
|
||||||
.then(function (d) {
|
.then(function (d) {
|
||||||
|
domaineCharge = domaine();
|
||||||
var precedente = convStockee();
|
var precedente = convStockee();
|
||||||
var candidates = (d.paralleles || []).concat(d.principale ? [d.principale] : []);
|
var candidates = (d.paralleles || []).concat(d.principale ? [d.principale] : []);
|
||||||
var trouvee = candidates.filter(function (c) { return c.id === precedente; })[0];
|
var trouvee = candidates.filter(function (c) { return c.id === precedente; })[0];
|
||||||
@@ -413,6 +507,7 @@
|
|||||||
chargerHistorique();
|
chargerHistorique();
|
||||||
})
|
})
|
||||||
.catch(function () {
|
.catch(function () {
|
||||||
|
domaineCharge = null; // échec : la prochaine ouverture réessaiera
|
||||||
ajouterMsg("sys", "Discussions : service injoignable pour le moment.");
|
ajouterMsg("sys", "Discussions : service injoignable pour le moment.");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -780,19 +875,232 @@
|
|||||||
.catch(function (e) { ajouterMsg("sys", "Lancement refusé : " + e.message); });
|
.catch(function (e) { ajouterMsg("sys", "Lancement refusé : " + e.message); });
|
||||||
}
|
}
|
||||||
|
|
||||||
function envoyer(ev) {
|
/* ── Composer façon console ────────────────────────────────
|
||||||
ev.preventDefault();
|
* Mêmes règles que la console : + (images/documents, 4 max),
|
||||||
var texte = inputEl.value.trim();
|
* textarea auto-extensible, micro affiché seulement sans texte
|
||||||
if (!texte) return;
|
* ni pièce et si le navigateur le supporte, envoi rond bleu
|
||||||
|
* affiché seulement avec texte ou pièce. */
|
||||||
|
var piecesEnAttente = [];
|
||||||
|
var TYPES_IMAGES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||||
|
var TYPES_DOCUMENTS = ["application/pdf",
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
"application/vnd.oasis.opendocument.text",
|
||||||
|
"text/plain", "text/markdown", "text/html", "text/csv"];
|
||||||
|
var TAILLE_MAX_IMAGE = 5 * 1024 * 1024;
|
||||||
|
var TAILLE_MAX_DOC = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
function majComposer() {
|
||||||
|
if (!inputEl) return;
|
||||||
|
var aTexte = !!(inputEl.value && inputEl.value.trim());
|
||||||
|
sendBtn.hidden = !(aTexte || piecesEnAttente.length);
|
||||||
|
if (microEl) microEl.hidden = !dicteeSupportee || aTexte || piecesEnAttente.length;
|
||||||
|
inputEl.style.height = "auto";
|
||||||
|
var sh = inputEl.scrollHeight;
|
||||||
|
inputEl.style.height = sh ? Math.min(sh, 152) + "px" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function rendrePiecesEnAttente() {
|
||||||
|
piecesEl.innerHTML = "";
|
||||||
|
piecesEl.hidden = !piecesEnAttente.length;
|
||||||
|
piecesEnAttente.forEach(function (p, i) {
|
||||||
|
var carte = document.createElement("div");
|
||||||
|
carte.className = "piece-carte";
|
||||||
|
var apercu;
|
||||||
|
if (TYPES_IMAGES.indexOf(p.type) >= 0) {
|
||||||
|
apercu = document.createElement("img");
|
||||||
|
apercu.src = p.dataUrl; apercu.alt = p.nom;
|
||||||
|
} else {
|
||||||
|
apercu = document.createElement("span");
|
||||||
|
apercu.className = "piece-doc-ext";
|
||||||
|
var ext = document.createElement("span");
|
||||||
|
ext.className = "piece-ext";
|
||||||
|
ext.textContent = (p.nom || "").split(".").pop() || "doc";
|
||||||
|
apercu.appendChild(ext);
|
||||||
|
}
|
||||||
|
var nom = document.createElement("span");
|
||||||
|
nom.className = "piece-nom"; nom.textContent = p.nom; nom.title = p.nom;
|
||||||
|
var x = document.createElement("button");
|
||||||
|
x.type = "button"; x.className = "piece-retirer"; x.textContent = "✕";
|
||||||
|
x.title = "Retirer " + p.nom;
|
||||||
|
x.setAttribute("aria-label", "Retirer " + p.nom);
|
||||||
|
x.addEventListener("click", function () {
|
||||||
|
piecesEnAttente.splice(i, 1);
|
||||||
|
rendrePiecesEnAttente(); majComposer();
|
||||||
|
});
|
||||||
|
carte.appendChild(apercu); carte.appendChild(nom); carte.appendChild(x);
|
||||||
|
piecesEl.appendChild(carte);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ajouterFichiers(fichiers) {
|
||||||
|
Array.prototype.forEach.call(fichiers, function (f) {
|
||||||
|
if (piecesEnAttente.length >= 4) {
|
||||||
|
ajouterMsg("sys", "Au plus 4 pièces par message.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var estImage = TYPES_IMAGES.indexOf(f.type) >= 0;
|
||||||
|
var estDoc = TYPES_DOCUMENTS.indexOf(f.type) >= 0;
|
||||||
|
if (!estImage && !estDoc) {
|
||||||
|
ajouterMsg("sys", "« " + f.name + " » refusée : images PNG, JPEG, GIF ou WebP, ou documents PDF, DOCX, ODT, TXT, MD, HTML ou CSV seulement.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (estImage && f.size > TAILLE_MAX_IMAGE) {
|
||||||
|
ajouterMsg("sys", "« " + f.name + " » refusée : 5 Mo maximum par image.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (estDoc && f.size > TAILLE_MAX_DOC) {
|
||||||
|
ajouterMsg("sys", "« " + f.name + " » refusée : 10 Mo maximum par document.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var lecteur = new FileReader();
|
||||||
|
lecteur.onload = function () {
|
||||||
|
var dataUrl = String(lecteur.result || "");
|
||||||
|
piecesEnAttente.push({
|
||||||
|
nom: f.name, type: f.type,
|
||||||
|
base64: dataUrl.split(",")[1] || "", dataUrl: dataUrl
|
||||||
|
});
|
||||||
|
rendrePiecesEnAttente(); majComposer();
|
||||||
|
};
|
||||||
|
lecteur.readAsDataURL(f);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function envoyerComposer() {
|
||||||
|
var t = inputEl.value;
|
||||||
|
var pieces = piecesEnAttente;
|
||||||
|
piecesEnAttente = [];
|
||||||
inputEl.value = "";
|
inputEl.value = "";
|
||||||
ajouterMsg("user", texte);
|
rendrePiecesEnAttente();
|
||||||
|
majComposer();
|
||||||
|
envoyerMessage(t, pieces);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dictée vocale — Web Speech API, 100 % navigateur, comme
|
||||||
|
* la console (fr-CA, résultats intérimaires en direct). */
|
||||||
|
var RecoVoc = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||||
|
var dicteeSupportee = !!RecoVoc;
|
||||||
|
var reco = null, enregEnCours = false, enregDebut = 0, enregMinuteur = null;
|
||||||
|
var texteAvantDictee = "", transcriptionFinale = "";
|
||||||
|
var modeFinDictee = "stop", dicteeErreur = null;
|
||||||
|
|
||||||
|
function demarrerDictee() {
|
||||||
|
if (!dicteeSupportee || enregEnCours) return;
|
||||||
|
texteAvantDictee = inputEl.value;
|
||||||
|
transcriptionFinale = "";
|
||||||
|
modeFinDictee = "stop";
|
||||||
|
dicteeErreur = null;
|
||||||
|
try { reco = new RecoVoc(); } catch (e) { reco = null; return; }
|
||||||
|
reco.lang = "fr-CA";
|
||||||
|
reco.interimResults = true;
|
||||||
|
reco.continuous = true;
|
||||||
|
reco.onresult = function (ev) {
|
||||||
|
var interim = "";
|
||||||
|
for (var i = ev.resultIndex; i < ev.results.length; i++) {
|
||||||
|
var r = ev.results[i];
|
||||||
|
if (r.isFinal) transcriptionFinale += r[0].transcript;
|
||||||
|
else interim += r[0].transcript;
|
||||||
|
}
|
||||||
|
var affiche = (texteAvantDictee + " " + transcriptionFinale + interim).trim();
|
||||||
|
root.querySelector(".enreg-texte").textContent = affiche;
|
||||||
|
inputEl.value = affiche;
|
||||||
|
};
|
||||||
|
reco.onerror = function (ev) {
|
||||||
|
if (ev.error === "aborted") return;
|
||||||
|
dicteeErreur = ev.error || "erreur";
|
||||||
|
modeFinDictee = "annuler";
|
||||||
|
};
|
||||||
|
reco.onend = terminerDictee;
|
||||||
|
try { reco.start(); } catch (e) { reco = null; return; }
|
||||||
|
enregEnCours = true;
|
||||||
|
enregDebut = Date.now();
|
||||||
|
root.querySelector(".enreg-temps").textContent = "0:00";
|
||||||
|
root.querySelector(".enreg-texte").textContent = "";
|
||||||
|
var onde = root.querySelector(".enreg-onde");
|
||||||
|
if (!onde.children.length) {
|
||||||
|
for (var j = 0; j < 14; j++) onde.appendChild(document.createElement("span"));
|
||||||
|
}
|
||||||
|
root.querySelector(".draco-composer-ligne").hidden = true;
|
||||||
|
root.querySelector(".draco-composer-enreg").hidden = false;
|
||||||
|
root.querySelector(".draco-composer").classList.add("en-cours");
|
||||||
|
enregMinuteur = window.setInterval(function () {
|
||||||
|
var s = Math.floor((Date.now() - enregDebut) / 1000);
|
||||||
|
root.querySelector(".enreg-temps").textContent = Math.floor(s / 60) + ":" + ("0" + (s % 60)).slice(-2);
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
function arreterDictee(mode) {
|
||||||
|
modeFinDictee = mode;
|
||||||
|
if (reco) {
|
||||||
|
try { reco.stop(); } catch (e) { terminerDictee(); }
|
||||||
|
} else {
|
||||||
|
terminerDictee();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function terminerDictee() {
|
||||||
|
if (!enregEnCours) return;
|
||||||
|
enregEnCours = false;
|
||||||
|
if (enregMinuteur) { window.clearInterval(enregMinuteur); enregMinuteur = null; }
|
||||||
|
reco = null;
|
||||||
|
root.querySelector(".draco-composer-ligne").hidden = false;
|
||||||
|
root.querySelector(".draco-composer-enreg").hidden = true;
|
||||||
|
root.querySelector(".draco-composer").classList.remove("en-cours");
|
||||||
|
if (modeFinDictee === "annuler") {
|
||||||
|
inputEl.value = texteAvantDictee;
|
||||||
|
if (dicteeErreur) {
|
||||||
|
ajouterMsg("sys", "Dictée vocale interrompue (" + dicteeErreur + ") — micro refusé ou indisponible ?");
|
||||||
|
dicteeErreur = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
inputEl.value = (texteAvantDictee + " " + transcriptionFinale).trim();
|
||||||
|
}
|
||||||
|
var mode = modeFinDictee;
|
||||||
|
modeFinDictee = "stop";
|
||||||
|
majComposer();
|
||||||
|
if (mode === "envoyer") envoyerComposer();
|
||||||
|
else inputEl.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function envoyer(ev) {
|
||||||
|
if (ev) ev.preventDefault();
|
||||||
|
envoyerComposer();
|
||||||
|
}
|
||||||
|
|
||||||
|
function envoyerMessage(texte, pieces) {
|
||||||
|
texte = (texte || "").trim();
|
||||||
|
pieces = pieces || [];
|
||||||
|
if (!texte && !pieces.length) return;
|
||||||
|
var el = ajouterMsg("user", texte);
|
||||||
|
if (pieces.length) {
|
||||||
|
var zone = document.createElement("div");
|
||||||
|
zone.className = "draco-msg-pieces";
|
||||||
|
pieces.forEach(function (p) {
|
||||||
|
if (TYPES_IMAGES.indexOf(p.type) >= 0) {
|
||||||
|
var img = document.createElement("img");
|
||||||
|
img.src = p.dataUrl; img.alt = p.nom;
|
||||||
|
zone.appendChild(img);
|
||||||
|
} else {
|
||||||
|
var past = document.createElement("span");
|
||||||
|
past.className = "piece-fichier";
|
||||||
|
past.textContent = p.nom;
|
||||||
|
zone.appendChild(past);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
el.appendChild(zone);
|
||||||
|
}
|
||||||
sendBtn.disabled = true;
|
sendBtn.disabled = true;
|
||||||
var attente = ajouterMsg("agent", "Spark réfléchit…");
|
var attente = ajouterMsg("agent", "Spark réfléchit…");
|
||||||
|
var corps = { message: texte, domain: domaine(), conversation_id: convId };
|
||||||
|
if (pieces.length) {
|
||||||
|
corps.pieces = pieces.map(function (p) {
|
||||||
|
return { nom: p.nom, type: p.type, donnees_base64: p.base64 };
|
||||||
|
});
|
||||||
|
}
|
||||||
fetch(API + "/api/v1/chat", {
|
fetch(API + "/api/v1/chat", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ message: texte, domain: domaine(), conversation_id: convId })
|
body: JSON.stringify(corps)
|
||||||
})
|
})
|
||||||
.then(function (r) {
|
.then(function (r) {
|
||||||
if (r.status === 401) {
|
if (r.status === 401) {
|
||||||
@@ -831,11 +1139,12 @@
|
|||||||
attente.textContent = "Service injoignable. Vérifie que le conteneur dracodev-agent est démarré, puis réessaie.";
|
attente.textContent = "Service injoignable. Vérifie que le conteneur dracodev-agent est démarré, puis réessaie.";
|
||||||
majEtat("err", "Service d'agents injoignable");
|
majEtat("err", "Service d'agents injoignable");
|
||||||
})
|
})
|
||||||
.finally(function () { sendBtn.disabled = false; inputEl.focus(); });
|
.finally(function () { sendBtn.disabled = false; majComposer(); inputEl.focus(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
function ouvrir() {
|
function ouvrir() {
|
||||||
if (!root) { construire(); demarrerDiscussions(); }
|
if (!root) { construire(); demarrerDiscussions(); }
|
||||||
|
else resynchroniserDomaine();
|
||||||
root.classList.add("open");
|
root.classList.add("open");
|
||||||
appliquerTheme();
|
appliquerTheme();
|
||||||
verifierIdentite();
|
verifierIdentite();
|
||||||
|
|||||||
@@ -59,11 +59,11 @@ function _sharedIcon(size = 16) {
|
|||||||
// renommer/déplacer/supprimer). Le menu ⋮ est un menu propre ci-dessous.
|
// renommer/déplacer/supprimer). Le menu ⋮ est un menu propre ci-dessous.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Icône cadenas (U+1F510) du nœud Secrets, distincte de la maison et du diamant. */
|
/** Icône coffre-fort du nœud Secrets (SVG Safe Vault Streamline, fourni par Bruno), distincte de la maison et du diamant. Le tracé utilise currentColor pour rester lisible en thème clair comme sombre (le noir d'origine serait invisible en sombre). */
|
||||||
function _secretsIcon() {
|
function _secretsIcon() {
|
||||||
const s = document.createElement("span");
|
const s = document.createElement("span");
|
||||||
s.className = "coffres-icon-lock";
|
s.className = "coffres-icon-lock";
|
||||||
s.textContent = "\u{1F510}";
|
s.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14" aria-hidden="true"><g stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"><path d="M12.5 0.5h-11c-0.552285 0 -1 0.447715 -1 1V11c0 0.5523 0.447715 1 1 1h11c0.5523 0 1 -0.4477 1 -1V1.5c0 -0.552285 -0.4477 -1 -1 -1Z"/><path d="M8.5 8c0.9665 0 1.75 -0.7835 1.75 -1.75S9.4665 4.5 8.5 4.5s-1.75 0.7835 -1.75 1.75S7.5335 8 8.5 8Z"/><path d="M8.5 3.25V4.5"/><path d="M8.5 8v1.25"/><path d="M11.5 6.25h-1.25"/><path d="M6.75 6.25H5.5"/><path d="m10.62 4.12988 -0.88001 0.88"/><path d="m7.26 7.48999 -0.88 0.88"/><path d="m10.62 8.36999 -0.88001 -0.88"/><path d="m7.26 5.00988 -0.88 -0.88"/><path d="M3 4.5V8"/><path d="M2 12v1.5"/><path d="M11.5 12v1.5"/></g></svg>';
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -653,7 +653,7 @@ function applyTheme(themeKey, mode) {
|
|||||||
root.style.setProperty(key, vars[key]);
|
root.style.setProperty(key, vars[key]);
|
||||||
});
|
});
|
||||||
// #180 — the spreadsheet grid follows the active theme: derive its tokens
|
// #180 — the spreadsheet grid follows the active theme: derive its tokens
|
||||||
// from the generic surfaces so every theme (15 built-in × 4 modes, custom
|
// from the generic surfaces so every theme (16 built-in × 4 modes, custom
|
||||||
// imports) gets a matching grid instead of the hardcoded dark/light pair.
|
// imports) gets a matching grid instead of the hardcoded dark/light pair.
|
||||||
// color-mix keeps the zebra stripe subtle on both dark and light grounds.
|
// color-mix keeps the zebra stripe subtle on both dark and light grounds.
|
||||||
// #185 — `--grid-border` no longer reuses `--border` (which sits 1-2% off the
|
// #185 — `--grid-border` no longer reuses `--border` (which sits 1-2% off the
|
||||||
|
|||||||
+2
-1
@@ -14372,7 +14372,8 @@ body.has-draco-bar .app-container { height: calc(100vh - 60px); }
|
|||||||
.coffres-alerts { margin: 12px 0; }
|
.coffres-alerts { margin: 12px 0; }
|
||||||
.coffres-alert { color: var(--warning, #f59e0b); padding: 2px 0; }
|
.coffres-alert { color: var(--warning, #f59e0b); padding: 2px 0; }
|
||||||
.coffres-form { display: grid; gap: 8px; max-width: 560px; margin: 8px 0; }
|
.coffres-form { display: grid; gap: 8px; max-width: 560px; margin: 8px 0; }
|
||||||
.coffres-icon-lock { font-size: 16px; line-height: 1; }
|
.coffres-icon-lock { display: inline-flex; align-items: center; line-height: 1; }
|
||||||
|
.coffres-icon-lock svg { width: 16px; height: 16px; flex: none; }
|
||||||
.coffres-alert-dot { color: var(--warning, #f59e0b); font-weight: 700; margin-left: 4px; }
|
.coffres-alert-dot { color: var(--warning, #f59e0b); font-weight: 700; margin-left: 4px; }
|
||||||
.coffres-context-menu { position: fixed; z-index: 1000; min-width: 220px; background: var(--bg-secondary, #161b22); border: 1px solid var(--border, #21262d); border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); }
|
.coffres-context-menu { position: fixed; z-index: 1000; min-width: 220px; background: var(--bg-secondary, #161b22); border: 1px solid var(--border, #21262d); border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); }
|
||||||
.coffres-context-item { display: block; width: 100%; text-align: left; padding: 8px 12px; background: transparent; border: none; color: var(--text-primary, #e6edf3); cursor: pointer; border-radius: 6px; }
|
.coffres-context-item { display: block; width: 100%; text-align: left; padding: 8px 12px; background: transparent; border: none; color: var(--text-primary, #e6edf3); cursor: pointer; border-radius: 6px; }
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsigate",
|
"name": "obsigate",
|
||||||
"version": "2.80.2",
|
"version": "2.81.2",
|
||||||
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
||||||
"main": "patch.js",
|
"main": "patch.js",
|
||||||
"directories": {
|
"directories": {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Validates:
|
Validates:
|
||||||
- Theme JSON structure validity
|
- Theme JSON structure validity
|
||||||
- All 15 built-in themes have required keys and valid CSS colors
|
- All 16 built-in themes have required keys and valid CSS colors
|
||||||
- High-contrast and sepia mode generators produce valid CSS vars
|
- High-contrast and sepia mode generators produce valid CSS vars
|
||||||
- Import/export round-trip
|
- Import/export round-trip
|
||||||
- AVAILABLE_MODES includes all 4 modes
|
- AVAILABLE_MODES includes all 4 modes
|
||||||
@@ -80,9 +80,9 @@ class TestThemeStructure:
|
|||||||
assert '_genSepia' in self.content
|
assert '_genSepia' in self.content
|
||||||
|
|
||||||
def test_theme_count(self):
|
def test_theme_count(self):
|
||||||
"""Should have 15 built-in themes."""
|
"""Should have 16 built-in themes."""
|
||||||
names = _extract_theme_names(self.content)
|
names = _extract_theme_names(self.content)
|
||||||
assert len(names) == 15, f"Expected 15 themes, found {len(names)}: {names}"
|
assert len(names) == 16, f"Expected 16 themes, found {len(names)}: {names}"
|
||||||
|
|
||||||
def test_required_export_functions(self):
|
def test_required_export_functions(self):
|
||||||
"""All required functions should be exported."""
|
"""All required functions should be exported."""
|
||||||
|
|||||||
Reference in New Issue
Block a user