Compare commits
33 Commits
v0.3.0-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 |
@@ -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
|
||||
|
||||
88
CHANGELOG.md
88
CHANGELOG.md
@@ -4,10 +4,98 @@ 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.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.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.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.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.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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
4
go.mod
4
go.mod
@@ -1,6 +1,8 @@
|
||||
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
|
||||
|
||||
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())
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
"github.com/muyue/muyue/internal/lsp"
|
||||
"github.com/muyue/muyue/internal/mcp"
|
||||
"github.com/muyue/muyue/internal/orchestrator"
|
||||
"github.com/muyue/muyue/internal/scanner"
|
||||
"github.com/muyue/muyue/internal/skills"
|
||||
"github.com/muyue/muyue/internal/updater"
|
||||
"github.com/muyue/muyue/internal/version"
|
||||
)
|
||||
|
||||
func writeJSON(w http.ResponseWriter, data interface{}) {
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, msg string, code int) {
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"name": version.Name,
|
||||
"version": version.Version,
|
||||
"author": version.Author,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleSystem(w http.ResponseWriter, r *http.Request) {
|
||||
if s.scanResult == nil {
|
||||
s.scanResult = scanner.ScanSystem()
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"system": s.scanResult.System,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleTools(w http.ResponseWriter, r *http.Request) {
|
||||
if s.scanResult == nil {
|
||||
s.scanResult = scanner.ScanSystem()
|
||||
}
|
||||
type toolInfo struct {
|
||||
Name string `json:"name"`
|
||||
Installed bool `json:"installed"`
|
||||
Version string `json:"version"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
tools := make([]toolInfo, len(s.scanResult.Tools))
|
||||
for i, t := range s.scanResult.Tools {
|
||||
tools[i] = toolInfo{
|
||||
Name: t.Name,
|
||||
Installed: t.Installed,
|
||||
Version: t.Version,
|
||||
Path: t.Path,
|
||||
}
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"tools": tools,
|
||||
"total": len(tools),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if s.config == nil {
|
||||
writeError(w, "no config", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"profile": s.config.Profile,
|
||||
"terminal": s.config.Terminal,
|
||||
"bmad": s.config.BMAD,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleProviders(w http.ResponseWriter, r *http.Request) {
|
||||
if s.config == nil {
|
||||
writeError(w, "no config", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"providers": s.config.AI.Providers,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleSkills(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := skills.List()
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"skills": list,
|
||||
"count": len(list),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleLSP(w http.ResponseWriter, r *http.Request) {
|
||||
servers := lsp.ScanServers()
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"servers": servers,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
|
||||
servers := mcp.ScanServers()
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"servers": servers,
|
||||
"configured": true,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleMCPConfigure(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if err := mcp.ConfigureAll(s.config); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdates(w http.ResponseWriter, r *http.Request) {
|
||||
result := scanner.ScanSystem()
|
||||
statuses := updater.CheckUpdates(result)
|
||||
type updateInfo struct {
|
||||
Tool string `json:"tool"`
|
||||
Current string `json:"current"`
|
||||
Latest string `json:"latest"`
|
||||
NeedsUpdate bool `json:"needsUpdate"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
updates := make([]updateInfo, len(statuses))
|
||||
for i, u := range statuses {
|
||||
updates[i] = updateInfo{
|
||||
Tool: u.Tool,
|
||||
Current: u.Current,
|
||||
Latest: u.Latest,
|
||||
NeedsUpdate: u.NeedsUpdate,
|
||||
Error: u.Error,
|
||||
}
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"updates": updates,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleInstall(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Tools []string `json:"tools"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(body.Tools) == 0 {
|
||||
writeError(w, "no tools specified", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]string{"status": "installing"})
|
||||
}
|
||||
|
||||
func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) {
|
||||
s.scanResult = scanner.ScanSystem()
|
||||
writeJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdatePreferences(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "PUT" {
|
||||
writeError(w, "PUT only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if s.config == nil {
|
||||
writeError(w, "no config", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Language string `json:"language"`
|
||||
KeyboardLayout string `json:"keyboard_layout"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Language != "" {
|
||||
s.config.Profile.Preferences.Language = body.Language
|
||||
}
|
||||
if body.KeyboardLayout != "" {
|
||||
s.config.Profile.Preferences.KeyboardLayout = body.KeyboardLayout
|
||||
}
|
||||
if err := config.Save(s.config); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleTerminal(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Command string `json:"command"`
|
||||
Cwd string `json:"cwd"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Command == "" {
|
||||
writeError(w, "no command", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
shell := "/bin/sh"
|
||||
if s, err := exec.LookPath("bash"); err == nil {
|
||||
shell = s
|
||||
}
|
||||
|
||||
cmd := exec.Command(shell, "-c", body.Command)
|
||||
if body.Cwd != "" {
|
||||
cmd.Dir = body.Cwd
|
||||
}
|
||||
out, err := cmd.CombinedOutput()
|
||||
|
||||
type termResult struct {
|
||||
Output string `json:"output"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
result := termResult{Output: string(out)}
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
}
|
||||
writeJSON(w, result)
|
||||
}
|
||||
|
||||
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Message string `json:"message"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Message == "" {
|
||||
writeError(w, "no message", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
orb, err := orchestrator.New(s.config)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
orb.SetSystemPrompt(`You are Muyue Studio's AI orchestrator. You help the user with software development tasks. You can:
|
||||
- Create and manage development plans with step-by-step workflows
|
||||
- Propose agents (tools like Crush, Claude Code, etc.) to execute specific tasks
|
||||
- Track progress across multi-step tasks
|
||||
- Suggest file modifications, code reviews, and architecture decisions
|
||||
|
||||
Be concise, actionable, and structured. When proposing a plan, use clear numbered steps. When referencing files, use relative paths. You are embedded in the Muyue desktop app.`)
|
||||
|
||||
if body.Stream {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
flusher, canFlush := w.(http.Flusher)
|
||||
|
||||
chunkSize := 8
|
||||
result, err := orb.Send(body.Message)
|
||||
if err != nil {
|
||||
data, _ := json.Marshal(map[string]string{"error": err.Error()})
|
||||
w.Write([]byte("data: " + string(data) + "\n\n"))
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
runes := []rune(result)
|
||||
for i := 0; i < len(runes); i += chunkSize {
|
||||
end := i + chunkSize
|
||||
if end > len(runes) {
|
||||
end = len(runes)
|
||||
}
|
||||
chunk := string(runes[i:end])
|
||||
data, _ := json.Marshal(map[string]string{"content": chunk})
|
||||
w.Write([]byte("data: " + string(data) + "\n\n"))
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(map[string]string{"done": "true"})
|
||||
w.Write([]byte("data: " + string(data) + "\n\n"))
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
result, err := orb.Send(body.Message)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]string{"content": result})
|
||||
}
|
||||
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"
|
||||
`
|
||||
}
|
||||
}
|
||||
119
internal/api/handlers_info.go
Normal file
119
internal/api/handlers_info.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"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/version"
|
||||
)
|
||||
|
||||
func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"name": version.Name,
|
||||
"version": version.Version,
|
||||
"author": version.Author,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleSystem(w http.ResponseWriter, r *http.Request) {
|
||||
if s.scanResult == nil {
|
||||
s.scanResult = scanner.ScanSystem()
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"system": s.scanResult.System,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleTools(w http.ResponseWriter, r *http.Request) {
|
||||
if s.scanResult == nil {
|
||||
s.scanResult = scanner.ScanSystem()
|
||||
}
|
||||
type toolInfo struct {
|
||||
Name string `json:"name"`
|
||||
Installed bool `json:"installed"`
|
||||
Version string `json:"version"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
tools := make([]toolInfo, len(s.scanResult.Tools))
|
||||
for i, t := range s.scanResult.Tools {
|
||||
tools[i] = toolInfo{
|
||||
Name: t.Name,
|
||||
Installed: t.Installed,
|
||||
Version: t.Version,
|
||||
Path: t.Path,
|
||||
}
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"tools": tools,
|
||||
"total": len(tools),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if s.config == nil {
|
||||
writeError(w, "no config", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"profile": s.config.Profile,
|
||||
"terminal": s.config.Terminal,
|
||||
"bmad": s.config.BMAD,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleProviders(w http.ResponseWriter, r *http.Request) {
|
||||
if s.config == nil {
|
||||
writeError(w, "no config", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"providers": s.config.AI.Providers,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleSkills(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := skills.List()
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"skills": list,
|
||||
"count": len(list),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleLSP(w http.ResponseWriter, r *http.Request) {
|
||||
servers := lsp.ScanServers()
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"servers": servers,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
|
||||
servers := mcp.ScanServers()
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"servers": servers,
|
||||
"configured": true,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleMCPConfigure(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if err := mcp.ConfigureAll(s.config); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) {
|
||||
s.scanResult = scanner.ScanSystem()
|
||||
writeJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
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()
|
||||
}
|
||||
@@ -12,6 +12,7 @@ type Server struct {
|
||||
config *config.MuyueConfig
|
||||
scanResult *scanner.ScanResult
|
||||
mux *http.ServeMux
|
||||
convStore *ConversationStore
|
||||
}
|
||||
|
||||
func NewServer(cfg *config.MuyueConfig) *Server {
|
||||
@@ -20,6 +21,7 @@ func NewServer(cfg *config.MuyueConfig) *Server {
|
||||
mux: http.NewServeMux(),
|
||||
}
|
||||
s.scanResult = scanner.ScanSystem()
|
||||
s.convStore = NewConversationStore()
|
||||
s.routes()
|
||||
return s
|
||||
}
|
||||
@@ -39,7 +41,18 @@ func (s *Server) routes() {
|
||||
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) {
|
||||
|
||||
@@ -2,19 +2,40 @@ 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 { return true },
|
||||
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 {
|
||||
@@ -32,20 +53,99 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
shell := "/bin/sh"
|
||||
if s, err := exec.LookPath("bash"); err == nil {
|
||||
shell = s
|
||||
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 := 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("pty start: %v", err)
|
||||
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 {
|
||||
@@ -65,7 +165,6 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// PTY -> WebSocket
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
@@ -86,8 +185,7 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}()
|
||||
|
||||
// WebSocket -> PTY
|
||||
conn.SetReadLimit(1 << 20) // 1MB
|
||||
conn.SetReadLimit(1 << 20)
|
||||
conn.SetReadDeadline(time.Time{})
|
||||
|
||||
for {
|
||||
@@ -118,3 +216,135 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -41,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"`
|
||||
@@ -54,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 {
|
||||
@@ -69,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 -",
|
||||
@@ -291,15 +292,15 @@ func (i *Installer) installGit() InstallResult {
|
||||
}
|
||||
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,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")
|
||||
|
||||
@@ -101,5 +101,3 @@ func InstallForLanguages(languages []string) []LSPServer {
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -34,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"`
|
||||
@@ -161,6 +165,104 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
|
||||
return content, nil
|
||||
}
|
||||
|
||||
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: messages,
|
||||
Stream: true,
|
||||
}
|
||||
o.histMu.Unlock()
|
||||
|
||||
body, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
baseURL := o.provider.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = getProviderBaseURL(o.provider.Name)
|
||||
}
|
||||
|
||||
url := strings.TrimRight(baseURL, "/") + "/chat/completions"
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+o.provider.APIKey)
|
||||
|
||||
resp, err := o.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
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 err := scanner.Err(); err != nil {
|
||||
return fullContent.String(), fmt.Errorf("read stream: %w", err)
|
||||
}
|
||||
|
||||
content := cleanAIResponse(fullContent.String())
|
||||
o.histMu.Lock()
|
||||
o.history = append(o.history, Message{
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
})
|
||||
o.histMu.Unlock()
|
||||
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func cleanAIResponse(content string) string {
|
||||
content = thinkRegex.ReplaceAllString(content, "")
|
||||
lines := strings.Split(content, "\n")
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -14,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",
|
||||
},
|
||||
@@ -24,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",
|
||||
},
|
||||
@@ -49,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",
|
||||
},
|
||||
@@ -146,3 +149,128 @@ func TestNewNoAPIKey(t *testing.T) {
|
||||
t.Error("Should fail with no API key")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package version
|
||||
|
||||
const (
|
||||
Name = "muyue"
|
||||
Version = "0.3.0"
|
||||
Version = "0.3.2"
|
||||
Author = "La Légion de Muyue"
|
||||
)
|
||||
|
||||
|
||||
10
web/package-lock.json
generated
10
web/package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"@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"
|
||||
},
|
||||
@@ -736,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",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"@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 }))
|
||||
@@ -26,8 +26,19 @@ const api = {
|
||||
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 }) }),
|
||||
sendChat: (message, stream = true) => {
|
||||
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 }) })
|
||||
}
|
||||
@@ -55,7 +66,12 @@ const api = {
|
||||
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 (data.content) {
|
||||
full += data.content
|
||||
if (onChunk) onChunk(full, data)
|
||||
} else if (data.thinking !== undefined || data.thinking_end) {
|
||||
if (onChunk) onChunk(full, data)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
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'
|
||||
import OnboardingWizard from './OnboardingWizard'
|
||||
|
||||
export default function App() {
|
||||
const [activeTab, setActiveTab] = useState('dash')
|
||||
@@ -13,20 +15,31 @@ export default function App() {
|
||||
const [clock, setClock] = useState(new Date())
|
||||
const [updates, setUpdates] = useState([])
|
||||
const [tools, setTools] = useState([])
|
||||
const [config, setConfig] = useState(null)
|
||||
const [showOnboarding, setShowOnboarding] = useState(false)
|
||||
const { t, layout } = useI18n()
|
||||
|
||||
const TABS = useMemo(() => [
|
||||
{ id: 'dash', label: t('tabs.dashboard'), icon: '\u25A0' },
|
||||
{ id: 'studio', label: t('tabs.studio'), icon: '\u27E8\u27E9' },
|
||||
{ id: 'shell', label: t('tabs.shell'), icon: '$' },
|
||||
{ id: 'config', label: t('tabs.config'), icon: '\u2699' },
|
||||
{ id: 'dash', label: t('tabs.dashboard'), icon: <LayoutDashboard size={15} /> },
|
||||
{ id: 'studio', label: t('tabs.studio'), icon: <Sparkles size={15} /> },
|
||||
{ id: 'shell', label: t('tabs.shell'), icon: <Terminal size={15} /> },
|
||||
{ id: 'config', label: t('tabs.config'), icon: <Settings size={15} /> },
|
||||
], [t])
|
||||
|
||||
useEffect(() => {
|
||||
api.getInfo().then(setInfo).catch(() => {})
|
||||
api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
|
||||
api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
|
||||
applyTheme(getTheme('cyberpunk-red'))
|
||||
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(() => {
|
||||
@@ -56,7 +69,7 @@ export default function App() {
|
||||
const switchTab = useCallback((tabId) => setActiveTab(tabId), [])
|
||||
|
||||
const hasUpdates = updates.some(u => u.needsUpdate)
|
||||
const installed = tools.filter(t => t.installed).length
|
||||
const installed = tools.filter(tool => tool.installed).length
|
||||
|
||||
const WINDOW_SHORTCUTS = useMemo(() => ({
|
||||
dash: [
|
||||
@@ -79,10 +92,10 @@ export default function App() {
|
||||
|
||||
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} onThemeChange={() => {}} />
|
||||
case 'config': return <Config api={api} />
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
@@ -142,6 +155,8 @@ export default function App() {
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{showOnboarding && <OnboardingWizard api={api} onComplete={() => setShowOnboarding(false)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,58 +1,410 @@
|
||||
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'
|
||||
|
||||
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, layout } = useI18n()
|
||||
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 [currentTheme, setCurrentTheme] = useState('cyberpunk-red')
|
||||
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(() => {})
|
||||
api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {})
|
||||
api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const themes = getThemeNames()
|
||||
const layouts = getLayoutList()
|
||||
|
||||
const handleThemeChange = (themeId) => {
|
||||
applyTheme(getTheme(themeId))
|
||||
setCurrentTheme(themeId)
|
||||
const loadData = useCallback(() => {
|
||||
api.getConfig().then(d => {
|
||||
setConfig(d)
|
||||
setProfileForm({
|
||||
name: d.profile?.name || '',
|
||||
pseudo: d.profile?.pseudo || '',
|
||||
email: d.profile?.email || '',
|
||||
editor: d.profile?.preferences?.editor || '',
|
||||
shell: d.profile?.preferences?.shell || '',
|
||||
})
|
||||
|
||||
}).catch(() => {})
|
||||
api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {})
|
||||
api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {})
|
||||
api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
|
||||
api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
|
||||
|
||||
}, [api])
|
||||
|
||||
useEffect(() => { loadData() }, [loadData])
|
||||
|
||||
const showToast = (msg) => {
|
||||
setToast(msg)
|
||||
setTimeout(() => setToast(null), 2500)
|
||||
}
|
||||
|
||||
const themeColors = {
|
||||
'cyberpunk-red': '#FF0033',
|
||||
'cyberpunk-pink': '#FF1A8C',
|
||||
'midnight-blue': '#0088FF',
|
||||
'matrix-green': '#00FF41',
|
||||
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-layout">
|
||||
<div className="config-section">
|
||||
<div className="config-section-title">{t('config.profile')}</div>
|
||||
{config?.profile ? (
|
||||
<div>
|
||||
<FieldRow label={t('config.name')} value={config.profile.name} />
|
||||
<FieldRow label={t('config.pseudo')} value={config.profile.pseudo} />
|
||||
<FieldRow label={t('config.email')} value={config.profile.email} />
|
||||
<FieldRow label={t('config.editor')} value={config.profile.preferences?.editor} />
|
||||
<FieldRow label={t('config.shell')} value={config.profile.preferences?.shell} />
|
||||
<FieldRow label={t('config.defaultAi')} value={config.profile.preferences?.defaultAI} />
|
||||
<FieldRow label={t('config.languages')} value={config.profile.languages?.join(', ')} />
|
||||
<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 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 className="empty-state">{t('config.loadingProfile')}</div>
|
||||
)}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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="config-section-title">{t('config.language')}</div>
|
||||
<div className="actions-stack">
|
||||
{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}
|
||||
@@ -64,10 +416,9 @@ export default function Config({ api }) {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="config-section">
|
||||
<div className="config-section-title">{t('config.keyboardLayout')}</div>
|
||||
<div className="actions-stack">
|
||||
<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}
|
||||
@@ -79,68 +430,108 @@ export default function Config({ api }) {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div className="config-section">
|
||||
<div className="config-section-title">{t('config.aiProviders')}</div>
|
||||
{providers.map((p, i) => (
|
||||
<div key={i} className="provider-card">
|
||||
<div className="provider-info">
|
||||
<div className="provider-name">
|
||||
{p.name}
|
||||
{p.active && <span className="badge accent" style={{ marginLeft: 8 }}>{t('config.active')}</span>}
|
||||
</div>
|
||||
<div className="provider-meta">
|
||||
<span>{p.model}</span>
|
||||
<span style={{ color: p.apiKey ? 'var(--success)' : 'var(--error)' }}>
|
||||
{p.apiKey ? t('config.keyConfigured') : t('config.noKey')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="config-section">
|
||||
<div className="config-section-title">{t('config.theme')}</div>
|
||||
<div className="theme-picker">
|
||||
{themes.map(th => (
|
||||
<div
|
||||
key={th.id}
|
||||
className={`theme-swatch ${currentTheme === th.id ? 'active' : ''}`}
|
||||
style={{ background: themeColors[th.id] || '#FF0033' }}
|
||||
onClick={() => handleThemeChange(th.id)}
|
||||
title={th.name}
|
||||
/>
|
||||
))}
|
||||
function PanelSkills({ skillList, t }) {
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
) : (
|
||||
skillList.map((s, i) => (
|
||||
<div key={i} className="config-skill-row">
|
||||
<span className="config-skill-name">{s.name}</span>
|
||||
<span className="badge neutral">{s.target || 'both'}</span>
|
||||
<span className="config-skill-desc">{s.description}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div className="config-section">
|
||||
<div className="config-section-title">{t('config.skills')} ({skillList.length})</div>
|
||||
{skillList.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
{t('config.noSkills')}
|
||||
<span style={{ fontFamily: 'var(--font-mono)' }}>{t('config.runSkillsInit')}</span>
|
||||
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>
|
||||
) : (
|
||||
skillList.map((s, i) => (
|
||||
<div key={i} className="tool-row">
|
||||
<span className="tool-name">{s.name}</span>
|
||||
<span className="badge neutral">{s.target || 'both'}</span>
|
||||
<span style={{ color: 'var(--text-tertiary)', fontSize: 12 }}>{s.description}</span>
|
||||
</div>
|
||||
))
|
||||
<button className="sm ghost danger" onClick={() => setResetConfirm(true)}>
|
||||
{t('config.resetConfig')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldRow({ label, value }) {
|
||||
function FormInput({ label, value, onChange, type = 'text' }) {
|
||||
return (
|
||||
<div className="field-row">
|
||||
<span className="field-label">{label}</span>
|
||||
<span className={`field-value ${!value ? 'empty' : ''}`}>{value || '—'}</span>
|
||||
<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,110 +1,58 @@
|
||||
import { useState } from 'react'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
export default function Dashboard({ tools, updates, api, onRescan }) {
|
||||
const { t, layout } = useI18n()
|
||||
const [activeSection, setActiveSection] = useState('tools')
|
||||
export default function Dashboard({ api }) {
|
||||
const { t } = useI18n()
|
||||
const [notifications, setNotifications] = useState([])
|
||||
|
||||
const installed = tools.filter(tool => tool.installed).length
|
||||
const total = tools.length
|
||||
|
||||
const addNotif = (text, type) => {
|
||||
setNotifications(prev => [{ text, type, id: Date.now(), time: new Date() }, ...prev])
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ id: 'tools', label: t('dashboard.systemOverview') },
|
||||
{ id: 'notifications', label: t('dashboard.activityLog') },
|
||||
{ id: 'workflows', label: t('studio.workflows') },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="dashboard-layout">
|
||||
<div className="dashboard-tabs">
|
||||
{sections.map(s => (
|
||||
<div
|
||||
key={s.id}
|
||||
className={`dashboard-tab ${activeSection === s.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveSection(s.id)}
|
||||
>
|
||||
{s.label}
|
||||
{s.id === 'tools' && total > 0 && (
|
||||
<span className="tab-count">{installed}/{total}</span>
|
||||
)}
|
||||
{s.id === 'notifications' && notifications.length > 0 && (
|
||||
<span className="tab-count warn">{notifications.length}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="dashboard-content">
|
||||
{activeSection === 'tools' && (
|
||||
<div className="dashboard-tools">
|
||||
{tools.length === 0 ? (
|
||||
<div className="empty-state">{t('dashboard.noUpdateData')}</div>
|
||||
) : (
|
||||
<div className="tools-compact">
|
||||
{tools.map((tool, i) => {
|
||||
const name = tool.name || tool.Name
|
||||
const ver = extractVersion(tool.Version || tool.version)
|
||||
return (
|
||||
<div key={i} className="tool-compact-row">
|
||||
<span className={`badge sm ${tool.installed ? 'ok' : 'error'}`}>
|
||||
{tool.installed ? '\u2713' : '\u2717'}
|
||||
</span>
|
||||
<span className="tool-compact-name">{name}</span>
|
||||
{ver && <span className="tool-compact-ver">{ver}</span>}
|
||||
{tool.installed && <span className="tool-compact-installed">{t('dashboard.installed')}</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="dashboard-grid">
|
||||
<div className="dashboard-section">
|
||||
<div className="dashboard-section-header">
|
||||
<div className="dashboard-section-title">{t('studio.workflows')}</div>
|
||||
</div>
|
||||
<div className="dashboard-workflows-inline">
|
||||
<div className="workflow-section">
|
||||
<div className="section-label">{t('studio.workflows')}</div>
|
||||
<div className="empty-state" style={{ padding: 20 }}>
|
||||
{t('studio.noWorkflow')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="workflow-section">
|
||||
<div className="section-label">{t('studio.activeAgents')}</div>
|
||||
<div className="empty-state" style={{ padding: 20 }}>
|
||||
{t('studio.noWorkflow')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'notifications' && (
|
||||
<div className="dashboard-notifications">
|
||||
<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>
|
||||
) : (
|
||||
notifications.map(n => (
|
||||
<div key={n.id} className={`notif-row notif-${n.type}`}>
|
||||
<span className="notif-time">
|
||||
{n.time.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||
</span>
|
||||
<span className="notif-text">{n.text}</span>
|
||||
</div>
|
||||
))
|
||||
<div 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>
|
||||
)}
|
||||
|
||||
{activeSection === 'workflows' && (
|
||||
<div className="dashboard-workflows">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
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,142 +1,376 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
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 { t } = useI18n()
|
||||
const termRef = useRef(null)
|
||||
const fitAddonRef = useRef(null)
|
||||
const wsRef = useRef(null)
|
||||
const containerRef = useRef(null)
|
||||
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: t('shell.aiWelcome') }
|
||||
])
|
||||
const [aiInput, setAiInput] = useState('')
|
||||
const [aiLoading, setAiLoading] = useState(false)
|
||||
const [connected, setConnected] = useState(false)
|
||||
const aiMessagesRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
|
||||
}, [aiMessages])
|
||||
|
||||
const getWsUrl = useCallback(() => {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${proto}//${window.location.host}/api/ws/terminal`
|
||||
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(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
const initTerminal = useCallback((tabId, tab) => {
|
||||
if (tabsRef.current[tabId]) return
|
||||
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
||||
theme: {
|
||||
background: '#0A0A0C',
|
||||
foreground: '#EAE0E2',
|
||||
cursor: '#FF0033',
|
||||
cursorAccent: '#0A0A0C',
|
||||
selectionBackground: '#FF003344',
|
||||
selectionForeground: '#ffffff',
|
||||
black: '#0A0A0C',
|
||||
red: '#FF0033',
|
||||
green: '#00E676',
|
||||
yellow: '#FFD740',
|
||||
blue: '#448AFF',
|
||||
magenta: '#FF1A5E',
|
||||
cyan: '#00BCD4',
|
||||
white: '#EAE0E2',
|
||||
brightBlack: '#5A4F52',
|
||||
brightRed: '#FF5252',
|
||||
brightGreen: '#69F0AE',
|
||||
brightYellow: '#FFFF00',
|
||||
brightBlue: '#82B1FF',
|
||||
brightMagenta: '#FF80AB',
|
||||
brightCyan: '#84FFFF',
|
||||
brightWhite: '#FFFFFF',
|
||||
},
|
||||
allowTransparency: false,
|
||||
scrollback: 5000,
|
||||
const container = document.getElementById(`terminal-${tabId}`)
|
||||
if (!container) return
|
||||
|
||||
const { term, fitAddon } = createTerminal(container, {
|
||||
fontSize: terminalSettings.fontSize,
|
||||
fontFamily: terminalSettings.fontFamily,
|
||||
theme: terminalSettings.theme,
|
||||
})
|
||||
|
||||
const fitAddon = new FitAddon()
|
||||
const webLinksAddon = new WebLinksAddon()
|
||||
term.loadAddon(fitAddon)
|
||||
term.loadAddon(webLinksAddon)
|
||||
term.open(containerRef.current)
|
||||
fitAddon.fit()
|
||||
|
||||
termRef.current = term
|
||||
fitAddonRef.current = fitAddon
|
||||
|
||||
const ws = new WebSocket(getWsUrl())
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
setConnected(true)
|
||||
const dims = fitAddon.proposeDimensions()
|
||||
if (dims) {
|
||||
ws.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols }))
|
||||
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 || '',
|
||||
}
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === 'output') {
|
||||
term.write(msg.data)
|
||||
} else if (msg.type === 'error') {
|
||||
term.write(`\r\n\x1b[31mError: ${msg.data}\x1b[0m\r\n`)
|
||||
}
|
||||
} catch {
|
||||
term.write(event.data)
|
||||
}
|
||||
const ws = connectWebSocket(term, fitAddon, initPayload)
|
||||
|
||||
ws.onopen = () => {
|
||||
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: true } : t))
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
setConnected(false)
|
||||
term.write('\r\n\x1b[33m— Connection closed —\x1b[0m\r\n')
|
||||
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t))
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
setConnected(false)
|
||||
term.write('\r\n\x1b[31m— Connection error —\x1b[0m\r\n')
|
||||
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t))
|
||||
}
|
||||
|
||||
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 }))
|
||||
}
|
||||
})
|
||||
|
||||
const onResize = () => {
|
||||
if (containerRef.current?.offsetParent !== null) {
|
||||
const el = document.getElementById(`terminal-${tabId}`)
|
||||
if (el && el.offsetParent !== null) {
|
||||
fitAddon.fit()
|
||||
}
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(onResize)
|
||||
resizeObserver.observe(containerRef.current)
|
||||
resizeObserver.observe(container)
|
||||
window.addEventListener('resize', onResize)
|
||||
|
||||
return () => {
|
||||
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]
|
||||
}
|
||||
}, [getWsUrl])
|
||||
|
||||
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 {
|
||||
await api.addSSHConnection(sshForm)
|
||||
setSshConnections(prev => [...prev, { ...sshForm }])
|
||||
setSshForm({ name: '', host: '', port: 22, user: '', key_path: '' })
|
||||
setShowSshModal(false)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteSSHConnection = async (name) => {
|
||||
try {
|
||||
await api.deleteSSHConnection(name)
|
||||
setSshConnections(prev => prev.filter(c => c.name !== name))
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAiSend = async () => {
|
||||
if (!aiInput.trim() || aiLoading) return
|
||||
@@ -144,26 +378,175 @@ export default function Shell({ api }) {
|
||||
setAiMessages(prev => [...prev, { role: 'user', content: text }])
|
||||
setAiInput('')
|
||||
setAiLoading(true)
|
||||
|
||||
try {
|
||||
const res = await api.runCommand(`echo "AI: ${text}"`, '')
|
||||
setAiMessages(prev => [...prev, { role: 'ai', content: res.output || t('shell.noResponse') }])
|
||||
const output = res.output || t('shell.noResponse')
|
||||
parseAndAddAiMessages(output)
|
||||
} catch (err) {
|
||||
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="shell-layout">
|
||||
<div className="shell-terminal-col">
|
||||
<div className="panel-header">
|
||||
<span className="panel-title">
|
||||
{t('shell.terminal')}
|
||||
<span className={`connection-dot ${connected ? 'on' : 'off'}`} />
|
||||
</span>
|
||||
<div className="shell-tabs-bar">
|
||||
<div className="shell-tabs">
|
||||
{tabs.map((tab, i) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`shell-tab ${activeTab === tab.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
onDoubleClick={(e) => startRename(tab.id, e)}
|
||||
>
|
||||
<span className={`connection-dot ${tab.connected ? 'on' : 'off'}`} />
|
||||
{tab.type === 'ssh' && <Globe size={12} />}
|
||||
{tab.type === 'local' && <Monitor size={12} />}
|
||||
{editingTab === tab.id ? (
|
||||
<input
|
||||
className="shell-tab-rename"
|
||||
value={editName}
|
||||
onChange={e => setEditName(e.target.value)}
|
||||
onBlur={finishRename}
|
||||
onKeyDown={e => { if (e.key === 'Enter') finishRename(); if (e.key === 'Escape') setEditingTab(null) }}
|
||||
autoFocus
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span className="shell-tab-name">{tab.name}</span>
|
||||
)}
|
||||
<span className="shell-tab-index">{i + 1}</span>
|
||||
{tabs.length > 1 && (
|
||||
<button
|
||||
className="shell-tab-close"
|
||||
onClick={(e) => closeTab(tab.id, e)}
|
||||
title={t('shell.closeTab')}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="shell-tab-actions">
|
||||
{tabs.length < MAX_TABS && (
|
||||
<div className="shell-new-tab-wrapper">
|
||||
<button className="shell-new-tab-btn" onClick={() => setShowMenu(!showMenu)} title={t('shell.newTab')}>
|
||||
<Plus size={16} />
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
{showMenu && (
|
||||
<>
|
||||
<div className="shell-menu-overlay" onClick={() => setShowMenu(false)} />
|
||||
<div className="shell-new-tab-menu">
|
||||
<div className="shell-menu-label">{t('shell.systemTerminals')}</div>
|
||||
{systemTerminals.map(st => (
|
||||
<button
|
||||
key={st.name}
|
||||
className="shell-menu-item"
|
||||
onClick={() => addLocalTab(st.shell, st.name)}
|
||||
>
|
||||
<Monitor size={14} />
|
||||
<span>{st.name}</span>
|
||||
<span className="shell-menu-item-sub">{st.shell}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="shell-menu-divider" />
|
||||
<div className="shell-menu-label">{t('shell.savedConnections')}</div>
|
||||
{sshConnections.length === 0 && (
|
||||
<div className="shell-menu-empty">{t('shell.noConnections')}</div>
|
||||
)}
|
||||
{sshConnections.map(conn => (
|
||||
<div key={conn.name} className="shell-menu-item-row">
|
||||
<button
|
||||
className="shell-menu-item"
|
||||
onClick={() => addSSHTab(conn)}
|
||||
>
|
||||
<Globe size={14} />
|
||||
<span>{conn.name}</span>
|
||||
<span className="shell-menu-item-sub">{conn.user}@{conn.host}:{conn.port}</span>
|
||||
</button>
|
||||
<button
|
||||
className="shell-menu-item-icon"
|
||||
onClick={(e) => { e.stopPropagation(); deleteSSHConnection(conn.name) }}
|
||||
title={t('shell.deleteConnection')}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="shell-menu-divider" />
|
||||
<button className="shell-menu-item accent" onClick={() => { setShowSshModal(true); setShowMenu(false) }}>
|
||||
<Plus size={14} />
|
||||
<span>{t('shell.addConnection')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shell-xterm-wrapper">
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
id={`terminal-${tab.id}`}
|
||||
className="shell-xterm-instance"
|
||||
style={{ display: activeTab === tab.id ? 'block' : 'none' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="shell-xterm-wrapper" ref={containerRef} />
|
||||
</div>
|
||||
|
||||
<div className="shell-ai-col">
|
||||
@@ -172,6 +555,7 @@ export default function Shell({ api }) {
|
||||
{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>}
|
||||
@@ -186,6 +570,56 @@ export default function Shell({ api }) {
|
||||
<button className="sm" onClick={handleAiSend} disabled={!aiInput.trim()}>{t('shell.send')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSshModal && (
|
||||
<div className="shell-modal-overlay" onClick={() => setShowSshModal(false)}>
|
||||
<div className="shell-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="shell-modal-header">{t('shell.addConnection')}</div>
|
||||
<div className="shell-modal-body">
|
||||
<label className="shell-modal-label">{t('shell.connectionName')}</label>
|
||||
<input
|
||||
value={sshForm.name}
|
||||
onChange={e => setSshForm(f => ({ ...f, name: e.target.value }))}
|
||||
placeholder="prod-server"
|
||||
/>
|
||||
<label className="shell-modal-label">{t('shell.host')}</label>
|
||||
<input
|
||||
value={sshForm.host}
|
||||
onChange={e => setSshForm(f => ({ ...f, host: e.target.value }))}
|
||||
placeholder="192.168.1.100"
|
||||
/>
|
||||
<div className="shell-modal-row">
|
||||
<div className="shell-modal-field">
|
||||
<label className="shell-modal-label">{t('shell.port')}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={sshForm.port}
|
||||
onChange={e => setSshForm(f => ({ ...f, port: parseInt(e.target.value) || 22 }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="shell-modal-field">
|
||||
<label className="shell-modal-label">{t('shell.user')}</label>
|
||||
<input
|
||||
value={sshForm.user}
|
||||
onChange={e => setSshForm(f => ({ ...f, user: e.target.value }))}
|
||||
placeholder="root"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label className="shell-modal-label">{t('shell.keyPath')} ({t('shell.local')})</label>
|
||||
<input
|
||||
value={sshForm.key_path}
|
||||
onChange={e => setSshForm(f => ({ ...f, key_path: e.target.value }))}
|
||||
placeholder="~/.ssh/id_rsa"
|
||||
/>
|
||||
</div>
|
||||
<div className="shell-modal-footer">
|
||||
<button className="ghost" onClick={() => setShowSshModal(false)}>{t('shell.cancel')}</button>
|
||||
<button className="primary" onClick={saveSSHConnection}>{t('shell.save')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,54 +1,37 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
const MSG_ID = () => Math.random().toString(36).slice(2, 10)
|
||||
|
||||
function parsePlanBlocks(text) {
|
||||
const plans = []
|
||||
const regex = /(?:^|\n)(?:###?\s+|(?:\d+\.\s+)?)\[?PLAN[^\]]*\]?:?\s*(.*?)(?=\n(?:###?\s+|(?:\d+\.\s+)?)\[?PLAN|\n## |\n### |\n$)/gis
|
||||
const matches = text.matchAll(regex)
|
||||
for (const m of matches) {
|
||||
plans.push({ id: MSG_ID(), title: m[1].trim(), content: m[0].trim() })
|
||||
}
|
||||
if (plans.length === 0 && /plan|workflow/i.test(text)) {
|
||||
const lines = text.split('\n').filter(l => /^\s*[-*]\s|^\s*\d+\.\s/.test(l))
|
||||
if (lines.length > 0) {
|
||||
plans.push({ id: MSG_ID(), title: text.split('\n')[0].slice(0, 80), content: text.trim() })
|
||||
}
|
||||
}
|
||||
return plans
|
||||
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 parseAgentMentions(text) {
|
||||
const agents = new Set()
|
||||
const names = ['crush', 'claude', 'claude code', 'ollama', 'copilot', 'cursor', 'agent']
|
||||
for (const name of names) {
|
||||
if (new RegExp('\\b' + name + '\\b', 'i').test(text)) {
|
||||
agents.add(name)
|
||||
}
|
||||
}
|
||||
return [...agents]
|
||||
function getRank(role) {
|
||||
if (role === 'user') return RANKS.commandant
|
||||
if (role === 'system') return null
|
||||
return RANKS.general
|
||||
}
|
||||
|
||||
function parseSteps(text) {
|
||||
const steps = []
|
||||
const lines = text.split('\n')
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^\s*(\d+)[.)]\s+(.+)/)
|
||||
if (match) {
|
||||
steps.push({ num: match[1], text: match[2].trim() })
|
||||
}
|
||||
const bulletMatch = line.match(/^\s*[-*]\s+(.+)/)
|
||||
if (bulletMatch) {
|
||||
steps.push({ num: String(steps.length + 1), text: bulletMatch[1].trim() })
|
||||
}
|
||||
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 steps
|
||||
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 = []
|
||||
let i = 0
|
||||
const codeBlockRegex = /(```[\s\S]*?```)/g
|
||||
let match
|
||||
let lastIndex = 0
|
||||
@@ -70,7 +53,7 @@ function renderContent(text) {
|
||||
}
|
||||
|
||||
function formatText(text) {
|
||||
let html = text
|
||||
return text
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
|
||||
@@ -78,188 +61,108 @@ function formatText(text) {
|
||||
.replace(/^## (.+)$/gm, '<h3 class="msg-h3">$1</h3>')
|
||||
.replace(/^\s*[-*] (.+)$/gm, '<span class="msg-bullet">$1</span>')
|
||||
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<span class="msg-step"><span class="msg-step-num">$1</span>$2</span>')
|
||||
return html
|
||||
}
|
||||
|
||||
function MessageBubble({ msg }) {
|
||||
const { t } = useI18n()
|
||||
const [expanded, setExpanded] = useState(null)
|
||||
const plans = msg.role === 'ai' ? parsePlanBlocks(msg.content) : []
|
||||
const steps = msg.role === 'ai' ? parseSteps(msg.content) : []
|
||||
const agents = msg.role === 'ai' ? parseAgentMentions(msg.content) : []
|
||||
|
||||
function ThinkingBlock({ content, done }) {
|
||||
return (
|
||||
<div className={`studio-msg ${msg.role}`}>
|
||||
{msg.role === 'ai' && (
|
||||
<div className="studio-msg-avatar">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
||||
</div>
|
||||
)}
|
||||
<div className="studio-msg-body">
|
||||
<div className="studio-msg-content">
|
||||
{renderContent(msg.content).map((part, i) =>
|
||||
part.type === 'code' ? (
|
||||
<div key={i} className="studio-code-block">
|
||||
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
|
||||
<pre><code>{part.content}</code></pre>
|
||||
</div>
|
||||
) : (
|
||||
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{msg.role === 'ai' && (plans.length > 0 || agents.length > 0) && (
|
||||
<div className="studio-msg-meta">
|
||||
{plans.map(plan => (
|
||||
<div key={plan.id} className="studio-plan-chip" onClick={() => setExpanded(expanded === plan.id ? null : plan.id)}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
{plan.title.slice(0, 60)}
|
||||
<span className="studio-expand-icon">{expanded === plan.id ? '\u25B2' : '\u25BC'}</span>
|
||||
</div>
|
||||
))}
|
||||
{agents.map(agent => (
|
||||
<span key={agent} className="studio-agent-tag">
|
||||
{agent}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{expanded && plans.find(p => p.id === expanded) && (
|
||||
<div className="studio-plan-detail">
|
||||
<div className="studio-plan-detail-header">{t('studio.planDetail')}</div>
|
||||
{steps.length > 0 && (
|
||||
<div className="studio-steps">
|
||||
{steps.map(step => (
|
||||
<div key={step.num} className="studio-step">
|
||||
<span className="studio-step-num">{step.num}</span>
|
||||
<span className="studio-step-text">{step.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="studio-plan-raw">
|
||||
<pre>{plans.find(p => p.id === expanded).content}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div 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 StreamingMessage({ content }) {
|
||||
return (
|
||||
<div className="studio-msg ai">
|
||||
<div className="studio-msg-avatar">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
||||
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>
|
||||
<div className="studio-msg-body">
|
||||
<div className="studio-msg-content">
|
||||
{renderContent(content).map((part, i) =>
|
||||
part.type === 'code' ? (
|
||||
<div key={i} className="studio-code-block">
|
||||
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
|
||||
<pre><code>{part.content}</code></pre>
|
||||
</div>
|
||||
) : (
|
||||
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<span className="studio-cursor" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextPanel({ messages, selectedPlan, onSelectPlan }) {
|
||||
const { t } = useI18n()
|
||||
const [tab, setTab] = useState('plans')
|
||||
|
||||
const allPlans = []
|
||||
const allAgents = new Set()
|
||||
const activities = []
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i]
|
||||
if (msg.role === 'ai') {
|
||||
const plans = parsePlanBlocks(msg.content)
|
||||
for (const plan of plans) {
|
||||
if (!allPlans.find(p => p.title === plan.title)) {
|
||||
allPlans.push({ ...plan, msgIndex: i })
|
||||
}
|
||||
}
|
||||
parseAgentMentions(msg.content).forEach(a => allAgents.add(a))
|
||||
}
|
||||
activities.push({ role: msg.role, content: msg.content.slice(0, 100), time: msg.time })
|
||||
)
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'plans', label: t('studio.plans'), count: allPlans.length },
|
||||
{ id: 'agents', label: t('studio.agents'), count: allAgents.size },
|
||||
{ id: 'activity', label: t('studio.activity'), count: activities.length },
|
||||
]
|
||||
const cleanContent = msg.content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||||
|
||||
return (
|
||||
<div className="studio-context">
|
||||
<div className="studio-context-tabs">
|
||||
{tabs.map(t2 => (
|
||||
<div
|
||||
key={t2.id}
|
||||
className={`studio-context-tab ${tab === t2.id ? 'active' : ''}`}
|
||||
onClick={() => setTab(t2.id)}
|
||||
>
|
||||
{t2.label}
|
||||
{t2.count > 0 && <span className="studio-tab-count">{t2.count}</span>}
|
||||
</div>
|
||||
))}
|
||||
<div className={`feed-item ${msg.role}`}>
|
||||
<div className={`feed-avatar ${isUser ? 'user-rank' : 'ai-rank'}`}>
|
||||
<RankIcon rank={rank} />
|
||||
</div>
|
||||
<div className="studio-context-body">
|
||||
{tab === 'plans' && (
|
||||
allPlans.length > 0 ? (
|
||||
<div className="studio-plan-list">
|
||||
{allPlans.map(plan => (
|
||||
<div
|
||||
key={plan.id}
|
||||
className={`studio-plan-item ${selectedPlan === plan.id ? 'active' : ''}`}
|
||||
onClick={() => onSelectPlan(selectedPlan === plan.id ? null : plan.id)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
<div className="studio-plan-item-text">{plan.title}</div>
|
||||
<span className="studio-plan-item-badge">{parseSteps(plan.content).length} {t('studio.steps')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="studio-empty">{t('studio.noPlansYet')}</div>
|
||||
)
|
||||
)}
|
||||
{tab === 'agents' && (
|
||||
allAgents.size > 0 ? (
|
||||
<div className="studio-agent-list">
|
||||
{[...allAgents].map(agent => (
|
||||
<div key={agent} className="studio-agent-item">
|
||||
<div className="studio-agent-dot" />
|
||||
<span className="studio-agent-name">{agent}</span>
|
||||
<span className="badge info">{t('studio.mentioned')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="studio-empty">{t('studio.noAgentsYet')}</div>
|
||||
)
|
||||
)}
|
||||
{tab === 'activity' && (
|
||||
<div className="studio-activity-list">
|
||||
{activities.map((act, i) => (
|
||||
<div key={i} className="studio-activity-item">
|
||||
<div className={`studio-activity-dot ${act.role}`} />
|
||||
<div className="studio-activity-text">
|
||||
{act.role === 'user' ? t('studio.you') + ': ' : 'AI: '}
|
||||
{act.content}{act.content.length >= 100 ? '...' : ''}
|
||||
</div>
|
||||
<div 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>
|
||||
@@ -269,20 +172,36 @@ function ContextPanel({ messages, selectedPlan, onSelectPlan }) {
|
||||
|
||||
export default function Studio({ api }) {
|
||||
const { t } = useI18n()
|
||||
const [messages, setMessages] = useState([
|
||||
{ id: MSG_ID(), role: 'ai', content: t('studio.welcomeNew'), time: new Date() },
|
||||
])
|
||||
const [messages, setMessages] = useState([])
|
||||
const [input, setInput] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [streaming, setStreaming] = useState('')
|
||||
const [selectedPlan, setSelectedPlan] = useState(null)
|
||||
const [showContext, setShowContext] = useState(true)
|
||||
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, streaming])
|
||||
}, [messages, streaming, streamThinking])
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
@@ -291,30 +210,69 @@ export default function Studio({ api }) {
|
||||
}
|
||||
}, [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()
|
||||
setInput('')
|
||||
const userMsg = { id: MSG_ID(), role: 'user', content: text, time: new Date() }
|
||||
|
||||
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 = ''
|
||||
await api.sendChat(text, true).then(full => {
|
||||
accumulated = full
|
||||
}).catch(() => {})
|
||||
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)
|
||||
})
|
||||
|
||||
const finalContent = accumulated || t('studio.noResponse')
|
||||
setMessages(prev => [...prev, { id: MSG_ID(), role: 'ai', content: finalContent, time: new Date() }])
|
||||
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: MSG_ID(), role: 'ai', content: `${t('studio.error')}: ${err.message}`, time: new Date() }])
|
||||
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])
|
||||
}, [input, loading, api, t, handleClear])
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
@@ -323,70 +281,54 @@ export default function Studio({ api }) {
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="studio-layout">
|
||||
<div className="studio-chat-area">
|
||||
<div className="studio-messages">
|
||||
{messages.map(msg => (
|
||||
<MessageBubble key={msg.id} msg={msg} />
|
||||
))}
|
||||
{streaming && <StreamingMessage content={streaming} />}
|
||||
{loading && !streaming && (
|
||||
<div className="studio-msg ai">
|
||||
<div className="studio-msg-avatar">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
||||
</div>
|
||||
<div className="studio-msg-body">
|
||||
<div className="studio-thinking">
|
||||
<span /><span /><span />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEnd} />
|
||||
</div>
|
||||
|
||||
<div className="studio-input-area">
|
||||
<div className="studio-input-row">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('studio.placeholderNew')}
|
||||
disabled={loading}
|
||||
rows={1}
|
||||
/>
|
||||
<button
|
||||
className="studio-send-btn"
|
||||
onClick={handleSend}
|
||||
disabled={loading || !input.trim()}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="studio-input-hint">
|
||||
{t('studio.inputHint')}
|
||||
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={`studio-sidebar ${showContext ? 'open' : ''}`}>
|
||||
<div className="studio-sidebar-header">
|
||||
<span>{t('studio.context')}</span>
|
||||
<button className="ghost sm studio-sidebar-toggle" onClick={() => setShowContext(!showContext)}>
|
||||
{showContext ? '\u203A' : '\u2039'}
|
||||
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="studio-input-area">
|
||||
<div className="studio-input-row">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('studio.placeholderNew')}
|
||||
disabled={loading}
|
||||
rows={1}
|
||||
/>
|
||||
<button
|
||||
className="studio-send-btn"
|
||||
onClick={handleSend}
|
||||
disabled={loading || !input.trim()}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{showContext && (
|
||||
<ContextPanel
|
||||
messages={messages}
|
||||
selectedPlan={selectedPlan}
|
||||
onSelectPlan={setSelectedPlan}
|
||||
/>
|
||||
)}
|
||||
<div className="studio-input-hint">
|
||||
{t('studio.inputHint')} · /clear
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -76,20 +76,53 @@ const en = {
|
||||
steps: 'steps',
|
||||
you: 'You',
|
||||
mentioned: 'mentioned',
|
||||
cleared: 'Conversation cleared.',
|
||||
},
|
||||
|
||||
shell: {
|
||||
terminal: 'Terminal',
|
||||
hideAi: 'Hide AI',
|
||||
aiAssistant: 'AI Assistant',
|
||||
aiWelcome: 'I know your system inside out. Ask me anything.',
|
||||
askAi: 'Ask AI...',
|
||||
send: 'Send',
|
||||
noResponse: 'No response',
|
||||
error: 'Error',
|
||||
newTab: 'New tab',
|
||||
closeTab: 'Close tab',
|
||||
maxTabsReached: 'Maximum 7 terminals reached',
|
||||
renameTab: 'Rename',
|
||||
local: 'Local',
|
||||
ssh: 'SSH',
|
||||
connections: 'Connections',
|
||||
addConnection: 'Add SSH connection',
|
||||
editConnection: 'Edit connection',
|
||||
deleteConnection: 'Delete',
|
||||
connectionName: 'Name',
|
||||
host: 'Host',
|
||||
port: 'Port',
|
||||
user: 'User',
|
||||
keyPath: 'SSH key path',
|
||||
connect: 'Connect',
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
savedConnections: 'Saved connections',
|
||||
noConnections: 'No saved SSH connections.',
|
||||
systemTerminals: 'System terminals',
|
||||
switchTerminal: 'Switch terminal',
|
||||
localShell: 'Local Shell',
|
||||
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',
|
||||
@@ -102,15 +135,58 @@ const en = {
|
||||
notSet: 'Not set',
|
||||
aiProviders: 'AI Providers',
|
||||
active: 'Active',
|
||||
activate: 'Activate',
|
||||
keyConfigured: 'Key configured',
|
||||
noKey: 'No key',
|
||||
theme: 'Theme',
|
||||
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.',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -76,20 +76,53 @@ const fr = {
|
||||
steps: '\u00e9tapes',
|
||||
you: 'Vous',
|
||||
mentioned: 'mentionn\u00e9',
|
||||
cleared: 'Conversation effac\u00e9e.',
|
||||
},
|
||||
|
||||
shell: {
|
||||
terminal: 'Terminal',
|
||||
hideAi: 'Masquer IA',
|
||||
aiAssistant: 'Assistant IA',
|
||||
aiWelcome: 'Je connais votre syst\u00e8me sur le bout des doigts. Demandez-moi n\u2019importe quoi.',
|
||||
askAi: 'Demander \u00e0 l\u2019IA...',
|
||||
send: 'Envoyer',
|
||||
noResponse: 'Pas de r\u00e9ponse',
|
||||
error: 'Erreur',
|
||||
newTab: 'Nouvel onglet',
|
||||
closeTab: 'Fermer l\u2019onglet',
|
||||
maxTabsReached: 'Maximum 7 terminaux atteint',
|
||||
renameTab: 'Renommer',
|
||||
local: 'Local',
|
||||
ssh: 'SSH',
|
||||
connections: 'Connexions',
|
||||
addConnection: 'Ajouter une connexion SSH',
|
||||
editConnection: 'Modifier la connexion',
|
||||
deleteConnection: 'Supprimer',
|
||||
connectionName: 'Nom',
|
||||
host: 'H\u00f4te',
|
||||
port: 'Port',
|
||||
user: 'Utilisateur',
|
||||
keyPath: 'Chemin cl\u00e9 SSH',
|
||||
connect: 'Se connecter',
|
||||
save: 'Enregistrer',
|
||||
cancel: 'Annuler',
|
||||
savedConnections: 'Connexions enregistr\u00e9es',
|
||||
noConnections: 'Aucune connexion SSH enregistr\u00e9e.',
|
||||
systemTerminals: 'Terminaux syst\u00e8me',
|
||||
switchTerminal: 'Changer de terminal',
|
||||
localShell: 'Shell local',
|
||||
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',
|
||||
@@ -102,15 +135,58 @@ const fr = {
|
||||
notSet: 'Non d\u00e9fini',
|
||||
aiProviders: 'Fournisseurs IA',
|
||||
active: 'Actif',
|
||||
activate: 'Activer',
|
||||
keyConfigured: 'Cl\u00e9 configur\u00e9e',
|
||||
noKey: 'Pas de cl\u00e9',
|
||||
theme: 'Th\u00e8me',
|
||||
skills: 'Comp\u00e9tences',
|
||||
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.',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -141,6 +141,7 @@ input::placeholder { color: var(--text-disabled); }
|
||||
}
|
||||
.nav-tab:hover { color: var(--text-primary); background: var(--bg-card); }
|
||||
.nav-tab.active { color: #fff; background: var(--accent); }
|
||||
.tab-icon { display: flex; align-items: center; }
|
||||
|
||||
.header-spacer { flex: 1; }
|
||||
|
||||
@@ -268,47 +269,248 @@ input::placeholder { color: var(--text-disabled); }
|
||||
.sidebar-tab.active { background: var(--accent); color: #fff; font-weight: 600; }
|
||||
|
||||
.shell-layout { display: flex; height: 100%; }
|
||||
.shell-terminal-col { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
.shell-xterm-wrapper { flex: 1; padding: 8px; background: var(--bg); overflow: hidden; }
|
||||
.shell-xterm-wrapper .xterm { height: 100%; padding: 4px; }
|
||||
.shell-ai-col { width: 340px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; }
|
||||
.connection-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; margin-left: 8px; }
|
||||
.shell-terminal-col { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; }
|
||||
|
||||
.shell-tabs-bar {
|
||||
display: flex; align-items: center; background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border); flex-shrink: 0;
|
||||
height: 36px; padding: 0 8px; gap: 4px;
|
||||
}
|
||||
.shell-tabs {
|
||||
display: flex; align-items: center; gap: 2px; flex: 1; overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.shell-tabs::-webkit-scrollbar { display: none; }
|
||||
|
||||
.shell-tab {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 4px 10px; border-radius: var(--radius) var(--radius) 0 0;
|
||||
font-size: 12px; font-weight: 500; color: var(--text-tertiary);
|
||||
cursor: pointer; transition: all 0.15s; user-select: none;
|
||||
border: 1px solid transparent; border-bottom: none;
|
||||
white-space: nowrap; max-width: 180px; position: relative;
|
||||
background: transparent;
|
||||
}
|
||||
.shell-tab:hover { color: var(--text-primary); background: var(--bg-card); }
|
||||
.shell-tab.active {
|
||||
color: var(--text-primary); background: var(--bg);
|
||||
border-color: var(--border); border-bottom-color: var(--bg);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.shell-tab-name {
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
max-width: 120px; font-size: 12px;
|
||||
}
|
||||
.shell-tab-index {
|
||||
font-size: 9px; color: var(--text-disabled); font-family: var(--font-mono);
|
||||
padding: 0 3px; background: var(--bg-input); border-radius: 3px; line-height: 1.4;
|
||||
}
|
||||
.shell-tab-close {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 16px; height: 16px; border-radius: 3px; border: none;
|
||||
background: transparent; color: var(--text-disabled); cursor: pointer;
|
||||
padding: 0; transition: all 0.1s; flex-shrink: 0;
|
||||
}
|
||||
.shell-tab-close:hover { background: var(--accent-bg); color: var(--accent); }
|
||||
|
||||
.shell-tab-rename {
|
||||
width: 80px; font-size: 12px; padding: 1px 4px; border-radius: 3px;
|
||||
background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--accent);
|
||||
outline: none; font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.shell-tab-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
|
||||
|
||||
.shell-new-tab-wrapper { position: relative; }
|
||||
.shell-new-tab-btn {
|
||||
display: flex; align-items: center; gap: 2px;
|
||||
padding: 4px 8px; border-radius: var(--radius);
|
||||
background: transparent; border: 1px solid var(--border);
|
||||
color: var(--text-tertiary); cursor: pointer; transition: all 0.15s;
|
||||
font-size: 12px;
|
||||
}
|
||||
.shell-new-tab-btn:hover { color: var(--text-primary); background: var(--bg-card); border-color: var(--accent-dark); }
|
||||
|
||||
.shell-menu-overlay {
|
||||
position: fixed; inset: 0; z-index: 998;
|
||||
}
|
||||
.shell-new-tab-menu {
|
||||
position: absolute; top: 100%; right: 0; z-index: 999;
|
||||
background: var(--bg-elevated); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg); padding: 6px;
|
||||
min-width: 260px; max-height: 400px; overflow-y: auto;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||||
}
|
||||
.shell-menu-label {
|
||||
font-size: 10px; font-weight: 700; color: var(--text-disabled);
|
||||
text-transform: uppercase; letter-spacing: 0.5px;
|
||||
padding: 6px 10px 4px;
|
||||
}
|
||||
.shell-menu-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
width: 100%; padding: 7px 10px; border-radius: var(--radius);
|
||||
background: transparent; border: none; color: var(--text-secondary);
|
||||
cursor: pointer; transition: all 0.1s; font-size: 12px;
|
||||
text-align: left; font-family: var(--font-sans);
|
||||
}
|
||||
.shell-menu-item:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
.shell-menu-item.accent { color: var(--accent); }
|
||||
.shell-menu-item.accent:hover { background: var(--accent-bg); }
|
||||
.shell-menu-item-sub {
|
||||
font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono);
|
||||
margin-left: auto;
|
||||
}
|
||||
.shell-menu-item-row { display: flex; align-items: center; }
|
||||
.shell-menu-item-icon {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 24px; height: 24px; border-radius: var(--radius);
|
||||
background: transparent; border: none; color: var(--text-disabled);
|
||||
cursor: pointer; transition: all 0.1s; flex-shrink: 0;
|
||||
}
|
||||
.shell-menu-item-icon:hover { background: var(--accent-bg); color: var(--accent); }
|
||||
.shell-menu-empty {
|
||||
font-size: 12px; color: var(--text-disabled); padding: 8px 10px;
|
||||
font-style: italic;
|
||||
}
|
||||
.shell-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; }
|
||||
|
||||
.shell-xterm-wrapper { flex: 1; background: var(--bg); overflow: hidden; position: relative; }
|
||||
.shell-xterm-instance {
|
||||
position: absolute; inset: 0; padding: 4px;
|
||||
display: block !important;
|
||||
}
|
||||
.shell-xterm-instance .xterm { height: 100%; padding: 4px; }
|
||||
|
||||
.connection-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
||||
.connection-dot.on { background: var(--success); box-shadow: 0 0 6px var(--success); }
|
||||
.connection-dot.off { background: var(--error); }
|
||||
|
||||
.config-layout { max-width: 840px; margin: 0 auto; padding: 24px; overflow-y: auto; height: 100%; }
|
||||
.config-section { margin-bottom: 28px; }
|
||||
.config-section-title {
|
||||
font-size: 12px; font-weight: 700; color: var(--accent); text-transform: uppercase;
|
||||
letter-spacing: 1px; margin-bottom: 16px; padding-bottom: 8px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.field-row { display: flex; align-items: center; padding: 10px 0; border-bottom: 1px solid var(--border); gap: 12px; }
|
||||
.field-row:last-child { border-bottom: none; }
|
||||
.field-label { width: 140px; flex-shrink: 0; color: var(--text-tertiary); font-size: 13px; }
|
||||
.field-value { color: var(--text-primary); font-size: 14px; flex: 1; }
|
||||
.field-value.empty { color: var(--text-disabled); font-style: italic; }
|
||||
.shell-ai-col { width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; }
|
||||
.ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); }
|
||||
.ai-panel-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.ai-message { padding: 8px 12px; border-radius: var(--radius); font-size: 13px; line-height: 1.5; word-break: break-word; }
|
||||
.ai-message.ai { background: var(--bg-card); border-left: 3px solid var(--accent); }
|
||||
.ai-message.user { background: var(--accent-bg); border-left: 3px solid var(--accent-muted); }
|
||||
.ai-message.thinking { background: var(--bg-elevated); border-left: 3px solid var(--info); font-style: italic; color: var(--text-tertiary); }
|
||||
.ai-message.tool { background: var(--bg-elevated); border-left: 3px solid var(--warning); }
|
||||
.ai-message.tool .tool-name { font-weight: 700; color: var(--warning); }
|
||||
.ai-message.tool .tool-args { font-family: var(--font-mono); font-size: 12px; color: var(--text-tertiary); margin-top: 4px; }
|
||||
.ai-panel-input { display: flex; gap: 6px; padding: 10px 12px; border-top: 1px solid var(--border); }
|
||||
.ai-panel-input input { flex: 1; font-size: 13px; padding: 6px 10px; }
|
||||
|
||||
.provider-card {
|
||||
background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 14px 16px; margin-bottom: 8px; display: flex; align-items: center; justify-content: space-between;
|
||||
transition: border-color 0.2s;
|
||||
.shell-modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 1000;
|
||||
}
|
||||
.shell-modal {
|
||||
background: var(--bg-elevated); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg); min-width: 380px; max-width: 480px;
|
||||
box-shadow: 0 16px 48px rgba(0,0,0,0.5);
|
||||
}
|
||||
.shell-modal-header {
|
||||
padding: 16px 20px; font-size: 14px; font-weight: 700;
|
||||
color: var(--text-primary); border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.shell-modal-body { padding: 16px 20px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.shell-modal-label { font-size: 11px; font-weight: 600; color: var(--text-tertiary); margin-bottom: 2px; }
|
||||
.shell-modal-row { display: grid; grid-template-columns: 1fr 2fr; gap: 12px; }
|
||||
.shell-modal-field { display: flex; flex-direction: column; }
|
||||
.shell-modal-footer {
|
||||
padding: 12px 20px; border-top: 1px solid var(--border);
|
||||
display: flex; justify-content: flex-end; gap: 8px;
|
||||
}
|
||||
.provider-card:hover { border-color: var(--accent-dim); }
|
||||
.provider-info { display: flex; flex-direction: column; gap: 4px; }
|
||||
.provider-name { font-weight: 600; color: var(--text-primary); font-size: 14px; }
|
||||
.provider-meta { display: flex; gap: 12px; font-size: 12px; color: var(--text-tertiary); font-family: var(--font-mono); }
|
||||
|
||||
.theme-picker { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.theme-swatch {
|
||||
width: 48px; height: 48px; border-radius: var(--radius); border: 2px solid var(--border);
|
||||
cursor: pointer; transition: all 0.15s; position: relative;
|
||||
.config-window { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||
|
||||
.config-tabs-bar {
|
||||
display: flex; gap: 4px; padding: 12px 20px 0; background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border); flex-shrink: 0;
|
||||
}
|
||||
.theme-swatch:hover { transform: scale(1.1); border-color: var(--accent-dim); }
|
||||
.theme-swatch.active { border-color: var(--accent); box-shadow: 0 0 0 3px var(--border-accent); }
|
||||
.theme-swatch.active::after {
|
||||
content: '\2713'; position: absolute; inset: 0; display: flex; align-items: center;
|
||||
justify-content: center; color: #fff; font-size: 18px; font-weight: 700; text-shadow: 0 1px 3px rgba(0,0,0,0.5);
|
||||
|
||||
.config-panel-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.config-panel-body { flex: 1; overflow-y: auto; padding: 16px 28px 28px; }
|
||||
|
||||
.config-card {
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg); padding: 20px 24px; margin-bottom: 16px;
|
||||
}
|
||||
.config-card-row {
|
||||
display: flex; align-items: center; padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border); gap: 16px;
|
||||
}
|
||||
.config-card-row:last-of-type { border-bottom: none; }
|
||||
.config-card-label { width: 130px; flex-shrink: 0; color: var(--text-tertiary); font-size: 13px; }
|
||||
.config-card-value { color: var(--text-primary); font-size: 14px; flex: 1; }
|
||||
.config-card-value.mono { font-family: var(--font-mono); }
|
||||
.config-card-value:not(.mono)[style*="—"] { color: var(--text-disabled); font-style: italic; }
|
||||
.config-card-actions { display: flex; gap: 8px; padding-top: 16px; }
|
||||
|
||||
.config-form-field { margin-bottom: 14px; }
|
||||
.config-form-label { display: block; font-size: 11px; font-weight: 600; color: var(--text-tertiary); margin-bottom: 5px; text-transform: uppercase; letter-spacing: 0.3px; }
|
||||
.config-form-input {
|
||||
width: 100%; background: var(--bg-input); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 8px 12px; color: var(--text-primary);
|
||||
font-size: 13px; font-family: var(--font-mono); outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.config-form-input:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--border-accent); }
|
||||
|
||||
.config-card-group { margin-bottom: 20px; }
|
||||
.config-card-group:last-child { margin-bottom: 0; }
|
||||
.config-card-group-label { display: block; font-size: 11px; font-weight: 700; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 10px; }
|
||||
|
||||
.config-providers-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.provider-card-v2 {
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg); padding: 16px 20px; transition: border-color 0.2s;
|
||||
}
|
||||
.provider-card-v2:hover { border-color: var(--accent-dim); }
|
||||
.provider-card-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.provider-card-identity { display: flex; align-items: center; gap: 10px; }
|
||||
.provider-card-name { font-weight: 600; color: var(--text-primary); font-size: 14px; }
|
||||
.provider-card-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
.provider-card-meta { display: flex; gap: 16px; font-size: 12px; color: var(--text-tertiary); font-family: var(--font-mono); margin-top: 8px; }
|
||||
.provider-card-form { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
|
||||
|
||||
.provider-setup-hint {
|
||||
font-size: 13px; color: var(--text-tertiary); margin-bottom: 16px;
|
||||
padding: 10px 14px; border-radius: var(--radius); background: var(--bg-surface);
|
||||
border-left: 3px solid var(--accent-dim);
|
||||
}
|
||||
.provider-setup-token-row { display: flex; gap: 12px; align-items: flex-end; }
|
||||
.provider-setup-token-input { flex: 1; }
|
||||
.provider-setup-token-actions { display: flex; gap: 8px; flex-shrink: 0; padding-bottom: 1px; }
|
||||
|
||||
.config-update-controls {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap;
|
||||
}
|
||||
.config-update-stats { display: flex; gap: 8px; }
|
||||
.config-update-buttons { display: flex; gap: 8px; }
|
||||
|
||||
.config-update-list { display: flex; flex-direction: column; gap: 2px; }
|
||||
.config-update-row { display: flex; align-items: center; justify-content: space-between; padding: 10px 16px; border-radius: var(--radius); background: var(--bg-card); border: 1px solid var(--border); margin-bottom: 6px; }
|
||||
.config-update-row:hover { border-color: var(--accent-dim); }
|
||||
.config-update-info { display: flex; align-items: center; gap: 16px; flex: 1; }
|
||||
.config-update-name { color: var(--text-primary); font-weight: 600; font-size: 13px; min-width: 100px; }
|
||||
.config-update-versions { color: var(--text-tertiary); font-size: 12px; font-family: var(--font-mono); }
|
||||
|
||||
.config-skill-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
|
||||
.config-skill-row:last-child { border-bottom: none; }
|
||||
.config-skill-name { color: var(--text-primary); font-weight: 600; min-width: 120px; }
|
||||
.config-skill-desc { color: var(--text-tertiary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.chip-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
.config-toast {
|
||||
position: fixed; bottom: 40px; left: 50%; transform: translateX(-50%);
|
||||
background: var(--accent); color: #fff; padding: 10px 24px; border-radius: var(--radius-lg);
|
||||
font-size: 13px; font-weight: 600; z-index: 100; animation: fadeIn 0.2s ease-out;
|
||||
box-shadow: 0 4px 24px rgba(255, 0, 51, 0.3);
|
||||
}
|
||||
|
||||
.spin-icon { animation: spin 0.8s linear infinite; display: inline-block; vertical-align: middle; }
|
||||
.mono { font-family: var(--font-mono); }
|
||||
|
||||
|
||||
|
||||
.section-title { font-size: 12px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 12px; }
|
||||
.actions-stack { display: flex; flex-direction: column; gap: 6px; }
|
||||
@@ -321,51 +523,29 @@ input::placeholder { color: var(--text-disabled); }
|
||||
.agent-name { font-weight: 600; color: var(--text-primary); font-size: 13px; }
|
||||
.agent-status { font-size: 11px; color: var(--text-tertiary); margin-top: 2px; }
|
||||
|
||||
.ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); }
|
||||
.ai-panel-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.ai-message { padding: 8px 12px; border-radius: var(--radius); font-size: 13px; line-height: 1.5; word-break: break-word; }
|
||||
.ai-message.ai { background: var(--bg-card); border-left: 3px solid var(--accent); }
|
||||
.ai-message.user { background: var(--accent-bg); border-left: 3px solid var(--accent-muted); }
|
||||
.ai-panel-input { display: flex; gap: 6px; padding: 10px 12px; border-top: 1px solid var(--border); }
|
||||
.ai-panel-input input { flex: 1; font-size: 13px; padding: 6px 10px; }
|
||||
|
||||
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; color: var(--text-disabled); font-size: 13px; text-align: center; gap: 8px; }
|
||||
|
||||
.dashboard-layout { display: flex; flex-direction: column; height: 100%; }
|
||||
.dashboard-tabs {
|
||||
display: flex; gap: 0; border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-surface); flex-shrink: 0;
|
||||
}
|
||||
.dashboard-tab {
|
||||
padding: 10px 24px; font-size: 13px; font-weight: 600;
|
||||
color: var(--text-tertiary); cursor: pointer; transition: all 0.15s;
|
||||
display: flex; align-items: center; gap: 8px; border-bottom: 2px solid transparent;
|
||||
user-select: none;
|
||||
}
|
||||
.dashboard-tab:hover { color: var(--text-primary); background: var(--bg-card); }
|
||||
.dashboard-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
.tab-count {
|
||||
font-size: 10px; padding: 1px 6px; border-radius: 99px;
|
||||
background: var(--bg-card); color: var(--text-tertiary); font-family: var(--font-mono);
|
||||
}
|
||||
.tab-count.warn { background: rgba(255,215,64,0.15); color: var(--warning); }
|
||||
|
||||
.dashboard-content { flex: 1; overflow-y: auto; }
|
||||
.dashboard-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; padding: 24px; }
|
||||
|
||||
.dashboard-tools { padding: 16px 24px; }
|
||||
.tools-compact { display: flex; flex-direction: column; gap: 2px; }
|
||||
.tool-compact-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 6px 12px; border-radius: var(--radius);
|
||||
font-size: 13px; transition: background 0.1s;
|
||||
.dashboard-section {
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg); padding: 20px; transition: border-color 0.2s;
|
||||
}
|
||||
.dashboard-section:hover { border-color: var(--accent-dim); }
|
||||
.dashboard-section.full-width { grid-column: 1 / -1; }
|
||||
.dashboard-section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
||||
.dashboard-section-title {
|
||||
font-size: 12px; font-weight: 700; color: var(--accent); text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.tool-compact-row:hover { background: var(--bg-card); }
|
||||
.badge.sm { padding: 1px 5px; font-size: 10px; }
|
||||
.tool-compact-name { color: var(--text-primary); font-weight: 500; flex: 1; }
|
||||
.tool-compact-ver { color: var(--text-tertiary); font-size: 11px; font-family: var(--font-mono); }
|
||||
.tool-compact-installed { color: var(--success); font-size: 11px; font-family: var(--font-mono); opacity: 0.7; }
|
||||
|
||||
.dashboard-notifications { padding: 16px 24px; }
|
||||
.dashboard-workflows-inline { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||
|
||||
.dashboard-notifications-inline { display: flex; flex-direction: column; gap: 2px; }
|
||||
|
||||
.dashboard-notifications { padding: 0; }
|
||||
.notif-row {
|
||||
display: flex; align-items: flex-start; gap: 12px;
|
||||
padding: 8px 12px; border-radius: var(--radius); margin-bottom: 4px;
|
||||
@@ -378,7 +558,7 @@ input::placeholder { color: var(--text-disabled); }
|
||||
.notif-warn .notif-text { color: var(--warning); }
|
||||
.notif-error .notif-text { color: var(--error); }
|
||||
|
||||
.dashboard-workflows { padding: 16px 24px; display: flex; flex-direction: column; gap: 24px; }
|
||||
.dashboard-workflows { padding: 0; display: flex; flex-direction: column; gap: 24px; }
|
||||
.workflow-section { }
|
||||
.section-label {
|
||||
font-size: 11px; font-weight: 700; color: var(--accent); text-transform: uppercase;
|
||||
@@ -397,21 +577,65 @@ input::placeholder { color: var(--text-disabled); }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.fade-in { animation: fadeIn 0.2s ease-out; }
|
||||
|
||||
/* ── Studio ── */
|
||||
.studio-layout { display: flex; height: 100%; overflow: hidden; }
|
||||
.studio-chat-area { display: flex; flex-direction: column; flex: 1; min-width: 0; }
|
||||
.studio-messages { flex: 1; overflow-y: auto; padding: 24px 20px; display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.studio-msg { display: flex; gap: 10px; max-width: 85%; animation: fadeIn 0.2s ease-out; }
|
||||
.studio-msg.user { align-self: flex-end; flex-direction: row-reverse; }
|
||||
.studio-msg.ai { align-self: flex-start; }
|
||||
|
||||
.studio-msg-avatar {
|
||||
width: 28px; height: 28px; border-radius: 50%; background: var(--accent-bg); color: var(--accent);
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
/* ── Studio Feed ── */
|
||||
.studio-feed-layout { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||
.studio-feed { flex: 1; overflow-y: auto; padding: 20px 24px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.feed-loading { display: flex; align-items: center; justify-content: center; padding: 60px 0; }
|
||||
.feed-item { display: flex; gap: 10px; padding: 8px 12px; border-radius: var(--radius); animation: fadeIn 0.15s ease-out; }
|
||||
.feed-item:hover { background: var(--bg-card); }
|
||||
.feed-item.user { background: var(--bg-card); border-left: 3px solid #FFD740; }
|
||||
.feed-item.assistant { border-left: 3px solid transparent; }
|
||||
.feed-item.assistant:hover { border-left-color: var(--accent-dark); }
|
||||
.feed-item.system { align-items: center; gap: 8px; padding: 6px 12px; }
|
||||
.feed-avatar { width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; margin-top: 2px; font-size: 14px; }
|
||||
.feed-avatar.user-rank { background: rgba(255, 215, 64, 0.15); }
|
||||
.feed-avatar.ai-rank { background: var(--accent-bg); }
|
||||
.feed-rank-icon { display: flex; align-items: center; justify-content: center; }
|
||||
.feed-body { flex: 1; min-width: 0; }
|
||||
.feed-header { display: flex; align-items: center; gap: 8px; margin-bottom: 2px; }
|
||||
.feed-rank-badge {
|
||||
font-size: 9px; font-weight: 800; font-family: var(--font-mono);
|
||||
padding: 1px 6px; border-radius: 3px; border: 1px solid;
|
||||
letter-spacing: 0.5px; text-transform: uppercase;
|
||||
background: rgba(255, 215, 64, 0.08);
|
||||
}
|
||||
.feed-role { font-size: 11px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.feed-time { font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono); }
|
||||
.feed-content { font-size: 14px; line-height: 1.7; color: var(--text-primary); word-break: break-word; }
|
||||
.feed-system-badge { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-dim); flex-shrink: 0; }
|
||||
.feed-system-text { font-size: 12px; color: var(--text-tertiary); font-style: italic; flex: 1; }
|
||||
|
||||
.feed-thinking-block {
|
||||
background: var(--bg-surface); border: 1px solid var(--border); border-left: 2px solid var(--accent-dim);
|
||||
border-radius: var(--radius); margin: 6px 0 8px; overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.feed-thinking-block.active {
|
||||
border-left-color: var(--warning);
|
||||
}
|
||||
.feed-thinking-block.done {
|
||||
border-left-color: var(--text-disabled);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.feed-thinking-block.done .feed-thinking-content {
|
||||
max-height: 80px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.feed-thinking-header {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 6px 10px; font-size: 10px; font-weight: 700;
|
||||
color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.5px;
|
||||
background: var(--bg-card); border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.feed-thinking-header svg { color: var(--warning); }
|
||||
.feed-thinking-dots { display: inline-flex; gap: 2px; margin-left: 4px; }
|
||||
.feed-thinking-dots span { width: 4px; height: 4px; border-radius: 50%; background: var(--warning); animation: bounce 1.2s ease-in-out infinite; }
|
||||
.feed-thinking-dots span:nth-child(2) { animation-delay: 0.15s; }
|
||||
.feed-thinking-dots span:nth-child(3) { animation-delay: 0.3s; }
|
||||
.feed-thinking-content {
|
||||
padding: 8px 10px; font-size: 12px; color: var(--text-tertiary);
|
||||
font-style: italic; line-height: 1.5; max-height: 120px; overflow-y: auto;
|
||||
}
|
||||
.studio-msg-body { display: flex; flex-direction: column; gap: 0; }
|
||||
.studio-msg-content { font-size: 14px; line-height: 1.7; color: var(--text-primary); word-break: break-word; }
|
||||
|
||||
.studio-code-block {
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
@@ -423,50 +647,19 @@ input::placeholder { color: var(--text-disabled); }
|
||||
background: var(--bg-surface); border-bottom: 1px solid var(--border); text-transform: uppercase; letter-spacing: 0.5px;
|
||||
}
|
||||
.inline-code { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 13px; color: var(--accent-muted); }
|
||||
|
||||
.msg-h3 { font-size: 16px; font-weight: 700; color: var(--text-primary); margin: 16px 0 8px; display: block; }
|
||||
.msg-h4 { font-size: 14px; font-weight: 700; color: var(--text-secondary); margin: 12px 0 6px; display: block; }
|
||||
.msg-bullet { display: block; padding-left: 16px; position: relative; margin: 2px 0; }
|
||||
.msg-bullet::before { content: '\2022'; position: absolute; left: 4px; color: var(--accent); }
|
||||
.msg-step { display: flex; gap: 8px; align-items: baseline; margin: 3px 0; }
|
||||
.msg-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); font-size: 13px; flex-shrink: 0; min-width: 20px; }
|
||||
|
||||
.studio-msg-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||||
|
||||
.studio-plan-chip {
|
||||
display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border-radius: var(--radius);
|
||||
background: var(--bg-card); border: 1px solid var(--border); font-size: 12px; color: var(--text-secondary);
|
||||
cursor: pointer; transition: all 0.15s; user-select: none;
|
||||
}
|
||||
.studio-plan-chip:hover { border-color: var(--accent-dark); background: var(--bg-hover); color: var(--text-primary); }
|
||||
.studio-expand-icon { font-size: 9px; color: var(--text-tertiary); margin-left: 4px; }
|
||||
|
||||
.studio-agent-tag {
|
||||
display: inline-flex; align-items: center; padding: 3px 8px; border-radius: 99px;
|
||||
background: rgba(68,138,255,0.12); color: var(--info); font-size: 11px; font-weight: 600;
|
||||
}
|
||||
|
||||
.studio-plan-detail {
|
||||
margin-top: 8px; border: 1px solid var(--border); border-radius: var(--radius);
|
||||
background: var(--bg-surface); overflow: hidden;
|
||||
}
|
||||
.studio-plan-detail-header { padding: 10px 14px; font-size: 12px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid var(--border); }
|
||||
.studio-steps { display: flex; flex-direction: column; gap: 2px; padding: 8px 0; }
|
||||
.studio-step { display: flex; gap: 10px; align-items: baseline; padding: 4px 14px; }
|
||||
.studio-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); font-size: 13px; flex-shrink: 0; min-width: 24px; }
|
||||
.studio-step-text { font-size: 13px; color: var(--text-secondary); line-height: 1.5; }
|
||||
.studio-plan-raw { padding: 8px 14px 12px; border-top: 1px solid var(--border); }
|
||||
.studio-plan-raw pre { font-family: var(--font-mono); font-size: 12px; color: var(--text-tertiary); white-space: pre-wrap; word-break: break-word; margin: 0; line-height: 1.5; }
|
||||
|
||||
.studio-cursor { display: inline-block; width: 8px; height: 16px; background: var(--accent); margin-left: 2px; vertical-align: text-bottom; animation: blink 0.8s step-end infinite; }
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
|
||||
.studio-thinking { display: flex; gap: 4px; padding: 8px 0; }
|
||||
.studio-thinking span { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-dim); animation: bounce 1.2s ease-in-out infinite; }
|
||||
.studio-thinking span:nth-child(2) { animation-delay: 0.15s; }
|
||||
.studio-thinking span:nth-child(3) { animation-delay: 0.3s; }
|
||||
@keyframes bounce { 0%, 80%, 100% { transform: translateY(0); opacity: 0.4; } 40% { transform: translateY(-6px); opacity: 1; } }
|
||||
|
||||
.studio-input-area { padding: 12px 20px 8px; border-top: 1px solid var(--border); background: var(--bg-surface); }
|
||||
.studio-input-row { display: flex; gap: 8px; align-items: flex-end; }
|
||||
.studio-input-row textarea {
|
||||
@@ -477,7 +670,6 @@ input::placeholder { color: var(--text-disabled); }
|
||||
}
|
||||
.studio-input-row textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--border-accent); }
|
||||
.studio-input-row textarea::placeholder { color: var(--text-disabled); }
|
||||
|
||||
.studio-send-btn {
|
||||
width: 42px; height: 42px; padding: 0; display: flex; align-items: center; justify-content: center;
|
||||
border-radius: var(--radius); background: var(--accent); color: #fff; border: 1px solid var(--accent);
|
||||
@@ -485,54 +677,4 @@ input::placeholder { color: var(--text-disabled); }
|
||||
}
|
||||
.studio-send-btn:hover:not(:disabled) { background: var(--accent-bright); border-color: var(--accent-bright); }
|
||||
.studio-send-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
|
||||
.studio-input-hint { font-size: 11px; color: var(--text-disabled); text-align: center; margin-top: 6px; }
|
||||
|
||||
/* ── Studio Sidebar ── */
|
||||
.studio-sidebar {
|
||||
width: 0; border-left: 1px solid var(--border); background: var(--bg-surface);
|
||||
overflow: hidden; transition: width 0.25s ease; flex-shrink: 0; display: flex; flex-direction: column;
|
||||
}
|
||||
.studio-sidebar.open { width: 300px; }
|
||||
|
||||
.studio-sidebar-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
|
||||
.studio-sidebar-header span { font-size: 13px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.studio-sidebar-toggle { font-size: 18px; padding: 0 6px; line-height: 1; }
|
||||
|
||||
.studio-context { display: flex; flex-direction: column; flex: 1; overflow: hidden; }
|
||||
.studio-context-tabs { display: flex; border-bottom: 1px solid var(--border); flex-shrink: 0; }
|
||||
.studio-context-tab {
|
||||
flex: 1; padding: 9px 8px; font-size: 12px; font-weight: 600; color: var(--text-tertiary);
|
||||
cursor: pointer; text-align: center; transition: all 0.15s; border-bottom: 2px solid transparent; user-select: none;
|
||||
}
|
||||
.studio-context-tab:hover { color: var(--text-primary); background: var(--bg-card); }
|
||||
.studio-context-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
.studio-tab-count { font-size: 10px; padding: 1px 5px; border-radius: 99px; background: var(--bg-card); color: var(--text-tertiary); font-family: var(--font-mono); margin-left: 4px; }
|
||||
|
||||
.studio-context-body { flex: 1; overflow-y: auto; padding: 12px; }
|
||||
|
||||
.studio-plan-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.studio-plan-item {
|
||||
display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: var(--radius);
|
||||
cursor: pointer; transition: all 0.15s; font-size: 13px; color: var(--text-secondary);
|
||||
}
|
||||
.studio-plan-item:hover { background: var(--bg-card); color: var(--text-primary); }
|
||||
.studio-plan-item.active { background: var(--accent-bg); border-left: 2px solid var(--accent); }
|
||||
.studio-plan-item svg { flex-shrink: 0; color: var(--text-tertiary); }
|
||||
.studio-plan-item-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.studio-plan-item-badge { font-size: 11px; color: var(--text-disabled); font-family: var(--font-mono); flex-shrink: 0; }
|
||||
|
||||
.studio-agent-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.studio-agent-item { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: var(--radius); }
|
||||
.studio-agent-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--info); flex-shrink: 0; }
|
||||
.studio-agent-name { font-size: 13px; color: var(--text-secondary); flex: 1; }
|
||||
|
||||
.studio-empty { display: flex; align-items: center; justify-content: center; padding: 32px 16px; color: var(--text-disabled); font-size: 12px; text-align: center; line-height: 1.6; }
|
||||
|
||||
.studio-activity-list { display: flex; flex-direction: column; gap: 2px; }
|
||||
.studio-activity-item { display: flex; gap: 8px; padding: 6px 10px; border-radius: var(--radius); font-size: 12px; }
|
||||
.studio-activity-item:hover { background: var(--bg-card); }
|
||||
.studio-activity-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; margin-top: 5px; }
|
||||
.studio-activity-dot.user { background: var(--accent-muted); }
|
||||
.studio-activity-dot.ai { background: var(--info); }
|
||||
.studio-activity-text { color: var(--text-tertiary); line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
@@ -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