Compare commits
39 Commits
v0.2.1-bet
...
v0.3.2-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58f8cb0bd3 | ||
|
|
b52feccc17 | ||
|
|
5bbac499a7 | ||
|
|
83d7a573c7 | ||
|
|
0fe82f67df | ||
|
|
4b9f2c377d | ||
|
|
95bd824259 | ||
|
|
252f178bbd | ||
|
|
7dcf505360 | ||
|
|
8fb93fa47e | ||
|
|
5ec373cd6a | ||
|
|
1eb5a6d00f | ||
|
|
cd5ebe083c | ||
|
|
2004c15dd7 | ||
|
|
9306152736 | ||
|
|
e15a034de5 | ||
|
|
3b6cc38ea0 | ||
|
|
93a22d4075 | ||
|
|
e0e1e73bca | ||
|
|
0496ca789b | ||
|
|
b407ab879b | ||
|
|
12df184e11 | ||
|
|
8af6d25e28 | ||
|
|
4fd599adec | ||
|
|
bcba5932d5 | ||
|
|
04b0fff791 | ||
|
|
80c11cab3f | ||
|
|
0b221094f2 | ||
|
|
7f674730c7 | ||
|
|
040e482c75 | ||
|
|
c8903efa5e | ||
|
|
f3cb306053 | ||
|
|
3cdcb22068 | ||
|
|
ee18bbeb53 | ||
|
|
b0b0e1d308 | ||
|
|
fc7981037f | ||
|
|
f7222b0f6c | ||
|
|
11417d3ea7 | ||
|
|
3dc24ae22c |
@@ -15,7 +15,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24.3'
|
||||
go-version: '1.24'
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24.3'
|
||||
go-version: '1.24'
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24.3'
|
||||
go-version: '1.24'
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
125
CHANGELOG.md
125
CHANGELOG.md
@@ -4,53 +4,121 @@ 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/).
|
||||
|
||||
## v0.2.1
|
||||
## v0.3.0
|
||||
|
||||
### Changes since v0.2.1
|
||||
|
||||
- fix(terminal): resolve PTY shell exec error, simplify CLI, unify Config tabs, restore Studio CSS (0b22109)
|
||||
- feat: add API key validation flow for AI provider config (7f67473)
|
||||
- feat(studio): replace sidebar layout with unified execution feed styles (040e482)
|
||||
- fix: guard against empty tabs array in closeTab (c8903ef)
|
||||
- refactor: redesign Config as settings window with sidebar panels, remove system overview from Dashboard (f3cb306)
|
||||
- feat: add multi-tab terminal with SSH support, config editing, and dashboard redesign (3cdcb22)
|
||||
- feat(studio): add i18n keys and CSS for redesigned AI chat interface (ee18bbe)
|
||||
- chore: bump version to 0.3.0 (b0b0e1d)
|
||||
- chore: remove dead code (packages, functions, types, constants) (fc79810)
|
||||
- docs: rewrite README and CHANGELOG for desktop app mode (f7222b0)
|
||||
- feat(web): add i18n support with FR/EN locales and keyboard layout awareness (11417d3)
|
||||
- refactor(web): redesign frontend for native web UX (3dc24ae)
|
||||
- refactor: remove TUI, desktop web UI is now the default and only mode (aa0ff19)
|
||||
- refactor: unify into single `muyue` binary with embedded desktop mode (3463605)
|
||||
- fix(ci): add frontend build step before Go vet/test/build (097cf40)
|
||||
- feat: add desktop app with React frontend, API backend, theme system (#2) (88d2a03)
|
||||
- chore: update CHANGELOG for v0.2.1 (1830c18)
|
||||
- feat: complete TUI redesign with cyberpunk theme (#1) (cb8e3d0)
|
||||
|
||||
### 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) |
|
||||
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-linux-amd64.tar.gz) |
|
||||
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-linux-arm64.tar.gz) |
|
||||
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-darwin-amd64.tar.gz) |
|
||||
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-darwin-arm64.tar.gz) |
|
||||
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-windows-amd64.zip) |
|
||||
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-windows-arm64.zip) |
|
||||
|
||||
The binary includes both CLI and Desktop modes.
|
||||
Run `muyue` for TUI, `muyue desktop` for web UI.
|
||||
|
||||
### 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
|
||||
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.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.1/muyue-darwin-arm64.tar.gz | tar xz
|
||||
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.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.1/muyue-windows-amd64.zip" -OutFile "muyue.zip"
|
||||
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-windows-amd64.zip" -OutFile "muyue.zip"
|
||||
Expand-Archive -Path "muyue.zip" -DestinationPath "."
|
||||
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
|
||||
```
|
||||
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Security
|
||||
|
||||
- **Command injection**: Removed non-functional AI sidebar from Shell.jsx that interpolated user input directly into a shell command (`echo "AI: ${text}"`). The panel was a stub with no real AI integration.
|
||||
- **WebSocket origin validation**: Terminal WebSocket handler now validates the `Origin` header matches the server's own host.
|
||||
- **DELETE method guard**: Terminal sessions DELETE endpoint now rejects non-DELETE methods.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Message ID collisions**: `generateMsgID()` now appends nanosecond suffix to prevent collisions under rapid creation.
|
||||
- **Legacy dir migration**: Config migration from `~/.muyue` to XDG path now logs errors instead of silently failing.
|
||||
- **MCP JSON parsing**: `json.Unmarshal` errors in MCP config loading are now handled instead of ignored.
|
||||
- **API header merging**: `client.js` `request()` now correctly merges caller headers with defaults (was overwriting `Content-Type`).
|
||||
- **Variable shadowing**: `t` translation function shadowed by `.filter(t => ...)` in Config.jsx and App.jsx — renamed to `tool`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Real SSE streaming**: Chat endpoint now streams AI responses via SSE (`data: {"content":"..."}` chunks) instead of fake 8-rune chunking. Frontend renders responses progressively as they arrive.
|
||||
- **Progressive rendering**: Studio.jsx now uses `StreamingItem` component to display partial AI output during streaming, with cursor animation.
|
||||
- **Theme from config**: App.jsx loads theme from user profile preferences on startup (was hardcoded to `cyberpunk-red`).
|
||||
- **Handlers split**: Monolithic `handlers.go` split into 6 focused files: `handlers_common.go`, `handlers_info.go`, `handlers_tools.go`, `handlers_config.go`, `handlers_chat.go`, `handlers_terminal.go`.
|
||||
- **Dynamic version**: Config `Version` field now uses `version.Version` constant instead of hardcoded `"0.1.0"`.
|
||||
- **Path construction**: `filepath.Join` used consistently in installer, MCP, scanner, and profiler for cross-platform safety.
|
||||
- **CI Go version**: All 3 CI workflows updated from `go-version: '1.24.3'` to `'1.24'` to match `go.mod`.
|
||||
- **Dead code removed**: Unused `addNotif` function in Dashboard.jsx, unused `layout` destructuring, dead `tools`/`updates`/`onRescan` props, dead AI sidebar in Shell.jsx, associated CSS and i18n keys.
|
||||
|
||||
### Added
|
||||
|
||||
- **SendStream tests**: 3 new tests for the SSE streaming method (chunk parsing, history accumulation, API error handling) using `httptest` server.
|
||||
|
||||
- **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
|
||||
|
||||
### Changes since v0.2.0
|
||||
|
||||
- chore: bump version to 0.2.1, update README for TUI redesign (22fb282)
|
||||
|
||||
### Downloads
|
||||
|
||||
| Platform | File |
|
||||
@@ -62,6 +130,11 @@ Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
|
||||
| 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)**
|
||||
@@ -85,9 +158,19 @@ 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)
|
||||
@@ -121,17 +204,6 @@ Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
|
||||
- ci: fix Gitea Actions - native checkout + auto-release on push (78c7239)
|
||||
- ci: migrate workflows to Gitea Actions with self-hosted runner (811a9aa)
|
||||
|
||||
### 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) |
|
||||
|
||||
### Install
|
||||
|
||||
**Linux (x86_64)**
|
||||
@@ -155,7 +227,6 @@ Expand-Archive -Path "muyue.zip" -DestinationPath "."
|
||||
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
|
||||
```
|
||||
|
||||
|
||||
## [0.2.0] - 2026-04-20
|
||||
|
||||
### Added
|
||||
|
||||
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
|
||||
|
||||
@@ -3,103 +3,15 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"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"
|
||||
"github.com/muyue/muyue/internal/orchestrator"
|
||||
"github.com/muyue/muyue/internal/profiler"
|
||||
"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 main() {
|
||||
if len(os.Args) > 1 {
|
||||
if isCommand(os.Args[1]) {
|
||||
handleCommand(os.Args[1:])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
switch args[0] {
|
||||
case "version", "-v", "--version":
|
||||
fmt.Println(version.FullVersion())
|
||||
case "scan":
|
||||
runScan()
|
||||
case "install":
|
||||
runInstall(args[1:])
|
||||
case "update":
|
||||
runUpdate()
|
||||
case "setup":
|
||||
runSetup()
|
||||
case "config":
|
||||
showConfig()
|
||||
case "doctor":
|
||||
runDoctor()
|
||||
case "lsp":
|
||||
runLSP(args[1:])
|
||||
case "mcp":
|
||||
runMCP(args[1:])
|
||||
case "skills":
|
||||
runSkills(args[1:])
|
||||
case "help", "-h", "--help":
|
||||
printHelp()
|
||||
}
|
||||
}
|
||||
|
||||
func printHelp() {
|
||||
fmt.Printf(`%s - AI-powered development environment assistant
|
||||
|
||||
Usage:
|
||||
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
|
||||
install [tools] Install missing tools (needs sudo for some tools)
|
||||
update Check and apply updates for all tools
|
||||
setup Run first-time setup wizard
|
||||
config Show current configuration
|
||||
doctor Check that everything is properly configured
|
||||
lsp [scan|install] Scan or install LSP servers
|
||||
mcp [config|scan] Configure MCP servers for Crush and Claude Code
|
||||
skills [list|generate|deploy|init|delete] Manage AI coding skills
|
||||
help Show this help
|
||||
|
||||
Note:
|
||||
Some tools (docker, gh, etc.) require elevated privileges.
|
||||
Run 'sudo muyue install' or use 'pkexec muyue install' if needed.
|
||||
`, version.FullVersion())
|
||||
}
|
||||
|
||||
func runDesktop(args []string) {
|
||||
cfg := loadOrSetupConfig()
|
||||
if err := desktop.Run(cfg, args); err != nil {
|
||||
if err := desktop.Run(cfg, os.Args[1:]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -140,453 +52,3 @@ func loadOrSetupConfig() *config.MuyueConfig {
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func runScan() {
|
||||
fmt.Println("Scanning system...")
|
||||
result := scanner.ScanSystem()
|
||||
fmt.Println(result.Summary())
|
||||
}
|
||||
|
||||
func runInstall(tools []string) {
|
||||
cfg := loadOrSetupConfig()
|
||||
inst := installer.New(cfg)
|
||||
|
||||
if len(tools) == 0 {
|
||||
result := scanner.ScanSystem()
|
||||
var missing []string
|
||||
for _, t := range result.Tools {
|
||||
if !t.Installed {
|
||||
missing = append(missing, t.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(missing) == 0 {
|
||||
fmt.Println("All tools are installed!")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Missing tools: %v\nInstalling...\n", missing)
|
||||
tools = missing
|
||||
}
|
||||
|
||||
if needsSudo(tools) && os.Geteuid() != 0 {
|
||||
fmt.Println("Some tools require elevated privileges.")
|
||||
if path, err := exec.LookPath("sudo"); err == nil {
|
||||
fmt.Printf("Re-running with sudo...\n")
|
||||
cmd := exec.Command(path, append([]string{os.Args[0], "install"}, tools...)...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "sudo install failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
config.Save(cfg)
|
||||
return
|
||||
}
|
||||
if path, err := exec.LookPath("pkexec"); err == nil {
|
||||
fmt.Printf("Re-running with pkexec...\n")
|
||||
cmd := exec.Command(path, append([]string{os.Args[0], "install"}, tools...)...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "pkexec install failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
config.Save(cfg)
|
||||
return
|
||||
}
|
||||
fmt.Println("Neither sudo nor pkexec found. Some installs may fail.")
|
||||
fmt.Println("Try running: sudo muyue install")
|
||||
}
|
||||
|
||||
results := inst.InstallAll(tools)
|
||||
for _, r := range results {
|
||||
status := "[OK]"
|
||||
if !r.Success {
|
||||
status = "[FAIL]"
|
||||
}
|
||||
fmt.Printf(" %s %s: %s\n", status, r.Tool, r.Message)
|
||||
}
|
||||
|
||||
config.Save(cfg)
|
||||
}
|
||||
|
||||
func needsSudo(tools []string) bool {
|
||||
sudoTools := map[string]bool{
|
||||
"docker": true, "git": true, "gh": true, "node": true, "python": true,
|
||||
}
|
||||
for _, t := range tools {
|
||||
if sudoTools[t] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func runUpdate() {
|
||||
fmt.Println("Checking for updates...")
|
||||
result := scanner.ScanSystem()
|
||||
statuses := updater.CheckUpdates(result)
|
||||
|
||||
needsUpdate := false
|
||||
for _, s := range statuses {
|
||||
if s.NeedsUpdate {
|
||||
fmt.Printf(" [!] %s: %s -> %s\n", s.Tool, s.Current, s.Latest)
|
||||
needsUpdate = true
|
||||
} else if s.Error == "" {
|
||||
fmt.Printf(" [v] %s: up to date (%s)\n", s.Tool, s.Current)
|
||||
} else {
|
||||
fmt.Printf(" [?] %s: %s\n", s.Tool, s.Error)
|
||||
}
|
||||
}
|
||||
|
||||
if needsUpdate {
|
||||
fmt.Println("\nApplying updates...")
|
||||
results := updater.RunAutoUpdate(statuses)
|
||||
for _, r := range results {
|
||||
fmt.Printf(" %s: %s\n", r.Tool, r.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runSetup() {
|
||||
cfg, err := profiler.RunFirstTimeSetup()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Setup error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
for i := range cfg.AI.Providers {
|
||||
if cfg.AI.Providers[i].Active && cfg.AI.Providers[i].APIKey == "" {
|
||||
key, err := profiler.AskAPIKey(cfg.AI.Providers[i].Name)
|
||||
if err == nil && key != "" {
|
||||
cfg.AI.Providers[i].APIKey = key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := config.Save(cfg); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Save error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("Setup complete!")
|
||||
}
|
||||
|
||||
func showConfig() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Config not found. Run `muyue setup` first.\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Profile: %s (%s)\n", cfg.Profile.Name, cfg.Profile.Pseudo)
|
||||
fmt.Printf("Email: %s\n", cfg.Profile.Email)
|
||||
fmt.Printf("Editor: %s\n", cfg.Profile.Preferences.Editor)
|
||||
fmt.Printf("Default AI: %s\n", cfg.Profile.Preferences.DefaultAI)
|
||||
fmt.Printf("Languages: %v\n", cfg.Profile.Languages)
|
||||
|
||||
for _, p := range cfg.AI.Providers {
|
||||
active := ""
|
||||
if p.Active {
|
||||
active = " (active)"
|
||||
}
|
||||
keyStatus := "no key"
|
||||
if p.APIKey != "" {
|
||||
keyStatus = "configured"
|
||||
}
|
||||
fmt.Printf(" %s: model=%s, key=%s%s\n", p.Name, p.Model, keyStatus, active)
|
||||
}
|
||||
|
||||
fmt.Printf("BMAD: installed=%v, global=%v\n", cfg.BMAD.Installed, cfg.BMAD.Global)
|
||||
fmt.Printf("Custom Prompt: %v\n", cfg.Terminal.CustomPrompt)
|
||||
}
|
||||
|
||||
func runDoctor() {
|
||||
ok := true
|
||||
fmt.Println("Running diagnostics...")
|
||||
fmt.Println()
|
||||
|
||||
fmt.Println("Configuration:")
|
||||
if !config.Exists() {
|
||||
fmt.Println(" [FAIL] Config file not found. Run 'muyue setup' first.")
|
||||
ok = false
|
||||
} else {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
fmt.Printf(" [FAIL] Config load error: %v\n", err)
|
||||
ok = false
|
||||
} else {
|
||||
fmt.Println(" [OK] Config file present")
|
||||
hasKey := false
|
||||
for _, p := range cfg.AI.Providers {
|
||||
if p.Active && p.APIKey != "" {
|
||||
hasKey = true
|
||||
}
|
||||
}
|
||||
if hasKey {
|
||||
fmt.Println(" [OK] API key configured")
|
||||
} else {
|
||||
fmt.Println(" [FAIL] No API key set for active provider")
|
||||
ok = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\nTools:")
|
||||
result := scanner.ScanSystem()
|
||||
installed := 0
|
||||
for _, t := range result.Tools {
|
||||
if t.Installed {
|
||||
installed++
|
||||
fmt.Printf(" [OK] %s\n", t.Name)
|
||||
} else {
|
||||
fmt.Printf(" [FAIL] %s (not installed)\n", t.Name)
|
||||
}
|
||||
}
|
||||
fmt.Printf(" Installed: %d/%d\n", installed, len(result.Tools))
|
||||
|
||||
fmt.Println("\nLSP Servers:")
|
||||
servers := lsp.ScanServers()
|
||||
lspOK := 0
|
||||
for _, s := range servers {
|
||||
if s.Installed {
|
||||
lspOK++
|
||||
fmt.Printf(" [OK] %s (%s)\n", s.Name, s.Language)
|
||||
}
|
||||
}
|
||||
fmt.Printf(" Available: %d/%d\n", lspOK, len(servers))
|
||||
|
||||
fmt.Println("\nMCP Servers:")
|
||||
mcpServers := mcp.ScanServers()
|
||||
mcpOK := 0
|
||||
for _, s := range mcpServers {
|
||||
if s.Installed {
|
||||
mcpOK++
|
||||
}
|
||||
}
|
||||
fmt.Printf(" Available: %d/%d\n", mcpOK, len(mcpServers))
|
||||
|
||||
fmt.Println("\nSkills:")
|
||||
skillList, err := skills.List()
|
||||
if err != nil || len(skillList) == 0 {
|
||||
fmt.Println(" [FAIL] No skills. Run 'muyue skills init'.")
|
||||
ok = false
|
||||
} else {
|
||||
fmt.Printf(" [OK] %d skills installed\n", len(skillList))
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
if ok {
|
||||
fmt.Println("All checks passed!")
|
||||
} else {
|
||||
fmt.Println("Some checks failed. Review the output above.")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func runLSP(args []string) {
|
||||
if len(args) == 0 {
|
||||
args = []string{"scan"}
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "scan":
|
||||
fmt.Println("Scanning LSP servers...")
|
||||
servers := lsp.ScanServers()
|
||||
installed := 0
|
||||
for _, s := range servers {
|
||||
if s.Installed {
|
||||
installed++
|
||||
fmt.Printf(" [v] %-35s (%s)\n", s.Name, s.Language)
|
||||
} else {
|
||||
fmt.Printf(" [ ] %-35s (%s)\n", s.Name, s.Language)
|
||||
}
|
||||
}
|
||||
fmt.Printf("\nInstalled: %d/%d\n", installed, len(servers))
|
||||
case "install":
|
||||
if len(args) < 2 {
|
||||
cfg := loadOrSetupConfig()
|
||||
fmt.Printf("Installing LSP servers for: %v\n", cfg.Profile.Languages)
|
||||
results := lsp.InstallForLanguages(cfg.Profile.Languages)
|
||||
for _, r := range results {
|
||||
if r.Installed {
|
||||
fmt.Printf(" [OK] %s (%s)\n", r.Name, r.Language)
|
||||
} else {
|
||||
fmt.Printf(" [FAIL] %s (%s)\n", r.Name, r.Language)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, name := range args[1:] {
|
||||
fmt.Printf("Installing %s...\n", name)
|
||||
if err := lsp.InstallServer(name); err != nil {
|
||||
fmt.Printf(" [FAIL] %s: %s\n", name, err)
|
||||
} else {
|
||||
fmt.Printf(" [OK] %s\n", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
fmt.Printf("Unknown lsp subcommand: %s (scan, install)\n", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runMCP(args []string) {
|
||||
if len(args) == 0 {
|
||||
args = []string{"config"}
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "config":
|
||||
cfg := loadOrSetupConfig()
|
||||
fmt.Println("Configuring MCP servers for Crush and Claude Code...")
|
||||
if err := mcp.ConfigureAll(cfg); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("Done! MCP servers configured.")
|
||||
case "scan":
|
||||
fmt.Println("Scanning MCP servers...")
|
||||
servers := mcp.ScanServers()
|
||||
available := 0
|
||||
for _, s := range servers {
|
||||
if s.Installed {
|
||||
available++
|
||||
fmt.Printf(" [v] %-30s (%s)\n", s.Name, s.Category)
|
||||
} else {
|
||||
fmt.Printf(" [ ] %-30s (%s)\n", s.Name, s.Category)
|
||||
}
|
||||
}
|
||||
fmt.Printf("\nAvailable: %d/%d\n", available, len(servers))
|
||||
default:
|
||||
fmt.Printf("Unknown mcp subcommand: %s (config, scan)\n", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runSkills(args []string) {
|
||||
if len(args) == 0 {
|
||||
args = []string{"list"}
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "list", "ls":
|
||||
skillsList, err := skills.List()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if len(skillsList) == 0 {
|
||||
fmt.Println("No skills found. Run `muyue skills init` to install built-in skills.")
|
||||
return
|
||||
}
|
||||
fmt.Printf("Skills (%d):\n", len(skillsList))
|
||||
for _, s := range skillsList {
|
||||
target := s.Target
|
||||
if target == "" {
|
||||
target = "both"
|
||||
}
|
||||
fmt.Printf(" %-20s %-8s %s\n", s.Name, target, s.Description)
|
||||
}
|
||||
|
||||
case "init":
|
||||
fmt.Println("Installing built-in skills...")
|
||||
if err := skills.InstallBuiltinSkills(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("Deploying to Crush and Claude Code...")
|
||||
if err := skills.DeployAll(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Deploy error: %v\n", err)
|
||||
}
|
||||
fmt.Println("Done! Built-in skills installed and deployed.")
|
||||
|
||||
case "show":
|
||||
if len(args) < 2 {
|
||||
fmt.Println("Usage: muyue skills show <name>")
|
||||
return
|
||||
}
|
||||
skill, err := skills.Get(args[1])
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Name: %s\n", skill.Name)
|
||||
fmt.Printf("Description: %s\n", skill.Description)
|
||||
fmt.Printf("Author: %s\n", skill.Author)
|
||||
fmt.Printf("Version: %s\n", skill.Version)
|
||||
fmt.Printf("Target: %s\n", skill.Target)
|
||||
fmt.Printf("Tags: %v\n", skill.Tags)
|
||||
fmt.Printf("Path: %s\n", skill.FilePath)
|
||||
fmt.Printf("\n--- Content ---\n%s\n", skill.Content)
|
||||
|
||||
case "generate":
|
||||
if len(args) < 3 {
|
||||
fmt.Println("Usage: muyue skills generate <name> <description> [crush|claude|both]")
|
||||
fmt.Println("Example: muyue skills generate docker-setup \"Set up Docker for a project\" both")
|
||||
return
|
||||
}
|
||||
name := args[1]
|
||||
description := args[2]
|
||||
target := "both"
|
||||
if len(args) > 3 {
|
||||
target = args[3]
|
||||
}
|
||||
|
||||
cfg := loadOrSetupConfig()
|
||||
orch, err := orchestrator.New(cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "AI not configured: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Generating skill '%s'...\n", name)
|
||||
prompt := skills.BuildAIGeneratePrompt(name, description, target)
|
||||
resp, err := orch.Send(prompt)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Generation error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
skill := &skills.Skill{
|
||||
Name: name,
|
||||
Description: description,
|
||||
Content: resp,
|
||||
Author: "muyue-generated",
|
||||
Version: "0.1.0",
|
||||
Target: target,
|
||||
Tags: []string{"generated"},
|
||||
}
|
||||
|
||||
if err := skills.Create(skill); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Save error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Skill '%s' created and deployed!\n", name)
|
||||
|
||||
case "deploy":
|
||||
fmt.Println("Deploying all skills to Crush and Claude Code...")
|
||||
if err := skills.DeployAll(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("Done!")
|
||||
|
||||
case "delete":
|
||||
if len(args) < 2 {
|
||||
fmt.Println("Usage: muyue skills delete <name>")
|
||||
return
|
||||
}
|
||||
if err := skills.Delete(args[1]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Skill '%s' deleted.\n", args[1])
|
||||
|
||||
default:
|
||||
fmt.Printf("Unknown skills subcommand: %s\n", args[0])
|
||||
fmt.Println("Available: list, show, generate, deploy, init, delete")
|
||||
}
|
||||
}
|
||||
|
||||
6
go.mod
6
go.mod
@@ -1,9 +1,13 @@
|
||||
module github.com/muyue/muyue
|
||||
|
||||
go 1.24.3
|
||||
go 1.24.2
|
||||
|
||||
toolchain go1.24.3
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/huh v1.0.0
|
||||
github.com/creack/pty/v2 v2.0.1
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
|
||||
4
go.sum
4
go.sum
@@ -44,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=
|
||||
|
||||
158
internal/api/conversation.go
Normal file
158
internal/api/conversation.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
)
|
||||
|
||||
const maxTokensApprox = 100000
|
||||
const summarizeThreshold = 80000
|
||||
const charsPerToken = 4
|
||||
|
||||
type FeedMessage struct {
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Time string `json:"time"`
|
||||
}
|
||||
|
||||
type Conversation struct {
|
||||
Messages []FeedMessage `json:"messages"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ConversationStore struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
conv *Conversation
|
||||
}
|
||||
|
||||
func NewConversationStore() *ConversationStore {
|
||||
dir, err := config.ConfigDir()
|
||||
if err != nil {
|
||||
dir = "/tmp/muyue"
|
||||
}
|
||||
path := filepath.Join(dir, "conversation.json")
|
||||
cs := &ConversationStore{path: path}
|
||||
cs.load()
|
||||
return cs
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) load() {
|
||||
data, err := os.ReadFile(cs.path)
|
||||
if err != nil {
|
||||
cs.conv = &Conversation{
|
||||
Messages: []FeedMessage{},
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
return
|
||||
}
|
||||
var conv Conversation
|
||||
if err := json.Unmarshal(data, &conv); err != nil {
|
||||
cs.conv = &Conversation{
|
||||
Messages: []FeedMessage{},
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
return
|
||||
}
|
||||
if conv.Messages == nil {
|
||||
conv.Messages = []FeedMessage{}
|
||||
}
|
||||
cs.conv = &conv
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) save() error {
|
||||
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
data, err := json.MarshalIndent(cs.conv, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir := filepath.Dir(cs.path)
|
||||
os.MkdirAll(dir, 0755)
|
||||
return os.WriteFile(cs.path, data, 0600)
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) Get() []FeedMessage {
|
||||
cs.mu.RLock()
|
||||
defer cs.mu.RUnlock()
|
||||
out := make([]FeedMessage, len(cs.conv.Messages))
|
||||
copy(out, cs.conv.Messages)
|
||||
return out
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) GetSummary() string {
|
||||
cs.mu.RLock()
|
||||
defer cs.mu.RUnlock()
|
||||
return cs.conv.Summary
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) Add(role, content string) FeedMessage {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
|
||||
msg := FeedMessage{
|
||||
ID: generateMsgID(),
|
||||
Role: role,
|
||||
Content: content,
|
||||
Time: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
cs.conv.Messages = append(cs.conv.Messages, msg)
|
||||
cs.save()
|
||||
return msg
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) Clear() {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
cs.conv.Messages = []FeedMessage{}
|
||||
cs.conv.Summary = ""
|
||||
cs.conv.CreatedAt = time.Now().Format(time.RFC3339)
|
||||
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
cs.save()
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) SetSummary(summary string) {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
cs.conv.Summary = summary
|
||||
cs.save()
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) TrimOld(keepCount int) {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
if len(cs.conv.Messages) <= keepCount {
|
||||
return
|
||||
}
|
||||
cs.conv.Messages = cs.conv.Messages[len(cs.conv.Messages)-keepCount:]
|
||||
cs.save()
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) ApproxTokenCount() int {
|
||||
cs.mu.RLock()
|
||||
defer cs.mu.RUnlock()
|
||||
total := utf8.RuneCountInString(cs.conv.Summary)
|
||||
for _, m := range cs.conv.Messages {
|
||||
total += utf8.RuneCountInString(m.Content)
|
||||
}
|
||||
return total / charsPerToken
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) NeedsSummarization() bool {
|
||||
return cs.ApproxTokenCount() > summarizeThreshold
|
||||
}
|
||||
|
||||
func generateMsgID() string {
|
||||
return time.Now().Format("20060102150405.000") + "-" + fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
236
internal/api/handlers_chat.go
Normal file
236
internal/api/handlers_chat.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/muyue/muyue/internal/orchestrator"
|
||||
)
|
||||
|
||||
var toolCallRegex = regexp.MustCompile(`\[TOOL_CALL:\{[^\}]+\}\]`)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
s.convStore.Add("user", body.Message)
|
||||
|
||||
if s.convStore.NeedsSummarization() {
|
||||
s.autoSummarize()
|
||||
}
|
||||
|
||||
orb, err := orchestrator.New(s.config)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
orb.SetSystemPrompt(`Tu es l'assistant IA de Muyue Studio. Tu as accès à un outil "crush" pour exécuter des tâches complexes sur l'ordinateur de l'utilisateur.
|
||||
|
||||
RÈGLES ABSOLUES:
|
||||
1. Tu as DEUX possibilités ONLY:
|
||||
- Répondre directement à l'utilisateur avec tes connaissances
|
||||
- Demander l'exécution d'une tâche via crush en utilisant ce format EXACT:
|
||||
[TOOL_CALL:{"tool":"crush","task":"description de la tâche"}]
|
||||
|
||||
2. Quand tu utilises [TOOL_CALL:...], le système exécutera la tâche et te donnera le résultat.
|
||||
Tu peux ensuite répondre à l'utilisateur avec ce résultat.
|
||||
|
||||
3. SOIS CONCIS - pas de blabla, vais droit au but.
|
||||
|
||||
4. L'utilisateur ne voit PAS tes pensées entre <think> tags.
|
||||
|
||||
5. EXEMPLES d'utilisation de tool:
|
||||
- "cherche tous les fichiers .md dans le projet" → [TOOL_CALL:{"tool":"crush","task":"Recherche les fichiers .md dans le projet courant"}]
|
||||
- "aide-moi à déboguer cette erreur" → tu peux répondre directement si tu as assez d'info, sinon utiliser tool
|
||||
- "quelle est la météo?" → [TOOL_CALL:{"tool":"crush","task":"Cherche la météo actuelle"}]
|
||||
|
||||
6. Ne fais PAS de multi-step tool calls dans une seule réponse. Attends le résultat avant de continuer.`)
|
||||
|
||||
if 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)
|
||||
|
||||
result, err := orb.SendStream(body.Message, func(chunk string) {
|
||||
if strings.HasPrefix(chunk, "<think") {
|
||||
data, _ := json.Marshal(map[string]string{"thinking": strings.TrimPrefix(chunk, "<think")})
|
||||
w.Write([]byte("data: " + string(data) + "\n\n"))
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
return
|
||||
}
|
||||
if chunk == "</think>" {
|
||||
data, _ := json.Marshal(map[string]string{"thinking_end": "true"})
|
||||
w.Write([]byte("data: " + string(data) + "\n\n"))
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
return
|
||||
}
|
||||
data, _ := json.Marshal(map[string]string{"content": chunk})
|
||||
w.Write([]byte("data: " + string(data) + "\n\n"))
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
data, _ := json.Marshal(map[string]string{"error": err.Error()})
|
||||
w.Write([]byte("data: " + string(data) + "\n\n"))
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Process tool calls if any
|
||||
cleanResult := processToolCalls(result)
|
||||
s.convStore.Add("assistant", cleanResult)
|
||||
|
||||
data, _ := json.Marshal(map[string]string{"done": "true"})
|
||||
w.Write([]byte("data: " + string(data) + "\n\n"))
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
result, err := orb.Send(body.Message)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
cleanResult := processToolCalls(result)
|
||||
s.convStore.Add("assistant", cleanResult)
|
||||
writeJSON(w, map[string]string{"content": cleanResult})
|
||||
}
|
||||
|
||||
func processToolCalls(content string) string {
|
||||
matches := toolCallRegex.FindAllString(content, -1)
|
||||
if len(matches) == 0 {
|
||||
return cleanThinkingTags(content)
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
clean := content
|
||||
|
||||
for _, match := range matches {
|
||||
// Extract tool and task from [TOOL_CALL:{...}]
|
||||
inner := strings.TrimPrefix(match, "[TOOL_CALL:")
|
||||
inner = strings.TrimSuffix(inner, "]}") + "}"
|
||||
|
||||
var call struct {
|
||||
Tool string `json:"tool"`
|
||||
Task string `json:"task"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(inner), &call); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if call.Tool == "crush" && call.Task != "" {
|
||||
result.WriteString(fmt.Sprintf("> %s\n\n", call.Task))
|
||||
output := executeCrush(call.Task)
|
||||
result.WriteString(output)
|
||||
result.WriteString("\n\n---\n\n")
|
||||
}
|
||||
|
||||
clean = strings.Replace(clean, match, "", 1)
|
||||
}
|
||||
|
||||
clean = cleanThinkingTags(clean)
|
||||
|
||||
if result.Len() > 0 {
|
||||
clean = strings.TrimSpace(clean) + "\n\n" + strings.TrimSpace(result.String())
|
||||
}
|
||||
|
||||
return clean
|
||||
}
|
||||
|
||||
func cleanThinkingTags(content string) string {
|
||||
re := regexp.MustCompile(`(?s)<think[^>]*>.*?</think>`)
|
||||
return re.ReplaceAllString(content, "")
|
||||
}
|
||||
|
||||
func executeCrush(task string) string {
|
||||
cmd := exec.Command("crush", "run", task)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Sprintf("Erreur: %v\n%s", err, string(output))
|
||||
}
|
||||
return string(output)
|
||||
}
|
||||
|
||||
func (s *Server) autoSummarize() {
|
||||
messages := s.convStore.Get()
|
||||
if len(messages) < 10 {
|
||||
return
|
||||
}
|
||||
|
||||
half := len(messages) / 2
|
||||
var oldText string
|
||||
for _, m := range messages[:half] {
|
||||
oldText += m.Role + ": " + m.Content + "\n\n"
|
||||
}
|
||||
|
||||
summary := s.convStore.GetSummary()
|
||||
if summary != "" {
|
||||
oldText = "Résumé précédent:\n" + summary + "\n\nNouveaux échanges:\n" + oldText
|
||||
}
|
||||
|
||||
orb, err := orchestrator.New(s.config)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
orb.SetSystemPrompt(summarizePrompt)
|
||||
|
||||
result, err := orb.Send(oldText)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.convStore.SetSummary(result)
|
||||
s.convStore.TrimOld(len(messages) - half)
|
||||
s.convStore.Add("system", "[Conversation résumée automatiquement]")
|
||||
}
|
||||
|
||||
func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
messages := s.convStore.Get()
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"messages": messages,
|
||||
"tokens": s.convStore.ApproxTokenCount(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleChatClear(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
s.convStore.Clear()
|
||||
writeJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
17
internal/api/handlers_common.go
Normal file
17
internal/api/handlers_common.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const summarizePrompt = `Résume la conversation suivante de manière concise et structurée. Garde les points clés, les décisions prises, le contexte technique important. Le résumé doit permettre de continuer la conversation sans perte de contexte. Réponds uniquement avec le résumé, sans meta-commentaire.`
|
||||
|
||||
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})
|
||||
}
|
||||
486
internal/api/handlers_config.go
Normal file
486
internal/api/handlers_config.go
Normal file
@@ -0,0 +1,486 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
)
|
||||
|
||||
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) 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) handleValidateProvider(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
APIKey string `json:"api_key"`
|
||||
Model string `json:"model"`
|
||||
BaseURL string `json:"base_url"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.APIKey == "" {
|
||||
writeError(w, "api_key required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
baseURL := body.BaseURL
|
||||
if baseURL == "" {
|
||||
for _, p := range s.config.AI.Providers {
|
||||
if p.Name == body.Name {
|
||||
baseURL = p.BaseURL
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if baseURL == "" {
|
||||
switch body.Name {
|
||||
case "minimax":
|
||||
baseURL = "https://api.minimax.io/v1"
|
||||
case "openai":
|
||||
baseURL = "https://api.openai.com/v1"
|
||||
case "anthropic":
|
||||
baseURL = "https://api.anthropic.com/v1"
|
||||
default:
|
||||
baseURL = "https://api.minimax.io/v1"
|
||||
}
|
||||
}
|
||||
|
||||
model := body.Model
|
||||
if model == "" {
|
||||
for _, p := range s.config.AI.Providers {
|
||||
if p.Name == body.Name {
|
||||
model = p.Model
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if model == "" {
|
||||
model = "MiniMax-M2.7"
|
||||
}
|
||||
|
||||
reqBody, _ := json.Marshal(map[string]interface{}{
|
||||
"model": model,
|
||||
"messages": []map[string]string{{"role": "user", "content": "Hi"}},
|
||||
"max_tokens": 5,
|
||||
"stream": false,
|
||||
})
|
||||
|
||||
url := strings.TrimRight(baseURL, "/") + "/chat/completions"
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+body.APIKey)
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
writeError(w, "connection failed: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
writeError(w, "invalid_api_key", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
writeError(w, "api_error: "+string(respBody), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{"status": "valid"})
|
||||
}
|
||||
|
||||
func (s *Server) handleSaveTerminalSettings(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 {
|
||||
FontSize int `json:"font_size"`
|
||||
FontFamily string `json:"font_family"`
|
||||
Theme string `json:"theme"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.FontSize > 0 {
|
||||
s.config.Terminal.FontSize = body.FontSize
|
||||
}
|
||||
if body.FontFamily != "" {
|
||||
s.config.Terminal.FontFamily = body.FontFamily
|
||||
}
|
||||
if body.Theme != "" {
|
||||
s.config.Terminal.Theme = body.Theme
|
||||
}
|
||||
if err := config.Save(s.config); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"status": "ok",
|
||||
"theme": config.GetTerminalTheme(s.config.Terminal.Theme),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetTerminalThemes(w http.ResponseWriter, r *http.Request) {
|
||||
themes := make([]map[string]string, 0, len(config.DEFAULT_TERMINAL_THEMES))
|
||||
for id, theme := range config.DEFAULT_TERMINAL_THEMES {
|
||||
themes = append(themes, map[string]string{
|
||||
"id": id,
|
||||
"name": theme.Name,
|
||||
})
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"themes": themes})
|
||||
}
|
||||
|
||||
func (s *Server) handleResetConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
dir, err := config.ConfigDir()
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.config = config.Default()
|
||||
if err := config.Save(s.config); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleApplyStarshipTheme(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Theme string `json:"theme"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Theme == "" {
|
||||
body.Theme = s.config.Terminal.PromptTheme
|
||||
}
|
||||
|
||||
cfgDir, err := config.ConfigDir()
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
starshipDir := filepath.Join(cfgDir, "starship")
|
||||
if err := os.MkdirAll(starshipDir, 0755); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
themeFile := filepath.Join(starshipDir, "starship.toml")
|
||||
|
||||
themeContent := getStarshipThemeConfig(body.Theme)
|
||||
if err := os.WriteFile(themeFile, []byte(themeContent), 0644); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
shellRCs := []string{
|
||||
filepath.Join(home, ".bashrc"),
|
||||
filepath.Join(home, ".zshrc"),
|
||||
}
|
||||
for _, rc := range shellRCs {
|
||||
if _, err := os.Stat(rc); err != nil {
|
||||
continue
|
||||
}
|
||||
content, _ := os.ReadFile(rc)
|
||||
if strings.Contains(string(content), "STARSHIP_CONFIG") {
|
||||
continue
|
||||
}
|
||||
exportLine := fmt.Sprintf("\n# Muyue Starship config\nexport STARSHIP_CONFIG=%s\n", themeFile)
|
||||
f, err := os.OpenFile(rc, os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
f.WriteString(exportLine)
|
||||
f.Close()
|
||||
}
|
||||
|
||||
s.config.Terminal.PromptTheme = body.Theme
|
||||
config.Save(s.config)
|
||||
|
||||
writeJSON(w, map[string]interface{}{"status": "ok", "config": themeFile})
|
||||
}
|
||||
|
||||
func getStarshipThemeConfig(theme string) string {
|
||||
switch theme {
|
||||
case "charm":
|
||||
return `[format]
|
||||
before_format = "$"
|
||||
format = """
|
||||
$username$directory$git_branch$git_status$cmd_duration$line_break$character"""
|
||||
|
||||
[character]
|
||||
success_symbol = "[➜](bold #00E676)"
|
||||
error_symbol = "[✗](bold #FF0033)"
|
||||
|
||||
[directory]
|
||||
truncation_length = 3
|
||||
truncation_symbol = "…/"
|
||||
style = "bold #00BCD4"
|
||||
|
||||
[username]
|
||||
show_on_left = false
|
||||
style_user = "bold #FF0033"
|
||||
style_root = "bold #FF0033"
|
||||
|
||||
[git_branch]
|
||||
symbol = " "
|
||||
format = "on [$symbol$branch]($style)"
|
||||
style = "bold #FFD740"
|
||||
|
||||
[git_status]
|
||||
format = "[$all_status$ahead_behind]($style) "
|
||||
style = "bold #FF1A5E"
|
||||
conflicted = "!"
|
||||
untracked = "?"
|
||||
modified = "~"
|
||||
staged = "[+]"
|
||||
renamed = "»"
|
||||
deleted = "-"
|
||||
|
||||
[cmd_duration]
|
||||
min_time = 500
|
||||
format = "took [$duration]($style)"
|
||||
style = "bold #75715E"
|
||||
`
|
||||
case "zerotwo":
|
||||
return `[format]
|
||||
before_format = "$"
|
||||
format = """
|
||||
$username$directory$git_branch$git_status$cmd_duration$line_break$character"""
|
||||
|
||||
[character]
|
||||
success_symbol = "[❯](bold #3B82F6)"
|
||||
error_symbol = "[❯](bold #EF4444)"
|
||||
|
||||
[directory]
|
||||
truncation_length = 3
|
||||
truncation_symbol = "…/"
|
||||
style = "bold #8B5CF6"
|
||||
|
||||
[username]
|
||||
show_on_left = false
|
||||
style_user = "bold #EC4899"
|
||||
style_root = "bold #EF4444"
|
||||
|
||||
[git_branch]
|
||||
symbol = " "
|
||||
format = "on [$symbol$branch]($style)"
|
||||
style = "bold #F472B6"
|
||||
|
||||
[git_status]
|
||||
format = "[$all_status$ahead_behind]($style) "
|
||||
style = "bold #EF4444"
|
||||
conflicted = "!"
|
||||
untracked = "?"
|
||||
modified = "~"
|
||||
staged = "[+]"
|
||||
renamed = "»"
|
||||
deleted = "-"
|
||||
|
||||
[cmd_duration]
|
||||
min_time = 500
|
||||
format = "took [$duration]($style)"
|
||||
style = "bold #6B7280"
|
||||
`
|
||||
default:
|
||||
return `[format]
|
||||
before_format = "$"
|
||||
format = """
|
||||
$username$directory$git_branch$git_status$line_break$character"""
|
||||
|
||||
[character]
|
||||
success_symbol = "[❯](bold green)"
|
||||
error_symbol = "[❯](bold red)"
|
||||
|
||||
[directory]
|
||||
truncation_length = 3
|
||||
truncation_symbol = "…/"
|
||||
style = "bold cyan"
|
||||
|
||||
[username]
|
||||
show_on_left = false
|
||||
style_user = "bold red"
|
||||
style_root = "bold red"
|
||||
|
||||
[git_branch]
|
||||
symbol = " "
|
||||
format = "on [$symbol$branch]($style)"
|
||||
style = "bold yellow"
|
||||
|
||||
[cmd_duration]
|
||||
min_time = 500
|
||||
format = "took [$duration]($style)"
|
||||
style = "bold bright-black"
|
||||
`
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,15 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
|
||||
"github.com/muyue/muyue/internal/lsp"
|
||||
"github.com/muyue/muyue/internal/mcp"
|
||||
"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,
|
||||
@@ -125,91 +113,7 @@ func (s *Server) handleMCPConfigure(w http.ResponseWriter, r *http.Request) {
|
||||
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) 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)
|
||||
}
|
||||
44
internal/api/handlers_terminal.go
Normal file
44
internal/api/handlers_terminal.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
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 := detectShell()
|
||||
|
||||
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)
|
||||
}
|
||||
94
internal/api/handlers_tools.go
Normal file
94
internal/api/handlers_tools.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/muyue/muyue/internal/scanner"
|
||||
"github.com/muyue/muyue/internal/updater"
|
||||
)
|
||||
|
||||
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) 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"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
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),
|
||||
})
|
||||
}
|
||||
80
internal/api/handlers_tools_exec.go
Normal file
80
internal/api/handlers_tools_exec.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type toolCallRequest struct {
|
||||
Tool string `json:"tool"`
|
||||
Task string `json:"task"`
|
||||
}
|
||||
|
||||
type toolResult struct {
|
||||
Success bool `json:"success"`
|
||||
Output string `json:"output"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleToolCall(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req toolCallRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Tool != "crush" {
|
||||
writeError(w, "unsupported tool: "+req.Tool, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Task == "" {
|
||||
writeError(w, "task is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
result := executeTool(req.Tool, req.Task)
|
||||
writeJSON(w, result)
|
||||
}
|
||||
|
||||
func executeTool(tool, task string) toolResult {
|
||||
var cmd *exec.Cmd
|
||||
|
||||
switch tool {
|
||||
case "crush":
|
||||
cmd = exec.Command("crush", "run", task)
|
||||
default:
|
||||
return toolResult{Success: false, Error: "unknown tool: " + tool}
|
||||
}
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return toolResult{
|
||||
Success: false,
|
||||
Output: string(output),
|
||||
Error: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
return toolResult{
|
||||
Success: true,
|
||||
Output: string(output),
|
||||
}
|
||||
}
|
||||
|
||||
func buildToolMessage(tool, task string, history []string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("TASK: " + task + "\n\n")
|
||||
b.WriteString("CONVERSATION HISTORY:\n")
|
||||
for _, msg := range history {
|
||||
b.WriteString(strings.Repeat(" ", 4) + strings.Join(strings.Split(msg, "\n"), "\n"+strings.Repeat(" ", 4)) + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
"github.com/muyue/muyue/internal/scanner"
|
||||
@@ -11,6 +12,7 @@ type Server struct {
|
||||
config *config.MuyueConfig
|
||||
scanResult *scanner.ScanResult
|
||||
mux *http.ServeMux
|
||||
convStore *ConversationStore
|
||||
}
|
||||
|
||||
func NewServer(cfg *config.MuyueConfig) *Server {
|
||||
@@ -19,6 +21,7 @@ func NewServer(cfg *config.MuyueConfig) *Server {
|
||||
mux: http.NewServeMux(),
|
||||
}
|
||||
s.scanResult = scanner.ScanSystem()
|
||||
s.convStore = NewConversationStore()
|
||||
s.routes()
|
||||
return s
|
||||
}
|
||||
@@ -35,14 +38,31 @@ func (s *Server) routes() {
|
||||
s.mux.HandleFunc("/api/updates", s.handleUpdates)
|
||||
s.mux.HandleFunc("/api/install", s.handleInstall)
|
||||
s.mux.HandleFunc("/api/scan", s.handleScan)
|
||||
s.mux.HandleFunc("/api/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/terminal/themes", s.handleGetTerminalThemes)
|
||||
s.mux.HandleFunc("/api/terminal/settings", s.handleSaveTerminalSettings)
|
||||
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/providers/validate", s.handleValidateProvider)
|
||||
s.mux.HandleFunc("/api/update/run", s.handleRunUpdate)
|
||||
s.mux.HandleFunc("/api/chat", s.handleChat)
|
||||
s.mux.HandleFunc("/api/chat/history", s.handleChatHistory)
|
||||
s.mux.HandleFunc("/api/chat/clear", s.handleChatClear)
|
||||
}
|
||||
|
||||
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, OPTIONS")
|
||||
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)
|
||||
|
||||
350
internal/api/terminal.go
Normal file
350
internal/api/terminal.go
Normal file
@@ -0,0 +1,350 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"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 {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
return true
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(origin, "http://127.0.0.1"),
|
||||
strings.HasPrefix(origin, "http://localhost"),
|
||||
strings.HasPrefix(origin, "http://[::1]"),
|
||||
strings.HasPrefix(origin, "https://127.0.0.1"),
|
||||
strings.HasPrefix(origin, "https://localhost"),
|
||||
strings.HasPrefix(origin, "https://[::1]"):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
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 {
|
||||
log.Printf("terminal: read init message failed: %v", err)
|
||||
conn.WriteJSON(wsMessage{Type: "error", Data: "failed to read init message"})
|
||||
return
|
||||
}
|
||||
log.Printf("terminal: init message received: %s", string(raw))
|
||||
if err := json.Unmarshal(raw, &initMsg); err != nil {
|
||||
log.Printf("terminal: unmarshal init message failed: %v", err)
|
||||
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid init message"})
|
||||
return
|
||||
}
|
||||
log.Printf("terminal: init type=%q data=%q", initMsg.Type, initMsg.Data)
|
||||
|
||||
var cmd *exec.Cmd
|
||||
|
||||
if initMsg.Type == "ssh" && initMsg.Data != "" {
|
||||
var sshConf struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
KeyPath string `json:"key_path"`
|
||||
}
|
||||
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 := strings.TrimSpace(initMsg.Data)
|
||||
log.Printf("terminal: requested shell=%q, trimmed=%q", initMsg.Data, shell)
|
||||
if shell == "" {
|
||||
shell = detectShell()
|
||||
log.Printf("terminal: auto-detected shell=%q", shell)
|
||||
}
|
||||
|
||||
if shell == "" {
|
||||
log.Printf("terminal: no shell detected, falling back to /bin/sh")
|
||||
shell = "/bin/sh"
|
||||
}
|
||||
|
||||
if path, err := exec.LookPath(shell); err == nil {
|
||||
shell = path
|
||||
log.Printf("terminal: resolved shell path=%q", shell)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(shell); err != nil {
|
||||
log.Printf("terminal: shell stat failed: %v for %q", err, shell)
|
||||
conn.WriteJSON(wsMessage{Type: "error", Data: fmt.Sprintf("shell not found: %s (resolved from: %q)", shell, initMsg.Data)})
|
||||
return
|
||||
}
|
||||
|
||||
shellName := filepath.Base(shell)
|
||||
switch shellName {
|
||||
case "wsl":
|
||||
cmd = exec.Command(shell, "--shell-type", "login")
|
||||
case "powershell", "pwsh":
|
||||
cmd = exec.Command(shell, "-NoLogo", "-NoProfile")
|
||||
case "fish":
|
||||
cmd = exec.Command(shell, "--login")
|
||||
default:
|
||||
cmd = exec.Command(shell)
|
||||
}
|
||||
}
|
||||
|
||||
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
|
||||
|
||||
log.Printf("terminal: starting pty with cmd=%q args=%v", cmd.Path, cmd.Args)
|
||||
ptmx, err := pty.Start(cmd)
|
||||
if err != nil {
|
||||
log.Printf("terminal: pty start failed: %v", err)
|
||||
conn.WriteJSON(wsMessage{Type: "error", Data: err.Error()})
|
||||
return
|
||||
}
|
||||
log.Printf("terminal: pty started successfully")
|
||||
defer func() {
|
||||
ptmx.Close()
|
||||
if cmd.Process != nil {
|
||||
cmd.Process.Kill()
|
||||
cmd.Wait()
|
||||
}
|
||||
}()
|
||||
|
||||
var once sync.Once
|
||||
cleanup := func() {
|
||||
once.Do(func() {
|
||||
ptmx.Close()
|
||||
if cmd.Process != nil {
|
||||
cmd.Process.Kill()
|
||||
cmd.Wait()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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) {
|
||||
if r.Method != "DELETE" {
|
||||
writeError(w, "DELETE only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
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 path, err := exec.LookPath(s); err == nil {
|
||||
return path
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -2,10 +2,12 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/muyue/muyue/internal/secret"
|
||||
"github.com/muyue/muyue/internal/version"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -15,12 +17,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 +43,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,11 +65,76 @@ 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"`
|
||||
FontSize int `yaml:"font_size"`
|
||||
FontFamily string `yaml:"font_family"`
|
||||
Theme string `yaml:"theme"`
|
||||
} `yaml:"terminal"`
|
||||
}
|
||||
|
||||
type TerminalTheme struct {
|
||||
Name string `yaml:"name"`
|
||||
Background string `yaml:"background"`
|
||||
Foreground string `yaml:"foreground"`
|
||||
Cursor string `yaml:"cursor"`
|
||||
Black string `yaml:"black"`
|
||||
Red string `yaml:"red"`
|
||||
Green string `yaml:"green"`
|
||||
Yellow string `yaml:"yellow"`
|
||||
Blue string `yaml:"blue"`
|
||||
Magenta string `yaml:"magenta"`
|
||||
Cyan string `yaml:"cyan"`
|
||||
White string `yaml:"white"`
|
||||
}
|
||||
|
||||
var DEFAULT_TERMINAL_THEMES = map[string]TerminalTheme{
|
||||
"default": {
|
||||
Name: "Default", Background: "#0A0A0C", Foreground: "#EAE0E2",
|
||||
Cursor: "#FF0033", Black: "#0A0A0C", Red: "#FF0033",
|
||||
Green: "#00E676", Yellow: "#FFD740", Blue: "#448AFF",
|
||||
Magenta: "#FF1A5E", Cyan: "#00BCD4", White: "#EAE0E2",
|
||||
},
|
||||
"monokai": {
|
||||
Name: "Monokai", Background: "#272822", Foreground: "#F8F8F2",
|
||||
Cursor: "#F8F8F0", Black: "#272822", Red: "#F92672",
|
||||
Green: "#A6E22E", Yellow: "#E6DB74", Blue: "#66D9EF",
|
||||
Magenta: "#AE81FF", Cyan: "#A1EFE4", White: "#F8F8F2",
|
||||
},
|
||||
"gruvbox": {
|
||||
Name: "Gruvbox", Background: "#282828", Foreground: "#EBDBB2",
|
||||
Cursor: "#FB4934", Black: "#282828", Red: "#CC241D",
|
||||
Green: "#98971A", Yellow: "#D79921", Blue: "#458588",
|
||||
Magenta: "#B16286", Cyan: "#689D6A", White: "#EBDBB2",
|
||||
},
|
||||
"nord": {
|
||||
Name: "Nord", Background: "#2E3440", Foreground: "#D8DEE9",
|
||||
Cursor: "#D8DEE9", Black: "#2E3440", Red: "#BF616A",
|
||||
Green: "#A3BE8C", Yellow: "#EBCB8B", Blue: "#81A1C1",
|
||||
Magenta: "#B48EAD", Cyan: "#88C0D0", White: "#D8DEE9",
|
||||
},
|
||||
"solarized-dark": {
|
||||
Name: "Solarized Dark", Background: "#002B36", Foreground: "#839496",
|
||||
Cursor: "#D33682", Black: "#002B36", Red: "#DC322F",
|
||||
Green: "#859900", Yellow: "#B58900", Blue: "#268BD2",
|
||||
Magenta: "#D33682", Cyan: "#2AA198", White: "#FDF6E3",
|
||||
},
|
||||
"dracula": {
|
||||
Name: "Dracula", Background: "#282A36", Foreground: "#F8F8F2",
|
||||
Cursor: "#F8F8F2", Black: "#282A36", Red: "#FF5555",
|
||||
Green: "#50FA7B", Yellow: "#F1FA8C", Blue: "#BD93F9",
|
||||
Magenta: "#FF79C6", Cyan: "#8BE9FD", White: "#F8F8F2",
|
||||
},
|
||||
}
|
||||
|
||||
func GetTerminalTheme(name string) TerminalTheme {
|
||||
if theme, ok := DEFAULT_TERMINAL_THEMES[name]; ok {
|
||||
return theme
|
||||
}
|
||||
return DEFAULT_TERMINAL_THEMES["default"]
|
||||
}
|
||||
|
||||
func ConfigDir() (string, error) {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
@@ -67,7 +145,9 @@ func ConfigDir() (string, error) {
|
||||
legacyDir := filepath.Join(homeDir(), ".muyue")
|
||||
if _, err := os.Stat(legacyDir); err == nil {
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
os.Rename(legacyDir, dir)
|
||||
if err := os.Rename(legacyDir, dir); err != nil {
|
||||
log.Printf("config migration: rename %s to %s: %v", legacyDir, dir, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +247,7 @@ func Save(cfg *MuyueConfig) error {
|
||||
|
||||
func Default() *MuyueConfig {
|
||||
cfg := &MuyueConfig{
|
||||
Version: "0.1.0",
|
||||
Version: version.Version,
|
||||
Profile: Profile{
|
||||
Name: "",
|
||||
Pseudo: "muyue",
|
||||
@@ -179,6 +259,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{
|
||||
{
|
||||
|
||||
@@ -4,12 +4,14 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/muyue/muyue/internal/version"
|
||||
)
|
||||
|
||||
func TestDefault(t *testing.T) {
|
||||
cfg := Default()
|
||||
if cfg.Version != "0.1.0" {
|
||||
t.Errorf("Expected version 0.1.0, got %s", cfg.Version)
|
||||
if cfg.Version != version.Version {
|
||||
t.Errorf("Expected version %s, got %s", version.Version, cfg.Version)
|
||||
}
|
||||
if cfg.Profile.Pseudo != "muyue" {
|
||||
t.Errorf("Expected pseudo muyue, got %s", cfg.Profile.Pseudo)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -26,11 +26,11 @@ type options struct {
|
||||
|
||||
type option func(*options)
|
||||
|
||||
func WithPort(port int) option {
|
||||
func withPort(port int) option {
|
||||
return func(o *options) { o.port = port }
|
||||
}
|
||||
|
||||
func WithNoOpen(noOpen bool) option {
|
||||
func withNoOpen(noOpen bool) option {
|
||||
return func(o *options) { o.noOpen = noOpen }
|
||||
}
|
||||
|
||||
@@ -39,10 +39,10 @@ func parseFlags(args []string) []option {
|
||||
for _, arg := range args {
|
||||
switch {
|
||||
case arg == "--no-open":
|
||||
opts = append(opts, WithNoOpen(true))
|
||||
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))
|
||||
opts = append(opts, withPort(p))
|
||||
}
|
||||
case arg == "--port":
|
||||
// handled as prefix case
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
@@ -123,7 +124,7 @@ func (i *Installer) installBMAD() InstallResult {
|
||||
return InstallResult{Tool: "bmad", Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
bmadDir := configDir + "/bmad"
|
||||
bmadDir := filepath.Join(configDir, "bmad")
|
||||
os.MkdirAll(bmadDir, 0755)
|
||||
|
||||
cmd := exec.Command("npx", "bmad-method@latest", "install",
|
||||
@@ -175,7 +176,7 @@ func (i *Installer) installGo() InstallResult {
|
||||
}
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
goDir := home + "/.local/go"
|
||||
goDir := filepath.Join(home, ".local", "go")
|
||||
|
||||
cmd := exec.Command("bash", "-c", fmt.Sprintf(
|
||||
"curl -sL https://go.dev/dl/go1.24.3.%s-%s.tar.gz | tar -C %s/.local -xzf -",
|
||||
@@ -290,56 +291,16 @@ 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 {
|
||||
func (i *Installer) getRCFile() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
switch i.system.Shell {
|
||||
case "zsh":
|
||||
return home + "/.zshrc"
|
||||
return filepath.Join(home, ".zshrc")
|
||||
case "fish":
|
||||
return home + "/.config/fish/config.fish"
|
||||
return filepath.Join(home, ".config", "fish", "config.fish")
|
||||
default:
|
||||
return home + "/.bashrc"
|
||||
return filepath.Join(home, ".bashrc")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,7 +341,7 @@ func (i *Installer) installUv() InstallResult {
|
||||
return InstallResult{Tool: "uv", Success: false, Message: fmt.Sprintf("install failed: %s: %s", err, string(output))}
|
||||
}
|
||||
rcFile := i.getRCFile()
|
||||
appendLine(rcFile, "export PATH="+home+"/.local/bin:$PATH")
|
||||
appendLine(rcFile, "export PATH="+filepath.Join(home, ".local", "bin")+":$PATH")
|
||||
return InstallResult{Tool: "uv", Success: true, Message: "installed (added ~/.local/bin to PATH)"}
|
||||
case platform.Windows:
|
||||
cmd = exec.Command("powershell", "-Command", "irm https://astral.sh/uv/install.ps1 | iex")
|
||||
|
||||
@@ -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"},
|
||||
@@ -110,86 +101,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ func ScanServers() []MCPServer {
|
||||
|
||||
func getCoreEntries(homeDir string) []mcpEntry {
|
||||
return []mcpEntry{
|
||||
{"filesystem", "npx", []string{"-y", "@modelcontextprotocol/server-filesystem", homeDir + "/projects"}, nil},
|
||||
{"filesystem", "npx", []string{"-y", "@modelcontextprotocol/server-filesystem", filepath.Join(homeDir, "projects")}, nil},
|
||||
{"fetch", "npx", []string{"-y", "@modelcontextprotocol/server-fetch"}, nil},
|
||||
{"memory", "npx", []string{"-y", "@modelcontextprotocol/server-memory"}, nil},
|
||||
}
|
||||
@@ -86,7 +86,9 @@ func writeMCPConfig(configPath string, mcpKey string, entries []mcpEntry) error
|
||||
existing := map[string]interface{}{}
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err == nil {
|
||||
json.Unmarshal(data, &existing)
|
||||
if err := json.Unmarshal(data, &existing); err != nil {
|
||||
return fmt.Errorf("parse existing config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
mcpMap := map[string]interface{}{}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -12,7 +13,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
"github.com/muyue/muyue/internal/workflow"
|
||||
)
|
||||
|
||||
var thinkRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
|
||||
@@ -35,6 +35,9 @@ type ChatResponse struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"delta"`
|
||||
} `json:"choices"`
|
||||
Usage struct {
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
@@ -42,12 +45,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 +75,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 +97,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,19 +165,29 @@ 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},
|
||||
func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (string, error) {
|
||||
o.histMu.Lock()
|
||||
o.history = append(o.history, Message{
|
||||
Role: "user",
|
||||
Content: userMessage,
|
||||
})
|
||||
|
||||
if len(o.history) > maxHistorySize {
|
||||
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,
|
||||
Stream: false,
|
||||
Messages: messages,
|
||||
Stream: true,
|
||||
}
|
||||
o.histMu.Unlock()
|
||||
|
||||
body, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
@@ -193,116 +215,54 @@ func (o *Orchestrator) StartWorkflow(goal string) (string, error) {
|
||||
}
|
||||
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 {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
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)
|
||||
var fullContent strings.Builder
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
if data == "[DONE]" {
|
||||
break
|
||||
}
|
||||
|
||||
var chatResp ChatResponse
|
||||
if err := json.Unmarshal([]byte(data), &chatResp); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(chatResp.Choices) > 0 {
|
||||
chunk := chatResp.Choices[0].Delta.Content
|
||||
if chunk != "" {
|
||||
fullContent.WriteString(chunk)
|
||||
onChunk(chunk)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(chatResp.Choices) == 0 {
|
||||
return "", fmt.Errorf("no response from AI")
|
||||
if err := scanner.Err(); err != nil {
|
||||
return fullContent.String(), fmt.Errorf("read stream: %w", err)
|
||||
}
|
||||
|
||||
content := cleanAIResponse(chatResp.Choices[0].Message.Content)
|
||||
content := cleanAIResponse(fullContent.String())
|
||||
o.histMu.Lock()
|
||||
o.history = append(o.history, Message{
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
})
|
||||
o.histMu.Unlock()
|
||||
|
||||
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")
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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
|
||||
@@ -21,7 +17,7 @@ func TestCleanAIResponse(t *testing.T) {
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
"removes standard think tags",
|
||||
"malformed think tags pass through",
|
||||
"<think internal reasoning</think Hello world",
|
||||
"<think internal reasoning</think Hello world",
|
||||
},
|
||||
@@ -31,7 +27,7 @@ func TestCleanAIResponse(t *testing.T) {
|
||||
"response",
|
||||
},
|
||||
{
|
||||
"removes think with attrs",
|
||||
"think with attrs, no closing bracket",
|
||||
"<think type=re>reasoning</think result",
|
||||
"<think type=re>reasoning</think result",
|
||||
},
|
||||
@@ -56,12 +52,12 @@ func TestCleanAIResponse(t *testing.T) {
|
||||
"",
|
||||
},
|
||||
{
|
||||
"removes valid think block",
|
||||
"malformed think block no closing bracket",
|
||||
"<think some reasoning here</think rest",
|
||||
"<think some reasoning here</think rest",
|
||||
},
|
||||
{
|
||||
"removes simple think",
|
||||
"malformed simple think no closing bracket",
|
||||
"before<think reasoning</think after",
|
||||
"before<think reasoning</think after",
|
||||
},
|
||||
@@ -154,57 +150,127 @@ func TestNewNoAPIKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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:]
|
||||
func TestSendStreamChunks(t *testing.T) {
|
||||
sseBody := `data: {"choices":[{"delta":{"content":"Hello"}}]}
|
||||
data: {"choices":[{"delta":{"content":" world"}}]}
|
||||
data: {"choices":[{"delta":{"content":"!"}}]}
|
||||
data: [DONE]
|
||||
`
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer test-key" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
orch.histMu.Unlock()
|
||||
var reqBody ChatRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !reqBody.Stream {
|
||||
http.Error(w, "stream must be true", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Write([]byte(sseBody))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.AI.Providers[0].Active = true
|
||||
cfg.AI.Providers[0].APIKey = "test-key"
|
||||
cfg.AI.Providers[0].BaseURL = ts.URL
|
||||
|
||||
orb, err := New(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
h := orch.History()
|
||||
if len(h) > maxHistorySize {
|
||||
t.Errorf("History should be capped at %d, got %d", maxHistorySize, len(h))
|
||||
var chunks []string
|
||||
result, err := orb.SendStream("hi", func(chunk string) {
|
||||
chunks = append(chunks, chunk)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendStream: %v", err)
|
||||
}
|
||||
if result != "Hello world!" {
|
||||
t.Errorf("SendStream result = %q, want %q", result, "Hello world!")
|
||||
}
|
||||
if len(chunks) != 3 {
|
||||
t.Fatalf("expected 3 chunks, got %d: %v", len(chunks), chunks)
|
||||
}
|
||||
if strings.Join(chunks, "") != "Hello world!" {
|
||||
t.Errorf("chunks joined = %q, want %q", strings.Join(chunks, ""), "Hello world!")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendStreamHistory(t *testing.T) {
|
||||
callCount := 0
|
||||
sseBody := `data: {"choices":[{"delta":{"content":"reply"}}]}
|
||||
data: [DONE]
|
||||
`
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
var reqBody ChatRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if callCount == 1 {
|
||||
if len(reqBody.Messages) != 2 {
|
||||
t.Errorf("first call: expected 2 messages (system + 1 user), got %d", len(reqBody.Messages))
|
||||
}
|
||||
} else {
|
||||
if len(reqBody.Messages) != 4 {
|
||||
t.Errorf("second call: expected 4 messages (system + 3 history), got %d", len(reqBody.Messages))
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Write([]byte(sseBody))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.AI.Providers[0].Active = true
|
||||
cfg.AI.Providers[0].APIKey = "test-key"
|
||||
cfg.AI.Providers[0].BaseURL = ts.URL
|
||||
|
||||
orb, err := New(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
orb.SetSystemPrompt("you are helpful")
|
||||
|
||||
_, _ = orb.SendStream("first", func(string) {})
|
||||
_, _ = orb.SendStream("second", func(string) {})
|
||||
|
||||
orb.histMu.Lock()
|
||||
if len(orb.history) != 4 {
|
||||
t.Errorf("expected 4 history entries (2 user + 2 assistant), got %d", len(orb.history))
|
||||
}
|
||||
orb.histMu.Unlock()
|
||||
}
|
||||
|
||||
func TestSendStreamAPIError(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"error":"rate limited"}`, http.StatusTooManyRequests)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.AI.Providers[0].Active = true
|
||||
cfg.AI.Providers[0].APIKey = "test-key"
|
||||
cfg.AI.Providers[0].BaseURL = ts.URL
|
||||
|
||||
orb, err := New(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
_, err = orb.SendStream("hi", func(string) {})
|
||||
if err == nil {
|
||||
t.Error("expected error for non-200 response")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "429") {
|
||||
t.Errorf("error should mention status code, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -43,16 +44,9 @@ func TestString(t *testing.T) {
|
||||
if s == "" {
|
||||
t.Error("String should not be empty")
|
||||
}
|
||||
if !contains(s, "linux") {
|
||||
if !strings.Contains(s, "linux") {
|
||||
t.Error("Should contain OS")
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -279,7 +279,7 @@ func AskAPIKey(providerName string) (string, error) {
|
||||
|
||||
field := huh.NewInput().
|
||||
Title(fmt.Sprintf("Enter your %s API key:", providerName)).
|
||||
Description("The key will be stored locally in ~/.muyue/config.yaml").
|
||||
Description("The key will be stored locally in ~/.config/muyue/config.yaml").
|
||||
EchoMode(huh.EchoModePassword).
|
||||
Value(&apiKey)
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -169,7 +170,7 @@ func checkShellSetup() bool {
|
||||
home, _ := os.UserHomeDir()
|
||||
rcFiles := []string{".bashrc", ".zshrc", ".config/fish/config.fish"}
|
||||
for _, f := range rcFiles {
|
||||
data, err := os.ReadFile(home + "/" + f)
|
||||
data, err := os.ReadFile(filepath.Join(home, f))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2,17 +2,10 @@ package version
|
||||
|
||||
const (
|
||||
Name = "muyue"
|
||||
Version = "0.2.1"
|
||||
Version = "0.3.2"
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,9 @@
|
||||
<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="stylesheet" href="/src/styles/global.css" />
|
||||
<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>
|
||||
|
||||
34
web/package-lock.json
generated
34
web/package-lock.json
generated
@@ -6,6 +6,10 @@
|
||||
"": {
|
||||
"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"
|
||||
},
|
||||
@@ -396,6 +400,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -712,6 +737,15 @@
|
||||
"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",
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -2,8 +2,8 @@ const API_BASE = '/api'
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||
@@ -25,7 +25,60 @@ const api = {
|
||||
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) }),
|
||||
validateProvider: (provider) => request('/providers/validate', { method: 'POST', 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' }),
|
||||
getTerminalThemes: () => request('/terminal/themes'),
|
||||
saveTerminalSettings: (settings) => request('/terminal/settings', { method: 'PUT', body: JSON.stringify(settings) }),
|
||||
getChatHistory: () => request('/chat/history'),
|
||||
clearChat: () => request('/chat/clear', { method: 'POST' }),
|
||||
sendChat: (message, stream = true, onChunk) => {
|
||||
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
|
||||
if (onChunk) onChunk(full, data)
|
||||
} else if (data.thinking !== undefined || data.thinking_end) {
|
||||
if (onChunk) onChunk(full, data)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
resolve(full)
|
||||
}).catch(reject)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default api
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { LayoutDashboard, Sparkles, Terminal, Settings } from 'lucide-react'
|
||||
import api from '../api/client'
|
||||
import { getTheme, getThemeNames, applyTheme } from '../themes'
|
||||
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'
|
||||
|
||||
const TABS = [
|
||||
{ id: 'dash', label: 'DASH', icon: '[■]' },
|
||||
{ id: 'studio', label: 'STUDIO', icon: '[<>]' },
|
||||
{ id: 'shell', label: 'SHELL', icon: '[$]' },
|
||||
{ id: 'config', label: 'CONFIG', icon: '[//]' },
|
||||
]
|
||||
import OnboardingWizard from './OnboardingWizard'
|
||||
|
||||
export default function App() {
|
||||
const [activeTab, setActiveTab] = useState('dash')
|
||||
@@ -19,41 +15,87 @@ export default function App() {
|
||||
const [clock, setClock] = useState(new Date())
|
||||
const [updates, setUpdates] = useState([])
|
||||
const [tools, setTools] = useState([])
|
||||
const [transition, setTransition] = useState(false)
|
||||
const [currentTheme, setCurrentTheme] = useState('cyberpunk-red')
|
||||
// api is imported directly
|
||||
const [config, setConfig] = useState(null)
|
||||
const [showOnboarding, setShowOnboarding] = useState(false)
|
||||
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(() => {})
|
||||
|
||||
const theme = getTheme(currentTheme)
|
||||
applyTheme(theme)
|
||||
api.getConfig().then(d => {
|
||||
setConfig(d)
|
||||
const theme = d.profile?.preferences?.theme || 'cyberpunk-red'
|
||||
applyTheme(getTheme(theme))
|
||||
const hasProfile = d.profile?.name || d.profile?.pseudo
|
||||
if (!hasProfile) setShowOnboarding(true)
|
||||
}).catch(() => {
|
||||
applyTheme(getTheme('cyberpunk-red'))
|
||||
setShowOnboarding(true)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setClock(new Date()), 1000)
|
||||
return () => clearInterval(timer)
|
||||
const id = setInterval(() => setClock(new Date()), 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
const switchTab = useCallback((tabId) => {
|
||||
if (tabId === activeTab) return
|
||||
setTransition(true)
|
||||
setTimeout(() => {
|
||||
setActiveTab(tabId)
|
||||
setTimeout(() => setTransition(false), 150)
|
||||
}, 100)
|
||||
}, [activeTab])
|
||||
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(tool => tool.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 'dash': return <Dashboard api={api} />
|
||||
case 'studio': return <Studio api={api} />
|
||||
case 'shell': return <Shell api={api} />
|
||||
case 'config': return <Config api={api} theme={currentTheme} onThemeChange={setCurrentTheme} />
|
||||
case 'config': return <Config api={api} />
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
@@ -61,45 +103,68 @@ export default function App() {
|
||||
return (
|
||||
<div className="app-layout">
|
||||
<header className="header">
|
||||
<span className="header-logo">MUYUE</span>
|
||||
<span className="header-version">v{info.version || '...'}</span>
|
||||
<div className="header-brand">
|
||||
<span className="header-logo">MUYUE</span>
|
||||
<span className="header-version">v{info.version || '...'}</span>
|
||||
</div>
|
||||
|
||||
<div className="header-tabs">
|
||||
<nav className="header-nav">
|
||||
{TABS.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`header-tab ${activeTab === tab.id ? 'active' : ''}`}
|
||||
className={`nav-tab ${activeTab === tab.id ? 'active' : ''}`}
|
||||
onClick={() => switchTab(tab.id)}
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab.id}
|
||||
>
|
||||
{tab.icon} {tab.label}
|
||||
<span className="tab-icon">{tab.icon}</span>
|
||||
{tab.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="header-spacer" />
|
||||
|
||||
<div className="header-status">
|
||||
<span className={`status-dot ${tools.length > 0 ? 'ok' : 'off'}`} title="System" />
|
||||
<span className={`status-dot ${hasUpdates ? 'warn' : 'ok'}`} title="Updates" />
|
||||
<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-date">{clock.toLocaleDateString('fr-FR')}</span>
|
||||
<span className="header-clock">{clock.toLocaleTimeString('fr-FR')}</span>
|
||||
<span className="header-clock">
|
||||
{clock.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div className={`content ${transition ? 'glitch-text' : 'fade-in tab-transition'}`}>
|
||||
<main className="content fade-in" key={`${activeTab}-${TABS.length}`}>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className="footer">
|
||||
<span className="footer-shortcuts">
|
||||
<kbd>1-4</kbd> tabs · <kbd>Ctrl+T</kbd> switcher · <kbd>Ctrl+C</kbd> quit
|
||||
</span>
|
||||
<span className={`footer-update ${hasUpdates ? 'available' : 'uptodate'}`}>
|
||||
{hasUpdates ? '[UPD] Updates available' : '[OK] Up to date'}
|
||||
</span>
|
||||
<span className="footer-version">v{info.version || '...'}</span>
|
||||
<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>
|
||||
|
||||
{showOnboarding && <OnboardingWizard api={api} onComplete={() => setShowOnboarding(false)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FooterShortcuts({ shortcuts }) {
|
||||
return shortcuts.map((s, i) => (
|
||||
<span key={i} className="statusbar-shortcut">
|
||||
<kbd>{s.keys}</kbd> {s.desc}
|
||||
</span>
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1,98 +1,537 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getThemeNames, applyTheme, getTheme } from '../themes'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { User, Brain, RefreshCw, Globe, Wrench, Monitor } from 'lucide-react'
|
||||
import { useI18n, LANGUAGES } from '../i18n'
|
||||
import { getLayoutList } from '../i18n/keyboards'
|
||||
|
||||
export default function Config({ api, theme, onThemeChange }) {
|
||||
const PANELS = [
|
||||
{ id: 'profile', icon: User },
|
||||
{ id: 'providers', icon: Brain },
|
||||
{ id: 'updates', icon: RefreshCw },
|
||||
{ id: 'locale', icon: Globe },
|
||||
{ id: 'skills', icon: Wrench },
|
||||
{ id: 'system', icon: Monitor },
|
||||
]
|
||||
|
||||
export default function Config({ api }) {
|
||||
const { t, language, keyboard, setLanguage, setKeyboard } = useI18n()
|
||||
const [activePanel, setActivePanel] = useState('profile')
|
||||
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({}) // keyed by provider name
|
||||
const [toast, setToast] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.getConfig().then(d => setConfig(d)).catch(() => {})
|
||||
|
||||
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(() => {})
|
||||
|
||||
const themes = getThemeNames()
|
||||
}, [api])
|
||||
|
||||
const handleThemeChange = (themeId) => {
|
||||
const t = getTheme(themeId)
|
||||
applyTheme(t)
|
||||
onThemeChange(themeId)
|
||||
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 (name) => {
|
||||
const form = providerForm[name]
|
||||
if (!form) return
|
||||
try {
|
||||
await api.saveProvider({ name, ...form })
|
||||
setEditProvider(null)
|
||||
loadData()
|
||||
showToast(t('config.saved'))
|
||||
} catch (err) {
|
||||
showToast(`${t('config.error')}: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const openProviderEdit = (p) => {
|
||||
setProviderForm(prev => ({
|
||||
...prev,
|
||||
[p.name]: {
|
||||
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(tool => tool.installed).length
|
||||
const missingCount = tools.filter(tool => !tool.installed).length
|
||||
|
||||
return (
|
||||
<div className="config-window">
|
||||
{toast && <div className="config-toast">{toast}</div>}
|
||||
|
||||
<div className="config-tabs-bar">
|
||||
{PANELS.map(p => {
|
||||
const Icon = p.icon
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className={`nav-tab ${activePanel === p.id ? 'active' : ''}`}
|
||||
onClick={() => setActivePanel(p.id)}
|
||||
>
|
||||
<span className="tab-icon"><Icon size={15} /></span>
|
||||
{t(`config.panels.${p.id}`)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="config-panel-area">
|
||||
<div className="config-panel-body">
|
||||
{activePanel === 'profile' && (
|
||||
<PanelProfile
|
||||
config={config} editProfile={editProfile}
|
||||
profileForm={profileForm} setProfileForm={setProfileForm}
|
||||
setEditProfile={setEditProfile} handleSaveProfile={handleSaveProfile}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{activePanel === 'providers' && (
|
||||
<PanelProviders
|
||||
providers={providers} editProvider={editProvider}
|
||||
providerForm={providerForm} setProviderForm={setProviderForm}
|
||||
setEditProvider={setEditProvider} openProviderEdit={openProviderEdit}
|
||||
handleSaveProvider={handleSaveProvider} api={api} loadData={loadData}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{activePanel === 'updates' && (
|
||||
<PanelUpdates
|
||||
updates={updates} tools={tools}
|
||||
checking={checking} updating={updating}
|
||||
needsUpdateCount={needsUpdateCount}
|
||||
installedCount={installedCount} missingCount={missingCount}
|
||||
handleCheckUpdates={handleCheckUpdates}
|
||||
handleUpdateTool={handleUpdateTool}
|
||||
handleUpdateAll={handleUpdateAll}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{activePanel === 'locale' && (
|
||||
<PanelLocale
|
||||
language={keyboard} layouts={layouts}
|
||||
setLanguage={setLanguage} setKeyboard={setKeyboard}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{activePanel === 'skills' && (
|
||||
<PanelSkills skillList={skillList} t={t} />
|
||||
)}
|
||||
{activePanel === 'system' && (
|
||||
<PanelSystem api={api} t={t} />
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PanelProfile({ config, editProfile, profileForm, setProfileForm, setEditProfile, handleSaveProfile, t }) {
|
||||
return (
|
||||
<div className="config-card">
|
||||
{config?.profile && !editProfile ? (
|
||||
<>
|
||||
<div className="config-card-row">
|
||||
<span className="config-card-label">{t('config.name')}</span>
|
||||
<span className="config-card-value">{config.profile.name || '—'}</span>
|
||||
</div>
|
||||
<div className="config-card-row">
|
||||
<span className="config-card-label">{t('config.pseudo')}</span>
|
||||
<span className="config-card-value">{config.profile.pseudo || '—'}</span>
|
||||
</div>
|
||||
<div className="config-card-row">
|
||||
<span className="config-card-label">{t('config.email')}</span>
|
||||
<span className="config-card-value">{config.profile.email || '—'}</span>
|
||||
</div>
|
||||
<div className="config-card-row">
|
||||
<span className="config-card-label">{t('config.editor')}</span>
|
||||
<span className="config-card-value mono">{config.profile.preferences?.editor || '—'}</span>
|
||||
</div>
|
||||
<div className="config-card-row">
|
||||
<span className="config-card-label">{t('config.shell')}</span>
|
||||
<span className="config-card-value mono">{config.profile.preferences?.shell || '—'}</span>
|
||||
</div>
|
||||
<div className="config-card-row">
|
||||
<span className="config-card-label">{t('config.languages')}</span>
|
||||
<span className="config-card-value">{config.profile.languages?.join(', ') || '—'}</span>
|
||||
</div>
|
||||
<div className="config-card-actions">
|
||||
<button className="primary sm" onClick={() => setEditProfile(true)}>{t('config.editProfile')}</button>
|
||||
</div>
|
||||
</>
|
||||
) : editProfile ? (
|
||||
<>
|
||||
<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 }))} type="email" />
|
||||
<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-card-actions">
|
||||
<button className="primary sm" onClick={handleSaveProfile}>{t('config.save')}</button>
|
||||
<button className="ghost sm" onClick={() => setEditProfile(false)}>{t('config.cancel')}</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="empty-state">{t('config.loadingProfile')}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PanelProviders({ providers, editProvider, providerForm, setProviderForm, setEditProvider, openProviderEdit, handleSaveProvider, api, loadData, t }) {
|
||||
const [validating, setValidating] = useState(null)
|
||||
const [validationStatus, setValidationStatus] = useState(null)
|
||||
|
||||
const handleValidate = async (name, apiKey, model, baseUrl) => {
|
||||
setValidating(name)
|
||||
setValidationStatus(null)
|
||||
try {
|
||||
await api.validateProvider({ name, api_key: apiKey, model, base_url: baseUrl })
|
||||
setValidationStatus({ provider: name, valid: true })
|
||||
} catch (err) {
|
||||
const msg = err.message || ''
|
||||
if (msg.includes('invalid_api_key')) {
|
||||
setValidationStatus({ provider: name, valid: false, error: t('config.keyInvalid') })
|
||||
} else {
|
||||
setValidationStatus({ provider: name, valid: false, error: `${t('config.connectionFailed')}: ${msg}` })
|
||||
}
|
||||
}
|
||||
setValidating(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="config-container">
|
||||
<div className="config-section">
|
||||
<div className="section-header">Profile</div>
|
||||
{config?.profile && (
|
||||
<div>
|
||||
<Field label="Name" value={config.profile.name} />
|
||||
<Field label="Pseudo" value={config.profile.pseudo} />
|
||||
<Field label="Email" value={config.profile.email} />
|
||||
<Field label="Editor" value={config.profile.preferences?.editor} />
|
||||
<Field label="Shell" value={config.profile.preferences?.shell} />
|
||||
<Field label="Default AI" value={config.profile.preferences?.defaultAI} />
|
||||
<Field label="Languages" value={config.profile.languages?.join(', ')} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="config-section">
|
||||
<div className="section-header">AI Providers</div>
|
||||
{providers.map((p, i) => (
|
||||
<div key={i} className="config-field" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ color: 'var(--text-bright)', fontWeight: 700 }}>{p.name}</span>
|
||||
{p.active && <span style={{ color: 'var(--cyber-red)', fontSize: 11, fontWeight: 700 }}>{'>>'}</span>}
|
||||
<div className="config-providers-list">
|
||||
<div className="provider-setup-hint">{t('config.setupDescription')}</div>
|
||||
{providers.map((p, i) => {
|
||||
const isEditing = editProvider === p.name
|
||||
const isValidationTarget = validationStatus?.provider === p.name
|
||||
return (
|
||||
<div key={i} className="config-card provider-card-v2">
|
||||
<div className="provider-card-top">
|
||||
<div className="provider-card-identity">
|
||||
<span className="provider-card-name">{p.name}</span>
|
||||
{p.apiKey && <span className="badge ok">{t('config.keyConfigured')}</span>}
|
||||
{!p.apiKey && <span className="badge error">{t('config.noKey')}</span>}
|
||||
{isValidationTarget && validationStatus?.valid && <span className="badge ok">{t('config.keyValid')}</span>}
|
||||
{isValidationTarget && !validationStatus?.valid && <span className="badge error">{validationStatus?.error}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16, fontSize: 12 }}>
|
||||
<span style={{ color: 'var(--dim-red)' }}>model={p.model}</span>
|
||||
<span style={{ color: p.apiKey ? 'var(--success)' : 'var(--error)' }}>
|
||||
key={p.apiKey ? 'configured' : 'no key'}
|
||||
</span>
|
||||
|
||||
<div className="provider-card-form">
|
||||
<div className="provider-setup-token-row">
|
||||
<div className="provider-setup-token-input">
|
||||
<label className="config-form-label">{t('config.apiKey')}</label>
|
||||
<input
|
||||
className="config-form-input"
|
||||
type="password"
|
||||
placeholder={t('config.tokenPlaceholder')}
|
||||
value={isEditing ? (providerForm[p.name]?.api_key || '') : ''}
|
||||
onChange={e => {
|
||||
if (!isEditing) openProviderEdit(p)
|
||||
setProviderForm(prev => ({
|
||||
...prev,
|
||||
[p.name]: { ...(prev[p.name] || {}), api_key: e.target.value },
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="provider-setup-token-actions">
|
||||
<button
|
||||
className="sm primary"
|
||||
disabled={validating === p.name || !providerForm[p.name]?.api_key}
|
||||
onClick={() => handleValidate(p.name, providerForm[p.name]?.api_key, providerForm[p.name]?.model, providerForm[p.name]?.base_url)}
|
||||
>
|
||||
{validating === p.name ? t('config.validating') : t('config.validateKey')}
|
||||
</button>
|
||||
{isValidationTarget && validationStatus?.valid && (
|
||||
<button className="sm" onClick={() => handleSaveProvider(p.name)}>{t('config.save')}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="provider-card-meta" style={{ marginTop: 8 }}>
|
||||
<span className="mono">{p.model || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div className="config-section">
|
||||
<div className="section-header">Theme</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{themes.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
className={theme === t.id ? 'primary' : ''}
|
||||
onClick={() => handleThemeChange(t.id)}
|
||||
>
|
||||
{t.name}
|
||||
function PanelUpdates({ updates, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleUpdateAll, t }) {
|
||||
return (
|
||||
<>
|
||||
<div className="config-card">
|
||||
<div className="config-update-controls">
|
||||
<div className="config-update-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>
|
||||
<div className="config-update-buttons">
|
||||
<button className="sm" onClick={handleCheckUpdates} disabled={checking}>
|
||||
{checking ? <><RefreshCw size={12} className="spin-icon" /> {t('config.checking')}</> : t('config.checkUpdates')}
|
||||
</button>
|
||||
))}
|
||||
{needsUpdateCount > 0 && (
|
||||
<button className="sm primary" onClick={handleUpdateAll} disabled={updating === '__all__'}>
|
||||
{updating === '__all__' ? t('config.updating') : `${t('config.updateAll')} (${needsUpdateCount})`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="config-section">
|
||||
<div className="section-header">Skills ({skillList.length})</div>
|
||||
{skillList.length === 0 ? (
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: 12 }}>No skills. Run `muyue skills init`.</span>
|
||||
) : (
|
||||
skillList.map((s, i) => (
|
||||
<div key={i} className="tool-item">
|
||||
<span className="tool-name">{s.name}</span>
|
||||
<span style={{ color: 'var(--cyber-red)', fontSize: 11 }}>[{s.target || 'both'}]</span>
|
||||
<span style={{ color: 'var(--dim-red)', fontSize: 11 }}>{s.description}</span>
|
||||
{updates.length === 0 ? (
|
||||
<div className="config-card">
|
||||
<div className="empty-state">{t('config.noUpdates')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="config-update-list">
|
||||
{updates.map((u, i) => (
|
||||
<div key={i} className="config-update-row">
|
||||
<div className="config-update-info">
|
||||
<span className="config-update-name">{u.tool}</span>
|
||||
<span className="config-update-versions">
|
||||
{u.needsUpdate ? (
|
||||
<>{u.current} → <span style={{ color: 'var(--warning)' }}>{u.latest}</span></>
|
||||
) : (
|
||||
<span style={{ color: 'var(--success)' }}>{u.current}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{u.needsUpdate && (
|
||||
<button
|
||||
className="sm"
|
||||
onClick={() => handleUpdateTool(u.tool)}
|
||||
disabled={updating === u.tool}
|
||||
>
|
||||
{updating === u.tool ? t('config.updating') : t('config.updateTool')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PanelLocale({ language, keyboard, layouts, setLanguage, setKeyboard, t }) {
|
||||
return (
|
||||
<div className="config-card">
|
||||
<div className="config-card-group">
|
||||
<span className="config-card-group-label">{t('config.language')}</span>
|
||||
<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-card-group">
|
||||
<span className="config-card-group-label">{t('config.keyboardLayout')}</span>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, value }) {
|
||||
function PanelSkills({ skillList, t }) {
|
||||
return (
|
||||
<div className="config-field">
|
||||
<span className="config-label">{label}:</span>
|
||||
<span className="config-value">{value || '-'}</span>
|
||||
<div className="config-card">
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
function PanelSystem({ api, t }) {
|
||||
const [resetConfirm, setResetConfirm] = useState(false)
|
||||
const [toast, setToast] = useState(null)
|
||||
|
||||
const showToast = (msg) => {
|
||||
setToast(msg)
|
||||
setTimeout(() => setToast(null), 3000)
|
||||
}
|
||||
|
||||
const handleReset = async () => {
|
||||
try {
|
||||
await api.resetConfig()
|
||||
setResetConfirm(false)
|
||||
showToast(t('config.resetDone'))
|
||||
setTimeout(() => window.location.reload(), 1500)
|
||||
} catch (err) {
|
||||
showToast(`${t('config.error')}: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleApplyStarship = async () => {
|
||||
try {
|
||||
await api.applyStarshipTheme('charm')
|
||||
showToast(t('config.starshipApplied'))
|
||||
} catch (err) {
|
||||
showToast(`${t('config.error')}: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{toast && <div className="config-toast">{toast}</div>}
|
||||
<div className="config-card">
|
||||
<div className="config-card-row" style={{ marginBottom: 16 }}>
|
||||
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.applyStarship')}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 12 }}>
|
||||
{t('config.starshipApplied')}
|
||||
</div>
|
||||
<button className="sm primary" onClick={handleApplyStarship}>
|
||||
{t('config.applyStarship')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="config-card" style={{ marginTop: 12 }}>
|
||||
<div className="config-card-row" style={{ marginBottom: 16 }}>
|
||||
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.resetConfig')}</span>
|
||||
</div>
|
||||
{resetConfirm ? (
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--warning)', marginBottom: 12 }}>
|
||||
{t('config.resetConfirm')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="sm" onClick={() => setResetConfirm(false)}>{t('config.cancel')}</button>
|
||||
<button className="sm danger" onClick={handleReset}>{t('config.resetConfig')}</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button className="sm ghost danger" onClick={() => setResetConfirm(true)}>
|
||||
{t('config.resetConfig')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function FormInput({ label, value, onChange, type = 'text' }) {
|
||||
return (
|
||||
<div className="config-form-field">
|
||||
<label className="config-form-label">{label}</label>
|
||||
<input
|
||||
className="config-form-input"
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,121 +1,58 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
export default function Dashboard({ tools, updates, api, onRescan }) {
|
||||
const [installing, setInstalling] = useState(false)
|
||||
const [installLog, setInstallLog] = useState([])
|
||||
|
||||
const installed = tools.filter(t => t.installed).length
|
||||
const total = tools.length
|
||||
const pct = total > 0 ? (installed / total) * 100 : 0
|
||||
const missing = tools.filter(t => !t.installed).map(t => t.Name || t.name)
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (missing.length === 0) return
|
||||
setInstalling(true)
|
||||
setInstallLog(prev => [...prev, { text: `Installing ${missing.length} tools...`, type: 'info' }])
|
||||
try {
|
||||
await api.installTools(missing)
|
||||
setInstallLog(prev => [...prev, { text: 'Install started. Rescan to see changes.', type: 'ok' }])
|
||||
const data = await api.runScan()
|
||||
const toolData = await api.getTools()
|
||||
onRescan(toolData.tools || [])
|
||||
} catch (err) {
|
||||
setInstallLog(prev => [...prev, { text: err.message, type: 'error' }])
|
||||
}
|
||||
setInstalling(false)
|
||||
}
|
||||
|
||||
const handleScan = async () => {
|
||||
await api.runScan()
|
||||
const data = await api.getTools()
|
||||
onRescan(data.tools || [])
|
||||
}
|
||||
export default function Dashboard({ api }) {
|
||||
const { t } = useI18n()
|
||||
const [notifications, setNotifications] = useState([])
|
||||
|
||||
return (
|
||||
<div className="grid-2">
|
||||
<div style={{ overflow: 'auto', padding: '4px' }}>
|
||||
<div className="section-header">System</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<span style={{ color: 'var(--text-main)' }}>{installed}/{total} tools installed</span>
|
||||
</div>
|
||||
|
||||
<div className="section-header">Installed Tools</div>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
{tools.map((t, i) => (
|
||||
<div key={i} className="tool-item">
|
||||
<span className={`tool-status ${t.installed ? 'ok' : 'missing'}`}>
|
||||
{t.installed ? '[OK]' : '[--]'}
|
||||
</span>
|
||||
<span className="tool-name">{t.Name || t.name}</span>
|
||||
{(t.Version || t.version) && (
|
||||
<span className="tool-version">{extractVersion(t.Version || t.version)}</span>
|
||||
)}
|
||||
<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('studio.workflows')}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="progress-bar" style={{ marginBottom: 16 }}>
|
||||
<div className="progress-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
|
||||
{installing && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<span className="loading-spinner"> Installing...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{installLog.length > 0 && (
|
||||
<div>
|
||||
<div className="section-header">Install Log</div>
|
||||
{installLog.map((log, i) => (
|
||||
<div key={i} style={{
|
||||
color: log.type === 'error' ? 'var(--error)' :
|
||||
log.type === 'ok' ? 'var(--success)' : 'var(--text-dim)',
|
||||
fontSize: 12, padding: '2px 0'
|
||||
}}>
|
||||
{log.text}
|
||||
<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>
|
||||
|
||||
<div style={{ overflow: 'auto', padding: '4px' }}>
|
||||
<div className="section-header">Quick Actions</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 16 }}>
|
||||
<button onClick={handleInstall} disabled={installing || missing.length === 0}>
|
||||
[i] Install missing ({missing.length})
|
||||
</button>
|
||||
<button onClick={() => api.getUpdates().then(d => {})}> [u] Check updates</button>
|
||||
<button onClick={handleScan}>[s] Rescan system</button>
|
||||
<button onClick={() => api.configureMCP()}>[m] Configure MCP</button>
|
||||
</div>
|
||||
|
||||
<div className="section-header">Updates</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
{updates.length === 0 ? (
|
||||
<span style={{ color: 'var(--text-muted)' }}>No update data yet</span>
|
||||
) : updates.map((u, i) => (
|
||||
<div key={i} className="tool-item">
|
||||
<span className={`tool-status ${u.needsUpdate ? 'missing' : 'ok'}`}>
|
||||
{u.needsUpdate ? '[!!]' : '[OK]'}
|
||||
</span>
|
||||
<span className="tool-name">{u.tool}</span>
|
||||
{u.needsUpdate && (
|
||||
<span style={{ color: 'var(--warning)', fontSize: 11 }}>
|
||||
{u.current} → {u.latest}
|
||||
</span>
|
||||
<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(undefined, { 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)
|
||||
}
|
||||
|
||||
262
web/src/components/OnboardingWizard.jsx
Normal file
262
web/src/components/OnboardingWizard.jsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Sparkles, ArrowRight, ArrowLeft } from 'lucide-react'
|
||||
import { useI18n, LANGUAGES } from '../i18n'
|
||||
import { getLayoutList } from '../i18n/keyboards'
|
||||
|
||||
const STEPS = [
|
||||
{ key: 'welcome', title: 'welcome', field: null },
|
||||
{ key: 'name', title: 'name', field: 'name' },
|
||||
{ key: 'language', title: 'language', field: 'language' },
|
||||
{ key: 'keyboard', title: 'keyboard', field: 'keyboard' },
|
||||
{ key: 'editor', title: 'editor', field: 'editor' },
|
||||
{ key: 'done', title: 'done', field: null },
|
||||
]
|
||||
|
||||
const EDITOR_SUGGESTIONS = ['vim', 'nvim', 'vscode', 'emacs', 'nano', 'helix']
|
||||
|
||||
export default function OnboardingWizard({ api, onComplete }) {
|
||||
const { t, language, keyboard, setLanguage, setKeyboard } = useI18n()
|
||||
const [step, setStep] = useState(0)
|
||||
const [answers, setAnswers] = useState({
|
||||
name: '',
|
||||
language: 'fr',
|
||||
keyboard: 'azerty',
|
||||
editor: '',
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const current = STEPS[step]
|
||||
const layouts = getLayoutList()
|
||||
|
||||
const goNext = () => {
|
||||
if (step < STEPS.length - 1) setStep(step + 1)
|
||||
}
|
||||
|
||||
const goPrev = () => {
|
||||
if (step > 0) setStep(step - 1)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
if (e.key === 'Escape') { goPrev(); return }
|
||||
if (e.key === 'Enter' && current.key !== 'done') { e.preventDefault(); goNext() }
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [step, current])
|
||||
|
||||
useEffect(() => {
|
||||
if (current.key === 'done' && !saving) {
|
||||
handleSave()
|
||||
}
|
||||
}, [step])
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await api.saveProfile({
|
||||
name: answers.name,
|
||||
pseudo: answers.name.split(' ')[0] || 'user',
|
||||
editor: answers.editor,
|
||||
})
|
||||
await api.savePreferences({
|
||||
language: answers.language,
|
||||
keyboard_layout: answers.keyboard,
|
||||
})
|
||||
onComplete()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Erreur lors de la sauvegarde')
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="onboarding-overlay">
|
||||
<div className="onboarding-card">
|
||||
<div className="onboarding-header">
|
||||
<Sparkles size={20} style={{ color: 'var(--accent)' }} />
|
||||
<span> Muyue Setup</span>
|
||||
</div>
|
||||
|
||||
<div className="onboarding-progress">
|
||||
{STEPS.map((_, i) => (
|
||||
<div key={i} className={`onboarding-dot ${i === step ? 'active' : ''} ${i < step ? 'done' : ''}`} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="onboarding-body">
|
||||
{current.key === 'welcome' && (
|
||||
<div className="onboarding-step">
|
||||
<div className="onboarding-title">Bienvenue ! 👋</div>
|
||||
<div className="onboarding-desc">
|
||||
Je suis votre assistant de configuration. Quelques questions rapides pour personnaliser votre expérience.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{current.key === 'name' && (
|
||||
<div className="onboarding-step">
|
||||
<div className="onboarding-title">Comment vous appelez-vous ?</div>
|
||||
<input
|
||||
className="onboarding-input"
|
||||
placeholder="Votre nom..."
|
||||
value={answers.name}
|
||||
onChange={e => setAnswers(a => ({ ...a, name: e.target.value }))}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{current.key === 'language' && (
|
||||
<div className="onboarding-step">
|
||||
<div className="onboarding-title">Quelle langue pr\u00e9f\u00e9rez-vous ?</div>
|
||||
<div className="onboarding-chips">
|
||||
{LANGUAGES.map(lang => (
|
||||
<div
|
||||
key={lang.id}
|
||||
className={`chip ${answers.language === lang.id ? 'active' : ''}`}
|
||||
onClick={() => setAnswers(a => ({ ...a, language: lang.id }))}
|
||||
>
|
||||
{lang.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{current.key === 'keyboard' && (
|
||||
<div className="onboarding-step">
|
||||
<div className="onboarding-title">Disposition du clavier ?</div>
|
||||
<div className="onboarding-chips">
|
||||
{layouts.map(l => (
|
||||
<div
|
||||
key={l.id}
|
||||
className={`chip ${answers.keyboard === l.id ? 'active' : ''}`}
|
||||
onClick={() => setAnswers(a => ({ ...a, keyboard: l.id }))}
|
||||
>
|
||||
{l.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{current.key === 'editor' && (
|
||||
<div className="onboarding-step">
|
||||
<div className="onboarding-title">Quel \u00e9diteur utilisez-vous ?</div>
|
||||
<div className="onboarding-chips">
|
||||
{EDITOR_SUGGESTIONS.map(ed => (
|
||||
<div
|
||||
key={ed}
|
||||
className={`chip ${answers.editor === ed ? 'active' : ''}`}
|
||||
onClick={() => setAnswers(a => ({ ...a, editor: ed }))}
|
||||
>
|
||||
{ed}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
className="onboarding-input"
|
||||
style={{ marginTop: 12 }}
|
||||
placeholder="Autre (vim, nvim, vscode...)"
|
||||
value={answers.editor}
|
||||
onChange={e => setAnswers(a => ({ ...a, editor: e.target.value }))}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{current.key === 'done' && (
|
||||
<div className="onboarding-step">
|
||||
{saving ? (
|
||||
<>
|
||||
<div className="onboarding-title">Configuration en cours...</div>
|
||||
<div className="onboarding-desc">Sauvegarde de vos préférences.</div>
|
||||
</>
|
||||
) : error ? (
|
||||
<>
|
||||
<div className="onboarding-title" style={{ color: 'var(--error)' }}>Erreur</div>
|
||||
<div className="onboarding-desc" style={{ color: 'var(--error)' }}>{error}</div>
|
||||
<button className="primary" style={{ alignSelf: 'flex-start', marginTop: 8 }} onClick={() => handleSave()}>Réessayer</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="onboarding-title">C'est parti ! 🚀</div>
|
||||
<div className="onboarding-desc">
|
||||
Votre profil est configuré. Vous pouvez toujours ajuster les paramètres dans l'onglet Configuration.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="onboarding-footer">
|
||||
{step > 0 && step < STEPS.length - 1 && (
|
||||
<button className="ghost" onClick={goPrev}>
|
||||
<ArrowLeft size={14} /> Précédent
|
||||
</button>
|
||||
)}
|
||||
<div style={{ flex: 1 }} />
|
||||
{step < STEPS.length - 1 && (
|
||||
<button className="primary" onClick={goNext}>
|
||||
Suivant <ArrowRight size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.onboarding-overlay {
|
||||
position: fixed; inset: 0; z-index: 500;
|
||||
background: rgba(10,10,12,0.85);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.onboarding-card {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
width: 480px; max-width: 90vw;
|
||||
box-shadow: 0 24px 64px rgba(0,0,0,0.5);
|
||||
overflow: hidden;
|
||||
}
|
||||
.onboarding-header {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 16px 20px; font-size: 14px; font-weight: 700;
|
||||
color: var(--accent); border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
.onboarding-progress {
|
||||
display: flex; gap: 6px; padding: 14px 20px;
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.onboarding-dot {
|
||||
width: 32px; height: 4px; border-radius: 2px;
|
||||
background: var(--bg-input); transition: all 0.3s;
|
||||
}
|
||||
.onboarding-dot.active { background: var(--accent); }
|
||||
.onboarding-dot.done { background: var(--accent-dim); }
|
||||
.onboarding-body { padding: 28px 24px; min-height: 200px; }
|
||||
.onboarding-step { display: flex; flex-direction: column; gap: 16px; }
|
||||
.onboarding-title { font-size: 18px; font-weight: 700; color: var(--text-primary); }
|
||||
.onboarding-desc { font-size: 14px; color: var(--text-tertiary); line-height: 1.6; }
|
||||
.onboarding-input {
|
||||
width: 100%; background: var(--bg-input); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 10px 14px; color: var(--text-primary);
|
||||
font-size: 14px; outline: none; transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.onboarding-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--border-accent); }
|
||||
.onboarding-chips { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.onboarding-footer {
|
||||
display: flex; justify-content: flex-end; gap: 8px;
|
||||
padding: 16px 20px; border-top: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,147 +1,622 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
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 THEMES = {
|
||||
default: {
|
||||
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',
|
||||
},
|
||||
monokai: {
|
||||
background: '#272822', foreground: '#F8F8F2', cursor: '#F8F8F0',
|
||||
cursorAccent: '#272822', selectionBackground: '#F8F8F244', selectionForeground: '#ffffff',
|
||||
black: '#272822', red: '#F92672', green: '#A6E22E', yellow: '#E6DB74',
|
||||
blue: '#66D9EF', magenta: '#AE81FF', cyan: '#A1EFE4', white: '#F8F8F2',
|
||||
brightBlack: '#75715E', brightRed: '#F92672', brightGreen: '#A6E22E',
|
||||
brightYellow: '#E6DB74', brightBlue: '#66D9EF', brightMagenta: '#AE81FF',
|
||||
brightCyan: '#A1EFE4', brightWhite: '#F8F8F2',
|
||||
},
|
||||
gruvbox: {
|
||||
background: '#282828', foreground: '#EBDBB2', cursor: '#FB4934',
|
||||
cursorAccent: '#282828', selectionBackground: '#EBDBB244', selectionForeground: '#ffffff',
|
||||
black: '#282828', red: '#CC241D', green: '#98971A', yellow: '#D79921',
|
||||
blue: '#458588', magenta: '#B16286', cyan: '#689D6A', white: '#EBDBB2',
|
||||
brightBlack: '#928374', brightRed: '#FB4934', brightGreen: '#B8BB26',
|
||||
brightYellow: '#FABC2A', brightBlue: '#83A598', brightMagenta: '#D3869B',
|
||||
brightCyan: '#8EC07C', brightWhite: '#EBDBB2',
|
||||
},
|
||||
nord: {
|
||||
background: '#2E3440', foreground: '#D8DEE9', cursor: '#D8DEE9',
|
||||
cursorAccent: '#2E3440', selectionBackground: '#D8DEE944', selectionForeground: '#ffffff',
|
||||
black: '#2E3440', red: '#BF616A', green: '#A3BE8C', yellow: '#EBCB8B',
|
||||
blue: '#81A1C1', magenta: '#B48EAD', cyan: '#88C0D0', white: '#D8DEE9',
|
||||
brightBlack: '#4C566A', brightRed: '#BF616A', brightGreen: '#A3BE8C',
|
||||
brightYellow: '#EBCB8B', brightBlue: '#81A1C1', brightMagenta: '#B48EAD',
|
||||
brightCyan: '#8FBCBB', brightWhite: '#ECEFF4',
|
||||
},
|
||||
'solarized-dark': {
|
||||
background: '#002B36', foreground: '#839496', cursor: '#D33682',
|
||||
cursorAccent: '#002B36', selectionBackground: '#83949644', selectionForeground: '#ffffff',
|
||||
black: '#002B36', red: '#DC322F', green: '#859900', yellow: '#B58900',
|
||||
blue: '#268BD2', magenta: '#D33682', cyan: '#2AA198', white: '#FDF6E3',
|
||||
brightBlack: '#073642', brightRed: '#CB4B16', brightGreen: '#586E75',
|
||||
brightYellow: '#657B83', brightBlue: '#6C71C4', brightMagenta: '#6C71C4',
|
||||
brightCyan: '#93A1A1', brightWhite: '#FDF6E3',
|
||||
},
|
||||
dracula: {
|
||||
background: '#282A36', foreground: '#F8F8F2', cursor: '#F8F8F2',
|
||||
cursorAccent: '#282A36', selectionBackground: '#F8F8F244', selectionForeground: '#ffffff',
|
||||
black: '#282A36', red: '#FF5555', green: '#50FA7B', yellow: '#F1FA8C',
|
||||
blue: '#BD93F9', magenta: '#FF79C6', cyan: '#8BE9FD', white: '#F8F8F2',
|
||||
brightBlack: '#6272A4', brightRed: '#FF6E6E', brightGreen: '#69FF94',
|
||||
brightYellow: '#FFFFA5', brightBlue: '#D6ACFF', brightMagenta: '#FF92DF',
|
||||
brightCyan: '#A4FFFF', brightWhite: '#FFFFFF',
|
||||
},
|
||||
}
|
||||
|
||||
function getTheme(themeName) {
|
||||
return THEMES[themeName] || THEMES.default
|
||||
}
|
||||
|
||||
function createTerminal(container, settings = {}) {
|
||||
const theme = getTheme(settings.theme || 'default')
|
||||
const term = new XTerm({
|
||||
cursorBlink: true,
|
||||
fontSize: settings.fontSize || 14,
|
||||
fontFamily: settings.fontFamily || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
||||
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.addEventListener('open', () => {
|
||||
ws.send(JSON.stringify(initPayload))
|
||||
const dims = fitAddon.proposeDimensions()
|
||||
if (dims) {
|
||||
ws.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols }))
|
||||
}
|
||||
})
|
||||
|
||||
ws.addEventListener('message', (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.addEventListener('close', () => {
|
||||
term.write('\r\n\x1b[33m— Connection closed —\x1b[0m\r\n')
|
||||
})
|
||||
|
||||
ws.addEventListener('error', () => {
|
||||
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 [history, setHistory] = useState([])
|
||||
const [input, setInput] = useState('')
|
||||
const [cwd, setCwd] = useState('~')
|
||||
const [aiPanel, setAiPanel] = useState(true)
|
||||
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 [terminalSettings, setTerminalSettings] = useState({
|
||||
fontSize: 14,
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
||||
theme: 'default',
|
||||
})
|
||||
|
||||
const [sshForm, setSshForm] = useState({
|
||||
name: '', host: '', port: 22, user: '', key_path: '',
|
||||
})
|
||||
|
||||
const [aiMessages, setAiMessages] = useState([
|
||||
{ role: 'ai', content: '>> I know your system inside out. Ask me anything.' }
|
||||
{ role: 'ai', content: t('shell.aiWelcome') }
|
||||
])
|
||||
const [aiInput, setAiInput] = useState('')
|
||||
const [aiLoading, setAiLoading] = useState(false)
|
||||
const outputRef = useRef(null)
|
||||
const aiMessagesRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
outputRef.current?.scrollTo(0, outputRef.current.scrollHeight)
|
||||
}, [history])
|
||||
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
|
||||
}, [aiMessages])
|
||||
|
||||
const handleCommand = async (cmd) => {
|
||||
if (!cmd.trim()) return
|
||||
if (cmd === 'clear') {
|
||||
setHistory([])
|
||||
return
|
||||
useEffect(() => {
|
||||
api.getTerminalSessions().then(d => {
|
||||
setSshConnections(d.ssh || [])
|
||||
setSystemTerminals(d.system || [])
|
||||
}).catch(() => {})
|
||||
api.getConfig().then(d => {
|
||||
if (d.terminal) {
|
||||
setTerminalSettings({
|
||||
fontSize: d.terminal.font_size || 14,
|
||||
fontFamily: d.terminal.font_family || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
||||
theme: d.terminal.theme || 'default',
|
||||
})
|
||||
}
|
||||
}).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, {
|
||||
fontSize: terminalSettings.fontSize,
|
||||
fontFamily: terminalSettings.fontFamily,
|
||||
theme: terminalSettings.theme,
|
||||
})
|
||||
|
||||
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 || '',
|
||||
}
|
||||
}
|
||||
if (cmd === 'exit' || cmd === 'quit') return
|
||||
|
||||
setHistory(prev => [...prev, { type: 'input', text: `${cwd} $ ${cmd}` }])
|
||||
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) return
|
||||
|
||||
const container = document.getElementById(`terminal-${tab.id}`)
|
||||
if (!container) return
|
||||
|
||||
if (!tabsRef.current[tab.id]) {
|
||||
const timer = setTimeout(() => {
|
||||
initTerminal(tab.id, tab)
|
||||
requestAnimationFrame(() => {
|
||||
const entry = tabsRef.current[tab.id]
|
||||
if (entry) entry.fitAddon.fit()
|
||||
})
|
||||
}, 100)
|
||||
return () => clearTimeout(timer)
|
||||
} else {
|
||||
requestAnimationFrame(() => {
|
||||
const entry = tabsRef.current[tab.id]
|
||||
if (entry) entry.fitAddon.fit()
|
||||
})
|
||||
}
|
||||
}, [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 && next.length > 0) {
|
||||
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 {
|
||||
const res = await api.runCommand(cmd, cwd === '~' ? '' : cwd)
|
||||
if (res.output) {
|
||||
setHistory(prev => [...prev, { type: 'output', text: res.output }])
|
||||
}
|
||||
if (res.error) {
|
||||
setHistory(prev => [...prev, { type: 'error', text: res.error }])
|
||||
}
|
||||
if (cmd.startsWith('cd ')) {
|
||||
const dir = cmd.slice(3).trim()
|
||||
setCwd(dir === '~' ? '~' : dir)
|
||||
}
|
||||
await api.addSSHConnection(sshForm)
|
||||
setSshConnections(prev => [...prev, { ...sshForm }])
|
||||
setSshForm({ name: '', host: '', port: 22, user: '', key_path: '' })
|
||||
setShowSshModal(false)
|
||||
} catch (err) {
|
||||
setHistory(prev => [...prev, { type: 'error', text: err.message }])
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleCommand(input)
|
||||
setInput('')
|
||||
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 }])
|
||||
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 || 'No response') }])
|
||||
const output = res.output || t('shell.noResponse')
|
||||
parseAndAddAiMessages(output)
|
||||
} catch (err) {
|
||||
setAiMessages(prev => [...prev, { role: 'ai', content: '[ERROR] ' + err.message }])
|
||||
setAiMessages(prev => [...prev, { role: 'ai', content: `${t('shell.error')}: ${err.message}` }])
|
||||
}
|
||||
setAiLoading(false)
|
||||
}
|
||||
|
||||
const parseAndAddAiMessages = (text) => {
|
||||
const lines = text.split('\n')
|
||||
let buffer = ''
|
||||
let inBlock = false
|
||||
|
||||
const flushBuffer = () => {
|
||||
if (buffer.trim()) {
|
||||
setAiMessages(prev => [...prev, { role: 'ai', content: buffer.trim() }])
|
||||
}
|
||||
buffer = ''
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const toolMatch = line.match(/^\[TOOL_CALL:\{.*\}\]$/)
|
||||
if (toolMatch) {
|
||||
flushBuffer()
|
||||
try {
|
||||
const toolData = JSON.parse(toolMatch[0].slice(10, -1))
|
||||
setAiMessages(prev => [...prev, {
|
||||
role: 'tool',
|
||||
content: `${t('shell.toolLaunched')}: ${toolData.tool || 'tool'}`,
|
||||
args: toolData.task || toolData.args || '',
|
||||
}])
|
||||
} catch {
|
||||
setAiMessages(prev => [...prev, { role: 'tool', content: line, args: '' }])
|
||||
}
|
||||
} else if (line.match(/^(Reflexion|Thought|thinking):/i) || line.startsWith('>')) {
|
||||
if (buffer.trim() && !inBlock) {
|
||||
flushBuffer()
|
||||
}
|
||||
inBlock = true
|
||||
const cleaned = line.replace(/^(Reflexion|Thought|thinking):\s*/i, '').replace(/^>\s*/, '')
|
||||
if (buffer) buffer += ' '
|
||||
buffer += cleaned
|
||||
} else {
|
||||
if (inBlock && buffer.trim()) {
|
||||
setAiMessages(prev => [...prev, { role: 'thinking', content: buffer.trim() }])
|
||||
buffer = ''
|
||||
}
|
||||
inBlock = false
|
||||
if (buffer) buffer += '\n'
|
||||
buffer += line
|
||||
}
|
||||
}
|
||||
flushBuffer()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="split-horizontal" style={{ height: '100%' }}>
|
||||
<div className="terminal-container" style={{ flex: 1 }}>
|
||||
<div style={{ padding: '8px 12px', borderBottom: '1px solid var(--border-dim)' }}>
|
||||
<div className="section-header" style={{ margin: 0 }}>
|
||||
Terminal
|
||||
<span style={{ color: 'var(--dim-red)', fontWeight: 400, marginLeft: 12, fontSize: 11 }}>{cwd}</span>
|
||||
<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="terminal-output" ref={outputRef}>
|
||||
{history.map((line, i) => (
|
||||
<div key={i} style={{
|
||||
color: line.type === 'input' ? 'var(--dim-red)' :
|
||||
line.type === 'error' ? 'var(--error)' : 'var(--text-main)',
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.4,
|
||||
}}>
|
||||
{line.text}
|
||||
</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 className="terminal-input-row">
|
||||
<span className="terminal-prompt">{'>'}</span>
|
||||
<input
|
||||
className="terminal-input"
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{aiPanel && (
|
||||
<div style={{
|
||||
width: 320,
|
||||
borderLeft: '1px solid var(--border-dim)',
|
||||
background: 'var(--bg-surface)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}>
|
||||
<div style={{ padding: '8px 12px', borderBottom: '1px solid var(--border-dim)' }}>
|
||||
<div className="section-header" style={{ margin: 0 }}>AI Assistant</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}
|
||||
{msg.args && <div className="tool-args">{msg.args}</div>}
|
||||
</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>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: 12 }}>
|
||||
{aiMessages.map((msg, i) => (
|
||||
<div key={i} style={{
|
||||
marginBottom: 8,
|
||||
padding: '6px 8px',
|
||||
borderRadius: 'var(--radius)',
|
||||
background: msg.role === 'ai' ? 'var(--bg-card)' : 'var(--muted-red)',
|
||||
borderLeft: `3px solid ${msg.role === 'ai' ? 'var(--cyber-red)' : 'var(--cyber-rose)'}`,
|
||||
fontSize: 12,
|
||||
lineHeight: 1.4,
|
||||
}}>
|
||||
{msg.content}
|
||||
{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>
|
||||
))}
|
||||
{aiLoading && <span className="loading-spinner"> thinking...</span>}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '8px 12px', borderTop: '1px solid var(--border-dim)', display: 'flex', gap: 6 }}>
|
||||
<input
|
||||
style={{ flex: 1, padding: '4px 8px', fontSize: 12 }}
|
||||
value={aiInput}
|
||||
onChange={e => setAiInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleAiSend()}
|
||||
placeholder="Ask AI..."
|
||||
/>
|
||||
<button style={{ padding: '4px 8px' }} onClick={handleAiSend}>Send</button>
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -1,35 +1,278 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
const RANKS = {
|
||||
commandant: { label: 'Commandant', short: 'CDT', color: '#FFD740' },
|
||||
general: { label: 'General', short: 'GEN', color: '#FF9100' },
|
||||
colonel: { label: 'Colonel', short: 'COL', color: '#FF6D00' },
|
||||
lieutenant: { label: 'Lieutenant', short: 'LT', color: '#448AFF' },
|
||||
soldat: { label: 'Soldat', short: 'SDT', color: '#00E676' },
|
||||
}
|
||||
|
||||
function getRank(role) {
|
||||
if (role === 'user') return RANKS.commandant
|
||||
if (role === 'system') return null
|
||||
return RANKS.general
|
||||
}
|
||||
|
||||
function RankIcon({ rank }) {
|
||||
if (rank === RANKS.commandant) {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={rank.color} strokeWidth="2">
|
||||
<polygon points="12,2 15.09,8.26 22,9.27 17,14.14 18.18,21.02 12,17.77 5.82,21.02 7,14.14 2,9.27 8.91,8.26"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={rank.color} 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>
|
||||
)
|
||||
}
|
||||
|
||||
function renderContent(text) {
|
||||
const parts = []
|
||||
const codeBlockRegex = /(```[\s\S]*?```)/g
|
||||
let match
|
||||
let lastIndex = 0
|
||||
while ((match = codeBlockRegex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({ type: 'text', content: text.slice(lastIndex, match.index) })
|
||||
}
|
||||
const full = match[1]
|
||||
const firstNewline = full.indexOf('\n')
|
||||
const lang = firstNewline > -1 ? full.slice(3, firstNewline).trim() : ''
|
||||
const code = firstNewline > -1 ? full.slice(firstNewline + 1, -3) : full.slice(3, -3)
|
||||
parts.push({ type: 'code', lang, content: code })
|
||||
lastIndex = match.index + full.length
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
parts.push({ type: 'text', content: text.slice(lastIndex) })
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
function formatText(text) {
|
||||
return 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>')
|
||||
}
|
||||
|
||||
function ThinkingBlock({ content, done }) {
|
||||
return (
|
||||
<div className={`feed-thinking-block ${done ? 'done' : 'active'}`}>
|
||||
<div className="feed-thinking-header">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>
|
||||
</svg>
|
||||
<span>Reflexion</span>
|
||||
{!done && <span className="feed-thinking-dots"><span/><span/><span/></span>}
|
||||
</div>
|
||||
<div className="feed-thinking-content">{content}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FeedItem({ msg }) {
|
||||
const isUser = msg.role === 'user'
|
||||
const isSystem = msg.role === 'system'
|
||||
const rank = getRank(msg.role)
|
||||
|
||||
const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''
|
||||
|
||||
if (isSystem) {
|
||||
return (
|
||||
<div className="feed-item system">
|
||||
<div className="feed-system-badge" />
|
||||
<div className="feed-system-text">{msg.content}</div>
|
||||
{timeStr && <span className="feed-time">{timeStr}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const cleanContent = msg.content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||||
|
||||
return (
|
||||
<div className={`feed-item ${msg.role}`}>
|
||||
<div className={`feed-avatar ${isUser ? 'user-rank' : 'ai-rank'}`}>
|
||||
<RankIcon rank={rank} />
|
||||
</div>
|
||||
<div className="feed-body">
|
||||
<div className="feed-header">
|
||||
<span className="feed-rank-badge" style={{ color: rank.color, borderColor: rank.color }}>
|
||||
{rank.short}
|
||||
</span>
|
||||
<span className="feed-role">{rank.label}</span>
|
||||
{timeStr && <span className="feed-time">{timeStr}</span>}
|
||||
</div>
|
||||
{msg.thinking && <ThinkingBlock content={msg.thinking} done />}
|
||||
<div className="feed-content">
|
||||
{renderContent(cleanContent).map((part, i) =>
|
||||
part.type === 'code' ? (
|
||||
<div key={i} className="studio-code-block">
|
||||
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
|
||||
<pre><code>{part.content}</code></pre>
|
||||
</div>
|
||||
) : (
|
||||
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StreamingItem({ content, thinking }) {
|
||||
const rank = RANKS.general
|
||||
const cleanContent = content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||||
|
||||
return (
|
||||
<div className="feed-item assistant">
|
||||
<div className="feed-avatar ai-rank">
|
||||
<RankIcon rank={rank} />
|
||||
</div>
|
||||
<div className="feed-body">
|
||||
<div className="feed-header">
|
||||
<span className="feed-rank-badge" style={{ color: rank.color, borderColor: rank.color }}>
|
||||
{rank.short}
|
||||
</span>
|
||||
<span className="feed-role">{rank.label}</span>
|
||||
</div>
|
||||
{thinking && <ThinkingBlock content={thinking} done={false} />}
|
||||
{!thinking && !cleanContent && (
|
||||
<div className="feed-content">
|
||||
<div className="studio-thinking"><span /><span /><span /></div>
|
||||
</div>
|
||||
)}
|
||||
{cleanContent && (
|
||||
<div className="feed-content">
|
||||
{renderContent(cleanContent).map((part, i) =>
|
||||
part.type === 'code' ? (
|
||||
<div key={i} className="studio-code-block">
|
||||
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
|
||||
<pre><code>{part.content}</code></pre>
|
||||
</div>
|
||||
) : (
|
||||
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||
)
|
||||
)}
|
||||
<span className="studio-cursor" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Studio({ api }) {
|
||||
const [messages, setMessages] = useState([
|
||||
{ role: 'ai', content: '>> Welcome to Studio! Chat with your AI assistant here.' },
|
||||
{ role: 'ai', content: '>> Configure agents and workflows from the sidebar.' },
|
||||
])
|
||||
const { t } = useI18n()
|
||||
const [messages, setMessages] = useState([])
|
||||
const [input, setInput] = useState('')
|
||||
const [sidebarPanel, setSidebarPanel] = useState('chat')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [streaming, setStreaming] = useState('')
|
||||
const [streamThinking, setStreamThinking] = useState('')
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const messagesEnd = useRef(null)
|
||||
const textareaRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.getChatHistory().then(data => {
|
||||
if (data.messages && data.messages.length > 0) {
|
||||
setMessages(data.messages)
|
||||
} else {
|
||||
setMessages([
|
||||
{ id: 'welcome', role: 'system', content: t('studio.welcomeNew'), time: new Date().toISOString() },
|
||||
])
|
||||
}
|
||||
setLoaded(true)
|
||||
}).catch(() => {
|
||||
setMessages([
|
||||
{ id: 'welcome', role: 'system', content: t('studio.welcomeNew'), time: new Date().toISOString() },
|
||||
])
|
||||
setLoaded(true)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
}, [messages, streaming, streamThinking])
|
||||
|
||||
const handleSend = () => {
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto'
|
||||
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 200) + 'px'
|
||||
}
|
||||
}, [input])
|
||||
|
||||
const handleClear = useCallback(async () => {
|
||||
try {
|
||||
await api.clearChat()
|
||||
setMessages([
|
||||
{ id: 'clear-' + Date.now(), role: 'system', content: t('studio.cleared'), time: new Date().toISOString() },
|
||||
])
|
||||
} catch {}
|
||||
}, [api, t])
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!input.trim() || loading) return
|
||||
const text = input.trim()
|
||||
setMessages(prev => [...prev, { role: 'user', content: '>> ' + text }])
|
||||
setInput('')
|
||||
setLoading(true)
|
||||
|
||||
api.runCommand(`echo "AI response simulation for: ${text}"`, '')
|
||||
.then(res => {
|
||||
setMessages(prev => [...prev, { role: 'ai', content: '>> ' + (res.output || res.error || 'No response') }])
|
||||
if (text === '/clear') {
|
||||
handleClear()
|
||||
return
|
||||
}
|
||||
|
||||
const userMsg = { id: Date.now().toString(), role: 'user', content: text, time: new Date().toISOString() }
|
||||
setMessages(prev => [...prev, userMsg])
|
||||
setLoading(true)
|
||||
setStreaming('')
|
||||
setStreamThinking('')
|
||||
|
||||
try {
|
||||
let accumulated = ''
|
||||
let thinking = ''
|
||||
|
||||
await api.sendChat(text, true, (partial, event) => {
|
||||
if (event && (event.thinking_start || event.thinking_end || event.thinking !== undefined)) {
|
||||
if (event.thinking !== undefined) {
|
||||
thinking += event.thinking
|
||||
setStreamThinking(thinking)
|
||||
}
|
||||
return
|
||||
}
|
||||
accumulated = partial
|
||||
setStreaming(partial)
|
||||
})
|
||||
.catch(err => {
|
||||
setMessages(prev => [...prev, { role: 'ai', content: '[ERROR] ' + err.message }])
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
const finalContent = accumulated || t('studio.noResponse')
|
||||
const aiMsg = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: finalContent,
|
||||
time: new Date().toISOString(),
|
||||
}
|
||||
if (thinking) aiMsg.thinking = thinking
|
||||
setMessages(prev => [...prev, aiMsg])
|
||||
} catch (err) {
|
||||
setMessages(prev => [...prev, {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'system',
|
||||
content: `${t('studio.error')}: ${err.message}`,
|
||||
time: new Date().toISOString(),
|
||||
}])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setStreaming('')
|
||||
setStreamThinking('')
|
||||
}
|
||||
}, [input, loading, api, t, handleClear])
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
@@ -38,87 +281,53 @@ export default function Studio({ api }) {
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="split-horizontal">
|
||||
<div className="chat-container" style={{ flex: 1, borderRight: '1px solid var(--border-dim)' }}>
|
||||
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border-dim)' }}>
|
||||
<div className="section-header" style={{ margin: 0 }}>
|
||||
Chat
|
||||
{loading && <span className="loading-spinner" style={{ marginLeft: 8 }}> thinking...</span>}
|
||||
if (!loaded) {
|
||||
return (
|
||||
<div className="studio-feed-layout">
|
||||
<div className="studio-feed">
|
||||
<div className="feed-loading">
|
||||
<div className="studio-thinking"><span /><span /><span /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div className="chat-messages">
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={`chat-message ${msg.role}`}>
|
||||
{msg.content}
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEnd} />
|
||||
</div>
|
||||
return (
|
||||
<div className="studio-feed-layout">
|
||||
<div className="studio-feed">
|
||||
{messages.map(msg => (
|
||||
<FeedItem key={msg.id} msg={msg} />
|
||||
))}
|
||||
{(streaming || streamThinking || loading) && (
|
||||
<StreamingItem content={streaming} thinking={streamThinking} />
|
||||
)}
|
||||
<div ref={messagesEnd} />
|
||||
</div>
|
||||
|
||||
<div className="chat-input-container">
|
||||
<input
|
||||
className="chat-input"
|
||||
<div className="studio-input-area">
|
||||
<div className="studio-input-row">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a message... (/plan <goal> for workflows)"
|
||||
placeholder={t('studio.placeholderNew')}
|
||||
disabled={loading}
|
||||
rows={1}
|
||||
/>
|
||||
<button className="primary" onClick={handleSend} disabled={loading || !input.trim()}>
|
||||
Send
|
||||
<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>
|
||||
|
||||
<div className="split-right">
|
||||
<div className="sidebar-section">
|
||||
<div className="section-header">Studio</div>
|
||||
{['chat', 'agents', 'workflows'].map(panel => (
|
||||
<div
|
||||
key={panel}
|
||||
className={`sidebar-item ${sidebarPanel === panel ? 'active' : ''}`}
|
||||
onClick={() => setSidebarPanel(panel)}
|
||||
>
|
||||
[{panel === 'chat' ? '#' : panel === 'agents' ? '*' : '~'}] {panel.charAt(0).toUpperCase() + panel.slice(1)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border-dim)', paddingTop: 12 }}>
|
||||
{sidebarPanel === 'chat' && (
|
||||
<div>
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 12, marginBottom: 8 }}>Commands</div>
|
||||
<div style={{ color: 'var(--dim-red)', fontSize: 12, fontFamily: 'var(--font-mono)' }}>
|
||||
/plan {'<goal>'}<br/>
|
||||
/help
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sidebarPanel === 'agents' && (
|
||||
<div>
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 12, marginBottom: 8 }}>Active Agents</div>
|
||||
<div style={{ padding: '4px 0' }}>
|
||||
<span style={{ color: 'var(--text-main)', fontWeight: 600 }}>Crush</span>
|
||||
<span style={{ color: 'var(--text-muted)', marginLeft: 8, fontSize: 11 }}>[|| stopped]</span>
|
||||
</div>
|
||||
<div style={{ padding: '4px 0' }}>
|
||||
<span style={{ color: 'var(--text-main)', fontWeight: 600 }}>Claude Code</span>
|
||||
<span style={{ color: 'var(--text-muted)', marginLeft: 8, fontSize: 11 }}>[|| stopped]</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sidebarPanel === 'workflows' && (
|
||||
<div>
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No active workflow.</div>
|
||||
<div style={{ color: 'var(--dim-red)', fontSize: 12, marginTop: 8 }}>
|
||||
Use /plan {'<goal>'} in chat to start.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="studio-input-hint">
|
||||
{t('studio.inputHint')} · /clear
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
193
web/src/i18n/en.js
Normal file
193
web/src/i18n/en.js
Normal file
@@ -0,0 +1,193 @@
|
||||
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',
|
||||
cleared: 'Conversation cleared.',
|
||||
},
|
||||
|
||||
shell: {
|
||||
terminal: 'Terminal',
|
||||
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',
|
||||
aiAssistant: 'AI Assistant',
|
||||
aiWelcome: 'Hello! I can help you with terminal commands. Ask me anything!',
|
||||
askAi: 'Ask AI assistant...',
|
||||
toolLaunched: 'Tool launched',
|
||||
},
|
||||
|
||||
config: {
|
||||
panels: {
|
||||
profile: 'Profile',
|
||||
providers: 'AI Providers',
|
||||
terminal: 'Terminal',
|
||||
updates: 'Updates',
|
||||
locale: 'Language & Keyboard',
|
||||
skills: 'Skills',
|
||||
system: 'System',
|
||||
},
|
||||
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',
|
||||
cancel: 'Cancel',
|
||||
editProvider: 'Configure',
|
||||
validateKey: 'Validate',
|
||||
validating: 'Validating...',
|
||||
keyValid: 'Valid key',
|
||||
keyInvalid: 'Invalid key',
|
||||
connectionFailed: 'Connection failed',
|
||||
enterToken: 'Enter your API token for {provider}',
|
||||
tokenPlaceholder: 'sk-...',
|
||||
setupDescription: 'Configure your AI provider token to use the assistant.',
|
||||
terminalTheme: 'Terminal Theme',
|
||||
fontSize: 'Font Size',
|
||||
fontFamily: 'Font Family',
|
||||
preview: 'Preview',
|
||||
saving: 'Saving...',
|
||||
resetConfig: 'Reset all',
|
||||
resetConfirm: 'Are you sure? All preferences will be erased.',
|
||||
resetDone: 'Settings reset.',
|
||||
applyStarship: 'Apply starship',
|
||||
starshipApplied: 'Starship theme applied! Restart your shell to see the result.',
|
||||
starshipError: 'Failed to apply starship theme.',
|
||||
},
|
||||
}
|
||||
|
||||
export default en
|
||||
193
web/src/i18n/fr.js
Normal file
193
web/src/i18n/fr.js
Normal file
@@ -0,0 +1,193 @@
|
||||
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',
|
||||
cleared: 'Conversation effac\u00e9e.',
|
||||
},
|
||||
|
||||
shell: {
|
||||
terminal: 'Terminal',
|
||||
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',
|
||||
aiAssistant: 'Assistant IA',
|
||||
aiWelcome: 'Bonjour ! Je peux vous aider avec les commandes du terminal. Demandez-moi n\'importe quoi !',
|
||||
askAi: 'Interroger l\'assistant IA...',
|
||||
toolLaunched: 'Outil lanc\u00e9',
|
||||
},
|
||||
|
||||
config: {
|
||||
panels: {
|
||||
profile: 'Profil',
|
||||
providers: 'Fournisseurs IA',
|
||||
terminal: 'Terminal',
|
||||
updates: 'Mises \u00e0 jour',
|
||||
locale: 'Langue & Clavier',
|
||||
skills: 'Comp\u00e9ENCES',
|
||||
system: 'Syst\u00e8me',
|
||||
},
|
||||
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\u00e9ENCES',
|
||||
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',
|
||||
editProvider: 'Configurer',
|
||||
validateKey: 'Valider',
|
||||
validating: 'V\u00e9rification...',
|
||||
keyValid: 'Cl\u00e9 valide',
|
||||
keyInvalid: 'Cl\u00e9 invalide',
|
||||
connectionFailed: 'Connexion \u00e9chou\u00e9e',
|
||||
enterToken: 'Entrez votre token API pour {provider}',
|
||||
tokenPlaceholder: 'sk-...',
|
||||
setupDescription: 'Configurez le token de votre fournisseur IA pour utiliser l\'assistant.',
|
||||
cancel: 'Annuler',
|
||||
terminalTheme: 'Th\u00e8me du terminal',
|
||||
fontSize: 'Taille de police',
|
||||
fontFamily: 'Police',
|
||||
preview: 'Aper\u00e7u',
|
||||
saving: 'Enregistrement...',
|
||||
resetConfig: 'R\u00e9initialiser',
|
||||
resetConfirm: '\u00cates-vous s\u00fbr ? Toutes les pr\u00e9f\u00e9rences seront effac\u00e9es.',
|
||||
resetDone: 'Param\u00e8tres r\u00e9initialis\u00e9s.',
|
||||
applyStarship: 'Appliquer starship',
|
||||
starshipApplied: 'Th\u00e8me starship appliqu\u00e9 ! Red\u00e9marrez votre shell pour voir le r\u00e9sultat.',
|
||||
starshipError: '\u00c9chec de l\u2019application du th\u00e8me starship.',
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -1,9 +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>
|
||||
<App />
|
||||
<I18nProvider>
|
||||
<App />
|
||||
</I18nProvider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,43 +1,33 @@
|
||||
const defaultTheme = {
|
||||
name: 'Cyberpunk Red',
|
||||
colors: {
|
||||
bgVoid: '#0A0A0C',
|
||||
bg: '#0A0A0C',
|
||||
bgBase: '#0F0D10',
|
||||
bgSurface: '#161218',
|
||||
bgPanel: '#1C1719',
|
||||
bgElevated: '#1C1719',
|
||||
bgCard: '#221B1E',
|
||||
bgInput: '#2A2225',
|
||||
bgHover: '#332528',
|
||||
cyberRed: '#FF0033',
|
||||
cyberRedDark: '#8B0020',
|
||||
cyberRedDeep: '#5C0015',
|
||||
cyberPink: '#FF1A5E',
|
||||
cyberRose: '#FF4D6D',
|
||||
neonRed: '#FF1744',
|
||||
brightRed: '#FF5252',
|
||||
dimRed: '#6B2033',
|
||||
mutedRed: '#4A1525',
|
||||
textBright: '#EAE0E2',
|
||||
textMain: '#D4C4C8',
|
||||
textDim: '#8A7A7E',
|
||||
textMuted: '#5A4F52',
|
||||
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',
|
||||
borderDim: '#2A1F22',
|
||||
borderRed: '#FF003344',
|
||||
borderRedFull: '#FF0033',
|
||||
},
|
||||
fonts: {
|
||||
mono: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace",
|
||||
ui: "-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
|
||||
},
|
||||
borderRadius: '8px',
|
||||
animations: {
|
||||
glitch: true,
|
||||
scanline: true,
|
||||
typewriter: true,
|
||||
pulse: true,
|
||||
info: '#448AFF',
|
||||
border: '#2A1F22',
|
||||
borderAccent: '#FF003344',
|
||||
borderAccentFull: '#FF0033',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -48,15 +38,15 @@ const themes = {
|
||||
name: 'Cyberpunk Pink',
|
||||
colors: {
|
||||
...defaultTheme.colors,
|
||||
cyberRed: '#FF1A8C',
|
||||
cyberRedDark: '#8B1050',
|
||||
cyberRedDeep: '#5C0A35',
|
||||
cyberPink: '#FF4DAE',
|
||||
cyberRose: '#FF6DC2',
|
||||
neonRed: '#FF1A8C',
|
||||
brightRed: '#FF6DC2',
|
||||
dimRed: '#6B2050',
|
||||
mutedRed: '#4A1535',
|
||||
accent: '#FF1A8C',
|
||||
accentDark: '#8B1050',
|
||||
accentDeep: '#5C0A35',
|
||||
accentLight: '#FF4DAE',
|
||||
accentMuted: '#FF6DC2',
|
||||
accentBright: '#FF1A8C',
|
||||
accentSoft: '#FF6DC2',
|
||||
accentDim: '#6B2050',
|
||||
accentBg: '#4A1535',
|
||||
},
|
||||
},
|
||||
'midnight-blue': {
|
||||
@@ -64,15 +54,15 @@ const themes = {
|
||||
name: 'Midnight Blue',
|
||||
colors: {
|
||||
...defaultTheme.colors,
|
||||
cyberRed: '#0088FF',
|
||||
cyberRedDark: '#004488',
|
||||
cyberRedDeep: '#002255',
|
||||
cyberPink: '#00AAFF',
|
||||
cyberRose: '#44CCFF',
|
||||
neonRed: '#0088FF',
|
||||
brightRed: '#44CCFF',
|
||||
dimRed: '#203366',
|
||||
mutedRed: '#152244',
|
||||
accent: '#0088FF',
|
||||
accentDark: '#004488',
|
||||
accentDeep: '#002255',
|
||||
accentLight: '#00AAFF',
|
||||
accentMuted: '#44CCFF',
|
||||
accentBright: '#0088FF',
|
||||
accentSoft: '#44CCFF',
|
||||
accentDim: '#203366',
|
||||
accentBg: '#152244',
|
||||
},
|
||||
},
|
||||
'matrix-green': {
|
||||
@@ -80,15 +70,15 @@ const themes = {
|
||||
name: 'Matrix Green',
|
||||
colors: {
|
||||
...defaultTheme.colors,
|
||||
cyberRed: '#00FF41',
|
||||
cyberRedDark: '#008822',
|
||||
cyberRedDeep: '#005515',
|
||||
cyberPink: '#33FF66',
|
||||
cyberRose: '#66FF99',
|
||||
neonRed: '#00FF41',
|
||||
brightRed: '#66FF99',
|
||||
dimRed: '#206630',
|
||||
mutedRed: '#154420',
|
||||
accent: '#00FF41',
|
||||
accentDark: '#008822',
|
||||
accentDeep: '#005515',
|
||||
accentLight: '#33FF66',
|
||||
accentMuted: '#66FF99',
|
||||
accentBright: '#00FF41',
|
||||
accentSoft: '#66FF99',
|
||||
accentDim: '#206630',
|
||||
accentBg: '#154420',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -105,32 +95,33 @@ export function applyTheme(theme) {
|
||||
const root = document.documentElement
|
||||
const c = theme.colors
|
||||
const map = {
|
||||
'--bg-void': c.bgVoid,
|
||||
'--bg': c.bg,
|
||||
'--bg-base': c.bgBase,
|
||||
'--bg-surface': c.bgSurface,
|
||||
'--bg-panel': c.bgPanel,
|
||||
'--bg-elevated': c.bgElevated,
|
||||
'--bg-card': c.bgCard,
|
||||
'--bg-input': c.bgInput,
|
||||
'--bg-hover': c.bgHover,
|
||||
'--cyber-red': c.cyberRed,
|
||||
'--cyber-red-dark': c.cyberRedDark,
|
||||
'--cyber-red-deep': c.cyberRedDeep,
|
||||
'--cyber-pink': c.cyberPink,
|
||||
'--cyber-rose': c.cyberRose,
|
||||
'--neon-red': c.neonRed,
|
||||
'--bright-red': c.brightRed,
|
||||
'--dim-red': c.dimRed,
|
||||
'--muted-red': c.mutedRed,
|
||||
'--text-bright': c.textBright,
|
||||
'--text-main': c.textMain,
|
||||
'--text-dim': c.textDim,
|
||||
'--text-muted': c.textMuted,
|
||||
'--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,
|
||||
'--border-dim': c.borderDim,
|
||||
'--border-red': c.borderRed,
|
||||
'--border-red-full': c.borderRedFull,
|
||||
'--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))
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export default defineConfig({
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8095',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user