Compare commits
13 Commits
v0.2.1-bet
...
v0.3.1-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8af6d25e28 | ||
|
|
4fd599adec | ||
|
|
bcba5932d5 | ||
|
|
04b0fff791 | ||
|
|
0b221094f2 | ||
|
|
7f674730c7 | ||
|
|
040e482c75 | ||
|
|
c8903efa5e | ||
|
|
f3cb306053 | ||
|
|
3cdcb22068 | ||
|
|
ee18bbeb53 | ||
|
|
b0b0e1d308 | ||
|
|
fc7981037f |
@@ -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
|
||||
|
||||
27
CHANGELOG.md
27
CHANGELOG.md
@@ -6,8 +6,35 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [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")
|
||||
}
|
||||
}
|
||||
|
||||
6
go.mod
6
go.mod
@@ -1,9 +1,13 @@
|
||||
module github.com/muyue/muyue
|
||||
|
||||
go 1.24.3
|
||||
go 1.24.2
|
||||
|
||||
toolchain go1.24.3
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/huh v1.0.0
|
||||
github.com/creack/pty/v2 v2.0.1
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
|
||||
4
go.sum
4
go.sum
@@ -44,10 +44,14 @@ github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/creack/pty/v2 v2.0.1 h1:RDY1VY5b+7m2mfPsugucOYPIxMp+xal5ZheSyVzUA+k=
|
||||
github.com/creack/pty/v2 v2.0.1/go.mod h1:2dSssKp3b86qYEMwA/FPwc3ff+kYpDdQI8osU8J7gxQ=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
|
||||
158
internal/api/conversation.go
Normal file
158
internal/api/conversation.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
)
|
||||
|
||||
const maxTokensApprox = 100000
|
||||
const summarizeThreshold = 80000
|
||||
const charsPerToken = 4
|
||||
|
||||
type FeedMessage struct {
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Time string `json:"time"`
|
||||
}
|
||||
|
||||
type Conversation struct {
|
||||
Messages []FeedMessage `json:"messages"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ConversationStore struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
conv *Conversation
|
||||
}
|
||||
|
||||
func NewConversationStore() *ConversationStore {
|
||||
dir, err := config.ConfigDir()
|
||||
if err != nil {
|
||||
dir = "/tmp/muyue"
|
||||
}
|
||||
path := filepath.Join(dir, "conversation.json")
|
||||
cs := &ConversationStore{path: path}
|
||||
cs.load()
|
||||
return cs
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) load() {
|
||||
data, err := os.ReadFile(cs.path)
|
||||
if err != nil {
|
||||
cs.conv = &Conversation{
|
||||
Messages: []FeedMessage{},
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
return
|
||||
}
|
||||
var conv Conversation
|
||||
if err := json.Unmarshal(data, &conv); err != nil {
|
||||
cs.conv = &Conversation{
|
||||
Messages: []FeedMessage{},
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
return
|
||||
}
|
||||
if conv.Messages == nil {
|
||||
conv.Messages = []FeedMessage{}
|
||||
}
|
||||
cs.conv = &conv
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) save() error {
|
||||
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
data, err := json.MarshalIndent(cs.conv, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir := filepath.Dir(cs.path)
|
||||
os.MkdirAll(dir, 0755)
|
||||
return os.WriteFile(cs.path, data, 0600)
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) Get() []FeedMessage {
|
||||
cs.mu.RLock()
|
||||
defer cs.mu.RUnlock()
|
||||
out := make([]FeedMessage, len(cs.conv.Messages))
|
||||
copy(out, cs.conv.Messages)
|
||||
return out
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) GetSummary() string {
|
||||
cs.mu.RLock()
|
||||
defer cs.mu.RUnlock()
|
||||
return cs.conv.Summary
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) Add(role, content string) FeedMessage {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
|
||||
msg := FeedMessage{
|
||||
ID: generateMsgID(),
|
||||
Role: role,
|
||||
Content: content,
|
||||
Time: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
cs.conv.Messages = append(cs.conv.Messages, msg)
|
||||
cs.save()
|
||||
return msg
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) Clear() {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
cs.conv.Messages = []FeedMessage{}
|
||||
cs.conv.Summary = ""
|
||||
cs.conv.CreatedAt = time.Now().Format(time.RFC3339)
|
||||
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
cs.save()
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) SetSummary(summary string) {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
cs.conv.Summary = summary
|
||||
cs.save()
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) TrimOld(keepCount int) {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
if len(cs.conv.Messages) <= keepCount {
|
||||
return
|
||||
}
|
||||
cs.conv.Messages = cs.conv.Messages[len(cs.conv.Messages)-keepCount:]
|
||||
cs.save()
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) ApproxTokenCount() int {
|
||||
cs.mu.RLock()
|
||||
defer cs.mu.RUnlock()
|
||||
total := utf8.RuneCountInString(cs.conv.Summary)
|
||||
for _, m := range cs.conv.Messages {
|
||||
total += utf8.RuneCountInString(m.Content)
|
||||
}
|
||||
return total / charsPerToken
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) NeedsSummarization() bool {
|
||||
return cs.ApproxTokenCount() > summarizeThreshold
|
||||
}
|
||||
|
||||
func generateMsgID() string {
|
||||
return time.Now().Format("20060102150405.000") + "-" + fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
@@ -1,246 +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/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)
|
||||
}
|
||||
142
internal/api/handlers_chat.go
Normal file
142
internal/api/handlers_chat.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/muyue/muyue/internal/orchestrator"
|
||||
)
|
||||
|
||||
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'orchestrateur IA de Muyue Studio. Tu aides l'utilisateur dans ses tâches de développement logiciel. Tu peux :
|
||||
|- Créer et gérer des plans de développement étape par étape
|
||||
|- Proposer des agents (outils comme Crush, Claude Code, etc.) pour exécuter des tâches spécifiques
|
||||
|- Suivre la progression de tâches multi-étapes
|
||||
|- Suggérer des modifications de fichiers, des revues de code, et des décisions d'architecture
|
||||
|
||||
Sois concis, actionnable, et structuré. Quand tu proposes un plan, utilise des étapes numérotées claires. Quand tu références des fichiers, utilise des chemins relatifs. Tu es intégré dans l'application desktop Muyue.`)
|
||||
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
||||
s.convStore.Add("assistant", result)
|
||||
|
||||
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
|
||||
}
|
||||
s.convStore.Add("assistant", result)
|
||||
writeJSON(w, map[string]string{"content": result})
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
283
internal/api/handlers_config.go
Normal file
283
internal/api/handlers_config.go
Normal file
@@ -0,0 +1,283 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"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})
|
||||
}
|
||||
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),
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
"github.com/muyue/muyue/internal/scanner"
|
||||
@@ -11,6 +12,7 @@ type Server struct {
|
||||
config *config.MuyueConfig
|
||||
scanResult *scanner.ScanResult
|
||||
mux *http.ServeMux
|
||||
convStore *ConversationStore
|
||||
}
|
||||
|
||||
func NewServer(cfg *config.MuyueConfig) *Server {
|
||||
@@ -19,6 +21,7 @@ func NewServer(cfg *config.MuyueConfig) *Server {
|
||||
mux: http.NewServeMux(),
|
||||
}
|
||||
s.scanResult = scanner.ScanSystem()
|
||||
s.convStore = NewConversationStore()
|
||||
s.routes()
|
||||
return s
|
||||
}
|
||||
@@ -37,10 +40,26 @@ func (s *Server) routes() {
|
||||
s.mux.HandleFunc("/api/scan", s.handleScan)
|
||||
s.mux.HandleFunc("/api/preferences", s.handleUpdatePreferences)
|
||||
s.mux.HandleFunc("/api/terminal", s.handleTerminal)
|
||||
s.mux.HandleFunc("/api/ws/terminal", s.handleTerminalWS)
|
||||
s.mux.HandleFunc("/api/terminal/sessions", s.handleTerminalSessions)
|
||||
s.mux.HandleFunc("/api/terminal/sessions/", s.handleTerminalSessionsDelete)
|
||||
s.mux.HandleFunc("/api/terminal/themes", s.handleGetTerminalThemes)
|
||||
s.mux.HandleFunc("/api/terminal/settings", s.handleSaveTerminalSettings)
|
||||
s.mux.HandleFunc("/api/mcp/configure", s.handleMCPConfigure)
|
||||
s.mux.HandleFunc("/api/config/profile", s.handleSaveProfile)
|
||||
s.mux.HandleFunc("/api/config/provider", s.handleSaveProvider)
|
||||
s.mux.HandleFunc("/api/providers/validate", s.handleValidateProvider)
|
||||
s.mux.HandleFunc("/api/update/run", s.handleRunUpdate)
|
||||
s.mux.HandleFunc("/api/chat", s.handleChat)
|
||||
s.mux.HandleFunc("/api/chat/history", s.handleChatHistory)
|
||||
s.mux.HandleFunc("/api/chat/clear", s.handleChatClear)
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/ws/") {
|
||||
s.mux.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")
|
||||
|
||||
341
internal/api/terminal.go
Normal file
341
internal/api/terminal.go
Normal file
@@ -0,0 +1,341 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/creack/pty/v2"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
return true
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(origin, "http://127.0.0.1"),
|
||||
strings.HasPrefix(origin, "http://localhost"),
|
||||
strings.HasPrefix(origin, "http://[::1]"),
|
||||
strings.HasPrefix(origin, "https://127.0.0.1"),
|
||||
strings.HasPrefix(origin, "https://localhost"),
|
||||
strings.HasPrefix(origin, "https://[::1]"):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
type wsMessage struct {
|
||||
Type string `json:"type"`
|
||||
Data string `json:"data"`
|
||||
Rows uint16 `json:"rows,omitempty"`
|
||||
Cols uint16 `json:"cols,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("ws upgrade: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
var initMsg wsMessage
|
||||
_, raw, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
conn.WriteJSON(wsMessage{Type: "error", Data: "failed to read init message"})
|
||||
return
|
||||
}
|
||||
if err := json.Unmarshal(raw, &initMsg); err != nil {
|
||||
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid init message"})
|
||||
return
|
||||
}
|
||||
|
||||
var cmd *exec.Cmd
|
||||
|
||||
if initMsg.Type == "ssh" && initMsg.Data != "" {
|
||||
var sshConf struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
KeyPath string `json:"key_path"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(initMsg.Data), &sshConf); err != nil {
|
||||
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid ssh config"})
|
||||
return
|
||||
}
|
||||
if sshConf.Port == 0 {
|
||||
sshConf.Port = 22
|
||||
}
|
||||
|
||||
sshArgs := []string{
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "UserKnownHostsFile=/dev/null",
|
||||
"-o", "LogLevel=ERROR",
|
||||
}
|
||||
if sshConf.KeyPath != "" {
|
||||
sshArgs = append(sshArgs, "-i", sshConf.KeyPath)
|
||||
}
|
||||
if sshConf.Port != 22 {
|
||||
sshArgs = append(sshArgs, "-p", fmt.Sprintf("%d", sshConf.Port))
|
||||
}
|
||||
sshArgs = append(sshArgs, fmt.Sprintf("%s@%s", sshConf.User, sshConf.Host))
|
||||
|
||||
cmd = exec.Command("ssh", sshArgs...)
|
||||
} else {
|
||||
shell := initMsg.Data
|
||||
if shell == "" {
|
||||
shell = detectShell()
|
||||
} else {
|
||||
if path, err := exec.LookPath(shell); err == nil {
|
||||
shell = path
|
||||
}
|
||||
}
|
||||
|
||||
// Ignore invalid shell paths (e.g., single characters from race condition)
|
||||
if len(shell) <= 1 {
|
||||
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid shell config"})
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := os.Stat(shell); err != nil {
|
||||
conn.WriteJSON(wsMessage{Type: "error", Data: fmt.Sprintf("shell not found: %s", shell)})
|
||||
return
|
||||
}
|
||||
|
||||
shellName := filepath.Base(shell)
|
||||
switch shellName {
|
||||
case "wsl":
|
||||
cmd = exec.Command(shell, "--shell-type", "login")
|
||||
case "powershell", "pwsh":
|
||||
cmd = exec.Command(shell, "-NoLogo", "-NoProfile")
|
||||
case "fish":
|
||||
cmd = exec.Command(shell, "--login")
|
||||
default:
|
||||
cmd = exec.Command(shell)
|
||||
}
|
||||
}
|
||||
|
||||
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
|
||||
|
||||
ptmx, err := pty.Start(cmd)
|
||||
if err != nil {
|
||||
log.Printf("pty start: %v", err)
|
||||
conn.WriteJSON(wsMessage{Type: "error", Data: err.Error()})
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
ptmx.Close()
|
||||
if cmd.Process != nil {
|
||||
cmd.Process.Kill()
|
||||
cmd.Wait()
|
||||
}
|
||||
}()
|
||||
|
||||
var once sync.Once
|
||||
cleanup := func() {
|
||||
once.Do(func() {
|
||||
ptmx.Close()
|
||||
if cmd.Process != nil {
|
||||
cmd.Process.Kill()
|
||||
cmd.Wait()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := ptmx.Read(buf)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
conn.WriteMessage(websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
||||
return
|
||||
}
|
||||
if err := conn.WriteJSON(wsMessage{
|
||||
Type: "output",
|
||||
Data: string(buf[:n]),
|
||||
}); err != nil {
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
conn.SetReadLimit(1 << 20)
|
||||
conn.SetReadDeadline(time.Time{})
|
||||
|
||||
for {
|
||||
_, raw, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
|
||||
var msg wsMessage
|
||||
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case "input":
|
||||
if _, err := ptmx.Write([]byte(msg.Data)); err != nil {
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
case "resize":
|
||||
if msg.Rows > 0 && msg.Cols > 0 {
|
||||
pty.Setsize(ptmx, &pty.Winsize{
|
||||
Rows: msg.Rows,
|
||||
Cols: msg.Cols,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" {
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"ssh": s.config.Terminal.SSH,
|
||||
"system": detectSystemTerminals(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
KeyPath string `json:"key_path"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Name == "" || body.Host == "" {
|
||||
writeError(w, "name and host required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Port == 0 {
|
||||
body.Port = 22
|
||||
}
|
||||
|
||||
conn := config.SSHConnection{
|
||||
Name: body.Name,
|
||||
Host: body.Host,
|
||||
Port: body.Port,
|
||||
User: body.User,
|
||||
KeyPath: body.KeyPath,
|
||||
}
|
||||
if s.config.Terminal.SSH == nil {
|
||||
s.config.Terminal.SSH = []config.SSHConnection{}
|
||||
}
|
||||
s.config.Terminal.SSH = append(s.config.Terminal.SSH, conn)
|
||||
if err := config.Save(s.config); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleTerminalSessionsDelete(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
"github.com/muyue/muyue/internal/scanner"
|
||||
"github.com/muyue/muyue/internal/updater"
|
||||
)
|
||||
|
||||
type Daemon struct {
|
||||
config *config.MuyueConfig
|
||||
interval time.Duration
|
||||
stopCh chan struct{}
|
||||
mu sync.RWMutex
|
||||
running bool
|
||||
lastCheck time.Time
|
||||
lastStatus []updater.UpdateStatus
|
||||
logs []string
|
||||
onUpdate func([]updater.UpdateStatus)
|
||||
}
|
||||
|
||||
func NewDaemon(cfg *config.MuyueConfig, interval time.Duration) *Daemon {
|
||||
if interval == 0 {
|
||||
interval = 1 * time.Hour
|
||||
}
|
||||
return &Daemon{
|
||||
config: cfg,
|
||||
interval: interval,
|
||||
stopCh: make(chan struct{}, 1),
|
||||
logs: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Daemon) OnUpdate(fn func([]updater.UpdateStatus)) {
|
||||
d.onUpdate = fn
|
||||
}
|
||||
|
||||
func (d *Daemon) Start() error {
|
||||
d.mu.Lock()
|
||||
if d.running {
|
||||
d.mu.Unlock()
|
||||
return fmt.Errorf("daemon already running")
|
||||
}
|
||||
d.running = true
|
||||
d.mu.Unlock()
|
||||
|
||||
d.log("daemon started (interval: %s)", d.interval)
|
||||
|
||||
go d.run()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Daemon) Stop() {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if !d.running {
|
||||
return
|
||||
}
|
||||
d.running = false
|
||||
d.stopCh <- struct{}{}
|
||||
d.log("daemon stopped")
|
||||
}
|
||||
|
||||
func (d *Daemon) IsRunning() bool {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
return d.running
|
||||
}
|
||||
|
||||
func (d *Daemon) LastCheck() time.Time {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
return d.lastCheck
|
||||
}
|
||||
|
||||
func (d *Daemon) LastStatus() []updater.UpdateStatus {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
return d.lastStatus
|
||||
}
|
||||
|
||||
func (d *Daemon) Logs() []string {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
return d.logs
|
||||
}
|
||||
|
||||
func (d *Daemon) TriggerCheck() []updater.UpdateStatus {
|
||||
return d.checkUpdates()
|
||||
}
|
||||
|
||||
func (d *Daemon) run() {
|
||||
d.checkUpdates()
|
||||
|
||||
ticker := time.NewTicker(d.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
d.checkUpdates()
|
||||
case <-d.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Daemon) checkUpdates() []updater.UpdateStatus {
|
||||
d.log("checking for updates...")
|
||||
result := scanner.ScanSystem()
|
||||
statuses := updater.CheckUpdates(result)
|
||||
|
||||
needsUpdate := false
|
||||
for _, s := range statuses {
|
||||
if s.NeedsUpdate {
|
||||
needsUpdate = true
|
||||
d.log("update available: %s %s -> %s", s.Tool, s.Current, s.Latest)
|
||||
}
|
||||
}
|
||||
|
||||
if !needsUpdate {
|
||||
d.log("all tools up to date")
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
d.lastCheck = time.Now()
|
||||
d.lastStatus = statuses
|
||||
d.mu.Unlock()
|
||||
|
||||
if d.config.Profile.Preferences.AutoUpdate && needsUpdate {
|
||||
d.log("auto-updating...")
|
||||
results := updater.RunAutoUpdate(statuses)
|
||||
for _, r := range results {
|
||||
if r.Message != "" {
|
||||
d.log(" %s: %s", r.Tool, r.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if d.onUpdate != nil {
|
||||
d.onUpdate(statuses)
|
||||
}
|
||||
|
||||
return statuses
|
||||
}
|
||||
|
||||
func (d *Daemon) log(format string, args ...interface{}) {
|
||||
msg := fmt.Sprintf("[%s] %s", time.Now().Format("15:04:05"), fmt.Sprintf(format, args...))
|
||||
d.mu.Lock()
|
||||
d.logs = append(d.logs, msg)
|
||||
if len(d.logs) > 500 {
|
||||
d.logs = d.logs[250:]
|
||||
}
|
||||
d.mu.Unlock()
|
||||
}
|
||||
|
||||
func RunStandalone(cfg *config.MuyueConfig) {
|
||||
d := NewDaemon(cfg, 1*time.Hour)
|
||||
d.Start()
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
<-sigCh
|
||||
d.Stop()
|
||||
}
|
||||
@@ -26,11 +26,11 @@ type options struct {
|
||||
|
||||
type option func(*options)
|
||||
|
||||
func WithPort(port int) option {
|
||||
func withPort(port int) option {
|
||||
return func(o *options) { o.port = port }
|
||||
}
|
||||
|
||||
func WithNoOpen(noOpen bool) option {
|
||||
func withNoOpen(noOpen bool) option {
|
||||
return func(o *options) { o.noOpen = noOpen }
|
||||
}
|
||||
|
||||
@@ -39,10 +39,10 @@ func parseFlags(args []string) []option {
|
||||
for _, arg := range args {
|
||||
switch {
|
||||
case arg == "--no-open":
|
||||
opts = append(opts, WithNoOpen(true))
|
||||
opts = append(opts, withNoOpen(true))
|
||||
case strings.HasPrefix(arg, "--port="):
|
||||
if p, err := strconv.Atoi(strings.TrimPrefix(arg, "--port=")); err == nil {
|
||||
opts = append(opts, WithPort(p))
|
||||
opts = append(opts, withPort(p))
|
||||
}
|
||||
case arg == "--port":
|
||||
// handled as prefix case
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
@@ -123,7 +124,7 @@ func (i *Installer) installBMAD() InstallResult {
|
||||
return InstallResult{Tool: "bmad", Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
bmadDir := configDir + "/bmad"
|
||||
bmadDir := filepath.Join(configDir, "bmad")
|
||||
os.MkdirAll(bmadDir, 0755)
|
||||
|
||||
cmd := exec.Command("npx", "bmad-method@latest", "install",
|
||||
@@ -175,7 +176,7 @@ func (i *Installer) installGo() InstallResult {
|
||||
}
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
goDir := home + "/.local/go"
|
||||
goDir := filepath.Join(home, ".local", "go")
|
||||
|
||||
cmd := exec.Command("bash", "-c", fmt.Sprintf(
|
||||
"curl -sL https://go.dev/dl/go1.24.3.%s-%s.tar.gz | tar -C %s/.local -xzf -",
|
||||
@@ -290,56 +291,16 @@ func (i *Installer) installGit() InstallResult {
|
||||
return InstallResult{Tool: "git", Success: true, Message: "installed and configured"}
|
||||
}
|
||||
|
||||
func (i *Installer) SetupPrompt() error {
|
||||
starshipPath, err := exec.LookPath("starship")
|
||||
if err != nil {
|
||||
return fmt.Errorf("starship not found")
|
||||
}
|
||||
|
||||
rcFile := i.getRCFile()
|
||||
line := fmt.Sprintf("eval \"$(" + starshipPath + " init %s)\"", i.system.Shell)
|
||||
appendLine(rcFile, line)
|
||||
|
||||
configDir, _ := config.ConfigDir()
|
||||
starshipConfig := `format = """
|
||||
$directory\
|
||||
$git_branch\
|
||||
$git_status\
|
||||
$git_metrics\
|
||||
$nodejs\
|
||||
$python\
|
||||
$golang\
|
||||
$rust\
|
||||
$cmd_duration\
|
||||
$line_break\
|
||||
$character"""
|
||||
|
||||
[character]
|
||||
success_symbol = "[❯](bold green)"
|
||||
error_symbol = "[❯](bold red)"
|
||||
|
||||
[git_branch]
|
||||
format = "[$symbol$branch]($style) "
|
||||
|
||||
[git_status]
|
||||
format = '([$all_status$ahead_behind]($style) )'
|
||||
`
|
||||
configPath := configDir + "/starship.toml"
|
||||
os.MkdirAll(configDir, 0755)
|
||||
os.WriteFile(configPath, []byte(starshipConfig), 0644)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *Installer) getRCFile() string {
|
||||
func (i *Installer) getRCFile() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
switch i.system.Shell {
|
||||
case "zsh":
|
||||
return home + "/.zshrc"
|
||||
return filepath.Join(home, ".zshrc")
|
||||
case "fish":
|
||||
return home + "/.config/fish/config.fish"
|
||||
return filepath.Join(home, ".config", "fish", "config.fish")
|
||||
default:
|
||||
return home + "/.bashrc"
|
||||
return filepath.Join(home, ".bashrc")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,7 +341,7 @@ func (i *Installer) installUv() InstallResult {
|
||||
return InstallResult{Tool: "uv", Success: false, Message: fmt.Sprintf("install failed: %s: %s", err, string(output))}
|
||||
}
|
||||
rcFile := i.getRCFile()
|
||||
appendLine(rcFile, "export PATH="+home+"/.local/bin:$PATH")
|
||||
appendLine(rcFile, "export PATH="+filepath.Join(home, ".local", "bin")+":$PATH")
|
||||
return InstallResult{Tool: "uv", Success: true, Message: "installed (added ~/.local/bin to PATH)"}
|
||||
case platform.Windows:
|
||||
cmd = exec.Command("powershell", "-Command", "irm https://astral.sh/uv/install.ps1 | iex")
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
)
|
||||
|
||||
type LSPServer struct {
|
||||
@@ -15,14 +11,9 @@ type LSPServer struct {
|
||||
Language string `json:"language"`
|
||||
Command string `json:"command"`
|
||||
InstallCmd string `json:"install_cmd"`
|
||||
ConfigFile string `json:"config_file"`
|
||||
Installed bool `json:"installed"`
|
||||
}
|
||||
|
||||
type LSPConfig struct {
|
||||
Servers []LSPServer `json:"servers"`
|
||||
}
|
||||
|
||||
var knownServers = []LSPServer{
|
||||
{Name: "gopls", Language: "go", Command: "gopls", InstallCmd: "go install golang.org/x/tools/gopls@latest"},
|
||||
{Name: "pyright", Language: "python", Command: "pyright", InstallCmd: "npm install -g pyright"},
|
||||
@@ -110,86 +101,3 @@ func InstallForLanguages(languages []string) []LSPServer {
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func GenerateCrushConfig(cfg *config.MuyueConfig) error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("config is nil")
|
||||
}
|
||||
|
||||
configDir, err := config.ConfigDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
type lspEntry struct {
|
||||
Command []string `json:"command"`
|
||||
}
|
||||
|
||||
lspConfig := map[string]lspEntry{}
|
||||
|
||||
for _, lang := range cfg.Profile.Languages {
|
||||
switch lang {
|
||||
case "go":
|
||||
lspConfig["go"] = lspEntry{Command: []string{"gopls"}}
|
||||
case "python":
|
||||
lspConfig["python"] = lspEntry{Command: []string{"pyright-langserver", "--stdio"}}
|
||||
case "typescript", "javascript":
|
||||
lspConfig["typescript"] = lspEntry{Command: []string{"typescript-language-server", "--stdio"}}
|
||||
case "rust":
|
||||
lspConfig["rust"] = lspEntry{Command: []string{"rust-analyzer"}}
|
||||
case "c", "cpp":
|
||||
lspConfig["c"] = lspEntry{Command: []string{"clangd"}}
|
||||
case "lua":
|
||||
lspConfig["lua"] = lspEntry{Command: []string{"lua-language-server"}}
|
||||
}
|
||||
}
|
||||
|
||||
if len(lspConfig) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(lspConfig, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
lspPath := filepath.Join(configDir, "crush.json")
|
||||
existing, err := os.ReadFile(lspPath)
|
||||
if err == nil {
|
||||
var existingConfig map[string]interface{}
|
||||
if unmarshalErr := json.Unmarshal(existing, &existingConfig); unmarshalErr == nil {
|
||||
var newConfig map[string]interface{}
|
||||
if unmarshalErr2 := json.Unmarshal(data, &newConfig); unmarshalErr2 == nil {
|
||||
for k, v := range newConfig {
|
||||
existingConfig[k] = v
|
||||
}
|
||||
data, _ = json.MarshalIndent(existingConfig, "", " ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return os.WriteFile(lspPath, data, 0644)
|
||||
}
|
||||
|
||||
func EnsureCrushConfig(cfg *config.MuyueConfig) error {
|
||||
configDir, _ := config.ConfigDir()
|
||||
crusherPath := filepath.Join(configDir, "crush.json")
|
||||
|
||||
if _, err := os.Stat(crusherPath); err != nil {
|
||||
home, _ := os.UserHomeDir()
|
||||
homeCrush := filepath.Join(home, ".config", "crush", "crush.json")
|
||||
if _, err := os.Stat(homeCrush); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
defaultConfig := map[string]interface{}{
|
||||
"version": "1",
|
||||
}
|
||||
|
||||
data, _ := json.MarshalIndent(defaultConfig, "", " ")
|
||||
os.MkdirAll(filepath.Dir(crusherPath), 0755)
|
||||
return os.WriteFile(crusherPath, data, 0644)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ func ScanServers() []MCPServer {
|
||||
|
||||
func getCoreEntries(homeDir string) []mcpEntry {
|
||||
return []mcpEntry{
|
||||
{"filesystem", "npx", []string{"-y", "@modelcontextprotocol/server-filesystem", homeDir + "/projects"}, nil},
|
||||
{"filesystem", "npx", []string{"-y", "@modelcontextprotocol/server-filesystem", filepath.Join(homeDir, "projects")}, nil},
|
||||
{"fetch", "npx", []string{"-y", "@modelcontextprotocol/server-fetch"}, nil},
|
||||
{"memory", "npx", []string{"-y", "@modelcontextprotocol/server-memory"}, nil},
|
||||
}
|
||||
@@ -86,7 +86,9 @@ func writeMCPConfig(configPath string, mcpKey string, entries []mcpEntry) error
|
||||
existing := map[string]interface{}{}
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err == nil {
|
||||
json.Unmarshal(data, &existing)
|
||||
if err := json.Unmarshal(data, &existing); err != nil {
|
||||
return fmt.Errorf("parse existing config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
mcpMap := map[string]interface{}{}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -12,7 +13,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
"github.com/muyue/muyue/internal/workflow"
|
||||
)
|
||||
|
||||
var thinkRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
|
||||
@@ -35,6 +35,9 @@ type ChatResponse struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"delta"`
|
||||
} `json:"choices"`
|
||||
Usage struct {
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
@@ -42,12 +45,12 @@ type ChatResponse struct {
|
||||
}
|
||||
|
||||
type Orchestrator struct {
|
||||
config *config.MuyueConfig
|
||||
provider *config.AIProvider
|
||||
client *http.Client
|
||||
history []Message
|
||||
histMu sync.Mutex
|
||||
Workflow *workflow.Workflow
|
||||
config *config.MuyueConfig
|
||||
provider *config.AIProvider
|
||||
client *http.Client
|
||||
history []Message
|
||||
histMu sync.Mutex
|
||||
systemPrompt string
|
||||
}
|
||||
|
||||
var sharedHTTPClient = &http.Client{
|
||||
@@ -72,14 +75,17 @@ func New(cfg *config.MuyueConfig) (*Orchestrator, error) {
|
||||
}
|
||||
|
||||
return &Orchestrator{
|
||||
config: cfg,
|
||||
config: cfg,
|
||||
provider: provider,
|
||||
client: sharedHTTPClient,
|
||||
history: []Message{},
|
||||
Workflow: workflow.New(),
|
||||
client: sharedHTTPClient,
|
||||
history: []Message{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (o *Orchestrator) SetSystemPrompt(prompt string) {
|
||||
o.systemPrompt = prompt
|
||||
}
|
||||
|
||||
func (o *Orchestrator) Send(userMessage string) (string, error) {
|
||||
o.histMu.Lock()
|
||||
o.history = append(o.history, Message{
|
||||
@@ -91,9 +97,15 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
|
||||
o.history = o.history[len(o.history)-maxHistorySize:]
|
||||
}
|
||||
|
||||
messages := make([]Message, 0, len(o.history)+1)
|
||||
if o.systemPrompt != "" {
|
||||
messages = append(messages, Message{Role: "system", Content: o.systemPrompt})
|
||||
}
|
||||
messages = append(messages, o.history...)
|
||||
|
||||
reqBody := ChatRequest{
|
||||
Model: o.provider.Model,
|
||||
Messages: o.history,
|
||||
Messages: messages,
|
||||
Stream: false,
|
||||
}
|
||||
o.histMu.Unlock()
|
||||
@@ -153,19 +165,29 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func (o *Orchestrator) StartWorkflow(goal string) (string, error) {
|
||||
o.Workflow.Start(goal)
|
||||
prompt := fmt.Sprintf("I want to: %s\nWhat questions do you need to ask me to fully understand this requirement? Ask ALL questions at once.", goal)
|
||||
o.history = []Message{
|
||||
{Role: "system", Content: workflow.BuildSystemPrompt(workflow.PhaseGathering, o.Workflow.Plan)},
|
||||
{Role: "user", Content: prompt},
|
||||
func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (string, error) {
|
||||
o.histMu.Lock()
|
||||
o.history = append(o.history, Message{
|
||||
Role: "user",
|
||||
Content: userMessage,
|
||||
})
|
||||
|
||||
if len(o.history) > maxHistorySize {
|
||||
o.history = o.history[len(o.history)-maxHistorySize:]
|
||||
}
|
||||
|
||||
messages := make([]Message, 0, len(o.history)+1)
|
||||
if o.systemPrompt != "" {
|
||||
messages = append(messages, Message{Role: "system", Content: o.systemPrompt})
|
||||
}
|
||||
messages = append(messages, o.history...)
|
||||
|
||||
reqBody := ChatRequest{
|
||||
Model: o.provider.Model,
|
||||
Messages: o.history,
|
||||
Stream: false,
|
||||
Messages: messages,
|
||||
Stream: true,
|
||||
}
|
||||
o.histMu.Unlock()
|
||||
|
||||
body, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
@@ -193,116 +215,54 @@ func (o *Orchestrator) StartWorkflow(goal string) (string, error) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var chatResp ChatResponse
|
||||
if err := json.Unmarshal(respBody, &chatResp); err != nil {
|
||||
return "", fmt.Errorf("parse response: %w", err)
|
||||
var fullContent strings.Builder
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
if data == "[DONE]" {
|
||||
break
|
||||
}
|
||||
|
||||
var chatResp ChatResponse
|
||||
if err := json.Unmarshal([]byte(data), &chatResp); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(chatResp.Choices) > 0 {
|
||||
chunk := chatResp.Choices[0].Delta.Content
|
||||
if chunk != "" {
|
||||
fullContent.WriteString(chunk)
|
||||
onChunk(chunk)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(chatResp.Choices) == 0 {
|
||||
return "", fmt.Errorf("no response from AI")
|
||||
if err := scanner.Err(); err != nil {
|
||||
return fullContent.String(), fmt.Errorf("read stream: %w", err)
|
||||
}
|
||||
|
||||
content := cleanAIResponse(chatResp.Choices[0].Message.Content)
|
||||
content := cleanAIResponse(fullContent.String())
|
||||
o.histMu.Lock()
|
||||
o.history = append(o.history, Message{
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
})
|
||||
o.histMu.Unlock()
|
||||
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func (o *Orchestrator) AnswerQuestion(answer string) (string, error) {
|
||||
o.Workflow.AddAnswer(answer)
|
||||
return o.Send(answer)
|
||||
}
|
||||
|
||||
func (o *Orchestrator) GeneratePlan() (string, error) {
|
||||
o.Workflow.Phase = workflow.PhasePlanning
|
||||
o.history = append(o.history, Message{
|
||||
Role: "system",
|
||||
Content: workflow.BuildSystemPrompt(workflow.PhasePlanning, o.Workflow.Plan),
|
||||
})
|
||||
|
||||
prompt := "All questions have been answered. Now create a detailed step-by-step execution plan as a JSON array. Each step should have: id, title, description, agent (crush/claude/muyue)."
|
||||
if len(o.Workflow.Plan.PreviewFiles) > 0 {
|
||||
prompt += "\nInclude visual previews where helpful using the PREVIEW_JSON format."
|
||||
}
|
||||
|
||||
resp, err := o.Send(prompt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
steps, parseErr := workflow.ParsePlanResponse(resp)
|
||||
if parseErr == nil {
|
||||
o.Workflow.SetPlan("")
|
||||
o.Workflow.Plan.Steps = steps
|
||||
o.Workflow.Phase = workflow.PhaseReviewing
|
||||
}
|
||||
|
||||
previewFiles := workflow.ParsePreviewFiles(resp)
|
||||
if len(previewFiles) > 0 {
|
||||
o.Workflow.SetPreviewFiles(previewFiles)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (o *Orchestrator) ReviewPlan(approved bool, feedback string) (string, error) {
|
||||
if approved {
|
||||
o.Workflow.Approve()
|
||||
return o.executeNextStep()
|
||||
}
|
||||
o.Workflow.Reject(feedback)
|
||||
return o.Send(fmt.Sprintf("The plan was rejected. Reason: %s. Please revise the plan.", feedback))
|
||||
}
|
||||
|
||||
func (o *Orchestrator) executeNextStep() (string, error) {
|
||||
step := o.Workflow.CurrentStep()
|
||||
if step == nil {
|
||||
return "All steps completed!", nil
|
||||
}
|
||||
|
||||
o.history = append(o.history, Message{
|
||||
Role: "system",
|
||||
Content: workflow.BuildSystemPrompt(workflow.PhaseExecuting, o.Workflow.Plan),
|
||||
})
|
||||
|
||||
return o.Send(fmt.Sprintf("Execute step %s: %s\n%s", step.ID, step.Title, step.Description))
|
||||
}
|
||||
|
||||
func (o *Orchestrator) ContinueExecution(output string) (string, error) {
|
||||
o.Workflow.AdvanceStep(output)
|
||||
if o.Workflow.Phase == workflow.PhaseDone {
|
||||
return "Workflow completed! All steps have been executed.", nil
|
||||
}
|
||||
return o.executeNextStep()
|
||||
}
|
||||
|
||||
func (o *Orchestrator) History() []Message {
|
||||
o.histMu.Lock()
|
||||
defer o.histMu.Unlock()
|
||||
cp := make([]Message, len(o.history))
|
||||
copy(cp, o.history)
|
||||
return cp
|
||||
}
|
||||
|
||||
func (o *Orchestrator) ClearHistory() {
|
||||
o.histMu.Lock()
|
||||
o.history = []Message{}
|
||||
o.histMu.Unlock()
|
||||
o.Workflow.Reset()
|
||||
}
|
||||
|
||||
func cleanAIResponse(content string) string {
|
||||
content = thinkRegex.ReplaceAllString(content, "")
|
||||
lines := strings.Split(content, "\n")
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
)
|
||||
|
||||
func testConfig() *config.MuyueConfig {
|
||||
cfg := config.Default()
|
||||
cfg.AI.Providers[0].Active = true
|
||||
cfg.AI.Providers[0].APIKey = "test-api-key-12345"
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestCleanAIResponse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -21,7 +17,7 @@ func TestCleanAIResponse(t *testing.T) {
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
"removes standard think tags",
|
||||
"malformed think tags pass through",
|
||||
"<think internal reasoning</think Hello world",
|
||||
"<think internal reasoning</think Hello world",
|
||||
},
|
||||
@@ -31,7 +27,7 @@ func TestCleanAIResponse(t *testing.T) {
|
||||
"response",
|
||||
},
|
||||
{
|
||||
"removes think with attrs",
|
||||
"think with attrs, no closing bracket",
|
||||
"<think type=re>reasoning</think result",
|
||||
"<think type=re>reasoning</think result",
|
||||
},
|
||||
@@ -56,12 +52,12 @@ func TestCleanAIResponse(t *testing.T) {
|
||||
"",
|
||||
},
|
||||
{
|
||||
"removes valid think block",
|
||||
"malformed think block no closing bracket",
|
||||
"<think some reasoning here</think rest",
|
||||
"<think some reasoning here</think rest",
|
||||
},
|
||||
{
|
||||
"removes simple think",
|
||||
"malformed simple think no closing bracket",
|
||||
"before<think reasoning</think after",
|
||||
"before<think reasoning</think after",
|
||||
},
|
||||
@@ -154,57 +150,127 @@ func TestNewNoAPIKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryManagement(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
orch, err := New(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("New failed: %v", err)
|
||||
}
|
||||
|
||||
h := orch.History()
|
||||
if len(h) != 0 {
|
||||
t.Errorf("Expected empty history, got %d", len(h))
|
||||
}
|
||||
|
||||
orch.ClearHistory()
|
||||
h = orch.History()
|
||||
if len(h) != 0 {
|
||||
t.Errorf("Expected 0 after clear, got %d", len(h))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryCopy(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
orch, _ := New(cfg)
|
||||
|
||||
orch.history = []Message{
|
||||
{Role: "user", Content: "hello"},
|
||||
}
|
||||
|
||||
h := orch.History()
|
||||
h[0].Content = "modified"
|
||||
|
||||
orig := orch.History()
|
||||
if orig[0].Content == "modified" {
|
||||
t.Error("History should return a copy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxHistorySize(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
orch, _ := New(cfg)
|
||||
|
||||
for i := 0; i < maxHistorySize+10; i++ {
|
||||
orch.histMu.Lock()
|
||||
orch.history = append(orch.history, Message{Role: "user", Content: "msg"})
|
||||
if len(orch.history) > maxHistorySize {
|
||||
orch.history = orch.history[len(orch.history)-maxHistorySize:]
|
||||
func TestSendStreamChunks(t *testing.T) {
|
||||
sseBody := `data: {"choices":[{"delta":{"content":"Hello"}}]}
|
||||
data: {"choices":[{"delta":{"content":" world"}}]}
|
||||
data: {"choices":[{"delta":{"content":"!"}}]}
|
||||
data: [DONE]
|
||||
`
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer test-key" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
orch.histMu.Unlock()
|
||||
var reqBody ChatRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !reqBody.Stream {
|
||||
http.Error(w, "stream must be true", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Write([]byte(sseBody))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.AI.Providers[0].Active = true
|
||||
cfg.AI.Providers[0].APIKey = "test-key"
|
||||
cfg.AI.Providers[0].BaseURL = ts.URL
|
||||
|
||||
orb, err := New(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
h := orch.History()
|
||||
if len(h) > maxHistorySize {
|
||||
t.Errorf("History should be capped at %d, got %d", maxHistorySize, len(h))
|
||||
var chunks []string
|
||||
result, err := orb.SendStream("hi", func(chunk string) {
|
||||
chunks = append(chunks, chunk)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendStream: %v", err)
|
||||
}
|
||||
if result != "Hello world!" {
|
||||
t.Errorf("SendStream result = %q, want %q", result, "Hello world!")
|
||||
}
|
||||
if len(chunks) != 3 {
|
||||
t.Fatalf("expected 3 chunks, got %d: %v", len(chunks), chunks)
|
||||
}
|
||||
if strings.Join(chunks, "") != "Hello world!" {
|
||||
t.Errorf("chunks joined = %q, want %q", strings.Join(chunks, ""), "Hello world!")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendStreamHistory(t *testing.T) {
|
||||
callCount := 0
|
||||
sseBody := `data: {"choices":[{"delta":{"content":"reply"}}]}
|
||||
data: [DONE]
|
||||
`
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
var reqBody ChatRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if callCount == 1 {
|
||||
if len(reqBody.Messages) != 2 {
|
||||
t.Errorf("first call: expected 2 messages (system + 1 user), got %d", len(reqBody.Messages))
|
||||
}
|
||||
} else {
|
||||
if len(reqBody.Messages) != 4 {
|
||||
t.Errorf("second call: expected 4 messages (system + 3 history), got %d", len(reqBody.Messages))
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Write([]byte(sseBody))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.AI.Providers[0].Active = true
|
||||
cfg.AI.Providers[0].APIKey = "test-key"
|
||||
cfg.AI.Providers[0].BaseURL = ts.URL
|
||||
|
||||
orb, err := New(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
orb.SetSystemPrompt("you are helpful")
|
||||
|
||||
_, _ = orb.SendStream("first", func(string) {})
|
||||
_, _ = orb.SendStream("second", func(string) {})
|
||||
|
||||
orb.histMu.Lock()
|
||||
if len(orb.history) != 4 {
|
||||
t.Errorf("expected 4 history entries (2 user + 2 assistant), got %d", len(orb.history))
|
||||
}
|
||||
orb.histMu.Unlock()
|
||||
}
|
||||
|
||||
func TestSendStreamAPIError(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"error":"rate limited"}`, http.StatusTooManyRequests)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.AI.Providers[0].Active = true
|
||||
cfg.AI.Providers[0].APIKey = "test-key"
|
||||
cfg.AI.Providers[0].BaseURL = ts.URL
|
||||
|
||||
orb, err := New(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
_, err = orb.SendStream("hi", func(string) {})
|
||||
if err == nil {
|
||||
t.Error("expected error for non-200 response")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "429") {
|
||||
t.Errorf("error should mention status code, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -43,16 +44,9 @@ func TestString(t *testing.T) {
|
||||
if s == "" {
|
||||
t.Error("String should not be empty")
|
||||
}
|
||||
if !contains(s, "linux") {
|
||||
if !strings.Contains(s, "linux") {
|
||||
t.Error("Should contain OS")
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
package preview
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PreviewServer struct {
|
||||
dir string
|
||||
server *http.Server
|
||||
}
|
||||
|
||||
func NewPreviewServer(dir string) *PreviewServer {
|
||||
return &PreviewServer{dir: dir}
|
||||
}
|
||||
|
||||
func (p *PreviewServer) Start(port int) error {
|
||||
fs := http.FileServer(http.Dir(p.dir))
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/", fs)
|
||||
|
||||
p.server = &http.Server{
|
||||
Addr: fmt.Sprintf("127.0.0.1:%d", port),
|
||||
Handler: mux,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := p.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
fmt.Printf("Preview server error: %s\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
url := fmt.Sprintf("http://127.0.0.1:%d", port)
|
||||
fmt.Printf("Preview server running at %s\n", url)
|
||||
|
||||
return openBrowser(url)
|
||||
}
|
||||
|
||||
func (p *PreviewServer) Stop() error {
|
||||
if p.server != nil {
|
||||
return p.server.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreatePreviewFile(dir, filename, content string) error {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644)
|
||||
}
|
||||
|
||||
func openBrowser(url string) error {
|
||||
var cmd string
|
||||
var args []string
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
cmd = "xdg-open"
|
||||
args = []string{url}
|
||||
case "darwin":
|
||||
cmd = "open"
|
||||
args = []string{url}
|
||||
case "windows":
|
||||
cmd = "cmd"
|
||||
args = []string{"/c", "start", url}
|
||||
default:
|
||||
return fmt.Errorf("unsupported platform")
|
||||
}
|
||||
|
||||
return exec.Command(cmd, args...).Start()
|
||||
}
|
||||
@@ -279,7 +279,7 @@ func AskAPIKey(providerName string) (string, error) {
|
||||
|
||||
field := huh.NewInput().
|
||||
Title(fmt.Sprintf("Enter your %s API key:", providerName)).
|
||||
Description("The key will be stored locally in ~/.muyue/config.yaml").
|
||||
Description("The key will be stored locally in ~/.config/muyue/config.yaml").
|
||||
EchoMode(huh.EchoModePassword).
|
||||
Value(&apiKey)
|
||||
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AgentType string
|
||||
|
||||
const (
|
||||
AgentCrush AgentType = "crush"
|
||||
AgentClaude AgentType = "claude"
|
||||
)
|
||||
|
||||
type AgentStatus string
|
||||
|
||||
const (
|
||||
StatusIdle AgentStatus = "idle"
|
||||
StatusRunning AgentStatus = "running"
|
||||
StatusStopped AgentStatus = "stopped"
|
||||
StatusError AgentStatus = "error"
|
||||
)
|
||||
|
||||
type LogEntry struct {
|
||||
Timestamp time.Time
|
||||
Agent AgentType
|
||||
Level string
|
||||
Message string
|
||||
}
|
||||
|
||||
type Agent struct {
|
||||
Type AgentType
|
||||
Status AgentStatus
|
||||
cmd *exec.Cmd
|
||||
stdout io.Reader
|
||||
stderr io.Reader
|
||||
cancel context.CancelFunc
|
||||
mu sync.Mutex
|
||||
logs []LogEntry
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
agents map[AgentType]*Agent
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewManager() *Manager {
|
||||
return &Manager{
|
||||
agents: make(map[AgentType]*Agent),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Start(agentType AgentType, args ...string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if a, exists := m.agents[agentType]; exists && a.Status == StatusRunning {
|
||||
return fmt.Errorf("%s already running", agentType)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
var cmdName string
|
||||
switch agentType {
|
||||
case AgentCrush:
|
||||
cmdName = "crush"
|
||||
case AgentClaude:
|
||||
cmdName = "claude"
|
||||
default:
|
||||
cancel()
|
||||
return fmt.Errorf("unknown agent type: %s", agentType)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, cmdName, args...)
|
||||
cmd.Env = os.Environ()
|
||||
|
||||
stdout, pipeErr := cmd.StdoutPipe()
|
||||
if pipeErr != nil {
|
||||
cancel()
|
||||
return fmt.Errorf("stdout pipe: %w", pipeErr)
|
||||
}
|
||||
stderr, pipeErr := cmd.StderrPipe()
|
||||
if pipeErr != nil {
|
||||
cancel()
|
||||
return fmt.Errorf("stderr pipe: %w", pipeErr)
|
||||
}
|
||||
|
||||
agent := &Agent{
|
||||
Type: agentType,
|
||||
Status: StatusRunning,
|
||||
cmd: cmd,
|
||||
stdout: stdout,
|
||||
stderr: stderr,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
m.agents[agentType] = agent
|
||||
|
||||
go agent.captureOutput(stdout, "info")
|
||||
go agent.captureOutput(stderr, "error")
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
agent.Status = StatusError
|
||||
cancel()
|
||||
return fmt.Errorf("start %s: %w", agentType, err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
err := cmd.Wait()
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if err != nil && ctx.Err() == nil {
|
||||
agent.Status = StatusError
|
||||
agent.log("error", fmt.Sprintf("exited with error: %s", err))
|
||||
} else {
|
||||
agent.Status = StatusStopped
|
||||
agent.log("info", "stopped")
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Stop(agentType AgentType) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
agent, exists := m.agents[agentType]
|
||||
if !exists {
|
||||
return fmt.Errorf("%s not found", agentType)
|
||||
}
|
||||
|
||||
if agent.Status != StatusRunning {
|
||||
return fmt.Errorf("%s is not running", agentType)
|
||||
}
|
||||
|
||||
agent.cancel()
|
||||
agent.Status = StatusStopped
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Status(agentType AgentType) (AgentStatus, []LogEntry) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
agent, exists := m.agents[agentType]
|
||||
if !exists {
|
||||
return StatusIdle, nil
|
||||
}
|
||||
|
||||
agent.mu.Lock()
|
||||
defer agent.mu.Unlock()
|
||||
|
||||
return agent.Status, agent.logs
|
||||
}
|
||||
|
||||
func (m *Manager) AllStatus() map[AgentType]AgentStatus {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
statuses := make(map[AgentType]AgentStatus)
|
||||
for t, a := range m.agents {
|
||||
statuses[t] = a.Status
|
||||
}
|
||||
return statuses
|
||||
}
|
||||
|
||||
|
||||
func (a *Agent) captureOutput(reader io.Reader, level string) {
|
||||
scanner := bufio.NewScanner(reader)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
a.mu.Lock()
|
||||
a.logs = append(a.logs, LogEntry{
|
||||
Timestamp: time.Now(),
|
||||
Agent: a.Type,
|
||||
Level: level,
|
||||
Message: line,
|
||||
})
|
||||
if len(a.logs) > 1000 {
|
||||
a.logs = a.logs[500:]
|
||||
}
|
||||
a.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) log(level, msg string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.logs = append(a.logs, LogEntry{
|
||||
Timestamp: time.Now(),
|
||||
Agent: a.Type,
|
||||
Level: level,
|
||||
Message: msg,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) IsAvailable(agentType AgentType) bool {
|
||||
var cmdName string
|
||||
switch agentType {
|
||||
case AgentCrush:
|
||||
cmdName = "crush"
|
||||
case AgentClaude:
|
||||
cmdName = "claude"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
path, err := exec.LookPath(cmdName)
|
||||
return err == nil && path != ""
|
||||
}
|
||||
|
||||
func (m *Manager) GetLogs(agentType AgentType, lastN int) []LogEntry {
|
||||
m.mu.RLock()
|
||||
agent, exists := m.agents[agentType]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
agent.mu.Lock()
|
||||
defer agent.mu.Unlock()
|
||||
|
||||
logs := agent.logs
|
||||
if lastN > 0 && len(logs) > lastN {
|
||||
logs = logs[len(logs)-lastN:]
|
||||
}
|
||||
return logs
|
||||
}
|
||||
|
||||
func FormatLogs(logs []LogEntry) string {
|
||||
var b strings.Builder
|
||||
for _, l := range logs {
|
||||
b.WriteString(fmt.Sprintf("[%s] %s %s: %s\n",
|
||||
l.Timestamp.Format("15:04:05"),
|
||||
l.Agent,
|
||||
l.Level,
|
||||
l.Message,
|
||||
))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -169,7 +170,7 @@ func checkShellSetup() bool {
|
||||
home, _ := os.UserHomeDir()
|
||||
rcFiles := []string{".bashrc", ".zshrc", ".config/fish/config.fish"}
|
||||
for _, f := range rcFiles {
|
||||
data, err := os.ReadFile(home + "/" + f)
|
||||
data, err := os.ReadFile(filepath.Join(home, f))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -24,14 +24,6 @@ type Skill struct {
|
||||
FilePath string `yaml:"-" json:"-"`
|
||||
}
|
||||
|
||||
type Target string
|
||||
|
||||
const (
|
||||
TargetCrush Target = "crush"
|
||||
TargetClaude Target = "claude"
|
||||
TargetBoth Target = "both"
|
||||
)
|
||||
|
||||
func SkillsDir() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
@@ -122,27 +114,6 @@ func Create(skill *Skill) error {
|
||||
return Deploy(skill)
|
||||
}
|
||||
|
||||
func Update(skill *Skill) error {
|
||||
dir, err := SkillsDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
skillDir := filepath.Join(dir, skill.Name)
|
||||
skillPath := filepath.Join(skillDir, "SKILL.md")
|
||||
if _, err := os.Stat(skillPath); err != nil {
|
||||
return fmt.Errorf("skill '%s' not found", skill.Name)
|
||||
}
|
||||
|
||||
skill.UpdatedAt = time.Now()
|
||||
content := renderSkill(skill)
|
||||
if err := os.WriteFile(skillPath, []byte(content), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return Deploy(skill)
|
||||
}
|
||||
|
||||
func Delete(name string) error {
|
||||
dir, err := SkillsDir()
|
||||
if err != nil {
|
||||
@@ -164,7 +135,7 @@ func Deploy(skill *Skill) error {
|
||||
return fmt.Errorf("get home dir: %w", err)
|
||||
}
|
||||
|
||||
if skill.Target == string(TargetCrush) || skill.Target == string(TargetBoth) {
|
||||
if skill.Target == "crush" || skill.Target == "both" {
|
||||
crushSkillsDir := filepath.Join(home, ".config", "crush", "skills")
|
||||
if err := os.MkdirAll(crushSkillsDir, 0755); err != nil {
|
||||
return fmt.Errorf("create crush skills dir: %w", err)
|
||||
@@ -179,7 +150,7 @@ func Deploy(skill *Skill) error {
|
||||
}
|
||||
}
|
||||
|
||||
if skill.Target == string(TargetClaude) || skill.Target == string(TargetBoth) {
|
||||
if skill.Target == "claude" || skill.Target == "both" {
|
||||
claudeSkillsDir := filepath.Join(home, ".claude", "skills")
|
||||
if err := os.MkdirAll(claudeSkillsDir, 0755); err != nil {
|
||||
return fmt.Errorf("create claude skills dir: %w", err)
|
||||
|
||||
@@ -2,17 +2,10 @@ package version
|
||||
|
||||
const (
|
||||
Name = "muyue"
|
||||
Version = "0.2.1"
|
||||
Version = "0.3.1"
|
||||
Author = "La Légion de Muyue"
|
||||
License = "MIT"
|
||||
)
|
||||
|
||||
var Prerelease string
|
||||
|
||||
func FullVersion() string {
|
||||
v := Name + " v" + Version
|
||||
if Prerelease != "" {
|
||||
v += "-" + Prerelease
|
||||
}
|
||||
return v
|
||||
return Name + " v" + Version
|
||||
}
|
||||
|
||||
@@ -15,28 +15,6 @@ func TestFullVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullVersionWithPrerelease(t *testing.T) {
|
||||
original := Prerelease
|
||||
Prerelease = "beta.1"
|
||||
defer func() { Prerelease = original }()
|
||||
|
||||
v := FullVersion()
|
||||
if !strings.Contains(v, "beta.1") {
|
||||
t.Errorf("FullVersion should contain prerelease suffix, got %s", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullVersionWithoutPrerelease(t *testing.T) {
|
||||
original := Prerelease
|
||||
Prerelease = ""
|
||||
defer func() { Prerelease = original }()
|
||||
|
||||
v := FullVersion()
|
||||
if strings.Contains(v, "-") {
|
||||
t.Errorf("FullVersion should not contain prerelease suffix, got %s", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConstants(t *testing.T) {
|
||||
if Name == "" {
|
||||
t.Error("Name should not be empty")
|
||||
@@ -47,7 +25,4 @@ func TestConstants(t *testing.T) {
|
||||
if Author == "" {
|
||||
t.Error("Author should not be empty")
|
||||
}
|
||||
if License == "" {
|
||||
t.Error("License should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Phase string
|
||||
|
||||
const (
|
||||
PhaseIdle Phase = "idle"
|
||||
PhaseGathering Phase = "gathering"
|
||||
PhasePlanning Phase = "planning"
|
||||
PhaseReviewing Phase = "reviewing"
|
||||
PhaseExecuting Phase = "executing"
|
||||
PhaseDone Phase = "done"
|
||||
PhaseError Phase = "error"
|
||||
)
|
||||
|
||||
type Step struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Agent string `json:"agent"`
|
||||
Output string `json:"output,omitempty"`
|
||||
}
|
||||
|
||||
type Plan struct {
|
||||
Goal string `json:"goal"`
|
||||
Context string `json:"context"`
|
||||
Questions []string `json:"questions"`
|
||||
Answers []string `json:"answers"`
|
||||
Steps []Step `json:"steps"`
|
||||
StepIndex int `json:"current_step"`
|
||||
PreviewFiles []PreviewFile `json:"preview_files,omitempty"`
|
||||
}
|
||||
|
||||
type PreviewFile struct {
|
||||
Filename string `json:"filename"`
|
||||
Content string `json:"content"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type Workflow struct {
|
||||
Phase Phase
|
||||
Plan *Plan
|
||||
History []string
|
||||
}
|
||||
|
||||
func New() *Workflow {
|
||||
return &Workflow{
|
||||
Phase: PhaseIdle,
|
||||
Plan: &Plan{},
|
||||
History: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Workflow) Start(goal string) {
|
||||
w.Phase = PhaseGathering
|
||||
w.Plan = &Plan{
|
||||
Goal: goal,
|
||||
Steps: []Step{},
|
||||
Answers: []string{},
|
||||
}
|
||||
w.History = append(w.History, fmt.Sprintf("[started] %s", goal))
|
||||
}
|
||||
|
||||
func (w *Workflow) AddAnswer(answer string) {
|
||||
w.Plan.Answers = append(w.Plan.Answers, answer)
|
||||
if len(w.Plan.Answers) >= len(w.Plan.Questions) {
|
||||
w.Phase = PhasePlanning
|
||||
w.History = append(w.History, "[gathering complete, moving to planning]")
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Workflow) SetPlan(planJSON string) error {
|
||||
var steps []Step
|
||||
if err := json.Unmarshal([]byte(planJSON), &steps); err != nil {
|
||||
if err2 := json.Unmarshal([]byte("["+planJSON+"]"), &steps); err2 != nil {
|
||||
return fmt.Errorf("parse plan: %w", err)
|
||||
}
|
||||
}
|
||||
w.Plan.Steps = steps
|
||||
w.Phase = PhaseReviewing
|
||||
w.History = append(w.History, fmt.Sprintf("[plan created] %d steps", len(steps)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Workflow) SetPreviewFiles(files []PreviewFile) {
|
||||
w.Plan.PreviewFiles = files
|
||||
}
|
||||
|
||||
func (w *Workflow) Approve() {
|
||||
w.Phase = PhaseExecuting
|
||||
w.Plan.StepIndex = 0
|
||||
w.History = append(w.History, "[plan approved, starting execution]")
|
||||
}
|
||||
|
||||
func (w *Workflow) Reject(feedback string) {
|
||||
w.Phase = PhasePlanning
|
||||
w.History = append(w.History, fmt.Sprintf("[plan rejected: %s]", feedback))
|
||||
}
|
||||
|
||||
func (w *Workflow) AdvanceStep(output string) {
|
||||
if w.Plan.StepIndex < len(w.Plan.Steps) {
|
||||
w.Plan.Steps[w.Plan.StepIndex].Status = "done"
|
||||
w.Plan.Steps[w.Plan.StepIndex].Output = output
|
||||
w.Plan.StepIndex++
|
||||
w.History = append(w.History, fmt.Sprintf("[step %d done]", w.Plan.StepIndex))
|
||||
|
||||
if w.Plan.StepIndex >= len(w.Plan.Steps) {
|
||||
w.Phase = PhaseDone
|
||||
w.History = append(w.History, "[all steps complete]")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Workflow) FailStep(errMsg string) {
|
||||
if w.Plan.StepIndex < len(w.Plan.Steps) {
|
||||
w.Plan.Steps[w.Plan.StepIndex].Status = "error"
|
||||
w.Plan.Steps[w.Plan.StepIndex].Output = errMsg
|
||||
w.Phase = PhaseError
|
||||
w.History = append(w.History, fmt.Sprintf("[step %d failed: %s]", w.Plan.StepIndex+1, errMsg))
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Workflow) Reset() {
|
||||
w.Phase = PhaseIdle
|
||||
w.Plan = &Plan{}
|
||||
}
|
||||
|
||||
func (w *Workflow) CurrentStep() *Step {
|
||||
if w.Plan.StepIndex < len(w.Plan.Steps) {
|
||||
return &w.Plan.Steps[w.Plan.StepIndex]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Workflow) Progress() (done, total int) {
|
||||
for _, s := range w.Plan.Steps {
|
||||
if s.Status == "done" {
|
||||
done++
|
||||
}
|
||||
total++
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func BuildSystemPrompt(phase Phase, plan *Plan) string {
|
||||
base := `You are muyue, an AI-powered development environment assistant.
|
||||
You follow a structured workflow: GATHER requirements → PLAN → REVIEW → EXECUTE.
|
||||
|
||||
RULES:
|
||||
- Always respond in the same language the user writes in.
|
||||
- When in GATHERING phase, ask clarifying questions ONE AT A TIME to understand the requirement fully.
|
||||
- When in PLANNING phase, create a detailed step-by-step plan as a JSON array of objects.
|
||||
- When in REVIEWING phase, present the plan clearly and wait for approval.
|
||||
- When in EXECUTING phase, execute one step at a time and report results.
|
||||
- If the user wants a visual preview, generate 1-2 HTML files wrapped in a PREVIEW_JSON block.`
|
||||
|
||||
switch phase {
|
||||
case PhaseGathering:
|
||||
base += fmt.Sprintf(`
|
||||
|
||||
CURRENT PHASE: GATHERING
|
||||
Goal: %s
|
||||
Questions to ask: %v
|
||||
Answers received: %v
|
||||
Remaining questions: %d
|
||||
Ask the NEXT question that hasn't been answered yet. If all questions are answered, say "GATHERING_COMPLETE".`,
|
||||
plan.Goal, plan.Questions, plan.Answers,
|
||||
len(plan.Questions)-len(plan.Answers))
|
||||
|
||||
case PhasePlanning:
|
||||
qa := ""
|
||||
for i, q := range plan.Questions {
|
||||
a := ""
|
||||
if i < len(plan.Answers) {
|
||||
a = plan.Answers[i]
|
||||
}
|
||||
qa += fmt.Sprintf("\nQ: %s\nA: %s", q, a)
|
||||
}
|
||||
base += fmt.Sprintf(`
|
||||
|
||||
CURRENT PHASE: PLANNING
|
||||
Goal: %s
|
||||
%s
|
||||
|
||||
Create a step-by-step plan. Output ONLY a JSON array of steps:
|
||||
[
|
||||
{"id": "1", "title": "...", "description": "...", "agent": "crush|claude|muyue", "status": "pending"},
|
||||
...
|
||||
]
|
||||
|
||||
If the user needs a visual preview, wrap HTML in:
|
||||
<<<PREVIEW_JSON>>>
|
||||
[{"filename":"preview.html","content":"<html>...</html>","type":"html"}]
|
||||
<<<END_PREVIEW>>>`,
|
||||
plan.Goal, qa)
|
||||
|
||||
case PhaseReviewing:
|
||||
steps, _ := json.MarshalIndent(plan.Steps, "", " ")
|
||||
base += fmt.Sprintf(`
|
||||
|
||||
CURRENT PHASE: REVIEWING
|
||||
Present the plan below clearly and ask for approval:
|
||||
%s
|
||||
|
||||
Say "PLAN_APPROVED" if the user approves, or "PLAN_REJECTED: <reason>" if not.`,
|
||||
string(steps))
|
||||
|
||||
case PhaseExecuting:
|
||||
if plan.StepIndex < len(plan.Steps) {
|
||||
step := plan.Steps[plan.StepIndex]
|
||||
base += fmt.Sprintf(`
|
||||
|
||||
CURRENT PHASE: EXECUTING
|
||||
Current step: %s — %s (agent: %s)
|
||||
Execute this step and report the result.`,
|
||||
step.Title, step.Description, step.Agent)
|
||||
}
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
|
||||
func ParsePlanResponse(response string) ([]Step, error) {
|
||||
response = strings.TrimSpace(response)
|
||||
|
||||
start := strings.Index(response, "[")
|
||||
end := strings.LastIndex(response, "]")
|
||||
if start == -1 || end == -1 || end <= start {
|
||||
return nil, fmt.Errorf("no JSON array found in response")
|
||||
}
|
||||
|
||||
jsonStr := response[start : end+1]
|
||||
var steps []Step
|
||||
if err := json.Unmarshal([]byte(jsonStr), &steps); err != nil {
|
||||
return nil, fmt.Errorf("parse steps: %w", err)
|
||||
}
|
||||
|
||||
for i := range steps {
|
||||
steps[i].Status = "pending"
|
||||
}
|
||||
|
||||
return steps, nil
|
||||
}
|
||||
|
||||
func ParsePreviewFiles(response string) []PreviewFile {
|
||||
startMarker := "<<<PREVIEW_JSON>>>"
|
||||
endMarker := "<<<END_PREVIEW>>>"
|
||||
start := strings.Index(response, startMarker)
|
||||
end := strings.Index(response, endMarker)
|
||||
if start == -1 || end == -1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
jsonStr := strings.TrimSpace(response[start+len(startMarker) : end])
|
||||
var files []PreviewFile
|
||||
if err := json.Unmarshal([]byte(jsonStr), &files); err != nil {
|
||||
return nil
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func ParseApproval(response string) (approved bool, feedback string) {
|
||||
lower := strings.ToLower(strings.TrimSpace(response))
|
||||
if strings.Contains(lower, "plan_approved") || strings.Contains(lower, "approved") || strings.Contains(lower, "yes") || strings.Contains(lower, "go ahead") || strings.Contains(lower, "oui") || strings.Contains(lower, "ok") {
|
||||
return true, ""
|
||||
}
|
||||
if strings.Contains(lower, "plan_rejected:") {
|
||||
parts := strings.SplitN(lower, "plan_rejected:", 2)
|
||||
if len(parts) > 1 {
|
||||
return false, strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
return false, response
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
wf := New()
|
||||
if wf.Phase != PhaseIdle {
|
||||
t.Errorf("Expected PhaseIdle, got %s", wf.Phase)
|
||||
}
|
||||
if wf.Plan == nil {
|
||||
t.Error("Plan should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStart(t *testing.T) {
|
||||
wf := New()
|
||||
wf.Start("Build a REST API")
|
||||
if wf.Phase != PhaseGathering {
|
||||
t.Errorf("Expected PhaseGathering, got %s", wf.Phase)
|
||||
}
|
||||
if wf.Plan.Goal != "Build a REST API" {
|
||||
t.Errorf("Expected goal 'Build a REST API', got %s", wf.Plan.Goal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddAnswer(t *testing.T) {
|
||||
wf := New()
|
||||
wf.Start("test goal")
|
||||
wf.Plan.Questions = []string{"Q1?", "Q2?"}
|
||||
|
||||
wf.AddAnswer("A1")
|
||||
if wf.Phase != PhaseGathering {
|
||||
t.Errorf("Should still be gathering, got %s", wf.Phase)
|
||||
}
|
||||
|
||||
wf.AddAnswer("A2")
|
||||
if wf.Phase != PhasePlanning {
|
||||
t.Errorf("Should move to planning, got %s", wf.Phase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPlan(t *testing.T) {
|
||||
wf := New()
|
||||
planJSON := `[{"id":"1","title":"Step 1","description":"Do something","agent":"crush","status":"pending"}]`
|
||||
err := wf.SetPlan(planJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("SetPlan failed: %v", err)
|
||||
}
|
||||
if len(wf.Plan.Steps) != 1 {
|
||||
t.Errorf("Expected 1 step, got %d", len(wf.Plan.Steps))
|
||||
}
|
||||
if wf.Phase != PhaseReviewing {
|
||||
t.Errorf("Expected PhaseReviewing, got %s", wf.Phase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprove(t *testing.T) {
|
||||
wf := New()
|
||||
wf.Start("test")
|
||||
wf.Plan.Steps = []Step{{ID: "1", Title: "Step 1", Status: "pending"}}
|
||||
wf.Phase = PhaseReviewing
|
||||
wf.Approve()
|
||||
if wf.Phase != PhaseExecuting {
|
||||
t.Errorf("Expected PhaseExecuting, got %s", wf.Phase)
|
||||
}
|
||||
if wf.Plan.StepIndex != 0 {
|
||||
t.Errorf("Expected step index 0, got %d", wf.Plan.StepIndex)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReject(t *testing.T) {
|
||||
wf := New()
|
||||
wf.Phase = PhaseReviewing
|
||||
wf.Reject("too complex")
|
||||
if wf.Phase != PhasePlanning {
|
||||
t.Errorf("Expected PhasePlanning, got %s", wf.Phase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvanceStep(t *testing.T) {
|
||||
wf := New()
|
||||
wf.Plan.Steps = []Step{
|
||||
{ID: "1", Title: "Step 1", Status: "pending"},
|
||||
{ID: "2", Title: "Step 2", Status: "pending"},
|
||||
}
|
||||
wf.Phase = PhaseExecuting
|
||||
|
||||
wf.AdvanceStep("output1")
|
||||
if wf.Plan.Steps[0].Status != "done" {
|
||||
t.Error("First step should be done")
|
||||
}
|
||||
if wf.Plan.StepIndex != 1 {
|
||||
t.Errorf("Expected step index 1, got %d", wf.Plan.StepIndex)
|
||||
}
|
||||
if wf.Phase != PhaseExecuting {
|
||||
t.Errorf("Should still be executing, got %s", wf.Phase)
|
||||
}
|
||||
|
||||
wf.AdvanceStep("output2")
|
||||
if wf.Phase != PhaseDone {
|
||||
t.Errorf("Expected PhaseDone, got %s", wf.Phase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailStep(t *testing.T) {
|
||||
wf := New()
|
||||
wf.Plan.Steps = []Step{{ID: "1", Title: "Step 1"}}
|
||||
wf.Phase = PhaseExecuting
|
||||
|
||||
wf.FailStep("something broke")
|
||||
if wf.Phase != PhaseError {
|
||||
t.Errorf("Expected PhaseError, got %s", wf.Phase)
|
||||
}
|
||||
if wf.Plan.Steps[0].Status != "error" {
|
||||
t.Error("Step should have error status")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReset(t *testing.T) {
|
||||
wf := New()
|
||||
wf.Start("test")
|
||||
wf.Phase = PhaseExecuting
|
||||
wf.Reset()
|
||||
if wf.Phase != PhaseIdle {
|
||||
t.Errorf("Expected PhaseIdle, got %s", wf.Phase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentStep(t *testing.T) {
|
||||
wf := New()
|
||||
if wf.CurrentStep() != nil {
|
||||
t.Error("Should be nil with no steps")
|
||||
}
|
||||
|
||||
wf.Plan.Steps = []Step{{ID: "1"}, {ID: "2"}}
|
||||
wf.Plan.StepIndex = 0
|
||||
step := wf.CurrentStep()
|
||||
if step == nil || step.ID != "1" {
|
||||
t.Error("Should return first step")
|
||||
}
|
||||
|
||||
wf.Plan.StepIndex = 2
|
||||
if wf.CurrentStep() != nil {
|
||||
t.Error("Should be nil when past all steps")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgress(t *testing.T) {
|
||||
wf := New()
|
||||
wf.Plan.Steps = []Step{
|
||||
{ID: "1", Status: "done"},
|
||||
{ID: "2", Status: "pending"},
|
||||
{ID: "3", Status: "done"},
|
||||
}
|
||||
done, total := wf.Progress()
|
||||
if done != 2 || total != 3 {
|
||||
t.Errorf("Expected 2/3, got %d/%d", done, total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePlanResponse(t *testing.T) {
|
||||
resp := `Here is the plan:
|
||||
[
|
||||
{"id": "1", "title": "Setup", "description": "Init project", "agent": "crush"},
|
||||
{"id": "2", "title": "Build", "description": "Write code", "agent": "claude"}
|
||||
]`
|
||||
steps, err := ParsePlanResponse(resp)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePlanResponse failed: %v", err)
|
||||
}
|
||||
if len(steps) != 2 {
|
||||
t.Errorf("Expected 2 steps, got %d", len(steps))
|
||||
}
|
||||
if steps[0].ID != "1" {
|
||||
t.Errorf("Expected step ID 1, got %s", steps[0].ID)
|
||||
}
|
||||
for _, s := range steps {
|
||||
if s.Status != "pending" {
|
||||
t.Errorf("Steps should be pending, got %s", s.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePlanResponseInvalid(t *testing.T) {
|
||||
_, err := ParsePlanResponse("no json here")
|
||||
if err == nil {
|
||||
t.Error("Should fail with no JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseApproval(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
approved bool
|
||||
}{
|
||||
{"plan_approved", true},
|
||||
{"approved", true},
|
||||
{"yes", true},
|
||||
{"ok", true},
|
||||
{"oui", true},
|
||||
{"go ahead", true},
|
||||
{"no", false},
|
||||
{"plan_rejected: too complex", false},
|
||||
{"I don't like it", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
approved, feedback := ParseApproval(tt.input)
|
||||
if approved != tt.approved {
|
||||
t.Errorf("ParseApproval(%q) = %v, want %v", tt.input, approved, tt.approved)
|
||||
}
|
||||
if !approved && tt.input == "plan_rejected: too complex" {
|
||||
if feedback != "too complex" {
|
||||
t.Errorf("Expected feedback 'too complex', got %s", feedback)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePreviewFiles(t *testing.T) {
|
||||
resp := `Some text
|
||||
<<<PREVIEW_JSON>>>
|
||||
[{"filename":"test.html","content":"<h1>Hello</h1>","type":"html"}]
|
||||
<<<END_PREVIEW>>>`
|
||||
files := ParsePreviewFiles(resp)
|
||||
if len(files) != 1 {
|
||||
t.Fatalf("Expected 1 file, got %d", len(files))
|
||||
}
|
||||
if files[0].Filename != "test.html" {
|
||||
t.Errorf("Expected test.html, got %s", files[0].Filename)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePreviewFilesNone(t *testing.T) {
|
||||
files := ParsePreviewFiles("no preview here")
|
||||
if files != nil {
|
||||
t.Error("Should return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPrompt(t *testing.T) {
|
||||
prompt := BuildSystemPrompt(PhaseIdle, &Plan{})
|
||||
if prompt == "" {
|
||||
t.Error("Prompt should not be empty")
|
||||
}
|
||||
if len(prompt) < 100 {
|
||||
t.Error("Prompt seems too short")
|
||||
}
|
||||
|
||||
prompt = BuildSystemPrompt(PhaseGathering, &Plan{Goal: "test"})
|
||||
if prompt == "" {
|
||||
t.Error("Gathering prompt should not be empty")
|
||||
}
|
||||
}
|
||||
34
web/package-lock.json
generated
34
web/package-lock.json
generated
@@ -6,6 +6,10 @@
|
||||
"": {
|
||||
"name": "muyue-web",
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5"
|
||||
},
|
||||
@@ -396,6 +400,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-web-links": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
|
||||
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -712,6 +737,15 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz",
|
||||
"integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5"
|
||||
},
|
||||
|
||||
@@ -2,8 +2,8 @@ const API_BASE = '/api'
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||
@@ -26,7 +26,57 @@ 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 }) }),
|
||||
getTerminalSessions: () => request('/terminal/sessions'),
|
||||
addSSHConnection: (conn) => request('/terminal/sessions', { method: 'POST', body: JSON.stringify(conn) }),
|
||||
deleteSSHConnection: (name) => request(`/terminal/sessions/${encodeURIComponent(name)}`, { method: 'DELETE' }),
|
||||
getTerminalThemes: () => request('/terminal/themes'),
|
||||
saveTerminalSettings: (settings) => request('/terminal/settings', { method: 'PUT', body: JSON.stringify(settings) }),
|
||||
getChatHistory: () => request('/chat/history'),
|
||||
clearChat: () => request('/chat/clear', { method: 'POST' }),
|
||||
sendChat: (message, stream = true, onChunk) => {
|
||||
if (!stream) {
|
||||
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) })
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
fetch(`${API_BASE}/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message, stream: true }),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||
reject(new Error(err.error || res.statusText))
|
||||
return
|
||||
}
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let full = ''
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
const text = decoder.decode(value, { stream: true })
|
||||
for (const line of text.split('\n')) {
|
||||
if (!line.startsWith('data: ')) continue
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6))
|
||||
if (data.error) { reject(new Error(data.error)); return }
|
||||
if (data.done) { resolve(full); return }
|
||||
if (data.content) {
|
||||
full += data.content
|
||||
if (onChunk) onChunk(full)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
resolve(full)
|
||||
}).catch(reject)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default api
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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'
|
||||
@@ -13,20 +14,27 @@ export default function App() {
|
||||
const [clock, setClock] = useState(new Date())
|
||||
const [updates, setUpdates] = useState([])
|
||||
const [tools, setTools] = useState([])
|
||||
const [config, setConfig] = useState(null)
|
||||
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))
|
||||
}).catch(() => {
|
||||
applyTheme(getTheme('cyberpunk-red'))
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -56,7 +64,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 +87,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,58 +1,425 @@
|
||||
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: 'terminal', icon: Monitor },
|
||||
{ id: 'updates', icon: RefreshCw },
|
||||
{ id: 'locale', icon: Globe },
|
||||
{ id: 'skills', icon: Wrench },
|
||||
]
|
||||
|
||||
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({})
|
||||
const [toast, setToast] = useState(null)
|
||||
const [terminalThemes, setTerminalThemes] = useState([])
|
||||
const [terminalSettings, setTerminalSettings] = useState({ font_size: 14, font_family: '', theme: 'default' })
|
||||
const [savingTerminal, setSavingTerminal] = useState(false)
|
||||
|
||||
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 || '',
|
||||
})
|
||||
if (d.terminal) {
|
||||
setTerminalSettings({
|
||||
font_size: d.terminal.font_size || 14,
|
||||
font_family: d.terminal.font_family || '',
|
||||
theme: d.terminal.theme || 'default',
|
||||
})
|
||||
}
|
||||
}).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.getTerminalThemes().then(d => setTerminalThemes(d.themes || [])).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 () => {
|
||||
try {
|
||||
await api.saveProvider(providerForm)
|
||||
setEditProvider(null)
|
||||
loadData()
|
||||
showToast(t('config.saved'))
|
||||
} catch (err) {
|
||||
showToast(`${t('config.error')}: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveTerminalSettings = async () => {
|
||||
setSavingTerminal(true)
|
||||
try {
|
||||
await api.saveTerminalSettings(terminalSettings)
|
||||
showToast(t('config.saved'))
|
||||
window.location.reload()
|
||||
} catch (err) {
|
||||
showToast(`${t('config.error')}: ${err.message}`)
|
||||
}
|
||||
setSavingTerminal(false)
|
||||
}
|
||||
|
||||
const openProviderEdit = (p) => {
|
||||
setProviderForm({
|
||||
name: p.name,
|
||||
api_key: p.apiKey || '',
|
||||
model: p.model || '',
|
||||
base_url: p.baseURL || '',
|
||||
})
|
||||
setEditProvider(p.name)
|
||||
}
|
||||
|
||||
const needsUpdateCount = updates.filter(u => u.needsUpdate).length
|
||||
const installedCount = tools.filter(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 === 'terminal' && (
|
||||
<PanelTerminal
|
||||
settings={terminalSettings} setSettings={setTerminalSettings}
|
||||
themes={terminalThemes} saving={savingTerminal}
|
||||
onSave={handleSaveTerminalSettings} 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.api_key : ''}
|
||||
onChange={e => {
|
||||
if (!isEditing) openProviderEdit(p)
|
||||
setProviderForm(f => ({ ...f, api_key: e.target.value }))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="provider-setup-token-actions">
|
||||
<button
|
||||
className="sm primary"
|
||||
disabled={validating === p.name || !providerForm.api_key}
|
||||
onClick={() => handleValidate(p.name, providerForm.api_key, providerForm.model, providerForm.base_url)}
|
||||
>
|
||||
{validating === p.name ? t('config.validating') : t('config.validateKey')}
|
||||
</button>
|
||||
{isValidationTarget && validationStatus?.valid && (
|
||||
<button className="sm" onClick={handleSaveProvider}>{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 +431,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 +445,137 @@ 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>
|
||||
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>
|
||||
) : (
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
<div className="config-section">
|
||||
<div className="config-section-title">{t('config.theme')}</div>
|
||||
<div className="theme-picker">
|
||||
function PanelTerminal({ settings, setSettings, themes, saving, onSave, t }) {
|
||||
const previewTheme = {
|
||||
background: settings.theme === 'default' ? '#0A0A0C' :
|
||||
settings.theme === 'monokai' ? '#272822' :
|
||||
settings.theme === 'gruvbox' ? '#282828' :
|
||||
settings.theme === 'nord' ? '#2E3440' :
|
||||
settings.theme === 'solarized-dark' ? '#002B36' :
|
||||
settings.theme === 'dracula' ? '#282A36' : '#0A0A0C',
|
||||
foreground: settings.theme === 'default' ? '#EAE0E2' :
|
||||
settings.theme === 'monokai' ? '#F8F8F2' :
|
||||
settings.theme === 'gruvbox' ? '#EBDBB2' :
|
||||
settings.theme === 'nord' ? '#D8DEE9' :
|
||||
settings.theme === 'solarized-dark' ? '#839496' :
|
||||
settings.theme === 'dracula' ? '#F8F8F2' : '#EAE0E2',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="config-card">
|
||||
<div className="config-card-group">
|
||||
<span className="config-card-group-label">{t('config.terminalTheme')}</span>
|
||||
<div className="chip-row">
|
||||
{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}
|
||||
/>
|
||||
className={`chip ${settings.theme === th.id ? 'active' : ''}`}
|
||||
onClick={() => setSettings(s => ({ ...s, theme: th.id }))}
|
||||
>
|
||||
{th.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="config-section">
|
||||
<div className="config-section-title">{t('config.skills')} ({skillList.length})</div>
|
||||
{skillList.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
{t('config.noSkills')}
|
||||
<span style={{ fontFamily: 'var(--font-mono)' }}>{t('config.runSkillsInit')}</span>
|
||||
</div>
|
||||
) : (
|
||||
skillList.map((s, i) => (
|
||||
<div key={i} className="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 className="config-card-group">
|
||||
<span className="config-card-group-label">{t('config.fontSize')}</span>
|
||||
<div className="chip-row">
|
||||
{[12, 14, 16, 18, 20, 24].map(size => (
|
||||
<div
|
||||
key={size}
|
||||
className={`chip ${settings.font_size === size ? 'active' : ''}`}
|
||||
onClick={() => setSettings(s => ({ ...s, font_size: size }))}
|
||||
>
|
||||
{size}px
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="config-card-group">
|
||||
<span className="config-card-group-label">{t('config.fontFamily')}</span>
|
||||
<select
|
||||
className="config-form-input"
|
||||
value={settings.font_family}
|
||||
onChange={e => setSettings(s => ({ ...s, font_family: e.target.value }))}
|
||||
style={{ maxWidth: 300 }}
|
||||
>
|
||||
<option value="">Default (JetBrains Mono)</option>
|
||||
<option value="'Fira Code', monospace">Fira Code</option>
|
||||
<option value="'Cascadia Code', 'SF Mono', monospace">Cascadia Code</option>
|
||||
<option value="'SF Mono', 'Menlo', monospace">SF Mono</option>
|
||||
<option value="'Source Code Pro', monospace">Source Code Pro</option>
|
||||
<option value="monospace">System Monospace</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="config-card-group">
|
||||
<span className="config-card-group-label">{t('config.preview')}</span>
|
||||
<div style={{
|
||||
background: previewTheme.background,
|
||||
color: previewTheme.foreground,
|
||||
padding: '16px 20px',
|
||||
borderRadius: 'var(--radius)',
|
||||
fontFamily: settings.font_family || "'JetBrains Mono', monospace",
|
||||
fontSize: settings.font_size || 14,
|
||||
border: '1px solid var(--border)',
|
||||
}}>
|
||||
<span style={{ color: '#00E676' }}>➜</span> <span>~/projects</span>
|
||||
<span style={{ color: '#448AFF' }}> git status</span>
|
||||
<br />
|
||||
<span>On branch </span>
|
||||
<span style={{ color: '#FFD740' }}>main</span>
|
||||
<br />
|
||||
<span style={{ opacity: 0.6 }}>Type a command...</span>
|
||||
<span style={{ animation: 'blink 1s step-end infinite' }}> ▋</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="config-card-actions" style={{ marginTop: 16 }}>
|
||||
<button className="primary sm" onClick={onSave} disabled={saving}>
|
||||
{saving ? t('config.saving') : t('config.save')}
|
||||
</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)
|
||||
}
|
||||
|
||||
@@ -1,63 +1,374 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { Terminal as XTerm } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2 } from 'lucide-react'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
const MAX_TABS = 7
|
||||
|
||||
const THEMES = {
|
||||
default: {
|
||||
background: '#0A0A0C', foreground: '#EAE0E2', cursor: '#FF0033',
|
||||
cursorAccent: '#0A0A0C', selectionBackground: '#FF003344', selectionForeground: '#ffffff',
|
||||
black: '#0A0A0C', red: '#FF0033', green: '#00E676', yellow: '#FFD740',
|
||||
blue: '#448AFF', magenta: '#FF1A5E', cyan: '#00BCD4', white: '#EAE0E2',
|
||||
brightBlack: '#5A4F52', brightRed: '#FF5252', brightGreen: '#69F0AE',
|
||||
brightYellow: '#FFFF00', brightBlue: '#82B1FF', brightMagenta: '#FF80AB',
|
||||
brightCyan: '#84FFFF', brightWhite: '#FFFFFF',
|
||||
},
|
||||
monokai: {
|
||||
background: '#272822', foreground: '#F8F8F2', cursor: '#F8F8F0',
|
||||
cursorAccent: '#272822', selectionBackground: '#F8F8F244', selectionForeground: '#ffffff',
|
||||
black: '#272822', red: '#F92672', green: '#A6E22E', yellow: '#E6DB74',
|
||||
blue: '#66D9EF', magenta: '#AE81FF', cyan: '#A1EFE4', white: '#F8F8F2',
|
||||
brightBlack: '#75715E', brightRed: '#F92672', brightGreen: '#A6E22E',
|
||||
brightYellow: '#E6DB74', brightBlue: '#66D9EF', brightMagenta: '#AE81FF',
|
||||
brightCyan: '#A1EFE4', brightWhite: '#F8F8F2',
|
||||
},
|
||||
gruvbox: {
|
||||
background: '#282828', foreground: '#EBDBB2', cursor: '#FB4934',
|
||||
cursorAccent: '#282828', selectionBackground: '#EBDBB244', selectionForeground: '#ffffff',
|
||||
black: '#282828', red: '#CC241D', green: '#98971A', yellow: '#D79921',
|
||||
blue: '#458588', magenta: '#B16286', cyan: '#689D6A', white: '#EBDBB2',
|
||||
brightBlack: '#928374', brightRed: '#FB4934', brightGreen: '#B8BB26',
|
||||
brightYellow: '#FABC2A', brightBlue: '#83A598', brightMagenta: '#D3869B',
|
||||
brightCyan: '#8EC07C', brightWhite: '#EBDBB2',
|
||||
},
|
||||
nord: {
|
||||
background: '#2E3440', foreground: '#D8DEE9', cursor: '#D8DEE9',
|
||||
cursorAccent: '#2E3440', selectionBackground: '#D8DEE944', selectionForeground: '#ffffff',
|
||||
black: '#2E3440', red: '#BF616A', green: '#A3BE8C', yellow: '#EBCB8B',
|
||||
blue: '#81A1C1', magenta: '#B48EAD', cyan: '#88C0D0', white: '#D8DEE9',
|
||||
brightBlack: '#4C566A', brightRed: '#BF616A', brightGreen: '#A3BE8C',
|
||||
brightYellow: '#EBCB8B', brightBlue: '#81A1C1', brightMagenta: '#B48EAD',
|
||||
brightCyan: '#8FBCBB', brightWhite: '#ECEFF4',
|
||||
},
|
||||
'solarized-dark': {
|
||||
background: '#002B36', foreground: '#839496', cursor: '#D33682',
|
||||
cursorAccent: '#002B36', selectionBackground: '#83949644', selectionForeground: '#ffffff',
|
||||
black: '#002B36', red: '#DC322F', green: '#859900', yellow: '#B58900',
|
||||
blue: '#268BD2', magenta: '#D33682', cyan: '#2AA198', white: '#FDF6E3',
|
||||
brightBlack: '#073642', brightRed: '#CB4B16', brightGreen: '#586E75',
|
||||
brightYellow: '#657B83', brightBlue: '#6C71C4', brightMagenta: '#6C71C4',
|
||||
brightCyan: '#93A1A1', brightWhite: '#FDF6E3',
|
||||
},
|
||||
dracula: {
|
||||
background: '#282A36', foreground: '#F8F8F2', cursor: '#F8F8F2',
|
||||
cursorAccent: '#282A36', selectionBackground: '#F8F8F244', selectionForeground: '#ffffff',
|
||||
black: '#282A36', red: '#FF5555', green: '#50FA7B', yellow: '#F1FA8C',
|
||||
blue: '#BD93F9', magenta: '#FF79C6', cyan: '#8BE9FD', white: '#F8F8F2',
|
||||
brightBlack: '#6272A4', brightRed: '#FF6E6E', brightGreen: '#69FF94',
|
||||
brightYellow: '#FFFFA5', brightBlue: '#D6ACFF', brightMagenta: '#FF92DF',
|
||||
brightCyan: '#A4FFFF', brightWhite: '#FFFFFF',
|
||||
},
|
||||
}
|
||||
|
||||
function getTheme(themeName) {
|
||||
return THEMES[themeName] || THEMES.default
|
||||
}
|
||||
|
||||
function createTerminal(container, settings = {}) {
|
||||
const theme = getTheme(settings.theme || 'default')
|
||||
const term = new XTerm({
|
||||
cursorBlink: true,
|
||||
fontSize: settings.fontSize || 14,
|
||||
fontFamily: settings.fontFamily || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
||||
theme,
|
||||
allowTransparency: false,
|
||||
scrollback: 5000,
|
||||
})
|
||||
|
||||
const fitAddon = new FitAddon()
|
||||
const webLinksAddon = new WebLinksAddon()
|
||||
term.loadAddon(fitAddon)
|
||||
term.loadAddon(webLinksAddon)
|
||||
term.open(container)
|
||||
fitAddon.fit()
|
||||
|
||||
return { term, fitAddon }
|
||||
}
|
||||
|
||||
function connectWebSocket(term, fitAddon, initPayload) {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const ws = new WebSocket(`${proto}//${window.location.host}/api/ws/terminal`)
|
||||
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify(initPayload))
|
||||
const dims = fitAddon.proposeDimensions()
|
||||
if (dims) {
|
||||
ws.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols }))
|
||||
}
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === 'output') {
|
||||
term.write(msg.data)
|
||||
} else if (msg.type === 'error') {
|
||||
term.write(`\r\n\x1b[31mError: ${msg.data}\x1b[0m\r\n`)
|
||||
}
|
||||
} catch {
|
||||
term.write(event.data)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
term.write('\r\n\x1b[33m— Connection closed —\x1b[0m\r\n')
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
term.write('\r\n\x1b[31m— Connection error —\x1b[0m\r\n')
|
||||
}
|
||||
|
||||
term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'input', data }))
|
||||
}
|
||||
})
|
||||
|
||||
term.onResize(({ rows, cols }) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'resize', rows, cols }))
|
||||
}
|
||||
})
|
||||
|
||||
return ws
|
||||
}
|
||||
|
||||
export default function Shell({ api }) {
|
||||
const { t } = useI18n()
|
||||
const [history, setHistory] = useState([])
|
||||
const [input, setInput] = useState('')
|
||||
const [cwd, setCwd] = useState('~')
|
||||
const [showAi, setShowAi] = useState(false)
|
||||
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 [cmdHistory, setCmdHistory] = useState([])
|
||||
const [histIdx, setHistIdx] = useState(-1)
|
||||
const outputRef = useRef(null)
|
||||
const aiMessagesRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
outputRef.current?.scrollTo(0, outputRef.current.scrollHeight)
|
||||
}, [history])
|
||||
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
|
||||
}, [aiMessages])
|
||||
|
||||
const handleCommand = async (cmd) => {
|
||||
if (!cmd.trim()) return
|
||||
if (cmd === 'clear') { setHistory([]); return }
|
||||
|
||||
setCmdHistory(prev => [...prev, cmd])
|
||||
setHistIdx(-1)
|
||||
setHistory(prev => [...prev, { type: 'cmd', text: `${cwd} $ ${cmd}` }])
|
||||
|
||||
try {
|
||||
const res = await api.runCommand(cmd, cwd === '~' ? '' : cwd)
|
||||
if (res.output) setHistory(prev => [...prev, { type: 'out', text: res.output }])
|
||||
if (res.error) setHistory(prev => [...prev, { type: 'err', text: res.error }])
|
||||
if (cmd.startsWith('cd ')) {
|
||||
const dir = cmd.slice(3).trim()
|
||||
setCwd(dir === '~' ? '~' : dir)
|
||||
useEffect(() => {
|
||||
api.getTerminalSessions().then(d => {
|
||||
setSshConnections(d.ssh || [])
|
||||
setSystemTerminals(d.system || [])
|
||||
}).catch(() => {})
|
||||
api.getConfig().then(d => {
|
||||
if (d.terminal) {
|
||||
setTerminalSettings({
|
||||
fontSize: d.terminal.font_size || 14,
|
||||
fontFamily: d.terminal.font_family || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
||||
theme: d.terminal.theme || 'default',
|
||||
})
|
||||
}
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const initTerminal = useCallback((tabId, tab) => {
|
||||
if (tabsRef.current[tabId]) return
|
||||
|
||||
const container = document.getElementById(`terminal-${tabId}`)
|
||||
if (!container) return
|
||||
|
||||
const { term, fitAddon } = createTerminal(container, {
|
||||
fontSize: terminalSettings.fontSize,
|
||||
fontFamily: terminalSettings.fontFamily,
|
||||
theme: terminalSettings.theme,
|
||||
})
|
||||
|
||||
let initPayload
|
||||
if (tab.type === 'ssh') {
|
||||
initPayload = {
|
||||
type: 'ssh',
|
||||
data: JSON.stringify({
|
||||
host: tab.host,
|
||||
port: tab.port || 22,
|
||||
user: tab.user || 'root',
|
||||
key_path: tab.key_path || '',
|
||||
}),
|
||||
}
|
||||
} else {
|
||||
initPayload = {
|
||||
type: 'shell',
|
||||
data: tab.shell || '',
|
||||
}
|
||||
}
|
||||
|
||||
const ws = connectWebSocket(term, fitAddon, initPayload)
|
||||
|
||||
ws.onopen = () => {
|
||||
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: true } : t))
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t))
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t))
|
||||
}
|
||||
|
||||
const onResize = () => {
|
||||
const el = document.getElementById(`terminal-${tabId}`)
|
||||
if (el && el.offsetParent !== null) {
|
||||
fitAddon.fit()
|
||||
}
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(onResize)
|
||||
resizeObserver.observe(container)
|
||||
window.addEventListener('resize', onResize)
|
||||
|
||||
tabsRef.current[tabId] = { term, fitAddon, ws, resizeObserver, onResize }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const tab = tabs.find(t => t.id === activeTab)
|
||||
if (!tab) return
|
||||
|
||||
const container = document.getElementById(`terminal-${tab.id}`)
|
||||
if (!container) return
|
||||
|
||||
if (!tabsRef.current[tab.id]) {
|
||||
const timer = setTimeout(() => {
|
||||
initTerminal(tab.id, tab)
|
||||
requestAnimationFrame(() => {
|
||||
const entry = tabsRef.current[tab.id]
|
||||
if (entry) entry.fitAddon.fit()
|
||||
})
|
||||
}, 100)
|
||||
return () => clearTimeout(timer)
|
||||
} else {
|
||||
requestAnimationFrame(() => {
|
||||
const entry = tabsRef.current[tab.id]
|
||||
if (entry) entry.fitAddon.fit()
|
||||
})
|
||||
}
|
||||
}, [activeTab, tabs, initTerminal])
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
|
||||
if (!e.altKey) return
|
||||
|
||||
const num = parseInt(e.key)
|
||||
if (num >= 1 && num <= tabs.length) {
|
||||
e.preventDefault()
|
||||
setActiveTab(tabs[num - 1].id)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [tabs])
|
||||
|
||||
const addLocalTab = (shell, name) => {
|
||||
if (tabs.length >= MAX_TABS) return
|
||||
const id = nextIdRef.current++
|
||||
const newTab = { id, name: name || `${t('shell.localShell')} ${tabs.length + 1}`, type: 'local', shell: shell || '', connected: false }
|
||||
setTabs(prev => [...prev, newTab])
|
||||
setActiveTab(id)
|
||||
setShowMenu(false)
|
||||
}
|
||||
|
||||
const addSSHTab = (conn) => {
|
||||
if (tabs.length >= MAX_TABS) return
|
||||
const id = nextIdRef.current++
|
||||
const newTab = {
|
||||
id,
|
||||
name: conn.name || `${conn.user}@${conn.host}`,
|
||||
type: 'ssh',
|
||||
host: conn.host,
|
||||
port: conn.port || 22,
|
||||
user: conn.user || 'root',
|
||||
key_path: conn.key_path || '',
|
||||
connected: false,
|
||||
}
|
||||
setTabs(prev => [...prev, newTab])
|
||||
setActiveTab(id)
|
||||
setShowMenu(false)
|
||||
}
|
||||
|
||||
const closeTab = (tabId, e) => {
|
||||
if (e) e.stopPropagation()
|
||||
if (tabs.length <= 1) return
|
||||
|
||||
if (tabsRef.current[tabId]) {
|
||||
const { ws, resizeObserver, onResize, term } = tabsRef.current[tabId]
|
||||
window.removeEventListener('resize', onResize)
|
||||
resizeObserver.disconnect()
|
||||
ws.close()
|
||||
term.dispose()
|
||||
delete tabsRef.current[tabId]
|
||||
}
|
||||
|
||||
setTabs(prev => {
|
||||
const next = prev.filter(t => t.id !== tabId)
|
||||
if (activeTab === tabId && next.length > 0) {
|
||||
setActiveTab(next[next.length - 1].id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const startRename = (tabId, e) => {
|
||||
if (e) e.stopPropagation()
|
||||
const tab = tabs.find(t => t.id === tabId)
|
||||
setEditingTab(tabId)
|
||||
setEditName(tab.name)
|
||||
}
|
||||
|
||||
const finishRename = () => {
|
||||
if (editName.trim() && editingTab) {
|
||||
setTabs(prev => prev.map(t => t.id === editingTab ? { ...t, name: editName.trim() } : t))
|
||||
}
|
||||
setEditingTab(null)
|
||||
setEditName('')
|
||||
}
|
||||
|
||||
const saveSSHConnection = async () => {
|
||||
if (!sshForm.name.trim() || !sshForm.host.trim()) return
|
||||
try {
|
||||
await api.addSSHConnection(sshForm)
|
||||
setSshConnections(prev => [...prev, { ...sshForm }])
|
||||
setSshForm({ name: '', host: '', port: 22, user: '', key_path: '' })
|
||||
setShowSshModal(false)
|
||||
} catch (err) {
|
||||
setHistory(prev => [...prev, { type: 'err', text: err.message }])
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleCommand(input)
|
||||
setInput('')
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
if (cmdHistory.length === 0) return
|
||||
const newIdx = histIdx === -1 ? cmdHistory.length - 1 : Math.max(0, histIdx - 1)
|
||||
setHistIdx(newIdx)
|
||||
setInput(cmdHistory[newIdx])
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
if (histIdx === -1) return
|
||||
const newIdx = histIdx + 1
|
||||
if (newIdx >= cmdHistory.length) { setHistIdx(-1); setInput('') }
|
||||
else { setHistIdx(newIdx); setInput(cmdHistory[newIdx]) }
|
||||
const deleteSSHConnection = async (name) => {
|
||||
try {
|
||||
await api.deleteSSHConnection(name)
|
||||
setSshConnections(prev => prev.filter(c => c.name !== name))
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +378,6 @@ 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') }])
|
||||
@@ -78,57 +388,186 @@ export default function Shell({ api }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="split-horizontal" style={{ height: '100%' }}>
|
||||
<div className="terminal" style={{ flex: 1 }}>
|
||||
<div className="panel-header">
|
||||
<span className="panel-title">
|
||||
{t('shell.terminal')}
|
||||
<span className="panel-subtitle">{cwd}</span>
|
||||
</span>
|
||||
<button className="ghost sm" onClick={() => setShowAi(!showAi)}>
|
||||
{showAi ? t('shell.hideAi') : t('shell.aiAssistant')}
|
||||
</button>
|
||||
<div className="shell-layout">
|
||||
<div className="shell-terminal-col">
|
||||
<div className="shell-tabs-bar">
|
||||
<div className="shell-tabs">
|
||||
{tabs.map((tab, i) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`shell-tab ${activeTab === tab.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
onDoubleClick={(e) => startRename(tab.id, e)}
|
||||
>
|
||||
<span className={`connection-dot ${tab.connected ? 'on' : 'off'}`} />
|
||||
{tab.type === 'ssh' && <Globe size={12} />}
|
||||
{tab.type === 'local' && <Monitor size={12} />}
|
||||
{editingTab === tab.id ? (
|
||||
<input
|
||||
className="shell-tab-rename"
|
||||
value={editName}
|
||||
onChange={e => setEditName(e.target.value)}
|
||||
onBlur={finishRename}
|
||||
onKeyDown={e => { if (e.key === 'Enter') finishRename(); if (e.key === 'Escape') setEditingTab(null) }}
|
||||
autoFocus
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span className="shell-tab-name">{tab.name}</span>
|
||||
)}
|
||||
<span className="shell-tab-index">{i + 1}</span>
|
||||
{tabs.length > 1 && (
|
||||
<button
|
||||
className="shell-tab-close"
|
||||
onClick={(e) => closeTab(tab.id, e)}
|
||||
title={t('shell.closeTab')}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="shell-tab-actions">
|
||||
{tabs.length < MAX_TABS && (
|
||||
<div className="shell-new-tab-wrapper">
|
||||
<button className="shell-new-tab-btn" onClick={() => setShowMenu(!showMenu)} title={t('shell.newTab')}>
|
||||
<Plus size={16} />
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
{showMenu && (
|
||||
<>
|
||||
<div className="shell-menu-overlay" onClick={() => setShowMenu(false)} />
|
||||
<div className="shell-new-tab-menu">
|
||||
<div className="shell-menu-label">{t('shell.systemTerminals')}</div>
|
||||
{systemTerminals.map(st => (
|
||||
<button
|
||||
key={st.name}
|
||||
className="shell-menu-item"
|
||||
onClick={() => addLocalTab(st.shell, st.name)}
|
||||
>
|
||||
<Monitor size={14} />
|
||||
<span>{st.name}</span>
|
||||
<span className="shell-menu-item-sub">{st.shell}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="shell-menu-divider" />
|
||||
<div className="shell-menu-label">{t('shell.savedConnections')}</div>
|
||||
{sshConnections.length === 0 && (
|
||||
<div className="shell-menu-empty">{t('shell.noConnections')}</div>
|
||||
)}
|
||||
{sshConnections.map(conn => (
|
||||
<div key={conn.name} className="shell-menu-item-row">
|
||||
<button
|
||||
className="shell-menu-item"
|
||||
onClick={() => addSSHTab(conn)}
|
||||
>
|
||||
<Globe size={14} />
|
||||
<span>{conn.name}</span>
|
||||
<span className="shell-menu-item-sub">{conn.user}@{conn.host}:{conn.port}</span>
|
||||
</button>
|
||||
<button
|
||||
className="shell-menu-item-icon"
|
||||
onClick={(e) => { e.stopPropagation(); deleteSSHConnection(conn.name) }}
|
||||
title={t('shell.deleteConnection')}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="shell-menu-divider" />
|
||||
<button className="shell-menu-item accent" onClick={() => { setShowSshModal(true); setShowMenu(false) }}>
|
||||
<Plus size={14} />
|
||||
<span>{t('shell.addConnection')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="terminal-output" ref={outputRef}>
|
||||
{history.map((line, i) => (
|
||||
<div key={i} className={`terminal-line ${line.type}`}>
|
||||
{line.text}
|
||||
</div>
|
||||
<div className="shell-xterm-wrapper">
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
id={`terminal-${tab.id}`}
|
||||
className="shell-xterm-instance"
|
||||
style={{ display: activeTab === tab.id ? 'block' : 'none' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="terminal-input-bar">
|
||||
<span className="terminal-prompt">›</span>
|
||||
<input
|
||||
className="terminal-input"
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAi && (
|
||||
<div className="ai-panel">
|
||||
<div className="ai-panel-header">{t('shell.aiAssistant')}</div>
|
||||
<div className="ai-panel-messages">
|
||||
{aiMessages.map((msg, i) => (
|
||||
<div key={i} className={`ai-message ${msg.role}`}>
|
||||
{msg.content}
|
||||
<div className="shell-ai-col">
|
||||
<div className="ai-panel-header">{t('shell.aiAssistant')}</div>
|
||||
<div className="ai-panel-messages" ref={aiMessagesRef}>
|
||||
{aiMessages.map((msg, i) => (
|
||||
<div key={i} className={`ai-message ${msg.role}`}>
|
||||
{msg.content}
|
||||
</div>
|
||||
))}
|
||||
{aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>}
|
||||
</div>
|
||||
<div className="ai-panel-input">
|
||||
<input
|
||||
value={aiInput}
|
||||
onChange={e => setAiInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleAiSend()}
|
||||
placeholder={t('shell.askAi')}
|
||||
/>
|
||||
<button className="sm" onClick={handleAiSend} disabled={!aiInput.trim()}>{t('shell.send')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSshModal && (
|
||||
<div className="shell-modal-overlay" onClick={() => setShowSshModal(false)}>
|
||||
<div className="shell-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="shell-modal-header">{t('shell.addConnection')}</div>
|
||||
<div className="shell-modal-body">
|
||||
<label className="shell-modal-label">{t('shell.connectionName')}</label>
|
||||
<input
|
||||
value={sshForm.name}
|
||||
onChange={e => setSshForm(f => ({ ...f, name: e.target.value }))}
|
||||
placeholder="prod-server"
|
||||
/>
|
||||
<label className="shell-modal-label">{t('shell.host')}</label>
|
||||
<input
|
||||
value={sshForm.host}
|
||||
onChange={e => setSshForm(f => ({ ...f, host: e.target.value }))}
|
||||
placeholder="192.168.1.100"
|
||||
/>
|
||||
<div className="shell-modal-row">
|
||||
<div className="shell-modal-field">
|
||||
<label className="shell-modal-label">{t('shell.port')}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={sshForm.port}
|
||||
onChange={e => setSshForm(f => ({ ...f, port: parseInt(e.target.value) || 22 }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="shell-modal-field">
|
||||
<label className="shell-modal-label">{t('shell.user')}</label>
|
||||
<input
|
||||
value={sshForm.user}
|
||||
onChange={e => setSshForm(f => ({ ...f, user: e.target.value }))}
|
||||
placeholder="root"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>}
|
||||
</div>
|
||||
<div className="ai-panel-input">
|
||||
<input
|
||||
value={aiInput}
|
||||
onChange={e => setAiInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleAiSend()}
|
||||
placeholder={t('shell.askAi')}
|
||||
/>
|
||||
<button className="sm" onClick={handleAiSend} disabled={!aiInput.trim()}>{t('shell.send')}</button>
|
||||
<label className="shell-modal-label">{t('shell.keyPath')} ({t('shell.local')})</label>
|
||||
<input
|
||||
value={sshForm.key_path}
|
||||
onChange={e => setSshForm(f => ({ ...f, key_path: e.target.value }))}
|
||||
placeholder="~/.ssh/id_rsa"
|
||||
/>
|
||||
</div>
|
||||
<div className="shell-modal-footer">
|
||||
<button className="ghost" onClick={() => setShowSshModal(false)}>{t('shell.cancel')}</button>
|
||||
<button className="primary" onClick={saveSSHConnection}>{t('shell.save')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,37 +1,207 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
function renderContent(text) {
|
||||
const parts = []
|
||||
const codeBlockRegex = /(```[\s\S]*?```)/g
|
||||
let match
|
||||
let lastIndex = 0
|
||||
while ((match = codeBlockRegex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({ type: 'text', content: text.slice(lastIndex, match.index) })
|
||||
}
|
||||
const full = match[1]
|
||||
const firstNewline = full.indexOf('\n')
|
||||
const lang = firstNewline > -1 ? full.slice(3, firstNewline).trim() : ''
|
||||
const code = firstNewline > -1 ? full.slice(firstNewline + 1, -3) : full.slice(3, -3)
|
||||
parts.push({ type: 'code', lang, content: code })
|
||||
lastIndex = match.index + full.length
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
parts.push({ type: 'text', content: text.slice(lastIndex) })
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
function formatText(text) {
|
||||
return text
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
|
||||
.replace(/^### (.+)$/gm, '<h4 class="msg-h4">$1</h4>')
|
||||
.replace(/^## (.+)$/gm, '<h3 class="msg-h3">$1</h3>')
|
||||
.replace(/^\s*[-*] (.+)$/gm, '<span class="msg-bullet">$1</span>')
|
||||
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<span class="msg-step"><span class="msg-step-num">$1</span>$2</span>')
|
||||
}
|
||||
|
||||
function FeedItem({ msg }) {
|
||||
const isUser = msg.role === 'user'
|
||||
const isSystem = msg.role === 'system'
|
||||
|
||||
const roleLabel = isUser ? null : isSystem ? null : (
|
||||
<div className="feed-avatar">
|
||||
<svg width="14" height="14" 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>
|
||||
)
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`feed-item ${msg.role}`}>
|
||||
{roleLabel}
|
||||
<div className="feed-body">
|
||||
<div className="feed-header">
|
||||
<span className="feed-role">{isUser ? 'Vous' : 'IA'}</span>
|
||||
{timeStr && <span className="feed-time">{timeStr}</span>}
|
||||
</div>
|
||||
<div className="feed-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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StreamingItem({ content }) {
|
||||
return (
|
||||
<div className="feed-item assistant">
|
||||
<div className="feed-avatar">
|
||||
<svg width="14" height="14" 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="feed-body">
|
||||
<div className="feed-header">
|
||||
<span className="feed-role">IA</span>
|
||||
</div>
|
||||
<div className="feed-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) }} />
|
||||
)
|
||||
)}
|
||||
<span className="studio-cursor" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Studio({ api }) {
|
||||
const { t, layout } = useI18n()
|
||||
const [messages, setMessages] = useState([
|
||||
{ role: 'ai', content: t('studio.welcome') },
|
||||
{ role: 'ai', content: t('studio.configureHint') },
|
||||
])
|
||||
const { t } = useI18n()
|
||||
const [messages, setMessages] = useState([])
|
||||
const [input, setInput] = useState('')
|
||||
const [sidebarPanel, setSidebarPanel] = useState('chat')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [streaming, setStreaming] = useState('')
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const messagesEnd = useRef(null)
|
||||
const textareaRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.getChatHistory().then(data => {
|
||||
if (data.messages && data.messages.length > 0) {
|
||||
setMessages(data.messages)
|
||||
} else {
|
||||
setMessages([
|
||||
{ id: 'welcome', role: 'system', content: t('studio.welcomeNew'), time: new Date().toISOString() },
|
||||
])
|
||||
}
|
||||
setLoaded(true)
|
||||
}).catch(() => {
|
||||
setMessages([
|
||||
{ id: 'welcome', role: 'system', content: t('studio.welcomeNew'), time: new Date().toISOString() },
|
||||
])
|
||||
setLoaded(true)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
}, [messages, streaming])
|
||||
|
||||
const handleSend = () => {
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto'
|
||||
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 200) + 'px'
|
||||
}
|
||||
}, [input])
|
||||
|
||||
const handleClear = useCallback(async () => {
|
||||
try {
|
||||
await api.clearChat()
|
||||
setMessages([
|
||||
{ id: 'clear-' + Date.now(), role: 'system', content: t('studio.cleared'), time: new Date().toISOString() },
|
||||
])
|
||||
} catch {}
|
||||
}, [api, t])
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!input.trim() || loading) return
|
||||
const text = input.trim()
|
||||
setMessages(prev => [...prev, { role: 'user', content: text }])
|
||||
setInput('')
|
||||
setLoading(true)
|
||||
|
||||
api.runCommand(`echo "AI response simulation for: ${text}"`, '')
|
||||
.then(res => {
|
||||
setMessages(prev => [...prev, { role: 'ai', content: res.output || res.error || t('studio.noResponse') }])
|
||||
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('')
|
||||
|
||||
try {
|
||||
let accumulated = ''
|
||||
await api.sendChat(text, true, (partial) => {
|
||||
accumulated = partial
|
||||
setStreaming(partial)
|
||||
})
|
||||
.catch(err => {
|
||||
setMessages(prev => [...prev, { role: 'ai', content: `${t('studio.error')}: ${err.message}` }])
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
const finalContent = accumulated || t('studio.noResponse')
|
||||
setMessages(prev => [...prev, {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: finalContent,
|
||||
time: new Date().toISOString(),
|
||||
}])
|
||||
} catch (err) {
|
||||
setMessages(prev => [...prev, {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'system',
|
||||
content: `${t('studio.error')}: ${err.message}`,
|
||||
time: new Date().toISOString(),
|
||||
}])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setStreaming('')
|
||||
}
|
||||
}, [input, loading, api, t, handleClear])
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
@@ -40,100 +210,66 @@ export default function Studio({ api }) {
|
||||
}
|
||||
}
|
||||
|
||||
const sidebarItems = [
|
||||
{ id: 'chat', label: t('studio.chat'), icon: '#' },
|
||||
{ id: 'agents', label: t('studio.agents'), icon: '*' },
|
||||
{ id: 'workflows', label: t('studio.workflows'), icon: '~' },
|
||||
]
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="split-horizontal">
|
||||
<div className="chat-layout" style={{ flex: 1, borderRight: '1px solid var(--border)' }}>
|
||||
<div className="panel-header">
|
||||
<span className="panel-title">
|
||||
{t('studio.chat')}
|
||||
{loading && <span className="spinner" />}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="chat-messages">
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={`message ${msg.role}`}>
|
||||
{msg.content}
|
||||
<div className="studio-feed-layout">
|
||||
<div className="studio-feed">
|
||||
{messages.map(msg => (
|
||||
<FeedItem key={msg.id} msg={msg} />
|
||||
))}
|
||||
{streaming && <StreamingItem content={streaming} />}
|
||||
{loading && !streaming && (
|
||||
<div className="feed-item assistant">
|
||||
<div className="feed-avatar">
|
||||
<svg width="14" height="14" 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 ref={messagesEnd} />
|
||||
</div>
|
||||
<div className="feed-body">
|
||||
<div className="feed-content">
|
||||
<div className="studio-thinking"><span /><span /><span /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEnd} />
|
||||
</div>
|
||||
|
||||
<div className="chat-input-bar">
|
||||
<input
|
||||
<div className="studio-input-area">
|
||||
<div className="studio-input-row">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('studio.placeholder')}
|
||||
placeholder={t('studio.placeholderNew')}
|
||||
disabled={loading}
|
||||
rows={1}
|
||||
/>
|
||||
<button className="primary" onClick={handleSend} disabled={loading || !input.trim()}>
|
||||
{t('studio.send')}
|
||||
<button
|
||||
className="studio-send-btn"
|
||||
onClick={handleSend}
|
||||
disabled={loading || !input.trim()}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="split-right">
|
||||
<div className="sidebar-nav">
|
||||
{sidebarItems.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`sidebar-tab ${sidebarPanel === item.id ? 'active' : ''}`}
|
||||
onClick={() => setSidebarPanel(item.id)}
|
||||
>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', width: 16 }}>{item.icon}</span>
|
||||
{item.label}
|
||||
</div>
|
||||
))}
|
||||
<div className="studio-input-hint">
|
||||
{t('studio.inputHint')} · /clear
|
||||
</div>
|
||||
|
||||
{sidebarPanel === 'chat' && (
|
||||
<div>
|
||||
<div className="section-title">{t('studio.commands')}</div>
|
||||
<div style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--text-tertiary)' }}>
|
||||
{t('studio.planGoal')}<br />
|
||||
{t('studio.help')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sidebarPanel === 'agents' && (
|
||||
<div>
|
||||
<div className="section-title">{t('studio.activeAgents')}</div>
|
||||
<div className="agent-card">
|
||||
<div className="agent-avatar">C</div>
|
||||
<div>
|
||||
<div className="agent-name">{t('studio.crush')}</div>
|
||||
<div className="agent-status">{t('studio.stopped')}</div>
|
||||
</div>
|
||||
<span className="badge neutral" style={{ marginLeft: 'auto' }}>{t('studio.inactive')}</span>
|
||||
</div>
|
||||
<div className="agent-card">
|
||||
<div className="agent-avatar">CC</div>
|
||||
<div>
|
||||
<div className="agent-name">{t('studio.claudeCode')}</div>
|
||||
<div className="agent-status">{t('studio.stopped')}</div>
|
||||
</div>
|
||||
<span className="badge neutral" style={{ marginLeft: 'auto' }}>{t('studio.inactive')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sidebarPanel === 'workflows' && (
|
||||
<div>
|
||||
<div className="section-title">{t('studio.workflows')}</div>
|
||||
<div className="empty-state">
|
||||
{t('studio.noWorkflow')}
|
||||
<span style={{ fontFamily: 'var(--font-mono)' }}>{t('studio.usePlan')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -46,11 +46,13 @@ const en = {
|
||||
|
||||
studio: {
|
||||
welcome: 'Welcome to Studio! Chat with your AI assistant here.',
|
||||
welcomeNew: 'Welcome to Muyue Studio. I am your AI orchestrator. Describe your project and I will create a plan, propose agents, and track each step.',
|
||||
configureHint: 'Configure agents and workflows from the sidebar.',
|
||||
chat: 'Chat',
|
||||
agents: 'Agents',
|
||||
workflows: 'Workflows',
|
||||
placeholder: 'Type a message... (Enter to send)',
|
||||
placeholderNew: 'Describe your project or ask a question...',
|
||||
send: 'Send',
|
||||
commands: 'Commands',
|
||||
planGoal: '/plan <goal>',
|
||||
@@ -64,20 +66,61 @@ const en = {
|
||||
usePlan: 'Use /plan <goal> in chat to start.',
|
||||
noResponse: 'No response',
|
||||
error: 'Error',
|
||||
inputHint: 'Enter to send, Shift+Enter for new line',
|
||||
context: 'Context',
|
||||
plans: 'Plans',
|
||||
activity: 'Activity',
|
||||
noPlansYet: 'No plans detected. Ask the AI to create a plan.',
|
||||
noAgentsYet: 'No agents mentioned.',
|
||||
planDetail: 'Plan detail',
|
||||
steps: 'steps',
|
||||
you: 'You',
|
||||
mentioned: 'mentioned',
|
||||
cleared: 'Conversation cleared.',
|
||||
},
|
||||
|
||||
shell: {
|
||||
terminal: 'Terminal',
|
||||
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...',
|
||||
},
|
||||
|
||||
config: {
|
||||
panels: {
|
||||
profile: 'Profile',
|
||||
providers: 'AI Providers',
|
||||
terminal: 'Terminal',
|
||||
updates: 'Updates',
|
||||
locale: 'Language & Keyboard',
|
||||
skills: 'Skills',
|
||||
},
|
||||
profile: 'Profile',
|
||||
name: 'Name',
|
||||
pseudo: 'Pseudo',
|
||||
@@ -90,15 +133,52 @@ 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...',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -46,11 +46,13 @@ const fr = {
|
||||
|
||||
studio: {
|
||||
welcome: 'Bienvenue dans Studio ! Discutez avec votre assistant IA ici.',
|
||||
welcomeNew: 'Bienvenue dans Muyue Studio. Je suis votre orchestrateur IA. D\u00e9crivez votre projet et je cr\u00e9erai un plan, proposerai des agents, et suivrai chaque \u00e9tape.',
|
||||
configureHint: 'Configurez les agents et workflows depuis la barre lat\u00e9rale.',
|
||||
chat: 'Chat',
|
||||
agents: 'Agents',
|
||||
workflows: 'Workflows',
|
||||
placeholder: 'Tapez un message... (Entr\u00e9e pour envoyer)',
|
||||
placeholderNew: 'D\u00e9crivez votre projet ou posez une question...',
|
||||
send: 'Envoyer',
|
||||
commands: 'Commandes',
|
||||
planGoal: '/plan <objectif>',
|
||||
@@ -64,20 +66,61 @@ const fr = {
|
||||
usePlan: 'Utilisez /plan <objectif> dans le chat pour d\u00e9marrer.',
|
||||
noResponse: 'Pas de r\u00e9ponse',
|
||||
error: 'Erreur',
|
||||
inputHint: 'Entr\u00e9e pour envoyer, Shift+Entr\u00e9e pour un retour \u00e0 la ligne',
|
||||
context: 'Contexte',
|
||||
plans: 'Plans',
|
||||
activity: 'Activit\u00e9',
|
||||
noPlansYet: 'Aucun plan d\u00e9tect\u00e9. Demandez \u00e0 l\u2019IA de cr\u00e9er un plan.',
|
||||
noAgentsYet: 'Aucun agent mentionn\u00e9.',
|
||||
planDetail: 'D\u00e9tail du plan',
|
||||
steps: '\u00e9tapes',
|
||||
you: 'Vous',
|
||||
mentioned: 'mentionn\u00e9',
|
||||
cleared: 'Conversation effac\u00e9e.',
|
||||
},
|
||||
|
||||
shell: {
|
||||
terminal: 'Terminal',
|
||||
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...',
|
||||
},
|
||||
|
||||
config: {
|
||||
panels: {
|
||||
profile: 'Profil',
|
||||
providers: 'Fournisseurs IA',
|
||||
terminal: 'Terminal',
|
||||
updates: 'Mises \u00e0 jour',
|
||||
locale: 'Langue & Clavier',
|
||||
skills: 'Comp\u00e9tences',
|
||||
},
|
||||
profile: 'Profil',
|
||||
name: 'Nom',
|
||||
pseudo: 'Pseudo',
|
||||
@@ -90,15 +133,52 @@ 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',
|
||||
apiKey: 'Cl\u00e9 API',
|
||||
model: 'Mod\u00e8le',
|
||||
baseUrl: 'URL de base',
|
||||
save: 'Enregistrer',
|
||||
saved: 'Enregistr\u00e9 !',
|
||||
error: 'Erreur',
|
||||
skills: 'Comp\u00e9tences',
|
||||
noSkills: 'Aucune comp\u00e9tence install\u00e9e.',
|
||||
runSkillsInit: 'Ex\u00e9cutez muyue skills init',
|
||||
language: 'Langue',
|
||||
keyboardLayout: 'Disposition du clavier',
|
||||
target: 'Cible',
|
||||
updates: 'Mises \u00e0 jour',
|
||||
systemUpdates: 'Mises \u00e0 jour syst\u00e8me',
|
||||
checkUpdates: 'V\u00e9rifier les mises \u00e0 jour',
|
||||
updateAll: 'Tout mettre \u00e0 jour',
|
||||
updateTool: 'Mettre \u00e0 jour',
|
||||
checking: 'V\u00e9rification...',
|
||||
updating: 'Mise \u00e0 jour...',
|
||||
upToDate: '\u00c0 jour',
|
||||
needsUpdate: 'Mise \u00e0 jour disponible',
|
||||
current: 'Actuel',
|
||||
latest: 'Dernier',
|
||||
noUpdates: 'Tous les outils sont \u00e0 jour.',
|
||||
version: 'Version',
|
||||
installed: 'Install\u00e9',
|
||||
missing: 'Manquant',
|
||||
editProfile: 'Modifier',
|
||||
editProvider: 'Configurer',
|
||||
validateKey: 'Valider',
|
||||
validating: 'Vérification...',
|
||||
keyValid: 'Clé valide',
|
||||
keyInvalid: 'Clé invalide',
|
||||
connectionFailed: 'Connexion échouée',
|
||||
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...',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -267,50 +268,245 @@ input::placeholder { color: var(--text-disabled); }
|
||||
.sidebar-tab:hover { background: var(--bg-card); color: var(--text-primary); }
|
||||
.sidebar-tab.active { background: var(--accent); color: #fff; font-weight: 600; }
|
||||
|
||||
.terminal { display: flex; flex-direction: column; height: 100%; background: var(--bg); }
|
||||
.terminal-output { flex: 1; padding: 16px; font-family: var(--font-mono); font-size: 13px; line-height: 1.6; overflow-y: auto; white-space: pre-wrap; word-break: break-all; }
|
||||
.terminal-line { margin-bottom: 2px; }
|
||||
.terminal-line.cmd { color: var(--accent-dim); }
|
||||
.terminal-line.out { color: var(--text-primary); }
|
||||
.terminal-line.err { color: var(--error); }
|
||||
.terminal-input-bar { display: flex; align-items: center; padding: 10px 16px; background: var(--bg-surface); border-top: 1px solid var(--border); gap: 8px; }
|
||||
.terminal-prompt { color: var(--success); font-family: var(--font-mono); font-weight: 700; font-size: 14px; flex-shrink: 0; }
|
||||
.terminal-input { flex: 1; background: transparent; border: none; outline: none; color: var(--text-primary); font-family: var(--font-mono); font-size: 13px; padding: 0; }
|
||||
.terminal-input:focus { box-shadow: none; border-color: transparent; }
|
||||
.shell-layout { display: flex; height: 100%; }
|
||||
.shell-terminal-col { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; }
|
||||
|
||||
.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);
|
||||
.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;
|
||||
}
|
||||
.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-tabs {
|
||||
display: flex; align-items: center; gap: 2px; flex: 1; overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.shell-tabs::-webkit-scrollbar { display: none; }
|
||||
|
||||
.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-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;
|
||||
}
|
||||
.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); }
|
||||
.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); }
|
||||
|
||||
.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;
|
||||
.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);
|
||||
}
|
||||
.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);
|
||||
|
||||
.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); }
|
||||
|
||||
.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-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; }
|
||||
|
||||
.shell-modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 1000;
|
||||
}
|
||||
.shell-modal {
|
||||
background: var(--bg-elevated); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg); min-width: 380px; max-width: 480px;
|
||||
box-shadow: 0 16px 48px rgba(0,0,0,0.5);
|
||||
}
|
||||
.shell-modal-header {
|
||||
padding: 16px 20px; font-size: 14px; font-weight: 700;
|
||||
color: var(--text-primary); border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.shell-modal-body { padding: 16px 20px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.shell-modal-label { font-size: 11px; font-weight: 600; color: var(--text-tertiary); margin-bottom: 2px; }
|
||||
.shell-modal-row { display: grid; grid-template-columns: 1fr 2fr; gap: 12px; }
|
||||
.shell-modal-field { display: flex; flex-direction: column; }
|
||||
.shell-modal-footer {
|
||||
padding: 12px 20px; border-top: 1px solid var(--border);
|
||||
display: flex; justify-content: flex-end; gap: 8px;
|
||||
}
|
||||
|
||||
.config-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;
|
||||
}
|
||||
|
||||
.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; }
|
||||
@@ -323,52 +519,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 { width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; }
|
||||
.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;
|
||||
@@ -381,7 +554,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;
|
||||
@@ -399,3 +572,105 @@ input::placeholder { color: var(--text-disabled); }
|
||||
.spinner { width: 16px; height: 16px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.6s linear infinite; display: inline-block; vertical-align: middle; }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.fade-in { animation: fadeIn 0.2s ease-out; }
|
||||
|
||||
/* ── Studio 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-code-block {
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
overflow: hidden; margin: 8px 0;
|
||||
}
|
||||
.studio-code-block pre { padding: 12px 16px; font-family: var(--font-mono); font-size: 13px; line-height: 1.5; overflow-x: auto; color: var(--text-primary); margin: 0; }
|
||||
.studio-code-lang {
|
||||
padding: 4px 12px; font-size: 11px; font-weight: 600; color: var(--text-tertiary);
|
||||
background: var(--bg-surface); border-bottom: 1px solid var(--border); text-transform: uppercase; letter-spacing: 0.5px;
|
||||
}
|
||||
.inline-code { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 13px; color: var(--accent-muted); }
|
||||
.msg-h3 { font-size: 16px; font-weight: 700; color: var(--text-primary); margin: 16px 0 8px; display: block; }
|
||||
.msg-h4 { font-size: 14px; font-weight: 700; color: var(--text-secondary); margin: 12px 0 6px; display: block; }
|
||||
.msg-bullet { display: block; padding-left: 16px; position: relative; margin: 2px 0; }
|
||||
.msg-bullet::before { content: '\2022'; position: absolute; left: 4px; color: var(--accent); }
|
||||
.msg-step { display: flex; gap: 8px; align-items: baseline; margin: 3px 0; }
|
||||
.msg-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); font-size: 13px; flex-shrink: 0; min-width: 20px; }
|
||||
.studio-cursor { display: inline-block; width: 8px; height: 16px; background: var(--accent); margin-left: 2px; vertical-align: text-bottom; animation: blink 0.8s step-end infinite; }
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
.studio-thinking { display: flex; gap: 4px; padding: 8px 0; }
|
||||
.studio-thinking span { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-dim); animation: bounce 1.2s ease-in-out infinite; }
|
||||
.studio-thinking span:nth-child(2) { animation-delay: 0.15s; }
|
||||
.studio-thinking span:nth-child(3) { animation-delay: 0.3s; }
|
||||
@keyframes bounce { 0%, 80%, 100% { transform: translateY(0); opacity: 0.4; } 40% { transform: translateY(-6px); opacity: 1; } }
|
||||
.studio-input-area { padding: 12px 20px 8px; border-top: 1px solid var(--border); background: var(--bg-surface); }
|
||||
.studio-input-row { display: flex; gap: 8px; align-items: flex-end; }
|
||||
.studio-input-row textarea {
|
||||
flex: 1; resize: none; min-height: 42px; max-height: 200px; padding: 10px 14px;
|
||||
font-size: 14px; line-height: 1.5; border-radius: var(--radius);
|
||||
background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border);
|
||||
font-family: var(--font-sans); outline: none; transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.studio-input-row textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--border-accent); }
|
||||
.studio-input-row textarea::placeholder { color: var(--text-disabled); }
|
||||
.studio-send-btn {
|
||||
width: 42px; height: 42px; padding: 0; display: flex; align-items: center; justify-content: center;
|
||||
border-radius: var(--radius); background: var(--accent); color: #fff; border: 1px solid var(--accent);
|
||||
cursor: pointer; transition: all 0.15s; flex-shrink: 0;
|
||||
}
|
||||
.studio-send-btn:hover:not(:disabled) { background: var(--accent-bright); border-color: var(--accent-bright); }
|
||||
.studio-send-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
.studio-input-hint { font-size: 11px; color: var(--text-disabled); text-align: center; margin-top: 6px; }
|
||||
|
||||
Reference in New Issue
Block a user