Compare commits

...

5 Commits

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

💘 Generated with Crush

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

💘 Generated with Crush

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

💘 Generated with Crush

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

💘 Generated with Crush

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

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:56:04 +02:00
15 changed files with 1922 additions and 200 deletions

View File

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

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

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ import { getLayoutList } from '../i18n/keyboards'
const PANELS = [
{ id: 'profile', icon: User },
{ id: 'providers', icon: Brain },
{ id: 'terminal', icon: Monitor },
{ id: 'updates', icon: RefreshCw },
{ id: 'locale', icon: Globe },
{ id: 'skills', icon: Wrench },
@@ -26,7 +25,7 @@ export default function Config({ api }) {
const [editProfile, setEditProfile] = useState(false)
const [editProvider, setEditProvider] = useState(null)
const [profileForm, setProfileForm] = useState({})
const [providerForm, setProviderForm] = useState({})
const [providerForm, setProviderForm] = useState({}) // keyed by provider name
const [toast, setToast] = useState(null)
@@ -108,9 +107,11 @@ export default function Config({ api }) {
}
}
const handleSaveProvider = async () => {
const handleSaveProvider = async (name) => {
const form = providerForm[name]
if (!form) return
try {
await api.saveProvider(providerForm)
await api.saveProvider({ name, ...form })
setEditProvider(null)
loadData()
showToast(t('config.saved'))
@@ -120,12 +121,15 @@ export default function Config({ api }) {
}
const openProviderEdit = (p) => {
setProviderForm({
name: p.name,
api_key: p.apiKey || '',
model: p.model || '',
base_url: p.baseURL || '',
})
setProviderForm(prev => ({
...prev,
[p.name]: {
name: p.name,
api_key: p.apiKey || '',
model: p.model || '',
base_url: p.baseURL || '',
},
}))
setEditProvider(p.name)
}
@@ -303,23 +307,26 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
className="config-form-input"
type="password"
placeholder={t('config.tokenPlaceholder')}
value={isEditing ? providerForm.api_key : ''}
value={isEditing ? (providerForm[p.name]?.api_key || '') : ''}
onChange={e => {
if (!isEditing) openProviderEdit(p)
setProviderForm(f => ({ ...f, api_key: e.target.value }))
setProviderForm(prev => ({
...prev,
[p.name]: { ...(prev[p.name] || {}), api_key: e.target.value },
}))
}}
/>
</div>
<div className="provider-setup-token-actions">
<button
className="sm primary"
disabled={validating === p.name || !providerForm.api_key}
onClick={() => handleValidate(p.name, providerForm.api_key, providerForm.model, providerForm.base_url)}
disabled={validating === p.name || !providerForm[p.name]?.api_key}
onClick={() => handleValidate(p.name, providerForm[p.name]?.api_key, providerForm[p.name]?.model, providerForm[p.name]?.base_url)}
>
{validating === p.name ? t('config.validating') : t('config.validateKey')}
</button>
{isValidationTarget && validationStatus?.valid && (
<button className="sm" onClick={handleSaveProvider}>{t('config.save')}</button>
<button className="sm" onClick={() => handleSaveProvider(p.name)}>{t('config.save')}</button>
)}
</div>
</div>

View File

@@ -1,18 +1,19 @@
import { useState, useEffect } from 'react'
import { Sparkles, ArrowRight, ArrowLeft } from 'lucide-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', field: null },
{ key: 'name', title: 'name', field: 'name' },
{ key: 'language', title: 'language', field: 'language' },
{ key: 'keyboard', title: 'keyboard', field: 'keyboard' },
{ key: 'editor', title: 'editor', field: 'editor' },
{ key: 'done', title: 'done', field: null },
{ 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 EDITOR_SUGGESTIONS = ['vim', 'nvim', 'vscode', 'emacs', 'nano', 'helix']
const BASE_EDITORS = ['vim', 'nvim', 'vscode', 'emacs', 'nano', 'helix']
export default function OnboardingWizard({ api, onComplete }) {
const { t, language, keyboard, setLanguage, setKeyboard } = useI18n()
@@ -21,18 +22,41 @@ export default function OnboardingWizard({ api, onComplete }) {
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) setStep(step + 1)
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)
}
@@ -40,7 +64,7 @@ export default function OnboardingWizard({ api, onComplete }) {
useEffect(() => {
const handler = (e) => {
if (e.key === 'Escape') { goPrev(); return }
if (e.key === 'Enter' && current.key !== 'done') { e.preventDefault(); goNext() }
if (e.key === 'Enter' && current.key !== 'done' && current.key !== 'editor') { e.preventDefault(); goNext() }
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
@@ -52,19 +76,68 @@ export default function OnboardingWizard({ api, onComplete }) {
}
}, [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 {
await api.saveProfile({
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')
@@ -103,15 +176,16 @@ export default function OnboardingWizard({ api, onComplete }) {
className="onboarding-input"
placeholder="Votre nom..."
value={answers.name}
onChange={e => setAnswers(a => ({ ...a, name: e.target.value }))}
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\u00e9f\u00e9rez-vous ?</div>
<div className="onboarding-title">Quelle langue préférez-vous ?</div>
<div className="onboarding-chips">
{LANGUAGES.map(lang => (
<div
@@ -143,28 +217,78 @@ export default function OnboardingWizard({ api, onComplete }) {
</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 \u00e9diteur utilisez-vous ?</div>
<div className="onboarding-chips">
{EDITOR_SUGGESTIONS.map(ed => (
<div
key={ed}
className={`chip ${answers.editor === ed ? 'active' : ''}`}
onClick={() => setAnswers(a => ({ ...a, editor: ed }))}
>
{ed}
</div>
))}
<div 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 (vim, nvim, vscode...)"
placeholder="Autre éditeur..."
value={answers.editor}
onChange={e => setAnswers(a => ({ ...a, editor: e.target.value }))}
autoFocus
/>
{error && <div className="onboarding-required">{error}</div>}
</div>
)}
@@ -205,6 +329,11 @@ export default function OnboardingWizard({ api, onComplete }) {
Suivant <ArrowRight size={14} />
</button>
)}
{step === STEPS.length - 1 && !saving && !error && (
<button className="primary" onClick={handleSave}>
Commencer
</button>
)}
</div>
</div>
@@ -256,6 +385,22 @@ export default function OnboardingWizard({ api, onComplete }) {
padding: 16px 20px; border-top: 1px solid var(--border);
background: var(--bg-surface);
}
.onboarding-required {
font-size: 12px; color: var(--error); margin-top: 4px;
}
.onboarding-valid {
font-size: 12px; color: var(--success); margin-top: 4px;
}
.onboarding-hint {
font-size: 12px; color: var(--text-tertiary); margin-top: 4px;
}
.spin-icon {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
</div>
)

View File

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

View File

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