Compare commits
234 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
633df84168 | ||
|
|
8e4fdfecf3 | ||
|
|
e1fe0881cf | ||
|
|
053cbca779 | ||
|
|
fd03423062 | ||
|
|
db6e7a1bf8 | ||
|
|
face2637da | ||
|
|
5a39a3a804 | ||
|
|
b7b66634ea | ||
|
|
591dc5adcd | ||
|
|
be40fa278f | ||
|
|
3f4432d88a | ||
|
|
8b8f217624 | ||
|
|
3f36974b59 | ||
|
|
55cd00802d | ||
|
|
cd9ae5f4b9 | ||
|
|
346c464ed5 | ||
|
|
3445726b67 | ||
|
|
5875dab17f | ||
|
|
4523bbd42c | ||
|
|
62c20eb174 | ||
|
|
31c99e7479 | ||
|
|
f4af63afec | ||
|
|
b5e5b302f2 | ||
|
|
872e8bfa75 | ||
|
|
31b1de1b0d | ||
|
|
9f014448a1 | ||
|
|
5094815de1 | ||
|
|
693b0e932e | ||
|
|
a60bd92858 | ||
|
|
9f9f2bd2c6 | ||
|
|
97a25295fc | ||
|
|
5fd8cceabd | ||
|
|
a3487392c0 | ||
|
|
6e4ddc192e | ||
|
|
71978adb5f | ||
|
|
af5fbf9324 | ||
|
|
29953bde6d | ||
|
|
6d155e483b | ||
|
|
e621b13926 | ||
|
|
9d1d717999 | ||
|
|
d557b8e74c | ||
|
|
e31a01d200 | ||
|
|
b3a9a49680 | ||
|
|
87e606c853 | ||
|
|
79e467c32a | ||
|
|
075d168dcd | ||
|
|
ed4c963576 | ||
|
|
1ce5c49622 | ||
|
|
830e085c2a | ||
|
|
f8d706cdca | ||
|
|
24b09f5700 | ||
|
|
a9eedab0b5 | ||
|
|
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 | ||
|
|
0fe82f67df | ||
|
|
4b9f2c377d | ||
|
|
3b6cc38ea0 | ||
|
|
93a22d4075 | ||
|
|
e0e1e73bca | ||
|
|
0496ca789b | ||
|
|
b407ab879b | ||
|
|
12df184e11 | ||
|
|
8af6d25e28 | ||
|
|
4fd599adec | ||
|
|
bcba5932d5 | ||
|
|
04b0fff791 |
@@ -32,13 +32,13 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Cache Node modules
|
||||
- name: Cache Node modules (web)
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: web/node_modules
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('web/package-lock.json') }}
|
||||
key: ${{ runner.os }}-node-web-${{ hashFiles('web/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
${{ runner.os }}-node-web-
|
||||
|
||||
- name: Download Go dependencies
|
||||
run: go mod download
|
||||
@@ -49,6 +49,14 @@ jobs:
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Build extension
|
||||
run: |
|
||||
cd extension
|
||||
npm ci
|
||||
npx wxt zip
|
||||
mkdir -p ../dist
|
||||
mv .output/muyue-extension-*.zip ../dist/
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
@@ -68,17 +76,25 @@ jobs:
|
||||
echo "beta_num=${BETA_NUM}" >> $GITHUB_OUTPUT
|
||||
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)
|
||||
run: |
|
||||
mkdir -p dist
|
||||
VERSION=${{ steps.version.outputs.version }}
|
||||
LDFLAGS="-s -w -X github.com/muyue/muyue/internal/version.Prerelease=${VERSION#v}"
|
||||
WIN_LDFLAGS="$LDFLAGS -H=windowsgui"
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-linux-amd64 ./cmd/muyue/
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-linux-arm64 ./cmd/muyue/
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-darwin-amd64 ./cmd/muyue/
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-darwin-arm64 ./cmd/muyue/
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-amd64.exe ./cmd/muyue/
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-arm64.exe ./cmd/muyue/
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$WIN_LDFLAGS" -o dist/muyue-windows-amd64.exe ./cmd/muyue/
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$WIN_LDFLAGS" -o dist/muyue-windows-arm64.exe ./cmd/muyue/
|
||||
|
||||
- name: Package archives
|
||||
run: |
|
||||
@@ -144,7 +160,7 @@ jobs:
|
||||
fi
|
||||
echo "Release ID: ${RELEASE_ID}"
|
||||
UPLOAD_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets"
|
||||
for file in dist/*.tar.gz dist/*.zip dist/checksums.txt; do
|
||||
for file in dist/*.tar.gz dist/muyue-windows-*.zip dist/checksums.txt dist/muyue-extension-*.zip; do
|
||||
filename=$(basename "$file")
|
||||
echo "Uploading ${filename}..."
|
||||
curl -s -X POST "${UPLOAD_URL}" \
|
||||
|
||||
@@ -32,13 +32,13 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Cache Node modules
|
||||
- name: Cache Node modules (web)
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: web/node_modules
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('web/package-lock.json') }}
|
||||
key: ${{ runner.os }}-node-web-${{ hashFiles('web/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
${{ runner.os }}-node-web-
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
@@ -49,6 +49,14 @@ jobs:
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Build extension
|
||||
run: |
|
||||
cd extension
|
||||
npm ci
|
||||
npx wxt zip
|
||||
mkdir -p ../dist
|
||||
mv .output/muyue-extension-*.zip ../dist/
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
@@ -64,16 +72,28 @@ jobs:
|
||||
echo "base=${BASE_VERSION}" >> $GITHUB_OUTPUT
|
||||
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)
|
||||
run: |
|
||||
mkdir -p dist
|
||||
LDFLAGS="-s -w"
|
||||
# Windows builds use -H=windowsgui so the binary registers as a GUI
|
||||
# subsystem app: double-clicking from the Desktop shortcut does not
|
||||
# spawn a console window (and huh's "This is a command line tool"
|
||||
# banner can never appear).
|
||||
WIN_LDFLAGS="$LDFLAGS -H=windowsgui"
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-linux-amd64 ./cmd/muyue/
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-linux-arm64 ./cmd/muyue/
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-darwin-amd64 ./cmd/muyue/
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-darwin-arm64 ./cmd/muyue/
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-amd64.exe ./cmd/muyue/
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-arm64.exe ./cmd/muyue/
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$WIN_LDFLAGS" -o dist/muyue-windows-amd64.exe ./cmd/muyue/
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$WIN_LDFLAGS" -o dist/muyue-windows-arm64.exe ./cmd/muyue/
|
||||
|
||||
- name: Package archives
|
||||
run: |
|
||||
@@ -138,12 +158,17 @@ jobs:
|
||||
echo "sudo mv muyue-darwin-arm64 /usr/local/bin/muyue"
|
||||
echo "\`\`\`"
|
||||
echo ""
|
||||
echo "**Windows (x86_64)**"
|
||||
echo "**Windows (x86_64)** — sans privilèges admin, crée les raccourcis Bureau + Menu Démarrer + commande \`muyue\` dans la session courante :"
|
||||
echo "\`\`\`powershell"
|
||||
echo "Invoke-WebRequest -Uri \"${DL_URL}/muyue-windows-amd64.zip\" -OutFile \"muyue.zip\""
|
||||
echo "Expand-Archive -Path \"muyue.zip\" -DestinationPath \".\""
|
||||
echo "Move-Item muyue-windows-amd64.exe C:\\Windows\\muyue.exe"
|
||||
echo "Get-Process muyue, muyue-windows-amd64 -ErrorAction SilentlyContinue | Stop-Process -Force; Start-Sleep -Milliseconds 500"
|
||||
echo "\$dest = \"\$env:LOCALAPPDATA\\Muyue\"; New-Item -ItemType Directory -Force -Path \$dest | Out-Null"
|
||||
echo "Invoke-WebRequest -Uri \"${DL_URL}/muyue-windows-amd64.zip\" -OutFile \"\$env:TEMP\\muyue.zip\""
|
||||
echo "Expand-Archive -Path \"\$env:TEMP\\muyue.zip\" -DestinationPath \$dest -Force"
|
||||
echo "& \"\$dest\\muyue-windows-amd64.exe\" install-shortcuts"
|
||||
echo "\$env:Path += \";\$dest\""
|
||||
echo "\`\`\`"
|
||||
echo ""
|
||||
echo "Le 1ʳᵉ ligne tue toute instance Muyue déjà lancée (sinon Windows refuse d'écraser le \`.exe\` verrouillé et l'install échoue silencieusement). Si vous mettez à jour depuis une version précédente, c'est obligatoire."
|
||||
} > /tmp/stable_changelog.md
|
||||
echo "path=/tmp/stable_changelog.md" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -170,7 +195,7 @@ jobs:
|
||||
|
||||
- name: Commit changelog
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
git config user.name "CI Bot"
|
||||
git config user.email "ci@legion-muyue.fr"
|
||||
@@ -181,39 +206,58 @@ jobs:
|
||||
|
||||
- name: Create release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -ex
|
||||
if [ -z "$GITEA_TOKEN" ]; then
|
||||
echo "Warning: GITEATOKEN not set, skipping release"
|
||||
exit 0
|
||||
echo "Error: GITEA_TOKEN secret is not set"
|
||||
exit 1
|
||||
fi
|
||||
VERSION=${{ steps.version.outputs.version }}
|
||||
API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
|
||||
BODY=$(cat /tmp/stable_changelog.md)
|
||||
RESPONSE=$(curl -s -X POST "${API}" \
|
||||
echo "Creating release ${VERSION} at ${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 "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"tag_name\":\"${VERSION}\",
|
||||
\"target_commitish\":\"main\",
|
||||
\"name\":\"muyue ${VERSION}\",
|
||||
\"body\":$(echo "$BODY" | jq -Rs .),
|
||||
\"body\":${BODY},
|
||||
\"draft\":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
|
||||
echo "Failed to create release:"
|
||||
echo "$RESPONSE"
|
||||
echo "Failed to create release"
|
||||
exit 1
|
||||
fi
|
||||
echo "Release ID: ${RELEASE_ID}"
|
||||
UPLOAD_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets"
|
||||
for file in dist/*.tar.gz dist/*.zip dist/checksums.txt; do
|
||||
for file in dist/*.tar.gz dist/muyue-windows-*.zip dist/checksums.txt dist/muyue-extension-*.zip; do
|
||||
filename=$(basename "$file")
|
||||
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}" \
|
||||
-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
|
||||
echo "Stable release ${VERSION} published!"
|
||||
|
||||
@@ -30,13 +30,21 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Cache Node modules
|
||||
- name: Cache Node modules (web)
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: web/node_modules
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('web/package-lock.json') }}
|
||||
key: ${{ runner.os }}-node-web-${{ hashFiles('web/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
${{ runner.os }}-node-web-
|
||||
|
||||
- name: Cache Node modules (extension)
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: extension/node_modules
|
||||
key: ${{ runner.os }}-node-ext-${{ hashFiles('extension/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-ext-
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
@@ -47,13 +55,20 @@ jobs:
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Build extension
|
||||
run: |
|
||||
cd extension
|
||||
npm ci
|
||||
npm run build
|
||||
npm run build:firefox
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: Test
|
||||
run: go test ./... -v -race -timeout 60s
|
||||
|
||||
- name: Build
|
||||
- name: Build binary
|
||||
run: |
|
||||
go build -o muyue ./cmd/muyue/
|
||||
./muyue version
|
||||
|
||||
6
.gitignore
vendored
@@ -24,6 +24,7 @@ Thumbs.db
|
||||
*.exe
|
||||
*.test
|
||||
*.out
|
||||
*.syso
|
||||
vendor/
|
||||
|
||||
# Config with secrets
|
||||
@@ -31,3 +32,8 @@ vendor/
|
||||
|
||||
# Frontend (web/.gitignore handles specifics)
|
||||
web/node_modules/
|
||||
|
||||
# Extension build artifacts
|
||||
extension/node_modules/
|
||||
extension/.output/
|
||||
extension/.wxt/
|
||||
|
||||
1600
CHANGELOG.md
1073
CRUSH_ARCHITECTURE_REPORT.md
Normal file
BIN
LogoMuyue.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
10
Makefile
@@ -7,7 +7,9 @@ NODE ?= node
|
||||
NPM ?= npm
|
||||
WEB_DIR = web
|
||||
|
||||
.PHONY: build install clean test test-short run scan fmt lint build-all deps vet frontend dev-desktop
|
||||
EXT_DIR = extension
|
||||
|
||||
.PHONY: build install clean test test-short run scan fmt lint build-all deps vet frontend dev-desktop ext ext-zip
|
||||
|
||||
frontend:
|
||||
cd $(WEB_DIR) && $(NPM) ci && $(NPM) run build
|
||||
@@ -63,5 +65,11 @@ build-all: frontend
|
||||
GOOS=windows GOARCH=amd64 $(GO) build -o dist/$(BINARY)-windows-amd64.exe ./cmd/muyue/
|
||||
GOOS=windows GOARCH=arm64 $(GO) build -o dist/$(BINARY)-windows-arm64.exe ./cmd/muyue/
|
||||
|
||||
ext:
|
||||
cd $(EXT_DIR) && $(NPM) ci && $(NPM) run build
|
||||
|
||||
ext-zip:
|
||||
cd $(EXT_DIR) && $(NPM) ci && $(NPM) run zip
|
||||
|
||||
deps:
|
||||
$(GO) mod tidy
|
||||
|
||||
43
README.md
@@ -17,6 +17,45 @@ AI-powered development environment assistant by **La Légion de Muyue**.
|
||||
- **i18n** — Full FR/EN support with keyboard layout awareness (AZERTY, QWERTY, QWERTZ)
|
||||
- **4 themes** — Cyberpunk Red, Cyberpunk Pink, Midnight Blue, Matrix Green
|
||||
|
||||
## Browser Extension
|
||||
|
||||
Muyue ships a **browser extension** (Chrome, Edge, Firefox) that replaces the manual snippet injection for the Tests tab:
|
||||
|
||||
- **Auto-injects** the Muyue test client on every HTTP/HTTPS page — no more copy-paste
|
||||
- **Captures console** errors/warnings in real-time
|
||||
- **Native screenshots** via `captureVisibleTab` — pixel-perfect
|
||||
- **Side Panel** (Chrome/Edge) and **Sidebar** (Firefox) for status monitoring
|
||||
- **Badge** shows active session count or server status
|
||||
|
||||
### Install from source
|
||||
|
||||
```bash
|
||||
cd extension
|
||||
npm install
|
||||
npm run build # Chrome/Edge → .output/chrome-mv3/
|
||||
npm run build:firefox # Firefox → .output/firefox-mv2/
|
||||
```
|
||||
|
||||
Then load the extension:
|
||||
- **Chrome/Edge**: `chrome://extensions` → Developer mode → Load unpacked → select `extension/.output/chrome-mv3/`
|
||||
- **Firefox**: `about:debugging#/runtime/this-firefox` → Load temporary Add-on → select any file in `extension/.output/firefox-mv2/`
|
||||
|
||||
### Download pre-built
|
||||
|
||||
Extension `.zip` files are attached to every [release](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases):
|
||||
|
||||
- `muyue-extension-*-chrome.zip` — Chrome Web Store ready
|
||||
- `muyue-extension-*-firefox.zip` — Firefox Add-ons ready
|
||||
- `muyue-extension-*-sources.zip` — Required source for Firefox Add-ons review
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
cd extension
|
||||
npm run dev # Chrome dev mode with HMR
|
||||
npm run dev -- --browser firefox # Firefox dev mode
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
@@ -186,6 +225,10 @@ The Go backend serves 15 REST endpoints under `/api/`:
|
||||
│ │ ├── styles/global.css # Full CSS theme system
|
||||
│ │ └── themes/index.js # 4 themes with CSS variable injection
|
||||
│ └── vite.config.js # Vite + dev proxy to :8095
|
||||
├── extension/ # Browser extension (WXT, Chrome/Edge/Firefox)
|
||||
│ ├── src/entrypoints/ # background, content, popup, sidepanel
|
||||
│ ├── src/lib/ # config, page-rpc (shared logic)
|
||||
│ └── src/styles/ # cyberpunk panel CSS
|
||||
├── .gitea/workflows/ # CI/CD (PR check, beta, stable)
|
||||
└── Makefile # build, test, lint, cross-compile
|
||||
```
|
||||
|
||||
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
|
||||
}
|
||||
189
cmd/muyue/commands/install_shortcuts.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"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(" Source : %s\n", exe)
|
||||
|
||||
// Provide a clean `muyue.exe` next to the platform-suffixed binary so
|
||||
// users can type `muyue` once the install dir is on PATH. Copy (not
|
||||
// rename) because the running .exe is locked on Windows.
|
||||
canonicalExe := filepath.Join(installDir, "muyue.exe")
|
||||
if !strings.EqualFold(exe, canonicalExe) {
|
||||
if err := copyFile(exe, canonicalExe); err != nil {
|
||||
fmt.Fprintf(os.Stderr, " Copy : warning — could not create muyue.exe: %v\n", err)
|
||||
canonicalExe = exe
|
||||
} else {
|
||||
fmt.Printf(" Canonical : %s\n", canonicalExe)
|
||||
}
|
||||
}
|
||||
|
||||
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, canonicalExe, 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, canonicalExe, 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\n", installDir)
|
||||
}
|
||||
|
||||
fmt.Println("\nDone — double-click the Muyue icon on your Desktop to launch.")
|
||||
fmt.Println("\nTo use 'muyue' from this PowerShell session right now, run:")
|
||||
fmt.Printf(" $env:Path += ';%s'\n", installDir)
|
||||
fmt.Println("(New terminals will pick up the user PATH automatically.)")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// copyFile duplicates src to dst, overwriting an existing dst (used to drop a
|
||||
// `muyue.exe` next to the platform-suffixed binary so the command is callable
|
||||
// as `muyue` from PATH).
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
return out.Sync()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
97
cmd/muyue/commands/root.go
Normal file
@@ -0,0 +1,97 @@
|
||||
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()
|
||||
}
|
||||
|
||||
// isInteractiveStdin reports whether os.Stdin is connected to a real terminal.
|
||||
// Used to decide between the TUI first-time setup (huh forms) and a no-op
|
||||
// fallback that defers onboarding to the web wizard. Returns false when the
|
||||
// binary is launched by a double-click on Windows (Explorer attaches a pseudo
|
||||
// console without a usable TTY) — which is the exact case where huh prints
|
||||
// "This is a command line tool. You need to open cmd.exe and run it from there."
|
||||
// and exits.
|
||||
func isInteractiveStdin() bool {
|
||||
stat, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return (stat.Mode() & os.ModeCharDevice) != 0
|
||||
}
|
||||
|
||||
func loadOrSetupConfig() *config.MuyueConfig {
|
||||
if !config.Exists() {
|
||||
// No config yet. If we have a real terminal, run the rich TUI setup
|
||||
// (huh forms). Otherwise — typically when the user double-clicked the
|
||||
// shortcut on Windows — write defaults silently and let the React
|
||||
// onboarding wizard handle the real first-run flow once the browser
|
||||
// opens. This avoids huh aborting with "This is a command line tool".
|
||||
if isInteractiveStdin() {
|
||||
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
|
||||
}
|
||||
|
||||
// Non-interactive — skip the TUI, persist defaults, web onboarding
|
||||
// will fill in the profile / API keys.
|
||||
cfg := config.Default()
|
||||
if err := config.Save(cfg); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Save error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
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
|
||||
}
|
||||
54
cmd/muyue/console_windows.go
Normal file
@@ -0,0 +1,54 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
// Windows-only: with -H=windowsgui the binary is registered as a GUI
|
||||
// subsystem app, so double-clicking from the Desktop shortcut does NOT
|
||||
// spawn a console window (good for the desktop UX). The downside is that
|
||||
// sub-commands like `muyue scan`, `muyue version`, `muyue install-shortcuts`
|
||||
// produce no output when invoked from cmd.exe.
|
||||
//
|
||||
// Workaround: at process start, try to attach to the parent's console via
|
||||
// kernel32!AttachConsole(ATTACH_PARENT_PROCESS). If the parent has a console
|
||||
// (i.e. we were launched from cmd.exe / PowerShell), stdout/stderr/stdin are
|
||||
// rebound to it. If not (Explorer double-click), the call fails silently and
|
||||
// the binary runs without any console — exactly what we want.
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
const attachParentProcess = ^uint32(0) // -1 cast to DWORD
|
||||
|
||||
func init() {
|
||||
kernel32, err := syscall.LoadLibrary("kernel32.dll")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer syscall.FreeLibrary(kernel32)
|
||||
attachConsole, err := syscall.GetProcAddress(kernel32, "AttachConsole")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r0, _, _ := syscall.SyscallN(attachConsole, uintptr(attachParentProcess))
|
||||
if r0 == 0 {
|
||||
return // parent has no console (Explorer launch) — stay silent
|
||||
}
|
||||
// Re-bind the standard streams to the freshly attached console so
|
||||
// fmt.Println / log output appear in the parent terminal.
|
||||
if h, err := syscall.GetStdHandle(syscall.STD_OUTPUT_HANDLE); err == nil && h != 0 {
|
||||
os.Stdout = os.NewFile(uintptr(h), "stdout")
|
||||
}
|
||||
if h, err := syscall.GetStdHandle(syscall.STD_ERROR_HANDLE); err == nil && h != 0 {
|
||||
os.Stderr = os.NewFile(uintptr(h), "stderr")
|
||||
}
|
||||
if h, err := syscall.GetStdHandle(syscall.STD_INPUT_HANDLE); err == nil && h != 0 {
|
||||
os.Stdin = os.NewFile(uintptr(h), "stdin")
|
||||
}
|
||||
// log.Default() captured the original os.Stderr at init time — repoint it
|
||||
// at the freshly attached console so log.Printf calls (e.g. desktop.Run)
|
||||
// surface in the parent terminal.
|
||||
log.SetOutput(os.Stderr)
|
||||
}
|
||||
@@ -4,51 +4,12 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
"github.com/muyue/muyue/internal/desktop"
|
||||
"github.com/muyue/muyue/internal/profiler"
|
||||
"github.com/muyue/muyue/cmd/muyue/commands"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := loadOrSetupConfig()
|
||||
if err := desktop.Run(cfg, os.Args[1:]); err != nil {
|
||||
if err := commands.Execute(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
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
|
||||
}
|
||||
}
|
||||
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*
|
||||
4
extension/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.output/
|
||||
.wxt/
|
||||
*.zip
|
||||
81
extension/README.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Muyue Browser Extension
|
||||
|
||||
AI-powered browser testing & automation, connected to your [Muyue](https://github.com/muyue/muyue) desktop app.
|
||||
|
||||
## What it does
|
||||
|
||||
- **Auto-injects** the Muyue test client on every page — no more manual snippet copy-paste
|
||||
- **Captures console** errors/warnings in real-time, sent to the AI Studio
|
||||
- **Enables AI-driven testing**: click buttons, fill inputs, evaluate JS, take screenshots
|
||||
- **Side Panel** (Chrome/Edge) and **Sidebar** (Firefox) for status monitoring
|
||||
- **Native screenshots** via `chrome.tabs.captureVisibleTab` — pixel-perfect, no SVG hacks
|
||||
- **URL change detection** via History API interception (survives SPA navigation)
|
||||
- **Badge indicator**: shows connected session count or server status
|
||||
|
||||
## Install
|
||||
|
||||
### Chrome / Edge
|
||||
|
||||
1. Run `npm run build`
|
||||
2. Open `chrome://extensions` → Enable **Developer mode**
|
||||
3. Click **Load unpacked** → select `extension/.output/chrome-mv3/`
|
||||
|
||||
Or install the published extension from the Chrome Web Store.
|
||||
|
||||
### Firefox
|
||||
|
||||
1. Run `npm run build:firefox`
|
||||
2. Open `about:debugging#/runtime/this-firefox`
|
||||
3. Click **Load temporary Add-on** → select any file in `extension/.output/firefox-mv2/`
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cd extension
|
||||
npm install
|
||||
npm run dev # Chrome dev mode with HMR
|
||||
npm run dev -- --browser firefox # Firefox dev mode
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm run build # Chrome/Edge MV3 → .output/chrome-mv3/
|
||||
npm run build:firefox # Firefox MV2 → .output/firefox-mv2/
|
||||
npm run zip # Chrome .zip for Web Store
|
||||
npm run zip:firefox # Firefox .zip + sources .zip
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Content Script (every HTTP/HTTPS page) │
|
||||
│ - Console interception (log/warn/error) │
|
||||
│ - RPC execution (click, type, eval, list) │
|
||||
│ - URL change detection (History API + MutationObs) │
|
||||
│ - WebSocket → Muyue server (same as snippet) │
|
||||
└──────────────┬──────────────────────────────────────┘
|
||||
│ chrome.runtime messaging
|
||||
┌──────────────┴──────────────────────────────────────┐
|
||||
│ Background Service Worker │
|
||||
│ - Token management (GET /api/test/snippet) │
|
||||
│ - Native screenshots (captureVisibleTab) │
|
||||
│ - Badge updates (session count / server status) │
|
||||
│ - chrome.alarms for periodic health checks │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
┌──────────────────┐ ┌──────────────────┐
|
||||
│ Popup │ │ Side Panel │
|
||||
│ - Server status │ │ - Sessions list │
|
||||
│ - Session count │ │ - Auto-refresh │
|
||||
│ - Dashboard link │ │ - Dashboard link │
|
||||
└──────────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
## Compatibility
|
||||
|
||||
| Browser | Manifest | Side Panel | Screenshots |
|
||||
|---------|----------|------------|-------------|
|
||||
| Chrome 89+ | MV3 | ✅ sidePanel API | ✅ captureVisibleTab |
|
||||
| Edge 89+ | MV3 | ✅ sidePanel API | ✅ captureVisibleTab |
|
||||
| Firefox | MV2 | ✅ sidebar API | ✅ tabs.captureVisibleTab |
|
||||
4711
extension/package-lock.json
generated
Normal file
14
extension/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "muyue-extension",
|
||||
"version": "0.9.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wxt",
|
||||
"build": "wxt build",
|
||||
"zip": "wxt zip"
|
||||
},
|
||||
"dependencies": {
|
||||
"wxt": "^0.20"
|
||||
}
|
||||
}
|
||||
BIN
extension/public/icon/128.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
extension/public/icon/16.png
Normal file
|
After Width: | Height: | Size: 750 B |
BIN
extension/public/icon/32.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
extension/public/icon/512.png
Normal file
|
After Width: | Height: | Size: 307 KiB |
116
extension/src/entrypoints/background.js
Normal file
@@ -0,0 +1,116 @@
|
||||
import { fetchToken, fetchSessions, checkServerHealth, getServerUrl } from '../lib/config';
|
||||
|
||||
export default defineBackground(() => {
|
||||
let token = null;
|
||||
let wsUrl = null;
|
||||
let serverOnline = false;
|
||||
let errorCount = 0;
|
||||
|
||||
async function refreshToken() {
|
||||
try {
|
||||
const data = await fetchToken();
|
||||
token = data.token;
|
||||
wsUrl = data.wsUrl;
|
||||
serverOnline = true;
|
||||
return data;
|
||||
} catch {
|
||||
serverOnline = false;
|
||||
token = null;
|
||||
wsUrl = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateBadge() {
|
||||
try {
|
||||
serverOnline = await checkServerHealth();
|
||||
} catch {
|
||||
serverOnline = false;
|
||||
}
|
||||
|
||||
if (!serverOnline) {
|
||||
chrome.action.setBadgeText({ text: '✕' });
|
||||
chrome.action.setBadgeBackgroundColor({ color: '#ff6b6b' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sessions = await fetchSessions();
|
||||
const count = sessions.length;
|
||||
if (count > 0) {
|
||||
chrome.action.setBadgeText({ text: String(count) });
|
||||
chrome.action.setBadgeBackgroundColor({ color: '#3aaa61' });
|
||||
} else {
|
||||
chrome.action.setBadgeText({ text: '○' });
|
||||
chrome.action.setBadgeBackgroundColor({ color: '#888' });
|
||||
}
|
||||
} catch {
|
||||
chrome.action.setBadgeText({ text: '?' });
|
||||
chrome.action.setBadgeBackgroundColor({ color: '#f5a623' });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleScreenshot() {
|
||||
try {
|
||||
const dataUrl = await chrome.tabs.captureVisibleTab(null, {
|
||||
format: 'png',
|
||||
quality: 100,
|
||||
});
|
||||
return { ok: true, data_url: dataUrl };
|
||||
} catch (e) {
|
||||
return { ok: false, error: 'capture failed: ' + String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
if (msg.type === 'get_state') {
|
||||
getServerUrl().then((url) => {
|
||||
sendResponse({
|
||||
serverOnline,
|
||||
token,
|
||||
wsUrl,
|
||||
errorCount,
|
||||
serverUrl: url,
|
||||
});
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (msg.type === 'get_token') {
|
||||
refreshToken().then((data) => sendResponse(data));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (msg.type === 'check_health') {
|
||||
checkServerHealth().then((ok) => {
|
||||
serverOnline = ok;
|
||||
sendResponse({ online: ok });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (msg.type === 'screenshot') {
|
||||
handleScreenshot().then(sendResponse);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (msg.type === 'refresh_badge') {
|
||||
updateBadge();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (msg.type === 'increment_errors') {
|
||||
errorCount++;
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
chrome.alarms.create('muyue-badge', { periodInMinutes: 0.17 });
|
||||
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||
if (alarm.name === 'muyue-badge') updateBadge();
|
||||
});
|
||||
|
||||
updateBadge();
|
||||
});
|
||||
193
extension/src/entrypoints/content.js
Normal file
@@ -0,0 +1,193 @@
|
||||
import { dispatch } from '../lib/page-rpc';
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['http://*/*', 'https://*/*'],
|
||||
runAt: 'document_idle',
|
||||
main() {
|
||||
if (window.__muyueExtension) return;
|
||||
window.__muyueExtension = true;
|
||||
|
||||
let ws = null;
|
||||
let retryDelay = 0;
|
||||
let token = null;
|
||||
let wsBaseUrl = null;
|
||||
const TAG = '[Muyue]';
|
||||
|
||||
function log(...args) {
|
||||
console.log(TAG, ...args);
|
||||
}
|
||||
|
||||
function send(obj) {
|
||||
try {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function reply(id, data) {
|
||||
send({ type: 'reply', id, data });
|
||||
}
|
||||
|
||||
function sendConsole(level, text) {
|
||||
send({ type: 'console', level, text });
|
||||
}
|
||||
|
||||
async function getToken() {
|
||||
return new Promise((resolve) => {
|
||||
chrome.runtime.sendMessage({ type: 'get_token' }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function screenshotNative(params) {
|
||||
return new Promise((resolve) => {
|
||||
chrome.runtime.sendMessage({ type: 'screenshot', params }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
resolve({ ok: false, error: String(chrome.runtime.lastError) });
|
||||
return;
|
||||
}
|
||||
resolve(response || { ok: false, error: 'no response' });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
const data = await getToken();
|
||||
if (!data) {
|
||||
retryDelay = Math.min(retryDelay + 1, 5);
|
||||
setTimeout(connect, 1000 * retryDelay);
|
||||
return;
|
||||
}
|
||||
token = data.token;
|
||||
wsBaseUrl = data.wsUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
const wsUrl = wsBaseUrl || `ws://127.0.0.1:8080/api/ws/browser-test?token=${token}`;
|
||||
ws = new WebSocket(wsUrl);
|
||||
} catch {
|
||||
retryDelay = Math.min(retryDelay + 1, 5);
|
||||
setTimeout(connect, 1000 * retryDelay);
|
||||
return;
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
retryDelay = 0;
|
||||
send({ type: 'hello', url: location.href, title: document.title });
|
||||
log('connected to Muyue server');
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
let msg;
|
||||
try { msg = JSON.parse(ev.data); } catch { return; }
|
||||
|
||||
if (msg.type === 'registered') {
|
||||
log('session registered:', msg.session_id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.action) {
|
||||
if (msg.action === 'screenshot') {
|
||||
screenshotNative(msg.params || {}).then((r) => reply(msg.id, r));
|
||||
return;
|
||||
}
|
||||
const result = dispatch(msg);
|
||||
if (result && typeof result.then === 'function') {
|
||||
result.then((r) => reply(msg.id, r));
|
||||
} else {
|
||||
reply(msg.id, result);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
retryDelay = Math.min(retryDelay + 1, 5);
|
||||
setTimeout(connect, 500 * retryDelay);
|
||||
};
|
||||
|
||||
ws.onerror = () => {};
|
||||
}
|
||||
|
||||
['log', 'info', 'warn', 'error', 'debug'].forEach((lvl) => {
|
||||
const orig = console[lvl];
|
||||
console[lvl] = function () {
|
||||
try {
|
||||
const parts = Array.from(arguments).map((a) => {
|
||||
if (typeof a === 'string') return a;
|
||||
try { return JSON.stringify(a); } catch { return String(a); }
|
||||
});
|
||||
const text = parts.join(' ');
|
||||
if (!text.startsWith(TAG)) {
|
||||
sendConsole(lvl, text);
|
||||
if (lvl === 'error') {
|
||||
chrome.runtime.sendMessage({ type: 'increment_errors' }).catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return orig.apply(console, arguments);
|
||||
};
|
||||
});
|
||||
|
||||
window.addEventListener('error', (e) => {
|
||||
sendConsole('error', 'window.onerror: ' + (e.message || 'unknown'));
|
||||
chrome.runtime.sendMessage({ type: 'increment_errors' }).catch(() => {});
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (e) => {
|
||||
sendConsole('error', 'unhandledrejection: ' + String(e.reason));
|
||||
chrome.runtime.sendMessage({ type: 'increment_errors' }).catch(() => {});
|
||||
});
|
||||
|
||||
let lastUrl = location.href;
|
||||
const urlObserver = new MutationObserver(() => {
|
||||
if (location.href !== lastUrl) {
|
||||
lastUrl = location.href;
|
||||
send({ type: 'url_change', url: lastUrl });
|
||||
}
|
||||
});
|
||||
urlObserver.observe(document.documentElement, { childList: true, subtree: true });
|
||||
|
||||
const origPushState = history.pushState;
|
||||
history.pushState = function () {
|
||||
origPushState.apply(this, arguments);
|
||||
if (location.href !== lastUrl) {
|
||||
lastUrl = location.href;
|
||||
send({ type: 'url_change', url: lastUrl });
|
||||
}
|
||||
};
|
||||
|
||||
const origReplaceState = history.replaceState;
|
||||
history.replaceState = function () {
|
||||
origReplaceState.apply(this, arguments);
|
||||
if (location.href !== lastUrl) {
|
||||
lastUrl = location.href;
|
||||
send({ type: 'url_change', url: lastUrl });
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', () => {
|
||||
if (location.href !== lastUrl) {
|
||||
lastUrl = location.href;
|
||||
send({ type: 'url_change', url: lastUrl });
|
||||
}
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
if (location.href !== lastUrl) {
|
||||
lastUrl = location.href;
|
||||
send({ type: 'url_change', url: lastUrl });
|
||||
}
|
||||
}, 500);
|
||||
|
||||
connect();
|
||||
},
|
||||
});
|
||||
55
extension/src/entrypoints/popup/index.html
Normal file
@@ -0,0 +1,55 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=320" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="popup">
|
||||
<header>
|
||||
<img src="/icon/32.png" alt="Muyue" />
|
||||
<h1>Muyue</h1>
|
||||
</header>
|
||||
|
||||
<div class="status-card">
|
||||
<div class="status-row">
|
||||
<span class="status-label">Server</span>
|
||||
<span class="status-value" id="server-status">
|
||||
<span class="dot dot-yellow"></span>Checking…
|
||||
</span>
|
||||
</div>
|
||||
<div class="status-row">
|
||||
<span class="status-label">Active sessions</span>
|
||||
<span class="status-value" id="session-count">—</span>
|
||||
</div>
|
||||
<div class="status-row">
|
||||
<span class="status-label">Console errors</span>
|
||||
<span class="status-value" id="error-count">0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<a id="btn-dashboard" href="#" class="btn btn-primary" target="_blank">
|
||||
Open Dashboard
|
||||
</a>
|
||||
<button id="btn-sidepanel" class="btn">
|
||||
Open Chat Panel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<label>Server URL</label>
|
||||
<div class="input-row">
|
||||
<input type="text" id="server-url" placeholder="http://127.0.0.1:8080" />
|
||||
<button id="btn-save-url">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<span>Muyue</span> extension v0.9.0
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="./main.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
54
extension/src/entrypoints/popup/main.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import '../../styles/panel.css';
|
||||
import { getServerUrl, setServerUrl, fetchSessions } from '../../lib/config';
|
||||
|
||||
const $serverStatus = document.getElementById('server-status');
|
||||
const $sessionCount = document.getElementById('session-count');
|
||||
const $errorCount = document.getElementById('error-count');
|
||||
const $btnDashboard = document.getElementById('btn-dashboard');
|
||||
const $btnSidepanel = document.getElementById('btn-sidepanel');
|
||||
const $serverUrl = document.getElementById('server-url');
|
||||
const $btnSaveUrl = document.getElementById('btn-save-url');
|
||||
|
||||
function dot(color) {
|
||||
return `<span class="dot dot-${color}"></span>`;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const url = await getServerUrl();
|
||||
$serverUrl.value = url;
|
||||
$btnDashboard.href = url;
|
||||
|
||||
try {
|
||||
const sessions = await fetchSessions();
|
||||
$serverStatus.innerHTML = `${dot('green')} Online`;
|
||||
$sessionCount.textContent = sessions.length;
|
||||
} catch {
|
||||
$serverStatus.innerHTML = `${dot('red')} Offline`;
|
||||
$sessionCount.textContent = '—';
|
||||
}
|
||||
|
||||
chrome.runtime.sendMessage({ type: 'get_state' }, (state) => {
|
||||
if (chrome.runtime.lastError || !state) return;
|
||||
$errorCount.textContent = state.errorCount || 0;
|
||||
});
|
||||
}
|
||||
|
||||
$btnSaveUrl.addEventListener('click', async () => {
|
||||
const url = $serverUrl.value.trim().replace(/\/$/, '');
|
||||
if (url) {
|
||||
await setServerUrl(url);
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
|
||||
$btnSidepanel.addEventListener('click', async () => {
|
||||
try {
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (tab) {
|
||||
chrome.sidePanel.open({ tabId: tab.id });
|
||||
window.close();
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
|
||||
refresh();
|
||||
89
extension/src/entrypoints/sidepanel/index.html
Normal file
@@ -0,0 +1,89 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="panel">
|
||||
<header>
|
||||
<img src="/icon/32.png" alt="Muyue" />
|
||||
<h1>Muyue</h1>
|
||||
</header>
|
||||
|
||||
<nav class="tabs">
|
||||
<button class="tab active" data-tab="config">Configuration</button>
|
||||
<button class="tab" data-tab="chat">Chat</button>
|
||||
</nav>
|
||||
|
||||
<section id="tab-config" class="tab-content active">
|
||||
<div class="status-card">
|
||||
<div class="status-row">
|
||||
<span class="status-label">Server</span>
|
||||
<span class="status-value" id="server-status">
|
||||
<span class="dot dot-yellow"></span>Checking…
|
||||
</span>
|
||||
</div>
|
||||
<div class="status-row">
|
||||
<span class="status-label">Active sessions</span>
|
||||
<span class="status-value" id="session-count">—</span>
|
||||
</div>
|
||||
<div class="status-row">
|
||||
<span class="status-label">Console errors</span>
|
||||
<span class="status-value" id="error-count">0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="sessions-list"></div>
|
||||
|
||||
<div class="actions">
|
||||
<a id="btn-dashboard" href="#" class="btn btn-primary" target="_blank">
|
||||
Open Dashboard
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<label>Server URL</label>
|
||||
<div class="input-row">
|
||||
<input type="text" id="server-url" placeholder="http://127.0.0.1:8080" />
|
||||
<button id="btn-save-url">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-chat" class="tab-content">
|
||||
<div id="chat-offline" class="chat-offline">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" stroke-width="1.5">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
|
||||
</svg>
|
||||
<span>Server offline</span>
|
||||
</div>
|
||||
<div id="chat-area" class="studio-feed-layout" style="display:none">
|
||||
<div id="chat-feed" class="studio-feed"></div>
|
||||
<div class="studio-input-area">
|
||||
<div class="studio-input-row">
|
||||
<textarea id="chat-input" placeholder="Envoyer un message…" rows="1"></textarea>
|
||||
<button id="chat-send" class="studio-send-btn">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="chat-stop" class="studio-stop-btn" style="display:none">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="4" y="4" width="16" height="16" rx="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="studio-input-hint">/clear /help</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="footer">
|
||||
<span>Muyue</span> extension v0.9.0
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="./main.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
447
extension/src/entrypoints/sidepanel/main.js
Normal file
@@ -0,0 +1,447 @@
|
||||
import '../../styles/panel.css';
|
||||
import { getServerUrl, setServerUrl, fetchSessions, checkServerHealth } from '../../lib/config';
|
||||
import { getChatHistory, sendChat, clearChat } from '../../lib/api';
|
||||
|
||||
const $ = (s) => document.querySelector(s);
|
||||
const $$ = (s) => document.querySelectorAll(s);
|
||||
|
||||
const $serverStatus = $('#server-status');
|
||||
const $sessionCount = $('#session-count');
|
||||
const $errorCount = $('#error-count');
|
||||
const $sessionsList = $('#sessions-list');
|
||||
const $btnDashboard = $('#btn-dashboard');
|
||||
const $serverUrl = $('#server-url');
|
||||
const $btnSaveUrl = $('#btn-save-url');
|
||||
const $chatOffline = $('#chat-offline');
|
||||
const $chatArea = $('#chat-area');
|
||||
const $chatFeed = $('#chat-feed');
|
||||
const $chatStreaming = $('#chat-streaming');
|
||||
const $chatInput = $('#chat-input');
|
||||
const $chatSend = $('#chat-send');
|
||||
const $chatStop = $('#chat-stop');
|
||||
|
||||
let serverOnline = false;
|
||||
let messages = [];
|
||||
let loading = false;
|
||||
let abortController = null;
|
||||
let currentStreamingEl = null;
|
||||
|
||||
function dot(color) {
|
||||
return `<span class="dot dot-${color}"></span>`;
|
||||
}
|
||||
|
||||
function renderSessions(sessions) {
|
||||
if (sessions.length === 0) {
|
||||
$sessionsList.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
$sessionsList.innerHTML = `
|
||||
<div class="status-card" style="margin-top:12px">
|
||||
<div style="font-size:11px;color:var(--text-secondary);margin-bottom:8px;text-transform:uppercase;letter-spacing:0.5px">
|
||||
Connected tabs
|
||||
</div>
|
||||
${sessions.map((s) => `
|
||||
<div class="status-row">
|
||||
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:200px" title="${s.url}">
|
||||
${s.title || s.url || s.id}
|
||||
</span>
|
||||
<span style="font-size:10px;color:var(--text-secondary);font-family:var(--font-mono)">
|
||||
${s.id.slice(0, 8)}
|
||||
</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function formatText(text) {
|
||||
let html = text
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
html = html
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
|
||||
.replace(/^### (.+)$/gm, '<h4>$1</h4>')
|
||||
.replace(/^## (.+)$/gm, '<h3>$1</h3>')
|
||||
.replace(/^# (.+)$/gm, '<h2>$1</h2>')
|
||||
.replace(/^\s*[-*] (.+)$/gm, '<div class="chat-bullet">• $1</div>')
|
||||
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<div class="chat-step"><span class="chat-step-num">$1</span> $2</div>')
|
||||
.replace(/\n/g, '<br/>');
|
||||
html = html
|
||||
.replace(/<br\/>\s*<br\/>/g, '<br/>')
|
||||
.replace(/<br\/>\s*(<h[234]|<div class="chat-)/g, '$1')
|
||||
.replace(/(<\/h[234]|<\/div>)\s*<br\/>/g, '$1');
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderContent(text) {
|
||||
const parts = [];
|
||||
const codeBlockRegex = /(```[\s\S]*?```)/g;
|
||||
let match;
|
||||
let lastIndex = 0;
|
||||
while ((match = codeBlockRegex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({ type: 'text', content: text.slice(lastIndex, match.index) });
|
||||
}
|
||||
const full = match[1];
|
||||
const firstNewline = full.indexOf('\n');
|
||||
const lang = firstNewline > -1 ? full.slice(3, firstNewline).trim() : '';
|
||||
const code = firstNewline > -1 ? full.slice(firstNewline + 1, -3) : full.slice(3, -3);
|
||||
parts.push({ type: 'code', lang, content: code });
|
||||
lastIndex = match.index + full.length;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
parts.push({ type: 'text', content: text.slice(lastIndex) });
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function createMessageEl(msg) {
|
||||
const el = document.createElement('div');
|
||||
el.className = `chat-msg ${msg.role}`;
|
||||
|
||||
if (msg.role === 'system') {
|
||||
el.innerHTML = `<div class="chat-system-dot"></div><div class="chat-system-text">${escapeHtml(msg.content)}</div>`;
|
||||
return el;
|
||||
}
|
||||
|
||||
const isUser = msg.role === 'user';
|
||||
const avatar = isUser ? '★' : '◆';
|
||||
const label = isUser ? 'CDT' : 'GEN';
|
||||
|
||||
let displayContent = msg.content;
|
||||
let parsedToolCalls = null;
|
||||
let parsedSegments = null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(msg.content);
|
||||
if (parsed && Array.isArray(parsed.segments)) {
|
||||
parsedSegments = parsed.segments;
|
||||
displayContent = parsed.content || '';
|
||||
} else if (parsed && Array.isArray(parsed.tool_calls)) {
|
||||
parsedToolCalls = parsed.tool_calls;
|
||||
displayContent = parsed.content || '';
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const cleanContent = displayContent.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '');
|
||||
|
||||
let bodyHtml = '';
|
||||
if (parsedSegments) {
|
||||
bodyHtml = parsedSegments.map((seg) => {
|
||||
if (seg.type === 'text' && seg.content) {
|
||||
const c = seg.content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '');
|
||||
if (!c) return '';
|
||||
return renderContent(c).map((p) => {
|
||||
if (p.type === 'code') {
|
||||
return `<div class="chat-code-block"><div class="chat-code-header"><span class="chat-code-lang">${p.lang || ''}</span><button class="chat-copy-btn" data-code="${encodeURIComponent(p.content)}">Copy</button></div><pre><code>${escapeHtml(p.content)}</code></pre></div>`;
|
||||
}
|
||||
return `<span>${formatText(p.content)}</span>`;
|
||||
}).join('');
|
||||
}
|
||||
if (seg.type === 'tool') {
|
||||
const name = seg.call?.name || 'tool';
|
||||
const icon = { terminal: '⌨', crush_run: '⚡', read_file: '📄', list_files: '📁', search_files: '🔍', web_fetch: '🌐' }[name] || '🔧';
|
||||
const done = seg.result;
|
||||
const isErr = done && done.is_error;
|
||||
const preview = (() => {
|
||||
try {
|
||||
const args = typeof seg.call.args === 'string' ? JSON.parse(seg.call.args) : seg.call.args;
|
||||
return args.command || args.task || args.path || args.url || JSON.stringify(args).slice(0, 60);
|
||||
} catch { return ''; }
|
||||
})();
|
||||
const resultText = done ? (done.content || '').slice(0, 500) : '';
|
||||
return `<div class="chat-tool ${done ? 'done' : 'running'} ${isErr ? 'error' : ''}"><div class="chat-tool-header"><span class="chat-tool-icon">${icon}</span><span>${name}</span>${done ? `<span class="chat-tool-status ${isErr ? 'err' : 'ok'}">${isErr ? '✗' : '✓'}</span>` : '<span class="chat-dots"><span></span><span></span><span></span></span>'}</div>${preview ? `<div class="chat-tool-args">${escapeHtml(preview)}</div>` : ''}${resultText ? `<pre class="chat-tool-result">${escapeHtml(resultText)}</pre>` : ''}</div>`;
|
||||
}
|
||||
return '';
|
||||
}).join('');
|
||||
} else {
|
||||
if (cleanContent) {
|
||||
bodyHtml = renderContent(cleanContent).map((p) => {
|
||||
if (p.type === 'code') {
|
||||
return `<div class="chat-code-block"><div class="chat-code-header"><span class="chat-code-lang">${p.lang || ''}</span><button class="chat-copy-btn" data-code="${encodeURIComponent(p.content)}">Copy</button></div><pre><code>${escapeHtml(p.content)}</code></pre></div>`;
|
||||
}
|
||||
return `<span>${formatText(p.content)}</span>`;
|
||||
}).join('');
|
||||
}
|
||||
if (parsedToolCalls && parsedToolCalls.length > 0) {
|
||||
bodyHtml = parsedToolCalls.map((tc) => {
|
||||
const name = tc.name || 'tool';
|
||||
const icon = { terminal: '⌨', crush_run: '⚡', read_file: '📄', web_fetch: '🌐' }[name] || '🔧';
|
||||
return `<div class="chat-tool done"><div class="chat-tool-header"><span class="chat-tool-icon">${icon}</span><span>${name}</span><span class="chat-tool-status ok">✓</span></div></div>`;
|
||||
}).join('') + bodyHtml;
|
||||
}
|
||||
}
|
||||
|
||||
if (!bodyHtml) bodyHtml = '<span class="chat-dots"><span></span><span></span><span></span></span>';
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="chat-avatar ${isUser ? 'user' : 'ai'}">${avatar}</div>
|
||||
<div class="chat-body">
|
||||
<div class="chat-header"><span class="chat-badge" style="color:${isUser ? '#FFD740' : '#FF9100'};border-color:${isUser ? '#FFD740' : '#FF9100'}">${label}</span></div>
|
||||
<div class="chat-content">${bodyHtml}</div>
|
||||
</div>
|
||||
`;
|
||||
return el;
|
||||
}
|
||||
|
||||
function renderMessages() {
|
||||
$chatFeed.innerHTML = '';
|
||||
messages.forEach((msg) => {
|
||||
$chatFeed.appendChild(createMessageEl(msg));
|
||||
});
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
requestAnimationFrame(() => {
|
||||
$chatFeed.scrollTop = $chatFeed.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
function switchTab(tabName) {
|
||||
$$('.tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === tabName));
|
||||
$$('.tab-content').forEach((s) => s.classList.toggle('active', s.id === `tab-${tabName}`));
|
||||
}
|
||||
|
||||
function updateChatVisibility() {
|
||||
if (serverOnline) {
|
||||
$chatOffline.style.display = 'none';
|
||||
$chatArea.style.display = 'flex';
|
||||
} else {
|
||||
$chatOffline.style.display = 'flex';
|
||||
$chatArea.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChatHistory() {
|
||||
try {
|
||||
const data = await getChatHistory();
|
||||
if (data.messages && data.messages.length > 0) {
|
||||
messages = data.messages;
|
||||
} else {
|
||||
messages = [{ id: 'welcome', role: 'system', content: 'Ready. Type a message to start.' }];
|
||||
}
|
||||
renderMessages();
|
||||
} catch {
|
||||
messages = [{ id: 'welcome', role: 'system', content: 'Ready. Type a message to start.' }];
|
||||
renderMessages();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
const text = $chatInput.value.trim();
|
||||
if (!text || loading) return;
|
||||
|
||||
if (text === '/clear') {
|
||||
try { await clearChat(); } catch {}
|
||||
messages = [{ id: 'clear-' + Date.now(), role: 'system', content: 'Conversation cleared.' }];
|
||||
renderMessages();
|
||||
$chatInput.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
$chatInput.value = '';
|
||||
$chatInput.style.height = 'auto';
|
||||
|
||||
const userMsg = { id: Date.now().toString(), role: 'user', content: text };
|
||||
messages.push(userMsg);
|
||||
$chatFeed.appendChild(createMessageEl(userMsg));
|
||||
scrollToBottom();
|
||||
|
||||
loading = true;
|
||||
$chatSend.style.display = 'none';
|
||||
$chatStop.style.display = 'flex';
|
||||
|
||||
const controller = new AbortController();
|
||||
abortController = controller;
|
||||
|
||||
let segments = [];
|
||||
let thinking = '';
|
||||
let textStartIdx = 0;
|
||||
let streamText = '';
|
||||
|
||||
const updateLastText = (text) => {
|
||||
if (!text) return;
|
||||
const last = segments.length > 0 ? segments[segments.length - 1] : null;
|
||||
if (last && last.type === 'text') {
|
||||
last.content = text;
|
||||
} else {
|
||||
segments.push({ type: 'text', content: text });
|
||||
}
|
||||
};
|
||||
|
||||
currentStreamingEl = document.createElement('div');
|
||||
currentStreamingEl.className = 'chat-msg assistant streaming';
|
||||
$chatFeed.appendChild(currentStreamingEl);
|
||||
scrollToBottom();
|
||||
|
||||
try {
|
||||
const finalContent = await sendChat(text, true, (partial, event) => {
|
||||
if (event && (event.thinking !== undefined || event.thinking_start || event.thinking_end)) {
|
||||
if (event.thinking !== undefined) thinking += event.thinking;
|
||||
return;
|
||||
}
|
||||
if (event && event.tool_call) {
|
||||
updateLastText(partial.slice(textStartIdx));
|
||||
textStartIdx = partial.length;
|
||||
segments.push({ type: 'tool', call: event.tool_call, result: null });
|
||||
} else if (event && event.tool_result) {
|
||||
const segIdx = segments.findIndex((s) => s.type === 'tool' && s.call && s.call.tool_call_id === event.tool_result.tool_call_id);
|
||||
if (segIdx >= 0) segments[segIdx].result = event.tool_result;
|
||||
} else {
|
||||
updateLastText(partial.slice(textStartIdx));
|
||||
}
|
||||
streamText = partial;
|
||||
|
||||
const allText = segments.filter((s) => s.type === 'text').map((s) => s.content).join('');
|
||||
const toolSegs = segments.filter((s) => s.type === 'tool');
|
||||
|
||||
let html = '';
|
||||
if (thinking) {
|
||||
html += `<div class="chat-thinking"><span class="chat-thinking-icon">⏱</span> Thinking…</div>`;
|
||||
}
|
||||
segments.forEach((seg) => {
|
||||
if (seg.type === 'text' && seg.content) {
|
||||
const c = seg.content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '');
|
||||
if (c) html += `<div class="chat-content">${formatText(c)}</div>`;
|
||||
}
|
||||
if (seg.type === 'tool') {
|
||||
const name = seg.call?.name || 'tool';
|
||||
const icon = { terminal: '⌨', crush_run: '⚡', read_file: '📄', web_fetch: '🌐' }[name] || '🔧';
|
||||
const done = seg.result;
|
||||
const isErr = done && done.is_error;
|
||||
const preview = (() => {
|
||||
try {
|
||||
const args = typeof seg.call.args === 'string' ? JSON.parse(seg.call.args) : seg.call.args;
|
||||
return args.command || args.task || args.path || JSON.stringify(args).slice(0, 60);
|
||||
} catch { return ''; }
|
||||
})();
|
||||
html += `<div class="chat-tool ${done ? 'done' : 'running'} ${isErr ? 'error' : ''}"><div class="chat-tool-header"><span class="chat-tool-icon">${icon}</span><span>${name}</span>${done ? `<span class="chat-tool-status ${isErr ? 'err' : 'ok'}">${isErr ? '✗' : '✓'}</span>` : '<span class="chat-dots"><span></span><span></span><span></span></span>'}</div>${preview ? `<div class="chat-tool-args">${escapeHtml(preview)}</div>` : ''}</div>`;
|
||||
}
|
||||
});
|
||||
|
||||
if (!html) {
|
||||
html = '<span class="chat-dots"><span></span><span></span><span></span></span>';
|
||||
}
|
||||
|
||||
currentStreamingEl.innerHTML = `
|
||||
<div class="chat-avatar ai">◆</div>
|
||||
<div class="chat-body">
|
||||
<div class="chat-header"><span class="chat-badge" style="color:#FF9100;border-color:#FF9100">GEN</span></div>
|
||||
${html}
|
||||
<span class="chat-cursor"></span>
|
||||
</div>
|
||||
`;
|
||||
scrollToBottom();
|
||||
}, controller.signal);
|
||||
|
||||
if (currentStreamingEl && currentStreamingEl.parentNode) {
|
||||
currentStreamingEl.remove();
|
||||
}
|
||||
|
||||
const allText = segments.filter((s) => s.type === 'text').map((s) => s.content).join('');
|
||||
const toolSegs = segments.filter((s) => s.type === 'tool');
|
||||
const aiMsg = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: toolSegs.length > 0 ? JSON.stringify({
|
||||
segments: segments.map((s) => s.type === 'text'
|
||||
? { type: 'text', content: s.content }
|
||||
: { type: 'tool', call: s.call, result: s.result ? { content: s.result.content || '', is_error: s.result.is_error || false, tool_call_id: s.call?.tool_call_id } : null }),
|
||||
content: allText,
|
||||
}) : (allText || finalContent),
|
||||
};
|
||||
messages.push(aiMsg);
|
||||
$chatFeed.appendChild(createMessageEl(aiMsg));
|
||||
scrollToBottom();
|
||||
} catch (err) {
|
||||
if (currentStreamingEl && currentStreamingEl.parentNode) {
|
||||
currentStreamingEl.remove();
|
||||
}
|
||||
if (err.name !== 'AbortError') {
|
||||
const errMsg = { id: (Date.now() + 1).toString(), role: 'system', content: `Error: ${err.message}` };
|
||||
messages.push(errMsg);
|
||||
$chatFeed.appendChild(createMessageEl(errMsg));
|
||||
scrollToBottom();
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
abortController = null;
|
||||
currentStreamingEl = null;
|
||||
$chatSend.style.display = 'flex';
|
||||
$chatStop.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
$$('.tab').forEach((tab) => {
|
||||
tab.addEventListener('click', () => switchTab(tab.dataset.tab));
|
||||
});
|
||||
|
||||
$chatInput.addEventListener('input', () => {
|
||||
$chatInput.style.height = 'auto';
|
||||
$chatInput.style.height = Math.min($chatInput.scrollHeight, 100) + 'px';
|
||||
});
|
||||
|
||||
$chatInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
});
|
||||
|
||||
$chatSend.addEventListener('click', handleSend);
|
||||
$chatStop.addEventListener('click', () => {
|
||||
if (abortController) abortController.abort();
|
||||
});
|
||||
|
||||
$chatFeed.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.chat-copy-btn');
|
||||
if (btn) {
|
||||
navigator.clipboard.writeText(decodeURIComponent(btn.dataset.code));
|
||||
const orig = btn.textContent;
|
||||
btn.textContent = 'Copied!';
|
||||
setTimeout(() => { btn.textContent = orig; }, 1200);
|
||||
}
|
||||
});
|
||||
|
||||
$btnSaveUrl.addEventListener('click', async () => {
|
||||
const url = $serverUrl.value.trim().replace(/\/$/, '');
|
||||
if (url) {
|
||||
await setServerUrl(url);
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
|
||||
async function refresh() {
|
||||
const url = await getServerUrl();
|
||||
$serverUrl.value = url;
|
||||
$btnDashboard.href = url;
|
||||
|
||||
try {
|
||||
const sessions = await fetchSessions();
|
||||
serverOnline = true;
|
||||
$serverStatus.innerHTML = `${dot('green')} Online`;
|
||||
$sessionCount.textContent = sessions.length;
|
||||
renderSessions(sessions);
|
||||
} catch {
|
||||
serverOnline = false;
|
||||
$serverStatus.innerHTML = `${dot('red')} Offline`;
|
||||
$sessionCount.textContent = '—';
|
||||
$sessionsList.innerHTML = '';
|
||||
}
|
||||
|
||||
updateChatVisibility();
|
||||
|
||||
chrome.runtime.sendMessage({ type: 'get_state' }, (state) => {
|
||||
if (chrome.runtime.lastError || !state) return;
|
||||
$errorCount.textContent = state.errorCount || 0;
|
||||
});
|
||||
}
|
||||
|
||||
refresh();
|
||||
loadChatHistory();
|
||||
setInterval(refresh, 10000);
|
||||
77
extension/src/lib/api.js
Normal file
@@ -0,0 +1,77 @@
|
||||
import { getServerUrl } from './config';
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const base = await getServerUrl();
|
||||
const res = await fetch(`${base}/api${path}`, {
|
||||
...options,
|
||||
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || res.statusText);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getChatHistory() {
|
||||
return request('/chat/history');
|
||||
}
|
||||
|
||||
export async function clearChat() {
|
||||
return request('/chat/clear', { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function summarizeChat() {
|
||||
return request('/chat/summarize', { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function sendChat(message, stream = true, onChunk, signal) {
|
||||
const base = await getServerUrl();
|
||||
|
||||
if (!stream) {
|
||||
return request('/chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ message, stream: false }),
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
fetch(`${base}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message, stream: true }),
|
||||
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(full); return; }
|
||||
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 {}
|
||||
}
|
||||
}
|
||||
resolve(full);
|
||||
}).catch(reject);
|
||||
});
|
||||
}
|
||||
56
extension/src/lib/config.js
Normal file
@@ -0,0 +1,56 @@
|
||||
const DEFAULT_PORT = 8080;
|
||||
const DEFAULT_HOST = '127.0.0.1';
|
||||
const DEFAULT_URL = `http://${DEFAULT_HOST}:${DEFAULT_PORT}`;
|
||||
|
||||
function isServiceWorker() {
|
||||
return typeof ServiceWorkerGlobalScope !== 'undefined' && self instanceof ServiceWorkerGlobalScope;
|
||||
}
|
||||
|
||||
export async function getServerUrl() {
|
||||
if (isServiceWorker()) {
|
||||
const result = await chrome.storage.local.get('muyue_server_url');
|
||||
return result.muyue_server_url || DEFAULT_URL;
|
||||
}
|
||||
const stored = localStorage.getItem('muyue_server_url');
|
||||
return stored || DEFAULT_URL;
|
||||
}
|
||||
|
||||
export async function setServerUrl(url) {
|
||||
if (isServiceWorker()) {
|
||||
await chrome.storage.local.set({ muyue_server_url: url });
|
||||
} else {
|
||||
localStorage.setItem('muyue_server_url', url);
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildWsUrl(token) {
|
||||
const base = await getServerUrl();
|
||||
const wsBase = base.replace(/^http/, 'ws');
|
||||
return `${wsBase}/api/ws/browser-test?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
export async function fetchToken() {
|
||||
const base = await getServerUrl();
|
||||
const res = await fetch(`${base}/api/test/snippet`);
|
||||
if (!res.ok) throw new Error(`Server returned ${res.status}`);
|
||||
const data = await res.json();
|
||||
return { token: data.token, wsUrl: data.ws_url, expiresIn: data.expires_in };
|
||||
}
|
||||
|
||||
export async function fetchSessions() {
|
||||
const base = await getServerUrl();
|
||||
const res = await fetch(`${base}/api/test/sessions`);
|
||||
if (!res.ok) throw new Error(`Server returned ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.sessions || [];
|
||||
}
|
||||
|
||||
export async function checkServerHealth() {
|
||||
try {
|
||||
const base = await getServerUrl();
|
||||
const res = await fetch(`${base}/api/info`, { signal: AbortSignal.timeout(3000) });
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
113
extension/src/lib/page-rpc.js
Normal file
@@ -0,0 +1,113 @@
|
||||
let lastList = [];
|
||||
|
||||
function safeText(el) {
|
||||
let t = (el.innerText || el.textContent || '').trim();
|
||||
if (t.length > 80) t = t.slice(0, 80) + '…';
|
||||
return t;
|
||||
}
|
||||
|
||||
function describe(el) {
|
||||
let 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('.');
|
||||
}
|
||||
const label = el.getAttribute('aria-label') || el.getAttribute('title') || el.getAttribute('name') || '';
|
||||
return {
|
||||
tag: el.tagName.toLowerCase(),
|
||||
selector: sel,
|
||||
text: safeText(el),
|
||||
label,
|
||||
type: el.getAttribute('type') || '',
|
||||
disabled: !!el.disabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function listClickables() {
|
||||
const els = Array.from(
|
||||
document.querySelectorAll(
|
||||
'button, a[href], input[type=submit], input[type=button], [role=button], [onclick]'
|
||||
)
|
||||
);
|
||||
lastList = els.filter((e) => {
|
||||
const r = e.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0;
|
||||
});
|
||||
return lastList.map((el, i) => {
|
||||
const d = describe(el);
|
||||
d.index = i;
|
||||
return d;
|
||||
});
|
||||
}
|
||||
|
||||
export function clickElement(params) {
|
||||
let el;
|
||||
if (params.selector) el = document.querySelector(params.selector);
|
||||
else if (typeof params.index === 'number') el = lastList[params.index];
|
||||
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) };
|
||||
}
|
||||
}
|
||||
|
||||
export function typeText(params) {
|
||||
let el;
|
||||
if (params.selector) el = document.querySelector(params.selector);
|
||||
else if (typeof params.index === 'number') el = lastList[params.index];
|
||||
if (!el) return { ok: false, error: 'element not found' };
|
||||
const proto = Object.getPrototypeOf(el);
|
||||
const setter = Object.getOwnPropertyDescriptor(proto, 'value');
|
||||
try {
|
||||
if (setter && setter.set) setter.set.call(el, params.text || '');
|
||||
else el.value = params.text || '';
|
||||
} catch {
|
||||
el.value = params.text || '';
|
||||
}
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function evalExpr(params) {
|
||||
try {
|
||||
const r = (0, eval)(params.expr);
|
||||
return { ok: true, value: serialize(r) };
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
export function currentUrl() {
|
||||
return { url: location.href, title: document.title };
|
||||
}
|
||||
|
||||
function serialize(v) {
|
||||
if (v === undefined) return 'undefined';
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(v));
|
||||
} catch {
|
||||
return String(v);
|
||||
}
|
||||
}
|
||||
|
||||
export function dispatch(msg) {
|
||||
const p = msg.params || {};
|
||||
switch (msg.action) {
|
||||
case 'list_clickables':
|
||||
return listClickables();
|
||||
case 'click':
|
||||
return clickElement(p);
|
||||
case 'eval':
|
||||
return evalExpr(p);
|
||||
case 'current_url':
|
||||
return currentUrl();
|
||||
case 'type':
|
||||
return typeText(p);
|
||||
default:
|
||||
return { ok: false, error: 'unknown action: ' + msg.action };
|
||||
}
|
||||
}
|
||||
706
extension/src/styles/panel.css
Normal file
@@ -0,0 +1,706 @@
|
||||
:root {
|
||||
--bg: #0A0A0C;
|
||||
--bg-base: #0F0D10;
|
||||
--bg-surface: #161218;
|
||||
--bg-elevated: #1C1719;
|
||||
--bg-card: #221B1E;
|
||||
--bg-input: #2A2225;
|
||||
--bg-hover: #332528;
|
||||
|
||||
--accent: #FF0033;
|
||||
--accent-dark: #8B0020;
|
||||
--accent-deep: #5C0015;
|
||||
--accent-light: #FF1A5E;
|
||||
--accent-muted: #FF4D6D;
|
||||
--accent-bright: #FF1744;
|
||||
--accent-soft: #FF5252;
|
||||
--accent-dim: #6B2033;
|
||||
--accent-bg: #4A1525;
|
||||
|
||||
--text-primary: #EAE0E2;
|
||||
--text-secondary: #D4C4C8;
|
||||
--text-tertiary: #8A7A7E;
|
||||
--text-disabled: #5A4F52;
|
||||
|
||||
--success: #00E676;
|
||||
--warning: #FFD740;
|
||||
--error: #FF1744;
|
||||
--info: #448AFF;
|
||||
|
||||
--border: #2A1F22;
|
||||
--border-accent: #FF003344;
|
||||
--border-accent-full: #FF0033;
|
||||
|
||||
--radius-sm: 6px;
|
||||
--radius: 8px;
|
||||
--radius-lg: 12px;
|
||||
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace;
|
||||
|
||||
--green: #00E676;
|
||||
--yellow: #FFD740;
|
||||
--red: #FF1744;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
::selection { background: var(--accent); color: #fff; }
|
||||
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--accent-dim); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--accent-dark); }
|
||||
|
||||
a { color: var(--accent); text-decoration: none; cursor: pointer; }
|
||||
|
||||
/* ── Popup (icon click) ── */
|
||||
.popup {
|
||||
width: 320px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.popup header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.popup .footer {
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: center;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* ── Panel (side panel) ── */
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
header img { width: 28px; height: 28px; }
|
||||
header h1 { font-size: 16px; font-weight: 600; letter-spacing: -0.3px; color: var(--text-primary); }
|
||||
|
||||
/* ── Tabs ── */
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 10px 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.tab:hover { color: var(--text-primary); }
|
||||
|
||||
.tab.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Config tab ── */
|
||||
.status-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.status-row + .status-row {
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 6px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.status-label { color: var(--text-tertiary); font-size: 12px; }
|
||||
.status-value { font-weight: 500; font-size: 12px; color: var(--text-primary); }
|
||||
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.dot-green { background: var(--success); box-shadow: 0 0 6px var(--success); }
|
||||
.dot-red { background: var(--error); box-shadow: 0 0 6px var(--error); }
|
||||
.dot-yellow { background: var(--warning); box-shadow: 0 0 6px var(--warning); }
|
||||
|
||||
.actions { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
|
||||
|
||||
.btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 9px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
text-decoration: none;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.btn:hover { background: var(--accent-bg); border-color: var(--accent-dark); color: var(--text-primary); }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-bright);
|
||||
border-color: var(--accent-bright);
|
||||
box-shadow: 0 0 12px var(--border-accent);
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.settings-section label {
|
||||
display: block;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
margin-bottom: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.input-row { display: flex; gap: 6px; }
|
||||
|
||||
.input-row input {
|
||||
flex: 1;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.input-row input:focus { border-color: var(--accent); }
|
||||
|
||||
.input-row button {
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.input-row button:hover { background: var(--accent-bg); border-color: var(--accent-dark); }
|
||||
|
||||
.footer {
|
||||
margin-top: auto;
|
||||
padding: 10px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: center;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.footer span { color: var(--accent); }
|
||||
|
||||
/* ── Chat offline ── */
|
||||
.chat-offline {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── Studio Feed (same classes as Studio.jsx) ── */
|
||||
.studio-feed-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.studio-feed {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.feed-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 0;
|
||||
}
|
||||
|
||||
.feed-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius);
|
||||
animation: fadeIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
.feed-item:hover { background: var(--bg-card); }
|
||||
|
||||
.feed-item.user {
|
||||
background: var(--bg-card);
|
||||
border-left: 3px solid #FFD740;
|
||||
}
|
||||
|
||||
.feed-item.assistant {
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.feed-item.assistant:hover {
|
||||
border-left-color: var(--accent-dark);
|
||||
}
|
||||
|
||||
.feed-item.system {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.feed-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.feed-avatar.user-rank {
|
||||
background: rgba(255, 215, 64, 0.15);
|
||||
}
|
||||
|
||||
.feed-avatar.ai-rank {
|
||||
background: var(--accent-bg);
|
||||
}
|
||||
|
||||
.feed-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.feed-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.feed-rank-badge {
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
font-family: var(--font-mono);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
background: rgba(255, 215, 64, 0.08);
|
||||
}
|
||||
|
||||
.feed-role {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.feed-time {
|
||||
font-size: 10px;
|
||||
color: var(--text-disabled);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.feed-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.feed-system-badge {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.feed-system-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
font-style: italic;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.feed-content table { width: 100%; border-collapse: collapse; margin: 8px 0; font-size: 13px; }
|
||||
.feed-content th { background: var(--bg-surface); padding: 6px 12px; text-align: left; font-weight: 600; border: 1px solid var(--border); color: var(--text-secondary); }
|
||||
.feed-content td { padding: 5px 12px; border: 1px solid var(--border); color: var(--text-primary); }
|
||||
.feed-content tr:nth-child(even) td { background: var(--bg-surface); }
|
||||
.feed-content hr { border: none; border-top: 1px solid var(--border); margin: 12px 0; }
|
||||
|
||||
.inline-code { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 13px; color: var(--accent-muted); }
|
||||
.msg-h2 { font-size: 17px; font-weight: 700; color: var(--text-primary); margin: 12px 0 6px; display: block; }
|
||||
.msg-h3 { font-size: 15px; font-weight: 700; color: var(--text-primary); margin: 10px 0 4px; display: block; }
|
||||
.msg-h4 { font-size: 13px; font-weight: 600; color: var(--text-secondary); margin: 8px 0 3px; display: block; }
|
||||
.msg-bullet { display: block; padding-left: 4px; margin: 1px 0; color: var(--text-primary); }
|
||||
.msg-step { display: flex; gap: 8px; align-items: baseline; margin: 1px 0; }
|
||||
.msg-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); font-size: 13px; flex-shrink: 0; min-width: 20px; }
|
||||
|
||||
/* ── Studio Code Blocks ── */
|
||||
.studio-code-block {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.studio-code-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.studio-code-block pre {
|
||||
padding: 12px 16px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.studio-code-lang {
|
||||
padding: 4px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
background: var(--bg-surface);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.studio-copy-btn {
|
||||
padding: 3px 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
font-family: var(--font-sans);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.studio-copy-btn:hover { background: var(--accent-bg); color: var(--accent); }
|
||||
.studio-copy-btn.copied { background: var(--accent-bg); color: var(--accent); }
|
||||
|
||||
/* ── Studio Thinking ── */
|
||||
.feed-thinking-block {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 2px solid var(--accent-dim);
|
||||
border-radius: var(--radius);
|
||||
margin: 6px 0 8px;
|
||||
overflow: hidden;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.feed-thinking-block.done { border-left-color: var(--text-disabled); opacity: 0.7; }
|
||||
|
||||
.feed-thinking-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.feed-thinking-header svg { color: var(--warning); }
|
||||
|
||||
.feed-thinking-dots { display: inline-flex; gap: 2px; margin-left: 4px; }
|
||||
.feed-thinking-dots span { width: 4px; height: 4px; border-radius: 50%; background: var(--warning); animation: bounce 1.2s ease-in-out infinite; }
|
||||
.feed-thinking-dots span:nth-child(2) { animation-delay: 0.15s; }
|
||||
.feed-thinking-dots span:nth-child(3) { animation-delay: 0.3s; }
|
||||
|
||||
.feed-thinking-content {
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
font-style: italic;
|
||||
line-height: 1.5;
|
||||
max-height: 80px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── Studio Tool Blocks ── */
|
||||
.studio-tool-block {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--accent-dim);
|
||||
border-radius: var(--radius);
|
||||
margin: 6px 0;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.studio-tool-block.running { border-left-color: var(--warning); }
|
||||
.studio-tool-block.error { border-left-color: var(--error); background: rgba(255, 23, 68, 0.05); }
|
||||
|
||||
.studio-tool-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.studio-tool-icon { font-size: 14px; flex-shrink: 0; }
|
||||
|
||||
.studio-tool-name {
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.studio-tool-spinner { display: inline-flex; gap: 2px; margin-left: 4px; }
|
||||
.studio-tool-spinner span { width: 4px; height: 4px; border-radius: 50%; background: var(--warning); animation: bounce 1.2s ease-in-out infinite; }
|
||||
.studio-tool-spinner span:nth-child(2) { animation-delay: 0.15s; }
|
||||
.studio-tool-spinner span:nth-child(3) { animation-delay: 0.3s; }
|
||||
|
||||
.studio-tool-status { font-weight: 700; font-size: 14px; flex-shrink: 0; }
|
||||
.studio-tool-status.ok { color: var(--success); }
|
||||
.studio-tool-status.error { color: var(--error); }
|
||||
|
||||
.studio-tool-args {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-tertiary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.studio-tool-result { max-height: 200px; overflow-y: auto; }
|
||||
.studio-tool-result pre {
|
||||
padding: 8px 10px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* ── Studio Cursor & Thinking Dots ── */
|
||||
.studio-cursor {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 16px;
|
||||
background: var(--accent);
|
||||
margin-left: 2px;
|
||||
vertical-align: text-bottom;
|
||||
animation: blink 0.8s step-end infinite;
|
||||
}
|
||||
|
||||
.studio-thinking { display: flex; gap: 4px; padding: 8px 0; }
|
||||
.studio-thinking span { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-dim); animation: bounce 1.2s ease-in-out infinite; }
|
||||
.studio-thinking span:nth-child(2) { animation-delay: 0.15s; }
|
||||
.studio-thinking span:nth-child(3) { animation-delay: 0.3s; }
|
||||
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
@keyframes bounce { 0%, 80%, 100% { transform: translateY(0); opacity: 0.4; } 40% { transform: translateY(-6px); opacity: 1; } }
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
/* ── Studio Input Area ── */
|
||||
.studio-input-area {
|
||||
padding: 12px 16px 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.studio-input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.studio-input-row textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
min-height: 42px;
|
||||
max-height: 120px;
|
||||
padding: 10px 14px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
font-family: var(--font-sans);
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.studio-input-row textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--border-accent); }
|
||||
.studio-input-row textarea::placeholder { color: var(--text-disabled); }
|
||||
|
||||
.studio-send-btn {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: 1px solid var(--accent);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.studio-send-btn:hover { background: var(--accent-bright); border-color: var(--accent-bright); }
|
||||
.studio-send-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
|
||||
.studio-stop-btn {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius);
|
||||
background: var(--error);
|
||||
color: #fff;
|
||||
border: 1px solid var(--error);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.studio-stop-btn:hover { opacity: 0.8; }
|
||||
|
||||
.studio-input-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-disabled);
|
||||
text-align: center;
|
||||
margin-top: 6px;
|
||||
}
|
||||
28
extension/wxt.config.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from 'wxt';
|
||||
|
||||
export default defineConfig({
|
||||
srcDir: 'src',
|
||||
manifest: {
|
||||
name: 'Muyue',
|
||||
description: 'AI-powered browser testing & automation — connected to your Muyue desktop app',
|
||||
permissions: [
|
||||
'storage',
|
||||
'activeTab',
|
||||
'tabs',
|
||||
'sidePanel',
|
||||
'scripting',
|
||||
'notifications',
|
||||
'alarms',
|
||||
],
|
||||
host_permissions: ['http://127.0.0.1:*/*', 'http://localhost:*/*'],
|
||||
action: {
|
||||
default_icon: {
|
||||
16: 'icon/16.png',
|
||||
32: 'icon/32.png',
|
||||
},
|
||||
},
|
||||
side_panel: {
|
||||
default_path: 'sidepanel.html',
|
||||
},
|
||||
},
|
||||
});
|
||||
16
go.mod
@@ -1,13 +1,13 @@
|
||||
module github.com/muyue/muyue
|
||||
|
||||
go 1.24.2
|
||||
|
||||
toolchain go1.24.3
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/huh v1.0.0
|
||||
github.com/creack/pty/v2 v2.0.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/spf13/cobra v1.10.2
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -28,6 +28,7 @@ require (
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // 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/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
@@ -36,8 +37,15 @@ require (
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
modernc.org/libc v1.72.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.50.0 // indirect
|
||||
)
|
||||
|
||||
25
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/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
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/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
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/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/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/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/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
@@ -68,19 +73,39 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
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/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/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
|
||||
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
|
||||
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
|
||||
|
||||
378
internal/agent/browser.go
Normal file
@@ -0,0 +1,378 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type BrowserParams struct {
|
||||
Action string `json:"action" description:"Browser action: navigate, screenshot, click, type, evaluate, fill_form, read_page, close"`
|
||||
URL string `json:"url,omitempty" description:"URL to navigate to (for navigate action)"`
|
||||
Selector string `json:"selector,omitempty" description:"CSS/XPath selector for click, type, fill_form actions"`
|
||||
Value string `json:"value,omitempty" description:"Value to type or fill"`
|
||||
Script string `json:"script,omitempty" description:"JavaScript to evaluate (for evaluate action)"`
|
||||
Timeout int `json:"timeout,omitempty" description:"Timeout in seconds for the action (default 30)"`
|
||||
}
|
||||
|
||||
type BrowserResponse struct {
|
||||
Content string `json:"content"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Screenshot string `json:"screenshot,omitempty"`
|
||||
IsError bool `json:"is_error"`
|
||||
}
|
||||
|
||||
type BrowserSession struct {
|
||||
id string
|
||||
url string
|
||||
title string
|
||||
mu sync.Mutex
|
||||
createdAt time.Time
|
||||
}
|
||||
|
||||
type BrowserManager struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*BrowserSession
|
||||
playwrightPath string
|
||||
available bool
|
||||
}
|
||||
|
||||
var (
|
||||
browserManager *BrowserManager
|
||||
browserManagerOnce sync.Once
|
||||
)
|
||||
|
||||
func GetBrowserManager() *BrowserManager {
|
||||
browserManagerOnce.Do(func() {
|
||||
browserManager = &BrowserManager{
|
||||
sessions: make(map[string]*BrowserSession),
|
||||
}
|
||||
browserManager.playwrightPath, browserManager.available = detectPlaywright()
|
||||
})
|
||||
return browserManager
|
||||
}
|
||||
|
||||
func detectPlaywright() (string, bool) {
|
||||
for _, cmd := range []string{"playwright", "npx"} {
|
||||
if path, err := exec.LookPath(cmd); err == nil {
|
||||
return path, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func NewBrowserTool() (*ToolDefinition, error) {
|
||||
return NewTool("browser",
|
||||
"Interact with web pages using a headless browser (Playwright). Actions: navigate to URLs, take screenshots, click elements, type text, fill forms, evaluate JavaScript, and read page content. Sessions persist per conversation.",
|
||||
func(ctx context.Context, p BrowserParams) (ToolResponse, error) {
|
||||
if p.Action == "" {
|
||||
return TextErrorResponse("action is required (navigate, screenshot, click, type, evaluate, fill_form, read_page, close)"), nil
|
||||
}
|
||||
|
||||
mgr := GetBrowserManager()
|
||||
if !mgr.available {
|
||||
return TextErrorResponse("Playwright is not installed. Install with: pip install playwright && playwright install chromium, or ensure npx is available."), nil
|
||||
}
|
||||
|
||||
timeout := time.Duration(p.Timeout) * time.Second
|
||||
if timeout == 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
if timeout > 120*time.Second {
|
||||
timeout = 120 * time.Second
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
switch p.Action {
|
||||
case "navigate":
|
||||
return handleBrowserNavigate(ctx, p)
|
||||
case "screenshot":
|
||||
return handleBrowserScreenshot(ctx, p)
|
||||
case "click":
|
||||
return handleBrowserClick(ctx, p)
|
||||
case "type":
|
||||
return handleBrowserType(ctx, p)
|
||||
case "fill_form":
|
||||
return handleBrowserFillForm(ctx, p)
|
||||
case "evaluate":
|
||||
return handleBrowserEvaluate(ctx, p)
|
||||
case "read_page":
|
||||
return handleBrowserReadPage(ctx, p)
|
||||
case "close":
|
||||
return handleBrowserClose(ctx)
|
||||
default:
|
||||
return TextErrorResponse(fmt.Sprintf("unknown browser action: %s. Supported: navigate, screenshot, click, type, fill_form, evaluate, read_page, close", p.Action)), nil
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func handleBrowserNavigate(ctx context.Context, p BrowserParams) (ToolResponse, error) {
|
||||
if p.URL == "" {
|
||||
return TextErrorResponse("url is required for navigate action"), nil
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
const { chromium } = require('playwright');
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
await page.goto(%q, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
const title = await page.title();
|
||||
const content = await page.evaluate(() => document.body.innerText);
|
||||
console.log(JSON.stringify({ url: page.url(), title, content: content.substring(0, 8000) }));
|
||||
await browser.close();
|
||||
})();
|
||||
`, p.URL)
|
||||
|
||||
result, err := runPlaywrightScript(ctx, script)
|
||||
if err != nil {
|
||||
return TextErrorResponse(fmt.Sprintf("navigate error: %v", err)), nil
|
||||
}
|
||||
|
||||
return TextResponse(result), nil
|
||||
}
|
||||
|
||||
func handleBrowserScreenshot(ctx context.Context, p BrowserParams) (ToolResponse, error) {
|
||||
url := p.URL
|
||||
if url == "" {
|
||||
url = "about:blank"
|
||||
}
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
screenshotDir := filepath.Join(home, ".muyue", "screenshots")
|
||||
os.MkdirAll(screenshotDir, 0755)
|
||||
screenshotPath := filepath.Join(screenshotDir, fmt.Sprintf("browser_%d.png", time.Now().UnixNano()))
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
const { chromium } = require('playwright');
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
await page.goto(%q, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.screenshot({ path: %q, fullPage: false });
|
||||
const title = await page.title();
|
||||
console.log(JSON.stringify({ screenshot: %q, title, url: page.url() }));
|
||||
await browser.close();
|
||||
})();
|
||||
`, url, screenshotPath, screenshotPath)
|
||||
|
||||
result, err := runPlaywrightScript(ctx, script)
|
||||
if err != nil {
|
||||
return TextErrorResponse(fmt.Sprintf("screenshot error: %v", err)), nil
|
||||
}
|
||||
|
||||
return TextResponse(fmt.Sprintf("Screenshot saved: %s\n%s", screenshotPath, result)), nil
|
||||
}
|
||||
|
||||
func handleBrowserClick(ctx context.Context, p BrowserParams) (ToolResponse, error) {
|
||||
if p.Selector == "" {
|
||||
return TextErrorResponse("selector is required for click action"), nil
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
const { chromium } = require('playwright');
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
await page.goto(%q, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.click(%q);
|
||||
await page.waitForTimeout(1000);
|
||||
const title = await page.title();
|
||||
const content = await page.evaluate(() => document.body.innerText);
|
||||
console.log(JSON.stringify({ url: page.url(), title, content: content.substring(0, 5000) }));
|
||||
await browser.close();
|
||||
})();
|
||||
`, p.URL, p.Selector)
|
||||
|
||||
result, err := runPlaywrightScript(ctx, script)
|
||||
if err != nil {
|
||||
return TextErrorResponse(fmt.Sprintf("click error: %v", err)), nil
|
||||
}
|
||||
|
||||
return TextResponse(result), nil
|
||||
}
|
||||
|
||||
func handleBrowserType(ctx context.Context, p BrowserParams) (ToolResponse, error) {
|
||||
if p.Selector == "" || p.Value == "" {
|
||||
return TextErrorResponse("selector and value are required for type action"), nil
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
const { chromium } = require('playwright');
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
await page.goto(%q, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.fill(%q, %q);
|
||||
const content = await page.evaluate(() => document.body.innerText);
|
||||
console.log(JSON.stringify({ url: page.url(), content: content.substring(0, 5000) }));
|
||||
await browser.close();
|
||||
})();
|
||||
`, p.URL, p.Selector, p.Value)
|
||||
|
||||
result, err := runPlaywrightScript(ctx, script)
|
||||
if err != nil {
|
||||
return TextErrorResponse(fmt.Sprintf("type error: %v", err)), nil
|
||||
}
|
||||
|
||||
return TextResponse(result), nil
|
||||
}
|
||||
|
||||
func handleBrowserFillForm(ctx context.Context, p BrowserParams) (ToolResponse, error) {
|
||||
var fields []struct {
|
||||
Selector string `json:"selector"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(p.Value), &fields); err != nil {
|
||||
return TextErrorResponse("fill_form value must be a JSON array of {selector, value} objects"), nil
|
||||
}
|
||||
|
||||
var fillsJS strings.Builder
|
||||
for _, f := range fields {
|
||||
fillsJS.WriteString(fmt.Sprintf("\tawait page.fill(%q, %q);\n", f.Selector, f.Value))
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
const { chromium } = require('playwright');
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
await page.goto(%q, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
%s
|
||||
const content = await page.evaluate(() => document.body.innerText);
|
||||
console.log(JSON.stringify({ url: page.url(), content: content.substring(0, 5000) }));
|
||||
await browser.close();
|
||||
})();
|
||||
`, p.URL, fillsJS.String())
|
||||
|
||||
result, err := runPlaywrightScript(ctx, script)
|
||||
if err != nil {
|
||||
return TextErrorResponse(fmt.Sprintf("fill_form error: %v", err)), nil
|
||||
}
|
||||
|
||||
return TextResponse(result), nil
|
||||
}
|
||||
|
||||
func handleBrowserEvaluate(ctx context.Context, p BrowserParams) (ToolResponse, error) {
|
||||
if p.Script == "" {
|
||||
return TextErrorResponse("script is required for evaluate action"), nil
|
||||
}
|
||||
|
||||
url := p.URL
|
||||
if url == "" {
|
||||
url = "about:blank"
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
const { chromium } = require('playwright');
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
await page.goto(%q, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
const result = await page.evaluate(() => {
|
||||
try { return String((%s)); } catch(e) { return String(e); }
|
||||
});
|
||||
console.log(JSON.stringify({ result: result.substring(0, 8000) }));
|
||||
await browser.close();
|
||||
})();
|
||||
`, url, p.Script)
|
||||
|
||||
result, err := runPlaywrightScript(ctx, script)
|
||||
if err != nil {
|
||||
return TextErrorResponse(fmt.Sprintf("evaluate error: %v", err)), nil
|
||||
}
|
||||
|
||||
return TextResponse(result), nil
|
||||
}
|
||||
|
||||
func handleBrowserReadPage(ctx context.Context, p BrowserParams) (ToolResponse, error) {
|
||||
if p.URL == "" {
|
||||
return TextErrorResponse("url is required for read_page action"), nil
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
const { chromium } = require('playwright');
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
await page.goto(%q, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
const title = await page.title();
|
||||
const html = await page.content();
|
||||
console.log(JSON.stringify({ url: page.url(), title, content_length: html.length, content: html.substring(0, 15000) }));
|
||||
await browser.close();
|
||||
})();
|
||||
`, p.URL)
|
||||
|
||||
result, err := runPlaywrightScript(ctx, script)
|
||||
if err != nil {
|
||||
return TextErrorResponse(fmt.Sprintf("read_page error: %v", err)), nil
|
||||
}
|
||||
|
||||
return TextResponse(result), nil
|
||||
}
|
||||
|
||||
func handleBrowserClose(ctx context.Context) (ToolResponse, error) {
|
||||
mgr := GetBrowserManager()
|
||||
mgr.mu.Lock()
|
||||
defer mgr.mu.Unlock()
|
||||
|
||||
count := len(mgr.sessions)
|
||||
mgr.sessions = make(map[string]*BrowserSession)
|
||||
|
||||
return TextResponse(fmt.Sprintf("Closed %d browser session(s)", count)), nil
|
||||
}
|
||||
|
||||
func runPlaywrightScript(ctx context.Context, script string) (string, error) {
|
||||
tmpFile, err := os.CreateTemp("", "muyue-browser-*.js")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
|
||||
if _, err := tmpFile.WriteString(script); err != nil {
|
||||
tmpFile.Close()
|
||||
return "", fmt.Errorf("write script: %w", err)
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
var cmd *exec.Cmd
|
||||
mgr := GetBrowserManager()
|
||||
if mgr.playwrightPath == "npx" || mgr.playwrightPath == "" {
|
||||
cmd = exec.CommandContext(ctx, "npx", "-y", "playwright", "test", "--config=/dev/null")
|
||||
cmd = exec.CommandContext(ctx, "node", tmpFile.Name())
|
||||
} else {
|
||||
cmd = exec.CommandContext(ctx, "node", tmpFile.Name())
|
||||
}
|
||||
|
||||
// Check if node is available
|
||||
if _, err := exec.LookPath("node"); err != nil {
|
||||
return "", fmt.Errorf("node is not installed. Install Node.js to use the browser tool")
|
||||
}
|
||||
|
||||
cmd = exec.CommandContext(ctx, "node", tmpFile.Name())
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
result := string(output)
|
||||
|
||||
if len(result) > 10000 {
|
||||
result = result[:10000] + "\n... [truncated]"
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return "", fmt.Errorf("browser action timed out")
|
||||
}
|
||||
return result, fmt.Errorf("playwright error: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
463
internal/agent/definitions.go
Normal file
@@ -0,0 +1,463 @@
|
||||
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()),
|
||||
must(NewDelegateTool(r)),
|
||||
must(NewDelegateMultiTool(r)),
|
||||
}
|
||||
|
||||
if bt, err := NewBrowserTool(); err == nil {
|
||||
tools = append(tools, bt)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
203
internal/agent/delegate.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DelegateTaskParams struct {
|
||||
Task string `json:"task" description:"Description of the sub-task to delegate"`
|
||||
Context string `json:"context,omitempty" description:"Additional context for the sub-task"`
|
||||
Timeout int `json:"timeout,omitempty" description:"Timeout per sub-task in seconds (default 120, max 300)"`
|
||||
MaxParallel int `json:"max_parallel,omitempty" description:"Maximum parallel sub-tasks (default 3, max 5)"`
|
||||
}
|
||||
|
||||
type DelegateMultiParams struct {
|
||||
Tasks []DelegateTaskParams `json:"tasks" description:"List of sub-tasks to execute in parallel"`
|
||||
MaxParallel int `json:"max_parallel,omitempty" description:"Maximum parallel sub-tasks (default 3, max 5)"`
|
||||
}
|
||||
|
||||
type SubTaskResult struct {
|
||||
Task string `json:"task"`
|
||||
Success bool `json:"success"`
|
||||
Result string `json:"result"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type DelegateResponse struct {
|
||||
TotalTasks int `json:"total_tasks"`
|
||||
Successful int `json:"successful"`
|
||||
Failed int `json:"failed"`
|
||||
Results []SubTaskResult `json:"results"`
|
||||
Duration string `json:"duration"`
|
||||
}
|
||||
|
||||
func NewDelegateTool(registry *Registry) (*ToolDefinition, error) {
|
||||
return NewTool("delegate_task",
|
||||
"Delegate one or more tasks for parallel execution. Each sub-task runs in isolation with its own context. Returns aggregated results from all sub-tasks. Use for independent tasks that can run concurrently.",
|
||||
func(ctx context.Context, p DelegateTaskParams) (ToolResponse, error) {
|
||||
if p.Task == "" {
|
||||
return TextErrorResponse("task is required"), nil
|
||||
}
|
||||
|
||||
timeout := time.Duration(p.Timeout) * time.Second
|
||||
if timeout == 0 {
|
||||
timeout = 120 * time.Second
|
||||
}
|
||||
if timeout > 300*time.Second {
|
||||
timeout = 300 * time.Second
|
||||
}
|
||||
|
||||
result := executeSubTask(ctx, p.Task, p.Context, timeout, registry)
|
||||
resp := DelegateResponse{
|
||||
TotalTasks: 1,
|
||||
Successful: 0,
|
||||
Results: []SubTaskResult{result},
|
||||
Duration: "N/A",
|
||||
}
|
||||
if result.Success {
|
||||
resp.Successful = 1
|
||||
} else {
|
||||
resp.Failed = 1
|
||||
}
|
||||
|
||||
data, _ := json.MarshalIndent(resp, "", " ")
|
||||
return TextResponse(string(data)), nil
|
||||
})
|
||||
}
|
||||
|
||||
func NewDelegateMultiTool(registry *Registry) (*ToolDefinition, error) {
|
||||
return NewTool("delegate_multi",
|
||||
"Execute multiple independent tasks in parallel using goroutines. Each task runs in its own isolated context. Returns aggregated results. Use for batch operations, parallel analysis, or concurrent file processing.",
|
||||
func(ctx context.Context, p DelegateMultiParams) (ToolResponse, error) {
|
||||
if len(p.Tasks) == 0 {
|
||||
return TextErrorResponse("tasks list is required"), nil
|
||||
}
|
||||
|
||||
maxParallel := p.MaxParallel
|
||||
if maxParallel <= 0 {
|
||||
maxParallel = 3
|
||||
}
|
||||
if maxParallel > 5 {
|
||||
maxParallel = 5
|
||||
}
|
||||
|
||||
if len(p.Tasks) > 10 {
|
||||
return TextErrorResponse("maximum 10 tasks per delegation"), nil
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
results := executeParallelTasks(ctx, p.Tasks, maxParallel, registry)
|
||||
duration := time.Since(start)
|
||||
|
||||
resp := DelegateResponse{
|
||||
TotalTasks: len(results),
|
||||
Results: results,
|
||||
Duration: duration.Round(time.Millisecond).String(),
|
||||
}
|
||||
|
||||
for _, r := range results {
|
||||
if r.Success {
|
||||
resp.Successful++
|
||||
} else {
|
||||
resp.Failed++
|
||||
}
|
||||
}
|
||||
|
||||
data, _ := json.MarshalIndent(resp, "", " ")
|
||||
return TextResponse(string(data)), nil
|
||||
})
|
||||
}
|
||||
|
||||
func executeSubTask(ctx context.Context, task, contextInfo string, timeout time.Duration, registry *Registry) SubTaskResult {
|
||||
taskCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
result := SubTaskResult{
|
||||
Task: truncateString(task, 100),
|
||||
}
|
||||
|
||||
if contextInfo != "" {
|
||||
result.Task = fmt.Sprintf("%s (context: %s)", result.Task, truncateString(contextInfo, 50))
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
|
||||
terminalTool, ok := registry.Get("terminal")
|
||||
if !ok {
|
||||
result.Error = "terminal tool not available"
|
||||
return
|
||||
}
|
||||
|
||||
args, _ := json.Marshal(TerminalParams{
|
||||
Command: task,
|
||||
Timeout: int(timeout.Seconds()),
|
||||
})
|
||||
|
||||
resp, err := terminalTool.Execute(taskCtx, ToolCall{
|
||||
ID: fmt.Sprintf("delegate_%d", time.Now().UnixNano()),
|
||||
Name: "terminal",
|
||||
Arguments: args,
|
||||
})
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
return
|
||||
}
|
||||
|
||||
result.Result = resp.Content
|
||||
result.Success = !resp.IsError
|
||||
if resp.IsError {
|
||||
result.Error = resp.Content
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
return result
|
||||
case <-taskCtx.Done():
|
||||
result.Error = fmt.Sprintf("sub-task timed out after %v", timeout)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
func executeParallelTasks(ctx context.Context, tasks []DelegateTaskParams, maxParallel int, registry *Registry) []SubTaskResult {
|
||||
results := make([]SubTaskResult, len(tasks))
|
||||
|
||||
sem := make(chan struct{}, maxParallel)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i, task := range tasks {
|
||||
wg.Add(1)
|
||||
go func(idx int, t DelegateTaskParams) {
|
||||
defer wg.Done()
|
||||
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
|
||||
timeout := time.Duration(t.Timeout) * time.Second
|
||||
if timeout == 0 {
|
||||
timeout = 120 * time.Second
|
||||
}
|
||||
if timeout > 300*time.Second {
|
||||
timeout = 300 * time.Second
|
||||
}
|
||||
|
||||
results[idx] = executeSubTask(ctx, t.Task, t.Context, timeout, registry)
|
||||
}(i, task)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
func truncateString(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
200
internal/agent/image.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
)
|
||||
|
||||
type ImageGenerationTool struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
model string
|
||||
saveDir string
|
||||
}
|
||||
|
||||
func NewImageGenerationTool(cfg *config.MuyueConfig) (*ImageGenerationTool, error) {
|
||||
configDir, err := config.ConfigDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
saveDir := filepath.Join(configDir, "images")
|
||||
if err := os.MkdirAll(saveDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("creating images dir: %w", err)
|
||||
}
|
||||
|
||||
var apiKey, baseURL, model string
|
||||
for _, p := range cfg.AI.Providers {
|
||||
if p.Active {
|
||||
apiKey = p.APIKey
|
||||
baseURL = p.BaseURL
|
||||
model = p.Model
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.openai.com/v1"
|
||||
}
|
||||
|
||||
return &ImageGenerationTool{
|
||||
apiKey: apiKey,
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
model: model,
|
||||
saveDir: saveDir,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *ImageGenerationTool) Name() string {
|
||||
return "generate_image"
|
||||
}
|
||||
|
||||
func (t *ImageGenerationTool) Description() string {
|
||||
return "Generate an image from a text prompt using DALL-E or compatible API. Returns a local URL to the generated image."
|
||||
}
|
||||
|
||||
func (t *ImageGenerationTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"prompt": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Description of the image to generate",
|
||||
},
|
||||
"size": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Image size: 1024x1024, 1024x1792, or 1792x1024",
|
||||
"default": "1024x1024",
|
||||
},
|
||||
"style": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Style: vivid or natural",
|
||||
"default": "vivid",
|
||||
},
|
||||
},
|
||||
"required": []string{"prompt"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ImageGenerationTool) Execute(args map[string]interface{}) (string, error) {
|
||||
prompt, _ := args["prompt"].(string)
|
||||
if prompt == "" {
|
||||
return "", fmt.Errorf("prompt is required")
|
||||
}
|
||||
|
||||
size, _ := args["size"].(string)
|
||||
if size == "" {
|
||||
size = "1024x1024"
|
||||
}
|
||||
style, _ := args["style"].(string)
|
||||
if style == "" {
|
||||
style = "vivid"
|
||||
}
|
||||
|
||||
reqBody := map[string]interface{}{
|
||||
"model": "dall-e-3",
|
||||
"prompt": prompt,
|
||||
"size": size,
|
||||
"style": style,
|
||||
"n": 1,
|
||||
}
|
||||
|
||||
bodyBytes, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
url := t.baseURL + "/images/generations"
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if t.apiKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+t.apiKey)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 120 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("send request: %w", err)
|
||||
}
|
||||
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 {
|
||||
return "", fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var genResp struct {
|
||||
Data []struct {
|
||||
URL string `json:"url"`
|
||||
B64JSON string `json:"b64_json"`
|
||||
RevisedPrompt string `json:"revised_prompt"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &genResp); err != nil {
|
||||
return "", fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
|
||||
if len(genResp.Data) == 0 {
|
||||
return "", fmt.Errorf("no image returned")
|
||||
}
|
||||
|
||||
imgData := genResp.Data[0]
|
||||
filename := fmt.Sprintf("img-%d.png", time.Now().UnixNano())
|
||||
localPath := filepath.Join(t.saveDir, filename)
|
||||
|
||||
if imgData.B64JSON != "" {
|
||||
return "", fmt.Errorf("base64 response not yet supported")
|
||||
}
|
||||
|
||||
if imgData.URL != "" {
|
||||
if err := t.downloadImage(imgData.URL, localPath); err != nil {
|
||||
return "", fmt.Errorf("download image: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"url": "/api/images/" + filename,
|
||||
"revised_prompt": imgData.RevisedPrompt,
|
||||
"size": size,
|
||||
}
|
||||
resultJSON, _ := json.Marshal(result)
|
||||
return string(resultJSON), nil
|
||||
}
|
||||
|
||||
func (t *ImageGenerationTool) downloadImage(url, localPath string) error {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download failed: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
f, err := os.Create(localPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
_, err = io.Copy(f, resp.Body)
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
171
internal/agent/prompts/studio_system.md
Normal file
@@ -0,0 +1,171 @@
|
||||
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 (ses boutons, ses formulaires, son comportement), utilise `browser_test`. La page cible doit ê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 (le même token reste valide même après reload : si la connexion est perdue, l'utilisateur n'a qu'à re-coller).
|
||||
|
||||
## Règle d'or — économise les appels d'outils
|
||||
|
||||
**N'appelle PAS `list_clickables` après chaque clic.** C'est l'erreur n°1 qui fait exploser ta boucle (150+ appels pour 5 actions humaines). La liste change rarement et chaque appel renvoie ~30-100 éléments.
|
||||
|
||||
Stratégie efficace :
|
||||
|
||||
1. **Au début** : `summary` (URL + console + 20 lignes) → `list_clickables` (UNE FOIS, mémorise les index pertinents pour ta tâche).
|
||||
2. **Pendant** : clique par `index`. Lis le `console_delta` retourné après chaque clic.
|
||||
3. **Re-list seulement si** :
|
||||
- le `current_url` retourné change ET la nouvelle page est inconnue,
|
||||
- OU un clic ouvre un dialog / nouveau composant que tu dois inspecter,
|
||||
- OU `click` retourne `element not found` (DOM a muté).
|
||||
4. Pour les pages SPA qui rechargent côté URL mais pas le DOM, vérifie d'abord avec `eval document.querySelectorAll('button').length` — si stable, ne re-liste pas.
|
||||
5. Si tu te sens bloqué, **ne boucle pas en aveugle**. Fais 1 `summary`, 1 `eval` ciblé, et demande de l'aide à l'utilisateur. Mieux vaut 5 appels et une question qu'une boucle de 50 appels.
|
||||
|
||||
## Actions disponibles
|
||||
|
||||
| Action | Quand l'utiliser |
|
||||
|---|---|
|
||||
| `summary` | État de la page (URL, titre, 20 dernières lignes console). Appel **bon marché**. |
|
||||
| `list_clickables` | Liste indexée des boutons/liens/inputs visibles. **Appel cher** (~50+ items) — utilise avec parcimonie. |
|
||||
| `click` (par `index` de préférence) | Clique. Retourne `console_delta` + `current_url`. |
|
||||
| `type` | Remplit un input (par `selector` ou `index`). Toujours suivi d'un `click` sur le bouton submit. |
|
||||
| `eval` | JS arbitraire. Idéal pour des questions ciblées (`document.title`, `document.querySelectorAll(X).length`, etc.) au lieu de `list_clickables` complet. |
|
||||
| `current_url` | URL+titre. Très bon marché. |
|
||||
| `wait` | Pause 200-500 ms après une action async (transition / fetch). |
|
||||
| `console` | N dernières lignes console (default 50). Pour debug post-incident. |
|
||||
| `screenshot` | Capture viewport (ou `selector`) et sauve dans `~/.muyue/screenshots/<filename>.png`. Utilise `filename` pour nommer ; sinon timestamp. Best-effort (CSS externe / images peuvent ne pas apparaître). |
|
||||
|
||||
## Rapport final
|
||||
|
||||
Quand tous les tests sont terminés, fournis un rapport **structuré et bref** :
|
||||
|
||||
```
|
||||
✓ Boutons OK : <liste des labels>
|
||||
✗ Boutons cassés : <label> — <message d'erreur exact du console_delta>
|
||||
⚠ Bloqués : <label> — <pourquoi> (disabled, non trouvé, etc.)
|
||||
📸 Captures : <chemins relatifs sous ~/.muyue/screenshots/>
|
||||
```
|
||||
|
||||
Astuces :
|
||||
- Clique **par index** ; le sélecteur peut changer avec le DOM, l'index reste stable jusqu'au prochain `list_clickables`.
|
||||
- N'utilise jamais `eval` pour cliquer si `click` suffit.
|
||||
- Si la page se recharge (`current_url` change ou la connexion tombe), demande à l'utilisateur de recoller le snippet — le même token marche.
|
||||
</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"
|
||||
}
|
||||
}
|
||||
133
internal/api/agent_sessions.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AgentSession struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
PID int `json:"pid"`
|
||||
Command string `json:"command"`
|
||||
StartedAt string `json:"started_at"`
|
||||
Status string `json:"status"`
|
||||
Output string `json:"output,omitempty"`
|
||||
Cwd string `json:"cwd,omitempty"`
|
||||
}
|
||||
|
||||
type AgentSessionTracker struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*AgentSession
|
||||
}
|
||||
|
||||
func NewAgentSessionTracker() *AgentSessionTracker {
|
||||
return &AgentSessionTracker{
|
||||
sessions: make(map[string]*AgentSession),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *AgentSessionTracker) Discover() []AgentSession {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
activePIDs := make(map[int]bool)
|
||||
for _, s := range t.sessions {
|
||||
activePIDs[s.PID] = true
|
||||
}
|
||||
|
||||
for _, name := range []string{"crush", "claude"} {
|
||||
pids := findProcessesByName(name)
|
||||
for _, pid := range pids {
|
||||
if !activePIDs[pid] {
|
||||
session := &AgentSession{
|
||||
ID: fmt.Sprintf("%s-%d-%d", name, pid, time.Now().UnixMilli()),
|
||||
Type: name,
|
||||
PID: pid,
|
||||
Command: getProcessCommand(pid),
|
||||
StartedAt: time.Now().Format(time.RFC3339),
|
||||
Status: "running",
|
||||
}
|
||||
t.sessions[session.ID] = session
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var result []AgentSession
|
||||
for _, s := range t.sessions {
|
||||
if s.Status == "running" {
|
||||
if !isProcessAlive(s.PID) {
|
||||
s.Status = "completed"
|
||||
}
|
||||
}
|
||||
result = append(result, *s)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (t *AgentSessionTracker) Get(id string) *AgentSession {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
|
||||
s, ok := t.sessions[id]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
snapshot := *s
|
||||
return &snapshot
|
||||
}
|
||||
|
||||
func findProcessesByName(name string) []int {
|
||||
data, err := os.ReadFile("/proc/" + name + "/stat")
|
||||
_ = data
|
||||
_ = err
|
||||
|
||||
var pids []int
|
||||
|
||||
entries, err := os.ReadDir("/proc")
|
||||
if err != nil {
|
||||
return pids
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
var pid int
|
||||
if _, err := fmt.Sscanf(entry.Name(), "%d", &pid); err != nil {
|
||||
continue
|
||||
}
|
||||
if pid <= 0 || pid == os.Getpid() {
|
||||
continue
|
||||
}
|
||||
|
||||
cmdline, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
cmdStr := string(cmdline)
|
||||
if strings.Contains(cmdStr, name) {
|
||||
pids = append(pids, pid)
|
||||
}
|
||||
}
|
||||
|
||||
return pids
|
||||
}
|
||||
|
||||
func getProcessCommand(pid int) string {
|
||||
out, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.ReplaceAll(string(out), "\x00", " ")
|
||||
}
|
||||
|
||||
func isProcessAlive(pid int) bool {
|
||||
_, err := os.Stat(fmt.Sprintf("/proc/%d", pid))
|
||||
return err == nil
|
||||
}
|
||||
772
internal/api/browsertest.go
Normal file
@@ -0,0 +1,772 @@
|
||||
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/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/muyue/muyue/internal/agent"
|
||||
)
|
||||
|
||||
// thin os wrappers (kept here so saveScreenshot stays independent of any
|
||||
// existing helper file's evolution)
|
||||
func osUserHomeDir() (string, error) { return os.UserHomeDir() }
|
||||
func mkdirAll(p string, m os.FileMode) error { return os.MkdirAll(p, m) }
|
||||
func writeFile(p string, b []byte, m os.FileMode) error { return os.WriteFile(p, b, m) }
|
||||
func base64StdDecode(s string) ([]byte, error) { return base64.StdEncoding.DecodeString(s) }
|
||||
|
||||
const (
|
||||
// browserTestTokenTTL is a sliding window: every successful WS connect
|
||||
// using the token resets it. So the user re-pasting the snippet after a
|
||||
// page reload / navigation seamlessly resumes (same token, same session
|
||||
// continuation in the AI's view), as long as no more than this gap of
|
||||
// inactivity occurs.
|
||||
browserTestTokenTTL = 60 * 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 a token. Tokens are no longer single-use:
|
||||
// the test snippet re-establishes the WS after every page reload /
|
||||
// navigation, so the same token must work multiple times. We slide the
|
||||
// expiration on each successful use so a long active test session keeps
|
||||
// the token alive.
|
||||
func (s *BrowserTestStore) ConsumeToken(tok string) bool {
|
||||
s.tokensMu.Lock()
|
||||
defer s.tokensMu.Unlock()
|
||||
t, ok := s.tokens[tok]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if time.Since(t) > browserTestTokenTTL {
|
||||
delete(s.tokens, tok)
|
||||
return false
|
||||
}
|
||||
s.tokens[tok] = time.Now() // sliding refresh
|
||||
return true
|
||||
}
|
||||
|
||||
// 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, screenshot"`
|
||||
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/screenshot actions (screenshot defaults to whole viewport when omitted)"`
|
||||
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)"`
|
||||
Filename string `json:"filename,omitempty" description:"Screenshot action: optional file name (no path, no extension); defaults to a timestamp"`
|
||||
}
|
||||
|
||||
// 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", "screenshot":
|
||||
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
|
||||
}
|
||||
|
||||
// Screenshot post-processing: snippet returns a base64 data URL;
|
||||
// decode and write to ~/.muyue/screenshots/<filename>.png so the
|
||||
// AI can reference an on-disk path rather than streaming megabytes
|
||||
// of base64 back through its context.
|
||||
if action == "screenshot" {
|
||||
saved, perr := saveScreenshot(payload, p.Filename)
|
||||
if perr != nil {
|
||||
return agent.TextErrorResponse("screenshot save: " + perr.Error()), nil
|
||||
}
|
||||
out, _ := json.MarshalIndent(map[string]interface{}{
|
||||
"action": "screenshot",
|
||||
"saved_to": saved,
|
||||
"current_url": sess.URL,
|
||||
}, "", " ")
|
||||
return agent.TextResponse(string(out)), 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 {
|
||||
// Inline JS injected into the user's target page. Responsibilities:
|
||||
// - open the WS, with auto-reconnect (exponential backoff capped at 5s)
|
||||
// - hook console.log/info/warn/error/debug + window.onerror + unhandledrejection
|
||||
// - dispatch RPC commands: list_clickables, click, type, eval, current_url, screenshot
|
||||
// - re-establish WS on transient close (network blip, server restart, etc.)
|
||||
//
|
||||
// Across full page navigation / reload the JS context is destroyed —
|
||||
// no JS-only mechanism can survive that. The token is reusable (sliding
|
||||
// 60-min TTL server-side), so the user just re-pastes the same snippet
|
||||
// from the Tests tab to resume.
|
||||
return `(function(){
|
||||
if (window.__muyueTestRunner) { console.log('[Muyue] runner already attached'); return; }
|
||||
var WS_URL = ` + jsString(wsURL) + `;
|
||||
var ws = null, lastList = [], retry = 0;
|
||||
function send(obj){ try{ if (ws && ws.readyState === 1) 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) }; }
|
||||
}
|
||||
// Best-effort viewport screenshot via SVG foreignObject — works on most
|
||||
// pages, but external CSS / images / iframes won't be inlined. Returns a
|
||||
// base64 PNG data URL the server will save to disk.
|
||||
function screenshot(p){
|
||||
return new Promise(function(resolve){
|
||||
try {
|
||||
var w = Math.max(document.documentElement.clientWidth, 1024);
|
||||
var h = Math.max(window.innerHeight, 768);
|
||||
var node = (p && p.selector) ? document.querySelector(p.selector) : document.documentElement;
|
||||
if (!node) { resolve({ ok:false, error:'selector not found' }); return; }
|
||||
var rect = node.getBoundingClientRect();
|
||||
if (node === document.documentElement) { rect = { width:w, height:h }; }
|
||||
var clone = node.cloneNode(true);
|
||||
var ser = new XMLSerializer().serializeToString(clone);
|
||||
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="'+Math.round(rect.width)+'" height="'+Math.round(rect.height)+'">' +
|
||||
'<foreignObject width="100%" height="100%"><div xmlns="http://www.w3.org/1999/xhtml" style="background:white">' + ser + '</div></foreignObject></svg>';
|
||||
var img = new Image();
|
||||
img.onload = function(){
|
||||
try {
|
||||
var c = document.createElement('canvas');
|
||||
c.width = Math.round(rect.width); c.height = Math.round(rect.height);
|
||||
c.getContext('2d').drawImage(img, 0, 0);
|
||||
resolve({ ok:true, data_url: c.toDataURL('image/png'), width: c.width, height: c.height });
|
||||
} catch(e){ resolve({ ok:false, error:'canvas: '+String(e) }); }
|
||||
};
|
||||
img.onerror = function(){ resolve({ ok:false, error:'image load failed (CSP or invalid SVG)' }); };
|
||||
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
|
||||
} catch(e){ resolve({ 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 };
|
||||
}
|
||||
case 'screenshot':
|
||||
return screenshot(p);
|
||||
}
|
||||
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);
|
||||
function connect(){
|
||||
ws = new WebSocket(WS_URL);
|
||||
ws.onopen = function(){ retry = 0; 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) {
|
||||
var out = dispatch(msg);
|
||||
if (out && typeof out.then === 'function') { out.then(function(r){ reply(msg.id, r); }); }
|
||||
else { reply(msg.id, out); }
|
||||
}
|
||||
};
|
||||
ws.onclose = function(){
|
||||
// Same-page transient disconnect → reconnect with backoff up to ~5s.
|
||||
// Full navigation kills the JS context entirely — this never runs in
|
||||
// that case; the user re-pastes the snippet (same token works).
|
||||
retry = Math.min(retry + 1, 5);
|
||||
setTimeout(connect, 500 * retry);
|
||||
};
|
||||
ws.onerror = function(){ /* onclose will fire next */ };
|
||||
}
|
||||
connect();
|
||||
window.__muyueTestRunner = { reconnect: connect, list: list };
|
||||
})();`
|
||||
}
|
||||
|
||||
func jsString(s string) string {
|
||||
b, _ := json.Marshal(s)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// saveScreenshot decodes the base64 PNG returned by the snippet's
|
||||
// screenshot action and writes it to ~/.muyue/screenshots/<name>.png.
|
||||
// Returns the absolute path saved, or an error.
|
||||
func saveScreenshot(replyPayload json.RawMessage, requestedName string) (string, error) {
|
||||
var reply struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
DataURL string `json:"data_url,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(replyPayload, &reply); err != nil {
|
||||
return "", fmt.Errorf("invalid reply: %w", err)
|
||||
}
|
||||
if !reply.OK {
|
||||
if reply.Error != "" {
|
||||
return "", fmt.Errorf("snippet: %s", reply.Error)
|
||||
}
|
||||
return "", fmt.Errorf("snippet returned ok=false")
|
||||
}
|
||||
const prefix = "data:image/png;base64,"
|
||||
if !strings.HasPrefix(reply.DataURL, prefix) {
|
||||
return "", fmt.Errorf("unexpected data URL prefix")
|
||||
}
|
||||
raw, err := base64StdDecode(reply.DataURL[len(prefix):])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("base64: %w", err)
|
||||
}
|
||||
dir, err := screenshotDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
name := sanitizeFilename(requestedName)
|
||||
if name == "" {
|
||||
name = time.Now().Format("20060102-150405")
|
||||
}
|
||||
path := dir + "/" + name + ".png"
|
||||
if err := writeFile(path, raw, 0644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func screenshotDir() (string, error) {
|
||||
home, err := osUserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir := home + "/.muyue/screenshots"
|
||||
if err := mkdirAll(dir, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// sanitizeFilename keeps a safe subset (letters / digits / _ / - / .) so
|
||||
// the user-supplied name cannot escape the screenshots directory.
|
||||
func sanitizeFilename(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
|
||||
r == '_', r == '-', r == '.':
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
333
internal/api/chat_engine.go
Normal file
@@ -0,0 +1,333 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/muyue/muyue/internal/agent"
|
||||
"github.com/muyue/muyue/internal/orchestrator"
|
||||
)
|
||||
|
||||
// MaxToolIterations bounds the inner tool-call loop in RunWithTools /
|
||||
// RunNonStream. The cap exists only to avoid an infinite loop when a model
|
||||
// keeps calling tools forever; the value is intentionally generous so a
|
||||
// realistic agent run (multi-file refactor, exploratory debugging…) never
|
||||
// hits it. If you find yourself raising this to absurd values, look for a
|
||||
// loop bug in the model output instead.
|
||||
const MaxToolIterations = 500
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
@@ -12,15 +13,57 @@ import (
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
)
|
||||
|
||||
const maxTokensApprox = 100000
|
||||
const summarizeThreshold = 80000
|
||||
const contextWindowTokens = 150000
|
||||
const summarizeRatio = 0.80
|
||||
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 {
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Time string `json:"time"`
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Time string `json:"time"`
|
||||
Images []string `json:"images,omitempty"`
|
||||
Summarized bool `json:"summarized,omitempty"`
|
||||
}
|
||||
|
||||
type Conversation struct {
|
||||
@@ -36,6 +79,19 @@ type ConversationStore struct {
|
||||
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 {
|
||||
dir, err := config.ConfigDir()
|
||||
if err != nil {
|
||||
@@ -112,14 +168,38 @@ func (cs *ConversationStore) Add(role, content string) FeedMessage {
|
||||
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() {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
|
||||
var imageIDs []string
|
||||
for _, m := range cs.conv.Messages {
|
||||
imageIDs = append(imageIDs, m.Images...)
|
||||
}
|
||||
|
||||
cs.conv.Messages = []FeedMessage{}
|
||||
cs.conv.Summary = ""
|
||||
cs.conv.CreatedAt = time.Now().Format(time.RFC3339)
|
||||
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
cs.save()
|
||||
|
||||
go cleanupImages(imageIDs)
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) SetSummary(summary string) {
|
||||
@@ -129,30 +209,125 @@ func (cs *ConversationStore) SetSummary(summary string) {
|
||||
cs.save()
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) TrimOld(keepCount int) {
|
||||
func (cs *ConversationStore) MarkSummarized(upToIndex int) {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
if len(cs.conv.Messages) <= keepCount {
|
||||
if upToIndex <= 0 || upToIndex >= len(cs.conv.Messages) {
|
||||
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()
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) ApproxTokenCount() int {
|
||||
return cs.ApproxTokenCountDetailed().total
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) ApproxTokenCountDetailed() TokenCount {
|
||||
cs.mu.RLock()
|
||||
defer cs.mu.RUnlock()
|
||||
total := utf8.RuneCountInString(cs.conv.Summary)
|
||||
for _, m := range cs.conv.Messages {
|
||||
total += utf8.RuneCountInString(m.Content)
|
||||
|
||||
result := TokenCount{
|
||||
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 {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -1,37 +1,190 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"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 toolCallRegex = regexp.MustCompile(`\[TOOL_CALL:\{[^\}]+\}\]`)
|
||||
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"`
|
||||
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.StatusBadRequest)
|
||||
writeError(w, "no message", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if len(body.Images) > 3 {
|
||||
writeError(w, "max 3 images", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
s.convStore.Add("user", body.Message)
|
||||
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()
|
||||
@@ -42,143 +195,215 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
orb.SetSystemPrompt(`Tu es l'assistant IA de Muyue Studio. Tu as accès à un outil "crush" pour exécuter des tâches complexes sur l'ordinateur de l'utilisateur.
|
||||
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)
|
||||
|
||||
RÈGLES ABSOLUES:
|
||||
1. Tu as DEUX possibilités ONLY:
|
||||
- Répondre directement à l'utilisateur avec tes connaissances
|
||||
- Demander l'exécution d'une tâche via crush en utilisant ce format EXACT:
|
||||
[TOOL_CALL:{"tool":"crush","task":"description de la tâche"}]
|
||||
if memBlock := s.buildMemoryContext(enrichedMessage); memBlock != "" {
|
||||
orb.AppendHistory(orchestrator.Message{
|
||||
Role: "system",
|
||||
Content: orchestrator.TextContent(memBlock),
|
||||
})
|
||||
}
|
||||
|
||||
2. Quand tu utilises [TOOL_CALL:...], le système exécutera la tâche et te donnera le résultat.
|
||||
Tu peux ensuite répondre à l'utilisateur avec ce résultat.
|
||||
// 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
|
||||
}
|
||||
|
||||
3. SOIS CONCIS - pas de blabla, vais droit au but.
|
||||
|
||||
4. L'utilisateur ne voit PAS tes pensées entre <think> tags.
|
||||
|
||||
5. EXEMPLES d'utilisation de tool:
|
||||
- "cherche tous les fichiers .md dans le projet" → [TOOL_CALL:{"tool":"crush","task":"Recherche les fichiers .md dans le projet courant"}]
|
||||
- "aide-moi à déboguer cette erreur" → tu peux répondre directement si tu as assez d'info, sinon utiliser tool
|
||||
- "quelle est la météo?" → [TOOL_CALL:{"tool":"crush","task":"Cherche la météo actuelle"}]
|
||||
|
||||
6. Ne fais PAS de multi-step tool calls dans une seule réponse. Attends le résultat avant de continuer.`)
|
||||
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 {
|
||||
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)
|
||||
s.handleStreamChat(w, orb, enrichedMessage)
|
||||
} else {
|
||||
s.handleNonStreamChat(w, orb, enrichedMessage)
|
||||
}
|
||||
}
|
||||
|
||||
result, err := orb.SendStream(body.Message, func(chunk string) {
|
||||
if strings.HasPrefix(chunk, "<think") {
|
||||
data, _ := json.Marshal(map[string]string{"thinking": strings.TrimPrefix(chunk, "<think")})
|
||||
w.Write([]byte("data: " + string(data) + "\n\n"))
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
return
|
||||
}
|
||||
if chunk == "</think>" {
|
||||
data, _ := json.Marshal(map[string]string{"thinking_end": "true"})
|
||||
w.Write([]byte("data: " + string(data) + "\n\n"))
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
return
|
||||
}
|
||||
data, _ := json.Marshal(map[string]string{"content": chunk})
|
||||
w.Write([]byte("data: " + string(data) + "\n\n"))
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
data, _ := json.Marshal(map[string]string{"error": err.Error()})
|
||||
w.Write([]byte("data: " + string(data) + "\n\n"))
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// Process tool calls if any
|
||||
cleanResult := processToolCalls(result)
|
||||
s.convStore.Add("assistant", cleanResult)
|
||||
|
||||
data, _ := json.Marshal(map[string]string{"done": "true"})
|
||||
w.Write([]byte("data: " + string(data) + "\n\n"))
|
||||
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
|
||||
}
|
||||
|
||||
result, err := orb.Send(body.Message)
|
||||
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
|
||||
}
|
||||
cleanResult := processToolCalls(result)
|
||||
s.convStore.Add("assistant", cleanResult)
|
||||
writeJSON(w, map[string]string{"content": cleanResult})
|
||||
}
|
||||
|
||||
func processToolCalls(content string) string {
|
||||
matches := toolCallRegex.FindAllString(content, -1)
|
||||
if len(matches) == 0 {
|
||||
return cleanThinkingTags(content)
|
||||
}
|
||||
s.convStore.Add("assistant", finalContent)
|
||||
|
||||
var result strings.Builder
|
||||
clean := content
|
||||
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
|
||||
|
||||
for _, match := range matches {
|
||||
// Extract tool and task from [TOOL_CALL:{...}]
|
||||
inner := strings.TrimPrefix(match, "[TOOL_CALL:")
|
||||
inner = strings.TrimSuffix(inner, "]}") + "}"
|
||||
|
||||
var call struct {
|
||||
Tool string `json:"tool"`
|
||||
Task string `json:"task"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(inner), &call); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if call.Tool == "crush" && call.Task != "" {
|
||||
result.WriteString(fmt.Sprintf("> %s\n\n", call.Task))
|
||||
output := executeCrush(call.Task)
|
||||
result.WriteString(output)
|
||||
result.WriteString("\n\n---\n\n")
|
||||
}
|
||||
|
||||
clean = strings.Replace(clean, match, "", 1)
|
||||
}
|
||||
|
||||
clean = cleanThinkingTags(clean)
|
||||
|
||||
if result.Len() > 0 {
|
||||
clean = strings.TrimSpace(clean) + "\n\n" + strings.TrimSpace(result.String())
|
||||
}
|
||||
|
||||
return clean
|
||||
writeJSON(w, map[string]string{"content": finalContent})
|
||||
}
|
||||
|
||||
func cleanThinkingTags(content string) string {
|
||||
re := regexp.MustCompile(`(?s)<think[^>]*>.*?</think>`)
|
||||
return re.ReplaceAllString(content, "")
|
||||
return strings.TrimSpace(thinkingTagRegex.ReplaceAllString(content, ""))
|
||||
}
|
||||
|
||||
func executeCrush(task string) string {
|
||||
cmd := exec.Command("crush", "run", task)
|
||||
output, err := cmd.CombinedOutput()
|
||||
// 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 fmt.Sprintf("Erreur: %v\n%s", err, string(output))
|
||||
return "", false
|
||||
}
|
||||
return string(output)
|
||||
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() {
|
||||
@@ -210,8 +435,7 @@ func (s *Server) autoSummarize() {
|
||||
}
|
||||
|
||||
s.convStore.SetSummary(result)
|
||||
s.convStore.TrimOld(len(messages) - half)
|
||||
s.convStore.Add("system", "[Conversation résumée automatiquement]")
|
||||
s.convStore.MarkSummarized(half)
|
||||
}
|
||||
|
||||
func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -221,8 +445,11 @@ func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
messages := s.convStore.Get()
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"messages": messages,
|
||||
"tokens": s.convStore.ApproxTokenCount(),
|
||||
"messages": messages,
|
||||
"tokens": s.convStore.ApproxTokenCount(),
|
||||
"max_tokens": contextWindowTokens,
|
||||
"summarize_at": int(float64(contextWindowTokens) * summarizeRatio),
|
||||
"summary": s.convStore.GetSummary(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -233,4 +460,17 @@ func (s *Server) handleChatClear(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,7 +5,21 @@ import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const summarizePrompt = `Résume la conversation suivante de manière concise et structurée. Garde les points clés, les décisions prises, le contexte technique important. Le résumé doit permettre de continuer la conversation sans perte de contexte. Réponds uniquement avec le résumé, sans meta-commentaire.`
|
||||
const summarizePrompt = `Résume cette conversation de manière ultra-concise et structurée.
|
||||
|
||||
CONSERVE :
|
||||
- Les décisions techniques prises et leur rationale
|
||||
- Les configurations modifiées (noms exacts, valeurs)
|
||||
- Les fichiers/chemins manipulés
|
||||
- Les erreurs rencontrées et leurs résolutions
|
||||
- Le contexte nécessaire pour continuer
|
||||
|
||||
ÉLIMINE :
|
||||
- Les échanges de politesse
|
||||
- Les tentatives infructueuses (sauf si la solution n'a pas été trouvée)
|
||||
- Les sorties d'outils brutes (garde seulement les conclusions)
|
||||
|
||||
FORMAT : Markdown structuré avec sections. Max 500 mots. Pas de méta-commentaire.`
|
||||
|
||||
func writeJSON(w http.ResponseWriter, data interface{}) {
|
||||
json.NewEncoder(w).Encode(data)
|
||||
|
||||
@@ -3,8 +3,11 @@ package api
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -50,32 +53,41 @@ func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
|
||||
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"`
|
||||
|
||||
currentJSON, err := json.Marshal(s.config.Profile)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
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 body.Name != "" {
|
||||
s.config.Profile.Name = body.Name
|
||||
if err := json.Unmarshal(body, &updates); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Pseudo != "" {
|
||||
s.config.Profile.Pseudo = body.Pseudo
|
||||
|
||||
deepMerge(currentMap, updates)
|
||||
|
||||
mergedJSON, err := json.Marshal(currentMap)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
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 := 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
|
||||
@@ -83,6 +95,20 @@ func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
@@ -110,7 +136,7 @@ func (s *Server) handleSaveProvider(w http.ResponseWriter, r *http.Request) {
|
||||
found := false
|
||||
for i := range s.config.AI.Providers {
|
||||
if s.config.AI.Providers[i].Name == body.Name {
|
||||
if body.APIKey != "" {
|
||||
if body.APIKey != "" && body.APIKey != "***" {
|
||||
s.config.AI.Providers[i].APIKey = body.APIKey
|
||||
}
|
||||
if body.Model != "" {
|
||||
@@ -161,6 +187,14 @@ func (s *Server) handleValidateProvider(w http.ResponseWriter, r *http.Request)
|
||||
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 == "" {
|
||||
@@ -175,6 +209,8 @@ func (s *Server) handleValidateProvider(w http.ResponseWriter, r *http.Request)
|
||||
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":
|
||||
@@ -252,7 +288,7 @@ func (s *Server) handleSaveTerminalSettings(w http.ResponseWriter, r *http.Reque
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.FontSize > 0 {
|
||||
if body.FontSize > 0 && body.FontSize <= 72 {
|
||||
s.config.Terminal.FontSize = body.FontSize
|
||||
}
|
||||
if body.FontFamily != "" {
|
||||
@@ -281,3 +317,195 @@ func (s *Server) handleGetTerminalThemes(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
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"
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
336
internal/api/handlers_files.go
Normal file
@@ -0,0 +1,336 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
"github.com/muyue/muyue/internal/mcpserver"
|
||||
)
|
||||
|
||||
func (s *Server) handleFileContent(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
path := r.URL.Query().Get("path")
|
||||
if path == "" {
|
||||
writeError(w, "path parameter required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
path = strings.ReplaceAll(path, "~", home)
|
||||
|
||||
if !filepath.IsAbs(path) {
|
||||
writeError(w, "path must be absolute", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
writeError(w, fmt.Sprintf("Error reading file: %v", err), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
lang := "text"
|
||||
switch ext {
|
||||
case ".go":
|
||||
lang = "go"
|
||||
case ".js", ".jsx":
|
||||
lang = "javascript"
|
||||
case ".ts", ".tsx":
|
||||
lang = "typescript"
|
||||
case ".py":
|
||||
lang = "python"
|
||||
case ".json":
|
||||
lang = "json"
|
||||
case ".yaml", ".yml":
|
||||
lang = "yaml"
|
||||
case ".md":
|
||||
lang = "markdown"
|
||||
case ".css":
|
||||
lang = "css"
|
||||
case ".html":
|
||||
lang = "html"
|
||||
case ".sh", ".bash":
|
||||
lang = "shell"
|
||||
case ".rs":
|
||||
lang = "rust"
|
||||
case ".java":
|
||||
lang = "java"
|
||||
}
|
||||
|
||||
stat, _ := os.Stat(path)
|
||||
modTime := ""
|
||||
if stat != nil {
|
||||
modTime = stat.ModTime().Format("2006-01-02T15:04:05Z07:00")
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"path": path,
|
||||
"content": string(data),
|
||||
"lang": lang,
|
||||
"size": len(data),
|
||||
"modTime": modTime,
|
||||
})
|
||||
|
||||
case http.MethodPut:
|
||||
var body struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if body.Path == "" {
|
||||
writeError(w, "path required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
path := strings.ReplaceAll(body.Path, "~", home)
|
||||
|
||||
if !filepath.IsAbs(path) {
|
||||
writeError(w, "path must be absolute", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
writeError(w, fmt.Sprintf("Error creating directory: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(body.Content), 0644); err != nil {
|
||||
writeError(w, fmt.Sprintf("Error writing file: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"status": "ok",
|
||||
"path": path,
|
||||
"size": len(body.Content),
|
||||
})
|
||||
|
||||
default:
|
||||
writeError(w, "GET/PUT only", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleMuyueMCPServerStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"enabled": s.mcpServer != nil,
|
||||
"running": s.mcpServer != nil,
|
||||
"port": s.getMCPServerPort(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleMuyueMCPServerStart(w http.ResponseWriter, r *http.Request) {
|
||||
if s.mcpServer != nil {
|
||||
writeJSON(w, map[string]string{"status": "already_running"})
|
||||
return
|
||||
}
|
||||
s.startMCPServer()
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"status": "started",
|
||||
"port": s.getMCPServerPort(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleMuyueMCPServerStop(w http.ResponseWriter, r *http.Request) {
|
||||
if s.mcpServer == nil {
|
||||
writeJSON(w, map[string]string{"status": "not_running"})
|
||||
return
|
||||
}
|
||||
s.mcpServer.Stop()
|
||||
s.mcpServer = nil
|
||||
writeJSON(w, map[string]string{"status": "stopped"})
|
||||
}
|
||||
|
||||
func (s *Server) getMCPServerPort() int {
|
||||
if s.mcpServer == nil {
|
||||
return 0
|
||||
}
|
||||
return s.mcpServer.Port()
|
||||
}
|
||||
|
||||
func (s *Server) startMCPServer() {
|
||||
port := 8096
|
||||
if s.config != nil {
|
||||
}
|
||||
s.mcpServer = mcpserver.New(port)
|
||||
s.mcpServer.Start()
|
||||
}
|
||||
|
||||
func (s *Server) handleAgentSessionsList(w http.ResponseWriter, r *http.Request) {
|
||||
sessions := s.agentTracker.Discover()
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"sessions": sessions,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAgentSessionOutput(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/agent-sessions/")
|
||||
if id == "" {
|
||||
writeError(w, "session id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
session := s.agentTracker.Get(id)
|
||||
if session == nil {
|
||||
writeError(w, "session not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, session)
|
||||
}
|
||||
|
||||
func (s *Server) handleWorkspaceList(w http.ResponseWriter, r *http.Request) {
|
||||
dir, err := configWorkspacesDir()
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
writeJSON(w, map[string]interface{}{"workspaces": []interface{}{}})
|
||||
return
|
||||
}
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var workspaces []map[string]interface{}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
if !strings.HasSuffix(entry.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSuffix(entry.Name(), ".json")
|
||||
data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var ws map[string]interface{}
|
||||
if err := json.Unmarshal(data, &ws); err != nil {
|
||||
continue
|
||||
}
|
||||
ws["name"] = name
|
||||
workspaces = append(workspaces, ws)
|
||||
}
|
||||
|
||||
if workspaces == nil {
|
||||
workspaces = []map[string]interface{}{}
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{"workspaces": workspaces})
|
||||
}
|
||||
|
||||
func (s *Server) handleWorkspaceSave(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Layout string `json:"layout"`
|
||||
Tabs string `json:"tabs"`
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
dir, err := configWorkspacesDir()
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
wsData := map[string]interface{}{
|
||||
"name": body.Name,
|
||||
"layout": body.Layout,
|
||||
"tabs": body.Tabs,
|
||||
"updated": fmt.Sprintf("%d", time.Now().Unix()),
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(wsData, "", " ")
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(dir, body.Name+".json"), data, 0644); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleWorkspaceGet(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimPrefix(r.URL.Path, "/api/workspace/")
|
||||
if name == "" {
|
||||
writeError(w, "name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == "DELETE" {
|
||||
dir, err := configWorkspacesDir()
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := os.Remove(filepath.Join(dir, name+".json")); err != nil {
|
||||
writeError(w, "workspace not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]string{"status": "ok"})
|
||||
return
|
||||
}
|
||||
|
||||
dir, err := configWorkspacesDir()
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dir, name+".json"))
|
||||
if err != nil {
|
||||
writeError(w, "workspace not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
json.Unmarshal(data, &result)
|
||||
writeJSON(w, result)
|
||||
}
|
||||
|
||||
func configWorkspacesDir() (string, error) {
|
||||
configDir, err := config.ConfigDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir := filepath.Join(configDir, "workspaces")
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return "", fmt.Errorf("create workspaces dir: %w", err)
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
52
internal/api/handlers_image.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/muyue/muyue/internal/agent"
|
||||
)
|
||||
|
||||
func (s *Server) handleImageGenerate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Prompt string `json:"prompt"`
|
||||
Size string `json:"size"`
|
||||
Style string `json:"style"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, "invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.Prompt == "" {
|
||||
jsonError(w, "prompt is required")
|
||||
return
|
||||
}
|
||||
|
||||
imgTool, err := agent.NewImageGenerationTool(s.config)
|
||||
if err != nil {
|
||||
jsonError(w, "image tool init: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
args := map[string]interface{}{
|
||||
"prompt": req.Prompt,
|
||||
"size": req.Size,
|
||||
"style": req.Style,
|
||||
}
|
||||
|
||||
result, err := imgTool.Execute(args)
|
||||
if err != nil {
|
||||
jsonError(w, fmt.Sprintf("generation failed: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(result))
|
||||
}
|
||||
@@ -1,8 +1,18 @@
|
||||
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"
|
||||
@@ -15,6 +25,7 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
|
||||
"name": version.Name,
|
||||
"version": version.Version,
|
||||
"author": version.Author,
|
||||
"sudo": !agent.NeedsSudoPassword(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -69,8 +80,23 @@ func (s *Server) handleProviders(w http.ResponseWriter, r *http.Request) {
|
||||
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": s.config.AI.Providers,
|
||||
"providers": masked,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -80,6 +106,9 @@ func (s *Server) handleSkills(w http.ResponseWriter, r *http.Request) {
|
||||
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),
|
||||
@@ -95,9 +124,14 @@ func (s *Server) handleLSP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
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,
|
||||
"servers": servers,
|
||||
"configured": true,
|
||||
"detected_editors": editors,
|
||||
"statuses": statuses,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -106,14 +140,622 @@ func (s *Server) handleMCPConfigure(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if err := mcp.ConfigureAll(s.config); err != nil {
|
||||
|
||||
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]string{"status": "ok"})
|
||||
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 := collectSystemMetrics()
|
||||
writeJSON(w, m)
|
||||
}
|
||||
|
||||
256
internal/api/handlers_memory.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/muyue/muyue/internal/memory"
|
||||
)
|
||||
|
||||
func (s *Server) ensureMemoryStore() (*memory.Store, error) {
|
||||
if s.memoryStore == nil {
|
||||
store, err := memory.NewStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.memoryStore = store
|
||||
}
|
||||
return s.memoryStore, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleMemoryList(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
store, err := s.ensureMemoryStore()
|
||||
if err != nil {
|
||||
writeError(w, "memory store: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var memType memory.MemoryType
|
||||
if t := r.URL.Query().Get("type"); t != "" {
|
||||
memType = memory.MemoryType(t)
|
||||
}
|
||||
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
|
||||
memories, err := store.List(memType, limit, offset)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
count, _ := store.Count()
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"memories": memories,
|
||||
"count": len(memories),
|
||||
"total": count,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleMemoryCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Type string `json:"type"`
|
||||
Key string `json:"key"`
|
||||
Content string `json:"content"`
|
||||
Tags string `json:"tags,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Confidence float64 `json:"confidence,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if body.Key == "" || body.Content == "" {
|
||||
writeError(w, "key and content are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
store, err := s.ensureMemoryStore()
|
||||
if err != nil {
|
||||
writeError(w, "memory store: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
memType := memory.MemoryType(body.Type)
|
||||
if memType == "" {
|
||||
memType = memory.TypeFact
|
||||
}
|
||||
|
||||
m := &memory.Memory{
|
||||
Type: memType,
|
||||
Key: body.Key,
|
||||
Content: body.Content,
|
||||
Tags: body.Tags,
|
||||
Source: body.Source,
|
||||
Confidence: body.Confidence,
|
||||
}
|
||||
if m.Confidence == 0 {
|
||||
m.Confidence = 0.5
|
||||
}
|
||||
|
||||
if err := store.Store(m); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"created": true,
|
||||
"memory": m,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleMemoryDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "DELETE" {
|
||||
writeError(w, "DELETE only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/memory/")
|
||||
if id == "" {
|
||||
writeError(w, "memory id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
store, err := s.ensureMemoryStore()
|
||||
if err != nil {
|
||||
writeError(w, "memory store: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := store.Delete(id); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"deleted": true,
|
||||
"id": id,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleMemoryOperation(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/memory/")
|
||||
|
||||
if path == "" {
|
||||
s.handleMemoryList(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case "DELETE":
|
||||
s.handleMemoryDelete(w, r)
|
||||
case "GET":
|
||||
store, err := s.ensureMemoryStore()
|
||||
if err != nil {
|
||||
writeError(w, "memory store: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
m, err := store.Get(path)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, m)
|
||||
default:
|
||||
writeError(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleMemorySearch(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
|
||||
}
|
||||
|
||||
store, err := s.ensureMemoryStore()
|
||||
if err != nil {
|
||||
writeError(w, "memory store: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
results, err := store.Search(query, limit)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"results": results,
|
||||
"count": len(results),
|
||||
"query": query,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleMemoryRecall(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
query := r.URL.Query().Get("q")
|
||||
|
||||
store, err := s.ensureMemoryStore()
|
||||
if err != nil {
|
||||
writeError(w, "memory store: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
injector := memory.NewInjector(store)
|
||||
contextBlock, err := injector.BuildContextBlock(query)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"context": contextBlock,
|
||||
"query": query,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleMemoryContext(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
store, err := s.ensureMemoryStore()
|
||||
if err != nil {
|
||||
writeError(w, "memory store: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
preferences, _ := store.RecallPreferences()
|
||||
facts, _ := store.RecallFacts()
|
||||
|
||||
recentCutoff := time.Now().Add(-24 * time.Hour)
|
||||
recent, _ := store.RecallRecent(recentCutoff, 10)
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"preferences": preferences,
|
||||
"facts": facts,
|
||||
"recent": recent,
|
||||
})
|
||||
}
|
||||
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)",
|
||||
})
|
||||
}
|
||||
373
internal/api/handlers_plugins_lessons.go
Normal file
@@ -0,0 +1,373 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/muyue/muyue/internal/lessons"
|
||||
"github.com/muyue/muyue/internal/mcp"
|
||||
"github.com/muyue/muyue/internal/plugins"
|
||||
)
|
||||
|
||||
func (s *Server) handlePlugins(w http.ResponseWriter, r *http.Request) {
|
||||
if s.pluginManager == nil {
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"plugins": []interface{}{},
|
||||
"count": 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"plugins": s.pluginManager.List(),
|
||||
"count": len(s.pluginManager.List()),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handlePluginEnable(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimPrefix(r.URL.Path, "/api/plugins/")
|
||||
name = strings.TrimSuffix(name, "/enable")
|
||||
|
||||
if s.pluginManager == nil {
|
||||
writeError(w, "plugin system not initialized", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.pluginManager.Enable(context.Background(), name, s.agentRegistry); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
s.refreshToolsJSON()
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"status": "enabled",
|
||||
"plugin": name,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handlePluginDisable(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimPrefix(r.URL.Path, "/api/plugins/")
|
||||
name = strings.TrimSuffix(name, "/disable")
|
||||
|
||||
if s.pluginManager == nil {
|
||||
writeError(w, "plugin system not initialized", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
s.pluginManager.Disable(name)
|
||||
s.refreshToolsJSON()
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"status": "disabled",
|
||||
"plugin": name,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleLessons(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
idx := lessons.GetIndex()
|
||||
all := idx.All()
|
||||
|
||||
type lessonInfo struct {
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"`
|
||||
Mode string `json:"mode"`
|
||||
Keywords []string `json:"keywords"`
|
||||
Tools []string `json:"tools"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
result := make([]lessonInfo, 0, len(all))
|
||||
for _, l := range all {
|
||||
result = append(result, lessonInfo{
|
||||
Name: l.Name,
|
||||
Title: l.Title,
|
||||
Description: l.Description,
|
||||
Category: l.Category,
|
||||
Mode: string(l.Mode),
|
||||
Keywords: l.Triggers.Keywords,
|
||||
Tools: l.Triggers.Tools,
|
||||
Enabled: l.Enabled,
|
||||
})
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"lessons": result,
|
||||
"count": len(result),
|
||||
})
|
||||
|
||||
case "POST":
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"`
|
||||
Keywords []string `json:"keywords"`
|
||||
Tools []string `json:"tools"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
lesson := &lessons.Lesson{
|
||||
Name: body.Name,
|
||||
Title: body.Title,
|
||||
Description: body.Description,
|
||||
Category: body.Category,
|
||||
Triggers: lessons.Triggers{
|
||||
Keywords: body.Keywords,
|
||||
Tools: body.Tools,
|
||||
},
|
||||
Content: body.Content,
|
||||
Mode: lessons.ModeBoth,
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
home, _ := userHomeDir()
|
||||
if home != "" {
|
||||
dir := home + "/.muyue/lessons"
|
||||
path := dir + "/" + body.Name + ".md"
|
||||
if err := lessons.WriteLesson(path, lesson); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
lessons.GetIndex().Reload()
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"created": true,
|
||||
"lesson": body.Name,
|
||||
})
|
||||
|
||||
default:
|
||||
writeError(w, "GET or POST only", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleLessonsMatch(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.URL.Query().Get("context")
|
||||
toolsUsed := r.URL.Query().Get("tools")
|
||||
|
||||
matchCtx := lessons.MatchContext{
|
||||
Message: ctx,
|
||||
}
|
||||
if toolsUsed != "" {
|
||||
matchCtx.ToolsUsed = strings.Split(toolsUsed, ",")
|
||||
}
|
||||
|
||||
idx := lessons.GetIndex()
|
||||
results := lessons.Match(idx.All(), matchCtx)
|
||||
|
||||
type matchInfo struct {
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title"`
|
||||
Category string `json:"category"`
|
||||
Score float64 `json:"score"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
matches := make([]matchInfo, 0, len(results))
|
||||
for _, r := range results {
|
||||
matches = append(matches, matchInfo{
|
||||
Name: r.Lesson.Name,
|
||||
Title: r.Lesson.Title,
|
||||
Category: r.Lesson.Category,
|
||||
Score: r.Score,
|
||||
Content: r.Lesson.Content,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"matches": matches,
|
||||
"count": len(matches),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleMCPDiscover(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
result := mcp.DiscoverSystemServers()
|
||||
writeJSON(w, result)
|
||||
}
|
||||
|
||||
func (s *Server) handleMCPServerStart(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimPrefix(r.URL.Path, "/api/mcp/")
|
||||
name = strings.TrimSuffix(name, "/start")
|
||||
|
||||
status := mcp.CheckServerStatus(name)
|
||||
if !status.Installed {
|
||||
writeError(w, "server not installed: "+name, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"status": "started",
|
||||
"server": name,
|
||||
"running": true,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleMCPServerStop(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimPrefix(r.URL.Path, "/api/mcp/")
|
||||
name = strings.TrimSuffix(name, "/stop")
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"status": "stopped",
|
||||
"server": name,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleMCPServerTools(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimPrefix(r.URL.Path, "/api/mcp/")
|
||||
name = strings.TrimSuffix(name, "/tools")
|
||||
|
||||
caps, err := mcp.DiscoverServerTools(name)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"server": name,
|
||||
"tools": caps.Tools,
|
||||
"count": len(caps.Tools),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleBrowserNavigate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"status": "navigating",
|
||||
"url": body.URL,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleBrowserScreenshot(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"status": "screenshot_taken",
|
||||
"url": body.URL,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleBrowserAction(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Action string `json:"action"`
|
||||
Selector string `json:"selector,omitempty"`
|
||||
Value string `json:"value,omitempty"`
|
||||
Script string `json:"script,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"status": "executed",
|
||||
"action": body.Action,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handlePluginAction(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
if strings.HasSuffix(path, "/enable") {
|
||||
s.handlePluginEnable(w, r)
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(path, "/disable") {
|
||||
s.handlePluginDisable(w, r)
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(path, "/discover") {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
paths := plugins.DefaultPluginPaths()
|
||||
discovered := plugins.DiscoverPlugins(paths)
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"discovered": discovered,
|
||||
"count": len(discovered),
|
||||
})
|
||||
return
|
||||
}
|
||||
writeError(w, "unknown plugin action", http.StatusNotFound)
|
||||
}
|
||||
|
||||
func (s *Server) refreshToolsJSON() {
|
||||
tools := s.agentRegistry.OpenAITools()
|
||||
toolsJSON, _ := json.Marshal(tools)
|
||||
s.agentToolsJSON = json.RawMessage(toolsJSON)
|
||||
}
|
||||
|
||||
func userHomeDir() (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
268
internal/api/handlers_rag.go
Normal file
@@ -0,0 +1,268 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
"github.com/muyue/muyue/internal/rag"
|
||||
)
|
||||
|
||||
func (s *Server) handleRAGIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
s.ensureRAGStore()
|
||||
|
||||
if r.Header.Get("Content-Type") == "application/json" {
|
||||
var req struct {
|
||||
Text string `json:"text"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, "invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Text == "" {
|
||||
jsonError(w, "text is required")
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
req.Name = "document-" + time.Now().Format("20060102-150405")
|
||||
}
|
||||
if req.Type == "" {
|
||||
req.Type = "text"
|
||||
}
|
||||
|
||||
s.indexText(w, req.Text, req.Name, req.Type)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
jsonError(w, "invalid multipart: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
jsonError(w, "file is required")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
jsonError(w, "reading file: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
name := header.Filename
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
docType := "text"
|
||||
switch ext {
|
||||
case ".md", ".markdown":
|
||||
docType = "markdown"
|
||||
case ".go", ".js", ".ts", ".py", ".java", ".rs", ".jsx", ".tsx":
|
||||
docType = "code"
|
||||
}
|
||||
|
||||
s.indexText(w, string(data), name, docType)
|
||||
}
|
||||
|
||||
func (s *Server) indexText(w http.ResponseWriter, text, name, docType string) {
|
||||
var chunks []rag.Chunk
|
||||
switch docType {
|
||||
case "markdown":
|
||||
chunks = rag.ChunkMarkdown(text, 500)
|
||||
case "code":
|
||||
lang := strings.TrimPrefix(filepath.Ext(name), ".")
|
||||
chunks = rag.ChunkCode(text, lang, 300)
|
||||
default:
|
||||
chunks = rag.ChunkText(text, 500)
|
||||
}
|
||||
|
||||
if len(chunks) == 0 {
|
||||
jsonError(w, "no content to index")
|
||||
return
|
||||
}
|
||||
|
||||
docID := uuid.New().String()[:8]
|
||||
doc := rag.Document{
|
||||
ID: docID,
|
||||
Name: name,
|
||||
Type: docType,
|
||||
Chunks: len(chunks),
|
||||
IndexedAt: time.Now(),
|
||||
Size: int64(len(text)),
|
||||
}
|
||||
|
||||
var chunkRecords []rag.ChunkRecord
|
||||
var texts []string
|
||||
for _, c := range chunks {
|
||||
texts = append(texts, c.Content)
|
||||
chunkRecords = append(chunkRecords, rag.ChunkRecord{
|
||||
DocumentID: docID,
|
||||
Content: c.Content,
|
||||
StartPos: c.StartPos,
|
||||
EndPos: c.EndPos,
|
||||
Metadata: c.Metadata,
|
||||
})
|
||||
}
|
||||
|
||||
embClient := s.getEmbeddingClient()
|
||||
if embClient != nil {
|
||||
embeddings, err := embClient.Embed(texts, "")
|
||||
if err == nil {
|
||||
for i := range chunkRecords {
|
||||
if i < len(embeddings) {
|
||||
chunkRecords[i].Embedding = embeddings[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.ragStore.StoreDocument(doc, chunkRecords); err != nil {
|
||||
jsonError(w, "storing document: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
jsonResp(w, map[string]interface{}{
|
||||
"id": docID,
|
||||
"name": name,
|
||||
"chunks": len(chunks),
|
||||
"type": docType,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleRAGSearch(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
s.ensureRAGStore()
|
||||
|
||||
var req struct {
|
||||
Query string `json:"query"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, "invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Query == "" {
|
||||
jsonError(w, "query is required")
|
||||
return
|
||||
}
|
||||
if req.Limit <= 0 {
|
||||
req.Limit = 5
|
||||
}
|
||||
|
||||
embClient := s.getEmbeddingClient()
|
||||
var results []rag.SearchResult
|
||||
var err error
|
||||
|
||||
if embClient != nil {
|
||||
queryEmb, embErr := embClient.EmbedSingle(req.Query, "")
|
||||
if embErr == nil {
|
||||
results, err = s.ragStore.Search(queryEmb, req.Limit)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil || len(results) == 0 {
|
||||
results, err = s.ragStore.SearchKeyword(req.Query, req.Limit)
|
||||
if err != nil {
|
||||
jsonError(w, "search error: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
jsonResp(w, map[string]interface{}{
|
||||
"results": results,
|
||||
"query": req.Query,
|
||||
"count": len(results),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleRAGStatus(w http.ResponseWriter, r *http.Request) {
|
||||
s.ensureRAGStore()
|
||||
status, err := s.ragStore.Status()
|
||||
if err != nil {
|
||||
jsonError(w, "status error: "+err.Error())
|
||||
return
|
||||
}
|
||||
jsonResp(w, status)
|
||||
}
|
||||
|
||||
func (s *Server) handleRAGDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
s.ensureRAGStore()
|
||||
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/rag/index/")
|
||||
if id == "" {
|
||||
jsonError(w, "document id is required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.ragStore.DeleteDocument(id); err != nil {
|
||||
jsonError(w, "delete error: "+err.Error())
|
||||
return
|
||||
}
|
||||
jsonResp(w, map[string]interface{}{"deleted": id})
|
||||
}
|
||||
|
||||
func (s *Server) ensureRAGStore() {
|
||||
if s.ragStore != nil {
|
||||
return
|
||||
}
|
||||
configDir, err := config.ConfigDir()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
store, err := rag.NewStore(configDir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "RAG store init error: %v\n", err)
|
||||
return
|
||||
}
|
||||
s.ragStore = store
|
||||
}
|
||||
|
||||
func (s *Server) getEmbeddingClient() *rag.EmbeddingClient {
|
||||
for _, p := range s.config.AI.Providers {
|
||||
if p.Active && p.APIKey != "" {
|
||||
baseURL := p.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.openai.com/v1"
|
||||
}
|
||||
return rag.NewEmbeddingClient(p.APIKey, baseURL)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleRAGDocuments(w http.ResponseWriter, r *http.Request) {
|
||||
s.ensureRAGStore()
|
||||
docs, err := s.ragStore.ListDocuments()
|
||||
if err != nil {
|
||||
jsonError(w, "list error: "+err.Error())
|
||||
return
|
||||
}
|
||||
if docs == nil {
|
||||
docs = []rag.Document{}
|
||||
}
|
||||
jsonResp(w, map[string]interface{}{"documents": docs})
|
||||
}
|
||||
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})
|
||||
}
|
||||
210
internal/api/handlers_skills_advanced.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/muyue/muyue/internal/skills"
|
||||
)
|
||||
|
||||
func (s *Server) handleSkillAutoCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Snippets []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
} `json:"snippets"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var snippets []skills.ConversationSnippet
|
||||
for _, s := range body.Snippets {
|
||||
snippets = append(snippets, skills.ConversationSnippet{
|
||||
Role: s.Role,
|
||||
Content: s.Content,
|
||||
})
|
||||
}
|
||||
|
||||
proposals := skills.AnalyzeConversation(snippets)
|
||||
|
||||
var results []map[string]interface{}
|
||||
for i := range proposals {
|
||||
p := &proposals[i]
|
||||
if err := skills.SaveProposal(p); err != nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, map[string]interface{}{
|
||||
"name": p.Name,
|
||||
"description": p.Description,
|
||||
"confidence": p.Confidence,
|
||||
"category": p.Category,
|
||||
"tags": p.SuggestedTags,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"proposals": results,
|
||||
"count": len(results),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleSkillDetail(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/skills/detail/")
|
||||
|
||||
if strings.HasSuffix(path, "/improve") {
|
||||
name := strings.TrimSuffix(path, "/improve")
|
||||
s.handleSkillImprove(w, r, name)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasSuffix(path, "/history") {
|
||||
name := strings.TrimSuffix(path, "/history")
|
||||
s.handleSkillHistoryGet(w, r, name)
|
||||
return
|
||||
}
|
||||
|
||||
writeError(w, "unknown skill action", http.StatusNotFound)
|
||||
}
|
||||
|
||||
func (s *Server) handleSkillImprove(w http.ResponseWriter, r *http.Request, name string) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
skill, err := skills.Get(name)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Context string `json:"context,omitempty"`
|
||||
Apply bool `json:"apply,omitempty"`
|
||||
}
|
||||
if r.Body != nil {
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
}
|
||||
|
||||
improver, err := skills.NewSkillImprover()
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
suggestions, err := improver.Analyze(skill, body.Context)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if body.Apply && len(suggestions) > 0 {
|
||||
if err := improver.ApplyImprovement(name, suggestions[0]); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
updated, _ := skills.Get(name)
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"applied": true,
|
||||
"suggestion": suggestions[0],
|
||||
"updated": updated,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"skill": skill.Name,
|
||||
"suggestions": suggestions,
|
||||
"count": len(suggestions),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleSkillHistoryGet(w http.ResponseWriter, r *http.Request, name string) {
|
||||
if r.Method != "GET" {
|
||||
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
improver, err := skills.NewSkillImprover()
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
history, err := improver.GetHistory(name)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"skill": name,
|
||||
"history": history,
|
||||
"count": len(history),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleSkillProposals(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
proposals, err := skills.LoadProposals()
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"proposals": proposals,
|
||||
"count": len(proposals),
|
||||
})
|
||||
|
||||
case "POST":
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
proposals, err := skills.LoadProposals()
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var target *skills.AutoCreateProposal
|
||||
for i := range proposals {
|
||||
if proposals[i].Name == body.Name {
|
||||
target = &proposals[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
writeError(w, "proposal not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
skill, err := skills.CreateFromProposal(target)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
skills.DeleteProposal(body.Name)
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"created": true,
|
||||
"skill": skill,
|
||||
})
|
||||
|
||||
default:
|
||||
writeError(w, "GET or POST only", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@ 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"
|
||||
)
|
||||
@@ -49,7 +51,30 @@ func (s *Server) handleInstall(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, "no tools specified", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]string{"status": "installing"})
|
||||
|
||||
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) {
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/muyue/muyue/internal/agent"
|
||||
)
|
||||
|
||||
type toolCallRequest struct {
|
||||
Tool string `json:"tool"`
|
||||
Task string `json:"task"`
|
||||
type ToolCallRequest struct {
|
||||
Tool string `json:"tool"`
|
||||
Args json.RawMessage `json:"args"`
|
||||
}
|
||||
|
||||
type toolResult struct {
|
||||
Success bool `json:"success"`
|
||||
Output string `json:"output"`
|
||||
Error string `json:"error,omitempty"`
|
||||
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) {
|
||||
@@ -24,57 +32,54 @@ func (s *Server) handleToolCall(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var req toolCallRequest
|
||||
var req ToolCallRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Tool != "crush" {
|
||||
writeError(w, "unsupported tool: "+req.Tool, http.StatusBadRequest)
|
||||
if req.Tool == "" {
|
||||
writeError(w, "tool is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Task == "" {
|
||||
writeError(w, "task is required", http.StatusBadRequest)
|
||||
return
|
||||
ctx := context.Background()
|
||||
call := agent.ToolCall{
|
||||
ID: generateMsgID(),
|
||||
Name: req.Tool,
|
||||
Arguments: req.Args,
|
||||
}
|
||||
|
||||
result := executeTool(req.Tool, req.Task)
|
||||
writeJSON(w, result)
|
||||
}
|
||||
|
||||
func executeTool(tool, task string) toolResult {
|
||||
var cmd *exec.Cmd
|
||||
|
||||
switch tool {
|
||||
case "crush":
|
||||
cmd = exec.Command("crush", "run", task)
|
||||
default:
|
||||
return toolResult{Success: false, Error: "unknown tool: " + tool}
|
||||
}
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return toolResult{
|
||||
result, execErr := s.agentRegistry.Execute(ctx, call)
|
||||
if execErr != nil {
|
||||
writeJSON(w, ToolResult{
|
||||
Success: false,
|
||||
Output: string(output),
|
||||
Error: err.Error(),
|
||||
}
|
||||
Tool: req.Tool,
|
||||
Error: execErr.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
return toolResult{
|
||||
writeJSON(w, ToolResult{
|
||||
Success: true,
|
||||
Output: string(output),
|
||||
}
|
||||
Tool: req.Tool,
|
||||
Result: &toolResponseData{
|
||||
Content: result.Content,
|
||||
IsError: result.IsError,
|
||||
Meta: result.Meta,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func buildToolMessage(tool, task string, history []string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("TASK: " + task + "\n\n")
|
||||
b.WriteString("CONVERSATION HISTORY:\n")
|
||||
for _, msg := range history {
|
||||
b.WriteString(strings.Repeat(" ", 4) + strings.Join(strings.Split(msg, "\n"), "\n"+strings.Repeat(" ", 4)) + "\n")
|
||||
func (s *Server) handleToolList(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
return b.String()
|
||||
|
||||
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)
|
||||
}
|
||||
106
internal/api/metrics_unix.go
Normal file
@@ -0,0 +1,106 @@
|
||||
//go:build !windows
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// collectSystemMetrics reads /proc on Linux. On macOS / BSD this returns
|
||||
// zeroes for files that don't exist — the dashboard panel renders blanks
|
||||
// rather than crashing. macOS-specific metrics could be added later via
|
||||
// `vm_stat` / `iostat` parsing.
|
||||
func collectSystemMetrics() sysMetrics {
|
||||
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
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
129
internal/api/metrics_windows.go
Normal file
@@ -0,0 +1,129 @@
|
||||
//go:build windows
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// collectSystemMetrics reads CPU% and memory from kernel32 directly.
|
||||
// Network throughput on Windows is left at zero for now — the iphlpapi
|
||||
// MIB_IF_ROW2 layout is large and version-sensitive; reliable net stats
|
||||
// would warrant a separate, well-tested implementation. CPU + RAM are
|
||||
// enough for the dashboard's main signal.
|
||||
func collectSystemMetrics() sysMetrics {
|
||||
m := sysMetrics{}
|
||||
|
||||
if cpu, ok := readWindowsCPUPercent(); ok {
|
||||
m.CPUPercent = cpu
|
||||
}
|
||||
if memTotalMB, memUsedMB, memPct, ok := readWindowsMemory(); ok {
|
||||
m.MemTotalMB = memTotalMB
|
||||
m.MemUsedMB = memUsedMB
|
||||
m.MemPercent = memPct
|
||||
}
|
||||
// Net: zero (TODO).
|
||||
return m
|
||||
}
|
||||
|
||||
// --- CPU ---------------------------------------------------------------
|
||||
|
||||
var (
|
||||
cpuOnce sync.Once
|
||||
getSystemTimes *syscall.LazyProc
|
||||
lastWinCPUIdle uint64
|
||||
lastWinCPUTotal uint64
|
||||
lastWinCPUSet bool
|
||||
winCPUMu sync.Mutex
|
||||
)
|
||||
|
||||
func loadCPUFns() {
|
||||
cpuOnce.Do(func() {
|
||||
k := syscall.NewLazyDLL("kernel32.dll")
|
||||
getSystemTimes = k.NewProc("GetSystemTimes")
|
||||
})
|
||||
}
|
||||
|
||||
func filetimeToUint64(low, high uint32) uint64 {
|
||||
return uint64(high)<<32 | uint64(low)
|
||||
}
|
||||
|
||||
// readWindowsCPUPercent samples GetSystemTimes twice and computes the busy
|
||||
// ratio as 1 - dIdle / (dKernel + dUser). The first call returns 0% and
|
||||
// stores the baseline; subsequent calls return the delta-based percentage.
|
||||
func readWindowsCPUPercent() (float64, bool) {
|
||||
loadCPUFns()
|
||||
if getSystemTimes == nil {
|
||||
return 0, false
|
||||
}
|
||||
var idle, kernel, user windows.Filetime
|
||||
r1, _, _ := getSystemTimes.Call(
|
||||
uintptr(unsafe.Pointer(&idle)),
|
||||
uintptr(unsafe.Pointer(&kernel)),
|
||||
uintptr(unsafe.Pointer(&user)),
|
||||
)
|
||||
if r1 == 0 {
|
||||
return 0, false
|
||||
}
|
||||
idleT := filetimeToUint64(idle.LowDateTime, idle.HighDateTime)
|
||||
totalT := filetimeToUint64(kernel.LowDateTime, kernel.HighDateTime) +
|
||||
filetimeToUint64(user.LowDateTime, user.HighDateTime)
|
||||
winCPUMu.Lock()
|
||||
defer winCPUMu.Unlock()
|
||||
if !lastWinCPUSet {
|
||||
lastWinCPUIdle = idleT
|
||||
lastWinCPUTotal = totalT
|
||||
lastWinCPUSet = true
|
||||
return 0, true
|
||||
}
|
||||
dIdle := idleT - lastWinCPUIdle
|
||||
dTotal := totalT - lastWinCPUTotal
|
||||
lastWinCPUIdle = idleT
|
||||
lastWinCPUTotal = totalT
|
||||
if dTotal == 0 {
|
||||
return 0, true
|
||||
}
|
||||
pct := (1 - float64(dIdle)/float64(dTotal)) * 100
|
||||
if pct < 0 {
|
||||
pct = 0
|
||||
} else if pct > 100 {
|
||||
pct = 100
|
||||
}
|
||||
return pct, true
|
||||
}
|
||||
|
||||
// --- Memory ------------------------------------------------------------
|
||||
|
||||
type memoryStatusEx struct {
|
||||
Length uint32
|
||||
MemoryLoad uint32
|
||||
TotalPhys uint64
|
||||
AvailPhys uint64
|
||||
TotalPageFile uint64
|
||||
AvailPageFile uint64
|
||||
TotalVirtual uint64
|
||||
AvailVirtual uint64
|
||||
AvailExtendedVirtual uint64
|
||||
}
|
||||
|
||||
var globalMemoryStatusEx = syscall.NewLazyDLL("kernel32.dll").NewProc("GlobalMemoryStatusEx")
|
||||
|
||||
func readWindowsMemory() (totalMB, usedMB, percent float64, ok bool) {
|
||||
var ms memoryStatusEx
|
||||
ms.Length = uint32(unsafe.Sizeof(ms))
|
||||
r1, _, _ := globalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&ms)))
|
||||
if r1 == 0 {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
const mb = 1024 * 1024
|
||||
totalMB = float64(ms.TotalPhys) / mb
|
||||
usedMB = float64(ms.TotalPhys-ms.AvailPhys) / mb
|
||||
if ms.TotalPhys > 0 {
|
||||
percent = float64(ms.TotalPhys-ms.AvailPhys) * 100 / float64(ms.TotalPhys)
|
||||
}
|
||||
return totalMB, usedMB, percent, true
|
||||
}
|
||||
283
internal/api/pipeline.go
Normal file
@@ -0,0 +1,283 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Filter interface {
|
||||
Name() string
|
||||
Process(ctx context.Context, req *FilterRequest) (*FilterResponse, error)
|
||||
}
|
||||
|
||||
type FilterRequest struct {
|
||||
UserMessage string `json:"user_message"`
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type FilterResponse struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
Modified string `json:"modified,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
TokenCount int `json:"token_count,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type Pipeline struct {
|
||||
mu sync.RWMutex
|
||||
filters map[string]Filter
|
||||
enabled map[string]bool
|
||||
stats map[string]*FilterStats
|
||||
}
|
||||
|
||||
type FilterStats struct {
|
||||
Invocations int64 `json:"invocations"`
|
||||
Blocked int64 `json:"blocked"`
|
||||
LastUsed time.Time `json:"last_used"`
|
||||
}
|
||||
|
||||
func NewPipeline() *Pipeline {
|
||||
p := &Pipeline{
|
||||
filters: make(map[string]Filter),
|
||||
enabled: make(map[string]bool),
|
||||
stats: make(map[string]*FilterStats),
|
||||
}
|
||||
|
||||
p.Register(&RateLimitFilter{})
|
||||
p.Register(&TokenCountFilter{})
|
||||
p.Register(&LoggingFilter{})
|
||||
p.Register(&ToxicityFilter{})
|
||||
|
||||
for name := range p.filters {
|
||||
p.enabled[name] = true
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *Pipeline) Register(f Filter) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.filters[f.Name()] = f
|
||||
p.stats[f.Name()] = &FilterStats{}
|
||||
}
|
||||
|
||||
func (p *Pipeline) Run(ctx context.Context, req *FilterRequest) (string, error) {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
for name, filter := range p.filters {
|
||||
if !p.enabled[name] {
|
||||
continue
|
||||
}
|
||||
|
||||
resp, err := filter.Process(ctx, req)
|
||||
if p.stats[name] != nil {
|
||||
p.stats[name].Invocations++
|
||||
p.stats[name].LastUsed = time.Now()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if !resp.Allowed {
|
||||
if p.stats[name] != nil {
|
||||
p.stats[name].Blocked++
|
||||
}
|
||||
return "", fmt.Errorf("blocked by filter %s: %s", name, resp.Reason)
|
||||
}
|
||||
|
||||
if resp.Modified != "" {
|
||||
req.UserMessage = resp.Modified
|
||||
}
|
||||
}
|
||||
|
||||
return req.UserMessage, nil
|
||||
}
|
||||
|
||||
func (p *Pipeline) Toggle(name string, enabled bool) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if _, ok := p.filters[name]; !ok {
|
||||
return fmt.Errorf("filter not found: %s", name)
|
||||
}
|
||||
p.enabled[name] = enabled
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Pipeline) IsEnabled(name string) bool {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.enabled[name]
|
||||
}
|
||||
|
||||
func (p *Pipeline) ListFilters() []map[string]interface{} {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
var result []map[string]interface{}
|
||||
for name, filter := range p.filters {
|
||||
entry := map[string]interface{}{
|
||||
"name": name,
|
||||
"enabled": p.enabled[name],
|
||||
}
|
||||
if stats, ok := p.stats[name]; ok {
|
||||
entry["invocations"] = stats.Invocations
|
||||
entry["blocked"] = stats.Blocked
|
||||
entry["last_used"] = stats.LastUsed
|
||||
}
|
||||
_ = filter
|
||||
result = append(result, entry)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ── Built-in Filters ──
|
||||
|
||||
type RateLimitFilter struct {
|
||||
mu sync.Mutex
|
||||
counters map[string][]time.Time
|
||||
}
|
||||
|
||||
func (f *RateLimitFilter) Name() string { return "rate_limit" }
|
||||
|
||||
func (f *RateLimitFilter) Process(ctx context.Context, req *FilterRequest) (*FilterResponse, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
if f.counters == nil {
|
||||
f.counters = make(map[string][]time.Time)
|
||||
}
|
||||
|
||||
key := req.Provider
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-time.Minute)
|
||||
|
||||
var recent []time.Time
|
||||
for _, t := range f.counters[key] {
|
||||
if t.After(cutoff) {
|
||||
recent = append(recent, t)
|
||||
}
|
||||
}
|
||||
recent = append(recent, now)
|
||||
f.counters[key] = recent
|
||||
|
||||
limit := 30
|
||||
if len(recent) > limit {
|
||||
return &FilterResponse{
|
||||
Allowed: false,
|
||||
Reason: fmt.Sprintf("rate limit exceeded: %d requests/minute (limit: %d)", len(recent), limit),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &FilterResponse{Allowed: true}, nil
|
||||
}
|
||||
|
||||
type TokenCountFilter struct{}
|
||||
|
||||
func (f *TokenCountFilter) Name() string { return "token_count" }
|
||||
|
||||
func (f *TokenCountFilter) Process(ctx context.Context, req *FilterRequest) (*FilterResponse, error) {
|
||||
count := len(req.UserMessage) / 4
|
||||
if count > 50000 {
|
||||
return &FilterResponse{
|
||||
Allowed: true,
|
||||
TokenCount: count,
|
||||
Reason: fmt.Sprintf("large message: ~%d tokens", count),
|
||||
}, nil
|
||||
}
|
||||
return &FilterResponse{Allowed: true, TokenCount: count}, nil
|
||||
}
|
||||
|
||||
type LoggingFilter struct{}
|
||||
|
||||
func (f *LoggingFilter) Name() string { return "logging" }
|
||||
|
||||
func (f *LoggingFilter) Process(ctx context.Context, req *FilterRequest) (*FilterResponse, error) {
|
||||
return &FilterResponse{Allowed: true, Metadata: map[string]string{
|
||||
"provider": req.Provider,
|
||||
"model": req.Model,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
type ToxicityFilter struct{}
|
||||
|
||||
func (f *ToxicityFilter) Name() string { return "toxicity" }
|
||||
|
||||
func (f *ToxicityFilter) Process(ctx context.Context, req *FilterRequest) (*FilterResponse, error) {
|
||||
return &FilterResponse{Allowed: true}, nil
|
||||
}
|
||||
|
||||
// ── Pipeline HTTP handlers ──
|
||||
|
||||
func (s *Server) handlePipelineFilters(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
filters := s.pipeline.ListFilters()
|
||||
if filters == nil {
|
||||
filters = []map[string]interface{}{}
|
||||
}
|
||||
jsonResp(w, map[string]interface{}{"filters": filters})
|
||||
return
|
||||
}
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
func (s *Server) handlePipelineToggle(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
name := ""
|
||||
if parts := splitPath(r.URL.Path); len(parts) > 0 {
|
||||
name = parts[len(parts)-1]
|
||||
}
|
||||
if strings.HasSuffix(r.URL.Path, "/toggle") {
|
||||
name = strings.TrimSuffix(name, "/toggle")
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, "invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.pipeline.Toggle(name, req.Enabled); err != nil {
|
||||
jsonError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
jsonResp(w, map[string]interface{}{"name": name, "enabled": req.Enabled})
|
||||
}
|
||||
|
||||
func splitPath(p string) []string {
|
||||
var parts []string
|
||||
for _, s := range strings.Split(p, "/") {
|
||||
if s != "" {
|
||||
parts = append(parts, s)
|
||||
}
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func jsonResp(w http.ResponseWriter, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func jsonError(w http.ResponseWriter, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
@@ -1,27 +1,122 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/muyue/muyue/internal/agent"
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
"github.com/muyue/muyue/internal/installer"
|
||||
"github.com/muyue/muyue/internal/lessons"
|
||||
"github.com/muyue/muyue/internal/memory"
|
||||
"github.com/muyue/muyue/internal/mcpserver"
|
||||
"github.com/muyue/muyue/internal/plugins"
|
||||
"github.com/muyue/muyue/internal/rag"
|
||||
"github.com/muyue/muyue/internal/scanner"
|
||||
"github.com/muyue/muyue/internal/workflow"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
config *config.MuyueConfig
|
||||
scanResult *scanner.ScanResult
|
||||
mux *http.ServeMux
|
||||
convStore *ConversationStore
|
||||
config *config.MuyueConfig
|
||||
scanResult *scanner.ScanResult
|
||||
mux *http.ServeMux
|
||||
convStore *ConversationStore
|
||||
shellConvStore *ShellConvStore
|
||||
consumption *consumptionStore
|
||||
agentRegistry *agent.Registry
|
||||
agentToolsJSON json.RawMessage
|
||||
shellAgentRegistry *agent.Registry
|
||||
shellAgentToolsJSON json.RawMessage
|
||||
workflowEngine *workflow.Engine
|
||||
pluginManager *plugins.Manager
|
||||
hookRegistry *plugins.HookRegistry
|
||||
browserTestStore *BrowserTestStore
|
||||
memoryStore *memory.Store
|
||||
ragStore *rag.Store
|
||||
pipeline *Pipeline
|
||||
activeCrushAgents atomic.Int32
|
||||
activeClaudeAgents atomic.Int32
|
||||
mcpServer *mcpserver.MCPServer
|
||||
agentTracker *AgentSessionTracker
|
||||
}
|
||||
|
||||
func NewServer(cfg *config.MuyueConfig) *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.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)
|
||||
|
||||
if cfg.Lessons.Enabled {
|
||||
lessons.EnsureBuiltinLessons()
|
||||
}
|
||||
|
||||
s.hookRegistry = plugins.NewHookRegistry()
|
||||
s.pluginManager = plugins.NewManager(s.hookRegistry)
|
||||
|
||||
pluginPaths := cfg.Plugins.Paths
|
||||
if len(pluginPaths) == 0 {
|
||||
pluginPaths = plugins.DefaultPluginPaths()
|
||||
}
|
||||
discovered := plugins.DiscoverPlugins(pluginPaths)
|
||||
for _, dp := range discovered {
|
||||
if dp.Valid {
|
||||
p, err := plugins.LoadExecutablePlugin(dp)
|
||||
if err == nil {
|
||||
s.pluginManager.Register(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.pluginManager.EnableFromConfig(context.Background(), cfg.Plugins.Enabled, s.agentRegistry)
|
||||
|
||||
s.pipeline = NewPipeline()
|
||||
|
||||
s.agentTracker = NewAgentSessionTracker()
|
||||
|
||||
s.initStarship()
|
||||
s.routes()
|
||||
return s
|
||||
}
|
||||
@@ -38,6 +133,7 @@ func (s *Server) routes() {
|
||||
s.mux.HandleFunc("/api/updates", s.handleUpdates)
|
||||
s.mux.HandleFunc("/api/install", s.handleInstall)
|
||||
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/terminal", s.handleTerminal)
|
||||
s.mux.HandleFunc("/api/ws/terminal", s.handleTerminalWS)
|
||||
@@ -48,21 +144,108 @@ func (s *Server) routes() {
|
||||
s.mux.HandleFunc("/api/mcp/configure", s.handleMCPConfigure)
|
||||
s.mux.HandleFunc("/api/config/profile", s.handleSaveProfile)
|
||||
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/images/generate", s.handleImageGenerate)
|
||||
s.mux.HandleFunc("/api/images/", s.handleServeImage)
|
||||
s.mux.HandleFunc("/api/chat", s.handleChat)
|
||||
s.mux.HandleFunc("/api/chat/history", s.handleChatHistory)
|
||||
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)
|
||||
|
||||
s.mux.HandleFunc("/api/skills/auto-create", s.handleSkillAutoCreate)
|
||||
s.mux.HandleFunc("/api/skills/proposals", s.handleSkillProposals)
|
||||
s.mux.HandleFunc("/api/skills/detail/", s.handleSkillDetail)
|
||||
s.mux.HandleFunc("/api/plugins", s.handlePlugins)
|
||||
s.mux.HandleFunc("/api/plugins/", s.handlePluginAction)
|
||||
s.mux.HandleFunc("/api/lessons", s.handleLessons)
|
||||
s.mux.HandleFunc("/api/lessons/match", s.handleLessonsMatch)
|
||||
s.mux.HandleFunc("/api/mcp/discover", s.handleMCPDiscover)
|
||||
s.mux.HandleFunc("/api/browser/navigate", s.handleBrowserNavigate)
|
||||
s.mux.HandleFunc("/api/browser/screenshot", s.handleBrowserScreenshot)
|
||||
s.mux.HandleFunc("/api/browser/action", s.handleBrowserAction)
|
||||
|
||||
s.mux.HandleFunc("/api/rag/index", s.handleRAGIndex)
|
||||
s.mux.HandleFunc("/api/rag/search", s.handleRAGSearch)
|
||||
s.mux.HandleFunc("/api/rag/status", s.handleRAGStatus)
|
||||
s.mux.HandleFunc("/api/rag/documents", s.handleRAGDocuments)
|
||||
s.mux.HandleFunc("/api/rag/index/", s.handleRAGDelete)
|
||||
s.mux.HandleFunc("/api/pipeline/filters", s.handlePipelineFilters)
|
||||
s.mux.HandleFunc("/api/pipeline/filters/", s.handlePipelineToggle)
|
||||
s.mux.HandleFunc("/api/memory", s.handleMemoryList)
|
||||
s.mux.HandleFunc("/api/memory/create", s.handleMemoryCreate)
|
||||
s.mux.HandleFunc("/api/memory/", s.handleMemoryOperation)
|
||||
s.mux.HandleFunc("/api/memory/search", s.handleMemorySearch)
|
||||
s.mux.HandleFunc("/api/memory/recall", s.handleMemoryRecall)
|
||||
s.mux.HandleFunc("/api/memory/context", s.handleMemoryContext)
|
||||
s.mux.HandleFunc("/api/files/content", s.handleFileContent)
|
||||
s.mux.HandleFunc("/api/mcp-server/status", s.handleMuyueMCPServerStatus)
|
||||
s.mux.HandleFunc("/api/mcp-server/start", s.handleMuyueMCPServerStart)
|
||||
s.mux.HandleFunc("/api/mcp-server/stop", s.handleMuyueMCPServerStop)
|
||||
s.mux.HandleFunc("/api/agent-sessions", s.handleAgentSessionsList)
|
||||
s.mux.HandleFunc("/api/agent-sessions/", s.handleAgentSessionOutput)
|
||||
s.mux.HandleFunc("/api/workspaces", s.handleWorkspaceList)
|
||||
s.mux.HandleFunc("/api/workspace", s.handleWorkspaceSave)
|
||||
s.mux.HandleFunc("/api/workspace/", s.handleWorkspaceGet)
|
||||
}
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")
|
||||
if origin := r.Header.Get("Origin"); isAllowedOrigin(origin) {
|
||||
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")
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -70,3 +253,66 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
func (s *Server) buildMemoryContext(query string) string {
|
||||
store, err := s.ensureMemoryStore()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
injector := memory.NewInjector(store)
|
||||
ctx, err := injector.BuildContextBlock(query)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
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,17 +3,16 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/creack/pty/v2"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
)
|
||||
@@ -48,7 +47,6 @@ type wsMessage struct {
|
||||
func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("ws upgrade: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
@@ -56,26 +54,23 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||
var initMsg wsMessage
|
||||
_, raw, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
log.Printf("terminal: read init message failed: %v", err)
|
||||
conn.WriteJSON(wsMessage{Type: "error", Data: "failed to read init message"})
|
||||
return
|
||||
}
|
||||
log.Printf("terminal: init message received: %s", string(raw))
|
||||
if err := json.Unmarshal(raw, &initMsg); err != nil {
|
||||
log.Printf("terminal: unmarshal init message failed: %v", err)
|
||||
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid init message"})
|
||||
return
|
||||
}
|
||||
log.Printf("terminal: init type=%q data=%q", initMsg.Type, initMsg.Data)
|
||||
|
||||
var cmd *exec.Cmd
|
||||
|
||||
if initMsg.Type == "ssh" && initMsg.Data != "" {
|
||||
var sshConf struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
KeyPath string `json:"key_path"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
KeyPath string `json:"key_path"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(initMsg.Data), &sshConf); err != nil {
|
||||
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid ssh config"})
|
||||
@@ -98,87 +93,95 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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 {
|
||||
shell := strings.TrimSpace(initMsg.Data)
|
||||
log.Printf("terminal: requested shell=%q, trimmed=%q", initMsg.Data, shell)
|
||||
if shell == "" {
|
||||
shell = detectShell()
|
||||
log.Printf("terminal: auto-detected shell=%q", shell)
|
||||
}
|
||||
|
||||
if shell == "" {
|
||||
log.Printf("terminal: no shell detected, falling back to /bin/sh")
|
||||
shell = "/bin/sh"
|
||||
}
|
||||
|
||||
if path, err := exec.LookPath(shell); err == nil {
|
||||
shell = path
|
||||
log.Printf("terminal: resolved shell path=%q", shell)
|
||||
}
|
||||
// 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 {
|
||||
if path, err := exec.LookPath(shell); err == nil {
|
||||
shell = path
|
||||
}
|
||||
|
||||
if _, err := os.Stat(shell); err != nil {
|
||||
log.Printf("terminal: shell stat failed: %v for %q", err, shell)
|
||||
conn.WriteJSON(wsMessage{Type: "error", Data: fmt.Sprintf("shell not found: %s (resolved from: %q)", shell, initMsg.Data)})
|
||||
return
|
||||
}
|
||||
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)
|
||||
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")
|
||||
|
||||
log.Printf("terminal: starting pty with cmd=%q args=%v", cmd.Path, cmd.Args)
|
||||
ptmx, err := pty.Start(cmd)
|
||||
session, err := startTermSession(cmd)
|
||||
if err != nil {
|
||||
log.Printf("terminal: pty start failed: %v", err)
|
||||
conn.WriteJSON(wsMessage{Type: "error", Data: err.Error()})
|
||||
return
|
||||
}
|
||||
log.Printf("terminal: pty started successfully")
|
||||
defer func() {
|
||||
ptmx.Close()
|
||||
if cmd.Process != nil {
|
||||
cmd.Process.Kill()
|
||||
cmd.Wait()
|
||||
}
|
||||
}()
|
||||
|
||||
var once sync.Once
|
||||
cleanup := func() {
|
||||
once.Do(func() {
|
||||
ptmx.Close()
|
||||
if cmd.Process != nil {
|
||||
cmd.Process.Kill()
|
||||
cmd.Wait()
|
||||
}
|
||||
session.Close()
|
||||
session.Wait()
|
||||
})
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := ptmx.Read(buf)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
conn.WriteMessage(websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
||||
return
|
||||
n, err := session.Read(buf)
|
||||
if n > 0 {
|
||||
if err := conn.WriteJSON(wsMessage{
|
||||
Type: "output",
|
||||
Data: string(buf[:n]),
|
||||
}); err != nil {
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := conn.WriteJSON(wsMessage{
|
||||
Type: "output",
|
||||
Data: string(buf[:n]),
|
||||
}); err != nil {
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
@@ -202,16 +205,13 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
switch msg.Type {
|
||||
case "input":
|
||||
if _, err := ptmx.Write([]byte(msg.Data)); err != nil {
|
||||
if _, err := session.Write([]byte(msg.Data)); err != nil {
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
case "resize":
|
||||
if msg.Rows > 0 && msg.Cols > 0 {
|
||||
pty.Setsize(ptmx, &pty.Winsize{
|
||||
Rows: msg.Rows,
|
||||
Cols: msg.Cols,
|
||||
})
|
||||
session.Resize(msg.Rows, msg.Cols)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,8 +219,15 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request) {
|
||||
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{}{
|
||||
"ssh": s.config.Terminal.SSH,
|
||||
"ssh": masked,
|
||||
"system": detectSystemTerminals(),
|
||||
})
|
||||
return
|
||||
@@ -234,8 +241,8 @@ func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request)
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
KeyPath string `json:"key_path"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
@@ -249,12 +256,36 @@ func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request)
|
||||
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{
|
||||
Name: body.Name,
|
||||
Host: body.Host,
|
||||
Port: body.Port,
|
||||
User: body.User,
|
||||
KeyPath: body.KeyPath,
|
||||
Name: body.Name,
|
||||
Host: body.Host,
|
||||
Port: body.Port,
|
||||
User: body.User,
|
||||
KeyPath: body.KeyPath,
|
||||
Password: body.Password,
|
||||
}
|
||||
if s.config.Terminal.SSH == nil {
|
||||
s.config.Terminal.SSH = []config.SSHConnection{}
|
||||
@@ -306,6 +337,87 @@ func detectShell() string {
|
||||
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 {
|
||||
var terminals []map[string]string
|
||||
|
||||
@@ -318,10 +430,17 @@ func detectSystemTerminals() []map[string]string {
|
||||
if runtime.GOOS == "windows" {
|
||||
if _, err := exec.LookPath("wsl"); err == nil {
|
||||
terminals = append(terminals, map[string]string{
|
||||
"type": "local",
|
||||
"name": "WSL",
|
||||
"type": "local",
|
||||
"name": "WSL (default)",
|
||||
"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 {
|
||||
terminals = append(terminals, map[string]string{
|
||||
|
||||
271
internal/api/terminal_conpty_windows.go
Normal file
@@ -0,0 +1,271 @@
|
||||
//go:build windows
|
||||
|
||||
package api
|
||||
|
||||
// Windows ConPTY (Pseudo Console) backend for the terminal tab.
|
||||
//
|
||||
// creack/pty/v2 returns "operating system not supported" on Windows, so the
|
||||
// previous fallback was plain stdin/stdout pipes (terminal_session.go::
|
||||
// pipeSession). Pipes don't carry TTY signals, so cmd.exe / pwsh / wsl
|
||||
// detect "no TTY" and either go silent or wait forever — the user sees a
|
||||
// black screen. This file implements a real pseudo console using the
|
||||
// kernel32 ConPTY API, so the spawned shell behaves as if it were attached
|
||||
// to a real terminal: prompts render, ANSI escapes are honoured, resize
|
||||
// events propagate.
|
||||
//
|
||||
// Requires Windows 10 v1809 (build 17763) or newer. On older hosts
|
||||
// CreatePseudoConsole returns an error and startTermSession_windows falls
|
||||
// back to pipeSession.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const (
|
||||
procThreadAttributePseudoconsole = 0x00020016
|
||||
extendedStartupinfoPresent = 0x00080000
|
||||
createUnicodeEnvironment = 0x00000400
|
||||
)
|
||||
|
||||
// conptySession drives a Windows pseudo console.
|
||||
type conptySession struct {
|
||||
hPC windows.Handle
|
||||
inWrite windows.Handle
|
||||
outRead windows.Handle
|
||||
procInfo windows.ProcessInformation
|
||||
closed bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// startConptySession spins up the pseudo console, plumbs the pipes, and
|
||||
// CreateProcessW's the child with the PC attached via STARTUPINFOEX.
|
||||
func startConptySession(cmd *exec.Cmd) (termSession, error) {
|
||||
// 1. Two pipe pairs: in (we write → child stdin) and out (child stdout → we read).
|
||||
var inRead, inWrite, outRead, outWrite windows.Handle
|
||||
if err := windows.CreatePipe(&inRead, &inWrite, nil, 0); err != nil {
|
||||
return nil, fmt.Errorf("create stdin pipe: %w", err)
|
||||
}
|
||||
if err := windows.CreatePipe(&outRead, &outWrite, nil, 0); err != nil {
|
||||
windows.CloseHandle(inRead)
|
||||
windows.CloseHandle(inWrite)
|
||||
return nil, fmt.Errorf("create stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
// 2. Create the pseudo console. After this call ConPTY effectively owns
|
||||
// the child-facing pipe ends (inRead, outWrite); we close our copy.
|
||||
var hPC windows.Handle
|
||||
sz := windows.Coord{X: 120, Y: 30}
|
||||
if err := windows.CreatePseudoConsole(sz, inRead, outWrite, 0, &hPC); err != nil {
|
||||
windows.CloseHandle(inRead)
|
||||
windows.CloseHandle(inWrite)
|
||||
windows.CloseHandle(outRead)
|
||||
windows.CloseHandle(outWrite)
|
||||
return nil, fmt.Errorf("CreatePseudoConsole: %w", err)
|
||||
}
|
||||
windows.CloseHandle(inRead)
|
||||
windows.CloseHandle(outWrite)
|
||||
|
||||
// 3. Allocate an attribute list with one slot for the PC attribute.
|
||||
attrList, err := windows.NewProcThreadAttributeList(1)
|
||||
if err != nil {
|
||||
windows.ClosePseudoConsole(hPC)
|
||||
windows.CloseHandle(inWrite)
|
||||
windows.CloseHandle(outRead)
|
||||
return nil, fmt.Errorf("NewProcThreadAttributeList: %w", err)
|
||||
}
|
||||
// PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE is a quirk of the Win32 API: lpValue
|
||||
// is the HPCON *value* (cast to PVOID), not a pointer to the handle. If
|
||||
// we pass &hPC the kernel reads garbage, the PC attribute is silently
|
||||
// ignored, and cmd/pwsh get their own external console window — which is
|
||||
// exactly the regression v0.7.6 introduced. The cbSize stays the size of
|
||||
// the handle (8 bytes on amd64). Reference: Microsoft EchoCon sample.
|
||||
if err := attrList.Update(
|
||||
procThreadAttributePseudoconsole,
|
||||
unsafe.Pointer(uintptr(hPC)),
|
||||
unsafe.Sizeof(hPC),
|
||||
); err != nil {
|
||||
attrList.Delete()
|
||||
windows.ClosePseudoConsole(hPC)
|
||||
windows.CloseHandle(inWrite)
|
||||
windows.CloseHandle(outRead)
|
||||
return nil, fmt.Errorf("attrList.Update: %w", err)
|
||||
}
|
||||
|
||||
// 4. Build command line.
|
||||
cmdLine, err := buildCommandLine(cmd)
|
||||
if err != nil {
|
||||
attrList.Delete()
|
||||
windows.ClosePseudoConsole(hPC)
|
||||
windows.CloseHandle(inWrite)
|
||||
windows.CloseHandle(outRead)
|
||||
return nil, err
|
||||
}
|
||||
cmdLineUTF16, err := windows.UTF16PtrFromString(cmdLine)
|
||||
if err != nil {
|
||||
attrList.Delete()
|
||||
windows.ClosePseudoConsole(hPC)
|
||||
windows.CloseHandle(inWrite)
|
||||
windows.CloseHandle(outRead)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 5. Build the env block (key=value\0...\0\0).
|
||||
var envBlock *uint16
|
||||
if cmd.Env != nil {
|
||||
eb, err := makeEnvBlock(cmd.Env)
|
||||
if err != nil {
|
||||
attrList.Delete()
|
||||
windows.ClosePseudoConsole(hPC)
|
||||
windows.CloseHandle(inWrite)
|
||||
windows.CloseHandle(outRead)
|
||||
return nil, err
|
||||
}
|
||||
envBlock = eb
|
||||
}
|
||||
|
||||
si := windows.StartupInfoEx{}
|
||||
si.StartupInfo.Cb = uint32(unsafe.Sizeof(si))
|
||||
si.ProcThreadAttributeList = attrList.List()
|
||||
|
||||
flags := uint32(extendedStartupinfoPresent)
|
||||
if envBlock != nil {
|
||||
flags |= createUnicodeEnvironment
|
||||
}
|
||||
|
||||
var pi windows.ProcessInformation
|
||||
err = windows.CreateProcess(
|
||||
nil, // application name (null = parse from cmdline)
|
||||
cmdLineUTF16,
|
||||
nil, // process security attrs
|
||||
nil, // thread security attrs
|
||||
false, // inherit handles (ConPTY hands handles via attribute list)
|
||||
flags,
|
||||
envBlock,
|
||||
nil, // working dir
|
||||
&si.StartupInfo,
|
||||
&pi,
|
||||
)
|
||||
attrList.Delete()
|
||||
if err != nil {
|
||||
windows.ClosePseudoConsole(hPC)
|
||||
windows.CloseHandle(inWrite)
|
||||
windows.CloseHandle(outRead)
|
||||
return nil, fmt.Errorf("CreateProcess: %w", err)
|
||||
}
|
||||
|
||||
return &conptySession{
|
||||
hPC: hPC,
|
||||
inWrite: inWrite,
|
||||
outRead: outRead,
|
||||
procInfo: pi,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *conptySession) Read(p []byte) (int, error) {
|
||||
var n uint32
|
||||
err := windows.ReadFile(s.outRead, p, &n, nil)
|
||||
if err != nil {
|
||||
if n > 0 {
|
||||
return int(n), nil
|
||||
}
|
||||
return 0, io.EOF
|
||||
}
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
func (s *conptySession) Write(p []byte) (int, error) {
|
||||
var n uint32
|
||||
err := windows.WriteFile(s.inWrite, p, &n, nil)
|
||||
if err != nil {
|
||||
return int(n), err
|
||||
}
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
func (s *conptySession) Resize(rows, cols uint16) error {
|
||||
return windows.ResizePseudoConsole(s.hPC, windows.Coord{X: int16(cols), Y: int16(rows)})
|
||||
}
|
||||
|
||||
func (s *conptySession) Close() error {
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
s.closed = true
|
||||
s.mu.Unlock()
|
||||
|
||||
// Order matters: close the pseudo console first so the child sees EOF,
|
||||
// then close our pipe ends, then terminate / close handles.
|
||||
windows.ClosePseudoConsole(s.hPC)
|
||||
windows.CloseHandle(s.inWrite)
|
||||
windows.CloseHandle(s.outRead)
|
||||
if s.procInfo.Process != 0 {
|
||||
windows.TerminateProcess(s.procInfo.Process, 0)
|
||||
windows.CloseHandle(s.procInfo.Process)
|
||||
}
|
||||
if s.procInfo.Thread != 0 {
|
||||
windows.CloseHandle(s.procInfo.Thread)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *conptySession) Wait() error {
|
||||
if s.procInfo.Process == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := windows.WaitForSingleObject(s.procInfo.Process, windows.INFINITE)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *conptySession) Pid() int {
|
||||
return int(s.procInfo.ProcessId)
|
||||
}
|
||||
|
||||
// --- helpers -----------------------------------------------------------
|
||||
|
||||
// buildCommandLine produces the Windows command-line string for an
|
||||
// *exec.Cmd, mirroring what os/exec uses internally (escaping spaces and
|
||||
// quotes per Windows convention).
|
||||
func buildCommandLine(cmd *exec.Cmd) (string, error) {
|
||||
if cmd.Path == "" {
|
||||
return "", fmt.Errorf("empty cmd.Path")
|
||||
}
|
||||
parts := []string{cmd.Path}
|
||||
if len(cmd.Args) > 1 {
|
||||
parts = append(parts, cmd.Args[1:]...)
|
||||
}
|
||||
out := syscall.EscapeArg(parts[0])
|
||||
for _, a := range parts[1:] {
|
||||
out += " " + syscall.EscapeArg(a)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// makeEnvBlock packs a Go environ slice into the Windows UTF-16 env block
|
||||
// format: key=value\0key=value\0\0.
|
||||
func makeEnvBlock(env []string) (*uint16, error) {
|
||||
var buf []uint16
|
||||
for _, kv := range env {
|
||||
s, err := syscall.UTF16FromString(kv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buf = append(buf, s...) // includes trailing NUL
|
||||
}
|
||||
buf = append(buf, 0) // final terminator
|
||||
if len(buf) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &buf[0], nil
|
||||
}
|
||||
|
||||
// Compile-time interface assertion.
|
||||
var _ termSession = (*conptySession)(nil)
|
||||
188
internal/api/terminal_session.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package api
|
||||
|
||||
// Cross-platform terminal session abstraction.
|
||||
//
|
||||
// On Linux / macOS the unix-tagged file (terminal_session_unix.go) wires
|
||||
// startTermSession to creack/pty for a real PTY: full TTY semantics,
|
||||
// resize support, interactive apps (vim, top…) work.
|
||||
//
|
||||
// On Windows the windows-tagged file (terminal_session_windows.go) tries
|
||||
// the kernel32 ConPTY API first, with a pipe-based fallback for older
|
||||
// hosts. pipeSession does NOT carry TTY signals, so most shells go silent
|
||||
// — it's only kept as a last resort.
|
||||
//
|
||||
// Both platforms share the termSession interface, the ptySession type
|
||||
// (used by unix), and the pipeSession type (used by the Windows fallback).
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"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
|
||||
}
|
||||
|
||||
// ptySession wraps creack/pty's *os.File-backed PTY (unix path).
|
||||
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 last-resort fallback when ConPTY is not
|
||||
// available: stdin pipe + merged stdout/stderr, no TTY signals. Most
|
||||
// interactive shells go silent in this mode, so it should rarely be hit on
|
||||
// modern Windows (10 1809+).
|
||||
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
|
||||
}
|
||||
19
internal/api/terminal_session_unix.go
Normal file
@@ -0,0 +1,19 @@
|
||||
//go:build !windows
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
|
||||
"github.com/creack/pty/v2"
|
||||
)
|
||||
|
||||
// startTermSession (unix) opens a real PTY via creack/pty. Fatal on error
|
||||
// — the unix build assumes PTY availability.
|
||||
func startTermSession(cmd *exec.Cmd) (termSession, error) {
|
||||
ptmx, err := pty.Start(cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ptySession{ptmx: ptmx, cmd: cmd}, nil
|
||||
}
|
||||
20
internal/api/terminal_session_windows.go
Normal file
@@ -0,0 +1,20 @@
|
||||
//go:build windows
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// startTermSession (windows) tries the kernel32 ConPTY API first. ConPTY
|
||||
// gives a real pseudo terminal, so wsl.exe / pwsh / cmd render their
|
||||
// prompt and the user can interact normally. If ConPTY is unavailable
|
||||
// (Windows < 10 1809) or the call fails for any reason, we fall back to
|
||||
// the line-buffered pipe session — degraded but functional for non-TUI
|
||||
// commands.
|
||||
func startTermSession(cmd *exec.Cmd) (termSession, error) {
|
||||
if sess, err := startConptySession(cmd); err == nil {
|
||||
return sess, nil
|
||||
}
|
||||
return startPipeSession(cmd)
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
@@ -12,66 +11,78 @@ import (
|
||||
)
|
||||
|
||||
type Profile struct {
|
||||
Name string `yaml:"name"`
|
||||
Pseudo string `yaml:"pseudo"`
|
||||
Email string `yaml:"email"`
|
||||
Languages []string `yaml:"languages"`
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Pseudo string `yaml:"pseudo" json:"pseudo"`
|
||||
Email string `yaml:"email" json:"email"`
|
||||
Languages []string `yaml:"languages" json:"languages"`
|
||||
Preferences struct {
|
||||
Editor string `yaml:"editor"`
|
||||
Shell string `yaml:"shell"`
|
||||
Theme string `yaml:"theme"`
|
||||
DefaultAI string `yaml:"default_ai"`
|
||||
AutoUpdate bool `yaml:"auto_update"`
|
||||
CheckOnStart bool `yaml:"check_on_start"`
|
||||
Language string `yaml:"language"`
|
||||
KeyboardLayout string `yaml:"keyboard_layout"`
|
||||
} `yaml:"preferences"`
|
||||
Editor string `yaml:"editor" json:"editor"`
|
||||
Shell string `yaml:"shell" json:"shell"`
|
||||
Theme string `yaml:"theme" json:"theme"`
|
||||
DefaultAI string `yaml:"default_ai" json:"default_ai"`
|
||||
AutoUpdate bool `yaml:"auto_update" json:"auto_update"`
|
||||
CheckOnStart bool `yaml:"check_on_start" json:"check_on_start"`
|
||||
Language string `yaml:"language" json:"language"`
|
||||
KeyboardLayout string `yaml:"keyboard_layout" json:"keyboard_layout"`
|
||||
} `yaml:"preferences" json:"preferences"`
|
||||
}
|
||||
|
||||
type AIProvider struct {
|
||||
Name string `yaml:"name"`
|
||||
APIKey string `yaml:"api_key,omitempty"`
|
||||
BaseURL string `yaml:"base_url,omitempty"`
|
||||
Model string `yaml:"model"`
|
||||
Active bool `yaml:"active"`
|
||||
Name string `yaml:"name" json:"name"`
|
||||
APIKey string `yaml:"api_key,omitempty" json:"api_key,omitempty"`
|
||||
BaseURL string `yaml:"base_url,omitempty" json:"base_url,omitempty"`
|
||||
Model string `yaml:"model" json:"model"`
|
||||
Active bool `yaml:"active" json:"active"`
|
||||
}
|
||||
|
||||
type ToolConfig struct {
|
||||
Name string `yaml:"name"`
|
||||
Installed bool `yaml:"installed"`
|
||||
Version string `yaml:"version"`
|
||||
AutoUpdate bool `yaml:"auto_update"`
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Installed bool `yaml:"installed" json:"installed"`
|
||||
Version string `yaml:"version" json:"version"`
|
||||
AutoUpdate bool `yaml:"auto_update" json:"auto_update"`
|
||||
}
|
||||
|
||||
type SSHConnection struct {
|
||||
Name string `yaml:"name"`
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
User string `yaml:"user"`
|
||||
Password string `yaml:"password,omitempty"`
|
||||
KeyPath string `yaml:"key_path,omitempty"`
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Host string `yaml:"host" json:"host"`
|
||||
Port int `yaml:"port" json:"port"`
|
||||
User string `yaml:"user" json:"user"`
|
||||
Password string `yaml:"password,omitempty" json:"password,omitempty"`
|
||||
KeyPath string `yaml:"key_path,omitempty" json:"key_path,omitempty"`
|
||||
}
|
||||
|
||||
type PluginsConfig struct {
|
||||
Enabled []string `yaml:"enabled" json:"enabled"`
|
||||
Paths []string `yaml:"paths,omitempty" json:"paths,omitempty"`
|
||||
}
|
||||
|
||||
type LessonsConfig struct {
|
||||
Dirs []string `yaml:"dirs,omitempty" json:"dirs,omitempty"`
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
}
|
||||
|
||||
type MuyueConfig struct {
|
||||
Version string `yaml:"version"`
|
||||
Profile Profile `yaml:"profile"`
|
||||
Version string `yaml:"version" json:"version"`
|
||||
Profile Profile `yaml:"profile" json:"profile"`
|
||||
AI struct {
|
||||
Providers []AIProvider `yaml:"providers"`
|
||||
} `yaml:"ai"`
|
||||
Tools []ToolConfig `yaml:"tools"`
|
||||
Providers []AIProvider `yaml:"providers" json:"providers"`
|
||||
} `yaml:"ai" json:"ai"`
|
||||
Tools []ToolConfig `yaml:"tools" json:"tools"`
|
||||
BMAD struct {
|
||||
Installed bool `yaml:"installed"`
|
||||
Version string `yaml:"version"`
|
||||
Global bool `yaml:"global"`
|
||||
} `yaml:"bmad"`
|
||||
Installed bool `yaml:"installed" json:"installed"`
|
||||
Version string `yaml:"version" json:"version"`
|
||||
Global bool `yaml:"global" json:"global"`
|
||||
} `yaml:"bmad" json:"bmad"`
|
||||
Terminal struct {
|
||||
CustomPrompt bool `yaml:"custom_prompt"`
|
||||
PromptTheme string `yaml:"prompt_theme"`
|
||||
SSH []SSHConnection `yaml:"ssh"`
|
||||
FontSize int `yaml:"font_size"`
|
||||
FontFamily string `yaml:"font_family"`
|
||||
Theme string `yaml:"theme"`
|
||||
} `yaml:"terminal"`
|
||||
CustomPrompt bool `yaml:"custom_prompt" json:"custom_prompt"`
|
||||
PromptTheme string `yaml:"prompt_theme" json:"prompt_theme"`
|
||||
SSH []SSHConnection `yaml:"ssh" json:"ssh"`
|
||||
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"`
|
||||
Plugins PluginsConfig `yaml:"plugins" json:"plugins"`
|
||||
Lessons LessonsConfig `yaml:"lessons" json:"lessons"`
|
||||
}
|
||||
|
||||
type TerminalTheme struct {
|
||||
@@ -128,6 +139,22 @@ var DEFAULT_TERMINAL_THEMES = map[string]TerminalTheme{
|
||||
},
|
||||
}
|
||||
|
||||
func migrateProviders(cfg *MuyueConfig) {
|
||||
defaults := Default().AI.Providers
|
||||
for _, dp := range defaults {
|
||||
found := false
|
||||
for _, p := range cfg.AI.Providers {
|
||||
if p.Name == dp.Name {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
cfg.AI.Providers = append(cfg.AI.Providers, dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func GetTerminalTheme(name string) TerminalTheme {
|
||||
if theme, ok := DEFAULT_TERMINAL_THEMES[name]; ok {
|
||||
return theme
|
||||
@@ -146,7 +173,7 @@ func ConfigDir() (string, error) {
|
||||
if _, err := os.Stat(legacyDir); err == nil {
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
if err := os.Rename(legacyDir, dir); err != nil {
|
||||
log.Printf("config migration: rename %s to %s: %v", legacyDir, dir, err)
|
||||
_ = err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,6 +233,8 @@ func Load() (*MuyueConfig, error) {
|
||||
}
|
||||
}
|
||||
|
||||
migrateProviders(&cfg)
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
@@ -269,6 +298,12 @@ func Default() *MuyueConfig {
|
||||
BaseURL: "https://api.minimax.io/v1",
|
||||
Active: true,
|
||||
},
|
||||
{
|
||||
Name: "mimo",
|
||||
Model: "mimo-v2.5-pro",
|
||||
BaseURL: "https://token-plan-ams.xiaomimimo.com/v1",
|
||||
Active: false,
|
||||
},
|
||||
{
|
||||
Name: "zai",
|
||||
Model: "glm",
|
||||
@@ -297,6 +332,13 @@ func Default() *MuyueConfig {
|
||||
|
||||
cfg.Terminal.CustomPrompt = true
|
||||
cfg.Terminal.PromptTheme = "zerotwo"
|
||||
cfg.Terminal.FontSize = 14
|
||||
|
||||
cfg.Plugins.Enabled = []string{}
|
||||
cfg.Plugins.Paths = []string{}
|
||||
|
||||
cfg.Lessons.Enabled = true
|
||||
cfg.Lessons.Dirs = []string{}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
513
internal/lessons/lesson.go
Normal file
@@ -0,0 +1,513 @@
|
||||
package lessons
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type LessonMode string
|
||||
|
||||
const (
|
||||
ModeInteractive LessonMode = "interactive"
|
||||
ModeAutonomous LessonMode = "autonomous"
|
||||
ModeBoth LessonMode = "both"
|
||||
)
|
||||
|
||||
type Lesson struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Title string `yaml:"title" json:"title"`
|
||||
Description string `yaml:"description" json:"description"`
|
||||
Category string `yaml:"category" json:"category"`
|
||||
Triggers Triggers `yaml:"triggers" json:"triggers"`
|
||||
Content string `yaml:"content" json:"content"`
|
||||
Mode LessonMode `yaml:"mode" json:"mode"`
|
||||
Priority int `yaml:"priority" json:"priority"`
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
Path string `yaml:"-" json:"path,omitempty"`
|
||||
}
|
||||
|
||||
type Triggers struct {
|
||||
Keywords []string `yaml:"keywords" json:"keywords"`
|
||||
Tools []string `yaml:"tools" json:"tools"`
|
||||
Patterns []string `yaml:"patterns" json:"patterns"`
|
||||
}
|
||||
|
||||
type MatchContext struct {
|
||||
Message string `json:"message"`
|
||||
ToolsUsed []string `json:"tools_used,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
}
|
||||
|
||||
type MatchResult struct {
|
||||
Lesson *Lesson `json:"lesson"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
type LessonFrontmatter struct {
|
||||
Name string `yaml:"name"`
|
||||
Title string `yaml:"title"`
|
||||
Description string `yaml:"description"`
|
||||
Category string `yaml:"category"`
|
||||
Mode LessonMode `yaml:"mode"`
|
||||
Priority int `yaml:"priority"`
|
||||
Enabled *bool `yaml:"enabled"`
|
||||
Triggers Triggers `yaml:"triggers"`
|
||||
}
|
||||
|
||||
type LessonIndex struct {
|
||||
mu sync.RWMutex
|
||||
lessons []*Lesson
|
||||
paths []string
|
||||
cache map[string]time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
globalIndex *LessonIndex
|
||||
globalIndexOnce sync.Once
|
||||
)
|
||||
|
||||
func GetIndex() *LessonIndex {
|
||||
globalIndexOnce.Do(func() {
|
||||
globalIndex = &LessonIndex{
|
||||
lessons: make([]*Lesson, 0),
|
||||
cache: make(map[string]time.Time),
|
||||
}
|
||||
globalIndex.paths = DefaultLessonDirs()
|
||||
globalIndex.Reload()
|
||||
})
|
||||
return globalIndex
|
||||
}
|
||||
|
||||
func DefaultLessonDirs() []string {
|
||||
var dirs []string
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
if home != "" {
|
||||
dirs = append(dirs,
|
||||
filepath.Join(home, ".muyue", "lessons"),
|
||||
)
|
||||
}
|
||||
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err == nil {
|
||||
dirs = append(dirs, filepath.Join(configDir, "muyue", "lessons"))
|
||||
}
|
||||
|
||||
if extra := os.Getenv("MUYUE_LESSONS_EXTRA_DIRS"); extra != "" {
|
||||
for _, d := range strings.Split(extra, ":") {
|
||||
d = strings.TrimSpace(d)
|
||||
if d != "" {
|
||||
dirs = append(dirs, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dirs
|
||||
}
|
||||
|
||||
func (idx *LessonIndex) Reload() {
|
||||
idx.mu.Lock()
|
||||
defer idx.mu.Unlock()
|
||||
|
||||
var all []*Lesson
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, dir := range idx.paths {
|
||||
files, err := filepath.Glob(filepath.Join(dir, "*.md"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, f := range files {
|
||||
realPath, _ := filepath.EvalSymlinks(f)
|
||||
if realPath == "" {
|
||||
realPath = f
|
||||
}
|
||||
if seen[realPath] {
|
||||
continue
|
||||
}
|
||||
seen[realPath] = true
|
||||
|
||||
lesson, err := ParseLessonFile(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
lesson.Path = f
|
||||
if lesson.Category == "" {
|
||||
lesson.Category = filepath.Base(filepath.Dir(f))
|
||||
}
|
||||
all = append(all, lesson)
|
||||
}
|
||||
|
||||
subDirs, _ := filepath.Glob(filepath.Join(dir, "*"))
|
||||
for _, subDir := range subDirs {
|
||||
info, err := os.Stat(subDir)
|
||||
if err != nil || !info.IsDir() {
|
||||
continue
|
||||
}
|
||||
category := filepath.Base(subDir)
|
||||
subFiles, _ := filepath.Glob(filepath.Join(subDir, "*.md"))
|
||||
for _, f := range subFiles {
|
||||
realPath, _ := filepath.EvalSymlinks(f)
|
||||
if realPath == "" {
|
||||
realPath = f
|
||||
}
|
||||
if seen[realPath] {
|
||||
continue
|
||||
}
|
||||
seen[realPath] = true
|
||||
|
||||
lesson, err := ParseLessonFile(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
lesson.Path = f
|
||||
if lesson.Category == "" {
|
||||
lesson.Category = category
|
||||
}
|
||||
all = append(all, lesson)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
idx.lessons = all
|
||||
}
|
||||
|
||||
func (idx *LessonIndex) All() []*Lesson {
|
||||
idx.mu.RLock()
|
||||
defer idx.mu.RUnlock()
|
||||
|
||||
result := make([]*Lesson, 0, len(idx.lessons))
|
||||
for _, l := range idx.lessons {
|
||||
if l.Enabled {
|
||||
result = append(result, l)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (idx *LessonIndex) Get(name string) *Lesson {
|
||||
idx.mu.RLock()
|
||||
defer idx.mu.RUnlock()
|
||||
|
||||
for _, l := range idx.lessons {
|
||||
if l.Name == name {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (idx *LessonIndex) Count() int {
|
||||
idx.mu.RLock()
|
||||
defer idx.mu.RUnlock()
|
||||
return len(idx.lessons)
|
||||
}
|
||||
|
||||
func ParseLessonFile(path string) (*Lesson, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read lesson: %w", err)
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
|
||||
var frontmatter LessonFrontmatter
|
||||
var body string
|
||||
|
||||
if strings.HasPrefix(content, "---") {
|
||||
end := strings.Index(content[3:], "---")
|
||||
if end != -1 {
|
||||
fm := content[3 : end+3]
|
||||
body = strings.TrimSpace(content[end+6:])
|
||||
|
||||
if err := yaml.Unmarshal([]byte(fm), &frontmatter); err != nil {
|
||||
body = content
|
||||
}
|
||||
} else {
|
||||
body = content
|
||||
}
|
||||
} else {
|
||||
body = content
|
||||
}
|
||||
|
||||
enabled := true
|
||||
if frontmatter.Enabled != nil {
|
||||
enabled = *frontmatter.Enabled
|
||||
}
|
||||
|
||||
if frontmatter.Mode == "" {
|
||||
frontmatter.Mode = ModeBoth
|
||||
}
|
||||
|
||||
name := frontmatter.Name
|
||||
if name == "" {
|
||||
name = strings.TrimSuffix(filepath.Base(path), ".md")
|
||||
name = strings.ReplaceAll(name, "-", "_")
|
||||
}
|
||||
|
||||
return &Lesson{
|
||||
Name: name,
|
||||
Title: frontmatter.Title,
|
||||
Description: frontmatter.Description,
|
||||
Category: frontmatter.Category,
|
||||
Triggers: frontmatter.Triggers,
|
||||
Content: body,
|
||||
Mode: frontmatter.Mode,
|
||||
Priority: frontmatter.Priority,
|
||||
Enabled: enabled,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func Match(lessons []*Lesson, ctx MatchContext) []*MatchResult {
|
||||
var results []*MatchResult
|
||||
msgLower := strings.ToLower(ctx.Message)
|
||||
|
||||
for _, l := range lessons {
|
||||
if !l.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
score := 0.0
|
||||
|
||||
for _, kw := range l.Triggers.Keywords {
|
||||
if containsKeyword(msgLower, strings.ToLower(kw)) {
|
||||
score += 1.0
|
||||
}
|
||||
}
|
||||
|
||||
for _, pattern := range l.Triggers.Patterns {
|
||||
re, err := regexp.Compile("(?i)" + pattern)
|
||||
if err == nil && re.MatchString(ctx.Message) {
|
||||
score += 1.5
|
||||
}
|
||||
}
|
||||
|
||||
if len(ctx.ToolsUsed) > 0 && len(l.Triggers.Tools) > 0 {
|
||||
for _, usedTool := range ctx.ToolsUsed {
|
||||
for _, triggerTool := range l.Triggers.Tools {
|
||||
if usedTool == triggerTool {
|
||||
score += 2.0
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if l.Name != "" {
|
||||
nameLower := strings.ToLower(l.Name)
|
||||
if strings.Contains(msgLower, nameLower) {
|
||||
score += 1.5
|
||||
}
|
||||
}
|
||||
|
||||
if score > 0 {
|
||||
results = append(results, &MatchResult{
|
||||
Lesson: l,
|
||||
Score: score,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
sortResults(results)
|
||||
return results
|
||||
}
|
||||
|
||||
func AutoInclude(systemPrompt string, lessons []*Lesson, ctx MatchContext, maxLessons int) string {
|
||||
if maxLessons <= 0 {
|
||||
maxLessons = 5
|
||||
}
|
||||
|
||||
results := Match(lessons, ctx)
|
||||
if len(results) == 0 {
|
||||
return systemPrompt
|
||||
}
|
||||
|
||||
if len(results) > maxLessons {
|
||||
results = results[:maxLessons]
|
||||
}
|
||||
|
||||
var lessonBlock strings.Builder
|
||||
lessonBlock.WriteString("\n\n--- Active Lessons ---\n\n")
|
||||
|
||||
for _, r := range results {
|
||||
lessonBlock.WriteString(fmt.Sprintf("## %s", r.Lesson.Name))
|
||||
if r.Lesson.Title != "" {
|
||||
lessonBlock.WriteString(fmt.Sprintf(" (%s)", r.Lesson.Title))
|
||||
}
|
||||
lessonBlock.WriteString("\n")
|
||||
lessonBlock.WriteString(r.Lesson.Content)
|
||||
lessonBlock.WriteString("\n\n")
|
||||
}
|
||||
|
||||
return systemPrompt + lessonBlock.String()
|
||||
}
|
||||
|
||||
func EnsureBuiltinLessons() error {
|
||||
home, _ := os.UserHomeDir()
|
||||
if home == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
lessonsDir := filepath.Join(home, ".muyue", "lessons")
|
||||
if err := os.MkdirAll(lessonsDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, lesson := range BuiltinLessons() {
|
||||
path := filepath.Join(lessonsDir, lesson.Name+".md")
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
continue
|
||||
}
|
||||
if err := WriteLesson(path, lesson); err != nil {
|
||||
_ = err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func WriteLesson(path string, lesson *Lesson) error {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("---\n")
|
||||
data, err := yaml.Marshal(&LessonFrontmatter{
|
||||
Name: lesson.Name,
|
||||
Title: lesson.Title,
|
||||
Description: lesson.Description,
|
||||
Category: lesson.Category,
|
||||
Mode: lesson.Mode,
|
||||
Priority: lesson.Priority,
|
||||
Enabled: &lesson.Enabled,
|
||||
Triggers: lesson.Triggers,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sb.WriteString(string(data))
|
||||
sb.WriteString("---\n\n")
|
||||
sb.WriteString(lesson.Content)
|
||||
|
||||
return os.WriteFile(path, []byte(sb.String()), 0644)
|
||||
}
|
||||
|
||||
func BuiltinLessons() []*Lesson {
|
||||
return []*Lesson{
|
||||
{
|
||||
Name: "code_style",
|
||||
Title: "Code Style Guidelines",
|
||||
Description: "Enforce consistent code style and formatting",
|
||||
Category: "development",
|
||||
Triggers: Triggers{
|
||||
Keywords: []string{"code style", "formatting", "lint", "format", "indentation", "naming convention"},
|
||||
Tools: []string{"terminal"},
|
||||
},
|
||||
Content: `- Follow the existing code style in each file
|
||||
- Use consistent indentation (match surrounding code)
|
||||
- Prefer descriptive variable names over abbreviations
|
||||
- Keep functions focused and small
|
||||
- Add error handling for all external calls`,
|
||||
Mode: ModeBoth,
|
||||
Priority: 5,
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
Name: "git_workflow",
|
||||
Title: "Git Workflow Best Practices",
|
||||
Description: "Guidelines for git operations and commit practices",
|
||||
Category: "development",
|
||||
Triggers: Triggers{
|
||||
Keywords: []string{"git", "commit", "branch", "merge", "pull request", "rebase"},
|
||||
Tools: []string{"terminal"},
|
||||
},
|
||||
Content: `- Write clear, descriptive commit messages
|
||||
- Use conventional commits format when applicable
|
||||
- Keep commits atomic and focused
|
||||
- Don't commit sensitive data or secrets
|
||||
- Test before committing`,
|
||||
Mode: ModeBoth,
|
||||
Priority: 5,
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
Name: "error_handling",
|
||||
Title: "Error Handling Patterns",
|
||||
Description: "Robust error handling guidelines",
|
||||
Category: "development",
|
||||
Triggers: Triggers{
|
||||
Keywords: []string{"error", "panic", "exception", "crash", "fail", "nil pointer"},
|
||||
Tools: []string{"terminal", "read_file"},
|
||||
Patterns: []string{`err\s*!=\s*nil`, `panic\(`, `log\.Fatal`},
|
||||
},
|
||||
Content: `- Always check errors from external calls
|
||||
- Provide context when wrapping errors
|
||||
- Use sentinel errors for expected conditions
|
||||
- Log errors with enough context for debugging
|
||||
- Don't silently ignore errors`,
|
||||
Mode: ModeBoth,
|
||||
Priority: 6,
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
Name: "testing",
|
||||
Title: "Testing Best Practices",
|
||||
Description: "Guidelines for writing effective tests",
|
||||
Category: "development",
|
||||
Triggers: Triggers{
|
||||
Keywords: []string{"test", "testing", "unit test", "integration test", "coverage"},
|
||||
Tools: []string{"terminal"},
|
||||
},
|
||||
Content: `- Write tests for critical paths first
|
||||
- Use table-driven tests for multiple cases
|
||||
- Keep tests independent and deterministic
|
||||
- Test error paths, not just happy paths
|
||||
- Aim for meaningful coverage, not just percentage`,
|
||||
Mode: ModeBoth,
|
||||
Priority: 5,
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
Name: "security",
|
||||
Title: "Security Guidelines",
|
||||
Description: "Security best practices for development",
|
||||
Category: "development",
|
||||
Triggers: Triggers{
|
||||
Keywords: []string{"security", "vulnerability", "inject", "sanitize", "auth", "secret", "password", "token"},
|
||||
Tools: []string{"terminal", "read_file", "web_fetch"},
|
||||
Patterns: []string{`SELECT\s.*\+`, `exec\.Command.*\+`, `os\.Getenv.*KEY`},
|
||||
},
|
||||
Content: `- Never log or expose secrets, API keys, or tokens
|
||||
- Validate and sanitize all user input
|
||||
- Use parameterized queries for database operations
|
||||
- Keep dependencies updated
|
||||
- Don't hardcode credentials`,
|
||||
Mode: ModeBoth,
|
||||
Priority: 8,
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func containsKeyword(text, keyword string) bool {
|
||||
if keyword == "*" {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(text, keyword)
|
||||
}
|
||||
|
||||
func sortResults(results []*MatchResult) {
|
||||
for i := 0; i < len(results)-1; i++ {
|
||||
for j := i + 1; j < len(results); j++ {
|
||||
if results[j].Score > results[i].Score {
|
||||
results[i], results[j] = results[j], results[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LSPServer struct {
|
||||
@@ -12,6 +16,10 @@ type LSPServer struct {
|
||||
Command string `json:"command"`
|
||||
InstallCmd string `json:"install_cmd"`
|
||||
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{
|
||||
@@ -39,27 +47,131 @@ func ScanServers() []LSPServer {
|
||||
servers[i] = s
|
||||
_, err := exec.LookPath(s.Command)
|
||||
servers[i].Installed = err == nil
|
||||
servers[i].Version = getInstalledLSPVersion(s.Name)
|
||||
}
|
||||
|
||||
regServers, err := scanLSPRegistryServers()
|
||||
if err == nil {
|
||||
servers = append(servers, regServers...)
|
||||
}
|
||||
|
||||
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 {
|
||||
for _, s := range knownServers {
|
||||
if s.Name == name {
|
||||
if s.InstallCmd == "" {
|
||||
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
|
||||
return doInstallLSP(s)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
langMap := map[string][]string{
|
||||
"go": {"gopls"},
|
||||
@@ -101,3 +213,100 @@ func InstallForLanguages(languages []string) []LSPServer {
|
||||
|
||||
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")
|
||||
}
|
||||
369
internal/mcp/discover.go
Normal file
@@ -0,0 +1,369 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type DiscoveredMCPServer struct {
|
||||
Name string `json:"name"`
|
||||
Command string `json:"command"`
|
||||
Source string `json:"source"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Installed bool `json:"installed"`
|
||||
Running bool `json:"running"`
|
||||
Category string `json:"category,omitempty"`
|
||||
}
|
||||
|
||||
type DiscoveryResult struct {
|
||||
Servers []DiscoveredMCPServer `json:"servers"`
|
||||
ScanPaths []string `json:"scan_paths"`
|
||||
TotalFound int `json:"total_found"`
|
||||
NewServers int `json:"new_servers"`
|
||||
}
|
||||
|
||||
type ToolDiscovery struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema json.RawMessage `json:"input_schema"`
|
||||
}
|
||||
|
||||
type ServerCapabilities struct {
|
||||
Name string `json:"name"`
|
||||
Tools []ToolDiscovery `json:"tools"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Raw json.RawMessage `json:"raw,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
capCache map[string]*ServerCapabilities
|
||||
capCacheMu sync.RWMutex
|
||||
)
|
||||
|
||||
func init() {
|
||||
capCache = make(map[string]*ServerCapabilities)
|
||||
}
|
||||
|
||||
func DiscoverSystemServers() *DiscoveryResult {
|
||||
result := &DiscoveryResult{}
|
||||
|
||||
knownNames := make(map[string]bool)
|
||||
for _, s := range knownMCPServers {
|
||||
knownNames[s.Name] = true
|
||||
}
|
||||
|
||||
reg, _ := LoadRegistry()
|
||||
if reg != nil {
|
||||
for _, s := range reg.Servers {
|
||||
knownNames[s.Name] = true
|
||||
}
|
||||
}
|
||||
|
||||
var servers []DiscoveredMCPServer
|
||||
|
||||
npmServers := discoverNpmGlobalServers(knownNames)
|
||||
servers = append(servers, npmServers...)
|
||||
|
||||
pipServers := discoverPipServers(knownNames)
|
||||
servers = append(servers, pipServers...)
|
||||
|
||||
pathServers := discoverPathServers(knownNames)
|
||||
servers = append(servers, pathServers...)
|
||||
|
||||
result.Servers = servers
|
||||
result.TotalFound = len(servers)
|
||||
result.NewServers = countNew(servers, knownNames)
|
||||
|
||||
paths := []string{}
|
||||
if path := os.Getenv("PATH"); path != "" {
|
||||
paths = strings.Split(path, ":")
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
paths = append(paths,
|
||||
filepath.Join(home, ".local", "bin"),
|
||||
filepath.Join(home, ".npm-global", "bin"),
|
||||
)
|
||||
}
|
||||
result.ScanPaths = paths
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func discoverNpmGlobalServers(known map[string]bool) []DiscoveredMCPServer {
|
||||
var servers []DiscoveredMCPServer
|
||||
|
||||
npx, err := exec.LookPath("npx")
|
||||
if err != nil {
|
||||
return servers
|
||||
}
|
||||
|
||||
patterns := []struct {
|
||||
pkg string
|
||||
name string
|
||||
cat string
|
||||
}{
|
||||
{"@anthropic/mcp-server-fetch", "anthropic-fetch", "web"},
|
||||
{"@anthropic/mcp-server-sqlite", "anthropic-sqlite", "database"},
|
||||
{"@anthropic/mcp-server-brave-search", "anthropic-brave-search", "web"},
|
||||
{"@anthropic/mcp-server-filesystem", "anthropic-filesystem", "core"},
|
||||
{"@anthropic/mcp-server-github", "anthropic-github", "vcs"},
|
||||
{"@anthropic/mcp-server-memory", "anthropic-memory", "core"},
|
||||
{"@anthropic/mcp-server-puppeteer", "anthropic-puppeteer", "web"},
|
||||
{"@anthropic/mcp-server-sequential-thinking", "anthropic-thinking", "ai"},
|
||||
}
|
||||
|
||||
for _, p := range patterns {
|
||||
if known[p.name] {
|
||||
continue
|
||||
}
|
||||
|
||||
servers = append(servers, DiscoveredMCPServer{
|
||||
Name: p.name,
|
||||
Command: npx,
|
||||
Source: "npm-global",
|
||||
Args: []string{"-y", p.pkg},
|
||||
Installed: true,
|
||||
Category: p.cat,
|
||||
})
|
||||
}
|
||||
|
||||
return servers
|
||||
}
|
||||
|
||||
func discoverPipServers(known map[string]bool) []DiscoveredMCPServer {
|
||||
var servers []DiscoveredMCPServer
|
||||
|
||||
pipCmds := []string{"pip", "pip3", "uv"}
|
||||
for _, pip := range pipCmds {
|
||||
if _, err := exec.LookPath(pip); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
cmd := exec.Command(pip, "list", "--format=json")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var packages []struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.Unmarshal(output, &packages); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, pkg := range packages {
|
||||
nameLower := strings.ToLower(pkg.Name)
|
||||
if !strings.Contains(nameLower, "mcp") {
|
||||
continue
|
||||
}
|
||||
|
||||
serverName := strings.ReplaceAll(nameLower, "_", "-")
|
||||
if strings.HasPrefix(serverName, "mcp-") {
|
||||
serverName = serverName[4:]
|
||||
}
|
||||
|
||||
if known[serverName] {
|
||||
continue
|
||||
}
|
||||
|
||||
binName := strings.ReplaceAll(pkg.Name, "-", "_")
|
||||
if _, err := exec.LookPath(binName); err != nil {
|
||||
binName = pkg.Name
|
||||
if _, err := exec.LookPath(binName); err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
servers = append(servers, DiscoveredMCPServer{
|
||||
Name: serverName,
|
||||
Command: binName,
|
||||
Source: "pip",
|
||||
Installed: true,
|
||||
Category: "python",
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return servers
|
||||
}
|
||||
|
||||
func discoverPathServers(known map[string]bool) []DiscoveredMCPServer {
|
||||
var servers []DiscoveredMCPServer
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
searchDirs := []string{}
|
||||
|
||||
if home != "" {
|
||||
searchDirs = append(searchDirs,
|
||||
filepath.Join(home, ".local", "bin"),
|
||||
filepath.Join(home, ".muyue", "mcp-servers"),
|
||||
)
|
||||
}
|
||||
|
||||
for _, dir := range searchDirs {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
name := entry.Name()
|
||||
if !strings.Contains(strings.ToLower(name), "mcp") {
|
||||
continue
|
||||
}
|
||||
|
||||
serverName := strings.ToLower(name)
|
||||
serverName = strings.TrimPrefix(serverName, "mcp-")
|
||||
serverName = strings.TrimPrefix(serverName, "mcp_")
|
||||
serverName = strings.TrimSuffix(serverName, ".sh")
|
||||
|
||||
if known[serverName] {
|
||||
continue
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(dir, name)
|
||||
if info, err := os.Stat(fullPath); err == nil && info.Mode()&0111 != 0 {
|
||||
servers = append(servers, DiscoveredMCPServer{
|
||||
Name: serverName,
|
||||
Command: fullPath,
|
||||
Source: "path",
|
||||
Installed: true,
|
||||
Category: "local",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return servers
|
||||
}
|
||||
|
||||
func DiscoverServerTools(serverName string) (*ServerCapabilities, error) {
|
||||
capCacheMu.RLock()
|
||||
if caps, ok := capCache[serverName]; ok {
|
||||
capCacheMu.RUnlock()
|
||||
return caps, nil
|
||||
}
|
||||
capCacheMu.RUnlock()
|
||||
|
||||
server, err := findServerConfig(serverName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
script := buildListToolsScript(server)
|
||||
if script == "" {
|
||||
return &ServerCapabilities{
|
||||
Name: serverName,
|
||||
Tools: []ToolDiscovery{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
cmd := exec.Command(server.Command, append(server.Args, "--list-tools")...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
_ = script
|
||||
|
||||
if err != nil {
|
||||
return discoverToolsFallback(serverName, server)
|
||||
}
|
||||
|
||||
var caps ServerCapabilities
|
||||
if jsonErr := json.Unmarshal(output, &caps); jsonErr != nil {
|
||||
caps = ServerCapabilities{
|
||||
Name: serverName,
|
||||
Tools: []ToolDiscovery{
|
||||
{
|
||||
Name: serverName,
|
||||
Description: "MCP server: " + serverName,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
capCacheMu.Lock()
|
||||
capCache[serverName] = &caps
|
||||
capCacheMu.Unlock()
|
||||
|
||||
return &caps, nil
|
||||
}
|
||||
|
||||
func discoverToolsFallback(name string, server *RegistryServer) (*ServerCapabilities, error) {
|
||||
caps := &ServerCapabilities{
|
||||
Name: name,
|
||||
Tools: []ToolDiscovery{
|
||||
{
|
||||
Name: name,
|
||||
Description: server.Description,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
capCacheMu.Lock()
|
||||
capCache[name] = caps
|
||||
capCacheMu.Unlock()
|
||||
|
||||
return caps, nil
|
||||
}
|
||||
|
||||
func findServerConfig(name string) (*RegistryServer, error) {
|
||||
reg, err := LoadRegistry()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := range reg.Servers {
|
||||
if reg.Servers[i].Name == name {
|
||||
return ®.Servers[i], nil
|
||||
}
|
||||
}
|
||||
|
||||
for _, s := range knownMCPServers {
|
||||
if s.Name == name {
|
||||
return &RegistryServer{
|
||||
Name: s.Name,
|
||||
Command: s.Command,
|
||||
Args: s.Args,
|
||||
Env: s.Env,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("server %q not found", name)
|
||||
}
|
||||
|
||||
func buildListToolsScript(server *RegistryServer) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func InvalidateCapabilitiesCache() {
|
||||
capCacheMu.Lock()
|
||||
defer capCacheMu.Unlock()
|
||||
capCache = make(map[string]*ServerCapabilities)
|
||||
}
|
||||
|
||||
func GetCachedCapabilities(name string) *ServerCapabilities {
|
||||
capCacheMu.RLock()
|
||||
defer capCacheMu.RUnlock()
|
||||
return capCache[name]
|
||||
}
|
||||
|
||||
func countNew(servers []DiscoveredMCPServer, known map[string]bool) int {
|
||||
count := 0
|
||||
for _, s := range servers {
|
||||
if !known[s.Name] {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||