Compare commits
4 Commits
v0.3.2-bet
...
v0.3.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28e5113733 | ||
|
|
51a599fc83 | ||
|
|
d8384cad00 | ||
|
|
5b4a70e690 |
117
CHANGELOG.md
117
CHANGELOG.md
@@ -4,6 +4,123 @@ 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/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
|
## v0.3.2
|
||||||
|
|
||||||
|
### Changes since v0.3.1
|
||||||
|
|
||||||
|
- chore: update CHANGELOG for v0.3.2-beta.1 (51a599f)
|
||||||
|
- fix: correct version from 3.2 to 0.3.2 (83d7a57)
|
||||||
|
- chore: bump version to 3.2 (0fe82f6)
|
||||||
|
- refactor(config): remove Terminal sub-tab from Configuration page (3b6cc38)
|
||||||
|
- fix(terminal): init payload never sent due to ws.onopen being overwritten (93a22d4)
|
||||||
|
- fix(terminal): improve shell resolution with better error handling and ws proxy support (e0e1e73)
|
||||||
|
- feat(studio): parse AI thinking and tool launch messages in terminal panel (0496ca7)
|
||||||
|
- fix(studio): forward AI thinking chunks to frontend instead of dropping them (b407ab8)
|
||||||
|
- feat(studio): add tool execution and hide AI thinking tags (12df184)
|
||||||
|
- fix(terminal): ignore invalid shell config from race condition (8af6d25)
|
||||||
|
- feat(shell): restore AI assistant panel (4fd599a)
|
||||||
|
- fix(terminal): restore terminal input and cursor visibility (bcba593)
|
||||||
|
- refactor(api): split monolithic handlers.go into focused modules (04b0fff)
|
||||||
|
|
||||||
|
### Downloads
|
||||||
|
|
||||||
|
| Platform | File |
|
||||||
|
|----------|------|
|
||||||
|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-linux-amd64.tar.gz) |
|
||||||
|
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-linux-arm64.tar.gz) |
|
||||||
|
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-darwin-amd64.tar.gz) |
|
||||||
|
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-darwin-arm64.tar.gz) |
|
||||||
|
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-windows-amd64.zip) |
|
||||||
|
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/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.2/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.2/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.2/muyue-windows-amd64.zip" -OutFile "muyue.zip"
|
||||||
|
Expand-Archive -Path "muyue.zip" -DestinationPath "."
|
||||||
|
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## v0.3.2-beta.1 (Beta)
|
||||||
|
|
||||||
|
### Commits since v0.3.1
|
||||||
|
|
||||||
|
- fix: correct version from 3.2 to 0.3.2 (83d7a57)
|
||||||
|
|
||||||
|
> This is a **beta** release. Use at your own risk.
|
||||||
|
|
||||||
|
## v0.3.1
|
||||||
|
|
||||||
|
### Changes since v0.3.0
|
||||||
|
|
||||||
|
- refactor(config): remove Terminal sub-tab from Configuration page (95bd824)
|
||||||
|
- fix(terminal): init payload never sent due to ws.onopen being overwritten (252f178)
|
||||||
|
- fix(terminal): improve shell resolution with better error handling and ws proxy support (7dcf505)
|
||||||
|
- feat(studio): parse AI thinking and tool launch messages in terminal panel (8fb93fa)
|
||||||
|
- fix(studio): forward AI thinking chunks to frontend instead of dropping them (5ec373c)
|
||||||
|
- feat(studio): add tool execution and hide AI thinking tags (1eb5a6d)
|
||||||
|
- fix(terminal): ignore invalid shell config from race condition (cd5ebe0)
|
||||||
|
- feat(shell): restore AI assistant panel (2004c15)
|
||||||
|
- fix(terminal): restore terminal input and cursor visibility (9306152)
|
||||||
|
- refactor(api): split monolithic handlers.go into focused modules (e15a034)
|
||||||
|
|
||||||
|
### Downloads
|
||||||
|
|
||||||
|
| Platform | File |
|
||||||
|
|----------|------|
|
||||||
|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-linux-amd64.tar.gz) |
|
||||||
|
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-linux-arm64.tar.gz) |
|
||||||
|
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-darwin-amd64.tar.gz) |
|
||||||
|
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-darwin-arm64.tar.gz) |
|
||||||
|
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-windows-amd64.zip) |
|
||||||
|
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/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.1/muyue-linux-amd64.tar.gz | tar xz
|
||||||
|
chmod +x muyue-linux-amd64
|
||||||
|
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**macOS (Apple Silicon)**
|
||||||
|
```bash
|
||||||
|
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-darwin-arm64.tar.gz | tar xz
|
||||||
|
chmod +x muyue-darwin-arm64
|
||||||
|
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows (x86_64)**
|
||||||
|
```powershell
|
||||||
|
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-windows-amd64.zip" -OutFile "muyue.zip"
|
||||||
|
Expand-Archive -Path "muyue.zip" -DestinationPath "."
|
||||||
|
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
## v0.3.0
|
## v0.3.0
|
||||||
|
|
||||||
### Changes since v0.2.1
|
### Changes since v0.2.1
|
||||||
|
|||||||
@@ -1,311 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,579 +0,0 @@
|
|||||||
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 "&":
|
|
||||||
return "&"
|
|
||||||
case "<":
|
|
||||||
return "<"
|
|
||||||
case ">":
|
|
||||||
return ">"
|
|
||||||
case """:
|
|
||||||
return "\""
|
|
||||||
case "'":
|
|
||||||
return "'"
|
|
||||||
case " ":
|
|
||||||
return " "
|
|
||||||
default:
|
|
||||||
return " "
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
multiSpace := regexp.MustCompile(`\s+`)
|
|
||||||
text = multiSpace.ReplaceAllString(text, " ")
|
|
||||||
return strings.TrimSpace(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = runtime.GOOS
|
|
||||||
var _ = json.Marshal
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import _ "embed"
|
|
||||||
|
|
||||||
//go:embed prompts/studio_system.md
|
|
||||||
var studioSystemPrompt string
|
|
||||||
|
|
||||||
func StudioSystemPrompt() string {
|
|
||||||
return studioSystemPrompt
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
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, ¶ms); 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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/muyue/muyue/internal/agent"
|
|
||||||
"github.com/muyue/muyue/internal/orchestrator"
|
"github.com/muyue/muyue/internal/orchestrator"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxToolIterations = 15
|
var toolCallRegex = regexp.MustCompile(`\[TOOL_CALL:\{[^\}]+\}\]`)
|
||||||
|
|
||||||
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != "POST" {
|
if r.Method != "POST" {
|
||||||
@@ -26,7 +27,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if body.Message == "" {
|
if body.Message == "" {
|
||||||
writeError(w, "no message", http.StatusMethodNotAllowed)
|
writeError(w, "no message", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,189 +42,143 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
orb.SetSystemPrompt(agent.StudioSystemPrompt())
|
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.
|
||||||
orb.SetTools(s.agentToolsJSON)
|
|
||||||
|
RÈGLES ABSOLUES:
|
||||||
|
1. Tu as DEUX possibilités ONLY:
|
||||||
|
- Répondre directement à l'utilisateur avec tes connaissances
|
||||||
|
- Demander l'exécution d'une tâche via crush en utilisant ce format EXACT:
|
||||||
|
[TOOL_CALL:{"tool":"crush","task":"description de la tâche"}]
|
||||||
|
|
||||||
|
2. Quand tu utilises [TOOL_CALL:...], le système exécutera la tâche et te donnera le résultat.
|
||||||
|
Tu peux ensuite répondre à l'utilisateur avec ce résultat.
|
||||||
|
|
||||||
|
3. SOIS CONCIS - pas de blabla, vais droit au but.
|
||||||
|
|
||||||
|
4. L'utilisateur ne voit PAS tes pensées entre <think> tags.
|
||||||
|
|
||||||
|
5. EXEMPLES d'utilisation de tool:
|
||||||
|
- "cherche tous les fichiers .md dans le projet" → [TOOL_CALL:{"tool":"crush","task":"Recherche les fichiers .md dans le projet courant"}]
|
||||||
|
- "aide-moi à déboguer cette erreur" → tu peux répondre directement si tu as assez d'info, sinon utiliser tool
|
||||||
|
- "quelle est la météo?" → [TOOL_CALL:{"tool":"crush","task":"Cherche la météo actuelle"}]
|
||||||
|
|
||||||
|
6. Ne fais PAS de multi-step tool calls dans une seule réponse. Attends le résultat avant de continuer.`)
|
||||||
|
|
||||||
if body.Stream {
|
if body.Stream {
|
||||||
s.handleStreamChat(w, orb, body.Message)
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
} else {
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
s.handleNonStreamChat(w, orb, body.Message)
|
w.Header().Set("Connection", "keep-alive")
|
||||||
}
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
}
|
w.WriteHeader(http.StatusOK)
|
||||||
|
flusher, canFlush := w.(http.Flusher)
|
||||||
|
|
||||||
func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orchestrator, userMessage string) {
|
result, err := orb.SendStream(body.Message, func(chunk string) {
|
||||||
w.Header().Set("Content-Type", "text/event-stream")
|
if strings.HasPrefix(chunk, "<think") {
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
data, _ := json.Marshal(map[string]string{"thinking": strings.TrimPrefix(chunk, "<think")})
|
||||||
w.Header().Set("Connection", "keep-alive")
|
w.Write([]byte("data: " + string(data) + "\n\n"))
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
if canFlush {
|
||||||
w.WriteHeader(http.StatusOK)
|
flusher.Flush()
|
||||||
flusher, canFlush := w.(http.Flusher)
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
writeSSE := func(data map[string]interface{}) {
|
// Process tool calls if any
|
||||||
b, _ := json.Marshal(data)
|
cleanResult := processToolCalls(result)
|
||||||
w.Write([]byte("data: " + string(b) + "\n\n"))
|
s.convStore.Add("assistant", cleanResult)
|
||||||
|
|
||||||
|
data, _ := json.Marshal(map[string]string{"done": "true"})
|
||||||
|
w.Write([]byte("data: " + string(data) + "\n\n"))
|
||||||
if canFlush {
|
if canFlush {
|
||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
}
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
result, err := orb.Send(body.Message)
|
||||||
messages := []orchestrator.Message{
|
if err != nil {
|
||||||
{Role: "user", Content: userMessage},
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
cleanResult := processToolCalls(result)
|
||||||
var finalContent string
|
s.convStore.Add("assistant", cleanResult)
|
||||||
var allToolCalls []map[string]interface{}
|
writeJSON(w, map[string]string{"content": cleanResult})
|
||||||
|
|
||||||
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 (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Orchestrator, userMessage string) {
|
func processToolCalls(content string) string {
|
||||||
ctx := context.Background()
|
matches := toolCallRegex.FindAllString(content, -1)
|
||||||
messages := []orchestrator.Message{
|
if len(matches) == 0 {
|
||||||
{Role: "user", Content: userMessage},
|
return cleanThinkingTags(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
var finalContent string
|
var result strings.Builder
|
||||||
|
clean := content
|
||||||
|
|
||||||
for i := 0; i < maxToolIterations; i++ {
|
for _, match := range matches {
|
||||||
resp, err := orb.SendWithTools(messages)
|
// Extract tool and task from [TOOL_CALL:{...}]
|
||||||
if err != nil {
|
inner := strings.TrimPrefix(match, "[TOOL_CALL:")
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
inner = strings.TrimSuffix(inner, "]}") + "}"
|
||||||
return
|
|
||||||
|
var call struct {
|
||||||
|
Tool string `json:"tool"`
|
||||||
|
Task string `json:"task"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(inner), &call); err != nil {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
choice := resp.Choices[0]
|
if call.Tool == "crush" && call.Task != "" {
|
||||||
content := cleanThinkingTags(choice.Message.Content)
|
result.WriteString(fmt.Sprintf("> %s\n\n", call.Task))
|
||||||
|
output := executeCrush(call.Task)
|
||||||
if content != "" {
|
result.WriteString(output)
|
||||||
finalContent = content
|
result.WriteString("\n\n---\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(choice.Message.ToolCalls) == 0 {
|
clean = strings.Replace(clean, match, "", 1)
|
||||||
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 = ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if finalContent == "" {
|
clean = cleanThinkingTags(clean)
|
||||||
finalContent = "(tool calls completed, no text response)"
|
|
||||||
|
if result.Len() > 0 {
|
||||||
|
clean = strings.TrimSpace(clean) + "\n\n" + strings.TrimSpace(result.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
s.convStore.Add("assistant", finalContent)
|
return clean
|
||||||
writeJSON(w, map[string]string{"content": finalContent})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func cleanThinkingTags(content string) string {
|
func cleanThinkingTags(content string) string {
|
||||||
return strings.ReplaceAll(content, "<think", "")
|
re := regexp.MustCompile(`(?s)<think[^>]*>.*?</think>`)
|
||||||
|
return re.ReplaceAllString(content, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeCrush(task string) string {
|
||||||
|
cmd := exec.Command("crush", "run", task)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("Erreur: %v\n%s", err, string(output))
|
||||||
|
}
|
||||||
|
return string(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) autoSummarize() {
|
func (s *Server) autoSummarize() {
|
||||||
@@ -278,4 +233,4 @@ func (s *Server) handleChatClear(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
s.convStore.Clear()
|
s.convStore.Clear()
|
||||||
writeJSON(w, map[string]string{"status": "ok"})
|
writeJSON(w, map[string]string{"status": "ok"})
|
||||||
}
|
}
|
||||||
@@ -3,11 +3,8 @@ package api
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -284,203 +281,3 @@ func (s *Server) handleGetTerminalThemes(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
writeJSON(w, map[string]interface{}{"themes": themes})
|
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"
|
|
||||||
`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -117,8 +117,3 @@ func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.scanResult = scanner.ScanSystem()
|
s.scanResult = scanner.ScanSystem()
|
||||||
writeJSON(w, map[string]string{"status": "ok"})
|
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})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,22 +1,18 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/muyue/muyue/internal/agent"
|
|
||||||
"github.com/muyue/muyue/internal/config"
|
"github.com/muyue/muyue/internal/config"
|
||||||
"github.com/muyue/muyue/internal/scanner"
|
"github.com/muyue/muyue/internal/scanner"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
config *config.MuyueConfig
|
config *config.MuyueConfig
|
||||||
scanResult *scanner.ScanResult
|
scanResult *scanner.ScanResult
|
||||||
mux *http.ServeMux
|
mux *http.ServeMux
|
||||||
convStore *ConversationStore
|
convStore *ConversationStore
|
||||||
agentRegistry *agent.Registry
|
|
||||||
agentToolsJSON json.RawMessage
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(cfg *config.MuyueConfig) *Server {
|
func NewServer(cfg *config.MuyueConfig) *Server {
|
||||||
@@ -26,10 +22,6 @@ func NewServer(cfg *config.MuyueConfig) *Server {
|
|||||||
}
|
}
|
||||||
s.scanResult = scanner.ScanSystem()
|
s.scanResult = scanner.ScanSystem()
|
||||||
s.convStore = NewConversationStore()
|
s.convStore = NewConversationStore()
|
||||||
s.agentRegistry = agent.DefaultRegistry()
|
|
||||||
tools := s.agentRegistry.OpenAITools()
|
|
||||||
toolsJSON, _ := json.Marshal(tools)
|
|
||||||
s.agentToolsJSON = json.RawMessage(toolsJSON)
|
|
||||||
s.routes()
|
s.routes()
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
@@ -46,7 +38,6 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("/api/updates", s.handleUpdates)
|
s.mux.HandleFunc("/api/updates", s.handleUpdates)
|
||||||
s.mux.HandleFunc("/api/install", s.handleInstall)
|
s.mux.HandleFunc("/api/install", s.handleInstall)
|
||||||
s.mux.HandleFunc("/api/scan", s.handleScan)
|
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/preferences", s.handleUpdatePreferences)
|
||||||
s.mux.HandleFunc("/api/terminal", s.handleTerminal)
|
s.mux.HandleFunc("/api/terminal", s.handleTerminal)
|
||||||
s.mux.HandleFunc("/api/ws/terminal", s.handleTerminalWS)
|
s.mux.HandleFunc("/api/ws/terminal", s.handleTerminalWS)
|
||||||
@@ -57,8 +48,6 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("/api/mcp/configure", s.handleMCPConfigure)
|
s.mux.HandleFunc("/api/mcp/configure", s.handleMCPConfigure)
|
||||||
s.mux.HandleFunc("/api/config/profile", s.handleSaveProfile)
|
s.mux.HandleFunc("/api/config/profile", s.handleSaveProfile)
|
||||||
s.mux.HandleFunc("/api/config/provider", s.handleSaveProvider)
|
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/providers/validate", s.handleValidateProvider)
|
||||||
s.mux.HandleFunc("/api/update/run", s.handleRunUpdate)
|
s.mux.HandleFunc("/api/update/run", s.handleRunUpdate)
|
||||||
s.mux.HandleFunc("/api/chat", s.handleChat)
|
s.mux.HandleFunc("/api/chat", s.handleChat)
|
||||||
|
|||||||
@@ -20,42 +20,24 @@ var thinkRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
|
|||||||
const maxHistorySize = 100
|
const maxHistorySize = 100
|
||||||
|
|
||||||
type Message struct {
|
type Message struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content,omitempty"`
|
Content string `json:"content"`
|
||||||
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 {
|
type ChatRequest struct {
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Messages []Message `json:"messages"`
|
Messages []Message `json:"messages"`
|
||||||
Stream bool `json:"stream"`
|
Stream bool `json:"stream"`
|
||||||
Tools json.RawMessage `json:"tools,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChatResponse struct {
|
type ChatResponse struct {
|
||||||
Choices []struct {
|
Choices []struct {
|
||||||
Message struct {
|
Message struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
ToolCalls []ToolCallMsg `json:"tool_calls"`
|
|
||||||
} `json:"message"`
|
} `json:"message"`
|
||||||
Delta struct {
|
Delta struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
ToolCalls []ToolCallMsg `json:"tool_calls"`
|
|
||||||
} `json:"delta"`
|
} `json:"delta"`
|
||||||
FinishReason *string `json:"finish_reason"`
|
|
||||||
} `json:"choices"`
|
} `json:"choices"`
|
||||||
Usage struct {
|
Usage struct {
|
||||||
TotalTokens int `json:"total_tokens"`
|
TotalTokens int `json:"total_tokens"`
|
||||||
@@ -69,7 +51,6 @@ type Orchestrator struct {
|
|||||||
history []Message
|
history []Message
|
||||||
histMu sync.Mutex
|
histMu sync.Mutex
|
||||||
systemPrompt string
|
systemPrompt string
|
||||||
tools json.RawMessage
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var sharedHTTPClient = &http.Client{
|
var sharedHTTPClient = &http.Client{
|
||||||
@@ -105,34 +86,6 @@ func (o *Orchestrator) SetSystemPrompt(prompt string) {
|
|||||||
o.systemPrompt = prompt
|
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) {
|
func (o *Orchestrator) Send(userMessage string) (string, error) {
|
||||||
o.histMu.Lock()
|
o.histMu.Lock()
|
||||||
o.history = append(o.history, Message{
|
o.history = append(o.history, Message{
|
||||||
@@ -154,7 +107,6 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
|
|||||||
Model: o.provider.Model,
|
Model: o.provider.Model,
|
||||||
Messages: messages,
|
Messages: messages,
|
||||||
Stream: false,
|
Stream: false,
|
||||||
Tools: o.tools,
|
|
||||||
}
|
}
|
||||||
o.histMu.Unlock()
|
o.histMu.Unlock()
|
||||||
|
|
||||||
@@ -234,7 +186,6 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str
|
|||||||
Model: o.provider.Model,
|
Model: o.provider.Model,
|
||||||
Messages: messages,
|
Messages: messages,
|
||||||
Stream: true,
|
Stream: true,
|
||||||
Tools: o.tools,
|
|
||||||
}
|
}
|
||||||
o.histMu.Unlock()
|
o.histMu.Unlock()
|
||||||
|
|
||||||
@@ -312,67 +263,6 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str
|
|||||||
return content, nil
|
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 {
|
func cleanAIResponse(content string) string {
|
||||||
content = thinkRegex.ReplaceAllString(content, "")
|
content = thinkRegex.ReplaceAllString(content, "")
|
||||||
lines := strings.Split(content, "\n")
|
lines := strings.Split(content, "\n")
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ToolStatus struct {
|
type ToolStatus struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
Installed bool `yaml:"installed"`
|
Installed bool `yaml:"installed"`
|
||||||
Version string `yaml:"version"`
|
Version string `yaml:"version"`
|
||||||
Path string `yaml:"path"`
|
Path string `yaml:"path"`
|
||||||
Latest string `yaml:"latest"`
|
Latest string `yaml:"latest"`
|
||||||
NeedsUpdate bool `yaml:"needs_update"`
|
NeedsUpdate bool `yaml:"needs_update"`
|
||||||
Category string `yaml:"category"`
|
Category string `yaml:"category"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RuntimeStatus struct {
|
type RuntimeStatus struct {
|
||||||
@@ -30,15 +30,15 @@ type RuntimeStatus struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ScanResult struct {
|
type ScanResult struct {
|
||||||
System platform.SystemInfo `yaml:"system"`
|
System platform.SystemInfo `yaml:"system"`
|
||||||
Tools []ToolStatus `yaml:"tools"`
|
Tools []ToolStatus `yaml:"tools"`
|
||||||
Runtimes []RuntimeStatus `yaml:"runtimes"`
|
Runtimes []RuntimeStatus `yaml:"runtimes"`
|
||||||
ShellSetup bool `yaml:"shell_setup"`
|
ShellSetup bool `yaml:"shell_setup"`
|
||||||
GitConfigured bool `yaml:"git_configured"`
|
GitConfigured bool `yaml:"git_configured"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
cacheMu sync.RWMutex
|
cacheMu sync.RWMutex
|
||||||
cacheResult *ScanResult
|
cacheResult *ScanResult
|
||||||
cacheTime time.Time
|
cacheTime time.Time
|
||||||
cacheTTL = 5 * time.Minute
|
cacheTTL = 5 * time.Minute
|
||||||
@@ -193,43 +193,6 @@ func checkGitConfig() bool {
|
|||||||
return true
|
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+`)
|
var versionRegex = regexp.MustCompile(`\d+\.\d+\.\d+`)
|
||||||
|
|
||||||
func (s *ScanResult) Summary() string {
|
func (s *ScanResult) Summary() string {
|
||||||
|
|||||||
@@ -22,15 +22,12 @@ const api = {
|
|||||||
getLSP: () => request('/lsp'),
|
getLSP: () => request('/lsp'),
|
||||||
getMCP: () => request('/mcp'),
|
getMCP: () => request('/mcp'),
|
||||||
getUpdates: () => request('/updates'),
|
getUpdates: () => request('/updates'),
|
||||||
getEditors: () => request('/editors'),
|
|
||||||
runScan: () => request('/scan', { method: 'POST' }),
|
runScan: () => request('/scan', { method: 'POST' }),
|
||||||
installTools: (tools) => request('/install', { method: 'POST', body: JSON.stringify({ tools }) }),
|
installTools: (tools) => request('/install', { method: 'POST', body: JSON.stringify({ tools }) }),
|
||||||
configureMCP: () => request('/mcp/configure', { method: 'POST' }),
|
configureMCP: () => request('/mcp/configure', { method: 'POST' }),
|
||||||
savePreferences: (prefs) => request('/preferences', { method: 'PUT', body: JSON.stringify(prefs) }),
|
savePreferences: (prefs) => request('/preferences', { method: 'PUT', body: JSON.stringify(prefs) }),
|
||||||
saveProfile: (profile) => request('/config/profile', { method: 'PUT', body: JSON.stringify(profile) }),
|
saveProfile: (profile) => request('/config/profile', { method: 'PUT', body: JSON.stringify(profile) }),
|
||||||
saveProvider: (provider) => request('/config/provider', { method: 'PUT', body: JSON.stringify(provider) }),
|
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) }),
|
validateProvider: (provider) => request('/providers/validate', { method: 'POST', body: JSON.stringify(provider) }),
|
||||||
runUpdate: (tool) => request('/update/run', { method: 'POST', body: JSON.stringify({ tool: tool || '' }) }),
|
runUpdate: (tool) => request('/update/run', { method: 'POST', body: JSON.stringify({ tool: tool || '' }) }),
|
||||||
runCommand: (command, cwd) => request('/terminal', { method: 'POST', body: JSON.stringify({ command, cwd }) }),
|
runCommand: (command, cwd) => request('/terminal', { method: 'POST', body: JSON.stringify({ command, cwd }) }),
|
||||||
@@ -74,8 +71,6 @@ const api = {
|
|||||||
if (onChunk) onChunk(full, data)
|
if (onChunk) onChunk(full, data)
|
||||||
} else if (data.thinking !== undefined || data.thinking_end) {
|
} else if (data.thinking !== undefined || data.thinking_end) {
|
||||||
if (onChunk) onChunk(full, data)
|
if (onChunk) onChunk(full, data)
|
||||||
} else if (data.tool_call || data.tool_result) {
|
|
||||||
if (onChunk) onChunk(full, data)
|
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import Dashboard from './Dashboard'
|
|||||||
import Studio from './Studio'
|
import Studio from './Studio'
|
||||||
import Shell from './Shell'
|
import Shell from './Shell'
|
||||||
import Config from './Config'
|
import Config from './Config'
|
||||||
import OnboardingWizard from './OnboardingWizard'
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [activeTab, setActiveTab] = useState('dash')
|
const [activeTab, setActiveTab] = useState('dash')
|
||||||
@@ -16,7 +15,6 @@ export default function App() {
|
|||||||
const [updates, setUpdates] = useState([])
|
const [updates, setUpdates] = useState([])
|
||||||
const [tools, setTools] = useState([])
|
const [tools, setTools] = useState([])
|
||||||
const [config, setConfig] = useState(null)
|
const [config, setConfig] = useState(null)
|
||||||
const [showOnboarding, setShowOnboarding] = useState(false)
|
|
||||||
const { t, layout } = useI18n()
|
const { t, layout } = useI18n()
|
||||||
|
|
||||||
const TABS = useMemo(() => [
|
const TABS = useMemo(() => [
|
||||||
@@ -34,11 +32,8 @@ export default function App() {
|
|||||||
setConfig(d)
|
setConfig(d)
|
||||||
const theme = d.profile?.preferences?.theme || 'cyberpunk-red'
|
const theme = d.profile?.preferences?.theme || 'cyberpunk-red'
|
||||||
applyTheme(getTheme(theme))
|
applyTheme(getTheme(theme))
|
||||||
const hasProfile = d.profile?.name || d.profile?.pseudo
|
|
||||||
if (!hasProfile) setShowOnboarding(true)
|
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
applyTheme(getTheme('cyberpunk-red'))
|
applyTheme(getTheme('cyberpunk-red'))
|
||||||
setShowOnboarding(true)
|
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -155,8 +150,6 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
{showOnboarding && <OnboardingWizard api={api} onComplete={() => setShowOnboarding(false)} />}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { User, Brain, RefreshCw, Globe, Wrench, Monitor } from 'lucide-react'
|
import { User, Brain, RefreshCw, Globe, Wrench } from 'lucide-react'
|
||||||
import { useI18n, LANGUAGES } from '../i18n'
|
import { useI18n, LANGUAGES } from '../i18n'
|
||||||
import { getLayoutList } from '../i18n/keyboards'
|
import { getLayoutList } from '../i18n/keyboards'
|
||||||
|
|
||||||
const PANELS = [
|
const PANELS = [
|
||||||
{ id: 'profile', icon: User },
|
{ id: 'profile', icon: User },
|
||||||
{ id: 'providers', icon: Brain },
|
{ id: 'providers', icon: Brain },
|
||||||
|
{ id: 'terminal', icon: Monitor },
|
||||||
{ id: 'updates', icon: RefreshCw },
|
{ id: 'updates', icon: RefreshCw },
|
||||||
{ id: 'locale', icon: Globe },
|
{ id: 'locale', icon: Globe },
|
||||||
{ id: 'skills', icon: Wrench },
|
{ id: 'skills', icon: Wrench },
|
||||||
{ id: 'system', icon: Monitor },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function Config({ api }) {
|
export default function Config({ api }) {
|
||||||
@@ -25,7 +25,7 @@ export default function Config({ api }) {
|
|||||||
const [editProfile, setEditProfile] = useState(false)
|
const [editProfile, setEditProfile] = useState(false)
|
||||||
const [editProvider, setEditProvider] = useState(null)
|
const [editProvider, setEditProvider] = useState(null)
|
||||||
const [profileForm, setProfileForm] = useState({})
|
const [profileForm, setProfileForm] = useState({})
|
||||||
const [providerForm, setProviderForm] = useState({}) // keyed by provider name
|
const [providerForm, setProviderForm] = useState({})
|
||||||
const [toast, setToast] = useState(null)
|
const [toast, setToast] = useState(null)
|
||||||
|
|
||||||
|
|
||||||
@@ -107,11 +107,9 @@ export default function Config({ api }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSaveProvider = async (name) => {
|
const handleSaveProvider = async () => {
|
||||||
const form = providerForm[name]
|
|
||||||
if (!form) return
|
|
||||||
try {
|
try {
|
||||||
await api.saveProvider({ name, ...form })
|
await api.saveProvider(providerForm)
|
||||||
setEditProvider(null)
|
setEditProvider(null)
|
||||||
loadData()
|
loadData()
|
||||||
showToast(t('config.saved'))
|
showToast(t('config.saved'))
|
||||||
@@ -121,15 +119,12 @@ export default function Config({ api }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openProviderEdit = (p) => {
|
const openProviderEdit = (p) => {
|
||||||
setProviderForm(prev => ({
|
setProviderForm({
|
||||||
...prev,
|
name: p.name,
|
||||||
[p.name]: {
|
api_key: p.apiKey || '',
|
||||||
name: p.name,
|
model: p.model || '',
|
||||||
api_key: p.apiKey || '',
|
base_url: p.baseURL || '',
|
||||||
model: p.model || '',
|
})
|
||||||
base_url: p.baseURL || '',
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
setEditProvider(p.name)
|
setEditProvider(p.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,9 +193,6 @@ export default function Config({ api }) {
|
|||||||
{activePanel === 'skills' && (
|
{activePanel === 'skills' && (
|
||||||
<PanelSkills skillList={skillList} t={t} />
|
<PanelSkills skillList={skillList} t={t} />
|
||||||
)}
|
)}
|
||||||
{activePanel === 'system' && (
|
|
||||||
<PanelSystem api={api} t={t} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -307,26 +299,23 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
|||||||
className="config-form-input"
|
className="config-form-input"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder={t('config.tokenPlaceholder')}
|
placeholder={t('config.tokenPlaceholder')}
|
||||||
value={isEditing ? (providerForm[p.name]?.api_key || '') : ''}
|
value={isEditing ? providerForm.api_key : ''}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
if (!isEditing) openProviderEdit(p)
|
if (!isEditing) openProviderEdit(p)
|
||||||
setProviderForm(prev => ({
|
setProviderForm(f => ({ ...f, api_key: e.target.value }))
|
||||||
...prev,
|
|
||||||
[p.name]: { ...(prev[p.name] || {}), api_key: e.target.value },
|
|
||||||
}))
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="provider-setup-token-actions">
|
<div className="provider-setup-token-actions">
|
||||||
<button
|
<button
|
||||||
className="sm primary"
|
className="sm primary"
|
||||||
disabled={validating === p.name || !providerForm[p.name]?.api_key}
|
disabled={validating === p.name || !providerForm.api_key}
|
||||||
onClick={() => handleValidate(p.name, providerForm[p.name]?.api_key, providerForm[p.name]?.model, providerForm[p.name]?.base_url)}
|
onClick={() => handleValidate(p.name, providerForm.api_key, providerForm.model, providerForm.base_url)}
|
||||||
>
|
>
|
||||||
{validating === p.name ? t('config.validating') : t('config.validateKey')}
|
{validating === p.name ? t('config.validating') : t('config.validateKey')}
|
||||||
</button>
|
</button>
|
||||||
{isValidationTarget && validationStatus?.valid && (
|
{isValidationTarget && validationStatus?.valid && (
|
||||||
<button className="sm" onClick={() => handleSaveProvider(p.name)}>{t('config.save')}</button>
|
<button className="sm" onClick={handleSaveProvider}>{t('config.save')}</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -455,73 +444,6 @@ function PanelSkills({ skillList, t }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PanelSystem({ api, t }) {
|
|
||||||
const [resetConfirm, setResetConfirm] = useState(false)
|
|
||||||
const [toast, setToast] = useState(null)
|
|
||||||
|
|
||||||
const showToast = (msg) => {
|
|
||||||
setToast(msg)
|
|
||||||
setTimeout(() => setToast(null), 3000)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleReset = async () => {
|
|
||||||
try {
|
|
||||||
await api.resetConfig()
|
|
||||||
setResetConfirm(false)
|
|
||||||
showToast(t('config.resetDone'))
|
|
||||||
setTimeout(() => window.location.reload(), 1500)
|
|
||||||
} catch (err) {
|
|
||||||
showToast(`${t('config.error')}: ${err.message}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleApplyStarship = async () => {
|
|
||||||
try {
|
|
||||||
await api.applyStarshipTheme('charm')
|
|
||||||
showToast(t('config.starshipApplied'))
|
|
||||||
} catch (err) {
|
|
||||||
showToast(`${t('config.error')}: ${err.message}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{toast && <div className="config-toast">{toast}</div>}
|
|
||||||
<div className="config-card">
|
|
||||||
<div className="config-card-row" style={{ marginBottom: 16 }}>
|
|
||||||
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.applyStarship')}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 12 }}>
|
|
||||||
{t('config.starshipApplied')}
|
|
||||||
</div>
|
|
||||||
<button className="sm primary" onClick={handleApplyStarship}>
|
|
||||||
{t('config.applyStarship')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="config-card" style={{ marginTop: 12 }}>
|
|
||||||
<div className="config-card-row" style={{ marginBottom: 16 }}>
|
|
||||||
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.resetConfig')}</span>
|
|
||||||
</div>
|
|
||||||
{resetConfirm ? (
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--warning)', marginBottom: 12 }}>
|
|
||||||
{t('config.resetConfirm')}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
|
||||||
<button className="sm" onClick={() => setResetConfirm(false)}>{t('config.cancel')}</button>
|
|
||||||
<button className="sm danger" onClick={handleReset}>{t('config.resetConfig')}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button className="sm ghost danger" onClick={() => setResetConfirm(true)}>
|
|
||||||
{t('config.resetConfig')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FormInput({ label, value, onChange, type = 'text' }) {
|
function FormInput({ label, value, onChange, type = 'text' }) {
|
||||||
return (
|
return (
|
||||||
<div className="config-form-field">
|
<div className="config-form-field">
|
||||||
|
|||||||
@@ -1,407 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -78,71 +78,6 @@ 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 }) {
|
function FeedItem({ msg }) {
|
||||||
const isUser = msg.role === 'user'
|
const isUser = msg.role === 'user'
|
||||||
const isSystem = msg.role === 'system'
|
const isSystem = msg.role === 'system'
|
||||||
@@ -150,16 +85,6 @@ function FeedItem({ msg }) {
|
|||||||
|
|
||||||
const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''
|
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) {
|
if (isSystem) {
|
||||||
return (
|
return (
|
||||||
<div className="feed-item system">
|
<div className="feed-item system">
|
||||||
@@ -170,7 +95,7 @@ function FeedItem({ msg }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanContent = displayContent.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
const cleanContent = msg.content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`feed-item ${msg.role}`}>
|
<div className={`feed-item ${msg.role}`}>
|
||||||
@@ -186,32 +111,26 @@ function FeedItem({ msg }) {
|
|||||||
{timeStr && <span className="feed-time">{timeStr}</span>}
|
{timeStr && <span className="feed-time">{timeStr}</span>}
|
||||||
</div>
|
</div>
|
||||||
{msg.thinking && <ThinkingBlock content={msg.thinking} done />}
|
{msg.thinking && <ThinkingBlock content={msg.thinking} done />}
|
||||||
{parsedToolCalls && parsedToolCalls.map((tc, i) => (
|
<div className="feed-content">
|
||||||
<ToolCallBlock key={tc.tool_call_id || i} call={tc} result={null} />
|
{renderContent(cleanContent).map((part, i) =>
|
||||||
))}
|
part.type === 'code' ? (
|
||||||
{cleanContent && (
|
<div key={i} className="studio-code-block">
|
||||||
<div className="feed-content">
|
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
|
||||||
{renderContent(cleanContent).map((part, i) =>
|
<pre><code>{part.content}</code></pre>
|
||||||
part.type === 'code' ? (
|
</div>
|
||||||
<div key={i} className="studio-code-block">
|
) : (
|
||||||
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
|
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||||
<pre><code>{part.content}</code></pre>
|
)
|
||||||
</div>
|
)}
|
||||||
) : (
|
</div>
|
||||||
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function StreamingItem({ content, thinking, toolCalls }) {
|
function StreamingItem({ content, thinking }) {
|
||||||
const rank = RANKS.general
|
const rank = RANKS.general
|
||||||
const cleanContent = content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
const cleanContent = content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||||||
const hasToolCalls = toolCalls && toolCalls.length > 0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="feed-item assistant">
|
<div className="feed-item assistant">
|
||||||
@@ -226,10 +145,7 @@ function StreamingItem({ content, thinking, toolCalls }) {
|
|||||||
<span className="feed-role">{rank.label}</span>
|
<span className="feed-role">{rank.label}</span>
|
||||||
</div>
|
</div>
|
||||||
{thinking && <ThinkingBlock content={thinking} done={false} />}
|
{thinking && <ThinkingBlock content={thinking} done={false} />}
|
||||||
{hasToolCalls && toolCalls.map((tc, i) => (
|
{!thinking && !cleanContent && (
|
||||||
<ToolCallBlock key={tc.call?.tool_call_id || i} call={tc.call} result={tc.result} />
|
|
||||||
))}
|
|
||||||
{!thinking && !cleanContent && !hasToolCalls && (
|
|
||||||
<div className="feed-content">
|
<div className="feed-content">
|
||||||
<div className="studio-thinking"><span /><span /><span /></div>
|
<div className="studio-thinking"><span /><span /><span /></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -261,7 +177,6 @@ export default function Studio({ api }) {
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [streaming, setStreaming] = useState('')
|
const [streaming, setStreaming] = useState('')
|
||||||
const [streamThinking, setStreamThinking] = useState('')
|
const [streamThinking, setStreamThinking] = useState('')
|
||||||
const [streamToolCalls, setStreamToolCalls] = useState([])
|
|
||||||
const [loaded, setLoaded] = useState(false)
|
const [loaded, setLoaded] = useState(false)
|
||||||
const messagesEnd = useRef(null)
|
const messagesEnd = useRef(null)
|
||||||
const textareaRef = useRef(null)
|
const textareaRef = useRef(null)
|
||||||
@@ -286,7 +201,7 @@ export default function Studio({ api }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
|
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
}, [messages, streaming, streamThinking, streamToolCalls])
|
}, [messages, streaming, streamThinking])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (textareaRef.current) {
|
if (textareaRef.current) {
|
||||||
@@ -319,12 +234,10 @@ export default function Studio({ api }) {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
setStreaming('')
|
setStreaming('')
|
||||||
setStreamThinking('')
|
setStreamThinking('')
|
||||||
setStreamToolCalls([])
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let accumulated = ''
|
let accumulated = ''
|
||||||
let thinking = ''
|
let thinking = ''
|
||||||
let toolCalls = []
|
|
||||||
|
|
||||||
await api.sendChat(text, true, (partial, event) => {
|
await api.sendChat(text, true, (partial, event) => {
|
||||||
if (event && (event.thinking_start || event.thinking_end || event.thinking !== undefined)) {
|
if (event && (event.thinking_start || event.thinking_end || event.thinking !== undefined)) {
|
||||||
@@ -334,19 +247,6 @@ export default function Studio({ api }) {
|
|||||||
}
|
}
|
||||||
return
|
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
|
accumulated = partial
|
||||||
setStreaming(partial)
|
setStreaming(partial)
|
||||||
})
|
})
|
||||||
@@ -359,12 +259,6 @@ export default function Studio({ api }) {
|
|||||||
time: new Date().toISOString(),
|
time: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
if (thinking) aiMsg.thinking = thinking
|
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])
|
setMessages(prev => [...prev, aiMsg])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMessages(prev => [...prev, {
|
setMessages(prev => [...prev, {
|
||||||
@@ -377,7 +271,6 @@ export default function Studio({ api }) {
|
|||||||
setLoading(false)
|
setLoading(false)
|
||||||
setStreaming('')
|
setStreaming('')
|
||||||
setStreamThinking('')
|
setStreamThinking('')
|
||||||
setStreamToolCalls([])
|
|
||||||
}
|
}
|
||||||
}, [input, loading, api, t, handleClear])
|
}, [input, loading, api, t, handleClear])
|
||||||
|
|
||||||
@@ -406,8 +299,8 @@ export default function Studio({ api }) {
|
|||||||
{messages.map(msg => (
|
{messages.map(msg => (
|
||||||
<FeedItem key={msg.id} msg={msg} />
|
<FeedItem key={msg.id} msg={msg} />
|
||||||
))}
|
))}
|
||||||
{(streaming || streamThinking || loading || streamToolCalls.length > 0) && (
|
{(streaming || streamThinking || loading) && (
|
||||||
<StreamingItem content={streaming} thinking={streamThinking} toolCalls={streamToolCalls} />
|
<StreamingItem content={streaming} thinking={streamThinking} />
|
||||||
)}
|
)}
|
||||||
<div ref={messagesEnd} />
|
<div ref={messagesEnd} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -121,7 +121,6 @@ const en = {
|
|||||||
updates: 'Updates',
|
updates: 'Updates',
|
||||||
locale: 'Language & Keyboard',
|
locale: 'Language & Keyboard',
|
||||||
skills: 'Skills',
|
skills: 'Skills',
|
||||||
system: 'System',
|
|
||||||
},
|
},
|
||||||
profile: 'Profile',
|
profile: 'Profile',
|
||||||
name: 'Name',
|
name: 'Name',
|
||||||
@@ -181,12 +180,6 @@ const en = {
|
|||||||
fontFamily: 'Font Family',
|
fontFamily: 'Font Family',
|
||||||
preview: 'Preview',
|
preview: 'Preview',
|
||||||
saving: 'Saving...',
|
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.',
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -120,8 +120,7 @@ const fr = {
|
|||||||
terminal: 'Terminal',
|
terminal: 'Terminal',
|
||||||
updates: 'Mises \u00e0 jour',
|
updates: 'Mises \u00e0 jour',
|
||||||
locale: 'Langue & Clavier',
|
locale: 'Langue & Clavier',
|
||||||
skills: 'Comp\u00e9ENCES',
|
skills: 'Comp\u00e9tences',
|
||||||
system: 'Syst\u00e8me',
|
|
||||||
},
|
},
|
||||||
profile: 'Profil',
|
profile: 'Profil',
|
||||||
name: 'Nom',
|
name: 'Nom',
|
||||||
@@ -144,7 +143,7 @@ const fr = {
|
|||||||
save: 'Enregistrer',
|
save: 'Enregistrer',
|
||||||
saved: 'Enregistr\u00e9 !',
|
saved: 'Enregistr\u00e9 !',
|
||||||
error: 'Erreur',
|
error: 'Erreur',
|
||||||
skills: 'Comp\u00e9ENCES',
|
skills: 'Comp\u00e9tences',
|
||||||
noSkills: 'Aucune comp\u00e9tence install\u00e9e.',
|
noSkills: 'Aucune comp\u00e9tence install\u00e9e.',
|
||||||
runSkillsInit: 'Ex\u00e9cutez muyue skills init',
|
runSkillsInit: 'Ex\u00e9cutez muyue skills init',
|
||||||
language: 'Langue',
|
language: 'Langue',
|
||||||
@@ -168,10 +167,10 @@ const fr = {
|
|||||||
editProfile: 'Modifier',
|
editProfile: 'Modifier',
|
||||||
editProvider: 'Configurer',
|
editProvider: 'Configurer',
|
||||||
validateKey: 'Valider',
|
validateKey: 'Valider',
|
||||||
validating: 'V\u00e9rification...',
|
validating: 'Vérification...',
|
||||||
keyValid: 'Cl\u00e9 valide',
|
keyValid: 'Clé valide',
|
||||||
keyInvalid: 'Cl\u00e9 invalide',
|
keyInvalid: 'Clé invalide',
|
||||||
connectionFailed: 'Connexion \u00e9chou\u00e9e',
|
connectionFailed: 'Connexion échouée',
|
||||||
enterToken: 'Entrez votre token API pour {provider}',
|
enterToken: 'Entrez votre token API pour {provider}',
|
||||||
tokenPlaceholder: 'sk-...',
|
tokenPlaceholder: 'sk-...',
|
||||||
setupDescription: 'Configurez le token de votre fournisseur IA pour utiliser l\'assistant.',
|
setupDescription: 'Configurez le token de votre fournisseur IA pour utiliser l\'assistant.',
|
||||||
@@ -181,12 +180,6 @@ const fr = {
|
|||||||
fontFamily: 'Police',
|
fontFamily: 'Police',
|
||||||
preview: 'Aper\u00e7u',
|
preview: 'Aper\u00e7u',
|
||||||
saving: 'Enregistrement...',
|
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.',
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -678,91 +678,3 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.studio-send-btn:hover:not(:disabled) { background: var(--accent-bright); border-color: var(--accent-bright); }
|
.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-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-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);
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user