Compare commits

..

24 Commits

Author SHA1 Message Date
Augustin
66b773ff86 feat(agent): refactor AI chat with streaming, agent registry, and tool execution
All checks were successful
Beta Release / beta (push) Successful in 47s
- Replace old tool-call regex with proper agent registry
- Add streaming chat via SSE (handleStreamChat / handleNonStreamChat)
- Add internal/agent package with tool definitions and execution
- Add orchestrator with system prompt and tool scaffolding
- Add internal/agent/ directory
- Studio.jsx: streaming chat with thinking indicator and tool result rendering
- global.css: chat bubble styles, streaming animation, thinking dots
- handlers_chat.go: full rewrite using new agent/orchestrator architecture

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 21:19:36 +02:00
Augustin
bc5c2956b4 feat(onboarding): add minimax api key step and AI-powered editor scan
Some checks failed
Beta Release / beta (push) Failing after 22s
- Add apikey step in onboarding wizard (optional, with validation)
- Add ScanEditors() in scanner package detecting vim/nvim/code/emacs/nano/helix/subl/zed
- Add GET /api/editors endpoint
- Editor step now has scan button to detect installed editors via backend
- MiniMax API key is saved to provider config if provided

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 21:04:27 +02:00
Augustin
e19122dad9 fix(onboarding): require fields before advancing steps
All checks were successful
Beta Release / beta (push) Successful in 39s
- Validate each step before allowing goNext
- Show required error message on name step if empty
- Clear error on input change

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:58:36 +02:00
Augustin
8b6a7e8bc3 fix: register missing /api/config/reset and /api/starship/apply-theme routes
All checks were successful
Beta Release / beta (push) Successful in 40s
- Add resetConfig and applyStarshipTheme to frontend api client
- Register handleResetConfig and handleApplyStarshipTheme in server mux

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:57:25 +02:00
Augustin
58f8cb0bd3 fix(config): per-provider form state to avoid field cross-talk
All checks were successful
Beta Release / beta (push) Successful in 38s
- providerForm is now keyed by provider name
- Each provider (minimax/glm/claude) has isolated form data
- Validation and save target the specific provider being edited

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:56:04 +02:00
Augustin
b52feccc17 fix(onboarding): auto-save on done step, keyboard nav, error feedback
All checks were successful
Beta Release / beta (push) Successful in 40s
- Trigger save automatically when reaching done step
- Add Escape to go back, Enter to advance (works in text fields)
- Add back button visible between step 1 and last step
- Fix accent encoding in done message
- Show saving state and error with retry button

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:53:12 +02:00
Augustin
5bbac499a7 feat(config): add system panel with reset and starship theme, add onboarding wizard
All checks were successful
Beta Release / beta (push) Successful in 41s
- Add PanelSystem with reset config and apply starship theme (charm/zerotwo/default)
- Add OnboardingWizard that activates when profile is empty on first run
- Fix <thing> tag parsing in Shell AI messages (wait for </thing> before rendering)
- Add /api/config/reset and /api/starship/apply-theme endpoints
- Wire wizard trigger in App.jsx based on profile completeness

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:36:36 +02:00
Augustin
83d7a573c7 fix: correct version from 3.2 to 0.3.2
All checks were successful
Beta Release / beta (push) Successful in 41s
The version was incorrectly bumped to 3.2 instead of 0.3.2.
This follows the existing semver pattern (v0.2.0, v0.2.1, v0.3.1).

💾 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-22 20:29:09 +02:00
Augustin
0fe82f67df chore: bump version to 3.2
All checks were successful
Beta Release / beta (push) Successful in 45s
2026-04-22 20:19:47 +02:00
Augustin
4b9f2c377d Merge branch 'main' into develop 2026-04-22 20:19:35 +02:00
Augustin
95bd824259 refactor(config): remove Terminal sub-tab from Configuration page
All checks were successful
Stable Release / stable (push) Successful in 41s
2026-04-22 20:19:29 +02:00
Augustin
252f178bbd fix(terminal): init payload never sent due to ws.onopen being overwritten
connectWebSocket set ws.onopen to send the shell init payload, but
initTerminal immediately overwrote it with a state-only handler.
Switched to addEventListener so both handlers coexist.
2026-04-22 20:19:29 +02:00
Augustin
7dcf505360 fix(terminal): improve shell resolution with better error handling and ws proxy support
The `len(shell) <= 1` guard was too aggressive and provided no diagnostic info.
Now trims whitespace, resolves path in all cases, falls back to /bin/sh, and
logs detailed context for debugging. Also enable WebSocket proxying in Vite dev.
2026-04-22 20:19:29 +02:00
Augustin
8fb93fa47e feat(studio): parse AI thinking and tool launch messages in terminal panel
- Add message type detection: thinking (Reflexion/Thought/>), tool (TOOL_CALL),
  and normal AI responses
- Style thinking messages with italic blue, tool messages with yellow border
- Add toolLaunched i18n key for both fr and en locales

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:19:29 +02:00
Augustin
5ec373cd6a fix(studio): forward AI thinking chunks to frontend instead of dropping them
The ThinkingBlock component existed but was dead code — the backend
silently discarded all <think chunks. Now emits thinking SSE events
so the UI can display AI reflections in real-time.

\xe2\x98\x85 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-22 20:19:29 +02:00
Augustin
1eb5a6d00f feat(studio): add tool execution and hide AI thinking tags
Changes:
- Hide <think> tags from user in Studio chat
- Add tool call detection [TOOL_CALL:{...}] in AI responses
- Execute crush tool when requested by AI
- Show loading animation while AI is thinking

The AI can now:
1. Respond directly to user
2. Request tool execution via [TOOL_CALL:{"tool":"crush","task":"..."}]

The system automatically executes the tool and includes results.

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:19:29 +02:00
Augustin
cd5ebe083c fix(terminal): ignore invalid shell config from race condition
Reject shell paths with length <= 1 to prevent errors when user
input is accidentally sent as init message.

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:19:29 +02:00
Augustin
2004c15dd7 feat(shell): restore AI assistant panel
Re-add the AI assistant panel that was removed in previous refactoring.
The panel includes:
- Message history display
- Input field for AI queries
- Loading state indicator

Also restored the associated CSS styles and i18n translations.

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:19:29 +02:00
Augustin
9306152736 fix(terminal): restore terminal input and cursor visibility
- Fix shell execution to avoid --login flag causing issues on some shells
- Improve terminal initialization timing with requestAnimationFrame
- Force display visibility on xterm instances via CSS
- Ensure container has proper min-height and overflow handling

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:19:29 +02:00
Augustin
e15a034de5 refactor(api): split monolithic handlers.go into focused modules
Break down the 627-line handlers.go into specialized modules:
- handlers_chat.go: chat and streaming endpoints
- handlers_config.go: configuration endpoints
- handlers_common.go: shared utilities
- handlers_info.go: info and status endpoints
- handlers_terminal.go: terminal/shell endpoints
- handlers_tools.go: tool-related endpoints

Also includes config improvements, orchestrator enhancements, and
web component updates.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:19:29 +02:00
Augustin
3b6cc38ea0 refactor(config): remove Terminal sub-tab from Configuration page
All checks were successful
Beta Release / beta (push) Successful in 41s
2026-04-22 20:13:17 +02:00
Augustin
93a22d4075 fix(terminal): init payload never sent due to ws.onopen being overwritten
All checks were successful
Beta Release / beta (push) Successful in 40s
connectWebSocket set ws.onopen to send the shell init payload, but
initTerminal immediately overwrote it with a state-only handler.
Switched to addEventListener so both handlers coexist.
2026-04-22 20:05:10 +02:00
Augustin
e0e1e73bca fix(terminal): improve shell resolution with better error handling and ws proxy support
All checks were successful
Beta Release / beta (push) Successful in 40s
The `len(shell) <= 1` guard was too aggressive and provided no diagnostic info.
Now trims whitespace, resolves path in all cases, falls back to /bin/sh, and
logs detailed context for debugging. Also enable WebSocket proxying in Vite dev.
2026-04-22 20:02:55 +02:00
CI Bot
80c11cab3f chore: update CHANGELOG for v0.3.0 2026-04-21 21:08:06 +00:00
24 changed files with 2543 additions and 315 deletions

View File

@@ -4,6 +4,67 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## v0.3.0
### Changes since v0.2.1
- fix(terminal): resolve PTY shell exec error, simplify CLI, unify Config tabs, restore Studio CSS (0b22109)
- feat: add API key validation flow for AI provider config (7f67473)
- feat(studio): replace sidebar layout with unified execution feed styles (040e482)
- fix: guard against empty tabs array in closeTab (c8903ef)
- refactor: redesign Config as settings window with sidebar panels, remove system overview from Dashboard (f3cb306)
- feat: add multi-tab terminal with SSH support, config editing, and dashboard redesign (3cdcb22)
- feat(studio): add i18n keys and CSS for redesigned AI chat interface (ee18bbe)
- chore: bump version to 0.3.0 (b0b0e1d)
- chore: remove dead code (packages, functions, types, constants) (fc79810)
- docs: rewrite README and CHANGELOG for desktop app mode (f7222b0)
- feat(web): add i18n support with FR/EN locales and keyboard layout awareness (11417d3)
- refactor(web): redesign frontend for native web UX (3dc24ae)
- refactor: remove TUI, desktop web UI is now the default and only mode (aa0ff19)
- refactor: unify into single `muyue` binary with embedded desktop mode (3463605)
- fix(ci): add frontend build step before Go vet/test/build (097cf40)
- feat: add desktop app with React frontend, API backend, theme system (#2) (88d2a03)
- chore: update CHANGELOG for v0.2.1 (1830c18)
- feat: complete TUI redesign with cyberpunk theme (#1) (cb8e3d0)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-windows-arm64.zip) |
The binary includes both CLI and Desktop modes.
Run `muyue` for TUI, `muyue desktop` for web UI.
### Install
**Linux (x86_64)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-linux-amd64.tar.gz | tar xz
chmod +x muyue-linux-amd64
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
```
**macOS (Apple Silicon)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-darwin-arm64.tar.gz | tar xz
chmod +x muyue-darwin-arm64
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
```
**Windows (x86_64)**
```powershell
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-windows-amd64.zip" -OutFile "muyue.zip"
Expand-Archive -Path "muyue.zip" -DestinationPath "."
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
```
## [Unreleased]
### Security

View File

@@ -0,0 +1,311 @@
package agent
import (
"context"
"fmt"
"os/exec"
"path/filepath"
"strings"
"time"
)
type TerminalParams struct {
Command string `json:"command" description:"The shell command to execute"`
Timeout int `json:"timeout,omitempty" description:"Timeout in seconds (default 60, max 300)"`
}
func NewTerminalTool() (*ToolDefinition, error) {
return NewTool("terminal",
"Execute a shell command on the local system and return the output. Use for running builds, tests, git operations, package management, system info, or any CLI task. Commands run in the user's home directory by default. Long-running commands are auto-terminated.",
func(ctx context.Context, p TerminalParams) (ToolResponse, error) {
if p.Command == "" {
return TextErrorResponse("command is required"), nil
}
timeout := time.Duration(p.Timeout) * time.Second
if timeout == 0 {
timeout = 60 * time.Second
}
if timeout > 300*time.Second {
timeout = 300 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
shell := detectShell()
cmd := exec.CommandContext(ctx, shell, "-c", p.Command)
output, err := cmd.CombinedOutput()
result := string(output)
if len(result) > 10000 {
result = result[:10000] + "\n... [truncated]"
}
if err != nil {
return TextErrorResponse(fmt.Sprintf("Error: %v\n\n%s", err, result)), nil
}
return TextResponse(result), nil
})
}
type CrushRunParams struct {
Task string `json:"task" description:"The task description for Crush to execute"`
}
func NewCrushRunTool() (*ToolDefinition, error) {
return NewTool("crush_run",
"Delegate a complex coding task to the Crush AI agent. Crush has access to file editing, code search, bash execution, and other development tools. Use this for multi-step coding tasks like refactoring, debugging, implementing features, or code review. Returns the agent's final output.",
func(ctx context.Context, p CrushRunParams) (ToolResponse, error) {
if p.Task == "" {
return TextErrorResponse("task is required"), nil
}
ctx, cancel := context.WithTimeout(ctx, 300*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "crush", "run", p.Task)
output, err := cmd.CombinedOutput()
result := string(output)
if len(result) > 15000 {
result = result[:15000] + "\n... [truncated]"
}
if err != nil {
return TextErrorResponse(fmt.Sprintf("Crush error: %v\n\n%s", err, result)), nil
}
return TextResponse(result), nil
})
}
type ReadFileParams struct {
Path string `json:"path" description:"Absolute or relative path to the file to read"`
Offset int `json:"offset,omitempty" description:"Line number to start reading from (0-based, default 0)"`
Limit int `json:"limit,omitempty" description:"Maximum number of lines to read (default 200, max 2000)"`
}
func NewReadFileTool() (*ToolDefinition, error) {
return NewTool("read_file",
"Read file contents from the local filesystem. Returns the file content with line numbers. Supports offset/limit for reading specific sections of large files.",
func(ctx context.Context, p ReadFileParams) (ToolResponse, error) {
if p.Path == "" {
return TextErrorResponse("path is required"), nil
}
expanded := expandHome(p.Path)
data, err := readFileLimited(expanded, p.Offset, p.Limit)
if err != nil {
return TextErrorResponse(fmt.Sprintf("read error: %v", err)), nil
}
return TextResponse(data), nil
})
}
type ListFilesParams struct {
Path string `json:"path,omitempty" description:"Directory path to list (default: user home)"`
Depth int `json:"depth,omitempty" description:"Maximum depth to traverse (default 1, max 3)"`
}
func NewListFilesTool() (*ToolDefinition, error) {
return NewTool("list_files",
"List files and directories at a given path. Shows directory tree structure with file names. Useful for exploring project structure or finding specific files.",
func(ctx context.Context, p ListFilesParams) (ToolResponse, error) {
dir := expandHome(p.Path)
if dir == "" {
dir, _ = osUserHomeDir()
}
if p.Depth <= 0 {
p.Depth = 1
}
if p.Depth > 3 {
p.Depth = 3
}
result, err := listDirTree(dir, p.Depth, 0)
if err != nil {
return TextErrorResponse(fmt.Sprintf("list error: %v", err)), nil
}
return TextResponse(result), nil
})
}
type SearchFilesParams struct {
Pattern string `json:"pattern" description:"Search pattern (supports * and ? glob wildcards)"`
Path string `json:"path,omitempty" description:"Directory to search in (default: current directory)"`
}
func NewSearchFilesTool() (*ToolDefinition, error) {
return NewTool("search_files",
"Search for files by name pattern using glob syntax. Use * for any characters, ** for recursive matching. Returns matching file paths sorted by name.",
func(ctx context.Context, p SearchFilesParams) (ToolResponse, error) {
if p.Pattern == "" {
return TextErrorResponse("pattern is required"), nil
}
dir := expandHome(p.Path)
if dir == "" {
dir = "."
}
matches, err := filepath.Glob(filepath.Join(dir, p.Pattern))
if err != nil {
return TextErrorResponse(fmt.Sprintf("glob error: %v", err)), nil
}
if len(matches) == 0 {
return TextResponse("No files found matching pattern."), nil
}
if len(matches) > 100 {
matches = matches[:100]
}
var result strings.Builder
for _, m := range matches {
result.WriteString(m)
result.WriteString("\n")
}
return TextResponse(result.String()), nil
})
}
type GrepContentParams struct {
Pattern string `json:"pattern" description:"Text pattern to search for in file contents"`
Path string `json:"path,omitempty" description:"Directory to search in (default: current directory)"`
Include string `json:"include,omitempty" description:"File extension filter, e.g. '*.go' or '*.{js,ts}'"`
}
func NewGrepContentTool() (*ToolDefinition, error) {
return NewTool("grep_content",
"Search for text patterns inside file contents. Returns matching lines with file paths and line numbers. Use include to filter by file extension.",
func(ctx context.Context, p GrepContentParams) (ToolResponse, error) {
if p.Pattern == "" {
return TextErrorResponse("pattern is required"), nil
}
dir := expandHome(p.Path)
if dir == "" {
dir = "."
}
result, err := grepFiles(dir, p.Pattern, p.Include)
if err != nil {
return TextErrorResponse(fmt.Sprintf("grep error: %v", err)), nil
}
if result == "" {
return TextResponse("No matches found."), nil
}
return TextResponse(result), nil
})
}
type GetConfigParams struct {
Section string `json:"section,omitempty" description:"Config section to retrieve: 'providers', 'profile', 'tools', 'terminal', 'all' (default: 'all')"`
}
func NewGetConfigTool() (*ToolDefinition, error) {
return NewTool("get_config",
"Read the Muyue configuration. Returns provider settings, profile info, installed tools, terminal config, etc. Use section parameter to get a specific part, or 'all' for the full config.",
func(ctx context.Context, p GetConfigParams) (ToolResponse, error) {
return getConfigSection(p.Section), nil
})
}
type SetProviderParams struct {
Name string `json:"name" description:"Provider name (e.g. 'openai', 'anthropic', 'ollama')"`
APIKey string `json:"api_key,omitempty" description:"API key for the provider"`
BaseURL string `json:"base_url,omitempty" description:"Custom base URL for the provider API"`
Model string `json:"model,omitempty" description:"Model identifier to use"`
Active *bool `json:"active,omitempty" description:"Set to true to make this the active provider"`
}
func NewSetProviderTool() (*ToolDefinition, error) {
return NewTool("set_provider",
"Configure an AI provider in Muyue settings. Can create, update, or activate a provider. API keys are automatically encrypted. Set active=true to switch to this provider.",
func(ctx context.Context, p SetProviderParams) (ToolResponse, error) {
if p.Name == "" {
return TextErrorResponse("name is required"), nil
}
return setProviderConfig(p), nil
})
}
type ManageSSHParams struct {
Action string `json:"action" description:"Action to perform: 'list', 'add', 'remove'"`
Name string `json:"name,omitempty" description:"Connection name (required for add/remove)"`
Host string `json:"host,omitempty" description:"SSH host (required for add)"`
Port int `json:"port,omitempty" description:"SSH port (default: 22)"`
User string `json:"user,omitempty" description:"SSH username (required for add)"`
KeyPath string `json:"key_path,omitempty" description:"Path to SSH private key"`
}
func NewManageSSHTool() (*ToolDefinition, error) {
return NewTool("manage_ssh",
"Manage SSH connections configured in Muyue. List existing connections, add new ones, or remove connections. SSH configs are persisted to the Muyue config file.",
func(ctx context.Context, p ManageSSHParams) (ToolResponse, error) {
if p.Action == "" {
return TextErrorResponse("action is required (list, add, remove)"), nil
}
return manageSSHAction(p), nil
})
}
type WebFetchParams struct {
URL string `json:"url" description:"The URL to fetch content from"`
}
func NewWebFetchTool() (*ToolDefinition, error) {
return NewTool("web_fetch",
"Fetch content from a URL and return the text. Useful for reading documentation, APIs, or web resources. Only HTTP/HTTPS URLs are supported.",
func(ctx context.Context, p WebFetchParams) (ToolResponse, error) {
if p.URL == "" {
return TextErrorResponse("url is required"), nil
}
return fetchURL(p.URL), nil
})
}
func DefaultRegistry() *Registry {
r := NewRegistry()
tools := []*ToolDefinition{
must(NewTerminalTool()),
must(NewCrushRunTool()),
must(NewReadFileTool()),
must(NewListFilesTool()),
must(NewSearchFilesTool()),
must(NewGrepContentTool()),
must(NewGetConfigTool()),
must(NewSetProviderTool()),
must(NewManageSSHTool()),
must(NewWebFetchTool()),
}
for _, t := range tools {
if err := r.Register(t); err != nil {
panic(err)
}
}
return r
}
func must(t *ToolDefinition, err error) *ToolDefinition {
if err != nil {
panic(err)
}
return t
}

579
internal/agent/impl.go Normal file
View File

@@ -0,0 +1,579 @@
package agent
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
"time"
)
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 expandHome(path string) string {
if path == "" {
return ""
}
if path == "~" {
home, _ := os.UserHomeDir()
return home
}
if strings.HasPrefix(path, "~/") {
home, _ := os.UserHomeDir()
return filepath.Join(home, path[2:])
}
return path
}
func osUserHomeDir() (string, error) {
return os.UserHomeDir()
}
func readFileLimited(path string, offset, limit int) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
lines := strings.Split(string(data), "\n")
if offset < 0 {
offset = 0
}
if offset > len(lines) {
offset = len(lines)
}
end := offset + limit
if limit <= 0 || limit > 2000 {
limit = 2000
}
if end > len(lines) {
end = len(lines)
}
if end-offset > limit {
end = offset + limit
}
selected := lines[offset:end]
var buf strings.Builder
for i, line := range selected {
fmt.Fprintf(&buf, "%6d\t%s\n", offset+i+1, line)
}
return buf.String(), nil
}
func listDirTree(dir string, maxDepth, currentDepth int) (string, error) {
info, err := os.Stat(dir)
if err != nil {
return "", err
}
if !info.IsDir() {
return dir + "\n", nil
}
entries, err := os.ReadDir(dir)
if err != nil {
return "", err
}
var buf strings.Builder
indent := strings.Repeat(" ", currentDepth)
for _, entry := range entries {
name := entry.Name()
if strings.HasPrefix(name, ".") && name != "." && name != ".." {
continue
}
if entry.IsDir() {
fmt.Fprintf(&buf, "%s%s/\n", indent, name)
if currentDepth < maxDepth {
sub, err := listDirTree(filepath.Join(dir, name), maxDepth, currentDepth+1)
if err == nil {
buf.WriteString(sub)
}
}
} else {
fmt.Fprintf(&buf, "%s%s\n", indent, name)
}
}
return buf.String(), nil
}
func grepFiles(dir, pattern, include string) (string, error) {
if include != "" {
matches, err := filepath.Glob(filepath.Join(dir, include))
if err != nil {
return "", err
}
if len(matches) == 0 {
return "", nil
}
var buf strings.Builder
for _, match := range matches {
result, err := grepInFile(match, pattern)
if err != nil {
continue
}
buf.WriteString(result)
}
return buf.String(), nil
}
return grepInDir(dir, pattern, 0)
}
func grepInDir(dir, pattern string, depth int) (string, error) {
if depth > 10 {
return "", nil
}
var buf strings.Builder
entries, err := os.ReadDir(dir)
if err != nil {
return "", err
}
for _, entry := range entries {
name := entry.Name()
if strings.HasPrefix(name, ".") {
continue
}
path := filepath.Join(dir, name)
if entry.IsDir() {
sub, err := grepInDir(path, pattern, depth+1)
if err == nil {
buf.WriteString(sub)
}
continue
}
result, err := grepInFile(path, pattern)
if err != nil {
continue
}
buf.WriteString(result)
}
return buf.String(), nil
}
func grepInFile(path, pattern string) (string, error) {
re, err := regexp.Compile(pattern)
if err != nil {
re, err = regexp.Compile(regexp.QuoteMeta(pattern))
if err != nil {
return "", err
}
}
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
var buf strings.Builder
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
lineNum := 0
matchCount := 0
for scanner.Scan() {
lineNum++
if re.MatchString(scanner.Text()) {
fmt.Fprintf(&buf, "%s:%d: %s\n", path, lineNum, scanner.Text())
matchCount++
if matchCount >= 50 {
buf.WriteString("... [truncated, more matches exist]\n")
break
}
}
}
return buf.String(), nil
}
func getConfigSection(section string) ToolResponse {
configPath, err := os.UserConfigDir()
if err != nil {
return TextErrorResponse(fmt.Sprintf("cannot find config dir: %v", err))
}
configPath = filepath.Join(configPath, "muyue", "config.yaml")
data, err := os.ReadFile(configPath)
if err != nil {
return TextErrorResponse(fmt.Sprintf("cannot read config: %v", err))
}
switch section {
case "providers", "profile", "tools", "terminal":
sectionData := extractYAMLSection(data, section)
if sectionData == "" {
return TextResponse(fmt.Sprintf("Section '%s' not found in config.", section))
}
return TextResponse(sectionData)
default:
content := string(data)
if len(content) > 8000 {
content = content[:8000] + "\n... [truncated]"
}
return TextResponse(content)
}
}
func extractYAMLSection(data []byte, section string) string {
lines := strings.Split(string(data), "\n")
inSection := false
indentLevel := 0
var buf strings.Builder
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
if inSection {
buf.WriteString("\n")
}
continue
}
if !inSection {
if strings.HasPrefix(trimmed, section+":") || strings.HasPrefix(trimmed, section+" ") {
inSection = true
indentLevel = len(line) - len(strings.TrimLeft(line, " "))
buf.WriteString(line)
buf.WriteString("\n")
}
continue
}
currentIndent := len(line) - len(strings.TrimLeft(line, " "))
if currentIndent <= indentLevel && trimmed != "" {
break
}
buf.WriteString(line)
buf.WriteString("\n")
}
return strings.TrimSpace(buf.String())
}
func setProviderConfig(p SetProviderParams) ToolResponse {
configPath, err := os.UserConfigDir()
if err != nil {
return TextErrorResponse(fmt.Sprintf("cannot find config dir: %v", err))
}
configPath = filepath.Join(configPath, "muyue", "config.yaml")
data, err := os.ReadFile(configPath)
if err != nil {
return TextErrorResponse(fmt.Sprintf("cannot read config: %v", err))
}
lines := strings.Split(string(data), "\n")
inProviders := false
providerIndent := 0
foundProvider := false
insertIdx := -1
lastProviderEnd := -1
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if !inProviders {
if strings.HasPrefix(trimmed, "providers:") {
inProviders = true
providerIndent = len(line) - len(strings.TrimLeft(line, " "))
}
continue
}
currentIndent := len(line) - len(strings.TrimLeft(line, " "))
if currentIndent <= providerIndent && trimmed != "" && !strings.HasPrefix(trimmed, "#") {
lastProviderEnd = i
break
}
if currentIndent == providerIndent+2 && strings.HasPrefix(trimmed, "- name:") {
nameMatch := strings.TrimPrefix(trimmed, "- name:")
nameMatch = strings.TrimSpace(nameMatch)
if nameMatch == p.Name {
foundProvider = true
insertIdx = i
}
if insertIdx == -1 || insertIdx < i {
insertIdx = i
}
}
}
if lastProviderEnd == -1 {
lastProviderEnd = len(lines)
}
entryIndent := strings.Repeat(" ", providerIndent+4)
var newEntry strings.Builder
newEntry.WriteString(fmt.Sprintf(" - name: %s\n", p.Name))
if p.Model != "" {
newEntry.WriteString(fmt.Sprintf("%smodel: %s\n", entryIndent, p.Model))
}
if p.BaseURL != "" {
newEntry.WriteString(fmt.Sprintf("%sbase_url: %s\n", entryIndent, p.BaseURL))
}
if p.APIKey != "" {
newEntry.WriteString(fmt.Sprintf("%sapi_key: %s\n", entryIndent, p.APIKey))
}
if p.Active != nil {
newEntry.WriteString(fmt.Sprintf("%sactive: %v\n", entryIndent, *p.Active))
}
if foundProvider && insertIdx >= 0 {
var endIdx int
for endIdx = insertIdx + 1; endIdx < len(lines); endIdx++ {
li := len(lines[endIdx]) - len(strings.TrimLeft(lines[endIdx], " "))
if li <= providerIndent+2 || lines[endIdx] == "" {
if endIdx > insertIdx+1 && strings.TrimSpace(lines[endIdx]) == "" {
continue
}
break
}
}
newLines := make([]string, 0, len(lines))
newLines = append(newLines, lines[:insertIdx]...)
newLines = append(newLines, strings.TrimSuffix(newEntry.String(), "\n"))
newLines = append(newLines, lines[endIdx:]...)
lines = newLines
} else {
insertAt := lastProviderEnd
newLines := make([]string, 0, len(lines)+10)
newLines = append(newLines, lines[:insertAt]...)
newLines = append(newLines, strings.TrimSuffix(newEntry.String(), "\n"))
newLines = append(newLines, lines[insertAt:]...)
lines = newLines
}
content := strings.Join(lines, "\n")
if err := os.WriteFile(configPath, []byte(content), 0600); err != nil {
return TextErrorResponse(fmt.Sprintf("write config error: %v", err))
}
return TextResponse(fmt.Sprintf("Provider '%s' configured successfully.", p.Name))
}
func manageSSHAction(p ManageSSHParams) ToolResponse {
configPath, err := os.UserConfigDir()
if err != nil {
return TextErrorResponse(fmt.Sprintf("cannot find config dir: %v", err))
}
configPath = filepath.Join(configPath, "muyue", "config.yaml")
data, err := os.ReadFile(configPath)
if err != nil {
return TextErrorResponse(fmt.Sprintf("cannot read config: %v", err))
}
switch p.Action {
case "list":
sshSection := extractYAMLSection(data, "ssh")
if sshSection == "" {
return TextResponse("No SSH connections configured.")
}
return TextResponse(sshSection)
case "add":
if p.Name == "" || p.Host == "" || p.User == "" {
return TextErrorResponse("name, host, and user are required for add action")
}
if p.Port == 0 {
p.Port = 22
}
lines := strings.Split(string(data), "\n")
sshIdx := -1
sshIndent := 0
lastSSHEnd := -1
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if sshIdx == -1 && strings.HasPrefix(trimmed, "ssh:") {
sshIdx = i
sshIndent = len(line) - len(strings.TrimLeft(line, " "))
continue
}
if sshIdx != -1 {
li := len(line) - len(strings.TrimLeft(line, " "))
if li <= sshIndent && trimmed != "" {
lastSSHEnd = i
break
}
}
}
if lastSSHEnd == -1 {
lastSSHEnd = len(lines)
}
entry := fmt.Sprintf(" - name: %s\n host: %s\n port: %d\n user: %s", p.Name, p.Host, p.Port, p.User)
if p.KeyPath != "" {
entry += fmt.Sprintf("\n key_path: %s", p.KeyPath)
}
newLines := make([]string, 0, len(lines)+10)
newLines = append(newLines, lines[:lastSSHEnd]...)
newLines = append(newLines, entry)
newLines = append(newLines, lines[lastSSHEnd:]...)
if err := os.WriteFile(configPath, []byte(strings.Join(newLines, "\n")), 0600); err != nil {
return TextErrorResponse(fmt.Sprintf("write config error: %v", err))
}
return TextResponse(fmt.Sprintf("SSH connection '%s' (%s@%s:%d) added.", p.Name, p.User, p.Host, p.Port))
case "remove":
if p.Name == "" {
return TextErrorResponse("name is required for remove action")
}
lines := strings.Split(string(data), "\n")
newLines := make([]string, 0, len(lines))
skipping := false
removed := false
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.Contains(trimmed, "name: "+p.Name) && strings.HasPrefix(trimmed, "-") {
skipping = true
removed = true
continue
}
if skipping {
li := len(line) - len(strings.TrimLeft(line, " "))
if li > 6 && i < len(lines)-1 && strings.TrimSpace(lines[i+1]) != "" {
continue
}
skipping = false
continue
}
newLines = append(newLines, line)
}
if !removed {
return TextErrorResponse(fmt.Sprintf("SSH connection '%s' not found.", p.Name))
}
if err := os.WriteFile(configPath, []byte(strings.Join(newLines, "\n")), 0600); err != nil {
return TextErrorResponse(fmt.Sprintf("write config error: %v", err))
}
return TextResponse(fmt.Sprintf("SSH connection '%s' removed.", p.Name))
default:
return TextErrorResponse("unknown action. Use 'list', 'add', or 'remove'")
}
}
func fetchURL(url string) ToolResponse {
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
return TextErrorResponse("only http/https URLs are supported")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return TextErrorResponse(fmt.Sprintf("create request: %v", err))
}
req.Header.Set("User-Agent", "MuyueStudio/1.0")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return TextErrorResponse(fmt.Sprintf("fetch error: %v", err))
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 50000))
if err != nil {
return TextErrorResponse(fmt.Sprintf("read error: %v", err))
}
if resp.StatusCode != http.StatusOK {
return TextErrorResponse(fmt.Sprintf("HTTP %d: %s", resp.StatusCode, truncate(string(body), 2000)))
}
contentType := resp.Header.Get("Content-Type")
if strings.Contains(contentType, "text/html") {
text := stripHTML(string(body))
if len(text) > 8000 {
text = text[:8000] + "\n... [truncated]"
}
return TextResponse(text)
}
result := string(body)
if len(result) > 10000 {
result = result[:10000] + "\n... [truncated]"
}
return TextResponse(result)
}
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}
func stripHTML(html string) string {
tagRe := regexp.MustCompile(`<[^>]*>`)
text := tagRe.ReplaceAllString(html, " ")
entityRe := regexp.MustCompile(`&[a-zA-Z]+;`)
text = entityRe.ReplaceAllStringFunc(text, func(s string) string {
switch s {
case "&amp;":
return "&"
case "&lt;":
return "<"
case "&gt;":
return ">"
case "&quot;":
return "\""
case "&#39;":
return "'"
case "&nbsp;":
return " "
default:
return " "
}
})
multiSpace := regexp.MustCompile(`\s+`)
text = multiSpace.ReplaceAllString(text, " ")
return strings.TrimSpace(text)
}
var _ = runtime.GOOS
var _ = json.Marshal

10
internal/agent/prompt.go Normal file
View File

@@ -0,0 +1,10 @@
package agent
import _ "embed"
//go:embed prompts/studio_system.md
var studioSystemPrompt string
func StudioSystemPrompt() string {
return studioSystemPrompt
}

View File

@@ -0,0 +1,44 @@
Tu es l'assistant IA de **Muyue Studio**, le centre de commandement de l'environnement de développement de l'utilisateur.
Tu es intégré dans Muyue, un gestionnaire d'environnement de développement de bureau. Ton rôle est d'aider l'utilisateur à configurer, gérer et optimiser son environnement dev.
## Environnement
Muyue gère :
- **Fournisseurs IA** (OpenAI, Anthropic, Ollama, MiniMax, etc.)
- **Outils de développement** (Crush, Claude Code, etc.)
- **Terminaux locaux et SSH**
- **Configuration et préférences**
- **Serveurs MCP et LSP**
## Outils disponibles
Tu as accès à des outils. Utilise-les concrètement, ne décris pas ce que tu ferais — fais-le.
- **terminal** : Exécuter des commandes shell (builds, tests, git, etc.)
- **crush_run** : Déléguer une tâche complexe à l'agent Crush (édition de fichiers, refactoring, debug)
- **read_file** : Lire le contenu d'un fichier
- **list_files** : Lister les fichiers d'un répertoire
- **search_files** : Chercher des fichiers par motif (glob)
- **grep_content** : Chercher du texte dans le contenu des fichiers
- **get_config** : Lire la configuration Muyue
- **set_provider** : Configurer un fournisseur IA
- **manage_ssh** : Gérer les connexions SSH
- **web_fetch** : Récupérer le contenu d'une URL
## Règles
1. **AGIS, ne décris pas** — Si l'utilisateur demande de faire quelque chose, utilise les outils pour le faire. Ne dis pas "je pourrais faire X" — fais-le.
2. **Sois concis** — Pas de préambule, pas de blabla. Réponse directe.
3. **Une chose à la fois** — N'appelle pas plusieurs outils simultanément sauf si c'est nécessaire.
4. **Gère les erreurs** — Si un outil échoue, essaie une approche différente avant de le dire à l'utilisateur.
5. **Ne devine pas** — Si tu n'as pas assez d'informations, utilise les outils pour les obtenir (lire un fichier, chercher, etc.)
6. **Confidentialité** — Ne révèle jamais les clés API, mots de passe ou informations sensibles dans tes réponses.
7. **Langue** — Réponds dans la même langue que l'utilisateur.
## Format des réponses
- Code : utilise des blocs markdown
- Résultats d'outils : résume les points clés, ne colle pas des milliers de lignes
- Erreurs : explique clairement et propose une solution
- Succès : confirme brièvement ce qui a été fait

218
internal/agent/tools.go Normal file
View File

@@ -0,0 +1,218 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"reflect"
"strings"
)
type ToolCall struct {
ID string `json:"id"`
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
type ToolResponse struct {
Content string `json:"content"`
IsError bool `json:"is_error"`
Meta map[string]string `json:"meta,omitempty"`
}
func TextResponse(content string) ToolResponse {
return ToolResponse{Content: content}
}
func TextErrorResponse(msg string) ToolResponse {
return ToolResponse{Content: msg, IsError: true}
}
type ToolDefinition struct {
Name string `json:"name"`
Description string `json:"description"`
Params json.RawMessage `json:"parameters"`
Handler func(ctx context.Context, args json.RawMessage) (ToolResponse, error)
}
func (td *ToolDefinition) Execute(ctx context.Context, call ToolCall) (ToolResponse, error) {
resp, err := td.Handler(ctx, call.Arguments)
if err != nil {
return ToolResponse{Content: err.Error(), IsError: true}, nil
}
return resp, nil
}
func (td *ToolDefinition) ToOpenAITool() map[string]interface{} {
return map[string]interface{}{
"type": "function",
"function": map[string]interface{}{
"name": td.Name,
"description": td.Description,
"parameters": td.Params,
},
}
}
func NewTool[P any](name, description string, handler func(ctx context.Context, params P) (ToolResponse, error)) (*ToolDefinition, error) {
var zero P
paramsSchema, err := generateSchema(zero)
if err != nil {
return nil, fmt.Errorf("generate schema for %s: %w", name, err)
}
wrappedHandler := func(ctx context.Context, raw json.RawMessage) (ToolResponse, error) {
var params P
if err := json.Unmarshal(raw, &params); err != nil {
return TextErrorResponse(fmt.Sprintf("invalid arguments: %v", err)), nil
}
return handler(ctx, params)
}
return &ToolDefinition{
Name: name,
Description: description,
Params: paramsSchema,
Handler: wrappedHandler,
}, nil
}
type Registry struct {
tools map[string]*ToolDefinition
}
func NewRegistry() *Registry {
return &Registry{
tools: make(map[string]*ToolDefinition),
}
}
func (r *Registry) Register(tool *ToolDefinition) error {
if _, exists := r.tools[tool.Name]; exists {
return fmt.Errorf("tool %q already registered", tool.Name)
}
r.tools[tool.Name] = tool
return nil
}
func (r *Registry) Get(name string) (*ToolDefinition, bool) {
t, ok := r.tools[name]
return t, ok
}
func (r *Registry) All() []*ToolDefinition {
out := make([]*ToolDefinition, 0, len(r.tools))
for _, t := range r.tools {
out = append(out, t)
}
return out
}
func (r *Registry) OpenAITools() []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(r.tools))
for _, t := range r.tools {
out = append(out, t.ToOpenAITool())
}
return out
}
func (r *Registry) Execute(ctx context.Context, call ToolCall) (ToolResponse, error) {
tool, ok := r.tools[call.Name]
if !ok {
return TextErrorResponse(fmt.Sprintf("unknown tool: %s", call.Name)), nil
}
return tool.Execute(ctx, call)
}
func generateSchema(v interface{}) (json.RawMessage, error) {
t := reflect.TypeOf(v)
if t == nil {
return json.RawMessage(`{"type":"object","properties":{}}`), nil
}
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return json.RawMessage(`{"type":"object","properties":{}}`), nil
}
props := make(map[string]interface{})
required := []string{}
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if !field.IsExported() {
continue
}
jsonTag := field.Tag.Get("json")
if jsonTag == "-" {
continue
}
jsonName := field.Name
parts := strings.Split(jsonTag, ",")
if parts[0] != "" {
jsonName = parts[0]
}
omitempty := false
for _, part := range parts[1:] {
if part == "omitempty" {
omitempty = true
}
}
desc := field.Tag.Get("description")
prop := map[string]interface{}{
"type": goTypeToJSON(field.Type),
}
if desc != "" {
prop["description"] = desc
}
props[jsonName] = prop
if !omitempty {
required = append(required, jsonName)
}
}
schema := map[string]interface{}{
"type": "object",
"properties": props,
}
if len(required) > 0 {
schema["required"] = required
}
data, err := json.Marshal(schema)
if err != nil {
return nil, err
}
return json.RawMessage(data), nil
}
func goTypeToJSON(t reflect.Type) string {
switch t.Kind() {
case reflect.String:
return "string"
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return "integer"
case reflect.Float32, reflect.Float64:
return "number"
case reflect.Bool:
return "boolean"
case reflect.Slice:
if t.Elem().Kind() == reflect.Uint8 {
return "string"
}
return "array"
case reflect.Map:
return "object"
default:
return "string"
}
}

View File

@@ -1,17 +1,16 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os/exec"
"regexp"
"strings"
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/orchestrator"
)
var toolCallRegex = regexp.MustCompile(`\[TOOL_CALL:\{[^\}]+\}\]`)
const maxToolIterations = 15
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
@@ -27,7 +26,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
return
}
if body.Message == "" {
writeError(w, "no message", http.StatusBadRequest)
writeError(w, "no message", http.StatusMethodNotAllowed)
return
}
@@ -42,143 +41,189 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
writeError(w, err.Error(), http.StatusServiceUnavailable)
return
}
orb.SetSystemPrompt(`Tu es l'assistant IA de Muyue Studio. Tu as accès à un outil "crush" pour exécuter des tâches complexes sur l'ordinateur de l'utilisateur.
RÈGLES ABSOLUES:
1. Tu as DEUX possibilités ONLY:
- Répondre directement à l'utilisateur avec tes connaissances
- Demander l'exécution d'une tâche via crush en utilisant ce format EXACT:
[TOOL_CALL:{"tool":"crush","task":"description de la tâche"}]
2. Quand tu utilises [TOOL_CALL:...], le système exécutera la tâche et te donnera le résultat.
Tu peux ensuite répondre à l'utilisateur avec ce résultat.
3. SOIS CONCIS - pas de blabla, vais droit au but.
4. L'utilisateur ne voit PAS tes pensées entre <think> tags.
5. EXEMPLES d'utilisation de tool:
- "cherche tous les fichiers .md dans le projet" → [TOOL_CALL:{"tool":"crush","task":"Recherche les fichiers .md dans le projet courant"}]
- "aide-moi à déboguer cette erreur" → tu peux répondre directement si tu as assez d'info, sinon utiliser tool
- "quelle est la météo?" → [TOOL_CALL:{"tool":"crush","task":"Cherche la météo actuelle"}]
6. Ne fais PAS de multi-step tool calls dans une seule réponse. Attends le résultat avant de continuer.`)
orb.SetSystemPrompt(agent.StudioSystemPrompt())
orb.SetTools(s.agentToolsJSON)
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)
s.handleStreamChat(w, orb, body.Message)
} else {
s.handleNonStreamChat(w, orb, body.Message)
}
}
result, err := orb.SendStream(body.Message, func(chunk string) {
if strings.HasPrefix(chunk, "<think") {
data, _ := json.Marshal(map[string]string{"thinking": strings.TrimPrefix(chunk, "<think")})
w.Write([]byte("data: " + string(data) + "\n\n"))
if canFlush {
flusher.Flush()
}
return
}
if chunk == "</think>" {
data, _ := json.Marshal(map[string]string{"thinking_end": "true"})
w.Write([]byte("data: " + string(data) + "\n\n"))
if canFlush {
flusher.Flush()
}
return
}
data, _ := json.Marshal(map[string]string{"content": chunk})
w.Write([]byte("data: " + string(data) + "\n\n"))
if canFlush {
flusher.Flush()
}
})
if err != nil {
data, _ := json.Marshal(map[string]string{"error": err.Error()})
w.Write([]byte("data: " + string(data) + "\n\n"))
if canFlush {
flusher.Flush()
}
return
}
func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orchestrator, userMessage string) {
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)
// Process tool calls if any
cleanResult := processToolCalls(result)
s.convStore.Add("assistant", cleanResult)
data, _ := json.Marshal(map[string]string{"done": "true"})
w.Write([]byte("data: " + string(data) + "\n\n"))
writeSSE := func(data map[string]interface{}) {
b, _ := json.Marshal(data)
w.Write([]byte("data: " + string(b) + "\n\n"))
if canFlush {
flusher.Flush()
}
return
}
result, err := orb.Send(body.Message)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
ctx := context.Background()
messages := []orchestrator.Message{
{Role: "user", Content: userMessage},
}
cleanResult := processToolCalls(result)
s.convStore.Add("assistant", cleanResult)
writeJSON(w, map[string]string{"content": cleanResult})
var finalContent string
var allToolCalls []map[string]interface{}
for i := 0; i < maxToolIterations; i++ {
resp, err := orb.SendWithTools(messages)
if err != nil {
writeSSE(map[string]interface{}{"error": err.Error()})
return
}
choice := resp.Choices[0]
content := cleanThinkingTags(choice.Message.Content)
if content != "" {
for _, ch := range strings.Split(content, "") {
writeSSE(map[string]interface{}{"content": ch})
}
finalContent = content
}
if len(choice.Message.ToolCalls) == 0 {
break
}
assistantMsg := orchestrator.Message{
Role: "assistant",
Content: content,
ToolCalls: choice.Message.ToolCalls,
}
messages = append(messages, assistantMsg)
for _, tc := range choice.Message.ToolCalls {
toolCallData := map[string]interface{}{
"tool_call_id": tc.ID,
"name": tc.Function.Name,
"args": tc.Function.Arguments,
}
allToolCalls = append(allToolCalls, toolCallData)
writeSSE(map[string]interface{}{"tool_call": toolCallData})
call := agent.ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Arguments: json.RawMessage(tc.Function.Arguments),
}
result, execErr := s.agentRegistry.Execute(ctx, call)
if execErr != nil {
result = agent.ToolResponse{
Content: execErr.Error(),
IsError: true,
}
}
resultData := map[string]interface{}{
"tool_call_id": tc.ID,
"content": result.Content,
"is_error": result.IsError,
}
writeSSE(map[string]interface{}{"tool_result": resultData})
messages = append(messages, orchestrator.Message{
Role: "tool",
Content: result.Content,
ToolCallID: tc.ID,
Name: tc.Function.Name,
})
}
finalContent = ""
}
storeContent := finalContent
if len(allToolCalls) > 0 {
storeObj := map[string]interface{}{"content": storeContent, "tool_calls": allToolCalls}
storeJSON, _ := json.Marshal(storeObj)
storeContent = string(storeJSON)
}
s.convStore.Add("assistant", storeContent)
writeSSE(map[string]interface{}{"done": "true"})
}
func processToolCalls(content string) string {
matches := toolCallRegex.FindAllString(content, -1)
if len(matches) == 0 {
return cleanThinkingTags(content)
func (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Orchestrator, userMessage string) {
ctx := context.Background()
messages := []orchestrator.Message{
{Role: "user", Content: userMessage},
}
var result strings.Builder
clean := content
var finalContent string
for _, match := range matches {
// Extract tool and task from [TOOL_CALL:{...}]
inner := strings.TrimPrefix(match, "[TOOL_CALL:")
inner = strings.TrimSuffix(inner, "]}") + "}"
var call struct {
Tool string `json:"tool"`
Task string `json:"task"`
}
if err := json.Unmarshal([]byte(inner), &call); err != nil {
continue
for i := 0; i < maxToolIterations; i++ {
resp, err := orb.SendWithTools(messages)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
if call.Tool == "crush" && call.Task != "" {
result.WriteString(fmt.Sprintf("> %s\n\n", call.Task))
output := executeCrush(call.Task)
result.WriteString(output)
result.WriteString("\n\n---\n\n")
choice := resp.Choices[0]
content := cleanThinkingTags(choice.Message.Content)
if content != "" {
finalContent = content
}
clean = strings.Replace(clean, match, "", 1)
if len(choice.Message.ToolCalls) == 0 {
break
}
assistantMsg := orchestrator.Message{
Role: "assistant",
Content: content,
ToolCalls: choice.Message.ToolCalls,
}
messages = append(messages, assistantMsg)
for _, tc := range choice.Message.ToolCalls {
call := agent.ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Arguments: json.RawMessage(tc.Function.Arguments),
}
result, execErr := s.agentRegistry.Execute(ctx, call)
if execErr != nil {
result = agent.ToolResponse{
Content: execErr.Error(),
IsError: true,
}
}
messages = append(messages, orchestrator.Message{
Role: "tool",
Content: result.Content,
ToolCallID: tc.ID,
Name: tc.Function.Name,
})
}
finalContent = ""
}
clean = cleanThinkingTags(clean)
if result.Len() > 0 {
clean = strings.TrimSpace(clean) + "\n\n" + strings.TrimSpace(result.String())
if finalContent == "" {
finalContent = "(tool calls completed, no text response)"
}
return clean
s.convStore.Add("assistant", finalContent)
writeJSON(w, map[string]string{"content": finalContent})
}
func cleanThinkingTags(content string) string {
re := regexp.MustCompile(`(?s)<think[^>]*>.*?</think>`)
return re.ReplaceAllString(content, "")
}
func executeCrush(task string) string {
cmd := exec.Command("crush", "run", task)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Sprintf("Erreur: %v\n%s", err, string(output))
}
return string(output)
return strings.ReplaceAll(content, "<think", "")
}
func (s *Server) autoSummarize() {
@@ -233,4 +278,4 @@ func (s *Server) handleChatClear(w http.ResponseWriter, r *http.Request) {
}
s.convStore.Clear()
writeJSON(w, map[string]string{"status": "ok"})
}
}

View File

@@ -3,8 +3,11 @@ package api
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
@@ -281,3 +284,203 @@ func (s *Server) handleGetTerminalThemes(w http.ResponseWriter, r *http.Request)
}
writeJSON(w, map[string]interface{}{"themes": themes})
}
func (s *Server) handleResetConfig(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
dir, err := config.ConfigDir()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
path := filepath.Join(dir, "config.yaml")
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
s.config = config.Default()
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleApplyStarshipTheme(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Theme string `json:"theme"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Theme == "" {
body.Theme = s.config.Terminal.PromptTheme
}
cfgDir, err := config.ConfigDir()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
starshipDir := filepath.Join(cfgDir, "starship")
if err := os.MkdirAll(starshipDir, 0755); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
themeFile := filepath.Join(starshipDir, "starship.toml")
themeContent := getStarshipThemeConfig(body.Theme)
if err := os.WriteFile(themeFile, []byte(themeContent), 0644); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
home, _ := os.UserHomeDir()
shellRCs := []string{
filepath.Join(home, ".bashrc"),
filepath.Join(home, ".zshrc"),
}
for _, rc := range shellRCs {
if _, err := os.Stat(rc); err != nil {
continue
}
content, _ := os.ReadFile(rc)
if strings.Contains(string(content), "STARSHIP_CONFIG") {
continue
}
exportLine := fmt.Sprintf("\n# Muyue Starship config\nexport STARSHIP_CONFIG=%s\n", themeFile)
f, err := os.OpenFile(rc, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
continue
}
f.WriteString(exportLine)
f.Close()
}
s.config.Terminal.PromptTheme = body.Theme
config.Save(s.config)
writeJSON(w, map[string]interface{}{"status": "ok", "config": themeFile})
}
func getStarshipThemeConfig(theme string) string {
switch theme {
case "charm":
return `[format]
before_format = "$"
format = """
$username$directory$git_branch$git_status$cmd_duration$line_break$character"""
[character]
success_symbol = "[➜](bold #00E676)"
error_symbol = "[✗](bold #FF0033)"
[directory]
truncation_length = 3
truncation_symbol = "…/"
style = "bold #00BCD4"
[username]
show_on_left = false
style_user = "bold #FF0033"
style_root = "bold #FF0033"
[git_branch]
symbol = " "
format = "on [$symbol$branch]($style)"
style = "bold #FFD740"
[git_status]
format = "[$all_status$ahead_behind]($style) "
style = "bold #FF1A5E"
conflicted = "!"
untracked = "?"
modified = "~"
staged = "[+]"
renamed = "»"
deleted = "-"
[cmd_duration]
min_time = 500
format = "took [$duration]($style)"
style = "bold #75715E"
`
case "zerotwo":
return `[format]
before_format = "$"
format = """
$username$directory$git_branch$git_status$cmd_duration$line_break$character"""
[character]
success_symbol = "[](bold #3B82F6)"
error_symbol = "[](bold #EF4444)"
[directory]
truncation_length = 3
truncation_symbol = "…/"
style = "bold #8B5CF6"
[username]
show_on_left = false
style_user = "bold #EC4899"
style_root = "bold #EF4444"
[git_branch]
symbol = " "
format = "on [$symbol$branch]($style)"
style = "bold #F472B6"
[git_status]
format = "[$all_status$ahead_behind]($style) "
style = "bold #EF4444"
conflicted = "!"
untracked = "?"
modified = "~"
staged = "[+]"
renamed = "»"
deleted = "-"
[cmd_duration]
min_time = 500
format = "took [$duration]($style)"
style = "bold #6B7280"
`
default:
return `[format]
before_format = "$"
format = """
$username$directory$git_branch$git_status$line_break$character"""
[character]
success_symbol = "[](bold green)"
error_symbol = "[](bold red)"
[directory]
truncation_length = 3
truncation_symbol = "…/"
style = "bold cyan"
[username]
show_on_left = false
style_user = "bold red"
style_root = "bold red"
[git_branch]
symbol = " "
format = "on [$symbol$branch]($style)"
style = "bold yellow"
[cmd_duration]
min_time = 500
format = "took [$duration]($style)"
style = "bold bright-black"
`
}
}

View File

@@ -117,3 +117,8 @@ func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) {
s.scanResult = scanner.ScanSystem()
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleEditors(w http.ResponseWriter, r *http.Request) {
editors := scanner.ScanEditors()
writeJSON(w, map[string]interface{}{"editors": editors})
}

View File

@@ -1,18 +1,22 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/scanner"
)
type Server struct {
config *config.MuyueConfig
scanResult *scanner.ScanResult
mux *http.ServeMux
convStore *ConversationStore
config *config.MuyueConfig
scanResult *scanner.ScanResult
mux *http.ServeMux
convStore *ConversationStore
agentRegistry *agent.Registry
agentToolsJSON json.RawMessage
}
func NewServer(cfg *config.MuyueConfig) *Server {
@@ -22,6 +26,10 @@ func NewServer(cfg *config.MuyueConfig) *Server {
}
s.scanResult = scanner.ScanSystem()
s.convStore = NewConversationStore()
s.agentRegistry = agent.DefaultRegistry()
tools := s.agentRegistry.OpenAITools()
toolsJSON, _ := json.Marshal(tools)
s.agentToolsJSON = json.RawMessage(toolsJSON)
s.routes()
return s
}
@@ -38,6 +46,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/updates", s.handleUpdates)
s.mux.HandleFunc("/api/install", s.handleInstall)
s.mux.HandleFunc("/api/scan", s.handleScan)
s.mux.HandleFunc("/api/editors", s.handleEditors)
s.mux.HandleFunc("/api/preferences", s.handleUpdatePreferences)
s.mux.HandleFunc("/api/terminal", s.handleTerminal)
s.mux.HandleFunc("/api/ws/terminal", s.handleTerminalWS)
@@ -48,6 +57,8 @@ func (s *Server) routes() {
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/config/reset", s.handleResetConfig)
s.mux.HandleFunc("/api/starship/apply-theme", s.handleApplyStarshipTheme)
s.mux.HandleFunc("/api/providers/validate", s.handleValidateProvider)
s.mux.HandleFunc("/api/update/run", s.handleRunUpdate)
s.mux.HandleFunc("/api/chat", s.handleChat)

View File

@@ -56,13 +56,17 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
var initMsg wsMessage
_, raw, err := conn.ReadMessage()
if err != nil {
log.Printf("terminal: read init message failed: %v", err)
conn.WriteJSON(wsMessage{Type: "error", Data: "failed to read init message"})
return
}
log.Printf("terminal: init message received: %s", string(raw))
if err := json.Unmarshal(raw, &initMsg); err != nil {
log.Printf("terminal: unmarshal init message failed: %v", err)
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid init message"})
return
}
log.Printf("terminal: init type=%q data=%q", initMsg.Type, initMsg.Data)
var cmd *exec.Cmd
@@ -96,23 +100,26 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
cmd = exec.Command("ssh", sshArgs...)
} else {
shell := initMsg.Data
shell := strings.TrimSpace(initMsg.Data)
log.Printf("terminal: requested shell=%q, trimmed=%q", initMsg.Data, shell)
if shell == "" {
shell = detectShell()
} else {
if path, err := exec.LookPath(shell); err == nil {
shell = path
}
log.Printf("terminal: auto-detected shell=%q", shell)
}
// 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 shell == "" {
log.Printf("terminal: no shell detected, falling back to /bin/sh")
shell = "/bin/sh"
}
if path, err := exec.LookPath(shell); err == nil {
shell = path
log.Printf("terminal: resolved shell path=%q", shell)
}
if _, err := os.Stat(shell); err != nil {
conn.WriteJSON(wsMessage{Type: "error", Data: fmt.Sprintf("shell not found: %s", shell)})
log.Printf("terminal: shell stat failed: %v for %q", err, shell)
conn.WriteJSON(wsMessage{Type: "error", Data: fmt.Sprintf("shell not found: %s (resolved from: %q)", shell, initMsg.Data)})
return
}
@@ -131,12 +138,14 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
log.Printf("terminal: starting pty with cmd=%q args=%v", cmd.Path, cmd.Args)
ptmx, err := pty.Start(cmd)
if err != nil {
log.Printf("pty start: %v", err)
log.Printf("terminal: pty start failed: %v", err)
conn.WriteJSON(wsMessage{Type: "error", Data: err.Error()})
return
}
log.Printf("terminal: pty started successfully")
defer func() {
ptmx.Close()
if cmd.Process != nil {

View File

@@ -20,24 +20,42 @@ var thinkRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
const maxHistorySize = 100
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
Role string `json:"role"`
Content string `json:"content,omitempty"`
ToolCalls []ToolCallMsg `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
Name string `json:"name,omitempty"`
}
type ToolCallMsg struct {
ID string `json:"id"`
Type string `json:"type"`
Function ToolCallFuncMsg `json:"function"`
}
type ToolCallFuncMsg struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
type ChatRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Stream bool `json:"stream"`
Model string `json:"model"`
Messages []Message `json:"messages"`
Stream bool `json:"stream"`
Tools json.RawMessage `json:"tools,omitempty"`
}
type ChatResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
Content string `json:"content"`
ToolCalls []ToolCallMsg `json:"tool_calls"`
} `json:"message"`
Delta struct {
Content string `json:"content"`
Content string `json:"content"`
ToolCalls []ToolCallMsg `json:"tool_calls"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
Usage struct {
TotalTokens int `json:"total_tokens"`
@@ -51,6 +69,7 @@ type Orchestrator struct {
history []Message
histMu sync.Mutex
systemPrompt string
tools json.RawMessage
}
var sharedHTTPClient = &http.Client{
@@ -86,6 +105,34 @@ func (o *Orchestrator) SetSystemPrompt(prompt string) {
o.systemPrompt = prompt
}
func (o *Orchestrator) SetTools(tools json.RawMessage) {
o.tools = tools
}
func (o *Orchestrator) ProviderName() string {
if o.provider == nil {
return ""
}
return o.provider.Name
}
func (o *Orchestrator) AppendHistory(msg Message) {
o.histMu.Lock()
defer o.histMu.Unlock()
o.history = append(o.history, msg)
if len(o.history) > maxHistorySize {
o.history = o.history[len(o.history)-maxHistorySize:]
}
}
func (o *Orchestrator) GetHistory() []Message {
o.histMu.Lock()
defer o.histMu.Unlock()
out := make([]Message, len(o.history))
copy(out, o.history)
return out
}
func (o *Orchestrator) Send(userMessage string) (string, error) {
o.histMu.Lock()
o.history = append(o.history, Message{
@@ -107,6 +154,7 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
Model: o.provider.Model,
Messages: messages,
Stream: false,
Tools: o.tools,
}
o.histMu.Unlock()
@@ -186,6 +234,7 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str
Model: o.provider.Model,
Messages: messages,
Stream: true,
Tools: o.tools,
}
o.histMu.Unlock()
@@ -263,6 +312,67 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str
return content, nil
}
func (o *Orchestrator) SendWithTools(messages []Message) (*ChatResponse, error) {
fullMessages := make([]Message, 0, len(messages)+1)
if o.systemPrompt != "" {
fullMessages = append(fullMessages, Message{Role: "system", Content: o.systemPrompt})
}
fullMessages = append(fullMessages, messages...)
reqBody := ChatRequest{
Model: o.provider.Model,
Messages: fullMessages,
Stream: false,
Tools: o.tools,
}
body, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
baseURL := o.provider.BaseURL
if baseURL == "" {
baseURL = getProviderBaseURL(o.provider.Name)
}
url := strings.TrimRight(baseURL, "/") + "/chat/completions"
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+o.provider.APIKey)
resp, err := o.client.Do(req)
if err != nil {
return nil, fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
}
var chatResp ChatResponse
if err := json.Unmarshal(respBody, &chatResp); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
if len(chatResp.Choices) == 0 {
return nil, fmt.Errorf("no response from AI")
}
return &chatResp, nil
}
func cleanAIResponse(content string) string {
content = thinkRegex.ReplaceAllString(content, "")
lines := strings.Split(content, "\n")

View File

@@ -14,13 +14,13 @@ import (
)
type ToolStatus struct {
Name string `yaml:"name"`
Installed bool `yaml:"installed"`
Version string `yaml:"version"`
Path string `yaml:"path"`
Latest string `yaml:"latest"`
NeedsUpdate bool `yaml:"needs_update"`
Category string `yaml:"category"`
Name string `yaml:"name"`
Installed bool `yaml:"installed"`
Version string `yaml:"version"`
Path string `yaml:"path"`
Latest string `yaml:"latest"`
NeedsUpdate bool `yaml:"needs_update"`
Category string `yaml:"category"`
}
type RuntimeStatus struct {
@@ -30,15 +30,15 @@ type RuntimeStatus struct {
}
type ScanResult struct {
System platform.SystemInfo `yaml:"system"`
Tools []ToolStatus `yaml:"tools"`
Runtimes []RuntimeStatus `yaml:"runtimes"`
ShellSetup bool `yaml:"shell_setup"`
GitConfigured bool `yaml:"git_configured"`
System platform.SystemInfo `yaml:"system"`
Tools []ToolStatus `yaml:"tools"`
Runtimes []RuntimeStatus `yaml:"runtimes"`
ShellSetup bool `yaml:"shell_setup"`
GitConfigured bool `yaml:"git_configured"`
}
var (
cacheMu sync.RWMutex
cacheMu sync.RWMutex
cacheResult *ScanResult
cacheTime time.Time
cacheTTL = 5 * time.Minute
@@ -193,6 +193,43 @@ func checkGitConfig() bool {
return true
}
var editorsList = []struct {
name string
cmd []string
version []string
}{
{"vim", []string{"vim"}, []string{"--version"}},
{"nvim", []string{"nvim"}, []string{"--version"}},
{"code", []string{"code"}, []string{"--version"}},
{"emacs", []string{"emacs"}, []string{"--version"}},
{"nano", []string{"nano"}, []string{"--version"}},
{"helix", []string{"hx"}, []string{"--version"}},
{"subl", []string{"subl"}, []string{"--version"}},
{"zed", []string{"zed"}, []string{"--version"}},
}
func ScanEditors() []ToolStatus {
var results []ToolStatus
for _, e := range editorsList {
status := ToolStatus{Name: e.name}
path, err := exec.LookPath(e.name)
if err != nil {
continue
}
status.Installed = true
status.Path = path
if len(e.version) > 0 {
cmd := exec.Command(e.cmd[0], e.version...)
out, err := cmd.Output()
if err == nil {
status.Version = strings.TrimSpace(string(out))
}
}
results = append(results, status)
}
return results
}
var versionRegex = regexp.MustCompile(`\d+\.\d+\.\d+`)
func (s *ScanResult) Summary() string {

View File

@@ -2,7 +2,7 @@ package version
const (
Name = "muyue"
Version = "0.3.1"
Version = "0.3.2"
Author = "La Légion de Muyue"
)

View File

@@ -22,12 +22,15 @@ const api = {
getLSP: () => request('/lsp'),
getMCP: () => request('/mcp'),
getUpdates: () => request('/updates'),
getEditors: () => request('/editors'),
runScan: () => request('/scan', { method: 'POST' }),
installTools: (tools) => request('/install', { method: 'POST', body: JSON.stringify({ tools }) }),
configureMCP: () => request('/mcp/configure', { method: 'POST' }),
savePreferences: (prefs) => request('/preferences', { method: 'PUT', body: JSON.stringify(prefs) }),
saveProfile: (profile) => request('/config/profile', { method: 'PUT', body: JSON.stringify(profile) }),
saveProvider: (provider) => request('/config/provider', { method: 'PUT', body: JSON.stringify(provider) }),
resetConfig: () => request('/config/reset', { method: 'POST' }),
applyStarshipTheme: (theme) => request('/starship/apply-theme', { method: 'POST', body: JSON.stringify({ theme }) }),
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 }) }),
@@ -71,6 +74,8 @@ const api = {
if (onChunk) onChunk(full, data)
} else if (data.thinking !== undefined || data.thinking_end) {
if (onChunk) onChunk(full, data)
} else if (data.tool_call || data.tool_result) {
if (onChunk) onChunk(full, data)
}
} catch {}
}

View File

@@ -7,6 +7,7 @@ import Dashboard from './Dashboard'
import Studio from './Studio'
import Shell from './Shell'
import Config from './Config'
import OnboardingWizard from './OnboardingWizard'
export default function App() {
const [activeTab, setActiveTab] = useState('dash')
@@ -15,6 +16,7 @@ export default function App() {
const [updates, setUpdates] = useState([])
const [tools, setTools] = useState([])
const [config, setConfig] = useState(null)
const [showOnboarding, setShowOnboarding] = useState(false)
const { t, layout } = useI18n()
const TABS = useMemo(() => [
@@ -32,8 +34,11 @@ export default function App() {
setConfig(d)
const theme = d.profile?.preferences?.theme || 'cyberpunk-red'
applyTheme(getTheme(theme))
const hasProfile = d.profile?.name || d.profile?.pseudo
if (!hasProfile) setShowOnboarding(true)
}).catch(() => {
applyTheme(getTheme('cyberpunk-red'))
setShowOnboarding(true)
})
}, [])
@@ -150,6 +155,8 @@ export default function App() {
</span>
</div>
</footer>
{showOnboarding && <OnboardingWizard api={api} onComplete={() => setShowOnboarding(false)} />}
</div>
)
}

View File

@@ -6,10 +6,10 @@ 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 },
{ id: 'system', icon: Monitor },
]
export default function Config({ api }) {
@@ -25,11 +25,9 @@ export default function Config({ api }) {
const [editProfile, setEditProfile] = useState(false)
const [editProvider, setEditProvider] = useState(null)
const [profileForm, setProfileForm] = useState({})
const [providerForm, setProviderForm] = useState({})
const [providerForm, setProviderForm] = useState({}) // keyed by provider name
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)
const layouts = getLayoutList()
@@ -43,19 +41,13 @@ export default function Config({ api }) {
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])
@@ -115,9 +107,11 @@ export default function Config({ api }) {
}
}
const handleSaveProvider = async () => {
const handleSaveProvider = async (name) => {
const form = providerForm[name]
if (!form) return
try {
await api.saveProvider(providerForm)
await api.saveProvider({ name, ...form })
setEditProvider(null)
loadData()
showToast(t('config.saved'))
@@ -126,25 +120,16 @@ export default function Config({ api }) {
}
}
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 || '',
})
setProviderForm(prev => ({
...prev,
[p.name]: {
name: p.name,
api_key: p.apiKey || '',
model: p.model || '',
base_url: p.baseURL || '',
},
}))
setEditProvider(p.name)
}
@@ -213,13 +198,10 @@ export default function Config({ api }) {
{activePanel === 'skills' && (
<PanelSkills skillList={skillList} t={t} />
)}
{activePanel === 'terminal' && (
<PanelTerminal
settings={terminalSettings} setSettings={setTerminalSettings}
themes={terminalThemes} saving={savingTerminal}
onSave={handleSaveTerminalSettings} t={t}
/>
{activePanel === 'system' && (
<PanelSystem api={api} t={t} />
)}
</div>
</div>
</div>
@@ -325,23 +307,26 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
className="config-form-input"
type="password"
placeholder={t('config.tokenPlaceholder')}
value={isEditing ? providerForm.api_key : ''}
value={isEditing ? (providerForm[p.name]?.api_key || '') : ''}
onChange={e => {
if (!isEditing) openProviderEdit(p)
setProviderForm(f => ({ ...f, api_key: e.target.value }))
setProviderForm(prev => ({
...prev,
[p.name]: { ...(prev[p.name] || {}), api_key: e.target.value },
}))
}}
/>
</div>
<div className="provider-setup-token-actions">
<button
className="sm primary"
disabled={validating === p.name || !providerForm.api_key}
onClick={() => handleValidate(p.name, providerForm.api_key, providerForm.model, providerForm.base_url)}
disabled={validating === p.name || !providerForm[p.name]?.api_key}
onClick={() => handleValidate(p.name, providerForm[p.name]?.api_key, providerForm[p.name]?.model, providerForm[p.name]?.base_url)}
>
{validating === p.name ? t('config.validating') : t('config.validateKey')}
</button>
{isValidationTarget && validationStatus?.valid && (
<button className="sm" onClick={handleSaveProvider}>{t('config.save')}</button>
<button className="sm" onClick={() => handleSaveProvider(p.name)}>{t('config.save')}</button>
)}
</div>
</div>
@@ -470,99 +455,70 @@ function PanelSkills({ skillList, t }) {
)
}
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',
function PanelSystem({ api, t }) {
const [resetConfirm, setResetConfirm] = useState(false)
const [toast, setToast] = useState(null)
const showToast = (msg) => {
setToast(msg)
setTimeout(() => setToast(null), 3000)
}
const handleReset = async () => {
try {
await api.resetConfig()
setResetConfirm(false)
showToast(t('config.resetDone'))
setTimeout(() => window.location.reload(), 1500)
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
}
const handleApplyStarship = async () => {
try {
await api.applyStarshipTheme('charm')
showToast(t('config.starshipApplied'))
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
}
return (
<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={`chip ${settings.theme === th.id ? 'active' : ''}`}
onClick={() => setSettings(s => ({ ...s, theme: th.id }))}
>
{th.name}
</div>
))}
<>
{toast && <div className="config-toast">{toast}</div>}
<div className="config-card">
<div className="config-card-row" style={{ marginBottom: 16 }}>
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.applyStarship')}</span>
</div>
</div>
<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 style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 12 }}>
{t('config.starshipApplied')}
</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 className="sm primary" onClick={handleApplyStarship}>
{t('config.applyStarship')}
</button>
</div>
</div>
<div className="config-card" style={{ marginTop: 12 }}>
<div className="config-card-row" style={{ marginBottom: 16 }}>
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.resetConfig')}</span>
</div>
{resetConfirm ? (
<div>
<div style={{ fontSize: 12, color: 'var(--warning)', marginBottom: 12 }}>
{t('config.resetConfirm')}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="sm" onClick={() => setResetConfirm(false)}>{t('config.cancel')}</button>
<button className="sm danger" onClick={handleReset}>{t('config.resetConfig')}</button>
</div>
</div>
) : (
<button className="sm ghost danger" onClick={() => setResetConfirm(true)}>
{t('config.resetConfig')}
</button>
)}
</div>
</>
)
}

View File

@@ -0,0 +1,407 @@
import { useState, useEffect } from 'react'
import { Sparkles, ArrowRight, ArrowLeft, Search, Loader } from 'lucide-react'
import { useI18n, LANGUAGES } from '../i18n'
import { getLayoutList } from '../i18n/keyboards'
const STEPS = [
{ key: 'welcome', title: 'welcome' },
{ key: 'name', title: 'name' },
{ key: 'language', title: 'language' },
{ key: 'keyboard', title: 'keyboard' },
{ key: 'apikey', title: 'apikey' },
{ key: 'editor', title: 'editor' },
{ key: 'done', title: 'done' },
]
const BASE_EDITORS = ['vim', 'nvim', 'vscode', 'emacs', 'nano', 'helix']
export default function OnboardingWizard({ api, onComplete }) {
const { t, language, keyboard, setLanguage, setKeyboard } = useI18n()
const [step, setStep] = useState(0)
const [answers, setAnswers] = useState({
name: '',
language: 'fr',
keyboard: 'azerty',
apikey: '',
editor: '',
})
const [editorList, setEditorList] = useState(BASE_EDITORS)
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const [requiredError, setRequiredError] = useState(false)
const [validating, setValidating] = useState(false)
const [keyValid, setKeyValid] = useState(false)
const [scanning, setScanning] = useState(false)
const current = STEPS[step]
const layouts = getLayoutList()
const goNext = () => {
if (step < STEPS.length - 1) {
if (!canProceed) { setRequiredError(true); return }
setRequiredError(false)
setStep(step + 1)
}
}
const canProceed = (() => {
switch (current.key) {
case 'welcome': return true
case 'name': return answers.name.trim().length > 0
case 'language': return !!answers.language
case 'keyboard': return !!answers.keyboard
case 'apikey': return true
case 'editor': return true
case 'done': return true
default: return true
}
})()
const goPrev = () => {
if (step > 0) setStep(step - 1)
}
useEffect(() => {
const handler = (e) => {
if (e.key === 'Escape') { goPrev(); return }
if (e.key === 'Enter' && current.key !== 'done' && current.key !== 'editor') { e.preventDefault(); goNext() }
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [step, current])
useEffect(() => {
if (current.key === 'done' && !saving) {
handleSave()
}
}, [step])
const handleValidateKey = async () => {
if (!answers.apikey.trim()) return
setValidating(true)
setError(null)
try {
await api.validateProvider({
name: 'minimax',
api_key: answers.apikey,
model: 'MiniMax-M2.7',
base_url: 'https://api.minimax.io/v1',
})
setKeyValid(true)
} catch (err) {
setError(err.message || 'Clé invalide')
setKeyValid(false)
}
setValidating(false)
}
const handleScanEditors = async () => {
setScanning(true)
setError(null)
try {
const data = await api.getEditors()
const detected = (data.editors || []).map(e => e.name)
const merged = [...new Set([...detected, ...BASE_EDITORS])]
setEditorList(merged)
if (detected.length === 0) {
setError('Aucun éditeur détecté')
}
} catch (err) {
setError(err.message || 'Erreur lors du scan')
}
setScanning(false)
}
const handleSave = async () => {
setSaving(true)
setError(null)
try {
const profile = {
name: answers.name,
pseudo: answers.name.split(' ')[0] || 'user',
editor: answers.editor,
}
if (answers.apikey.trim()) {
profile.apikey = answers.apikey
}
await api.saveProfile(profile)
await api.savePreferences({
language: answers.language,
keyboard_layout: answers.keyboard,
})
if (answers.apikey.trim()) {
await api.saveProvider({
name: 'minimax',
api_key: answers.apikey,
model: 'MiniMax-M2.7',
base_url: 'https://api.minimax.io/v1',
active: true,
})
}
onComplete()
} catch (err) {
setError(err.message || 'Erreur lors de la sauvegarde')
setSaving(false)
}
}
return (
<div className="onboarding-overlay">
<div className="onboarding-card">
<div className="onboarding-header">
<Sparkles size={20} style={{ color: 'var(--accent)' }} />
<span> Muyue Setup</span>
</div>
<div className="onboarding-progress">
{STEPS.map((_, i) => (
<div key={i} className={`onboarding-dot ${i === step ? 'active' : ''} ${i < step ? 'done' : ''}`} />
))}
</div>
<div className="onboarding-body">
{current.key === 'welcome' && (
<div className="onboarding-step">
<div className="onboarding-title">Bienvenue ! 👋</div>
<div className="onboarding-desc">
Je suis votre assistant de configuration. Quelques questions rapides pour personnaliser votre expérience.
</div>
</div>
)}
{current.key === 'name' && (
<div className="onboarding-step">
<div className="onboarding-title">Comment vous appelez-vous ?</div>
<input
className="onboarding-input"
placeholder="Votre nom..."
value={answers.name}
onChange={e => { setAnswers(a => ({ ...a, name: e.target.value })); setRequiredError(false) }}
autoFocus
/>
{requiredError && <div className="onboarding-required">Veuillez entrer votre nom</div>}
</div>
)}
{current.key === 'language' && (
<div className="onboarding-step">
<div className="onboarding-title">Quelle langue préférez-vous ?</div>
<div className="onboarding-chips">
{LANGUAGES.map(lang => (
<div
key={lang.id}
className={`chip ${answers.language === lang.id ? 'active' : ''}`}
onClick={() => setAnswers(a => ({ ...a, language: lang.id }))}
>
{lang.name}
</div>
))}
</div>
</div>
)}
{current.key === 'keyboard' && (
<div className="onboarding-step">
<div className="onboarding-title">Disposition du clavier ?</div>
<div className="onboarding-chips">
{layouts.map(l => (
<div
key={l.id}
className={`chip ${answers.keyboard === l.id ? 'active' : ''}`}
onClick={() => setAnswers(a => ({ ...a, keyboard: l.id }))}
>
{l.name}
</div>
))}
</div>
</div>
)}
{current.key === 'apikey' && (
<div className="onboarding-step">
<div className="onboarding-title">Clé API MiniMax</div>
<div className="onboarding-desc">
Entrez votre clé API MiniMax pour activer l'assistant IA. Vous pouvez passer cette étape et la configurer plus tard.
</div>
<input
className="onboarding-input"
placeholder="sk-xxxxxxxxxxxxxxxx"
type="password"
value={answers.apikey}
onChange={e => { setAnswers(a => ({ ...a, apikey: e.target.value })); setKeyValid(false); setError(null) }}
autoFocus
/>
{error && !keyValid && <div className="onboarding-required">{error}</div>}
{keyValid && <div className="onboarding-valid">Clé valide ✓</div>}
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<button
className="sm primary"
onClick={handleValidateKey}
disabled={validating || !answers.apikey.trim()}
>
{validating ? 'Validation...' : 'Valider la clé'}
</button>
<button
className="sm ghost"
onClick={goNext}
disabled={!answers.apikey.trim()}
>
Passer
</button>
</div>
{answers.apikey.trim() && !keyValid && !error && (
<div className="onboarding-hint">Cliquez "Valider la clé" ou "Passer"</div>
)}
</div>
)}
{current.key === 'editor' && (
<div className="onboarding-step">
<div className="onboarding-title">Quel éditeur utilisez-vous ?</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className="onboarding-chips" style={{ flex: 1 }}>
{editorList.map(ed => (
<div
key={ed}
className={`chip ${answers.editor === ed ? 'active' : ''}`}
onClick={() => setAnswers(a => ({ ...a, editor: ed }))}
>
{ed}
</div>
))}
</div>
<button
className="sm ghost"
onClick={handleScanEditors}
disabled={scanning}
title="Détecter les éditeurs installés"
style={{ marginLeft: 8, flexShrink: 0 }}
>
{scanning ? <Loader size={14} className="spin-icon" /> : <Search size={14} />}
</button>
</div>
<input
className="onboarding-input"
style={{ marginTop: 12 }}
placeholder="Autre éditeur..."
value={answers.editor}
onChange={e => setAnswers(a => ({ ...a, editor: e.target.value }))}
autoFocus
/>
{error && <div className="onboarding-required">{error}</div>}
</div>
)}
{current.key === 'done' && (
<div className="onboarding-step">
{saving ? (
<>
<div className="onboarding-title">Configuration en cours...</div>
<div className="onboarding-desc">Sauvegarde de vos préférences.</div>
</>
) : error ? (
<>
<div className="onboarding-title" style={{ color: 'var(--error)' }}>Erreur</div>
<div className="onboarding-desc" style={{ color: 'var(--error)' }}>{error}</div>
<button className="primary" style={{ alignSelf: 'flex-start', marginTop: 8 }} onClick={() => handleSave()}>Réessayer</button>
</>
) : (
<>
<div className="onboarding-title">C'est parti ! 🚀</div>
<div className="onboarding-desc">
Votre profil est configuré. Vous pouvez toujours ajuster les paramètres dans l'onglet Configuration.
</div>
</>
)}
</div>
)}
</div>
<div className="onboarding-footer">
{step > 0 && step < STEPS.length - 1 && (
<button className="ghost" onClick={goPrev}>
<ArrowLeft size={14} /> Précédent
</button>
)}
<div style={{ flex: 1 }} />
{step < STEPS.length - 1 && (
<button className="primary" onClick={goNext}>
Suivant <ArrowRight size={14} />
</button>
)}
{step === STEPS.length - 1 && !saving && !error && (
<button className="primary" onClick={handleSave}>
Commencer
</button>
)}
</div>
</div>
<style>{`
.onboarding-overlay {
position: fixed; inset: 0; z-index: 500;
background: rgba(10,10,12,0.85);
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(8px);
}
.onboarding-card {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
width: 480px; max-width: 90vw;
box-shadow: 0 24px 64px rgba(0,0,0,0.5);
overflow: hidden;
}
.onboarding-header {
display: flex; align-items: center; gap: 8px;
padding: 16px 20px; font-size: 14px; font-weight: 700;
color: var(--accent); border-bottom: 1px solid var(--border);
background: var(--bg-surface);
}
.onboarding-progress {
display: flex; gap: 6px; padding: 14px 20px;
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
}
.onboarding-dot {
width: 32px; height: 4px; border-radius: 2px;
background: var(--bg-input); transition: all 0.3s;
}
.onboarding-dot.active { background: var(--accent); }
.onboarding-dot.done { background: var(--accent-dim); }
.onboarding-body { padding: 28px 24px; min-height: 200px; }
.onboarding-step { display: flex; flex-direction: column; gap: 16px; }
.onboarding-title { font-size: 18px; font-weight: 700; color: var(--text-primary); }
.onboarding-desc { font-size: 14px; color: var(--text-tertiary); line-height: 1.6; }
.onboarding-input {
width: 100%; background: var(--bg-input); border: 1px solid var(--border);
border-radius: var(--radius); padding: 10px 14px; color: var(--text-primary);
font-size: 14px; outline: none; transition: border-color 0.2s, box-shadow 0.2s;
}
.onboarding-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--border-accent); }
.onboarding-chips { display: flex; gap: 8px; flex-wrap: wrap; }
.onboarding-footer {
display: flex; justify-content: flex-end; gap: 8px;
padding: 16px 20px; border-top: 1px solid var(--border);
background: var(--bg-surface);
}
.onboarding-required {
font-size: 12px; color: var(--error); margin-top: 4px;
}
.onboarding-valid {
font-size: 12px; color: var(--success); margin-top: 4px;
}
.onboarding-hint {
font-size: 12px; color: var(--text-tertiary); margin-top: 4px;
}
.spin-icon {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
</div>
)
}

View File

@@ -94,15 +94,15 @@ 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.addEventListener('open', () => {
ws.send(JSON.stringify(initPayload))
const dims = fitAddon.proposeDimensions()
if (dims) {
ws.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols }))
}
}
})
ws.onmessage = (event) => {
ws.addEventListener('message', (event) => {
try {
const msg = JSON.parse(event.data)
if (msg.type === 'output') {
@@ -113,15 +113,15 @@ function connectWebSocket(term, fitAddon, initPayload) {
} catch {
term.write(event.data)
}
}
})
ws.onclose = () => {
ws.addEventListener('close', () => {
term.write('\r\n\x1b[33m— Connection closed —\x1b[0m\r\n')
}
})
ws.onerror = () => {
ws.addEventListener('error', () => {
term.write('\r\n\x1b[31m— Connection error —\x1b[0m\r\n')
}
})
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {

View File

@@ -78,6 +78,71 @@ function ThinkingBlock({ content, done }) {
)
}
const TOOL_ICONS = {
terminal: '⌨',
crush_run: '⚡',
read_file: '📄',
list_files: '📁',
search_files: '🔍',
grep_content: '🔎',
get_config: '⚙',
set_provider: '🔑',
manage_ssh: '🌐',
web_fetch: '🌐',
}
const TOOL_LABELS = {
terminal: 'Terminal',
crush_run: 'Crush Agent',
read_file: 'Read File',
list_files: 'List Files',
search_files: 'Search Files',
grep_content: 'Grep',
get_config: 'Config',
set_provider: 'Set Provider',
manage_ssh: 'SSH',
web_fetch: 'Web Fetch',
}
function ToolCallBlock({ call, result }) {
const icon = TOOL_ICONS[call.name] || '🔧'
const label = TOOL_LABELS[call.name] || call.name
const isErr = result && result.is_error
let argsPreview = ''
try {
const args = typeof call.args === 'string' ? JSON.parse(call.args) : call.args
if (args.command) argsPreview = args.command
else if (args.task) argsPreview = args.task
else if (args.path) argsPreview = args.path
else if (args.pattern) argsPreview = args.pattern
else if (args.url) argsPreview = args.url
else if (args.action) argsPreview = args.action
else argsPreview = JSON.stringify(args).slice(0, 80)
} catch {
argsPreview = String(call.args).slice(0, 80)
}
const truncatedResult = result ? (result.content || '').slice(0, 2000) : null
return (
<div className={`studio-tool-block ${isErr ? 'error' : ''} ${result ? 'done' : 'running'}`}>
<div className="studio-tool-header">
<span className="studio-tool-icon">{icon}</span>
<span className="studio-tool-name">{label}</span>
{!result && <span className="studio-tool-spinner"><span/><span/><span/></span>}
{result && <span className={`studio-tool-status ${isErr ? 'error' : 'ok'}`}>{isErr ? '✗' : '✓'}</span>}
</div>
<div className="studio-tool-args" title={argsPreview}>{argsPreview}</div>
{truncatedResult && (
<div className="studio-tool-result">
<pre>{truncatedResult}</pre>
</div>
)}
</div>
)
}
function FeedItem({ msg }) {
const isUser = msg.role === 'user'
const isSystem = msg.role === 'system'
@@ -85,6 +150,16 @@ function FeedItem({ msg }) {
const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''
let parsedToolCalls = null
let displayContent = msg.content
try {
const parsed = JSON.parse(msg.content)
if (parsed && Array.isArray(parsed.tool_calls)) {
parsedToolCalls = parsed.tool_calls
displayContent = parsed.content || ''
}
} catch {}
if (isSystem) {
return (
<div className="feed-item system">
@@ -95,7 +170,7 @@ function FeedItem({ msg }) {
)
}
const cleanContent = msg.content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
const cleanContent = displayContent.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
return (
<div className={`feed-item ${msg.role}`}>
@@ -111,26 +186,32 @@ function FeedItem({ msg }) {
{timeStr && <span className="feed-time">{timeStr}</span>}
</div>
{msg.thinking && <ThinkingBlock content={msg.thinking} done />}
<div className="feed-content">
{renderContent(cleanContent).map((part, i) =>
part.type === 'code' ? (
<div key={i} className="studio-code-block">
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
<pre><code>{part.content}</code></pre>
</div>
) : (
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
)
)}
</div>
{parsedToolCalls && parsedToolCalls.map((tc, i) => (
<ToolCallBlock key={tc.tool_call_id || i} call={tc} result={null} />
))}
{cleanContent && (
<div className="feed-content">
{renderContent(cleanContent).map((part, i) =>
part.type === 'code' ? (
<div key={i} className="studio-code-block">
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
<pre><code>{part.content}</code></pre>
</div>
) : (
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
)
)}
</div>
)}
</div>
</div>
)
}
function StreamingItem({ content, thinking }) {
function StreamingItem({ content, thinking, toolCalls }) {
const rank = RANKS.general
const cleanContent = content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
const hasToolCalls = toolCalls && toolCalls.length > 0
return (
<div className="feed-item assistant">
@@ -145,7 +226,10 @@ function StreamingItem({ content, thinking }) {
<span className="feed-role">{rank.label}</span>
</div>
{thinking && <ThinkingBlock content={thinking} done={false} />}
{!thinking && !cleanContent && (
{hasToolCalls && toolCalls.map((tc, i) => (
<ToolCallBlock key={tc.call?.tool_call_id || i} call={tc.call} result={tc.result} />
))}
{!thinking && !cleanContent && !hasToolCalls && (
<div className="feed-content">
<div className="studio-thinking"><span /><span /><span /></div>
</div>
@@ -177,6 +261,7 @@ export default function Studio({ api }) {
const [loading, setLoading] = useState(false)
const [streaming, setStreaming] = useState('')
const [streamThinking, setStreamThinking] = useState('')
const [streamToolCalls, setStreamToolCalls] = useState([])
const [loaded, setLoaded] = useState(false)
const messagesEnd = useRef(null)
const textareaRef = useRef(null)
@@ -201,7 +286,7 @@ export default function Studio({ api }) {
useEffect(() => {
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, streaming, streamThinking])
}, [messages, streaming, streamThinking, streamToolCalls])
useEffect(() => {
if (textareaRef.current) {
@@ -234,10 +319,12 @@ export default function Studio({ api }) {
setLoading(true)
setStreaming('')
setStreamThinking('')
setStreamToolCalls([])
try {
let accumulated = ''
let thinking = ''
let toolCalls = []
await api.sendChat(text, true, (partial, event) => {
if (event && (event.thinking_start || event.thinking_end || event.thinking !== undefined)) {
@@ -247,6 +334,19 @@ export default function Studio({ api }) {
}
return
}
if (event && event.tool_call) {
toolCalls = [...toolCalls, { call: event.tool_call, result: null }]
setStreamToolCalls([...toolCalls])
return
}
if (event && event.tool_result) {
const idx = toolCalls.findIndex(tc => tc.call && tc.call.tool_call_id === event.tool_result.tool_call_id)
if (idx >= 0) {
toolCalls[idx] = { ...toolCalls[idx], result: event.tool_result }
setStreamToolCalls([...toolCalls])
}
return
}
accumulated = partial
setStreaming(partial)
})
@@ -259,6 +359,12 @@ export default function Studio({ api }) {
time: new Date().toISOString(),
}
if (thinking) aiMsg.thinking = thinking
if (toolCalls.length > 0) {
aiMsg.content = JSON.stringify({
content: finalContent,
tool_calls: toolCalls.map(tc => tc.call),
})
}
setMessages(prev => [...prev, aiMsg])
} catch (err) {
setMessages(prev => [...prev, {
@@ -271,6 +377,7 @@ export default function Studio({ api }) {
setLoading(false)
setStreaming('')
setStreamThinking('')
setStreamToolCalls([])
}
}, [input, loading, api, t, handleClear])
@@ -299,8 +406,8 @@ export default function Studio({ api }) {
{messages.map(msg => (
<FeedItem key={msg.id} msg={msg} />
))}
{(streaming || streamThinking || loading) && (
<StreamingItem content={streaming} thinking={streamThinking} />
{(streaming || streamThinking || loading || streamToolCalls.length > 0) && (
<StreamingItem content={streaming} thinking={streamThinking} toolCalls={streamToolCalls} />
)}
<div ref={messagesEnd} />
</div>

View File

@@ -121,6 +121,7 @@ const en = {
updates: 'Updates',
locale: 'Language & Keyboard',
skills: 'Skills',
system: 'System',
},
profile: 'Profile',
name: 'Name',
@@ -180,6 +181,12 @@ const en = {
fontFamily: 'Font Family',
preview: 'Preview',
saving: 'Saving...',
resetConfig: 'Reset all',
resetConfirm: 'Are you sure? All preferences will be erased.',
resetDone: 'Settings reset.',
applyStarship: 'Apply starship',
starshipApplied: 'Starship theme applied! Restart your shell to see the result.',
starshipError: 'Failed to apply starship theme.',
},
}

View File

@@ -120,7 +120,8 @@ const fr = {
terminal: 'Terminal',
updates: 'Mises \u00e0 jour',
locale: 'Langue & Clavier',
skills: 'Comp\u00e9tences',
skills: 'Comp\u00e9ENCES',
system: 'Syst\u00e8me',
},
profile: 'Profil',
name: 'Nom',
@@ -143,7 +144,7 @@ const fr = {
save: 'Enregistrer',
saved: 'Enregistr\u00e9 !',
error: 'Erreur',
skills: 'Comp\u00e9tences',
skills: 'Comp\u00e9ENCES',
noSkills: 'Aucune comp\u00e9tence install\u00e9e.',
runSkillsInit: 'Ex\u00e9cutez muyue skills init',
language: 'Langue',
@@ -167,10 +168,10 @@ const fr = {
editProfile: 'Modifier',
editProvider: 'Configurer',
validateKey: 'Valider',
validating: 'Vérification...',
keyValid: 'Clé valide',
keyInvalid: 'Clé invalide',
connectionFailed: 'Connexion échouée',
validating: 'V\u00e9rification...',
keyValid: 'Cl\u00e9 valide',
keyInvalid: 'Cl\u00e9 invalide',
connectionFailed: 'Connexion \u00e9chou\u00e9e',
enterToken: 'Entrez votre token API pour {provider}',
tokenPlaceholder: 'sk-...',
setupDescription: 'Configurez le token de votre fournisseur IA pour utiliser l\'assistant.',
@@ -180,6 +181,12 @@ const fr = {
fontFamily: 'Police',
preview: 'Aper\u00e7u',
saving: 'Enregistrement...',
resetConfig: 'R\u00e9initialiser',
resetConfirm: '\u00cates-vous s\u00fbr ? Toutes les pr\u00e9f\u00e9rences seront effac\u00e9es.',
resetDone: 'Param\u00e8tres r\u00e9initialis\u00e9s.',
applyStarship: 'Appliquer starship',
starshipApplied: 'Th\u00e8me starship appliqu\u00e9 ! Red\u00e9marrez votre shell pour voir le r\u00e9sultat.',
starshipError: '\u00c9chec de l\u2019application du th\u00e8me starship.',
},
}

View File

@@ -678,3 +678,91 @@ input::placeholder { color: var(--text-disabled); }
.studio-send-btn:hover:not(:disabled) { background: var(--accent-bright); border-color: var(--accent-bright); }
.studio-send-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.studio-input-hint { font-size: 11px; color: var(--text-disabled); text-align: center; margin-top: 6px; }
/* ── Studio Tool Blocks ── */
.studio-tool-block {
background: var(--bg-surface);
border: 1px solid var(--border);
border-left: 3px solid var(--accent-dim);
border-radius: var(--radius);
margin: 6px 0;
overflow: hidden;
transition: all 0.3s ease;
}
.studio-tool-block.running {
border-left-color: var(--warning);
}
.studio-tool-block.error {
border-left-color: var(--error);
background: rgba(255, 23, 68, 0.05);
}
.studio-tool-header {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
font-size: 12px;
}
.studio-tool-icon {
font-size: 14px;
flex-shrink: 0;
}
.studio-tool-name {
color: var(--text-tertiary);
font-weight: 600;
font-family: var(--font-mono);
font-size: 12px;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.studio-tool-spinner {
display: inline-flex;
gap: 2px;
margin-left: 4px;
}
.studio-tool-spinner span {
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--warning);
animation: bounce 1.2s ease-in-out infinite;
}
.studio-tool-spinner span:nth-child(2) { animation-delay: 0.15s; }
.studio-tool-spinner span:nth-child(3) { animation-delay: 0.3s; }
.studio-tool-status {
font-weight: 700;
font-size: 14px;
flex-shrink: 0;
}
.studio-tool-status.ok { color: var(--success); }
.studio-tool-status.error { color: var(--error); }
.studio-tool-args {
padding: 6px 10px;
font-size: 12px;
font-family: var(--font-mono);
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-bottom: 1px solid var(--border);
background: var(--bg-elevated);
}
.studio-tool-result {
max-height: 200px;
overflow-y: auto;
}
.studio-tool-result pre {
padding: 8px 10px;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.5;
color: var(--text-secondary);
margin: 0;
white-space: pre-wrap;
word-break: break-word;
background: var(--bg);
}

View File

@@ -13,6 +13,7 @@ export default defineConfig({
'/api': {
target: 'http://127.0.0.1:8095',
changeOrigin: true,
ws: true,
},
},
},