Compare commits
145 Commits
v0.3.5-bet
...
v0.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
c39203cc4b | ||
|
|
869bf154cc | ||
|
|
52a785ec9a | ||
|
|
0b6d5281df | ||
|
|
745e03d00a | ||
|
|
f88c7a4f3f | ||
|
|
028fb364ba | ||
|
|
85edea9ed9 | ||
|
|
0232bd7afe | ||
|
|
49a0f5c8c3 | ||
|
|
d3755028fb | ||
|
|
41cbee8928 | ||
|
|
1d521cbf90 | ||
|
|
d9d1ec5cb7 | ||
|
|
45884ee75c | ||
|
|
6f7f588e51 | ||
|
|
328e9e6457 | ||
|
|
c81ebb4e46 | ||
|
|
b0865bc598 | ||
|
|
0d8e1b1e1a | ||
|
|
485e085bb0 | ||
|
|
61da8039bc | ||
|
|
65df15498b | ||
|
|
b6147ddb12 | ||
|
|
275a9a4cc7 | ||
|
|
e92a2f00f5 | ||
|
|
1f12b8a4fb | ||
|
|
9188231a05 | ||
|
|
28e5113733 | ||
|
|
51a599fc83 | ||
|
|
d8384cad00 | ||
|
|
5b4a70e690 |
@@ -32,13 +32,21 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-go-
|
${{ runner.os }}-go-
|
||||||
|
|
||||||
- name: Cache Node modules
|
- name: Cache Node modules (web)
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: web/node_modules
|
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: |
|
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 Go dependencies
|
- name: Download Go dependencies
|
||||||
run: go mod download
|
run: go mod download
|
||||||
@@ -49,6 +57,15 @@ jobs:
|
|||||||
npm ci
|
npm ci
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
|
- name: Build extension
|
||||||
|
run: |
|
||||||
|
cd extension
|
||||||
|
npm ci
|
||||||
|
npx wxt zip
|
||||||
|
npx wxt zip --browser firefox
|
||||||
|
mkdir -p ../dist
|
||||||
|
mv .output/muyue-extension-*.zip ../dist/
|
||||||
|
|
||||||
- name: Vet
|
- name: Vet
|
||||||
run: go vet ./...
|
run: go vet ./...
|
||||||
|
|
||||||
@@ -68,17 +85,25 @@ jobs:
|
|||||||
echo "beta_num=${BETA_NUM}" >> $GITHUB_OUTPUT
|
echo "beta_num=${BETA_NUM}" >> $GITHUB_OUTPUT
|
||||||
echo "Building beta release: ${VERSION}"
|
echo "Building beta release: ${VERSION}"
|
||||||
|
|
||||||
|
- name: Generate Windows resource (icon)
|
||||||
|
run: |
|
||||||
|
go install github.com/akavel/rsrc@latest
|
||||||
|
RSRC="$(go env GOPATH)/bin/rsrc"
|
||||||
|
$RSRC -ico assets/muyue.ico -arch amd64 -o cmd/muyue/rsrc_windows_amd64.syso
|
||||||
|
$RSRC -ico assets/muyue.ico -arch arm64 -o cmd/muyue/rsrc_windows_arm64.syso
|
||||||
|
|
||||||
- name: Build (all platforms)
|
- name: Build (all platforms)
|
||||||
run: |
|
run: |
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
VERSION=${{ steps.version.outputs.version }}
|
VERSION=${{ steps.version.outputs.version }}
|
||||||
LDFLAGS="-s -w -X github.com/muyue/muyue/internal/version.Prerelease=${VERSION#v}"
|
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=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=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=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=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=amd64 go build -ldflags="$WIN_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=arm64 go build -ldflags="$WIN_LDFLAGS" -o dist/muyue-windows-arm64.exe ./cmd/muyue/
|
||||||
|
|
||||||
- name: Package archives
|
- name: Package archives
|
||||||
run: |
|
run: |
|
||||||
@@ -144,7 +169,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "Release ID: ${RELEASE_ID}"
|
echo "Release ID: ${RELEASE_ID}"
|
||||||
UPLOAD_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets"
|
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/*.zip dist/checksums.txt dist/muyue-extension-*.zip; do
|
||||||
filename=$(basename "$file")
|
filename=$(basename "$file")
|
||||||
echo "Uploading ${filename}..."
|
echo "Uploading ${filename}..."
|
||||||
curl -s -X POST "${UPLOAD_URL}" \
|
curl -s -X POST "${UPLOAD_URL}" \
|
||||||
|
|||||||
@@ -32,13 +32,21 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-go-
|
${{ runner.os }}-go-
|
||||||
|
|
||||||
- name: Cache Node modules
|
- name: Cache Node modules (web)
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: web/node_modules
|
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: |
|
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
|
- name: Download dependencies
|
||||||
run: go mod download
|
run: go mod download
|
||||||
@@ -49,6 +57,15 @@ jobs:
|
|||||||
npm ci
|
npm ci
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
|
- name: Build extension
|
||||||
|
run: |
|
||||||
|
cd extension
|
||||||
|
npm ci
|
||||||
|
npx wxt zip
|
||||||
|
npx wxt zip --browser firefox
|
||||||
|
mkdir -p ../dist
|
||||||
|
mv .output/muyue-extension-*.zip ../dist/
|
||||||
|
|
||||||
- name: Vet
|
- name: Vet
|
||||||
run: go vet ./...
|
run: go vet ./...
|
||||||
|
|
||||||
@@ -64,16 +81,28 @@ jobs:
|
|||||||
echo "base=${BASE_VERSION}" >> $GITHUB_OUTPUT
|
echo "base=${BASE_VERSION}" >> $GITHUB_OUTPUT
|
||||||
echo "Building stable release: ${VERSION}"
|
echo "Building stable release: ${VERSION}"
|
||||||
|
|
||||||
|
- name: Generate Windows resource (icon)
|
||||||
|
run: |
|
||||||
|
go install github.com/akavel/rsrc@latest
|
||||||
|
RSRC="$(go env GOPATH)/bin/rsrc"
|
||||||
|
$RSRC -ico assets/muyue.ico -arch amd64 -o cmd/muyue/rsrc_windows_amd64.syso
|
||||||
|
$RSRC -ico assets/muyue.ico -arch arm64 -o cmd/muyue/rsrc_windows_arm64.syso
|
||||||
|
|
||||||
- name: Build (all platforms)
|
- name: Build (all platforms)
|
||||||
run: |
|
run: |
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
LDFLAGS="-s -w"
|
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=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=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=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=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=amd64 go build -ldflags="$WIN_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=arm64 go build -ldflags="$WIN_LDFLAGS" -o dist/muyue-windows-arm64.exe ./cmd/muyue/
|
||||||
|
|
||||||
- name: Package archives
|
- name: Package archives
|
||||||
run: |
|
run: |
|
||||||
@@ -138,12 +167,17 @@ jobs:
|
|||||||
echo "sudo mv muyue-darwin-arm64 /usr/local/bin/muyue"
|
echo "sudo mv muyue-darwin-arm64 /usr/local/bin/muyue"
|
||||||
echo "\`\`\`"
|
echo "\`\`\`"
|
||||||
echo ""
|
echo ""
|
||||||
echo "**Windows (x86_64)**"
|
echo "**Windows (x86_64)** — sans privilèges admin, crée les raccourcis Bureau + Menu Démarrer + commande \`muyue\` dans la session courante :"
|
||||||
echo "\`\`\`powershell"
|
echo "\`\`\`powershell"
|
||||||
echo "Invoke-WebRequest -Uri \"${DL_URL}/muyue-windows-amd64.zip\" -OutFile \"muyue.zip\""
|
echo "Get-Process muyue, muyue-windows-amd64 -ErrorAction SilentlyContinue | Stop-Process -Force; Start-Sleep -Milliseconds 500"
|
||||||
echo "Expand-Archive -Path \"muyue.zip\" -DestinationPath \".\""
|
echo "\$dest = \"\$env:LOCALAPPDATA\\Muyue\"; New-Item -ItemType Directory -Force -Path \$dest | Out-Null"
|
||||||
echo "Move-Item muyue-windows-amd64.exe C:\\Windows\\muyue.exe"
|
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 ""
|
||||||
|
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
|
} > /tmp/stable_changelog.md
|
||||||
echo "path=/tmp/stable_changelog.md" >> $GITHUB_OUTPUT
|
echo "path=/tmp/stable_changelog.md" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
@@ -224,7 +258,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "Release ID: ${RELEASE_ID}"
|
echo "Release ID: ${RELEASE_ID}"
|
||||||
UPLOAD_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets"
|
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/*.zip dist/checksums.txt dist/muyue-extension-*.zip; do
|
||||||
filename=$(basename "$file")
|
filename=$(basename "$file")
|
||||||
echo "Uploading ${filename}..."
|
echo "Uploading ${filename}..."
|
||||||
UPLOAD_RESP=$(curl -s -w "\n%{http_code}" -X POST "${UPLOAD_URL}" \
|
UPLOAD_RESP=$(curl -s -w "\n%{http_code}" -X POST "${UPLOAD_URL}" \
|
||||||
|
|||||||
@@ -30,13 +30,21 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-go-
|
${{ runner.os }}-go-
|
||||||
|
|
||||||
- name: Cache Node modules
|
- name: Cache Node modules (web)
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: web/node_modules
|
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: |
|
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
|
- name: Download dependencies
|
||||||
run: go mod download
|
run: go mod download
|
||||||
@@ -47,13 +55,20 @@ jobs:
|
|||||||
npm ci
|
npm ci
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
|
- name: Build extension
|
||||||
|
run: |
|
||||||
|
cd extension
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
npm run build:firefox
|
||||||
|
|
||||||
- name: Vet
|
- name: Vet
|
||||||
run: go vet ./...
|
run: go vet ./...
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: go test ./... -v -race -timeout 60s
|
run: go test ./... -v -race -timeout 60s
|
||||||
|
|
||||||
- name: Build
|
- name: Build binary
|
||||||
run: |
|
run: |
|
||||||
go build -o muyue ./cmd/muyue/
|
go build -o muyue ./cmd/muyue/
|
||||||
./muyue version
|
./muyue version
|
||||||
|
|||||||
6
.gitignore
vendored
@@ -24,6 +24,7 @@ Thumbs.db
|
|||||||
*.exe
|
*.exe
|
||||||
*.test
|
*.test
|
||||||
*.out
|
*.out
|
||||||
|
*.syso
|
||||||
vendor/
|
vendor/
|
||||||
|
|
||||||
# Config with secrets
|
# Config with secrets
|
||||||
@@ -31,3 +32,8 @@ vendor/
|
|||||||
|
|
||||||
# Frontend (web/.gitignore handles specifics)
|
# Frontend (web/.gitignore handles specifics)
|
||||||
web/node_modules/
|
web/node_modules/
|
||||||
|
|
||||||
|
# Extension build artifacts
|
||||||
|
extension/node_modules/
|
||||||
|
extension/.output/
|
||||||
|
extension/.wxt/
|
||||||
|
|||||||
1041
CHANGELOG.md
1073
CRUSH_ARCHITECTURE_REPORT.md
Normal file
BIN
LogoMuyue.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
16
Makefile
@@ -7,7 +7,9 @@ NODE ?= node
|
|||||||
NPM ?= npm
|
NPM ?= npm
|
||||||
WEB_DIR = web
|
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-chrome ext-firefox ext-zip
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
cd $(WEB_DIR) && $(NPM) ci && $(NPM) run build
|
cd $(WEB_DIR) && $(NPM) ci && $(NPM) run build
|
||||||
@@ -63,5 +65,17 @@ build-all: frontend
|
|||||||
GOOS=windows GOARCH=amd64 $(GO) build -o dist/$(BINARY)-windows-amd64.exe ./cmd/muyue/
|
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/
|
GOOS=windows GOARCH=arm64 $(GO) build -o dist/$(BINARY)-windows-arm64.exe ./cmd/muyue/
|
||||||
|
|
||||||
|
ext:
|
||||||
|
cd $(EXT_DIR) && $(NPM) ci && $(NPM) run build && $(NPM) run build:firefox
|
||||||
|
|
||||||
|
ext-chrome:
|
||||||
|
cd $(EXT_DIR) && $(NPM) ci && $(NPM) run build
|
||||||
|
|
||||||
|
ext-firefox:
|
||||||
|
cd $(EXT_DIR) && $(NPM) ci && $(NPM) run build:firefox
|
||||||
|
|
||||||
|
ext-zip:
|
||||||
|
cd $(EXT_DIR) && $(NPM) ci && $(NPM) run zip && $(NPM) run zip:firefox
|
||||||
|
|
||||||
deps:
|
deps:
|
||||||
$(GO) mod tidy
|
$(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)
|
- **i18n** — Full FR/EN support with keyboard layout awareness (AZERTY, QWERTY, QWERTZ)
|
||||||
- **4 themes** — Cyberpunk Red, Cyberpunk Pink, Midnight Blue, Matrix Green
|
- **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
|
## Tech Stack
|
||||||
|
|
||||||
| Layer | Technology |
|
| Layer | Technology |
|
||||||
@@ -186,6 +225,10 @@ The Go backend serves 15 REST endpoints under `/api/`:
|
|||||||
│ │ ├── styles/global.css # Full CSS theme system
|
│ │ ├── styles/global.css # Full CSS theme system
|
||||||
│ │ └── themes/index.js # 4 themes with CSS variable injection
|
│ │ └── themes/index.js # 4 themes with CSS variable injection
|
||||||
│ └── vite.config.js # Vite + dev proxy to :8095
|
│ └── 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)
|
├── .gitea/workflows/ # CI/CD (PR check, beta, stable)
|
||||||
└── Makefile # build, test, lint, cross-compile
|
└── 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 |
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
|
||||||
|
}
|
||||||
@@ -24,30 +24,61 @@ func Execute() error {
|
|||||||
return rootCmd.Execute()
|
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 {
|
func loadOrSetupConfig() *config.MuyueConfig {
|
||||||
if !config.Exists() {
|
if !config.Exists() {
|
||||||
fmt.Println("First time setup detected!")
|
// No config yet. If we have a real terminal, run the rich TUI setup
|
||||||
cfg, err := profiler.RunFirstTimeSetup()
|
// (huh forms). Otherwise — typically when the user double-clicked the
|
||||||
if err != nil {
|
// shortcut on Windows — write defaults silently and let the React
|
||||||
fmt.Fprintf(os.Stderr, "Setup error: %v\n", err)
|
// onboarding wizard handle the real first-run flow once the browser
|
||||||
os.Exit(1)
|
// 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 {
|
for i := range cfg.AI.Providers {
|
||||||
if cfg.AI.Providers[i].Active && cfg.AI.Providers[i].APIKey == "" {
|
if cfg.AI.Providers[i].Active && cfg.AI.Providers[i].APIKey == "" {
|
||||||
key, err := profiler.AskAPIKey(cfg.AI.Providers[i].Name)
|
key, err := profiler.AskAPIKey(cfg.AI.Providers[i].Name)
|
||||||
if err == nil && key != "" {
|
if err == nil && key != "" {
|
||||||
cfg.AI.Providers[i].APIKey = 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 {
|
if err := config.Save(cfg); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Save error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Save error: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("\nSetup complete! Starting muyue...")
|
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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
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
16
extension/package.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "muyue-extension",
|
||||||
|
"version": "0.8.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "wxt",
|
||||||
|
"build": "wxt build",
|
||||||
|
"build:firefox": "wxt build --browser firefox",
|
||||||
|
"zip": "wxt zip",
|
||||||
|
"zip:firefox": "wxt zip --browser firefox"
|
||||||
|
},
|
||||||
|
"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="panel">
|
||||||
|
<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 Side 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> browser extension v0.1.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();
|
||||||
54
extension/src/entrypoints/sidepanel/index.html
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=100%" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="panel">
|
||||||
|
<header>
|
||||||
|
<img src="/icon/32.png" alt="Muyue" />
|
||||||
|
<h1>Muyue Side Panel</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 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>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<span>Muyue</span> browser extension v0.1.0
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="./main.js" type="module"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
72
extension/src/entrypoints/sidepanel/main.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
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 $sessionsList = document.getElementById('sessions-list');
|
||||||
|
const $btnDashboard = document.getElementById('btn-dashboard');
|
||||||
|
const $serverUrl = document.getElementById('server-url');
|
||||||
|
const $btnSaveUrl = document.getElementById('btn-save-url');
|
||||||
|
|
||||||
|
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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
renderSessions(sessions);
|
||||||
|
} catch {
|
||||||
|
$serverStatus.innerHTML = `${dot('red')} Offline`;
|
||||||
|
$sessionCount.textContent = '—';
|
||||||
|
$sessionsList.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
setInterval(refresh, 5000);
|
||||||
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
211
extension/src/styles/panel.css
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
:root {
|
||||||
|
--bg-primary: #0a0a0f;
|
||||||
|
--bg-secondary: #12121a;
|
||||||
|
--bg-tertiary: rgba(255, 255, 255, 0.05);
|
||||||
|
--border: rgba(255, 255, 255, 0.1);
|
||||||
|
--text-primary: #e8e8f0;
|
||||||
|
--text-secondary: #9999aa;
|
||||||
|
--accent: #ff4757;
|
||||||
|
--accent-dim: rgba(255, 71, 87, 0.15);
|
||||||
|
--accent-glow: rgba(255, 71, 87, 0.4);
|
||||||
|
--green: #3aaa61;
|
||||||
|
--yellow: #f5a623;
|
||||||
|
--red: #ff6b6b;
|
||||||
|
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||||
|
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
width: 320px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
header img {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
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-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-value {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot-green { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
||||||
|
.dot-red { background: var(--red); box-shadow: 0 0 6px var(--red); }
|
||||||
|
.dot-yellow { background: var(--yellow); box-shadow: 0 0 6px var(--yellow); }
|
||||||
|
|
||||||
|
.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: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #e8414f;
|
||||||
|
box-shadow: 0 0 12px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section label {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
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-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row button {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row button:hover {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer span {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
30
extension/wxt.config.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { defineConfig } from 'wxt';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
srcDir: 'src',
|
||||||
|
suppressWarnings: {
|
||||||
|
firefoxDataCollection: true,
|
||||||
|
},
|
||||||
|
manifest: {
|
||||||
|
name: 'Muyue',
|
||||||
|
description: 'AI-powered browser testing & automation — connected to your Muyue desktop app',
|
||||||
|
permissions: [
|
||||||
|
'storage',
|
||||||
|
'activeTab',
|
||||||
|
'tabs',
|
||||||
|
'sidePanel',
|
||||||
|
'scripting',
|
||||||
|
'notifications',
|
||||||
|
],
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -3,17 +3,55 @@ package agent
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"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 {
|
type TerminalParams struct {
|
||||||
Command string `json:"command" description:"The shell command to execute"`
|
Command string `json:"command" description:"The shell command to execute"`
|
||||||
Timeout int `json:"timeout,omitempty" description:"Timeout in seconds (default 60, max 300)"`
|
Timeout int `json:"timeout,omitempty" description:"Timeout in seconds (default 60, max 300)"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TerminalResponse struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
IsError bool `json:"is_error"`
|
||||||
|
SudoBlocked bool `json:"sudo_blocked,omitempty"`
|
||||||
|
Command string `json:"command,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
func NewTerminalTool() (*ToolDefinition, error) {
|
func NewTerminalTool() (*ToolDefinition, error) {
|
||||||
return NewTool("terminal",
|
return NewTool("terminal",
|
||||||
"Execute a shell command on the local system and return the output. Use for running builds, tests, git operations, package management, system info, or any CLI task. Commands run in the user's home directory by default. Long-running commands are auto-terminated.",
|
"Execute a shell command on the local system and return the output. Use for running builds, tests, git operations, package management, system info, or any CLI task. Commands run in the user's home directory by default. Long-running commands are auto-terminated.",
|
||||||
@@ -22,6 +60,39 @@ func NewTerminalTool() (*ToolDefinition, error) {
|
|||||||
return TextErrorResponse("command is required"), nil
|
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
|
timeout := time.Duration(p.Timeout) * time.Second
|
||||||
if timeout == 0 {
|
if timeout == 0 {
|
||||||
timeout = 60 * time.Second
|
timeout = 60 * time.Second
|
||||||
@@ -39,6 +110,7 @@ func NewTerminalTool() (*ToolDefinition, error) {
|
|||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
|
|
||||||
result := string(output)
|
result := string(output)
|
||||||
|
result = stripANSI(result)
|
||||||
if len(result) > 10000 {
|
if len(result) > 10000 {
|
||||||
result = result[:10000] + "\n... [truncated]"
|
result = result[:10000] + "\n... [truncated]"
|
||||||
}
|
}
|
||||||
@@ -52,21 +124,35 @@ func NewTerminalTool() (*ToolDefinition, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CrushRunParams struct {
|
type CrushRunParams struct {
|
||||||
Task string `json:"task" description:"The task description for Crush to execute"`
|
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) {
|
func NewCrushRunTool() (*ToolDefinition, error) {
|
||||||
return NewTool("crush_run",
|
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. Returns the agent's final output.",
|
"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) {
|
func(ctx context.Context, p CrushRunParams) (ToolResponse, error) {
|
||||||
if p.Task == "" {
|
if p.Task == "" {
|
||||||
return TextErrorResponse("task is required"), nil
|
return TextErrorResponse("task is required"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, 300*time.Second)
|
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()
|
defer cancel()
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, "crush", "run", p.Task)
|
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()
|
output, err := cmd.CombinedOutput()
|
||||||
|
|
||||||
result := string(output)
|
result := string(output)
|
||||||
@@ -75,7 +161,66 @@ func NewCrushRunTool() (*ToolDefinition, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return TextErrorResponse(fmt.Sprintf("Crush error: %v\n\n%s", err, result)), 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
|
return TextResponse(result), nil
|
||||||
@@ -284,6 +429,7 @@ func DefaultRegistry() *Registry {
|
|||||||
tools := []*ToolDefinition{
|
tools := []*ToolDefinition{
|
||||||
must(NewTerminalTool()),
|
must(NewTerminalTool()),
|
||||||
must(NewCrushRunTool()),
|
must(NewCrushRunTool()),
|
||||||
|
must(NewClaudeRunTool()),
|
||||||
must(NewReadFileTool()),
|
must(NewReadFileTool()),
|
||||||
must(NewListFilesTool()),
|
must(NewListFilesTool()),
|
||||||
must(NewSearchFilesTool()),
|
must(NewSearchFilesTool()),
|
||||||
|
|||||||
@@ -26,6 +26,43 @@ func detectShell() string {
|
|||||||
return "/bin/sh"
|
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 {
|
func expandHome(path string) string {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -1,6 +1,43 @@
|
|||||||
Tu es l'assistant IA de **Muyue Studio**, le centre de commandement de l'environnement de développement de l'utilisateur.
|
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 d'aider l'utilisateur à configurer, gérer et optimiser son environnement dev.
|
Tu es intégré dans Muyue, un gestionnaire d'environnement de développement de bureau. Ton rôle est 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
|
## Environnement
|
||||||
|
|
||||||
@@ -13,32 +50,122 @@ Muyue gère :
|
|||||||
|
|
||||||
## Outils disponibles
|
## Outils disponibles
|
||||||
|
|
||||||
Tu as accès à des outils. Utilise-les concrètement, ne décris pas ce que tu ferais — fais-le.
|
| Outil | Usage |
|
||||||
|
|-------|-------|
|
||||||
|
| **terminal** | Exécuter des commandes shell (builds, tests, git, etc.) |
|
||||||
|
| **crush_run** | Déléguer une tâche complexe à Crush (édition de fichiers, refactoring, debug) — préfère cet outil pour les tâches multi-fichiers ou l'écriture de code |
|
||||||
|
| **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 |
|
||||||
|
|
||||||
- **terminal** : Exécuter des commandes shell (builds, tests, git, etc.)
|
<browser_test_strategy>
|
||||||
- **crush_run** : Déléguer une tâche complexe à l'agent Crush (édition de fichiers, refactoring, debug)
|
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).
|
||||||
- **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 le contenu des 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
|
|
||||||
|
|
||||||
## Règles
|
## Règle d'or — économise les appels d'outils
|
||||||
|
|
||||||
1. **AGIS, ne décris pas** — Si l'utilisateur demande de faire quelque chose, utilise les outils pour le faire. Ne dis pas "je pourrais faire X" — fais-le.
|
**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.
|
||||||
2. **Sois concis** — Pas de préambule, pas de blabla. Réponse directe.
|
|
||||||
3. **Une chose à la fois** — N'appelle pas plusieurs outils simultanément sauf si c'est nécessaire.
|
Stratégie efficace :
|
||||||
4. **Gère les erreurs** — Si un outil échoue, essaie une approche différente avant de le dire à l'utilisateur.
|
|
||||||
5. **Ne devine pas** — Si tu n'as pas assez d'informations, utilise les outils pour les obtenir (lire un fichier, chercher, etc.)
|
1. **Au début** : `summary` (URL + console + 20 lignes) → `list_clickables` (UNE FOIS, mémorise les index pertinents pour ta tâche).
|
||||||
6. **Confidentialité** — Ne révèle jamais les clés API, mots de passe ou informations sensibles dans tes réponses.
|
2. **Pendant** : clique par `index`. Lis le `console_delta` retourné après chaque clic.
|
||||||
7. **Langue** — Réponds dans la même langue que l'utilisateur.
|
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
|
## Format des réponses
|
||||||
|
|
||||||
- Code : utilise des blocs markdown
|
- **Code** : blocs markdown avec le langage spécifié
|
||||||
- Résultats d'outils : résume les points clés, ne colle pas des milliers de lignes
|
- **Résultats d'outils** : résume les points clés, max 2000 caractères, ne copie pas des milliers de lignes
|
||||||
- Erreurs : explique clairement et propose une solution
|
- **Erreurs** : explique clairement la cause et propose une solution concrète
|
||||||
- Succès : confirme brièvement ce qui a été fait
|
- **Succès** : confirme brièvement ce qui a été fait (1 ligne)
|
||||||
|
- **Multi-fichiers** : liste les fichiers modifiés avec `fichier:ligne` pour les références
|
||||||
|
|
||||||
|
## Diagrammes Mermaid
|
||||||
|
|
||||||
|
Tu peux utiliser des diagrammes Mermaid pour visualiser des architectures, flux, séquences, etc.
|
||||||
|
Utilise un bloc code avec le langage `mermaid` :
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[Début] --> B{Décision}
|
||||||
|
B -->|Oui| C[Action]
|
||||||
|
B -->|Non| D[Fin]
|
||||||
|
```
|
||||||
|
|
||||||
|
Types utiles :
|
||||||
|
- `graph TD/LR` — Architecture, flux de données
|
||||||
|
- `sequenceDiagram` — Interactions entre composants
|
||||||
|
- `flowchart` — Processus et décisions
|
||||||
|
- `classDiagram` — Structures de données
|
||||||
|
- `erDiagram` — Schémas de base de données
|
||||||
|
- `gantt` — Planning et timelines
|
||||||
|
|
||||||
|
Utilise Mermaid quand ça apporte de la clarté : architecture complexe, flux multi-étapes, relations entre entités. Ne l'utilise pas pour du texte simple.
|
||||||
|
|||||||
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()
|
||||||
|
}
|
||||||
@@ -3,15 +3,23 @@ package api
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/muyue/muyue/internal/agent"
|
"github.com/muyue/muyue/internal/agent"
|
||||||
"github.com/muyue/muyue/internal/orchestrator"
|
"github.com/muyue/muyue/internal/orchestrator"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
// MaxToolIterations bounds the inner tool-call loop in RunWithTools /
|
||||||
MaxToolIterations = 15
|
// 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.
|
// ChatEngine handles chat interactions with tool execution.
|
||||||
// This deduplicates chat logic previously repeated in handlers_chat.go and handlers_shell_chat.go.
|
// This deduplicates chat logic previously repeated in handlers_chat.go and handlers_shell_chat.go.
|
||||||
@@ -21,6 +29,8 @@ type ChatEngine struct {
|
|||||||
tools json.RawMessage
|
tools json.RawMessage
|
||||||
onChunk func(map[string]interface{})
|
onChunk func(map[string]interface{})
|
||||||
stream bool
|
stream bool
|
||||||
|
limiter ToolLimiter
|
||||||
|
TotalTokens int
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewChatEngine creates a new ChatEngine instance.
|
// NewChatEngine creates a new ChatEngine instance.
|
||||||
@@ -43,6 +53,11 @@ func (ce *ChatEngine) OnChunk(fn func(map[string]interface{})) {
|
|||||||
ce.onChunk = fn
|
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.
|
// RunWithTools executes the chat loop with tool calls.
|
||||||
// Returns final content, tool calls, tool results, and error.
|
// 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) {
|
func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.Message) (string, []map[string]interface{}, []map[string]interface{}, error) {
|
||||||
@@ -71,8 +86,15 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
|
|||||||
return finalContent, allToolCalls, allToolResults, err
|
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]
|
choice := resp.Choices[0]
|
||||||
content := cleanThinkingTags(choice.Message.Content)
|
content := orchestrator.CleanAIResponse(cleanThinkingTags(choice.Message.Content))
|
||||||
|
|
||||||
if content != "" {
|
if content != "" {
|
||||||
if ce.onChunk != nil {
|
if ce.onChunk != nil {
|
||||||
@@ -87,7 +109,7 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
|
|||||||
|
|
||||||
assistantMsg := orchestrator.Message{
|
assistantMsg := orchestrator.Message{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: content,
|
Content: orchestrator.TextContent(content),
|
||||||
ToolCalls: choice.Message.ToolCalls,
|
ToolCalls: choice.Message.ToolCalls,
|
||||||
}
|
}
|
||||||
messages = append(messages, assistantMsg)
|
messages = append(messages, assistantMsg)
|
||||||
@@ -110,7 +132,40 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
|
|||||||
Arguments: json.RawMessage(tc.Function.Arguments),
|
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)
|
result, execErr := ce.registry.Execute(ctx, call)
|
||||||
|
if release != nil {
|
||||||
|
release()
|
||||||
|
}
|
||||||
if execErr != nil {
|
if execErr != nil {
|
||||||
result = agent.ToolResponse{
|
result = agent.ToolResponse{
|
||||||
Content: execErr.Error(),
|
Content: execErr.Error(),
|
||||||
@@ -123,6 +178,11 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
|
|||||||
"content": result.Content,
|
"content": result.Content,
|
||||||
"is_error": result.IsError,
|
"is_error": result.IsError,
|
||||||
}
|
}
|
||||||
|
if result.Meta != nil {
|
||||||
|
for k, v := range result.Meta {
|
||||||
|
resultData[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
allToolResults = append(allToolResults, map[string]interface{}{
|
allToolResults = append(allToolResults, map[string]interface{}{
|
||||||
"tool_call_id": tc.ID,
|
"tool_call_id": tc.ID,
|
||||||
"name": tc.Function.Name,
|
"name": tc.Function.Name,
|
||||||
@@ -137,7 +197,7 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
|
|||||||
|
|
||||||
messages = append(messages, orchestrator.Message{
|
messages = append(messages, orchestrator.Message{
|
||||||
Role: "tool",
|
Role: "tool",
|
||||||
Content: result.Content,
|
Content: orchestrator.TextContent(result.Content),
|
||||||
ToolCallID: tc.ID,
|
ToolCallID: tc.ID,
|
||||||
Name: tc.Function.Name,
|
Name: tc.Function.Name,
|
||||||
})
|
})
|
||||||
@@ -149,6 +209,11 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
|
|||||||
return finalContent, allToolCalls, allToolResults, nil
|
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.
|
// RunNonStream executes chat without streaming content to client.
|
||||||
func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.Message) (string, error) {
|
func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.Message) (string, error) {
|
||||||
var finalContent string
|
var finalContent string
|
||||||
@@ -159,8 +224,15 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.
|
|||||||
return finalContent, err
|
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]
|
choice := resp.Choices[0]
|
||||||
content := cleanThinkingTags(choice.Message.Content)
|
content := orchestrator.CleanAIResponse(cleanThinkingTags(choice.Message.Content))
|
||||||
|
|
||||||
if content != "" {
|
if content != "" {
|
||||||
finalContent = content
|
finalContent = content
|
||||||
@@ -172,7 +244,7 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.
|
|||||||
|
|
||||||
assistantMsg := orchestrator.Message{
|
assistantMsg := orchestrator.Message{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: content,
|
Content: orchestrator.TextContent(content),
|
||||||
ToolCalls: choice.Message.ToolCalls,
|
ToolCalls: choice.Message.ToolCalls,
|
||||||
}
|
}
|
||||||
messages = append(messages, assistantMsg)
|
messages = append(messages, assistantMsg)
|
||||||
@@ -184,7 +256,25 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.
|
|||||||
Arguments: json.RawMessage(tc.Function.Arguments),
|
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)
|
result, execErr := ce.registry.Execute(ctx, call)
|
||||||
|
if release != nil {
|
||||||
|
release()
|
||||||
|
}
|
||||||
if execErr != nil {
|
if execErr != nil {
|
||||||
result = agent.ToolResponse{
|
result = agent.ToolResponse{
|
||||||
Content: execErr.Error(),
|
Content: execErr.Error(),
|
||||||
@@ -194,7 +284,7 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.
|
|||||||
|
|
||||||
messages = append(messages, orchestrator.Message{
|
messages = append(messages, orchestrator.Message{
|
||||||
Role: "tool",
|
Role: "tool",
|
||||||
Content: result.Content,
|
Content: orchestrator.TextContent(result.Content),
|
||||||
ToolCallID: tc.ID,
|
ToolCallID: tc.ID,
|
||||||
Name: tc.Function.Name,
|
Name: tc.Function.Name,
|
||||||
})
|
})
|
||||||
@@ -239,6 +329,5 @@ func SetupSSEHeaders(w http.ResponseWriter) {
|
|||||||
w.Header().Set("Content-Type", "text/event-stream")
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
w.Header().Set("Connection", "keep-alive")
|
w.Header().Set("Connection", "keep-alive")
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
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)
|
||||||
|
}
|
||||||
@@ -13,15 +13,57 @@ import (
|
|||||||
"github.com/muyue/muyue/internal/config"
|
"github.com/muyue/muyue/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxTokensApprox = 100000
|
const contextWindowTokens = 150000
|
||||||
const summarizeThreshold = 80000
|
const summarizeRatio = 0.80
|
||||||
const charsPerToken = 4
|
const charsPerToken = 4
|
||||||
|
|
||||||
|
func extractDisplayContent(role, content string) string {
|
||||||
|
if role != "assistant" {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
var parsed struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
ToolCalls []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Args string `json:"args"`
|
||||||
|
} `json:"tool_calls"`
|
||||||
|
ToolResults []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Result string `json:"result"`
|
||||||
|
} `json:"tool_results"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(content), &parsed); err != nil {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
if parsed.Content != "" {
|
||||||
|
sb.WriteString(parsed.Content)
|
||||||
|
}
|
||||||
|
for _, tc := range parsed.ToolCalls {
|
||||||
|
sb.WriteString("\n[")
|
||||||
|
sb.WriteString(tc.Name)
|
||||||
|
sb.WriteString("] ")
|
||||||
|
sb.WriteString(tc.Args)
|
||||||
|
}
|
||||||
|
for _, tr := range parsed.ToolResults {
|
||||||
|
sb.WriteString("\n[result")
|
||||||
|
if tr.Name != "" {
|
||||||
|
sb.WriteString(":")
|
||||||
|
sb.WriteString(tr.Name)
|
||||||
|
}
|
||||||
|
sb.WriteString("] ")
|
||||||
|
sb.WriteString(tr.Result)
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
type FeedMessage struct {
|
type FeedMessage struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Time string `json:"time"`
|
Time string `json:"time"`
|
||||||
|
Images []string `json:"images,omitempty"`
|
||||||
|
Summarized bool `json:"summarized,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Conversation struct {
|
type Conversation struct {
|
||||||
@@ -126,14 +168,38 @@ func (cs *ConversationStore) Add(role, content string) FeedMessage {
|
|||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cs *ConversationStore) AddWithImages(role, content string, imageIDs []string) FeedMessage {
|
||||||
|
cs.mu.Lock()
|
||||||
|
defer cs.mu.Unlock()
|
||||||
|
|
||||||
|
msg := FeedMessage{
|
||||||
|
ID: generateMsgID(),
|
||||||
|
Role: role,
|
||||||
|
Content: content,
|
||||||
|
Time: time.Now().Format(time.RFC3339),
|
||||||
|
Images: imageIDs,
|
||||||
|
}
|
||||||
|
cs.conv.Messages = append(cs.conv.Messages, msg)
|
||||||
|
cs.save()
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
func (cs *ConversationStore) Clear() {
|
func (cs *ConversationStore) Clear() {
|
||||||
cs.mu.Lock()
|
cs.mu.Lock()
|
||||||
defer cs.mu.Unlock()
|
defer cs.mu.Unlock()
|
||||||
|
|
||||||
|
var imageIDs []string
|
||||||
|
for _, m := range cs.conv.Messages {
|
||||||
|
imageIDs = append(imageIDs, m.Images...)
|
||||||
|
}
|
||||||
|
|
||||||
cs.conv.Messages = []FeedMessage{}
|
cs.conv.Messages = []FeedMessage{}
|
||||||
cs.conv.Summary = ""
|
cs.conv.Summary = ""
|
||||||
cs.conv.CreatedAt = time.Now().Format(time.RFC3339)
|
cs.conv.CreatedAt = time.Now().Format(time.RFC3339)
|
||||||
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
|
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||||
cs.save()
|
cs.save()
|
||||||
|
|
||||||
|
go cleanupImages(imageIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cs *ConversationStore) SetSummary(summary string) {
|
func (cs *ConversationStore) SetSummary(summary string) {
|
||||||
@@ -143,13 +209,15 @@ func (cs *ConversationStore) SetSummary(summary string) {
|
|||||||
cs.save()
|
cs.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cs *ConversationStore) TrimOld(keepCount int) {
|
func (cs *ConversationStore) MarkSummarized(upToIndex int) {
|
||||||
cs.mu.Lock()
|
cs.mu.Lock()
|
||||||
defer cs.mu.Unlock()
|
defer cs.mu.Unlock()
|
||||||
if len(cs.conv.Messages) <= keepCount {
|
if upToIndex <= 0 || upToIndex >= len(cs.conv.Messages) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cs.conv.Messages = cs.conv.Messages[len(cs.conv.Messages)-keepCount:]
|
for i := 0; i < upToIndex; i++ {
|
||||||
|
cs.conv.Messages[i].Summarized = true
|
||||||
|
}
|
||||||
cs.save()
|
cs.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +234,10 @@ func (cs *ConversationStore) ApproxTokenCountDetailed() TokenCount {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, m := range cs.conv.Messages {
|
for _, m := range cs.conv.Messages {
|
||||||
count := utf8.RuneCountInString(m.Content) / charsPerToken
|
if m.Role == "system" || m.Summarized {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
count := utf8.RuneCountInString(extractDisplayContent(m.Role, m.Content)) / charsPerToken
|
||||||
result.byMessage += count
|
result.byMessage += count
|
||||||
result.byRole[m.Role] += count
|
result.byRole[m.Role] += count
|
||||||
}
|
}
|
||||||
@@ -181,7 +252,7 @@ func (cs *ConversationStore) ApproxTokenCountDetailed() TokenCount {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (cs *ConversationStore) NeedsSummarization() bool {
|
func (cs *ConversationStore) NeedsSummarization() bool {
|
||||||
return cs.ApproxTokenCount() > summarizeThreshold
|
return cs.ApproxTokenCount() > int(float64(contextWindowTokens)*summarizeRatio)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cs *ConversationStore) Search(query string) []SearchResult {
|
func (cs *ConversationStore) Search(query string) []SearchResult {
|
||||||
|
|||||||
@@ -222,9 +222,9 @@ func (cs *ConversationStoreMulti) Add(role, content string) FeedMessage {
|
|||||||
Time: time.Now().Format(time.RFC3339),
|
Time: time.Now().Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
conv.Messages = append(conv.Messages, msg)
|
conv.Messages = append(conv.Messages, msg)
|
||||||
|
|
||||||
go cs.saveCurrent() // Fire and forget
|
cs.saveCurrent()
|
||||||
|
|
||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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,26 +1,143 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/muyue/muyue/internal/agent"
|
"github.com/muyue/muyue/internal/agent"
|
||||||
"github.com/muyue/muyue/internal/orchestrator"
|
"github.com/muyue/muyue/internal/orchestrator"
|
||||||
|
"github.com/muyue/muyue/internal/platform"
|
||||||
)
|
)
|
||||||
|
|
||||||
var thinkingTagRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
|
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) {
|
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != "POST" {
|
if r.Method != "POST" {
|
||||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 50*1024*1024)
|
||||||
var body struct {
|
var body struct {
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Stream bool `json:"stream"`
|
Stream bool `json:"stream"`
|
||||||
|
Images []ImageAttachment `json:"images"`
|
||||||
|
AdvancedReflection bool `json:"advanced_reflection"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
writeError(w, err.Error(), http.StatusBadRequest)
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
@@ -30,8 +147,44 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, "no message", http.StatusMethodNotAllowed)
|
writeError(w, "no message", http.StatusMethodNotAllowed)
|
||||||
return
|
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() {
|
if s.convStore.NeedsSummarization() {
|
||||||
s.autoSummarize()
|
s.autoSummarize()
|
||||||
@@ -42,13 +195,44 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
orb.SetSystemPrompt(agent.StudioSystemPrompt())
|
var studioPrompt strings.Builder
|
||||||
|
studioPrompt.WriteString(agent.StudioSystemPrompt())
|
||||||
|
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)
|
orb.SetTools(s.agentToolsJSON)
|
||||||
|
|
||||||
|
// Auto-force advanced reflection while a browser-test session is active:
|
||||||
|
// the user is doing AI-driven UI testing, where having a second model
|
||||||
|
// produce a preliminary report (when one is configured) materially
|
||||||
|
// improves which clicks the active model decides to perform. The toggle
|
||||||
|
// remains user-controllable for non-test conversations.
|
||||||
|
wantReflection := body.AdvancedReflection
|
||||||
|
if !wantReflection && s.browserTestStore != nil && len(s.browserTestStore.List()) > 0 {
|
||||||
|
wantReflection = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if wantReflection {
|
||||||
|
if report, ok := s.runReflectionReport(enrichedMessage); ok {
|
||||||
|
enrichedMessage = enrichedMessage + "\n\n[RAPPORT PRÉALABLE — produit par un autre modèle, à valider]\n" + report + "\n[/RAPPORT PRÉALABLE]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if body.Stream {
|
if body.Stream {
|
||||||
s.handleStreamChat(w, orb, body.Message)
|
s.handleStreamChat(w, orb, enrichedMessage)
|
||||||
} else {
|
} else {
|
||||||
s.handleNonStreamChat(w, orb, body.Message)
|
s.handleNonStreamChat(w, orb, enrichedMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +248,7 @@ func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orche
|
|||||||
messages := s.buildContextMessages(userMessage)
|
messages := s.buildContextMessages(userMessage)
|
||||||
|
|
||||||
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
|
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
|
||||||
|
engine.SetLimiter(s.AcquireAgentSlot)
|
||||||
engine.OnChunk(func(data map[string]interface{}) {
|
engine.OnChunk(func(data map[string]interface{}) {
|
||||||
if data == nil {
|
if data == nil {
|
||||||
return
|
return
|
||||||
@@ -92,6 +277,8 @@ func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orche
|
|||||||
}
|
}
|
||||||
s.convStore.Add("assistant", storeContent)
|
s.convStore.Add("assistant", storeContent)
|
||||||
|
|
||||||
|
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
|
||||||
|
|
||||||
sseWriter.Write(map[string]interface{}{"done": "true"})
|
sseWriter.Write(map[string]interface{}{"done": "true"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +287,7 @@ func (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Or
|
|||||||
messages := s.buildContextMessages(userMessage)
|
messages := s.buildContextMessages(userMessage)
|
||||||
|
|
||||||
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
|
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
|
||||||
|
engine.SetLimiter(s.AcquireAgentSlot)
|
||||||
finalContent, err := engine.RunNonStream(ctx, messages)
|
finalContent, err := engine.RunNonStream(ctx, messages)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
@@ -107,6 +295,9 @@ func (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Or
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.convStore.Add("assistant", finalContent)
|
s.convStore.Add("assistant", finalContent)
|
||||||
|
|
||||||
|
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
|
||||||
|
|
||||||
writeJSON(w, map[string]string{"content": finalContent})
|
writeJSON(w, map[string]string{"content": finalContent})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,53 +305,95 @@ func cleanThinkingTags(content string) string {
|
|||||||
return strings.TrimSpace(thinkingTagRegex.ReplaceAllString(content, ""))
|
return strings.TrimSpace(thinkingTagRegex.ReplaceAllString(content, ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
const contextWindowMessages = 20
|
// runReflectionReport runs the inactive AI provider on the user message to
|
||||||
|
// produce a preliminary analysis report that the active provider will then
|
||||||
|
// use as additional context. Returns ("", false) if no inactive provider is
|
||||||
|
// configured or on error — the caller falls back to a normal chat flow.
|
||||||
|
func (s *Server) runReflectionReport(userMessage string) (string, bool) {
|
||||||
|
orb, err := orchestrator.NewForInactiveProvider(s.config)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
orb.SetSystemPrompt("Tu es un analyste. Pour la question ci-dessous, produis un rapport bref (max 15 lignes) qui : (1) reformule l'objectif de l'utilisateur, (2) liste les points à clarifier ou les risques, (3) suggère une approche structurée. Pas de code, pas d'action — uniquement de l'analyse.")
|
||||||
|
resp, err := orb.SendNoTools(userMessage)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(resp), true
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message {
|
func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message {
|
||||||
history := s.convStore.Get()
|
history := s.convStore.Get()
|
||||||
start := 0
|
|
||||||
if len(history) > contextWindowMessages {
|
sysPromptTokens := utf8.RuneCountInString(agent.StudioSystemPrompt())/charsPerToken + 50
|
||||||
start = len(history) - contextWindowMessages
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
messages := make([]orchestrator.Message, 0, len(history[start:])+1)
|
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()
|
summary := s.convStore.GetSummary()
|
||||||
if summary != "" {
|
if summary != "" && (start > 0 || hasSummarized) {
|
||||||
messages = append(messages, orchestrator.Message{
|
messages = append(messages, orchestrator.Message{
|
||||||
Role: "system",
|
Role: "system",
|
||||||
Content: "Résumé de la conversation précédente:\n" + summary,
|
Content: orchestrator.TextContent("Résumé de la conversation précédente:\n" + summary),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, m := range history[start:] {
|
for _, m := range history[start:] {
|
||||||
content := m.Content
|
if m.Role == "system" {
|
||||||
if m.Role == "assistant" {
|
|
||||||
var parsed struct {
|
|
||||||
Content string `json:"content"`
|
|
||||||
ToolCalls []struct {
|
|
||||||
ToolCallID string `json:"tool_call_id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Args string `json:"args"`
|
|
||||||
} `json:"tool_calls"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(content), &parsed); err == nil && parsed.Content != "" {
|
|
||||||
content = parsed.Content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
role := m.Role
|
|
||||||
if role == "system" {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
displayContent := extractDisplayContent(m.Role, m.Content)
|
||||||
messages = append(messages, orchestrator.Message{
|
messages = append(messages, orchestrator.Message{
|
||||||
Role: role,
|
Role: m.Role,
|
||||||
Content: content,
|
Content: orchestrator.TextContent(displayContent),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
messages = append(messages, orchestrator.Message{
|
messages = append(messages, orchestrator.Message{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: userMessage,
|
Content: orchestrator.TextContent(userMessage),
|
||||||
})
|
})
|
||||||
|
|
||||||
return messages
|
return messages
|
||||||
@@ -195,8 +428,7 @@ func (s *Server) autoSummarize() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.convStore.SetSummary(result)
|
s.convStore.SetSummary(result)
|
||||||
s.convStore.TrimOld(len(messages) - half)
|
s.convStore.MarkSummarized(half)
|
||||||
s.convStore.Add("system", "[Conversation résumée automatiquement]")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -208,8 +440,8 @@ func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, map[string]interface{}{
|
writeJSON(w, map[string]interface{}{
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"tokens": s.convStore.ApproxTokenCount(),
|
"tokens": s.convStore.ApproxTokenCount(),
|
||||||
"max_tokens": maxTokensApprox,
|
"max_tokens": contextWindowTokens,
|
||||||
"summarize_at": summarizeThreshold,
|
"summarize_at": int(float64(contextWindowTokens) * summarizeRatio),
|
||||||
"summary": s.convStore.GetSummary(),
|
"summary": s.convStore.GetSummary(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,21 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
const summarizePrompt = `Résume la conversation suivante de manière concise et structurée. Garde les points clés, les décisions prises, le contexte technique important. Le résumé doit permettre de continuer la conversation sans perte de contexte. Réponds uniquement avec le résumé, sans meta-commentaire.`
|
const summarizePrompt = `Résume cette conversation de manière ultra-concise et structurée.
|
||||||
|
|
||||||
|
CONSERVE :
|
||||||
|
- Les décisions techniques prises et leur rationale
|
||||||
|
- Les configurations modifiées (noms exacts, valeurs)
|
||||||
|
- Les fichiers/chemins manipulés
|
||||||
|
- Les erreurs rencontrées et leurs résolutions
|
||||||
|
- Le contexte nécessaire pour continuer
|
||||||
|
|
||||||
|
ÉLIMINE :
|
||||||
|
- Les échanges de politesse
|
||||||
|
- Les tentatives infructueuses (sauf si la solution n'a pas été trouvée)
|
||||||
|
- Les sorties d'outils brutes (garde seulement les conclusions)
|
||||||
|
|
||||||
|
FORMAT : Markdown structuré avec sections. Max 500 mots. Pas de méta-commentaire.`
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, data interface{}) {
|
func writeJSON(w http.ResponseWriter, data interface{}) {
|
||||||
json.NewEncoder(w).Encode(data)
|
json.NewEncoder(w).Encode(data)
|
||||||
|
|||||||
@@ -60,10 +60,17 @@ func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var currentMap map[string]interface{}
|
var currentMap map[string]interface{}
|
||||||
json.Unmarshal(currentJSON, ¤tMap)
|
if err := json.Unmarshal(currentJSON, ¤tMap); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var updates map[string]interface{}
|
var updates map[string]interface{}
|
||||||
body, _ := io.ReadAll(r.Body)
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := json.Unmarshal(body, &updates); err != nil {
|
if err := json.Unmarshal(body, &updates); err != nil {
|
||||||
writeError(w, err.Error(), http.StatusBadRequest)
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -71,8 +78,15 @@ func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
deepMerge(currentMap, updates)
|
deepMerge(currentMap, updates)
|
||||||
|
|
||||||
mergedJSON, _ := json.Marshal(currentMap)
|
mergedJSON, err := json.Marshal(currentMap)
|
||||||
json.Unmarshal(mergedJSON, &s.config.Profile)
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(mergedJSON, &s.config.Profile); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := config.Save(s.config); err != nil {
|
if err := config.Save(s.config); err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
@@ -122,7 +136,7 @@ func (s *Server) handleSaveProvider(w http.ResponseWriter, r *http.Request) {
|
|||||||
found := false
|
found := false
|
||||||
for i := range s.config.AI.Providers {
|
for i := range s.config.AI.Providers {
|
||||||
if s.config.AI.Providers[i].Name == body.Name {
|
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
|
s.config.AI.Providers[i].APIKey = body.APIKey
|
||||||
}
|
}
|
||||||
if body.Model != "" {
|
if body.Model != "" {
|
||||||
@@ -173,6 +187,14 @@ func (s *Server) handleValidateProvider(w http.ResponseWriter, r *http.Request)
|
|||||||
writeError(w, "api_key required", http.StatusBadRequest)
|
writeError(w, "api_key required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if body.APIKey == "***" {
|
||||||
|
for _, p := range s.config.AI.Providers {
|
||||||
|
if p.Name == body.Name {
|
||||||
|
body.APIKey = p.APIKey
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
baseURL := body.BaseURL
|
baseURL := body.BaseURL
|
||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
@@ -187,6 +209,8 @@ func (s *Server) handleValidateProvider(w http.ResponseWriter, r *http.Request)
|
|||||||
switch body.Name {
|
switch body.Name {
|
||||||
case "minimax":
|
case "minimax":
|
||||||
baseURL = "https://api.minimax.io/v1"
|
baseURL = "https://api.minimax.io/v1"
|
||||||
|
case "mimo":
|
||||||
|
baseURL = "https://token-plan-ams.xiaomimimo.com/v1"
|
||||||
case "openai":
|
case "openai":
|
||||||
baseURL = "https://api.openai.com/v1"
|
baseURL = "https://api.openai.com/v1"
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
@@ -264,7 +288,7 @@ func (s *Server) handleSaveTerminalSettings(w http.ResponseWriter, r *http.Reque
|
|||||||
writeError(w, err.Error(), http.StatusBadRequest)
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if body.FontSize > 0 {
|
if body.FontSize > 0 && body.FontSize <= 72 {
|
||||||
s.config.Terminal.FontSize = body.FontSize
|
s.config.Terminal.FontSize = body.FontSize
|
||||||
}
|
}
|
||||||
if body.FontFamily != "" {
|
if body.FontFamily != "" {
|
||||||
@@ -333,30 +357,25 @@ func (s *Server) handleApplyStarshipTheme(w http.ResponseWriter, r *http.Request
|
|||||||
body.Theme = s.config.Terminal.PromptTheme
|
body.Theme = s.config.Terminal.PromptTheme
|
||||||
}
|
}
|
||||||
|
|
||||||
cfgDir, err := config.ConfigDir()
|
themeFile := ApplyStarshipTheme(body.Theme)
|
||||||
if err != nil {
|
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
s.config.Terminal.PromptTheme = body.Theme
|
||||||
return
|
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")
|
starshipDir := filepath.Join(cfgDir, "starship")
|
||||||
if err := os.MkdirAll(starshipDir, 0755); err != nil {
|
os.MkdirAll(starshipDir, 0755)
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
themeFile := filepath.Join(starshipDir, "starship.toml")
|
themeFile := filepath.Join(starshipDir, "starship.toml")
|
||||||
|
|
||||||
themeContent := getStarshipThemeConfig(body.Theme)
|
themeContent := getStarshipThemeConfig(theme)
|
||||||
if err := os.WriteFile(themeFile, []byte(themeContent), 0644); err != nil {
|
os.WriteFile(themeFile, []byte(themeContent), 0644)
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
shellRCs := []string{
|
for _, rc := range []string{filepath.Join(home, ".bashrc"), filepath.Join(home, ".zshrc")} {
|
||||||
filepath.Join(home, ".bashrc"),
|
|
||||||
filepath.Join(home, ".zshrc"),
|
|
||||||
}
|
|
||||||
for _, rc := range shellRCs {
|
|
||||||
if _, err := os.Stat(rc); err != nil {
|
if _, err := os.Stat(rc); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -373,10 +392,7 @@ func (s *Server) handleApplyStarshipTheme(w http.ResponseWriter, r *http.Request
|
|||||||
f.Close()
|
f.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
s.config.Terminal.PromptTheme = body.Theme
|
return themeFile
|
||||||
config.Save(s.config)
|
|
||||||
|
|
||||||
writeJSON(w, map[string]interface{}{"status": "ok", "config": themeFile})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getStarshipThemeConfig(theme string) string {
|
func getStarshipThemeConfig(theme string) string {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/agent"
|
||||||
"github.com/muyue/muyue/internal/lsp"
|
"github.com/muyue/muyue/internal/lsp"
|
||||||
"github.com/muyue/muyue/internal/mcp"
|
"github.com/muyue/muyue/internal/mcp"
|
||||||
"github.com/muyue/muyue/internal/scanner"
|
"github.com/muyue/muyue/internal/scanner"
|
||||||
@@ -24,7 +25,7 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
"name": version.Name,
|
"name": version.Name,
|
||||||
"version": version.Version,
|
"version": version.Version,
|
||||||
"author": version.Author,
|
"author": version.Author,
|
||||||
"sudo": os.Geteuid() == 0,
|
"sudo": !agent.NeedsSudoPassword(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,8 +80,23 @@ func (s *Server) handleProviders(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, "no config", http.StatusNotFound)
|
writeError(w, "no config", http.StatusNotFound)
|
||||||
return
|
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{}{
|
writeJSON(w, map[string]interface{}{
|
||||||
"providers": s.config.AI.Providers,
|
"providers": masked,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +106,9 @@ func (s *Server) handleSkills(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
for i := range list {
|
||||||
|
list[i].Deployed = skills.IsDeployed(list[i].Name)
|
||||||
|
}
|
||||||
writeJSON(w, map[string]interface{}{
|
writeJSON(w, map[string]interface{}{
|
||||||
"skills": list,
|
"skills": list,
|
||||||
"count": len(list),
|
"count": len(list),
|
||||||
@@ -199,9 +218,20 @@ func (s *Server) handleLSPAutoInstall(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewDecoder(r.Body).Decode(&body)
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
if body.ProjectDir == "" {
|
if body.ProjectDir == "" {
|
||||||
home, _ := os.UserHomeDir()
|
|
||||||
body.ProjectDir = home
|
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)
|
results, err := lsp.AutoInstallForProject(body.ProjectDir)
|
||||||
@@ -530,6 +560,44 @@ func (s *Server) handleProvidersQuota(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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":
|
case "claude", "anthropic":
|
||||||
// Claude Code n'a pas d'API externe, vérifier l'installation
|
// Claude Code n'a pas d'API externe, vérifier l'installation
|
||||||
claudePath := "/usr/bin/claude"
|
claudePath := "/usr/bin/claude"
|
||||||
@@ -546,6 +614,15 @@ func (s *Server) handleProvidersQuota(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, map[string]interface{}{"providers": results})
|
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) {
|
func (s *Server) handleRecentCommands(w http.ResponseWriter, r *http.Request) {
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
type cmdEntry struct {
|
type cmdEntry struct {
|
||||||
@@ -679,93 +756,6 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) handleSystemMetrics(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSystemMetrics(w http.ResponseWriter, r *http.Request) {
|
||||||
m := sysMetrics{}
|
m := collectSystemMetrics()
|
||||||
|
|
||||||
// CPU from /proc/stat
|
|
||||||
if data, err := os.ReadFile("/proc/stat"); err == nil {
|
|
||||||
line := strings.Split(string(data), "\n")[0]
|
|
||||||
fields := strings.Fields(line)
|
|
||||||
if len(fields) >= 5 {
|
|
||||||
var idle, total float64
|
|
||||||
for i := 1; i < len(fields) && i <= 4; i++ {
|
|
||||||
var v float64
|
|
||||||
fmt.Sscanf(fields[i], "%f", &v)
|
|
||||||
total += v
|
|
||||||
if i == 4 {
|
|
||||||
idle = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if lastCPUSet {
|
|
||||||
dIdle := idle - lastCPU[0]
|
|
||||||
dTotal := total - lastCPU[1]
|
|
||||||
if dTotal > 0 {
|
|
||||||
m.CPUPercent = (1 - dIdle/dTotal) * 100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lastCPU = [2]float64{idle, total}
|
|
||||||
lastCPUSet = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Memory from /proc/meminfo
|
|
||||||
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
|
|
||||||
var memTotal, memAvailable float64
|
|
||||||
for _, line := range strings.Split(string(data), "\n") {
|
|
||||||
fields := strings.Fields(line)
|
|
||||||
if len(fields) < 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var v float64
|
|
||||||
fmt.Sscanf(fields[1], "%f", &v)
|
|
||||||
switch fields[0] {
|
|
||||||
case "MemTotal:":
|
|
||||||
memTotal = v
|
|
||||||
case "MemAvailable:":
|
|
||||||
memAvailable = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if memTotal > 0 {
|
|
||||||
m.MemTotalMB = memTotal / 1024
|
|
||||||
m.MemUsedMB = (memTotal - memAvailable) / 1024
|
|
||||||
m.MemPercent = (memTotal - memAvailable) / memTotal * 100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Network from /proc/net/dev
|
|
||||||
if data, err := os.ReadFile("/proc/net/dev"); err == nil {
|
|
||||||
var rxBytes, txBytes float64
|
|
||||||
for _, line := range strings.Split(string(data), "\n")[2:] {
|
|
||||||
fields := strings.Fields(line)
|
|
||||||
if len(fields) < 10 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
iface := strings.TrimSuffix(fields[0], ":")
|
|
||||||
if iface == "lo" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var rx, tx float64
|
|
||||||
fmt.Sscanf(fields[1], "%f", &rx)
|
|
||||||
fmt.Sscanf(fields[9], "%f", &tx)
|
|
||||||
rxBytes += rx
|
|
||||||
txBytes += tx
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
if !lastNetTs.IsZero() {
|
|
||||||
elapsed := now.Sub(lastNetTs).Seconds()
|
|
||||||
if elapsed > 0 {
|
|
||||||
m.NetRxKBs = (rxBytes - lastNet[0]) / 1024 / elapsed
|
|
||||||
m.NetTxKBs = (txBytes - lastNet[1]) / 1024 / elapsed
|
|
||||||
if m.NetRxKBs < 0 {
|
|
||||||
m.NetRxKBs = 0
|
|
||||||
}
|
|
||||||
if m.NetTxKBs < 0 {
|
|
||||||
m.NetTxKBs = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lastNet = [2]float64{rxBytes, txBytes}
|
|
||||||
lastNetTs = now
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, m)
|
writeJSON(w, m)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -226,6 +226,29 @@ func (s *Server) handleSkillsDeploy(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, map[string]string{"status": "all deployed"})
|
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) {
|
func (s *Server) handleSSHConnections(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != "GET" {
|
if r.Method != "GET" {
|
||||||
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -8,6 +9,8 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/muyue/muyue/internal/agent"
|
"github.com/muyue/muyue/internal/agent"
|
||||||
"github.com/muyue/muyue/internal/orchestrator"
|
"github.com/muyue/muyue/internal/orchestrator"
|
||||||
@@ -51,81 +54,89 @@ func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
orb.SetSystemPrompt(s.buildShellSystemPromptV2(req))
|
orb.SetSystemPrompt(s.buildShellSystemPrompt(req))
|
||||||
|
orb.SetTools(s.shellAgentToolsJSON)
|
||||||
|
|
||||||
if req.Stream {
|
if req.Stream {
|
||||||
s.handleShellChatStreamV2(w, orb)
|
s.handleShellChatStream(w, orb)
|
||||||
} else {
|
} else {
|
||||||
s.handleShellChatNonStreamV2(w, orb)
|
s.handleShellChatNonStream(w, orb)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) buildShellSystemPromptV2(_ ShellChatRequest) string {
|
func (s *Server) buildShellSystemPrompt(req ShellChatRequest) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
sb.WriteString(`Tu es l'Analyste Système de Muyue. Tu es un expert en administration système et développement.
|
sb.WriteString(shellSystemPromptBase)
|
||||||
Tu aides l'utilisateur à comprendre son système, diagnostiquer des problèmes, et optimiser son environnement.
|
|
||||||
|
|
||||||
RÈGLES STRICTES:
|
|
||||||
- Tu ne peux JAMAIS exécuter de commande ou de code
|
|
||||||
- Tu ne peux que analyser, expliquer, et proposer des solutions
|
|
||||||
- Quand tu proposes du code ou des commandes, mets-les dans des blocs de code markdown avec le langage spécifié
|
|
||||||
- L'utilisateur pourra les copier ou les envoyer directement au terminal depuis les boutons
|
|
||||||
|
|
||||||
`)
|
|
||||||
|
|
||||||
analysis := LoadSystemAnalysis()
|
analysis := LoadSystemAnalysis()
|
||||||
if analysis != "" {
|
if analysis != "" {
|
||||||
sb.WriteString("=== ANALYSE SYSTÈME ACTUELLE ===\n")
|
sb.WriteString("<system_context>\n")
|
||||||
sb.WriteString(analysis)
|
sb.WriteString(analysis)
|
||||||
sb.WriteString("\n=== FIN DE L'ANALYSE ===\n\n")
|
sb.WriteString("\n</system_context>\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
sb.WriteString(fmt.Sprintf("OS: %s/%s\n", runtime.GOOS, runtime.GOARCH))
|
sb.WriteString(fmt.Sprintf("OS: %s/%s\n", runtime.GOOS, runtime.GOARCH))
|
||||||
if hostname, err := os.Hostname(); err == nil {
|
if hostname, err := os.Hostname(); err == nil {
|
||||||
sb.WriteString("Hostname: " + hostname + "\n")
|
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()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleShellChatStreamV2(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
|
func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
|
||||||
SetupSSEHeaders(w)
|
SetupSSEHeaders(w)
|
||||||
flusher, canFlush := w.(http.Flusher)
|
flusher, canFlush := w.(http.Flusher)
|
||||||
sseWriter := NewSSEWriter(w)
|
sseWriter := NewSSEWriter(w)
|
||||||
|
|
||||||
// Rebuild history into orchestrator
|
ctx := context.Background()
|
||||||
history := s.shellConvStore.Get()
|
messages := s.buildShellContextMessages()
|
||||||
for _, m := range history[:len(history)-1] { // all except last user msg
|
|
||||||
if m.Role == "system" {
|
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
|
||||||
continue
|
engine.SetLimiter(s.AcquireAgentSlot)
|
||||||
|
engine.OnChunk(func(data map[string]interface{}) {
|
||||||
|
if data == nil {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
// Pre-load orchestrator history
|
sseWriter.Write(data)
|
||||||
orb.AppendHistory(orchestrator.Message{Role: m.Role, Content: m.Content})
|
|
||||||
}
|
|
||||||
|
|
||||||
lastUserMsg := history[len(history)-1].Content
|
|
||||||
|
|
||||||
var finalContent string
|
|
||||||
result, err := orb.SendStream(lastUserMsg, func(chunk string) {
|
|
||||||
finalContent = chunk
|
|
||||||
sseWriter.Write(map[string]interface{}{"content": chunk})
|
|
||||||
if canFlush {
|
if canFlush {
|
||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
finalContent, allToolCalls, allToolResults, err := engine.RunWithTools(ctx, messages)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sseWriter.Write(map[string]interface{}{"error": err.Error()})
|
sseWriter.Write(map[string]interface{}{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
content := result
|
storeContent := finalContent
|
||||||
if content == "" {
|
if len(allToolCalls) > 0 {
|
||||||
content = finalContent
|
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.shellConvStore.Add("assistant", cleanThinkingTags(content))
|
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
|
||||||
|
|
||||||
sseWriter.Write(map[string]interface{}{
|
sseWriter.Write(map[string]interface{}{
|
||||||
"done": "true",
|
"done": "true",
|
||||||
@@ -133,30 +144,85 @@ func (s *Server) handleShellChatStreamV2(w http.ResponseWriter, orb *orchestrato
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleShellChatNonStreamV2(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
|
func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
|
||||||
history := s.shellConvStore.Get()
|
ctx := context.Background()
|
||||||
for _, m := range history[:len(history)-1] {
|
messages := s.buildShellContextMessages()
|
||||||
if m.Role == "system" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
orb.AppendHistory(orchestrator.Message{Role: m.Role, Content: m.Content})
|
|
||||||
}
|
|
||||||
|
|
||||||
lastUserMsg := history[len(history)-1].Content
|
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
|
||||||
|
engine.SetLimiter(s.AcquireAgentSlot)
|
||||||
result, err := orb.Send(lastUserMsg)
|
finalContent, err := engine.RunNonStream(ctx, messages)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.shellConvStore.Add("assistant", cleanThinkingTags(result))
|
s.shellConvStore.Add("assistant", finalContent)
|
||||||
|
|
||||||
|
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
|
||||||
|
|
||||||
writeJSON(w, map[string]interface{}{
|
writeJSON(w, map[string]interface{}{
|
||||||
"content": result,
|
"content": finalContent,
|
||||||
"tokens": s.shellConvStore.ApproxTokens(),
|
"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) {
|
func (s *Server) handleShellChatHistory(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != "GET" {
|
if r.Method != "GET" {
|
||||||
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
@@ -252,15 +318,33 @@ func (s *Server) handleShellAnalyze(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
orb.SetSystemPrompt(agent.StudioSystemPrompt())
|
orb.SetSystemPrompt(agent.StudioSystemPrompt())
|
||||||
|
|
||||||
analysisPrompt := `Tu es un expert en administration système. Analyse les informations suivantes sur le système de l'utilisateur.
|
analysisPrompt := `Tu es un expert en administration système. Analyse les informations suivantes et génère un rapport structuré en markdown.
|
||||||
Génère un rapport d'analyse concis et structuré en markdown qui inclut:
|
|
||||||
1. Un résumé de l'état du système
|
|
||||||
2. Les points d'attention (performance, sécurité, configuration)
|
|
||||||
3. Des recommandations spécifiques d'optimisation
|
|
||||||
4. Les outils manquants qui pourraient être utiles
|
|
||||||
5. L'état du réseau et des connexions
|
|
||||||
|
|
||||||
Sois concret et technique. Le rapport sera utilisé comme contexte pour un assistant terminal.
|
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()
|
` + sysInfo.String()
|
||||||
|
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ func (s *Server) handleWorkflowPlan(w http.ResponseWriter, r *http.Request) {
|
|||||||
engine, _ = workflow.NewEngine(s.agentRegistry)
|
engine, _ = workflow.NewEngine(s.agentRegistry)
|
||||||
}
|
}
|
||||||
|
|
||||||
wf := engine.Create("Plan: "+body.Goal[:min(len(body.Goal), 30)], body.Goal, "plan_execute", steps)
|
wf := engine.Create("Plan: "+truncateString(body.Goal, 30), body.Goal, "plan_execute", steps)
|
||||||
writeJSON(w, wf)
|
writeJSON(w, wf)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +188,6 @@ func (s *Server) handleWorkflowExecuteStream(w http.ResponseWriter, engine *work
|
|||||||
w.Header().Set("Content-Type", "text/event-stream")
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
w.Header().Set("Connection", "keep-alive")
|
w.Header().Set("Connection", "keep-alive")
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
flusher, canFlush := w.(http.Flusher)
|
flusher, canFlush := w.(http.Flusher)
|
||||||
|
|
||||||
@@ -250,9 +249,10 @@ func (s *Server) handleWorkflowApprove(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, map[string]string{"status": "approved"})
|
writeJSON(w, map[string]string{"status": "approved"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func min(a, b int) int {
|
func truncateString(s string, max int) string {
|
||||||
if a < b {
|
runes := []rune(s)
|
||||||
return a
|
if len(runes) <= max {
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
return b
|
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
|
||||||
|
}
|
||||||
@@ -2,25 +2,34 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/muyue/muyue/internal/agent"
|
"github.com/muyue/muyue/internal/agent"
|
||||||
"github.com/muyue/muyue/internal/config"
|
"github.com/muyue/muyue/internal/config"
|
||||||
|
"github.com/muyue/muyue/internal/installer"
|
||||||
"github.com/muyue/muyue/internal/scanner"
|
"github.com/muyue/muyue/internal/scanner"
|
||||||
"github.com/muyue/muyue/internal/workflow"
|
"github.com/muyue/muyue/internal/workflow"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
config *config.MuyueConfig
|
config *config.MuyueConfig
|
||||||
scanResult *scanner.ScanResult
|
scanResult *scanner.ScanResult
|
||||||
mux *http.ServeMux
|
mux *http.ServeMux
|
||||||
convStore *ConversationStore
|
convStore *ConversationStore
|
||||||
shellConvStore *ShellConvStore
|
shellConvStore *ShellConvStore
|
||||||
agentRegistry *agent.Registry
|
consumption *consumptionStore
|
||||||
agentToolsJSON json.RawMessage
|
agentRegistry *agent.Registry
|
||||||
workflowEngine *workflow.Engine
|
agentToolsJSON json.RawMessage
|
||||||
|
shellAgentRegistry *agent.Registry
|
||||||
|
shellAgentToolsJSON json.RawMessage
|
||||||
|
workflowEngine *workflow.Engine
|
||||||
|
browserTestStore *BrowserTestStore
|
||||||
|
activeCrushAgents atomic.Int32
|
||||||
|
activeClaudeAgents atomic.Int32
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(cfg *config.MuyueConfig) *Server {
|
func NewServer(cfg *config.MuyueConfig) *Server {
|
||||||
@@ -40,7 +49,7 @@ func NewServer(cfg *config.MuyueConfig) *Server {
|
|||||||
}
|
}
|
||||||
// Save initial config to establish the file for first-time usage
|
// Save initial config to establish the file for first-time usage
|
||||||
if err := config.Save(defaultCfg); err != nil {
|
if err := config.Save(defaultCfg); err != nil {
|
||||||
log.Printf("config: initial save failed: %v", err)
|
_ = err
|
||||||
}
|
}
|
||||||
cfg = defaultCfg
|
cfg = defaultCfg
|
||||||
}
|
}
|
||||||
@@ -48,11 +57,26 @@ func NewServer(cfg *config.MuyueConfig) *Server {
|
|||||||
s.scanResult = scanner.ScanSystem()
|
s.scanResult = scanner.ScanSystem()
|
||||||
s.convStore = NewConversationStore()
|
s.convStore = NewConversationStore()
|
||||||
s.shellConvStore = NewShellConvStore()
|
s.shellConvStore = NewShellConvStore()
|
||||||
|
s.consumption = newConsumptionStore()
|
||||||
s.agentRegistry = agent.DefaultRegistry()
|
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()
|
tools := s.agentRegistry.OpenAITools()
|
||||||
toolsJSON, _ := json.Marshal(tools)
|
toolsJSON, _ := json.Marshal(tools)
|
||||||
s.agentToolsJSON = json.RawMessage(toolsJSON)
|
s.agentToolsJSON = json.RawMessage(toolsJSON)
|
||||||
|
|
||||||
|
s.shellAgentRegistry = agent.NewRegistry()
|
||||||
|
terminalTool, _ := agent.NewTerminalTool()
|
||||||
|
s.shellAgentRegistry.Register(terminalTool)
|
||||||
|
shellTools := s.shellAgentRegistry.OpenAITools()
|
||||||
|
shellToolsJSON, _ := json.Marshal(shellTools)
|
||||||
|
s.shellAgentToolsJSON = json.RawMessage(shellToolsJSON)
|
||||||
|
|
||||||
s.workflowEngine, _ = workflow.NewEngine(s.agentRegistry)
|
s.workflowEngine, _ = workflow.NewEngine(s.agentRegistry)
|
||||||
|
s.initStarship()
|
||||||
s.routes()
|
s.routes()
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
@@ -84,6 +108,7 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("/api/starship/apply-theme", s.handleApplyStarshipTheme)
|
s.mux.HandleFunc("/api/starship/apply-theme", s.handleApplyStarshipTheme)
|
||||||
s.mux.HandleFunc("/api/providers/validate", s.handleValidateProvider)
|
s.mux.HandleFunc("/api/providers/validate", s.handleValidateProvider)
|
||||||
s.mux.HandleFunc("/api/update/run", s.handleRunUpdate)
|
s.mux.HandleFunc("/api/update/run", s.handleRunUpdate)
|
||||||
|
s.mux.HandleFunc("/api/images/", s.handleServeImage)
|
||||||
s.mux.HandleFunc("/api/chat", s.handleChat)
|
s.mux.HandleFunc("/api/chat", s.handleChat)
|
||||||
s.mux.HandleFunc("/api/chat/history", s.handleChatHistory)
|
s.mux.HandleFunc("/api/chat/history", s.handleChatHistory)
|
||||||
s.mux.HandleFunc("/api/chat/clear", s.handleChatClear)
|
s.mux.HandleFunc("/api/chat/clear", s.handleChatClear)
|
||||||
@@ -107,6 +132,7 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("/api/conversations/", s.handleDeleteConversation)
|
s.mux.HandleFunc("/api/conversations/", s.handleDeleteConversation)
|
||||||
s.mux.HandleFunc("/api/lsp/install", s.handleLSPInstall)
|
s.mux.HandleFunc("/api/lsp/install", s.handleLSPInstall)
|
||||||
s.mux.HandleFunc("/api/skills/deploy", s.handleSkillsDeploy)
|
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/connections", s.handleSSHConnections)
|
||||||
s.mux.HandleFunc("/api/ssh/test", s.handleSSHTest)
|
s.mux.HandleFunc("/api/ssh/test", s.handleSSHTest)
|
||||||
|
|
||||||
@@ -120,20 +146,30 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("/api/skills/export", s.handleSkillExport)
|
s.mux.HandleFunc("/api/skills/export", s.handleSkillExport)
|
||||||
s.mux.HandleFunc("/api/skills/import", s.handleSkillImport)
|
s.mux.HandleFunc("/api/skills/import", s.handleSkillImport)
|
||||||
s.mux.HandleFunc("/api/dashboard/status", s.handleDashboardStatus)
|
s.mux.HandleFunc("/api/dashboard/status", s.handleDashboardStatus)
|
||||||
|
s.mux.HandleFunc("/api/ai/task", s.handleAITask)
|
||||||
s.mux.HandleFunc("/api/providers/quota", s.handleProvidersQuota)
|
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/recent-commands", s.handleRecentCommands)
|
||||||
s.mux.HandleFunc("/api/running-processes", s.handleRunningProcesses)
|
s.mux.HandleFunc("/api/running-processes", s.handleRunningProcesses)
|
||||||
s.mux.HandleFunc("/api/system/metrics", s.handleSystemMetrics)
|
s.mux.HandleFunc("/api/system/metrics", s.handleSystemMetrics)
|
||||||
|
|
||||||
|
s.mux.HandleFunc("/api/test/snippet", s.handleBrowserTestSnippet)
|
||||||
|
s.mux.HandleFunc("/api/test/sessions", s.handleBrowserTestSessions)
|
||||||
|
s.mux.HandleFunc("/api/test/console/", s.handleBrowserTestConsole)
|
||||||
|
s.mux.HandleFunc("/api/ws/browser-test", s.handleBrowserTestWS)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
if strings.HasPrefix(r.URL.Path, "/api/ws/") {
|
if strings.HasPrefix(r.URL.Path, "/api/ws/") || strings.HasPrefix(r.URL.Path, "/api/images/") {
|
||||||
s.mux.ServeHTTP(w, r)
|
s.mux.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
if origin := r.Header.Get("Origin"); isAllowedOrigin(origin) {
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||||
|
w.Header().Set("Vary", "Origin")
|
||||||
|
}
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
if r.Method == "OPTIONS" {
|
if r.Method == "OPTIONS" {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
@@ -141,3 +177,53 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
s.mux.ServeHTTP(w, r)
|
s.mux.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isAllowedOrigin(origin string) bool {
|
||||||
|
if origin == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(origin, "http://127.0.0.1"),
|
||||||
|
strings.HasPrefix(origin, "http://localhost"),
|
||||||
|
strings.HasPrefix(origin, "http://[::1]"),
|
||||||
|
strings.HasPrefix(origin, "https://127.0.0.1"),
|
||||||
|
strings.HasPrefix(origin, "https://localhost"),
|
||||||
|
strings.HasPrefix(origin, "https://[::1]"):
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxCrushAgents = 2
|
||||||
|
const maxClaudeAgents = 2
|
||||||
|
|
||||||
|
func (s *Server) AcquireAgentSlot(toolName string) (release func(), err error) {
|
||||||
|
var counter *atomic.Int32
|
||||||
|
var max int32
|
||||||
|
switch toolName {
|
||||||
|
case "crush_run":
|
||||||
|
counter = &s.activeCrushAgents
|
||||||
|
max = maxCrushAgents
|
||||||
|
case "claude_run":
|
||||||
|
counter = &s.activeClaudeAgents
|
||||||
|
max = maxClaudeAgents
|
||||||
|
default:
|
||||||
|
return func() {}, nil
|
||||||
|
}
|
||||||
|
current := counter.Add(1)
|
||||||
|
if current > max {
|
||||||
|
counter.Add(-1)
|
||||||
|
return nil, fmt.Errorf("Limite de %d agents %s atteinte", max, toolName)
|
||||||
|
}
|
||||||
|
return func() { counter.Add(-1) }, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) initStarship() {
|
||||||
|
if _, err := exec.LookPath("starship"); err != nil {
|
||||||
|
inst := installer.New(s.config)
|
||||||
|
if result := inst.InstallTool("starship"); !result.Success {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ApplyStarshipTheme(s.config.Terminal.PromptTheme)
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,63 @@ import (
|
|||||||
const shellMaxTokens = 100000
|
const shellMaxTokens = 100000
|
||||||
const shellCharsPerToken = 4
|
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 {
|
type ShellMessage struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
@@ -90,7 +147,14 @@ func (s *ShellConvStore) ApproxTokens() int {
|
|||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
total := 0
|
total := 0
|
||||||
for _, m := range s.msgs {
|
for _, m := range s.msgs {
|
||||||
total += utf8.RuneCountInString(m.Content) / shellCharsPerToken
|
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
|
return total
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,17 +3,16 @@ package api
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/creack/pty/v2"
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
"github.com/muyue/muyue/internal/config"
|
"github.com/muyue/muyue/internal/config"
|
||||||
)
|
)
|
||||||
@@ -48,7 +47,6 @@ type wsMessage struct {
|
|||||||
func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||||
conn, err := upgrader.Upgrade(w, r, nil)
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("ws upgrade: %v", err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
@@ -56,26 +54,23 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
var initMsg wsMessage
|
var initMsg wsMessage
|
||||||
_, raw, err := conn.ReadMessage()
|
_, raw, err := conn.ReadMessage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("terminal: read init message failed: %v", err)
|
|
||||||
conn.WriteJSON(wsMessage{Type: "error", Data: "failed to read init message"})
|
conn.WriteJSON(wsMessage{Type: "error", Data: "failed to read init message"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("terminal: init message received: %s", string(raw))
|
|
||||||
if err := json.Unmarshal(raw, &initMsg); err != nil {
|
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"})
|
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid init message"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("terminal: init type=%q data=%q", initMsg.Type, initMsg.Data)
|
|
||||||
|
|
||||||
var cmd *exec.Cmd
|
var cmd *exec.Cmd
|
||||||
|
|
||||||
if initMsg.Type == "ssh" && initMsg.Data != "" {
|
if initMsg.Type == "ssh" && initMsg.Data != "" {
|
||||||
var sshConf struct {
|
var sshConf struct {
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
KeyPath string `json:"key_path"`
|
KeyPath string `json:"key_path"`
|
||||||
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal([]byte(initMsg.Data), &sshConf); err != nil {
|
if err := json.Unmarshal([]byte(initMsg.Data), &sshConf); err != nil {
|
||||||
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid ssh config"})
|
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid ssh config"})
|
||||||
@@ -98,63 +93,77 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
sshArgs = append(sshArgs, fmt.Sprintf("%s@%s", sshConf.User, sshConf.Host))
|
sshArgs = append(sshArgs, fmt.Sprintf("%s@%s", sshConf.User, sshConf.Host))
|
||||||
|
|
||||||
cmd = exec.Command("ssh", sshArgs...)
|
if sshConf.Password != "" {
|
||||||
|
sshpassPath, err := exec.LookPath("sshpass")
|
||||||
|
if err == nil {
|
||||||
|
args := append([]string{"-e"}, "ssh")
|
||||||
|
args = append(args, sshArgs...)
|
||||||
|
cmd = exec.Command(sshpassPath, args...)
|
||||||
|
cmd.Env = append(os.Environ(), "SSHPASS="+sshConf.Password)
|
||||||
|
} else {
|
||||||
|
cmd = exec.Command("ssh", sshArgs...)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cmd = exec.Command("ssh", sshArgs...)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
shell := strings.TrimSpace(initMsg.Data)
|
shell := strings.TrimSpace(initMsg.Data)
|
||||||
log.Printf("terminal: requested shell=%q, trimmed=%q", initMsg.Data, shell)
|
|
||||||
if shell == "" {
|
if shell == "" {
|
||||||
shell = detectShell()
|
shell = detectShell()
|
||||||
log.Printf("terminal: auto-detected shell=%q", shell)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if shell == "" {
|
if shell == "" {
|
||||||
log.Printf("terminal: no shell detected, falling back to /bin/sh")
|
|
||||||
shell = "/bin/sh"
|
shell = "/bin/sh"
|
||||||
}
|
}
|
||||||
|
|
||||||
if path, err := exec.LookPath(shell); err == nil {
|
// Support "wsl -d <distro>" shell strings sent from the UI quick-access.
|
||||||
shell = path
|
if extra, ok := parseWSLShell(shell); ok {
|
||||||
log.Printf("terminal: resolved shell path=%q", shell)
|
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 {
|
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)})
|
||||||
conn.WriteJSON(wsMessage{Type: "error", Data: fmt.Sprintf("shell not found: %s (resolved from: %q)", shell, initMsg.Data)})
|
return
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
|
||||||
shellName := filepath.Base(shell)
|
shellName := filepath.Base(shell)
|
||||||
switch shellName {
|
switch shellName {
|
||||||
case "wsl":
|
case "wsl":
|
||||||
cmd = exec.Command(shell, "--shell-type", "login")
|
cmd = exec.Command(shell, "--shell-type", "login")
|
||||||
case "powershell", "pwsh":
|
case "powershell", "pwsh":
|
||||||
cmd = exec.Command(shell, "-NoLogo", "-NoProfile")
|
cmd = exec.Command(shell, "-NoLogo", "-NoProfile")
|
||||||
case "fish":
|
case "fish":
|
||||||
cmd = exec.Command(shell, "--login")
|
cmd = exec.Command(shell, "--login")
|
||||||
default:
|
default:
|
||||||
cmd = exec.Command(shell)
|
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)
|
session, err := startTermSession(cmd)
|
||||||
ptmx, err := pty.Start(cmd)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("terminal: pty start failed: %v", err)
|
|
||||||
conn.WriteJSON(wsMessage{Type: "error", Data: err.Error()})
|
conn.WriteJSON(wsMessage{Type: "error", Data: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("terminal: pty started successfully")
|
|
||||||
|
|
||||||
var once sync.Once
|
var once sync.Once
|
||||||
cleanup := func() {
|
cleanup := func() {
|
||||||
once.Do(func() {
|
once.Do(func() {
|
||||||
ptmx.Close()
|
session.Close()
|
||||||
if cmd.Process != nil {
|
session.Wait()
|
||||||
cmd.Process.Kill()
|
|
||||||
cmd.Wait()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
@@ -162,15 +171,17 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
go func() {
|
go func() {
|
||||||
buf := make([]byte, 4096)
|
buf := make([]byte, 4096)
|
||||||
for {
|
for {
|
||||||
n, err := ptmx.Read(buf)
|
n, err := session.Read(buf)
|
||||||
if err != nil {
|
if n > 0 {
|
||||||
cleanup()
|
if err := conn.WriteJSON(wsMessage{
|
||||||
return
|
Type: "output",
|
||||||
|
Data: string(buf[:n]),
|
||||||
|
}); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := conn.WriteJSON(wsMessage{
|
if err != nil {
|
||||||
Type: "output",
|
|
||||||
Data: string(buf[:n]),
|
|
||||||
}); err != nil {
|
|
||||||
cleanup()
|
cleanup()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -194,16 +205,13 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
switch msg.Type {
|
switch msg.Type {
|
||||||
case "input":
|
case "input":
|
||||||
if _, err := ptmx.Write([]byte(msg.Data)); err != nil {
|
if _, err := session.Write([]byte(msg.Data)); err != nil {
|
||||||
cleanup()
|
cleanup()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "resize":
|
case "resize":
|
||||||
if msg.Rows > 0 && msg.Cols > 0 {
|
if msg.Rows > 0 && msg.Cols > 0 {
|
||||||
pty.Setsize(ptmx, &pty.Winsize{
|
session.Resize(msg.Rows, msg.Cols)
|
||||||
Rows: msg.Rows,
|
|
||||||
Cols: msg.Cols,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,8 +219,15 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method == "GET" {
|
if r.Method == "GET" {
|
||||||
|
masked := make([]config.SSHConnection, len(s.config.Terminal.SSH))
|
||||||
|
for i, c := range s.config.Terminal.SSH {
|
||||||
|
masked[i] = c
|
||||||
|
if masked[i].Password != "" {
|
||||||
|
masked[i].Password = "***"
|
||||||
|
}
|
||||||
|
}
|
||||||
writeJSON(w, map[string]interface{}{
|
writeJSON(w, map[string]interface{}{
|
||||||
"ssh": s.config.Terminal.SSH,
|
"ssh": masked,
|
||||||
"system": detectSystemTerminals(),
|
"system": detectSystemTerminals(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -222,11 +237,12 @@ func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var body struct {
|
var body struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
KeyPath string `json:"key_path"`
|
KeyPath string `json:"key_path"`
|
||||||
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
writeError(w, err.Error(), http.StatusBadRequest)
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
@@ -240,12 +256,36 @@ func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request)
|
|||||||
body.Port = 22
|
body.Port = 22
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for i, c := range s.config.Terminal.SSH {
|
||||||
|
if c.Name == body.Name {
|
||||||
|
password := body.Password
|
||||||
|
if password == "***" {
|
||||||
|
password = c.Password
|
||||||
|
}
|
||||||
|
s.config.Terminal.SSH[i] = config.SSHConnection{
|
||||||
|
Name: body.Name,
|
||||||
|
Host: body.Host,
|
||||||
|
Port: body.Port,
|
||||||
|
User: body.User,
|
||||||
|
KeyPath: body.KeyPath,
|
||||||
|
Password: password,
|
||||||
|
}
|
||||||
|
if err := config.Save(s.config); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]string{"status": "ok"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
conn := config.SSHConnection{
|
conn := config.SSHConnection{
|
||||||
Name: body.Name,
|
Name: body.Name,
|
||||||
Host: body.Host,
|
Host: body.Host,
|
||||||
Port: body.Port,
|
Port: body.Port,
|
||||||
User: body.User,
|
User: body.User,
|
||||||
KeyPath: body.KeyPath,
|
KeyPath: body.KeyPath,
|
||||||
|
Password: body.Password,
|
||||||
}
|
}
|
||||||
if s.config.Terminal.SSH == nil {
|
if s.config.Terminal.SSH == nil {
|
||||||
s.config.Terminal.SSH = []config.SSHConnection{}
|
s.config.Terminal.SSH = []config.SSHConnection{}
|
||||||
@@ -297,6 +337,87 @@ func detectShell() string {
|
|||||||
return "/bin/sh"
|
return "/bin/sh"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listWSLDistros returns the list of installed WSL distribution names.
|
||||||
|
// Windows hosts only — returns nil on other platforms or if WSL is unavailable.
|
||||||
|
func listWSLDistros() []string {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out, err := exec.Command("wsl", "--list", "--quiet").Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// `wsl --list --quiet` outputs UTF-16LE on Windows. Strip BOM and decode best-effort.
|
||||||
|
raw := stripUTF16ToASCII(out)
|
||||||
|
var distros []string
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, line := range strings.Split(raw, "\n") {
|
||||||
|
name := strings.TrimSpace(line)
|
||||||
|
if name == "" || seen[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Skip default-marker arrows or annotations.
|
||||||
|
name = strings.TrimSpace(strings.TrimPrefix(name, "*"))
|
||||||
|
if name == "" || !validWSLName.MatchString(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[name] = true
|
||||||
|
distros = append(distros, name)
|
||||||
|
}
|
||||||
|
return distros
|
||||||
|
}
|
||||||
|
|
||||||
|
var validWSLName = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
|
||||||
|
|
||||||
|
// parseWSLShell recognises strings of the form "wsl -d <distro>" (and optionally
|
||||||
|
// "-u <user>") emitted by the Shell tab quick-access menu, returning the args
|
||||||
|
// to pass to the wsl binary. Returns ok=false otherwise.
|
||||||
|
func parseWSLShell(shell string) ([]string, bool) {
|
||||||
|
parts := strings.Fields(shell)
|
||||||
|
if len(parts) < 3 || parts[0] != "wsl" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
args := []string{}
|
||||||
|
i := 1
|
||||||
|
for i < len(parts) {
|
||||||
|
switch parts[i] {
|
||||||
|
case "-d", "--distribution":
|
||||||
|
if i+1 >= len(parts) || !validWSLName.MatchString(parts[i+1]) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
args = append(args, "-d", parts[i+1])
|
||||||
|
i += 2
|
||||||
|
case "-u", "--user":
|
||||||
|
if i+1 >= len(parts) || !validWSLName.MatchString(parts[i+1]) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
args = append(args, "-u", parts[i+1])
|
||||||
|
i += 2
|
||||||
|
default:
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(args) == 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return args, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripUTF16ToASCII(b []byte) string {
|
||||||
|
// Best-effort: keep only printable bytes (drop high bytes from UTF-16LE pairs).
|
||||||
|
var out []byte
|
||||||
|
for i := 0; i < len(b); i++ {
|
||||||
|
c := b[i]
|
||||||
|
if c == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c >= 32 && c < 127 || c == '\n' || c == '\r' || c == '\t' {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
func detectSystemTerminals() []map[string]string {
|
func detectSystemTerminals() []map[string]string {
|
||||||
var terminals []map[string]string
|
var terminals []map[string]string
|
||||||
|
|
||||||
@@ -309,10 +430,17 @@ func detectSystemTerminals() []map[string]string {
|
|||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
if _, err := exec.LookPath("wsl"); err == nil {
|
if _, err := exec.LookPath("wsl"); err == nil {
|
||||||
terminals = append(terminals, map[string]string{
|
terminals = append(terminals, map[string]string{
|
||||||
"type": "local",
|
"type": "local",
|
||||||
"name": "WSL",
|
"name": "WSL (default)",
|
||||||
"shell": "wsl",
|
"shell": "wsl",
|
||||||
})
|
})
|
||||||
|
for _, distro := range listWSLDistros() {
|
||||||
|
terminals = append(terminals, map[string]string{
|
||||||
|
"type": "local",
|
||||||
|
"name": "WSL: " + distro,
|
||||||
|
"shell": "wsl -d " + distro,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if _, err := exec.LookPath("powershell"); err == nil {
|
if _, err := exec.LookPath("powershell"); err == nil {
|
||||||
terminals = append(terminals, map[string]string{
|
terminals = append(terminals, map[string]string{
|
||||||
|
|||||||
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
@@ -128,6 +127,22 @@ var DEFAULT_TERMINAL_THEMES = map[string]TerminalTheme{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func migrateProviders(cfg *MuyueConfig) {
|
||||||
|
defaults := Default().AI.Providers
|
||||||
|
for _, dp := range defaults {
|
||||||
|
found := false
|
||||||
|
for _, p := range cfg.AI.Providers {
|
||||||
|
if p.Name == dp.Name {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
cfg.AI.Providers = append(cfg.AI.Providers, dp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func GetTerminalTheme(name string) TerminalTheme {
|
func GetTerminalTheme(name string) TerminalTheme {
|
||||||
if theme, ok := DEFAULT_TERMINAL_THEMES[name]; ok {
|
if theme, ok := DEFAULT_TERMINAL_THEMES[name]; ok {
|
||||||
return theme
|
return theme
|
||||||
@@ -146,7 +161,7 @@ func ConfigDir() (string, error) {
|
|||||||
if _, err := os.Stat(legacyDir); err == nil {
|
if _, err := os.Stat(legacyDir); err == nil {
|
||||||
if _, err := os.Stat(dir); err != nil {
|
if _, err := os.Stat(dir); err != nil {
|
||||||
if err := os.Rename(legacyDir, 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 +221,8 @@ func Load() (*MuyueConfig, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
migrateProviders(&cfg)
|
||||||
|
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,6 +286,12 @@ func Default() *MuyueConfig {
|
|||||||
BaseURL: "https://api.minimax.io/v1",
|
BaseURL: "https://api.minimax.io/v1",
|
||||||
Active: true,
|
Active: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "mimo",
|
||||||
|
Model: "mimo-v2.5-pro",
|
||||||
|
BaseURL: "https://token-plan-ams.xiaomimimo.com/v1",
|
||||||
|
Active: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "zai",
|
Name: "zai",
|
||||||
Model: "glm",
|
Model: "glm",
|
||||||
@@ -297,6 +320,7 @@ func Default() *MuyueConfig {
|
|||||||
|
|
||||||
cfg.Terminal.CustomPrompt = true
|
cfg.Terminal.CustomPrompt = true
|
||||||
cfg.Terminal.PromptTheme = "zerotwo"
|
cfg.Terminal.PromptTheme = "zerotwo"
|
||||||
|
cfg.Terminal.FontSize = 14
|
||||||
|
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -17,17 +16,53 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var thinkRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
|
var thinkRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
|
||||||
|
var providerToolBlockRegex = regexp.MustCompile(`(?s)<[a-zA-Z][a-zA-Z0-9]*:tool_call[^>]*>.*?</[a-zA-Z][a-zA-Z0-9]*:tool_call>`)
|
||||||
|
var providerTagRegex = regexp.MustCompile(`(?s)</?[a-zA-Z][a-zA-Z0-9]*:[a-zA-Z_]+[^>]*>`)
|
||||||
|
var xmlToolTagRegex = regexp.MustCompile(`(?s)</?(invoke|parameter|tool_call|tool_result)[^>]*>`)
|
||||||
|
var bracketToolCallRegex = regexp.MustCompile(`(?m)^\[(?:terminal|shell|bash|command|execute)\]\s*\{[^}]*\}\s*$`)
|
||||||
|
|
||||||
|
var streamBlockStartRegex = regexp.MustCompile(`<[a-zA-Z][a-zA-Z0-9]*:tool_call`)
|
||||||
|
var streamXmlStartRegex = regexp.MustCompile(`<(?:invoke|parameter|tool_call|tool_result)[\s>]`)
|
||||||
|
var streamBracketStartRegex = regexp.MustCompile(`\[(?:terminal|shell|bash|command|execute)\]\s*\{`)
|
||||||
|
|
||||||
const maxHistorySize = 100
|
const maxHistorySize = 100
|
||||||
|
|
||||||
|
type ContentPart struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
ImageURL *ImageURL `json:"image_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImageURL struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
type Message struct {
|
type Message struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content,omitempty"`
|
Content json.RawMessage `json:"content,omitempty"`
|
||||||
ToolCalls []ToolCallMsg `json:"tool_calls,omitempty"`
|
ToolCalls []ToolCallMsg `json:"tool_calls,omitempty"`
|
||||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TextContent(s string) json.RawMessage {
|
||||||
|
b, _ := json.Marshal(s)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func PartsContent(parts []ContentPart) json.RawMessage {
|
||||||
|
b, _ := json.Marshal(parts)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Message) ContentString() string {
|
||||||
|
var s string
|
||||||
|
if json.Unmarshal(m.Content, &s) == nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(m.Content)
|
||||||
|
}
|
||||||
|
|
||||||
type ToolCallMsg struct {
|
type ToolCallMsg struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
@@ -107,6 +142,37 @@ func New(cfg *config.MuyueConfig) (*Orchestrator, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewForProvider builds an orchestrator using a specific (non-active) provider,
|
||||||
|
// for the Advanced Reflection feature where the inactive provider produces a
|
||||||
|
// preliminary report before the active provider answers. Excludes the currently
|
||||||
|
// active provider from selection — picks the first other configured provider
|
||||||
|
// with a non-empty API key.
|
||||||
|
func NewForInactiveProvider(cfg *config.MuyueConfig) (*Orchestrator, error) {
|
||||||
|
var activeName string
|
||||||
|
for _, p := range cfg.AI.Providers {
|
||||||
|
if p.Active {
|
||||||
|
activeName = p.Name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range cfg.AI.Providers {
|
||||||
|
p := &cfg.AI.Providers[i]
|
||||||
|
if p.Name == activeName {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.APIKey == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return &Orchestrator{
|
||||||
|
config: cfg,
|
||||||
|
provider: p,
|
||||||
|
client: sharedHTTPClient,
|
||||||
|
history: []Message{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no inactive provider with API key configured")
|
||||||
|
}
|
||||||
|
|
||||||
func (o *Orchestrator) SetSystemPrompt(prompt string) {
|
func (o *Orchestrator) SetSystemPrompt(prompt string) {
|
||||||
o.systemPrompt = prompt
|
o.systemPrompt = prompt
|
||||||
}
|
}
|
||||||
@@ -139,11 +205,38 @@ func (o *Orchestrator) GetHistory() []Message {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendNoTools issues a one-shot, history-less request to this orchestrator's
|
||||||
|
// provider. Used by the Advanced Reflection feature so the inactive provider
|
||||||
|
// can produce a preliminary report without contaminating the active
|
||||||
|
// orchestrator's history or invoking tools.
|
||||||
|
func (o *Orchestrator) SendNoTools(userMessage string) (string, error) {
|
||||||
|
messages := make([]Message, 0, 2)
|
||||||
|
if o.systemPrompt != "" {
|
||||||
|
messages = append(messages, Message{Role: "system", Content: TextContent(o.systemPrompt)})
|
||||||
|
}
|
||||||
|
messages = append(messages, Message{Role: "user", Content: TextContent(userMessage)})
|
||||||
|
|
||||||
|
reqBody := ChatRequest{
|
||||||
|
Model: o.provider.Model,
|
||||||
|
Messages: messages,
|
||||||
|
Stream: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
chatResp, _, err := o.sendWithFallback(reqBody, "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(chatResp.Choices) == 0 {
|
||||||
|
return "", fmt.Errorf("empty response from provider")
|
||||||
|
}
|
||||||
|
return CleanAIResponse(chatResp.Choices[0].Message.Content), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (o *Orchestrator) Send(userMessage string) (string, error) {
|
func (o *Orchestrator) Send(userMessage string) (string, error) {
|
||||||
o.histMu.Lock()
|
o.histMu.Lock()
|
||||||
o.history = append(o.history, Message{
|
o.history = append(o.history, Message{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: userMessage,
|
Content: TextContent(userMessage),
|
||||||
})
|
})
|
||||||
|
|
||||||
if len(o.history) > maxHistorySize {
|
if len(o.history) > maxHistorySize {
|
||||||
@@ -152,7 +245,7 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
|
|||||||
|
|
||||||
messages := make([]Message, 0, len(o.history)+1)
|
messages := make([]Message, 0, len(o.history)+1)
|
||||||
if o.systemPrompt != "" {
|
if o.systemPrompt != "" {
|
||||||
messages = append(messages, Message{Role: "system", Content: o.systemPrompt})
|
messages = append(messages, Message{Role: "system", Content: TextContent(o.systemPrompt)})
|
||||||
}
|
}
|
||||||
messages = append(messages, o.history...)
|
messages = append(messages, o.history...)
|
||||||
|
|
||||||
@@ -169,11 +262,11 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
content := cleanAIResponse(chatResp.Choices[0].Message.Content)
|
content := CleanAIResponse(chatResp.Choices[0].Message.Content)
|
||||||
o.histMu.Lock()
|
o.histMu.Lock()
|
||||||
o.history = append(o.history, Message{
|
o.history = append(o.history, Message{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: content,
|
Content: TextContent(content),
|
||||||
})
|
})
|
||||||
_ = providerName
|
_ = providerName
|
||||||
o.histMu.Unlock()
|
o.histMu.Unlock()
|
||||||
@@ -185,7 +278,7 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str
|
|||||||
o.histMu.Lock()
|
o.histMu.Lock()
|
||||||
o.history = append(o.history, Message{
|
o.history = append(o.history, Message{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: userMessage,
|
Content: TextContent(userMessage),
|
||||||
})
|
})
|
||||||
|
|
||||||
if len(o.history) > maxHistorySize {
|
if len(o.history) > maxHistorySize {
|
||||||
@@ -194,7 +287,7 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str
|
|||||||
|
|
||||||
messages := make([]Message, 0, len(o.history)+1)
|
messages := make([]Message, 0, len(o.history)+1)
|
||||||
if o.systemPrompt != "" {
|
if o.systemPrompt != "" {
|
||||||
messages = append(messages, Message{Role: "system", Content: o.systemPrompt})
|
messages = append(messages, Message{Role: "system", Content: TextContent(o.systemPrompt)})
|
||||||
}
|
}
|
||||||
messages = append(messages, o.history...)
|
messages = append(messages, o.history...)
|
||||||
|
|
||||||
@@ -269,11 +362,11 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str
|
|||||||
return fullContent.String(), fmt.Errorf("read stream: %w", err)
|
return fullContent.String(), fmt.Errorf("read stream: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
content := cleanAIResponse(fullContent.String())
|
content := CleanAIResponse(fullContent.String())
|
||||||
o.histMu.Lock()
|
o.histMu.Lock()
|
||||||
o.history = append(o.history, Message{
|
o.history = append(o.history, Message{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: content,
|
Content: TextContent(content),
|
||||||
})
|
})
|
||||||
o.histMu.Unlock()
|
o.histMu.Unlock()
|
||||||
|
|
||||||
@@ -283,7 +376,7 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str
|
|||||||
func (o *Orchestrator) SendWithTools(messages []Message) (*ChatResponse, error) {
|
func (o *Orchestrator) SendWithTools(messages []Message) (*ChatResponse, error) {
|
||||||
fullMessages := make([]Message, 0, len(messages)+1)
|
fullMessages := make([]Message, 0, len(messages)+1)
|
||||||
if o.systemPrompt != "" {
|
if o.systemPrompt != "" {
|
||||||
fullMessages = append(fullMessages, Message{Role: "system", Content: o.systemPrompt})
|
fullMessages = append(fullMessages, Message{Role: "system", Content: TextContent(o.systemPrompt)})
|
||||||
}
|
}
|
||||||
fullMessages = append(fullMessages, messages...)
|
fullMessages = append(fullMessages, messages...)
|
||||||
|
|
||||||
@@ -314,7 +407,7 @@ type ChunkCallback func(content string, toolCalls []ToolCallMsg)
|
|||||||
func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCallback) (*ChatResponse, error) {
|
func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCallback) (*ChatResponse, error) {
|
||||||
fullMessages := make([]Message, 0, len(messages)+1)
|
fullMessages := make([]Message, 0, len(messages)+1)
|
||||||
if o.systemPrompt != "" {
|
if o.systemPrompt != "" {
|
||||||
fullMessages = append(fullMessages, Message{Role: "system", Content: o.systemPrompt})
|
fullMessages = append(fullMessages, Message{Role: "system", Content: TextContent(o.systemPrompt)})
|
||||||
}
|
}
|
||||||
fullMessages = append(fullMessages, messages...)
|
fullMessages = append(fullMessages, messages...)
|
||||||
|
|
||||||
@@ -360,6 +453,7 @@ func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCall
|
|||||||
var fullContent strings.Builder
|
var fullContent strings.Builder
|
||||||
var accumulatedToolCalls []ToolCallMsg
|
var accumulatedToolCalls []ToolCallMsg
|
||||||
var totalTokens int
|
var totalTokens int
|
||||||
|
var insideToolBlock bool
|
||||||
|
|
||||||
scanner := bufio.NewScanner(resp.Body)
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
@@ -383,7 +477,10 @@ func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCall
|
|||||||
chunk := chatResp.Choices[0].Delta.Content
|
chunk := chatResp.Choices[0].Delta.Content
|
||||||
if chunk != "" {
|
if chunk != "" {
|
||||||
fullContent.WriteString(chunk)
|
fullContent.WriteString(chunk)
|
||||||
onChunk(chunk, nil)
|
cleanedChunk := CleanStreamChunk(chunk, &insideToolBlock)
|
||||||
|
if cleanedChunk != "" {
|
||||||
|
onChunk(cleanedChunk, nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle delta tool calls
|
// Handle delta tool calls
|
||||||
@@ -435,15 +532,19 @@ func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCall
|
|||||||
}{},
|
}{},
|
||||||
}
|
}
|
||||||
|
|
||||||
finalContent := cleanAIResponse(fullContent.String())
|
finalContent := CleanAIResponse(fullContent.String())
|
||||||
finalResp.Choices[0].Message.Content = finalContent
|
finalResp.Choices[0].Message.Content = finalContent
|
||||||
finalResp.Choices[0].Message.ToolCalls = accumulatedToolCalls
|
finalResp.Choices[0].Message.ToolCalls = accumulatedToolCalls
|
||||||
|
|
||||||
return finalResp, nil
|
return finalResp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func cleanAIResponse(content string) string {
|
func CleanAIResponse(content string) string {
|
||||||
content = thinkRegex.ReplaceAllString(content, "")
|
content = thinkRegex.ReplaceAllString(content, "")
|
||||||
|
content = providerToolBlockRegex.ReplaceAllString(content, "")
|
||||||
|
content = providerTagRegex.ReplaceAllString(content, "")
|
||||||
|
content = xmlToolTagRegex.ReplaceAllString(content, "")
|
||||||
|
content = bracketToolCallRegex.ReplaceAllString(content, "")
|
||||||
lines := strings.Split(content, "\n")
|
lines := strings.Split(content, "\n")
|
||||||
var clean []string
|
var clean []string
|
||||||
inBlock := false
|
inBlock := false
|
||||||
@@ -466,6 +567,35 @@ func cleanAIResponse(content string) string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CleanStreamChunk applies lightweight cleaning to individual streaming chunks.
|
||||||
|
// It tracks state via a bool pointer to suppress content inside tool-call blocks.
|
||||||
|
func CleanStreamChunk(chunk string, insideBlock *bool) string {
|
||||||
|
if *insideBlock {
|
||||||
|
// Check for closing tag
|
||||||
|
if strings.Contains(chunk, ":tool_call>") {
|
||||||
|
*insideBlock = false
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for opening tool_call block
|
||||||
|
if streamBlockStartRegex.MatchString(chunk) {
|
||||||
|
*insideBlock = true
|
||||||
|
// If closing tag also in same chunk, emit nothing
|
||||||
|
if strings.Contains(chunk, ":tool_call>") {
|
||||||
|
*insideBlock = false
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean individual tags and bracket calls
|
||||||
|
cleaned := providerTagRegex.ReplaceAllString(chunk, "")
|
||||||
|
cleaned = xmlToolTagRegex.ReplaceAllString(cleaned, "")
|
||||||
|
cleaned = bracketToolCallRegex.ReplaceAllString(cleaned, "")
|
||||||
|
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
|
||||||
func getProviderBaseURL(name string) string {
|
func getProviderBaseURL(name string) string {
|
||||||
switch name {
|
switch name {
|
||||||
case "minimax":
|
case "minimax":
|
||||||
@@ -476,6 +606,8 @@ func getProviderBaseURL(name string) string {
|
|||||||
return "https://api.openai.com/v1"
|
return "https://api.openai.com/v1"
|
||||||
case "zai":
|
case "zai":
|
||||||
return "https://api.z.ai/v1"
|
return "https://api.z.ai/v1"
|
||||||
|
case "mimo":
|
||||||
|
return "https://token-plan-ams.xiaomimimo.com/v1"
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -503,11 +635,19 @@ func (o *Orchestrator) sendWithFallback(reqBody ChatRequest, baseURLOverride str
|
|||||||
if o.provider != nil {
|
if o.provider != nil {
|
||||||
providerOrder = append(providerOrder, o.provider)
|
providerOrder = append(providerOrder, o.provider)
|
||||||
}
|
}
|
||||||
|
var zaiProvider *config.AIProvider
|
||||||
for _, p := range providers {
|
for _, p := range providers {
|
||||||
if o.provider == nil || p.Name != o.provider.Name {
|
if o.provider == nil || p.Name != o.provider.Name {
|
||||||
providerOrder = append(providerOrder, p)
|
if p.Name == "zai" {
|
||||||
|
zaiProvider = p
|
||||||
|
} else {
|
||||||
|
providerOrder = append(providerOrder, p)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if zaiProvider != nil {
|
||||||
|
providerOrder = append(providerOrder, zaiProvider)
|
||||||
|
}
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
var triedProviders []string
|
var triedProviders []string
|
||||||
@@ -578,6 +718,5 @@ func (o *Orchestrator) sendWithFallback(reqBody ChatRequest, baseURLOverride str
|
|||||||
return &chatResp, prov.Name, nil
|
return &chatResp, prov.Name, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[orchestrator] fallback from %v to next provider", triedProviders)
|
|
||||||
return nil, "", lastErr
|
return nil, "", lastErr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,11 +65,11 @@ func TestCleanAIResponse(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
result := cleanAIResponse(tt.input)
|
result := CleanAIResponse(tt.input)
|
||||||
result = strings.TrimSpace(result)
|
result = strings.TrimSpace(result)
|
||||||
expected := strings.TrimSpace(tt.expected)
|
expected := strings.TrimSpace(tt.expected)
|
||||||
if result != expected {
|
if result != expected {
|
||||||
t.Errorf("cleanAIResponse(%q) = %q, want %q", tt.input, result, expected)
|
t.Errorf("CleanAIResponse(%q) = %q, want %q", tt.input, result, expected)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -77,34 +77,34 @@ func TestCleanAIResponse(t *testing.T) {
|
|||||||
|
|
||||||
func TestCleanAIResponseThinkRegex(t *testing.T) {
|
func TestCleanAIResponseThinkRegex(t *testing.T) {
|
||||||
input2 := "<Think>some reasoning</Think>actual response"
|
input2 := "<Think>some reasoning</Think>actual response"
|
||||||
result2 := cleanAIResponse(input2)
|
result2 := CleanAIResponse(input2)
|
||||||
if result2 != "actual response" {
|
if result2 != "actual response" {
|
||||||
t.Errorf("Valid Think tags should be removed: %q", result2)
|
t.Errorf("Valid Think tags should be removed: %q", result2)
|
||||||
}
|
}
|
||||||
|
|
||||||
input3 := "<think\nmultiline\nreasoning</think visible"
|
input3 := "<think\nmultiline\nreasoning</think visible"
|
||||||
result3 := cleanAIResponse(input3)
|
result3 := CleanAIResponse(input3)
|
||||||
// No closing > on opening tag, so won't match regex
|
// No closing > on opening tag, so won't match regex
|
||||||
if result3 != "<think\nmultiline\nreasoning</think visible" {
|
if result3 != "<think\nmultiline\nreasoning</think visible" {
|
||||||
t.Errorf("Malformed think should not be removed: %q", result3)
|
t.Errorf("Malformed think should not be removed: %q", result3)
|
||||||
}
|
}
|
||||||
|
|
||||||
input4 := "<think type=re>reasoning</think visible"
|
input4 := "<think type=re>reasoning</think visible"
|
||||||
result4 := cleanAIResponse(input4)
|
result4 := CleanAIResponse(input4)
|
||||||
// </think followed by space, not >, so won't match
|
// </think followed by space, not >, so won't match
|
||||||
if result4 != "<think type=re>reasoning</think visible" {
|
if result4 != "<think type=re>reasoning</think visible" {
|
||||||
t.Errorf("Malformed closing should not be removed: %q", result4)
|
t.Errorf("Malformed closing should not be removed: %q", result4)
|
||||||
}
|
}
|
||||||
|
|
||||||
input_real := "prefix<think reasoning here</think suffix"
|
input_real := "prefix<think reasoning here</think suffix"
|
||||||
result_real := cleanAIResponse(input_real)
|
result_real := CleanAIResponse(input_real)
|
||||||
// The closing </think has no > after it, so won't match
|
// The closing </think has no > after it, so won't match
|
||||||
if result_real != "prefix<think reasoning here</think suffix" {
|
if result_real != "prefix<think reasoning here</think suffix" {
|
||||||
t.Errorf("Malformed tags should pass through: %q", result_real)
|
t.Errorf("Malformed tags should pass through: %q", result_real)
|
||||||
}
|
}
|
||||||
|
|
||||||
input_valid := "<Think>reasoning</Think>result"
|
input_valid := "<Think>reasoning</Think>result"
|
||||||
result_valid := cleanAIResponse(input_valid)
|
result_valid := CleanAIResponse(input_valid)
|
||||||
if result_valid != "result" {
|
if result_valid != "result" {
|
||||||
t.Errorf("Valid tags should be removed: %q", result_valid)
|
t.Errorf("Valid tags should be removed: %q", result_valid)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,3 +17,62 @@ func fileContains(path, substr string) bool {
|
|||||||
func execLookPath(name string) (string, error) {
|
func execLookPath(name string) (string, error) {
|
||||||
return exec.LookPath(name)
|
return exec.LookPath(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func readOSReleaseName() string {
|
||||||
|
data, err := os.ReadFile("/etc/os-release")
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var pretty, name, version string
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
key, val, ok := strings.Cut(line, "=")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val = strings.Trim(val, `"'`)
|
||||||
|
switch key {
|
||||||
|
case "PRETTY_NAME":
|
||||||
|
pretty = val
|
||||||
|
case "NAME":
|
||||||
|
name = val
|
||||||
|
case "VERSION_ID":
|
||||||
|
version = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pretty != "" {
|
||||||
|
return pretty
|
||||||
|
}
|
||||||
|
if name != "" && version != "" {
|
||||||
|
return name + " " + version
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func readMacOSVersion() string {
|
||||||
|
out, err := exec.Command("sw_vers", "-productVersion").Output()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func readWindowsVersion() string {
|
||||||
|
if v := os.Getenv("OS"); v != "" && strings.Contains(strings.ToLower(v), "windows") {
|
||||||
|
// Try to detect Windows 11 vs 10 via build number
|
||||||
|
if build := os.Getenv("MUYUE_WIN_BUILD"); build != "" {
|
||||||
|
return "Windows " + build
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out, err := exec.Command("cmd", "/c", "ver").Output()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s := strings.TrimSpace(string(out))
|
||||||
|
if strings.Contains(s, "10.0.22") || strings.Contains(s, "10.0.23") {
|
||||||
|
return "Windows 11"
|
||||||
|
}
|
||||||
|
if strings.Contains(s, "10.0.") {
|
||||||
|
return "Windows 10"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const (
|
|||||||
|
|
||||||
type SystemInfo struct {
|
type SystemInfo struct {
|
||||||
OS OS `json:"os"`
|
OS OS `json:"os"`
|
||||||
|
OSName string `json:"os_name"`
|
||||||
Arch Arch `json:"arch"`
|
Arch Arch `json:"arch"`
|
||||||
IsWSL bool `json:"is_wsl"`
|
IsWSL bool `json:"is_wsl"`
|
||||||
Shell string `json:"shell"`
|
Shell string `json:"shell"`
|
||||||
@@ -39,6 +40,7 @@ func Detect() SystemInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
info.IsWSL = detectWSL()
|
info.IsWSL = detectWSL()
|
||||||
|
info.OSName = detectOSName(info.OS, info.IsWSL)
|
||||||
info.Shell = detectShell()
|
info.Shell = detectShell()
|
||||||
info.Terminal = detectTerminal()
|
info.Terminal = detectTerminal()
|
||||||
info.PackageManager = detectPackageManager(info.OS)
|
info.PackageManager = detectPackageManager(info.OS)
|
||||||
@@ -46,6 +48,33 @@ func Detect() SystemInfo {
|
|||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func detectOSName(os OS, isWSL bool) string {
|
||||||
|
switch os {
|
||||||
|
case Linux:
|
||||||
|
if name := readOSReleaseName(); name != "" {
|
||||||
|
if isWSL {
|
||||||
|
return name + " (WSL)"
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
if isWSL {
|
||||||
|
return "Linux (WSL)"
|
||||||
|
}
|
||||||
|
return "Linux"
|
||||||
|
case MacOS:
|
||||||
|
if v := readMacOSVersion(); v != "" {
|
||||||
|
return "macOS " + v
|
||||||
|
}
|
||||||
|
return "macOS"
|
||||||
|
case Windows:
|
||||||
|
if v := readWindowsVersion(); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return "Windows"
|
||||||
|
}
|
||||||
|
return string(os)
|
||||||
|
}
|
||||||
|
|
||||||
func detectWSL() bool {
|
func detectWSL() bool {
|
||||||
return fileContains("/proc/version", "microsoft") ||
|
return fileContains("/proc/version", "microsoft") ||
|
||||||
fileContains("/proc/version", "WSL")
|
fileContains("/proc/version", "WSL")
|
||||||
@@ -95,8 +124,11 @@ func detectPackageManager(os OS) string {
|
|||||||
func (s SystemInfo) String() string {
|
func (s SystemInfo) String() string {
|
||||||
parts := []string{
|
parts := []string{
|
||||||
"OS: " + string(s.OS),
|
"OS: " + string(s.OS),
|
||||||
"Arch: " + string(s.Arch),
|
|
||||||
}
|
}
|
||||||
|
if s.OSName != "" {
|
||||||
|
parts = append(parts, "Name: "+s.OSName)
|
||||||
|
}
|
||||||
|
parts = append(parts, "Arch: "+string(s.Arch))
|
||||||
if s.IsWSL {
|
if s.IsWSL {
|
||||||
parts = append(parts, "WSL: yes")
|
parts = append(parts, "WSL: yes")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type Skill struct {
|
|||||||
Dependencies []SkillDependency `yaml:"dependencies,omitempty" json:"dependencies,omitempty"`
|
Dependencies []SkillDependency `yaml:"dependencies,omitempty" json:"dependencies,omitempty"`
|
||||||
Languages []string `yaml:"languages,omitempty" json:"languages,omitempty"`
|
Languages []string `yaml:"languages,omitempty" json:"languages,omitempty"`
|
||||||
Category string `yaml:"category,omitempty" json:"category,omitempty"`
|
Category string `yaml:"category,omitempty" json:"category,omitempty"`
|
||||||
|
Deployed bool `yaml:"-" json:"deployed,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ValidationError struct {
|
type ValidationError struct {
|
||||||
@@ -155,6 +156,27 @@ func Delete(name string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func IsDeployed(name string) bool {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
crushPath := filepath.Join(home, ".config", "crush", "skills", name, "SKILL.md")
|
||||||
|
claudePath := filepath.Join(home, ".claude", "skills", name, "SKILL.md")
|
||||||
|
_, crushErr := os.Stat(crushPath)
|
||||||
|
_, claudeErr := os.Stat(claudePath)
|
||||||
|
return crushErr == nil || claudeErr == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Undeploy(name string) error {
|
||||||
|
skill, err := Get(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
undeployFromTargets(skill.Name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func Update(skill *Skill) error {
|
func Update(skill *Skill) error {
|
||||||
if errs := Validate(skill); len(errs) > 0 {
|
if errs := Validate(skill); len(errs) > 0 {
|
||||||
return fmt.Errorf("validation failed: %v", errs)
|
return fmt.Errorf("validation failed: %v", errs)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
Name = "muyue"
|
Name = "muyue"
|
||||||
Version = "0.3.5"
|
Version = "0.8.0"
|
||||||
Author = "La Légion de Muyue"
|
Author = "La Légion de Muyue"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -225,17 +225,21 @@ func (e *Engine) Execute(ctx context.Context, workflowID string, onStep func(ste
|
|||||||
stepStatuses[step.ID] = StatusPending
|
stepStatuses[step.ID] = StatusPending
|
||||||
}
|
}
|
||||||
|
|
||||||
resolveDeps := func(stepID string) bool {
|
resolveDeps := func(stepID string) (ready bool, blocked bool) {
|
||||||
step := wf.findStep(stepID)
|
step := wf.findStep(stepID)
|
||||||
if step == nil {
|
if step == nil {
|
||||||
return false
|
return false, true
|
||||||
}
|
}
|
||||||
for _, dep := range step.DependsOn {
|
for _, dep := range step.DependsOn {
|
||||||
if stepStatuses[dep] != StatusDone {
|
depStatus := stepStatuses[dep]
|
||||||
return false
|
if depStatus == StatusFailed || depStatus == StatusSkipped {
|
||||||
|
return false, true
|
||||||
|
}
|
||||||
|
if depStatus != StatusDone {
|
||||||
|
return false, false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true, false
|
||||||
}
|
}
|
||||||
|
|
||||||
executeStep := func(step *Step) error {
|
executeStep := func(step *Step) error {
|
||||||
@@ -296,6 +300,7 @@ func (e *Engine) Execute(ctx context.Context, workflowID string, onStep func(ste
|
|||||||
s.Error = stepErr.Error()
|
s.Error = stepErr.Error()
|
||||||
s.EndedAt = &endTime
|
s.EndedAt = &endTime
|
||||||
})
|
})
|
||||||
|
stepStatuses[step.ID] = StatusFailed
|
||||||
if onStep != nil {
|
if onStep != nil {
|
||||||
onStep(step, "failed")
|
onStep(step, "failed")
|
||||||
}
|
}
|
||||||
@@ -321,8 +326,27 @@ func (e *Engine) Execute(ctx context.Context, workflowID string, onStep func(ste
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
for !resolveDeps(step.ID) {
|
ready, blocked := resolveDeps(step.ID)
|
||||||
time.Sleep(100 * time.Millisecond)
|
if blocked {
|
||||||
|
e.UpdateStep(workflowID, step.ID, func(s *Step) {
|
||||||
|
s.Status = StatusSkipped
|
||||||
|
})
|
||||||
|
stepStatuses[step.ID] = StatusSkipped
|
||||||
|
if onStep != nil {
|
||||||
|
onStep(&step, "skipped")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !ready {
|
||||||
|
e.UpdateStep(workflowID, step.ID, func(s *Step) {
|
||||||
|
s.Status = StatusSkipped
|
||||||
|
s.Error = "dependency not satisfied at execution time"
|
||||||
|
})
|
||||||
|
stepStatuses[step.ID] = StatusSkipped
|
||||||
|
if onStep != nil {
|
||||||
|
onStep(&step, "skipped")
|
||||||
|
}
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := executeStep(&step); err != nil {
|
if err := executeStep(&step); err != nil {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func (p *Planner) GeneratePlan(ctx context.Context, goal string) ([]Step, error)
|
|||||||
prompt := buildPlanPrompt(goal)
|
prompt := buildPlanPrompt(goal)
|
||||||
|
|
||||||
messages := []orchestrator.Message{
|
messages := []orchestrator.Message{
|
||||||
{Role: "user", Content: prompt},
|
{Role: "user", Content: orchestrator.TextContent(prompt)},
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := p.orchestrator.SendWithTools(messages)
|
resp, err := p.orchestrator.SendWithTools(messages)
|
||||||
@@ -159,14 +159,18 @@ func parsePlanResponse(content string) ([]Step, error) {
|
|||||||
return steps, nil
|
return steps, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const plannerSystemPrompt = `Tu es un assistant de planification de workflows pour Muyue. Tu génères des plans d'exécution sous forme de JSON. Chaque plan est une séquence d'étapes (steps) représentant des appels d'outils.
|
const plannerSystemPrompt = `Tu es un planificateur de workflows pour Muyue. Tu génères des plans d'exécution sous forme de tableaux JSON.
|
||||||
|
|
||||||
Pour générer un plan:
|
RÈGLES :
|
||||||
1. Comprends l'objectif de l'utilisateur
|
1. Analyse l'objectif → identifie les outils → décompose en étapes
|
||||||
2. Identifie les outils nécessaires
|
2. Chaque étape : {"name": string, "tool": string, "args": object}
|
||||||
3. Décompose en étapes logiques
|
3. Max 10 étapes par plan
|
||||||
4. Spécifie les paramètres de chaque outil
|
4. Ordonne par dépendances (les lectures avant les écritures)
|
||||||
|
5. Préfère les commandes non-interactives
|
||||||
|
6. Utilise crush_run pour les tâches complexes multi-fichiers
|
||||||
|
|
||||||
Réponds toujours en JSON valide, sans texte additionnel.`
|
Outils : terminal, crush_run, read_file, list_files, search_files, grep_content, get_config, set_provider, manage_ssh, web_fetch
|
||||||
|
|
||||||
var _ = plannerSystemPrompt
|
Réponds UNIQUEMENT en JSON valide, sans texte avant/après.`
|
||||||
|
|
||||||
|
const _ = plannerSystemPrompt
|
||||||
1
web/.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
legacy-peer-deps=true
|
||||||
@@ -4,8 +4,11 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#0A0A0C" />
|
<meta name="theme-color" content="#0A0A0C" />
|
||||||
<title>muyue</title>
|
<title>Muyue</title>
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⬡</text></svg>" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/muyue.png" />
|
||||||
|
<link rel="shortcut icon" href="/muyue.png" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
1301
web/package-lock.json
generated
@@ -9,9 +9,14 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-image": "^0.10.0-beta.203",
|
||||||
|
"@xterm/addon-search": "^0.17.0-beta.203",
|
||||||
|
"@xterm/addon-unicode11": "^0.10.0-beta.203",
|
||||||
"@xterm/addon-web-links": "^0.12.0",
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/addon-webgl": "^0.20.0-beta.202",
|
||||||
|
"@xterm/xterm": "^6.1.0-beta.203",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
|
"mermaid": "^11.14.0",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5"
|
"react-dom": "^19.2.5"
|
||||||
},
|
},
|
||||||
|
|||||||
BIN
web/public/favicon-16.png
Normal file
|
After Width: | Height: | Size: 750 B |
BIN
web/public/favicon-32.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
web/public/muyue-64.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
web/public/muyue.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
@@ -36,11 +36,17 @@ const api = {
|
|||||||
testSkill: (name, sampleTask) => request('/skills/test', { method: 'POST', body: JSON.stringify({ name, sample_task: sampleTask || '' }) }),
|
testSkill: (name, sampleTask) => request('/skills/test', { method: 'POST', body: JSON.stringify({ name, sample_task: sampleTask || '' }) }),
|
||||||
exportSkill: (name) => request('/skills/export', { method: 'POST', body: JSON.stringify({ name }) }),
|
exportSkill: (name) => request('/skills/export', { method: 'POST', body: JSON.stringify({ name }) }),
|
||||||
importSkill: (path) => request('/skills/import', { method: 'POST', body: JSON.stringify({ import_path: path }) }),
|
importSkill: (path) => request('/skills/import', { method: 'POST', body: JSON.stringify({ import_path: path }) }),
|
||||||
|
deploySkill: (name) => request('/skills/deploy', { method: 'POST', body: JSON.stringify({ name }) }),
|
||||||
|
undeploySkill: (name) => request('/skills/undeploy', { method: 'POST', body: JSON.stringify({ name }) }),
|
||||||
getDashboardStatus: () => request('/dashboard/status'),
|
getDashboardStatus: () => request('/dashboard/status'),
|
||||||
getProvidersQuota: () => request('/providers/quota'),
|
getProvidersQuota: () => request('/providers/quota'),
|
||||||
|
getProvidersConsumption: () => request('/providers/consumption'),
|
||||||
getRecentCommands: () => request('/recent-commands'),
|
getRecentCommands: () => request('/recent-commands'),
|
||||||
getRunningProcesses: () => request('/running-processes'),
|
getRunningProcesses: () => request('/running-processes'),
|
||||||
getSystemMetrics: () => request('/system/metrics'),
|
getSystemMetrics: () => request('/system/metrics'),
|
||||||
|
getTestSnippet: () => request('/test/snippet'),
|
||||||
|
getTestSessions: () => request('/test/sessions'),
|
||||||
|
getTestConsole: (sessionId) => request(`/test/console/${encodeURIComponent(sessionId || '')}`),
|
||||||
savePreferences: (prefs) => request('/preferences', { method: 'PUT', body: JSON.stringify(prefs) }),
|
savePreferences: (prefs) => request('/preferences', { method: 'PUT', body: JSON.stringify(prefs) }),
|
||||||
saveProfile: (profile) => request('/config/profile', { method: 'PUT', body: JSON.stringify(profile) }),
|
saveProfile: (profile) => request('/config/profile', { method: 'PUT', body: JSON.stringify(profile) }),
|
||||||
saveProvider: (provider) => request('/config/provider', { method: 'PUT', body: JSON.stringify(provider) }),
|
saveProvider: (provider) => request('/config/provider', { method: 'PUT', body: JSON.stringify(provider) }),
|
||||||
@@ -48,6 +54,7 @@ const api = {
|
|||||||
applyStarshipTheme: (theme) => request('/starship/apply-theme', { method: 'POST', body: JSON.stringify({ theme }) }),
|
applyStarshipTheme: (theme) => request('/starship/apply-theme', { method: 'POST', body: JSON.stringify({ theme }) }),
|
||||||
validateProvider: (provider) => request('/providers/validate', { method: 'POST', body: JSON.stringify(provider) }),
|
validateProvider: (provider) => request('/providers/validate', { method: 'POST', body: JSON.stringify(provider) }),
|
||||||
runUpdate: (tool) => request('/update/run', { method: 'POST', body: JSON.stringify({ tool: tool || '' }) }),
|
runUpdate: (tool) => request('/update/run', { method: 'POST', body: JSON.stringify({ tool: tool || '' }) }),
|
||||||
|
aiTask: (task, tool) => request('/ai/task', { method: 'POST', body: JSON.stringify({ task, tool: tool || '' }) }),
|
||||||
runCommand: (command, cwd) => request('/terminal', { method: 'POST', body: JSON.stringify({ command, cwd }) }),
|
runCommand: (command, cwd) => request('/terminal', { method: 'POST', body: JSON.stringify({ command, cwd }) }),
|
||||||
getTerminalSessions: () => request('/terminal/sessions'),
|
getTerminalSessions: () => request('/terminal/sessions'),
|
||||||
addSSHConnection: (conn) => request('/terminal/sessions', { method: 'POST', body: JSON.stringify(conn) }),
|
addSSHConnection: (conn) => request('/terminal/sessions', { method: 'POST', body: JSON.stringify(conn) }),
|
||||||
@@ -61,15 +68,15 @@ const api = {
|
|||||||
clearShellChat: () => request('/shell/chat/clear', { method: 'POST' }),
|
clearShellChat: () => request('/shell/chat/clear', { method: 'POST' }),
|
||||||
analyzeSystem: () => request('/shell/analyze', { method: 'POST' }),
|
analyzeSystem: () => request('/shell/analyze', { method: 'POST' }),
|
||||||
getShellAnalysis: () => request('/shell/analysis'),
|
getShellAnalysis: () => request('/shell/analysis'),
|
||||||
sendChat: (message, stream = true, onChunk, signal) => {
|
sendChat: (message, stream = true, onChunk, signal, images = [], advancedReflection = false) => {
|
||||||
if (!stream) {
|
if (!stream) {
|
||||||
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) })
|
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false, images, advanced_reflection: advancedReflection }) })
|
||||||
}
|
}
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
fetch(`${API_BASE}/chat`, {
|
fetch(`${API_BASE}/chat`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ message, stream: true }),
|
body: JSON.stringify({ message, stream: true, images, advanced_reflection: advancedReflection }),
|
||||||
signal,
|
signal,
|
||||||
}).then(async (res) => {
|
}).then(async (res) => {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -141,7 +148,11 @@ const api = {
|
|||||||
if (data.error) { reject(new Error(data.error)); return }
|
if (data.error) { reject(new Error(data.error)); return }
|
||||||
if (data.done) { resolve({ content: full, tokens: data.tokens }); return }
|
if (data.done) { resolve({ content: full, tokens: data.tokens }); return }
|
||||||
if (data.content) {
|
if (data.content) {
|
||||||
full = data.content
|
full += data.content
|
||||||
|
if (onChunk) onChunk(full, data)
|
||||||
|
} else if (data.tool_call || data.tool_result) {
|
||||||
|
if (onChunk) onChunk(full, data)
|
||||||
|
} else if (data.thinking !== undefined || data.thinking_end) {
|
||||||
if (onChunk) onChunk(full, data)
|
if (onChunk) onChunk(full, data)
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||||
import { LayoutDashboard, Sparkles, Terminal, Settings } from 'lucide-react'
|
import { LayoutDashboard, Sparkles, Terminal, Settings, TestTube2 } from 'lucide-react'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
import { getTheme, applyTheme } from '../themes'
|
import { getTheme, applyTheme } from '../themes'
|
||||||
import { useI18n } from '../i18n'
|
import { useI18n } from '../i18n'
|
||||||
@@ -7,6 +7,7 @@ import Dashboard from './Dashboard'
|
|||||||
import Studio from './Studio'
|
import Studio from './Studio'
|
||||||
import Shell from './Shell'
|
import Shell from './Shell'
|
||||||
import Config from './Config'
|
import Config from './Config'
|
||||||
|
import Tests from './Tests'
|
||||||
import OnboardingWizard from './OnboardingWizard'
|
import OnboardingWizard from './OnboardingWizard'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -16,8 +17,6 @@ export default function App() {
|
|||||||
const [isSudo, setIsSudo] = useState(false)
|
const [isSudo, setIsSudo] = useState(false)
|
||||||
const [dashRefreshKey, setDashRefreshKey] = useState(0)
|
const [dashRefreshKey, setDashRefreshKey] = useState(0)
|
||||||
const dashRefreshRef = useRef(null)
|
const dashRefreshRef = useRef(null)
|
||||||
const [updates, setUpdates] = useState([])
|
|
||||||
const [tools, setTools] = useState([])
|
|
||||||
const [config, setConfig] = useState(null)
|
const [config, setConfig] = useState(null)
|
||||||
const [showOnboarding, setShowOnboarding] = useState(false)
|
const [showOnboarding, setShowOnboarding] = useState(false)
|
||||||
const { t, layout } = useI18n()
|
const { t, layout } = useI18n()
|
||||||
@@ -26,13 +25,12 @@ export default function App() {
|
|||||||
{ id: 'dash', label: t('tabs.dashboard'), icon: <LayoutDashboard size={15} /> },
|
{ id: 'dash', label: t('tabs.dashboard'), icon: <LayoutDashboard size={15} /> },
|
||||||
{ id: 'studio', label: t('tabs.studio'), icon: <Sparkles size={15} /> },
|
{ id: 'studio', label: t('tabs.studio'), icon: <Sparkles size={15} /> },
|
||||||
{ id: 'shell', label: t('tabs.shell'), icon: <Terminal size={15} /> },
|
{ id: 'shell', label: t('tabs.shell'), icon: <Terminal size={15} /> },
|
||||||
|
{ id: 'tests', label: 'Tests', icon: <TestTube2 size={15} /> },
|
||||||
{ id: 'config', label: t('tabs.config'), icon: <Settings size={15} /> },
|
{ id: 'config', label: t('tabs.config'), icon: <Settings size={15} /> },
|
||||||
], [t])
|
], [t])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getInfo().then(d => { setInfo(d); setIsSudo(!!d.sudo) }).catch(() => {})
|
api.getInfo().then(d => { setInfo(d); setIsSudo(!!d.sudo) }).catch(() => {})
|
||||||
api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
|
|
||||||
api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
|
|
||||||
api.getConfig().then(d => {
|
api.getConfig().then(d => {
|
||||||
setConfig(d)
|
setConfig(d)
|
||||||
const theme = d.profile?.preferences?.theme || 'cyberpunk-red'
|
const theme = d.profile?.preferences?.theme || 'cyberpunk-red'
|
||||||
@@ -58,7 +56,8 @@ export default function App() {
|
|||||||
Digit1: 'dash',
|
Digit1: 'dash',
|
||||||
Digit2: 'studio',
|
Digit2: 'studio',
|
||||||
Digit3: 'shell',
|
Digit3: 'shell',
|
||||||
Digit4: 'config',
|
Digit4: 'tests',
|
||||||
|
Digit5: 'config',
|
||||||
}
|
}
|
||||||
if (map[e.code]) {
|
if (map[e.code]) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -82,9 +81,6 @@ export default function App() {
|
|||||||
return () => window.removeEventListener('navigate-to-shell', handler)
|
return () => window.removeEventListener('navigate-to-shell', handler)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const hasUpdates = updates.some(u => u.needsUpdate)
|
|
||||||
const installed = tools.filter(tool => tool.installed).length
|
|
||||||
|
|
||||||
const WINDOW_SHORTCUTS = useMemo(() => ({
|
const WINDOW_SHORTCUTS = useMemo(() => ({
|
||||||
dash: [],
|
dash: [],
|
||||||
studio: [
|
studio: [
|
||||||
@@ -92,9 +88,14 @@ export default function App() {
|
|||||||
{ keys: `${layout.keys.shift}+${layout.keys.enter}`, desc: t('statusbar.newLine') },
|
{ keys: `${layout.keys.shift}+${layout.keys.enter}`, desc: t('statusbar.newLine') },
|
||||||
],
|
],
|
||||||
shell: [
|
shell: [
|
||||||
|
{ keys: `${layout.keys.ctrl}+${layout.keys.shift}+C`, desc: t('statusbar.copy') },
|
||||||
|
{ keys: `${layout.keys.ctrl}+${layout.keys.shift}+V`, desc: t('statusbar.paste') },
|
||||||
|
{ keys: `${layout.keys.ctrl}+${layout.keys.shift}+F`, desc: t('statusbar.search') },
|
||||||
|
{ keys: `${layout.keys.ctrl}++/${layout.keys.ctrl}+−`, desc: t('statusbar.zoom') },
|
||||||
{ keys: layout.keys.enter, desc: t('statusbar.runCommand') },
|
{ keys: layout.keys.enter, desc: t('statusbar.runCommand') },
|
||||||
{ keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') },
|
{ keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') },
|
||||||
],
|
],
|
||||||
|
tests: [],
|
||||||
config: [],
|
config: [],
|
||||||
}), [layout, t])
|
}), [layout, t])
|
||||||
|
|
||||||
@@ -102,6 +103,7 @@ export default function App() {
|
|||||||
<div className="app-layout">
|
<div className="app-layout">
|
||||||
<header className="header">
|
<header className="header">
|
||||||
<div className="header-brand">
|
<div className="header-brand">
|
||||||
|
<img src="/muyue-64.png" alt="Muyue" className="header-logo-img" width="22" height="22" style={{ borderRadius: 4, verticalAlign: 'middle' }} />
|
||||||
<span className="header-logo">MUYUE</span>
|
<span className="header-logo">MUYUE</span>
|
||||||
<span className="header-version">v{info.version || '...'}</span>
|
<span className="header-version">v{info.version || '...'}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -123,17 +125,6 @@ export default function App() {
|
|||||||
|
|
||||||
<div className="header-spacer" />
|
<div className="header-spacer" />
|
||||||
|
|
||||||
<div className="header-indicators">
|
|
||||||
<span
|
|
||||||
className={`indicator ${installed > 0 ? 'ok' : 'off'}`}
|
|
||||||
title={t('header.toolsInstalled', { count: installed })}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className={`indicator ${hasUpdates ? 'warn' : 'ok'}`}
|
|
||||||
title={hasUpdates ? t('header.updatesAvailable') : t('header.upToDate')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span className="header-clock">
|
<span className="header-clock">
|
||||||
{clock.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit' })}
|
{clock.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit' })}
|
||||||
</span>
|
</span>
|
||||||
@@ -142,13 +133,14 @@ export default function App() {
|
|||||||
<main className="content">
|
<main className="content">
|
||||||
<div className={activeTab === 'dash' ? '' : 'tab-hidden'}><Dashboard api={api} refreshRef={dashRefreshRef} /></div>
|
<div className={activeTab === 'dash' ? '' : 'tab-hidden'}><Dashboard api={api} refreshRef={dashRefreshRef} /></div>
|
||||||
<div className={activeTab === 'studio' ? '' : 'tab-hidden'}><Studio api={api} /></div>
|
<div className={activeTab === 'studio' ? '' : 'tab-hidden'}><Studio api={api} /></div>
|
||||||
<div className={activeTab === 'shell' ? '' : 'tab-hidden'}><Shell api={api} /></div>
|
<div className={activeTab === 'shell' ? '' : 'tab-hidden'}><Shell api={api} isSudo={isSudo} /></div>
|
||||||
|
<div className={activeTab === 'tests' ? '' : 'tab-hidden'}><Tests api={api} /></div>
|
||||||
<div className={activeTab === 'config' ? '' : 'tab-hidden'}><Config api={api} /></div>
|
<div className={activeTab === 'config' ? '' : 'tab-hidden'}><Config api={api} /></div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="statusbar">
|
<footer className="statusbar">
|
||||||
<div className="statusbar-left">
|
<div className="statusbar-left">
|
||||||
{isSudo && <span className="statusbar-sudo">⚡ ROOT</span>}
|
{isSudo && <span className="statusbar-sudo">⚡ SUDO</span>}
|
||||||
{activeTab === 'dash' && (
|
{activeTab === 'dash' && (
|
||||||
<span className="statusbar-shortcut">
|
<span className="statusbar-shortcut">
|
||||||
<kbd>{layout.keys.ctrl}+R</kbd> refresh
|
<kbd>{layout.keys.ctrl}+R</kbd> refresh
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { User, Brain, RefreshCw, Wrench, Monitor, AlertTriangle } from 'lucide-react'
|
import { User, Brain, Wrench, Monitor, AlertTriangle, Bot, Sparkles, Zap, GitBranch, Container, Circle, Hexagon, Code, Rocket, Download } from 'lucide-react'
|
||||||
import { useI18n } from '../i18n'
|
import { useI18n } from '../i18n'
|
||||||
|
|
||||||
const PANELS = [
|
const PANELS = [
|
||||||
{ id: 'profile', icon: User },
|
{ id: 'profile', icon: User },
|
||||||
{ id: 'providers', icon: Brain },
|
{ id: 'providers', icon: Brain },
|
||||||
{ id: 'updates', icon: RefreshCw },
|
|
||||||
{ id: 'skills', icon: Wrench },
|
{ id: 'skills', icon: Wrench },
|
||||||
{ id: 'system', icon: Monitor },
|
{ id: 'system', icon: Monitor },
|
||||||
]
|
]
|
||||||
@@ -16,10 +15,7 @@ export default function Config({ api }) {
|
|||||||
const [config, setConfig] = useState(null)
|
const [config, setConfig] = useState(null)
|
||||||
const [providers, setProviders] = useState([])
|
const [providers, setProviders] = useState([])
|
||||||
const [skillList, setSkillList] = useState([])
|
const [skillList, setSkillList] = useState([])
|
||||||
const [updates, setUpdates] = useState([])
|
|
||||||
const [tools, setTools] = useState([])
|
|
||||||
const [checking, setChecking] = useState(false)
|
|
||||||
const [updating, setUpdating] = useState(null)
|
|
||||||
const [editProfile, setEditProfile] = useState(false)
|
const [editProfile, setEditProfile] = useState(false)
|
||||||
const [editProvider, setEditProvider] = useState(null)
|
const [editProvider, setEditProvider] = useState(null)
|
||||||
const [profileForm, setProfileForm] = useState({})
|
const [profileForm, setProfileForm] = useState({})
|
||||||
@@ -34,8 +30,6 @@ export default function Config({ api }) {
|
|||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {})
|
api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {})
|
||||||
api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {})
|
api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {})
|
||||||
api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
|
|
||||||
api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
|
|
||||||
|
|
||||||
}, [api])
|
}, [api])
|
||||||
|
|
||||||
@@ -46,31 +40,6 @@ export default function Config({ api }) {
|
|||||||
setTimeout(() => setToast(null), 2500)
|
setTimeout(() => setToast(null), 2500)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCheckUpdates = async () => {
|
|
||||||
setChecking(true)
|
|
||||||
try {
|
|
||||||
await api.runScan()
|
|
||||||
const d = await api.getUpdates()
|
|
||||||
setUpdates(d.updates || [])
|
|
||||||
const td = await api.getTools()
|
|
||||||
setTools(td.tools || [])
|
|
||||||
showToast(t('config.upToDate'))
|
|
||||||
} catch (err) {
|
|
||||||
showToast(`${t('config.error')}: ${err.message}`)
|
|
||||||
}
|
|
||||||
setChecking(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUpdateTool = (tool) => {
|
|
||||||
window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
|
|
||||||
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Met à jour l'outil ${tool} sur mon système. Exécute les commandes nécessaires.` } }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUpdateAll = () => {
|
|
||||||
const toUpdate = updates.filter(u => u.needsUpdate).map(u => u.tool)
|
|
||||||
window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
|
|
||||||
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Met à jour tous les outils suivants sur mon système : ${toUpdate.join(', ')}. Exécute les commandes nécessaires une par une.` } }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSaveProfile = async () => {
|
const handleSaveProfile = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -101,17 +70,15 @@ export default function Config({ api }) {
|
|||||||
...prev,
|
...prev,
|
||||||
[p.name]: {
|
[p.name]: {
|
||||||
name: p.name,
|
name: p.name,
|
||||||
api_key: p.apiKey || '',
|
api_key: p.api_key || '',
|
||||||
model: p.model || '',
|
model: p.model || '',
|
||||||
base_url: p.baseURL || '',
|
base_url: p.base_url || '',
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
setEditProvider(p.name)
|
setEditProvider(p.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
const needsUpdateCount = updates.filter(u => u.needsUpdate).length
|
|
||||||
const installedCount = tools.filter(tool => tool.installed).length
|
|
||||||
const missingCount = tools.filter(tool => !tool.installed).length
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="config-window">
|
<div className="config-window">
|
||||||
@@ -152,20 +119,8 @@ export default function Config({ api }) {
|
|||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activePanel === 'updates' && (
|
|
||||||
<PanelUpdates
|
|
||||||
updates={updates} tools={tools}
|
|
||||||
checking={checking} updating={updating}
|
|
||||||
needsUpdateCount={needsUpdateCount}
|
|
||||||
installedCount={installedCount} missingCount={missingCount}
|
|
||||||
handleCheckUpdates={handleCheckUpdates}
|
|
||||||
handleUpdateTool={handleUpdateTool}
|
|
||||||
handleUpdateAll={handleUpdateAll}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{activePanel === 'skills' && (
|
{activePanel === 'skills' && (
|
||||||
<PanelSkills skillList={skillList} t={t} />
|
<PanelSkills skillList={skillList} api={api} loadData={loadData} t={t} />
|
||||||
)}
|
)}
|
||||||
{activePanel === 'system' && (
|
{activePanel === 'system' && (
|
||||||
<PanelSystem api={api} t={t} />
|
<PanelSystem api={api} t={t} />
|
||||||
@@ -314,7 +269,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
|||||||
const validateKey = async (p) => {
|
const validateKey = async (p) => {
|
||||||
setValidating(p.name)
|
setValidating(p.name)
|
||||||
try {
|
try {
|
||||||
await api.validateProvider({ name: p.name, api_key: p.apiKey, model: p.model, base_url: p.baseURL || '' })
|
await api.validateProvider({ name: p.name, api_key: p.api_key, model: p.model, base_url: p.base_url || '' })
|
||||||
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: true, checked: true } }))
|
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: true, checked: true } }))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: err.message || 'Clé invalide' } }))
|
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: err.message || 'Clé invalide' } }))
|
||||||
@@ -324,9 +279,9 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
providers.forEach(p => {
|
providers.forEach(p => {
|
||||||
if (p.apiKey && !keyStatus[p.name]) {
|
if (p.api_key && !keyStatus[p.name]) {
|
||||||
validateKey(p)
|
validateKey(p)
|
||||||
} else if (!p.apiKey) {
|
} else if (!p.api_key) {
|
||||||
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: 'Aucune clé' } }))
|
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: 'Aucune clé' } }))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -343,7 +298,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
|||||||
setValidating(null)
|
setValidating(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayed = providers.filter(p => p.name === 'minimax' || p.name === 'zai')
|
const displayed = providers.filter(p => p.name === 'minimax' || p.name === 'mimo')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="config-providers-list">
|
<div className="config-providers-list">
|
||||||
@@ -370,7 +325,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
|||||||
<input
|
<input
|
||||||
className="config-form-input"
|
className="config-form-input"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder={p.apiKey ? '••••••••' : t('config.tokenPlaceholder')}
|
placeholder={p.api_key ? '••••••••' : t('config.tokenPlaceholder')}
|
||||||
value={isEditing ? (providerForm[p.name]?.api_key || '') : ''}
|
value={isEditing ? (providerForm[p.name]?.api_key || '') : ''}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
if (!isEditing) openProviderEdit(p)
|
if (!isEditing) openProviderEdit(p)
|
||||||
@@ -406,180 +361,80 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PanelUpdates({ updates, tools, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleUpdateAll, t }) {
|
function PanelSkills({ skillList, api, loadData, t }) {
|
||||||
const handleInstallTool = (tool) => {
|
const [deploying, setDeploying] = useState(null)
|
||||||
window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
|
|
||||||
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Installe l'outil ${tool} sur mon système. Vérifie d'abord s'il est déjà installé, puis installe-le si nécessaire avec les commandes appropriées.` } }))
|
const handleDeploy = async (name) => {
|
||||||
|
setDeploying(name + '-deploy')
|
||||||
|
try {
|
||||||
|
await api.deploySkill(name)
|
||||||
|
loadData()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('deploy skill:', err)
|
||||||
|
}
|
||||||
|
setDeploying(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const missingTools = tools.filter(tool => !tool.installed)
|
const handleUndeploy = async (name) => {
|
||||||
|
setDeploying(name + '-undeploy')
|
||||||
return (
|
try {
|
||||||
<>
|
await api.undeploySkill(name)
|
||||||
<div className="config-card">
|
loadData()
|
||||||
<div className="config-update-controls">
|
} catch (err) {
|
||||||
<div className="config-update-stats">
|
console.error('undeploy skill:', err)
|
||||||
<span className="badge ok">{installedCount} {t('config.installed')}</span>
|
}
|
||||||
{missingCount > 0 && <span className="badge error">{missingCount} {t('config.missing')}</span>}
|
setDeploying(null)
|
||||||
{needsUpdateCount > 0 && <span className="badge warn">{needsUpdateCount} {t('config.needsUpdate')}</span>}
|
}
|
||||||
</div>
|
|
||||||
<div className="config-update-buttons">
|
|
||||||
<button className="sm" onClick={handleCheckUpdates} disabled={checking}>
|
|
||||||
{checking ? <><RefreshCw size={12} className="spin-icon" /> {t('config.checking')}</> : t('config.checkUpdates')}
|
|
||||||
</button>
|
|
||||||
{needsUpdateCount > 0 && (
|
|
||||||
<button className="sm primary" onClick={handleUpdateAll} disabled={updating === '__all__'}>
|
|
||||||
{updating === '__all__' ? t('config.updating') : `${t('config.updateAll')} (${needsUpdateCount})`}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{missingTools.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="section-title" style={{ marginTop: 12, marginBottom: 4 }}>{t('config.missing') || 'Modules manquants'}</div>
|
|
||||||
<div className="config-update-list">
|
|
||||||
{missingTools.map((tool, i) => (
|
|
||||||
<div key={`miss-${i}`} className="config-update-row">
|
|
||||||
<div className="config-update-info">
|
|
||||||
<span className="config-update-name">{tool.name}</span>
|
|
||||||
<span className="config-update-versions">
|
|
||||||
<span style={{ color: 'var(--danger)' }}>{t('config.notInstalled') || 'Non installé'}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="sm primary"
|
|
||||||
onClick={() => handleInstallTool(tool.name)}
|
|
||||||
>
|
|
||||||
{t('config.install') || 'Installer'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{updates.length === 0 ? (
|
|
||||||
<div className="config-card">
|
|
||||||
<div className="empty-state">{t('config.noUpdates')}</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="config-update-list">
|
|
||||||
{updates.map((u, i) => (
|
|
||||||
<div key={i} className="config-update-row">
|
|
||||||
<div className="config-update-info">
|
|
||||||
<span className="config-update-name">{u.tool}</span>
|
|
||||||
<span className="config-update-versions">
|
|
||||||
{u.needsUpdate ? (
|
|
||||||
<>{u.current} → <span style={{ color: 'var(--warning)' }}>{u.latest}</span></>
|
|
||||||
) : (
|
|
||||||
<span style={{ color: 'var(--success)' }}>{u.current}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{u.needsUpdate && (
|
|
||||||
<button
|
|
||||||
className="sm"
|
|
||||||
onClick={() => handleUpdateTool(u.tool)}
|
|
||||||
disabled={updating === u.tool}
|
|
||||||
>
|
|
||||||
{updating === u.tool ? t('config.updating') : t('config.updateTool')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function PanelSkills({ skillList, t }) {
|
|
||||||
const [selected, setSelected] = useState(null)
|
|
||||||
|
|
||||||
if (skillList.length === 0) {
|
if (skillList.length === 0) {
|
||||||
return <div className="empty-state" style={{ color: 'var(--text-disabled)', padding: 40 }}>{t('config.noSkills')}</div>
|
return <div className="empty-state" style={{ color: 'var(--text-disabled)', padding: 40 }}>{t('config.noSkills')}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="skills-list">
|
||||||
<div className="skill-tiles">
|
{skillList.map((s, i) => (
|
||||||
{skillList.map((s, i) => (
|
<div key={i} className="config-update-row" style={{ alignItems: 'center' }}>
|
||||||
<div key={i} className="skill-tile" onClick={() => setSelected(s)}>
|
<div className="skill-list-info">
|
||||||
<div className="skill-tile-name">{s.name}</div>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<div className="skill-tile-desc">{s.description}</div>
|
<span className="config-update-name">{s.name}</span>
|
||||||
<div className="skill-tile-tags">
|
{s.deployed ? (
|
||||||
{s.target && <span className="badge neutral">{s.target}</span>}
|
<span className="badge ok">{t('config.installed')}</span>
|
||||||
{s.version && <span className="badge">{s.version}</span>}
|
) : (
|
||||||
{s.category && <span className="badge" style={{ opacity: 0.7 }}>{s.category}</span>}
|
<span className="badge neutral">{t('config.notInstalled')}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 2 }}>{s.description}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
|
||||||
</div>
|
<button
|
||||||
{selected && (
|
className="sm primary"
|
||||||
<div className="skill-detail-overlay" onClick={() => setSelected(null)}>
|
disabled={s.deployed || deploying === s.name + '-deploy'}
|
||||||
<div className="skill-detail-panel" onClick={e => e.stopPropagation()}>
|
onClick={() => handleDeploy(s.name)}
|
||||||
<div className="skill-detail-header">
|
>
|
||||||
<span className="skill-detail-name">{selected.name}</span>
|
{deploying === s.name + '-deploy' ? '...' : t('config.apply')}
|
||||||
<button className="ghost sm" onClick={() => setSelected(null)}>✕</button>
|
</button>
|
||||||
</div>
|
<button
|
||||||
<div className="skill-detail-body">
|
className="sm ghost"
|
||||||
<div className="skill-detail-section">
|
disabled={!s.deployed || deploying === s.name + '-undeploy'}
|
||||||
<div className="skill-detail-label">Description</div>
|
onClick={() => handleUndeploy(s.name)}
|
||||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{selected.description}</div>
|
>
|
||||||
</div>
|
{deploying === s.name + '-undeploy' ? '...' : t('config.remove')}
|
||||||
<div className="skill-detail-section">
|
</button>
|
||||||
<div className="skill-detail-label">Métadonnées</div>
|
|
||||||
<div className="skill-detail-meta">
|
|
||||||
{selected.target && <span className="badge neutral">{selected.target}</span>}
|
|
||||||
{selected.version && <span className="badge">{selected.version}</span>}
|
|
||||||
{selected.category && <span className="badge">{selected.category}</span>}
|
|
||||||
{selected.author && <span className="badge ghost">{selected.author}</span>}
|
|
||||||
{selected.languages && selected.languages.map(l => <span key={l} className="badge ghost">{l}</span>)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{selected.tags && selected.tags.length > 0 && (
|
|
||||||
<div className="skill-detail-section">
|
|
||||||
<div className="skill-detail-label">Tags</div>
|
|
||||||
<div className="chip-row">
|
|
||||||
{selected.tags.map(tag => <span key={tag} className="badge">{tag}</span>)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selected.content && (
|
|
||||||
<div className="skill-detail-section">
|
|
||||||
<div className="skill-detail-label">Contenu</div>
|
|
||||||
<div className="skill-detail-content">{selected.content}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selected.dependencies && selected.dependencies.length > 0 && (
|
|
||||||
<div className="skill-detail-section">
|
|
||||||
<div className="skill-detail-label">Dépendances</div>
|
|
||||||
<div className="skill-detail-deps">
|
|
||||||
{selected.dependencies.map((d, i) => (
|
|
||||||
<div key={i} className="skill-detail-dep">
|
|
||||||
<span className="badge">{d.type}</span>
|
|
||||||
<span>{d.name}</span>
|
|
||||||
{d.required === false && <span style={{ fontSize: 11, color: 'var(--text-disabled)' }}>optionnel</span>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
</>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PanelSystem({ api, t }) {
|
function PanelSystem({ api, t }) {
|
||||||
const [showResetModal, setShowResetModal] = useState(false)
|
const [showResetModal, setShowResetModal] = useState(false)
|
||||||
const [toast, setToast] = useState(null)
|
const [toast, setToast] = useState(null)
|
||||||
|
const [isSudo, setIsSudo] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.getInfo().then(d => setIsSudo(!!d.sudo)).catch(() => {})
|
||||||
|
}, [api])
|
||||||
|
|
||||||
const showToast = (msg) => {
|
const showToast = (msg) => {
|
||||||
setToast(msg)
|
setToast(msg)
|
||||||
@@ -597,26 +452,123 @@ function PanelSystem({ api, t }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleApplyStarship = () => {
|
const handleSystemUpdate = () => {
|
||||||
window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
|
window.dispatchEvent(new CustomEvent('navigate-to-shell'))
|
||||||
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Vérifie si starship est installé sur le système. S'il ne l'est pas, installe-le (avec curl ou le gestionnaire de paquets). Ensuite, applique la configuration du thème "charm" pour starship. Assure-toi que starship est bien initialisé dans le shell de l'utilisateur.` } }))
|
if (isSudo) {
|
||||||
|
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Mets à jour le système et tous les outils utilisés par l'application Muyue. Exécute les commandes suivantes dans l'ordre :\n1. Met à jour les paquets système : sudo apt update && sudo apt upgrade -y\n2. Installe les dépendances utiles si manquantes : sudo apt install -y sshpass git curl wget\n3. Mets à jour les outils installés : crush, claude, gh, go, node/npm, python3/pip3/uv, docker, starship\n4. Pour chaque outil, vérifie la version actuelle, mets à jour si possible, puis vérifie la nouvelle version\n5. Donne un récapitulatif final de tout ce qui a été mis à jour ou installé` } }))
|
||||||
|
} else {
|
||||||
|
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Je n'ai pas les droits sudo sur ce système. Donne-moi les commandes nécessaires pour mettre à jour le système et les outils suivants. Pour chaque outil, indique la commande exacte à exécuter :\n1. Paquets système (apt update && apt upgrade)\n2. Outils à mettre à jour : crush, claude, gh, go, node/npm, python3/pip3/uv, docker, starship\n3. Dépendances utiles à installer : sshpass, git, curl, wget\n4. Présente les commandes dans un tableau markdown avec le nom de l'outil, la commande, et si sudo est requis` } }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const configureTool = (tool) => {
|
||||||
|
window.dispatchEvent(new CustomEvent('navigate-to-shell'))
|
||||||
|
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: tool.prompt } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const AI_TOOLS = [
|
||||||
|
{
|
||||||
|
id: 'crush',
|
||||||
|
name: 'Crush',
|
||||||
|
icon: 'Zap',
|
||||||
|
description: t('config.toolCrushDesc'),
|
||||||
|
prompt: `Configure l'outil Crush sur ce système. Vérifie d'abord s'il est installé avec "crush --version". S'il n'est pas installé, installe-le avec la méthode appropriée (npm install -g @anthropic/crush ou via le script officiel). S'il est déjà installé, vérifie sa configuration dans ~/.config/crush/ et affiche son état. Demande-moi les informations nécessaires si besoin (clés API, préférences, etc.).`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'claude',
|
||||||
|
name: 'Claude Code',
|
||||||
|
icon: 'Bot',
|
||||||
|
description: t('config.toolClaudeDesc'),
|
||||||
|
prompt: `Configure l'outil Claude Code (claude) sur ce système. Vérifie d'abord s'il est installé avec "claude --version". S'il n'est pas installé, installe-le avec npm install -g @anthropic-ai/claude-code. S'il est installé, vérifie sa configuration et son authentification. Demande-moi les informations nécessaires si besoin (clé API Anthropic, etc.).`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gh',
|
||||||
|
name: 'GitHub CLI',
|
||||||
|
icon: 'GitBranch',
|
||||||
|
description: t('config.toolGhDesc'),
|
||||||
|
prompt: `Configure l'outil GitHub CLI (gh) sur ce système. Vérifie d'abord s'il est installé avec "gh --version". S'il n'est pas installé, installe-le avec la méthode appropriée pour ce système. S'il est installé, vérifie son authentification avec "gh auth status". Si non authentifié, guide-moi pour le configurer avec "gh auth login". Demande-moi le token si nécessaire.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'docker',
|
||||||
|
name: 'Docker',
|
||||||
|
icon: 'Container',
|
||||||
|
description: t('config.toolDockerDesc'),
|
||||||
|
prompt: `Configure Docker sur ce système. Vérifie d'abord s'il est installé avec "docker --version". Vérifie aussi si le daemon tourne avec "docker info". S'il n'est pas installé, installe-le avec la méthode appropriée. Vérifie que l'utilisateur est dans le groupe docker. Si des problèmes de permissions existent, explique comment les résoudre.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'go',
|
||||||
|
name: 'Go',
|
||||||
|
icon: 'Circle',
|
||||||
|
description: t('config.toolGoDesc'),
|
||||||
|
prompt: `Configure l'environnement Go sur ce système. Vérifie s'il est installé avec "go version". Vérifie le GOPATH, GOROOT et les variables d'environnement. S'il n'est pas installé, installe-le avec la méthode appropriée. Vérifie que les binaires Go sont dans le PATH.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'node',
|
||||||
|
name: 'Node.js',
|
||||||
|
icon: 'Hexagon',
|
||||||
|
description: t('config.toolNodeDesc'),
|
||||||
|
prompt: `Configure l'environnement Node.js sur ce système. Vérifie s'il est installé avec "node --version" et "npm --version". Vérifie aussi pnpm et npx. S'il n'est pas installé, installe-le avec la méthode recommandée (nvm, fnm ou le gestionnaire de paquets). Vérifie la version LTS vs Current.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'python',
|
||||||
|
name: 'Python',
|
||||||
|
icon: 'Code',
|
||||||
|
description: t('config.toolPythonDesc'),
|
||||||
|
prompt: `Configure l'environnement Python sur ce système. Vérifie python3 --version, pip3 --version, et uv --version. S'ils ne sont pas installés, installe-les avec la méthode appropriée. Vérifie les paquets essentiels (venv, pip). Configure uv si nécessaire.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'starship',
|
||||||
|
name: 'Starship',
|
||||||
|
icon: 'Rocket',
|
||||||
|
description: t('config.toolStarshipDesc'),
|
||||||
|
prompt: `Configure Starship (prompt shell) sur ce système. Vérifie s'il est installé avec "starship --version". S'il n'est pas installé, installe-le. Ensuite, configure le thème "charm" dans ~/.config/starship.toml. Assure-toi que starship est initialisé dans le shell de l'utilisateur (.bashrc, .zshrc ou config fish).`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const ICON_MAP = { Zap, Bot, GitBranch, Container, Circle, Hexagon, Code, Rocket }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{toast && <div className="config-toast">{toast}</div>}
|
{toast && <div className="config-toast">{toast}</div>}
|
||||||
|
|
||||||
<div className="section-title" style={{ marginBottom: 8 }}>Configuration Système</div>
|
<div className="section-title" style={{ marginBottom: 8 }}>{t('config.systemConfig')}</div>
|
||||||
<div className="config-card">
|
|
||||||
<div className="config-card-row" style={{ marginBottom: 16 }}>
|
<div className="section-title" style={{ marginTop: 4, marginBottom: 8, fontSize: 12, color: 'var(--text-tertiary)', textTransform: 'none', letterSpacing: 0 }}>
|
||||||
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.applyStarship')}</span>
|
<Bot size={13} style={{ verticalAlign: 'middle', marginRight: 6 }} />
|
||||||
|
{t('config.aiToolsConfig')}
|
||||||
|
</div>
|
||||||
|
<div className="config-ai-tools-grid">
|
||||||
|
{AI_TOOLS.map(tool => {
|
||||||
|
const Icon = ICON_MAP[tool.icon] || Bot
|
||||||
|
return (
|
||||||
|
<div key={tool.id} className="config-ai-tool-card">
|
||||||
|
<div className="config-ai-tool-header">
|
||||||
|
<span className="config-ai-tool-icon"><Icon size={16} /></span>
|
||||||
|
<span className="config-ai-tool-name">{tool.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="config-ai-tool-desc">{tool.description}</div>
|
||||||
|
<button className="sm primary" onClick={() => configureTool(tool)} style={{ marginTop: 'auto' }}>
|
||||||
|
<Sparkles size={12} style={{ verticalAlign: 'middle', marginRight: 4 }} />
|
||||||
|
{t('config.configureViaAI')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="config-card" style={{ marginTop: 12, marginBottom: 4 }}>
|
||||||
|
<div className="config-card-row" style={{ alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.systemUpdate')}</span>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 2 }}>
|
||||||
|
{isSudo ? t('config.systemUpdateDescSudo') : t('config.systemUpdateDescNoSudo')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="sm primary" onClick={handleSystemUpdate}>
|
||||||
|
<Download size={12} style={{ verticalAlign: 'middle', marginRight: 4 }} />
|
||||||
|
{t('config.updateBtn')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 12 }}>
|
|
||||||
Vérifie l'installation de starship et configure le thème charm via l'IA.
|
|
||||||
</div>
|
|
||||||
<button className="sm primary" onClick={handleApplyStarship}>
|
|
||||||
{t('config.applyStarship')}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="section-title" style={{ marginTop: 20, marginBottom: 8, color: 'var(--danger)' }}>
|
<div className="section-title" style={{ marginTop: 20, marginBottom: 8, color: 'var(--danger)' }}>
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ const MAX_POINTS = 30
|
|||||||
const POLL_INTERVAL = 5000
|
const POLL_INTERVAL = 5000
|
||||||
const MAX_IDLE_POLLS = 3
|
const MAX_IDLE_POLLS = 3
|
||||||
|
|
||||||
|
function formatTokens(n) {
|
||||||
|
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
|
||||||
|
if (n >= 1000) return (n / 1000).toFixed(1) + 'K'
|
||||||
|
return String(n)
|
||||||
|
}
|
||||||
|
|
||||||
function MiniGraph({ data, max, color, label, unit }) {
|
function MiniGraph({ data, max, color, label, unit }) {
|
||||||
if (!data || data.length < 2) return <div className="dash-graph-empty">collecting...</div>
|
if (!data || data.length < 2) return <div className="dash-graph-empty">collecting...</div>
|
||||||
const m = max || Math.max(...data, 1)
|
const m = max || Math.max(...data, 1)
|
||||||
@@ -37,9 +43,28 @@ function MiniGraph({ data, max, color, label, unit }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function BarChart({ data, max, color }) {
|
||||||
|
if (!data || data.length === 0) return null
|
||||||
|
const barW = 100 / 7
|
||||||
|
const m = max || Math.max(...data.map(d => d.tokens), 1)
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 100 40" className="dash-graph-svg" preserveAspectRatio="none">
|
||||||
|
{data.map((d, i) => {
|
||||||
|
const h = Math.max(1, (d.tokens / m) * 36)
|
||||||
|
const x = i * barW + barW * 0.15
|
||||||
|
const w = barW * 0.7
|
||||||
|
return (
|
||||||
|
<rect key={i} x={x} y={40 - h} width={w} height={h} rx="1.5" fill={color} opacity={0.85} />
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function Dashboard({ api, refreshRef }) {
|
export default function Dashboard({ api, refreshRef }) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const [quota, setQuota] = useState(null)
|
const [quota, setQuota] = useState(null)
|
||||||
|
const [consumption, setConsumption] = useState(null)
|
||||||
const [recentCmds, setRecentCmds] = useState([])
|
const [recentCmds, setRecentCmds] = useState([])
|
||||||
const [processes, setProcesses] = useState([])
|
const [processes, setProcesses] = useState([])
|
||||||
const [metrics, setMetrics] = useState(null)
|
const [metrics, setMetrics] = useState(null)
|
||||||
@@ -51,13 +76,15 @@ export default function Dashboard({ api, refreshRef }) {
|
|||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [quotaData, cmdData, procData, metricsData] = await Promise.all([
|
const [quotaData, consumData, cmdData, procData, metricsData] = await Promise.all([
|
||||||
api.getProvidersQuota().catch(() => null),
|
api.getProvidersQuota().catch(() => null),
|
||||||
|
api.getProvidersConsumption().catch(() => null),
|
||||||
api.getRecentCommands().catch(() => ({ commands: [] })),
|
api.getRecentCommands().catch(() => ({ commands: [] })),
|
||||||
api.getRunningProcesses().catch(() => ({ processes: [] })),
|
api.getRunningProcesses().catch(() => ({ processes: [] })),
|
||||||
api.getSystemMetrics().catch(() => null),
|
api.getSystemMetrics().catch(() => null),
|
||||||
])
|
])
|
||||||
setQuota(quotaData?.providers || [])
|
setQuota(quotaData?.providers || [])
|
||||||
|
setConsumption(consumData?.providers || {})
|
||||||
setRecentCmds(cmdData.commands || [])
|
setRecentCmds(cmdData.commands || [])
|
||||||
setProcesses(procData.processes || [])
|
setProcesses(procData.processes || [])
|
||||||
if (metricsData) {
|
if (metricsData) {
|
||||||
@@ -91,7 +118,6 @@ export default function Dashboard({ api, refreshRef }) {
|
|||||||
}, [loadData, refreshRef])
|
}, [loadData, refreshRef])
|
||||||
|
|
||||||
const minimax = (quota || []).find(p => p.name === 'minimax')
|
const minimax = (quota || []).find(p => p.name === 'minimax')
|
||||||
const zai = (quota || []).find(p => p.name === 'zai')
|
|
||||||
|
|
||||||
const EXCLUDE_CMDS = ['ls', 'cd', 'pwd', 'clear', 'exit', 'history', 'cat', 'echo', 'grep', 'export', 'alias', 'unalias', 'set', 'unset', 'source', '.', 'fg', 'bg', 'jobs', 'wait', 'true', 'false', 'yes', 'sleep', 'date', 'whoami', 'id', 'uname', 'hostname', 'uptime', 'df', 'free', 'top', 'htop', 'nano', 'vi', 'vim', 'less', 'more', 'tail', 'head', 'man', 'info', 'which', 'whereis', 'type', 'command', 'hash', 'builtin', 'help']
|
const EXCLUDE_CMDS = ['ls', 'cd', 'pwd', 'clear', 'exit', 'history', 'cat', 'echo', 'grep', 'export', 'alias', 'unalias', 'set', 'unset', 'source', '.', 'fg', 'bg', 'jobs', 'wait', 'true', 'false', 'yes', 'sleep', 'date', 'whoami', 'id', 'uname', 'hostname', 'uptime', 'df', 'free', 'top', 'htop', 'nano', 'vi', 'vim', 'less', 'more', 'tail', 'head', 'man', 'info', 'which', 'whereis', 'type', 'command', 'hash', 'builtin', 'help']
|
||||||
|
|
||||||
@@ -135,6 +161,12 @@ export default function Dashboard({ api, refreshRef }) {
|
|||||||
})
|
})
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
const providerEntries = consumption ? Object.entries(consumption) : []
|
||||||
|
const colors = ['var(--accent)', '#34d399', '#a78bfa', '#f59e0b', '#f472b6']
|
||||||
|
const maxDaily = providerEntries.length > 0
|
||||||
|
? Math.max(...providerEntries.map(([, p]) => Math.max(...(p.daily || []).map(d => d.tokens), 0)), 1)
|
||||||
|
: 1
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dash-grid">
|
<div className="dash-grid">
|
||||||
{/* CPU */}
|
{/* CPU */}
|
||||||
@@ -165,43 +197,36 @@ export default function Dashboard({ api, refreshRef }) {
|
|||||||
<MiniGraph data={netTxRef.current} max={null} color="#f59e0b" label="TX" unit=" KB/s" />
|
<MiniGraph data={netTxRef.current} max={null} color="#f59e0b" label="TX" unit=" KB/s" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* API Quota */}
|
{/* Consommation */}
|
||||||
<div className="dash-card">
|
<div className="dash-card">
|
||||||
<div className="dash-card-head">
|
<div className="dash-card-head">
|
||||||
<span className="dash-label">API Quota</span>
|
<span className="dash-label">Consommation</span>
|
||||||
|
<span className="dash-count">7j</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="dash-quota-list">
|
<div className="dash-consumption-list">
|
||||||
{minimax && minimax.data?.models?.map((m, i) => (
|
{providerEntries.length === 0 && (
|
||||||
<div key={i} className="dash-quota-row">
|
<span className="dash-empty">Aucune donnée</span>
|
||||||
<span className="dash-quota-name">{String(m.model).replace('MiniMax-', '')}</span>
|
)}
|
||||||
<div className="dash-bar">
|
{providerEntries.map(([name, p], pi) => (
|
||||||
<div className="dash-bar-fill" style={{ width: `${Math.min(100, (m.used / m.total) * 100)}%` }} />
|
<div key={name} className="dash-consumption-provider">
|
||||||
|
<div className="dash-consumption-head">
|
||||||
|
<span className="dash-consumption-name" style={{ color: colors[pi % colors.length] }}>
|
||||||
|
{name.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="dash-consumption-total">
|
||||||
|
{formatTokens(p.total_tokens)} tokens · {p.total_requests} req
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<BarChart data={p.daily || []} max={maxDaily} color={colors[pi % colors.length]} />
|
||||||
|
<div className="dash-consumption-days">
|
||||||
|
{(p.daily || []).map((d, i) => (
|
||||||
|
<span key={i} className="dash-consumption-day">
|
||||||
|
{d.date.slice(5)} <strong>{formatTokens(d.tokens)}</strong>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<span className="dash-quota-val">{m.used}/{m.total}</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{minimax && minimax.data?.models?.length === 0 && (
|
|
||||||
<div className="dash-quota-row">
|
|
||||||
<span className="dash-quota-name">MiniMax</span>
|
|
||||||
<span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>{minimax.error || 'no data'}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{zai && zai.data?.models?.map((m, i) => (
|
|
||||||
<div key={i} className="dash-quota-row">
|
|
||||||
<span className="dash-quota-name">{String(m.model)}</span>
|
|
||||||
<div className="dash-bar">
|
|
||||||
<div className="dash-bar-fill" style={{ width: `${Math.min(100, (m.used / m.total) * 100)}%` }} />
|
|
||||||
</div>
|
|
||||||
<span className="dash-quota-val">{m.used}/{m.total}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{zai && !zai.data?.models?.length && (
|
|
||||||
<div className="dash-quota-row">
|
|
||||||
<span className="dash-quota-name">Z.AI</span>
|
|
||||||
<span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>{zai.error || 'no data'}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!minimax && !zai && <span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>No providers</span>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,12 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
language: 'fr',
|
language: 'fr',
|
||||||
keyboard: 'azerty',
|
keyboard: 'azerty',
|
||||||
apikey: '',
|
apikey: '',
|
||||||
|
apikey_mimo: '',
|
||||||
editor: '',
|
editor: '',
|
||||||
})
|
})
|
||||||
|
const [keyValidMimo, setKeyValidMimo] = useState(false)
|
||||||
|
const [errorMimo, setErrorMimo] = useState(null)
|
||||||
|
const [validatingMimo, setValidatingMimo] = useState(false)
|
||||||
const [editorList, setEditorList] = useState(BASE_EDITORS)
|
const [editorList, setEditorList] = useState(BASE_EDITORS)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
@@ -52,7 +56,7 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
case 'name': return answers.name.trim().length > 0
|
case 'name': return answers.name.trim().length > 0
|
||||||
case 'language': return !!answers.language
|
case 'language': return !!answers.language
|
||||||
case 'keyboard': return !!answers.keyboard
|
case 'keyboard': return !!answers.keyboard
|
||||||
case 'apikey': return keyValid && !scanning
|
case 'apikey': return (keyValid || keyValidMimo) && !scanning
|
||||||
case 'editor': return true
|
case 'editor': return true
|
||||||
case 'done': return true
|
case 'done': return true
|
||||||
default: return true
|
default: return true
|
||||||
@@ -173,6 +177,33 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
setValidating(false)
|
setValidating(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleValidateKeyMimo = async () => {
|
||||||
|
if (!answers.apikey_mimo.trim()) return
|
||||||
|
setValidatingMimo(true)
|
||||||
|
setErrorMimo(null)
|
||||||
|
try {
|
||||||
|
await api.validateProvider({
|
||||||
|
name: 'mimo',
|
||||||
|
api_key: answers.apikey_mimo,
|
||||||
|
model: 'mimo-v2.5-pro',
|
||||||
|
base_url: 'https://token-plan-ams.xiaomimimo.com/v1',
|
||||||
|
})
|
||||||
|
setKeyValidMimo(true)
|
||||||
|
// Save MiMo. If MiniMax wasn't validated yet, MiMo becomes the active provider.
|
||||||
|
await api.saveProvider({
|
||||||
|
name: 'mimo',
|
||||||
|
api_key: answers.apikey_mimo,
|
||||||
|
model: 'mimo-v2.5-pro',
|
||||||
|
base_url: 'https://token-plan-ams.xiaomimimo.com/v1',
|
||||||
|
active: !keyValid,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
setErrorMimo(err.message || 'Clé invalide')
|
||||||
|
setKeyValidMimo(false)
|
||||||
|
}
|
||||||
|
setValidatingMimo(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@@ -201,6 +232,15 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
active: true,
|
active: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (answers.apikey_mimo.trim()) {
|
||||||
|
await api.saveProvider({
|
||||||
|
name: 'mimo',
|
||||||
|
api_key: answers.apikey_mimo,
|
||||||
|
model: 'mimo-v2.5-pro',
|
||||||
|
base_url: 'https://token-plan-ams.xiaomimimo.com/v1',
|
||||||
|
active: !answers.apikey.trim(),
|
||||||
|
})
|
||||||
|
}
|
||||||
onComplete()
|
onComplete()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || 'Erreur lors de la sauvegarde')
|
setError(err.message || 'Erreur lors de la sauvegarde')
|
||||||
@@ -283,38 +323,71 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
|
|
||||||
{current.key === 'apikey' && (
|
{current.key === 'apikey' && (
|
||||||
<div className="onboarding-step">
|
<div className="onboarding-step">
|
||||||
<div className="onboarding-title">Clé API MiniMax</div>
|
<div className="onboarding-title">Clés API</div>
|
||||||
<div className="onboarding-desc">
|
<div className="onboarding-desc">
|
||||||
Entrez votre clé API MiniMax pour activer l'assistant IA. La clé est obligatoire pour continuer.
|
Renseignez au moins l'une des deux clés pour activer l'assistant. Les autres fournisseurs (OpenAI, Anthropic, Ollama, Z.AI) se configurent plus tard depuis l'onglet Configuration.
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
className="onboarding-input"
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 4 }}>
|
||||||
placeholder="sk-xxxxxxxxxxxxxxxx"
|
<label style={{ fontSize: 12, color: 'var(--text-tertiary)', fontWeight: 600 }}>MiniMax</label>
|
||||||
type="password"
|
<input
|
||||||
value={answers.apikey}
|
className="onboarding-input"
|
||||||
onChange={e => { setAnswers(a => ({ ...a, apikey: e.target.value })); setKeyValid(false); setError(null) }}
|
placeholder="sk-xxxxxxxxxxxxxxxx (MiniMax)"
|
||||||
autoFocus
|
type="password"
|
||||||
/>
|
value={answers.apikey}
|
||||||
{error && !keyValid && <div className="onboarding-required">{error}</div>}
|
onChange={e => { setAnswers(a => ({ ...a, apikey: e.target.value })); setKeyValid(false); setError(null) }}
|
||||||
{keyValid && !scanning && <div className="onboarding-valid">Clé valide ✓ — Appuyez sur Entrée pour continuer</div>}
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
className="sm primary"
|
||||||
|
onClick={handleValidateKey}
|
||||||
|
disabled={validating || !answers.apikey.trim()}
|
||||||
|
>
|
||||||
|
{validating ? 'Validation...' : 'Valider MiniMax'}
|
||||||
|
</button>
|
||||||
|
{keyValid && <span className="onboarding-valid">✓ MiniMax OK</span>}
|
||||||
|
{error && !keyValid && <span className="onboarding-required">{error}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 12 }}>
|
||||||
|
<label style={{ fontSize: 12, color: 'var(--text-tertiary)', fontWeight: 600 }}>MiMo (Xiaomi)</label>
|
||||||
|
<input
|
||||||
|
className="onboarding-input"
|
||||||
|
placeholder="sk-xxxxxxxxxxxxxxxx (MiMo)"
|
||||||
|
type="password"
|
||||||
|
value={answers.apikey_mimo}
|
||||||
|
onChange={e => { setAnswers(a => ({ ...a, apikey_mimo: e.target.value })); setKeyValidMimo(false); setErrorMimo(null) }}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
className="sm primary"
|
||||||
|
onClick={handleValidateKeyMimo}
|
||||||
|
disabled={validatingMimo || !answers.apikey_mimo.trim()}
|
||||||
|
>
|
||||||
|
{validatingMimo ? 'Validation...' : 'Valider MiMo'}
|
||||||
|
</button>
|
||||||
|
{keyValidMimo && <span className="onboarding-valid">✓ MiMo OK</span>}
|
||||||
|
{errorMimo && !keyValidMimo && <span className="onboarding-required">{errorMimo}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{scanning && (
|
{scanning && (
|
||||||
<div className="onboarding-scanning">
|
<div className="onboarding-scanning" style={{ marginTop: 8 }}>
|
||||||
<Loader size={14} className="spin-icon" />
|
<Loader size={14} className="spin-icon" />
|
||||||
<span>{scanMessage}</span>
|
<span>{scanMessage}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{requiredError && <div className="onboarding-required">Veuillez valider votre clé API pour continuer</div>}
|
{requiredError && (
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
<div className="onboarding-required" style={{ marginTop: 8 }}>
|
||||||
<button
|
Veuillez valider au moins une clé (MiniMax ou MiMo) pour continuer.
|
||||||
className="sm primary"
|
</div>
|
||||||
onClick={handleValidateKey}
|
)}
|
||||||
disabled={validating || !answers.apikey.trim()}
|
{(keyValid || keyValidMimo) && !scanning && (
|
||||||
>
|
<div className="onboarding-valid" style={{ marginTop: 8 }}>
|
||||||
{validating ? 'Validation...' : 'Valider la clé'}
|
Au moins une clé est valide — appuyez sur Suivant pour continuer.
|
||||||
</button>
|
</div>
|
||||||
</div>
|
|
||||||
{!keyValid && !error && answers.apikey.trim() && (
|
|
||||||
<div className="onboarding-hint">Entrez votre clé puis cliquez "Valider la clé"</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||||
import { useI18n } from '../i18n'
|
import { useI18n } from '../i18n'
|
||||||
|
import mermaid from 'mermaid'
|
||||||
|
|
||||||
|
mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose', fontFamily: 'var(--font-mono)' })
|
||||||
|
|
||||||
const RANKS = {
|
const RANKS = {
|
||||||
commandant: { label: 'Commandant', short: 'CDT', color: '#FFD740' },
|
commandant: { label: 'Commandant', short: 'CDT', color: '#FFD740' },
|
||||||
@@ -64,25 +67,35 @@ function renderContent(text) {
|
|||||||
function formatText(text) {
|
function formatText(text) {
|
||||||
let html = text
|
let html = text
|
||||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
|
||||||
|
html = html.replace(/^(\|.+\|)\n(\|[\s\-:|]+\|)\n((?:\|.+\|\n?)+)/gm, (match, headerRow, sepRow, bodyRows) => {
|
||||||
|
const headers = headerRow.split('|').filter(c => c.trim() !== '').map(c => `<th>${c.trim()}</th>`).join('')
|
||||||
|
const rows = bodyRows.trim().split('\n').map(row => {
|
||||||
|
const cells = row.split('|').filter(c => c.trim() !== '').map(c => `<td>${c.trim()}</td>`).join('')
|
||||||
|
return `<tr>${cells}</tr>`
|
||||||
|
}).join('')
|
||||||
|
return `<table><thead><tr>${headers}</tr></thead><tbody>${rows}</tbody></table>`
|
||||||
|
})
|
||||||
|
|
||||||
html = html
|
html = html
|
||||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
|
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
|
||||||
.replace(/^### (.+)$/gm, '<h4 class="msg-h4">$1</h4>')
|
.replace(/^### (.+)$/gm, '<h4 class="msg-h4">$1</h4>')
|
||||||
.replace(/^## (.+)$/gm, '<h3 class="msg-h3">$1</h3>')
|
.replace(/^## (.+)$/gm, '<h3 class="msg-h3">$1</h3>')
|
||||||
.replace(/^# (.+)$/gm, '<h2 class="msg-h2">$1</h2>')
|
.replace(/^# (.+)$/gm, '<h2 class="msg-h2">$1</h2>')
|
||||||
.replace(/^\s*[-*] (.+)$/gm, '<div class="msg-bullet">• $1</div>')
|
.replace(/^---+$/gm, '<hr>')
|
||||||
|
.replace(/^\s*[-*] (.+)$/gm, '<div class="msg-bullet">\u2022 $1</div>')
|
||||||
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<div class="msg-step"><span class="msg-step-num">$1</span> $2</div>')
|
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<div class="msg-step"><span class="msg-step-num">$1</span> $2</div>')
|
||||||
.replace(/\n/g, '<br/>')
|
.replace(/\n/g, '<br/>')
|
||||||
|
|
||||||
html = html
|
html = html
|
||||||
.replace(/<br\/>\s*<br\/>/g, '<br/>')
|
.replace(/<br\/>\s*<br\/>/g, '<br/>')
|
||||||
.replace(/<br\/>\s*(<h[234]|<div class="msg-)/g, '$1')
|
.replace(/<br\/>\s*(<h[234]|<div class="msg-|<table|<hr)/g, '$1')
|
||||||
.replace(/(<\/h[234]|<\/div>)\s*<br\/>/g, '$1')
|
.replace(/(<\/h[234]|<\/div>|<\/table>|<hr>)\s*<br\/>/g, '$1')
|
||||||
.replace(/\s+on\w+=["'][^"']*["']/gi, '')
|
.replace(/\s+on\w+=["'][^"']*["']/gi, '')
|
||||||
.replace(/javascript:/gi, '')
|
.replace(/javascript:/gi, '')
|
||||||
.replace(/data:/gi, '')
|
.replace(/data:/gi, '')
|
||||||
|
|
||||||
return html
|
return html
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,10 +142,17 @@ const TOOL_LABELS = {
|
|||||||
web_fetch: 'Web Fetch',
|
web_fetch: 'Web Fetch',
|
||||||
}
|
}
|
||||||
|
|
||||||
function ToolCallBlock({ call, result }) {
|
function ToolCallBlock({ call, result, activeAgents, onModeChange }) {
|
||||||
const icon = TOOL_ICONS[call.name] || '🔧'
|
const icon = TOOL_ICONS[call.name] || '🔧'
|
||||||
const label = TOOL_LABELS[call.name] || call.name
|
const label = TOOL_LABELS[call.name] || call.name
|
||||||
const isErr = result && result.is_error
|
const isErr = result && result.is_error
|
||||||
|
const isCrush = call.name === 'crush_run'
|
||||||
|
const isClaude = call.name === 'claude_run'
|
||||||
|
const isAgent = isCrush || isClaude
|
||||||
|
const agentType = isCrush ? 'crush' : isClaude ? 'claude' : null
|
||||||
|
const maxAgents = isCrush ? 2 : isClaude ? 2 : 0
|
||||||
|
const currentCount = agentType && activeAgents ? (activeAgents[agentType] || 0) : 0
|
||||||
|
const [mode, setMode] = useState('sync')
|
||||||
|
|
||||||
let argsPreview = ''
|
let argsPreview = ''
|
||||||
try {
|
try {
|
||||||
@@ -150,15 +170,39 @@ function ToolCallBlock({ call, result }) {
|
|||||||
|
|
||||||
const truncatedResult = result ? (result.content || '').slice(0, 2000) : null
|
const truncatedResult = result ? (result.content || '').slice(0, 2000) : null
|
||||||
|
|
||||||
|
const handleModeChange = (newMode) => {
|
||||||
|
setMode(newMode)
|
||||||
|
if (onModeChange) onModeChange(call.tool_call_id, newMode)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`studio-tool-block ${isErr ? 'error' : ''} ${result ? 'done' : 'running'}`}>
|
<div className={`studio-tool-block ${isErr ? 'error' : ''} ${result ? 'done' : 'running'}`}>
|
||||||
<div className="studio-tool-header">
|
<div className="studio-tool-header">
|
||||||
<span className="studio-tool-icon">{icon}</span>
|
<span className="studio-tool-icon">{icon}</span>
|
||||||
<span className="studio-tool-name">{label}</span>
|
<span className="studio-tool-name">{label}</span>
|
||||||
|
{isAgent && !result && (
|
||||||
|
<span className="studio-agent-badge">{currentCount}/{maxAgents}</span>
|
||||||
|
)}
|
||||||
{!result && <span className="studio-tool-spinner"><span/><span/><span/></span>}
|
{!result && <span className="studio-tool-spinner"><span/><span/><span/></span>}
|
||||||
{result && <span className={`studio-tool-status ${isErr ? 'error' : 'ok'}`}>{isErr ? '✗' : '✓'}</span>}
|
{result && <span className={`studio-tool-status ${isErr ? 'error' : 'ok'}`}>{isErr ? '✗' : '✓'}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="studio-tool-args" title={argsPreview}>{argsPreview}</div>
|
<div className="studio-tool-args" title={argsPreview}>{argsPreview}</div>
|
||||||
|
{isAgent && !result && (
|
||||||
|
<div className="studio-agent-mode">
|
||||||
|
<button
|
||||||
|
className={`studio-mode-btn ${mode === 'sync' ? 'active' : ''}`}
|
||||||
|
onClick={() => handleModeChange('sync')}
|
||||||
|
>
|
||||||
|
Exécuter et attendre
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`studio-mode-btn ${mode === 'async' ? 'active' : ''}`}
|
||||||
|
onClick={() => handleModeChange('async')}
|
||||||
|
>
|
||||||
|
Exécuter en arrière-plan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{truncatedResult && (
|
{truncatedResult && (
|
||||||
<div className="studio-tool-result">
|
<div className="studio-tool-result">
|
||||||
<pre>{truncatedResult}</pre>
|
<pre>{truncatedResult}</pre>
|
||||||
@@ -168,19 +212,85 @@ function ToolCallBlock({ call, result }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function FeedItem({ msg }) {
|
let mermaidIdCounter = 0
|
||||||
|
|
||||||
|
function MermaidBlock({ code }) {
|
||||||
|
const ref = useRef(null)
|
||||||
|
const [svg, setSvg] = useState('')
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
const id = `studio-mermaid-${++mermaidIdCounter}`
|
||||||
|
mermaid.render(id, code).then(({ svg }) => {
|
||||||
|
if (!cancelled) setSvg(svg)
|
||||||
|
}).catch(() => {
|
||||||
|
if (!cancelled) setError(true)
|
||||||
|
})
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [code])
|
||||||
|
|
||||||
|
if (error) return <pre className="studio-mermaid-error">{code}</pre>
|
||||||
|
if (!svg) return <div className="studio-mermaid-loading">Chargement...</div>
|
||||||
|
return <div className="studio-mermaid-container" ref={ref} dangerouslySetInnerHTML={{ __html: svg }} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodeBlockWithCopy({ part, index, copiedIdx, setCopiedIdx }) {
|
||||||
|
if (part.lang === 'mermaid') {
|
||||||
|
return (
|
||||||
|
<div className="studio-code-block">
|
||||||
|
<div className="studio-code-header">
|
||||||
|
<span className="studio-code-lang">mermaid</span>
|
||||||
|
<button className={`studio-copy-btn ${copiedIdx === index ? 'copied' : ''}`} onClick={() => {
|
||||||
|
navigator.clipboard.writeText(part.content)
|
||||||
|
setCopiedIdx(index)
|
||||||
|
setTimeout(() => setCopiedIdx(null), 1500)
|
||||||
|
}}>
|
||||||
|
{copiedIdx === index ? 'Copie!' : 'Copier'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<MermaidBlock code={part.content} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="studio-code-block">
|
||||||
|
<div className="studio-code-header">
|
||||||
|
{part.lang && <span className="studio-code-lang">{part.lang}</span>}
|
||||||
|
<button className={`studio-copy-btn ${copiedIdx === index ? 'copied' : ''}`} onClick={() => {
|
||||||
|
navigator.clipboard.writeText(part.content)
|
||||||
|
setCopiedIdx(index)
|
||||||
|
setTimeout(() => setCopiedIdx(null), 1500)
|
||||||
|
}}>
|
||||||
|
{copiedIdx === index ? 'Copie!' : 'Copier'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre><code>{part.content}</code></pre>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeedItem({ msg, activeAgents, onModeChange, collapseHistory }) {
|
||||||
const isUser = msg.role === 'user'
|
const isUser = msg.role === 'user'
|
||||||
const isSystem = msg.role === 'system'
|
const isSystem = msg.role === 'system'
|
||||||
const rank = getRank(msg.role)
|
const rank = getRank(msg.role)
|
||||||
|
const [copiedIdx, setCopiedIdx] = useState(null)
|
||||||
|
const [forceExpand, setForceExpand] = useState(false)
|
||||||
|
|
||||||
const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''
|
const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''
|
||||||
|
|
||||||
let parsedToolCalls = null
|
let parsedToolCalls = null
|
||||||
let parsedToolResults = null
|
let parsedToolResults = null
|
||||||
|
let parsedSegments = null
|
||||||
let displayContent = msg.content
|
let displayContent = msg.content
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(msg.content)
|
const parsed = JSON.parse(msg.content)
|
||||||
if (parsed && Array.isArray(parsed.tool_calls)) {
|
if (parsed && Array.isArray(parsed.segments)) {
|
||||||
|
parsedSegments = parsed.segments
|
||||||
|
parsedToolCalls = parsed.tool_calls || null
|
||||||
|
parsedToolResults = parsed.tool_results || null
|
||||||
|
displayContent = parsed.content || ''
|
||||||
|
} else if (parsed && Array.isArray(parsed.tool_calls)) {
|
||||||
parsedToolCalls = parsed.tool_calls
|
parsedToolCalls = parsed.tool_calls
|
||||||
parsedToolResults = parsed.tool_results || null
|
parsedToolResults = parsed.tool_results || null
|
||||||
displayContent = parsed.content || ''
|
displayContent = parsed.content || ''
|
||||||
@@ -213,38 +323,113 @@ function FeedItem({ msg }) {
|
|||||||
{timeStr && <span className="feed-time">{timeStr}</span>}
|
{timeStr && <span className="feed-time">{timeStr}</span>}
|
||||||
</div>
|
</div>
|
||||||
{msg.thinking && <ThinkingBlock content={formatText(msg.thinking)} done raw />}
|
{msg.thinking && <ThinkingBlock content={formatText(msg.thinking)} done raw />}
|
||||||
{parsedToolCalls && parsedToolCalls.map((tc, i) => {
|
{msg.images && msg.images.length > 0 && (
|
||||||
const resultData = parsedToolResults
|
<div className="feed-images">
|
||||||
? parsedToolResults.find(r => r.tool_call_id === tc.tool_call_id)
|
{msg.images.map((imgId, i) => (
|
||||||
: null
|
<img key={i} className="feed-image" src={`/api/images/${imgId}`} alt={`Image ${i + 1}`} />
|
||||||
const result = resultData
|
))}
|
||||||
? { content: resultData.result, is_error: resultData.is_error }
|
|
||||||
: null
|
|
||||||
return <ToolCallBlock key={tc.tool_call_id || i} call={tc} result={result} />
|
|
||||||
})}
|
|
||||||
{cleanContent && (
|
|
||||||
<div className="feed-content">
|
|
||||||
{renderContent(cleanContent).map((part, i) =>
|
|
||||||
part.type === 'code' ? (
|
|
||||||
<div key={i} className="studio-code-block">
|
|
||||||
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
|
|
||||||
<pre><code>{part.content}</code></pre>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{parsedSegments && parsedSegments.some(s => s.type === 'tool') ? (
|
||||||
|
(() => {
|
||||||
|
const toolSegs = parsedSegments.filter(s => s.type === 'tool')
|
||||||
|
const compress = collapseHistory && !forceExpand && toolSegs.length > 1
|
||||||
|
const lastTool = toolSegs.length > 0 ? toolSegs[toolSegs.length - 1] : null
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{compress && (
|
||||||
|
<div className="feed-content" style={{ opacity: 0.7, fontSize: '0.85em', display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<span>… {toolSegs.length - 1} action{toolSegs.length - 1 > 1 ? 's' : ''} précédente{toolSegs.length - 1 > 1 ? 's' : ''} masquée{toolSegs.length - 1 > 1 ? 's' : ''}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setForceExpand(true)}
|
||||||
|
style={{ background: 'transparent', border: 'none', color: 'var(--accent, #6c5ce7)', cursor: 'pointer', fontSize: 'inherit' }}
|
||||||
|
>Tout afficher</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{parsedSegments.map((seg, i) => {
|
||||||
|
if (seg.type === 'text') {
|
||||||
|
if (!seg.content) return null
|
||||||
|
const c = seg.content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||||||
|
if (!c) return null
|
||||||
|
return (
|
||||||
|
<div key={`t${i}`} className="feed-content">
|
||||||
|
{renderContent(c).map((part, j) =>
|
||||||
|
part.type === 'code' ? (
|
||||||
|
<CodeBlockWithCopy key={j} part={part} index={j} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
|
||||||
|
) : (
|
||||||
|
<span key={j} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (seg.type === 'tool') {
|
||||||
|
if (compress && seg !== lastTool) return null
|
||||||
|
const r = seg.result
|
||||||
|
const result = r && (r.content !== undefined || r.is_error !== undefined)
|
||||||
|
? { content: r.content, is_error: r.is_error }
|
||||||
|
: null
|
||||||
|
return <ToolCallBlock key={`tc${i}`} call={seg.call} result={result} activeAgents={activeAgents} onModeChange={onModeChange} />
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{parsedToolCalls && (() => {
|
||||||
|
const compress = collapseHistory && !forceExpand && parsedToolCalls.length > 1
|
||||||
|
const items = compress ? parsedToolCalls.slice(-1) : parsedToolCalls
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{compress && (
|
||||||
|
<div className="feed-content" style={{ opacity: 0.7, fontSize: '0.85em', display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<span>… {parsedToolCalls.length - 1} action{parsedToolCalls.length - 1 > 1 ? 's' : ''} précédente{parsedToolCalls.length - 1 > 1 ? 's' : ''} masquée{parsedToolCalls.length - 1 > 1 ? 's' : ''}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setForceExpand(true)}
|
||||||
|
style={{ background: 'transparent', border: 'none', color: 'var(--accent, #6c5ce7)', cursor: 'pointer', fontSize: 'inherit' }}
|
||||||
|
>Tout afficher</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{items.map((tc, i) => {
|
||||||
|
const resultData = parsedToolResults
|
||||||
|
? parsedToolResults.find(r => r.tool_call_id === tc.tool_call_id)
|
||||||
|
: null
|
||||||
|
const result = resultData
|
||||||
|
? { content: resultData.result, is_error: resultData.is_error }
|
||||||
|
: null
|
||||||
|
return <ToolCallBlock key={tc.tool_call_id || i} call={tc} result={result} activeAgents={activeAgents} onModeChange={onModeChange} />
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
{cleanContent && (
|
||||||
|
<div className="feed-content">
|
||||||
|
{renderContent(cleanContent).map((part, i) =>
|
||||||
|
part.type === 'code' ? (
|
||||||
|
<CodeBlockWithCopy key={i} part={part} index={i} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
|
||||||
|
) : (
|
||||||
|
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function StreamingItem({ content, thinking, toolCalls }) {
|
function StreamingItem({ content, thinking, toolCalls, segments, activeAgents, onModeChange, collapseHistory }) {
|
||||||
const rank = RANKS.general
|
const rank = RANKS.general
|
||||||
const cleanContent = content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
const cleanContent = content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||||||
const hasToolCalls = toolCalls && toolCalls.length > 0
|
const hasToolCalls = toolCalls && toolCalls.length > 0
|
||||||
|
const [copiedIdx, setCopiedIdx] = useState(null)
|
||||||
|
const [forceExpand, setForceExpand] = useState(false)
|
||||||
|
|
||||||
const renderedContent = useMemo(() => {
|
const renderedContent = useMemo(() => {
|
||||||
if (!cleanContent) return []
|
if (!cleanContent) return []
|
||||||
@@ -256,6 +441,10 @@ function StreamingItem({ content, thinking, toolCalls }) {
|
|||||||
return formatText(thinking)
|
return formatText(thinking)
|
||||||
}, [thinking])
|
}, [thinking])
|
||||||
|
|
||||||
|
const hasOrderedSegments = segments && segments.some(s => s.type === 'tool')
|
||||||
|
const toolSegments = (segments || []).filter(s => s.type === 'tool')
|
||||||
|
const compress = collapseHistory && !forceExpand && toolSegments.length > 1
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="feed-item assistant">
|
<div className="feed-item assistant">
|
||||||
<div className="feed-avatar ai-rank">
|
<div className="feed-avatar ai-rank">
|
||||||
@@ -269,28 +458,73 @@ function StreamingItem({ content, thinking, toolCalls }) {
|
|||||||
<span className="feed-role">{rank.label}</span>
|
<span className="feed-role">{rank.label}</span>
|
||||||
</div>
|
</div>
|
||||||
{thinking && <ThinkingBlock content={formattedThinking} raw done={false} />}
|
{thinking && <ThinkingBlock content={formattedThinking} raw done={false} />}
|
||||||
{hasToolCalls && toolCalls.map((tc, i) => (
|
{hasOrderedSegments ? (
|
||||||
<ToolCallBlock key={tc.call?.tool_call_id || i} call={tc.call} result={tc.result} />
|
<>
|
||||||
))}
|
{compress && (
|
||||||
{!thinking && !cleanContent && !hasToolCalls && (
|
<div className="feed-content" style={{ opacity: 0.7, fontSize: '0.85em', display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<span>… {toolSegments.length - 1} action{toolSegments.length - 1 > 1 ? 's' : ''} précédente{toolSegments.length - 1 > 1 ? 's' : ''} masquée{toolSegments.length - 1 > 1 ? 's' : ''} (mode compressé)</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setForceExpand(true)}
|
||||||
|
style={{ background: 'transparent', border: 'none', color: 'var(--accent, #6c5ce7)', cursor: 'pointer', fontSize: 'inherit' }}
|
||||||
|
>Tout afficher</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(() => {
|
||||||
|
const lastToolId = toolSegments.length > 0 ? toolSegments[toolSegments.length - 1] : null
|
||||||
|
return segments.map((seg, i) => {
|
||||||
|
if (seg.type === 'text') {
|
||||||
|
if (!seg.content) return null
|
||||||
|
const parts = renderContent(seg.content)
|
||||||
|
return (
|
||||||
|
<div key={`t${i}`} className="feed-content">
|
||||||
|
{parts.map((part, j) =>
|
||||||
|
part.type === 'code' ? (
|
||||||
|
<CodeBlockWithCopy key={j} part={part} index={j} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
|
||||||
|
) : (
|
||||||
|
<span key={j} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (seg.type === 'tool') {
|
||||||
|
if (compress && seg !== lastToolId) return null
|
||||||
|
return <ToolCallBlock key={`tc${i}`} call={seg.call} result={seg.result} activeAgents={activeAgents} onModeChange={onModeChange} />
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
})()}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{hasToolCalls && (compress
|
||||||
|
? [<ToolCallBlock key={toolCalls[toolCalls.length - 1].call?.tool_call_id || 'last'} call={toolCalls[toolCalls.length - 1].call} result={toolCalls[toolCalls.length - 1].result} activeAgents={activeAgents} onModeChange={onModeChange} />]
|
||||||
|
: toolCalls.map((tc, i) => (
|
||||||
|
<ToolCallBlock key={tc.call?.tool_call_id || i} call={tc.call} result={tc.result} activeAgents={activeAgents} onModeChange={onModeChange} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{cleanContent && (
|
||||||
|
<div className="feed-content">
|
||||||
|
{renderedContent.map((part, i) =>
|
||||||
|
part.type === 'code' ? (
|
||||||
|
<CodeBlockWithCopy key={i} part={part} index={i} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
|
||||||
|
) : (
|
||||||
|
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<span className="studio-cursor" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!thinking && !cleanContent && !hasToolCalls && !hasOrderedSegments && (
|
||||||
<div className="feed-content">
|
<div className="feed-content">
|
||||||
<div className="studio-thinking"><span /><span /><span /></div>
|
<div className="studio-thinking"><span /><span /><span /></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{cleanContent && (
|
{!hasOrderedSegments && cleanContent && (
|
||||||
<div className="feed-content">
|
<span className="studio-cursor" />
|
||||||
{renderedContent.map((part, i) =>
|
|
||||||
part.type === 'code' ? (
|
|
||||||
<div key={i} className="studio-code-block">
|
|
||||||
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
|
|
||||||
<pre><code>{part.content}</code></pre>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
<span className="studio-cursor" />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -305,14 +539,28 @@ export default function Studio({ api }) {
|
|||||||
const [streaming, setStreaming] = useState('')
|
const [streaming, setStreaming] = useState('')
|
||||||
const [streamThinking, setStreamThinking] = useState('')
|
const [streamThinking, setStreamThinking] = useState('')
|
||||||
const [streamToolCalls, setStreamToolCalls] = useState([])
|
const [streamToolCalls, setStreamToolCalls] = useState([])
|
||||||
|
const [streamSegments, setStreamSegments] = useState(null)
|
||||||
const [loaded, setLoaded] = useState(false)
|
const [loaded, setLoaded] = useState(false)
|
||||||
const [tokenInfo, setTokenInfo] = useState({ used: 0, max: 100000, summarizeAt: 80000 })
|
const [tokenInfo, setTokenInfo] = useState({ used: 0, max: 150000, summarizeAt: 120000 })
|
||||||
const [contextCollapsed, setContextCollapsed] = useState(false)
|
const [contextCollapsed, setContextCollapsed] = useState(false)
|
||||||
const [messagesCollapsed, setMessagesCollapsed] = useState(false)
|
const [messagesCollapsed, setMessagesCollapsed] = useState(false)
|
||||||
|
const [sudoModal, setSudoModal] = useState(null)
|
||||||
|
const [attachedImages, setAttachedImages] = useState([])
|
||||||
|
const [activeAgents, setActiveAgents] = useState({ crush: 0, claude: 0 })
|
||||||
|
const [toolModes, setToolModes] = useState({})
|
||||||
|
const [advancedReflection, setAdvancedReflection] = useState(() => {
|
||||||
|
try { return localStorage.getItem('muyue.advancedReflection') === 'true' } catch { return false }
|
||||||
|
})
|
||||||
|
const [collapseHistory, setCollapseHistory] = useState(() => {
|
||||||
|
try { return localStorage.getItem('muyue.collapseHistory') !== 'false' } catch { return true }
|
||||||
|
})
|
||||||
|
const MAX_CRUSH_AGENTS = 2
|
||||||
|
const MAX_CLAUDE_AGENTS = 2
|
||||||
const messagesEnd = useRef(null)
|
const messagesEnd = useRef(null)
|
||||||
const feedRef = useRef(null)
|
const feedRef = useRef(null)
|
||||||
const textareaRef = useRef(null)
|
const textareaRef = useRef(null)
|
||||||
const abortRef = useRef(null)
|
const abortRef = useRef(null)
|
||||||
|
const fileInputRef = useRef(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getChatHistory().then(data => {
|
api.getChatHistory().then(data => {
|
||||||
@@ -325,8 +573,8 @@ export default function Studio({ api }) {
|
|||||||
}
|
}
|
||||||
setTokenInfo({
|
setTokenInfo({
|
||||||
used: data.tokens || 0,
|
used: data.tokens || 0,
|
||||||
max: data.max_tokens || 100000,
|
max: data.max_tokens || 150000,
|
||||||
summarizeAt: data.summarize_at || 80000,
|
summarizeAt: data.summarize_at || 120000,
|
||||||
})
|
})
|
||||||
setLoaded(true)
|
setLoaded(true)
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
@@ -367,8 +615,8 @@ export default function Studio({ api }) {
|
|||||||
const data = await api.getChatHistory()
|
const data = await api.getChatHistory()
|
||||||
setTokenInfo({
|
setTokenInfo({
|
||||||
used: data.tokens || 0,
|
used: data.tokens || 0,
|
||||||
max: data.max_tokens || 100000,
|
max: data.max_tokens || 150000,
|
||||||
summarizeAt: data.summarize_at || 80000,
|
summarizeAt: data.summarize_at || 120000,
|
||||||
})
|
})
|
||||||
} catch {}
|
} catch {}
|
||||||
}, [api])
|
}, [api])
|
||||||
@@ -399,12 +647,38 @@ export default function Studio({ api }) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}, [api, t])
|
}, [api, t])
|
||||||
|
|
||||||
|
const handleImageSelect = useCallback((e) => {
|
||||||
|
const files = Array.from(e.target.files || [])
|
||||||
|
if (files.length === 0) return
|
||||||
|
const remaining = 3 - attachedImages.length
|
||||||
|
const toProcess = files.slice(0, remaining)
|
||||||
|
toProcess.forEach(file => {
|
||||||
|
if (!file.type.match(/^image\/(jpeg|jpg|png|webp)$/)) return
|
||||||
|
if (file.size > 50 * 1024 * 1024) return
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (ev) => {
|
||||||
|
setAttachedImages(prev => {
|
||||||
|
if (prev.length >= 3) return prev
|
||||||
|
return [...prev, { data: ev.target.result, filename: file.name, mime_type: file.type }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
|
e.target.value = ''
|
||||||
|
}, [attachedImages.length])
|
||||||
|
|
||||||
|
const removeImage = useCallback((index) => {
|
||||||
|
setAttachedImages(prev => prev.filter((_, i) => i !== index))
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleSend = useCallback(async () => {
|
const handleSend = useCallback(async () => {
|
||||||
if (!input.trim() || loading) return
|
if (!input.trim() || loading) return
|
||||||
const text = input.trim()
|
const text = input.trim()
|
||||||
|
const images = [...attachedImages]
|
||||||
setInput('')
|
setInput('')
|
||||||
|
setAttachedImages([])
|
||||||
|
|
||||||
const isSlashCommand = (t) => /^\/(clear|help|summarize|export|model(?:\s+\S+)?|plan\s+.+)$/.test(t)
|
const isSlashCommand = (t) => /^\/(clear|help|summarize|model(?:\s+\S+)?)$/.test(t)
|
||||||
|
|
||||||
if (text.startsWith('/') && !isSlashCommand(text)) {
|
if (text.startsWith('/') && !isSlashCommand(text)) {
|
||||||
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'user', content: text, time: new Date().toISOString() }])
|
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'user', content: text, time: new Date().toISOString() }])
|
||||||
@@ -424,19 +698,8 @@ export default function Studio({ api }) {
|
|||||||
'- `/clear` - Effacer la conversation',
|
'- `/clear` - Effacer la conversation',
|
||||||
'- `/summarize` - Résumer la conversation précédente',
|
'- `/summarize` - Résumer la conversation précédente',
|
||||||
'- `/help` - Afficher cette aide',
|
'- `/help` - Afficher cette aide',
|
||||||
'- `/plan <objectif>` - Demander un plan structuré',
|
|
||||||
'- `/export` - Exporter la conversation en Markdown',
|
|
||||||
'- `/model` - Afficher le provider et modèle actifs',
|
'- `/model` - Afficher le provider et modèle actifs',
|
||||||
'- `/model change` - Basculer entre MiniMax et ZAI',
|
'- `/model change` - Basculer entre MiniMax et MiMo',
|
||||||
'',
|
|
||||||
'## Tools disponibles',
|
|
||||||
'- Terminal - Exécuter des commandes',
|
|
||||||
'- read_file - Lire des fichiers',
|
|
||||||
'- list_files - Lister des fichiers',
|
|
||||||
'- search_files - Rechercher des fichiers',
|
|
||||||
'- grep_content - Rechercher dans le contenu',
|
|
||||||
'- get_config - Lire la configuration',
|
|
||||||
'- web_fetch - Récupérer une page web',
|
|
||||||
].join('\n')
|
].join('\n')
|
||||||
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: helpMsg, time: new Date().toISOString() }])
|
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: helpMsg, time: new Date().toISOString() }])
|
||||||
return
|
return
|
||||||
@@ -452,15 +715,15 @@ export default function Studio({ api }) {
|
|||||||
api.getProviders().then(data => {
|
api.getProviders().then(data => {
|
||||||
const providers = data.providers || []
|
const providers = data.providers || []
|
||||||
const minimax = providers.find(p => p.name.toUpperCase() === 'MINIMAX')
|
const minimax = providers.find(p => p.name.toUpperCase() === 'MINIMAX')
|
||||||
const zai = providers.find(p => p.name.toUpperCase() === 'ZAI')
|
const mimo = providers.find(p => p.name.toUpperCase() === 'MIMO')
|
||||||
if (!minimax || !zai) {
|
if (!minimax || !mimo) {
|
||||||
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'MiniMax et ZAI doivent être configurés pour utiliser `/model change`.', time: new Date().toISOString() }])
|
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'MiniMax et MiMo doivent être configurés pour utiliser `/model change`.', time: new Date().toISOString() }])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const active = providers.find(p => p.active)
|
const active = providers.find(p => p.active)
|
||||||
const activeName = active ? active.name.toUpperCase() : ''
|
const activeName = active ? active.name.toUpperCase() : ''
|
||||||
const switchTo = activeName === 'MINIMAX' ? 'ZAI' : 'MINIMAX'
|
const switchTo = activeName === 'MINIMAX' ? 'MIMO' : 'MINIMAX'
|
||||||
const target = switchTo === 'MINIMAX' ? minimax : zai
|
const target = switchTo === 'MINIMAX' ? minimax : mimo
|
||||||
api.saveProvider({ name: target.name, active: true }).then(() => {
|
api.saveProvider({ name: target.name, active: true }).then(() => {
|
||||||
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: `✓ Provider changé: **${target.name}** (${target.model})`, time: new Date().toISOString() }])
|
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: `✓ Provider changé: **${target.name}** (${target.model})`, time: new Date().toISOString() }])
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
@@ -481,31 +744,6 @@ export default function Studio({ api }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (text.startsWith('/plan ')) {
|
|
||||||
const objective = text.slice(6).trim()
|
|
||||||
if (!objective) {
|
|
||||||
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Usage: `/plan <objectif>`\nEx: `/plan créer un fichier de test`', time: new Date().toISOString() }])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setInput(`Crée un plan structuré en étapes numérotées pour: ${objective}. Chaque étape devrait avoir une estimation de complexité et de temps.`)
|
|
||||||
handleSend()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (text === '/export') {
|
|
||||||
api.getChatHistory().then(data => {
|
|
||||||
let markdown = '# Conversation Export\n\n'
|
|
||||||
data.messages?.forEach((msg, i) => {
|
|
||||||
const roleLabel = msg.role === 'user' ? '👤' : (msg.role === 'assistant' ? '🤖' : '⚙️')
|
|
||||||
markdown += `## [${i + 1}] ${roleLabel} ${msg.role}\n${msg.content}\n\n---\n\n`
|
|
||||||
})
|
|
||||||
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Conversation exportée:\n```markdown\n' + markdown + '```', time: new Date().toISOString() }])
|
|
||||||
}).catch(() => {
|
|
||||||
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur: impossible d\'exporter la conversation', time: new Date().toISOString() }])
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const userMsg = { id: Date.now().toString(), role: 'user', content: text, time: new Date().toISOString() }
|
const userMsg = { id: Date.now().toString(), role: 'user', content: text, time: new Date().toISOString() }
|
||||||
setMessages(prev => [...prev, userMsg])
|
setMessages(prev => [...prev, userMsg])
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -517,9 +755,19 @@ export default function Studio({ api }) {
|
|||||||
abortRef.current = controller
|
abortRef.current = controller
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let accumulated = ''
|
let segments = []
|
||||||
|
let textStartIdx = 0
|
||||||
let thinking = ''
|
let thinking = ''
|
||||||
let toolCalls = []
|
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await api.sendChat(text, true, (partial, event) => {
|
await api.sendChat(text, true, (partial, event) => {
|
||||||
if (event && (event.thinking_start || event.thinking_end || event.thinking !== undefined)) {
|
if (event && (event.thinking_start || event.thinking_end || event.thinking !== undefined)) {
|
||||||
@@ -530,25 +778,47 @@ export default function Studio({ api }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event && event.tool_call) {
|
if (event && event.tool_call) {
|
||||||
toolCalls = [...toolCalls, { call: event.tool_call, result: null }]
|
_updateLastText(partial.slice(textStartIdx))
|
||||||
setStreamToolCalls([...toolCalls])
|
textStartIdx = partial.length
|
||||||
accumulated = ''
|
segments.push({ type: 'tool', call: event.tool_call, result: null })
|
||||||
setStreaming('')
|
const toolName = event.tool_call.name
|
||||||
|
if (toolName === 'crush_run' || toolName === 'claude_run') {
|
||||||
|
const agentType = toolName === 'crush_run' ? 'crush' : 'claude'
|
||||||
|
setActiveAgents(prev => ({ ...prev, [agentType]: prev[agentType] + 1 }))
|
||||||
|
}
|
||||||
|
const snap = segments.map(s => ({ ...s }))
|
||||||
|
setStreamToolCalls(snap.filter(s => s.type === 'tool'))
|
||||||
|
setStreamSegments(snap)
|
||||||
|
setStreaming(partial)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event && event.tool_result) {
|
if (event && event.tool_result) {
|
||||||
const idx = toolCalls.findIndex(tc => tc.call && tc.call.tool_call_id === event.tool_result.tool_call_id)
|
if (event.tool_result.sudo_blocked) {
|
||||||
if (idx >= 0) {
|
setSudoModal({ command: event.tool_result.command || event.tool_result.content })
|
||||||
toolCalls[idx] = { ...toolCalls[idx], result: event.tool_result }
|
}
|
||||||
setStreamToolCalls([...toolCalls])
|
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
|
||||||
|
const toolName = segments[segIdx].call?.name
|
||||||
|
if (toolName === 'crush_run' || toolName === 'claude_run') {
|
||||||
|
const agentType = toolName === 'crush_run' ? 'crush' : 'claude'
|
||||||
|
setActiveAgents(prev => ({ ...prev, [agentType]: Math.max(0, prev[agentType] - 1) }))
|
||||||
|
}
|
||||||
|
const snap = segments.map(s => ({ ...s }))
|
||||||
|
setStreamToolCalls(snap.filter(s => s.type === 'tool'))
|
||||||
|
setStreamSegments(snap)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
accumulated = partial
|
_updateLastText(partial.slice(textStartIdx))
|
||||||
setStreaming(partial)
|
setStreaming(partial)
|
||||||
}, controller.signal)
|
const snap = segments.map(s => ({ ...s }))
|
||||||
|
setStreamSegments(snap)
|
||||||
|
}, controller.signal, images, advancedReflection)
|
||||||
|
|
||||||
const finalContent = accumulated || t('studio.noResponse')
|
const allText = segments.filter(s => s.type === 'text').map(s => s.content).join('')
|
||||||
|
const toolSegs = segments.filter(s => s.type === 'tool')
|
||||||
|
const finalContent = allText || t('studio.noResponse')
|
||||||
const aiMsg = {
|
const aiMsg = {
|
||||||
id: (Date.now() + 1).toString(),
|
id: (Date.now() + 1).toString(),
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
@@ -556,14 +826,18 @@ export default function Studio({ api }) {
|
|||||||
time: new Date().toISOString(),
|
time: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
if (thinking) aiMsg.thinking = thinking
|
if (thinking) aiMsg.thinking = thinking
|
||||||
if (toolCalls.length > 0) {
|
if (toolSegs.length > 0 || segments.length > 1) {
|
||||||
aiMsg.content = JSON.stringify({
|
aiMsg.content = JSON.stringify({
|
||||||
content: finalContent,
|
segments: segments.map(s => s.type === 'text'
|
||||||
tool_calls: toolCalls.map(tc => tc.call),
|
? { type: 'text', content: s.content }
|
||||||
tool_results: toolCalls.map(tc => ({
|
: { type: 'tool', call: s.call, result: { content: s.result?.content || '', is_error: s.result?.is_error || false, tool_call_id: s.call?.tool_call_id } }
|
||||||
tool_call_id: tc.call?.tool_call_id,
|
),
|
||||||
result: tc.result?.content || '',
|
content: allText,
|
||||||
is_error: tc.result?.is_error || false,
|
tool_calls: toolSegs.map(s => s.call),
|
||||||
|
tool_results: toolSegs.map(s => ({
|
||||||
|
tool_call_id: s.call?.tool_call_id,
|
||||||
|
result: s.result?.content || '',
|
||||||
|
is_error: s.result?.is_error || false,
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -591,10 +865,13 @@ export default function Studio({ api }) {
|
|||||||
setStreaming('')
|
setStreaming('')
|
||||||
setStreamThinking('')
|
setStreamThinking('')
|
||||||
setStreamToolCalls([])
|
setStreamToolCalls([])
|
||||||
|
setStreamSegments(null)
|
||||||
|
setActiveAgents({ crush: 0, claude: 0 })
|
||||||
|
setToolModes({})
|
||||||
abortRef.current = null
|
abortRef.current = null
|
||||||
refreshTokens()
|
refreshTokens()
|
||||||
}
|
}
|
||||||
}, [input, loading, api, t, handleClear, streaming, refreshTokens, handleSummarize])
|
}, [input, loading, api, t, handleClear, streaming, refreshTokens, handleSummarize, attachedImages])
|
||||||
|
|
||||||
const handleStop = useCallback(() => {
|
const handleStop = useCallback(() => {
|
||||||
if (abortRef.current) {
|
if (abortRef.current) {
|
||||||
@@ -602,7 +879,11 @@ export default function Studio({ api }) {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const COMMANDS = ['/clear', '/summarize', '/help', '/plan', '/export', '/model', '/model change']
|
const handleToolModeChange = useCallback((toolCallId, mode) => {
|
||||||
|
setToolModes(prev => ({ ...prev, [toolCallId]: mode }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const COMMANDS = ['/clear', '/summarize', '/help', '/model', '/model change']
|
||||||
|
|
||||||
const handleKeyDown = (e) => {
|
const handleKeyDown = (e) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
@@ -625,30 +906,62 @@ export default function Studio({ api }) {
|
|||||||
if (afterSlash) {
|
if (afterSlash) {
|
||||||
const partial = afterSlash[0]
|
const partial = afterSlash[0]
|
||||||
const matches = COMMANDS.filter(c => c.startsWith(partial) && c !== partial)
|
const matches = COMMANDS.filter(c => c.startsWith(partial) && c !== partial)
|
||||||
if (matches.length === 1) {
|
if (matches.length >= 1) {
|
||||||
const completed = matches[0] + ' '
|
let completed = matches[0]
|
||||||
const newText = val.slice(0, pos - afterSlash[0].length) + completed + val.slice(pos)
|
for (const m of matches) {
|
||||||
setInput(newText)
|
while (!m.startsWith(completed)) completed = completed.slice(0, -1)
|
||||||
requestAnimationFrame(() => {
|
}
|
||||||
ta.selectionStart = ta.selectionEnd = pos - afterSlash[0].length + completed.length
|
if (completed === partial && matches.length === 1) completed = matches[0]
|
||||||
})
|
if (completed.length > partial.length) {
|
||||||
|
const suffix = completed[completed.length - 1] === ' ' ? '' : (matches.length === 1 ? ' ' : '')
|
||||||
|
completed += suffix
|
||||||
|
const newText = val.slice(0, pos - afterSlash[0].length) + completed + val.slice(pos)
|
||||||
|
setInput(newText)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
ta.selectionStart = ta.selectionEnd = pos - afterSlash[0].length + completed.length
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [summarizedExpanded, setSummarizedExpanded] = useState(false)
|
||||||
|
|
||||||
const handleToggleCollapsed = useCallback(() => {
|
const handleToggleCollapsed = useCallback(() => {
|
||||||
setMessagesCollapsed(prev => !prev)
|
setMessagesCollapsed(prev => !prev)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const renderMessages = () => {
|
const renderMessages = () => {
|
||||||
if (messagesCollapsed && messages.length > 4) {
|
const summarizedMsgs = messages.filter(m => m.summarized)
|
||||||
|
const activeMsgs = messages.filter(m => !m.summarized)
|
||||||
|
|
||||||
|
const renderSummaryBlock = () => summarizedMsgs.length > 0 && (
|
||||||
|
<div className="feed-summary-block">
|
||||||
|
<div className="feed-summary-header" onClick={() => setSummarizedExpanded(prev => !prev)}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||||
|
</svg>
|
||||||
|
<span className="feed-summary-text">Résumé · {summarizedMsgs.length} messages</span>
|
||||||
|
<span className="feed-summary-toggle">{summarizedExpanded ? 'masquer' : 'voir'}</span>
|
||||||
|
</div>
|
||||||
|
{summarizedExpanded && summarizedMsgs.map(msg => (
|
||||||
|
<FeedItem key={msg.id} msg={msg} activeAgents={activeAgents} onModeChange={handleToolModeChange} collapseHistory={collapseHistory} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (messagesCollapsed && activeMsgs.length > 4) {
|
||||||
const visibleCount = 4
|
const visibleCount = 4
|
||||||
const hiddenCount = messages.length - visibleCount
|
const hiddenCount = activeMsgs.length - visibleCount
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{messages.slice(0, visibleCount).map(msg => (
|
{renderSummaryBlock()}
|
||||||
<FeedItem key={msg.id} msg={msg} />
|
{activeMsgs.slice(0, visibleCount).map(msg => (
|
||||||
|
<FeedItem key={msg.id} msg={msg} activeAgents={activeAgents} onModeChange={handleToolModeChange} collapseHistory={collapseHistory} />
|
||||||
))}
|
))}
|
||||||
<div className="feed-collapsed-messages" onClick={handleToggleCollapsed}>
|
<div className="feed-collapsed-messages" onClick={handleToggleCollapsed}>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
@@ -660,9 +973,15 @@ export default function Studio({ api }) {
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return messages.map(msg => (
|
|
||||||
<FeedItem key={msg.id} msg={msg} />
|
return (
|
||||||
))
|
<>
|
||||||
|
{renderSummaryBlock()}
|
||||||
|
{activeMsgs.map(msg => (
|
||||||
|
<FeedItem key={msg.id} msg={msg} activeAgents={activeAgents} onModeChange={handleToolModeChange} collapseHistory={collapseHistory} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!loaded) {
|
if (!loaded) {
|
||||||
@@ -683,7 +1002,7 @@ export default function Studio({ api }) {
|
|||||||
<div className="studio-feed" ref={feedRef}>
|
<div className="studio-feed" ref={feedRef}>
|
||||||
{renderMessages()}
|
{renderMessages()}
|
||||||
{(streaming || streamThinking || loading || streamToolCalls.length > 0) && (
|
{(streaming || streamThinking || loading || streamToolCalls.length > 0) && (
|
||||||
<StreamingItem content={streaming} thinking={streamThinking} toolCalls={streamToolCalls} />
|
<StreamingItem content={streaming} thinking={streamThinking} toolCalls={streamToolCalls} segments={streamSegments} activeAgents={activeAgents} onModeChange={handleToolModeChange} collapseHistory={collapseHistory} />
|
||||||
)}
|
)}
|
||||||
<div ref={messagesEnd} style={{ height: '24px' }} />
|
<div ref={messagesEnd} style={{ height: '24px' }} />
|
||||||
</div>
|
</div>
|
||||||
@@ -698,6 +1017,16 @@ export default function Studio({ api }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="studio-input-area">
|
<div className="studio-input-area">
|
||||||
|
{attachedImages.length > 0 && (
|
||||||
|
<div className="studio-image-previews">
|
||||||
|
{attachedImages.map((img, i) => (
|
||||||
|
<div key={i} className="studio-image-preview">
|
||||||
|
<img src={img.data} alt={img.filename} />
|
||||||
|
<button className="studio-image-remove" onClick={() => removeImage(i)}>×</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className={`studio-token-bar ${contextCollapsed === true ? 'compressed' : ''}`}>
|
<div className={`studio-token-bar ${contextCollapsed === true ? 'compressed' : ''}`}>
|
||||||
<div className={`studio-token-track ${contextCollapsed === true ? 'compressed' : ''}`}>
|
<div className={`studio-token-track ${contextCollapsed === true ? 'compressed' : ''}`}>
|
||||||
<div
|
<div
|
||||||
@@ -717,6 +1046,54 @@ export default function Studio({ api }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="studio-input-row">
|
<div className="studio-input-row">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
multiple
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handleImageSelect}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="studio-attach-btn"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={loading || attachedImages.length >= 3}
|
||||||
|
title="Joindre des images (max 3)"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="studio-attach-btn"
|
||||||
|
onClick={() => {
|
||||||
|
const next = !advancedReflection
|
||||||
|
setAdvancedReflection(next)
|
||||||
|
try { localStorage.setItem('muyue.advancedReflection', String(next)) } catch {}
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
title={advancedReflection ? "Réflexion avancée: ON (un autre modèle produit un rapport préalable)" : "Réflexion avancée: OFF"}
|
||||||
|
style={advancedReflection ? { color: 'var(--accent, #6c5ce7)', borderColor: 'var(--accent, #6c5ce7)' } : undefined}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="studio-attach-btn"
|
||||||
|
onClick={() => {
|
||||||
|
const next = !collapseHistory
|
||||||
|
setCollapseHistory(next)
|
||||||
|
try { localStorage.setItem('muyue.collapseHistory', String(next)) } catch {}
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
title={collapseHistory ? "Historique compressé (dernière action visible)" : "Historique complet (tout visible)"}
|
||||||
|
style={collapseHistory ? { color: 'var(--accent, #6c5ce7)', borderColor: 'var(--accent, #6c5ce7)' } : undefined}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={input}
|
value={input}
|
||||||
@@ -744,9 +1121,25 @@ export default function Studio({ api }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="studio-input-hint">
|
<div className="studio-input-hint">
|
||||||
{t('studio.inputHint')} · /clear /summarize /help /plan /export /model /model change
|
{t('studio.inputHint')} · /clear /summarize /help /model · @fichier.ext pour joindre un fichier{attachedImages.length > 0 && ` · ${attachedImages.length} image${attachedImages.length > 1 ? 's' : ''} attachée${attachedImages.length > 1 ? 's' : ''}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{sudoModal && (
|
||||||
|
<div className="shell-modal-overlay" onClick={() => setSudoModal(null)}>
|
||||||
|
<div className="shell-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="shell-modal-header">Commande bloquée</div>
|
||||||
|
<div className="shell-modal-body">
|
||||||
|
<p style={{ color: 'var(--accent-bright)', fontWeight: 600, marginBottom: 8 }}>L'IA a tenté d'exécuter une commande nécessitant des privilèges administrateur :</p>
|
||||||
|
<pre style={{ background: 'var(--bg)', padding: '10px 12px', borderRadius: 'var(--radius)', fontSize: 12, overflow: 'auto', fontFamily: 'var(--font-mono)' }}>{sudoModal.command}</pre>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', fontSize: 12, marginTop: 12 }}>La commande a été bloquée. L'IA en a été informée et cherchera une alternative.</p>
|
||||||
|
</div>
|
||||||
|
<div className="shell-modal-footer">
|
||||||
|
<button className="primary" onClick={() => setSudoModal(null)}>Compris</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
241
web/src/components/Tests.jsx
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
|
import { TestTube2, Copy, RefreshCw, CheckCircle2, AlertTriangle, Globe, Terminal as TerminalIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function Tests({ api }) {
|
||||||
|
const [snippet, setSnippet] = useState(null)
|
||||||
|
const [snippetError, setSnippetError] = useState('')
|
||||||
|
const [sessions, setSessions] = useState([])
|
||||||
|
const [console_, setConsole_] = useState([])
|
||||||
|
const [activeSessionId, setActiveSessionId] = useState('')
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const pollRef = useRef(null)
|
||||||
|
|
||||||
|
const refreshSnippet = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.getTestSnippet()
|
||||||
|
setSnippet(data)
|
||||||
|
setSnippetError('')
|
||||||
|
} catch (err) {
|
||||||
|
setSnippetError(err.message || 'Failed to load snippet')
|
||||||
|
}
|
||||||
|
}, [api])
|
||||||
|
|
||||||
|
const refreshSessions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.getTestSessions()
|
||||||
|
const next = data.sessions || []
|
||||||
|
setSessions(next)
|
||||||
|
if (!activeSessionId && next.length > 0) {
|
||||||
|
setActiveSessionId(next[0].id)
|
||||||
|
} else if (activeSessionId && !next.find(s => s.id === activeSessionId)) {
|
||||||
|
setActiveSessionId(next.length > 0 ? next[0].id : '')
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}, [api, activeSessionId])
|
||||||
|
|
||||||
|
const refreshConsole = useCallback(async () => {
|
||||||
|
if (!activeSessionId) {
|
||||||
|
setConsole_([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await api.getTestConsole(activeSessionId)
|
||||||
|
setConsole_(data.console || [])
|
||||||
|
} catch {
|
||||||
|
setConsole_([])
|
||||||
|
}
|
||||||
|
}, [api, activeSessionId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshSnippet()
|
||||||
|
}, [refreshSnippet])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshSessions()
|
||||||
|
refreshConsole()
|
||||||
|
pollRef.current = setInterval(() => {
|
||||||
|
refreshSessions()
|
||||||
|
refreshConsole()
|
||||||
|
}, 2000)
|
||||||
|
return () => clearInterval(pollRef.current)
|
||||||
|
}, [refreshSessions, refreshConsole])
|
||||||
|
|
||||||
|
const copySnippet = useCallback(async () => {
|
||||||
|
if (!snippet) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(snippet.snippet)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 1500)
|
||||||
|
} catch {}
|
||||||
|
}, [snippet])
|
||||||
|
|
||||||
|
const activeSession = sessions.find(s => s.id === activeSessionId) || null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tests-layout" style={{ padding: '20px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '20px', height: '100%', overflow: 'auto' }}>
|
||||||
|
<section className="tests-pane">
|
||||||
|
<header style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||||
|
<TestTube2 size={18} />
|
||||||
|
<h2 style={{ margin: 0, fontSize: '1.1em' }}>Tests pilotés par l'IA</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p style={{ marginTop: 0, opacity: 0.85, lineHeight: 1.5 }}>
|
||||||
|
Donnez à l'IA Studio le contrôle d'un onglet de votre navigateur pour tester chaque bouton et détecter les erreurs console.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ borderTop: '1px solid var(--border, rgba(128,128,128,0.3))', paddingTop: 12, marginTop: 12 }}>
|
||||||
|
<h3 style={{ fontSize: '0.95em', margin: '0 0 8px' }}>1. Connexion</h3>
|
||||||
|
<ol style={{ paddingLeft: 18, lineHeight: 1.6 }}>
|
||||||
|
<li>Ouvrez la page à tester dans n'importe quel navigateur (Chrome, Firefox, Edge…).</li>
|
||||||
|
<li>Ouvrez la console développeur (<kbd>F12</kbd>).</li>
|
||||||
|
<li>Collez ce snippet et appuyez sur <kbd>Entrée</kbd> :</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{snippetError && (
|
||||||
|
<div style={{ background: 'rgba(220,80,80,0.1)', border: '1px solid rgba(220,80,80,0.3)', padding: 8, borderRadius: 4, marginBottom: 8 }}>
|
||||||
|
{snippetError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ position: 'relative', marginBottom: 12 }}>
|
||||||
|
<pre style={{
|
||||||
|
background: 'var(--bg-secondary, rgba(0,0,0,0.3))',
|
||||||
|
padding: '10px 12px',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: '0.75em',
|
||||||
|
maxHeight: 180,
|
||||||
|
overflow: 'auto',
|
||||||
|
border: '1px solid var(--border, rgba(128,128,128,0.3))',
|
||||||
|
margin: 0,
|
||||||
|
}}>
|
||||||
|
{snippet?.snippet || 'Chargement…'}
|
||||||
|
</pre>
|
||||||
|
<button
|
||||||
|
onClick={copySnippet}
|
||||||
|
disabled={!snippet}
|
||||||
|
title="Copier"
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: 6, right: 6,
|
||||||
|
background: 'var(--bg-tertiary, rgba(255,255,255,0.08))',
|
||||||
|
border: '1px solid var(--border, rgba(128,128,128,0.3))',
|
||||||
|
color: 'inherit', padding: '4px 8px', borderRadius: 3,
|
||||||
|
cursor: 'pointer', fontSize: '0.75em',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy size={11} /> {copied ? 'Copié !' : 'Copier'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={refreshSnippet} style={{ background: 'transparent', border: '1px solid var(--border, rgba(128,128,128,0.3))', color: 'inherit', padding: '4px 10px', borderRadius: 3, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: '0.85em' }}>
|
||||||
|
<RefreshCw size={12} /> Régénérer le token
|
||||||
|
</button>
|
||||||
|
<small style={{ display: 'block', opacity: 0.6, marginTop: 4 }}>
|
||||||
|
Le token expire après {snippet?.expires_in ? Math.round(snippet.expires_in / 60) : 5} minutes ou dès la première connexion.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ borderTop: '1px solid var(--border, rgba(128,128,128,0.3))', paddingTop: 12, marginTop: 16 }}>
|
||||||
|
<h3 style={{ fontSize: '0.95em', margin: '0 0 8px' }}>2. Pilotage par l'IA</h3>
|
||||||
|
<p style={{ margin: '0 0 8px', lineHeight: 1.5 }}>
|
||||||
|
Une fois la session connectée, allez dans l'onglet <strong>Studio</strong> et demandez par exemple :
|
||||||
|
</p>
|
||||||
|
<pre style={{ background: 'var(--bg-secondary, rgba(0,0,0,0.3))', padding: 8, borderRadius: 4, fontSize: '0.85em', margin: 0 }}>
|
||||||
|
{`Teste tous les boutons de cette page,
|
||||||
|
clique sur chacun, et dis-moi
|
||||||
|
lesquels déclenchent une erreur console.`}
|
||||||
|
</pre>
|
||||||
|
<p style={{ margin: '8px 0 0', opacity: 0.75, fontSize: '0.85em' }}>
|
||||||
|
L'IA dispose de l'outil <code>browser_test</code> avec les actions <code>list_clickables</code>, <code>click</code>, <code>console</code>, <code>eval</code>, <code>type</code>, <code>current_url</code>, <code>wait</code>, <code>summary</code>.
|
||||||
|
</p>
|
||||||
|
<p style={{ margin: '8px 0 0', padding: 8, fontSize: '0.85em', background: 'var(--accent-bg, rgba(108,92,231,0.1))', border: '1px solid var(--accent, #6c5ce7)', borderRadius: 4 }}>
|
||||||
|
<strong>Réflexion avancée auto :</strong> tant qu'au moins une session de test est connectée, chaque message dans Studio utilise automatiquement la réflexion avancée — un second modèle (s'il est configuré) produit un rapport d'analyse préalable injecté dans le prompt actif. Le toggle Studio est ignoré pendant la session.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="tests-pane">
|
||||||
|
<header style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, justifyContent: 'space-between' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Globe size={16} />
|
||||||
|
<h2 style={{ margin: 0, fontSize: '1.1em' }}>Sessions connectées</h2>
|
||||||
|
</div>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: '0.85em' }}>
|
||||||
|
{sessions.length > 0 ? <CheckCircle2 size={14} color="#3aaa61" /> : <span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: '50%', background: '#888' }} />}
|
||||||
|
{sessions.length} session{sessions.length > 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{sessions.length === 0 ? (
|
||||||
|
<div style={{ padding: 16, textAlign: 'center', opacity: 0.7, border: '1px dashed var(--border, rgba(128,128,128,0.3))', borderRadius: 4 }}>
|
||||||
|
<AlertTriangle size={20} style={{ opacity: 0.4 }} />
|
||||||
|
<div style={{ marginTop: 6 }}>Aucune session active.</div>
|
||||||
|
<small>Collez le snippet dans une page pour démarrer.</small>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 12 }}>
|
||||||
|
{sessions.map(s => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => setActiveSessionId(s.id)}
|
||||||
|
style={{
|
||||||
|
textAlign: 'left',
|
||||||
|
background: s.id === activeSessionId ? 'var(--accent-bg, rgba(108,92,231,0.15))' : 'transparent',
|
||||||
|
border: '1px solid ' + (s.id === activeSessionId ? 'var(--accent, #6c5ce7)' : 'var(--border, rgba(128,128,128,0.3))'),
|
||||||
|
color: 'inherit',
|
||||||
|
padding: 8, borderRadius: 4, cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 500, fontSize: '0.9em', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{s.title || s.url || s.id}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.75em', opacity: 0.65, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{s.url} · session {s.id.slice(0, 8)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeSession && (
|
||||||
|
<div style={{ borderTop: '1px solid var(--border, rgba(128,128,128,0.3))', paddingTop: 12 }}>
|
||||||
|
<header style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
|
||||||
|
<TerminalIcon size={14} />
|
||||||
|
<h3 style={{ margin: 0, fontSize: '0.95em' }}>Console (live, dernières {console_.length})</h3>
|
||||||
|
</header>
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--bg-secondary, rgba(0,0,0,0.3))',
|
||||||
|
padding: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
maxHeight: 380,
|
||||||
|
overflow: 'auto',
|
||||||
|
fontSize: '0.8em',
|
||||||
|
fontFamily: 'var(--font-mono, ui-monospace, monospace)',
|
||||||
|
border: '1px solid var(--border, rgba(128,128,128,0.3))',
|
||||||
|
}}>
|
||||||
|
{console_.length === 0 ? (
|
||||||
|
<div style={{ opacity: 0.5 }}>(aucun message console)</div>
|
||||||
|
) : (
|
||||||
|
console_.map((c, i) => (
|
||||||
|
<div key={i} style={{ color: levelColor(c.level), padding: '2px 0', borderBottom: '1px dashed rgba(128,128,128,0.15)' }}>
|
||||||
|
<span style={{ opacity: 0.55, fontSize: '0.85em' }}>[{c.time?.slice(11, 19)} {c.level}]</span> {c.message}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function levelColor(lvl) {
|
||||||
|
switch (lvl) {
|
||||||
|
case 'error': return '#ff6b6b'
|
||||||
|
case 'warn': return '#f5a623'
|
||||||
|
case 'info': return '#4dabf7'
|
||||||
|
case 'debug': return '#888'
|
||||||
|
default: return 'inherit'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,12 @@ const en = {
|
|||||||
switchWindow: 'Switch window',
|
switchWindow: 'Switch window',
|
||||||
sendMessage: 'Send message',
|
sendMessage: 'Send message',
|
||||||
newLine: 'New line',
|
newLine: 'New line',
|
||||||
|
copy: 'Copy',
|
||||||
|
paste: 'Paste',
|
||||||
|
search: 'Search',
|
||||||
|
zoom: 'Zoom +/−',
|
||||||
|
switchTab: 'Switch tab',
|
||||||
|
nextTab: 'Next tab',
|
||||||
runCommand: 'Run command',
|
runCommand: 'Run command',
|
||||||
commandHistory: 'Command history',
|
commandHistory: 'Command history',
|
||||||
},
|
},
|
||||||
@@ -114,6 +120,8 @@ const en = {
|
|||||||
port: 'Port',
|
port: 'Port',
|
||||||
user: 'User',
|
user: 'User',
|
||||||
keyPath: 'SSH key path',
|
keyPath: 'SSH key path',
|
||||||
|
password: 'Password',
|
||||||
|
passwordHint: 'requires sshpass installed',
|
||||||
connect: 'Connect',
|
connect: 'Connect',
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
@@ -203,8 +211,27 @@ const en = {
|
|||||||
resetConfirm: 'Are you sure? All preferences will be erased.',
|
resetConfirm: 'Are you sure? All preferences will be erased.',
|
||||||
resetDone: 'Settings reset.',
|
resetDone: 'Settings reset.',
|
||||||
applyStarship: 'Apply starship',
|
applyStarship: 'Apply starship',
|
||||||
|
apply: 'Apply',
|
||||||
|
remove: 'Remove',
|
||||||
starshipApplied: 'Starship theme applied! Restart your shell to see the result.',
|
starshipApplied: 'Starship theme applied! Restart your shell to see the result.',
|
||||||
starshipError: 'Failed to apply starship theme.',
|
starshipError: 'Failed to apply starship theme.',
|
||||||
|
systemConfig: 'System Configuration',
|
||||||
|
aiToolsConfig: 'Tools & Environments',
|
||||||
|
configureViaAI: 'Configure',
|
||||||
|
toolCrushDesc: 'Autonomous AI agent for code writing and refactoring.',
|
||||||
|
toolClaudeDesc: 'AI coding assistant by Anthropic.',
|
||||||
|
toolGhDesc: 'Command-line interface for GitHub.',
|
||||||
|
toolDockerDesc: 'Application containerization platform.',
|
||||||
|
toolGoDesc: 'Programming language and runtime environment.',
|
||||||
|
toolNodeDesc: 'JavaScript runtime and package manager.',
|
||||||
|
toolPythonDesc: 'Programming language, pip and uv manager.',
|
||||||
|
toolStarshipDesc: 'Modern and customizable shell prompt.',
|
||||||
|
systemUpdate: 'System Update',
|
||||||
|
systemUpdateDescSudo: 'Updates the system and all tools (sshpass, crush, claude, gh, etc.).',
|
||||||
|
systemUpdateDescNoSudo: 'Shows update commands to run manually.',
|
||||||
|
updateBtn: 'Update',
|
||||||
|
notInstalled: 'Not installed',
|
||||||
|
install: 'Install',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ const fr = {
|
|||||||
switchWindow: 'Changer de fen\u00eatre',
|
switchWindow: 'Changer de fen\u00eatre',
|
||||||
sendMessage: 'Envoyer le message',
|
sendMessage: 'Envoyer le message',
|
||||||
newLine: 'Nouvelle ligne',
|
newLine: 'Nouvelle ligne',
|
||||||
|
copy: 'Copier',
|
||||||
|
paste: 'Coller',
|
||||||
|
search: 'Rechercher',
|
||||||
|
zoom: 'Zoom +/\u2212',
|
||||||
|
switchTab: 'Changer d\u2019onglet',
|
||||||
|
nextTab: 'Onglet suivant',
|
||||||
runCommand: 'Ex\u00e9cuter',
|
runCommand: 'Ex\u00e9cuter',
|
||||||
commandHistory: 'Historique',
|
commandHistory: 'Historique',
|
||||||
},
|
},
|
||||||
@@ -114,6 +120,8 @@ const fr = {
|
|||||||
port: 'Port',
|
port: 'Port',
|
||||||
user: 'Utilisateur',
|
user: 'Utilisateur',
|
||||||
keyPath: 'Chemin cl\u00e9 SSH',
|
keyPath: 'Chemin cl\u00e9 SSH',
|
||||||
|
password: 'Mot de passe',
|
||||||
|
passwordHint: 'n\u00e9cessite sshpass install\u00e9',
|
||||||
connect: 'Se connecter',
|
connect: 'Se connecter',
|
||||||
save: 'Enregistrer',
|
save: 'Enregistrer',
|
||||||
cancel: 'Annuler',
|
cancel: 'Annuler',
|
||||||
@@ -203,8 +211,27 @@ const fr = {
|
|||||||
resetConfirm: '\u00cates-vous s\u00fbr ? Toutes les pr\u00e9f\u00e9rences seront effac\u00e9es.',
|
resetConfirm: '\u00cates-vous s\u00fbr ? Toutes les pr\u00e9f\u00e9rences seront effac\u00e9es.',
|
||||||
resetDone: 'Param\u00e8tres r\u00e9initialis\u00e9s.',
|
resetDone: 'Param\u00e8tres r\u00e9initialis\u00e9s.',
|
||||||
applyStarship: 'Appliquer starship',
|
applyStarship: 'Appliquer starship',
|
||||||
|
apply: 'Appliquer',
|
||||||
|
remove: 'Retirer',
|
||||||
starshipApplied: 'Th\u00e8me starship appliqu\u00e9 ! Red\u00e9marrez votre shell pour voir le r\u00e9sultat.',
|
starshipApplied: 'Th\u00e8me starship appliqu\u00e9 ! Red\u00e9marrez votre shell pour voir le r\u00e9sultat.',
|
||||||
starshipError: '\u00c9chec de l\u2019application du th\u00e8me starship.',
|
starshipError: '\u00c9chec de l\u2019application du th\u00e8me starship.',
|
||||||
|
systemConfig: 'Configuration Syst\u00e8me',
|
||||||
|
aiToolsConfig: 'Outils & Environnements',
|
||||||
|
configureViaAI: 'Configurer',
|
||||||
|
toolCrushDesc: 'Agent IA autonome pour l\u2019\u00e9criture et le refactoring de code.',
|
||||||
|
toolClaudeDesc: 'Assistant de codage IA par Anthropic.',
|
||||||
|
toolGhDesc: 'Interface en ligne de commande pour GitHub.',
|
||||||
|
toolDockerDesc: 'Plateforme de conteneurisation d\u2019applications.',
|
||||||
|
toolGoDesc: 'Langage de programmation et environnement d\u2019ex\u00e9cution.',
|
||||||
|
toolNodeDesc: 'Environnement d\u2019ex\u00e9cution JavaScript et gestionnaire de paquets.',
|
||||||
|
toolPythonDesc: 'Langage de programmation, pip et gestionnaire uv.',
|
||||||
|
toolStarshipDesc: 'Prompt shell moderne et personnalisable.',
|
||||||
|
systemUpdate: 'Mise à jour système',
|
||||||
|
systemUpdateDescSudo: 'Met à jour le système et tous les outils (sshpass, crush, claude, gh, etc.).',
|
||||||
|
systemUpdateDescNoSudo: 'Affiche les commandes de mise à jour à exécuter manuellement.',
|
||||||
|
updateBtn: 'Mettre à jour',
|
||||||
|
notInstalled: 'Non installé',
|
||||||
|
install: 'Installer',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.header-clock { font-family: var(--font-mono); font-size: 12px; color: var(--accent); font-weight: 600; }
|
.header-clock { font-family: var(--font-mono); font-size: 12px; color: var(--accent); font-weight: 600; }
|
||||||
|
|
||||||
.content { flex: 1; overflow: hidden; position: relative; }
|
.content { flex: 1; overflow: hidden; position: relative; }
|
||||||
.content > div { height: 100%; }
|
.content > div { position: absolute; inset: 0; overflow: hidden; }
|
||||||
.tab-hidden { display: none; }
|
.tab-hidden { display: none; }
|
||||||
|
|
||||||
.statusbar {
|
.statusbar {
|
||||||
@@ -329,6 +329,14 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
|
|
||||||
.shell-tab-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
|
.shell-tab-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.shell-zoom-badge {
|
||||||
|
font-size: 10px; font-family: var(--font-mono); font-weight: 600;
|
||||||
|
color: var(--accent); background: var(--accent-bg);
|
||||||
|
padding: 2px 6px; border-radius: 3px;
|
||||||
|
border: 1px solid var(--accent-dim);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.shell-new-tab-wrapper { position: relative; }
|
.shell-new-tab-wrapper { position: relative; }
|
||||||
.shell-new-tab-btn {
|
.shell-new-tab-btn {
|
||||||
display: flex; align-items: center; gap: 2px;
|
display: flex; align-items: center; gap: 2px;
|
||||||
@@ -371,11 +379,11 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.shell-menu-item-row { display: flex; align-items: center; }
|
.shell-menu-item-row { display: flex; align-items: center; }
|
||||||
.shell-menu-item-icon {
|
.shell-menu-item-icon {
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
width: 24px; height: 24px; border-radius: var(--radius);
|
width: 26px; height: 26px; border-radius: var(--radius);
|
||||||
background: transparent; border: none; color: var(--text-disabled);
|
background: var(--bg-card); border: 1px solid var(--border); color: var(--text-secondary);
|
||||||
cursor: pointer; transition: all 0.1s; flex-shrink: 0;
|
cursor: pointer; transition: all 0.1s; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.shell-menu-item-icon:hover { background: var(--accent-bg); color: var(--accent); }
|
.shell-menu-item-icon:hover { background: var(--accent-bg); color: var(--accent); border-color: var(--accent-dim); }
|
||||||
.shell-menu-empty {
|
.shell-menu-empty {
|
||||||
font-size: 12px; color: var(--text-disabled); padding: 8px 10px;
|
font-size: 12px; color: var(--text-disabled); padding: 8px 10px;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
@@ -383,6 +391,36 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.shell-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; }
|
.shell-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; }
|
||||||
|
|
||||||
.shell-xterm-wrapper { flex: 1; min-height: 0; background: var(--bg); overflow: hidden; position: relative; }
|
.shell-xterm-wrapper { flex: 1; min-height: 0; background: var(--bg); overflow: hidden; position: relative; }
|
||||||
|
|
||||||
|
.shell-search-bar {
|
||||||
|
position: absolute; top: 8px; right: 12px; z-index: 20;
|
||||||
|
display: flex; align-items: center; gap: 4px;
|
||||||
|
background: var(--bg-elevated); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); padding: 4px 6px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.shell-search-icon { color: var(--text-tertiary); flex-shrink: 0; }
|
||||||
|
.shell-search-input {
|
||||||
|
width: 200px; font-size: 12px; padding: 3px 6px; border-radius: 4px;
|
||||||
|
background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border);
|
||||||
|
font-family: var(--font-mono); outline: none;
|
||||||
|
}
|
||||||
|
.shell-search-input:focus { border-color: var(--accent); }
|
||||||
|
.shell-search-nav {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 24px; height: 24px; border-radius: 4px;
|
||||||
|
background: transparent; border: 1px solid var(--border);
|
||||||
|
color: var(--text-tertiary); cursor: pointer; font-size: 12px;
|
||||||
|
padding: 0; transition: all 0.1s;
|
||||||
|
}
|
||||||
|
.shell-search-nav:hover { background: var(--bg-hover); color: var(--text-primary); border-color: var(--accent-dark); }
|
||||||
|
.shell-search-close {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 24px; height: 24px; border-radius: 4px;
|
||||||
|
background: transparent; border: none;
|
||||||
|
color: var(--text-disabled); cursor: pointer; padding: 0;
|
||||||
|
}
|
||||||
|
.shell-search-close:hover { color: var(--accent); }
|
||||||
.shell-xterm-instance {
|
.shell-xterm-instance {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -404,6 +442,9 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
|
|
||||||
.shell-ai-col { width: 320px; max-width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; overflow: hidden; }
|
.shell-ai-col { width: 320px; max-width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; overflow: hidden; }
|
||||||
.ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); display: flex; align-items: center; justify-content: space-between; }
|
.ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.sudo-indicator { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
||||||
|
.sudo-indicator.sudo-ok { background: #22c55e; box-shadow: 0 0 6px rgba(34, 197, 94, 0.5); }
|
||||||
|
.sudo-indicator.sudo-blocked { background: #ef4444; box-shadow: 0 0 6px rgba(239, 68, 68, 0.5); }
|
||||||
.shell-analyze-btn {
|
.shell-analyze-btn {
|
||||||
display: flex; align-items: center; gap: 4px;
|
display: flex; align-items: center; gap: 4px;
|
||||||
padding: 4px 10px; border-radius: var(--radius);
|
padding: 4px 10px; border-radius: var(--radius);
|
||||||
@@ -418,8 +459,10 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.shell-ai-token-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.4s, background 0.3s; }
|
.shell-ai-token-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.4s, background 0.3s; }
|
||||||
.shell-ai-token-fill.warn { background: var(--warning); }
|
.shell-ai-token-fill.warn { background: var(--warning); }
|
||||||
.shell-ai-token-text { font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); white-space: nowrap; }
|
.shell-ai-token-text { font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); white-space: nowrap; }
|
||||||
.ai-panel-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
|
.ai-panel-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
||||||
.ai-message { padding: 8px 12px; border-radius: var(--radius); font-size: 13px; line-height: 1.5; word-break: break-word; }
|
.ai-message { padding: 8px 12px; border-radius: var(--radius); font-size: 13px; line-height: 1.5; word-break: break-word; }
|
||||||
|
.ai-message.user { background: var(--bg-elevated); border-left: 3px solid var(--accent-bright); color: var(--text-primary); }
|
||||||
|
.ai-message.user.analysis { border-left-color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, var(--bg-elevated)); }
|
||||||
.ai-message.assistant { background: var(--bg-card); border-left: 3px solid var(--accent); }
|
.ai-message.assistant { background: var(--bg-card); border-left: 3px solid var(--accent); }
|
||||||
.ai-message.system { background: var(--bg-elevated); border-left: 3px solid var(--info); font-style: italic; color: var(--text-tertiary); font-size: 12px; }
|
.ai-message.system { background: var(--bg-elevated); border-left: 3px solid var(--info); font-style: italic; color: var(--text-tertiary); font-size: 12px; }
|
||||||
.ai-message.assistant { background: var(--bg-card); border-left: 3px solid var(--accent); }
|
.ai-message.assistant { background: var(--bg-card); border-left: 3px solid var(--accent); }
|
||||||
@@ -429,6 +472,13 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.ai-message.tool .tool-args { font-family: var(--font-mono); font-size: 12px; color: var(--text-tertiary); margin-top: 4px; }
|
.ai-message.tool .tool-args { font-family: var(--font-mono); font-size: 12px; color: var(--text-tertiary); margin-top: 4px; }
|
||||||
.ai-panel-input { display: flex; gap: 6px; padding: 10px 12px; border-top: 1px solid var(--border); }
|
.ai-panel-input { display: flex; gap: 6px; padding: 10px 12px; border-top: 1px solid var(--border); }
|
||||||
.ai-panel-input input { flex: 1; font-size: 13px; padding: 6px 10px; }
|
.ai-panel-input input { flex: 1; font-size: 13px; padding: 6px 10px; }
|
||||||
|
.ai-panel-input .ai-clear-btn {
|
||||||
|
flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||||
|
padding: 10px 16px; border-radius: var(--radius); border: 1px solid var(--accent);
|
||||||
|
background: var(--accent-bg); color: var(--accent); font-size: 13px; font-weight: 700;
|
||||||
|
cursor: pointer; transition: all 0.15s; font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
.ai-panel-input .ai-clear-btn:hover { background: var(--accent); color: #fff; }
|
||||||
|
|
||||||
.shell-code-block {
|
.shell-code-block {
|
||||||
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
@@ -454,6 +504,25 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
}
|
}
|
||||||
.shell-code-actions button:last-child { border-right: none; }
|
.shell-code-actions button:last-child { border-right: none; }
|
||||||
.shell-code-actions button:hover { background: var(--accent-bg); color: var(--accent); }
|
.shell-code-actions button:hover { background: var(--accent-bg); color: var(--accent); }
|
||||||
|
.shell-code-actions button.copied { background: var(--accent-bg); color: var(--accent); animation: copy-flash 0.3s ease; }
|
||||||
|
|
||||||
|
.shell-mermaid-container { padding: 12px; background: var(--bg); overflow-x: auto; display: flex; justify-content: center; }
|
||||||
|
.shell-mermaid-container svg { max-width: 100%; height: auto; }
|
||||||
|
.shell-mermaid-loading { padding: 16px; text-align: center; color: var(--text-tertiary); font-size: 12px; }
|
||||||
|
.shell-mermaid-error { padding: 10px 12px; color: var(--accent-bright); font-family: var(--font-mono); font-size: 12px; white-space: pre-wrap; }
|
||||||
|
|
||||||
|
.ai-message table { width: 100%; border-collapse: collapse; margin: 6px 0; font-size: 12px; display: block; overflow-x: auto; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
||||||
|
.ai-message thead, .ai-message tbody { display: table-row-group; }
|
||||||
|
.ai-message th { background: var(--bg-surface); padding: 4px 8px; text-align: left; font-weight: 600; border: 1px solid var(--border); color: var(--text-secondary); white-space: nowrap; }
|
||||||
|
.ai-message td { padding: 3px 8px; border: 1px solid var(--border); color: var(--text-primary); white-space: nowrap; }
|
||||||
|
.ai-message tr { display: table-row; }
|
||||||
|
.ai-message tr:nth-child(even) td { background: var(--bg-surface); }
|
||||||
|
|
||||||
|
@keyframes copy-flash {
|
||||||
|
0% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.05); background: color-mix(in srgb, var(--accent) 20%, transparent); }
|
||||||
|
100% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
.shell-analysis-modal {
|
.shell-analysis-modal {
|
||||||
background: var(--bg-elevated); border: 1px solid var(--border);
|
background: var(--bg-elevated); border: 1px solid var(--border);
|
||||||
@@ -469,6 +538,18 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
flex: 1; overflow-y: auto; padding: 20px; font-size: 14px; line-height: 1.5;
|
flex: 1; overflow-y: auto; padding: 20px; font-size: 14px; line-height: 1.5;
|
||||||
color: var(--text-primary); word-break: break-word;
|
color: var(--text-primary); word-break: break-word;
|
||||||
}
|
}
|
||||||
|
.shell-analysis-modal-body table { width: 100%; border-collapse: collapse; margin: 8px 0; font-size: 13px; }
|
||||||
|
.shell-analysis-modal-body th { background: var(--bg-surface); padding: 4px 10px; text-align: left; font-weight: 600; border: 1px solid var(--border); color: var(--text-secondary); }
|
||||||
|
.shell-analysis-modal-body td { padding: 3px 10px; border: 1px solid var(--border); color: var(--text-primary); }
|
||||||
|
.shell-analysis-modal-body tr:nth-child(even) td { background: var(--bg-surface); }
|
||||||
|
.shell-analysis-modal-body .msg-h3 { font-size: 18px; font-weight: 700; color: var(--text-primary); margin: 16px 0 6px; display: block; }
|
||||||
|
.shell-analysis-modal-body .msg-h4 { font-size: 15px; font-weight: 700; color: var(--text-secondary); margin: 12px 0 4px; display: block; }
|
||||||
|
.shell-analysis-modal-body .msg-h2 { font-size: 20px; font-weight: 700; color: var(--accent); margin: 20px 0 8px; display: block; }
|
||||||
|
.shell-analysis-modal-body .msg-bullet { display: block; padding-left: 4px; margin: 2px 0; color: var(--text-primary); }
|
||||||
|
.shell-analysis-modal-body .msg-step { display: flex; gap: 8px; align-items: baseline; margin: 2px 0; }
|
||||||
|
.shell-analysis-modal-body .msg-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); flex-shrink: 0; }
|
||||||
|
.shell-analysis-modal-body strong { color: var(--accent-light); }
|
||||||
|
.shell-analysis-modal-body .inline-code { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 13px; color: var(--accent-muted); }
|
||||||
|
|
||||||
.shell-modal-overlay {
|
.shell-modal-overlay {
|
||||||
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
||||||
@@ -682,6 +763,25 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Consumption */
|
||||||
|
.dash-consumption-list { display: flex; flex-direction: column; gap: 10px; max-height: 270px; overflow-y: auto; }
|
||||||
|
.dash-consumption-provider { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.dash-consumption-head { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.dash-consumption-name {
|
||||||
|
font-size: 11px; font-weight: 700; letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.dash-consumption-total {
|
||||||
|
font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
.dash-consumption-days {
|
||||||
|
display: flex; gap: 4px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.dash-consumption-day {
|
||||||
|
font-size: 9px; font-family: var(--font-mono); color: var(--text-tertiary);
|
||||||
|
background: var(--bg-input); padding: 1px 5px; border-radius: 4px;
|
||||||
|
}
|
||||||
|
.dash-consumption-day strong { color: var(--text-secondary); }
|
||||||
|
|
||||||
/* Processes */
|
/* Processes */
|
||||||
.dash-proc-list { display: flex; flex-direction: column; gap: 4px; max-height: 270px; overflow-y: auto; }
|
.dash-proc-list { display: flex; flex-direction: column; gap: 4px; max-height: 270px; overflow-y: auto; }
|
||||||
.dash-proc-row {
|
.dash-proc-row {
|
||||||
@@ -895,16 +995,40 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
overflow: hidden; margin: 8px 0;
|
overflow: hidden; margin: 8px 0;
|
||||||
}
|
}
|
||||||
|
.studio-code-header {
|
||||||
|
display: flex; align-items: center; justify-content: 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-block pre { padding: 12px 16px; font-family: var(--font-mono); font-size: 13px; line-height: 1.5; overflow-x: auto; color: var(--text-primary); margin: 0; }
|
||||||
.studio-code-lang {
|
.studio-code-lang {
|
||||||
padding: 4px 12px; font-size: 11px; font-weight: 600; color: var(--text-tertiary);
|
padding: 4px 12px; font-size: 11px; font-weight: 600; color: var(--text-tertiary);
|
||||||
background: var(--bg-surface); border-bottom: 1px solid var(--border); text-transform: uppercase; letter-spacing: 0.5px;
|
background: var(--bg-surface); text-transform: uppercase; letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
.studio-copy-btn {
|
||||||
|
padding: 3px 10px; font-size: 10px; font-weight: 600; color: var(--text-tertiary);
|
||||||
|
background: transparent; border: none; border-left: 1px solid var(--border);
|
||||||
|
cursor: pointer; transition: all 0.15s; font-family: var(--font-sans);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.studio-copy-btn:hover { background: var(--accent-bg); color: var(--accent); }
|
||||||
|
.studio-copy-btn.copied { background: var(--accent-bg); color: var(--accent); }
|
||||||
|
|
||||||
|
.studio-mermaid-container { padding: 12px; background: var(--bg); overflow-x: auto; display: flex; justify-content: center; }
|
||||||
|
.studio-mermaid-container svg { max-width: 100%; height: auto; }
|
||||||
|
.studio-mermaid-loading { padding: 12px; text-align: center; color: var(--text-tertiary); font-size: 12px; }
|
||||||
|
.studio-mermaid-error { padding: 10px 12px; color: var(--accent-bright); font-family: var(--font-mono); font-size: 12px; white-space: pre-wrap; }
|
||||||
|
|
||||||
|
.feed-content table { width: 100%; border-collapse: collapse; margin: 8px 0; font-size: 13px; }
|
||||||
|
.feed-content th { background: var(--bg-surface); padding: 6px 12px; text-align: left; font-weight: 600; border: 1px solid var(--border); color: var(--text-secondary); }
|
||||||
|
.feed-content td { padding: 5px 12px; border: 1px solid var(--border); color: var(--text-primary); }
|
||||||
|
.feed-content tr:nth-child(even) td { background: var(--bg-surface); }
|
||||||
|
.feed-content hr, .ai-message 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); }
|
.inline-code { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 13px; color: var(--accent-muted); }
|
||||||
.msg-h3 { font-size: 16px; font-weight: 700; color: var(--text-primary); margin: 10px 0 4px; display: block; }
|
.msg-h1 { font-size: 20px; font-weight: 800; color: var(--accent); margin: 16px 0 8px; display: block; }
|
||||||
.msg-h4 { font-size: 14px; font-weight: 700; color: var(--text-secondary); margin: 8px 0 3px; display: block; }
|
.msg-h2 { font-size: 17px; font-weight: 700; color: var(--text-primary); margin: 12px 0 6px; display: block; }
|
||||||
.msg-bullet { display: block; padding-left: 16px; position: relative; margin: 1px 0; }
|
.msg-h3 { font-size: 15px; font-weight: 700; color: var(--text-primary); margin: 10px 0 4px; display: block; }
|
||||||
.msg-bullet::before { content: '\2022'; position: absolute; left: 4px; color: var(--accent); }
|
.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 { display: flex; gap: 8px; align-items: baseline; margin: 1px 0; }
|
||||||
.msg-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); font-size: 13px; flex-shrink: 0; min-width: 20px; }
|
.msg-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); font-size: 13px; flex-shrink: 0; min-width: 20px; }
|
||||||
.studio-cursor { display: inline-block; width: 8px; height: 16px; background: var(--accent); margin-left: 2px; vertical-align: text-bottom; animation: blink 0.8s step-end infinite; }
|
.studio-cursor { display: inline-block; width: 8px; height: 16px; background: var(--accent); margin-left: 2px; vertical-align: text-bottom; animation: blink 0.8s step-end infinite; }
|
||||||
@@ -953,6 +1077,47 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
cursor: pointer; transition: all 0.15s; flex-shrink: 0;
|
cursor: pointer; transition: all 0.15s; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.studio-stop-btn:hover { opacity: 0.8; }
|
.studio-stop-btn:hover { opacity: 0.8; }
|
||||||
|
|
||||||
|
/* ── Image Attachments ── */
|
||||||
|
.studio-attach-btn {
|
||||||
|
width: 42px; height: 42px; padding: 0; display: flex; align-items: center; justify-content: center;
|
||||||
|
border-radius: var(--radius); background: var(--bg-card); color: var(--text-tertiary);
|
||||||
|
border: 1px solid var(--border); cursor: pointer; transition: all 0.15s; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.studio-attach-btn:hover:not(:disabled) { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); }
|
||||||
|
.studio-attach-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||||
|
.studio-image-previews {
|
||||||
|
display: flex; gap: 10px; padding: 10px 8px; flex-wrap: wrap; justify-content: center;
|
||||||
|
}
|
||||||
|
.studio-image-preview {
|
||||||
|
position: relative; width: 110px; height: 110px; border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden; border: 2px solid var(--border); background: var(--bg-surface);
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.studio-image-preview:hover { border-color: var(--accent-dim); }
|
||||||
|
.studio-image-preview img {
|
||||||
|
width: 100%; height: 100%; object-fit: cover;
|
||||||
|
}
|
||||||
|
.studio-image-remove {
|
||||||
|
position: absolute; top: 4px; right: 4px; width: 24px; height: 24px;
|
||||||
|
border-radius: 50%; background: rgba(0,0,0,0.75); color: #fff; border: none;
|
||||||
|
font-size: 14px; font-weight: 600; cursor: pointer; display: flex; align-items: center;
|
||||||
|
justify-content: center; line-height: 1; transition: background 0.15s;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
.studio-image-remove:hover { background: var(--error); }
|
||||||
|
|
||||||
|
/* ── Feed Images (in chat messages) ── */
|
||||||
|
.feed-images {
|
||||||
|
display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.feed-image {
|
||||||
|
max-width: 240px; max-height: 180px; border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border); object-fit: cover; cursor: pointer;
|
||||||
|
transition: transform 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.feed-image:hover { transform: scale(1.03); border-color: var(--accent-dim); }
|
||||||
|
|
||||||
.studio-input-hint { font-size: 11px; color: var(--text-disabled); text-align: center; margin-top: 6px; }
|
.studio-input-hint { font-size: 11px; color: var(--text-disabled); text-align: center; margin-top: 6px; }
|
||||||
|
|
||||||
/* ── Collapsed Messages ── */
|
/* ── Collapsed Messages ── */
|
||||||
@@ -970,6 +1135,22 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.feed-collapsed-count { font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono); }
|
.feed-collapsed-count { font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono); }
|
||||||
.feed-expanded-messages { animation: fadeIn 0.2s ease-out; }
|
.feed-expanded-messages { animation: fadeIn 0.2s ease-out; }
|
||||||
|
|
||||||
|
.feed-summary-block { margin: 4px 0; }
|
||||||
|
.feed-summary-header {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--bg-surface); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.feed-summary-header:hover { background: var(--bg-hover); border-color: var(--accent-dim); }
|
||||||
|
.feed-summary-header svg { color: var(--accent); flex-shrink: 0; }
|
||||||
|
.feed-summary-text { font-size: 11px; color: var(--text-tertiary); flex: 1; font-weight: 600; }
|
||||||
|
.feed-summary-toggle { font-size: 10px; color: var(--accent); font-family: var(--font-mono); }
|
||||||
|
|
||||||
|
.skill-list-info { display: flex; flex-direction: column; flex: 1; min-width: 0; }
|
||||||
|
.skills-list { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
|
||||||
/* ── Studio Tool Blocks ── */
|
/* ── Studio Tool Blocks ── */
|
||||||
.studio-tool-block {
|
.studio-tool-block {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
@@ -1058,3 +1239,124 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === XTerm Custom Styling === */
|
||||||
|
/* Styles for xterm.js integrated with Muyue theme */
|
||||||
|
.shell-xterm-instance .xterm {
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-xterm-instance .xterm-viewport {
|
||||||
|
background-color: var(--bg-base) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-xterm-instance .xterm-screen {
|
||||||
|
background-color: var(--bg-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling for xterm */
|
||||||
|
.shell-xterm-instance .xterm-viewport::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-xterm-instance .xterm-viewport::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-xterm-instance .xterm-viewport::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-xterm-instance .xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--accent-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selection styling */
|
||||||
|
.shell-xterm-instance .xterm-selection {
|
||||||
|
background: var(--accent-dim) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus ring styling */
|
||||||
|
.shell-xterm-instance .xterm:focus .xterm-helper-text-container {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure consistent font rendering */
|
||||||
|
.shell-xterm-instance .xterm .xterm-char-measure-element {
|
||||||
|
font-family: var(--font-mono) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bell animation styling */
|
||||||
|
.shell-xterm-instance .xterm-bell {
|
||||||
|
animation: xterm-bell-flash 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes xterm-bell-flash {
|
||||||
|
0% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cursor styling */
|
||||||
|
.shell-xterm-instance .xterm-cursor {
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Link styling for web links addon */
|
||||||
|
.shell-xterm-instance .xterm-link {
|
||||||
|
color: var(--accent-light) !important;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-xterm-instance .xterm-link:hover {
|
||||||
|
color: var(--accent-muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-ai-tools-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-ai-tool-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-ai-tool-card:hover {
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-ai-tool-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-ai-tool-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-ai-tool-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-ai-tool-desc {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|||||||