Compare commits
121 Commits
v0.4.1-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 | ||
|
|
c9f2932147 | ||
|
|
f05181b2db | ||
|
|
95e6cdaf41 | ||
|
|
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 | ||
|
|
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
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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -6,11 +6,18 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"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 (
|
var (
|
||||||
sudoCache bool
|
sudoCache bool
|
||||||
sudoCacheSet bool
|
sudoCacheSet bool
|
||||||
@@ -103,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]"
|
||||||
}
|
}
|
||||||
@@ -116,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)
|
||||||
@@ -139,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
|
||||||
@@ -348,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,33 @@
|
|||||||
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>
|
<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.
|
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.
|
||||||
@@ -27,6 +54,7 @@ Muyue gère :
|
|||||||
|-------|-------|
|
|-------|-------|
|
||||||
| **terminal** | Exécuter des commandes shell (builds, tests, git, etc.) |
|
| **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 |
|
| **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 |
|
| **read_file** | Lire le contenu d'un fichier |
|
||||||
| **list_files** | Lister les fichiers d'un répertoire |
|
| **list_files** | Lister les fichiers d'un répertoire |
|
||||||
| **search_files** | Chercher des fichiers par motif (glob) |
|
| **search_files** | Chercher des fichiers par motif (glob) |
|
||||||
@@ -35,6 +63,56 @@ Muyue gère :
|
|||||||
| **set_provider** | Configurer un fournisseur IA |
|
| **set_provider** | Configurer un fournisseur IA |
|
||||||
| **manage_ssh** | Gérer les connexions SSH |
|
| **manage_ssh** | Gérer les connexions SSH |
|
||||||
| **web_fetch** | Récupérer le contenu d'une URL |
|
| **web_fetch** | Récupérer le contenu d'une URL |
|
||||||
|
| **browser_test** | Piloter un onglet de navigateur de l'utilisateur (clic, eval, lecture console) — voir `<browser_test_strategy>` ci-dessous |
|
||||||
|
|
||||||
|
<browser_test_strategy>
|
||||||
|
Quand l'utilisateur demande de **tester** une UI (ses boutons, ses formulaires, son comportement), utilise `browser_test`. La page cible doit être connectée via le snippet de l'onglet **Tests** — sinon, l'outil te le dira et tu demandes à l'utilisateur de coller le snippet (le même token reste valide même après reload : si la connexion est perdue, l'utilisateur n'a qu'à re-coller).
|
||||||
|
|
||||||
|
## Règle d'or — économise les appels d'outils
|
||||||
|
|
||||||
|
**N'appelle PAS `list_clickables` après chaque clic.** C'est l'erreur n°1 qui fait exploser ta boucle (150+ appels pour 5 actions humaines). La liste change rarement et chaque appel renvoie ~30-100 éléments.
|
||||||
|
|
||||||
|
Stratégie efficace :
|
||||||
|
|
||||||
|
1. **Au début** : `summary` (URL + console + 20 lignes) → `list_clickables` (UNE FOIS, mémorise les index pertinents pour ta tâche).
|
||||||
|
2. **Pendant** : clique par `index`. Lis le `console_delta` retourné après chaque clic.
|
||||||
|
3. **Re-list seulement si** :
|
||||||
|
- le `current_url` retourné change ET la nouvelle page est inconnue,
|
||||||
|
- OU un clic ouvre un dialog / nouveau composant que tu dois inspecter,
|
||||||
|
- OU `click` retourne `element not found` (DOM a muté).
|
||||||
|
4. Pour les pages SPA qui rechargent côté URL mais pas le DOM, vérifie d'abord avec `eval document.querySelectorAll('button').length` — si stable, ne re-liste pas.
|
||||||
|
5. Si tu te sens bloqué, **ne boucle pas en aveugle**. Fais 1 `summary`, 1 `eval` ciblé, et demande de l'aide à l'utilisateur. Mieux vaut 5 appels et une question qu'une boucle de 50 appels.
|
||||||
|
|
||||||
|
## Actions disponibles
|
||||||
|
|
||||||
|
| Action | Quand l'utiliser |
|
||||||
|
|---|---|
|
||||||
|
| `summary` | État de la page (URL, titre, 20 dernières lignes console). Appel **bon marché**. |
|
||||||
|
| `list_clickables` | Liste indexée des boutons/liens/inputs visibles. **Appel cher** (~50+ items) — utilise avec parcimonie. |
|
||||||
|
| `click` (par `index` de préférence) | Clique. Retourne `console_delta` + `current_url`. |
|
||||||
|
| `type` | Remplit un input (par `selector` ou `index`). Toujours suivi d'un `click` sur le bouton submit. |
|
||||||
|
| `eval` | JS arbitraire. Idéal pour des questions ciblées (`document.title`, `document.querySelectorAll(X).length`, etc.) au lieu de `list_clickables` complet. |
|
||||||
|
| `current_url` | URL+titre. Très bon marché. |
|
||||||
|
| `wait` | Pause 200-500 ms après une action async (transition / fetch). |
|
||||||
|
| `console` | N dernières lignes console (default 50). Pour debug post-incident. |
|
||||||
|
| `screenshot` | Capture viewport (ou `selector`) et sauve dans `~/.muyue/screenshots/<filename>.png`. Utilise `filename` pour nommer ; sinon timestamp. Best-effort (CSS externe / images peuvent ne pas apparaître). |
|
||||||
|
|
||||||
|
## Rapport final
|
||||||
|
|
||||||
|
Quand tous les tests sont terminés, fournis un rapport **structuré et bref** :
|
||||||
|
|
||||||
|
```
|
||||||
|
✓ Boutons OK : <liste des labels>
|
||||||
|
✗ Boutons cassés : <label> — <message d'erreur exact du console_delta>
|
||||||
|
⚠ Bloqués : <label> — <pourquoi> (disabled, non trouvé, etc.)
|
||||||
|
📸 Captures : <chemins relatifs sous ~/.muyue/screenshots/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Astuces :
|
||||||
|
- Clique **par index** ; le sélecteur peut changer avec le DOM, l'index reste stable jusqu'au prochain `list_clickables`.
|
||||||
|
- N'utilise jamais `eval` pour cliquer si `click` suffit.
|
||||||
|
- Si la page se recharge (`current_url` change ou la connexion tombe), demande à l'utilisateur de recoller le snippet — le même token marche.
|
||||||
|
</browser_test_strategy>
|
||||||
|
|
||||||
<tool_strategy>
|
<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
|
- **Recherche avant action** — Utilise `search_files`, `grep_content`, `read_file` avant de supposer quoi que ce soit sur l'état du système
|
||||||
|
|||||||
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,7 @@ 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
|
TotalTokens int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,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) {
|
||||||
@@ -76,8 +90,11 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
|
|||||||
ce.TotalTokens += resp.Usage.TotalTokens
|
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 {
|
||||||
@@ -115,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(),
|
||||||
@@ -178,8 +228,11 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.
|
|||||||
ce.TotalTokens += resp.Usage.TotalTokens
|
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
|
||||||
@@ -203,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(),
|
||||||
@@ -258,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)
|
||||||
}
|
}
|
||||||
@@ -17,12 +17,53 @@ const contextWindowTokens = 150000
|
|||||||
const summarizeRatio = 0.80
|
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"`
|
Images []string `json:"images,omitempty"`
|
||||||
|
Summarized bool `json:"summarized,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Conversation struct {
|
type Conversation struct {
|
||||||
@@ -168,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()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -17,6 +16,7 @@ import (
|
|||||||
|
|
||||||
"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>`)
|
||||||
@@ -64,15 +64,13 @@ func (s *Server) describeImages(images []ImageAttachment) []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if apiKey == "" {
|
if apiKey == "" {
|
||||||
log.Printf("[vlm] no API key found for image description")
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
descriptions := make([]string, 0, len(images))
|
descriptions := make([]string, 0, len(images))
|
||||||
for i, img := range images {
|
for _, img := range images {
|
||||||
desc, err := s.callVLM(apiKey, img)
|
desc, err := s.callVLM(apiKey, img)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[vlm] image %d (%s) failed: %v", i+1, img.Filename, err)
|
|
||||||
descriptions = append(descriptions, fmt.Sprintf("(description unavailable: %v)", err))
|
descriptions = append(descriptions, fmt.Sprintf("(description unavailable: %v)", err))
|
||||||
} else {
|
} else {
|
||||||
descriptions = append(descriptions, desc)
|
descriptions = append(descriptions, desc)
|
||||||
@@ -134,10 +132,12 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
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"`
|
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)
|
||||||
@@ -163,7 +163,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
id, err := saveImage(body.Images[i].Data, body.Images[i].Filename, body.Images[i].MimeType)
|
id, err := saveImage(body.Images[i].Data, body.Images[i].Filename, body.Images[i].MimeType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[images] failed to save %s: %v", body.Images[i].Filename, err)
|
_ = err
|
||||||
} else {
|
} else {
|
||||||
imageIDs = append(imageIDs, id)
|
imageIDs = append(imageIDs, id)
|
||||||
}
|
}
|
||||||
@@ -197,7 +197,12 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
var studioPrompt strings.Builder
|
var studioPrompt strings.Builder
|
||||||
studioPrompt.WriteString(agent.StudioSystemPrompt())
|
studioPrompt.WriteString(agent.StudioSystemPrompt())
|
||||||
studioPrompt.WriteString(fmt.Sprintf("\nDate: %s\nHeure: %s\n", time.Now().Format("02/01/2006"), time.Now().Format("15:04:05")))
|
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()
|
canSudo := !agent.NeedsSudoPassword()
|
||||||
studioPrompt.WriteString(fmt.Sprintf("Root: %t\n", !canSudo))
|
studioPrompt.WriteString(fmt.Sprintf("Root: %t\n", !canSudo))
|
||||||
if !canSudo {
|
if !canSudo {
|
||||||
@@ -208,6 +213,22 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
orb.SetSystemPrompt(studioPrompt.String())
|
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, enrichedMessage)
|
s.handleStreamChat(w, orb, enrichedMessage)
|
||||||
} else {
|
} else {
|
||||||
@@ -227,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
|
||||||
@@ -265,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)
|
||||||
@@ -282,6 +305,23 @@ func cleanThinkingTags(content string) string {
|
|||||||
return strings.TrimSpace(thinkingTagRegex.ReplaceAllString(content, ""))
|
return strings.TrimSpace(thinkingTagRegex.ReplaceAllString(content, ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runReflectionReport runs the inactive AI provider on the user message to
|
||||||
|
// produce a preliminary analysis report that the active provider will then
|
||||||
|
// use as additional context. Returns ("", false) if no inactive provider is
|
||||||
|
// configured or on error — the caller falls back to a normal chat flow.
|
||||||
|
func (s *Server) runReflectionReport(userMessage string) (string, bool) {
|
||||||
|
orb, err := orchestrator.NewForInactiveProvider(s.config)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
orb.SetSystemPrompt("Tu es un analyste. Pour la question ci-dessous, produis un rapport bref (max 15 lignes) qui : (1) reformule l'objectif de l'utilisateur, (2) liste les points à clarifier ou les risques, (3) suggère une approche structurée. Pas de code, pas d'action — uniquement de l'analyse.")
|
||||||
|
resp, err := orb.SendNoTools(userMessage)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(resp), true
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message {
|
func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message {
|
||||||
history := s.convStore.Get()
|
history := s.convStore.Get()
|
||||||
|
|
||||||
@@ -299,7 +339,11 @@ func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message
|
|||||||
included := 0
|
included := 0
|
||||||
tokensUsed := 0
|
tokensUsed := 0
|
||||||
for i := len(history) - 1; i >= 0; i-- {
|
for i := len(history) - 1; i >= 0; i-- {
|
||||||
msgTokens := utf8.RuneCountInString(history[i].Content) / charsPerToken
|
if history[i].Summarized {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
displayContent := extractDisplayContent(history[i].Role, history[i].Content)
|
||||||
|
msgTokens := utf8.RuneCountInString(displayContent) / charsPerToken
|
||||||
if msgTokens == 0 {
|
if msgTokens == 0 {
|
||||||
msgTokens = 1
|
msgTokens = 1
|
||||||
}
|
}
|
||||||
@@ -315,14 +359,21 @@ func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message
|
|||||||
start = 0
|
start = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasSummarized := false
|
||||||
|
for i := 0; i < start; i++ {
|
||||||
|
if history[i].Summarized {
|
||||||
|
hasSummarized = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
if start > 0 {
|
if start > 0 {
|
||||||
log.Printf("[studio] context budget: %d/%d tokens, including %d/%d messages (dropped %d older)", tokensUsed+overhead, contextWindowTokens, included, len(history), start)
|
_ = start
|
||||||
}
|
}
|
||||||
|
|
||||||
messages := make([]orchestrator.Message, 0, included+2)
|
messages := make([]orchestrator.Message, 0, included+2)
|
||||||
|
|
||||||
summary := s.convStore.GetSummary()
|
summary := s.convStore.GetSummary()
|
||||||
if summary != "" && start > 0 {
|
if summary != "" && (start > 0 || hasSummarized) {
|
||||||
messages = append(messages, orchestrator.Message{
|
messages = append(messages, orchestrator.Message{
|
||||||
Role: "system",
|
Role: "system",
|
||||||
Content: orchestrator.TextContent("Résumé de la conversation précédente:\n" + summary),
|
Content: orchestrator.TextContent("Résumé de la conversation précédente:\n" + summary),
|
||||||
@@ -330,27 +381,13 @@ func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message
|
|||||||
}
|
}
|
||||||
|
|
||||||
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: orchestrator.TextContent(content),
|
Content: orchestrator.TextContent(displayContent),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,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) {
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
@@ -266,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 != "" {
|
||||||
@@ -335,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
|
||||||
}
|
}
|
||||||
@@ -375,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 {
|
||||||
|
|||||||
@@ -80,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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,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),
|
||||||
@@ -200,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)
|
||||||
@@ -727,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)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -108,6 +107,7 @@ func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.
|
|||||||
messages := s.buildShellContextMessages()
|
messages := s.buildShellContextMessages()
|
||||||
|
|
||||||
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
|
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
|
||||||
|
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
|
||||||
@@ -149,6 +149,7 @@ func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrat
|
|||||||
messages := s.buildShellContextMessages()
|
messages := s.buildShellContextMessages()
|
||||||
|
|
||||||
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
|
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
|
||||||
|
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)
|
||||||
@@ -185,7 +186,8 @@ func (s *Server) buildShellContextMessages() []orchestrator.Message {
|
|||||||
included := 0
|
included := 0
|
||||||
tokensUsed := 0
|
tokensUsed := 0
|
||||||
for i := len(history) - 1; i >= 0; i-- {
|
for i := len(history) - 1; i >= 0; i-- {
|
||||||
msgTokens := utf8.RuneCountInString(history[i].Content) / charsPerToken
|
displayContent := extractDisplayContent(history[i].Role, history[i].Content)
|
||||||
|
msgTokens := utf8.RuneCountInString(displayContent) / charsPerToken
|
||||||
if msgTokens == 0 {
|
if msgTokens == 0 {
|
||||||
msgTokens = 1
|
msgTokens = 1
|
||||||
}
|
}
|
||||||
@@ -202,33 +204,19 @@ func (s *Server) buildShellContextMessages() []orchestrator.Message {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if start > 0 {
|
if start > 0 {
|
||||||
log.Printf("[shell] context budget: %d/%d tokens, including %d/%d messages (dropped %d older)", tokensUsed+overhead, shellMaxTokens, included, len(history), start)
|
_ = start
|
||||||
}
|
}
|
||||||
|
|
||||||
messages := make([]orchestrator.Message, 0, included)
|
messages := make([]orchestrator.Message, 0, included)
|
||||||
|
|
||||||
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: orchestrator.TextContent(content),
|
Content: orchestrator.TextContent(displayContent),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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])
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,6 @@ package api
|
|||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -38,6 +37,9 @@ func saveImage(dataURI, filename, mimeType string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("base64 decode: %w", err)
|
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))
|
id := fmt.Sprintf("%d-%d", time.Now().UnixMilli(), atomic.AddUint64(&imageCounter, 1))
|
||||||
ext := ".png"
|
ext := ".png"
|
||||||
@@ -64,7 +66,7 @@ func cleanupImages(ids []string) {
|
|||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
p := imagePath(id)
|
p := imagePath(id)
|
||||||
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
|
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
|
||||||
log.Printf("[images] failed to delete %s: %v", id, err)
|
_ = err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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,12 +2,15 @@ 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"
|
||||||
)
|
)
|
||||||
@@ -24,6 +27,9 @@ type Server struct {
|
|||||||
shellAgentRegistry *agent.Registry
|
shellAgentRegistry *agent.Registry
|
||||||
shellAgentToolsJSON json.RawMessage
|
shellAgentToolsJSON json.RawMessage
|
||||||
workflowEngine *workflow.Engine
|
workflowEngine *workflow.Engine
|
||||||
|
browserTestStore *BrowserTestStore
|
||||||
|
activeCrushAgents atomic.Int32
|
||||||
|
activeClaudeAgents atomic.Int32
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(cfg *config.MuyueConfig) *Server {
|
func NewServer(cfg *config.MuyueConfig) *Server {
|
||||||
@@ -43,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
|
||||||
}
|
}
|
||||||
@@ -53,6 +59,11 @@ func NewServer(cfg *config.MuyueConfig) *Server {
|
|||||||
s.shellConvStore = NewShellConvStore()
|
s.shellConvStore = NewShellConvStore()
|
||||||
s.consumption = newConsumptionStore()
|
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)
|
||||||
@@ -65,6 +76,7 @@ func NewServer(cfg *config.MuyueConfig) *Server {
|
|||||||
s.shellAgentToolsJSON = json.RawMessage(shellToolsJSON)
|
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
|
||||||
}
|
}
|
||||||
@@ -120,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)
|
||||||
|
|
||||||
@@ -139,6 +152,11 @@ func (s *Server) routes() {
|
|||||||
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) {
|
||||||
@@ -147,8 +165,11 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
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)
|
||||||
@@ -156,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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -147,7 +147,10 @@ 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
|
total += utf8.RuneCountInString(shellSystemPromptBase) / shellCharsPerToken
|
||||||
if analysis := LoadSystemAnalysis(); analysis != "" {
|
if analysis := LoadSystemAnalysis(); analysis != "" {
|
||||||
|
|||||||
@@ -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,17 +54,13 @@ 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
|
||||||
|
|
||||||
@@ -102,7 +96,10 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
if sshConf.Password != "" {
|
if sshConf.Password != "" {
|
||||||
sshpassPath, err := exec.LookPath("sshpass")
|
sshpassPath, err := exec.LookPath("sshpass")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
cmd = exec.Command(sshpassPath, append([]string{"-p", sshConf.Password}, append([]string{"-e"}, sshArgs...)...)...)
|
args := append([]string{"-e"}, "ssh")
|
||||||
|
args = append(args, sshArgs...)
|
||||||
|
cmd = exec.Command(sshpassPath, args...)
|
||||||
|
cmd.Env = append(os.Environ(), "SSHPASS="+sshConf.Password)
|
||||||
} else {
|
} else {
|
||||||
cmd = exec.Command("ssh", sshArgs...)
|
cmd = exec.Command("ssh", sshArgs...)
|
||||||
}
|
}
|
||||||
@@ -111,60 +108,62 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
} 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()
|
||||||
@@ -172,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
|
||||||
}
|
}
|
||||||
@@ -204,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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -221,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
|
||||||
@@ -253,13 +258,17 @@ func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
for i, c := range s.config.Terminal.SSH {
|
for i, c := range s.config.Terminal.SSH {
|
||||||
if c.Name == body.Name {
|
if c.Name == body.Name {
|
||||||
|
password := body.Password
|
||||||
|
if password == "***" {
|
||||||
|
password = c.Password
|
||||||
|
}
|
||||||
s.config.Terminal.SSH[i] = config.SSHConnection{
|
s.config.Terminal.SSH[i] = 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,
|
Password: password,
|
||||||
}
|
}
|
||||||
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)
|
||||||
@@ -328,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
|
||||||
|
|
||||||
@@ -340,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"
|
||||||
|
|
||||||
@@ -162,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -17,6 +16,14 @@ 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
|
||||||
|
|
||||||
@@ -135,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
|
||||||
}
|
}
|
||||||
@@ -167,6 +205,33 @@ 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{
|
||||||
@@ -197,7 +262,7 @@ 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",
|
||||||
@@ -297,7 +362,7 @@ 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",
|
||||||
@@ -388,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)
|
||||||
@@ -411,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
|
||||||
@@ -463,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
|
||||||
@@ -494,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":
|
||||||
@@ -616,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.4.1"
|
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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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,12 +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'),
|
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) }),
|
||||||
@@ -63,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, images = []) => {
|
sendChat: (message, stream = true, onChunk, signal, images = [], advancedReflection = false) => {
|
||||||
if (!stream) {
|
if (!stream) {
|
||||||
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false, images }) })
|
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, images }),
|
body: JSON.stringify({ message, stream: true, images, advanced_reflection: advancedReflection }),
|
||||||
signal,
|
signal,
|
||||||
}).then(async (res) => {
|
}).then(async (res) => {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@@ -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: [
|
||||||
@@ -99,6 +95,7 @@ export default function App() {
|
|||||||
{ 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])
|
||||||
|
|
||||||
@@ -106,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>
|
||||||
@@ -127,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>
|
||||||
@@ -147,6 +134,7 @@ export default function App() {
|
|||||||
<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} isSudo={isSudo} /></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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,83 +40,6 @@ export default function Config({ api }) {
|
|||||||
setTimeout(() => setToast(null), 2500)
|
setTimeout(() => setToast(null), 2500)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCheckUpdates = async () => {
|
|
||||||
setChecking(true)
|
|
||||||
try {
|
|
||||||
const d = await api.aiTask('check_tools')
|
|
||||||
const result = d.result
|
|
||||||
if (result && result.tools) {
|
|
||||||
const aiTools = result.tools
|
|
||||||
const newUpdates = aiTools.filter(t => t.installed).map(t => ({
|
|
||||||
tool: t.name,
|
|
||||||
current: t.version || '',
|
|
||||||
latest: t.latest || '',
|
|
||||||
needsUpdate: t.needs_update || false,
|
|
||||||
error: t.error || '',
|
|
||||||
}))
|
|
||||||
const newTools = aiTools.map(t => ({
|
|
||||||
name: t.name,
|
|
||||||
installed: t.installed,
|
|
||||||
version: t.version || '',
|
|
||||||
category: t.category || '',
|
|
||||||
}))
|
|
||||||
setUpdates(newUpdates)
|
|
||||||
setTools(newTools)
|
|
||||||
showToast(t('config.upToDate'))
|
|
||||||
} else {
|
|
||||||
showToast(t('config.error'))
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
showToast(`${t('config.error')}: ${err.message}`)
|
|
||||||
}
|
|
||||||
setChecking(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUpdateTool = async (tool) => {
|
|
||||||
setUpdating(tool)
|
|
||||||
try {
|
|
||||||
const d = await api.aiTask('update_tool', tool)
|
|
||||||
if (d.result && d.result.updated) {
|
|
||||||
showToast(`${tool} ${t('config.updated') || 'mis à jour'}`)
|
|
||||||
} else {
|
|
||||||
showToast(d.result?.error || d.result?.message || t('config.error'))
|
|
||||||
}
|
|
||||||
handleCheckUpdates()
|
|
||||||
} catch (err) {
|
|
||||||
showToast(`${t('config.error')}: ${err.message}`)
|
|
||||||
}
|
|
||||||
setUpdating(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleInstallTool = async (tool) => {
|
|
||||||
setUpdating(`install-${tool}`)
|
|
||||||
try {
|
|
||||||
const d = await api.aiTask('install_tool', tool)
|
|
||||||
if (d.result && d.result.installed) {
|
|
||||||
showToast(`${tool} ${t('config.installed') || 'installé'}`)
|
|
||||||
} else {
|
|
||||||
showToast(d.result?.error || d.result?.message || t('config.error'))
|
|
||||||
}
|
|
||||||
handleCheckUpdates()
|
|
||||||
} catch (err) {
|
|
||||||
showToast(`${t('config.error')}: ${err.message}`)
|
|
||||||
}
|
|
||||||
setUpdating(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUpdateAll = async () => {
|
|
||||||
const toUpdate = updates.filter(u => u.needsUpdate)
|
|
||||||
setUpdating('__all__')
|
|
||||||
for (const u of toUpdate) {
|
|
||||||
try {
|
|
||||||
await api.aiTask('update_tool', u.tool)
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to update ${u.tool}:`, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setUpdating(null)
|
|
||||||
handleCheckUpdates()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSaveProfile = async () => {
|
const handleSaveProfile = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -161,9 +78,7 @@ export default function Config({ api }) {
|
|||||||
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">
|
||||||
@@ -204,21 +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}
|
|
||||||
handleInstallTool={handleInstallTool}
|
|
||||||
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} />
|
||||||
@@ -459,176 +361,80 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PanelUpdates({ updates, tools, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleInstallTool, handleUpdateAll, t }) {
|
function PanelSkills({ skillList, api, loadData, t }) {
|
||||||
|
const [deploying, setDeploying] = useState(null)
|
||||||
|
|
||||||
const missingTools = tools.filter(tool => !tool.installed)
|
const handleDeploy = async (name) => {
|
||||||
|
setDeploying(name + '-deploy')
|
||||||
|
try {
|
||||||
|
await api.deploySkill(name)
|
||||||
|
loadData()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('deploy skill:', err)
|
||||||
|
}
|
||||||
|
setDeploying(null)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
const handleUndeploy = async (name) => {
|
||||||
<>
|
setDeploying(name + '-undeploy')
|
||||||
<div className="config-card">
|
try {
|
||||||
<div className="config-update-controls">
|
await api.undeploySkill(name)
|
||||||
<div className="config-update-stats">
|
loadData()
|
||||||
<span className="badge ok">{installedCount} {t('config.installed')}</span>
|
} catch (err) {
|
||||||
{missingCount > 0 && <span className="badge error">{missingCount} {t('config.missing')}</span>}
|
console.error('undeploy skill:', err)
|
||||||
{needsUpdateCount > 0 && <span className="badge warn">{needsUpdateCount} {t('config.needsUpdate')}</span>}
|
}
|
||||||
</div>
|
setDeploying(null)
|
||||||
<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)
|
||||||
@@ -646,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)' }}>
|
||||||
|
|||||||
@@ -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,4 +1,4 @@
|
|||||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
import { useState, useRef, useEffect, useCallback, useMemo, memo, Fragment } from 'react'
|
||||||
import { Terminal as XTerm } from '@xterm/xterm'
|
import { Terminal as XTerm } from '@xterm/xterm'
|
||||||
import { FitAddon } from '@xterm/addon-fit'
|
import { FitAddon } from '@xterm/addon-fit'
|
||||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||||
@@ -214,7 +214,6 @@ function getTheme(themeName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createTerminal(container, settings = {}) {
|
function createTerminal(container, settings = {}) {
|
||||||
console.log('[Shell] createTerminal called with settings:', JSON.stringify({ fontSize: settings.fontSize, fontFamily: settings.fontFamily?.slice(0, 30), theme: settings.theme }))
|
|
||||||
const theme = getTheme(settings.theme || 'system')
|
const theme = getTheme(settings.theme || 'system')
|
||||||
const actualFontSize = settings.fontSize || 14
|
const actualFontSize = settings.fontSize || 14
|
||||||
const term = new XTerm({
|
const term = new XTerm({
|
||||||
@@ -409,6 +408,7 @@ export default function Shell({ api, isSudo }) {
|
|||||||
})
|
})
|
||||||
const activeTabRef = useRef(activeTab)
|
const activeTabRef = useRef(activeTab)
|
||||||
useEffect(() => { activeTabRef.current = activeTab }, [activeTab])
|
useEffect(() => { activeTabRef.current = activeTab }, [activeTab])
|
||||||
|
const tabIdsKey = useMemo(() => tabs.map(t => t.id).join(','), [tabs])
|
||||||
const [sshConnections, setSshConnections] = useState([])
|
const [sshConnections, setSshConnections] = useState([])
|
||||||
const [systemTerminals, setSystemTerminals] = useState([])
|
const [systemTerminals, setSystemTerminals] = useState([])
|
||||||
const [showMenu, setShowMenu] = useState(false)
|
const [showMenu, setShowMenu] = useState(false)
|
||||||
@@ -474,8 +474,22 @@ export default function Shell({ api, isSudo }) {
|
|||||||
const aiLoadedRef = useRef(false)
|
const aiLoadedRef = useRef(false)
|
||||||
const aiLoadingRef = useRef(false)
|
const aiLoadingRef = useRef(false)
|
||||||
const analysisSavingRef = useRef(false)
|
const analysisSavingRef = useRef(false)
|
||||||
|
const _streamRafRef = useRef(null)
|
||||||
|
const _streamPendingRef = useRef(null)
|
||||||
|
|
||||||
|
const _flushStreamUpdate = useCallback(() => {
|
||||||
|
_streamRafRef.current = null
|
||||||
|
const pending = _streamPendingRef.current
|
||||||
|
if (!pending) return
|
||||||
|
_streamPendingRef.current = null
|
||||||
|
setAiMessages(pending)
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (_streamRafRef.current) {
|
||||||
|
cancelAnimationFrame(_streamRafRef.current)
|
||||||
|
_streamRafRef.current = null
|
||||||
|
}
|
||||||
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
|
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
|
||||||
}, [aiMessages])
|
}, [aiMessages])
|
||||||
|
|
||||||
@@ -513,20 +527,15 @@ export default function Shell({ api, isSudo }) {
|
|||||||
setSystemTerminals(d.system || [])
|
setSystemTerminals(d.system || [])
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
api.getConfig().then(d => {
|
api.getConfig().then(d => {
|
||||||
console.log('[Shell] config response terminal:', JSON.stringify(d?.terminal))
|
|
||||||
if (d.terminal) {
|
if (d.terminal) {
|
||||||
const fontSize = d.terminal.font_size || 14
|
const fontSize = d.terminal.font_size || 14
|
||||||
const fontFamily = d.terminal.font_family || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace"
|
const fontFamily = d.terminal.font_family || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace"
|
||||||
const theme = d.terminal.theme || 'system'
|
const theme = d.terminal.theme || 'system'
|
||||||
console.log('[Shell] setting fontSize to:', fontSize, 'from config')
|
|
||||||
setTerminalSettings({ fontSize, fontFamily, theme })
|
setTerminalSettings({ fontSize, fontFamily, theme })
|
||||||
settingsRef.current = { fontSize, fontFamily, theme }
|
settingsRef.current = { fontSize, fontFamily, theme }
|
||||||
baseFontSizeRef.current = fontSize
|
baseFontSizeRef.current = fontSize
|
||||||
} else {
|
|
||||||
console.log('[Shell] no terminal config in response, using defaults')
|
|
||||||
}
|
}
|
||||||
setConfigLoaded(true)
|
setConfigLoaded(true)
|
||||||
console.log('[Shell] configLoaded = true, settingsRef:', JSON.stringify(settingsRef.current))
|
|
||||||
}).catch((err) => { console.warn('[Shell] getConfig failed:', err); setConfigLoaded(true) })
|
}).catch((err) => { console.warn('[Shell] getConfig failed:', err); setConfigLoaded(true) })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -538,7 +547,6 @@ export default function Shell({ api, isSudo }) {
|
|||||||
|
|
||||||
const s = settingsRef.current
|
const s = settingsRef.current
|
||||||
const effectiveFontSize = s.fontSize + zoomLevel * 2
|
const effectiveFontSize = s.fontSize + zoomLevel * 2
|
||||||
console.log(`[Shell] initTerminal tab=${tabId}: settingsRef.fontSize=${s.fontSize}, zoomLevel=${zoomLevel}, effectiveFontSize=${effectiveFontSize}`)
|
|
||||||
const { term, fitAddon, searchAddon } = createTerminal(container, {
|
const { term, fitAddon, searchAddon } = createTerminal(container, {
|
||||||
fontSize: effectiveFontSize,
|
fontSize: effectiveFontSize,
|
||||||
fontFamily: s.fontFamily,
|
fontFamily: s.fontFamily,
|
||||||
@@ -631,14 +639,11 @@ export default function Shell({ api, isSudo }) {
|
|||||||
|
|
||||||
const bufferSaveInterval = setInterval(() => { if (!disposed) saveBuffer() }, 5000)
|
const bufferSaveInterval = setInterval(() => { if (!disposed) saveBuffer() }, 5000)
|
||||||
|
|
||||||
console.log(`[Shell] initTerminal tab=${tabId} type=${tab.type} name="${tab.name}" shell="${tab.shell || '(default)'}"`)
|
|
||||||
tabsRef.current[tabId] = { term, fitAddon, searchAddon, ws, resizeObserver, onResize, bufferSaveInterval, saveBuffer, disposed: () => disposed }
|
tabsRef.current[tabId] = { term, fitAddon, searchAddon, ws, resizeObserver, onResize, bufferSaveInterval, saveBuffer, disposed: () => disposed }
|
||||||
tabsRef.current[tabId]._markDisposed = () => { disposed = true }
|
tabsRef.current[tabId]._markDisposed = () => { disposed = true }
|
||||||
console.log(`[Shell] initTerminal tab=${tabId} done, tabsRef keys:`, Object.keys(tabsRef.current))
|
|
||||||
|
|
||||||
const pending = pendingCommandsRef.current[tabId]
|
const pending = pendingCommandsRef.current[tabId]
|
||||||
if (pending && pending.length > 0) {
|
if (pending && pending.length > 0) {
|
||||||
console.log(`[Shell] Flushing ${pending.length} pending commands for tab ${tabId}`)
|
|
||||||
for (const cmd of pending) {
|
for (const cmd of pending) {
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(JSON.stringify({ type: 'input', data: cmd + '\r' }))
|
ws.send(JSON.stringify({ type: 'input', data: cmd + '\r' }))
|
||||||
@@ -694,7 +699,6 @@ export default function Shell({ api, isSudo }) {
|
|||||||
const tryInitTab = (tab, attempt) => {
|
const tryInitTab = (tab, attempt) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
if (attempt > 20) {
|
if (attempt > 20) {
|
||||||
console.warn(`[Shell] max attempts reached for tab ${tab.id}`)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -718,7 +722,6 @@ export default function Shell({ api, isSudo }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!tabsRef.current[tab.id]) {
|
if (!tabsRef.current[tab.id]) {
|
||||||
console.log(`[Shell] tryInitTab: calling initTerminal for tab ${tab.id}, configLoaded=${configLoaded}`)
|
|
||||||
initTerminal(tab.id, tab)
|
initTerminal(tab.id, tab)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -737,7 +740,6 @@ export default function Shell({ api, isSudo }) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[Shell] init effect: tabs=${tabs.length}, configLoaded=${configLoaded}`)
|
|
||||||
for (const tab of tabs) {
|
for (const tab of tabs) {
|
||||||
if (configLoaded && !tabsRef.current[tab.id]) {
|
if (configLoaded && !tabsRef.current[tab.id]) {
|
||||||
tryInitTab(tab, 0)
|
tryInitTab(tab, 0)
|
||||||
@@ -760,7 +762,7 @@ export default function Shell({ api, isSudo }) {
|
|||||||
pending.forEach(clearTimeout)
|
pending.forEach(clearTimeout)
|
||||||
observer?.disconnect()
|
observer?.disconnect()
|
||||||
}
|
}
|
||||||
}, [tabs, initTerminal, initPendingTabs, configLoaded])
|
}, [tabIdsKey, initTerminal, initPendingTabs, configLoaded])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const entry = tabsRef.current[activeTab]
|
const entry = tabsRef.current[activeTab]
|
||||||
@@ -778,12 +780,18 @@ export default function Shell({ api, isSudo }) {
|
|||||||
const wrapper = document.querySelector('.shell-layout')?.parentElement
|
const wrapper = document.querySelector('.shell-layout')?.parentElement
|
||||||
if (wrapper && wrapper.classList.contains('tab-hidden')) return
|
if (wrapper && wrapper.classList.contains('tab-hidden')) return
|
||||||
const entry = tabsRef.current[activeTabRef.current]
|
const entry = tabsRef.current[activeTabRef.current]
|
||||||
if (entry) {
|
if (entry && entry.fitAddon && entry.term) {
|
||||||
entry.fitAddon.fit()
|
const container = document.getElementById(`terminal-${activeTabRef.current}`)
|
||||||
|
if (!container) return
|
||||||
|
const rect = container.getBoundingClientRect()
|
||||||
|
const dims = entry.fitAddon.proposeDimensions()
|
||||||
|
if (dims && (entry.term.cols !== dims.cols || entry.term.rows !== dims.rows)) {
|
||||||
|
entry.fitAddon.fit()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, 2000)
|
}, 2000)
|
||||||
return () => clearInterval(iv)
|
return () => clearInterval(iv)
|
||||||
}, [tabs])
|
}, [tabIdsKey])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -813,25 +821,26 @@ export default function Shell({ api, isSudo }) {
|
|||||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
|
||||||
if (!e.altKey && !(e.key === 'Tab' && e.shiftKey)) return
|
if (!e.altKey && !(e.key === 'Tab' && e.shiftKey)) return
|
||||||
|
|
||||||
|
const currentTabs = tabsRef.current._tabList || []
|
||||||
if (e.key === 'Tab' && e.shiftKey) {
|
if (e.key === 'Tab' && e.shiftKey) {
|
||||||
const shellTab = document.querySelector('.shell-layout')
|
const shellTab = document.querySelector('.shell-layout')
|
||||||
if (!shellTab || shellTab.closest('.tab-hidden')) return
|
if (!shellTab || shellTab.closest('.tab-hidden')) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const idx = tabs.findIndex(t => t.id === activeTab)
|
const idx = currentTabs.findIndex(t => t.id === activeTabRef.current)
|
||||||
const next = (idx + 1) % tabs.length
|
const next = (idx + 1) % currentTabs.length
|
||||||
setActiveTab(tabs[next].id)
|
setActiveTab(currentTabs[next].id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const num = parseInt(e.key)
|
const num = parseInt(e.key)
|
||||||
if (num >= 1 && num <= tabs.length) {
|
if (num >= 1 && num <= currentTabs.length) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setActiveTab(tabs[num - 1].id)
|
setActiveTab(currentTabs[num - 1].id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
return () => window.removeEventListener('keydown', onKey)
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
}, [tabs])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showSearch && searchInputRef.current) {
|
if (showSearch && searchInputRef.current) {
|
||||||
@@ -1011,7 +1020,6 @@ export default function Shell({ api, isSudo }) {
|
|||||||
pendingCommandsRef.current[targetId].push(code)
|
pendingCommandsRef.current[targetId].push(code)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
console.log(`[Shell] sendToTerminal: tab ${targetId} ← ${code.length} chars`)
|
|
||||||
entry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' }))
|
entry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' }))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -1098,21 +1106,35 @@ export default function Shell({ api, isSudo }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentTab = activeTabRef.current
|
const currentTab = activeTabRef.current
|
||||||
console.log(`[Shell] _sendAiMessage: activeTab=${currentTab}, fromEvent=${fromEvent}, text="${trimmed.slice(0, 50)}"`)
|
|
||||||
setAiMessages(prev => [...prev, { role: 'user', content: trimmed, _tabId: currentTab, _analysis: isAnalysis || undefined }])
|
setAiMessages(prev => [...prev, { role: 'user', content: trimmed, _tabId: currentTab, _analysis: isAnalysis || undefined }])
|
||||||
setAiLoading(true)
|
setAiLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let accumulated = ''
|
let segments = []
|
||||||
let toolCalls = []
|
let textStartIdx = 0
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
|
|
||||||
|
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.sendShellChat(trimmed, {}, true, (partial, event) => {
|
await api.sendShellChat(trimmed, {}, true, (partial, event) => {
|
||||||
if (event && event.tool_call) {
|
if (event && event.tool_call) {
|
||||||
toolCalls = [...toolCalls, { call: event.tool_call, result: null }]
|
_updateLastText(partial.slice(textStartIdx))
|
||||||
|
textStartIdx = partial.length
|
||||||
|
segments.push({ type: 'tool', call: event.tool_call, result: null })
|
||||||
|
const snap = segments.map(s => ({ ...s, ...(s.type === 'tool' ? { call: s.call, result: s.result } : {}) }))
|
||||||
|
if (_streamRafRef.current) { cancelAnimationFrame(_streamRafRef.current); _streamRafRef.current = null }
|
||||||
|
_streamPendingRef.current = null
|
||||||
setAiMessages(prev => {
|
setAiMessages(prev => {
|
||||||
const filtered = prev.filter(m => !m._streaming)
|
const filtered = prev.filter(m => !m._streaming)
|
||||||
return [...filtered, { role: 'assistant', content: accumulated, _streaming: true, _tabId: currentTab, _toolCalls: [...toolCalls] }]
|
return [...filtered, { role: 'assistant', _streaming: true, _tabId: currentTab, _segments: snap }]
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1120,12 +1142,15 @@ export default function Shell({ api, isSudo }) {
|
|||||||
if (event.tool_result.sudo_blocked) {
|
if (event.tool_result.sudo_blocked) {
|
||||||
setSudoModal({ command: event.tool_result.command || event.tool_result.content })
|
setSudoModal({ command: event.tool_result.command || event.tool_result.content })
|
||||||
}
|
}
|
||||||
const idx = toolCalls.findIndex(tc => tc.call && tc.call.tool_call_id === event.tool_result.tool_call_id)
|
const segIdx = segments.findIndex(s => s.type === 'tool' && s.call && s.call.tool_call_id === event.tool_result.tool_call_id)
|
||||||
if (idx >= 0) {
|
if (segIdx >= 0) {
|
||||||
toolCalls[idx] = { ...toolCalls[idx], result: event.tool_result }
|
segments[segIdx].result = event.tool_result
|
||||||
|
const snap = segments.map(s => ({ ...s, ...(s.type === 'tool' ? { call: s.call, result: s.result } : {}) }))
|
||||||
|
if (_streamRafRef.current) { cancelAnimationFrame(_streamRafRef.current); _streamRafRef.current = null }
|
||||||
|
_streamPendingRef.current = null
|
||||||
setAiMessages(prev => {
|
setAiMessages(prev => {
|
||||||
const filtered = prev.filter(m => !m._streaming)
|
const filtered = prev.filter(m => !m._streaming)
|
||||||
return [...filtered, { role: 'assistant', content: accumulated, _streaming: true, _tabId: currentTab, _toolCalls: [...toolCalls] }]
|
return [...filtered, { role: 'assistant', _streaming: true, _tabId: currentTab, _segments: snap }]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -1133,23 +1158,37 @@ export default function Shell({ api, isSudo }) {
|
|||||||
if (event && (event.thinking !== undefined || event.thinking_end)) {
|
if (event && (event.thinking !== undefined || event.thinking_end)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
accumulated = partial
|
_updateLastText(partial.slice(textStartIdx))
|
||||||
setAiMessages(prev => {
|
const nextMsgs = prev => {
|
||||||
const filtered = prev.filter(m => !m._streaming)
|
const filtered = prev.filter(m => !m._streaming)
|
||||||
return [...filtered, { role: 'assistant', content: partial, _streaming: true, _tabId: currentTab, _toolCalls: toolCalls.length > 0 ? [...toolCalls] : undefined }]
|
const snap = segments.map(s => ({ ...s, ...(s.type === 'tool' ? { call: s.call, result: s.result } : {}) }))
|
||||||
})
|
return [...filtered, { role: 'assistant', _streaming: true, _tabId: currentTab, _segments: snap }]
|
||||||
|
}
|
||||||
|
_streamPendingRef.current = nextMsgs
|
||||||
|
if (!_streamRafRef.current) {
|
||||||
|
_streamRafRef.current = requestAnimationFrame(_flushStreamUpdate)
|
||||||
|
}
|
||||||
}, controller.signal)
|
}, controller.signal)
|
||||||
|
|
||||||
const finalMsg = { role: 'assistant', content: accumulated, _tabId: currentTab }
|
if (_streamRafRef.current) { cancelAnimationFrame(_streamRafRef.current); _streamRafRef.current = null }
|
||||||
if (toolCalls.length > 0) {
|
_streamPendingRef.current = null
|
||||||
finalMsg._toolCalls = toolCalls
|
|
||||||
|
const allText = segments.filter(s => s.type === 'text').map(s => s.content).join('')
|
||||||
|
const toolSegs = segments.filter(s => s.type === 'tool')
|
||||||
|
|
||||||
|
const finalMsg = { role: 'assistant', content: allText, _tabId: currentTab }
|
||||||
|
if (toolSegs.length > 0 || segments.length > 1) {
|
||||||
finalMsg.content = JSON.stringify({
|
finalMsg.content = JSON.stringify({
|
||||||
content: accumulated,
|
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,
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1159,10 +1198,10 @@ export default function Shell({ api, isSudo }) {
|
|||||||
return [...filtered, finalMsg]
|
return [...filtered, finalMsg]
|
||||||
})
|
})
|
||||||
|
|
||||||
if (analysisSavingRef.current && accumulated) {
|
if (analysisSavingRef.current && allText) {
|
||||||
analysisSavingRef.current = false
|
analysisSavingRef.current = false
|
||||||
setAnalysisContent(accumulated)
|
setAnalysisContent(allText)
|
||||||
try { localStorage.setItem('shell_analysis', accumulated) } catch {}
|
try { localStorage.setItem('shell_analysis', allText) } catch {}
|
||||||
setAnalyzing(false)
|
setAnalyzing(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1182,7 +1221,7 @@ export default function Shell({ api, isSudo }) {
|
|||||||
}
|
}
|
||||||
setAiLoading(false)
|
setAiLoading(false)
|
||||||
aiLoadingRef.current = false
|
aiLoadingRef.current = false
|
||||||
}, [api, t, aiAtLimit, focusAiTerminal])
|
}, [api, t, aiAtLimit, focusAiTerminal, _flushStreamUpdate])
|
||||||
|
|
||||||
const handleAiSend = () => _sendAiMessage(aiInput, false)
|
const handleAiSend = () => _sendAiMessage(aiInput, false)
|
||||||
|
|
||||||
@@ -1190,7 +1229,7 @@ export default function Shell({ api, isSudo }) {
|
|||||||
const handler = (e) => {
|
const handler = (e) => {
|
||||||
const msg = e.detail?.message
|
const msg = e.detail?.message
|
||||||
if (!msg) return
|
if (!msg) return
|
||||||
setAiInput(msg)
|
setAiInput('')
|
||||||
setTimeout(() => _sendAiMessage(msg, true), 100)
|
setTimeout(() => _sendAiMessage(msg, true), 100)
|
||||||
}
|
}
|
||||||
window.addEventListener('ask-ai-terminal', handler)
|
window.addEventListener('ask-ai-terminal', handler)
|
||||||
@@ -1378,11 +1417,9 @@ Sois concret : cite les vraies versions, les vrais chemins, les vrais nombres. L
|
|||||||
|
|
||||||
<div className="shell-ai-col">
|
<div className="shell-ai-col">
|
||||||
<div className="ai-panel-header">
|
<div className="ai-panel-header">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<span style={{ flex: 1 }}>Analyste Système</span>
|
||||||
<span>Analyste Système</span>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
||||||
<span className={`sudo-indicator ${isSudo ? 'sudo-ok' : 'sudo-blocked'}`} title={isSudo ? 'Sudo sans mot de passe disponible' : 'Sudo bloqué — mot de passe requis'} />
|
<span className={`sudo-indicator ${isSudo ? 'sudo-ok' : 'sudo-blocked'}`} title={isSudo ? 'Sudo sans mot de passe disponible' : 'Sudo bloqué — mot de passe requis'} />
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
|
||||||
<button
|
<button
|
||||||
className="shell-analyze-btn"
|
className="shell-analyze-btn"
|
||||||
onClick={() => setShowAnalysis(true)}
|
onClick={() => setShowAnalysis(true)}
|
||||||
@@ -1627,7 +1664,39 @@ function MermaidBlock({ code }) {
|
|||||||
return <div className="shell-mermaid-container" ref={ref} dangerouslySetInnerHTML={{ __html: svg }} />
|
return <div className="shell-mermaid-container" ref={ref} dangerouslySetInnerHTML={{ __html: svg }} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
|
const _renderParts = (parts, copiedIdx, setCopiedIdx, sendToTerminal, terminalTabId) => parts.map((part, i) => {
|
||||||
|
if (part.type === 'code' && part.lang === 'mermaid') {
|
||||||
|
return (
|
||||||
|
<div key={i} className="shell-code-block">
|
||||||
|
<div className="shell-code-lang">mermaid</div>
|
||||||
|
<MermaidBlock code={part.content} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (part.type === 'code') {
|
||||||
|
return (
|
||||||
|
<div key={i} className="shell-code-block">
|
||||||
|
{part.lang && <div className="shell-code-lang">{part.lang}</div>}
|
||||||
|
<pre><code>{part.content}</code></pre>
|
||||||
|
<div className="shell-code-actions">
|
||||||
|
<button className={copiedIdx === i ? 'copied' : ''} onClick={() => {
|
||||||
|
navigator.clipboard.writeText(part.content)
|
||||||
|
setCopiedIdx(i)
|
||||||
|
setTimeout(() => setCopiedIdx(null), 1500)
|
||||||
|
}} title="Copier">
|
||||||
|
<Copy size={12} /> {copiedIdx === i ? 'Copié !' : 'Copier'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => sendToTerminal(part.content, terminalTabId)} title="Envoyer au terminal">
|
||||||
|
<Send size={12} /> Terminal
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||||
|
})
|
||||||
|
|
||||||
|
const ShellAIMessage = memo(function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
|
||||||
const role = msg.role === 'user' ? 'user' : msg.role === 'system' ? 'system' : 'assistant'
|
const role = msg.role === 'user' ? 'user' : msg.role === 'system' ? 'system' : 'assistant'
|
||||||
const content = msg.content || ''
|
const content = msg.content || ''
|
||||||
const [copiedIdx, setCopiedIdx] = useState(null)
|
const [copiedIdx, setCopiedIdx] = useState(null)
|
||||||
@@ -1640,18 +1709,51 @@ function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
|
|||||||
return <div className={`ai-message system`}>{content}</div>
|
return <div className={`ai-message system`}>{content}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ordered segments (streaming or final with segments)
|
||||||
|
let segments = msg._segments || null
|
||||||
|
if (!segments) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content)
|
||||||
|
if (parsed && Array.isArray(parsed.segments)) {
|
||||||
|
segments = parsed.segments
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segments && segments.length > 0) {
|
||||||
|
const hasTools = segments.some(s => s.type === 'tool')
|
||||||
|
if (hasTools) {
|
||||||
|
return (
|
||||||
|
<div className="ai-message assistant">
|
||||||
|
{segments.map((seg, i) => {
|
||||||
|
if (seg.type === 'text') {
|
||||||
|
if (!seg.content) return null
|
||||||
|
return <Fragment key={`t${i}`}>{_renderParts(renderContent(seg.content), copiedIdx, setCopiedIdx, sendToTerminal, terminalTabId)}</Fragment>
|
||||||
|
}
|
||||||
|
if (seg.type === 'tool') {
|
||||||
|
const r = seg.result
|
||||||
|
const result = r && (r.content !== undefined || r.is_error !== undefined)
|
||||||
|
? { content: r.content, is_error: r.is_error }
|
||||||
|
: null
|
||||||
|
return <ShellToolBlock key={`tc${i}`} call={seg.call} result={result} />
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: old format (all tools then all text)
|
||||||
let parsedToolCalls = null
|
let parsedToolCalls = null
|
||||||
let parsedToolResults = null
|
let parsedToolResults = null
|
||||||
let displayContent = content
|
let displayContent = content
|
||||||
let streamingToolCalls = msg._toolCalls || null
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(content)
|
const parsed = JSON.parse(content)
|
||||||
if (parsed && Array.isArray(parsed.tool_calls)) {
|
if (parsed && Array.isArray(parsed.tool_calls)) {
|
||||||
if (!streamingToolCalls) {
|
parsedToolCalls = parsed.tool_calls
|
||||||
parsedToolCalls = parsed.tool_calls
|
parsedToolResults = parsed.tool_results || null
|
||||||
parsedToolResults = parsed.tool_results || null
|
|
||||||
}
|
|
||||||
displayContent = parsed.content || ''
|
displayContent = parsed.content || ''
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -1660,9 +1762,6 @@ function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`ai-message assistant`}>
|
<div className={`ai-message assistant`}>
|
||||||
{streamingToolCalls && streamingToolCalls.map((tc, i) => (
|
|
||||||
<ShellToolBlock key={tc.call?.tool_call_id || i} call={tc.call} result={tc.result} />
|
|
||||||
))}
|
|
||||||
{parsedToolCalls && parsedToolCalls.map((tc, i) => {
|
{parsedToolCalls && parsedToolCalls.map((tc, i) => {
|
||||||
const resultData = parsedToolResults
|
const resultData = parsedToolResults
|
||||||
? parsedToolResults.find(r => r.tool_call_id === tc.tool_call_id)
|
? parsedToolResults.find(r => r.tool_call_id === tc.tool_call_id)
|
||||||
@@ -1672,37 +1771,7 @@ function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
|
|||||||
: null
|
: null
|
||||||
return <ShellToolBlock key={tc.tool_call_id || i} call={tc} result={result} />
|
return <ShellToolBlock key={tc.tool_call_id || i} call={tc} result={result} />
|
||||||
})}
|
})}
|
||||||
{parts.map((part, i) => {
|
{_renderParts(parts, copiedIdx, setCopiedIdx, sendToTerminal, terminalTabId)}
|
||||||
if (part.type === 'code' && part.lang === 'mermaid') {
|
|
||||||
return (
|
|
||||||
<div key={i} className="shell-code-block">
|
|
||||||
<div className="shell-code-lang">mermaid</div>
|
|
||||||
<MermaidBlock code={part.content} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (part.type === 'code') {
|
|
||||||
return (
|
|
||||||
<div key={i} className="shell-code-block">
|
|
||||||
{part.lang && <div className="shell-code-lang">{part.lang}</div>}
|
|
||||||
<pre><code>{part.content}</code></pre>
|
|
||||||
<div className="shell-code-actions">
|
|
||||||
<button className={copiedIdx === i ? 'copied' : ''} onClick={() => {
|
|
||||||
navigator.clipboard.writeText(part.content)
|
|
||||||
setCopiedIdx(i)
|
|
||||||
setTimeout(() => setCopiedIdx(null), 1500)
|
|
||||||
}} title="Copier">
|
|
||||||
<Copy size={12} /> {copiedIdx === i ? 'Copié !' : 'Copier'}
|
|
||||||
</button>
|
|
||||||
<button onClick={() => sendToTerminal(part.content, terminalTabId)} title="Envoyer au terminal">
|
|
||||||
<Send size={12} /> Terminal
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return <span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -142,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 {
|
||||||
@@ -163,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>
|
||||||
@@ -239,20 +270,27 @@ function CodeBlockWithCopy({ part, index, copiedIdx, setCopiedIdx }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function FeedItem({ msg }) {
|
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 [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 || ''
|
||||||
@@ -292,36 +330,106 @@ function FeedItem({ msg }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{parsedToolCalls && parsedToolCalls.map((tc, i) => {
|
{parsedSegments && parsedSegments.some(s => s.type === 'tool') ? (
|
||||||
const resultData = parsedToolResults
|
(() => {
|
||||||
? parsedToolResults.find(r => r.tool_call_id === tc.tool_call_id)
|
const toolSegs = parsedSegments.filter(s => s.type === 'tool')
|
||||||
: null
|
const compress = collapseHistory && !forceExpand && toolSegs.length > 1
|
||||||
const result = resultData
|
const lastTool = toolSegs.length > 0 ? toolSegs[toolSegs.length - 1] : null
|
||||||
? { content: resultData.result, is_error: resultData.is_error }
|
return (
|
||||||
: null
|
<>
|
||||||
return <ToolCallBlock key={tc.tool_call_id || i} call={tc} result={result} />
|
{compress && (
|
||||||
})}
|
<div className="feed-content" style={{ opacity: 0.7, fontSize: '0.85em', display: 'flex', justifyContent: 'space-between' }}>
|
||||||
{cleanContent && (
|
<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>
|
||||||
<div className="feed-content">
|
<button
|
||||||
{renderContent(cleanContent).map((part, i) =>
|
type="button"
|
||||||
part.type === 'code' ? (
|
onClick={() => setForceExpand(true)}
|
||||||
<CodeBlockWithCopy key={i} part={part} index={i} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
|
style={{ background: 'transparent', border: 'none', color: 'var(--accent, #6c5ce7)', cursor: 'pointer', fontSize: 'inherit' }}
|
||||||
) : (
|
>Tout afficher</button>
|
||||||
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
</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>
|
</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 [copiedIdx, setCopiedIdx] = useState(null)
|
||||||
|
const [forceExpand, setForceExpand] = useState(false)
|
||||||
|
|
||||||
const renderedContent = useMemo(() => {
|
const renderedContent = useMemo(() => {
|
||||||
if (!cleanContent) return []
|
if (!cleanContent) return []
|
||||||
@@ -333,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">
|
||||||
@@ -346,25 +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' ? (
|
|
||||||
<CodeBlockWithCopy key={i} part={part} index={i} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
|
|
||||||
) : (
|
|
||||||
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
<span className="studio-cursor" />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -379,12 +539,23 @@ 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: 150000, summarizeAt: 120000 })
|
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 [sudoModal, setSudoModal] = useState(null)
|
||||||
const [attachedImages, setAttachedImages] = useState([])
|
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)
|
||||||
@@ -584,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)) {
|
||||||
@@ -597,28 +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) {
|
||||||
if (event.tool_result.sudo_blocked) {
|
if (event.tool_result.sudo_blocked) {
|
||||||
setSudoModal({ command: event.tool_result.command || event.tool_result.content })
|
setSudoModal({ command: event.tool_result.command || event.tool_result.content })
|
||||||
}
|
}
|
||||||
const idx = toolCalls.findIndex(tc => tc.call && tc.call.tool_call_id === event.tool_result.tool_call_id)
|
const segIdx = segments.findIndex(s => s.type === 'tool' && s.call && s.call.tool_call_id === event.tool_result.tool_call_id)
|
||||||
if (idx >= 0) {
|
if (segIdx >= 0) {
|
||||||
toolCalls[idx] = { ...toolCalls[idx], result: event.tool_result }
|
segments[segIdx].result = event.tool_result
|
||||||
setStreamToolCalls([...toolCalls])
|
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, images)
|
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',
|
||||||
@@ -626,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,
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -661,6 +865,9 @@ 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()
|
||||||
}
|
}
|
||||||
@@ -672,6 +879,10 @@ export default function Studio({ api }) {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const handleToolModeChange = useCallback((toolCallId, mode) => {
|
||||||
|
setToolModes(prev => ({ ...prev, [toolCallId]: mode }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
const COMMANDS = ['/clear', '/summarize', '/help', '/model', '/model change']
|
const COMMANDS = ['/clear', '/summarize', '/help', '/model', '/model change']
|
||||||
|
|
||||||
const handleKeyDown = (e) => {
|
const handleKeyDown = (e) => {
|
||||||
@@ -695,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">
|
||||||
@@ -730,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) {
|
||||||
@@ -753,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>
|
||||||
@@ -815,6 +1064,36 @@ export default function Studio({ api }) {
|
|||||||
<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"/>
|
<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>
|
</svg>
|
||||||
</button>
|
</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}
|
||||||
|
|||||||
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'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -211,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',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -211,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',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -379,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;
|
||||||
@@ -459,7 +459,7 @@ 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 { 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.user.analysis { border-left-color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, var(--bg-elevated)); }
|
||||||
@@ -511,7 +511,7 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.shell-mermaid-loading { padding: 16px; text-align: center; color: var(--text-tertiary); font-size: 12px; }
|
.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; }
|
.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; }
|
.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 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 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 td { padding: 3px 8px; border: 1px solid var(--border); color: var(--text-primary); white-space: nowrap; }
|
||||||
@@ -1024,8 +1024,10 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.feed-content tr:nth-child(even) td { background: var(--bg-surface); }
|
.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; }
|
.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-h3 { font-size: 15px; font-weight: 700; color: var(--text-primary); margin: 10px 0 4px; display: block; }
|
||||||
|
.msg-h4 { font-size: 13px; font-weight: 600; color: var(--text-secondary); margin: 8px 0 3px; display: block; }
|
||||||
.msg-bullet { display: block; padding-left: 4px; margin: 1px 0; color: var(--text-primary); }
|
.msg-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; }
|
||||||
@@ -1133,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);
|
||||||
@@ -1294,3 +1312,51 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.shell-xterm-instance .xterm-link:hover {
|
.shell-xterm-instance .xterm-link:hover {
|
||||||
color: var(--accent-muted) !important;
|
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;
|
||||||
|
}
|
||||||
|
|||||||