Compare commits
65 Commits
v0.3.2-bet
...
v0.4.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb3d35756a | ||
|
|
0830e64ae6 | ||
|
|
9a218b1904 | ||
|
|
399b845e14 | ||
|
|
436d5c6149 | ||
|
|
5a9edc076e | ||
|
|
5bdc7a6429 | ||
|
|
5a0480bae0 | ||
|
|
80de4dd523 | ||
|
|
de52f4ebd6 | ||
|
|
98ff0dd578 | ||
|
|
9a1ff6e8dc | ||
|
|
034b9ee0e4 | ||
|
|
c1b1fc653f | ||
|
|
50ca75180c | ||
|
|
b8aa935bec | ||
|
|
5627ddd2ce | ||
|
|
d27872572a | ||
|
|
7d0f807fb0 | ||
|
|
cbf623b98b | ||
|
|
b85ebb8e54 | ||
|
|
7cc206dc20 | ||
|
|
bf8c0fd380 | ||
|
|
08dc1fd53b | ||
|
|
13e937a11b | ||
|
|
3cf701b002 | ||
|
|
3a09e0e0c2 | ||
|
|
47fa2e01bb | ||
|
|
401292ec5b | ||
|
|
199a7e409a | ||
|
|
c91931f42f | ||
|
|
cbbb224725 | ||
|
|
8d10d2182e | ||
|
|
e9696ef82b | ||
|
|
1edd4f053a | ||
|
|
92f943c3e6 | ||
|
|
1704b196cf | ||
|
|
40ec493bae | ||
|
|
233368c954 | ||
|
|
00118f0803 | ||
|
|
167ab82978 | ||
|
|
a23c0c5b94 | ||
|
|
24b31b0b47 | ||
|
|
7ae4017672 | ||
|
|
8c540eba93 | ||
|
|
1074b019d3 | ||
|
|
2da0cf9421 | ||
|
|
9987a586e2 | ||
|
|
2827acfe96 | ||
|
|
afb6e77c7f | ||
|
|
84be22661b | ||
|
|
f9c4cf11ff | ||
|
|
eda7293286 | ||
|
|
b55feaed09 | ||
|
|
54621bd960 | ||
|
|
6bad2948c5 | ||
|
|
92eb783df0 | ||
|
|
8005e978f0 | ||
|
|
6e76e7dca6 | ||
|
|
e8f6dc4b4d | ||
|
|
bb03c9fe2d | ||
|
|
79d082180c | ||
|
|
7682717093 | ||
|
|
3948a4c656 | ||
|
|
65804aae4e |
@@ -170,7 +170,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Commit changelog
|
- name: Commit changelog
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
git config user.name "CI Bot"
|
git config user.name "CI Bot"
|
||||||
git config user.email "ci@legion-muyue.fr"
|
git config user.email "ci@legion-muyue.fr"
|
||||||
@@ -181,30 +181,45 @@ jobs:
|
|||||||
|
|
||||||
- name: Create release
|
- name: Create release
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
|
set -ex
|
||||||
if [ -z "$GITEA_TOKEN" ]; then
|
if [ -z "$GITEA_TOKEN" ]; then
|
||||||
echo "Warning: GITEATOKEN not set, skipping release"
|
echo "Error: GITEA_TOKEN secret is not set"
|
||||||
exit 0
|
exit 1
|
||||||
fi
|
fi
|
||||||
VERSION=${{ steps.version.outputs.version }}
|
VERSION=${{ steps.version.outputs.version }}
|
||||||
API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
|
API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
|
||||||
BODY=$(cat /tmp/stable_changelog.md)
|
echo "Creating release ${VERSION} at ${API}"
|
||||||
RESPONSE=$(curl -s -X POST "${API}" \
|
|
||||||
|
EXISTING=$(curl -sf -H "Authorization: token ${GITEA_TOKEN}" "${API}/tags/${VERSION}" || echo "")
|
||||||
|
if [ -n "$EXISTING" ]; then
|
||||||
|
EXISTING_ID=$(echo "$EXISTING" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || echo "")
|
||||||
|
if [ -n "$EXISTING_ID" ]; then
|
||||||
|
echo "Release ${VERSION} already exists (ID: ${EXISTING_ID}), deleting..."
|
||||||
|
curl -sf -X DELETE -H "Authorization: token ${GITEA_TOKEN}" "${API}/${EXISTING_ID}" || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
BODY=$(python3 -c "import json,sys; print(json.dumps(sys.stdin.read()))" < /tmp/stable_changelog.md)
|
||||||
|
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "${API}" \
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{
|
-d "{
|
||||||
\"tag_name\":\"${VERSION}\",
|
\"tag_name\":\"${VERSION}\",
|
||||||
\"target_commitish\":\"main\",
|
\"target_commitish\":\"main\",
|
||||||
\"name\":\"muyue ${VERSION}\",
|
\"name\":\"muyue ${VERSION}\",
|
||||||
\"body\":$(echo "$BODY" | jq -Rs .),
|
\"body\":${BODY},
|
||||||
\"draft\":false,
|
\"draft\":false,
|
||||||
\"prerelease\":false
|
\"prerelease\":false
|
||||||
}")
|
}")
|
||||||
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
|
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
|
||||||
|
RESPONSE_BODY=$(echo "$RESPONSE" | sed '$d')
|
||||||
|
echo "HTTP Status: ${HTTP_CODE}"
|
||||||
|
echo "Response: ${RESPONSE_BODY}"
|
||||||
|
RELEASE_ID=$(echo "$RESPONSE_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || echo "")
|
||||||
if [ -z "$RELEASE_ID" ]; then
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
echo "Failed to create release:"
|
echo "Failed to create release"
|
||||||
echo "$RESPONSE"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "Release ID: ${RELEASE_ID}"
|
echo "Release ID: ${RELEASE_ID}"
|
||||||
@@ -212,8 +227,12 @@ jobs:
|
|||||||
for file in dist/*.tar.gz dist/*.zip dist/checksums.txt; do
|
for file in dist/*.tar.gz dist/*.zip dist/checksums.txt; do
|
||||||
filename=$(basename "$file")
|
filename=$(basename "$file")
|
||||||
echo "Uploading ${filename}..."
|
echo "Uploading ${filename}..."
|
||||||
curl -s -X POST "${UPLOAD_URL}" \
|
UPLOAD_RESP=$(curl -s -w "\n%{http_code}" -X POST "${UPLOAD_URL}" \
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
-F "attachment=@${file};filename=${filename}" > /dev/null
|
-F "attachment=@${file};filename=${filename}")
|
||||||
|
UPLOAD_CODE=$(echo "$UPLOAD_RESP" | tail -1)
|
||||||
|
if [ "$UPLOAD_CODE" != "201" ]; then
|
||||||
|
echo "Upload failed with status ${UPLOAD_CODE}"
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
echo "Stable release ${VERSION} published!"
|
echo "Stable release ${VERSION} published!"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
|
|
||||||
var versionCmd = &cobra.Command{
|
var versionCmd = &cobra.Command{
|
||||||
Use: "version",
|
Use: "version",
|
||||||
Short: "Print version",
|
Short: "Print version info",
|
||||||
RunE: runVersion,
|
RunE: runVersion,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,6 +18,6 @@ func init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runVersion(cmd *cobra.Command, args []string) error {
|
func runVersion(cmd *cobra.Command, args []string) error {
|
||||||
fmt.Printf("Muyue version %s\n", version.Version)
|
fmt.Print(version.FullInfo())
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
1
go.mod
1
go.mod
@@ -7,6 +7,7 @@ toolchain go1.24.3
|
|||||||
require (
|
require (
|
||||||
github.com/charmbracelet/huh v1.0.0
|
github.com/charmbracelet/huh v1.0.0
|
||||||
github.com/creack/pty/v2 v2.0.1
|
github.com/creack/pty/v2 v2.0.1
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -51,6 +51,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
|||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package agent
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -14,6 +15,13 @@ type TerminalParams struct {
|
|||||||
Timeout int `json:"timeout,omitempty" description:"Timeout in seconds (default 60, max 300)"`
|
Timeout int `json:"timeout,omitempty" description:"Timeout in seconds (default 60, max 300)"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TerminalResponse struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
IsError bool `json:"is_error"`
|
||||||
|
SudoBlocked bool `json:"sudo_blocked,omitempty"`
|
||||||
|
Command string `json:"command,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
func NewTerminalTool() (*ToolDefinition, error) {
|
func NewTerminalTool() (*ToolDefinition, error) {
|
||||||
return NewTool("terminal",
|
return NewTool("terminal",
|
||||||
"Execute a shell command on the local system and return the output. Use for running builds, tests, git operations, package management, system info, or any CLI task. Commands run in the user's home directory by default. Long-running commands are auto-terminated.",
|
"Execute a shell command on the local system and return the output. Use for running builds, tests, git operations, package management, system info, or any CLI task. Commands run in the user's home directory by default. Long-running commands are auto-terminated.",
|
||||||
@@ -22,6 +30,18 @@ func NewTerminalTool() (*ToolDefinition, error) {
|
|||||||
return TextErrorResponse("command is required"), nil
|
return TextErrorResponse("command is required"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if os.Geteuid() != 0 {
|
||||||
|
trimmed := strings.TrimSpace(p.Command)
|
||||||
|
lower := strings.ToLower(trimmed)
|
||||||
|
if strings.HasPrefix(lower, "sudo ") || strings.HasPrefix(lower, "doas ") || strings.HasPrefix(lower, "run0 ") || strings.HasPrefix(lower, "pkexec ") {
|
||||||
|
return ToolResponse{
|
||||||
|
Content: fmt.Sprintf("BLOCKED: Command '%s' requires elevated privileges (%s). The current user is not root. Do NOT retry with sudo. Explain to the user that this command needs admin privileges and suggest an alternative, or tell them to run it manually in their terminal.", trimmed, strings.Fields(trimmed)[0]),
|
||||||
|
IsError: true,
|
||||||
|
Meta: map[string]string{"sudo_blocked": "true", "command": trimmed},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
timeout := time.Duration(p.Timeout) * time.Second
|
timeout := time.Duration(p.Timeout) * time.Second
|
||||||
if timeout == 0 {
|
if timeout == 0 {
|
||||||
timeout = 60 * time.Second
|
timeout = 60 * time.Second
|
||||||
|
|||||||
@@ -2,6 +2,16 @@ Tu es l'assistant IA de **Muyue Studio**, le centre de commandement de l'environ
|
|||||||
|
|
||||||
Tu es intégré dans Muyue, un gestionnaire d'environnement de développement de bureau. Ton rôle est d'aider l'utilisateur à configurer, gérer et optimiser son environnement dev.
|
Tu es intégré dans Muyue, un gestionnaire d'environnement de développement de bureau. Ton rôle est d'aider l'utilisateur à configurer, gérer et optimiser son environnement dev.
|
||||||
|
|
||||||
|
<critical_rules>
|
||||||
|
1. **AGIS, ne décris pas** — Si l'utilisateur demande de faire quelque chose, utilise les outils immédiatement. Ne dis pas "je pourrais faire X" — fais-le.
|
||||||
|
2. **SOIS AUTONOME** — Ne pose pas de questions si tu peux chercher, lire, déduire. Essaie plusieurs approches avant de bloquer. Ne t'arrête que pour les erreurs bloquantes réelles (credentials manquants, permissions, etc.).
|
||||||
|
3. **SOIS CONCIS** — Max 4 lignes par défaut. Pas de préambule ("Voici...", "Je vais..."), pas de postambule ("N'hésitez pas...", "J'espère que..."). Réponse directe. Un mot quand c'est suffisant.
|
||||||
|
4. **GÈRE LES ERREURS** — Si un outil échoue, essaie 2-3 approches alternatives avant de rapporter l'échec. Lis le message d'erreur complet, isole la cause racine.
|
||||||
|
5. **NE DEVINE PAS** — Lis les fichiers avant d'éditer. Utilise les outils pour obtenir les informations manquantes (lire, chercher, grep).
|
||||||
|
6. **CONFIDENTIALITÉ** — Ne révèle jamais les clés API, mots de passe, tokens ou informations sensibles.
|
||||||
|
7. **LANGUE** — Réponds dans la même langue que l'utilisateur.
|
||||||
|
</critical_rules>
|
||||||
|
|
||||||
## Environnement
|
## Environnement
|
||||||
|
|
||||||
Muyue gère :
|
Muyue gère :
|
||||||
@@ -13,32 +23,70 @@ Muyue gère :
|
|||||||
|
|
||||||
## Outils disponibles
|
## Outils disponibles
|
||||||
|
|
||||||
Tu as accès à des outils. Utilise-les concrètement, ne décris pas ce que tu ferais — fais-le.
|
| Outil | Usage |
|
||||||
|
|-------|-------|
|
||||||
|
| **terminal** | Exécuter des commandes shell (builds, tests, git, etc.) |
|
||||||
|
| **crush_run** | Déléguer une tâche complexe à Crush (édition de fichiers, refactoring, debug) — préfère cet outil pour les tâches multi-fichiers ou l'écriture de code |
|
||||||
|
| **read_file** | Lire le contenu d'un fichier |
|
||||||
|
| **list_files** | Lister les fichiers d'un répertoire |
|
||||||
|
| **search_files** | Chercher des fichiers par motif (glob) |
|
||||||
|
| **grep_content** | Chercher du texte dans les fichiers |
|
||||||
|
| **get_config** | Lire la configuration Muyue |
|
||||||
|
| **set_provider** | Configurer un fournisseur IA |
|
||||||
|
| **manage_ssh** | Gérer les connexions SSH |
|
||||||
|
| **web_fetch** | Récupérer le contenu d'une URL |
|
||||||
|
|
||||||
- **terminal** : Exécuter des commandes shell (builds, tests, git, etc.)
|
<tool_strategy>
|
||||||
- **crush_run** : Déléguer une tâche complexe à l'agent Crush (édition de fichiers, refactoring, debug)
|
- **Recherche avant action** — Utilise `search_files`, `grep_content`, `read_file` avant de supposer quoi que ce soit sur l'état du système
|
||||||
- **read_file** : Lire le contenu d'un fichier
|
- **Délégation intelligente** — Pour les tâches complexes (refactoring, création de fichiers, debug multi-fichiers), utilise `crush_run` au lieu d'enchaîner des commandes terminal
|
||||||
- **list_files** : Lister les fichiers d'un répertoire
|
- **Parallélisme** — Lance plusieurs appels d'outils en parallèle quand les opérations sont indépendantes
|
||||||
- **search_files** : Chercher des fichiers par motif (glob)
|
- **Troncature** — Si un résultat d'outil dépasse 2000 caractères, résume les points clés au lieu de tout afficher
|
||||||
- **grep_content** : Chercher du texte dans le contenu des fichiers
|
- **Une chose à la fois** — Sauf si les opérations sont indépendantes, exécute séquentiellement
|
||||||
- **get_config** : Lire la configuration Muyue
|
</tool_strategy>
|
||||||
- **set_provider** : Configurer un fournisseur IA
|
|
||||||
- **manage_ssh** : Gérer les connexions SSH
|
|
||||||
- **web_fetch** : Récupérer le contenu d'une URL
|
|
||||||
|
|
||||||
## Règles
|
<decision_making>
|
||||||
|
- Décide par toi-même : cherche, lis, déduis, agis
|
||||||
|
- Ne demande confirmation que pour : actions destructrices (suppression, overwrite), plusieurs approches valides avec des trade-offs importants
|
||||||
|
- Si bloqué : documente (a) ce que tu as essayé, (b) pourquoi tu es bloqué, (c) l'action minimale requise
|
||||||
|
- Ne t'arrête jamais pour : tâche trop grosse (découpe), trop de fichiers (change-les), complexité (gère-la)
|
||||||
|
</decision_making>
|
||||||
|
|
||||||
1. **AGIS, ne décris pas** — Si l'utilisateur demande de faire quelque chose, utilise les outils pour le faire. Ne dis pas "je pourrais faire X" — fais-le.
|
<error_recovery>
|
||||||
2. **Sois concis** — Pas de préambule, pas de blabla. Réponse directe.
|
1. Lis le message d'erreur complet
|
||||||
3. **Une chose à la fois** — N'appelle pas plusieurs outils simultanément sauf si c'est nécessaire.
|
2. Comprends la cause racine
|
||||||
4. **Gère les erreurs** — Si un outil échoue, essaie une approche différente avant de le dire à l'utilisateur.
|
3. Essaie une approche différente (pas la même)
|
||||||
5. **Ne devine pas** — Si tu n'as pas assez d'informations, utilise les outils pour les obtenir (lire un fichier, chercher, etc.)
|
4. Cherche du code similaire qui fonctionne
|
||||||
6. **Confidentialité** — Ne révèle jamais les clés API, mots de passe ou informations sensibles dans tes réponses.
|
5. Applique un correctif ciblé
|
||||||
7. **Langue** — Réponds dans la même langue que l'utilisateur.
|
6. Vérifie que ça marche
|
||||||
|
7. Pour chaque erreur, essaie au moins 2-3 stratégies avant de conclure que c'est bloquant
|
||||||
|
</error_recovery>
|
||||||
|
|
||||||
## Format des réponses
|
## Format des réponses
|
||||||
|
|
||||||
- Code : utilise des blocs markdown
|
- **Code** : blocs markdown avec le langage spécifié
|
||||||
- Résultats d'outils : résume les points clés, ne colle pas des milliers de lignes
|
- **Résultats d'outils** : résume les points clés, max 2000 caractères, ne copie pas des milliers de lignes
|
||||||
- Erreurs : explique clairement et propose une solution
|
- **Erreurs** : explique clairement la cause et propose une solution concrète
|
||||||
- Succès : confirme brièvement ce qui a été fait
|
- **Succès** : confirme brièvement ce qui a été fait (1 ligne)
|
||||||
|
- **Multi-fichiers** : liste les fichiers modifiés avec `fichier:ligne` pour les références
|
||||||
|
|
||||||
|
## Diagrammes Mermaid
|
||||||
|
|
||||||
|
Tu peux utiliser des diagrammes Mermaid pour visualiser des architectures, flux, séquences, etc.
|
||||||
|
Utilise un bloc code avec le langage `mermaid` :
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[Début] --> B{Décision}
|
||||||
|
B -->|Oui| C[Action]
|
||||||
|
B -->|Non| D[Fin]
|
||||||
|
```
|
||||||
|
|
||||||
|
Types utiles :
|
||||||
|
- `graph TD/LR` — Architecture, flux de données
|
||||||
|
- `sequenceDiagram` — Interactions entre composants
|
||||||
|
- `flowchart` — Processus et décisions
|
||||||
|
- `classDiagram` — Structures de données
|
||||||
|
- `erDiagram` — Schémas de base de données
|
||||||
|
- `gantt` — Planning et timelines
|
||||||
|
|
||||||
|
Utilise Mermaid quand ça apporte de la clarté : architecture complexe, flux multi-étapes, relations entre entités. Ne l'utilise pas pour du texte simple.
|
||||||
|
|||||||
263
internal/api/chat_engine.go
Normal file
263
internal/api/chat_engine.go
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/agent"
|
||||||
|
"github.com/muyue/muyue/internal/orchestrator"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
MaxToolIterations = 15
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChatEngine handles chat interactions with tool execution.
|
||||||
|
// This deduplicates chat logic previously repeated in handlers_chat.go and handlers_shell_chat.go.
|
||||||
|
type ChatEngine struct {
|
||||||
|
orchestrator *orchestrator.Orchestrator
|
||||||
|
registry *agent.Registry
|
||||||
|
tools json.RawMessage
|
||||||
|
onChunk func(map[string]interface{})
|
||||||
|
stream bool
|
||||||
|
TotalTokens int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewChatEngine creates a new ChatEngine instance.
|
||||||
|
func NewChatEngine(orb *orchestrator.Orchestrator, registry *agent.Registry, tools json.RawMessage) *ChatEngine {
|
||||||
|
return &ChatEngine{
|
||||||
|
orchestrator: orb,
|
||||||
|
registry: registry,
|
||||||
|
tools: tools,
|
||||||
|
stream: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetStream enables streaming mode for the chat engine.
|
||||||
|
func (ce *ChatEngine) SetStream(enabled bool) {
|
||||||
|
ce.stream = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnChunk sets the callback for SSE chunk writing.
|
||||||
|
func (ce *ChatEngine) OnChunk(fn func(map[string]interface{})) {
|
||||||
|
ce.onChunk = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunWithTools executes the chat loop with tool calls.
|
||||||
|
// Returns final content, tool calls, tool results, and error.
|
||||||
|
func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.Message) (string, []map[string]interface{}, []map[string]interface{}, error) {
|
||||||
|
var finalContent string
|
||||||
|
var allToolCalls []map[string]interface{}
|
||||||
|
var allToolResults []map[string]interface{}
|
||||||
|
|
||||||
|
for i := 0; i < MaxToolIterations; i++ {
|
||||||
|
var resp *orchestrator.ChatResponse
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if ce.stream {
|
||||||
|
// Use streaming version
|
||||||
|
resp, err = ce.orchestrator.SendWithToolsStream(messages, func(content string, toolCalls []orchestrator.ToolCallMsg) {
|
||||||
|
if ce.onChunk != nil && content != "" {
|
||||||
|
ce.onChunk(map[string]interface{}{"content": content})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
resp, err = ce.orchestrator.SendWithTools(messages)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if ce.onChunk != nil {
|
||||||
|
ce.onChunk(map[string]interface{}{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return finalContent, allToolCalls, allToolResults, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Usage.TotalTokens > 0 {
|
||||||
|
ce.TotalTokens += resp.Usage.TotalTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
choice := resp.Choices[0]
|
||||||
|
content := cleanThinkingTags(choice.Message.Content)
|
||||||
|
|
||||||
|
if content != "" {
|
||||||
|
if ce.onChunk != nil {
|
||||||
|
ce.onChunk(map[string]interface{}{"content": content})
|
||||||
|
}
|
||||||
|
finalContent = content
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(choice.Message.ToolCalls) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
assistantMsg := orchestrator.Message{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: content,
|
||||||
|
ToolCalls: choice.Message.ToolCalls,
|
||||||
|
}
|
||||||
|
messages = append(messages, assistantMsg)
|
||||||
|
|
||||||
|
for _, tc := range choice.Message.ToolCalls {
|
||||||
|
toolCallData := map[string]interface{}{
|
||||||
|
"tool_call_id": tc.ID,
|
||||||
|
"name": tc.Function.Name,
|
||||||
|
"args": tc.Function.Arguments,
|
||||||
|
}
|
||||||
|
allToolCalls = append(allToolCalls, toolCallData)
|
||||||
|
|
||||||
|
if ce.onChunk != nil {
|
||||||
|
ce.onChunk(map[string]interface{}{"tool_call": toolCallData})
|
||||||
|
}
|
||||||
|
|
||||||
|
call := agent.ToolCall{
|
||||||
|
ID: tc.ID,
|
||||||
|
Name: tc.Function.Name,
|
||||||
|
Arguments: json.RawMessage(tc.Function.Arguments),
|
||||||
|
}
|
||||||
|
|
||||||
|
result, execErr := ce.registry.Execute(ctx, call)
|
||||||
|
if execErr != nil {
|
||||||
|
result = agent.ToolResponse{
|
||||||
|
Content: execErr.Error(),
|
||||||
|
IsError: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resultData := map[string]interface{}{
|
||||||
|
"tool_call_id": tc.ID,
|
||||||
|
"content": result.Content,
|
||||||
|
"is_error": result.IsError,
|
||||||
|
}
|
||||||
|
if result.Meta != nil {
|
||||||
|
for k, v := range result.Meta {
|
||||||
|
resultData[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allToolResults = append(allToolResults, map[string]interface{}{
|
||||||
|
"tool_call_id": tc.ID,
|
||||||
|
"name": tc.Function.Name,
|
||||||
|
"args": tc.Function.Arguments,
|
||||||
|
"result": result.Content,
|
||||||
|
"is_error": result.IsError,
|
||||||
|
})
|
||||||
|
|
||||||
|
if ce.onChunk != nil {
|
||||||
|
ce.onChunk(map[string]interface{}{"tool_result": resultData})
|
||||||
|
}
|
||||||
|
|
||||||
|
messages = append(messages, orchestrator.Message{
|
||||||
|
Role: "tool",
|
||||||
|
Content: result.Content,
|
||||||
|
ToolCallID: tc.ID,
|
||||||
|
Name: tc.Function.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
finalContent = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalContent, allToolCalls, allToolResults, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProviderName returns the name of the active provider used by the engine.
|
||||||
|
func (ce *ChatEngine) ProviderName() string {
|
||||||
|
return ce.orchestrator.ProviderName()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunNonStream executes chat without streaming content to client.
|
||||||
|
func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.Message) (string, error) {
|
||||||
|
var finalContent string
|
||||||
|
|
||||||
|
for i := 0; i < MaxToolIterations; i++ {
|
||||||
|
resp, err := ce.orchestrator.SendWithTools(messages)
|
||||||
|
if err != nil {
|
||||||
|
return finalContent, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Usage.TotalTokens > 0 {
|
||||||
|
ce.TotalTokens += resp.Usage.TotalTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
choice := resp.Choices[0]
|
||||||
|
content := cleanThinkingTags(choice.Message.Content)
|
||||||
|
|
||||||
|
if content != "" {
|
||||||
|
finalContent = content
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(choice.Message.ToolCalls) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
assistantMsg := orchestrator.Message{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: content,
|
||||||
|
ToolCalls: choice.Message.ToolCalls,
|
||||||
|
}
|
||||||
|
messages = append(messages, assistantMsg)
|
||||||
|
|
||||||
|
for _, tc := range choice.Message.ToolCalls {
|
||||||
|
call := agent.ToolCall{
|
||||||
|
ID: tc.ID,
|
||||||
|
Name: tc.Function.Name,
|
||||||
|
Arguments: json.RawMessage(tc.Function.Arguments),
|
||||||
|
}
|
||||||
|
|
||||||
|
result, execErr := ce.registry.Execute(ctx, call)
|
||||||
|
if execErr != nil {
|
||||||
|
result = agent.ToolResponse{
|
||||||
|
Content: execErr.Error(),
|
||||||
|
IsError: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messages = append(messages, orchestrator.Message{
|
||||||
|
Role: "tool",
|
||||||
|
Content: result.Content,
|
||||||
|
ToolCallID: tc.ID,
|
||||||
|
Name: tc.Function.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
finalContent = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if finalContent == "" {
|
||||||
|
finalContent = "(tool calls completed, no text response)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalContent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSEWriter handles Server-Sent Events writing to HTTP response.
|
||||||
|
type SSEWriter struct {
|
||||||
|
w http.ResponseWriter
|
||||||
|
flusher http.Flusher
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSSEWriter creates a new SSEWriter.
|
||||||
|
func NewSSEWriter(w http.ResponseWriter) *SSEWriter {
|
||||||
|
sse := &SSEWriter{w: w}
|
||||||
|
if f, ok := w.(http.Flusher); ok {
|
||||||
|
sse.flusher = f
|
||||||
|
}
|
||||||
|
return sse
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write sends an SSE message.
|
||||||
|
func (s *SSEWriter) Write(data map[string]interface{}) {
|
||||||
|
b, _ := json.Marshal(data)
|
||||||
|
s.w.Write([]byte("data: " + string(b) + "\n\n"))
|
||||||
|
if s.flusher != nil {
|
||||||
|
s.flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupSSEHeaders sets up SSE response headers.
|
||||||
|
func SetupSSEHeaders(w http.ResponseWriter) {
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
127
internal/api/consumption.go
Normal file
127
internal/api/consumption.go
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type consumptionEntry struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Tokens int `json:"tokens"`
|
||||||
|
Requests int `json:"requests"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type providerConsumption struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Daily []consumptionEntry `json:"daily"`
|
||||||
|
Total int `json:"total_tokens"`
|
||||||
|
Requests int `json:"total_requests"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type consumptionStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
providers map[string]*providerConsumption
|
||||||
|
}
|
||||||
|
|
||||||
|
func newConsumptionStore() *consumptionStore {
|
||||||
|
cs := &consumptionStore{
|
||||||
|
providers: make(map[string]*providerConsumption),
|
||||||
|
}
|
||||||
|
cs.load()
|
||||||
|
cs.prune()
|
||||||
|
return cs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cs *consumptionStore) Record(providerName string, tokens int) {
|
||||||
|
if tokens <= 0 || providerName == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cs.mu.Lock()
|
||||||
|
defer cs.mu.Unlock()
|
||||||
|
|
||||||
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
|
|
||||||
|
p, ok := cs.providers[providerName]
|
||||||
|
if !ok {
|
||||||
|
p = &providerConsumption{Name: providerName}
|
||||||
|
cs.providers[providerName] = p
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Total += tokens
|
||||||
|
p.Requests++
|
||||||
|
|
||||||
|
if len(p.Daily) > 0 && p.Daily[len(p.Daily)-1].Date == today {
|
||||||
|
p.Daily[len(p.Daily)-1].Tokens += tokens
|
||||||
|
p.Daily[len(p.Daily)-1].Requests++
|
||||||
|
} else {
|
||||||
|
p.Daily = append(p.Daily, consumptionEntry{
|
||||||
|
Date: today,
|
||||||
|
Tokens: tokens,
|
||||||
|
Requests: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
cs.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cs *consumptionStore) GetAll() map[string]*providerConsumption {
|
||||||
|
cs.mu.Lock()
|
||||||
|
defer cs.mu.Unlock()
|
||||||
|
|
||||||
|
result := make(map[string]*providerConsumption)
|
||||||
|
for k, v := range cs.providers {
|
||||||
|
pc := *v
|
||||||
|
daily := make([]consumptionEntry, len(v.Daily))
|
||||||
|
copy(daily, v.Daily)
|
||||||
|
pc.Daily = daily
|
||||||
|
result[k] = &pc
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cs *consumptionStore) prune() {
|
||||||
|
cutoff := time.Now().UTC().AddDate(0, 0, -7).Format("2006-01-02")
|
||||||
|
for _, p := range cs.providers {
|
||||||
|
filtered := make([]consumptionEntry, 0)
|
||||||
|
for _, d := range p.Daily {
|
||||||
|
if d.Date >= cutoff {
|
||||||
|
filtered = append(filtered, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.Daily = filtered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cs *consumptionStore) filePath() string {
|
||||||
|
dir, err := config.ConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, "consumption.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cs *consumptionStore) load() {
|
||||||
|
fp := cs.filePath()
|
||||||
|
if fp == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(fp)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json.Unmarshal(data, &cs.providers)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cs *consumptionStore) save() {
|
||||||
|
fp := cs.filePath()
|
||||||
|
if fp == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(cs.providers)
|
||||||
|
os.WriteFile(fp, data, 0644)
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ type ConversationStore struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
path string
|
path string
|
||||||
conv *Conversation
|
conv *Conversation
|
||||||
|
realTokens int
|
||||||
}
|
}
|
||||||
|
|
||||||
type TokenCount struct {
|
type TokenCount struct {
|
||||||
@@ -133,6 +134,7 @@ func (cs *ConversationStore) Clear() {
|
|||||||
cs.conv.Summary = ""
|
cs.conv.Summary = ""
|
||||||
cs.conv.CreatedAt = time.Now().Format(time.RFC3339)
|
cs.conv.CreatedAt = time.Now().Format(time.RFC3339)
|
||||||
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
|
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||||
|
cs.realTokens = 0
|
||||||
cs.save()
|
cs.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,9 +156,22 @@ func (cs *ConversationStore) TrimOld(keepCount int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (cs *ConversationStore) ApproxTokenCount() int {
|
func (cs *ConversationStore) ApproxTokenCount() int {
|
||||||
|
if cs.realTokens > 0 {
|
||||||
|
return cs.realTokens
|
||||||
|
}
|
||||||
return cs.ApproxTokenCountDetailed().total
|
return cs.ApproxTokenCountDetailed().total
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddRealTokens accumulates actual token counts from the API response.
|
||||||
|
func (cs *ConversationStore) AddRealTokens(tokens int) {
|
||||||
|
if tokens <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cs.mu.Lock()
|
||||||
|
cs.realTokens += tokens
|
||||||
|
cs.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
func (cs *ConversationStore) ApproxTokenCountDetailed() TokenCount {
|
func (cs *ConversationStore) ApproxTokenCountDetailed() TokenCount {
|
||||||
cs.mu.RLock()
|
cs.mu.RLock()
|
||||||
defer cs.mu.RUnlock()
|
defer cs.mu.RUnlock()
|
||||||
|
|||||||
370
internal/api/conversation_multi.go
Normal file
370
internal/api/conversation_multi.go
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/muyue/muyue/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConversationMeta represents metadata for a conversation (used for listing).
|
||||||
|
type ConversationMeta struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
MessageCount int `json:"message_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConversationStoreMulti manages multiple conversations.
|
||||||
|
type ConversationStoreMulti struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
dir string
|
||||||
|
currentID string
|
||||||
|
conversations map[string]*Conversation
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConversationStoreMulti() *ConversationStoreMulti {
|
||||||
|
dir, err := config.ConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
dir = "/tmp/muyue"
|
||||||
|
}
|
||||||
|
dir = filepath.Join(dir, "conversations")
|
||||||
|
|
||||||
|
cs := &ConversationStoreMulti{
|
||||||
|
dir: dir,
|
||||||
|
conversations: make(map[string]*Conversation),
|
||||||
|
}
|
||||||
|
cs.loadIndex()
|
||||||
|
return cs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cs *ConversationStoreMulti) loadIndex() {
|
||||||
|
os.MkdirAll(cs.dir, 0755)
|
||||||
|
|
||||||
|
// Load index file if exists
|
||||||
|
indexPath := filepath.Join(cs.dir, "index.json")
|
||||||
|
data, err := os.ReadFile(indexPath)
|
||||||
|
if err != nil {
|
||||||
|
// Create default conversation
|
||||||
|
cs.createDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var index struct {
|
||||||
|
CurrentID string `json:"current_id"`
|
||||||
|
Conversations []ConversationMeta `json:"conversations"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &index); err != nil {
|
||||||
|
cs.createDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cs.currentID = index.CurrentID
|
||||||
|
if cs.currentID == "" {
|
||||||
|
cs.createDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all conversations
|
||||||
|
for _, meta := range index.Conversations {
|
||||||
|
convPath := filepath.Join(cs.dir, fmt.Sprintf("conv_%s.json", meta.ID))
|
||||||
|
data, err := os.ReadFile(convPath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var conv Conversation
|
||||||
|
if err := json.Unmarshal(data, &conv); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cs.conversations[meta.ID] = &conv
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure current conversation exists
|
||||||
|
if _, ok := cs.conversations[cs.currentID]; !ok {
|
||||||
|
cs.createDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cs *ConversationStoreMulti) createDefault() {
|
||||||
|
cs.currentID = uuid.New().String()
|
||||||
|
cs.conversations[cs.currentID] = &Conversation{
|
||||||
|
Messages: []FeedMessage{},
|
||||||
|
CreatedAt: time.Now().Format(time.RFC3339),
|
||||||
|
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
cs.saveIndex()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cs *ConversationStoreMulti) saveIndex() error {
|
||||||
|
var metas []ConversationMeta
|
||||||
|
for id, conv := range cs.conversations {
|
||||||
|
title := "Nouvelle conversation"
|
||||||
|
if len(conv.Messages) > 0 {
|
||||||
|
// Use first user message as title
|
||||||
|
for _, m := range conv.Messages {
|
||||||
|
if m.Role == "user" {
|
||||||
|
if len(m.Content) > 50 {
|
||||||
|
title = m.Content[:50] + "..."
|
||||||
|
} else {
|
||||||
|
title = m.Content
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
metas = append(metas, ConversationMeta{
|
||||||
|
ID: id,
|
||||||
|
Title: title,
|
||||||
|
CreatedAt: conv.CreatedAt,
|
||||||
|
UpdatedAt: conv.UpdatedAt,
|
||||||
|
MessageCount: len(conv.Messages),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
index := struct {
|
||||||
|
CurrentID string `json:"current_id"`
|
||||||
|
Conversations []ConversationMeta `json:"conversations"`
|
||||||
|
}{
|
||||||
|
CurrentID: cs.currentID,
|
||||||
|
Conversations: metas,
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(index, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(filepath.Join(cs.dir, "index.json"), data, 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cs *ConversationStoreMulti) saveCurrent() error {
|
||||||
|
conv, ok := cs.conversations[cs.currentID]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("no current conversation")
|
||||||
|
}
|
||||||
|
|
||||||
|
conv.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||||
|
data, err := json.MarshalIndent(conv, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
convPath := filepath.Join(cs.dir, fmt.Sprintf("conv_%s.json", cs.currentID))
|
||||||
|
if err := os.WriteFile(convPath, data, 0600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return cs.saveIndex()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current returns the current conversation store.
|
||||||
|
func (cs *ConversationStoreMulti) Current() *ConversationStore {
|
||||||
|
cs.mu.RLock()
|
||||||
|
defer cs.mu.RUnlock()
|
||||||
|
|
||||||
|
conv, ok := cs.conversations[cs.currentID]
|
||||||
|
if !ok {
|
||||||
|
return &ConversationStore{
|
||||||
|
conv: &Conversation{
|
||||||
|
Messages: []FeedMessage{},
|
||||||
|
CreatedAt: time.Now().Format(time.RFC3339),
|
||||||
|
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ConversationStore{
|
||||||
|
conv: conv,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the current conversation messages.
|
||||||
|
func (cs *ConversationStoreMulti) Get() []FeedMessage {
|
||||||
|
cs.mu.RLock()
|
||||||
|
defer cs.mu.RUnlock()
|
||||||
|
|
||||||
|
conv, ok := cs.conversations[cs.currentID]
|
||||||
|
if !ok {
|
||||||
|
return []FeedMessage{}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]FeedMessage, len(conv.Messages))
|
||||||
|
copy(out, conv.Messages)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add adds a message to the current conversation.
|
||||||
|
func (cs *ConversationStoreMulti) Add(role, content string) FeedMessage {
|
||||||
|
cs.mu.Lock()
|
||||||
|
defer cs.mu.Unlock()
|
||||||
|
|
||||||
|
conv, ok := cs.conversations[cs.currentID]
|
||||||
|
if !ok {
|
||||||
|
cs.currentID = uuid.New().String()
|
||||||
|
conv = &Conversation{
|
||||||
|
Messages: []FeedMessage{},
|
||||||
|
CreatedAt: time.Now().Format(time.RFC3339),
|
||||||
|
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
cs.conversations[cs.currentID] = conv
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := FeedMessage{
|
||||||
|
ID: generateMsgID(),
|
||||||
|
Role: role,
|
||||||
|
Content: content,
|
||||||
|
Time: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
conv.Messages = append(conv.Messages, msg)
|
||||||
|
|
||||||
|
go cs.saveCurrent() // Fire and forget
|
||||||
|
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear clears the current conversation.
|
||||||
|
func (cs *ConversationStoreMulti) Clear() {
|
||||||
|
cs.mu.Lock()
|
||||||
|
defer cs.mu.Unlock()
|
||||||
|
|
||||||
|
conv, ok := cs.conversations[cs.currentID]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conv.Messages = []FeedMessage{}
|
||||||
|
conv.Summary = ""
|
||||||
|
conv.CreatedAt = time.Now().Format(time.RFC3339)
|
||||||
|
conv.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||||
|
|
||||||
|
cs.saveCurrent()
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns all conversations.
|
||||||
|
func (cs *ConversationStoreMulti) List() []ConversationMeta {
|
||||||
|
cs.mu.RLock()
|
||||||
|
defer cs.mu.RUnlock()
|
||||||
|
|
||||||
|
var metas []ConversationMeta
|
||||||
|
for id, conv := range cs.conversations {
|
||||||
|
title := "Nouvelle conversation"
|
||||||
|
if len(conv.Messages) > 0 {
|
||||||
|
for _, m := range conv.Messages {
|
||||||
|
if m.Role == "user" {
|
||||||
|
if len(m.Content) > 50 {
|
||||||
|
title = m.Content[:50] + "..."
|
||||||
|
} else {
|
||||||
|
title = m.Content
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
metas = append(metas, ConversationMeta{
|
||||||
|
ID: id,
|
||||||
|
Title: title,
|
||||||
|
CreatedAt: conv.CreatedAt,
|
||||||
|
UpdatedAt: conv.UpdatedAt,
|
||||||
|
MessageCount: len(conv.Messages),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return metas
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a new conversation and switches to it.
|
||||||
|
func (cs *ConversationStoreMulti) Create() string {
|
||||||
|
cs.mu.Lock()
|
||||||
|
defer cs.mu.Unlock()
|
||||||
|
|
||||||
|
id := uuid.New().String()
|
||||||
|
cs.conversations[id] = &Conversation{
|
||||||
|
Messages: []FeedMessage{},
|
||||||
|
CreatedAt: time.Now().Format(time.RFC3339),
|
||||||
|
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
cs.currentID = id
|
||||||
|
cs.saveIndex()
|
||||||
|
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch switches to a different conversation.
|
||||||
|
func (cs *ConversationStoreMulti) Switch(id string) error {
|
||||||
|
cs.mu.Lock()
|
||||||
|
defer cs.mu.Unlock()
|
||||||
|
|
||||||
|
if _, ok := cs.conversations[id]; !ok {
|
||||||
|
return fmt.Errorf("conversation not found: %s", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
cs.currentID = id
|
||||||
|
cs.saveIndex()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID returns a conversation by ID.
|
||||||
|
func (cs *ConversationStoreMulti) GetByID(id string) (*Conversation, error) {
|
||||||
|
cs.mu.RLock()
|
||||||
|
defer cs.mu.RUnlock()
|
||||||
|
|
||||||
|
conv, ok := cs.conversations[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("conversation not found: %s", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return conv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete deletes a conversation.
|
||||||
|
func (cs *ConversationStoreMulti) Delete(id string) error {
|
||||||
|
cs.mu.Lock()
|
||||||
|
defer cs.mu.Unlock()
|
||||||
|
|
||||||
|
if _, ok := cs.conversations[id]; !ok {
|
||||||
|
return fmt.Errorf("conversation not found: %s", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(cs.conversations, id)
|
||||||
|
|
||||||
|
// Delete file
|
||||||
|
convPath := filepath.Join(cs.dir, fmt.Sprintf("conv_%s.json", id))
|
||||||
|
os.Remove(convPath)
|
||||||
|
|
||||||
|
// If deleted current, switch to another
|
||||||
|
if cs.currentID == id {
|
||||||
|
if len(cs.conversations) > 0 {
|
||||||
|
for newID := range cs.conversations {
|
||||||
|
cs.currentID = newID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create new default
|
||||||
|
cs.currentID = uuid.New().String()
|
||||||
|
cs.conversations[cs.currentID] = &Conversation{
|
||||||
|
Messages: []FeedMessage{},
|
||||||
|
CreatedAt: time.Now().Format(time.RFC3339),
|
||||||
|
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cs.saveIndex()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentID returns the current conversation ID.
|
||||||
|
func (cs *ConversationStoreMulti) CurrentID() string {
|
||||||
|
cs.mu.RLock()
|
||||||
|
defer cs.mu.RUnlock()
|
||||||
|
|
||||||
|
return cs.currentID
|
||||||
|
}
|
||||||
@@ -3,14 +3,18 @@ package api
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/muyue/muyue/internal/agent"
|
"github.com/muyue/muyue/internal/agent"
|
||||||
"github.com/muyue/muyue/internal/orchestrator"
|
"github.com/muyue/muyue/internal/orchestrator"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxToolIterations = 15
|
var thinkingTagRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
|
||||||
|
|
||||||
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != "POST" {
|
if r.Method != "POST" {
|
||||||
@@ -41,7 +45,14 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
orb.SetSystemPrompt(agent.StudioSystemPrompt())
|
var studioPrompt strings.Builder
|
||||||
|
studioPrompt.WriteString(agent.StudioSystemPrompt())
|
||||||
|
studioPrompt.WriteString(fmt.Sprintf("\nDate: %s\nHeure: %s\n", time.Now().Format("02/01/2006"), time.Now().Format("15:04:05")))
|
||||||
|
studioPrompt.WriteString(fmt.Sprintf("Root: %t\n", os.Geteuid() == 0))
|
||||||
|
if os.Geteuid() != 0 {
|
||||||
|
studioPrompt.WriteString("⚠️ Session utilisateur standard — les commandes sudo/doas nécessitent une autorisation. N'utilise PAS sudo ou doas sans demander.\n")
|
||||||
|
}
|
||||||
|
orb.SetSystemPrompt(studioPrompt.String())
|
||||||
orb.SetTools(s.agentToolsJSON)
|
orb.SetTools(s.agentToolsJSON)
|
||||||
|
|
||||||
if body.Stream {
|
if body.Stream {
|
||||||
@@ -52,178 +63,124 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orchestrator, userMessage string) {
|
func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orchestrator, userMessage string) {
|
||||||
w.Header().Set("Content-Type", "text/event-stream")
|
SetupSSEHeaders(w)
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
|
||||||
w.Header().Set("Connection", "keep-alive")
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
flusher, canFlush := w.(http.Flusher)
|
flusher, canFlush := w.(http.Flusher)
|
||||||
|
|
||||||
writeSSE := func(data map[string]interface{}) {
|
|
||||||
b, _ := json.Marshal(data)
|
sseWriter := NewSSEWriter(w)
|
||||||
w.Write([]byte("data: " + string(b) + "\n\n"))
|
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
messages := s.buildContextMessages(userMessage)
|
||||||
|
|
||||||
|
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
|
||||||
|
engine.OnChunk(func(data map[string]interface{}) {
|
||||||
|
if data == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sseWriter.Write(data)
|
||||||
if canFlush {
|
if canFlush {
|
||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
messages := []orchestrator.Message{
|
|
||||||
{Role: "user", Content: userMessage},
|
|
||||||
}
|
|
||||||
|
|
||||||
var finalContent string
|
|
||||||
var allToolCalls []map[string]interface{}
|
|
||||||
|
|
||||||
for i := 0; i < maxToolIterations; i++ {
|
|
||||||
resp, err := orb.SendWithTools(messages)
|
|
||||||
if err != nil {
|
|
||||||
writeSSE(map[string]interface{}{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
choice := resp.Choices[0]
|
|
||||||
content := cleanThinkingTags(choice.Message.Content)
|
|
||||||
|
|
||||||
if content != "" {
|
|
||||||
for _, ch := range strings.Split(content, "") {
|
|
||||||
writeSSE(map[string]interface{}{"content": ch})
|
|
||||||
}
|
|
||||||
finalContent = content
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(choice.Message.ToolCalls) == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
assistantMsg := orchestrator.Message{
|
|
||||||
Role: "assistant",
|
|
||||||
Content: content,
|
|
||||||
ToolCalls: choice.Message.ToolCalls,
|
|
||||||
}
|
|
||||||
messages = append(messages, assistantMsg)
|
|
||||||
|
|
||||||
for _, tc := range choice.Message.ToolCalls {
|
|
||||||
toolCallData := map[string]interface{}{
|
|
||||||
"tool_call_id": tc.ID,
|
|
||||||
"name": tc.Function.Name,
|
|
||||||
"args": tc.Function.Arguments,
|
|
||||||
}
|
|
||||||
allToolCalls = append(allToolCalls, toolCallData)
|
|
||||||
writeSSE(map[string]interface{}{"tool_call": toolCallData})
|
|
||||||
|
|
||||||
call := agent.ToolCall{
|
|
||||||
ID: tc.ID,
|
|
||||||
Name: tc.Function.Name,
|
|
||||||
Arguments: json.RawMessage(tc.Function.Arguments),
|
|
||||||
}
|
|
||||||
|
|
||||||
result, execErr := s.agentRegistry.Execute(ctx, call)
|
|
||||||
if execErr != nil {
|
|
||||||
result = agent.ToolResponse{
|
|
||||||
Content: execErr.Error(),
|
|
||||||
IsError: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resultData := map[string]interface{}{
|
|
||||||
"tool_call_id": tc.ID,
|
|
||||||
"content": result.Content,
|
|
||||||
"is_error": result.IsError,
|
|
||||||
}
|
|
||||||
writeSSE(map[string]interface{}{"tool_result": resultData})
|
|
||||||
|
|
||||||
messages = append(messages, orchestrator.Message{
|
|
||||||
Role: "tool",
|
|
||||||
Content: result.Content,
|
|
||||||
ToolCallID: tc.ID,
|
|
||||||
Name: tc.Function.Name,
|
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
finalContent = ""
|
finalContent, allToolCalls, allToolResults, err := engine.RunWithTools(ctx, messages)
|
||||||
|
if err != nil {
|
||||||
|
sseWriter.Write(map[string]interface{}{"error": err.Error()})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
storeContent := finalContent
|
storeContent := finalContent
|
||||||
if len(allToolCalls) > 0 {
|
if len(allToolCalls) > 0 {
|
||||||
storeObj := map[string]interface{}{"content": storeContent, "tool_calls": allToolCalls}
|
storeObj := map[string]interface{}{
|
||||||
|
"content": storeContent,
|
||||||
|
"tool_calls": allToolCalls,
|
||||||
|
"tool_results": allToolResults,
|
||||||
|
}
|
||||||
storeJSON, _ := json.Marshal(storeObj)
|
storeJSON, _ := json.Marshal(storeObj)
|
||||||
storeContent = string(storeJSON)
|
storeContent = string(storeJSON)
|
||||||
}
|
}
|
||||||
s.convStore.Add("assistant", storeContent)
|
s.convStore.Add("assistant", storeContent)
|
||||||
|
s.convStore.AddRealTokens(engine.TotalTokens)
|
||||||
|
|
||||||
writeSSE(map[string]interface{}{"done": "true"})
|
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
|
||||||
|
|
||||||
|
sseWriter.Write(map[string]interface{}{"done": "true"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Orchestrator, userMessage string) {
|
func (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Orchestrator, userMessage string) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
messages := []orchestrator.Message{
|
messages := s.buildContextMessages(userMessage)
|
||||||
{Role: "user", Content: userMessage},
|
|
||||||
}
|
|
||||||
|
|
||||||
var finalContent string
|
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
|
||||||
|
finalContent, err := engine.RunNonStream(ctx, messages)
|
||||||
for i := 0; i < maxToolIterations; i++ {
|
|
||||||
resp, err := orb.SendWithTools(messages)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
choice := resp.Choices[0]
|
|
||||||
content := cleanThinkingTags(choice.Message.Content)
|
|
||||||
|
|
||||||
if content != "" {
|
|
||||||
finalContent = content
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(choice.Message.ToolCalls) == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
assistantMsg := orchestrator.Message{
|
|
||||||
Role: "assistant",
|
|
||||||
Content: content,
|
|
||||||
ToolCalls: choice.Message.ToolCalls,
|
|
||||||
}
|
|
||||||
messages = append(messages, assistantMsg)
|
|
||||||
|
|
||||||
for _, tc := range choice.Message.ToolCalls {
|
|
||||||
call := agent.ToolCall{
|
|
||||||
ID: tc.ID,
|
|
||||||
Name: tc.Function.Name,
|
|
||||||
Arguments: json.RawMessage(tc.Function.Arguments),
|
|
||||||
}
|
|
||||||
|
|
||||||
result, execErr := s.agentRegistry.Execute(ctx, call)
|
|
||||||
if execErr != nil {
|
|
||||||
result = agent.ToolResponse{
|
|
||||||
Content: execErr.Error(),
|
|
||||||
IsError: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
messages = append(messages, orchestrator.Message{
|
|
||||||
Role: "tool",
|
|
||||||
Content: result.Content,
|
|
||||||
ToolCallID: tc.ID,
|
|
||||||
Name: tc.Function.Name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
finalContent = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if finalContent == "" {
|
|
||||||
finalContent = "(tool calls completed, no text response)"
|
|
||||||
}
|
|
||||||
|
|
||||||
s.convStore.Add("assistant", finalContent)
|
s.convStore.Add("assistant", finalContent)
|
||||||
|
s.convStore.AddRealTokens(engine.TotalTokens)
|
||||||
|
|
||||||
|
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
|
||||||
|
|
||||||
writeJSON(w, map[string]string{"content": finalContent})
|
writeJSON(w, map[string]string{"content": finalContent})
|
||||||
}
|
}
|
||||||
|
|
||||||
func cleanThinkingTags(content string) string {
|
func cleanThinkingTags(content string) string {
|
||||||
return strings.ReplaceAll(content, "<think", "")
|
return strings.TrimSpace(thinkingTagRegex.ReplaceAllString(content, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
const contextWindowMessages = 20
|
||||||
|
|
||||||
|
func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message {
|
||||||
|
history := s.convStore.Get()
|
||||||
|
start := 0
|
||||||
|
if len(history) > contextWindowMessages {
|
||||||
|
start = len(history) - contextWindowMessages
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := make([]orchestrator.Message, 0, len(history[start:])+1)
|
||||||
|
|
||||||
|
summary := s.convStore.GetSummary()
|
||||||
|
if summary != "" {
|
||||||
|
messages = append(messages, orchestrator.Message{
|
||||||
|
Role: "system",
|
||||||
|
Content: "Résumé de la conversation précédente:\n" + summary,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range history[start:] {
|
||||||
|
content := m.Content
|
||||||
|
if m.Role == "assistant" {
|
||||||
|
var parsed struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
ToolCalls []struct {
|
||||||
|
ToolCallID string `json:"tool_call_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Args string `json:"args"`
|
||||||
|
} `json:"tool_calls"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(content), &parsed); err == nil && parsed.Content != "" {
|
||||||
|
content = parsed.Content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
role := m.Role
|
||||||
|
if role == "system" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
messages = append(messages, orchestrator.Message{
|
||||||
|
Role: role,
|
||||||
|
Content: content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
messages = append(messages, orchestrator.Message{
|
||||||
|
Role: "user",
|
||||||
|
Content: userMessage,
|
||||||
|
})
|
||||||
|
|
||||||
|
return messages
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) autoSummarize() {
|
func (s *Server) autoSummarize() {
|
||||||
@@ -268,6 +225,9 @@ func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, map[string]interface{}{
|
writeJSON(w, map[string]interface{}{
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"tokens": s.convStore.ApproxTokenCount(),
|
"tokens": s.convStore.ApproxTokenCount(),
|
||||||
|
"max_tokens": maxTokensApprox,
|
||||||
|
"summarize_at": summarizeThreshold,
|
||||||
|
"summary": s.convStore.GetSummary(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,3 +239,16 @@ func (s *Server) handleChatClear(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.convStore.Clear()
|
s.convStore.Clear()
|
||||||
writeJSON(w, map[string]string{"status": "ok"})
|
writeJSON(w, map[string]string{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleChatSummarize(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.autoSummarize()
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"tokens": s.convStore.ApproxTokenCount(),
|
||||||
|
"summary": s.convStore.GetSummary(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,21 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
const summarizePrompt = `Résume la conversation suivante de manière concise et structurée. Garde les points clés, les décisions prises, le contexte technique important. Le résumé doit permettre de continuer la conversation sans perte de contexte. Réponds uniquement avec le résumé, sans meta-commentaire.`
|
const summarizePrompt = `Résume cette conversation de manière ultra-concise et structurée.
|
||||||
|
|
||||||
|
CONSERVE :
|
||||||
|
- Les décisions techniques prises et leur rationale
|
||||||
|
- Les configurations modifiées (noms exacts, valeurs)
|
||||||
|
- Les fichiers/chemins manipulés
|
||||||
|
- Les erreurs rencontrées et leurs résolutions
|
||||||
|
- Le contexte nécessaire pour continuer
|
||||||
|
|
||||||
|
ÉLIMINE :
|
||||||
|
- Les échanges de politesse
|
||||||
|
- Les tentatives infructueuses (sauf si la solution n'a pas été trouvée)
|
||||||
|
- Les sorties d'outils brutes (garde seulement les conclusions)
|
||||||
|
|
||||||
|
FORMAT : Markdown structuré avec sections. Max 500 mots. Pas de méta-commentaire.`
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, data interface{}) {
|
func writeJSON(w http.ResponseWriter, data interface{}) {
|
||||||
json.NewEncoder(w).Encode(data)
|
json.NewEncoder(w).Encode(data)
|
||||||
|
|||||||
@@ -53,32 +53,27 @@ func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, "no config", http.StatusNotFound)
|
writeError(w, "no config", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var body struct {
|
|
||||||
Name string `json:"name"`
|
currentJSON, err := json.Marshal(s.config.Profile)
|
||||||
Pseudo string `json:"pseudo"`
|
if err != nil {
|
||||||
Email string `json:"email"`
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
Editor string `json:"editor"`
|
return
|
||||||
Shell string `json:"shell"`
|
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
var currentMap map[string]interface{}
|
||||||
|
json.Unmarshal(currentJSON, ¤tMap)
|
||||||
|
|
||||||
|
var updates map[string]interface{}
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
if err := json.Unmarshal(body, &updates); err != nil {
|
||||||
writeError(w, err.Error(), http.StatusBadRequest)
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if body.Name != "" {
|
|
||||||
s.config.Profile.Name = body.Name
|
deepMerge(currentMap, updates)
|
||||||
}
|
|
||||||
if body.Pseudo != "" {
|
mergedJSON, _ := json.Marshal(currentMap)
|
||||||
s.config.Profile.Pseudo = body.Pseudo
|
json.Unmarshal(mergedJSON, &s.config.Profile)
|
||||||
}
|
|
||||||
if body.Email != "" {
|
|
||||||
s.config.Profile.Email = body.Email
|
|
||||||
}
|
|
||||||
if body.Editor != "" {
|
|
||||||
s.config.Profile.Preferences.Editor = body.Editor
|
|
||||||
}
|
|
||||||
if body.Shell != "" {
|
|
||||||
s.config.Profile.Preferences.Shell = body.Shell
|
|
||||||
}
|
|
||||||
if err := config.Save(s.config); err != nil {
|
if err := config.Save(s.config); err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -86,6 +81,20 @@ func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, map[string]string{"status": "ok"})
|
writeJSON(w, map[string]string{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func deepMerge(dst, src map[string]interface{}) {
|
||||||
|
for k, sv := range src {
|
||||||
|
if dv, ok := dst[k]; ok {
|
||||||
|
dstMap, dOk := dv.(map[string]interface{})
|
||||||
|
srcMap, sOk := sv.(map[string]interface{})
|
||||||
|
if dOk && sOk {
|
||||||
|
deepMerge(dstMap, srcMap)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dst[k] = sv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleSaveProvider(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSaveProvider(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != "PUT" {
|
if r.Method != "PUT" {
|
||||||
writeError(w, "PUT only", http.StatusMethodNotAllowed)
|
writeError(w, "PUT only", http.StatusMethodNotAllowed)
|
||||||
@@ -178,6 +187,8 @@ func (s *Server) handleValidateProvider(w http.ResponseWriter, r *http.Request)
|
|||||||
switch body.Name {
|
switch body.Name {
|
||||||
case "minimax":
|
case "minimax":
|
||||||
baseURL = "https://api.minimax.io/v1"
|
baseURL = "https://api.minimax.io/v1"
|
||||||
|
case "mimo":
|
||||||
|
baseURL = "https://token-plan-ams.xiaomimimo.com/v1"
|
||||||
case "openai":
|
case "openai":
|
||||||
baseURL = "https://api.openai.com/v1"
|
baseURL = "https://api.openai.com/v1"
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
|
|||||||
@@ -2,8 +2,15 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/muyue/muyue/internal/lsp"
|
"github.com/muyue/muyue/internal/lsp"
|
||||||
"github.com/muyue/muyue/internal/mcp"
|
"github.com/muyue/muyue/internal/mcp"
|
||||||
@@ -17,6 +24,7 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
"name": version.Name,
|
"name": version.Name,
|
||||||
"version": version.Version,
|
"version": version.Version,
|
||||||
"author": version.Author,
|
"author": version.Author,
|
||||||
|
"sudo": os.Geteuid() == 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,3 +423,396 @@ func (s *Server) handleEditors(w http.ResponseWriter, r *http.Request) {
|
|||||||
editors := scanner.ScanEditors()
|
editors := scanner.ScanEditors()
|
||||||
writeJSON(w, map[string]interface{}{"editors": editors})
|
writeJSON(w, map[string]interface{}{"editors": editors})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleProvidersQuota(w http.ResponseWriter, r *http.Request) {
|
||||||
|
type providerQuota struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
Healthy bool `json:"healthy"`
|
||||||
|
Data map[string]interface{} `json:"data,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
var results []providerQuota
|
||||||
|
client := &http.Client{Timeout: 8 * time.Second}
|
||||||
|
for _, p := range s.config.AI.Providers {
|
||||||
|
q := providerQuota{Name: p.Name, Active: p.Active}
|
||||||
|
switch p.Name {
|
||||||
|
case "minimax":
|
||||||
|
if p.APIKey == "" {
|
||||||
|
q.Error = "no API key"
|
||||||
|
results = append(results, q)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest("GET", "https://api.minimax.io/v1/token_plan/remains", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+p.APIKey)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
q.Error = err.Error()
|
||||||
|
} else {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
var data map[string]interface{}
|
||||||
|
if json.Unmarshal(body, &data) == nil {
|
||||||
|
if models, ok := data["model_remains"].([]interface{}); ok {
|
||||||
|
filtered := make([]map[string]interface{}, 0)
|
||||||
|
for _, m := range models {
|
||||||
|
if mm, ok := m.(map[string]interface{}); ok {
|
||||||
|
usage, _ := mm["current_interval_usage_count"].(float64)
|
||||||
|
total, _ := mm["current_interval_total_count"].(float64)
|
||||||
|
if total > 0 {
|
||||||
|
filtered = append(filtered, map[string]interface{}{
|
||||||
|
"model": mm["model_name"],
|
||||||
|
"used": usage,
|
||||||
|
"total": total,
|
||||||
|
"remaining": total - usage,
|
||||||
|
"weekly_used": mm["current_weekly_usage_count"],
|
||||||
|
"weekly_total": mm["current_weekly_total_count"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
q.Data = map[string]interface{}{"models": filtered}
|
||||||
|
q.Healthy = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "zai":
|
||||||
|
if p.APIKey == "" {
|
||||||
|
q.Error = "no API key"
|
||||||
|
results = append(results, q)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest("GET", "https://api.z.ai/api/monitor/usage/quota/limit", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+p.APIKey)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
q.Error = err.Error()
|
||||||
|
} else {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
var data map[string]interface{}
|
||||||
|
if json.Unmarshal(body, &data) == nil {
|
||||||
|
if d, ok := data["data"].(map[string]interface{}); ok {
|
||||||
|
if limits, ok := d["limits"].([]interface{}); ok {
|
||||||
|
models := make([]map[string]interface{}, 0)
|
||||||
|
for _, l := range limits {
|
||||||
|
if lm, ok := l.(map[string]interface{}); ok {
|
||||||
|
name := "Z.AI"
|
||||||
|
if model, ok := lm["model"].(string); ok && model != "" {
|
||||||
|
name = model
|
||||||
|
} else if t, ok := lm["type"].(string); ok && t != "TIME_LIMIT" {
|
||||||
|
name = t
|
||||||
|
}
|
||||||
|
usage, _ := lm["usage"].(float64)
|
||||||
|
remaining, _ := lm["remaining"].(float64)
|
||||||
|
limitVal, hasLimit := lm["limit"].(float64)
|
||||||
|
total := usage + remaining
|
||||||
|
if hasLimit && limitVal > 0 {
|
||||||
|
total = limitVal
|
||||||
|
}
|
||||||
|
if total > 0 {
|
||||||
|
models = append(models, map[string]interface{}{
|
||||||
|
"model": name,
|
||||||
|
"used": usage,
|
||||||
|
"total": total,
|
||||||
|
"remaining": remaining,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(models) > 0 {
|
||||||
|
q.Data = map[string]interface{}{"models": models}
|
||||||
|
q.Healthy = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "mimo":
|
||||||
|
q.Healthy = p.APIKey != ""
|
||||||
|
if p.APIKey == "" {
|
||||||
|
q.Error = "no API key"
|
||||||
|
results = append(results, q)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mimoBase := p.BaseURL
|
||||||
|
if mimoBase == "" {
|
||||||
|
mimoBase = "https://token-plan-ams.xiaomimimo.com/v1"
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest("GET", strings.TrimRight(mimoBase, "/")+"/models", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+p.APIKey)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
q.Error = err.Error()
|
||||||
|
} else {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
var data map[string]interface{}
|
||||||
|
if json.Unmarshal(body, &data) == nil {
|
||||||
|
if modelList, ok := data["data"].([]interface{}); ok {
|
||||||
|
models := make([]map[string]interface{}, 0)
|
||||||
|
for _, m := range modelList {
|
||||||
|
if mm, ok := m.(map[string]interface{}); ok {
|
||||||
|
id, _ := mm["id"].(string)
|
||||||
|
if id != "" {
|
||||||
|
models = append(models, map[string]interface{}{
|
||||||
|
"model": id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
q.Data = map[string]interface{}{"models": models, "available": len(models)}
|
||||||
|
q.Healthy = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "claude", "anthropic":
|
||||||
|
// Claude Code n'a pas d'API externe, vérifier l'installation
|
||||||
|
claudePath := "/usr/bin/claude"
|
||||||
|
if _, err := os.Stat(claudePath); err == nil {
|
||||||
|
q.Healthy = true
|
||||||
|
} else {
|
||||||
|
q.Error = "claude code not installed"
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
q.Error = "quota not supported"
|
||||||
|
}
|
||||||
|
results = append(results, q)
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{"providers": results})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleProvidersConsumption(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" {
|
||||||
|
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := s.consumption.GetAll()
|
||||||
|
writeJSON(w, map[string]interface{}{"providers": data})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRecentCommands(w http.ResponseWriter, r *http.Request) {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
type cmdEntry struct {
|
||||||
|
Cmd string `json:"cmd"`
|
||||||
|
Shell string `json:"shell"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries []cmdEntry
|
||||||
|
|
||||||
|
for _, histFile := range []string{".bash_history", ".zsh_history"} {
|
||||||
|
path := filepath.Join(home, histFile)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
shell := "bash"
|
||||||
|
if strings.Contains(histFile, "zsh") {
|
||||||
|
shell = "zsh"
|
||||||
|
}
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
start := len(lines) - 50
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := len(lines) - 1; i >= start; i-- {
|
||||||
|
line := strings.TrimSpace(lines[i])
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, ": ") {
|
||||||
|
parts := strings.SplitN(line, ";", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
line = strings.TrimSpace(parts[1])
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
base := strings.Fields(line)[0]
|
||||||
|
if len(base) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !regexp.MustCompile(`^[a-zA-Z@./]`).MatchString(base) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
entries = append(entries, cmdEntry{Cmd: line, Shell: shell})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
max := 20
|
||||||
|
if len(entries) > max {
|
||||||
|
entries = entries[:max]
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{"commands": entries})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRunningProcesses(w http.ResponseWriter, r *http.Request) {
|
||||||
|
type proc struct {
|
||||||
|
PID int `json:"pid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
CPU string `json:"cpu"`
|
||||||
|
Mem string `json:"mem"`
|
||||||
|
}
|
||||||
|
var procs []proc
|
||||||
|
|
||||||
|
editors := []string{"code", "nvim", "vim", "emacs", "hx", "subl", "zed", "cursor"}
|
||||||
|
langs := []string{"node", "python", "java", "go", "rustc", "cargo", "ruby", "php"}
|
||||||
|
interesting := append(editors, langs...)
|
||||||
|
interesting = append(interesting, "muyue")
|
||||||
|
|
||||||
|
cmd := exec.Command("ps", "aux")
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, map[string]interface{}{"processes": procs})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(string(out), "\n")
|
||||||
|
for _, line := range lines[1:] {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 11 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fullCmd := strings.Join(fields[10:], " ")
|
||||||
|
name := filepath.Base(fields[10])
|
||||||
|
matched := false
|
||||||
|
for _, pattern := range interesting {
|
||||||
|
if strings.Contains(name, pattern) || strings.Contains(strings.ToLower(fullCmd), pattern) {
|
||||||
|
matched = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !matched {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var pid int
|
||||||
|
fmt.Sscanf(fields[1], "%d", &pid)
|
||||||
|
procs = append(procs, proc{
|
||||||
|
PID: pid,
|
||||||
|
Name: name,
|
||||||
|
Command: fullCmd,
|
||||||
|
CPU: fields[2],
|
||||||
|
Mem: fields[3],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{"processes": procs})
|
||||||
|
}
|
||||||
|
|
||||||
|
type sysMetrics struct {
|
||||||
|
CPUPercent float64 `json:"cpu_percent"`
|
||||||
|
MemPercent float64 `json:"mem_percent"`
|
||||||
|
MemUsedMB float64 `json:"mem_used_mb"`
|
||||||
|
MemTotalMB float64 `json:"mem_total_mb"`
|
||||||
|
NetRxKBs float64 `json:"net_rx_kbs"`
|
||||||
|
NetTxKBs float64 `json:"net_tx_kbs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
lastCPU [2]float64
|
||||||
|
lastNet [2]float64
|
||||||
|
lastNetTs time.Time
|
||||||
|
lastCPUSet bool
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleSystemMetrics(w http.ResponseWriter, r *http.Request) {
|
||||||
|
m := sysMetrics{}
|
||||||
|
|
||||||
|
// CPU from /proc/stat
|
||||||
|
if data, err := os.ReadFile("/proc/stat"); err == nil {
|
||||||
|
line := strings.Split(string(data), "\n")[0]
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) >= 5 {
|
||||||
|
var idle, total float64
|
||||||
|
for i := 1; i < len(fields) && i <= 4; i++ {
|
||||||
|
var v float64
|
||||||
|
fmt.Sscanf(fields[i], "%f", &v)
|
||||||
|
total += v
|
||||||
|
if i == 4 {
|
||||||
|
idle = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastCPUSet {
|
||||||
|
dIdle := idle - lastCPU[0]
|
||||||
|
dTotal := total - lastCPU[1]
|
||||||
|
if dTotal > 0 {
|
||||||
|
m.CPUPercent = (1 - dIdle/dTotal) * 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastCPU = [2]float64{idle, total}
|
||||||
|
lastCPUSet = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memory from /proc/meminfo
|
||||||
|
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
|
||||||
|
var memTotal, memAvailable float64
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var v float64
|
||||||
|
fmt.Sscanf(fields[1], "%f", &v)
|
||||||
|
switch fields[0] {
|
||||||
|
case "MemTotal:":
|
||||||
|
memTotal = v
|
||||||
|
case "MemAvailable:":
|
||||||
|
memAvailable = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if memTotal > 0 {
|
||||||
|
m.MemTotalMB = memTotal / 1024
|
||||||
|
m.MemUsedMB = (memTotal - memAvailable) / 1024
|
||||||
|
m.MemPercent = (memTotal - memAvailable) / memTotal * 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network from /proc/net/dev
|
||||||
|
if data, err := os.ReadFile("/proc/net/dev"); err == nil {
|
||||||
|
var rxBytes, txBytes float64
|
||||||
|
for _, line := range strings.Split(string(data), "\n")[2:] {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 10 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
iface := strings.TrimSuffix(fields[0], ":")
|
||||||
|
if iface == "lo" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var rx, tx float64
|
||||||
|
fmt.Sscanf(fields[1], "%f", &rx)
|
||||||
|
fmt.Sscanf(fields[9], "%f", &tx)
|
||||||
|
rxBytes += rx
|
||||||
|
txBytes += tx
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if !lastNetTs.IsZero() {
|
||||||
|
elapsed := now.Sub(lastNetTs).Seconds()
|
||||||
|
if elapsed > 0 {
|
||||||
|
m.NetRxKBs = (rxBytes - lastNet[0]) / 1024 / elapsed
|
||||||
|
m.NetTxKBs = (txBytes - lastNet[1]) / 1024 / elapsed
|
||||||
|
if m.NetRxKBs < 0 {
|
||||||
|
m.NetRxKBs = 0
|
||||||
|
}
|
||||||
|
if m.NetTxKBs < 0 {
|
||||||
|
m.NetTxKBs = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastNet = [2]float64{rxBytes, txBytes}
|
||||||
|
lastNetTs = now
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, m)
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,44 +3,37 @@ package api
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/muyue/muyue/internal/agent"
|
"github.com/muyue/muyue/internal/agent"
|
||||||
"github.com/muyue/muyue/internal/orchestrator"
|
"github.com/muyue/muyue/internal/orchestrator"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxShellToolIterations = 10
|
|
||||||
|
|
||||||
type ShellChatRequest struct {
|
type ShellChatRequest struct {
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Context string `json:"context,omitempty"`
|
Context string `json:"context,omitempty"`
|
||||||
History []string `json:"history,omitempty"`
|
|
||||||
Cwd string `json:"cwd,omitempty"`
|
Cwd string `json:"cwd,omitempty"`
|
||||||
Platform string `json:"platform,omitempty"`
|
Platform string `json:"platform,omitempty"`
|
||||||
Stream bool `json:"stream"`
|
Stream bool `json:"stream"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ShellChatResponse struct {
|
|
||||||
Content string `json:"content,omitempty"`
|
|
||||||
ToolCalls []ToolCallInfo `json:"tool_calls,omitempty"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ToolCallInfo struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Args map[string]interface{} `json:"args"`
|
|
||||||
Result *toolResponseData `json:"result,omitempty"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != "POST" {
|
if r.Method != "POST" {
|
||||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if s.shellConvStore.AtLimit() {
|
||||||
|
writeError(w, "context limit reached, use /clear", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req ShellChatRequest
|
var req ShellChatRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeError(w, err.Error(), http.StatusBadRequest)
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
@@ -52,6 +45,8 @@ func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.shellConvStore.Add("user", req.Message)
|
||||||
|
|
||||||
orb, err := orchestrator.New(s.config)
|
orb, err := orchestrator.New(s.config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
||||||
@@ -59,240 +54,303 @@ func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
orb.SetSystemPrompt(s.buildShellSystemPrompt(req))
|
orb.SetSystemPrompt(s.buildShellSystemPrompt(req))
|
||||||
orb.SetTools(s.agentToolsJSON)
|
orb.SetTools(s.shellAgentToolsJSON)
|
||||||
|
|
||||||
if req.Stream {
|
if req.Stream {
|
||||||
s.handleShellChatStream(w, orb, req)
|
s.handleShellChatStream(w, orb)
|
||||||
} else {
|
} else {
|
||||||
s.handleShellChatNonStream(w, orb, req)
|
s.handleShellChatNonStream(w, orb)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) buildShellSystemPrompt(req ShellChatRequest) string {
|
func (s *Server) buildShellSystemPrompt(req ShellChatRequest) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
sb.WriteString(`Tu es l'assistant Shell de Muyue. Tu as accès à un terminal et peux aider l'utilisateur avec:
|
sb.WriteString(shellSystemPromptBase)
|
||||||
- Exécuter des commandes shell
|
|
||||||
- Expliquer des erreurs de commandes
|
|
||||||
- Suggérer des commandes appropriées pour la tâche demandée
|
|
||||||
- Lire et explorer des fichiers
|
|
||||||
- Configurer l'environnement de développement
|
|
||||||
|
|
||||||
Tu peux appeler des outils pour exécuter des commandes, lire des fichiers, etc. Sois précis et concis dans tes réponses.
|
analysis := LoadSystemAnalysis()
|
||||||
|
if analysis != "" {
|
||||||
|
sb.WriteString("<system_context>\n")
|
||||||
|
sb.WriteString(analysis)
|
||||||
|
sb.WriteString("\n</system_context>\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
`)
|
sb.WriteString(fmt.Sprintf("OS: %s/%s\n", runtime.GOOS, runtime.GOARCH))
|
||||||
|
if hostname, err := os.Hostname(); err == nil {
|
||||||
|
sb.WriteString("Hostname: " + hostname + "\n")
|
||||||
|
}
|
||||||
|
if user := os.Getenv("USER"); user != "" {
|
||||||
|
sb.WriteString("User: " + user + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
if req.Cwd != "" {
|
isRoot := os.Geteuid() == 0
|
||||||
sb.WriteString("Répertoire courant: " + req.Cwd + "\n")
|
sb.WriteString(fmt.Sprintf("Root: %t\n", isRoot))
|
||||||
}
|
if isRoot {
|
||||||
if req.Platform != "" {
|
sb.WriteString("⚠️ Session en root — toutes les commandes ont les privilèges administrateur.\n")
|
||||||
sb.WriteString("Plateforme: " + req.Platform + "\n")
|
} else {
|
||||||
}
|
sb.WriteString("⚠️ Session utilisateur standard — les commandes sudo/doas nécessitent une autorisation. N'utilise PAS sudo ou doas sans demander.\n")
|
||||||
if req.Context != "" {
|
|
||||||
sb.WriteString("\nContexte du terminal:\n" + req.Context + "\n")
|
|
||||||
}
|
|
||||||
if len(req.History) > 0 {
|
|
||||||
sb.WriteString("\nDernières commandes exécutées:\n")
|
|
||||||
for _, h := range req.History {
|
|
||||||
sb.WriteString(" " + h + "\n")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
sb.WriteString(fmt.Sprintf("Date: %s\nHeure: %s\n", now.Format("02/01/2006"), now.Format("15:04:05")))
|
||||||
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.Orchestrator, req ShellChatRequest) {
|
func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
|
||||||
w.Header().Set("Content-Type", "text/event-stream")
|
SetupSSEHeaders(w)
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
|
||||||
w.Header().Set("Connection", "keep-alive")
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
flusher, canFlush := w.(http.Flusher)
|
flusher, canFlush := w.(http.Flusher)
|
||||||
|
sseWriter := NewSSEWriter(w)
|
||||||
|
|
||||||
writeSSE := func(data map[string]interface{}) {
|
ctx := context.Background()
|
||||||
b, _ := json.Marshal(data)
|
messages := s.buildShellContextMessages()
|
||||||
w.Write([]byte("data: " + string(b) + "\n\n"))
|
|
||||||
|
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
|
||||||
|
engine.OnChunk(func(data map[string]interface{}) {
|
||||||
|
if data == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sseWriter.Write(data)
|
||||||
if canFlush {
|
if canFlush {
|
||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
ctx := context.Background()
|
finalContent, allToolCalls, allToolResults, err := engine.RunWithTools(ctx, messages)
|
||||||
messages := []orchestrator.Message{
|
|
||||||
{Role: "user", Content: req.Message},
|
|
||||||
}
|
|
||||||
|
|
||||||
var finalContent string
|
|
||||||
var toolCalls []ToolCallInfo
|
|
||||||
|
|
||||||
for i := 0; i < maxShellToolIterations; i++ {
|
|
||||||
resp, err := orb.SendWithTools(messages)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeSSE(map[string]interface{}{"error": err.Error()})
|
sseWriter.Write(map[string]interface{}{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
choice := resp.Choices[0]
|
storeContent := finalContent
|
||||||
content := cleanThinkingTags(choice.Message.Content)
|
if len(allToolCalls) > 0 {
|
||||||
|
storeObj := map[string]interface{}{
|
||||||
if content != "" {
|
"content": storeContent,
|
||||||
for _, ch := range strings.Split(content, "") {
|
"tool_calls": allToolCalls,
|
||||||
writeSSE(map[string]interface{}{"content": ch})
|
"tool_results": allToolResults,
|
||||||
}
|
}
|
||||||
finalContent = content
|
storeJSON, _ := json.Marshal(storeObj)
|
||||||
|
storeContent = string(storeJSON)
|
||||||
}
|
}
|
||||||
|
s.shellConvStore.Add("assistant", storeContent)
|
||||||
|
s.shellConvStore.AddRealTokens(engine.TotalTokens)
|
||||||
|
|
||||||
if len(choice.Message.ToolCalls) == 0 {
|
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
assistantMsg := orchestrator.Message{
|
sseWriter.Write(map[string]interface{}{
|
||||||
Role: "assistant",
|
"done": "true",
|
||||||
Content: content,
|
"tokens": s.shellConvStore.ApproxTokens(),
|
||||||
ToolCalls: choice.Message.ToolCalls,
|
|
||||||
}
|
|
||||||
messages = append(messages, assistantMsg)
|
|
||||||
|
|
||||||
for _, tc := range choice.Message.ToolCalls {
|
|
||||||
toolCallData := map[string]interface{}{
|
|
||||||
"tool_call_id": tc.ID,
|
|
||||||
"name": tc.Function.Name,
|
|
||||||
"args": tc.Function.Arguments,
|
|
||||||
}
|
|
||||||
writeSSE(map[string]interface{}{"tool_call": toolCallData})
|
|
||||||
|
|
||||||
argsMap := make(map[string]interface{})
|
|
||||||
json.Unmarshal([]byte(tc.Function.Arguments), &argsMap)
|
|
||||||
|
|
||||||
tcInfo := ToolCallInfo{
|
|
||||||
ID: tc.ID,
|
|
||||||
Name: tc.Function.Name,
|
|
||||||
Args: argsMap,
|
|
||||||
}
|
|
||||||
|
|
||||||
call := agent.ToolCall{
|
|
||||||
ID: tc.ID,
|
|
||||||
Name: tc.Function.Name,
|
|
||||||
Arguments: json.RawMessage(tc.Function.Arguments),
|
|
||||||
}
|
|
||||||
|
|
||||||
result, execErr := s.agentRegistry.Execute(ctx, call)
|
|
||||||
if execErr != nil {
|
|
||||||
tcInfo.Error = execErr.Error()
|
|
||||||
writeSSE(map[string]interface{}{"tool_result": tcInfo})
|
|
||||||
} else {
|
|
||||||
tcInfo.Result = &toolResponseData{
|
|
||||||
Content: result.Content,
|
|
||||||
IsError: result.IsError,
|
|
||||||
Meta: result.Meta,
|
|
||||||
}
|
|
||||||
writeSSE(map[string]interface{}{"tool_result": tcInfo})
|
|
||||||
}
|
|
||||||
|
|
||||||
toolCalls = append(toolCalls, tcInfo)
|
|
||||||
|
|
||||||
messages = append(messages, orchestrator.Message{
|
|
||||||
Role: "tool",
|
|
||||||
Content: result.Content,
|
|
||||||
ToolCallID: tc.ID,
|
|
||||||
Name: tc.Function.Name,
|
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
finalContent = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if finalContent == "" && len(toolCalls) > 0 {
|
|
||||||
finalContent = "(opérations terminées)"
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSONResp, _ := json.Marshal(ShellChatResponse{
|
|
||||||
Content: finalContent,
|
|
||||||
ToolCalls: toolCalls,
|
|
||||||
})
|
|
||||||
writeSSE(map[string]interface{}{"done": true, "response": string(writeJSONResp)})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrator.Orchestrator, req ShellChatRequest) {
|
func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
messages := []orchestrator.Message{
|
messages := s.buildShellContextMessages()
|
||||||
{Role: "user", Content: req.Message},
|
|
||||||
}
|
|
||||||
|
|
||||||
var finalContent string
|
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
|
||||||
var toolCalls []ToolCallInfo
|
finalContent, err := engine.RunNonStream(ctx, messages)
|
||||||
|
|
||||||
for i := 0; i < maxShellToolIterations; i++ {
|
|
||||||
resp, err := orb.SendWithTools(messages)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
choice := resp.Choices[0]
|
s.shellConvStore.Add("assistant", finalContent)
|
||||||
content := cleanThinkingTags(choice.Message.Content)
|
s.shellConvStore.AddRealTokens(engine.TotalTokens)
|
||||||
|
|
||||||
if content != "" {
|
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
|
||||||
finalContent = content
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(choice.Message.ToolCalls) == 0 {
|
writeJSON(w, map[string]interface{}{
|
||||||
break
|
"content": finalContent,
|
||||||
}
|
"tokens": s.shellConvStore.ApproxTokens(),
|
||||||
|
|
||||||
assistantMsg := orchestrator.Message{
|
|
||||||
Role: "assistant",
|
|
||||||
Content: content,
|
|
||||||
ToolCalls: choice.Message.ToolCalls,
|
|
||||||
}
|
|
||||||
messages = append(messages, assistantMsg)
|
|
||||||
|
|
||||||
for _, tc := range choice.Message.ToolCalls {
|
|
||||||
argsMap := make(map[string]interface{})
|
|
||||||
json.Unmarshal([]byte(tc.Function.Arguments), &argsMap)
|
|
||||||
|
|
||||||
tcInfo := ToolCallInfo{
|
|
||||||
ID: tc.ID,
|
|
||||||
Name: tc.Function.Name,
|
|
||||||
Args: argsMap,
|
|
||||||
}
|
|
||||||
|
|
||||||
call := agent.ToolCall{
|
|
||||||
ID: tc.ID,
|
|
||||||
Name: tc.Function.Name,
|
|
||||||
Arguments: json.RawMessage(tc.Function.Arguments),
|
|
||||||
}
|
|
||||||
|
|
||||||
result, execErr := s.agentRegistry.Execute(ctx, call)
|
|
||||||
if execErr != nil {
|
|
||||||
tcInfo.Error = execErr.Error()
|
|
||||||
} else {
|
|
||||||
tcInfo.Result = &toolResponseData{
|
|
||||||
Content: result.Content,
|
|
||||||
IsError: result.IsError,
|
|
||||||
Meta: result.Meta,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toolCalls = append(toolCalls, tcInfo)
|
|
||||||
|
|
||||||
messages = append(messages, orchestrator.Message{
|
|
||||||
Role: "tool",
|
|
||||||
Content: result.Content,
|
|
||||||
ToolCallID: tc.ID,
|
|
||||||
Name: tc.Function.Name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
finalContent = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if finalContent == "" && len(toolCalls) > 0 {
|
|
||||||
finalContent = "(tool calls completed, no text response)"
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, ShellChatResponse{
|
|
||||||
Content: finalContent,
|
|
||||||
ToolCalls: toolCalls,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) buildShellContextMessages() []orchestrator.Message {
|
||||||
|
history := s.shellConvStore.Get()
|
||||||
|
start := 0
|
||||||
|
const shellContextWindow = 20
|
||||||
|
if len(history) > shellContextWindow {
|
||||||
|
start = len(history) - shellContextWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := make([]orchestrator.Message, 0, len(history[start:]))
|
||||||
|
|
||||||
|
for _, m := range history[start:] {
|
||||||
|
content := m.Content
|
||||||
|
if m.Role == "assistant" {
|
||||||
|
var parsed struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
ToolCalls []struct {
|
||||||
|
ToolCallID string `json:"tool_call_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Args string `json:"args"`
|
||||||
|
} `json:"tool_calls"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(content), &parsed); err == nil && parsed.Content != "" {
|
||||||
|
content = parsed.Content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
role := m.Role
|
||||||
|
if role == "system" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
messages = append(messages, orchestrator.Message{
|
||||||
|
Role: role,
|
||||||
|
Content: content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleShellChatHistory(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" {
|
||||||
|
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
messages := s.shellConvStore.Get()
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"messages": messages,
|
||||||
|
"tokens": s.shellConvStore.ApproxTokens(),
|
||||||
|
"max_tokens": shellMaxTokens,
|
||||||
|
"at_limit": s.shellConvStore.AtLimit(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleShellChatClear(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.shellConvStore.Clear()
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"tokens": 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleShellAnalyze(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var sysInfo strings.Builder
|
||||||
|
sysInfo.WriteString("=== INFORMATIONS SYSTÈME ===\n")
|
||||||
|
sysInfo.WriteString(fmt.Sprintf("OS: %s/%s\n", runtime.GOOS, runtime.GOARCH))
|
||||||
|
if hostname, err := os.Hostname(); err == nil {
|
||||||
|
sysInfo.WriteString("Hostname: " + hostname + "\n")
|
||||||
|
}
|
||||||
|
if user := os.Getenv("USER"); user != "" {
|
||||||
|
sysInfo.WriteString("User: " + user + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if data, err := os.ReadFile("/proc/cpuinfo"); err == nil {
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
if strings.HasPrefix(line, "model name") {
|
||||||
|
sysInfo.WriteString("CPU: " + strings.SplitN(line, ":", 2)[1] + "\n")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
if strings.HasPrefix(line, "MemTotal:") || strings.HasPrefix(line, "MemAvailable:") {
|
||||||
|
sysInfo.WriteString(strings.TrimSpace(line) + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if out, err := exec.Command("df", "-h", "/").Output(); err == nil {
|
||||||
|
lines := strings.Split(string(out), "\n")
|
||||||
|
if len(lines) >= 2 {
|
||||||
|
sysInfo.WriteString("Disk: " + strings.TrimSpace(lines[1]) + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if out, err := exec.Command("ps", "aux", "--sort=-pcpu").Output(); err == nil {
|
||||||
|
lines := strings.Split(string(out), "\n")
|
||||||
|
sysInfo.WriteString(fmt.Sprintf("\nProcessus actifs (%d total):\n", len(lines)-1))
|
||||||
|
for i := 1; i < len(lines) && i <= 10; i++ {
|
||||||
|
fields := strings.Fields(lines[i])
|
||||||
|
if len(fields) >= 11 {
|
||||||
|
sysInfo.WriteString(fmt.Sprintf(" %-20s CPU:%-6s MEM:%-6s %s\n", fields[10], fields[2]+"%", fields[3]+"%", fields[0]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.scanResult != nil {
|
||||||
|
sysInfo.WriteString("\nOutils installés:\n")
|
||||||
|
for _, t := range s.scanResult.Tools {
|
||||||
|
status := "✗"
|
||||||
|
if t.Installed {
|
||||||
|
status = "✓"
|
||||||
|
}
|
||||||
|
sysInfo.WriteString(fmt.Sprintf(" %s %s %s\n", status, t.Name, t.Version))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
orb, err := orchestrator.New(s.config)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
orb.SetSystemPrompt(agent.StudioSystemPrompt())
|
||||||
|
|
||||||
|
analysisPrompt := `Tu es un expert en administration système. Analyse les informations suivantes et génère un rapport structuré en markdown.
|
||||||
|
|
||||||
|
STRUCTURE REQUISE :
|
||||||
|
|
||||||
|
## État du système
|
||||||
|
- Résumé en 2-3 phrases de l'état général (OK/Attention/Critique)
|
||||||
|
|
||||||
|
## Points d'attention
|
||||||
|
Liste les problèmes détectés par priorité :
|
||||||
|
- **CRITIQUE** : problèmes de sécurité, espace disque < 10%, mémoire < 10%
|
||||||
|
- **ATTENTION** : CPU élevé, services en échec, config non-optimale
|
||||||
|
- **INFO** : améliorations possibles, mises à jour disponibles
|
||||||
|
|
||||||
|
## Recommandations
|
||||||
|
Pour chaque point d'attention, donne UNE commande ou action corrective concrète.
|
||||||
|
|
||||||
|
## Outils manquants
|
||||||
|
Liste les outils utiles non installés avec la commande d'installation.
|
||||||
|
|
||||||
|
## Réseau
|
||||||
|
- Interfaces actives, ports en écoute, connectivité
|
||||||
|
|
||||||
|
RÈGLES :
|
||||||
|
- Pas de blabla générique — sois spécifique à CE système
|
||||||
|
- Inclus les valeurs numériques réelles (%, Go, MHz)
|
||||||
|
- Max 1500 mots
|
||||||
|
- Le rapport sert de contexte persistant pour un assistant terminal
|
||||||
|
|
||||||
|
` + sysInfo.String()
|
||||||
|
|
||||||
|
result, err := orb.Send(analysisPrompt)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "analysis failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SaveSystemAnalysis(result)
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"analysis": result,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleShellAnalysisGet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" {
|
||||||
|
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
analysis := LoadSystemAnalysis()
|
||||||
|
if analysis == "" {
|
||||||
|
writeJSON(w, map[string]interface{}{"analysis": nil})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{"analysis": analysis})
|
||||||
|
}
|
||||||
|
|||||||
66
internal/api/handlers_test.go
Normal file
66
internal/api/handlers_test.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleToolCall(t *testing.T) {
|
||||||
|
// Test unknown tool returns error
|
||||||
|
registry := agent.NewRegistry()
|
||||||
|
|
||||||
|
// Register a test tool
|
||||||
|
testTool, _ := agent.NewTool[struct{ Command string }]("test_tool", "Test tool", func(ctx context.Context, params struct{ Command string }) (agent.ToolResponse, error) {
|
||||||
|
return agent.TextResponse("executed: " + params.Command), nil
|
||||||
|
})
|
||||||
|
registry.Register(testTool)
|
||||||
|
|
||||||
|
// Test executing known tool
|
||||||
|
resp, err := registry.Execute(context.Background(), agent.ToolCall{
|
||||||
|
ID: "test-id",
|
||||||
|
Name: "test_tool",
|
||||||
|
Arguments: json.RawMessage(`{"Command": "hello"}`),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.IsError {
|
||||||
|
t.Errorf("expected no error, got error response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test executing unknown tool
|
||||||
|
resp, err = registry.Execute(context.Background(), agent.ToolCall{
|
||||||
|
ID: "test-id",
|
||||||
|
Name: "unknown_tool",
|
||||||
|
Arguments: json.RawMessage(`{}`),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !resp.IsError {
|
||||||
|
t.Errorf("expected error for unknown tool")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanThinkingTags(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"hello world", "hello world"},
|
||||||
|
{"<think>thinking</think>hello", "hello"},
|
||||||
|
{"<Think>THINKING</Think>hello", "hello"},
|
||||||
|
{"hello <think>thinking</think> world", "hello world"},
|
||||||
|
{"no tags here", "no tags here"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
result := cleanThinkingTags(tc.input)
|
||||||
|
if result != tc.expected {
|
||||||
|
t.Errorf("cleanThinkingTags(%q) = %q, want %q", tc.input, result, tc.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -16,22 +17,53 @@ type Server struct {
|
|||||||
scanResult *scanner.ScanResult
|
scanResult *scanner.ScanResult
|
||||||
mux *http.ServeMux
|
mux *http.ServeMux
|
||||||
convStore *ConversationStore
|
convStore *ConversationStore
|
||||||
|
shellConvStore *ShellConvStore
|
||||||
|
consumption *consumptionStore
|
||||||
agentRegistry *agent.Registry
|
agentRegistry *agent.Registry
|
||||||
agentToolsJSON json.RawMessage
|
agentToolsJSON json.RawMessage
|
||||||
|
shellAgentRegistry *agent.Registry
|
||||||
|
shellAgentToolsJSON json.RawMessage
|
||||||
workflowEngine *workflow.Engine
|
workflowEngine *workflow.Engine
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(cfg *config.MuyueConfig) *Server {
|
func NewServer(cfg *config.MuyueConfig) *Server {
|
||||||
s := &Server{
|
s := &Server{
|
||||||
config: cfg,
|
|
||||||
mux: http.NewServeMux(),
|
mux: http.NewServeMux(),
|
||||||
}
|
}
|
||||||
|
// Auto-initialize config if nil or if no config file exists on disk
|
||||||
|
if cfg == nil || !config.Exists() {
|
||||||
|
defaultCfg := config.Default()
|
||||||
|
if cfg != nil {
|
||||||
|
// Preserve any user-provided settings from cfg
|
||||||
|
defaultCfg.Profile = cfg.Profile
|
||||||
|
defaultCfg.AI = cfg.AI
|
||||||
|
defaultCfg.Tools = cfg.Tools
|
||||||
|
defaultCfg.BMAD = cfg.BMAD
|
||||||
|
defaultCfg.Terminal = cfg.Terminal
|
||||||
|
}
|
||||||
|
// Save initial config to establish the file for first-time usage
|
||||||
|
if err := config.Save(defaultCfg); err != nil {
|
||||||
|
log.Printf("config: initial save failed: %v", err)
|
||||||
|
}
|
||||||
|
cfg = defaultCfg
|
||||||
|
}
|
||||||
|
s.config = cfg
|
||||||
s.scanResult = scanner.ScanSystem()
|
s.scanResult = scanner.ScanSystem()
|
||||||
s.convStore = NewConversationStore()
|
s.convStore = NewConversationStore()
|
||||||
|
s.shellConvStore = NewShellConvStore()
|
||||||
|
s.consumption = newConsumptionStore()
|
||||||
s.agentRegistry = agent.DefaultRegistry()
|
s.agentRegistry = agent.DefaultRegistry()
|
||||||
tools := s.agentRegistry.OpenAITools()
|
tools := s.agentRegistry.OpenAITools()
|
||||||
toolsJSON, _ := json.Marshal(tools)
|
toolsJSON, _ := json.Marshal(tools)
|
||||||
s.agentToolsJSON = json.RawMessage(toolsJSON)
|
s.agentToolsJSON = json.RawMessage(toolsJSON)
|
||||||
|
|
||||||
|
s.shellAgentRegistry = agent.NewRegistry()
|
||||||
|
terminalTool, _ := agent.NewTerminalTool()
|
||||||
|
s.shellAgentRegistry.Register(terminalTool)
|
||||||
|
shellTools := s.shellAgentRegistry.OpenAITools()
|
||||||
|
shellToolsJSON, _ := json.Marshal(shellTools)
|
||||||
|
s.shellAgentToolsJSON = json.RawMessage(shellToolsJSON)
|
||||||
|
|
||||||
s.workflowEngine, _ = workflow.NewEngine(s.agentRegistry)
|
s.workflowEngine, _ = workflow.NewEngine(s.agentRegistry)
|
||||||
s.routes()
|
s.routes()
|
||||||
return s
|
return s
|
||||||
@@ -67,9 +99,14 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("/api/chat", s.handleChat)
|
s.mux.HandleFunc("/api/chat", s.handleChat)
|
||||||
s.mux.HandleFunc("/api/chat/history", s.handleChatHistory)
|
s.mux.HandleFunc("/api/chat/history", s.handleChatHistory)
|
||||||
s.mux.HandleFunc("/api/chat/clear", s.handleChatClear)
|
s.mux.HandleFunc("/api/chat/clear", s.handleChatClear)
|
||||||
|
s.mux.HandleFunc("/api/chat/summarize", s.handleChatSummarize)
|
||||||
s.mux.HandleFunc("/api/tool/call", s.handleToolCall)
|
s.mux.HandleFunc("/api/tool/call", s.handleToolCall)
|
||||||
s.mux.HandleFunc("/api/tools/list", s.handleToolList)
|
s.mux.HandleFunc("/api/tools/list", s.handleToolList)
|
||||||
s.mux.HandleFunc("/api/shell/chat", s.handleShellChat)
|
s.mux.HandleFunc("/api/shell/chat", s.handleShellChat)
|
||||||
|
s.mux.HandleFunc("/api/shell/chat/history", s.handleShellChatHistory)
|
||||||
|
s.mux.HandleFunc("/api/shell/chat/clear", s.handleShellChatClear)
|
||||||
|
s.mux.HandleFunc("/api/shell/analyze", s.handleShellAnalyze)
|
||||||
|
s.mux.HandleFunc("/api/shell/analysis", s.handleShellAnalysisGet)
|
||||||
s.mux.HandleFunc("/api/workflow", s.handleWorkflowCreate)
|
s.mux.HandleFunc("/api/workflow", s.handleWorkflowCreate)
|
||||||
s.mux.HandleFunc("/api/workflow/list", s.handleWorkflowList)
|
s.mux.HandleFunc("/api/workflow/list", s.handleWorkflowList)
|
||||||
s.mux.HandleFunc("/api/workflow/", s.handleWorkflowGet)
|
s.mux.HandleFunc("/api/workflow/", s.handleWorkflowGet)
|
||||||
@@ -95,6 +132,11 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("/api/skills/export", s.handleSkillExport)
|
s.mux.HandleFunc("/api/skills/export", s.handleSkillExport)
|
||||||
s.mux.HandleFunc("/api/skills/import", s.handleSkillImport)
|
s.mux.HandleFunc("/api/skills/import", s.handleSkillImport)
|
||||||
s.mux.HandleFunc("/api/dashboard/status", s.handleDashboardStatus)
|
s.mux.HandleFunc("/api/dashboard/status", s.handleDashboardStatus)
|
||||||
|
s.mux.HandleFunc("/api/providers/quota", s.handleProvidersQuota)
|
||||||
|
s.mux.HandleFunc("/api/providers/consumption", s.handleProvidersConsumption)
|
||||||
|
s.mux.HandleFunc("/api/recent-commands", s.handleRecentCommands)
|
||||||
|
s.mux.HandleFunc("/api/running-processes", s.handleRunningProcesses)
|
||||||
|
s.mux.HandleFunc("/api/system/metrics", s.handleSystemMetrics)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
197
internal/api/shell_conversation.go
Normal file
197
internal/api/shell_conversation.go
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const shellMaxTokens = 100000
|
||||||
|
const shellCharsPerToken = 4
|
||||||
|
|
||||||
|
const shellSystemPromptBase = `Tu es l'**Analyste Système** de Muyue. Tu es un expert en administration système, DevOps et développement.
|
||||||
|
|
||||||
|
<critical_rules>
|
||||||
|
1. **AGIS, ne décris pas** — Utilise l'outil terminal pour exécuter, ne te contente pas de proposer des commandes.
|
||||||
|
2. **SOIS AUTONOME** — Cherche les infos manquantes via des commandes avant de demander à l'utilisateur. Essaie plusieurs approches avant de bloquer.
|
||||||
|
3. **SOIS CONCIS** — Max 4 lignes par défaut. Pas de préambule. Réponse directe et technique.
|
||||||
|
4. **GÈRE LES ERREURS** — Si une commande échoue, lis l'erreur, comprends la cause, essaie une approche alternative. 2-3 tentatives avant de rapporter.
|
||||||
|
5. **SÉCURITÉ** — Ne révèle jamais de credentials. Demande confirmation avant les commandes destructrices (rm -rf, format, etc.).
|
||||||
|
6. **LANGUE** — Réponds dans la même langue que l'utilisateur.
|
||||||
|
</critical_rules>
|
||||||
|
|
||||||
|
<tool_usage>
|
||||||
|
Outil disponible : **terminal** — Exécute des commandes shell sur le système local.
|
||||||
|
|
||||||
|
Stratégies :
|
||||||
|
- **Diagnostique** — Enchaîne les commandes de diagnostic (ps, df, free, top, journalctl, dmesg, netstat, ss, etc.)
|
||||||
|
- **Parallélisme** — Combine les commandes avec && ou ; quand elles sont indépendantes
|
||||||
|
- **Filtrage** — Utilise grep, awk, sort, head pour extraire l'essentiel des sorties volumineuses
|
||||||
|
- **Non-interactif** — Préfère les commandes non-interactives (apt install -y, non pas apt install)
|
||||||
|
- **Troncature** — Si le résultat dépasse 2000 caractères, résume les points clés au lieu de tout afficher
|
||||||
|
</tool_usage>
|
||||||
|
|
||||||
|
<decision_making>
|
||||||
|
- Décide par toi-même : exécute des commandes pour comprendre l'état du système
|
||||||
|
- Ne demande confirmation que pour les actions destructrices
|
||||||
|
- Si tu ne connais pas la commande exacte, exécute la commande avec --help pour la trouver
|
||||||
|
- Si bloqué : documente ce que tu as essayé, pourquoi, et l'action minimale requise
|
||||||
|
- Ne t'arrête jamais pour une tâche complexe — découpe en étapes et exécute-les
|
||||||
|
</decision_making>
|
||||||
|
|
||||||
|
<error_recovery>
|
||||||
|
1. Lis le message d'erreur complet (stderr + stdout)
|
||||||
|
2. Identifie la cause racine (permissions, paquet manquant, config, service)
|
||||||
|
3. Essaie : vérifier le service, vérifier les logs, chercher le paquet, tester la connexion
|
||||||
|
4. Propose une solution concrète, pas générique
|
||||||
|
</error_recovery>
|
||||||
|
|
||||||
|
<response_format>
|
||||||
|
- **Commandes** : blocs markdown avec le langage (bash, sh, etc.)
|
||||||
|
- **Résultats** : résume les métriques clés, pas de dump complet
|
||||||
|
- **Erreurs** : cause + solution en 1-2 lignes
|
||||||
|
- **Succès** : confirmation en 1 ligne
|
||||||
|
- **Analyses** : markdown structuré avec sections si nécessaire
|
||||||
|
</response_format>
|
||||||
|
|
||||||
|
<mermaid>
|
||||||
|
Tu peux utiliser des diagrammes Mermaid pour visualiser :
|
||||||
|
- Architecture système (graph TD/LR)
|
||||||
|
- Flux réseau (sequenceDiagram)
|
||||||
|
- Processus (flowchart)
|
||||||
|
- Timeline (gantt)
|
||||||
|
|
||||||
|
Utilise un bloc de code avec le langage mermaid quand ça clarifie l'explication. Pas pour du texte simple.
|
||||||
|
</mermaid>
|
||||||
|
|
||||||
|
`
|
||||||
|
|
||||||
|
type ShellMessage struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Time string `json:"time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShellConvStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
path string
|
||||||
|
msgs []ShellMessage
|
||||||
|
realTokens int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewShellConvStore() *ShellConvStore {
|
||||||
|
dir, err := config.ConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
dir = "/tmp/muyue"
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "shell_conversation.json")
|
||||||
|
s := &ShellConvStore{path: path}
|
||||||
|
s.load()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ShellConvStore) load() {
|
||||||
|
data, err := os.ReadFile(s.path)
|
||||||
|
if err != nil {
|
||||||
|
s.msgs = []ShellMessage{}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json.Unmarshal(data, &s.msgs)
|
||||||
|
if s.msgs == nil {
|
||||||
|
s.msgs = []ShellMessage{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ShellConvStore) save() {
|
||||||
|
data, _ := json.MarshalIndent(s.msgs, "", " ")
|
||||||
|
os.MkdirAll(filepath.Dir(s.path), 0755)
|
||||||
|
os.WriteFile(s.path, data, 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ShellConvStore) Get() []ShellMessage {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
out := make([]ShellMessage, len(s.msgs))
|
||||||
|
copy(out, s.msgs)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ShellConvStore) Add(role, content string) ShellMessage {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
msg := ShellMessage{
|
||||||
|
ID: time.Now().Format("20060102150405.000"),
|
||||||
|
Role: role,
|
||||||
|
Content: content,
|
||||||
|
Time: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
s.msgs = append(s.msgs, msg)
|
||||||
|
s.save()
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ShellConvStore) Clear() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.msgs = []ShellMessage{}
|
||||||
|
s.realTokens = 0
|
||||||
|
s.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ShellConvStore) ApproxTokens() int {
|
||||||
|
if s.realTokens > 0 {
|
||||||
|
return s.realTokens
|
||||||
|
}
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
total := 0
|
||||||
|
for _, m := range s.msgs {
|
||||||
|
total += utf8.RuneCountInString(m.Content) / shellCharsPerToken
|
||||||
|
}
|
||||||
|
total += utf8.RuneCountInString(shellSystemPromptBase) / shellCharsPerToken
|
||||||
|
if analysis := LoadSystemAnalysis(); analysis != "" {
|
||||||
|
total += utf8.RuneCountInString(analysis) / shellCharsPerToken
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddRealTokens accumulates actual token counts from the API response.
|
||||||
|
func (s *ShellConvStore) AddRealTokens(tokens int) {
|
||||||
|
if tokens <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.realTokens += tokens
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ShellConvStore) AtLimit() bool {
|
||||||
|
return s.ApproxTokens() >= shellMaxTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadSystemAnalysis() string {
|
||||||
|
dir, err := config.ConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, "system_analysis.md"))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveSystemAnalysis(content string) error {
|
||||||
|
dir, err := config.ConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
os.MkdirAll(dir, 0755)
|
||||||
|
return os.WriteFile(filepath.Join(dir, "system_analysis.md"), []byte(content), 0644)
|
||||||
|
}
|
||||||
@@ -146,13 +146,6 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("terminal: pty started successfully")
|
log.Printf("terminal: pty started successfully")
|
||||||
defer func() {
|
|
||||||
ptmx.Close()
|
|
||||||
if cmd.Process != nil {
|
|
||||||
cmd.Process.Kill()
|
|
||||||
cmd.Wait()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
var once sync.Once
|
var once sync.Once
|
||||||
cleanup := func() {
|
cleanup := func() {
|
||||||
@@ -164,6 +157,7 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
buf := make([]byte, 4096)
|
buf := make([]byte, 4096)
|
||||||
@@ -171,8 +165,6 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
n, err := ptmx.Read(buf)
|
n, err := ptmx.Read(buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cleanup()
|
cleanup()
|
||||||
conn.WriteMessage(websocket.CloseMessage,
|
|
||||||
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := conn.WriteJSON(wsMessage{
|
if err := conn.WriteJSON(wsMessage{
|
||||||
@@ -234,7 +226,6 @@ func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request)
|
|||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
Password string `json:"password"`
|
|
||||||
KeyPath string `json:"key_path"`
|
KeyPath string `json:"key_path"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
|||||||
@@ -12,66 +12,66 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Profile struct {
|
type Profile struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name" json:"name"`
|
||||||
Pseudo string `yaml:"pseudo"`
|
Pseudo string `yaml:"pseudo" json:"pseudo"`
|
||||||
Email string `yaml:"email"`
|
Email string `yaml:"email" json:"email"`
|
||||||
Languages []string `yaml:"languages"`
|
Languages []string `yaml:"languages" json:"languages"`
|
||||||
Preferences struct {
|
Preferences struct {
|
||||||
Editor string `yaml:"editor"`
|
Editor string `yaml:"editor" json:"editor"`
|
||||||
Shell string `yaml:"shell"`
|
Shell string `yaml:"shell" json:"shell"`
|
||||||
Theme string `yaml:"theme"`
|
Theme string `yaml:"theme" json:"theme"`
|
||||||
DefaultAI string `yaml:"default_ai"`
|
DefaultAI string `yaml:"default_ai" json:"default_ai"`
|
||||||
AutoUpdate bool `yaml:"auto_update"`
|
AutoUpdate bool `yaml:"auto_update" json:"auto_update"`
|
||||||
CheckOnStart bool `yaml:"check_on_start"`
|
CheckOnStart bool `yaml:"check_on_start" json:"check_on_start"`
|
||||||
Language string `yaml:"language"`
|
Language string `yaml:"language" json:"language"`
|
||||||
KeyboardLayout string `yaml:"keyboard_layout"`
|
KeyboardLayout string `yaml:"keyboard_layout" json:"keyboard_layout"`
|
||||||
} `yaml:"preferences"`
|
} `yaml:"preferences" json:"preferences"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AIProvider struct {
|
type AIProvider struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name" json:"name"`
|
||||||
APIKey string `yaml:"api_key,omitempty"`
|
APIKey string `yaml:"api_key,omitempty" json:"api_key,omitempty"`
|
||||||
BaseURL string `yaml:"base_url,omitempty"`
|
BaseURL string `yaml:"base_url,omitempty" json:"base_url,omitempty"`
|
||||||
Model string `yaml:"model"`
|
Model string `yaml:"model" json:"model"`
|
||||||
Active bool `yaml:"active"`
|
Active bool `yaml:"active" json:"active"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToolConfig struct {
|
type ToolConfig struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name" json:"name"`
|
||||||
Installed bool `yaml:"installed"`
|
Installed bool `yaml:"installed" json:"installed"`
|
||||||
Version string `yaml:"version"`
|
Version string `yaml:"version" json:"version"`
|
||||||
AutoUpdate bool `yaml:"auto_update"`
|
AutoUpdate bool `yaml:"auto_update" json:"auto_update"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SSHConnection struct {
|
type SSHConnection struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name" json:"name"`
|
||||||
Host string `yaml:"host"`
|
Host string `yaml:"host" json:"host"`
|
||||||
Port int `yaml:"port"`
|
Port int `yaml:"port" json:"port"`
|
||||||
User string `yaml:"user"`
|
User string `yaml:"user" json:"user"`
|
||||||
Password string `yaml:"password,omitempty"`
|
Password string `yaml:"password,omitempty" json:"password,omitempty"`
|
||||||
KeyPath string `yaml:"key_path,omitempty"`
|
KeyPath string `yaml:"key_path,omitempty" json:"key_path,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MuyueConfig struct {
|
type MuyueConfig struct {
|
||||||
Version string `yaml:"version"`
|
Version string `yaml:"version" json:"version"`
|
||||||
Profile Profile `yaml:"profile"`
|
Profile Profile `yaml:"profile" json:"profile"`
|
||||||
AI struct {
|
AI struct {
|
||||||
Providers []AIProvider `yaml:"providers"`
|
Providers []AIProvider `yaml:"providers" json:"providers"`
|
||||||
} `yaml:"ai"`
|
} `yaml:"ai" json:"ai"`
|
||||||
Tools []ToolConfig `yaml:"tools"`
|
Tools []ToolConfig `yaml:"tools" json:"tools"`
|
||||||
BMAD struct {
|
BMAD struct {
|
||||||
Installed bool `yaml:"installed"`
|
Installed bool `yaml:"installed" json:"installed"`
|
||||||
Version string `yaml:"version"`
|
Version string `yaml:"version" json:"version"`
|
||||||
Global bool `yaml:"global"`
|
Global bool `yaml:"global" json:"global"`
|
||||||
} `yaml:"bmad"`
|
} `yaml:"bmad" json:"bmad"`
|
||||||
Terminal struct {
|
Terminal struct {
|
||||||
CustomPrompt bool `yaml:"custom_prompt"`
|
CustomPrompt bool `yaml:"custom_prompt" json:"custom_prompt"`
|
||||||
PromptTheme string `yaml:"prompt_theme"`
|
PromptTheme string `yaml:"prompt_theme" json:"prompt_theme"`
|
||||||
SSH []SSHConnection `yaml:"ssh"`
|
SSH []SSHConnection `yaml:"ssh" json:"ssh"`
|
||||||
FontSize int `yaml:"font_size"`
|
FontSize int `yaml:"font_size" json:"font_size"`
|
||||||
FontFamily string `yaml:"font_family"`
|
FontFamily string `yaml:"font_family" json:"font_family"`
|
||||||
Theme string `yaml:"theme"`
|
Theme string `yaml:"theme" json:"theme"`
|
||||||
} `yaml:"terminal"`
|
} `yaml:"terminal" json:"terminal"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TerminalTheme struct {
|
type TerminalTheme struct {
|
||||||
@@ -128,6 +128,22 @@ var DEFAULT_TERMINAL_THEMES = map[string]TerminalTheme{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func migrateProviders(cfg *MuyueConfig) {
|
||||||
|
defaults := Default().AI.Providers
|
||||||
|
for _, dp := range defaults {
|
||||||
|
found := false
|
||||||
|
for _, p := range cfg.AI.Providers {
|
||||||
|
if p.Name == dp.Name {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
cfg.AI.Providers = append(cfg.AI.Providers, dp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func GetTerminalTheme(name string) TerminalTheme {
|
func GetTerminalTheme(name string) TerminalTheme {
|
||||||
if theme, ok := DEFAULT_TERMINAL_THEMES[name]; ok {
|
if theme, ok := DEFAULT_TERMINAL_THEMES[name]; ok {
|
||||||
return theme
|
return theme
|
||||||
@@ -206,6 +222,8 @@ func Load() (*MuyueConfig, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
migrateProviders(&cfg)
|
||||||
|
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,6 +287,12 @@ func Default() *MuyueConfig {
|
|||||||
BaseURL: "https://api.minimax.io/v1",
|
BaseURL: "https://api.minimax.io/v1",
|
||||||
Active: true,
|
Active: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "mimo",
|
||||||
|
Model: "mimo-v2.5-pro",
|
||||||
|
BaseURL: "https://token-plan-ams.xiaomimimo.com/v1",
|
||||||
|
Active: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "zai",
|
Name: "zai",
|
||||||
Model: "glm",
|
Model: "glm",
|
||||||
@@ -297,6 +321,7 @@ func Default() *MuyueConfig {
|
|||||||
|
|
||||||
cfg.Terminal.CustomPrompt = true
|
cfg.Terminal.CustomPrompt = true
|
||||||
cfg.Terminal.PromptTheme = "zerotwo"
|
cfg.Terminal.PromptTheme = "zerotwo"
|
||||||
|
cfg.Terminal.FontSize = 14
|
||||||
|
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -76,6 +77,11 @@ var sharedHTTPClient = &http.Client{
|
|||||||
Timeout: 120 * time.Second,
|
Timeout: 120 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requestClient creates an HTTP client with the specified timeout.
|
||||||
|
func requestClient(timeout time.Duration) *http.Client {
|
||||||
|
return &http.Client{Timeout: timeout}
|
||||||
|
}
|
||||||
|
|
||||||
func New(cfg *config.MuyueConfig) (*Orchestrator, error) {
|
func New(cfg *config.MuyueConfig) (*Orchestrator, error) {
|
||||||
var provider *config.AIProvider
|
var provider *config.AIProvider
|
||||||
for i := range cfg.AI.Providers {
|
for i := range cfg.AI.Providers {
|
||||||
@@ -300,6 +306,142 @@ func (o *Orchestrator) SendWithTools(messages []Message) (*ChatResponse, error)
|
|||||||
return chatResp, nil
|
return chatResp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ChunkCallback is called for each streaming chunk.
|
||||||
|
type ChunkCallback func(content string, toolCalls []ToolCallMsg)
|
||||||
|
|
||||||
|
// SendWithToolsStream sends messages with streaming responses.
|
||||||
|
// The callback receives chunks of content and tool_calls as they arrive.
|
||||||
|
func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCallback) (*ChatResponse, error) {
|
||||||
|
fullMessages := make([]Message, 0, len(messages)+1)
|
||||||
|
if o.systemPrompt != "" {
|
||||||
|
fullMessages = append(fullMessages, Message{Role: "system", Content: o.systemPrompt})
|
||||||
|
}
|
||||||
|
fullMessages = append(fullMessages, messages...)
|
||||||
|
|
||||||
|
reqBody := ChatRequest{
|
||||||
|
Model: o.provider.Model,
|
||||||
|
Messages: fullMessages,
|
||||||
|
Stream: true,
|
||||||
|
Tools: o.tools,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := o.provider
|
||||||
|
baseURL := provider.BaseURL
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = getProviderBaseURL(provider.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
url := strings.TrimRight(baseURL, "/") + "/chat/completions"
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+provider.APIKey)
|
||||||
|
|
||||||
|
resp, err := o.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("send request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var fullContent strings.Builder
|
||||||
|
var accumulatedToolCalls []ToolCallMsg
|
||||||
|
var totalTokens int
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if !strings.HasPrefix(line, "data: ") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data := strings.TrimPrefix(line, "data: ")
|
||||||
|
if data == "[DONE]" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
var chatResp ChatResponse
|
||||||
|
if err := json.Unmarshal([]byte(data), &chatResp); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(chatResp.Choices) > 0 {
|
||||||
|
chunk := chatResp.Choices[0].Delta.Content
|
||||||
|
if chunk != "" {
|
||||||
|
fullContent.WriteString(chunk)
|
||||||
|
onChunk(chunk, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle delta tool calls
|
||||||
|
if len(chatResp.Choices[0].Delta.ToolCalls) > 0 {
|
||||||
|
for _, tc := range chatResp.Choices[0].Delta.ToolCalls {
|
||||||
|
// Find or create the tool call in accumulated list
|
||||||
|
found := false
|
||||||
|
for i := range accumulatedToolCalls {
|
||||||
|
if accumulatedToolCalls[i].ID == tc.ID {
|
||||||
|
// Append arguments
|
||||||
|
accumulatedToolCalls[i].Function.Arguments += tc.Function.Arguments
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
accumulatedToolCalls = append(accumulatedToolCalls, tc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onChunk("", accumulatedToolCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture usage from final chunk
|
||||||
|
if chatResp.Usage.TotalTokens > 0 {
|
||||||
|
totalTokens = chatResp.Usage.TotalTokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("read stream: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build final response
|
||||||
|
finalResp := &ChatResponse{
|
||||||
|
Usage: struct {
|
||||||
|
TotalTokens int `json:"total_tokens"`
|
||||||
|
}{TotalTokens: totalTokens},
|
||||||
|
Choices: []struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
ToolCalls []ToolCallMsg `json:"tool_calls"`
|
||||||
|
} `json:"message"`
|
||||||
|
Delta struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
ToolCalls []ToolCallMsg `json:"tool_calls"`
|
||||||
|
} `json:"delta"`
|
||||||
|
FinishReason *string `json:"finish_reason"`
|
||||||
|
}{},
|
||||||
|
}
|
||||||
|
|
||||||
|
finalContent := cleanAIResponse(fullContent.String())
|
||||||
|
finalResp.Choices[0].Message.Content = finalContent
|
||||||
|
finalResp.Choices[0].Message.ToolCalls = accumulatedToolCalls
|
||||||
|
|
||||||
|
return finalResp, nil
|
||||||
|
}
|
||||||
|
|
||||||
func cleanAIResponse(content string) string {
|
func cleanAIResponse(content string) string {
|
||||||
content = thinkRegex.ReplaceAllString(content, "")
|
content = thinkRegex.ReplaceAllString(content, "")
|
||||||
lines := strings.Split(content, "\n")
|
lines := strings.Split(content, "\n")
|
||||||
@@ -334,6 +476,8 @@ func getProviderBaseURL(name string) string {
|
|||||||
return "https://api.openai.com/v1"
|
return "https://api.openai.com/v1"
|
||||||
case "zai":
|
case "zai":
|
||||||
return "https://api.z.ai/v1"
|
return "https://api.z.ai/v1"
|
||||||
|
case "mimo":
|
||||||
|
return "https://token-plan-ams.xiaomimimo.com/v1"
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -361,14 +505,24 @@ func (o *Orchestrator) sendWithFallback(reqBody ChatRequest, baseURLOverride str
|
|||||||
if o.provider != nil {
|
if o.provider != nil {
|
||||||
providerOrder = append(providerOrder, o.provider)
|
providerOrder = append(providerOrder, o.provider)
|
||||||
}
|
}
|
||||||
|
var zaiProvider *config.AIProvider
|
||||||
for _, p := range providers {
|
for _, p := range providers {
|
||||||
if o.provider == nil || p.Name != o.provider.Name {
|
if o.provider == nil || p.Name != o.provider.Name {
|
||||||
|
if p.Name == "zai" {
|
||||||
|
zaiProvider = p
|
||||||
|
} else {
|
||||||
providerOrder = append(providerOrder, p)
|
providerOrder = append(providerOrder, p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if zaiProvider != nil {
|
||||||
|
providerOrder = append(providerOrder, zaiProvider)
|
||||||
|
}
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
|
var triedProviders []string
|
||||||
for _, prov := range providerOrder {
|
for _, prov := range providerOrder {
|
||||||
|
triedProviders = append(triedProviders, prov.Name)
|
||||||
baseURL := baseURLOverride
|
baseURL := baseURLOverride
|
||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
baseURL = prov.BaseURL
|
baseURL = prov.BaseURL
|
||||||
@@ -392,7 +546,14 @@ func (o *Orchestrator) sendWithFallback(reqBody ChatRequest, baseURLOverride str
|
|||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
// Provider-specific headers
|
||||||
|
if prov.Name == "anthropic" {
|
||||||
|
req.Header.Set("x-api-key", prov.APIKey)
|
||||||
|
req.Header.Set("anthropic-version", "2023-06-01")
|
||||||
|
} else {
|
||||||
req.Header.Set("Authorization", "Bearer "+prov.APIKey)
|
req.Header.Set("Authorization", "Bearer "+prov.APIKey)
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := o.client.Do(req)
|
resp, err := o.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -427,5 +588,6 @@ func (o *Orchestrator) sendWithFallback(reqBody ChatRequest, baseURLOverride str
|
|||||||
return &chatResp, prov.Name, nil
|
return &chatResp, prov.Name, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("[orchestrator] fallback from %v to next provider", triedProviders)
|
||||||
return nil, "", lastErr
|
return nil, "", lastErr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,12 +24,12 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type SystemInfo struct {
|
type SystemInfo struct {
|
||||||
OS OS
|
OS OS `json:"os"`
|
||||||
Arch Arch
|
Arch Arch `json:"arch"`
|
||||||
IsWSL bool
|
IsWSL bool `json:"is_wsl"`
|
||||||
Shell string
|
Shell string `json:"shell"`
|
||||||
Terminal string
|
Terminal string `json:"terminal"`
|
||||||
PackageManager string
|
PackageManager string `json:"package_manager"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Detect() SystemInfo {
|
func Detect() SystemInfo {
|
||||||
|
|||||||
@@ -14,27 +14,27 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ToolStatus struct {
|
type ToolStatus struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name" json:"name"`
|
||||||
Installed bool `yaml:"installed"`
|
Installed bool `yaml:"installed" json:"installed"`
|
||||||
Version string `yaml:"version"`
|
Version string `yaml:"version" json:"version"`
|
||||||
Path string `yaml:"path"`
|
Path string `yaml:"path" json:"path"`
|
||||||
Latest string `yaml:"latest"`
|
Latest string `yaml:"latest" json:"latest"`
|
||||||
NeedsUpdate bool `yaml:"needs_update"`
|
NeedsUpdate bool `yaml:"needs_update" json:"needs_update"`
|
||||||
Category string `yaml:"category"`
|
Category string `yaml:"category" json:"category"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RuntimeStatus struct {
|
type RuntimeStatus struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name" json:"name"`
|
||||||
Installed bool `yaml:"installed"`
|
Installed bool `yaml:"installed" json:"installed"`
|
||||||
Version string `yaml:"version"`
|
Version string `yaml:"version" json:"version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ScanResult struct {
|
type ScanResult struct {
|
||||||
System platform.SystemInfo `yaml:"system"`
|
System platform.SystemInfo `yaml:"system" json:"system"`
|
||||||
Tools []ToolStatus `yaml:"tools"`
|
Tools []ToolStatus `yaml:"tools" json:"tools"`
|
||||||
Runtimes []RuntimeStatus `yaml:"runtimes"`
|
Runtimes []RuntimeStatus `yaml:"runtimes" json:"runtimes"`
|
||||||
ShellSetup bool `yaml:"shell_setup"`
|
ShellSetup bool `yaml:"shell_setup" json:"shell_setup"`
|
||||||
GitConfigured bool `yaml:"git_configured"`
|
GitConfigured bool `yaml:"git_configured" json:"git_configured"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -1,11 +1,33 @@
|
|||||||
package version
|
package version
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
Name = "muyue"
|
Name = "muyue"
|
||||||
Version = "0.3.2"
|
Version = "0.4.0"
|
||||||
Author = "La Légion de Muyue"
|
Author = "La Légion de Muyue"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// BuildDate is set at build time
|
||||||
|
BuildDate = ""
|
||||||
|
)
|
||||||
|
|
||||||
func FullVersion() string {
|
func FullVersion() string {
|
||||||
return Name + " v" + Version
|
return Name + " v" + Version
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FullInfo returns full version information.
|
||||||
|
func FullInfo() string {
|
||||||
|
info := fmt.Sprintf("%-12s %s\n", "Version:", Version)
|
||||||
|
info += fmt.Sprintf("%-12s %s\n", "Author:", Author)
|
||||||
|
info += fmt.Sprintf("%-12s %s\n", "Go:", runtime.Version())
|
||||||
|
info += fmt.Sprintf("%-12s %s\n", "Platform:", runtime.GOOS+"/"+runtime.GOARCH)
|
||||||
|
if BuildDate != "" {
|
||||||
|
info += fmt.Sprintf("%-12s %s\n", "Build:", BuildDate)
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|||||||
@@ -159,14 +159,18 @@ func parsePlanResponse(content string) ([]Step, error) {
|
|||||||
return steps, nil
|
return steps, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const plannerSystemPrompt = `Tu es un assistant de planification de workflows pour Muyue. Tu génères des plans d'exécution sous forme de JSON. Chaque plan est une séquence d'étapes (steps) représentant des appels d'outils.
|
const plannerSystemPrompt = `Tu es un planificateur de workflows pour Muyue. Tu génères des plans d'exécution sous forme de tableaux JSON.
|
||||||
|
|
||||||
Pour générer un plan:
|
RÈGLES :
|
||||||
1. Comprends l'objectif de l'utilisateur
|
1. Analyse l'objectif → identifie les outils → décompose en étapes
|
||||||
2. Identifie les outils nécessaires
|
2. Chaque étape : {"name": string, "tool": string, "args": object}
|
||||||
3. Décompose en étapes logiques
|
3. Max 10 étapes par plan
|
||||||
4. Spécifie les paramètres de chaque outil
|
4. Ordonne par dépendances (les lectures avant les écritures)
|
||||||
|
5. Préfère les commandes non-interactives
|
||||||
|
6. Utilise crush_run pour les tâches complexes multi-fichiers
|
||||||
|
|
||||||
Réponds toujours en JSON valide, sans texte additionnel.`
|
Outils : terminal, crush_run, read_file, list_files, search_files, grep_content, get_config, set_provider, manage_ssh, web_fetch
|
||||||
|
|
||||||
var _ = plannerSystemPrompt
|
Réponds UNIQUEMENT en JSON valide, sans texte avant/après.`
|
||||||
|
|
||||||
|
const _ = plannerSystemPrompt
|
||||||
1
web/.npmrc
Normal file
1
web/.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
legacy-peer-deps=true
|
||||||
1301
web/package-lock.json
generated
1301
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,9 +9,14 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-image": "^0.10.0-beta.203",
|
||||||
|
"@xterm/addon-search": "^0.17.0-beta.203",
|
||||||
|
"@xterm/addon-unicode11": "^0.10.0-beta.203",
|
||||||
"@xterm/addon-web-links": "^0.12.0",
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/addon-webgl": "^0.20.0-beta.202",
|
||||||
|
"@xterm/xterm": "^6.1.0-beta.203",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
|
"mermaid": "^11.14.0",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5"
|
"react-dom": "^19.2.5"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ const api = {
|
|||||||
exportSkill: (name) => request('/skills/export', { method: 'POST', body: JSON.stringify({ name }) }),
|
exportSkill: (name) => request('/skills/export', { method: 'POST', body: JSON.stringify({ name }) }),
|
||||||
importSkill: (path) => request('/skills/import', { method: 'POST', body: JSON.stringify({ import_path: path }) }),
|
importSkill: (path) => request('/skills/import', { method: 'POST', body: JSON.stringify({ import_path: path }) }),
|
||||||
getDashboardStatus: () => request('/dashboard/status'),
|
getDashboardStatus: () => request('/dashboard/status'),
|
||||||
|
getProvidersQuota: () => request('/providers/quota'),
|
||||||
|
getProvidersConsumption: () => request('/providers/consumption'),
|
||||||
|
getRecentCommands: () => request('/recent-commands'),
|
||||||
|
getRunningProcesses: () => request('/running-processes'),
|
||||||
|
getSystemMetrics: () => request('/system/metrics'),
|
||||||
savePreferences: (prefs) => request('/preferences', { method: 'PUT', body: JSON.stringify(prefs) }),
|
savePreferences: (prefs) => request('/preferences', { method: 'PUT', body: JSON.stringify(prefs) }),
|
||||||
saveProfile: (profile) => request('/config/profile', { method: 'PUT', body: JSON.stringify(profile) }),
|
saveProfile: (profile) => request('/config/profile', { method: 'PUT', body: JSON.stringify(profile) }),
|
||||||
saveProvider: (provider) => request('/config/provider', { method: 'PUT', body: JSON.stringify(provider) }),
|
saveProvider: (provider) => request('/config/provider', { method: 'PUT', body: JSON.stringify(provider) }),
|
||||||
@@ -52,7 +57,12 @@ const api = {
|
|||||||
saveTerminalSettings: (settings) => request('/terminal/settings', { method: 'PUT', body: JSON.stringify(settings) }),
|
saveTerminalSettings: (settings) => request('/terminal/settings', { method: 'PUT', body: JSON.stringify(settings) }),
|
||||||
getChatHistory: () => request('/chat/history'),
|
getChatHistory: () => request('/chat/history'),
|
||||||
clearChat: () => request('/chat/clear', { method: 'POST' }),
|
clearChat: () => request('/chat/clear', { method: 'POST' }),
|
||||||
sendChat: (message, stream = true, onChunk) => {
|
summarizeChat: () => request('/chat/summarize', { method: 'POST' }),
|
||||||
|
getShellChatHistory: () => request('/shell/chat/history'),
|
||||||
|
clearShellChat: () => request('/shell/chat/clear', { method: 'POST' }),
|
||||||
|
analyzeSystem: () => request('/shell/analyze', { method: 'POST' }),
|
||||||
|
getShellAnalysis: () => request('/shell/analysis'),
|
||||||
|
sendChat: (message, stream = true, onChunk, signal) => {
|
||||||
if (!stream) {
|
if (!stream) {
|
||||||
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) })
|
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) })
|
||||||
}
|
}
|
||||||
@@ -61,6 +71,7 @@ const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ message, stream: true }),
|
body: JSON.stringify({ message, stream: true }),
|
||||||
|
signal,
|
||||||
}).then(async (res) => {
|
}).then(async (res) => {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||||
@@ -95,11 +106,9 @@ const api = {
|
|||||||
}).catch(reject)
|
}).catch(reject)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
sendShellChat: (message, context = {}, stream = true, onChunk) => {
|
sendShellChat: (message, context = {}, stream = true, onChunk, signal) => {
|
||||||
const payload = {
|
const payload = {
|
||||||
message,
|
message,
|
||||||
context: context.context || '',
|
|
||||||
history: context.history || [],
|
|
||||||
cwd: context.cwd || '',
|
cwd: context.cwd || '',
|
||||||
platform: context.platform || '',
|
platform: context.platform || '',
|
||||||
stream,
|
stream,
|
||||||
@@ -112,6 +121,7 @@ const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
|
signal,
|
||||||
}).then(async (res) => {
|
}).then(async (res) => {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||||
@@ -121,7 +131,6 @@ const api = {
|
|||||||
const reader = res.body.getReader()
|
const reader = res.body.getReader()
|
||||||
const decoder = new TextDecoder()
|
const decoder = new TextDecoder()
|
||||||
let full = ''
|
let full = ''
|
||||||
let toolCalls = []
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read()
|
const { done, value } = await reader.read()
|
||||||
if (done) break
|
if (done) break
|
||||||
@@ -131,27 +140,19 @@ const api = {
|
|||||||
try {
|
try {
|
||||||
const data = JSON.parse(line.slice(6))
|
const data = JSON.parse(line.slice(6))
|
||||||
if (data.error) { reject(new Error(data.error)); return }
|
if (data.error) { reject(new Error(data.error)); return }
|
||||||
if (data.done) {
|
if (data.done) { resolve({ content: full, tokens: data.tokens }); return }
|
||||||
resolve({ content: full, tool_calls: toolCalls })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (data.content) {
|
if (data.content) {
|
||||||
full += data.content
|
full += data.content
|
||||||
if (onChunk) onChunk(full, data)
|
if (onChunk) onChunk(full, data)
|
||||||
} else if (data.tool_call) {
|
} else if (data.tool_call || data.tool_result) {
|
||||||
toolCalls.push(data.tool_call)
|
if (onChunk) onChunk(full, data)
|
||||||
if (onChunk) onChunk(full, data, toolCalls)
|
} else if (data.thinking !== undefined || data.thinking_end) {
|
||||||
} else if (data.tool_result) {
|
if (onChunk) onChunk(full, data)
|
||||||
const idx = toolCalls.findIndex(tc => tc.tool_call_id === data.tool_result.id)
|
|
||||||
if (idx >= 0) {
|
|
||||||
toolCalls[idx].result = data.tool_result
|
|
||||||
}
|
|
||||||
if (onChunk) onChunk(full, data, toolCalls)
|
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
resolve({ content: full, tool_calls: toolCalls })
|
resolve({ content: full })
|
||||||
}).catch(reject)
|
}).catch(reject)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||||
import { LayoutDashboard, Sparkles, Terminal, Settings } from 'lucide-react'
|
import { LayoutDashboard, Sparkles, Terminal, Settings } from 'lucide-react'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
import { getTheme, applyTheme } from '../themes'
|
import { getTheme, applyTheme } from '../themes'
|
||||||
@@ -13,6 +13,9 @@ export default function App() {
|
|||||||
const [activeTab, setActiveTab] = useState('dash')
|
const [activeTab, setActiveTab] = useState('dash')
|
||||||
const [info, setInfo] = useState({})
|
const [info, setInfo] = useState({})
|
||||||
const [clock, setClock] = useState(new Date())
|
const [clock, setClock] = useState(new Date())
|
||||||
|
const [isSudo, setIsSudo] = useState(false)
|
||||||
|
const [dashRefreshKey, setDashRefreshKey] = useState(0)
|
||||||
|
const dashRefreshRef = useRef(null)
|
||||||
const [updates, setUpdates] = useState([])
|
const [updates, setUpdates] = useState([])
|
||||||
const [tools, setTools] = useState([])
|
const [tools, setTools] = useState([])
|
||||||
const [config, setConfig] = useState(null)
|
const [config, setConfig] = useState(null)
|
||||||
@@ -27,7 +30,7 @@ export default function App() {
|
|||||||
], [t])
|
], [t])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getInfo().then(setInfo).catch(() => {})
|
api.getInfo().then(d => { setInfo(d); setIsSudo(!!d.sudo) }).catch(() => {})
|
||||||
api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
|
api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
|
||||||
api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
|
api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
|
||||||
api.getConfig().then(d => {
|
api.getConfig().then(d => {
|
||||||
@@ -60,6 +63,11 @@ export default function App() {
|
|||||||
if (map[e.code]) {
|
if (map[e.code]) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setActiveTab(map[e.code])
|
setActiveTab(map[e.code])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.ctrlKey && e.code === 'KeyR') {
|
||||||
|
e.preventDefault()
|
||||||
|
if (dashRefreshRef.current) dashRefreshRef.current()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
@@ -68,38 +76,32 @@ export default function App() {
|
|||||||
|
|
||||||
const switchTab = useCallback((tabId) => setActiveTab(tabId), [])
|
const switchTab = useCallback((tabId) => setActiveTab(tabId), [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => setActiveTab('shell')
|
||||||
|
window.addEventListener('navigate-to-shell', handler)
|
||||||
|
return () => window.removeEventListener('navigate-to-shell', handler)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const hasUpdates = updates.some(u => u.needsUpdate)
|
const hasUpdates = updates.some(u => u.needsUpdate)
|
||||||
const installed = tools.filter(tool => tool.installed).length
|
const installed = tools.filter(tool => tool.installed).length
|
||||||
|
|
||||||
const WINDOW_SHORTCUTS = useMemo(() => ({
|
const WINDOW_SHORTCUTS = useMemo(() => ({
|
||||||
dash: [
|
dash: [],
|
||||||
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
|
|
||||||
],
|
|
||||||
studio: [
|
studio: [
|
||||||
{ keys: layout.keys.enter, desc: t('statusbar.sendMessage') },
|
{ keys: layout.keys.enter, desc: t('statusbar.sendMessage') },
|
||||||
{ keys: `${layout.keys.shift}+${layout.keys.enter}`, desc: t('statusbar.newLine') },
|
{ keys: `${layout.keys.shift}+${layout.keys.enter}`, desc: t('statusbar.newLine') },
|
||||||
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
|
|
||||||
],
|
],
|
||||||
shell: [
|
shell: [
|
||||||
|
{ keys: `${layout.keys.ctrl}+${layout.keys.shift}+C`, desc: t('statusbar.copy') },
|
||||||
|
{ keys: `${layout.keys.ctrl}+${layout.keys.shift}+V`, desc: t('statusbar.paste') },
|
||||||
|
{ keys: `${layout.keys.ctrl}+${layout.keys.shift}+F`, desc: t('statusbar.search') },
|
||||||
|
{ keys: `${layout.keys.ctrl}++/${layout.keys.ctrl}+−`, desc: t('statusbar.zoom') },
|
||||||
{ keys: layout.keys.enter, desc: t('statusbar.runCommand') },
|
{ keys: layout.keys.enter, desc: t('statusbar.runCommand') },
|
||||||
{ keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') },
|
{ keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') },
|
||||||
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
|
|
||||||
],
|
|
||||||
config: [
|
|
||||||
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
|
|
||||||
],
|
],
|
||||||
|
config: [],
|
||||||
}), [layout, t])
|
}), [layout, t])
|
||||||
|
|
||||||
const renderContent = () => {
|
|
||||||
switch (activeTab) {
|
|
||||||
case 'dash': return <Dashboard api={api} />
|
|
||||||
case 'studio': return <Studio api={api} />
|
|
||||||
case 'shell': return <Shell api={api} />
|
|
||||||
case 'config': return <Config api={api} />
|
|
||||||
default: return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-layout">
|
<div className="app-layout">
|
||||||
<header className="header">
|
<header className="header">
|
||||||
@@ -141,12 +143,21 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="content fade-in" key={`${activeTab}-${TABS.length}`}>
|
<main className="content">
|
||||||
{renderContent()}
|
<div className={activeTab === 'dash' ? '' : 'tab-hidden'}><Dashboard api={api} refreshRef={dashRefreshRef} /></div>
|
||||||
|
<div className={activeTab === 'studio' ? '' : 'tab-hidden'}><Studio api={api} /></div>
|
||||||
|
<div className={activeTab === 'shell' ? '' : 'tab-hidden'}><Shell api={api} /></div>
|
||||||
|
<div className={activeTab === 'config' ? '' : 'tab-hidden'}><Config api={api} /></div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="statusbar">
|
<footer className="statusbar">
|
||||||
<div className="statusbar-left">
|
<div className="statusbar-left">
|
||||||
|
{isSudo && <span className="statusbar-sudo">⚡ ROOT</span>}
|
||||||
|
{activeTab === 'dash' && (
|
||||||
|
<span className="statusbar-shortcut">
|
||||||
|
<kbd>{layout.keys.ctrl}+R</kbd> refresh
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<FooterShortcuts shortcuts={WINDOW_SHORTCUTS[activeTab] || []} />
|
<FooterShortcuts shortcuts={WINDOW_SHORTCUTS[activeTab] || []} />
|
||||||
</div>
|
</div>
|
||||||
<div className="statusbar-right">
|
<div className="statusbar-right">
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { User, Brain, RefreshCw, Globe, Wrench, Monitor } from 'lucide-react'
|
import { User, Brain, RefreshCw, Wrench, Monitor, AlertTriangle } from 'lucide-react'
|
||||||
import { useI18n, LANGUAGES } from '../i18n'
|
import { useI18n } from '../i18n'
|
||||||
import { getLayoutList } from '../i18n/keyboards'
|
|
||||||
|
|
||||||
const PANELS = [
|
const PANELS = [
|
||||||
{ id: 'profile', icon: User },
|
{ id: 'profile', icon: User },
|
||||||
{ id: 'providers', icon: Brain },
|
{ id: 'providers', icon: Brain },
|
||||||
{ id: 'updates', icon: RefreshCw },
|
{ id: 'updates', icon: RefreshCw },
|
||||||
{ id: 'locale', icon: Globe },
|
|
||||||
{ id: 'skills', icon: Wrench },
|
{ id: 'skills', icon: Wrench },
|
||||||
{ id: 'system', icon: Monitor },
|
{ id: 'system', icon: Monitor },
|
||||||
]
|
]
|
||||||
@@ -29,19 +27,10 @@ export default function Config({ api }) {
|
|||||||
const [toast, setToast] = useState(null)
|
const [toast, setToast] = useState(null)
|
||||||
|
|
||||||
|
|
||||||
const layouts = getLayoutList()
|
|
||||||
|
|
||||||
const loadData = useCallback(() => {
|
const loadData = useCallback(() => {
|
||||||
api.getConfig().then(d => {
|
api.getConfig().then(d => {
|
||||||
setConfig(d)
|
setConfig(d)
|
||||||
setProfileForm({
|
setProfileForm(d.profile ? JSON.parse(JSON.stringify(d.profile)) : {})
|
||||||
name: d.profile?.name || '',
|
|
||||||
pseudo: d.profile?.pseudo || '',
|
|
||||||
email: d.profile?.email || '',
|
|
||||||
editor: d.profile?.preferences?.editor || '',
|
|
||||||
shell: d.profile?.preferences?.shell || '',
|
|
||||||
})
|
|
||||||
|
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {})
|
api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {})
|
||||||
api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {})
|
api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {})
|
||||||
@@ -72,28 +61,15 @@ export default function Config({ api }) {
|
|||||||
setChecking(false)
|
setChecking(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdateTool = async (tool) => {
|
const handleUpdateTool = (tool) => {
|
||||||
setUpdating(tool)
|
window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
|
||||||
try {
|
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Met à jour l'outil ${tool} sur mon système. Exécute les commandes nécessaires.` } }))
|
||||||
await api.runUpdate(tool)
|
|
||||||
await handleCheckUpdates()
|
|
||||||
showToast(`${tool} ✓`)
|
|
||||||
} catch (err) {
|
|
||||||
showToast(`${t('config.error')}: ${err.message}`)
|
|
||||||
}
|
|
||||||
setUpdating(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdateAll = async () => {
|
const handleUpdateAll = () => {
|
||||||
setUpdating('__all__')
|
const toUpdate = updates.filter(u => u.needsUpdate).map(u => u.tool)
|
||||||
try {
|
window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
|
||||||
await api.runUpdate('')
|
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Met à jour tous les outils suivants sur mon système : ${toUpdate.join(', ')}. Exécute les commandes nécessaires une par une.` } }))
|
||||||
await handleCheckUpdates()
|
|
||||||
showToast(t('config.saved'))
|
|
||||||
} catch (err) {
|
|
||||||
showToast(`${t('config.error')}: ${err.message}`)
|
|
||||||
}
|
|
||||||
setUpdating(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSaveProfile = async () => {
|
const handleSaveProfile = async () => {
|
||||||
@@ -125,9 +101,9 @@ export default function Config({ api }) {
|
|||||||
...prev,
|
...prev,
|
||||||
[p.name]: {
|
[p.name]: {
|
||||||
name: p.name,
|
name: p.name,
|
||||||
api_key: p.apiKey || '',
|
api_key: p.api_key || '',
|
||||||
model: p.model || '',
|
model: p.model || '',
|
||||||
base_url: p.baseURL || '',
|
base_url: p.base_url || '',
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
setEditProvider(p.name)
|
setEditProvider(p.name)
|
||||||
@@ -188,13 +164,6 @@ export default function Config({ api }) {
|
|||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activePanel === 'locale' && (
|
|
||||||
<PanelLocale
|
|
||||||
language={keyboard} layouts={layouts}
|
|
||||||
setLanguage={setLanguage} setKeyboard={setKeyboard}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{activePanel === 'skills' && (
|
{activePanel === 'skills' && (
|
||||||
<PanelSkills skillList={skillList} t={t} />
|
<PanelSkills skillList={skillList} t={t} />
|
||||||
)}
|
)}
|
||||||
@@ -209,93 +178,188 @@ export default function Config({ api }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PanelProfile({ config, editProfile, profileForm, setProfileForm, setEditProfile, handleSaveProfile, t }) {
|
function PanelProfile({ config, editProfile, profileForm, setProfileForm, setEditProfile, handleSaveProfile, t }) {
|
||||||
|
const updateField = (path, value) => {
|
||||||
|
setProfileForm(prev => {
|
||||||
|
const next = JSON.parse(JSON.stringify(prev))
|
||||||
|
const keys = path.split('.')
|
||||||
|
let target = next
|
||||||
|
for (let i = 0; i < keys.length - 1; i++) {
|
||||||
|
if (target[keys[i]] == null) target[keys[i]] = {}
|
||||||
|
target = target[keys[i]]
|
||||||
|
}
|
||||||
|
target[keys[keys.length - 1]] = value
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = editProfile ? profileForm : config?.profile
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
return (
|
return (
|
||||||
|
<div className="config-profile-center">
|
||||||
<div className="config-card">
|
<div className="config-card">
|
||||||
{config?.profile && !editProfile ? (
|
<div className="empty-state">{t('config.loadingProfile')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const personalKeys = Object.entries(profile).filter(([k, v]) => k !== 'preferences' && typeof v !== 'object')
|
||||||
|
const personalObj = Object.fromEntries(personalKeys)
|
||||||
|
const preferences = profile.preferences || null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="config-profile-center">
|
||||||
|
<div className="config-card">
|
||||||
|
<div className="section-title">{t('config.profileInfo') || 'Informations personnelles'}</div>
|
||||||
|
<RenderFields obj={personalObj} path="" editing={editProfile} onChange={updateField} t={t} />
|
||||||
|
</div>
|
||||||
|
<div className="config-card">
|
||||||
|
<div className="section-title">{t('config.profilePrefs') || 'Préférences'}</div>
|
||||||
|
{preferences ? (
|
||||||
|
<RenderFields obj={preferences} path="preferences" editing={editProfile} onChange={updateField} t={t} />
|
||||||
|
) : (
|
||||||
|
<div className="config-card-row"><span className="config-card-value" style={{ color: 'var(--text-disabled)' }}>—</span></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="config-card">
|
||||||
|
<div className="config-card-actions" style={{ justifyContent: 'center' }}>
|
||||||
|
{editProfile ? (
|
||||||
<>
|
<>
|
||||||
<div className="config-card-row">
|
|
||||||
<span className="config-card-label">{t('config.name')}</span>
|
|
||||||
<span className="config-card-value">{config.profile.name || '—'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="config-card-row">
|
|
||||||
<span className="config-card-label">{t('config.pseudo')}</span>
|
|
||||||
<span className="config-card-value">{config.profile.pseudo || '—'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="config-card-row">
|
|
||||||
<span className="config-card-label">{t('config.email')}</span>
|
|
||||||
<span className="config-card-value">{config.profile.email || '—'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="config-card-row">
|
|
||||||
<span className="config-card-label">{t('config.editor')}</span>
|
|
||||||
<span className="config-card-value mono">{config.profile.preferences?.editor || '—'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="config-card-row">
|
|
||||||
<span className="config-card-label">{t('config.shell')}</span>
|
|
||||||
<span className="config-card-value mono">{config.profile.preferences?.shell || '—'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="config-card-row">
|
|
||||||
<span className="config-card-label">{t('config.languages')}</span>
|
|
||||||
<span className="config-card-value">{config.profile.languages?.join(', ') || '—'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="config-card-actions">
|
|
||||||
<button className="primary sm" onClick={() => setEditProfile(true)}>{t('config.editProfile')}</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : editProfile ? (
|
|
||||||
<>
|
|
||||||
<FormInput label={t('config.name')} value={profileForm.name} onChange={v => setProfileForm(p => ({ ...p, name: v }))} />
|
|
||||||
<FormInput label={t('config.pseudo')} value={profileForm.pseudo} onChange={v => setProfileForm(p => ({ ...p, pseudo: v }))} />
|
|
||||||
<FormInput label={t('config.email')} value={profileForm.email} onChange={v => setProfileForm(p => ({ ...p, email: v }))} type="email" />
|
|
||||||
<FormInput label={t('config.editor')} value={profileForm.editor} onChange={v => setProfileForm(p => ({ ...p, editor: v }))} />
|
|
||||||
<FormInput label={t('config.shell')} value={profileForm.shell} onChange={v => setProfileForm(p => ({ ...p, shell: v }))} />
|
|
||||||
<div className="config-card-actions">
|
|
||||||
<button className="primary sm" onClick={handleSaveProfile}>{t('config.save')}</button>
|
<button className="primary sm" onClick={handleSaveProfile}>{t('config.save')}</button>
|
||||||
<button className="ghost sm" onClick={() => setEditProfile(false)}>{t('config.cancel')}</button>
|
<button className="ghost sm" onClick={() => setEditProfile(false)}>{t('config.cancel')}</button>
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="empty-state">{t('config.loadingProfile')}</div>
|
<button className="primary sm" onClick={() => {
|
||||||
|
setProfileForm(config.profile ? JSON.parse(JSON.stringify(config.profile)) : {})
|
||||||
|
setEditProfile(true)
|
||||||
|
}}>{t('config.editProfile')}</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RenderFields({ obj, path, editing, onChange, t }) {
|
||||||
|
if (!obj || typeof obj !== 'object') return null
|
||||||
|
|
||||||
|
return Object.entries(obj).filter(([, v]) => v === null || typeof v !== 'object').map(([key, value]) => {
|
||||||
|
const fieldPath = path ? `${path}.${key}` : key
|
||||||
|
const label = getFieldLabel(key, t)
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return (
|
||||||
|
<div key={key} className="config-card-row">
|
||||||
|
<span className="config-card-label">{label}</span>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" checked={value} onChange={e => onChange(fieldPath, e.target.checked)} />
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>{value ? 'On' : 'Off'}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return (
|
||||||
|
<div key={key} className="config-form-field">
|
||||||
|
<label className="config-form-label">{label}</label>
|
||||||
|
<input className="config-form-input" value={value.join(', ')} onChange={e => onChange(fieldPath, e.target.value.split(',').map(s => s.trim()).filter(Boolean))} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div key={key} className="config-form-field">
|
||||||
|
<label className="config-form-label">{label}</label>
|
||||||
|
<input className="config-form-input" type={typeof value === 'number' ? 'number' : 'text'} value={value ?? ''} onChange={e => onChange(fieldPath, typeof value === 'number' ? Number(e.target.value) : e.target.value)} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return (
|
||||||
|
<div key={key} className="config-card-row">
|
||||||
|
<span className="config-card-label">{label}</span>
|
||||||
|
<span className="config-card-value">{value ? 'On' : 'Off'}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return (
|
||||||
|
<div key={key} className="config-card-row">
|
||||||
|
<span className="config-card-label">{label}</span>
|
||||||
|
<span className="config-card-value">{value.length > 0 ? value.join(', ') : '—'}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div key={key} className="config-card-row">
|
||||||
|
<span className="config-card-label">{label}</span>
|
||||||
|
<span className="config-card-value">{value != null && value !== '' ? String(value) : '—'}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFieldLabel(key, t) {
|
||||||
|
const translated = t(`config.${key}`)
|
||||||
|
if (translated !== `config.${key}`) return translated
|
||||||
|
return key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||||
|
}
|
||||||
|
|
||||||
function PanelProviders({ providers, editProvider, providerForm, setProviderForm, setEditProvider, openProviderEdit, handleSaveProvider, api, loadData, t }) {
|
function PanelProviders({ providers, editProvider, providerForm, setProviderForm, setEditProvider, openProviderEdit, handleSaveProvider, api, loadData, t }) {
|
||||||
const [validating, setValidating] = useState(null)
|
const [validating, setValidating] = useState(null)
|
||||||
const [validationStatus, setValidationStatus] = useState(null)
|
const [keyStatus, setKeyStatus] = useState({})
|
||||||
|
|
||||||
const handleValidate = async (name, apiKey, model, baseUrl) => {
|
const validateKey = async (p) => {
|
||||||
setValidating(name)
|
setValidating(p.name)
|
||||||
setValidationStatus(null)
|
|
||||||
try {
|
try {
|
||||||
await api.validateProvider({ name, api_key: apiKey, model, base_url: baseUrl })
|
await api.validateProvider({ name: p.name, api_key: p.api_key, model: p.model, base_url: p.base_url || '' })
|
||||||
setValidationStatus({ provider: name, valid: true })
|
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: true, checked: true } }))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err.message || ''
|
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: err.message || 'Clé invalide' } }))
|
||||||
if (msg.includes('invalid_api_key')) {
|
|
||||||
setValidationStatus({ provider: name, valid: false, error: t('config.keyInvalid') })
|
|
||||||
} else {
|
|
||||||
setValidationStatus({ provider: name, valid: false, error: `${t('config.connectionFailed')}: ${msg}` })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setValidating(null)
|
setValidating(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
providers.forEach(p => {
|
||||||
|
if (p.api_key && !keyStatus[p.name]) {
|
||||||
|
validateKey(p)
|
||||||
|
} else if (!p.api_key) {
|
||||||
|
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: 'Aucune clé' } }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [providers])
|
||||||
|
|
||||||
|
const handleValidate = async (name, apiKey, model, baseUrl) => {
|
||||||
|
setValidating(name)
|
||||||
|
try {
|
||||||
|
await api.validateProvider({ name, api_key: apiKey, model, base_url: baseUrl })
|
||||||
|
setKeyStatus(prev => ({ ...prev, [name]: { valid: true, checked: true } }))
|
||||||
|
} catch (err) {
|
||||||
|
setKeyStatus(prev => ({ ...prev, [name]: { valid: false, checked: true, error: err.message || 'Clé invalide' } }))
|
||||||
|
}
|
||||||
|
setValidating(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayed = providers.filter(p => p.name === 'minimax' || p.name === 'mimo')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="config-providers-list">
|
<div className="config-providers-list">
|
||||||
<div className="provider-setup-hint">{t('config.setupDescription')}</div>
|
{displayed.map((p, i) => {
|
||||||
{providers.map((p, i) => {
|
|
||||||
const isEditing = editProvider === p.name
|
const isEditing = editProvider === p.name
|
||||||
const isValidationTarget = validationStatus?.provider === p.name
|
const currentModel = providerForm[p.name]?.model || p.model
|
||||||
|
const status = keyStatus[p.name]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={i} className="config-card provider-card-v2">
|
<div key={i} className="config-card provider-card-v2">
|
||||||
<div className="provider-card-top">
|
<div className="provider-card-top">
|
||||||
<div className="provider-card-identity">
|
<div className="provider-card-identity">
|
||||||
<span className="provider-card-name">{p.name}</span>
|
<span className="provider-card-name">{p.name.toUpperCase()}</span>
|
||||||
{p.apiKey && <span className="badge ok">{t('config.keyConfigured')}</span>}
|
{p.active && <span className="badge ok" style={{ marginLeft: 6 }}>active</span>}
|
||||||
{!p.apiKey && <span className="badge error">{t('config.noKey')}</span>}
|
{status?.checked && status?.valid && <span className="badge ok">✓ {t('config.keyValid')}</span>}
|
||||||
{isValidationTarget && validationStatus?.valid && <span className="badge ok">{t('config.keyValid')}</span>}
|
{status?.checked && !status?.valid && <span className="badge error">✗ {status.error || t('config.keyInvalid')}</span>}
|
||||||
{isValidationTarget && !validationStatus?.valid && <span className="badge error">{validationStatus?.error}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -306,7 +370,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
|||||||
<input
|
<input
|
||||||
className="config-form-input"
|
className="config-form-input"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder={t('config.tokenPlaceholder')}
|
placeholder={p.api_key ? '••••••••' : t('config.tokenPlaceholder')}
|
||||||
value={isEditing ? (providerForm[p.name]?.api_key || '') : ''}
|
value={isEditing ? (providerForm[p.name]?.api_key || '') : ''}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
if (!isEditing) openProviderEdit(p)
|
if (!isEditing) openProviderEdit(p)
|
||||||
@@ -321,17 +385,18 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
|||||||
<button
|
<button
|
||||||
className="sm primary"
|
className="sm primary"
|
||||||
disabled={validating === p.name || !providerForm[p.name]?.api_key}
|
disabled={validating === p.name || !providerForm[p.name]?.api_key}
|
||||||
onClick={() => handleValidate(p.name, providerForm[p.name]?.api_key, providerForm[p.name]?.model, providerForm[p.name]?.base_url)}
|
onClick={() => handleValidate(p.name, providerForm[p.name]?.api_key, currentModel, providerForm[p.name]?.base_url)}
|
||||||
>
|
>
|
||||||
{validating === p.name ? t('config.validating') : t('config.validateKey')}
|
{validating === p.name ? t('config.validating') : t('config.validateKey')}
|
||||||
</button>
|
</button>
|
||||||
{isValidationTarget && validationStatus?.valid && (
|
{isEditing && (
|
||||||
<button className="sm" onClick={() => handleSaveProvider(p.name)}>{t('config.save')}</button>
|
<button className="sm" onClick={() => handleSaveProvider(p.name)}>{t('config.save')}</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="provider-card-meta" style={{ marginTop: 8 }}>
|
<div className="provider-card-model">
|
||||||
<span className="mono">{p.model || '—'}</span>
|
<span className="provider-card-model-label">{t('config.model')}</span>
|
||||||
|
<span className="provider-card-model-value">{p.model || '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -341,7 +406,14 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PanelUpdates({ updates, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleUpdateAll, t }) {
|
function PanelUpdates({ updates, tools, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleUpdateAll, t }) {
|
||||||
|
const handleInstallTool = (tool) => {
|
||||||
|
window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
|
||||||
|
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Installe l'outil ${tool} sur mon système. Vérifie d'abord s'il est déjà installé, puis installe-le si nécessaire avec les commandes appropriées.` } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingTools = tools.filter(tool => !tool.installed)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="config-card">
|
<div className="config-card">
|
||||||
@@ -364,6 +436,30 @@ function PanelUpdates({ updates, checking, updating, needsUpdateCount, installed
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{missingTools.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="section-title" style={{ marginTop: 12, marginBottom: 4 }}>{t('config.missing') || 'Modules manquants'}</div>
|
||||||
|
<div className="config-update-list">
|
||||||
|
{missingTools.map((tool, i) => (
|
||||||
|
<div key={`miss-${i}`} className="config-update-row">
|
||||||
|
<div className="config-update-info">
|
||||||
|
<span className="config-update-name">{tool.name}</span>
|
||||||
|
<span className="config-update-versions">
|
||||||
|
<span style={{ color: 'var(--danger)' }}>{t('config.notInstalled') || 'Non installé'}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="sm primary"
|
||||||
|
onClick={() => handleInstallTool(tool.name)}
|
||||||
|
>
|
||||||
|
{t('config.install') || 'Installer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{updates.length === 0 ? (
|
{updates.length === 0 ? (
|
||||||
<div className="config-card">
|
<div className="config-card">
|
||||||
<div className="empty-state">{t('config.noUpdates')}</div>
|
<div className="empty-state">{t('config.noUpdates')}</div>
|
||||||
@@ -399,71 +495,90 @@ function PanelUpdates({ updates, checking, updating, needsUpdateCount, installed
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PanelLocale({ language, keyboard, layouts, setLanguage, setKeyboard, t }) {
|
|
||||||
return (
|
|
||||||
<div className="config-card">
|
|
||||||
<div className="config-card-group">
|
|
||||||
<span className="config-card-group-label">{t('config.language')}</span>
|
|
||||||
<div className="chip-row">
|
|
||||||
{LANGUAGES.map(lang => (
|
|
||||||
<div
|
|
||||||
key={lang.id}
|
|
||||||
className={`chip ${language === lang.id ? 'active' : ''}`}
|
|
||||||
onClick={() => setLanguage(lang.id)}
|
|
||||||
>
|
|
||||||
{lang.name}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="config-card-group">
|
|
||||||
<span className="config-card-group-label">{t('config.keyboardLayout')}</span>
|
|
||||||
<div className="chip-row">
|
|
||||||
{layouts.map(l => (
|
|
||||||
<div
|
|
||||||
key={l.id}
|
|
||||||
className={`chip ${keyboard === l.id ? 'active' : ''}`}
|
|
||||||
onClick={() => setKeyboard(l.id)}
|
|
||||||
>
|
|
||||||
{l.name}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PanelSkills({ skillList, t }) {
|
function PanelSkills({ skillList, t }) {
|
||||||
|
const [selected, setSelected] = useState(null)
|
||||||
|
|
||||||
|
if (skillList.length === 0) {
|
||||||
|
return <div className="empty-state" style={{ color: 'var(--text-disabled)', padding: 40 }}>{t('config.noSkills')}</div>
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="config-card">
|
<>
|
||||||
{skillList.length === 0 ? (
|
<div className="skill-tiles">
|
||||||
<div className="empty-state">
|
{skillList.map((s, i) => (
|
||||||
{t('config.noSkills')}
|
<div key={i} className="skill-tile" onClick={() => setSelected(s)}>
|
||||||
<span style={{ fontFamily: 'var(--font-mono)' }}>{t('config.runSkillsInit')}</span>
|
<div className="skill-tile-name">{s.name}</div>
|
||||||
|
<div className="skill-tile-desc">{s.description}</div>
|
||||||
|
<div className="skill-tile-tags">
|
||||||
|
{s.target && <span className="badge neutral">{s.target}</span>}
|
||||||
|
{s.version && <span className="badge">{s.version}</span>}
|
||||||
|
{s.category && <span className="badge" style={{ opacity: 0.7 }}>{s.category}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{selected && (
|
||||||
|
<div className="skill-detail-overlay" onClick={() => setSelected(null)}>
|
||||||
|
<div className="skill-detail-panel" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="skill-detail-header">
|
||||||
|
<span className="skill-detail-name">{selected.name}</span>
|
||||||
|
<button className="ghost sm" onClick={() => setSelected(null)}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="skill-detail-body">
|
||||||
|
<div className="skill-detail-section">
|
||||||
|
<div className="skill-detail-label">Description</div>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{selected.description}</div>
|
||||||
|
</div>
|
||||||
|
<div className="skill-detail-section">
|
||||||
|
<div className="skill-detail-label">Métadonnées</div>
|
||||||
|
<div className="skill-detail-meta">
|
||||||
|
{selected.target && <span className="badge neutral">{selected.target}</span>}
|
||||||
|
{selected.version && <span className="badge">{selected.version}</span>}
|
||||||
|
{selected.category && <span className="badge">{selected.category}</span>}
|
||||||
|
{selected.author && <span className="badge ghost">{selected.author}</span>}
|
||||||
|
{selected.languages && selected.languages.map(l => <span key={l} className="badge ghost">{l}</span>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selected.tags && selected.tags.length > 0 && (
|
||||||
|
<div className="skill-detail-section">
|
||||||
|
<div className="skill-detail-label">Tags</div>
|
||||||
|
<div className="chip-row">
|
||||||
|
{selected.tags.map(tag => <span key={tag} className="badge">{tag}</span>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selected.content && (
|
||||||
|
<div className="skill-detail-section">
|
||||||
|
<div className="skill-detail-label">Contenu</div>
|
||||||
|
<div className="skill-detail-content">{selected.content}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selected.dependencies && selected.dependencies.length > 0 && (
|
||||||
|
<div className="skill-detail-section">
|
||||||
|
<div className="skill-detail-label">Dépendances</div>
|
||||||
|
<div className="skill-detail-deps">
|
||||||
|
{selected.dependencies.map((d, i) => (
|
||||||
|
<div key={i} className="skill-detail-dep">
|
||||||
|
<span className="badge">{d.type}</span>
|
||||||
|
<span>{d.name}</span>
|
||||||
|
{d.required === false && <span style={{ fontSize: 11, color: 'var(--text-disabled)' }}>optionnel</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
skillList.map((s, i) => (
|
|
||||||
<div key={i} className="config-skill-row">
|
|
||||||
<span className="config-skill-name">{s.name}</span>
|
|
||||||
<span className="badge neutral">{s.target || 'both'}</span>
|
|
||||||
{s.version && <span className="badge" style={{ fontSize: 10 }}>{s.version}</span>}
|
|
||||||
{s.category && <span className="badge" style={{ fontSize: 10, opacity: 0.7 }}>{s.category}</span>}
|
|
||||||
<span className="config-skill-desc">{s.description}</span>
|
|
||||||
{s.dependencies && s.dependencies.length > 0 && (
|
|
||||||
<div style={{ marginTop: 4, fontSize: 10, color: 'var(--muted)' }}>
|
|
||||||
deps: {s.dependencies.map(d => d.name).join(', ')}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PanelSystem({ api, t }) {
|
function PanelSystem({ api, t }) {
|
||||||
const [resetConfirm, setResetConfirm] = useState(false)
|
const [showResetModal, setShowResetModal] = useState(false)
|
||||||
const [toast, setToast] = useState(null)
|
const [toast, setToast] = useState(null)
|
||||||
|
|
||||||
const showToast = (msg) => {
|
const showToast = (msg) => {
|
||||||
@@ -474,7 +589,7 @@ function PanelSystem({ api, t }) {
|
|||||||
const handleReset = async () => {
|
const handleReset = async () => {
|
||||||
try {
|
try {
|
||||||
await api.resetConfig()
|
await api.resetConfig()
|
||||||
setResetConfirm(false)
|
setShowResetModal(false)
|
||||||
showToast(t('config.resetDone'))
|
showToast(t('config.resetDone'))
|
||||||
setTimeout(() => window.location.reload(), 1500)
|
setTimeout(() => window.location.reload(), 1500)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -482,49 +597,66 @@ function PanelSystem({ api, t }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleApplyStarship = async () => {
|
const handleApplyStarship = () => {
|
||||||
try {
|
window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
|
||||||
await api.applyStarshipTheme('charm')
|
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Vérifie si starship est installé sur le système. S'il ne l'est pas, installe-le (avec curl ou le gestionnaire de paquets). Ensuite, applique la configuration du thème "charm" pour starship. Assure-toi que starship est bien initialisé dans le shell de l'utilisateur.` } }))
|
||||||
showToast(t('config.starshipApplied'))
|
|
||||||
} catch (err) {
|
|
||||||
showToast(`${t('config.error')}: ${err.message}`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{toast && <div className="config-toast">{toast}</div>}
|
{toast && <div className="config-toast">{toast}</div>}
|
||||||
|
|
||||||
|
<div className="section-title" style={{ marginBottom: 8 }}>Configuration Système</div>
|
||||||
<div className="config-card">
|
<div className="config-card">
|
||||||
<div className="config-card-row" style={{ marginBottom: 16 }}>
|
<div className="config-card-row" style={{ marginBottom: 16 }}>
|
||||||
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.applyStarship')}</span>
|
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.applyStarship')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 12 }}>
|
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 12 }}>
|
||||||
{t('config.starshipApplied')}
|
Vérifie l'installation de starship et configure le thème charm via l'IA.
|
||||||
</div>
|
</div>
|
||||||
<button className="sm primary" onClick={handleApplyStarship}>
|
<button className="sm primary" onClick={handleApplyStarship}>
|
||||||
{t('config.applyStarship')}
|
{t('config.applyStarship')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="config-card" style={{ marginTop: 12 }}>
|
|
||||||
|
<div className="section-title" style={{ marginTop: 20, marginBottom: 8, color: 'var(--danger)' }}>
|
||||||
|
<AlertTriangle size={14} style={{ verticalAlign: 'middle', marginRight: 6 }} />
|
||||||
|
Zone Rouge
|
||||||
|
</div>
|
||||||
|
<div className="config-card" style={{ borderColor: 'var(--danger)', borderWidth: 1, borderStyle: 'solid' }}>
|
||||||
<div className="config-card-row" style={{ marginBottom: 16 }}>
|
<div className="config-card-row" style={{ marginBottom: 16 }}>
|
||||||
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.resetConfig')}</span>
|
<span className="config-card-label" style={{ fontWeight: 600, color: 'var(--danger)' }}>{t('config.resetConfig')}</span>
|
||||||
</div>
|
</div>
|
||||||
{resetConfirm ? (
|
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 12 }}>
|
||||||
<div>
|
Cette action supprimera toute votre configuration et relancera l'application.
|
||||||
<div style={{ fontSize: 12, color: 'var(--warning)', marginBottom: 12 }}>
|
|
||||||
{t('config.resetConfirm')}
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<button className="sm ghost danger" onClick={() => setShowResetModal(true)}>
|
||||||
<button className="sm" onClick={() => setResetConfirm(false)}>{t('config.cancel')}</button>
|
|
||||||
<button className="sm danger" onClick={handleReset}>{t('config.resetConfig')}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button className="sm ghost danger" onClick={() => setResetConfirm(true)}>
|
|
||||||
{t('config.resetConfig')}
|
{t('config.resetConfig')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showResetModal && (
|
||||||
|
<div className="shell-modal-overlay" onClick={() => setShowResetModal(false)}>
|
||||||
|
<div className="shell-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="shell-modal-header" style={{ color: 'var(--danger)' }}>
|
||||||
|
<AlertTriangle size={16} style={{ verticalAlign: 'middle', marginRight: 8 }} />
|
||||||
|
{t('config.resetConfig')}
|
||||||
|
</div>
|
||||||
|
<div className="shell-modal-body">
|
||||||
|
<p style={{ color: 'var(--warning)', fontSize: 13, marginBottom: 12 }}>
|
||||||
|
{t('config.resetConfirm')}
|
||||||
|
</p>
|
||||||
|
<p style={{ color: 'var(--text-tertiary)', fontSize: 12 }}>
|
||||||
|
Cette action est irréversible. Toute votre configuration (profil, clés API, préférences) sera supprimée.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="shell-modal-footer">
|
||||||
|
<button className="ghost" onClick={() => setShowResetModal(false)}>{t('config.cancel')}</button>
|
||||||
|
<button className="danger" onClick={handleReset}>{t('config.resetConfig')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,437 +1,284 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { useI18n } from '../i18n'
|
import { useI18n } from '../i18n'
|
||||||
|
|
||||||
const TOOL_ICONS = {
|
const MAX_POINTS = 30
|
||||||
crush: '⚡',
|
|
||||||
claude: '🤖',
|
const POLL_INTERVAL = 5000
|
||||||
go: '🔷',
|
const MAX_IDLE_POLLS = 3
|
||||||
node: '🟢',
|
|
||||||
python: '🐍',
|
function formatTokens(n) {
|
||||||
docker: '🐳',
|
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
|
||||||
git: '📚',
|
if (n >= 1000) return (n / 1000).toFixed(1) + 'K'
|
||||||
ssh: '🌐',
|
return String(n)
|
||||||
starship: '🚀',
|
|
||||||
rust: '🦀',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ToolCard({ tool, onInstall, installing }) {
|
function MiniGraph({ data, max, color, label, unit }) {
|
||||||
|
if (!data || data.length < 2) return <div className="dash-graph-empty">collecting...</div>
|
||||||
|
const m = max || Math.max(...data, 1)
|
||||||
|
const w = 100
|
||||||
|
const h = 32
|
||||||
|
const points = data.map((v, i) => {
|
||||||
|
const x = (i / (data.length - 1)) * w
|
||||||
|
const y = h - (v / m) * h
|
||||||
|
return `${x},${y}`
|
||||||
|
}).join(' ')
|
||||||
|
const last = data[data.length - 1]
|
||||||
|
return (
|
||||||
|
<div className="dash-graph-wrap">
|
||||||
|
<div className="dash-graph-header">
|
||||||
|
<span className="dash-graph-label">{label}</span>
|
||||||
|
<span className="dash-graph-value" style={{ color }}>{last.toFixed(1)}{unit}</span>
|
||||||
|
</div>
|
||||||
|
<svg viewBox={`0 0 ${w} ${h}`} className="dash-graph-svg" preserveAspectRatio="none">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`fg-${color.replace('#','').replace('var(','').replace(')','')}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={color} stopOpacity="0.3" />
|
||||||
|
<stop offset="100%" stopColor={color} stopOpacity="0.02" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<polygon fill={`url(#fg-${color.replace('#','').replace('var(','').replace(')','')})`} points={`${points} ${w},${h} 0,${h}`} />
|
||||||
|
<polyline fill="none" stroke={color} strokeWidth="1.5" points={points} vectorEffect="non-scaling-stroke" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BarChart({ data, max, color }) {
|
||||||
|
if (!data || data.length === 0) return null
|
||||||
|
const barW = 100 / 7
|
||||||
|
const m = max || Math.max(...data.map(d => d.tokens), 1)
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 100 40" className="dash-graph-svg" preserveAspectRatio="none">
|
||||||
|
{data.map((d, i) => {
|
||||||
|
const h = Math.max(1, (d.tokens / m) * 36)
|
||||||
|
const x = i * barW + barW * 0.15
|
||||||
|
const w = barW * 0.7
|
||||||
|
return (
|
||||||
|
<rect key={i} x={x} y={40 - h} width={w} height={h} rx="1.5" fill={color} opacity={0.85} />
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Dashboard({ api, refreshRef }) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const [showInstall, setShowInstall] = useState(false)
|
const [quota, setQuota] = useState(null)
|
||||||
|
const [consumption, setConsumption] = useState(null)
|
||||||
const icon = TOOL_ICONS[tool.name?.toLowerCase()] || '🔧'
|
const [recentCmds, setRecentCmds] = useState([])
|
||||||
const isInstalled = tool.installed || tool.status === 'installed'
|
const [processes, setProcesses] = useState([])
|
||||||
const version = tool.version || ''
|
const [metrics, setMetrics] = useState(null)
|
||||||
const hasUpdate = tool.hasUpdate || tool.updateAvailable
|
const [copiedSet, setCopiedSet] = useState(new Set())
|
||||||
|
const cpuRef = useRef([])
|
||||||
return (
|
const memRef = useRef([])
|
||||||
<div className={`tool-card ${isInstalled ? 'installed' : 'missing'}`}>
|
const netRxRef = useRef([])
|
||||||
<div className="tool-card-icon">{icon}</div>
|
const netTxRef = useRef([])
|
||||||
<div className="tool-card-info">
|
|
||||||
<div className="tool-card-name">{tool.name || 'Unknown'}</div>
|
|
||||||
<div className="tool-card-version">
|
|
||||||
{isInstalled ? (
|
|
||||||
<span className="status-ok">{t('dashboard.installed')}</span>
|
|
||||||
) : (
|
|
||||||
<span className="status-missing">{t('dashboard.missing')}</span>
|
|
||||||
)}
|
|
||||||
{version && <span className="tool-version-text">{version}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="tool-card-actions">
|
|
||||||
{isInstalled && hasUpdate && (
|
|
||||||
<span className="tool-update-badge" title={`Update to ${tool.latestVersion || 'latest'}`}>
|
|
||||||
↑ {tool.latestVersion || 'new'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!isInstalled && (
|
|
||||||
<button
|
|
||||||
className="sm primary"
|
|
||||||
onClick={() => onInstall(tool.name)}
|
|
||||||
disabled={installing}
|
|
||||||
>
|
|
||||||
{installing ? '...' : t('dashboard.install')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActivityItem({ entry }) {
|
|
||||||
const time = entry.time
|
|
||||||
? new Date(entry.time).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
||||||
: ''
|
|
||||||
const type = entry.type || entry.level || 'info'
|
|
||||||
const text = entry.message || entry.text || entry.content || ''
|
|
||||||
|
|
||||||
const typeClass = {
|
|
||||||
ok: 'notif-ok',
|
|
||||||
success: 'notif-ok',
|
|
||||||
install: 'notif-ok',
|
|
||||||
update: 'notif-info',
|
|
||||||
info: 'notif-info',
|
|
||||||
warn: 'notif-warn',
|
|
||||||
warning: 'notif-warn',
|
|
||||||
error: 'notif-error',
|
|
||||||
fail: 'notif-error',
|
|
||||||
}[type] || 'notif-info'
|
|
||||||
|
|
||||||
const icon = {
|
|
||||||
ok: '✓', success: '✓', install: '✓', update: '→',
|
|
||||||
info: 'ℹ', warn: '⚠', warning: '⚠', error: '✗', fail: '✗',
|
|
||||||
}[type] || '•'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`notif-row ${typeClass}`}>
|
|
||||||
<span className="notif-time">{time}</span>
|
|
||||||
<span className="notif-icon">{icon}</span>
|
|
||||||
<span className="notif-text">{text}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function QuickActionButton({ icon, label, onClick, loading, disabled }) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className="quick-action-btn"
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={disabled || loading}
|
|
||||||
>
|
|
||||||
{loading ? <span className="spinner" style={{ width: 14, height: 14 }} /> : <span className="quick-action-icon">{icon}</span>}
|
|
||||||
<span className="quick-action-label">{label}</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Dashboard({ api }) {
|
|
||||||
const { t } = useI18n()
|
|
||||||
const [activeTab, setActiveTab] = useState('tools')
|
|
||||||
const [tools, setTools] = useState([])
|
|
||||||
const [updates, setUpdates] = useState([])
|
|
||||||
const [systemInfo, setSystemInfo] = useState(null)
|
|
||||||
const [notifications, setNotifications] = useState([])
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [installing, setInstalling] = useState(false)
|
|
||||||
const [scanLoading, setScanLoading] = useState(false)
|
|
||||||
const [mcpLoading, setMcpLoading] = useState(false)
|
|
||||||
const [dashboardStatus, setDashboardStatus] = useState(null)
|
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [toolsData, updatesData, systemData] = await Promise.all([
|
const [quotaData, consumData, cmdData, procData, metricsData] = await Promise.all([
|
||||||
api.getTools().catch(() => ({ tools: [] })),
|
api.getProvidersQuota().catch(() => null),
|
||||||
api.getUpdates().catch(() => ({ updates: [] })),
|
api.getProvidersConsumption().catch(() => null),
|
||||||
api.getSystem().catch(() => null),
|
api.getRecentCommands().catch(() => ({ commands: [] })),
|
||||||
|
api.getRunningProcesses().catch(() => ({ processes: [] })),
|
||||||
|
api.getSystemMetrics().catch(() => null),
|
||||||
])
|
])
|
||||||
setTools(toolsData.tools || toolsData || [])
|
setQuota(quotaData?.providers || [])
|
||||||
setUpdates(updatesData.updates || updatesData || [])
|
setConsumption(consumData?.providers || {})
|
||||||
setSystemInfo(systemData)
|
setRecentCmds(cmdData.commands || [])
|
||||||
api.getDashboardStatus().then(d => setDashboardStatus(d)).catch(() => {})
|
setProcesses(procData.processes || [])
|
||||||
|
if (metricsData) {
|
||||||
|
setMetrics(metricsData)
|
||||||
|
cpuRef.current = [...cpuRef.current, metricsData.cpu_percent].slice(-MAX_POINTS)
|
||||||
|
memRef.current = [...memRef.current, metricsData.mem_percent].slice(-MAX_POINTS)
|
||||||
|
netRxRef.current = [...netRxRef.current, metricsData.net_rx_kbs].slice(-MAX_POINTS)
|
||||||
|
netTxRef.current = [...netTxRef.current, metricsData.net_tx_kbs].slice(-MAX_POINTS)
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load dashboard data:', err)
|
console.error('Dashboard load error:', err)
|
||||||
}
|
}
|
||||||
}, [api])
|
}, [api])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData()
|
loadData()
|
||||||
}, [loadData])
|
if (refreshRef) refreshRef.current = loadData
|
||||||
|
let active = true
|
||||||
const addNotification = (message, type = 'info') => {
|
let idleTicks = 0
|
||||||
const entry = { id: Date.now(), time: new Date().toISOString(), message, type }
|
const iv = setInterval(() => {
|
||||||
setNotifications(prev => [entry, ...prev].slice(0, 100))
|
const hidden = document.querySelector('.dash-grid')?.closest('.tab-hidden')
|
||||||
}
|
if (hidden) {
|
||||||
|
idleTicks++
|
||||||
const handleRescan = async () => {
|
if (idleTicks >= MAX_IDLE_POLLS) return
|
||||||
setScanLoading(true)
|
|
||||||
addNotification(t('dashboard.rescanning'), 'info')
|
|
||||||
try {
|
|
||||||
await api.runScan()
|
|
||||||
await loadData()
|
|
||||||
addNotification(t('dashboard.scanComplete'), 'ok')
|
|
||||||
} catch (err) {
|
|
||||||
addNotification(`${t('dashboard.scanFailed')}: ${err.message}`, 'error')
|
|
||||||
} finally {
|
|
||||||
setScanLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleInstallMissing = async () => {
|
|
||||||
const missing = tools.filter(t => !t.installed && t.status !== 'installed')
|
|
||||||
if (missing.length === 0) return
|
|
||||||
setInstalling(true)
|
|
||||||
addNotification(t('dashboard.installing', { count: missing.length }), 'info')
|
|
||||||
try {
|
|
||||||
await api.installTools(missing.map(t => t.name))
|
|
||||||
addNotification(t('dashboard.installStarted'), 'ok')
|
|
||||||
setTimeout(() => handleRescan(), 2000)
|
|
||||||
} catch (err) {
|
|
||||||
addNotification(`${t('dashboard.installFailed')}: ${err.message}`, 'error')
|
|
||||||
} finally {
|
|
||||||
setInstalling(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCheckUpdates = async () => {
|
|
||||||
setLoading(true)
|
|
||||||
addNotification(t('config.checking'), 'info')
|
|
||||||
try {
|
|
||||||
const data = await api.getUpdates()
|
|
||||||
setUpdates(data.updates || data || [])
|
|
||||||
const count = (data.updates || data || []).length
|
|
||||||
if (count > 0) {
|
|
||||||
addNotification(t('dashboard.updatesCount', { count }), 'warn')
|
|
||||||
} else {
|
} else {
|
||||||
addNotification(t('dashboard.allUpToDate'), 'ok')
|
idleTicks = 0
|
||||||
}
|
}
|
||||||
} catch (err) {
|
if (active) loadData()
|
||||||
addNotification(`${t('dashboard.checkUpdatesFailed')}: ${err.message}`, 'error')
|
}, POLL_INTERVAL)
|
||||||
} finally {
|
return () => { active = false; clearInterval(iv) }
|
||||||
setLoading(false)
|
}, [loadData, refreshRef])
|
||||||
|
|
||||||
|
const minimax = (quota || []).find(p => p.name === 'minimax')
|
||||||
|
|
||||||
|
const EXCLUDE_CMDS = ['ls', 'cd', 'pwd', 'clear', 'exit', 'history', 'cat', 'echo', 'grep', 'export', 'alias', 'unalias', 'set', 'unset', 'source', '.', 'fg', 'bg', 'jobs', 'wait', 'true', 'false', 'yes', 'sleep', 'date', 'whoami', 'id', 'uname', 'hostname', 'uptime', 'df', 'free', 'top', 'htop', 'nano', 'vi', 'vim', 'less', 'more', 'tail', 'head', 'man', 'info', 'which', 'whereis', 'type', 'command', 'hash', 'builtin', 'help']
|
||||||
|
|
||||||
|
const topCmds = (() => {
|
||||||
|
const counts = {}
|
||||||
|
for (const c of recentCmds) {
|
||||||
|
const base = c.cmd.split(/\s+/)[0]
|
||||||
|
if (!base || base.length < 2 || EXCLUDE_CMDS.includes(base)) continue
|
||||||
|
if (!/^[a-zA-Z@.\/]/.test(base)) continue
|
||||||
|
counts[base] = (counts[base] || 0) + 1
|
||||||
}
|
}
|
||||||
|
return Object.entries(counts)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(([cmd, count]) => ({ cmd, count }))
|
||||||
|
})()
|
||||||
|
|
||||||
|
const maxCount = topCmds.length > 0 ? topCmds[0].count : 1
|
||||||
|
|
||||||
|
const copyCmd = (cmd, key) => {
|
||||||
|
navigator.clipboard.writeText(cmd)
|
||||||
|
setCopiedSet(prev => new Set(prev).add(key))
|
||||||
|
setTimeout(() => setCopiedSet(prev => { const next = new Set(prev); next.delete(key); return next }), 1500)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleConfigureMCP = async () => {
|
const relativeTime = (ts) => {
|
||||||
setMcpLoading(true)
|
if (!ts) return ''
|
||||||
addNotification(t('dashboard.configuringMCP'), 'info')
|
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000)
|
||||||
try {
|
if (diff < 60) return `${diff}s`
|
||||||
await api.configureMCP()
|
if (diff < 3600) return `${Math.floor(diff / 60)}m`
|
||||||
addNotification(t('dashboard.mcpConfigured'), 'ok')
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h`
|
||||||
} catch (err) {
|
return `${Math.floor(diff / 86400)}d`
|
||||||
addNotification(`${t('dashboard.mcpConfigFailed')}: ${err.message}`, 'error')
|
|
||||||
} finally {
|
|
||||||
setMcpLoading(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleInstallTool = async (name) => {
|
const recentUnique = (() => {
|
||||||
setInstalling(true)
|
const seen = new Set()
|
||||||
addNotification(`${t('dashboard.installing')} ${name}...`, 'info')
|
return recentCmds.filter(c => {
|
||||||
try {
|
if (seen.has(c.cmd)) return false
|
||||||
await api.installTools([name])
|
seen.add(c.cmd)
|
||||||
addNotification(`${name} ${t('dashboard.installed')}`, 'ok')
|
return true
|
||||||
setTimeout(() => loadData(), 2000)
|
})
|
||||||
} catch (err) {
|
})()
|
||||||
addNotification(`${t('dashboard.installFailed')}: ${err.message}`, 'error')
|
|
||||||
} finally {
|
|
||||||
setInstalling(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const installedCount = tools.filter(t => t.installed || t.status === 'installed').length
|
const providerEntries = consumption ? Object.entries(consumption) : []
|
||||||
const missingCount = tools.length - installedCount
|
const colors = ['var(--accent)', '#34d399', '#a78bfa', '#f59e0b', '#f472b6']
|
||||||
|
const maxDaily = providerEntries.length > 0
|
||||||
|
? Math.max(...providerEntries.map(([, p]) => Math.max(...(p.daily || []).map(d => d.tokens), 0)), 1)
|
||||||
|
: 1
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dashboard-layout">
|
<div className="dash-grid">
|
||||||
<div className="dashboard-tabs">
|
{/* CPU */}
|
||||||
<button
|
<div className="dash-card">
|
||||||
className={`dashboard-tab ${activeTab === 'tools' ? 'active' : ''}`}
|
<div className="dash-card-head">
|
||||||
onClick={() => setActiveTab('tools')}
|
<span className="dash-label">CPU</span>
|
||||||
>
|
<span className="dash-count">{metrics ? metrics.cpu_percent.toFixed(0) : '—'}%</span>
|
||||||
<span className="tab-icon">🔧</span>
|
</div>
|
||||||
{t('dashboard.tools')}
|
<MiniGraph data={cpuRef.current} max={100} color="var(--accent)" label="CPU" unit="%" />
|
||||||
<span className="tab-count">{installedCount}</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`dashboard-tab ${activeTab === 'activity' ? 'active' : ''}`}
|
|
||||||
onClick={() => setActiveTab('activity')}
|
|
||||||
>
|
|
||||||
<span className="tab-icon">📋</span>
|
|
||||||
{t('dashboard.activity')}
|
|
||||||
{notifications.length > 0 && <span className="tab-count warn">{notifications.length}</span>}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`dashboard-tab ${activeTab === 'actions' ? 'active' : ''}`}
|
|
||||||
onClick={() => setActiveTab('actions')}
|
|
||||||
>
|
|
||||||
<span className="tab-icon">⚡</span>
|
|
||||||
{t('dashboard.quickActions')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`dashboard-tab ${activeTab === 'status' ? 'active' : ''}`}
|
|
||||||
onClick={() => setActiveTab('status')}
|
|
||||||
>
|
|
||||||
<span className="tab-icon">📡</span>
|
|
||||||
{t('dashboard.status') || 'Status'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dashboard-content">
|
{/* RAM */}
|
||||||
{activeTab === 'tools' && (
|
<div className="dash-card">
|
||||||
<div className="dashboard-tools-panel">
|
<div className="dash-card-head">
|
||||||
<div className="dashboard-section-header">
|
<span className="dash-label">RAM</span>
|
||||||
<div className="dashboard-section-title">{t('dashboard.systemOverview')}</div>
|
<span className="dash-count">{metrics ? `${metrics.mem_used_mb.toFixed(0)}/${metrics.mem_total_mb.toFixed(0)}` : '—'}</span>
|
||||||
<div className="dashboard-tools-stats">
|
|
||||||
<span className="stat-ok">{installedCount} {t('dashboard.installed')}</span>
|
|
||||||
{missingCount > 0 && <span className="stat-missing">{missingCount} {t('dashboard.missing')}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<MiniGraph data={memRef.current} max={100} color="#a78bfa" label="RAM" unit="%" />
|
||||||
{systemInfo && (
|
|
||||||
<div className="dashboard-system-info">
|
|
||||||
<span className="sys-info-item">{systemInfo.os || systemInfo.platform || 'Unknown'}</span>
|
|
||||||
<span className="sys-info-sep">·</span>
|
|
||||||
<span className="sys-info-item">{systemInfo.arch || 'Unknown'}</span>
|
|
||||||
{systemInfo.shell && <><span className="sys-info-sep">·</span><span className="sys-info-item">{systemInfo.shell}</span></>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="tools-grid">
|
|
||||||
{tools.length === 0 && (
|
|
||||||
<div className="empty-state">{t('dashboard.noTools')}</div>
|
|
||||||
)}
|
|
||||||
{tools.map((tool, i) => (
|
|
||||||
<ToolCard
|
|
||||||
key={tool.name || i}
|
|
||||||
tool={tool}
|
|
||||||
onInstall={handleInstallTool}
|
|
||||||
installing={installing}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'activity' && (
|
|
||||||
<div className="dashboard-activity-panel">
|
|
||||||
<div className="dashboard-section-header">
|
|
||||||
<div className="dashboard-section-title">{t('dashboard.activityLog')}</div>
|
|
||||||
<button className="sm ghost" onClick={() => setNotifications([])} disabled={notifications.length === 0}>
|
|
||||||
{t('dashboard.clearLog')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{notifications.length === 0 ? (
|
|
||||||
<div className="empty-state">{t('dashboard.noActivity')}</div>
|
|
||||||
) : (
|
|
||||||
<div className="activity-log">
|
|
||||||
{notifications.map(entry => (
|
|
||||||
<ActivityItem key={entry.id} entry={entry} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'actions' && (
|
|
||||||
<div className="dashboard-actions-panel">
|
|
||||||
<div className="dashboard-section-header">
|
|
||||||
<div className="dashboard-section-title">{t('dashboard.quickActions')}</div>
|
|
||||||
</div>
|
|
||||||
<div className="quick-actions-grid">
|
|
||||||
<QuickActionButton
|
|
||||||
icon="🔍"
|
|
||||||
label={t('dashboard.rescanSystem')}
|
|
||||||
onClick={handleRescan}
|
|
||||||
loading={scanLoading}
|
|
||||||
/>
|
|
||||||
<QuickActionButton
|
|
||||||
icon="📦"
|
|
||||||
label={t('dashboard.installMissing')}
|
|
||||||
onClick={handleInstallMissing}
|
|
||||||
loading={installing}
|
|
||||||
disabled={missingCount === 0}
|
|
||||||
/>
|
|
||||||
<QuickActionButton
|
|
||||||
icon="🔄"
|
|
||||||
label={t('dashboard.checkUpdates')}
|
|
||||||
onClick={handleCheckUpdates}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
<QuickActionButton
|
|
||||||
icon="⚙"
|
|
||||||
label={t('dashboard.configureMCP')}
|
|
||||||
onClick={handleConfigureMCP}
|
|
||||||
loading={mcpLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{updates.length > 0 && (
|
{/* Network */}
|
||||||
<div className="dashboard-updates-section">
|
<div className="dash-card">
|
||||||
<div className="dashboard-section-header">
|
<div className="dash-card-head">
|
||||||
<div className="dashboard-section-title">{t('dashboard.updates')}</div>
|
<span className="dash-label">Network</span>
|
||||||
<span className="badge warn">{updates.length}</span>
|
<span className="dash-count">{metrics ? `↓${metrics.net_rx_kbs.toFixed(0)} ↑${metrics.net_tx_kbs.toFixed(0)}` : '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="updates-list">
|
<MiniGraph data={netRxRef.current} max={null} color="#34d399" label="RX" unit=" KB/s" />
|
||||||
{updates.map((update, i) => (
|
<MiniGraph data={netTxRef.current} max={null} color="#f59e0b" label="TX" unit=" KB/s" />
|
||||||
<div key={update.name || i} className="update-row">
|
</div>
|
||||||
<div className="update-info">
|
|
||||||
<span className="update-name">{update.name || 'Unknown'}</span>
|
{/* Consommation */}
|
||||||
<span className="update-versions">
|
<div className="dash-card">
|
||||||
{update.current || update.version || '?'} → {update.latest || update.target || '?'}
|
<div className="dash-card-head">
|
||||||
|
<span className="dash-label">Consommation</span>
|
||||||
|
<span className="dash-count">7j</span>
|
||||||
|
</div>
|
||||||
|
<div className="dash-consumption-list">
|
||||||
|
{providerEntries.length === 0 && (
|
||||||
|
<span className="dash-empty">Aucune donnée</span>
|
||||||
|
)}
|
||||||
|
{providerEntries.map(([name, p], pi) => (
|
||||||
|
<div key={name} className="dash-consumption-provider">
|
||||||
|
<div className="dash-consumption-head">
|
||||||
|
<span className="dash-consumption-name" style={{ color: colors[pi % colors.length] }}>
|
||||||
|
{name.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="dash-consumption-total">
|
||||||
|
{formatTokens(p.total_tokens)} tokens · {p.total_requests} req
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<BarChart data={p.daily || []} max={maxDaily} color={colors[pi % colors.length]} />
|
||||||
className="sm"
|
<div className="dash-consumption-days">
|
||||||
onClick={() => api.runUpdate(update.name)}
|
{(p.daily || []).map((d, i) => (
|
||||||
disabled={loading}
|
<span key={i} className="dash-consumption-day">
|
||||||
>
|
{d.date.slice(5)} <strong>{formatTokens(d.tokens)}</strong>
|
||||||
{t('dashboard.update')}
|
</span>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'status' && (
|
|
||||||
<div className="dashboard-status-panel">
|
|
||||||
{dashboardStatus ? (
|
|
||||||
<>
|
|
||||||
<div className="dashboard-section-header">
|
|
||||||
<div className="dashboard-section-title">MCP Servers</div>
|
|
||||||
<span className="badge">{dashboardStatus.mcp?.healthy || 0}/{dashboardStatus.mcp?.total || 0} healthy</span>
|
|
||||||
</div>
|
|
||||||
<div className="tools-grid" style={{ marginBottom: 16 }}>
|
|
||||||
{(dashboardStatus.mcp?.servers || []).map((s, i) => (
|
|
||||||
<div key={i} className={`tool-card ${s.healthy ? 'installed' : s.installed ? '' : 'missing'}`}>
|
|
||||||
<div className="tool-card-info">
|
|
||||||
<div className="tool-card-name">{s.name}</div>
|
|
||||||
<div className="tool-card-version">
|
|
||||||
{s.healthy ? <span className="status-ok">healthy</span> :
|
|
||||||
s.installed ? <span className="status-missing">installed</span> :
|
|
||||||
<span className="status-missing">not found</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="dashboard-section-header">
|
{/* Running Processes */}
|
||||||
<div className="dashboard-section-title">LSP Servers</div>
|
<div className="dash-card">
|
||||||
<span className="badge">{dashboardStatus.lsp?.installed || 0}/{dashboardStatus.lsp?.total || 0} installed</span>
|
<div className="dash-card-head">
|
||||||
</div>
|
<span className="dash-label">Processes</span>
|
||||||
<div className="tools-grid" style={{ marginBottom: 16 }}>
|
<span className="dash-count">{processes.length}</span>
|
||||||
{(dashboardStatus.lsp?.servers || []).filter(s => s.installed).map((s, i) => (
|
|
||||||
<div key={i} className="tool-card installed">
|
|
||||||
<div className="tool-card-info">
|
|
||||||
<div className="tool-card-name">{s.name}</div>
|
|
||||||
<div className="tool-card-version">
|
|
||||||
<span className="status-ok">{s.language}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="dash-proc-list">
|
||||||
|
{processes.length === 0 && <span className="dash-empty">No relevant processes</span>}
|
||||||
|
{processes.map((p, i) => (
|
||||||
|
<div key={i} className="dash-proc-row">
|
||||||
|
<span className="dash-proc-name">{p.name}</span>
|
||||||
|
<span className="dash-proc-res">cpu {p.cpu}% · mem {p.mem}%</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="dashboard-section-header">
|
{/* Recent Commands */}
|
||||||
<div className="dashboard-section-title">Skills</div>
|
<div className="dash-card dash-cmd-card">
|
||||||
<span className="badge">{dashboardStatus.skills?.total || 0} deployed</span>
|
<div className="dash-card-head">
|
||||||
{(dashboardStatus.skills?.issues || []).length > 0 && (
|
<span className="dash-label">Recent Commands</span>
|
||||||
<span className="badge warn">{(dashboardStatus.skills.issues || []).length} issues</span>
|
<span className="dash-count">{recentUnique.length}</span>
|
||||||
)}
|
</div>
|
||||||
|
{topCmds.length > 0 && (
|
||||||
|
<div className="dash-cmd-freq">
|
||||||
|
<span className="dash-cmd-freq-title">Most used</span>
|
||||||
|
{topCmds.map((c, i) => (
|
||||||
|
<div key={i} className="dash-cmd-freq-row" onClick={() => copyCmd(c.cmd, `top-${i}`)} title={c.cmd}>
|
||||||
|
<span className="dash-cmd-freq-name">{copiedSet.has(`top-${i}`) ? '✓ Copié' : c.cmd}</span>
|
||||||
|
<div className="dash-cmd-freq-bar-wrap">
|
||||||
|
<div className="dash-cmd-freq-bar" style={{ width: `${(c.count / maxCount) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="dash-cmd-freq-count">{c.count}×</span>
|
||||||
</div>
|
</div>
|
||||||
{(dashboardStatus.skills?.issues || []).length > 0 && (
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--warning)', marginBottom: 8 }}>
|
|
||||||
{(dashboardStatus.skills.issues || []).map((issue, i) => (
|
|
||||||
<div key={i}>{issue}</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
<div className="dash-cmd-list">
|
||||||
) : (
|
{recentUnique.length === 0 && <span className="dash-empty">No history</span>}
|
||||||
<div className="empty-state">Loading status...</div>
|
{recentUnique.map((c, i) => (
|
||||||
)}
|
<div key={i} className="dash-cmd-row" onClick={() => copyCmd(c.cmd, `list-${i}`)} title={c.cmd + ' · click to copy'}>
|
||||||
|
<div className="dash-cmd-left">
|
||||||
|
<span className="dash-cmd-text">{c.cmd.length > 38 ? c.cmd.slice(0, 35) + '...' : c.cmd}</span>
|
||||||
|
<span className="dash-cmd-time">{relativeTime(c.ts)}</span>
|
||||||
|
</div>
|
||||||
|
<span className="dash-cmd-copy">{copiedSet.has(`list-${i}`) ? '✓' : '⎘'}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { Sparkles, ArrowRight, ArrowLeft, Search, Loader } from 'lucide-react'
|
import { Sparkles, ArrowRight, ArrowLeft, Loader } from 'lucide-react'
|
||||||
import { useI18n, LANGUAGES } from '../i18n'
|
import { useI18n, LANGUAGES } from '../i18n'
|
||||||
import { getLayoutList } from '../i18n/keyboards'
|
import { getLayoutList } from '../i18n/keyboards'
|
||||||
|
|
||||||
@@ -32,6 +32,8 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
const [validating, setValidating] = useState(false)
|
const [validating, setValidating] = useState(false)
|
||||||
const [keyValid, setKeyValid] = useState(false)
|
const [keyValid, setKeyValid] = useState(false)
|
||||||
const [scanning, setScanning] = useState(false)
|
const [scanning, setScanning] = useState(false)
|
||||||
|
const [scanMessage, setScanMessage] = useState('')
|
||||||
|
const scanAbortRef = useRef(null)
|
||||||
|
|
||||||
const current = STEPS[step]
|
const current = STEPS[step]
|
||||||
const layouts = getLayoutList()
|
const layouts = getLayoutList()
|
||||||
@@ -50,7 +52,7 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
case 'name': return answers.name.trim().length > 0
|
case 'name': return answers.name.trim().length > 0
|
||||||
case 'language': return !!answers.language
|
case 'language': return !!answers.language
|
||||||
case 'keyboard': return !!answers.keyboard
|
case 'keyboard': return !!answers.keyboard
|
||||||
case 'apikey': return true
|
case 'apikey': return keyValid && !scanning
|
||||||
case 'editor': return true
|
case 'editor': return true
|
||||||
case 'done': return true
|
case 'done': return true
|
||||||
default: return true
|
default: return true
|
||||||
@@ -61,14 +63,82 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
if (step > 0) setStep(step - 1)
|
if (step > 0) setStep(step - 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cycleOption = (key, list, dir) => {
|
||||||
|
const idx = list.findIndex(item => item.id === answers[key])
|
||||||
|
const next = (idx + dir + list.length) % list.length
|
||||||
|
setAnswers(a => ({ ...a, [key]: list[next].id }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const cycleOptionEditor = (dir) => {
|
||||||
|
const idx = editorList.findIndex(ed => ed === answers.editor)
|
||||||
|
const next = (idx + dir + editorList.length) % editorList.length
|
||||||
|
setAnswers(a => ({ ...a, editor: editorList[next] }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScanViaChat = async (apikey) => {
|
||||||
|
setScanning(true)
|
||||||
|
setScanMessage('Recherche des éditeurs sur votre système...')
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const detected = []
|
||||||
|
const fallback = async () => {
|
||||||
|
setScanMessage('Utilisation du scan local...')
|
||||||
|
const data = await api.getEditors()
|
||||||
|
return (data.editors || []).map(e => e.name)
|
||||||
|
}
|
||||||
|
const prompt = 'Liste tous les éditeurs de texte et IDE installés sur ce système. Exécute les commandes nécessaires pour les détecter (which, command -v, etc.). Réponds UNIQUEMENT avec les noms séparés par des virgules, sans aucune autre explication. Exemples: vim, nvim, code, emacs, nano, helix, subl, zed'
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
scanAbortRef.current = ctrl
|
||||||
|
const full = await api.sendChat(prompt, true, (text, data) => {
|
||||||
|
if (data.tool_call) setScanMessage('Exécution: ' + (data.tool_call.name || '...'))
|
||||||
|
else if (data.tool_result) setScanMessage('Analyse des résultats...')
|
||||||
|
else if (data.content) setScanMessage('Réception: ' + text.slice(0, 60) + (text.length > 60 ? '...' : ''))
|
||||||
|
}, ctrl.signal)
|
||||||
|
const names = full.split(/[,\n]/).map(s => s.replace(/[^a-zA-Z0-9._-]/g, '')).filter(Boolean)
|
||||||
|
if (names.length > 0) {
|
||||||
|
detected.push(...names)
|
||||||
|
} else {
|
||||||
|
detected.push(...(await fallback()))
|
||||||
|
}
|
||||||
|
setEditorList([...new Set(detected.map(n => n.toLowerCase()))])
|
||||||
|
setScanMessage('')
|
||||||
|
} catch (err) {
|
||||||
|
try {
|
||||||
|
setScanMessage('Fallback: scan local...')
|
||||||
|
const data = await api.getEditors()
|
||||||
|
const detected = (data.editors || []).map(e => e.name)
|
||||||
|
setEditorList([...new Set(detected)])
|
||||||
|
} catch {}
|
||||||
|
setScanMessage('')
|
||||||
|
}
|
||||||
|
setScanning(false)
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e) => {
|
const handler = (e) => {
|
||||||
if (e.key === 'Escape') { goPrev(); return }
|
if (e.key === 'Escape') { goPrev(); return }
|
||||||
|
if (current.key === 'language') {
|
||||||
|
if (e.key === 'Tab' || e.key === 'ArrowRight') { e.preventDefault(); cycleOption('language', LANGUAGES, 1); return }
|
||||||
|
if (e.key === 'ArrowLeft') { e.preventDefault(); cycleOption('language', LANGUAGES, -1); return }
|
||||||
|
}
|
||||||
|
if (current.key === 'keyboard') {
|
||||||
|
if (e.key === 'Tab' || e.key === 'ArrowRight') { e.preventDefault(); cycleOption('keyboard', layouts, 1); return }
|
||||||
|
if (e.key === 'ArrowLeft') { e.preventDefault(); cycleOption('keyboard', layouts, -1); return }
|
||||||
|
}
|
||||||
|
if (current.key === 'editor') {
|
||||||
|
if (e.key === 'Tab' || e.key === 'ArrowRight') { e.preventDefault(); cycleOptionEditor(1); return }
|
||||||
|
if (e.key === 'ArrowLeft') { e.preventDefault(); cycleOptionEditor(-1); return }
|
||||||
|
}
|
||||||
|
if (e.key === 'Tab') { e.preventDefault(); const input = document.querySelector('.onboarding-input'); if (input) input.focus(); return }
|
||||||
if (e.key === 'Enter' && current.key !== 'done' && current.key !== 'editor') { e.preventDefault(); goNext() }
|
if (e.key === 'Enter' && current.key !== 'done' && current.key !== 'editor') { e.preventDefault(); goNext() }
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', handler)
|
window.addEventListener('keydown', handler)
|
||||||
return () => window.removeEventListener('keydown', handler)
|
return () => window.removeEventListener('keydown', handler)
|
||||||
}, [step, current])
|
}, [step, current, answers, editorList])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => { if (scanAbortRef.current) scanAbortRef.current.abort() }
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (current.key === 'done' && !saving) {
|
if (current.key === 'done' && !saving) {
|
||||||
@@ -88,6 +158,14 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
base_url: 'https://api.minimax.io/v1',
|
base_url: 'https://api.minimax.io/v1',
|
||||||
})
|
})
|
||||||
setKeyValid(true)
|
setKeyValid(true)
|
||||||
|
await api.saveProvider({
|
||||||
|
name: 'minimax',
|
||||||
|
api_key: answers.apikey,
|
||||||
|
model: 'MiniMax-M2.7',
|
||||||
|
base_url: 'https://api.minimax.io/v1',
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
handleScanViaChat(answers.apikey)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || 'Clé invalide')
|
setError(err.message || 'Clé invalide')
|
||||||
setKeyValid(false)
|
setKeyValid(false)
|
||||||
@@ -95,22 +173,7 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
setValidating(false)
|
setValidating(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleScanEditors = async () => {
|
|
||||||
setScanning(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const data = await api.getEditors()
|
|
||||||
const detected = (data.editors || []).map(e => e.name)
|
|
||||||
const merged = [...new Set([...detected, ...BASE_EDITORS])]
|
|
||||||
setEditorList(merged)
|
|
||||||
if (detected.length === 0) {
|
|
||||||
setError('Aucun éditeur détecté')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message || 'Erreur lors du scan')
|
|
||||||
}
|
|
||||||
setScanning(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
@@ -154,9 +217,10 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="onboarding-progress">
|
<div className="onboarding-progress">
|
||||||
{STEPS.map((_, i) => (
|
{STEPS.filter(s => s.key !== 'done').map(s => {
|
||||||
<div key={i} className={`onboarding-dot ${i === step ? 'active' : ''} ${i < step ? 'done' : ''}`} />
|
const i = STEPS.indexOf(s)
|
||||||
))}
|
return <div key={s.key} className={`onboarding-dot ${i === step ? 'active' : ''} ${i < step ? 'done' : ''}`} />
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="onboarding-body">
|
<div className="onboarding-body">
|
||||||
@@ -221,7 +285,7 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
<div className="onboarding-step">
|
<div className="onboarding-step">
|
||||||
<div className="onboarding-title">Clé API MiniMax</div>
|
<div className="onboarding-title">Clé API MiniMax</div>
|
||||||
<div className="onboarding-desc">
|
<div className="onboarding-desc">
|
||||||
Entrez votre clé API MiniMax pour activer l'assistant IA. Vous pouvez passer cette étape et la configurer plus tard.
|
Entrez votre clé API MiniMax pour activer l'assistant IA. La clé est obligatoire pour continuer.
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
className="onboarding-input"
|
className="onboarding-input"
|
||||||
@@ -232,7 +296,14 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
{error && !keyValid && <div className="onboarding-required">{error}</div>}
|
{error && !keyValid && <div className="onboarding-required">{error}</div>}
|
||||||
{keyValid && <div className="onboarding-valid">Clé valide ✓</div>}
|
{keyValid && !scanning && <div className="onboarding-valid">Clé valide ✓ — Appuyez sur Entrée pour continuer</div>}
|
||||||
|
{scanning && (
|
||||||
|
<div className="onboarding-scanning">
|
||||||
|
<Loader size={14} className="spin-icon" />
|
||||||
|
<span>{scanMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{requiredError && <div className="onboarding-required">Veuillez valider votre clé API pour continuer</div>}
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
||||||
<button
|
<button
|
||||||
className="sm primary"
|
className="sm primary"
|
||||||
@@ -241,16 +312,9 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
>
|
>
|
||||||
{validating ? 'Validation...' : 'Valider la clé'}
|
{validating ? 'Validation...' : 'Valider la clé'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
className="sm ghost"
|
|
||||||
onClick={goNext}
|
|
||||||
disabled={!answers.apikey.trim()}
|
|
||||||
>
|
|
||||||
Passer
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{answers.apikey.trim() && !keyValid && !error && (
|
{!keyValid && !error && answers.apikey.trim() && (
|
||||||
<div className="onboarding-hint">Cliquez "Valider la clé" ou "Passer"</div>
|
<div className="onboarding-hint">Entrez votre clé puis cliquez "Valider la clé"</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -258,8 +322,10 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
{current.key === 'editor' && (
|
{current.key === 'editor' && (
|
||||||
<div className="onboarding-step">
|
<div className="onboarding-step">
|
||||||
<div className="onboarding-title">Quel éditeur utilisez-vous ?</div>
|
<div className="onboarding-title">Quel éditeur utilisez-vous ?</div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div className="onboarding-desc">
|
||||||
<div className="onboarding-chips" style={{ flex: 1 }}>
|
{scanning ? 'Détection en cours...' : 'Sélectionnez votre éditeur.'}
|
||||||
|
</div>
|
||||||
|
<div className="onboarding-chips">
|
||||||
{editorList.map(ed => (
|
{editorList.map(ed => (
|
||||||
<div
|
<div
|
||||||
key={ed}
|
key={ed}
|
||||||
@@ -270,25 +336,6 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
className="sm ghost"
|
|
||||||
onClick={handleScanEditors}
|
|
||||||
disabled={scanning}
|
|
||||||
title="Détecter les éditeurs installés"
|
|
||||||
style={{ marginLeft: 8, flexShrink: 0 }}
|
|
||||||
>
|
|
||||||
{scanning ? <Loader size={14} className="spin-icon" /> : <Search size={14} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
className="onboarding-input"
|
|
||||||
style={{ marginTop: 12 }}
|
|
||||||
placeholder="Autre éditeur..."
|
|
||||||
value={answers.editor}
|
|
||||||
onChange={e => setAnswers(a => ({ ...a, editor: e.target.value }))}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
{error && <div className="onboarding-required">{error}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -394,6 +441,10 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
.onboarding-hint {
|
.onboarding-hint {
|
||||||
font-size: 12px; color: var(--text-tertiary); margin-top: 4px;
|
font-size: 12px; color: var(--text-tertiary); margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
.onboarding-scanning {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
font-size: 13px; color: var(--accent); margin-top: 4px;
|
||||||
|
}
|
||||||
.spin-icon {
|
.spin-icon {
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,8 @@
|
|||||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||||
import { useI18n } from '../i18n'
|
import { useI18n } from '../i18n'
|
||||||
|
import mermaid from 'mermaid'
|
||||||
|
|
||||||
|
mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose', fontFamily: 'var(--font-mono)' })
|
||||||
|
|
||||||
const RANKS = {
|
const RANKS = {
|
||||||
commandant: { label: 'Commandant', short: 'CDT', color: '#FFD740' },
|
commandant: { label: 'Commandant', short: 'CDT', color: '#FFD740' },
|
||||||
@@ -47,23 +50,55 @@ function renderContent(text) {
|
|||||||
lastIndex = match.index + full.length
|
lastIndex = match.index + full.length
|
||||||
}
|
}
|
||||||
if (lastIndex < text.length) {
|
if (lastIndex < text.length) {
|
||||||
parts.push({ type: 'text', content: text.slice(lastIndex) })
|
const remaining = text.slice(lastIndex)
|
||||||
|
const openBlock = remaining.match(/```(\w*)\n?([\s\S]*)$/)
|
||||||
|
if (openBlock) {
|
||||||
|
if (openBlock.index > 0) {
|
||||||
|
parts.push({ type: 'text', content: remaining.slice(0, openBlock.index) })
|
||||||
|
}
|
||||||
|
parts.push({ type: 'code', lang: openBlock[1] || '', content: openBlock[2] || '' })
|
||||||
|
} else {
|
||||||
|
parts.push({ type: 'text', content: remaining })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return parts
|
return parts
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatText(text) {
|
function formatText(text) {
|
||||||
return text
|
let html = text
|
||||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
|
||||||
|
html = html.replace(/^(\|.+\|)\n(\|[\s\-:|]+\|)\n((?:\|.+\|\n?)+)/gm, (match, headerRow, sepRow, bodyRows) => {
|
||||||
|
const headers = headerRow.split('|').filter(c => c.trim() !== '').map(c => `<th>${c.trim()}</th>`).join('')
|
||||||
|
const rows = bodyRows.trim().split('\n').map(row => {
|
||||||
|
const cells = row.split('|').filter(c => c.trim() !== '').map(c => `<td>${c.trim()}</td>`).join('')
|
||||||
|
return `<tr>${cells}</tr>`
|
||||||
|
}).join('')
|
||||||
|
return `<table><thead><tr>${headers}</tr></thead><tbody>${rows}</tbody></table>`
|
||||||
|
})
|
||||||
|
|
||||||
|
html = html
|
||||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
|
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
|
||||||
.replace(/^### (.+)$/gm, '<h4 class="msg-h4">$1</h4>')
|
.replace(/^### (.+)$/gm, '<h4 class="msg-h4">$1</h4>')
|
||||||
.replace(/^## (.+)$/gm, '<h3 class="msg-h3">$1</h3>')
|
.replace(/^## (.+)$/gm, '<h3 class="msg-h3">$1</h3>')
|
||||||
.replace(/^\s*[-*] (.+)$/gm, '<span class="msg-bullet">$1</span>')
|
.replace(/^# (.+)$/gm, '<h2 class="msg-h2">$1</h2>')
|
||||||
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<span class="msg-step"><span class="msg-step-num">$1</span>$2</span>')
|
.replace(/^\s*[-*] (.+)$/gm, '<div class="msg-bullet">• $1</div>')
|
||||||
|
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<div class="msg-step"><span class="msg-step-num">$1</span> $2</div>')
|
||||||
|
.replace(/\n/g, '<br/>')
|
||||||
|
|
||||||
|
html = html
|
||||||
|
.replace(/<br\/>\s*<br\/>/g, '<br/>')
|
||||||
|
.replace(/<br\/>\s*(<h[234]|<div class="msg-|<table)/g, '$1')
|
||||||
|
.replace(/(<\/h[234]|<\/div>|<\/table>)\s*<br\/>/g, '$1')
|
||||||
|
.replace(/\s+on\w+=["'][^"']*["']/gi, '')
|
||||||
|
.replace(/javascript:/gi, '')
|
||||||
|
.replace(/data:/gi, '')
|
||||||
|
|
||||||
|
return html
|
||||||
}
|
}
|
||||||
|
|
||||||
function ThinkingBlock({ content, done }) {
|
function ThinkingBlock({ content, done, raw }) {
|
||||||
return (
|
return (
|
||||||
<div className={`feed-thinking-block ${done ? 'done' : 'active'}`}>
|
<div className={`feed-thinking-block ${done ? 'done' : 'active'}`}>
|
||||||
<div className="feed-thinking-header">
|
<div className="feed-thinking-header">
|
||||||
@@ -73,7 +108,9 @@ function ThinkingBlock({ content, done }) {
|
|||||||
<span>Reflexion</span>
|
<span>Reflexion</span>
|
||||||
{!done && <span className="feed-thinking-dots"><span/><span/><span/></span>}
|
{!done && <span className="feed-thinking-dots"><span/><span/><span/></span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="feed-thinking-content">{content}</div>
|
<div className="feed-thinking-content">
|
||||||
|
{raw ? <span dangerouslySetInnerHTML={{ __html: content }} /> : content}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -143,19 +180,80 @@ function ToolCallBlock({ call, result }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mermaidIdCounter = 0
|
||||||
|
|
||||||
|
function MermaidBlock({ code }) {
|
||||||
|
const ref = useRef(null)
|
||||||
|
const [svg, setSvg] = useState('')
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
const id = `studio-mermaid-${++mermaidIdCounter}`
|
||||||
|
mermaid.render(id, code).then(({ svg }) => {
|
||||||
|
if (!cancelled) setSvg(svg)
|
||||||
|
}).catch(() => {
|
||||||
|
if (!cancelled) setError(true)
|
||||||
|
})
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [code])
|
||||||
|
|
||||||
|
if (error) return <pre className="studio-mermaid-error">{code}</pre>
|
||||||
|
if (!svg) return <div className="studio-mermaid-loading">Chargement...</div>
|
||||||
|
return <div className="studio-mermaid-container" ref={ref} dangerouslySetInnerHTML={{ __html: svg }} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodeBlockWithCopy({ part, index, copiedIdx, setCopiedIdx }) {
|
||||||
|
if (part.lang === 'mermaid') {
|
||||||
|
return (
|
||||||
|
<div className="studio-code-block">
|
||||||
|
<div className="studio-code-header">
|
||||||
|
<span className="studio-code-lang">mermaid</span>
|
||||||
|
<button className={`studio-copy-btn ${copiedIdx === index ? 'copied' : ''}`} onClick={() => {
|
||||||
|
navigator.clipboard.writeText(part.content)
|
||||||
|
setCopiedIdx(index)
|
||||||
|
setTimeout(() => setCopiedIdx(null), 1500)
|
||||||
|
}}>
|
||||||
|
{copiedIdx === index ? 'Copie!' : 'Copier'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<MermaidBlock code={part.content} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="studio-code-block">
|
||||||
|
<div className="studio-code-header">
|
||||||
|
{part.lang && <span className="studio-code-lang">{part.lang}</span>}
|
||||||
|
<button className={`studio-copy-btn ${copiedIdx === index ? 'copied' : ''}`} onClick={() => {
|
||||||
|
navigator.clipboard.writeText(part.content)
|
||||||
|
setCopiedIdx(index)
|
||||||
|
setTimeout(() => setCopiedIdx(null), 1500)
|
||||||
|
}}>
|
||||||
|
{copiedIdx === index ? 'Copie!' : 'Copier'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre><code>{part.content}</code></pre>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function FeedItem({ msg }) {
|
function FeedItem({ msg }) {
|
||||||
const isUser = msg.role === 'user'
|
const isUser = msg.role === 'user'
|
||||||
const isSystem = msg.role === 'system'
|
const isSystem = msg.role === 'system'
|
||||||
const rank = getRank(msg.role)
|
const rank = getRank(msg.role)
|
||||||
|
const [copiedIdx, setCopiedIdx] = useState(null)
|
||||||
|
|
||||||
const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''
|
const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''
|
||||||
|
|
||||||
let parsedToolCalls = null
|
let parsedToolCalls = null
|
||||||
|
let parsedToolResults = null
|
||||||
let displayContent = msg.content
|
let displayContent = msg.content
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(msg.content)
|
const parsed = JSON.parse(msg.content)
|
||||||
if (parsed && Array.isArray(parsed.tool_calls)) {
|
if (parsed && Array.isArray(parsed.tool_calls)) {
|
||||||
parsedToolCalls = parsed.tool_calls
|
parsedToolCalls = parsed.tool_calls
|
||||||
|
parsedToolResults = parsed.tool_results || null
|
||||||
displayContent = parsed.content || ''
|
displayContent = parsed.content || ''
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -170,7 +268,7 @@ function FeedItem({ msg }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanContent = displayContent.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
let cleanContent = displayContent.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`feed-item ${msg.role}`}>
|
<div className={`feed-item ${msg.role}`}>
|
||||||
@@ -185,18 +283,21 @@ function FeedItem({ msg }) {
|
|||||||
<span className="feed-role">{rank.label}</span>
|
<span className="feed-role">{rank.label}</span>
|
||||||
{timeStr && <span className="feed-time">{timeStr}</span>}
|
{timeStr && <span className="feed-time">{timeStr}</span>}
|
||||||
</div>
|
</div>
|
||||||
{msg.thinking && <ThinkingBlock content={msg.thinking} done />}
|
{msg.thinking && <ThinkingBlock content={formatText(msg.thinking)} done raw />}
|
||||||
{parsedToolCalls && parsedToolCalls.map((tc, i) => (
|
{parsedToolCalls && parsedToolCalls.map((tc, i) => {
|
||||||
<ToolCallBlock key={tc.tool_call_id || i} call={tc} result={null} />
|
const resultData = parsedToolResults
|
||||||
))}
|
? parsedToolResults.find(r => r.tool_call_id === tc.tool_call_id)
|
||||||
|
: null
|
||||||
|
const result = resultData
|
||||||
|
? { content: resultData.result, is_error: resultData.is_error }
|
||||||
|
: null
|
||||||
|
return <ToolCallBlock key={tc.tool_call_id || i} call={tc} result={result} />
|
||||||
|
})}
|
||||||
{cleanContent && (
|
{cleanContent && (
|
||||||
<div className="feed-content">
|
<div className="feed-content">
|
||||||
{renderContent(cleanContent).map((part, i) =>
|
{renderContent(cleanContent).map((part, i) =>
|
||||||
part.type === 'code' ? (
|
part.type === 'code' ? (
|
||||||
<div key={i} className="studio-code-block">
|
<CodeBlockWithCopy key={i} part={part} index={i} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
|
||||||
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
|
|
||||||
<pre><code>{part.content}</code></pre>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||||
)
|
)
|
||||||
@@ -212,6 +313,17 @@ function StreamingItem({ content, thinking, toolCalls }) {
|
|||||||
const rank = RANKS.general
|
const rank = RANKS.general
|
||||||
const cleanContent = content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
const cleanContent = content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||||||
const hasToolCalls = toolCalls && toolCalls.length > 0
|
const hasToolCalls = toolCalls && toolCalls.length > 0
|
||||||
|
const [copiedIdx, setCopiedIdx] = useState(null)
|
||||||
|
|
||||||
|
const renderedContent = useMemo(() => {
|
||||||
|
if (!cleanContent) return []
|
||||||
|
return renderContent(cleanContent)
|
||||||
|
}, [cleanContent])
|
||||||
|
|
||||||
|
const formattedThinking = useMemo(() => {
|
||||||
|
if (!thinking) return ''
|
||||||
|
return formatText(thinking)
|
||||||
|
}, [thinking])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="feed-item assistant">
|
<div className="feed-item assistant">
|
||||||
@@ -225,7 +337,7 @@ function StreamingItem({ content, thinking, toolCalls }) {
|
|||||||
</span>
|
</span>
|
||||||
<span className="feed-role">{rank.label}</span>
|
<span className="feed-role">{rank.label}</span>
|
||||||
</div>
|
</div>
|
||||||
{thinking && <ThinkingBlock content={thinking} done={false} />}
|
{thinking && <ThinkingBlock content={formattedThinking} raw done={false} />}
|
||||||
{hasToolCalls && toolCalls.map((tc, i) => (
|
{hasToolCalls && toolCalls.map((tc, i) => (
|
||||||
<ToolCallBlock key={tc.call?.tool_call_id || i} call={tc.call} result={tc.result} />
|
<ToolCallBlock key={tc.call?.tool_call_id || i} call={tc.call} result={tc.result} />
|
||||||
))}
|
))}
|
||||||
@@ -236,12 +348,9 @@ function StreamingItem({ content, thinking, toolCalls }) {
|
|||||||
)}
|
)}
|
||||||
{cleanContent && (
|
{cleanContent && (
|
||||||
<div className="feed-content">
|
<div className="feed-content">
|
||||||
{renderContent(cleanContent).map((part, i) =>
|
{renderedContent.map((part, i) =>
|
||||||
part.type === 'code' ? (
|
part.type === 'code' ? (
|
||||||
<div key={i} className="studio-code-block">
|
<CodeBlockWithCopy key={i} part={part} index={i} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
|
||||||
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
|
|
||||||
<pre><code>{part.content}</code></pre>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||||
)
|
)
|
||||||
@@ -263,8 +372,14 @@ export default function Studio({ api }) {
|
|||||||
const [streamThinking, setStreamThinking] = useState('')
|
const [streamThinking, setStreamThinking] = useState('')
|
||||||
const [streamToolCalls, setStreamToolCalls] = useState([])
|
const [streamToolCalls, setStreamToolCalls] = useState([])
|
||||||
const [loaded, setLoaded] = useState(false)
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
const [tokenInfo, setTokenInfo] = useState({ used: 0, max: 100000, summarizeAt: 80000 })
|
||||||
|
const [contextCollapsed, setContextCollapsed] = useState(false)
|
||||||
|
const [messagesCollapsed, setMessagesCollapsed] = useState(false)
|
||||||
|
const [sudoModal, setSudoModal] = useState(null)
|
||||||
const messagesEnd = useRef(null)
|
const messagesEnd = useRef(null)
|
||||||
|
const feedRef = useRef(null)
|
||||||
const textareaRef = useRef(null)
|
const textareaRef = useRef(null)
|
||||||
|
const abortRef = useRef(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getChatHistory().then(data => {
|
api.getChatHistory().then(data => {
|
||||||
@@ -275,6 +390,11 @@ export default function Studio({ api }) {
|
|||||||
{ id: 'welcome', role: 'system', content: t('studio.welcomeNew'), time: new Date().toISOString() },
|
{ id: 'welcome', role: 'system', content: t('studio.welcomeNew'), time: new Date().toISOString() },
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
setTokenInfo({
|
||||||
|
used: data.tokens || 0,
|
||||||
|
max: data.max_tokens || 100000,
|
||||||
|
summarizeAt: data.summarize_at || 80000,
|
||||||
|
})
|
||||||
setLoaded(true)
|
setLoaded(true)
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
setMessages([
|
setMessages([
|
||||||
@@ -288,6 +408,20 @@ export default function Studio({ api }) {
|
|||||||
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
|
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
}, [messages, streaming, streamThinking, streamToolCalls])
|
}, [messages, streaming, streamThinking, streamToolCalls])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onTab = (e) => {
|
||||||
|
if (e.key !== 'Tab') return
|
||||||
|
if (document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA') return
|
||||||
|
const feed = document.querySelector('.studio-feed-layout')
|
||||||
|
if (!feed?.closest('.tab-hidden')) {
|
||||||
|
e.preventDefault()
|
||||||
|
textareaRef.current?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onTab)
|
||||||
|
return () => window.removeEventListener('keydown', onTab)
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (textareaRef.current) {
|
if (textareaRef.current) {
|
||||||
textareaRef.current.style.height = 'auto'
|
textareaRef.current.style.height = 'auto'
|
||||||
@@ -295,6 +429,34 @@ export default function Studio({ api }) {
|
|||||||
}
|
}
|
||||||
}, [input])
|
}, [input])
|
||||||
|
|
||||||
|
const refreshTokens = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.getChatHistory()
|
||||||
|
setTokenInfo({
|
||||||
|
used: data.tokens || 0,
|
||||||
|
max: data.max_tokens || 100000,
|
||||||
|
summarizeAt: data.summarize_at || 80000,
|
||||||
|
})
|
||||||
|
} catch {}
|
||||||
|
}, [api])
|
||||||
|
|
||||||
|
const handleSummarize = useCallback(async () => {
|
||||||
|
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'system', content: 'Résumé de la conversation en cours...', time: new Date().toISOString() }])
|
||||||
|
setContextCollapsed('animating')
|
||||||
|
try {
|
||||||
|
const data = await api.summarizeChat()
|
||||||
|
setTokenInfo(prev => ({ ...prev, used: data.tokens || 0 }))
|
||||||
|
setTimeout(() => {
|
||||||
|
setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: 'system', content: '✓ Conversation résumée automatiquement. Le contexte a été compressé.', time: new Date().toISOString(), compressed: true }])
|
||||||
|
setContextCollapsed(true)
|
||||||
|
setMessagesCollapsed(true)
|
||||||
|
}, 600)
|
||||||
|
} catch (err) {
|
||||||
|
setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: 'system', content: `Erreur de résumé: ${err.message}`, time: new Date().toISOString() }])
|
||||||
|
setContextCollapsed(false)
|
||||||
|
}
|
||||||
|
}, [api])
|
||||||
|
|
||||||
const handleClear = useCallback(async () => {
|
const handleClear = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await api.clearChat()
|
await api.clearChat()
|
||||||
@@ -309,11 +471,72 @@ export default function Studio({ api }) {
|
|||||||
const text = input.trim()
|
const text = input.trim()
|
||||||
setInput('')
|
setInput('')
|
||||||
|
|
||||||
|
const isSlashCommand = (t) => /^\/(clear|help|summarize|model(?:\s+\S+)?)$/.test(t)
|
||||||
|
|
||||||
|
if (text.startsWith('/') && !isSlashCommand(text)) {
|
||||||
|
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'user', content: text, time: new Date().toISOString() }])
|
||||||
|
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Commande inconnue. Tapez `/help` pour la liste des commandes.', time: new Date().toISOString() }])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (text === '/clear') {
|
if (text === '/clear') {
|
||||||
handleClear()
|
handleClear()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (text === '/help') {
|
||||||
|
const helpMsg = [
|
||||||
|
'## Commandes Studio',
|
||||||
|
'',
|
||||||
|
'- `/clear` - Effacer la conversation',
|
||||||
|
'- `/summarize` - Résumer la conversation précédente',
|
||||||
|
'- `/help` - Afficher cette aide',
|
||||||
|
'- `/model` - Afficher le provider et modèle actifs',
|
||||||
|
'- `/model change` - Basculer entre MiniMax et MiMo',
|
||||||
|
].join('\n')
|
||||||
|
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: helpMsg, time: new Date().toISOString() }])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text === '/summarize') {
|
||||||
|
handleSummarize()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text === '/model' || text === '/model change') {
|
||||||
|
if (text === '/model change') {
|
||||||
|
api.getProviders().then(data => {
|
||||||
|
const providers = data.providers || []
|
||||||
|
const minimax = providers.find(p => p.name.toUpperCase() === 'MINIMAX')
|
||||||
|
const mimo = providers.find(p => p.name.toUpperCase() === 'MIMO')
|
||||||
|
if (!minimax || !mimo) {
|
||||||
|
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'MiniMax et MiMo doivent être configurés pour utiliser `/model change`.', time: new Date().toISOString() }])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const active = providers.find(p => p.active)
|
||||||
|
const activeName = active ? active.name.toUpperCase() : ''
|
||||||
|
const switchTo = activeName === 'MINIMAX' ? 'MIMO' : 'MINIMAX'
|
||||||
|
const target = switchTo === 'MINIMAX' ? minimax : mimo
|
||||||
|
api.saveProvider({ name: target.name, active: true }).then(() => {
|
||||||
|
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: `✓ Provider changé: **${target.name}** (${target.model})`, time: new Date().toISOString() }])
|
||||||
|
}).catch(() => {
|
||||||
|
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur lors du changement de provider.', time: new Date().toISOString() }])
|
||||||
|
})
|
||||||
|
}).catch(() => {
|
||||||
|
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur: impossible de récupérer les providers', time: new Date().toISOString() }])
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
api.getProviders().then(data => {
|
||||||
|
const active = data.providers?.find(p => p.active)
|
||||||
|
const modelMsg = active ? `**${active.name}** — ${active.model}` : 'Aucun provider actif configuré'
|
||||||
|
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: modelMsg, time: new Date().toISOString() }])
|
||||||
|
}).catch(() => {
|
||||||
|
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur: impossible de récupérer les providers', time: new Date().toISOString() }])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const userMsg = { id: Date.now().toString(), role: 'user', content: text, time: new Date().toISOString() }
|
const userMsg = { id: Date.now().toString(), role: 'user', content: text, time: new Date().toISOString() }
|
||||||
setMessages(prev => [...prev, userMsg])
|
setMessages(prev => [...prev, userMsg])
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -321,6 +544,9 @@ export default function Studio({ api }) {
|
|||||||
setStreamThinking('')
|
setStreamThinking('')
|
||||||
setStreamToolCalls([])
|
setStreamToolCalls([])
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
abortRef.current = controller
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let accumulated = ''
|
let accumulated = ''
|
||||||
let thinking = ''
|
let thinking = ''
|
||||||
@@ -337,9 +563,14 @@ export default function Studio({ api }) {
|
|||||||
if (event && event.tool_call) {
|
if (event && event.tool_call) {
|
||||||
toolCalls = [...toolCalls, { call: event.tool_call, result: null }]
|
toolCalls = [...toolCalls, { call: event.tool_call, result: null }]
|
||||||
setStreamToolCalls([...toolCalls])
|
setStreamToolCalls([...toolCalls])
|
||||||
|
accumulated = ''
|
||||||
|
setStreaming('')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event && event.tool_result) {
|
if (event && event.tool_result) {
|
||||||
|
if (event.tool_result.sudo_blocked) {
|
||||||
|
setSudoModal({ command: event.tool_result.command || event.tool_result.content })
|
||||||
|
}
|
||||||
const idx = toolCalls.findIndex(tc => tc.call && tc.call.tool_call_id === event.tool_result.tool_call_id)
|
const idx = toolCalls.findIndex(tc => tc.call && tc.call.tool_call_id === event.tool_result.tool_call_id)
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
toolCalls[idx] = { ...toolCalls[idx], result: event.tool_result }
|
toolCalls[idx] = { ...toolCalls[idx], result: event.tool_result }
|
||||||
@@ -349,7 +580,7 @@ export default function Studio({ api }) {
|
|||||||
}
|
}
|
||||||
accumulated = partial
|
accumulated = partial
|
||||||
setStreaming(partial)
|
setStreaming(partial)
|
||||||
})
|
}, controller.signal)
|
||||||
|
|
||||||
const finalContent = accumulated || t('studio.noResponse')
|
const finalContent = accumulated || t('studio.noResponse')
|
||||||
const aiMsg = {
|
const aiMsg = {
|
||||||
@@ -363,29 +594,109 @@ export default function Studio({ api }) {
|
|||||||
aiMsg.content = JSON.stringify({
|
aiMsg.content = JSON.stringify({
|
||||||
content: finalContent,
|
content: finalContent,
|
||||||
tool_calls: toolCalls.map(tc => tc.call),
|
tool_calls: toolCalls.map(tc => tc.call),
|
||||||
|
tool_results: toolCalls.map(tc => ({
|
||||||
|
tool_call_id: tc.call?.tool_call_id,
|
||||||
|
result: tc.result?.content || '',
|
||||||
|
is_error: tc.result?.is_error || false,
|
||||||
|
})),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
setMessages(prev => [...prev, aiMsg])
|
setMessages(prev => [...prev, aiMsg])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err.name === 'AbortError') {
|
||||||
|
if (streaming) {
|
||||||
|
setMessages(prev => [...prev, {
|
||||||
|
id: (Date.now() + 1).toString(),
|
||||||
|
role: 'system',
|
||||||
|
content: t('studio.cancelled'),
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
setMessages(prev => [...prev, {
|
setMessages(prev => [...prev, {
|
||||||
id: (Date.now() + 1).toString(),
|
id: (Date.now() + 1).toString(),
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content: `${t('studio.error')}: ${err.message}`,
|
content: `${t('studio.error')}: ${err.message}`,
|
||||||
time: new Date().toISOString(),
|
time: new Date().toISOString(),
|
||||||
}])
|
}])
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setStreaming('')
|
setStreaming('')
|
||||||
setStreamThinking('')
|
setStreamThinking('')
|
||||||
setStreamToolCalls([])
|
setStreamToolCalls([])
|
||||||
|
abortRef.current = null
|
||||||
|
refreshTokens()
|
||||||
}
|
}
|
||||||
}, [input, loading, api, t, handleClear])
|
}, [input, loading, api, t, handleClear, streaming, refreshTokens, handleSummarize])
|
||||||
|
|
||||||
|
const handleStop = useCallback(() => {
|
||||||
|
if (abortRef.current) {
|
||||||
|
abortRef.current.abort()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const COMMANDS = ['/clear', '/summarize', '/help', '/model', '/model change']
|
||||||
|
|
||||||
const handleKeyDown = (e) => {
|
const handleKeyDown = (e) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleSend()
|
handleSend()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
e.preventDefault()
|
||||||
|
const ta = textareaRef.current
|
||||||
|
if (!ta) return
|
||||||
|
if (document.activeElement !== ta) {
|
||||||
|
ta.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const val = ta.value
|
||||||
|
const pos = ta.selectionStart
|
||||||
|
const before = val.slice(0, pos)
|
||||||
|
const afterSlash = before.match(/\/[\w ]*$/)
|
||||||
|
if (afterSlash) {
|
||||||
|
const partial = afterSlash[0]
|
||||||
|
const matches = COMMANDS.filter(c => c.startsWith(partial) && c !== partial)
|
||||||
|
if (matches.length === 1) {
|
||||||
|
const completed = matches[0] + ' '
|
||||||
|
const newText = val.slice(0, pos - afterSlash[0].length) + completed + val.slice(pos)
|
||||||
|
setInput(newText)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
ta.selectionStart = ta.selectionEnd = pos - afterSlash[0].length + completed.length
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleCollapsed = useCallback(() => {
|
||||||
|
setMessagesCollapsed(prev => !prev)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const renderMessages = () => {
|
||||||
|
if (messagesCollapsed && messages.length > 4) {
|
||||||
|
const visibleCount = 4
|
||||||
|
const hiddenCount = messages.length - visibleCount
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{messages.slice(0, visibleCount).map(msg => (
|
||||||
|
<FeedItem key={msg.id} msg={msg} />
|
||||||
|
))}
|
||||||
|
<div className="feed-collapsed-messages" onClick={handleToggleCollapsed}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
|
||||||
|
</svg>
|
||||||
|
<span className="feed-collapsed-text">{hiddenCount} messages antérieurs compressés</span>
|
||||||
|
<span className="feed-collapsed-count">clic pour développer</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return messages.map(msg => (
|
||||||
|
<FeedItem key={msg.id} msg={msg} />
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!loaded) {
|
if (!loaded) {
|
||||||
@@ -402,17 +713,43 @@ export default function Studio({ api }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="studio-feed-layout">
|
<div className="studio-feed-layout">
|
||||||
<div className="studio-feed">
|
<div className="studio-feed-scroll-wrap">
|
||||||
{messages.map(msg => (
|
<div className="studio-feed" ref={feedRef}>
|
||||||
<FeedItem key={msg.id} msg={msg} />
|
{renderMessages()}
|
||||||
))}
|
|
||||||
{(streaming || streamThinking || loading || streamToolCalls.length > 0) && (
|
{(streaming || streamThinking || loading || streamToolCalls.length > 0) && (
|
||||||
<StreamingItem content={streaming} thinking={streamThinking} toolCalls={streamToolCalls} />
|
<StreamingItem content={streaming} thinking={streamThinking} toolCalls={streamToolCalls} />
|
||||||
)}
|
)}
|
||||||
<div ref={messagesEnd} />
|
<div ref={messagesEnd} style={{ height: '24px' }} />
|
||||||
|
</div>
|
||||||
|
<div className="studio-scroll-btns">
|
||||||
|
<button className="studio-scroll-btn" onClick={() => feedRef.current?.scrollTo({ top: 0, behavior: 'smooth' })} title="Remonter">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 15l-6-6-6 6"/></svg>
|
||||||
|
</button>
|
||||||
|
<button className="studio-scroll-btn" onClick={() => messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })} title="Descendre">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="studio-input-area">
|
<div className="studio-input-area">
|
||||||
|
<div className={`studio-token-bar ${contextCollapsed === true ? 'compressed' : ''}`}>
|
||||||
|
<div className={`studio-token-track ${contextCollapsed === true ? 'compressed' : ''}`}>
|
||||||
|
<div
|
||||||
|
className={`studio-token-fill ${tokenInfo.used >= tokenInfo.summarizeAt ? 'warn' : ''} ${contextCollapsed === true ? 'compressed' : ''} ${contextCollapsed === 'animating' ? 'animating' : ''}`}
|
||||||
|
style={{ width: `${Math.min(100, (tokenInfo.used / tokenInfo.max) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className={`studio-token-text ${contextCollapsed === true ? 'compressed' : ''}`}>
|
||||||
|
{(tokenInfo.used / 1000).toFixed(1)}k / {(tokenInfo.max / 1000).toFixed(0)}k tokens
|
||||||
|
{contextCollapsed === true && ' · compressé'}
|
||||||
|
{tokenInfo.used >= tokenInfo.summarizeAt && contextCollapsed !== true && ' · résumé auto.'}
|
||||||
|
</span>
|
||||||
|
{contextCollapsed === true && (
|
||||||
|
<button className="ghost sm" onClick={handleToggleCollapsed} style={{ marginLeft: '8px', fontSize: '10px' }}>
|
||||||
|
voir plus
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="studio-input-row">
|
<div className="studio-input-row">
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
@@ -432,11 +769,34 @@ export default function Studio({ api }) {
|
|||||||
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
{loading && (
|
||||||
|
<button className="studio-stop-btn" onClick={handleStop} title={t('studio.stop')}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<rect x="4" y="4" width="16" height="16" rx="2"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="studio-input-hint">
|
<div className="studio-input-hint">
|
||||||
{t('studio.inputHint')} · /clear
|
{t('studio.inputHint')} · /clear /summarize /help /model
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{sudoModal && (
|
||||||
|
<div className="shell-modal-overlay" onClick={() => setSudoModal(null)}>
|
||||||
|
<div className="shell-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="shell-modal-header">Commande bloquée</div>
|
||||||
|
<div className="shell-modal-body">
|
||||||
|
<p style={{ color: 'var(--accent-bright)', fontWeight: 600, marginBottom: 8 }}>L'IA a tenté d'exécuter une commande nécessitant des privilèges administrateur :</p>
|
||||||
|
<pre style={{ background: 'var(--bg)', padding: '10px 12px', borderRadius: 'var(--radius)', fontSize: 12, overflow: 'auto', fontFamily: 'var(--font-mono)' }}>{sudoModal.command}</pre>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', fontSize: 12, marginTop: 12 }}>La commande a été bloquée. L'IA en a été informée et cherchera une alternative.</p>
|
||||||
|
</div>
|
||||||
|
<div className="shell-modal-footer">
|
||||||
|
<button className="primary" onClick={() => setSudoModal(null)}>Compris</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ const en = {
|
|||||||
switchWindow: 'Switch window',
|
switchWindow: 'Switch window',
|
||||||
sendMessage: 'Send message',
|
sendMessage: 'Send message',
|
||||||
newLine: 'New line',
|
newLine: 'New line',
|
||||||
|
copy: 'Copy',
|
||||||
|
paste: 'Paste',
|
||||||
|
search: 'Search',
|
||||||
|
zoom: 'Zoom +/−',
|
||||||
|
switchTab: 'Switch tab',
|
||||||
|
nextTab: 'Next tab',
|
||||||
runCommand: 'Run command',
|
runCommand: 'Run command',
|
||||||
commandHistory: 'Command history',
|
commandHistory: 'Command history',
|
||||||
},
|
},
|
||||||
@@ -90,6 +96,8 @@ const en = {
|
|||||||
you: 'You',
|
you: 'You',
|
||||||
mentioned: 'mentioned',
|
mentioned: 'mentioned',
|
||||||
cleared: 'Conversation cleared.',
|
cleared: 'Conversation cleared.',
|
||||||
|
cancelled: 'Request cancelled.',
|
||||||
|
stop: 'Stop',
|
||||||
},
|
},
|
||||||
|
|
||||||
shell: {
|
shell: {
|
||||||
@@ -180,6 +188,8 @@ const en = {
|
|||||||
installed: 'Installed',
|
installed: 'Installed',
|
||||||
missing: 'Missing',
|
missing: 'Missing',
|
||||||
editProfile: 'Edit',
|
editProfile: 'Edit',
|
||||||
|
profileInfo: 'Personal Info',
|
||||||
|
profilePrefs: 'Preferences',
|
||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
editProvider: 'Configure',
|
editProvider: 'Configure',
|
||||||
validateKey: 'Validate',
|
validateKey: 'Validate',
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ const fr = {
|
|||||||
switchWindow: 'Changer de fen\u00eatre',
|
switchWindow: 'Changer de fen\u00eatre',
|
||||||
sendMessage: 'Envoyer le message',
|
sendMessage: 'Envoyer le message',
|
||||||
newLine: 'Nouvelle ligne',
|
newLine: 'Nouvelle ligne',
|
||||||
|
copy: 'Copier',
|
||||||
|
paste: 'Coller',
|
||||||
|
search: 'Rechercher',
|
||||||
|
zoom: 'Zoom +/\u2212',
|
||||||
|
switchTab: 'Changer d\u2019onglet',
|
||||||
|
nextTab: 'Onglet suivant',
|
||||||
runCommand: 'Ex\u00e9cuter',
|
runCommand: 'Ex\u00e9cuter',
|
||||||
commandHistory: 'Historique',
|
commandHistory: 'Historique',
|
||||||
},
|
},
|
||||||
@@ -90,6 +96,8 @@ const fr = {
|
|||||||
you: 'Vous',
|
you: 'Vous',
|
||||||
mentioned: 'mentionn\u00e9',
|
mentioned: 'mentionn\u00e9',
|
||||||
cleared: 'Conversation effac\u00e9e.',
|
cleared: 'Conversation effac\u00e9e.',
|
||||||
|
cancelled: 'Requ\u00eate annul\u00e9e.',
|
||||||
|
stop: 'Stop',
|
||||||
},
|
},
|
||||||
|
|
||||||
shell: {
|
shell: {
|
||||||
@@ -134,7 +142,7 @@ const fr = {
|
|||||||
terminal: 'Terminal',
|
terminal: 'Terminal',
|
||||||
updates: 'Mises \u00e0 jour',
|
updates: 'Mises \u00e0 jour',
|
||||||
locale: 'Langue & Clavier',
|
locale: 'Langue & Clavier',
|
||||||
skills: 'Comp\u00e9ENCES',
|
skills: 'Compétences',
|
||||||
system: 'Syst\u00e8me',
|
system: 'Syst\u00e8me',
|
||||||
},
|
},
|
||||||
profile: 'Profil',
|
profile: 'Profil',
|
||||||
@@ -158,7 +166,7 @@ const fr = {
|
|||||||
save: 'Enregistrer',
|
save: 'Enregistrer',
|
||||||
saved: 'Enregistr\u00e9 !',
|
saved: 'Enregistr\u00e9 !',
|
||||||
error: 'Erreur',
|
error: 'Erreur',
|
||||||
skills: 'Comp\u00e9ENCES',
|
skills: 'Compétences',
|
||||||
noSkills: 'Aucune comp\u00e9tence install\u00e9e.',
|
noSkills: 'Aucune comp\u00e9tence install\u00e9e.',
|
||||||
runSkillsInit: 'Ex\u00e9cutez muyue skills init',
|
runSkillsInit: 'Ex\u00e9cutez muyue skills init',
|
||||||
language: 'Langue',
|
language: 'Langue',
|
||||||
@@ -180,6 +188,8 @@ const fr = {
|
|||||||
installed: 'Install\u00e9',
|
installed: 'Install\u00e9',
|
||||||
missing: 'Manquant',
|
missing: 'Manquant',
|
||||||
editProfile: 'Modifier',
|
editProfile: 'Modifier',
|
||||||
|
profileInfo: 'Informations personnelles',
|
||||||
|
profilePrefs: 'Préférences',
|
||||||
editProvider: 'Configurer',
|
editProvider: 'Configurer',
|
||||||
validateKey: 'Valider',
|
validateKey: 'Valider',
|
||||||
validating: 'V\u00e9rification...',
|
validating: 'V\u00e9rification...',
|
||||||
|
|||||||
@@ -154,7 +154,9 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
|
|
||||||
.header-clock { font-family: var(--font-mono); font-size: 12px; color: var(--accent); font-weight: 600; }
|
.header-clock { font-family: var(--font-mono); font-size: 12px; color: var(--accent); font-weight: 600; }
|
||||||
|
|
||||||
.content { flex: 1; overflow: hidden; }
|
.content { flex: 1; overflow: hidden; position: relative; }
|
||||||
|
.content > div { position: absolute; inset: 0; overflow: hidden; }
|
||||||
|
.tab-hidden { display: none; }
|
||||||
|
|
||||||
.statusbar {
|
.statusbar {
|
||||||
height: 28px;
|
height: 28px;
|
||||||
@@ -169,6 +171,12 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
color: var(--text-disabled);
|
color: var(--text-disabled);
|
||||||
}
|
}
|
||||||
.statusbar-left, .statusbar-right { display: flex; align-items: center; gap: 12px; }
|
.statusbar-left, .statusbar-right { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.statusbar-sudo {
|
||||||
|
font-size: 10px; font-weight: 700; font-family: var(--font-mono);
|
||||||
|
padding: 1px 6px; border-radius: 3px;
|
||||||
|
background: rgba(239, 68, 68, 0.15); color: #ef4444; border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
.statusbar-shortcut { display: inline-flex; align-items: center; gap: 4px; }
|
.statusbar-shortcut { display: inline-flex; align-items: center; gap: 4px; }
|
||||||
.statusbar-shortcut kbd {
|
.statusbar-shortcut kbd {
|
||||||
display: inline-block; padding: 1px 5px; border-radius: 3px;
|
display: inline-block; padding: 1px 5px; border-radius: 3px;
|
||||||
@@ -268,8 +276,8 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.sidebar-tab:hover { background: var(--bg-card); color: var(--text-primary); }
|
.sidebar-tab:hover { background: var(--bg-card); color: var(--text-primary); }
|
||||||
.sidebar-tab.active { background: var(--accent); color: #fff; font-weight: 600; }
|
.sidebar-tab.active { background: var(--accent); color: #fff; font-weight: 600; }
|
||||||
|
|
||||||
.shell-layout { display: flex; height: 100%; }
|
.shell-layout { display: flex; height: 100%; overflow: hidden; }
|
||||||
.shell-terminal-col { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; }
|
.shell-terminal-col { flex: 1; display: flex; flex-direction: column; min-width: 0; overflow: hidden; }
|
||||||
|
|
||||||
.shell-tabs-bar {
|
.shell-tabs-bar {
|
||||||
display: flex; align-items: center; background: var(--bg-surface);
|
display: flex; align-items: center; background: var(--bg-surface);
|
||||||
@@ -321,6 +329,14 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
|
|
||||||
.shell-tab-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
|
.shell-tab-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.shell-zoom-badge {
|
||||||
|
font-size: 10px; font-family: var(--font-mono); font-weight: 600;
|
||||||
|
color: var(--accent); background: var(--accent-bg);
|
||||||
|
padding: 2px 6px; border-radius: 3px;
|
||||||
|
border: 1px solid var(--accent-dim);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.shell-new-tab-wrapper { position: relative; }
|
.shell-new-tab-wrapper { position: relative; }
|
||||||
.shell-new-tab-btn {
|
.shell-new-tab-btn {
|
||||||
display: flex; align-items: center; gap: 2px;
|
display: flex; align-items: center; gap: 2px;
|
||||||
@@ -374,23 +390,79 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
}
|
}
|
||||||
.shell-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; }
|
.shell-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; }
|
||||||
|
|
||||||
.shell-xterm-wrapper { flex: 1; background: var(--bg); overflow: hidden; position: relative; }
|
.shell-xterm-wrapper { flex: 1; min-height: 0; background: var(--bg); overflow: hidden; position: relative; }
|
||||||
.shell-xterm-instance {
|
|
||||||
position: absolute; inset: 0; padding: 4px;
|
.shell-search-bar {
|
||||||
display: block !important;
|
position: absolute; top: 8px; right: 12px; z-index: 20;
|
||||||
|
display: flex; align-items: center; gap: 4px;
|
||||||
|
background: var(--bg-elevated); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); padding: 4px 6px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||||
}
|
}
|
||||||
.shell-xterm-instance .xterm { height: 100%; padding: 4px; }
|
.shell-search-icon { color: var(--text-tertiary); flex-shrink: 0; }
|
||||||
|
.shell-search-input {
|
||||||
|
width: 200px; font-size: 12px; padding: 3px 6px; border-radius: 4px;
|
||||||
|
background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border);
|
||||||
|
font-family: var(--font-mono); outline: none;
|
||||||
|
}
|
||||||
|
.shell-search-input:focus { border-color: var(--accent); }
|
||||||
|
.shell-search-nav {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 24px; height: 24px; border-radius: 4px;
|
||||||
|
background: transparent; border: 1px solid var(--border);
|
||||||
|
color: var(--text-tertiary); cursor: pointer; font-size: 12px;
|
||||||
|
padding: 0; transition: all 0.1s;
|
||||||
|
}
|
||||||
|
.shell-search-nav:hover { background: var(--bg-hover); color: var(--text-primary); border-color: var(--accent-dark); }
|
||||||
|
.shell-search-close {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 24px; height: 24px; border-radius: 4px;
|
||||||
|
background: transparent; border: none;
|
||||||
|
color: var(--text-disabled); cursor: pointer; padding: 0;
|
||||||
|
}
|
||||||
|
.shell-search-close:hover { color: var(--accent); }
|
||||||
|
.shell-xterm-instance {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.shell-xterm-instance.active {
|
||||||
|
visibility: visible;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.shell-xterm-instance .xterm { height: 100%; }
|
||||||
|
|
||||||
.connection-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
.connection-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
||||||
.connection-dot.on { background: var(--success); box-shadow: 0 0 6px var(--success); }
|
.connection-dot.on { background: var(--success); box-shadow: 0 0 6px var(--success); }
|
||||||
.connection-dot.off { background: var(--error); }
|
.connection-dot.off { background: var(--error); }
|
||||||
|
|
||||||
.shell-ai-col { width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; }
|
.shell-tab.ai-tab .shell-tab-name { color: var(--accent); }
|
||||||
.ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); }
|
.shell-tab.ai-tab { border-bottom-color: var(--accent); }
|
||||||
|
|
||||||
|
.shell-ai-col { width: 320px; max-width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; overflow: hidden; }
|
||||||
|
.ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.shell-analyze-btn {
|
||||||
|
display: flex; align-items: center; gap: 4px;
|
||||||
|
padding: 4px 10px; border-radius: var(--radius);
|
||||||
|
background: transparent; border: 1px solid var(--accent-dim);
|
||||||
|
color: var(--accent); font-size: 11px; font-weight: 600;
|
||||||
|
cursor: pointer; transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.shell-analyze-btn:hover:not(:disabled) { background: var(--accent-bg); }
|
||||||
|
.shell-analyze-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.shell-ai-token-bar { display: flex; align-items: center; gap: 8px; padding: 6px 12px; border-bottom: 1px solid var(--border); }
|
||||||
|
.shell-ai-token-track { flex: 1; height: 3px; background: var(--bg-input); border-radius: 2px; overflow: hidden; }
|
||||||
|
.shell-ai-token-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.4s, background 0.3s; }
|
||||||
|
.shell-ai-token-fill.warn { background: var(--warning); }
|
||||||
|
.shell-ai-token-text { font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); white-space: nowrap; }
|
||||||
.ai-panel-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
|
.ai-panel-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
|
||||||
.ai-message { padding: 8px 12px; border-radius: var(--radius); font-size: 13px; line-height: 1.5; word-break: break-word; }
|
.ai-message { padding: 8px 12px; border-radius: var(--radius); font-size: 13px; line-height: 1.5; word-break: break-word; }
|
||||||
.ai-message.ai { background: var(--bg-card); border-left: 3px solid var(--accent); }
|
.ai-message.user { background: var(--bg-elevated); border-left: 3px solid var(--accent-bright); color: var(--text-primary); }
|
||||||
.ai-message.user { background: var(--accent-bg); border-left: 3px solid var(--accent-muted); }
|
.ai-message.user.analysis { border-left-color: var(--info); background: color-mix(in srgb, var(--info) 8%, var(--bg-elevated)); }
|
||||||
|
.ai-message.assistant { background: var(--bg-card); border-left: 3px solid var(--accent); }
|
||||||
|
.ai-message.system { background: var(--bg-elevated); border-left: 3px solid var(--info); font-style: italic; color: var(--text-tertiary); font-size: 12px; }
|
||||||
|
.ai-message.assistant { background: var(--bg-card); border-left: 3px solid var(--accent); }
|
||||||
.ai-message.thinking { background: var(--bg-elevated); border-left: 3px solid var(--info); font-style: italic; color: var(--text-tertiary); }
|
.ai-message.thinking { background: var(--bg-elevated); border-left: 3px solid var(--info); font-style: italic; color: var(--text-tertiary); }
|
||||||
.ai-message.tool { background: var(--bg-elevated); border-left: 3px solid var(--warning); }
|
.ai-message.tool { background: var(--bg-elevated); border-left: 3px solid var(--warning); }
|
||||||
.ai-message.tool .tool-name { font-weight: 700; color: var(--warning); }
|
.ai-message.tool .tool-name { font-weight: 700; color: var(--warning); }
|
||||||
@@ -398,6 +470,63 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.ai-panel-input { display: flex; gap: 6px; padding: 10px 12px; border-top: 1px solid var(--border); }
|
.ai-panel-input { display: flex; gap: 6px; padding: 10px 12px; border-top: 1px solid var(--border); }
|
||||||
.ai-panel-input input { flex: 1; font-size: 13px; padding: 6px 10px; }
|
.ai-panel-input input { flex: 1; font-size: 13px; padding: 6px 10px; }
|
||||||
|
|
||||||
|
.shell-code-block {
|
||||||
|
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
margin: 8px 0 4px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.shell-code-block pre {
|
||||||
|
padding: 10px 12px; font-family: var(--font-mono); font-size: 12px; line-height: 1.5;
|
||||||
|
overflow-x: auto; color: var(--text-primary); margin: 0;
|
||||||
|
}
|
||||||
|
.shell-code-lang {
|
||||||
|
padding: 3px 10px; font-size: 10px; font-weight: 600; color: var(--text-tertiary);
|
||||||
|
background: var(--bg-surface); border-bottom: 1px solid var(--border);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.shell-code-actions {
|
||||||
|
display: flex; border-top: 1px solid var(--border); background: var(--bg-surface);
|
||||||
|
}
|
||||||
|
.shell-code-actions button {
|
||||||
|
flex: 1; display: flex; align-items: center; justify-content: center; gap: 4px;
|
||||||
|
padding: 5px 0; background: transparent; border: none; border-right: 1px solid var(--border);
|
||||||
|
color: var(--text-tertiary); font-size: 11px; cursor: pointer; transition: all 0.1s;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
.shell-code-actions button:last-child { border-right: none; }
|
||||||
|
.shell-code-actions button:hover { background: var(--accent-bg); color: var(--accent); }
|
||||||
|
.shell-code-actions button.copied { background: var(--accent-bg); color: var(--accent); animation: copy-flash 0.3s ease; }
|
||||||
|
|
||||||
|
.shell-mermaid-container { padding: 12px; background: var(--bg); overflow-x: auto; display: flex; justify-content: center; }
|
||||||
|
.shell-mermaid-container svg { max-width: 100%; height: auto; }
|
||||||
|
.shell-mermaid-loading { padding: 16px; text-align: center; color: var(--text-tertiary); font-size: 12px; }
|
||||||
|
.shell-mermaid-error { padding: 10px 12px; color: var(--accent-bright); font-family: var(--font-mono); font-size: 12px; white-space: pre-wrap; }
|
||||||
|
|
||||||
|
.ai-message table { width: 100%; border-collapse: collapse; margin: 6px 0; font-size: 12px; }
|
||||||
|
.ai-message th { background: var(--bg-surface); padding: 6px 10px; text-align: left; font-weight: 600; border: 1px solid var(--border); color: var(--text-secondary); }
|
||||||
|
.ai-message td { padding: 5px 10px; border: 1px solid var(--border); color: var(--text-primary); }
|
||||||
|
.ai-message tr:nth-child(even) td { background: var(--bg-surface); }
|
||||||
|
|
||||||
|
@keyframes copy-flash {
|
||||||
|
0% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.05); background: color-mix(in srgb, var(--accent) 20%, transparent); }
|
||||||
|
100% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-analysis-modal {
|
||||||
|
background: var(--bg-elevated); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg); width: 720px; max-width: 90vw; max-height: 80vh;
|
||||||
|
display: flex; flex-direction: column; overflow: hidden;
|
||||||
|
}
|
||||||
|
.shell-analysis-modal-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 14px 20px; border-bottom: 1px solid var(--border);
|
||||||
|
font-weight: 700; font-size: 15px; color: var(--accent);
|
||||||
|
}
|
||||||
|
.shell-analysis-modal-body {
|
||||||
|
flex: 1; overflow-y: auto; padding: 20px; font-size: 14px; line-height: 1.5;
|
||||||
|
color: var(--text-primary); word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.shell-modal-overlay {
|
.shell-modal-overlay {
|
||||||
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
||||||
display: flex; align-items: center; justify-content: center; z-index: 1000;
|
display: flex; align-items: center; justify-content: center; z-index: 1000;
|
||||||
@@ -423,12 +552,16 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.config-window { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
.config-window { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||||
|
|
||||||
.config-tabs-bar {
|
.config-tabs-bar {
|
||||||
display: flex; gap: 4px; padding: 12px 20px 0; background: var(--bg-surface);
|
display: flex; gap: 4px; padding: 12px 20px; background: var(--bg-surface);
|
||||||
border-bottom: 1px solid var(--border); flex-shrink: 0;
|
border-bottom: 1px solid var(--border); flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-panel-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
.config-panel-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
.config-panel-body { flex: 1; overflow-y: auto; padding: 16px 28px 28px; }
|
.config-panel-body { flex: 1; overflow-y: auto; padding: 16px 28px 28px; }
|
||||||
|
.config-profile-center {
|
||||||
|
max-width: 540px; margin: 0 auto; width: 100%;
|
||||||
|
display: flex; flex-direction: column; gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.config-card {
|
.config-card {
|
||||||
background: var(--bg-card); border: 1px solid var(--border);
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
@@ -470,6 +603,9 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.provider-card-name { font-weight: 600; color: var(--text-primary); font-size: 14px; }
|
.provider-card-name { font-weight: 600; color: var(--text-primary); font-size: 14px; }
|
||||||
.provider-card-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
.provider-card-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||||
.provider-card-meta { display: flex; gap: 16px; font-size: 12px; color: var(--text-tertiary); font-family: var(--font-mono); margin-top: 8px; }
|
.provider-card-meta { display: flex; gap: 16px; font-size: 12px; color: var(--text-tertiary); font-family: var(--font-mono); margin-top: 8px; }
|
||||||
|
.provider-card-model { display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border); }
|
||||||
|
.provider-card-model-label { font-size: 11px; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.provider-card-model-value { font-size: 14px; font-weight: 600; font-family: var(--font-mono); color: var(--accent); }
|
||||||
.provider-card-form { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
|
.provider-card-form { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
|
||||||
|
|
||||||
.provider-setup-hint {
|
.provider-setup-hint {
|
||||||
@@ -494,10 +630,24 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.config-update-name { color: var(--text-primary); font-weight: 600; font-size: 13px; min-width: 100px; }
|
.config-update-name { color: var(--text-primary); font-weight: 600; font-size: 13px; min-width: 100px; }
|
||||||
.config-update-versions { color: var(--text-tertiary); font-size: 12px; font-family: var(--font-mono); }
|
.config-update-versions { color: var(--text-tertiary); font-size: 12px; font-family: var(--font-mono); }
|
||||||
|
|
||||||
.config-skill-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
|
.skill-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
|
||||||
.config-skill-row:last-child { border-bottom: none; }
|
.skill-tile { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px; cursor: pointer; transition: border-color 0.15s; }
|
||||||
.config-skill-name { color: var(--text-primary); font-weight: 600; min-width: 120px; }
|
.skill-tile:hover { border-color: var(--accent-dim); }
|
||||||
.config-skill-desc { color: var(--text-tertiary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.skill-tile-name { font-weight: 600; color: var(--text-primary); font-size: 14px; margin-bottom: 6px; }
|
||||||
|
.skill-tile-desc { font-size: 12px; color: var(--text-tertiary); overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||||
|
.skill-tile-tags { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
|
||||||
|
.skill-detail-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 50; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.skill-detail-panel { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius-lg); width: 90%; max-width: 600px; max-height: 80vh; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
.skill-detail-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--border); }
|
||||||
|
.skill-detail-name { font-weight: 600; font-size: 16px; color: var(--text-primary); }
|
||||||
|
.skill-detail-body { flex: 1; overflow-y: auto; padding: 20px; }
|
||||||
|
.skill-detail-section { margin-bottom: 16px; }
|
||||||
|
.skill-detail-label { font-size: 11px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
|
||||||
|
.skill-detail-meta { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.skill-detail-content { font-family: var(--font-mono); font-size: 12px; color: var(--text-secondary); white-space: pre-wrap; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; line-height: 1.6; max-height: 300px; overflow-y: auto; }
|
||||||
|
.skill-detail-deps { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.skill-detail-dep { font-size: 12px; color: var(--text-tertiary); display: flex; align-items: center; gap: 8px; }
|
||||||
|
.skill-detail-dep .badge { font-size: 10px; }
|
||||||
.chip-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
.chip-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
|
||||||
.config-toast {
|
.config-toast {
|
||||||
@@ -525,10 +675,170 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
|
|
||||||
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; color: var(--text-disabled); font-size: 13px; text-align: center; gap: 8px; }
|
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; color: var(--text-disabled); font-size: 13px; text-align: center; gap: 8px; }
|
||||||
|
|
||||||
|
/* ── Dashboard Grid ── */
|
||||||
|
.dash-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
grid-template-rows: repeat(2, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.dash-card {
|
||||||
|
position: relative;
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg); padding: 14px 16px;
|
||||||
|
display: flex; flex-direction: column; justify-content: center; gap: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-span-2 { grid-column: span 2; }
|
||||||
|
.dash-card-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.dash-label {
|
||||||
|
font-size: 11px; font-weight: 700; color: var(--accent);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.dash-count {
|
||||||
|
font-size: 10px; font-family: var(--font-mono);
|
||||||
|
background: var(--bg-input); padding: 1px 6px; border-radius: 10px;
|
||||||
|
}
|
||||||
|
.dash-count.warn { background: var(--accent-bg); color: var(--accent); }
|
||||||
|
|
||||||
|
/* Tools row */
|
||||||
|
.dash-tools-row {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 6px;
|
||||||
|
}
|
||||||
|
.dash-tool-tag {
|
||||||
|
font-size: 11px; font-family: var(--font-mono);
|
||||||
|
padding: 3px 8px; border-radius: var(--radius);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
}
|
||||||
|
.dash-tool-tag.ok { color: var(--success); }
|
||||||
|
.dash-tool-tag.missing { color: var(--error); }
|
||||||
|
|
||||||
|
/* Quota */
|
||||||
|
.dash-quota-list { display: flex; flex-direction: column; gap: 6px; max-height: 270px; overflow-y: auto; }
|
||||||
|
.dash-quota-row { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.dash-quota-name {
|
||||||
|
font-size: 11px; font-weight: 600; color: var(--text-primary);
|
||||||
|
min-width: 80px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.dash-bar {
|
||||||
|
flex: 1; height: 4px; background: var(--bg-input); border-radius: 2px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.dash-bar-fill {
|
||||||
|
height: 100%; background: var(--accent); border-radius: 2px;
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
.dash-quota-val {
|
||||||
|
font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Consumption */
|
||||||
|
.dash-consumption-list { display: flex; flex-direction: column; gap: 10px; max-height: 270px; overflow-y: auto; }
|
||||||
|
.dash-consumption-provider { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.dash-consumption-head { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.dash-consumption-name {
|
||||||
|
font-size: 11px; font-weight: 700; letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.dash-consumption-total {
|
||||||
|
font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
.dash-consumption-days {
|
||||||
|
display: flex; gap: 4px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.dash-consumption-day {
|
||||||
|
font-size: 9px; font-family: var(--font-mono); color: var(--text-tertiary);
|
||||||
|
background: var(--bg-input); padding: 1px 5px; border-radius: 4px;
|
||||||
|
}
|
||||||
|
.dash-consumption-day strong { color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* Processes */
|
||||||
|
.dash-proc-list { display: flex; flex-direction: column; gap: 4px; max-height: 270px; overflow-y: auto; }
|
||||||
|
.dash-proc-row {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.dash-proc-name {
|
||||||
|
font-size: 11px; font-weight: 600; color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.dash-proc-res {
|
||||||
|
font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Commands */
|
||||||
|
.dash-cmd-card .dash-cmd-list { max-height: 220px; }
|
||||||
|
.dash-cmd-list { display: flex; flex-direction: column; gap: 2px; overflow-y: auto; }
|
||||||
|
.dash-cmd-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||||
|
padding: 5px 8px; border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-surface); cursor: pointer;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
.dash-cmd-row:hover { background: var(--accent-bg); }
|
||||||
|
.dash-cmd-left { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
||||||
|
.dash-cmd-text {
|
||||||
|
font-size: 11px; font-family: var(--font-mono); color: var(--text-primary);
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.dash-cmd-time { font-size: 9px; color: var(--text-disabled); }
|
||||||
|
.dash-cmd-copy { font-size: 13px; color: var(--text-disabled); flex-shrink: 0; }
|
||||||
|
.dash-cmd-row:hover .dash-cmd-copy { color: var(--accent); }
|
||||||
|
|
||||||
|
.dash-cmd-freq { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid var(--border); }
|
||||||
|
.dash-cmd-freq-title { font-size: 10px; font-weight: 700; text-transform: uppercase; color: var(--text-disabled); letter-spacing: 0.05em; margin-bottom: 2px; }
|
||||||
|
.dash-cmd-freq-row {
|
||||||
|
display: flex; align-items: center; gap: 8px; cursor: pointer;
|
||||||
|
padding: 3px 4px; border-radius: var(--radius-sm);
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
.dash-cmd-freq-row:hover { background: var(--accent-bg); }
|
||||||
|
.dash-cmd-freq-name { font-size: 12px; font-weight: 600; font-family: var(--font-mono); color: var(--text-primary); width: 100px; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.dash-cmd-freq-bar-wrap { flex: 1; height: 6px; background: var(--bg-input); border-radius: 3px; overflow: hidden; }
|
||||||
|
.dash-cmd-freq-bar { height: 100%; background: var(--accent); border-radius: 3px; transition: width 0.3s ease; }
|
||||||
|
.dash-cmd-freq-count { font-size: 10px; font-family: var(--font-mono); color: var(--accent); width: 28px; text-align: right; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.dash-cmd-top { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
|
||||||
|
|
||||||
|
/* Services */
|
||||||
|
.dash-services { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.dash-svc-row {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.dash-svc-name { font-size: 12px; font-weight: 600; color: var(--text-primary); }
|
||||||
|
.dash-svc-val { font-size: 11px; font-family: var(--font-mono); color: var(--text-tertiary); }
|
||||||
|
.dash-svc-issues { margin-top: 4px; }
|
||||||
|
.dash-svc-issue { font-size: 10px; color: var(--warning); padding: 2px 0; }
|
||||||
|
|
||||||
|
/* Updates */
|
||||||
|
.dash-updates-list { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.dash-update-row {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
}
|
||||||
|
.dash-update-name { font-size: 12px; font-weight: 600; color: var(--text-primary); }
|
||||||
|
.dash-update-ver { font-size: 11px; font-family: var(--font-mono); color: var(--text-tertiary); }
|
||||||
|
|
||||||
|
.dash-empty { font-size: 11px; color: var(--text-disabled); }
|
||||||
|
|
||||||
|
/* Graph */
|
||||||
|
.dash-graph-wrap { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.dash-graph-header { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.dash-graph-label { font-size: 9px; color: var(--text-disabled); text-transform: uppercase; }
|
||||||
|
.dash-graph-value { font-size: 10px; font-family: var(--font-mono); font-weight: 600; }
|
||||||
|
.dash-graph-svg { width: 100%; height: 32px; }
|
||||||
|
.dash-graph-empty { font-size: 10px; color: var(--text-disabled); text-align: center; padding: 8px 0; }
|
||||||
|
|
||||||
|
/* Legacy dashboard kept for reference */
|
||||||
.dashboard-layout { display: flex; flex-direction: column; height: 100%; }
|
.dashboard-layout { display: flex; flex-direction: column; height: 100%; }
|
||||||
.dashboard-content { flex: 1; overflow-y: auto; }
|
.dashboard-content { flex: 1; overflow-y: auto; }
|
||||||
.dashboard-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; padding: 24px; }
|
.dashboard-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; padding: 24px; }
|
||||||
|
|
||||||
.dashboard-section {
|
.dashboard-section {
|
||||||
background: var(--bg-card); border: 1px solid var(--border);
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg); padding: 20px; transition: border-color 0.2s;
|
border-radius: var(--radius-lg); padding: 20px; transition: border-color 0.2s;
|
||||||
@@ -540,11 +850,8 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
font-size: 12px; font-weight: 700; color: var(--accent); text-transform: uppercase;
|
font-size: 12px; font-weight: 700; color: var(--accent); text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-workflows-inline { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
.dashboard-workflows-inline { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||||
|
|
||||||
.dashboard-notifications-inline { display: flex; flex-direction: column; gap: 2px; }
|
.dashboard-notifications-inline { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
|
||||||
.dashboard-notifications { padding: 0; }
|
.dashboard-notifications { padding: 0; }
|
||||||
.notif-row {
|
.notif-row {
|
||||||
display: flex; align-items: flex-start; gap: 12px;
|
display: flex; align-items: flex-start; gap: 12px;
|
||||||
@@ -557,7 +864,6 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.notif-ok .notif-text { color: var(--success); }
|
.notif-ok .notif-text { color: var(--success); }
|
||||||
.notif-warn .notif-text { color: var(--warning); }
|
.notif-warn .notif-text { color: var(--warning); }
|
||||||
.notif-error .notif-text { color: var(--error); }
|
.notif-error .notif-text { color: var(--error); }
|
||||||
|
|
||||||
.dashboard-workflows { padding: 0; display: flex; flex-direction: column; gap: 24px; }
|
.dashboard-workflows { padding: 0; display: flex; flex-direction: column; gap: 24px; }
|
||||||
.workflow-section { }
|
.workflow-section { }
|
||||||
.section-label {
|
.section-label {
|
||||||
@@ -565,81 +871,6 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
letter-spacing: 1px; margin-bottom: 12px; padding-bottom: 6px; border-bottom: 1px solid var(--border);
|
letter-spacing: 1px; margin-bottom: 12px; padding-bottom: 6px; border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Dashboard Tabs ── */
|
|
||||||
.dashboard-tabs {
|
|
||||||
display: flex; gap: 4px; padding: 12px 20px 0;
|
|
||||||
border-bottom: 1px solid var(--border); background: var(--bg-surface); flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.dashboard-tab {
|
|
||||||
padding: 8px 16px; border-radius: var(--radius) var(--radius) 0 0;
|
|
||||||
border: 1px solid transparent; border-bottom: none; background: transparent;
|
|
||||||
color: var(--text-tertiary); font-size: 12px; font-weight: 600; cursor: pointer;
|
|
||||||
display: flex; align-items: center; gap: 6px; transition: all 0.15s;
|
|
||||||
}
|
|
||||||
.dashboard-tab:hover { color: var(--text-primary); background: var(--bg-hover); }
|
|
||||||
.dashboard-tab.active { background: var(--bg-card); color: var(--accent); border-color: var(--border); }
|
|
||||||
.dashboard-tab .tab-icon { font-size: 14px; }
|
|
||||||
.dashboard-tab .tab-count {
|
|
||||||
background: var(--bg-input); padding: 1px 6px; border-radius: 10px; font-size: 10px; font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
.dashboard-tab .tab-count.warn { background: var(--accent-bg); color: var(--accent); }
|
|
||||||
|
|
||||||
.dashboard-tools-panel { padding: 20px 24px; }
|
|
||||||
.dashboard-tools-stats { display: flex; gap: 12px; font-size: 12px; }
|
|
||||||
.stat-ok { color: var(--success); font-family: var(--font-mono); }
|
|
||||||
.stat-missing { color: var(--error); font-family: var(--font-mono); }
|
|
||||||
|
|
||||||
.dashboard-system-info { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; font-size: 12px; color: var(--text-tertiary); }
|
|
||||||
.sys-info-item { font-family: var(--font-mono); }
|
|
||||||
.sys-info-sep { color: var(--text-disabled); }
|
|
||||||
|
|
||||||
.tools-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; margin-top: 8px; }
|
|
||||||
.tool-card {
|
|
||||||
background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg);
|
|
||||||
padding: 14px 16px; display: flex; align-items: center; gap: 12px; transition: border-color 0.2s;
|
|
||||||
}
|
|
||||||
.tool-card:hover { border-color: var(--accent-dim); }
|
|
||||||
.tool-card.installed { border-left: 3px solid var(--success); }
|
|
||||||
.tool-card.missing { border-left: 3px solid var(--error); }
|
|
||||||
.tool-card-icon { font-size: 20px; flex-shrink: 0; }
|
|
||||||
.tool-card-info { flex: 1; min-width: 0; }
|
|
||||||
.tool-card-name { font-weight: 600; font-size: 13px; color: var(--text-primary); margin-bottom: 2px; }
|
|
||||||
.tool-card-version { font-size: 11px; color: var(--text-tertiary); display: flex; align-items: center; gap: 6px; }
|
|
||||||
.tool-version-text { font-family: var(--font-mono); font-size: 10px; color: var(--text-disabled); }
|
|
||||||
.status-ok { color: var(--success); }
|
|
||||||
.status-missing { color: var(--error); }
|
|
||||||
.tool-card-actions { flex-shrink: 0; display: flex; align-items: center; gap: 6px; }
|
|
||||||
.tool-update-badge { background: var(--accent-bg); color: var(--accent); font-size: 10px; font-family: var(--font-mono); padding: 2px 6px; border-radius: 4px; cursor: pointer; }
|
|
||||||
.tool-update-badge:hover { background: var(--accent-dim); }
|
|
||||||
|
|
||||||
.dashboard-activity-panel { padding: 20px 24px; }
|
|
||||||
.activity-log { display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
.notif-icon { font-size: 12px; width: 16px; text-align: center; }
|
|
||||||
|
|
||||||
.dashboard-actions-panel { padding: 20px 24px; }
|
|
||||||
.quick-actions-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; margin-bottom: 24px; }
|
|
||||||
.quick-action-btn {
|
|
||||||
background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg);
|
|
||||||
padding: 16px 20px; display: flex; align-items: center; gap: 12px; cursor: pointer;
|
|
||||||
transition: all 0.2s; font-size: 13px; color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
.quick-action-btn:hover:not(:disabled) { border-color: var(--accent-dim); background: var(--bg-hover); color: var(--text-primary); }
|
|
||||||
.quick-action-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
||||||
.quick-action-icon { font-size: 18px; }
|
|
||||||
.quick-action-label { font-weight: 600; }
|
|
||||||
|
|
||||||
.dashboard-updates-section { margin-top: 16px; }
|
|
||||||
.updates-list { display: flex; flex-direction: column; gap: 6px; }
|
|
||||||
.update-row {
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
padding: 10px 14px; border-radius: var(--radius); background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.update-row:hover { border-color: var(--accent-dim); }
|
|
||||||
.update-info { display: flex; align-items: center; gap: 16px; }
|
|
||||||
.update-name { font-weight: 600; color: var(--text-primary); font-size: 13px; min-width: 100px; }
|
|
||||||
.update-versions { color: var(--text-tertiary); font-size: 12px; font-family: var(--font-mono); }
|
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
display: flex; align-items: center; justify-content: space-between; padding: 10px 16px;
|
display: flex; align-items: center; justify-content: space-between; padding: 10px 16px;
|
||||||
border-bottom: 1px solid var(--border); background: var(--bg-surface);
|
border-bottom: 1px solid var(--border); background: var(--bg-surface);
|
||||||
@@ -654,7 +885,17 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
|
|
||||||
/* ── Studio Feed ── */
|
/* ── Studio Feed ── */
|
||||||
.studio-feed-layout { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
.studio-feed-layout { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||||
.studio-feed { flex: 1; overflow-y: auto; padding: 20px 24px; display: flex; flex-direction: column; gap: 4px; }
|
.studio-feed-scroll-wrap { flex: 1; position: relative; overflow: hidden; }
|
||||||
|
.studio-feed { height: 100%; overflow-y: auto; padding: 20px 24px; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.studio-scroll-btns { position: absolute; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 4px; z-index: 10; }
|
||||||
|
.studio-scroll-btn {
|
||||||
|
width: 32px; height: 32px; border-radius: 50%; padding: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
color: var(--text-tertiary); cursor: pointer; transition: all 0.15s;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.studio-scroll-btn:hover { background: var(--accent-bg); color: var(--accent); border-color: var(--accent-dim); opacity: 1; }
|
||||||
.feed-loading { display: flex; align-items: center; justify-content: center; padding: 60px 0; }
|
.feed-loading { display: flex; align-items: center; justify-content: center; padding: 60px 0; }
|
||||||
.feed-item { display: flex; gap: 10px; padding: 8px 12px; border-radius: var(--radius); animation: fadeIn 0.15s ease-out; }
|
.feed-item { display: flex; gap: 10px; padding: 8px 12px; border-radius: var(--radius); animation: fadeIn 0.15s ease-out; }
|
||||||
.feed-item:hover { background: var(--bg-card); }
|
.feed-item:hover { background: var(--bg-card); }
|
||||||
@@ -676,14 +917,28 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
}
|
}
|
||||||
.feed-role { font-size: 11px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; }
|
.feed-role { font-size: 11px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
.feed-time { font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono); }
|
.feed-time { font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono); }
|
||||||
.feed-content { font-size: 14px; line-height: 1.7; color: var(--text-primary); word-break: break-word; }
|
.feed-content { font-size: 14px; line-height: 1.5; color: var(--text-primary); word-break: break-word; }
|
||||||
.feed-system-badge { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-dim); flex-shrink: 0; }
|
.feed-system-badge { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-dim); flex-shrink: 0; }
|
||||||
.feed-system-text { font-size: 12px; color: var(--text-tertiary); font-style: italic; flex: 1; }
|
.feed-system-text { font-size: 12px; color: var(--text-tertiary); font-style: italic; flex: 1; }
|
||||||
|
.feed-system-text.compressed { color: var(--accent); font-style: normal; }
|
||||||
|
.feed-compressed-indicator {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 10px 12px; margin: 4px 0;
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.feed-compressed-indicator:hover { background: var(--bg-hover); border-color: var(--accent-dim); }
|
||||||
|
.feed-compressed-indicator svg { color: var(--accent); flex-shrink: 0; }
|
||||||
|
.feed-compressed-text { font-size: 12px; color: var(--text-tertiary); flex: 1; }
|
||||||
|
.feed-compressed-count { font-size: 11px; color: var(--text-disabled); font-family: var(--font-mono); }
|
||||||
|
|
||||||
.feed-thinking-block {
|
.feed-thinking-block {
|
||||||
background: var(--bg-surface); border: 1px solid var(--border); border-left: 2px solid var(--accent-dim);
|
background: var(--bg-surface); border: 1px solid var(--border); border-left: 2px solid var(--accent-dim);
|
||||||
border-radius: var(--radius); margin: 6px 0 8px; overflow: hidden;
|
border-radius: var(--radius); margin: 6px 0 8px; overflow: hidden;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
.feed-thinking-block.active {
|
.feed-thinking-block.active {
|
||||||
border-left-color: var(--warning);
|
border-left-color: var(--warning);
|
||||||
@@ -716,17 +971,39 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
overflow: hidden; margin: 8px 0;
|
overflow: hidden; margin: 8px 0;
|
||||||
}
|
}
|
||||||
|
.studio-code-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
background: var(--bg-surface); border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
.studio-code-block pre { padding: 12px 16px; font-family: var(--font-mono); font-size: 13px; line-height: 1.5; overflow-x: auto; color: var(--text-primary); margin: 0; }
|
.studio-code-block pre { padding: 12px 16px; font-family: var(--font-mono); font-size: 13px; line-height: 1.5; overflow-x: auto; color: var(--text-primary); margin: 0; }
|
||||||
.studio-code-lang {
|
.studio-code-lang {
|
||||||
padding: 4px 12px; font-size: 11px; font-weight: 600; color: var(--text-tertiary);
|
padding: 4px 12px; font-size: 11px; font-weight: 600; color: var(--text-tertiary);
|
||||||
background: var(--bg-surface); border-bottom: 1px solid var(--border); text-transform: uppercase; letter-spacing: 0.5px;
|
background: var(--bg-surface); text-transform: uppercase; letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
.studio-copy-btn {
|
||||||
|
padding: 3px 10px; font-size: 10px; font-weight: 600; color: var(--text-tertiary);
|
||||||
|
background: transparent; border: none; border-left: 1px solid var(--border);
|
||||||
|
cursor: pointer; transition: all 0.15s; font-family: var(--font-sans);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.studio-copy-btn:hover { background: var(--accent-bg); color: var(--accent); }
|
||||||
|
.studio-copy-btn.copied { background: var(--accent-bg); color: var(--accent); }
|
||||||
|
|
||||||
|
.studio-mermaid-container { padding: 12px; background: var(--bg); overflow-x: auto; display: flex; justify-content: center; }
|
||||||
|
.studio-mermaid-container svg { max-width: 100%; height: auto; }
|
||||||
|
.studio-mermaid-loading { padding: 12px; text-align: center; color: var(--text-tertiary); font-size: 12px; }
|
||||||
|
.studio-mermaid-error { padding: 10px 12px; color: var(--accent-bright); font-family: var(--font-mono); font-size: 12px; white-space: pre-wrap; }
|
||||||
|
|
||||||
|
.feed-content table { width: 100%; border-collapse: collapse; margin: 8px 0; font-size: 13px; }
|
||||||
|
.feed-content th { background: var(--bg-surface); padding: 6px 12px; text-align: left; font-weight: 600; border: 1px solid var(--border); color: var(--text-secondary); }
|
||||||
|
.feed-content td { padding: 5px 12px; border: 1px solid var(--border); color: var(--text-primary); }
|
||||||
|
.feed-content tr:nth-child(even) td { background: var(--bg-surface); }
|
||||||
.inline-code { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 13px; color: var(--accent-muted); }
|
.inline-code { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 13px; color: var(--accent-muted); }
|
||||||
.msg-h3 { font-size: 16px; font-weight: 700; color: var(--text-primary); margin: 16px 0 8px; display: block; }
|
.msg-h3 { font-size: 16px; font-weight: 700; color: var(--text-primary); margin: 10px 0 4px; display: block; }
|
||||||
.msg-h4 { font-size: 14px; font-weight: 700; color: var(--text-secondary); margin: 12px 0 6px; display: block; }
|
.msg-h4 { font-size: 14px; font-weight: 700; color: var(--text-secondary); margin: 8px 0 3px; display: block; }
|
||||||
.msg-bullet { display: block; padding-left: 16px; position: relative; margin: 2px 0; }
|
.msg-bullet { display: block; padding-left: 16px; position: relative; margin: 1px 0; }
|
||||||
.msg-bullet::before { content: '\2022'; position: absolute; left: 4px; color: var(--accent); }
|
.msg-bullet::before { content: '\2022'; position: absolute; left: 4px; color: var(--accent); }
|
||||||
.msg-step { display: flex; gap: 8px; align-items: baseline; margin: 3px 0; }
|
.msg-step { display: flex; gap: 8px; align-items: baseline; margin: 1px 0; }
|
||||||
.msg-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); font-size: 13px; flex-shrink: 0; min-width: 20px; }
|
.msg-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); font-size: 13px; flex-shrink: 0; min-width: 20px; }
|
||||||
.studio-cursor { display: inline-block; width: 8px; height: 16px; background: var(--accent); margin-left: 2px; vertical-align: text-bottom; animation: blink 0.8s step-end infinite; }
|
.studio-cursor { display: inline-block; width: 8px; height: 16px; background: var(--accent); margin-left: 2px; vertical-align: text-bottom; animation: blink 0.8s step-end infinite; }
|
||||||
@keyframes blink { 50% { opacity: 0; } }
|
@keyframes blink { 50% { opacity: 0; } }
|
||||||
@@ -736,6 +1013,22 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.studio-thinking span:nth-child(3) { animation-delay: 0.3s; }
|
.studio-thinking span:nth-child(3) { animation-delay: 0.3s; }
|
||||||
@keyframes bounce { 0%, 80%, 100% { transform: translateY(0); opacity: 0.4; } 40% { transform: translateY(-6px); opacity: 1; } }
|
@keyframes bounce { 0%, 80%, 100% { transform: translateY(0); opacity: 0.4; } 40% { transform: translateY(-6px); opacity: 1; } }
|
||||||
.studio-input-area { padding: 12px 20px 8px; border-top: 1px solid var(--border); background: var(--bg-surface); }
|
.studio-input-area { padding: 12px 20px 8px; border-top: 1px solid var(--border); background: var(--bg-surface); }
|
||||||
|
.studio-token-bar { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||||
|
.studio-token-track { flex: 1; height: 3px; background: var(--bg-input); border-radius: 2px; overflow: hidden; }
|
||||||
|
.studio-token-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.4s, background 0.3s; }
|
||||||
|
.studio-token-fill.warn { background: var(--warning); }
|
||||||
|
.studio-token-fill.compressed { height: 2px; }
|
||||||
|
.studio-token-fill.animating { animation: compress-pulse 0.6s ease-in-out; }
|
||||||
|
@keyframes compress-pulse {
|
||||||
|
0% { height: 3px; opacity: 1; }
|
||||||
|
50% { height: 5px; opacity: 0.8; background: var(--accent-light); }
|
||||||
|
100% { height: 2px; opacity: 1; }
|
||||||
|
}
|
||||||
|
.studio-token-text { font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); white-space: nowrap; }
|
||||||
|
.studio-token-text.compressed { font-size: 9px; }
|
||||||
|
.studio-token-track.compressed { height: 2px; }
|
||||||
|
.studio-token-bar.compressed { margin-bottom: 4px; }
|
||||||
|
|
||||||
.studio-input-row { display: flex; gap: 8px; align-items: flex-end; }
|
.studio-input-row { display: flex; gap: 8px; align-items: flex-end; }
|
||||||
.studio-input-row textarea {
|
.studio-input-row textarea {
|
||||||
flex: 1; resize: none; min-height: 42px; max-height: 200px; padding: 10px 14px;
|
flex: 1; resize: none; min-height: 42px; max-height: 200px; padding: 10px 14px;
|
||||||
@@ -752,8 +1045,29 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
}
|
}
|
||||||
.studio-send-btn:hover:not(:disabled) { background: var(--accent-bright); border-color: var(--accent-bright); }
|
.studio-send-btn:hover:not(:disabled) { background: var(--accent-bright); border-color: var(--accent-bright); }
|
||||||
.studio-send-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
.studio-send-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||||
|
.studio-stop-btn {
|
||||||
|
width: 42px; height: 42px; padding: 0; display: flex; align-items: center; justify-content: center;
|
||||||
|
border-radius: var(--radius); background: var(--error); color: #fff; border: 1px solid var(--error);
|
||||||
|
cursor: pointer; transition: all 0.15s; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.studio-stop-btn:hover { opacity: 0.8; }
|
||||||
.studio-input-hint { font-size: 11px; color: var(--text-disabled); text-align: center; margin-top: 6px; }
|
.studio-input-hint { font-size: 11px; color: var(--text-disabled); text-align: center; margin-top: 6px; }
|
||||||
|
|
||||||
|
/* ── Collapsed Messages ── */
|
||||||
|
.feed-collapsed-messages {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 8px 16px; margin: 4px 0;
|
||||||
|
background: linear-gradient(135deg, var(--bg-surface), var(--bg-elevated));
|
||||||
|
border: 1px dashed var(--border-accent);
|
||||||
|
border-radius: var(--radius); cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.feed-collapsed-messages:hover { background: var(--bg-hover); border-color: var(--accent); }
|
||||||
|
.feed-collapsed-messages svg { color: var(--accent); flex-shrink: 0; }
|
||||||
|
.feed-collapsed-text { font-size: 11px; color: var(--text-tertiary); flex: 1; }
|
||||||
|
.feed-collapsed-count { font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono); }
|
||||||
|
.feed-expanded-messages { animation: fadeIn 0.2s ease-out; }
|
||||||
|
|
||||||
/* ── Studio Tool Blocks ── */
|
/* ── Studio Tool Blocks ── */
|
||||||
.studio-tool-block {
|
.studio-tool-block {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
@@ -820,7 +1134,8 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
white-space: nowrap;
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
@@ -841,3 +1156,76 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === XTerm Custom Styling === */
|
||||||
|
/* Styles for xterm.js integrated with Muyue theme */
|
||||||
|
.shell-xterm-instance .xterm {
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-xterm-instance .xterm-viewport {
|
||||||
|
background-color: var(--bg-base) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-xterm-instance .xterm-screen {
|
||||||
|
background-color: var(--bg-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling for xterm */
|
||||||
|
.shell-xterm-instance .xterm-viewport::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-xterm-instance .xterm-viewport::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-xterm-instance .xterm-viewport::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-xterm-instance .xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--accent-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selection styling */
|
||||||
|
.shell-xterm-instance .xterm-selection {
|
||||||
|
background: var(--accent-dim) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus ring styling */
|
||||||
|
.shell-xterm-instance .xterm:focus .xterm-helper-text-container {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure consistent font rendering */
|
||||||
|
.shell-xterm-instance .xterm .xterm-char-measure-element {
|
||||||
|
font-family: var(--font-mono) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bell animation styling */
|
||||||
|
.shell-xterm-instance .xterm-bell {
|
||||||
|
animation: xterm-bell-flash 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes xterm-bell-flash {
|
||||||
|
0% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cursor styling */
|
||||||
|
.shell-xterm-instance .xterm-cursor {
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Link styling for web links addon */
|
||||||
|
.shell-xterm-instance .xterm-link {
|
||||||
|
color: var(--accent-light) !important;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-xterm-instance .xterm-link:hover {
|
||||||
|
color: var(--accent-muted) !important;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user