Compare commits
53 Commits
v0.7.4-bet
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b40bc291ba | ||
|
|
1216c80118 | ||
|
|
eda493856a | ||
|
|
633df84168 | ||
|
|
8e4fdfecf3 | ||
|
|
e1fe0881cf | ||
|
|
053cbca779 | ||
|
|
fd03423062 | ||
|
|
db6e7a1bf8 | ||
|
|
face2637da | ||
|
|
5a39a3a804 | ||
|
|
b7b66634ea | ||
|
|
591dc5adcd | ||
|
|
be40fa278f | ||
|
|
3f4432d88a | ||
|
|
8b8f217624 | ||
|
|
3f36974b59 | ||
|
|
55cd00802d | ||
|
|
cd9ae5f4b9 | ||
|
|
346c464ed5 | ||
|
|
3445726b67 | ||
|
|
5875dab17f | ||
|
|
4523bbd42c | ||
|
|
62c20eb174 | ||
|
|
31c99e7479 | ||
|
|
f4af63afec | ||
|
|
b5e5b302f2 | ||
|
|
872e8bfa75 | ||
|
|
31b1de1b0d | ||
|
|
9f014448a1 | ||
|
|
5094815de1 | ||
|
|
693b0e932e | ||
|
|
a60bd92858 | ||
|
|
9f9f2bd2c6 | ||
|
|
97a25295fc | ||
|
|
5fd8cceabd | ||
|
|
a3487392c0 | ||
|
|
6e4ddc192e | ||
|
|
71978adb5f | ||
|
|
af5fbf9324 | ||
|
|
29953bde6d | ||
|
|
6d155e483b | ||
|
|
e621b13926 | ||
|
|
9d1d717999 | ||
|
|
d557b8e74c | ||
|
|
e31a01d200 | ||
|
|
b3a9a49680 | ||
|
|
87e606c853 | ||
|
|
79e467c32a | ||
|
|
075d168dcd | ||
|
|
ed4c963576 | ||
|
|
f8d706cdca | ||
|
|
a9eedab0b5 |
@@ -32,13 +32,13 @@ 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: Download Go dependencies
|
- name: Download Go dependencies
|
||||||
run: go mod download
|
run: go mod download
|
||||||
@@ -49,6 +49,14 @@ jobs:
|
|||||||
npm ci
|
npm ci
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
|
- name: Build extension
|
||||||
|
run: |
|
||||||
|
cd extension
|
||||||
|
npm ci
|
||||||
|
npx wxt zip
|
||||||
|
mkdir -p ../dist
|
||||||
|
mv .output/muyue-extension-*.zip ../dist/
|
||||||
|
|
||||||
- name: Vet
|
- name: Vet
|
||||||
run: go vet ./...
|
run: go vet ./...
|
||||||
|
|
||||||
@@ -80,12 +88,13 @@ jobs:
|
|||||||
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: |
|
||||||
@@ -151,7 +160,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/muyue-windows-*.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,13 @@ 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: Download dependencies
|
- name: Download dependencies
|
||||||
run: go mod download
|
run: go mod download
|
||||||
@@ -49,6 +49,14 @@ jobs:
|
|||||||
npm ci
|
npm ci
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
|
- name: Build extension
|
||||||
|
run: |
|
||||||
|
cd extension
|
||||||
|
npm ci
|
||||||
|
npx wxt zip
|
||||||
|
mkdir -p ../dist
|
||||||
|
mv .output/muyue-extension-*.zip ../dist/
|
||||||
|
|
||||||
- name: Vet
|
- name: Vet
|
||||||
run: go vet ./...
|
run: go vet ./...
|
||||||
|
|
||||||
@@ -75,12 +83,17 @@ jobs:
|
|||||||
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: |
|
||||||
@@ -145,13 +158,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)** — sans privilèges admin, crée les raccourcis Bureau + Menu Démarrer :"
|
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 "Get-Process muyue, muyue-windows-amd64 -ErrorAction SilentlyContinue | Stop-Process -Force; Start-Sleep -Milliseconds 500"
|
||||||
echo "\$dest = \"\$env:LOCALAPPDATA\\Muyue\"; New-Item -ItemType Directory -Force -Path \$dest | Out-Null"
|
echo "\$dest = \"\$env:LOCALAPPDATA\\Muyue\"; New-Item -ItemType Directory -Force -Path \$dest | Out-Null"
|
||||||
echo "Invoke-WebRequest -Uri \"${DL_URL}/muyue-windows-amd64.zip\" -OutFile \"\$env:TEMP\\muyue.zip\""
|
echo "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 "Expand-Archive -Path \"\$env:TEMP\\muyue.zip\" -DestinationPath \$dest -Force"
|
||||||
echo "& \"\$dest\\muyue-windows-amd64.exe\" install-shortcuts"
|
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
|
||||||
|
|
||||||
@@ -232,7 +249,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/muyue-windows-*.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
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -32,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/
|
||||||
|
|||||||
1068
CHANGELOG.md
1068
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
10
Makefile
10
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-zip
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
cd $(WEB_DIR) && $(NPM) ci && $(NPM) run build
|
cd $(WEB_DIR) && $(NPM) ci && $(NPM) run build
|
||||||
@@ -63,5 +65,11 @@ build-all: frontend
|
|||||||
GOOS=windows GOARCH=amd64 $(GO) build -o dist/$(BINARY)-windows-amd64.exe ./cmd/muyue/
|
GOOS=windows GOARCH=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
|
||||||
|
|
||||||
|
ext-zip:
|
||||||
|
cd $(EXT_DIR) && $(NPM) ci && $(NPM) run zip
|
||||||
|
|
||||||
deps:
|
deps:
|
||||||
$(GO) mod tidy
|
$(GO) mod tidy
|
||||||
|
|||||||
43
README.md
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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ package commands
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -34,7 +36,20 @@ var installShortcutsCmd = &cobra.Command{
|
|||||||
installDir := filepath.Dir(exe)
|
installDir := filepath.Dir(exe)
|
||||||
|
|
||||||
fmt.Println("Installing Muyue shortcuts...")
|
fmt.Println("Installing Muyue shortcuts...")
|
||||||
fmt.Printf(" Executable : %s\n", exe)
|
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")
|
desktop, err := userShellFolder("Desktop")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -48,12 +63,12 @@ var installShortcutsCmd = &cobra.Command{
|
|||||||
desktopLnk := filepath.Join(desktop, "Muyue.lnk")
|
desktopLnk := filepath.Join(desktop, "Muyue.lnk")
|
||||||
startLnk := filepath.Join(startMenu, "Muyue.lnk")
|
startLnk := filepath.Join(startMenu, "Muyue.lnk")
|
||||||
|
|
||||||
if err := createWindowsShortcut(desktopLnk, exe, installDir, "Muyue — AI-powered dev environment"); err != nil {
|
if err := createWindowsShortcut(desktopLnk, canonicalExe, installDir, "Muyue — AI-powered dev environment"); err != nil {
|
||||||
return fmt.Errorf("create desktop shortcut: %w", err)
|
return fmt.Errorf("create desktop shortcut: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Printf(" Desktop : %s\n", desktopLnk)
|
fmt.Printf(" Desktop : %s\n", desktopLnk)
|
||||||
|
|
||||||
if err := createWindowsShortcut(startLnk, exe, installDir, "Muyue — AI-powered dev environment"); err != nil {
|
if err := createWindowsShortcut(startLnk, canonicalExe, installDir, "Muyue — AI-powered dev environment"); err != nil {
|
||||||
return fmt.Errorf("create Start Menu shortcut: %w", err)
|
return fmt.Errorf("create Start Menu shortcut: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Printf(" Start Menu : %s\n", startLnk)
|
fmt.Printf(" Start Menu : %s\n", startLnk)
|
||||||
@@ -61,14 +76,37 @@ var installShortcutsCmd = &cobra.Command{
|
|||||||
if err := addUserPATH(installDir); err != nil {
|
if err := addUserPATH(installDir); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, " PATH : warning — could not add %s to user PATH: %v\n", installDir, err)
|
fmt.Fprintf(os.Stderr, " PATH : warning — could not add %s to user PATH: %v\n", installDir, err)
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf(" PATH : added %s (open a new terminal to pick it up)\n", installDir)
|
fmt.Printf(" PATH : added %s\n", installDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("\nDone — double-click the Muyue icon on your Desktop to launch.")
|
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
|
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() {
|
func init() {
|
||||||
rootCmd.AddCommand(installShortcutsCmd)
|
rootCmd.AddCommand(installShortcutsCmd)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
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
4
extension/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
.output/
|
||||||
|
.wxt/
|
||||||
|
*.zip
|
||||||
81
extension/README.md
Normal file
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
4711
extension/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
extension/package.json
Normal file
14
extension/package.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "muyue-extension",
|
||||||
|
"version": "0.9.6",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "wxt",
|
||||||
|
"build": "wxt build",
|
||||||
|
"zip": "wxt zip"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"wxt": "^0.20"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
extension/public/icon/128.png
Normal file
BIN
extension/public/icon/128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
extension/public/icon/16.png
Normal file
BIN
extension/public/icon/16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 750 B |
BIN
extension/public/icon/32.png
Normal file
BIN
extension/public/icon/32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
BIN
extension/public/icon/512.png
Normal file
BIN
extension/public/icon/512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 307 KiB |
116
extension/src/entrypoints/background.js
Normal file
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();
|
||||||
|
});
|
||||||
195
extension/src/entrypoints/content.js
Normal file
195
extension/src/entrypoints/content.js
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { dispatch } from '../lib/page-rpc';
|
||||||
|
|
||||||
|
export default defineContentScript({
|
||||||
|
matches: ['http://*/*', 'https://*/*'],
|
||||||
|
runAt: 'document_idle',
|
||||||
|
async main() {
|
||||||
|
const autoInjectResult = await chrome.storage.local.get('muyue_auto_inject');
|
||||||
|
if (!autoInjectResult.muyue_auto_inject) return;
|
||||||
|
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
55
extension/src/entrypoints/popup/index.html
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=320" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="popup">
|
||||||
|
<header>
|
||||||
|
<img src="/icon/32.png" alt="Muyue" />
|
||||||
|
<h1>Muyue</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="status-card">
|
||||||
|
<div class="status-row">
|
||||||
|
<span class="status-label">Server</span>
|
||||||
|
<span class="status-value" id="server-status">
|
||||||
|
<span class="dot dot-yellow"></span>Checking…
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-row">
|
||||||
|
<span class="status-label">Active sessions</span>
|
||||||
|
<span class="status-value" id="session-count">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-row">
|
||||||
|
<span class="status-label">Console errors</span>
|
||||||
|
<span class="status-value" id="error-count">0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<a id="btn-dashboard" href="#" class="btn btn-primary" target="_blank">
|
||||||
|
Open Dashboard
|
||||||
|
</a>
|
||||||
|
<button id="btn-sidepanel" class="btn">
|
||||||
|
Open Chat Panel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<label>Server URL</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input type="text" id="server-url" placeholder="http://127.0.0.1:8080" />
|
||||||
|
<button id="btn-save-url">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<span>Muyue</span> extension v0.9.6
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="./main.js" type="module"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
54
extension/src/entrypoints/popup/main.js
Normal file
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();
|
||||||
97
extension/src/entrypoints/sidepanel/index.html
Normal file
97
extension/src/entrypoints/sidepanel/index.html
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="panel">
|
||||||
|
<header>
|
||||||
|
<img src="/icon/32.png" alt="Muyue" />
|
||||||
|
<h1>Muyue</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="tabs">
|
||||||
|
<button class="tab active" data-tab="config">Configuration</button>
|
||||||
|
<button class="tab" data-tab="chat">Chat</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section id="tab-config" class="tab-content active">
|
||||||
|
<div class="status-card">
|
||||||
|
<div class="status-row">
|
||||||
|
<span class="status-label">Server</span>
|
||||||
|
<span class="status-value" id="server-status">
|
||||||
|
<span class="dot dot-yellow"></span>Checking…
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-row">
|
||||||
|
<span class="status-label">Active sessions</span>
|
||||||
|
<span class="status-value" id="session-count">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-row">
|
||||||
|
<span class="status-label">Console errors</span>
|
||||||
|
<span class="status-value" id="error-count">0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="sessions-list"></div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<a id="btn-dashboard" href="#" class="btn btn-primary" target="_blank">
|
||||||
|
Open Dashboard
|
||||||
|
</a>
|
||||||
|
<button id="btn-inject-session" class="btn">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>
|
||||||
|
</svg>
|
||||||
|
Injecter le script de session
|
||||||
|
</button>
|
||||||
|
<div class="inject-status" id="inject-status" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toggle-section">
|
||||||
|
<div class="toggle-row">
|
||||||
|
<span class="toggle-label">Auto-injection</span>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="toggle-auto-inject" />
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="toggle-desc">
|
||||||
|
Injecte automatiquement le script de session dans chaque page visitée
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<label>Server URL</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input type="text" id="server-url" placeholder="http://127.0.0.1:8080" />
|
||||||
|
<button id="btn-save-url">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="tab-chat" class="tab-content">
|
||||||
|
<div id="chat-offline" class="chat-offline">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" stroke-width="1.5">
|
||||||
|
<circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
|
||||||
|
</svg>
|
||||||
|
<span>Server offline</span>
|
||||||
|
</div>
|
||||||
|
<div id="chat-area" class="studio-feed-layout" style="display:none">
|
||||||
|
<div class="chat-live-header">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="var(--accent)"><circle cx="12" cy="12" r="5"/></svg>
|
||||||
|
<span>Live — Studio</span>
|
||||||
|
</div>
|
||||||
|
<div id="chat-feed" class="studio-feed"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<span>Muyue</span> extension v0.9.6
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="./main.js" type="module"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
345
extension/src/entrypoints/sidepanel/main.js
Normal file
345
extension/src/entrypoints/sidepanel/main.js
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
import '../../styles/panel.css';
|
||||||
|
import { getServerUrl, setServerUrl, fetchSessions, checkServerHealth } from '../../lib/config';
|
||||||
|
import { getChatHistory } from '../../lib/api';
|
||||||
|
|
||||||
|
const $ = (s) => document.querySelector(s);
|
||||||
|
const $$ = (s) => document.querySelectorAll(s);
|
||||||
|
|
||||||
|
const $serverStatus = $('#server-status');
|
||||||
|
const $sessionCount = $('#session-count');
|
||||||
|
const $errorCount = $('#error-count');
|
||||||
|
const $sessionsList = $('#sessions-list');
|
||||||
|
const $btnDashboard = $('#btn-dashboard');
|
||||||
|
const $serverUrl = $('#server-url');
|
||||||
|
const $btnSaveUrl = $('#btn-save-url');
|
||||||
|
const $chatOffline = $('#chat-offline');
|
||||||
|
const $chatArea = $('#chat-area');
|
||||||
|
const $chatFeed = $('#chat-feed');
|
||||||
|
const $btnInject = $('#btn-inject-session');
|
||||||
|
const $injectStatus = $('#inject-status');
|
||||||
|
const $toggleAutoInject = $('#toggle-auto-inject');
|
||||||
|
|
||||||
|
let serverOnline = false;
|
||||||
|
let lastMessageCount = -1;
|
||||||
|
let pollInterval = null;
|
||||||
|
|
||||||
|
function dot(color) {
|
||||||
|
return `<span class="dot dot-${color}"></span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSessions(sessions) {
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
$sessionsList.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$sessionsList.innerHTML = `
|
||||||
|
<div class="status-card" style="margin-top:12px">
|
||||||
|
<div style="font-size:11px;color:var(--text-secondary);margin-bottom:8px;text-transform:uppercase;letter-spacing:0.5px">
|
||||||
|
Connected tabs
|
||||||
|
</div>
|
||||||
|
${sessions.map((s) => `
|
||||||
|
<div class="status-row">
|
||||||
|
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:200px" title="${s.url}">
|
||||||
|
${s.title || s.url || s.id}
|
||||||
|
</span>
|
||||||
|
<span style="font-size:10px;color:var(--text-secondary);font-family:var(--font-mono)">
|
||||||
|
${s.id.slice(0, 8)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatText(text) {
|
||||||
|
let html = text
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
html = html
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
|
||||||
|
.replace(/^### (.+)$/gm, '<h4>$1</h4>')
|
||||||
|
.replace(/^## (.+)$/gm, '<h3>$1</h3>')
|
||||||
|
.replace(/^# (.+)$/gm, '<h2>$1</h2>')
|
||||||
|
.replace(/^\s*[-*] (.+)$/gm, '<div class="chat-bullet">• $1</div>')
|
||||||
|
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<div class="chat-step"><span class="chat-step-num">$1</span> $2</div>')
|
||||||
|
.replace(/\n/g, '<br/>');
|
||||||
|
html = html
|
||||||
|
.replace(/<br\/>\s*<br\/>/g, '<br/>')
|
||||||
|
.replace(/<br\/>\s*(<h[234]|<div class="chat-)/g, '$1')
|
||||||
|
.replace(/(<\/h[234]|<\/div>)\s*<br\/>/g, '$1');
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderContent(text) {
|
||||||
|
const parts = [];
|
||||||
|
const codeBlockRegex = /(```[\s\S]*?```)/g;
|
||||||
|
let match;
|
||||||
|
let lastIndex = 0;
|
||||||
|
while ((match = codeBlockRegex.exec(text)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
parts.push({ type: 'text', content: text.slice(lastIndex, match.index) });
|
||||||
|
}
|
||||||
|
const full = match[1];
|
||||||
|
const firstNewline = full.indexOf('\n');
|
||||||
|
const lang = firstNewline > -1 ? full.slice(3, firstNewline).trim() : '';
|
||||||
|
const code = firstNewline > -1 ? full.slice(firstNewline + 1, -3) : full.slice(3, -3);
|
||||||
|
parts.push({ type: 'code', lang, content: code });
|
||||||
|
lastIndex = match.index + full.length;
|
||||||
|
}
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
parts.push({ type: 'text', content: text.slice(lastIndex) });
|
||||||
|
}
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMessageEl(msg) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = `chat-msg ${msg.role}`;
|
||||||
|
|
||||||
|
if (msg.role === 'system') {
|
||||||
|
el.innerHTML = `<div class="chat-system-dot"></div><div class="chat-system-text">${escapeHtml(msg.content)}</div>`;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUser = msg.role === 'user';
|
||||||
|
const avatar = isUser ? '★' : '◆';
|
||||||
|
const label = isUser ? 'CDT' : 'GEN';
|
||||||
|
|
||||||
|
let displayContent = msg.content;
|
||||||
|
let parsedToolCalls = null;
|
||||||
|
let parsedSegments = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(msg.content);
|
||||||
|
if (parsed && Array.isArray(parsed.segments)) {
|
||||||
|
parsedSegments = parsed.segments;
|
||||||
|
displayContent = parsed.content || '';
|
||||||
|
} else if (parsed && Array.isArray(parsed.tool_calls)) {
|
||||||
|
parsedToolCalls = parsed.tool_calls;
|
||||||
|
displayContent = parsed.content || '';
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const cleanContent = displayContent.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '');
|
||||||
|
|
||||||
|
let bodyHtml = '';
|
||||||
|
if (parsedSegments) {
|
||||||
|
bodyHtml = parsedSegments.map((seg) => {
|
||||||
|
if (seg.type === 'text' && seg.content) {
|
||||||
|
const c = seg.content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '');
|
||||||
|
if (!c) return '';
|
||||||
|
return renderContent(c).map((p) => {
|
||||||
|
if (p.type === 'code') {
|
||||||
|
return `<div class="chat-code-block"><div class="chat-code-header"><span class="chat-code-lang">${p.lang || ''}</span><button class="chat-copy-btn" data-code="${encodeURIComponent(p.content)}">Copy</button></div><pre><code>${escapeHtml(p.content)}</code></pre></div>`;
|
||||||
|
}
|
||||||
|
return `<span>${formatText(p.content)}</span>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
if (seg.type === 'tool') {
|
||||||
|
const name = seg.call?.name || 'tool';
|
||||||
|
const icon = { terminal: '⌨', crush_run: '⚡', read_file: '📄', list_files: '📁', search_files: '🔍', web_fetch: '🌐' }[name] || '🔧';
|
||||||
|
const done = seg.result;
|
||||||
|
const isErr = done && done.is_error;
|
||||||
|
const preview = (() => {
|
||||||
|
try {
|
||||||
|
const args = typeof seg.call.args === 'string' ? JSON.parse(seg.call.args) : seg.call.args;
|
||||||
|
return args.command || args.task || args.path || args.url || JSON.stringify(args).slice(0, 60);
|
||||||
|
} catch { return ''; }
|
||||||
|
})();
|
||||||
|
const resultText = done ? (done.content || '').slice(0, 500) : '';
|
||||||
|
return `<div class="chat-tool ${done ? 'done' : 'running'} ${isErr ? 'error' : ''}"><div class="chat-tool-header"><span class="chat-tool-icon">${icon}</span><span>${name}</span>${done ? `<span class="chat-tool-status ${isErr ? 'err' : 'ok'}">${isErr ? '✗' : '✓'}</span>` : '<span class="chat-dots"><span></span><span></span><span></span></span>'}</div>${preview ? `<div class="chat-tool-args">${escapeHtml(preview)}</div>` : ''}${resultText ? `<pre class="chat-tool-result">${escapeHtml(resultText)}</pre>` : ''}</div>`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}).join('');
|
||||||
|
} else {
|
||||||
|
if (cleanContent) {
|
||||||
|
bodyHtml = renderContent(cleanContent).map((p) => {
|
||||||
|
if (p.type === 'code') {
|
||||||
|
return `<div class="chat-code-block"><div class="chat-code-header"><span class="chat-code-lang">${p.lang || ''}</span><button class="chat-copy-btn" data-code="${encodeURIComponent(p.content)}">Copy</button></div><pre><code>${escapeHtml(p.content)}</code></pre></div>`;
|
||||||
|
}
|
||||||
|
return `<span>${formatText(p.content)}</span>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
if (parsedToolCalls && parsedToolCalls.length > 0) {
|
||||||
|
bodyHtml = parsedToolCalls.map((tc) => {
|
||||||
|
const name = tc.name || 'tool';
|
||||||
|
const icon = { terminal: '⌨', crush_run: '⚡', read_file: '📄', web_fetch: '🌐' }[name] || '🔧';
|
||||||
|
return `<div class="chat-tool done"><div class="chat-tool-header"><span class="chat-tool-icon">${icon}</span><span>${name}</span><span class="chat-tool-status ok">✓</span></div></div>`;
|
||||||
|
}).join('') + bodyHtml;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bodyHtml) bodyHtml = '<span class="chat-dots"><span></span><span></span><span></span></span>';
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="chat-avatar ${isUser ? 'user' : 'ai'}">${avatar}</div>
|
||||||
|
<div class="chat-body">
|
||||||
|
<div class="chat-header"><span class="chat-badge" style="color:${isUser ? '#FFD740' : '#FF9100'};border-color:${isUser ? '#FFD740' : '#FF9100'}">${label}</span></div>
|
||||||
|
<div class="chat-content">${bodyHtml}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
$chatFeed.scrollTop = $chatFeed.scrollHeight;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchTab(tabName) {
|
||||||
|
$$('.tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === tabName));
|
||||||
|
$$('.tab-content').forEach((s) => s.classList.toggle('active', s.id === `tab-${tabName}`));
|
||||||
|
if (tabName === 'chat' && serverOnline) {
|
||||||
|
pollStudio();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChatVisibility() {
|
||||||
|
if (serverOnline) {
|
||||||
|
$chatOffline.style.display = 'none';
|
||||||
|
$chatArea.style.display = 'flex';
|
||||||
|
} else {
|
||||||
|
$chatOffline.style.display = 'flex';
|
||||||
|
$chatArea.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollStudio() {
|
||||||
|
try {
|
||||||
|
const data = await getChatHistory();
|
||||||
|
const msgs = data.messages || [];
|
||||||
|
if (msgs.length !== lastMessageCount) {
|
||||||
|
lastMessageCount = msgs.length;
|
||||||
|
$chatFeed.innerHTML = '';
|
||||||
|
msgs.forEach((msg) => {
|
||||||
|
$chatFeed.appendChild(createMessageEl(msg));
|
||||||
|
});
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectSessionScript() {
|
||||||
|
$injectStatus.style.display = 'block';
|
||||||
|
$injectStatus.className = 'inject-status';
|
||||||
|
$injectStatus.textContent = 'Injection en cours…';
|
||||||
|
|
||||||
|
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||||
|
if (!tabs || !tabs[0]) {
|
||||||
|
$injectStatus.textContent = 'Erreur: aucun onglet actif';
|
||||||
|
$injectStatus.classList.add('inject-error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tabId = tabs[0].id;
|
||||||
|
|
||||||
|
chrome.scripting.executeScript({
|
||||||
|
target: { tabId },
|
||||||
|
func: () => {
|
||||||
|
if (window.__muyueExtension) {
|
||||||
|
return 'already_injected';
|
||||||
|
}
|
||||||
|
window.__muyueExtension = true;
|
||||||
|
return 'fresh_inject';
|
||||||
|
},
|
||||||
|
}, (results) => {
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
$injectStatus.textContent = 'Erreur: ' + chrome.runtime.lastError.message;
|
||||||
|
$injectStatus.classList.add('inject-error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = results?.[0]?.result;
|
||||||
|
|
||||||
|
if (status === 'already_injected') {
|
||||||
|
$injectStatus.textContent = '✓ Script déjà injecté dans cette page';
|
||||||
|
$injectStatus.classList.add('inject-success');
|
||||||
|
} else {
|
||||||
|
chrome.tabs.reload(tabId, {}, () => {
|
||||||
|
$injectStatus.textContent = '✓ Page rechargée avec le script de session';
|
||||||
|
$injectStatus.classList.add('inject-success');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
$injectStatus.style.display = 'none';
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAutoInjectSetting() {
|
||||||
|
const result = await chrome.storage.local.get('muyue_auto_inject');
|
||||||
|
$toggleAutoInject.checked = !!result.muyue_auto_inject;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAutoInjectSetting(enabled) {
|
||||||
|
await chrome.storage.local.set({ muyue_auto_inject: enabled });
|
||||||
|
}
|
||||||
|
|
||||||
|
$$('.tab').forEach((tab) => {
|
||||||
|
tab.addEventListener('click', () => switchTab(tab.dataset.tab));
|
||||||
|
});
|
||||||
|
|
||||||
|
$chatFeed.addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('.chat-copy-btn');
|
||||||
|
if (btn) {
|
||||||
|
navigator.clipboard.writeText(decodeURIComponent(btn.dataset.code));
|
||||||
|
const orig = btn.textContent;
|
||||||
|
btn.textContent = 'Copied!';
|
||||||
|
setTimeout(() => { btn.textContent = orig; }, 1200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$btnInject.addEventListener('click', injectSessionScript);
|
||||||
|
|
||||||
|
$toggleAutoInject.addEventListener('change', () => {
|
||||||
|
saveAutoInjectSetting($toggleAutoInject.checked);
|
||||||
|
});
|
||||||
|
|
||||||
|
$btnSaveUrl.addEventListener('click', async () => {
|
||||||
|
const url = $serverUrl.value.trim().replace(/\/$/, '');
|
||||||
|
if (url) {
|
||||||
|
await setServerUrl(url);
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const url = await getServerUrl();
|
||||||
|
$serverUrl.value = url;
|
||||||
|
$btnDashboard.href = url;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessions = await fetchSessions();
|
||||||
|
serverOnline = true;
|
||||||
|
$serverStatus.innerHTML = `${dot('green')} Online`;
|
||||||
|
$sessionCount.textContent = sessions.length;
|
||||||
|
renderSessions(sessions);
|
||||||
|
} catch {
|
||||||
|
serverOnline = false;
|
||||||
|
$serverStatus.innerHTML = `${dot('red')} Offline`;
|
||||||
|
$sessionCount.textContent = '—';
|
||||||
|
$sessionsList.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
updateChatVisibility();
|
||||||
|
|
||||||
|
chrome.runtime.sendMessage({ type: 'get_state' }, (state) => {
|
||||||
|
if (chrome.runtime.lastError || !state) return;
|
||||||
|
$errorCount.textContent = state.errorCount || 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAutoInjectSetting();
|
||||||
|
refresh();
|
||||||
|
|
||||||
|
if (pollInterval) clearInterval(pollInterval);
|
||||||
|
pollInterval = setInterval(() => {
|
||||||
|
refresh();
|
||||||
|
if (serverOnline) pollStudio();
|
||||||
|
}, 3000);
|
||||||
77
extension/src/lib/api.js
Normal file
77
extension/src/lib/api.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { getServerUrl } from './config';
|
||||||
|
|
||||||
|
async function request(path, options = {}) {
|
||||||
|
const base = await getServerUrl();
|
||||||
|
const res = await fetch(`${base}/api${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||||
|
throw new Error(err.error || res.statusText);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getChatHistory() {
|
||||||
|
return request('/chat/history');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearChat() {
|
||||||
|
return request('/chat/clear', { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function summarizeChat() {
|
||||||
|
return request('/chat/summarize', { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendChat(message, stream = true, onChunk, signal) {
|
||||||
|
const base = await getServerUrl();
|
||||||
|
|
||||||
|
if (!stream) {
|
||||||
|
return request('/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ message, stream: false }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
fetch(`${base}/api/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ message, stream: true }),
|
||||||
|
signal,
|
||||||
|
}).then(async (res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||||
|
reject(new Error(err.error || res.statusText));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let full = '';
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
const text = decoder.decode(value, { stream: true });
|
||||||
|
for (const line of text.split('\n')) {
|
||||||
|
if (!line.startsWith('data: ')) continue;
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(line.slice(6));
|
||||||
|
if (data.error) { reject(new Error(data.error)); return; }
|
||||||
|
if (data.done) { resolve(full); return; }
|
||||||
|
if (data.content) {
|
||||||
|
full += data.content;
|
||||||
|
if (onChunk) onChunk(full, data);
|
||||||
|
} else if (data.thinking !== undefined || data.thinking_end) {
|
||||||
|
if (onChunk) onChunk(full, data);
|
||||||
|
} else if (data.tool_call || data.tool_result) {
|
||||||
|
if (onChunk) onChunk(full, data);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolve(full);
|
||||||
|
}).catch(reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
56
extension/src/lib/config.js
Normal file
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
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
841
extension/src/styles/panel.css
Normal file
841
extension/src/styles/panel.css
Normal file
@@ -0,0 +1,841 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0A0A0C;
|
||||||
|
--bg-base: #0F0D10;
|
||||||
|
--bg-surface: #161218;
|
||||||
|
--bg-elevated: #1C1719;
|
||||||
|
--bg-card: #221B1E;
|
||||||
|
--bg-input: #2A2225;
|
||||||
|
--bg-hover: #332528;
|
||||||
|
|
||||||
|
--accent: #FF0033;
|
||||||
|
--accent-dark: #8B0020;
|
||||||
|
--accent-deep: #5C0015;
|
||||||
|
--accent-light: #FF1A5E;
|
||||||
|
--accent-muted: #FF4D6D;
|
||||||
|
--accent-bright: #FF1744;
|
||||||
|
--accent-soft: #FF5252;
|
||||||
|
--accent-dim: #6B2033;
|
||||||
|
--accent-bg: #4A1525;
|
||||||
|
|
||||||
|
--text-primary: #EAE0E2;
|
||||||
|
--text-secondary: #D4C4C8;
|
||||||
|
--text-tertiary: #8A7A7E;
|
||||||
|
--text-disabled: #5A4F52;
|
||||||
|
|
||||||
|
--success: #00E676;
|
||||||
|
--warning: #FFD740;
|
||||||
|
--error: #FF1744;
|
||||||
|
--info: #448AFF;
|
||||||
|
|
||||||
|
--border: #2A1F22;
|
||||||
|
--border-accent: #FF003344;
|
||||||
|
--border-accent-full: #FF0033;
|
||||||
|
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius: 8px;
|
||||||
|
--radius-lg: 12px;
|
||||||
|
|
||||||
|
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace;
|
||||||
|
|
||||||
|
--green: #00E676;
|
||||||
|
--yellow: #FFD740;
|
||||||
|
--red: #FF1744;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection { background: var(--accent); color: #fff; }
|
||||||
|
|
||||||
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--accent-dim); border-radius: 3px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: var(--accent-dark); }
|
||||||
|
|
||||||
|
a { color: var(--accent); text-decoration: none; cursor: pointer; }
|
||||||
|
|
||||||
|
/* ── Popup (icon click) ── */
|
||||||
|
.popup {
|
||||||
|
width: 320px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup .footer {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Panel (side panel) ── */
|
||||||
|
.panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel > header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
header img { width: 28px; height: 28px; }
|
||||||
|
header h1 { font-size: 16px; font-weight: 600; letter-spacing: -0.3px; color: var(--text-primary); }
|
||||||
|
|
||||||
|
/* ── Tabs ── */
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 8px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover { color: var(--text-primary); }
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
color: var(--accent);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Config tab ── */
|
||||||
|
.status-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-row + .status-row {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin-top: 6px;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-label { color: var(--text-tertiary); font-size: 12px; }
|
||||||
|
.status-value { font-weight: 500; font-size: 12px; color: var(--text-primary); }
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot-green { background: var(--success); box-shadow: 0 0 6px var(--success); }
|
||||||
|
.dot-red { background: var(--error); box-shadow: 0 0 6px var(--error); }
|
||||||
|
.dot-yellow { background: var(--warning); box-shadow: 0 0 6px var(--warning); }
|
||||||
|
|
||||||
|
.actions { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover { background: var(--accent-bg); border-color: var(--accent-dark); color: var(--text-primary); }
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--accent-bright);
|
||||||
|
border-color: var(--accent-bright);
|
||||||
|
box-shadow: 0 0 12px var(--border-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section label {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 11px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row { display: flex; gap: 6px; }
|
||||||
|
|
||||||
|
.input-row input {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 6px 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row input:focus { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.input-row button {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row button:hover { background: var(--accent-bg); border-color: var(--accent-dark); }
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer span { color: var(--accent); }
|
||||||
|
|
||||||
|
/* ── Chat offline ── */
|
||||||
|
.chat-offline {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Studio Feed (same classes as Studio.jsx) ── */
|
||||||
|
.studio-feed-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.studio-feed {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
animation: fadeIn 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-item:hover { background: var(--bg-card); }
|
||||||
|
|
||||||
|
.feed-item.user {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-left: 3px solid #FFD740;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-item.assistant {
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-item.assistant:hover {
|
||||||
|
border-left-color: var(--accent-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-item.system {
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-avatar.user-rank {
|
||||||
|
background: rgba(255, 215, 64, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-avatar.ai-rank {
|
||||||
|
background: var(--accent-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-rank-badge {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 800;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: rgba(255, 215, 64, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-role {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-time {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-disabled);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-content {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-system-badge {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-system-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-style: italic;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-content table { width: 100%; border-collapse: collapse; margin: 8px 0; font-size: 13px; }
|
||||||
|
.feed-content th { background: var(--bg-surface); padding: 6px 12px; text-align: left; font-weight: 600; border: 1px solid var(--border); color: var(--text-secondary); }
|
||||||
|
.feed-content td { padding: 5px 12px; border: 1px solid var(--border); color: var(--text-primary); }
|
||||||
|
.feed-content tr:nth-child(even) td { background: var(--bg-surface); }
|
||||||
|
.feed-content hr { border: none; border-top: 1px solid var(--border); margin: 12px 0; }
|
||||||
|
|
||||||
|
.inline-code { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 13px; color: var(--accent-muted); }
|
||||||
|
.msg-h2 { font-size: 17px; font-weight: 700; color: var(--text-primary); margin: 12px 0 6px; display: block; }
|
||||||
|
.msg-h3 { font-size: 15px; font-weight: 700; color: var(--text-primary); margin: 10px 0 4px; display: block; }
|
||||||
|
.msg-h4 { font-size: 13px; font-weight: 600; color: var(--text-secondary); margin: 8px 0 3px; display: block; }
|
||||||
|
.msg-bullet { display: block; padding-left: 4px; margin: 1px 0; color: var(--text-primary); }
|
||||||
|
.msg-step { display: flex; gap: 8px; align-items: baseline; margin: 1px 0; }
|
||||||
|
.msg-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); font-size: 13px; flex-shrink: 0; min-width: 20px; }
|
||||||
|
|
||||||
|
/* ── Studio Code Blocks ── */
|
||||||
|
.studio-code-block {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.studio-code-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.studio-code-block pre {
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-x: auto;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.studio-code-lang {
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.studio-copy-btn {
|
||||||
|
padding: 3px 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.studio-copy-btn:hover { background: var(--accent-bg); color: var(--accent); }
|
||||||
|
.studio-copy-btn.copied { background: var(--accent-bg); color: var(--accent); }
|
||||||
|
|
||||||
|
/* ── Studio Thinking ── */
|
||||||
|
.feed-thinking-block {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 2px solid var(--accent-dim);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin: 6px 0 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-thinking-block.done { border-left-color: var(--text-disabled); opacity: 0.7; }
|
||||||
|
|
||||||
|
.feed-thinking-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-thinking-header svg { color: var(--warning); }
|
||||||
|
|
||||||
|
.feed-thinking-dots { display: inline-flex; gap: 2px; margin-left: 4px; }
|
||||||
|
.feed-thinking-dots span { width: 4px; height: 4px; border-radius: 50%; background: var(--warning); animation: bounce 1.2s ease-in-out infinite; }
|
||||||
|
.feed-thinking-dots span:nth-child(2) { animation-delay: 0.15s; }
|
||||||
|
.feed-thinking-dots span:nth-child(3) { animation-delay: 0.3s; }
|
||||||
|
|
||||||
|
.feed-thinking-content {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-style: italic;
|
||||||
|
line-height: 1.5;
|
||||||
|
max-height: 80px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Studio Tool Blocks ── */
|
||||||
|
.studio-tool-block {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--accent-dim);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin: 6px 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.studio-tool-block.running { border-left-color: var(--warning); }
|
||||||
|
.studio-tool-block.error { border-left-color: var(--error); background: rgba(255, 23, 68, 0.05); }
|
||||||
|
|
||||||
|
.studio-tool-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.studio-tool-icon { font-size: 14px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.studio-tool-name {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.studio-tool-spinner { display: inline-flex; gap: 2px; margin-left: 4px; }
|
||||||
|
.studio-tool-spinner span { width: 4px; height: 4px; border-radius: 50%; background: var(--warning); animation: bounce 1.2s ease-in-out infinite; }
|
||||||
|
.studio-tool-spinner span:nth-child(2) { animation-delay: 0.15s; }
|
||||||
|
.studio-tool-spinner span:nth-child(3) { animation-delay: 0.3s; }
|
||||||
|
|
||||||
|
.studio-tool-status { font-weight: 700; font-size: 14px; flex-shrink: 0; }
|
||||||
|
.studio-tool-status.ok { color: var(--success); }
|
||||||
|
.studio-tool-status.error { color: var(--error); }
|
||||||
|
|
||||||
|
.studio-tool-args {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.studio-tool-result { max-height: 200px; overflow-y: auto; }
|
||||||
|
.studio-tool-result pre {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Studio Cursor & Thinking Dots ── */
|
||||||
|
.studio-cursor {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 16px;
|
||||||
|
background: var(--accent);
|
||||||
|
margin-left: 2px;
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
animation: blink 0.8s step-end infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.studio-thinking { display: flex; gap: 4px; padding: 8px 0; }
|
||||||
|
.studio-thinking span { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-dim); animation: bounce 1.2s ease-in-out infinite; }
|
||||||
|
.studio-thinking span:nth-child(2) { animation-delay: 0.15s; }
|
||||||
|
.studio-thinking span:nth-child(3) { animation-delay: 0.3s; }
|
||||||
|
|
||||||
|
@keyframes blink { 50% { opacity: 0; } }
|
||||||
|
@keyframes bounce { 0%, 80%, 100% { transform: translateY(0); opacity: 0.4; } 40% { transform: translateY(-6px); opacity: 1; } }
|
||||||
|
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
|
||||||
|
/* ── Studio Input Area ── */
|
||||||
|
.studio-input-area {
|
||||||
|
padding: 12px 16px 8px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.studio-input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.studio-input-row textarea {
|
||||||
|
flex: 1;
|
||||||
|
resize: none;
|
||||||
|
min-height: 42px;
|
||||||
|
max-height: 120px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.studio-input-row textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--border-accent); }
|
||||||
|
.studio-input-row textarea::placeholder { color: var(--text-disabled); }
|
||||||
|
|
||||||
|
.studio-send-btn {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.studio-send-btn:hover { background: var(--accent-bright); border-color: var(--accent-bright); }
|
||||||
|
.studio-send-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.studio-stop-btn {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--error);
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid var(--error);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.studio-stop-btn:hover { opacity: 0.8; }
|
||||||
|
|
||||||
|
.studio-input-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-disabled);
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Inject Session ── */
|
||||||
|
#btn-inject-session {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
#btn-inject-session:hover {
|
||||||
|
background: var(--accent-bg);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#btn-inject-session svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inject-status {
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inject-status.inject-success {
|
||||||
|
color: var(--success);
|
||||||
|
background: rgba(0, 230, 118, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inject-status.inject-error {
|
||||||
|
color: var(--error);
|
||||||
|
background: rgba(255, 23, 68, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Toggle Switch ── */
|
||||||
|
.toggle-section {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-desc {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-top: 6px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 40px;
|
||||||
|
height: 22px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 22px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-slider::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
left: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
background: var(--text-tertiary);
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input:checked + .toggle-slider {
|
||||||
|
background: var(--accent-bg);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input:checked + .toggle-slider::before {
|
||||||
|
background: var(--accent);
|
||||||
|
transform: translateX(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Live Chat Header ── */
|
||||||
|
.chat-live-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-live-header svg {
|
||||||
|
animation: pulse-live 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-live {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
28
extension/wxt.config.js
Normal file
28
extension/wxt.config.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { defineConfig } from 'wxt';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
srcDir: 'src',
|
||||||
|
manifest: {
|
||||||
|
name: 'Muyue',
|
||||||
|
description: 'AI-powered browser testing & automation — connected to your Muyue desktop app',
|
||||||
|
permissions: [
|
||||||
|
'storage',
|
||||||
|
'activeTab',
|
||||||
|
'tabs',
|
||||||
|
'sidePanel',
|
||||||
|
'scripting',
|
||||||
|
'notifications',
|
||||||
|
'alarms',
|
||||||
|
],
|
||||||
|
host_permissions: ['<all_urls>'],
|
||||||
|
action: {
|
||||||
|
default_icon: {
|
||||||
|
16: 'icon/16.png',
|
||||||
|
32: 'icon/32.png',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
side_panel: {
|
||||||
|
default_path: 'sidepanel.html',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
12
go.mod
12
go.mod
@@ -1,8 +1,6 @@
|
|||||||
module github.com/muyue/muyue
|
module github.com/muyue/muyue
|
||||||
|
|
||||||
go 1.24.2
|
go 1.25.0
|
||||||
|
|
||||||
toolchain go1.24.3
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/charmbracelet/huh v1.0.0
|
github.com/charmbracelet/huh v1.0.0
|
||||||
@@ -39,9 +37,15 @@ require (
|
|||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/termenv v0.16.0 // indirect
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/spf13/pflag v1.0.9 // indirect
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
golang.org/x/text v0.23.0 // indirect
|
golang.org/x/text v0.23.0 // indirect
|
||||||
|
modernc.org/libc v1.72.0 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
modernc.org/sqlite v1.50.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
14
go.sum
14
go.sum
@@ -73,6 +73,10 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
|
|||||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
@@ -89,9 +93,19 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
|
||||||
|
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
|
||||||
|
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
|
||||||
|
|||||||
378
internal/agent/browser.go
Normal file
378
internal/agent/browser.go
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BrowserParams struct {
|
||||||
|
Action string `json:"action" description:"Browser action: navigate, screenshot, click, type, evaluate, fill_form, read_page, close"`
|
||||||
|
URL string `json:"url,omitempty" description:"URL to navigate to (for navigate action)"`
|
||||||
|
Selector string `json:"selector,omitempty" description:"CSS/XPath selector for click, type, fill_form actions"`
|
||||||
|
Value string `json:"value,omitempty" description:"Value to type or fill"`
|
||||||
|
Script string `json:"script,omitempty" description:"JavaScript to evaluate (for evaluate action)"`
|
||||||
|
Timeout int `json:"timeout,omitempty" description:"Timeout in seconds for the action (default 30)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BrowserResponse struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Screenshot string `json:"screenshot,omitempty"`
|
||||||
|
IsError bool `json:"is_error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BrowserSession struct {
|
||||||
|
id string
|
||||||
|
url string
|
||||||
|
title string
|
||||||
|
mu sync.Mutex
|
||||||
|
createdAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type BrowserManager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
sessions map[string]*BrowserSession
|
||||||
|
playwrightPath string
|
||||||
|
available bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
browserManager *BrowserManager
|
||||||
|
browserManagerOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetBrowserManager() *BrowserManager {
|
||||||
|
browserManagerOnce.Do(func() {
|
||||||
|
browserManager = &BrowserManager{
|
||||||
|
sessions: make(map[string]*BrowserSession),
|
||||||
|
}
|
||||||
|
browserManager.playwrightPath, browserManager.available = detectPlaywright()
|
||||||
|
})
|
||||||
|
return browserManager
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectPlaywright() (string, bool) {
|
||||||
|
for _, cmd := range []string{"playwright", "npx"} {
|
||||||
|
if path, err := exec.LookPath(cmd); err == nil {
|
||||||
|
return path, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBrowserTool() (*ToolDefinition, error) {
|
||||||
|
return NewTool("browser",
|
||||||
|
"Interact with web pages using a headless browser (Playwright). Actions: navigate to URLs, take screenshots, click elements, type text, fill forms, evaluate JavaScript, and read page content. Sessions persist per conversation.",
|
||||||
|
func(ctx context.Context, p BrowserParams) (ToolResponse, error) {
|
||||||
|
if p.Action == "" {
|
||||||
|
return TextErrorResponse("action is required (navigate, screenshot, click, type, evaluate, fill_form, read_page, close)"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
mgr := GetBrowserManager()
|
||||||
|
if !mgr.available {
|
||||||
|
return TextErrorResponse("Playwright is not installed. Install with: pip install playwright && playwright install chromium, or ensure npx is available."), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := time.Duration(p.Timeout) * time.Second
|
||||||
|
if timeout == 0 {
|
||||||
|
timeout = 30 * time.Second
|
||||||
|
}
|
||||||
|
if timeout > 120*time.Second {
|
||||||
|
timeout = 120 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
switch p.Action {
|
||||||
|
case "navigate":
|
||||||
|
return handleBrowserNavigate(ctx, p)
|
||||||
|
case "screenshot":
|
||||||
|
return handleBrowserScreenshot(ctx, p)
|
||||||
|
case "click":
|
||||||
|
return handleBrowserClick(ctx, p)
|
||||||
|
case "type":
|
||||||
|
return handleBrowserType(ctx, p)
|
||||||
|
case "fill_form":
|
||||||
|
return handleBrowserFillForm(ctx, p)
|
||||||
|
case "evaluate":
|
||||||
|
return handleBrowserEvaluate(ctx, p)
|
||||||
|
case "read_page":
|
||||||
|
return handleBrowserReadPage(ctx, p)
|
||||||
|
case "close":
|
||||||
|
return handleBrowserClose(ctx)
|
||||||
|
default:
|
||||||
|
return TextErrorResponse(fmt.Sprintf("unknown browser action: %s. Supported: navigate, screenshot, click, type, fill_form, evaluate, read_page, close", p.Action)), nil
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleBrowserNavigate(ctx context.Context, p BrowserParams) (ToolResponse, error) {
|
||||||
|
if p.URL == "" {
|
||||||
|
return TextErrorResponse("url is required for navigate action"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
const { chromium } = require('playwright');
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.goto(%q, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||||
|
const title = await page.title();
|
||||||
|
const content = await page.evaluate(() => document.body.innerText);
|
||||||
|
console.log(JSON.stringify({ url: page.url(), title, content: content.substring(0, 8000) }));
|
||||||
|
await browser.close();
|
||||||
|
})();
|
||||||
|
`, p.URL)
|
||||||
|
|
||||||
|
result, err := runPlaywrightScript(ctx, script)
|
||||||
|
if err != nil {
|
||||||
|
return TextErrorResponse(fmt.Sprintf("navigate error: %v", err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return TextResponse(result), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleBrowserScreenshot(ctx context.Context, p BrowserParams) (ToolResponse, error) {
|
||||||
|
url := p.URL
|
||||||
|
if url == "" {
|
||||||
|
url = "about:blank"
|
||||||
|
}
|
||||||
|
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
screenshotDir := filepath.Join(home, ".muyue", "screenshots")
|
||||||
|
os.MkdirAll(screenshotDir, 0755)
|
||||||
|
screenshotPath := filepath.Join(screenshotDir, fmt.Sprintf("browser_%d.png", time.Now().UnixNano()))
|
||||||
|
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
const { chromium } = require('playwright');
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.goto(%q, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||||
|
await page.screenshot({ path: %q, fullPage: false });
|
||||||
|
const title = await page.title();
|
||||||
|
console.log(JSON.stringify({ screenshot: %q, title, url: page.url() }));
|
||||||
|
await browser.close();
|
||||||
|
})();
|
||||||
|
`, url, screenshotPath, screenshotPath)
|
||||||
|
|
||||||
|
result, err := runPlaywrightScript(ctx, script)
|
||||||
|
if err != nil {
|
||||||
|
return TextErrorResponse(fmt.Sprintf("screenshot error: %v", err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return TextResponse(fmt.Sprintf("Screenshot saved: %s\n%s", screenshotPath, result)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleBrowserClick(ctx context.Context, p BrowserParams) (ToolResponse, error) {
|
||||||
|
if p.Selector == "" {
|
||||||
|
return TextErrorResponse("selector is required for click action"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
const { chromium } = require('playwright');
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.goto(%q, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||||
|
await page.click(%q);
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
const title = await page.title();
|
||||||
|
const content = await page.evaluate(() => document.body.innerText);
|
||||||
|
console.log(JSON.stringify({ url: page.url(), title, content: content.substring(0, 5000) }));
|
||||||
|
await browser.close();
|
||||||
|
})();
|
||||||
|
`, p.URL, p.Selector)
|
||||||
|
|
||||||
|
result, err := runPlaywrightScript(ctx, script)
|
||||||
|
if err != nil {
|
||||||
|
return TextErrorResponse(fmt.Sprintf("click error: %v", err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return TextResponse(result), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleBrowserType(ctx context.Context, p BrowserParams) (ToolResponse, error) {
|
||||||
|
if p.Selector == "" || p.Value == "" {
|
||||||
|
return TextErrorResponse("selector and value are required for type action"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
const { chromium } = require('playwright');
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.goto(%q, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||||
|
await page.fill(%q, %q);
|
||||||
|
const content = await page.evaluate(() => document.body.innerText);
|
||||||
|
console.log(JSON.stringify({ url: page.url(), content: content.substring(0, 5000) }));
|
||||||
|
await browser.close();
|
||||||
|
})();
|
||||||
|
`, p.URL, p.Selector, p.Value)
|
||||||
|
|
||||||
|
result, err := runPlaywrightScript(ctx, script)
|
||||||
|
if err != nil {
|
||||||
|
return TextErrorResponse(fmt.Sprintf("type error: %v", err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return TextResponse(result), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleBrowserFillForm(ctx context.Context, p BrowserParams) (ToolResponse, error) {
|
||||||
|
var fields []struct {
|
||||||
|
Selector string `json:"selector"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(p.Value), &fields); err != nil {
|
||||||
|
return TextErrorResponse("fill_form value must be a JSON array of {selector, value} objects"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var fillsJS strings.Builder
|
||||||
|
for _, f := range fields {
|
||||||
|
fillsJS.WriteString(fmt.Sprintf("\tawait page.fill(%q, %q);\n", f.Selector, f.Value))
|
||||||
|
}
|
||||||
|
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
const { chromium } = require('playwright');
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.goto(%q, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||||
|
%s
|
||||||
|
const content = await page.evaluate(() => document.body.innerText);
|
||||||
|
console.log(JSON.stringify({ url: page.url(), content: content.substring(0, 5000) }));
|
||||||
|
await browser.close();
|
||||||
|
})();
|
||||||
|
`, p.URL, fillsJS.String())
|
||||||
|
|
||||||
|
result, err := runPlaywrightScript(ctx, script)
|
||||||
|
if err != nil {
|
||||||
|
return TextErrorResponse(fmt.Sprintf("fill_form error: %v", err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return TextResponse(result), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleBrowserEvaluate(ctx context.Context, p BrowserParams) (ToolResponse, error) {
|
||||||
|
if p.Script == "" {
|
||||||
|
return TextErrorResponse("script is required for evaluate action"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
url := p.URL
|
||||||
|
if url == "" {
|
||||||
|
url = "about:blank"
|
||||||
|
}
|
||||||
|
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
const { chromium } = require('playwright');
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.goto(%q, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||||
|
const result = await page.evaluate(() => {
|
||||||
|
try { return String((%s)); } catch(e) { return String(e); }
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify({ result: result.substring(0, 8000) }));
|
||||||
|
await browser.close();
|
||||||
|
})();
|
||||||
|
`, url, p.Script)
|
||||||
|
|
||||||
|
result, err := runPlaywrightScript(ctx, script)
|
||||||
|
if err != nil {
|
||||||
|
return TextErrorResponse(fmt.Sprintf("evaluate error: %v", err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return TextResponse(result), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleBrowserReadPage(ctx context.Context, p BrowserParams) (ToolResponse, error) {
|
||||||
|
if p.URL == "" {
|
||||||
|
return TextErrorResponse("url is required for read_page action"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
const { chromium } = require('playwright');
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.goto(%q, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||||
|
const title = await page.title();
|
||||||
|
const html = await page.content();
|
||||||
|
console.log(JSON.stringify({ url: page.url(), title, content_length: html.length, content: html.substring(0, 15000) }));
|
||||||
|
await browser.close();
|
||||||
|
})();
|
||||||
|
`, p.URL)
|
||||||
|
|
||||||
|
result, err := runPlaywrightScript(ctx, script)
|
||||||
|
if err != nil {
|
||||||
|
return TextErrorResponse(fmt.Sprintf("read_page error: %v", err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return TextResponse(result), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleBrowserClose(ctx context.Context) (ToolResponse, error) {
|
||||||
|
mgr := GetBrowserManager()
|
||||||
|
mgr.mu.Lock()
|
||||||
|
defer mgr.mu.Unlock()
|
||||||
|
|
||||||
|
count := len(mgr.sessions)
|
||||||
|
mgr.sessions = make(map[string]*BrowserSession)
|
||||||
|
|
||||||
|
return TextResponse(fmt.Sprintf("Closed %d browser session(s)", count)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runPlaywrightScript(ctx context.Context, script string) (string, error) {
|
||||||
|
tmpFile, err := os.CreateTemp("", "muyue-browser-*.js")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create temp file: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tmpFile.Name())
|
||||||
|
|
||||||
|
if _, err := tmpFile.WriteString(script); err != nil {
|
||||||
|
tmpFile.Close()
|
||||||
|
return "", fmt.Errorf("write script: %w", err)
|
||||||
|
}
|
||||||
|
tmpFile.Close()
|
||||||
|
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
mgr := GetBrowserManager()
|
||||||
|
if mgr.playwrightPath == "npx" || mgr.playwrightPath == "" {
|
||||||
|
cmd = exec.CommandContext(ctx, "npx", "-y", "playwright", "test", "--config=/dev/null")
|
||||||
|
cmd = exec.CommandContext(ctx, "node", tmpFile.Name())
|
||||||
|
} else {
|
||||||
|
cmd = exec.CommandContext(ctx, "node", tmpFile.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if node is available
|
||||||
|
if _, err := exec.LookPath("node"); err != nil {
|
||||||
|
return "", fmt.Errorf("node is not installed. Install Node.js to use the browser tool")
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd = exec.CommandContext(ctx, "node", tmpFile.Name())
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
result := string(output)
|
||||||
|
|
||||||
|
if len(result) > 10000 {
|
||||||
|
result = result[:10000] + "\n... [truncated]"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
return "", fmt.Errorf("browser action timed out")
|
||||||
|
}
|
||||||
|
return result, fmt.Errorf("playwright error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -438,6 +438,12 @@ func DefaultRegistry() *Registry {
|
|||||||
must(NewSetProviderTool()),
|
must(NewSetProviderTool()),
|
||||||
must(NewManageSSHTool()),
|
must(NewManageSSHTool()),
|
||||||
must(NewWebFetchTool()),
|
must(NewWebFetchTool()),
|
||||||
|
must(NewDelegateTool(r)),
|
||||||
|
must(NewDelegateMultiTool(r)),
|
||||||
|
}
|
||||||
|
|
||||||
|
if bt, err := NewBrowserTool(); err == nil {
|
||||||
|
tools = append(tools, bt)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, t := range tools {
|
for _, t := range tools {
|
||||||
|
|||||||
203
internal/agent/delegate.go
Normal file
203
internal/agent/delegate.go
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DelegateTaskParams struct {
|
||||||
|
Task string `json:"task" description:"Description of the sub-task to delegate"`
|
||||||
|
Context string `json:"context,omitempty" description:"Additional context for the sub-task"`
|
||||||
|
Timeout int `json:"timeout,omitempty" description:"Timeout per sub-task in seconds (default 120, max 300)"`
|
||||||
|
MaxParallel int `json:"max_parallel,omitempty" description:"Maximum parallel sub-tasks (default 3, max 5)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DelegateMultiParams struct {
|
||||||
|
Tasks []DelegateTaskParams `json:"tasks" description:"List of sub-tasks to execute in parallel"`
|
||||||
|
MaxParallel int `json:"max_parallel,omitempty" description:"Maximum parallel sub-tasks (default 3, max 5)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubTaskResult struct {
|
||||||
|
Task string `json:"task"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Result string `json:"result"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DelegateResponse struct {
|
||||||
|
TotalTasks int `json:"total_tasks"`
|
||||||
|
Successful int `json:"successful"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
Results []SubTaskResult `json:"results"`
|
||||||
|
Duration string `json:"duration"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDelegateTool(registry *Registry) (*ToolDefinition, error) {
|
||||||
|
return NewTool("delegate_task",
|
||||||
|
"Delegate one or more tasks for parallel execution. Each sub-task runs in isolation with its own context. Returns aggregated results from all sub-tasks. Use for independent tasks that can run concurrently.",
|
||||||
|
func(ctx context.Context, p DelegateTaskParams) (ToolResponse, error) {
|
||||||
|
if p.Task == "" {
|
||||||
|
return TextErrorResponse("task is required"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := time.Duration(p.Timeout) * time.Second
|
||||||
|
if timeout == 0 {
|
||||||
|
timeout = 120 * time.Second
|
||||||
|
}
|
||||||
|
if timeout > 300*time.Second {
|
||||||
|
timeout = 300 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
result := executeSubTask(ctx, p.Task, p.Context, timeout, registry)
|
||||||
|
resp := DelegateResponse{
|
||||||
|
TotalTasks: 1,
|
||||||
|
Successful: 0,
|
||||||
|
Results: []SubTaskResult{result},
|
||||||
|
Duration: "N/A",
|
||||||
|
}
|
||||||
|
if result.Success {
|
||||||
|
resp.Successful = 1
|
||||||
|
} else {
|
||||||
|
resp.Failed = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.MarshalIndent(resp, "", " ")
|
||||||
|
return TextResponse(string(data)), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDelegateMultiTool(registry *Registry) (*ToolDefinition, error) {
|
||||||
|
return NewTool("delegate_multi",
|
||||||
|
"Execute multiple independent tasks in parallel using goroutines. Each task runs in its own isolated context. Returns aggregated results. Use for batch operations, parallel analysis, or concurrent file processing.",
|
||||||
|
func(ctx context.Context, p DelegateMultiParams) (ToolResponse, error) {
|
||||||
|
if len(p.Tasks) == 0 {
|
||||||
|
return TextErrorResponse("tasks list is required"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
maxParallel := p.MaxParallel
|
||||||
|
if maxParallel <= 0 {
|
||||||
|
maxParallel = 3
|
||||||
|
}
|
||||||
|
if maxParallel > 5 {
|
||||||
|
maxParallel = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(p.Tasks) > 10 {
|
||||||
|
return TextErrorResponse("maximum 10 tasks per delegation"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
results := executeParallelTasks(ctx, p.Tasks, maxParallel, registry)
|
||||||
|
duration := time.Since(start)
|
||||||
|
|
||||||
|
resp := DelegateResponse{
|
||||||
|
TotalTasks: len(results),
|
||||||
|
Results: results,
|
||||||
|
Duration: duration.Round(time.Millisecond).String(),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range results {
|
||||||
|
if r.Success {
|
||||||
|
resp.Successful++
|
||||||
|
} else {
|
||||||
|
resp.Failed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.MarshalIndent(resp, "", " ")
|
||||||
|
return TextResponse(string(data)), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeSubTask(ctx context.Context, task, contextInfo string, timeout time.Duration, registry *Registry) SubTaskResult {
|
||||||
|
taskCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
result := SubTaskResult{
|
||||||
|
Task: truncateString(task, 100),
|
||||||
|
}
|
||||||
|
|
||||||
|
if contextInfo != "" {
|
||||||
|
result.Task = fmt.Sprintf("%s (context: %s)", result.Task, truncateString(contextInfo, 50))
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
|
||||||
|
terminalTool, ok := registry.Get("terminal")
|
||||||
|
if !ok {
|
||||||
|
result.Error = "terminal tool not available"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
args, _ := json.Marshal(TerminalParams{
|
||||||
|
Command: task,
|
||||||
|
Timeout: int(timeout.Seconds()),
|
||||||
|
})
|
||||||
|
|
||||||
|
resp, err := terminalTool.Execute(taskCtx, ToolCall{
|
||||||
|
ID: fmt.Sprintf("delegate_%d", time.Now().UnixNano()),
|
||||||
|
Name: "terminal",
|
||||||
|
Arguments: args,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
result.Error = err.Error()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Result = resp.Content
|
||||||
|
result.Success = !resp.IsError
|
||||||
|
if resp.IsError {
|
||||||
|
result.Error = resp.Content
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return result
|
||||||
|
case <-taskCtx.Done():
|
||||||
|
result.Error = fmt.Sprintf("sub-task timed out after %v", timeout)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeParallelTasks(ctx context.Context, tasks []DelegateTaskParams, maxParallel int, registry *Registry) []SubTaskResult {
|
||||||
|
results := make([]SubTaskResult, len(tasks))
|
||||||
|
|
||||||
|
sem := make(chan struct{}, maxParallel)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for i, task := range tasks {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int, t DelegateTaskParams) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
sem <- struct{}{}
|
||||||
|
defer func() { <-sem }()
|
||||||
|
|
||||||
|
timeout := time.Duration(t.Timeout) * time.Second
|
||||||
|
if timeout == 0 {
|
||||||
|
timeout = 120 * time.Second
|
||||||
|
}
|
||||||
|
if timeout > 300*time.Second {
|
||||||
|
timeout = 300 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
results[idx] = executeSubTask(ctx, t.Task, t.Context, timeout, registry)
|
||||||
|
}(i, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateString(s string, maxLen int) string {
|
||||||
|
if len(s) <= maxLen {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:maxLen] + "..."
|
||||||
|
}
|
||||||
200
internal/agent/image.go
Normal file
200
internal/agent/image.go
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ImageGenerationTool struct {
|
||||||
|
apiKey string
|
||||||
|
baseURL string
|
||||||
|
model string
|
||||||
|
saveDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewImageGenerationTool(cfg *config.MuyueConfig) (*ImageGenerationTool, error) {
|
||||||
|
configDir, err := config.ConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
saveDir := filepath.Join(configDir, "images")
|
||||||
|
if err := os.MkdirAll(saveDir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("creating images dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var apiKey, baseURL, model string
|
||||||
|
for _, p := range cfg.AI.Providers {
|
||||||
|
if p.Active {
|
||||||
|
apiKey = p.APIKey
|
||||||
|
baseURL = p.BaseURL
|
||||||
|
model = p.Model
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = "https://api.openai.com/v1"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ImageGenerationTool{
|
||||||
|
apiKey: apiKey,
|
||||||
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
model: model,
|
||||||
|
saveDir: saveDir,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ImageGenerationTool) Name() string {
|
||||||
|
return "generate_image"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ImageGenerationTool) Description() string {
|
||||||
|
return "Generate an image from a text prompt using DALL-E or compatible API. Returns a local URL to the generated image."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ImageGenerationTool) Parameters() map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"prompt": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Description of the image to generate",
|
||||||
|
},
|
||||||
|
"size": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Image size: 1024x1024, 1024x1792, or 1792x1024",
|
||||||
|
"default": "1024x1024",
|
||||||
|
},
|
||||||
|
"style": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Style: vivid or natural",
|
||||||
|
"default": "vivid",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"prompt"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ImageGenerationTool) Execute(args map[string]interface{}) (string, error) {
|
||||||
|
prompt, _ := args["prompt"].(string)
|
||||||
|
if prompt == "" {
|
||||||
|
return "", fmt.Errorf("prompt is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
size, _ := args["size"].(string)
|
||||||
|
if size == "" {
|
||||||
|
size = "1024x1024"
|
||||||
|
}
|
||||||
|
style, _ := args["style"].(string)
|
||||||
|
if style == "" {
|
||||||
|
style = "vivid"
|
||||||
|
}
|
||||||
|
|
||||||
|
reqBody := map[string]interface{}{
|
||||||
|
"model": "dall-e-3",
|
||||||
|
"prompt": prompt,
|
||||||
|
"size": size,
|
||||||
|
"style": style,
|
||||||
|
"n": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
url := t.baseURL + "/images/generations"
|
||||||
|
req, err := http.NewRequest("POST", url, bytes.NewReader(bodyBytes))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if t.apiKey != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+t.apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 120 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("send request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var genResp struct {
|
||||||
|
Data []struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
B64JSON string `json:"b64_json"`
|
||||||
|
RevisedPrompt string `json:"revised_prompt"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(respBody, &genResp); err != nil {
|
||||||
|
return "", fmt.Errorf("parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(genResp.Data) == 0 {
|
||||||
|
return "", fmt.Errorf("no image returned")
|
||||||
|
}
|
||||||
|
|
||||||
|
imgData := genResp.Data[0]
|
||||||
|
filename := fmt.Sprintf("img-%d.png", time.Now().UnixNano())
|
||||||
|
localPath := filepath.Join(t.saveDir, filename)
|
||||||
|
|
||||||
|
if imgData.B64JSON != "" {
|
||||||
|
return "", fmt.Errorf("base64 response not yet supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
if imgData.URL != "" {
|
||||||
|
if err := t.downloadImage(imgData.URL, localPath); err != nil {
|
||||||
|
return "", fmt.Errorf("download image: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"url": "/api/images/" + filename,
|
||||||
|
"revised_prompt": imgData.RevisedPrompt,
|
||||||
|
"size": size,
|
||||||
|
}
|
||||||
|
resultJSON, _ := json.Marshal(result)
|
||||||
|
return string(resultJSON), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ImageGenerationTool) downloadImage(url, localPath string) error {
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("download failed: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Create(localPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(f, resp.Body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -66,23 +66,52 @@ Muyue gère :
|
|||||||
| **browser_test** | Piloter un onglet de navigateur de l'utilisateur (clic, eval, lecture console) — voir `<browser_test_strategy>` ci-dessous |
|
| **browser_test** | Piloter un onglet de navigateur de l'utilisateur (clic, eval, lecture console) — voir `<browser_test_strategy>` ci-dessous |
|
||||||
|
|
||||||
<browser_test_strategy>
|
<browser_test_strategy>
|
||||||
Quand l'utilisateur demande de **tester** une UI / une page (ses boutons, ses formulaires, son comportement), utilise `browser_test`. La page cible doit déjà être connectée via le snippet de l'onglet "Tests" — sinon, l'outil te le dira et tu demandes à l'utilisateur de coller le snippet.
|
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).
|
||||||
|
|
||||||
Boucle recommandée :
|
## Règle d'or — économise les appels d'outils
|
||||||
|
|
||||||
1. `browser_test` action `summary` — voir l'URL, le titre et les dernières erreurs console déjà présentes.
|
**N'appelle PAS `list_clickables` après chaque clic.** C'est l'erreur n°1 qui fait exploser ta boucle (150+ appels pour 5 actions humaines). La liste change rarement et chaque appel renvoie ~30-100 éléments.
|
||||||
2. `browser_test` action `list_clickables` — récupérer la liste indexée des boutons / liens / inputs cliquables.
|
|
||||||
3. Pour chaque cible : `browser_test` action `click` (avec `index` ou `selector`).
|
Stratégie efficace :
|
||||||
4. Immédiatement après chaque clic, **regarde le `console_delta` retourné** : c'est la liste des messages console émis pendant le clic. `level: "error"` = bouton cassé.
|
|
||||||
5. Vérifie aussi `current_url` retourné — un changement d'URL inattendu peut signaler un bug.
|
1. **Au début** : `summary` (URL + console + 20 lignes) → `list_clickables` (UNE FOIS, mémorise les index pertinents pour ta tâche).
|
||||||
6. Si l'élément ouvre un dialog ou modifie le DOM, refais `list_clickables` pour découvrir les nouveaux éléments.
|
2. **Pendant** : clique par `index`. Lis le `console_delta` retourné après chaque clic.
|
||||||
7. Pour les inputs : utilise `type` avant `click` sur le bouton de soumission.
|
3. **Re-list seulement si** :
|
||||||
8. À la fin, fournis un **rapport** structuré : ✓ boutons OK / ✗ boutons cassés (avec le message d'erreur exact) / ⚠ boutons disabled ou non trouvés.
|
- 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 :
|
Astuces :
|
||||||
- Préfère cliquer **par `index`** que par sélecteur — le sélecteur change avec le DOM, l'index reste stable jusqu'au prochain `list_clickables`.
|
- Clique **par index** ; le sélecteur peut changer avec le DOM, l'index reste stable jusqu'au prochain `list_clickables`.
|
||||||
- Entre deux actions sensibles, `wait` 200-500 ms si la page a des transitions / fetches asynchrones.
|
|
||||||
- N'utilise jamais `eval` pour cliquer si `click` suffit.
|
- 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>
|
</browser_test_strategy>
|
||||||
|
|
||||||
<tool_strategy>
|
<tool_strategy>
|
||||||
|
|||||||
133
internal/api/agent_sessions.go
Normal file
133
internal/api/agent_sessions.go
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AgentSession struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
PID int `json:"pid"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
StartedAt string `json:"started_at"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Output string `json:"output,omitempty"`
|
||||||
|
Cwd string `json:"cwd,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AgentSessionTracker struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
sessions map[string]*AgentSession
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAgentSessionTracker() *AgentSessionTracker {
|
||||||
|
return &AgentSessionTracker{
|
||||||
|
sessions: make(map[string]*AgentSession),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *AgentSessionTracker) Discover() []AgentSession {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
activePIDs := make(map[int]bool)
|
||||||
|
for _, s := range t.sessions {
|
||||||
|
activePIDs[s.PID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range []string{"crush", "claude"} {
|
||||||
|
pids := findProcessesByName(name)
|
||||||
|
for _, pid := range pids {
|
||||||
|
if !activePIDs[pid] {
|
||||||
|
session := &AgentSession{
|
||||||
|
ID: fmt.Sprintf("%s-%d-%d", name, pid, time.Now().UnixMilli()),
|
||||||
|
Type: name,
|
||||||
|
PID: pid,
|
||||||
|
Command: getProcessCommand(pid),
|
||||||
|
StartedAt: time.Now().Format(time.RFC3339),
|
||||||
|
Status: "running",
|
||||||
|
}
|
||||||
|
t.sessions[session.ID] = session
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []AgentSession
|
||||||
|
for _, s := range t.sessions {
|
||||||
|
if s.Status == "running" {
|
||||||
|
if !isProcessAlive(s.PID) {
|
||||||
|
s.Status = "completed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = append(result, *s)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *AgentSessionTracker) Get(id string) *AgentSession {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
|
||||||
|
s, ok := t.sessions[id]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
snapshot := *s
|
||||||
|
return &snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
func findProcessesByName(name string) []int {
|
||||||
|
data, err := os.ReadFile("/proc/" + name + "/stat")
|
||||||
|
_ = data
|
||||||
|
_ = err
|
||||||
|
|
||||||
|
var pids []int
|
||||||
|
|
||||||
|
entries, err := os.ReadDir("/proc")
|
||||||
|
if err != nil {
|
||||||
|
return pids
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var pid int
|
||||||
|
if _, err := fmt.Sscanf(entry.Name(), "%d", &pid); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if pid <= 0 || pid == os.Getpid() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdline, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdStr := string(cmdline)
|
||||||
|
if strings.Contains(cmdStr, name) {
|
||||||
|
pids = append(pids, pid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pids
|
||||||
|
}
|
||||||
|
|
||||||
|
func getProcessCommand(pid int) string {
|
||||||
|
out, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.ReplaceAll(string(out), "\x00", " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isProcessAlive(pid int) bool {
|
||||||
|
_, err := os.Stat(fmt.Sprintf("/proc/%d", pid))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
@@ -12,10 +12,12 @@ package api
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -24,8 +26,20 @@ import (
|
|||||||
"github.com/muyue/muyue/internal/agent"
|
"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 (
|
const (
|
||||||
browserTestTokenTTL = 5 * time.Minute
|
// 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
|
browserTestCommandTTL = 30 * time.Second
|
||||||
browserTestConsoleMax = 200
|
browserTestConsoleMax = 200
|
||||||
browserTestSessionsMax = 16
|
browserTestSessionsMax = 16
|
||||||
@@ -86,7 +100,11 @@ func (s *BrowserTestStore) IssueToken() string {
|
|||||||
return tok
|
return tok
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConsumeToken validates and removes a token in one step.
|
// 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 {
|
func (s *BrowserTestStore) ConsumeToken(tok string) bool {
|
||||||
s.tokensMu.Lock()
|
s.tokensMu.Lock()
|
||||||
defer s.tokensMu.Unlock()
|
defer s.tokensMu.Unlock()
|
||||||
@@ -94,8 +112,12 @@ func (s *BrowserTestStore) ConsumeToken(tok string) bool {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
delete(s.tokens, tok)
|
if time.Since(t) > browserTestTokenTTL {
|
||||||
return 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.
|
// Register inserts a new session, evicting the oldest if at capacity.
|
||||||
@@ -377,14 +399,15 @@ func (s *Server) handleBrowserTestWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// BrowserTestParams is the schema exposed to the AI for the browser_test tool.
|
// BrowserTestParams is the schema exposed to the AI for the browser_test tool.
|
||||||
type BrowserTestParams struct {
|
type BrowserTestParams struct {
|
||||||
Action string `json:"action" description:"One of: list_clickables, click, eval, console, current_url, wait, type, summary"`
|
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)"`
|
SessionID string `json:"session_id,omitempty" description:"Browser session id (optional, defaults to most recent)"`
|
||||||
Selector string `json:"selector,omitempty" description:"CSS selector for click/type actions"`
|
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)"`
|
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)"`
|
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)"`
|
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)"`
|
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)"`
|
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.
|
// RegisterBrowserTestTool wires the agent tool against a session store.
|
||||||
@@ -401,7 +424,7 @@ func RegisterBrowserTestTool(reg *agent.Registry, store *BrowserTestStore) error
|
|||||||
switch action {
|
switch action {
|
||||||
case "":
|
case "":
|
||||||
return agent.TextErrorResponse("action is required"), nil
|
return agent.TextErrorResponse("action is required"), nil
|
||||||
case "list_clickables", "click", "eval", "current_url", "type":
|
case "list_clickables", "click", "eval", "current_url", "type", "screenshot":
|
||||||
case "console", "summary", "wait":
|
case "console", "summary", "wait":
|
||||||
default:
|
default:
|
||||||
return agent.TextErrorResponse("unknown action: " + p.Action), nil
|
return agent.TextErrorResponse("unknown action: " + p.Action), nil
|
||||||
@@ -479,6 +502,23 @@ func RegisterBrowserTestTool(reg *agent.Registry, store *BrowserTestStore) error
|
|||||||
return agent.TextErrorResponse(err.Error()), 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.
|
// Console delta: messages logged during this command.
|
||||||
post := sess.SnapshotConsole()
|
post := sess.SnapshotConsole()
|
||||||
var delta []ConsoleEntry
|
var delta []ConsoleEntry
|
||||||
@@ -504,14 +544,21 @@ func RegisterBrowserTestTool(reg *agent.Registry, store *BrowserTestStore) error
|
|||||||
// Snippet generator ----------------------------------------------------------
|
// Snippet generator ----------------------------------------------------------
|
||||||
|
|
||||||
func buildBrowserTestSnippet(wsURL string) string {
|
func buildBrowserTestSnippet(wsURL string) string {
|
||||||
// Note: this is the JS injected into the user's target page. It opens the
|
// Inline JS injected into the user's target page. Responsibilities:
|
||||||
// WS, hooks console, and dispatches commands. Kept terse on purpose.
|
// - 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(){
|
return `(function(){
|
||||||
if (window.__muyueTestRunner) { console.log('[Muyue] runner already attached'); return; }
|
if (window.__muyueTestRunner) { console.log('[Muyue] runner already attached'); return; }
|
||||||
var WS_URL = ` + jsString(wsURL) + `;
|
var WS_URL = ` + jsString(wsURL) + `;
|
||||||
var ws = new WebSocket(WS_URL);
|
var ws = null, lastList = [], retry = 0;
|
||||||
var lastList = [];
|
function send(obj){ try{ if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); }catch(e){} }
|
||||||
function send(obj){ try{ ws.send(JSON.stringify(obj)); }catch(e){} }
|
|
||||||
function reply(id, data){ send({type:'reply', id:id, data:data}); }
|
function reply(id, data){ send({type:'reply', id:id, data:data}); }
|
||||||
function safeText(el){
|
function safeText(el){
|
||||||
var t = (el.innerText || el.textContent || '').trim();
|
var t = (el.innerText || el.textContent || '').trim();
|
||||||
@@ -537,6 +584,36 @@ func buildBrowserTestSnippet(wsURL string) string {
|
|||||||
try { el.scrollIntoView({block:'center'}); el.click(); return { ok:true }; }
|
try { el.scrollIntoView({block:'center'}); el.click(); return { ok:true }; }
|
||||||
catch(e){ return { ok:false, error:String(e) }; }
|
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){
|
function dispatch(msg){
|
||||||
var p = msg.params || {};
|
var p = msg.params || {};
|
||||||
switch(msg.action){
|
switch(msg.action){
|
||||||
@@ -563,6 +640,8 @@ func buildBrowserTestSnippet(wsURL string) string {
|
|||||||
el.dispatchEvent(new Event('change', {bubbles:true}));
|
el.dispatchEvent(new Event('change', {bubbles:true}));
|
||||||
return { ok:true };
|
return { ok:true };
|
||||||
}
|
}
|
||||||
|
case 'screenshot':
|
||||||
|
return screenshot(p);
|
||||||
}
|
}
|
||||||
return { ok:false, error:'unknown action' };
|
return { ok:false, error:'unknown action' };
|
||||||
}
|
}
|
||||||
@@ -594,15 +673,29 @@ func buildBrowserTestSnippet(wsURL string) string {
|
|||||||
setInterval(function(){
|
setInterval(function(){
|
||||||
if (location.href !== lastUrl){ lastUrl = location.href; send({type:'url_change', url: lastUrl}); }
|
if (location.href !== lastUrl){ lastUrl = location.href; send({type:'url_change', url: lastUrl}); }
|
||||||
}, 500);
|
}, 500);
|
||||||
ws.onopen = function(){ send({type:'hello', url: location.href, title: document.title}); };
|
function connect(){
|
||||||
ws.onmessage = function(ev){
|
ws = new WebSocket(WS_URL);
|
||||||
try { var msg = JSON.parse(ev.data); }
|
ws.onopen = function(){ retry = 0; send({type:'hello', url: location.href, title: document.title}); };
|
||||||
catch(e){ return; }
|
ws.onmessage = function(ev){
|
||||||
if (msg.type === 'registered') { console.log('[Muyue] connected — session', msg.session_id); return; }
|
try { var msg = JSON.parse(ev.data); } catch(e){ return; }
|
||||||
if (msg.action) reply(msg.id, dispatch(msg));
|
if (msg.type === 'registered') { console.log('[Muyue] connected — session', msg.session_id); return; }
|
||||||
};
|
if (msg.action) {
|
||||||
ws.onclose = function(){ console.log('[Muyue] runner disconnected'); window.__muyueTestRunner = null; };
|
var out = dispatch(msg);
|
||||||
window.__muyueTestRunner = { ws: ws, list: list };
|
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 };
|
||||||
})();`
|
})();`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -610,3 +703,70 @@ func jsString(s string) string {
|
|||||||
b, _ := json.Marshal(s)
|
b, _ := json.Marshal(s)
|
||||||
return string(b)
|
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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,9 +10,13 @@ import (
|
|||||||
"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.
|
// ToolLimiter checks if a tool call is allowed and returns a release function.
|
||||||
type ToolLimiter func(toolName string) (release func(), err error)
|
type ToolLimiter func(toolName string) (release func(), err error)
|
||||||
|
|||||||
@@ -213,6 +213,13 @@ 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)
|
||||||
|
|
||||||
|
if memBlock := s.buildMemoryContext(enrichedMessage); memBlock != "" {
|
||||||
|
orb.AppendHistory(orchestrator.Message{
|
||||||
|
Role: "system",
|
||||||
|
Content: orchestrator.TextContent(memBlock),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-force advanced reflection while a browser-test session is active:
|
// Auto-force advanced reflection while a browser-test session is active:
|
||||||
// the user is doing AI-driven UI testing, where having a second model
|
// the user is doing AI-driven UI testing, where having a second model
|
||||||
// produce a preliminary report (when one is configured) materially
|
// produce a preliminary report (when one is configured) materially
|
||||||
|
|||||||
336
internal/api/handlers_files.go
Normal file
336
internal/api/handlers_files.go
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/config"
|
||||||
|
"github.com/muyue/muyue/internal/mcpserver"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleFileContent(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
path := r.URL.Query().Get("path")
|
||||||
|
if path == "" {
|
||||||
|
writeError(w, "path parameter required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
path = strings.ReplaceAll(path, "~", home)
|
||||||
|
|
||||||
|
if !filepath.IsAbs(path) {
|
||||||
|
writeError(w, "path must be absolute", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, fmt.Sprintf("Error reading file: %v", err), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
|
lang := "text"
|
||||||
|
switch ext {
|
||||||
|
case ".go":
|
||||||
|
lang = "go"
|
||||||
|
case ".js", ".jsx":
|
||||||
|
lang = "javascript"
|
||||||
|
case ".ts", ".tsx":
|
||||||
|
lang = "typescript"
|
||||||
|
case ".py":
|
||||||
|
lang = "python"
|
||||||
|
case ".json":
|
||||||
|
lang = "json"
|
||||||
|
case ".yaml", ".yml":
|
||||||
|
lang = "yaml"
|
||||||
|
case ".md":
|
||||||
|
lang = "markdown"
|
||||||
|
case ".css":
|
||||||
|
lang = "css"
|
||||||
|
case ".html":
|
||||||
|
lang = "html"
|
||||||
|
case ".sh", ".bash":
|
||||||
|
lang = "shell"
|
||||||
|
case ".rs":
|
||||||
|
lang = "rust"
|
||||||
|
case ".java":
|
||||||
|
lang = "java"
|
||||||
|
}
|
||||||
|
|
||||||
|
stat, _ := os.Stat(path)
|
||||||
|
modTime := ""
|
||||||
|
if stat != nil {
|
||||||
|
modTime = stat.ModTime().Format("2006-01-02T15:04:05Z07:00")
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"path": path,
|
||||||
|
"content": string(data),
|
||||||
|
"lang": lang,
|
||||||
|
"size": len(data),
|
||||||
|
"modTime": modTime,
|
||||||
|
})
|
||||||
|
|
||||||
|
case http.MethodPut:
|
||||||
|
var body struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.Path == "" {
|
||||||
|
writeError(w, "path required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
path := strings.ReplaceAll(body.Path, "~", home)
|
||||||
|
|
||||||
|
if !filepath.IsAbs(path) {
|
||||||
|
writeError(w, "path must be absolute", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
writeError(w, fmt.Sprintf("Error creating directory: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(path, []byte(body.Content), 0644); err != nil {
|
||||||
|
writeError(w, fmt.Sprintf("Error writing file: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"path": path,
|
||||||
|
"size": len(body.Content),
|
||||||
|
})
|
||||||
|
|
||||||
|
default:
|
||||||
|
writeError(w, "GET/PUT only", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMuyueMCPServerStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"enabled": s.mcpServer != nil,
|
||||||
|
"running": s.mcpServer != nil,
|
||||||
|
"port": s.getMCPServerPort(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMuyueMCPServerStart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.mcpServer != nil {
|
||||||
|
writeJSON(w, map[string]string{"status": "already_running"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.startMCPServer()
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"status": "started",
|
||||||
|
"port": s.getMCPServerPort(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMuyueMCPServerStop(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.mcpServer == nil {
|
||||||
|
writeJSON(w, map[string]string{"status": "not_running"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mcpServer.Stop()
|
||||||
|
s.mcpServer = nil
|
||||||
|
writeJSON(w, map[string]string{"status": "stopped"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getMCPServerPort() int {
|
||||||
|
if s.mcpServer == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return s.mcpServer.Port()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) startMCPServer() {
|
||||||
|
port := 8096
|
||||||
|
if s.config != nil {
|
||||||
|
}
|
||||||
|
s.mcpServer = mcpserver.New(port)
|
||||||
|
s.mcpServer.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAgentSessionsList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sessions := s.agentTracker.Discover()
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"sessions": sessions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAgentSessionOutput(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := strings.TrimPrefix(r.URL.Path, "/api/agent-sessions/")
|
||||||
|
if id == "" {
|
||||||
|
writeError(w, "session id required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session := s.agentTracker.Get(id)
|
||||||
|
if session == nil {
|
||||||
|
writeError(w, "session not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, session)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleWorkspaceList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
dir, err := configWorkspacesDir()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
writeJSON(w, map[string]interface{}{"workspaces": []interface{}{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var workspaces []map[string]interface{}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(entry.Name(), ".json") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.TrimSuffix(entry.Name(), ".json")
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var ws map[string]interface{}
|
||||||
|
if err := json.Unmarshal(data, &ws); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ws["name"] = name
|
||||||
|
workspaces = append(workspaces, ws)
|
||||||
|
}
|
||||||
|
|
||||||
|
if workspaces == nil {
|
||||||
|
workspaces = []map[string]interface{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{"workspaces": workspaces})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleWorkspaceSave(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Layout string `json:"layout"`
|
||||||
|
Tabs string `json:"tabs"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.Name == "" {
|
||||||
|
writeError(w, "name required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dir, err := configWorkspacesDir()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wsData := map[string]interface{}{
|
||||||
|
"name": body.Name,
|
||||||
|
"layout": body.Layout,
|
||||||
|
"tabs": body.Tabs,
|
||||||
|
"updated": fmt.Sprintf("%d", time.Now().Unix()),
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(wsData, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, body.Name+".json"), data, 0644); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleWorkspaceGet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := strings.TrimPrefix(r.URL.Path, "/api/workspace/")
|
||||||
|
if name == "" {
|
||||||
|
writeError(w, "name required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == "DELETE" {
|
||||||
|
dir, err := configWorkspacesDir()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.Remove(filepath.Join(dir, name+".json")); err != nil {
|
||||||
|
writeError(w, "workspace not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]string{"status": "ok"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dir, err := configWorkspacesDir()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, name+".json"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "workspace not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
json.Unmarshal(data, &result)
|
||||||
|
writeJSON(w, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func configWorkspacesDir() (string, error) {
|
||||||
|
configDir, err := config.ConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
dir := filepath.Join(configDir, "workspaces")
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return "", fmt.Errorf("create workspaces dir: %w", err)
|
||||||
|
}
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
52
internal/api/handlers_image.go
Normal file
52
internal/api/handlers_image.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleImageGenerate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
Size string `json:"size"`
|
||||||
|
Style string `json:"style"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonError(w, "invalid request: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Prompt == "" {
|
||||||
|
jsonError(w, "prompt is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
imgTool, err := agent.NewImageGenerationTool(s.config)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, "image tool init: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
args := map[string]interface{}{
|
||||||
|
"prompt": req.Prompt,
|
||||||
|
"size": req.Size,
|
||||||
|
"style": req.Style,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := imgTool.Execute(args)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, fmt.Sprintf("generation failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(result))
|
||||||
|
}
|
||||||
@@ -756,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)
|
||||||
}
|
}
|
||||||
|
|||||||
256
internal/api/handlers_memory.go
Normal file
256
internal/api/handlers_memory.go
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/memory"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) ensureMemoryStore() (*memory.Store, error) {
|
||||||
|
if s.memoryStore == nil {
|
||||||
|
store, err := memory.NewStore()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.memoryStore = store
|
||||||
|
}
|
||||||
|
return s.memoryStore, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMemoryList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" {
|
||||||
|
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := s.ensureMemoryStore()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "memory store: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var memType memory.MemoryType
|
||||||
|
if t := r.URL.Query().Get("type"); t != "" {
|
||||||
|
memType = memory.MemoryType(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||||
|
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||||
|
|
||||||
|
memories, err := store.List(memType, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
count, _ := store.Count()
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"memories": memories,
|
||||||
|
"count": len(memories),
|
||||||
|
"total": count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMemoryCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Tags string `json:"tags,omitempty"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
Confidence float64 `json:"confidence,omitempty"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.Key == "" || body.Content == "" {
|
||||||
|
writeError(w, "key and content are required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := s.ensureMemoryStore()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "memory store: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
memType := memory.MemoryType(body.Type)
|
||||||
|
if memType == "" {
|
||||||
|
memType = memory.TypeFact
|
||||||
|
}
|
||||||
|
|
||||||
|
m := &memory.Memory{
|
||||||
|
Type: memType,
|
||||||
|
Key: body.Key,
|
||||||
|
Content: body.Content,
|
||||||
|
Tags: body.Tags,
|
||||||
|
Source: body.Source,
|
||||||
|
Confidence: body.Confidence,
|
||||||
|
}
|
||||||
|
if m.Confidence == 0 {
|
||||||
|
m.Confidence = 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.Store(m); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"created": true,
|
||||||
|
"memory": m,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMemoryDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "DELETE" {
|
||||||
|
writeError(w, "DELETE only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := strings.TrimPrefix(r.URL.Path, "/api/memory/")
|
||||||
|
if id == "" {
|
||||||
|
writeError(w, "memory id required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := s.ensureMemoryStore()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "memory store: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.Delete(id); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"deleted": true,
|
||||||
|
"id": id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMemoryOperation(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/api/memory/")
|
||||||
|
|
||||||
|
if path == "" {
|
||||||
|
s.handleMemoryList(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case "DELETE":
|
||||||
|
s.handleMemoryDelete(w, r)
|
||||||
|
case "GET":
|
||||||
|
store, err := s.ensureMemoryStore()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "memory store: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m, err := store.Get(path)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, m)
|
||||||
|
default:
|
||||||
|
writeError(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMemorySearch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" {
|
||||||
|
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
query := r.URL.Query().Get("q")
|
||||||
|
if query == "" {
|
||||||
|
writeError(w, "query parameter 'q' is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := s.ensureMemoryStore()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "memory store: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||||
|
results, err := store.Search(query, limit)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"results": results,
|
||||||
|
"count": len(results),
|
||||||
|
"query": query,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMemoryRecall(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" {
|
||||||
|
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
query := r.URL.Query().Get("q")
|
||||||
|
|
||||||
|
store, err := s.ensureMemoryStore()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "memory store: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
injector := memory.NewInjector(store)
|
||||||
|
contextBlock, err := injector.BuildContextBlock(query)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"context": contextBlock,
|
||||||
|
"query": query,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMemoryContext(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" {
|
||||||
|
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := s.ensureMemoryStore()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "memory store: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
preferences, _ := store.RecallPreferences()
|
||||||
|
facts, _ := store.RecallFacts()
|
||||||
|
|
||||||
|
recentCutoff := time.Now().Add(-24 * time.Hour)
|
||||||
|
recent, _ := store.RecallRecent(recentCutoff, 10)
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"preferences": preferences,
|
||||||
|
"facts": facts,
|
||||||
|
"recent": recent,
|
||||||
|
})
|
||||||
|
}
|
||||||
373
internal/api/handlers_plugins_lessons.go
Normal file
373
internal/api/handlers_plugins_lessons.go
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/lessons"
|
||||||
|
"github.com/muyue/muyue/internal/mcp"
|
||||||
|
"github.com/muyue/muyue/internal/plugins"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handlePlugins(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.pluginManager == nil {
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"plugins": []interface{}{},
|
||||||
|
"count": 0,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"plugins": s.pluginManager.List(),
|
||||||
|
"count": len(s.pluginManager.List()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePluginEnable(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimPrefix(r.URL.Path, "/api/plugins/")
|
||||||
|
name = strings.TrimSuffix(name, "/enable")
|
||||||
|
|
||||||
|
if s.pluginManager == nil {
|
||||||
|
writeError(w, "plugin system not initialized", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.pluginManager.Enable(context.Background(), name, s.agentRegistry); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.refreshToolsJSON()
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"status": "enabled",
|
||||||
|
"plugin": name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePluginDisable(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimPrefix(r.URL.Path, "/api/plugins/")
|
||||||
|
name = strings.TrimSuffix(name, "/disable")
|
||||||
|
|
||||||
|
if s.pluginManager == nil {
|
||||||
|
writeError(w, "plugin system not initialized", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.pluginManager.Disable(name)
|
||||||
|
s.refreshToolsJSON()
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"status": "disabled",
|
||||||
|
"plugin": name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLessons(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case "GET":
|
||||||
|
idx := lessons.GetIndex()
|
||||||
|
all := idx.All()
|
||||||
|
|
||||||
|
type lessonInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Keywords []string `json:"keywords"`
|
||||||
|
Tools []string `json:"tools"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]lessonInfo, 0, len(all))
|
||||||
|
for _, l := range all {
|
||||||
|
result = append(result, lessonInfo{
|
||||||
|
Name: l.Name,
|
||||||
|
Title: l.Title,
|
||||||
|
Description: l.Description,
|
||||||
|
Category: l.Category,
|
||||||
|
Mode: string(l.Mode),
|
||||||
|
Keywords: l.Triggers.Keywords,
|
||||||
|
Tools: l.Triggers.Tools,
|
||||||
|
Enabled: l.Enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"lessons": result,
|
||||||
|
"count": len(result),
|
||||||
|
})
|
||||||
|
|
||||||
|
case "POST":
|
||||||
|
var body struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Keywords []string `json:"keywords"`
|
||||||
|
Tools []string `json:"tools"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lesson := &lessons.Lesson{
|
||||||
|
Name: body.Name,
|
||||||
|
Title: body.Title,
|
||||||
|
Description: body.Description,
|
||||||
|
Category: body.Category,
|
||||||
|
Triggers: lessons.Triggers{
|
||||||
|
Keywords: body.Keywords,
|
||||||
|
Tools: body.Tools,
|
||||||
|
},
|
||||||
|
Content: body.Content,
|
||||||
|
Mode: lessons.ModeBoth,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
home, _ := userHomeDir()
|
||||||
|
if home != "" {
|
||||||
|
dir := home + "/.muyue/lessons"
|
||||||
|
path := dir + "/" + body.Name + ".md"
|
||||||
|
if err := lessons.WriteLesson(path, lesson); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lessons.GetIndex().Reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"created": true,
|
||||||
|
"lesson": body.Name,
|
||||||
|
})
|
||||||
|
|
||||||
|
default:
|
||||||
|
writeError(w, "GET or POST only", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLessonsMatch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" {
|
||||||
|
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.URL.Query().Get("context")
|
||||||
|
toolsUsed := r.URL.Query().Get("tools")
|
||||||
|
|
||||||
|
matchCtx := lessons.MatchContext{
|
||||||
|
Message: ctx,
|
||||||
|
}
|
||||||
|
if toolsUsed != "" {
|
||||||
|
matchCtx.ToolsUsed = strings.Split(toolsUsed, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
idx := lessons.GetIndex()
|
||||||
|
results := lessons.Match(idx.All(), matchCtx)
|
||||||
|
|
||||||
|
type matchInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
matches := make([]matchInfo, 0, len(results))
|
||||||
|
for _, r := range results {
|
||||||
|
matches = append(matches, matchInfo{
|
||||||
|
Name: r.Lesson.Name,
|
||||||
|
Title: r.Lesson.Title,
|
||||||
|
Category: r.Lesson.Category,
|
||||||
|
Score: r.Score,
|
||||||
|
Content: r.Lesson.Content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"matches": matches,
|
||||||
|
"count": len(matches),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMCPDiscover(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := mcp.DiscoverSystemServers()
|
||||||
|
writeJSON(w, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMCPServerStart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimPrefix(r.URL.Path, "/api/mcp/")
|
||||||
|
name = strings.TrimSuffix(name, "/start")
|
||||||
|
|
||||||
|
status := mcp.CheckServerStatus(name)
|
||||||
|
if !status.Installed {
|
||||||
|
writeError(w, "server not installed: "+name, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"status": "started",
|
||||||
|
"server": name,
|
||||||
|
"running": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMCPServerStop(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimPrefix(r.URL.Path, "/api/mcp/")
|
||||||
|
name = strings.TrimSuffix(name, "/stop")
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"status": "stopped",
|
||||||
|
"server": name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMCPServerTools(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" {
|
||||||
|
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimPrefix(r.URL.Path, "/api/mcp/")
|
||||||
|
name = strings.TrimSuffix(name, "/tools")
|
||||||
|
|
||||||
|
caps, err := mcp.DiscoverServerTools(name)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"server": name,
|
||||||
|
"tools": caps.Tools,
|
||||||
|
"count": len(caps.Tools),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBrowserNavigate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"status": "navigating",
|
||||||
|
"url": body.URL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBrowserScreenshot(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"status": "screenshot_taken",
|
||||||
|
"url": body.URL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBrowserAction(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
Selector string `json:"selector,omitempty"`
|
||||||
|
Value string `json:"value,omitempty"`
|
||||||
|
Script string `json:"script,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"status": "executed",
|
||||||
|
"action": body.Action,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePluginAction(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := r.URL.Path
|
||||||
|
if strings.HasSuffix(path, "/enable") {
|
||||||
|
s.handlePluginEnable(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(path, "/disable") {
|
||||||
|
s.handlePluginDisable(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(path, "/discover") {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
paths := plugins.DefaultPluginPaths()
|
||||||
|
discovered := plugins.DiscoverPlugins(paths)
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"discovered": discovered,
|
||||||
|
"count": len(discovered),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, "unknown plugin action", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) refreshToolsJSON() {
|
||||||
|
tools := s.agentRegistry.OpenAITools()
|
||||||
|
toolsJSON, _ := json.Marshal(tools)
|
||||||
|
s.agentToolsJSON = json.RawMessage(toolsJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
func userHomeDir() (string, error) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
268
internal/api/handlers_rag.go
Normal file
268
internal/api/handlers_rag.go
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/muyue/muyue/internal/config"
|
||||||
|
"github.com/muyue/muyue/internal/rag"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleRAGIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.ensureRAGStore()
|
||||||
|
|
||||||
|
if r.Header.Get("Content-Type") == "application/json" {
|
||||||
|
var req struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonError(w, "invalid request: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Text == "" {
|
||||||
|
jsonError(w, "text is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Name == "" {
|
||||||
|
req.Name = "document-" + time.Now().Format("20060102-150405")
|
||||||
|
}
|
||||||
|
if req.Type == "" {
|
||||||
|
req.Type = "text"
|
||||||
|
}
|
||||||
|
|
||||||
|
s.indexText(w, req.Text, req.Name, req.Type)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||||
|
jsonError(w, "invalid multipart: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, header, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, "file is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, "reading file: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := header.Filename
|
||||||
|
ext := strings.ToLower(filepath.Ext(name))
|
||||||
|
docType := "text"
|
||||||
|
switch ext {
|
||||||
|
case ".md", ".markdown":
|
||||||
|
docType = "markdown"
|
||||||
|
case ".go", ".js", ".ts", ".py", ".java", ".rs", ".jsx", ".tsx":
|
||||||
|
docType = "code"
|
||||||
|
}
|
||||||
|
|
||||||
|
s.indexText(w, string(data), name, docType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) indexText(w http.ResponseWriter, text, name, docType string) {
|
||||||
|
var chunks []rag.Chunk
|
||||||
|
switch docType {
|
||||||
|
case "markdown":
|
||||||
|
chunks = rag.ChunkMarkdown(text, 500)
|
||||||
|
case "code":
|
||||||
|
lang := strings.TrimPrefix(filepath.Ext(name), ".")
|
||||||
|
chunks = rag.ChunkCode(text, lang, 300)
|
||||||
|
default:
|
||||||
|
chunks = rag.ChunkText(text, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(chunks) == 0 {
|
||||||
|
jsonError(w, "no content to index")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
docID := uuid.New().String()[:8]
|
||||||
|
doc := rag.Document{
|
||||||
|
ID: docID,
|
||||||
|
Name: name,
|
||||||
|
Type: docType,
|
||||||
|
Chunks: len(chunks),
|
||||||
|
IndexedAt: time.Now(),
|
||||||
|
Size: int64(len(text)),
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunkRecords []rag.ChunkRecord
|
||||||
|
var texts []string
|
||||||
|
for _, c := range chunks {
|
||||||
|
texts = append(texts, c.Content)
|
||||||
|
chunkRecords = append(chunkRecords, rag.ChunkRecord{
|
||||||
|
DocumentID: docID,
|
||||||
|
Content: c.Content,
|
||||||
|
StartPos: c.StartPos,
|
||||||
|
EndPos: c.EndPos,
|
||||||
|
Metadata: c.Metadata,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
embClient := s.getEmbeddingClient()
|
||||||
|
if embClient != nil {
|
||||||
|
embeddings, err := embClient.Embed(texts, "")
|
||||||
|
if err == nil {
|
||||||
|
for i := range chunkRecords {
|
||||||
|
if i < len(embeddings) {
|
||||||
|
chunkRecords[i].Embedding = embeddings[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.ragStore.StoreDocument(doc, chunkRecords); err != nil {
|
||||||
|
jsonError(w, "storing document: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResp(w, map[string]interface{}{
|
||||||
|
"id": docID,
|
||||||
|
"name": name,
|
||||||
|
"chunks": len(chunks),
|
||||||
|
"type": docType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRAGSearch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.ensureRAGStore()
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Query string `json:"query"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonError(w, "invalid request: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Query == "" {
|
||||||
|
jsonError(w, "query is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Limit <= 0 {
|
||||||
|
req.Limit = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
embClient := s.getEmbeddingClient()
|
||||||
|
var results []rag.SearchResult
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if embClient != nil {
|
||||||
|
queryEmb, embErr := embClient.EmbedSingle(req.Query, "")
|
||||||
|
if embErr == nil {
|
||||||
|
results, err = s.ragStore.Search(queryEmb, req.Limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil || len(results) == 0 {
|
||||||
|
results, err = s.ragStore.SearchKeyword(req.Query, req.Limit)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, "search error: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResp(w, map[string]interface{}{
|
||||||
|
"results": results,
|
||||||
|
"query": req.Query,
|
||||||
|
"count": len(results),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRAGStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.ensureRAGStore()
|
||||||
|
status, err := s.ragStore.Status()
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, "status error: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonResp(w, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRAGDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodDelete {
|
||||||
|
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.ensureRAGStore()
|
||||||
|
|
||||||
|
id := strings.TrimPrefix(r.URL.Path, "/api/rag/index/")
|
||||||
|
if id == "" {
|
||||||
|
jsonError(w, "document id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.ragStore.DeleteDocument(id); err != nil {
|
||||||
|
jsonError(w, "delete error: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonResp(w, map[string]interface{}{"deleted": id})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) ensureRAGStore() {
|
||||||
|
if s.ragStore != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
configDir, err := config.ConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
store, err := rag.NewStore(configDir)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "RAG store init error: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.ragStore = store
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getEmbeddingClient() *rag.EmbeddingClient {
|
||||||
|
for _, p := range s.config.AI.Providers {
|
||||||
|
if p.Active && p.APIKey != "" {
|
||||||
|
baseURL := p.BaseURL
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = "https://api.openai.com/v1"
|
||||||
|
}
|
||||||
|
return rag.NewEmbeddingClient(p.APIKey, baseURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRAGDocuments(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.ensureRAGStore()
|
||||||
|
docs, err := s.ragStore.ListDocuments()
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, "list error: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if docs == nil {
|
||||||
|
docs = []rag.Document{}
|
||||||
|
}
|
||||||
|
jsonResp(w, map[string]interface{}{"documents": docs})
|
||||||
|
}
|
||||||
210
internal/api/handlers_skills_advanced.go
Normal file
210
internal/api/handlers_skills_advanced.go
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/skills"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleSkillAutoCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Snippets []struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"snippets"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var snippets []skills.ConversationSnippet
|
||||||
|
for _, s := range body.Snippets {
|
||||||
|
snippets = append(snippets, skills.ConversationSnippet{
|
||||||
|
Role: s.Role,
|
||||||
|
Content: s.Content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
proposals := skills.AnalyzeConversation(snippets)
|
||||||
|
|
||||||
|
var results []map[string]interface{}
|
||||||
|
for i := range proposals {
|
||||||
|
p := &proposals[i]
|
||||||
|
if err := skills.SaveProposal(p); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results = append(results, map[string]interface{}{
|
||||||
|
"name": p.Name,
|
||||||
|
"description": p.Description,
|
||||||
|
"confidence": p.Confidence,
|
||||||
|
"category": p.Category,
|
||||||
|
"tags": p.SuggestedTags,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"proposals": results,
|
||||||
|
"count": len(results),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSkillDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/api/skills/detail/")
|
||||||
|
|
||||||
|
if strings.HasSuffix(path, "/improve") {
|
||||||
|
name := strings.TrimSuffix(path, "/improve")
|
||||||
|
s.handleSkillImprove(w, r, name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(path, "/history") {
|
||||||
|
name := strings.TrimSuffix(path, "/history")
|
||||||
|
s.handleSkillHistoryGet(w, r, name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeError(w, "unknown skill action", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSkillImprove(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
skill, err := skills.Get(name)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Context string `json:"context,omitempty"`
|
||||||
|
Apply bool `json:"apply,omitempty"`
|
||||||
|
}
|
||||||
|
if r.Body != nil {
|
||||||
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
}
|
||||||
|
|
||||||
|
improver, err := skills.NewSkillImprover()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestions, err := improver.Analyze(skill, body.Context)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.Apply && len(suggestions) > 0 {
|
||||||
|
if err := improver.ApplyImprovement(name, suggestions[0]); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updated, _ := skills.Get(name)
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"applied": true,
|
||||||
|
"suggestion": suggestions[0],
|
||||||
|
"updated": updated,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"skill": skill.Name,
|
||||||
|
"suggestions": suggestions,
|
||||||
|
"count": len(suggestions),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSkillHistoryGet(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
if r.Method != "GET" {
|
||||||
|
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
improver, err := skills.NewSkillImprover()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := improver.GetHistory(name)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"skill": name,
|
||||||
|
"history": history,
|
||||||
|
"count": len(history),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSkillProposals(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case "GET":
|
||||||
|
proposals, err := skills.LoadProposals()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"proposals": proposals,
|
||||||
|
"count": len(proposals),
|
||||||
|
})
|
||||||
|
|
||||||
|
case "POST":
|
||||||
|
var body struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
proposals, err := skills.LoadProposals()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var target *skills.AutoCreateProposal
|
||||||
|
for i := range proposals {
|
||||||
|
if proposals[i].Name == body.Name {
|
||||||
|
target = &proposals[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if target == nil {
|
||||||
|
writeError(w, "proposal not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
skill, err := skills.CreateFromProposal(target)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
skills.DeleteProposal(body.Name)
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"created": true,
|
||||||
|
"skill": skill,
|
||||||
|
})
|
||||||
|
|
||||||
|
default:
|
||||||
|
writeError(w, "GET or POST only", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
106
internal/api/metrics_unix.go
Normal file
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
129
internal/api/metrics_windows.go
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
// collectSystemMetrics reads CPU% and memory from kernel32 directly.
|
||||||
|
// Network throughput on Windows is left at zero for now — the iphlpapi
|
||||||
|
// MIB_IF_ROW2 layout is large and version-sensitive; reliable net stats
|
||||||
|
// would warrant a separate, well-tested implementation. CPU + RAM are
|
||||||
|
// enough for the dashboard's main signal.
|
||||||
|
func collectSystemMetrics() sysMetrics {
|
||||||
|
m := sysMetrics{}
|
||||||
|
|
||||||
|
if cpu, ok := readWindowsCPUPercent(); ok {
|
||||||
|
m.CPUPercent = cpu
|
||||||
|
}
|
||||||
|
if memTotalMB, memUsedMB, memPct, ok := readWindowsMemory(); ok {
|
||||||
|
m.MemTotalMB = memTotalMB
|
||||||
|
m.MemUsedMB = memUsedMB
|
||||||
|
m.MemPercent = memPct
|
||||||
|
}
|
||||||
|
// Net: zero (TODO).
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CPU ---------------------------------------------------------------
|
||||||
|
|
||||||
|
var (
|
||||||
|
cpuOnce sync.Once
|
||||||
|
getSystemTimes *syscall.LazyProc
|
||||||
|
lastWinCPUIdle uint64
|
||||||
|
lastWinCPUTotal uint64
|
||||||
|
lastWinCPUSet bool
|
||||||
|
winCPUMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
func loadCPUFns() {
|
||||||
|
cpuOnce.Do(func() {
|
||||||
|
k := syscall.NewLazyDLL("kernel32.dll")
|
||||||
|
getSystemTimes = k.NewProc("GetSystemTimes")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func filetimeToUint64(low, high uint32) uint64 {
|
||||||
|
return uint64(high)<<32 | uint64(low)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readWindowsCPUPercent samples GetSystemTimes twice and computes the busy
|
||||||
|
// ratio as 1 - dIdle / (dKernel + dUser). The first call returns 0% and
|
||||||
|
// stores the baseline; subsequent calls return the delta-based percentage.
|
||||||
|
func readWindowsCPUPercent() (float64, bool) {
|
||||||
|
loadCPUFns()
|
||||||
|
if getSystemTimes == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
var idle, kernel, user windows.Filetime
|
||||||
|
r1, _, _ := getSystemTimes.Call(
|
||||||
|
uintptr(unsafe.Pointer(&idle)),
|
||||||
|
uintptr(unsafe.Pointer(&kernel)),
|
||||||
|
uintptr(unsafe.Pointer(&user)),
|
||||||
|
)
|
||||||
|
if r1 == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
idleT := filetimeToUint64(idle.LowDateTime, idle.HighDateTime)
|
||||||
|
totalT := filetimeToUint64(kernel.LowDateTime, kernel.HighDateTime) +
|
||||||
|
filetimeToUint64(user.LowDateTime, user.HighDateTime)
|
||||||
|
winCPUMu.Lock()
|
||||||
|
defer winCPUMu.Unlock()
|
||||||
|
if !lastWinCPUSet {
|
||||||
|
lastWinCPUIdle = idleT
|
||||||
|
lastWinCPUTotal = totalT
|
||||||
|
lastWinCPUSet = true
|
||||||
|
return 0, true
|
||||||
|
}
|
||||||
|
dIdle := idleT - lastWinCPUIdle
|
||||||
|
dTotal := totalT - lastWinCPUTotal
|
||||||
|
lastWinCPUIdle = idleT
|
||||||
|
lastWinCPUTotal = totalT
|
||||||
|
if dTotal == 0 {
|
||||||
|
return 0, true
|
||||||
|
}
|
||||||
|
pct := (1 - float64(dIdle)/float64(dTotal)) * 100
|
||||||
|
if pct < 0 {
|
||||||
|
pct = 0
|
||||||
|
} else if pct > 100 {
|
||||||
|
pct = 100
|
||||||
|
}
|
||||||
|
return pct, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Memory ------------------------------------------------------------
|
||||||
|
|
||||||
|
type memoryStatusEx struct {
|
||||||
|
Length uint32
|
||||||
|
MemoryLoad uint32
|
||||||
|
TotalPhys uint64
|
||||||
|
AvailPhys uint64
|
||||||
|
TotalPageFile uint64
|
||||||
|
AvailPageFile uint64
|
||||||
|
TotalVirtual uint64
|
||||||
|
AvailVirtual uint64
|
||||||
|
AvailExtendedVirtual uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
var globalMemoryStatusEx = syscall.NewLazyDLL("kernel32.dll").NewProc("GlobalMemoryStatusEx")
|
||||||
|
|
||||||
|
func readWindowsMemory() (totalMB, usedMB, percent float64, ok bool) {
|
||||||
|
var ms memoryStatusEx
|
||||||
|
ms.Length = uint32(unsafe.Sizeof(ms))
|
||||||
|
r1, _, _ := globalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&ms)))
|
||||||
|
if r1 == 0 {
|
||||||
|
return 0, 0, 0, false
|
||||||
|
}
|
||||||
|
const mb = 1024 * 1024
|
||||||
|
totalMB = float64(ms.TotalPhys) / mb
|
||||||
|
usedMB = float64(ms.TotalPhys-ms.AvailPhys) / mb
|
||||||
|
if ms.TotalPhys > 0 {
|
||||||
|
percent = float64(ms.TotalPhys-ms.AvailPhys) * 100 / float64(ms.TotalPhys)
|
||||||
|
}
|
||||||
|
return totalMB, usedMB, percent, true
|
||||||
|
}
|
||||||
283
internal/api/pipeline.go
Normal file
283
internal/api/pipeline.go
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Filter interface {
|
||||||
|
Name() string
|
||||||
|
Process(ctx context.Context, req *FilterRequest) (*FilterResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type FilterRequest struct {
|
||||||
|
UserMessage string `json:"user_message"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Metadata map[string]string `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FilterResponse struct {
|
||||||
|
Allowed bool `json:"allowed"`
|
||||||
|
Modified string `json:"modified,omitempty"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
TokenCount int `json:"token_count,omitempty"`
|
||||||
|
Metadata map[string]string `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Pipeline struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
filters map[string]Filter
|
||||||
|
enabled map[string]bool
|
||||||
|
stats map[string]*FilterStats
|
||||||
|
}
|
||||||
|
|
||||||
|
type FilterStats struct {
|
||||||
|
Invocations int64 `json:"invocations"`
|
||||||
|
Blocked int64 `json:"blocked"`
|
||||||
|
LastUsed time.Time `json:"last_used"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPipeline() *Pipeline {
|
||||||
|
p := &Pipeline{
|
||||||
|
filters: make(map[string]Filter),
|
||||||
|
enabled: make(map[string]bool),
|
||||||
|
stats: make(map[string]*FilterStats),
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Register(&RateLimitFilter{})
|
||||||
|
p.Register(&TokenCountFilter{})
|
||||||
|
p.Register(&LoggingFilter{})
|
||||||
|
p.Register(&ToxicityFilter{})
|
||||||
|
|
||||||
|
for name := range p.filters {
|
||||||
|
p.enabled[name] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pipeline) Register(f Filter) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
p.filters[f.Name()] = f
|
||||||
|
p.stats[f.Name()] = &FilterStats{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pipeline) Run(ctx context.Context, req *FilterRequest) (string, error) {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
|
||||||
|
for name, filter := range p.filters {
|
||||||
|
if !p.enabled[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := filter.Process(ctx, req)
|
||||||
|
if p.stats[name] != nil {
|
||||||
|
p.stats[name].Invocations++
|
||||||
|
p.stats[name].LastUsed = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !resp.Allowed {
|
||||||
|
if p.stats[name] != nil {
|
||||||
|
p.stats[name].Blocked++
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("blocked by filter %s: %s", name, resp.Reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Modified != "" {
|
||||||
|
req.UserMessage = resp.Modified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return req.UserMessage, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pipeline) Toggle(name string, enabled bool) error {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
if _, ok := p.filters[name]; !ok {
|
||||||
|
return fmt.Errorf("filter not found: %s", name)
|
||||||
|
}
|
||||||
|
p.enabled[name] = enabled
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pipeline) IsEnabled(name string) bool {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
return p.enabled[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pipeline) ListFilters() []map[string]interface{} {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
|
||||||
|
var result []map[string]interface{}
|
||||||
|
for name, filter := range p.filters {
|
||||||
|
entry := map[string]interface{}{
|
||||||
|
"name": name,
|
||||||
|
"enabled": p.enabled[name],
|
||||||
|
}
|
||||||
|
if stats, ok := p.stats[name]; ok {
|
||||||
|
entry["invocations"] = stats.Invocations
|
||||||
|
entry["blocked"] = stats.Blocked
|
||||||
|
entry["last_used"] = stats.LastUsed
|
||||||
|
}
|
||||||
|
_ = filter
|
||||||
|
result = append(result, entry)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Built-in Filters ──
|
||||||
|
|
||||||
|
type RateLimitFilter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
counters map[string][]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *RateLimitFilter) Name() string { return "rate_limit" }
|
||||||
|
|
||||||
|
func (f *RateLimitFilter) Process(ctx context.Context, req *FilterRequest) (*FilterResponse, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
|
||||||
|
if f.counters == nil {
|
||||||
|
f.counters = make(map[string][]time.Time)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := req.Provider
|
||||||
|
now := time.Now()
|
||||||
|
cutoff := now.Add(-time.Minute)
|
||||||
|
|
||||||
|
var recent []time.Time
|
||||||
|
for _, t := range f.counters[key] {
|
||||||
|
if t.After(cutoff) {
|
||||||
|
recent = append(recent, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recent = append(recent, now)
|
||||||
|
f.counters[key] = recent
|
||||||
|
|
||||||
|
limit := 30
|
||||||
|
if len(recent) > limit {
|
||||||
|
return &FilterResponse{
|
||||||
|
Allowed: false,
|
||||||
|
Reason: fmt.Sprintf("rate limit exceeded: %d requests/minute (limit: %d)", len(recent), limit),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &FilterResponse{Allowed: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type TokenCountFilter struct{}
|
||||||
|
|
||||||
|
func (f *TokenCountFilter) Name() string { return "token_count" }
|
||||||
|
|
||||||
|
func (f *TokenCountFilter) Process(ctx context.Context, req *FilterRequest) (*FilterResponse, error) {
|
||||||
|
count := len(req.UserMessage) / 4
|
||||||
|
if count > 50000 {
|
||||||
|
return &FilterResponse{
|
||||||
|
Allowed: true,
|
||||||
|
TokenCount: count,
|
||||||
|
Reason: fmt.Sprintf("large message: ~%d tokens", count),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return &FilterResponse{Allowed: true, TokenCount: count}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoggingFilter struct{}
|
||||||
|
|
||||||
|
func (f *LoggingFilter) Name() string { return "logging" }
|
||||||
|
|
||||||
|
func (f *LoggingFilter) Process(ctx context.Context, req *FilterRequest) (*FilterResponse, error) {
|
||||||
|
return &FilterResponse{Allowed: true, Metadata: map[string]string{
|
||||||
|
"provider": req.Provider,
|
||||||
|
"model": req.Model,
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToxicityFilter struct{}
|
||||||
|
|
||||||
|
func (f *ToxicityFilter) Name() string { return "toxicity" }
|
||||||
|
|
||||||
|
func (f *ToxicityFilter) Process(ctx context.Context, req *FilterRequest) (*FilterResponse, error) {
|
||||||
|
return &FilterResponse{Allowed: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pipeline HTTP handlers ──
|
||||||
|
|
||||||
|
func (s *Server) handlePipelineFilters(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
filters := s.pipeline.ListFilters()
|
||||||
|
if filters == nil {
|
||||||
|
filters = []map[string]interface{}{}
|
||||||
|
}
|
||||||
|
jsonResp(w, map[string]interface{}{"filters": filters})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePipelineToggle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := ""
|
||||||
|
if parts := splitPath(r.URL.Path); len(parts) > 0 {
|
||||||
|
name = parts[len(parts)-1]
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(r.URL.Path, "/toggle") {
|
||||||
|
name = strings.TrimSuffix(name, "/toggle")
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonError(w, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.pipeline.Toggle(name, req.Enabled); err != nil {
|
||||||
|
jsonError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResp(w, map[string]interface{}{"name": name, "enabled": req.Enabled})
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitPath(p string) []string {
|
||||||
|
var parts []string
|
||||||
|
for _, s := range strings.Split(p, "/") {
|
||||||
|
if s != "" {
|
||||||
|
parts = append(parts, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonResp(w http.ResponseWriter, v interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonError(w http.ResponseWriter, msg string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -11,6 +12,11 @@ import (
|
|||||||
"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/installer"
|
||||||
|
"github.com/muyue/muyue/internal/lessons"
|
||||||
|
"github.com/muyue/muyue/internal/memory"
|
||||||
|
"github.com/muyue/muyue/internal/mcpserver"
|
||||||
|
"github.com/muyue/muyue/internal/plugins"
|
||||||
|
"github.com/muyue/muyue/internal/rag"
|
||||||
"github.com/muyue/muyue/internal/scanner"
|
"github.com/muyue/muyue/internal/scanner"
|
||||||
"github.com/muyue/muyue/internal/workflow"
|
"github.com/muyue/muyue/internal/workflow"
|
||||||
)
|
)
|
||||||
@@ -27,9 +33,16 @@ type Server struct {
|
|||||||
shellAgentRegistry *agent.Registry
|
shellAgentRegistry *agent.Registry
|
||||||
shellAgentToolsJSON json.RawMessage
|
shellAgentToolsJSON json.RawMessage
|
||||||
workflowEngine *workflow.Engine
|
workflowEngine *workflow.Engine
|
||||||
|
pluginManager *plugins.Manager
|
||||||
|
hookRegistry *plugins.HookRegistry
|
||||||
browserTestStore *BrowserTestStore
|
browserTestStore *BrowserTestStore
|
||||||
|
memoryStore *memory.Store
|
||||||
|
ragStore *rag.Store
|
||||||
|
pipeline *Pipeline
|
||||||
activeCrushAgents atomic.Int32
|
activeCrushAgents atomic.Int32
|
||||||
activeClaudeAgents atomic.Int32
|
activeClaudeAgents atomic.Int32
|
||||||
|
mcpServer *mcpserver.MCPServer
|
||||||
|
agentTracker *AgentSessionTracker
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(cfg *config.MuyueConfig) *Server {
|
func NewServer(cfg *config.MuyueConfig) *Server {
|
||||||
@@ -76,6 +89,33 @@ 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)
|
||||||
|
|
||||||
|
if cfg.Lessons.Enabled {
|
||||||
|
lessons.EnsureBuiltinLessons()
|
||||||
|
}
|
||||||
|
|
||||||
|
s.hookRegistry = plugins.NewHookRegistry()
|
||||||
|
s.pluginManager = plugins.NewManager(s.hookRegistry)
|
||||||
|
|
||||||
|
pluginPaths := cfg.Plugins.Paths
|
||||||
|
if len(pluginPaths) == 0 {
|
||||||
|
pluginPaths = plugins.DefaultPluginPaths()
|
||||||
|
}
|
||||||
|
discovered := plugins.DiscoverPlugins(pluginPaths)
|
||||||
|
for _, dp := range discovered {
|
||||||
|
if dp.Valid {
|
||||||
|
p, err := plugins.LoadExecutablePlugin(dp)
|
||||||
|
if err == nil {
|
||||||
|
s.pluginManager.Register(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.pluginManager.EnableFromConfig(context.Background(), cfg.Plugins.Enabled, s.agentRegistry)
|
||||||
|
|
||||||
|
s.pipeline = NewPipeline()
|
||||||
|
|
||||||
|
s.agentTracker = NewAgentSessionTracker()
|
||||||
|
|
||||||
s.initStarship()
|
s.initStarship()
|
||||||
s.routes()
|
s.routes()
|
||||||
return s
|
return s
|
||||||
@@ -108,6 +148,7 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("/api/starship/apply-theme", s.handleApplyStarshipTheme)
|
s.mux.HandleFunc("/api/starship/apply-theme", s.handleApplyStarshipTheme)
|
||||||
s.mux.HandleFunc("/api/providers/validate", s.handleValidateProvider)
|
s.mux.HandleFunc("/api/providers/validate", s.handleValidateProvider)
|
||||||
s.mux.HandleFunc("/api/update/run", s.handleRunUpdate)
|
s.mux.HandleFunc("/api/update/run", s.handleRunUpdate)
|
||||||
|
s.mux.HandleFunc("/api/images/generate", s.handleImageGenerate)
|
||||||
s.mux.HandleFunc("/api/images/", s.handleServeImage)
|
s.mux.HandleFunc("/api/images/", s.handleServeImage)
|
||||||
s.mux.HandleFunc("/api/chat", s.handleChat)
|
s.mux.HandleFunc("/api/chat", s.handleChat)
|
||||||
s.mux.HandleFunc("/api/chat/history", s.handleChatHistory)
|
s.mux.HandleFunc("/api/chat/history", s.handleChatHistory)
|
||||||
@@ -157,6 +198,41 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("/api/test/sessions", s.handleBrowserTestSessions)
|
s.mux.HandleFunc("/api/test/sessions", s.handleBrowserTestSessions)
|
||||||
s.mux.HandleFunc("/api/test/console/", s.handleBrowserTestConsole)
|
s.mux.HandleFunc("/api/test/console/", s.handleBrowserTestConsole)
|
||||||
s.mux.HandleFunc("/api/ws/browser-test", s.handleBrowserTestWS)
|
s.mux.HandleFunc("/api/ws/browser-test", s.handleBrowserTestWS)
|
||||||
|
|
||||||
|
s.mux.HandleFunc("/api/skills/auto-create", s.handleSkillAutoCreate)
|
||||||
|
s.mux.HandleFunc("/api/skills/proposals", s.handleSkillProposals)
|
||||||
|
s.mux.HandleFunc("/api/skills/detail/", s.handleSkillDetail)
|
||||||
|
s.mux.HandleFunc("/api/plugins", s.handlePlugins)
|
||||||
|
s.mux.HandleFunc("/api/plugins/", s.handlePluginAction)
|
||||||
|
s.mux.HandleFunc("/api/lessons", s.handleLessons)
|
||||||
|
s.mux.HandleFunc("/api/lessons/match", s.handleLessonsMatch)
|
||||||
|
s.mux.HandleFunc("/api/mcp/discover", s.handleMCPDiscover)
|
||||||
|
s.mux.HandleFunc("/api/browser/navigate", s.handleBrowserNavigate)
|
||||||
|
s.mux.HandleFunc("/api/browser/screenshot", s.handleBrowserScreenshot)
|
||||||
|
s.mux.HandleFunc("/api/browser/action", s.handleBrowserAction)
|
||||||
|
|
||||||
|
s.mux.HandleFunc("/api/rag/index", s.handleRAGIndex)
|
||||||
|
s.mux.HandleFunc("/api/rag/search", s.handleRAGSearch)
|
||||||
|
s.mux.HandleFunc("/api/rag/status", s.handleRAGStatus)
|
||||||
|
s.mux.HandleFunc("/api/rag/documents", s.handleRAGDocuments)
|
||||||
|
s.mux.HandleFunc("/api/rag/index/", s.handleRAGDelete)
|
||||||
|
s.mux.HandleFunc("/api/pipeline/filters", s.handlePipelineFilters)
|
||||||
|
s.mux.HandleFunc("/api/pipeline/filters/", s.handlePipelineToggle)
|
||||||
|
s.mux.HandleFunc("/api/memory", s.handleMemoryList)
|
||||||
|
s.mux.HandleFunc("/api/memory/create", s.handleMemoryCreate)
|
||||||
|
s.mux.HandleFunc("/api/memory/", s.handleMemoryOperation)
|
||||||
|
s.mux.HandleFunc("/api/memory/search", s.handleMemorySearch)
|
||||||
|
s.mux.HandleFunc("/api/memory/recall", s.handleMemoryRecall)
|
||||||
|
s.mux.HandleFunc("/api/memory/context", s.handleMemoryContext)
|
||||||
|
s.mux.HandleFunc("/api/files/content", s.handleFileContent)
|
||||||
|
s.mux.HandleFunc("/api/mcp-server/status", s.handleMuyueMCPServerStatus)
|
||||||
|
s.mux.HandleFunc("/api/mcp-server/start", s.handleMuyueMCPServerStart)
|
||||||
|
s.mux.HandleFunc("/api/mcp-server/stop", s.handleMuyueMCPServerStop)
|
||||||
|
s.mux.HandleFunc("/api/agent-sessions", s.handleAgentSessionsList)
|
||||||
|
s.mux.HandleFunc("/api/agent-sessions/", s.handleAgentSessionOutput)
|
||||||
|
s.mux.HandleFunc("/api/workspaces", s.handleWorkspaceList)
|
||||||
|
s.mux.HandleFunc("/api/workspace", s.handleWorkspaceSave)
|
||||||
|
s.mux.HandleFunc("/api/workspace/", s.handleWorkspaceGet)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -227,3 +303,16 @@ func (s *Server) initStarship() {
|
|||||||
}
|
}
|
||||||
ApplyStarshipTheme(s.config.Terminal.PromptTheme)
|
ApplyStarshipTheme(s.config.Terminal.PromptTheme)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) buildMemoryContext(query string) string {
|
||||||
|
store, err := s.ensureMemoryStore()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
injector := memory.NewInjector(store)
|
||||||
|
ctx, err := injector.BuildContextBlock(query)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|||||||
271
internal/api/terminal_conpty_windows.go
Normal file
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)
|
||||||
@@ -2,19 +2,22 @@ package api
|
|||||||
|
|
||||||
// Cross-platform terminal session abstraction.
|
// Cross-platform terminal session abstraction.
|
||||||
//
|
//
|
||||||
// On Linux / macOS we have a real PTY via creack/pty: full TTY semantics,
|
// On Linux / macOS the unix-tagged file (terminal_session_unix.go) wires
|
||||||
// resize support, interactive apps (vim, top…) work. On Windows the same
|
// startTermSession to creack/pty for a real PTY: full TTY semantics,
|
||||||
// package returns "operating system not supported" at pty.Start time, so we
|
// resize support, interactive apps (vim, top…) work.
|
||||||
// fall back to plain pipes (stdin / stdout merged with stderr). Pipes don't
|
//
|
||||||
// give a real TTY — interactive TUIs misbehave — but `wsl`, `pwsh`, `cmd`,
|
// On Windows the windows-tagged file (terminal_session_windows.go) tries
|
||||||
// and most CLI tools emit usable line-buffered output, which is what the
|
// the kernel32 ConPTY API first, with a pipe-based fallback for older
|
||||||
// user actually clicks for.
|
// 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 (
|
import (
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"runtime"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/creack/pty/v2"
|
"github.com/creack/pty/v2"
|
||||||
@@ -30,22 +33,7 @@ type termSession interface {
|
|||||||
Pid() int
|
Pid() int
|
||||||
}
|
}
|
||||||
|
|
||||||
// startTermSession tries a real PTY first; on Windows or any pty.Start failure
|
// ptySession wraps creack/pty's *os.File-backed PTY (unix path).
|
||||||
// it falls back to a pipe-based session.
|
|
||||||
func startTermSession(cmd *exec.Cmd) (termSession, error) {
|
|
||||||
if runtime.GOOS != "windows" {
|
|
||||||
ptmx, err := pty.Start(cmd)
|
|
||||||
if err == nil {
|
|
||||||
return &ptySession{ptmx: ptmx, cmd: cmd}, nil
|
|
||||||
}
|
|
||||||
// On unix, a pty.Start error is fatal — pipes won't help interactive
|
|
||||||
// shells without a TTY, and the unix build is the supported path.
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return startPipeSession(cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ptySession wraps creack/pty's *os.File-backed PTY.
|
|
||||||
type ptySession struct {
|
type ptySession struct {
|
||||||
ptmx *os.File
|
ptmx *os.File
|
||||||
cmd *exec.Cmd
|
cmd *exec.Cmd
|
||||||
@@ -76,8 +64,10 @@ func (s *ptySession) Pid() int {
|
|||||||
return s.cmd.Process.Pid
|
return s.cmd.Process.Pid
|
||||||
}
|
}
|
||||||
|
|
||||||
// pipeSession is the Windows fallback: stdin pipe + merged stdout/stderr pipe,
|
// pipeSession is the Windows last-resort fallback when ConPTY is not
|
||||||
// running concurrently. Resize is a no-op (no TTY to send TIOCSWINSZ to).
|
// 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 {
|
type pipeSession struct {
|
||||||
cmd *exec.Cmd
|
cmd *exec.Cmd
|
||||||
stdin io.WriteCloser
|
stdin io.WriteCloser
|
||||||
@@ -115,7 +105,7 @@ func startPipeSession(cmd *exec.Cmd) (termSession, error) {
|
|||||||
cmd: cmd,
|
cmd: cmd,
|
||||||
stdin: stdin,
|
stdin: stdin,
|
||||||
stdout: stdout,
|
stdout: stdout,
|
||||||
stderr: stderr,
|
stderr: stderr,
|
||||||
merged: make(chan []byte, 32),
|
merged: make(chan []byte, 32),
|
||||||
closeCh: make(chan struct{}),
|
closeCh: make(chan struct{}),
|
||||||
}
|
}
|
||||||
|
|||||||
19
internal/api/terminal_session_unix.go
Normal file
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
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)
|
||||||
|
}
|
||||||
@@ -51,6 +51,16 @@ type SSHConnection struct {
|
|||||||
KeyPath string `yaml:"key_path,omitempty" json:"key_path,omitempty"`
|
KeyPath string `yaml:"key_path,omitempty" json:"key_path,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PluginsConfig struct {
|
||||||
|
Enabled []string `yaml:"enabled" json:"enabled"`
|
||||||
|
Paths []string `yaml:"paths,omitempty" json:"paths,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LessonsConfig struct {
|
||||||
|
Dirs []string `yaml:"dirs,omitempty" json:"dirs,omitempty"`
|
||||||
|
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
type MuyueConfig struct {
|
type MuyueConfig struct {
|
||||||
Version string `yaml:"version" json:"version"`
|
Version string `yaml:"version" json:"version"`
|
||||||
Profile Profile `yaml:"profile" json:"profile"`
|
Profile Profile `yaml:"profile" json:"profile"`
|
||||||
@@ -71,6 +81,8 @@ type MuyueConfig struct {
|
|||||||
FontFamily string `yaml:"font_family" json:"font_family"`
|
FontFamily string `yaml:"font_family" json:"font_family"`
|
||||||
Theme string `yaml:"theme" json:"theme"`
|
Theme string `yaml:"theme" json:"theme"`
|
||||||
} `yaml:"terminal" json:"terminal"`
|
} `yaml:"terminal" json:"terminal"`
|
||||||
|
Plugins PluginsConfig `yaml:"plugins" json:"plugins"`
|
||||||
|
Lessons LessonsConfig `yaml:"lessons" json:"lessons"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TerminalTheme struct {
|
type TerminalTheme struct {
|
||||||
@@ -322,5 +334,11 @@ func Default() *MuyueConfig {
|
|||||||
cfg.Terminal.PromptTheme = "zerotwo"
|
cfg.Terminal.PromptTheme = "zerotwo"
|
||||||
cfg.Terminal.FontSize = 14
|
cfg.Terminal.FontSize = 14
|
||||||
|
|
||||||
|
cfg.Plugins.Enabled = []string{}
|
||||||
|
cfg.Plugins.Paths = []string{}
|
||||||
|
|
||||||
|
cfg.Lessons.Enabled = true
|
||||||
|
cfg.Lessons.Dirs = []string{}
|
||||||
|
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|||||||
513
internal/lessons/lesson.go
Normal file
513
internal/lessons/lesson.go
Normal file
@@ -0,0 +1,513 @@
|
|||||||
|
package lessons
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LessonMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModeInteractive LessonMode = "interactive"
|
||||||
|
ModeAutonomous LessonMode = "autonomous"
|
||||||
|
ModeBoth LessonMode = "both"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Lesson struct {
|
||||||
|
Name string `yaml:"name" json:"name"`
|
||||||
|
Title string `yaml:"title" json:"title"`
|
||||||
|
Description string `yaml:"description" json:"description"`
|
||||||
|
Category string `yaml:"category" json:"category"`
|
||||||
|
Triggers Triggers `yaml:"triggers" json:"triggers"`
|
||||||
|
Content string `yaml:"content" json:"content"`
|
||||||
|
Mode LessonMode `yaml:"mode" json:"mode"`
|
||||||
|
Priority int `yaml:"priority" json:"priority"`
|
||||||
|
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||||
|
Path string `yaml:"-" json:"path,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Triggers struct {
|
||||||
|
Keywords []string `yaml:"keywords" json:"keywords"`
|
||||||
|
Tools []string `yaml:"tools" json:"tools"`
|
||||||
|
Patterns []string `yaml:"patterns" json:"patterns"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MatchContext struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
ToolsUsed []string `json:"tools_used,omitempty"`
|
||||||
|
Mode string `json:"mode,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MatchResult struct {
|
||||||
|
Lesson *Lesson `json:"lesson"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LessonFrontmatter struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Title string `yaml:"title"`
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
Category string `yaml:"category"`
|
||||||
|
Mode LessonMode `yaml:"mode"`
|
||||||
|
Priority int `yaml:"priority"`
|
||||||
|
Enabled *bool `yaml:"enabled"`
|
||||||
|
Triggers Triggers `yaml:"triggers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LessonIndex struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
lessons []*Lesson
|
||||||
|
paths []string
|
||||||
|
cache map[string]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
globalIndex *LessonIndex
|
||||||
|
globalIndexOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetIndex() *LessonIndex {
|
||||||
|
globalIndexOnce.Do(func() {
|
||||||
|
globalIndex = &LessonIndex{
|
||||||
|
lessons: make([]*Lesson, 0),
|
||||||
|
cache: make(map[string]time.Time),
|
||||||
|
}
|
||||||
|
globalIndex.paths = DefaultLessonDirs()
|
||||||
|
globalIndex.Reload()
|
||||||
|
})
|
||||||
|
return globalIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultLessonDirs() []string {
|
||||||
|
var dirs []string
|
||||||
|
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
if home != "" {
|
||||||
|
dirs = append(dirs,
|
||||||
|
filepath.Join(home, ".muyue", "lessons"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
configDir, err := os.UserConfigDir()
|
||||||
|
if err == nil {
|
||||||
|
dirs = append(dirs, filepath.Join(configDir, "muyue", "lessons"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if extra := os.Getenv("MUYUE_LESSONS_EXTRA_DIRS"); extra != "" {
|
||||||
|
for _, d := range strings.Split(extra, ":") {
|
||||||
|
d = strings.TrimSpace(d)
|
||||||
|
if d != "" {
|
||||||
|
dirs = append(dirs, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dirs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (idx *LessonIndex) Reload() {
|
||||||
|
idx.mu.Lock()
|
||||||
|
defer idx.mu.Unlock()
|
||||||
|
|
||||||
|
var all []*Lesson
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
|
for _, dir := range idx.paths {
|
||||||
|
files, err := filepath.Glob(filepath.Join(dir, "*.md"))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range files {
|
||||||
|
realPath, _ := filepath.EvalSymlinks(f)
|
||||||
|
if realPath == "" {
|
||||||
|
realPath = f
|
||||||
|
}
|
||||||
|
if seen[realPath] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[realPath] = true
|
||||||
|
|
||||||
|
lesson, err := ParseLessonFile(f)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lesson.Path = f
|
||||||
|
if lesson.Category == "" {
|
||||||
|
lesson.Category = filepath.Base(filepath.Dir(f))
|
||||||
|
}
|
||||||
|
all = append(all, lesson)
|
||||||
|
}
|
||||||
|
|
||||||
|
subDirs, _ := filepath.Glob(filepath.Join(dir, "*"))
|
||||||
|
for _, subDir := range subDirs {
|
||||||
|
info, err := os.Stat(subDir)
|
||||||
|
if err != nil || !info.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
category := filepath.Base(subDir)
|
||||||
|
subFiles, _ := filepath.Glob(filepath.Join(subDir, "*.md"))
|
||||||
|
for _, f := range subFiles {
|
||||||
|
realPath, _ := filepath.EvalSymlinks(f)
|
||||||
|
if realPath == "" {
|
||||||
|
realPath = f
|
||||||
|
}
|
||||||
|
if seen[realPath] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[realPath] = true
|
||||||
|
|
||||||
|
lesson, err := ParseLessonFile(f)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lesson.Path = f
|
||||||
|
if lesson.Category == "" {
|
||||||
|
lesson.Category = category
|
||||||
|
}
|
||||||
|
all = append(all, lesson)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
idx.lessons = all
|
||||||
|
}
|
||||||
|
|
||||||
|
func (idx *LessonIndex) All() []*Lesson {
|
||||||
|
idx.mu.RLock()
|
||||||
|
defer idx.mu.RUnlock()
|
||||||
|
|
||||||
|
result := make([]*Lesson, 0, len(idx.lessons))
|
||||||
|
for _, l := range idx.lessons {
|
||||||
|
if l.Enabled {
|
||||||
|
result = append(result, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (idx *LessonIndex) Get(name string) *Lesson {
|
||||||
|
idx.mu.RLock()
|
||||||
|
defer idx.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, l := range idx.lessons {
|
||||||
|
if l.Name == name {
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (idx *LessonIndex) Count() int {
|
||||||
|
idx.mu.RLock()
|
||||||
|
defer idx.mu.RUnlock()
|
||||||
|
return len(idx.lessons)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseLessonFile(path string) (*Lesson, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read lesson: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := string(data)
|
||||||
|
|
||||||
|
var frontmatter LessonFrontmatter
|
||||||
|
var body string
|
||||||
|
|
||||||
|
if strings.HasPrefix(content, "---") {
|
||||||
|
end := strings.Index(content[3:], "---")
|
||||||
|
if end != -1 {
|
||||||
|
fm := content[3 : end+3]
|
||||||
|
body = strings.TrimSpace(content[end+6:])
|
||||||
|
|
||||||
|
if err := yaml.Unmarshal([]byte(fm), &frontmatter); err != nil {
|
||||||
|
body = content
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
body = content
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
body = content
|
||||||
|
}
|
||||||
|
|
||||||
|
enabled := true
|
||||||
|
if frontmatter.Enabled != nil {
|
||||||
|
enabled = *frontmatter.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
if frontmatter.Mode == "" {
|
||||||
|
frontmatter.Mode = ModeBoth
|
||||||
|
}
|
||||||
|
|
||||||
|
name := frontmatter.Name
|
||||||
|
if name == "" {
|
||||||
|
name = strings.TrimSuffix(filepath.Base(path), ".md")
|
||||||
|
name = strings.ReplaceAll(name, "-", "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Lesson{
|
||||||
|
Name: name,
|
||||||
|
Title: frontmatter.Title,
|
||||||
|
Description: frontmatter.Description,
|
||||||
|
Category: frontmatter.Category,
|
||||||
|
Triggers: frontmatter.Triggers,
|
||||||
|
Content: body,
|
||||||
|
Mode: frontmatter.Mode,
|
||||||
|
Priority: frontmatter.Priority,
|
||||||
|
Enabled: enabled,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Match(lessons []*Lesson, ctx MatchContext) []*MatchResult {
|
||||||
|
var results []*MatchResult
|
||||||
|
msgLower := strings.ToLower(ctx.Message)
|
||||||
|
|
||||||
|
for _, l := range lessons {
|
||||||
|
if !l.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
score := 0.0
|
||||||
|
|
||||||
|
for _, kw := range l.Triggers.Keywords {
|
||||||
|
if containsKeyword(msgLower, strings.ToLower(kw)) {
|
||||||
|
score += 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pattern := range l.Triggers.Patterns {
|
||||||
|
re, err := regexp.Compile("(?i)" + pattern)
|
||||||
|
if err == nil && re.MatchString(ctx.Message) {
|
||||||
|
score += 1.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ctx.ToolsUsed) > 0 && len(l.Triggers.Tools) > 0 {
|
||||||
|
for _, usedTool := range ctx.ToolsUsed {
|
||||||
|
for _, triggerTool := range l.Triggers.Tools {
|
||||||
|
if usedTool == triggerTool {
|
||||||
|
score += 2.0
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if l.Name != "" {
|
||||||
|
nameLower := strings.ToLower(l.Name)
|
||||||
|
if strings.Contains(msgLower, nameLower) {
|
||||||
|
score += 1.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if score > 0 {
|
||||||
|
results = append(results, &MatchResult{
|
||||||
|
Lesson: l,
|
||||||
|
Score: score,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sortResults(results)
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
func AutoInclude(systemPrompt string, lessons []*Lesson, ctx MatchContext, maxLessons int) string {
|
||||||
|
if maxLessons <= 0 {
|
||||||
|
maxLessons = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
results := Match(lessons, ctx)
|
||||||
|
if len(results) == 0 {
|
||||||
|
return systemPrompt
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) > maxLessons {
|
||||||
|
results = results[:maxLessons]
|
||||||
|
}
|
||||||
|
|
||||||
|
var lessonBlock strings.Builder
|
||||||
|
lessonBlock.WriteString("\n\n--- Active Lessons ---\n\n")
|
||||||
|
|
||||||
|
for _, r := range results {
|
||||||
|
lessonBlock.WriteString(fmt.Sprintf("## %s", r.Lesson.Name))
|
||||||
|
if r.Lesson.Title != "" {
|
||||||
|
lessonBlock.WriteString(fmt.Sprintf(" (%s)", r.Lesson.Title))
|
||||||
|
}
|
||||||
|
lessonBlock.WriteString("\n")
|
||||||
|
lessonBlock.WriteString(r.Lesson.Content)
|
||||||
|
lessonBlock.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return systemPrompt + lessonBlock.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func EnsureBuiltinLessons() error {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
if home == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lessonsDir := filepath.Join(home, ".muyue", "lessons")
|
||||||
|
if err := os.MkdirAll(lessonsDir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, lesson := range BuiltinLessons() {
|
||||||
|
path := filepath.Join(lessonsDir, lesson.Name+".md")
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := WriteLesson(path, lesson); err != nil {
|
||||||
|
_ = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteLesson(path string, lesson *Lesson) error {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
sb.WriteString("---\n")
|
||||||
|
data, err := yaml.Marshal(&LessonFrontmatter{
|
||||||
|
Name: lesson.Name,
|
||||||
|
Title: lesson.Title,
|
||||||
|
Description: lesson.Description,
|
||||||
|
Category: lesson.Category,
|
||||||
|
Mode: lesson.Mode,
|
||||||
|
Priority: lesson.Priority,
|
||||||
|
Enabled: &lesson.Enabled,
|
||||||
|
Triggers: lesson.Triggers,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sb.WriteString(string(data))
|
||||||
|
sb.WriteString("---\n\n")
|
||||||
|
sb.WriteString(lesson.Content)
|
||||||
|
|
||||||
|
return os.WriteFile(path, []byte(sb.String()), 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuiltinLessons() []*Lesson {
|
||||||
|
return []*Lesson{
|
||||||
|
{
|
||||||
|
Name: "code_style",
|
||||||
|
Title: "Code Style Guidelines",
|
||||||
|
Description: "Enforce consistent code style and formatting",
|
||||||
|
Category: "development",
|
||||||
|
Triggers: Triggers{
|
||||||
|
Keywords: []string{"code style", "formatting", "lint", "format", "indentation", "naming convention"},
|
||||||
|
Tools: []string{"terminal"},
|
||||||
|
},
|
||||||
|
Content: `- Follow the existing code style in each file
|
||||||
|
- Use consistent indentation (match surrounding code)
|
||||||
|
- Prefer descriptive variable names over abbreviations
|
||||||
|
- Keep functions focused and small
|
||||||
|
- Add error handling for all external calls`,
|
||||||
|
Mode: ModeBoth,
|
||||||
|
Priority: 5,
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "git_workflow",
|
||||||
|
Title: "Git Workflow Best Practices",
|
||||||
|
Description: "Guidelines for git operations and commit practices",
|
||||||
|
Category: "development",
|
||||||
|
Triggers: Triggers{
|
||||||
|
Keywords: []string{"git", "commit", "branch", "merge", "pull request", "rebase"},
|
||||||
|
Tools: []string{"terminal"},
|
||||||
|
},
|
||||||
|
Content: `- Write clear, descriptive commit messages
|
||||||
|
- Use conventional commits format when applicable
|
||||||
|
- Keep commits atomic and focused
|
||||||
|
- Don't commit sensitive data or secrets
|
||||||
|
- Test before committing`,
|
||||||
|
Mode: ModeBoth,
|
||||||
|
Priority: 5,
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "error_handling",
|
||||||
|
Title: "Error Handling Patterns",
|
||||||
|
Description: "Robust error handling guidelines",
|
||||||
|
Category: "development",
|
||||||
|
Triggers: Triggers{
|
||||||
|
Keywords: []string{"error", "panic", "exception", "crash", "fail", "nil pointer"},
|
||||||
|
Tools: []string{"terminal", "read_file"},
|
||||||
|
Patterns: []string{`err\s*!=\s*nil`, `panic\(`, `log\.Fatal`},
|
||||||
|
},
|
||||||
|
Content: `- Always check errors from external calls
|
||||||
|
- Provide context when wrapping errors
|
||||||
|
- Use sentinel errors for expected conditions
|
||||||
|
- Log errors with enough context for debugging
|
||||||
|
- Don't silently ignore errors`,
|
||||||
|
Mode: ModeBoth,
|
||||||
|
Priority: 6,
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "testing",
|
||||||
|
Title: "Testing Best Practices",
|
||||||
|
Description: "Guidelines for writing effective tests",
|
||||||
|
Category: "development",
|
||||||
|
Triggers: Triggers{
|
||||||
|
Keywords: []string{"test", "testing", "unit test", "integration test", "coverage"},
|
||||||
|
Tools: []string{"terminal"},
|
||||||
|
},
|
||||||
|
Content: `- Write tests for critical paths first
|
||||||
|
- Use table-driven tests for multiple cases
|
||||||
|
- Keep tests independent and deterministic
|
||||||
|
- Test error paths, not just happy paths
|
||||||
|
- Aim for meaningful coverage, not just percentage`,
|
||||||
|
Mode: ModeBoth,
|
||||||
|
Priority: 5,
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "security",
|
||||||
|
Title: "Security Guidelines",
|
||||||
|
Description: "Security best practices for development",
|
||||||
|
Category: "development",
|
||||||
|
Triggers: Triggers{
|
||||||
|
Keywords: []string{"security", "vulnerability", "inject", "sanitize", "auth", "secret", "password", "token"},
|
||||||
|
Tools: []string{"terminal", "read_file", "web_fetch"},
|
||||||
|
Patterns: []string{`SELECT\s.*\+`, `exec\.Command.*\+`, `os\.Getenv.*KEY`},
|
||||||
|
},
|
||||||
|
Content: `- Never log or expose secrets, API keys, or tokens
|
||||||
|
- Validate and sanitize all user input
|
||||||
|
- Use parameterized queries for database operations
|
||||||
|
- Keep dependencies updated
|
||||||
|
- Don't hardcode credentials`,
|
||||||
|
Mode: ModeBoth,
|
||||||
|
Priority: 8,
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsKeyword(text, keyword string) bool {
|
||||||
|
if keyword == "*" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.Contains(text, keyword)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortResults(results []*MatchResult) {
|
||||||
|
for i := 0; i < len(results)-1; i++ {
|
||||||
|
for j := i + 1; j < len(results); j++ {
|
||||||
|
if results[j].Score > results[i].Score {
|
||||||
|
results[i], results[j] = results[j], results[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
369
internal/mcp/discover.go
Normal file
369
internal/mcp/discover.go
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DiscoveredMCPServer struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Args []string `json:"args,omitempty"`
|
||||||
|
Installed bool `json:"installed"`
|
||||||
|
Running bool `json:"running"`
|
||||||
|
Category string `json:"category,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DiscoveryResult struct {
|
||||||
|
Servers []DiscoveredMCPServer `json:"servers"`
|
||||||
|
ScanPaths []string `json:"scan_paths"`
|
||||||
|
TotalFound int `json:"total_found"`
|
||||||
|
NewServers int `json:"new_servers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolDiscovery struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
InputSchema json.RawMessage `json:"input_schema"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerCapabilities struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Tools []ToolDiscovery `json:"tools"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
Raw json.RawMessage `json:"raw,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
capCache map[string]*ServerCapabilities
|
||||||
|
capCacheMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
capCache = make(map[string]*ServerCapabilities)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DiscoverSystemServers() *DiscoveryResult {
|
||||||
|
result := &DiscoveryResult{}
|
||||||
|
|
||||||
|
knownNames := make(map[string]bool)
|
||||||
|
for _, s := range knownMCPServers {
|
||||||
|
knownNames[s.Name] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
reg, _ := LoadRegistry()
|
||||||
|
if reg != nil {
|
||||||
|
for _, s := range reg.Servers {
|
||||||
|
knownNames[s.Name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var servers []DiscoveredMCPServer
|
||||||
|
|
||||||
|
npmServers := discoverNpmGlobalServers(knownNames)
|
||||||
|
servers = append(servers, npmServers...)
|
||||||
|
|
||||||
|
pipServers := discoverPipServers(knownNames)
|
||||||
|
servers = append(servers, pipServers...)
|
||||||
|
|
||||||
|
pathServers := discoverPathServers(knownNames)
|
||||||
|
servers = append(servers, pathServers...)
|
||||||
|
|
||||||
|
result.Servers = servers
|
||||||
|
result.TotalFound = len(servers)
|
||||||
|
result.NewServers = countNew(servers, knownNames)
|
||||||
|
|
||||||
|
paths := []string{}
|
||||||
|
if path := os.Getenv("PATH"); path != "" {
|
||||||
|
paths = strings.Split(path, ":")
|
||||||
|
}
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
paths = append(paths,
|
||||||
|
filepath.Join(home, ".local", "bin"),
|
||||||
|
filepath.Join(home, ".npm-global", "bin"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
result.ScanPaths = paths
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func discoverNpmGlobalServers(known map[string]bool) []DiscoveredMCPServer {
|
||||||
|
var servers []DiscoveredMCPServer
|
||||||
|
|
||||||
|
npx, err := exec.LookPath("npx")
|
||||||
|
if err != nil {
|
||||||
|
return servers
|
||||||
|
}
|
||||||
|
|
||||||
|
patterns := []struct {
|
||||||
|
pkg string
|
||||||
|
name string
|
||||||
|
cat string
|
||||||
|
}{
|
||||||
|
{"@anthropic/mcp-server-fetch", "anthropic-fetch", "web"},
|
||||||
|
{"@anthropic/mcp-server-sqlite", "anthropic-sqlite", "database"},
|
||||||
|
{"@anthropic/mcp-server-brave-search", "anthropic-brave-search", "web"},
|
||||||
|
{"@anthropic/mcp-server-filesystem", "anthropic-filesystem", "core"},
|
||||||
|
{"@anthropic/mcp-server-github", "anthropic-github", "vcs"},
|
||||||
|
{"@anthropic/mcp-server-memory", "anthropic-memory", "core"},
|
||||||
|
{"@anthropic/mcp-server-puppeteer", "anthropic-puppeteer", "web"},
|
||||||
|
{"@anthropic/mcp-server-sequential-thinking", "anthropic-thinking", "ai"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range patterns {
|
||||||
|
if known[p.name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
servers = append(servers, DiscoveredMCPServer{
|
||||||
|
Name: p.name,
|
||||||
|
Command: npx,
|
||||||
|
Source: "npm-global",
|
||||||
|
Args: []string{"-y", p.pkg},
|
||||||
|
Installed: true,
|
||||||
|
Category: p.cat,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return servers
|
||||||
|
}
|
||||||
|
|
||||||
|
func discoverPipServers(known map[string]bool) []DiscoveredMCPServer {
|
||||||
|
var servers []DiscoveredMCPServer
|
||||||
|
|
||||||
|
pipCmds := []string{"pip", "pip3", "uv"}
|
||||||
|
for _, pip := range pipCmds {
|
||||||
|
if _, err := exec.LookPath(pip); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(pip, "list", "--format=json")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var packages []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(output, &packages); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pkg := range packages {
|
||||||
|
nameLower := strings.ToLower(pkg.Name)
|
||||||
|
if !strings.Contains(nameLower, "mcp") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
serverName := strings.ReplaceAll(nameLower, "_", "-")
|
||||||
|
if strings.HasPrefix(serverName, "mcp-") {
|
||||||
|
serverName = serverName[4:]
|
||||||
|
}
|
||||||
|
|
||||||
|
if known[serverName] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
binName := strings.ReplaceAll(pkg.Name, "-", "_")
|
||||||
|
if _, err := exec.LookPath(binName); err != nil {
|
||||||
|
binName = pkg.Name
|
||||||
|
if _, err := exec.LookPath(binName); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
servers = append(servers, DiscoveredMCPServer{
|
||||||
|
Name: serverName,
|
||||||
|
Command: binName,
|
||||||
|
Source: "pip",
|
||||||
|
Installed: true,
|
||||||
|
Category: "python",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return servers
|
||||||
|
}
|
||||||
|
|
||||||
|
func discoverPathServers(known map[string]bool) []DiscoveredMCPServer {
|
||||||
|
var servers []DiscoveredMCPServer
|
||||||
|
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
searchDirs := []string{}
|
||||||
|
|
||||||
|
if home != "" {
|
||||||
|
searchDirs = append(searchDirs,
|
||||||
|
filepath.Join(home, ".local", "bin"),
|
||||||
|
filepath.Join(home, ".muyue", "mcp-servers"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dir := range searchDirs {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name := entry.Name()
|
||||||
|
if !strings.Contains(strings.ToLower(name), "mcp") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
serverName := strings.ToLower(name)
|
||||||
|
serverName = strings.TrimPrefix(serverName, "mcp-")
|
||||||
|
serverName = strings.TrimPrefix(serverName, "mcp_")
|
||||||
|
serverName = strings.TrimSuffix(serverName, ".sh")
|
||||||
|
|
||||||
|
if known[serverName] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fullPath := filepath.Join(dir, name)
|
||||||
|
if info, err := os.Stat(fullPath); err == nil && info.Mode()&0111 != 0 {
|
||||||
|
servers = append(servers, DiscoveredMCPServer{
|
||||||
|
Name: serverName,
|
||||||
|
Command: fullPath,
|
||||||
|
Source: "path",
|
||||||
|
Installed: true,
|
||||||
|
Category: "local",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return servers
|
||||||
|
}
|
||||||
|
|
||||||
|
func DiscoverServerTools(serverName string) (*ServerCapabilities, error) {
|
||||||
|
capCacheMu.RLock()
|
||||||
|
if caps, ok := capCache[serverName]; ok {
|
||||||
|
capCacheMu.RUnlock()
|
||||||
|
return caps, nil
|
||||||
|
}
|
||||||
|
capCacheMu.RUnlock()
|
||||||
|
|
||||||
|
server, err := findServerConfig(serverName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
script := buildListToolsScript(server)
|
||||||
|
if script == "" {
|
||||||
|
return &ServerCapabilities{
|
||||||
|
Name: serverName,
|
||||||
|
Tools: []ToolDiscovery{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(server.Command, append(server.Args, "--list-tools")...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
_ = script
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return discoverToolsFallback(serverName, server)
|
||||||
|
}
|
||||||
|
|
||||||
|
var caps ServerCapabilities
|
||||||
|
if jsonErr := json.Unmarshal(output, &caps); jsonErr != nil {
|
||||||
|
caps = ServerCapabilities{
|
||||||
|
Name: serverName,
|
||||||
|
Tools: []ToolDiscovery{
|
||||||
|
{
|
||||||
|
Name: serverName,
|
||||||
|
Description: "MCP server: " + serverName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
capCacheMu.Lock()
|
||||||
|
capCache[serverName] = &caps
|
||||||
|
capCacheMu.Unlock()
|
||||||
|
|
||||||
|
return &caps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func discoverToolsFallback(name string, server *RegistryServer) (*ServerCapabilities, error) {
|
||||||
|
caps := &ServerCapabilities{
|
||||||
|
Name: name,
|
||||||
|
Tools: []ToolDiscovery{
|
||||||
|
{
|
||||||
|
Name: name,
|
||||||
|
Description: server.Description,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
capCacheMu.Lock()
|
||||||
|
capCache[name] = caps
|
||||||
|
capCacheMu.Unlock()
|
||||||
|
|
||||||
|
return caps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findServerConfig(name string) (*RegistryServer, error) {
|
||||||
|
reg, err := LoadRegistry()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range reg.Servers {
|
||||||
|
if reg.Servers[i].Name == name {
|
||||||
|
return ®.Servers[i], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range knownMCPServers {
|
||||||
|
if s.Name == name {
|
||||||
|
return &RegistryServer{
|
||||||
|
Name: s.Name,
|
||||||
|
Command: s.Command,
|
||||||
|
Args: s.Args,
|
||||||
|
Env: s.Env,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("server %q not found", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildListToolsScript(server *RegistryServer) string {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func InvalidateCapabilitiesCache() {
|
||||||
|
capCacheMu.Lock()
|
||||||
|
defer capCacheMu.Unlock()
|
||||||
|
capCache = make(map[string]*ServerCapabilities)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetCachedCapabilities(name string) *ServerCapabilities {
|
||||||
|
capCacheMu.RLock()
|
||||||
|
defer capCacheMu.RUnlock()
|
||||||
|
return capCache[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
func countNew(servers []DiscoveredMCPServer, known map[string]bool) int {
|
||||||
|
count := 0
|
||||||
|
for _, s := range servers {
|
||||||
|
if !known[s.Name] {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
556
internal/mcpserver/server.go
Normal file
556
internal/mcpserver/server.go
Normal file
@@ -0,0 +1,556 @@
|
|||||||
|
package mcpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Tool struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
InputSchema map[string]interface{} `json:"inputSchema"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolCall struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Args json.RawMessage `json:"arguments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolResult struct {
|
||||||
|
Content []ContentBlock `json:"content"`
|
||||||
|
IsError bool `json:"isError,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContentBlock struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type JSONRPCRequest struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID json.RawMessage `json:"id,omitempty"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Params json.RawMessage `json:"params,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type JSONRPCResponse struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID json.RawMessage `json:"id"`
|
||||||
|
Result interface{} `json:"result,omitempty"`
|
||||||
|
Error *RPCError `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RPCError struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var tools = []Tool{
|
||||||
|
{
|
||||||
|
Name: "terminal_exec",
|
||||||
|
Description: "Execute a command in the terminal and return the output",
|
||||||
|
InputSchema: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"command": map[string]interface{}{"type": "string", "description": "The command to execute"},
|
||||||
|
"cwd": map[string]interface{}{"type": "string", "description": "Working directory (optional)"},
|
||||||
|
"timeout": map[string]interface{}{"type": "integer", "description": "Timeout in seconds (default 30)"},
|
||||||
|
},
|
||||||
|
"required": []string{"command"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "file_read",
|
||||||
|
Description: "Read the contents of a file",
|
||||||
|
InputSchema: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"path": map[string]interface{}{"type": "string", "description": "Path to the file"},
|
||||||
|
"offset": map[string]interface{}{"type": "integer", "description": "Line offset to start reading from (0-based)"},
|
||||||
|
"limit": map[string]interface{}{"type": "integer", "description": "Maximum number of lines to read"},
|
||||||
|
},
|
||||||
|
"required": []string{"path"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "file_write",
|
||||||
|
Description: "Write content to a file, creating it if needed",
|
||||||
|
InputSchema: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"path": map[string]interface{}{"type": "string", "description": "Path to the file"},
|
||||||
|
"content": map[string]interface{}{"type": "string", "description": "Content to write"},
|
||||||
|
},
|
||||||
|
"required": []string{"path", "content"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "search",
|
||||||
|
Description: "Search for files by name pattern",
|
||||||
|
InputSchema: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"path": map[string]interface{}{"type": "string", "description": "Directory to search in"},
|
||||||
|
"pattern": map[string]interface{}{"type": "string", "description": "Glob pattern to match filenames"},
|
||||||
|
},
|
||||||
|
"required": []string{"path", "pattern"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "grep",
|
||||||
|
Description: "Search file contents for a pattern",
|
||||||
|
InputSchema: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"path": map[string]interface{}{"type": "string", "description": "Directory to search in"},
|
||||||
|
"pattern": map[string]interface{}{"type": "string", "description": "Text or regex pattern to search for"},
|
||||||
|
},
|
||||||
|
"required": []string{"path", "pattern"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "system_info",
|
||||||
|
Description: "Get system information (OS, CPU, memory, disk)",
|
||||||
|
InputSchema: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
type MCPServer struct {
|
||||||
|
port int
|
||||||
|
server *http.Server
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
sseClients map[string]chan SSEEvent
|
||||||
|
sseClientsMu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
type SSEEvent struct {
|
||||||
|
Event string
|
||||||
|
Data string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(port int) *MCPServer {
|
||||||
|
return &MCPServer{
|
||||||
|
port: port,
|
||||||
|
sseClients: make(map[string]chan SSEEvent),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MCPServer) Start() error {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/", m.handleSSE)
|
||||||
|
mux.HandleFunc("/message", m.handleHTTPMessage)
|
||||||
|
mux.HandleFunc("/mcp", m.handleStreamableHTTP)
|
||||||
|
|
||||||
|
m.server = &http.Server{
|
||||||
|
Addr: fmt.Sprintf("127.0.0.1:%d", m.port),
|
||||||
|
Handler: mux,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := m.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
fmt.Printf("[MCP Server] Error: %v\n", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MCPServer) Stop() error {
|
||||||
|
if m.server != nil {
|
||||||
|
return m.server.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MCPServer) Port() int {
|
||||||
|
return m.port
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MCPServer) handleSSE(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
|
||||||
|
clientID := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||||
|
ch := make(chan SSEEvent, 32)
|
||||||
|
|
||||||
|
m.sseClientsMu.Lock()
|
||||||
|
m.sseClients[clientID] = ch
|
||||||
|
m.sseClientsMu.Unlock()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
m.sseClientsMu.Lock()
|
||||||
|
delete(m.sseClients, clientID)
|
||||||
|
m.sseClientsMu.Unlock()
|
||||||
|
close(ch)
|
||||||
|
}()
|
||||||
|
|
||||||
|
fmt.Fprintf(w, "event: endpoint\ndata: /message?clientId=%s\n\n", clientID)
|
||||||
|
if f, ok := w.(http.Flusher); ok {
|
||||||
|
f.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case evt, ok := <-ch:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", evt.Event, evt.Data)
|
||||||
|
if f, ok := w.(http.Flusher); ok {
|
||||||
|
f.Flush()
|
||||||
|
}
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MCPServer) handleHTTPMessage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method != "POST" {
|
||||||
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
m.writeRPCError(w, nil, -32700, "Parse error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := m.handleJSONRPC(body)
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MCPServer) handleStreamableHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == "GET" {
|
||||||
|
m.handleSSE(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method != "POST" {
|
||||||
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
m.writeRPCError(w, nil, -32700, "Parse error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := m.handleJSONRPC(body)
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MCPServer) handleJSONRPC(body []byte) JSONRPCResponse {
|
||||||
|
var req JSONRPCRequest
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
return JSONRPCResponse{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
Error: &RPCError{Code: -32700, Message: "Parse error"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch req.Method {
|
||||||
|
case "initialize":
|
||||||
|
return JSONRPCResponse{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: req.ID,
|
||||||
|
Result: map[string]interface{}{
|
||||||
|
"protocolVersion": "2024-11-05",
|
||||||
|
"capabilities": map[string]interface{}{
|
||||||
|
"tools": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
"serverInfo": map[string]interface{}{
|
||||||
|
"name": "muyue",
|
||||||
|
"version": "0.9.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
case "notifications/initialized":
|
||||||
|
return JSONRPCResponse{JSONRPC: "2.0", ID: req.ID}
|
||||||
|
|
||||||
|
case "tools/list":
|
||||||
|
return JSONRPCResponse{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: req.ID,
|
||||||
|
Result: map[string]interface{}{
|
||||||
|
"tools": tools,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
case "tools/call":
|
||||||
|
var params struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments json.RawMessage `json:"arguments"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||||
|
return JSONRPCResponse{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: req.ID,
|
||||||
|
Error: &RPCError{Code: -32602, Message: "Invalid params"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result := m.executeTool(params.Name, params.Arguments)
|
||||||
|
return JSONRPCResponse{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: req.ID,
|
||||||
|
Result: result,
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return JSONRPCResponse{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: req.ID,
|
||||||
|
Error: &RPCError{Code: -32601, Message: fmt.Sprintf("Method not found: %s", req.Method)},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MCPServer) executeTool(name string, args json.RawMessage) ToolResult {
|
||||||
|
switch name {
|
||||||
|
case "terminal_exec":
|
||||||
|
return m.toolTerminalExec(args)
|
||||||
|
case "file_read":
|
||||||
|
return m.toolFileRead(args)
|
||||||
|
case "file_write":
|
||||||
|
return m.toolFileWrite(args)
|
||||||
|
case "search":
|
||||||
|
return m.toolSearch(args)
|
||||||
|
case "grep":
|
||||||
|
return m.toolGrep(args)
|
||||||
|
case "system_info":
|
||||||
|
return m.toolSystemInfo()
|
||||||
|
default:
|
||||||
|
return ToolResult{
|
||||||
|
Content: []ContentBlock{{Type: "text", Text: fmt.Sprintf("Unknown tool: %s", name)}},
|
||||||
|
IsError: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MCPServer) toolTerminalExec(args json.RawMessage) ToolResult {
|
||||||
|
var params struct {
|
||||||
|
Command string `json:"command"`
|
||||||
|
Cwd string `json:"cwd"`
|
||||||
|
Timeout int `json:"timeout"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||||
|
return ToolResult{Content: []ContentBlock{{Type: "text", Text: err.Error()}}, IsError: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := params.Timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 30
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := exec.Command("sh", "-c", params.Command)
|
||||||
|
if params.Cwd != "" {
|
||||||
|
ctx.Dir = params.Cwd
|
||||||
|
}
|
||||||
|
|
||||||
|
var stdout, stderr strings.Builder
|
||||||
|
ctx.Stdout = &stdout
|
||||||
|
ctx.Stderr = &stderr
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- ctx.Run() }()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
output := stdout.String()
|
||||||
|
if errMsg := stderr.String(); errMsg != "" {
|
||||||
|
output += "\n" + errMsg
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
output += fmt.Sprintf("\nExit error: %v", err)
|
||||||
|
}
|
||||||
|
return ToolResult{Content: []ContentBlock{{Type: "text", Text: output}}}
|
||||||
|
case <-time.After(time.Duration(timeout) * time.Second):
|
||||||
|
ctx.Process.Kill()
|
||||||
|
return ToolResult{
|
||||||
|
Content: []ContentBlock{{Type: "text", Text: fmt.Sprintf("Command timed out after %ds\n%s%s", timeout, stdout.String(), stderr.String())}},
|
||||||
|
IsError: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MCPServer) toolFileRead(args json.RawMessage) ToolResult {
|
||||||
|
var params struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||||
|
return ToolResult{Content: []ContentBlock{{Type: "text", Text: err.Error()}}, IsError: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
path := strings.ReplaceAll(params.Path, "~", home)
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return ToolResult{Content: []ContentBlock{{Type: "text", Text: fmt.Sprintf("Error reading file: %v", err)}}, IsError: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
start := params.Offset
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
end := len(lines)
|
||||||
|
if params.Limit > 0 && start+params.Limit < end {
|
||||||
|
end = start + params.Limit
|
||||||
|
}
|
||||||
|
|
||||||
|
if start > len(lines) {
|
||||||
|
start = len(lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ToolResult{Content: []ContentBlock{{Type: "text", Text: strings.Join(lines[start:end], "\n")}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MCPServer) toolFileWrite(args json.RawMessage) ToolResult {
|
||||||
|
var params struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||||
|
return ToolResult{Content: []ContentBlock{{Type: "text", Text: err.Error()}}, IsError: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
path := strings.ReplaceAll(params.Path, "~", home)
|
||||||
|
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return ToolResult{Content: []ContentBlock{{Type: "text", Text: fmt.Sprintf("Error creating directory: %v", err)}}, IsError: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(path, []byte(params.Content), 0644); err != nil {
|
||||||
|
return ToolResult{Content: []ContentBlock{{Type: "text", Text: fmt.Sprintf("Error writing file: %v", err)}}, IsError: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ToolResult{Content: []ContentBlock{{Type: "text", Text: fmt.Sprintf("Successfully wrote %d bytes to %s", len(params.Content), path)}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MCPServer) toolSearch(args json.RawMessage) ToolResult {
|
||||||
|
var params struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Pattern string `json:"pattern"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||||
|
return ToolResult{Content: []ContentBlock{{Type: "text", Text: err.Error()}}, IsError: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
basePath := strings.ReplaceAll(params.Path, "~", home)
|
||||||
|
|
||||||
|
cmd := exec.Command("find", basePath, "-name", params.Pattern, "-type", "f", "-not", "-path", "*/node_modules/*", "-not", "-path", "*/.git/*")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return ToolResult{Content: []ContentBlock{{Type: "text", Text: string(output)}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||||
|
if len(lines) > 100 {
|
||||||
|
lines = lines[:100]
|
||||||
|
}
|
||||||
|
|
||||||
|
return ToolResult{Content: []ContentBlock{{Type: "text", Text: strings.Join(lines, "\n")}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MCPServer) toolGrep(args json.RawMessage) ToolResult {
|
||||||
|
var params struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Pattern string `json:"pattern"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||||
|
return ToolResult{Content: []ContentBlock{{Type: "text", Text: err.Error()}}, IsError: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
basePath := strings.ReplaceAll(params.Path, "~", home)
|
||||||
|
|
||||||
|
cmd := exec.Command("grep", "-rn", "--include=*", params.Pattern, basePath)
|
||||||
|
output, _ := cmd.CombinedOutput()
|
||||||
|
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||||
|
if len(lines) > 50 {
|
||||||
|
lines = lines[:50]
|
||||||
|
lines = append(lines, fmt.Sprintf("... (%d more results truncated)", len(strings.Split(string(output), "\n"))-50))
|
||||||
|
}
|
||||||
|
|
||||||
|
return ToolResult{Content: []ContentBlock{{Type: "text", Text: strings.Join(lines, "\n")}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MCPServer) toolSystemInfo() ToolResult {
|
||||||
|
var info strings.Builder
|
||||||
|
info.WriteString(fmt.Sprintf("OS: %s/%s\n", runtime.GOOS, runtime.GOARCH))
|
||||||
|
info.WriteString(fmt.Sprintf("CPUs: %d\n", runtime.NumCPU()))
|
||||||
|
|
||||||
|
if out, err := exec.Command("uname", "-a").Output(); err == nil {
|
||||||
|
info.WriteString(fmt.Sprintf("Kernel: %s", string(out)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if out, err := exec.Command("free", "-h").Output(); err == nil {
|
||||||
|
info.WriteString(fmt.Sprintf("Memory:\n%s", string(out)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if out, err := exec.Command("df", "-h", "/").Output(); err == nil {
|
||||||
|
info.WriteString(fmt.Sprintf("Disk:\n%s", string(out)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if out, err := exec.Command("uptime").Output(); err == nil {
|
||||||
|
info.WriteString(fmt.Sprintf("Uptime: %s", string(out)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return ToolResult{Content: []ContentBlock{{Type: "text", Text: info.String()}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MCPServer) writeRPCError(w http.ResponseWriter, id json.RawMessage, code int, msg string) {
|
||||||
|
resp := JSONRPCResponse{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: id,
|
||||||
|
Error: &RPCError{Code: code, Message: msg},
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
140
internal/memory/inject.go
Normal file
140
internal/memory/inject.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MemoryInjector struct {
|
||||||
|
store *Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInjector(store *Store) *MemoryInjector {
|
||||||
|
return &MemoryInjector{store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mi *MemoryInjector) BuildContextBlock(query string) (string, error) {
|
||||||
|
var contextParts []string
|
||||||
|
|
||||||
|
preferences, err := mi.store.RecallPreferences()
|
||||||
|
if err == nil && len(preferences) > 0 {
|
||||||
|
var prefLines []string
|
||||||
|
for _, p := range preferences {
|
||||||
|
prefLines = append(prefLines, fmt.Sprintf("- %s: %s", p.Key, p.Content))
|
||||||
|
}
|
||||||
|
contextParts = append(contextParts,
|
||||||
|
"[User Preferences]\n"+strings.Join(prefLines, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
facts, err := mi.store.RecallFacts()
|
||||||
|
if err == nil && len(facts) > 0 {
|
||||||
|
var factLines []string
|
||||||
|
for _, f := range facts {
|
||||||
|
factLines = append(factLines, fmt.Sprintf("- %s: %s", f.Key, f.Content))
|
||||||
|
}
|
||||||
|
contextParts = append(contextParts,
|
||||||
|
"[Known Facts]\n"+strings.Join(factLines, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if query != "" {
|
||||||
|
relevant, err := mi.store.Recall(query, 5)
|
||||||
|
if err == nil && len(relevant) > 0 {
|
||||||
|
var relLines []string
|
||||||
|
for _, r := range relevant {
|
||||||
|
relLines = append(relLines, fmt.Sprintf("- [%s] %s: %s", r.Type, r.Key, truncate(r.Content, 150)))
|
||||||
|
}
|
||||||
|
contextParts = append(contextParts,
|
||||||
|
"[Relevant Memories]\n"+strings.Join(relLines, "\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recentCutoff := time.Now().Add(-24 * time.Hour)
|
||||||
|
recent, err := mi.store.RecallRecent(recentCutoff, 5)
|
||||||
|
if err == nil && len(recent) > 0 {
|
||||||
|
var recentLines []string
|
||||||
|
for _, r := range recent {
|
||||||
|
recentLines = append(recentLines, fmt.Sprintf("- [%s] %s", r.Type, truncate(r.Content, 100)))
|
||||||
|
}
|
||||||
|
contextParts = append(contextParts,
|
||||||
|
"[Recent Context]\n"+strings.Join(recentLines, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(contextParts) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("<memory-context>\n[System note: NOT new user input — recalled context]\n%s\n</memory-context>",
|
||||||
|
strings.Join(contextParts, "\n\n")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mi *MemoryInjector) BuildSystemPromptBlock() (string, error) {
|
||||||
|
preferences, err := mi.store.RecallPreferences()
|
||||||
|
if err != nil || len(preferences) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
lines = append(lines, "Known user preferences:")
|
||||||
|
for _, p := range preferences {
|
||||||
|
lines = append(lines, fmt.Sprintf("- %s: %s", p.Key, p.Content))
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(lines, "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mi *MemoryInjector) ExtractAndStore(userMessage, assistantMessage string) error {
|
||||||
|
pref := extractPreference(userMessage)
|
||||||
|
if pref != "" {
|
||||||
|
if err := mi.store.StorePreference("detected", pref); err != nil {
|
||||||
|
return fmt.Errorf("store preference: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if assistantMessage != "" {
|
||||||
|
ctx := extractContext(assistantMessage)
|
||||||
|
if ctx != "" {
|
||||||
|
if err := mi.store.StoreContext("conversation", ctx); err != nil {
|
||||||
|
return fmt.Errorf("store context: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractPreference(message string) string {
|
||||||
|
indicators := []string{
|
||||||
|
"i prefer", "i like", "i always", "i never", "my favorite",
|
||||||
|
"i use", "je préfère", "j'aime", "toujours", "jamais",
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(message)
|
||||||
|
for _, ind := range indicators {
|
||||||
|
if strings.Contains(lower, ind) {
|
||||||
|
idx := strings.Index(lower, ind)
|
||||||
|
end := idx + len(ind) + 100
|
||||||
|
if end > len(message) {
|
||||||
|
end = len(message)
|
||||||
|
}
|
||||||
|
return truncate(message[idx:end], 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractContext(message string) string {
|
||||||
|
if len(message) < 50 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(message) > 500 {
|
||||||
|
return truncate(message, 500)
|
||||||
|
}
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, maxLen int) string {
|
||||||
|
if len(s) <= maxLen {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:maxLen] + "..."
|
||||||
|
}
|
||||||
215
internal/memory/recall.go
Normal file
215
internal/memory/recall.go
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SearchResult struct {
|
||||||
|
Memory
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Search(query string, limit int) ([]SearchResult, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 10
|
||||||
|
}
|
||||||
|
if limit > 50 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedQuery := normalizeQuery(query)
|
||||||
|
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT m.id, m.type, m.key, m.content, m.tags, m.source, m.confidence,
|
||||||
|
m.access_count, m.created_at, m.updated_at,
|
||||||
|
bm25(memories_fts) as score
|
||||||
|
FROM memories_fts f
|
||||||
|
JOIN memories m ON m.rowid = f.rowid
|
||||||
|
WHERE memories_fts MATCH ?
|
||||||
|
ORDER BY score
|
||||||
|
LIMIT ?
|
||||||
|
`, normalizedQuery, limit)
|
||||||
|
if err != nil {
|
||||||
|
return fallbackSearch(s.db, query, limit)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return scanSearchResults(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Recall(query string, limit int) ([]Memory, error) {
|
||||||
|
results, err := s.Search(query, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
memories := make([]Memory, len(results))
|
||||||
|
for i, r := range results {
|
||||||
|
memories[i] = r.Memory
|
||||||
|
}
|
||||||
|
return memories, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) RecallByType(memType MemoryType, limit int) ([]Memory, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT id, type, key, content, tags, source, confidence, access_count, created_at, updated_at
|
||||||
|
FROM memories WHERE type = ?
|
||||||
|
ORDER BY access_count DESC, updated_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`, string(memType), limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return scanMemories(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) RecallRecent(since time.Time, limit int) ([]Memory, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT id, type, key, content, tags, source, confidence, access_count, created_at, updated_at
|
||||||
|
FROM memories WHERE updated_at >= ?
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`, since, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return scanMemories(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) RecallPreferences() ([]Memory, error) {
|
||||||
|
return s.RecallByType(TypePreference, 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) RecallFacts() ([]Memory, error) {
|
||||||
|
return s.RecallByType(TypeFact, 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) StorePreference(key, content string) error {
|
||||||
|
return s.Store(&Memory{
|
||||||
|
Type: TypePreference,
|
||||||
|
Key: key,
|
||||||
|
Content: content,
|
||||||
|
Source: "user",
|
||||||
|
Confidence: 0.9,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) StoreContext(key, content string) error {
|
||||||
|
return s.Store(&Memory{
|
||||||
|
Type: TypeContext,
|
||||||
|
Key: key,
|
||||||
|
Content: content,
|
||||||
|
Source: "conversation",
|
||||||
|
Confidence: 0.7,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) StoreSummary(sessionID, summary string) error {
|
||||||
|
return s.Store(&Memory{
|
||||||
|
Type: TypeSummary,
|
||||||
|
Key: "session:" + sessionID,
|
||||||
|
Content: summary,
|
||||||
|
Source: "auto",
|
||||||
|
Confidence: 0.8,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) StoreFact(key, content string) error {
|
||||||
|
return s.Store(&Memory{
|
||||||
|
Type: TypeFact,
|
||||||
|
Key: key,
|
||||||
|
Content: content,
|
||||||
|
Source: "auto",
|
||||||
|
Confidence: 0.85,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeQuery(query string) string {
|
||||||
|
words := strings.Fields(strings.ToLower(query))
|
||||||
|
var escaped []string
|
||||||
|
for _, w := range words {
|
||||||
|
if len(w) > 0 {
|
||||||
|
escaped = append(escaped, w+"*")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(escaped, " OR ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func fallbackSearch(db *sql.DB, query string, limit int) ([]SearchResult, error) {
|
||||||
|
likePattern := "%" + strings.ToLower(query) + "%"
|
||||||
|
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT id, type, key, content, tags, source, confidence, access_count, created_at, updated_at
|
||||||
|
FROM memories
|
||||||
|
WHERE LOWER(key) LIKE ? OR LOWER(content) LIKE ? OR LOWER(tags) LIKE ?
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`, likePattern, likePattern, likePattern, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var results []SearchResult
|
||||||
|
for rows.Next() {
|
||||||
|
var m Memory
|
||||||
|
err := rows.Scan(&m.ID, &m.Type, &m.Key, &m.Content, &m.Tags, &m.Source, &m.Confidence, &m.AccessCount, &m.CreatedAt, &m.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return results, err
|
||||||
|
}
|
||||||
|
score := computeFallbackScore(m, query)
|
||||||
|
results = append(results, SearchResult{Memory: m, Score: score})
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeFallbackScore(m Memory, query string) float64 {
|
||||||
|
score := m.Confidence * 0.5
|
||||||
|
lower := strings.ToLower(query)
|
||||||
|
if strings.Contains(strings.ToLower(m.Key), lower) {
|
||||||
|
score += 0.3
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ToLower(m.Content), lower) {
|
||||||
|
score += 0.2
|
||||||
|
}
|
||||||
|
score += float64(m.AccessCount) * 0.01
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanSearchResults(rows *sql.Rows) ([]SearchResult, error) {
|
||||||
|
var results []SearchResult
|
||||||
|
for rows.Next() {
|
||||||
|
var m Memory
|
||||||
|
var score float64
|
||||||
|
err := rows.Scan(&m.ID, &m.Type, &m.Key, &m.Content, &m.Tags, &m.Source,
|
||||||
|
&m.Confidence, &m.AccessCount, &m.CreatedAt, &m.UpdatedAt, &score)
|
||||||
|
if err != nil {
|
||||||
|
return results, err
|
||||||
|
}
|
||||||
|
results = append(results, SearchResult{Memory: m, Score: score})
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
276
internal/memory/store.go
Normal file
276
internal/memory/store.go
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MemoryType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TypePreference MemoryType = "preference"
|
||||||
|
TypeContext MemoryType = "context"
|
||||||
|
TypeSummary MemoryType = "summary"
|
||||||
|
TypeFact MemoryType = "fact"
|
||||||
|
TypePattern MemoryType = "pattern"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Memory struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type MemoryType `json:"type"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Tags string `json:"tags,omitempty"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
Confidence float64 `json:"confidence,omitempty"`
|
||||||
|
AccessCount int `json:"access_count"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
db *sql.DB
|
||||||
|
path string
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore() (*Store, error) {
|
||||||
|
dbPath, err := dbPath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get db path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("create memory dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite", dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open memory db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
s := &Store{db: db, path: dbPath}
|
||||||
|
if err := s.migrate(); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf("migrate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Close() error {
|
||||||
|
return s.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Store(m *Memory) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if m.ID == "" {
|
||||||
|
m.ID = generateID()
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if m.CreatedAt.IsZero() {
|
||||||
|
m.CreatedAt = now
|
||||||
|
}
|
||||||
|
m.UpdatedAt = now
|
||||||
|
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
INSERT INTO memories (id, type, key, content, tags, source, confidence, access_count, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
type = excluded.type,
|
||||||
|
key = excluded.key,
|
||||||
|
content = excluded.content,
|
||||||
|
tags = excluded.tags,
|
||||||
|
source = excluded.source,
|
||||||
|
confidence = excluded.confidence,
|
||||||
|
access_count = excluded.access_count,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`, m.ID, string(m.Type), m.Key, m.Content, m.Tags, m.Source, m.Confidence, m.AccessCount, m.CreatedAt, m.UpdatedAt)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Get(id string) (*Memory, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
m := &Memory{}
|
||||||
|
err := s.db.QueryRow(`
|
||||||
|
SELECT id, type, key, content, tags, source, confidence, access_count, created_at, updated_at
|
||||||
|
FROM memories WHERE id = ?
|
||||||
|
`, id).Scan(&m.ID, &m.Type, &m.Key, &m.Content, &m.Tags, &m.Source, &m.Confidence, &m.AccessCount, &m.CreatedAt, &m.UpdatedAt)
|
||||||
|
if err == nil {
|
||||||
|
s.incrementAccess(id)
|
||||||
|
}
|
||||||
|
return m, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Delete(id string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
_, err := s.db.Exec(`DELETE FROM memories WHERE id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) List(memType MemoryType, limit, offset int) ([]Memory, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
if limit > 200 {
|
||||||
|
limit = 200
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows *sql.Rows
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if memType != "" {
|
||||||
|
rows, err = s.db.Query(`
|
||||||
|
SELECT id, type, key, content, tags, source, confidence, access_count, created_at, updated_at
|
||||||
|
FROM memories WHERE type = ?
|
||||||
|
ORDER BY updated_at DESC LIMIT ? OFFSET ?
|
||||||
|
`, string(memType), limit, offset)
|
||||||
|
} else {
|
||||||
|
rows, err = s.db.Query(`
|
||||||
|
SELECT id, type, key, content, tags, source, confidence, access_count, created_at, updated_at
|
||||||
|
FROM memories ORDER BY updated_at DESC LIMIT ? OFFSET ?
|
||||||
|
`, limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return scanMemories(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Count() (int, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
var count int
|
||||||
|
err := s.db.QueryRow(`SELECT COUNT(*) FROM memories`).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) incrementAccess(id string) {
|
||||||
|
go func() {
|
||||||
|
s.db.Exec(`UPDATE memories SET access_count = access_count + 1 WHERE id = ?`, id)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) migrate() error {
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS memories (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
tags TEXT DEFAULT '',
|
||||||
|
source TEXT DEFAULT '',
|
||||||
|
confidence REAL DEFAULT 0.5,
|
||||||
|
access_count INTEGER DEFAULT 0,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.db.Exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(type)
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.db.Exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_memories_key ON memories(key)
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.db.Exec(`
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
|
||||||
|
key, content, tags,
|
||||||
|
content=memories,
|
||||||
|
content_rowid=rowid
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.db.Exec(`
|
||||||
|
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
|
||||||
|
INSERT INTO memories_fts(rowid, key, content, tags)
|
||||||
|
VALUES (new.rowid, new.key, new.content, new.tags);
|
||||||
|
END
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.db.Exec(`
|
||||||
|
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
|
||||||
|
INSERT INTO memories_fts(memories_fts, rowid, key, content, tags)
|
||||||
|
VALUES ('delete', old.rowid, old.key, old.content, old.tags);
|
||||||
|
END
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.db.Exec(`
|
||||||
|
CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
|
||||||
|
INSERT INTO memories_fts(memories_fts, rowid, key, content, tags)
|
||||||
|
VALUES ('delete', old.rowid, old.key, old.content, old.tags);
|
||||||
|
INSERT INTO memories_fts(rowid, key, content, tags)
|
||||||
|
VALUES (new.rowid, new.key, new.content, new.tags);
|
||||||
|
END
|
||||||
|
`)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanMemories(rows *sql.Rows) ([]Memory, error) {
|
||||||
|
var memories []Memory
|
||||||
|
for rows.Next() {
|
||||||
|
var m Memory
|
||||||
|
err := rows.Scan(&m.ID, &m.Type, &m.Key, &m.Content, &m.Tags, &m.Source, &m.Confidence, &m.AccessCount, &m.CreatedAt, &m.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return memories, err
|
||||||
|
}
|
||||||
|
memories = append(memories, m)
|
||||||
|
}
|
||||||
|
return memories, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbPath() (string, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".muyue", "memory", "memories.db"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateID() string {
|
||||||
|
return fmt.Sprintf("mem_%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
189
internal/memory/store_test.go
Normal file
189
internal/memory/store_test.go
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testDBPath(t *testing.T) string {
|
||||||
|
dir := t.TempDir()
|
||||||
|
return filepath.Join(dir, "test_memory.db")
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestStore(t *testing.T) *Store {
|
||||||
|
t.Helper()
|
||||||
|
dbPath := testDBPath(t)
|
||||||
|
|
||||||
|
db, err := openDB(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
|
||||||
|
s := &Store{db: db, path: dbPath}
|
||||||
|
if err := s.migrate(); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func openDB(path string) (*sql.DB, error) {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return sql.Open("sqlite", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreAndRetrieve(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
|
||||||
|
m := &Memory{
|
||||||
|
Type: TypeFact,
|
||||||
|
Key: "golang_version",
|
||||||
|
Content: "User uses Go 1.24",
|
||||||
|
Source: "conversation",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.Store(m); err != nil {
|
||||||
|
t.Fatalf("store: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.ID == "" {
|
||||||
|
t.Fatal("expected ID to be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.Get(m.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Key != m.Key {
|
||||||
|
t.Errorf("expected key %s, got %s", m.Key, got.Key)
|
||||||
|
}
|
||||||
|
if got.Content != m.Content {
|
||||||
|
t.Errorf("expected content %s, got %s", m.Content, got.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDelete(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
|
||||||
|
m := &Memory{
|
||||||
|
Type: TypePreference,
|
||||||
|
Key: "editor",
|
||||||
|
Content: "vim",
|
||||||
|
}
|
||||||
|
s.Store(m)
|
||||||
|
|
||||||
|
if err := s.Delete(m.ID); err != nil {
|
||||||
|
t.Fatalf("delete: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.Get(m.ID)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error after delete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestList(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
s.Store(&Memory{
|
||||||
|
Type: TypeFact,
|
||||||
|
Key: "fact_" + string(rune('a'+i)),
|
||||||
|
Content: "content",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
memories, err := s.List(TypeFact, 10, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(memories) != 5 {
|
||||||
|
t.Errorf("expected 5 memories, got %d", len(memories))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearch(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
|
||||||
|
s.Store(&Memory{Type: TypeFact, Key: "language", Content: "Go is the primary language"})
|
||||||
|
s.Store(&Memory{Type: TypeFact, Key: "editor", Content: "VSCode is the editor"})
|
||||||
|
s.Store(&Memory{Type: TypeContext, Key: "project", Content: "Muyue is a Go project"})
|
||||||
|
|
||||||
|
results, err := s.Search("Go language", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("search: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) == 0 {
|
||||||
|
t.Error("expected search results")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecallPreferences(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
|
||||||
|
s.Store(&Memory{Type: TypePreference, Key: "theme", Content: "dark"})
|
||||||
|
s.Store(&Memory{Type: TypePreference, Key: "lang", Content: "fr"})
|
||||||
|
s.Store(&Memory{Type: TypeFact, Key: "tool", Content: "go"})
|
||||||
|
|
||||||
|
prefs, err := s.RecallPreferences()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recall preferences: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(prefs) != 2 {
|
||||||
|
t.Errorf("expected 2 preferences, got %d", len(prefs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecallRecent(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
|
||||||
|
s.Store(&Memory{Type: TypeFact, Key: "old", Content: "old fact"})
|
||||||
|
|
||||||
|
recent, err := s.RecallRecent(time.Now().Add(-1*time.Hour), 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recall recent: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(recent) == 0 {
|
||||||
|
t.Error("expected recent memories")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStorePreference(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
|
||||||
|
if err := s.StorePreference("editor", "vim"); err != nil {
|
||||||
|
t.Fatalf("store preference: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
prefs, _ := s.RecallPreferences()
|
||||||
|
if len(prefs) != 1 {
|
||||||
|
t.Errorf("expected 1 preference, got %d", len(prefs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCount(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
|
||||||
|
s.Store(&Memory{Type: TypeFact, Key: "a", Content: "a"})
|
||||||
|
s.Store(&Memory{Type: TypeFact, Key: "b", Content: "b"})
|
||||||
|
|
||||||
|
count, err := s.Count()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("count: %v", err)
|
||||||
|
}
|
||||||
|
if count != 2 {
|
||||||
|
t.Errorf("expected 2, got %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
94
internal/plugins/hooks.go
Normal file
94
internal/plugins/hooks.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HookType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
BeforeToolCall HookType = "before_tool_call"
|
||||||
|
AfterToolCall HookType = "after_tool_call"
|
||||||
|
OnConversationStart HookType = "on_conversation_start"
|
||||||
|
OnToolError HookType = "on_tool_error"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HookFunc func(ctx context.Context, payload HookPayload) error
|
||||||
|
|
||||||
|
type HookPayload struct {
|
||||||
|
ToolName string `json:"tool_name"`
|
||||||
|
Arguments json.RawMessage `json:"arguments,omitempty"`
|
||||||
|
Response *agent.ToolResponse `json:"response,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Hook struct {
|
||||||
|
Type HookType
|
||||||
|
Plugin string
|
||||||
|
Priority int
|
||||||
|
Fn HookFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
type HookRegistry struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
hooks map[HookType][]Hook
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHookRegistry() *HookRegistry {
|
||||||
|
return &HookRegistry{
|
||||||
|
hooks: make(map[HookType][]Hook),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hr *HookRegistry) Register(hookType HookType, pluginName string, priority int, fn HookFunc) {
|
||||||
|
hr.mu.Lock()
|
||||||
|
defer hr.mu.Unlock()
|
||||||
|
|
||||||
|
h := Hook{
|
||||||
|
Type: hookType,
|
||||||
|
Plugin: pluginName,
|
||||||
|
Priority: priority,
|
||||||
|
Fn: fn,
|
||||||
|
}
|
||||||
|
hr.hooks[hookType] = append(hr.hooks[hookType], h)
|
||||||
|
|
||||||
|
for i := len(hr.hooks[hookType]) - 1; i > 0; i-- {
|
||||||
|
if hr.hooks[hookType][i].Priority < hr.hooks[hookType][i-1].Priority {
|
||||||
|
hr.hooks[hookType][i], hr.hooks[hookType][i-1] = hr.hooks[hookType][i-1], hr.hooks[hookType][i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hr *HookRegistry) Fire(ctx context.Context, hookType HookType, payload HookPayload) error {
|
||||||
|
hr.mu.RLock()
|
||||||
|
hooks := make([]Hook, len(hr.hooks[hookType]))
|
||||||
|
copy(hooks, hr.hooks[hookType])
|
||||||
|
hr.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, h := range hooks {
|
||||||
|
if err := h.Fn(ctx, payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hr *HookRegistry) RemoveByPlugin(pluginName string) {
|
||||||
|
hr.mu.Lock()
|
||||||
|
defer hr.mu.Unlock()
|
||||||
|
|
||||||
|
for hookType := range hr.hooks {
|
||||||
|
filtered := make([]Hook, 0, len(hr.hooks[hookType]))
|
||||||
|
for _, h := range hr.hooks[hookType] {
|
||||||
|
if h.Plugin != pluginName {
|
||||||
|
filtered = append(filtered, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hr.hooks[hookType] = filtered
|
||||||
|
}
|
||||||
|
}
|
||||||
334
internal/plugins/loader.go
Normal file
334
internal/plugins/loader.go
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
func DiscoverPlugins(paths []string) []*DiscoveredPlugin {
|
||||||
|
var plugins []*DiscoveredPlugin
|
||||||
|
|
||||||
|
for _, p := range paths {
|
||||||
|
expanded := expandPath(p)
|
||||||
|
info, err := os.Stat(expanded)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
entries, err := os.ReadDir(expanded)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pluginDir := filepath.Join(expanded, entry.Name())
|
||||||
|
if dp := scanPluginDir(pluginDir); dp != nil {
|
||||||
|
plugins = append(plugins, dp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return plugins
|
||||||
|
}
|
||||||
|
|
||||||
|
type DiscoveredPlugin struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanPluginDir(dir string) *DiscoveredPlugin {
|
||||||
|
name := filepath.Base(dir)
|
||||||
|
dp := &DiscoveredPlugin{
|
||||||
|
Name: name,
|
||||||
|
Path: dir,
|
||||||
|
}
|
||||||
|
|
||||||
|
initPy := filepath.Join(dir, "__init__.py")
|
||||||
|
mainGo := filepath.Join(dir, "main.go")
|
||||||
|
manifest := filepath.Join(dir, "plugin.json")
|
||||||
|
|
||||||
|
if _, err := os.Stat(manifest); err == nil {
|
||||||
|
dp.Type = "manifest"
|
||||||
|
dp.Valid = true
|
||||||
|
return dp
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(mainGo); err == nil {
|
||||||
|
dp.Type = "go"
|
||||||
|
dp.Valid = true
|
||||||
|
return dp
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(initPy); err == nil {
|
||||||
|
dp.Type = "python"
|
||||||
|
dp.Valid = true
|
||||||
|
return dp
|
||||||
|
}
|
||||||
|
|
||||||
|
executables := []string{name, name + ".sh"}
|
||||||
|
for _, exe := range executables {
|
||||||
|
fullPath := filepath.Join(dir, exe)
|
||||||
|
if info, err := os.Stat(fullPath); err == nil && !info.IsDir() {
|
||||||
|
dp.Type = "executable"
|
||||||
|
dp.Valid = true
|
||||||
|
dp.Path = fullPath
|
||||||
|
return dp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dp
|
||||||
|
}
|
||||||
|
|
||||||
|
type PluginManifest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Tools []ManifestTool `json:"tools,omitempty"`
|
||||||
|
Hooks []ManifestHook `json:"hooks,omitempty"`
|
||||||
|
Command string `json:"command,omitempty"`
|
||||||
|
Args []string `json:"args,omitempty"`
|
||||||
|
Env map[string]string `json:"env,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ManifestTool struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Params json.RawMessage `json:"parameters"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ManifestHook struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadManifest(path string) (*PluginManifest, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifest PluginManifest
|
||||||
|
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &manifest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadExecutablePlugin(discovered *DiscoveredPlugin) (*Plugin, error) {
|
||||||
|
if !discovered.Valid {
|
||||||
|
return nil, fmt.Errorf("invalid plugin: %s", discovered.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch discovered.Type {
|
||||||
|
case "manifest":
|
||||||
|
return loadManifestPlugin(discovered)
|
||||||
|
case "executable":
|
||||||
|
return loadExecutableAsPlugin(discovered)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported plugin type: %s", discovered.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadManifestPlugin(dp *DiscoveredPlugin) (*Plugin, error) {
|
||||||
|
manifestPath := filepath.Join(dp.Path, "plugin.json")
|
||||||
|
manifest, err := LoadManifest(manifestPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
p := NewPlugin(manifest.Name, manifest.Version, manifest.Description)
|
||||||
|
|
||||||
|
for _, mt := range manifest.Tools {
|
||||||
|
handler := createExternalHandler(dp.Path, manifest)
|
||||||
|
td := &ToolDefinition{
|
||||||
|
Name: mt.Name,
|
||||||
|
Description: mt.Description,
|
||||||
|
Params: mt.Params,
|
||||||
|
Handler: handler,
|
||||||
|
}
|
||||||
|
p.AddTool(td)
|
||||||
|
}
|
||||||
|
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadExecutableAsPlugin(dp *DiscoveredPlugin) (*Plugin, error) {
|
||||||
|
p := NewPlugin(dp.Name, "0.0.1", "Executable plugin: "+dp.Name)
|
||||||
|
|
||||||
|
paramsSchema, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"action": map[string]string{"type": "string", "description": "Action to execute"},
|
||||||
|
"args": map[string]string{"type": "object", "description": "Arguments for the action"},
|
||||||
|
},
|
||||||
|
"required": []string{"action"},
|
||||||
|
})
|
||||||
|
|
||||||
|
td := &ToolDefinition{
|
||||||
|
Name: dp.Name,
|
||||||
|
Description: "External plugin tool: " + dp.Name,
|
||||||
|
Params: paramsSchema,
|
||||||
|
Handler: createScriptHandler(dp.Path),
|
||||||
|
}
|
||||||
|
p.AddTool(td)
|
||||||
|
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createExternalHandler(pluginDir string, manifest *PluginManifest) func(ctx context.Context, raw json.RawMessage) (agent.ToolResponse, error) {
|
||||||
|
return func(ctx context.Context, raw json.RawMessage) (agent.ToolResponse, error) {
|
||||||
|
if manifest.Command == "" {
|
||||||
|
return agent.TextErrorResponse(fmt.Sprintf("no command configured for plugin %s", manifest.Name)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, manifest.Command, manifest.Args...)
|
||||||
|
cmd.Dir = pluginDir
|
||||||
|
cmd.Stdin = strings.NewReader(string(raw))
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return agent.TextErrorResponse(fmt.Sprintf("plugin execution failed: %v\n%s", err, string(output))), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return agent.TextResponse(string(output)), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createScriptHandler(scriptPath string) func(ctx context.Context, raw json.RawMessage) (agent.ToolResponse, error) {
|
||||||
|
return func(ctx context.Context, raw json.RawMessage) (agent.ToolResponse, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, scriptPath)
|
||||||
|
cmd.Stdin = strings.NewReader(string(raw))
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return agent.TextErrorResponse(fmt.Sprintf("script failed: %v\n%s", err, string(output))), nil
|
||||||
|
}
|
||||||
|
return agent.TextResponse(string(output)), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultPluginPaths() []string {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
configDir, err := configDir()
|
||||||
|
if err != nil {
|
||||||
|
return []string{filepath.Join(home, ".muyue", "plugins")}
|
||||||
|
}
|
||||||
|
|
||||||
|
return []string{
|
||||||
|
filepath.Join(configDir, "plugins"),
|
||||||
|
filepath.Join(home, ".muyue", "plugins"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandPath(p string) string {
|
||||||
|
if strings.HasPrefix(p, "~/") {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
return filepath.Join(home, p[2:])
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func configDir() (string, error) {
|
||||||
|
configDir, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(configDir, "muyue"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generatePluginSchema(v interface{}) (json.RawMessage, error) {
|
||||||
|
t := reflect.TypeOf(v)
|
||||||
|
if t == nil {
|
||||||
|
return json.RawMessage(`{"type":"object","properties":{}}`), nil
|
||||||
|
}
|
||||||
|
if t.Kind() == reflect.Ptr {
|
||||||
|
t = t.Elem()
|
||||||
|
}
|
||||||
|
if t.Kind() != reflect.Struct {
|
||||||
|
return json.RawMessage(`{"type":"object","properties":{}}`), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
props := make(map[string]interface{})
|
||||||
|
required := []string{}
|
||||||
|
|
||||||
|
for i := 0; i < t.NumField(); i++ {
|
||||||
|
field := t.Field(i)
|
||||||
|
if !field.IsExported() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
jsonTag := field.Tag.Get("json")
|
||||||
|
if jsonTag == "-" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
jsonName := field.Name
|
||||||
|
parts := strings.Split(jsonTag, ",")
|
||||||
|
if parts[0] != "" {
|
||||||
|
jsonName = parts[0]
|
||||||
|
}
|
||||||
|
omitempty := false
|
||||||
|
for _, part := range parts[1:] {
|
||||||
|
if part == "omitempty" {
|
||||||
|
omitempty = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
desc := field.Tag.Get("description")
|
||||||
|
prop := map[string]interface{}{"type": goTypeToJSON(field.Type)}
|
||||||
|
if desc != "" {
|
||||||
|
prop["description"] = desc
|
||||||
|
}
|
||||||
|
props[jsonName] = prop
|
||||||
|
if !omitempty {
|
||||||
|
required = append(required, jsonName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
schema := map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": props,
|
||||||
|
}
|
||||||
|
if len(required) > 0 {
|
||||||
|
schema["required"] = required
|
||||||
|
}
|
||||||
|
return json.Marshal(schema)
|
||||||
|
}
|
||||||
|
|
||||||
|
func goTypeToJSON(t reflect.Type) string {
|
||||||
|
switch t.Kind() {
|
||||||
|
case reflect.String:
|
||||||
|
return "string"
|
||||||
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
|
||||||
|
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||||
|
return "integer"
|
||||||
|
case reflect.Float32, reflect.Float64:
|
||||||
|
return "number"
|
||||||
|
case reflect.Bool:
|
||||||
|
return "boolean"
|
||||||
|
case reflect.Slice:
|
||||||
|
if t.Elem().Kind() == reflect.Uint8 {
|
||||||
|
return "string"
|
||||||
|
}
|
||||||
|
return "array"
|
||||||
|
case reflect.Map:
|
||||||
|
return "object"
|
||||||
|
default:
|
||||||
|
return "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
224
internal/plugins/plugin.go
Normal file
224
internal/plugins/plugin.go
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PluginStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusEnabled PluginStatus = "enabled"
|
||||||
|
StatusDisabled PluginStatus = "disabled"
|
||||||
|
StatusError PluginStatus = "error"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Plugin struct {
|
||||||
|
name string
|
||||||
|
version string
|
||||||
|
description string
|
||||||
|
status PluginStatus
|
||||||
|
tools []*agent.ToolDefinition
|
||||||
|
hooks map[HookType]HookFunc
|
||||||
|
init func(ctx context.Context, registry *agent.Registry) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPlugin(name, version, description string) *Plugin {
|
||||||
|
return &Plugin{
|
||||||
|
name: name,
|
||||||
|
version: version,
|
||||||
|
description: description,
|
||||||
|
status: StatusDisabled,
|
||||||
|
tools: make([]*agent.ToolDefinition, 0),
|
||||||
|
hooks: make(map[HookType]HookFunc),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Name() string { return p.name }
|
||||||
|
func (p *Plugin) Version() string { return p.version }
|
||||||
|
func (p *Plugin) Description() string { return p.description }
|
||||||
|
func (p *Plugin) Status() PluginStatus { return p.status }
|
||||||
|
|
||||||
|
func (p *Plugin) AddTool(tool *ToolDefinition) *Plugin {
|
||||||
|
td := &agent.ToolDefinition{
|
||||||
|
Name: tool.Name,
|
||||||
|
Description: tool.Description,
|
||||||
|
Params: tool.Params,
|
||||||
|
Handler: tool.Handler,
|
||||||
|
}
|
||||||
|
p.tools = append(p.tools, td)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) AddToolGeneric(params interface{}, name, description string, handler func(ctx context.Context, raw json.RawMessage) (agent.ToolResponse, error)) *Plugin {
|
||||||
|
paramsSchema, err := generatePluginSchema(params)
|
||||||
|
if err == nil {
|
||||||
|
td := &agent.ToolDefinition{
|
||||||
|
Name: name,
|
||||||
|
Description: description,
|
||||||
|
Params: paramsSchema,
|
||||||
|
Handler: handler,
|
||||||
|
}
|
||||||
|
p.tools = append(p.tools, td)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) AddHook(hookType HookType, fn HookFunc) *Plugin {
|
||||||
|
p.hooks[hookType] = fn
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) SetInit(fn func(ctx context.Context, registry *agent.Registry) error) *Plugin {
|
||||||
|
p.init = fn
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolDefinition struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Params json.RawMessage
|
||||||
|
Handler func(ctx context.Context, args json.RawMessage) (agent.ToolResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type PluginInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Status PluginStatus `json:"status"`
|
||||||
|
ToolCount int `json:"tool_count"`
|
||||||
|
HookTypes []string `json:"hook_types,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Manager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
plugins map[string]*Plugin
|
||||||
|
hooks *HookRegistry
|
||||||
|
enabled map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewManager(hooks *HookRegistry) *Manager {
|
||||||
|
return &Manager{
|
||||||
|
plugins: make(map[string]*Plugin),
|
||||||
|
hooks: hooks,
|
||||||
|
enabled: make(map[string]bool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Register(p *Plugin) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
if _, exists := m.plugins[p.name]; exists {
|
||||||
|
return fmt.Errorf("plugin %q already registered", p.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.plugins[p.name] = p
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Enable(ctx context.Context, name string, registry *agent.Registry) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
p, ok := m.plugins[name]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("plugin %q not found", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.status == StatusEnabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.init != nil {
|
||||||
|
if err := p.init(ctx, registry); err != nil {
|
||||||
|
p.status = StatusError
|
||||||
|
return fmt.Errorf("plugin %q init failed: %w", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tool := range p.tools {
|
||||||
|
if err := registry.Register(tool); err != nil {
|
||||||
|
p.status = StatusError
|
||||||
|
return fmt.Errorf("plugin %q register tool %q: %w", name, tool.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for hookType, fn := range p.hooks {
|
||||||
|
m.hooks.Register(hookType, name, 10, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.status = StatusEnabled
|
||||||
|
m.enabled[name] = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Disable(name string) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
p, ok := m.plugins[name]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.status != StatusEnabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.hooks.RemoveByPlugin(name)
|
||||||
|
p.status = StatusDisabled
|
||||||
|
delete(m.enabled, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Get(name string) (*Plugin, bool) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
p, ok := m.plugins[name]
|
||||||
|
return p, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) List() []PluginInfo {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
result := make([]PluginInfo, 0, len(m.plugins))
|
||||||
|
for _, p := range m.plugins {
|
||||||
|
info := PluginInfo{
|
||||||
|
Name: p.name,
|
||||||
|
Version: p.version,
|
||||||
|
Description: p.description,
|
||||||
|
Status: p.status,
|
||||||
|
ToolCount: len(p.tools),
|
||||||
|
}
|
||||||
|
for ht := range p.hooks {
|
||||||
|
info.HookTypes = append(info.HookTypes, string(ht))
|
||||||
|
}
|
||||||
|
result = append(result, info)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) EnabledNames() []string {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
names := make([]string, 0, len(m.enabled))
|
||||||
|
for name := range m.enabled {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) EnableFromConfig(ctx context.Context, enabledList []string, registry *agent.Registry) {
|
||||||
|
for _, name := range enabledList {
|
||||||
|
if err := m.Enable(ctx, name, registry); err != nil {
|
||||||
|
_ = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
174
internal/rag/chunker.go
Normal file
174
internal/rag/chunker.go
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
package rag
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Chunk struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
StartPos int `json:"start_pos"`
|
||||||
|
EndPos int `json:"end_pos"`
|
||||||
|
Metadata string `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ChunkText(text string, maxTokens int) []Chunk {
|
||||||
|
if maxTokens <= 0 {
|
||||||
|
maxTokens = 500
|
||||||
|
}
|
||||||
|
maxChars := maxTokens * 4
|
||||||
|
if maxChars < 200 {
|
||||||
|
maxChars = 200
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
var chunks []Chunk
|
||||||
|
var current strings.Builder
|
||||||
|
chunkID := 0
|
||||||
|
startPos := 0
|
||||||
|
currentPos := 0
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
lineLen := utf8.RuneCountInString(line) + 1
|
||||||
|
|
||||||
|
if current.Len() > 0 && utf8.RuneCountInString(current.String())+lineLen > maxChars {
|
||||||
|
chunks = append(chunks, Chunk{
|
||||||
|
ID: chunkID,
|
||||||
|
Content: strings.TrimSpace(current.String()),
|
||||||
|
StartPos: startPos,
|
||||||
|
EndPos: currentPos,
|
||||||
|
})
|
||||||
|
chunkID++
|
||||||
|
startPos = currentPos
|
||||||
|
current.Reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
current.WriteString(line)
|
||||||
|
current.WriteString("\n")
|
||||||
|
currentPos += lineLen
|
||||||
|
}
|
||||||
|
|
||||||
|
if current.Len() > 0 {
|
||||||
|
chunks = append(chunks, Chunk{
|
||||||
|
ID: chunkID,
|
||||||
|
Content: strings.TrimSpace(current.String()),
|
||||||
|
StartPos: startPos,
|
||||||
|
EndPos: currentPos,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
func ChunkMarkdown(text string, maxTokens int) []Chunk {
|
||||||
|
if maxTokens <= 0 {
|
||||||
|
maxTokens = 500
|
||||||
|
}
|
||||||
|
maxChars := maxTokens * 4
|
||||||
|
|
||||||
|
sections := splitMarkdownSections(text)
|
||||||
|
var chunks []Chunk
|
||||||
|
chunkID := 0
|
||||||
|
pos := 0
|
||||||
|
|
||||||
|
for _, section := range sections {
|
||||||
|
if utf8.RuneCountInString(section) > maxChars {
|
||||||
|
subChunks := ChunkText(section, maxTokens)
|
||||||
|
for i := range subChunks {
|
||||||
|
subChunks[i].ID = chunkID
|
||||||
|
subChunks[i].StartPos += pos
|
||||||
|
subChunks[i].EndPos += pos
|
||||||
|
chunkID++
|
||||||
|
}
|
||||||
|
chunks = append(chunks, subChunks...)
|
||||||
|
} else {
|
||||||
|
chunks = append(chunks, Chunk{
|
||||||
|
ID: chunkID,
|
||||||
|
Content: strings.TrimSpace(section),
|
||||||
|
StartPos: pos,
|
||||||
|
EndPos: pos + utf8.RuneCountInString(section),
|
||||||
|
})
|
||||||
|
chunkID++
|
||||||
|
}
|
||||||
|
pos += utf8.RuneCountInString(section)
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitMarkdownSections(text string) []string {
|
||||||
|
var sections []string
|
||||||
|
var current strings.Builder
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.HasPrefix(line, "#") || strings.HasPrefix(line, "##") || strings.HasPrefix(line, "###") {
|
||||||
|
if current.Len() > 0 {
|
||||||
|
sections = append(sections, current.String())
|
||||||
|
current.Reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current.WriteString(line)
|
||||||
|
current.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if current.Len() > 0 {
|
||||||
|
sections = append(sections, current.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sections) == 0 && text != "" {
|
||||||
|
sections = []string{text}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections
|
||||||
|
}
|
||||||
|
|
||||||
|
func ChunkCode(code string, lang string, maxTokens int) []Chunk {
|
||||||
|
if maxTokens <= 0 {
|
||||||
|
maxTokens = 300
|
||||||
|
}
|
||||||
|
maxChars := maxTokens * 4
|
||||||
|
|
||||||
|
var chunks []Chunk
|
||||||
|
chunkID := 0
|
||||||
|
pos := 0
|
||||||
|
|
||||||
|
lines := strings.Split(code, "\n")
|
||||||
|
var current strings.Builder
|
||||||
|
currentLines := 0
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
lineLen := utf8.RuneCountInString(line) + 1
|
||||||
|
|
||||||
|
if current.Len() > 0 && (utf8.RuneCountInString(current.String())+lineLen > maxChars || currentLines > 50) {
|
||||||
|
chunks = append(chunks, Chunk{
|
||||||
|
ID: chunkID,
|
||||||
|
Content: strings.TrimSpace(current.String()),
|
||||||
|
StartPos: pos,
|
||||||
|
EndPos: pos + utf8.RuneCountInString(current.String()),
|
||||||
|
Metadata: lang,
|
||||||
|
})
|
||||||
|
chunkID++
|
||||||
|
pos += utf8.RuneCountInString(current.String())
|
||||||
|
current.Reset()
|
||||||
|
currentLines = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
current.WriteString(line)
|
||||||
|
current.WriteString("\n")
|
||||||
|
currentLines++
|
||||||
|
}
|
||||||
|
|
||||||
|
if current.Len() > 0 {
|
||||||
|
chunks = append(chunks, Chunk{
|
||||||
|
ID: chunkID,
|
||||||
|
Content: strings.TrimSpace(current.String()),
|
||||||
|
StartPos: pos,
|
||||||
|
EndPos: pos + utf8.RuneCountInString(current.String()),
|
||||||
|
Metadata: lang,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
113
internal/rag/embed.go
Normal file
113
internal/rag/embed.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package rag
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EmbeddingClient struct {
|
||||||
|
apiKey string
|
||||||
|
baseURL string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEmbeddingClient(apiKey, baseURL string) *EmbeddingClient {
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = "https://api.openai.com/v1"
|
||||||
|
}
|
||||||
|
return &EmbeddingClient{
|
||||||
|
apiKey: apiKey,
|
||||||
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
client: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type embeddingRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Input []string `json:"input"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type embeddingResponse struct {
|
||||||
|
Data []struct {
|
||||||
|
Embedding []float64 `json:"embedding"`
|
||||||
|
Index int `json:"index"`
|
||||||
|
} `json:"data"`
|
||||||
|
Usage struct {
|
||||||
|
TotalTokens int `json:"total_tokens"`
|
||||||
|
} `json:"usage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *EmbeddingClient) Embed(texts []string, model string) ([][]float64, error) {
|
||||||
|
if len(texts) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if model == "" {
|
||||||
|
model = "text-embedding-3-small"
|
||||||
|
}
|
||||||
|
|
||||||
|
body := embeddingRequest{
|
||||||
|
Model: model,
|
||||||
|
Input: texts,
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal embedding request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
url := c.baseURL + "/embeddings"
|
||||||
|
req, err := http.NewRequest("POST", url, bytes.NewReader(bodyBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create embedding request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if c.apiKey != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("send embedding request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read embedding response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("embedding API error (%d): %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var embResp embeddingResponse
|
||||||
|
if err := json.Unmarshal(respBody, &embResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse embedding response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([][]float64, len(texts))
|
||||||
|
for _, data := range embResp.Data {
|
||||||
|
if data.Index < len(result) {
|
||||||
|
result[data.Index] = data.Embedding
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *EmbeddingClient) EmbedSingle(text, model string) ([]float64, error) {
|
||||||
|
results, err := c.Embed([]string{text}, model)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(results) == 0 {
|
||||||
|
return nil, fmt.Errorf("no embedding returned")
|
||||||
|
}
|
||||||
|
return results[0], nil
|
||||||
|
}
|
||||||
79
internal/rag/inject.go
Normal file
79
internal/rag/inject.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package rag
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func BuildContextBlock(results []SearchResult, maxTokens int) string {
|
||||||
|
if len(results) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if maxTokens <= 0 {
|
||||||
|
maxTokens = 4000
|
||||||
|
}
|
||||||
|
maxChars := maxTokens * 4
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("<rag_context>\n")
|
||||||
|
b.WriteString("The following context was retrieved from indexed documents to help answer the user's question.\n\n")
|
||||||
|
|
||||||
|
for i, r := range results {
|
||||||
|
entry := fmt.Sprintf("--- Source: %s (relevance: %.2f) ---\n%s\n\n", r.DocumentName, r.Score, r.Content)
|
||||||
|
if b.Len()+len(entry) > maxChars {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
b.WriteString(entry)
|
||||||
|
_ = i
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("</rag_context>\n")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractRAGQueries(message string) (queries []string, cleaned string) {
|
||||||
|
cleaned = message
|
||||||
|
parts := strings.Split(message, "#")
|
||||||
|
if len(parts) <= 1 {
|
||||||
|
return nil, message
|
||||||
|
}
|
||||||
|
|
||||||
|
var queryParts []string
|
||||||
|
var textParts []string
|
||||||
|
|
||||||
|
for i, part := range parts {
|
||||||
|
if i == 0 {
|
||||||
|
textParts = append(textParts, part)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
firstSpace := strings.IndexByte(part, ' ')
|
||||||
|
newline := strings.IndexByte(part, '\n')
|
||||||
|
|
||||||
|
end := len(part)
|
||||||
|
if firstSpace > 0 && (newline < 0 || firstSpace < newline) {
|
||||||
|
end = firstSpace
|
||||||
|
} else if newline > 0 {
|
||||||
|
end = newline
|
||||||
|
}
|
||||||
|
|
||||||
|
query := strings.TrimSpace(part[:end])
|
||||||
|
if query != "" {
|
||||||
|
queryParts = append(queryParts, query)
|
||||||
|
}
|
||||||
|
if end < len(part) {
|
||||||
|
textParts = append(textParts, part[end:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(queryParts) > 0 {
|
||||||
|
cleaned = strings.Join(textParts, " ")
|
||||||
|
cleaned = strings.TrimSpace(cleaned)
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryParts, cleaned
|
||||||
|
}
|
||||||
343
internal/rag/store.go
Normal file
343
internal/rag/store.go
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
package rag
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Document struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Chunks int `json:"chunks"`
|
||||||
|
IndexedAt time.Time `json:"indexed_at"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChunkRecord struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
DocumentID string `json:"document_id"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Embedding []float64 `json:"embedding,omitempty"`
|
||||||
|
StartPos int `json:"start_pos"`
|
||||||
|
EndPos int `json:"end_pos"`
|
||||||
|
Metadata string `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
db *sql.DB
|
||||||
|
dir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(configDir string) (*Store, error) {
|
||||||
|
ragDir := filepath.Join(configDir, "rag")
|
||||||
|
if err := os.MkdirAll(ragDir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("creating rag dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dbPath := filepath.Join(ragDir, "rag.db")
|
||||||
|
db, err := sql.Open("sqlite", dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("opening rag db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &Store{db: db, dir: ragDir}
|
||||||
|
if err := s.migrate(); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf("migrating rag db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) migrate() error {
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS documents (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL DEFAULT '',
|
||||||
|
type TEXT NOT NULL DEFAULT 'text',
|
||||||
|
chunks INTEGER NOT NULL DEFAULT 0,
|
||||||
|
indexed_at DATETIME NOT NULL,
|
||||||
|
size INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS chunks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
embedding BLOB,
|
||||||
|
start_pos INTEGER NOT NULL DEFAULT 0,
|
||||||
|
end_pos INTEGER NOT NULL DEFAULT 0,
|
||||||
|
metadata TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chunks_document ON chunks(document_id);
|
||||||
|
`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) StoreDocument(doc Document, chunks []ChunkRecord) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
_, err = tx.Exec(`INSERT OR REPLACE INTO documents (id, name, path, type, chunks, indexed_at, size) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
doc.ID, doc.Name, doc.Path, doc.Type, doc.Chunks, doc.IndexedAt, doc.Size)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert document: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stmt, err := tx.Prepare(`INSERT INTO chunks (document_id, content, embedding, start_pos, end_pos, metadata) VALUES (?, ?, ?, ?, ?, ?)`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("prepare chunk insert: %w", err)
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
var embBytes []byte
|
||||||
|
if len(chunk.Embedding) > 0 {
|
||||||
|
embBytes, err = json.Marshal(chunk.Embedding)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal embedding: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err = stmt.Exec(chunk.DocumentID, chunk.Content, embBytes, chunk.StartPos, chunk.EndPos, chunk.Metadata)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert chunk: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListDocuments() ([]Document, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
rows, err := s.db.Query(`SELECT id, name, path, type, chunks, indexed_at, size FROM documents ORDER BY indexed_at DESC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var docs []Document
|
||||||
|
for rows.Next() {
|
||||||
|
var doc Document
|
||||||
|
if err := rows.Scan(&doc.ID, &doc.Name, &doc.Path, &doc.Type, &doc.Chunks, &doc.IndexedAt, &doc.Size); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
docs = append(docs, doc)
|
||||||
|
}
|
||||||
|
return docs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteDocument(id string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
_, err := s.db.Exec(`DELETE FROM documents WHERE id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchResult struct {
|
||||||
|
ChunkID int64 `json:"chunk_id"`
|
||||||
|
DocumentID string `json:"document_id"`
|
||||||
|
DocumentName string `json:"document_name"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
Metadata string `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Search(queryEmbedding []float64, limit int) ([]SearchResult, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
rows, err := s.db.Query(`SELECT c.id, c.document_id, c.content, c.embedding, c.metadata, d.name FROM chunks c JOIN documents d ON c.document_id = d.id WHERE c.embedding IS NOT NULL`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type scored struct {
|
||||||
|
result SearchResult
|
||||||
|
score float64
|
||||||
|
}
|
||||||
|
var results []scored
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
var docID, content, metadata, docName string
|
||||||
|
var embBytes []byte
|
||||||
|
if err := rows.Scan(&id, &docID, &content, &embBytes, &metadata, &docName); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var embedding []float64
|
||||||
|
if err := json.Unmarshal(embBytes, &embedding); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
score := cosineSimilarity(queryEmbedding, embedding)
|
||||||
|
results = append(results, scored{
|
||||||
|
result: SearchResult{
|
||||||
|
ChunkID: id,
|
||||||
|
DocumentID: docID,
|
||||||
|
DocumentName: docName,
|
||||||
|
Content: content,
|
||||||
|
Metadata: metadata,
|
||||||
|
},
|
||||||
|
score: score,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < len(results); i++ {
|
||||||
|
for j := i + 1; j < len(results); j++ {
|
||||||
|
if results[j].score > results[i].score {
|
||||||
|
results[i], results[j] = results[j], results[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) > limit {
|
||||||
|
results = results[:limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]SearchResult, len(results))
|
||||||
|
for i, r := range results {
|
||||||
|
r.result.Score = r.score
|
||||||
|
out[i] = r.result
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SearchKeyword(query string, limit int) ([]SearchResult, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
words := strings.Fields(strings.ToLower(query))
|
||||||
|
if len(words) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.db.Query(`SELECT c.id, c.document_id, c.content, c.metadata, d.name FROM chunks c JOIN documents d ON c.document_id = d.id`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type scored struct {
|
||||||
|
result SearchResult
|
||||||
|
score float64
|
||||||
|
}
|
||||||
|
var results []scored
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
var docID, content, metadata, docName string
|
||||||
|
if err := rows.Scan(&id, &docID, &content, &metadata, &docName); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
lower := strings.ToLower(content)
|
||||||
|
var score float64
|
||||||
|
for _, word := range words {
|
||||||
|
count := strings.Count(lower, word)
|
||||||
|
if count > 0 {
|
||||||
|
score += float64(count) / float64(len(strings.Fields(lower)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if score > 0 {
|
||||||
|
results = append(results, scored{
|
||||||
|
result: SearchResult{
|
||||||
|
ChunkID: id,
|
||||||
|
DocumentID: docID,
|
||||||
|
DocumentName: docName,
|
||||||
|
Content: content,
|
||||||
|
Metadata: metadata,
|
||||||
|
},
|
||||||
|
score: score,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < len(results); i++ {
|
||||||
|
for j := i + 1; j < len(results); j++ {
|
||||||
|
if results[j].score > results[i].score {
|
||||||
|
results[i], results[j] = results[j], results[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) > limit {
|
||||||
|
results = results[:limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]SearchResult, len(results))
|
||||||
|
for i, r := range results {
|
||||||
|
r.result.Score = r.score
|
||||||
|
out[i] = r.result
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Status() (map[string]interface{}, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
var docCount, chunkCount int
|
||||||
|
s.db.QueryRow(`SELECT COUNT(*) FROM documents`).Scan(&docCount)
|
||||||
|
s.db.QueryRow(`SELECT COUNT(*) FROM chunks`).Scan(&chunkCount)
|
||||||
|
var withEmb int
|
||||||
|
s.db.QueryRow(`SELECT COUNT(*) FROM chunks WHERE embedding IS NOT NULL`).Scan(&withEmb)
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"documents": docCount,
|
||||||
|
"chunks": chunkCount,
|
||||||
|
"chunks_embedded": withEmb,
|
||||||
|
"storage_path": s.dir,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Close() error {
|
||||||
|
return s.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cosineSimilarity(a, b []float64) float64 {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var dot, normA, normB float64
|
||||||
|
for i := range a {
|
||||||
|
dot += a[i] * b[i]
|
||||||
|
normA += a[i] * a[i]
|
||||||
|
normB += b[i] * b[i]
|
||||||
|
}
|
||||||
|
if normA == 0 || normB == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return dot / (math.Sqrt(normA) * math.Sqrt(normB))
|
||||||
|
}
|
||||||
177
internal/skills/advanced_test.go
Normal file
177
internal/skills/advanced_test.go
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
package skills
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCheckActivationNoConditions(t *testing.T) {
|
||||||
|
skill := &Skill{
|
||||||
|
Name: "test-skill",
|
||||||
|
Description: "A test skill",
|
||||||
|
}
|
||||||
|
|
||||||
|
result := CheckActivation(skill, []string{"terminal"})
|
||||||
|
if !result.Active {
|
||||||
|
t.Error("expected skill with no conditions to be active")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckActivationRequiresTools(t *testing.T) {
|
||||||
|
skill := &Skill{
|
||||||
|
Name: "docker-setup",
|
||||||
|
RequiresTools: []string{"terminal", "docker"},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := CheckActivation(skill, []string{"terminal", "docker"})
|
||||||
|
if !result.Active {
|
||||||
|
t.Error("expected skill to be active when all required tools present")
|
||||||
|
}
|
||||||
|
|
||||||
|
result = CheckActivation(skill, []string{"terminal"})
|
||||||
|
if result.Active {
|
||||||
|
t.Error("expected skill to be inactive when required tool missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckActivationFallbackForTools(t *testing.T) {
|
||||||
|
skill := &Skill{
|
||||||
|
Name: "basic-review",
|
||||||
|
FallbackForTools: []string{"crush_run", "claude_run"},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := CheckActivation(skill, []string{"terminal"})
|
||||||
|
if !result.Active {
|
||||||
|
t.Error("expected fallback skill to activate when primary tools absent")
|
||||||
|
}
|
||||||
|
|
||||||
|
result = CheckActivation(skill, []string{"crush_run", "claude_run"})
|
||||||
|
if result.Active {
|
||||||
|
t.Error("expected fallback skill to stay inactive when primary tools present")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterActiveSkills(t *testing.T) {
|
||||||
|
skills := []Skill{
|
||||||
|
{Name: "basic", Description: "basic"},
|
||||||
|
{Name: "needs-docker", RequiresTools: []string{"docker"}},
|
||||||
|
{Name: "fallback-review", FallbackForTools: []string{"crush_run"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
active := FilterActiveSkills(skills, []string{"terminal"})
|
||||||
|
if len(active) != 2 {
|
||||||
|
t.Errorf("expected 2 active skills, got %d", len(active))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupByReadiness(t *testing.T) {
|
||||||
|
skills := []Skill{
|
||||||
|
{Name: "basic", Description: "basic"},
|
||||||
|
{Name: "needs-docker", RequiresTools: []string{"docker"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
available, needsSetup, unsupported := GroupByReadiness(skills, []string{})
|
||||||
|
if len(available) != 1 {
|
||||||
|
t.Errorf("expected 1 available, got %d", len(available))
|
||||||
|
}
|
||||||
|
if len(unsupported) != 1 {
|
||||||
|
t.Errorf("expected 1 unsupported, got %d", len(unsupported))
|
||||||
|
}
|
||||||
|
_ = needsSetup
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnalyzeConversation(t *testing.T) {
|
||||||
|
snippets := []ConversationSnippet{
|
||||||
|
{Role: "assistant", Content: "go test ./... -race", Timestamp: time.Now()},
|
||||||
|
{Role: "assistant", Content: "go test ./... -race -cover", Timestamp: time.Now()},
|
||||||
|
{Role: "assistant", Content: "go test ./internal/... -v", Timestamp: time.Now()},
|
||||||
|
}
|
||||||
|
|
||||||
|
proposals := AnalyzeConversation(snippets)
|
||||||
|
if len(proposals) == 0 {
|
||||||
|
t.Error("expected at least one proposal from recurring patterns")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range proposals {
|
||||||
|
if p.Confidence <= 0 {
|
||||||
|
t.Error("expected positive confidence")
|
||||||
|
}
|
||||||
|
if p.CreatedFrom != "conversation" {
|
||||||
|
t.Errorf("expected created_from=conversation, got %s", p.CreatedFrom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCategorize(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
pattern string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"go test", "testing"},
|
||||||
|
{"docker build", "devops"},
|
||||||
|
{"git commit", "workflow"},
|
||||||
|
{"npm test", "testing"},
|
||||||
|
{"make", "build"},
|
||||||
|
{"unknown", "general"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := categorize(tt.pattern)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("categorize(%q) = %q, want %q", tt.pattern, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImproverAnalyze(t *testing.T) {
|
||||||
|
improver, err := NewSkillImprover()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new improver: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
skill := &Skill{
|
||||||
|
Name: "test-skill",
|
||||||
|
Description: "A test skill",
|
||||||
|
Content: "# Test\n\nSome basic content without structure.",
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestions, err := improver.Analyze(skill, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("analyze: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(suggestions) == 0 {
|
||||||
|
t.Error("expected improvement suggestions for minimal skill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImproverAnalyzeComplete(t *testing.T) {
|
||||||
|
improver, _ := NewSkillImprover()
|
||||||
|
|
||||||
|
skill := &Skill{
|
||||||
|
Name: "complete-skill",
|
||||||
|
Description: "A well-structured skill",
|
||||||
|
Content: `# Complete Skill
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Do step one
|
||||||
|
2. Do step two
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- Handle error A
|
||||||
|
- Handle error B
|
||||||
|
|
||||||
|
## When to use
|
||||||
|
|
||||||
|
Use this skill when doing X.
|
||||||
|
`,
|
||||||
|
Tags: []string{"testing", "go"},
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestions, _ := improver.Analyze(skill, "testing go code")
|
||||||
|
if len(suggestions) > 2 {
|
||||||
|
t.Errorf("expected few suggestions for complete skill, got %d", len(suggestions))
|
||||||
|
}
|
||||||
|
}
|
||||||
282
internal/skills/auto_create.go
Normal file
282
internal/skills/auto_create.go
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
package skills
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PatternMatch struct {
|
||||||
|
Pattern string
|
||||||
|
Count int
|
||||||
|
LastSeen time.Time
|
||||||
|
ExampleText string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AutoCreateProposal struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
SuggestedTags []string
|
||||||
|
Category string
|
||||||
|
Patterns []PatternMatch
|
||||||
|
Confidence float64
|
||||||
|
CreatedFrom string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConversationSnippet struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func AnalyzeConversation(snippets []ConversationSnippet) []AutoCreateProposal {
|
||||||
|
patterns := detectPatterns(snippets)
|
||||||
|
var proposals []AutoCreateProposal
|
||||||
|
|
||||||
|
for _, p := range patterns {
|
||||||
|
if p.Count < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name := generateSkillName(p.Pattern)
|
||||||
|
proposal := AutoCreateProposal{
|
||||||
|
Name: name,
|
||||||
|
Description: fmt.Sprintf("Auto-detected skill for recurring pattern: %s", p.Pattern),
|
||||||
|
SuggestedTags: extractTags(p.Pattern),
|
||||||
|
Category: categorize(p.Pattern),
|
||||||
|
Patterns: []PatternMatch{p},
|
||||||
|
Confidence: computeConfidence(p),
|
||||||
|
CreatedFrom: "conversation",
|
||||||
|
}
|
||||||
|
proposals = append(proposals, proposal)
|
||||||
|
}
|
||||||
|
|
||||||
|
return proposals
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateFromProposal(proposal *AutoCreateProposal) (*Skill, error) {
|
||||||
|
skill := &Skill{
|
||||||
|
Name: proposal.Name,
|
||||||
|
Description: proposal.Description,
|
||||||
|
Author: "muyue-auto",
|
||||||
|
Version: "0.1.0",
|
||||||
|
Tags: proposal.SuggestedTags,
|
||||||
|
Category: proposal.Category,
|
||||||
|
Target: "both",
|
||||||
|
CreatedFrom: proposal.CreatedFrom,
|
||||||
|
AutoImprove: true,
|
||||||
|
Content: buildAutoSkillContent(proposal),
|
||||||
|
}
|
||||||
|
|
||||||
|
return skill, Create(skill)
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadProposals() ([]AutoCreateProposal, error) {
|
||||||
|
dir, err := proposalsDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var proposals []AutoCreateProposal
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var p AutoCreateProposal
|
||||||
|
if err := json.Unmarshal(data, &p); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
proposals = append(proposals, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return proposals, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveProposal(proposal *AutoCreateProposal) error {
|
||||||
|
dir, err := proposalsDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(proposal, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(dir, proposal.Name+".json")
|
||||||
|
return os.WriteFile(path, data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteProposal(name string) error {
|
||||||
|
dir, err := proposalsDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, name+".json")
|
||||||
|
return os.Remove(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func proposalsDir() (string, error) {
|
||||||
|
dir, err := SkillsDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(filepath.Dir(dir), ".muyue", "proposals"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectPatterns(snippets []ConversationSnippet) []PatternMatch {
|
||||||
|
commandPatterns := make(map[string]*PatternMatch)
|
||||||
|
|
||||||
|
for _, s := range snippets {
|
||||||
|
if s.Role != "assistant" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines := strings.Split(s.Content, "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if isCommandPattern(line) {
|
||||||
|
key := extractPatternKey(line)
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if existing, ok := commandPatterns[key]; ok {
|
||||||
|
existing.Count++
|
||||||
|
if s.Timestamp.After(existing.LastSeen) {
|
||||||
|
existing.LastSeen = s.Timestamp
|
||||||
|
existing.ExampleText = truncate(line, 200)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
commandPatterns[key] = &PatternMatch{
|
||||||
|
Pattern: key,
|
||||||
|
Count: 1,
|
||||||
|
LastSeen: s.Timestamp,
|
||||||
|
ExampleText: truncate(line, 200),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var patterns []PatternMatch
|
||||||
|
for _, p := range commandPatterns {
|
||||||
|
patterns = append(patterns, *p)
|
||||||
|
}
|
||||||
|
return patterns
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCommandPattern(line string) bool {
|
||||||
|
toolPrefixes := []string{"go test", "go build", "go run", "npm test", "npm run",
|
||||||
|
"docker build", "docker run", "git commit", "git push", "kubectl",
|
||||||
|
"cargo test", "cargo build", "pytest", "make "}
|
||||||
|
for _, prefix := range toolPrefixes {
|
||||||
|
if strings.HasPrefix(line, prefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractPatternKey(line string) string {
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(parts) >= 3 && (parts[0] == "go" || parts[0] == "npm" || parts[0] == "cargo" || parts[0] == "git" || parts[0] == "docker") {
|
||||||
|
return parts[0] + " " + parts[1]
|
||||||
|
}
|
||||||
|
return parts[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateSkillName(pattern string) string {
|
||||||
|
name := strings.ReplaceAll(pattern, " ", "-")
|
||||||
|
name = strings.ToLower(name)
|
||||||
|
if len(name) > 30 {
|
||||||
|
name = name[:30]
|
||||||
|
}
|
||||||
|
h := sha256.Sum256([]byte(pattern))
|
||||||
|
return fmt.Sprintf("auto-%s-%x", name, h[:4])
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractTags(pattern string) []string {
|
||||||
|
var tags []string
|
||||||
|
parts := strings.Fields(pattern)
|
||||||
|
for _, p := range parts {
|
||||||
|
if len(p) > 2 {
|
||||||
|
tags = append(tags, strings.ToLower(p))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
func categorize(pattern string) string {
|
||||||
|
categories := map[string]string{
|
||||||
|
"go test": "testing", "go build": "build", "go run": "build",
|
||||||
|
"npm test": "testing", "npm run": "build",
|
||||||
|
"docker build": "devops", "docker run": "devops",
|
||||||
|
"git commit": "workflow", "git push": "workflow",
|
||||||
|
"kubectl": "devops", "cargo test": "testing",
|
||||||
|
"cargo build": "build", "pytest": "testing",
|
||||||
|
"make": "build",
|
||||||
|
}
|
||||||
|
for prefix, cat := range categories {
|
||||||
|
if strings.HasPrefix(pattern, prefix) {
|
||||||
|
return cat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "general"
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeConfidence(p PatternMatch) float64 {
|
||||||
|
confidence := 0.3
|
||||||
|
confidence += float64(p.Count) * 0.1
|
||||||
|
if confidence > 0.95 {
|
||||||
|
confidence = 0.95
|
||||||
|
}
|
||||||
|
return confidence
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAutoSkillContent(proposal *AutoCreateProposal) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(fmt.Sprintf("# %s\n\n", strings.Title(proposal.Name)))
|
||||||
|
b.WriteString("Auto-generated skill based on recurring patterns detected in conversations.\n\n")
|
||||||
|
b.WriteString("## Activation\n\n")
|
||||||
|
b.WriteString("This skill activates when the following patterns are detected:\n\n")
|
||||||
|
for _, p := range proposal.Patterns {
|
||||||
|
b.WriteString(fmt.Sprintf("- `%s` (seen %d times)\n", p.Pattern, p.Count))
|
||||||
|
}
|
||||||
|
b.WriteString("\n## Instructions\n\n")
|
||||||
|
b.WriteString("1. Detect the pattern context from the user request\n")
|
||||||
|
b.WriteString("2. Apply the standard workflow for this pattern\n")
|
||||||
|
b.WriteString("3. Handle common errors and edge cases\n")
|
||||||
|
b.WriteString("4. Verify the result\n\n")
|
||||||
|
b.WriteString("## Error Handling\n\n")
|
||||||
|
b.WriteString("- If a command fails, check for missing dependencies\n")
|
||||||
|
b.WriteString("- Suggest alternative approaches when the standard pattern doesn't fit\n")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, maxLen int) string {
|
||||||
|
if len(s) <= maxLen {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:maxLen]
|
||||||
|
}
|
||||||
125
internal/skills/conditional.go
Normal file
125
internal/skills/conditional.go
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
package skills
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ActivationResult struct {
|
||||||
|
Active bool
|
||||||
|
Reason string
|
||||||
|
Skill *Skill
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckActivation(skill *Skill, availableTools []string) ActivationResult {
|
||||||
|
if len(skill.RequiresTools) == 0 && len(skill.FallbackForTools) == 0 {
|
||||||
|
return ActivationResult{
|
||||||
|
Active: true,
|
||||||
|
Reason: "no activation conditions",
|
||||||
|
Skill: skill,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toolSet := make(map[string]bool, len(availableTools))
|
||||||
|
for _, t := range availableTools {
|
||||||
|
toolSet[strings.ToLower(t)] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(skill.RequiresTools) > 0 {
|
||||||
|
for _, req := range skill.RequiresTools {
|
||||||
|
if !toolSet[strings.ToLower(req)] {
|
||||||
|
return ActivationResult{
|
||||||
|
Active: false,
|
||||||
|
Reason: "missing required tool: " + req,
|
||||||
|
Skill: skill,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ActivationResult{
|
||||||
|
Active: true,
|
||||||
|
Reason: "all required tools available",
|
||||||
|
Skill: skill,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(skill.FallbackForTools) > 0 {
|
||||||
|
allPresent := true
|
||||||
|
for _, fb := range skill.FallbackForTools {
|
||||||
|
if !toolSet[strings.ToLower(fb)] {
|
||||||
|
allPresent = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if allPresent {
|
||||||
|
return ActivationResult{
|
||||||
|
Active: false,
|
||||||
|
Reason: "primary tools available, fallback not needed",
|
||||||
|
Skill: skill,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ActivationResult{
|
||||||
|
Active: true,
|
||||||
|
Reason: "primary tools absent, activating as fallback",
|
||||||
|
Skill: skill,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ActivationResult{Active: true, Skill: skill}
|
||||||
|
}
|
||||||
|
|
||||||
|
func FilterActiveSkills(skillsList []Skill, availableTools []string) []Skill {
|
||||||
|
var active []Skill
|
||||||
|
for i := range skillsList {
|
||||||
|
result := CheckActivation(&skillsList[i], availableTools)
|
||||||
|
if result.Active {
|
||||||
|
active = append(active, skillsList[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return active
|
||||||
|
}
|
||||||
|
|
||||||
|
func GroupByReadiness(skillsList []Skill, availableTools []string) (available, needsSetup, unsupported []Skill) {
|
||||||
|
toolSet := make(map[string]bool, len(availableTools))
|
||||||
|
for _, t := range availableTools {
|
||||||
|
toolSet[strings.ToLower(t)] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range skillsList {
|
||||||
|
s := &skillsList[i]
|
||||||
|
if len(s.RequiresTools) == 0 && len(s.FallbackForTools) == 0 {
|
||||||
|
missing := CheckDependencies(s)
|
||||||
|
if len(missing) == 0 {
|
||||||
|
available = append(available, *s)
|
||||||
|
} else {
|
||||||
|
needsSetup = append(needsSetup, *s)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
allReqMet := true
|
||||||
|
for _, req := range s.RequiresTools {
|
||||||
|
if !toolSet[strings.ToLower(req)] {
|
||||||
|
allReqMet = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if allReqMet && len(s.RequiresTools) > 0 {
|
||||||
|
available = append(available, *s)
|
||||||
|
} else if !allReqMet && len(s.RequiresTools) > 0 {
|
||||||
|
unsupported = append(unsupported, *s)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(s.FallbackForTools) > 0 {
|
||||||
|
anyMissing := false
|
||||||
|
for _, fb := range s.FallbackForTools {
|
||||||
|
if !toolSet[strings.ToLower(fb)] {
|
||||||
|
anyMissing = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if anyMissing {
|
||||||
|
available = append(available, *s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
267
internal/skills/improver.go
Normal file
267
internal/skills/improver.go
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
package skills
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ImprovementSuggestion struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Section string `json:"section"`
|
||||||
|
Current string `json:"current"`
|
||||||
|
Suggested string `json:"suggested"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Confidence float64 `json:"confidence"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImprovementHistory struct {
|
||||||
|
SkillName string `json:"skill_name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Improvements []ImprovementSuggestion `json:"improvements"`
|
||||||
|
AppliedAt time.Time `json:"applied_at"`
|
||||||
|
Result string `json:"result"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SkillImprover struct {
|
||||||
|
historyDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSkillImprover() (*SkillImprover, error) {
|
||||||
|
dir, err := improvementHistoryDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &SkillImprover{historyDir: dir}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (si *SkillImprover) Analyze(skill *Skill, conversationContext string) ([]ImprovementSuggestion, error) {
|
||||||
|
var suggestions []ImprovementSuggestion
|
||||||
|
|
||||||
|
if skill.Content == "" {
|
||||||
|
return nil, fmt.Errorf("skill has no content to analyze")
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestions = append(suggestions, si.checkMissingSections(skill)...)
|
||||||
|
suggestions = append(suggestions, si.checkErrorHandling(skill)...)
|
||||||
|
suggestions = append(suggestions, si.checkStepCompleteness(skill)...)
|
||||||
|
suggestions = append(suggestions, si.analyzeContextRelevance(skill, conversationContext)...)
|
||||||
|
|
||||||
|
return suggestions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (si *SkillImprover) ApplyImprovement(skillName string, suggestion ImprovementSuggestion) error {
|
||||||
|
skill, err := Get(skillName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get skill: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch suggestion.Section {
|
||||||
|
case "content":
|
||||||
|
skill.Content = applyContentSuggestion(skill.Content, suggestion)
|
||||||
|
case "description":
|
||||||
|
skill.Description = suggestion.Suggested
|
||||||
|
default:
|
||||||
|
skill.Content = applyContentSuggestion(skill.Content, suggestion)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
skill.LastImprovedAt = &now
|
||||||
|
skill.ImprovementCount++
|
||||||
|
|
||||||
|
if err := Update(skill); err != nil {
|
||||||
|
return fmt.Errorf("update skill: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
history := ImprovementHistory{
|
||||||
|
SkillName: skillName,
|
||||||
|
Version: skill.Version,
|
||||||
|
Improvements: []ImprovementSuggestion{suggestion},
|
||||||
|
AppliedAt: now,
|
||||||
|
Result: "applied",
|
||||||
|
}
|
||||||
|
|
||||||
|
return si.saveHistory(&history)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (si *SkillImprover) GetHistory(skillName string) ([]ImprovementHistory, error) {
|
||||||
|
if err := os.MkdirAll(si.historyDir, 0755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(si.historyDir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var histories []ImprovementHistory
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if skillName != "" && !strings.HasPrefix(e.Name(), skillName+"_") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(si.historyDir, e.Name()))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var h ImprovementHistory
|
||||||
|
if err := json.Unmarshal(data, &h); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
histories = append(histories, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
return histories, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (si *SkillImprover) checkMissingSections(skill *Skill) []ImprovementSuggestion {
|
||||||
|
var suggestions []ImprovementSuggestion
|
||||||
|
content := strings.ToLower(skill.Content)
|
||||||
|
|
||||||
|
requiredSections := []struct {
|
||||||
|
keyword string
|
||||||
|
label string
|
||||||
|
}{
|
||||||
|
{"error handling", "Error Handling"},
|
||||||
|
{"steps", "Steps"},
|
||||||
|
{"when to", "Activation"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, req := range requiredSections {
|
||||||
|
if !strings.Contains(content, req.keyword) {
|
||||||
|
suggestions = append(suggestions, ImprovementSuggestion{
|
||||||
|
Type: "missing_section",
|
||||||
|
Section: "content",
|
||||||
|
Current: "",
|
||||||
|
Suggested: fmt.Sprintf("Add a '%s' section", req.label),
|
||||||
|
Reason: fmt.Sprintf("Skill is missing a '%s' section which is important for completeness", req.label),
|
||||||
|
Confidence: 0.8,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return suggestions
|
||||||
|
}
|
||||||
|
|
||||||
|
func (si *SkillImprover) checkErrorHandling(skill *Skill) []ImprovementSuggestion {
|
||||||
|
var suggestions []ImprovementSuggestion
|
||||||
|
content := strings.ToLower(skill.Content)
|
||||||
|
|
||||||
|
if !strings.Contains(content, "error") && !strings.Contains(content, "fail") {
|
||||||
|
suggestions = append(suggestions, ImprovementSuggestion{
|
||||||
|
Type: "missing_error_handling",
|
||||||
|
Section: "content",
|
||||||
|
Current: "",
|
||||||
|
Suggested: "Add error handling guidance covering common failure modes",
|
||||||
|
Reason: "Skill lacks error handling guidance, which may lead to poor user experience when things go wrong",
|
||||||
|
Confidence: 0.85,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return suggestions
|
||||||
|
}
|
||||||
|
|
||||||
|
func (si *SkillImprover) checkStepCompleteness(skill *Skill) []ImprovementSuggestion {
|
||||||
|
var suggestions []ImprovementSuggestion
|
||||||
|
|
||||||
|
lines := strings.Split(skill.Content, "\n")
|
||||||
|
stepCount := 0
|
||||||
|
for _, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(trimmed, "1.") || strings.HasPrefix(trimmed, "Step 1") {
|
||||||
|
stepCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if stepCount == 0 && len(lines) > 10 {
|
||||||
|
suggestions = append(suggestions, ImprovementSuggestion{
|
||||||
|
Type: "no_clear_steps",
|
||||||
|
Section: "content",
|
||||||
|
Current: "",
|
||||||
|
Suggested: "Add numbered step-by-step instructions for clarity",
|
||||||
|
Reason: "Long skill content without clear step-by-step structure can be hard to follow",
|
||||||
|
Confidence: 0.7,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return suggestions
|
||||||
|
}
|
||||||
|
|
||||||
|
func (si *SkillImprover) analyzeContextRelevance(skill *Skill, context string) []ImprovementSuggestion {
|
||||||
|
if context == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var suggestions []ImprovementSuggestion
|
||||||
|
contextLower := strings.ToLower(context)
|
||||||
|
tags := skill.Tags
|
||||||
|
relevance := 0
|
||||||
|
|
||||||
|
for _, tag := range tags {
|
||||||
|
if strings.Contains(contextLower, strings.ToLower(tag)) {
|
||||||
|
relevance++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tags) > 0 && relevance == 0 && skill.Category != "" && !strings.Contains(contextLower, strings.ToLower(skill.Category)) {
|
||||||
|
suggestions = append(suggestions, ImprovementSuggestion{
|
||||||
|
Type: "tag_relevance",
|
||||||
|
Section: "tags",
|
||||||
|
Current: strings.Join(tags, ", "),
|
||||||
|
Suggested: "Review tags for better context matching",
|
||||||
|
Reason: "Current tags do not match recent conversation context, suggesting tags may need updating",
|
||||||
|
Confidence: 0.5,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return suggestions
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyContentSuggestion(content string, suggestion ImprovementSuggestion) string {
|
||||||
|
switch suggestion.Type {
|
||||||
|
case "missing_section":
|
||||||
|
return content + "\n\n## " + strings.Title(suggestion.Type) + "\n\n" + suggestion.Suggested + ".\n"
|
||||||
|
case "missing_error_handling":
|
||||||
|
return content + "\n\n## Error Handling\n\n- Handle common failure modes gracefully\n- Provide clear error messages\n- Suggest alternative approaches\n"
|
||||||
|
case "no_clear_steps":
|
||||||
|
return "## Steps\n\n1. Review the skill context\n2. Apply the appropriate pattern\n3. Verify the result\n\n" + content
|
||||||
|
default:
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (si *SkillImprover) saveHistory(history *ImprovementHistory) error {
|
||||||
|
if err := os.MkdirAll(si.historyDir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("%s_%s.json", history.SkillName, history.AppliedAt.Format("20060102-150405"))
|
||||||
|
data, err := json.MarshalIndent(history, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(filepath.Join(si.historyDir, filename), data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func improvementHistoryDir() (string, error) {
|
||||||
|
dir, err := SkillsDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(filepath.Dir(dir), ".muyue", "improvements"), nil
|
||||||
|
}
|
||||||
@@ -20,20 +20,26 @@ type SkillDependency struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Skill struct {
|
type Skill struct {
|
||||||
Name string `yaml:"name" json:"name"`
|
Name string `yaml:"name" json:"name"`
|
||||||
Description string `yaml:"description" json:"description"`
|
Description string `yaml:"description" json:"description"`
|
||||||
Content string `yaml:"content" json:"content"`
|
Content string `yaml:"content" json:"content"`
|
||||||
Author string `yaml:"author" json:"author"`
|
Author string `yaml:"author" json:"author"`
|
||||||
Version string `yaml:"version" json:"version"`
|
Version string `yaml:"version" json:"version"`
|
||||||
CreatedAt time.Time `yaml:"created_at" json:"created_at"`
|
CreatedAt time.Time `yaml:"created_at" json:"created_at"`
|
||||||
UpdatedAt time.Time `yaml:"updated_at" json:"updated_at"`
|
UpdatedAt time.Time `yaml:"updated_at" json:"updated_at"`
|
||||||
Tags []string `yaml:"tags" json:"tags"`
|
Tags []string `yaml:"tags" json:"tags"`
|
||||||
Target string `yaml:"target" json:"target"`
|
Target string `yaml:"target" json:"target"`
|
||||||
FilePath string `yaml:"-" json:"-"`
|
FilePath string `yaml:"-" json:"-"`
|
||||||
Dependencies []SkillDependency `yaml:"dependencies,omitempty" json:"dependencies,omitempty"`
|
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"`
|
Deployed bool `yaml:"-" json:"deployed,omitempty"`
|
||||||
|
RequiresTools []string `yaml:"requires_tools,omitempty" json:"requires_tools,omitempty"`
|
||||||
|
FallbackForTools []string `yaml:"fallback_for_tools,omitempty" json:"fallback_for_tools,omitempty"`
|
||||||
|
AutoImprove bool `yaml:"auto_improve,omitempty" json:"auto_improve,omitempty"`
|
||||||
|
CreatedFrom string `yaml:"created_from,omitempty" json:"created_from,omitempty"`
|
||||||
|
ImprovementCount int `yaml:"improvement_count,omitempty" json:"improvement_count,omitempty"`
|
||||||
|
LastImprovedAt *time.Time `yaml:"last_improved_at,omitempty" json:"last_improved_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ValidationError struct {
|
type ValidationError struct {
|
||||||
@@ -516,6 +522,24 @@ func renderSkill(skill *Skill) string {
|
|||||||
b.WriteString(fmt.Sprintf(" - type: %s, name: %s%s\n", dep.Type, dep.Name, req))
|
b.WriteString(fmt.Sprintf(" - type: %s, name: %s%s\n", dep.Type, dep.Name, req))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(skill.RequiresTools) > 0 {
|
||||||
|
b.WriteString(fmt.Sprintf("requires_tools: [%s]\n", strings.Join(skill.RequiresTools, ", ")))
|
||||||
|
}
|
||||||
|
if len(skill.FallbackForTools) > 0 {
|
||||||
|
b.WriteString(fmt.Sprintf("fallback_for_tools: [%s]\n", strings.Join(skill.FallbackForTools, ", ")))
|
||||||
|
}
|
||||||
|
if skill.AutoImprove {
|
||||||
|
b.WriteString("auto_improve: true\n")
|
||||||
|
}
|
||||||
|
if skill.CreatedFrom != "" {
|
||||||
|
b.WriteString(fmt.Sprintf("created_from: %s\n", skill.CreatedFrom))
|
||||||
|
}
|
||||||
|
if skill.ImprovementCount > 0 {
|
||||||
|
b.WriteString(fmt.Sprintf("improvement_count: %d\n", skill.ImprovementCount))
|
||||||
|
}
|
||||||
|
if skill.LastImprovedAt != nil {
|
||||||
|
b.WriteString(fmt.Sprintf("last_improved_at: %s\n", skill.LastImprovedAt.Format(time.RFC3339)))
|
||||||
|
}
|
||||||
b.WriteString("---\n\n")
|
b.WriteString("---\n\n")
|
||||||
b.WriteString(skill.Content)
|
b.WriteString(skill.Content)
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
Name = "muyue"
|
Name = "muyue"
|
||||||
Version = "0.7.4"
|
Version = "0.9.6"
|
||||||
Author = "La Légion de Muyue"
|
Author = "La Légion de Muyue"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,28 @@
|
|||||||
<html lang="fr">
|
<html lang="fr">
|
||||||
<head>
|
<head>
|
||||||
<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, maximum-scale=1.0, user-scalable=no" />
|
||||||
<meta name="theme-color" content="#0A0A0C" />
|
<meta name="theme-color" content="#0A0A0C" />
|
||||||
<title>Muyue</title>
|
<meta name="description" content="Muyue - AI-powered development environment" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="mobile-web-app-title" content="Muyue" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
<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="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/muyue.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/muyue.png" />
|
||||||
<link rel="shortcut icon" href="/muyue.png" />
|
<link rel="shortcut icon" href="/muyue.png" />
|
||||||
|
<title>Muyue</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
<script>
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
2180
web/package-lock.json
generated
2180
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,18 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/commands": "^6.10.3",
|
||||||
|
"@codemirror/lang-go": "^6.0.1",
|
||||||
|
"@codemirror/lang-javascript": "^6.2.5",
|
||||||
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
|
"@codemirror/lang-markdown": "^6.5.0",
|
||||||
|
"@codemirror/lang-python": "^6.2.1",
|
||||||
|
"@codemirror/lang-yaml": "^6.1.3",
|
||||||
|
"@codemirror/language": "^6.12.3",
|
||||||
|
"@codemirror/search": "^6.7.0",
|
||||||
|
"@codemirror/state": "^6.6.0",
|
||||||
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
|
"@codemirror/view": "^6.41.1",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/addon-image": "^0.10.0-beta.203",
|
"@xterm/addon-image": "^0.10.0-beta.203",
|
||||||
"@xterm/addon-search": "^0.17.0-beta.203",
|
"@xterm/addon-search": "^0.17.0-beta.203",
|
||||||
@@ -15,10 +27,17 @@
|
|||||||
"@xterm/addon-web-links": "^0.12.0",
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
"@xterm/addon-webgl": "^0.20.0-beta.202",
|
"@xterm/addon-webgl": "^0.20.0-beta.202",
|
||||||
"@xterm/xterm": "^6.1.0-beta.203",
|
"@xterm/xterm": "^6.1.0-beta.203",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
|
"katex": "^0.16.45",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
"mermaid": "^11.14.0",
|
"mermaid": "^11.14.0",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5"
|
"react-dom": "^19.2.5",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"rehype-highlight": "^7.0.2",
|
||||||
|
"rehype-katex": "^7.0.1",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"remark-math": "^6.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
|||||||
28
web/public/manifest.json
Normal file
28
web/public/manifest.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "Muyue",
|
||||||
|
"short_name": "Muyue",
|
||||||
|
"description": "AI-powered development environment",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0A0A0C",
|
||||||
|
"theme_color": "#FF0033",
|
||||||
|
"orientation": "any",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/muyue-64.png",
|
||||||
|
"sizes": "64x64",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/muyue.png",
|
||||||
|
"sizes": "128x128",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/favicon-32.png",
|
||||||
|
"sizes": "32x32",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"categories": ["developer tools", "productivity"]
|
||||||
|
}
|
||||||
42
web/public/sw.js
Normal file
42
web/public/sw.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
const CACHE_NAME = 'muyue-v1';
|
||||||
|
const STATIC_ASSETS = [
|
||||||
|
'/',
|
||||||
|
'/index.html',
|
||||||
|
'/manifest.json',
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
|
||||||
|
);
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((keys) =>
|
||||||
|
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
if (request.method !== 'GET') return;
|
||||||
|
if (request.url.includes('/api/')) return;
|
||||||
|
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(request).then((cached) => {
|
||||||
|
const fetchPromise = fetch(request).then((response) => {
|
||||||
|
if (response && response.status === 200 && response.type === 'basic') {
|
||||||
|
const clone = response.clone();
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}).catch(() => cached);
|
||||||
|
|
||||||
|
return cached || fetchPromise;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -162,6 +162,33 @@ const api = {
|
|||||||
}).catch(reject)
|
}).catch(reject)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
ragIndex: (text, name, type) => request('/rag/index', { method: 'POST', body: JSON.stringify({ text, name, type: type || 'text' }) }),
|
||||||
|
ragIndexFile: (file) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
return fetch(`${API_BASE}/rag/index`, { method: 'POST', body: formData }).then(r => {
|
||||||
|
if (!r.ok) return r.json().then(e => { throw new Error(e.error || r.statusText) })
|
||||||
|
return r.json()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
ragSearch: (query, limit) => request('/rag/search', { method: 'POST', body: JSON.stringify({ query, limit: limit || 5 }) }),
|
||||||
|
ragStatus: () => request('/rag/status'),
|
||||||
|
ragDocuments: () => request('/rag/documents'),
|
||||||
|
ragDelete: (id) => fetch(`${API_BASE}/rag/index/${id}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' } }).then(r => r.json()),
|
||||||
|
pipelineFilters: () => request('/pipeline/filters'),
|
||||||
|
pipelineToggle: (name, enabled) => request(`/pipeline/filters/${name}`, { method: 'POST', body: JSON.stringify({ enabled }) }),
|
||||||
|
generateImage: (prompt, size, style) => request('/images/generate', { method: 'POST', body: JSON.stringify({ prompt, size: size || '1024x1024', style: style || 'vivid' }) }),
|
||||||
|
fileRead: (path) => request(`/files/content?path=${encodeURIComponent(path)}`),
|
||||||
|
fileWrite: (path, content) => request('/files/content', { method: 'PUT', body: JSON.stringify({ path, content }) }),
|
||||||
|
mcpServerStatus: () => request('/mcp-server/status'),
|
||||||
|
mcpServerStart: () => request('/mcp-server/start', { method: 'POST' }),
|
||||||
|
mcpServerStop: () => request('/mcp-server/stop', { method: 'POST' }),
|
||||||
|
getAgentSessions: () => request('/agent-sessions'),
|
||||||
|
getAgentSessionOutput: (id) => request(`/agent-sessions/${encodeURIComponent(id)}`),
|
||||||
|
getWorkspaces: () => request('/workspaces'),
|
||||||
|
saveWorkspace: (name, layout, tabs) => request('/workspace', { method: 'POST', body: JSON.stringify({ name, layout, tabs }) }),
|
||||||
|
getWorkspace: (name) => request(`/workspace/${encodeURIComponent(name)}`),
|
||||||
|
deleteWorkspace: (name) => request(`/workspace/${encodeURIComponent(name)}`, { method: 'DELETE' }),
|
||||||
}
|
}
|
||||||
|
|
||||||
export default api
|
export default api
|
||||||
|
|||||||
262
web/src/components/FileEditor.jsx
Normal file
262
web/src/components/FileEditor.jsx
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||||
|
import { EditorView, keymap, lineNumbers, highlightActiveLineGutter, highlightActiveLine, drawSelection, rectangularSelection, highlightSpecialChars } from '@codemirror/view'
|
||||||
|
import { EditorState, Compartment } from '@codemirror/state'
|
||||||
|
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
|
||||||
|
import { syntaxHighlighting, defaultHighlightStyle, bracketMatching, foldGutter, indentOnInput } from '@codemirror/language'
|
||||||
|
import { javascript } from '@codemirror/lang-javascript'
|
||||||
|
import { python } from '@codemirror/lang-python'
|
||||||
|
import { go } from '@codemirror/lang-go'
|
||||||
|
import { json } from '@codemirror/lang-json'
|
||||||
|
import { yaml } from '@codemirror/lang-yaml'
|
||||||
|
import { markdown } from '@codemirror/lang-markdown'
|
||||||
|
import { oneDark } from '@codemirror/theme-one-dark'
|
||||||
|
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'
|
||||||
|
import { closeBrackets, closeBracketsKeymap, autocompletion, completionKeymap } from '@codemirror/autocomplete'
|
||||||
|
import { X, Save, RotateCcw } from 'lucide-react'
|
||||||
|
|
||||||
|
const langExtensions = {
|
||||||
|
javascript: () => javascript({ jsx: true }),
|
||||||
|
typescript: () => javascript({ jsx: true, typescript: true }),
|
||||||
|
python: () => python(),
|
||||||
|
go: () => go(),
|
||||||
|
json: () => json(),
|
||||||
|
yaml: () => yaml(),
|
||||||
|
markdown: () => markdown(),
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLangExtension(lang) {
|
||||||
|
const factory = langExtensions[lang]
|
||||||
|
if (factory) return factory()
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEditorTheme() {
|
||||||
|
return EditorView.theme({
|
||||||
|
'&': {
|
||||||
|
fontSize: '13px',
|
||||||
|
backgroundColor: 'var(--bg-base, #0F0D10)',
|
||||||
|
color: 'var(--text-primary, #EAE0E2)',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
'.cm-content': {
|
||||||
|
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||||
|
caretColor: 'var(--accent, #FF0033)',
|
||||||
|
padding: '4px 0',
|
||||||
|
},
|
||||||
|
'.cm-cursor': {
|
||||||
|
borderLeftColor: 'var(--accent, #FF0033)',
|
||||||
|
borderLeftWidth: '2px',
|
||||||
|
},
|
||||||
|
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground': {
|
||||||
|
backgroundColor: 'var(--accent-dim, #6B2033) !important',
|
||||||
|
},
|
||||||
|
'.cm-gutters': {
|
||||||
|
backgroundColor: 'var(--bg-surface, #161218)',
|
||||||
|
color: 'var(--text-tertiary, #8A7A7E)',
|
||||||
|
border: 'none',
|
||||||
|
borderRight: '1px solid var(--border, #2A1F22)',
|
||||||
|
},
|
||||||
|
'.cm-activeLineGutter': {
|
||||||
|
backgroundColor: 'var(--bg-elevated, #1C1719)',
|
||||||
|
color: 'var(--text-secondary, #D4C4C8)',
|
||||||
|
},
|
||||||
|
'.cm-activeLine': {
|
||||||
|
backgroundColor: 'rgba(255, 0, 51, 0.05)',
|
||||||
|
},
|
||||||
|
'.cm-matchingBracket': {
|
||||||
|
backgroundColor: 'var(--accent-dim, #6B2033)',
|
||||||
|
outline: '1px solid var(--accent, #FF0033)',
|
||||||
|
color: '#fff !important',
|
||||||
|
},
|
||||||
|
'.cm-selectionMatch': {
|
||||||
|
backgroundColor: 'var(--accent-dim, #6B2033)',
|
||||||
|
},
|
||||||
|
'.cm-foldGutter': {
|
||||||
|
color: 'var(--text-tertiary, #8A7A7E)',
|
||||||
|
},
|
||||||
|
'.cm-scroller': {
|
||||||
|
overflow: 'auto',
|
||||||
|
},
|
||||||
|
}, { dark: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FileEditor({ api, filePath, onClose }) {
|
||||||
|
const editorRef = useRef(null)
|
||||||
|
const viewRef = useRef(null)
|
||||||
|
const langCompartment = useRef(new Compartment())
|
||||||
|
const [content, setContent] = useState('')
|
||||||
|
const [originalContent, setOriginalContent] = useState('')
|
||||||
|
const [lang, setLang] = useState('text')
|
||||||
|
const [dirty, setDirty] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [fileName, setFileName] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!filePath) return
|
||||||
|
const name = filePath.split('/').pop()
|
||||||
|
setFileName(name)
|
||||||
|
|
||||||
|
api.fileRead(filePath).then(data => {
|
||||||
|
setContent(data.content || '')
|
||||||
|
setOriginalContent(data.content || '')
|
||||||
|
setLang(data.lang || 'text')
|
||||||
|
setLoading(false)
|
||||||
|
}).catch(err => {
|
||||||
|
setError(err.message || 'Failed to read file')
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}, [filePath])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editorRef.current || loading || viewRef.current) return
|
||||||
|
|
||||||
|
const customTheme = createEditorTheme()
|
||||||
|
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc: content,
|
||||||
|
extensions: [
|
||||||
|
customTheme,
|
||||||
|
oneDark,
|
||||||
|
lineNumbers(),
|
||||||
|
highlightActiveLineGutter(),
|
||||||
|
highlightSpecialChars(),
|
||||||
|
history(),
|
||||||
|
foldGutter(),
|
||||||
|
drawSelection(),
|
||||||
|
indentOnInput(),
|
||||||
|
bracketMatching(),
|
||||||
|
closeBrackets(),
|
||||||
|
autocompletion(),
|
||||||
|
rectangularSelection(),
|
||||||
|
highlightActiveLine(),
|
||||||
|
highlightSelectionMatches(),
|
||||||
|
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||||
|
keymap.of([
|
||||||
|
...closeBracketsKeymap,
|
||||||
|
...defaultKeymap,
|
||||||
|
...searchKeymap,
|
||||||
|
...historyKeymap,
|
||||||
|
...completionKeymap,
|
||||||
|
indentWithTab,
|
||||||
|
{
|
||||||
|
key: 'Mod-s',
|
||||||
|
run: () => { handleSave() ; return true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Escape',
|
||||||
|
run: () => { if (onClose) onClose() ; return true },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
langCompartment.current.of(getLangExtension(lang)),
|
||||||
|
EditorView.updateListener.of(update => {
|
||||||
|
if (update.docChanged) {
|
||||||
|
const newContent = update.state.doc.toString()
|
||||||
|
setDirty(newContent !== originalContent)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
EditorView.lineWrapping,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const view = new EditorView({
|
||||||
|
state,
|
||||||
|
parent: editorRef.current,
|
||||||
|
})
|
||||||
|
|
||||||
|
viewRef.current = view
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
view.destroy()
|
||||||
|
viewRef.current = null
|
||||||
|
}
|
||||||
|
}, [loading])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!viewRef.current || !lang) return
|
||||||
|
try {
|
||||||
|
viewRef.current.dispatch({
|
||||||
|
effects: langCompartment.current.reconfigure(getLangExtension(lang)),
|
||||||
|
})
|
||||||
|
} catch {}
|
||||||
|
}, [lang])
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!viewRef.current || !filePath || saving) return
|
||||||
|
const newContent = viewRef.current.state.doc.toString()
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await api.fileWrite(filePath, newContent)
|
||||||
|
setOriginalContent(newContent)
|
||||||
|
setDirty(false)
|
||||||
|
setContent(newContent)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
setSaving(false)
|
||||||
|
}, [filePath, saving, api])
|
||||||
|
|
||||||
|
const handleReload = useCallback(() => {
|
||||||
|
if (!viewRef.current) return
|
||||||
|
api.fileRead(filePath).then(data => {
|
||||||
|
const doc = data.content || ''
|
||||||
|
viewRef.current.dispatch({
|
||||||
|
changes: { from: 0, to: viewRef.current.state.doc.length, insert: doc },
|
||||||
|
})
|
||||||
|
setOriginalContent(doc)
|
||||||
|
setDirty(false)
|
||||||
|
setContent(doc)
|
||||||
|
setLang(data.lang || 'text')
|
||||||
|
}).catch(err => setError(err.message))
|
||||||
|
}, [filePath, api])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="file-editor-panel">
|
||||||
|
<div className="file-editor-header">
|
||||||
|
<span className="file-editor-title">Loading...</span>
|
||||||
|
<button className="ghost sm" onClick={onClose}><X size={14} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !content) {
|
||||||
|
return (
|
||||||
|
<div className="file-editor-panel">
|
||||||
|
<div className="file-editor-header">
|
||||||
|
<span className="file-editor-title" style={{ color: 'var(--error)' }}>{error}</span>
|
||||||
|
<button className="ghost sm" onClick={onClose}><X size={14} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="file-editor-panel">
|
||||||
|
<div className="file-editor-header">
|
||||||
|
<span className="file-editor-title">
|
||||||
|
{fileName}
|
||||||
|
{dirty && <span className="file-editor-dirty">●</span>}
|
||||||
|
</span>
|
||||||
|
<div className="file-editor-actions">
|
||||||
|
<span className="file-editor-lang-badge">{lang}</span>
|
||||||
|
<button className="ghost sm" onClick={handleReload} title="Reload">
|
||||||
|
<RotateCcw size={13} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="sm primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!dirty || saving}
|
||||||
|
>
|
||||||
|
<Save size={13} />
|
||||||
|
{saving ? '...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button className="ghost sm" onClick={onClose}><X size={14} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="file-editor-body" ref={editorRef} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
import { useI18n } from '../i18n'
|
import { useI18n } from '../i18n'
|
||||||
import mermaid from 'mermaid'
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose', fontFamily: 'var(--font-mono)' })
|
import remarkMath from 'remark-math'
|
||||||
|
import rehypeKatex from 'rehype-katex'
|
||||||
|
import rehypeHighlight from 'rehype-highlight'
|
||||||
|
import 'katex/dist/katex.min.css'
|
||||||
|
import 'highlight.js/styles/github-dark.css'
|
||||||
|
|
||||||
const RANKS = {
|
const RANKS = {
|
||||||
commandant: { label: 'Commandant', short: 'CDT', color: '#FFD740' },
|
commandant: { label: 'Commandant', short: 'CDT', color: '#FFD740' },
|
||||||
@@ -33,72 +37,6 @@ function RankIcon({ rank }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderContent(text) {
|
|
||||||
const parts = []
|
|
||||||
const codeBlockRegex = /(```[\s\S]*?```)/g
|
|
||||||
let match
|
|
||||||
let lastIndex = 0
|
|
||||||
while ((match = codeBlockRegex.exec(text)) !== null) {
|
|
||||||
if (match.index > lastIndex) {
|
|
||||||
parts.push({ type: 'text', content: text.slice(lastIndex, match.index) })
|
|
||||||
}
|
|
||||||
const full = match[1]
|
|
||||||
const firstNewline = full.indexOf('\n')
|
|
||||||
const lang = firstNewline > -1 ? full.slice(3, firstNewline).trim() : ''
|
|
||||||
const code = firstNewline > -1 ? full.slice(firstNewline + 1, -3) : full.slice(3, -3)
|
|
||||||
parts.push({ type: 'code', lang, content: code })
|
|
||||||
lastIndex = match.index + full.length
|
|
||||||
}
|
|
||||||
if (lastIndex < text.length) {
|
|
||||||
const remaining = text.slice(lastIndex)
|
|
||||||
const openBlock = remaining.match(/```(\w*)\n?([\s\S]*)$/)
|
|
||||||
if (openBlock) {
|
|
||||||
if (openBlock.index > 0) {
|
|
||||||
parts.push({ type: 'text', content: remaining.slice(0, openBlock.index) })
|
|
||||||
}
|
|
||||||
parts.push({ type: 'code', lang: openBlock[1] || '', content: openBlock[2] || '' })
|
|
||||||
} else {
|
|
||||||
parts.push({ type: 'text', content: remaining })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return parts
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatText(text) {
|
|
||||||
let html = text
|
|
||||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
||||||
|
|
||||||
html = html.replace(/^(\|.+\|)\n(\|[\s\-:|]+\|)\n((?:\|.+\|\n?)+)/gm, (match, headerRow, sepRow, bodyRows) => {
|
|
||||||
const headers = headerRow.split('|').filter(c => c.trim() !== '').map(c => `<th>${c.trim()}</th>`).join('')
|
|
||||||
const rows = bodyRows.trim().split('\n').map(row => {
|
|
||||||
const cells = row.split('|').filter(c => c.trim() !== '').map(c => `<td>${c.trim()}</td>`).join('')
|
|
||||||
return `<tr>${cells}</tr>`
|
|
||||||
}).join('')
|
|
||||||
return `<table><thead><tr>${headers}</tr></thead><tbody>${rows}</tbody></table>`
|
|
||||||
})
|
|
||||||
|
|
||||||
html = html
|
|
||||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
||||||
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
|
|
||||||
.replace(/^### (.+)$/gm, '<h4 class="msg-h4">$1</h4>')
|
|
||||||
.replace(/^## (.+)$/gm, '<h3 class="msg-h3">$1</h3>')
|
|
||||||
.replace(/^# (.+)$/gm, '<h2 class="msg-h2">$1</h2>')
|
|
||||||
.replace(/^---+$/gm, '<hr>')
|
|
||||||
.replace(/^\s*[-*] (.+)$/gm, '<div class="msg-bullet">\u2022 $1</div>')
|
|
||||||
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<div class="msg-step"><span class="msg-step-num">$1</span> $2</div>')
|
|
||||||
.replace(/\n/g, '<br/>')
|
|
||||||
|
|
||||||
html = html
|
|
||||||
.replace(/<br\/>\s*<br\/>/g, '<br/>')
|
|
||||||
.replace(/<br\/>\s*(<h[234]|<div class="msg-|<table|<hr)/g, '$1')
|
|
||||||
.replace(/(<\/h[234]|<\/div>|<\/table>|<hr>)\s*<br\/>/g, '$1')
|
|
||||||
.replace(/\s+on\w+=["'][^"']*["']/gi, '')
|
|
||||||
.replace(/javascript:/gi, '')
|
|
||||||
.replace(/data:/gi, '')
|
|
||||||
|
|
||||||
return html
|
|
||||||
}
|
|
||||||
|
|
||||||
function ThinkingBlock({ content, done, raw }) {
|
function ThinkingBlock({ content, done, raw }) {
|
||||||
return (
|
return (
|
||||||
<div className={`feed-thinking-block ${done ? 'done' : 'active'}`}>
|
<div className={`feed-thinking-block ${done ? 'done' : 'active'}`}>
|
||||||
@@ -212,73 +150,28 @@ function ToolCallBlock({ call, result, activeAgents, onModeChange }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let mermaidIdCounter = 0
|
function MarkdownContent({ content, raw }) {
|
||||||
|
if (raw) {
|
||||||
function MermaidBlock({ code }) {
|
return <pre className="feed-content" style={{ whiteSpace: 'pre-wrap', fontFamily: 'var(--font-mono)', fontSize: '0.9em' }}>{content}</pre>
|
||||||
const ref = useRef(null)
|
|
||||||
const [svg, setSvg] = useState('')
|
|
||||||
const [error, setError] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false
|
|
||||||
const id = `studio-mermaid-${++mermaidIdCounter}`
|
|
||||||
mermaid.render(id, code).then(({ svg }) => {
|
|
||||||
if (!cancelled) setSvg(svg)
|
|
||||||
}).catch(() => {
|
|
||||||
if (!cancelled) setError(true)
|
|
||||||
})
|
|
||||||
return () => { cancelled = true }
|
|
||||||
}, [code])
|
|
||||||
|
|
||||||
if (error) return <pre className="studio-mermaid-error">{code}</pre>
|
|
||||||
if (!svg) return <div className="studio-mermaid-loading">Chargement...</div>
|
|
||||||
return <div className="studio-mermaid-container" ref={ref} dangerouslySetInnerHTML={{ __html: svg }} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function CodeBlockWithCopy({ part, index, copiedIdx, setCopiedIdx }) {
|
|
||||||
if (part.lang === 'mermaid') {
|
|
||||||
return (
|
|
||||||
<div className="studio-code-block">
|
|
||||||
<div className="studio-code-header">
|
|
||||||
<span className="studio-code-lang">mermaid</span>
|
|
||||||
<button className={`studio-copy-btn ${copiedIdx === index ? 'copied' : ''}`} onClick={() => {
|
|
||||||
navigator.clipboard.writeText(part.content)
|
|
||||||
setCopiedIdx(index)
|
|
||||||
setTimeout(() => setCopiedIdx(null), 1500)
|
|
||||||
}}>
|
|
||||||
{copiedIdx === index ? 'Copie!' : 'Copier'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<MermaidBlock code={part.content} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="studio-code-block">
|
<div className="feed-content">
|
||||||
<div className="studio-code-header">
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||||
{part.lang && <span className="studio-code-lang">{part.lang}</span>}
|
|
||||||
<button className={`studio-copy-btn ${copiedIdx === index ? 'copied' : ''}`} onClick={() => {
|
|
||||||
navigator.clipboard.writeText(part.content)
|
|
||||||
setCopiedIdx(index)
|
|
||||||
setTimeout(() => setCopiedIdx(null), 1500)
|
|
||||||
}}>
|
|
||||||
{copiedIdx === index ? 'Copie!' : 'Copier'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<pre><code>{part.content}</code></pre>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function FeedItem({ msg, activeAgents, onModeChange, collapseHistory }) {
|
function FeedItem({ msg, activeAgents, onModeChange }) {
|
||||||
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 [copiedMsg, setCopiedMsg] = useState(false)
|
||||||
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' }) : ''
|
||||||
|
|
||||||
|
const renderMarkdown = useCallback((content) => {
|
||||||
|
return <MarkdownContent content={content} raw={false} />
|
||||||
|
}, [])
|
||||||
let parsedToolCalls = null
|
let parsedToolCalls = null
|
||||||
let parsedToolResults = null
|
let parsedToolResults = null
|
||||||
let parsedSegments = null
|
let parsedSegments = null
|
||||||
@@ -321,8 +214,21 @@ function FeedItem({ msg, activeAgents, onModeChange, collapseHistory }) {
|
|||||||
</span>
|
</span>
|
||||||
<span className="feed-role">{rank.label}</span>
|
<span className="feed-role">{rank.label}</span>
|
||||||
{timeStr && <span className="feed-time">{timeStr}</span>}
|
{timeStr && <span className="feed-time">{timeStr}</span>}
|
||||||
|
{!isUser && !isSystem && (
|
||||||
|
<button
|
||||||
|
className="studio-copy-btn"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(displayContent)
|
||||||
|
setCopiedMsg(true)
|
||||||
|
setTimeout(() => setCopiedMsg(false), 1500)
|
||||||
|
}}
|
||||||
|
style={{ marginLeft: 'auto', fontSize: '0.7em', opacity: copiedMsg ? 1 : 0.5, transition: 'opacity 0.15s' }}
|
||||||
|
>
|
||||||
|
{copiedMsg ? '✓' : 'Copy MD'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{msg.thinking && <ThinkingBlock content={formatText(msg.thinking)} done raw />}
|
{msg.thinking && <ThinkingBlock content={msg.thinking} done raw />}
|
||||||
{msg.images && msg.images.length > 0 && (
|
{msg.images && msg.images.length > 0 && (
|
||||||
<div className="feed-images">
|
<div className="feed-images">
|
||||||
{msg.images.map((imgId, i) => (
|
{msg.images.map((imgId, i) => (
|
||||||
@@ -332,21 +238,8 @@ function FeedItem({ msg, activeAgents, onModeChange, collapseHistory }) {
|
|||||||
)}
|
)}
|
||||||
{parsedSegments && parsedSegments.some(s => s.type === 'tool') ? (
|
{parsedSegments && parsedSegments.some(s => s.type === 'tool') ? (
|
||||||
(() => {
|
(() => {
|
||||||
const toolSegs = parsedSegments.filter(s => s.type === 'tool')
|
|
||||||
const compress = collapseHistory && !forceExpand && toolSegs.length > 1
|
|
||||||
const lastTool = toolSegs.length > 0 ? toolSegs[toolSegs.length - 1] : null
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{compress && (
|
|
||||||
<div className="feed-content" style={{ opacity: 0.7, fontSize: '0.85em', display: 'flex', justifyContent: 'space-between' }}>
|
|
||||||
<span>… {toolSegs.length - 1} action{toolSegs.length - 1 > 1 ? 's' : ''} précédente{toolSegs.length - 1 > 1 ? 's' : ''} masquée{toolSegs.length - 1 > 1 ? 's' : ''}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setForceExpand(true)}
|
|
||||||
style={{ background: 'transparent', border: 'none', color: 'var(--accent, #6c5ce7)', cursor: 'pointer', fontSize: 'inherit' }}
|
|
||||||
>Tout afficher</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{parsedSegments.map((seg, i) => {
|
{parsedSegments.map((seg, i) => {
|
||||||
if (seg.type === 'text') {
|
if (seg.type === 'text') {
|
||||||
if (!seg.content) return null
|
if (!seg.content) return null
|
||||||
@@ -354,18 +247,11 @@ function FeedItem({ msg, activeAgents, onModeChange, collapseHistory }) {
|
|||||||
if (!c) return null
|
if (!c) return null
|
||||||
return (
|
return (
|
||||||
<div key={`t${i}`} className="feed-content">
|
<div key={`t${i}`} className="feed-content">
|
||||||
{renderContent(c).map((part, j) =>
|
{renderMarkdown(c)}
|
||||||
part.type === 'code' ? (
|
|
||||||
<CodeBlockWithCopy key={j} part={part} index={j} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
|
|
||||||
) : (
|
|
||||||
<span key={j} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (seg.type === 'tool') {
|
if (seg.type === 'tool') {
|
||||||
if (compress && seg !== lastTool) return null
|
|
||||||
const r = seg.result
|
const r = seg.result
|
||||||
const result = r && (r.content !== undefined || r.is_error !== undefined)
|
const result = r && (r.content !== undefined || r.is_error !== undefined)
|
||||||
? { content: r.content, is_error: r.is_error }
|
? { content: r.content, is_error: r.is_error }
|
||||||
@@ -380,21 +266,9 @@ function FeedItem({ msg, activeAgents, onModeChange, collapseHistory }) {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{parsedToolCalls && (() => {
|
{parsedToolCalls && (() => {
|
||||||
const compress = collapseHistory && !forceExpand && parsedToolCalls.length > 1
|
|
||||||
const items = compress ? parsedToolCalls.slice(-1) : parsedToolCalls
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{compress && (
|
{parsedToolCalls.map((tc, i) => {
|
||||||
<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
|
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)
|
||||||
: null
|
: null
|
||||||
@@ -408,13 +282,7 @@ function FeedItem({ msg, activeAgents, onModeChange, collapseHistory }) {
|
|||||||
})()}
|
})()}
|
||||||
{cleanContent && (
|
{cleanContent && (
|
||||||
<div className="feed-content">
|
<div className="feed-content">
|
||||||
{renderContent(cleanContent).map((part, i) =>
|
{renderMarkdown(cleanContent)}
|
||||||
part.type === 'code' ? (
|
|
||||||
<CodeBlockWithCopy key={i} part={part} index={i} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
|
|
||||||
) : (
|
|
||||||
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -424,26 +292,12 @@ function FeedItem({ msg, activeAgents, onModeChange, collapseHistory }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function StreamingItem({ content, thinking, toolCalls, segments, activeAgents, onModeChange, collapseHistory }) {
|
function StreamingItem({ content, thinking, toolCalls, segments, activeAgents, onModeChange }) {
|
||||||
const rank = RANKS.general
|
const rank = RANKS.general
|
||||||
const cleanContent = content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
const cleanContent = content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||||||
const hasToolCalls = toolCalls && toolCalls.length > 0
|
const hasToolCalls = toolCalls && toolCalls.length > 0
|
||||||
const [copiedIdx, setCopiedIdx] = useState(null)
|
|
||||||
const [forceExpand, setForceExpand] = useState(false)
|
|
||||||
|
|
||||||
const renderedContent = useMemo(() => {
|
|
||||||
if (!cleanContent) return []
|
|
||||||
return renderContent(cleanContent)
|
|
||||||
}, [cleanContent])
|
|
||||||
|
|
||||||
const formattedThinking = useMemo(() => {
|
|
||||||
if (!thinking) return ''
|
|
||||||
return formatText(thinking)
|
|
||||||
}, [thinking])
|
|
||||||
|
|
||||||
const hasOrderedSegments = segments && segments.some(s => s.type === 'tool')
|
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">
|
||||||
@@ -457,62 +311,33 @@ function StreamingItem({ content, thinking, toolCalls, segments, activeAgents, o
|
|||||||
</span>
|
</span>
|
||||||
<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={thinking} raw done={false} />}
|
||||||
{hasOrderedSegments ? (
|
{hasOrderedSegments ? (
|
||||||
<>
|
<>
|
||||||
{compress && (
|
{segments.map((seg, i) => {
|
||||||
<div className="feed-content" style={{ opacity: 0.7, fontSize: '0.85em', display: 'flex', justifyContent: 'space-between' }}>
|
if (seg.type === 'text') {
|
||||||
<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>
|
if (!seg.content) return null
|
||||||
<button
|
return (
|
||||||
type="button"
|
<div key={`t${i}`} className="feed-content">
|
||||||
onClick={() => setForceExpand(true)}
|
<MarkdownContent content={seg.content} raw={false} />
|
||||||
style={{ background: 'transparent', border: 'none', color: 'var(--accent, #6c5ce7)', cursor: 'pointer', fontSize: 'inherit' }}
|
</div>
|
||||||
>Tout afficher</button>
|
)
|
||||||
</div>
|
}
|
||||||
)}
|
if (seg.type === 'tool') {
|
||||||
{(() => {
|
return <ToolCallBlock key={`tc${i}`} call={seg.call} result={seg.result} activeAgents={activeAgents} onModeChange={onModeChange} />
|
||||||
const lastToolId = toolSegments.length > 0 ? toolSegments[toolSegments.length - 1] : null
|
}
|
||||||
return segments.map((seg, i) => {
|
return null
|
||||||
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
|
{hasToolCalls && toolCalls.map((tc, i) => (
|
||||||
? [<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} />
|
<ToolCallBlock key={tc.call?.tool_call_id || i} call={tc.call} result={tc.result} activeAgents={activeAgents} onModeChange={onModeChange} />
|
||||||
))
|
))
|
||||||
)}
|
}
|
||||||
{cleanContent && (
|
{cleanContent && (
|
||||||
<div className="feed-content">
|
<div className="feed-content">
|
||||||
{renderedContent.map((part, i) =>
|
<MarkdownContent content={cleanContent} raw={false} />
|
||||||
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" />
|
<span className="studio-cursor" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -547,13 +372,9 @@ export default function Studio({ api }) {
|
|||||||
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 [activeAgents, setActiveAgents] = useState({ crush: 0, claude: 0 })
|
||||||
const [toolModes, setToolModes] = useState({})
|
|
||||||
const [advancedReflection, setAdvancedReflection] = useState(() => {
|
const [advancedReflection, setAdvancedReflection] = useState(() => {
|
||||||
try { return localStorage.getItem('muyue.advancedReflection') === 'true' } catch { return false }
|
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_CRUSH_AGENTS = 2
|
||||||
const MAX_CLAUDE_AGENTS = 2
|
const MAX_CLAUDE_AGENTS = 2
|
||||||
const messagesEnd = useRef(null)
|
const messagesEnd = useRef(null)
|
||||||
@@ -561,6 +382,8 @@ export default function Studio({ api }) {
|
|||||||
const textareaRef = useRef(null)
|
const textareaRef = useRef(null)
|
||||||
const abortRef = useRef(null)
|
const abortRef = useRef(null)
|
||||||
const fileInputRef = useRef(null)
|
const fileInputRef = useRef(null)
|
||||||
|
const ragFileRef = useRef(null)
|
||||||
|
const [ragStatus, setRagStatus] = useState(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getChatHistory().then(data => {
|
api.getChatHistory().then(data => {
|
||||||
@@ -589,6 +412,10 @@ export default function Studio({ api }) {
|
|||||||
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
|
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
}, [messages, streaming, streamThinking, streamToolCalls])
|
}, [messages, streaming, streamThinking, streamToolCalls])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.ragStatus().then(setRagStatus).catch(() => {})
|
||||||
|
}, [api])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onTab = (e) => {
|
const onTab = (e) => {
|
||||||
if (e.key !== 'Tab') return
|
if (e.key !== 'Tab') return
|
||||||
@@ -671,6 +498,20 @@ export default function Studio({ api }) {
|
|||||||
setAttachedImages(prev => prev.filter((_, i) => i !== index))
|
setAttachedImages(prev => prev.filter((_, i) => i !== index))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const handleRAGFileSelect = useCallback(async (e) => {
|
||||||
|
const files = Array.from(e.target.files || [])
|
||||||
|
if (files.length === 0) return
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
await api.ragIndexFile(file)
|
||||||
|
} catch (err) {
|
||||||
|
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'system', content: `RAG: erreur d'indexation de ${file.name}: ${err.message}`, time: new Date().toISOString() }])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
api.ragStatus().then(setRagStatus).catch(() => {})
|
||||||
|
e.target.value = ''
|
||||||
|
}, [api])
|
||||||
|
|
||||||
const handleSend = useCallback(async () => {
|
const handleSend = useCallback(async () => {
|
||||||
if (!input.trim() || loading) return
|
if (!input.trim() || loading) return
|
||||||
const text = input.trim()
|
const text = input.trim()
|
||||||
@@ -949,7 +790,7 @@ export default function Studio({ api }) {
|
|||||||
<span className="feed-summary-toggle">{summarizedExpanded ? 'masquer' : 'voir'}</span>
|
<span className="feed-summary-toggle">{summarizedExpanded ? 'masquer' : 'voir'}</span>
|
||||||
</div>
|
</div>
|
||||||
{summarizedExpanded && summarizedMsgs.map(msg => (
|
{summarizedExpanded && summarizedMsgs.map(msg => (
|
||||||
<FeedItem key={msg.id} msg={msg} activeAgents={activeAgents} onModeChange={handleToolModeChange} collapseHistory={collapseHistory} />
|
<FeedItem key={msg.id} msg={msg} activeAgents={activeAgents} onModeChange={handleToolModeChange} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -961,7 +802,7 @@ export default function Studio({ api }) {
|
|||||||
<>
|
<>
|
||||||
{renderSummaryBlock()}
|
{renderSummaryBlock()}
|
||||||
{activeMsgs.slice(0, visibleCount).map(msg => (
|
{activeMsgs.slice(0, visibleCount).map(msg => (
|
||||||
<FeedItem key={msg.id} msg={msg} activeAgents={activeAgents} onModeChange={handleToolModeChange} collapseHistory={collapseHistory} />
|
<FeedItem key={msg.id} msg={msg} activeAgents={activeAgents} onModeChange={handleToolModeChange} />
|
||||||
))}
|
))}
|
||||||
<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">
|
||||||
@@ -978,7 +819,7 @@ export default function Studio({ api }) {
|
|||||||
<>
|
<>
|
||||||
{renderSummaryBlock()}
|
{renderSummaryBlock()}
|
||||||
{activeMsgs.map(msg => (
|
{activeMsgs.map(msg => (
|
||||||
<FeedItem key={msg.id} msg={msg} activeAgents={activeAgents} onModeChange={handleToolModeChange} collapseHistory={collapseHistory} />
|
<FeedItem key={msg.id} msg={msg} activeAgents={activeAgents} onModeChange={handleToolModeChange} />
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -1002,7 +843,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} segments={streamSegments} activeAgents={activeAgents} onModeChange={handleToolModeChange} collapseHistory={collapseHistory} />
|
<StreamingItem content={streaming} thinking={streamThinking} toolCalls={streamToolCalls} segments={streamSegments} activeAgents={activeAgents} onModeChange={handleToolModeChange} />
|
||||||
)}
|
)}
|
||||||
<div ref={messagesEnd} style={{ height: '24px' }} />
|
<div ref={messagesEnd} style={{ height: '24px' }} />
|
||||||
</div>
|
</div>
|
||||||
@@ -1054,6 +895,14 @@ export default function Studio({ api }) {
|
|||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
onChange={handleImageSelect}
|
onChange={handleImageSelect}
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={ragFileRef}
|
||||||
|
accept=".txt,.md,.go,.js,.ts,.py,.java,.rs,.jsx,.tsx,.json,.yaml,.yml,.csv,.html,.css,.sh,.bash,.zsh,.fish"
|
||||||
|
multiple
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handleRAGFileSelect}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
className="studio-attach-btn"
|
className="studio-attach-btn"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
@@ -1064,6 +913,17 @@ 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={() => ragFileRef.current?.click()}
|
||||||
|
disabled={loading}
|
||||||
|
title={ragStatus ? `RAG: ${ragStatus.documents || 0} docs, ${ragStatus.chunks || 0} chunks` : 'Ajouter un contexte RAG'}
|
||||||
|
style={ragStatus && ragStatus.documents > 0 ? { 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">
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="studio-attach-btn"
|
className="studio-attach-btn"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -1079,21 +939,6 @@ export default function Studio({ api }) {
|
|||||||
<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>
|
<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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}
|
||||||
|
|||||||
@@ -1313,6 +1313,415 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
color: var(--accent-muted) !important;
|
color: var(--accent-muted) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── KaTeX overrides ── */
|
||||||
|
.katex { font-size: 1em; color: var(--text-primary); }
|
||||||
|
.katex-display { margin: 12px 0; overflow-x: auto; }
|
||||||
|
|
||||||
|
/* ── Raw Markdown Toggle ── */
|
||||||
|
.studio-raw-markdown {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
background: var(--bg);
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── ReactMarkdown prose styles ── */
|
||||||
|
.feed-content > div:not(.studio-code-block):not(.studio-mermaid-container) {
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.feed-content h1 { font-size: 20px; font-weight: 800; color: var(--accent); margin: 16px 0 8px; }
|
||||||
|
.feed-content h2 { font-size: 17px; font-weight: 700; color: var(--text-primary); margin: 12px 0 6px; }
|
||||||
|
.feed-content h3 { font-size: 15px; font-weight: 700; color: var(--text-primary); margin: 10px 0 4px; }
|
||||||
|
.feed-content h4 { font-size: 13px; font-weight: 600; color: var(--text-secondary); margin: 8px 0 3px; }
|
||||||
|
.feed-content h5 { font-size: 12px; font-weight: 600; color: var(--text-tertiary); margin: 6px 0 2px; }
|
||||||
|
.feed-content h6 { font-size: 11px; font-weight: 600; color: var(--text-tertiary); margin: 6px 0 2px; text-transform: uppercase; }
|
||||||
|
.feed-content p { margin: 4px 0; }
|
||||||
|
.feed-content ul { padding-left: 20px; margin: 4px 0; }
|
||||||
|
.feed-content ol { padding-left: 20px; margin: 4px 0; }
|
||||||
|
.feed-content li { margin: 2px 0; }
|
||||||
|
.feed-content blockquote {
|
||||||
|
border-left: 3px solid var(--accent-dim);
|
||||||
|
padding: 4px 12px;
|
||||||
|
margin: 8px 0;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-radius: 0 var(--radius) var(--radius) 0;
|
||||||
|
}
|
||||||
|
.feed-content hr { border: none; border-top: 1px solid var(--border); margin: 12px 0; }
|
||||||
|
.feed-content strong { color: var(--accent-light); font-weight: 700; }
|
||||||
|
.feed-content em { color: var(--text-secondary); }
|
||||||
|
.feed-content a { color: var(--accent); text-decoration: underline; }
|
||||||
|
.feed-content img { max-width: 100%; border-radius: var(--radius); }
|
||||||
|
.feed-content input[type="checkbox"] {
|
||||||
|
margin-right: 6px;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
.feed-content del { color: var(--text-disabled); text-decoration: line-through; }
|
||||||
|
.feed-content sup { font-size: 0.75em; color: var(--text-tertiary); vertical-align: super; }
|
||||||
|
|
||||||
|
/* ── highlight.js overrides for dark theme ── */
|
||||||
|
.hljs { background: var(--bg) !important; color: var(--text-primary) !important; }
|
||||||
|
.hljs-keyword { color: var(--accent-muted) !important; }
|
||||||
|
.hljs-string { color: var(--success) !important; }
|
||||||
|
.hljs-comment { color: var(--text-disabled) !important; font-style: italic; }
|
||||||
|
.hljs-function { color: var(--accent-light) !important; }
|
||||||
|
.hljs-number { color: var(--warning) !important; }
|
||||||
|
|
||||||
|
/* ── Responsive / Mobile ── */
|
||||||
|
|
||||||
|
/* ── Large tablets / small laptops ── */
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.dash-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
grid-template-rows: auto;
|
||||||
|
height: auto;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.dash-span-2 { grid-column: span 2; }
|
||||||
|
.shell-ai-col { width: 280px; max-width: 280px; }
|
||||||
|
.config-profile-center { max-width: 100%; }
|
||||||
|
.config-ai-tools-grid { grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); }
|
||||||
|
.skill-tiles { grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tablets portrait ── */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
:root { --sidebar-w: 240px; }
|
||||||
|
.header { padding: 0 16px; gap: 8px; }
|
||||||
|
.header-nav { margin-left: 16px; }
|
||||||
|
.nav-tab { padding: 6px 12px; font-size: 12px; }
|
||||||
|
.nav-tab .tab-icon { display: none; }
|
||||||
|
.dash-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
grid-template-rows: auto;
|
||||||
|
height: auto;
|
||||||
|
overflow: auto;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.dash-span-2 { grid-column: span 2; }
|
||||||
|
.studio-feed { padding: 16px 12px; }
|
||||||
|
.studio-input-area { padding: 10px 12px 6px; }
|
||||||
|
.config-panel-body { padding: 12px 16px 16px; }
|
||||||
|
.config-card-row { gap: 10px; }
|
||||||
|
.config-card-label { width: 110px; }
|
||||||
|
.shell-ai-col { width: 240px; max-width: 240px; }
|
||||||
|
.shell-analysis-modal { width: 90vw; }
|
||||||
|
.shell-modal { min-width: 320px; }
|
||||||
|
.grid-2 { grid-template-columns: 1fr; }
|
||||||
|
.split-horizontal { flex-direction: column; }
|
||||||
|
.split-right { width: 100%; border-left: none; border-top: 1px solid var(--border); max-height: 300px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile landscape / small tablets ── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
:root { --sidebar-w: 100%; --header-h: 46px; }
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header {
|
||||||
|
padding: 0 10px;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.header-brand { gap: 4px; flex-shrink: 0; }
|
||||||
|
.header-logo { font-size: 14px; letter-spacing: 2px; }
|
||||||
|
.header-logo-img { width: 18px !important; height: 18px !important; }
|
||||||
|
.header-version { display: none; }
|
||||||
|
.header-nav {
|
||||||
|
margin-left: 8px;
|
||||||
|
gap: 2px;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.header-nav::-webkit-scrollbar { display: none; }
|
||||||
|
.nav-tab {
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.header-spacer { display: none; }
|
||||||
|
.header-clock { font-size: 10px; flex-shrink: 0; }
|
||||||
|
.header-indicators { gap: 6px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* Statusbar */
|
||||||
|
.statusbar { padding: 0 8px; font-size: 10px; }
|
||||||
|
.statusbar-sudo { font-size: 9px; padding: 1px 4px; }
|
||||||
|
.statusbar-shortcut { display: none; }
|
||||||
|
.statusbar-right { display: none; }
|
||||||
|
|
||||||
|
/* Dashboard */
|
||||||
|
.dash-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: auto;
|
||||||
|
height: auto;
|
||||||
|
overflow: auto;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.dash-span-2 { grid-column: span 1; }
|
||||||
|
.dash-card { padding: 10px 12px; }
|
||||||
|
.dash-quota-list,
|
||||||
|
.dash-consumption-list,
|
||||||
|
.dash-proc-list { max-height: 200px; }
|
||||||
|
.dash-cmd-card .dash-cmd-list { max-height: 160px; }
|
||||||
|
|
||||||
|
/* Studio */
|
||||||
|
.studio-feed { padding: 10px 6px; gap: 2px; }
|
||||||
|
.studio-feed-scroll-wrap { }
|
||||||
|
.studio-input-area { padding: 8px 6px 4px; }
|
||||||
|
.studio-input-row textarea { min-height: 36px; padding: 8px 10px; font-size: 13px; }
|
||||||
|
.studio-send-btn, .studio-stop-btn, .studio-attach-btn { width: 36px; height: 36px; }
|
||||||
|
.studio-token-bar { gap: 6px; margin-bottom: 6px; }
|
||||||
|
.studio-image-previews { gap: 6px; padding: 6px 4px; }
|
||||||
|
.studio-image-preview { width: 80px; height: 80px; }
|
||||||
|
.studio-scroll-btns { right: 8px; bottom: 8px; }
|
||||||
|
|
||||||
|
/* Feed items */
|
||||||
|
.feed-item { padding: 4px 6px; gap: 6px; }
|
||||||
|
.feed-avatar { width: 22px; height: 22px; font-size: 12px; }
|
||||||
|
.feed-header { gap: 4px; flex-wrap: wrap; }
|
||||||
|
.feed-rank-badge { font-size: 8px; padding: 0 4px; }
|
||||||
|
.feed-role { font-size: 10px; }
|
||||||
|
.feed-time { font-size: 9px; }
|
||||||
|
.feed-content { font-size: 13px; }
|
||||||
|
.feed-content table { font-size: 11px; }
|
||||||
|
.feed-content th, .feed-content td { padding: 3px 6px; }
|
||||||
|
.feed-images { gap: 4px; }
|
||||||
|
.feed-image { max-width: 160px; max-height: 120px; }
|
||||||
|
|
||||||
|
/* Thinking blocks */
|
||||||
|
.feed-thinking-block { max-height: 120px; }
|
||||||
|
.feed-thinking-content { max-height: 60px; font-size: 11px; }
|
||||||
|
|
||||||
|
/* Tool blocks */
|
||||||
|
.studio-tool-args { font-size: 11px; }
|
||||||
|
.studio-tool-result { max-height: 150px; }
|
||||||
|
.studio-tool-result pre { font-size: 11px; }
|
||||||
|
|
||||||
|
/* Code blocks */
|
||||||
|
.studio-code-block pre { padding: 8px 10px; font-size: 12px; }
|
||||||
|
.studio-code-header { }
|
||||||
|
.studio-code-lang { font-size: 10px; padding: 3px 8px; }
|
||||||
|
|
||||||
|
/* Messages */
|
||||||
|
.message { max-width: 95%; padding: 10px 12px; font-size: 13px; }
|
||||||
|
|
||||||
|
/* Shell / Terminal — AI panel stacks below on narrow screens */
|
||||||
|
.shell-ai-col {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
border-left: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
max-height: 45vh;
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
.ai-panel-messages { padding: 8px; }
|
||||||
|
.ai-message { padding: 6px 8px; font-size: 12px; }
|
||||||
|
.ai-panel-input input { font-size: 12px; padding: 5px 8px; }
|
||||||
|
.shell-modal-row { grid-template-columns: 1fr; gap: 6px; }
|
||||||
|
|
||||||
|
/* Config */
|
||||||
|
.config-tabs-bar { padding: 8px 12px; gap: 2px; overflow-x: auto; scrollbar-width: none; }
|
||||||
|
.config-tabs-bar::-webkit-scrollbar { display: none; }
|
||||||
|
.config-panel-body { padding: 10px 12px 12px; }
|
||||||
|
.config-card { padding: 14px 16px; }
|
||||||
|
.config-card-row { flex-wrap: wrap; gap: 6px; }
|
||||||
|
.config-card-label { width: 100%; margin-bottom: 2px; }
|
||||||
|
.config-card-value { width: 100%; }
|
||||||
|
.config-form-input { font-size: 12px; padding: 6px 10px; }
|
||||||
|
.provider-card-top { flex-wrap: wrap; }
|
||||||
|
.provider-card-meta { flex-wrap: wrap; gap: 8px; }
|
||||||
|
.provider-card-actions { width: 100%; justify-content: flex-end; }
|
||||||
|
.provider-setup-token-row { flex-direction: column; gap: 8px; }
|
||||||
|
.provider-setup-token-actions { width: 100%; }
|
||||||
|
.config-update-controls { flex-direction: column; gap: 8px; }
|
||||||
|
.config-update-info { flex-wrap: wrap; gap: 6px; }
|
||||||
|
.config-ai-tools-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }
|
||||||
|
.config-ai-tool-card { padding: 10px; min-height: 100px; }
|
||||||
|
|
||||||
|
/* Skills */
|
||||||
|
.skill-tiles { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }
|
||||||
|
.skill-tile { padding: 10px; }
|
||||||
|
.skill-detail-panel { width: 95%; max-height: 90vh; }
|
||||||
|
|
||||||
|
/* Grids */
|
||||||
|
.grid-2 { grid-template-columns: 1fr; padding: 10px; }
|
||||||
|
.split-horizontal { flex-direction: column; }
|
||||||
|
.split-right { width: 100%; border-left: none; border-top: 1px solid var(--border); max-height: 300px; }
|
||||||
|
|
||||||
|
/* Onboarding */
|
||||||
|
.onboarding-overlay { padding: 12px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile portrait ── */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
:root { --header-h: 42px; }
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header { padding: 0 6px; gap: 4px; }
|
||||||
|
.header-brand { gap: 2px; }
|
||||||
|
.header-logo { font-size: 12px; letter-spacing: 1px; }
|
||||||
|
.header-logo-img { width: 16px !important; height: 16px !important; }
|
||||||
|
.header-nav { margin-left: 4px; gap: 1px; }
|
||||||
|
.nav-tab { padding: 4px 6px; font-size: 10px; gap: 0; }
|
||||||
|
.nav-tab .tab-icon { display: none; }
|
||||||
|
.header-clock { font-size: 9px; }
|
||||||
|
.header-indicators { gap: 4px; }
|
||||||
|
.indicator { width: 6px; height: 6px; }
|
||||||
|
|
||||||
|
/* Statusbar */
|
||||||
|
.statusbar { height: 24px; padding: 0 6px; }
|
||||||
|
.statusbar-sudo { font-size: 8px; padding: 0 3px; }
|
||||||
|
|
||||||
|
/* Dashboard */
|
||||||
|
.dash-grid { padding: 6px; gap: 6px; }
|
||||||
|
.dash-card { padding: 8px 10px; }
|
||||||
|
.dash-label { font-size: 10px; }
|
||||||
|
.dash-count { font-size: 9px; }
|
||||||
|
.dash-tool-tag { font-size: 10px; padding: 2px 5px; }
|
||||||
|
.dash-quota-name { font-size: 10px; min-width: 60px; }
|
||||||
|
.dash-consumption-name { font-size: 10px; }
|
||||||
|
.dash-consumption-day { font-size: 8px; }
|
||||||
|
.dash-proc-name { font-size: 10px; }
|
||||||
|
.dash-cmd-text { font-size: 10px; }
|
||||||
|
.dash-cmd-freq-name { font-size: 10px; width: 70px; }
|
||||||
|
.dash-svc-name { font-size: 10px; }
|
||||||
|
|
||||||
|
/* Studio */
|
||||||
|
.studio-feed { padding: 6px 4px; }
|
||||||
|
.studio-input-area { padding: 6px 4px 3px; }
|
||||||
|
.studio-input-row { gap: 4px; }
|
||||||
|
.studio-input-row textarea { min-height: 32px; padding: 6px 8px; font-size: 12px; }
|
||||||
|
.studio-send-btn, .studio-stop-btn, .studio-attach-btn { width: 32px; height: 32px; }
|
||||||
|
.studio-send-btn svg, .studio-stop-btn svg, .studio-attach-btn svg { width: 14px; height: 14px; }
|
||||||
|
.studio-image-preview { width: 60px; height: 60px; }
|
||||||
|
.studio-image-remove { width: 18px; height: 18px; font-size: 11px; top: 2px; right: 2px; }
|
||||||
|
.studio-input-hint { font-size: 9px; }
|
||||||
|
.studio-scroll-btn { width: 28px; height: 28px; }
|
||||||
|
.studio-scroll-btn svg { width: 12px; height: 12px; }
|
||||||
|
.studio-copy-btn { font-size: 9px; padding: 2px 6px; }
|
||||||
|
|
||||||
|
/* Feed */
|
||||||
|
.feed-item { padding: 3px 4px; gap: 4px; }
|
||||||
|
.feed-avatar { width: 20px; height: 20px; font-size: 11px; }
|
||||||
|
.feed-body { min-width: 0; }
|
||||||
|
.feed-header { gap: 3px; margin-bottom: 1px; }
|
||||||
|
.feed-rank-badge { font-size: 7px; padding: 0 3px; }
|
||||||
|
.feed-role { font-size: 9px; }
|
||||||
|
.feed-time { font-size: 8px; }
|
||||||
|
.feed-content { font-size: 12px; line-height: 1.4; }
|
||||||
|
.feed-content h1 { font-size: 16px; }
|
||||||
|
.feed-content h2 { font-size: 14px; }
|
||||||
|
.feed-content h3 { font-size: 13px; }
|
||||||
|
.feed-content h4 { font-size: 12px; }
|
||||||
|
.feed-content table { font-size: 10px; }
|
||||||
|
.feed-content th, .feed-content td { padding: 2px 4px; }
|
||||||
|
.feed-image { max-width: 120px; max-height: 90px; }
|
||||||
|
.feed-system-text { font-size: 11px; }
|
||||||
|
|
||||||
|
/* Thinking */
|
||||||
|
.feed-thinking-header { padding: 4px 6px; font-size: 9px; }
|
||||||
|
.feed-thinking-content { padding: 4px 6px; font-size: 10px; max-height: 50px; }
|
||||||
|
|
||||||
|
/* Tool blocks */
|
||||||
|
.studio-tool-header { padding: 4px 6px; gap: 4px; font-size: 11px; }
|
||||||
|
.studio-tool-name { font-size: 11px; }
|
||||||
|
.studio-tool-args { padding: 4px 6px; font-size: 10px; }
|
||||||
|
.studio-tool-result { max-height: 120px; }
|
||||||
|
.studio-tool-result pre { padding: 4px 6px; font-size: 10px; }
|
||||||
|
|
||||||
|
/* Code */
|
||||||
|
.studio-code-block pre { padding: 6px 8px; font-size: 11px; }
|
||||||
|
.studio-code-lang { font-size: 9px; padding: 2px 6px; }
|
||||||
|
|
||||||
|
/* Markdown content */
|
||||||
|
.feed-content blockquote { padding: 3px 8px; }
|
||||||
|
.feed-content ul, .feed-content ol { padding-left: 14px; }
|
||||||
|
.msg-bullet, .msg-step { font-size: 12px; }
|
||||||
|
.msg-h1 { font-size: 16px; }
|
||||||
|
.msg-h2 { font-size: 14px; }
|
||||||
|
.msg-h3 { font-size: 13px; }
|
||||||
|
.msg-h4 { font-size: 12px; }
|
||||||
|
.inline-code { font-size: 11px; padding: 1px 4px; }
|
||||||
|
|
||||||
|
/* Messages */
|
||||||
|
.message { max-width: 98%; padding: 8px 10px; font-size: 12px; line-height: 1.4; }
|
||||||
|
.chat-input-bar { padding: 10px 8px; gap: 6px; }
|
||||||
|
.chat-input-bar input { font-size: 13px; }
|
||||||
|
|
||||||
|
/* Shell — only AI panel shrinks, terminal stays full size */
|
||||||
|
.shell-ai-col { max-height: 40vh; }
|
||||||
|
.ai-panel-header { padding: 8px 10px; font-size: 12px; }
|
||||||
|
.ai-panel-messages { padding: 6px; gap: 4px; }
|
||||||
|
.ai-message { padding: 5px 6px; font-size: 11px; }
|
||||||
|
.ai-panel-input { padding: 6px 8px; gap: 4px; }
|
||||||
|
.ai-panel-input input { font-size: 11px; padding: 4px 6px; }
|
||||||
|
|
||||||
|
/* Config */
|
||||||
|
.config-tabs-bar { padding: 6px 8px; gap: 1px; }
|
||||||
|
.nav-tab { padding: 4px 6px; font-size: 10px; }
|
||||||
|
.config-panel-body { padding: 8px 8px 8px; }
|
||||||
|
.config-card { padding: 10px 12px; margin-bottom: 10px; border-radius: var(--radius); }
|
||||||
|
.config-card-row { padding: 6px 0; gap: 4px; }
|
||||||
|
.config-card-label { width: 100%; font-size: 11px; }
|
||||||
|
.config-card-value { font-size: 12px; }
|
||||||
|
.config-form-label { font-size: 10px; }
|
||||||
|
.config-form-input { font-size: 11px; padding: 5px 8px; }
|
||||||
|
.provider-card-v2 { padding: 10px 12px; }
|
||||||
|
.provider-card-name { font-size: 12px; }
|
||||||
|
.provider-card-meta { font-size: 10px; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.provider-card-model { margin-top: 8px; padding-top: 8px; gap: 4px; }
|
||||||
|
.provider-card-model-value { font-size: 12px; }
|
||||||
|
.provider-setup-hint { font-size: 11px; padding: 8px 10px; }
|
||||||
|
.config-update-row { padding: 6px 10px; flex-wrap: wrap; gap: 4px; }
|
||||||
|
.config-update-name { font-size: 11px; min-width: auto; }
|
||||||
|
.config-update-versions { font-size: 10px; }
|
||||||
|
.config-ai-tools-grid { grid-template-columns: 1fr 1fr; gap: 6px; }
|
||||||
|
.config-ai-tool-card { padding: 8px; min-height: 80px; }
|
||||||
|
.config-ai-tool-name { font-size: 11px; }
|
||||||
|
.config-ai-tool-desc { font-size: 10px; }
|
||||||
|
.config-toast { padding: 8px 16px; font-size: 12px; bottom: 30px; }
|
||||||
|
|
||||||
|
/* Skills */
|
||||||
|
.skill-tiles { grid-template-columns: 1fr 1fr; gap: 6px; }
|
||||||
|
.skill-tile { padding: 8px; }
|
||||||
|
.skill-tile-name { font-size: 12px; }
|
||||||
|
.skill-tile-desc { font-size: 10px; -webkit-line-clamp: 2; }
|
||||||
|
.skill-detail-panel { width: 98%; max-height: 95vh; }
|
||||||
|
.skill-detail-header { padding: 12px 14px; }
|
||||||
|
.skill-detail-name { font-size: 14px; }
|
||||||
|
.skill-detail-body { padding: 14px; }
|
||||||
|
.skill-detail-content { font-size: 10px; padding: 8px; max-height: 200px; }
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
button { font-size: 12px; padding: 6px 12px; }
|
||||||
|
button.sm { font-size: 11px; padding: 3px 8px; }
|
||||||
|
input, textarea { font-size: 12px; padding: 6px 10px; }
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card { padding: 12px; border-radius: var(--radius); }
|
||||||
|
.card-header { font-size: 10px; margin-bottom: 10px; }
|
||||||
|
.badge { font-size: 10px; padding: 1px 6px; }
|
||||||
|
.chip { font-size: 11px; padding: 4px 8px; }
|
||||||
|
|
||||||
|
/* Empty states */
|
||||||
|
.empty-state { padding: 24px 12px; font-size: 12px; }
|
||||||
|
|
||||||
|
/* KaTeX */
|
||||||
|
.katex { font-size: 0.85em; }
|
||||||
|
.katex-display { margin: 8px 0; overflow-x: auto; }
|
||||||
|
}
|
||||||
|
|
||||||
.config-ai-tools-grid {
|
.config-ai-tools-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
@@ -1360,3 +1769,228 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === Split Panes === */
|
||||||
|
.shell-split-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-split-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-agent-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--accent-dim);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
animation: agent-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-agent-count {
|
||||||
|
min-width: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes agent-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-xterm-wrapper.has-splits {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-pane-split {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-pane-split.row {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-pane-split.column {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-pane-child {
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-pane-resizer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--border);
|
||||||
|
transition: background 0.15s;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-pane-split.row > .split-pane-resizer {
|
||||||
|
width: 4px;
|
||||||
|
cursor: col-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-pane-split.column > .split-pane-resizer {
|
||||||
|
height: 4px;
|
||||||
|
cursor: row-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-pane-resizer:hover,
|
||||||
|
.split-pane-resizer:active {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-pane-leaf {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-pane-leaf.active {
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-pane-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-pane-title {
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-pane-close {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-disabled);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-pane-close:hover {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-pane-content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-pane-leaf.empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-pane-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: var(--text-disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === File Editor === */
|
||||||
|
.file-editor-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-editor-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-editor-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-editor-dirty {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-editor-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-editor-lang-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-editor-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-editor-body .cm-editor {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,9 +3,13 @@ import react from '@vitejs/plugin-react'
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
css: {
|
||||||
|
transformer: 'postcss',
|
||||||
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
|
cssMinify: false,
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
|||||||
Reference in New Issue
Block a user