Compare commits
16 Commits
v0.2.1-bet
...
v0.3.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cdcb22068 | ||
|
|
ee18bbeb53 | ||
|
|
b0b0e1d308 | ||
|
|
fc7981037f | ||
|
|
f7222b0f6c | ||
|
|
11417d3ea7 | ||
|
|
3dc24ae22c | ||
|
|
aa0ff199c6 | ||
|
|
34636056da | ||
|
|
097cf40ccd | ||
|
|
88d2a03808 | ||
|
|
1830c18c7a | ||
|
|
cb8e3d0d26 | ||
|
|
8ea7418684 | ||
|
|
ec33ff4e4d | ||
|
|
6dad84067d |
@@ -17,6 +17,11 @@ jobs:
|
||||
with:
|
||||
go-version: '1.24.3'
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -27,9 +32,23 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Download dependencies
|
||||
- name: Cache Node modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: web/node_modules
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('web/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
|
||||
- name: Download Go dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Build frontend
|
||||
run: |
|
||||
cd web
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
@@ -49,7 +68,7 @@ jobs:
|
||||
echo "beta_num=${BETA_NUM}" >> $GITHUB_OUTPUT
|
||||
echo "Building beta release: ${VERSION}"
|
||||
|
||||
- name: Build all platforms
|
||||
- name: Build (all platforms)
|
||||
run: |
|
||||
mkdir -p dist
|
||||
VERSION=${{ steps.version.outputs.version }}
|
||||
|
||||
@@ -17,6 +17,11 @@ jobs:
|
||||
with:
|
||||
go-version: '1.24.3'
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -27,9 +32,23 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Cache Node modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: web/node_modules
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('web/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Build frontend
|
||||
run: |
|
||||
cd web
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
@@ -45,7 +64,7 @@ jobs:
|
||||
echo "base=${BASE_VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "Building stable release: ${VERSION}"
|
||||
|
||||
- name: Build all platforms
|
||||
- name: Build (all platforms)
|
||||
run: |
|
||||
mkdir -p dist
|
||||
LDFLAGS="-s -w"
|
||||
@@ -100,6 +119,9 @@ jobs:
|
||||
echo "| Windows x86_64 | [muyue-windows-amd64.zip](${DL_URL}/muyue-windows-amd64.zip) |"
|
||||
echo "| Windows ARM64 | [muyue-windows-arm64.zip](${DL_URL}/muyue-windows-arm64.zip) |"
|
||||
echo ""
|
||||
echo "The binary includes both CLI and Desktop modes."
|
||||
echo "Run \`muyue\` for TUI, \`muyue desktop\` for web UI."
|
||||
echo ""
|
||||
echo "### Install"
|
||||
echo ""
|
||||
echo "**Linux (x86_64)**"
|
||||
|
||||
@@ -15,6 +15,11 @@ jobs:
|
||||
with:
|
||||
go-version: '1.24.3'
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -25,9 +30,23 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Cache Node modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: web/node_modules
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('web/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Build frontend
|
||||
run: |
|
||||
cd web
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -28,3 +28,6 @@ vendor/
|
||||
|
||||
# Config with secrets
|
||||
.muyue/
|
||||
|
||||
# Frontend (web/.gitignore handles specifics)
|
||||
web/node_modules/
|
||||
|
||||
135
CHANGELOG.md
135
CHANGELOG.md
@@ -4,6 +4,141 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Desktop mode**: React 19 web UI served locally, auto-opens in browser. Frontend embedded in Go binary via `go:embed`.
|
||||
- **API backend**: 15 REST endpoints (`/api/info`, `/api/system`, `/api/tools`, `/api/config`, `/api/providers`, `/api/skills`, `/api/lsp`, `/api/mcp`, `/api/updates`, `/api/scan`, `/api/install`, `/api/terminal`, `/api/mcp/configure`, `/api/preferences`).
|
||||
- **i18n**: Full FR/EN translation system with keyboard layout awareness (AZERTY, QWERTY, QWERTZ). Preferences synced to backend.
|
||||
- **Themes**: 4 built-in themes (Cyberpunk Red, Cyberpunk Pink, Midnight Blue, Matrix Green) with 30+ CSS custom properties applied at runtime.
|
||||
- **Desktop flags**: `--port=PORT` to specify port, `--no-open` to skip browser auto-open.
|
||||
- **SPA routing**: Frontend handles client-side routing via catch-all fallback.
|
||||
- **CI**: Frontend build step (`npm ci && npm run build`) added to all 3 CI pipelines.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Default mode**: `muyue` now launches the desktop web app instead of the TUI. The TUI has been removed entirely.
|
||||
- **Single binary**: `cmd/muyue-desktop` merged into `cmd/muyue`. Only one binary needed.
|
||||
- **Frontend**: Moved from `cmd/muyue-desktop/frontend/` to `web/` and embedded via `web/embed.go`.
|
||||
- **Go module**: Dependencies cleaned up — removed indirect TUI-related packages.
|
||||
- **Makefile**: `build` target now runs `frontend` (npm build) automatically. Added `dev-desktop` target for Vite dev server.
|
||||
|
||||
### Removed
|
||||
|
||||
- **TUI**: All `internal/tui/` code removed (model, views, handlers, animations, terminal, styles).
|
||||
- **`cmd/muyue-desktop/`**: Separate desktop binary removed; merged into main binary.
|
||||
|
||||
## v0.2.1
|
||||
|
||||
### Downloads
|
||||
|
||||
| Platform | File |
|
||||
|----------|------|
|
||||
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-linux-amd64.tar.gz) |
|
||||
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-linux-arm64.tar.gz) |
|
||||
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-darwin-amd64.tar.gz) |
|
||||
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-darwin-arm64.tar.gz) |
|
||||
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-windows-amd64.zip) |
|
||||
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-windows-arm64.zip) |
|
||||
|
||||
### Changes since v0.2.0
|
||||
|
||||
- feat: complete TUI redesign with cyberpunk theme (#1) (cb8e3d0)
|
||||
- chore: bump version to 0.2.1, update README for TUI redesign (22fb282)
|
||||
|
||||
### Install
|
||||
|
||||
**Linux (x86_64)**
|
||||
```bash
|
||||
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-linux-amd64.tar.gz | tar xz
|
||||
chmod +x muyue-linux-amd64
|
||||
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
|
||||
```
|
||||
|
||||
**macOS (Apple Silicon)**
|
||||
```bash
|
||||
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-darwin-arm64.tar.gz | tar xz
|
||||
chmod +x muyue-darwin-arm64
|
||||
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
|
||||
```
|
||||
|
||||
**Windows (x86_64)**
|
||||
```powershell
|
||||
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-windows-amd64.zip" -OutFile "muyue.zip"
|
||||
Expand-Archive -Path "muyue.zip" -DestinationPath "."
|
||||
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
|
||||
```
|
||||
|
||||
## v0.2.0
|
||||
|
||||
### Downloads
|
||||
|
||||
| Platform | File |
|
||||
|----------|------|
|
||||
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-linux-amd64.tar.gz) |
|
||||
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-linux-arm64.tar.gz) |
|
||||
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-darwin-amd64.tar.gz) |
|
||||
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-darwin-arm64.tar.gz) |
|
||||
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-windows-amd64.zip) |
|
||||
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-windows-arm64.zip) |
|
||||
|
||||
### Changes since start
|
||||
|
||||
- refactor: redesign TUI with 4 tabs, red/rose theme, split layouts (035e923)
|
||||
- feat: GitFlow workflow with beta/stable CI pipelines (bbdac6c)
|
||||
- feat: security hardening, tests, doctor command, CI update, CHANGELOG (3494f6b)
|
||||
- refactor: modularize TUI, improve error handling, add CI caching and tests (4469122)
|
||||
- fix: remove tab switching, filter AI thinking from responses (5a33dfc)
|
||||
- fix: enable text selection, dashboard multi-column layout (82b2816)
|
||||
- feat: Ctrl+T tab switcher, minimal header, integrated terminal (2d6fc64)
|
||||
- feat: Ctrl+M tab switcher overlay menu (bb3b303)
|
||||
- fix: docker version check, uv PATH, install progress bar (e6fdec4)
|
||||
- feat: smart setup wizard - sort choices by system detection (1be4fc0)
|
||||
- fix: use Alt+1-5 for tab navigation to free number keys for input (825b429)
|
||||
- ci: add install instructions for all platforms in release body (ac35ff2)
|
||||
- ci: add build + release steps with push-only conditions (bcb9aa0)
|
||||
- ci: restore exact working ci.yml from e58e00d for testing (0a91cef)
|
||||
- fix: rename workflow back to CI (slash in name breaks Gitea 1.25) (461122a)
|
||||
- ci: trigger workflow run (ea59c2c)
|
||||
- fix: remove workflow_dispatch + add push-only conditions on release steps (9cd583f)
|
||||
- ci: single job - build + vet + release latest in one pass (92275be)
|
||||
- ci: merge CI and Release into single workflow (f2c0996)
|
||||
- fix: release workflow - delete old release before creating new one (5eb237f)
|
||||
- feat: redesign TUI + Ctrl+C quit confirm + version logic + sudo handling (e3cd618)
|
||||
- feat: add mouse support + install pnpm, uv, docker, gh (e58e00d)
|
||||
- fix: use GITEATOKEN secret name (no underscores in Gitea 1.25) (8e3f8b8)
|
||||
- fix: make release delete step resilient + check GITEA_TOKEN (69ca5c6)
|
||||
- fix: remove redundant newline in profiler.go (go vet) (2d421fe)
|
||||
- fix: export PATH in every step for Gitea runner compatibility (3f8e01f)
|
||||
- ci: restore actions/checkout + simplify workflows (4db69e4)
|
||||
- fix: add missing cmd/muyue/main.go and fix .gitignore (f650988)
|
||||
- ci: fix Gitea Actions - native checkout + auto-release on push (78c7239)
|
||||
- ci: migrate workflows to Gitea Actions with self-hosted runner (811a9aa)
|
||||
|
||||
### Install
|
||||
|
||||
**Linux (x86_64)**
|
||||
```bash
|
||||
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-linux-amd64.tar.gz | tar xz
|
||||
chmod +x muyue-linux-amd64
|
||||
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
|
||||
```
|
||||
|
||||
**macOS (Apple Silicon)**
|
||||
```bash
|
||||
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-darwin-arm64.tar.gz | tar xz
|
||||
chmod +x muyue-darwin-arm64
|
||||
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
|
||||
```
|
||||
|
||||
**Windows (x86_64)**
|
||||
```powershell
|
||||
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-windows-amd64.zip" -OutFile "muyue.zip"
|
||||
Expand-Archive -Path "muyue.zip" -DestinationPath "."
|
||||
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
|
||||
```
|
||||
|
||||
## [0.2.0] - 2026-04-20
|
||||
|
||||
### Added
|
||||
|
||||
20
Makefile
20
Makefile
@@ -3,10 +3,16 @@ GOBIN ?= $(GOPATH)/bin
|
||||
BINARY = muyue
|
||||
BUILD_DIR = .
|
||||
GO = go
|
||||
NODE ?= node
|
||||
NPM ?= npm
|
||||
WEB_DIR = web
|
||||
|
||||
.PHONY: build install clean test test-short run scan fmt lint build-all deps vet
|
||||
.PHONY: build install clean test test-short run scan fmt lint build-all deps vet frontend dev-desktop
|
||||
|
||||
build:
|
||||
frontend:
|
||||
cd $(WEB_DIR) && $(NPM) ci && $(NPM) run build
|
||||
|
||||
build: frontend
|
||||
$(GO) build -o $(BUILD_DIR)/$(BINARY) ./cmd/muyue/
|
||||
|
||||
install: build
|
||||
@@ -18,6 +24,8 @@ install-local: build
|
||||
|
||||
clean:
|
||||
rm -f $(BUILD_DIR)/$(BINARY)
|
||||
rm -rf $(WEB_DIR)/dist
|
||||
rm -rf $(WEB_DIR)/node_modules
|
||||
|
||||
test:
|
||||
$(GO) test ./... -v -count=1
|
||||
@@ -31,6 +39,12 @@ vet:
|
||||
run: build
|
||||
./$(BINARY)
|
||||
|
||||
desktop: build
|
||||
./$(BINARY) desktop
|
||||
|
||||
dev-desktop:
|
||||
cd $(WEB_DIR) && $(NPM) run dev
|
||||
|
||||
scan: build
|
||||
./$(BINARY) scan
|
||||
|
||||
@@ -41,7 +55,7 @@ fmt:
|
||||
lint:
|
||||
which golangci-lint > /dev/null 2>&1 && golangci-lint run || true
|
||||
|
||||
build-all:
|
||||
build-all: frontend
|
||||
GOOS=linux GOARCH=amd64 $(GO) build -o dist/$(BINARY)-linux-amd64 ./cmd/muyue/
|
||||
GOOS=linux GOARCH=arm64 $(GO) build -o dist/$(BINARY)-linux-arm64 ./cmd/muyue/
|
||||
GOOS=darwin GOARCH=amd64 $(GO) build -o dist/$(BINARY)-darwin-amd64 ./cmd/muyue/
|
||||
|
||||
174
README.md
174
README.md
@@ -4,25 +4,30 @@ AI-powered development environment assistant by **La Légion de Muyue**.
|
||||
|
||||
## What it does
|
||||
|
||||
`muyue` is a single binary that transforms your entire development environment:
|
||||
`muyue` is a single binary (frontend embedded) that transforms your entire development environment:
|
||||
|
||||
- **Desktop app** — React web UI served locally, auto-opens in your browser
|
||||
- **Scans** your system for tools, runtimes, and configs
|
||||
- **Installs** missing tools automatically (Crush, Claude Code, BMAD, Starship, runtimes...)
|
||||
- **Updates** everything in the background
|
||||
- **Profiles** you on first run to personalize the experience
|
||||
- **Unifies** control of Crush and Claude Code from one TUI
|
||||
- **Unifies** control of Crush and Claude Code from one interface
|
||||
- **Orchestrates** AI agents via MiniMax M2.7
|
||||
- **Customizes** your terminal prompt (branch, commits, language, etc.)
|
||||
- **Configures** MCP servers, LSPs, and skills automatically
|
||||
- **Previews** HTML/visual outputs in your browser
|
||||
- **i18n** — Full FR/EN support with keyboard layout awareness (AZERTY, QWERTY, QWERTZ)
|
||||
- **4 themes** — Cyberpunk Red, Cyberpunk Pink, Midnight Blue, Matrix Green
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Go** — single binary, no dependencies
|
||||
- **Charm** — Bubble Tea, Lip Gloss, Huh (TUI, styling, forms)
|
||||
- **Starship** — terminal prompt customization
|
||||
- **MiniMax M2.7** — AI orchestration
|
||||
- **BMAD-METHOD** — structured development workflows
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| **Backend** | Go 1.24 — single binary, no runtime dependencies |
|
||||
| **Frontend** | React 19, Vite 8 — embedded via `go:embed` |
|
||||
| **Styling** | CSS custom properties, 4 built-in themes |
|
||||
| **i18n** | Custom FR/EN system with keyboard layout awareness |
|
||||
| **CLI** | Charm (Bubble Tea, Huh) — for setup wizard, profiler, and CLI commands |
|
||||
| **AI** | MiniMax M2.7 — orchestration |
|
||||
| **CI/CD** | Gitea Actions — Go + Node build, multi-platform releases |
|
||||
|
||||
## Install
|
||||
|
||||
@@ -37,10 +42,14 @@ make build
|
||||
make install-local
|
||||
```
|
||||
|
||||
The frontend is built automatically during `make build` (runs `npm ci && npm run build` in `web/`).
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
muyue # Start interactive TUI
|
||||
muyue # Launch desktop app (opens browser)
|
||||
muyue --port=8080 # Launch on a specific port
|
||||
muyue --no-open # Launch without opening the browser
|
||||
muyue scan # Scan system
|
||||
muyue install # Install missing tools
|
||||
muyue update # Check and apply updates
|
||||
@@ -76,55 +85,116 @@ muyue skills deploy # Deploy skills to Crush and Claude Code
|
||||
muyue skills delete <name> # Delete a skill
|
||||
```
|
||||
|
||||
## TUI — 4 Tabs
|
||||
## Desktop App — 4 Tabs
|
||||
|
||||
The TUI is organized into 4 tabs with a red/rose theme (`#E8364F` → `#FF6B8A`):
|
||||
The web UI is organized into 4 tabs with a cyberpunk dark theme. Navigate with `Ctrl+1` through `Ctrl+4`.
|
||||
|
||||
### ◉ Dashboard
|
||||
### ■ Dashboard
|
||||
|
||||
System overview: installed tools with status, active agents, updates, LSP/MCP/daemon status, and quick actions (install, update, scan).
|
||||
System overview with sub-tabs:
|
||||
- **Tools** — installed/missing tools with status badges and version info
|
||||
- **Notifications** — activity log with colored severity
|
||||
- **Workflows** — quick actions (install missing, check updates, rescan, configure MCP)
|
||||
|
||||
### ◈ Studio
|
||||
### ⟨⟩ Studio
|
||||
|
||||
Central AI chat with a collapsible sidebar (`Ctrl+S`) containing 3 panels:
|
||||
AI chat interface with a sidebar containing 3 panels:
|
||||
|
||||
| Panel | Shortcut | Description |
|
||||
|-------|----------|-------------|
|
||||
| **Chat** | `1` | AI conversation, `/plan <goal>` to start workflows |
|
||||
| **Agents** | `2` | Start/stop Crush and Claude Code agents |
|
||||
| **Workflows** | `3` | Plan→Execute workflow controls (approve, reject, next step) |
|
||||
| Panel | Description |
|
||||
|-------|-------------|
|
||||
| **Chat** | AI conversation, `/plan <goal>` to start workflows |
|
||||
| **Agents** | Status of Crush and Claude Code agents |
|
||||
| **Workflows** | Plan→Execute workflow controls |
|
||||
|
||||
### ▶ Shell
|
||||
### $ Shell
|
||||
|
||||
Split-view terminal with an AI assistant panel (`Ctrl+A` to toggle). The AI knows your system and suggests commands you can easily copy into the terminal.
|
||||
Split-view: terminal emulator on the left (sends commands to the Go backend), collapsible AI assistant panel on the right. Full command history with `↑`/`↓` navigation.
|
||||
|
||||
### ⚙ Config
|
||||
|
||||
Profile, API providers, terminal/starship settings, BMAD, and skills — displayed in a two-column layout.
|
||||
Two-column profile settings:
|
||||
- **Profile** — name, pseudo, email, editor, shell, default AI, languages
|
||||
- **AI Providers** — active provider, API key status, model info
|
||||
- **Theme** — 4 swatches (Cyberpunk Red, Cyberpunk Pink, Midnight Blue, Matrix Green)
|
||||
- **Language** — FR/EN with keyboard layout selection (AZERTY, QWERTY, QWERTZ)
|
||||
- **Skills** — installed skills list
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
| Key | Context | Action |
|
||||
|-----|---------|--------|
|
||||
| `Ctrl+T` | Global | Open tab switcher |
|
||||
| `Ctrl+S` | Studio | Toggle sidebar |
|
||||
| `Ctrl+A` | Shell | Toggle AI assistant panel |
|
||||
| `Ctrl+C` | Global | Quit confirmation |
|
||||
| `i` | Dashboard | Install missing tools |
|
||||
| `u` | Dashboard | Check for updates |
|
||||
| `s` | Dashboard | Rescan system |
|
||||
| `1` `2` `3` | Studio sidebar | Switch panels (Chat/Agents/Workflows) |
|
||||
| `a` | Workflow | Approve plan |
|
||||
| `r` | Workflow | Reject plan |
|
||||
| `g` | Workflow | Generate plan |
|
||||
| `n` | Workflow | Next step |
|
||||
| `x` | Workflow | Cancel workflow |
|
||||
| `Ctrl+1` | Global | Dashboard tab |
|
||||
| `Ctrl+2` | Global | Studio tab |
|
||||
| `Ctrl+3` | Global | Shell tab |
|
||||
| `Ctrl+4` | Global | Config tab |
|
||||
| `Enter` | Studio | Send message |
|
||||
| `Shift+Enter` | Studio | New line |
|
||||
| `Enter` | Shell | Run command |
|
||||
| `↑`/`↓` | Shell | Command history |
|
||||
|
||||
## API Endpoints
|
||||
|
||||
The Go backend serves 15 REST endpoints under `/api/`:
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/info` | GET | App name, version, author |
|
||||
| `/api/system` | GET | OS, arch, platform info |
|
||||
| `/api/tools` | GET | Tool scan results |
|
||||
| `/api/config` | GET | Profile, terminal, BMAD config |
|
||||
| `/api/providers` | GET | AI provider list |
|
||||
| `/api/skills` | GET | Installed skills |
|
||||
| `/api/lsp` | GET | LSP server scan |
|
||||
| `/api/mcp` | GET | MCP server scan |
|
||||
| `/api/updates` | GET | Update check results |
|
||||
| `/api/scan` | POST | Trigger system rescan |
|
||||
| `/api/install` | POST | Install tools `{"tools": [...]}` |
|
||||
| `/api/terminal` | POST | Execute command `{"command": "...", "cwd": "..."}` |
|
||||
| `/api/mcp/configure` | POST | Configure MCP servers |
|
||||
| `/api/preferences` | PUT | Save language/keyboard preferences |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── cmd/muyue/main.go # CLI entry point + command routing
|
||||
├── internal/
|
||||
│ ├── api/ # HTTP server + handlers (15 endpoints)
|
||||
│ ├── config/ # YAML config + XDG paths
|
||||
│ ├── daemon/ # Background daemon
|
||||
│ ├── desktop/ # Desktop mode (HTTP server + SPA)
|
||||
│ ├── installer/ # Tool installation logic
|
||||
│ ├── lsp/ # LSP server scan + install
|
||||
│ ├── mcp/ # MCP server configuration
|
||||
│ ├── orchestrator/ # AI agent orchestration
|
||||
│ ├── platform/ # Cross-platform abstractions
|
||||
│ ├── preview/ # HTML preview server
|
||||
│ ├── profiler/ # First-run setup wizard
|
||||
│ ├── proxy/ # AI proxy agents
|
||||
│ ├── scanner/ # System tool/runtime scanner
|
||||
│ ├── secret/ # AES-256-GCM key encryption
|
||||
│ ├── skills/ # Skills management (CRUD, deploy, AI-generate)
|
||||
│ ├── updater/ # Tool auto-updater
|
||||
│ ├── version/ # Version constants
|
||||
│ └── workflow/ # Plan→Execute workflow engine
|
||||
├── web/ # Frontend (React 19 + Vite)
|
||||
│ ├── embed.go # go:embed dist/
|
||||
│ ├── src/
|
||||
│ │ ├── api/client.js # API client
|
||||
│ │ ├── components/ # App, Dashboard, Studio, Shell, Config
|
||||
│ │ ├── i18n/ # FR/EN translations + keyboard layouts
|
||||
│ │ ├── styles/global.css # Full CSS theme system
|
||||
│ │ └── themes/index.js # 4 themes with CSS variable injection
|
||||
│ └── vite.config.js # Vite + dev proxy to :8095
|
||||
├── .gitea/workflows/ # CI/CD (PR check, beta, stable)
|
||||
└── Makefile # build, test, lint, cross-compile
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Config stored at `$XDG_CONFIG_HOME/muyue/config.yaml` (defaults to `~/.config/muyue/config.yaml`).
|
||||
|
||||
API keys are encrypted at rest using AES-GCM with a machine-local key stored in `~/.muyue_key`.
|
||||
API keys are encrypted at rest using AES-256-GCM with a machine-local key stored in `~/.muyue_key`.
|
||||
|
||||
First run launches an interactive profiling wizard that:
|
||||
1. Asks your name, pseudo, email
|
||||
@@ -133,17 +203,39 @@ First run launches an interactive profiling wizard that:
|
||||
4. Scans your system
|
||||
5. Installs missing tools
|
||||
|
||||
## Themes
|
||||
|
||||
4 built-in themes, selectable from the Config tab:
|
||||
|
||||
| Theme | Accent Color |
|
||||
|-------|-------------|
|
||||
| Cyberpunk Red | `#FF0033` |
|
||||
| Cyberpunk Pink | `#FF1A8C` |
|
||||
| Midnight Blue | `#0088FF` |
|
||||
| Matrix Green | `#00FF41` |
|
||||
|
||||
Themes are applied via CSS custom properties injected at runtime. All colors (30+ variables) adapt automatically.
|
||||
|
||||
## i18n & Keyboard Layouts
|
||||
|
||||
- **Languages**: Français, English
|
||||
- **Keyboard layouts**: AZERTY (fr-FR), QWERTY (en-US), QWERTZ (de-DE)
|
||||
- Keyboard layout affects displayed shortcuts in the status bar (e.g., `Ctrl+&-é-"-'` on AZERTY vs `Ctrl+1-4` on QWERTY)
|
||||
- Preferences saved to backend and synced across sessions
|
||||
|
||||
## Security
|
||||
|
||||
- API keys are encrypted at rest (AES-256-GCM) with a per-machine key
|
||||
- API keys encrypted at rest (AES-256-GCM) with a per-machine key
|
||||
- Config files use restrictive permissions (0600)
|
||||
- MCP config files use restrictive permissions (0600)
|
||||
- Integrated terminal blocks dangerous commands (rm -rf /, mkfs, fork bombs, etc.)
|
||||
- Terminal API executes commands via shell — only accessible on localhost
|
||||
|
||||
## Cross-Platform
|
||||
|
||||
Built for Linux (primary), macOS, and Windows. WSL supported.
|
||||
|
||||
Single binary includes both CLI and embedded web frontend.
|
||||
|
||||
## Contributing — GitFlow Workflow
|
||||
|
||||
This project uses a **lightweight GitFlow** with 2 permanent branches and conventional commits.
|
||||
@@ -179,6 +271,8 @@ hotfix/xxx ──PR (squash)──▶ main (+ backport develop)
|
||||
| `ci-develop.yml` | Push to `develop` | vet + test + build all platforms + create beta release |
|
||||
| `ci-main.yml` | Push to `main` | vet + test + build all platforms + update CHANGELOG.md + create stable release |
|
||||
|
||||
All CI pipelines build the frontend (`npm ci && npm run build`) before Go vet/test/build.
|
||||
|
||||
### Step-by-step: contribute a feature
|
||||
|
||||
```bash
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
"github.com/muyue/muyue/internal/desktop"
|
||||
"github.com/muyue/muyue/internal/installer"
|
||||
"github.com/muyue/muyue/internal/lsp"
|
||||
"github.com/muyue/muyue/internal/mcp"
|
||||
@@ -14,26 +14,33 @@ import (
|
||||
"github.com/muyue/muyue/internal/profiler"
|
||||
"github.com/muyue/muyue/internal/scanner"
|
||||
"github.com/muyue/muyue/internal/skills"
|
||||
"github.com/muyue/muyue/internal/tui"
|
||||
"github.com/muyue/muyue/internal/updater"
|
||||
"github.com/muyue/muyue/internal/version"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 {
|
||||
handleCommand(os.Args[1:])
|
||||
return
|
||||
if isCommand(os.Args[1]) {
|
||||
handleCommand(os.Args[1:])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
runTUI()
|
||||
runDesktop(os.Args[1:])
|
||||
}
|
||||
|
||||
func isCommand(arg string) bool {
|
||||
switch arg {
|
||||
case "version", "-v", "--version",
|
||||
"scan", "install", "update", "setup",
|
||||
"config", "doctor", "lsp", "mcp", "skills",
|
||||
"help", "-h", "--help":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func handleCommand(args []string) {
|
||||
if len(args) == 0 {
|
||||
runTUI()
|
||||
return
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "version", "-v", "--version":
|
||||
fmt.Println(version.FullVersion())
|
||||
@@ -57,10 +64,6 @@ func handleCommand(args []string) {
|
||||
runSkills(args[1:])
|
||||
case "help", "-h", "--help":
|
||||
printHelp()
|
||||
default:
|
||||
fmt.Printf("Unknown command: %s\n", args[0])
|
||||
printHelp()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,9 +71,13 @@ func printHelp() {
|
||||
fmt.Printf(`%s - AI-powered development environment assistant
|
||||
|
||||
Usage:
|
||||
muyue Start the interactive TUI
|
||||
muyue Launch desktop app (opens browser)
|
||||
muyue <command> Run a specific command
|
||||
|
||||
Options:
|
||||
--port=PORT Specify port (default: auto)
|
||||
--no-open Don't open browser automatically
|
||||
|
||||
Commands:
|
||||
version Show version
|
||||
scan Scan your system for tools and runtimes
|
||||
@@ -84,35 +91,15 @@ Commands:
|
||||
skills [list|generate|deploy|init|delete] Manage AI coding skills
|
||||
help Show this help
|
||||
|
||||
TUI Controls:
|
||||
Ctrl+T Open tab switcher (navigate with arrows, select with enter)
|
||||
Tab / Shift+Tab Cycle tabs
|
||||
Ctrl+C Show quit confirmation (press twice quickly to force quit)
|
||||
|
||||
Chat Commands:
|
||||
/plan <goal> Start a structured Plan→Execute workflow
|
||||
|
||||
Workflow Controls:
|
||||
[a] Approve plan
|
||||
[r] Reject plan (type feedback)
|
||||
[g] Generate plan (after answering questions)
|
||||
[n] Execute next step
|
||||
[x] Cancel/reset workflow
|
||||
|
||||
Note:
|
||||
Some tools (docker, gh, etc.) require elevated privileges.
|
||||
Run 'sudo muyue install' or use 'pkexec muyue install' if needed.
|
||||
`, version.FullVersion())
|
||||
}
|
||||
|
||||
func runTUI() {
|
||||
func runDesktop(args []string) {
|
||||
cfg := loadOrSetupConfig()
|
||||
result := scanner.ScanSystem()
|
||||
|
||||
model := tui.NewModel(cfg, result)
|
||||
p := tea.NewProgram(model, tea.WithAltScreen())
|
||||
|
||||
if _, err := p.Run(); err != nil {
|
||||
if err := desktop.Run(cfg, args); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
9
go.mod
9
go.mod
@@ -3,10 +3,9 @@ module github.com/muyue/muyue
|
||||
go 1.24.3
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/huh v1.0.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/creack/pty/v2 v2.0.1
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -14,8 +13,10 @@ require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
github.com/charmbracelet/bubbles v1.0.0 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||
|
||||
6
go.sum
6
go.sum
@@ -14,8 +14,6 @@ github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlv
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||
github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
|
||||
github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
@@ -46,10 +44,14 @@ github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/creack/pty/v2 v2.0.1 h1:RDY1VY5b+7m2mfPsugucOYPIxMp+xal5ZheSyVzUA+k=
|
||||
github.com/creack/pty/v2 v2.0.1/go.mod h1:2dSssKp3b86qYEMwA/FPwc3ff+kYpDdQI8osU8J7gxQ=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
|
||||
466
internal/api/handlers.go
Normal file
466
internal/api/handlers.go
Normal file
@@ -0,0 +1,466 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
"github.com/muyue/muyue/internal/lsp"
|
||||
"github.com/muyue/muyue/internal/mcp"
|
||||
"github.com/muyue/muyue/internal/orchestrator"
|
||||
"github.com/muyue/muyue/internal/scanner"
|
||||
"github.com/muyue/muyue/internal/skills"
|
||||
"github.com/muyue/muyue/internal/updater"
|
||||
"github.com/muyue/muyue/internal/version"
|
||||
)
|
||||
|
||||
func writeJSON(w http.ResponseWriter, data interface{}) {
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, msg string, code int) {
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"name": version.Name,
|
||||
"version": version.Version,
|
||||
"author": version.Author,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleSystem(w http.ResponseWriter, r *http.Request) {
|
||||
if s.scanResult == nil {
|
||||
s.scanResult = scanner.ScanSystem()
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"system": s.scanResult.System,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleTools(w http.ResponseWriter, r *http.Request) {
|
||||
if s.scanResult == nil {
|
||||
s.scanResult = scanner.ScanSystem()
|
||||
}
|
||||
type toolInfo struct {
|
||||
Name string `json:"name"`
|
||||
Installed bool `json:"installed"`
|
||||
Version string `json:"version"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
tools := make([]toolInfo, len(s.scanResult.Tools))
|
||||
for i, t := range s.scanResult.Tools {
|
||||
tools[i] = toolInfo{
|
||||
Name: t.Name,
|
||||
Installed: t.Installed,
|
||||
Version: t.Version,
|
||||
Path: t.Path,
|
||||
}
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"tools": tools,
|
||||
"total": len(tools),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if s.config == nil {
|
||||
writeError(w, "no config", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"profile": s.config.Profile,
|
||||
"terminal": s.config.Terminal,
|
||||
"bmad": s.config.BMAD,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleProviders(w http.ResponseWriter, r *http.Request) {
|
||||
if s.config == nil {
|
||||
writeError(w, "no config", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"providers": s.config.AI.Providers,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleSkills(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := skills.List()
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"skills": list,
|
||||
"count": len(list),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleLSP(w http.ResponseWriter, r *http.Request) {
|
||||
servers := lsp.ScanServers()
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"servers": servers,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
|
||||
servers := mcp.ScanServers()
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"servers": servers,
|
||||
"configured": true,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleMCPConfigure(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if err := mcp.ConfigureAll(s.config); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdates(w http.ResponseWriter, r *http.Request) {
|
||||
result := scanner.ScanSystem()
|
||||
statuses := updater.CheckUpdates(result)
|
||||
type updateInfo struct {
|
||||
Tool string `json:"tool"`
|
||||
Current string `json:"current"`
|
||||
Latest string `json:"latest"`
|
||||
NeedsUpdate bool `json:"needsUpdate"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
updates := make([]updateInfo, len(statuses))
|
||||
for i, u := range statuses {
|
||||
updates[i] = updateInfo{
|
||||
Tool: u.Tool,
|
||||
Current: u.Current,
|
||||
Latest: u.Latest,
|
||||
NeedsUpdate: u.NeedsUpdate,
|
||||
Error: u.Error,
|
||||
}
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"updates": updates,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleInstall(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Tools []string `json:"tools"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(body.Tools) == 0 {
|
||||
writeError(w, "no tools specified", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]string{"status": "installing"})
|
||||
}
|
||||
|
||||
func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) {
|
||||
s.scanResult = scanner.ScanSystem()
|
||||
writeJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdatePreferences(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "PUT" {
|
||||
writeError(w, "PUT only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if s.config == nil {
|
||||
writeError(w, "no config", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Language string `json:"language"`
|
||||
KeyboardLayout string `json:"keyboard_layout"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Language != "" {
|
||||
s.config.Profile.Preferences.Language = body.Language
|
||||
}
|
||||
if body.KeyboardLayout != "" {
|
||||
s.config.Profile.Preferences.KeyboardLayout = body.KeyboardLayout
|
||||
}
|
||||
if err := config.Save(s.config); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleTerminal(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Command string `json:"command"`
|
||||
Cwd string `json:"cwd"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Command == "" {
|
||||
writeError(w, "no command", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
shell := "/bin/sh"
|
||||
if s, err := exec.LookPath("bash"); err == nil {
|
||||
shell = s
|
||||
}
|
||||
|
||||
cmd := exec.Command(shell, "-c", body.Command)
|
||||
if body.Cwd != "" {
|
||||
cmd.Dir = body.Cwd
|
||||
}
|
||||
out, err := cmd.CombinedOutput()
|
||||
|
||||
type termResult struct {
|
||||
Output string `json:"output"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
result := termResult{Output: string(out)}
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
}
|
||||
writeJSON(w, result)
|
||||
}
|
||||
|
||||
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Message string `json:"message"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Message == "" {
|
||||
writeError(w, "no message", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
orb, err := orchestrator.New(s.config)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
orb.SetSystemPrompt(`You are Muyue Studio's AI orchestrator. You help the user with software development tasks. You can:
|
||||
- Create and manage development plans with step-by-step workflows
|
||||
- Propose agents (tools like Crush, Claude Code, etc.) to execute specific tasks
|
||||
- Track progress across multi-step tasks
|
||||
- Suggest file modifications, code reviews, and architecture decisions
|
||||
|
||||
Be concise, actionable, and structured. When proposing a plan, use clear numbered steps. When referencing files, use relative paths. You are embedded in the Muyue desktop app.`)
|
||||
|
||||
if body.Stream {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
flusher, canFlush := w.(http.Flusher)
|
||||
|
||||
chunkSize := 8
|
||||
result, err := orb.Send(body.Message)
|
||||
if err != nil {
|
||||
data, _ := json.Marshal(map[string]string{"error": err.Error()})
|
||||
w.Write([]byte("data: " + string(data) + "\n\n"))
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
runes := []rune(result)
|
||||
for i := 0; i < len(runes); i += chunkSize {
|
||||
end := i + chunkSize
|
||||
if end > len(runes) {
|
||||
end = len(runes)
|
||||
}
|
||||
chunk := string(runes[i:end])
|
||||
data, _ := json.Marshal(map[string]string{"content": chunk})
|
||||
w.Write([]byte("data: " + string(data) + "\n\n"))
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(map[string]string{"done": "true"})
|
||||
w.Write([]byte("data: " + string(data) + "\n\n"))
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
result, err := orb.Send(body.Message)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]string{"content": result})
|
||||
}
|
||||
|
||||
func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "PUT" {
|
||||
writeError(w, "PUT only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if s.config == nil {
|
||||
writeError(w, "no config", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Pseudo string `json:"pseudo"`
|
||||
Email string `json:"email"`
|
||||
Editor string `json:"editor"`
|
||||
Shell string `json:"shell"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Name != "" {
|
||||
s.config.Profile.Name = body.Name
|
||||
}
|
||||
if body.Pseudo != "" {
|
||||
s.config.Profile.Pseudo = body.Pseudo
|
||||
}
|
||||
if body.Email != "" {
|
||||
s.config.Profile.Email = body.Email
|
||||
}
|
||||
if body.Editor != "" {
|
||||
s.config.Profile.Preferences.Editor = body.Editor
|
||||
}
|
||||
if body.Shell != "" {
|
||||
s.config.Profile.Preferences.Shell = body.Shell
|
||||
}
|
||||
if err := config.Save(s.config); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleSaveProvider(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "PUT" {
|
||||
writeError(w, "PUT only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if s.config == nil {
|
||||
writeError(w, "no config", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
APIKey string `json:"api_key"`
|
||||
Model string `json:"model"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Active *bool `json:"active"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Name == "" {
|
||||
writeError(w, "name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
found := false
|
||||
for i := range s.config.AI.Providers {
|
||||
if s.config.AI.Providers[i].Name == body.Name {
|
||||
if body.APIKey != "" {
|
||||
s.config.AI.Providers[i].APIKey = body.APIKey
|
||||
}
|
||||
if body.Model != "" {
|
||||
s.config.AI.Providers[i].Model = body.Model
|
||||
}
|
||||
if body.BaseURL != "" {
|
||||
s.config.AI.Providers[i].BaseURL = body.BaseURL
|
||||
}
|
||||
if body.Active != nil {
|
||||
if *body.Active {
|
||||
for j := range s.config.AI.Providers {
|
||||
s.config.AI.Providers[j].Active = false
|
||||
}
|
||||
}
|
||||
s.config.AI.Providers[i].Active = *body.Active
|
||||
}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
writeError(w, "provider not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := config.Save(s.config); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleRunUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Tool string `json:"tool"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
|
||||
result := scanner.ScanSystem()
|
||||
statuses := updater.CheckUpdates(result)
|
||||
|
||||
if body.Tool != "" {
|
||||
for _, u := range statuses {
|
||||
if u.Tool == body.Tool && u.NeedsUpdate {
|
||||
updater.RunAutoUpdate([]updater.UpdateStatus{u})
|
||||
}
|
||||
}
|
||||
writeJSON(w, map[string]string{"status": "ok", "tool": body.Tool})
|
||||
return
|
||||
}
|
||||
|
||||
needsUpdate := make([]updater.UpdateStatus, 0)
|
||||
for _, u := range statuses {
|
||||
if u.NeedsUpdate {
|
||||
needsUpdate = append(needsUpdate, u)
|
||||
}
|
||||
}
|
||||
if len(needsUpdate) > 0 {
|
||||
updater.RunAutoUpdate(needsUpdate)
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"status": "ok",
|
||||
"updated": len(needsUpdate),
|
||||
})
|
||||
}
|
||||
64
internal/api/server.go
Normal file
64
internal/api/server.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
"github.com/muyue/muyue/internal/scanner"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
config *config.MuyueConfig
|
||||
scanResult *scanner.ScanResult
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
func NewServer(cfg *config.MuyueConfig) *Server {
|
||||
s := &Server{
|
||||
config: cfg,
|
||||
mux: http.NewServeMux(),
|
||||
}
|
||||
s.scanResult = scanner.ScanSystem()
|
||||
s.routes()
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) routes() {
|
||||
s.mux.HandleFunc("/api/info", s.handleInfo)
|
||||
s.mux.HandleFunc("/api/system", s.handleSystem)
|
||||
s.mux.HandleFunc("/api/tools", s.handleTools)
|
||||
s.mux.HandleFunc("/api/config", s.handleConfig)
|
||||
s.mux.HandleFunc("/api/providers", s.handleProviders)
|
||||
s.mux.HandleFunc("/api/skills", s.handleSkills)
|
||||
s.mux.HandleFunc("/api/lsp", s.handleLSP)
|
||||
s.mux.HandleFunc("/api/mcp", s.handleMCP)
|
||||
s.mux.HandleFunc("/api/updates", s.handleUpdates)
|
||||
s.mux.HandleFunc("/api/install", s.handleInstall)
|
||||
s.mux.HandleFunc("/api/scan", s.handleScan)
|
||||
s.mux.HandleFunc("/api/preferences", s.handleUpdatePreferences)
|
||||
s.mux.HandleFunc("/api/terminal", s.handleTerminal)
|
||||
s.mux.HandleFunc("/api/ws/terminal", s.handleTerminalWS)
|
||||
s.mux.HandleFunc("/api/terminal/sessions", s.handleTerminalSessions)
|
||||
s.mux.HandleFunc("/api/terminal/sessions/", s.handleTerminalSessionsDelete)
|
||||
s.mux.HandleFunc("/api/mcp/configure", s.handleMCPConfigure)
|
||||
s.mux.HandleFunc("/api/config/profile", s.handleSaveProfile)
|
||||
s.mux.HandleFunc("/api/config/provider", s.handleSaveProvider)
|
||||
s.mux.HandleFunc("/api/update/run", s.handleRunUpdate)
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/ws/") {
|
||||
s.mux.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
s.mux.ServeHTTP(w, r)
|
||||
}
|
||||
301
internal/api/terminal.go
Normal file
301
internal/api/terminal.go
Normal file
@@ -0,0 +1,301 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/creack/pty/v2"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
type wsMessage struct {
|
||||
Type string `json:"type"`
|
||||
Data string `json:"data"`
|
||||
Rows uint16 `json:"rows,omitempty"`
|
||||
Cols uint16 `json:"cols,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("ws upgrade: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
var initMsg wsMessage
|
||||
_, raw, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
conn.WriteJSON(wsMessage{Type: "error", Data: "failed to read init message"})
|
||||
return
|
||||
}
|
||||
if err := json.Unmarshal(raw, &initMsg); err != nil {
|
||||
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid init message"})
|
||||
return
|
||||
}
|
||||
|
||||
var cmd *exec.Cmd
|
||||
|
||||
if initMsg.Type == "ssh" && initMsg.Data != "" {
|
||||
var sshConf struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
KeyPath string `json:"key_path"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(initMsg.Data), &sshConf); err != nil {
|
||||
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid ssh config"})
|
||||
return
|
||||
}
|
||||
if sshConf.Port == 0 {
|
||||
sshConf.Port = 22
|
||||
}
|
||||
|
||||
sshArgs := []string{
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "UserKnownHostsFile=/dev/null",
|
||||
"-o", "LogLevel=ERROR",
|
||||
}
|
||||
if sshConf.KeyPath != "" {
|
||||
sshArgs = append(sshArgs, "-i", sshConf.KeyPath)
|
||||
}
|
||||
if sshConf.Port != 22 {
|
||||
sshArgs = append(sshArgs, "-p", fmt.Sprintf("%d", sshConf.Port))
|
||||
}
|
||||
sshArgs = append(sshArgs, fmt.Sprintf("%s@%s", sshConf.User, sshConf.Host))
|
||||
|
||||
cmd = exec.Command("ssh", sshArgs...)
|
||||
} else {
|
||||
shell := initMsg.Data
|
||||
if shell == "" {
|
||||
shell = detectShell()
|
||||
}
|
||||
|
||||
if strings.Contains(shell, "wsl") {
|
||||
cmd = exec.Command("wsl", "--shell-type", "login")
|
||||
} else if strings.Contains(shell, "powershell") || strings.Contains(shell, "pwsh") {
|
||||
cmd = exec.Command(shell, "-NoLogo", "-NoProfile")
|
||||
} else {
|
||||
cmd = exec.Command(shell, "--login")
|
||||
}
|
||||
}
|
||||
|
||||
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
|
||||
|
||||
ptmx, err := pty.Start(cmd)
|
||||
if err != nil {
|
||||
log.Printf("pty start: %v", err)
|
||||
conn.WriteJSON(wsMessage{Type: "error", Data: err.Error()})
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
ptmx.Close()
|
||||
if cmd.Process != nil {
|
||||
cmd.Process.Kill()
|
||||
cmd.Wait()
|
||||
}
|
||||
}()
|
||||
|
||||
var once sync.Once
|
||||
cleanup := func() {
|
||||
once.Do(func() {
|
||||
ptmx.Close()
|
||||
if cmd.Process != nil {
|
||||
cmd.Process.Kill()
|
||||
cmd.Wait()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := ptmx.Read(buf)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
conn.WriteMessage(websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
||||
return
|
||||
}
|
||||
if err := conn.WriteJSON(wsMessage{
|
||||
Type: "output",
|
||||
Data: string(buf[:n]),
|
||||
}); err != nil {
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
conn.SetReadLimit(1 << 20)
|
||||
conn.SetReadDeadline(time.Time{})
|
||||
|
||||
for {
|
||||
_, raw, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
|
||||
var msg wsMessage
|
||||
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case "input":
|
||||
if _, err := ptmx.Write([]byte(msg.Data)); err != nil {
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
case "resize":
|
||||
if msg.Rows > 0 && msg.Cols > 0 {
|
||||
pty.Setsize(ptmx, &pty.Winsize{
|
||||
Rows: msg.Rows,
|
||||
Cols: msg.Cols,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" {
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"ssh": s.config.Terminal.SSH,
|
||||
"system": detectSystemTerminals(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
KeyPath string `json:"key_path"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Name == "" || body.Host == "" {
|
||||
writeError(w, "name and host required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Port == 0 {
|
||||
body.Port = 22
|
||||
}
|
||||
|
||||
conn := config.SSHConnection{
|
||||
Name: body.Name,
|
||||
Host: body.Host,
|
||||
Port: body.Port,
|
||||
User: body.User,
|
||||
KeyPath: body.KeyPath,
|
||||
}
|
||||
if s.config.Terminal.SSH == nil {
|
||||
s.config.Terminal.SSH = []config.SSHConnection{}
|
||||
}
|
||||
s.config.Terminal.SSH = append(s.config.Terminal.SSH, conn)
|
||||
if err := config.Save(s.config); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleTerminalSessionsDelete(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimPrefix(r.URL.Path, "/api/terminal/sessions/")
|
||||
if name == "" {
|
||||
writeError(w, "name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
found := false
|
||||
for i, c := range s.config.Terminal.SSH {
|
||||
if c.Name == name {
|
||||
s.config.Terminal.SSH = append(s.config.Terminal.SSH[:i], s.config.Terminal.SSH[i+1:]...)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
writeError(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := config.Save(s.config); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func detectShell() string {
|
||||
shells := []string{"zsh", "bash", "fish", "pwsh", "powershell"}
|
||||
for _, s := range shells {
|
||||
if _, err := exec.LookPath(s); err == nil {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return "/bin/sh"
|
||||
}
|
||||
|
||||
func detectSystemTerminals() []map[string]string {
|
||||
var terminals []map[string]string
|
||||
|
||||
terminals = append(terminals, map[string]string{
|
||||
"type": "local",
|
||||
"name": "Default Shell",
|
||||
"shell": detectShell(),
|
||||
})
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
if _, err := exec.LookPath("wsl"); err == nil {
|
||||
terminals = append(terminals, map[string]string{
|
||||
"type": "local",
|
||||
"name": "WSL",
|
||||
"shell": "wsl",
|
||||
})
|
||||
}
|
||||
if _, err := exec.LookPath("powershell"); err == nil {
|
||||
terminals = append(terminals, map[string]string{
|
||||
"type": "local",
|
||||
"name": "PowerShell",
|
||||
"shell": "powershell",
|
||||
})
|
||||
}
|
||||
if _, err := exec.LookPath("pwsh"); err == nil {
|
||||
terminals = append(terminals, map[string]string{
|
||||
"type": "local",
|
||||
"name": "PowerShell Core",
|
||||
"shell": "pwsh",
|
||||
})
|
||||
}
|
||||
if _, err := exec.LookPath("cmd"); err == nil {
|
||||
terminals = append(terminals, map[string]string{
|
||||
"type": "local",
|
||||
"name": "Command Prompt",
|
||||
"shell": "cmd",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return terminals
|
||||
}
|
||||
@@ -15,12 +15,14 @@ type Profile struct {
|
||||
Email string `yaml:"email"`
|
||||
Languages []string `yaml:"languages"`
|
||||
Preferences struct {
|
||||
Editor string `yaml:"editor"`
|
||||
Shell string `yaml:"shell"`
|
||||
Theme string `yaml:"theme"`
|
||||
DefaultAI string `yaml:"default_ai"`
|
||||
AutoUpdate bool `yaml:"auto_update"`
|
||||
CheckOnStart bool `yaml:"check_on_start"`
|
||||
Editor string `yaml:"editor"`
|
||||
Shell string `yaml:"shell"`
|
||||
Theme string `yaml:"theme"`
|
||||
DefaultAI string `yaml:"default_ai"`
|
||||
AutoUpdate bool `yaml:"auto_update"`
|
||||
CheckOnStart bool `yaml:"check_on_start"`
|
||||
Language string `yaml:"language"`
|
||||
KeyboardLayout string `yaml:"keyboard_layout"`
|
||||
} `yaml:"preferences"`
|
||||
}
|
||||
|
||||
@@ -39,6 +41,15 @@ type ToolConfig struct {
|
||||
AutoUpdate bool `yaml:"auto_update"`
|
||||
}
|
||||
|
||||
type SSHConnection struct {
|
||||
Name string `yaml:"name"`
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
User string `yaml:"user"`
|
||||
Password string `yaml:"password,omitempty"`
|
||||
KeyPath string `yaml:"key_path,omitempty"`
|
||||
}
|
||||
|
||||
type MuyueConfig struct {
|
||||
Version string `yaml:"version"`
|
||||
Profile Profile `yaml:"profile"`
|
||||
@@ -52,8 +63,9 @@ type MuyueConfig struct {
|
||||
Global bool `yaml:"global"`
|
||||
} `yaml:"bmad"`
|
||||
Terminal struct {
|
||||
CustomPrompt bool `yaml:"custom_prompt"`
|
||||
PromptTheme string `yaml:"prompt_theme"`
|
||||
CustomPrompt bool `yaml:"custom_prompt"`
|
||||
PromptTheme string `yaml:"prompt_theme"`
|
||||
SSH []SSHConnection `yaml:"ssh"`
|
||||
} `yaml:"terminal"`
|
||||
}
|
||||
|
||||
@@ -179,6 +191,8 @@ func Default() *MuyueConfig {
|
||||
cfg.Profile.Preferences.AutoUpdate = true
|
||||
cfg.Profile.Preferences.CheckOnStart = true
|
||||
cfg.Profile.Preferences.Theme = "charm"
|
||||
cfg.Profile.Preferences.Language = "fr"
|
||||
cfg.Profile.Preferences.KeyboardLayout = "azerty"
|
||||
|
||||
cfg.AI.Providers = []AIProvider{
|
||||
{
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
"github.com/muyue/muyue/internal/scanner"
|
||||
"github.com/muyue/muyue/internal/updater"
|
||||
)
|
||||
|
||||
type Daemon struct {
|
||||
config *config.MuyueConfig
|
||||
interval time.Duration
|
||||
stopCh chan struct{}
|
||||
mu sync.RWMutex
|
||||
running bool
|
||||
lastCheck time.Time
|
||||
lastStatus []updater.UpdateStatus
|
||||
logs []string
|
||||
onUpdate func([]updater.UpdateStatus)
|
||||
}
|
||||
|
||||
func NewDaemon(cfg *config.MuyueConfig, interval time.Duration) *Daemon {
|
||||
if interval == 0 {
|
||||
interval = 1 * time.Hour
|
||||
}
|
||||
return &Daemon{
|
||||
config: cfg,
|
||||
interval: interval,
|
||||
stopCh: make(chan struct{}, 1),
|
||||
logs: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Daemon) OnUpdate(fn func([]updater.UpdateStatus)) {
|
||||
d.onUpdate = fn
|
||||
}
|
||||
|
||||
func (d *Daemon) Start() error {
|
||||
d.mu.Lock()
|
||||
if d.running {
|
||||
d.mu.Unlock()
|
||||
return fmt.Errorf("daemon already running")
|
||||
}
|
||||
d.running = true
|
||||
d.mu.Unlock()
|
||||
|
||||
d.log("daemon started (interval: %s)", d.interval)
|
||||
|
||||
go d.run()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Daemon) Stop() {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if !d.running {
|
||||
return
|
||||
}
|
||||
d.running = false
|
||||
d.stopCh <- struct{}{}
|
||||
d.log("daemon stopped")
|
||||
}
|
||||
|
||||
func (d *Daemon) IsRunning() bool {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
return d.running
|
||||
}
|
||||
|
||||
func (d *Daemon) LastCheck() time.Time {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
return d.lastCheck
|
||||
}
|
||||
|
||||
func (d *Daemon) LastStatus() []updater.UpdateStatus {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
return d.lastStatus
|
||||
}
|
||||
|
||||
func (d *Daemon) Logs() []string {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
return d.logs
|
||||
}
|
||||
|
||||
func (d *Daemon) TriggerCheck() []updater.UpdateStatus {
|
||||
return d.checkUpdates()
|
||||
}
|
||||
|
||||
func (d *Daemon) run() {
|
||||
d.checkUpdates()
|
||||
|
||||
ticker := time.NewTicker(d.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
d.checkUpdates()
|
||||
case <-d.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Daemon) checkUpdates() []updater.UpdateStatus {
|
||||
d.log("checking for updates...")
|
||||
result := scanner.ScanSystem()
|
||||
statuses := updater.CheckUpdates(result)
|
||||
|
||||
needsUpdate := false
|
||||
for _, s := range statuses {
|
||||
if s.NeedsUpdate {
|
||||
needsUpdate = true
|
||||
d.log("update available: %s %s -> %s", s.Tool, s.Current, s.Latest)
|
||||
}
|
||||
}
|
||||
|
||||
if !needsUpdate {
|
||||
d.log("all tools up to date")
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
d.lastCheck = time.Now()
|
||||
d.lastStatus = statuses
|
||||
d.mu.Unlock()
|
||||
|
||||
if d.config.Profile.Preferences.AutoUpdate && needsUpdate {
|
||||
d.log("auto-updating...")
|
||||
results := updater.RunAutoUpdate(statuses)
|
||||
for _, r := range results {
|
||||
if r.Message != "" {
|
||||
d.log(" %s: %s", r.Tool, r.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if d.onUpdate != nil {
|
||||
d.onUpdate(statuses)
|
||||
}
|
||||
|
||||
return statuses
|
||||
}
|
||||
|
||||
func (d *Daemon) log(format string, args ...interface{}) {
|
||||
msg := fmt.Sprintf("[%s] %s", time.Now().Format("15:04:05"), fmt.Sprintf(format, args...))
|
||||
d.mu.Lock()
|
||||
d.logs = append(d.logs, msg)
|
||||
if len(d.logs) > 500 {
|
||||
d.logs = d.logs[250:]
|
||||
}
|
||||
d.mu.Unlock()
|
||||
}
|
||||
|
||||
func RunStandalone(cfg *config.MuyueConfig) {
|
||||
d := NewDaemon(cfg, 1*time.Hour)
|
||||
d.Start()
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
<-sigCh
|
||||
d.Stop()
|
||||
}
|
||||
131
internal/desktop/desktop.go
Normal file
131
internal/desktop/desktop.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/muyue/muyue/internal/api"
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
"github.com/muyue/muyue/internal/version"
|
||||
"github.com/muyue/muyue/web"
|
||||
)
|
||||
|
||||
type options struct {
|
||||
port int
|
||||
noOpen bool
|
||||
}
|
||||
|
||||
type option func(*options)
|
||||
|
||||
func withPort(port int) option {
|
||||
return func(o *options) { o.port = port }
|
||||
}
|
||||
|
||||
func withNoOpen(noOpen bool) option {
|
||||
return func(o *options) { o.noOpen = noOpen }
|
||||
}
|
||||
|
||||
func parseFlags(args []string) []option {
|
||||
var opts []option
|
||||
for _, arg := range args {
|
||||
switch {
|
||||
case arg == "--no-open":
|
||||
opts = append(opts, withNoOpen(true))
|
||||
case strings.HasPrefix(arg, "--port="):
|
||||
if p, err := strconv.Atoi(strings.TrimPrefix(arg, "--port=")); err == nil {
|
||||
opts = append(opts, withPort(p))
|
||||
}
|
||||
case arg == "--port":
|
||||
// handled as prefix case
|
||||
}
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
func Run(cfg *config.MuyueConfig, args []string) error {
|
||||
o := options{}
|
||||
for _, opt := range parseFlags(args) {
|
||||
opt(&o)
|
||||
}
|
||||
|
||||
log.Printf("%s Desktop v%s", version.Name, version.Version)
|
||||
|
||||
srv := api.NewServer(cfg)
|
||||
|
||||
frontendFS, err := fs.Sub(web.Assets, "dist")
|
||||
if err != nil {
|
||||
return fmt.Errorf("frontend assets: %w", err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/api/", srv)
|
||||
mux.Handle("/", spaHandler(http.FileServer(http.FS(frontendFS))))
|
||||
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", o.port)
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bind %s: %w", addr, err)
|
||||
}
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
go func() {
|
||||
if err := http.Serve(listener, mux); err != nil {
|
||||
log.Fatalf("Server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
url := fmt.Sprintf("http://127.0.0.1:%d", port)
|
||||
log.Printf("Listening on %s", url)
|
||||
|
||||
if !o.noOpen {
|
||||
openBrowser(url)
|
||||
log.Printf("Opened browser")
|
||||
}
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
log.Println("Shutting down...")
|
||||
return nil
|
||||
}
|
||||
|
||||
func spaHandler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
if path != "/" && !strings.Contains(path, ".") {
|
||||
r.URL.Path = "/"
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func openBrowser(url string) {
|
||||
var cmd *exec.Cmd
|
||||
switch {
|
||||
case exists("xdg-open"):
|
||||
cmd = exec.Command("xdg-open", url)
|
||||
case exists("open"):
|
||||
cmd = exec.Command("open", url)
|
||||
case exists("cmd"):
|
||||
cmd = exec.Command("cmd", "/c", "start", url)
|
||||
default:
|
||||
fmt.Printf("Open manually: %s\n", url)
|
||||
return
|
||||
}
|
||||
_ = cmd.Start()
|
||||
}
|
||||
|
||||
func exists(name string) bool {
|
||||
_, err := exec.LookPath(name)
|
||||
return err == nil
|
||||
}
|
||||
@@ -290,46 +290,6 @@ func (i *Installer) installGit() InstallResult {
|
||||
return InstallResult{Tool: "git", Success: true, Message: "installed and configured"}
|
||||
}
|
||||
|
||||
func (i *Installer) SetupPrompt() error {
|
||||
starshipPath, err := exec.LookPath("starship")
|
||||
if err != nil {
|
||||
return fmt.Errorf("starship not found")
|
||||
}
|
||||
|
||||
rcFile := i.getRCFile()
|
||||
line := fmt.Sprintf("eval \"$(" + starshipPath + " init %s)\"", i.system.Shell)
|
||||
appendLine(rcFile, line)
|
||||
|
||||
configDir, _ := config.ConfigDir()
|
||||
starshipConfig := `format = """
|
||||
$directory\
|
||||
$git_branch\
|
||||
$git_status\
|
||||
$git_metrics\
|
||||
$nodejs\
|
||||
$python\
|
||||
$golang\
|
||||
$rust\
|
||||
$cmd_duration\
|
||||
$line_break\
|
||||
$character"""
|
||||
|
||||
[character]
|
||||
success_symbol = "[❯](bold green)"
|
||||
error_symbol = "[❯](bold red)"
|
||||
|
||||
[git_branch]
|
||||
format = "[$symbol$branch]($style) "
|
||||
|
||||
[git_status]
|
||||
format = '([$all_status$ahead_behind]($style) )'
|
||||
`
|
||||
configPath := configDir + "/starship.toml"
|
||||
os.MkdirAll(configDir, 0755)
|
||||
os.WriteFile(configPath, []byte(starshipConfig), 0644)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *Installer) getRCFile() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
)
|
||||
|
||||
type LSPServer struct {
|
||||
@@ -15,14 +11,9 @@ type LSPServer struct {
|
||||
Language string `json:"language"`
|
||||
Command string `json:"command"`
|
||||
InstallCmd string `json:"install_cmd"`
|
||||
ConfigFile string `json:"config_file"`
|
||||
Installed bool `json:"installed"`
|
||||
}
|
||||
|
||||
type LSPConfig struct {
|
||||
Servers []LSPServer `json:"servers"`
|
||||
}
|
||||
|
||||
var knownServers = []LSPServer{
|
||||
{Name: "gopls", Language: "go", Command: "gopls", InstallCmd: "go install golang.org/x/tools/gopls@latest"},
|
||||
{Name: "pyright", Language: "python", Command: "pyright", InstallCmd: "npm install -g pyright"},
|
||||
@@ -111,85 +102,4 @@ func InstallForLanguages(languages []string) []LSPServer {
|
||||
return results
|
||||
}
|
||||
|
||||
func GenerateCrushConfig(cfg *config.MuyueConfig) error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("config is nil")
|
||||
}
|
||||
|
||||
configDir, err := config.ConfigDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
type lspEntry struct {
|
||||
Command []string `json:"command"`
|
||||
}
|
||||
|
||||
lspConfig := map[string]lspEntry{}
|
||||
|
||||
for _, lang := range cfg.Profile.Languages {
|
||||
switch lang {
|
||||
case "go":
|
||||
lspConfig["go"] = lspEntry{Command: []string{"gopls"}}
|
||||
case "python":
|
||||
lspConfig["python"] = lspEntry{Command: []string{"pyright-langserver", "--stdio"}}
|
||||
case "typescript", "javascript":
|
||||
lspConfig["typescript"] = lspEntry{Command: []string{"typescript-language-server", "--stdio"}}
|
||||
case "rust":
|
||||
lspConfig["rust"] = lspEntry{Command: []string{"rust-analyzer"}}
|
||||
case "c", "cpp":
|
||||
lspConfig["c"] = lspEntry{Command: []string{"clangd"}}
|
||||
case "lua":
|
||||
lspConfig["lua"] = lspEntry{Command: []string{"lua-language-server"}}
|
||||
}
|
||||
}
|
||||
|
||||
if len(lspConfig) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(lspConfig, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
lspPath := filepath.Join(configDir, "crush.json")
|
||||
existing, err := os.ReadFile(lspPath)
|
||||
if err == nil {
|
||||
var existingConfig map[string]interface{}
|
||||
if unmarshalErr := json.Unmarshal(existing, &existingConfig); unmarshalErr == nil {
|
||||
var newConfig map[string]interface{}
|
||||
if unmarshalErr2 := json.Unmarshal(data, &newConfig); unmarshalErr2 == nil {
|
||||
for k, v := range newConfig {
|
||||
existingConfig[k] = v
|
||||
}
|
||||
data, _ = json.MarshalIndent(existingConfig, "", " ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return os.WriteFile(lspPath, data, 0644)
|
||||
}
|
||||
|
||||
func EnsureCrushConfig(cfg *config.MuyueConfig) error {
|
||||
configDir, _ := config.ConfigDir()
|
||||
crusherPath := filepath.Join(configDir, "crush.json")
|
||||
|
||||
if _, err := os.Stat(crusherPath); err != nil {
|
||||
home, _ := os.UserHomeDir()
|
||||
homeCrush := filepath.Join(home, ".config", "crush", "crush.json")
|
||||
if _, err := os.Stat(homeCrush); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
defaultConfig := map[string]interface{}{
|
||||
"version": "1",
|
||||
}
|
||||
|
||||
data, _ := json.MarshalIndent(defaultConfig, "", " ")
|
||||
os.MkdirAll(filepath.Dir(crusherPath), 0755)
|
||||
return os.WriteFile(crusherPath, data, 0644)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
"github.com/muyue/muyue/internal/workflow"
|
||||
)
|
||||
|
||||
var thinkRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
|
||||
@@ -42,12 +41,12 @@ type ChatResponse struct {
|
||||
}
|
||||
|
||||
type Orchestrator struct {
|
||||
config *config.MuyueConfig
|
||||
provider *config.AIProvider
|
||||
client *http.Client
|
||||
history []Message
|
||||
histMu sync.Mutex
|
||||
Workflow *workflow.Workflow
|
||||
config *config.MuyueConfig
|
||||
provider *config.AIProvider
|
||||
client *http.Client
|
||||
history []Message
|
||||
histMu sync.Mutex
|
||||
systemPrompt string
|
||||
}
|
||||
|
||||
var sharedHTTPClient = &http.Client{
|
||||
@@ -72,14 +71,17 @@ func New(cfg *config.MuyueConfig) (*Orchestrator, error) {
|
||||
}
|
||||
|
||||
return &Orchestrator{
|
||||
config: cfg,
|
||||
config: cfg,
|
||||
provider: provider,
|
||||
client: sharedHTTPClient,
|
||||
history: []Message{},
|
||||
Workflow: workflow.New(),
|
||||
client: sharedHTTPClient,
|
||||
history: []Message{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (o *Orchestrator) SetSystemPrompt(prompt string) {
|
||||
o.systemPrompt = prompt
|
||||
}
|
||||
|
||||
func (o *Orchestrator) Send(userMessage string) (string, error) {
|
||||
o.histMu.Lock()
|
||||
o.history = append(o.history, Message{
|
||||
@@ -91,9 +93,15 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
|
||||
o.history = o.history[len(o.history)-maxHistorySize:]
|
||||
}
|
||||
|
||||
messages := make([]Message, 0, len(o.history)+1)
|
||||
if o.systemPrompt != "" {
|
||||
messages = append(messages, Message{Role: "system", Content: o.systemPrompt})
|
||||
}
|
||||
messages = append(messages, o.history...)
|
||||
|
||||
reqBody := ChatRequest{
|
||||
Model: o.provider.Model,
|
||||
Messages: o.history,
|
||||
Messages: messages,
|
||||
Stream: false,
|
||||
}
|
||||
o.histMu.Unlock()
|
||||
@@ -153,156 +161,6 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func (o *Orchestrator) StartWorkflow(goal string) (string, error) {
|
||||
o.Workflow.Start(goal)
|
||||
prompt := fmt.Sprintf("I want to: %s\nWhat questions do you need to ask me to fully understand this requirement? Ask ALL questions at once.", goal)
|
||||
o.history = []Message{
|
||||
{Role: "system", Content: workflow.BuildSystemPrompt(workflow.PhaseGathering, o.Workflow.Plan)},
|
||||
{Role: "user", Content: prompt},
|
||||
}
|
||||
|
||||
reqBody := ChatRequest{
|
||||
Model: o.provider.Model,
|
||||
Messages: o.history,
|
||||
Stream: false,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
baseURL := o.provider.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = getProviderBaseURL(o.provider.Name)
|
||||
}
|
||||
|
||||
url := strings.TrimRight(baseURL, "/") + "/chat/completions"
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+o.provider.APIKey)
|
||||
|
||||
resp, err := o.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 chatResp ChatResponse
|
||||
if err := json.Unmarshal(respBody, &chatResp); err != nil {
|
||||
return "", fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
|
||||
if len(chatResp.Choices) == 0 {
|
||||
return "", fmt.Errorf("no response from AI")
|
||||
}
|
||||
|
||||
content := cleanAIResponse(chatResp.Choices[0].Message.Content)
|
||||
o.history = append(o.history, Message{
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
})
|
||||
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func (o *Orchestrator) AnswerQuestion(answer string) (string, error) {
|
||||
o.Workflow.AddAnswer(answer)
|
||||
return o.Send(answer)
|
||||
}
|
||||
|
||||
func (o *Orchestrator) GeneratePlan() (string, error) {
|
||||
o.Workflow.Phase = workflow.PhasePlanning
|
||||
o.history = append(o.history, Message{
|
||||
Role: "system",
|
||||
Content: workflow.BuildSystemPrompt(workflow.PhasePlanning, o.Workflow.Plan),
|
||||
})
|
||||
|
||||
prompt := "All questions have been answered. Now create a detailed step-by-step execution plan as a JSON array. Each step should have: id, title, description, agent (crush/claude/muyue)."
|
||||
if len(o.Workflow.Plan.PreviewFiles) > 0 {
|
||||
prompt += "\nInclude visual previews where helpful using the PREVIEW_JSON format."
|
||||
}
|
||||
|
||||
resp, err := o.Send(prompt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
steps, parseErr := workflow.ParsePlanResponse(resp)
|
||||
if parseErr == nil {
|
||||
o.Workflow.SetPlan("")
|
||||
o.Workflow.Plan.Steps = steps
|
||||
o.Workflow.Phase = workflow.PhaseReviewing
|
||||
}
|
||||
|
||||
previewFiles := workflow.ParsePreviewFiles(resp)
|
||||
if len(previewFiles) > 0 {
|
||||
o.Workflow.SetPreviewFiles(previewFiles)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (o *Orchestrator) ReviewPlan(approved bool, feedback string) (string, error) {
|
||||
if approved {
|
||||
o.Workflow.Approve()
|
||||
return o.executeNextStep()
|
||||
}
|
||||
o.Workflow.Reject(feedback)
|
||||
return o.Send(fmt.Sprintf("The plan was rejected. Reason: %s. Please revise the plan.", feedback))
|
||||
}
|
||||
|
||||
func (o *Orchestrator) executeNextStep() (string, error) {
|
||||
step := o.Workflow.CurrentStep()
|
||||
if step == nil {
|
||||
return "All steps completed!", nil
|
||||
}
|
||||
|
||||
o.history = append(o.history, Message{
|
||||
Role: "system",
|
||||
Content: workflow.BuildSystemPrompt(workflow.PhaseExecuting, o.Workflow.Plan),
|
||||
})
|
||||
|
||||
return o.Send(fmt.Sprintf("Execute step %s: %s\n%s", step.ID, step.Title, step.Description))
|
||||
}
|
||||
|
||||
func (o *Orchestrator) ContinueExecution(output string) (string, error) {
|
||||
o.Workflow.AdvanceStep(output)
|
||||
if o.Workflow.Phase == workflow.PhaseDone {
|
||||
return "Workflow completed! All steps have been executed.", nil
|
||||
}
|
||||
return o.executeNextStep()
|
||||
}
|
||||
|
||||
func (o *Orchestrator) History() []Message {
|
||||
o.histMu.Lock()
|
||||
defer o.histMu.Unlock()
|
||||
cp := make([]Message, len(o.history))
|
||||
copy(cp, o.history)
|
||||
return cp
|
||||
}
|
||||
|
||||
func (o *Orchestrator) ClearHistory() {
|
||||
o.histMu.Lock()
|
||||
o.history = []Message{}
|
||||
o.histMu.Unlock()
|
||||
o.Workflow.Reset()
|
||||
}
|
||||
|
||||
func cleanAIResponse(content string) string {
|
||||
content = thinkRegex.ReplaceAllString(content, "")
|
||||
lines := strings.Split(content, "\n")
|
||||
|
||||
@@ -7,13 +7,6 @@ import (
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
)
|
||||
|
||||
func testConfig() *config.MuyueConfig {
|
||||
cfg := config.Default()
|
||||
cfg.AI.Providers[0].Active = true
|
||||
cfg.AI.Providers[0].APIKey = "test-api-key-12345"
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestCleanAIResponse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -153,58 +146,3 @@ func TestNewNoAPIKey(t *testing.T) {
|
||||
t.Error("Should fail with no API key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryManagement(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
orch, err := New(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("New failed: %v", err)
|
||||
}
|
||||
|
||||
h := orch.History()
|
||||
if len(h) != 0 {
|
||||
t.Errorf("Expected empty history, got %d", len(h))
|
||||
}
|
||||
|
||||
orch.ClearHistory()
|
||||
h = orch.History()
|
||||
if len(h) != 0 {
|
||||
t.Errorf("Expected 0 after clear, got %d", len(h))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryCopy(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
orch, _ := New(cfg)
|
||||
|
||||
orch.history = []Message{
|
||||
{Role: "user", Content: "hello"},
|
||||
}
|
||||
|
||||
h := orch.History()
|
||||
h[0].Content = "modified"
|
||||
|
||||
orig := orch.History()
|
||||
if orig[0].Content == "modified" {
|
||||
t.Error("History should return a copy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxHistorySize(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
orch, _ := New(cfg)
|
||||
|
||||
for i := 0; i < maxHistorySize+10; i++ {
|
||||
orch.histMu.Lock()
|
||||
orch.history = append(orch.history, Message{Role: "user", Content: "msg"})
|
||||
if len(orch.history) > maxHistorySize {
|
||||
orch.history = orch.history[len(orch.history)-maxHistorySize:]
|
||||
}
|
||||
orch.histMu.Unlock()
|
||||
}
|
||||
|
||||
h := orch.History()
|
||||
if len(h) > maxHistorySize {
|
||||
t.Errorf("History should be capped at %d, got %d", maxHistorySize, len(h))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
package preview
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PreviewServer struct {
|
||||
dir string
|
||||
server *http.Server
|
||||
}
|
||||
|
||||
func NewPreviewServer(dir string) *PreviewServer {
|
||||
return &PreviewServer{dir: dir}
|
||||
}
|
||||
|
||||
func (p *PreviewServer) Start(port int) error {
|
||||
fs := http.FileServer(http.Dir(p.dir))
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/", fs)
|
||||
|
||||
p.server = &http.Server{
|
||||
Addr: fmt.Sprintf("127.0.0.1:%d", port),
|
||||
Handler: mux,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := p.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
fmt.Printf("Preview server error: %s\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
url := fmt.Sprintf("http://127.0.0.1:%d", port)
|
||||
fmt.Printf("Preview server running at %s\n", url)
|
||||
|
||||
return openBrowser(url)
|
||||
}
|
||||
|
||||
func (p *PreviewServer) Stop() error {
|
||||
if p.server != nil {
|
||||
return p.server.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreatePreviewFile(dir, filename, content string) error {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644)
|
||||
}
|
||||
|
||||
func openBrowser(url string) error {
|
||||
var cmd string
|
||||
var args []string
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
cmd = "xdg-open"
|
||||
args = []string{url}
|
||||
case "darwin":
|
||||
cmd = "open"
|
||||
args = []string{url}
|
||||
case "windows":
|
||||
cmd = "cmd"
|
||||
args = []string{"/c", "start", url}
|
||||
default:
|
||||
return fmt.Errorf("unsupported platform")
|
||||
}
|
||||
|
||||
return exec.Command(cmd, args...).Start()
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AgentType string
|
||||
|
||||
const (
|
||||
AgentCrush AgentType = "crush"
|
||||
AgentClaude AgentType = "claude"
|
||||
)
|
||||
|
||||
type AgentStatus string
|
||||
|
||||
const (
|
||||
StatusIdle AgentStatus = "idle"
|
||||
StatusRunning AgentStatus = "running"
|
||||
StatusStopped AgentStatus = "stopped"
|
||||
StatusError AgentStatus = "error"
|
||||
)
|
||||
|
||||
type LogEntry struct {
|
||||
Timestamp time.Time
|
||||
Agent AgentType
|
||||
Level string
|
||||
Message string
|
||||
}
|
||||
|
||||
type Agent struct {
|
||||
Type AgentType
|
||||
Status AgentStatus
|
||||
cmd *exec.Cmd
|
||||
stdout io.Reader
|
||||
stderr io.Reader
|
||||
cancel context.CancelFunc
|
||||
mu sync.Mutex
|
||||
logs []LogEntry
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
agents map[AgentType]*Agent
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewManager() *Manager {
|
||||
return &Manager{
|
||||
agents: make(map[AgentType]*Agent),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Start(agentType AgentType, args ...string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if a, exists := m.agents[agentType]; exists && a.Status == StatusRunning {
|
||||
return fmt.Errorf("%s already running", agentType)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
var cmdName string
|
||||
switch agentType {
|
||||
case AgentCrush:
|
||||
cmdName = "crush"
|
||||
case AgentClaude:
|
||||
cmdName = "claude"
|
||||
default:
|
||||
cancel()
|
||||
return fmt.Errorf("unknown agent type: %s", agentType)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, cmdName, args...)
|
||||
cmd.Env = os.Environ()
|
||||
|
||||
stdout, pipeErr := cmd.StdoutPipe()
|
||||
if pipeErr != nil {
|
||||
cancel()
|
||||
return fmt.Errorf("stdout pipe: %w", pipeErr)
|
||||
}
|
||||
stderr, pipeErr := cmd.StderrPipe()
|
||||
if pipeErr != nil {
|
||||
cancel()
|
||||
return fmt.Errorf("stderr pipe: %w", pipeErr)
|
||||
}
|
||||
|
||||
agent := &Agent{
|
||||
Type: agentType,
|
||||
Status: StatusRunning,
|
||||
cmd: cmd,
|
||||
stdout: stdout,
|
||||
stderr: stderr,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
m.agents[agentType] = agent
|
||||
|
||||
go agent.captureOutput(stdout, "info")
|
||||
go agent.captureOutput(stderr, "error")
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
agent.Status = StatusError
|
||||
cancel()
|
||||
return fmt.Errorf("start %s: %w", agentType, err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
err := cmd.Wait()
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if err != nil && ctx.Err() == nil {
|
||||
agent.Status = StatusError
|
||||
agent.log("error", fmt.Sprintf("exited with error: %s", err))
|
||||
} else {
|
||||
agent.Status = StatusStopped
|
||||
agent.log("info", "stopped")
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Stop(agentType AgentType) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
agent, exists := m.agents[agentType]
|
||||
if !exists {
|
||||
return fmt.Errorf("%s not found", agentType)
|
||||
}
|
||||
|
||||
if agent.Status != StatusRunning {
|
||||
return fmt.Errorf("%s is not running", agentType)
|
||||
}
|
||||
|
||||
agent.cancel()
|
||||
agent.Status = StatusStopped
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Status(agentType AgentType) (AgentStatus, []LogEntry) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
agent, exists := m.agents[agentType]
|
||||
if !exists {
|
||||
return StatusIdle, nil
|
||||
}
|
||||
|
||||
agent.mu.Lock()
|
||||
defer agent.mu.Unlock()
|
||||
|
||||
return agent.Status, agent.logs
|
||||
}
|
||||
|
||||
func (m *Manager) AllStatus() map[AgentType]AgentStatus {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
statuses := make(map[AgentType]AgentStatus)
|
||||
for t, a := range m.agents {
|
||||
statuses[t] = a.Status
|
||||
}
|
||||
return statuses
|
||||
}
|
||||
|
||||
|
||||
func (a *Agent) captureOutput(reader io.Reader, level string) {
|
||||
scanner := bufio.NewScanner(reader)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
a.mu.Lock()
|
||||
a.logs = append(a.logs, LogEntry{
|
||||
Timestamp: time.Now(),
|
||||
Agent: a.Type,
|
||||
Level: level,
|
||||
Message: line,
|
||||
})
|
||||
if len(a.logs) > 1000 {
|
||||
a.logs = a.logs[500:]
|
||||
}
|
||||
a.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) log(level, msg string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.logs = append(a.logs, LogEntry{
|
||||
Timestamp: time.Now(),
|
||||
Agent: a.Type,
|
||||
Level: level,
|
||||
Message: msg,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) IsAvailable(agentType AgentType) bool {
|
||||
var cmdName string
|
||||
switch agentType {
|
||||
case AgentCrush:
|
||||
cmdName = "crush"
|
||||
case AgentClaude:
|
||||
cmdName = "claude"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
path, err := exec.LookPath(cmdName)
|
||||
return err == nil && path != ""
|
||||
}
|
||||
|
||||
func (m *Manager) GetLogs(agentType AgentType, lastN int) []LogEntry {
|
||||
m.mu.RLock()
|
||||
agent, exists := m.agents[agentType]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
agent.mu.Lock()
|
||||
defer agent.mu.Unlock()
|
||||
|
||||
logs := agent.logs
|
||||
if lastN > 0 && len(logs) > lastN {
|
||||
logs = logs[len(logs)-lastN:]
|
||||
}
|
||||
return logs
|
||||
}
|
||||
|
||||
func FormatLogs(logs []LogEntry) string {
|
||||
var b strings.Builder
|
||||
for _, l := range logs {
|
||||
b.WriteString(fmt.Sprintf("[%s] %s %s: %s\n",
|
||||
l.Timestamp.Format("15:04:05"),
|
||||
l.Agent,
|
||||
l.Level,
|
||||
l.Message,
|
||||
))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -24,14 +24,6 @@ type Skill struct {
|
||||
FilePath string `yaml:"-" json:"-"`
|
||||
}
|
||||
|
||||
type Target string
|
||||
|
||||
const (
|
||||
TargetCrush Target = "crush"
|
||||
TargetClaude Target = "claude"
|
||||
TargetBoth Target = "both"
|
||||
)
|
||||
|
||||
func SkillsDir() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
@@ -122,27 +114,6 @@ func Create(skill *Skill) error {
|
||||
return Deploy(skill)
|
||||
}
|
||||
|
||||
func Update(skill *Skill) error {
|
||||
dir, err := SkillsDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
skillDir := filepath.Join(dir, skill.Name)
|
||||
skillPath := filepath.Join(skillDir, "SKILL.md")
|
||||
if _, err := os.Stat(skillPath); err != nil {
|
||||
return fmt.Errorf("skill '%s' not found", skill.Name)
|
||||
}
|
||||
|
||||
skill.UpdatedAt = time.Now()
|
||||
content := renderSkill(skill)
|
||||
if err := os.WriteFile(skillPath, []byte(content), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return Deploy(skill)
|
||||
}
|
||||
|
||||
func Delete(name string) error {
|
||||
dir, err := SkillsDir()
|
||||
if err != nil {
|
||||
@@ -164,7 +135,7 @@ func Deploy(skill *Skill) error {
|
||||
return fmt.Errorf("get home dir: %w", err)
|
||||
}
|
||||
|
||||
if skill.Target == string(TargetCrush) || skill.Target == string(TargetBoth) {
|
||||
if skill.Target == "crush" || skill.Target == "both" {
|
||||
crushSkillsDir := filepath.Join(home, ".config", "crush", "skills")
|
||||
if err := os.MkdirAll(crushSkillsDir, 0755); err != nil {
|
||||
return fmt.Errorf("create crush skills dir: %w", err)
|
||||
@@ -179,7 +150,7 @@ func Deploy(skill *Skill) error {
|
||||
}
|
||||
}
|
||||
|
||||
if skill.Target == string(TargetClaude) || skill.Target == string(TargetBoth) {
|
||||
if skill.Target == "claude" || skill.Target == "both" {
|
||||
claudeSkillsDir := filepath.Join(home, ".claude", "skills")
|
||||
if err := os.MkdirAll(claudeSkillsDir, 0755); err != nil {
|
||||
return fmt.Errorf("create claude skills dir: %w", err)
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
"github.com/muyue/muyue/internal/installer"
|
||||
"github.com/muyue/muyue/internal/orchestrator"
|
||||
"github.com/muyue/muyue/internal/workflow"
|
||||
)
|
||||
|
||||
func startInstallCmd(cfg *config.MuyueConfig, tools []string, index int) tea.Cmd {
|
||||
return tea.Cmd(func() tea.Msg {
|
||||
inst := installer.New(cfg)
|
||||
result := inst.InstallTool(tools[index])
|
||||
|
||||
if index+1 < len(tools) {
|
||||
return installBatchMsg{
|
||||
result: result,
|
||||
tools: tools,
|
||||
index: index,
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
return installCompleteMsg{results: []installer.InstallResult{result}}
|
||||
})
|
||||
}
|
||||
|
||||
func sendAIMessage(orch *orchestrator.Orchestrator, input string) tea.Cmd {
|
||||
return tea.Cmd(func() tea.Msg {
|
||||
if orch == nil {
|
||||
return aiErrMsg{err: fmt.Errorf("orchestrator not configured")}
|
||||
}
|
||||
resp, err := orch.Send(input)
|
||||
if err != nil {
|
||||
return aiErrMsg{err: err}
|
||||
}
|
||||
return aiResponseMsg{content: resp}
|
||||
})
|
||||
}
|
||||
|
||||
func startWorkflowCmd(orch *orchestrator.Orchestrator, goal string) tea.Cmd {
|
||||
return tea.Cmd(func() tea.Msg {
|
||||
resp, err := orch.StartWorkflow(goal)
|
||||
if err != nil {
|
||||
return aiErrMsg{err: err}
|
||||
}
|
||||
return aiResponseMsg{content: resp}
|
||||
})
|
||||
}
|
||||
|
||||
func workflowChatCmd(orch *orchestrator.Orchestrator, input string) tea.Cmd {
|
||||
return tea.Cmd(func() tea.Msg {
|
||||
wf := orch.Workflow
|
||||
switch wf.Phase {
|
||||
case workflow.PhaseGathering:
|
||||
resp, err := orch.AnswerQuestion(input)
|
||||
if err != nil {
|
||||
return aiErrMsg{err: err}
|
||||
}
|
||||
return aiResponseMsg{content: resp}
|
||||
case workflow.PhaseReviewing:
|
||||
approved, feedback := workflow.ParseApproval(input)
|
||||
resp, err := orch.ReviewPlan(approved, feedback)
|
||||
if err != nil {
|
||||
return aiErrMsg{err: err}
|
||||
}
|
||||
return aiResponseMsg{content: resp}
|
||||
default:
|
||||
resp, err := orch.Send(input)
|
||||
if err != nil {
|
||||
return aiErrMsg{err: err}
|
||||
}
|
||||
return aiResponseMsg{content: resp}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func generatePlanCmd(orch *orchestrator.Orchestrator) tea.Cmd {
|
||||
return tea.Cmd(func() tea.Msg {
|
||||
resp, err := orch.GeneratePlan()
|
||||
if err != nil {
|
||||
return aiErrMsg{err: err}
|
||||
}
|
||||
return aiResponseMsg{content: resp}
|
||||
})
|
||||
}
|
||||
|
||||
func reviewPlanCmd(orch *orchestrator.Orchestrator, approved bool, feedback string) tea.Cmd {
|
||||
return tea.Cmd(func() tea.Msg {
|
||||
resp, err := orch.ReviewPlan(approved, feedback)
|
||||
if err != nil {
|
||||
return aiErrMsg{err: err}
|
||||
}
|
||||
return aiResponseMsg{content: resp}
|
||||
})
|
||||
}
|
||||
|
||||
func continueWorkflowCmd(orch *orchestrator.Orchestrator, output string) tea.Cmd {
|
||||
return tea.Cmd(func() tea.Msg {
|
||||
resp, err := orch.ContinueExecution(output)
|
||||
if err != nil {
|
||||
return aiErrMsg{err: err}
|
||||
}
|
||||
return aiResponseMsg{content: resp}
|
||||
})
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
var versionRegex = regexp.MustCompile(`\d+\.\d+\.\d+`)
|
||||
|
||||
func extractVersion(s string) string {
|
||||
return versionRegex.FindString(s)
|
||||
}
|
||||
|
||||
func (m Model) renderConfig() string {
|
||||
colWidth := m.width / 2
|
||||
if colWidth < 30 {
|
||||
colWidth = 30
|
||||
}
|
||||
|
||||
var left, right strings.Builder
|
||||
|
||||
left.WriteString(renderSectionWithIcon("Profile", "👤"))
|
||||
left.WriteString("\n")
|
||||
if m.config != nil {
|
||||
fields := []struct {
|
||||
label string
|
||||
value string
|
||||
}{
|
||||
{"Name", m.config.Profile.Name},
|
||||
{"Pseudo", m.config.Profile.Pseudo},
|
||||
{"Email", m.config.Profile.Email},
|
||||
{"Editor", m.config.Profile.Preferences.Editor},
|
||||
{"Shell", m.config.Profile.Preferences.Shell},
|
||||
{"Theme", m.config.Profile.Preferences.Theme},
|
||||
{"Default AI", m.config.Profile.Preferences.DefaultAI},
|
||||
}
|
||||
for _, f := range fields {
|
||||
left.WriteString(fmt.Sprintf(" %s %s\n",
|
||||
labelStyle.Render(f.label+":"),
|
||||
valueStyle.Render(f.value)))
|
||||
}
|
||||
if len(m.config.Profile.Languages) > 0 {
|
||||
left.WriteString(fmt.Sprintf(" %s %s\n",
|
||||
labelStyle.Render("Languages:"),
|
||||
valueStyle.Render(strings.Join(m.config.Profile.Languages, ", "))))
|
||||
}
|
||||
}
|
||||
left.WriteString("\n")
|
||||
|
||||
left.WriteString(renderSectionWithIcon("AI Providers", "◆"))
|
||||
left.WriteString("\n")
|
||||
if m.config != nil {
|
||||
for _, p := range m.config.AI.Providers {
|
||||
active := ""
|
||||
if p.Active {
|
||||
active = lipgloss.NewStyle().Foreground(primaryColor).Bold(true).Render(" ●")
|
||||
}
|
||||
keyStatus := itemMissingStyle.Render("no key")
|
||||
if p.APIKey != "" {
|
||||
keyStatus = itemOKStyle.Render("configured")
|
||||
}
|
||||
nameStyle := lipgloss.NewStyle().Foreground(textColor).Bold(true)
|
||||
left.WriteString(fmt.Sprintf(" %s %s key=%s%s\n",
|
||||
nameStyle.Render(p.Name),
|
||||
lipgloss.NewStyle().Foreground(dimColor).Render("model="+p.Model),
|
||||
keyStatus, active))
|
||||
}
|
||||
}
|
||||
left.WriteString("\n")
|
||||
|
||||
right.WriteString(renderSectionWithIcon("Terminal", "▶"))
|
||||
right.WriteString("\n")
|
||||
if m.config != nil {
|
||||
right.WriteString(fmt.Sprintf(" %s %v\n", labelStyle.Render("Custom Prompt:"), valueStyle.Render(fmt.Sprintf("%v", m.config.Terminal.CustomPrompt))))
|
||||
right.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Prompt Theme:"), valueStyle.Render(m.config.Terminal.PromptTheme)))
|
||||
right.WriteString(fmt.Sprintf(" %s %v\n", labelStyle.Render("Auto Update:"), valueStyle.Render(fmt.Sprintf("%v", m.config.Profile.Preferences.AutoUpdate))))
|
||||
right.WriteString(fmt.Sprintf(" %s %v\n", labelStyle.Render("Check on Start:"), valueStyle.Render(fmt.Sprintf("%v", m.config.Profile.Preferences.CheckOnStart))))
|
||||
}
|
||||
right.WriteString("\n")
|
||||
|
||||
right.WriteString(renderSectionWithIcon("BMAD Method", "◈"))
|
||||
right.WriteString("\n")
|
||||
if m.config != nil {
|
||||
installed := itemMissingStyle.Render("no")
|
||||
if m.config.BMAD.Installed {
|
||||
installed = itemOKStyle.Render("yes")
|
||||
}
|
||||
right.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Installed:"), installed))
|
||||
right.WriteString(fmt.Sprintf(" %s %v\n", labelStyle.Render("Global:"), valueStyle.Render(fmt.Sprintf("%v", m.config.BMAD.Global))))
|
||||
if m.config.BMAD.Version != "" {
|
||||
right.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Version:"), valueStyle.Render(m.config.BMAD.Version)))
|
||||
}
|
||||
}
|
||||
right.WriteString("\n")
|
||||
|
||||
right.WriteString(renderSectionWithIcon(fmt.Sprintf("Skills (%d)", len(m.skillList)), "⚡"))
|
||||
right.WriteString("\n")
|
||||
if len(m.skillList) > 0 {
|
||||
for _, s := range m.skillList {
|
||||
target := s.Target
|
||||
if target == "" {
|
||||
target = "both"
|
||||
}
|
||||
right.WriteString(fmt.Sprintf(" %s %s %s\n",
|
||||
lipgloss.NewStyle().Foreground(textColor).Render(s.Name),
|
||||
lipgloss.NewStyle().Foreground(primaryColor).Render("["+target+"]"),
|
||||
lipgloss.NewStyle().Foreground(dimColor).Render(s.Description)))
|
||||
}
|
||||
} else {
|
||||
right.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render(" No skills. Run `muyue skills init`."))
|
||||
right.WriteString("\n")
|
||||
}
|
||||
|
||||
leftCol := lipgloss.NewStyle().Width(colWidth).Render(left.String())
|
||||
rightCol := lipgloss.NewStyle().Width(colWidth).Render(right.String())
|
||||
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top, leftCol, rightCol)
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
func (m Model) renderDashboard() string {
|
||||
colWidth := m.width / 2
|
||||
if colWidth < 30 {
|
||||
colWidth = 30
|
||||
}
|
||||
|
||||
var left, right strings.Builder
|
||||
|
||||
left.WriteString(renderSectionWithIcon("System", "◉"))
|
||||
left.WriteString("\n")
|
||||
if m.scanResult != nil {
|
||||
sysInfo := m.scanResult.System.String()
|
||||
left.WriteString(" ")
|
||||
left.WriteString(lipgloss.NewStyle().Foreground(textColor).Render(sysInfo))
|
||||
}
|
||||
left.WriteString("\n\n")
|
||||
|
||||
left.WriteString(renderSectionWithIcon("Installed Tools", "◆"))
|
||||
left.WriteString("\n")
|
||||
if m.scanResult != nil {
|
||||
installed := 0
|
||||
total := len(m.scanResult.Tools)
|
||||
for _, t := range m.scanResult.Tools {
|
||||
if t.Installed {
|
||||
installed++
|
||||
left.WriteString(" ")
|
||||
left.WriteString(itemOKStyle.Render("✓ "))
|
||||
left.WriteString(lipgloss.NewStyle().Foreground(textColor).Render(t.Name))
|
||||
left.WriteString(lipgloss.NewStyle().Foreground(dimColor).Render(fmt.Sprintf(" %s", extractVersion(t.Version))))
|
||||
left.WriteString("\n")
|
||||
} else {
|
||||
left.WriteString(" ")
|
||||
left.WriteString(itemMissingStyle.Render("✗ "))
|
||||
left.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render(t.Name))
|
||||
left.WriteString(itemPendingStyle.Render(" (missing)"))
|
||||
left.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
barWidth := 20
|
||||
pct := 0
|
||||
if total > 0 {
|
||||
pct = (installed * barWidth) / total
|
||||
}
|
||||
bar := lipgloss.NewStyle().Foreground(primaryColor).Render(strings.Repeat("█", pct)) +
|
||||
lipgloss.NewStyle().Foreground(dimColor).Render(strings.Repeat("░", barWidth-pct))
|
||||
left.WriteString(fmt.Sprintf("\n %s %d/%d\n", bar, installed, total))
|
||||
}
|
||||
left.WriteString("\n")
|
||||
|
||||
if m.installing {
|
||||
left.WriteString(renderSectionWithIcon("Installing", "⏳"))
|
||||
left.WriteString("\n")
|
||||
progBar := m.progressBar.View()
|
||||
label := ""
|
||||
if m.installTool != "" {
|
||||
label = fmt.Sprintf(" %d/%d - %s", m.installCurrent+1, m.installTotal, m.installTool)
|
||||
} else {
|
||||
label = fmt.Sprintf(" %d/%d", m.installCurrent, m.installTotal)
|
||||
}
|
||||
left.WriteString(fmt.Sprintf(" %s%s\n", progBar, label))
|
||||
left.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(m.installLog) > 0 {
|
||||
left.WriteString(renderSectionWithIcon("Install Log", "📋"))
|
||||
left.WriteString("\n")
|
||||
for _, l := range m.installLog {
|
||||
left.WriteString(l + "\n")
|
||||
}
|
||||
left.WriteString("\n")
|
||||
}
|
||||
|
||||
right.WriteString(renderSectionWithIcon("Quick Actions", "⚡"))
|
||||
right.WriteString("\n")
|
||||
actions := []struct {
|
||||
key string
|
||||
desc string
|
||||
color lipgloss.Color
|
||||
}{
|
||||
{"i", "Install missing tools", primaryColor},
|
||||
{"u", "Check for updates", warmColor},
|
||||
{"s", "Rescan system", roseColor},
|
||||
{"l", "Scan LSP servers", accentColor},
|
||||
{"m", "Configure MCP servers", roseLightColor},
|
||||
}
|
||||
for _, a := range actions {
|
||||
right.WriteString(fmt.Sprintf(" %s %s\n",
|
||||
lipgloss.NewStyle().Foreground(a.color).Bold(true).Render("["+a.key+"]"),
|
||||
lipgloss.NewStyle().Foreground(textColor).Render(a.desc)))
|
||||
}
|
||||
right.WriteString("\n")
|
||||
|
||||
right.WriteString(renderSectionWithIcon("Active Agents", "◉"))
|
||||
right.WriteString("\n")
|
||||
|
||||
agents := []struct {
|
||||
name string
|
||||
}{
|
||||
{"Crush"},
|
||||
{"Claude Code"},
|
||||
}
|
||||
for _, a := range agents {
|
||||
right.WriteString(" ")
|
||||
right.WriteString(lipgloss.NewStyle().Foreground(dimColor).Render("● "))
|
||||
right.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render(a.name + " "))
|
||||
right.WriteString(itemPendingStyle.Render("stopped"))
|
||||
right.WriteString("\n")
|
||||
}
|
||||
right.WriteString("\n")
|
||||
|
||||
if len(m.updateStatus) > 0 {
|
||||
right.WriteString(renderSectionWithIcon("Updates", "↻"))
|
||||
right.WriteString("\n")
|
||||
for _, s := range m.updateStatus {
|
||||
if s.NeedsUpdate {
|
||||
right.WriteString(" ")
|
||||
right.WriteString(itemWarnStyle.Render("⚠ "))
|
||||
right.WriteString(fmt.Sprintf("%s: %s → %s\n", s.Tool, s.Current, s.Latest))
|
||||
} else if s.Error == "" {
|
||||
right.WriteString(" ")
|
||||
right.WriteString(itemOKStyle.Render("✓ "))
|
||||
right.WriteString(fmt.Sprintf("%s: up to date\n", s.Tool))
|
||||
}
|
||||
}
|
||||
right.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(m.lspServers) > 0 {
|
||||
right.WriteString(renderSectionWithIcon("LSP Servers", "§"))
|
||||
right.WriteString("\n")
|
||||
lspInstalled := 0
|
||||
for _, s := range m.lspServers {
|
||||
if s.Installed {
|
||||
lspInstalled++
|
||||
right.WriteString(" ")
|
||||
right.WriteString(itemOKStyle.Render("✓ "))
|
||||
right.WriteString(fmt.Sprintf("%-22s (%s)\n", s.Name, s.Language))
|
||||
} else {
|
||||
right.WriteString(" ")
|
||||
right.WriteString(itemPendingStyle.Render("○ "))
|
||||
right.WriteString(fmt.Sprintf("%-22s (%s)\n", s.Name, s.Language))
|
||||
}
|
||||
}
|
||||
right.WriteString(fmt.Sprintf("\n %d/%d available\n", lspInstalled, len(m.lspServers)))
|
||||
right.WriteString("\n")
|
||||
}
|
||||
|
||||
mcpStatus := itemPendingStyle.Render("○ not configured")
|
||||
if m.mcpConfigured {
|
||||
mcpStatus = itemOKStyle.Render("✓ configured")
|
||||
}
|
||||
right.WriteString(fmt.Sprintf(" MCP: %s\n", mcpStatus))
|
||||
|
||||
if m.daemon != nil {
|
||||
daemonStatus := itemPendingStyle.Render("○ stopped")
|
||||
if m.daemon.IsRunning() {
|
||||
daemonStatus = itemOKStyle.Render("✓ running")
|
||||
}
|
||||
right.WriteString(fmt.Sprintf(" Daemon: %s\n", daemonStatus))
|
||||
}
|
||||
|
||||
leftCol := lipgloss.NewStyle().Width(colWidth).Render(left.String())
|
||||
rightCol := lipgloss.NewStyle().Width(colWidth).Render(right.String())
|
||||
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top, leftCol, rightCol)
|
||||
}
|
||||
|
||||
func renderSectionWithIcon(title string, icon string) string {
|
||||
return lipgloss.NewStyle().Foreground(primaryColor).Render(icon+" ") +
|
||||
sectionStyle.Render(title)
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/muyue/muyue/internal/lsp"
|
||||
"github.com/muyue/muyue/internal/mcp"
|
||||
"github.com/muyue/muyue/internal/proxy"
|
||||
"github.com/muyue/muyue/internal/scanner"
|
||||
"github.com/muyue/muyue/internal/updater"
|
||||
"github.com/muyue/muyue/internal/workflow"
|
||||
)
|
||||
|
||||
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if m.showingQuit {
|
||||
return m.handleQuitConfirm(msg)
|
||||
}
|
||||
if m.showingTabMenu {
|
||||
return m.handleTabMenu(msg)
|
||||
}
|
||||
|
||||
if m.activeTab == tabShell {
|
||||
return m.handleShellKey(msg)
|
||||
}
|
||||
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
now := time.Now()
|
||||
if m.ctrlCCount > 0 && now.Sub(m.lastCtrlC) < 2*time.Second {
|
||||
return m, tea.Quit
|
||||
}
|
||||
m.ctrlCCount++
|
||||
m.lastCtrlC = now
|
||||
m.showingQuit = true
|
||||
m.confirmCursor = 1
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
return m, nil
|
||||
case "ctrl+t":
|
||||
m.showingTabMenu = true
|
||||
m.tabMenuCursor = int(m.activeTab)
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
return m, nil
|
||||
case "ctrl+s":
|
||||
if m.activeTab == tabStudio {
|
||||
m.studioSidebarOpen = !m.studioSidebarOpen
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
}
|
||||
case "enter":
|
||||
if m.activeTab == tabStudio && m.chatInput != "" && !m.chatLoading {
|
||||
return m.handleChatSubmit()
|
||||
}
|
||||
case "backspace":
|
||||
if m.activeTab == tabStudio && len(m.chatInput) > 0 {
|
||||
m.chatInput = m.chatInput[:len(m.chatInput)-1]
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
}
|
||||
default:
|
||||
if m.activeTab == tabStudio && len(msg.String()) == 1 && !m.chatLoading {
|
||||
m.chatInput += msg.String()
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
}
|
||||
}
|
||||
|
||||
if m.activeTab == tabDashboard {
|
||||
return m.handleDashboardKey(msg)
|
||||
}
|
||||
if m.activeTab == tabStudio {
|
||||
return m.handleStudioKey(msg)
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func cleanup(m Model) {
|
||||
if m.daemon != nil {
|
||||
m.daemon.Stop()
|
||||
}
|
||||
if m.previewSrv != nil {
|
||||
m.previewSrv.Stop()
|
||||
}
|
||||
for _, agentType := range []proxy.AgentType{proxy.AgentCrush, proxy.AgentClaude} {
|
||||
m.proxyMgr.Stop(agentType)
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) handleQuitConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "y", "Y", "o", "O":
|
||||
m.showingQuit = false
|
||||
cleanup(m)
|
||||
return m, tea.Quit
|
||||
case "n", "N", "esc":
|
||||
m.showingQuit = false
|
||||
m.ctrlCCount = 0
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
return m, nil
|
||||
case "left", "h":
|
||||
m.confirmCursor = 0
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
return m, nil
|
||||
case "right", "l":
|
||||
m.confirmCursor = 1
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
return m, nil
|
||||
case "enter":
|
||||
if m.confirmCursor == 0 {
|
||||
m.showingQuit = false
|
||||
cleanup(m)
|
||||
return m, tea.Quit
|
||||
}
|
||||
m.showingQuit = false
|
||||
m.ctrlCCount = 0
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
return m, nil
|
||||
case "ctrl+c":
|
||||
m.showingQuit = false
|
||||
cleanup(m)
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) handleTabMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
m.showingTabMenu = false
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
return m, nil
|
||||
case "up", "k":
|
||||
if m.tabMenuCursor > 0 {
|
||||
m.tabMenuCursor--
|
||||
}
|
||||
return m, nil
|
||||
case "down", "j":
|
||||
if m.tabMenuCursor < int(tabCount)-1 {
|
||||
m.tabMenuCursor++
|
||||
}
|
||||
return m, nil
|
||||
case "enter":
|
||||
m.activeTab = tab(m.tabMenuCursor)
|
||||
m.showingTabMenu = false
|
||||
m.resizeViewport()
|
||||
return m, nil
|
||||
default:
|
||||
for i := 0; i < int(tabCount); i++ {
|
||||
if msg.String() == fmt.Sprintf("%d", i+1) {
|
||||
m.activeTab = tab(i)
|
||||
m.showingTabMenu = false
|
||||
m.resizeViewport()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "i":
|
||||
if m.installing {
|
||||
return m, nil
|
||||
}
|
||||
var missing []string
|
||||
if m.scanResult != nil {
|
||||
for _, t := range m.scanResult.Tools {
|
||||
if !t.Installed {
|
||||
missing = append(missing, t.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
m.installLog = append(m.installLog, itemOKStyle.Render("✓ All tools already installed!"))
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
return m, nil
|
||||
}
|
||||
needsSudo := checkNeedsSudo(m.scanResult)
|
||||
if needsSudo && !hasSudo() {
|
||||
m.installLog = append(m.installLog, errMsgStyle.Render("✗ Some tools require sudo. Run: sudo muyue install"))
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
return m, nil
|
||||
}
|
||||
m.installing = true
|
||||
m.installCurrent = 0
|
||||
m.installTotal = len(missing)
|
||||
m.installTool = missing[0]
|
||||
m.progressBar.SetPercent(0)
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
return m, startInstallCmd(m.config, missing, 0)
|
||||
case "u":
|
||||
return m, tea.Cmd(func() tea.Msg {
|
||||
result := scanner.ScanSystem()
|
||||
return updateCheckMsg{statuses: updater.CheckUpdates(result)}
|
||||
})
|
||||
case "s":
|
||||
return m, tea.Cmd(func() tea.Msg {
|
||||
return scanCompleteMsg{result: scanner.ScanSystem()}
|
||||
})
|
||||
case "l":
|
||||
return m, tea.Cmd(func() tea.Msg {
|
||||
servers := lsp.ScanServers()
|
||||
return lspScanMsg{servers: servers}
|
||||
})
|
||||
case "m":
|
||||
return m, tea.Cmd(func() tea.Msg {
|
||||
err := mcp.ConfigureAll(m.config)
|
||||
return mcpConfigMsg{err: err}
|
||||
})
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) handleStudioKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if !m.studioSidebarOpen {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch msg.String() {
|
||||
case "1":
|
||||
m.studioPanel = panelChat
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
case "2":
|
||||
m.studioPanel = panelAgents
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
case "3":
|
||||
m.studioPanel = panelWorkflows
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
}
|
||||
|
||||
if m.studioPanel == panelAgents {
|
||||
return m.handleAgentsKey(msg)
|
||||
}
|
||||
if m.studioPanel == panelWorkflows {
|
||||
return m.handleWorkflowKey(msg)
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) handleAgentsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "c":
|
||||
if m.proxyMgr.IsAvailable(proxy.AgentCrush) {
|
||||
m.proxyMgr.Start(proxy.AgentCrush)
|
||||
}
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
case "l":
|
||||
if m.proxyMgr.IsAvailable(proxy.AgentClaude) {
|
||||
m.proxyMgr.Start(proxy.AgentClaude)
|
||||
}
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) handleWorkflowKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if m.orch == nil || m.orch.Workflow == nil {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
wf := m.orch.Workflow
|
||||
|
||||
switch msg.String() {
|
||||
case "a":
|
||||
if wf.Phase == workflow.PhaseReviewing {
|
||||
m.chatLog = append(m.chatLog, userMsgStyle.Render("⟩ [Plan approved]"))
|
||||
m.chatLoading = true
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
return m, reviewPlanCmd(m.orch, true, "")
|
||||
}
|
||||
case "r":
|
||||
if wf.Phase == workflow.PhaseReviewing {
|
||||
m.chatInput = ""
|
||||
m.chatLog = append(m.chatLog, itemWarnStyle.Render("Type your rejection feedback below:"))
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
}
|
||||
case "g":
|
||||
if wf.Phase == workflow.PhaseGathering && len(wf.Plan.Questions) > 0 && len(wf.Plan.Answers) >= len(wf.Plan.Questions) {
|
||||
m.chatLog = append(m.chatLog, userMsgStyle.Render("⟩ [Generate plan]"))
|
||||
m.chatLoading = true
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
return m, generatePlanCmd(m.orch)
|
||||
}
|
||||
case "n":
|
||||
if wf.Phase == workflow.PhaseExecuting {
|
||||
current := wf.CurrentStep()
|
||||
if current != nil {
|
||||
m.chatLoading = true
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
return m, continueWorkflowCmd(m.orch, "proceeding")
|
||||
}
|
||||
}
|
||||
case "x":
|
||||
wf.Reset()
|
||||
m.chatLog = append(m.chatLog, itemWarnStyle.Render("Workflow reset."))
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func checkNeedsSudo(scan *scanner.ScanResult) bool {
|
||||
if scan == nil {
|
||||
return false
|
||||
}
|
||||
sudoTools := map[string]bool{
|
||||
"docker": true, "git": true, "gh": true, "node": true, "python3": true,
|
||||
}
|
||||
for _, t := range scan.Tools {
|
||||
if !t.Installed && sudoTools[t.Name] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasSudo() bool {
|
||||
if os.Geteuid() == 0 {
|
||||
return true
|
||||
}
|
||||
if _, err := exec.LookPath("sudo"); err == nil {
|
||||
return true
|
||||
}
|
||||
if _, err := exec.LookPath("pkexec"); err == nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m Model) handleChatSubmit() (tea.Model, tea.Cmd) {
|
||||
input := m.chatInput
|
||||
m.chatLog = append(m.chatLog, userMsgStyle.Render("⟩ "+input))
|
||||
m.chatInput = ""
|
||||
m.chatLoading = true
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
m.viewport.GotoBottom()
|
||||
|
||||
if strings.HasPrefix(input, "/plan ") {
|
||||
goal := strings.TrimPrefix(input, "/plan ")
|
||||
return m, startWorkflowCmd(m.orch, goal)
|
||||
}
|
||||
|
||||
if m.orch != nil && m.orch.Workflow != nil && m.orch.Workflow.Phase != workflow.PhaseIdle {
|
||||
return m, workflowChatCmd(m.orch, input)
|
||||
}
|
||||
|
||||
return m, sendAIMessage(m.orch, input)
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"github.com/muyue/muyue/internal/workflow"
|
||||
)
|
||||
|
||||
type previewFile = workflow.PreviewFile
|
||||
|
||||
func parsePreviewFiles(response string) []previewFile {
|
||||
return workflow.ParsePreviewFiles(response)
|
||||
}
|
||||
@@ -1,516 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/progress"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
"github.com/muyue/muyue/internal/daemon"
|
||||
"github.com/muyue/muyue/internal/lsp"
|
||||
"github.com/muyue/muyue/internal/mcp"
|
||||
"github.com/muyue/muyue/internal/orchestrator"
|
||||
"github.com/muyue/muyue/internal/preview"
|
||||
"github.com/muyue/muyue/internal/proxy"
|
||||
"github.com/muyue/muyue/internal/scanner"
|
||||
"github.com/muyue/muyue/internal/skills"
|
||||
"github.com/muyue/muyue/internal/version"
|
||||
)
|
||||
|
||||
func NewModel(cfg *config.MuyueConfig, scan *scanner.ScanResult) Model {
|
||||
orch, _ := orchestrator.New(cfg)
|
||||
proxyMgr := proxy.NewManager()
|
||||
d := daemon.NewDaemon(cfg, 1*time.Hour)
|
||||
|
||||
lspServers := lsp.ScanServers()
|
||||
skillList, _ := skills.List()
|
||||
|
||||
mcpConfigured := false
|
||||
if err := mcp.ConfigureAll(cfg); err == nil {
|
||||
mcpConfigured = true
|
||||
}
|
||||
|
||||
if cfg.Profile.Preferences.AutoUpdate {
|
||||
d.Start()
|
||||
}
|
||||
|
||||
sp := spinner.New()
|
||||
sp.Spinner = spinner.Dot
|
||||
sp.Style = lipgloss.NewStyle().Foreground(primaryColor)
|
||||
|
||||
prog := progress.New(progress.WithGradient("#E8364F", "#FF6B8A"))
|
||||
|
||||
cwd, _ := os.Getwd()
|
||||
|
||||
return Model{
|
||||
config: cfg,
|
||||
scanResult: scan,
|
||||
activeTab: tabDashboard,
|
||||
chatLog: []string{
|
||||
aiMsgStyle.Render(" Welcome to Studio! Chat with your AI assistant here."),
|
||||
aiMsgStyle.Render(" Configure agents and workflows from the sidebar. Type /plan <goal> to start."),
|
||||
},
|
||||
orch: orch,
|
||||
proxyMgr: proxyMgr,
|
||||
chatInput: "",
|
||||
chatLoading: false,
|
||||
daemon: d,
|
||||
lspServers: lspServers,
|
||||
mcpConfigured: mcpConfigured,
|
||||
skillList: skillList,
|
||||
helpModel: help.New(),
|
||||
progressBar: prog,
|
||||
spinner: sp,
|
||||
showingQuit: false,
|
||||
confirmCursor: 1,
|
||||
showingTabMenu: false,
|
||||
tabMenuCursor: 0,
|
||||
termCwd: cwd,
|
||||
studioPanel: panelChat,
|
||||
studioSidebarOpen: true,
|
||||
termAIChat: []string{
|
||||
aiMsgStyle.Render(" I know your system inside out. Ask me anything."),
|
||||
},
|
||||
termAIShow: true,
|
||||
configSection: configProfile,
|
||||
configField: 0,
|
||||
animationFrame: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func animTick() tea.Cmd {
|
||||
return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg {
|
||||
return animTickMsg{time: t}
|
||||
})
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return tea.Batch(spinner.Tick, animTick(), tea.EnterAltScreen)
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
return m.handleKey(msg)
|
||||
case spinner.TickMsg:
|
||||
var cmd tea.Cmd
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
return m, cmd
|
||||
case animTickMsg:
|
||||
m.animationFrame++
|
||||
return m, animTick()
|
||||
case progress.FrameMsg:
|
||||
pm, cmd := m.progressBar.Update(msg)
|
||||
m.progressBar = pm.(progress.Model)
|
||||
return m, cmd
|
||||
case termOutputMsg:
|
||||
m.termLog = append(m.termLog, msg.line)
|
||||
if m.activeTab == tabShell {
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
m.viewport.GotoBottom()
|
||||
}
|
||||
return m, nil
|
||||
case termExitMsg:
|
||||
m.termRunning = false
|
||||
m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(dimColor).Render("(process exited)"))
|
||||
m.termCmd = nil
|
||||
if m.activeTab == tabShell {
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
}
|
||||
return m, nil
|
||||
case aiResponseMsg:
|
||||
m.chatLoading = false
|
||||
m.termAILoading = false
|
||||
content := msg.content
|
||||
|
||||
if m.activeTab == tabShell && m.termAIShow {
|
||||
m.termAIChat = append(m.termAIChat, aiMsgStyle.Render(" "+content))
|
||||
if m.activeTab == tabShell {
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
m.viewport.GotoBottom()
|
||||
}
|
||||
} else {
|
||||
m.chatLog = append(m.chatLog, aiMsgStyle.Render(" "+content))
|
||||
if m.orch != nil && m.orch.Workflow != nil {
|
||||
previewFiles := parsePreviewFiles(content)
|
||||
if len(previewFiles) > 0 {
|
||||
m.handlePreview(previewFiles)
|
||||
}
|
||||
}
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
m.viewport.GotoBottom()
|
||||
}
|
||||
return m, nil
|
||||
case aiErrMsg:
|
||||
m.chatLoading = false
|
||||
m.termAILoading = false
|
||||
errText := errMsgStyle.Render(" error: " + msg.err.Error())
|
||||
if m.activeTab == tabShell && m.termAIShow {
|
||||
m.termAIChat = append(m.termAIChat, errText)
|
||||
} else {
|
||||
m.chatLog = append(m.chatLog, errText)
|
||||
}
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
m.viewport.GotoBottom()
|
||||
return m, nil
|
||||
case scanCompleteMsg:
|
||||
m.scanResult = msg.result
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
return m, nil
|
||||
case installCompleteMsg:
|
||||
m.installing = false
|
||||
for _, r := range msg.results {
|
||||
status := itemOKStyle.Render("✓")
|
||||
if !r.Success {
|
||||
status = itemMissingStyle.Render("✗")
|
||||
}
|
||||
m.installLog = append(m.installLog, fmt.Sprintf(" %s %s: %s", status, r.Tool, r.Message))
|
||||
}
|
||||
m.scanResult = scanner.ScanSystem()
|
||||
m.progressBar.SetPercent(1)
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
return m, nil
|
||||
case installProgressMsg:
|
||||
status := itemOKStyle.Render("✓")
|
||||
m.installLog = append(m.installLog, fmt.Sprintf(" %s %s installed", status, msg.tool))
|
||||
m.installCurrent = msg.current
|
||||
m.installTool = ""
|
||||
pct := float64(msg.current) / float64(max(msg.total, 1))
|
||||
m.progressBar.SetPercent(pct)
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
return m, nil
|
||||
case installBatchMsg:
|
||||
status := itemOKStyle.Render("✓")
|
||||
if !msg.result.Success {
|
||||
status = itemMissingStyle.Render("✗")
|
||||
}
|
||||
m.installLog = append(m.installLog, fmt.Sprintf(" %s %s: %s", status, msg.result.Tool, msg.result.Message))
|
||||
m.installCurrent = msg.index + 1
|
||||
m.installTotal = len(msg.tools)
|
||||
pct := float64(m.installCurrent) / float64(max(m.installTotal, 1))
|
||||
m.progressBar.SetPercent(pct)
|
||||
if msg.index+1 < len(msg.tools) {
|
||||
m.installTool = msg.tools[msg.index+1]
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
return m, startInstallCmd(msg.config, msg.tools, msg.index+1)
|
||||
}
|
||||
m.installing = false
|
||||
m.scanResult = scanner.ScanSystem()
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
return m, nil
|
||||
case updateCheckMsg:
|
||||
m.updateStatus = msg.statuses
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
return m, nil
|
||||
case previewReadyMsg:
|
||||
m.previewURL = msg.url
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
return m, nil
|
||||
case lspScanMsg:
|
||||
m.lspServers = msg.servers
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
return m, nil
|
||||
case mcpConfigMsg:
|
||||
if msg.err == nil {
|
||||
m.mcpConfigured = true
|
||||
}
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
return m, nil
|
||||
case daemonLogMsg:
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
return m, nil
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.helpModel.Width = msg.Width
|
||||
headerH := 2
|
||||
footerH := 2
|
||||
inputH := 0
|
||||
if m.activeTab == tabStudio || m.activeTab == tabShell {
|
||||
inputH = 2
|
||||
}
|
||||
contentH := msg.Height - headerH - footerH - inputH
|
||||
if contentH < 1 {
|
||||
contentH = 1
|
||||
}
|
||||
m.viewport = viewport.New(msg.Width, contentH)
|
||||
m.viewport.Width = msg.Width
|
||||
m.viewport.Height = contentH
|
||||
m.progressBar.Width = msg.Width - 20
|
||||
m.ready = true
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
if !m.ready {
|
||||
return lipgloss.NewStyle().Foreground(primaryColor).Bold(true).Render("Loading muyue...")
|
||||
}
|
||||
|
||||
if m.showingQuit {
|
||||
return m.renderQuitOverlay()
|
||||
}
|
||||
|
||||
if m.showingTabMenu {
|
||||
return m.renderTabMenuOverlay()
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(m.renderHeader())
|
||||
b.WriteString("\n")
|
||||
b.WriteString(m.viewport.View())
|
||||
if m.activeTab == tabStudio {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(m.renderStudioInput())
|
||||
}
|
||||
if m.activeTab == tabShell {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(m.renderShellInput())
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.WriteString(m.renderFooter())
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m Model) renderHeader() string {
|
||||
var tabs []string
|
||||
for i, name := range tabNames {
|
||||
icon := tabIcons[i]
|
||||
if tab(i) == m.activeTab {
|
||||
tabStyle := lipgloss.NewStyle().
|
||||
Background(primaryColor).
|
||||
Foreground(lipgloss.Color("#FFFFFF")).
|
||||
Bold(true).
|
||||
Padding(0, 2)
|
||||
tabs = append(tabs, tabStyle.Render(icon+" "+name))
|
||||
} else {
|
||||
tabStyle := lipgloss.NewStyle().
|
||||
Background(bgPanel).
|
||||
Foreground(textDimColor).
|
||||
Padding(0, 2)
|
||||
tabs = append(tabs, tabStyle.Render(icon+" "+name))
|
||||
}
|
||||
}
|
||||
|
||||
tabLine := tabBarStyle.Render(lipgloss.JoinHorizontal(lipgloss.Bottom, tabs...))
|
||||
|
||||
badge := lipgloss.NewStyle().
|
||||
Foreground(roseColor).
|
||||
Bold(true).
|
||||
Render("muyue")
|
||||
versionBadge := lipgloss.NewStyle().
|
||||
Foreground(dimColor).
|
||||
Render("v" + version.Version)
|
||||
|
||||
anim := lipgloss.NewStyle().Foreground(warmColor).Render(getAnimFrame(m.animationFrame))
|
||||
|
||||
logoLine := lipgloss.NewStyle().Background(bgDark).Padding(0, 1).Render(
|
||||
lipgloss.JoinHorizontal(lipgloss.Center, badge, " ", versionBadge, " ", anim),
|
||||
)
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, logoLine, tabLine)
|
||||
}
|
||||
|
||||
func (m Model) renderContent() string {
|
||||
switch m.activeTab {
|
||||
case tabDashboard:
|
||||
return m.renderDashboard()
|
||||
case tabStudio:
|
||||
return m.renderStudio()
|
||||
case tabShell:
|
||||
return m.renderShell()
|
||||
case tabConfig:
|
||||
return m.renderConfig()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) resizeViewport() {
|
||||
headerH := 2
|
||||
footerH := 2
|
||||
inputH := 0
|
||||
if m.activeTab == tabStudio || m.activeTab == tabShell {
|
||||
inputH = 2
|
||||
}
|
||||
contentH := m.height - headerH - footerH - inputH
|
||||
if contentH < 1 {
|
||||
contentH = 1
|
||||
}
|
||||
m.viewport = viewport.New(m.width, contentH)
|
||||
m.viewport.Width = m.width
|
||||
m.viewport.Height = contentH
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
}
|
||||
|
||||
func (m Model) renderFooter() string {
|
||||
profile := "unknown"
|
||||
if m.config != nil && m.config.Profile.Pseudo != "" {
|
||||
profile = m.config.Profile.Pseudo
|
||||
}
|
||||
|
||||
left := fmt.Sprintf(" %s@%s",
|
||||
lipgloss.NewStyle().Foreground(roseColor).Bold(true).Render(profile),
|
||||
lipgloss.NewStyle().Foreground(dimColor).Render(version.Name))
|
||||
leftR := statusBarStyle.Render(left)
|
||||
|
||||
var helpText string
|
||||
switch m.activeTab {
|
||||
case tabDashboard:
|
||||
helpText = "[i] install [u] update [s] scan [ctrl+t] tabs"
|
||||
case tabStudio:
|
||||
helpText = "[enter] send [ctrl+s] sidebar [ctrl+t] tabs"
|
||||
case tabShell:
|
||||
helpText = "[enter] run [ctrl+a] AI panel [ctrl+c] kill"
|
||||
case tabConfig:
|
||||
helpText = "[↑↓] sections [ctrl+t] tabs"
|
||||
default:
|
||||
helpText = "[ctrl+t] tabs [ctrl+c] quit"
|
||||
}
|
||||
rightR := statusBarStyle.Render(helpText)
|
||||
|
||||
gap := m.width - lipgloss.Width(leftR) - lipgloss.Width(rightR)
|
||||
if gap < 0 {
|
||||
gap = 0
|
||||
}
|
||||
|
||||
statusLine := lipgloss.JoinHorizontal(lipgloss.Bottom,
|
||||
leftR,
|
||||
strings.Repeat(" ", gap),
|
||||
rightR,
|
||||
)
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, statusLine,
|
||||
lipgloss.NewStyle().Background(bgPanel).Foreground(dimColor).Render(
|
||||
lipgloss.NewStyle().Padding(0, 1).Render(m.helpModel.View(keys))))
|
||||
}
|
||||
|
||||
func (m Model) renderQuitOverlay() string {
|
||||
yesStyle := confirmNoStyle
|
||||
noStyle := confirmYesStyle
|
||||
if m.confirmCursor == 0 {
|
||||
yesStyle = confirmYesStyle
|
||||
noStyle = confirmNoStyle
|
||||
}
|
||||
|
||||
frame := lipgloss.NewStyle().Foreground(primaryColor).Render(getAnimFrame(m.animationFrame))
|
||||
|
||||
box := fmt.Sprintf("\n\n %s Quit muyue?\n\n %s %s",
|
||||
frame,
|
||||
yesStyle.Render("[ Yes ]"),
|
||||
noStyle.Render("[ No ]"),
|
||||
)
|
||||
|
||||
content := confirmBoxStyle.Render(box)
|
||||
|
||||
return lipgloss.Place(m.width, m.height,
|
||||
0.5, 0.5,
|
||||
content,
|
||||
lipgloss.WithWhitespaceBackground(bgDark),
|
||||
lipgloss.WithWhitespaceForeground(dimColor),
|
||||
)
|
||||
}
|
||||
|
||||
func (m Model) renderTabMenuOverlay() string {
|
||||
menuStyle := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(primaryColor).
|
||||
Background(bgCard).
|
||||
Padding(1, 3)
|
||||
|
||||
tabItemStyle := lipgloss.NewStyle().
|
||||
Foreground(textDimColor).
|
||||
Padding(0, 2)
|
||||
|
||||
tabItemActiveStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FFFFFF")).
|
||||
Background(primaryColor).
|
||||
Bold(true).
|
||||
Padding(0, 2)
|
||||
|
||||
descs := []string{
|
||||
"tools, updates & system status",
|
||||
"chat, agents & workflows",
|
||||
"terminal + AI assistant",
|
||||
"profile, API keys & settings",
|
||||
}
|
||||
|
||||
var items []string
|
||||
for i, name := range tabNames {
|
||||
num := lipgloss.NewStyle().Foreground(dimColor).Render(fmt.Sprintf(" %d.", i+1))
|
||||
icon := tabIcons[i] + " "
|
||||
if i == m.tabMenuCursor {
|
||||
item := fmt.Sprintf("%s %s%-10s %s", num, icon, name, lipgloss.NewStyle().Foreground(roseLightColor).Render(descs[i]))
|
||||
items = append(items, tabItemActiveStyle.Render("▸"+item))
|
||||
} else {
|
||||
item := fmt.Sprintf("%s %s%-10s %s", num, icon, name, lipgloss.NewStyle().Foreground(dimColor).Render(descs[i]))
|
||||
items = append(items, tabItemStyle.Render(" "+item))
|
||||
}
|
||||
}
|
||||
|
||||
header := lipgloss.NewStyle().Foreground(primaryColor).Bold(true).Render("Switch Tab")
|
||||
content := header + "\n\n" +
|
||||
strings.Join(items, "\n") +
|
||||
"\n\n" +
|
||||
lipgloss.NewStyle().Foreground(dimColor).Render("↑↓ navigate · enter select · esc cancel")
|
||||
|
||||
box := menuStyle.Render(content)
|
||||
|
||||
return lipgloss.Place(m.width, m.height,
|
||||
0.5, 0.5,
|
||||
box,
|
||||
lipgloss.WithWhitespaceBackground(bgDark),
|
||||
lipgloss.WithWhitespaceForeground(dimColor),
|
||||
)
|
||||
}
|
||||
|
||||
func (m *Model) handlePreview(files []previewFile) {
|
||||
dir := filepath.Join(os.TempDir(), "muyue-preview")
|
||||
os.RemoveAll(dir)
|
||||
os.MkdirAll(dir, 0755)
|
||||
|
||||
for _, f := range files {
|
||||
preview.CreatePreviewFile(dir, f.Filename, f.Content)
|
||||
}
|
||||
|
||||
if m.previewSrv != nil {
|
||||
m.previewSrv.Stop()
|
||||
}
|
||||
m.previewSrv = preview.NewPreviewServer(dir)
|
||||
if err := m.previewSrv.Start(8765); err != nil {
|
||||
m.chatLog = append(m.chatLog, errMsgStyle.Render(" preview error: "+err.Error()))
|
||||
} else {
|
||||
m.previewURL = "http://127.0.0.1:8765"
|
||||
m.chatLog = append(m.chatLog, itemOKStyle.Render(" Preview: http://127.0.0.1:8765"))
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) renderStudioInput() string {
|
||||
if m.chatLoading {
|
||||
return lipgloss.NewStyle().Background(bgInput).Padding(0, 1).Render(
|
||||
inputStyle.Render("⟩ ") + m.spinner.View() + lipgloss.NewStyle().Foreground(mutedColor).Render(" thinking..."),
|
||||
)
|
||||
}
|
||||
cursor := lipgloss.NewStyle().Foreground(primaryColor).Render("▎")
|
||||
return lipgloss.NewStyle().Background(bgInput).Padding(0, 1).Render(
|
||||
inputStyle.Render("⟩ ") + m.chatInput + cursor,
|
||||
)
|
||||
}
|
||||
|
||||
func (m Model) renderShellInput() string {
|
||||
prompt := lipgloss.NewStyle().Foreground(successColor).Render("❯ ")
|
||||
return lipgloss.NewStyle().Background(bgInput).Padding(0, 1).Render(
|
||||
prompt + m.termInput + lipgloss.NewStyle().Foreground(primaryColor).Render("▎"),
|
||||
)
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/muyue/muyue/internal/proxy"
|
||||
"github.com/muyue/muyue/internal/workflow"
|
||||
)
|
||||
|
||||
func (m Model) renderStudio() string {
|
||||
if m.studioSidebarOpen {
|
||||
sidebarWidth := 28
|
||||
chatWidth := m.width - sidebarWidth - 2
|
||||
if chatWidth < 20 {
|
||||
chatWidth = 20
|
||||
sidebarWidth = m.width - chatWidth - 2
|
||||
}
|
||||
|
||||
sidebar := m.renderStudioSidebar(sidebarWidth)
|
||||
chat := m.renderStudioChat(chatWidth)
|
||||
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top, sidebar, chat)
|
||||
}
|
||||
|
||||
return m.renderStudioChat(m.width)
|
||||
}
|
||||
|
||||
func (m Model) renderStudioSidebar(width int) string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(renderSectionWithIcon("Studio", "◈"))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
panels := []struct {
|
||||
name string
|
||||
panel studioPanel
|
||||
icon string
|
||||
}{
|
||||
{"Chat", panelChat, "💬"},
|
||||
{"Agents", panelAgents, "◉"},
|
||||
{"Workflows", panelWorkflows, "⟐"},
|
||||
}
|
||||
|
||||
for _, p := range panels {
|
||||
if m.studioPanel == p.panel {
|
||||
activeStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FFFFFF")).
|
||||
Background(primaryColor).
|
||||
Bold(true).
|
||||
Padding(0, 1)
|
||||
b.WriteString(activeStyle.Render(p.icon + " " + p.name))
|
||||
b.WriteString("\n")
|
||||
} else {
|
||||
inactiveStyle := lipgloss.NewStyle().
|
||||
Foreground(textDimColor).
|
||||
Padding(0, 1)
|
||||
b.WriteString(inactiveStyle.Render(p.icon + " " + p.name))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(borderColor).Render(strings.Repeat("─", width-4)))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
switch m.studioPanel {
|
||||
case panelAgents:
|
||||
m.renderAgentsSidebar(&b, width)
|
||||
case panelWorkflows:
|
||||
m.renderWorkflowSidebar(&b, width)
|
||||
default:
|
||||
m.renderChatSidebar(&b, width)
|
||||
}
|
||||
|
||||
return sidebarStyle.Width(width).Render(b.String())
|
||||
}
|
||||
|
||||
func (m Model) renderChatSidebar(b *strings.Builder, width int) {
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render("Active Provider"))
|
||||
b.WriteString("\n")
|
||||
provider := "none"
|
||||
if m.config != nil {
|
||||
provider = m.config.Profile.Preferences.DefaultAI
|
||||
}
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(roseColor).Bold(true).Render(" " + provider))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render("Commands"))
|
||||
b.WriteString("\n")
|
||||
cmds := []string{"/plan <goal>", "/help"}
|
||||
for _, c := range cmds {
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(dimColor).Render(" " + c))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
if m.previewURL != "" {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render("Preview"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(itemOKStyle.Render(" " + m.previewURL))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) renderAgentsSidebar(b *strings.Builder, width int) {
|
||||
agents := []struct {
|
||||
name string
|
||||
agentType proxy.AgentType
|
||||
tool string
|
||||
}{
|
||||
{"Crush", proxy.AgentCrush, "GLM"},
|
||||
{"Claude Code", proxy.AgentClaude, "Anthropic"},
|
||||
}
|
||||
|
||||
for _, a := range agents {
|
||||
status, _ := m.proxyMgr.Status(a.agentType)
|
||||
available := m.proxyMgr.IsAvailable(a.agentType)
|
||||
|
||||
var statusIcon string
|
||||
switch status {
|
||||
case proxy.StatusRunning:
|
||||
statusIcon = lipgloss.NewStyle().Foreground(warmColor).Render("● running")
|
||||
case proxy.StatusStopped:
|
||||
statusIcon = lipgloss.NewStyle().Foreground(mutedColor).Render("○ stopped")
|
||||
case proxy.StatusError:
|
||||
statusIcon = lipgloss.NewStyle().Foreground(errorColor).Render("✗ error")
|
||||
default:
|
||||
if available {
|
||||
statusIcon = lipgloss.NewStyle().Foreground(successColor).Render("✓ available")
|
||||
} else {
|
||||
statusIcon = lipgloss.NewStyle().Foreground(dimColor).Render("✗ not installed")
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(textColor).Bold(true).Render(a.name))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(fmt.Sprintf(" %s\n", statusIcon))
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(dimColor).Render(fmt.Sprintf(" %s\n", a.tool)))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render("Actions"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(primaryColor).Bold(true).Render(" [c]"))
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(textColor).Render(" Start Crush"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(primaryColor).Bold(true).Render(" [l]"))
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(textColor).Render(" Start Claude"))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
func (m Model) renderWorkflowSidebar(b *strings.Builder, width int) {
|
||||
if m.orch == nil || m.orch.Workflow == nil {
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render("No active workflow."))
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(dimColor).Render("Use /plan <goal> in chat"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(dimColor).Render("to start a workflow."))
|
||||
b.WriteString("\n")
|
||||
return
|
||||
}
|
||||
|
||||
wf := m.orch.Workflow
|
||||
|
||||
phaseColors := map[workflow.Phase]lipgloss.Style{
|
||||
workflow.PhaseIdle: lipgloss.NewStyle().Foreground(mutedColor),
|
||||
workflow.PhaseGathering: lipgloss.NewStyle().Foreground(warningColor).Bold(true),
|
||||
workflow.PhasePlanning: lipgloss.NewStyle().Foreground(roseColor).Bold(true),
|
||||
workflow.PhaseReviewing: lipgloss.NewStyle().Foreground(accentColor).Bold(true),
|
||||
workflow.PhaseExecuting: lipgloss.NewStyle().Foreground(primaryColor).Bold(true),
|
||||
workflow.PhaseDone: lipgloss.NewStyle().Foreground(successColor).Bold(true),
|
||||
workflow.PhaseError: lipgloss.NewStyle().Foreground(errorColor).Bold(true),
|
||||
}
|
||||
|
||||
if style, ok := phaseColors[wf.Phase]; ok {
|
||||
b.WriteString(style.Render(string(wf.Phase)))
|
||||
}
|
||||
b.WriteString("\n\n")
|
||||
|
||||
if wf.Plan.Goal != "" {
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render("Goal"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(textColor).Render(wf.Plan.Goal))
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
if wf.Phase == workflow.PhaseExecuting {
|
||||
done, total := wf.Progress()
|
||||
m.progressBar.SetPercent(float64(done) / float64(max(total, 1)))
|
||||
b.WriteString(m.progressBar.View())
|
||||
b.WriteString(fmt.Sprintf(" %d/%d", done, total))
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render("Controls"))
|
||||
b.WriteString("\n")
|
||||
controls := []struct {
|
||||
key string
|
||||
desc string
|
||||
}{
|
||||
{"[a]", "Approve plan"},
|
||||
{"[r]", "Reject plan"},
|
||||
{"[g]", "Generate plan"},
|
||||
{"[n]", "Next step"},
|
||||
{"[x]", "Cancel"},
|
||||
}
|
||||
for _, c := range controls {
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(primaryColor).Bold(true).Render(" " + c.key))
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(textDimColor).Render(" " + c.desc))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) renderStudioChat(width int) string {
|
||||
var b strings.Builder
|
||||
|
||||
chatHeader := renderSectionWithIcon("Chat", "💬")
|
||||
if m.chatLoading {
|
||||
chatHeader += " " + m.spinner.View() + lipgloss.NewStyle().Foreground(warningColor).Render("thinking...")
|
||||
}
|
||||
b.WriteString(chatHeader)
|
||||
b.WriteString("\n\n")
|
||||
|
||||
sep := lipgloss.NewStyle().Foreground(borderColor).Render(strings.Repeat("─", max(width-4, 10)))
|
||||
b.WriteString(" " + sep)
|
||||
b.WriteString("\n\n")
|
||||
|
||||
for _, msg := range m.chatLog {
|
||||
b.WriteString(msg)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m Model) handleStudioPanelSwitch(panel studioPanel) {
|
||||
m.studioPanel = panel
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
var (
|
||||
primaryColor = lipgloss.Color("#E8364F")
|
||||
roseColor = lipgloss.Color("#FF6B8A")
|
||||
roseLightColor = lipgloss.Color("#FFB3C6")
|
||||
accentColor = lipgloss.Color("#FF8FA3")
|
||||
warmColor = lipgloss.Color("#FF4D6D")
|
||||
successColor = lipgloss.Color("#4ADE80")
|
||||
warningColor = lipgloss.Color("#FBBF24")
|
||||
errorColor = lipgloss.Color("#FF4D4D")
|
||||
mutedColor = lipgloss.Color("#8B7E8E")
|
||||
dimColor = lipgloss.Color("#5A4F5E")
|
||||
textColor = lipgloss.Color("#F0E6E8")
|
||||
textDimColor = lipgloss.Color("#B8A9AD")
|
||||
|
||||
bgDark = lipgloss.Color("#0D0A0B")
|
||||
bgPanel = lipgloss.Color("#1A1215")
|
||||
bgCard = lipgloss.Color("#231A1D")
|
||||
bgInput = lipgloss.Color("#2A2023")
|
||||
bgHover = lipgloss.Color("#332528")
|
||||
|
||||
borderColor = lipgloss.Color("#3D2E32")
|
||||
borderAccent = lipgloss.Color("#E8364F")
|
||||
|
||||
tabActiveBg = lipgloss.Color("#E8364F")
|
||||
tabInactiveBg = lipgloss.Color("#1A1215")
|
||||
|
||||
sectionStyle = lipgloss.NewStyle().
|
||||
Foreground(roseColor).
|
||||
Bold(true)
|
||||
|
||||
sectionIconStyle = lipgloss.NewStyle().
|
||||
Foreground(primaryColor).
|
||||
Bold(true)
|
||||
|
||||
itemOKStyle = lipgloss.NewStyle().
|
||||
Foreground(successColor)
|
||||
|
||||
itemMissingStyle = lipgloss.NewStyle().
|
||||
Foreground(errorColor)
|
||||
|
||||
itemWarnStyle = lipgloss.NewStyle().
|
||||
Foreground(warningColor)
|
||||
|
||||
itemPendingStyle = lipgloss.NewStyle().
|
||||
Foreground(mutedColor)
|
||||
|
||||
userMsgStyle = lipgloss.NewStyle().
|
||||
Foreground(roseLightColor)
|
||||
|
||||
aiMsgStyle = lipgloss.NewStyle().
|
||||
Foreground(textColor)
|
||||
|
||||
errMsgStyle = lipgloss.NewStyle().
|
||||
Foreground(errorColor)
|
||||
|
||||
inputStyle = lipgloss.NewStyle().
|
||||
Foreground(roseColor)
|
||||
|
||||
stepDoneStyle = lipgloss.NewStyle().
|
||||
Foreground(successColor)
|
||||
|
||||
stepPendingStyle = lipgloss.NewStyle().
|
||||
Foreground(mutedColor)
|
||||
|
||||
stepCurrentStyle = lipgloss.NewStyle().
|
||||
Foreground(primaryColor).
|
||||
Bold(true)
|
||||
|
||||
stepErrorStyle = lipgloss.NewStyle().
|
||||
Foreground(errorColor)
|
||||
|
||||
statusBarStyle = lipgloss.NewStyle().
|
||||
Background(bgPanel).
|
||||
Foreground(textDimColor).
|
||||
Padding(0, 1)
|
||||
|
||||
confirmBoxStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(primaryColor).
|
||||
Background(bgCard).
|
||||
Foreground(textColor).
|
||||
Padding(1, 3).
|
||||
Bold(true)
|
||||
|
||||
confirmYesStyle = lipgloss.NewStyle().
|
||||
Foreground(successColor).
|
||||
Bold(true)
|
||||
|
||||
confirmNoStyle = lipgloss.NewStyle().
|
||||
Foreground(mutedColor)
|
||||
|
||||
cardStyle = lipgloss.NewStyle().
|
||||
Background(bgCard).
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(borderColor).
|
||||
Padding(0, 1)
|
||||
|
||||
sidebarStyle = lipgloss.NewStyle().
|
||||
Background(bgPanel).
|
||||
Border(lipgloss.Border{Right: "│"}).
|
||||
BorderForeground(borderColor).
|
||||
Padding(0, 1)
|
||||
|
||||
badgeStyle = lipgloss.NewStyle().
|
||||
Background(primaryColor).
|
||||
Foreground(lipgloss.Color("#FFFFFF")).
|
||||
Padding(0, 1).
|
||||
Bold(true)
|
||||
|
||||
labelStyle = lipgloss.NewStyle().
|
||||
Foreground(mutedColor).
|
||||
Width(14)
|
||||
|
||||
valueStyle = lipgloss.NewStyle().
|
||||
Foreground(textColor)
|
||||
|
||||
tabBarStyle = lipgloss.NewStyle().
|
||||
Background(bgPanel)
|
||||
|
||||
pulseFrames = []string{"⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"}
|
||||
)
|
||||
|
||||
func getAnimFrame(frame int) string {
|
||||
return pulseFrames[frame%len(pulseFrames)]
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
var dangerousPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)\brm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+|/)`),
|
||||
regexp.MustCompile(`(?i)\bmkfs\b`),
|
||||
regexp.MustCompile(`(?i)\bdd\s+if=`),
|
||||
regexp.MustCompile(`(?i)\b(format\s+[A-Za-z]:)\b`),
|
||||
regexp.MustCompile(`(?i):\(\)\{.*\}`),
|
||||
regexp.MustCompile(`(?i)>(/dev/|/etc/|/boot/)`),
|
||||
regexp.MustCompile(`(?i)\bshutdown\b`),
|
||||
regexp.MustCompile(`(?i)\breboot\b`),
|
||||
regexp.MustCompile(`(?i)\bhalt\b`),
|
||||
regexp.MustCompile(`(?i)\bpoweroff\b`),
|
||||
}
|
||||
|
||||
func isDangerousCommand(input string) bool {
|
||||
for _, pat := range dangerousPatterns {
|
||||
if pat.MatchString(input) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m Model) renderShell() string {
|
||||
if m.termAIShow {
|
||||
aiWidth := 36
|
||||
termWidth := m.width - aiWidth - 2
|
||||
if termWidth < 20 {
|
||||
termWidth = 20
|
||||
aiWidth = m.width - termWidth - 2
|
||||
}
|
||||
|
||||
termPanel := m.renderTermPanel(termWidth)
|
||||
aiPanel := m.renderAIPanel(aiWidth)
|
||||
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top, termPanel, aiPanel)
|
||||
}
|
||||
|
||||
return m.renderTermPanel(m.width)
|
||||
}
|
||||
|
||||
func (m Model) renderTermPanel(width int) string {
|
||||
var b strings.Builder
|
||||
|
||||
cwdStyle := lipgloss.NewStyle().Foreground(dimColor).Render(m.termCwd)
|
||||
b.WriteString(renderSectionWithIcon("Terminal", "▶"))
|
||||
b.WriteString(" ")
|
||||
b.WriteString(cwdStyle)
|
||||
b.WriteString("\n\n")
|
||||
|
||||
sep := lipgloss.NewStyle().Foreground(borderColor).Render(strings.Repeat("─", max(width-4, 10)))
|
||||
b.WriteString(" " + sep)
|
||||
b.WriteString("\n")
|
||||
|
||||
for _, line := range m.termLog {
|
||||
b.WriteString(line + "\n")
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m Model) renderAIPanel(width int) string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(renderSectionWithIcon("AI Assistant", "◈"))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
sep := lipgloss.NewStyle().Foreground(borderColor).Render(strings.Repeat("─", max(width-4, 10)))
|
||||
b.WriteString(" " + sep)
|
||||
b.WriteString("\n\n")
|
||||
|
||||
for _, msg := range m.termAIChat {
|
||||
b.WriteString(msg)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
if m.termAILoading {
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(warmColor).Render(" " + getAnimFrame(m.animationFrame) + " thinking..."))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
inputLabel := lipgloss.NewStyle().Foreground(roseColor).Render("⟩ ")
|
||||
b.WriteString(inputLabel)
|
||||
b.WriteString(m.termAIInput)
|
||||
|
||||
return lipgloss.NewStyle().
|
||||
Background(bgPanel).
|
||||
Border(lipgloss.Border{Left: "│"}).
|
||||
BorderForeground(borderColor).
|
||||
Width(width).
|
||||
Padding(0, 1).
|
||||
Render(b.String())
|
||||
}
|
||||
|
||||
func (m Model) handleShellKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
if m.termCmd != nil && m.termCmd.Process != nil {
|
||||
m.termCmd.Process.Kill()
|
||||
m.termRunning = false
|
||||
m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(errorColor).Render("^C"))
|
||||
m.termCmd = nil
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
return m, nil
|
||||
}
|
||||
now := time.Now()
|
||||
if m.ctrlCCount > 0 && now.Sub(m.lastCtrlC) < 2*time.Second {
|
||||
return m, tea.Quit
|
||||
}
|
||||
m.ctrlCCount++
|
||||
m.lastCtrlC = now
|
||||
m.showingQuit = true
|
||||
m.confirmCursor = 1
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
return m, nil
|
||||
case "ctrl+t":
|
||||
m.showingTabMenu = true
|
||||
m.tabMenuCursor = int(m.activeTab)
|
||||
return m, nil
|
||||
case "ctrl+a":
|
||||
m.termAIShow = !m.termAIShow
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
return m, nil
|
||||
case "enter":
|
||||
if m.termRunning {
|
||||
return m, nil
|
||||
}
|
||||
input := strings.TrimSpace(m.termInput)
|
||||
m.termInput = ""
|
||||
if input == "" {
|
||||
return m, nil
|
||||
}
|
||||
if input == "exit" || input == "quit" {
|
||||
return m, nil
|
||||
}
|
||||
if input == "clear" {
|
||||
m.termLog = nil
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
return m, nil
|
||||
}
|
||||
if isDangerousCommand(input) {
|
||||
m.termLog = append(m.termLog, errMsgStyle.Render(" blocked: potentially dangerous command"))
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
m.viewport.GotoBottom()
|
||||
return m, nil
|
||||
}
|
||||
if strings.HasPrefix(input, "cd ") {
|
||||
dir := strings.TrimPrefix(input, "cd ")
|
||||
dir = strings.TrimSpace(dir)
|
||||
if dir == "~" {
|
||||
home, _ := os.UserHomeDir()
|
||||
dir = home
|
||||
}
|
||||
if err := os.Chdir(dir); err == nil {
|
||||
m.termCwd, _ = os.Getwd()
|
||||
m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(dimColor).Render(m.termCwd+" $ "+input))
|
||||
} else {
|
||||
m.termLog = append(m.termLog, errMsgStyle.Render(" cd: "+err.Error()))
|
||||
}
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
m.viewport.GotoBottom()
|
||||
return m, nil
|
||||
}
|
||||
m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(dimColor).Render(m.termCwd+" $ ")+input)
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
m.viewport.GotoBottom()
|
||||
return m, m.runTermCommand(input)
|
||||
case "backspace":
|
||||
if len(m.termInput) > 0 {
|
||||
m.termInput = m.termInput[:len(m.termInput)-1]
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
}
|
||||
return m, nil
|
||||
default:
|
||||
if len(msg.String()) == 1 {
|
||||
m.termInput += msg.String()
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) runTermCommand(input string) tea.Cmd {
|
||||
return tea.Cmd(func() tea.Msg {
|
||||
shell := os.Getenv("SHELL")
|
||||
if shell == "" {
|
||||
shell = "/bin/sh"
|
||||
}
|
||||
cmd := exec.Command(shell, "-c", input)
|
||||
cmd.Dir = m.termCwd
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return termOutputMsg{line: string(out) + errMsgStyle.Render(err.Error())}
|
||||
}
|
||||
return termOutputMsg{line: string(out)}
|
||||
})
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/progress"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
"github.com/muyue/muyue/internal/daemon"
|
||||
"github.com/muyue/muyue/internal/installer"
|
||||
"github.com/muyue/muyue/internal/lsp"
|
||||
"github.com/muyue/muyue/internal/orchestrator"
|
||||
"github.com/muyue/muyue/internal/preview"
|
||||
"github.com/muyue/muyue/internal/proxy"
|
||||
"github.com/muyue/muyue/internal/scanner"
|
||||
"github.com/muyue/muyue/internal/skills"
|
||||
"github.com/muyue/muyue/internal/updater"
|
||||
"github.com/muyue/muyue/internal/workflow"
|
||||
)
|
||||
|
||||
type tab int
|
||||
|
||||
const (
|
||||
tabDashboard tab = iota
|
||||
tabStudio
|
||||
tabShell
|
||||
tabConfig
|
||||
tabCount
|
||||
)
|
||||
|
||||
var tabNames = []string{"Dashboard", "Studio", "Shell", "Config"}
|
||||
var tabIcons = []string{"◉", "◈", "▶", "⚙"}
|
||||
|
||||
type aiResponseMsg struct{ content string }
|
||||
type aiErrMsg struct{ err error }
|
||||
type scanCompleteMsg struct{ result *scanner.ScanResult }
|
||||
type installCompleteMsg struct{ results []installer.InstallResult }
|
||||
type installProgressMsg struct {
|
||||
tool string
|
||||
current int
|
||||
total int
|
||||
}
|
||||
type installBatchMsg struct {
|
||||
result installer.InstallResult
|
||||
tools []string
|
||||
index int
|
||||
config *config.MuyueConfig
|
||||
}
|
||||
type updateCheckMsg struct{ statuses []updater.UpdateStatus }
|
||||
type previewReadyMsg struct{ url string }
|
||||
type workflowPhaseMsg struct{ phase workflow.Phase }
|
||||
type daemonLogMsg struct{ logs []string }
|
||||
type lspScanMsg struct{ servers []lsp.LSPServer }
|
||||
type mcpConfigMsg struct{ err error }
|
||||
type skillsListMsg struct{ skills []skills.Skill }
|
||||
type spinnerTickMsg struct{ time time.Time }
|
||||
type termOutputMsg struct{ line string }
|
||||
type termExitMsg struct{}
|
||||
type animTickMsg struct{ time time.Time }
|
||||
|
||||
type studioPanel int
|
||||
|
||||
const (
|
||||
panelChat studioPanel = iota
|
||||
panelAgents
|
||||
panelWorkflows
|
||||
)
|
||||
|
||||
type configSection int
|
||||
|
||||
const (
|
||||
configProfile configSection = iota
|
||||
configProviders
|
||||
configTerminal
|
||||
configSkills
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
config *config.MuyueConfig
|
||||
scanResult *scanner.ScanResult
|
||||
activeTab tab
|
||||
width int
|
||||
height int
|
||||
viewport viewport.Model
|
||||
ready bool
|
||||
|
||||
chatInput string
|
||||
chatLog []string
|
||||
chatLoading bool
|
||||
orch *orchestrator.Orchestrator
|
||||
proxyMgr *proxy.Manager
|
||||
|
||||
updateStatus []updater.UpdateStatus
|
||||
installLog []string
|
||||
previewURL string
|
||||
previewSrv *preview.PreviewServer
|
||||
daemon *daemon.Daemon
|
||||
lspServers []lsp.LSPServer
|
||||
mcpConfigured bool
|
||||
skillList []skills.Skill
|
||||
|
||||
helpModel help.Model
|
||||
progressBar progress.Model
|
||||
spinner spinner.Model
|
||||
|
||||
showingQuit bool
|
||||
confirmCursor int
|
||||
showingTabMenu bool
|
||||
tabMenuCursor int
|
||||
|
||||
ctrlCCount int
|
||||
lastCtrlC time.Time
|
||||
|
||||
installing bool
|
||||
installCurrent int
|
||||
installTotal int
|
||||
installTool string
|
||||
|
||||
termCmd *exec.Cmd
|
||||
termInput string
|
||||
termLog []string
|
||||
termRunning bool
|
||||
termCwd string
|
||||
|
||||
studioPanel studioPanel
|
||||
studioSidebarOpen bool
|
||||
|
||||
termAIChat []string
|
||||
termAIInput string
|
||||
termAILoading bool
|
||||
termAIShow bool
|
||||
|
||||
configSection configSection
|
||||
configField int
|
||||
|
||||
animationFrame int
|
||||
}
|
||||
|
||||
type keyMap struct {
|
||||
Tab key.Binding
|
||||
Prev key.Binding
|
||||
Quit key.Binding
|
||||
TabMenu key.Binding
|
||||
Enter key.Binding
|
||||
Backspace key.Binding
|
||||
}
|
||||
|
||||
var keys = keyMap{
|
||||
Tab: key.NewBinding(
|
||||
key.WithKeys("tab"),
|
||||
key.WithHelp("tab", "next"),
|
||||
),
|
||||
Prev: key.NewBinding(
|
||||
key.WithKeys("shift+tab"),
|
||||
key.WithHelp("shift+tab", "prev"),
|
||||
),
|
||||
Quit: key.NewBinding(
|
||||
key.WithKeys("ctrl+c"),
|
||||
key.WithHelp("ctrl+c", "quit"),
|
||||
),
|
||||
TabMenu: key.NewBinding(
|
||||
key.WithKeys("ctrl+t"),
|
||||
key.WithHelp("ctrl+t", "tabs"),
|
||||
),
|
||||
Enter: key.NewBinding(
|
||||
key.WithKeys("enter"),
|
||||
key.WithHelp("enter", "send"),
|
||||
),
|
||||
Backspace: key.NewBinding(
|
||||
key.WithKeys("backspace"),
|
||||
key.WithHelp("backspace", "delete"),
|
||||
),
|
||||
}
|
||||
|
||||
func (k keyMap) ShortHelp() []key.Binding {
|
||||
return []key.Binding{k.TabMenu, k.Tab, k.Quit}
|
||||
}
|
||||
|
||||
func (k keyMap) FullHelp() [][]key.Binding {
|
||||
return [][]key.Binding{
|
||||
{k.TabMenu, k.Tab, k.Prev},
|
||||
{k.Quit},
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,10 @@ package version
|
||||
|
||||
const (
|
||||
Name = "muyue"
|
||||
Version = "0.2.1"
|
||||
Version = "0.3.0"
|
||||
Author = "La Légion de Muyue"
|
||||
License = "MIT"
|
||||
)
|
||||
|
||||
var Prerelease string
|
||||
|
||||
func FullVersion() string {
|
||||
v := Name + " v" + Version
|
||||
if Prerelease != "" {
|
||||
v += "-" + Prerelease
|
||||
}
|
||||
return v
|
||||
return Name + " v" + Version
|
||||
}
|
||||
|
||||
@@ -15,28 +15,6 @@ func TestFullVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullVersionWithPrerelease(t *testing.T) {
|
||||
original := Prerelease
|
||||
Prerelease = "beta.1"
|
||||
defer func() { Prerelease = original }()
|
||||
|
||||
v := FullVersion()
|
||||
if !strings.Contains(v, "beta.1") {
|
||||
t.Errorf("FullVersion should contain prerelease suffix, got %s", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullVersionWithoutPrerelease(t *testing.T) {
|
||||
original := Prerelease
|
||||
Prerelease = ""
|
||||
defer func() { Prerelease = original }()
|
||||
|
||||
v := FullVersion()
|
||||
if strings.Contains(v, "-") {
|
||||
t.Errorf("FullVersion should not contain prerelease suffix, got %s", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConstants(t *testing.T) {
|
||||
if Name == "" {
|
||||
t.Error("Name should not be empty")
|
||||
@@ -47,7 +25,4 @@ func TestConstants(t *testing.T) {
|
||||
if Author == "" {
|
||||
t.Error("Author should not be empty")
|
||||
}
|
||||
if License == "" {
|
||||
t.Error("License should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Phase string
|
||||
|
||||
const (
|
||||
PhaseIdle Phase = "idle"
|
||||
PhaseGathering Phase = "gathering"
|
||||
PhasePlanning Phase = "planning"
|
||||
PhaseReviewing Phase = "reviewing"
|
||||
PhaseExecuting Phase = "executing"
|
||||
PhaseDone Phase = "done"
|
||||
PhaseError Phase = "error"
|
||||
)
|
||||
|
||||
type Step struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Agent string `json:"agent"`
|
||||
Output string `json:"output,omitempty"`
|
||||
}
|
||||
|
||||
type Plan struct {
|
||||
Goal string `json:"goal"`
|
||||
Context string `json:"context"`
|
||||
Questions []string `json:"questions"`
|
||||
Answers []string `json:"answers"`
|
||||
Steps []Step `json:"steps"`
|
||||
StepIndex int `json:"current_step"`
|
||||
PreviewFiles []PreviewFile `json:"preview_files,omitempty"`
|
||||
}
|
||||
|
||||
type PreviewFile struct {
|
||||
Filename string `json:"filename"`
|
||||
Content string `json:"content"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type Workflow struct {
|
||||
Phase Phase
|
||||
Plan *Plan
|
||||
History []string
|
||||
}
|
||||
|
||||
func New() *Workflow {
|
||||
return &Workflow{
|
||||
Phase: PhaseIdle,
|
||||
Plan: &Plan{},
|
||||
History: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Workflow) Start(goal string) {
|
||||
w.Phase = PhaseGathering
|
||||
w.Plan = &Plan{
|
||||
Goal: goal,
|
||||
Steps: []Step{},
|
||||
Answers: []string{},
|
||||
}
|
||||
w.History = append(w.History, fmt.Sprintf("[started] %s", goal))
|
||||
}
|
||||
|
||||
func (w *Workflow) AddAnswer(answer string) {
|
||||
w.Plan.Answers = append(w.Plan.Answers, answer)
|
||||
if len(w.Plan.Answers) >= len(w.Plan.Questions) {
|
||||
w.Phase = PhasePlanning
|
||||
w.History = append(w.History, "[gathering complete, moving to planning]")
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Workflow) SetPlan(planJSON string) error {
|
||||
var steps []Step
|
||||
if err := json.Unmarshal([]byte(planJSON), &steps); err != nil {
|
||||
if err2 := json.Unmarshal([]byte("["+planJSON+"]"), &steps); err2 != nil {
|
||||
return fmt.Errorf("parse plan: %w", err)
|
||||
}
|
||||
}
|
||||
w.Plan.Steps = steps
|
||||
w.Phase = PhaseReviewing
|
||||
w.History = append(w.History, fmt.Sprintf("[plan created] %d steps", len(steps)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Workflow) SetPreviewFiles(files []PreviewFile) {
|
||||
w.Plan.PreviewFiles = files
|
||||
}
|
||||
|
||||
func (w *Workflow) Approve() {
|
||||
w.Phase = PhaseExecuting
|
||||
w.Plan.StepIndex = 0
|
||||
w.History = append(w.History, "[plan approved, starting execution]")
|
||||
}
|
||||
|
||||
func (w *Workflow) Reject(feedback string) {
|
||||
w.Phase = PhasePlanning
|
||||
w.History = append(w.History, fmt.Sprintf("[plan rejected: %s]", feedback))
|
||||
}
|
||||
|
||||
func (w *Workflow) AdvanceStep(output string) {
|
||||
if w.Plan.StepIndex < len(w.Plan.Steps) {
|
||||
w.Plan.Steps[w.Plan.StepIndex].Status = "done"
|
||||
w.Plan.Steps[w.Plan.StepIndex].Output = output
|
||||
w.Plan.StepIndex++
|
||||
w.History = append(w.History, fmt.Sprintf("[step %d done]", w.Plan.StepIndex))
|
||||
|
||||
if w.Plan.StepIndex >= len(w.Plan.Steps) {
|
||||
w.Phase = PhaseDone
|
||||
w.History = append(w.History, "[all steps complete]")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Workflow) FailStep(errMsg string) {
|
||||
if w.Plan.StepIndex < len(w.Plan.Steps) {
|
||||
w.Plan.Steps[w.Plan.StepIndex].Status = "error"
|
||||
w.Plan.Steps[w.Plan.StepIndex].Output = errMsg
|
||||
w.Phase = PhaseError
|
||||
w.History = append(w.History, fmt.Sprintf("[step %d failed: %s]", w.Plan.StepIndex+1, errMsg))
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Workflow) Reset() {
|
||||
w.Phase = PhaseIdle
|
||||
w.Plan = &Plan{}
|
||||
}
|
||||
|
||||
func (w *Workflow) CurrentStep() *Step {
|
||||
if w.Plan.StepIndex < len(w.Plan.Steps) {
|
||||
return &w.Plan.Steps[w.Plan.StepIndex]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Workflow) Progress() (done, total int) {
|
||||
for _, s := range w.Plan.Steps {
|
||||
if s.Status == "done" {
|
||||
done++
|
||||
}
|
||||
total++
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func BuildSystemPrompt(phase Phase, plan *Plan) string {
|
||||
base := `You are muyue, an AI-powered development environment assistant.
|
||||
You follow a structured workflow: GATHER requirements → PLAN → REVIEW → EXECUTE.
|
||||
|
||||
RULES:
|
||||
- Always respond in the same language the user writes in.
|
||||
- When in GATHERING phase, ask clarifying questions ONE AT A TIME to understand the requirement fully.
|
||||
- When in PLANNING phase, create a detailed step-by-step plan as a JSON array of objects.
|
||||
- When in REVIEWING phase, present the plan clearly and wait for approval.
|
||||
- When in EXECUTING phase, execute one step at a time and report results.
|
||||
- If the user wants a visual preview, generate 1-2 HTML files wrapped in a PREVIEW_JSON block.`
|
||||
|
||||
switch phase {
|
||||
case PhaseGathering:
|
||||
base += fmt.Sprintf(`
|
||||
|
||||
CURRENT PHASE: GATHERING
|
||||
Goal: %s
|
||||
Questions to ask: %v
|
||||
Answers received: %v
|
||||
Remaining questions: %d
|
||||
Ask the NEXT question that hasn't been answered yet. If all questions are answered, say "GATHERING_COMPLETE".`,
|
||||
plan.Goal, plan.Questions, plan.Answers,
|
||||
len(plan.Questions)-len(plan.Answers))
|
||||
|
||||
case PhasePlanning:
|
||||
qa := ""
|
||||
for i, q := range plan.Questions {
|
||||
a := ""
|
||||
if i < len(plan.Answers) {
|
||||
a = plan.Answers[i]
|
||||
}
|
||||
qa += fmt.Sprintf("\nQ: %s\nA: %s", q, a)
|
||||
}
|
||||
base += fmt.Sprintf(`
|
||||
|
||||
CURRENT PHASE: PLANNING
|
||||
Goal: %s
|
||||
%s
|
||||
|
||||
Create a step-by-step plan. Output ONLY a JSON array of steps:
|
||||
[
|
||||
{"id": "1", "title": "...", "description": "...", "agent": "crush|claude|muyue", "status": "pending"},
|
||||
...
|
||||
]
|
||||
|
||||
If the user needs a visual preview, wrap HTML in:
|
||||
<<<PREVIEW_JSON>>>
|
||||
[{"filename":"preview.html","content":"<html>...</html>","type":"html"}]
|
||||
<<<END_PREVIEW>>>`,
|
||||
plan.Goal, qa)
|
||||
|
||||
case PhaseReviewing:
|
||||
steps, _ := json.MarshalIndent(plan.Steps, "", " ")
|
||||
base += fmt.Sprintf(`
|
||||
|
||||
CURRENT PHASE: REVIEWING
|
||||
Present the plan below clearly and ask for approval:
|
||||
%s
|
||||
|
||||
Say "PLAN_APPROVED" if the user approves, or "PLAN_REJECTED: <reason>" if not.`,
|
||||
string(steps))
|
||||
|
||||
case PhaseExecuting:
|
||||
if plan.StepIndex < len(plan.Steps) {
|
||||
step := plan.Steps[plan.StepIndex]
|
||||
base += fmt.Sprintf(`
|
||||
|
||||
CURRENT PHASE: EXECUTING
|
||||
Current step: %s — %s (agent: %s)
|
||||
Execute this step and report the result.`,
|
||||
step.Title, step.Description, step.Agent)
|
||||
}
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
|
||||
func ParsePlanResponse(response string) ([]Step, error) {
|
||||
response = strings.TrimSpace(response)
|
||||
|
||||
start := strings.Index(response, "[")
|
||||
end := strings.LastIndex(response, "]")
|
||||
if start == -1 || end == -1 || end <= start {
|
||||
return nil, fmt.Errorf("no JSON array found in response")
|
||||
}
|
||||
|
||||
jsonStr := response[start : end+1]
|
||||
var steps []Step
|
||||
if err := json.Unmarshal([]byte(jsonStr), &steps); err != nil {
|
||||
return nil, fmt.Errorf("parse steps: %w", err)
|
||||
}
|
||||
|
||||
for i := range steps {
|
||||
steps[i].Status = "pending"
|
||||
}
|
||||
|
||||
return steps, nil
|
||||
}
|
||||
|
||||
func ParsePreviewFiles(response string) []PreviewFile {
|
||||
startMarker := "<<<PREVIEW_JSON>>>"
|
||||
endMarker := "<<<END_PREVIEW>>>"
|
||||
start := strings.Index(response, startMarker)
|
||||
end := strings.Index(response, endMarker)
|
||||
if start == -1 || end == -1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
jsonStr := strings.TrimSpace(response[start+len(startMarker) : end])
|
||||
var files []PreviewFile
|
||||
if err := json.Unmarshal([]byte(jsonStr), &files); err != nil {
|
||||
return nil
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func ParseApproval(response string) (approved bool, feedback string) {
|
||||
lower := strings.ToLower(strings.TrimSpace(response))
|
||||
if strings.Contains(lower, "plan_approved") || strings.Contains(lower, "approved") || strings.Contains(lower, "yes") || strings.Contains(lower, "go ahead") || strings.Contains(lower, "oui") || strings.Contains(lower, "ok") {
|
||||
return true, ""
|
||||
}
|
||||
if strings.Contains(lower, "plan_rejected:") {
|
||||
parts := strings.SplitN(lower, "plan_rejected:", 2)
|
||||
if len(parts) > 1 {
|
||||
return false, strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
return false, response
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
wf := New()
|
||||
if wf.Phase != PhaseIdle {
|
||||
t.Errorf("Expected PhaseIdle, got %s", wf.Phase)
|
||||
}
|
||||
if wf.Plan == nil {
|
||||
t.Error("Plan should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStart(t *testing.T) {
|
||||
wf := New()
|
||||
wf.Start("Build a REST API")
|
||||
if wf.Phase != PhaseGathering {
|
||||
t.Errorf("Expected PhaseGathering, got %s", wf.Phase)
|
||||
}
|
||||
if wf.Plan.Goal != "Build a REST API" {
|
||||
t.Errorf("Expected goal 'Build a REST API', got %s", wf.Plan.Goal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddAnswer(t *testing.T) {
|
||||
wf := New()
|
||||
wf.Start("test goal")
|
||||
wf.Plan.Questions = []string{"Q1?", "Q2?"}
|
||||
|
||||
wf.AddAnswer("A1")
|
||||
if wf.Phase != PhaseGathering {
|
||||
t.Errorf("Should still be gathering, got %s", wf.Phase)
|
||||
}
|
||||
|
||||
wf.AddAnswer("A2")
|
||||
if wf.Phase != PhasePlanning {
|
||||
t.Errorf("Should move to planning, got %s", wf.Phase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPlan(t *testing.T) {
|
||||
wf := New()
|
||||
planJSON := `[{"id":"1","title":"Step 1","description":"Do something","agent":"crush","status":"pending"}]`
|
||||
err := wf.SetPlan(planJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("SetPlan failed: %v", err)
|
||||
}
|
||||
if len(wf.Plan.Steps) != 1 {
|
||||
t.Errorf("Expected 1 step, got %d", len(wf.Plan.Steps))
|
||||
}
|
||||
if wf.Phase != PhaseReviewing {
|
||||
t.Errorf("Expected PhaseReviewing, got %s", wf.Phase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprove(t *testing.T) {
|
||||
wf := New()
|
||||
wf.Start("test")
|
||||
wf.Plan.Steps = []Step{{ID: "1", Title: "Step 1", Status: "pending"}}
|
||||
wf.Phase = PhaseReviewing
|
||||
wf.Approve()
|
||||
if wf.Phase != PhaseExecuting {
|
||||
t.Errorf("Expected PhaseExecuting, got %s", wf.Phase)
|
||||
}
|
||||
if wf.Plan.StepIndex != 0 {
|
||||
t.Errorf("Expected step index 0, got %d", wf.Plan.StepIndex)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReject(t *testing.T) {
|
||||
wf := New()
|
||||
wf.Phase = PhaseReviewing
|
||||
wf.Reject("too complex")
|
||||
if wf.Phase != PhasePlanning {
|
||||
t.Errorf("Expected PhasePlanning, got %s", wf.Phase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvanceStep(t *testing.T) {
|
||||
wf := New()
|
||||
wf.Plan.Steps = []Step{
|
||||
{ID: "1", Title: "Step 1", Status: "pending"},
|
||||
{ID: "2", Title: "Step 2", Status: "pending"},
|
||||
}
|
||||
wf.Phase = PhaseExecuting
|
||||
|
||||
wf.AdvanceStep("output1")
|
||||
if wf.Plan.Steps[0].Status != "done" {
|
||||
t.Error("First step should be done")
|
||||
}
|
||||
if wf.Plan.StepIndex != 1 {
|
||||
t.Errorf("Expected step index 1, got %d", wf.Plan.StepIndex)
|
||||
}
|
||||
if wf.Phase != PhaseExecuting {
|
||||
t.Errorf("Should still be executing, got %s", wf.Phase)
|
||||
}
|
||||
|
||||
wf.AdvanceStep("output2")
|
||||
if wf.Phase != PhaseDone {
|
||||
t.Errorf("Expected PhaseDone, got %s", wf.Phase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailStep(t *testing.T) {
|
||||
wf := New()
|
||||
wf.Plan.Steps = []Step{{ID: "1", Title: "Step 1"}}
|
||||
wf.Phase = PhaseExecuting
|
||||
|
||||
wf.FailStep("something broke")
|
||||
if wf.Phase != PhaseError {
|
||||
t.Errorf("Expected PhaseError, got %s", wf.Phase)
|
||||
}
|
||||
if wf.Plan.Steps[0].Status != "error" {
|
||||
t.Error("Step should have error status")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReset(t *testing.T) {
|
||||
wf := New()
|
||||
wf.Start("test")
|
||||
wf.Phase = PhaseExecuting
|
||||
wf.Reset()
|
||||
if wf.Phase != PhaseIdle {
|
||||
t.Errorf("Expected PhaseIdle, got %s", wf.Phase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentStep(t *testing.T) {
|
||||
wf := New()
|
||||
if wf.CurrentStep() != nil {
|
||||
t.Error("Should be nil with no steps")
|
||||
}
|
||||
|
||||
wf.Plan.Steps = []Step{{ID: "1"}, {ID: "2"}}
|
||||
wf.Plan.StepIndex = 0
|
||||
step := wf.CurrentStep()
|
||||
if step == nil || step.ID != "1" {
|
||||
t.Error("Should return first step")
|
||||
}
|
||||
|
||||
wf.Plan.StepIndex = 2
|
||||
if wf.CurrentStep() != nil {
|
||||
t.Error("Should be nil when past all steps")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgress(t *testing.T) {
|
||||
wf := New()
|
||||
wf.Plan.Steps = []Step{
|
||||
{ID: "1", Status: "done"},
|
||||
{ID: "2", Status: "pending"},
|
||||
{ID: "3", Status: "done"},
|
||||
}
|
||||
done, total := wf.Progress()
|
||||
if done != 2 || total != 3 {
|
||||
t.Errorf("Expected 2/3, got %d/%d", done, total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePlanResponse(t *testing.T) {
|
||||
resp := `Here is the plan:
|
||||
[
|
||||
{"id": "1", "title": "Setup", "description": "Init project", "agent": "crush"},
|
||||
{"id": "2", "title": "Build", "description": "Write code", "agent": "claude"}
|
||||
]`
|
||||
steps, err := ParsePlanResponse(resp)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePlanResponse failed: %v", err)
|
||||
}
|
||||
if len(steps) != 2 {
|
||||
t.Errorf("Expected 2 steps, got %d", len(steps))
|
||||
}
|
||||
if steps[0].ID != "1" {
|
||||
t.Errorf("Expected step ID 1, got %s", steps[0].ID)
|
||||
}
|
||||
for _, s := range steps {
|
||||
if s.Status != "pending" {
|
||||
t.Errorf("Steps should be pending, got %s", s.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePlanResponseInvalid(t *testing.T) {
|
||||
_, err := ParsePlanResponse("no json here")
|
||||
if err == nil {
|
||||
t.Error("Should fail with no JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseApproval(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
approved bool
|
||||
}{
|
||||
{"plan_approved", true},
|
||||
{"approved", true},
|
||||
{"yes", true},
|
||||
{"ok", true},
|
||||
{"oui", true},
|
||||
{"go ahead", true},
|
||||
{"no", false},
|
||||
{"plan_rejected: too complex", false},
|
||||
{"I don't like it", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
approved, feedback := ParseApproval(tt.input)
|
||||
if approved != tt.approved {
|
||||
t.Errorf("ParseApproval(%q) = %v, want %v", tt.input, approved, tt.approved)
|
||||
}
|
||||
if !approved && tt.input == "plan_rejected: too complex" {
|
||||
if feedback != "too complex" {
|
||||
t.Errorf("Expected feedback 'too complex', got %s", feedback)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePreviewFiles(t *testing.T) {
|
||||
resp := `Some text
|
||||
<<<PREVIEW_JSON>>>
|
||||
[{"filename":"test.html","content":"<h1>Hello</h1>","type":"html"}]
|
||||
<<<END_PREVIEW>>>`
|
||||
files := ParsePreviewFiles(resp)
|
||||
if len(files) != 1 {
|
||||
t.Fatalf("Expected 1 file, got %d", len(files))
|
||||
}
|
||||
if files[0].Filename != "test.html" {
|
||||
t.Errorf("Expected test.html, got %s", files[0].Filename)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePreviewFilesNone(t *testing.T) {
|
||||
files := ParsePreviewFiles("no preview here")
|
||||
if files != nil {
|
||||
t.Error("Should return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPrompt(t *testing.T) {
|
||||
prompt := BuildSystemPrompt(PhaseIdle, &Plan{})
|
||||
if prompt == "" {
|
||||
t.Error("Prompt should not be empty")
|
||||
}
|
||||
if len(prompt) < 100 {
|
||||
t.Error("Prompt seems too short")
|
||||
}
|
||||
|
||||
prompt = BuildSystemPrompt(PhaseGathering, &Plan{Goal: "test"})
|
||||
if prompt == "" {
|
||||
t.Error("Gathering prompt should not be empty")
|
||||
}
|
||||
}
|
||||
4
web/.gitignore
vendored
Normal file
4
web/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
!dist/.gitkeep
|
||||
.vite/
|
||||
6
web/embed.go
Normal file
6
web/embed.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed all:dist
|
||||
var Assets embed.FS
|
||||
14
web/index.html
Normal file
14
web/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0A0A0C" />
|
||||
<title>muyue</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⬡</text></svg>" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
999
web/package-lock.json
generated
Normal file
999
web/package-lock.json
generated
Normal file
@@ -0,0 +1,999 @@
|
||||
{
|
||||
"name": "muyue-web",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "muyue-web",
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"vite": "^8.0.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
|
||||
"integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.1",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
|
||||
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.126.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.126.0.tgz",
|
||||
"integrity": "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "1.9.2",
|
||||
"@emnapi/runtime": "1.9.2",
|
||||
"@napi-rs/wasm-runtime": "^1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.7",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz",
|
||||
"integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
|
||||
"integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rolldown/pluginutils": "1.0.0-rc.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@rolldown/plugin-babel": {
|
||||
"optional": true
|
||||
},
|
||||
"babel-plugin-react-compiler": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-web-links": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
|
||||
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"picomatch": "^3 || ^4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"picomatch": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.32.0",
|
||||
"lightningcss-darwin-arm64": "1.32.0",
|
||||
"lightningcss-darwin-x64": "1.32.0",
|
||||
"lightningcss-freebsd-x64": "1.32.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.32.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.32.0",
|
||||
"lightningcss-linux-arm64-musl": "1.32.0",
|
||||
"lightningcss-linux-x64-gnu": "1.32.0",
|
||||
"lightningcss-linux-x64-musl": "1.32.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.32.0",
|
||||
"lightningcss-win32-x64-msvc": "1.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
|
||||
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz",
|
||||
"integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.5",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
|
||||
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.5",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
|
||||
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.5"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.126.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.16"
|
||||
},
|
||||
"bin": {
|
||||
"rolldown": "bin/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.0.0-rc.16",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.16",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.16",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.16",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.16",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.16",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.16",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.16",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.9",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz",
|
||||
"integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.10",
|
||||
"rolldown": "1.0.0-rc.16",
|
||||
"tinyglobby": "^0.2.16"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/vitejs/vite?sponsor=1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.1.0",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
"sass": "^1.70.0",
|
||||
"sass-embedded": "^1.70.0",
|
||||
"stylus": ">=0.54.8",
|
||||
"sugarss": "^5.0.0",
|
||||
"terser": "^5.16.0",
|
||||
"tsx": "^4.8.1",
|
||||
"yaml": "^2.4.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitejs/devtools": {
|
||||
"optional": true
|
||||
},
|
||||
"esbuild": {
|
||||
"optional": true
|
||||
},
|
||||
"jiti": {
|
||||
"optional": true
|
||||
},
|
||||
"less": {
|
||||
"optional": true
|
||||
},
|
||||
"sass": {
|
||||
"optional": true
|
||||
},
|
||||
"sass-embedded": {
|
||||
"optional": true
|
||||
},
|
||||
"stylus": {
|
||||
"optional": true
|
||||
},
|
||||
"sugarss": {
|
||||
"optional": true
|
||||
},
|
||||
"terser": {
|
||||
"optional": true
|
||||
},
|
||||
"tsx": {
|
||||
"optional": true
|
||||
},
|
||||
"yaml": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
web/package.json
Normal file
22
web/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "muyue-web",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"vite": "^8.0.9"
|
||||
}
|
||||
}
|
||||
74
web/src/api/client.js
Normal file
74
web/src/api/client.js
Normal file
@@ -0,0 +1,74 @@
|
||||
const API_BASE = '/api'
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||
throw new Error(err.error || res.statusText)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
const api = {
|
||||
getInfo: () => request('/info'),
|
||||
getSystem: () => request('/system'),
|
||||
getTools: () => request('/tools'),
|
||||
getConfig: () => request('/config'),
|
||||
getProviders: () => request('/providers'),
|
||||
getSkills: () => request('/skills'),
|
||||
getLSP: () => request('/lsp'),
|
||||
getMCP: () => request('/mcp'),
|
||||
getUpdates: () => request('/updates'),
|
||||
runScan: () => request('/scan', { method: 'POST' }),
|
||||
installTools: (tools) => request('/install', { method: 'POST', body: JSON.stringify({ tools }) }),
|
||||
configureMCP: () => request('/mcp/configure', { method: 'POST' }),
|
||||
savePreferences: (prefs) => request('/preferences', { method: 'PUT', body: JSON.stringify(prefs) }),
|
||||
saveProfile: (profile) => request('/config/profile', { method: 'PUT', body: JSON.stringify(profile) }),
|
||||
saveProvider: (provider) => request('/config/provider', { method: 'PUT', body: JSON.stringify(provider) }),
|
||||
runUpdate: (tool) => request('/update/run', { method: 'POST', body: JSON.stringify({ tool: tool || '' }) }),
|
||||
runCommand: (command, cwd) => request('/terminal', { method: 'POST', body: JSON.stringify({ command, cwd }) }),
|
||||
getTerminalSessions: () => request('/terminal/sessions'),
|
||||
addSSHConnection: (conn) => request('/terminal/sessions', { method: 'POST', body: JSON.stringify(conn) }),
|
||||
deleteSSHConnection: (name) => request(`/terminal/sessions/${encodeURIComponent(name)}`, { method: 'DELETE' }),
|
||||
sendChat: (message, stream = true) => {
|
||||
if (!stream) {
|
||||
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) })
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
fetch(`${API_BASE}/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message, stream: true }),
|
||||
}).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
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
resolve(full)
|
||||
}).catch(reject)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default api
|
||||
156
web/src/components/App.jsx
Normal file
156
web/src/components/App.jsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { LayoutDashboard, Sparkles, Terminal, Settings } from 'lucide-react'
|
||||
import api from '../api/client'
|
||||
import { getTheme, applyTheme } from '../themes'
|
||||
import { useI18n } from '../i18n'
|
||||
import Dashboard from './Dashboard'
|
||||
import Studio from './Studio'
|
||||
import Shell from './Shell'
|
||||
import Config from './Config'
|
||||
|
||||
export default function App() {
|
||||
const [activeTab, setActiveTab] = useState('dash')
|
||||
const [info, setInfo] = useState({})
|
||||
const [clock, setClock] = useState(new Date())
|
||||
const [updates, setUpdates] = useState([])
|
||||
const [tools, setTools] = useState([])
|
||||
const { t, layout } = useI18n()
|
||||
|
||||
const TABS = useMemo(() => [
|
||||
{ id: 'dash', label: t('tabs.dashboard'), icon: <LayoutDashboard size={15} /> },
|
||||
{ id: 'studio', label: t('tabs.studio'), icon: <Sparkles size={15} /> },
|
||||
{ id: 'shell', label: t('tabs.shell'), icon: <Terminal size={15} /> },
|
||||
{ id: 'config', label: t('tabs.config'), icon: <Settings size={15} /> },
|
||||
], [t])
|
||||
|
||||
useEffect(() => {
|
||||
api.getInfo().then(setInfo).catch(() => {})
|
||||
api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
|
||||
api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
|
||||
applyTheme(getTheme('cyberpunk-red'))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setClock(new Date()), 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
|
||||
if (!e.ctrlKey && !e.metaKey) return
|
||||
const map = {
|
||||
Digit1: 'dash',
|
||||
Digit2: 'studio',
|
||||
Digit3: 'shell',
|
||||
Digit4: 'config',
|
||||
}
|
||||
if (map[e.code]) {
|
||||
e.preventDefault()
|
||||
setActiveTab(map[e.code])
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [])
|
||||
|
||||
const switchTab = useCallback((tabId) => setActiveTab(tabId), [])
|
||||
|
||||
const hasUpdates = updates.some(u => u.needsUpdate)
|
||||
const installed = tools.filter(t => t.installed).length
|
||||
|
||||
const WINDOW_SHORTCUTS = useMemo(() => ({
|
||||
dash: [
|
||||
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
|
||||
],
|
||||
studio: [
|
||||
{ keys: layout.keys.enter, desc: t('statusbar.sendMessage') },
|
||||
{ keys: `${layout.keys.shift}+${layout.keys.enter}`, desc: t('statusbar.newLine') },
|
||||
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
|
||||
],
|
||||
shell: [
|
||||
{ keys: layout.keys.enter, desc: t('statusbar.runCommand') },
|
||||
{ keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') },
|
||||
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
|
||||
],
|
||||
config: [
|
||||
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
|
||||
],
|
||||
}), [layout, t])
|
||||
|
||||
const renderContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'dash': return <Dashboard tools={tools} updates={updates} api={api} onRescan={t => setTools(t)} />
|
||||
case 'studio': return <Studio api={api} />
|
||||
case 'shell': return <Shell api={api} />
|
||||
case 'config': return <Config api={api} onThemeChange={() => {}} />
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-layout">
|
||||
<header className="header">
|
||||
<div className="header-brand">
|
||||
<span className="header-logo">MUYUE</span>
|
||||
<span className="header-version">v{info.version || '...'}</span>
|
||||
</div>
|
||||
|
||||
<nav className="header-nav">
|
||||
{TABS.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`nav-tab ${activeTab === tab.id ? 'active' : ''}`}
|
||||
onClick={() => switchTab(tab.id)}
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab.id}
|
||||
>
|
||||
<span className="tab-icon">{tab.icon}</span>
|
||||
{tab.label}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="header-spacer" />
|
||||
|
||||
<div className="header-indicators">
|
||||
<span
|
||||
className={`indicator ${installed > 0 ? 'ok' : 'off'}`}
|
||||
title={t('header.toolsInstalled', { count: installed })}
|
||||
/>
|
||||
<span
|
||||
className={`indicator ${hasUpdates ? 'warn' : 'ok'}`}
|
||||
title={hasUpdates ? t('header.updatesAvailable') : t('header.upToDate')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="header-clock">
|
||||
{clock.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<main className="content fade-in" key={`${activeTab}-${TABS.length}`}>
|
||||
{renderContent()}
|
||||
</main>
|
||||
|
||||
<footer className="statusbar">
|
||||
<div className="statusbar-left">
|
||||
<FooterShortcuts shortcuts={WINDOW_SHORTCUTS[activeTab] || []} />
|
||||
</div>
|
||||
<div className="statusbar-right">
|
||||
<span style={{ fontFamily: 'var(--font-mono)' }}>
|
||||
{layout.keys.ctrl}+{layout.keys.range} {t('statusbar.switchWindow')}
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FooterShortcuts({ shortcuts }) {
|
||||
return shortcuts.map((s, i) => (
|
||||
<span key={i} className="statusbar-shortcut">
|
||||
<kbd>{s.keys}</kbd> {s.desc}
|
||||
</span>
|
||||
))
|
||||
}
|
||||
318
web/src/components/Config.jsx
Normal file
318
web/src/components/Config.jsx
Normal file
@@ -0,0 +1,318 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useI18n, LANGUAGES } from '../i18n'
|
||||
import { getLayoutList } from '../i18n/keyboards'
|
||||
|
||||
export default function Config({ api }) {
|
||||
const { t, language, keyboard, setLanguage, setKeyboard } = useI18n()
|
||||
const [config, setConfig] = useState(null)
|
||||
const [providers, setProviders] = useState([])
|
||||
const [skillList, setSkillList] = useState([])
|
||||
const [updates, setUpdates] = useState([])
|
||||
const [tools, setTools] = useState([])
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [updating, setUpdating] = useState(null)
|
||||
const [editProfile, setEditProfile] = useState(false)
|
||||
const [editProvider, setEditProvider] = useState(null)
|
||||
const [profileForm, setProfileForm] = useState({})
|
||||
const [providerForm, setProviderForm] = useState({})
|
||||
const [toast, setToast] = useState(null)
|
||||
|
||||
const layouts = getLayoutList()
|
||||
|
||||
const loadData = useCallback(() => {
|
||||
api.getConfig().then(d => {
|
||||
setConfig(d)
|
||||
setProfileForm({
|
||||
name: d.profile?.name || '',
|
||||
pseudo: d.profile?.pseudo || '',
|
||||
email: d.profile?.email || '',
|
||||
editor: d.profile?.preferences?.editor || '',
|
||||
shell: d.profile?.preferences?.shell || '',
|
||||
})
|
||||
}).catch(() => {})
|
||||
api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {})
|
||||
api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {})
|
||||
api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
|
||||
api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
|
||||
}, [api])
|
||||
|
||||
useEffect(() => { loadData() }, [loadData])
|
||||
|
||||
const showToast = (msg) => {
|
||||
setToast(msg)
|
||||
setTimeout(() => setToast(null), 2500)
|
||||
}
|
||||
|
||||
const handleCheckUpdates = async () => {
|
||||
setChecking(true)
|
||||
try {
|
||||
await api.runScan()
|
||||
const d = await api.getUpdates()
|
||||
setUpdates(d.updates || [])
|
||||
const td = await api.getTools()
|
||||
setTools(td.tools || [])
|
||||
showToast(t('config.upToDate'))
|
||||
} catch (err) {
|
||||
showToast(`${t('config.error')}: ${err.message}`)
|
||||
}
|
||||
setChecking(false)
|
||||
}
|
||||
|
||||
const handleUpdateTool = async (tool) => {
|
||||
setUpdating(tool)
|
||||
try {
|
||||
await api.runUpdate(tool)
|
||||
await handleCheckUpdates()
|
||||
showToast(`${tool} ✓`)
|
||||
} catch (err) {
|
||||
showToast(`${t('config.error')}: ${err.message}`)
|
||||
}
|
||||
setUpdating(null)
|
||||
}
|
||||
|
||||
const handleUpdateAll = async () => {
|
||||
setUpdating('__all__')
|
||||
try {
|
||||
await api.runUpdate('')
|
||||
await handleCheckUpdates()
|
||||
showToast(t('config.saved'))
|
||||
} catch (err) {
|
||||
showToast(`${t('config.error')}: ${err.message}`)
|
||||
}
|
||||
setUpdating(null)
|
||||
}
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
try {
|
||||
await api.saveProfile(profileForm)
|
||||
setEditProfile(false)
|
||||
loadData()
|
||||
showToast(t('config.saved'))
|
||||
} catch (err) {
|
||||
showToast(`${t('config.error')}: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveProvider = async () => {
|
||||
try {
|
||||
await api.saveProvider(providerForm)
|
||||
setEditProvider(null)
|
||||
loadData()
|
||||
showToast(t('config.saved'))
|
||||
} catch (err) {
|
||||
showToast(`${t('config.error')}: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const openProviderEdit = (p) => {
|
||||
setProviderForm({
|
||||
name: p.name,
|
||||
api_key: p.apiKey || '',
|
||||
model: p.model || '',
|
||||
base_url: p.baseURL || '',
|
||||
})
|
||||
setEditProvider(p.name)
|
||||
}
|
||||
|
||||
const needsUpdateCount = updates.filter(u => u.needsUpdate).length
|
||||
const installedCount = tools.filter(t => t.installed).length
|
||||
const missingCount = tools.filter(t => !t.installed).length
|
||||
|
||||
return (
|
||||
<div className="config-layout">
|
||||
{toast && <div className="config-toast">{toast}</div>}
|
||||
|
||||
<div className="config-section">
|
||||
<div className="config-section-title">{t('config.systemUpdates')}</div>
|
||||
<div className="config-actions-row">
|
||||
<button className="sm" onClick={handleCheckUpdates} disabled={checking}>
|
||||
{checking ? t('config.checking') : t('config.checkUpdates')}
|
||||
</button>
|
||||
{needsUpdateCount > 0 && (
|
||||
<button className="sm primary" onClick={handleUpdateAll} disabled={updating === '__all__'}>
|
||||
{updating === '__all__' ? t('config.updating') : `${t('config.updateAll')} (${needsUpdateCount})`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="config-stats">
|
||||
<span className="badge ok">{installedCount} {t('config.installed')}</span>
|
||||
{missingCount > 0 && <span className="badge error">{missingCount} {t('config.missing')}</span>}
|
||||
{needsUpdateCount > 0 && <span className="badge warn">{needsUpdateCount} {t('config.needsUpdate')}</span>}
|
||||
</div>
|
||||
|
||||
{updates.length === 0 ? (
|
||||
<div className="empty-state">{t('config.noUpdates')}</div>
|
||||
) : (
|
||||
<div className="config-update-list">
|
||||
{updates.map((u, i) => (
|
||||
<div key={i} className="config-update-row">
|
||||
<div className="config-update-info">
|
||||
<span className="config-update-name">{u.tool}</span>
|
||||
<span className="config-update-versions">
|
||||
{u.needsUpdate ? (
|
||||
<>{u.current} → <span style={{ color: 'var(--warning)' }}>{u.latest}</span></>
|
||||
) : (
|
||||
<span style={{ color: 'var(--success)' }}>{u.current}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{u.needsUpdate && (
|
||||
<button
|
||||
className="sm"
|
||||
onClick={() => handleUpdateTool(u.tool)}
|
||||
disabled={updating === u.tool}
|
||||
>
|
||||
{updating === u.tool ? t('config.updating') : t('config.updateTool')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="config-section">
|
||||
<div className="config-section-title">
|
||||
{t('config.profile')}
|
||||
<button className="ghost sm" onClick={() => setEditProfile(!editProfile)}>
|
||||
{editProfile ? t('config.cancel') : t('config.editProfile')}
|
||||
</button>
|
||||
</div>
|
||||
{config?.profile && !editProfile ? (
|
||||
<div>
|
||||
<FieldRow label={t('config.name')} value={config.profile.name} />
|
||||
<FieldRow label={t('config.pseudo')} value={config.profile.pseudo} />
|
||||
<FieldRow label={t('config.email')} value={config.profile.email} />
|
||||
<FieldRow label={t('config.editor')} value={config.profile.preferences?.editor} />
|
||||
<FieldRow label={t('config.shell')} value={config.profile.preferences?.shell} />
|
||||
<FieldRow label={t('config.languages')} value={config.profile.languages?.join(', ')} />
|
||||
</div>
|
||||
) : editProfile ? (
|
||||
<div className="config-form">
|
||||
<FormInput label={t('config.name')} value={profileForm.name} onChange={v => setProfileForm(p => ({ ...p, name: v }))} />
|
||||
<FormInput label={t('config.pseudo')} value={profileForm.pseudo} onChange={v => setProfileForm(p => ({ ...p, pseudo: v }))} />
|
||||
<FormInput label={t('config.email')} value={profileForm.email} onChange={v => setProfileForm(p => ({ ...p, email: v }))} />
|
||||
<FormInput label={t('config.editor')} value={profileForm.editor} onChange={v => setProfileForm(p => ({ ...p, editor: v }))} />
|
||||
<FormInput label={t('config.shell')} value={profileForm.shell} onChange={v => setProfileForm(p => ({ ...p, shell: v }))} />
|
||||
<div className="config-form-actions">
|
||||
<button className="primary sm" onClick={handleSaveProfile}>{t('config.save')}</button>
|
||||
<button className="ghost sm" onClick={() => setEditProfile(false)}>{t('config.cancel')}</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state">{t('config.loadingProfile')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="config-section">
|
||||
<div className="config-section-title">{t('config.aiProviders')}</div>
|
||||
{providers.map((p, i) => (
|
||||
<div key={i} className="provider-card">
|
||||
<div className="provider-info">
|
||||
<div className="provider-name">
|
||||
{p.name}
|
||||
{p.active && <span className="badge accent" style={{ marginLeft: 8 }}>{t('config.active')}</span>}
|
||||
</div>
|
||||
{editProvider !== p.name ? (
|
||||
<div className="provider-meta">
|
||||
<span>{p.model}</span>
|
||||
<span style={{ color: p.apiKey ? 'var(--success)' : 'var(--error)' }}>
|
||||
{p.apiKey ? t('config.keyConfigured') : t('config.noKey')}
|
||||
</span>
|
||||
<button className="ghost sm" onClick={() => openProviderEdit(p)}>{t('config.editProvider')}</button>
|
||||
{!p.active && (
|
||||
<button className="sm" onClick={async () => {
|
||||
await api.saveProvider({ name: p.name, active: true })
|
||||
loadData()
|
||||
}}>{t('config.activate')}</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="config-form" style={{ marginTop: 8 }}>
|
||||
<FormInput label={t('config.apiKey')} value={providerForm.api_key} onChange={v => setProviderForm(f => ({ ...f, api_key: v }))} type="password" />
|
||||
<FormInput label={t('config.model')} value={providerForm.model} onChange={v => setProviderForm(f => ({ ...f, model: v }))} />
|
||||
<FormInput label={t('config.baseUrl')} value={providerForm.base_url} onChange={v => setProviderForm(f => ({ ...f, base_url: v }))} />
|
||||
<div className="config-form-actions">
|
||||
<button className="primary sm" onClick={handleSaveProvider}>{t('config.save')}</button>
|
||||
<button className="ghost sm" onClick={() => setEditProvider(null)}>{t('config.cancel')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="config-section">
|
||||
<div className="config-section-title">{t('config.language')}</div>
|
||||
<div className="chip-row">
|
||||
{LANGUAGES.map(lang => (
|
||||
<div
|
||||
key={lang.id}
|
||||
className={`chip ${language === lang.id ? 'active' : ''}`}
|
||||
onClick={() => setLanguage(lang.id)}
|
||||
>
|
||||
{lang.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="config-section">
|
||||
<div className="config-section-title">{t('config.keyboardLayout')}</div>
|
||||
<div className="chip-row">
|
||||
{layouts.map(l => (
|
||||
<div
|
||||
key={l.id}
|
||||
className={`chip ${keyboard === l.id ? 'active' : ''}`}
|
||||
onClick={() => setKeyboard(l.id)}
|
||||
>
|
||||
{l.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="config-section">
|
||||
<div className="config-section-title">{t('config.skills')} ({skillList.length})</div>
|
||||
{skillList.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
{t('config.noSkills')}
|
||||
<span style={{ fontFamily: 'var(--font-mono)' }}>{t('config.runSkillsInit')}</span>
|
||||
</div>
|
||||
) : (
|
||||
skillList.map((s, i) => (
|
||||
<div key={i} className="config-skill-row">
|
||||
<span className="config-skill-name">{s.name}</span>
|
||||
<span className="badge neutral">{s.target || 'both'}</span>
|
||||
<span className="config-skill-desc">{s.description}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldRow({ label, value }) {
|
||||
return (
|
||||
<div className="field-row">
|
||||
<span className="field-label">{label}</span>
|
||||
<span className={`field-value ${!value ? 'empty' : ''}`}>{value || '—'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FormInput({ label, value, onChange, type = 'text' }) {
|
||||
return (
|
||||
<div className="field-row">
|
||||
<span className="field-label">{label}</span>
|
||||
<input
|
||||
className="config-input"
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
100
web/src/components/Dashboard.jsx
Normal file
100
web/src/components/Dashboard.jsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useState } from 'react'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
export default function Dashboard({ tools, updates, api, onRescan }) {
|
||||
const { t, layout } = useI18n()
|
||||
const [notifications, setNotifications] = useState([])
|
||||
|
||||
const installed = tools.filter(tool => tool.installed).length
|
||||
const total = tools.length
|
||||
|
||||
const addNotif = (text, type) => {
|
||||
setNotifications(prev => [{ text, type, id: Date.now(), time: new Date() }, ...prev])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dashboard-layout">
|
||||
<div className="dashboard-content">
|
||||
<div className="dashboard-grid">
|
||||
<div className="dashboard-section">
|
||||
<div className="dashboard-section-header">
|
||||
<div className="dashboard-section-title">{t('dashboard.systemOverview')}</div>
|
||||
{total > 0 && (
|
||||
<span className="badge info">{installed}/{total}</span>
|
||||
)}
|
||||
</div>
|
||||
{tools.length === 0 ? (
|
||||
<div className="empty-state">{t('dashboard.noUpdateData')}</div>
|
||||
) : (
|
||||
<div className="tools-compact">
|
||||
{tools.map((tool, i) => {
|
||||
const name = tool.name || tool.Name
|
||||
const ver = extractVersion(tool.Version || tool.version)
|
||||
return (
|
||||
<div key={i} className="tool-compact-row">
|
||||
<span className={`badge sm ${tool.installed ? 'ok' : 'error'}`}>
|
||||
{tool.installed ? '\u2713' : '\u2717'}
|
||||
</span>
|
||||
<span className="tool-compact-name">{name}</span>
|
||||
{ver && <span className="tool-compact-ver">{ver}</span>}
|
||||
{tool.installed && <span className="tool-compact-installed">{t('dashboard.installed')}</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="dashboard-section">
|
||||
<div className="dashboard-section-header">
|
||||
<div className="dashboard-section-title">{t('studio.workflows')}</div>
|
||||
</div>
|
||||
<div className="dashboard-workflows-inline">
|
||||
<div className="workflow-section">
|
||||
<div className="section-label">{t('studio.workflows')}</div>
|
||||
<div className="empty-state" style={{ padding: 20 }}>
|
||||
{t('studio.noWorkflow')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="workflow-section">
|
||||
<div className="section-label">{t('studio.activeAgents')}</div>
|
||||
<div className="empty-state" style={{ padding: 20 }}>
|
||||
{t('studio.noWorkflow')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-section">
|
||||
<div className="dashboard-section-header">
|
||||
<div className="dashboard-section-title">{t('dashboard.activityLog')}</div>
|
||||
{notifications.length > 0 && (
|
||||
<span className="badge warn">{notifications.length}</span>
|
||||
)}
|
||||
</div>
|
||||
{notifications.length === 0 ? (
|
||||
<div className="empty-state">{t('dashboard.noUpdateData')}</div>
|
||||
) : (
|
||||
<div className="dashboard-notifications-inline">
|
||||
{notifications.map(n => (
|
||||
<div key={n.id} className={`notif-row notif-${n.type}`}>
|
||||
<span className="notif-time">
|
||||
{n.time.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||
</span>
|
||||
<span className="notif-text">{n.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function extractVersion(s) {
|
||||
if (!s) return ''
|
||||
const m = s.match(/\d+\.\d+\.\d+/)
|
||||
return m ? m[0] : s.slice(0, 12)
|
||||
}
|
||||
511
web/src/components/Shell.jsx
Normal file
511
web/src/components/Shell.jsx
Normal file
@@ -0,0 +1,511 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { Terminal as XTerm } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2 } from 'lucide-react'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
const MAX_TABS = 7
|
||||
|
||||
const XTERM_THEME = {
|
||||
background: '#0A0A0C',
|
||||
foreground: '#EAE0E2',
|
||||
cursor: '#FF0033',
|
||||
cursorAccent: '#0A0A0C',
|
||||
selectionBackground: '#FF003344',
|
||||
selectionForeground: '#ffffff',
|
||||
black: '#0A0A0C',
|
||||
red: '#FF0033',
|
||||
green: '#00E676',
|
||||
yellow: '#FFD740',
|
||||
blue: '#448AFF',
|
||||
magenta: '#FF1A5E',
|
||||
cyan: '#00BCD4',
|
||||
white: '#EAE0E2',
|
||||
brightBlack: '#5A4F52',
|
||||
brightRed: '#FF5252',
|
||||
brightGreen: '#69F0AE',
|
||||
brightYellow: '#FFFF00',
|
||||
brightBlue: '#82B1FF',
|
||||
brightMagenta: '#FF80AB',
|
||||
brightCyan: '#84FFFF',
|
||||
brightWhite: '#FFFFFF',
|
||||
}
|
||||
|
||||
function createTerminal(container) {
|
||||
const term = new XTerm({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
||||
theme: XTERM_THEME,
|
||||
allowTransparency: false,
|
||||
scrollback: 5000,
|
||||
})
|
||||
|
||||
const fitAddon = new FitAddon()
|
||||
const webLinksAddon = new WebLinksAddon()
|
||||
term.loadAddon(fitAddon)
|
||||
term.loadAddon(webLinksAddon)
|
||||
term.open(container)
|
||||
fitAddon.fit()
|
||||
|
||||
return { term, fitAddon }
|
||||
}
|
||||
|
||||
function connectWebSocket(term, fitAddon, initPayload) {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const ws = new WebSocket(`${proto}//${window.location.host}/api/ws/terminal`)
|
||||
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify(initPayload))
|
||||
const dims = fitAddon.proposeDimensions()
|
||||
if (dims) {
|
||||
ws.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols }))
|
||||
}
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === 'output') {
|
||||
term.write(msg.data)
|
||||
} else if (msg.type === 'error') {
|
||||
term.write(`\r\n\x1b[31mError: ${msg.data}\x1b[0m\r\n`)
|
||||
}
|
||||
} catch {
|
||||
term.write(event.data)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
term.write('\r\n\x1b[33m— Connection closed —\x1b[0m\r\n')
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
term.write('\r\n\x1b[31m— Connection error —\x1b[0m\r\n')
|
||||
}
|
||||
|
||||
term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'input', data }))
|
||||
}
|
||||
})
|
||||
|
||||
term.onResize(({ rows, cols }) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'resize', rows, cols }))
|
||||
}
|
||||
})
|
||||
|
||||
return ws
|
||||
}
|
||||
|
||||
export default function Shell({ api }) {
|
||||
const { t } = useI18n()
|
||||
const tabsRef = useRef({})
|
||||
const nextIdRef = useRef(1)
|
||||
|
||||
const [tabs, setTabs] = useState([
|
||||
{ id: 1, name: 'Local Shell', type: 'local', shell: '', connected: false },
|
||||
])
|
||||
const [activeTab, setActiveTab] = useState(1)
|
||||
const [sshConnections, setSshConnections] = useState([])
|
||||
const [systemTerminals, setSystemTerminals] = useState([])
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
const [showSshModal, setShowSshModal] = useState(false)
|
||||
const [editingTab, setEditingTab] = useState(null)
|
||||
const [editName, setEditName] = useState('')
|
||||
|
||||
const [sshForm, setSshForm] = useState({
|
||||
name: '', host: '', port: 22, user: '', key_path: '',
|
||||
})
|
||||
|
||||
const [aiMessages, setAiMessages] = useState([
|
||||
{ role: 'ai', content: t('shell.aiWelcome') }
|
||||
])
|
||||
const [aiInput, setAiInput] = useState('')
|
||||
const [aiLoading, setAiLoading] = useState(false)
|
||||
const aiMessagesRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
|
||||
}, [aiMessages])
|
||||
|
||||
useEffect(() => {
|
||||
api.getTerminalSessions().then(d => {
|
||||
setSshConnections(d.ssh || [])
|
||||
setSystemTerminals(d.system || [])
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const initTerminal = useCallback((tabId, tab) => {
|
||||
if (tabsRef.current[tabId]) return
|
||||
|
||||
const container = document.getElementById(`terminal-${tabId}`)
|
||||
if (!container) return
|
||||
|
||||
const { term, fitAddon } = createTerminal(container)
|
||||
|
||||
let initPayload
|
||||
if (tab.type === 'ssh') {
|
||||
initPayload = {
|
||||
type: 'ssh',
|
||||
data: JSON.stringify({
|
||||
host: tab.host,
|
||||
port: tab.port || 22,
|
||||
user: tab.user || 'root',
|
||||
key_path: tab.key_path || '',
|
||||
}),
|
||||
}
|
||||
} else {
|
||||
initPayload = {
|
||||
type: 'shell',
|
||||
data: tab.shell || '',
|
||||
}
|
||||
}
|
||||
|
||||
const ws = connectWebSocket(term, fitAddon, initPayload)
|
||||
|
||||
ws.onopen = () => {
|
||||
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: true } : t))
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t))
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t))
|
||||
}
|
||||
|
||||
const onResize = () => {
|
||||
const el = document.getElementById(`terminal-${tabId}`)
|
||||
if (el && el.offsetParent !== null) {
|
||||
fitAddon.fit()
|
||||
}
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(onResize)
|
||||
resizeObserver.observe(container)
|
||||
window.addEventListener('resize', onResize)
|
||||
|
||||
tabsRef.current[tabId] = { term, fitAddon, ws, resizeObserver, onResize }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const tab = tabs.find(t => t.id === activeTab)
|
||||
if (tab && !tabsRef.current[tab.id]) {
|
||||
const timer = setTimeout(() => initTerminal(tab.id, tab), 50)
|
||||
return () => clearTimeout(timer)
|
||||
} else if (tab && tabsRef.current[tab.id]) {
|
||||
const timer = setTimeout(() => {
|
||||
const { fitAddon } = tabsRef.current[tab.id]
|
||||
fitAddon.fit()
|
||||
}, 50)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [activeTab, tabs, initTerminal])
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
|
||||
if (!e.altKey) return
|
||||
|
||||
const num = parseInt(e.key)
|
||||
if (num >= 1 && num <= tabs.length) {
|
||||
e.preventDefault()
|
||||
setActiveTab(tabs[num - 1].id)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [tabs])
|
||||
|
||||
const addLocalTab = (shell, name) => {
|
||||
if (tabs.length >= MAX_TABS) return
|
||||
const id = nextIdRef.current++
|
||||
const newTab = { id, name: name || `${t('shell.localShell')} ${tabs.length + 1}`, type: 'local', shell: shell || '', connected: false }
|
||||
setTabs(prev => [...prev, newTab])
|
||||
setActiveTab(id)
|
||||
setShowMenu(false)
|
||||
}
|
||||
|
||||
const addSSHTab = (conn) => {
|
||||
if (tabs.length >= MAX_TABS) return
|
||||
const id = nextIdRef.current++
|
||||
const newTab = {
|
||||
id,
|
||||
name: conn.name || `${conn.user}@${conn.host}`,
|
||||
type: 'ssh',
|
||||
host: conn.host,
|
||||
port: conn.port || 22,
|
||||
user: conn.user || 'root',
|
||||
key_path: conn.key_path || '',
|
||||
connected: false,
|
||||
}
|
||||
setTabs(prev => [...prev, newTab])
|
||||
setActiveTab(id)
|
||||
setShowMenu(false)
|
||||
}
|
||||
|
||||
const closeTab = (tabId, e) => {
|
||||
if (e) e.stopPropagation()
|
||||
if (tabs.length <= 1) return
|
||||
|
||||
if (tabsRef.current[tabId]) {
|
||||
const { ws, resizeObserver, onResize, term } = tabsRef.current[tabId]
|
||||
window.removeEventListener('resize', onResize)
|
||||
resizeObserver.disconnect()
|
||||
ws.close()
|
||||
term.dispose()
|
||||
delete tabsRef.current[tabId]
|
||||
}
|
||||
|
||||
setTabs(prev => {
|
||||
const next = prev.filter(t => t.id !== tabId)
|
||||
if (activeTab === tabId) {
|
||||
setActiveTab(next[next.length - 1].id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const startRename = (tabId, e) => {
|
||||
if (e) e.stopPropagation()
|
||||
const tab = tabs.find(t => t.id === tabId)
|
||||
setEditingTab(tabId)
|
||||
setEditName(tab.name)
|
||||
}
|
||||
|
||||
const finishRename = () => {
|
||||
if (editName.trim() && editingTab) {
|
||||
setTabs(prev => prev.map(t => t.id === editingTab ? { ...t, name: editName.trim() } : t))
|
||||
}
|
||||
setEditingTab(null)
|
||||
setEditName('')
|
||||
}
|
||||
|
||||
const saveSSHConnection = async () => {
|
||||
if (!sshForm.name.trim() || !sshForm.host.trim()) return
|
||||
try {
|
||||
await api.addSSHConnection(sshForm)
|
||||
setSshConnections(prev => [...prev, { ...sshForm }])
|
||||
setSshForm({ name: '', host: '', port: 22, user: '', key_path: '' })
|
||||
setShowSshModal(false)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteSSHConnection = async (name) => {
|
||||
try {
|
||||
await api.deleteSSHConnection(name)
|
||||
setSshConnections(prev => prev.filter(c => c.name !== name))
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAiSend = async () => {
|
||||
if (!aiInput.trim() || aiLoading) return
|
||||
const text = aiInput.trim()
|
||||
setAiMessages(prev => [...prev, { role: 'user', content: text }])
|
||||
setAiInput('')
|
||||
setAiLoading(true)
|
||||
try {
|
||||
const res = await api.runCommand(`echo "AI: ${text}"`, '')
|
||||
setAiMessages(prev => [...prev, { role: 'ai', content: res.output || t('shell.noResponse') }])
|
||||
} catch (err) {
|
||||
setAiMessages(prev => [...prev, { role: 'ai', content: `${t('shell.error')}: ${err.message}` }])
|
||||
}
|
||||
setAiLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shell-layout">
|
||||
<div className="shell-terminal-col">
|
||||
<div className="shell-tabs-bar">
|
||||
<div className="shell-tabs">
|
||||
{tabs.map((tab, i) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`shell-tab ${activeTab === tab.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
onDoubleClick={(e) => startRename(tab.id, e)}
|
||||
>
|
||||
<span className={`connection-dot ${tab.connected ? 'on' : 'off'}`} />
|
||||
{tab.type === 'ssh' && <Globe size={12} />}
|
||||
{tab.type === 'local' && <Monitor size={12} />}
|
||||
{editingTab === tab.id ? (
|
||||
<input
|
||||
className="shell-tab-rename"
|
||||
value={editName}
|
||||
onChange={e => setEditName(e.target.value)}
|
||||
onBlur={finishRename}
|
||||
onKeyDown={e => { if (e.key === 'Enter') finishRename(); if (e.key === 'Escape') setEditingTab(null) }}
|
||||
autoFocus
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span className="shell-tab-name">{tab.name}</span>
|
||||
)}
|
||||
<span className="shell-tab-index">{i + 1}</span>
|
||||
{tabs.length > 1 && (
|
||||
<button
|
||||
className="shell-tab-close"
|
||||
onClick={(e) => closeTab(tab.id, e)}
|
||||
title={t('shell.closeTab')}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="shell-tab-actions">
|
||||
{tabs.length < MAX_TABS && (
|
||||
<div className="shell-new-tab-wrapper">
|
||||
<button className="shell-new-tab-btn" onClick={() => setShowMenu(!showMenu)} title={t('shell.newTab')}>
|
||||
<Plus size={16} />
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
{showMenu && (
|
||||
<>
|
||||
<div className="shell-menu-overlay" onClick={() => setShowMenu(false)} />
|
||||
<div className="shell-new-tab-menu">
|
||||
<div className="shell-menu-label">{t('shell.systemTerminals')}</div>
|
||||
{systemTerminals.map(st => (
|
||||
<button
|
||||
key={st.name}
|
||||
className="shell-menu-item"
|
||||
onClick={() => addLocalTab(st.shell, st.name)}
|
||||
>
|
||||
<Monitor size={14} />
|
||||
<span>{st.name}</span>
|
||||
<span className="shell-menu-item-sub">{st.shell}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="shell-menu-divider" />
|
||||
<div className="shell-menu-label">{t('shell.savedConnections')}</div>
|
||||
{sshConnections.length === 0 && (
|
||||
<div className="shell-menu-empty">{t('shell.noConnections')}</div>
|
||||
)}
|
||||
{sshConnections.map(conn => (
|
||||
<div key={conn.name} className="shell-menu-item-row">
|
||||
<button
|
||||
className="shell-menu-item"
|
||||
onClick={() => addSSHTab(conn)}
|
||||
>
|
||||
<Globe size={14} />
|
||||
<span>{conn.name}</span>
|
||||
<span className="shell-menu-item-sub">{conn.user}@{conn.host}:{conn.port}</span>
|
||||
</button>
|
||||
<button
|
||||
className="shell-menu-item-icon"
|
||||
onClick={(e) => { e.stopPropagation(); deleteSSHConnection(conn.name) }}
|
||||
title={t('shell.deleteConnection')}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="shell-menu-divider" />
|
||||
<button className="shell-menu-item accent" onClick={() => { setShowSshModal(true); setShowMenu(false) }}>
|
||||
<Plus size={14} />
|
||||
<span>{t('shell.addConnection')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shell-xterm-wrapper">
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
id={`terminal-${tab.id}`}
|
||||
className="shell-xterm-instance"
|
||||
style={{ display: activeTab === tab.id ? 'block' : 'none' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shell-ai-col">
|
||||
<div className="ai-panel-header">{t('shell.aiAssistant')}</div>
|
||||
<div className="ai-panel-messages" ref={aiMessagesRef}>
|
||||
{aiMessages.map((msg, i) => (
|
||||
<div key={i} className={`ai-message ${msg.role}`}>
|
||||
{msg.content}
|
||||
</div>
|
||||
))}
|
||||
{aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>}
|
||||
</div>
|
||||
<div className="ai-panel-input">
|
||||
<input
|
||||
value={aiInput}
|
||||
onChange={e => setAiInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleAiSend()}
|
||||
placeholder={t('shell.askAi')}
|
||||
/>
|
||||
<button className="sm" onClick={handleAiSend} disabled={!aiInput.trim()}>{t('shell.send')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSshModal && (
|
||||
<div className="shell-modal-overlay" onClick={() => setShowSshModal(false)}>
|
||||
<div className="shell-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="shell-modal-header">{t('shell.addConnection')}</div>
|
||||
<div className="shell-modal-body">
|
||||
<label className="shell-modal-label">{t('shell.connectionName')}</label>
|
||||
<input
|
||||
value={sshForm.name}
|
||||
onChange={e => setSshForm(f => ({ ...f, name: e.target.value }))}
|
||||
placeholder="prod-server"
|
||||
/>
|
||||
<label className="shell-modal-label">{t('shell.host')}</label>
|
||||
<input
|
||||
value={sshForm.host}
|
||||
onChange={e => setSshForm(f => ({ ...f, host: e.target.value }))}
|
||||
placeholder="192.168.1.100"
|
||||
/>
|
||||
<div className="shell-modal-row">
|
||||
<div className="shell-modal-field">
|
||||
<label className="shell-modal-label">{t('shell.port')}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={sshForm.port}
|
||||
onChange={e => setSshForm(f => ({ ...f, port: parseInt(e.target.value) || 22 }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="shell-modal-field">
|
||||
<label className="shell-modal-label">{t('shell.user')}</label>
|
||||
<input
|
||||
value={sshForm.user}
|
||||
onChange={e => setSshForm(f => ({ ...f, user: e.target.value }))}
|
||||
placeholder="root"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label className="shell-modal-label">{t('shell.keyPath')} ({t('shell.local')})</label>
|
||||
<input
|
||||
value={sshForm.key_path}
|
||||
onChange={e => setSshForm(f => ({ ...f, key_path: e.target.value }))}
|
||||
placeholder="~/.ssh/id_rsa"
|
||||
/>
|
||||
</div>
|
||||
<div className="shell-modal-footer">
|
||||
<button className="ghost" onClick={() => setShowSshModal(false)}>{t('shell.cancel')}</button>
|
||||
<button className="primary" onClick={saveSSHConnection}>{t('shell.save')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
393
web/src/components/Studio.jsx
Normal file
393
web/src/components/Studio.jsx
Normal file
@@ -0,0 +1,393 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
const MSG_ID = () => Math.random().toString(36).slice(2, 10)
|
||||
|
||||
function parsePlanBlocks(text) {
|
||||
const plans = []
|
||||
const regex = /(?:^|\n)(?:###?\s+|(?:\d+\.\s+)?)\[?PLAN[^\]]*\]?:?\s*(.*?)(?=\n(?:###?\s+|(?:\d+\.\s+)?)\[?PLAN|\n## |\n### |\n$)/gis
|
||||
const matches = text.matchAll(regex)
|
||||
for (const m of matches) {
|
||||
plans.push({ id: MSG_ID(), title: m[1].trim(), content: m[0].trim() })
|
||||
}
|
||||
if (plans.length === 0 && /plan|workflow/i.test(text)) {
|
||||
const lines = text.split('\n').filter(l => /^\s*[-*]\s|^\s*\d+\.\s/.test(l))
|
||||
if (lines.length > 0) {
|
||||
plans.push({ id: MSG_ID(), title: text.split('\n')[0].slice(0, 80), content: text.trim() })
|
||||
}
|
||||
}
|
||||
return plans
|
||||
}
|
||||
|
||||
function parseAgentMentions(text) {
|
||||
const agents = new Set()
|
||||
const names = ['crush', 'claude', 'claude code', 'ollama', 'copilot', 'cursor', 'agent']
|
||||
for (const name of names) {
|
||||
if (new RegExp('\\b' + name + '\\b', 'i').test(text)) {
|
||||
agents.add(name)
|
||||
}
|
||||
}
|
||||
return [...agents]
|
||||
}
|
||||
|
||||
function parseSteps(text) {
|
||||
const steps = []
|
||||
const lines = text.split('\n')
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^\s*(\d+)[.)]\s+(.+)/)
|
||||
if (match) {
|
||||
steps.push({ num: match[1], text: match[2].trim() })
|
||||
}
|
||||
const bulletMatch = line.match(/^\s*[-*]\s+(.+)/)
|
||||
if (bulletMatch) {
|
||||
steps.push({ num: String(steps.length + 1), text: bulletMatch[1].trim() })
|
||||
}
|
||||
}
|
||||
return steps
|
||||
}
|
||||
|
||||
function renderContent(text) {
|
||||
const parts = []
|
||||
let i = 0
|
||||
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 formatText(text) {
|
||||
let html = text
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.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(/^\s*[-*] (.+)$/gm, '<span class="msg-bullet">$1</span>')
|
||||
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<span class="msg-step"><span class="msg-step-num">$1</span>$2</span>')
|
||||
return html
|
||||
}
|
||||
|
||||
function MessageBubble({ msg }) {
|
||||
const { t } = useI18n()
|
||||
const [expanded, setExpanded] = useState(null)
|
||||
const plans = msg.role === 'ai' ? parsePlanBlocks(msg.content) : []
|
||||
const steps = msg.role === 'ai' ? parseSteps(msg.content) : []
|
||||
const agents = msg.role === 'ai' ? parseAgentMentions(msg.content) : []
|
||||
|
||||
return (
|
||||
<div className={`studio-msg ${msg.role}`}>
|
||||
{msg.role === 'ai' && (
|
||||
<div className="studio-msg-avatar">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
||||
</div>
|
||||
)}
|
||||
<div className="studio-msg-body">
|
||||
<div className="studio-msg-content">
|
||||
{renderContent(msg.content).map((part, i) =>
|
||||
part.type === 'code' ? (
|
||||
<div key={i} className="studio-code-block">
|
||||
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
|
||||
<pre><code>{part.content}</code></pre>
|
||||
</div>
|
||||
) : (
|
||||
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{msg.role === 'ai' && (plans.length > 0 || agents.length > 0) && (
|
||||
<div className="studio-msg-meta">
|
||||
{plans.map(plan => (
|
||||
<div key={plan.id} className="studio-plan-chip" onClick={() => setExpanded(expanded === plan.id ? null : plan.id)}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
{plan.title.slice(0, 60)}
|
||||
<span className="studio-expand-icon">{expanded === plan.id ? '\u25B2' : '\u25BC'}</span>
|
||||
</div>
|
||||
))}
|
||||
{agents.map(agent => (
|
||||
<span key={agent} className="studio-agent-tag">
|
||||
{agent}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{expanded && plans.find(p => p.id === expanded) && (
|
||||
<div className="studio-plan-detail">
|
||||
<div className="studio-plan-detail-header">{t('studio.planDetail')}</div>
|
||||
{steps.length > 0 && (
|
||||
<div className="studio-steps">
|
||||
{steps.map(step => (
|
||||
<div key={step.num} className="studio-step">
|
||||
<span className="studio-step-num">{step.num}</span>
|
||||
<span className="studio-step-text">{step.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="studio-plan-raw">
|
||||
<pre>{plans.find(p => p.id === expanded).content}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StreamingMessage({ content }) {
|
||||
return (
|
||||
<div className="studio-msg ai">
|
||||
<div className="studio-msg-avatar">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
||||
</div>
|
||||
<div className="studio-msg-body">
|
||||
<div className="studio-msg-content">
|
||||
{renderContent(content).map((part, i) =>
|
||||
part.type === 'code' ? (
|
||||
<div key={i} className="studio-code-block">
|
||||
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
|
||||
<pre><code>{part.content}</code></pre>
|
||||
</div>
|
||||
) : (
|
||||
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<span className="studio-cursor" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextPanel({ messages, selectedPlan, onSelectPlan }) {
|
||||
const { t } = useI18n()
|
||||
const [tab, setTab] = useState('plans')
|
||||
|
||||
const allPlans = []
|
||||
const allAgents = new Set()
|
||||
const activities = []
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i]
|
||||
if (msg.role === 'ai') {
|
||||
const plans = parsePlanBlocks(msg.content)
|
||||
for (const plan of plans) {
|
||||
if (!allPlans.find(p => p.title === plan.title)) {
|
||||
allPlans.push({ ...plan, msgIndex: i })
|
||||
}
|
||||
}
|
||||
parseAgentMentions(msg.content).forEach(a => allAgents.add(a))
|
||||
}
|
||||
activities.push({ role: msg.role, content: msg.content.slice(0, 100), time: msg.time })
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'plans', label: t('studio.plans'), count: allPlans.length },
|
||||
{ id: 'agents', label: t('studio.agents'), count: allAgents.size },
|
||||
{ id: 'activity', label: t('studio.activity'), count: activities.length },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="studio-context">
|
||||
<div className="studio-context-tabs">
|
||||
{tabs.map(t2 => (
|
||||
<div
|
||||
key={t2.id}
|
||||
className={`studio-context-tab ${tab === t2.id ? 'active' : ''}`}
|
||||
onClick={() => setTab(t2.id)}
|
||||
>
|
||||
{t2.label}
|
||||
{t2.count > 0 && <span className="studio-tab-count">{t2.count}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="studio-context-body">
|
||||
{tab === 'plans' && (
|
||||
allPlans.length > 0 ? (
|
||||
<div className="studio-plan-list">
|
||||
{allPlans.map(plan => (
|
||||
<div
|
||||
key={plan.id}
|
||||
className={`studio-plan-item ${selectedPlan === plan.id ? 'active' : ''}`}
|
||||
onClick={() => onSelectPlan(selectedPlan === plan.id ? null : plan.id)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
<div className="studio-plan-item-text">{plan.title}</div>
|
||||
<span className="studio-plan-item-badge">{parseSteps(plan.content).length} {t('studio.steps')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="studio-empty">{t('studio.noPlansYet')}</div>
|
||||
)
|
||||
)}
|
||||
{tab === 'agents' && (
|
||||
allAgents.size > 0 ? (
|
||||
<div className="studio-agent-list">
|
||||
{[...allAgents].map(agent => (
|
||||
<div key={agent} className="studio-agent-item">
|
||||
<div className="studio-agent-dot" />
|
||||
<span className="studio-agent-name">{agent}</span>
|
||||
<span className="badge info">{t('studio.mentioned')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="studio-empty">{t('studio.noAgentsYet')}</div>
|
||||
)
|
||||
)}
|
||||
{tab === 'activity' && (
|
||||
<div className="studio-activity-list">
|
||||
{activities.map((act, i) => (
|
||||
<div key={i} className="studio-activity-item">
|
||||
<div className={`studio-activity-dot ${act.role}`} />
|
||||
<div className="studio-activity-text">
|
||||
{act.role === 'user' ? t('studio.you') + ': ' : 'AI: '}
|
||||
{act.content}{act.content.length >= 100 ? '...' : ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Studio({ api }) {
|
||||
const { t } = useI18n()
|
||||
const [messages, setMessages] = useState([
|
||||
{ id: MSG_ID(), role: 'ai', content: t('studio.welcomeNew'), time: new Date() },
|
||||
])
|
||||
const [input, setInput] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [streaming, setStreaming] = useState('')
|
||||
const [selectedPlan, setSelectedPlan] = useState(null)
|
||||
const [showContext, setShowContext] = useState(true)
|
||||
const messagesEnd = useRef(null)
|
||||
const textareaRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages, streaming])
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto'
|
||||
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 200) + 'px'
|
||||
}
|
||||
}, [input])
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!input.trim() || loading) return
|
||||
const text = input.trim()
|
||||
setInput('')
|
||||
const userMsg = { id: MSG_ID(), role: 'user', content: text, time: new Date() }
|
||||
setMessages(prev => [...prev, userMsg])
|
||||
setLoading(true)
|
||||
setStreaming('')
|
||||
|
||||
try {
|
||||
let accumulated = ''
|
||||
await api.sendChat(text, true).then(full => {
|
||||
accumulated = full
|
||||
}).catch(() => {})
|
||||
|
||||
const finalContent = accumulated || t('studio.noResponse')
|
||||
setMessages(prev => [...prev, { id: MSG_ID(), role: 'ai', content: finalContent, time: new Date() }])
|
||||
} catch (err) {
|
||||
setMessages(prev => [...prev, { id: MSG_ID(), role: 'ai', content: `${t('studio.error')}: ${err.message}`, time: new Date() }])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setStreaming('')
|
||||
}
|
||||
}, [input, loading, api, t])
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="studio-layout">
|
||||
<div className="studio-chat-area">
|
||||
<div className="studio-messages">
|
||||
{messages.map(msg => (
|
||||
<MessageBubble key={msg.id} msg={msg} />
|
||||
))}
|
||||
{streaming && <StreamingMessage content={streaming} />}
|
||||
{loading && !streaming && (
|
||||
<div className="studio-msg ai">
|
||||
<div className="studio-msg-avatar">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
||||
</div>
|
||||
<div className="studio-msg-body">
|
||||
<div className="studio-thinking">
|
||||
<span /><span /><span />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEnd} />
|
||||
</div>
|
||||
|
||||
<div className="studio-input-area">
|
||||
<div className="studio-input-row">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('studio.placeholderNew')}
|
||||
disabled={loading}
|
||||
rows={1}
|
||||
/>
|
||||
<button
|
||||
className="studio-send-btn"
|
||||
onClick={handleSend}
|
||||
disabled={loading || !input.trim()}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="studio-input-hint">
|
||||
{t('studio.inputHint')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`studio-sidebar ${showContext ? 'open' : ''}`}>
|
||||
<div className="studio-sidebar-header">
|
||||
<span>{t('studio.context')}</span>
|
||||
<button className="ghost sm studio-sidebar-toggle" onClick={() => setShowContext(!showContext)}>
|
||||
{showContext ? '\u203A' : '\u2039'}
|
||||
</button>
|
||||
</div>
|
||||
{showContext && (
|
||||
<ContextPanel
|
||||
messages={messages}
|
||||
selectedPlan={selectedPlan}
|
||||
onSelectPlan={setSelectedPlan}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
164
web/src/i18n/en.js
Normal file
164
web/src/i18n/en.js
Normal file
@@ -0,0 +1,164 @@
|
||||
const en = {
|
||||
tabs: {
|
||||
dashboard: 'Dashboard',
|
||||
studio: 'Studio',
|
||||
shell: 'Shell',
|
||||
config: 'Config',
|
||||
},
|
||||
|
||||
header: {
|
||||
toolsInstalled: '{count} tools installed',
|
||||
updatesAvailable: 'Updates available',
|
||||
upToDate: 'Up to date',
|
||||
},
|
||||
|
||||
statusbar: {
|
||||
switchWindow: 'Switch window',
|
||||
sendMessage: 'Send message',
|
||||
newLine: 'New line',
|
||||
runCommand: 'Run command',
|
||||
commandHistory: 'Command history',
|
||||
},
|
||||
|
||||
dashboard: {
|
||||
systemOverview: 'System Overview',
|
||||
tools: 'tools',
|
||||
installed: 'Installed',
|
||||
missing: 'Missing',
|
||||
quickActions: 'Quick Actions',
|
||||
installMissing: 'Install missing',
|
||||
checkUpdates: 'Check for updates',
|
||||
rescanSystem: 'Rescan system',
|
||||
configureMCP: 'Configure MCP',
|
||||
updates: 'Updates',
|
||||
update: 'Update',
|
||||
latest: 'Latest',
|
||||
activityLog: 'Activity Log',
|
||||
noUpdateData: 'No update data yet.',
|
||||
installing: 'Installing {count} tools...',
|
||||
installStarted: 'Install started. Rescanning...',
|
||||
done: 'Done.',
|
||||
scanComplete: 'Scan complete.',
|
||||
updatesCount: '{count} updates available.',
|
||||
allUpToDate: 'All tools up to date.',
|
||||
mcpConfigured: 'MCP configured.',
|
||||
},
|
||||
|
||||
studio: {
|
||||
welcome: 'Welcome to Studio! Chat with your AI assistant here.',
|
||||
welcomeNew: 'Welcome to Muyue Studio. I am your AI orchestrator. Describe your project and I will create a plan, propose agents, and track each step.',
|
||||
configureHint: 'Configure agents and workflows from the sidebar.',
|
||||
chat: 'Chat',
|
||||
agents: 'Agents',
|
||||
workflows: 'Workflows',
|
||||
placeholder: 'Type a message... (Enter to send)',
|
||||
placeholderNew: 'Describe your project or ask a question...',
|
||||
send: 'Send',
|
||||
commands: 'Commands',
|
||||
planGoal: '/plan <goal>',
|
||||
help: '/help',
|
||||
activeAgents: 'Active Agents',
|
||||
crush: 'Crush',
|
||||
claudeCode: 'Claude Code',
|
||||
stopped: 'Stopped',
|
||||
inactive: 'Inactive',
|
||||
noWorkflow: 'No active workflow.',
|
||||
usePlan: 'Use /plan <goal> in chat to start.',
|
||||
noResponse: 'No response',
|
||||
error: 'Error',
|
||||
inputHint: 'Enter to send, Shift+Enter for new line',
|
||||
context: 'Context',
|
||||
plans: 'Plans',
|
||||
activity: 'Activity',
|
||||
noPlansYet: 'No plans detected. Ask the AI to create a plan.',
|
||||
noAgentsYet: 'No agents mentioned.',
|
||||
planDetail: 'Plan detail',
|
||||
steps: 'steps',
|
||||
you: 'You',
|
||||
mentioned: 'mentioned',
|
||||
},
|
||||
|
||||
shell: {
|
||||
terminal: 'Terminal',
|
||||
hideAi: 'Hide AI',
|
||||
aiAssistant: 'AI Assistant',
|
||||
aiWelcome: 'I know your system inside out. Ask me anything.',
|
||||
askAi: 'Ask AI...',
|
||||
send: 'Send',
|
||||
noResponse: 'No response',
|
||||
error: 'Error',
|
||||
newTab: 'New tab',
|
||||
closeTab: 'Close tab',
|
||||
maxTabsReached: 'Maximum 7 terminals reached',
|
||||
renameTab: 'Rename',
|
||||
local: 'Local',
|
||||
ssh: 'SSH',
|
||||
connections: 'Connections',
|
||||
addConnection: 'Add SSH connection',
|
||||
editConnection: 'Edit connection',
|
||||
deleteConnection: 'Delete',
|
||||
connectionName: 'Name',
|
||||
host: 'Host',
|
||||
port: 'Port',
|
||||
user: 'User',
|
||||
keyPath: 'SSH key path',
|
||||
connect: 'Connect',
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
savedConnections: 'Saved connections',
|
||||
noConnections: 'No saved SSH connections.',
|
||||
systemTerminals: 'System terminals',
|
||||
switchTerminal: 'Switch terminal',
|
||||
localShell: 'Local Shell',
|
||||
},
|
||||
|
||||
config: {
|
||||
profile: 'Profile',
|
||||
name: 'Name',
|
||||
pseudo: 'Pseudo',
|
||||
email: 'Email',
|
||||
editor: 'Editor',
|
||||
shell: 'Shell',
|
||||
defaultAi: 'Default AI',
|
||||
languages: 'Languages',
|
||||
loadingProfile: 'Loading profile...',
|
||||
notSet: 'Not set',
|
||||
aiProviders: 'AI Providers',
|
||||
active: 'Active',
|
||||
activate: 'Activate',
|
||||
keyConfigured: 'Key configured',
|
||||
noKey: 'No key',
|
||||
apiKey: 'API Key',
|
||||
model: 'Model',
|
||||
baseUrl: 'Base URL',
|
||||
save: 'Save',
|
||||
saved: 'Saved!',
|
||||
error: 'Error',
|
||||
skills: 'Skills',
|
||||
noSkills: 'No skills installed.',
|
||||
runSkillsInit: 'Run muyue skills init',
|
||||
language: 'Language',
|
||||
keyboardLayout: 'Keyboard Layout',
|
||||
target: 'Target',
|
||||
updates: 'Updates',
|
||||
systemUpdates: 'System Updates',
|
||||
checkUpdates: 'Check for updates',
|
||||
updateAll: 'Update all',
|
||||
updateTool: 'Update',
|
||||
checking: 'Checking...',
|
||||
updating: 'Updating...',
|
||||
upToDate: 'Up to date',
|
||||
needsUpdate: 'Update available',
|
||||
current: 'Current',
|
||||
latest: 'Latest',
|
||||
noUpdates: 'All tools are up to date.',
|
||||
version: 'Version',
|
||||
installed: 'Installed',
|
||||
missing: 'Missing',
|
||||
editProfile: 'Edit profile',
|
||||
cancel: 'Cancel',
|
||||
editProvider: 'Configure',
|
||||
},
|
||||
}
|
||||
|
||||
export default en
|
||||
164
web/src/i18n/fr.js
Normal file
164
web/src/i18n/fr.js
Normal file
@@ -0,0 +1,164 @@
|
||||
const fr = {
|
||||
tabs: {
|
||||
dashboard: 'Tableau de bord',
|
||||
studio: 'Studio',
|
||||
shell: 'Terminal',
|
||||
config: 'Configuration',
|
||||
},
|
||||
|
||||
header: {
|
||||
toolsInstalled: '{count} outils install\u00e9s',
|
||||
updatesAvailable: 'Mises \u00e0 jour disponibles',
|
||||
upToDate: '\u00c0 jour',
|
||||
},
|
||||
|
||||
statusbar: {
|
||||
switchWindow: 'Changer de fen\u00eatre',
|
||||
sendMessage: 'Envoyer le message',
|
||||
newLine: 'Nouvelle ligne',
|
||||
runCommand: 'Ex\u00e9cuter',
|
||||
commandHistory: 'Historique',
|
||||
},
|
||||
|
||||
dashboard: {
|
||||
systemOverview: 'Vue d\u2019ensemble du syst\u00e8me',
|
||||
tools: 'outils',
|
||||
installed: 'Install\u00e9',
|
||||
missing: 'Manquant',
|
||||
quickActions: 'Actions rapides',
|
||||
installMissing: 'Installer les manquants',
|
||||
checkUpdates: 'V\u00e9rifier les mises \u00e0 jour',
|
||||
rescanSystem: 'Rescanner le syst\u00e8me',
|
||||
configureMCP: 'Configurer MCP',
|
||||
updates: 'Mises \u00e0 jour',
|
||||
update: 'Mise \u00e0 jour',
|
||||
latest: '\u00c0 jour',
|
||||
activityLog: 'Journal d\u2019activit\u00e9',
|
||||
noUpdateData: 'Aucune donn\u00e9e de mise \u00e0 jour.',
|
||||
installing: 'Installation de {count} outils...',
|
||||
installStarted: 'Installation lanc\u00e9e. Rescan en cours...',
|
||||
done: 'Termin\u00e9.',
|
||||
scanComplete: 'Scan termin\u00e9.',
|
||||
updatesCount: '{count} mises \u00e0 jour disponibles.',
|
||||
allUpToDate: 'Tous les outils sont \u00e0 jour.',
|
||||
mcpConfigured: 'MCP configur\u00e9.',
|
||||
},
|
||||
|
||||
studio: {
|
||||
welcome: 'Bienvenue dans Studio ! Discutez avec votre assistant IA ici.',
|
||||
welcomeNew: 'Bienvenue dans Muyue Studio. Je suis votre orchestrateur IA. D\u00e9crivez votre projet et je cr\u00e9erai un plan, proposerai des agents, et suivrai chaque \u00e9tape.',
|
||||
configureHint: 'Configurez les agents et workflows depuis la barre lat\u00e9rale.',
|
||||
chat: 'Chat',
|
||||
agents: 'Agents',
|
||||
workflows: 'Workflows',
|
||||
placeholder: 'Tapez un message... (Entr\u00e9e pour envoyer)',
|
||||
placeholderNew: 'D\u00e9crivez votre projet ou posez une question...',
|
||||
send: 'Envoyer',
|
||||
commands: 'Commandes',
|
||||
planGoal: '/plan <objectif>',
|
||||
help: '/help',
|
||||
activeAgents: 'Agents actifs',
|
||||
crush: 'Crush',
|
||||
claudeCode: 'Claude Code',
|
||||
stopped: 'Arr\u00eat\u00e9',
|
||||
inactive: 'Inactif',
|
||||
noWorkflow: 'Aucun workflow actif.',
|
||||
usePlan: 'Utilisez /plan <objectif> dans le chat pour d\u00e9marrer.',
|
||||
noResponse: 'Pas de r\u00e9ponse',
|
||||
error: 'Erreur',
|
||||
inputHint: 'Entr\u00e9e pour envoyer, Shift+Entr\u00e9e pour un retour \u00e0 la ligne',
|
||||
context: 'Contexte',
|
||||
plans: 'Plans',
|
||||
activity: 'Activit\u00e9',
|
||||
noPlansYet: 'Aucun plan d\u00e9tect\u00e9. Demandez \u00e0 l\u2019IA de cr\u00e9er un plan.',
|
||||
noAgentsYet: 'Aucun agent mentionn\u00e9.',
|
||||
planDetail: 'D\u00e9tail du plan',
|
||||
steps: '\u00e9tapes',
|
||||
you: 'Vous',
|
||||
mentioned: 'mentionn\u00e9',
|
||||
},
|
||||
|
||||
shell: {
|
||||
terminal: 'Terminal',
|
||||
hideAi: 'Masquer IA',
|
||||
aiAssistant: 'Assistant IA',
|
||||
aiWelcome: 'Je connais votre syst\u00e8me sur le bout des doigts. Demandez-moi n\u2019importe quoi.',
|
||||
askAi: 'Demander \u00e0 l\u2019IA...',
|
||||
send: 'Envoyer',
|
||||
noResponse: 'Pas de r\u00e9ponse',
|
||||
error: 'Erreur',
|
||||
newTab: 'Nouvel onglet',
|
||||
closeTab: 'Fermer l\u2019onglet',
|
||||
maxTabsReached: 'Maximum 7 terminaux atteint',
|
||||
renameTab: 'Renommer',
|
||||
local: 'Local',
|
||||
ssh: 'SSH',
|
||||
connections: 'Connexions',
|
||||
addConnection: 'Ajouter une connexion SSH',
|
||||
editConnection: 'Modifier la connexion',
|
||||
deleteConnection: 'Supprimer',
|
||||
connectionName: 'Nom',
|
||||
host: 'H\u00f4te',
|
||||
port: 'Port',
|
||||
user: 'Utilisateur',
|
||||
keyPath: 'Chemin cl\u00e9 SSH',
|
||||
connect: 'Se connecter',
|
||||
save: 'Enregistrer',
|
||||
cancel: 'Annuler',
|
||||
savedConnections: 'Connexions enregistr\u00e9es',
|
||||
noConnections: 'Aucune connexion SSH enregistr\u00e9e.',
|
||||
systemTerminals: 'Terminaux syst\u00e8me',
|
||||
switchTerminal: 'Changer de terminal',
|
||||
localShell: 'Shell local',
|
||||
},
|
||||
|
||||
config: {
|
||||
profile: 'Profil',
|
||||
name: 'Nom',
|
||||
pseudo: 'Pseudo',
|
||||
email: 'Email',
|
||||
editor: '\u00c9diteur',
|
||||
shell: 'Shell',
|
||||
defaultAi: 'IA par d\u00e9faut',
|
||||
languages: 'Langages',
|
||||
loadingProfile: 'Chargement du profil...',
|
||||
notSet: 'Non d\u00e9fini',
|
||||
aiProviders: 'Fournisseurs IA',
|
||||
active: 'Actif',
|
||||
activate: 'Activer',
|
||||
keyConfigured: 'Cl\u00e9 configur\u00e9e',
|
||||
noKey: 'Pas de cl\u00e9',
|
||||
apiKey: 'Cl\u00e9 API',
|
||||
model: 'Mod\u00e8le',
|
||||
baseUrl: 'URL de base',
|
||||
save: 'Enregistrer',
|
||||
saved: 'Enregistr\u00e9 !',
|
||||
error: 'Erreur',
|
||||
skills: 'Comp\u00e9tences',
|
||||
noSkills: 'Aucune comp\u00e9tence install\u00e9e.',
|
||||
runSkillsInit: 'Ex\u00e9cutez muyue skills init',
|
||||
language: 'Langue',
|
||||
keyboardLayout: 'Disposition du clavier',
|
||||
target: 'Cible',
|
||||
updates: 'Mises \u00e0 jour',
|
||||
systemUpdates: 'Mises \u00e0 jour syst\u00e8me',
|
||||
checkUpdates: 'V\u00e9rifier les mises \u00e0 jour',
|
||||
updateAll: 'Tout mettre \u00e0 jour',
|
||||
updateTool: 'Mettre \u00e0 jour',
|
||||
checking: 'V\u00e9rification...',
|
||||
updating: 'Mise \u00e0 jour...',
|
||||
upToDate: '\u00c0 jour',
|
||||
needsUpdate: 'Mise \u00e0 jour disponible',
|
||||
current: 'Actuel',
|
||||
latest: 'Dernier',
|
||||
noUpdates: 'Tous les outils sont \u00e0 jour.',
|
||||
version: 'Version',
|
||||
installed: 'Install\u00e9',
|
||||
missing: 'Manquant',
|
||||
editProfile: 'Modifier le profil',
|
||||
editProvider: 'Configurer',
|
||||
cancel: 'Annuler',
|
||||
},
|
||||
}
|
||||
|
||||
export default fr
|
||||
101
web/src/i18n/index.jsx
Normal file
101
web/src/i18n/index.jsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { createContext, useContext, useState, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import en from './en'
|
||||
import fr from './fr'
|
||||
import { getLayout, getLayoutList } from './keyboards'
|
||||
import api from '../api/client'
|
||||
|
||||
const translations = { en, fr }
|
||||
|
||||
const STORAGE_KEY_LANG = 'muyue-language'
|
||||
const STORAGE_KEY_KBD = 'muyue-keyboard'
|
||||
|
||||
const I18nContext = createContext(null)
|
||||
|
||||
function resolveLocale(layout) {
|
||||
const l = getLayout(layout)
|
||||
return l.locale
|
||||
}
|
||||
|
||||
export function I18nProvider({ children }) {
|
||||
const [language, setLanguageState] = useState(() => localStorage.getItem(STORAGE_KEY_LANG) || 'fr')
|
||||
const [keyboard, setKeyboardState] = useState(() => localStorage.getItem(STORAGE_KEY_KBD) || 'azerty')
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const pendingSave = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.getConfig()
|
||||
.then(d => {
|
||||
const prefs = d.profile?.preferences
|
||||
if (prefs?.language) setLanguageState(prefs.language)
|
||||
if (prefs?.keyboard_layout) setKeyboardState(prefs.keyboard_layout)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoaded(true))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaded) return
|
||||
if (pendingSave.current) clearTimeout(pendingSave.current)
|
||||
pendingSave.current = setTimeout(() => {
|
||||
api.savePreferences({ language, keyboard_layout: keyboard }).catch(() => {})
|
||||
}, 500)
|
||||
return () => { if (pendingSave.current) clearTimeout(pendingSave.current) }
|
||||
}, [language, keyboard, loaded])
|
||||
|
||||
const setLanguage = useCallback((lang) => {
|
||||
setLanguageState(lang)
|
||||
localStorage.setItem(STORAGE_KEY_LANG, lang)
|
||||
}, [])
|
||||
|
||||
const setKeyboard = useCallback((kbd) => {
|
||||
setKeyboardState(kbd)
|
||||
localStorage.setItem(STORAGE_KEY_KBD, kbd)
|
||||
}, [])
|
||||
|
||||
const layout = useMemo(() => getLayout(keyboard), [keyboard])
|
||||
|
||||
const t = useCallback((key, params) => {
|
||||
const dict = translations[language] || translations.fr
|
||||
const keys = key.split('.')
|
||||
let value = dict
|
||||
for (const k of keys) {
|
||||
if (value == null) return key
|
||||
value = value[k]
|
||||
}
|
||||
if (typeof value !== 'string') return key
|
||||
if (params) {
|
||||
return Object.entries(params).reduce((str, [k, v]) => str.replace(`{${k}}`, v), value)
|
||||
}
|
||||
return value
|
||||
}, [language])
|
||||
|
||||
const clockLocale = useMemo(() => resolveLocale(keyboard), [keyboard])
|
||||
|
||||
const contextValue = useMemo(() => ({
|
||||
language,
|
||||
keyboard,
|
||||
layout,
|
||||
setLanguage,
|
||||
setKeyboard,
|
||||
t,
|
||||
clockLocale,
|
||||
layouts: getLayoutList(),
|
||||
}), [language, keyboard, layout, t, clockLocale])
|
||||
|
||||
return (
|
||||
<I18nContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</I18nContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useI18n() {
|
||||
const ctx = useContext(I18nContext)
|
||||
if (!ctx) throw new Error('useI18n must be used within I18nProvider')
|
||||
return ctx
|
||||
}
|
||||
|
||||
export const LANGUAGES = [
|
||||
{ id: 'fr', name: 'Fran\u00e7ais' },
|
||||
{ id: 'en', name: 'English' },
|
||||
]
|
||||
61
web/src/i18n/keyboards.js
Normal file
61
web/src/i18n/keyboards.js
Normal file
@@ -0,0 +1,61 @@
|
||||
export const LAYOUTS = {
|
||||
qwerty: {
|
||||
id: 'qwerty',
|
||||
name: 'QWERTY',
|
||||
locale: 'en-US',
|
||||
keys: {
|
||||
tab1: '1',
|
||||
tab2: '2',
|
||||
tab3: '3',
|
||||
tab4: '4',
|
||||
ctrl: 'Ctrl',
|
||||
enter: 'Enter',
|
||||
shift: 'Shift',
|
||||
up: '\u2191',
|
||||
down: '\u2193',
|
||||
range: '1-4',
|
||||
},
|
||||
},
|
||||
azerty: {
|
||||
id: 'azerty',
|
||||
name: 'AZERTY',
|
||||
locale: 'fr-FR',
|
||||
keys: {
|
||||
tab1: '&',
|
||||
tab2: '\u00e9',
|
||||
tab3: '"',
|
||||
tab4: "'",
|
||||
ctrl: 'Ctrl',
|
||||
enter: 'Entr\u00e9e',
|
||||
shift: 'Maj',
|
||||
up: '\u2191',
|
||||
down: '\u2193',
|
||||
range: '&-\u00e9-"-\'',
|
||||
},
|
||||
},
|
||||
qwertz: {
|
||||
id: 'qwertz',
|
||||
name: 'QWERTZ',
|
||||
locale: 'de-DE',
|
||||
keys: {
|
||||
tab1: '1',
|
||||
tab2: '2',
|
||||
tab3: '3',
|
||||
tab4: '4',
|
||||
ctrl: 'Strg',
|
||||
enter: 'Enter',
|
||||
shift: 'Umschalt',
|
||||
up: '\u2191',
|
||||
down: '\u2193',
|
||||
range: '1-4',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export function getLayout(id) {
|
||||
return LAYOUTS[id] || LAYOUTS.azerty
|
||||
}
|
||||
|
||||
export function getLayoutList() {
|
||||
return Object.values(LAYOUTS)
|
||||
}
|
||||
13
web/src/main.jsx
Normal file
13
web/src/main.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { I18nProvider } from './i18n'
|
||||
import './styles/global.css'
|
||||
import App from './components/App'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<I18nProvider>
|
||||
<App />
|
||||
</I18nProvider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
684
web/src/styles/global.css
Normal file
684
web/src/styles/global.css
Normal file
@@ -0,0 +1,684 @@
|
||||
: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;
|
||||
|
||||
--header-h: 52px;
|
||||
--sidebar-w: 280px;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html, body, #root { height: 100%; width: 100%; overflow: hidden; }
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
::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; }
|
||||
a:hover { color: var(--accent-light); }
|
||||
|
||||
button {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
button:hover:not(:disabled) { background: var(--bg-hover); border-color: var(--accent-dark); color: var(--text-primary); }
|
||||
button:active:not(:disabled) { transform: scale(0.97); }
|
||||
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
button.primary:hover:not(:disabled) { background: var(--accent-bright); border-color: var(--accent-bright); }
|
||||
button.ghost { background: transparent; border-color: transparent; color: var(--text-tertiary); }
|
||||
button.ghost:hover:not(:disabled) { background: var(--bg-hover); color: var(--text-primary); }
|
||||
button.sm { font-size: 12px; padding: 4px 10px; }
|
||||
|
||||
input, textarea {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 12px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
width: 100%;
|
||||
}
|
||||
input:focus, textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--border-accent); }
|
||||
input::placeholder { color: var(--text-disabled); }
|
||||
|
||||
.app-layout { display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
|
||||
|
||||
.header {
|
||||
height: var(--header-h);
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-brand { display: flex; align-items: center; gap: 8px; }
|
||||
.header-logo { font-family: var(--font-mono); font-weight: 900; font-size: 18px; color: var(--accent); letter-spacing: 3px; user-select: none; }
|
||||
.header-version { font-size: 11px; color: var(--accent-dim); font-family: var(--font-mono); }
|
||||
|
||||
.header-nav { display: flex; gap: 4px; margin-left: 32px; }
|
||||
|
||||
.nav-tab {
|
||||
padding: 8px 18px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
user-select: none;
|
||||
}
|
||||
.nav-tab:hover { color: var(--text-primary); background: var(--bg-card); }
|
||||
.nav-tab.active { color: #fff; background: var(--accent); }
|
||||
.tab-icon { display: flex; align-items: center; }
|
||||
|
||||
.header-spacer { flex: 1; }
|
||||
|
||||
.header-indicators { display: flex; align-items: center; gap: 12px; }
|
||||
.indicator { width: 8px; height: 8px; border-radius: 50%; transition: background 0.3s; }
|
||||
.indicator.ok { background: var(--success); }
|
||||
.indicator.warn { background: var(--warning); }
|
||||
.indicator.error { background: var(--error); }
|
||||
.indicator.off { background: var(--text-disabled); }
|
||||
|
||||
.header-clock { font-family: var(--font-mono); font-size: 12px; color: var(--accent); font-weight: 600; }
|
||||
|
||||
.content { flex: 1; overflow: hidden; }
|
||||
|
||||
.statusbar {
|
||||
height: 28px;
|
||||
background: var(--bg-surface);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-disabled);
|
||||
}
|
||||
.statusbar-left, .statusbar-right { display: flex; align-items: center; gap: 12px; }
|
||||
.statusbar-shortcut { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.statusbar-shortcut kbd {
|
||||
display: inline-block; padding: 1px 5px; border-radius: 3px;
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
font-family: var(--font-mono); font-size: 10px; color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.card:hover { border-color: var(--accent-dim); }
|
||||
.card-header {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
margin-bottom: 16px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge.ok { background: rgba(0,230,118,0.15); color: var(--success); }
|
||||
.badge.error { background: rgba(255,23,68,0.15); color: var(--error); }
|
||||
.badge.warn { background: rgba(255,215,64,0.15); color: var(--warning); }
|
||||
.badge.info { background: rgba(68,138,255,0.15); color: var(--info); }
|
||||
.badge.neutral { background: var(--bg-hover); color: var(--text-tertiary); }
|
||||
.badge.accent { background: var(--accent-bg); color: var(--accent); }
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
.chip:hover { border-color: var(--accent-dark); background: var(--bg-hover); }
|
||||
.chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
|
||||
.progress { height: 6px; background: var(--bg-input); border-radius: 3px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent-light)); border-radius: 3px; transition: width 0.4s ease; }
|
||||
|
||||
.tool-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
}
|
||||
.tool-row:last-child { border-bottom: none; }
|
||||
.tool-name { flex: 1; color: var(--text-primary); font-weight: 500; }
|
||||
.tool-version { color: var(--text-tertiary); font-size: 12px; font-family: var(--font-mono); }
|
||||
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; height: 100%; padding: 20px; overflow: auto; }
|
||||
.split-horizontal { display: flex; height: 100%; }
|
||||
.split-right { width: var(--sidebar-w); border-left: 1px solid var(--border); background: var(--bg-surface); overflow: auto; padding: 16px; }
|
||||
|
||||
.chat-layout { display: flex; flex-direction: column; height: 100%; }
|
||||
.chat-messages { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.message {
|
||||
max-width: 80%;
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
}
|
||||
.message.user { align-self: flex-end; background: var(--accent-bg); color: var(--text-primary); border-bottom-right-radius: 4px; }
|
||||
.message.ai { align-self: flex-start; background: var(--bg-card); color: var(--text-primary); border-bottom-left-radius: 4px; border-left: 3px solid var(--accent); }
|
||||
.chat-input-bar { display: flex; gap: 8px; padding: 16px 20px; border-top: 1px solid var(--border); background: var(--bg-surface); }
|
||||
.chat-input-bar input { flex: 1; }
|
||||
|
||||
.sidebar-nav { display: flex; flex-direction: column; gap: 2px; margin-bottom: 20px; }
|
||||
.sidebar-tab {
|
||||
display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-radius: var(--radius);
|
||||
font-size: 13px; color: var(--text-tertiary); cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.sidebar-tab:hover { background: var(--bg-card); color: var(--text-primary); }
|
||||
.sidebar-tab.active { background: var(--accent); color: #fff; font-weight: 600; }
|
||||
|
||||
.shell-layout { display: flex; height: 100%; }
|
||||
.shell-terminal-col { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
|
||||
.shell-tabs-bar {
|
||||
display: flex; align-items: center; background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border); flex-shrink: 0;
|
||||
height: 36px; padding: 0 8px; gap: 4px;
|
||||
}
|
||||
.shell-tabs {
|
||||
display: flex; align-items: center; gap: 2px; flex: 1; overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.shell-tabs::-webkit-scrollbar { display: none; }
|
||||
|
||||
.shell-tab {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 4px 10px; border-radius: var(--radius) var(--radius) 0 0;
|
||||
font-size: 12px; font-weight: 500; color: var(--text-tertiary);
|
||||
cursor: pointer; transition: all 0.15s; user-select: none;
|
||||
border: 1px solid transparent; border-bottom: none;
|
||||
white-space: nowrap; max-width: 180px; position: relative;
|
||||
background: transparent;
|
||||
}
|
||||
.shell-tab:hover { color: var(--text-primary); background: var(--bg-card); }
|
||||
.shell-tab.active {
|
||||
color: var(--text-primary); background: var(--bg);
|
||||
border-color: var(--border); border-bottom-color: var(--bg);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.shell-tab-name {
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
max-width: 120px; font-size: 12px;
|
||||
}
|
||||
.shell-tab-index {
|
||||
font-size: 9px; color: var(--text-disabled); font-family: var(--font-mono);
|
||||
padding: 0 3px; background: var(--bg-input); border-radius: 3px; line-height: 1.4;
|
||||
}
|
||||
.shell-tab-close {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 16px; height: 16px; border-radius: 3px; border: none;
|
||||
background: transparent; color: var(--text-disabled); cursor: pointer;
|
||||
padding: 0; transition: all 0.1s; flex-shrink: 0;
|
||||
}
|
||||
.shell-tab-close:hover { background: var(--accent-bg); color: var(--accent); }
|
||||
|
||||
.shell-tab-rename {
|
||||
width: 80px; font-size: 12px; padding: 1px 4px; border-radius: 3px;
|
||||
background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--accent);
|
||||
outline: none; font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.shell-tab-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
|
||||
|
||||
.shell-new-tab-wrapper { position: relative; }
|
||||
.shell-new-tab-btn {
|
||||
display: flex; align-items: center; gap: 2px;
|
||||
padding: 4px 8px; border-radius: var(--radius);
|
||||
background: transparent; border: 1px solid var(--border);
|
||||
color: var(--text-tertiary); cursor: pointer; transition: all 0.15s;
|
||||
font-size: 12px;
|
||||
}
|
||||
.shell-new-tab-btn:hover { color: var(--text-primary); background: var(--bg-card); border-color: var(--accent-dark); }
|
||||
|
||||
.shell-menu-overlay {
|
||||
position: fixed; inset: 0; z-index: 998;
|
||||
}
|
||||
.shell-new-tab-menu {
|
||||
position: absolute; top: 100%; right: 0; z-index: 999;
|
||||
background: var(--bg-elevated); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg); padding: 6px;
|
||||
min-width: 260px; max-height: 400px; overflow-y: auto;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||||
}
|
||||
.shell-menu-label {
|
||||
font-size: 10px; font-weight: 700; color: var(--text-disabled);
|
||||
text-transform: uppercase; letter-spacing: 0.5px;
|
||||
padding: 6px 10px 4px;
|
||||
}
|
||||
.shell-menu-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
width: 100%; padding: 7px 10px; border-radius: var(--radius);
|
||||
background: transparent; border: none; color: var(--text-secondary);
|
||||
cursor: pointer; transition: all 0.1s; font-size: 12px;
|
||||
text-align: left; font-family: var(--font-sans);
|
||||
}
|
||||
.shell-menu-item:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
.shell-menu-item.accent { color: var(--accent); }
|
||||
.shell-menu-item.accent:hover { background: var(--accent-bg); }
|
||||
.shell-menu-item-sub {
|
||||
font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono);
|
||||
margin-left: auto;
|
||||
}
|
||||
.shell-menu-item-row { display: flex; align-items: center; }
|
||||
.shell-menu-item-icon {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 24px; height: 24px; border-radius: var(--radius);
|
||||
background: transparent; border: none; color: var(--text-disabled);
|
||||
cursor: pointer; transition: all 0.1s; flex-shrink: 0;
|
||||
}
|
||||
.shell-menu-item-icon:hover { background: var(--accent-bg); color: var(--accent); }
|
||||
.shell-menu-empty {
|
||||
font-size: 12px; color: var(--text-disabled); padding: 8px 10px;
|
||||
font-style: italic;
|
||||
}
|
||||
.shell-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; }
|
||||
|
||||
.shell-xterm-wrapper { flex: 1; background: var(--bg); overflow: hidden; position: relative; }
|
||||
.shell-xterm-instance {
|
||||
position: absolute; inset: 0; padding: 4px;
|
||||
}
|
||||
.shell-xterm-instance .xterm { height: 100%; padding: 4px; }
|
||||
|
||||
.shell-ai-col { width: 340px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; }
|
||||
.connection-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
||||
.connection-dot.on { background: var(--success); box-shadow: 0 0 6px var(--success); }
|
||||
.connection-dot.off { background: var(--error); }
|
||||
|
||||
.shell-modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 1000;
|
||||
}
|
||||
.shell-modal {
|
||||
background: var(--bg-elevated); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg); min-width: 380px; max-width: 480px;
|
||||
box-shadow: 0 16px 48px rgba(0,0,0,0.5);
|
||||
}
|
||||
.shell-modal-header {
|
||||
padding: 16px 20px; font-size: 14px; font-weight: 700;
|
||||
color: var(--text-primary); border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.shell-modal-body { padding: 16px 20px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.shell-modal-label { font-size: 11px; font-weight: 600; color: var(--text-tertiary); margin-bottom: 2px; }
|
||||
.shell-modal-row { display: grid; grid-template-columns: 1fr 2fr; gap: 12px; }
|
||||
.shell-modal-field { display: flex; flex-direction: column; }
|
||||
.shell-modal-footer {
|
||||
padding: 12px 20px; border-top: 1px solid var(--border);
|
||||
display: flex; justify-content: flex-end; gap: 8px;
|
||||
}
|
||||
|
||||
.config-layout { max-width: 840px; margin: 0 auto; padding: 24px; overflow-y: auto; height: 100%; position: relative; }
|
||||
.config-section { margin-bottom: 28px; }
|
||||
.config-section-title {
|
||||
font-size: 12px; font-weight: 700; color: var(--accent); text-transform: uppercase;
|
||||
letter-spacing: 1px; margin-bottom: 16px; padding-bottom: 8px; border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.field-row { display: flex; align-items: center; padding: 10px 0; border-bottom: 1px solid var(--border); gap: 12px; }
|
||||
.field-row:last-child { border-bottom: none; }
|
||||
.field-label { width: 140px; flex-shrink: 0; color: var(--text-tertiary); font-size: 13px; }
|
||||
.field-value { color: var(--text-primary); font-size: 14px; flex: 1; }
|
||||
.field-value.empty { color: var(--text-disabled); font-style: italic; }
|
||||
.config-input { flex: 1; background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--radius); padding: 6px 10px; color: var(--text-primary); font-size: 13px; outline: none; font-family: var(--font-mono); }
|
||||
.config-input:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--border-accent); }
|
||||
.config-form-actions { display: flex; gap: 8px; padding: 12px 0 0 152px; }
|
||||
.config-actions-row { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
.config-stats { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
.config-update-list { display: flex; flex-direction: column; gap: 2px; }
|
||||
.config-update-row { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-radius: var(--radius); }
|
||||
.config-update-row:hover { background: var(--bg-card); }
|
||||
.config-update-info { display: flex; align-items: center; gap: 16px; flex: 1; }
|
||||
.config-update-name { color: var(--text-primary); font-weight: 600; font-size: 13px; min-width: 100px; }
|
||||
.config-update-versions { color: var(--text-tertiary); font-size: 12px; font-family: var(--font-mono); }
|
||||
.config-skill-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
|
||||
.config-skill-row:last-child { border-bottom: none; }
|
||||
.config-skill-name { color: var(--text-primary); font-weight: 600; min-width: 120px; }
|
||||
.config-skill-desc { color: var(--text-tertiary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.chip-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.config-toast {
|
||||
position: fixed; bottom: 40px; left: 50%; transform: translateX(-50%);
|
||||
background: var(--accent); color: #fff; padding: 10px 24px; border-radius: var(--radius-lg);
|
||||
font-size: 13px; font-weight: 600; z-index: 100; animation: fadeIn 0.2s ease-out;
|
||||
box-shadow: 0 4px 24px rgba(255, 0, 51, 0.3);
|
||||
}
|
||||
|
||||
.provider-card {
|
||||
background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 14px 16px; margin-bottom: 8px; display: flex; align-items: center; justify-content: space-between;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.provider-card:hover { border-color: var(--accent-dim); }
|
||||
.provider-info { display: flex; flex-direction: column; gap: 4px; }
|
||||
.provider-name { font-weight: 600; color: var(--text-primary); font-size: 14px; }
|
||||
.provider-meta { display: flex; gap: 12px; font-size: 12px; color: var(--text-tertiary); font-family: var(--font-mono); }
|
||||
|
||||
|
||||
|
||||
.section-title { font-size: 12px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 12px; }
|
||||
.actions-stack { display: flex; flex-direction: column; gap: 6px; }
|
||||
|
||||
.agent-card { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: var(--radius); background: var(--bg-card); margin-bottom: 6px; }
|
||||
.agent-avatar {
|
||||
width: 32px; height: 32px; border-radius: 50%; background: var(--accent-bg); color: var(--accent);
|
||||
display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 13px; flex-shrink: 0;
|
||||
}
|
||||
.agent-name { font-weight: 600; color: var(--text-primary); font-size: 13px; }
|
||||
.agent-status { font-size: 11px; color: var(--text-tertiary); margin-top: 2px; }
|
||||
|
||||
.ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); }
|
||||
.ai-panel-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.ai-message { padding: 8px 12px; border-radius: var(--radius); font-size: 13px; line-height: 1.5; word-break: break-word; }
|
||||
.ai-message.ai { background: var(--bg-card); border-left: 3px solid var(--accent); }
|
||||
.ai-message.user { background: var(--accent-bg); border-left: 3px solid var(--accent-muted); }
|
||||
.ai-panel-input { display: flex; gap: 6px; padding: 10px 12px; border-top: 1px solid var(--border); }
|
||||
.ai-panel-input input { flex: 1; font-size: 13px; padding: 6px 10px; }
|
||||
|
||||
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; color: var(--text-disabled); font-size: 13px; text-align: center; gap: 8px; }
|
||||
|
||||
.dashboard-layout { display: flex; flex-direction: column; height: 100%; }
|
||||
.dashboard-content { flex: 1; overflow-y: auto; }
|
||||
.dashboard-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; padding: 24px; }
|
||||
|
||||
.dashboard-section {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.dashboard-section:hover { border-color: var(--accent-dim); }
|
||||
.dashboard-section.full-width { grid-column: 1 / -1; }
|
||||
.dashboard-section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
||||
.dashboard-section-title {
|
||||
font-size: 12px; font-weight: 700; color: var(--accent); text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.dashboard-workflows-inline { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||
|
||||
.dashboard-notifications-inline { display: flex; flex-direction: column; gap: 2px; }
|
||||
|
||||
.dashboard-tools { padding: 0; }
|
||||
.tools-compact { display: flex; flex-direction: column; gap: 2px; }
|
||||
.tool-compact-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 6px 12px; border-radius: var(--radius);
|
||||
font-size: 13px; transition: background 0.1s;
|
||||
}
|
||||
.tool-compact-row:hover { background: var(--bg-card); }
|
||||
.badge.sm { padding: 1px 5px; font-size: 10px; }
|
||||
.tool-compact-name { color: var(--text-primary); font-weight: 500; flex: 1; }
|
||||
.tool-compact-ver { color: var(--text-tertiary); font-size: 11px; font-family: var(--font-mono); }
|
||||
.tool-compact-installed { color: var(--success); font-size: 11px; font-family: var(--font-mono); opacity: 0.7; }
|
||||
|
||||
.dashboard-notifications { padding: 0; }
|
||||
.notif-row {
|
||||
display: flex; align-items: flex-start; gap: 12px;
|
||||
padding: 8px 12px; border-radius: var(--radius); margin-bottom: 4px;
|
||||
}
|
||||
.notif-row:hover { background: var(--bg-card); }
|
||||
.notif-time { color: var(--text-disabled); font-size: 11px; font-family: var(--font-mono); flex-shrink: 0; padding-top: 1px; }
|
||||
.notif-text { font-size: 13px; color: var(--text-secondary); }
|
||||
.notif-info .notif-text { color: var(--info); }
|
||||
.notif-ok .notif-text { color: var(--success); }
|
||||
.notif-warn .notif-text { color: var(--warning); }
|
||||
.notif-error .notif-text { color: var(--error); }
|
||||
|
||||
.dashboard-workflows { padding: 0; display: flex; flex-direction: column; gap: 24px; }
|
||||
.workflow-section { }
|
||||
.section-label {
|
||||
font-size: 11px; font-weight: 700; color: var(--accent); text-transform: uppercase;
|
||||
letter-spacing: 1px; margin-bottom: 12px; padding-bottom: 6px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex; align-items: center; justify-content: space-between; padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--border); background: var(--bg-surface);
|
||||
}
|
||||
.panel-title { font-weight: 700; font-size: 13px; color: var(--accent); display: flex; align-items: center; gap: 8px; }
|
||||
.panel-subtitle { font-weight: 400; font-size: 12px; color: var(--text-tertiary); margin-left: 8px; }
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.spinner { width: 16px; height: 16px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.6s linear infinite; display: inline-block; vertical-align: middle; }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.fade-in { animation: fadeIn 0.2s ease-out; }
|
||||
|
||||
/* ── Studio ── */
|
||||
.studio-layout { display: flex; height: 100%; overflow: hidden; }
|
||||
.studio-chat-area { display: flex; flex-direction: column; flex: 1; min-width: 0; }
|
||||
.studio-messages { flex: 1; overflow-y: auto; padding: 24px 20px; display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.studio-msg { display: flex; gap: 10px; max-width: 85%; animation: fadeIn 0.2s ease-out; }
|
||||
.studio-msg.user { align-self: flex-end; flex-direction: row-reverse; }
|
||||
.studio-msg.ai { align-self: flex-start; }
|
||||
|
||||
.studio-msg-avatar {
|
||||
width: 28px; height: 28px; border-radius: 50%; background: var(--accent-bg); color: var(--accent);
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
.studio-msg-body { display: flex; flex-direction: column; gap: 0; }
|
||||
.studio-msg-content { font-size: 14px; line-height: 1.7; color: var(--text-primary); word-break: break-word; }
|
||||
|
||||
.studio-code-block {
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
overflow: hidden; margin: 8px 0;
|
||||
}
|
||||
.studio-code-block pre { padding: 12px 16px; font-family: var(--font-mono); font-size: 13px; line-height: 1.5; overflow-x: auto; color: var(--text-primary); margin: 0; }
|
||||
.studio-code-lang {
|
||||
padding: 4px 12px; font-size: 11px; font-weight: 600; color: var(--text-tertiary);
|
||||
background: var(--bg-surface); border-bottom: 1px solid var(--border); text-transform: uppercase; letter-spacing: 0.5px;
|
||||
}
|
||||
.inline-code { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 13px; color: var(--accent-muted); }
|
||||
|
||||
.msg-h3 { font-size: 16px; font-weight: 700; color: var(--text-primary); margin: 16px 0 8px; display: block; }
|
||||
.msg-h4 { font-size: 14px; font-weight: 700; color: var(--text-secondary); margin: 12px 0 6px; display: block; }
|
||||
.msg-bullet { display: block; padding-left: 16px; position: relative; margin: 2px 0; }
|
||||
.msg-bullet::before { content: '\2022'; position: absolute; left: 4px; color: var(--accent); }
|
||||
.msg-step { display: flex; gap: 8px; align-items: baseline; margin: 3px 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-msg-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||||
|
||||
.studio-plan-chip {
|
||||
display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border-radius: var(--radius);
|
||||
background: var(--bg-card); border: 1px solid var(--border); font-size: 12px; color: var(--text-secondary);
|
||||
cursor: pointer; transition: all 0.15s; user-select: none;
|
||||
}
|
||||
.studio-plan-chip:hover { border-color: var(--accent-dark); background: var(--bg-hover); color: var(--text-primary); }
|
||||
.studio-expand-icon { font-size: 9px; color: var(--text-tertiary); margin-left: 4px; }
|
||||
|
||||
.studio-agent-tag {
|
||||
display: inline-flex; align-items: center; padding: 3px 8px; border-radius: 99px;
|
||||
background: rgba(68,138,255,0.12); color: var(--info); font-size: 11px; font-weight: 600;
|
||||
}
|
||||
|
||||
.studio-plan-detail {
|
||||
margin-top: 8px; border: 1px solid var(--border); border-radius: var(--radius);
|
||||
background: var(--bg-surface); overflow: hidden;
|
||||
}
|
||||
.studio-plan-detail-header { padding: 10px 14px; font-size: 12px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid var(--border); }
|
||||
.studio-steps { display: flex; flex-direction: column; gap: 2px; padding: 8px 0; }
|
||||
.studio-step { display: flex; gap: 10px; align-items: baseline; padding: 4px 14px; }
|
||||
.studio-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); font-size: 13px; flex-shrink: 0; min-width: 24px; }
|
||||
.studio-step-text { font-size: 13px; color: var(--text-secondary); line-height: 1.5; }
|
||||
.studio-plan-raw { padding: 8px 14px 12px; border-top: 1px solid var(--border); }
|
||||
.studio-plan-raw pre { font-family: var(--font-mono); font-size: 12px; color: var(--text-tertiary); white-space: pre-wrap; word-break: break-word; margin: 0; line-height: 1.5; }
|
||||
|
||||
.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; }
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
|
||||
.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 bounce { 0%, 80%, 100% { transform: translateY(0); opacity: 0.4; } 40% { transform: translateY(-6px); opacity: 1; } }
|
||||
|
||||
.studio-input-area { padding: 12px 20px 8px; border-top: 1px solid var(--border); background: var(--bg-surface); }
|
||||
.studio-input-row { display: flex; gap: 8px; align-items: flex-end; }
|
||||
.studio-input-row textarea {
|
||||
flex: 1; resize: none; min-height: 42px; max-height: 200px; 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:not(:disabled) { background: var(--accent-bright); border-color: var(--accent-bright); }
|
||||
.studio-send-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
|
||||
.studio-input-hint { font-size: 11px; color: var(--text-disabled); text-align: center; margin-top: 6px; }
|
||||
|
||||
/* ── Studio Sidebar ── */
|
||||
.studio-sidebar {
|
||||
width: 0; border-left: 1px solid var(--border); background: var(--bg-surface);
|
||||
overflow: hidden; transition: width 0.25s ease; flex-shrink: 0; display: flex; flex-direction: column;
|
||||
}
|
||||
.studio-sidebar.open { width: 300px; }
|
||||
|
||||
.studio-sidebar-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
|
||||
.studio-sidebar-header span { font-size: 13px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.studio-sidebar-toggle { font-size: 18px; padding: 0 6px; line-height: 1; }
|
||||
|
||||
.studio-context { display: flex; flex-direction: column; flex: 1; overflow: hidden; }
|
||||
.studio-context-tabs { display: flex; border-bottom: 1px solid var(--border); flex-shrink: 0; }
|
||||
.studio-context-tab {
|
||||
flex: 1; padding: 9px 8px; font-size: 12px; font-weight: 600; color: var(--text-tertiary);
|
||||
cursor: pointer; text-align: center; transition: all 0.15s; border-bottom: 2px solid transparent; user-select: none;
|
||||
}
|
||||
.studio-context-tab:hover { color: var(--text-primary); background: var(--bg-card); }
|
||||
.studio-context-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
.studio-tab-count { font-size: 10px; padding: 1px 5px; border-radius: 99px; background: var(--bg-card); color: var(--text-tertiary); font-family: var(--font-mono); margin-left: 4px; }
|
||||
|
||||
.studio-context-body { flex: 1; overflow-y: auto; padding: 12px; }
|
||||
|
||||
.studio-plan-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.studio-plan-item {
|
||||
display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: var(--radius);
|
||||
cursor: pointer; transition: all 0.15s; font-size: 13px; color: var(--text-secondary);
|
||||
}
|
||||
.studio-plan-item:hover { background: var(--bg-card); color: var(--text-primary); }
|
||||
.studio-plan-item.active { background: var(--accent-bg); border-left: 2px solid var(--accent); }
|
||||
.studio-plan-item svg { flex-shrink: 0; color: var(--text-tertiary); }
|
||||
.studio-plan-item-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.studio-plan-item-badge { font-size: 11px; color: var(--text-disabled); font-family: var(--font-mono); flex-shrink: 0; }
|
||||
|
||||
.studio-agent-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.studio-agent-item { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: var(--radius); }
|
||||
.studio-agent-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--info); flex-shrink: 0; }
|
||||
.studio-agent-name { font-size: 13px; color: var(--text-secondary); flex: 1; }
|
||||
|
||||
.studio-empty { display: flex; align-items: center; justify-content: center; padding: 32px 16px; color: var(--text-disabled); font-size: 12px; text-align: center; line-height: 1.6; }
|
||||
|
||||
.studio-activity-list { display: flex; flex-direction: column; gap: 2px; }
|
||||
.studio-activity-item { display: flex; gap: 8px; padding: 6px 10px; border-radius: var(--radius); font-size: 12px; }
|
||||
.studio-activity-item:hover { background: var(--bg-card); }
|
||||
.studio-activity-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; margin-top: 5px; }
|
||||
.studio-activity-dot.user { background: var(--accent-muted); }
|
||||
.studio-activity-dot.ai { background: var(--info); }
|
||||
.studio-activity-text { color: var(--text-tertiary); line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
129
web/src/themes/index.js
Normal file
129
web/src/themes/index.js
Normal file
@@ -0,0 +1,129 @@
|
||||
const defaultTheme = {
|
||||
name: 'Cyberpunk Red',
|
||||
colors: {
|
||||
bg: '#0A0A0C',
|
||||
bgBase: '#0F0D10',
|
||||
bgSurface: '#161218',
|
||||
bgElevated: '#1C1719',
|
||||
bgCard: '#221B1E',
|
||||
bgInput: '#2A2225',
|
||||
bgHover: '#332528',
|
||||
accent: '#FF0033',
|
||||
accentDark: '#8B0020',
|
||||
accentDeep: '#5C0015',
|
||||
accentLight: '#FF1A5E',
|
||||
accentMuted: '#FF4D6D',
|
||||
accentBright: '#FF1744',
|
||||
accentSoft: '#FF5252',
|
||||
accentDim: '#6B2033',
|
||||
accentBg: '#4A1525',
|
||||
textPrimary: '#EAE0E2',
|
||||
textSecondary: '#D4C4C8',
|
||||
textTertiary: '#8A7A7E',
|
||||
textDisabled: '#5A4F52',
|
||||
success: '#00E676',
|
||||
warning: '#FFD740',
|
||||
error: '#FF1744',
|
||||
info: '#448AFF',
|
||||
border: '#2A1F22',
|
||||
borderAccent: '#FF003344',
|
||||
borderAccentFull: '#FF0033',
|
||||
},
|
||||
}
|
||||
|
||||
const themes = {
|
||||
'cyberpunk-red': defaultTheme,
|
||||
'cyberpunk-pink': {
|
||||
...defaultTheme,
|
||||
name: 'Cyberpunk Pink',
|
||||
colors: {
|
||||
...defaultTheme.colors,
|
||||
accent: '#FF1A8C',
|
||||
accentDark: '#8B1050',
|
||||
accentDeep: '#5C0A35',
|
||||
accentLight: '#FF4DAE',
|
||||
accentMuted: '#FF6DC2',
|
||||
accentBright: '#FF1A8C',
|
||||
accentSoft: '#FF6DC2',
|
||||
accentDim: '#6B2050',
|
||||
accentBg: '#4A1535',
|
||||
},
|
||||
},
|
||||
'midnight-blue': {
|
||||
...defaultTheme,
|
||||
name: 'Midnight Blue',
|
||||
colors: {
|
||||
...defaultTheme.colors,
|
||||
accent: '#0088FF',
|
||||
accentDark: '#004488',
|
||||
accentDeep: '#002255',
|
||||
accentLight: '#00AAFF',
|
||||
accentMuted: '#44CCFF',
|
||||
accentBright: '#0088FF',
|
||||
accentSoft: '#44CCFF',
|
||||
accentDim: '#203366',
|
||||
accentBg: '#152244',
|
||||
},
|
||||
},
|
||||
'matrix-green': {
|
||||
...defaultTheme,
|
||||
name: 'Matrix Green',
|
||||
colors: {
|
||||
...defaultTheme.colors,
|
||||
accent: '#00FF41',
|
||||
accentDark: '#008822',
|
||||
accentDeep: '#005515',
|
||||
accentLight: '#33FF66',
|
||||
accentMuted: '#66FF99',
|
||||
accentBright: '#00FF41',
|
||||
accentSoft: '#66FF99',
|
||||
accentDim: '#206630',
|
||||
accentBg: '#154420',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export function getTheme(name) {
|
||||
return themes[name] || defaultTheme
|
||||
}
|
||||
|
||||
export function getThemeNames() {
|
||||
return Object.keys(themes).map(k => ({ id: k, name: themes[k].name }))
|
||||
}
|
||||
|
||||
export function applyTheme(theme) {
|
||||
const root = document.documentElement
|
||||
const c = theme.colors
|
||||
const map = {
|
||||
'--bg': c.bg,
|
||||
'--bg-base': c.bgBase,
|
||||
'--bg-surface': c.bgSurface,
|
||||
'--bg-elevated': c.bgElevated,
|
||||
'--bg-card': c.bgCard,
|
||||
'--bg-input': c.bgInput,
|
||||
'--bg-hover': c.bgHover,
|
||||
'--accent': c.accent,
|
||||
'--accent-dark': c.accentDark,
|
||||
'--accent-deep': c.accentDeep,
|
||||
'--accent-light': c.accentLight,
|
||||
'--accent-muted': c.accentMuted,
|
||||
'--accent-bright': c.accentBright,
|
||||
'--accent-soft': c.accentSoft,
|
||||
'--accent-dim': c.accentDim,
|
||||
'--accent-bg': c.accentBg,
|
||||
'--text-primary': c.textPrimary,
|
||||
'--text-secondary': c.textSecondary,
|
||||
'--text-tertiary': c.textTertiary,
|
||||
'--text-disabled': c.textDisabled,
|
||||
'--success': c.success,
|
||||
'--warning': c.warning,
|
||||
'--error': c.error,
|
||||
'--info': c.info,
|
||||
'--border': c.border,
|
||||
'--border-accent': c.borderAccent,
|
||||
'--border-accent-full': c.borderAccentFull,
|
||||
}
|
||||
Object.entries(map).forEach(([k, v]) => root.style.setProperty(k, v))
|
||||
}
|
||||
|
||||
export default defaultTheme
|
||||
19
web/vite.config.js
Normal file
19
web/vite.config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8095',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user