Compare commits
199 Commits
v0.3.0-bet
...
v0.7.4-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ce5c49622 | ||
|
|
830e085c2a | ||
|
|
24b09f5700 | ||
|
|
1442b4fd8a | ||
|
|
a1da9da3db | ||
|
|
a7d4b31a0d | ||
|
|
0ee006f71f | ||
|
|
fc7a5b9d87 | ||
|
|
654444ccc8 | ||
|
|
991878939b | ||
|
|
dbb97cc164 | ||
|
|
6d2f174ae8 | ||
|
|
0d1d8d3ec3 | ||
|
|
c820d55710 | ||
|
|
6a7b4d8001 | ||
|
|
0753167fb9 | ||
|
|
2a6647b5cb | ||
|
|
3740454201 | ||
|
|
d98110ce8a | ||
|
|
d2bb42b212 | ||
|
|
e8a289ccf3 | ||
|
|
c9f2932147 | ||
|
|
f05181b2db | ||
|
|
95e6cdaf41 | ||
|
|
12000e523c | ||
|
|
cb3d35756a | ||
|
|
0830e64ae6 | ||
|
|
b43e3352e7 | ||
|
|
a60435d002 | ||
|
|
6b0fcfbd31 | ||
|
|
df46b5c14e | ||
|
|
7240813de6 | ||
|
|
97bfb803a6 | ||
|
|
3104179109 | ||
|
|
e21b47a27c | ||
|
|
2e98701104 | ||
|
|
f9d56de65a | ||
|
|
0e7340891c | ||
|
|
3b819be5ac | ||
|
|
c607943ca3 | ||
|
|
3312005be4 | ||
|
|
6cc86b7f89 | ||
|
|
1885616068 | ||
|
|
c8506d4dfc | ||
|
|
68acabd6a1 | ||
|
|
b80562a669 | ||
|
|
c562972da3 | ||
|
|
3651f62127 | ||
|
|
18e83479d6 | ||
|
|
6596d86db6 | ||
|
|
9fb5aa8dbf | ||
|
|
ab3641d00d | ||
|
|
5dac191d9a | ||
|
|
e6da61f460 | ||
|
|
a994749dcf | ||
|
|
b394ef9979 | ||
|
|
fca53440e6 | ||
|
|
0a3123ec17 | ||
|
|
e6447f2f5a | ||
|
|
16c5ed6dd9 | ||
|
|
e8924be182 | ||
|
|
a905f22f1a | ||
|
|
183dd27407 | ||
|
|
203f57fa31 | ||
|
|
a1046da67b | ||
|
|
02ee41c12b | ||
|
|
06810be9a3 | ||
|
|
8db3bd7c6b | ||
|
|
20237c022f | ||
|
|
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 | ||
|
|
c39203cc4b | ||
|
|
869bf154cc | ||
|
|
7ae4017672 | ||
|
|
52a785ec9a | ||
|
|
8c540eba93 | ||
|
|
0b6d5281df | ||
|
|
1074b019d3 | ||
|
|
745e03d00a | ||
|
|
2da0cf9421 | ||
|
|
f88c7a4f3f | ||
|
|
9987a586e2 | ||
|
|
028fb364ba | ||
|
|
2827acfe96 | ||
|
|
85edea9ed9 | ||
|
|
afb6e77c7f | ||
|
|
0232bd7afe | ||
|
|
84be22661b | ||
|
|
49a0f5c8c3 | ||
|
|
f9c4cf11ff | ||
|
|
d3755028fb | ||
|
|
eda7293286 | ||
|
|
41cbee8928 | ||
|
|
b55feaed09 | ||
|
|
1d521cbf90 | ||
|
|
54621bd960 | ||
|
|
6bad2948c5 | ||
|
|
d9d1ec5cb7 | ||
|
|
92eb783df0 | ||
|
|
45884ee75c | ||
|
|
8005e978f0 | ||
|
|
6f7f588e51 | ||
|
|
6e76e7dca6 | ||
|
|
e8f6dc4b4d | ||
|
|
bb03c9fe2d | ||
|
|
328e9e6457 | ||
|
|
c81ebb4e46 | ||
|
|
b0865bc598 | ||
|
|
0d8e1b1e1a | ||
|
|
485e085bb0 | ||
|
|
61da8039bc | ||
|
|
65df15498b | ||
|
|
b6147ddb12 | ||
|
|
275a9a4cc7 | ||
|
|
e92a2f00f5 | ||
|
|
1f12b8a4fb | ||
|
|
9188231a05 | ||
|
|
79d082180c | ||
|
|
7682717093 | ||
|
|
3948a4c656 | ||
|
|
65804aae4e | ||
|
|
2e50366cd8 | ||
|
|
66b773ff86 | ||
|
|
bc5c2956b4 | ||
|
|
e19122dad9 | ||
|
|
8b6a7e8bc3 | ||
|
|
58f8cb0bd3 | ||
|
|
b52feccc17 | ||
|
|
5bbac499a7 | ||
|
|
28e5113733 | ||
|
|
51a599fc83 | ||
|
|
d8384cad00 | ||
|
|
83d7a573c7 | ||
|
|
5b4a70e690 | ||
|
|
0fe82f67df | ||
|
|
4b9f2c377d | ||
|
|
95bd824259 | ||
|
|
252f178bbd | ||
|
|
7dcf505360 | ||
|
|
8fb93fa47e | ||
|
|
5ec373cd6a | ||
|
|
1eb5a6d00f | ||
|
|
cd5ebe083c | ||
|
|
2004c15dd7 | ||
|
|
9306152736 | ||
|
|
e15a034de5 | ||
|
|
3b6cc38ea0 | ||
|
|
93a22d4075 | ||
|
|
e0e1e73bca | ||
|
|
0496ca789b | ||
|
|
b407ab879b | ||
|
|
12df184e11 | ||
|
|
8af6d25e28 | ||
|
|
4fd599adec | ||
|
|
bcba5932d5 | ||
|
|
04b0fff791 | ||
|
|
80c11cab3f | ||
|
|
0b221094f2 | ||
|
|
7f674730c7 | ||
|
|
040e482c75 |
@@ -15,7 +15,7 @@ jobs:
|
|||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.24.3'
|
go-version: '1.24'
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -68,6 +68,13 @@ jobs:
|
|||||||
echo "beta_num=${BETA_NUM}" >> $GITHUB_OUTPUT
|
echo "beta_num=${BETA_NUM}" >> $GITHUB_OUTPUT
|
||||||
echo "Building beta release: ${VERSION}"
|
echo "Building beta release: ${VERSION}"
|
||||||
|
|
||||||
|
- name: Generate Windows resource (icon)
|
||||||
|
run: |
|
||||||
|
go install github.com/akavel/rsrc@latest
|
||||||
|
RSRC="$(go env GOPATH)/bin/rsrc"
|
||||||
|
$RSRC -ico assets/muyue.ico -arch amd64 -o cmd/muyue/rsrc_windows_amd64.syso
|
||||||
|
$RSRC -ico assets/muyue.ico -arch arm64 -o cmd/muyue/rsrc_windows_arm64.syso
|
||||||
|
|
||||||
- name: Build (all platforms)
|
- name: Build (all platforms)
|
||||||
run: |
|
run: |
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.24.3'
|
go-version: '1.24'
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -64,6 +64,13 @@ jobs:
|
|||||||
echo "base=${BASE_VERSION}" >> $GITHUB_OUTPUT
|
echo "base=${BASE_VERSION}" >> $GITHUB_OUTPUT
|
||||||
echo "Building stable release: ${VERSION}"
|
echo "Building stable release: ${VERSION}"
|
||||||
|
|
||||||
|
- name: Generate Windows resource (icon)
|
||||||
|
run: |
|
||||||
|
go install github.com/akavel/rsrc@latest
|
||||||
|
RSRC="$(go env GOPATH)/bin/rsrc"
|
||||||
|
$RSRC -ico assets/muyue.ico -arch amd64 -o cmd/muyue/rsrc_windows_amd64.syso
|
||||||
|
$RSRC -ico assets/muyue.ico -arch arm64 -o cmd/muyue/rsrc_windows_arm64.syso
|
||||||
|
|
||||||
- name: Build (all platforms)
|
- name: Build (all platforms)
|
||||||
run: |
|
run: |
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
@@ -138,11 +145,12 @@ jobs:
|
|||||||
echo "sudo mv muyue-darwin-arm64 /usr/local/bin/muyue"
|
echo "sudo mv muyue-darwin-arm64 /usr/local/bin/muyue"
|
||||||
echo "\`\`\`"
|
echo "\`\`\`"
|
||||||
echo ""
|
echo ""
|
||||||
echo "**Windows (x86_64)**"
|
echo "**Windows (x86_64)** — sans privilèges admin, crée les raccourcis Bureau + Menu Démarrer :"
|
||||||
echo "\`\`\`powershell"
|
echo "\`\`\`powershell"
|
||||||
echo "Invoke-WebRequest -Uri \"${DL_URL}/muyue-windows-amd64.zip\" -OutFile \"muyue.zip\""
|
echo "\$dest = \"\$env:LOCALAPPDATA\\Muyue\"; New-Item -ItemType Directory -Force -Path \$dest | Out-Null"
|
||||||
echo "Expand-Archive -Path \"muyue.zip\" -DestinationPath \".\""
|
echo "Invoke-WebRequest -Uri \"${DL_URL}/muyue-windows-amd64.zip\" -OutFile \"\$env:TEMP\\muyue.zip\""
|
||||||
echo "Move-Item muyue-windows-amd64.exe C:\\Windows\\muyue.exe"
|
echo "Expand-Archive -Path \"\$env:TEMP\\muyue.zip\" -DestinationPath \$dest -Force"
|
||||||
|
echo "& \"\$dest\\muyue-windows-amd64.exe\" install-shortcuts"
|
||||||
echo "\`\`\`"
|
echo "\`\`\`"
|
||||||
} > /tmp/stable_changelog.md
|
} > /tmp/stable_changelog.md
|
||||||
echo "path=/tmp/stable_changelog.md" >> $GITHUB_OUTPUT
|
echo "path=/tmp/stable_changelog.md" >> $GITHUB_OUTPUT
|
||||||
@@ -170,7 +178,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 +189,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 +235,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!"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.24.3'
|
go-version: '1.24'
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
|
|||||||
1
.gitignore
vendored
@@ -24,6 +24,7 @@ Thumbs.db
|
|||||||
*.exe
|
*.exe
|
||||||
*.test
|
*.test
|
||||||
*.out
|
*.out
|
||||||
|
*.syso
|
||||||
vendor/
|
vendor/
|
||||||
|
|
||||||
# Config with secrets
|
# Config with secrets
|
||||||
|
|||||||
681
CHANGELOG.md
@@ -4,10 +4,691 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
|
## v0.7.4
|
||||||
|
|
||||||
|
### Logo Muyue intégré
|
||||||
|
|
||||||
|
- `LogoMuyue.png` ajouté à la racine + déclinaisons générées dans `assets/` (16/32/64/128/256/512 px) et `assets/muyue.ico` (multi-résolution 16-256 px).
|
||||||
|
- **Binaire Windows** : icône embarquée comme ressource Windows via `github.com/akavel/rsrc` au build CI (génération de `cmd/muyue/rsrc_windows_{amd64,arm64}.syso`). Conséquences :
|
||||||
|
- Explorateur Windows affiche l'icône Muyue sur le `.exe`
|
||||||
|
- Les raccourcis créés par `install-shortcuts` héritent de l'icône (via `IconLocation = "$exe,0"`)
|
||||||
|
- Aucune dépendance Go à runtime ; les `.syso` sont gitignorés et regénérés à chaque build
|
||||||
|
- **UI web** : favicon réel (16/32 px), apple-touch-icon (256 px) et logo affiché dans le header à côté de "MUYUE".
|
||||||
|
- Snippet d'install Windows : 1ʳᵉ ligne idempotente (`New-Item -ItemType Directory -Force`) pour gérer le cas d'une ré-exécution après install partielle.
|
||||||
|
- Préservation du logo source en pleine résolution (912×950 RGBA) — pas de perte d'information.
|
||||||
|
|
||||||
|
## v0.7.3
|
||||||
|
|
||||||
|
### Onboarding — focus MiniMax + MiMo
|
||||||
|
|
||||||
|
- L'étape `apikey` du wizard de premier lancement propose désormais **les deux clés** (MiniMax + MiMo) côte à côte ; au moins une doit être validée pour continuer.
|
||||||
|
- Les autres fournisseurs (OpenAI, Anthropic, Z.AI, Ollama) ne sont plus proposés dans le wizard — l'utilisateur les configure ensuite via l'onglet **Configuration** s'il le souhaite. Justification : pour les nouveaux utilisateurs, deux choix simples > six choix qui ralentissent le démarrage.
|
||||||
|
- Si MiniMax est validé, il devient le provider actif. Sinon, c'est MiMo. Si les deux sont validés, MiniMax reste actif (peut être basculé via `/model change` plus tard).
|
||||||
|
|
||||||
|
### Install Windows — pas d'admin + raccourcis automatiques
|
||||||
|
|
||||||
|
- **Avant** : la 3ᵉ ligne du snippet d'install (`Move-Item ... C:\Windows\muyue.exe`) échouait avec `UnauthorizedAccessException` sur PowerShell sans élévation.
|
||||||
|
- **Maintenant** : 4 lignes, toutes exécutables sans admin :
|
||||||
|
```powershell
|
||||||
|
$dest = "$env:LOCALAPPDATA\Muyue"
|
||||||
|
Invoke-WebRequest -Uri ".../muyue-windows-amd64.zip" -OutFile "$env:TEMP\muyue.zip"
|
||||||
|
Expand-Archive -Path "$env:TEMP\muyue.zip" -DestinationPath $dest -Force
|
||||||
|
& "$dest\muyue-windows-amd64.exe" install-shortcuts
|
||||||
|
```
|
||||||
|
- Nouvelle commande `muyue install-shortcuts` (Windows uniquement) :
|
||||||
|
- crée `Muyue.lnk` sur le Bureau et dans le Menu Démarrer (résolus via `[Environment]::GetFolderPath`, robuste OneDrive / profils non-standards) ;
|
||||||
|
- utilise WScript.Shell COM via PowerShell pour générer les `.lnk` (pas de dépendance Go ajoutée) ;
|
||||||
|
- ajoute le dossier d'install au `PATH` utilisateur (scope User, pas de modif système).
|
||||||
|
- Une icône custom pourra être branchée plus tard en remplaçant la ressource embed du `.exe` ; pour l'instant, l'icône Windows par défaut du binaire est utilisée.
|
||||||
|
|
||||||
|
## v0.7.2
|
||||||
|
|
||||||
|
### Amélioration
|
||||||
|
|
||||||
|
- **feat(studio): réflexion avancée forcée automatiquement pendant les tests** — quand au moins une session `browser_test` est connectée, chaque message à Studio active automatiquement la réflexion avancée (un second modèle, si configuré, produit un rapport préalable injecté dans le prompt actif). Le toggle UI est ignoré tant qu'une session de test est active. Justification : pendant un test piloté par l'IA, avoir une analyse complémentaire d'un autre modèle améliore matériellement la qualité des décisions de clic et la couverture du rapport final.
|
||||||
|
- Si aucun second provider n'est configuré, le comportement reste silencieux (fallback chat normal — pas d'erreur visible côté utilisateur).
|
||||||
|
- Hint UI ajouté dans l'onglet Tests pour expliquer le comportement.
|
||||||
|
|
||||||
|
## v0.7.1
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **fix(terminal/windows): "unsupported" / connection closed** — `creack/pty` n'a pas de support Windows natif et `pty.Start()` retourne immédiatement une erreur ("operating system not supported"), fermant le WebSocket avant même la bannière. L'utilisateur voyait le menu des terminaux peuplé (détection OK : `wsl --list --quiet` fonctionne) mais chaque clic se soldait par "unsupported" ou une connexion fermée.
|
||||||
|
- Introduction de l'abstraction `termSession` (`internal/api/terminal_session.go`) avec deux implémentations sélectionnées au runtime :
|
||||||
|
- **`ptySession`** (Linux / macOS / BSDs) : conserve le comportement existant (TTY complet via `creack/pty`, resize, apps interactives type vim/top).
|
||||||
|
- **`pipeSession`** (Windows) : pipes natifs `stdin` + `stdout` + `stderr` mergés, lus en goroutines, forwardés au WebSocket. Suffisant pour `wsl.exe`, `pwsh`, `cmd` en mode ligne — la plupart des cas d'usage (lancer une commande, voir la sortie, taper la suivante). Resize est un no-op (pas de SIGWINCH sans TTY) ; les TUIs en plein écran ne fonctionnent pas dans ce mode.
|
||||||
|
- Refactor minimal de `handleTerminalWS` : utilise `startTermSession(cmd)` au lieu de `pty.Start(cmd)` direct ; même chemin code pour les deux OS.
|
||||||
|
|
||||||
|
## v0.7.0
|
||||||
|
|
||||||
|
### Changes since v0.4.0
|
||||||
|
|
||||||
|
- fix(ci): rename browser_test.go → browsertest.go (6d2f174)
|
||||||
|
- feat: AI-driven browser tests — Tests tab + browser_test agent tool (c820d55)
|
||||||
|
- release: v0.6.0 — security audit fixes + 7 new features (6a7b4d8)
|
||||||
|
- chore: bump version to 0.5.0 (2a6647b)
|
||||||
|
- feat: agent concurrency, conversation summaries, AI tools config, UI polish (3740454)
|
||||||
|
- feat: AI task API, token-based context windows, SSH password auth, sudo bypass detection (d98110c)
|
||||||
|
- feat: agent concurrency, conversation summaries, AI tools config, UI polish (d2bb42b)
|
||||||
|
- feat: AI task API, token-based context windows, SSH password auth, sudo bypass detection (e8a289c)
|
||||||
|
|
||||||
|
### Downloads
|
||||||
|
|
||||||
|
| Platform | File |
|
||||||
|
|----------|------|
|
||||||
|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.0/muyue-linux-amd64.tar.gz) |
|
||||||
|
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.0/muyue-linux-arm64.tar.gz) |
|
||||||
|
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.0/muyue-darwin-amd64.tar.gz) |
|
||||||
|
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.0/muyue-darwin-arm64.tar.gz) |
|
||||||
|
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.0/muyue-windows-amd64.zip) |
|
||||||
|
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.0/muyue-windows-arm64.zip) |
|
||||||
|
|
||||||
|
The binary includes both CLI and Desktop modes.
|
||||||
|
Run `muyue` for TUI, `muyue desktop` for web UI.
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
**Linux (x86_64)**
|
||||||
|
```bash
|
||||||
|
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.0/muyue-linux-amd64.tar.gz | tar xz
|
||||||
|
chmod +x muyue-linux-amd64
|
||||||
|
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**macOS (Apple Silicon)**
|
||||||
|
```bash
|
||||||
|
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.0/muyue-darwin-arm64.tar.gz | tar xz
|
||||||
|
chmod +x muyue-darwin-arm64
|
||||||
|
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows (x86_64)**
|
||||||
|
```powershell
|
||||||
|
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.0/muyue-windows-amd64.zip" -OutFile "muyue.zip"
|
||||||
|
Expand-Archive -Path "muyue.zip" -DestinationPath "."
|
||||||
|
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## v0.7.0
|
||||||
|
|
||||||
|
### Nouvelle fonctionnalité majeure : Tests pilotés par l'IA
|
||||||
|
|
||||||
|
- **Onglet Tests** dédié dans l'UI : génère un snippet JS à coller dans n'importe quelle page web ouverte (Chrome, Firefox, Edge, dev local ou distant).
|
||||||
|
- **Session WebSocket** authentifiée par token à usage unique (5 min TTL) — la page connectée transmet ses messages console en temps réel et expose une RPC pour cliquer / évaluer / inspecter.
|
||||||
|
- **Outil agent `browser_test`** disponible pour Studio, avec actions :
|
||||||
|
- `list_clickables` : énumère tous les éléments cliquables visibles avec un index stable
|
||||||
|
- `click` : clic par sélecteur CSS ou par index — retourne le **delta console** émis pendant le clic
|
||||||
|
- `eval` : évalue une expression JS et retourne sa valeur sérialisée
|
||||||
|
- `console` / `summary` : lit le buffer console (200 dernières entrées)
|
||||||
|
- `current_url` : URL et titre courants
|
||||||
|
- `type` : remplit un champ input/textarea (utilise le setter natif pour compatibilité React)
|
||||||
|
- `wait` : pause asynchrone (max 5s)
|
||||||
|
- **Stratégie BMAD** intégrée au prompt système Studio : boucle `summary → list_clickables → click → vérifier console_delta → rapport final ✓/✗/⚠`.
|
||||||
|
- **Multi-sessions** : jusqu'à 16 onglets connectés simultanément ; éviction LRU au-delà.
|
||||||
|
- **Sécurité** : token consommé à la première connexion ; CheckOrigin libre côté snippet (gating par token uniquement) ; CORS API REST inchangé.
|
||||||
|
- **Backend** : `internal/api/browser_test.go` (nouveau, ~480 lignes) + 4 routes (`/api/test/snippet`, `/api/test/sessions`, `/api/test/console/{id}`, `/api/ws/browser-test`).
|
||||||
|
- **Frontend** : `web/src/components/Tests.jsx` (nouveau) + nouvel onglet ⌃4.
|
||||||
|
|
||||||
|
## v0.6.0
|
||||||
|
|
||||||
|
### Audit & corrections (sécurité, concurrence, stabilité)
|
||||||
|
|
||||||
|
- fix(api): empty `resp.Choices[0]` panic in chat engine — bounded check
|
||||||
|
- fix(api): `defer release()` accumulating inside tool-call loop — release immediately after each tool call
|
||||||
|
- fix(api): race in `ConversationStoreMulti.Add` (fire-and-forget save under released lock) — synchronous save under existing lock
|
||||||
|
- fix(workflow): infinite busy-wait in `engine.Execute` when a dependency fails — propagate `StatusFailed`/`StatusSkipped` and short-circuit
|
||||||
|
- fix(workflow): UTF-8-unsafe slicing of plan goal — rune-aware truncate
|
||||||
|
- fix(security): CORS `Access-Control-Allow-Origin: *` — restricted to localhost origins
|
||||||
|
- fix(security): API key disclosure in `/api/providers` — masked as `"***"`; saving handler ignores `"***"` placeholder
|
||||||
|
- fix(security): SSH password disclosure in `/api/terminal/sessions` — masked; update handler preserves stored password if `"***"` is sent
|
||||||
|
- fix(security): sshpass `-p` + `-e` mutually-exclusive flags — use only `-e` with `SSHPASS` env var
|
||||||
|
- fix(security): unbounded chat request body — `MaxBytesReader` 50 MB
|
||||||
|
- fix(security): unbounded image upload — 10 MB cap in `saveImage`
|
||||||
|
- fix(security): font size unbounded — capped at 72
|
||||||
|
- fix(security): `LSP /auto-install` accepted arbitrary `project_dir` — restricted to user home subtree
|
||||||
|
- fix(api): silent `json.Unmarshal` errors in profile save — propagated
|
||||||
|
- fix(ui): operator-precedence bug in `Shell.jsx` resize check — parenthesized
|
||||||
|
|
||||||
|
### Nouvelles fonctionnalités
|
||||||
|
|
||||||
|
- feat(ai): inject OS name (e.g. `Debian 12`, `Windows 11`, `macOS 14.5`) alongside date in Studio system prompt
|
||||||
|
- feat(agents): default timeout raised to 30 minutes for `crush_run` and `claude_run`; max also 30 min
|
||||||
|
- feat(agents): new optional params `cwd`, `wsl_distro`, `wsl_user` — agents can be launched in a specific directory, and on Windows hosts inside a specific WSL distribution under a specific user
|
||||||
|
- feat(agents): new `claude_run` tool (mirrors `crush_run` for the Claude Code CLI)
|
||||||
|
- feat(terminal): WSL distros listed individually as quick-launch entries in the new-tab menu (Windows hosts only)
|
||||||
|
- feat(studio): system prompt rewritten around the BMAD-METHOD (Analyst/PM/Architect/SM/Dev/QA personas + mandatory `[OBJECTIF]/[CONTEXTE]/[CONTRAINTES]/[LIVRABLE]/[CRITÈRE D'ACCEPTATION]` template for any agent delegation)
|
||||||
|
- feat(studio): "Réflexion avancée" toggle — when enabled, the inactive AI provider produces a preliminary report that is injected as `[RAPPORT PRÉALABLE]` context into the active provider's prompt
|
||||||
|
- feat(studio): "Historique compressé" toggle — collapses past tool calls and keeps only the last visible action per assistant message, with `Tout afficher` to expand
|
||||||
|
|
||||||
|
### Bug fix CI
|
||||||
|
|
||||||
|
- fix(test): `cleanAIResponse` → `CleanAIResponse` in `orchestrator_test.go` (was failing `go vet`)
|
||||||
|
|
||||||
|
## v0.4.0
|
||||||
|
|
||||||
|
### Changes since v0.3.5
|
||||||
|
|
||||||
|
- fix: token persistence, context windows, CSS tables/bullets/hr, image attachments (12000e5)
|
||||||
|
- feat: terminal sudo blocking, token tracking, mermaid & consumption UI (cb3d357)
|
||||||
|
- fix(shell,config): terminal font size, AI tools, provider keys (0830e64)
|
||||||
|
- chore: update CHANGELOG for v0.3.5 (b43e335)
|
||||||
|
- fix(shell): set default terminal fontSize to 6px (a60435d)
|
||||||
|
- fix(shell): default fontSize 10px and init new tabs immediately (6b0fcfb)
|
||||||
|
- feat(shell): add Ctrl+/- zoom and display all shortcuts in footer (df46b5c)
|
||||||
|
- fix(deps): upgrade @xterm/xterm to 6.1.0-beta.203 for addon compatibility (7240813)
|
||||||
|
- fix(shell): enable allowProposedApi for Unicode11 addon (97bfb80)
|
||||||
|
- fix(ci): add .npmrc with legacy-peer-deps for xterm addon resolution (3104179)
|
||||||
|
- feat(shell): integrate Hyper-like terminal technologies (WebGL, search, unicode11, image) (e21b47a)
|
||||||
|
- fix(shell): restore all missing imports, constants, and utility functions (2e98701)
|
||||||
|
- fix(shell): add missing Monitor import from lucide-react (f9d56de)
|
||||||
|
- fix(shell): restore missing MAX_TABS, TABS_STORAGE_KEY, TERMINAL_BUFFER_KEY constants (0e73408)
|
||||||
|
- fix(shell): add missing useI18n import (3b819be)
|
||||||
|
- fix(shell): remove stray 'impo' typo causing ReferenceError (c607943)
|
||||||
|
- fix(terminal): improve dimensions handling and add system theme for xterm (3312005)
|
||||||
|
- fix(shell): resolve savedTabs undefined ReferenceError in activeTab init (6cc86b7)
|
||||||
|
- fix(terminal): improve dimension calculation and tab init reliability (1885616)
|
||||||
|
- fix(dashboard): show MiMo quota instead of ZAI on dashboard (c8506d4)
|
||||||
|
- feat(ai): add Xiaomi MiMo provider, ZAI as last-resort fallback (68acabd)
|
||||||
|
- fix(terminal): use absolute positioning for content panels (b80562a)
|
||||||
|
- feat(terminal): add Ctrl+Shift+C/V copy/paste shortcuts (c562972)
|
||||||
|
- fix(shell): prevent Enter in AI chat from leaking to terminal (3651f62)
|
||||||
|
- fix(terminal): improve terminal dimensions and fit timing (18e8347)
|
||||||
|
- fix(terminal): detect shell tab visibility via MutationObserver (6596d86)
|
||||||
|
- fix(terminal): init all tabs on load, fix excessive zoom (9fb5aa8)
|
||||||
|
- fix(terminal): improve tab visibility checks and positioning (ab3641d)
|
||||||
|
- fix(ui): adjust global CSS styles (5dac191)
|
||||||
|
- fix(terminal): use display:none instead of visibility for tab hiding (e6da61f)
|
||||||
|
- feat(ui): refactor copy state to Set and add helper functions (a994749)
|
||||||
|
- feat(ui): add recentUnique to deduplicate recent commands in Dashboard (b394ef9)
|
||||||
|
- feat(ui): redesign recent commands display and fix terminal visibility (fca5344)
|
||||||
|
- fix(shell): initialize activeTabRef with activeTab and move useEffect (0a3123e)
|
||||||
|
- fix(config): remove unused import, reorder hooks, and improve variable naming (e6447f2)
|
||||||
|
- fix(studio): add tool results serialization and improve message handling (16c5ed6)
|
||||||
|
- fix(shell): improve tab reference stability and command queueing (e8924be)
|
||||||
|
- fix(shell): add debug logging for tab tracking and WebSocket state (a905f22)
|
||||||
|
- fix(terminal): refactor WebSocket cleanup, buffer management, and disposal (183dd27)
|
||||||
|
- fix(terminal): refactor WS cleanup, improve clear detection, fix sendToTerminal (203f57f)
|
||||||
|
- fix: restore buffer after WebSocket init, fix clear detection, fix streaming chunks (a1046da)
|
||||||
|
- refactor: remove locale panel, improve provider validation and terminal buffer persistence (02ee41c)
|
||||||
|
- bump: v0.3.5 (06810be)
|
||||||
|
- fix: display all quota models, center card content vertically (8db3bd7)
|
||||||
|
- fix: AI terminal init, Shift+Tab nav, code block rendering, command filtering (20237c0)
|
||||||
|
- fix(shell): set default terminal fontSize to 6px (9a218b1)
|
||||||
|
- fix(shell): default fontSize 10px and init new tabs immediately (399b845)
|
||||||
|
- feat(shell): add Ctrl+/- zoom and display all shortcuts in footer (436d5c6)
|
||||||
|
- fix(deps): upgrade @xterm/xterm to 6.1.0-beta.203 for addon compatibility (5a9edc0)
|
||||||
|
- fix(shell): enable allowProposedApi for Unicode11 addon (5bdc7a6)
|
||||||
|
- fix(ci): add .npmrc with legacy-peer-deps for xterm addon resolution (5a0480b)
|
||||||
|
- feat(shell): integrate Hyper-like terminal technologies (WebGL, search, unicode11, image) (80de4dd)
|
||||||
|
- fix(shell): restore all missing imports, constants, and utility functions (de52f4e)
|
||||||
|
- fix(shell): add missing Monitor import from lucide-react (98ff0dd)
|
||||||
|
- fix(shell): restore missing MAX_TABS, TABS_STORAGE_KEY, TERMINAL_BUFFER_KEY constants (9a1ff6e)
|
||||||
|
- fix(shell): add missing useI18n import (034b9ee)
|
||||||
|
- fix(shell): remove stray 'impo' typo causing ReferenceError (c1b1fc6)
|
||||||
|
- fix(terminal): improve dimensions handling and add system theme for xterm (50ca751)
|
||||||
|
- fix(shell): resolve savedTabs undefined ReferenceError in activeTab init (b8aa935)
|
||||||
|
- fix(terminal): improve dimension calculation and tab init reliability (5627ddd)
|
||||||
|
- fix(dashboard): show MiMo quota instead of ZAI on dashboard (d278725)
|
||||||
|
- feat(ai): add Xiaomi MiMo provider, ZAI as last-resort fallback (7d0f807)
|
||||||
|
- fix(terminal): use absolute positioning for content panels (cbf623b)
|
||||||
|
- feat(terminal): add Ctrl+Shift+C/V copy/paste shortcuts (b85ebb8)
|
||||||
|
- fix(shell): prevent Enter in AI chat from leaking to terminal (7cc206d)
|
||||||
|
- fix(terminal): improve terminal dimensions and fit timing (bf8c0fd)
|
||||||
|
- fix(terminal): detect shell tab visibility via MutationObserver (08dc1fd)
|
||||||
|
- fix(terminal): init all tabs on load, fix excessive zoom (13e937a)
|
||||||
|
- fix(terminal): improve tab visibility checks and positioning (3cf701b)
|
||||||
|
- fix(ui): adjust global CSS styles (3a09e0e)
|
||||||
|
- fix(terminal): use display:none instead of visibility for tab hiding (47fa2e0)
|
||||||
|
- feat(ui): refactor copy state to Set and add helper functions (401292e)
|
||||||
|
- feat(ui): add recentUnique to deduplicate recent commands in Dashboard (199a7e4)
|
||||||
|
- feat(ui): redesign recent commands display and fix terminal visibility (c91931f)
|
||||||
|
- fix(shell): initialize activeTabRef with activeTab and move useEffect (cbbb224)
|
||||||
|
- fix(config): remove unused import, reorder hooks, and improve variable naming (8d10d21)
|
||||||
|
- fix(studio): add tool results serialization and improve message handling (e9696ef)
|
||||||
|
- fix(shell): improve tab reference stability and command queueing (1edd4f0)
|
||||||
|
- fix(shell): add debug logging for tab tracking and WebSocket state (92f943c)
|
||||||
|
- fix(terminal): refactor WebSocket cleanup, buffer management, and disposal (1704b19)
|
||||||
|
- fix(terminal): refactor WS cleanup, improve clear detection, fix sendToTerminal (40ec493)
|
||||||
|
- fix: restore buffer after WebSocket init, fix clear detection, fix streaming chunks (233368c)
|
||||||
|
- refactor: remove locale panel, improve provider validation and terminal buffer persistence (00118f0)
|
||||||
|
- chore: update CHANGELOG for v0.3.4 (c39203c)
|
||||||
|
- feat(dashboard): single-view grid with live CPU/RAM/Net graphs, API quota, processes, and sudo indicator (328e9e6)
|
||||||
|
- feat(dashboard): add quota monitoring, process list, and command history (c81ebb4)
|
||||||
|
- refactor(chat): deduplicate streaming code, add multi-conv, and XSS protection (b0865bc)
|
||||||
|
- fix(studio): improve chat context, thinking tags, streaming, and tool results (0d8e1b1)
|
||||||
|
- feat: add Cobra CLI, LSP/MCP registries, workflow engine, and enriched dashboard (485e085)
|
||||||
|
- feat(agent): refactor AI chat with streaming, agent registry, and tool execution (61da803)
|
||||||
|
- feat(onboarding): add minimax api key step and AI-powered editor scan (65df154)
|
||||||
|
- fix(onboarding): require fields before advancing steps (b6147dd)
|
||||||
|
- fix: register missing /api/config/reset and /api/starship/apply-theme routes (275a9a4)
|
||||||
|
- fix(config): per-provider form state to avoid field cross-talk (e92a2f0)
|
||||||
|
- fix(onboarding): auto-save on done step, keyboard nav, error feedback (1f12b8a)
|
||||||
|
- feat(config): add system panel with reset and starship theme, add onboarding wizard (9188231)
|
||||||
|
- chore: update CHANGELOG for v0.3.2 (28e5113)
|
||||||
|
- chore: update CHANGELOG for v0.3.2-beta.1 (51a599f)
|
||||||
|
- chore: update CHANGELOG for v0.3.1 (5b4a70e)
|
||||||
|
|
||||||
|
### Downloads
|
||||||
|
|
||||||
|
| Platform | File |
|
||||||
|
|----------|------|
|
||||||
|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.4.0/muyue-linux-amd64.tar.gz) |
|
||||||
|
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.4.0/muyue-linux-arm64.tar.gz) |
|
||||||
|
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.4.0/muyue-darwin-amd64.tar.gz) |
|
||||||
|
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.4.0/muyue-darwin-arm64.tar.gz) |
|
||||||
|
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.4.0/muyue-windows-amd64.zip) |
|
||||||
|
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.4.0/muyue-windows-arm64.zip) |
|
||||||
|
|
||||||
|
The binary includes both CLI and Desktop modes.
|
||||||
|
Run `muyue` for TUI, `muyue desktop` for web UI.
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
**Linux (x86_64)**
|
||||||
|
```bash
|
||||||
|
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.4.0/muyue-linux-amd64.tar.gz | tar xz
|
||||||
|
chmod +x muyue-linux-amd64
|
||||||
|
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**macOS (Apple Silicon)**
|
||||||
|
```bash
|
||||||
|
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.4.0/muyue-darwin-arm64.tar.gz | tar xz
|
||||||
|
chmod +x muyue-darwin-arm64
|
||||||
|
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows (x86_64)**
|
||||||
|
```powershell
|
||||||
|
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.4.0/muyue-windows-amd64.zip" -OutFile "muyue.zip"
|
||||||
|
Expand-Archive -Path "muyue.zip" -DestinationPath "."
|
||||||
|
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## v0.3.5
|
||||||
|
|
||||||
|
### Changes since v0.3.5
|
||||||
|
|
||||||
|
- fix(shell): set default terminal fontSize to 6px (a60435d)
|
||||||
|
- fix(shell): default fontSize 10px and init new tabs immediately (6b0fcfb)
|
||||||
|
- feat(shell): add Ctrl+/- zoom and display all shortcuts in footer (df46b5c)
|
||||||
|
- fix(deps): upgrade @xterm/xterm to 6.1.0-beta.203 for addon compatibility (7240813)
|
||||||
|
- fix(shell): enable allowProposedApi for Unicode11 addon (97bfb80)
|
||||||
|
- fix(ci): add .npmrc with legacy-peer-deps for xterm addon resolution (3104179)
|
||||||
|
- feat(shell): integrate Hyper-like terminal technologies (WebGL, search, unicode11, image) (e21b47a)
|
||||||
|
- fix(shell): restore all missing imports, constants, and utility functions (2e98701)
|
||||||
|
- fix(shell): add missing Monitor import from lucide-react (f9d56de)
|
||||||
|
- fix(shell): restore missing MAX_TABS, TABS_STORAGE_KEY, TERMINAL_BUFFER_KEY constants (0e73408)
|
||||||
|
- fix(shell): add missing useI18n import (3b819be)
|
||||||
|
- fix(shell): remove stray 'impo' typo causing ReferenceError (c607943)
|
||||||
|
- fix(terminal): improve dimensions handling and add system theme for xterm (3312005)
|
||||||
|
- fix(shell): resolve savedTabs undefined ReferenceError in activeTab init (6cc86b7)
|
||||||
|
- fix(terminal): improve dimension calculation and tab init reliability (1885616)
|
||||||
|
- fix(dashboard): show MiMo quota instead of ZAI on dashboard (c8506d4)
|
||||||
|
- feat(ai): add Xiaomi MiMo provider, ZAI as last-resort fallback (68acabd)
|
||||||
|
- fix(terminal): use absolute positioning for content panels (b80562a)
|
||||||
|
- feat(terminal): add Ctrl+Shift+C/V copy/paste shortcuts (c562972)
|
||||||
|
- fix(shell): prevent Enter in AI chat from leaking to terminal (3651f62)
|
||||||
|
- fix(terminal): improve terminal dimensions and fit timing (18e8347)
|
||||||
|
- fix(terminal): detect shell tab visibility via MutationObserver (6596d86)
|
||||||
|
- fix(terminal): init all tabs on load, fix excessive zoom (9fb5aa8)
|
||||||
|
- fix(terminal): improve tab visibility checks and positioning (ab3641d)
|
||||||
|
- fix(ui): adjust global CSS styles (5dac191)
|
||||||
|
- fix(terminal): use display:none instead of visibility for tab hiding (e6da61f)
|
||||||
|
- feat(ui): refactor copy state to Set and add helper functions (a994749)
|
||||||
|
- feat(ui): add recentUnique to deduplicate recent commands in Dashboard (b394ef9)
|
||||||
|
- feat(ui): redesign recent commands display and fix terminal visibility (fca5344)
|
||||||
|
- fix(shell): initialize activeTabRef with activeTab and move useEffect (0a3123e)
|
||||||
|
- fix(config): remove unused import, reorder hooks, and improve variable naming (e6447f2)
|
||||||
|
- fix(studio): add tool results serialization and improve message handling (16c5ed6)
|
||||||
|
- fix(shell): improve tab reference stability and command queueing (e8924be)
|
||||||
|
- fix(shell): add debug logging for tab tracking and WebSocket state (a905f22)
|
||||||
|
- fix(terminal): refactor WebSocket cleanup, buffer management, and disposal (183dd27)
|
||||||
|
- fix(terminal): refactor WS cleanup, improve clear detection, fix sendToTerminal (203f57f)
|
||||||
|
- fix: restore buffer after WebSocket init, fix clear detection, fix streaming chunks (a1046da)
|
||||||
|
- refactor: remove locale panel, improve provider validation and terminal buffer persistence (02ee41c)
|
||||||
|
- bump: v0.3.5 (06810be)
|
||||||
|
- fix: display all quota models, center card content vertically (8db3bd7)
|
||||||
|
- fix: AI terminal init, Shift+Tab nav, code block rendering, command filtering (20237c0)
|
||||||
|
- chore: update CHANGELOG for v0.3.4 (c39203c)
|
||||||
|
- feat(dashboard): single-view grid with live CPU/RAM/Net graphs, API quota, processes, and sudo indicator (328e9e6)
|
||||||
|
- feat(dashboard): add quota monitoring, process list, and command history (c81ebb4)
|
||||||
|
- refactor(chat): deduplicate streaming code, add multi-conv, and XSS protection (b0865bc)
|
||||||
|
- fix(studio): improve chat context, thinking tags, streaming, and tool results (0d8e1b1)
|
||||||
|
- feat: add Cobra CLI, LSP/MCP registries, workflow engine, and enriched dashboard (485e085)
|
||||||
|
- feat(agent): refactor AI chat with streaming, agent registry, and tool execution (61da803)
|
||||||
|
- feat(onboarding): add minimax api key step and AI-powered editor scan (65df154)
|
||||||
|
- fix(onboarding): require fields before advancing steps (b6147dd)
|
||||||
|
- fix: register missing /api/config/reset and /api/starship/apply-theme routes (275a9a4)
|
||||||
|
- fix(config): per-provider form state to avoid field cross-talk (e92a2f0)
|
||||||
|
- fix(onboarding): auto-save on done step, keyboard nav, error feedback (1f12b8a)
|
||||||
|
- feat(config): add system panel with reset and starship theme, add onboarding wizard (9188231)
|
||||||
|
- chore: update CHANGELOG for v0.3.2 (28e5113)
|
||||||
|
- chore: update CHANGELOG for v0.3.2-beta.1 (51a599f)
|
||||||
|
- chore: update CHANGELOG for v0.3.1 (5b4a70e)
|
||||||
|
|
||||||
|
### Downloads
|
||||||
|
|
||||||
|
| Platform | File |
|
||||||
|
|----------|------|
|
||||||
|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.5/muyue-linux-amd64.tar.gz) |
|
||||||
|
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.5/muyue-linux-arm64.tar.gz) |
|
||||||
|
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.5/muyue-darwin-amd64.tar.gz) |
|
||||||
|
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.5/muyue-darwin-arm64.tar.gz) |
|
||||||
|
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.5/muyue-windows-amd64.zip) |
|
||||||
|
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.5/muyue-windows-arm64.zip) |
|
||||||
|
|
||||||
|
The binary includes both CLI and Desktop modes.
|
||||||
|
Run `muyue` for TUI, `muyue desktop` for web UI.
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
**Linux (x86_64)**
|
||||||
|
```bash
|
||||||
|
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.5/muyue-linux-amd64.tar.gz | tar xz
|
||||||
|
chmod +x muyue-linux-amd64
|
||||||
|
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**macOS (Apple Silicon)**
|
||||||
|
```bash
|
||||||
|
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.5/muyue-darwin-arm64.tar.gz | tar xz
|
||||||
|
chmod +x muyue-darwin-arm64
|
||||||
|
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows (x86_64)**
|
||||||
|
```powershell
|
||||||
|
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.5/muyue-windows-amd64.zip" -OutFile "muyue.zip"
|
||||||
|
Expand-Archive -Path "muyue.zip" -DestinationPath "."
|
||||||
|
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## v0.3.4
|
||||||
|
|
||||||
|
### Changes since v0.3.3
|
||||||
|
|
||||||
|
- fix(ci): replace jq with python3 in release step, add debug output (7ae4017)
|
||||||
|
- feat: AI terminal, Z.AI quota, /model change, formatting fixes, update redirects (8c540eb)
|
||||||
|
- feat(studio): Tab focuses textarea, autocomplete commands (1074b01)
|
||||||
|
- fix(studio): convert newlines to <br/> in AI message rendering (2da0cf9)
|
||||||
|
- fix(config): replace hardcoded model list with free text input (9987a58)
|
||||||
|
- feat(config): providers panel shows only MINIMAX/ZAI with model selector (2827acf)
|
||||||
|
- feat(dashboard): show top 5 most used commands as clickable chips (afb6e77)
|
||||||
|
- fix: tab containers height, dashboard 2-row grid, studio scroll buttons (84be226)
|
||||||
|
- feat(shell): dedicated System Analyst AI, no code execution, analyze system (f9c4cf1)
|
||||||
|
- fix: keep all tabs mounted, switch via CSS display instead of unmount (eda7293)
|
||||||
|
- refactor(config): locale panel with edit/save flow like profile (b55feae)
|
||||||
|
- feat(config): split profile into Personal Info + Preferences sections, centered (54621bd)
|
||||||
|
- feat(studio): improve context compression UI and provider display (6bad294)
|
||||||
|
- fix(config): locale panel show active language/keyboard, add save button (92eb783)
|
||||||
|
- feat(config): dynamic profile panel, generic save, tabs margin fix (8005e97)
|
||||||
|
- fix(dashboard): remove bg graphs, add scrollable lists, show used/total quota (6e76e7d)
|
||||||
|
- feat(chat): add auto-summarization with token tracking UI (e8f6dc4)
|
||||||
|
- feat(dashboard): add background graphs to cards and improve layout (bb03c9f)
|
||||||
|
- feat(dashboard): single-view grid with live CPU/RAM/Net graphs, API quota, processes, and sudo indicator (79d0821)
|
||||||
|
- feat(dashboard): add quota monitoring, process list, and command history (7682717)
|
||||||
|
- refactor(chat): deduplicate streaming code, add multi-conv, and XSS protection (3948a4c)
|
||||||
|
- fix(studio): improve chat context, thinking tags, streaming, and tool results (65804aa)
|
||||||
|
- feat: add Cobra CLI, LSP/MCP registries, workflow engine, and enriched dashboard (2e50366)
|
||||||
|
- feat(agent): refactor AI chat with streaming, agent registry, and tool execution (66b773f)
|
||||||
|
- feat(onboarding): add minimax api key step and AI-powered editor scan (bc5c295)
|
||||||
|
- fix(onboarding): require fields before advancing steps (e19122d)
|
||||||
|
- fix: register missing /api/config/reset and /api/starship/apply-theme routes (8b6a7e8)
|
||||||
|
- fix(config): per-provider form state to avoid field cross-talk (58f8cb0)
|
||||||
|
- fix(onboarding): auto-save on done step, keyboard nav, error feedback (b52fecc)
|
||||||
|
- feat(config): add system panel with reset and starship theme, add onboarding wizard (5bbac49)
|
||||||
|
|
||||||
|
### Downloads
|
||||||
|
|
||||||
|
| Platform | File |
|
||||||
|
|----------|------|
|
||||||
|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-linux-amd64.tar.gz) |
|
||||||
|
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-linux-arm64.tar.gz) |
|
||||||
|
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-darwin-amd64.tar.gz) |
|
||||||
|
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-darwin-arm64.tar.gz) |
|
||||||
|
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-windows-amd64.zip) |
|
||||||
|
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-windows-arm64.zip) |
|
||||||
|
|
||||||
|
The binary includes both CLI and Desktop modes.
|
||||||
|
Run `muyue` for TUI, `muyue desktop` for web UI.
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
**Linux (x86_64)**
|
||||||
|
```bash
|
||||||
|
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-linux-amd64.tar.gz | tar xz
|
||||||
|
chmod +x muyue-linux-amd64
|
||||||
|
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**macOS (Apple Silicon)**
|
||||||
|
```bash
|
||||||
|
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-darwin-arm64.tar.gz | tar xz
|
||||||
|
chmod +x muyue-darwin-arm64
|
||||||
|
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows (x86_64)**
|
||||||
|
```powershell
|
||||||
|
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-windows-amd64.zip" -OutFile "muyue.zip"
|
||||||
|
Expand-Archive -Path "muyue.zip" -DestinationPath "."
|
||||||
|
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## v0.3.2
|
||||||
|
|
||||||
|
### Changes since v0.3.1
|
||||||
|
|
||||||
|
- chore: update CHANGELOG for v0.3.2-beta.1 (51a599f)
|
||||||
|
- fix: correct version from 3.2 to 0.3.2 (83d7a57)
|
||||||
|
- chore: bump version to 3.2 (0fe82f6)
|
||||||
|
- refactor(config): remove Terminal sub-tab from Configuration page (3b6cc38)
|
||||||
|
- fix(terminal): init payload never sent due to ws.onopen being overwritten (93a22d4)
|
||||||
|
- fix(terminal): improve shell resolution with better error handling and ws proxy support (e0e1e73)
|
||||||
|
- feat(studio): parse AI thinking and tool launch messages in terminal panel (0496ca7)
|
||||||
|
- fix(studio): forward AI thinking chunks to frontend instead of dropping them (b407ab8)
|
||||||
|
- feat(studio): add tool execution and hide AI thinking tags (12df184)
|
||||||
|
- fix(terminal): ignore invalid shell config from race condition (8af6d25)
|
||||||
|
- feat(shell): restore AI assistant panel (4fd599a)
|
||||||
|
- fix(terminal): restore terminal input and cursor visibility (bcba593)
|
||||||
|
- refactor(api): split monolithic handlers.go into focused modules (04b0fff)
|
||||||
|
|
||||||
|
### Downloads
|
||||||
|
|
||||||
|
| Platform | File |
|
||||||
|
|----------|------|
|
||||||
|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-linux-amd64.tar.gz) |
|
||||||
|
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-linux-arm64.tar.gz) |
|
||||||
|
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-darwin-amd64.tar.gz) |
|
||||||
|
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-darwin-arm64.tar.gz) |
|
||||||
|
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-windows-amd64.zip) |
|
||||||
|
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-windows-arm64.zip) |
|
||||||
|
|
||||||
|
The binary includes both CLI and Desktop modes.
|
||||||
|
Run `muyue` for TUI, `muyue desktop` for web UI.
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
**Linux (x86_64)**
|
||||||
|
```bash
|
||||||
|
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-linux-amd64.tar.gz | tar xz
|
||||||
|
chmod +x muyue-linux-amd64
|
||||||
|
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**macOS (Apple Silicon)**
|
||||||
|
```bash
|
||||||
|
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-darwin-arm64.tar.gz | tar xz
|
||||||
|
chmod +x muyue-darwin-arm64
|
||||||
|
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows (x86_64)**
|
||||||
|
```powershell
|
||||||
|
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-windows-amd64.zip" -OutFile "muyue.zip"
|
||||||
|
Expand-Archive -Path "muyue.zip" -DestinationPath "."
|
||||||
|
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## v0.3.2-beta.1 (Beta)
|
||||||
|
|
||||||
|
### Commits since v0.3.1
|
||||||
|
|
||||||
|
- fix: correct version from 3.2 to 0.3.2 (83d7a57)
|
||||||
|
|
||||||
|
> This is a **beta** release. Use at your own risk.
|
||||||
|
|
||||||
|
## v0.3.1
|
||||||
|
|
||||||
|
### Changes since v0.3.0
|
||||||
|
|
||||||
|
- refactor(config): remove Terminal sub-tab from Configuration page (95bd824)
|
||||||
|
- fix(terminal): init payload never sent due to ws.onopen being overwritten (252f178)
|
||||||
|
- fix(terminal): improve shell resolution with better error handling and ws proxy support (7dcf505)
|
||||||
|
- feat(studio): parse AI thinking and tool launch messages in terminal panel (8fb93fa)
|
||||||
|
- fix(studio): forward AI thinking chunks to frontend instead of dropping them (5ec373c)
|
||||||
|
- feat(studio): add tool execution and hide AI thinking tags (1eb5a6d)
|
||||||
|
- fix(terminal): ignore invalid shell config from race condition (cd5ebe0)
|
||||||
|
- feat(shell): restore AI assistant panel (2004c15)
|
||||||
|
- fix(terminal): restore terminal input and cursor visibility (9306152)
|
||||||
|
- refactor(api): split monolithic handlers.go into focused modules (e15a034)
|
||||||
|
|
||||||
|
### Downloads
|
||||||
|
|
||||||
|
| Platform | File |
|
||||||
|
|----------|------|
|
||||||
|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-linux-amd64.tar.gz) |
|
||||||
|
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-linux-arm64.tar.gz) |
|
||||||
|
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-darwin-amd64.tar.gz) |
|
||||||
|
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-darwin-arm64.tar.gz) |
|
||||||
|
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-windows-amd64.zip) |
|
||||||
|
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-windows-arm64.zip) |
|
||||||
|
|
||||||
|
The binary includes both CLI and Desktop modes.
|
||||||
|
Run `muyue` for TUI, `muyue desktop` for web UI.
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
**Linux (x86_64)**
|
||||||
|
```bash
|
||||||
|
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-linux-amd64.tar.gz | tar xz
|
||||||
|
chmod +x muyue-linux-amd64
|
||||||
|
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**macOS (Apple Silicon)**
|
||||||
|
```bash
|
||||||
|
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-darwin-arm64.tar.gz | tar xz
|
||||||
|
chmod +x muyue-darwin-arm64
|
||||||
|
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows (x86_64)**
|
||||||
|
```powershell
|
||||||
|
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-windows-amd64.zip" -OutFile "muyue.zip"
|
||||||
|
Expand-Archive -Path "muyue.zip" -DestinationPath "."
|
||||||
|
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## v0.3.0
|
||||||
|
|
||||||
|
### Changes since v0.2.1
|
||||||
|
|
||||||
|
- fix(terminal): resolve PTY shell exec error, simplify CLI, unify Config tabs, restore Studio CSS (0b22109)
|
||||||
|
- feat: add API key validation flow for AI provider config (7f67473)
|
||||||
|
- feat(studio): replace sidebar layout with unified execution feed styles (040e482)
|
||||||
|
- fix: guard against empty tabs array in closeTab (c8903ef)
|
||||||
|
- refactor: redesign Config as settings window with sidebar panels, remove system overview from Dashboard (f3cb306)
|
||||||
|
- feat: add multi-tab terminal with SSH support, config editing, and dashboard redesign (3cdcb22)
|
||||||
|
- feat(studio): add i18n keys and CSS for redesigned AI chat interface (ee18bbe)
|
||||||
|
- chore: bump version to 0.3.0 (b0b0e1d)
|
||||||
|
- chore: remove dead code (packages, functions, types, constants) (fc79810)
|
||||||
|
- docs: rewrite README and CHANGELOG for desktop app mode (f7222b0)
|
||||||
|
- feat(web): add i18n support with FR/EN locales and keyboard layout awareness (11417d3)
|
||||||
|
- refactor(web): redesign frontend for native web UX (3dc24ae)
|
||||||
|
- refactor: remove TUI, desktop web UI is now the default and only mode (aa0ff19)
|
||||||
|
- refactor: unify into single `muyue` binary with embedded desktop mode (3463605)
|
||||||
|
- fix(ci): add frontend build step before Go vet/test/build (097cf40)
|
||||||
|
- feat: add desktop app with React frontend, API backend, theme system (#2) (88d2a03)
|
||||||
|
- chore: update CHANGELOG for v0.2.1 (1830c18)
|
||||||
|
- feat: complete TUI redesign with cyberpunk theme (#1) (cb8e3d0)
|
||||||
|
|
||||||
|
### Downloads
|
||||||
|
|
||||||
|
| Platform | File |
|
||||||
|
|----------|------|
|
||||||
|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-linux-amd64.tar.gz) |
|
||||||
|
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-linux-arm64.tar.gz) |
|
||||||
|
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-darwin-amd64.tar.gz) |
|
||||||
|
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-darwin-arm64.tar.gz) |
|
||||||
|
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-windows-amd64.zip) |
|
||||||
|
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-windows-arm64.zip) |
|
||||||
|
|
||||||
|
The binary includes both CLI and Desktop modes.
|
||||||
|
Run `muyue` for TUI, `muyue desktop` for web UI.
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
**Linux (x86_64)**
|
||||||
|
```bash
|
||||||
|
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-linux-amd64.tar.gz | tar xz
|
||||||
|
chmod +x muyue-linux-amd64
|
||||||
|
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**macOS (Apple Silicon)**
|
||||||
|
```bash
|
||||||
|
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-darwin-arm64.tar.gz | tar xz
|
||||||
|
chmod +x muyue-darwin-arm64
|
||||||
|
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows (x86_64)**
|
||||||
|
```powershell
|
||||||
|
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-windows-amd64.zip" -OutFile "muyue.zip"
|
||||||
|
Expand-Archive -Path "muyue.zip" -DestinationPath "."
|
||||||
|
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **Command injection**: Removed non-functional AI sidebar from Shell.jsx that interpolated user input directly into a shell command (`echo "AI: ${text}"`). The panel was a stub with no real AI integration.
|
||||||
|
- **WebSocket origin validation**: Terminal WebSocket handler now validates the `Origin` header matches the server's own host.
|
||||||
|
- **DELETE method guard**: Terminal sessions DELETE endpoint now rejects non-DELETE methods.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Message ID collisions**: `generateMsgID()` now appends nanosecond suffix to prevent collisions under rapid creation.
|
||||||
|
- **Legacy dir migration**: Config migration from `~/.muyue` to XDG path now logs errors instead of silently failing.
|
||||||
|
- **MCP JSON parsing**: `json.Unmarshal` errors in MCP config loading are now handled instead of ignored.
|
||||||
|
- **API header merging**: `client.js` `request()` now correctly merges caller headers with defaults (was overwriting `Content-Type`).
|
||||||
|
- **Variable shadowing**: `t` translation function shadowed by `.filter(t => ...)` in Config.jsx and App.jsx — renamed to `tool`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Real SSE streaming**: Chat endpoint now streams AI responses via SSE (`data: {"content":"..."}` chunks) instead of fake 8-rune chunking. Frontend renders responses progressively as they arrive.
|
||||||
|
- **Progressive rendering**: Studio.jsx now uses `StreamingItem` component to display partial AI output during streaming, with cursor animation.
|
||||||
|
- **Theme from config**: App.jsx loads theme from user profile preferences on startup (was hardcoded to `cyberpunk-red`).
|
||||||
|
- **Handlers split**: Monolithic `handlers.go` split into 6 focused files: `handlers_common.go`, `handlers_info.go`, `handlers_tools.go`, `handlers_config.go`, `handlers_chat.go`, `handlers_terminal.go`.
|
||||||
|
- **Dynamic version**: Config `Version` field now uses `version.Version` constant instead of hardcoded `"0.1.0"`.
|
||||||
|
- **Path construction**: `filepath.Join` used consistently in installer, MCP, scanner, and profiler for cross-platform safety.
|
||||||
|
- **CI Go version**: All 3 CI workflows updated from `go-version: '1.24.3'` to `'1.24'` to match `go.mod`.
|
||||||
|
- **Dead code removed**: Unused `addNotif` function in Dashboard.jsx, unused `layout` destructuring, dead `tools`/`updates`/`onRescan` props, dead AI sidebar in Shell.jsx, associated CSS and i18n keys.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **SendStream tests**: 3 new tests for the SSE streaming method (chunk parsing, history accumulation, API error handling) using `httptest` server.
|
||||||
|
|
||||||
- **Desktop mode**: React 19 web UI served locally, auto-opens in browser. Frontend embedded in Go binary via `go:embed`.
|
- **Desktop mode**: React 19 web UI served locally, auto-opens in browser. Frontend embedded in Go binary via `go:embed`.
|
||||||
- **API backend**: 15 REST endpoints (`/api/info`, `/api/system`, `/api/tools`, `/api/config`, `/api/providers`, `/api/skills`, `/api/lsp`, `/api/mcp`, `/api/updates`, `/api/scan`, `/api/install`, `/api/terminal`, `/api/mcp/configure`, `/api/preferences`).
|
- **API backend**: 15 REST endpoints (`/api/info`, `/api/system`, `/api/tools`, `/api/config`, `/api/providers`, `/api/skills`, `/api/lsp`, `/api/mcp`, `/api/updates`, `/api/scan`, `/api/install`, `/api/terminal`, `/api/mcp/configure`, `/api/preferences`).
|
||||||
- **i18n**: Full FR/EN translation system with keyboard layout awareness (AZERTY, QWERTY, QWERTZ). Preferences synced to backend.
|
- **i18n**: Full FR/EN translation system with keyboard layout awareness (AZERTY, QWERTY, QWERTZ). Preferences synced to backend.
|
||||||
|
|||||||
1073
CRUSH_ARCHITECTURE_REPORT.md
Normal file
BIN
LogoMuyue.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
assets/muyue-128.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
assets/muyue-16.png
Normal file
|
After Width: | Height: | Size: 750 B |
BIN
assets/muyue-256.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
assets/muyue-32.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
assets/muyue-512.png
Normal file
|
After Width: | Height: | Size: 307 KiB |
BIN
assets/muyue-64.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
assets/muyue.ico
Normal file
|
After Width: | Height: | Size: 119 KiB |
59
cmd/muyue/commands/config.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/config"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var configCmd = &cobra.Command{
|
||||||
|
Use: "config",
|
||||||
|
Short: "Show/print config",
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(configCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runConfigGet(cmd *cobra.Command, args []string) error {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
key := args[0]
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "%v\n", getConfigValue(cfg, key))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getConfigValue(cfg *config.MuyueConfig, key string) interface{} {
|
||||||
|
switch key {
|
||||||
|
case "version":
|
||||||
|
return cfg.Version
|
||||||
|
case "profile.name":
|
||||||
|
return cfg.Profile.Name
|
||||||
|
case "profile.email":
|
||||||
|
return cfg.Profile.Email
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runConfigSet(cmd *cobra.Command, args []string) error {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
key, value := args[0], args[1]
|
||||||
|
setConfigValue(cfg, key, value)
|
||||||
|
return config.Save(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setConfigValue(cfg *config.MuyueConfig, key, value string) {
|
||||||
|
switch key {
|
||||||
|
case "profile.name":
|
||||||
|
cfg.Profile.Name = value
|
||||||
|
case "profile.email":
|
||||||
|
cfg.Profile.Email = value
|
||||||
|
}
|
||||||
|
}
|
||||||
77
cmd/muyue/commands/doctor.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/config"
|
||||||
|
"github.com/muyue/muyue/internal/scanner"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var doctorCmd = &cobra.Command{
|
||||||
|
Use: "doctor",
|
||||||
|
Short: "Diagnose issues (scan + config check + connectivity)",
|
||||||
|
RunE: runDoctor,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(doctorCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDoctor(cmd *cobra.Command, args []string) error {
|
||||||
|
fmt.Println("Running Muyue diagnostics...")
|
||||||
|
|
||||||
|
fmt.Println("\n=== System Scan ===")
|
||||||
|
result := scanner.ScanSystem()
|
||||||
|
for _, t := range result.Tools {
|
||||||
|
status := "✓"
|
||||||
|
if !t.Installed {
|
||||||
|
status = "✗"
|
||||||
|
}
|
||||||
|
fmt.Printf(" %s %s\n", status, t.Name)
|
||||||
|
}
|
||||||
|
fmt.Printf("\nInstalled: %d/%d\n", countInstalled(result.Tools), len(result.Tools))
|
||||||
|
|
||||||
|
fmt.Println("\n=== Config Check ===")
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" ✗ Failed to load config: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" ✓ Config loaded (version: %s)\n", cfg.Version)
|
||||||
|
if cfg.Profile.Name != "" {
|
||||||
|
fmt.Printf(" ✓ Profile: %s\n", cfg.Profile.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\n=== Connectivity ===")
|
||||||
|
endpoints := []string{
|
||||||
|
"https://api.minimax.io",
|
||||||
|
"https://api.openai.com",
|
||||||
|
}
|
||||||
|
for _, ep := range endpoints {
|
||||||
|
fmt.Printf(" Checking %s... ", ep)
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
resp, err := client.Head(ep)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("✗ (%v)\n", err)
|
||||||
|
} else {
|
||||||
|
resp.Body.Close()
|
||||||
|
fmt.Printf("✓ (status %d)\n", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\n=== Diagnosis complete ===")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func countInstalled(tools []scanner.ToolStatus) int {
|
||||||
|
installed := 0
|
||||||
|
for _, t := range tools {
|
||||||
|
if t.Installed {
|
||||||
|
installed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return installed
|
||||||
|
}
|
||||||
56
cmd/muyue/commands/install.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/installer"
|
||||||
|
"github.com/muyue/muyue/internal/scanner"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var installCmd = &cobra.Command{
|
||||||
|
Use: "install [tool]",
|
||||||
|
Short: "Install missing tools",
|
||||||
|
Args: cobra.RangeArgs(0, 1),
|
||||||
|
RunE: runInstall,
|
||||||
|
}
|
||||||
|
|
||||||
|
var installYes bool
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(installCmd)
|
||||||
|
installCmd.Flags().BoolVar(&installYes, "yes", false, "Skip confirmation")
|
||||||
|
}
|
||||||
|
|
||||||
|
func runInstall(cmd *cobra.Command, args []string) error {
|
||||||
|
var tools []string
|
||||||
|
if len(args) > 0 {
|
||||||
|
tools = args
|
||||||
|
}
|
||||||
|
|
||||||
|
inst := installer.New(nil)
|
||||||
|
if len(tools) == 0 {
|
||||||
|
result := scanner.ScanSystem()
|
||||||
|
for _, t := range result.Tools {
|
||||||
|
if !t.Installed {
|
||||||
|
tools = append(tools, t.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(tools) == 0 {
|
||||||
|
fmt.Println("All tools already installed!")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Printf("Installing missing tools: %v\n", tools)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tool := range tools {
|
||||||
|
fmt.Printf("Installing %s...\n", tool)
|
||||||
|
res := inst.InstallTool(tool)
|
||||||
|
if res.Success {
|
||||||
|
fmt.Printf("✓ %s: %s\n", tool, res.Message)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("✗ %s: %s\n", tool, res.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
151
cmd/muyue/commands/install_shortcuts.go
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// installShortcutsCmd creates desktop + Start Menu shortcuts on Windows so
|
||||||
|
// non-technical users can launch Muyue without opening a terminal. It also
|
||||||
|
// adds the install directory to the user's PATH (per-user, no admin).
|
||||||
|
//
|
||||||
|
// Implementation note: shortcut (.lnk) creation on Windows is most reliable
|
||||||
|
// via WScript.Shell COM. We invoke it via PowerShell — keeps the Go binary
|
||||||
|
// dependency-free and works on any Windows 10+ host.
|
||||||
|
var installShortcutsCmd = &cobra.Command{
|
||||||
|
Use: "install-shortcuts",
|
||||||
|
Short: "Create Desktop + Start Menu shortcuts (Windows only) and add Muyue to PATH",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
fmt.Println("install-shortcuts is a Windows-only command (no-op on this platform)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("locate executable: %w", err)
|
||||||
|
}
|
||||||
|
exe, _ = filepath.Abs(exe)
|
||||||
|
installDir := filepath.Dir(exe)
|
||||||
|
|
||||||
|
fmt.Println("Installing Muyue shortcuts...")
|
||||||
|
fmt.Printf(" Executable : %s\n", exe)
|
||||||
|
|
||||||
|
desktop, err := userShellFolder("Desktop")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("locate Desktop folder: %w", err)
|
||||||
|
}
|
||||||
|
startMenu, err := userShellFolder("Programs")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("locate Start Menu Programs folder: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
desktopLnk := filepath.Join(desktop, "Muyue.lnk")
|
||||||
|
startLnk := filepath.Join(startMenu, "Muyue.lnk")
|
||||||
|
|
||||||
|
if err := createWindowsShortcut(desktopLnk, exe, installDir, "Muyue — AI-powered dev environment"); err != nil {
|
||||||
|
return fmt.Errorf("create desktop shortcut: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf(" Desktop : %s\n", desktopLnk)
|
||||||
|
|
||||||
|
if err := createWindowsShortcut(startLnk, exe, installDir, "Muyue — AI-powered dev environment"); err != nil {
|
||||||
|
return fmt.Errorf("create Start Menu shortcut: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf(" Start Menu : %s\n", startLnk)
|
||||||
|
|
||||||
|
if err := addUserPATH(installDir); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, " PATH : warning — could not add %s to user PATH: %v\n", installDir, err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" PATH : added %s (open a new terminal to pick it up)\n", installDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\nDone — double-click the Muyue icon on your Desktop to launch.")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(installShortcutsCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// userShellFolder asks Windows for a user shell folder via PowerShell —
|
||||||
|
// resilient to OneDrive redirection and non-default profile locations.
|
||||||
|
// `which` is one of: Desktop, Programs (Start Menu Programs), StartMenu.
|
||||||
|
func userShellFolder(which string) (string, error) {
|
||||||
|
ps := fmt.Sprintf(`[Environment]::GetFolderPath('%s')`, which)
|
||||||
|
out, err := exec.Command("powershell", "-NoLogo", "-NoProfile", "-Command", ps).Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
path := stripTrailingWhitespace(string(out))
|
||||||
|
if path == "" {
|
||||||
|
return "", fmt.Errorf("empty path for %s", which)
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripTrailingWhitespace(s string) string {
|
||||||
|
for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r' || s[len(s)-1] == ' ' || s[len(s)-1] == '\t') {
|
||||||
|
s = s[:len(s)-1]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// createWindowsShortcut generates a .lnk via WScript.Shell COM. The arguments
|
||||||
|
// are passed through PowerShell variables (not interpolated into the script
|
||||||
|
// body) to avoid quoting issues with paths containing spaces or special chars.
|
||||||
|
func createWindowsShortcut(lnkPath, target, workingDir, description string) error {
|
||||||
|
script := `
|
||||||
|
$lnk = $env:MUYUE_LNK
|
||||||
|
$target = $env:MUYUE_TARGET
|
||||||
|
$workdir = $env:MUYUE_WORKDIR
|
||||||
|
$desc = $env:MUYUE_DESC
|
||||||
|
$wsh = New-Object -ComObject WScript.Shell
|
||||||
|
$sc = $wsh.CreateShortcut($lnk)
|
||||||
|
$sc.TargetPath = $target
|
||||||
|
$sc.WorkingDirectory = $workdir
|
||||||
|
$sc.Description = $desc
|
||||||
|
$sc.IconLocation = "$target,0"
|
||||||
|
$sc.Save()
|
||||||
|
`
|
||||||
|
cmd := exec.Command("powershell", "-NoLogo", "-NoProfile", "-Command", script)
|
||||||
|
cmd.Env = append(os.Environ(),
|
||||||
|
"MUYUE_LNK="+lnkPath,
|
||||||
|
"MUYUE_TARGET="+target,
|
||||||
|
"MUYUE_WORKDIR="+workingDir,
|
||||||
|
"MUYUE_DESC="+description,
|
||||||
|
)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("powershell: %v: %s", err, string(out))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// addUserPATH appends installDir to the user's PATH if not already present.
|
||||||
|
// Uses PowerShell to read/write the User-scope environment via .NET API,
|
||||||
|
// which broadcasts WM_SETTINGCHANGE so new processes pick it up.
|
||||||
|
func addUserPATH(installDir string) error {
|
||||||
|
script := `
|
||||||
|
$dir = $env:MUYUE_INSTALL_DIR
|
||||||
|
$current = [Environment]::GetEnvironmentVariable('Path', 'User')
|
||||||
|
if ($current -eq $null) { $current = '' }
|
||||||
|
$parts = $current -split ';' | Where-Object { $_ -ne '' }
|
||||||
|
if ($parts -notcontains $dir) {
|
||||||
|
$new = if ($current -eq '') { $dir } else { "$current;$dir" }
|
||||||
|
[Environment]::SetEnvironmentVariable('Path', $new, 'User')
|
||||||
|
}
|
||||||
|
`
|
||||||
|
cmd := exec.Command("powershell", "-NoLogo", "-NoProfile", "-Command", script)
|
||||||
|
cmd.Env = append(os.Environ(), "MUYUE_INSTALL_DIR="+installDir)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("powershell: %v: %s", err, string(out))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
55
cmd/muyue/commands/lsp.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/lsp"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var lspCmd = &cobra.Command{
|
||||||
|
Use: "lsp",
|
||||||
|
Short: "LSP management",
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(lspCmd)
|
||||||
|
lspCmd.AddCommand(&cobra.Command{
|
||||||
|
Use: "scan",
|
||||||
|
Short: "Scan for installed LSPs",
|
||||||
|
RunE: runLSPScan,
|
||||||
|
})
|
||||||
|
lspCmd.AddCommand(&cobra.Command{
|
||||||
|
Use: "install [name]",
|
||||||
|
Short: "Install LSP server(s)",
|
||||||
|
Args: cobra.RangeArgs(0, 1),
|
||||||
|
RunE: runLSPInstall,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func runLSPScan(cmd *cobra.Command, args []string) error {
|
||||||
|
servers := lsp.ScanServers()
|
||||||
|
fmt.Printf("%-25s %-15s %-10s\n", "Name", "Language", "Status")
|
||||||
|
fmt.Println("──────────────────────────────────────────")
|
||||||
|
for _, s := range servers {
|
||||||
|
status := "✗ missing"
|
||||||
|
if s.Installed {
|
||||||
|
status = "✓ installed"
|
||||||
|
}
|
||||||
|
fmt.Printf("%-25s %-15s %-10s\n", s.Name, s.Language, status)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runLSPInstall(cmd *cobra.Command, args []string) error {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return fmt.Errorf("server name required")
|
||||||
|
}
|
||||||
|
name := args[0]
|
||||||
|
fmt.Printf("Installing %s...\n", name)
|
||||||
|
if err := lsp.InstallServer(name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("✓ %s installed\n", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
54
cmd/muyue/commands/mcp.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/config"
|
||||||
|
"github.com/muyue/muyue/internal/mcp"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var mcpCmd = &cobra.Command{
|
||||||
|
Use: "mcp",
|
||||||
|
Short: "MCP management",
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(mcpCmd)
|
||||||
|
mcpCmd.AddCommand(&cobra.Command{
|
||||||
|
Use: "config",
|
||||||
|
Short: "Generate MCP configs for Crush + Claude Code",
|
||||||
|
RunE: runMCPConfig,
|
||||||
|
})
|
||||||
|
mcpCmd.AddCommand(&cobra.Command{
|
||||||
|
Use: "scan",
|
||||||
|
Short: "Scan available MCP servers",
|
||||||
|
RunE: runMCPScan,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMCPConfig(cmd *cobra.Command, args []string) error {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := mcp.ConfigureAll(cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("MCP configs generated for Crush and Claude Code")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMCPScan(cmd *cobra.Command, args []string) error {
|
||||||
|
servers := mcp.ScanServers()
|
||||||
|
fmt.Printf("%-25s %-15s %-10s\n", "Name", "Category", "Status")
|
||||||
|
fmt.Println("──────────────────────────────────────────")
|
||||||
|
for _, s := range servers {
|
||||||
|
status := "✗ missing"
|
||||||
|
if s.Installed {
|
||||||
|
status = "✓ installed"
|
||||||
|
}
|
||||||
|
fmt.Printf("%-25s %-15s %-10s\n", s.Name, s.Category, status)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
66
cmd/muyue/commands/root.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/config"
|
||||||
|
"github.com/muyue/muyue/internal/desktop"
|
||||||
|
"github.com/muyue/muyue/internal/profiler"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var rootCmd = &cobra.Command{
|
||||||
|
Use: "muyue",
|
||||||
|
Short: "Muyue is your AI-powered development companion",
|
||||||
|
Long: `Muyue - A modern development environment with AI assistance, tool management, and seamless desktop integration.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cfg := loadOrSetupConfig()
|
||||||
|
return desktop.Run(cfg, os.Args[1:])
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func Execute() error {
|
||||||
|
return rootCmd.Execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadOrSetupConfig() *config.MuyueConfig {
|
||||||
|
if !config.Exists() {
|
||||||
|
fmt.Println("First time setup detected!")
|
||||||
|
cfg, err := profiler.RunFirstTimeSetup()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Setup error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range cfg.AI.Providers {
|
||||||
|
if cfg.AI.Providers[i].Active && cfg.AI.Providers[i].APIKey == "" {
|
||||||
|
key, err := profiler.AskAPIKey(cfg.AI.Providers[i].Name)
|
||||||
|
if err == nil && key != "" {
|
||||||
|
cfg.AI.Providers[i].APIKey = key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.Save(cfg); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Save error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\nSetup complete! Starting muyue...")
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Config load error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.PersistentFlags().Int("port", 8080, "HTTP port for the desktop server")
|
||||||
|
rootCmd.PersistentFlags().Bool("no-open", false, "Don't open browser on startup")
|
||||||
|
}
|
||||||
56
cmd/muyue/commands/scan.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/scanner"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var scanCmd = &cobra.Command{
|
||||||
|
Use: "scan",
|
||||||
|
Short: "Run system scan and print results table",
|
||||||
|
RunE: runScan,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(scanCmd)
|
||||||
|
scanCmd.Flags().Bool("json", false, "Output results as JSON")
|
||||||
|
}
|
||||||
|
|
||||||
|
func runScan(cmd *cobra.Command, args []string) error {
|
||||||
|
useJSON, _ := cmd.Flags().GetBool("json")
|
||||||
|
result := scanner.ScanSystem()
|
||||||
|
|
||||||
|
if useJSON {
|
||||||
|
data, err := json.MarshalIndent(result, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println(string(data))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%-15s %-20s %-10s %-10s\n", "Tool", "Version", "Status", "Path")
|
||||||
|
fmt.Println("─────────────────────────────────────────────────")
|
||||||
|
for _, t := range result.Tools {
|
||||||
|
status := "✓ installed"
|
||||||
|
if !t.Installed {
|
||||||
|
status = "✗ missing"
|
||||||
|
}
|
||||||
|
fmt.Printf("%-15s %-20s %-10s %-10s\n", t.Name, t.Version, status, t.Path)
|
||||||
|
}
|
||||||
|
fmt.Printf("\n% d/%d tools installed\n", len(result.Tools) - countMissing(result.Tools), len(result.Tools))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func countMissing(tools []scanner.ToolStatus) int {
|
||||||
|
missing := 0
|
||||||
|
for _, t := range tools {
|
||||||
|
if !t.Installed {
|
||||||
|
missing++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return missing
|
||||||
|
}
|
||||||
39
cmd/muyue/commands/setup.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/config"
|
||||||
|
"github.com/muyue/muyue/internal/profiler"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var setupCmd = &cobra.Command{
|
||||||
|
Use: "setup",
|
||||||
|
Short: "Run first-run wizard (profiler)",
|
||||||
|
RunE: runSetup,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(setupCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSetup(cmd *cobra.Command, args []string) error {
|
||||||
|
cfg, err := profiler.RunFirstTimeSetup()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range cfg.AI.Providers {
|
||||||
|
if cfg.AI.Providers[i].Active && cfg.AI.Providers[i].APIKey == "" {
|
||||||
|
key, err := profiler.AskAPIKey(cfg.AI.Providers[i].Name)
|
||||||
|
if err == nil && key != "" {
|
||||||
|
cfg.AI.Providers[i].APIKey = key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := config.Save(cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("Setup complete!")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
105
cmd/muyue/commands/skills.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/skills"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var skillsCmd = &cobra.Command{
|
||||||
|
Use: "skills",
|
||||||
|
Short: "Skills management",
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(skillsCmd)
|
||||||
|
skillsCmd.AddCommand(&cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List installed skills",
|
||||||
|
RunE: runSkillsList,
|
||||||
|
})
|
||||||
|
skillsCmd.AddCommand(&cobra.Command{
|
||||||
|
Use: "init",
|
||||||
|
Short: "Install built-in skills",
|
||||||
|
RunE: runSkillsInit,
|
||||||
|
})
|
||||||
|
skillsCmd.AddCommand(&cobra.Command{
|
||||||
|
Use: "show [name]",
|
||||||
|
Short: "Show skill details",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: runSkillsShow,
|
||||||
|
})
|
||||||
|
skillsCmd.AddCommand(&cobra.Command{
|
||||||
|
Use: "generate [name] [description]",
|
||||||
|
Short: "AI-generate a skill",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: runSkillsGenerate,
|
||||||
|
})
|
||||||
|
skillsCmd.AddCommand(&cobra.Command{
|
||||||
|
Use: "deploy",
|
||||||
|
Short: "Deploy skills to Crush/Claude Code",
|
||||||
|
RunE: runSkillsDeploy,
|
||||||
|
})
|
||||||
|
skillsCmd.AddCommand(&cobra.Command{
|
||||||
|
Use: "delete [name]",
|
||||||
|
Short: "Delete a skill",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: runSkillsDelete,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSkillsList(cmd *cobra.Command, args []string) error {
|
||||||
|
list, err := skills.List()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(list) == 0 {
|
||||||
|
fmt.Println("No skills installed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Printf("%-20s %-40s\n", "Name", "Description")
|
||||||
|
fmt.Println("─────────────────────────────────────────────────────")
|
||||||
|
for _, s := range list {
|
||||||
|
fmt.Printf("%-20s %-40s\n", s.Name, s.Description)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSkillsInit(cmd *cobra.Command, args []string) error {
|
||||||
|
fmt.Println("Initializing built-in skills...")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSkillsShow(cmd *cobra.Command, args []string) error {
|
||||||
|
name := args[0]
|
||||||
|
skill, err := skills.Get(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("Name: %s\nDescription: %s\nAuthor: %s\nVersion: %s\n\n%s\n",
|
||||||
|
skill.Name, skill.Description, skill.Author, skill.Version, skill.Content)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSkillsGenerate(cmd *cobra.Command, args []string) error {
|
||||||
|
fmt.Printf("Generating skill '%s': %s\n", args[0], args[1])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSkillsDeploy(cmd *cobra.Command, args []string) error {
|
||||||
|
if err := skills.DeployAll(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("All skills deployed to Crush and Claude Code")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSkillsDelete(cmd *cobra.Command, args []string) error {
|
||||||
|
name := args[0]
|
||||||
|
if err := skills.Delete(name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("Skill '%s' deleted\n", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
80
cmd/muyue/commands/update.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/scanner"
|
||||||
|
"github.com/muyue/muyue/internal/updater"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var updateCmd = &cobra.Command{
|
||||||
|
Use: "update [tool]",
|
||||||
|
Short: "Check and apply updates",
|
||||||
|
Args: cobra.RangeArgs(0, 1),
|
||||||
|
RunE: runUpdate,
|
||||||
|
}
|
||||||
|
|
||||||
|
var checkOnly bool
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(updateCmd)
|
||||||
|
updateCmd.Flags().BoolVar(&checkOnly, "check", false, "Check only, don't update")
|
||||||
|
}
|
||||||
|
|
||||||
|
func runUpdate(cmd *cobra.Command, args []string) error {
|
||||||
|
result := scanner.ScanSystem()
|
||||||
|
statuses := updater.CheckUpdates(result)
|
||||||
|
|
||||||
|
if len(args) > 0 {
|
||||||
|
for _, u := range statuses {
|
||||||
|
if u.Tool == args[0] {
|
||||||
|
if u.NeedsUpdate {
|
||||||
|
fmt.Printf("%s: %s → %s\n", u.Tool, u.Current, u.Latest)
|
||||||
|
if !checkOnly {
|
||||||
|
updater.RunAutoUpdate([]updater.UpdateStatus{u})
|
||||||
|
fmt.Println("Updated!")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%s is up to date (%s)\n", u.Tool, u.Current)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf("Tool '%s' not found\n", args[0])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%-15s %-10s %-10s %-10s\n", "Tool", "Current", "Latest", "Status")
|
||||||
|
fmt.Println("─────────────────────────────────────────")
|
||||||
|
hasUpdates := false
|
||||||
|
for _, u := range statuses {
|
||||||
|
status := "✓"
|
||||||
|
if u.NeedsUpdate {
|
||||||
|
status = "⟳ update"
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
if u.Error != "" {
|
||||||
|
status = "✗ " + u.Error
|
||||||
|
}
|
||||||
|
fmt.Printf("%-15s %-10s %-10s %-10s\n", u.Tool, u.Current, u.Latest, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if checkOnly {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasUpdates {
|
||||||
|
toUpdate := make([]updater.UpdateStatus, 0)
|
||||||
|
for _, u := range statuses {
|
||||||
|
if u.NeedsUpdate {
|
||||||
|
toUpdate = append(toUpdate, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updater.RunAutoUpdate(toUpdate)
|
||||||
|
fmt.Println("\nUpdates applied.")
|
||||||
|
} else {
|
||||||
|
fmt.Println("\nAll tools are up to date.")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
23
cmd/muyue/commands/version.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/version"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var versionCmd = &cobra.Command{
|
||||||
|
Use: "version",
|
||||||
|
Short: "Print version info",
|
||||||
|
RunE: runVersion,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(versionCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runVersion(cmd *cobra.Command, args []string) error {
|
||||||
|
fmt.Print(version.FullInfo())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -3,590 +3,13 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
|
|
||||||
"github.com/muyue/muyue/internal/config"
|
"github.com/muyue/muyue/cmd/muyue/commands"
|
||||||
"github.com/muyue/muyue/internal/desktop"
|
|
||||||
"github.com/muyue/muyue/internal/installer"
|
|
||||||
"github.com/muyue/muyue/internal/lsp"
|
|
||||||
"github.com/muyue/muyue/internal/mcp"
|
|
||||||
"github.com/muyue/muyue/internal/orchestrator"
|
|
||||||
"github.com/muyue/muyue/internal/profiler"
|
|
||||||
"github.com/muyue/muyue/internal/scanner"
|
|
||||||
"github.com/muyue/muyue/internal/skills"
|
|
||||||
"github.com/muyue/muyue/internal/updater"
|
|
||||||
"github.com/muyue/muyue/internal/version"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if len(os.Args) > 1 {
|
if err := commands.Execute(); err != nil {
|
||||||
if isCommand(os.Args[1]) {
|
|
||||||
handleCommand(os.Args[1:])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
runDesktop(os.Args[1:])
|
|
||||||
}
|
|
||||||
|
|
||||||
func isCommand(arg string) bool {
|
|
||||||
switch arg {
|
|
||||||
case "version", "-v", "--version",
|
|
||||||
"scan", "install", "update", "setup",
|
|
||||||
"config", "doctor", "lsp", "mcp", "skills",
|
|
||||||
"help", "-h", "--help":
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleCommand(args []string) {
|
|
||||||
switch args[0] {
|
|
||||||
case "version", "-v", "--version":
|
|
||||||
fmt.Println(version.FullVersion())
|
|
||||||
case "scan":
|
|
||||||
runScan()
|
|
||||||
case "install":
|
|
||||||
runInstall(args[1:])
|
|
||||||
case "update":
|
|
||||||
runUpdate()
|
|
||||||
case "setup":
|
|
||||||
runSetup()
|
|
||||||
case "config":
|
|
||||||
showConfig()
|
|
||||||
case "doctor":
|
|
||||||
runDoctor()
|
|
||||||
case "lsp":
|
|
||||||
runLSP(args[1:])
|
|
||||||
case "mcp":
|
|
||||||
runMCP(args[1:])
|
|
||||||
case "skills":
|
|
||||||
runSkills(args[1:])
|
|
||||||
case "help", "-h", "--help":
|
|
||||||
printHelp()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func printHelp() {
|
|
||||||
fmt.Printf(`%s - AI-powered development environment assistant
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
muyue Launch desktop app (opens browser)
|
|
||||||
muyue <command> Run a specific command
|
|
||||||
|
|
||||||
Options:
|
|
||||||
--port=PORT Specify port (default: auto)
|
|
||||||
--no-open Don't open browser automatically
|
|
||||||
|
|
||||||
Commands:
|
|
||||||
version Show version
|
|
||||||
scan Scan your system for tools and runtimes
|
|
||||||
install [tools] Install missing tools (needs sudo for some tools)
|
|
||||||
update Check and apply updates for all tools
|
|
||||||
setup Run first-time setup wizard
|
|
||||||
config Show current configuration
|
|
||||||
doctor Check that everything is properly configured
|
|
||||||
lsp [scan|install] Scan or install LSP servers
|
|
||||||
mcp [config|scan] Configure MCP servers for Crush and Claude Code
|
|
||||||
skills [list|generate|deploy|init|delete] Manage AI coding skills
|
|
||||||
help Show this help
|
|
||||||
|
|
||||||
Note:
|
|
||||||
Some tools (docker, gh, etc.) require elevated privileges.
|
|
||||||
Run 'sudo muyue install' or use 'pkexec muyue install' if needed.
|
|
||||||
`, version.FullVersion())
|
|
||||||
}
|
|
||||||
|
|
||||||
func runDesktop(args []string) {
|
|
||||||
cfg := loadOrSetupConfig()
|
|
||||||
if err := desktop.Run(cfg, args); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadOrSetupConfig() *config.MuyueConfig {
|
|
||||||
if !config.Exists() {
|
|
||||||
fmt.Println("First time setup detected!")
|
|
||||||
cfg, err := profiler.RunFirstTimeSetup()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Setup error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range cfg.AI.Providers {
|
|
||||||
if cfg.AI.Providers[i].Active && cfg.AI.Providers[i].APIKey == "" {
|
|
||||||
key, err := profiler.AskAPIKey(cfg.AI.Providers[i].Name)
|
|
||||||
if err == nil && key != "" {
|
|
||||||
cfg.AI.Providers[i].APIKey = key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := config.Save(cfg); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Save error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("\nSetup complete! Starting muyue...")
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg, err := config.Load()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Config load error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
func runScan() {
|
|
||||||
fmt.Println("Scanning system...")
|
|
||||||
result := scanner.ScanSystem()
|
|
||||||
fmt.Println(result.Summary())
|
|
||||||
}
|
|
||||||
|
|
||||||
func runInstall(tools []string) {
|
|
||||||
cfg := loadOrSetupConfig()
|
|
||||||
inst := installer.New(cfg)
|
|
||||||
|
|
||||||
if len(tools) == 0 {
|
|
||||||
result := scanner.ScanSystem()
|
|
||||||
var missing []string
|
|
||||||
for _, t := range result.Tools {
|
|
||||||
if !t.Installed {
|
|
||||||
missing = append(missing, t.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(missing) == 0 {
|
|
||||||
fmt.Println("All tools are installed!")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Missing tools: %v\nInstalling...\n", missing)
|
|
||||||
tools = missing
|
|
||||||
}
|
|
||||||
|
|
||||||
if needsSudo(tools) && os.Geteuid() != 0 {
|
|
||||||
fmt.Println("Some tools require elevated privileges.")
|
|
||||||
if path, err := exec.LookPath("sudo"); err == nil {
|
|
||||||
fmt.Printf("Re-running with sudo...\n")
|
|
||||||
cmd := exec.Command(path, append([]string{os.Args[0], "install"}, tools...)...)
|
|
||||||
cmd.Stdin = os.Stdin
|
|
||||||
cmd.Stdout = os.Stdout
|
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "sudo install failed: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
config.Save(cfg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if path, err := exec.LookPath("pkexec"); err == nil {
|
|
||||||
fmt.Printf("Re-running with pkexec...\n")
|
|
||||||
cmd := exec.Command(path, append([]string{os.Args[0], "install"}, tools...)...)
|
|
||||||
cmd.Stdin = os.Stdin
|
|
||||||
cmd.Stdout = os.Stdout
|
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "pkexec install failed: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
config.Save(cfg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Println("Neither sudo nor pkexec found. Some installs may fail.")
|
|
||||||
fmt.Println("Try running: sudo muyue install")
|
|
||||||
}
|
|
||||||
|
|
||||||
results := inst.InstallAll(tools)
|
|
||||||
for _, r := range results {
|
|
||||||
status := "[OK]"
|
|
||||||
if !r.Success {
|
|
||||||
status = "[FAIL]"
|
|
||||||
}
|
|
||||||
fmt.Printf(" %s %s: %s\n", status, r.Tool, r.Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
config.Save(cfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
func needsSudo(tools []string) bool {
|
|
||||||
sudoTools := map[string]bool{
|
|
||||||
"docker": true, "git": true, "gh": true, "node": true, "python": true,
|
|
||||||
}
|
|
||||||
for _, t := range tools {
|
|
||||||
if sudoTools[t] {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func runUpdate() {
|
|
||||||
fmt.Println("Checking for updates...")
|
|
||||||
result := scanner.ScanSystem()
|
|
||||||
statuses := updater.CheckUpdates(result)
|
|
||||||
|
|
||||||
needsUpdate := false
|
|
||||||
for _, s := range statuses {
|
|
||||||
if s.NeedsUpdate {
|
|
||||||
fmt.Printf(" [!] %s: %s -> %s\n", s.Tool, s.Current, s.Latest)
|
|
||||||
needsUpdate = true
|
|
||||||
} else if s.Error == "" {
|
|
||||||
fmt.Printf(" [v] %s: up to date (%s)\n", s.Tool, s.Current)
|
|
||||||
} else {
|
|
||||||
fmt.Printf(" [?] %s: %s\n", s.Tool, s.Error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if needsUpdate {
|
|
||||||
fmt.Println("\nApplying updates...")
|
|
||||||
results := updater.RunAutoUpdate(statuses)
|
|
||||||
for _, r := range results {
|
|
||||||
fmt.Printf(" %s: %s\n", r.Tool, r.Message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func runSetup() {
|
|
||||||
cfg, err := profiler.RunFirstTimeSetup()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Setup error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range cfg.AI.Providers {
|
|
||||||
if cfg.AI.Providers[i].Active && cfg.AI.Providers[i].APIKey == "" {
|
|
||||||
key, err := profiler.AskAPIKey(cfg.AI.Providers[i].Name)
|
|
||||||
if err == nil && key != "" {
|
|
||||||
cfg.AI.Providers[i].APIKey = key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := config.Save(cfg); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Save error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("Setup complete!")
|
|
||||||
}
|
|
||||||
|
|
||||||
func showConfig() {
|
|
||||||
cfg, err := config.Load()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Config not found. Run `muyue setup` first.\n")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Profile: %s (%s)\n", cfg.Profile.Name, cfg.Profile.Pseudo)
|
|
||||||
fmt.Printf("Email: %s\n", cfg.Profile.Email)
|
|
||||||
fmt.Printf("Editor: %s\n", cfg.Profile.Preferences.Editor)
|
|
||||||
fmt.Printf("Default AI: %s\n", cfg.Profile.Preferences.DefaultAI)
|
|
||||||
fmt.Printf("Languages: %v\n", cfg.Profile.Languages)
|
|
||||||
|
|
||||||
for _, p := range cfg.AI.Providers {
|
|
||||||
active := ""
|
|
||||||
if p.Active {
|
|
||||||
active = " (active)"
|
|
||||||
}
|
|
||||||
keyStatus := "no key"
|
|
||||||
if p.APIKey != "" {
|
|
||||||
keyStatus = "configured"
|
|
||||||
}
|
|
||||||
fmt.Printf(" %s: model=%s, key=%s%s\n", p.Name, p.Model, keyStatus, active)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("BMAD: installed=%v, global=%v\n", cfg.BMAD.Installed, cfg.BMAD.Global)
|
|
||||||
fmt.Printf("Custom Prompt: %v\n", cfg.Terminal.CustomPrompt)
|
|
||||||
}
|
|
||||||
|
|
||||||
func runDoctor() {
|
|
||||||
ok := true
|
|
||||||
fmt.Println("Running diagnostics...")
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
fmt.Println("Configuration:")
|
|
||||||
if !config.Exists() {
|
|
||||||
fmt.Println(" [FAIL] Config file not found. Run 'muyue setup' first.")
|
|
||||||
ok = false
|
|
||||||
} else {
|
|
||||||
cfg, err := config.Load()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf(" [FAIL] Config load error: %v\n", err)
|
|
||||||
ok = false
|
|
||||||
} else {
|
|
||||||
fmt.Println(" [OK] Config file present")
|
|
||||||
hasKey := false
|
|
||||||
for _, p := range cfg.AI.Providers {
|
|
||||||
if p.Active && p.APIKey != "" {
|
|
||||||
hasKey = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if hasKey {
|
|
||||||
fmt.Println(" [OK] API key configured")
|
|
||||||
} else {
|
|
||||||
fmt.Println(" [FAIL] No API key set for active provider")
|
|
||||||
ok = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("\nTools:")
|
|
||||||
result := scanner.ScanSystem()
|
|
||||||
installed := 0
|
|
||||||
for _, t := range result.Tools {
|
|
||||||
if t.Installed {
|
|
||||||
installed++
|
|
||||||
fmt.Printf(" [OK] %s\n", t.Name)
|
|
||||||
} else {
|
|
||||||
fmt.Printf(" [FAIL] %s (not installed)\n", t.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Printf(" Installed: %d/%d\n", installed, len(result.Tools))
|
|
||||||
|
|
||||||
fmt.Println("\nLSP Servers:")
|
|
||||||
servers := lsp.ScanServers()
|
|
||||||
lspOK := 0
|
|
||||||
for _, s := range servers {
|
|
||||||
if s.Installed {
|
|
||||||
lspOK++
|
|
||||||
fmt.Printf(" [OK] %s (%s)\n", s.Name, s.Language)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Printf(" Available: %d/%d\n", lspOK, len(servers))
|
|
||||||
|
|
||||||
fmt.Println("\nMCP Servers:")
|
|
||||||
mcpServers := mcp.ScanServers()
|
|
||||||
mcpOK := 0
|
|
||||||
for _, s := range mcpServers {
|
|
||||||
if s.Installed {
|
|
||||||
mcpOK++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Printf(" Available: %d/%d\n", mcpOK, len(mcpServers))
|
|
||||||
|
|
||||||
fmt.Println("\nSkills:")
|
|
||||||
skillList, err := skills.List()
|
|
||||||
if err != nil || len(skillList) == 0 {
|
|
||||||
fmt.Println(" [FAIL] No skills. Run 'muyue skills init'.")
|
|
||||||
ok = false
|
|
||||||
} else {
|
|
||||||
fmt.Printf(" [OK] %d skills installed\n", len(skillList))
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println()
|
|
||||||
if ok {
|
|
||||||
fmt.Println("All checks passed!")
|
|
||||||
} else {
|
|
||||||
fmt.Println("Some checks failed. Review the output above.")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func runLSP(args []string) {
|
|
||||||
if len(args) == 0 {
|
|
||||||
args = []string{"scan"}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch args[0] {
|
|
||||||
case "scan":
|
|
||||||
fmt.Println("Scanning LSP servers...")
|
|
||||||
servers := lsp.ScanServers()
|
|
||||||
installed := 0
|
|
||||||
for _, s := range servers {
|
|
||||||
if s.Installed {
|
|
||||||
installed++
|
|
||||||
fmt.Printf(" [v] %-35s (%s)\n", s.Name, s.Language)
|
|
||||||
} else {
|
|
||||||
fmt.Printf(" [ ] %-35s (%s)\n", s.Name, s.Language)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Printf("\nInstalled: %d/%d\n", installed, len(servers))
|
|
||||||
case "install":
|
|
||||||
if len(args) < 2 {
|
|
||||||
cfg := loadOrSetupConfig()
|
|
||||||
fmt.Printf("Installing LSP servers for: %v\n", cfg.Profile.Languages)
|
|
||||||
results := lsp.InstallForLanguages(cfg.Profile.Languages)
|
|
||||||
for _, r := range results {
|
|
||||||
if r.Installed {
|
|
||||||
fmt.Printf(" [OK] %s (%s)\n", r.Name, r.Language)
|
|
||||||
} else {
|
|
||||||
fmt.Printf(" [FAIL] %s (%s)\n", r.Name, r.Language)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for _, name := range args[1:] {
|
|
||||||
fmt.Printf("Installing %s...\n", name)
|
|
||||||
if err := lsp.InstallServer(name); err != nil {
|
|
||||||
fmt.Printf(" [FAIL] %s: %s\n", name, err)
|
|
||||||
} else {
|
|
||||||
fmt.Printf(" [OK] %s\n", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
fmt.Printf("Unknown lsp subcommand: %s (scan, install)\n", args[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func runMCP(args []string) {
|
|
||||||
if len(args) == 0 {
|
|
||||||
args = []string{"config"}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch args[0] {
|
|
||||||
case "config":
|
|
||||||
cfg := loadOrSetupConfig()
|
|
||||||
fmt.Println("Configuring MCP servers for Crush and Claude Code...")
|
|
||||||
if err := mcp.ConfigureAll(cfg); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Println("Done! MCP servers configured.")
|
|
||||||
case "scan":
|
|
||||||
fmt.Println("Scanning MCP servers...")
|
|
||||||
servers := mcp.ScanServers()
|
|
||||||
available := 0
|
|
||||||
for _, s := range servers {
|
|
||||||
if s.Installed {
|
|
||||||
available++
|
|
||||||
fmt.Printf(" [v] %-30s (%s)\n", s.Name, s.Category)
|
|
||||||
} else {
|
|
||||||
fmt.Printf(" [ ] %-30s (%s)\n", s.Name, s.Category)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Printf("\nAvailable: %d/%d\n", available, len(servers))
|
|
||||||
default:
|
|
||||||
fmt.Printf("Unknown mcp subcommand: %s (config, scan)\n", args[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func runSkills(args []string) {
|
|
||||||
if len(args) == 0 {
|
|
||||||
args = []string{"list"}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch args[0] {
|
|
||||||
case "list", "ls":
|
|
||||||
skillsList, err := skills.List()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
if len(skillsList) == 0 {
|
|
||||||
fmt.Println("No skills found. Run `muyue skills init` to install built-in skills.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Printf("Skills (%d):\n", len(skillsList))
|
|
||||||
for _, s := range skillsList {
|
|
||||||
target := s.Target
|
|
||||||
if target == "" {
|
|
||||||
target = "both"
|
|
||||||
}
|
|
||||||
fmt.Printf(" %-20s %-8s %s\n", s.Name, target, s.Description)
|
|
||||||
}
|
|
||||||
|
|
||||||
case "init":
|
|
||||||
fmt.Println("Installing built-in skills...")
|
|
||||||
if err := skills.InstallBuiltinSkills(); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Println("Deploying to Crush and Claude Code...")
|
|
||||||
if err := skills.DeployAll(); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Deploy error: %v\n", err)
|
|
||||||
}
|
|
||||||
fmt.Println("Done! Built-in skills installed and deployed.")
|
|
||||||
|
|
||||||
case "show":
|
|
||||||
if len(args) < 2 {
|
|
||||||
fmt.Println("Usage: muyue skills show <name>")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
skill, err := skills.Get(args[1])
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Printf("Name: %s\n", skill.Name)
|
|
||||||
fmt.Printf("Description: %s\n", skill.Description)
|
|
||||||
fmt.Printf("Author: %s\n", skill.Author)
|
|
||||||
fmt.Printf("Version: %s\n", skill.Version)
|
|
||||||
fmt.Printf("Target: %s\n", skill.Target)
|
|
||||||
fmt.Printf("Tags: %v\n", skill.Tags)
|
|
||||||
fmt.Printf("Path: %s\n", skill.FilePath)
|
|
||||||
fmt.Printf("\n--- Content ---\n%s\n", skill.Content)
|
|
||||||
|
|
||||||
case "generate":
|
|
||||||
if len(args) < 3 {
|
|
||||||
fmt.Println("Usage: muyue skills generate <name> <description> [crush|claude|both]")
|
|
||||||
fmt.Println("Example: muyue skills generate docker-setup \"Set up Docker for a project\" both")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
name := args[1]
|
|
||||||
description := args[2]
|
|
||||||
target := "both"
|
|
||||||
if len(args) > 3 {
|
|
||||||
target = args[3]
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := loadOrSetupConfig()
|
|
||||||
orch, err := orchestrator.New(cfg)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "AI not configured: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Generating skill '%s'...\n", name)
|
|
||||||
prompt := skills.BuildAIGeneratePrompt(name, description, target)
|
|
||||||
resp, err := orch.Send(prompt)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Generation error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
skill := &skills.Skill{
|
|
||||||
Name: name,
|
|
||||||
Description: description,
|
|
||||||
Content: resp,
|
|
||||||
Author: "muyue-generated",
|
|
||||||
Version: "0.1.0",
|
|
||||||
Target: target,
|
|
||||||
Tags: []string{"generated"},
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := skills.Create(skill); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Save error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Skill '%s' created and deployed!\n", name)
|
|
||||||
|
|
||||||
case "deploy":
|
|
||||||
fmt.Println("Deploying all skills to Crush and Claude Code...")
|
|
||||||
if err := skills.DeployAll(); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Println("Done!")
|
|
||||||
|
|
||||||
case "delete":
|
|
||||||
if len(args) < 2 {
|
|
||||||
fmt.Println("Usage: muyue skills delete <name>")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := skills.Delete(args[1]); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Printf("Skill '%s' deleted.\n", args[1])
|
|
||||||
|
|
||||||
default:
|
|
||||||
fmt.Printf("Unknown skills subcommand: %s\n", args[0])
|
|
||||||
fmt.Println("Available: list, show, generate, deploy, init, delete")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
914
docs/PRD.md
Normal file
@@ -0,0 +1,914 @@
|
|||||||
|
# Muyue PRD v1.0
|
||||||
|
|
||||||
|
> **Author**: Product Architect
|
||||||
|
> **Date**: 2026-04-22
|
||||||
|
> **Status**: Definitive
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Product Vision & Positioning
|
||||||
|
|
||||||
|
### What is Muyue?
|
||||||
|
|
||||||
|
Muyue is a local-first, single-binary development environment assistant that combines an AI orchestration layer, a tool manager, and a cyberpunk-themed desktop UI into one cohesive experience. It scans your system, installs missing tools, configures AI agent environments (MCP servers, LSPs, skills), and provides a Studio for AI-assisted workflows — all without requiring cloud infrastructure.
|
||||||
|
|
||||||
|
### What problem does it solve?
|
||||||
|
|
||||||
|
Developers spend significant time setting up and maintaining their dev environments: installing tools, configuring MCP servers for AI agents, managing API keys, and switching between CLI tools. Muyue eliminates this friction by providing a single interface that unifies environment management, AI orchestration, and terminal access. It is the "home base" for developers who use AI coding agents (Crush, Claude Code) daily.
|
||||||
|
|
||||||
|
### Who is it for?
|
||||||
|
|
||||||
|
- **Primary**: Solo developers and small teams who use AI coding agents (Crush, Claude Code) and want a unified control panel.
|
||||||
|
- **Secondary**: Developers setting up new machines who want a "one-click" environment bootstrap.
|
||||||
|
- **Not for**: Enterprise teams needing sandboxed environments (Daytona), container orchestration (DevPod), or MCP server registries (MCPM).
|
||||||
|
|
||||||
|
### How is Muyue different?
|
||||||
|
|
||||||
|
| Competitor | What they do | What Muyue does differently |
|
||||||
|
|---|---|---|
|
||||||
|
| **Daytona** | Cloud sandbox infrastructure for AI agents (sandboxes, snapshots, multi-tenant) | Muyue is local-first, lightweight, no infra required. Daytona is "cloud VMs for AI"; Muyue is "desktop control panel for your local AI agents". |
|
||||||
|
| **kasetto** | Declarative AI agent environment manager (Rust, CLI-only) | Muyue adds a desktop GUI, interactive workflows, and a terminal. kasetto is "Nix for AI tools"; Muyue is "a cockpit". |
|
||||||
|
| **OpenCode** | Terminal-based AI coding agent (Go, TUI, LSP+MCP client) | OpenCode is an AI coding agent itself. Muyue orchestrates agents (Crush, Claude) rather than being one. Muyue provides a desktop UI, tool management, and MCP config generation that OpenCode doesn't. |
|
||||||
|
| **DevPod** | Dev environment manager using devcontainers (Go, CLI+Desktop) | DevPod manages remote/container environments. Muyue manages your local machine's tools and AI agent configs. No container overlap. |
|
||||||
|
| **MCPM** | MCP server package manager (Python, CLI, registry) | Muyue generates MCP configs for Crush + Claude Code directly. Delegates server discovery to MCPM where needed. |
|
||||||
|
| **McpMux** | Desktop MCP gateway/router (Rust) | Muyue manages MCP configs per-tool rather than routing through a gateway. Simpler, no encryption layer needed for local use. |
|
||||||
|
|
||||||
|
### What Muyue should NOT do (anti-scope)
|
||||||
|
|
||||||
|
1. **Not a coding agent** — Muyue orchestrates agents (Crush, Claude Code); it does not edit files, run tests, or write code autonomously. The `crush_run` tool delegates to Crush.
|
||||||
|
2. **Not a sandbox/container manager** — No Docker orchestration, no VM provisioning. Use DevPod or Daytona for that.
|
||||||
|
3. **Not an MCP registry** — No server discovery marketplace. Delegate to MCPM for that.
|
||||||
|
4. **Not a CI/CD tool** — No build pipelines, no deployment workflows.
|
||||||
|
5. **Not a multi-tenant platform** — Single-user, local machine only. No org management, no billing.
|
||||||
|
6. **Not an IDE** — No file tree editor, no debugging, no syntax highlighting. Use VS Code, Zed, or Neovim.
|
||||||
|
7. **Not an LSP client** — Muyue installs and manages LSP servers; it does not connect to them as a client. The IDE handles that.
|
||||||
|
8. **Not a proxy/gateway** — No AI proxy agents, no request routing. The orchestrator talks directly to providers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Feature Matrix
|
||||||
|
|
||||||
|
### P0 — Must Have for Launch
|
||||||
|
|
||||||
|
| # | Feature | Priority | Status | Decision | Description |
|
||||||
|
|---|---------|----------|--------|----------|-------------|
|
||||||
|
| 1 | System scanning (tools, runtimes, editors, shell, git) | P0 | **EXISTS** | KEEP | Scanner checks 14 tools, 8 runtimes, 8 editors, shell setup, git config. Has 5-min cache, JSON output. |
|
||||||
|
| 2 | Tool installation (crush, claude, bmad, starship, go, node, python, git, pnpm, uv, docker, gh) | P0 | **EXISTS** | KEEP | Installer handles 12 tools with platform-specific install methods (apt/brew/winget). API endpoint wired. |
|
||||||
|
| 3 | CLI subcommands (scan, install, update, setup, config, doctor, version, lsp, mcp, skills) | P0 | **EXISTS** | KEEP | Cobra-based CLI with all documented subcommands. Each has appropriate flags and output. |
|
||||||
|
| 4 | Desktop mode (HTTP server + embedded SPA) | P0 | **EXISTS** | KEEP | `desktop.go` serves frontend via `go:embed`, auto-opens browser, handles `--port` and `--no-open`. |
|
||||||
|
| 5 | AI orchestration (OpenAI-compatible, multi-provider) | P0 | **EXISTS** | KEEP | Orchestrator supports MiniMax, ZAI, Anthropic, OpenAI, Ollama. History management, tool calling loop. |
|
||||||
|
| 6 | Agent tools (10 tools: terminal, crush_run, read_file, list_files, search_files, grep_content, get_config, set_provider, manage_ssh, web_fetch) | P0 | **EXISTS** | KEEP | All 10 tools implemented with proper parameter validation, timeouts, and output truncation. |
|
||||||
|
| 7 | Tool execution endpoint | P0 | **EXISTS** | KEEP | `/api/tool/call` dispatches to agent registry for any registered tool. `/api/tools/list` returns all tools. |
|
||||||
|
| 8 | MCP server management (scan, configure, generate configs) | P0 | **EXISTS** | KEEP | Scans 12 known MCP servers, generates configs for Crush (`crush.json`) and Claude Code (`.claude.json`). |
|
||||||
|
| 9 | LSP server management (scan, install) | P0 | **EXISTS** | KEEP | 16 known LSP servers with install commands. `InstallForLanguages()` for batch installs. |
|
||||||
|
| 10 | Skills management (CRUD, deploy, built-in skills) | P0 | **EXISTS** | KEEP | 5 built-in skills (env-setup, git-workflow, api-design, debug-assist, code-review). YAML frontmatter format. Deploy to Crush + Claude Code. |
|
||||||
|
| 11 | Conversation persistence (JSON file store) | P0 | **EXISTS** | KEEP | `ConversationStore` with JSON persistence, auto-summarization at 80K tokens. |
|
||||||
|
| 12 | API key encryption (AES-256-GCM) | P0 | **EXISTS** | KEEP | `internal/secret/` with encrypt/decrypt. Keys encrypted at rest in config.yaml. |
|
||||||
|
| 13 | Config management (YAML, XDG paths, defaults) | P0 | **EXISTS** | KEEP | Full config schema with profile, AI providers, terminal, tools, SSH. Legacy migration from `~/.muyue`. |
|
||||||
|
| 14 | Studio tab (AI chat, SSE streaming, tool calls) | P0 | **EXISTS** | KEEP | Full chat UI with SSE streaming, tool call visualization, thinking blocks, markdown rendering. |
|
||||||
|
| 15 | Shell tab (PTY terminal, tabs, SSH connections) | P0 | **EXISTS** | KEEP | xterm.js with WebSocket PTY, tab management (max 7), SSH connection support, 6 terminal themes. |
|
||||||
|
| 16 | Config tab (profile, providers, theme, language, skills) | P0 | **EXISTS** | KEEP | Two-column layout with profile editing, provider management, key validation, terminal settings. |
|
||||||
|
| 17 | First-run profiling wizard (TUI) | P0 | **EXISTS** | KEEP | Charmbracelet/huh TUI wizard: name, pseudo, email, languages, editor, AI provider. Scored suggestions. |
|
||||||
|
| 18 | Onboarding wizard (web) | P0 | **EXISTS** | KEEP | React-based web wizard for desktop mode. |
|
||||||
|
| 19 | i18n (FR/EN, keyboard layout awareness) | P0 | **EXISTS** | KEEP | Full FR/EN translations, AZERTY/QWERTY/QWERTZ layouts affecting shortcut display. |
|
||||||
|
| 20 | Theming (4 cyberpunk themes, CSS custom properties) | P0 | **EXISTS** | KEEP | 4 themes (Red, Pink, Blue, Green) with 30+ CSS variables. Runtime injection. |
|
||||||
|
| 21 | Workflow engine (Plan→Execute) | P0 | **EXISTS** | KEEP | State machine with steps (tool_call, condition, approval). JSON persistence. SSE streaming execution. |
|
||||||
|
|
||||||
|
### P0 — Needs Implementation/Completion
|
||||||
|
|
||||||
|
| # | Feature | Priority | Status | Decision | Description |
|
||||||
|
|---|---------|----------|--------|----------|-------------|
|
||||||
|
| 22 | Dashboard tab (tools grid, notifications, quick actions) | P0 | **PARTIAL** | KEEP, BUILD | Currently shows empty workflow/activity placeholders. Needs: tools grid with status badges, update notifications, quick actions (install missing, check updates, rescan, configure MCP). |
|
||||||
|
| 23 | Shell AI panel (real AI backend) | P0 | **EXISTS** | KEEP | Was fake, now uses `/api/shell/chat` with real AI backend + tool calling. Functional. |
|
||||||
|
| 24 | Tool updates (check + auto-update) | P0 | **EXISTS** | KEEP | `internal/updater/` checks versions and runs auto-updates. API + CLI endpoints wired. |
|
||||||
|
|
||||||
|
### P1 — Post-Launch
|
||||||
|
|
||||||
|
| # | Feature | Priority | Status | Decision | Description |
|
||||||
|
|---|---------|----------|--------|----------|-------------|
|
||||||
|
| 25 | AI-generated skills (via Studio chat) | P1 | **STUBBED** | KEEP | `BuildAIGeneratePrompt()` exists but CLI `skills generate` is a stub. Need to wire to orchestrator. |
|
||||||
|
| 26 | SSH test connectivity | P1 | **STUBBED** | KEEP | `handleSSHTest()` returns "not implemented". Add `net.DialTimeout` check. |
|
||||||
|
| 27 | Conversation list/switch (multiple conversations) | P1 | **PARTIAL** | KEEP | `/api/conversations` list + delete exist. No create/switch/load. Need multi-conversation support in Studio. |
|
||||||
|
| 28 | Dashboard activity log with real events | P1 | **MISSING** | KEEP | Wire install/scan/update events to a notification system that Dashboard renders. |
|
||||||
|
| 29 | Starship prompt integration (multi-theme) | P1 | **EXISTS** | KEEP | 3 theme configs (charm, zerotwo, default). `handleApplyStarshipTheme` writes TOML + patches RC files. |
|
||||||
|
| 30 | Terminal settings persistence (font, theme) | P1 | **EXISTS** | KEEP | Settings saved to config, loaded on startup. |
|
||||||
|
|
||||||
|
### P2 — Nice-to-Have
|
||||||
|
|
||||||
|
| # | Feature | Priority | Status | Decision | Description |
|
||||||
|
|---|---------|----------|--------|----------|-------------|
|
||||||
|
| 31 | Background daemon (`internal/daemon/`) | P2 | **MISSING** | DEFER | README mentions it. Not needed for launch. Tools can run on-demand. |
|
||||||
|
| 32 | HTML preview server (`internal/preview/`) | P2 | **MISSING** | DROP | Use browser or IDE instead. Not Muyue's job. |
|
||||||
|
| 33 | AI proxy agents (`internal/proxy/`) | P2 | **MISSING** | DROP | Direct provider communication is sufficient. No proxy layer needed. |
|
||||||
|
|
||||||
|
### DROPPED
|
||||||
|
|
||||||
|
| # | Feature | Reason | Replacement |
|
||||||
|
|---|---------|--------|-------------|
|
||||||
|
| 34 | HTML preview server | Not core value. IDEs handle this. | Browser / VS Code Live Preview |
|
||||||
|
| 35 | AI proxy agents | Adds complexity without benefit for local-first tool. | Direct provider API calls |
|
||||||
|
| 36 | MCP server registry / marketplace | Out of scope. | MCPM (`mcpm install <server>`) |
|
||||||
|
| 37 | Sandboxed code execution | Out of scope. Requires infra. | Daytona sandboxes |
|
||||||
|
| 38 | Dev container management | Out of scope. | DevPod |
|
||||||
|
| 39 | Full IDE features (file tree, debugger) | Out of scope. | VS Code / Zed / Neovim |
|
||||||
|
| 40 | LSP client mode (connecting to LSPs) | Out of scope. Muyue installs LSPs, doesn't consume them. | IDE handles LSP client |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. User Flows
|
||||||
|
|
||||||
|
### 3.1 First-Time User Opens `muyue`
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User runs `muyue` (or downloaded binary)
|
||||||
|
2. No config exists → loadOrSetupConfig() detects first run
|
||||||
|
3. Profiler TUI wizard launches:
|
||||||
|
a. Asks: name, pseudo, email
|
||||||
|
b. Scans system → detects languages → shows scored language options
|
||||||
|
c. Detects editors → shows scored editor options
|
||||||
|
d. Shows AI provider options → user picks one
|
||||||
|
4. If selected provider has no API key → asks for key (masked input)
|
||||||
|
5. Config saved to ~/.config/muyue/config.yaml (API key encrypted)
|
||||||
|
6. Built-in skills installed to ~/.muyue/skills/
|
||||||
|
7. MCP configs generated for Crush + Claude Code
|
||||||
|
8. Desktop server starts on port 8080
|
||||||
|
9. Browser opens to http://127.0.0.1:8080
|
||||||
|
10. Onboarding wizard checks if profile is empty → shows web wizard as fallback
|
||||||
|
11. Dashboard tab loads → shows tools grid (some installed, some missing)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Edge cases:**
|
||||||
|
- Config file exists but is corrupted → show error, offer `muyue setup` to recreate
|
||||||
|
- No internet → profiler still works (local scan only), AI features unavailable
|
||||||
|
- API key invalid → doctor command detects, Config tab shows "Invalid key" badge
|
||||||
|
|
||||||
|
### 3.2 Returning User Opens `muyue`
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User runs `muyue`
|
||||||
|
2. Config exists → loads from ~/.config/muyue/config.yaml
|
||||||
|
3. Desktop server starts → browser opens (or reconnects)
|
||||||
|
4. Previous conversation loaded from conversation.json
|
||||||
|
5. Dashboard shows current tool status (cached, 5-min TTL)
|
||||||
|
6. If checkOnStart=true → background update check runs
|
||||||
|
7. User picks up where they left off
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 User Installs a Missing Tool from Dashboard
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Dashboard shows tools grid with status badges:
|
||||||
|
- Green ✓ = installed
|
||||||
|
- Red ✗ = missing
|
||||||
|
- Yellow ⟳ = update available
|
||||||
|
2. User clicks "Install" on a missing tool (e.g., "pnpm")
|
||||||
|
3. Frontend calls POST /api/install {"tools": ["pnpm"]}
|
||||||
|
4. Backend spawns installer.InstallTool("pnpm") in goroutine
|
||||||
|
5. Installer checks if already installed → if yes, returns success
|
||||||
|
6. Installer runs `npm install -g pnpm`
|
||||||
|
7. Result returned: {"status": "done", "tools": ["pnpm"], "results": [{...}]}
|
||||||
|
8. Frontend updates tool status badge to green ✓
|
||||||
|
9. Activity log entry added: "Installed pnpm"
|
||||||
|
10. System scan cache invalidated
|
||||||
|
```
|
||||||
|
|
||||||
|
**Edge cases:**
|
||||||
|
- Install fails (permission denied) → show error in results, suggest `sudo` or manual install
|
||||||
|
- Tool requires Node.js but Node isn't installed → installer returns "npx not found, install node first"
|
||||||
|
- Multiple tools installed in parallel → `sync.WaitGroup` handles concurrent installs
|
||||||
|
|
||||||
|
### 3.4 User Starts a Chat in Studio
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User clicks Studio tab (Ctrl+2)
|
||||||
|
2. Chat history loaded from /api/chat/history
|
||||||
|
3. If no history → welcome message shown
|
||||||
|
4. User types message in textarea, presses Enter
|
||||||
|
5. Frontend calls POST /api/chat {"message": "...", "stream": true}
|
||||||
|
6. SSE connection opens
|
||||||
|
7. Backend:
|
||||||
|
a. Adds message to conversation store
|
||||||
|
b. Checks if summarization needed (>80K tokens)
|
||||||
|
c. Creates orchestrator with active provider
|
||||||
|
d. Sets system prompt (Studio prompt with agent context)
|
||||||
|
e. Sets tools (all 10 agent tools as OpenAI function specs)
|
||||||
|
f. Sends to AI provider API
|
||||||
|
8. Streaming begins:
|
||||||
|
a. Content chunks → SSE {"content": "char"} events
|
||||||
|
b. Tool calls → SSE {"tool_call": {...}} events
|
||||||
|
c. Tool results → SSE {"tool_result": {...}} events
|
||||||
|
d. Max 15 tool iterations
|
||||||
|
9. Frontend renders:
|
||||||
|
a. Text content streamed character-by-character
|
||||||
|
b. Tool calls shown as expandable blocks with icon + status
|
||||||
|
c. Thinking blocks (if any) shown with spinner
|
||||||
|
10. Final response stored in conversation
|
||||||
|
11. SSE {"done": "true"} closes stream
|
||||||
|
```
|
||||||
|
|
||||||
|
**Edge cases:**
|
||||||
|
- AI provider returns error → SSE error event, shown as red message
|
||||||
|
- Tool execution times out → error result returned to AI, may retry
|
||||||
|
- No active provider configured → 503 error, redirect to Config tab
|
||||||
|
- API key invalid → 401 error, show "Configure your API key" prompt
|
||||||
|
|
||||||
|
### 3.5 User Runs a Plan→Execute Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User types `/plan Set up a Go project with Docker` in Studio
|
||||||
|
2. Frontend calls POST /api/workflow/plan {"goal": "Set up a Go project..."}
|
||||||
|
3. Backend:
|
||||||
|
a. Creates Planner with AI orchestrator
|
||||||
|
b. Sends goal to AI with planning prompt
|
||||||
|
c. AI generates JSON array of steps
|
||||||
|
d. Planner parses response into []Step
|
||||||
|
e. Workflow Engine creates workflow with steps
|
||||||
|
4. Workflow returned to frontend with ID and steps
|
||||||
|
5. Frontend shows workflow panel:
|
||||||
|
- Step 1: "Check Go installation" → tool: terminal, args: {command: "go version"}
|
||||||
|
- Step 2: "Create project directory" → tool: terminal, args: {command: "mkdir -p ..."}
|
||||||
|
- Step 3: "Initialize Go module" → tool: terminal, args: {command: "go mod init ..."}
|
||||||
|
- etc.
|
||||||
|
6. User clicks "Execute"
|
||||||
|
7. Frontend calls POST /api/workflow/execute/{id}?stream=true
|
||||||
|
8. SSE stream:
|
||||||
|
a. Each step: {"event": "started", "step": {...}}
|
||||||
|
b. On completion: {"event": "done", "step": {...}}
|
||||||
|
c. On failure: {"event": "failed", "step": {...}}
|
||||||
|
d. If approval step: {"event": "awaiting_approval", "step": {...}}
|
||||||
|
9. User can approve/skip steps via POST /api/workflow/approve/{id}
|
||||||
|
10. Final event: {"event": "workflow_done", "status": "done|failed"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Edge cases:**
|
||||||
|
- AI generates invalid JSON → planner returns error, shown in chat
|
||||||
|
- Step fails mid-workflow → remaining steps skipped, workflow marked "failed"
|
||||||
|
- Approval step → execution pauses until user approves
|
||||||
|
- Workflow exceeds 10 steps → planner prompt limits to 10
|
||||||
|
|
||||||
|
### 3.6 User Opens Shell, Connects via SSH
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User clicks Shell tab (Ctrl+3)
|
||||||
|
2. Default "Local Shell" tab created with xterm.js terminal
|
||||||
|
3. WebSocket connects to /api/ws/terminal with {type: "shell", data: ""}
|
||||||
|
4. Backend creates PTY via creack/pty, pipes I/O through WebSocket
|
||||||
|
5. User sees their shell prompt (starship if configured)
|
||||||
|
6. To add SSH tab:
|
||||||
|
a. User clicks "+" → dropdown shows:
|
||||||
|
- System terminals (zsh, bash, fish)
|
||||||
|
- Saved SSH connections (from config)
|
||||||
|
- "Add SSH connection" button
|
||||||
|
b. User selects saved connection or adds new one
|
||||||
|
c. New tab created with {type: "ssh", data: JSON.stringify({host, port, user, key_path})}
|
||||||
|
d. Backend establishes SSH connection, creates PTY
|
||||||
|
e. Tab shows connected indicator (green dot)
|
||||||
|
7. User can rename tabs (double-click), close tabs (×), switch with Alt+1-7
|
||||||
|
8. AI assistant panel on right:
|
||||||
|
a. User types question
|
||||||
|
b. Frontend calls POST /api/shell/chat with message + terminal context
|
||||||
|
c. AI responds with shell-aware answers (commands, explanations)
|
||||||
|
d. Can execute tools (terminal, read_file, etc.) to help user
|
||||||
|
```
|
||||||
|
|
||||||
|
**Edge cases:**
|
||||||
|
- SSH connection fails → tab shows "Connection error" in terminal
|
||||||
|
- WebSocket disconnects → terminal shows "Connection closed" message
|
||||||
|
- Tab limit (7) reached → "+" button disabled
|
||||||
|
- SSH key not found → connection fails, suggest key path
|
||||||
|
|
||||||
|
### 3.7 User Changes AI Provider
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User clicks Config tab (Ctrl+4)
|
||||||
|
2. "AI Providers" panel shows list of providers:
|
||||||
|
- MiniMax (active) — Key configured ✓
|
||||||
|
- Z.AI — No key
|
||||||
|
- Anthropic — No key
|
||||||
|
- OpenAI — No key
|
||||||
|
- Ollama — No key (local)
|
||||||
|
3. User clicks "Configure" on Anthropic
|
||||||
|
4. Modal opens with fields: API Key, Model, Base URL
|
||||||
|
5. User enters API key
|
||||||
|
6. User clicks "Validate" → POST /api/providers/validate
|
||||||
|
7. Backend sends test request to Anthropic API with key
|
||||||
|
8. Response: {"status": "valid"} or error
|
||||||
|
9. User clicks "Activate" → provider set active, others deactivated
|
||||||
|
10. Config saved → new orchestrator instances use Anthropic
|
||||||
|
```
|
||||||
|
|
||||||
|
**Edge cases:**
|
||||||
|
- Key validation fails → show "Invalid key" badge, don't save
|
||||||
|
- No internet → validation times out, show "Connection failed"
|
||||||
|
- Ollama selected but not running → user sees local URL, no validation needed
|
||||||
|
- Switching provider mid-conversation → new messages use new provider, old messages preserved
|
||||||
|
|
||||||
|
### 3.8 User Manages MCP Servers
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User opens Config tab → MCP section
|
||||||
|
OR: User runs `muyue mcp scan` from CLI
|
||||||
|
2. System scans 12 known MCP servers (filesystem, github, git, fetch, memory, etc.)
|
||||||
|
3. Each server shows: name, category, installed status (npx available)
|
||||||
|
4. User clicks "Configure MCP" → POST /api/mcp/configure
|
||||||
|
5. Backend:
|
||||||
|
a. Generates MCP config for Crush: ~/.config/crush/crush.json → {"mcps": {...}}
|
||||||
|
b. Generates MCP config for Claude Code: ~/.claude.json → {"mcpServers": {...}}
|
||||||
|
c. Core servers: filesystem, fetch, memory
|
||||||
|
d. Provider-specific: minimax-web-search, minimax-image (if API key set)
|
||||||
|
e. Claude-specific: sequential-thinking
|
||||||
|
6. Configs written with 0600 permissions
|
||||||
|
```
|
||||||
|
|
||||||
|
**Edge cases:**
|
||||||
|
- Existing configs not overwritten (merged) — `writeMCPConfig` merges into existing JSON
|
||||||
|
- No API key for provider-specific servers → those servers omitted
|
||||||
|
- Crush or Claude Code not installed → configs still generated (for when they are installed)
|
||||||
|
|
||||||
|
### 3.9 User Manages LSP Servers
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User runs `muyue lsp scan` from CLI
|
||||||
|
OR: views LSP section in Config tab
|
||||||
|
2. System checks 16 known LSP servers
|
||||||
|
3. Each shows: name, language, command path, installed status
|
||||||
|
4. User installs specific LSP:
|
||||||
|
- CLI: `muyue lsp install gopls`
|
||||||
|
- API: POST /api/lsp/install {"name": "gopls"}
|
||||||
|
5. Backend runs install command (e.g., `go install golang.org/x/tools/gopls@latest`)
|
||||||
|
6. Result: success or error
|
||||||
|
```
|
||||||
|
|
||||||
|
**Edge cases:**
|
||||||
|
- LSP has no auto-install command (e.g., clangd) → return "install manually" message
|
||||||
|
- Install fails (network error) → show error, suggest retry
|
||||||
|
- Language mapping: TypeScript installs 4 servers (TS, JSON, HTML, CSS)
|
||||||
|
|
||||||
|
### 3.10 User Creates/Deploys a Skill
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User runs `muyue skills init` → installs 5 built-in skills to ~/.muyue/skills/
|
||||||
|
2. User creates custom skill:
|
||||||
|
- Manually: create ~/.muyue/skills/my-skill/SKILL.md with YAML frontmatter
|
||||||
|
- CLI: `muyue skills generate my-skill "Does X for Y" crush`
|
||||||
|
- API: POST /api/skills (via Config tab or Studio chat)
|
||||||
|
3. SKILL.md format:
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
name: my-skill
|
||||||
|
description: What it does
|
||||||
|
author: username
|
||||||
|
version: 1.0.0
|
||||||
|
target: crush|claude|both
|
||||||
|
tags: [tag1, tag2]
|
||||||
|
---
|
||||||
|
# Skill instructions in markdown
|
||||||
|
```
|
||||||
|
4. Deploy: `muyue skills deploy`
|
||||||
|
5. Skill copied to:
|
||||||
|
- Crush: ~/.config/crush/skills/my-skill/SKILL.md
|
||||||
|
- Claude Code: ~/.claude/skills/my-skill/SKILL.md
|
||||||
|
```
|
||||||
|
|
||||||
|
**Edge cases:**
|
||||||
|
- Skill already exists at target → overwritten
|
||||||
|
- Target is "both" → deployed to both Crush and Claude
|
||||||
|
- Delete removes from all locations (source + targets)
|
||||||
|
|
||||||
|
### 3.11 User Runs `muyue scan` from CLI
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User runs `muyue scan`
|
||||||
|
2. Scanner runs full system scan (tools, runtimes, shell, git)
|
||||||
|
3. Output: formatted table with columns: Tool, Version, Status, Path
|
||||||
|
4. Summary line: "Installed: 8/14"
|
||||||
|
5. With --json flag: full JSON output
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.12 User Runs `muyue doctor` from CLI
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User runs `muyue doctor`
|
||||||
|
2. Three checks run:
|
||||||
|
a. System scan → shows installed/missing tools
|
||||||
|
b. Config check → loads config, validates profile
|
||||||
|
c. Connectivity check → HEAD requests to AI provider endpoints
|
||||||
|
3. Output: diagnostic report with ✓/✗ indicators
|
||||||
|
4. User sees what's broken and can take action
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. API Contract
|
||||||
|
|
||||||
|
### Existing Endpoints (37 routes)
|
||||||
|
|
||||||
|
| Method | Path | Request Body | Response Body | Status |
|
||||||
|
|--------|------|-------------|---------------|--------|
|
||||||
|
| GET | `/api/info` | — | `{name, version, author}` | EXISTS |
|
||||||
|
| GET | `/api/system` | — | `{system: {os, arch, platform, shell, ...}}` | EXISTS |
|
||||||
|
| GET | `/api/tools` | — | `{tools: [{name, installed, version, path}], total}` | EXISTS |
|
||||||
|
| GET | `/api/config` | — | `{profile, terminal, bmad}` | EXISTS |
|
||||||
|
| GET | `/api/providers` | — | `{providers: [{name, model, active, ...}]}` | EXISTS |
|
||||||
|
| GET | `/api/skills` | — | `{skills: [...], count}` | EXISTS |
|
||||||
|
| GET | `/api/lsp` | — | `{servers: [{name, language, command, installed}]}` | EXISTS |
|
||||||
|
| GET | `/api/mcp` | — | `{servers: [...], configured}` | EXISTS |
|
||||||
|
| GET | `/api/updates` | — | `{updates: [{tool, current, latest, needsUpdate}]}` | EXISTS |
|
||||||
|
| GET | `/api/editors` | — | `{editors: [{name, installed, version, path}]}` | EXISTS |
|
||||||
|
| GET | `/api/terminal/sessions` | — | `{ssh: [...], system: [...]}` | EXISTS |
|
||||||
|
| GET | `/api/terminal/themes` | — | `{themes: [{id, name}]}` | EXISTS |
|
||||||
|
| GET | `/api/chat/history` | — | `{messages: [...], tokens}` | EXISTS |
|
||||||
|
| GET | `/api/tools/list` | — | `{tools: [...], count}` | EXISTS |
|
||||||
|
| GET | `/api/workflow/list` | — | `{workflows: [...], count}` | EXISTS |
|
||||||
|
| GET | `/api/workflow/{id}` | — | `{id, name, steps, status, ...}` | EXISTS |
|
||||||
|
| GET | `/api/conversations` | — | `{conversations: [...]}` | EXISTS |
|
||||||
|
| GET | `/api/ssh/connections` | — | `{connections: [...]}` | EXISTS |
|
||||||
|
| POST | `/api/scan` | — | `{status: "ok"}` | EXISTS |
|
||||||
|
| POST | `/api/install` | `{tools: [string]}` | `{status, tools, results: [{tool, success, message}]}` | EXISTS |
|
||||||
|
| POST | `/api/mcp/configure` | — | `{status: "ok"}` | EXISTS |
|
||||||
|
| POST | `/api/terminal` | `{command, cwd}` | `{output, error}` | EXISTS |
|
||||||
|
| POST | `/api/chat` | `{message, stream}` | SSE stream or `{content}` | EXISTS |
|
||||||
|
| POST | `/api/chat/clear` | — | `{status: "ok"}` | EXISTS |
|
||||||
|
| POST | `/api/tool/call` | `{tool, args}` | `{success, tool, result, error}` | EXISTS |
|
||||||
|
| POST | `/api/shell/chat` | `{message, context, history, cwd, platform, stream}` | SSE stream or `{content, tool_calls}` | EXISTS |
|
||||||
|
| POST | `/api/workflow` | `{name, description, type}` | `{id, name, steps, status, ...}` | EXISTS |
|
||||||
|
| POST | `/api/workflow/plan` | `{goal}` | `{id, name, steps, status, ...}` | EXISTS |
|
||||||
|
| POST | `/api/workflow/execute/{id}` | `?stream=true` optional | SSE stream or workflow object | EXISTS |
|
||||||
|
| POST | `/api/workflow/approve/{id}` | `{step_id}` | `{status: "approved"}` | EXISTS |
|
||||||
|
| POST | `/api/lsp/install` | `{name}` | `{success, server}` or `{success, error}` | EXISTS |
|
||||||
|
| POST | `/api/skills/deploy` | `{name}` optional | `{status, skill}` | EXISTS |
|
||||||
|
| POST | `/api/config/reset` | — | `{status: "ok"}` | EXISTS |
|
||||||
|
| POST | `/api/providers/validate` | `{name, api_key, model, base_url}` | `{status: "valid"}` or error | EXISTS |
|
||||||
|
| POST | `/api/update/run` | `{tool}` optional | `{status, updated}` or `{status, tool}` | EXISTS |
|
||||||
|
| POST | `/api/ssh/test` | `{host, port, user}` | `{success, message}` (stubbed) | PARTIAL |
|
||||||
|
| POST | `/api/starship/apply-theme` | `{theme}` | `{status, config}` | EXISTS |
|
||||||
|
| PUT | `/api/preferences` | `{language, keyboard_layout}` | `{status: "ok"}` | EXISTS |
|
||||||
|
| PUT | `/api/config/profile` | `{name, pseudo, email, editor, shell}` | `{status: "ok"}` | EXISTS |
|
||||||
|
| PUT | `/api/config/provider` | `{name, api_key, model, base_url, active}` | `{status: "ok"}` | EXISTS |
|
||||||
|
| PUT | `/api/terminal/settings` | `{font_size, font_family, theme}` | `{status, theme}` | EXISTS |
|
||||||
|
| DELETE | `/api/conversations/{id}` | — | `{status: "deleted"}` | EXISTS |
|
||||||
|
| DELETE | `/api/terminal/sessions/{name}` | — | (removes SSH connection) | EXISTS |
|
||||||
|
| WS | `/api/ws/terminal` | `{type, data}` | `{type, data}` | EXISTS |
|
||||||
|
|
||||||
|
### Error Response Format (all endpoints)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"error": "Human-readable error message"}
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP status codes: 400 (bad request), 401 (unauthorized), 404 (not found), 405 (method not allowed), 500 (internal), 503 (service unavailable — AI provider not configured).
|
||||||
|
|
||||||
|
### SSE Event Format
|
||||||
|
|
||||||
|
```
|
||||||
|
data: {"content": "character"}
|
||||||
|
data: {"tool_call": {"tool_call_id": "...", "name": "...", "args": "..."}}
|
||||||
|
data: {"tool_result": {"tool_call_id": "...", "content": "...", "is_error": false}}
|
||||||
|
data: {"done": "true"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. CLI Contract
|
||||||
|
|
||||||
|
### Root Command
|
||||||
|
|
||||||
|
```
|
||||||
|
muyue Launch desktop app (opens browser)
|
||||||
|
muyue --port=8080 Launch on specific port
|
||||||
|
muyue --no-open Launch without opening browser
|
||||||
|
```
|
||||||
|
|
||||||
|
### Subcommands
|
||||||
|
|
||||||
|
| Command | Flags | Output | Status |
|
||||||
|
|---------|-------|--------|--------|
|
||||||
|
| `muyue scan` | `--json` | Table or JSON of tools/runtimes | EXISTS |
|
||||||
|
| `muyue install [tool]` | `--yes` | Install progress per tool | EXISTS |
|
||||||
|
| `muyue update [tool]` | `--check` | Table of versions + status | EXISTS |
|
||||||
|
| `muyue setup` | — | Interactive TUI wizard | EXISTS |
|
||||||
|
| `muyue config` | — | (subcommand stub) | PARTIAL |
|
||||||
|
| `muyue doctor` | — | Diagnostic report | EXISTS |
|
||||||
|
| `muyue version` | — | `Muyue version X.Y.Z` | EXISTS |
|
||||||
|
| `muyue lsp scan` | — | Table of LSP servers | EXISTS |
|
||||||
|
| `muyue lsp install <name>` | — | Install progress | EXISTS |
|
||||||
|
| `muyue mcp config` | — | Confirmation message | EXISTS |
|
||||||
|
| `muyue mcp scan` | — | Table of MCP servers | EXISTS |
|
||||||
|
| `muyue skills list` | — | Table of skills | EXISTS |
|
||||||
|
| `muyue skills init` | — | Confirmation | STUBBED |
|
||||||
|
| `muyue skills show <name>` | — | Skill details | EXISTS |
|
||||||
|
| `muyue skills generate <name> <desc>` | — | (stub) | STUBBED |
|
||||||
|
| `muyue skills deploy` | — | Confirmation | EXISTS |
|
||||||
|
| `muyue skills delete <name>` | — | Confirmation | EXISTS |
|
||||||
|
|
||||||
|
### CLI Commands Needing Work
|
||||||
|
|
||||||
|
| Command | Issue | Fix |
|
||||||
|
|---------|-------|-----|
|
||||||
|
| `muyue config` | No subcommands (get/set are defined but not registered) | Register `config get <key>` and `config set <key> <value>` as subcommands |
|
||||||
|
| `muyue skills init` | Just prints message, doesn't call `skills.InstallBuiltinSkills()` | Wire to actual function |
|
||||||
|
| `muyue skills generate` | Just prints message, doesn't call AI | Wire to orchestrator |
|
||||||
|
| `muyue install` | Passes `nil` config to installer | Pass loaded config |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Data Model
|
||||||
|
|
||||||
|
### 6.1 Config YAML Schema (`~/.config/muyue/config.yaml`)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: "0.2.1"
|
||||||
|
|
||||||
|
profile:
|
||||||
|
name: "Augustin"
|
||||||
|
pseudo: "muyue"
|
||||||
|
email: "augustin@example.com"
|
||||||
|
languages: ["go", "typescript", "python"]
|
||||||
|
preferences:
|
||||||
|
editor: "nvim"
|
||||||
|
shell: "zsh"
|
||||||
|
theme: "cyberpunk-red" # cyberpunk-red | cyberpunk-pink | midnight-blue | matrix-green
|
||||||
|
default_ai: "minimax"
|
||||||
|
auto_update: true
|
||||||
|
check_on_start: true
|
||||||
|
language: "fr" # fr | en
|
||||||
|
keyboard_layout: "azerty" # azerty | qwerty | qwertz
|
||||||
|
|
||||||
|
ai:
|
||||||
|
providers:
|
||||||
|
- name: "minimax"
|
||||||
|
api_key: "enc:AES256GCM..." # encrypted at rest
|
||||||
|
base_url: "https://api.minimax.io/v1"
|
||||||
|
model: "MiniMax-M2.7"
|
||||||
|
active: true
|
||||||
|
- name: "zai"
|
||||||
|
model: "glm"
|
||||||
|
active: false
|
||||||
|
- name: "anthropic"
|
||||||
|
api_key: "enc:AES256GCM..."
|
||||||
|
model: "claude-sonnet-4-20250514"
|
||||||
|
active: false
|
||||||
|
- name: "openai"
|
||||||
|
api_key: "enc:AES256GCM..."
|
||||||
|
base_url: "https://api.openai.com/v1"
|
||||||
|
model: "gpt-4o"
|
||||||
|
active: false
|
||||||
|
- name: "ollama"
|
||||||
|
model: "llama3"
|
||||||
|
base_url: "http://localhost:11434/api"
|
||||||
|
active: false
|
||||||
|
|
||||||
|
tools:
|
||||||
|
- name: "crush"
|
||||||
|
installed: true
|
||||||
|
version: "v1.2.3"
|
||||||
|
auto_update: true
|
||||||
|
|
||||||
|
bmad:
|
||||||
|
installed: true
|
||||||
|
version: "latest"
|
||||||
|
global: true
|
||||||
|
|
||||||
|
terminal:
|
||||||
|
custom_prompt: true
|
||||||
|
prompt_theme: "zerotwo" # charm | zerotwo | default
|
||||||
|
ssh:
|
||||||
|
- name: "prod-server"
|
||||||
|
host: "192.168.1.100"
|
||||||
|
port: 22
|
||||||
|
user: "deploy"
|
||||||
|
key_path: "~/.ssh/id_rsa"
|
||||||
|
font_size: 14
|
||||||
|
font_family: "'JetBrains Mono', monospace"
|
||||||
|
theme: "default" # default | monokai | gruvbox | nord | solarized-dark | dracula
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Conversation JSON Schema (`~/.config/muyue/conversation.json`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"id": "20260422150000.000-1234567890",
|
||||||
|
"role": "user|assistant|system",
|
||||||
|
"content": "message text or JSON-encoded {content, tool_calls}",
|
||||||
|
"time": "2026-04-22T15:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Auto-generated conversation summary when >80K tokens",
|
||||||
|
"created_at": "2026-04-22T15:00:00Z",
|
||||||
|
"updated_at": "2026-04-22T15:30:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 Skill SKILL.md Format
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: skill-name
|
||||||
|
description: What this skill does
|
||||||
|
author: muyue
|
||||||
|
version: 1.0.0
|
||||||
|
target: both # crush | claude | both
|
||||||
|
tags: [tag1, tag2]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Skill Title
|
||||||
|
|
||||||
|
Instructions for the AI agent in markdown.
|
||||||
|
Includes: when to activate, step-by-step instructions, examples, error handling.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 MCP Config JSON Format
|
||||||
|
|
||||||
|
**For Crush** (`~/.config/crush/crush.json`):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcps": {
|
||||||
|
"filesystem": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"]
|
||||||
|
},
|
||||||
|
"fetch": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@modelcontextprotocol/server-fetch"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**For Claude Code** (`~/.claude.json`):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"filesystem": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"]
|
||||||
|
},
|
||||||
|
"sequential-thinking": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@modelcontextprotocol/server-sequential-thinking"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5 Workflow JSON Schema (`~/.config/muyue/workflows.json`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "wf-1234567890",
|
||||||
|
"name": "Plan: Set up Go project",
|
||||||
|
"description": "Full goal description",
|
||||||
|
"type": "plan_execute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"id": "step-0",
|
||||||
|
"name": "Check Go installation",
|
||||||
|
"type": "tool_call",
|
||||||
|
"tool": "terminal",
|
||||||
|
"args": {"command": "go version"},
|
||||||
|
"status": "pending|running|done|failed|awaiting_approval|skipped",
|
||||||
|
"result": "",
|
||||||
|
"error": "",
|
||||||
|
"depends_on": [],
|
||||||
|
"started_at": null,
|
||||||
|
"ended_at": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"status": "pending|running|done|failed",
|
||||||
|
"created_at": "2026-04-22T15:00:00Z",
|
||||||
|
"updated_at": "2026-04-22T15:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Technical Decisions
|
||||||
|
|
||||||
|
### 7.1 CLI Framework: **Keep Cobra** ✓
|
||||||
|
|
||||||
|
**Decision**: Keep `spf13/cobra` (already in `go.mod`, already used for all 11 subcommands).
|
||||||
|
|
||||||
|
**Rationale**: Cobra is the de-facto standard for Go CLIs. All commands are already implemented. No benefit to switching to `urfave/cli`.
|
||||||
|
|
||||||
|
### 7.2 HTTP Router: **Keep stdlib `http.ServeMux`** ✓
|
||||||
|
|
||||||
|
**Decision**: Keep `net/http.ServeMux`. Do NOT add chi, echo, or gin.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- 37 routes registered. Stdlib handles this fine.
|
||||||
|
- Go 1.22+ `ServeMux` supports method-based routing (`GET /api/foo`).
|
||||||
|
- Adding a framework adds a dependency and learning curve for no benefit.
|
||||||
|
- Performance is irrelevant at localhost scale.
|
||||||
|
|
||||||
|
**One improvement**: Use Go 1.22 method-based patterns to clean up manual method checks:
|
||||||
|
```go
|
||||||
|
mux.HandleFunc("GET /api/tools", s.handleTools)
|
||||||
|
mux.HandleFunc("POST /api/install", s.handleInstall)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 WebSocket: **Keep gorilla/websocket** ✓
|
||||||
|
|
||||||
|
**Decision**: Keep `gorilla/websocket` for terminal PTY.
|
||||||
|
|
||||||
|
**Rationale**: Already working for terminal WebSocket. Only used for one endpoint (`/api/ws/terminal`). No need for a framework.
|
||||||
|
|
||||||
|
### 7.4 Frontend Framework: **Keep vanilla React** ✓
|
||||||
|
|
||||||
|
**Decision**: Keep React 19 + vanilla state management. Do NOT add zustand or react-query.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- 4 components, ~1200 lines total. State is simple (tab switching, form inputs, chat messages).
|
||||||
|
- Adding zustand/redux would be over-engineering for this scale.
|
||||||
|
- `useState` + `useCallback` + `useRef` is sufficient.
|
||||||
|
- SSE handling is custom and wouldn't benefit from react-query.
|
||||||
|
|
||||||
|
**One consideration**: If Dashboard grows complex (many sub-components), extract a `useApi` custom hook pattern for data fetching.
|
||||||
|
|
||||||
|
### 7.5 Async Operations: **SSE for everything** ✓
|
||||||
|
|
||||||
|
**Decision**: Use SSE (Server-Sent Events) for all streaming operations (chat, workflow execution). Use synchronous JSON for non-streaming operations (install, scan).
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- SSE is already implemented for chat and workflow execution.
|
||||||
|
- Install operations are fast enough to be synchronous (wait for all goroutines, return results).
|
||||||
|
- No polling needed.
|
||||||
|
- WebSocket only for terminal PTY (bidirectional needed).
|
||||||
|
|
||||||
|
### 7.6 Workflow Engine: **State machine** ✓
|
||||||
|
|
||||||
|
**Decision**: Keep the current state machine approach. Do NOT convert to a DAG.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Plans are linear sequences (step 1 → step 2 → step 3).
|
||||||
|
- Dependencies are simple (wait for previous step).
|
||||||
|
- DAG adds complexity (topological sort, parallel execution) for no benefit.
|
||||||
|
- The current `depends_on` field supports basic ordering. Parallel execution can be added later if needed via `TypeParallel` step type (already defined but not implemented).
|
||||||
|
|
||||||
|
### 7.7 Styling: **Keep CSS custom properties** ✓
|
||||||
|
|
||||||
|
**Decision**: Keep CSS custom properties + 4 theme objects. Do NOT add Tailwind or CSS-in-JS.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- 30+ CSS variables already define the full theme system.
|
||||||
|
- Theme switching works by setting `document.documentElement.style.setProperty()`.
|
||||||
|
- Adding Tailwind would conflict with the existing CSS architecture.
|
||||||
|
- Current CSS is ~1000 lines and well-structured.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Delegation Strategy
|
||||||
|
|
||||||
|
### What Muyue delegates to existing tools
|
||||||
|
|
||||||
|
| Feature | Delegated To | Integration Method | UI |
|
||||||
|
|---------|-------------|-------------------|-----|
|
||||||
|
| **Code editing / AI coding** | Crush (`crush run`) | `crush_run` agent tool → spawns `crush run <task>` | Studio chat invokes tool, Shell AI panel invokes tool |
|
||||||
|
| **Code editing / AI coding** | Claude Code | Skills deployed to `~/.claude/skills/` | Config tab shows deployment status |
|
||||||
|
| **MCP server discovery** | MCPM (`mcpm`) | CLI passthrough suggestion | Doctor command suggests `mcpm install <server>` if server missing |
|
||||||
|
| **MCP server routing** | McpMux | Not needed | Muyue generates per-tool configs directly |
|
||||||
|
| **Dev environments / containers** | DevPod | CLI passthrough suggestion | Doctor suggests DevPod if container needed |
|
||||||
|
| **IDE features** | VS Code / Zed / Neovim | Config integration (editor preference) | Config tab sets editor, LSPs installed for editor |
|
||||||
|
| **Terminal prompt** | Starship | Config generation (`starship.toml` + RC file patching) | Config tab applies themes |
|
||||||
|
| **Git operations** | `git` CLI | Agent `terminal` tool runs git commands | Studio / Shell AI can execute git commands |
|
||||||
|
|
||||||
|
### Integration Patterns
|
||||||
|
|
||||||
|
1. **Config Generation** (primary pattern): Muyue generates config files for external tools (Crush `crush.json`, Claude `.claude.json`, Starship `starship.toml`). This is the cleanest integration — no API coupling, no version lock-in.
|
||||||
|
|
||||||
|
2. **CLI Wrapping**: Muyue invokes external CLIs (`crush run`, `git`, `go install`) through the agent `terminal` tool. Stdout/stderr captured and returned to AI.
|
||||||
|
|
||||||
|
3. **Suggestion**: Muyue suggests tools the user should install separately (MCPM, DevPod) but doesn't wrap them. `muyue doctor` output includes recommendations.
|
||||||
|
|
||||||
|
4. **Skills Deployment**: Muyue's skills system deploys SKILL.md files to both Crush and Claude Code directories. Both tools natively understand this format.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Implementation Priority
|
||||||
|
|
||||||
|
### Phase 1: Dashboard Completion (P0 gap)
|
||||||
|
|
||||||
|
The only significant P0 gap is the Dashboard. Current state: empty placeholders.
|
||||||
|
|
||||||
|
**Dashboard must have:**
|
||||||
|
1. **Tools Grid** — Cards for each scanned tool showing name, status badge (installed/missing/update), version, install button
|
||||||
|
2. **Quick Actions** — Buttons: "Install missing tools", "Check for updates", "Rescan system", "Configure MCP"
|
||||||
|
3. **Update Notifications** — List of tools with available updates, with "Update" buttons
|
||||||
|
4. **Activity Log** — Scrollable list of recent events (installs, scans, config changes) with timestamps
|
||||||
|
|
||||||
|
**Implementation approach:**
|
||||||
|
- Fetch from `/api/tools`, `/api/updates`, `/api/editors` on mount
|
||||||
|
- Quick actions call existing API endpoints (`POST /api/install`, `POST /api/scan`, `POST /api/mcp/configure`)
|
||||||
|
- Activity log: client-side event accumulation (no backend change needed for MVP)
|
||||||
|
|
||||||
|
### Phase 2: CLI Polish (P0 gaps)
|
||||||
|
|
||||||
|
1. Wire `muyue skills init` to `skills.InstallBuiltinSkills()`
|
||||||
|
2. Wire `muyue skills generate` to orchestrator
|
||||||
|
3. Register `muyue config get` and `muyue config set` subcommands
|
||||||
|
4. Pass loaded config to installer in `muyue install`
|
||||||
|
|
||||||
|
### Phase 3: P1 Features
|
||||||
|
|
||||||
|
1. AI-generated skills via Studio chat
|
||||||
|
2. SSH connectivity test
|
||||||
|
3. Multi-conversation support in Studio
|
||||||
|
4. Real event-based activity log
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Architecture Summary
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Browser (React SPA) │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||||
|
│ │Dashboard │ │ Studio │ │ Shell │ │ Config │ │
|
||||||
|
│ │(tools, │ │(AI chat, │ │(xterm.js,│ │(profile, │ │
|
||||||
|
│ │ updates, │ │ tool │ │ WS PTY, │ │provider, │ │
|
||||||
|
│ │ actions) │ │ calls) │ │ AI panel)│ │ theme) │ │
|
||||||
|
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
|
||||||
|
└──────────────────────┬──────────────────────────────────┘
|
||||||
|
│ HTTP/SSE/WS
|
||||||
|
┌──────────────────────┴──────────────────────────────────┐
|
||||||
|
│ Go HTTP Server │
|
||||||
|
│ ┌────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ api.Server (37 routes) │ │
|
||||||
|
│ │ /api/chat → SSE stream + tool calling loop │ │
|
||||||
|
│ │ /api/shell/chat → SSE stream + tool calling loop │ │
|
||||||
|
│ │ /api/ws/terminal → WebSocket PTY │ │
|
||||||
|
│ │ /api/install → parallel tool installation │ │
|
||||||
|
│ │ /api/workflow/* → CRUD + plan + execute │ │
|
||||||
|
│ └────────────────────────────────────────────────────┘ │
|
||||||
|
│ ┌──────────┐ ┌────────────┐ ┌──────────┐ ┌──────────┐ │
|
||||||
|
│ │Scanner │ │ Installer │ │ Updater │ │ MCP │ │
|
||||||
|
│ │(14 tools,│ │(12 tools, │ │(version │ │(12 known │ │
|
||||||
|
│ │ 8 runts, │ │ platform- │ │ check + │ │ servers, │ │
|
||||||
|
│ │ 8 edtrs) │ │ specific) │ │ auto-upd)│ │ config │ │
|
||||||
|
│ └──────────┘ └────────────┘ └──────────┘ │ gen) │ │
|
||||||
|
│ ┌──────────┐ ┌────────────┐ ┌──────────┐ └──────────┘ │
|
||||||
|
│ │ LSP │ │ Skills │ │ Workflow │ │
|
||||||
|
│ │(16 known │ │(CRUD + │ │(Plan→ │ │
|
||||||
|
│ │ servers) │ │ deploy + │ │ Execute │ │
|
||||||
|
│ │ │ │ builtins) │ │ engine) │ │
|
||||||
|
│ └──────────┘ └────────────┘ └──────────┘ │
|
||||||
|
│ ┌──────────┐ ┌────────────┐ ┌──────────┐ ┌──────────┐ │
|
||||||
|
│ │Orchestrtr│ │ Agent │ │ Secret │ │ Config │ │
|
||||||
|
│ │(OpenAI- │ │ Registry │ │(AES-256- │ │(YAML, │ │
|
||||||
|
│ │ compat, │ │(10 tools: │ │ GCM key │ │ XDG, │ │
|
||||||
|
│ │ multi- │ │ terminal, │ │ encrypt) │ │ encrypted│ │
|
||||||
|
│ │ provider)│ │ files, │ │ │ │ API keys)│ │
|
||||||
|
│ │ │ │ grep, etc)│ │ │ │ │ │
|
||||||
|
│ └──────────┘ └────────────┘ └──────────┘ └──────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ External Tools/Agents │
|
||||||
|
│ Crush, Claude Code, │
|
||||||
|
│ Starship, MCP servers │
|
||||||
|
└─────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Dependencies
|
||||||
|
|
||||||
|
| Dependency | Version | Purpose |
|
||||||
|
|-----------|---------|---------|
|
||||||
|
| `spf13/cobra` | v1.10.2 | CLI framework |
|
||||||
|
| `charmbracelet/huh` | v1.0.0 | TUI forms (profiler, API key input) |
|
||||||
|
| `charmbracelet/bubbletea` | v1.3.10 | TUI framework (indirect) |
|
||||||
|
| `gorilla/websocket` | v1.5.3 | Terminal WebSocket |
|
||||||
|
| `creack/pty/v2` | v2.0.1 | PTY for terminal |
|
||||||
|
| `gopkg.in/yaml.v3` | v3.0.1 | Config serialization |
|
||||||
|
| React 19 | — | Frontend UI |
|
||||||
|
| Vite 8 | — | Frontend build |
|
||||||
|
| xterm.js | — | Terminal emulator component |
|
||||||
|
|
||||||
|
### File Count Summary
|
||||||
|
|
||||||
|
| Layer | Files | Lines (approx) |
|
||||||
|
|-------|-------|---------------|
|
||||||
|
| Go backend (`internal/`) | 41 `.go` files | ~8,000 |
|
||||||
|
| CLI commands (`cmd/`) | 12 `.go` files | ~600 |
|
||||||
|
| Frontend (`web/src/`) | ~20 files | ~3,500 |
|
||||||
|
| CSS (`web/src/styles/`) | 1 file | ~1,500 |
|
||||||
|
| **Total** | ~75 files | ~13,600 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Risks & Mitigations
|
||||||
|
|
||||||
|
| Risk | Impact | Mitigation |
|
||||||
|
|------|--------|-----------|
|
||||||
|
| AI provider API changes break orchestrator | Studio/Shell chat stops working | Orchestrator uses OpenAI-compatible format (widely supported). Fallback: user switches provider. |
|
||||||
|
| Tool install commands change (brew, apt) | Installer fails | Installer returns clear error messages. Doctor command diagnoses. User can install manually. |
|
||||||
|
| Frontend grows beyond vanilla React manageability | Hard to maintain | At current scale (4 components), this is not a risk. Re-evaluate if components exceed 20. |
|
||||||
|
| Security: API keys in config file | Key exposure | AES-256-GCM encryption at rest. Config file permissions 0600. |
|
||||||
|
| Terminal WebSocket security | Remote command execution | Server binds to 127.0.0.1 only. No remote access possible. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*End of Muyue PRD v1.0*
|
||||||
8
go.mod
@@ -1,11 +1,15 @@
|
|||||||
module github.com/muyue/muyue
|
module github.com/muyue/muyue
|
||||||
|
|
||||||
go 1.24.3
|
go 1.24.2
|
||||||
|
|
||||||
|
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
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,6 +30,7 @@ require (
|
|||||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
@@ -35,6 +40,7 @@ require (
|
|||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/termenv v0.16.0 // indirect
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
golang.org/x/text v0.23.0 // indirect
|
golang.org/x/text v0.23.0 // indirect
|
||||||
|
|||||||
11
go.sum
@@ -42,6 +42,7 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa
|
|||||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||||
github.com/creack/pty/v2 v2.0.1 h1:RDY1VY5b+7m2mfPsugucOYPIxMp+xal5ZheSyVzUA+k=
|
github.com/creack/pty/v2 v2.0.1 h1:RDY1VY5b+7m2mfPsugucOYPIxMp+xal5ZheSyVzUA+k=
|
||||||
@@ -50,8 +51,12 @@ 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/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
@@ -70,8 +75,14 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
|
|||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
|
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|||||||
457
internal/agent/definitions.go
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b\][^\x1b]*\x1b\\|\x1b[()][AB012]|\[\]`)
|
||||||
|
|
||||||
|
func stripANSI(s string) string {
|
||||||
|
return ansiRegex.ReplaceAllString(s, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
sudoCache bool
|
||||||
|
sudoCacheSet bool
|
||||||
|
sudoCacheOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
func NeedsSudoPassword() bool {
|
||||||
|
sudoCacheOnce.Do(func() {
|
||||||
|
if os.Geteuid() == 0 {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
err := exec.CommandContext(ctx, "sudo", "-n", "true").Run()
|
||||||
|
sudoCacheSet = true
|
||||||
|
sudoCache = err != nil
|
||||||
|
} else {
|
||||||
|
sudoCache = true
|
||||||
|
sudoCacheSet = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return sudoCache
|
||||||
|
}
|
||||||
|
|
||||||
|
type TerminalParams struct {
|
||||||
|
Command string `json:"command" description:"The shell command to execute"`
|
||||||
|
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) {
|
||||||
|
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.",
|
||||||
|
func(ctx context.Context, p TerminalParams) (ToolResponse, error) {
|
||||||
|
if p.Command == "" {
|
||||||
|
return TextErrorResponse("command is required"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if NeedsSudoPassword() {
|
||||||
|
trimmed := strings.TrimSpace(p.Command)
|
||||||
|
lower := strings.ToLower(trimmed)
|
||||||
|
prefixBlocked := strings.HasPrefix(lower, "sudo ") || strings.HasPrefix(lower, "doas ") || strings.HasPrefix(lower, "run0 ") || strings.HasPrefix(lower, "pkexec ")
|
||||||
|
anywhereBlocked := false
|
||||||
|
blockedCmd := ""
|
||||||
|
if !prefixBlocked {
|
||||||
|
for _, kw := range []string{"sudo", "doas", "run0", "pkexec"} {
|
||||||
|
for _, pattern := range []string{" " + kw + " ", "|" + kw + " ", ";" + kw + " ", "&&" + kw + " ", "||" + kw + " ", "`" + kw + " ", "$(" + kw + " "} {
|
||||||
|
if strings.Contains(lower, pattern) {
|
||||||
|
anywhereBlocked = true
|
||||||
|
blockedCmd = kw
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if anywhereBlocked {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if prefixBlocked || anywhereBlocked {
|
||||||
|
elevCmd := blockedCmd
|
||||||
|
if prefixBlocked {
|
||||||
|
elevCmd = strings.Fields(trimmed)[0]
|
||||||
|
}
|
||||||
|
return ToolResponse{
|
||||||
|
Content: fmt.Sprintf("BLOCKED: Command '%s' requires elevated privileges (%s). Passwordless sudo is not available. 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, elevCmd),
|
||||||
|
IsError: true,
|
||||||
|
Meta: map[string]string{"sudo_blocked": "true", "command": trimmed},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := time.Duration(p.Timeout) * time.Second
|
||||||
|
if timeout == 0 {
|
||||||
|
timeout = 60 * time.Second
|
||||||
|
}
|
||||||
|
if timeout > 300*time.Second {
|
||||||
|
timeout = 300 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
shell := detectShell()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, shell, "-c", p.Command)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
|
||||||
|
result := string(output)
|
||||||
|
result = stripANSI(result)
|
||||||
|
if len(result) > 10000 {
|
||||||
|
result = result[:10000] + "\n... [truncated]"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return TextErrorResponse(fmt.Sprintf("Error: %v\n\n%s", err, result)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return TextResponse(result), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type CrushRunParams struct {
|
||||||
|
Task string `json:"task" description:"The task description for Crush to execute"`
|
||||||
|
Timeout int `json:"timeout,omitempty" description:"Maximum execution time in seconds (default 1800, max 1800)"`
|
||||||
|
Cwd string `json:"cwd,omitempty" description:"Working directory in which to launch the agent (absolute path; falls back to user home)"`
|
||||||
|
WSLDistro string `json:"wsl_distro,omitempty" description:"On Windows host: WSL distribution to launch the agent in (e.g. 'Ubuntu')"`
|
||||||
|
WSLUser string `json:"wsl_user,omitempty" description:"On Windows host: WSL user to run the agent as"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCrushRunTool() (*ToolDefinition, error) {
|
||||||
|
return NewTool("crush_run",
|
||||||
|
"Delegate a complex coding task to the Crush AI agent. Crush has access to file editing, code search, bash execution, and other development tools. Use this for multi-step coding tasks like refactoring, debugging, implementing features, or code review. Optionally pass cwd to run in a specific directory, or wsl_distro/wsl_user to launch inside a WSL distribution under a specific user (Windows hosts only). Returns the agent's final output.",
|
||||||
|
func(ctx context.Context, p CrushRunParams) (ToolResponse, error) {
|
||||||
|
if p.Task == "" {
|
||||||
|
return TextErrorResponse("task is required"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := time.Duration(p.Timeout) * time.Second
|
||||||
|
if timeout == 0 {
|
||||||
|
timeout = 1800 * time.Second
|
||||||
|
}
|
||||||
|
if timeout > 1800*time.Second {
|
||||||
|
timeout = 1800 * time.Second
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd, prepErr := buildAgentCommand(ctx, "crush", []string{"run", p.Task}, p.Cwd, p.WSLDistro, p.WSLUser)
|
||||||
|
if prepErr != nil {
|
||||||
|
return TextErrorResponse(prepErr.Error()), nil
|
||||||
|
}
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
|
||||||
|
result := string(output)
|
||||||
|
if len(result) > 15000 {
|
||||||
|
result = result[:15000] + "\n... [truncated]"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
errMsg := fmt.Sprintf("Crush error: %v", err)
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
errMsg = fmt.Sprintf("Crush timed out after %d seconds. Try splitting the task into smaller parts.", int(timeout.Seconds()))
|
||||||
|
}
|
||||||
|
if result != "" {
|
||||||
|
errMsg += "\n\n" + result
|
||||||
|
}
|
||||||
|
return TextErrorResponse(errMsg), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return TextResponse(result), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClaudeRunParams struct {
|
||||||
|
Task string `json:"task" description:"The task description for Claude Code to execute"`
|
||||||
|
Timeout int `json:"timeout,omitempty" description:"Maximum execution time in seconds (default 1800, max 1800)"`
|
||||||
|
Cwd string `json:"cwd,omitempty" description:"Working directory in which to launch the agent (absolute path; falls back to user home)"`
|
||||||
|
WSLDistro string `json:"wsl_distro,omitempty" description:"On Windows host: WSL distribution to launch the agent in (e.g. 'Ubuntu')"`
|
||||||
|
WSLUser string `json:"wsl_user,omitempty" description:"On Windows host: WSL user to run the agent as"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClaudeRunTool() (*ToolDefinition, error) {
|
||||||
|
return NewTool("claude_run",
|
||||||
|
"Delegate a complex coding task to the Claude Code CLI agent. Claude has access to file editing, code search, bash execution. Use for multi-step coding tasks. Same cwd/wsl_distro/wsl_user options as crush_run.",
|
||||||
|
func(ctx context.Context, p ClaudeRunParams) (ToolResponse, error) {
|
||||||
|
if p.Task == "" {
|
||||||
|
return TextErrorResponse("task is required"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := time.Duration(p.Timeout) * time.Second
|
||||||
|
if timeout == 0 {
|
||||||
|
timeout = 1800 * time.Second
|
||||||
|
}
|
||||||
|
if timeout > 1800*time.Second {
|
||||||
|
timeout = 1800 * time.Second
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd, prepErr := buildAgentCommand(ctx, "claude", []string{"-p", p.Task}, p.Cwd, p.WSLDistro, p.WSLUser)
|
||||||
|
if prepErr != nil {
|
||||||
|
return TextErrorResponse(prepErr.Error()), nil
|
||||||
|
}
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
|
||||||
|
result := string(output)
|
||||||
|
if len(result) > 15000 {
|
||||||
|
result = result[:15000] + "\n... [truncated]"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
errMsg := fmt.Sprintf("Claude error: %v", err)
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
errMsg = fmt.Sprintf("Claude timed out after %d seconds. Try splitting the task into smaller parts.", int(timeout.Seconds()))
|
||||||
|
}
|
||||||
|
if result != "" {
|
||||||
|
errMsg += "\n\n" + result
|
||||||
|
}
|
||||||
|
return TextErrorResponse(errMsg), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return TextResponse(result), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReadFileParams struct {
|
||||||
|
Path string `json:"path" description:"Absolute or relative path to the file to read"`
|
||||||
|
Offset int `json:"offset,omitempty" description:"Line number to start reading from (0-based, default 0)"`
|
||||||
|
Limit int `json:"limit,omitempty" description:"Maximum number of lines to read (default 200, max 2000)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReadFileTool() (*ToolDefinition, error) {
|
||||||
|
return NewTool("read_file",
|
||||||
|
"Read file contents from the local filesystem. Returns the file content with line numbers. Supports offset/limit for reading specific sections of large files.",
|
||||||
|
func(ctx context.Context, p ReadFileParams) (ToolResponse, error) {
|
||||||
|
if p.Path == "" {
|
||||||
|
return TextErrorResponse("path is required"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
expanded := expandHome(p.Path)
|
||||||
|
data, err := readFileLimited(expanded, p.Offset, p.Limit)
|
||||||
|
if err != nil {
|
||||||
|
return TextErrorResponse(fmt.Sprintf("read error: %v", err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return TextResponse(data), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListFilesParams struct {
|
||||||
|
Path string `json:"path,omitempty" description:"Directory path to list (default: user home)"`
|
||||||
|
Depth int `json:"depth,omitempty" description:"Maximum depth to traverse (default 1, max 3)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewListFilesTool() (*ToolDefinition, error) {
|
||||||
|
return NewTool("list_files",
|
||||||
|
"List files and directories at a given path. Shows directory tree structure with file names. Useful for exploring project structure or finding specific files.",
|
||||||
|
func(ctx context.Context, p ListFilesParams) (ToolResponse, error) {
|
||||||
|
dir := expandHome(p.Path)
|
||||||
|
if dir == "" {
|
||||||
|
dir, _ = osUserHomeDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Depth <= 0 {
|
||||||
|
p.Depth = 1
|
||||||
|
}
|
||||||
|
if p.Depth > 3 {
|
||||||
|
p.Depth = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := listDirTree(dir, p.Depth, 0)
|
||||||
|
if err != nil {
|
||||||
|
return TextErrorResponse(fmt.Sprintf("list error: %v", err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return TextResponse(result), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchFilesParams struct {
|
||||||
|
Pattern string `json:"pattern" description:"Search pattern (supports * and ? glob wildcards)"`
|
||||||
|
Path string `json:"path,omitempty" description:"Directory to search in (default: current directory)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSearchFilesTool() (*ToolDefinition, error) {
|
||||||
|
return NewTool("search_files",
|
||||||
|
"Search for files by name pattern using glob syntax. Use * for any characters, ** for recursive matching. Returns matching file paths sorted by name.",
|
||||||
|
func(ctx context.Context, p SearchFilesParams) (ToolResponse, error) {
|
||||||
|
if p.Pattern == "" {
|
||||||
|
return TextErrorResponse("pattern is required"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := expandHome(p.Path)
|
||||||
|
if dir == "" {
|
||||||
|
dir = "."
|
||||||
|
}
|
||||||
|
|
||||||
|
matches, err := filepath.Glob(filepath.Join(dir, p.Pattern))
|
||||||
|
if err != nil {
|
||||||
|
return TextErrorResponse(fmt.Sprintf("glob error: %v", err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return TextResponse("No files found matching pattern."), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(matches) > 100 {
|
||||||
|
matches = matches[:100]
|
||||||
|
}
|
||||||
|
|
||||||
|
var result strings.Builder
|
||||||
|
for _, m := range matches {
|
||||||
|
result.WriteString(m)
|
||||||
|
result.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return TextResponse(result.String()), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type GrepContentParams struct {
|
||||||
|
Pattern string `json:"pattern" description:"Text pattern to search for in file contents"`
|
||||||
|
Path string `json:"path,omitempty" description:"Directory to search in (default: current directory)"`
|
||||||
|
Include string `json:"include,omitempty" description:"File extension filter, e.g. '*.go' or '*.{js,ts}'"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGrepContentTool() (*ToolDefinition, error) {
|
||||||
|
return NewTool("grep_content",
|
||||||
|
"Search for text patterns inside file contents. Returns matching lines with file paths and line numbers. Use include to filter by file extension.",
|
||||||
|
func(ctx context.Context, p GrepContentParams) (ToolResponse, error) {
|
||||||
|
if p.Pattern == "" {
|
||||||
|
return TextErrorResponse("pattern is required"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := expandHome(p.Path)
|
||||||
|
if dir == "" {
|
||||||
|
dir = "."
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := grepFiles(dir, p.Pattern, p.Include)
|
||||||
|
if err != nil {
|
||||||
|
return TextErrorResponse(fmt.Sprintf("grep error: %v", err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if result == "" {
|
||||||
|
return TextResponse("No matches found."), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return TextResponse(result), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetConfigParams struct {
|
||||||
|
Section string `json:"section,omitempty" description:"Config section to retrieve: 'providers', 'profile', 'tools', 'terminal', 'all' (default: 'all')"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGetConfigTool() (*ToolDefinition, error) {
|
||||||
|
return NewTool("get_config",
|
||||||
|
"Read the Muyue configuration. Returns provider settings, profile info, installed tools, terminal config, etc. Use section parameter to get a specific part, or 'all' for the full config.",
|
||||||
|
func(ctx context.Context, p GetConfigParams) (ToolResponse, error) {
|
||||||
|
return getConfigSection(p.Section), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type SetProviderParams struct {
|
||||||
|
Name string `json:"name" description:"Provider name (e.g. 'openai', 'anthropic', 'ollama')"`
|
||||||
|
APIKey string `json:"api_key,omitempty" description:"API key for the provider"`
|
||||||
|
BaseURL string `json:"base_url,omitempty" description:"Custom base URL for the provider API"`
|
||||||
|
Model string `json:"model,omitempty" description:"Model identifier to use"`
|
||||||
|
Active *bool `json:"active,omitempty" description:"Set to true to make this the active provider"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSetProviderTool() (*ToolDefinition, error) {
|
||||||
|
return NewTool("set_provider",
|
||||||
|
"Configure an AI provider in Muyue settings. Can create, update, or activate a provider. API keys are automatically encrypted. Set active=true to switch to this provider.",
|
||||||
|
func(ctx context.Context, p SetProviderParams) (ToolResponse, error) {
|
||||||
|
if p.Name == "" {
|
||||||
|
return TextErrorResponse("name is required"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return setProviderConfig(p), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type ManageSSHParams struct {
|
||||||
|
Action string `json:"action" description:"Action to perform: 'list', 'add', 'remove'"`
|
||||||
|
Name string `json:"name,omitempty" description:"Connection name (required for add/remove)"`
|
||||||
|
Host string `json:"host,omitempty" description:"SSH host (required for add)"`
|
||||||
|
Port int `json:"port,omitempty" description:"SSH port (default: 22)"`
|
||||||
|
User string `json:"user,omitempty" description:"SSH username (required for add)"`
|
||||||
|
KeyPath string `json:"key_path,omitempty" description:"Path to SSH private key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewManageSSHTool() (*ToolDefinition, error) {
|
||||||
|
return NewTool("manage_ssh",
|
||||||
|
"Manage SSH connections configured in Muyue. List existing connections, add new ones, or remove connections. SSH configs are persisted to the Muyue config file.",
|
||||||
|
func(ctx context.Context, p ManageSSHParams) (ToolResponse, error) {
|
||||||
|
if p.Action == "" {
|
||||||
|
return TextErrorResponse("action is required (list, add, remove)"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return manageSSHAction(p), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebFetchParams struct {
|
||||||
|
URL string `json:"url" description:"The URL to fetch content from"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWebFetchTool() (*ToolDefinition, error) {
|
||||||
|
return NewTool("web_fetch",
|
||||||
|
"Fetch content from a URL and return the text. Useful for reading documentation, APIs, or web resources. Only HTTP/HTTPS URLs are supported.",
|
||||||
|
func(ctx context.Context, p WebFetchParams) (ToolResponse, error) {
|
||||||
|
if p.URL == "" {
|
||||||
|
return TextErrorResponse("url is required"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchURL(p.URL), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultRegistry() *Registry {
|
||||||
|
r := NewRegistry()
|
||||||
|
|
||||||
|
tools := []*ToolDefinition{
|
||||||
|
must(NewTerminalTool()),
|
||||||
|
must(NewCrushRunTool()),
|
||||||
|
must(NewClaudeRunTool()),
|
||||||
|
must(NewReadFileTool()),
|
||||||
|
must(NewListFilesTool()),
|
||||||
|
must(NewSearchFilesTool()),
|
||||||
|
must(NewGrepContentTool()),
|
||||||
|
must(NewGetConfigTool()),
|
||||||
|
must(NewSetProviderTool()),
|
||||||
|
must(NewManageSSHTool()),
|
||||||
|
must(NewWebFetchTool()),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, t := range tools {
|
||||||
|
if err := r.Register(t); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func must(t *ToolDefinition, err error) *ToolDefinition {
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
616
internal/agent/impl.go
Normal file
@@ -0,0 +1,616 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func detectShell() string {
|
||||||
|
shells := []string{"zsh", "bash", "fish", "pwsh", "powershell"}
|
||||||
|
for _, s := range shells {
|
||||||
|
if path, err := exec.LookPath(s); err == nil {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "/bin/sh"
|
||||||
|
}
|
||||||
|
|
||||||
|
var validIdentifier = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
|
||||||
|
|
||||||
|
// buildAgentCommand assembles an agent execution command, optionally launching it
|
||||||
|
// inside a WSL distribution (Windows host only) and applying a working directory.
|
||||||
|
// On non-Windows hosts, wsl_* parameters are ignored.
|
||||||
|
func buildAgentCommand(ctx context.Context, bin string, args []string, cwd, wslDistro, wslUser string) (*exec.Cmd, error) {
|
||||||
|
if wslDistro != "" && runtime.GOOS == "windows" {
|
||||||
|
if !validIdentifier.MatchString(wslDistro) {
|
||||||
|
return nil, fmt.Errorf("invalid wsl_distro: %q", wslDistro)
|
||||||
|
}
|
||||||
|
if wslUser != "" && !validIdentifier.MatchString(wslUser) {
|
||||||
|
return nil, fmt.Errorf("invalid wsl_user: %q", wslUser)
|
||||||
|
}
|
||||||
|
wslArgs := []string{"-d", wslDistro}
|
||||||
|
if wslUser != "" {
|
||||||
|
wslArgs = append(wslArgs, "-u", wslUser)
|
||||||
|
}
|
||||||
|
if cwd != "" {
|
||||||
|
wslArgs = append(wslArgs, "--cd", cwd)
|
||||||
|
}
|
||||||
|
wslArgs = append(wslArgs, "--")
|
||||||
|
wslArgs = append(wslArgs, bin)
|
||||||
|
wslArgs = append(wslArgs, args...)
|
||||||
|
return exec.CommandContext(ctx, "wsl", wslArgs...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, bin, args...)
|
||||||
|
if cwd != "" {
|
||||||
|
dir := expandHome(cwd)
|
||||||
|
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
|
||||||
|
return nil, fmt.Errorf("cwd does not exist or is not a directory: %s", cwd)
|
||||||
|
}
|
||||||
|
cmd.Dir = dir
|
||||||
|
}
|
||||||
|
return cmd, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandHome(path string) string {
|
||||||
|
if path == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if path == "~" {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
return home
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(path, "~/") {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
return filepath.Join(home, path[2:])
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func osUserHomeDir() (string, error) {
|
||||||
|
return os.UserHomeDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
func readFileLimited(path string, offset, limit int) (string, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
if offset > len(lines) {
|
||||||
|
offset = len(lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
end := offset + limit
|
||||||
|
if limit <= 0 || limit > 2000 {
|
||||||
|
limit = 2000
|
||||||
|
}
|
||||||
|
if end > len(lines) {
|
||||||
|
end = len(lines)
|
||||||
|
}
|
||||||
|
if end-offset > limit {
|
||||||
|
end = offset + limit
|
||||||
|
}
|
||||||
|
|
||||||
|
selected := lines[offset:end]
|
||||||
|
|
||||||
|
var buf strings.Builder
|
||||||
|
for i, line := range selected {
|
||||||
|
fmt.Fprintf(&buf, "%6d\t%s\n", offset+i+1, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func listDirTree(dir string, maxDepth, currentDepth int) (string, error) {
|
||||||
|
info, err := os.Stat(dir)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return dir + "\n", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf strings.Builder
|
||||||
|
indent := strings.Repeat(" ", currentDepth)
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry.Name()
|
||||||
|
if strings.HasPrefix(name, ".") && name != "." && name != ".." {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.IsDir() {
|
||||||
|
fmt.Fprintf(&buf, "%s%s/\n", indent, name)
|
||||||
|
if currentDepth < maxDepth {
|
||||||
|
sub, err := listDirTree(filepath.Join(dir, name), maxDepth, currentDepth+1)
|
||||||
|
if err == nil {
|
||||||
|
buf.WriteString(sub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(&buf, "%s%s\n", indent, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func grepFiles(dir, pattern, include string) (string, error) {
|
||||||
|
if include != "" {
|
||||||
|
matches, err := filepath.Glob(filepath.Join(dir, include))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
var buf strings.Builder
|
||||||
|
for _, match := range matches {
|
||||||
|
result, err := grepInFile(match, pattern)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
buf.WriteString(result)
|
||||||
|
}
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return grepInDir(dir, pattern, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func grepInDir(dir, pattern string, depth int) (string, error) {
|
||||||
|
if depth > 10 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf strings.Builder
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry.Name()
|
||||||
|
if strings.HasPrefix(name, ".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
|
||||||
|
if entry.IsDir() {
|
||||||
|
sub, err := grepInDir(path, pattern, depth+1)
|
||||||
|
if err == nil {
|
||||||
|
buf.WriteString(sub)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := grepInFile(path, pattern)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
buf.WriteString(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func grepInFile(path, pattern string) (string, error) {
|
||||||
|
re, err := regexp.Compile(pattern)
|
||||||
|
if err != nil {
|
||||||
|
re, err = regexp.Compile(regexp.QuoteMeta(pattern))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
var buf strings.Builder
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
|
|
||||||
|
lineNum := 0
|
||||||
|
matchCount := 0
|
||||||
|
for scanner.Scan() {
|
||||||
|
lineNum++
|
||||||
|
if re.MatchString(scanner.Text()) {
|
||||||
|
fmt.Fprintf(&buf, "%s:%d: %s\n", path, lineNum, scanner.Text())
|
||||||
|
matchCount++
|
||||||
|
if matchCount >= 50 {
|
||||||
|
buf.WriteString("... [truncated, more matches exist]\n")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getConfigSection(section string) ToolResponse {
|
||||||
|
configPath, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return TextErrorResponse(fmt.Sprintf("cannot find config dir: %v", err))
|
||||||
|
}
|
||||||
|
configPath = filepath.Join(configPath, "muyue", "config.yaml")
|
||||||
|
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return TextErrorResponse(fmt.Sprintf("cannot read config: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
switch section {
|
||||||
|
case "providers", "profile", "tools", "terminal":
|
||||||
|
sectionData := extractYAMLSection(data, section)
|
||||||
|
if sectionData == "" {
|
||||||
|
return TextResponse(fmt.Sprintf("Section '%s' not found in config.", section))
|
||||||
|
}
|
||||||
|
return TextResponse(sectionData)
|
||||||
|
default:
|
||||||
|
content := string(data)
|
||||||
|
if len(content) > 8000 {
|
||||||
|
content = content[:8000] + "\n... [truncated]"
|
||||||
|
}
|
||||||
|
return TextResponse(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractYAMLSection(data []byte, section string) string {
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
inSection := false
|
||||||
|
indentLevel := 0
|
||||||
|
var buf strings.Builder
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||||
|
if inSection {
|
||||||
|
buf.WriteString("\n")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !inSection {
|
||||||
|
if strings.HasPrefix(trimmed, section+":") || strings.HasPrefix(trimmed, section+" ") {
|
||||||
|
inSection = true
|
||||||
|
indentLevel = len(line) - len(strings.TrimLeft(line, " "))
|
||||||
|
buf.WriteString(line)
|
||||||
|
buf.WriteString("\n")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
currentIndent := len(line) - len(strings.TrimLeft(line, " "))
|
||||||
|
if currentIndent <= indentLevel && trimmed != "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
buf.WriteString(line)
|
||||||
|
buf.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func setProviderConfig(p SetProviderParams) ToolResponse {
|
||||||
|
configPath, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return TextErrorResponse(fmt.Sprintf("cannot find config dir: %v", err))
|
||||||
|
}
|
||||||
|
configPath = filepath.Join(configPath, "muyue", "config.yaml")
|
||||||
|
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return TextErrorResponse(fmt.Sprintf("cannot read config: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
inProviders := false
|
||||||
|
providerIndent := 0
|
||||||
|
foundProvider := false
|
||||||
|
insertIdx := -1
|
||||||
|
lastProviderEnd := -1
|
||||||
|
|
||||||
|
for i, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if !inProviders {
|
||||||
|
if strings.HasPrefix(trimmed, "providers:") {
|
||||||
|
inProviders = true
|
||||||
|
providerIndent = len(line) - len(strings.TrimLeft(line, " "))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
currentIndent := len(line) - len(strings.TrimLeft(line, " "))
|
||||||
|
if currentIndent <= providerIndent && trimmed != "" && !strings.HasPrefix(trimmed, "#") {
|
||||||
|
lastProviderEnd = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentIndent == providerIndent+2 && strings.HasPrefix(trimmed, "- name:") {
|
||||||
|
nameMatch := strings.TrimPrefix(trimmed, "- name:")
|
||||||
|
nameMatch = strings.TrimSpace(nameMatch)
|
||||||
|
if nameMatch == p.Name {
|
||||||
|
foundProvider = true
|
||||||
|
insertIdx = i
|
||||||
|
}
|
||||||
|
if insertIdx == -1 || insertIdx < i {
|
||||||
|
insertIdx = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastProviderEnd == -1 {
|
||||||
|
lastProviderEnd = len(lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
entryIndent := strings.Repeat(" ", providerIndent+4)
|
||||||
|
|
||||||
|
var newEntry strings.Builder
|
||||||
|
newEntry.WriteString(fmt.Sprintf(" - name: %s\n", p.Name))
|
||||||
|
if p.Model != "" {
|
||||||
|
newEntry.WriteString(fmt.Sprintf("%smodel: %s\n", entryIndent, p.Model))
|
||||||
|
}
|
||||||
|
if p.BaseURL != "" {
|
||||||
|
newEntry.WriteString(fmt.Sprintf("%sbase_url: %s\n", entryIndent, p.BaseURL))
|
||||||
|
}
|
||||||
|
if p.APIKey != "" {
|
||||||
|
newEntry.WriteString(fmt.Sprintf("%sapi_key: %s\n", entryIndent, p.APIKey))
|
||||||
|
}
|
||||||
|
if p.Active != nil {
|
||||||
|
newEntry.WriteString(fmt.Sprintf("%sactive: %v\n", entryIndent, *p.Active))
|
||||||
|
}
|
||||||
|
|
||||||
|
if foundProvider && insertIdx >= 0 {
|
||||||
|
var endIdx int
|
||||||
|
for endIdx = insertIdx + 1; endIdx < len(lines); endIdx++ {
|
||||||
|
li := len(lines[endIdx]) - len(strings.TrimLeft(lines[endIdx], " "))
|
||||||
|
if li <= providerIndent+2 || lines[endIdx] == "" {
|
||||||
|
if endIdx > insertIdx+1 && strings.TrimSpace(lines[endIdx]) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newLines := make([]string, 0, len(lines))
|
||||||
|
newLines = append(newLines, lines[:insertIdx]...)
|
||||||
|
newLines = append(newLines, strings.TrimSuffix(newEntry.String(), "\n"))
|
||||||
|
newLines = append(newLines, lines[endIdx:]...)
|
||||||
|
lines = newLines
|
||||||
|
} else {
|
||||||
|
insertAt := lastProviderEnd
|
||||||
|
newLines := make([]string, 0, len(lines)+10)
|
||||||
|
newLines = append(newLines, lines[:insertAt]...)
|
||||||
|
newLines = append(newLines, strings.TrimSuffix(newEntry.String(), "\n"))
|
||||||
|
newLines = append(newLines, lines[insertAt:]...)
|
||||||
|
lines = newLines
|
||||||
|
}
|
||||||
|
|
||||||
|
content := strings.Join(lines, "\n")
|
||||||
|
if err := os.WriteFile(configPath, []byte(content), 0600); err != nil {
|
||||||
|
return TextErrorResponse(fmt.Sprintf("write config error: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return TextResponse(fmt.Sprintf("Provider '%s' configured successfully.", p.Name))
|
||||||
|
}
|
||||||
|
|
||||||
|
func manageSSHAction(p ManageSSHParams) ToolResponse {
|
||||||
|
configPath, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return TextErrorResponse(fmt.Sprintf("cannot find config dir: %v", err))
|
||||||
|
}
|
||||||
|
configPath = filepath.Join(configPath, "muyue", "config.yaml")
|
||||||
|
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return TextErrorResponse(fmt.Sprintf("cannot read config: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
switch p.Action {
|
||||||
|
case "list":
|
||||||
|
sshSection := extractYAMLSection(data, "ssh")
|
||||||
|
if sshSection == "" {
|
||||||
|
return TextResponse("No SSH connections configured.")
|
||||||
|
}
|
||||||
|
return TextResponse(sshSection)
|
||||||
|
|
||||||
|
case "add":
|
||||||
|
if p.Name == "" || p.Host == "" || p.User == "" {
|
||||||
|
return TextErrorResponse("name, host, and user are required for add action")
|
||||||
|
}
|
||||||
|
if p.Port == 0 {
|
||||||
|
p.Port = 22
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
sshIdx := -1
|
||||||
|
sshIndent := 0
|
||||||
|
lastSSHEnd := -1
|
||||||
|
|
||||||
|
for i, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if sshIdx == -1 && strings.HasPrefix(trimmed, "ssh:") {
|
||||||
|
sshIdx = i
|
||||||
|
sshIndent = len(line) - len(strings.TrimLeft(line, " "))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sshIdx != -1 {
|
||||||
|
li := len(line) - len(strings.TrimLeft(line, " "))
|
||||||
|
if li <= sshIndent && trimmed != "" {
|
||||||
|
lastSSHEnd = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastSSHEnd == -1 {
|
||||||
|
lastSSHEnd = len(lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := fmt.Sprintf(" - name: %s\n host: %s\n port: %d\n user: %s", p.Name, p.Host, p.Port, p.User)
|
||||||
|
if p.KeyPath != "" {
|
||||||
|
entry += fmt.Sprintf("\n key_path: %s", p.KeyPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
newLines := make([]string, 0, len(lines)+10)
|
||||||
|
newLines = append(newLines, lines[:lastSSHEnd]...)
|
||||||
|
newLines = append(newLines, entry)
|
||||||
|
newLines = append(newLines, lines[lastSSHEnd:]...)
|
||||||
|
|
||||||
|
if err := os.WriteFile(configPath, []byte(strings.Join(newLines, "\n")), 0600); err != nil {
|
||||||
|
return TextErrorResponse(fmt.Sprintf("write config error: %v", err))
|
||||||
|
}
|
||||||
|
return TextResponse(fmt.Sprintf("SSH connection '%s' (%s@%s:%d) added.", p.Name, p.User, p.Host, p.Port))
|
||||||
|
|
||||||
|
case "remove":
|
||||||
|
if p.Name == "" {
|
||||||
|
return TextErrorResponse("name is required for remove action")
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
newLines := make([]string, 0, len(lines))
|
||||||
|
skipping := false
|
||||||
|
removed := false
|
||||||
|
|
||||||
|
for i, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if strings.Contains(trimmed, "name: "+p.Name) && strings.HasPrefix(trimmed, "-") {
|
||||||
|
skipping = true
|
||||||
|
removed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if skipping {
|
||||||
|
li := len(line) - len(strings.TrimLeft(line, " "))
|
||||||
|
if li > 6 && i < len(lines)-1 && strings.TrimSpace(lines[i+1]) != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
skipping = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
newLines = append(newLines, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !removed {
|
||||||
|
return TextErrorResponse(fmt.Sprintf("SSH connection '%s' not found.", p.Name))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(configPath, []byte(strings.Join(newLines, "\n")), 0600); err != nil {
|
||||||
|
return TextErrorResponse(fmt.Sprintf("write config error: %v", err))
|
||||||
|
}
|
||||||
|
return TextResponse(fmt.Sprintf("SSH connection '%s' removed.", p.Name))
|
||||||
|
|
||||||
|
default:
|
||||||
|
return TextErrorResponse("unknown action. Use 'list', 'add', or 'remove'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchURL(url string) ToolResponse {
|
||||||
|
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||||
|
return TextErrorResponse("only http/https URLs are supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return TextErrorResponse(fmt.Sprintf("create request: %v", err))
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "MuyueStudio/1.0")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return TextErrorResponse(fmt.Sprintf("fetch error: %v", err))
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 50000))
|
||||||
|
if err != nil {
|
||||||
|
return TextErrorResponse(fmt.Sprintf("read error: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return TextErrorResponse(fmt.Sprintf("HTTP %d: %s", resp.StatusCode, truncate(string(body), 2000)))
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := resp.Header.Get("Content-Type")
|
||||||
|
if strings.Contains(contentType, "text/html") {
|
||||||
|
text := stripHTML(string(body))
|
||||||
|
if len(text) > 8000 {
|
||||||
|
text = text[:8000] + "\n... [truncated]"
|
||||||
|
}
|
||||||
|
return TextResponse(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := string(body)
|
||||||
|
if len(result) > 10000 {
|
||||||
|
result = result[:10000] + "\n... [truncated]"
|
||||||
|
}
|
||||||
|
return TextResponse(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, maxLen int) string {
|
||||||
|
if len(s) <= maxLen {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:maxLen] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripHTML(html string) string {
|
||||||
|
tagRe := regexp.MustCompile(`<[^>]*>`)
|
||||||
|
text := tagRe.ReplaceAllString(html, " ")
|
||||||
|
|
||||||
|
entityRe := regexp.MustCompile(`&[a-zA-Z]+;`)
|
||||||
|
text = entityRe.ReplaceAllStringFunc(text, func(s string) string {
|
||||||
|
switch s {
|
||||||
|
case "&":
|
||||||
|
return "&"
|
||||||
|
case "<":
|
||||||
|
return "<"
|
||||||
|
case ">":
|
||||||
|
return ">"
|
||||||
|
case """:
|
||||||
|
return "\""
|
||||||
|
case "'":
|
||||||
|
return "'"
|
||||||
|
case " ":
|
||||||
|
return " "
|
||||||
|
default:
|
||||||
|
return " "
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
multiSpace := regexp.MustCompile(`\s+`)
|
||||||
|
text = multiSpace.ReplaceAllString(text, " ")
|
||||||
|
return strings.TrimSpace(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = runtime.GOOS
|
||||||
|
var _ = json.Marshal
|
||||||
10
internal/agent/prompt.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import _ "embed"
|
||||||
|
|
||||||
|
//go:embed prompts/studio_system.md
|
||||||
|
var studioSystemPrompt string
|
||||||
|
|
||||||
|
func StudioSystemPrompt() string {
|
||||||
|
return studioSystemPrompt
|
||||||
|
}
|
||||||
142
internal/agent/prompts/studio_system.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
Tu es l'assistant IA de **Muyue Studio**, le centre de commandement de l'environnement de développement de l'utilisateur, et tu es spécialisé dans la **construction de prompts** selon la **méthode BMAD** (Breakthrough Method for Agile AI-Driven Development — https://github.com/bmad-code-org/BMAD-METHOD).
|
||||||
|
|
||||||
|
Tu es intégré dans Muyue, un gestionnaire d'environnement de développement de bureau. Ton rôle est double :
|
||||||
|
1. Aider l'utilisateur à configurer, gérer et optimiser son environnement dev (avec les outils ci-dessous).
|
||||||
|
2. Construire pour lui des prompts structurés et actionnables avant d'exécuter une tâche complexe ou de la déléguer à un agent (`crush_run`, `claude_run`).
|
||||||
|
|
||||||
|
## Méthode BMAD — principes appliqués à chaque réponse
|
||||||
|
|
||||||
|
BMAD organise le travail IA comme une équipe agile : chaque demande est traitée avec une persona spécifique (Analyst, PM, Architect, SM, Dev, QA) puis exécutée. Tu n'as pas besoin de jouer toutes les personas — applique simplement leurs réflexes :
|
||||||
|
|
||||||
|
- **Analyst** : reformule l'objectif réel derrière la demande en 1 phrase. S'il est ambigu, choisis l'interprétation la plus probable et indique-la au début.
|
||||||
|
- **PM** : découpe en livrables concrets (épopée → stories). Pas plus de 3-5 stories pour une demande, chaque story doit être indépendamment livrable.
|
||||||
|
- **Architect** : pour toute story qui touche au code, identifie les fichiers concernés, les contraintes (compat, style, perf, sécurité) et les risques avant d'écrire.
|
||||||
|
- **SM (Scrum Master)** : si tu délègues à `crush_run`/`claude_run`, fournis un prompt **autonome** : objectif, contraintes, fichiers cibles, critère d'acceptation. Pas de référence à la conversation parente — l'agent ne la voit pas.
|
||||||
|
- **Dev** : exécute story par story. Vérifie chaque livraison avant de passer à la suivante.
|
||||||
|
- **QA** : avant de répondre "fini", relis l'objectif initial et confirme qu'il est atteint.
|
||||||
|
|
||||||
|
## Format d'un prompt BMAD délégué
|
||||||
|
|
||||||
|
Quand tu construis un prompt pour `crush_run`/`claude_run`, suis ce gabarit :
|
||||||
|
|
||||||
|
```
|
||||||
|
[OBJECTIF] <une phrase, l'objectif final>
|
||||||
|
[CONTEXTE] <fichiers/dossiers concernés, ce qui existe déjà>
|
||||||
|
[CONTRAINTES] <ne pas faire X, préserver Y, respecter style Z>
|
||||||
|
[LIVRABLE] <fichier(s) modifié(s), comportement attendu>
|
||||||
|
[CRITÈRE D'ACCEPTATION] <comment savoir que c'est fini>
|
||||||
|
```
|
||||||
|
|
||||||
|
Ce gabarit est **obligatoire** pour toute délégation à un agent. Il évite que l'agent erre, suppose, ou produise du code hors-périmètre.
|
||||||
|
|
||||||
|
<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
|
||||||
|
|
||||||
|
Muyue gère :
|
||||||
|
- **Fournisseurs IA** (OpenAI, Anthropic, Ollama, MiniMax, etc.)
|
||||||
|
- **Outils de développement** (Crush, Claude Code, etc.)
|
||||||
|
- **Terminaux locaux et SSH**
|
||||||
|
- **Configuration et préférences**
|
||||||
|
- **Serveurs MCP et LSP**
|
||||||
|
|
||||||
|
## Outils disponibles
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
| **claude_run** | Déléguer une tâche complexe à Claude Code CLI |
|
||||||
|
| **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 |
|
||||||
|
| **browser_test** | Piloter un onglet de navigateur de l'utilisateur (clic, eval, lecture console) — voir `<browser_test_strategy>` ci-dessous |
|
||||||
|
|
||||||
|
<browser_test_strategy>
|
||||||
|
Quand l'utilisateur demande de **tester** une UI / une page (ses boutons, ses formulaires, son comportement), utilise `browser_test`. La page cible doit déjà être connectée via le snippet de l'onglet "Tests" — sinon, l'outil te le dira et tu demandes à l'utilisateur de coller le snippet.
|
||||||
|
|
||||||
|
Boucle recommandée :
|
||||||
|
|
||||||
|
1. `browser_test` action `summary` — voir l'URL, le titre et les dernières erreurs console déjà présentes.
|
||||||
|
2. `browser_test` action `list_clickables` — récupérer la liste indexée des boutons / liens / inputs cliquables.
|
||||||
|
3. Pour chaque cible : `browser_test` action `click` (avec `index` ou `selector`).
|
||||||
|
4. Immédiatement après chaque clic, **regarde le `console_delta` retourné** : c'est la liste des messages console émis pendant le clic. `level: "error"` = bouton cassé.
|
||||||
|
5. Vérifie aussi `current_url` retourné — un changement d'URL inattendu peut signaler un bug.
|
||||||
|
6. Si l'élément ouvre un dialog ou modifie le DOM, refais `list_clickables` pour découvrir les nouveaux éléments.
|
||||||
|
7. Pour les inputs : utilise `type` avant `click` sur le bouton de soumission.
|
||||||
|
8. À la fin, fournis un **rapport** structuré : ✓ boutons OK / ✗ boutons cassés (avec le message d'erreur exact) / ⚠ boutons disabled ou non trouvés.
|
||||||
|
|
||||||
|
Astuces :
|
||||||
|
- Préfère cliquer **par `index`** que par sélecteur — le sélecteur change avec le DOM, l'index reste stable jusqu'au prochain `list_clickables`.
|
||||||
|
- Entre deux actions sensibles, `wait` 200-500 ms si la page a des transitions / fetches asynchrones.
|
||||||
|
- N'utilise jamais `eval` pour cliquer si `click` suffit.
|
||||||
|
</browser_test_strategy>
|
||||||
|
|
||||||
|
<tool_strategy>
|
||||||
|
- **Recherche avant action** — Utilise `search_files`, `grep_content`, `read_file` avant de supposer quoi que ce soit sur l'état du système
|
||||||
|
- **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
|
||||||
|
- **Lecture de fichiers** — Utilise TOUJOURS `read_file` pour lire le contenu d'un fichier. N'utilise PAS `terminal` avec `cat` pour lire des fichiers — `read_file` est plus rapide, plus précis, et consomme moins de tokens
|
||||||
|
- **Parallélisme** — Lance plusieurs appels d'outils en parallèle quand les opérations sont indépendantes
|
||||||
|
- **Troncature** — Si un résultat d'outil dépasse 2000 caractères, résume les points clés au lieu de tout afficher
|
||||||
|
- **Une chose à la fois** — Sauf si les opérations sont indépendantes, exécute séquentiellement
|
||||||
|
</tool_strategy>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<error_recovery>
|
||||||
|
1. Lis le message d'erreur complet
|
||||||
|
2. Comprends la cause racine
|
||||||
|
3. Essaie une approche différente (pas la même)
|
||||||
|
4. Cherche du code similaire qui fonctionne
|
||||||
|
5. Applique un correctif ciblé
|
||||||
|
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
|
||||||
|
|
||||||
|
- **Code** : blocs markdown avec le langage spécifié
|
||||||
|
- **Résultats d'outils** : résume les points clés, max 2000 caractères, ne copie pas des milliers de lignes
|
||||||
|
- **Erreurs** : explique clairement la cause et propose une solution concrète
|
||||||
|
- **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.
|
||||||
218
internal/agent/tools.go
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ToolCall struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments json.RawMessage `json:"arguments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolResponse struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
IsError bool `json:"is_error"`
|
||||||
|
Meta map[string]string `json:"meta,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TextResponse(content string) ToolResponse {
|
||||||
|
return ToolResponse{Content: content}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TextErrorResponse(msg string) ToolResponse {
|
||||||
|
return ToolResponse{Content: msg, IsError: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolDefinition struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Params json.RawMessage `json:"parameters"`
|
||||||
|
Handler func(ctx context.Context, args json.RawMessage) (ToolResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (td *ToolDefinition) Execute(ctx context.Context, call ToolCall) (ToolResponse, error) {
|
||||||
|
resp, err := td.Handler(ctx, call.Arguments)
|
||||||
|
if err != nil {
|
||||||
|
return ToolResponse{Content: err.Error(), IsError: true}, nil
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (td *ToolDefinition) ToOpenAITool() map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"type": "function",
|
||||||
|
"function": map[string]interface{}{
|
||||||
|
"name": td.Name,
|
||||||
|
"description": td.Description,
|
||||||
|
"parameters": td.Params,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTool[P any](name, description string, handler func(ctx context.Context, params P) (ToolResponse, error)) (*ToolDefinition, error) {
|
||||||
|
var zero P
|
||||||
|
paramsSchema, err := generateSchema(zero)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("generate schema for %s: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wrappedHandler := func(ctx context.Context, raw json.RawMessage) (ToolResponse, error) {
|
||||||
|
var params P
|
||||||
|
if err := json.Unmarshal(raw, ¶ms); err != nil {
|
||||||
|
return TextErrorResponse(fmt.Sprintf("invalid arguments: %v", err)), nil
|
||||||
|
}
|
||||||
|
return handler(ctx, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ToolDefinition{
|
||||||
|
Name: name,
|
||||||
|
Description: description,
|
||||||
|
Params: paramsSchema,
|
||||||
|
Handler: wrappedHandler,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Registry struct {
|
||||||
|
tools map[string]*ToolDefinition
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRegistry() *Registry {
|
||||||
|
return &Registry{
|
||||||
|
tools: make(map[string]*ToolDefinition),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) Register(tool *ToolDefinition) error {
|
||||||
|
if _, exists := r.tools[tool.Name]; exists {
|
||||||
|
return fmt.Errorf("tool %q already registered", tool.Name)
|
||||||
|
}
|
||||||
|
r.tools[tool.Name] = tool
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) Get(name string) (*ToolDefinition, bool) {
|
||||||
|
t, ok := r.tools[name]
|
||||||
|
return t, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) All() []*ToolDefinition {
|
||||||
|
out := make([]*ToolDefinition, 0, len(r.tools))
|
||||||
|
for _, t := range r.tools {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) OpenAITools() []map[string]interface{} {
|
||||||
|
out := make([]map[string]interface{}, 0, len(r.tools))
|
||||||
|
for _, t := range r.tools {
|
||||||
|
out = append(out, t.ToOpenAITool())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) Execute(ctx context.Context, call ToolCall) (ToolResponse, error) {
|
||||||
|
tool, ok := r.tools[call.Name]
|
||||||
|
if !ok {
|
||||||
|
return TextErrorResponse(fmt.Sprintf("unknown tool: %s", call.Name)), nil
|
||||||
|
}
|
||||||
|
return tool.Execute(ctx, call)
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateSchema(v interface{}) (json.RawMessage, error) {
|
||||||
|
t := reflect.TypeOf(v)
|
||||||
|
if t == nil {
|
||||||
|
return json.RawMessage(`{"type":"object","properties":{}}`), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.Kind() == reflect.Ptr {
|
||||||
|
t = t.Elem()
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.Kind() != reflect.Struct {
|
||||||
|
return json.RawMessage(`{"type":"object","properties":{}}`), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
props := make(map[string]interface{})
|
||||||
|
required := []string{}
|
||||||
|
|
||||||
|
for i := 0; i < t.NumField(); i++ {
|
||||||
|
field := t.Field(i)
|
||||||
|
if !field.IsExported() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonTag := field.Tag.Get("json")
|
||||||
|
if jsonTag == "-" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonName := field.Name
|
||||||
|
parts := strings.Split(jsonTag, ",")
|
||||||
|
if parts[0] != "" {
|
||||||
|
jsonName = parts[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
omitempty := false
|
||||||
|
for _, part := range parts[1:] {
|
||||||
|
if part == "omitempty" {
|
||||||
|
omitempty = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
desc := field.Tag.Get("description")
|
||||||
|
prop := map[string]interface{}{
|
||||||
|
"type": goTypeToJSON(field.Type),
|
||||||
|
}
|
||||||
|
if desc != "" {
|
||||||
|
prop["description"] = desc
|
||||||
|
}
|
||||||
|
|
||||||
|
props[jsonName] = prop
|
||||||
|
if !omitempty {
|
||||||
|
required = append(required, jsonName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
schema := map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": props,
|
||||||
|
}
|
||||||
|
if len(required) > 0 {
|
||||||
|
schema["required"] = required
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(schema)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.RawMessage(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func goTypeToJSON(t reflect.Type) string {
|
||||||
|
switch t.Kind() {
|
||||||
|
case reflect.String:
|
||||||
|
return "string"
|
||||||
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
|
||||||
|
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||||
|
return "integer"
|
||||||
|
case reflect.Float32, reflect.Float64:
|
||||||
|
return "number"
|
||||||
|
case reflect.Bool:
|
||||||
|
return "boolean"
|
||||||
|
case reflect.Slice:
|
||||||
|
if t.Elem().Kind() == reflect.Uint8 {
|
||||||
|
return "string"
|
||||||
|
}
|
||||||
|
return "array"
|
||||||
|
case reflect.Map:
|
||||||
|
return "object"
|
||||||
|
default:
|
||||||
|
return "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
612
internal/api/browsertest.go
Normal file
@@ -0,0 +1,612 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// Browser-test feature: an out-of-process page (the user's target tab)
|
||||||
|
// connects to Muyue via WebSocket using a short-lived token, and exposes a
|
||||||
|
// thin RPC: Studio's AI can list clickable elements, click them, evaluate JS,
|
||||||
|
// read the recent console buffer, and observe what changes after each action.
|
||||||
|
//
|
||||||
|
// Threat model: an injected snippet runs in the user's chosen page only, with
|
||||||
|
// the same origin as that page; the WS endpoint is bound to localhost and
|
||||||
|
// gated by a 5-minute token issued by the local Muyue server.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/muyue/muyue/internal/agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
browserTestTokenTTL = 5 * time.Minute
|
||||||
|
browserTestCommandTTL = 30 * time.Second
|
||||||
|
browserTestConsoleMax = 200
|
||||||
|
browserTestSessionsMax = 16
|
||||||
|
)
|
||||||
|
|
||||||
|
// BrowserTestSession represents one connected browser tab.
|
||||||
|
type BrowserTestSession struct {
|
||||||
|
ID string
|
||||||
|
URL string
|
||||||
|
Title string
|
||||||
|
conn *websocket.Conn
|
||||||
|
mu sync.Mutex
|
||||||
|
console []ConsoleEntry
|
||||||
|
pending map[string]chan json.RawMessage
|
||||||
|
pendingMu sync.Mutex
|
||||||
|
connectedAt time.Time
|
||||||
|
writeMu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConsoleEntry is a captured console message from the connected page.
|
||||||
|
type ConsoleEntry struct {
|
||||||
|
Level string `json:"level"` // log, info, warn, error, debug
|
||||||
|
Message string `json:"message"`
|
||||||
|
Time string `json:"time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BrowserTestStore manages active sessions + pending one-shot connect tokens.
|
||||||
|
type BrowserTestStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
sessions map[string]*BrowserTestSession
|
||||||
|
tokens map[string]time.Time
|
||||||
|
tokensMu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBrowserTestStore() *BrowserTestStore {
|
||||||
|
return &BrowserTestStore{
|
||||||
|
sessions: map[string]*BrowserTestSession{},
|
||||||
|
tokens: map[string]time.Time{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueToken creates a single-use token used by the snippet to authenticate.
|
||||||
|
func (s *BrowserTestStore) IssueToken() string {
|
||||||
|
buf := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return fmt.Sprintf("fallback-%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
tok := hex.EncodeToString(buf)
|
||||||
|
s.tokensMu.Lock()
|
||||||
|
now := time.Now()
|
||||||
|
for k, v := range s.tokens {
|
||||||
|
if now.Sub(v) > browserTestTokenTTL {
|
||||||
|
delete(s.tokens, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.tokens[tok] = now
|
||||||
|
s.tokensMu.Unlock()
|
||||||
|
return tok
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConsumeToken validates and removes a token in one step.
|
||||||
|
func (s *BrowserTestStore) ConsumeToken(tok string) bool {
|
||||||
|
s.tokensMu.Lock()
|
||||||
|
defer s.tokensMu.Unlock()
|
||||||
|
t, ok := s.tokens[tok]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
delete(s.tokens, tok)
|
||||||
|
return time.Since(t) <= browserTestTokenTTL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register inserts a new session, evicting the oldest if at capacity.
|
||||||
|
func (s *BrowserTestStore) Register(session *BrowserTestSession) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if len(s.sessions) >= browserTestSessionsMax {
|
||||||
|
var oldestID string
|
||||||
|
var oldest time.Time
|
||||||
|
for id, sess := range s.sessions {
|
||||||
|
if oldestID == "" || sess.connectedAt.Before(oldest) {
|
||||||
|
oldestID = id
|
||||||
|
oldest = sess.connectedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if old, ok := s.sessions[oldestID]; ok {
|
||||||
|
old.conn.Close()
|
||||||
|
delete(s.sessions, oldestID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.sessions[session.ID] = session
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BrowserTestStore) Remove(id string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if sess, ok := s.sessions[id]; ok {
|
||||||
|
sess.conn.Close()
|
||||||
|
delete(s.sessions, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BrowserTestStore) Get(id string) *BrowserTestSession {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.sessions[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick returns the requested session by ID, or the most-recently-connected
|
||||||
|
// session if id is empty. Returns nil if no session matches.
|
||||||
|
func (s *BrowserTestStore) Pick(id string) *BrowserTestSession {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
if id != "" {
|
||||||
|
return s.sessions[id]
|
||||||
|
}
|
||||||
|
var picked *BrowserTestSession
|
||||||
|
for _, sess := range s.sessions {
|
||||||
|
if picked == nil || sess.connectedAt.After(picked.connectedAt) {
|
||||||
|
picked = sess
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return picked
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BrowserTestStore) List() []map[string]interface{} {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
out := make([]map[string]interface{}, 0, len(s.sessions))
|
||||||
|
for _, sess := range s.sessions {
|
||||||
|
out = append(out, map[string]interface{}{
|
||||||
|
"id": sess.ID,
|
||||||
|
"url": sess.URL,
|
||||||
|
"title": sess.Title,
|
||||||
|
"connected_at": sess.connectedAt.Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send issues an RPC command to the browser session and waits up to TTL for
|
||||||
|
// the matching reply. Returns the raw payload or an error.
|
||||||
|
func (sess *BrowserTestSession) Send(action string, params map[string]interface{}) (json.RawMessage, error) {
|
||||||
|
cid := newCorrelationID()
|
||||||
|
ch := make(chan json.RawMessage, 1)
|
||||||
|
sess.pendingMu.Lock()
|
||||||
|
sess.pending[cid] = ch
|
||||||
|
sess.pendingMu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
sess.pendingMu.Lock()
|
||||||
|
delete(sess.pending, cid)
|
||||||
|
sess.pendingMu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
cmd := map[string]interface{}{
|
||||||
|
"id": cid,
|
||||||
|
"action": action,
|
||||||
|
"params": params,
|
||||||
|
}
|
||||||
|
sess.writeMu.Lock()
|
||||||
|
err := sess.conn.WriteJSON(cmd)
|
||||||
|
sess.writeMu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("write: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case payload := <-ch:
|
||||||
|
return payload, nil
|
||||||
|
case <-time.After(browserTestCommandTTL):
|
||||||
|
return nil, fmt.Errorf("browser session did not reply within %s", browserTestCommandTTL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppendConsole records a console line, trimming to the buffer cap.
|
||||||
|
func (sess *BrowserTestSession) AppendConsole(level, message string) {
|
||||||
|
sess.mu.Lock()
|
||||||
|
defer sess.mu.Unlock()
|
||||||
|
sess.console = append(sess.console, ConsoleEntry{
|
||||||
|
Level: level,
|
||||||
|
Message: message,
|
||||||
|
Time: time.Now().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
if len(sess.console) > browserTestConsoleMax {
|
||||||
|
sess.console = sess.console[len(sess.console)-browserTestConsoleMax:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SnapshotConsole returns a copy of the current console buffer.
|
||||||
|
func (sess *BrowserTestSession) SnapshotConsole() []ConsoleEntry {
|
||||||
|
sess.mu.Lock()
|
||||||
|
defer sess.mu.Unlock()
|
||||||
|
out := make([]ConsoleEntry, len(sess.console))
|
||||||
|
copy(out, sess.console)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCorrelationID() string {
|
||||||
|
buf := make([]byte, 8)
|
||||||
|
rand.Read(buf)
|
||||||
|
return hex.EncodeToString(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP handlers --------------------------------------------------------------
|
||||||
|
|
||||||
|
func (s *Server) handleBrowserTestSnippet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" {
|
||||||
|
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tok := s.browserTestStore.IssueToken()
|
||||||
|
host := r.Host
|
||||||
|
if host == "" {
|
||||||
|
host = "127.0.0.1"
|
||||||
|
}
|
||||||
|
scheme := "ws"
|
||||||
|
if r.TLS != nil {
|
||||||
|
scheme = "wss"
|
||||||
|
}
|
||||||
|
wsURL := fmt.Sprintf("%s://%s/api/ws/browser-test?token=%s", scheme, host, tok)
|
||||||
|
snippet := buildBrowserTestSnippet(wsURL)
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"token": tok,
|
||||||
|
"ws_url": wsURL,
|
||||||
|
"snippet": snippet,
|
||||||
|
"expires_in": int(browserTestTokenTTL / time.Second),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBrowserTestSessions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" {
|
||||||
|
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"sessions": s.browserTestStore.List(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBrowserTestConsole(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" {
|
||||||
|
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := strings.TrimPrefix(r.URL.Path, "/api/test/console/")
|
||||||
|
sess := s.browserTestStore.Pick(id)
|
||||||
|
if sess == nil {
|
||||||
|
writeError(w, "no active browser test session", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"session_id": sess.ID,
|
||||||
|
"url": sess.URL,
|
||||||
|
"console": sess.SnapshotConsole(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// browserTestUpgrader accepts any origin: the connection is gated by a
|
||||||
|
// short-lived token issued to the local UI, not by Origin checking.
|
||||||
|
var browserTestUpgrader = websocket.Upgrader{
|
||||||
|
CheckOrigin: func(r *http.Request) bool { return true },
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBrowserTestWS(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tok := r.URL.Query().Get("token")
|
||||||
|
if tok == "" || !s.browserTestStore.ConsumeToken(tok) {
|
||||||
|
writeError(w, "invalid or expired token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn, err := browserTestUpgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn.SetReadLimit(2 << 20)
|
||||||
|
|
||||||
|
// Read the hello message: page sends {"type":"hello","url":"...","title":"..."}.
|
||||||
|
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
var hello struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
if err := conn.ReadJSON(&hello); err != nil || hello.Type != "hello" {
|
||||||
|
conn.WriteJSON(map[string]string{"type": "error", "message": "expected hello"})
|
||||||
|
conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn.SetReadDeadline(time.Time{})
|
||||||
|
|
||||||
|
id := newCorrelationID()
|
||||||
|
sess := &BrowserTestSession{
|
||||||
|
ID: id,
|
||||||
|
URL: hello.URL,
|
||||||
|
Title: hello.Title,
|
||||||
|
conn: conn,
|
||||||
|
pending: map[string]chan json.RawMessage{},
|
||||||
|
connectedAt: time.Now(),
|
||||||
|
}
|
||||||
|
s.browserTestStore.Register(sess)
|
||||||
|
defer s.browserTestStore.Remove(id)
|
||||||
|
|
||||||
|
// Acknowledge with the assigned session ID.
|
||||||
|
sess.writeMu.Lock()
|
||||||
|
conn.WriteJSON(map[string]string{"type": "registered", "session_id": id})
|
||||||
|
sess.writeMu.Unlock()
|
||||||
|
|
||||||
|
for {
|
||||||
|
_, raw, err := conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var msg struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Level string `json:"level,omitempty"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
Data json.RawMessage `json:"data,omitempty"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch msg.Type {
|
||||||
|
case "console":
|
||||||
|
sess.AppendConsole(msg.Level, msg.Text)
|
||||||
|
case "url_change":
|
||||||
|
sess.mu.Lock()
|
||||||
|
sess.URL = msg.URL
|
||||||
|
sess.mu.Unlock()
|
||||||
|
case "reply":
|
||||||
|
sess.pendingMu.Lock()
|
||||||
|
ch, ok := sess.pending[msg.ID]
|
||||||
|
sess.pendingMu.Unlock()
|
||||||
|
if ok {
|
||||||
|
select {
|
||||||
|
case ch <- msg.Data:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "ping":
|
||||||
|
sess.writeMu.Lock()
|
||||||
|
conn.WriteJSON(map[string]string{"type": "pong"})
|
||||||
|
sess.writeMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent tool -----------------------------------------------------------------
|
||||||
|
|
||||||
|
// BrowserTestParams is the schema exposed to the AI for the browser_test tool.
|
||||||
|
type BrowserTestParams struct {
|
||||||
|
Action string `json:"action" description:"One of: list_clickables, click, eval, console, current_url, wait, type, summary"`
|
||||||
|
SessionID string `json:"session_id,omitempty" description:"Browser session id (optional, defaults to most recent)"`
|
||||||
|
Selector string `json:"selector,omitempty" description:"CSS selector for click/type actions"`
|
||||||
|
Index int `json:"index,omitempty" description:"Alternative to selector: index into the last list_clickables result (0-based)"`
|
||||||
|
Expr string `json:"expr,omitempty" description:"JS expression to evaluate (eval action only)"`
|
||||||
|
Text string `json:"text,omitempty" description:"Text to type (type action only)"`
|
||||||
|
WaitMs int `json:"wait_ms,omitempty" description:"Milliseconds to wait (wait action only, max 5000)"`
|
||||||
|
Tail int `json:"tail,omitempty" description:"Console action: how many recent lines to return (default 50, max 200)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterBrowserTestTool wires the agent tool against a session store.
|
||||||
|
func RegisterBrowserTestTool(reg *agent.Registry, store *BrowserTestStore) error {
|
||||||
|
tool, err := agent.NewTool("browser_test",
|
||||||
|
"Drive the user's connected browser tab for end-to-end testing. Available actions: list_clickables (returns indexed clickable elements), click (by selector or index), eval (run a JS expression and return result), console (read recent console output, ideal to spot errors after a click), current_url, wait (sleep ms before next check), type (set value on an input), summary (URL+title+last console entries). Always start with list_clickables; click; then console to verify no errors.",
|
||||||
|
func(ctx context.Context, p BrowserTestParams) (agent.ToolResponse, error) {
|
||||||
|
sess := store.Pick(p.SessionID)
|
||||||
|
if sess == nil {
|
||||||
|
return agent.TextErrorResponse("no active browser session — ask the user to paste the snippet from the Tests tab in their target page"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
action := strings.ToLower(strings.TrimSpace(p.Action))
|
||||||
|
switch action {
|
||||||
|
case "":
|
||||||
|
return agent.TextErrorResponse("action is required"), nil
|
||||||
|
case "list_clickables", "click", "eval", "current_url", "type":
|
||||||
|
case "console", "summary", "wait":
|
||||||
|
default:
|
||||||
|
return agent.TextErrorResponse("unknown action: " + p.Action), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if action == "console" {
|
||||||
|
tail := p.Tail
|
||||||
|
if tail <= 0 {
|
||||||
|
tail = 50
|
||||||
|
}
|
||||||
|
if tail > browserTestConsoleMax {
|
||||||
|
tail = browserTestConsoleMax
|
||||||
|
}
|
||||||
|
entries := sess.SnapshotConsole()
|
||||||
|
if len(entries) > tail {
|
||||||
|
entries = entries[len(entries)-tail:]
|
||||||
|
}
|
||||||
|
out, _ := json.MarshalIndent(map[string]interface{}{
|
||||||
|
"session_id": sess.ID,
|
||||||
|
"console": entries,
|
||||||
|
}, "", " ")
|
||||||
|
return agent.TextResponse(string(out)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if action == "summary" {
|
||||||
|
entries := sess.SnapshotConsole()
|
||||||
|
if len(entries) > 20 {
|
||||||
|
entries = entries[len(entries)-20:]
|
||||||
|
}
|
||||||
|
out, _ := json.MarshalIndent(map[string]interface{}{
|
||||||
|
"session_id": sess.ID,
|
||||||
|
"url": sess.URL,
|
||||||
|
"title": sess.Title,
|
||||||
|
"recent_console": entries,
|
||||||
|
}, "", " ")
|
||||||
|
return agent.TextResponse(string(out)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if action == "wait" {
|
||||||
|
ms := p.WaitMs
|
||||||
|
if ms <= 0 {
|
||||||
|
ms = 200
|
||||||
|
}
|
||||||
|
if ms > 5000 {
|
||||||
|
ms = 5000
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return agent.TextErrorResponse("cancelled"), nil
|
||||||
|
case <-time.After(time.Duration(ms) * time.Millisecond):
|
||||||
|
}
|
||||||
|
return agent.TextResponse(fmt.Sprintf("waited %dms", ms)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture console snapshot length before so we can return only the delta
|
||||||
|
// after the action — useful so the AI can spot errors caused by the click.
|
||||||
|
pre := len(sess.SnapshotConsole())
|
||||||
|
|
||||||
|
params := map[string]interface{}{}
|
||||||
|
if p.Selector != "" {
|
||||||
|
params["selector"] = p.Selector
|
||||||
|
}
|
||||||
|
if p.Index > 0 || (action == "click" && p.Selector == "") {
|
||||||
|
params["index"] = p.Index
|
||||||
|
}
|
||||||
|
if p.Expr != "" {
|
||||||
|
params["expr"] = p.Expr
|
||||||
|
}
|
||||||
|
if p.Text != "" {
|
||||||
|
params["text"] = p.Text
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := sess.Send(action, params)
|
||||||
|
if err != nil {
|
||||||
|
return agent.TextErrorResponse(err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Console delta: messages logged during this command.
|
||||||
|
post := sess.SnapshotConsole()
|
||||||
|
var delta []ConsoleEntry
|
||||||
|
if len(post) > pre {
|
||||||
|
delta = post[pre:]
|
||||||
|
}
|
||||||
|
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"action": action,
|
||||||
|
"reply": json.RawMessage(payload),
|
||||||
|
"console_delta": delta,
|
||||||
|
"current_url": sess.URL,
|
||||||
|
}
|
||||||
|
out, _ := json.MarshalIndent(result, "", " ")
|
||||||
|
return agent.TextResponse(string(out)), nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return reg.Register(tool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snippet generator ----------------------------------------------------------
|
||||||
|
|
||||||
|
func buildBrowserTestSnippet(wsURL string) string {
|
||||||
|
// Note: this is the JS injected into the user's target page. It opens the
|
||||||
|
// WS, hooks console, and dispatches commands. Kept terse on purpose.
|
||||||
|
return `(function(){
|
||||||
|
if (window.__muyueTestRunner) { console.log('[Muyue] runner already attached'); return; }
|
||||||
|
var WS_URL = ` + jsString(wsURL) + `;
|
||||||
|
var ws = new WebSocket(WS_URL);
|
||||||
|
var lastList = [];
|
||||||
|
function send(obj){ try{ ws.send(JSON.stringify(obj)); }catch(e){} }
|
||||||
|
function reply(id, data){ send({type:'reply', id:id, data:data}); }
|
||||||
|
function safeText(el){
|
||||||
|
var t = (el.innerText || el.textContent || '').trim();
|
||||||
|
if (t.length > 80) t = t.slice(0,80)+'…';
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
function describe(el){
|
||||||
|
var sel = el.id ? '#'+el.id : el.tagName.toLowerCase();
|
||||||
|
if (!el.id && el.className && typeof el.className === 'string') {
|
||||||
|
sel += '.' + el.className.trim().split(/\s+/).slice(0,2).join('.');
|
||||||
|
}
|
||||||
|
var label = el.getAttribute('aria-label') || el.getAttribute('title') || el.getAttribute('name') || '';
|
||||||
|
return { tag: el.tagName.toLowerCase(), selector: sel, text: safeText(el), label: label, type: el.getAttribute('type')||'', disabled: !!el.disabled };
|
||||||
|
}
|
||||||
|
function list(){
|
||||||
|
var els = Array.from(document.querySelectorAll('button, a[href], input[type=submit], input[type=button], [role=button], [onclick]'));
|
||||||
|
lastList = els.filter(function(e){ var r=e.getBoundingClientRect(); return r.width>0 && r.height>0; });
|
||||||
|
return lastList.map(describe).map(function(d,i){ d.index = i; return d; });
|
||||||
|
}
|
||||||
|
function clickEl(el){
|
||||||
|
if (!el) return { ok:false, error:'element not found' };
|
||||||
|
if (el.disabled) return { ok:false, error:'element is disabled' };
|
||||||
|
try { el.scrollIntoView({block:'center'}); el.click(); return { ok:true }; }
|
||||||
|
catch(e){ return { ok:false, error:String(e) }; }
|
||||||
|
}
|
||||||
|
function dispatch(msg){
|
||||||
|
var p = msg.params || {};
|
||||||
|
switch(msg.action){
|
||||||
|
case 'list_clickables': return list();
|
||||||
|
case 'click': {
|
||||||
|
var el;
|
||||||
|
if (p.selector) el = document.querySelector(p.selector);
|
||||||
|
else if (typeof p.index === 'number') el = lastList[p.index];
|
||||||
|
return clickEl(el);
|
||||||
|
}
|
||||||
|
case 'eval': {
|
||||||
|
try { var r = (0,eval)(p.expr); return { ok:true, value: serialize(r) }; }
|
||||||
|
catch(e){ return { ok:false, error:String(e) }; }
|
||||||
|
}
|
||||||
|
case 'current_url': return { url: location.href, title: document.title };
|
||||||
|
case 'type': {
|
||||||
|
var el = p.selector ? document.querySelector(p.selector) : (lastList[p.index]);
|
||||||
|
if (!el) return { ok:false, error:'element not found' };
|
||||||
|
var proto = Object.getPrototypeOf(el);
|
||||||
|
var setter = Object.getOwnPropertyDescriptor(proto, 'value');
|
||||||
|
try { setter && setter.set ? setter.set.call(el, p.text||'') : (el.value = p.text||''); }
|
||||||
|
catch(e){ el.value = p.text||''; }
|
||||||
|
el.dispatchEvent(new Event('input', {bubbles:true}));
|
||||||
|
el.dispatchEvent(new Event('change', {bubbles:true}));
|
||||||
|
return { ok:true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok:false, error:'unknown action' };
|
||||||
|
}
|
||||||
|
function serialize(v){
|
||||||
|
if (v === undefined) return 'undefined';
|
||||||
|
try { return JSON.parse(JSON.stringify(v)); }
|
||||||
|
catch(e){ return String(v); }
|
||||||
|
}
|
||||||
|
['log','info','warn','error','debug'].forEach(function(lvl){
|
||||||
|
var orig = console[lvl];
|
||||||
|
console[lvl] = function(){
|
||||||
|
try {
|
||||||
|
var parts = Array.from(arguments).map(function(a){
|
||||||
|
if (typeof a === 'string') return a;
|
||||||
|
try { return JSON.stringify(a); } catch(e){ return String(a); }
|
||||||
|
});
|
||||||
|
send({type:'console', level: lvl, text: parts.join(' ')});
|
||||||
|
} catch(e){}
|
||||||
|
return orig.apply(console, arguments);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
window.addEventListener('error', function(e){
|
||||||
|
send({type:'console', level:'error', text:'window.onerror: '+(e.message||e.error||'unknown')});
|
||||||
|
});
|
||||||
|
window.addEventListener('unhandledrejection', function(e){
|
||||||
|
send({type:'console', level:'error', text:'unhandledrejection: '+String(e.reason)});
|
||||||
|
});
|
||||||
|
var lastUrl = location.href;
|
||||||
|
setInterval(function(){
|
||||||
|
if (location.href !== lastUrl){ lastUrl = location.href; send({type:'url_change', url: lastUrl}); }
|
||||||
|
}, 500);
|
||||||
|
ws.onopen = function(){ send({type:'hello', url: location.href, title: document.title}); };
|
||||||
|
ws.onmessage = function(ev){
|
||||||
|
try { var msg = JSON.parse(ev.data); }
|
||||||
|
catch(e){ return; }
|
||||||
|
if (msg.type === 'registered') { console.log('[Muyue] connected — session', msg.session_id); return; }
|
||||||
|
if (msg.action) reply(msg.id, dispatch(msg));
|
||||||
|
};
|
||||||
|
ws.onclose = function(){ console.log('[Muyue] runner disconnected'); window.__muyueTestRunner = null; };
|
||||||
|
window.__muyueTestRunner = { ws: ws, list: list };
|
||||||
|
})();`
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsString(s string) string {
|
||||||
|
b, _ := json.Marshal(s)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
329
internal/api/chat_engine.go
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/agent"
|
||||||
|
"github.com/muyue/muyue/internal/orchestrator"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
MaxToolIterations = 15
|
||||||
|
)
|
||||||
|
|
||||||
|
// ToolLimiter checks if a tool call is allowed and returns a release function.
|
||||||
|
type ToolLimiter func(toolName string) (release func(), err error)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
limiter ToolLimiter
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLimiter sets the tool call limiter for agent concurrency control.
|
||||||
|
func (ce *ChatEngine) SetLimiter(l ToolLimiter) {
|
||||||
|
ce.limiter = l
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Choices) == 0 {
|
||||||
|
return finalContent, allToolCalls, allToolResults, fmt.Errorf("empty response from provider")
|
||||||
|
}
|
||||||
|
choice := resp.Choices[0]
|
||||||
|
content := orchestrator.CleanAIResponse(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: orchestrator.TextContent(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),
|
||||||
|
}
|
||||||
|
|
||||||
|
var release func()
|
||||||
|
if ce.limiter != nil {
|
||||||
|
rel, limitErr := ce.limiter(tc.Function.Name)
|
||||||
|
if limitErr != nil {
|
||||||
|
limResultData := map[string]interface{}{
|
||||||
|
"tool_call_id": tc.ID,
|
||||||
|
"content": limitErr.Error(),
|
||||||
|
"is_error": true,
|
||||||
|
}
|
||||||
|
allToolResults = append(allToolResults, map[string]interface{}{
|
||||||
|
"tool_call_id": tc.ID,
|
||||||
|
"name": tc.Function.Name,
|
||||||
|
"args": tc.Function.Arguments,
|
||||||
|
"result": limitErr.Error(),
|
||||||
|
"is_error": true,
|
||||||
|
})
|
||||||
|
if ce.onChunk != nil {
|
||||||
|
ce.onChunk(map[string]interface{}{"tool_result": limResultData})
|
||||||
|
}
|
||||||
|
messages = append(messages, orchestrator.Message{
|
||||||
|
Role: "tool",
|
||||||
|
Content: orchestrator.TextContent(limitErr.Error()),
|
||||||
|
ToolCallID: tc.ID,
|
||||||
|
Name: tc.Function.Name,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
release = rel
|
||||||
|
}
|
||||||
|
|
||||||
|
result, execErr := ce.registry.Execute(ctx, call)
|
||||||
|
if release != nil {
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
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: orchestrator.TextContent(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
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Choices) == 0 {
|
||||||
|
return finalContent, fmt.Errorf("empty response from provider")
|
||||||
|
}
|
||||||
|
choice := resp.Choices[0]
|
||||||
|
content := orchestrator.CleanAIResponse(cleanThinkingTags(choice.Message.Content))
|
||||||
|
|
||||||
|
if content != "" {
|
||||||
|
finalContent = content
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(choice.Message.ToolCalls) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
assistantMsg := orchestrator.Message{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: orchestrator.TextContent(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),
|
||||||
|
}
|
||||||
|
|
||||||
|
var release func()
|
||||||
|
if ce.limiter != nil {
|
||||||
|
rel, limitErr := ce.limiter(tc.Function.Name)
|
||||||
|
if limitErr != nil {
|
||||||
|
messages = append(messages, orchestrator.Message{
|
||||||
|
Role: "tool",
|
||||||
|
Content: orchestrator.TextContent(limitErr.Error()),
|
||||||
|
ToolCallID: tc.ID,
|
||||||
|
Name: tc.Function.Name,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
release = rel
|
||||||
|
}
|
||||||
|
|
||||||
|
result, execErr := ce.registry.Execute(ctx, call)
|
||||||
|
if release != nil {
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
if execErr != nil {
|
||||||
|
result = agent.ToolResponse{
|
||||||
|
Content: execErr.Error(),
|
||||||
|
IsError: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messages = append(messages, orchestrator.Message{
|
||||||
|
Role: "tool",
|
||||||
|
Content: orchestrator.TextContent(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.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
@@ -2,8 +2,10 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
@@ -11,15 +13,57 @@ import (
|
|||||||
"github.com/muyue/muyue/internal/config"
|
"github.com/muyue/muyue/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxTokensApprox = 100000
|
const contextWindowTokens = 150000
|
||||||
const summarizeThreshold = 80000
|
const summarizeRatio = 0.80
|
||||||
const charsPerToken = 4
|
const charsPerToken = 4
|
||||||
|
|
||||||
|
func extractDisplayContent(role, content string) string {
|
||||||
|
if role != "assistant" {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
var parsed struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
ToolCalls []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Args string `json:"args"`
|
||||||
|
} `json:"tool_calls"`
|
||||||
|
ToolResults []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Result string `json:"result"`
|
||||||
|
} `json:"tool_results"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(content), &parsed); err != nil {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
if parsed.Content != "" {
|
||||||
|
sb.WriteString(parsed.Content)
|
||||||
|
}
|
||||||
|
for _, tc := range parsed.ToolCalls {
|
||||||
|
sb.WriteString("\n[")
|
||||||
|
sb.WriteString(tc.Name)
|
||||||
|
sb.WriteString("] ")
|
||||||
|
sb.WriteString(tc.Args)
|
||||||
|
}
|
||||||
|
for _, tr := range parsed.ToolResults {
|
||||||
|
sb.WriteString("\n[result")
|
||||||
|
if tr.Name != "" {
|
||||||
|
sb.WriteString(":")
|
||||||
|
sb.WriteString(tr.Name)
|
||||||
|
}
|
||||||
|
sb.WriteString("] ")
|
||||||
|
sb.WriteString(tr.Result)
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
type FeedMessage struct {
|
type FeedMessage struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Time string `json:"time"`
|
Time string `json:"time"`
|
||||||
|
Images []string `json:"images,omitempty"`
|
||||||
|
Summarized bool `json:"summarized,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Conversation struct {
|
type Conversation struct {
|
||||||
@@ -35,6 +79,19 @@ type ConversationStore struct {
|
|||||||
conv *Conversation
|
conv *Conversation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TokenCount struct {
|
||||||
|
total int
|
||||||
|
byRole map[string]int
|
||||||
|
byMessage int
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Time string `json:"time"`
|
||||||
|
}
|
||||||
|
|
||||||
func NewConversationStore() *ConversationStore {
|
func NewConversationStore() *ConversationStore {
|
||||||
dir, err := config.ConfigDir()
|
dir, err := config.ConfigDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -111,14 +168,38 @@ func (cs *ConversationStore) Add(role, content string) FeedMessage {
|
|||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cs *ConversationStore) AddWithImages(role, content string, imageIDs []string) FeedMessage {
|
||||||
|
cs.mu.Lock()
|
||||||
|
defer cs.mu.Unlock()
|
||||||
|
|
||||||
|
msg := FeedMessage{
|
||||||
|
ID: generateMsgID(),
|
||||||
|
Role: role,
|
||||||
|
Content: content,
|
||||||
|
Time: time.Now().Format(time.RFC3339),
|
||||||
|
Images: imageIDs,
|
||||||
|
}
|
||||||
|
cs.conv.Messages = append(cs.conv.Messages, msg)
|
||||||
|
cs.save()
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
func (cs *ConversationStore) Clear() {
|
func (cs *ConversationStore) Clear() {
|
||||||
cs.mu.Lock()
|
cs.mu.Lock()
|
||||||
defer cs.mu.Unlock()
|
defer cs.mu.Unlock()
|
||||||
|
|
||||||
|
var imageIDs []string
|
||||||
|
for _, m := range cs.conv.Messages {
|
||||||
|
imageIDs = append(imageIDs, m.Images...)
|
||||||
|
}
|
||||||
|
|
||||||
cs.conv.Messages = []FeedMessage{}
|
cs.conv.Messages = []FeedMessage{}
|
||||||
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.save()
|
cs.save()
|
||||||
|
|
||||||
|
go cleanupImages(imageIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cs *ConversationStore) SetSummary(summary string) {
|
func (cs *ConversationStore) SetSummary(summary string) {
|
||||||
@@ -128,30 +209,125 @@ func (cs *ConversationStore) SetSummary(summary string) {
|
|||||||
cs.save()
|
cs.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cs *ConversationStore) TrimOld(keepCount int) {
|
func (cs *ConversationStore) MarkSummarized(upToIndex int) {
|
||||||
cs.mu.Lock()
|
cs.mu.Lock()
|
||||||
defer cs.mu.Unlock()
|
defer cs.mu.Unlock()
|
||||||
if len(cs.conv.Messages) <= keepCount {
|
if upToIndex <= 0 || upToIndex >= len(cs.conv.Messages) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cs.conv.Messages = cs.conv.Messages[len(cs.conv.Messages)-keepCount:]
|
for i := 0; i < upToIndex; i++ {
|
||||||
|
cs.conv.Messages[i].Summarized = true
|
||||||
|
}
|
||||||
cs.save()
|
cs.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cs *ConversationStore) ApproxTokenCount() int {
|
func (cs *ConversationStore) ApproxTokenCount() int {
|
||||||
|
return cs.ApproxTokenCountDetailed().total
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cs *ConversationStore) ApproxTokenCountDetailed() TokenCount {
|
||||||
cs.mu.RLock()
|
cs.mu.RLock()
|
||||||
defer cs.mu.RUnlock()
|
defer cs.mu.RUnlock()
|
||||||
total := utf8.RuneCountInString(cs.conv.Summary)
|
|
||||||
for _, m := range cs.conv.Messages {
|
result := TokenCount{
|
||||||
total += utf8.RuneCountInString(m.Content)
|
byRole: make(map[string]int),
|
||||||
}
|
}
|
||||||
return total / charsPerToken
|
|
||||||
|
for _, m := range cs.conv.Messages {
|
||||||
|
if m.Role == "system" || m.Summarized {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
count := utf8.RuneCountInString(extractDisplayContent(m.Role, m.Content)) / charsPerToken
|
||||||
|
result.byMessage += count
|
||||||
|
result.byRole[m.Role] += count
|
||||||
|
}
|
||||||
|
|
||||||
|
if cs.conv.Summary != "" {
|
||||||
|
result.total = result.byMessage + utf8.RuneCountInString(cs.conv.Summary)/charsPerToken
|
||||||
|
} else {
|
||||||
|
result.total = result.byMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cs *ConversationStore) NeedsSummarization() bool {
|
func (cs *ConversationStore) NeedsSummarization() bool {
|
||||||
return cs.ApproxTokenCount() > summarizeThreshold
|
return cs.ApproxTokenCount() > int(float64(contextWindowTokens)*summarizeRatio)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cs *ConversationStore) Search(query string) []SearchResult {
|
||||||
|
cs.mu.RLock()
|
||||||
|
defer cs.mu.RUnlock()
|
||||||
|
|
||||||
|
var results []SearchResult
|
||||||
|
queryLower := strings.ToLower(query)
|
||||||
|
|
||||||
|
for _, msg := range cs.conv.Messages {
|
||||||
|
if strings.Contains(strings.ToLower(msg.Content), queryLower) {
|
||||||
|
results = append(results, SearchResult{
|
||||||
|
ID: msg.ID,
|
||||||
|
Role: msg.Role,
|
||||||
|
Content: msg.Content,
|
||||||
|
Time: msg.Time,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cs *ConversationStore) ExportMarkdown() string {
|
||||||
|
cs.mu.RLock()
|
||||||
|
defer cs.mu.RUnlock()
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("# Conversation Export\n\n")
|
||||||
|
sb.WriteString(fmt.Sprintf("Exporté le: %s\n\n", time.Now().Format(time.RFC3339)))
|
||||||
|
|
||||||
|
if cs.conv.Summary != "" {
|
||||||
|
sb.WriteString("## Résumé\n\n")
|
||||||
|
sb.WriteString(cs.conv.Summary)
|
||||||
|
sb.WriteString("\n\n---\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("## Messages\n\n")
|
||||||
|
|
||||||
|
for i, msg := range cs.conv.Messages {
|
||||||
|
roleLabel := msg.Role
|
||||||
|
if roleLabel == "user" {
|
||||||
|
roleLabel = "👤 Utilisateur"
|
||||||
|
} else if roleLabel == "assistant" {
|
||||||
|
roleLabel = "🤖 Assistant"
|
||||||
|
} else if roleLabel == "system" {
|
||||||
|
roleLabel = "⚙️ Système"
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamp := ""
|
||||||
|
if msg.Time != "" {
|
||||||
|
if t, err := time.Parse(time.RFC3339, msg.Time); err == nil {
|
||||||
|
timestamp = t.Format("2006-01-02 15:04")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("### [%d] %s (%s)\n\n", i+1, roleLabel, timestamp))
|
||||||
|
sb.WriteString(msg.Content)
|
||||||
|
sb.WriteString("\n\n---\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cs *ConversationStore) ExportJSON() string {
|
||||||
|
cs.mu.RLock()
|
||||||
|
defer cs.mu.RUnlock()
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(cs.conv, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateMsgID() string {
|
func generateMsgID() string {
|
||||||
return time.Now().Format("20060102150405.000")
|
return time.Now().Format("20060102150405.000") + "-" + fmt.Sprintf("%d", time.Now().UnixNano())
|
||||||
}
|
}
|
||||||
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)
|
||||||
|
|
||||||
|
cs.saveCurrent()
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -1,531 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"os/exec"
|
|
||||||
|
|
||||||
"github.com/muyue/muyue/internal/config"
|
|
||||||
"github.com/muyue/muyue/internal/lsp"
|
|
||||||
"github.com/muyue/muyue/internal/mcp"
|
|
||||||
"github.com/muyue/muyue/internal/orchestrator"
|
|
||||||
"github.com/muyue/muyue/internal/scanner"
|
|
||||||
"github.com/muyue/muyue/internal/skills"
|
|
||||||
"github.com/muyue/muyue/internal/updater"
|
|
||||||
"github.com/muyue/muyue/internal/version"
|
|
||||||
)
|
|
||||||
|
|
||||||
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.`
|
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, data interface{}) {
|
|
||||||
json.NewEncoder(w).Encode(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeError(w http.ResponseWriter, msg string, code int) {
|
|
||||||
w.WriteHeader(code)
|
|
||||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
|
|
||||||
writeJSON(w, map[string]interface{}{
|
|
||||||
"name": version.Name,
|
|
||||||
"version": version.Version,
|
|
||||||
"author": version.Author,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleSystem(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if s.scanResult == nil {
|
|
||||||
s.scanResult = scanner.ScanSystem()
|
|
||||||
}
|
|
||||||
writeJSON(w, map[string]interface{}{
|
|
||||||
"system": s.scanResult.System,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleTools(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if s.scanResult == nil {
|
|
||||||
s.scanResult = scanner.ScanSystem()
|
|
||||||
}
|
|
||||||
type toolInfo struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Installed bool `json:"installed"`
|
|
||||||
Version string `json:"version"`
|
|
||||||
Path string `json:"path"`
|
|
||||||
}
|
|
||||||
tools := make([]toolInfo, len(s.scanResult.Tools))
|
|
||||||
for i, t := range s.scanResult.Tools {
|
|
||||||
tools[i] = toolInfo{
|
|
||||||
Name: t.Name,
|
|
||||||
Installed: t.Installed,
|
|
||||||
Version: t.Version,
|
|
||||||
Path: t.Path,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writeJSON(w, map[string]interface{}{
|
|
||||||
"tools": tools,
|
|
||||||
"total": len(tools),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if s.config == nil {
|
|
||||||
writeError(w, "no config", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, map[string]interface{}{
|
|
||||||
"profile": s.config.Profile,
|
|
||||||
"terminal": s.config.Terminal,
|
|
||||||
"bmad": s.config.BMAD,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleProviders(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if s.config == nil {
|
|
||||||
writeError(w, "no config", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, map[string]interface{}{
|
|
||||||
"providers": s.config.AI.Providers,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleSkills(w http.ResponseWriter, r *http.Request) {
|
|
||||||
list, err := skills.List()
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, map[string]interface{}{
|
|
||||||
"skills": list,
|
|
||||||
"count": len(list),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleLSP(w http.ResponseWriter, r *http.Request) {
|
|
||||||
servers := lsp.ScanServers()
|
|
||||||
writeJSON(w, map[string]interface{}{
|
|
||||||
"servers": servers,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
|
|
||||||
servers := mcp.ScanServers()
|
|
||||||
writeJSON(w, map[string]interface{}{
|
|
||||||
"servers": servers,
|
|
||||||
"configured": true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleMCPConfigure(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "POST" {
|
|
||||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := mcp.ConfigureAll(s.config); err != nil {
|
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, map[string]string{"status": "ok"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleUpdates(w http.ResponseWriter, r *http.Request) {
|
|
||||||
result := scanner.ScanSystem()
|
|
||||||
statuses := updater.CheckUpdates(result)
|
|
||||||
type updateInfo struct {
|
|
||||||
Tool string `json:"tool"`
|
|
||||||
Current string `json:"current"`
|
|
||||||
Latest string `json:"latest"`
|
|
||||||
NeedsUpdate bool `json:"needsUpdate"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
updates := make([]updateInfo, len(statuses))
|
|
||||||
for i, u := range statuses {
|
|
||||||
updates[i] = updateInfo{
|
|
||||||
Tool: u.Tool,
|
|
||||||
Current: u.Current,
|
|
||||||
Latest: u.Latest,
|
|
||||||
NeedsUpdate: u.NeedsUpdate,
|
|
||||||
Error: u.Error,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writeJSON(w, map[string]interface{}{
|
|
||||||
"updates": updates,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleInstall(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "POST" {
|
|
||||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var body struct {
|
|
||||||
Tools []string `json:"tools"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
||||||
writeError(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(body.Tools) == 0 {
|
|
||||||
writeError(w, "no tools specified", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, map[string]string{"status": "installing"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) {
|
|
||||||
s.scanResult = scanner.ScanSystem()
|
|
||||||
writeJSON(w, map[string]string{"status": "ok"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleUpdatePreferences(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "PUT" {
|
|
||||||
writeError(w, "PUT only", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if s.config == nil {
|
|
||||||
writeError(w, "no config", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var body struct {
|
|
||||||
Language string `json:"language"`
|
|
||||||
KeyboardLayout string `json:"keyboard_layout"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
||||||
writeError(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if body.Language != "" {
|
|
||||||
s.config.Profile.Preferences.Language = body.Language
|
|
||||||
}
|
|
||||||
if body.KeyboardLayout != "" {
|
|
||||||
s.config.Profile.Preferences.KeyboardLayout = body.KeyboardLayout
|
|
||||||
}
|
|
||||||
if err := config.Save(s.config); err != nil {
|
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, map[string]string{"status": "ok"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleTerminal(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "POST" {
|
|
||||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var body struct {
|
|
||||||
Command string `json:"command"`
|
|
||||||
Cwd string `json:"cwd"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
||||||
writeError(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if body.Command == "" {
|
|
||||||
writeError(w, "no command", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
shell := "/bin/sh"
|
|
||||||
if s, err := exec.LookPath("bash"); err == nil {
|
|
||||||
shell = s
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.Command(shell, "-c", body.Command)
|
|
||||||
if body.Cwd != "" {
|
|
||||||
cmd.Dir = body.Cwd
|
|
||||||
}
|
|
||||||
out, err := cmd.CombinedOutput()
|
|
||||||
|
|
||||||
type termResult struct {
|
|
||||||
Output string `json:"output"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
result := termResult{Output: string(out)}
|
|
||||||
if err != nil {
|
|
||||||
result.Error = err.Error()
|
|
||||||
}
|
|
||||||
writeJSON(w, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "POST" {
|
|
||||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var body struct {
|
|
||||||
Message string `json:"message"`
|
|
||||||
Stream bool `json:"stream"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
||||||
writeError(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if body.Message == "" {
|
|
||||||
writeError(w, "no message", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.convStore.Add("user", body.Message)
|
|
||||||
|
|
||||||
if s.convStore.NeedsSummarization() {
|
|
||||||
s.autoSummarize()
|
|
||||||
}
|
|
||||||
|
|
||||||
orb, err := orchestrator.New(s.config)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
orb.SetSystemPrompt(`Tu es l'orchestrateur IA de Muyue Studio. Tu aides l'utilisateur dans ses tâches de développement logiciel. Tu peux :
|
|
||||||
- Créer et gérer des plans de développement étape par étape
|
|
||||||
- Proposer des agents (outils comme Crush, Claude Code, etc.) pour exécuter des tâches spécifiques
|
|
||||||
- Suivre la progression de tâches multi-étapes
|
|
||||||
- Suggérer des modifications de fichiers, des revues de code, et des décisions d'architecture
|
|
||||||
|
|
||||||
Sois concis, actionnable, et structuré. Quand tu proposes un plan, utilise des étapes numérotées claires. Quand tu références des fichiers, utilise des chemins relatifs. Tu es intégré dans l'application desktop Muyue.`)
|
|
||||||
|
|
||||||
if body.Stream {
|
|
||||||
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)
|
|
||||||
flusher, canFlush := w.(http.Flusher)
|
|
||||||
|
|
||||||
result, err := orb.Send(body.Message)
|
|
||||||
if err != nil {
|
|
||||||
data, _ := json.Marshal(map[string]string{"error": err.Error()})
|
|
||||||
w.Write([]byte("data: " + string(data) + "\n\n"))
|
|
||||||
if canFlush {
|
|
||||||
flusher.Flush()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.convStore.Add("assistant", result)
|
|
||||||
|
|
||||||
chunkSize := 8
|
|
||||||
runes := []rune(result)
|
|
||||||
for i := 0; i < len(runes); i += chunkSize {
|
|
||||||
end := i + chunkSize
|
|
||||||
if end > len(runes) {
|
|
||||||
end = len(runes)
|
|
||||||
}
|
|
||||||
chunk := string(runes[i:end])
|
|
||||||
data, _ := json.Marshal(map[string]string{"content": chunk})
|
|
||||||
w.Write([]byte("data: " + string(data) + "\n\n"))
|
|
||||||
if canFlush {
|
|
||||||
flusher.Flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data, _ := json.Marshal(map[string]string{"done": "true"})
|
|
||||||
w.Write([]byte("data: " + string(data) + "\n\n"))
|
|
||||||
if canFlush {
|
|
||||||
flusher.Flush()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := orb.Send(body.Message)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.convStore.Add("assistant", result)
|
|
||||||
writeJSON(w, map[string]string{"content": result})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) autoSummarize() {
|
|
||||||
messages := s.convStore.Get()
|
|
||||||
if len(messages) < 10 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
half := len(messages) / 2
|
|
||||||
var oldText string
|
|
||||||
for _, m := range messages[:half] {
|
|
||||||
oldText += m.Role + ": " + m.Content + "\n\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
summary := s.convStore.GetSummary()
|
|
||||||
if summary != "" {
|
|
||||||
oldText = "Résumé précédent:\n" + summary + "\n\nNouveaux échanges:\n" + oldText
|
|
||||||
}
|
|
||||||
|
|
||||||
orb, err := orchestrator.New(s.config)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
orb.SetSystemPrompt(summarizePrompt)
|
|
||||||
|
|
||||||
result, err := orb.Send(oldText)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.convStore.SetSummary(result)
|
|
||||||
s.convStore.TrimOld(len(messages) - half)
|
|
||||||
s.convStore.Add("system", "[Conversation résumée automatiquement]")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "GET" {
|
|
||||||
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
messages := s.convStore.Get()
|
|
||||||
writeJSON(w, map[string]interface{}{
|
|
||||||
"messages": messages,
|
|
||||||
"tokens": s.convStore.ApproxTokenCount(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleChatClear(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "POST" {
|
|
||||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.convStore.Clear()
|
|
||||||
writeJSON(w, map[string]string{"status": "ok"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "PUT" {
|
|
||||||
writeError(w, "PUT only", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if s.config == nil {
|
|
||||||
writeError(w, "no config", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var body struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Pseudo string `json:"pseudo"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
Editor string `json:"editor"`
|
|
||||||
Shell string `json:"shell"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
||||||
writeError(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if body.Name != "" {
|
|
||||||
s.config.Profile.Name = body.Name
|
|
||||||
}
|
|
||||||
if body.Pseudo != "" {
|
|
||||||
s.config.Profile.Pseudo = body.Pseudo
|
|
||||||
}
|
|
||||||
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 {
|
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, map[string]string{"status": "ok"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleSaveProvider(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "PUT" {
|
|
||||||
writeError(w, "PUT only", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if s.config == nil {
|
|
||||||
writeError(w, "no config", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var body struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
APIKey string `json:"api_key"`
|
|
||||||
Model string `json:"model"`
|
|
||||||
BaseURL string `json:"base_url"`
|
|
||||||
Active *bool `json:"active"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
||||||
writeError(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if body.Name == "" {
|
|
||||||
writeError(w, "name required", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
found := false
|
|
||||||
for i := range s.config.AI.Providers {
|
|
||||||
if s.config.AI.Providers[i].Name == body.Name {
|
|
||||||
if body.APIKey != "" {
|
|
||||||
s.config.AI.Providers[i].APIKey = body.APIKey
|
|
||||||
}
|
|
||||||
if body.Model != "" {
|
|
||||||
s.config.AI.Providers[i].Model = body.Model
|
|
||||||
}
|
|
||||||
if body.BaseURL != "" {
|
|
||||||
s.config.AI.Providers[i].BaseURL = body.BaseURL
|
|
||||||
}
|
|
||||||
if body.Active != nil {
|
|
||||||
if *body.Active {
|
|
||||||
for j := range s.config.AI.Providers {
|
|
||||||
s.config.AI.Providers[j].Active = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.config.AI.Providers[i].Active = *body.Active
|
|
||||||
}
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
writeError(w, "provider not found", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := config.Save(s.config); err != nil {
|
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, map[string]string{"status": "ok"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleRunUpdate(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "POST" {
|
|
||||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var body struct {
|
|
||||||
Tool string `json:"tool"`
|
|
||||||
}
|
|
||||||
json.NewDecoder(r.Body).Decode(&body)
|
|
||||||
|
|
||||||
result := scanner.ScanSystem()
|
|
||||||
statuses := updater.CheckUpdates(result)
|
|
||||||
|
|
||||||
if body.Tool != "" {
|
|
||||||
for _, u := range statuses {
|
|
||||||
if u.Tool == body.Tool && u.NeedsUpdate {
|
|
||||||
updater.RunAutoUpdate([]updater.UpdateStatus{u})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writeJSON(w, map[string]string{"status": "ok", "tool": body.Tool})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
needsUpdate := make([]updater.UpdateStatus, 0)
|
|
||||||
for _, u := range statuses {
|
|
||||||
if u.NeedsUpdate {
|
|
||||||
needsUpdate = append(needsUpdate, u)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(needsUpdate) > 0 {
|
|
||||||
updater.RunAutoUpdate(needsUpdate)
|
|
||||||
}
|
|
||||||
writeJSON(w, map[string]interface{}{
|
|
||||||
"status": "ok",
|
|
||||||
"updated": len(needsUpdate),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
172
internal/api/handlers_ai_task.go
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/orchestrator"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleAITask(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Task string `json:"task"`
|
||||||
|
Tool string `json:"tool,omitempty"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Task == "" {
|
||||||
|
writeError(w, "task is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
orb, err := orchestrator.New(s.config)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "AI not available: "+err.Error(), http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
orb.SetSystemPrompt(buildAITaskSystemPrompt())
|
||||||
|
orb.SetTools(s.shellAgentToolsJSON)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
messages := []orchestrator.Message{
|
||||||
|
{Role: "user", Content: orchestrator.TextContent(buildAITaskPrompt(body.Task, body.Tool))},
|
||||||
|
}
|
||||||
|
|
||||||
|
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
|
||||||
|
finalContent, err := engine.RunNonStream(ctx, messages)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "AI task failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
|
||||||
|
|
||||||
|
parsed := parseAIJSONResponse(finalContent)
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"raw": finalContent,
|
||||||
|
"result": parsed,
|
||||||
|
"tokens": engine.TotalTokens,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAITaskSystemPrompt() string {
|
||||||
|
return fmt.Sprintf(`You are a system administration assistant. You have access to a terminal tool to run commands on the host system.
|
||||||
|
|
||||||
|
IMPORTANT RULES:
|
||||||
|
- You MUST respond ONLY with valid JSON. No markdown, no code fences, no extra text.
|
||||||
|
- Always run the actual commands needed to complete the task.
|
||||||
|
- Be thorough: check versions, verify installations, compare with latest releases.
|
||||||
|
|
||||||
|
OS: %s/%s
|
||||||
|
Date: %s
|
||||||
|
`, runtime.GOOS, runtime.GOARCH, time.Now().Format("2006-01-02"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAITaskPrompt(task, tool string) string {
|
||||||
|
switch task {
|
||||||
|
case "check_tools":
|
||||||
|
return `Check the following tools on this system. For each tool, determine:
|
||||||
|
1. Is it installed? Run "which <tool>" or "<tool> --version"
|
||||||
|
2. If installed, what is the current version?
|
||||||
|
3. What is the latest available version? Check GitHub releases API or official sources.
|
||||||
|
|
||||||
|
Tools to check: crush, claude, git, node, npm, pnpm, python3, pip3, uv, go, docker, gh, starship, npx
|
||||||
|
|
||||||
|
Run the commands needed, then respond with ONLY this JSON structure (no markdown fences):
|
||||||
|
{
|
||||||
|
"tools": [
|
||||||
|
{"name": "tool_name", "installed": true/false, "version": "x.y.z", "latest": "a.b.c", "needs_update": true/false, "category": "ai|runtime|vcs|devops|prompt"}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
case "install_tool":
|
||||||
|
return fmt.Sprintf(`Install the tool "%s" on this system.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Check if it's already installed: run "which %s" and "%s --version"
|
||||||
|
2. If not installed, determine the best installation method for this OS
|
||||||
|
3. Run the installation command
|
||||||
|
4. Verify the installation succeeded
|
||||||
|
|
||||||
|
Respond with ONLY this JSON (no markdown fences):
|
||||||
|
{
|
||||||
|
"tool": "%s",
|
||||||
|
"installed": true/false,
|
||||||
|
"version": "installed version or empty",
|
||||||
|
"message": "what was done",
|
||||||
|
"error": "error message or empty"
|
||||||
|
}`, tool, tool, tool, tool)
|
||||||
|
|
||||||
|
case "update_tool":
|
||||||
|
return fmt.Sprintf(`Update the tool "%s" to its latest version on this system.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Check current version: run "%s --version"
|
||||||
|
2. Find the latest version available
|
||||||
|
3. Run the update/upgrade command
|
||||||
|
4. Verify the new version
|
||||||
|
|
||||||
|
Respond with ONLY this JSON (no markdown fences):
|
||||||
|
{
|
||||||
|
"tool": "%s",
|
||||||
|
"previous_version": "old version",
|
||||||
|
"version": "new version",
|
||||||
|
"updated": true/false,
|
||||||
|
"message": "what was done",
|
||||||
|
"error": "error message or empty"
|
||||||
|
}`, tool, tool, tool)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAIJSONResponse(content string) interface{} {
|
||||||
|
cleaned := content
|
||||||
|
|
||||||
|
if idx := strings.Index(cleaned, "```json"); idx != -1 {
|
||||||
|
cleaned = cleaned[idx+7:]
|
||||||
|
if end := strings.Index(cleaned, "```"); end != -1 {
|
||||||
|
cleaned = cleaned[:end]
|
||||||
|
}
|
||||||
|
} else if idx := strings.Index(cleaned, "```"); idx != -1 {
|
||||||
|
cleaned = cleaned[idx+3:]
|
||||||
|
if end := strings.Index(cleaned, "```"); end != -1 {
|
||||||
|
cleaned = cleaned[:end]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleaned = strings.TrimSpace(cleaned)
|
||||||
|
|
||||||
|
jsonStart := strings.Index(cleaned, "{")
|
||||||
|
jsonEnd := strings.LastIndex(cleaned, "}")
|
||||||
|
if jsonStart != -1 && jsonEnd > jsonStart {
|
||||||
|
cleaned = cleaned[jsonStart : jsonEnd+1]
|
||||||
|
}
|
||||||
|
|
||||||
|
var result interface{}
|
||||||
|
if err := json.Unmarshal([]byte(cleaned), &result); err != nil {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"raw": content,
|
||||||
|
"error": "failed to parse AI response as JSON",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
469
internal/api/handlers_chat.go
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/agent"
|
||||||
|
"github.com/muyue/muyue/internal/orchestrator"
|
||||||
|
"github.com/muyue/muyue/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
var thinkingTagRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
|
||||||
|
var fileMentionRegex = regexp.MustCompile(`@(\S+\.[a-zA-Z0-9]+)`)
|
||||||
|
|
||||||
|
type ImageAttachment struct {
|
||||||
|
Data string `json:"data"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveFileMentions(text string) string {
|
||||||
|
return fileMentionRegex.ReplaceAllStringFunc(text, func(match string) string {
|
||||||
|
filePath := match[1:]
|
||||||
|
if strings.HasPrefix(filePath, "~/") {
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
filePath = filepath.Join(home, filePath[2:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(filePath) {
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
filePath = filepath.Join(home, filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return match + fmt.Sprintf(" (erreur: fichier non trouve)")
|
||||||
|
}
|
||||||
|
content := string(data)
|
||||||
|
if len(content) > 50000 {
|
||||||
|
content = content[:50000] + "\n... (tronque a 50Ko)"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("[Fichier: %s]\n%s\n[Fin du fichier: %s]", filepath.Base(filePath), content, filepath.Base(filePath))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var vlmClient = &http.Client{Timeout: 60 * time.Second}
|
||||||
|
|
||||||
|
func (s *Server) describeImages(images []ImageAttachment) []string {
|
||||||
|
var apiKey string
|
||||||
|
for i := range s.config.AI.Providers {
|
||||||
|
if s.config.AI.Providers[i].Active {
|
||||||
|
apiKey = s.config.AI.Providers[i].APIKey
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if apiKey == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
descriptions := make([]string, 0, len(images))
|
||||||
|
for _, img := range images {
|
||||||
|
desc, err := s.callVLM(apiKey, img)
|
||||||
|
if err != nil {
|
||||||
|
descriptions = append(descriptions, fmt.Sprintf("(description unavailable: %v)", err))
|
||||||
|
} else {
|
||||||
|
descriptions = append(descriptions, desc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return descriptions
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) callVLM(apiKey string, img ImageAttachment) (string, error) {
|
||||||
|
payload := map[string]string{
|
||||||
|
"prompt": "Describe this image in detail. Include all text, UI elements, code, diagrams, or data visible. Be thorough and specific.",
|
||||||
|
"image_url": img.Data,
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("marshal vlm request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 55*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", "https://api.minimax.io/v1/coding_plan/vlm", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create vlm request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
|
||||||
|
resp, err := vlmClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("vlm request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read vlm response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("vlm API error (%d): %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||||
|
return "", fmt.Errorf("parse vlm response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Content == "" {
|
||||||
|
return "(empty description)", nil
|
||||||
|
}
|
||||||
|
return result.Content, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 50*1024*1024)
|
||||||
|
var body struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Stream bool `json:"stream"`
|
||||||
|
Images []ImageAttachment `json:"images"`
|
||||||
|
AdvancedReflection bool `json:"advanced_reflection"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Message == "" {
|
||||||
|
writeError(w, "no message", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(body.Images) > 3 {
|
||||||
|
writeError(w, "max 3 images", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
enrichedMessage := resolveFileMentions(body.Message)
|
||||||
|
|
||||||
|
var imageIDs []string
|
||||||
|
if len(body.Images) > 0 {
|
||||||
|
descriptions := s.describeImages(body.Images)
|
||||||
|
var imgContext strings.Builder
|
||||||
|
for i, desc := range descriptions {
|
||||||
|
imgContext.WriteString(fmt.Sprintf("\n[Image %d (%s): %s]\n", i+1, body.Images[i].Filename, desc))
|
||||||
|
|
||||||
|
id, err := saveImage(body.Images[i].Data, body.Images[i].Filename, body.Images[i].MimeType)
|
||||||
|
if err != nil {
|
||||||
|
_ = err
|
||||||
|
} else {
|
||||||
|
imageIDs = append(imageIDs, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enrichedMessage = imgContext.String() + enrichedMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
displayMsg := body.Message
|
||||||
|
if len(body.Images) > 0 {
|
||||||
|
imgNames := make([]string, len(body.Images))
|
||||||
|
for i, img := range body.Images {
|
||||||
|
imgNames[i] = img.Filename
|
||||||
|
}
|
||||||
|
displayMsg += " [" + strings.Join(imgNames, ", ") + "]"
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(imageIDs) > 0 {
|
||||||
|
s.convStore.AddWithImages("user", displayMsg, imageIDs)
|
||||||
|
} else {
|
||||||
|
s.convStore.Add("user", displayMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.convStore.NeedsSummarization() {
|
||||||
|
s.autoSummarize()
|
||||||
|
}
|
||||||
|
|
||||||
|
orb, err := orchestrator.New(s.config)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var studioPrompt strings.Builder
|
||||||
|
studioPrompt.WriteString(agent.StudioSystemPrompt())
|
||||||
|
sysInfo := platform.Detect()
|
||||||
|
osName := sysInfo.OSName
|
||||||
|
if osName == "" {
|
||||||
|
osName = string(sysInfo.OS)
|
||||||
|
}
|
||||||
|
studioPrompt.WriteString(fmt.Sprintf("\nDate: %s\nHeure: %s\nSystème: %s\n", time.Now().Format("02/01/2006"), time.Now().Format("15:04:05"), osName))
|
||||||
|
canSudo := !agent.NeedsSudoPassword()
|
||||||
|
studioPrompt.WriteString(fmt.Sprintf("Root: %t\n", !canSudo))
|
||||||
|
if !canSudo {
|
||||||
|
studioPrompt.WriteString("⚠️ Session sans sudo sans mot de passe — les commandes sudo/doas nécessitent une autorisation. N'utilise PAS sudo ou doas sans demander.\n")
|
||||||
|
} else {
|
||||||
|
studioPrompt.WriteString("⚠️ Session avec privilèges sudo sans mot de passe — les commandes sudo s'exécuteront directement.\n")
|
||||||
|
}
|
||||||
|
orb.SetSystemPrompt(studioPrompt.String())
|
||||||
|
orb.SetTools(s.agentToolsJSON)
|
||||||
|
|
||||||
|
// Auto-force advanced reflection while a browser-test session is active:
|
||||||
|
// the user is doing AI-driven UI testing, where having a second model
|
||||||
|
// produce a preliminary report (when one is configured) materially
|
||||||
|
// improves which clicks the active model decides to perform. The toggle
|
||||||
|
// remains user-controllable for non-test conversations.
|
||||||
|
wantReflection := body.AdvancedReflection
|
||||||
|
if !wantReflection && s.browserTestStore != nil && len(s.browserTestStore.List()) > 0 {
|
||||||
|
wantReflection = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if wantReflection {
|
||||||
|
if report, ok := s.runReflectionReport(enrichedMessage); ok {
|
||||||
|
enrichedMessage = enrichedMessage + "\n\n[RAPPORT PRÉALABLE — produit par un autre modèle, à valider]\n" + report + "\n[/RAPPORT PRÉALABLE]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.Stream {
|
||||||
|
s.handleStreamChat(w, orb, enrichedMessage)
|
||||||
|
} else {
|
||||||
|
s.handleNonStreamChat(w, orb, enrichedMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orchestrator, userMessage string) {
|
||||||
|
SetupSSEHeaders(w)
|
||||||
|
flusher, canFlush := w.(http.Flusher)
|
||||||
|
|
||||||
|
|
||||||
|
sseWriter := NewSSEWriter(w)
|
||||||
|
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
messages := s.buildContextMessages(userMessage)
|
||||||
|
|
||||||
|
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
|
||||||
|
engine.SetLimiter(s.AcquireAgentSlot)
|
||||||
|
engine.OnChunk(func(data map[string]interface{}) {
|
||||||
|
if data == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sseWriter.Write(data)
|
||||||
|
if canFlush {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
finalContent, allToolCalls, allToolResults, err := engine.RunWithTools(ctx, messages)
|
||||||
|
if err != nil {
|
||||||
|
sseWriter.Write(map[string]interface{}{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
storeContent := finalContent
|
||||||
|
if len(allToolCalls) > 0 {
|
||||||
|
storeObj := map[string]interface{}{
|
||||||
|
"content": storeContent,
|
||||||
|
"tool_calls": allToolCalls,
|
||||||
|
"tool_results": allToolResults,
|
||||||
|
}
|
||||||
|
storeJSON, _ := json.Marshal(storeObj)
|
||||||
|
storeContent = string(storeJSON)
|
||||||
|
}
|
||||||
|
s.convStore.Add("assistant", storeContent)
|
||||||
|
|
||||||
|
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) {
|
||||||
|
ctx := context.Background()
|
||||||
|
messages := s.buildContextMessages(userMessage)
|
||||||
|
|
||||||
|
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
|
||||||
|
engine.SetLimiter(s.AcquireAgentSlot)
|
||||||
|
finalContent, err := engine.RunNonStream(ctx, messages)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.convStore.Add("assistant", finalContent)
|
||||||
|
|
||||||
|
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
|
||||||
|
|
||||||
|
writeJSON(w, map[string]string{"content": finalContent})
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanThinkingTags(content string) string {
|
||||||
|
return strings.TrimSpace(thinkingTagRegex.ReplaceAllString(content, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
// runReflectionReport runs the inactive AI provider on the user message to
|
||||||
|
// produce a preliminary analysis report that the active provider will then
|
||||||
|
// use as additional context. Returns ("", false) if no inactive provider is
|
||||||
|
// configured or on error — the caller falls back to a normal chat flow.
|
||||||
|
func (s *Server) runReflectionReport(userMessage string) (string, bool) {
|
||||||
|
orb, err := orchestrator.NewForInactiveProvider(s.config)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
orb.SetSystemPrompt("Tu es un analyste. Pour la question ci-dessous, produis un rapport bref (max 15 lignes) qui : (1) reformule l'objectif de l'utilisateur, (2) liste les points à clarifier ou les risques, (3) suggère une approche structurée. Pas de code, pas d'action — uniquement de l'analyse.")
|
||||||
|
resp, err := orb.SendNoTools(userMessage)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(resp), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message {
|
||||||
|
history := s.convStore.Get()
|
||||||
|
|
||||||
|
sysPromptTokens := utf8.RuneCountInString(agent.StudioSystemPrompt())/charsPerToken + 50
|
||||||
|
toolsTokens := utf8.RuneCountInString(string(s.agentToolsJSON)) / charsPerToken
|
||||||
|
responseMargin := 4000
|
||||||
|
userMsgTokens := utf8.RuneCountInString(userMessage) / charsPerToken
|
||||||
|
|
||||||
|
overhead := sysPromptTokens + toolsTokens + responseMargin + userMsgTokens
|
||||||
|
available := contextWindowTokens - overhead
|
||||||
|
if available < 1000 {
|
||||||
|
available = 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
included := 0
|
||||||
|
tokensUsed := 0
|
||||||
|
for i := len(history) - 1; i >= 0; i-- {
|
||||||
|
if history[i].Summarized {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
displayContent := extractDisplayContent(history[i].Role, history[i].Content)
|
||||||
|
msgTokens := utf8.RuneCountInString(displayContent) / charsPerToken
|
||||||
|
if msgTokens == 0 {
|
||||||
|
msgTokens = 1
|
||||||
|
}
|
||||||
|
if tokensUsed+msgTokens > available {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
tokensUsed += msgTokens
|
||||||
|
included++
|
||||||
|
}
|
||||||
|
|
||||||
|
start := len(history) - included
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
hasSummarized := false
|
||||||
|
for i := 0; i < start; i++ {
|
||||||
|
if history[i].Summarized {
|
||||||
|
hasSummarized = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if start > 0 {
|
||||||
|
_ = start
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := make([]orchestrator.Message, 0, included+2)
|
||||||
|
|
||||||
|
summary := s.convStore.GetSummary()
|
||||||
|
if summary != "" && (start > 0 || hasSummarized) {
|
||||||
|
messages = append(messages, orchestrator.Message{
|
||||||
|
Role: "system",
|
||||||
|
Content: orchestrator.TextContent("Résumé de la conversation précédente:\n" + summary),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range history[start:] {
|
||||||
|
if m.Role == "system" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
displayContent := extractDisplayContent(m.Role, m.Content)
|
||||||
|
messages = append(messages, orchestrator.Message{
|
||||||
|
Role: m.Role,
|
||||||
|
Content: orchestrator.TextContent(displayContent),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
messages = append(messages, orchestrator.Message{
|
||||||
|
Role: "user",
|
||||||
|
Content: orchestrator.TextContent(userMessage),
|
||||||
|
})
|
||||||
|
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) autoSummarize() {
|
||||||
|
messages := s.convStore.Get()
|
||||||
|
if len(messages) < 10 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
half := len(messages) / 2
|
||||||
|
var oldText string
|
||||||
|
for _, m := range messages[:half] {
|
||||||
|
oldText += m.Role + ": " + m.Content + "\n\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := s.convStore.GetSummary()
|
||||||
|
if summary != "" {
|
||||||
|
oldText = "Résumé précédent:\n" + summary + "\n\nNouveaux échanges:\n" + oldText
|
||||||
|
}
|
||||||
|
|
||||||
|
orb, err := orchestrator.New(s.config)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
orb.SetSystemPrompt(summarizePrompt)
|
||||||
|
|
||||||
|
result, err := orb.Send(oldText)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.convStore.SetSummary(result)
|
||||||
|
s.convStore.MarkSummarized(half)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" {
|
||||||
|
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
messages := s.convStore.Get()
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"messages": messages,
|
||||||
|
"tokens": s.convStore.ApproxTokenCount(),
|
||||||
|
"max_tokens": contextWindowTokens,
|
||||||
|
"summarize_at": int(float64(contextWindowTokens) * summarizeRatio),
|
||||||
|
"summary": s.convStore.GetSummary(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleChatClear(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.convStore.Clear()
|
||||||
|
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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
31
internal/api/handlers_common.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
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{}) {
|
||||||
|
json.NewEncoder(w).Encode(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, msg string, code int) {
|
||||||
|
w.WriteHeader(code)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||||
|
}
|
||||||
511
internal/api/handlers_config.go
Normal file
@@ -0,0 +1,511 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleUpdatePreferences(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "PUT" {
|
||||||
|
writeError(w, "PUT only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.config == nil {
|
||||||
|
writeError(w, "no config", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Language string `json:"language"`
|
||||||
|
KeyboardLayout string `json:"keyboard_layout"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Language != "" {
|
||||||
|
s.config.Profile.Preferences.Language = body.Language
|
||||||
|
}
|
||||||
|
if body.KeyboardLayout != "" {
|
||||||
|
s.config.Profile.Preferences.KeyboardLayout = body.KeyboardLayout
|
||||||
|
}
|
||||||
|
if err := config.Save(s.config); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "PUT" {
|
||||||
|
writeError(w, "PUT only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.config == nil {
|
||||||
|
writeError(w, "no config", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentJSON, err := json.Marshal(s.config.Profile)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var currentMap map[string]interface{}
|
||||||
|
if err := json.Unmarshal(currentJSON, ¤tMap); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var updates map[string]interface{}
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &updates); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deepMerge(currentMap, updates)
|
||||||
|
|
||||||
|
mergedJSON, err := json.Marshal(currentMap)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(mergedJSON, &s.config.Profile); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.Save(s.config); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
if r.Method != "PUT" {
|
||||||
|
writeError(w, "PUT only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.config == nil {
|
||||||
|
writeError(w, "no config", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
Active *bool `json:"active"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Name == "" {
|
||||||
|
writeError(w, "name required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for i := range s.config.AI.Providers {
|
||||||
|
if s.config.AI.Providers[i].Name == body.Name {
|
||||||
|
if body.APIKey != "" && body.APIKey != "***" {
|
||||||
|
s.config.AI.Providers[i].APIKey = body.APIKey
|
||||||
|
}
|
||||||
|
if body.Model != "" {
|
||||||
|
s.config.AI.Providers[i].Model = body.Model
|
||||||
|
}
|
||||||
|
if body.BaseURL != "" {
|
||||||
|
s.config.AI.Providers[i].BaseURL = body.BaseURL
|
||||||
|
}
|
||||||
|
if body.Active != nil {
|
||||||
|
if *body.Active {
|
||||||
|
for j := range s.config.AI.Providers {
|
||||||
|
s.config.AI.Providers[j].Active = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.config.AI.Providers[i].Active = *body.Active
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
writeError(w, "provider not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := config.Save(s.config); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleValidateProvider(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.APIKey == "" {
|
||||||
|
writeError(w, "api_key required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.APIKey == "***" {
|
||||||
|
for _, p := range s.config.AI.Providers {
|
||||||
|
if p.Name == body.Name {
|
||||||
|
body.APIKey = p.APIKey
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL := body.BaseURL
|
||||||
|
if baseURL == "" {
|
||||||
|
for _, p := range s.config.AI.Providers {
|
||||||
|
if p.Name == body.Name {
|
||||||
|
baseURL = p.BaseURL
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if baseURL == "" {
|
||||||
|
switch body.Name {
|
||||||
|
case "minimax":
|
||||||
|
baseURL = "https://api.minimax.io/v1"
|
||||||
|
case "mimo":
|
||||||
|
baseURL = "https://token-plan-ams.xiaomimimo.com/v1"
|
||||||
|
case "openai":
|
||||||
|
baseURL = "https://api.openai.com/v1"
|
||||||
|
case "anthropic":
|
||||||
|
baseURL = "https://api.anthropic.com/v1"
|
||||||
|
default:
|
||||||
|
baseURL = "https://api.minimax.io/v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
model := body.Model
|
||||||
|
if model == "" {
|
||||||
|
for _, p := range s.config.AI.Providers {
|
||||||
|
if p.Name == body.Name {
|
||||||
|
model = p.Model
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if model == "" {
|
||||||
|
model = "MiniMax-M2.7"
|
||||||
|
}
|
||||||
|
|
||||||
|
reqBody, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"model": model,
|
||||||
|
"messages": []map[string]string{{"role": "user", "content": "Hi"}},
|
||||||
|
"max_tokens": 5,
|
||||||
|
"stream": false,
|
||||||
|
})
|
||||||
|
|
||||||
|
url := strings.TrimRight(baseURL, "/") + "/chat/completions"
|
||||||
|
req, err := http.NewRequest("POST", url, bytes.NewReader(reqBody))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+body.APIKey)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "connection failed: "+err.Error(), http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||||
|
writeError(w, "invalid_api_key", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
writeError(w, "api_error: "+string(respBody), http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{"status": "valid"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSaveTerminalSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "PUT" {
|
||||||
|
writeError(w, "PUT only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.config == nil {
|
||||||
|
writeError(w, "no config", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
FontSize int `json:"font_size"`
|
||||||
|
FontFamily string `json:"font_family"`
|
||||||
|
Theme string `json:"theme"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.FontSize > 0 && body.FontSize <= 72 {
|
||||||
|
s.config.Terminal.FontSize = body.FontSize
|
||||||
|
}
|
||||||
|
if body.FontFamily != "" {
|
||||||
|
s.config.Terminal.FontFamily = body.FontFamily
|
||||||
|
}
|
||||||
|
if body.Theme != "" {
|
||||||
|
s.config.Terminal.Theme = body.Theme
|
||||||
|
}
|
||||||
|
if err := config.Save(s.config); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"theme": config.GetTerminalTheme(s.config.Terminal.Theme),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetTerminalThemes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
themes := make([]map[string]string, 0, len(config.DEFAULT_TERMINAL_THEMES))
|
||||||
|
for id, theme := range config.DEFAULT_TERMINAL_THEMES {
|
||||||
|
themes = append(themes, map[string]string{
|
||||||
|
"id": id,
|
||||||
|
"name": theme.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{"themes": themes})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleResetConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dir, err := config.ConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.config = config.Default()
|
||||||
|
if err := config.Save(s.config); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleApplyStarshipTheme(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Theme string `json:"theme"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Theme == "" {
|
||||||
|
body.Theme = s.config.Terminal.PromptTheme
|
||||||
|
}
|
||||||
|
|
||||||
|
themeFile := ApplyStarshipTheme(body.Theme)
|
||||||
|
|
||||||
|
s.config.Terminal.PromptTheme = body.Theme
|
||||||
|
config.Save(s.config)
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{"status": "ok", "config": themeFile})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ApplyStarshipTheme(theme string) string {
|
||||||
|
cfgDir, _ := config.ConfigDir()
|
||||||
|
starshipDir := filepath.Join(cfgDir, "starship")
|
||||||
|
os.MkdirAll(starshipDir, 0755)
|
||||||
|
themeFile := filepath.Join(starshipDir, "starship.toml")
|
||||||
|
|
||||||
|
themeContent := getStarshipThemeConfig(theme)
|
||||||
|
os.WriteFile(themeFile, []byte(themeContent), 0644)
|
||||||
|
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
for _, rc := range []string{filepath.Join(home, ".bashrc"), filepath.Join(home, ".zshrc")} {
|
||||||
|
if _, err := os.Stat(rc); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
content, _ := os.ReadFile(rc)
|
||||||
|
if strings.Contains(string(content), "STARSHIP_CONFIG") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
exportLine := fmt.Sprintf("\n# Muyue Starship config\nexport STARSHIP_CONFIG=%s\n", themeFile)
|
||||||
|
f, err := os.OpenFile(rc, os.O_APPEND|os.O_WRONLY, 0644)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
f.WriteString(exportLine)
|
||||||
|
f.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return themeFile
|
||||||
|
}
|
||||||
|
|
||||||
|
func getStarshipThemeConfig(theme string) string {
|
||||||
|
switch theme {
|
||||||
|
case "charm":
|
||||||
|
return `[format]
|
||||||
|
before_format = "$"
|
||||||
|
format = """
|
||||||
|
$username$directory$git_branch$git_status$cmd_duration$line_break$character"""
|
||||||
|
|
||||||
|
[character]
|
||||||
|
success_symbol = "[➜](bold #00E676)"
|
||||||
|
error_symbol = "[✗](bold #FF0033)"
|
||||||
|
|
||||||
|
[directory]
|
||||||
|
truncation_length = 3
|
||||||
|
truncation_symbol = "…/"
|
||||||
|
style = "bold #00BCD4"
|
||||||
|
|
||||||
|
[username]
|
||||||
|
show_on_left = false
|
||||||
|
style_user = "bold #FF0033"
|
||||||
|
style_root = "bold #FF0033"
|
||||||
|
|
||||||
|
[git_branch]
|
||||||
|
symbol = " "
|
||||||
|
format = "on [$symbol$branch]($style)"
|
||||||
|
style = "bold #FFD740"
|
||||||
|
|
||||||
|
[git_status]
|
||||||
|
format = "[$all_status$ahead_behind]($style) "
|
||||||
|
style = "bold #FF1A5E"
|
||||||
|
conflicted = "!"
|
||||||
|
untracked = "?"
|
||||||
|
modified = "~"
|
||||||
|
staged = "[+]"
|
||||||
|
renamed = "»"
|
||||||
|
deleted = "-"
|
||||||
|
|
||||||
|
[cmd_duration]
|
||||||
|
min_time = 500
|
||||||
|
format = "took [$duration]($style)"
|
||||||
|
style = "bold #75715E"
|
||||||
|
`
|
||||||
|
case "zerotwo":
|
||||||
|
return `[format]
|
||||||
|
before_format = "$"
|
||||||
|
format = """
|
||||||
|
$username$directory$git_branch$git_status$cmd_duration$line_break$character"""
|
||||||
|
|
||||||
|
[character]
|
||||||
|
success_symbol = "[❯](bold #3B82F6)"
|
||||||
|
error_symbol = "[❯](bold #EF4444)"
|
||||||
|
|
||||||
|
[directory]
|
||||||
|
truncation_length = 3
|
||||||
|
truncation_symbol = "…/"
|
||||||
|
style = "bold #8B5CF6"
|
||||||
|
|
||||||
|
[username]
|
||||||
|
show_on_left = false
|
||||||
|
style_user = "bold #EC4899"
|
||||||
|
style_root = "bold #EF4444"
|
||||||
|
|
||||||
|
[git_branch]
|
||||||
|
symbol = " "
|
||||||
|
format = "on [$symbol$branch]($style)"
|
||||||
|
style = "bold #F472B6"
|
||||||
|
|
||||||
|
[git_status]
|
||||||
|
format = "[$all_status$ahead_behind]($style) "
|
||||||
|
style = "bold #EF4444"
|
||||||
|
conflicted = "!"
|
||||||
|
untracked = "?"
|
||||||
|
modified = "~"
|
||||||
|
staged = "[+]"
|
||||||
|
renamed = "»"
|
||||||
|
deleted = "-"
|
||||||
|
|
||||||
|
[cmd_duration]
|
||||||
|
min_time = 500
|
||||||
|
format = "took [$duration]($style)"
|
||||||
|
style = "bold #6B7280"
|
||||||
|
`
|
||||||
|
default:
|
||||||
|
return `[format]
|
||||||
|
before_format = "$"
|
||||||
|
format = """
|
||||||
|
$username$directory$git_branch$git_status$line_break$character"""
|
||||||
|
|
||||||
|
[character]
|
||||||
|
success_symbol = "[❯](bold green)"
|
||||||
|
error_symbol = "[❯](bold red)"
|
||||||
|
|
||||||
|
[directory]
|
||||||
|
truncation_length = 3
|
||||||
|
truncation_symbol = "…/"
|
||||||
|
style = "bold cyan"
|
||||||
|
|
||||||
|
[username]
|
||||||
|
show_on_left = false
|
||||||
|
style_user = "bold red"
|
||||||
|
style_root = "bold red"
|
||||||
|
|
||||||
|
[git_branch]
|
||||||
|
symbol = " "
|
||||||
|
format = "on [$symbol$branch]($style)"
|
||||||
|
style = "bold yellow"
|
||||||
|
|
||||||
|
[cmd_duration]
|
||||||
|
min_time = 500
|
||||||
|
format = "took [$duration]($style)"
|
||||||
|
style = "bold bright-black"
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
848
internal/api/handlers_info.go
Normal file
@@ -0,0 +1,848 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/agent"
|
||||||
|
"github.com/muyue/muyue/internal/lsp"
|
||||||
|
"github.com/muyue/muyue/internal/mcp"
|
||||||
|
"github.com/muyue/muyue/internal/scanner"
|
||||||
|
"github.com/muyue/muyue/internal/skills"
|
||||||
|
"github.com/muyue/muyue/internal/version"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"name": version.Name,
|
||||||
|
"version": version.Version,
|
||||||
|
"author": version.Author,
|
||||||
|
"sudo": !agent.NeedsSudoPassword(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSystem(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.scanResult == nil {
|
||||||
|
s.scanResult = scanner.ScanSystem()
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"system": s.scanResult.System,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleTools(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.scanResult == nil {
|
||||||
|
s.scanResult = scanner.ScanSystem()
|
||||||
|
}
|
||||||
|
type toolInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Installed bool `json:"installed"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
tools := make([]toolInfo, len(s.scanResult.Tools))
|
||||||
|
for i, t := range s.scanResult.Tools {
|
||||||
|
tools[i] = toolInfo{
|
||||||
|
Name: t.Name,
|
||||||
|
Installed: t.Installed,
|
||||||
|
Version: t.Version,
|
||||||
|
Path: t.Path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"tools": tools,
|
||||||
|
"total": len(tools),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.config == nil {
|
||||||
|
writeError(w, "no config", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"profile": s.config.Profile,
|
||||||
|
"terminal": s.config.Terminal,
|
||||||
|
"bmad": s.config.BMAD,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleProviders(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.config == nil {
|
||||||
|
writeError(w, "no config", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
masked := make([]map[string]interface{}, 0, len(s.config.AI.Providers))
|
||||||
|
for _, p := range s.config.AI.Providers {
|
||||||
|
entry := map[string]interface{}{
|
||||||
|
"name": p.Name,
|
||||||
|
"model": p.Model,
|
||||||
|
"base_url": p.BaseURL,
|
||||||
|
"active": p.Active,
|
||||||
|
}
|
||||||
|
if p.APIKey != "" {
|
||||||
|
entry["api_key"] = "***"
|
||||||
|
} else {
|
||||||
|
entry["api_key"] = ""
|
||||||
|
}
|
||||||
|
masked = append(masked, entry)
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"providers": masked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSkills(w http.ResponseWriter, r *http.Request) {
|
||||||
|
list, err := skills.List()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range list {
|
||||||
|
list[i].Deployed = skills.IsDeployed(list[i].Name)
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"skills": list,
|
||||||
|
"count": len(list),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLSP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
servers := lsp.ScanServers()
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"servers": servers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
servers := mcp.ScanServers()
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
editors := mcp.DetectInstalledEditors(home)
|
||||||
|
statuses := mcp.GetAllStatuses()
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"servers": servers,
|
||||||
|
"configured": true,
|
||||||
|
"detected_editors": editors,
|
||||||
|
"statuses": statuses,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMCPConfigure(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Editor string `json:"editor,omitempty"`
|
||||||
|
}
|
||||||
|
if r.Body != nil {
|
||||||
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.Editor != "" {
|
||||||
|
if err := mcp.ConfigureForEditor(s.config, body.Editor); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := mcp.ConfigureAll(s.config); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMCPStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
statuses := mcp.GetAllStatuses()
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"statuses": statuses,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMCPRegistry(w http.ResponseWriter, r *http.Request) {
|
||||||
|
reg, err := mcp.LoadRegistry()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"registry": reg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLSPHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
servers := lsp.ScanServers()
|
||||||
|
type healthInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
Installed bool `json:"installed"`
|
||||||
|
Healthy bool `json:"healthy"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
}
|
||||||
|
var results []healthInfo
|
||||||
|
for _, srv := range servers {
|
||||||
|
healthy, detail := lsp.HealthCheck(srv.Name)
|
||||||
|
results = append(results, healthInfo{
|
||||||
|
Name: srv.Name,
|
||||||
|
Language: srv.Language,
|
||||||
|
Installed: srv.Installed,
|
||||||
|
Healthy: healthy,
|
||||||
|
Detail: detail,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"servers": results,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLSPAutoInstall(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
ProjectDir string `json:"project_dir,omitempty"`
|
||||||
|
}
|
||||||
|
if r.Body != nil {
|
||||||
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
}
|
||||||
|
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
if body.ProjectDir == "" {
|
||||||
|
body.ProjectDir = home
|
||||||
|
} else {
|
||||||
|
abs, err := filepath.Abs(body.ProjectDir)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "invalid project_dir", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body.ProjectDir = abs
|
||||||
|
if home != "" && !strings.HasPrefix(abs, home+string(filepath.Separator)) && abs != home {
|
||||||
|
writeError(w, "project_dir must be within user home", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := lsp.AutoInstallForProject(body.ProjectDir)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"results": results,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLSPEditorConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Editor string `json:"editor"`
|
||||||
|
Names []string `json:"names,omitempty"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
allServers := lsp.ScanServers()
|
||||||
|
var selected []lsp.LSPServer
|
||||||
|
if len(body.Names) > 0 {
|
||||||
|
nameSet := map[string]bool{}
|
||||||
|
for _, n := range body.Names {
|
||||||
|
nameSet[n] = true
|
||||||
|
}
|
||||||
|
for _, srv := range allServers {
|
||||||
|
if nameSet[srv.Name] {
|
||||||
|
selected = append(selected, srv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for _, srv := range allServers {
|
||||||
|
if srv.Installed {
|
||||||
|
selected = append(selected, srv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := lsp.GenerateEditorConfigs(selected, body.Editor, "")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"editor": body.Editor,
|
||||||
|
"config": config,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSkillValidate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
skill, err := skills.Get(body.Name)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
errs := skills.Validate(skill)
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"name": body.Name,
|
||||||
|
"valid": len(errs) == 0,
|
||||||
|
"errors": errs,
|
||||||
|
"dependencies": skills.CheckDependencies(skill),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSkillTest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
SampleTask string `json:"sample_task,omitempty"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := skills.DryRun(body.Name, body.SampleTask)
|
||||||
|
writeJSON(w, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSkillExport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ExportPath string `json:"export_path"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
if body.ExportPath == "" {
|
||||||
|
body.ExportPath = home + "/.muyue/exports/" + body.Name + ".md"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := skills.Export(body.Name, body.ExportPath); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]string{"status": "ok", "path": body.ExportPath})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSkillImport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
ImportPath string `json:"import_path"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
skill, err := skills.Import(body.ImportPath)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := skills.Create(skill); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{"status": "ok", "skill": skill.Name})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDashboardStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mcpStatuses := mcp.GetAllStatuses()
|
||||||
|
lspServers := lsp.ScanServers()
|
||||||
|
skillList, _ := skills.List()
|
||||||
|
|
||||||
|
mcpHealthy := 0
|
||||||
|
mcpTotal := len(mcpStatuses)
|
||||||
|
for _, st := range mcpStatuses {
|
||||||
|
if st.Healthy {
|
||||||
|
mcpHealthy++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lspInstalled := 0
|
||||||
|
lspTotal := len(lspServers)
|
||||||
|
for _, srv := range lspServers {
|
||||||
|
if srv.Installed {
|
||||||
|
lspInstalled++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
skillsDeployed := len(skillList)
|
||||||
|
var skillIssues []string
|
||||||
|
for _, sk := range skillList {
|
||||||
|
missing := skills.CheckDependencies(&sk)
|
||||||
|
if len(missing) > 0 {
|
||||||
|
for _, dep := range missing {
|
||||||
|
skillIssues = append(skillIssues, sk.Name+": missing "+dep.Type+" "+dep.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"mcp": map[string]interface{}{
|
||||||
|
"total": mcpTotal,
|
||||||
|
"healthy": mcpHealthy,
|
||||||
|
"servers": mcpStatuses,
|
||||||
|
},
|
||||||
|
"lsp": map[string]interface{}{
|
||||||
|
"total": lspTotal,
|
||||||
|
"installed": lspInstalled,
|
||||||
|
"servers": lspServers,
|
||||||
|
},
|
||||||
|
"skills": map[string]interface{}{
|
||||||
|
"total": skillsDeployed,
|
||||||
|
"issues": skillIssues,
|
||||||
|
"deployed": skillList,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.scanResult = scanner.ScanSystem()
|
||||||
|
writeJSON(w, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleEditors(w http.ResponseWriter, r *http.Request) {
|
||||||
|
editors := scanner.ScanEditors()
|
||||||
|
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)
|
||||||
|
}
|
||||||
292
internal/api/handlers_missing.go
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/config"
|
||||||
|
"github.com/muyue/muyue/internal/lsp"
|
||||||
|
"github.com/muyue/muyue/internal/skills"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SavedConversation struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Summary string `json:"summary,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
Messages []MessageEntry `json:"messages,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageEntry struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Time string `json:"time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type conversationsStore struct {
|
||||||
|
Path string
|
||||||
|
Items []SavedConversation
|
||||||
|
}
|
||||||
|
|
||||||
|
func conversationsPath() string {
|
||||||
|
dir, _ := config.ConfigDir()
|
||||||
|
return filepath.Join(dir, "conversations.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func listConversations() ([]SavedConversation, error) {
|
||||||
|
path := conversationsPath()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return []SavedConversation{}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var store conversationsStore
|
||||||
|
if err := json.Unmarshal(data, &store); err != nil {
|
||||||
|
return []SavedConversation{}, nil
|
||||||
|
}
|
||||||
|
return store.Items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveConversations(items []SavedConversation) error {
|
||||||
|
path := conversationsPath()
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
os.MkdirAll(dir, 0755)
|
||||||
|
data, err := json.MarshalIndent(struct {
|
||||||
|
Items []SavedConversation `json:"items"`
|
||||||
|
}{Items: items}, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, data, 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleListConversations(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" {
|
||||||
|
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
convs, err := listConversations()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conv := s.convStore.Get()
|
||||||
|
tokenInfo := s.convStore.ApproxTokenCountDetailed()
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"conversations": convs,
|
||||||
|
"current_messages": conv,
|
||||||
|
"tokens": tokenInfo.total,
|
||||||
|
"tokens_by_role": tokenInfo.byRole,
|
||||||
|
"summary": s.convStore.GetSummary(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeleteConversation(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "DELETE" {
|
||||||
|
writeError(w, "DELETE only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := strings.TrimPrefix(r.URL.Path, "/api/conversations/")
|
||||||
|
id = strings.TrimPrefix(id, "/")
|
||||||
|
if id == "" {
|
||||||
|
s.convStore.Clear()
|
||||||
|
writeJSON(w, map[string]string{"status": "cleared"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
convs, err := listConversations()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filtered := make([]SavedConversation, 0, len(convs))
|
||||||
|
found := false
|
||||||
|
for _, c := range convs {
|
||||||
|
if c.ID == id {
|
||||||
|
found = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, c)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
writeError(w, "conversation not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := saveConversations(filtered); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]string{"status": "deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSearchConversations(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" {
|
||||||
|
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
query := r.URL.Query().Get("q")
|
||||||
|
if query == "" {
|
||||||
|
writeError(w, "query parameter 'q' is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results := s.convStore.Search(query)
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"query": query,
|
||||||
|
"results": results,
|
||||||
|
"count": len(results),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleExportConversation(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" {
|
||||||
|
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
format := r.URL.Query().Get("format")
|
||||||
|
if format == "markdown" || format == "md" {
|
||||||
|
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
|
||||||
|
w.Write([]byte(s.convStore.ExportMarkdown()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(s.convStore.ExportJSON()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLSPInstall(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Name == "" {
|
||||||
|
writeError(w, "name is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := lsp.InstallServer(body.Name); err != nil {
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"server": body.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSkillsDeploy(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Name != "" {
|
||||||
|
skill, err := skills.Get(body.Name)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := skills.Deploy(skill); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]string{"status": "deployed", "skill": body.Name})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := skills.DeployAll(); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]string{"status": "all deployed"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSkillsUndeploy(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Name == "" {
|
||||||
|
writeError(w, "name is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := skills.Undeploy(body.Name); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]string{"status": "undeployed", "skill": body.Name})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSSHConnections(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" {
|
||||||
|
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"connections": cfg.Terminal.SSH,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSSHTest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
User string `json:"user"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Host == "" || body.User == "" {
|
||||||
|
writeError(w, "host and user are required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Port == 0 {
|
||||||
|
body.Port = 22
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"message": "SSH connection test not implemented (requires net.DialTimeout)",
|
||||||
|
})
|
||||||
|
}
|
||||||
376
internal/api/handlers_shell_chat.go
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/agent"
|
||||||
|
"github.com/muyue/muyue/internal/orchestrator"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ShellChatRequest struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Context string `json:"context,omitempty"`
|
||||||
|
Cwd string `json:"cwd,omitempty"`
|
||||||
|
Platform string `json:"platform,omitempty"`
|
||||||
|
Stream bool `json:"stream"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.shellConvStore.AtLimit() {
|
||||||
|
writeError(w, "context limit reached, use /clear", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req ShellChatRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Message == "" {
|
||||||
|
writeError(w, "message is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.shellConvStore.Add("user", req.Message)
|
||||||
|
|
||||||
|
orb, err := orchestrator.New(s.config)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
orb.SetSystemPrompt(s.buildShellSystemPrompt(req))
|
||||||
|
orb.SetTools(s.shellAgentToolsJSON)
|
||||||
|
|
||||||
|
if req.Stream {
|
||||||
|
s.handleShellChatStream(w, orb)
|
||||||
|
} else {
|
||||||
|
s.handleShellChatNonStream(w, orb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) buildShellSystemPrompt(req ShellChatRequest) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
sb.WriteString(shellSystemPromptBase)
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
canSudo := !agent.NeedsSudoPassword()
|
||||||
|
sb.WriteString(fmt.Sprintf("Root: %t\n", !canSudo))
|
||||||
|
if canSudo {
|
||||||
|
sb.WriteString("⚠️ Session avec privilèges sudo sans mot de passe — les commandes sudo s'exécuteront directement.\n")
|
||||||
|
} else {
|
||||||
|
sb.WriteString("⚠️ Session sans sudo sans mot de passe — les commandes sudo/doas nécessitent une autorisation. N'utilise PAS sudo ou doas sans demander.\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()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
|
||||||
|
SetupSSEHeaders(w)
|
||||||
|
flusher, canFlush := w.(http.Flusher)
|
||||||
|
sseWriter := NewSSEWriter(w)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
messages := s.buildShellContextMessages()
|
||||||
|
|
||||||
|
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
|
||||||
|
engine.SetLimiter(s.AcquireAgentSlot)
|
||||||
|
engine.OnChunk(func(data map[string]interface{}) {
|
||||||
|
if data == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sseWriter.Write(data)
|
||||||
|
if canFlush {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
finalContent, allToolCalls, allToolResults, err := engine.RunWithTools(ctx, messages)
|
||||||
|
if err != nil {
|
||||||
|
sseWriter.Write(map[string]interface{}{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
storeContent := finalContent
|
||||||
|
if len(allToolCalls) > 0 {
|
||||||
|
storeObj := map[string]interface{}{
|
||||||
|
"content": storeContent,
|
||||||
|
"tool_calls": allToolCalls,
|
||||||
|
"tool_results": allToolResults,
|
||||||
|
}
|
||||||
|
storeJSON, _ := json.Marshal(storeObj)
|
||||||
|
storeContent = string(storeJSON)
|
||||||
|
}
|
||||||
|
s.shellConvStore.Add("assistant", storeContent)
|
||||||
|
|
||||||
|
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
|
||||||
|
|
||||||
|
sseWriter.Write(map[string]interface{}{
|
||||||
|
"done": "true",
|
||||||
|
"tokens": s.shellConvStore.ApproxTokens(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
|
||||||
|
ctx := context.Background()
|
||||||
|
messages := s.buildShellContextMessages()
|
||||||
|
|
||||||
|
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
|
||||||
|
engine.SetLimiter(s.AcquireAgentSlot)
|
||||||
|
finalContent, err := engine.RunNonStream(ctx, messages)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.shellConvStore.Add("assistant", finalContent)
|
||||||
|
|
||||||
|
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"content": finalContent,
|
||||||
|
"tokens": s.shellConvStore.ApproxTokens(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) buildShellContextMessages() []orchestrator.Message {
|
||||||
|
history := s.shellConvStore.Get()
|
||||||
|
|
||||||
|
sysTokens := utf8.RuneCountInString(shellSystemPromptBase) / charsPerToken
|
||||||
|
if analysis := LoadSystemAnalysis(); analysis != "" {
|
||||||
|
sysTokens += utf8.RuneCountInString(analysis) / charsPerToken
|
||||||
|
}
|
||||||
|
sysTokens += 100
|
||||||
|
toolsTokens := utf8.RuneCountInString(string(s.shellAgentToolsJSON)) / charsPerToken
|
||||||
|
responseMargin := 4000
|
||||||
|
|
||||||
|
overhead := sysTokens + toolsTokens + responseMargin
|
||||||
|
available := shellMaxTokens - overhead
|
||||||
|
if available < 1000 {
|
||||||
|
available = 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
included := 0
|
||||||
|
tokensUsed := 0
|
||||||
|
for i := len(history) - 1; i >= 0; i-- {
|
||||||
|
displayContent := extractDisplayContent(history[i].Role, history[i].Content)
|
||||||
|
msgTokens := utf8.RuneCountInString(displayContent) / charsPerToken
|
||||||
|
if msgTokens == 0 {
|
||||||
|
msgTokens = 1
|
||||||
|
}
|
||||||
|
if tokensUsed+msgTokens > available {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
tokensUsed += msgTokens
|
||||||
|
included++
|
||||||
|
}
|
||||||
|
|
||||||
|
start := len(history) - included
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if start > 0 {
|
||||||
|
_ = start
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := make([]orchestrator.Message, 0, included)
|
||||||
|
|
||||||
|
for _, m := range history[start:] {
|
||||||
|
if m.Role == "system" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
displayContent := extractDisplayContent(m.Role, m.Content)
|
||||||
|
messages = append(messages, orchestrator.Message{
|
||||||
|
Role: m.Role,
|
||||||
|
Content: orchestrator.TextContent(displayContent),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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})
|
||||||
|
}
|
||||||
44
internal/api/handlers_terminal.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleTerminal(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Command string `json:"command"`
|
||||||
|
Cwd string `json:"cwd"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Command == "" {
|
||||||
|
writeError(w, "no command", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
shell := detectShell()
|
||||||
|
|
||||||
|
cmd := exec.Command(shell, "-c", body.Command)
|
||||||
|
if body.Cwd != "" {
|
||||||
|
cmd.Dir = body.Cwd
|
||||||
|
}
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
|
||||||
|
type termResult struct {
|
||||||
|
Output string `json:"output"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
result := termResult{Output: string(out)}
|
||||||
|
if err != nil {
|
||||||
|
result.Error = err.Error()
|
||||||
|
}
|
||||||
|
writeJSON(w, result)
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
119
internal/api/handlers_tools.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/installer"
|
||||||
|
"github.com/muyue/muyue/internal/scanner"
|
||||||
|
"github.com/muyue/muyue/internal/updater"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleUpdates(w http.ResponseWriter, r *http.Request) {
|
||||||
|
result := scanner.ScanSystem()
|
||||||
|
statuses := updater.CheckUpdates(result)
|
||||||
|
type updateInfo struct {
|
||||||
|
Tool string `json:"tool"`
|
||||||
|
Current string `json:"current"`
|
||||||
|
Latest string `json:"latest"`
|
||||||
|
NeedsUpdate bool `json:"needsUpdate"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
updates := make([]updateInfo, len(statuses))
|
||||||
|
for i, u := range statuses {
|
||||||
|
updates[i] = updateInfo{
|
||||||
|
Tool: u.Tool,
|
||||||
|
Current: u.Current,
|
||||||
|
Latest: u.Latest,
|
||||||
|
NeedsUpdate: u.NeedsUpdate,
|
||||||
|
Error: u.Error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"updates": updates,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleInstall(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Tools []string `json:"tools"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(body.Tools) == 0 {
|
||||||
|
writeError(w, "no tools specified", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]installer.InstallResult, len(body.Tools))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var mu sync.Mutex
|
||||||
|
|
||||||
|
for i, tool := range body.Tools {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int, name string) {
|
||||||
|
defer wg.Done()
|
||||||
|
inst := installer.New(s.config)
|
||||||
|
res := inst.InstallTool(name)
|
||||||
|
mu.Lock()
|
||||||
|
results[idx] = res
|
||||||
|
mu.Unlock()
|
||||||
|
}(i, tool)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"status": "done",
|
||||||
|
"tools": body.Tools,
|
||||||
|
"results": results,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRunUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Tool string `json:"tool"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := scanner.ScanSystem()
|
||||||
|
statuses := updater.CheckUpdates(result)
|
||||||
|
|
||||||
|
if body.Tool != "" {
|
||||||
|
for _, u := range statuses {
|
||||||
|
if u.Tool == body.Tool && u.NeedsUpdate {
|
||||||
|
updater.RunAutoUpdate([]updater.UpdateStatus{u})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]string{"status": "ok", "tool": body.Tool})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
needsUpdate := make([]updater.UpdateStatus, 0)
|
||||||
|
for _, u := range statuses {
|
||||||
|
if u.NeedsUpdate {
|
||||||
|
needsUpdate = append(needsUpdate, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(needsUpdate) > 0 {
|
||||||
|
updater.RunAutoUpdate(needsUpdate)
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"updated": len(needsUpdate),
|
||||||
|
})
|
||||||
|
}
|
||||||
85
internal/api/handlers_tools_exec.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ToolCallRequest struct {
|
||||||
|
Tool string `json:"tool"`
|
||||||
|
Args json.RawMessage `json:"args"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolResult struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Tool string `json:"tool"`
|
||||||
|
Result *toolResponseData `json:"result,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type toolResponseData struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
IsError bool `json:"is_error"`
|
||||||
|
Meta map[string]string `json:"meta,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleToolCall(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req ToolCallRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Tool == "" {
|
||||||
|
writeError(w, "tool is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
call := agent.ToolCall{
|
||||||
|
ID: generateMsgID(),
|
||||||
|
Name: req.Tool,
|
||||||
|
Arguments: req.Args,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, execErr := s.agentRegistry.Execute(ctx, call)
|
||||||
|
if execErr != nil {
|
||||||
|
writeJSON(w, ToolResult{
|
||||||
|
Success: false,
|
||||||
|
Tool: req.Tool,
|
||||||
|
Error: execErr.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, ToolResult{
|
||||||
|
Success: true,
|
||||||
|
Tool: req.Tool,
|
||||||
|
Result: &toolResponseData{
|
||||||
|
Content: result.Content,
|
||||||
|
IsError: result.IsError,
|
||||||
|
Meta: result.Meta,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleToolList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" {
|
||||||
|
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tools := s.agentRegistry.All()
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"tools": tools,
|
||||||
|
"count": len(tools),
|
||||||
|
})
|
||||||
|
}
|
||||||
258
internal/api/handlers_workflow.go
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/workflow"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleWorkflowCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.Name == "" {
|
||||||
|
writeError(w, "name is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
engine := s.workflowEngine
|
||||||
|
if engine == nil {
|
||||||
|
engine, _ = workflow.NewEngine(s.agentRegistry)
|
||||||
|
}
|
||||||
|
|
||||||
|
wf := engine.Create(body.Name, body.Description, body.Type, []workflow.Step{})
|
||||||
|
writeJSON(w, wf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleWorkflowList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" {
|
||||||
|
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
engine := s.workflowEngine
|
||||||
|
if engine == nil {
|
||||||
|
engine, _ = workflow.NewEngine(s.agentRegistry)
|
||||||
|
}
|
||||||
|
|
||||||
|
workflows := engine.List()
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"workflows": workflows,
|
||||||
|
"count": len(workflows),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleWorkflowGet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" {
|
||||||
|
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := strings.TrimPrefix(r.URL.Path, "/api/workflow/")
|
||||||
|
if id == "" {
|
||||||
|
writeError(w, "workflow id required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
engine := s.workflowEngine
|
||||||
|
if engine == nil {
|
||||||
|
engine, _ = workflow.NewEngine(s.agentRegistry)
|
||||||
|
}
|
||||||
|
|
||||||
|
wf, ok := engine.Get(id)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, "workflow not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, wf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleWorkflowDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "DELETE" {
|
||||||
|
writeError(w, "DELETE only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := strings.TrimPrefix(r.URL.Path, "/api/workflow/")
|
||||||
|
if id == "" {
|
||||||
|
writeError(w, "workflow id required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
engine := s.workflowEngine
|
||||||
|
if engine == nil {
|
||||||
|
engine, _ = workflow.NewEngine(s.agentRegistry)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := engine.Delete(id); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]string{"status": "deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleWorkflowPlan(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Goal string `json:"goal"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.Goal == "" {
|
||||||
|
writeError(w, "goal is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
planner, err := workflow.NewPlanner(s.config)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
steps, err := planner.GeneratePlan(context.Background(), body.Goal)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
engine := s.workflowEngine
|
||||||
|
if engine == nil {
|
||||||
|
engine, _ = workflow.NewEngine(s.agentRegistry)
|
||||||
|
}
|
||||||
|
|
||||||
|
wf := engine.Create("Plan: "+truncateString(body.Goal, 30), body.Goal, "plan_execute", steps)
|
||||||
|
writeJSON(w, wf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleWorkflowExecute(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := strings.TrimPrefix(r.URL.Path, "/api/workflow/execute/")
|
||||||
|
if id == "" {
|
||||||
|
writeError(w, "workflow id required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
engine := s.workflowEngine
|
||||||
|
if engine == nil {
|
||||||
|
engine, _ = workflow.NewEngine(s.agentRegistry)
|
||||||
|
}
|
||||||
|
|
||||||
|
wf, ok := engine.Get(id)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, "workflow not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.URL.Query().Get("stream") == "true" {
|
||||||
|
s.handleWorkflowExecuteStream(w, engine, wf)
|
||||||
|
} else {
|
||||||
|
err := engine.Execute(context.Background(), id, nil)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wf, _ = engine.Get(id)
|
||||||
|
writeJSON(w, wf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleWorkflowExecuteStream(w http.ResponseWriter, engine *workflow.Engine, wf *workflow.Workflow) {
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
flusher, canFlush := w.(http.Flusher)
|
||||||
|
|
||||||
|
writeSSE := func(data map[string]interface{}) {
|
||||||
|
b, _ := json.Marshal(data)
|
||||||
|
w.Write([]byte("data: " + string(b) + "\n\n"))
|
||||||
|
if canFlush {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
engine.Execute(context.Background(), wf.ID, func(step *workflow.Step, event string) {
|
||||||
|
writeSSE(map[string]interface{}{
|
||||||
|
"event": event,
|
||||||
|
"step": step,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
wf, _ = engine.Get(wf.ID)
|
||||||
|
writeSSE(map[string]interface{}{
|
||||||
|
"event": "workflow_done",
|
||||||
|
"status": wf.Status,
|
||||||
|
"workflow": wf,
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleWorkflowApprove(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := strings.TrimPrefix(r.URL.Path, "/api/workflow/approve/")
|
||||||
|
if id == "" {
|
||||||
|
writeError(w, "workflow id required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
StepID string `json:"step_id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
engine := s.workflowEngine
|
||||||
|
if engine == nil {
|
||||||
|
engine, _ = workflow.NewEngine(s.agentRegistry)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := engine.ApproveStep(id, body.StepID); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]string{"status": "approved"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateString(s string, max int) string {
|
||||||
|
runes := []rune(s)
|
||||||
|
if len(runes) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(runes[:max])
|
||||||
|
}
|
||||||
106
internal/api/image_cache.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
var imageDir string
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
dir, err := config.ConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
dir = "/tmp/muyue"
|
||||||
|
}
|
||||||
|
imageDir = filepath.Join(dir, "images")
|
||||||
|
os.MkdirAll(imageDir, 0755)
|
||||||
|
}
|
||||||
|
|
||||||
|
var imageCounter uint64
|
||||||
|
|
||||||
|
func saveImage(dataURI, filename, mimeType string) (string, error) {
|
||||||
|
parts := strings.SplitN(dataURI, ",", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return "", fmt.Errorf("invalid data URI")
|
||||||
|
}
|
||||||
|
encoded := parts[1]
|
||||||
|
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("base64 decode: %w", err)
|
||||||
|
}
|
||||||
|
if len(decoded) > 10*1024*1024 {
|
||||||
|
return "", fmt.Errorf("image too large (max 10MB)")
|
||||||
|
}
|
||||||
|
|
||||||
|
id := fmt.Sprintf("%d-%d", time.Now().UnixMilli(), atomic.AddUint64(&imageCounter, 1))
|
||||||
|
ext := ".png"
|
||||||
|
switch mimeType {
|
||||||
|
case "image/jpeg":
|
||||||
|
ext = ".jpg"
|
||||||
|
case "image/webp":
|
||||||
|
ext = ".webp"
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := filepath.Join(imageDir, id+ext)
|
||||||
|
if err := os.WriteFile(filePath, decoded, 0600); err != nil {
|
||||||
|
return "", fmt.Errorf("write image: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return id + ext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func imagePath(id string) string {
|
||||||
|
return filepath.Join(imageDir, filepath.Base(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanupImages(ids []string) {
|
||||||
|
for _, id := range ids {
|
||||||
|
p := imagePath(id)
|
||||||
|
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
|
||||||
|
_ = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleServeImage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" {
|
||||||
|
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := strings.TrimPrefix(r.URL.Path, "/api/images/")
|
||||||
|
if id == "" {
|
||||||
|
writeError(w, "image id required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := imagePath(id)
|
||||||
|
if _, err := os.Stat(filePath); err != nil {
|
||||||
|
writeError(w, "image not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(id))
|
||||||
|
switch ext {
|
||||||
|
case ".jpg", ".jpeg":
|
||||||
|
w.Header().Set("Content-Type", "image/jpeg")
|
||||||
|
case ".png":
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
case ".webp":
|
||||||
|
w.Header().Set("Content-Type", "image/webp")
|
||||||
|
default:
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||||
|
|
||||||
|
http.ServeFile(w, r, filePath)
|
||||||
|
}
|
||||||
@@ -1,27 +1,82 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/agent"
|
||||||
"github.com/muyue/muyue/internal/config"
|
"github.com/muyue/muyue/internal/config"
|
||||||
|
"github.com/muyue/muyue/internal/installer"
|
||||||
"github.com/muyue/muyue/internal/scanner"
|
"github.com/muyue/muyue/internal/scanner"
|
||||||
|
"github.com/muyue/muyue/internal/workflow"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
config *config.MuyueConfig
|
config *config.MuyueConfig
|
||||||
scanResult *scanner.ScanResult
|
scanResult *scanner.ScanResult
|
||||||
mux *http.ServeMux
|
mux *http.ServeMux
|
||||||
convStore *ConversationStore
|
convStore *ConversationStore
|
||||||
|
shellConvStore *ShellConvStore
|
||||||
|
consumption *consumptionStore
|
||||||
|
agentRegistry *agent.Registry
|
||||||
|
agentToolsJSON json.RawMessage
|
||||||
|
shellAgentRegistry *agent.Registry
|
||||||
|
shellAgentToolsJSON json.RawMessage
|
||||||
|
workflowEngine *workflow.Engine
|
||||||
|
browserTestStore *BrowserTestStore
|
||||||
|
activeCrushAgents atomic.Int32
|
||||||
|
activeClaudeAgents atomic.Int32
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
|
_ = 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.browserTestStore = NewBrowserTestStore()
|
||||||
|
if err := RegisterBrowserTestTool(s.agentRegistry, s.browserTestStore); err != nil {
|
||||||
|
// Tool registration only fails for duplicate names — non-fatal
|
||||||
|
_ = err
|
||||||
|
}
|
||||||
|
tools := s.agentRegistry.OpenAITools()
|
||||||
|
toolsJSON, _ := json.Marshal(tools)
|
||||||
|
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.initStarship()
|
||||||
s.routes()
|
s.routes()
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
@@ -38,28 +93,83 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("/api/updates", s.handleUpdates)
|
s.mux.HandleFunc("/api/updates", s.handleUpdates)
|
||||||
s.mux.HandleFunc("/api/install", s.handleInstall)
|
s.mux.HandleFunc("/api/install", s.handleInstall)
|
||||||
s.mux.HandleFunc("/api/scan", s.handleScan)
|
s.mux.HandleFunc("/api/scan", s.handleScan)
|
||||||
|
s.mux.HandleFunc("/api/editors", s.handleEditors)
|
||||||
s.mux.HandleFunc("/api/preferences", s.handleUpdatePreferences)
|
s.mux.HandleFunc("/api/preferences", s.handleUpdatePreferences)
|
||||||
s.mux.HandleFunc("/api/terminal", s.handleTerminal)
|
s.mux.HandleFunc("/api/terminal", s.handleTerminal)
|
||||||
s.mux.HandleFunc("/api/ws/terminal", s.handleTerminalWS)
|
s.mux.HandleFunc("/api/ws/terminal", s.handleTerminalWS)
|
||||||
s.mux.HandleFunc("/api/terminal/sessions", s.handleTerminalSessions)
|
s.mux.HandleFunc("/api/terminal/sessions", s.handleTerminalSessions)
|
||||||
s.mux.HandleFunc("/api/terminal/sessions/", s.handleTerminalSessionsDelete)
|
s.mux.HandleFunc("/api/terminal/sessions/", s.handleTerminalSessionsDelete)
|
||||||
|
s.mux.HandleFunc("/api/terminal/themes", s.handleGetTerminalThemes)
|
||||||
|
s.mux.HandleFunc("/api/terminal/settings", s.handleSaveTerminalSettings)
|
||||||
s.mux.HandleFunc("/api/mcp/configure", s.handleMCPConfigure)
|
s.mux.HandleFunc("/api/mcp/configure", s.handleMCPConfigure)
|
||||||
s.mux.HandleFunc("/api/config/profile", s.handleSaveProfile)
|
s.mux.HandleFunc("/api/config/profile", s.handleSaveProfile)
|
||||||
s.mux.HandleFunc("/api/config/provider", s.handleSaveProvider)
|
s.mux.HandleFunc("/api/config/provider", s.handleSaveProvider)
|
||||||
|
s.mux.HandleFunc("/api/config/reset", s.handleResetConfig)
|
||||||
|
s.mux.HandleFunc("/api/starship/apply-theme", s.handleApplyStarshipTheme)
|
||||||
|
s.mux.HandleFunc("/api/providers/validate", s.handleValidateProvider)
|
||||||
s.mux.HandleFunc("/api/update/run", s.handleRunUpdate)
|
s.mux.HandleFunc("/api/update/run", s.handleRunUpdate)
|
||||||
|
s.mux.HandleFunc("/api/images/", s.handleServeImage)
|
||||||
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/tools/list", s.handleToolList)
|
||||||
|
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/list", s.handleWorkflowList)
|
||||||
|
s.mux.HandleFunc("/api/workflow/", s.handleWorkflowGet)
|
||||||
|
s.mux.HandleFunc("/api/workflow/plan", s.handleWorkflowPlan)
|
||||||
|
s.mux.HandleFunc("/api/workflow/execute/", s.handleWorkflowExecute)
|
||||||
|
s.mux.HandleFunc("/api/workflow/approve/", s.handleWorkflowApprove)
|
||||||
|
s.mux.HandleFunc("/api/conversations", s.handleListConversations)
|
||||||
|
s.mux.HandleFunc("/api/conversations/search", s.handleSearchConversations)
|
||||||
|
s.mux.HandleFunc("/api/conversations/export", s.handleExportConversation)
|
||||||
|
s.mux.HandleFunc("/api/conversations/", s.handleDeleteConversation)
|
||||||
|
s.mux.HandleFunc("/api/lsp/install", s.handleLSPInstall)
|
||||||
|
s.mux.HandleFunc("/api/skills/deploy", s.handleSkillsDeploy)
|
||||||
|
s.mux.HandleFunc("/api/skills/undeploy", s.handleSkillsUndeploy)
|
||||||
|
s.mux.HandleFunc("/api/ssh/connections", s.handleSSHConnections)
|
||||||
|
s.mux.HandleFunc("/api/ssh/test", s.handleSSHTest)
|
||||||
|
|
||||||
|
s.mux.HandleFunc("/api/mcp/status", s.handleMCPStatus)
|
||||||
|
s.mux.HandleFunc("/api/mcp/registry", s.handleMCPRegistry)
|
||||||
|
s.mux.HandleFunc("/api/lsp/health", s.handleLSPHealth)
|
||||||
|
s.mux.HandleFunc("/api/lsp/auto-install", s.handleLSPAutoInstall)
|
||||||
|
s.mux.HandleFunc("/api/lsp/editor-config", s.handleLSPEditorConfig)
|
||||||
|
s.mux.HandleFunc("/api/skills/validate", s.handleSkillValidate)
|
||||||
|
s.mux.HandleFunc("/api/skills/test", s.handleSkillTest)
|
||||||
|
s.mux.HandleFunc("/api/skills/export", s.handleSkillExport)
|
||||||
|
s.mux.HandleFunc("/api/skills/import", s.handleSkillImport)
|
||||||
|
s.mux.HandleFunc("/api/dashboard/status", s.handleDashboardStatus)
|
||||||
|
s.mux.HandleFunc("/api/ai/task", s.handleAITask)
|
||||||
|
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)
|
||||||
|
|
||||||
|
s.mux.HandleFunc("/api/test/snippet", s.handleBrowserTestSnippet)
|
||||||
|
s.mux.HandleFunc("/api/test/sessions", s.handleBrowserTestSessions)
|
||||||
|
s.mux.HandleFunc("/api/test/console/", s.handleBrowserTestConsole)
|
||||||
|
s.mux.HandleFunc("/api/ws/browser-test", s.handleBrowserTestWS)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
if strings.HasPrefix(r.URL.Path, "/api/ws/") {
|
if strings.HasPrefix(r.URL.Path, "/api/ws/") || strings.HasPrefix(r.URL.Path, "/api/images/") {
|
||||||
s.mux.ServeHTTP(w, r)
|
s.mux.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
if origin := r.Header.Get("Origin"); isAllowedOrigin(origin) {
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||||
|
w.Header().Set("Vary", "Origin")
|
||||||
|
}
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
if r.Method == "OPTIONS" {
|
if r.Method == "OPTIONS" {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
@@ -67,3 +177,53 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
s.mux.ServeHTTP(w, r)
|
s.mux.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isAllowedOrigin(origin string) bool {
|
||||||
|
if origin == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(origin, "http://127.0.0.1"),
|
||||||
|
strings.HasPrefix(origin, "http://localhost"),
|
||||||
|
strings.HasPrefix(origin, "http://[::1]"),
|
||||||
|
strings.HasPrefix(origin, "https://127.0.0.1"),
|
||||||
|
strings.HasPrefix(origin, "https://localhost"),
|
||||||
|
strings.HasPrefix(origin, "https://[::1]"):
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxCrushAgents = 2
|
||||||
|
const maxClaudeAgents = 2
|
||||||
|
|
||||||
|
func (s *Server) AcquireAgentSlot(toolName string) (release func(), err error) {
|
||||||
|
var counter *atomic.Int32
|
||||||
|
var max int32
|
||||||
|
switch toolName {
|
||||||
|
case "crush_run":
|
||||||
|
counter = &s.activeCrushAgents
|
||||||
|
max = maxCrushAgents
|
||||||
|
case "claude_run":
|
||||||
|
counter = &s.activeClaudeAgents
|
||||||
|
max = maxClaudeAgents
|
||||||
|
default:
|
||||||
|
return func() {}, nil
|
||||||
|
}
|
||||||
|
current := counter.Add(1)
|
||||||
|
if current > max {
|
||||||
|
counter.Add(-1)
|
||||||
|
return nil, fmt.Errorf("Limite de %d agents %s atteinte", max, toolName)
|
||||||
|
}
|
||||||
|
return func() { counter.Add(-1) }, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) initStarship() {
|
||||||
|
if _, err := exec.LookPath("starship"); err != nil {
|
||||||
|
inst := installer.New(s.config)
|
||||||
|
if result := inst.InstallTool("starship"); !result.Success {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ApplyStarshipTheme(s.config.Terminal.PromptTheme)
|
||||||
|
}
|
||||||
|
|||||||
185
internal/api/shell_conversation.go
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ShellConvStore) ApproxTokens() int {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
total := 0
|
||||||
|
for _, m := range s.msgs {
|
||||||
|
if m.Role == "system" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total += utf8.RuneCountInString(extractDisplayContent(m.Role, m.Content)) / shellCharsPerToken
|
||||||
|
}
|
||||||
|
total += utf8.RuneCountInString(shellSystemPromptBase) / shellCharsPerToken
|
||||||
|
if analysis := LoadSystemAnalysis(); analysis != "" {
|
||||||
|
total += utf8.RuneCountInString(analysis) / shellCharsPerToken
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -3,22 +3,38 @@ package api
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/creack/pty/v2"
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
"github.com/muyue/muyue/internal/config"
|
"github.com/muyue/muyue/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
var upgrader = websocket.Upgrader{
|
var upgrader = websocket.Upgrader{
|
||||||
CheckOrigin: func(r *http.Request) bool { return true },
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
|
origin := r.Header.Get("Origin")
|
||||||
|
if origin == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(origin, "http://127.0.0.1"),
|
||||||
|
strings.HasPrefix(origin, "http://localhost"),
|
||||||
|
strings.HasPrefix(origin, "http://[::1]"),
|
||||||
|
strings.HasPrefix(origin, "https://127.0.0.1"),
|
||||||
|
strings.HasPrefix(origin, "https://localhost"),
|
||||||
|
strings.HasPrefix(origin, "https://[::1]"):
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
type wsMessage struct {
|
type wsMessage struct {
|
||||||
@@ -31,7 +47,6 @@ type wsMessage struct {
|
|||||||
func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||||
conn, err := upgrader.Upgrade(w, r, nil)
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("ws upgrade: %v", err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
@@ -51,10 +66,11 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
if initMsg.Type == "ssh" && initMsg.Data != "" {
|
if initMsg.Type == "ssh" && initMsg.Data != "" {
|
||||||
var sshConf struct {
|
var sshConf struct {
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
KeyPath string `json:"key_path"`
|
KeyPath string `json:"key_path"`
|
||||||
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal([]byte(initMsg.Data), &sshConf); err != nil {
|
if err := json.Unmarshal([]byte(initMsg.Data), &sshConf); err != nil {
|
||||||
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid ssh config"})
|
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid ssh config"})
|
||||||
@@ -77,63 +93,95 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
sshArgs = append(sshArgs, fmt.Sprintf("%s@%s", sshConf.User, sshConf.Host))
|
sshArgs = append(sshArgs, fmt.Sprintf("%s@%s", sshConf.User, sshConf.Host))
|
||||||
|
|
||||||
cmd = exec.Command("ssh", sshArgs...)
|
if sshConf.Password != "" {
|
||||||
|
sshpassPath, err := exec.LookPath("sshpass")
|
||||||
|
if err == nil {
|
||||||
|
args := append([]string{"-e"}, "ssh")
|
||||||
|
args = append(args, sshArgs...)
|
||||||
|
cmd = exec.Command(sshpassPath, args...)
|
||||||
|
cmd.Env = append(os.Environ(), "SSHPASS="+sshConf.Password)
|
||||||
|
} else {
|
||||||
|
cmd = exec.Command("ssh", sshArgs...)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cmd = exec.Command("ssh", sshArgs...)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
shell := initMsg.Data
|
shell := strings.TrimSpace(initMsg.Data)
|
||||||
if shell == "" {
|
if shell == "" {
|
||||||
shell = detectShell()
|
shell = detectShell()
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.Contains(shell, "wsl") {
|
if shell == "" {
|
||||||
cmd = exec.Command("wsl", "--shell-type", "login")
|
shell = "/bin/sh"
|
||||||
} else if strings.Contains(shell, "powershell") || strings.Contains(shell, "pwsh") {
|
}
|
||||||
cmd = exec.Command(shell, "-NoLogo", "-NoProfile")
|
|
||||||
|
// Support "wsl -d <distro>" shell strings sent from the UI quick-access.
|
||||||
|
if extra, ok := parseWSLShell(shell); ok {
|
||||||
|
wslPath, err := exec.LookPath("wsl")
|
||||||
|
if err != nil {
|
||||||
|
conn.WriteJSON(wsMessage{Type: "error", Data: "wsl not found on this host"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cmd = exec.Command(wslPath, extra...)
|
||||||
} else {
|
} else {
|
||||||
cmd = exec.Command(shell, "--login")
|
if path, err := exec.LookPath(shell); err == nil {
|
||||||
|
shell = path
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(shell); err != nil {
|
||||||
|
conn.WriteJSON(wsMessage{Type: "error", Data: fmt.Sprintf("shell not found: %s (resolved from: %q)", shell, initMsg.Data)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
shellName := filepath.Base(shell)
|
||||||
|
switch shellName {
|
||||||
|
case "wsl":
|
||||||
|
cmd = exec.Command(shell, "--shell-type", "login")
|
||||||
|
case "powershell", "pwsh":
|
||||||
|
cmd = exec.Command(shell, "-NoLogo", "-NoProfile")
|
||||||
|
case "fish":
|
||||||
|
cmd = exec.Command(shell, "--login")
|
||||||
|
default:
|
||||||
|
cmd = exec.Command(shell)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
|
if cmd.Env == nil {
|
||||||
|
cmd.Env = os.Environ()
|
||||||
|
}
|
||||||
|
cmd.Env = append(cmd.Env, "TERM=xterm-256color")
|
||||||
|
|
||||||
ptmx, err := pty.Start(cmd)
|
session, err := startTermSession(cmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("pty start: %v", err)
|
|
||||||
conn.WriteJSON(wsMessage{Type: "error", Data: err.Error()})
|
conn.WriteJSON(wsMessage{Type: "error", Data: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
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() {
|
||||||
once.Do(func() {
|
once.Do(func() {
|
||||||
ptmx.Close()
|
session.Close()
|
||||||
if cmd.Process != nil {
|
session.Wait()
|
||||||
cmd.Process.Kill()
|
|
||||||
cmd.Wait()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
buf := make([]byte, 4096)
|
buf := make([]byte, 4096)
|
||||||
for {
|
for {
|
||||||
n, err := ptmx.Read(buf)
|
n, err := session.Read(buf)
|
||||||
if err != nil {
|
if n > 0 {
|
||||||
cleanup()
|
if err := conn.WriteJSON(wsMessage{
|
||||||
conn.WriteMessage(websocket.CloseMessage,
|
Type: "output",
|
||||||
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
Data: string(buf[:n]),
|
||||||
return
|
}); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := conn.WriteJSON(wsMessage{
|
if err != nil {
|
||||||
Type: "output",
|
|
||||||
Data: string(buf[:n]),
|
|
||||||
}); err != nil {
|
|
||||||
cleanup()
|
cleanup()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -157,16 +205,13 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
switch msg.Type {
|
switch msg.Type {
|
||||||
case "input":
|
case "input":
|
||||||
if _, err := ptmx.Write([]byte(msg.Data)); err != nil {
|
if _, err := session.Write([]byte(msg.Data)); err != nil {
|
||||||
cleanup()
|
cleanup()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "resize":
|
case "resize":
|
||||||
if msg.Rows > 0 && msg.Cols > 0 {
|
if msg.Rows > 0 && msg.Cols > 0 {
|
||||||
pty.Setsize(ptmx, &pty.Winsize{
|
session.Resize(msg.Rows, msg.Cols)
|
||||||
Rows: msg.Rows,
|
|
||||||
Cols: msg.Cols,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,8 +219,15 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method == "GET" {
|
if r.Method == "GET" {
|
||||||
|
masked := make([]config.SSHConnection, len(s.config.Terminal.SSH))
|
||||||
|
for i, c := range s.config.Terminal.SSH {
|
||||||
|
masked[i] = c
|
||||||
|
if masked[i].Password != "" {
|
||||||
|
masked[i].Password = "***"
|
||||||
|
}
|
||||||
|
}
|
||||||
writeJSON(w, map[string]interface{}{
|
writeJSON(w, map[string]interface{}{
|
||||||
"ssh": s.config.Terminal.SSH,
|
"ssh": masked,
|
||||||
"system": detectSystemTerminals(),
|
"system": detectSystemTerminals(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -189,8 +241,8 @@ 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"`
|
||||||
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
writeError(w, err.Error(), http.StatusBadRequest)
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
@@ -204,12 +256,36 @@ func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request)
|
|||||||
body.Port = 22
|
body.Port = 22
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for i, c := range s.config.Terminal.SSH {
|
||||||
|
if c.Name == body.Name {
|
||||||
|
password := body.Password
|
||||||
|
if password == "***" {
|
||||||
|
password = c.Password
|
||||||
|
}
|
||||||
|
s.config.Terminal.SSH[i] = config.SSHConnection{
|
||||||
|
Name: body.Name,
|
||||||
|
Host: body.Host,
|
||||||
|
Port: body.Port,
|
||||||
|
User: body.User,
|
||||||
|
KeyPath: body.KeyPath,
|
||||||
|
Password: password,
|
||||||
|
}
|
||||||
|
if err := config.Save(s.config); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]string{"status": "ok"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
conn := config.SSHConnection{
|
conn := config.SSHConnection{
|
||||||
Name: body.Name,
|
Name: body.Name,
|
||||||
Host: body.Host,
|
Host: body.Host,
|
||||||
Port: body.Port,
|
Port: body.Port,
|
||||||
User: body.User,
|
User: body.User,
|
||||||
KeyPath: body.KeyPath,
|
KeyPath: body.KeyPath,
|
||||||
|
Password: body.Password,
|
||||||
}
|
}
|
||||||
if s.config.Terminal.SSH == nil {
|
if s.config.Terminal.SSH == nil {
|
||||||
s.config.Terminal.SSH = []config.SSHConnection{}
|
s.config.Terminal.SSH = []config.SSHConnection{}
|
||||||
@@ -223,6 +299,10 @@ func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleTerminalSessionsDelete(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleTerminalSessionsDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "DELETE" {
|
||||||
|
writeError(w, "DELETE only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
name := strings.TrimPrefix(r.URL.Path, "/api/terminal/sessions/")
|
name := strings.TrimPrefix(r.URL.Path, "/api/terminal/sessions/")
|
||||||
if name == "" {
|
if name == "" {
|
||||||
writeError(w, "name required", http.StatusBadRequest)
|
writeError(w, "name required", http.StatusBadRequest)
|
||||||
@@ -250,13 +330,94 @@ func (s *Server) handleTerminalSessionsDelete(w http.ResponseWriter, r *http.Req
|
|||||||
func detectShell() string {
|
func detectShell() string {
|
||||||
shells := []string{"zsh", "bash", "fish", "pwsh", "powershell"}
|
shells := []string{"zsh", "bash", "fish", "pwsh", "powershell"}
|
||||||
for _, s := range shells {
|
for _, s := range shells {
|
||||||
if _, err := exec.LookPath(s); err == nil {
|
if path, err := exec.LookPath(s); err == nil {
|
||||||
return s
|
return path
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "/bin/sh"
|
return "/bin/sh"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listWSLDistros returns the list of installed WSL distribution names.
|
||||||
|
// Windows hosts only — returns nil on other platforms or if WSL is unavailable.
|
||||||
|
func listWSLDistros() []string {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out, err := exec.Command("wsl", "--list", "--quiet").Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// `wsl --list --quiet` outputs UTF-16LE on Windows. Strip BOM and decode best-effort.
|
||||||
|
raw := stripUTF16ToASCII(out)
|
||||||
|
var distros []string
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, line := range strings.Split(raw, "\n") {
|
||||||
|
name := strings.TrimSpace(line)
|
||||||
|
if name == "" || seen[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Skip default-marker arrows or annotations.
|
||||||
|
name = strings.TrimSpace(strings.TrimPrefix(name, "*"))
|
||||||
|
if name == "" || !validWSLName.MatchString(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[name] = true
|
||||||
|
distros = append(distros, name)
|
||||||
|
}
|
||||||
|
return distros
|
||||||
|
}
|
||||||
|
|
||||||
|
var validWSLName = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
|
||||||
|
|
||||||
|
// parseWSLShell recognises strings of the form "wsl -d <distro>" (and optionally
|
||||||
|
// "-u <user>") emitted by the Shell tab quick-access menu, returning the args
|
||||||
|
// to pass to the wsl binary. Returns ok=false otherwise.
|
||||||
|
func parseWSLShell(shell string) ([]string, bool) {
|
||||||
|
parts := strings.Fields(shell)
|
||||||
|
if len(parts) < 3 || parts[0] != "wsl" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
args := []string{}
|
||||||
|
i := 1
|
||||||
|
for i < len(parts) {
|
||||||
|
switch parts[i] {
|
||||||
|
case "-d", "--distribution":
|
||||||
|
if i+1 >= len(parts) || !validWSLName.MatchString(parts[i+1]) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
args = append(args, "-d", parts[i+1])
|
||||||
|
i += 2
|
||||||
|
case "-u", "--user":
|
||||||
|
if i+1 >= len(parts) || !validWSLName.MatchString(parts[i+1]) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
args = append(args, "-u", parts[i+1])
|
||||||
|
i += 2
|
||||||
|
default:
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(args) == 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return args, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripUTF16ToASCII(b []byte) string {
|
||||||
|
// Best-effort: keep only printable bytes (drop high bytes from UTF-16LE pairs).
|
||||||
|
var out []byte
|
||||||
|
for i := 0; i < len(b); i++ {
|
||||||
|
c := b[i]
|
||||||
|
if c == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c >= 32 && c < 127 || c == '\n' || c == '\r' || c == '\t' {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
func detectSystemTerminals() []map[string]string {
|
func detectSystemTerminals() []map[string]string {
|
||||||
var terminals []map[string]string
|
var terminals []map[string]string
|
||||||
|
|
||||||
@@ -269,10 +430,17 @@ func detectSystemTerminals() []map[string]string {
|
|||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
if _, err := exec.LookPath("wsl"); err == nil {
|
if _, err := exec.LookPath("wsl"); err == nil {
|
||||||
terminals = append(terminals, map[string]string{
|
terminals = append(terminals, map[string]string{
|
||||||
"type": "local",
|
"type": "local",
|
||||||
"name": "WSL",
|
"name": "WSL (default)",
|
||||||
"shell": "wsl",
|
"shell": "wsl",
|
||||||
})
|
})
|
||||||
|
for _, distro := range listWSLDistros() {
|
||||||
|
terminals = append(terminals, map[string]string{
|
||||||
|
"type": "local",
|
||||||
|
"name": "WSL: " + distro,
|
||||||
|
"shell": "wsl -d " + distro,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if _, err := exec.LookPath("powershell"); err == nil {
|
if _, err := exec.LookPath("powershell"); err == nil {
|
||||||
terminals = append(terminals, map[string]string{
|
terminals = append(terminals, map[string]string{
|
||||||
|
|||||||
198
internal/api/terminal_session.go
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// Cross-platform terminal session abstraction.
|
||||||
|
//
|
||||||
|
// On Linux / macOS we have a real PTY via creack/pty: full TTY semantics,
|
||||||
|
// resize support, interactive apps (vim, top…) work. On Windows the same
|
||||||
|
// package returns "operating system not supported" at pty.Start time, so we
|
||||||
|
// fall back to plain pipes (stdin / stdout merged with stderr). Pipes don't
|
||||||
|
// give a real TTY — interactive TUIs misbehave — but `wsl`, `pwsh`, `cmd`,
|
||||||
|
// and most CLI tools emit usable line-buffered output, which is what the
|
||||||
|
// user actually clicks for.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/creack/pty/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// termSession is the read/write/resize/close surface used by handleTerminalWS.
|
||||||
|
type termSession interface {
|
||||||
|
Read([]byte) (int, error)
|
||||||
|
Write([]byte) (int, error)
|
||||||
|
Resize(rows, cols uint16) error
|
||||||
|
Close() error
|
||||||
|
Wait() error
|
||||||
|
Pid() int
|
||||||
|
}
|
||||||
|
|
||||||
|
// startTermSession tries a real PTY first; on Windows or any pty.Start failure
|
||||||
|
// it falls back to a pipe-based session.
|
||||||
|
func startTermSession(cmd *exec.Cmd) (termSession, error) {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
ptmx, err := pty.Start(cmd)
|
||||||
|
if err == nil {
|
||||||
|
return &ptySession{ptmx: ptmx, cmd: cmd}, nil
|
||||||
|
}
|
||||||
|
// On unix, a pty.Start error is fatal — pipes won't help interactive
|
||||||
|
// shells without a TTY, and the unix build is the supported path.
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return startPipeSession(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ptySession wraps creack/pty's *os.File-backed PTY.
|
||||||
|
type ptySession struct {
|
||||||
|
ptmx *os.File
|
||||||
|
cmd *exec.Cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ptySession) Read(p []byte) (int, error) { return s.ptmx.Read(p) }
|
||||||
|
func (s *ptySession) Write(p []byte) (int, error) { return s.ptmx.Write(p) }
|
||||||
|
func (s *ptySession) Resize(rows, cols uint16) error {
|
||||||
|
return pty.Setsize(s.ptmx, &pty.Winsize{Rows: rows, Cols: cols})
|
||||||
|
}
|
||||||
|
func (s *ptySession) Close() error {
|
||||||
|
err := s.ptmx.Close()
|
||||||
|
if s.cmd.Process != nil {
|
||||||
|
s.cmd.Process.Kill()
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
func (s *ptySession) Wait() error {
|
||||||
|
if s.cmd.Process == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.cmd.Wait()
|
||||||
|
}
|
||||||
|
func (s *ptySession) Pid() int {
|
||||||
|
if s.cmd.Process == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return s.cmd.Process.Pid
|
||||||
|
}
|
||||||
|
|
||||||
|
// pipeSession is the Windows fallback: stdin pipe + merged stdout/stderr pipe,
|
||||||
|
// running concurrently. Resize is a no-op (no TTY to send TIOCSWINSZ to).
|
||||||
|
type pipeSession struct {
|
||||||
|
cmd *exec.Cmd
|
||||||
|
stdin io.WriteCloser
|
||||||
|
stdout io.ReadCloser
|
||||||
|
stderr io.ReadCloser
|
||||||
|
mu sync.Mutex
|
||||||
|
merged chan []byte
|
||||||
|
closed bool
|
||||||
|
closeCh chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startPipeSession(cmd *exec.Cmd) (termSession, error) {
|
||||||
|
stdin, err := cmd.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
stdin.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stderr, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
stdin.Close()
|
||||||
|
stdout.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
stdin.Close()
|
||||||
|
stdout.Close()
|
||||||
|
stderr.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s := &pipeSession{
|
||||||
|
cmd: cmd,
|
||||||
|
stdin: stdin,
|
||||||
|
stdout: stdout,
|
||||||
|
stderr: stderr,
|
||||||
|
merged: make(chan []byte, 32),
|
||||||
|
closeCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
go s.pump(stdout)
|
||||||
|
go s.pump(stderr)
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *pipeSession) pump(r io.ReadCloser) {
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
n, err := r.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
chunk := make([]byte, n)
|
||||||
|
copy(chunk, buf[:n])
|
||||||
|
select {
|
||||||
|
case s.merged <- chunk:
|
||||||
|
case <-s.closeCh:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *pipeSession) Read(p []byte) (int, error) {
|
||||||
|
select {
|
||||||
|
case chunk, ok := <-s.merged:
|
||||||
|
if !ok {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
n := copy(p, chunk)
|
||||||
|
return n, nil
|
||||||
|
case <-s.closeCh:
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *pipeSession) Write(p []byte) (int, error) {
|
||||||
|
return s.stdin.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *pipeSession) Resize(rows, cols uint16) error {
|
||||||
|
// No real TTY → resize is a no-op; the child won't get SIGWINCH.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *pipeSession) Close() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.closed {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.closed = true
|
||||||
|
close(s.closeCh)
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.stdin.Close()
|
||||||
|
s.stdout.Close()
|
||||||
|
s.stderr.Close()
|
||||||
|
if s.cmd.Process != nil {
|
||||||
|
s.cmd.Process.Kill()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *pipeSession) Wait() error {
|
||||||
|
if s.cmd.Process == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.cmd.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *pipeSession) Pid() int {
|
||||||
|
if s.cmd.Process == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return s.cmd.Process.Pid
|
||||||
|
}
|
||||||
@@ -6,67 +6,148 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/muyue/muyue/internal/secret"
|
"github.com/muyue/muyue/internal/secret"
|
||||||
|
"github.com/muyue/muyue/internal/version"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
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"`
|
||||||
} `yaml:"terminal"`
|
FontSize int `yaml:"font_size" json:"font_size"`
|
||||||
|
FontFamily string `yaml:"font_family" json:"font_family"`
|
||||||
|
Theme string `yaml:"theme" json:"theme"`
|
||||||
|
} `yaml:"terminal" json:"terminal"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TerminalTheme struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Background string `yaml:"background"`
|
||||||
|
Foreground string `yaml:"foreground"`
|
||||||
|
Cursor string `yaml:"cursor"`
|
||||||
|
Black string `yaml:"black"`
|
||||||
|
Red string `yaml:"red"`
|
||||||
|
Green string `yaml:"green"`
|
||||||
|
Yellow string `yaml:"yellow"`
|
||||||
|
Blue string `yaml:"blue"`
|
||||||
|
Magenta string `yaml:"magenta"`
|
||||||
|
Cyan string `yaml:"cyan"`
|
||||||
|
White string `yaml:"white"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var DEFAULT_TERMINAL_THEMES = map[string]TerminalTheme{
|
||||||
|
"default": {
|
||||||
|
Name: "Default", Background: "#0A0A0C", Foreground: "#EAE0E2",
|
||||||
|
Cursor: "#FF0033", Black: "#0A0A0C", Red: "#FF0033",
|
||||||
|
Green: "#00E676", Yellow: "#FFD740", Blue: "#448AFF",
|
||||||
|
Magenta: "#FF1A5E", Cyan: "#00BCD4", White: "#EAE0E2",
|
||||||
|
},
|
||||||
|
"monokai": {
|
||||||
|
Name: "Monokai", Background: "#272822", Foreground: "#F8F8F2",
|
||||||
|
Cursor: "#F8F8F0", Black: "#272822", Red: "#F92672",
|
||||||
|
Green: "#A6E22E", Yellow: "#E6DB74", Blue: "#66D9EF",
|
||||||
|
Magenta: "#AE81FF", Cyan: "#A1EFE4", White: "#F8F8F2",
|
||||||
|
},
|
||||||
|
"gruvbox": {
|
||||||
|
Name: "Gruvbox", Background: "#282828", Foreground: "#EBDBB2",
|
||||||
|
Cursor: "#FB4934", Black: "#282828", Red: "#CC241D",
|
||||||
|
Green: "#98971A", Yellow: "#D79921", Blue: "#458588",
|
||||||
|
Magenta: "#B16286", Cyan: "#689D6A", White: "#EBDBB2",
|
||||||
|
},
|
||||||
|
"nord": {
|
||||||
|
Name: "Nord", Background: "#2E3440", Foreground: "#D8DEE9",
|
||||||
|
Cursor: "#D8DEE9", Black: "#2E3440", Red: "#BF616A",
|
||||||
|
Green: "#A3BE8C", Yellow: "#EBCB8B", Blue: "#81A1C1",
|
||||||
|
Magenta: "#B48EAD", Cyan: "#88C0D0", White: "#D8DEE9",
|
||||||
|
},
|
||||||
|
"solarized-dark": {
|
||||||
|
Name: "Solarized Dark", Background: "#002B36", Foreground: "#839496",
|
||||||
|
Cursor: "#D33682", Black: "#002B36", Red: "#DC322F",
|
||||||
|
Green: "#859900", Yellow: "#B58900", Blue: "#268BD2",
|
||||||
|
Magenta: "#D33682", Cyan: "#2AA198", White: "#FDF6E3",
|
||||||
|
},
|
||||||
|
"dracula": {
|
||||||
|
Name: "Dracula", Background: "#282A36", Foreground: "#F8F8F2",
|
||||||
|
Cursor: "#F8F8F2", Black: "#282A36", Red: "#FF5555",
|
||||||
|
Green: "#50FA7B", Yellow: "#F1FA8C", Blue: "#BD93F9",
|
||||||
|
Magenta: "#FF79C6", Cyan: "#8BE9FD", White: "#F8F8F2",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
if theme, ok := DEFAULT_TERMINAL_THEMES[name]; ok {
|
||||||
|
return theme
|
||||||
|
}
|
||||||
|
return DEFAULT_TERMINAL_THEMES["default"]
|
||||||
}
|
}
|
||||||
|
|
||||||
func ConfigDir() (string, error) {
|
func ConfigDir() (string, error) {
|
||||||
@@ -79,7 +160,9 @@ func ConfigDir() (string, error) {
|
|||||||
legacyDir := filepath.Join(homeDir(), ".muyue")
|
legacyDir := filepath.Join(homeDir(), ".muyue")
|
||||||
if _, err := os.Stat(legacyDir); err == nil {
|
if _, err := os.Stat(legacyDir); err == nil {
|
||||||
if _, err := os.Stat(dir); err != nil {
|
if _, err := os.Stat(dir); err != nil {
|
||||||
os.Rename(legacyDir, dir)
|
if err := os.Rename(legacyDir, dir); err != nil {
|
||||||
|
_ = err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +221,8 @@ func Load() (*MuyueConfig, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
migrateProviders(&cfg)
|
||||||
|
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +264,7 @@ func Save(cfg *MuyueConfig) error {
|
|||||||
|
|
||||||
func Default() *MuyueConfig {
|
func Default() *MuyueConfig {
|
||||||
cfg := &MuyueConfig{
|
cfg := &MuyueConfig{
|
||||||
Version: "0.1.0",
|
Version: version.Version,
|
||||||
Profile: Profile{
|
Profile: Profile{
|
||||||
Name: "",
|
Name: "",
|
||||||
Pseudo: "muyue",
|
Pseudo: "muyue",
|
||||||
@@ -201,6 +286,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",
|
||||||
@@ -229,6 +320,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDefault(t *testing.T) {
|
func TestDefault(t *testing.T) {
|
||||||
cfg := Default()
|
cfg := Default()
|
||||||
if cfg.Version != "0.1.0" {
|
if cfg.Version != version.Version {
|
||||||
t.Errorf("Expected version 0.1.0, got %s", cfg.Version)
|
t.Errorf("Expected version %s, got %s", version.Version, cfg.Version)
|
||||||
}
|
}
|
||||||
if cfg.Profile.Pseudo != "muyue" {
|
if cfg.Profile.Pseudo != "muyue" {
|
||||||
t.Errorf("Expected pseudo muyue, got %s", cfg.Profile.Pseudo)
|
t.Errorf("Expected pseudo muyue, got %s", cfg.Profile.Pseudo)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -123,7 +124,7 @@ func (i *Installer) installBMAD() InstallResult {
|
|||||||
return InstallResult{Tool: "bmad", Success: false, Message: err.Error()}
|
return InstallResult{Tool: "bmad", Success: false, Message: err.Error()}
|
||||||
}
|
}
|
||||||
|
|
||||||
bmadDir := configDir + "/bmad"
|
bmadDir := filepath.Join(configDir, "bmad")
|
||||||
os.MkdirAll(bmadDir, 0755)
|
os.MkdirAll(bmadDir, 0755)
|
||||||
|
|
||||||
cmd := exec.Command("npx", "bmad-method@latest", "install",
|
cmd := exec.Command("npx", "bmad-method@latest", "install",
|
||||||
@@ -175,7 +176,7 @@ func (i *Installer) installGo() InstallResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
goDir := home + "/.local/go"
|
goDir := filepath.Join(home, ".local", "go")
|
||||||
|
|
||||||
cmd := exec.Command("bash", "-c", fmt.Sprintf(
|
cmd := exec.Command("bash", "-c", fmt.Sprintf(
|
||||||
"curl -sL https://go.dev/dl/go1.24.3.%s-%s.tar.gz | tar -C %s/.local -xzf -",
|
"curl -sL https://go.dev/dl/go1.24.3.%s-%s.tar.gz | tar -C %s/.local -xzf -",
|
||||||
@@ -291,15 +292,15 @@ func (i *Installer) installGit() InstallResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func (i *Installer) getRCFile() string {
|
func (i *Installer) getRCFile() string {
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
switch i.system.Shell {
|
switch i.system.Shell {
|
||||||
case "zsh":
|
case "zsh":
|
||||||
return home + "/.zshrc"
|
return filepath.Join(home, ".zshrc")
|
||||||
case "fish":
|
case "fish":
|
||||||
return home + "/.config/fish/config.fish"
|
return filepath.Join(home, ".config", "fish", "config.fish")
|
||||||
default:
|
default:
|
||||||
return home + "/.bashrc"
|
return filepath.Join(home, ".bashrc")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,7 +341,7 @@ func (i *Installer) installUv() InstallResult {
|
|||||||
return InstallResult{Tool: "uv", Success: false, Message: fmt.Sprintf("install failed: %s: %s", err, string(output))}
|
return InstallResult{Tool: "uv", Success: false, Message: fmt.Sprintf("install failed: %s: %s", err, string(output))}
|
||||||
}
|
}
|
||||||
rcFile := i.getRCFile()
|
rcFile := i.getRCFile()
|
||||||
appendLine(rcFile, "export PATH="+home+"/.local/bin:$PATH")
|
appendLine(rcFile, "export PATH="+filepath.Join(home, ".local", "bin")+":$PATH")
|
||||||
return InstallResult{Tool: "uv", Success: true, Message: "installed (added ~/.local/bin to PATH)"}
|
return InstallResult{Tool: "uv", Success: true, Message: "installed (added ~/.local/bin to PATH)"}
|
||||||
case platform.Windows:
|
case platform.Windows:
|
||||||
cmd = exec.Command("powershell", "-Command", "irm https://astral.sh/uv/install.ps1 | iex")
|
cmd = exec.Command("powershell", "-Command", "irm https://astral.sh/uv/install.ps1 | iex")
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
package lsp
|
package lsp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type LSPServer struct {
|
type LSPServer struct {
|
||||||
@@ -12,6 +16,10 @@ type LSPServer struct {
|
|||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
InstallCmd string `json:"install_cmd"`
|
InstallCmd string `json:"install_cmd"`
|
||||||
Installed bool `json:"installed"`
|
Installed bool `json:"installed"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
Healthy bool `json:"healthy,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Category string `json:"category,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var knownServers = []LSPServer{
|
var knownServers = []LSPServer{
|
||||||
@@ -39,27 +47,131 @@ func ScanServers() []LSPServer {
|
|||||||
servers[i] = s
|
servers[i] = s
|
||||||
_, err := exec.LookPath(s.Command)
|
_, err := exec.LookPath(s.Command)
|
||||||
servers[i].Installed = err == nil
|
servers[i].Installed = err == nil
|
||||||
|
servers[i].Version = getInstalledLSPVersion(s.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
regServers, err := scanLSPRegistryServers()
|
||||||
|
if err == nil {
|
||||||
|
servers = append(servers, regServers...)
|
||||||
|
}
|
||||||
|
|
||||||
return servers
|
return servers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func scanLSPRegistryServers() ([]LSPServer, error) {
|
||||||
|
reg, err := LoadLSPRegistry()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
knownNames := map[string]bool{}
|
||||||
|
for _, s := range knownServers {
|
||||||
|
knownNames[s.Name] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var servers []LSPServer
|
||||||
|
for _, rs := range reg.Servers {
|
||||||
|
if knownNames[rs.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
servers = append(servers, LSPServer{
|
||||||
|
Name: rs.Name,
|
||||||
|
Language: rs.Language,
|
||||||
|
Command: rs.Command,
|
||||||
|
InstallCmd: rs.InstallCmd,
|
||||||
|
Installed: isLSPCommandAvailable(rs.Command),
|
||||||
|
Description: rs.Description,
|
||||||
|
Category: rs.Category,
|
||||||
|
Version: getInstalledLSPVersion(rs.Name),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return servers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLSPCommandAvailable(cmd string) bool {
|
||||||
|
_, err := exec.LookPath(cmd)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getInstalledLSPVersion(name string) string {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
if home == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
receiptPath := filepath.Join(home, ".muyue", "receipts", "lsp", name+".json")
|
||||||
|
data, err := os.ReadFile(receiptPath)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var receipt struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(data, &receipt) == nil {
|
||||||
|
return receipt.Version
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveLSPReceipt(name, version string) error {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
if home == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
receiptDir := filepath.Join(home, ".muyue", "receipts", "lsp")
|
||||||
|
os.MkdirAll(receiptDir, 0755)
|
||||||
|
|
||||||
|
receipt := struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}{
|
||||||
|
Name: name,
|
||||||
|
Version: version,
|
||||||
|
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.MarshalIndent(receipt, "", " ")
|
||||||
|
return os.WriteFile(filepath.Join(receiptDir, name+".json"), data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
func InstallServer(name string) error {
|
func InstallServer(name string) error {
|
||||||
for _, s := range knownServers {
|
for _, s := range knownServers {
|
||||||
if s.Name == name {
|
if s.Name == name {
|
||||||
if s.InstallCmd == "" {
|
return doInstallLSP(s)
|
||||||
return fmt.Errorf("%s has no auto-install command, install manually", name)
|
|
||||||
}
|
|
||||||
cmd := exec.Command("bash", "-c", s.InstallCmd)
|
|
||||||
cmd.Env = os.Environ()
|
|
||||||
if output, err := cmd.CombinedOutput(); err != nil {
|
|
||||||
return fmt.Errorf("install %s: %s: %w", name, string(output), err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reg, err := LoadLSPRegistry()
|
||||||
|
if err == nil {
|
||||||
|
for _, s := range reg.Servers {
|
||||||
|
if s.Name == name {
|
||||||
|
return doInstallLSP(LSPServer{
|
||||||
|
Name: s.Name,
|
||||||
|
Language: s.Language,
|
||||||
|
Command: s.Command,
|
||||||
|
InstallCmd: s.InstallCmd,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return fmt.Errorf("unknown LSP server: %s", name)
|
return fmt.Errorf("unknown LSP server: %s", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func doInstallLSP(s LSPServer) error {
|
||||||
|
if s.InstallCmd == "" {
|
||||||
|
return fmt.Errorf("%s has no auto-install command, install manually", s.Name)
|
||||||
|
}
|
||||||
|
cmd := exec.Command("bash", "-c", s.InstallCmd)
|
||||||
|
cmd.Env = os.Environ()
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("install %s: %s: %w", s.Name, string(output), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
saveLSPReceipt(s.Name, "latest")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func InstallForLanguages(languages []string) []LSPServer {
|
func InstallForLanguages(languages []string) []LSPServer {
|
||||||
langMap := map[string][]string{
|
langMap := map[string][]string{
|
||||||
"go": {"gopls"},
|
"go": {"gopls"},
|
||||||
@@ -102,4 +214,99 @@ func InstallForLanguages(languages []string) []LSPServer {
|
|||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func AutoInstallForProject(projectDir string) ([]LSPServer, error) {
|
||||||
|
languages := DetectProjectLanguages(projectDir)
|
||||||
|
if len(languages) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
results := InstallForLanguages(languages)
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func HealthCheck(name string) (bool, string) {
|
||||||
|
for _, s := range knownServers {
|
||||||
|
if s.Name == name {
|
||||||
|
return healthCheckServer(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, "unknown server"
|
||||||
|
}
|
||||||
|
|
||||||
|
func healthCheckServer(s LSPServer) (bool, string) {
|
||||||
|
path, err := exec.LookPath(s.Command)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Sprintf("command %q not found in PATH", s.Command)
|
||||||
|
}
|
||||||
|
|
||||||
|
versionArgs := map[string][]string{
|
||||||
|
"gopls": {"version"},
|
||||||
|
"pyright": {"--version"},
|
||||||
|
"typescript-language-server": {"--version"},
|
||||||
|
"rust-analyzer": {"--version"},
|
||||||
|
"clangd": {"--version"},
|
||||||
|
"lua-language-server": {"--version"},
|
||||||
|
"bash-language-server": {"--version"},
|
||||||
|
"yaml-language-server": {"--version"},
|
||||||
|
}
|
||||||
|
|
||||||
|
if args, ok := versionArgs[s.Command]; ok {
|
||||||
|
cmd := exec.Command(path, args...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return true, fmt.Sprintf("installed at %s but version check failed", path)
|
||||||
|
}
|
||||||
|
version := strings.TrimSpace(string(output))
|
||||||
|
if idx := strings.Index(version, "\n"); idx > 0 {
|
||||||
|
version = version[:idx]
|
||||||
|
}
|
||||||
|
saveLSPReceipt(s.Name, version)
|
||||||
|
return true, version
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, fmt.Sprintf("installed at %s", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateEditorConfigs(servers []LSPServer, editor string, homeDir string) (string, error) {
|
||||||
|
if homeDir == "" {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
homeDir = home
|
||||||
|
}
|
||||||
|
|
||||||
|
reg, err := LoadLSPRegistry()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
regMap := map[string]RegistryEntry{}
|
||||||
|
for _, s := range reg.Servers {
|
||||||
|
regMap[s.Name] = s
|
||||||
|
}
|
||||||
|
|
||||||
|
var regEntries []RegistryEntry
|
||||||
|
for _, s := range servers {
|
||||||
|
if re, ok := regMap[s.Name]; ok {
|
||||||
|
regEntries = append(regEntries, re)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch editor {
|
||||||
|
case "neovim", "nvim":
|
||||||
|
return GenerateNeovimConfig(regEntries), nil
|
||||||
|
case "helix", "hx":
|
||||||
|
return GenerateHelixConfig(regEntries), nil
|
||||||
|
case "vscode", "code", "cursor":
|
||||||
|
exts := GenerateVSCodeRecommendations(regEntries)
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("{\n \"recommendations\": [\n")
|
||||||
|
for i, ext := range exts {
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteString(",\n")
|
||||||
|
}
|
||||||
|
b.WriteString(" \"" + ext + "\"")
|
||||||
|
}
|
||||||
|
b.WriteString("\n ]\n}")
|
||||||
|
return b.String(), nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported editor: %s", editor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
333
internal/lsp/registry.go
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
package lsp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RegistryEntry struct {
|
||||||
|
Name string `yaml:"name" json:"name"`
|
||||||
|
Language string `yaml:"language" json:"language"`
|
||||||
|
Description string `yaml:"description" json:"description"`
|
||||||
|
Command string `yaml:"command" json:"command"`
|
||||||
|
InstallCmd string `yaml:"install_cmd" json:"install_cmd"`
|
||||||
|
InstallType string `yaml:"install_type" json:"install_type"`
|
||||||
|
Category string `yaml:"category" json:"category"`
|
||||||
|
FilePatterns []string `yaml:"file_patterns,omitempty" json:"file_patterns,omitempty"`
|
||||||
|
ConfigFiles []string `yaml:"config_files,omitempty" json:"config_files,omitempty"`
|
||||||
|
Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"`
|
||||||
|
HomePage string `yaml:"homepage,omitempty" json:"homepage,omitempty"`
|
||||||
|
|
||||||
|
NeovimSetup string `yaml:"neovim_setup,omitempty" json:"neovim_setup,omitempty"`
|
||||||
|
HelixLanguage string `yaml:"helix_language,omitempty" json:"helix_language,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LSPRegistry struct {
|
||||||
|
SchemaVersion string `yaml:"schema_version"`
|
||||||
|
UpdatedAt time.Time `yaml:"updated_at"`
|
||||||
|
Servers []RegistryEntry `yaml:"servers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultLSPRegistry() *LSPRegistry {
|
||||||
|
return &LSPRegistry{
|
||||||
|
SchemaVersion: "v1",
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
Servers: []RegistryEntry{
|
||||||
|
{
|
||||||
|
Name: "gopls", Language: "go", Description: "Go language server",
|
||||||
|
Command: "gopls", InstallCmd: "go install golang.org/x/tools/gopls@latest",
|
||||||
|
InstallType: "go", Category: "lsp", FilePatterns: []string{"*.go"},
|
||||||
|
ConfigFiles: []string{"go.mod"}, Tags: []string{"go", "linting", "completion"},
|
||||||
|
HomePage: "https://github.com/golang/tools",
|
||||||
|
NeovimSetup: `lspconfig.gopls.setup{}`,
|
||||||
|
HelixLanguage: "go",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "pyright", Language: "python", Description: "Python type checker and language server",
|
||||||
|
Command: "pyright", InstallCmd: "npm install -g pyright",
|
||||||
|
InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.py", "*.pyi"},
|
||||||
|
ConfigFiles: []string{"requirements.txt", "pyproject.toml", "setup.py", "Pipfile"},
|
||||||
|
Tags: []string{"python", "type-checking"}, HomePage: "https://github.com/microsoft/pyright",
|
||||||
|
NeovimSetup: `lspconfig.pyright.setup{}`,
|
||||||
|
HelixLanguage: "python",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "typescript-language-server", Language: "typescript", Description: "TypeScript and JavaScript language server",
|
||||||
|
Command: "typescript-language-server", InstallCmd: "npm install -g typescript-language-server typescript",
|
||||||
|
InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.ts", "*.tsx", "*.js", "*.jsx"},
|
||||||
|
ConfigFiles: []string{"tsconfig.json", "package.json"},
|
||||||
|
Tags: []string{"typescript", "javascript"}, HomePage: "https://github.com/typescript-language-server/typescript-language-server",
|
||||||
|
NeovimSetup: `lspconfig.tsserver.setup{}`,
|
||||||
|
HelixLanguage: "typescript",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "vscode-json-language-server", Language: "json", Description: "JSON language server",
|
||||||
|
Command: "vscode-json-language-server", InstallCmd: "npm install -g vscode-langservers-extracted",
|
||||||
|
InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.json", "*.jsonc"},
|
||||||
|
Tags: []string{"json"}, NeovimSetup: `lspconfig.jsonls.setup{}`,
|
||||||
|
HelixLanguage: "json",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "vscode-html-language-server", Language: "html", Description: "HTML language server",
|
||||||
|
Command: "vscode-html-language-server", InstallCmd: "npm install -g vscode-langservers-extracted",
|
||||||
|
InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.html", "*.htm"},
|
||||||
|
Tags: []string{"html"}, NeovimSetup: `lspconfig.html.setup{}`,
|
||||||
|
HelixLanguage: "html",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "vscode-css-language-server", Language: "css", Description: "CSS/SCSS/LESS language server",
|
||||||
|
Command: "vscode-css-language-server", InstallCmd: "npm install -g vscode-langservers-extracted",
|
||||||
|
InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.css", "*.scss", "*.less"},
|
||||||
|
Tags: []string{"css"}, NeovimSetup: `lspconfig.cssls.setup{}`,
|
||||||
|
HelixLanguage: "css",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "yaml-language-server", Language: "yaml", Description: "YAML language server",
|
||||||
|
Command: "yaml-language-server", InstallCmd: "npm install -g yaml-language-server",
|
||||||
|
InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.yml", "*.yaml"},
|
||||||
|
Tags: []string{"yaml"}, NeovimSetup: `lspconfig.yamlls.setup{}`,
|
||||||
|
HelixLanguage: "yaml",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "bash-language-server", Language: "bash", Description: "Bash language server",
|
||||||
|
Command: "bash-language-server", InstallCmd: "npm install -g bash-language-server",
|
||||||
|
InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.sh", "*.bash"},
|
||||||
|
Tags: []string{"bash", "shell"}, NeovimSetup: `lspconfig.bashls.setup{}`,
|
||||||
|
HelixLanguage: "bash",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "rust-analyzer", Language: "rust", Description: "Rust language server",
|
||||||
|
Command: "rust-analyzer", InstallCmd: "rustup component add rust-analyzer",
|
||||||
|
InstallType: "rustup", Category: "lsp", FilePatterns: []string{"*.rs"},
|
||||||
|
ConfigFiles: []string{"Cargo.toml"}, Tags: []string{"rust"},
|
||||||
|
HomePage: "https://github.com/rust-lang/rust-analyzer",
|
||||||
|
NeovimSetup: `lspconfig.rust_analyzer.setup{}`,
|
||||||
|
HelixLanguage: "rust",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "clangd", Language: "c/c++", Description: "C/C++ language server",
|
||||||
|
Command: "clangd", InstallCmd: "", InstallType: "system",
|
||||||
|
Category: "lsp", FilePatterns: []string{"*.c", "*.cpp", "*.h", "*.hpp"},
|
||||||
|
ConfigFiles: []string{"CMakeLists.txt", "Makefile"}, Tags: []string{"c", "cpp"},
|
||||||
|
NeovimSetup: `lspconfig.clangd.setup{}`,
|
||||||
|
HelixLanguage: "c",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "lua-language-server", Language: "lua", Description: "Lua language server",
|
||||||
|
Command: "lua-language-server", InstallCmd: "npm install -g lua-language-server",
|
||||||
|
InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.lua"},
|
||||||
|
Tags: []string{"lua"}, NeovimSetup: `lspconfig.lua_ls.setup{}`,
|
||||||
|
HelixLanguage: "lua",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "dockerfile-language-server", Language: "dockerfile", Description: "Dockerfile language server",
|
||||||
|
Command: "docker-langserver", InstallCmd: "npm install -g dockerfile-language-server-nodejs",
|
||||||
|
InstallType: "npm", Category: "lsp", FilePatterns: []string{"Dockerfile", "Dockerfile.*"},
|
||||||
|
Tags: []string{"docker"}, NeovimSetup: `lspconfig.dockerls.setup{}`,
|
||||||
|
HelixLanguage: "dockerfile",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "tailwindcss-language-server", Language: "tailwind", Description: "Tailwind CSS language server",
|
||||||
|
Command: "tailwindcss-language-server", InstallCmd: "npm install -g @tailwindcss/language-server",
|
||||||
|
InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.html", "*.tsx", "*.jsx"},
|
||||||
|
ConfigFiles: []string{"tailwind.config.js", "tailwind.config.ts"},
|
||||||
|
Tags: []string{"tailwind", "css"}, NeovimSetup: `lspconfig.tailwindcss.setup{}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "svelte-language-server", Language: "svelte", Description: "Svelte language server",
|
||||||
|
Command: "svelteserver", InstallCmd: "npm install -g svelte-language-server",
|
||||||
|
InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.svelte"},
|
||||||
|
Tags: []string{"svelte"}, NeovimSetup: `lspconfig.svelte.setup{}`,
|
||||||
|
HelixLanguage: "svelte",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "vue-language-server", Language: "vue", Description: "Vue language server",
|
||||||
|
Command: "vue-language-server", InstallCmd: "npm install -g @vue/language-server",
|
||||||
|
InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.vue"},
|
||||||
|
Tags: []string{"vue"}, NeovimSetup: `lspconfig.vuels.setup{}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "golangci-lint-langserver", Language: "go-lint", Description: "Go linter language server",
|
||||||
|
Command: "golangci-lint-langserver", InstallCmd: "go install github.com/nametake/golangci-lint-langserver@latest",
|
||||||
|
InstallType: "go", Category: "lsp", FilePatterns: []string{"*.go"},
|
||||||
|
Tags: []string{"go", "linting"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var lspRegistryPath string
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
if home != "" {
|
||||||
|
lspRegistryPath = filepath.Join(home, ".muyue", "lsp-registry.yaml")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetLSPRegistryPath(p string) {
|
||||||
|
lspRegistryPath = p
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadLSPRegistry() (*LSPRegistry, error) {
|
||||||
|
if lspRegistryPath == "" {
|
||||||
|
return DefaultLSPRegistry(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(lspRegistryPath)
|
||||||
|
if err != nil {
|
||||||
|
return DefaultLSPRegistry(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var reg LSPRegistry
|
||||||
|
if err := yaml.Unmarshal(data, ®); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ®, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveLSPRegistry(reg *LSPRegistry) error {
|
||||||
|
if lspRegistryPath == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
reg.UpdatedAt = time.Now()
|
||||||
|
data, err := yaml.Marshal(reg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
os.MkdirAll(filepath.Dir(lspRegistryPath), 0755)
|
||||||
|
return os.WriteFile(lspRegistryPath, data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func InitLSPRegistry() error {
|
||||||
|
if lspRegistryPath == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(lspRegistryPath); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return SaveLSPRegistry(DefaultLSPRegistry())
|
||||||
|
}
|
||||||
|
|
||||||
|
func DetectProjectLanguages(projectDir string) []string {
|
||||||
|
if projectDir == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
langDetectors := map[string][]string{
|
||||||
|
"go": {"go.mod", "go.sum"},
|
||||||
|
"python": {"requirements.txt", "pyproject.toml", "setup.py", "Pipfile", "uv.lock"},
|
||||||
|
"typescript": {"tsconfig.json", "package.json"},
|
||||||
|
"javascript": {"package.json"},
|
||||||
|
"rust": {"Cargo.toml"},
|
||||||
|
"ruby": {"Gemfile"},
|
||||||
|
"java": {"pom.xml", "build.gradle"},
|
||||||
|
"c": {"CMakeLists.txt", "Makefile"},
|
||||||
|
"cpp": {"CMakeLists.txt"},
|
||||||
|
"php": {"composer.json"},
|
||||||
|
"lua": {".luarc.json"},
|
||||||
|
"docker": {"Dockerfile"},
|
||||||
|
}
|
||||||
|
|
||||||
|
extDetectors := map[string]string{
|
||||||
|
".go": "go",
|
||||||
|
".py": "python",
|
||||||
|
".rs": "rust",
|
||||||
|
".ts": "typescript",
|
||||||
|
".tsx": "typescript",
|
||||||
|
".js": "javascript",
|
||||||
|
".jsx": "javascript",
|
||||||
|
".rb": "ruby",
|
||||||
|
".java": "java",
|
||||||
|
".c": "c",
|
||||||
|
".cpp": "cpp",
|
||||||
|
".h": "c",
|
||||||
|
".lua": "lua",
|
||||||
|
".vue": "vue",
|
||||||
|
".svelte": "svelte",
|
||||||
|
}
|
||||||
|
|
||||||
|
detected := map[string]bool{}
|
||||||
|
for lang, files := range langDetectors {
|
||||||
|
for _, f := range files {
|
||||||
|
if _, err := os.Stat(filepath.Join(projectDir, f)); err == nil {
|
||||||
|
detected[lang] = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(projectDir)
|
||||||
|
if err == nil {
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ext := filepath.Ext(e.Name())
|
||||||
|
if lang, ok := extDetectors[ext]; ok {
|
||||||
|
detected[lang] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var languages []string
|
||||||
|
for lang := range detected {
|
||||||
|
languages = append(languages, lang)
|
||||||
|
}
|
||||||
|
return languages
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateNeovimConfig(servers []RegistryEntry) string {
|
||||||
|
config := `-- Generated by Muyue LSP Manager
|
||||||
|
-- Add to your init.lua or require from lspconfig setup
|
||||||
|
local lspconfig = require('lspconfig')
|
||||||
|
|
||||||
|
`
|
||||||
|
for _, s := range servers {
|
||||||
|
if s.NeovimSetup != "" {
|
||||||
|
config += s.NeovimSetup + "\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateHelixConfig(servers []RegistryEntry) string {
|
||||||
|
config := `# Generated by Muyue LSP Manager
|
||||||
|
# Add to ~/.config/helix/languages.toml
|
||||||
|
|
||||||
|
`
|
||||||
|
for _, s := range servers {
|
||||||
|
if s.HelixLanguage != "" {
|
||||||
|
config += "[[language]]\n"
|
||||||
|
config += "name = \"" + s.HelixLanguage + "\"\n"
|
||||||
|
config += "language-servers = [\"" + s.Name + "\"]\n\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateVSCodeRecommendations(servers []RegistryEntry) []string {
|
||||||
|
extensionMap := map[string][]string{
|
||||||
|
"gopls": {"golang.go"},
|
||||||
|
"pyright": {"ms-python.python", "ms-python.vscode-pylance"},
|
||||||
|
"typescript-language-server": {"ms-vscode.vscode-typescript-next"},
|
||||||
|
"rust-analyzer": {"rust-lang.rust-analyzer"},
|
||||||
|
"lua-language-server": {"sumneko.lua"},
|
||||||
|
"tailwindcss-language-server": {"bradlc.vscode-tailwindcss"},
|
||||||
|
"svelte-language-server": {"svelte.svelte-vscode"},
|
||||||
|
"vue-language-server": {"vue.volar"},
|
||||||
|
"yaml-language-server": {"redhat.vscode-yaml"},
|
||||||
|
"bash-language-server": {"mads-hartmann.bash-ide-vscode"},
|
||||||
|
}
|
||||||
|
|
||||||
|
var extensions []string
|
||||||
|
for _, s := range servers {
|
||||||
|
if exts, ok := extensionMap[s.Name]; ok {
|
||||||
|
extensions = append(extensions, exts...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return extensions
|
||||||
|
}
|
||||||
142
internal/lsp/registry_test.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package lsp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDefaultLSPRegistry(t *testing.T) {
|
||||||
|
reg := DefaultLSPRegistry()
|
||||||
|
if reg.SchemaVersion != "v1" {
|
||||||
|
t.Errorf("Expected v1, got %s", reg.SchemaVersion)
|
||||||
|
}
|
||||||
|
if len(reg.Servers) == 0 {
|
||||||
|
t.Error("Default LSP registry should have servers")
|
||||||
|
}
|
||||||
|
|
||||||
|
names := map[string]bool{}
|
||||||
|
for _, s := range reg.Servers {
|
||||||
|
if names[s.Name] {
|
||||||
|
t.Errorf("Duplicate server name: %s", s.Name)
|
||||||
|
}
|
||||||
|
names[s.Name] = true
|
||||||
|
if s.Command == "" {
|
||||||
|
t.Errorf("Server %s missing command", s.Name)
|
||||||
|
}
|
||||||
|
if s.Language == "" {
|
||||||
|
t.Errorf("Server %s missing language", s.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveAndLoadLSPRegistry(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
SetLSPRegistryPath(filepath.Join(tmpDir, "lsp-registry.yaml"))
|
||||||
|
|
||||||
|
reg := DefaultLSPRegistry()
|
||||||
|
if err := SaveLSPRegistry(reg); err != nil {
|
||||||
|
t.Fatalf("SaveLSPRegistry failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, err := LoadLSPRegistry()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLSPRegistry failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(loaded.Servers) != len(reg.Servers) {
|
||||||
|
t.Errorf("Expected %d servers, got %d", len(reg.Servers), len(loaded.Servers))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitLSPRegistry(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
SetLSPRegistryPath(filepath.Join(tmpDir, "lsp-reg.yaml"))
|
||||||
|
|
||||||
|
if err := InitLSPRegistry(); err != nil {
|
||||||
|
t.Fatalf("InitLSPRegistry failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(filepath.Join(tmpDir, "lsp-reg.yaml")); os.IsNotExist(err) {
|
||||||
|
t.Error("LSP registry file should be created")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectProjectLanguages(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test\ngo 1.24\n"), 0644)
|
||||||
|
os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"name": "test"}`), 0644)
|
||||||
|
|
||||||
|
languages := DetectProjectLanguages(tmpDir)
|
||||||
|
if len(languages) == 0 {
|
||||||
|
t.Error("Should detect languages")
|
||||||
|
}
|
||||||
|
|
||||||
|
langSet := map[string]bool{}
|
||||||
|
for _, l := range languages {
|
||||||
|
langSet[l] = true
|
||||||
|
}
|
||||||
|
if !langSet["go"] {
|
||||||
|
t.Error("Should detect Go")
|
||||||
|
}
|
||||||
|
if !langSet["typescript"] {
|
||||||
|
t.Error("Should detect TypeScript/JS from package.json")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectProjectLanguagesEmpty(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
languages := DetectProjectLanguages(tmpDir)
|
||||||
|
if len(languages) != 0 {
|
||||||
|
t.Errorf("Empty dir should detect no languages, got %v", languages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateNeovimConfig(t *testing.T) {
|
||||||
|
servers := []RegistryEntry{
|
||||||
|
{Name: "gopls", Language: "go", NeovimSetup: "lspconfig.gopls.setup{}"},
|
||||||
|
{Name: "pyright", Language: "python", NeovimSetup: "lspconfig.pyright.setup{}"},
|
||||||
|
}
|
||||||
|
|
||||||
|
config := GenerateNeovimConfig(servers)
|
||||||
|
if config == "" {
|
||||||
|
t.Error("Config should not be empty")
|
||||||
|
}
|
||||||
|
if len(config) < 50 {
|
||||||
|
t.Error("Config seems too short")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateHelixConfig(t *testing.T) {
|
||||||
|
servers := []RegistryEntry{
|
||||||
|
{Name: "gopls", Language: "go", HelixLanguage: "go"},
|
||||||
|
}
|
||||||
|
|
||||||
|
config := GenerateHelixConfig(servers)
|
||||||
|
if config == "" {
|
||||||
|
t.Error("Config should not be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateVSCodeRecommendations(t *testing.T) {
|
||||||
|
servers := []RegistryEntry{
|
||||||
|
{Name: "gopls", Language: "go"},
|
||||||
|
{Name: "pyright", Language: "python"},
|
||||||
|
}
|
||||||
|
|
||||||
|
exts := GenerateVSCodeRecommendations(servers)
|
||||||
|
if len(exts) == 0 {
|
||||||
|
t.Error("Should return some extensions")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthCheck(t *testing.T) {
|
||||||
|
healthy, detail := HealthCheck("gopls")
|
||||||
|
if healthy && detail == "" {
|
||||||
|
t.Error("If healthy, should have version detail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthCheckUnknown(t *testing.T) {
|
||||||
|
_, _ = HealthCheck("nonexistent-server")
|
||||||
|
}
|
||||||
@@ -6,17 +6,22 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/muyue/muyue/internal/config"
|
"github.com/muyue/muyue/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MCPServer struct {
|
type MCPServer struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
Args []string `json:"args"`
|
Args []string `json:"args"`
|
||||||
Env map[string]string `json:"env,omitempty"`
|
Env map[string]string `json:"env,omitempty"`
|
||||||
Installed bool `json:"installed"`
|
Installed bool `json:"installed"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type mcpEntry struct {
|
type mcpEntry struct {
|
||||||
@@ -47,13 +52,55 @@ func ScanServers() []MCPServer {
|
|||||||
servers[i] = s
|
servers[i] = s
|
||||||
_, err := exec.LookPath(s.Command)
|
_, err := exec.LookPath(s.Command)
|
||||||
servers[i].Installed = err == nil
|
servers[i].Installed = err == nil
|
||||||
|
servers[i].Version = GetInstalledVersion(s.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
regServers, err := scanRegistryServers()
|
||||||
|
if err == nil {
|
||||||
|
servers = append(servers, regServers...)
|
||||||
|
}
|
||||||
|
|
||||||
return servers
|
return servers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func scanRegistryServers() ([]MCPServer, error) {
|
||||||
|
reg, err := LoadRegistry()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
knownNames := map[string]bool{}
|
||||||
|
for _, s := range knownMCPServers {
|
||||||
|
knownNames[s.Name] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var servers []MCPServer
|
||||||
|
for _, rs := range reg.Servers {
|
||||||
|
if knownNames[rs.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
servers = append(servers, MCPServer{
|
||||||
|
Name: rs.Name,
|
||||||
|
Command: rs.Command,
|
||||||
|
Args: rs.Args,
|
||||||
|
Env: rs.Env,
|
||||||
|
Category: rs.Category,
|
||||||
|
Description: rs.Description,
|
||||||
|
Installed: isCommandAvailable(rs.Command),
|
||||||
|
Version: GetInstalledVersion(rs.Name),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return servers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCommandAvailable(cmd string) bool {
|
||||||
|
_, err := exec.LookPath(cmd)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
func getCoreEntries(homeDir string) []mcpEntry {
|
func getCoreEntries(homeDir string) []mcpEntry {
|
||||||
return []mcpEntry{
|
return []mcpEntry{
|
||||||
{"filesystem", "npx", []string{"-y", "@modelcontextprotocol/server-filesystem", homeDir + "/projects"}, nil},
|
{"filesystem", "npx", []string{"-y", "@modelcontextprotocol/server-filesystem", filepath.Join(homeDir, "projects")}, nil},
|
||||||
{"fetch", "npx", []string{"-y", "@modelcontextprotocol/server-fetch"}, nil},
|
{"fetch", "npx", []string{"-y", "@modelcontextprotocol/server-fetch"}, nil},
|
||||||
{"memory", "npx", []string{"-y", "@modelcontextprotocol/server-memory"}, nil},
|
{"memory", "npx", []string{"-y", "@modelcontextprotocol/server-memory"}, nil},
|
||||||
}
|
}
|
||||||
@@ -86,7 +133,9 @@ func writeMCPConfig(configPath string, mcpKey string, entries []mcpEntry) error
|
|||||||
existing := map[string]interface{}{}
|
existing := map[string]interface{}{}
|
||||||
data, err := os.ReadFile(configPath)
|
data, err := os.ReadFile(configPath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
json.Unmarshal(data, &existing)
|
if err := json.Unmarshal(data, &existing); err != nil {
|
||||||
|
return fmt.Errorf("parse existing config: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mcpMap := map[string]interface{}{}
|
mcpMap := map[string]interface{}{}
|
||||||
@@ -96,7 +145,8 @@ func writeMCPConfig(configPath string, mcpKey string, entries []mcpEntry) error
|
|||||||
"args": e.args,
|
"args": e.args,
|
||||||
}
|
}
|
||||||
if len(e.env) > 0 {
|
if len(e.env) > 0 {
|
||||||
entry["env"] = e.env
|
resolved := ResolveEnv(e.env, nil)
|
||||||
|
entry["env"] = resolved
|
||||||
}
|
}
|
||||||
mcpMap[e.name] = entry
|
mcpMap[e.name] = entry
|
||||||
}
|
}
|
||||||
@@ -108,7 +158,49 @@ func writeMCPConfig(configPath string, mcpKey string, entries []mcpEntry) error
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return os.WriteFile(configPath, out, 0600)
|
if err := os.WriteFile(configPath, out, 0600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValidateConfig(configPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeMCPConfigForEditor(editor EditorConfig, entries []mcpEntry) error {
|
||||||
|
configDir := filepath.Dir(editor.ConfigPath)
|
||||||
|
if err := os.MkdirAll(configDir, 0700); err != nil {
|
||||||
|
return fmt.Errorf("create config dir %s: %w", editor.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
existing := map[string]interface{}{}
|
||||||
|
data, err := os.ReadFile(editor.ConfigPath)
|
||||||
|
if err == nil {
|
||||||
|
_ = json.Unmarshal(data, &existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
mcpMap := map[string]interface{}{}
|
||||||
|
for _, e := range entries {
|
||||||
|
if editor.TransformCommand != nil {
|
||||||
|
mcpMap[e.name] = editor.TransformCommand(e)
|
||||||
|
} else {
|
||||||
|
entry := map[string]interface{}{
|
||||||
|
"command": e.cmd,
|
||||||
|
"args": e.args,
|
||||||
|
}
|
||||||
|
if len(e.env) > 0 {
|
||||||
|
entry["env"] = e.env
|
||||||
|
}
|
||||||
|
mcpMap[e.name] = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
existing[editor.ConfigKey] = mcpMap
|
||||||
|
|
||||||
|
out, err := json.MarshalIndent(existing, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(editor.ConfigPath, out, 0600)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GenerateCrushMCPConfig(cfg *config.MuyueConfig, homeDir string) error {
|
func GenerateCrushMCPConfig(cfg *config.MuyueConfig, homeDir string) error {
|
||||||
@@ -138,19 +230,154 @@ func GenerateClaudeMCPConfig(cfg *config.MuyueConfig, homeDir string) error {
|
|||||||
return writeMCPConfig(configPath, "mcpServers", entries)
|
return writeMCPConfig(configPath, "mcpServers", entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GenerateCursorMCPConfig(cfg *config.MuyueConfig, homeDir string) error {
|
||||||
|
if homeDir == "" {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
homeDir = home
|
||||||
|
}
|
||||||
|
|
||||||
|
core := getCoreEntries(homeDir)
|
||||||
|
entries := withProviderEntries(core, cfg, nil)
|
||||||
|
editor := EditorConfig{
|
||||||
|
Name: "cursor",
|
||||||
|
ConfigPath: filepath.Join(homeDir, ".cursor", "mcp.json"),
|
||||||
|
ConfigKey: "mcpServers",
|
||||||
|
Format: "json",
|
||||||
|
TransformCommand: func(e mcpEntry) interface{} {
|
||||||
|
m := map[string]interface{}{
|
||||||
|
"type": "stdio",
|
||||||
|
"command": e.cmd,
|
||||||
|
"args": e.args,
|
||||||
|
}
|
||||||
|
if len(e.env) > 0 {
|
||||||
|
m["env"] = e.env
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return writeMCPConfigForEditor(editor, entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateVSCodeMCPConfig(cfg *config.MuyueConfig, homeDir string) error {
|
||||||
|
if homeDir == "" {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
homeDir = home
|
||||||
|
}
|
||||||
|
|
||||||
|
core := getCoreEntries(homeDir)
|
||||||
|
entries := withProviderEntries(core, cfg, nil)
|
||||||
|
editor := EditorConfig{
|
||||||
|
Name: "vscode",
|
||||||
|
ConfigPath: filepath.Join(homeDir, ".vscode", "mcp.json"),
|
||||||
|
ConfigKey: "servers",
|
||||||
|
Format: "json",
|
||||||
|
}
|
||||||
|
return writeMCPConfigForEditor(editor, entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateWindsurfMCPConfig(cfg *config.MuyueConfig, homeDir string) error {
|
||||||
|
if homeDir == "" {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
homeDir = home
|
||||||
|
}
|
||||||
|
|
||||||
|
core := getCoreEntries(homeDir)
|
||||||
|
entries := withProviderEntries(core, cfg, nil)
|
||||||
|
editor := EditorConfig{
|
||||||
|
Name: "windsurf",
|
||||||
|
ConfigPath: filepath.Join(homeDir, ".windsurf", "mcp.json"),
|
||||||
|
ConfigKey: "mcpServers",
|
||||||
|
Format: "json",
|
||||||
|
}
|
||||||
|
return writeMCPConfigForEditor(editor, entries)
|
||||||
|
}
|
||||||
|
|
||||||
func ConfigureAll(cfg *config.MuyueConfig) error {
|
func ConfigureAll(cfg *config.MuyueConfig) error {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get home dir: %w", err)
|
return fmt.Errorf("get home dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := GenerateCrushMCPConfig(cfg, home); err != nil {
|
editors := []struct {
|
||||||
return fmt.Errorf("crush MCP config: %w", err)
|
name string
|
||||||
|
fn func(*config.MuyueConfig, string) error
|
||||||
|
}{
|
||||||
|
{"crush", GenerateCrushMCPConfig},
|
||||||
|
{"claude", GenerateClaudeMCPConfig},
|
||||||
|
{"cursor", GenerateCursorMCPConfig},
|
||||||
|
{"vscode", GenerateVSCodeMCPConfig},
|
||||||
|
{"windsurf", GenerateWindsurfMCPConfig},
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := GenerateClaudeMCPConfig(cfg, home); err != nil {
|
var errs []string
|
||||||
return fmt.Errorf("claude MCP config: %w", err)
|
for _, e := range editors {
|
||||||
|
if err := e.fn(cfg, home); err != nil {
|
||||||
|
errs = append(errs, fmt.Sprintf("%s: %s", e.name, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SaveReceipt("all", time.Now().Format("2006-01-02"))
|
||||||
|
|
||||||
|
if len(errs) > 0 {
|
||||||
|
return fmt.Errorf("MCP config errors: %s", strings.Join(errs, "; "))
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ConfigureForEditor(cfg *config.MuyueConfig, editorName string) error {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get home dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch editorName {
|
||||||
|
case "crush":
|
||||||
|
return GenerateCrushMCPConfig(cfg, home)
|
||||||
|
case "claude", "claude-code":
|
||||||
|
return GenerateClaudeMCPConfig(cfg, home)
|
||||||
|
case "cursor":
|
||||||
|
return GenerateCursorMCPConfig(cfg, home)
|
||||||
|
case "vscode", "code":
|
||||||
|
return GenerateVSCodeMCPConfig(cfg, home)
|
||||||
|
case "windsurf":
|
||||||
|
return GenerateWindsurfMCPConfig(cfg, home)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown editor: %s (supported: crush, claude-code, cursor, vscode, windsurf)", editorName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func DetectInstalledEditors(homeDir string) []string {
|
||||||
|
if homeDir == "" {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
homeDir = home
|
||||||
|
}
|
||||||
|
|
||||||
|
editors := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
}{
|
||||||
|
{"crush", filepath.Join(homeDir, ".config", "crush", "crush.json")},
|
||||||
|
{"claude-code", filepath.Join(homeDir, ".claude.json")},
|
||||||
|
{"cursor", filepath.Join(homeDir, ".cursor")},
|
||||||
|
{"vscode", filepath.Join(homeDir, ".vscode")},
|
||||||
|
{"windsurf", filepath.Join(homeDir, ".windsurf")},
|
||||||
|
}
|
||||||
|
|
||||||
|
var detected []string
|
||||||
|
for _, e := range editors {
|
||||||
|
if _, err := os.Stat(e.path); err == nil {
|
||||||
|
detected = append(detected, e.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return detected
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAllStatuses() []MCPStatus {
|
||||||
|
servers := ScanServers()
|
||||||
|
statuses := make([]MCPStatus, len(servers))
|
||||||
|
for i, s := range servers {
|
||||||
|
statuses[i] = CheckServerStatus(s.Name)
|
||||||
|
}
|
||||||
|
return statuses
|
||||||
|
}
|
||||||
|
|||||||
520
internal/mcp/registry.go
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RegistryServer struct {
|
||||||
|
Name string `yaml:"name" json:"name"`
|
||||||
|
Description string `yaml:"description" json:"description"`
|
||||||
|
Category string `yaml:"category" json:"category"`
|
||||||
|
Package string `yaml:"package" json:"package"`
|
||||||
|
Command string `yaml:"command" json:"command"`
|
||||||
|
Args []string `yaml:"args" json:"args"`
|
||||||
|
Env map[string]string `yaml:"env,omitempty" json:"env,omitempty"`
|
||||||
|
RequiredEnv []string `yaml:"required_env,omitempty" json:"required_env,omitempty"`
|
||||||
|
HomePage string `yaml:"homepage,omitempty" json:"homepage,omitempty"`
|
||||||
|
Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"`
|
||||||
|
Version string `yaml:"version,omitempty" json:"version,omitempty"`
|
||||||
|
InstallType string `yaml:"install_type" json:"install_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Registry struct {
|
||||||
|
SchemaVersion string `yaml:"schema_version"`
|
||||||
|
UpdatedAt time.Time `yaml:"updated_at"`
|
||||||
|
Servers []RegistryServer `yaml:"servers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MCPStatus struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Installed bool `json:"installed"`
|
||||||
|
Running bool `json:"running"`
|
||||||
|
Healthy bool `json:"healthy"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditorConfig struct {
|
||||||
|
Name string
|
||||||
|
ConfigPath string
|
||||||
|
ConfigKey string
|
||||||
|
LocalConfigPath string
|
||||||
|
Format string
|
||||||
|
TransformCommand func(entry mcpEntry) interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
registryMu sync.RWMutex
|
||||||
|
registryCache *Registry
|
||||||
|
registryPath string
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
if home != "" {
|
||||||
|
registryPath = filepath.Join(home, ".muyue", "mcp-registry.yaml")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetRegistryPath(p string) {
|
||||||
|
registryMu.Lock()
|
||||||
|
defer registryMu.Unlock()
|
||||||
|
registryPath = p
|
||||||
|
registryCache = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultRegistry() *Registry {
|
||||||
|
return &Registry{
|
||||||
|
SchemaVersion: "v1",
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
Servers: []RegistryServer{
|
||||||
|
{
|
||||||
|
Name: "filesystem", Description: "File system operations for AI tools",
|
||||||
|
Category: "core", Package: "@modelcontextprotocol/server-filesystem",
|
||||||
|
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-filesystem"},
|
||||||
|
InstallType: "npm", Tags: []string{"files", "core"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "github", Description: "GitHub API integration",
|
||||||
|
Category: "vcs", Package: "@modelcontextprotocol/server-github",
|
||||||
|
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-github"},
|
||||||
|
Env: map[string]string{"GITHUB_PERSONAL_ACCESS_TOKEN": ""},
|
||||||
|
RequiredEnv: []string{"GITHUB_PERSONAL_ACCESS_TOKEN"},
|
||||||
|
InstallType: "npm", Tags: []string{"github", "git"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "git", Description: "Git repository operations",
|
||||||
|
Category: "vcs", Package: "@modelcontextprotocol/server-git",
|
||||||
|
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-git"},
|
||||||
|
InstallType: "npm", Tags: []string{"git"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "fetch", Description: "Web fetching and HTTP requests",
|
||||||
|
Category: "web", Package: "@modelcontextprotocol/server-fetch",
|
||||||
|
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-fetch"},
|
||||||
|
InstallType: "npm", Tags: []string{"web", "http"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "memory", Description: "Persistent memory/knowledge graph",
|
||||||
|
Category: "core", Package: "@modelcontextprotocol/server-memory",
|
||||||
|
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-memory"},
|
||||||
|
InstallType: "npm", Tags: []string{"memory", "core"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "sequential-thinking", Description: "Structured reasoning and chain-of-thought",
|
||||||
|
Category: "ai", Package: "@modelcontextprotocol/server-sequential-thinking",
|
||||||
|
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-sequential-thinking"},
|
||||||
|
InstallType: "npm", Tags: []string{"ai", "reasoning"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "brave-search", Description: "Web search via Brave Search API",
|
||||||
|
Category: "web", Package: "@modelcontextprotocol/server-brave-search",
|
||||||
|
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-brave-search"},
|
||||||
|
Env: map[string]string{"BRAVE_API_KEY": ""},
|
||||||
|
RequiredEnv: []string{"BRAVE_API_KEY"},
|
||||||
|
InstallType: "npm", Tags: []string{"search", "web"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "sqlite", Description: "SQLite database operations",
|
||||||
|
Category: "database", Package: "@modelcontextprotocol/server-sqlite",
|
||||||
|
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-sqlite"},
|
||||||
|
InstallType: "npm", Tags: []string{"database", "sqlite"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "postgres", Description: "PostgreSQL database operations",
|
||||||
|
Category: "database", Package: "@modelcontextprotocol/server-postgres",
|
||||||
|
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-postgres"},
|
||||||
|
InstallType: "npm", Tags: []string{"database", "postgres"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "docker", Description: "Docker container management",
|
||||||
|
Category: "devops", Package: "@modelcontextprotocol/server-docker",
|
||||||
|
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-docker"},
|
||||||
|
InstallType: "npm", Tags: []string{"docker", "devops"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "minimax-web-search", Description: "Web search via MiniMax API",
|
||||||
|
Category: "ai", Package: "@minimax/mcp-web-search",
|
||||||
|
Command: "npx", Args: []string{"-y", "@minimax/mcp-web-search"},
|
||||||
|
Env: map[string]string{"MINIMAX_API_KEY": ""},
|
||||||
|
RequiredEnv: []string{"MINIMAX_API_KEY"},
|
||||||
|
InstallType: "npm", Tags: []string{"ai", "search"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "minimax-image", Description: "Image understanding via MiniMax API",
|
||||||
|
Category: "ai", Package: "@minimax/mcp-image-understanding",
|
||||||
|
Command: "npx", Args: []string{"-y", "@minimax/mcp-image-understanding"},
|
||||||
|
Env: map[string]string{"MINIMAX_API_KEY": ""},
|
||||||
|
RequiredEnv: []string{"MINIMAX_API_KEY"},
|
||||||
|
InstallType: "npm", Tags: []string{"ai", "image"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "puppeteer", Description: "Browser automation with Puppeteer",
|
||||||
|
Category: "web", Package: "@modelcontextprotocol/server-puppeteer",
|
||||||
|
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-puppeteer"},
|
||||||
|
InstallType: "npm", Tags: []string{"browser", "automation"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "everything", Description: "Test/debug MCP server with all features",
|
||||||
|
Category: "testing", Package: "@modelcontextprotocol/server-everything",
|
||||||
|
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-everything"},
|
||||||
|
InstallType: "npm", Tags: []string{"testing", "debug"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "slack", Description: "Slack workspace integration",
|
||||||
|
Category: "communication", Package: "@modelcontextprotocol/server-slack",
|
||||||
|
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-slack"},
|
||||||
|
Env: map[string]string{"SLACK_BOT_TOKEN": ""},
|
||||||
|
RequiredEnv: []string{"SLACK_BOT_TOKEN"},
|
||||||
|
InstallType: "npm", Tags: []string{"slack", "communication"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "google-maps", Description: "Google Maps integration",
|
||||||
|
Category: "web", Package: "@modelcontextprotocol/server-google-maps",
|
||||||
|
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-google-maps"},
|
||||||
|
Env: map[string]string{"GOOGLE_MAPS_API_KEY": ""},
|
||||||
|
RequiredEnv: []string{"GOOGLE_MAPS_API_KEY"},
|
||||||
|
InstallType: "npm", Tags: []string{"maps", "location"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadRegistry() (*Registry, error) {
|
||||||
|
registryMu.RLock()
|
||||||
|
if registryCache != nil {
|
||||||
|
defer registryMu.RUnlock()
|
||||||
|
return registryCache, nil
|
||||||
|
}
|
||||||
|
registryMu.RUnlock()
|
||||||
|
|
||||||
|
reg, err := loadRegistryFromDisk()
|
||||||
|
if err != nil {
|
||||||
|
defaultReg := DefaultRegistry()
|
||||||
|
registryMu.Lock()
|
||||||
|
registryCache = defaultReg
|
||||||
|
registryMu.Unlock()
|
||||||
|
return defaultReg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
registryMu.Lock()
|
||||||
|
registryCache = reg
|
||||||
|
registryMu.Unlock()
|
||||||
|
return reg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadRegistryFromDisk() (*Registry, error) {
|
||||||
|
if registryPath == "" {
|
||||||
|
return nil, fmt.Errorf("registry path not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(registryPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var reg Registry
|
||||||
|
if err := yaml.Unmarshal(data, ®); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse registry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ®, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveRegistry(reg *Registry) error {
|
||||||
|
if registryPath == "" {
|
||||||
|
return fmt.Errorf("registry path not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
reg.UpdatedAt = time.Now()
|
||||||
|
data, err := yaml.Marshal(reg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal registry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(registryPath), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(registryPath, data, 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
registryMu.Lock()
|
||||||
|
registryCache = reg
|
||||||
|
registryMu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func AddToRegistry(server RegistryServer) error {
|
||||||
|
reg, err := LoadRegistry()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range reg.Servers {
|
||||||
|
if s.Name == server.Name {
|
||||||
|
return fmt.Errorf("server %q already exists in registry", server.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reg.Servers = append(reg.Servers, server)
|
||||||
|
return SaveRegistry(reg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RemoveFromRegistry(name string) error {
|
||||||
|
reg, err := LoadRegistry()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, s := range reg.Servers {
|
||||||
|
if s.Name == name {
|
||||||
|
reg.Servers = append(reg.Servers[:i], reg.Servers[i+1:]...)
|
||||||
|
return SaveRegistry(reg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("server %q not found in registry", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func InitRegistry() error {
|
||||||
|
if _, err := os.Stat(registryPath); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return SaveRegistry(DefaultRegistry())
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveEnv(env map[string]string, providerKeys map[string]string) map[string]string {
|
||||||
|
resolved := make(map[string]string)
|
||||||
|
for k, v := range env {
|
||||||
|
if v != "" {
|
||||||
|
resolved[k] = v
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if providerKeys != nil {
|
||||||
|
for providerKey, apiKey := range providerKeys {
|
||||||
|
if strings.EqualFold(k, providerKey) || strings.Contains(strings.ToUpper(k), strings.ToUpper(providerKey)) {
|
||||||
|
if apiKey != "" {
|
||||||
|
resolved[k] = apiKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if resolved[k] == "" {
|
||||||
|
if envVal := os.Getenv(k); envVal != "" {
|
||||||
|
resolved[k] = envVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateConfig(configPath string) error {
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg map[string]interface{}
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return fmt.Errorf("parse config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DiscoverNpmServers() ([]RegistryServer, error) {
|
||||||
|
var servers []RegistryServer
|
||||||
|
|
||||||
|
packages := []struct {
|
||||||
|
pkg string
|
||||||
|
name string
|
||||||
|
desc string
|
||||||
|
cat string
|
||||||
|
args []string
|
||||||
|
}{
|
||||||
|
{"@modelcontextprotocol/server-filesystem", "filesystem", "File system operations", "core", []string{"-y", "@modelcontextprotocol/server-filesystem"}},
|
||||||
|
{"@modelcontextprotocol/server-github", "github", "GitHub API integration", "vcs", []string{"-y", "@modelcontextprotocol/server-github"}},
|
||||||
|
{"@modelcontextprotocol/server-fetch", "fetch", "Web fetching", "web", []string{"-y", "@modelcontextprotocol/server-fetch"}},
|
||||||
|
{"@modelcontextprotocol/server-memory", "memory", "Persistent memory", "core", []string{"-y", "@modelcontextprotocol/server-memory"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range packages {
|
||||||
|
servers = append(servers, RegistryServer{
|
||||||
|
Name: p.name,
|
||||||
|
Description: p.desc,
|
||||||
|
Category: p.cat,
|
||||||
|
Package: p.pkg,
|
||||||
|
Command: "npx",
|
||||||
|
Args: p.args,
|
||||||
|
InstallType: "npm",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return servers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetInstalledVersion(name string) string {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
if home == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
receiptPath := filepath.Join(home, ".muyue", "receipts", "mcp", name+".json")
|
||||||
|
data, err := os.ReadFile(receiptPath)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var receipt struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(data, &receipt) == nil {
|
||||||
|
return receipt.Version
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveReceipt(name, version string) error {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
if home == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
receiptDir := filepath.Join(home, ".muyue", "receipts", "mcp")
|
||||||
|
os.MkdirAll(receiptDir, 0755)
|
||||||
|
|
||||||
|
receipt := struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}{
|
||||||
|
Name: name,
|
||||||
|
Version: version,
|
||||||
|
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.MarshalIndent(receipt, "", " ")
|
||||||
|
return os.WriteFile(filepath.Join(receiptDir, name+".json"), data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildProviderKeyMap(cfg interface{ GetAPIKeys() map[string]string }) map[string]string {
|
||||||
|
if cfg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return cfg.GetAPIKeys()
|
||||||
|
}
|
||||||
|
|
||||||
|
func EditorConfigs(homeDir string) []EditorConfig {
|
||||||
|
if homeDir == "" {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
homeDir = home
|
||||||
|
}
|
||||||
|
|
||||||
|
transformStdio := func(e mcpEntry) interface{} {
|
||||||
|
m := map[string]interface{}{
|
||||||
|
"command": e.cmd,
|
||||||
|
"args": e.args,
|
||||||
|
}
|
||||||
|
if len(e.env) > 0 {
|
||||||
|
m["env"] = e.env
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
transformCursor := func(e mcpEntry) interface{} {
|
||||||
|
m := map[string]interface{}{
|
||||||
|
"type": "stdio",
|
||||||
|
"command": e.cmd,
|
||||||
|
"args": e.args,
|
||||||
|
}
|
||||||
|
if len(e.env) > 0 {
|
||||||
|
m["env"] = e.env
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
return []EditorConfig{
|
||||||
|
{
|
||||||
|
Name: "crush", ConfigPath: filepath.Join(homeDir, ".config", "crush", "crush.json"),
|
||||||
|
ConfigKey: "mcps", Format: "json", TransformCommand: transformStdio,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "claude-code", ConfigPath: filepath.Join(homeDir, ".claude.json"),
|
||||||
|
ConfigKey: "mcpServers", Format: "json", TransformCommand: transformStdio,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "cursor", ConfigPath: filepath.Join(homeDir, ".cursor", "mcp.json"),
|
||||||
|
LocalConfigPath: ".cursor/mcp.json", ConfigKey: "mcpServers",
|
||||||
|
Format: "json", TransformCommand: transformCursor,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "vscode", ConfigPath: filepath.Join(homeDir, ".vscode", "mcp.json"),
|
||||||
|
LocalConfigPath: ".vscode/mcp.json", ConfigKey: "servers",
|
||||||
|
Format: "json", TransformCommand: transformStdio,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "windsurf", ConfigPath: filepath.Join(homeDir, ".windsurf", "mcp.json"),
|
||||||
|
ConfigKey: "mcpServers", Format: "json", TransformCommand: transformStdio,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckServerStatus(name string) MCPStatus {
|
||||||
|
status := MCPStatus{Name: name}
|
||||||
|
|
||||||
|
reg, err := LoadRegistry()
|
||||||
|
if err != nil {
|
||||||
|
status.Error = "registry unavailable"
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
var server *RegistryServer
|
||||||
|
for i := range reg.Servers {
|
||||||
|
if reg.Servers[i].Name == name {
|
||||||
|
server = ®.Servers[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if server == nil {
|
||||||
|
status.Error = "not in registry"
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = exec.LookPath(server.Command)
|
||||||
|
if err != nil {
|
||||||
|
status.Error = fmt.Sprintf("command %q not found", server.Command)
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
status.Installed = true
|
||||||
|
|
||||||
|
status.Version = GetInstalledVersion(name)
|
||||||
|
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
if home != "" {
|
||||||
|
crushingPath := filepath.Join(home, ".config", "crush", "crush.json")
|
||||||
|
data, err := os.ReadFile(crushingPath)
|
||||||
|
if err == nil {
|
||||||
|
var cfg map[string]interface{}
|
||||||
|
if json.Unmarshal(data, &cfg) == nil {
|
||||||
|
if mcps, ok := cfg["mcps"].(map[string]interface{}); ok {
|
||||||
|
if _, exists := mcps[name]; exists {
|
||||||
|
status.Running = true
|
||||||
|
status.Healthy = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return status
|
||||||
|
}
|
||||||
228
internal/mcp/registry_test.go
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDefaultRegistry(t *testing.T) {
|
||||||
|
reg := DefaultRegistry()
|
||||||
|
if reg.SchemaVersion != "v1" {
|
||||||
|
t.Errorf("Expected v1, got %s", reg.SchemaVersion)
|
||||||
|
}
|
||||||
|
if len(reg.Servers) == 0 {
|
||||||
|
t.Error("Default registry should have servers")
|
||||||
|
}
|
||||||
|
|
||||||
|
names := map[string]bool{}
|
||||||
|
for _, s := range reg.Servers {
|
||||||
|
if names[s.Name] {
|
||||||
|
t.Errorf("Duplicate server name: %s", s.Name)
|
||||||
|
}
|
||||||
|
names[s.Name] = true
|
||||||
|
if s.Command == "" {
|
||||||
|
t.Errorf("Server %s missing command", s.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveAndLoadRegistry(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
registryPath := filepath.Join(tmpDir, "mcp-registry.yaml")
|
||||||
|
SetRegistryPath(registryPath)
|
||||||
|
|
||||||
|
reg := DefaultRegistry()
|
||||||
|
if err := SaveRegistry(reg); err != nil {
|
||||||
|
t.Fatalf("SaveRegistry failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(registryPath); os.IsNotExist(err) {
|
||||||
|
t.Error("Registry file should exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, err := LoadRegistry()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadRegistry failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(loaded.Servers) != len(reg.Servers) {
|
||||||
|
t.Errorf("Expected %d servers, got %d", len(reg.Servers), len(loaded.Servers))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddAndRemoveFromRegistry(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
SetRegistryPath(filepath.Join(tmpDir, "mcp-registry.yaml"))
|
||||||
|
SaveRegistry(DefaultRegistry())
|
||||||
|
|
||||||
|
newServer := RegistryServer{
|
||||||
|
Name: "test-server",
|
||||||
|
Description: "Test server",
|
||||||
|
Category: "test",
|
||||||
|
Command: "npx",
|
||||||
|
Args: []string{"-y", "test-pkg"},
|
||||||
|
InstallType: "npm",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := AddToRegistry(newServer); err != nil {
|
||||||
|
t.Fatalf("AddToRegistry failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reg, _ := LoadRegistry()
|
||||||
|
found := false
|
||||||
|
for _, s := range reg.Servers {
|
||||||
|
if s.Name == "test-server" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("test-server should be in registry")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := RemoveFromRegistry("test-server"); err != nil {
|
||||||
|
t.Fatalf("RemoveFromRegistry failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reg, _ = LoadRegistry()
|
||||||
|
for _, s := range reg.Servers {
|
||||||
|
if s.Name == "test-server" {
|
||||||
|
t.Error("test-server should have been removed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveEnv(t *testing.T) {
|
||||||
|
env := map[string]string{
|
||||||
|
"API_KEY": "",
|
||||||
|
"HOST": "localhost",
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Setenv("API_KEY", "from-env")
|
||||||
|
defer os.Unsetenv("API_KEY")
|
||||||
|
|
||||||
|
resolved := ResolveEnv(env, nil)
|
||||||
|
if resolved["API_KEY"] != "from-env" {
|
||||||
|
t.Errorf("Expected from-env, got %s", resolved["API_KEY"])
|
||||||
|
}
|
||||||
|
if resolved["HOST"] != "localhost" {
|
||||||
|
t.Errorf("Expected localhost, got %s", resolved["HOST"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfig(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "test-config.json")
|
||||||
|
os.WriteFile(configPath, []byte(`{"mcps":{}}`), 0644)
|
||||||
|
|
||||||
|
if err := ValidateConfig(configPath); err != nil {
|
||||||
|
t.Errorf("Valid config should pass: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
badPath := filepath.Join(tmpDir, "nonexistent.json")
|
||||||
|
if err := ValidateConfig(badPath); err == nil {
|
||||||
|
t.Error("Nonexistent config should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEditorConfigs(t *testing.T) {
|
||||||
|
configs := EditorConfigs("/tmp")
|
||||||
|
if len(configs) < 3 {
|
||||||
|
t.Errorf("Expected at least 3 editor configs, got %d", len(configs))
|
||||||
|
}
|
||||||
|
|
||||||
|
names := map[string]bool{}
|
||||||
|
for _, c := range configs {
|
||||||
|
if names[c.Name] {
|
||||||
|
t.Errorf("Duplicate editor: %s", c.Name)
|
||||||
|
}
|
||||||
|
names[c.Name] = true
|
||||||
|
if c.ConfigPath == "" {
|
||||||
|
t.Errorf("Editor %s missing config path", c.Name)
|
||||||
|
}
|
||||||
|
if c.ConfigKey == "" {
|
||||||
|
t.Errorf("Editor %s missing config key", c.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverNpmServers(t *testing.T) {
|
||||||
|
servers, err := DiscoverNpmServers()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DiscoverNpmServers failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(servers) == 0 {
|
||||||
|
t.Error("Should discover some npm servers")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReceiptRoundTrip(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
os.Setenv("HOME", tmpDir)
|
||||||
|
defer os.Unsetenv("HOME")
|
||||||
|
|
||||||
|
SetRegistryPath(filepath.Join(tmpDir, "reg.yaml"))
|
||||||
|
|
||||||
|
if err := SaveReceipt("test-server", "1.2.3"); err != nil {
|
||||||
|
t.Fatalf("SaveReceipt failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
version := GetInstalledVersion("test-server")
|
||||||
|
if version != "1.2.3" {
|
||||||
|
t.Errorf("Expected 1.2.3, got %s", version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitRegistry(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
SetRegistryPath(filepath.Join(tmpDir, "init-reg.yaml"))
|
||||||
|
|
||||||
|
if err := InitRegistry(); err != nil {
|
||||||
|
t.Fatalf("InitRegistry failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(filepath.Join(tmpDir, "init-reg.yaml")); os.IsNotExist(err) {
|
||||||
|
t.Error("Registry file should be created")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := InitRegistry(); err != nil {
|
||||||
|
t.Fatalf("Second InitRegistry should not fail: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectInstalledEditors(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
os.MkdirAll(filepath.Join(tmpDir, ".config", "crush"), 0755)
|
||||||
|
os.WriteFile(filepath.Join(tmpDir, ".config", "crush", "crush.json"), []byte(`{}`), 0644)
|
||||||
|
os.MkdirAll(filepath.Join(tmpDir, ".cursor"), 0755)
|
||||||
|
|
||||||
|
editors := DetectInstalledEditors(tmpDir)
|
||||||
|
if len(editors) < 2 {
|
||||||
|
t.Errorf("Expected at least 2 editors, got %d", len(editors))
|
||||||
|
}
|
||||||
|
|
||||||
|
found := map[string]bool{}
|
||||||
|
for _, e := range editors {
|
||||||
|
found[e] = true
|
||||||
|
}
|
||||||
|
if !found["crush"] {
|
||||||
|
t.Error("Should detect crush")
|
||||||
|
}
|
||||||
|
if !found["cursor"] {
|
||||||
|
t.Error("Should detect cursor")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckServerStatus(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
os.Setenv("HOME", tmpDir)
|
||||||
|
defer os.Unsetenv("HOME")
|
||||||
|
|
||||||
|
SetRegistryPath(filepath.Join(tmpDir, "reg.yaml"))
|
||||||
|
SaveRegistry(DefaultRegistry())
|
||||||
|
|
||||||
|
status := CheckServerStatus("nonexistent")
|
||||||
|
if status.Error == "" {
|
||||||
|
t.Error("Should have error for nonexistent server")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package orchestrator
|
package orchestrator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -15,25 +16,82 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var thinkRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
|
var thinkRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
|
||||||
|
var providerToolBlockRegex = regexp.MustCompile(`(?s)<[a-zA-Z][a-zA-Z0-9]*:tool_call[^>]*>.*?</[a-zA-Z][a-zA-Z0-9]*:tool_call>`)
|
||||||
|
var providerTagRegex = regexp.MustCompile(`(?s)</?[a-zA-Z][a-zA-Z0-9]*:[a-zA-Z_]+[^>]*>`)
|
||||||
|
var xmlToolTagRegex = regexp.MustCompile(`(?s)</?(invoke|parameter|tool_call|tool_result)[^>]*>`)
|
||||||
|
var bracketToolCallRegex = regexp.MustCompile(`(?m)^\[(?:terminal|shell|bash|command|execute)\]\s*\{[^}]*\}\s*$`)
|
||||||
|
|
||||||
|
var streamBlockStartRegex = regexp.MustCompile(`<[a-zA-Z][a-zA-Z0-9]*:tool_call`)
|
||||||
|
var streamXmlStartRegex = regexp.MustCompile(`<(?:invoke|parameter|tool_call|tool_result)[\s>]`)
|
||||||
|
var streamBracketStartRegex = regexp.MustCompile(`\[(?:terminal|shell|bash|command|execute)\]\s*\{`)
|
||||||
|
|
||||||
const maxHistorySize = 100
|
const maxHistorySize = 100
|
||||||
|
|
||||||
|
type ContentPart struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
ImageURL *ImageURL `json:"image_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImageURL struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
type Message struct {
|
type Message struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content"`
|
Content json.RawMessage `json:"content,omitempty"`
|
||||||
|
ToolCalls []ToolCallMsg `json:"tool_calls,omitempty"`
|
||||||
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TextContent(s string) json.RawMessage {
|
||||||
|
b, _ := json.Marshal(s)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func PartsContent(parts []ContentPart) json.RawMessage {
|
||||||
|
b, _ := json.Marshal(parts)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Message) ContentString() string {
|
||||||
|
var s string
|
||||||
|
if json.Unmarshal(m.Content, &s) == nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(m.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolCallMsg struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Function ToolCallFuncMsg `json:"function"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolCallFuncMsg struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments string `json:"arguments"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChatRequest struct {
|
type ChatRequest struct {
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Messages []Message `json:"messages"`
|
Messages []Message `json:"messages"`
|
||||||
Stream bool `json:"stream"`
|
Stream bool `json:"stream"`
|
||||||
|
Tools json.RawMessage `json:"tools,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChatResponse struct {
|
type ChatResponse struct {
|
||||||
Choices []struct {
|
Choices []struct {
|
||||||
Message struct {
|
Message struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
ToolCalls []ToolCallMsg `json:"tool_calls"`
|
||||||
} `json:"message"`
|
} `json:"message"`
|
||||||
|
Delta struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
ToolCalls []ToolCallMsg `json:"tool_calls"`
|
||||||
|
} `json:"delta"`
|
||||||
|
FinishReason *string `json:"finish_reason"`
|
||||||
} `json:"choices"`
|
} `json:"choices"`
|
||||||
Usage struct {
|
Usage struct {
|
||||||
TotalTokens int `json:"total_tokens"`
|
TotalTokens int `json:"total_tokens"`
|
||||||
@@ -47,12 +105,18 @@ type Orchestrator struct {
|
|||||||
history []Message
|
history []Message
|
||||||
histMu sync.Mutex
|
histMu sync.Mutex
|
||||||
systemPrompt string
|
systemPrompt string
|
||||||
|
tools json.RawMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
var sharedHTTPClient = &http.Client{
|
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 {
|
||||||
@@ -78,15 +142,101 @@ func New(cfg *config.MuyueConfig) (*Orchestrator, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewForProvider builds an orchestrator using a specific (non-active) provider,
|
||||||
|
// for the Advanced Reflection feature where the inactive provider produces a
|
||||||
|
// preliminary report before the active provider answers. Excludes the currently
|
||||||
|
// active provider from selection — picks the first other configured provider
|
||||||
|
// with a non-empty API key.
|
||||||
|
func NewForInactiveProvider(cfg *config.MuyueConfig) (*Orchestrator, error) {
|
||||||
|
var activeName string
|
||||||
|
for _, p := range cfg.AI.Providers {
|
||||||
|
if p.Active {
|
||||||
|
activeName = p.Name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range cfg.AI.Providers {
|
||||||
|
p := &cfg.AI.Providers[i]
|
||||||
|
if p.Name == activeName {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.APIKey == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return &Orchestrator{
|
||||||
|
config: cfg,
|
||||||
|
provider: p,
|
||||||
|
client: sharedHTTPClient,
|
||||||
|
history: []Message{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no inactive provider with API key configured")
|
||||||
|
}
|
||||||
|
|
||||||
func (o *Orchestrator) SetSystemPrompt(prompt string) {
|
func (o *Orchestrator) SetSystemPrompt(prompt string) {
|
||||||
o.systemPrompt = prompt
|
o.systemPrompt = prompt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (o *Orchestrator) SetTools(tools json.RawMessage) {
|
||||||
|
o.tools = tools
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Orchestrator) ProviderName() string {
|
||||||
|
if o.provider == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return o.provider.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Orchestrator) AppendHistory(msg Message) {
|
||||||
|
o.histMu.Lock()
|
||||||
|
defer o.histMu.Unlock()
|
||||||
|
o.history = append(o.history, msg)
|
||||||
|
if len(o.history) > maxHistorySize {
|
||||||
|
o.history = o.history[len(o.history)-maxHistorySize:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Orchestrator) GetHistory() []Message {
|
||||||
|
o.histMu.Lock()
|
||||||
|
defer o.histMu.Unlock()
|
||||||
|
out := make([]Message, len(o.history))
|
||||||
|
copy(out, o.history)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendNoTools issues a one-shot, history-less request to this orchestrator's
|
||||||
|
// provider. Used by the Advanced Reflection feature so the inactive provider
|
||||||
|
// can produce a preliminary report without contaminating the active
|
||||||
|
// orchestrator's history or invoking tools.
|
||||||
|
func (o *Orchestrator) SendNoTools(userMessage string) (string, error) {
|
||||||
|
messages := make([]Message, 0, 2)
|
||||||
|
if o.systemPrompt != "" {
|
||||||
|
messages = append(messages, Message{Role: "system", Content: TextContent(o.systemPrompt)})
|
||||||
|
}
|
||||||
|
messages = append(messages, Message{Role: "user", Content: TextContent(userMessage)})
|
||||||
|
|
||||||
|
reqBody := ChatRequest{
|
||||||
|
Model: o.provider.Model,
|
||||||
|
Messages: messages,
|
||||||
|
Stream: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
chatResp, _, err := o.sendWithFallback(reqBody, "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(chatResp.Choices) == 0 {
|
||||||
|
return "", fmt.Errorf("empty response from provider")
|
||||||
|
}
|
||||||
|
return CleanAIResponse(chatResp.Choices[0].Message.Content), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (o *Orchestrator) Send(userMessage string) (string, error) {
|
func (o *Orchestrator) Send(userMessage string) (string, error) {
|
||||||
o.histMu.Lock()
|
o.histMu.Lock()
|
||||||
o.history = append(o.history, Message{
|
o.history = append(o.history, Message{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: userMessage,
|
Content: TextContent(userMessage),
|
||||||
})
|
})
|
||||||
|
|
||||||
if len(o.history) > maxHistorySize {
|
if len(o.history) > maxHistorySize {
|
||||||
@@ -95,7 +245,7 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
|
|||||||
|
|
||||||
messages := make([]Message, 0, len(o.history)+1)
|
messages := make([]Message, 0, len(o.history)+1)
|
||||||
if o.systemPrompt != "" {
|
if o.systemPrompt != "" {
|
||||||
messages = append(messages, Message{Role: "system", Content: o.systemPrompt})
|
messages = append(messages, Message{Role: "system", Content: TextContent(o.systemPrompt)})
|
||||||
}
|
}
|
||||||
messages = append(messages, o.history...)
|
messages = append(messages, o.history...)
|
||||||
|
|
||||||
@@ -103,6 +253,49 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
|
|||||||
Model: o.provider.Model,
|
Model: o.provider.Model,
|
||||||
Messages: messages,
|
Messages: messages,
|
||||||
Stream: false,
|
Stream: false,
|
||||||
|
Tools: o.tools,
|
||||||
|
}
|
||||||
|
o.histMu.Unlock()
|
||||||
|
|
||||||
|
chatResp, providerName, err := o.sendWithFallback(reqBody, "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
content := CleanAIResponse(chatResp.Choices[0].Message.Content)
|
||||||
|
o.histMu.Lock()
|
||||||
|
o.history = append(o.history, Message{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: TextContent(content),
|
||||||
|
})
|
||||||
|
_ = providerName
|
||||||
|
o.histMu.Unlock()
|
||||||
|
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (string, error) {
|
||||||
|
o.histMu.Lock()
|
||||||
|
o.history = append(o.history, Message{
|
||||||
|
Role: "user",
|
||||||
|
Content: TextContent(userMessage),
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(o.history) > maxHistorySize {
|
||||||
|
o.history = o.history[len(o.history)-maxHistorySize:]
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := make([]Message, 0, len(o.history)+1)
|
||||||
|
if o.systemPrompt != "" {
|
||||||
|
messages = append(messages, Message{Role: "system", Content: TextContent(o.systemPrompt)})
|
||||||
|
}
|
||||||
|
messages = append(messages, o.history...)
|
||||||
|
|
||||||
|
reqBody := ChatRequest{
|
||||||
|
Model: o.provider.Model,
|
||||||
|
Messages: messages,
|
||||||
|
Stream: true,
|
||||||
|
Tools: o.tools,
|
||||||
}
|
}
|
||||||
o.histMu.Unlock()
|
o.histMu.Unlock()
|
||||||
|
|
||||||
@@ -132,37 +325,226 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
respBody, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
return "", fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
|
return "", fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
|
||||||
}
|
}
|
||||||
|
|
||||||
var chatResp ChatResponse
|
var fullContent strings.Builder
|
||||||
if err := json.Unmarshal(respBody, &chatResp); err != nil {
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
return "", fmt.Errorf("parse response: %w", err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(chatResp.Choices) == 0 {
|
if err := scanner.Err(); err != nil {
|
||||||
return "", fmt.Errorf("no response from AI")
|
return fullContent.String(), fmt.Errorf("read stream: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
content := cleanAIResponse(chatResp.Choices[0].Message.Content)
|
content := CleanAIResponse(fullContent.String())
|
||||||
o.histMu.Lock()
|
o.histMu.Lock()
|
||||||
o.history = append(o.history, Message{
|
o.history = append(o.history, Message{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: content,
|
Content: TextContent(content),
|
||||||
})
|
})
|
||||||
o.histMu.Unlock()
|
o.histMu.Unlock()
|
||||||
|
|
||||||
return content, nil
|
return content, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func cleanAIResponse(content string) string {
|
func (o *Orchestrator) SendWithTools(messages []Message) (*ChatResponse, error) {
|
||||||
|
fullMessages := make([]Message, 0, len(messages)+1)
|
||||||
|
if o.systemPrompt != "" {
|
||||||
|
fullMessages = append(fullMessages, Message{Role: "system", Content: TextContent(o.systemPrompt)})
|
||||||
|
}
|
||||||
|
fullMessages = append(fullMessages, messages...)
|
||||||
|
|
||||||
|
reqBody := ChatRequest{
|
||||||
|
Model: o.provider.Model,
|
||||||
|
Messages: fullMessages,
|
||||||
|
Stream: false,
|
||||||
|
Tools: o.tools,
|
||||||
|
}
|
||||||
|
|
||||||
|
chatResp, _, err := o.sendWithFallback(reqBody, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(chatResp.Choices) == 0 {
|
||||||
|
return nil, fmt.Errorf("no response from AI")
|
||||||
|
}
|
||||||
|
|
||||||
|
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: TextContent(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
|
||||||
|
var insideToolBlock bool
|
||||||
|
|
||||||
|
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)
|
||||||
|
cleanedChunk := CleanStreamChunk(chunk, &insideToolBlock)
|
||||||
|
if cleanedChunk != "" {
|
||||||
|
onChunk(cleanedChunk, 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 {
|
||||||
content = thinkRegex.ReplaceAllString(content, "")
|
content = thinkRegex.ReplaceAllString(content, "")
|
||||||
|
content = providerToolBlockRegex.ReplaceAllString(content, "")
|
||||||
|
content = providerTagRegex.ReplaceAllString(content, "")
|
||||||
|
content = xmlToolTagRegex.ReplaceAllString(content, "")
|
||||||
|
content = bracketToolCallRegex.ReplaceAllString(content, "")
|
||||||
lines := strings.Split(content, "\n")
|
lines := strings.Split(content, "\n")
|
||||||
var clean []string
|
var clean []string
|
||||||
inBlock := false
|
inBlock := false
|
||||||
@@ -185,6 +567,35 @@ func cleanAIResponse(content string) string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CleanStreamChunk applies lightweight cleaning to individual streaming chunks.
|
||||||
|
// It tracks state via a bool pointer to suppress content inside tool-call blocks.
|
||||||
|
func CleanStreamChunk(chunk string, insideBlock *bool) string {
|
||||||
|
if *insideBlock {
|
||||||
|
// Check for closing tag
|
||||||
|
if strings.Contains(chunk, ":tool_call>") {
|
||||||
|
*insideBlock = false
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for opening tool_call block
|
||||||
|
if streamBlockStartRegex.MatchString(chunk) {
|
||||||
|
*insideBlock = true
|
||||||
|
// If closing tag also in same chunk, emit nothing
|
||||||
|
if strings.Contains(chunk, ":tool_call>") {
|
||||||
|
*insideBlock = false
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean individual tags and bracket calls
|
||||||
|
cleaned := providerTagRegex.ReplaceAllString(chunk, "")
|
||||||
|
cleaned = xmlToolTagRegex.ReplaceAllString(cleaned, "")
|
||||||
|
cleaned = bracketToolCallRegex.ReplaceAllString(cleaned, "")
|
||||||
|
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
|
||||||
func getProviderBaseURL(name string) string {
|
func getProviderBaseURL(name string) string {
|
||||||
switch name {
|
switch name {
|
||||||
case "minimax":
|
case "minimax":
|
||||||
@@ -195,7 +606,117 @@ 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 ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (o *Orchestrator) getAvailableProviders() []*config.AIProvider {
|
||||||
|
var providers []*config.AIProvider
|
||||||
|
for i := range o.config.AI.Providers {
|
||||||
|
prov := &o.config.AI.Providers[i]
|
||||||
|
if prov.APIKey != "" {
|
||||||
|
providers = append(providers, prov)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return providers
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Orchestrator) sendWithFallback(reqBody ChatRequest, baseURLOverride string) (*ChatResponse, string, error) {
|
||||||
|
providers := o.getAvailableProviders()
|
||||||
|
|
||||||
|
if len(providers) == 0 {
|
||||||
|
return nil, "", fmt.Errorf("no providers available")
|
||||||
|
}
|
||||||
|
|
||||||
|
providerOrder := make([]*config.AIProvider, 0, len(providers))
|
||||||
|
if o.provider != nil {
|
||||||
|
providerOrder = append(providerOrder, o.provider)
|
||||||
|
}
|
||||||
|
var zaiProvider *config.AIProvider
|
||||||
|
for _, p := range providers {
|
||||||
|
if o.provider == nil || p.Name != o.provider.Name {
|
||||||
|
if p.Name == "zai" {
|
||||||
|
zaiProvider = p
|
||||||
|
} else {
|
||||||
|
providerOrder = append(providerOrder, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if zaiProvider != nil {
|
||||||
|
providerOrder = append(providerOrder, zaiProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
var triedProviders []string
|
||||||
|
for _, prov := range providerOrder {
|
||||||
|
triedProviders = append(triedProviders, prov.Name)
|
||||||
|
baseURL := baseURLOverride
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = prov.BaseURL
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = getProviderBaseURL(prov.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
url := strings.TrimRight(baseURL, "/") + "/chat/completions"
|
||||||
|
|
||||||
|
body, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = fmt.Errorf("marshal request: %w", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
lastErr = fmt.Errorf("create request: %w", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := o.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = fmt.Errorf("send request to %s: %w", prov.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = fmt.Errorf("read response: %w", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
lastErr = fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var chatResp ChatResponse
|
||||||
|
if err := json.Unmarshal(respBody, &chatResp); err != nil {
|
||||||
|
lastErr = fmt.Errorf("parse response: %w", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(chatResp.Choices) == 0 {
|
||||||
|
lastErr = fmt.Errorf("no response from AI")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
o.provider = prov
|
||||||
|
return &chatResp, prov.Name, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, "", lastErr
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package orchestrator
|
package orchestrator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -14,7 +17,7 @@ func TestCleanAIResponse(t *testing.T) {
|
|||||||
expected string
|
expected string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
"removes standard think tags",
|
"malformed think tags pass through",
|
||||||
"<think internal reasoning</think Hello world",
|
"<think internal reasoning</think Hello world",
|
||||||
"<think internal reasoning</think Hello world",
|
"<think internal reasoning</think Hello world",
|
||||||
},
|
},
|
||||||
@@ -24,7 +27,7 @@ func TestCleanAIResponse(t *testing.T) {
|
|||||||
"response",
|
"response",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"removes think with attrs",
|
"think with attrs, no closing bracket",
|
||||||
"<think type=re>reasoning</think result",
|
"<think type=re>reasoning</think result",
|
||||||
"<think type=re>reasoning</think result",
|
"<think type=re>reasoning</think result",
|
||||||
},
|
},
|
||||||
@@ -49,12 +52,12 @@ func TestCleanAIResponse(t *testing.T) {
|
|||||||
"",
|
"",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"removes valid think block",
|
"malformed think block no closing bracket",
|
||||||
"<think some reasoning here</think rest",
|
"<think some reasoning here</think rest",
|
||||||
"<think some reasoning here</think rest",
|
"<think some reasoning here</think rest",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"removes simple think",
|
"malformed simple think no closing bracket",
|
||||||
"before<think reasoning</think after",
|
"before<think reasoning</think after",
|
||||||
"before<think reasoning</think after",
|
"before<think reasoning</think after",
|
||||||
},
|
},
|
||||||
@@ -62,11 +65,11 @@ func TestCleanAIResponse(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
result := cleanAIResponse(tt.input)
|
result := CleanAIResponse(tt.input)
|
||||||
result = strings.TrimSpace(result)
|
result = strings.TrimSpace(result)
|
||||||
expected := strings.TrimSpace(tt.expected)
|
expected := strings.TrimSpace(tt.expected)
|
||||||
if result != expected {
|
if result != expected {
|
||||||
t.Errorf("cleanAIResponse(%q) = %q, want %q", tt.input, result, expected)
|
t.Errorf("CleanAIResponse(%q) = %q, want %q", tt.input, result, expected)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -74,34 +77,34 @@ func TestCleanAIResponse(t *testing.T) {
|
|||||||
|
|
||||||
func TestCleanAIResponseThinkRegex(t *testing.T) {
|
func TestCleanAIResponseThinkRegex(t *testing.T) {
|
||||||
input2 := "<Think>some reasoning</Think>actual response"
|
input2 := "<Think>some reasoning</Think>actual response"
|
||||||
result2 := cleanAIResponse(input2)
|
result2 := CleanAIResponse(input2)
|
||||||
if result2 != "actual response" {
|
if result2 != "actual response" {
|
||||||
t.Errorf("Valid Think tags should be removed: %q", result2)
|
t.Errorf("Valid Think tags should be removed: %q", result2)
|
||||||
}
|
}
|
||||||
|
|
||||||
input3 := "<think\nmultiline\nreasoning</think visible"
|
input3 := "<think\nmultiline\nreasoning</think visible"
|
||||||
result3 := cleanAIResponse(input3)
|
result3 := CleanAIResponse(input3)
|
||||||
// No closing > on opening tag, so won't match regex
|
// No closing > on opening tag, so won't match regex
|
||||||
if result3 != "<think\nmultiline\nreasoning</think visible" {
|
if result3 != "<think\nmultiline\nreasoning</think visible" {
|
||||||
t.Errorf("Malformed think should not be removed: %q", result3)
|
t.Errorf("Malformed think should not be removed: %q", result3)
|
||||||
}
|
}
|
||||||
|
|
||||||
input4 := "<think type=re>reasoning</think visible"
|
input4 := "<think type=re>reasoning</think visible"
|
||||||
result4 := cleanAIResponse(input4)
|
result4 := CleanAIResponse(input4)
|
||||||
// </think followed by space, not >, so won't match
|
// </think followed by space, not >, so won't match
|
||||||
if result4 != "<think type=re>reasoning</think visible" {
|
if result4 != "<think type=re>reasoning</think visible" {
|
||||||
t.Errorf("Malformed closing should not be removed: %q", result4)
|
t.Errorf("Malformed closing should not be removed: %q", result4)
|
||||||
}
|
}
|
||||||
|
|
||||||
input_real := "prefix<think reasoning here</think suffix"
|
input_real := "prefix<think reasoning here</think suffix"
|
||||||
result_real := cleanAIResponse(input_real)
|
result_real := CleanAIResponse(input_real)
|
||||||
// The closing </think has no > after it, so won't match
|
// The closing </think has no > after it, so won't match
|
||||||
if result_real != "prefix<think reasoning here</think suffix" {
|
if result_real != "prefix<think reasoning here</think suffix" {
|
||||||
t.Errorf("Malformed tags should pass through: %q", result_real)
|
t.Errorf("Malformed tags should pass through: %q", result_real)
|
||||||
}
|
}
|
||||||
|
|
||||||
input_valid := "<Think>reasoning</Think>result"
|
input_valid := "<Think>reasoning</Think>result"
|
||||||
result_valid := cleanAIResponse(input_valid)
|
result_valid := CleanAIResponse(input_valid)
|
||||||
if result_valid != "result" {
|
if result_valid != "result" {
|
||||||
t.Errorf("Valid tags should be removed: %q", result_valid)
|
t.Errorf("Valid tags should be removed: %q", result_valid)
|
||||||
}
|
}
|
||||||
@@ -146,3 +149,128 @@ func TestNewNoAPIKey(t *testing.T) {
|
|||||||
t.Error("Should fail with no API key")
|
t.Error("Should fail with no API key")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSendStreamChunks(t *testing.T) {
|
||||||
|
sseBody := `data: {"choices":[{"delta":{"content":"Hello"}}]}
|
||||||
|
data: {"choices":[{"delta":{"content":" world"}}]}
|
||||||
|
data: {"choices":[{"delta":{"content":"!"}}]}
|
||||||
|
data: [DONE]
|
||||||
|
`
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("Authorization") != "Bearer test-key" {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var reqBody ChatRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !reqBody.Stream {
|
||||||
|
http.Error(w, "stream must be true", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Write([]byte(sseBody))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
cfg := config.Default()
|
||||||
|
cfg.AI.Providers[0].Active = true
|
||||||
|
cfg.AI.Providers[0].APIKey = "test-key"
|
||||||
|
cfg.AI.Providers[0].BaseURL = ts.URL
|
||||||
|
|
||||||
|
orb, err := New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunks []string
|
||||||
|
result, err := orb.SendStream("hi", func(chunk string) {
|
||||||
|
chunks = append(chunks, chunk)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SendStream: %v", err)
|
||||||
|
}
|
||||||
|
if result != "Hello world!" {
|
||||||
|
t.Errorf("SendStream result = %q, want %q", result, "Hello world!")
|
||||||
|
}
|
||||||
|
if len(chunks) != 3 {
|
||||||
|
t.Fatalf("expected 3 chunks, got %d: %v", len(chunks), chunks)
|
||||||
|
}
|
||||||
|
if strings.Join(chunks, "") != "Hello world!" {
|
||||||
|
t.Errorf("chunks joined = %q, want %q", strings.Join(chunks, ""), "Hello world!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendStreamHistory(t *testing.T) {
|
||||||
|
callCount := 0
|
||||||
|
sseBody := `data: {"choices":[{"delta":{"content":"reply"}}]}
|
||||||
|
data: [DONE]
|
||||||
|
`
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
callCount++
|
||||||
|
var reqBody ChatRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if callCount == 1 {
|
||||||
|
if len(reqBody.Messages) != 2 {
|
||||||
|
t.Errorf("first call: expected 2 messages (system + 1 user), got %d", len(reqBody.Messages))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if len(reqBody.Messages) != 4 {
|
||||||
|
t.Errorf("second call: expected 4 messages (system + 3 history), got %d", len(reqBody.Messages))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Write([]byte(sseBody))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
cfg := config.Default()
|
||||||
|
cfg.AI.Providers[0].Active = true
|
||||||
|
cfg.AI.Providers[0].APIKey = "test-key"
|
||||||
|
cfg.AI.Providers[0].BaseURL = ts.URL
|
||||||
|
|
||||||
|
orb, err := New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
orb.SetSystemPrompt("you are helpful")
|
||||||
|
|
||||||
|
_, _ = orb.SendStream("first", func(string) {})
|
||||||
|
_, _ = orb.SendStream("second", func(string) {})
|
||||||
|
|
||||||
|
orb.histMu.Lock()
|
||||||
|
if len(orb.history) != 4 {
|
||||||
|
t.Errorf("expected 4 history entries (2 user + 2 assistant), got %d", len(orb.history))
|
||||||
|
}
|
||||||
|
orb.histMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendStreamAPIError(t *testing.T) {
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, `{"error":"rate limited"}`, http.StatusTooManyRequests)
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
cfg := config.Default()
|
||||||
|
cfg.AI.Providers[0].Active = true
|
||||||
|
cfg.AI.Providers[0].APIKey = "test-key"
|
||||||
|
cfg.AI.Providers[0].BaseURL = ts.URL
|
||||||
|
|
||||||
|
orb, err := New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = orb.SendStream("hi", func(string) {})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for non-200 response")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "429") {
|
||||||
|
t.Errorf("error should mention status code, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,3 +17,62 @@ func fileContains(path, substr string) bool {
|
|||||||
func execLookPath(name string) (string, error) {
|
func execLookPath(name string) (string, error) {
|
||||||
return exec.LookPath(name)
|
return exec.LookPath(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func readOSReleaseName() string {
|
||||||
|
data, err := os.ReadFile("/etc/os-release")
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var pretty, name, version string
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
key, val, ok := strings.Cut(line, "=")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val = strings.Trim(val, `"'`)
|
||||||
|
switch key {
|
||||||
|
case "PRETTY_NAME":
|
||||||
|
pretty = val
|
||||||
|
case "NAME":
|
||||||
|
name = val
|
||||||
|
case "VERSION_ID":
|
||||||
|
version = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pretty != "" {
|
||||||
|
return pretty
|
||||||
|
}
|
||||||
|
if name != "" && version != "" {
|
||||||
|
return name + " " + version
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func readMacOSVersion() string {
|
||||||
|
out, err := exec.Command("sw_vers", "-productVersion").Output()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func readWindowsVersion() string {
|
||||||
|
if v := os.Getenv("OS"); v != "" && strings.Contains(strings.ToLower(v), "windows") {
|
||||||
|
// Try to detect Windows 11 vs 10 via build number
|
||||||
|
if build := os.Getenv("MUYUE_WIN_BUILD"); build != "" {
|
||||||
|
return "Windows " + build
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out, err := exec.Command("cmd", "/c", "ver").Output()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s := strings.TrimSpace(string(out))
|
||||||
|
if strings.Contains(s, "10.0.22") || strings.Contains(s, "10.0.23") {
|
||||||
|
return "Windows 11"
|
||||||
|
}
|
||||||
|
if strings.Contains(s, "10.0.") {
|
||||||
|
return "Windows 10"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,12 +24,13 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type SystemInfo struct {
|
type SystemInfo struct {
|
||||||
OS OS
|
OS OS `json:"os"`
|
||||||
Arch Arch
|
OSName string `json:"os_name"`
|
||||||
IsWSL bool
|
Arch Arch `json:"arch"`
|
||||||
Shell string
|
IsWSL bool `json:"is_wsl"`
|
||||||
Terminal string
|
Shell string `json:"shell"`
|
||||||
PackageManager string
|
Terminal string `json:"terminal"`
|
||||||
|
PackageManager string `json:"package_manager"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Detect() SystemInfo {
|
func Detect() SystemInfo {
|
||||||
@@ -39,6 +40,7 @@ func Detect() SystemInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
info.IsWSL = detectWSL()
|
info.IsWSL = detectWSL()
|
||||||
|
info.OSName = detectOSName(info.OS, info.IsWSL)
|
||||||
info.Shell = detectShell()
|
info.Shell = detectShell()
|
||||||
info.Terminal = detectTerminal()
|
info.Terminal = detectTerminal()
|
||||||
info.PackageManager = detectPackageManager(info.OS)
|
info.PackageManager = detectPackageManager(info.OS)
|
||||||
@@ -46,6 +48,33 @@ func Detect() SystemInfo {
|
|||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func detectOSName(os OS, isWSL bool) string {
|
||||||
|
switch os {
|
||||||
|
case Linux:
|
||||||
|
if name := readOSReleaseName(); name != "" {
|
||||||
|
if isWSL {
|
||||||
|
return name + " (WSL)"
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
if isWSL {
|
||||||
|
return "Linux (WSL)"
|
||||||
|
}
|
||||||
|
return "Linux"
|
||||||
|
case MacOS:
|
||||||
|
if v := readMacOSVersion(); v != "" {
|
||||||
|
return "macOS " + v
|
||||||
|
}
|
||||||
|
return "macOS"
|
||||||
|
case Windows:
|
||||||
|
if v := readWindowsVersion(); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return "Windows"
|
||||||
|
}
|
||||||
|
return string(os)
|
||||||
|
}
|
||||||
|
|
||||||
func detectWSL() bool {
|
func detectWSL() bool {
|
||||||
return fileContains("/proc/version", "microsoft") ||
|
return fileContains("/proc/version", "microsoft") ||
|
||||||
fileContains("/proc/version", "WSL")
|
fileContains("/proc/version", "WSL")
|
||||||
@@ -95,8 +124,11 @@ func detectPackageManager(os OS) string {
|
|||||||
func (s SystemInfo) String() string {
|
func (s SystemInfo) String() string {
|
||||||
parts := []string{
|
parts := []string{
|
||||||
"OS: " + string(s.OS),
|
"OS: " + string(s.OS),
|
||||||
"Arch: " + string(s.Arch),
|
|
||||||
}
|
}
|
||||||
|
if s.OSName != "" {
|
||||||
|
parts = append(parts, "Name: "+s.OSName)
|
||||||
|
}
|
||||||
|
parts = append(parts, "Arch: "+string(s.Arch))
|
||||||
if s.IsWSL {
|
if s.IsWSL {
|
||||||
parts = append(parts, "WSL: yes")
|
parts = append(parts, "WSL: yes")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package platform
|
package platform
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -43,16 +44,9 @@ func TestString(t *testing.T) {
|
|||||||
if s == "" {
|
if s == "" {
|
||||||
t.Error("String should not be empty")
|
t.Error("String should not be empty")
|
||||||
}
|
}
|
||||||
if !contains(s, "linux") {
|
if !strings.Contains(s, "linux") {
|
||||||
t.Error("Should contain OS")
|
t.Error("Should contain OS")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func contains(s, sub string) bool {
|
|
||||||
for i := 0; i+len(sub) <= len(s); i++ {
|
|
||||||
if s[i:i+len(sub)] == sub {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ func AskAPIKey(providerName string) (string, error) {
|
|||||||
|
|
||||||
field := huh.NewInput().
|
field := huh.NewInput().
|
||||||
Title(fmt.Sprintf("Enter your %s API key:", providerName)).
|
Title(fmt.Sprintf("Enter your %s API key:", providerName)).
|
||||||
Description("The key will be stored locally in ~/.muyue/config.yaml").
|
Description("The key will be stored locally in ~/.config/muyue/config.yaml").
|
||||||
EchoMode(huh.EchoModePassword).
|
EchoMode(huh.EchoModePassword).
|
||||||
Value(&apiKey)
|
Value(&apiKey)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -13,31 +14,31 @@ 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 (
|
||||||
cacheMu sync.RWMutex
|
cacheMu sync.RWMutex
|
||||||
cacheResult *ScanResult
|
cacheResult *ScanResult
|
||||||
cacheTime time.Time
|
cacheTime time.Time
|
||||||
cacheTTL = 5 * time.Minute
|
cacheTTL = 5 * time.Minute
|
||||||
@@ -169,7 +170,7 @@ func checkShellSetup() bool {
|
|||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
rcFiles := []string{".bashrc", ".zshrc", ".config/fish/config.fish"}
|
rcFiles := []string{".bashrc", ".zshrc", ".config/fish/config.fish"}
|
||||||
for _, f := range rcFiles {
|
for _, f := range rcFiles {
|
||||||
data, err := os.ReadFile(home + "/" + f)
|
data, err := os.ReadFile(filepath.Join(home, f))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -192,6 +193,43 @@ func checkGitConfig() bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var editorsList = []struct {
|
||||||
|
name string
|
||||||
|
cmd []string
|
||||||
|
version []string
|
||||||
|
}{
|
||||||
|
{"vim", []string{"vim"}, []string{"--version"}},
|
||||||
|
{"nvim", []string{"nvim"}, []string{"--version"}},
|
||||||
|
{"code", []string{"code"}, []string{"--version"}},
|
||||||
|
{"emacs", []string{"emacs"}, []string{"--version"}},
|
||||||
|
{"nano", []string{"nano"}, []string{"--version"}},
|
||||||
|
{"helix", []string{"hx"}, []string{"--version"}},
|
||||||
|
{"subl", []string{"subl"}, []string{"--version"}},
|
||||||
|
{"zed", []string{"zed"}, []string{"--version"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
func ScanEditors() []ToolStatus {
|
||||||
|
var results []ToolStatus
|
||||||
|
for _, e := range editorsList {
|
||||||
|
status := ToolStatus{Name: e.name}
|
||||||
|
path, err := exec.LookPath(e.name)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
status.Installed = true
|
||||||
|
status.Path = path
|
||||||
|
if len(e.version) > 0 {
|
||||||
|
cmd := exec.Command(e.cmd[0], e.version...)
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err == nil {
|
||||||
|
status.Version = strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results = append(results, status)
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
var versionRegex = regexp.MustCompile(`\d+\.\d+\.\d+`)
|
var versionRegex = regexp.MustCompile(`\d+\.\d+\.\d+`)
|
||||||
|
|
||||||
func (s *ScanResult) Summary() string {
|
func (s *ScanResult) Summary() string {
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ var builtinSkills = []Skill{
|
|||||||
Name: "env-setup",
|
Name: "env-setup",
|
||||||
Description: "Set up a complete development environment for any language. Detects missing tools, installs them, and configures the project.",
|
Description: "Set up a complete development environment for any language. Detects missing tools, installs them, and configures the project.",
|
||||||
Author: "muyue",
|
Author: "muyue",
|
||||||
Version: "1.0.0",
|
Version: "1.1.0",
|
||||||
Target: "both",
|
Target: "both",
|
||||||
Tags: []string{"setup", "environment", "install"},
|
Tags: []string{"setup", "environment", "install"},
|
||||||
|
Category: "setup",
|
||||||
Content: `# Environment Setup
|
Content: `# Environment Setup
|
||||||
|
|
||||||
Use this skill when setting up a new development environment or project.
|
Use this skill when setting up a new development environment or project.
|
||||||
@@ -58,9 +59,14 @@ Use this skill when setting up a new development environment or project.
|
|||||||
Name: "git-workflow",
|
Name: "git-workflow",
|
||||||
Description: "Manage git branches, commits, and pull requests following best practices. Handles branching strategy, conventional commits, and PR creation.",
|
Description: "Manage git branches, commits, and pull requests following best practices. Handles branching strategy, conventional commits, and PR creation.",
|
||||||
Author: "muyue",
|
Author: "muyue",
|
||||||
Version: "1.0.0",
|
Version: "1.1.0",
|
||||||
Target: "both",
|
Target: "both",
|
||||||
Tags: []string{"git", "workflow", "branching", "commits"},
|
Tags: []string{"git", "workflow", "branching", "commits"},
|
||||||
|
Category: "workflow",
|
||||||
|
Dependencies: []SkillDependency{
|
||||||
|
{Type: "tool", Name: "git", Required: true},
|
||||||
|
{Type: "tool", Name: "gh", Required: false},
|
||||||
|
},
|
||||||
Content: `# Git Workflow
|
Content: `# Git Workflow
|
||||||
|
|
||||||
Use this skill when the user needs to create branches, make commits, or manage pull requests.
|
Use this skill when the user needs to create branches, make commits, or manage pull requests.
|
||||||
@@ -114,9 +120,10 @@ Follow Conventional Commits:
|
|||||||
Name: "api-design",
|
Name: "api-design",
|
||||||
Description: "Design and implement REST or GraphQL APIs following best practices. Includes endpoint design, error handling, and documentation.",
|
Description: "Design and implement REST or GraphQL APIs following best practices. Includes endpoint design, error handling, and documentation.",
|
||||||
Author: "muyue",
|
Author: "muyue",
|
||||||
Version: "1.0.0",
|
Version: "1.1.0",
|
||||||
Target: "both",
|
Target: "both",
|
||||||
Tags: []string{"api", "rest", "graphql", "design"},
|
Tags: []string{"api", "rest", "graphql", "design"},
|
||||||
|
Category: "design",
|
||||||
Content: `# API Design
|
Content: `# API Design
|
||||||
|
|
||||||
Use this skill when designing or implementing an API.
|
Use this skill when designing or implementing an API.
|
||||||
@@ -171,9 +178,10 @@ Use this skill when designing or implementing an API.
|
|||||||
Name: "debug-assist",
|
Name: "debug-assist",
|
||||||
Description: "Systematic debugging assistant. Helps identify, isolate, and fix bugs using a structured approach.",
|
Description: "Systematic debugging assistant. Helps identify, isolate, and fix bugs using a structured approach.",
|
||||||
Author: "muyue",
|
Author: "muyue",
|
||||||
Version: "1.0.0",
|
Version: "1.1.0",
|
||||||
Target: "both",
|
Target: "both",
|
||||||
Tags: []string{"debug", "troubleshooting", "bugs"},
|
Tags: []string{"debug", "troubleshooting", "bugs"},
|
||||||
|
Category: "debugging",
|
||||||
Content: `# Debug Assist
|
Content: `# Debug Assist
|
||||||
|
|
||||||
Use this skill when the user reports a bug or asks for help debugging.
|
Use this skill when the user reports a bug or asks for help debugging.
|
||||||
@@ -188,7 +196,7 @@ Use this skill when the user reports a bug or asks for help debugging.
|
|||||||
3. **Hypothesize** — Form a hypothesis about the root cause
|
3. **Hypothesize** — Form a hypothesis about the root cause
|
||||||
4. **Verify** — Add logging or breakpoints to confirm
|
4. **Verify** — Add logging or breakpoints to confirm
|
||||||
5. **Fix** — Make the minimal change to fix the issue
|
5. **Fix** — Make the minimal change to fix the issue
|
||||||
6. **Test** — Verify the fix works and doesn't break other things
|
6. **Test** — Verify the fix works and does not break other things
|
||||||
7. **Prevent** — Add a test to prevent regression
|
7. **Prevent** — Add a test to prevent regression
|
||||||
|
|
||||||
## Common Patterns
|
## Common Patterns
|
||||||
@@ -211,9 +219,10 @@ Use this skill when the user reports a bug or asks for help debugging.
|
|||||||
Name: "code-review",
|
Name: "code-review",
|
||||||
Description: "Perform a thorough code review. Checks for bugs, security issues, performance problems, and style consistency.",
|
Description: "Perform a thorough code review. Checks for bugs, security issues, performance problems, and style consistency.",
|
||||||
Author: "muyue",
|
Author: "muyue",
|
||||||
Version: "1.0.0",
|
Version: "1.1.0",
|
||||||
Target: "both",
|
Target: "both",
|
||||||
Tags: []string{"review", "quality", "security"},
|
Tags: []string{"review", "quality", "security"},
|
||||||
|
Category: "quality",
|
||||||
Content: `# Code Review
|
Content: `# Code Review
|
||||||
|
|
||||||
Use this skill when reviewing code changes or pull requests.
|
Use this skill when reviewing code changes or pull requests.
|
||||||
@@ -221,7 +230,7 @@ Use this skill when reviewing code changes or pull requests.
|
|||||||
## Review Checklist
|
## Review Checklist
|
||||||
|
|
||||||
### Correctness
|
### Correctness
|
||||||
- Does the code do what it's supposed to?
|
- Does the code do what it is supposed to?
|
||||||
- Are edge cases handled?
|
- Are edge cases handled?
|
||||||
- Are there off-by-one errors?
|
- Are there off-by-one errors?
|
||||||
- Are error paths handled?
|
- Are error paths handled?
|
||||||
@@ -254,7 +263,7 @@ Use this skill when reviewing code changes or pull requests.
|
|||||||
## Review Format
|
## Review Format
|
||||||
|
|
||||||
1. Summary of changes
|
1. Summary of changes
|
||||||
2. Issues found (critical → minor)
|
2. Issues found (critical to minor)
|
||||||
3. Suggestions for improvement
|
3. Suggestions for improvement
|
||||||
4. Positive observations
|
4. Positive observations
|
||||||
|
|
||||||
@@ -265,6 +274,351 @@ Use this skill when reviewing code changes or pull requests.
|
|||||||
- **Minor**: Style issues, naming, minor refactoring opportunities
|
- **Minor**: Style issues, naming, minor refactoring opportunities
|
||||||
- **Suggestion**: Alternative approaches, improvements`,
|
- **Suggestion**: Alternative approaches, improvements`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "docker-setup",
|
||||||
|
Description: "Set up Docker and docker-compose for a project with best practices including multi-stage builds, health checks, and proper networking.",
|
||||||
|
Author: "muyue",
|
||||||
|
Version: "1.0.0",
|
||||||
|
Target: "both",
|
||||||
|
Tags: []string{"docker", "containers", "devops", "compose"},
|
||||||
|
Category: "devops",
|
||||||
|
Dependencies: []SkillDependency{
|
||||||
|
{Type: "tool", Name: "docker", Required: true},
|
||||||
|
},
|
||||||
|
Content: `# Docker Setup
|
||||||
|
|
||||||
|
Use this skill when the user needs Docker configuration for a project.
|
||||||
|
|
||||||
|
## Dockerfile Best Practices
|
||||||
|
|
||||||
|
1. Use multi-stage builds to reduce image size:
|
||||||
|
- Builder stage: install dependencies, compile
|
||||||
|
- Runtime stage: copy only the binary/artifacts
|
||||||
|
|
||||||
|
2. Use specific base image tags (not ` + "`latest`" + `):
|
||||||
|
- ` + "`golang:1.24-alpine`" + ` for Go
|
||||||
|
- ` + "`node:22-slim`" + ` for Node.js
|
||||||
|
- ` + "`python:3.12-slim`" + ` for Python
|
||||||
|
|
||||||
|
3. Order layers for cache efficiency:
|
||||||
|
- Copy dependency files first (go.mod, package.json, requirements.txt)
|
||||||
|
- Install dependencies
|
||||||
|
- Copy source code last
|
||||||
|
|
||||||
|
4. Add health checks:
|
||||||
|
` + "```" + `dockerfile
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:8080/health || exit 1
|
||||||
|
` + "```" + `
|
||||||
|
|
||||||
|
5. Run as non-root user:
|
||||||
|
` + "```" + `dockerfile
|
||||||
|
RUN adduser -D appuser
|
||||||
|
USER appuser
|
||||||
|
` + "```" + `
|
||||||
|
|
||||||
|
## docker-compose.yml Structure
|
||||||
|
|
||||||
|
` + "```" + `yaml
|
||||||
|
version: "3.9"
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgres://user:pass@db:5432/app
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: user
|
||||||
|
POSTGRES_PASSWORD: pass
|
||||||
|
POSTGRES_DB: app
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U user"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
` + "```" + `
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- If Docker is not installed, provide install instructions for the platform
|
||||||
|
- If port is already in use, suggest alternative ports
|
||||||
|
- If build fails, check for missing .dockerignore and suggest one`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "security-audit",
|
||||||
|
Description: "Perform a security audit on code, dependencies, and configuration. Checks for OWASP Top 10 vulnerabilities, dependency vulnerabilities, and misconfigurations.",
|
||||||
|
Author: "muyue",
|
||||||
|
Version: "1.0.0",
|
||||||
|
Target: "both",
|
||||||
|
Tags: []string{"security", "audit", "vulnerabilities", "owasp"},
|
||||||
|
Category: "security",
|
||||||
|
Content: `# Security Audit
|
||||||
|
|
||||||
|
Use this skill when the user needs a security review or vulnerability assessment.
|
||||||
|
|
||||||
|
## Audit Checklist
|
||||||
|
|
||||||
|
### Input Validation (OWASP A03:2021)
|
||||||
|
- All user input is validated and sanitized
|
||||||
|
- SQL queries use parameterized statements
|
||||||
|
- File paths are validated (no path traversal)
|
||||||
|
- Input length limits are enforced
|
||||||
|
|
||||||
|
### Authentication and Authorization (OWASP A07:2021)
|
||||||
|
- Passwords are hashed with bcrypt/argon2 (never MD5/SHA1)
|
||||||
|
- JWT tokens have short expiry with refresh rotation
|
||||||
|
- Session management is secure
|
||||||
|
- RBAC or ABAC is properly implemented
|
||||||
|
- API endpoints have proper auth checks
|
||||||
|
|
||||||
|
### Data Protection (OWASP A02:2021)
|
||||||
|
- Secrets are not in source code (use env vars or secret managers)
|
||||||
|
- Sensitive data is encrypted at rest and in transit
|
||||||
|
- PII is properly handled and not logged
|
||||||
|
- TLS is enforced for all connections
|
||||||
|
|
||||||
|
### Dependency Security (OWASP A06:2021)
|
||||||
|
- Run ` + "`npm audit`" + `, ` + "`pip audit`" + `, or ` + "`go vuln check`" + `
|
||||||
|
- Check for known CVEs in dependencies
|
||||||
|
- Keep dependencies up to date
|
||||||
|
- Use lock files for reproducible builds
|
||||||
|
|
||||||
|
### Configuration Security
|
||||||
|
- Debug mode is disabled in production
|
||||||
|
- CORS is properly configured
|
||||||
|
- Rate limiting is in place
|
||||||
|
- Security headers are set (CSP, HSTS, X-Frame-Options)
|
||||||
|
- Error messages do not leak internal details
|
||||||
|
|
||||||
|
## Automated Checks
|
||||||
|
|
||||||
|
Run these tools if available:
|
||||||
|
- ` + "`gosec ./...`" + ` for Go security
|
||||||
|
- ` + "`bandit -r .`" + ` for Python security
|
||||||
|
- ` + "`npm audit`" + ` for Node.js vulnerabilities
|
||||||
|
- ` + "`trivy fs .`" + ` for container/Dockerfile scanning
|
||||||
|
|
||||||
|
## Report Format
|
||||||
|
|
||||||
|
1. Executive Summary (risk level, total findings)
|
||||||
|
2. Critical findings (immediate action required)
|
||||||
|
3. High findings (fix within 24h)
|
||||||
|
4. Medium findings (fix within sprint)
|
||||||
|
5. Low findings (address when convenient)
|
||||||
|
6. Recommendations`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "mcp-setup",
|
||||||
|
Description: "Configure MCP (Model Context Protocol) servers for AI tools. Discovers, installs, and configures MCP servers across multiple editors.",
|
||||||
|
Author: "muyue",
|
||||||
|
Version: "1.0.0",
|
||||||
|
Target: "both",
|
||||||
|
Tags: []string{"mcp", "ai", "configuration", "editors"},
|
||||||
|
Category: "setup",
|
||||||
|
Dependencies: []SkillDependency{
|
||||||
|
{Type: "tool", Name: "npx", Required: true},
|
||||||
|
},
|
||||||
|
Content: `# MCP Server Setup
|
||||||
|
|
||||||
|
Use this skill when the user wants to configure MCP servers for their AI coding tools.
|
||||||
|
|
||||||
|
## Supported Editors
|
||||||
|
|
||||||
|
Muyue can generate MCP configs for:
|
||||||
|
- **Crush**: ` + "`~/.config/crush/crush.json`" + ` (key: ` + "`mcps`" + `)
|
||||||
|
- **Claude Code**: ` + "`~/.claude.json`" + ` (key: ` + "`mcpServers`" + `)
|
||||||
|
- **Cursor**: ` + "`~/.cursor/mcp.json`" + ` (key: ` + "`mcpServers`" + `, adds ` + "`type: stdio`" + `)
|
||||||
|
- **VS Code**: ` + "`~/.vscode/mcp.json`" + ` (key: ` + "`servers`" + `)
|
||||||
|
- **Windsurf**: ` + "`~/.windsurf/mcp.json`" + ` (key: ` + "`mcpServers`" + `)
|
||||||
|
|
||||||
|
## Common MCP Servers
|
||||||
|
|
||||||
|
| Server | Package | Required Env |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| filesystem | @modelcontextprotocol/server-filesystem | None |
|
||||||
|
| fetch | @modelcontextprotocol/server-fetch | None |
|
||||||
|
| github | @modelcontextprotocol/server-github | GITHUB_PERSONAL_ACCESS_TOKEN |
|
||||||
|
| brave-search | @modelcontextprotocol/server-brave-search | BRAVE_API_KEY |
|
||||||
|
| memory | @modelcontextprotocol/server-memory | None |
|
||||||
|
| postgres | @modelcontextprotocol/server-postgres | DATABASE_URL |
|
||||||
|
| sqlite | @modelcontextprotocol/server-sqlite | None |
|
||||||
|
| docker | @modelcontextprotocol/server-docker | None |
|
||||||
|
|
||||||
|
## Setup Steps
|
||||||
|
|
||||||
|
1. Ask which editors the user wants to configure
|
||||||
|
2. Ask which MCP servers they need
|
||||||
|
3. For servers requiring API keys, prompt for the key
|
||||||
|
4. Generate configs for each selected editor
|
||||||
|
5. Validate configs (check JSON is valid, commands exist)
|
||||||
|
6. Test connectivity if possible
|
||||||
|
|
||||||
|
## Credential Management
|
||||||
|
|
||||||
|
- API keys should be stored in the Muyue config (encrypted)
|
||||||
|
- When generating MCP configs, inject keys from the Muyue config
|
||||||
|
- Never hardcode API keys in config files in version control
|
||||||
|
- Suggest adding MCP config files to ` + "`.gitignore`" + `
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- If npx fails, suggest ` + "`npm install -g`" + ` the package
|
||||||
|
- If a server does not start, check the command and args
|
||||||
|
- If auth fails, verify the API key is correct and active`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "lsp-setup",
|
||||||
|
Description: "Configure Language Server Protocol servers for code intelligence. Detects project languages, installs LSPs, and generates editor configs.",
|
||||||
|
Author: "muyue",
|
||||||
|
Version: "1.0.0",
|
||||||
|
Target: "both",
|
||||||
|
Tags: []string{"lsp", "language-server", "ide", "configuration"},
|
||||||
|
Category: "setup",
|
||||||
|
Content: `# LSP Server Setup
|
||||||
|
|
||||||
|
Use this skill when the user wants to set up language servers for code intelligence.
|
||||||
|
|
||||||
|
## Supported Languages
|
||||||
|
|
||||||
|
| Language | Server | Install Method |
|
||||||
|
|----------|--------|---------------|
|
||||||
|
| Go | gopls | ` + "`go install`" + ` |
|
||||||
|
| Python | pyright | ` + "`npm install -g`" + ` |
|
||||||
|
| TypeScript/JS | typescript-language-server | ` + "`npm install -g`" + ` |
|
||||||
|
| Rust | rust-analyzer | ` + "`rustup component add`" + ` |
|
||||||
|
| C/C++ | clangd | System package |
|
||||||
|
| Lua | lua-language-server | ` + "`npm install -g`" + ` |
|
||||||
|
| HTML | vscode-html-language-server | ` + "`npm install -g vscode-langservers-extracted`" + ` |
|
||||||
|
| CSS | vscode-css-language-server | ` + "`npm install -g vscode-langservers-extracted`" + ` |
|
||||||
|
| JSON | vscode-json-language-server | ` + "`npm install -g vscode-langservers-extracted`" + ` |
|
||||||
|
| YAML | yaml-language-server | ` + "`npm install -g`" + ` |
|
||||||
|
| Bash | bash-language-server | ` + "`npm install -g`" + ` |
|
||||||
|
| Docker | dockerfile-language-server | ` + "`npm install -g`" + ` |
|
||||||
|
| Vue | vue-language-server | ` + "`npm install -g`" + ` |
|
||||||
|
| Svelte | svelte-language-server | ` + "`npm install -g`" + ` |
|
||||||
|
|
||||||
|
## Auto-Detection
|
||||||
|
|
||||||
|
Detect project languages from:
|
||||||
|
- Config files: ` + "`go.mod`" + `, ` + "`package.json`" + `, ` + "`Cargo.toml`" + `, ` + "`pyproject.toml`" + `
|
||||||
|
- Source file extensions: ` + "`*.go`" + `, ` + "`*.py`" + `, ` + "`*.ts`" + `, ` + "`*.rs`" + `
|
||||||
|
|
||||||
|
## Editor Config Generation
|
||||||
|
|
||||||
|
### Neovim
|
||||||
|
Generate ` + "`lspconfig`" + ` setup snippet for each LSP.
|
||||||
|
|
||||||
|
### Helix
|
||||||
|
Generate ` + "`languages.toml`" + ` entries with language-server mappings.
|
||||||
|
|
||||||
|
### VS Code / Cursor
|
||||||
|
Generate ` + "`extensions.json`" + ` recommendations for each LSP.
|
||||||
|
|
||||||
|
## Health Checks
|
||||||
|
|
||||||
|
After installation, verify:
|
||||||
|
1. The binary is in PATH
|
||||||
|
2. The version matches expected
|
||||||
|
3. A basic ` + "`initialize`" + ` request succeeds (if applicable)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "workflow-design",
|
||||||
|
Description: "Design development workflows and automations. Creates CI/CD pipelines, git hooks, and development process documentation.",
|
||||||
|
Author: "muyue",
|
||||||
|
Version: "1.0.0",
|
||||||
|
Target: "both",
|
||||||
|
Tags: []string{"workflow", "ci-cd", "automation", "process"},
|
||||||
|
Category: "workflow",
|
||||||
|
Content: `# Workflow Design
|
||||||
|
|
||||||
|
Use this skill when the user wants to establish development workflows or CI/CD pipelines.
|
||||||
|
|
||||||
|
## CI/CD Pipeline Design
|
||||||
|
|
||||||
|
### GitHub Actions Template
|
||||||
|
|
||||||
|
` + "```" + `yaml
|
||||||
|
name: CI
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, develop]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with: { go-version: "1.24" }
|
||||||
|
- run: go vet ./...
|
||||||
|
- run: golint ./...
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with: { go-version: "1.24" }
|
||||||
|
- run: go test -race -coverprofile=coverage.out ./...
|
||||||
|
- run: go tool cover -func=coverage.out
|
||||||
|
|
||||||
|
build:
|
||||||
|
needs: [lint, test]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: go build -o bin/app ./cmd/app
|
||||||
|
` + "```" + `
|
||||||
|
|
||||||
|
## Git Hooks
|
||||||
|
|
||||||
|
Use ` + "`pre-commit`" + ` framework:
|
||||||
|
- ` + "`pre-commit`" + `: lint, format check, trailing whitespace
|
||||||
|
- ` + "`commit-msg`" + `: validate conventional commit format
|
||||||
|
- ` + "`pre-push`" + `: run tests
|
||||||
|
|
||||||
|
## Branch Protection Rules
|
||||||
|
|
||||||
|
- Require PR reviews (at least 1 approval)
|
||||||
|
- Require status checks to pass
|
||||||
|
- Require up-to-date branch before merge
|
||||||
|
- Require linear history (rebase merge)
|
||||||
|
|
||||||
|
## Development Process
|
||||||
|
|
||||||
|
1. Pick a task from the backlog
|
||||||
|
2. Create a feature branch
|
||||||
|
3. Implement with tests
|
||||||
|
4. Run linter and tests locally
|
||||||
|
5. Push and create PR
|
||||||
|
6. Address review feedback
|
||||||
|
7. Merge when approved and CI passes
|
||||||
|
8. Delete feature branch
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- If CI fails, provide clear error output and suggested fixes
|
||||||
|
- If hooks fail, explain what failed and how to fix
|
||||||
|
- Suggest ` + "`--no-verify`" + ` only as a last resort, with a warning`,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func InstallBuiltinSkills() error {
|
func InstallBuiltinSkills() error {
|
||||||
|
|||||||
@@ -1,27 +1,54 @@
|
|||||||
package skills
|
package skills
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type SkillDependency struct {
|
||||||
|
Type string `yaml:"type,omitempty" json:"type,omitempty"`
|
||||||
|
Name string `yaml:"name,omitempty" json:"name,omitempty"`
|
||||||
|
Version string `yaml:"version,omitempty" json:"version,omitempty"`
|
||||||
|
Required bool `yaml:"required,omitempty" json:"required,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type Skill struct {
|
type Skill struct {
|
||||||
Name string `yaml:"name" json:"name"`
|
Name string `yaml:"name" json:"name"`
|
||||||
Description string `yaml:"description" json:"description"`
|
Description string `yaml:"description" json:"description"`
|
||||||
Content string `yaml:"content" json:"content"`
|
Content string `yaml:"content" json:"content"`
|
||||||
Author string `yaml:"author" json:"author"`
|
Author string `yaml:"author" json:"author"`
|
||||||
Version string `yaml:"version" json:"version"`
|
Version string `yaml:"version" json:"version"`
|
||||||
CreatedAt time.Time `yaml:"created_at" json:"created_at"`
|
CreatedAt time.Time `yaml:"created_at" json:"created_at"`
|
||||||
UpdatedAt time.Time `yaml:"updated_at" json:"updated_at"`
|
UpdatedAt time.Time `yaml:"updated_at" json:"updated_at"`
|
||||||
Tags []string `yaml:"tags" json:"tags"`
|
Tags []string `yaml:"tags" json:"tags"`
|
||||||
Target string `yaml:"target" json:"target"`
|
Target string `yaml:"target" json:"target"`
|
||||||
FilePath string `yaml:"-" json:"-"`
|
FilePath string `yaml:"-" json:"-"`
|
||||||
|
Dependencies []SkillDependency `yaml:"dependencies,omitempty" json:"dependencies,omitempty"`
|
||||||
|
Languages []string `yaml:"languages,omitempty" json:"languages,omitempty"`
|
||||||
|
Category string `yaml:"category,omitempty" json:"category,omitempty"`
|
||||||
|
Deployed bool `yaml:"-" json:"deployed,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ValidationError struct {
|
||||||
|
Field string `json:"field"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v ValidationError) Error() string {
|
||||||
|
return fmt.Sprintf("%s: %s", v.Field, v.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SkillTestResult struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Passed bool `json:"passed"`
|
||||||
|
Message string `json:"message"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func SkillsDir() (string, error) {
|
func SkillsDir() (string, error) {
|
||||||
@@ -66,10 +93,6 @@ func List() ([]Skill, error) {
|
|||||||
skills = append(skills, *skill)
|
skills = append(skills, *skill)
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Slice(skills, func(i, j int) bool {
|
|
||||||
return skills[i].Name < skills[j].Name
|
|
||||||
})
|
|
||||||
|
|
||||||
return skills, nil
|
return skills, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +118,10 @@ func Get(name string) (*Skill, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Create(skill *Skill) error {
|
func Create(skill *Skill) error {
|
||||||
|
if errs := Validate(skill); len(errs) > 0 {
|
||||||
|
return fmt.Errorf("validation failed: %v", errs)
|
||||||
|
}
|
||||||
|
|
||||||
dir, err := SkillsDir()
|
dir, err := SkillsDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -129,6 +156,49 @@ func Delete(name string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func IsDeployed(name string) bool {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
crushPath := filepath.Join(home, ".config", "crush", "skills", name, "SKILL.md")
|
||||||
|
claudePath := filepath.Join(home, ".claude", "skills", name, "SKILL.md")
|
||||||
|
_, crushErr := os.Stat(crushPath)
|
||||||
|
_, claudeErr := os.Stat(claudePath)
|
||||||
|
return crushErr == nil || claudeErr == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Undeploy(name string) error {
|
||||||
|
skill, err := Get(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
undeployFromTargets(skill.Name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Update(skill *Skill) error {
|
||||||
|
if errs := Validate(skill); len(errs) > 0 {
|
||||||
|
return fmt.Errorf("validation failed: %v", errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
dir, err := SkillsDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
skillDir := filepath.Join(dir, skill.Name)
|
||||||
|
skillPath := filepath.Join(skillDir, "SKILL.md")
|
||||||
|
|
||||||
|
skill.UpdatedAt = time.Now()
|
||||||
|
content := renderSkill(skill)
|
||||||
|
if err := os.WriteFile(skillPath, []byte(content), 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return Deploy(skill)
|
||||||
|
}
|
||||||
|
|
||||||
func Deploy(skill *Skill) error {
|
func Deploy(skill *Skill) error {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -188,6 +258,206 @@ func undeployFromTargets(name string) {
|
|||||||
os.RemoveAll(filepath.Join(home, ".claude", "skills", name))
|
os.RemoveAll(filepath.Join(home, ".claude", "skills", name))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Validate(skill *Skill) []ValidationError {
|
||||||
|
var errs []ValidationError
|
||||||
|
|
||||||
|
if skill.Name == "" {
|
||||||
|
errs = append(errs, ValidationError{Field: "name", Message: "name is required"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if skill.Name != "" {
|
||||||
|
if matched, _ := regexp.MatchString(`^[a-z0-9][a-z0-9-]*$`, skill.Name); !matched {
|
||||||
|
errs = append(errs, ValidationError{Field: "name", Message: "name must be lowercase alphanumeric with dashes"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if skill.Description == "" {
|
||||||
|
errs = append(errs, ValidationError{Field: "description", Message: "description is required"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if skill.Content == "" {
|
||||||
|
errs = append(errs, ValidationError{Field: "content", Message: "content is required"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if skill.Target != "" && skill.Target != "crush" && skill.Target != "claude" && skill.Target != "both" {
|
||||||
|
errs = append(errs, ValidationError{Field: "target", Message: "target must be crush, claude, or both"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if skill.Version != "" {
|
||||||
|
if matched, _ := regexp.MatchString(`^\d+\.\d+\.\d+$`, skill.Version); !matched {
|
||||||
|
errs = append(errs, ValidationError{Field: "version", Message: "version must be semver (e.g. 1.0.0)"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, dep := range skill.Dependencies {
|
||||||
|
if dep.Type != "mcp_server" && dep.Type != "lsp" && dep.Type != "tool" && dep.Type != "runtime" && dep.Type != "" {
|
||||||
|
errs = append(errs, ValidationError{
|
||||||
|
Field: fmt.Sprintf("dependencies[%d].type", i),
|
||||||
|
Message: "dependency type must be mcp_server, lsp, tool, or runtime",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if dep.Name == "" {
|
||||||
|
errs = append(errs, ValidationError{
|
||||||
|
Field: fmt.Sprintf("dependencies[%d].name", i),
|
||||||
|
Message: "dependency name is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckDependencies(skill *Skill) []SkillDependency {
|
||||||
|
var missing []SkillDependency
|
||||||
|
for _, dep := range skill.Dependencies {
|
||||||
|
switch dep.Type {
|
||||||
|
case "mcp_server":
|
||||||
|
if !isMCPServerAvailable(dep.Name) {
|
||||||
|
missing = append(missing, dep)
|
||||||
|
}
|
||||||
|
case "lsp", "tool", "runtime":
|
||||||
|
if !isToolAvailable(dep.Name) {
|
||||||
|
missing = append(missing, dep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return missing
|
||||||
|
}
|
||||||
|
|
||||||
|
func isToolAvailable(name string) bool {
|
||||||
|
_, err := lookPath(name)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookPath(name string) (string, error) {
|
||||||
|
pathEnv := os.Getenv("PATH")
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
if home != "" {
|
||||||
|
pathEnv = home + "/.local/bin:" + home + "/go/bin:" + pathEnv
|
||||||
|
}
|
||||||
|
for _, dir := range filepath.SplitList(pathEnv) {
|
||||||
|
candidate := filepath.Join(dir, name)
|
||||||
|
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||||
|
return candidate, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("%s not found", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isMCPServerAvailable(name string) bool {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
if home == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
configPath := filepath.Join(home, ".config", "crush", "crush.json")
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var cfg map[string]interface{}
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
mcps, ok := cfg["mcps"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, exists := mcps[name]
|
||||||
|
return exists
|
||||||
|
}
|
||||||
|
|
||||||
|
func Export(name string, exportPath string) error {
|
||||||
|
skill, err := Get(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(exportPath), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
content := renderSkill(skill)
|
||||||
|
return os.WriteFile(exportPath, []byte(content), 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Import(exportPath string) (*Skill, error) {
|
||||||
|
data, err := os.ReadFile(exportPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read export file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
skill, err := parseSkill(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
name := filepath.Base(filepath.Dir(exportPath))
|
||||||
|
if skill.Name == "" {
|
||||||
|
skill.Name = strings.TrimSuffix(filepath.Base(exportPath), ".md")
|
||||||
|
if skill.Name == "SKILL" {
|
||||||
|
skill.Name = filepath.Base(filepath.Dir(exportPath))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = name
|
||||||
|
if errs := Validate(skill); len(errs) > 0 {
|
||||||
|
return nil, fmt.Errorf("validation failed: %v", errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
return skill, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DryRun(name string, sampleTask string) SkillTestResult {
|
||||||
|
skill, err := Get(name)
|
||||||
|
if err != nil {
|
||||||
|
return SkillTestResult{Name: name, Passed: false, Message: fmt.Sprintf("skill not found: %s", err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
if skill.Content == "" {
|
||||||
|
return SkillTestResult{Name: name, Passed: false, Message: "skill has no content"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(skill.Dependencies) > 0 {
|
||||||
|
missing := CheckDependencies(skill)
|
||||||
|
if len(missing) > 0 {
|
||||||
|
var names []string
|
||||||
|
for _, d := range missing {
|
||||||
|
names = append(names, d.Name)
|
||||||
|
}
|
||||||
|
return SkillTestResult{
|
||||||
|
Name: name,
|
||||||
|
Passed: false,
|
||||||
|
Message: fmt.Sprintf("missing dependencies: %s", strings.Join(names, ", ")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if sampleTask != "" {
|
||||||
|
tags := skill.Tags
|
||||||
|
taskLower := strings.ToLower(sampleTask)
|
||||||
|
matched := false
|
||||||
|
for _, tag := range tags {
|
||||||
|
if strings.Contains(taskLower, strings.ToLower(tag)) {
|
||||||
|
matched = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(tags) > 0 && !matched {
|
||||||
|
return SkillTestResult{
|
||||||
|
Name: name,
|
||||||
|
Passed: true,
|
||||||
|
Message: "skill loaded but sample task does not match skill tags",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return SkillTestResult{
|
||||||
|
Name: name,
|
||||||
|
Passed: true,
|
||||||
|
Message: "skill validated successfully",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func parseSkill(data []byte) (*Skill, error) {
|
func parseSkill(data []byte) (*Skill, error) {
|
||||||
content := string(data)
|
content := string(data)
|
||||||
|
|
||||||
@@ -227,9 +497,25 @@ func renderSkill(skill *Skill) string {
|
|||||||
if skill.Target != "" {
|
if skill.Target != "" {
|
||||||
b.WriteString(fmt.Sprintf("target: %s\n", skill.Target))
|
b.WriteString(fmt.Sprintf("target: %s\n", skill.Target))
|
||||||
}
|
}
|
||||||
|
if skill.Category != "" {
|
||||||
|
b.WriteString(fmt.Sprintf("category: %s\n", skill.Category))
|
||||||
|
}
|
||||||
if len(skill.Tags) > 0 {
|
if len(skill.Tags) > 0 {
|
||||||
b.WriteString(fmt.Sprintf("tags: [%s]\n", strings.Join(skill.Tags, ", ")))
|
b.WriteString(fmt.Sprintf("tags: [%s]\n", strings.Join(skill.Tags, ", ")))
|
||||||
}
|
}
|
||||||
|
if len(skill.Languages) > 0 {
|
||||||
|
b.WriteString(fmt.Sprintf("languages: [%s]\n", strings.Join(skill.Languages, ", ")))
|
||||||
|
}
|
||||||
|
if len(skill.Dependencies) > 0 {
|
||||||
|
b.WriteString("dependencies:\n")
|
||||||
|
for _, dep := range skill.Dependencies {
|
||||||
|
req := ""
|
||||||
|
if dep.Required {
|
||||||
|
req = ", required: true"
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf(" - type: %s, name: %s%s\n", dep.Type, dep.Name, req))
|
||||||
|
}
|
||||||
|
}
|
||||||
b.WriteString("---\n\n")
|
b.WriteString("---\n\n")
|
||||||
b.WriteString(skill.Content)
|
b.WriteString(skill.Content)
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
@@ -245,7 +531,7 @@ DESCRIPTION: %s
|
|||||||
TARGET: %s (crush = Crush with GLM, claude = Claude Code, both = both tools)
|
TARGET: %s (crush = Crush with GLM, claude = Claude Code, both = both tools)
|
||||||
|
|
||||||
The skill must follow this EXACT format:
|
The skill must follow this EXACT format:
|
||||||
1. YAML frontmatter with: name, description
|
1. YAML frontmatter with: name, description, tags, dependencies (if needed)
|
||||||
2. Markdown body with detailed instructions
|
2. Markdown body with detailed instructions
|
||||||
|
|
||||||
The skill should be practical, specific, and actionable.
|
The skill should be practical, specific, and actionable.
|
||||||
@@ -255,5 +541,10 @@ Include:
|
|||||||
- Examples where relevant
|
- Examples where relevant
|
||||||
- Error handling guidance
|
- Error handling guidance
|
||||||
|
|
||||||
|
If the skill requires specific tools, MCP servers, or LSP servers, declare them as dependencies:
|
||||||
|
- type: mcp_server, name: <server-name>
|
||||||
|
- type: lsp, name: <language-server-name>
|
||||||
|
- type: tool, name: <tool-name>
|
||||||
|
|
||||||
Output ONLY the skill file content, starting with ---`, name, description, target)
|
Output ONLY the skill file content, starting with ---`, name, description, target)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ func TestCreateAndGet(t *testing.T) {
|
|||||||
Description: "Test description",
|
Description: "Test description",
|
||||||
Content: "Test content body",
|
Content: "Test content body",
|
||||||
Author: "tester",
|
Author: "tester",
|
||||||
Version: "0.1",
|
Version: "1.0.0",
|
||||||
Target: "both",
|
Target: "both",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,3 +198,242 @@ func TestInstallBuiltinSkills(t *testing.T) {
|
|||||||
t.Error("Expected env-setup skill")
|
t.Error("Expected env-setup skill")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidate(t *testing.T) {
|
||||||
|
skill := &Skill{
|
||||||
|
Name: "valid-skill",
|
||||||
|
Description: "A valid skill",
|
||||||
|
Content: "## Steps\nDo things",
|
||||||
|
Version: "1.0.0",
|
||||||
|
Target: "both",
|
||||||
|
}
|
||||||
|
|
||||||
|
errs := Validate(skill)
|
||||||
|
if len(errs) != 0 {
|
||||||
|
t.Errorf("Valid skill should have no errors, got %v", errs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateMissingFields(t *testing.T) {
|
||||||
|
skill := &Skill{}
|
||||||
|
errs := Validate(skill)
|
||||||
|
if len(errs) == 0 {
|
||||||
|
t.Error("Empty skill should have validation errors")
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := map[string]bool{}
|
||||||
|
for _, e := range errs {
|
||||||
|
fields[e.Field] = true
|
||||||
|
}
|
||||||
|
if !fields["name"] {
|
||||||
|
t.Error("Should require name")
|
||||||
|
}
|
||||||
|
if !fields["description"] {
|
||||||
|
t.Error("Should require description")
|
||||||
|
}
|
||||||
|
if !fields["content"] {
|
||||||
|
t.Error("Should require content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateBadVersion(t *testing.T) {
|
||||||
|
skill := &Skill{
|
||||||
|
Name: "test-skill",
|
||||||
|
Description: "desc",
|
||||||
|
Content: "content",
|
||||||
|
Version: "not-semver",
|
||||||
|
}
|
||||||
|
errs := Validate(skill)
|
||||||
|
hasVersionErr := false
|
||||||
|
for _, e := range errs {
|
||||||
|
if e.Field == "version" {
|
||||||
|
hasVersionErr = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasVersionErr {
|
||||||
|
t.Error("Should reject non-semver version")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateBadTarget(t *testing.T) {
|
||||||
|
skill := &Skill{
|
||||||
|
Name: "test",
|
||||||
|
Description: "desc",
|
||||||
|
Content: "content",
|
||||||
|
Target: "invalid",
|
||||||
|
}
|
||||||
|
errs := Validate(skill)
|
||||||
|
hasTargetErr := false
|
||||||
|
for _, e := range errs {
|
||||||
|
if e.Field == "target" {
|
||||||
|
hasTargetErr = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasTargetErr {
|
||||||
|
t.Error("Should reject invalid target")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateBadName(t *testing.T) {
|
||||||
|
skill := &Skill{
|
||||||
|
Name: "INVALID",
|
||||||
|
Description: "desc",
|
||||||
|
Content: "content",
|
||||||
|
}
|
||||||
|
errs := Validate(skill)
|
||||||
|
hasNameErr := false
|
||||||
|
for _, e := range errs {
|
||||||
|
if e.Field == "name" {
|
||||||
|
hasNameErr = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasNameErr {
|
||||||
|
t.Error("Should reject uppercase name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDependencies(t *testing.T) {
|
||||||
|
skill := &Skill{
|
||||||
|
Name: "test",
|
||||||
|
Description: "desc",
|
||||||
|
Content: "content",
|
||||||
|
Dependencies: []SkillDependency{
|
||||||
|
{Type: "mcp_server", Name: "github", Required: true},
|
||||||
|
{Type: "invalid_type", Name: "test"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
errs := Validate(skill)
|
||||||
|
hasDepErr := false
|
||||||
|
for _, e := range errs {
|
||||||
|
if e.Field == "dependencies[1].type" {
|
||||||
|
hasDepErr = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasDepErr {
|
||||||
|
t.Error("Should reject invalid dependency type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportImport(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
os.Setenv("HOME", tmpDir)
|
||||||
|
defer os.Setenv("HOME", tmpDir)
|
||||||
|
|
||||||
|
skill := &Skill{
|
||||||
|
Name: "export-test",
|
||||||
|
Description: "Export test skill",
|
||||||
|
Content: "## Content",
|
||||||
|
Author: "tester",
|
||||||
|
Version: "1.0.0",
|
||||||
|
Target: "both",
|
||||||
|
Tags: []string{"test"},
|
||||||
|
}
|
||||||
|
Create(skill)
|
||||||
|
|
||||||
|
exportPath := filepath.Join(tmpDir, "export", "export-test.md")
|
||||||
|
if err := Export("export-test", exportPath); err != nil {
|
||||||
|
t.Fatalf("Export failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(exportPath); os.IsNotExist(err) {
|
||||||
|
t.Error("Export file should exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
imported, err := Import(exportPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Import failed: %v", err)
|
||||||
|
}
|
||||||
|
if imported.Description != "Export test skill" {
|
||||||
|
t.Errorf("Expected 'Export test skill', got %s", imported.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDryRun(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
os.Setenv("HOME", tmpDir)
|
||||||
|
defer os.Setenv("HOME", tmpDir)
|
||||||
|
|
||||||
|
skill := &Skill{
|
||||||
|
Name: "dry-run-test",
|
||||||
|
Description: "Dry run test",
|
||||||
|
Content: "## Steps\nDo something",
|
||||||
|
Version: "1.0.0",
|
||||||
|
Target: "both",
|
||||||
|
Tags: []string{"test"},
|
||||||
|
}
|
||||||
|
Create(skill)
|
||||||
|
|
||||||
|
result := DryRun("dry-run-test", "test something")
|
||||||
|
if !result.Passed {
|
||||||
|
t.Errorf("DryRun should pass, got: %s", result.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDryRunMissing(t *testing.T) {
|
||||||
|
result := DryRun("nonexistent", "")
|
||||||
|
if result.Passed {
|
||||||
|
t.Error("DryRun of nonexistent skill should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdate(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
os.Setenv("HOME", tmpDir)
|
||||||
|
defer os.Setenv("HOME", tmpDir)
|
||||||
|
|
||||||
|
skill := &Skill{
|
||||||
|
Name: "update-test",
|
||||||
|
Description: "Original",
|
||||||
|
Content: "Original content",
|
||||||
|
Version: "1.0.0",
|
||||||
|
Target: "both",
|
||||||
|
}
|
||||||
|
Create(skill)
|
||||||
|
|
||||||
|
skill.Description = "Updated"
|
||||||
|
skill.Content = "Updated content"
|
||||||
|
skill.Version = "2.0.0"
|
||||||
|
if err := Update(skill); err != nil {
|
||||||
|
t.Fatalf("Update failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := Get("update-test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get failed: %v", err)
|
||||||
|
}
|
||||||
|
if got.Description != "Updated" {
|
||||||
|
t.Errorf("Expected 'Updated', got %s", got.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuiltinSkillCount(t *testing.T) {
|
||||||
|
if len(builtinSkills) < 5 {
|
||||||
|
t.Errorf("Expected at least 5 builtin skills, got %d", len(builtinSkills))
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedSkills := []string{"env-setup", "git-workflow", "api-design", "debug-assist", "code-review", "docker-setup", "security-audit", "mcp-setup", "lsp-setup", "workflow-design"}
|
||||||
|
for _, name := range expectedSkills {
|
||||||
|
found := false
|
||||||
|
for _, s := range builtinSkills {
|
||||||
|
if s.Name == name {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("Expected builtin skill: %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuiltinSkillsHaveDependencies(t *testing.T) {
|
||||||
|
hasDeps := 0
|
||||||
|
for _, s := range builtinSkills {
|
||||||
|
if len(s.Dependencies) > 0 {
|
||||||
|
hasDeps++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasDeps == 0 {
|
||||||
|
t.Error("At least some builtin skills should declare dependencies")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,33 @@
|
|||||||
package version
|
package version
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
Name = "muyue"
|
Name = "muyue"
|
||||||
Version = "0.3.0"
|
Version = "0.7.4"
|
||||||
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
|
||||||
|
}
|
||||||
|
|||||||
386
internal/workflow/engine.go
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
package workflow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/agent"
|
||||||
|
"github.com/muyue/muyue/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Status string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusPending Status = "pending"
|
||||||
|
StatusRunning Status = "running"
|
||||||
|
StatusDone Status = "done"
|
||||||
|
StatusFailed Status = "failed"
|
||||||
|
StatusSkipped Status = "skipped"
|
||||||
|
StatusAwaiting Status = "awaiting_approval"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StepType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TypeToolCall StepType = "tool_call"
|
||||||
|
TypeCondition StepType = "condition"
|
||||||
|
TypeParallel StepType = "parallel"
|
||||||
|
TypeApproval StepType = "approval"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Step struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type StepType `json:"type"`
|
||||||
|
Tool string `json:"tool,omitempty"`
|
||||||
|
Args json.RawMessage `json:"args,omitempty"`
|
||||||
|
Status Status `json:"status"`
|
||||||
|
Result string `json:"result,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Condition string `json:"condition,omitempty"`
|
||||||
|
DependsOn []string `json:"depends_on,omitempty"`
|
||||||
|
ApproveRole string `json:"approve_role,omitempty"`
|
||||||
|
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||||
|
EndedAt *time.Time `json:"ended_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Workflow struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Steps []Step `json:"steps"`
|
||||||
|
Status Status `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Engine struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
workflows map[string]*Workflow
|
||||||
|
agentRegistry *agent.Registry
|
||||||
|
storePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEngine(registry *agent.Registry) (*Engine, error) {
|
||||||
|
dir, err := config.ConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
dir = "/tmp/muyue"
|
||||||
|
}
|
||||||
|
|
||||||
|
storePath := filepath.Join(dir, "workflows.json")
|
||||||
|
engine := &Engine{
|
||||||
|
workflows: make(map[string]*Workflow),
|
||||||
|
agentRegistry: registry,
|
||||||
|
storePath: storePath,
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.load()
|
||||||
|
return engine, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) load() {
|
||||||
|
data, err := os.ReadFile(e.storePath)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var workflows []*Workflow
|
||||||
|
if err := json.Unmarshal(data, &workflows); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, w := range workflows {
|
||||||
|
e.workflows[w.ID] = w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) save() error {
|
||||||
|
dir := filepath.Dir(e.storePath)
|
||||||
|
os.MkdirAll(dir, 0755)
|
||||||
|
|
||||||
|
e.mu.RLock()
|
||||||
|
workflows := make([]*Workflow, 0, len(e.workflows))
|
||||||
|
for _, w := range e.workflows {
|
||||||
|
workflows = append(workflows, w)
|
||||||
|
}
|
||||||
|
e.mu.RUnlock()
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(workflows, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(e.storePath, data, 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Create(name, description, wfType string, steps []Step) *Workflow {
|
||||||
|
wf := &Workflow{
|
||||||
|
ID: fmt.Sprintf("wf-%d", time.Now().UnixNano()),
|
||||||
|
Name: name,
|
||||||
|
Description: description,
|
||||||
|
Type: wfType,
|
||||||
|
Steps: steps,
|
||||||
|
Status: StatusPending,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range wf.Steps {
|
||||||
|
if wf.Steps[i].ID == "" {
|
||||||
|
wf.Steps[i].ID = fmt.Sprintf("step-%d", i)
|
||||||
|
}
|
||||||
|
if wf.Steps[i].Status == "" {
|
||||||
|
wf.Steps[i].Status = StatusPending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
e.mu.Lock()
|
||||||
|
e.workflows[wf.ID] = wf
|
||||||
|
e.mu.Unlock()
|
||||||
|
|
||||||
|
e.save()
|
||||||
|
return wf
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Get(id string) (*Workflow, bool) {
|
||||||
|
e.mu.RLock()
|
||||||
|
defer e.mu.RUnlock()
|
||||||
|
wf, ok := e.workflows[id]
|
||||||
|
return wf, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) List() []*Workflow {
|
||||||
|
e.mu.RLock()
|
||||||
|
defer e.mu.RUnlock()
|
||||||
|
result := make([]*Workflow, 0, len(e.workflows))
|
||||||
|
for _, w := range e.workflows {
|
||||||
|
result = append(result, w)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Delete(id string) error {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
if _, ok := e.workflows[id]; !ok {
|
||||||
|
return fmt.Errorf("workflow not found: %s", id)
|
||||||
|
}
|
||||||
|
delete(e.workflows, id)
|
||||||
|
return e.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) UpdateStep(workflowID, stepID string, update func(*Step)) error {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
|
||||||
|
wf, ok := e.workflows[workflowID]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("workflow not found: %s", workflowID)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range wf.Steps {
|
||||||
|
if wf.Steps[i].ID == stepID {
|
||||||
|
update(&wf.Steps[i])
|
||||||
|
wf.UpdatedAt = time.Now()
|
||||||
|
e.save()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("step not found: %s", stepID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) UpdateWorkflowStatus(workflowID string, status Status) error {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
|
||||||
|
wf, ok := e.workflows[workflowID]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("workflow not found: %s", workflowID)
|
||||||
|
}
|
||||||
|
|
||||||
|
wf.Status = status
|
||||||
|
wf.UpdatedAt = time.Now()
|
||||||
|
return e.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Execute(ctx context.Context, workflowID string, onStep func(step *Step, event string)) error {
|
||||||
|
wf, ok := e.Get(workflowID)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("workflow not found: %s", workflowID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := e.UpdateWorkflowStatus(workflowID, StatusRunning); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
stepStatuses := make(map[string]Status)
|
||||||
|
for _, step := range wf.Steps {
|
||||||
|
stepStatuses[step.ID] = StatusPending
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveDeps := func(stepID string) (ready bool, blocked bool) {
|
||||||
|
step := wf.findStep(stepID)
|
||||||
|
if step == nil {
|
||||||
|
return false, true
|
||||||
|
}
|
||||||
|
for _, dep := range step.DependsOn {
|
||||||
|
depStatus := stepStatuses[dep]
|
||||||
|
if depStatus == StatusFailed || depStatus == StatusSkipped {
|
||||||
|
return false, true
|
||||||
|
}
|
||||||
|
if depStatus != StatusDone {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, false
|
||||||
|
}
|
||||||
|
|
||||||
|
executeStep := func(step *Step) error {
|
||||||
|
now := time.Now()
|
||||||
|
e.UpdateStep(workflowID, step.ID, func(s *Step) {
|
||||||
|
s.Status = StatusRunning
|
||||||
|
s.StartedAt = &now
|
||||||
|
})
|
||||||
|
|
||||||
|
if onStep != nil {
|
||||||
|
onStep(step, "started")
|
||||||
|
}
|
||||||
|
|
||||||
|
var result string
|
||||||
|
var stepErr error
|
||||||
|
|
||||||
|
switch step.Type {
|
||||||
|
case TypeToolCall:
|
||||||
|
if step.Tool == "" {
|
||||||
|
stepErr = fmt.Errorf("tool not specified for step %s", step.ID)
|
||||||
|
} else {
|
||||||
|
call := agent.ToolCall{
|
||||||
|
ID: step.ID,
|
||||||
|
Name: step.Tool,
|
||||||
|
Arguments: step.Args,
|
||||||
|
}
|
||||||
|
resp, err := e.agentRegistry.Execute(ctx, call)
|
||||||
|
if err != nil {
|
||||||
|
stepErr = err
|
||||||
|
} else {
|
||||||
|
result = resp.Content
|
||||||
|
if resp.IsError {
|
||||||
|
stepErr = fmt.Errorf("%s", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case TypeApproval:
|
||||||
|
e.UpdateStep(workflowID, step.ID, func(s *Step) {
|
||||||
|
s.Status = StatusAwaiting
|
||||||
|
})
|
||||||
|
if onStep != nil {
|
||||||
|
onStep(step, "awaiting_approval")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case TypeCondition:
|
||||||
|
result = fmt.Sprintf("condition '%s' evaluated", step.Condition)
|
||||||
|
|
||||||
|
default:
|
||||||
|
stepErr = fmt.Errorf("unknown step type: %s", step.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
endTime := time.Now()
|
||||||
|
if stepErr != nil {
|
||||||
|
e.UpdateStep(workflowID, step.ID, func(s *Step) {
|
||||||
|
s.Status = StatusFailed
|
||||||
|
s.Error = stepErr.Error()
|
||||||
|
s.EndedAt = &endTime
|
||||||
|
})
|
||||||
|
stepStatuses[step.ID] = StatusFailed
|
||||||
|
if onStep != nil {
|
||||||
|
onStep(step, "failed")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
e.UpdateStep(workflowID, step.ID, func(s *Step) {
|
||||||
|
s.Status = StatusDone
|
||||||
|
s.Result = result
|
||||||
|
s.EndedAt = &endTime
|
||||||
|
})
|
||||||
|
stepStatuses[step.ID] = StatusDone
|
||||||
|
if onStep != nil {
|
||||||
|
onStep(step, "done")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stepErr
|
||||||
|
}
|
||||||
|
|
||||||
|
hasFailures := false
|
||||||
|
|
||||||
|
for _, step := range wf.Steps {
|
||||||
|
if step.Type == TypeParallel {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ready, blocked := resolveDeps(step.ID)
|
||||||
|
if blocked {
|
||||||
|
e.UpdateStep(workflowID, step.ID, func(s *Step) {
|
||||||
|
s.Status = StatusSkipped
|
||||||
|
})
|
||||||
|
stepStatuses[step.ID] = StatusSkipped
|
||||||
|
if onStep != nil {
|
||||||
|
onStep(&step, "skipped")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !ready {
|
||||||
|
e.UpdateStep(workflowID, step.ID, func(s *Step) {
|
||||||
|
s.Status = StatusSkipped
|
||||||
|
s.Error = "dependency not satisfied at execution time"
|
||||||
|
})
|
||||||
|
stepStatuses[step.ID] = StatusSkipped
|
||||||
|
if onStep != nil {
|
||||||
|
onStep(&step, "skipped")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := executeStep(&step); err != nil {
|
||||||
|
hasFailures = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasFailures {
|
||||||
|
e.UpdateWorkflowStatus(workflowID, StatusFailed)
|
||||||
|
} else {
|
||||||
|
e.UpdateWorkflowStatus(workflowID, StatusDone)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Workflow) findStep(id string) *Step {
|
||||||
|
for i := range w.Steps {
|
||||||
|
if w.Steps[i].ID == id {
|
||||||
|
return &w.Steps[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) ApproveStep(workflowID, stepID string) error {
|
||||||
|
return e.UpdateStep(workflowID, stepID, func(s *Step) {
|
||||||
|
s.Status = StatusDone
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) SkipStep(workflowID, stepID string) error {
|
||||||
|
return e.UpdateStep(workflowID, stepID, func(s *Step) {
|
||||||
|
s.Status = StatusSkipped
|
||||||
|
})
|
||||||
|
}
|
||||||
176
internal/workflow/planner.go
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
package workflow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/config"
|
||||||
|
"github.com/muyue/muyue/internal/orchestrator"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Planner struct {
|
||||||
|
orchestrator *orchestrator.Orchestrator
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPlanner(cfg *config.MuyueConfig) (*Planner, error) {
|
||||||
|
orb, err := orchestrator.New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
orb.SetSystemPrompt(plannerSystemPrompt)
|
||||||
|
return &Planner{orchestrator: orb}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Planner) GeneratePlan(ctx context.Context, goal string) ([]Step, error) {
|
||||||
|
prompt := buildPlanPrompt(goal)
|
||||||
|
|
||||||
|
messages := []orchestrator.Message{
|
||||||
|
{Role: "user", Content: orchestrator.TextContent(prompt)},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := p.orchestrator.SendWithTools(messages)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Choices) == 0 || resp.Choices[0].Message.Content == "" {
|
||||||
|
return nil, fmt.Errorf("no plan generated")
|
||||||
|
}
|
||||||
|
|
||||||
|
content := resp.Choices[0].Message.Content
|
||||||
|
plan, err := parsePlanResponse(content)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return plan, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPlanPrompt(goal string) string {
|
||||||
|
return fmt.Sprintf(`Tu es un planificateur de workflows pour Muyue. L'utilisateur veut accomplir la tâche suivante:
|
||||||
|
|
||||||
|
"%s"
|
||||||
|
|
||||||
|
Analyse cette tâche et génère un plan d'exécution en une série d'étapes. Chaque étape est un appel d'outil.
|
||||||
|
|
||||||
|
Les outils disponibles sont:
|
||||||
|
- terminal: Exécuter une commande shell
|
||||||
|
- read_file: Lire un fichier
|
||||||
|
- list_files: Lister les fichiers d'un répertoire
|
||||||
|
- search_files: Rechercher des fichiers par pattern
|
||||||
|
- grep_content: Rechercher du texte dans des fichiers
|
||||||
|
- get_config: Lire la configuration Muyue
|
||||||
|
- set_provider: Configurer un provider AI
|
||||||
|
- manage_ssh: Gérer les connexions SSH
|
||||||
|
- web_fetch: Récupérer le contenu d'une URL
|
||||||
|
|
||||||
|
Réponds UNIQUEMENT avec un JSON valide représentant un tableau d'étapes, sans texte avant ou après:
|
||||||
|
|
||||||
|
[
|
||||||
|
{"name": "Nom de l'étape", "tool": "terminal", "args": {"command": "ls -la"}},
|
||||||
|
{"name": "Lire le fichier config", "tool": "read_file", "args": {"path": "~/.muyue/config.json"}}
|
||||||
|
]
|
||||||
|
|
||||||
|
Règles:
|
||||||
|
- Chaque étape doit avoir: name, tool, args
|
||||||
|
- Les args varient selon le tool (voir les définitions)
|
||||||
|
- Sois précis dans les commandes
|
||||||
|
- Sépare en étapes logiques
|
||||||
|
- Ne génère pas plus de 10 étapes`, goal)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePlanResponse(content string) ([]Step, error) {
|
||||||
|
content = strings.TrimSpace(content)
|
||||||
|
|
||||||
|
var jsonStr string
|
||||||
|
if strings.HasPrefix(content, "```json") {
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
var jsonLines []string
|
||||||
|
for _, line := range lines[1:] {
|
||||||
|
if strings.HasPrefix(line, "```") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
jsonLines = append(jsonLines, line)
|
||||||
|
}
|
||||||
|
jsonStr = strings.Join(jsonLines, "\n")
|
||||||
|
} else if strings.HasPrefix(content, "```") {
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
var jsonLines []string
|
||||||
|
for _, line := range lines[1:] {
|
||||||
|
if strings.HasPrefix(line, "```") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
jsonLines = append(jsonLines, line)
|
||||||
|
}
|
||||||
|
jsonStr = strings.Join(jsonLines, "\n")
|
||||||
|
} else {
|
||||||
|
jsonStr = content
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawSteps []map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(jsonStr), &rawSteps); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse plan JSON: %v\nContent: %s", err, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
steps := make([]Step, 0, len(rawSteps))
|
||||||
|
for i, raw := range rawSteps {
|
||||||
|
step := Step{
|
||||||
|
ID: fmt.Sprintf("step-%d", i),
|
||||||
|
Status: StatusPending,
|
||||||
|
}
|
||||||
|
|
||||||
|
if name, ok := raw["name"].(string); ok {
|
||||||
|
step.Name = name
|
||||||
|
} else {
|
||||||
|
step.Name = fmt.Sprintf("Step %d", i+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tool, ok := raw["tool"].(string); ok {
|
||||||
|
step.Tool = tool
|
||||||
|
step.Type = TypeToolCall
|
||||||
|
}
|
||||||
|
|
||||||
|
if args, ok := raw["args"].(map[string]interface{}); ok {
|
||||||
|
argsJSON, err := json.Marshal(args)
|
||||||
|
if err == nil {
|
||||||
|
step.Args = argsJSON
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tool, ok := raw["type"].(string); ok {
|
||||||
|
switch tool {
|
||||||
|
case "approval":
|
||||||
|
step.Type = TypeApproval
|
||||||
|
case "condition":
|
||||||
|
step.Type = TypeCondition
|
||||||
|
if cond, ok := raw["condition"].(string); ok {
|
||||||
|
step.Condition = cond
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
step.Type = TypeToolCall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
steps = append(steps, step)
|
||||||
|
}
|
||||||
|
|
||||||
|
return steps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const plannerSystemPrompt = `Tu es un planificateur de workflows pour Muyue. Tu génères des plans d'exécution sous forme de tableaux JSON.
|
||||||
|
|
||||||
|
RÈGLES :
|
||||||
|
1. Analyse l'objectif → identifie les outils → décompose en étapes
|
||||||
|
2. Chaque étape : {"name": string, "tool": string, "args": object}
|
||||||
|
3. Max 10 étapes par plan
|
||||||
|
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
|
||||||
|
|
||||||
|
Outils : terminal, crush_run, read_file, list_files, search_files, grep_content, get_config, set_provider, manage_ssh, web_fetch
|
||||||
|
|
||||||
|
Réponds UNIQUEMENT en JSON valide, sans texte avant/après.`
|
||||||
|
|
||||||
|
const _ = plannerSystemPrompt
|
||||||
1
web/.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
legacy-peer-deps=true
|
||||||
@@ -4,8 +4,11 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#0A0A0C" />
|
<meta name="theme-color" content="#0A0A0C" />
|
||||||
<title>muyue</title>
|
<title>Muyue</title>
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⬡</text></svg>" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/muyue.png" />
|
||||||
|
<link rel="shortcut icon" href="/muyue.png" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
1301
web/package-lock.json
generated
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
BIN
web/public/favicon-16.png
Normal file
|
After Width: | Height: | Size: 750 B |
BIN
web/public/favicon-32.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
web/public/muyue-64.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
web/public/muyue.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
@@ -2,8 +2,8 @@ const API_BASE = '/api'
|
|||||||
|
|
||||||
async function request(path, options = {}) {
|
async function request(path, options = {}) {
|
||||||
const res = await fetch(`${API_BASE}${path}`, {
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
...options,
|
...options,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||||
@@ -22,28 +22,62 @@ const api = {
|
|||||||
getLSP: () => request('/lsp'),
|
getLSP: () => request('/lsp'),
|
||||||
getMCP: () => request('/mcp'),
|
getMCP: () => request('/mcp'),
|
||||||
getUpdates: () => request('/updates'),
|
getUpdates: () => request('/updates'),
|
||||||
|
getEditors: () => request('/editors'),
|
||||||
runScan: () => request('/scan', { method: 'POST' }),
|
runScan: () => request('/scan', { method: 'POST' }),
|
||||||
installTools: (tools) => request('/install', { method: 'POST', body: JSON.stringify({ tools }) }),
|
installTools: (tools) => request('/install', { method: 'POST', body: JSON.stringify({ tools }) }),
|
||||||
configureMCP: () => request('/mcp/configure', { method: 'POST' }),
|
configureMCP: () => request('/mcp/configure', { method: 'POST' }),
|
||||||
|
configureMCPForEditor: (editor) => request('/mcp/configure', { method: 'POST', body: JSON.stringify({ editor }) }),
|
||||||
|
getMCPStatus: () => request('/mcp/status'),
|
||||||
|
getMCPRegistry: () => request('/mcp/registry'),
|
||||||
|
getLSPHealth: () => request('/lsp/health'),
|
||||||
|
autoInstallLSP: (projectDir) => request('/lsp/auto-install', { method: 'POST', body: JSON.stringify({ project_dir: projectDir || '' }) }),
|
||||||
|
generateLSPConfig: (editor, names) => request('/lsp/editor-config', { method: 'POST', body: JSON.stringify({ editor, names }) }),
|
||||||
|
validateSkill: (name) => request('/skills/validate', { method: 'POST', body: JSON.stringify({ name }) }),
|
||||||
|
testSkill: (name, sampleTask) => request('/skills/test', { method: 'POST', body: JSON.stringify({ name, sample_task: sampleTask || '' }) }),
|
||||||
|
exportSkill: (name) => request('/skills/export', { method: 'POST', body: JSON.stringify({ name }) }),
|
||||||
|
importSkill: (path) => request('/skills/import', { method: 'POST', body: JSON.stringify({ import_path: path }) }),
|
||||||
|
deploySkill: (name) => request('/skills/deploy', { method: 'POST', body: JSON.stringify({ name }) }),
|
||||||
|
undeploySkill: (name) => request('/skills/undeploy', { method: 'POST', body: JSON.stringify({ name }) }),
|
||||||
|
getDashboardStatus: () => request('/dashboard/status'),
|
||||||
|
getProvidersQuota: () => request('/providers/quota'),
|
||||||
|
getProvidersConsumption: () => request('/providers/consumption'),
|
||||||
|
getRecentCommands: () => request('/recent-commands'),
|
||||||
|
getRunningProcesses: () => request('/running-processes'),
|
||||||
|
getSystemMetrics: () => request('/system/metrics'),
|
||||||
|
getTestSnippet: () => request('/test/snippet'),
|
||||||
|
getTestSessions: () => request('/test/sessions'),
|
||||||
|
getTestConsole: (sessionId) => request(`/test/console/${encodeURIComponent(sessionId || '')}`),
|
||||||
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) }),
|
||||||
|
resetConfig: () => request('/config/reset', { method: 'POST' }),
|
||||||
|
applyStarshipTheme: (theme) => request('/starship/apply-theme', { method: 'POST', body: JSON.stringify({ theme }) }),
|
||||||
|
validateProvider: (provider) => request('/providers/validate', { method: 'POST', body: JSON.stringify(provider) }),
|
||||||
runUpdate: (tool) => request('/update/run', { method: 'POST', body: JSON.stringify({ tool: tool || '' }) }),
|
runUpdate: (tool) => request('/update/run', { method: 'POST', body: JSON.stringify({ tool: tool || '' }) }),
|
||||||
|
aiTask: (task, tool) => request('/ai/task', { method: 'POST', body: JSON.stringify({ task, tool: tool || '' }) }),
|
||||||
runCommand: (command, cwd) => request('/terminal', { method: 'POST', body: JSON.stringify({ command, cwd }) }),
|
runCommand: (command, cwd) => request('/terminal', { method: 'POST', body: JSON.stringify({ command, cwd }) }),
|
||||||
getTerminalSessions: () => request('/terminal/sessions'),
|
getTerminalSessions: () => request('/terminal/sessions'),
|
||||||
addSSHConnection: (conn) => request('/terminal/sessions', { method: 'POST', body: JSON.stringify(conn) }),
|
addSSHConnection: (conn) => request('/terminal/sessions', { method: 'POST', body: JSON.stringify(conn) }),
|
||||||
deleteSSHConnection: (name) => request(`/terminal/sessions/${encodeURIComponent(name)}`, { method: 'DELETE' }),
|
deleteSSHConnection: (name) => request(`/terminal/sessions/${encodeURIComponent(name)}`, { method: 'DELETE' }),
|
||||||
|
getTerminalThemes: () => request('/terminal/themes'),
|
||||||
|
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) => {
|
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, images = [], advancedReflection = false) => {
|
||||||
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, images, advanced_reflection: advancedReflection }) })
|
||||||
}
|
}
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
fetch(`${API_BASE}/chat`, {
|
fetch(`${API_BASE}/chat`, {
|
||||||
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, images, advanced_reflection: advancedReflection }),
|
||||||
|
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 }))
|
||||||
@@ -63,7 +97,14 @@ const api = {
|
|||||||
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) { resolve(full); return }
|
if (data.done) { resolve(full); return }
|
||||||
if (data.content) full += data.content
|
if (data.content) {
|
||||||
|
full += data.content
|
||||||
|
if (onChunk) onChunk(full, data)
|
||||||
|
} else if (data.thinking !== undefined || data.thinking_end) {
|
||||||
|
if (onChunk) onChunk(full, data)
|
||||||
|
} else if (data.tool_call || data.tool_result) {
|
||||||
|
if (onChunk) onChunk(full, data)
|
||||||
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,6 +112,56 @@ const api = {
|
|||||||
}).catch(reject)
|
}).catch(reject)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
sendShellChat: (message, context = {}, stream = true, onChunk, signal) => {
|
||||||
|
const payload = {
|
||||||
|
message,
|
||||||
|
cwd: context.cwd || '',
|
||||||
|
platform: context.platform || '',
|
||||||
|
stream,
|
||||||
|
}
|
||||||
|
if (!stream) {
|
||||||
|
return request('/shell/chat', { method: 'POST', body: JSON.stringify(payload) })
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
fetch(`${API_BASE}/shell/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal,
|
||||||
|
}).then(async (res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||||
|
reject(new Error(err.error || res.statusText))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const reader = res.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let full = ''
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
const text = decoder.decode(value, { stream: true })
|
||||||
|
for (const line of text.split('\n')) {
|
||||||
|
if (!line.startsWith('data: ')) continue
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(line.slice(6))
|
||||||
|
if (data.error) { reject(new Error(data.error)); return }
|
||||||
|
if (data.done) { resolve({ content: full, tokens: data.tokens }); return }
|
||||||
|
if (data.content) {
|
||||||
|
full += data.content
|
||||||
|
if (onChunk) onChunk(full, data)
|
||||||
|
} else if (data.tool_call || data.tool_result) {
|
||||||
|
if (onChunk) onChunk(full, data)
|
||||||
|
} else if (data.thinking !== undefined || data.thinking_end) {
|
||||||
|
if (onChunk) onChunk(full, data)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolve({ content: full })
|
||||||
|
}).catch(reject)
|
||||||
|
})
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export default api
|
export default api
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
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, TestTube2 } from 'lucide-react'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
import { getTheme, applyTheme } from '../themes'
|
import { getTheme, applyTheme } from '../themes'
|
||||||
import { useI18n } from '../i18n'
|
import { useI18n } from '../i18n'
|
||||||
@@ -7,27 +7,40 @@ import Dashboard from './Dashboard'
|
|||||||
import Studio from './Studio'
|
import Studio from './Studio'
|
||||||
import Shell from './Shell'
|
import Shell from './Shell'
|
||||||
import Config from './Config'
|
import Config from './Config'
|
||||||
|
import Tests from './Tests'
|
||||||
|
import OnboardingWizard from './OnboardingWizard'
|
||||||
|
|
||||||
export default function App() {
|
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 [updates, setUpdates] = useState([])
|
const [isSudo, setIsSudo] = useState(false)
|
||||||
const [tools, setTools] = useState([])
|
const [dashRefreshKey, setDashRefreshKey] = useState(0)
|
||||||
|
const dashRefreshRef = useRef(null)
|
||||||
|
const [config, setConfig] = useState(null)
|
||||||
|
const [showOnboarding, setShowOnboarding] = useState(false)
|
||||||
const { t, layout } = useI18n()
|
const { t, layout } = useI18n()
|
||||||
|
|
||||||
const TABS = useMemo(() => [
|
const TABS = useMemo(() => [
|
||||||
{ id: 'dash', label: t('tabs.dashboard'), icon: <LayoutDashboard size={15} /> },
|
{ id: 'dash', label: t('tabs.dashboard'), icon: <LayoutDashboard size={15} /> },
|
||||||
{ id: 'studio', label: t('tabs.studio'), icon: <Sparkles size={15} /> },
|
{ id: 'studio', label: t('tabs.studio'), icon: <Sparkles size={15} /> },
|
||||||
{ id: 'shell', label: t('tabs.shell'), icon: <Terminal size={15} /> },
|
{ id: 'shell', label: t('tabs.shell'), icon: <Terminal size={15} /> },
|
||||||
|
{ id: 'tests', label: 'Tests', icon: <TestTube2 size={15} /> },
|
||||||
{ id: 'config', label: t('tabs.config'), icon: <Settings size={15} /> },
|
{ id: 'config', label: t('tabs.config'), icon: <Settings size={15} /> },
|
||||||
], [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.getConfig().then(d => {
|
||||||
api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
|
setConfig(d)
|
||||||
applyTheme(getTheme('cyberpunk-red'))
|
const theme = d.profile?.preferences?.theme || 'cyberpunk-red'
|
||||||
|
applyTheme(getTheme(theme))
|
||||||
|
const hasProfile = d.profile?.name || d.profile?.pseudo
|
||||||
|
if (!hasProfile) setShowOnboarding(true)
|
||||||
|
}).catch(() => {
|
||||||
|
applyTheme(getTheme('cyberpunk-red'))
|
||||||
|
setShowOnboarding(true)
|
||||||
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -43,11 +56,17 @@ export default function App() {
|
|||||||
Digit1: 'dash',
|
Digit1: 'dash',
|
||||||
Digit2: 'studio',
|
Digit2: 'studio',
|
||||||
Digit3: 'shell',
|
Digit3: 'shell',
|
||||||
Digit4: 'config',
|
Digit4: 'tests',
|
||||||
|
Digit5: 'config',
|
||||||
}
|
}
|
||||||
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)
|
||||||
@@ -56,42 +75,35 @@ export default function App() {
|
|||||||
|
|
||||||
const switchTab = useCallback((tabId) => setActiveTab(tabId), [])
|
const switchTab = useCallback((tabId) => setActiveTab(tabId), [])
|
||||||
|
|
||||||
const hasUpdates = updates.some(u => u.needsUpdate)
|
useEffect(() => {
|
||||||
const installed = tools.filter(t => t.installed).length
|
const handler = () => setActiveTab('shell')
|
||||||
|
window.addEventListener('navigate-to-shell', handler)
|
||||||
|
return () => window.removeEventListener('navigate-to-shell', handler)
|
||||||
|
}, [])
|
||||||
|
|
||||||
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') },
|
|
||||||
],
|
],
|
||||||
|
tests: [],
|
||||||
|
config: [],
|
||||||
}), [layout, t])
|
}), [layout, t])
|
||||||
|
|
||||||
const renderContent = () => {
|
|
||||||
switch (activeTab) {
|
|
||||||
case 'dash': return <Dashboard tools={tools} updates={updates} api={api} onRescan={t => setTools(t)} />
|
|
||||||
case 'studio': return <Studio api={api} />
|
|
||||||
case 'shell': return <Shell api={api} />
|
|
||||||
case 'config': return <Config api={api} onThemeChange={() => {}} />
|
|
||||||
default: return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-layout">
|
<div className="app-layout">
|
||||||
<header className="header">
|
<header className="header">
|
||||||
<div className="header-brand">
|
<div className="header-brand">
|
||||||
|
<img src="/muyue-64.png" alt="Muyue" className="header-logo-img" width="22" height="22" style={{ borderRadius: 4, verticalAlign: 'middle' }} />
|
||||||
<span className="header-logo">MUYUE</span>
|
<span className="header-logo">MUYUE</span>
|
||||||
<span className="header-version">v{info.version || '...'}</span>
|
<span className="header-version">v{info.version || '...'}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,28 +125,27 @@ export default function App() {
|
|||||||
|
|
||||||
<div className="header-spacer" />
|
<div className="header-spacer" />
|
||||||
|
|
||||||
<div className="header-indicators">
|
|
||||||
<span
|
|
||||||
className={`indicator ${installed > 0 ? 'ok' : 'off'}`}
|
|
||||||
title={t('header.toolsInstalled', { count: installed })}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className={`indicator ${hasUpdates ? 'warn' : 'ok'}`}
|
|
||||||
title={hasUpdates ? t('header.updatesAvailable') : t('header.upToDate')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span className="header-clock">
|
<span className="header-clock">
|
||||||
{clock.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit' })}
|
{clock.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit' })}
|
||||||
</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} isSudo={isSudo} /></div>
|
||||||
|
<div className={activeTab === 'tests' ? '' : 'tab-hidden'}><Tests 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">⚡ SUDO</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">
|
||||||
@@ -143,6 +154,8 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
{showOnboarding && <OnboardingWizard api={api} onComplete={() => setShowOnboarding(false)} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { User, Brain, RefreshCw, Globe, Wrench } from 'lucide-react'
|
import { User, Brain, Wrench, Monitor, AlertTriangle, Bot, Sparkles, Zap, GitBranch, Container, Circle, Hexagon, Code, Rocket, Download } 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: 'locale', icon: Globe },
|
|
||||||
{ id: 'skills', icon: Wrench },
|
{ id: 'skills', icon: Wrench },
|
||||||
|
{ id: 'system', icon: Monitor },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function Config({ api }) {
|
export default function Config({ api }) {
|
||||||
@@ -17,33 +15,22 @@ export default function Config({ api }) {
|
|||||||
const [config, setConfig] = useState(null)
|
const [config, setConfig] = useState(null)
|
||||||
const [providers, setProviders] = useState([])
|
const [providers, setProviders] = useState([])
|
||||||
const [skillList, setSkillList] = useState([])
|
const [skillList, setSkillList] = useState([])
|
||||||
const [updates, setUpdates] = useState([])
|
|
||||||
const [tools, setTools] = useState([])
|
|
||||||
const [checking, setChecking] = useState(false)
|
|
||||||
const [updating, setUpdating] = useState(null)
|
|
||||||
const [editProfile, setEditProfile] = useState(false)
|
const [editProfile, setEditProfile] = useState(false)
|
||||||
const [editProvider, setEditProvider] = useState(null)
|
const [editProvider, setEditProvider] = useState(null)
|
||||||
const [profileForm, setProfileForm] = useState({})
|
const [profileForm, setProfileForm] = useState({})
|
||||||
const [providerForm, setProviderForm] = useState({})
|
const [providerForm, setProviderForm] = useState({}) // keyed by provider name
|
||||||
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(() => {})
|
||||||
api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
|
|
||||||
api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
|
|
||||||
}, [api])
|
}, [api])
|
||||||
|
|
||||||
useEffect(() => { loadData() }, [loadData])
|
useEffect(() => { loadData() }, [loadData])
|
||||||
@@ -53,44 +40,6 @@ export default function Config({ api }) {
|
|||||||
setTimeout(() => setToast(null), 2500)
|
setTimeout(() => setToast(null), 2500)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCheckUpdates = async () => {
|
|
||||||
setChecking(true)
|
|
||||||
try {
|
|
||||||
await api.runScan()
|
|
||||||
const d = await api.getUpdates()
|
|
||||||
setUpdates(d.updates || [])
|
|
||||||
const td = await api.getTools()
|
|
||||||
setTools(td.tools || [])
|
|
||||||
showToast(t('config.upToDate'))
|
|
||||||
} catch (err) {
|
|
||||||
showToast(`${t('config.error')}: ${err.message}`)
|
|
||||||
}
|
|
||||||
setChecking(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUpdateTool = async (tool) => {
|
|
||||||
setUpdating(tool)
|
|
||||||
try {
|
|
||||||
await api.runUpdate(tool)
|
|
||||||
await handleCheckUpdates()
|
|
||||||
showToast(`${tool} ✓`)
|
|
||||||
} catch (err) {
|
|
||||||
showToast(`${t('config.error')}: ${err.message}`)
|
|
||||||
}
|
|
||||||
setUpdating(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUpdateAll = async () => {
|
|
||||||
setUpdating('__all__')
|
|
||||||
try {
|
|
||||||
await api.runUpdate('')
|
|
||||||
await handleCheckUpdates()
|
|
||||||
showToast(t('config.saved'))
|
|
||||||
} catch (err) {
|
|
||||||
showToast(`${t('config.error')}: ${err.message}`)
|
|
||||||
}
|
|
||||||
setUpdating(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSaveProfile = async () => {
|
const handleSaveProfile = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -103,9 +52,11 @@ export default function Config({ api }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSaveProvider = async () => {
|
const handleSaveProvider = async (name) => {
|
||||||
|
const form = providerForm[name]
|
||||||
|
if (!form) return
|
||||||
try {
|
try {
|
||||||
await api.saveProvider(providerForm)
|
await api.saveProvider({ name, ...form })
|
||||||
setEditProvider(null)
|
setEditProvider(null)
|
||||||
loadData()
|
loadData()
|
||||||
showToast(t('config.saved'))
|
showToast(t('config.saved'))
|
||||||
@@ -115,44 +66,41 @@ export default function Config({ api }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openProviderEdit = (p) => {
|
const openProviderEdit = (p) => {
|
||||||
setProviderForm({
|
setProviderForm(prev => ({
|
||||||
name: p.name,
|
...prev,
|
||||||
api_key: p.apiKey || '',
|
[p.name]: {
|
||||||
model: p.model || '',
|
name: p.name,
|
||||||
base_url: p.baseURL || '',
|
api_key: p.api_key || '',
|
||||||
})
|
model: p.model || '',
|
||||||
|
base_url: p.base_url || '',
|
||||||
|
},
|
||||||
|
}))
|
||||||
setEditProvider(p.name)
|
setEditProvider(p.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
const needsUpdateCount = updates.filter(u => u.needsUpdate).length
|
|
||||||
const installedCount = tools.filter(t => t.installed).length
|
|
||||||
const missingCount = tools.filter(t => !t.installed).length
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="config-window">
|
<div className="config-window">
|
||||||
{toast && <div className="config-toast">{toast}</div>}
|
{toast && <div className="config-toast">{toast}</div>}
|
||||||
|
|
||||||
<div className="config-sidebar">
|
<div className="config-tabs-bar">
|
||||||
{PANELS.map(p => {
|
{PANELS.map(p => {
|
||||||
const Icon = p.icon
|
const Icon = p.icon
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={p.id}
|
key={p.id}
|
||||||
className={`config-sidebar-item ${activePanel === p.id ? 'active' : ''}`}
|
className={`nav-tab ${activePanel === p.id ? 'active' : ''}`}
|
||||||
onClick={() => setActivePanel(p.id)}
|
onClick={() => setActivePanel(p.id)}
|
||||||
>
|
>
|
||||||
<Icon size={16} />
|
<span className="tab-icon"><Icon size={15} /></span>
|
||||||
<span>{t(`config.panels.${p.id}`)}</span>
|
{t(`config.panels.${p.id}`)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="config-panel-area">
|
<div className="config-panel-area">
|
||||||
<div className="config-panel-header">
|
|
||||||
<h2 className="config-panel-title">{t(`config.panels.${activePanel}`)}</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="config-panel-body">
|
<div className="config-panel-body">
|
||||||
{activePanel === 'profile' && (
|
{activePanel === 'profile' && (
|
||||||
<PanelProfile
|
<PanelProfile
|
||||||
@@ -171,28 +119,13 @@ export default function Config({ api }) {
|
|||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activePanel === 'updates' && (
|
|
||||||
<PanelUpdates
|
|
||||||
updates={updates} tools={tools}
|
|
||||||
checking={checking} updating={updating}
|
|
||||||
needsUpdateCount={needsUpdateCount}
|
|
||||||
installedCount={installedCount} missingCount={missingCount}
|
|
||||||
handleCheckUpdates={handleCheckUpdates}
|
|
||||||
handleUpdateTool={handleUpdateTool}
|
|
||||||
handleUpdateAll={handleUpdateAll}
|
|
||||||
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} api={api} loadData={loadData} t={t} />
|
||||||
)}
|
)}
|
||||||
|
{activePanel === 'system' && (
|
||||||
|
<PanelSystem api={api} t={t} />
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -200,218 +133,486 @@ 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 (
|
||||||
|
<div className="config-profile-center">
|
||||||
|
<div className="config-card">
|
||||||
|
<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 (
|
return (
|
||||||
<div className="config-card">
|
<div className="config-profile-center">
|
||||||
{config?.profile && !editProfile ? (
|
<div className="config-card">
|
||||||
<>
|
<div className="section-title">{t('config.profileInfo') || 'Informations personnelles'}</div>
|
||||||
<div className="config-card-row">
|
<RenderFields obj={personalObj} path="" editing={editProfile} onChange={updateField} t={t} />
|
||||||
<span className="config-card-label">{t('config.name')}</span>
|
</div>
|
||||||
<span className="config-card-value">{config.profile.name || '—'}</span>
|
<div className="config-card">
|
||||||
</div>
|
<div className="section-title">{t('config.profilePrefs') || 'Préférences'}</div>
|
||||||
<div className="config-card-row">
|
{preferences ? (
|
||||||
<span className="config-card-label">{t('config.pseudo')}</span>
|
<RenderFields obj={preferences} path="preferences" editing={editProfile} onChange={updateField} t={t} />
|
||||||
<span className="config-card-value">{config.profile.pseudo || '—'}</span>
|
) : (
|
||||||
</div>
|
<div className="config-card-row"><span className="config-card-value" style={{ color: 'var(--text-disabled)' }}>—</span></div>
|
||||||
<div className="config-card-row">
|
)}
|
||||||
<span className="config-card-label">{t('config.email')}</span>
|
</div>
|
||||||
<span className="config-card-value">{config.profile.email || '—'}</span>
|
<div className="config-card">
|
||||||
</div>
|
<div className="config-card-actions" style={{ justifyContent: 'center' }}>
|
||||||
<div className="config-card-row">
|
{editProfile ? (
|
||||||
<span className="config-card-label">{t('config.editor')}</span>
|
<>
|
||||||
<span className="config-card-value mono">{config.profile.preferences?.editor || '—'}</span>
|
<button className="primary sm" onClick={handleSaveProfile}>{t('config.save')}</button>
|
||||||
</div>
|
<button className="ghost sm" onClick={() => setEditProfile(false)}>{t('config.cancel')}</button>
|
||||||
<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>
|
<button className="primary sm" onClick={() => {
|
||||||
</div>
|
setProfileForm(config.profile ? JSON.parse(JSON.stringify(config.profile)) : {})
|
||||||
<div className="config-card-row">
|
setEditProfile(true)
|
||||||
<span className="config-card-label">{t('config.languages')}</span>
|
}}>{t('config.editProfile')}</button>
|
||||||
<span className="config-card-value">{config.profile.languages?.join(', ') || '—'}</span>
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="config-card-actions">
|
</div>
|
||||||
<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="ghost sm" onClick={() => setEditProfile(false)}>{t('config.cancel')}</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="empty-state">{t('config.loadingProfile')}</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 [keyStatus, setKeyStatus] = useState({})
|
||||||
|
|
||||||
|
const validateKey = async (p) => {
|
||||||
|
setValidating(p.name)
|
||||||
|
try {
|
||||||
|
await api.validateProvider({ name: p.name, api_key: p.api_key, model: p.model, base_url: p.base_url || '' })
|
||||||
|
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: true, checked: true } }))
|
||||||
|
} catch (err) {
|
||||||
|
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: err.message || 'Clé invalide' } }))
|
||||||
|
}
|
||||||
|
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">
|
||||||
{providers.map((p, i) => (
|
{displayed.map((p, i) => {
|
||||||
<div key={i} className="config-card provider-card-v2">
|
const isEditing = editProvider === p.name
|
||||||
<div className="provider-card-top">
|
const currentModel = providerForm[p.name]?.model || p.model
|
||||||
<div className="provider-card-identity">
|
const status = keyStatus[p.name]
|
||||||
<span className="provider-card-name">{p.name}</span>
|
|
||||||
{p.active && <span className="badge accent">{t('config.active')}</span>}
|
|
||||||
</div>
|
|
||||||
<div className="provider-card-actions">
|
|
||||||
{editProvider !== p.name && (
|
|
||||||
<button className="ghost sm" onClick={() => openProviderEdit(p)}>{t('config.editProvider')}</button>
|
|
||||||
)}
|
|
||||||
{!p.active && editProvider !== p.name && (
|
|
||||||
<button className="sm" onClick={async () => {
|
|
||||||
await api.saveProvider({ name: p.name, active: true })
|
|
||||||
loadData()
|
|
||||||
}}>{t('config.activate')}</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{editProvider !== p.name ? (
|
return (
|
||||||
<div className="provider-card-meta">
|
<div key={i} className="config-card provider-card-v2">
|
||||||
<span className="mono">{p.model || '—'}</span>
|
<div className="provider-card-top">
|
||||||
<span style={{ color: p.apiKey ? 'var(--success)' : 'var(--error)' }}>
|
<div className="provider-card-identity">
|
||||||
{p.apiKey ? t('config.keyConfigured') : t('config.noKey')}
|
<span className="provider-card-name">{p.name.toUpperCase()}</span>
|
||||||
</span>
|
{p.active && <span className="badge ok" style={{ marginLeft: 6 }}>active</span>}
|
||||||
</div>
|
{status?.checked && status?.valid && <span className="badge ok">✓ {t('config.keyValid')}</span>}
|
||||||
) : (
|
{status?.checked && !status?.valid && <span className="badge error">✗ {status.error || t('config.keyInvalid')}</span>}
|
||||||
<div className="provider-card-form">
|
|
||||||
<FormInput label={t('config.apiKey')} value={providerForm.api_key} onChange={v => setProviderForm(f => ({ ...f, api_key: v }))} type="password" />
|
|
||||||
<FormInput label={t('config.model')} value={providerForm.model} onChange={v => setProviderForm(f => ({ ...f, model: v }))} />
|
|
||||||
<FormInput label={t('config.baseUrl')} value={providerForm.base_url} onChange={v => setProviderForm(f => ({ ...f, base_url: v }))} />
|
|
||||||
<div className="config-card-actions">
|
|
||||||
<button className="primary sm" onClick={handleSaveProvider}>{t('config.save')}</button>
|
|
||||||
<button className="ghost sm" onClick={() => setEditProvider(null)}>{t('config.cancel')}</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
<div className="provider-card-form">
|
||||||
|
<div className="provider-setup-token-row">
|
||||||
|
<div className="provider-setup-token-input">
|
||||||
|
<label className="config-form-label">{t('config.apiKey')}</label>
|
||||||
|
<input
|
||||||
|
className="config-form-input"
|
||||||
|
type="password"
|
||||||
|
placeholder={p.api_key ? '••••••••' : t('config.tokenPlaceholder')}
|
||||||
|
value={isEditing ? (providerForm[p.name]?.api_key || '') : ''}
|
||||||
|
onChange={e => {
|
||||||
|
if (!isEditing) openProviderEdit(p)
|
||||||
|
setProviderForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
[p.name]: { ...(prev[p.name] || {}), api_key: e.target.value },
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="provider-setup-token-actions">
|
||||||
|
<button
|
||||||
|
className="sm primary"
|
||||||
|
disabled={validating === p.name || !providerForm[p.name]?.api_key}
|
||||||
|
onClick={() => handleValidate(p.name, providerForm[p.name]?.api_key, currentModel, providerForm[p.name]?.base_url)}
|
||||||
|
>
|
||||||
|
{validating === p.name ? t('config.validating') : t('config.validateKey')}
|
||||||
|
</button>
|
||||||
|
{isEditing && (
|
||||||
|
<button className="sm" onClick={() => handleSaveProvider(p.name)}>{t('config.save')}</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="provider-card-model">
|
||||||
|
<span className="provider-card-model-label">{t('config.model')}</span>
|
||||||
|
<span className="provider-card-model-value">{p.model || '—'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanelSkills({ skillList, api, loadData, t }) {
|
||||||
|
const [deploying, setDeploying] = useState(null)
|
||||||
|
|
||||||
|
const handleDeploy = async (name) => {
|
||||||
|
setDeploying(name + '-deploy')
|
||||||
|
try {
|
||||||
|
await api.deploySkill(name)
|
||||||
|
loadData()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('deploy skill:', err)
|
||||||
|
}
|
||||||
|
setDeploying(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUndeploy = async (name) => {
|
||||||
|
setDeploying(name + '-undeploy')
|
||||||
|
try {
|
||||||
|
await api.undeploySkill(name)
|
||||||
|
loadData()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('undeploy skill:', err)
|
||||||
|
}
|
||||||
|
setDeploying(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skillList.length === 0) {
|
||||||
|
return <div className="empty-state" style={{ color: 'var(--text-disabled)', padding: 40 }}>{t('config.noSkills')}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="skills-list">
|
||||||
|
{skillList.map((s, i) => (
|
||||||
|
<div key={i} className="config-update-row" style={{ alignItems: 'center' }}>
|
||||||
|
<div className="skill-list-info">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span className="config-update-name">{s.name}</span>
|
||||||
|
{s.deployed ? (
|
||||||
|
<span className="badge ok">{t('config.installed')}</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge neutral">{t('config.notInstalled')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 2 }}>{s.description}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
|
||||||
|
<button
|
||||||
|
className="sm primary"
|
||||||
|
disabled={s.deployed || deploying === s.name + '-deploy'}
|
||||||
|
onClick={() => handleDeploy(s.name)}
|
||||||
|
>
|
||||||
|
{deploying === s.name + '-deploy' ? '...' : t('config.apply')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="sm ghost"
|
||||||
|
disabled={!s.deployed || deploying === s.name + '-undeploy'}
|
||||||
|
onClick={() => handleUndeploy(s.name)}
|
||||||
|
>
|
||||||
|
{deploying === s.name + '-undeploy' ? '...' : t('config.remove')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PanelUpdates({ updates, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleUpdateAll, t }) {
|
function PanelSystem({ api, t }) {
|
||||||
|
const [showResetModal, setShowResetModal] = useState(false)
|
||||||
|
const [toast, setToast] = useState(null)
|
||||||
|
const [isSudo, setIsSudo] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.getInfo().then(d => setIsSudo(!!d.sudo)).catch(() => {})
|
||||||
|
}, [api])
|
||||||
|
|
||||||
|
const showToast = (msg) => {
|
||||||
|
setToast(msg)
|
||||||
|
setTimeout(() => setToast(null), 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = async () => {
|
||||||
|
try {
|
||||||
|
await api.resetConfig()
|
||||||
|
setShowResetModal(false)
|
||||||
|
showToast(t('config.resetDone'))
|
||||||
|
setTimeout(() => window.location.reload(), 1500)
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`${t('config.error')}: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSystemUpdate = () => {
|
||||||
|
window.dispatchEvent(new CustomEvent('navigate-to-shell'))
|
||||||
|
if (isSudo) {
|
||||||
|
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Mets à jour le système et tous les outils utilisés par l'application Muyue. Exécute les commandes suivantes dans l'ordre :\n1. Met à jour les paquets système : sudo apt update && sudo apt upgrade -y\n2. Installe les dépendances utiles si manquantes : sudo apt install -y sshpass git curl wget\n3. Mets à jour les outils installés : crush, claude, gh, go, node/npm, python3/pip3/uv, docker, starship\n4. Pour chaque outil, vérifie la version actuelle, mets à jour si possible, puis vérifie la nouvelle version\n5. Donne un récapitulatif final de tout ce qui a été mis à jour ou installé` } }))
|
||||||
|
} else {
|
||||||
|
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Je n'ai pas les droits sudo sur ce système. Donne-moi les commandes nécessaires pour mettre à jour le système et les outils suivants. Pour chaque outil, indique la commande exacte à exécuter :\n1. Paquets système (apt update && apt upgrade)\n2. Outils à mettre à jour : crush, claude, gh, go, node/npm, python3/pip3/uv, docker, starship\n3. Dépendances utiles à installer : sshpass, git, curl, wget\n4. Présente les commandes dans un tableau markdown avec le nom de l'outil, la commande, et si sudo est requis` } }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const configureTool = (tool) => {
|
||||||
|
window.dispatchEvent(new CustomEvent('navigate-to-shell'))
|
||||||
|
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: tool.prompt } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const AI_TOOLS = [
|
||||||
|
{
|
||||||
|
id: 'crush',
|
||||||
|
name: 'Crush',
|
||||||
|
icon: 'Zap',
|
||||||
|
description: t('config.toolCrushDesc'),
|
||||||
|
prompt: `Configure l'outil Crush sur ce système. Vérifie d'abord s'il est installé avec "crush --version". S'il n'est pas installé, installe-le avec la méthode appropriée (npm install -g @anthropic/crush ou via le script officiel). S'il est déjà installé, vérifie sa configuration dans ~/.config/crush/ et affiche son état. Demande-moi les informations nécessaires si besoin (clés API, préférences, etc.).`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'claude',
|
||||||
|
name: 'Claude Code',
|
||||||
|
icon: 'Bot',
|
||||||
|
description: t('config.toolClaudeDesc'),
|
||||||
|
prompt: `Configure l'outil Claude Code (claude) sur ce système. Vérifie d'abord s'il est installé avec "claude --version". S'il n'est pas installé, installe-le avec npm install -g @anthropic-ai/claude-code. S'il est installé, vérifie sa configuration et son authentification. Demande-moi les informations nécessaires si besoin (clé API Anthropic, etc.).`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gh',
|
||||||
|
name: 'GitHub CLI',
|
||||||
|
icon: 'GitBranch',
|
||||||
|
description: t('config.toolGhDesc'),
|
||||||
|
prompt: `Configure l'outil GitHub CLI (gh) sur ce système. Vérifie d'abord s'il est installé avec "gh --version". S'il n'est pas installé, installe-le avec la méthode appropriée pour ce système. S'il est installé, vérifie son authentification avec "gh auth status". Si non authentifié, guide-moi pour le configurer avec "gh auth login". Demande-moi le token si nécessaire.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'docker',
|
||||||
|
name: 'Docker',
|
||||||
|
icon: 'Container',
|
||||||
|
description: t('config.toolDockerDesc'),
|
||||||
|
prompt: `Configure Docker sur ce système. Vérifie d'abord s'il est installé avec "docker --version". Vérifie aussi si le daemon tourne avec "docker info". S'il n'est pas installé, installe-le avec la méthode appropriée. Vérifie que l'utilisateur est dans le groupe docker. Si des problèmes de permissions existent, explique comment les résoudre.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'go',
|
||||||
|
name: 'Go',
|
||||||
|
icon: 'Circle',
|
||||||
|
description: t('config.toolGoDesc'),
|
||||||
|
prompt: `Configure l'environnement Go sur ce système. Vérifie s'il est installé avec "go version". Vérifie le GOPATH, GOROOT et les variables d'environnement. S'il n'est pas installé, installe-le avec la méthode appropriée. Vérifie que les binaires Go sont dans le PATH.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'node',
|
||||||
|
name: 'Node.js',
|
||||||
|
icon: 'Hexagon',
|
||||||
|
description: t('config.toolNodeDesc'),
|
||||||
|
prompt: `Configure l'environnement Node.js sur ce système. Vérifie s'il est installé avec "node --version" et "npm --version". Vérifie aussi pnpm et npx. S'il n'est pas installé, installe-le avec la méthode recommandée (nvm, fnm ou le gestionnaire de paquets). Vérifie la version LTS vs Current.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'python',
|
||||||
|
name: 'Python',
|
||||||
|
icon: 'Code',
|
||||||
|
description: t('config.toolPythonDesc'),
|
||||||
|
prompt: `Configure l'environnement Python sur ce système. Vérifie python3 --version, pip3 --version, et uv --version. S'ils ne sont pas installés, installe-les avec la méthode appropriée. Vérifie les paquets essentiels (venv, pip). Configure uv si nécessaire.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'starship',
|
||||||
|
name: 'Starship',
|
||||||
|
icon: 'Rocket',
|
||||||
|
description: t('config.toolStarshipDesc'),
|
||||||
|
prompt: `Configure Starship (prompt shell) sur ce système. Vérifie s'il est installé avec "starship --version". S'il n'est pas installé, installe-le. Ensuite, configure le thème "charm" dans ~/.config/starship.toml. Assure-toi que starship est initialisé dans le shell de l'utilisateur (.bashrc, .zshrc ou config fish).`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const ICON_MAP = { Zap, Bot, GitBranch, Container, Circle, Hexagon, Code, Rocket }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="config-card">
|
{toast && <div className="config-toast">{toast}</div>}
|
||||||
<div className="config-update-controls">
|
|
||||||
<div className="config-update-stats">
|
<div className="section-title" style={{ marginBottom: 8 }}>{t('config.systemConfig')}</div>
|
||||||
<span className="badge ok">{installedCount} {t('config.installed')}</span>
|
|
||||||
{missingCount > 0 && <span className="badge error">{missingCount} {t('config.missing')}</span>}
|
<div className="section-title" style={{ marginTop: 4, marginBottom: 8, fontSize: 12, color: 'var(--text-tertiary)', textTransform: 'none', letterSpacing: 0 }}>
|
||||||
{needsUpdateCount > 0 && <span className="badge warn">{needsUpdateCount} {t('config.needsUpdate')}</span>}
|
<Bot size={13} style={{ verticalAlign: 'middle', marginRight: 6 }} />
|
||||||
</div>
|
{t('config.aiToolsConfig')}
|
||||||
<div className="config-update-buttons">
|
</div>
|
||||||
<button className="sm" onClick={handleCheckUpdates} disabled={checking}>
|
<div className="config-ai-tools-grid">
|
||||||
{checking ? <><RefreshCw size={12} className="spin-icon" /> {t('config.checking')}</> : t('config.checkUpdates')}
|
{AI_TOOLS.map(tool => {
|
||||||
</button>
|
const Icon = ICON_MAP[tool.icon] || Bot
|
||||||
{needsUpdateCount > 0 && (
|
return (
|
||||||
<button className="sm primary" onClick={handleUpdateAll} disabled={updating === '__all__'}>
|
<div key={tool.id} className="config-ai-tool-card">
|
||||||
{updating === '__all__' ? t('config.updating') : `${t('config.updateAll')} (${needsUpdateCount})`}
|
<div className="config-ai-tool-header">
|
||||||
|
<span className="config-ai-tool-icon"><Icon size={16} /></span>
|
||||||
|
<span className="config-ai-tool-name">{tool.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="config-ai-tool-desc">{tool.description}</div>
|
||||||
|
<button className="sm primary" onClick={() => configureTool(tool)} style={{ marginTop: 'auto' }}>
|
||||||
|
<Sparkles size={12} style={{ verticalAlign: 'middle', marginRight: 4 }} />
|
||||||
|
{t('config.configureViaAI')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="config-card" style={{ marginTop: 12, marginBottom: 4 }}>
|
||||||
|
<div className="config-card-row" style={{ alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.systemUpdate')}</span>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 2 }}>
|
||||||
|
{isSudo ? t('config.systemUpdateDescSudo') : t('config.systemUpdateDescNoSudo')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button className="sm primary" onClick={handleSystemUpdate}>
|
||||||
|
<Download size={12} style={{ verticalAlign: 'middle', marginRight: 4 }} />
|
||||||
|
{t('config.updateBtn')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{updates.length === 0 ? (
|
<div className="section-title" style={{ marginTop: 20, marginBottom: 8, color: 'var(--danger)' }}>
|
||||||
<div className="config-card">
|
<AlertTriangle size={14} style={{ verticalAlign: 'middle', marginRight: 6 }} />
|
||||||
<div className="empty-state">{t('config.noUpdates')}</div>
|
Zone Rouge
|
||||||
|
</div>
|
||||||
|
<div className="config-card" style={{ borderColor: 'var(--danger)', borderWidth: 1, borderStyle: 'solid' }}>
|
||||||
|
<div className="config-card-row" style={{ marginBottom: 16 }}>
|
||||||
|
<span className="config-card-label" style={{ fontWeight: 600, color: 'var(--danger)' }}>{t('config.resetConfig')}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 12 }}>
|
||||||
<div className="config-update-list">
|
Cette action supprimera toute votre configuration et relancera l'application.
|
||||||
{updates.map((u, i) => (
|
</div>
|
||||||
<div key={i} className="config-update-row">
|
<button className="sm ghost danger" onClick={() => setShowResetModal(true)}>
|
||||||
<div className="config-update-info">
|
{t('config.resetConfig')}
|
||||||
<span className="config-update-name">{u.tool}</span>
|
</button>
|
||||||
<span className="config-update-versions">
|
</div>
|
||||||
{u.needsUpdate ? (
|
|
||||||
<>{u.current} → <span style={{ color: 'var(--warning)' }}>{u.latest}</span></>
|
{showResetModal && (
|
||||||
) : (
|
<div className="shell-modal-overlay" onClick={() => setShowResetModal(false)}>
|
||||||
<span style={{ color: 'var(--success)' }}>{u.current}</span>
|
<div className="shell-modal" onClick={e => e.stopPropagation()}>
|
||||||
)}
|
<div className="shell-modal-header" style={{ color: 'var(--danger)' }}>
|
||||||
</span>
|
<AlertTriangle size={16} style={{ verticalAlign: 'middle', marginRight: 8 }} />
|
||||||
</div>
|
{t('config.resetConfig')}
|
||||||
{u.needsUpdate && (
|
|
||||||
<button
|
|
||||||
className="sm"
|
|
||||||
onClick={() => handleUpdateTool(u.tool)}
|
|
||||||
disabled={updating === u.tool}
|
|
||||||
>
|
|
||||||
{updating === u.tool ? t('config.updating') : t('config.updateTool')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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 }) {
|
|
||||||
return (
|
|
||||||
<div className="config-card">
|
|
||||||
{skillList.length === 0 ? (
|
|
||||||
<div className="empty-state">
|
|
||||||
{t('config.noSkills')}
|
|
||||||
<span style={{ fontFamily: 'var(--font-mono)' }}>{t('config.runSkillsInit')}</span>
|
|
||||||
</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>
|
|
||||||
<span className="config-skill-desc">{s.description}</span>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FormInput({ label, value, onChange, type = 'text' }) {
|
function FormInput({ label, value, onChange, type = 'text' }) {
|
||||||
return (
|
return (
|
||||||
<div className="config-form-field">
|
<div className="config-form-field">
|
||||||
|
|||||||
@@ -1,60 +1,283 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { useI18n } from '../i18n'
|
import { useI18n } from '../i18n'
|
||||||
|
|
||||||
export default function Dashboard({ api, onRescan }) {
|
const MAX_POINTS = 30
|
||||||
const { t, layout } = useI18n()
|
|
||||||
const [notifications, setNotifications] = useState([])
|
|
||||||
|
|
||||||
const addNotif = (text, type) => {
|
const POLL_INTERVAL = 5000
|
||||||
setNotifications(prev => [{ text, type, id: Date.now(), time: new Date() }, ...prev])
|
const MAX_IDLE_POLLS = 3
|
||||||
|
|
||||||
|
function formatTokens(n) {
|
||||||
|
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
|
||||||
|
if (n >= 1000) return (n / 1000).toFixed(1) + 'K'
|
||||||
|
return String(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [quota, setQuota] = useState(null)
|
||||||
|
const [consumption, setConsumption] = useState(null)
|
||||||
|
const [recentCmds, setRecentCmds] = useState([])
|
||||||
|
const [processes, setProcesses] = useState([])
|
||||||
|
const [metrics, setMetrics] = useState(null)
|
||||||
|
const [copiedSet, setCopiedSet] = useState(new Set())
|
||||||
|
const cpuRef = useRef([])
|
||||||
|
const memRef = useRef([])
|
||||||
|
const netRxRef = useRef([])
|
||||||
|
const netTxRef = useRef([])
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [quotaData, consumData, cmdData, procData, metricsData] = await Promise.all([
|
||||||
|
api.getProvidersQuota().catch(() => null),
|
||||||
|
api.getProvidersConsumption().catch(() => null),
|
||||||
|
api.getRecentCommands().catch(() => ({ commands: [] })),
|
||||||
|
api.getRunningProcesses().catch(() => ({ processes: [] })),
|
||||||
|
api.getSystemMetrics().catch(() => null),
|
||||||
|
])
|
||||||
|
setQuota(quotaData?.providers || [])
|
||||||
|
setConsumption(consumData?.providers || {})
|
||||||
|
setRecentCmds(cmdData.commands || [])
|
||||||
|
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) {
|
||||||
|
console.error('Dashboard load error:', err)
|
||||||
|
}
|
||||||
|
}, [api])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
if (refreshRef) refreshRef.current = loadData
|
||||||
|
let active = true
|
||||||
|
let idleTicks = 0
|
||||||
|
const iv = setInterval(() => {
|
||||||
|
const hidden = document.querySelector('.dash-grid')?.closest('.tab-hidden')
|
||||||
|
if (hidden) {
|
||||||
|
idleTicks++
|
||||||
|
if (idleTicks >= MAX_IDLE_POLLS) return
|
||||||
|
} else {
|
||||||
|
idleTicks = 0
|
||||||
|
}
|
||||||
|
if (active) loadData()
|
||||||
|
}, POLL_INTERVAL)
|
||||||
|
return () => { active = false; clearInterval(iv) }
|
||||||
|
}, [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)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const relativeTime = (ts) => {
|
||||||
<div className="dashboard-layout">
|
if (!ts) return ''
|
||||||
<div className="dashboard-content">
|
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000)
|
||||||
<div className="dashboard-grid">
|
if (diff < 60) return `${diff}s`
|
||||||
<div className="dashboard-section">
|
if (diff < 3600) return `${Math.floor(diff / 60)}m`
|
||||||
<div className="dashboard-section-header">
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h`
|
||||||
<div className="dashboard-section-title">{t('studio.workflows')}</div>
|
return `${Math.floor(diff / 86400)}d`
|
||||||
</div>
|
}
|
||||||
<div className="dashboard-workflows-inline">
|
|
||||||
<div className="workflow-section">
|
|
||||||
<div className="section-label">{t('studio.workflows')}</div>
|
|
||||||
<div className="empty-state" style={{ padding: 20 }}>
|
|
||||||
{t('studio.noWorkflow')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="workflow-section">
|
|
||||||
<div className="section-label">{t('studio.activeAgents')}</div>
|
|
||||||
<div className="empty-state" style={{ padding: 20 }}>
|
|
||||||
{t('studio.noWorkflow')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="dashboard-section">
|
const recentUnique = (() => {
|
||||||
<div className="dashboard-section-header">
|
const seen = new Set()
|
||||||
<div className="dashboard-section-title">{t('dashboard.activityLog')}</div>
|
return recentCmds.filter(c => {
|
||||||
{notifications.length > 0 && (
|
if (seen.has(c.cmd)) return false
|
||||||
<span className="badge warn">{notifications.length}</span>
|
seen.add(c.cmd)
|
||||||
)}
|
return true
|
||||||
</div>
|
})
|
||||||
{notifications.length === 0 ? (
|
})()
|
||||||
<div className="empty-state">{t('dashboard.noUpdateData')}</div>
|
|
||||||
) : (
|
const providerEntries = consumption ? Object.entries(consumption) : []
|
||||||
<div className="dashboard-notifications-inline">
|
const colors = ['var(--accent)', '#34d399', '#a78bfa', '#f59e0b', '#f472b6']
|
||||||
{notifications.map(n => (
|
const maxDaily = providerEntries.length > 0
|
||||||
<div key={n.id} className={`notif-row notif-${n.type}`}>
|
? Math.max(...providerEntries.map(([, p]) => Math.max(...(p.daily || []).map(d => d.tokens), 0)), 1)
|
||||||
<span className="notif-time">
|
: 1
|
||||||
{n.time.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
|
||||||
</span>
|
return (
|
||||||
<span className="notif-text">{n.text}</span>
|
<div className="dash-grid">
|
||||||
</div>
|
{/* CPU */}
|
||||||
|
<div className="dash-card">
|
||||||
|
<div className="dash-card-head">
|
||||||
|
<span className="dash-label">CPU</span>
|
||||||
|
<span className="dash-count">{metrics ? metrics.cpu_percent.toFixed(0) : '—'}%</span>
|
||||||
|
</div>
|
||||||
|
<MiniGraph data={cpuRef.current} max={100} color="var(--accent)" label="CPU" unit="%" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RAM */}
|
||||||
|
<div className="dash-card">
|
||||||
|
<div className="dash-card-head">
|
||||||
|
<span className="dash-label">RAM</span>
|
||||||
|
<span className="dash-count">{metrics ? `${metrics.mem_used_mb.toFixed(0)}/${metrics.mem_total_mb.toFixed(0)}` : '—'}</span>
|
||||||
|
</div>
|
||||||
|
<MiniGraph data={memRef.current} max={100} color="#a78bfa" label="RAM" unit="%" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Network */}
|
||||||
|
<div className="dash-card">
|
||||||
|
<div className="dash-card-head">
|
||||||
|
<span className="dash-label">Network</span>
|
||||||
|
<span className="dash-count">{metrics ? `↓${metrics.net_rx_kbs.toFixed(0)} ↑${metrics.net_tx_kbs.toFixed(0)}` : '—'}</span>
|
||||||
|
</div>
|
||||||
|
<MiniGraph data={netRxRef.current} max={null} color="#34d399" label="RX" unit=" KB/s" />
|
||||||
|
<MiniGraph data={netTxRef.current} max={null} color="#f59e0b" label="TX" unit=" KB/s" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Consommation */}
|
||||||
|
<div className="dash-card">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<BarChart data={p.daily || []} max={maxDaily} color={colors[pi % colors.length]} />
|
||||||
|
<div className="dash-consumption-days">
|
||||||
|
{(p.daily || []).map((d, i) => (
|
||||||
|
<span key={i} className="dash-consumption-day">
|
||||||
|
{d.date.slice(5)} <strong>{formatTokens(d.tokens)}</strong>
|
||||||
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Running Processes */}
|
||||||
|
<div className="dash-card">
|
||||||
|
<div className="dash-card-head">
|
||||||
|
<span className="dash-label">Processes</span>
|
||||||
|
<span className="dash-count">{processes.length}</span>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* Recent Commands */}
|
||||||
|
<div className="dash-card dash-cmd-card">
|
||||||
|
<div className="dash-card-head">
|
||||||
|
<span className="dash-label">Recent Commands</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>
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="dash-cmd-list">
|
||||||
|
{recentUnique.length === 0 && <span className="dash-empty">No history</span>}
|
||||||
|
{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>
|
||||||
|
|||||||
531
web/src/components/OnboardingWizard.jsx
Normal file
@@ -0,0 +1,531 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { Sparkles, ArrowRight, ArrowLeft, Loader } from 'lucide-react'
|
||||||
|
import { useI18n, LANGUAGES } from '../i18n'
|
||||||
|
import { getLayoutList } from '../i18n/keyboards'
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ key: 'welcome', title: 'welcome' },
|
||||||
|
{ key: 'name', title: 'name' },
|
||||||
|
{ key: 'language', title: 'language' },
|
||||||
|
{ key: 'keyboard', title: 'keyboard' },
|
||||||
|
{ key: 'apikey', title: 'apikey' },
|
||||||
|
{ key: 'editor', title: 'editor' },
|
||||||
|
{ key: 'done', title: 'done' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const BASE_EDITORS = ['vim', 'nvim', 'vscode', 'emacs', 'nano', 'helix']
|
||||||
|
|
||||||
|
export default function OnboardingWizard({ api, onComplete }) {
|
||||||
|
const { t, language, keyboard, setLanguage, setKeyboard } = useI18n()
|
||||||
|
const [step, setStep] = useState(0)
|
||||||
|
const [answers, setAnswers] = useState({
|
||||||
|
name: '',
|
||||||
|
language: 'fr',
|
||||||
|
keyboard: 'azerty',
|
||||||
|
apikey: '',
|
||||||
|
apikey_mimo: '',
|
||||||
|
editor: '',
|
||||||
|
})
|
||||||
|
const [keyValidMimo, setKeyValidMimo] = useState(false)
|
||||||
|
const [errorMimo, setErrorMimo] = useState(null)
|
||||||
|
const [validatingMimo, setValidatingMimo] = useState(false)
|
||||||
|
const [editorList, setEditorList] = useState(BASE_EDITORS)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [requiredError, setRequiredError] = useState(false)
|
||||||
|
const [validating, setValidating] = useState(false)
|
||||||
|
const [keyValid, setKeyValid] = useState(false)
|
||||||
|
const [scanning, setScanning] = useState(false)
|
||||||
|
const [scanMessage, setScanMessage] = useState('')
|
||||||
|
const scanAbortRef = useRef(null)
|
||||||
|
|
||||||
|
const current = STEPS[step]
|
||||||
|
const layouts = getLayoutList()
|
||||||
|
|
||||||
|
const goNext = () => {
|
||||||
|
if (step < STEPS.length - 1) {
|
||||||
|
if (!canProceed) { setRequiredError(true); return }
|
||||||
|
setRequiredError(false)
|
||||||
|
setStep(step + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canProceed = (() => {
|
||||||
|
switch (current.key) {
|
||||||
|
case 'welcome': return true
|
||||||
|
case 'name': return answers.name.trim().length > 0
|
||||||
|
case 'language': return !!answers.language
|
||||||
|
case 'keyboard': return !!answers.keyboard
|
||||||
|
case 'apikey': return (keyValid || keyValidMimo) && !scanning
|
||||||
|
case 'editor': return true
|
||||||
|
case 'done': return true
|
||||||
|
default: return true
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
const goPrev = () => {
|
||||||
|
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(() => {
|
||||||
|
const handler = (e) => {
|
||||||
|
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() }
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handler)
|
||||||
|
return () => window.removeEventListener('keydown', handler)
|
||||||
|
}, [step, current, answers, editorList])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => { if (scanAbortRef.current) scanAbortRef.current.abort() }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (current.key === 'done' && !saving) {
|
||||||
|
handleSave()
|
||||||
|
}
|
||||||
|
}, [step])
|
||||||
|
|
||||||
|
const handleValidateKey = async () => {
|
||||||
|
if (!answers.apikey.trim()) return
|
||||||
|
setValidating(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await api.validateProvider({
|
||||||
|
name: 'minimax',
|
||||||
|
api_key: answers.apikey,
|
||||||
|
model: 'MiniMax-M2.7',
|
||||||
|
base_url: 'https://api.minimax.io/v1',
|
||||||
|
})
|
||||||
|
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) {
|
||||||
|
setError(err.message || 'Clé invalide')
|
||||||
|
setKeyValid(false)
|
||||||
|
}
|
||||||
|
setValidating(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleValidateKeyMimo = async () => {
|
||||||
|
if (!answers.apikey_mimo.trim()) return
|
||||||
|
setValidatingMimo(true)
|
||||||
|
setErrorMimo(null)
|
||||||
|
try {
|
||||||
|
await api.validateProvider({
|
||||||
|
name: 'mimo',
|
||||||
|
api_key: answers.apikey_mimo,
|
||||||
|
model: 'mimo-v2.5-pro',
|
||||||
|
base_url: 'https://token-plan-ams.xiaomimimo.com/v1',
|
||||||
|
})
|
||||||
|
setKeyValidMimo(true)
|
||||||
|
// Save MiMo. If MiniMax wasn't validated yet, MiMo becomes the active provider.
|
||||||
|
await api.saveProvider({
|
||||||
|
name: 'mimo',
|
||||||
|
api_key: answers.apikey_mimo,
|
||||||
|
model: 'mimo-v2.5-pro',
|
||||||
|
base_url: 'https://token-plan-ams.xiaomimimo.com/v1',
|
||||||
|
active: !keyValid,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
setErrorMimo(err.message || 'Clé invalide')
|
||||||
|
setKeyValidMimo(false)
|
||||||
|
}
|
||||||
|
setValidatingMimo(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const profile = {
|
||||||
|
name: answers.name,
|
||||||
|
pseudo: answers.name.split(' ')[0] || 'user',
|
||||||
|
editor: answers.editor,
|
||||||
|
}
|
||||||
|
if (answers.apikey.trim()) {
|
||||||
|
profile.apikey = answers.apikey
|
||||||
|
}
|
||||||
|
await api.saveProfile(profile)
|
||||||
|
await api.savePreferences({
|
||||||
|
language: answers.language,
|
||||||
|
keyboard_layout: answers.keyboard,
|
||||||
|
})
|
||||||
|
if (answers.apikey.trim()) {
|
||||||
|
await api.saveProvider({
|
||||||
|
name: 'minimax',
|
||||||
|
api_key: answers.apikey,
|
||||||
|
model: 'MiniMax-M2.7',
|
||||||
|
base_url: 'https://api.minimax.io/v1',
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (answers.apikey_mimo.trim()) {
|
||||||
|
await api.saveProvider({
|
||||||
|
name: 'mimo',
|
||||||
|
api_key: answers.apikey_mimo,
|
||||||
|
model: 'mimo-v2.5-pro',
|
||||||
|
base_url: 'https://token-plan-ams.xiaomimimo.com/v1',
|
||||||
|
active: !answers.apikey.trim(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onComplete()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Erreur lors de la sauvegarde')
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="onboarding-overlay">
|
||||||
|
<div className="onboarding-card">
|
||||||
|
<div className="onboarding-header">
|
||||||
|
<Sparkles size={20} style={{ color: 'var(--accent)' }} />
|
||||||
|
<span> Muyue Setup</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="onboarding-progress">
|
||||||
|
{STEPS.filter(s => s.key !== 'done').map(s => {
|
||||||
|
const i = STEPS.indexOf(s)
|
||||||
|
return <div key={s.key} className={`onboarding-dot ${i === step ? 'active' : ''} ${i < step ? 'done' : ''}`} />
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="onboarding-body">
|
||||||
|
{current.key === 'welcome' && (
|
||||||
|
<div className="onboarding-step">
|
||||||
|
<div className="onboarding-title">Bienvenue ! 👋</div>
|
||||||
|
<div className="onboarding-desc">
|
||||||
|
Je suis votre assistant de configuration. Quelques questions rapides pour personnaliser votre expérience.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{current.key === 'name' && (
|
||||||
|
<div className="onboarding-step">
|
||||||
|
<div className="onboarding-title">Comment vous appelez-vous ?</div>
|
||||||
|
<input
|
||||||
|
className="onboarding-input"
|
||||||
|
placeholder="Votre nom..."
|
||||||
|
value={answers.name}
|
||||||
|
onChange={e => { setAnswers(a => ({ ...a, name: e.target.value })); setRequiredError(false) }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{requiredError && <div className="onboarding-required">Veuillez entrer votre nom</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{current.key === 'language' && (
|
||||||
|
<div className="onboarding-step">
|
||||||
|
<div className="onboarding-title">Quelle langue préférez-vous ?</div>
|
||||||
|
<div className="onboarding-chips">
|
||||||
|
{LANGUAGES.map(lang => (
|
||||||
|
<div
|
||||||
|
key={lang.id}
|
||||||
|
className={`chip ${answers.language === lang.id ? 'active' : ''}`}
|
||||||
|
onClick={() => setAnswers(a => ({ ...a, language: lang.id }))}
|
||||||
|
>
|
||||||
|
{lang.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{current.key === 'keyboard' && (
|
||||||
|
<div className="onboarding-step">
|
||||||
|
<div className="onboarding-title">Disposition du clavier ?</div>
|
||||||
|
<div className="onboarding-chips">
|
||||||
|
{layouts.map(l => (
|
||||||
|
<div
|
||||||
|
key={l.id}
|
||||||
|
className={`chip ${answers.keyboard === l.id ? 'active' : ''}`}
|
||||||
|
onClick={() => setAnswers(a => ({ ...a, keyboard: l.id }))}
|
||||||
|
>
|
||||||
|
{l.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{current.key === 'apikey' && (
|
||||||
|
<div className="onboarding-step">
|
||||||
|
<div className="onboarding-title">Clés API</div>
|
||||||
|
<div className="onboarding-desc">
|
||||||
|
Renseignez au moins l'une des deux clés pour activer l'assistant. Les autres fournisseurs (OpenAI, Anthropic, Ollama, Z.AI) se configurent plus tard depuis l'onglet Configuration.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 4 }}>
|
||||||
|
<label style={{ fontSize: 12, color: 'var(--text-tertiary)', fontWeight: 600 }}>MiniMax</label>
|
||||||
|
<input
|
||||||
|
className="onboarding-input"
|
||||||
|
placeholder="sk-xxxxxxxxxxxxxxxx (MiniMax)"
|
||||||
|
type="password"
|
||||||
|
value={answers.apikey}
|
||||||
|
onChange={e => { setAnswers(a => ({ ...a, apikey: e.target.value })); setKeyValid(false); setError(null) }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
className="sm primary"
|
||||||
|
onClick={handleValidateKey}
|
||||||
|
disabled={validating || !answers.apikey.trim()}
|
||||||
|
>
|
||||||
|
{validating ? 'Validation...' : 'Valider MiniMax'}
|
||||||
|
</button>
|
||||||
|
{keyValid && <span className="onboarding-valid">✓ MiniMax OK</span>}
|
||||||
|
{error && !keyValid && <span className="onboarding-required">{error}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 12 }}>
|
||||||
|
<label style={{ fontSize: 12, color: 'var(--text-tertiary)', fontWeight: 600 }}>MiMo (Xiaomi)</label>
|
||||||
|
<input
|
||||||
|
className="onboarding-input"
|
||||||
|
placeholder="sk-xxxxxxxxxxxxxxxx (MiMo)"
|
||||||
|
type="password"
|
||||||
|
value={answers.apikey_mimo}
|
||||||
|
onChange={e => { setAnswers(a => ({ ...a, apikey_mimo: e.target.value })); setKeyValidMimo(false); setErrorMimo(null) }}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
className="sm primary"
|
||||||
|
onClick={handleValidateKeyMimo}
|
||||||
|
disabled={validatingMimo || !answers.apikey_mimo.trim()}
|
||||||
|
>
|
||||||
|
{validatingMimo ? 'Validation...' : 'Valider MiMo'}
|
||||||
|
</button>
|
||||||
|
{keyValidMimo && <span className="onboarding-valid">✓ MiMo OK</span>}
|
||||||
|
{errorMimo && !keyValidMimo && <span className="onboarding-required">{errorMimo}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{scanning && (
|
||||||
|
<div className="onboarding-scanning" style={{ marginTop: 8 }}>
|
||||||
|
<Loader size={14} className="spin-icon" />
|
||||||
|
<span>{scanMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{requiredError && (
|
||||||
|
<div className="onboarding-required" style={{ marginTop: 8 }}>
|
||||||
|
Veuillez valider au moins une clé (MiniMax ou MiMo) pour continuer.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(keyValid || keyValidMimo) && !scanning && (
|
||||||
|
<div className="onboarding-valid" style={{ marginTop: 8 }}>
|
||||||
|
Au moins une clé est valide — appuyez sur Suivant pour continuer.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{current.key === 'editor' && (
|
||||||
|
<div className="onboarding-step">
|
||||||
|
<div className="onboarding-title">Quel éditeur utilisez-vous ?</div>
|
||||||
|
<div className="onboarding-desc">
|
||||||
|
{scanning ? 'Détection en cours...' : 'Sélectionnez votre éditeur.'}
|
||||||
|
</div>
|
||||||
|
<div className="onboarding-chips">
|
||||||
|
{editorList.map(ed => (
|
||||||
|
<div
|
||||||
|
key={ed}
|
||||||
|
className={`chip ${answers.editor === ed ? 'active' : ''}`}
|
||||||
|
onClick={() => setAnswers(a => ({ ...a, editor: ed }))}
|
||||||
|
>
|
||||||
|
{ed}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{current.key === 'done' && (
|
||||||
|
<div className="onboarding-step">
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<div className="onboarding-title">Configuration en cours...</div>
|
||||||
|
<div className="onboarding-desc">Sauvegarde de vos préférences.</div>
|
||||||
|
</>
|
||||||
|
) : error ? (
|
||||||
|
<>
|
||||||
|
<div className="onboarding-title" style={{ color: 'var(--error)' }}>Erreur</div>
|
||||||
|
<div className="onboarding-desc" style={{ color: 'var(--error)' }}>{error}</div>
|
||||||
|
<button className="primary" style={{ alignSelf: 'flex-start', marginTop: 8 }} onClick={() => handleSave()}>Réessayer</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="onboarding-title">C'est parti ! 🚀</div>
|
||||||
|
<div className="onboarding-desc">
|
||||||
|
Votre profil est configuré. Vous pouvez toujours ajuster les paramètres dans l'onglet Configuration.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="onboarding-footer">
|
||||||
|
{step > 0 && step < STEPS.length - 1 && (
|
||||||
|
<button className="ghost" onClick={goPrev}>
|
||||||
|
<ArrowLeft size={14} /> Précédent
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
{step < STEPS.length - 1 && (
|
||||||
|
<button className="primary" onClick={goNext}>
|
||||||
|
Suivant <ArrowRight size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{step === STEPS.length - 1 && !saving && !error && (
|
||||||
|
<button className="primary" onClick={handleSave}>
|
||||||
|
Commencer
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.onboarding-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 500;
|
||||||
|
background: rgba(10,10,12,0.85);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
.onboarding-card {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
width: 480px; max-width: 90vw;
|
||||||
|
box-shadow: 0 24px 64px rgba(0,0,0,0.5);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.onboarding-header {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 16px 20px; font-size: 14px; font-weight: 700;
|
||||||
|
color: var(--accent); border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
}
|
||||||
|
.onboarding-progress {
|
||||||
|
display: flex; gap: 6px; padding: 14px 20px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.onboarding-dot {
|
||||||
|
width: 32px; height: 4px; border-radius: 2px;
|
||||||
|
background: var(--bg-input); transition: all 0.3s;
|
||||||
|
}
|
||||||
|
.onboarding-dot.active { background: var(--accent); }
|
||||||
|
.onboarding-dot.done { background: var(--accent-dim); }
|
||||||
|
.onboarding-body { padding: 28px 24px; min-height: 200px; }
|
||||||
|
.onboarding-step { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.onboarding-title { font-size: 18px; font-weight: 700; color: var(--text-primary); }
|
||||||
|
.onboarding-desc { font-size: 14px; color: var(--text-tertiary); line-height: 1.6; }
|
||||||
|
.onboarding-input {
|
||||||
|
width: 100%; background: var(--bg-input); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); padding: 10px 14px; color: var(--text-primary);
|
||||||
|
font-size: 14px; outline: none; transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
.onboarding-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--border-accent); }
|
||||||
|
.onboarding-chips { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.onboarding-footer {
|
||||||
|
display: flex; justify-content: flex-end; gap: 8px;
|
||||||
|
padding: 16px 20px; border-top: 1px solid var(--border);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
}
|
||||||
|
.onboarding-required {
|
||||||
|
font-size: 12px; color: var(--error); margin-top: 4px;
|
||||||
|
}
|
||||||
|
.onboarding-valid {
|
||||||
|
font-size: 12px; color: var(--success); margin-top: 4px;
|
||||||
|
}
|
||||||
|
.onboarding-hint {
|
||||||
|
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 {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
241
web/src/components/Tests.jsx
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
|
import { TestTube2, Copy, RefreshCw, CheckCircle2, AlertTriangle, Globe, Terminal as TerminalIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function Tests({ api }) {
|
||||||
|
const [snippet, setSnippet] = useState(null)
|
||||||
|
const [snippetError, setSnippetError] = useState('')
|
||||||
|
const [sessions, setSessions] = useState([])
|
||||||
|
const [console_, setConsole_] = useState([])
|
||||||
|
const [activeSessionId, setActiveSessionId] = useState('')
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const pollRef = useRef(null)
|
||||||
|
|
||||||
|
const refreshSnippet = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.getTestSnippet()
|
||||||
|
setSnippet(data)
|
||||||
|
setSnippetError('')
|
||||||
|
} catch (err) {
|
||||||
|
setSnippetError(err.message || 'Failed to load snippet')
|
||||||
|
}
|
||||||
|
}, [api])
|
||||||
|
|
||||||
|
const refreshSessions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.getTestSessions()
|
||||||
|
const next = data.sessions || []
|
||||||
|
setSessions(next)
|
||||||
|
if (!activeSessionId && next.length > 0) {
|
||||||
|
setActiveSessionId(next[0].id)
|
||||||
|
} else if (activeSessionId && !next.find(s => s.id === activeSessionId)) {
|
||||||
|
setActiveSessionId(next.length > 0 ? next[0].id : '')
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}, [api, activeSessionId])
|
||||||
|
|
||||||
|
const refreshConsole = useCallback(async () => {
|
||||||
|
if (!activeSessionId) {
|
||||||
|
setConsole_([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await api.getTestConsole(activeSessionId)
|
||||||
|
setConsole_(data.console || [])
|
||||||
|
} catch {
|
||||||
|
setConsole_([])
|
||||||
|
}
|
||||||
|
}, [api, activeSessionId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshSnippet()
|
||||||
|
}, [refreshSnippet])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshSessions()
|
||||||
|
refreshConsole()
|
||||||
|
pollRef.current = setInterval(() => {
|
||||||
|
refreshSessions()
|
||||||
|
refreshConsole()
|
||||||
|
}, 2000)
|
||||||
|
return () => clearInterval(pollRef.current)
|
||||||
|
}, [refreshSessions, refreshConsole])
|
||||||
|
|
||||||
|
const copySnippet = useCallback(async () => {
|
||||||
|
if (!snippet) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(snippet.snippet)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 1500)
|
||||||
|
} catch {}
|
||||||
|
}, [snippet])
|
||||||
|
|
||||||
|
const activeSession = sessions.find(s => s.id === activeSessionId) || null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tests-layout" style={{ padding: '20px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '20px', height: '100%', overflow: 'auto' }}>
|
||||||
|
<section className="tests-pane">
|
||||||
|
<header style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||||
|
<TestTube2 size={18} />
|
||||||
|
<h2 style={{ margin: 0, fontSize: '1.1em' }}>Tests pilotés par l'IA</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p style={{ marginTop: 0, opacity: 0.85, lineHeight: 1.5 }}>
|
||||||
|
Donnez à l'IA Studio le contrôle d'un onglet de votre navigateur pour tester chaque bouton et détecter les erreurs console.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ borderTop: '1px solid var(--border, rgba(128,128,128,0.3))', paddingTop: 12, marginTop: 12 }}>
|
||||||
|
<h3 style={{ fontSize: '0.95em', margin: '0 0 8px' }}>1. Connexion</h3>
|
||||||
|
<ol style={{ paddingLeft: 18, lineHeight: 1.6 }}>
|
||||||
|
<li>Ouvrez la page à tester dans n'importe quel navigateur (Chrome, Firefox, Edge…).</li>
|
||||||
|
<li>Ouvrez la console développeur (<kbd>F12</kbd>).</li>
|
||||||
|
<li>Collez ce snippet et appuyez sur <kbd>Entrée</kbd> :</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{snippetError && (
|
||||||
|
<div style={{ background: 'rgba(220,80,80,0.1)', border: '1px solid rgba(220,80,80,0.3)', padding: 8, borderRadius: 4, marginBottom: 8 }}>
|
||||||
|
{snippetError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ position: 'relative', marginBottom: 12 }}>
|
||||||
|
<pre style={{
|
||||||
|
background: 'var(--bg-secondary, rgba(0,0,0,0.3))',
|
||||||
|
padding: '10px 12px',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: '0.75em',
|
||||||
|
maxHeight: 180,
|
||||||
|
overflow: 'auto',
|
||||||
|
border: '1px solid var(--border, rgba(128,128,128,0.3))',
|
||||||
|
margin: 0,
|
||||||
|
}}>
|
||||||
|
{snippet?.snippet || 'Chargement…'}
|
||||||
|
</pre>
|
||||||
|
<button
|
||||||
|
onClick={copySnippet}
|
||||||
|
disabled={!snippet}
|
||||||
|
title="Copier"
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: 6, right: 6,
|
||||||
|
background: 'var(--bg-tertiary, rgba(255,255,255,0.08))',
|
||||||
|
border: '1px solid var(--border, rgba(128,128,128,0.3))',
|
||||||
|
color: 'inherit', padding: '4px 8px', borderRadius: 3,
|
||||||
|
cursor: 'pointer', fontSize: '0.75em',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy size={11} /> {copied ? 'Copié !' : 'Copier'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={refreshSnippet} style={{ background: 'transparent', border: '1px solid var(--border, rgba(128,128,128,0.3))', color: 'inherit', padding: '4px 10px', borderRadius: 3, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: '0.85em' }}>
|
||||||
|
<RefreshCw size={12} /> Régénérer le token
|
||||||
|
</button>
|
||||||
|
<small style={{ display: 'block', opacity: 0.6, marginTop: 4 }}>
|
||||||
|
Le token expire après {snippet?.expires_in ? Math.round(snippet.expires_in / 60) : 5} minutes ou dès la première connexion.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ borderTop: '1px solid var(--border, rgba(128,128,128,0.3))', paddingTop: 12, marginTop: 16 }}>
|
||||||
|
<h3 style={{ fontSize: '0.95em', margin: '0 0 8px' }}>2. Pilotage par l'IA</h3>
|
||||||
|
<p style={{ margin: '0 0 8px', lineHeight: 1.5 }}>
|
||||||
|
Une fois la session connectée, allez dans l'onglet <strong>Studio</strong> et demandez par exemple :
|
||||||
|
</p>
|
||||||
|
<pre style={{ background: 'var(--bg-secondary, rgba(0,0,0,0.3))', padding: 8, borderRadius: 4, fontSize: '0.85em', margin: 0 }}>
|
||||||
|
{`Teste tous les boutons de cette page,
|
||||||
|
clique sur chacun, et dis-moi
|
||||||
|
lesquels déclenchent une erreur console.`}
|
||||||
|
</pre>
|
||||||
|
<p style={{ margin: '8px 0 0', opacity: 0.75, fontSize: '0.85em' }}>
|
||||||
|
L'IA dispose de l'outil <code>browser_test</code> avec les actions <code>list_clickables</code>, <code>click</code>, <code>console</code>, <code>eval</code>, <code>type</code>, <code>current_url</code>, <code>wait</code>, <code>summary</code>.
|
||||||
|
</p>
|
||||||
|
<p style={{ margin: '8px 0 0', padding: 8, fontSize: '0.85em', background: 'var(--accent-bg, rgba(108,92,231,0.1))', border: '1px solid var(--accent, #6c5ce7)', borderRadius: 4 }}>
|
||||||
|
<strong>Réflexion avancée auto :</strong> tant qu'au moins une session de test est connectée, chaque message dans Studio utilise automatiquement la réflexion avancée — un second modèle (s'il est configuré) produit un rapport d'analyse préalable injecté dans le prompt actif. Le toggle Studio est ignoré pendant la session.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="tests-pane">
|
||||||
|
<header style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, justifyContent: 'space-between' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Globe size={16} />
|
||||||
|
<h2 style={{ margin: 0, fontSize: '1.1em' }}>Sessions connectées</h2>
|
||||||
|
</div>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: '0.85em' }}>
|
||||||
|
{sessions.length > 0 ? <CheckCircle2 size={14} color="#3aaa61" /> : <span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: '50%', background: '#888' }} />}
|
||||||
|
{sessions.length} session{sessions.length > 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{sessions.length === 0 ? (
|
||||||
|
<div style={{ padding: 16, textAlign: 'center', opacity: 0.7, border: '1px dashed var(--border, rgba(128,128,128,0.3))', borderRadius: 4 }}>
|
||||||
|
<AlertTriangle size={20} style={{ opacity: 0.4 }} />
|
||||||
|
<div style={{ marginTop: 6 }}>Aucune session active.</div>
|
||||||
|
<small>Collez le snippet dans une page pour démarrer.</small>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 12 }}>
|
||||||
|
{sessions.map(s => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => setActiveSessionId(s.id)}
|
||||||
|
style={{
|
||||||
|
textAlign: 'left',
|
||||||
|
background: s.id === activeSessionId ? 'var(--accent-bg, rgba(108,92,231,0.15))' : 'transparent',
|
||||||
|
border: '1px solid ' + (s.id === activeSessionId ? 'var(--accent, #6c5ce7)' : 'var(--border, rgba(128,128,128,0.3))'),
|
||||||
|
color: 'inherit',
|
||||||
|
padding: 8, borderRadius: 4, cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 500, fontSize: '0.9em', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{s.title || s.url || s.id}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.75em', opacity: 0.65, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{s.url} · session {s.id.slice(0, 8)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeSession && (
|
||||||
|
<div style={{ borderTop: '1px solid var(--border, rgba(128,128,128,0.3))', paddingTop: 12 }}>
|
||||||
|
<header style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
|
||||||
|
<TerminalIcon size={14} />
|
||||||
|
<h3 style={{ margin: 0, fontSize: '0.95em' }}>Console (live, dernières {console_.length})</h3>
|
||||||
|
</header>
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--bg-secondary, rgba(0,0,0,0.3))',
|
||||||
|
padding: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
maxHeight: 380,
|
||||||
|
overflow: 'auto',
|
||||||
|
fontSize: '0.8em',
|
||||||
|
fontFamily: 'var(--font-mono, ui-monospace, monospace)',
|
||||||
|
border: '1px solid var(--border, rgba(128,128,128,0.3))',
|
||||||
|
}}>
|
||||||
|
{console_.length === 0 ? (
|
||||||
|
<div style={{ opacity: 0.5 }}>(aucun message console)</div>
|
||||||
|
) : (
|
||||||
|
console_.map((c, i) => (
|
||||||
|
<div key={i} style={{ color: levelColor(c.level), padding: '2px 0', borderBottom: '1px dashed rgba(128,128,128,0.15)' }}>
|
||||||
|
<span style={{ opacity: 0.55, fontSize: '0.85em' }}>[{c.time?.slice(11, 19)} {c.level}]</span> {c.message}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function levelColor(lvl) {
|
||||||
|
switch (lvl) {
|
||||||
|
case 'error': return '#ff6b6b'
|
||||||
|
case 'warn': return '#f5a623'
|
||||||
|
case 'info': return '#4dabf7'
|
||||||
|
case 'debug': return '#888'
|
||||||
|
default: return 'inherit'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,13 +16,21 @@ 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',
|
||||||
},
|
},
|
||||||
|
|
||||||
dashboard: {
|
dashboard: {
|
||||||
systemOverview: 'System Overview',
|
systemOverview: 'System Overview',
|
||||||
tools: 'tools',
|
tools: 'Tools',
|
||||||
|
activity: 'Activity',
|
||||||
|
toolsCount: '{count} tools installed',
|
||||||
installed: 'Installed',
|
installed: 'Installed',
|
||||||
missing: 'Missing',
|
missing: 'Missing',
|
||||||
quickActions: 'Quick Actions',
|
quickActions: 'Quick Actions',
|
||||||
@@ -39,9 +47,20 @@ const en = {
|
|||||||
installStarted: 'Install started. Rescanning...',
|
installStarted: 'Install started. Rescanning...',
|
||||||
done: 'Done.',
|
done: 'Done.',
|
||||||
scanComplete: 'Scan complete.',
|
scanComplete: 'Scan complete.',
|
||||||
|
scanFailed: 'Scan failed',
|
||||||
updatesCount: '{count} updates available.',
|
updatesCount: '{count} updates available.',
|
||||||
allUpToDate: 'All tools up to date.',
|
allUpToDate: 'All tools up to date.',
|
||||||
mcpConfigured: 'MCP configured.',
|
mcpConfigured: 'MCP configured.',
|
||||||
|
mcpConfigFailed: 'MCP configuration failed',
|
||||||
|
status: 'Status',
|
||||||
|
clearLog: 'Clear',
|
||||||
|
noActivity: 'No recent activity.',
|
||||||
|
rescanning: 'Scanning...',
|
||||||
|
install: 'Install',
|
||||||
|
installFailed: 'Install failed',
|
||||||
|
checkUpdatesFailed: 'Check failed',
|
||||||
|
configuringMCP: 'Configuring MCP...',
|
||||||
|
mcpConfigFailed: 'MCP configuration failed',
|
||||||
},
|
},
|
||||||
|
|
||||||
studio: {
|
studio: {
|
||||||
@@ -76,14 +95,13 @@ const en = {
|
|||||||
steps: 'steps',
|
steps: 'steps',
|
||||||
you: 'You',
|
you: 'You',
|
||||||
mentioned: 'mentioned',
|
mentioned: 'mentioned',
|
||||||
|
cleared: 'Conversation cleared.',
|
||||||
|
cancelled: 'Request cancelled.',
|
||||||
|
stop: 'Stop',
|
||||||
},
|
},
|
||||||
|
|
||||||
shell: {
|
shell: {
|
||||||
terminal: 'Terminal',
|
terminal: 'Terminal',
|
||||||
hideAi: 'Hide AI',
|
|
||||||
aiAssistant: 'AI Assistant',
|
|
||||||
aiWelcome: 'I know your system inside out. Ask me anything.',
|
|
||||||
askAi: 'Ask AI...',
|
|
||||||
send: 'Send',
|
send: 'Send',
|
||||||
noResponse: 'No response',
|
noResponse: 'No response',
|
||||||
error: 'Error',
|
error: 'Error',
|
||||||
@@ -102,6 +120,8 @@ const en = {
|
|||||||
port: 'Port',
|
port: 'Port',
|
||||||
user: 'User',
|
user: 'User',
|
||||||
keyPath: 'SSH key path',
|
keyPath: 'SSH key path',
|
||||||
|
password: 'Password',
|
||||||
|
passwordHint: 'requires sshpass installed',
|
||||||
connect: 'Connect',
|
connect: 'Connect',
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
@@ -110,15 +130,22 @@ const en = {
|
|||||||
systemTerminals: 'System terminals',
|
systemTerminals: 'System terminals',
|
||||||
switchTerminal: 'Switch terminal',
|
switchTerminal: 'Switch terminal',
|
||||||
localShell: 'Local Shell',
|
localShell: 'Local Shell',
|
||||||
|
aiAssistant: 'AI Assistant',
|
||||||
|
aiWelcome: 'Hello! I can help you with terminal commands. Ask me anything!',
|
||||||
|
askAi: 'Ask AI assistant...',
|
||||||
|
toolLaunched: 'Tool launched',
|
||||||
|
toolResult: 'Result',
|
||||||
},
|
},
|
||||||
|
|
||||||
config: {
|
config: {
|
||||||
panels: {
|
panels: {
|
||||||
profile: 'Profile',
|
profile: 'Profile',
|
||||||
providers: 'AI Providers',
|
providers: 'AI Providers',
|
||||||
|
terminal: 'Terminal',
|
||||||
updates: 'Updates',
|
updates: 'Updates',
|
||||||
locale: 'Language & Keyboard',
|
locale: 'Language & Keyboard',
|
||||||
skills: 'Skills',
|
skills: 'Skills',
|
||||||
|
system: 'System',
|
||||||
},
|
},
|
||||||
profile: 'Profile',
|
profile: 'Profile',
|
||||||
name: 'Name',
|
name: 'Name',
|
||||||
@@ -163,8 +190,48 @@ 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',
|
||||||
|
validating: 'Validating...',
|
||||||
|
keyValid: 'Valid key',
|
||||||
|
keyInvalid: 'Invalid key',
|
||||||
|
connectionFailed: 'Connection failed',
|
||||||
|
enterToken: 'Enter your API token for {provider}',
|
||||||
|
tokenPlaceholder: 'sk-...',
|
||||||
|
setupDescription: 'Configure your AI provider token to use the assistant.',
|
||||||
|
terminalTheme: 'Terminal Theme',
|
||||||
|
fontSize: 'Font Size',
|
||||||
|
fontFamily: 'Font Family',
|
||||||
|
preview: 'Preview',
|
||||||
|
saving: 'Saving...',
|
||||||
|
resetConfig: 'Reset all',
|
||||||
|
resetConfirm: 'Are you sure? All preferences will be erased.',
|
||||||
|
resetDone: 'Settings reset.',
|
||||||
|
applyStarship: 'Apply starship',
|
||||||
|
apply: 'Apply',
|
||||||
|
remove: 'Remove',
|
||||||
|
starshipApplied: 'Starship theme applied! Restart your shell to see the result.',
|
||||||
|
starshipError: 'Failed to apply starship theme.',
|
||||||
|
systemConfig: 'System Configuration',
|
||||||
|
aiToolsConfig: 'Tools & Environments',
|
||||||
|
configureViaAI: 'Configure',
|
||||||
|
toolCrushDesc: 'Autonomous AI agent for code writing and refactoring.',
|
||||||
|
toolClaudeDesc: 'AI coding assistant by Anthropic.',
|
||||||
|
toolGhDesc: 'Command-line interface for GitHub.',
|
||||||
|
toolDockerDesc: 'Application containerization platform.',
|
||||||
|
toolGoDesc: 'Programming language and runtime environment.',
|
||||||
|
toolNodeDesc: 'JavaScript runtime and package manager.',
|
||||||
|
toolPythonDesc: 'Programming language, pip and uv manager.',
|
||||||
|
toolStarshipDesc: 'Modern and customizable shell prompt.',
|
||||||
|
systemUpdate: 'System Update',
|
||||||
|
systemUpdateDescSudo: 'Updates the system and all tools (sshpass, crush, claude, gh, etc.).',
|
||||||
|
systemUpdateDescNoSudo: 'Shows update commands to run manually.',
|
||||||
|
updateBtn: 'Update',
|
||||||
|
notInstalled: 'Not installed',
|
||||||
|
install: 'Install',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,13 +16,21 @@ 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',
|
||||||
},
|
},
|
||||||
|
|
||||||
dashboard: {
|
dashboard: {
|
||||||
systemOverview: 'Vue d\u2019ensemble du syst\u00e8me',
|
systemOverview: 'Vue d\u2019ensemble du syst\u00e8me',
|
||||||
tools: 'outils',
|
tools: 'Outils',
|
||||||
|
activity: 'Activit\u00e9',
|
||||||
|
toolsCount: '{count} outils install\u00e9s',
|
||||||
installed: 'Install\u00e9',
|
installed: 'Install\u00e9',
|
||||||
missing: 'Manquant',
|
missing: 'Manquant',
|
||||||
quickActions: 'Actions rapides',
|
quickActions: 'Actions rapides',
|
||||||
@@ -39,9 +47,20 @@ const fr = {
|
|||||||
installStarted: 'Installation lanc\u00e9e. Rescan en cours...',
|
installStarted: 'Installation lanc\u00e9e. Rescan en cours...',
|
||||||
done: 'Termin\u00e9.',
|
done: 'Termin\u00e9.',
|
||||||
scanComplete: 'Scan termin\u00e9.',
|
scanComplete: 'Scan termin\u00e9.',
|
||||||
|
scanFailed: '\u00c9chec du scan',
|
||||||
updatesCount: '{count} mises \u00e0 jour disponibles.',
|
updatesCount: '{count} mises \u00e0 jour disponibles.',
|
||||||
allUpToDate: 'Tous les outils sont \u00e0 jour.',
|
allUpToDate: 'Tous les outils sont \u00e0 jour.',
|
||||||
mcpConfigured: 'MCP configur\u00e9.',
|
mcpConfigured: 'MCP configur\u00e9.',
|
||||||
|
status: 'Statut',
|
||||||
|
noTools: 'Aucun outil d\u00e9tect\u00e9. Ex\u00e9cutez un scan.',
|
||||||
|
clearLog: 'Effacer',
|
||||||
|
noActivity: 'Aucune activit\u00e9 r\u00e9cente.',
|
||||||
|
rescanning: 'Scan en cours...',
|
||||||
|
install: 'Installer',
|
||||||
|
installFailed: '\u00c9chec de l\u2019installation',
|
||||||
|
checkUpdatesFailed: '\u00c9chec de la v\u00e9rification',
|
||||||
|
configuringMCP: 'Configuration MCP en cours...',
|
||||||
|
mcpConfigFailed: '\u00c9chec de la configuration MCP',
|
||||||
},
|
},
|
||||||
|
|
||||||
studio: {
|
studio: {
|
||||||
@@ -76,14 +95,13 @@ const fr = {
|
|||||||
steps: '\u00e9tapes',
|
steps: '\u00e9tapes',
|
||||||
you: 'Vous',
|
you: 'Vous',
|
||||||
mentioned: 'mentionn\u00e9',
|
mentioned: 'mentionn\u00e9',
|
||||||
|
cleared: 'Conversation effac\u00e9e.',
|
||||||
|
cancelled: 'Requ\u00eate annul\u00e9e.',
|
||||||
|
stop: 'Stop',
|
||||||
},
|
},
|
||||||
|
|
||||||
shell: {
|
shell: {
|
||||||
terminal: 'Terminal',
|
terminal: 'Terminal',
|
||||||
hideAi: 'Masquer IA',
|
|
||||||
aiAssistant: 'Assistant IA',
|
|
||||||
aiWelcome: 'Je connais votre syst\u00e8me sur le bout des doigts. Demandez-moi n\u2019importe quoi.',
|
|
||||||
askAi: 'Demander \u00e0 l\u2019IA...',
|
|
||||||
send: 'Envoyer',
|
send: 'Envoyer',
|
||||||
noResponse: 'Pas de r\u00e9ponse',
|
noResponse: 'Pas de r\u00e9ponse',
|
||||||
error: 'Erreur',
|
error: 'Erreur',
|
||||||
@@ -102,6 +120,8 @@ const fr = {
|
|||||||
port: 'Port',
|
port: 'Port',
|
||||||
user: 'Utilisateur',
|
user: 'Utilisateur',
|
||||||
keyPath: 'Chemin cl\u00e9 SSH',
|
keyPath: 'Chemin cl\u00e9 SSH',
|
||||||
|
password: 'Mot de passe',
|
||||||
|
passwordHint: 'n\u00e9cessite sshpass install\u00e9',
|
||||||
connect: 'Se connecter',
|
connect: 'Se connecter',
|
||||||
save: 'Enregistrer',
|
save: 'Enregistrer',
|
||||||
cancel: 'Annuler',
|
cancel: 'Annuler',
|
||||||
@@ -110,15 +130,22 @@ const fr = {
|
|||||||
systemTerminals: 'Terminaux syst\u00e8me',
|
systemTerminals: 'Terminaux syst\u00e8me',
|
||||||
switchTerminal: 'Changer de terminal',
|
switchTerminal: 'Changer de terminal',
|
||||||
localShell: 'Shell local',
|
localShell: 'Shell local',
|
||||||
|
aiAssistant: 'Assistant IA',
|
||||||
|
aiWelcome: 'Bonjour ! Je peux vous aider avec les commandes du terminal. Demandez-moi n\'importe quoi !',
|
||||||
|
askAi: 'Interroger l\'assistant IA...',
|
||||||
|
toolLaunched: 'Outil lanc\u00e9',
|
||||||
|
toolResult: 'R\u00e9sultat',
|
||||||
},
|
},
|
||||||
|
|
||||||
config: {
|
config: {
|
||||||
panels: {
|
panels: {
|
||||||
profile: 'Profil',
|
profile: 'Profil',
|
||||||
providers: 'Fournisseurs IA',
|
providers: 'Fournisseurs IA',
|
||||||
|
terminal: 'Terminal',
|
||||||
updates: 'Mises \u00e0 jour',
|
updates: 'Mises \u00e0 jour',
|
||||||
locale: 'Langue & Clavier',
|
locale: 'Langue & Clavier',
|
||||||
skills: 'Comp\u00e9tences',
|
skills: 'Compétences',
|
||||||
|
system: 'Syst\u00e8me',
|
||||||
},
|
},
|
||||||
profile: 'Profil',
|
profile: 'Profil',
|
||||||
name: 'Nom',
|
name: 'Nom',
|
||||||
@@ -141,7 +168,7 @@ const fr = {
|
|||||||
save: 'Enregistrer',
|
save: 'Enregistrer',
|
||||||
saved: 'Enregistr\u00e9 !',
|
saved: 'Enregistr\u00e9 !',
|
||||||
error: 'Erreur',
|
error: 'Erreur',
|
||||||
skills: 'Comp\u00e9tences',
|
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',
|
||||||
@@ -163,8 +190,48 @@ 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',
|
||||||
|
validating: 'V\u00e9rification...',
|
||||||
|
keyValid: 'Cl\u00e9 valide',
|
||||||
|
keyInvalid: 'Cl\u00e9 invalide',
|
||||||
|
connectionFailed: 'Connexion \u00e9chou\u00e9e',
|
||||||
|
enterToken: 'Entrez votre token API pour {provider}',
|
||||||
|
tokenPlaceholder: 'sk-...',
|
||||||
|
setupDescription: 'Configurez le token de votre fournisseur IA pour utiliser l\'assistant.',
|
||||||
cancel: 'Annuler',
|
cancel: 'Annuler',
|
||||||
|
terminalTheme: 'Th\u00e8me du terminal',
|
||||||
|
fontSize: 'Taille de police',
|
||||||
|
fontFamily: 'Police',
|
||||||
|
preview: 'Aper\u00e7u',
|
||||||
|
saving: 'Enregistrement...',
|
||||||
|
resetConfig: 'R\u00e9initialiser',
|
||||||
|
resetConfirm: '\u00cates-vous s\u00fbr ? Toutes les pr\u00e9f\u00e9rences seront effac\u00e9es.',
|
||||||
|
resetDone: 'Param\u00e8tres r\u00e9initialis\u00e9s.',
|
||||||
|
applyStarship: 'Appliquer starship',
|
||||||
|
apply: 'Appliquer',
|
||||||
|
remove: 'Retirer',
|
||||||
|
starshipApplied: 'Th\u00e8me starship appliqu\u00e9 ! Red\u00e9marrez votre shell pour voir le r\u00e9sultat.',
|
||||||
|
starshipError: '\u00c9chec de l\u2019application du th\u00e8me starship.',
|
||||||
|
systemConfig: 'Configuration Syst\u00e8me',
|
||||||
|
aiToolsConfig: 'Outils & Environnements',
|
||||||
|
configureViaAI: 'Configurer',
|
||||||
|
toolCrushDesc: 'Agent IA autonome pour l\u2019\u00e9criture et le refactoring de code.',
|
||||||
|
toolClaudeDesc: 'Assistant de codage IA par Anthropic.',
|
||||||
|
toolGhDesc: 'Interface en ligne de commande pour GitHub.',
|
||||||
|
toolDockerDesc: 'Plateforme de conteneurisation d\u2019applications.',
|
||||||
|
toolGoDesc: 'Langage de programmation et environnement d\u2019ex\u00e9cution.',
|
||||||
|
toolNodeDesc: 'Environnement d\u2019ex\u00e9cution JavaScript et gestionnaire de paquets.',
|
||||||
|
toolPythonDesc: 'Langage de programmation, pip et gestionnaire uv.',
|
||||||
|
toolStarshipDesc: 'Prompt shell moderne et personnalisable.',
|
||||||
|
systemUpdate: 'Mise à jour système',
|
||||||
|
systemUpdateDescSudo: 'Met à jour le système et tous les outils (sshpass, crush, claude, gh, etc.).',
|
||||||
|
systemUpdateDescNoSudo: 'Affiche les commandes de mise à jour à exécuter manuellement.',
|
||||||
|
updateBtn: 'Mettre à jour',
|
||||||
|
notInstalled: 'Non installé',
|
||||||
|
install: 'Installer',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export default defineConfig({
|
|||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://127.0.0.1:8095',
|
target: 'http://127.0.0.1:8095',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||