Compare commits
6 Commits
v0.3.2-bet
...
v0.3.3-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e76e7dca6 | ||
|
|
e8f6dc4b4d | ||
|
|
bb03c9fe2d | ||
|
|
79d082180c | ||
|
|
7682717093 | ||
|
|
3948a4c656 |
@@ -9,7 +9,7 @@ import (
|
|||||||
|
|
||||||
var versionCmd = &cobra.Command{
|
var versionCmd = &cobra.Command{
|
||||||
Use: "version",
|
Use: "version",
|
||||||
Short: "Print version",
|
Short: "Print version info",
|
||||||
RunE: runVersion,
|
RunE: runVersion,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,6 +18,6 @@ func init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runVersion(cmd *cobra.Command, args []string) error {
|
func runVersion(cmd *cobra.Command, args []string) error {
|
||||||
fmt.Printf("Muyue version %s\n", version.Version)
|
fmt.Print(version.FullInfo())
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
1
go.mod
1
go.mod
@@ -7,6 +7,7 @@ toolchain go1.24.3
|
|||||||
require (
|
require (
|
||||||
github.com/charmbracelet/huh v1.0.0
|
github.com/charmbracelet/huh v1.0.0
|
||||||
github.com/creack/pty/v2 v2.0.1
|
github.com/creack/pty/v2 v2.0.1
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -51,6 +51,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
|||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
|||||||
249
internal/api/chat_engine.go
Normal file
249
internal/api/chat_engine.go
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/agent"
|
||||||
|
"github.com/muyue/muyue/internal/orchestrator"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
MaxToolIterations = 15
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChatEngine handles chat interactions with tool execution.
|
||||||
|
// This deduplicates chat logic previously repeated in handlers_chat.go and handlers_shell_chat.go.
|
||||||
|
type ChatEngine struct {
|
||||||
|
orchestrator *orchestrator.Orchestrator
|
||||||
|
registry *agent.Registry
|
||||||
|
tools json.RawMessage
|
||||||
|
onChunk func(map[string]interface{})
|
||||||
|
stream bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewChatEngine creates a new ChatEngine instance.
|
||||||
|
func NewChatEngine(orb *orchestrator.Orchestrator, registry *agent.Registry, tools json.RawMessage) *ChatEngine {
|
||||||
|
return &ChatEngine{
|
||||||
|
orchestrator: orb,
|
||||||
|
registry: registry,
|
||||||
|
tools: tools,
|
||||||
|
stream: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetStream enables streaming mode for the chat engine.
|
||||||
|
func (ce *ChatEngine) SetStream(enabled bool) {
|
||||||
|
ce.stream = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnChunk sets the callback for SSE chunk writing.
|
||||||
|
func (ce *ChatEngine) OnChunk(fn func(map[string]interface{})) {
|
||||||
|
ce.onChunk = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunWithTools executes the chat loop with tool calls.
|
||||||
|
// Returns final content, tool calls, tool results, and error.
|
||||||
|
func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.Message) (string, []map[string]interface{}, []map[string]interface{}, error) {
|
||||||
|
var finalContent string
|
||||||
|
var allToolCalls []map[string]interface{}
|
||||||
|
var allToolResults []map[string]interface{}
|
||||||
|
|
||||||
|
for i := 0; i < MaxToolIterations; i++ {
|
||||||
|
var resp *orchestrator.ChatResponse
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if ce.stream {
|
||||||
|
// Use streaming version
|
||||||
|
resp, err = ce.orchestrator.SendWithToolsStream(messages, func(content string, toolCalls []orchestrator.ToolCallMsg) {
|
||||||
|
if ce.onChunk != nil && content != "" {
|
||||||
|
ce.onChunk(map[string]interface{}{"content": content})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
resp, err = ce.orchestrator.SendWithTools(messages)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if ce.onChunk != nil {
|
||||||
|
ce.onChunk(map[string]interface{}{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return finalContent, allToolCalls, allToolResults, err
|
||||||
|
}
|
||||||
|
|
||||||
|
choice := resp.Choices[0]
|
||||||
|
content := cleanThinkingTags(choice.Message.Content)
|
||||||
|
|
||||||
|
if content != "" {
|
||||||
|
words := strings.Fields(content)
|
||||||
|
for _, w := range words {
|
||||||
|
chunk := w
|
||||||
|
if ce.onChunk != nil {
|
||||||
|
ce.onChunk(map[string]interface{}{"content": chunk})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
|
||||||
|
if ce.onChunk != nil {
|
||||||
|
ce.onChunk(map[string]interface{}{"tool_call": toolCallData})
|
||||||
|
}
|
||||||
|
|
||||||
|
call := agent.ToolCall{
|
||||||
|
ID: tc.ID,
|
||||||
|
Name: tc.Function.Name,
|
||||||
|
Arguments: json.RawMessage(tc.Function.Arguments),
|
||||||
|
}
|
||||||
|
|
||||||
|
result, execErr := ce.registry.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,
|
||||||
|
}
|
||||||
|
allToolResults = append(allToolResults, map[string]interface{}{
|
||||||
|
"tool_call_id": tc.ID,
|
||||||
|
"name": tc.Function.Name,
|
||||||
|
"args": tc.Function.Arguments,
|
||||||
|
"result": result.Content,
|
||||||
|
"is_error": result.IsError,
|
||||||
|
})
|
||||||
|
|
||||||
|
if ce.onChunk != nil {
|
||||||
|
ce.onChunk(map[string]interface{}{"tool_result": resultData})
|
||||||
|
}
|
||||||
|
|
||||||
|
messages = append(messages, orchestrator.Message{
|
||||||
|
Role: "tool",
|
||||||
|
Content: result.Content,
|
||||||
|
ToolCallID: tc.ID,
|
||||||
|
Name: tc.Function.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
finalContent = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalContent, allToolCalls, allToolResults, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunNonStream executes chat without streaming content to client.
|
||||||
|
func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.Message) (string, error) {
|
||||||
|
var finalContent string
|
||||||
|
|
||||||
|
for i := 0; i < MaxToolIterations; i++ {
|
||||||
|
resp, err := ce.orchestrator.SendWithTools(messages)
|
||||||
|
if err != nil {
|
||||||
|
return finalContent, err
|
||||||
|
}
|
||||||
|
|
||||||
|
choice := resp.Choices[0]
|
||||||
|
content := cleanThinkingTags(choice.Message.Content)
|
||||||
|
|
||||||
|
if content != "" {
|
||||||
|
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 {
|
||||||
|
call := agent.ToolCall{
|
||||||
|
ID: tc.ID,
|
||||||
|
Name: tc.Function.Name,
|
||||||
|
Arguments: json.RawMessage(tc.Function.Arguments),
|
||||||
|
}
|
||||||
|
|
||||||
|
result, execErr := ce.registry.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 == "" {
|
||||||
|
finalContent = "(tool calls completed, no text response)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalContent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSEWriter handles Server-Sent Events writing to HTTP response.
|
||||||
|
type SSEWriter struct {
|
||||||
|
w http.ResponseWriter
|
||||||
|
flusher http.Flusher
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSSEWriter creates a new SSEWriter.
|
||||||
|
func NewSSEWriter(w http.ResponseWriter) *SSEWriter {
|
||||||
|
sse := &SSEWriter{w: w}
|
||||||
|
if f, ok := w.(http.Flusher); ok {
|
||||||
|
sse.flusher = f
|
||||||
|
}
|
||||||
|
return sse
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write sends an SSE message.
|
||||||
|
func (s *SSEWriter) Write(data map[string]interface{}) {
|
||||||
|
b, _ := json.Marshal(data)
|
||||||
|
s.w.Write([]byte("data: " + string(b) + "\n\n"))
|
||||||
|
if s.flusher != nil {
|
||||||
|
s.flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupSSEHeaders sets up SSE response headers.
|
||||||
|
func SetupSSEHeaders(w http.ResponseWriter) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
370
internal/api/conversation_multi.go
Normal file
370
internal/api/conversation_multi.go
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/muyue/muyue/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConversationMeta represents metadata for a conversation (used for listing).
|
||||||
|
type ConversationMeta struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
MessageCount int `json:"message_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConversationStoreMulti manages multiple conversations.
|
||||||
|
type ConversationStoreMulti struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
dir string
|
||||||
|
currentID string
|
||||||
|
conversations map[string]*Conversation
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConversationStoreMulti() *ConversationStoreMulti {
|
||||||
|
dir, err := config.ConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
dir = "/tmp/muyue"
|
||||||
|
}
|
||||||
|
dir = filepath.Join(dir, "conversations")
|
||||||
|
|
||||||
|
cs := &ConversationStoreMulti{
|
||||||
|
dir: dir,
|
||||||
|
conversations: make(map[string]*Conversation),
|
||||||
|
}
|
||||||
|
cs.loadIndex()
|
||||||
|
return cs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cs *ConversationStoreMulti) loadIndex() {
|
||||||
|
os.MkdirAll(cs.dir, 0755)
|
||||||
|
|
||||||
|
// Load index file if exists
|
||||||
|
indexPath := filepath.Join(cs.dir, "index.json")
|
||||||
|
data, err := os.ReadFile(indexPath)
|
||||||
|
if err != nil {
|
||||||
|
// Create default conversation
|
||||||
|
cs.createDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var index struct {
|
||||||
|
CurrentID string `json:"current_id"`
|
||||||
|
Conversations []ConversationMeta `json:"conversations"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &index); err != nil {
|
||||||
|
cs.createDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cs.currentID = index.CurrentID
|
||||||
|
if cs.currentID == "" {
|
||||||
|
cs.createDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all conversations
|
||||||
|
for _, meta := range index.Conversations {
|
||||||
|
convPath := filepath.Join(cs.dir, fmt.Sprintf("conv_%s.json", meta.ID))
|
||||||
|
data, err := os.ReadFile(convPath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var conv Conversation
|
||||||
|
if err := json.Unmarshal(data, &conv); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cs.conversations[meta.ID] = &conv
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure current conversation exists
|
||||||
|
if _, ok := cs.conversations[cs.currentID]; !ok {
|
||||||
|
cs.createDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cs *ConversationStoreMulti) createDefault() {
|
||||||
|
cs.currentID = uuid.New().String()
|
||||||
|
cs.conversations[cs.currentID] = &Conversation{
|
||||||
|
Messages: []FeedMessage{},
|
||||||
|
CreatedAt: time.Now().Format(time.RFC3339),
|
||||||
|
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
cs.saveIndex()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cs *ConversationStoreMulti) saveIndex() error {
|
||||||
|
var metas []ConversationMeta
|
||||||
|
for id, conv := range cs.conversations {
|
||||||
|
title := "Nouvelle conversation"
|
||||||
|
if len(conv.Messages) > 0 {
|
||||||
|
// Use first user message as title
|
||||||
|
for _, m := range conv.Messages {
|
||||||
|
if m.Role == "user" {
|
||||||
|
if len(m.Content) > 50 {
|
||||||
|
title = m.Content[:50] + "..."
|
||||||
|
} else {
|
||||||
|
title = m.Content
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
metas = append(metas, ConversationMeta{
|
||||||
|
ID: id,
|
||||||
|
Title: title,
|
||||||
|
CreatedAt: conv.CreatedAt,
|
||||||
|
UpdatedAt: conv.UpdatedAt,
|
||||||
|
MessageCount: len(conv.Messages),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
index := struct {
|
||||||
|
CurrentID string `json:"current_id"`
|
||||||
|
Conversations []ConversationMeta `json:"conversations"`
|
||||||
|
}{
|
||||||
|
CurrentID: cs.currentID,
|
||||||
|
Conversations: metas,
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(index, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(filepath.Join(cs.dir, "index.json"), data, 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cs *ConversationStoreMulti) saveCurrent() error {
|
||||||
|
conv, ok := cs.conversations[cs.currentID]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("no current conversation")
|
||||||
|
}
|
||||||
|
|
||||||
|
conv.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||||
|
data, err := json.MarshalIndent(conv, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
convPath := filepath.Join(cs.dir, fmt.Sprintf("conv_%s.json", cs.currentID))
|
||||||
|
if err := os.WriteFile(convPath, data, 0600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return cs.saveIndex()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current returns the current conversation store.
|
||||||
|
func (cs *ConversationStoreMulti) Current() *ConversationStore {
|
||||||
|
cs.mu.RLock()
|
||||||
|
defer cs.mu.RUnlock()
|
||||||
|
|
||||||
|
conv, ok := cs.conversations[cs.currentID]
|
||||||
|
if !ok {
|
||||||
|
return &ConversationStore{
|
||||||
|
conv: &Conversation{
|
||||||
|
Messages: []FeedMessage{},
|
||||||
|
CreatedAt: time.Now().Format(time.RFC3339),
|
||||||
|
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ConversationStore{
|
||||||
|
conv: conv,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the current conversation messages.
|
||||||
|
func (cs *ConversationStoreMulti) Get() []FeedMessage {
|
||||||
|
cs.mu.RLock()
|
||||||
|
defer cs.mu.RUnlock()
|
||||||
|
|
||||||
|
conv, ok := cs.conversations[cs.currentID]
|
||||||
|
if !ok {
|
||||||
|
return []FeedMessage{}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]FeedMessage, len(conv.Messages))
|
||||||
|
copy(out, conv.Messages)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add adds a message to the current conversation.
|
||||||
|
func (cs *ConversationStoreMulti) Add(role, content string) FeedMessage {
|
||||||
|
cs.mu.Lock()
|
||||||
|
defer cs.mu.Unlock()
|
||||||
|
|
||||||
|
conv, ok := cs.conversations[cs.currentID]
|
||||||
|
if !ok {
|
||||||
|
cs.currentID = uuid.New().String()
|
||||||
|
conv = &Conversation{
|
||||||
|
Messages: []FeedMessage{},
|
||||||
|
CreatedAt: time.Now().Format(time.RFC3339),
|
||||||
|
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
cs.conversations[cs.currentID] = conv
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := FeedMessage{
|
||||||
|
ID: generateMsgID(),
|
||||||
|
Role: role,
|
||||||
|
Content: content,
|
||||||
|
Time: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
conv.Messages = append(conv.Messages, msg)
|
||||||
|
|
||||||
|
go cs.saveCurrent() // Fire and forget
|
||||||
|
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear clears the current conversation.
|
||||||
|
func (cs *ConversationStoreMulti) Clear() {
|
||||||
|
cs.mu.Lock()
|
||||||
|
defer cs.mu.Unlock()
|
||||||
|
|
||||||
|
conv, ok := cs.conversations[cs.currentID]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conv.Messages = []FeedMessage{}
|
||||||
|
conv.Summary = ""
|
||||||
|
conv.CreatedAt = time.Now().Format(time.RFC3339)
|
||||||
|
conv.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||||
|
|
||||||
|
cs.saveCurrent()
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns all conversations.
|
||||||
|
func (cs *ConversationStoreMulti) List() []ConversationMeta {
|
||||||
|
cs.mu.RLock()
|
||||||
|
defer cs.mu.RUnlock()
|
||||||
|
|
||||||
|
var metas []ConversationMeta
|
||||||
|
for id, conv := range cs.conversations {
|
||||||
|
title := "Nouvelle conversation"
|
||||||
|
if len(conv.Messages) > 0 {
|
||||||
|
for _, m := range conv.Messages {
|
||||||
|
if m.Role == "user" {
|
||||||
|
if len(m.Content) > 50 {
|
||||||
|
title = m.Content[:50] + "..."
|
||||||
|
} else {
|
||||||
|
title = m.Content
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
metas = append(metas, ConversationMeta{
|
||||||
|
ID: id,
|
||||||
|
Title: title,
|
||||||
|
CreatedAt: conv.CreatedAt,
|
||||||
|
UpdatedAt: conv.UpdatedAt,
|
||||||
|
MessageCount: len(conv.Messages),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return metas
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a new conversation and switches to it.
|
||||||
|
func (cs *ConversationStoreMulti) Create() string {
|
||||||
|
cs.mu.Lock()
|
||||||
|
defer cs.mu.Unlock()
|
||||||
|
|
||||||
|
id := uuid.New().String()
|
||||||
|
cs.conversations[id] = &Conversation{
|
||||||
|
Messages: []FeedMessage{},
|
||||||
|
CreatedAt: time.Now().Format(time.RFC3339),
|
||||||
|
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
cs.currentID = id
|
||||||
|
cs.saveIndex()
|
||||||
|
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch switches to a different conversation.
|
||||||
|
func (cs *ConversationStoreMulti) Switch(id string) error {
|
||||||
|
cs.mu.Lock()
|
||||||
|
defer cs.mu.Unlock()
|
||||||
|
|
||||||
|
if _, ok := cs.conversations[id]; !ok {
|
||||||
|
return fmt.Errorf("conversation not found: %s", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
cs.currentID = id
|
||||||
|
cs.saveIndex()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID returns a conversation by ID.
|
||||||
|
func (cs *ConversationStoreMulti) GetByID(id string) (*Conversation, error) {
|
||||||
|
cs.mu.RLock()
|
||||||
|
defer cs.mu.RUnlock()
|
||||||
|
|
||||||
|
conv, ok := cs.conversations[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("conversation not found: %s", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return conv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete deletes a conversation.
|
||||||
|
func (cs *ConversationStoreMulti) Delete(id string) error {
|
||||||
|
cs.mu.Lock()
|
||||||
|
defer cs.mu.Unlock()
|
||||||
|
|
||||||
|
if _, ok := cs.conversations[id]; !ok {
|
||||||
|
return fmt.Errorf("conversation not found: %s", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(cs.conversations, id)
|
||||||
|
|
||||||
|
// Delete file
|
||||||
|
convPath := filepath.Join(cs.dir, fmt.Sprintf("conv_%s.json", id))
|
||||||
|
os.Remove(convPath)
|
||||||
|
|
||||||
|
// If deleted current, switch to another
|
||||||
|
if cs.currentID == id {
|
||||||
|
if len(cs.conversations) > 0 {
|
||||||
|
for newID := range cs.conversations {
|
||||||
|
cs.currentID = newID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create new default
|
||||||
|
cs.currentID = uuid.New().String()
|
||||||
|
cs.conversations[cs.currentID] = &Conversation{
|
||||||
|
Messages: []FeedMessage{},
|
||||||
|
CreatedAt: time.Now().Format(time.RFC3339),
|
||||||
|
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cs.saveIndex()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentID returns the current conversation ID.
|
||||||
|
func (cs *ConversationStoreMulti) CurrentID() string {
|
||||||
|
cs.mu.RLock()
|
||||||
|
defer cs.mu.RUnlock()
|
||||||
|
|
||||||
|
return cs.currentID
|
||||||
|
}
|
||||||
@@ -13,8 +13,6 @@ import (
|
|||||||
|
|
||||||
var thinkingTagRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
|
var thinkingTagRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
|
||||||
|
|
||||||
const maxToolIterations = 15
|
|
||||||
|
|
||||||
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" {
|
||||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
@@ -55,108 +53,31 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orchestrator, userMessage string) {
|
func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orchestrator, userMessage string) {
|
||||||
w.Header().Set("Content-Type", "text/event-stream")
|
SetupSSEHeaders(w)
|
||||||
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)
|
flusher, canFlush := w.(http.Flusher)
|
||||||
|
|
||||||
writeSSE := func(data map[string]interface{}) {
|
|
||||||
b, _ := json.Marshal(data)
|
sseWriter := NewSSEWriter(w)
|
||||||
w.Write([]byte("data: " + string(b) + "\n\n"))
|
|
||||||
if canFlush {
|
|
||||||
flusher.Flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
messages := s.buildContextMessages(userMessage)
|
messages := s.buildContextMessages(userMessage)
|
||||||
|
|
||||||
var finalContent string
|
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
|
||||||
var allToolCalls []map[string]interface{}
|
engine.OnChunk(func(data map[string]interface{}) {
|
||||||
var allToolResults []map[string]interface{}
|
if data == nil {
|
||||||
|
|
||||||
for i := 0; i < maxToolIterations; i++ {
|
|
||||||
resp, err := orb.SendWithTools(messages)
|
|
||||||
if err != nil {
|
|
||||||
writeSSE(map[string]interface{}{"error": err.Error()})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
sseWriter.Write(data)
|
||||||
choice := resp.Choices[0]
|
if canFlush {
|
||||||
content := cleanThinkingTags(choice.Message.Content)
|
flusher.Flush()
|
||||||
|
|
||||||
if content != "" {
|
|
||||||
words := strings.Fields(content)
|
|
||||||
for i, w := range words {
|
|
||||||
chunk := w
|
|
||||||
if i < len(words)-1 {
|
|
||||||
chunk += " "
|
|
||||||
}
|
}
|
||||||
writeSSE(map[string]interface{}{"content": chunk})
|
|
||||||
}
|
|
||||||
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})
|
|
||||||
|
|
||||||
allToolResults = append(allToolResults, map[string]interface{}{
|
|
||||||
"tool_call_id": tc.ID,
|
|
||||||
"name": tc.Function.Name,
|
|
||||||
"args": tc.Function.Arguments,
|
|
||||||
"result": result.Content,
|
|
||||||
"is_error": result.IsError,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
messages = append(messages, orchestrator.Message{
|
finalContent, allToolCalls, allToolResults, err := engine.RunWithTools(ctx, messages)
|
||||||
Role: "tool",
|
if err != nil {
|
||||||
Content: result.Content,
|
sseWriter.Write(map[string]interface{}{"error": err.Error()})
|
||||||
ToolCallID: tc.ID,
|
return
|
||||||
Name: tc.Function.Name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
finalContent = ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
storeContent := finalContent
|
storeContent := finalContent
|
||||||
@@ -171,70 +92,20 @@ func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orche
|
|||||||
}
|
}
|
||||||
s.convStore.Add("assistant", storeContent)
|
s.convStore.Add("assistant", storeContent)
|
||||||
|
|
||||||
writeSSE(map[string]interface{}{"done": "true"})
|
sseWriter.Write(map[string]interface{}{"done": "true"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Orchestrator, userMessage string) {
|
func (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Orchestrator, userMessage string) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
messages := s.buildContextMessages(userMessage)
|
messages := s.buildContextMessages(userMessage)
|
||||||
|
|
||||||
var finalContent string
|
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
|
||||||
|
finalContent, err := engine.RunNonStream(ctx, messages)
|
||||||
for i := 0; i < maxToolIterations; i++ {
|
|
||||||
resp, err := orb.SendWithTools(messages)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
choice := resp.Choices[0]
|
|
||||||
content := cleanThinkingTags(choice.Message.Content)
|
|
||||||
|
|
||||||
if content != "" {
|
|
||||||
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 {
|
|
||||||
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 == "" {
|
|
||||||
finalContent = "(tool calls completed, no text response)"
|
|
||||||
}
|
|
||||||
|
|
||||||
s.convStore.Add("assistant", finalContent)
|
s.convStore.Add("assistant", finalContent)
|
||||||
writeJSON(w, map[string]string{"content": finalContent})
|
writeJSON(w, map[string]string{"content": finalContent})
|
||||||
}
|
}
|
||||||
@@ -337,6 +208,9 @@ func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, map[string]interface{}{
|
writeJSON(w, map[string]interface{}{
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"tokens": s.convStore.ApproxTokenCount(),
|
"tokens": s.convStore.ApproxTokenCount(),
|
||||||
|
"max_tokens": maxTokensApprox,
|
||||||
|
"summarize_at": summarizeThreshold,
|
||||||
|
"summary": s.convStore.GetSummary(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,3 +222,16 @@ 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"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleChatSummarize(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.autoSummarize()
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"tokens": s.convStore.ApproxTokenCount(),
|
||||||
|
"summary": s.convStore.GetSummary(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,8 +2,14 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/muyue/muyue/internal/lsp"
|
"github.com/muyue/muyue/internal/lsp"
|
||||||
"github.com/muyue/muyue/internal/mcp"
|
"github.com/muyue/muyue/internal/mcp"
|
||||||
@@ -17,6 +23,7 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
"name": version.Name,
|
"name": version.Name,
|
||||||
"version": version.Version,
|
"version": version.Version,
|
||||||
"author": version.Author,
|
"author": version.Author,
|
||||||
|
"sudo": os.Geteuid() == 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,3 +422,299 @@ func (s *Server) handleEditors(w http.ResponseWriter, r *http.Request) {
|
|||||||
editors := scanner.ScanEditors()
|
editors := scanner.ScanEditors()
|
||||||
writeJSON(w, map[string]interface{}{"editors": editors})
|
writeJSON(w, map[string]interface{}{"editors": editors})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleProvidersQuota(w http.ResponseWriter, r *http.Request) {
|
||||||
|
type providerQuota struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
Healthy bool `json:"healthy"`
|
||||||
|
Data map[string]interface{} `json:"data,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
var results []providerQuota
|
||||||
|
client := &http.Client{Timeout: 8 * time.Second}
|
||||||
|
for _, p := range s.config.AI.Providers {
|
||||||
|
q := providerQuota{Name: p.Name, Active: p.Active}
|
||||||
|
switch p.Name {
|
||||||
|
case "minimax":
|
||||||
|
if p.APIKey == "" {
|
||||||
|
q.Error = "no API key"
|
||||||
|
results = append(results, q)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest("GET", "https://api.minimax.io/v1/token_plan/remains", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+p.APIKey)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
q.Error = err.Error()
|
||||||
|
} else {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
var data map[string]interface{}
|
||||||
|
if json.Unmarshal(body, &data) == nil {
|
||||||
|
if models, ok := data["model_remains"].([]interface{}); ok {
|
||||||
|
filtered := make([]map[string]interface{}, 0)
|
||||||
|
for _, m := range models {
|
||||||
|
if mm, ok := m.(map[string]interface{}); ok {
|
||||||
|
usage, _ := mm["current_interval_usage_count"].(float64)
|
||||||
|
total, _ := mm["current_interval_total_count"].(float64)
|
||||||
|
if total > 0 {
|
||||||
|
filtered = append(filtered, map[string]interface{}{
|
||||||
|
"model": mm["model_name"],
|
||||||
|
"used": usage,
|
||||||
|
"total": total,
|
||||||
|
"remaining": total - usage,
|
||||||
|
"weekly_used": mm["current_weekly_usage_count"],
|
||||||
|
"weekly_total": mm["current_weekly_total_count"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
q.Data = map[string]interface{}{"models": filtered}
|
||||||
|
q.Healthy = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "zai":
|
||||||
|
if p.APIKey == "" {
|
||||||
|
q.Error = "no API key"
|
||||||
|
results = append(results, q)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest("GET", "https://api.z.ai/api/monitor/usage/quota/limit", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+p.APIKey)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
q.Error = err.Error()
|
||||||
|
} else {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
var data map[string]interface{}
|
||||||
|
if json.Unmarshal(body, &data) == nil {
|
||||||
|
q.Data = data
|
||||||
|
q.Healthy = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
q.Error = "quota not supported"
|
||||||
|
}
|
||||||
|
results = append(results, q)
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{"providers": results})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRecentCommands(w http.ResponseWriter, r *http.Request) {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
type cmdEntry struct {
|
||||||
|
Cmd string `json:"cmd"`
|
||||||
|
Shell string `json:"shell"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries []cmdEntry
|
||||||
|
|
||||||
|
for _, histFile := range []string{".bash_history", ".zsh_history"} {
|
||||||
|
path := filepath.Join(home, histFile)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
shell := "bash"
|
||||||
|
if strings.Contains(histFile, "zsh") {
|
||||||
|
shell = "zsh"
|
||||||
|
}
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
start := len(lines) - 25
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
for i := len(lines) - 1; i >= start; i-- {
|
||||||
|
line := strings.TrimSpace(lines[i])
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, ": ") {
|
||||||
|
parts := strings.SplitN(line, ";", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
line = strings.TrimSpace(parts[1])
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries = append(entries, cmdEntry{Cmd: line, Shell: shell})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
max := 20
|
||||||
|
if len(entries) > max {
|
||||||
|
entries = entries[:max]
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{"commands": entries})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRunningProcesses(w http.ResponseWriter, r *http.Request) {
|
||||||
|
type proc struct {
|
||||||
|
PID int `json:"pid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
CPU string `json:"cpu"`
|
||||||
|
Mem string `json:"mem"`
|
||||||
|
}
|
||||||
|
var procs []proc
|
||||||
|
|
||||||
|
editors := []string{"code", "nvim", "vim", "emacs", "hx", "subl", "zed", "cursor"}
|
||||||
|
langs := []string{"node", "python", "java", "go", "rustc", "cargo", "ruby", "php"}
|
||||||
|
interesting := append(editors, langs...)
|
||||||
|
interesting = append(interesting, "muyue")
|
||||||
|
|
||||||
|
cmd := exec.Command("ps", "aux")
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, map[string]interface{}{"processes": procs})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(string(out), "\n")
|
||||||
|
for _, line := range lines[1:] {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 11 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fullCmd := strings.Join(fields[10:], " ")
|
||||||
|
name := filepath.Base(fields[10])
|
||||||
|
matched := false
|
||||||
|
for _, pattern := range interesting {
|
||||||
|
if strings.Contains(name, pattern) || strings.Contains(strings.ToLower(fullCmd), pattern) {
|
||||||
|
matched = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !matched {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var pid int
|
||||||
|
fmt.Sscanf(fields[1], "%d", &pid)
|
||||||
|
procs = append(procs, proc{
|
||||||
|
PID: pid,
|
||||||
|
Name: name,
|
||||||
|
Command: fullCmd,
|
||||||
|
CPU: fields[2],
|
||||||
|
Mem: fields[3],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{"processes": procs})
|
||||||
|
}
|
||||||
|
|
||||||
|
type sysMetrics struct {
|
||||||
|
CPUPercent float64 `json:"cpu_percent"`
|
||||||
|
MemPercent float64 `json:"mem_percent"`
|
||||||
|
MemUsedMB float64 `json:"mem_used_mb"`
|
||||||
|
MemTotalMB float64 `json:"mem_total_mb"`
|
||||||
|
NetRxKBs float64 `json:"net_rx_kbs"`
|
||||||
|
NetTxKBs float64 `json:"net_tx_kbs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
lastCPU [2]float64
|
||||||
|
lastNet [2]float64
|
||||||
|
lastNetTs time.Time
|
||||||
|
lastCPUSet bool
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleSystemMetrics(w http.ResponseWriter, r *http.Request) {
|
||||||
|
m := sysMetrics{}
|
||||||
|
|
||||||
|
// CPU from /proc/stat
|
||||||
|
if data, err := os.ReadFile("/proc/stat"); err == nil {
|
||||||
|
line := strings.Split(string(data), "\n")[0]
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) >= 5 {
|
||||||
|
var idle, total float64
|
||||||
|
for i := 1; i < len(fields) && i <= 4; i++ {
|
||||||
|
var v float64
|
||||||
|
fmt.Sscanf(fields[i], "%f", &v)
|
||||||
|
total += v
|
||||||
|
if i == 4 {
|
||||||
|
idle = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastCPUSet {
|
||||||
|
dIdle := idle - lastCPU[0]
|
||||||
|
dTotal := total - lastCPU[1]
|
||||||
|
if dTotal > 0 {
|
||||||
|
m.CPUPercent = (1 - dIdle/dTotal) * 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastCPU = [2]float64{idle, total}
|
||||||
|
lastCPUSet = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memory from /proc/meminfo
|
||||||
|
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
|
||||||
|
var memTotal, memAvailable float64
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var v float64
|
||||||
|
fmt.Sscanf(fields[1], "%f", &v)
|
||||||
|
switch fields[0] {
|
||||||
|
case "MemTotal:":
|
||||||
|
memTotal = v
|
||||||
|
case "MemAvailable:":
|
||||||
|
memAvailable = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if memTotal > 0 {
|
||||||
|
m.MemTotalMB = memTotal / 1024
|
||||||
|
m.MemUsedMB = (memTotal - memAvailable) / 1024
|
||||||
|
m.MemPercent = (memTotal - memAvailable) / memTotal * 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network from /proc/net/dev
|
||||||
|
if data, err := os.ReadFile("/proc/net/dev"); err == nil {
|
||||||
|
var rxBytes, txBytes float64
|
||||||
|
for _, line := range strings.Split(string(data), "\n")[2:] {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 10 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
iface := strings.TrimSuffix(fields[0], ":")
|
||||||
|
if iface == "lo" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var rx, tx float64
|
||||||
|
fmt.Sscanf(fields[1], "%f", &rx)
|
||||||
|
fmt.Sscanf(fields[9], "%f", &tx)
|
||||||
|
rxBytes += rx
|
||||||
|
txBytes += tx
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if !lastNetTs.IsZero() {
|
||||||
|
elapsed := now.Sub(lastNetTs).Seconds()
|
||||||
|
if elapsed > 0 {
|
||||||
|
m.NetRxKBs = (rxBytes - lastNet[0]) / 1024 / elapsed
|
||||||
|
m.NetTxKBs = (txBytes - lastNet[1]) / 1024 / elapsed
|
||||||
|
if m.NetRxKBs < 0 {
|
||||||
|
m.NetRxKBs = 0
|
||||||
|
}
|
||||||
|
if m.NetTxKBs < 0 {
|
||||||
|
m.NetTxKBs = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastNet = [2]float64{rxBytes, txBytes}
|
||||||
|
lastNetTs = now
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, m)
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/muyue/muyue/internal/agent"
|
|
||||||
"github.com/muyue/muyue/internal/orchestrator"
|
"github.com/muyue/muyue/internal/orchestrator"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,6 +34,22 @@ type ToolCallInfo struct {
|
|||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toString(v interface{}) string {
|
||||||
|
if v == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s, _ := v.(string)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func toBool(v interface{}) bool {
|
||||||
|
if v == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
b, _ := v.(bool)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != "POST" {
|
if r.Method != "POST" {
|
||||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
@@ -102,109 +117,59 @@ Tu peux appeler des outils pour exécuter des commandes, lire des fichiers, etc.
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.Orchestrator, req ShellChatRequest) {
|
func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.Orchestrator, req ShellChatRequest) {
|
||||||
w.Header().Set("Content-Type", "text/event-stream")
|
SetupSSEHeaders(w)
|
||||||
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)
|
flusher, canFlush := w.(http.Flusher)
|
||||||
|
sseWriter := NewSSEWriter(w)
|
||||||
writeSSE := func(data map[string]interface{}) {
|
|
||||||
b, _ := json.Marshal(data)
|
|
||||||
w.Write([]byte("data: " + string(b) + "\n\n"))
|
|
||||||
if canFlush {
|
|
||||||
flusher.Flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
messages := []orchestrator.Message{
|
messages := []orchestrator.Message{
|
||||||
{Role: "user", Content: req.Message},
|
{Role: "user", Content: req.Message},
|
||||||
}
|
}
|
||||||
|
|
||||||
var finalContent string
|
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
|
||||||
var toolCalls []ToolCallInfo
|
|
||||||
|
|
||||||
for i := 0; i < maxShellToolIterations; i++ {
|
var toolCalls []ToolCallInfo
|
||||||
resp, err := orb.SendWithTools(messages)
|
engine.OnChunk(func(data map[string]interface{}) {
|
||||||
if err != nil {
|
if data == nil {
|
||||||
writeSSE(map[string]interface{}{"error": err.Error()})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
sseWriter.Write(data)
|
||||||
choice := resp.Choices[0]
|
if canFlush {
|
||||||
content := cleanThinkingTags(choice.Message.Content)
|
flusher.Flush()
|
||||||
|
|
||||||
if content != "" {
|
|
||||||
words := strings.Fields(content)
|
|
||||||
for i, w := range words {
|
|
||||||
chunk := w
|
|
||||||
if i < len(words)-1 {
|
|
||||||
chunk += " "
|
|
||||||
}
|
}
|
||||||
writeSSE(map[string]interface{}{"content": chunk})
|
if tc, ok := data["tool_call"].(map[string]interface{}); ok {
|
||||||
}
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
writeSSE(map[string]interface{}{"tool_call": toolCallData})
|
|
||||||
|
|
||||||
argsMap := make(map[string]interface{})
|
argsMap := make(map[string]interface{})
|
||||||
json.Unmarshal([]byte(tc.Function.Arguments), &argsMap)
|
if args, ok := tc["args"].(string); ok {
|
||||||
|
json.Unmarshal([]byte(args), &argsMap)
|
||||||
tcInfo := ToolCallInfo{
|
}
|
||||||
ID: tc.ID,
|
toolCalls = append(toolCalls, ToolCallInfo{
|
||||||
Name: tc.Function.Name,
|
ID: toString(tc["tool_call_id"]),
|
||||||
|
Name: toString(tc["name"]),
|
||||||
Args: argsMap,
|
Args: argsMap,
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
tcInfo.Error = execErr.Error()
|
|
||||||
writeSSE(map[string]interface{}{"tool_result": tcInfo})
|
|
||||||
} else {
|
|
||||||
tcInfo.Result = &toolResponseData{
|
|
||||||
Content: result.Content,
|
|
||||||
IsError: result.IsError,
|
|
||||||
Meta: result.Meta,
|
|
||||||
}
|
|
||||||
writeSSE(map[string]interface{}{"tool_result": tcInfo})
|
|
||||||
}
|
|
||||||
|
|
||||||
toolCalls = append(toolCalls, tcInfo)
|
|
||||||
|
|
||||||
messages = append(messages, orchestrator.Message{
|
|
||||||
Role: "tool",
|
|
||||||
Content: result.Content,
|
|
||||||
ToolCallID: tc.ID,
|
|
||||||
Name: tc.Function.Name,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if tr, ok := data["tool_result"].(map[string]interface{}); ok {
|
||||||
|
tcID := toString(tr["tool_call_id"])
|
||||||
|
for i := range toolCalls {
|
||||||
|
if toolCalls[i].ID == tcID {
|
||||||
|
if err, ok := tr["is_error"].(bool); ok && err {
|
||||||
|
toolCalls[i].Error = toString(tr["content"])
|
||||||
|
} else {
|
||||||
|
toolCalls[i].Result = &toolResponseData{
|
||||||
|
Content: toString(tr["content"]),
|
||||||
|
IsError: toBool(tr["is_error"]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
finalContent = ""
|
finalContent, _, _, err := engine.RunWithTools(ctx, messages)
|
||||||
|
if err != nil {
|
||||||
|
sseWriter.Write(map[string]interface{}{"error": err.Error()})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if finalContent == "" && len(toolCalls) > 0 {
|
if finalContent == "" && len(toolCalls) > 0 {
|
||||||
@@ -215,7 +180,7 @@ func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.
|
|||||||
Content: finalContent,
|
Content: finalContent,
|
||||||
ToolCalls: toolCalls,
|
ToolCalls: toolCalls,
|
||||||
})
|
})
|
||||||
writeSSE(map[string]interface{}{"done": true, "response": string(writeJSONResp)})
|
sseWriter.Write(map[string]interface{}{"done": true, "response": string(writeJSONResp)})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrator.Orchestrator, req ShellChatRequest) {
|
func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrator.Orchestrator, req ShellChatRequest) {
|
||||||
@@ -224,80 +189,20 @@ func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrat
|
|||||||
{Role: "user", Content: req.Message},
|
{Role: "user", Content: req.Message},
|
||||||
}
|
}
|
||||||
|
|
||||||
var finalContent string
|
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
|
||||||
var toolCalls []ToolCallInfo
|
|
||||||
|
|
||||||
for i := 0; i < maxShellToolIterations; i++ {
|
finalContent, err := engine.RunNonStream(ctx, messages)
|
||||||
resp, err := orb.SendWithTools(messages)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
choice := resp.Choices[0]
|
if finalContent == "" {
|
||||||
content := cleanThinkingTags(choice.Message.Content)
|
|
||||||
|
|
||||||
if content != "" {
|
|
||||||
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 {
|
|
||||||
argsMap := make(map[string]interface{})
|
|
||||||
json.Unmarshal([]byte(tc.Function.Arguments), &argsMap)
|
|
||||||
|
|
||||||
tcInfo := ToolCallInfo{
|
|
||||||
ID: tc.ID,
|
|
||||||
Name: tc.Function.Name,
|
|
||||||
Args: argsMap,
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
tcInfo.Error = execErr.Error()
|
|
||||||
} else {
|
|
||||||
tcInfo.Result = &toolResponseData{
|
|
||||||
Content: result.Content,
|
|
||||||
IsError: result.IsError,
|
|
||||||
Meta: result.Meta,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toolCalls = append(toolCalls, tcInfo)
|
|
||||||
|
|
||||||
messages = append(messages, orchestrator.Message{
|
|
||||||
Role: "tool",
|
|
||||||
Content: result.Content,
|
|
||||||
ToolCallID: tc.ID,
|
|
||||||
Name: tc.Function.Name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
finalContent = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if finalContent == "" && len(toolCalls) > 0 {
|
|
||||||
finalContent = "(tool calls completed, no text response)"
|
finalContent = "(tool calls completed, no text response)"
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, ShellChatResponse{
|
writeJSON(w, ShellChatResponse{
|
||||||
Content: finalContent,
|
Content: finalContent,
|
||||||
ToolCalls: toolCalls,
|
ToolCalls: nil,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
66
internal/api/handlers_test.go
Normal file
66
internal/api/handlers_test.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleToolCall(t *testing.T) {
|
||||||
|
// Test unknown tool returns error
|
||||||
|
registry := agent.NewRegistry()
|
||||||
|
|
||||||
|
// Register a test tool
|
||||||
|
testTool, _ := agent.NewTool[struct{ Command string }]("test_tool", "Test tool", func(ctx context.Context, params struct{ Command string }) (agent.ToolResponse, error) {
|
||||||
|
return agent.TextResponse("executed: " + params.Command), nil
|
||||||
|
})
|
||||||
|
registry.Register(testTool)
|
||||||
|
|
||||||
|
// Test executing known tool
|
||||||
|
resp, err := registry.Execute(context.Background(), agent.ToolCall{
|
||||||
|
ID: "test-id",
|
||||||
|
Name: "test_tool",
|
||||||
|
Arguments: json.RawMessage(`{"Command": "hello"}`),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.IsError {
|
||||||
|
t.Errorf("expected no error, got error response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test executing unknown tool
|
||||||
|
resp, err = registry.Execute(context.Background(), agent.ToolCall{
|
||||||
|
ID: "test-id",
|
||||||
|
Name: "unknown_tool",
|
||||||
|
Arguments: json.RawMessage(`{}`),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !resp.IsError {
|
||||||
|
t.Errorf("expected error for unknown tool")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanThinkingTags(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"hello world", "hello world"},
|
||||||
|
{"<think>thinking</think>hello", "hello"},
|
||||||
|
{"<Think>THINKING</Think>hello", "hello"},
|
||||||
|
{"hello <think>thinking</think> world", "hello world"},
|
||||||
|
{"no tags here", "no tags here"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
result := cleanThinkingTags(tc.input)
|
||||||
|
if result != tc.expected {
|
||||||
|
t.Errorf("cleanThinkingTags(%q) = %q, want %q", tc.input, result, tc.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -23,9 +24,26 @@ type Server struct {
|
|||||||
|
|
||||||
func NewServer(cfg *config.MuyueConfig) *Server {
|
func NewServer(cfg *config.MuyueConfig) *Server {
|
||||||
s := &Server{
|
s := &Server{
|
||||||
config: cfg,
|
|
||||||
mux: http.NewServeMux(),
|
mux: http.NewServeMux(),
|
||||||
}
|
}
|
||||||
|
// Auto-initialize config if nil or if no config file exists on disk
|
||||||
|
if cfg == nil || !config.Exists() {
|
||||||
|
defaultCfg := config.Default()
|
||||||
|
if cfg != nil {
|
||||||
|
// Preserve any user-provided settings from cfg
|
||||||
|
defaultCfg.Profile = cfg.Profile
|
||||||
|
defaultCfg.AI = cfg.AI
|
||||||
|
defaultCfg.Tools = cfg.Tools
|
||||||
|
defaultCfg.BMAD = cfg.BMAD
|
||||||
|
defaultCfg.Terminal = cfg.Terminal
|
||||||
|
}
|
||||||
|
// Save initial config to establish the file for first-time usage
|
||||||
|
if err := config.Save(defaultCfg); err != nil {
|
||||||
|
log.Printf("config: initial save failed: %v", err)
|
||||||
|
}
|
||||||
|
cfg = defaultCfg
|
||||||
|
}
|
||||||
|
s.config = cfg
|
||||||
s.scanResult = scanner.ScanSystem()
|
s.scanResult = scanner.ScanSystem()
|
||||||
s.convStore = NewConversationStore()
|
s.convStore = NewConversationStore()
|
||||||
s.agentRegistry = agent.DefaultRegistry()
|
s.agentRegistry = agent.DefaultRegistry()
|
||||||
@@ -67,6 +85,7 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("/api/chat", s.handleChat)
|
s.mux.HandleFunc("/api/chat", s.handleChat)
|
||||||
s.mux.HandleFunc("/api/chat/history", s.handleChatHistory)
|
s.mux.HandleFunc("/api/chat/history", s.handleChatHistory)
|
||||||
s.mux.HandleFunc("/api/chat/clear", s.handleChatClear)
|
s.mux.HandleFunc("/api/chat/clear", s.handleChatClear)
|
||||||
|
s.mux.HandleFunc("/api/chat/summarize", s.handleChatSummarize)
|
||||||
s.mux.HandleFunc("/api/tool/call", s.handleToolCall)
|
s.mux.HandleFunc("/api/tool/call", s.handleToolCall)
|
||||||
s.mux.HandleFunc("/api/tools/list", s.handleToolList)
|
s.mux.HandleFunc("/api/tools/list", s.handleToolList)
|
||||||
s.mux.HandleFunc("/api/shell/chat", s.handleShellChat)
|
s.mux.HandleFunc("/api/shell/chat", s.handleShellChat)
|
||||||
@@ -95,6 +114,10 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("/api/skills/export", s.handleSkillExport)
|
s.mux.HandleFunc("/api/skills/export", s.handleSkillExport)
|
||||||
s.mux.HandleFunc("/api/skills/import", s.handleSkillImport)
|
s.mux.HandleFunc("/api/skills/import", s.handleSkillImport)
|
||||||
s.mux.HandleFunc("/api/dashboard/status", s.handleDashboardStatus)
|
s.mux.HandleFunc("/api/dashboard/status", s.handleDashboardStatus)
|
||||||
|
s.mux.HandleFunc("/api/providers/quota", s.handleProvidersQuota)
|
||||||
|
s.mux.HandleFunc("/api/recent-commands", s.handleRecentCommands)
|
||||||
|
s.mux.HandleFunc("/api/running-processes", s.handleRunningProcesses)
|
||||||
|
s.mux.HandleFunc("/api/system/metrics", s.handleSystemMetrics)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -12,66 +12,66 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Profile struct {
|
type Profile struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name" json:"name"`
|
||||||
Pseudo string `yaml:"pseudo"`
|
Pseudo string `yaml:"pseudo" json:"pseudo"`
|
||||||
Email string `yaml:"email"`
|
Email string `yaml:"email" json:"email"`
|
||||||
Languages []string `yaml:"languages"`
|
Languages []string `yaml:"languages" json:"languages"`
|
||||||
Preferences struct {
|
Preferences struct {
|
||||||
Editor string `yaml:"editor"`
|
Editor string `yaml:"editor" json:"editor"`
|
||||||
Shell string `yaml:"shell"`
|
Shell string `yaml:"shell" json:"shell"`
|
||||||
Theme string `yaml:"theme"`
|
Theme string `yaml:"theme" json:"theme"`
|
||||||
DefaultAI string `yaml:"default_ai"`
|
DefaultAI string `yaml:"default_ai" json:"default_ai"`
|
||||||
AutoUpdate bool `yaml:"auto_update"`
|
AutoUpdate bool `yaml:"auto_update" json:"auto_update"`
|
||||||
CheckOnStart bool `yaml:"check_on_start"`
|
CheckOnStart bool `yaml:"check_on_start" json:"check_on_start"`
|
||||||
Language string `yaml:"language"`
|
Language string `yaml:"language" json:"language"`
|
||||||
KeyboardLayout string `yaml:"keyboard_layout"`
|
KeyboardLayout string `yaml:"keyboard_layout" json:"keyboard_layout"`
|
||||||
} `yaml:"preferences"`
|
} `yaml:"preferences" json:"preferences"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AIProvider struct {
|
type AIProvider struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name" json:"name"`
|
||||||
APIKey string `yaml:"api_key,omitempty"`
|
APIKey string `yaml:"api_key,omitempty" json:"api_key,omitempty"`
|
||||||
BaseURL string `yaml:"base_url,omitempty"`
|
BaseURL string `yaml:"base_url,omitempty" json:"base_url,omitempty"`
|
||||||
Model string `yaml:"model"`
|
Model string `yaml:"model" json:"model"`
|
||||||
Active bool `yaml:"active"`
|
Active bool `yaml:"active" json:"active"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToolConfig struct {
|
type ToolConfig struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name" json:"name"`
|
||||||
Installed bool `yaml:"installed"`
|
Installed bool `yaml:"installed" json:"installed"`
|
||||||
Version string `yaml:"version"`
|
Version string `yaml:"version" json:"version"`
|
||||||
AutoUpdate bool `yaml:"auto_update"`
|
AutoUpdate bool `yaml:"auto_update" json:"auto_update"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SSHConnection struct {
|
type SSHConnection struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name" json:"name"`
|
||||||
Host string `yaml:"host"`
|
Host string `yaml:"host" json:"host"`
|
||||||
Port int `yaml:"port"`
|
Port int `yaml:"port" json:"port"`
|
||||||
User string `yaml:"user"`
|
User string `yaml:"user" json:"user"`
|
||||||
Password string `yaml:"password,omitempty"`
|
Password string `yaml:"password,omitempty" json:"password,omitempty"`
|
||||||
KeyPath string `yaml:"key_path,omitempty"`
|
KeyPath string `yaml:"key_path,omitempty" json:"key_path,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MuyueConfig struct {
|
type MuyueConfig struct {
|
||||||
Version string `yaml:"version"`
|
Version string `yaml:"version" json:"version"`
|
||||||
Profile Profile `yaml:"profile"`
|
Profile Profile `yaml:"profile" json:"profile"`
|
||||||
AI struct {
|
AI struct {
|
||||||
Providers []AIProvider `yaml:"providers"`
|
Providers []AIProvider `yaml:"providers" json:"providers"`
|
||||||
} `yaml:"ai"`
|
} `yaml:"ai" json:"ai"`
|
||||||
Tools []ToolConfig `yaml:"tools"`
|
Tools []ToolConfig `yaml:"tools" json:"tools"`
|
||||||
BMAD struct {
|
BMAD struct {
|
||||||
Installed bool `yaml:"installed"`
|
Installed bool `yaml:"installed" json:"installed"`
|
||||||
Version string `yaml:"version"`
|
Version string `yaml:"version" json:"version"`
|
||||||
Global bool `yaml:"global"`
|
Global bool `yaml:"global" json:"global"`
|
||||||
} `yaml:"bmad"`
|
} `yaml:"bmad" json:"bmad"`
|
||||||
Terminal struct {
|
Terminal struct {
|
||||||
CustomPrompt bool `yaml:"custom_prompt"`
|
CustomPrompt bool `yaml:"custom_prompt" json:"custom_prompt"`
|
||||||
PromptTheme string `yaml:"prompt_theme"`
|
PromptTheme string `yaml:"prompt_theme" json:"prompt_theme"`
|
||||||
SSH []SSHConnection `yaml:"ssh"`
|
SSH []SSHConnection `yaml:"ssh" json:"ssh"`
|
||||||
FontSize int `yaml:"font_size"`
|
FontSize int `yaml:"font_size" json:"font_size"`
|
||||||
FontFamily string `yaml:"font_family"`
|
FontFamily string `yaml:"font_family" json:"font_family"`
|
||||||
Theme string `yaml:"theme"`
|
Theme string `yaml:"theme" json:"theme"`
|
||||||
} `yaml:"terminal"`
|
} `yaml:"terminal" json:"terminal"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TerminalTheme struct {
|
type TerminalTheme struct {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -76,6 +77,11 @@ var sharedHTTPClient = &http.Client{
|
|||||||
Timeout: 120 * time.Second,
|
Timeout: 120 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requestClient creates an HTTP client with the specified timeout.
|
||||||
|
func requestClient(timeout time.Duration) *http.Client {
|
||||||
|
return &http.Client{Timeout: timeout}
|
||||||
|
}
|
||||||
|
|
||||||
func New(cfg *config.MuyueConfig) (*Orchestrator, error) {
|
func New(cfg *config.MuyueConfig) (*Orchestrator, error) {
|
||||||
var provider *config.AIProvider
|
var provider *config.AIProvider
|
||||||
for i := range cfg.AI.Providers {
|
for i := range cfg.AI.Providers {
|
||||||
@@ -300,6 +306,142 @@ func (o *Orchestrator) SendWithTools(messages []Message) (*ChatResponse, error)
|
|||||||
return chatResp, nil
|
return chatResp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ChunkCallback is called for each streaming chunk.
|
||||||
|
type ChunkCallback func(content string, toolCalls []ToolCallMsg)
|
||||||
|
|
||||||
|
// SendWithToolsStream sends messages with streaming responses.
|
||||||
|
// The callback receives chunks of content and tool_calls as they arrive.
|
||||||
|
func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCallback) (*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: true,
|
||||||
|
Tools: o.tools,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := o.provider
|
||||||
|
baseURL := provider.BaseURL
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = getProviderBaseURL(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 "+provider.APIKey)
|
||||||
|
|
||||||
|
resp, err := o.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("send request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var fullContent strings.Builder
|
||||||
|
var accumulatedToolCalls []ToolCallMsg
|
||||||
|
var totalTokens int
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if !strings.HasPrefix(line, "data: ") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data := strings.TrimPrefix(line, "data: ")
|
||||||
|
if data == "[DONE]" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
var chatResp ChatResponse
|
||||||
|
if err := json.Unmarshal([]byte(data), &chatResp); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(chatResp.Choices) > 0 {
|
||||||
|
chunk := chatResp.Choices[0].Delta.Content
|
||||||
|
if chunk != "" {
|
||||||
|
fullContent.WriteString(chunk)
|
||||||
|
onChunk(chunk, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle delta tool calls
|
||||||
|
if len(chatResp.Choices[0].Delta.ToolCalls) > 0 {
|
||||||
|
for _, tc := range chatResp.Choices[0].Delta.ToolCalls {
|
||||||
|
// Find or create the tool call in accumulated list
|
||||||
|
found := false
|
||||||
|
for i := range accumulatedToolCalls {
|
||||||
|
if accumulatedToolCalls[i].ID == tc.ID {
|
||||||
|
// Append arguments
|
||||||
|
accumulatedToolCalls[i].Function.Arguments += tc.Function.Arguments
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
accumulatedToolCalls = append(accumulatedToolCalls, tc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onChunk("", accumulatedToolCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture usage from final chunk
|
||||||
|
if chatResp.Usage.TotalTokens > 0 {
|
||||||
|
totalTokens = chatResp.Usage.TotalTokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("read stream: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build final response
|
||||||
|
finalResp := &ChatResponse{
|
||||||
|
Usage: struct {
|
||||||
|
TotalTokens int `json:"total_tokens"`
|
||||||
|
}{TotalTokens: totalTokens},
|
||||||
|
Choices: []struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
ToolCalls []ToolCallMsg `json:"tool_calls"`
|
||||||
|
} `json:"message"`
|
||||||
|
Delta struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
ToolCalls []ToolCallMsg `json:"tool_calls"`
|
||||||
|
} `json:"delta"`
|
||||||
|
FinishReason *string `json:"finish_reason"`
|
||||||
|
}{},
|
||||||
|
}
|
||||||
|
|
||||||
|
finalContent := cleanAIResponse(fullContent.String())
|
||||||
|
finalResp.Choices[0].Message.Content = finalContent
|
||||||
|
finalResp.Choices[0].Message.ToolCalls = accumulatedToolCalls
|
||||||
|
|
||||||
|
return finalResp, 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")
|
||||||
@@ -368,7 +510,9 @@ func (o *Orchestrator) sendWithFallback(reqBody ChatRequest, baseURLOverride str
|
|||||||
}
|
}
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
|
var triedProviders []string
|
||||||
for _, prov := range providerOrder {
|
for _, prov := range providerOrder {
|
||||||
|
triedProviders = append(triedProviders, prov.Name)
|
||||||
baseURL := baseURLOverride
|
baseURL := baseURLOverride
|
||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
baseURL = prov.BaseURL
|
baseURL = prov.BaseURL
|
||||||
@@ -392,7 +536,14 @@ func (o *Orchestrator) sendWithFallback(reqBody ChatRequest, baseURLOverride str
|
|||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
// Provider-specific headers
|
||||||
|
if prov.Name == "anthropic" {
|
||||||
|
req.Header.Set("x-api-key", prov.APIKey)
|
||||||
|
req.Header.Set("anthropic-version", "2023-06-01")
|
||||||
|
} else {
|
||||||
req.Header.Set("Authorization", "Bearer "+prov.APIKey)
|
req.Header.Set("Authorization", "Bearer "+prov.APIKey)
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := o.client.Do(req)
|
resp, err := o.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -427,5 +578,6 @@ func (o *Orchestrator) sendWithFallback(reqBody ChatRequest, baseURLOverride str
|
|||||||
return &chatResp, prov.Name, nil
|
return &chatResp, prov.Name, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("[orchestrator] fallback from %v to next provider", triedProviders)
|
||||||
return nil, "", lastErr
|
return nil, "", lastErr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,12 +24,12 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type SystemInfo struct {
|
type SystemInfo struct {
|
||||||
OS OS
|
OS OS `json:"os"`
|
||||||
Arch Arch
|
Arch Arch `json:"arch"`
|
||||||
IsWSL bool
|
IsWSL bool `json:"is_wsl"`
|
||||||
Shell string
|
Shell string `json:"shell"`
|
||||||
Terminal string
|
Terminal string `json:"terminal"`
|
||||||
PackageManager string
|
PackageManager string `json:"package_manager"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Detect() SystemInfo {
|
func Detect() SystemInfo {
|
||||||
|
|||||||
@@ -14,27 +14,27 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ToolStatus struct {
|
type ToolStatus struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name" json:"name"`
|
||||||
Installed bool `yaml:"installed"`
|
Installed bool `yaml:"installed" json:"installed"`
|
||||||
Version string `yaml:"version"`
|
Version string `yaml:"version" json:"version"`
|
||||||
Path string `yaml:"path"`
|
Path string `yaml:"path" json:"path"`
|
||||||
Latest string `yaml:"latest"`
|
Latest string `yaml:"latest" json:"latest"`
|
||||||
NeedsUpdate bool `yaml:"needs_update"`
|
NeedsUpdate bool `yaml:"needs_update" json:"needs_update"`
|
||||||
Category string `yaml:"category"`
|
Category string `yaml:"category" json:"category"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RuntimeStatus struct {
|
type RuntimeStatus struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name" json:"name"`
|
||||||
Installed bool `yaml:"installed"`
|
Installed bool `yaml:"installed" json:"installed"`
|
||||||
Version string `yaml:"version"`
|
Version string `yaml:"version" json:"version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ScanResult struct {
|
type ScanResult struct {
|
||||||
System platform.SystemInfo `yaml:"system"`
|
System platform.SystemInfo `yaml:"system" json:"system"`
|
||||||
Tools []ToolStatus `yaml:"tools"`
|
Tools []ToolStatus `yaml:"tools" json:"tools"`
|
||||||
Runtimes []RuntimeStatus `yaml:"runtimes"`
|
Runtimes []RuntimeStatus `yaml:"runtimes" json:"runtimes"`
|
||||||
ShellSetup bool `yaml:"shell_setup"`
|
ShellSetup bool `yaml:"shell_setup" json:"shell_setup"`
|
||||||
GitConfigured bool `yaml:"git_configured"`
|
GitConfigured bool `yaml:"git_configured" json:"git_configured"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -1,11 +1,33 @@
|
|||||||
package version
|
package version
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
Name = "muyue"
|
Name = "muyue"
|
||||||
Version = "0.3.2"
|
Version = "0.3.3"
|
||||||
Author = "La Légion de Muyue"
|
Author = "La Légion de Muyue"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// BuildDate is set at build time
|
||||||
|
BuildDate = ""
|
||||||
|
)
|
||||||
|
|
||||||
func FullVersion() string {
|
func FullVersion() string {
|
||||||
return Name + " v" + Version
|
return Name + " v" + Version
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FullInfo returns full version information.
|
||||||
|
func FullInfo() string {
|
||||||
|
info := fmt.Sprintf("%-12s %s\n", "Version:", Version)
|
||||||
|
info += fmt.Sprintf("%-12s %s\n", "Author:", Author)
|
||||||
|
info += fmt.Sprintf("%-12s %s\n", "Go:", runtime.Version())
|
||||||
|
info += fmt.Sprintf("%-12s %s\n", "Platform:", runtime.GOOS+"/"+runtime.GOARCH)
|
||||||
|
if BuildDate != "" {
|
||||||
|
info += fmt.Sprintf("%-12s %s\n", "Build:", BuildDate)
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ const api = {
|
|||||||
exportSkill: (name) => request('/skills/export', { method: 'POST', body: JSON.stringify({ name }) }),
|
exportSkill: (name) => request('/skills/export', { method: 'POST', body: JSON.stringify({ name }) }),
|
||||||
importSkill: (path) => request('/skills/import', { method: 'POST', body: JSON.stringify({ import_path: path }) }),
|
importSkill: (path) => request('/skills/import', { method: 'POST', body: JSON.stringify({ import_path: path }) }),
|
||||||
getDashboardStatus: () => request('/dashboard/status'),
|
getDashboardStatus: () => request('/dashboard/status'),
|
||||||
|
getProvidersQuota: () => request('/providers/quota'),
|
||||||
|
getRecentCommands: () => request('/recent-commands'),
|
||||||
|
getRunningProcesses: () => request('/running-processes'),
|
||||||
|
getSystemMetrics: () => request('/system/metrics'),
|
||||||
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) }),
|
||||||
@@ -52,6 +56,7 @@ const api = {
|
|||||||
saveTerminalSettings: (settings) => request('/terminal/settings', { method: 'PUT', body: JSON.stringify(settings) }),
|
saveTerminalSettings: (settings) => request('/terminal/settings', { method: 'PUT', body: JSON.stringify(settings) }),
|
||||||
getChatHistory: () => request('/chat/history'),
|
getChatHistory: () => request('/chat/history'),
|
||||||
clearChat: () => request('/chat/clear', { method: 'POST' }),
|
clearChat: () => request('/chat/clear', { method: 'POST' }),
|
||||||
|
summarizeChat: () => request('/chat/summarize', { method: 'POST' }),
|
||||||
sendChat: (message, stream = true, onChunk, signal) => {
|
sendChat: (message, stream = true, onChunk, signal) => {
|
||||||
if (!stream) {
|
if (!stream) {
|
||||||
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) })
|
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) })
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||||
import { LayoutDashboard, Sparkles, Terminal, Settings } from 'lucide-react'
|
import { LayoutDashboard, Sparkles, Terminal, Settings } from 'lucide-react'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
import { getTheme, applyTheme } from '../themes'
|
import { getTheme, applyTheme } from '../themes'
|
||||||
@@ -13,6 +13,9 @@ export default function App() {
|
|||||||
const [activeTab, setActiveTab] = useState('dash')
|
const [activeTab, setActiveTab] = useState('dash')
|
||||||
const [info, setInfo] = useState({})
|
const [info, setInfo] = useState({})
|
||||||
const [clock, setClock] = useState(new Date())
|
const [clock, setClock] = useState(new Date())
|
||||||
|
const [isSudo, setIsSudo] = useState(false)
|
||||||
|
const [dashRefreshKey, setDashRefreshKey] = useState(0)
|
||||||
|
const dashRefreshRef = useRef(null)
|
||||||
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)
|
||||||
@@ -27,7 +30,7 @@ export default function App() {
|
|||||||
], [t])
|
], [t])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getInfo().then(setInfo).catch(() => {})
|
api.getInfo().then(d => { setInfo(d); setIsSudo(!!d.sudo) }).catch(() => {})
|
||||||
api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
|
api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
|
||||||
api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
|
api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
|
||||||
api.getConfig().then(d => {
|
api.getConfig().then(d => {
|
||||||
@@ -60,6 +63,11 @@ export default function App() {
|
|||||||
if (map[e.code]) {
|
if (map[e.code]) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setActiveTab(map[e.code])
|
setActiveTab(map[e.code])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.ctrlKey && e.code === 'KeyR') {
|
||||||
|
e.preventDefault()
|
||||||
|
if (dashRefreshRef.current) dashRefreshRef.current()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
@@ -72,27 +80,21 @@ export default function App() {
|
|||||||
const installed = tools.filter(tool => tool.installed).length
|
const installed = tools.filter(tool => tool.installed).length
|
||||||
|
|
||||||
const WINDOW_SHORTCUTS = useMemo(() => ({
|
const WINDOW_SHORTCUTS = useMemo(() => ({
|
||||||
dash: [
|
dash: [],
|
||||||
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
|
|
||||||
],
|
|
||||||
studio: [
|
studio: [
|
||||||
{ keys: layout.keys.enter, desc: t('statusbar.sendMessage') },
|
{ keys: layout.keys.enter, desc: t('statusbar.sendMessage') },
|
||||||
{ keys: `${layout.keys.shift}+${layout.keys.enter}`, desc: t('statusbar.newLine') },
|
{ keys: `${layout.keys.shift}+${layout.keys.enter}`, desc: t('statusbar.newLine') },
|
||||||
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
|
|
||||||
],
|
],
|
||||||
shell: [
|
shell: [
|
||||||
{ keys: layout.keys.enter, desc: t('statusbar.runCommand') },
|
{ keys: layout.keys.enter, desc: t('statusbar.runCommand') },
|
||||||
{ keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') },
|
{ keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') },
|
||||||
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
|
|
||||||
],
|
|
||||||
config: [
|
|
||||||
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
|
|
||||||
],
|
],
|
||||||
|
config: [],
|
||||||
}), [layout, t])
|
}), [layout, t])
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
case 'dash': return <Dashboard api={api} />
|
case 'dash': return <Dashboard api={api} refreshRef={dashRefreshRef} />
|
||||||
case 'studio': return <Studio api={api} />
|
case 'studio': return <Studio api={api} />
|
||||||
case 'shell': return <Shell api={api} />
|
case 'shell': return <Shell api={api} />
|
||||||
case 'config': return <Config api={api} />
|
case 'config': return <Config api={api} />
|
||||||
@@ -147,6 +149,12 @@ export default function App() {
|
|||||||
|
|
||||||
<footer className="statusbar">
|
<footer className="statusbar">
|
||||||
<div className="statusbar-left">
|
<div className="statusbar-left">
|
||||||
|
{isSudo && <span className="statusbar-sudo">⚡ ROOT</span>}
|
||||||
|
{activeTab === 'dash' && (
|
||||||
|
<span className="statusbar-shortcut">
|
||||||
|
<kbd>{layout.keys.ctrl}+R</kbd> refresh
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<FooterShortcuts shortcuts={WINDOW_SHORTCUTS[activeTab] || []} />
|
<FooterShortcuts shortcuts={WINDOW_SHORTCUTS[activeTab] || []} />
|
||||||
</div>
|
</div>
|
||||||
<div className="statusbar-right">
|
<div className="statusbar-right">
|
||||||
|
|||||||
@@ -1,437 +1,175 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { useI18n } from '../i18n'
|
import { useI18n } from '../i18n'
|
||||||
|
|
||||||
const TOOL_ICONS = {
|
const MAX_POINTS = 30
|
||||||
crush: '⚡',
|
|
||||||
claude: '🤖',
|
function MiniGraph({ data, max, color, label, unit }) {
|
||||||
go: '🔷',
|
if (!data || data.length < 2) return <div className="dash-graph-empty">collecting...</div>
|
||||||
node: '🟢',
|
const m = max || Math.max(...data, 1)
|
||||||
python: '🐍',
|
const w = 100
|
||||||
docker: '🐳',
|
const h = 32
|
||||||
git: '📚',
|
const points = data.map((v, i) => {
|
||||||
ssh: '🌐',
|
const x = (i / (data.length - 1)) * w
|
||||||
starship: '🚀',
|
const y = h - (v / m) * h
|
||||||
rust: '🦀',
|
return `${x},${y}`
|
||||||
|
}).join(' ')
|
||||||
|
const last = data[data.length - 1]
|
||||||
|
return (
|
||||||
|
<div className="dash-graph-wrap">
|
||||||
|
<div className="dash-graph-header">
|
||||||
|
<span className="dash-graph-label">{label}</span>
|
||||||
|
<span className="dash-graph-value" style={{ color }}>{last.toFixed(1)}{unit}</span>
|
||||||
|
</div>
|
||||||
|
<svg viewBox={`0 0 ${w} ${h}`} className="dash-graph-svg" preserveAspectRatio="none">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`fg-${color.replace('#','').replace('var(','').replace(')','')}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={color} stopOpacity="0.3" />
|
||||||
|
<stop offset="100%" stopColor={color} stopOpacity="0.02" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<polygon fill={`url(#fg-${color.replace('#','').replace('var(','').replace(')','')})`} points={`${points} ${w},${h} 0,${h}`} />
|
||||||
|
<polyline fill="none" stroke={color} strokeWidth="1.5" points={points} vectorEffect="non-scaling-stroke" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ToolCard({ tool, onInstall, installing }) {
|
export default function Dashboard({ api, refreshRef }) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const [showInstall, setShowInstall] = useState(false)
|
const [quota, setQuota] = useState(null)
|
||||||
|
const [recentCmds, setRecentCmds] = useState([])
|
||||||
const icon = TOOL_ICONS[tool.name?.toLowerCase()] || '🔧'
|
const [processes, setProcesses] = useState([])
|
||||||
const isInstalled = tool.installed || tool.status === 'installed'
|
const [metrics, setMetrics] = useState(null)
|
||||||
const version = tool.version || ''
|
const cpuRef = useRef([])
|
||||||
const hasUpdate = tool.hasUpdate || tool.updateAvailable
|
const memRef = useRef([])
|
||||||
|
const netRxRef = useRef([])
|
||||||
return (
|
const netTxRef = useRef([])
|
||||||
<div className={`tool-card ${isInstalled ? 'installed' : 'missing'}`}>
|
|
||||||
<div className="tool-card-icon">{icon}</div>
|
|
||||||
<div className="tool-card-info">
|
|
||||||
<div className="tool-card-name">{tool.name || 'Unknown'}</div>
|
|
||||||
<div className="tool-card-version">
|
|
||||||
{isInstalled ? (
|
|
||||||
<span className="status-ok">{t('dashboard.installed')}</span>
|
|
||||||
) : (
|
|
||||||
<span className="status-missing">{t('dashboard.missing')}</span>
|
|
||||||
)}
|
|
||||||
{version && <span className="tool-version-text">{version}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="tool-card-actions">
|
|
||||||
{isInstalled && hasUpdate && (
|
|
||||||
<span className="tool-update-badge" title={`Update to ${tool.latestVersion || 'latest'}`}>
|
|
||||||
↑ {tool.latestVersion || 'new'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!isInstalled && (
|
|
||||||
<button
|
|
||||||
className="sm primary"
|
|
||||||
onClick={() => onInstall(tool.name)}
|
|
||||||
disabled={installing}
|
|
||||||
>
|
|
||||||
{installing ? '...' : t('dashboard.install')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActivityItem({ entry }) {
|
|
||||||
const time = entry.time
|
|
||||||
? new Date(entry.time).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
||||||
: ''
|
|
||||||
const type = entry.type || entry.level || 'info'
|
|
||||||
const text = entry.message || entry.text || entry.content || ''
|
|
||||||
|
|
||||||
const typeClass = {
|
|
||||||
ok: 'notif-ok',
|
|
||||||
success: 'notif-ok',
|
|
||||||
install: 'notif-ok',
|
|
||||||
update: 'notif-info',
|
|
||||||
info: 'notif-info',
|
|
||||||
warn: 'notif-warn',
|
|
||||||
warning: 'notif-warn',
|
|
||||||
error: 'notif-error',
|
|
||||||
fail: 'notif-error',
|
|
||||||
}[type] || 'notif-info'
|
|
||||||
|
|
||||||
const icon = {
|
|
||||||
ok: '✓', success: '✓', install: '✓', update: '→',
|
|
||||||
info: 'ℹ', warn: '⚠', warning: '⚠', error: '✗', fail: '✗',
|
|
||||||
}[type] || '•'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`notif-row ${typeClass}`}>
|
|
||||||
<span className="notif-time">{time}</span>
|
|
||||||
<span className="notif-icon">{icon}</span>
|
|
||||||
<span className="notif-text">{text}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function QuickActionButton({ icon, label, onClick, loading, disabled }) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className="quick-action-btn"
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={disabled || loading}
|
|
||||||
>
|
|
||||||
{loading ? <span className="spinner" style={{ width: 14, height: 14 }} /> : <span className="quick-action-icon">{icon}</span>}
|
|
||||||
<span className="quick-action-label">{label}</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Dashboard({ api }) {
|
|
||||||
const { t } = useI18n()
|
|
||||||
const [activeTab, setActiveTab] = useState('tools')
|
|
||||||
const [tools, setTools] = useState([])
|
|
||||||
const [updates, setUpdates] = useState([])
|
|
||||||
const [systemInfo, setSystemInfo] = useState(null)
|
|
||||||
const [notifications, setNotifications] = useState([])
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [installing, setInstalling] = useState(false)
|
|
||||||
const [scanLoading, setScanLoading] = useState(false)
|
|
||||||
const [mcpLoading, setMcpLoading] = useState(false)
|
|
||||||
const [dashboardStatus, setDashboardStatus] = useState(null)
|
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [toolsData, updatesData, systemData] = await Promise.all([
|
const [quotaData, cmdData, procData, metricsData] = await Promise.all([
|
||||||
api.getTools().catch(() => ({ tools: [] })),
|
api.getProvidersQuota().catch(() => null),
|
||||||
api.getUpdates().catch(() => ({ updates: [] })),
|
api.getRecentCommands().catch(() => ({ commands: [] })),
|
||||||
api.getSystem().catch(() => null),
|
api.getRunningProcesses().catch(() => ({ processes: [] })),
|
||||||
|
api.getSystemMetrics().catch(() => null),
|
||||||
])
|
])
|
||||||
setTools(toolsData.tools || toolsData || [])
|
setQuota(quotaData?.providers || [])
|
||||||
setUpdates(updatesData.updates || updatesData || [])
|
setRecentCmds(cmdData.commands || [])
|
||||||
setSystemInfo(systemData)
|
setProcesses(procData.processes || [])
|
||||||
api.getDashboardStatus().then(d => setDashboardStatus(d)).catch(() => {})
|
if (metricsData) {
|
||||||
|
setMetrics(metricsData)
|
||||||
|
cpuRef.current = [...cpuRef.current, metricsData.cpu_percent].slice(-MAX_POINTS)
|
||||||
|
memRef.current = [...memRef.current, metricsData.mem_percent].slice(-MAX_POINTS)
|
||||||
|
netRxRef.current = [...netRxRef.current, metricsData.net_rx_kbs].slice(-MAX_POINTS)
|
||||||
|
netTxRef.current = [...netTxRef.current, metricsData.net_tx_kbs].slice(-MAX_POINTS)
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load dashboard data:', err)
|
console.error('Dashboard load error:', err)
|
||||||
}
|
}
|
||||||
}, [api])
|
}, [api])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData()
|
loadData()
|
||||||
}, [loadData])
|
if (refreshRef) refreshRef.current = loadData
|
||||||
|
const iv = setInterval(loadData, 5000)
|
||||||
|
return () => clearInterval(iv)
|
||||||
|
}, [loadData, refreshRef])
|
||||||
|
|
||||||
const addNotification = (message, type = 'info') => {
|
const minimax = (quota || []).find(p => p.name === 'minimax')
|
||||||
const entry = { id: Date.now(), time: new Date().toISOString(), message, type }
|
const zai = (quota || []).find(p => p.name === 'zai')
|
||||||
setNotifications(prev => [entry, ...prev].slice(0, 100))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRescan = async () => {
|
|
||||||
setScanLoading(true)
|
|
||||||
addNotification(t('dashboard.rescanning'), 'info')
|
|
||||||
try {
|
|
||||||
await api.runScan()
|
|
||||||
await loadData()
|
|
||||||
addNotification(t('dashboard.scanComplete'), 'ok')
|
|
||||||
} catch (err) {
|
|
||||||
addNotification(`${t('dashboard.scanFailed')}: ${err.message}`, 'error')
|
|
||||||
} finally {
|
|
||||||
setScanLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleInstallMissing = async () => {
|
|
||||||
const missing = tools.filter(t => !t.installed && t.status !== 'installed')
|
|
||||||
if (missing.length === 0) return
|
|
||||||
setInstalling(true)
|
|
||||||
addNotification(t('dashboard.installing', { count: missing.length }), 'info')
|
|
||||||
try {
|
|
||||||
await api.installTools(missing.map(t => t.name))
|
|
||||||
addNotification(t('dashboard.installStarted'), 'ok')
|
|
||||||
setTimeout(() => handleRescan(), 2000)
|
|
||||||
} catch (err) {
|
|
||||||
addNotification(`${t('dashboard.installFailed')}: ${err.message}`, 'error')
|
|
||||||
} finally {
|
|
||||||
setInstalling(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCheckUpdates = async () => {
|
|
||||||
setLoading(true)
|
|
||||||
addNotification(t('config.checking'), 'info')
|
|
||||||
try {
|
|
||||||
const data = await api.getUpdates()
|
|
||||||
setUpdates(data.updates || data || [])
|
|
||||||
const count = (data.updates || data || []).length
|
|
||||||
if (count > 0) {
|
|
||||||
addNotification(t('dashboard.updatesCount', { count }), 'warn')
|
|
||||||
} else {
|
|
||||||
addNotification(t('dashboard.allUpToDate'), 'ok')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
addNotification(`${t('dashboard.checkUpdatesFailed')}: ${err.message}`, 'error')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleConfigureMCP = async () => {
|
|
||||||
setMcpLoading(true)
|
|
||||||
addNotification(t('dashboard.configuringMCP'), 'info')
|
|
||||||
try {
|
|
||||||
await api.configureMCP()
|
|
||||||
addNotification(t('dashboard.mcpConfigured'), 'ok')
|
|
||||||
} catch (err) {
|
|
||||||
addNotification(`${t('dashboard.mcpConfigFailed')}: ${err.message}`, 'error')
|
|
||||||
} finally {
|
|
||||||
setMcpLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleInstallTool = async (name) => {
|
|
||||||
setInstalling(true)
|
|
||||||
addNotification(`${t('dashboard.installing')} ${name}...`, 'info')
|
|
||||||
try {
|
|
||||||
await api.installTools([name])
|
|
||||||
addNotification(`${name} ${t('dashboard.installed')}`, 'ok')
|
|
||||||
setTimeout(() => loadData(), 2000)
|
|
||||||
} catch (err) {
|
|
||||||
addNotification(`${t('dashboard.installFailed')}: ${err.message}`, 'error')
|
|
||||||
} finally {
|
|
||||||
setInstalling(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const installedCount = tools.filter(t => t.installed || t.status === 'installed').length
|
|
||||||
const missingCount = tools.length - installedCount
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dashboard-layout">
|
<div className="dash-grid">
|
||||||
<div className="dashboard-tabs">
|
{/* CPU */}
|
||||||
<button
|
<div className="dash-card">
|
||||||
className={`dashboard-tab ${activeTab === 'tools' ? 'active' : ''}`}
|
<div className="dash-card-head">
|
||||||
onClick={() => setActiveTab('tools')}
|
<span className="dash-label">CPU</span>
|
||||||
>
|
<span className="dash-count">{metrics ? metrics.cpu_percent.toFixed(0) : '—'}%</span>
|
||||||
<span className="tab-icon">🔧</span>
|
</div>
|
||||||
{t('dashboard.tools')}
|
<MiniGraph data={cpuRef.current} max={100} color="var(--accent)" label="CPU" unit="%" />
|
||||||
<span className="tab-count">{installedCount}</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`dashboard-tab ${activeTab === 'activity' ? 'active' : ''}`}
|
|
||||||
onClick={() => setActiveTab('activity')}
|
|
||||||
>
|
|
||||||
<span className="tab-icon">📋</span>
|
|
||||||
{t('dashboard.activity')}
|
|
||||||
{notifications.length > 0 && <span className="tab-count warn">{notifications.length}</span>}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`dashboard-tab ${activeTab === 'actions' ? 'active' : ''}`}
|
|
||||||
onClick={() => setActiveTab('actions')}
|
|
||||||
>
|
|
||||||
<span className="tab-icon">⚡</span>
|
|
||||||
{t('dashboard.quickActions')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`dashboard-tab ${activeTab === 'status' ? 'active' : ''}`}
|
|
||||||
onClick={() => setActiveTab('status')}
|
|
||||||
>
|
|
||||||
<span className="tab-icon">📡</span>
|
|
||||||
{t('dashboard.status') || 'Status'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dashboard-content">
|
{/* RAM */}
|
||||||
{activeTab === 'tools' && (
|
<div className="dash-card">
|
||||||
<div className="dashboard-tools-panel">
|
<div className="dash-card-head">
|
||||||
<div className="dashboard-section-header">
|
<span className="dash-label">RAM</span>
|
||||||
<div className="dashboard-section-title">{t('dashboard.systemOverview')}</div>
|
<span className="dash-count">{metrics ? `${metrics.mem_used_mb.toFixed(0)}/${metrics.mem_total_mb.toFixed(0)}` : '—'}</span>
|
||||||
<div className="dashboard-tools-stats">
|
|
||||||
<span className="stat-ok">{installedCount} {t('dashboard.installed')}</span>
|
|
||||||
{missingCount > 0 && <span className="stat-missing">{missingCount} {t('dashboard.missing')}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
|
<MiniGraph data={memRef.current} max={100} color="#a78bfa" label="RAM" unit="%" />
|
||||||
</div>
|
</div>
|
||||||
{systemInfo && (
|
|
||||||
<div className="dashboard-system-info">
|
{/* Network */}
|
||||||
<span className="sys-info-item">{systemInfo.os || systemInfo.platform || 'Unknown'}</span>
|
<div className="dash-card">
|
||||||
<span className="sys-info-sep">·</span>
|
<div className="dash-card-head">
|
||||||
<span className="sys-info-item">{systemInfo.arch || 'Unknown'}</span>
|
<span className="dash-label">Network</span>
|
||||||
{systemInfo.shell && <><span className="sys-info-sep">·</span><span className="sys-info-item">{systemInfo.shell}</span></>}
|
<span className="dash-count">{metrics ? `↓${metrics.net_rx_kbs.toFixed(0)} ↑${metrics.net_tx_kbs.toFixed(0)}` : '—'}</span>
|
||||||
|
</div>
|
||||||
|
<MiniGraph data={netRxRef.current} max={null} color="#34d399" label="RX" unit=" KB/s" />
|
||||||
|
<MiniGraph data={netTxRef.current} max={null} color="#f59e0b" label="TX" unit=" KB/s" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API Quota */}
|
||||||
|
<div className="dash-card">
|
||||||
|
<div className="dash-card-head">
|
||||||
|
<span className="dash-label">API Quota</span>
|
||||||
|
</div>
|
||||||
|
<div className="dash-quota-list">
|
||||||
|
{minimax && minimax.data?.models?.map((m, i) => (
|
||||||
|
<div key={i} className="dash-quota-row">
|
||||||
|
<span className="dash-quota-name">{String(m.model).replace('MiniMax-', '')}</span>
|
||||||
|
<div className="dash-bar">
|
||||||
|
<div className="dash-bar-fill" style={{ width: `${Math.min(100, (m.used / m.total) * 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="dash-quota-val">{m.used}/{m.total}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<div className="tools-grid">
|
|
||||||
{tools.length === 0 && (
|
|
||||||
<div className="empty-state">{t('dashboard.noTools')}</div>
|
|
||||||
)}
|
|
||||||
{tools.map((tool, i) => (
|
|
||||||
<ToolCard
|
|
||||||
key={tool.name || i}
|
|
||||||
tool={tool}
|
|
||||||
onInstall={handleInstallTool}
|
|
||||||
installing={installing}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
{minimax && minimax.data?.models?.length === 0 && (
|
||||||
|
<div className="dash-quota-row">
|
||||||
|
<span className="dash-quota-name">MiniMax</span>
|
||||||
|
<span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>{minimax.error || 'no data'}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{zai && (
|
||||||
{activeTab === 'activity' && (
|
<div className="dash-quota-row">
|
||||||
<div className="dashboard-activity-panel">
|
<span className="dash-quota-name">Z.AI</span>
|
||||||
<div className="dashboard-section-header">
|
<span className="dash-quota-val">{zai.healthy ? '✓ active' : zai.error || '—'}</span>
|
||||||
<div className="dashboard-section-title">{t('dashboard.activityLog')}</div>
|
|
||||||
<button className="sm ghost" onClick={() => setNotifications([])} disabled={notifications.length === 0}>
|
|
||||||
{t('dashboard.clearLog')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{notifications.length === 0 ? (
|
|
||||||
<div className="empty-state">{t('dashboard.noActivity')}</div>
|
|
||||||
) : (
|
|
||||||
<div className="activity-log">
|
|
||||||
{notifications.map(entry => (
|
|
||||||
<ActivityItem key={entry.id} entry={entry} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!minimax && !zai && <span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>No providers</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'actions' && (
|
|
||||||
<div className="dashboard-actions-panel">
|
|
||||||
<div className="dashboard-section-header">
|
|
||||||
<div className="dashboard-section-title">{t('dashboard.quickActions')}</div>
|
|
||||||
</div>
|
|
||||||
<div className="quick-actions-grid">
|
|
||||||
<QuickActionButton
|
|
||||||
icon="🔍"
|
|
||||||
label={t('dashboard.rescanSystem')}
|
|
||||||
onClick={handleRescan}
|
|
||||||
loading={scanLoading}
|
|
||||||
/>
|
|
||||||
<QuickActionButton
|
|
||||||
icon="📦"
|
|
||||||
label={t('dashboard.installMissing')}
|
|
||||||
onClick={handleInstallMissing}
|
|
||||||
loading={installing}
|
|
||||||
disabled={missingCount === 0}
|
|
||||||
/>
|
|
||||||
<QuickActionButton
|
|
||||||
icon="🔄"
|
|
||||||
label={t('dashboard.checkUpdates')}
|
|
||||||
onClick={handleCheckUpdates}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
<QuickActionButton
|
|
||||||
icon="⚙"
|
|
||||||
label={t('dashboard.configureMCP')}
|
|
||||||
onClick={handleConfigureMCP}
|
|
||||||
loading={mcpLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{updates.length > 0 && (
|
{/* Running Processes */}
|
||||||
<div className="dashboard-updates-section">
|
<div className="dash-card">
|
||||||
<div className="dashboard-section-header">
|
<div className="dash-card-head">
|
||||||
<div className="dashboard-section-title">{t('dashboard.updates')}</div>
|
<span className="dash-label">Processes</span>
|
||||||
<span className="badge warn">{updates.length}</span>
|
<span className="dash-count">{processes.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="updates-list">
|
<div className="dash-proc-list">
|
||||||
{updates.map((update, i) => (
|
{processes.length === 0 && <span className="dash-empty">No relevant processes</span>}
|
||||||
<div key={update.name || i} className="update-row">
|
{processes.map((p, i) => (
|
||||||
<div className="update-info">
|
<div key={i} className="dash-proc-row">
|
||||||
<span className="update-name">{update.name || 'Unknown'}</span>
|
<span className="dash-proc-name">{p.name}</span>
|
||||||
<span className="update-versions">
|
<span className="dash-proc-res">cpu {p.cpu}% · mem {p.mem}%</span>
|
||||||
{update.current || update.version || '?'} → {update.latest || update.target || '?'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="sm"
|
|
||||||
onClick={() => api.runUpdate(update.name)}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{t('dashboard.update')}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'status' && (
|
{/* Recent Commands */}
|
||||||
<div className="dashboard-status-panel">
|
<div className="dash-card">
|
||||||
{dashboardStatus ? (
|
<div className="dash-card-head">
|
||||||
<>
|
<span className="dash-label">Recent Commands</span>
|
||||||
<div className="dashboard-section-header">
|
|
||||||
<div className="dashboard-section-title">MCP Servers</div>
|
|
||||||
<span className="badge">{dashboardStatus.mcp?.healthy || 0}/{dashboardStatus.mcp?.total || 0} healthy</span>
|
|
||||||
</div>
|
|
||||||
<div className="tools-grid" style={{ marginBottom: 16 }}>
|
|
||||||
{(dashboardStatus.mcp?.servers || []).map((s, i) => (
|
|
||||||
<div key={i} className={`tool-card ${s.healthy ? 'installed' : s.installed ? '' : 'missing'}`}>
|
|
||||||
<div className="tool-card-info">
|
|
||||||
<div className="tool-card-name">{s.name}</div>
|
|
||||||
<div className="tool-card-version">
|
|
||||||
{s.healthy ? <span className="status-ok">healthy</span> :
|
|
||||||
s.installed ? <span className="status-missing">installed</span> :
|
|
||||||
<span className="status-missing">not found</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="dash-cmd-list">
|
||||||
|
{recentCmds.length === 0 && <span className="dash-empty">No history</span>}
|
||||||
|
{recentCmds.map((c, i) => (
|
||||||
|
<div key={i} className="dash-cmd-row" title={c.cmd}>
|
||||||
|
<span className="dash-cmd-shell">{c.shell}</span>
|
||||||
|
<span className="dash-cmd-text">{c.cmd.length > 45 ? c.cmd.slice(0, 42) + '...' : c.cmd}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dashboard-section-header">
|
|
||||||
<div className="dashboard-section-title">LSP Servers</div>
|
|
||||||
<span className="badge">{dashboardStatus.lsp?.installed || 0}/{dashboardStatus.lsp?.total || 0} installed</span>
|
|
||||||
</div>
|
|
||||||
<div className="tools-grid" style={{ marginBottom: 16 }}>
|
|
||||||
{(dashboardStatus.lsp?.servers || []).filter(s => s.installed).map((s, i) => (
|
|
||||||
<div key={i} className="tool-card installed">
|
|
||||||
<div className="tool-card-info">
|
|
||||||
<div className="tool-card-name">{s.name}</div>
|
|
||||||
<div className="tool-card-version">
|
|
||||||
<span className="status-ok">{s.language}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="dashboard-section-header">
|
|
||||||
<div className="dashboard-section-title">Skills</div>
|
|
||||||
<span className="badge">{dashboardStatus.skills?.total || 0} deployed</span>
|
|
||||||
{(dashboardStatus.skills?.issues || []).length > 0 && (
|
|
||||||
<span className="badge warn">{(dashboardStatus.skills.issues || []).length} issues</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{(dashboardStatus.skills?.issues || []).length > 0 && (
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--warning)', marginBottom: 8 }}>
|
|
||||||
{(dashboardStatus.skills.issues || []).map((issue, i) => (
|
|
||||||
<div key={i}>{issue}</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="empty-state">Loading status...</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { Sparkles, ArrowRight, ArrowLeft, Search, Loader } from 'lucide-react'
|
import { Sparkles, ArrowRight, ArrowLeft, Loader } from 'lucide-react'
|
||||||
import { useI18n, LANGUAGES } from '../i18n'
|
import { useI18n, LANGUAGES } from '../i18n'
|
||||||
import { getLayoutList } from '../i18n/keyboards'
|
import { getLayoutList } from '../i18n/keyboards'
|
||||||
|
|
||||||
@@ -32,6 +32,8 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
const [validating, setValidating] = useState(false)
|
const [validating, setValidating] = useState(false)
|
||||||
const [keyValid, setKeyValid] = useState(false)
|
const [keyValid, setKeyValid] = useState(false)
|
||||||
const [scanning, setScanning] = useState(false)
|
const [scanning, setScanning] = useState(false)
|
||||||
|
const [scanMessage, setScanMessage] = useState('')
|
||||||
|
const scanAbortRef = useRef(null)
|
||||||
|
|
||||||
const current = STEPS[step]
|
const current = STEPS[step]
|
||||||
const layouts = getLayoutList()
|
const layouts = getLayoutList()
|
||||||
@@ -50,7 +52,7 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
case 'name': return answers.name.trim().length > 0
|
case 'name': return answers.name.trim().length > 0
|
||||||
case 'language': return !!answers.language
|
case 'language': return !!answers.language
|
||||||
case 'keyboard': return !!answers.keyboard
|
case 'keyboard': return !!answers.keyboard
|
||||||
case 'apikey': return true
|
case 'apikey': return keyValid && !scanning
|
||||||
case 'editor': return true
|
case 'editor': return true
|
||||||
case 'done': return true
|
case 'done': return true
|
||||||
default: return true
|
default: return true
|
||||||
@@ -61,14 +63,82 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
if (step > 0) setStep(step - 1)
|
if (step > 0) setStep(step - 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cycleOption = (key, list, dir) => {
|
||||||
|
const idx = list.findIndex(item => item.id === answers[key])
|
||||||
|
const next = (idx + dir + list.length) % list.length
|
||||||
|
setAnswers(a => ({ ...a, [key]: list[next].id }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const cycleOptionEditor = (dir) => {
|
||||||
|
const idx = editorList.findIndex(ed => ed === answers.editor)
|
||||||
|
const next = (idx + dir + editorList.length) % editorList.length
|
||||||
|
setAnswers(a => ({ ...a, editor: editorList[next] }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScanViaChat = async (apikey) => {
|
||||||
|
setScanning(true)
|
||||||
|
setScanMessage('Recherche des éditeurs sur votre système...')
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const detected = []
|
||||||
|
const fallback = async () => {
|
||||||
|
setScanMessage('Utilisation du scan local...')
|
||||||
|
const data = await api.getEditors()
|
||||||
|
return (data.editors || []).map(e => e.name)
|
||||||
|
}
|
||||||
|
const prompt = 'Liste tous les éditeurs de texte et IDE installés sur ce système. Exécute les commandes nécessaires pour les détecter (which, command -v, etc.). Réponds UNIQUEMENT avec les noms séparés par des virgules, sans aucune autre explication. Exemples: vim, nvim, code, emacs, nano, helix, subl, zed'
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
scanAbortRef.current = ctrl
|
||||||
|
const full = await api.sendChat(prompt, true, (text, data) => {
|
||||||
|
if (data.tool_call) setScanMessage('Exécution: ' + (data.tool_call.name || '...'))
|
||||||
|
else if (data.tool_result) setScanMessage('Analyse des résultats...')
|
||||||
|
else if (data.content) setScanMessage('Réception: ' + text.slice(0, 60) + (text.length > 60 ? '...' : ''))
|
||||||
|
}, ctrl.signal)
|
||||||
|
const names = full.split(/[,\n]/).map(s => s.replace(/[^a-zA-Z0-9._-]/g, '')).filter(Boolean)
|
||||||
|
if (names.length > 0) {
|
||||||
|
detected.push(...names)
|
||||||
|
} else {
|
||||||
|
detected.push(...(await fallback()))
|
||||||
|
}
|
||||||
|
setEditorList([...new Set(detected.map(n => n.toLowerCase()))])
|
||||||
|
setScanMessage('')
|
||||||
|
} catch (err) {
|
||||||
|
try {
|
||||||
|
setScanMessage('Fallback: scan local...')
|
||||||
|
const data = await api.getEditors()
|
||||||
|
const detected = (data.editors || []).map(e => e.name)
|
||||||
|
setEditorList([...new Set(detected)])
|
||||||
|
} catch {}
|
||||||
|
setScanMessage('')
|
||||||
|
}
|
||||||
|
setScanning(false)
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e) => {
|
const handler = (e) => {
|
||||||
if (e.key === 'Escape') { goPrev(); return }
|
if (e.key === 'Escape') { goPrev(); return }
|
||||||
|
if (current.key === 'language') {
|
||||||
|
if (e.key === 'Tab' || e.key === 'ArrowRight') { e.preventDefault(); cycleOption('language', LANGUAGES, 1); return }
|
||||||
|
if (e.key === 'ArrowLeft') { e.preventDefault(); cycleOption('language', LANGUAGES, -1); return }
|
||||||
|
}
|
||||||
|
if (current.key === 'keyboard') {
|
||||||
|
if (e.key === 'Tab' || e.key === 'ArrowRight') { e.preventDefault(); cycleOption('keyboard', layouts, 1); return }
|
||||||
|
if (e.key === 'ArrowLeft') { e.preventDefault(); cycleOption('keyboard', layouts, -1); return }
|
||||||
|
}
|
||||||
|
if (current.key === 'editor') {
|
||||||
|
if (e.key === 'Tab' || e.key === 'ArrowRight') { e.preventDefault(); cycleOptionEditor(1); return }
|
||||||
|
if (e.key === 'ArrowLeft') { e.preventDefault(); cycleOptionEditor(-1); return }
|
||||||
|
}
|
||||||
|
if (e.key === 'Tab') { e.preventDefault(); const input = document.querySelector('.onboarding-input'); if (input) input.focus(); return }
|
||||||
if (e.key === 'Enter' && current.key !== 'done' && current.key !== 'editor') { e.preventDefault(); goNext() }
|
if (e.key === 'Enter' && current.key !== 'done' && current.key !== 'editor') { e.preventDefault(); goNext() }
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', handler)
|
window.addEventListener('keydown', handler)
|
||||||
return () => window.removeEventListener('keydown', handler)
|
return () => window.removeEventListener('keydown', handler)
|
||||||
}, [step, current])
|
}, [step, current, answers, editorList])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => { if (scanAbortRef.current) scanAbortRef.current.abort() }
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (current.key === 'done' && !saving) {
|
if (current.key === 'done' && !saving) {
|
||||||
@@ -88,6 +158,14 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
base_url: 'https://api.minimax.io/v1',
|
base_url: 'https://api.minimax.io/v1',
|
||||||
})
|
})
|
||||||
setKeyValid(true)
|
setKeyValid(true)
|
||||||
|
await api.saveProvider({
|
||||||
|
name: 'minimax',
|
||||||
|
api_key: answers.apikey,
|
||||||
|
model: 'MiniMax-M2.7',
|
||||||
|
base_url: 'https://api.minimax.io/v1',
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
handleScanViaChat(answers.apikey)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || 'Clé invalide')
|
setError(err.message || 'Clé invalide')
|
||||||
setKeyValid(false)
|
setKeyValid(false)
|
||||||
@@ -95,22 +173,7 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
setValidating(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 () => {
|
const handleSave = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
@@ -154,9 +217,10 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="onboarding-progress">
|
<div className="onboarding-progress">
|
||||||
{STEPS.map((_, i) => (
|
{STEPS.filter(s => s.key !== 'done').map(s => {
|
||||||
<div key={i} className={`onboarding-dot ${i === step ? 'active' : ''} ${i < step ? 'done' : ''}`} />
|
const i = STEPS.indexOf(s)
|
||||||
))}
|
return <div key={s.key} className={`onboarding-dot ${i === step ? 'active' : ''} ${i < step ? 'done' : ''}`} />
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="onboarding-body">
|
<div className="onboarding-body">
|
||||||
@@ -221,7 +285,7 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
<div className="onboarding-step">
|
<div className="onboarding-step">
|
||||||
<div className="onboarding-title">Clé API MiniMax</div>
|
<div className="onboarding-title">Clé API MiniMax</div>
|
||||||
<div className="onboarding-desc">
|
<div className="onboarding-desc">
|
||||||
Entrez votre clé API MiniMax pour activer l'assistant IA. Vous pouvez passer cette étape et la configurer plus tard.
|
Entrez votre clé API MiniMax pour activer l'assistant IA. La clé est obligatoire pour continuer.
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
className="onboarding-input"
|
className="onboarding-input"
|
||||||
@@ -232,7 +296,14 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
{error && !keyValid && <div className="onboarding-required">{error}</div>}
|
{error && !keyValid && <div className="onboarding-required">{error}</div>}
|
||||||
{keyValid && <div className="onboarding-valid">Clé valide ✓</div>}
|
{keyValid && !scanning && <div className="onboarding-valid">Clé valide ✓ — Appuyez sur Entrée pour continuer</div>}
|
||||||
|
{scanning && (
|
||||||
|
<div className="onboarding-scanning">
|
||||||
|
<Loader size={14} className="spin-icon" />
|
||||||
|
<span>{scanMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{requiredError && <div className="onboarding-required">Veuillez valider votre clé API pour continuer</div>}
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
||||||
<button
|
<button
|
||||||
className="sm primary"
|
className="sm primary"
|
||||||
@@ -241,16 +312,9 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
>
|
>
|
||||||
{validating ? 'Validation...' : 'Valider la clé'}
|
{validating ? 'Validation...' : 'Valider la clé'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
className="sm ghost"
|
|
||||||
onClick={goNext}
|
|
||||||
disabled={!answers.apikey.trim()}
|
|
||||||
>
|
|
||||||
Passer
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{answers.apikey.trim() && !keyValid && !error && (
|
{!keyValid && !error && answers.apikey.trim() && (
|
||||||
<div className="onboarding-hint">Cliquez "Valider la clé" ou "Passer"</div>
|
<div className="onboarding-hint">Entrez votre clé puis cliquez "Valider la clé"</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -258,8 +322,10 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
{current.key === 'editor' && (
|
{current.key === 'editor' && (
|
||||||
<div className="onboarding-step">
|
<div className="onboarding-step">
|
||||||
<div className="onboarding-title">Quel éditeur utilisez-vous ?</div>
|
<div className="onboarding-title">Quel éditeur utilisez-vous ?</div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div className="onboarding-desc">
|
||||||
<div className="onboarding-chips" style={{ flex: 1 }}>
|
{scanning ? 'Détection en cours...' : 'Sélectionnez votre éditeur.'}
|
||||||
|
</div>
|
||||||
|
<div className="onboarding-chips">
|
||||||
{editorList.map(ed => (
|
{editorList.map(ed => (
|
||||||
<div
|
<div
|
||||||
key={ed}
|
key={ed}
|
||||||
@@ -270,25 +336,6 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -394,6 +441,10 @@ export default function OnboardingWizard({ api, onComplete }) {
|
|||||||
.onboarding-hint {
|
.onboarding-hint {
|
||||||
font-size: 12px; color: var(--text-tertiary); margin-top: 4px;
|
font-size: 12px; color: var(--text-tertiary); margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
.onboarding-scanning {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
font-size: 13px; color: var(--accent); margin-top: 4px;
|
||||||
|
}
|
||||||
.spin-icon {
|
.spin-icon {
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,8 +53,12 @@ function renderContent(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatText(text) {
|
function formatText(text) {
|
||||||
return text
|
// First escape HTML entities
|
||||||
|
let html = text
|
||||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
|
||||||
|
// Apply markdown transformations (now with escaped brackets)
|
||||||
|
html = html
|
||||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
|
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
|
||||||
.replace(/^### (.+)$/gm, '<h4 class="msg-h4">$1</h4>')
|
.replace(/^### (.+)$/gm, '<h4 class="msg-h4">$1</h4>')
|
||||||
@@ -62,6 +66,14 @@ function formatText(text) {
|
|||||||
.replace(/^# (.+)$/gm, '<h2 class="msg-h2">$1</h2>')
|
.replace(/^# (.+)$/gm, '<h2 class="msg-h2">$1</h2>')
|
||||||
.replace(/^\s*[-*] (.+)$/gm, '<div class="msg-bullet">• $1</div>')
|
.replace(/^\s*[-*] (.+)$/gm, '<div class="msg-bullet">• $1</div>')
|
||||||
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<div class="msg-step"><span class="msg-step-num">$1</span> $2</div>')
|
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<div class="msg-step"><span class="msg-step-num">$1</span> $2</div>')
|
||||||
|
|
||||||
|
// Sanitize: remove event handlers and dangerous protocols
|
||||||
|
html = html
|
||||||
|
.replace(/\s+on\w+=["'][^"']*["']/gi, '') // Remove on* event handlers
|
||||||
|
.replace(/javascript:/gi, '')
|
||||||
|
.replace(/data:/gi, '')
|
||||||
|
|
||||||
|
return html
|
||||||
}
|
}
|
||||||
|
|
||||||
function ThinkingBlock({ content, done }) {
|
function ThinkingBlock({ content, done }) {
|
||||||
@@ -272,6 +284,7 @@ export default function Studio({ api }) {
|
|||||||
const [streamThinking, setStreamThinking] = useState('')
|
const [streamThinking, setStreamThinking] = useState('')
|
||||||
const [streamToolCalls, setStreamToolCalls] = useState([])
|
const [streamToolCalls, setStreamToolCalls] = useState([])
|
||||||
const [loaded, setLoaded] = useState(false)
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
const [tokenInfo, setTokenInfo] = useState({ used: 0, max: 100000, summarizeAt: 80000 })
|
||||||
const messagesEnd = useRef(null)
|
const messagesEnd = useRef(null)
|
||||||
const textareaRef = useRef(null)
|
const textareaRef = useRef(null)
|
||||||
const abortRef = useRef(null)
|
const abortRef = useRef(null)
|
||||||
@@ -285,6 +298,11 @@ export default function Studio({ api }) {
|
|||||||
{ id: 'welcome', role: 'system', content: t('studio.welcomeNew'), time: new Date().toISOString() },
|
{ id: 'welcome', role: 'system', content: t('studio.welcomeNew'), time: new Date().toISOString() },
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
setTokenInfo({
|
||||||
|
used: data.tokens || 0,
|
||||||
|
max: data.max_tokens || 100000,
|
||||||
|
summarizeAt: data.summarize_at || 80000,
|
||||||
|
})
|
||||||
setLoaded(true)
|
setLoaded(true)
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
setMessages([
|
setMessages([
|
||||||
@@ -305,6 +323,28 @@ export default function Studio({ api }) {
|
|||||||
}
|
}
|
||||||
}, [input])
|
}, [input])
|
||||||
|
|
||||||
|
const refreshTokens = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.getChatHistory()
|
||||||
|
setTokenInfo({
|
||||||
|
used: data.tokens || 0,
|
||||||
|
max: data.max_tokens || 100000,
|
||||||
|
summarizeAt: data.summarize_at || 80000,
|
||||||
|
})
|
||||||
|
} catch {}
|
||||||
|
}, [api])
|
||||||
|
|
||||||
|
const handleSummarize = useCallback(async () => {
|
||||||
|
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'system', content: 'Résumé de la conversation en cours...', time: new Date().toISOString() }])
|
||||||
|
try {
|
||||||
|
const data = await api.summarizeChat()
|
||||||
|
setTokenInfo(prev => ({ ...prev, used: data.tokens || 0 }))
|
||||||
|
setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: 'system', content: '✓ Conversation résumée automatiquement. Le contexte a été compressé.', time: new Date().toISOString() }])
|
||||||
|
} catch (err) {
|
||||||
|
setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: 'system', content: `Erreur de résumé: ${err.message}`, time: new Date().toISOString() }])
|
||||||
|
}
|
||||||
|
}, [api])
|
||||||
|
|
||||||
const handleClear = useCallback(async () => {
|
const handleClear = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await api.clearChat()
|
await api.clearChat()
|
||||||
@@ -324,6 +364,71 @@ export default function Studio({ api }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (text === '/help') {
|
||||||
|
const helpMsg = [
|
||||||
|
'## Commandes Studio',
|
||||||
|
'',
|
||||||
|
'- `/clear` - Effacer la conversation',
|
||||||
|
'- `/summarize` - Résumer la conversation précédente',
|
||||||
|
'- `/help` - Afficher cette aide',
|
||||||
|
'- `/plan <objectif>` - Demander un plan structuré',
|
||||||
|
'- `/export` - Exporter la conversation en Markdown',
|
||||||
|
'- `/model` - Afficher le provider et modèle actifs',
|
||||||
|
'',
|
||||||
|
'## Tools disponibles',
|
||||||
|
'- Terminal - Exécuter des commandes',
|
||||||
|
'- read_file - Lire des fichiers',
|
||||||
|
'- list_files - Lister des fichiers',
|
||||||
|
'- search_files - Rechercher des fichiers',
|
||||||
|
'- grep_content - Rechercher dans le contenu',
|
||||||
|
'- get_config - Lire la configuration',
|
||||||
|
'- web_fetch - Récupérer une page web',
|
||||||
|
].join('\n')
|
||||||
|
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: helpMsg, time: new Date().toISOString() }])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text === '/summarize') {
|
||||||
|
handleSummarize()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text === '/model') {
|
||||||
|
api.getProviders().then(data => {
|
||||||
|
const active = data.providers?.find(p => p.active)
|
||||||
|
const modelMsg = active ? `Provider: ${active.name}\nModèle: ${active.model}` : 'Aucun provider actif configuré'
|
||||||
|
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: modelMsg, time: new Date().toISOString() }])
|
||||||
|
}).catch(() => {
|
||||||
|
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur: impossible de récupérer les providers', time: new Date().toISOString() }])
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.startsWith('/plan ')) {
|
||||||
|
const objective = text.slice(6).trim()
|
||||||
|
if (!objective) {
|
||||||
|
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Usage: `/plan <objectif>`\nEx: `/plan créer un fichier de test`', time: new Date().toISOString() }])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setInput(`Crée un plan structuré en étapes numérotées pour: ${objective}. Chaque étape devrait avoir une estimation de complexité et de temps.`)
|
||||||
|
handleSend()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text === '/export') {
|
||||||
|
api.getChatHistory().then(data => {
|
||||||
|
let markdown = '# Conversation Export\n\n'
|
||||||
|
data.messages?.forEach((msg, i) => {
|
||||||
|
const roleLabel = msg.role === 'user' ? '👤' : (msg.role === 'assistant' ? '🤖' : '⚙️')
|
||||||
|
markdown += `## [${i + 1}] ${roleLabel} ${msg.role}\n${msg.content}\n\n---\n\n`
|
||||||
|
})
|
||||||
|
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Conversation exportée:\n```markdown\n' + markdown + '```', time: new Date().toISOString() }])
|
||||||
|
}).catch(() => {
|
||||||
|
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur: impossible d\'exporter la conversation', time: new Date().toISOString() }])
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const userMsg = { id: Date.now().toString(), role: 'user', content: text, time: new Date().toISOString() }
|
const userMsg = { id: Date.now().toString(), role: 'user', content: text, time: new Date().toISOString() }
|
||||||
setMessages(prev => [...prev, userMsg])
|
setMessages(prev => [...prev, userMsg])
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -403,8 +508,9 @@ export default function Studio({ api }) {
|
|||||||
setStreamThinking('')
|
setStreamThinking('')
|
||||||
setStreamToolCalls([])
|
setStreamToolCalls([])
|
||||||
abortRef.current = null
|
abortRef.current = null
|
||||||
|
refreshTokens()
|
||||||
}
|
}
|
||||||
}, [input, loading, api, t, handleClear, streaming])
|
}, [input, loading, api, t, handleClear, streaming, refreshTokens, handleSummarize])
|
||||||
|
|
||||||
const handleStop = useCallback(() => {
|
const handleStop = useCallback(() => {
|
||||||
if (abortRef.current) {
|
if (abortRef.current) {
|
||||||
@@ -444,6 +550,18 @@ export default function Studio({ api }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="studio-input-area">
|
<div className="studio-input-area">
|
||||||
|
<div className="studio-token-bar">
|
||||||
|
<div className="studio-token-track">
|
||||||
|
<div
|
||||||
|
className={`studio-token-fill ${tokenInfo.used >= tokenInfo.summarizeAt ? 'warn' : ''}`}
|
||||||
|
style={{ width: `${Math.min(100, (tokenInfo.used / tokenInfo.max) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="studio-token-text">
|
||||||
|
{(tokenInfo.used / 1000).toFixed(1)}k / {(tokenInfo.max / 1000).toFixed(0)}k tokens
|
||||||
|
{tokenInfo.used >= tokenInfo.summarizeAt && ' · résumé automatique déclenché'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className="studio-input-row">
|
<div className="studio-input-row">
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
@@ -472,7 +590,7 @@ export default function Studio({ api }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="studio-input-hint">
|
<div className="studio-input-hint">
|
||||||
{t('studio.inputHint')} · /clear
|
{t('studio.inputHint')} · /clear /summarize /help /plan /export /model
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -169,6 +169,12 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
color: var(--text-disabled);
|
color: var(--text-disabled);
|
||||||
}
|
}
|
||||||
.statusbar-left, .statusbar-right { display: flex; align-items: center; gap: 12px; }
|
.statusbar-left, .statusbar-right { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.statusbar-sudo {
|
||||||
|
font-size: 10px; font-weight: 700; font-family: var(--font-mono);
|
||||||
|
padding: 1px 6px; border-radius: 3px;
|
||||||
|
background: rgba(239, 68, 68, 0.15); color: #ef4444; border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
.statusbar-shortcut { display: inline-flex; align-items: center; gap: 4px; }
|
.statusbar-shortcut { display: inline-flex; align-items: center; gap: 4px; }
|
||||||
.statusbar-shortcut kbd {
|
.statusbar-shortcut kbd {
|
||||||
display: inline-block; padding: 1px 5px; border-radius: 3px;
|
display: inline-block; padding: 1px 5px; border-radius: 3px;
|
||||||
@@ -525,10 +531,132 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
|
|
||||||
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; color: var(--text-disabled); font-size: 13px; text-align: center; gap: 8px; }
|
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; color: var(--text-disabled); font-size: 13px; text-align: center; gap: 8px; }
|
||||||
|
|
||||||
|
/* ── Dashboard Grid ── */
|
||||||
|
.dash-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.dash-card {
|
||||||
|
position: relative;
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg); padding: 14px 16px;
|
||||||
|
display: flex; flex-direction: column; gap: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-span-2 { grid-column: span 2; }
|
||||||
|
.dash-card-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.dash-label {
|
||||||
|
font-size: 11px; font-weight: 700; color: var(--accent);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.dash-count {
|
||||||
|
font-size: 10px; font-family: var(--font-mono);
|
||||||
|
background: var(--bg-input); padding: 1px 6px; border-radius: 10px;
|
||||||
|
}
|
||||||
|
.dash-count.warn { background: var(--accent-bg); color: var(--accent); }
|
||||||
|
|
||||||
|
/* Tools row */
|
||||||
|
.dash-tools-row {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 6px;
|
||||||
|
}
|
||||||
|
.dash-tool-tag {
|
||||||
|
font-size: 11px; font-family: var(--font-mono);
|
||||||
|
padding: 3px 8px; border-radius: var(--radius);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
}
|
||||||
|
.dash-tool-tag.ok { color: var(--success); }
|
||||||
|
.dash-tool-tag.missing { color: var(--error); }
|
||||||
|
|
||||||
|
/* Quota */
|
||||||
|
.dash-quota-list { display: flex; flex-direction: column; gap: 6px; max-height: 270px; overflow-y: auto; }
|
||||||
|
.dash-quota-row { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.dash-quota-name {
|
||||||
|
font-size: 11px; font-weight: 600; color: var(--text-primary);
|
||||||
|
min-width: 80px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.dash-bar {
|
||||||
|
flex: 1; height: 4px; background: var(--bg-input); border-radius: 2px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.dash-bar-fill {
|
||||||
|
height: 100%; background: var(--accent); border-radius: 2px;
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
.dash-quota-val {
|
||||||
|
font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Processes */
|
||||||
|
.dash-proc-list { display: flex; flex-direction: column; gap: 4px; max-height: 270px; overflow-y: auto; }
|
||||||
|
.dash-proc-row {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.dash-proc-name {
|
||||||
|
font-size: 11px; font-weight: 600; color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.dash-proc-res {
|
||||||
|
font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Commands */
|
||||||
|
.dash-cmd-list { display: flex; flex-direction: column; gap: 3px; max-height: 270px; overflow-y: auto; }
|
||||||
|
.dash-cmd-row {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
padding: 3px 0; overflow: hidden;
|
||||||
|
}
|
||||||
|
.dash-cmd-shell {
|
||||||
|
font-size: 9px; font-family: var(--font-mono); color: var(--text-disabled);
|
||||||
|
background: var(--bg-input); padding: 1px 4px; border-radius: 3px;
|
||||||
|
text-transform: uppercase; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.dash-cmd-text {
|
||||||
|
font-size: 11px; font-family: var(--font-mono); color: var(--text-secondary);
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Services */
|
||||||
|
.dash-services { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.dash-svc-row {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.dash-svc-name { font-size: 12px; font-weight: 600; color: var(--text-primary); }
|
||||||
|
.dash-svc-val { font-size: 11px; font-family: var(--font-mono); color: var(--text-tertiary); }
|
||||||
|
.dash-svc-issues { margin-top: 4px; }
|
||||||
|
.dash-svc-issue { font-size: 10px; color: var(--warning); padding: 2px 0; }
|
||||||
|
|
||||||
|
/* Updates */
|
||||||
|
.dash-updates-list { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.dash-update-row {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
}
|
||||||
|
.dash-update-name { font-size: 12px; font-weight: 600; color: var(--text-primary); }
|
||||||
|
.dash-update-ver { font-size: 11px; font-family: var(--font-mono); color: var(--text-tertiary); }
|
||||||
|
|
||||||
|
.dash-empty { font-size: 11px; color: var(--text-disabled); }
|
||||||
|
|
||||||
|
/* Graph */
|
||||||
|
.dash-graph-wrap { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.dash-graph-header { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.dash-graph-label { font-size: 9px; color: var(--text-disabled); text-transform: uppercase; }
|
||||||
|
.dash-graph-value { font-size: 10px; font-family: var(--font-mono); font-weight: 600; }
|
||||||
|
.dash-graph-svg { width: 100%; height: 32px; }
|
||||||
|
.dash-graph-empty { font-size: 10px; color: var(--text-disabled); text-align: center; padding: 8px 0; }
|
||||||
|
|
||||||
|
/* Legacy dashboard kept for reference */
|
||||||
.dashboard-layout { display: flex; flex-direction: column; height: 100%; }
|
.dashboard-layout { display: flex; flex-direction: column; height: 100%; }
|
||||||
.dashboard-content { flex: 1; overflow-y: auto; }
|
.dashboard-content { flex: 1; overflow-y: auto; }
|
||||||
.dashboard-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; padding: 24px; }
|
.dashboard-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; padding: 24px; }
|
||||||
|
|
||||||
.dashboard-section {
|
.dashboard-section {
|
||||||
background: var(--bg-card); border: 1px solid var(--border);
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg); padding: 20px; transition: border-color 0.2s;
|
border-radius: var(--radius-lg); padding: 20px; transition: border-color 0.2s;
|
||||||
@@ -540,11 +668,8 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
font-size: 12px; font-weight: 700; color: var(--accent); text-transform: uppercase;
|
font-size: 12px; font-weight: 700; color: var(--accent); text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-workflows-inline { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
.dashboard-workflows-inline { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||||
|
|
||||||
.dashboard-notifications-inline { display: flex; flex-direction: column; gap: 2px; }
|
.dashboard-notifications-inline { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
|
||||||
.dashboard-notifications { padding: 0; }
|
.dashboard-notifications { padding: 0; }
|
||||||
.notif-row {
|
.notif-row {
|
||||||
display: flex; align-items: flex-start; gap: 12px;
|
display: flex; align-items: flex-start; gap: 12px;
|
||||||
@@ -557,7 +682,6 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.notif-ok .notif-text { color: var(--success); }
|
.notif-ok .notif-text { color: var(--success); }
|
||||||
.notif-warn .notif-text { color: var(--warning); }
|
.notif-warn .notif-text { color: var(--warning); }
|
||||||
.notif-error .notif-text { color: var(--error); }
|
.notif-error .notif-text { color: var(--error); }
|
||||||
|
|
||||||
.dashboard-workflows { padding: 0; display: flex; flex-direction: column; gap: 24px; }
|
.dashboard-workflows { padding: 0; display: flex; flex-direction: column; gap: 24px; }
|
||||||
.workflow-section { }
|
.workflow-section { }
|
||||||
.section-label {
|
.section-label {
|
||||||
@@ -565,81 +689,6 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
letter-spacing: 1px; margin-bottom: 12px; padding-bottom: 6px; border-bottom: 1px solid var(--border);
|
letter-spacing: 1px; margin-bottom: 12px; padding-bottom: 6px; border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Dashboard Tabs ── */
|
|
||||||
.dashboard-tabs {
|
|
||||||
display: flex; gap: 4px; padding: 12px 20px 0;
|
|
||||||
border-bottom: 1px solid var(--border); background: var(--bg-surface); flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.dashboard-tab {
|
|
||||||
padding: 8px 16px; border-radius: var(--radius) var(--radius) 0 0;
|
|
||||||
border: 1px solid transparent; border-bottom: none; background: transparent;
|
|
||||||
color: var(--text-tertiary); font-size: 12px; font-weight: 600; cursor: pointer;
|
|
||||||
display: flex; align-items: center; gap: 6px; transition: all 0.15s;
|
|
||||||
}
|
|
||||||
.dashboard-tab:hover { color: var(--text-primary); background: var(--bg-hover); }
|
|
||||||
.dashboard-tab.active { background: var(--bg-card); color: var(--accent); border-color: var(--border); }
|
|
||||||
.dashboard-tab .tab-icon { font-size: 14px; }
|
|
||||||
.dashboard-tab .tab-count {
|
|
||||||
background: var(--bg-input); padding: 1px 6px; border-radius: 10px; font-size: 10px; font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
.dashboard-tab .tab-count.warn { background: var(--accent-bg); color: var(--accent); }
|
|
||||||
|
|
||||||
.dashboard-tools-panel { padding: 20px 24px; }
|
|
||||||
.dashboard-tools-stats { display: flex; gap: 12px; font-size: 12px; }
|
|
||||||
.stat-ok { color: var(--success); font-family: var(--font-mono); }
|
|
||||||
.stat-missing { color: var(--error); font-family: var(--font-mono); }
|
|
||||||
|
|
||||||
.dashboard-system-info { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; font-size: 12px; color: var(--text-tertiary); }
|
|
||||||
.sys-info-item { font-family: var(--font-mono); }
|
|
||||||
.sys-info-sep { color: var(--text-disabled); }
|
|
||||||
|
|
||||||
.tools-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; margin-top: 8px; }
|
|
||||||
.tool-card {
|
|
||||||
background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg);
|
|
||||||
padding: 14px 16px; display: flex; align-items: center; gap: 12px; transition: border-color 0.2s;
|
|
||||||
}
|
|
||||||
.tool-card:hover { border-color: var(--accent-dim); }
|
|
||||||
.tool-card.installed { border-left: 3px solid var(--success); }
|
|
||||||
.tool-card.missing { border-left: 3px solid var(--error); }
|
|
||||||
.tool-card-icon { font-size: 20px; flex-shrink: 0; }
|
|
||||||
.tool-card-info { flex: 1; min-width: 0; }
|
|
||||||
.tool-card-name { font-weight: 600; font-size: 13px; color: var(--text-primary); margin-bottom: 2px; }
|
|
||||||
.tool-card-version { font-size: 11px; color: var(--text-tertiary); display: flex; align-items: center; gap: 6px; }
|
|
||||||
.tool-version-text { font-family: var(--font-mono); font-size: 10px; color: var(--text-disabled); }
|
|
||||||
.status-ok { color: var(--success); }
|
|
||||||
.status-missing { color: var(--error); }
|
|
||||||
.tool-card-actions { flex-shrink: 0; display: flex; align-items: center; gap: 6px; }
|
|
||||||
.tool-update-badge { background: var(--accent-bg); color: var(--accent); font-size: 10px; font-family: var(--font-mono); padding: 2px 6px; border-radius: 4px; cursor: pointer; }
|
|
||||||
.tool-update-badge:hover { background: var(--accent-dim); }
|
|
||||||
|
|
||||||
.dashboard-activity-panel { padding: 20px 24px; }
|
|
||||||
.activity-log { display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
.notif-icon { font-size: 12px; width: 16px; text-align: center; }
|
|
||||||
|
|
||||||
.dashboard-actions-panel { padding: 20px 24px; }
|
|
||||||
.quick-actions-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; margin-bottom: 24px; }
|
|
||||||
.quick-action-btn {
|
|
||||||
background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg);
|
|
||||||
padding: 16px 20px; display: flex; align-items: center; gap: 12px; cursor: pointer;
|
|
||||||
transition: all 0.2s; font-size: 13px; color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
.quick-action-btn:hover:not(:disabled) { border-color: var(--accent-dim); background: var(--bg-hover); color: var(--text-primary); }
|
|
||||||
.quick-action-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
||||||
.quick-action-icon { font-size: 18px; }
|
|
||||||
.quick-action-label { font-weight: 600; }
|
|
||||||
|
|
||||||
.dashboard-updates-section { margin-top: 16px; }
|
|
||||||
.updates-list { display: flex; flex-direction: column; gap: 6px; }
|
|
||||||
.update-row {
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
padding: 10px 14px; border-radius: var(--radius); background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.update-row:hover { border-color: var(--accent-dim); }
|
|
||||||
.update-info { display: flex; align-items: center; gap: 16px; }
|
|
||||||
.update-name { font-weight: 600; color: var(--text-primary); font-size: 13px; min-width: 100px; }
|
|
||||||
.update-versions { color: var(--text-tertiary); font-size: 12px; font-family: var(--font-mono); }
|
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
display: flex; align-items: center; justify-content: space-between; padding: 10px 16px;
|
display: flex; align-items: center; justify-content: space-between; padding: 10px 16px;
|
||||||
border-bottom: 1px solid var(--border); background: var(--bg-surface);
|
border-bottom: 1px solid var(--border); background: var(--bg-surface);
|
||||||
@@ -684,6 +733,8 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
background: var(--bg-surface); border: 1px solid var(--border); border-left: 2px solid var(--accent-dim);
|
background: var(--bg-surface); border: 1px solid var(--border); border-left: 2px solid var(--accent-dim);
|
||||||
border-radius: var(--radius); margin: 6px 0 8px; overflow: hidden;
|
border-radius: var(--radius); margin: 6px 0 8px; overflow: hidden;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
.feed-thinking-block.active {
|
.feed-thinking-block.active {
|
||||||
border-left-color: var(--warning);
|
border-left-color: var(--warning);
|
||||||
@@ -736,6 +787,11 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.studio-thinking span:nth-child(3) { animation-delay: 0.3s; }
|
.studio-thinking span:nth-child(3) { animation-delay: 0.3s; }
|
||||||
@keyframes bounce { 0%, 80%, 100% { transform: translateY(0); opacity: 0.4; } 40% { transform: translateY(-6px); opacity: 1; } }
|
@keyframes bounce { 0%, 80%, 100% { transform: translateY(0); opacity: 0.4; } 40% { transform: translateY(-6px); opacity: 1; } }
|
||||||
.studio-input-area { padding: 12px 20px 8px; border-top: 1px solid var(--border); background: var(--bg-surface); }
|
.studio-input-area { padding: 12px 20px 8px; border-top: 1px solid var(--border); background: var(--bg-surface); }
|
||||||
|
.studio-token-bar { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||||
|
.studio-token-track { flex: 1; height: 3px; background: var(--bg-input); border-radius: 2px; overflow: hidden; }
|
||||||
|
.studio-token-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.4s, background 0.3s; }
|
||||||
|
.studio-token-fill.warn { background: var(--warning); }
|
||||||
|
.studio-token-text { font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); white-space: nowrap; }
|
||||||
.studio-input-row { display: flex; gap: 8px; align-items: flex-end; }
|
.studio-input-row { display: flex; gap: 8px; align-items: flex-end; }
|
||||||
.studio-input-row textarea {
|
.studio-input-row textarea {
|
||||||
flex: 1; resize: none; min-height: 42px; max-height: 200px; padding: 10px 14px;
|
flex: 1; resize: none; min-height: 42px; max-height: 200px; padding: 10px 14px;
|
||||||
@@ -826,7 +882,8 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
white-space: nowrap;
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
|
|||||||
Reference in New Issue
Block a user