Compare commits
41 Commits
v0.3.2-bet
...
v0.3.5-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08dc1fd53b | ||
|
|
13e937a11b | ||
|
|
3cf701b002 | ||
|
|
3a09e0e0c2 | ||
|
|
47fa2e01bb | ||
|
|
401292ec5b | ||
|
|
199a7e409a | ||
|
|
c91931f42f | ||
|
|
cbbb224725 | ||
|
|
8d10d2182e | ||
|
|
e9696ef82b | ||
|
|
1edd4f053a | ||
|
|
92f943c3e6 | ||
|
|
1704b196cf | ||
|
|
40ec493bae | ||
|
|
233368c954 | ||
|
|
00118f0803 | ||
|
|
167ab82978 | ||
|
|
a23c0c5b94 | ||
|
|
24b31b0b47 | ||
|
|
7ae4017672 | ||
|
|
8c540eba93 | ||
|
|
1074b019d3 | ||
|
|
2da0cf9421 | ||
|
|
9987a586e2 | ||
|
|
2827acfe96 | ||
|
|
afb6e77c7f | ||
|
|
84be22661b | ||
|
|
f9c4cf11ff | ||
|
|
eda7293286 | ||
|
|
b55feaed09 | ||
|
|
54621bd960 | ||
|
|
6bad2948c5 | ||
|
|
92eb783df0 | ||
|
|
8005e978f0 | ||
|
|
6e76e7dca6 | ||
|
|
e8f6dc4b4d | ||
|
|
bb03c9fe2d | ||
|
|
79d082180c | ||
|
|
7682717093 | ||
|
|
3948a4c656 |
@@ -170,7 +170,7 @@ jobs:
|
||||
|
||||
- name: Commit changelog
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
git config user.name "CI Bot"
|
||||
git config user.email "ci@legion-muyue.fr"
|
||||
@@ -181,30 +181,45 @@ jobs:
|
||||
|
||||
- name: Create release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -ex
|
||||
if [ -z "$GITEA_TOKEN" ]; then
|
||||
echo "Warning: GITEATOKEN not set, skipping release"
|
||||
exit 0
|
||||
echo "Error: GITEA_TOKEN secret is not set"
|
||||
exit 1
|
||||
fi
|
||||
VERSION=${{ steps.version.outputs.version }}
|
||||
API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
|
||||
BODY=$(cat /tmp/stable_changelog.md)
|
||||
RESPONSE=$(curl -s -X POST "${API}" \
|
||||
echo "Creating release ${VERSION} at ${API}"
|
||||
|
||||
EXISTING=$(curl -sf -H "Authorization: token ${GITEA_TOKEN}" "${API}/tags/${VERSION}" || echo "")
|
||||
if [ -n "$EXISTING" ]; then
|
||||
EXISTING_ID=$(echo "$EXISTING" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || echo "")
|
||||
if [ -n "$EXISTING_ID" ]; then
|
||||
echo "Release ${VERSION} already exists (ID: ${EXISTING_ID}), deleting..."
|
||||
curl -sf -X DELETE -H "Authorization: token ${GITEA_TOKEN}" "${API}/${EXISTING_ID}" || true
|
||||
fi
|
||||
fi
|
||||
|
||||
BODY=$(python3 -c "import json,sys; print(json.dumps(sys.stdin.read()))" < /tmp/stable_changelog.md)
|
||||
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "${API}" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"tag_name\":\"${VERSION}\",
|
||||
\"target_commitish\":\"main\",
|
||||
\"name\":\"muyue ${VERSION}\",
|
||||
\"body\":$(echo "$BODY" | jq -Rs .),
|
||||
\"body\":${BODY},
|
||||
\"draft\":false,
|
||||
\"prerelease\":false
|
||||
}")
|
||||
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
|
||||
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
|
||||
RESPONSE_BODY=$(echo "$RESPONSE" | sed '$d')
|
||||
echo "HTTP Status: ${HTTP_CODE}"
|
||||
echo "Response: ${RESPONSE_BODY}"
|
||||
RELEASE_ID=$(echo "$RESPONSE_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || echo "")
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
echo "Failed to create release:"
|
||||
echo "$RESPONSE"
|
||||
echo "Failed to create release"
|
||||
exit 1
|
||||
fi
|
||||
echo "Release ID: ${RELEASE_ID}"
|
||||
@@ -212,8 +227,12 @@ jobs:
|
||||
for file in dist/*.tar.gz dist/*.zip dist/checksums.txt; do
|
||||
filename=$(basename "$file")
|
||||
echo "Uploading ${filename}..."
|
||||
curl -s -X POST "${UPLOAD_URL}" \
|
||||
UPLOAD_RESP=$(curl -s -w "\n%{http_code}" -X POST "${UPLOAD_URL}" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-F "attachment=@${file};filename=${filename}" > /dev/null
|
||||
-F "attachment=@${file};filename=${filename}")
|
||||
UPLOAD_CODE=$(echo "$UPLOAD_RESP" | tail -1)
|
||||
if [ "$UPLOAD_CODE" != "201" ]; then
|
||||
echo "Upload failed with status ${UPLOAD_CODE}"
|
||||
fi
|
||||
done
|
||||
echo "Stable release ${VERSION} published!"
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
var versionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print version",
|
||||
Short: "Print version info",
|
||||
RunE: runVersion,
|
||||
}
|
||||
|
||||
@@ -18,6 +18,6 @@ func init() {
|
||||
}
|
||||
|
||||
func runVersion(cmd *cobra.Command, args []string) error {
|
||||
fmt.Printf("Muyue version %s\n", version.Version)
|
||||
fmt.Print(version.FullInfo())
|
||||
return nil
|
||||
}
|
||||
1
go.mod
1
go.mod
@@ -7,6 +7,7 @@ toolchain go1.24.3
|
||||
require (
|
||||
github.com/charmbracelet/huh v1.0.0
|
||||
github.com/creack/pty/v2 v2.0.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/spf13/cobra v1.10.2
|
||||
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/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/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/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
|
||||
244
internal/api/chat_engine.go
Normal file
244
internal/api/chat_engine.go
Normal file
@@ -0,0 +1,244 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"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 != "" {
|
||||
if ce.onChunk != nil {
|
||||
ce.onChunk(map[string]interface{}{"content": 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 {
|
||||
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>`)
|
||||
|
||||
const maxToolIterations = 15
|
||||
|
||||
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
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) {
|
||||
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)
|
||||
SetupSSEHeaders(w)
|
||||
flusher, canFlush := w.(http.Flusher)
|
||||
|
||||
writeSSE := func(data map[string]interface{}) {
|
||||
b, _ := json.Marshal(data)
|
||||
w.Write([]byte("data: " + string(b) + "\n\n"))
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
sseWriter := NewSSEWriter(w)
|
||||
|
||||
|
||||
ctx := context.Background()
|
||||
messages := s.buildContextMessages(userMessage)
|
||||
|
||||
var finalContent string
|
||||
var allToolCalls []map[string]interface{}
|
||||
var allToolResults []map[string]interface{}
|
||||
|
||||
for i := 0; i < maxToolIterations; i++ {
|
||||
resp, err := orb.SendWithTools(messages)
|
||||
if err != nil {
|
||||
writeSSE(map[string]interface{}{"error": err.Error()})
|
||||
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
|
||||
engine.OnChunk(func(data map[string]interface{}) {
|
||||
if data == nil {
|
||||
return
|
||||
}
|
||||
|
||||
choice := resp.Choices[0]
|
||||
content := cleanThinkingTags(choice.Message.Content)
|
||||
|
||||
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
|
||||
sseWriter.Write(data)
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
})
|
||||
|
||||
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{
|
||||
Role: "tool",
|
||||
Content: result.Content,
|
||||
ToolCallID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
})
|
||||
}
|
||||
|
||||
finalContent = ""
|
||||
finalContent, allToolCalls, allToolResults, err := engine.RunWithTools(ctx, messages)
|
||||
if err != nil {
|
||||
sseWriter.Write(map[string]interface{}{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
storeContent := finalContent
|
||||
@@ -171,68 +92,18 @@ func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orche
|
||||
}
|
||||
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) {
|
||||
ctx := context.Background()
|
||||
messages := s.buildContextMessages(userMessage)
|
||||
|
||||
var finalContent string
|
||||
|
||||
for i := 0; i < maxToolIterations; i++ {
|
||||
resp, err := orb.SendWithTools(messages)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
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)"
|
||||
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
|
||||
finalContent, err := engine.RunNonStream(ctx, messages)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
s.convStore.Add("assistant", finalContent)
|
||||
@@ -335,8 +206,11 @@ func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
messages := s.convStore.Get()
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"messages": messages,
|
||||
"tokens": s.convStore.ApproxTokenCount(),
|
||||
"messages": messages,
|
||||
"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()
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -53,32 +53,27 @@ func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, "no config", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Pseudo string `json:"pseudo"`
|
||||
Email string `json:"email"`
|
||||
Editor string `json:"editor"`
|
||||
Shell string `json:"shell"`
|
||||
|
||||
currentJSON, err := json.Marshal(s.config.Profile)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
var currentMap map[string]interface{}
|
||||
json.Unmarshal(currentJSON, ¤tMap)
|
||||
|
||||
var updates map[string]interface{}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if err := json.Unmarshal(body, &updates); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Name != "" {
|
||||
s.config.Profile.Name = body.Name
|
||||
}
|
||||
if body.Pseudo != "" {
|
||||
s.config.Profile.Pseudo = body.Pseudo
|
||||
}
|
||||
if body.Email != "" {
|
||||
s.config.Profile.Email = body.Email
|
||||
}
|
||||
if body.Editor != "" {
|
||||
s.config.Profile.Preferences.Editor = body.Editor
|
||||
}
|
||||
if body.Shell != "" {
|
||||
s.config.Profile.Preferences.Shell = body.Shell
|
||||
}
|
||||
|
||||
deepMerge(currentMap, updates)
|
||||
|
||||
mergedJSON, _ := json.Marshal(currentMap)
|
||||
json.Unmarshal(mergedJSON, &s.config.Profile)
|
||||
|
||||
if err := config.Save(s.config); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -86,6 +81,20 @@ func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func deepMerge(dst, src map[string]interface{}) {
|
||||
for k, sv := range src {
|
||||
if dv, ok := dst[k]; ok {
|
||||
dstMap, dOk := dv.(map[string]interface{})
|
||||
srcMap, sOk := sv.(map[string]interface{})
|
||||
if dOk && sOk {
|
||||
deepMerge(dstMap, srcMap)
|
||||
continue
|
||||
}
|
||||
}
|
||||
dst[k] = sv
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleSaveProvider(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "PUT" {
|
||||
writeError(w, "PUT only", http.StatusMethodNotAllowed)
|
||||
|
||||
@@ -2,8 +2,15 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/muyue/muyue/internal/lsp"
|
||||
"github.com/muyue/muyue/internal/mcp"
|
||||
@@ -17,6 +24,7 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
|
||||
"name": version.Name,
|
||||
"version": version.Version,
|
||||
"author": version.Author,
|
||||
"sudo": os.Geteuid() == 0,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -415,3 +423,349 @@ func (s *Server) handleEditors(w http.ResponseWriter, r *http.Request) {
|
||||
editors := scanner.ScanEditors()
|
||||
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 {
|
||||
if d, ok := data["data"].(map[string]interface{}); ok {
|
||||
if limits, ok := d["limits"].([]interface{}); ok {
|
||||
models := make([]map[string]interface{}, 0)
|
||||
for _, l := range limits {
|
||||
if lm, ok := l.(map[string]interface{}); ok {
|
||||
name := "Z.AI"
|
||||
if model, ok := lm["model"].(string); ok && model != "" {
|
||||
name = model
|
||||
} else if t, ok := lm["type"].(string); ok && t != "TIME_LIMIT" {
|
||||
name = t
|
||||
}
|
||||
usage, _ := lm["usage"].(float64)
|
||||
remaining, _ := lm["remaining"].(float64)
|
||||
limitVal, hasLimit := lm["limit"].(float64)
|
||||
total := usage + remaining
|
||||
if hasLimit && limitVal > 0 {
|
||||
total = limitVal
|
||||
}
|
||||
if total > 0 {
|
||||
models = append(models, map[string]interface{}{
|
||||
"model": name,
|
||||
"used": usage,
|
||||
"total": total,
|
||||
"remaining": remaining,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(models) > 0 {
|
||||
q.Data = map[string]interface{}{"models": models}
|
||||
q.Healthy = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case "claude", "anthropic":
|
||||
// Claude Code n'a pas d'API externe, vérifier l'installation
|
||||
claudePath := "/usr/bin/claude"
|
||||
if _, err := os.Stat(claudePath); err == nil {
|
||||
q.Healthy = true
|
||||
} else {
|
||||
q.Error = "claude code not installed"
|
||||
}
|
||||
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) - 50
|
||||
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
|
||||
}
|
||||
|
||||
base := strings.Fields(line)[0]
|
||||
if len(base) < 2 {
|
||||
continue
|
||||
}
|
||||
if !regexp.MustCompile(`^[a-zA-Z@./]`).MatchString(base) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,38 +1,24 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/muyue/muyue/internal/agent"
|
||||
"github.com/muyue/muyue/internal/orchestrator"
|
||||
)
|
||||
|
||||
const maxShellToolIterations = 10
|
||||
|
||||
type ShellChatRequest struct {
|
||||
Message string `json:"message"`
|
||||
Context string `json:"context,omitempty"`
|
||||
History []string `json:"history,omitempty"`
|
||||
Cwd string `json:"cwd,omitempty"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
type ShellChatResponse struct {
|
||||
Content string `json:"content,omitempty"`
|
||||
ToolCalls []ToolCallInfo `json:"tool_calls,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type ToolCallInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Args map[string]interface{} `json:"args"`
|
||||
Result *toolResponseData `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Context string `json:"context,omitempty"`
|
||||
Cwd string `json:"cwd,omitempty"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -41,6 +27,11 @@ func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if s.shellConvStore.AtLimit() {
|
||||
writeError(w, "context limit reached, use /clear", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req ShellChatRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
@@ -52,252 +43,250 @@ func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
s.shellConvStore.Add("user", req.Message)
|
||||
|
||||
orb, err := orchestrator.New(s.config)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
orb.SetSystemPrompt(s.buildShellSystemPrompt(req))
|
||||
orb.SetTools(s.agentToolsJSON)
|
||||
orb.SetSystemPrompt(s.buildShellSystemPromptV2(req))
|
||||
|
||||
if req.Stream {
|
||||
s.handleShellChatStream(w, orb, req)
|
||||
s.handleShellChatStreamV2(w, orb)
|
||||
} else {
|
||||
s.handleShellChatNonStream(w, orb, req)
|
||||
s.handleShellChatNonStreamV2(w, orb)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) buildShellSystemPrompt(req ShellChatRequest) string {
|
||||
func (s *Server) buildShellSystemPromptV2(_ ShellChatRequest) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(`Tu es l'assistant Shell de Muyue. Tu as accès à un terminal et peux aider l'utilisateur avec:
|
||||
- Exécuter des commandes shell
|
||||
- Expliquer des erreurs de commandes
|
||||
- Suggérer des commandes appropriées pour la tâche demandée
|
||||
- Lire et explorer des fichiers
|
||||
- Configurer l'environnement de développement
|
||||
sb.WriteString(`Tu es l'Analyste Système de Muyue. Tu es un expert en administration système et développement.
|
||||
Tu aides l'utilisateur à comprendre son système, diagnostiquer des problèmes, et optimiser son environnement.
|
||||
|
||||
Tu peux appeler des outils pour exécuter des commandes, lire des fichiers, etc. Sois précis et concis dans tes réponses.
|
||||
RÈGLES STRICTES:
|
||||
- Tu ne peux JAMAIS exécuter de commande ou de code
|
||||
- Tu ne peux que analyser, expliquer, et proposer des solutions
|
||||
- Quand tu proposes du code ou des commandes, mets-les dans des blocs de code markdown avec le langage spécifié
|
||||
- L'utilisateur pourra les copier ou les envoyer directement au terminal depuis les boutons
|
||||
|
||||
`)
|
||||
|
||||
if req.Cwd != "" {
|
||||
sb.WriteString("Répertoire courant: " + req.Cwd + "\n")
|
||||
analysis := LoadSystemAnalysis()
|
||||
if analysis != "" {
|
||||
sb.WriteString("=== ANALYSE SYSTÈME ACTUELLE ===\n")
|
||||
sb.WriteString(analysis)
|
||||
sb.WriteString("\n=== FIN DE L'ANALYSE ===\n\n")
|
||||
}
|
||||
if req.Platform != "" {
|
||||
sb.WriteString("Plateforme: " + req.Platform + "\n")
|
||||
}
|
||||
if req.Context != "" {
|
||||
sb.WriteString("\nContexte du terminal:\n" + req.Context + "\n")
|
||||
}
|
||||
if len(req.History) > 0 {
|
||||
sb.WriteString("\nDernières commandes exécutées:\n")
|
||||
for _, h := range req.History {
|
||||
sb.WriteString(" " + h + "\n")
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("OS: %s/%s\n", runtime.GOOS, runtime.GOARCH))
|
||||
if hostname, err := os.Hostname(); err == nil {
|
||||
sb.WriteString("Hostname: " + hostname + "\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.Orchestrator, req ShellChatRequest) {
|
||||
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)
|
||||
func (s *Server) handleShellChatStreamV2(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
|
||||
SetupSSEHeaders(w)
|
||||
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"))
|
||||
// Rebuild history into orchestrator
|
||||
history := s.shellConvStore.Get()
|
||||
for _, m := range history[:len(history)-1] { // all except last user msg
|
||||
if m.Role == "system" {
|
||||
continue
|
||||
}
|
||||
// Pre-load orchestrator history
|
||||
orb.AppendHistory(orchestrator.Message{Role: m.Role, Content: m.Content})
|
||||
}
|
||||
|
||||
lastUserMsg := history[len(history)-1].Content
|
||||
|
||||
var finalContent string
|
||||
result, err := orb.SendStream(lastUserMsg, func(chunk string) {
|
||||
finalContent = chunk
|
||||
sseWriter.Write(map[string]interface{}{"content": chunk})
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
messages := []orchestrator.Message{
|
||||
{Role: "user", Content: req.Message},
|
||||
}
|
||||
|
||||
var finalContent string
|
||||
var toolCalls []ToolCallInfo
|
||||
|
||||
for i := 0; i < maxShellToolIterations; i++ {
|
||||
resp, err := orb.SendWithTools(messages)
|
||||
if err != nil {
|
||||
writeSSE(map[string]interface{}{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
choice := resp.Choices[0]
|
||||
content := cleanThinkingTags(choice.Message.Content)
|
||||
|
||||
if content != "" {
|
||||
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,
|
||||
}
|
||||
writeSSE(map[string]interface{}{"tool_call": toolCallData})
|
||||
|
||||
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()
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
finalContent = ""
|
||||
}
|
||||
|
||||
if finalContent == "" && len(toolCalls) > 0 {
|
||||
finalContent = "(opérations terminées)"
|
||||
}
|
||||
|
||||
writeJSONResp, _ := json.Marshal(ShellChatResponse{
|
||||
Content: finalContent,
|
||||
ToolCalls: toolCalls,
|
||||
})
|
||||
writeSSE(map[string]interface{}{"done": true, "response": string(writeJSONResp)})
|
||||
|
||||
if err != nil {
|
||||
sseWriter.Write(map[string]interface{}{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
content := result
|
||||
if content == "" {
|
||||
content = finalContent
|
||||
}
|
||||
|
||||
s.shellConvStore.Add("assistant", cleanThinkingTags(content))
|
||||
|
||||
sseWriter.Write(map[string]interface{}{
|
||||
"done": "true",
|
||||
"tokens": s.shellConvStore.ApproxTokens(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrator.Orchestrator, req ShellChatRequest) {
|
||||
ctx := context.Background()
|
||||
messages := []orchestrator.Message{
|
||||
{Role: "user", Content: req.Message},
|
||||
func (s *Server) handleShellChatNonStreamV2(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
|
||||
history := s.shellConvStore.Get()
|
||||
for _, m := range history[:len(history)-1] {
|
||||
if m.Role == "system" {
|
||||
continue
|
||||
}
|
||||
orb.AppendHistory(orchestrator.Message{Role: m.Role, Content: m.Content})
|
||||
}
|
||||
|
||||
var finalContent string
|
||||
var toolCalls []ToolCallInfo
|
||||
lastUserMsg := history[len(history)-1].Content
|
||||
|
||||
for i := 0; i < maxShellToolIterations; i++ {
|
||||
resp, err := orb.SendWithTools(messages)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
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 {
|
||||
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 = ""
|
||||
result, err := orb.Send(lastUserMsg)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if finalContent == "" && len(toolCalls) > 0 {
|
||||
finalContent = "(tool calls completed, no text response)"
|
||||
}
|
||||
|
||||
writeJSON(w, ShellChatResponse{
|
||||
Content: finalContent,
|
||||
ToolCalls: toolCalls,
|
||||
s.shellConvStore.Add("assistant", cleanThinkingTags(result))
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"content": result,
|
||||
"tokens": s.shellConvStore.ApproxTokens(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleShellChatHistory(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
messages := s.shellConvStore.Get()
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"messages": messages,
|
||||
"tokens": s.shellConvStore.ApproxTokens(),
|
||||
"max_tokens": shellMaxTokens,
|
||||
"at_limit": s.shellConvStore.AtLimit(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleShellChatClear(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
s.shellConvStore.Clear()
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"status": "ok",
|
||||
"tokens": 0,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleShellAnalyze(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var sysInfo strings.Builder
|
||||
sysInfo.WriteString("=== INFORMATIONS SYSTÈME ===\n")
|
||||
sysInfo.WriteString(fmt.Sprintf("OS: %s/%s\n", runtime.GOOS, runtime.GOARCH))
|
||||
if hostname, err := os.Hostname(); err == nil {
|
||||
sysInfo.WriteString("Hostname: " + hostname + "\n")
|
||||
}
|
||||
if user := os.Getenv("USER"); user != "" {
|
||||
sysInfo.WriteString("User: " + user + "\n")
|
||||
}
|
||||
|
||||
if data, err := os.ReadFile("/proc/cpuinfo"); err == nil {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.HasPrefix(line, "model name") {
|
||||
sysInfo.WriteString("CPU: " + strings.SplitN(line, ":", 2)[1] + "\n")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.HasPrefix(line, "MemTotal:") || strings.HasPrefix(line, "MemAvailable:") {
|
||||
sysInfo.WriteString(strings.TrimSpace(line) + "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if out, err := exec.Command("df", "-h", "/").Output(); err == nil {
|
||||
lines := strings.Split(string(out), "\n")
|
||||
if len(lines) >= 2 {
|
||||
sysInfo.WriteString("Disk: " + strings.TrimSpace(lines[1]) + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
if out, err := exec.Command("ps", "aux", "--sort=-pcpu").Output(); err == nil {
|
||||
lines := strings.Split(string(out), "\n")
|
||||
sysInfo.WriteString(fmt.Sprintf("\nProcessus actifs (%d total):\n", len(lines)-1))
|
||||
for i := 1; i < len(lines) && i <= 10; i++ {
|
||||
fields := strings.Fields(lines[i])
|
||||
if len(fields) >= 11 {
|
||||
sysInfo.WriteString(fmt.Sprintf(" %-20s CPU:%-6s MEM:%-6s %s\n", fields[10], fields[2]+"%", fields[3]+"%", fields[0]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if s.scanResult != nil {
|
||||
sysInfo.WriteString("\nOutils installés:\n")
|
||||
for _, t := range s.scanResult.Tools {
|
||||
status := "✗"
|
||||
if t.Installed {
|
||||
status = "✓"
|
||||
}
|
||||
sysInfo.WriteString(fmt.Sprintf(" %s %s %s\n", status, t.Name, t.Version))
|
||||
}
|
||||
}
|
||||
|
||||
orb, err := orchestrator.New(s.config)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
orb.SetSystemPrompt(agent.StudioSystemPrompt())
|
||||
|
||||
analysisPrompt := `Tu es un expert en administration système. Analyse les informations suivantes sur le système de l'utilisateur.
|
||||
Génère un rapport d'analyse concis et structuré en markdown qui inclut:
|
||||
1. Un résumé de l'état du système
|
||||
2. Les points d'attention (performance, sécurité, configuration)
|
||||
3. Des recommandations spécifiques d'optimisation
|
||||
4. Les outils manquants qui pourraient être utiles
|
||||
5. L'état du réseau et des connexions
|
||||
|
||||
Sois concret et technique. Le rapport sera utilisé comme contexte pour un assistant terminal.
|
||||
|
||||
` + sysInfo.String()
|
||||
|
||||
result, err := orb.Send(analysisPrompt)
|
||||
if err != nil {
|
||||
writeError(w, "analysis failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SaveSystemAnalysis(result)
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"status": "ok",
|
||||
"analysis": result,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleShellAnalysisGet(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
analysis := LoadSystemAnalysis()
|
||||
if analysis == "" {
|
||||
writeJSON(w, map[string]interface{}{"analysis": nil})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"analysis": analysis})
|
||||
}
|
||||
|
||||
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 (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -16,6 +17,7 @@ type Server struct {
|
||||
scanResult *scanner.ScanResult
|
||||
mux *http.ServeMux
|
||||
convStore *ConversationStore
|
||||
shellConvStore *ShellConvStore
|
||||
agentRegistry *agent.Registry
|
||||
agentToolsJSON json.RawMessage
|
||||
workflowEngine *workflow.Engine
|
||||
@@ -23,11 +25,29 @@ type Server struct {
|
||||
|
||||
func NewServer(cfg *config.MuyueConfig) *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.convStore = NewConversationStore()
|
||||
s.shellConvStore = NewShellConvStore()
|
||||
s.agentRegistry = agent.DefaultRegistry()
|
||||
tools := s.agentRegistry.OpenAITools()
|
||||
toolsJSON, _ := json.Marshal(tools)
|
||||
@@ -67,9 +87,14 @@ func (s *Server) routes() {
|
||||
s.mux.HandleFunc("/api/chat", s.handleChat)
|
||||
s.mux.HandleFunc("/api/chat/history", s.handleChatHistory)
|
||||
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/tools/list", s.handleToolList)
|
||||
s.mux.HandleFunc("/api/shell/chat", s.handleShellChat)
|
||||
s.mux.HandleFunc("/api/shell/chat/history", s.handleShellChatHistory)
|
||||
s.mux.HandleFunc("/api/shell/chat/clear", s.handleShellChatClear)
|
||||
s.mux.HandleFunc("/api/shell/analyze", s.handleShellAnalyze)
|
||||
s.mux.HandleFunc("/api/shell/analysis", s.handleShellAnalysisGet)
|
||||
s.mux.HandleFunc("/api/workflow", s.handleWorkflowCreate)
|
||||
s.mux.HandleFunc("/api/workflow/list", s.handleWorkflowList)
|
||||
s.mux.HandleFunc("/api/workflow/", s.handleWorkflowGet)
|
||||
@@ -95,6 +120,10 @@ func (s *Server) routes() {
|
||||
s.mux.HandleFunc("/api/skills/export", s.handleSkillExport)
|
||||
s.mux.HandleFunc("/api/skills/import", s.handleSkillImport)
|
||||
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) {
|
||||
|
||||
121
internal/api/shell_conversation.go
Normal file
121
internal/api/shell_conversation.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
)
|
||||
|
||||
const shellMaxTokens = 100000
|
||||
const shellCharsPerToken = 4
|
||||
|
||||
type ShellMessage struct {
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Time string `json:"time"`
|
||||
}
|
||||
|
||||
type ShellConvStore struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
msgs []ShellMessage
|
||||
}
|
||||
|
||||
func NewShellConvStore() *ShellConvStore {
|
||||
dir, err := config.ConfigDir()
|
||||
if err != nil {
|
||||
dir = "/tmp/muyue"
|
||||
}
|
||||
path := filepath.Join(dir, "shell_conversation.json")
|
||||
s := &ShellConvStore{path: path}
|
||||
s.load()
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *ShellConvStore) load() {
|
||||
data, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
s.msgs = []ShellMessage{}
|
||||
return
|
||||
}
|
||||
json.Unmarshal(data, &s.msgs)
|
||||
if s.msgs == nil {
|
||||
s.msgs = []ShellMessage{}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ShellConvStore) save() {
|
||||
data, _ := json.MarshalIndent(s.msgs, "", " ")
|
||||
os.MkdirAll(filepath.Dir(s.path), 0755)
|
||||
os.WriteFile(s.path, data, 0600)
|
||||
}
|
||||
|
||||
func (s *ShellConvStore) Get() []ShellMessage {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]ShellMessage, len(s.msgs))
|
||||
copy(out, s.msgs)
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *ShellConvStore) Add(role, content string) ShellMessage {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
msg := ShellMessage{
|
||||
ID: time.Now().Format("20060102150405.000"),
|
||||
Role: role,
|
||||
Content: content,
|
||||
Time: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
s.msgs = append(s.msgs, msg)
|
||||
s.save()
|
||||
return msg
|
||||
}
|
||||
|
||||
func (s *ShellConvStore) Clear() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.msgs = []ShellMessage{}
|
||||
s.save()
|
||||
}
|
||||
|
||||
func (s *ShellConvStore) ApproxTokens() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
total := 0
|
||||
for _, m := range s.msgs {
|
||||
total += utf8.RuneCountInString(m.Content) / shellCharsPerToken
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func (s *ShellConvStore) AtLimit() bool {
|
||||
return s.ApproxTokens() >= shellMaxTokens
|
||||
}
|
||||
|
||||
func LoadSystemAnalysis() string {
|
||||
dir, err := config.ConfigDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, "system_analysis.md"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func SaveSystemAnalysis(content string) error {
|
||||
dir, err := config.ConfigDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
os.MkdirAll(dir, 0755)
|
||||
return os.WriteFile(filepath.Join(dir, "system_analysis.md"), []byte(content), 0644)
|
||||
}
|
||||
@@ -146,13 +146,6 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
log.Printf("terminal: pty started successfully")
|
||||
defer func() {
|
||||
ptmx.Close()
|
||||
if cmd.Process != nil {
|
||||
cmd.Process.Kill()
|
||||
cmd.Wait()
|
||||
}
|
||||
}()
|
||||
|
||||
var once sync.Once
|
||||
cleanup := func() {
|
||||
@@ -164,6 +157,7 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
})
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
@@ -171,8 +165,6 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := ptmx.Read(buf)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
conn.WriteMessage(websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
||||
return
|
||||
}
|
||||
if err := conn.WriteJSON(wsMessage{
|
||||
@@ -230,12 +222,11 @@ func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
KeyPath string `json:"key_path"`
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
KeyPath string `json:"key_path"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
|
||||
@@ -12,66 +12,66 @@ import (
|
||||
)
|
||||
|
||||
type Profile struct {
|
||||
Name string `yaml:"name"`
|
||||
Pseudo string `yaml:"pseudo"`
|
||||
Email string `yaml:"email"`
|
||||
Languages []string `yaml:"languages"`
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Pseudo string `yaml:"pseudo" json:"pseudo"`
|
||||
Email string `yaml:"email" json:"email"`
|
||||
Languages []string `yaml:"languages" json:"languages"`
|
||||
Preferences struct {
|
||||
Editor string `yaml:"editor"`
|
||||
Shell string `yaml:"shell"`
|
||||
Theme string `yaml:"theme"`
|
||||
DefaultAI string `yaml:"default_ai"`
|
||||
AutoUpdate bool `yaml:"auto_update"`
|
||||
CheckOnStart bool `yaml:"check_on_start"`
|
||||
Language string `yaml:"language"`
|
||||
KeyboardLayout string `yaml:"keyboard_layout"`
|
||||
} `yaml:"preferences"`
|
||||
Editor string `yaml:"editor" json:"editor"`
|
||||
Shell string `yaml:"shell" json:"shell"`
|
||||
Theme string `yaml:"theme" json:"theme"`
|
||||
DefaultAI string `yaml:"default_ai" json:"default_ai"`
|
||||
AutoUpdate bool `yaml:"auto_update" json:"auto_update"`
|
||||
CheckOnStart bool `yaml:"check_on_start" json:"check_on_start"`
|
||||
Language string `yaml:"language" json:"language"`
|
||||
KeyboardLayout string `yaml:"keyboard_layout" json:"keyboard_layout"`
|
||||
} `yaml:"preferences" json:"preferences"`
|
||||
}
|
||||
|
||||
type AIProvider struct {
|
||||
Name string `yaml:"name"`
|
||||
APIKey string `yaml:"api_key,omitempty"`
|
||||
BaseURL string `yaml:"base_url,omitempty"`
|
||||
Model string `yaml:"model"`
|
||||
Active bool `yaml:"active"`
|
||||
Name string `yaml:"name" json:"name"`
|
||||
APIKey string `yaml:"api_key,omitempty" json:"api_key,omitempty"`
|
||||
BaseURL string `yaml:"base_url,omitempty" json:"base_url,omitempty"`
|
||||
Model string `yaml:"model" json:"model"`
|
||||
Active bool `yaml:"active" json:"active"`
|
||||
}
|
||||
|
||||
type ToolConfig struct {
|
||||
Name string `yaml:"name"`
|
||||
Installed bool `yaml:"installed"`
|
||||
Version string `yaml:"version"`
|
||||
AutoUpdate bool `yaml:"auto_update"`
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Installed bool `yaml:"installed" json:"installed"`
|
||||
Version string `yaml:"version" json:"version"`
|
||||
AutoUpdate bool `yaml:"auto_update" json:"auto_update"`
|
||||
}
|
||||
|
||||
type SSHConnection struct {
|
||||
Name string `yaml:"name"`
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
User string `yaml:"user"`
|
||||
Password string `yaml:"password,omitempty"`
|
||||
KeyPath string `yaml:"key_path,omitempty"`
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Host string `yaml:"host" json:"host"`
|
||||
Port int `yaml:"port" json:"port"`
|
||||
User string `yaml:"user" json:"user"`
|
||||
Password string `yaml:"password,omitempty" json:"password,omitempty"`
|
||||
KeyPath string `yaml:"key_path,omitempty" json:"key_path,omitempty"`
|
||||
}
|
||||
|
||||
type MuyueConfig struct {
|
||||
Version string `yaml:"version"`
|
||||
Profile Profile `yaml:"profile"`
|
||||
Version string `yaml:"version" json:"version"`
|
||||
Profile Profile `yaml:"profile" json:"profile"`
|
||||
AI struct {
|
||||
Providers []AIProvider `yaml:"providers"`
|
||||
} `yaml:"ai"`
|
||||
Tools []ToolConfig `yaml:"tools"`
|
||||
Providers []AIProvider `yaml:"providers" json:"providers"`
|
||||
} `yaml:"ai" json:"ai"`
|
||||
Tools []ToolConfig `yaml:"tools" json:"tools"`
|
||||
BMAD struct {
|
||||
Installed bool `yaml:"installed"`
|
||||
Version string `yaml:"version"`
|
||||
Global bool `yaml:"global"`
|
||||
} `yaml:"bmad"`
|
||||
Installed bool `yaml:"installed" json:"installed"`
|
||||
Version string `yaml:"version" json:"version"`
|
||||
Global bool `yaml:"global" json:"global"`
|
||||
} `yaml:"bmad" json:"bmad"`
|
||||
Terminal struct {
|
||||
CustomPrompt bool `yaml:"custom_prompt"`
|
||||
PromptTheme string `yaml:"prompt_theme"`
|
||||
SSH []SSHConnection `yaml:"ssh"`
|
||||
FontSize int `yaml:"font_size"`
|
||||
FontFamily string `yaml:"font_family"`
|
||||
Theme string `yaml:"theme"`
|
||||
} `yaml:"terminal"`
|
||||
CustomPrompt bool `yaml:"custom_prompt" json:"custom_prompt"`
|
||||
PromptTheme string `yaml:"prompt_theme" json:"prompt_theme"`
|
||||
SSH []SSHConnection `yaml:"ssh" json:"ssh"`
|
||||
FontSize int `yaml:"font_size" json:"font_size"`
|
||||
FontFamily string `yaml:"font_family" json:"font_family"`
|
||||
Theme string `yaml:"theme" json:"theme"`
|
||||
} `yaml:"terminal" json:"terminal"`
|
||||
}
|
||||
|
||||
type TerminalTheme struct {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -76,6 +77,11 @@ var sharedHTTPClient = &http.Client{
|
||||
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) {
|
||||
var provider *config.AIProvider
|
||||
for i := range cfg.AI.Providers {
|
||||
@@ -300,6 +306,142 @@ func (o *Orchestrator) SendWithTools(messages []Message) (*ChatResponse, error)
|
||||
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 {
|
||||
content = thinkRegex.ReplaceAllString(content, "")
|
||||
lines := strings.Split(content, "\n")
|
||||
@@ -368,7 +510,9 @@ func (o *Orchestrator) sendWithFallback(reqBody ChatRequest, baseURLOverride str
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
var triedProviders []string
|
||||
for _, prov := range providerOrder {
|
||||
triedProviders = append(triedProviders, prov.Name)
|
||||
baseURL := baseURLOverride
|
||||
if 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("Authorization", "Bearer "+prov.APIKey)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
resp, err := o.client.Do(req)
|
||||
if err != nil {
|
||||
@@ -427,5 +578,6 @@ func (o *Orchestrator) sendWithFallback(reqBody ChatRequest, baseURLOverride str
|
||||
return &chatResp, prov.Name, nil
|
||||
}
|
||||
|
||||
log.Printf("[orchestrator] fallback from %v to next provider", triedProviders)
|
||||
return nil, "", lastErr
|
||||
}
|
||||
|
||||
@@ -24,12 +24,12 @@ const (
|
||||
)
|
||||
|
||||
type SystemInfo struct {
|
||||
OS OS
|
||||
Arch Arch
|
||||
IsWSL bool
|
||||
Shell string
|
||||
Terminal string
|
||||
PackageManager string
|
||||
OS OS `json:"os"`
|
||||
Arch Arch `json:"arch"`
|
||||
IsWSL bool `json:"is_wsl"`
|
||||
Shell string `json:"shell"`
|
||||
Terminal string `json:"terminal"`
|
||||
PackageManager string `json:"package_manager"`
|
||||
}
|
||||
|
||||
func Detect() SystemInfo {
|
||||
|
||||
@@ -14,27 +14,27 @@ import (
|
||||
)
|
||||
|
||||
type ToolStatus struct {
|
||||
Name string `yaml:"name"`
|
||||
Installed bool `yaml:"installed"`
|
||||
Version string `yaml:"version"`
|
||||
Path string `yaml:"path"`
|
||||
Latest string `yaml:"latest"`
|
||||
NeedsUpdate bool `yaml:"needs_update"`
|
||||
Category string `yaml:"category"`
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Installed bool `yaml:"installed" json:"installed"`
|
||||
Version string `yaml:"version" json:"version"`
|
||||
Path string `yaml:"path" json:"path"`
|
||||
Latest string `yaml:"latest" json:"latest"`
|
||||
NeedsUpdate bool `yaml:"needs_update" json:"needs_update"`
|
||||
Category string `yaml:"category" json:"category"`
|
||||
}
|
||||
|
||||
type RuntimeStatus struct {
|
||||
Name string `yaml:"name"`
|
||||
Installed bool `yaml:"installed"`
|
||||
Version string `yaml:"version"`
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Installed bool `yaml:"installed" json:"installed"`
|
||||
Version string `yaml:"version" json:"version"`
|
||||
}
|
||||
|
||||
type ScanResult struct {
|
||||
System platform.SystemInfo `yaml:"system"`
|
||||
Tools []ToolStatus `yaml:"tools"`
|
||||
Runtimes []RuntimeStatus `yaml:"runtimes"`
|
||||
ShellSetup bool `yaml:"shell_setup"`
|
||||
GitConfigured bool `yaml:"git_configured"`
|
||||
System platform.SystemInfo `yaml:"system" json:"system"`
|
||||
Tools []ToolStatus `yaml:"tools" json:"tools"`
|
||||
Runtimes []RuntimeStatus `yaml:"runtimes" json:"runtimes"`
|
||||
ShellSetup bool `yaml:"shell_setup" json:"shell_setup"`
|
||||
GitConfigured bool `yaml:"git_configured" json:"git_configured"`
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
@@ -1,11 +1,33 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
const (
|
||||
Name = "muyue"
|
||||
Version = "0.3.2"
|
||||
Version = "0.3.5"
|
||||
Author = "La Légion de Muyue"
|
||||
)
|
||||
|
||||
var (
|
||||
// BuildDate is set at build time
|
||||
BuildDate = ""
|
||||
)
|
||||
|
||||
func FullVersion() string {
|
||||
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 }) }),
|
||||
importSkill: (path) => request('/skills/import', { method: 'POST', body: JSON.stringify({ import_path: path }) }),
|
||||
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) }),
|
||||
saveProfile: (profile) => request('/config/profile', { method: 'PUT', body: JSON.stringify(profile) }),
|
||||
saveProvider: (provider) => request('/config/provider', { method: 'PUT', body: JSON.stringify(provider) }),
|
||||
@@ -52,6 +56,11 @@ const api = {
|
||||
saveTerminalSettings: (settings) => request('/terminal/settings', { method: 'PUT', body: JSON.stringify(settings) }),
|
||||
getChatHistory: () => request('/chat/history'),
|
||||
clearChat: () => request('/chat/clear', { method: 'POST' }),
|
||||
summarizeChat: () => request('/chat/summarize', { method: 'POST' }),
|
||||
getShellChatHistory: () => request('/shell/chat/history'),
|
||||
clearShellChat: () => request('/shell/chat/clear', { method: 'POST' }),
|
||||
analyzeSystem: () => request('/shell/analyze', { method: 'POST' }),
|
||||
getShellAnalysis: () => request('/shell/analysis'),
|
||||
sendChat: (message, stream = true, onChunk, signal) => {
|
||||
if (!stream) {
|
||||
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) })
|
||||
@@ -96,11 +105,9 @@ const api = {
|
||||
}).catch(reject)
|
||||
})
|
||||
},
|
||||
sendShellChat: (message, context = {}, stream = true, onChunk) => {
|
||||
sendShellChat: (message, context = {}, stream = true, onChunk, signal) => {
|
||||
const payload = {
|
||||
message,
|
||||
context: context.context || '',
|
||||
history: context.history || [],
|
||||
cwd: context.cwd || '',
|
||||
platform: context.platform || '',
|
||||
stream,
|
||||
@@ -113,6 +120,7 @@ const api = {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||
@@ -122,7 +130,6 @@ const api = {
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let full = ''
|
||||
let toolCalls = []
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
@@ -132,27 +139,15 @@ const api = {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6))
|
||||
if (data.error) { reject(new Error(data.error)); return }
|
||||
if (data.done) {
|
||||
resolve({ content: full, tool_calls: toolCalls })
|
||||
return
|
||||
}
|
||||
if (data.done) { resolve({ content: full, tokens: data.tokens }); return }
|
||||
if (data.content) {
|
||||
full += data.content
|
||||
full = data.content
|
||||
if (onChunk) onChunk(full, data)
|
||||
} else if (data.tool_call) {
|
||||
toolCalls.push(data.tool_call)
|
||||
if (onChunk) onChunk(full, data, toolCalls)
|
||||
} else if (data.tool_result) {
|
||||
const idx = toolCalls.findIndex(tc => tc.tool_call_id === data.tool_result.id)
|
||||
if (idx >= 0) {
|
||||
toolCalls[idx].result = data.tool_result
|
||||
}
|
||||
if (onChunk) onChunk(full, data, toolCalls)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
resolve({ content: full, tool_calls: toolCalls })
|
||||
resolve({ content: full })
|
||||
}).catch(reject)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -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 api from '../api/client'
|
||||
import { getTheme, applyTheme } from '../themes'
|
||||
@@ -13,6 +13,9 @@ export default function App() {
|
||||
const [activeTab, setActiveTab] = useState('dash')
|
||||
const [info, setInfo] = useState({})
|
||||
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 [tools, setTools] = useState([])
|
||||
const [config, setConfig] = useState(null)
|
||||
@@ -27,7 +30,7 @@ export default function App() {
|
||||
], [t])
|
||||
|
||||
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.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
|
||||
api.getConfig().then(d => {
|
||||
@@ -60,6 +63,11 @@ export default function App() {
|
||||
if (map[e.code]) {
|
||||
e.preventDefault()
|
||||
setActiveTab(map[e.code])
|
||||
return
|
||||
}
|
||||
if (e.ctrlKey && e.code === 'KeyR') {
|
||||
e.preventDefault()
|
||||
if (dashRefreshRef.current) dashRefreshRef.current()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
@@ -68,38 +76,28 @@ export default function App() {
|
||||
|
||||
const switchTab = useCallback((tabId) => setActiveTab(tabId), [])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setActiveTab('shell')
|
||||
window.addEventListener('navigate-to-shell', handler)
|
||||
return () => window.removeEventListener('navigate-to-shell', handler)
|
||||
}, [])
|
||||
|
||||
const hasUpdates = updates.some(u => u.needsUpdate)
|
||||
const installed = tools.filter(tool => tool.installed).length
|
||||
|
||||
const WINDOW_SHORTCUTS = useMemo(() => ({
|
||||
dash: [
|
||||
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
|
||||
],
|
||||
dash: [],
|
||||
studio: [
|
||||
{ keys: layout.keys.enter, desc: t('statusbar.sendMessage') },
|
||||
{ keys: `${layout.keys.shift}+${layout.keys.enter}`, desc: t('statusbar.newLine') },
|
||||
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
|
||||
],
|
||||
shell: [
|
||||
{ keys: layout.keys.enter, desc: t('statusbar.runCommand') },
|
||||
{ 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])
|
||||
|
||||
const renderContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'dash': return <Dashboard api={api} />
|
||||
case 'studio': return <Studio api={api} />
|
||||
case 'shell': return <Shell api={api} />
|
||||
case 'config': return <Config api={api} />
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-layout">
|
||||
<header className="header">
|
||||
@@ -141,12 +139,21 @@ export default function App() {
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<main className="content fade-in" key={`${activeTab}-${TABS.length}`}>
|
||||
{renderContent()}
|
||||
<main className="content">
|
||||
<div className={activeTab === 'dash' ? '' : 'tab-hidden'}><Dashboard api={api} refreshRef={dashRefreshRef} /></div>
|
||||
<div className={activeTab === 'studio' ? '' : 'tab-hidden'}><Studio api={api} /></div>
|
||||
<div className={activeTab === 'shell' ? '' : 'tab-hidden'}><Shell api={api} /></div>
|
||||
<div className={activeTab === 'config' ? '' : 'tab-hidden'}><Config api={api} /></div>
|
||||
</main>
|
||||
|
||||
<footer className="statusbar">
|
||||
<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] || []} />
|
||||
</div>
|
||||
<div className="statusbar-right">
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { User, Brain, RefreshCw, Globe, Wrench, Monitor } from 'lucide-react'
|
||||
import { useI18n, LANGUAGES } from '../i18n'
|
||||
import { getLayoutList } from '../i18n/keyboards'
|
||||
import { User, Brain, RefreshCw, Wrench, Monitor, AlertTriangle } from 'lucide-react'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
const PANELS = [
|
||||
{ id: 'profile', icon: User },
|
||||
{ id: 'providers', icon: Brain },
|
||||
{ id: 'updates', icon: RefreshCw },
|
||||
{ id: 'locale', icon: Globe },
|
||||
{ id: 'skills', icon: Wrench },
|
||||
{ id: 'system', icon: Monitor },
|
||||
]
|
||||
@@ -29,19 +27,10 @@ export default function Config({ api }) {
|
||||
const [toast, setToast] = useState(null)
|
||||
|
||||
|
||||
const layouts = getLayoutList()
|
||||
|
||||
const loadData = useCallback(() => {
|
||||
api.getConfig().then(d => {
|
||||
setConfig(d)
|
||||
setProfileForm({
|
||||
name: d.profile?.name || '',
|
||||
pseudo: d.profile?.pseudo || '',
|
||||
email: d.profile?.email || '',
|
||||
editor: d.profile?.preferences?.editor || '',
|
||||
shell: d.profile?.preferences?.shell || '',
|
||||
})
|
||||
|
||||
setProfileForm(d.profile ? JSON.parse(JSON.stringify(d.profile)) : {})
|
||||
}).catch(() => {})
|
||||
api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {})
|
||||
api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {})
|
||||
@@ -72,28 +61,15 @@ export default function Config({ api }) {
|
||||
setChecking(false)
|
||||
}
|
||||
|
||||
const handleUpdateTool = async (tool) => {
|
||||
setUpdating(tool)
|
||||
try {
|
||||
await api.runUpdate(tool)
|
||||
await handleCheckUpdates()
|
||||
showToast(`${tool} ✓`)
|
||||
} catch (err) {
|
||||
showToast(`${t('config.error')}: ${err.message}`)
|
||||
}
|
||||
setUpdating(null)
|
||||
const handleUpdateTool = (tool) => {
|
||||
window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
|
||||
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Met à jour l'outil ${tool} sur mon système. Exécute les commandes nécessaires.` } }))
|
||||
}
|
||||
|
||||
const handleUpdateAll = async () => {
|
||||
setUpdating('__all__')
|
||||
try {
|
||||
await api.runUpdate('')
|
||||
await handleCheckUpdates()
|
||||
showToast(t('config.saved'))
|
||||
} catch (err) {
|
||||
showToast(`${t('config.error')}: ${err.message}`)
|
||||
}
|
||||
setUpdating(null)
|
||||
const handleUpdateAll = () => {
|
||||
const toUpdate = updates.filter(u => u.needsUpdate).map(u => u.tool)
|
||||
window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
|
||||
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Met à jour tous les outils suivants sur mon système : ${toUpdate.join(', ')}. Exécute les commandes nécessaires une par une.` } }))
|
||||
}
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
@@ -188,13 +164,6 @@ export default function Config({ api }) {
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{activePanel === 'locale' && (
|
||||
<PanelLocale
|
||||
language={keyboard} layouts={layouts}
|
||||
setLanguage={setLanguage} setKeyboard={setKeyboard}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{activePanel === 'skills' && (
|
||||
<PanelSkills skillList={skillList} t={t} />
|
||||
)}
|
||||
@@ -209,93 +178,188 @@ export default function Config({ api }) {
|
||||
}
|
||||
|
||||
function PanelProfile({ config, editProfile, profileForm, setProfileForm, setEditProfile, handleSaveProfile, t }) {
|
||||
const updateField = (path, value) => {
|
||||
setProfileForm(prev => {
|
||||
const next = JSON.parse(JSON.stringify(prev))
|
||||
const keys = path.split('.')
|
||||
let target = next
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
if (target[keys[i]] == null) target[keys[i]] = {}
|
||||
target = target[keys[i]]
|
||||
}
|
||||
target[keys[keys.length - 1]] = value
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const profile = editProfile ? profileForm : config?.profile
|
||||
|
||||
if (!profile) {
|
||||
return (
|
||||
<div className="config-profile-center">
|
||||
<div className="config-card">
|
||||
<div className="empty-state">{t('config.loadingProfile')}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const personalKeys = Object.entries(profile).filter(([k, v]) => k !== 'preferences' && typeof v !== 'object')
|
||||
const personalObj = Object.fromEntries(personalKeys)
|
||||
const preferences = profile.preferences || null
|
||||
|
||||
return (
|
||||
<div className="config-card">
|
||||
{config?.profile && !editProfile ? (
|
||||
<>
|
||||
<div className="config-card-row">
|
||||
<span className="config-card-label">{t('config.name')}</span>
|
||||
<span className="config-card-value">{config.profile.name || '—'}</span>
|
||||
</div>
|
||||
<div className="config-card-row">
|
||||
<span className="config-card-label">{t('config.pseudo')}</span>
|
||||
<span className="config-card-value">{config.profile.pseudo || '—'}</span>
|
||||
</div>
|
||||
<div className="config-card-row">
|
||||
<span className="config-card-label">{t('config.email')}</span>
|
||||
<span className="config-card-value">{config.profile.email || '—'}</span>
|
||||
</div>
|
||||
<div className="config-card-row">
|
||||
<span className="config-card-label">{t('config.editor')}</span>
|
||||
<span className="config-card-value mono">{config.profile.preferences?.editor || '—'}</span>
|
||||
</div>
|
||||
<div className="config-card-row">
|
||||
<span className="config-card-label">{t('config.shell')}</span>
|
||||
<span className="config-card-value mono">{config.profile.preferences?.shell || '—'}</span>
|
||||
</div>
|
||||
<div className="config-card-row">
|
||||
<span className="config-card-label">{t('config.languages')}</span>
|
||||
<span className="config-card-value">{config.profile.languages?.join(', ') || '—'}</span>
|
||||
</div>
|
||||
<div className="config-card-actions">
|
||||
<button className="primary sm" onClick={() => setEditProfile(true)}>{t('config.editProfile')}</button>
|
||||
</div>
|
||||
</>
|
||||
) : editProfile ? (
|
||||
<>
|
||||
<FormInput label={t('config.name')} value={profileForm.name} onChange={v => setProfileForm(p => ({ ...p, name: v }))} />
|
||||
<FormInput label={t('config.pseudo')} value={profileForm.pseudo} onChange={v => setProfileForm(p => ({ ...p, pseudo: v }))} />
|
||||
<FormInput label={t('config.email')} value={profileForm.email} onChange={v => setProfileForm(p => ({ ...p, email: v }))} type="email" />
|
||||
<FormInput label={t('config.editor')} value={profileForm.editor} onChange={v => setProfileForm(p => ({ ...p, editor: v }))} />
|
||||
<FormInput label={t('config.shell')} value={profileForm.shell} onChange={v => setProfileForm(p => ({ ...p, shell: v }))} />
|
||||
<div className="config-card-actions">
|
||||
<button className="primary sm" onClick={handleSaveProfile}>{t('config.save')}</button>
|
||||
<button className="ghost sm" onClick={() => setEditProfile(false)}>{t('config.cancel')}</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="empty-state">{t('config.loadingProfile')}</div>
|
||||
)}
|
||||
<div className="config-profile-center">
|
||||
<div className="config-card">
|
||||
<div className="section-title">{t('config.profileInfo') || 'Informations personnelles'}</div>
|
||||
<RenderFields obj={personalObj} path="" editing={editProfile} onChange={updateField} t={t} />
|
||||
</div>
|
||||
<div className="config-card">
|
||||
<div className="section-title">{t('config.profilePrefs') || 'Préférences'}</div>
|
||||
{preferences ? (
|
||||
<RenderFields obj={preferences} path="preferences" editing={editProfile} onChange={updateField} t={t} />
|
||||
) : (
|
||||
<div className="config-card-row"><span className="config-card-value" style={{ color: 'var(--text-disabled)' }}>—</span></div>
|
||||
)}
|
||||
</div>
|
||||
<div className="config-card">
|
||||
<div className="config-card-actions" style={{ justifyContent: 'center' }}>
|
||||
{editProfile ? (
|
||||
<>
|
||||
<button className="primary sm" onClick={handleSaveProfile}>{t('config.save')}</button>
|
||||
<button className="ghost sm" onClick={() => setEditProfile(false)}>{t('config.cancel')}</button>
|
||||
</>
|
||||
) : (
|
||||
<button className="primary sm" onClick={() => {
|
||||
setProfileForm(config.profile ? JSON.parse(JSON.stringify(config.profile)) : {})
|
||||
setEditProfile(true)
|
||||
}}>{t('config.editProfile')}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RenderFields({ obj, path, editing, onChange, t }) {
|
||||
if (!obj || typeof obj !== 'object') return null
|
||||
|
||||
return Object.entries(obj).filter(([, v]) => v === null || typeof v !== 'object').map(([key, value]) => {
|
||||
const fieldPath = path ? `${path}.${key}` : key
|
||||
const label = getFieldLabel(key, t)
|
||||
|
||||
if (editing) {
|
||||
if (typeof value === 'boolean') {
|
||||
return (
|
||||
<div key={key} className="config-card-row">
|
||||
<span className="config-card-label">{label}</span>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={value} onChange={e => onChange(fieldPath, e.target.checked)} />
|
||||
<span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>{value ? 'On' : 'Off'}</span>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return (
|
||||
<div key={key} className="config-form-field">
|
||||
<label className="config-form-label">{label}</label>
|
||||
<input className="config-form-input" value={value.join(', ')} onChange={e => onChange(fieldPath, e.target.value.split(',').map(s => s.trim()).filter(Boolean))} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div key={key} className="config-form-field">
|
||||
<label className="config-form-label">{label}</label>
|
||||
<input className="config-form-input" type={typeof value === 'number' ? 'number' : 'text'} value={value ?? ''} onChange={e => onChange(fieldPath, typeof value === 'number' ? Number(e.target.value) : e.target.value)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return (
|
||||
<div key={key} className="config-card-row">
|
||||
<span className="config-card-label">{label}</span>
|
||||
<span className="config-card-value">{value ? 'On' : 'Off'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return (
|
||||
<div key={key} className="config-card-row">
|
||||
<span className="config-card-label">{label}</span>
|
||||
<span className="config-card-value">{value.length > 0 ? value.join(', ') : '—'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div key={key} className="config-card-row">
|
||||
<span className="config-card-label">{label}</span>
|
||||
<span className="config-card-value">{value != null && value !== '' ? String(value) : '—'}</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function getFieldLabel(key, t) {
|
||||
const translated = t(`config.${key}`)
|
||||
if (translated !== `config.${key}`) return translated
|
||||
return key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
}
|
||||
|
||||
function PanelProviders({ providers, editProvider, providerForm, setProviderForm, setEditProvider, openProviderEdit, handleSaveProvider, api, loadData, t }) {
|
||||
const [validating, setValidating] = useState(null)
|
||||
const [validationStatus, setValidationStatus] = useState(null)
|
||||
const [keyStatus, setKeyStatus] = useState({})
|
||||
|
||||
const handleValidate = async (name, apiKey, model, baseUrl) => {
|
||||
setValidating(name)
|
||||
setValidationStatus(null)
|
||||
const validateKey = async (p) => {
|
||||
setValidating(p.name)
|
||||
try {
|
||||
await api.validateProvider({ name, api_key: apiKey, model, base_url: baseUrl })
|
||||
setValidationStatus({ provider: name, valid: true })
|
||||
await api.validateProvider({ name: p.name, api_key: p.apiKey, model: p.model, base_url: p.baseURL || '' })
|
||||
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: true, checked: true } }))
|
||||
} catch (err) {
|
||||
const msg = err.message || ''
|
||||
if (msg.includes('invalid_api_key')) {
|
||||
setValidationStatus({ provider: name, valid: false, error: t('config.keyInvalid') })
|
||||
} else {
|
||||
setValidationStatus({ provider: name, valid: false, error: `${t('config.connectionFailed')}: ${msg}` })
|
||||
}
|
||||
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: err.message || 'Clé invalide' } }))
|
||||
}
|
||||
setValidating(null)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
providers.forEach(p => {
|
||||
if (p.apiKey && !keyStatus[p.name]) {
|
||||
validateKey(p)
|
||||
} else if (!p.apiKey) {
|
||||
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: 'Aucune clé' } }))
|
||||
}
|
||||
})
|
||||
}, [providers])
|
||||
|
||||
const handleValidate = async (name, apiKey, model, baseUrl) => {
|
||||
setValidating(name)
|
||||
try {
|
||||
await api.validateProvider({ name, api_key: apiKey, model, base_url: baseUrl })
|
||||
setKeyStatus(prev => ({ ...prev, [name]: { valid: true, checked: true } }))
|
||||
} catch (err) {
|
||||
setKeyStatus(prev => ({ ...prev, [name]: { valid: false, checked: true, error: err.message || 'Clé invalide' } }))
|
||||
}
|
||||
setValidating(null)
|
||||
}
|
||||
|
||||
const displayed = providers.filter(p => p.name === 'minimax' || p.name === 'zai')
|
||||
|
||||
return (
|
||||
<div className="config-providers-list">
|
||||
<div className="provider-setup-hint">{t('config.setupDescription')}</div>
|
||||
{providers.map((p, i) => {
|
||||
{displayed.map((p, i) => {
|
||||
const isEditing = editProvider === p.name
|
||||
const isValidationTarget = validationStatus?.provider === p.name
|
||||
const currentModel = providerForm[p.name]?.model || p.model
|
||||
const status = keyStatus[p.name]
|
||||
|
||||
return (
|
||||
<div key={i} className="config-card provider-card-v2">
|
||||
<div className="provider-card-top">
|
||||
<div className="provider-card-identity">
|
||||
<span className="provider-card-name">{p.name}</span>
|
||||
{p.apiKey && <span className="badge ok">{t('config.keyConfigured')}</span>}
|
||||
{!p.apiKey && <span className="badge error">{t('config.noKey')}</span>}
|
||||
{isValidationTarget && validationStatus?.valid && <span className="badge ok">{t('config.keyValid')}</span>}
|
||||
{isValidationTarget && !validationStatus?.valid && <span className="badge error">{validationStatus?.error}</span>}
|
||||
<span className="provider-card-name">{p.name.toUpperCase()}</span>
|
||||
{p.active && <span className="badge ok" style={{ marginLeft: 6 }}>active</span>}
|
||||
{status?.checked && status?.valid && <span className="badge ok">✓ {t('config.keyValid')}</span>}
|
||||
{status?.checked && !status?.valid && <span className="badge error">✗ {status.error || t('config.keyInvalid')}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -306,7 +370,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
||||
<input
|
||||
className="config-form-input"
|
||||
type="password"
|
||||
placeholder={t('config.tokenPlaceholder')}
|
||||
placeholder={p.apiKey ? '••••••••' : t('config.tokenPlaceholder')}
|
||||
value={isEditing ? (providerForm[p.name]?.api_key || '') : ''}
|
||||
onChange={e => {
|
||||
if (!isEditing) openProviderEdit(p)
|
||||
@@ -321,17 +385,18 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
||||
<button
|
||||
className="sm primary"
|
||||
disabled={validating === p.name || !providerForm[p.name]?.api_key}
|
||||
onClick={() => handleValidate(p.name, providerForm[p.name]?.api_key, providerForm[p.name]?.model, providerForm[p.name]?.base_url)}
|
||||
onClick={() => handleValidate(p.name, providerForm[p.name]?.api_key, currentModel, providerForm[p.name]?.base_url)}
|
||||
>
|
||||
{validating === p.name ? t('config.validating') : t('config.validateKey')}
|
||||
</button>
|
||||
{isValidationTarget && validationStatus?.valid && (
|
||||
{isEditing && (
|
||||
<button className="sm" onClick={() => handleSaveProvider(p.name)}>{t('config.save')}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="provider-card-meta" style={{ marginTop: 8 }}>
|
||||
<span className="mono">{p.model || '—'}</span>
|
||||
<div className="provider-card-model">
|
||||
<span className="provider-card-model-label">{t('config.model')}</span>
|
||||
<span className="provider-card-model-value">{p.model || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -341,7 +406,14 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
||||
)
|
||||
}
|
||||
|
||||
function PanelUpdates({ updates, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleUpdateAll, t }) {
|
||||
function PanelUpdates({ updates, tools, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleUpdateAll, t }) {
|
||||
const handleInstallTool = (tool) => {
|
||||
window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
|
||||
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Installe l'outil ${tool} sur mon système. Vérifie d'abord s'il est déjà installé, puis installe-le si nécessaire avec les commandes appropriées.` } }))
|
||||
}
|
||||
|
||||
const missingTools = tools.filter(tool => !tool.installed)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="config-card">
|
||||
@@ -364,6 +436,30 @@ function PanelUpdates({ updates, checking, updating, needsUpdateCount, installed
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{missingTools.length > 0 && (
|
||||
<>
|
||||
<div className="section-title" style={{ marginTop: 12, marginBottom: 4 }}>{t('config.missing') || 'Modules manquants'}</div>
|
||||
<div className="config-update-list">
|
||||
{missingTools.map((tool, i) => (
|
||||
<div key={`miss-${i}`} className="config-update-row">
|
||||
<div className="config-update-info">
|
||||
<span className="config-update-name">{tool.name}</span>
|
||||
<span className="config-update-versions">
|
||||
<span style={{ color: 'var(--danger)' }}>{t('config.notInstalled') || 'Non installé'}</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="sm primary"
|
||||
onClick={() => handleInstallTool(tool.name)}
|
||||
>
|
||||
{t('config.install') || 'Installer'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{updates.length === 0 ? (
|
||||
<div className="config-card">
|
||||
<div className="empty-state">{t('config.noUpdates')}</div>
|
||||
@@ -399,71 +495,90 @@ function PanelUpdates({ updates, checking, updating, needsUpdateCount, installed
|
||||
)
|
||||
}
|
||||
|
||||
function PanelLocale({ language, keyboard, layouts, setLanguage, setKeyboard, t }) {
|
||||
return (
|
||||
<div className="config-card">
|
||||
<div className="config-card-group">
|
||||
<span className="config-card-group-label">{t('config.language')}</span>
|
||||
<div className="chip-row">
|
||||
{LANGUAGES.map(lang => (
|
||||
<div
|
||||
key={lang.id}
|
||||
className={`chip ${language === lang.id ? 'active' : ''}`}
|
||||
onClick={() => setLanguage(lang.id)}
|
||||
>
|
||||
{lang.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="config-card-group">
|
||||
<span className="config-card-group-label">{t('config.keyboardLayout')}</span>
|
||||
<div className="chip-row">
|
||||
{layouts.map(l => (
|
||||
<div
|
||||
key={l.id}
|
||||
className={`chip ${keyboard === l.id ? 'active' : ''}`}
|
||||
onClick={() => setKeyboard(l.id)}
|
||||
>
|
||||
{l.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function PanelSkills({ skillList, t }) {
|
||||
const [selected, setSelected] = useState(null)
|
||||
|
||||
if (skillList.length === 0) {
|
||||
return <div className="empty-state" style={{ color: 'var(--text-disabled)', padding: 40 }}>{t('config.noSkills')}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="config-card">
|
||||
{skillList.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
{t('config.noSkills')}
|
||||
<span style={{ fontFamily: 'var(--font-mono)' }}>{t('config.runSkillsInit')}</span>
|
||||
</div>
|
||||
) : (
|
||||
skillList.map((s, i) => (
|
||||
<div key={i} className="config-skill-row">
|
||||
<span className="config-skill-name">{s.name}</span>
|
||||
<span className="badge neutral">{s.target || 'both'}</span>
|
||||
{s.version && <span className="badge" style={{ fontSize: 10 }}>{s.version}</span>}
|
||||
{s.category && <span className="badge" style={{ fontSize: 10, opacity: 0.7 }}>{s.category}</span>}
|
||||
<span className="config-skill-desc">{s.description}</span>
|
||||
{s.dependencies && s.dependencies.length > 0 && (
|
||||
<div style={{ marginTop: 4, fontSize: 10, color: 'var(--muted)' }}>
|
||||
deps: {s.dependencies.map(d => d.name).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
<>
|
||||
<div className="skill-tiles">
|
||||
{skillList.map((s, i) => (
|
||||
<div key={i} className="skill-tile" onClick={() => setSelected(s)}>
|
||||
<div className="skill-tile-name">{s.name}</div>
|
||||
<div className="skill-tile-desc">{s.description}</div>
|
||||
<div className="skill-tile-tags">
|
||||
{s.target && <span className="badge neutral">{s.target}</span>}
|
||||
{s.version && <span className="badge">{s.version}</span>}
|
||||
{s.category && <span className="badge" style={{ opacity: 0.7 }}>{s.category}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
))}
|
||||
</div>
|
||||
{selected && (
|
||||
<div className="skill-detail-overlay" onClick={() => setSelected(null)}>
|
||||
<div className="skill-detail-panel" onClick={e => e.stopPropagation()}>
|
||||
<div className="skill-detail-header">
|
||||
<span className="skill-detail-name">{selected.name}</span>
|
||||
<button className="ghost sm" onClick={() => setSelected(null)}>✕</button>
|
||||
</div>
|
||||
<div className="skill-detail-body">
|
||||
<div className="skill-detail-section">
|
||||
<div className="skill-detail-label">Description</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{selected.description}</div>
|
||||
</div>
|
||||
<div className="skill-detail-section">
|
||||
<div className="skill-detail-label">Métadonnées</div>
|
||||
<div className="skill-detail-meta">
|
||||
{selected.target && <span className="badge neutral">{selected.target}</span>}
|
||||
{selected.version && <span className="badge">{selected.version}</span>}
|
||||
{selected.category && <span className="badge">{selected.category}</span>}
|
||||
{selected.author && <span className="badge ghost">{selected.author}</span>}
|
||||
{selected.languages && selected.languages.map(l => <span key={l} className="badge ghost">{l}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
{selected.tags && selected.tags.length > 0 && (
|
||||
<div className="skill-detail-section">
|
||||
<div className="skill-detail-label">Tags</div>
|
||||
<div className="chip-row">
|
||||
{selected.tags.map(tag => <span key={tag} className="badge">{tag}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selected.content && (
|
||||
<div className="skill-detail-section">
|
||||
<div className="skill-detail-label">Contenu</div>
|
||||
<div className="skill-detail-content">{selected.content}</div>
|
||||
</div>
|
||||
)}
|
||||
{selected.dependencies && selected.dependencies.length > 0 && (
|
||||
<div className="skill-detail-section">
|
||||
<div className="skill-detail-label">Dépendances</div>
|
||||
<div className="skill-detail-deps">
|
||||
{selected.dependencies.map((d, i) => (
|
||||
<div key={i} className="skill-detail-dep">
|
||||
<span className="badge">{d.type}</span>
|
||||
<span>{d.name}</span>
|
||||
{d.required === false && <span style={{ fontSize: 11, color: 'var(--text-disabled)' }}>optionnel</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PanelSystem({ api, t }) {
|
||||
const [resetConfirm, setResetConfirm] = useState(false)
|
||||
const [showResetModal, setShowResetModal] = useState(false)
|
||||
const [toast, setToast] = useState(null)
|
||||
|
||||
const showToast = (msg) => {
|
||||
@@ -474,7 +589,7 @@ function PanelSystem({ api, t }) {
|
||||
const handleReset = async () => {
|
||||
try {
|
||||
await api.resetConfig()
|
||||
setResetConfirm(false)
|
||||
setShowResetModal(false)
|
||||
showToast(t('config.resetDone'))
|
||||
setTimeout(() => window.location.reload(), 1500)
|
||||
} catch (err) {
|
||||
@@ -482,49 +597,66 @@ function PanelSystem({ api, t }) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleApplyStarship = async () => {
|
||||
try {
|
||||
await api.applyStarshipTheme('charm')
|
||||
showToast(t('config.starshipApplied'))
|
||||
} catch (err) {
|
||||
showToast(`${t('config.error')}: ${err.message}`)
|
||||
}
|
||||
const handleApplyStarship = () => {
|
||||
window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
|
||||
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Vérifie si starship est installé sur le système. S'il ne l'est pas, installe-le (avec curl ou le gestionnaire de paquets). Ensuite, applique la configuration du thème "charm" pour starship. Assure-toi que starship est bien initialisé dans le shell de l'utilisateur.` } }))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{toast && <div className="config-toast">{toast}</div>}
|
||||
|
||||
<div className="section-title" style={{ marginBottom: 8 }}>Configuration Système</div>
|
||||
<div className="config-card">
|
||||
<div className="config-card-row" style={{ marginBottom: 16 }}>
|
||||
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.applyStarship')}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 12 }}>
|
||||
{t('config.starshipApplied')}
|
||||
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 12 }}>
|
||||
Vérifie l'installation de starship et configure le thème charm via l'IA.
|
||||
</div>
|
||||
<button className="sm primary" onClick={handleApplyStarship}>
|
||||
{t('config.applyStarship')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="config-card" style={{ marginTop: 12 }}>
|
||||
|
||||
<div className="section-title" style={{ marginTop: 20, marginBottom: 8, color: 'var(--danger)' }}>
|
||||
<AlertTriangle size={14} style={{ verticalAlign: 'middle', marginRight: 6 }} />
|
||||
Zone Rouge
|
||||
</div>
|
||||
<div className="config-card" style={{ borderColor: 'var(--danger)', borderWidth: 1, borderStyle: 'solid' }}>
|
||||
<div className="config-card-row" style={{ marginBottom: 16 }}>
|
||||
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.resetConfig')}</span>
|
||||
<span className="config-card-label" style={{ fontWeight: 600, color: 'var(--danger)' }}>{t('config.resetConfig')}</span>
|
||||
</div>
|
||||
{resetConfirm ? (
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--warning)', marginBottom: 12 }}>
|
||||
{t('config.resetConfirm')}
|
||||
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 12 }}>
|
||||
Cette action supprimera toute votre configuration et relancera l'application.
|
||||
</div>
|
||||
<button className="sm ghost danger" onClick={() => setShowResetModal(true)}>
|
||||
{t('config.resetConfig')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showResetModal && (
|
||||
<div className="shell-modal-overlay" onClick={() => setShowResetModal(false)}>
|
||||
<div className="shell-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="shell-modal-header" style={{ color: 'var(--danger)' }}>
|
||||
<AlertTriangle size={16} style={{ verticalAlign: 'middle', marginRight: 8 }} />
|
||||
{t('config.resetConfig')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="sm" onClick={() => setResetConfirm(false)}>{t('config.cancel')}</button>
|
||||
<button className="sm danger" onClick={handleReset}>{t('config.resetConfig')}</button>
|
||||
<div className="shell-modal-body">
|
||||
<p style={{ color: 'var(--warning)', fontSize: 13, marginBottom: 12 }}>
|
||||
{t('config.resetConfirm')}
|
||||
</p>
|
||||
<p style={{ color: 'var(--text-tertiary)', fontSize: 12 }}>
|
||||
Cette action est irréversible. Toute votre configuration (profil, clés API, préférences) sera supprimée.
|
||||
</p>
|
||||
</div>
|
||||
<div className="shell-modal-footer">
|
||||
<button className="ghost" onClick={() => setShowResetModal(false)}>{t('config.cancel')}</button>
|
||||
<button className="danger" onClick={handleReset}>{t('config.resetConfig')}</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button className="sm ghost danger" onClick={() => setResetConfirm(true)}>
|
||||
{t('config.resetConfig')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,438 +1,260 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
const TOOL_ICONS = {
|
||||
crush: '⚡',
|
||||
claude: '🤖',
|
||||
go: '🔷',
|
||||
node: '🟢',
|
||||
python: '🐍',
|
||||
docker: '🐳',
|
||||
git: '📚',
|
||||
ssh: '🌐',
|
||||
starship: '🚀',
|
||||
rust: '🦀',
|
||||
}
|
||||
const MAX_POINTS = 30
|
||||
|
||||
function ToolCard({ tool, onInstall, installing }) {
|
||||
const { t } = useI18n()
|
||||
const [showInstall, setShowInstall] = useState(false)
|
||||
|
||||
const icon = TOOL_ICONS[tool.name?.toLowerCase()] || '🔧'
|
||||
const isInstalled = tool.installed || tool.status === 'installed'
|
||||
const version = tool.version || ''
|
||||
const hasUpdate = tool.hasUpdate || tool.updateAvailable
|
||||
const POLL_INTERVAL = 5000
|
||||
const MAX_IDLE_POLLS = 3
|
||||
|
||||
function MiniGraph({ data, max, color, label, unit }) {
|
||||
if (!data || data.length < 2) return <div className="dash-graph-empty">collecting...</div>
|
||||
const m = max || Math.max(...data, 1)
|
||||
const w = 100
|
||||
const h = 32
|
||||
const points = data.map((v, i) => {
|
||||
const x = (i / (data.length - 1)) * w
|
||||
const y = h - (v / m) * h
|
||||
return `${x},${y}`
|
||||
}).join(' ')
|
||||
const last = data[data.length - 1]
|
||||
return (
|
||||
<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 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 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 }) {
|
||||
export default function Dashboard({ api, refreshRef }) {
|
||||
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 [quota, setQuota] = useState(null)
|
||||
const [recentCmds, setRecentCmds] = useState([])
|
||||
const [processes, setProcesses] = useState([])
|
||||
const [metrics, setMetrics] = useState(null)
|
||||
const [copiedSet, setCopiedSet] = useState(new Set())
|
||||
const cpuRef = useRef([])
|
||||
const memRef = useRef([])
|
||||
const netRxRef = useRef([])
|
||||
const netTxRef = useRef([])
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const [toolsData, updatesData, systemData] = await Promise.all([
|
||||
api.getTools().catch(() => ({ tools: [] })),
|
||||
api.getUpdates().catch(() => ({ updates: [] })),
|
||||
api.getSystem().catch(() => null),
|
||||
const [quotaData, cmdData, procData, metricsData] = await Promise.all([
|
||||
api.getProvidersQuota().catch(() => null),
|
||||
api.getRecentCommands().catch(() => ({ commands: [] })),
|
||||
api.getRunningProcesses().catch(() => ({ processes: [] })),
|
||||
api.getSystemMetrics().catch(() => null),
|
||||
])
|
||||
setTools(toolsData.tools || toolsData || [])
|
||||
setUpdates(updatesData.updates || updatesData || [])
|
||||
setSystemInfo(systemData)
|
||||
api.getDashboardStatus().then(d => setDashboardStatus(d)).catch(() => {})
|
||||
setQuota(quotaData?.providers || [])
|
||||
setRecentCmds(cmdData.commands || [])
|
||||
setProcesses(procData.processes || [])
|
||||
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) {
|
||||
console.error('Failed to load dashboard data:', err)
|
||||
console.error('Dashboard load error:', err)
|
||||
}
|
||||
}, [api])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [loadData])
|
||||
|
||||
const addNotification = (message, type = 'info') => {
|
||||
const entry = { id: Date.now(), time: new Date().toISOString(), message, type }
|
||||
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')
|
||||
if (refreshRef) refreshRef.current = loadData
|
||||
let active = true
|
||||
let idleTicks = 0
|
||||
const iv = setInterval(() => {
|
||||
const hidden = document.querySelector('.dash-grid')?.closest('.tab-hidden')
|
||||
if (hidden) {
|
||||
idleTicks++
|
||||
if (idleTicks >= MAX_IDLE_POLLS) return
|
||||
} else {
|
||||
addNotification(t('dashboard.allUpToDate'), 'ok')
|
||||
idleTicks = 0
|
||||
}
|
||||
} catch (err) {
|
||||
addNotification(`${t('dashboard.checkUpdatesFailed')}: ${err.message}`, 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (active) loadData()
|
||||
}, POLL_INTERVAL)
|
||||
return () => { active = false; clearInterval(iv) }
|
||||
}, [loadData, refreshRef])
|
||||
|
||||
const minimax = (quota || []).find(p => p.name === 'minimax')
|
||||
const zai = (quota || []).find(p => p.name === 'zai')
|
||||
|
||||
const EXCLUDE_CMDS = ['ls', 'cd', 'pwd', 'clear', 'exit', 'history', 'cat', 'echo', 'grep', 'export', 'alias', 'unalias', 'set', 'unset', 'source', '.', 'fg', 'bg', 'jobs', 'wait', 'true', 'false', 'yes', 'sleep', 'date', 'whoami', 'id', 'uname', 'hostname', 'uptime', 'df', 'free', 'top', 'htop', 'nano', 'vi', 'vim', 'less', 'more', 'tail', 'head', 'man', 'info', 'which', 'whereis', 'type', 'command', 'hash', 'builtin', 'help']
|
||||
|
||||
const topCmds = (() => {
|
||||
const counts = {}
|
||||
for (const c of recentCmds) {
|
||||
const base = c.cmd.split(/\s+/)[0]
|
||||
if (!base || base.length < 2 || EXCLUDE_CMDS.includes(base)) continue
|
||||
if (!/^[a-zA-Z@.\/]/.test(base)) continue
|
||||
counts[base] = (counts[base] || 0) + 1
|
||||
}
|
||||
return Object.entries(counts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 5)
|
||||
.map(([cmd, count]) => ({ cmd, count }))
|
||||
})()
|
||||
|
||||
const maxCount = topCmds.length > 0 ? topCmds[0].count : 1
|
||||
|
||||
const copyCmd = (cmd, key) => {
|
||||
navigator.clipboard.writeText(cmd)
|
||||
setCopiedSet(prev => new Set(prev).add(key))
|
||||
setTimeout(() => setCopiedSet(prev => { const next = new Set(prev); next.delete(key); return next }), 1500)
|
||||
}
|
||||
|
||||
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 relativeTime = (ts) => {
|
||||
if (!ts) return ''
|
||||
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000)
|
||||
if (diff < 60) return `${diff}s`
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h`
|
||||
return `${Math.floor(diff / 86400)}d`
|
||||
}
|
||||
|
||||
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
|
||||
const recentUnique = (() => {
|
||||
const seen = new Set()
|
||||
return recentCmds.filter(c => {
|
||||
if (seen.has(c.cmd)) return false
|
||||
seen.add(c.cmd)
|
||||
return true
|
||||
})
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="dashboard-layout">
|
||||
<div className="dashboard-tabs">
|
||||
<button
|
||||
className={`dashboard-tab ${activeTab === 'tools' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('tools')}
|
||||
>
|
||||
<span className="tab-icon">🔧</span>
|
||||
{t('dashboard.tools')}
|
||||
<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 className="dash-grid">
|
||||
{/* CPU */}
|
||||
<div className="dash-card">
|
||||
<div className="dash-card-head">
|
||||
<span className="dash-label">CPU</span>
|
||||
<span className="dash-count">{metrics ? metrics.cpu_percent.toFixed(0) : '—'}%</span>
|
||||
</div>
|
||||
<MiniGraph data={cpuRef.current} max={100} color="var(--accent)" label="CPU" unit="%" />
|
||||
</div>
|
||||
|
||||
<div className="dashboard-content">
|
||||
{activeTab === 'tools' && (
|
||||
<div className="dashboard-tools-panel">
|
||||
<div className="dashboard-section-header">
|
||||
<div className="dashboard-section-title">{t('dashboard.systemOverview')}</div>
|
||||
<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>}
|
||||
{/* RAM */}
|
||||
<div className="dash-card">
|
||||
<div className="dash-card-head">
|
||||
<span className="dash-label">RAM</span>
|
||||
<span className="dash-count">{metrics ? `${metrics.mem_used_mb.toFixed(0)}/${metrics.mem_total_mb.toFixed(0)}` : '—'}</span>
|
||||
</div>
|
||||
<MiniGraph data={memRef.current} max={100} color="#a78bfa" label="RAM" unit="%" />
|
||||
</div>
|
||||
|
||||
{/* Network */}
|
||||
<div className="dash-card">
|
||||
<div className="dash-card-head">
|
||||
<span className="dash-label">Network</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>
|
||||
{systemInfo && (
|
||||
<div className="dashboard-system-info">
|
||||
<span className="sys-info-item">{systemInfo.os || systemInfo.platform || 'Unknown'}</span>
|
||||
<span className="sys-info-sep">·</span>
|
||||
<span className="sys-info-item">{systemInfo.arch || 'Unknown'}</span>
|
||||
{systemInfo.shell && <><span className="sys-info-sep">·</span><span className="sys-info-item">{systemInfo.shell}</span></>}
|
||||
))}
|
||||
{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>
|
||||
)}
|
||||
{zai && zai.data?.models?.map((m, i) => (
|
||||
<div key={i} className="dash-quota-row">
|
||||
<span className="dash-quota-name">{String(m.model)}</span>
|
||||
<div className="dash-bar">
|
||||
<div className="dash-bar-fill" style={{ width: `${Math.min(100, (m.used / m.total) * 100)}%` }} />
|
||||
</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}
|
||||
/>
|
||||
))}
|
||||
<span className="dash-quota-val">{m.used}/{m.total}</span>
|
||||
</div>
|
||||
))}
|
||||
{zai && !zai.data?.models?.length && (
|
||||
<div className="dash-quota-row">
|
||||
<span className="dash-quota-name">Z.AI</span>
|
||||
<span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>{zai.error || 'no data'}</span>
|
||||
</div>
|
||||
)}
|
||||
{!minimax && !zai && <span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>No providers</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Running Processes */}
|
||||
<div className="dash-card">
|
||||
<div className="dash-card-head">
|
||||
<span className="dash-label">Processes</span>
|
||||
<span className="dash-count">{processes.length}</span>
|
||||
</div>
|
||||
<div className="dash-proc-list">
|
||||
{processes.length === 0 && <span className="dash-empty">No relevant processes</span>}
|
||||
{processes.map((p, i) => (
|
||||
<div key={i} className="dash-proc-row">
|
||||
<span className="dash-proc-name">{p.name}</span>
|
||||
<span className="dash-proc-res">cpu {p.cpu}% · mem {p.mem}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Commands */}
|
||||
<div className="dash-card dash-cmd-card">
|
||||
<div className="dash-card-head">
|
||||
<span className="dash-label">Recent Commands</span>
|
||||
<span className="dash-count">{recentUnique.length}</span>
|
||||
</div>
|
||||
{topCmds.length > 0 && (
|
||||
<div className="dash-cmd-freq">
|
||||
<span className="dash-cmd-freq-title">Most used</span>
|
||||
{topCmds.map((c, i) => (
|
||||
<div key={i} className="dash-cmd-freq-row" onClick={() => copyCmd(c.cmd, `top-${i}`)} title={c.cmd}>
|
||||
<span className="dash-cmd-freq-name">{copiedSet.has(`top-${i}`) ? '✓ Copié' : c.cmd}</span>
|
||||
<div className="dash-cmd-freq-bar-wrap">
|
||||
<div className="dash-cmd-freq-bar" style={{ width: `${(c.count / maxCount) * 100}%` }} />
|
||||
</div>
|
||||
<span className="dash-cmd-freq-count">{c.count}×</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'activity' && (
|
||||
<div className="dashboard-activity-panel">
|
||||
<div className="dashboard-section-header">
|
||||
<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 className="dash-cmd-list">
|
||||
{recentUnique.length === 0 && <span className="dash-empty">No history</span>}
|
||||
{recentUnique.map((c, i) => (
|
||||
<div key={i} className="dash-cmd-row" onClick={() => copyCmd(c.cmd, `list-${i}`)} title={c.cmd + ' · click to copy'}>
|
||||
<div className="dash-cmd-left">
|
||||
<span className="dash-cmd-text">{c.cmd.length > 38 ? c.cmd.slice(0, 35) + '...' : c.cmd}</span>
|
||||
<span className="dash-cmd-time">{relativeTime(c.ts)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'actions' && (
|
||||
<div className="dashboard-actions-panel">
|
||||
<div className="dashboard-section-header">
|
||||
<div className="dashboard-section-title">{t('dashboard.quickActions')}</div>
|
||||
<span className="dash-cmd-copy">{copiedSet.has(`list-${i}`) ? '✓' : '⎘'}</span>
|
||||
</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>
|
||||
|
||||
{updates.length > 0 && (
|
||||
<div className="dashboard-updates-section">
|
||||
<div className="dashboard-section-header">
|
||||
<div className="dashboard-section-title">{t('dashboard.updates')}</div>
|
||||
<span className="badge warn">{updates.length}</span>
|
||||
</div>
|
||||
<div className="updates-list">
|
||||
{updates.map((update, i) => (
|
||||
<div key={update.name || i} className="update-row">
|
||||
<div className="update-info">
|
||||
<span className="update-name">{update.name || 'Unknown'}</span>
|
||||
<span className="update-versions">
|
||||
{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>
|
||||
)}
|
||||
|
||||
{activeTab === 'status' && (
|
||||
<div className="dashboard-status-panel">
|
||||
{dashboardStatus ? (
|
||||
<>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Sparkles, ArrowRight, ArrowLeft, Search, Loader } from 'lucide-react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Sparkles, ArrowRight, ArrowLeft, Loader } from 'lucide-react'
|
||||
import { useI18n, LANGUAGES } from '../i18n'
|
||||
import { getLayoutList } from '../i18n/keyboards'
|
||||
|
||||
@@ -32,6 +32,8 @@ export default function OnboardingWizard({ api, onComplete }) {
|
||||
const [validating, setValidating] = useState(false)
|
||||
const [keyValid, setKeyValid] = useState(false)
|
||||
const [scanning, setScanning] = useState(false)
|
||||
const [scanMessage, setScanMessage] = useState('')
|
||||
const scanAbortRef = useRef(null)
|
||||
|
||||
const current = STEPS[step]
|
||||
const layouts = getLayoutList()
|
||||
@@ -50,7 +52,7 @@ export default function OnboardingWizard({ api, onComplete }) {
|
||||
case 'name': return answers.name.trim().length > 0
|
||||
case 'language': return !!answers.language
|
||||
case 'keyboard': return !!answers.keyboard
|
||||
case 'apikey': return true
|
||||
case 'apikey': return keyValid && !scanning
|
||||
case 'editor': return true
|
||||
case 'done': return true
|
||||
default: return true
|
||||
@@ -61,14 +63,82 @@ export default function OnboardingWizard({ api, onComplete }) {
|
||||
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(() => {
|
||||
const handler = (e) => {
|
||||
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() }
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [step, current])
|
||||
}, [step, current, answers, editorList])
|
||||
|
||||
useEffect(() => {
|
||||
return () => { if (scanAbortRef.current) scanAbortRef.current.abort() }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (current.key === 'done' && !saving) {
|
||||
@@ -88,6 +158,14 @@ export default function OnboardingWizard({ api, onComplete }) {
|
||||
base_url: 'https://api.minimax.io/v1',
|
||||
})
|
||||
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) {
|
||||
setError(err.message || 'Clé invalide')
|
||||
setKeyValid(false)
|
||||
@@ -95,22 +173,7 @@ export default function OnboardingWizard({ api, onComplete }) {
|
||||
setValidating(false)
|
||||
}
|
||||
|
||||
const handleScanEditors = async () => {
|
||||
setScanning(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await api.getEditors()
|
||||
const detected = (data.editors || []).map(e => e.name)
|
||||
const merged = [...new Set([...detected, ...BASE_EDITORS])]
|
||||
setEditorList(merged)
|
||||
if (detected.length === 0) {
|
||||
setError('Aucun éditeur détecté')
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || 'Erreur lors du scan')
|
||||
}
|
||||
setScanning(false)
|
||||
}
|
||||
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
@@ -154,9 +217,10 @@ export default function OnboardingWizard({ api, onComplete }) {
|
||||
</div>
|
||||
|
||||
<div className="onboarding-progress">
|
||||
{STEPS.map((_, i) => (
|
||||
<div key={i} className={`onboarding-dot ${i === step ? 'active' : ''} ${i < step ? 'done' : ''}`} />
|
||||
))}
|
||||
{STEPS.filter(s => s.key !== 'done').map(s => {
|
||||
const i = STEPS.indexOf(s)
|
||||
return <div key={s.key} className={`onboarding-dot ${i === step ? 'active' : ''} ${i < step ? 'done' : ''}`} />
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="onboarding-body">
|
||||
@@ -221,7 +285,7 @@ export default function OnboardingWizard({ api, onComplete }) {
|
||||
<div className="onboarding-step">
|
||||
<div className="onboarding-title">Clé API MiniMax</div>
|
||||
<div className="onboarding-desc">
|
||||
Entrez votre clé API MiniMax pour activer l'assistant IA. Vous pouvez passer cette étape et la configurer plus tard.
|
||||
Entrez votre clé API MiniMax pour activer l'assistant IA. La clé est obligatoire pour continuer.
|
||||
</div>
|
||||
<input
|
||||
className="onboarding-input"
|
||||
@@ -232,7 +296,14 @@ export default function OnboardingWizard({ api, onComplete }) {
|
||||
autoFocus
|
||||
/>
|
||||
{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 }}>
|
||||
<button
|
||||
className="sm primary"
|
||||
@@ -241,16 +312,9 @@ export default function OnboardingWizard({ api, onComplete }) {
|
||||
>
|
||||
{validating ? 'Validation...' : 'Valider la clé'}
|
||||
</button>
|
||||
<button
|
||||
className="sm ghost"
|
||||
onClick={goNext}
|
||||
disabled={!answers.apikey.trim()}
|
||||
>
|
||||
Passer
|
||||
</button>
|
||||
</div>
|
||||
{answers.apikey.trim() && !keyValid && !error && (
|
||||
<div className="onboarding-hint">Cliquez "Valider la clé" ou "Passer"</div>
|
||||
{!keyValid && !error && answers.apikey.trim() && (
|
||||
<div className="onboarding-hint">Entrez votre clé puis cliquez "Valider la clé"</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -258,37 +322,20 @@ export default function OnboardingWizard({ api, onComplete }) {
|
||||
{current.key === 'editor' && (
|
||||
<div className="onboarding-step">
|
||||
<div className="onboarding-title">Quel éditeur utilisez-vous ?</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div className="onboarding-chips" style={{ flex: 1 }}>
|
||||
{editorList.map(ed => (
|
||||
<div
|
||||
key={ed}
|
||||
className={`chip ${answers.editor === ed ? 'active' : ''}`}
|
||||
onClick={() => setAnswers(a => ({ ...a, editor: ed }))}
|
||||
>
|
||||
{ed}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="sm ghost"
|
||||
onClick={handleScanEditors}
|
||||
disabled={scanning}
|
||||
title="Détecter les éditeurs installés"
|
||||
style={{ marginLeft: 8, flexShrink: 0 }}
|
||||
>
|
||||
{scanning ? <Loader size={14} className="spin-icon" /> : <Search size={14} />}
|
||||
</button>
|
||||
<div className="onboarding-desc">
|
||||
{scanning ? 'Détection en cours...' : 'Sélectionnez votre éditeur.'}
|
||||
</div>
|
||||
<div className="onboarding-chips">
|
||||
{editorList.map(ed => (
|
||||
<div
|
||||
key={ed}
|
||||
className={`chip ${answers.editor === ed ? 'active' : ''}`}
|
||||
onClick={() => setAnswers(a => ({ ...a, editor: ed }))}
|
||||
>
|
||||
{ed}
|
||||
</div>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -394,6 +441,10 @@ export default function OnboardingWizard({ api, onComplete }) {
|
||||
.onboarding-hint {
|
||||
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 {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,70 @@ import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { Terminal as XTerm } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2 } from 'lucide-react'
|
||||
import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2, Search, Copy, Send, Eye } from 'lucide-react'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
const MAX_TABS = 7
|
||||
const SHELL_MAX_TOKENS = 100000
|
||||
const TABS_STORAGE_KEY = 'muyue_shell_tabs'
|
||||
const TERMINAL_BUFFER_KEY = 'muyue_terminal_buffers'
|
||||
|
||||
function renderContent(text) {
|
||||
const parts = []
|
||||
const codeBlockRegex = /(```[\s\S]*?```)/g
|
||||
let match
|
||||
let lastIndex = 0
|
||||
while ((match = codeBlockRegex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({ type: 'text', content: text.slice(lastIndex, match.index) })
|
||||
}
|
||||
const full = match[1]
|
||||
const firstNewline = full.indexOf('\n')
|
||||
const lang = firstNewline > -1 ? full.slice(3, firstNewline).trim() : ''
|
||||
const code = firstNewline > -1 ? full.slice(firstNewline + 1, -3) : full.slice(3, -3)
|
||||
parts.push({ type: 'code', lang, content: code })
|
||||
lastIndex = match.index + full.length
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
const remaining = text.slice(lastIndex)
|
||||
const openBlock = remaining.match(/```(\w*)\n?([\s\S]*)$/)
|
||||
if (openBlock) {
|
||||
if (openBlock.index > 0) {
|
||||
parts.push({ type: 'text', content: remaining.slice(0, openBlock.index) })
|
||||
}
|
||||
parts.push({ type: 'code', lang: openBlock[1] || '', content: openBlock[2] || '' })
|
||||
} else {
|
||||
parts.push({ type: 'text', content: remaining })
|
||||
}
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
function formatText(text) {
|
||||
let html = text
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
|
||||
html = html
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
|
||||
.replace(/^### (.+)$/gm, '<h4 class="msg-h4">$1</h4>')
|
||||
.replace(/^## (.+)$/gm, '<h3 class="msg-h3">$1</h3>')
|
||||
.replace(/^# (.+)$/gm, '<h2 class="msg-h2">$1</h2>')
|
||||
.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(/\n/g, '<br/>')
|
||||
|
||||
html = html
|
||||
.replace(/<br\/>\s*<br\/>/g, '<br/>')
|
||||
.replace(/<br\/>\s*(<h[234]|<div class="msg-)/g, '$1')
|
||||
.replace(/(<\/h[234]|<\/div>)\s*<br\/>/g, '$1')
|
||||
.replace(/\s+on\w+=["'][^"']*["']/gi, '')
|
||||
.replace(/javascript:/gi, '')
|
||||
.replace(/data:/gi, '')
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
const THEMES = {
|
||||
default: {
|
||||
@@ -73,7 +132,7 @@ function createTerminal(container, settings = {}) {
|
||||
const theme = getTheme(settings.theme || 'default')
|
||||
const term = new XTerm({
|
||||
cursorBlink: true,
|
||||
fontSize: settings.fontSize || 14,
|
||||
fontSize: settings.fontSize || 12,
|
||||
fontFamily: settings.fontFamily || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
||||
theme,
|
||||
allowTransparency: false,
|
||||
@@ -90,7 +149,7 @@ function createTerminal(container, settings = {}) {
|
||||
return { term, fitAddon }
|
||||
}
|
||||
|
||||
function connectWebSocket(term, fitAddon, initPayload) {
|
||||
function connectWebSocket(term, fitAddon, initPayload, onStateChange, onFirstMessage) {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const ws = new WebSocket(`${proto}//${window.location.host}/api/ws/terminal`)
|
||||
|
||||
@@ -100,9 +159,15 @@ function connectWebSocket(term, fitAddon, initPayload) {
|
||||
if (dims) {
|
||||
ws.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols }))
|
||||
}
|
||||
if (onStateChange) onStateChange(true)
|
||||
})
|
||||
|
||||
let firstMessage = true
|
||||
ws.addEventListener('message', (event) => {
|
||||
if (firstMessage) {
|
||||
firstMessage = false
|
||||
if (onFirstMessage) onFirstMessage()
|
||||
}
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === 'output') {
|
||||
@@ -117,16 +182,12 @@ function connectWebSocket(term, fitAddon, initPayload) {
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
term.write('\r\n\x1b[33m— Connection closed —\x1b[0m\r\n')
|
||||
if (onStateChange) onStateChange(false)
|
||||
})
|
||||
|
||||
ws.addEventListener('error', () => {
|
||||
term.write('\r\n\x1b[31m— Connection error —\x1b[0m\r\n')
|
||||
})
|
||||
|
||||
term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'input', data }))
|
||||
}
|
||||
if (onStateChange) onStateChange(false)
|
||||
})
|
||||
|
||||
term.onResize(({ rows, cols }) => {
|
||||
@@ -142,11 +203,33 @@ export default function Shell({ api }) {
|
||||
const { t } = useI18n()
|
||||
const tabsRef = useRef({})
|
||||
const nextIdRef = useRef(1)
|
||||
const settingsRef = useRef({ fontSize: 12, fontFamily: "'JetBrains Mono', 'Fira Code', monospace", theme: 'default' })
|
||||
const pendingCommandsRef = useRef({})
|
||||
|
||||
const [tabs, setTabs] = useState([
|
||||
const savedTabs = (() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(TABS_STORAGE_KEY)
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
return parsed.map(t => ({ ...t, connected: false }))
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return null
|
||||
})()
|
||||
|
||||
const [tabs, setTabs] = useState(savedTabs || [
|
||||
{ id: 1, name: 'Local Shell', type: 'local', shell: '', connected: false },
|
||||
])
|
||||
const [activeTab, setActiveTab] = useState(1)
|
||||
const [activeTab, setActiveTab] = useState(() => {
|
||||
if (savedTabs) {
|
||||
return savedTabs[0]?.id || 1
|
||||
}
|
||||
return 1
|
||||
})
|
||||
const activeTabRef = useRef(activeTab)
|
||||
useEffect(() => { activeTabRef.current = activeTab }, [activeTab])
|
||||
const [sshConnections, setSshConnections] = useState([])
|
||||
const [systemTerminals, setSystemTerminals] = useState([])
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
@@ -154,26 +237,63 @@ export default function Shell({ api }) {
|
||||
const [editingTab, setEditingTab] = useState(null)
|
||||
const [editName, setEditName] = useState('')
|
||||
const [terminalSettings, setTerminalSettings] = useState({
|
||||
fontSize: 14,
|
||||
fontSize: 12,
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
||||
theme: 'default',
|
||||
})
|
||||
|
||||
useEffect(() => { settingsRef.current = terminalSettings }, [terminalSettings])
|
||||
|
||||
const [sshForm, setSshForm] = useState({
|
||||
name: '', host: '', port: 22, user: '', key_path: '',
|
||||
})
|
||||
|
||||
const [aiMessages, setAiMessages] = useState([
|
||||
{ role: 'ai', content: t('shell.aiWelcome') }
|
||||
])
|
||||
const [aiMessages, setAiMessages] = useState([])
|
||||
const [aiInput, setAiInput] = useState('')
|
||||
const [aiLoading, setAiLoading] = useState(false)
|
||||
const [aiTokens, setAiTokens] = useState(0)
|
||||
const [aiAtLimit, setAiAtLimit] = useState(false)
|
||||
const [analyzing, setAnalyzing] = useState(false)
|
||||
const [showAnalysis, setShowAnalysis] = useState(false)
|
||||
const [analysisContent, setAnalysisContent] = useState('')
|
||||
const aiMessagesRef = useRef(null)
|
||||
const aiLoadedRef = useRef(false)
|
||||
const aiLoadingRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
|
||||
}, [aiMessages])
|
||||
|
||||
useEffect(() => {
|
||||
api.getShellAnalysis?.().then(d => {
|
||||
if (d?.analysis) setAnalysisContent(d.analysis)
|
||||
}).catch(() => {
|
||||
const stored = localStorage.getItem('shell_analysis')
|
||||
if (stored) setAnalysisContent(stored)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (aiLoadedRef.current) return
|
||||
aiLoadedRef.current = true
|
||||
api.getShellChatHistory().then(d => {
|
||||
if (d.messages && d.messages.length > 0) {
|
||||
setAiMessages(d.messages)
|
||||
} else {
|
||||
setAiMessages([{ role: 'assistant', content: t('shell.aiWelcome') || 'Système Analyste prêt. Tapez /help pour les commandes.' }])
|
||||
}
|
||||
setAiTokens(d.tokens || 0)
|
||||
setAiAtLimit(d.at_limit || false)
|
||||
}).catch(() => {
|
||||
setAiMessages([{ role: 'assistant', content: 'Système Analyste prêt.' }])
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const maxId = tabs.reduce((max, t) => Math.max(max, t.id), 0)
|
||||
nextIdRef.current = maxId + 1
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
api.getTerminalSessions().then(d => {
|
||||
setSshConnections(d.ssh || [])
|
||||
@@ -182,7 +302,7 @@ export default function Shell({ api }) {
|
||||
api.getConfig().then(d => {
|
||||
if (d.terminal) {
|
||||
setTerminalSettings({
|
||||
fontSize: d.terminal.font_size || 14,
|
||||
fontSize: d.terminal.font_size || 12,
|
||||
fontFamily: d.terminal.font_family || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
||||
theme: d.terminal.theme || 'default',
|
||||
})
|
||||
@@ -196,10 +316,11 @@ export default function Shell({ api }) {
|
||||
const container = document.getElementById(`terminal-${tabId}`)
|
||||
if (!container) return
|
||||
|
||||
const s = settingsRef.current
|
||||
const { term, fitAddon } = createTerminal(container, {
|
||||
fontSize: terminalSettings.fontSize,
|
||||
fontFamily: terminalSettings.fontFamily,
|
||||
theme: terminalSettings.theme,
|
||||
fontSize: s.fontSize,
|
||||
fontFamily: s.fontFamily,
|
||||
theme: s.theme,
|
||||
})
|
||||
|
||||
let initPayload
|
||||
@@ -220,62 +341,216 @@ export default function Shell({ api }) {
|
||||
}
|
||||
}
|
||||
|
||||
const ws = connectWebSocket(term, fitAddon, initPayload)
|
||||
let disposed = false
|
||||
|
||||
ws.onopen = () => {
|
||||
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: true } : t))
|
||||
const saveBuffer = () => {
|
||||
try {
|
||||
const buf = term.buffer.active
|
||||
const lines = []
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
const line = buf.getLine(i)
|
||||
if (line) lines.push(line.translateToString(true))
|
||||
}
|
||||
const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
||||
savedBuffers[tabId] = lines.join('\n')
|
||||
sessionStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
|
||||
} catch (e) { console.warn('[Shell] Buffer save failed:', e) }
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t))
|
||||
const onWsState = (connected) => {
|
||||
if (disposed) return
|
||||
if (!connected) saveBuffer()
|
||||
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected } : t))
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t))
|
||||
const restoreBuffer = () => {
|
||||
try {
|
||||
const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
||||
if (savedBuffers[tabId]) {
|
||||
term.write('\x1b[90m— session restaurée —\x1b[0m\r\n')
|
||||
term.write(savedBuffers[tabId])
|
||||
}
|
||||
} catch (e) { console.warn('[Shell] Buffer restore failed:', e) }
|
||||
}
|
||||
|
||||
const ws = connectWebSocket(term, fitAddon, initPayload, onWsState, restoreBuffer)
|
||||
|
||||
const clearBufferOnClear = () => {
|
||||
try {
|
||||
const buf = term.buffer.active
|
||||
const lineY = buf.length - 1
|
||||
const line = buf.getLine(lineY)
|
||||
if (line) {
|
||||
const text = line.translateToString(true).trim().toLowerCase()
|
||||
if (text === 'clear' || text === '$ clear' || text.endsWith(' clear')) {
|
||||
const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
||||
delete savedBuffers[tabId]
|
||||
sessionStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
|
||||
}
|
||||
}
|
||||
} catch (e) { console.warn('[Shell] Clear detection failed:', e) }
|
||||
}
|
||||
|
||||
term.onData((data) => {
|
||||
if (data === '\r') clearBufferOnClear()
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'input', data }))
|
||||
}
|
||||
})
|
||||
|
||||
const onResize = () => {
|
||||
const el = document.getElementById(`terminal-${tabId}`)
|
||||
if (el && el.offsetParent !== null) {
|
||||
fitAddon.fit()
|
||||
}
|
||||
fitAddon.fit()
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(onResize)
|
||||
resizeObserver.observe(container)
|
||||
window.addEventListener('resize', onResize)
|
||||
|
||||
tabsRef.current[tabId] = { term, fitAddon, ws, resizeObserver, onResize }
|
||||
const bufferSaveInterval = setInterval(() => { if (!disposed) saveBuffer() }, 5000)
|
||||
|
||||
console.log(`[Shell] initTerminal tab=${tabId} type=${tab.type} name="${tab.name}" shell="${tab.shell || '(default)'}"`)
|
||||
tabsRef.current[tabId] = { term, fitAddon, ws, resizeObserver, onResize, bufferSaveInterval, saveBuffer, disposed: () => disposed }
|
||||
tabsRef.current[tabId]._markDisposed = () => { disposed = true }
|
||||
console.log(`[Shell] initTerminal tab=${tabId} done, tabsRef keys:`, Object.keys(tabsRef.current))
|
||||
|
||||
const pending = pendingCommandsRef.current[tabId]
|
||||
if (pending && pending.length > 0) {
|
||||
console.log(`[Shell] Flushing ${pending.length} pending commands for tab ${tabId}`)
|
||||
for (const cmd of pending) {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'input', data: cmd + '\r' }))
|
||||
}
|
||||
}
|
||||
delete pendingCommandsRef.current[tabId]
|
||||
}
|
||||
}, [])
|
||||
|
||||
const initPendingTabs = useCallback(() => {
|
||||
for (const tab of tabsRef.current._tabList || []) {
|
||||
if (!tabsRef.current[tab.id]) {
|
||||
const container = document.getElementById(`terminal-${tab.id}`)
|
||||
if (container && container.offsetHeight > 0) {
|
||||
initTerminal(tab.id, tab)
|
||||
}
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
for (const tab of tabsRef.current._tabList || []) {
|
||||
const entry = tabsRef.current[tab.id]
|
||||
if (entry) entry.fitAddon.fit()
|
||||
}
|
||||
})
|
||||
}, [initTerminal])
|
||||
|
||||
useEffect(() => {
|
||||
const tab = tabs.find(t => t.id === activeTab)
|
||||
if (!tab) return
|
||||
tabsRef.current._tabList = tabs
|
||||
}, [tabs])
|
||||
|
||||
const container = document.getElementById(`terminal-${tab.id}`)
|
||||
if (!container) return
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const pending = []
|
||||
|
||||
if (!tabsRef.current[tab.id]) {
|
||||
const timer = setTimeout(() => {
|
||||
const tryInitTab = (tab, attempt) => {
|
||||
if (cancelled) return
|
||||
const shellCol = document.querySelector('.shell-terminal-col')
|
||||
if (!shellCol || shellCol.offsetParent === null) {
|
||||
pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 200))
|
||||
return
|
||||
}
|
||||
const container = document.getElementById(`terminal-${tab.id}`)
|
||||
if (!container) {
|
||||
pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 100))
|
||||
return
|
||||
}
|
||||
if (container.offsetHeight === 0) {
|
||||
pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 100))
|
||||
return
|
||||
}
|
||||
if (!tabsRef.current[tab.id]) {
|
||||
initTerminal(tab.id, tab)
|
||||
requestAnimationFrame(() => {
|
||||
const entry = tabsRef.current[tab.id]
|
||||
if (entry) entry.fitAddon.fit()
|
||||
})
|
||||
}, 100)
|
||||
return () => clearTimeout(timer)
|
||||
} else {
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
if (cancelled) return
|
||||
const entry = tabsRef.current[tab.id]
|
||||
if (entry) entry.fitAddon.fit()
|
||||
})
|
||||
}
|
||||
}, [activeTab, tabs, initTerminal])
|
||||
|
||||
for (const tab of tabs) {
|
||||
if (!tabsRef.current[tab.id]) {
|
||||
tryInitTab(tab, 0)
|
||||
}
|
||||
}
|
||||
|
||||
const wrapper = document.querySelector('.shell-layout')?.parentElement
|
||||
let observer
|
||||
if (wrapper) {
|
||||
observer = new MutationObserver(() => {
|
||||
if (!wrapper.classList.contains('tab-hidden') && wrapper.offsetParent !== null) {
|
||||
initPendingTabs()
|
||||
}
|
||||
})
|
||||
observer.observe(wrapper, { attributes: true, attributeFilter: ['class'] })
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
pending.forEach(clearTimeout)
|
||||
observer?.disconnect()
|
||||
}
|
||||
}, [tabs, initTerminal, initPendingTabs])
|
||||
|
||||
useEffect(() => {
|
||||
const entry = tabsRef.current[activeTab]
|
||||
if (entry) {
|
||||
requestAnimationFrame(() => {
|
||||
if (activeTabRef.current === activeTab) {
|
||||
entry.fitAddon.fit()
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [activeTab])
|
||||
|
||||
useEffect(() => {
|
||||
const iv = setInterval(() => {
|
||||
const wrapper = document.querySelector('.shell-layout')?.parentElement
|
||||
if (wrapper && wrapper.classList.contains('tab-hidden')) return
|
||||
const entry = tabsRef.current[activeTabRef.current]
|
||||
if (entry) {
|
||||
entry.fitAddon.fit()
|
||||
}
|
||||
}, 2000)
|
||||
return () => clearInterval(iv)
|
||||
}, [tabs])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const [tabId, entry] of Object.entries(tabsRef.current)) {
|
||||
entry._markDisposed?.()
|
||||
if (entry.bufferSaveInterval) clearInterval(entry.bufferSaveInterval)
|
||||
window.removeEventListener('resize', entry.onResize)
|
||||
entry.resizeObserver?.disconnect()
|
||||
entry.ws?.close()
|
||||
entry.term?.dispose()
|
||||
}
|
||||
tabsRef.current = {}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
|
||||
if (!e.altKey) return
|
||||
if (!e.altKey && !(e.key === 'Tab' && e.shiftKey)) return
|
||||
|
||||
if (e.key === 'Tab' && e.shiftKey) {
|
||||
const shellTab = document.querySelector('.shell-layout')
|
||||
if (!shellTab || shellTab.closest('.tab-hidden')) return
|
||||
e.preventDefault()
|
||||
const idx = tabs.findIndex(t => t.id === activeTab)
|
||||
const next = (idx + 1) % tabs.length
|
||||
setActiveTab(tabs[next].id)
|
||||
return
|
||||
}
|
||||
|
||||
const num = parseInt(e.key)
|
||||
if (num >= 1 && num <= tabs.length) {
|
||||
@@ -290,8 +565,8 @@ export default function Shell({ api }) {
|
||||
const addLocalTab = (shell, name) => {
|
||||
if (tabs.length >= MAX_TABS) return
|
||||
const id = nextIdRef.current++
|
||||
const newTab = { id, name: name || `${t('shell.localShell')} ${tabs.length + 1}`, type: 'local', shell: shell || '', connected: false }
|
||||
setTabs(prev => [...prev, newTab])
|
||||
const newTab = { id, name: name || `${t('shell.localShell')} ${tabs.length}`, type: 'local', shell: shell || '', connected: false }
|
||||
setTabs(prev => { const next = [...prev, newTab]; localStorage.setItem(TABS_STORAGE_KEY, JSON.stringify(next.map(t => ({ id: t.id, name: t.name, type: t.type, shell: t.shell, host: t.host, port: t.port, user: t.user, key_path: t.key_path })))); return next })
|
||||
setActiveTab(id)
|
||||
setShowMenu(false)
|
||||
}
|
||||
@@ -309,25 +584,34 @@ export default function Shell({ api }) {
|
||||
key_path: conn.key_path || '',
|
||||
connected: false,
|
||||
}
|
||||
setTabs(prev => [...prev, newTab])
|
||||
setTabs(prev => { const next = [...prev, newTab]; localStorage.setItem(TABS_STORAGE_KEY, JSON.stringify(next.map(t => ({ id: t.id, name: t.name, type: t.type, shell: t.shell, host: t.host, port: t.port, user: t.user, key_path: t.key_path })))); return next })
|
||||
setActiveTab(id)
|
||||
setShowMenu(false)
|
||||
}
|
||||
|
||||
const closeTab = (tabId, e) => {
|
||||
if (e) e.stopPropagation()
|
||||
if (tabs.length <= 1) return
|
||||
|
||||
if (tabsRef.current[tabId]) {
|
||||
const { ws, resizeObserver, onResize, term } = tabsRef.current[tabId]
|
||||
window.removeEventListener('resize', onResize)
|
||||
resizeObserver.disconnect()
|
||||
ws.close()
|
||||
term.dispose()
|
||||
const entry = tabsRef.current[tabId]
|
||||
if (entry) {
|
||||
entry._markDisposed?.()
|
||||
entry.saveBuffer?.()
|
||||
if (entry.bufferSaveInterval) clearInterval(entry.bufferSaveInterval)
|
||||
window.removeEventListener('resize', entry.onResize)
|
||||
entry.resizeObserver.disconnect()
|
||||
entry.ws.close()
|
||||
entry.term.dispose()
|
||||
delete tabsRef.current[tabId]
|
||||
}
|
||||
|
||||
try {
|
||||
const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
||||
delete savedBuffers[tabId]
|
||||
sessionStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
|
||||
} catch (e) { console.warn('[Shell] Buffer cleanup failed:', e) }
|
||||
|
||||
setTabs(prev => {
|
||||
if (prev.length <= 1) return prev
|
||||
const next = prev.filter(t => t.id !== tabId)
|
||||
if (activeTab === tabId && next.length > 0) {
|
||||
setActiveTab(next[next.length - 1].id)
|
||||
@@ -372,55 +656,123 @@ export default function Shell({ api }) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleAiSend = async () => {
|
||||
if (!aiInput.trim() || aiLoading) return
|
||||
const text = aiInput.trim()
|
||||
setAiMessages(prev => [...prev, { role: 'user', content: text }])
|
||||
setAiInput('')
|
||||
setAiLoading(true)
|
||||
|
||||
const currentTab = tabs.find(t => t.id === activeTab)
|
||||
const context = {
|
||||
cwd: currentTab?.cwd || '',
|
||||
platform: navigator.platform || '',
|
||||
const sendToTerminal = useCallback((code, tabId) => {
|
||||
const targetId = tabId || activeTabRef.current
|
||||
const entry = tabsRef.current[targetId]
|
||||
if (!entry) {
|
||||
console.warn(`[Shell] sendToTerminal: tab ${targetId} not ready. Queueing. tabsRef:`, Object.keys(tabsRef.current), 'activeTab:', activeTabRef.current, 'requested:', tabId)
|
||||
if (!pendingCommandsRef.current[targetId]) pendingCommandsRef.current[targetId] = []
|
||||
pendingCommandsRef.current[targetId].push(code)
|
||||
return
|
||||
}
|
||||
if (!entry.ws || entry.ws.readyState !== WebSocket.OPEN) {
|
||||
console.warn(`[Shell] sendToTerminal: WS not open for tab ${targetId} (state=${entry.ws?.readyState}). Queueing.`)
|
||||
if (!pendingCommandsRef.current[targetId]) pendingCommandsRef.current[targetId] = []
|
||||
pendingCommandsRef.current[targetId].push(code)
|
||||
return
|
||||
}
|
||||
console.log(`[Shell] sendToTerminal: tab ${targetId} ← ${code.length} chars`)
|
||||
entry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' }))
|
||||
}, [])
|
||||
|
||||
const focusAiTerminal = useCallback(() => {
|
||||
const entry = tabsRef.current[activeTabRef.current]
|
||||
if (entry) entry.term.focus()
|
||||
}, [])
|
||||
|
||||
const _sendAiMessage = useCallback(async (text, fromEvent = false) => {
|
||||
if (!text || !text.trim() || aiLoadingRef.current || aiAtLimit) return
|
||||
const trimmed = text.trim()
|
||||
aiLoadingRef.current = true
|
||||
|
||||
if (!fromEvent) {
|
||||
setAiInput('')
|
||||
focusAiTerminal()
|
||||
}
|
||||
|
||||
if (trimmed === '/clear') {
|
||||
try {
|
||||
await api.clearShellChat()
|
||||
setAiMessages([{ role: 'assistant', content: t('shell.aiWelcome') || 'Contexte effacé. Prêt.' }])
|
||||
setAiTokens(0)
|
||||
setAiAtLimit(false)
|
||||
} catch {}
|
||||
aiLoadingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
if (trimmed === '/help') {
|
||||
setAiMessages(prev => [...prev,
|
||||
{ role: 'user', content: trimmed },
|
||||
{ role: 'assistant', content: 'Commandes disponibles:\n• /clear — Effacer la conversation\n• /help — Afficher l\'aide\n\nJe ne peux pas exécuter de code. Les blocs de code proposés peuvent être copiés ou envoyés directement au terminal actif.' }
|
||||
])
|
||||
aiLoadingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
const currentTab = activeTabRef.current
|
||||
console.log(`[Shell] _sendAiMessage: activeTab=${currentTab}, fromEvent=${fromEvent}, text="${trimmed.slice(0, 50)}"`)
|
||||
setAiMessages(prev => [...prev, { role: 'user', content: trimmed, _tabId: currentTab }])
|
||||
setAiLoading(true)
|
||||
|
||||
try {
|
||||
let accumulated = ''
|
||||
await api.sendShellChat(text, context, true, (partial, event) => {
|
||||
if (event && event.tool_call) {
|
||||
setAiMessages(prev => [...prev, {
|
||||
role: 'tool',
|
||||
content: `${t('shell.toolLaunched')}: ${event.tool_call.name || 'tool'}`,
|
||||
args: event.tool_call.args ? JSON.stringify(event.tool_call.args).slice(0, 100) : '',
|
||||
}])
|
||||
return
|
||||
}
|
||||
if (event && event.tool_result) {
|
||||
const resultText = event.tool_result.result?.content || event.tool_result.error || 'completed'
|
||||
setAiMessages(prev => [...prev, {
|
||||
role: 'tool_result',
|
||||
content: resultText,
|
||||
isError: event.tool_result.result?.is_error,
|
||||
}])
|
||||
return
|
||||
}
|
||||
if (event && event.done) return
|
||||
await api.sendShellChat(trimmed, {}, true, (partial) => {
|
||||
accumulated = partial
|
||||
setAiMessages(prev => {
|
||||
const filtered = prev.filter(m => !m._streaming)
|
||||
return [...filtered, { role: 'ai', content: partial, _streaming: true }]
|
||||
return [...filtered, { role: 'assistant', content: partial, _streaming: true, _tabId: currentTab }]
|
||||
})
|
||||
})
|
||||
|
||||
setAiMessages(prev => prev.filter(m => !m._streaming))
|
||||
if (accumulated) {
|
||||
setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'ai', content: accumulated }])
|
||||
}
|
||||
setAiMessages(prev => {
|
||||
const filtered = prev.filter(m => !m._streaming)
|
||||
return [...filtered, { role: 'assistant', content: accumulated, _tabId: currentTab }]
|
||||
})
|
||||
api.getShellChatHistory().then(d => {
|
||||
setAiTokens(d.tokens || 0)
|
||||
setAiAtLimit(d.at_limit || false)
|
||||
}).catch(() => {})
|
||||
} catch (err) {
|
||||
setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'ai', content: `${t('shell.error')}: ${err.message}` }])
|
||||
if (err.message?.includes('context limit')) {
|
||||
setAiAtLimit(true)
|
||||
}
|
||||
setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }])
|
||||
}
|
||||
setAiLoading(false)
|
||||
aiLoadingRef.current = false
|
||||
}, [api, t, aiAtLimit, focusAiTerminal])
|
||||
|
||||
const handleAiSend = () => _sendAiMessage(aiInput, false)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
const msg = e.detail?.message
|
||||
if (!msg) return
|
||||
setAiInput(msg)
|
||||
setTimeout(() => _sendAiMessage(msg, true), 100)
|
||||
}
|
||||
window.addEventListener('ask-ai-terminal', handler)
|
||||
return () => window.removeEventListener('ask-ai-terminal', handler)
|
||||
}, [_sendAiMessage])
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
setAnalyzing(true)
|
||||
setAiMessages(prev => [...prev, { role: 'system', content: 'Analyse du système en cours...' }])
|
||||
try {
|
||||
const d = await api.analyzeSystem()
|
||||
if (d.analysis) {
|
||||
setAnalysisContent(d.analysis)
|
||||
localStorage.setItem('shell_analysis', d.analysis)
|
||||
}
|
||||
setAiMessages(prev => [...prev.filter(m => m.content !== 'Analyse du système en cours...'), {
|
||||
role: 'system',
|
||||
content: 'Analyse système terminée et sauvegardée. Le contexte système est maintenant disponible.'
|
||||
}])
|
||||
} catch (err) {
|
||||
setAiMessages(prev => prev.filter(m => m.content !== 'Analyse du système en cours...'))
|
||||
}
|
||||
setAnalyzing(false)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -530,21 +882,48 @@ export default function Shell({ api }) {
|
||||
<div
|
||||
key={tab.id}
|
||||
id={`terminal-${tab.id}`}
|
||||
className="shell-xterm-instance"
|
||||
style={{ display: activeTab === tab.id ? 'block' : 'none' }}
|
||||
className={`shell-xterm-instance${activeTab === tab.id ? ' active' : ''}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shell-ai-col">
|
||||
<div className="ai-panel-header">{t('shell.aiAssistant')}</div>
|
||||
<div className="ai-panel-header">
|
||||
<span>Analyste Système</span>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
className="shell-analyze-btn"
|
||||
onClick={() => setShowAnalysis(true)}
|
||||
disabled={!analysisContent}
|
||||
title="Voir l'analyse"
|
||||
>
|
||||
<Eye size={13} />
|
||||
Analyse
|
||||
</button>
|
||||
<button
|
||||
className="shell-analyze-btn"
|
||||
onClick={handleAnalyze}
|
||||
disabled={analyzing}
|
||||
title="Analyser le système"
|
||||
>
|
||||
<Search size={13} />
|
||||
{analyzing ? '...' : 'Analyser'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shell-ai-token-bar">
|
||||
<div className="shell-ai-token-track">
|
||||
<div
|
||||
className={`shell-ai-token-fill ${aiTokens >= SHELL_MAX_TOKENS * 0.8 ? 'warn' : ''}`}
|
||||
style={{ width: `${Math.min(100, (aiTokens / SHELL_MAX_TOKENS) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="shell-ai-token-text">{Math.round(aiTokens / 1000)}k/{Math.round(SHELL_MAX_TOKENS / 1000)}k</span>
|
||||
</div>
|
||||
<div className="ai-panel-messages" ref={aiMessagesRef}>
|
||||
{aiMessages.map((msg, i) => (
|
||||
<div key={i} className={`ai-message ${msg.role}`}>
|
||||
{msg.content}
|
||||
{msg.args && <div className="tool-args">{msg.args}</div>}
|
||||
</div>
|
||||
<ShellAIMessage key={i} msg={msg} sendToTerminal={sendToTerminal} terminalTabId={msg._tabId || activeTab} />
|
||||
))}
|
||||
{aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>}
|
||||
</div>
|
||||
@@ -553,12 +932,36 @@ export default function Shell({ api }) {
|
||||
value={aiInput}
|
||||
onChange={e => setAiInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleAiSend()}
|
||||
placeholder={t('shell.askAi')}
|
||||
placeholder={aiAtLimit ? '/clear pour continuer' : t('shell.askAi')}
|
||||
disabled={aiAtLimit && aiInput !== '/clear'}
|
||||
/>
|
||||
<button className="sm" onClick={handleAiSend} disabled={!aiInput.trim()}>{t('shell.send')}</button>
|
||||
<button className="sm" onClick={handleAiSend} disabled={(!aiInput.trim() && !aiAtLimit) || (aiAtLimit && aiInput !== '/clear')}>{t('shell.send')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAnalysis && analysisContent && (
|
||||
<div className="shell-modal-overlay" onClick={() => setShowAnalysis(false)}>
|
||||
<div className="shell-analysis-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="shell-analysis-modal-header">
|
||||
<span>Analyse Système</span>
|
||||
<button className="shell-tab-close" onClick={() => setShowAnalysis(false)}><X size={16} /></button>
|
||||
</div>
|
||||
<div className="shell-analysis-modal-body">
|
||||
{renderContent(analysisContent).map((part, i) =>
|
||||
part.type === 'code' ? (
|
||||
<div key={i} className="shell-code-block">
|
||||
{part.lang && <div className="shell-code-lang">{part.lang}</div>}
|
||||
<pre><code>{part.content}</code></pre>
|
||||
</div>
|
||||
) : (
|
||||
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSshModal && (
|
||||
<div className="shell-modal-overlay" onClick={() => setShowSshModal(false)}>
|
||||
<div className="shell-modal" onClick={e => e.stopPropagation()}>
|
||||
@@ -611,3 +1014,42 @@ export default function Shell({ api }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
|
||||
const role = msg.role === 'user' ? 'user' : msg.role === 'system' ? 'system' : 'assistant'
|
||||
const content = msg.content || ''
|
||||
|
||||
if (role === 'user') {
|
||||
return <div className={`ai-message user`}>{content}</div>
|
||||
}
|
||||
|
||||
if (role === 'system') {
|
||||
return <div className={`ai-message system`}>{content}</div>
|
||||
}
|
||||
|
||||
const parts = renderContent(content)
|
||||
|
||||
return (
|
||||
<div className={`ai-message assistant`}>
|
||||
{parts.map((part, i) => {
|
||||
if (part.type === 'code') {
|
||||
return (
|
||||
<div key={i} className="shell-code-block">
|
||||
{part.lang && <div className="shell-code-lang">{part.lang}</div>}
|
||||
<pre><code>{part.content}</code></pre>
|
||||
<div className="shell-code-actions">
|
||||
<button onClick={() => navigator.clipboard.writeText(part.content)} title="Copier">
|
||||
<Copy size={12} /> Copier
|
||||
</button>
|
||||
<button onClick={() => sendToTerminal(part.content, terminalTabId)} title="Envoyer au terminal">
|
||||
<Send size={12} /> Terminal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
const RANKS = {
|
||||
@@ -47,14 +47,25 @@ function renderContent(text) {
|
||||
lastIndex = match.index + full.length
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
parts.push({ type: 'text', content: text.slice(lastIndex) })
|
||||
const remaining = text.slice(lastIndex)
|
||||
const openBlock = remaining.match(/```(\w*)\n?([\s\S]*)$/)
|
||||
if (openBlock) {
|
||||
if (openBlock.index > 0) {
|
||||
parts.push({ type: 'text', content: remaining.slice(0, openBlock.index) })
|
||||
}
|
||||
parts.push({ type: 'code', lang: openBlock[1] || '', content: openBlock[2] || '' })
|
||||
} else {
|
||||
parts.push({ type: 'text', content: remaining })
|
||||
}
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
function formatText(text) {
|
||||
return text
|
||||
let html = text
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
|
||||
html = html
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
|
||||
.replace(/^### (.+)$/gm, '<h4 class="msg-h4">$1</h4>')
|
||||
@@ -62,9 +73,20 @@ function formatText(text) {
|
||||
.replace(/^# (.+)$/gm, '<h2 class="msg-h2">$1</h2>')
|
||||
.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(/\n/g, '<br/>')
|
||||
|
||||
html = html
|
||||
.replace(/<br\/>\s*<br\/>/g, '<br/>')
|
||||
.replace(/<br\/>\s*(<h[234]|<div class="msg-)/g, '$1')
|
||||
.replace(/(<\/h[234]|<\/div>)\s*<br\/>/g, '$1')
|
||||
.replace(/\s+on\w+=["'][^"']*["']/gi, '')
|
||||
.replace(/javascript:/gi, '')
|
||||
.replace(/data:/gi, '')
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
function ThinkingBlock({ content, done }) {
|
||||
function ThinkingBlock({ content, done, raw }) {
|
||||
return (
|
||||
<div className={`feed-thinking-block ${done ? 'done' : 'active'}`}>
|
||||
<div className="feed-thinking-header">
|
||||
@@ -74,7 +96,9 @@ function ThinkingBlock({ content, done }) {
|
||||
<span>Reflexion</span>
|
||||
{!done && <span className="feed-thinking-dots"><span/><span/><span/></span>}
|
||||
</div>
|
||||
<div className="feed-thinking-content">{content}</div>
|
||||
<div className="feed-thinking-content">
|
||||
{raw ? <span dangerouslySetInnerHTML={{ __html: content }} /> : content}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -173,7 +197,7 @@ function FeedItem({ msg }) {
|
||||
)
|
||||
}
|
||||
|
||||
const cleanContent = displayContent.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||||
let cleanContent = displayContent.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||||
|
||||
return (
|
||||
<div className={`feed-item ${msg.role}`}>
|
||||
@@ -188,7 +212,7 @@ function FeedItem({ msg }) {
|
||||
<span className="feed-role">{rank.label}</span>
|
||||
{timeStr && <span className="feed-time">{timeStr}</span>}
|
||||
</div>
|
||||
{msg.thinking && <ThinkingBlock content={msg.thinking} done />}
|
||||
{msg.thinking && <ThinkingBlock content={formatText(msg.thinking)} done raw />}
|
||||
{parsedToolCalls && parsedToolCalls.map((tc, i) => {
|
||||
const resultData = parsedToolResults
|
||||
? parsedToolResults.find(r => r.tool_call_id === tc.tool_call_id)
|
||||
@@ -222,6 +246,16 @@ function StreamingItem({ content, thinking, toolCalls }) {
|
||||
const cleanContent = content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||||
const hasToolCalls = toolCalls && toolCalls.length > 0
|
||||
|
||||
const renderedContent = useMemo(() => {
|
||||
if (!cleanContent) return []
|
||||
return renderContent(cleanContent)
|
||||
}, [cleanContent])
|
||||
|
||||
const formattedThinking = useMemo(() => {
|
||||
if (!thinking) return ''
|
||||
return formatText(thinking)
|
||||
}, [thinking])
|
||||
|
||||
return (
|
||||
<div className="feed-item assistant">
|
||||
<div className="feed-avatar ai-rank">
|
||||
@@ -234,7 +268,7 @@ function StreamingItem({ content, thinking, toolCalls }) {
|
||||
</span>
|
||||
<span className="feed-role">{rank.label}</span>
|
||||
</div>
|
||||
{thinking && <ThinkingBlock content={thinking} done={false} />}
|
||||
{thinking && <ThinkingBlock content={formattedThinking} raw done={false} />}
|
||||
{hasToolCalls && toolCalls.map((tc, i) => (
|
||||
<ToolCallBlock key={tc.call?.tool_call_id || i} call={tc.call} result={tc.result} />
|
||||
))}
|
||||
@@ -245,7 +279,7 @@ function StreamingItem({ content, thinking, toolCalls }) {
|
||||
)}
|
||||
{cleanContent && (
|
||||
<div className="feed-content">
|
||||
{renderContent(cleanContent).map((part, i) =>
|
||||
{renderedContent.map((part, i) =>
|
||||
part.type === 'code' ? (
|
||||
<div key={i} className="studio-code-block">
|
||||
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
|
||||
@@ -272,7 +306,11 @@ export default function Studio({ api }) {
|
||||
const [streamThinking, setStreamThinking] = useState('')
|
||||
const [streamToolCalls, setStreamToolCalls] = useState([])
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [tokenInfo, setTokenInfo] = useState({ used: 0, max: 100000, summarizeAt: 80000 })
|
||||
const [contextCollapsed, setContextCollapsed] = useState(false)
|
||||
const [messagesCollapsed, setMessagesCollapsed] = useState(false)
|
||||
const messagesEnd = useRef(null)
|
||||
const feedRef = useRef(null)
|
||||
const textareaRef = useRef(null)
|
||||
const abortRef = useRef(null)
|
||||
|
||||
@@ -285,6 +323,11 @@ export default function Studio({ api }) {
|
||||
{ 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)
|
||||
}).catch(() => {
|
||||
setMessages([
|
||||
@@ -298,6 +341,20 @@ export default function Studio({ api }) {
|
||||
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages, streaming, streamThinking, streamToolCalls])
|
||||
|
||||
useEffect(() => {
|
||||
const onTab = (e) => {
|
||||
if (e.key !== 'Tab') return
|
||||
if (document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA') return
|
||||
const feed = document.querySelector('.studio-feed-layout')
|
||||
if (!feed?.closest('.tab-hidden')) {
|
||||
e.preventDefault()
|
||||
textareaRef.current?.focus()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onTab)
|
||||
return () => window.removeEventListener('keydown', onTab)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto'
|
||||
@@ -305,6 +362,34 @@ export default function Studio({ api }) {
|
||||
}
|
||||
}, [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() }])
|
||||
setContextCollapsed('animating')
|
||||
try {
|
||||
const data = await api.summarizeChat()
|
||||
setTokenInfo(prev => ({ ...prev, used: data.tokens || 0 }))
|
||||
setTimeout(() => {
|
||||
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(), compressed: true }])
|
||||
setContextCollapsed(true)
|
||||
setMessagesCollapsed(true)
|
||||
}, 600)
|
||||
} catch (err) {
|
||||
setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: 'system', content: `Erreur de résumé: ${err.message}`, time: new Date().toISOString() }])
|
||||
setContextCollapsed(false)
|
||||
}
|
||||
}, [api])
|
||||
|
||||
const handleClear = useCallback(async () => {
|
||||
try {
|
||||
await api.clearChat()
|
||||
@@ -319,11 +404,108 @@ export default function Studio({ api }) {
|
||||
const text = input.trim()
|
||||
setInput('')
|
||||
|
||||
const isSlashCommand = (t) => /^\/(clear|help|summarize|export|model(?:\s+\S+)?|plan\s+.+)$/.test(t)
|
||||
|
||||
if (text.startsWith('/') && !isSlashCommand(text)) {
|
||||
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'user', content: text, time: new Date().toISOString() }])
|
||||
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Commande inconnue. Tapez `/help` pour la liste des commandes.', time: new Date().toISOString() }])
|
||||
return
|
||||
}
|
||||
|
||||
if (text === '/clear') {
|
||||
handleClear()
|
||||
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',
|
||||
'- `/model change` - Basculer entre MiniMax et ZAI',
|
||||
'',
|
||||
'## 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' || text === '/model change') {
|
||||
if (text === '/model change') {
|
||||
api.getProviders().then(data => {
|
||||
const providers = data.providers || []
|
||||
const minimax = providers.find(p => p.name.toUpperCase() === 'MINIMAX')
|
||||
const zai = providers.find(p => p.name.toUpperCase() === 'ZAI')
|
||||
if (!minimax || !zai) {
|
||||
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'MiniMax et ZAI doivent être configurés pour utiliser `/model change`.', time: new Date().toISOString() }])
|
||||
return
|
||||
}
|
||||
const active = providers.find(p => p.active)
|
||||
const activeName = active ? active.name.toUpperCase() : ''
|
||||
const switchTo = activeName === 'MINIMAX' ? 'ZAI' : 'MINIMAX'
|
||||
const target = switchTo === 'MINIMAX' ? minimax : zai
|
||||
api.saveProvider({ name: target.name, active: true }).then(() => {
|
||||
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: `✓ Provider changé: **${target.name}** (${target.model})`, time: new Date().toISOString() }])
|
||||
}).catch(() => {
|
||||
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur lors du changement de provider.', 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() }])
|
||||
})
|
||||
} else {
|
||||
api.getProviders().then(data => {
|
||||
const active = data.providers?.find(p => p.active)
|
||||
const modelMsg = active ? `**${active.name}** — ${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() }
|
||||
setMessages(prev => [...prev, userMsg])
|
||||
setLoading(true)
|
||||
@@ -350,6 +532,8 @@ export default function Studio({ api }) {
|
||||
if (event && event.tool_call) {
|
||||
toolCalls = [...toolCalls, { call: event.tool_call, result: null }]
|
||||
setStreamToolCalls([...toolCalls])
|
||||
accumulated = ''
|
||||
setStreaming('')
|
||||
return
|
||||
}
|
||||
if (event && event.tool_result) {
|
||||
@@ -376,6 +560,11 @@ export default function Studio({ api }) {
|
||||
aiMsg.content = JSON.stringify({
|
||||
content: finalContent,
|
||||
tool_calls: toolCalls.map(tc => tc.call),
|
||||
tool_results: toolCalls.map(tc => ({
|
||||
tool_call_id: tc.call?.tool_call_id,
|
||||
result: tc.result?.content || '',
|
||||
is_error: tc.result?.is_error || false,
|
||||
})),
|
||||
})
|
||||
}
|
||||
setMessages(prev => [...prev, aiMsg])
|
||||
@@ -403,8 +592,9 @@ export default function Studio({ api }) {
|
||||
setStreamThinking('')
|
||||
setStreamToolCalls([])
|
||||
abortRef.current = null
|
||||
refreshTokens()
|
||||
}
|
||||
}, [input, loading, api, t, handleClear, streaming])
|
||||
}, [input, loading, api, t, handleClear, streaming, refreshTokens, handleSummarize])
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
if (abortRef.current) {
|
||||
@@ -412,11 +602,67 @@ export default function Studio({ api }) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const COMMANDS = ['/clear', '/summarize', '/help', '/plan', '/export', '/model', '/model change']
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
return
|
||||
}
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
const ta = textareaRef.current
|
||||
if (!ta) return
|
||||
if (document.activeElement !== ta) {
|
||||
ta.focus()
|
||||
return
|
||||
}
|
||||
const val = ta.value
|
||||
const pos = ta.selectionStart
|
||||
const before = val.slice(0, pos)
|
||||
const afterSlash = before.match(/\/[\w ]*$/)
|
||||
if (afterSlash) {
|
||||
const partial = afterSlash[0]
|
||||
const matches = COMMANDS.filter(c => c.startsWith(partial) && c !== partial)
|
||||
if (matches.length === 1) {
|
||||
const completed = matches[0] + ' '
|
||||
const newText = val.slice(0, pos - afterSlash[0].length) + completed + val.slice(pos)
|
||||
setInput(newText)
|
||||
requestAnimationFrame(() => {
|
||||
ta.selectionStart = ta.selectionEnd = pos - afterSlash[0].length + completed.length
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleCollapsed = useCallback(() => {
|
||||
setMessagesCollapsed(prev => !prev)
|
||||
}, [])
|
||||
|
||||
const renderMessages = () => {
|
||||
if (messagesCollapsed && messages.length > 4) {
|
||||
const visibleCount = 4
|
||||
const hiddenCount = messages.length - visibleCount
|
||||
return (
|
||||
<>
|
||||
{messages.slice(0, visibleCount).map(msg => (
|
||||
<FeedItem key={msg.id} msg={msg} />
|
||||
))}
|
||||
<div className="feed-collapsed-messages" onClick={handleToggleCollapsed}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
|
||||
</svg>
|
||||
<span className="feed-collapsed-text">{hiddenCount} messages antérieurs compressés</span>
|
||||
<span className="feed-collapsed-count">clic pour développer</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
return messages.map(msg => (
|
||||
<FeedItem key={msg.id} msg={msg} />
|
||||
))
|
||||
}
|
||||
|
||||
if (!loaded) {
|
||||
@@ -433,17 +679,43 @@ export default function Studio({ api }) {
|
||||
|
||||
return (
|
||||
<div className="studio-feed-layout">
|
||||
<div className="studio-feed">
|
||||
{messages.map(msg => (
|
||||
<FeedItem key={msg.id} msg={msg} />
|
||||
))}
|
||||
{(streaming || streamThinking || loading || streamToolCalls.length > 0) && (
|
||||
<StreamingItem content={streaming} thinking={streamThinking} toolCalls={streamToolCalls} />
|
||||
)}
|
||||
<div ref={messagesEnd} />
|
||||
<div className="studio-feed-scroll-wrap">
|
||||
<div className="studio-feed" ref={feedRef}>
|
||||
{renderMessages()}
|
||||
{(streaming || streamThinking || loading || streamToolCalls.length > 0) && (
|
||||
<StreamingItem content={streaming} thinking={streamThinking} toolCalls={streamToolCalls} />
|
||||
)}
|
||||
<div ref={messagesEnd} style={{ height: '24px' }} />
|
||||
</div>
|
||||
<div className="studio-scroll-btns">
|
||||
<button className="studio-scroll-btn" onClick={() => feedRef.current?.scrollTo({ top: 0, behavior: 'smooth' })} title="Remonter">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 15l-6-6-6 6"/></svg>
|
||||
</button>
|
||||
<button className="studio-scroll-btn" onClick={() => messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })} title="Descendre">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="studio-input-area">
|
||||
<div className={`studio-token-bar ${contextCollapsed === true ? 'compressed' : ''}`}>
|
||||
<div className={`studio-token-track ${contextCollapsed === true ? 'compressed' : ''}`}>
|
||||
<div
|
||||
className={`studio-token-fill ${tokenInfo.used >= tokenInfo.summarizeAt ? 'warn' : ''} ${contextCollapsed === true ? 'compressed' : ''} ${contextCollapsed === 'animating' ? 'animating' : ''}`}
|
||||
style={{ width: `${Math.min(100, (tokenInfo.used / tokenInfo.max) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`studio-token-text ${contextCollapsed === true ? 'compressed' : ''}`}>
|
||||
{(tokenInfo.used / 1000).toFixed(1)}k / {(tokenInfo.max / 1000).toFixed(0)}k tokens
|
||||
{contextCollapsed === true && ' · compressé'}
|
||||
{tokenInfo.used >= tokenInfo.summarizeAt && contextCollapsed !== true && ' · résumé auto.'}
|
||||
</span>
|
||||
{contextCollapsed === true && (
|
||||
<button className="ghost sm" onClick={handleToggleCollapsed} style={{ marginLeft: '8px', fontSize: '10px' }}>
|
||||
voir plus
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="studio-input-row">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
@@ -472,7 +744,7 @@ export default function Studio({ api }) {
|
||||
)}
|
||||
</div>
|
||||
<div className="studio-input-hint">
|
||||
{t('studio.inputHint')} · /clear
|
||||
{t('studio.inputHint')} · /clear /summarize /help /plan /export /model /model change
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -182,6 +182,8 @@ const en = {
|
||||
installed: 'Installed',
|
||||
missing: 'Missing',
|
||||
editProfile: 'Edit',
|
||||
profileInfo: 'Personal Info',
|
||||
profilePrefs: 'Preferences',
|
||||
cancel: 'Cancel',
|
||||
editProvider: 'Configure',
|
||||
validateKey: 'Validate',
|
||||
|
||||
@@ -136,7 +136,7 @@ const fr = {
|
||||
terminal: 'Terminal',
|
||||
updates: 'Mises \u00e0 jour',
|
||||
locale: 'Langue & Clavier',
|
||||
skills: 'Comp\u00e9ENCES',
|
||||
skills: 'Compétences',
|
||||
system: 'Syst\u00e8me',
|
||||
},
|
||||
profile: 'Profil',
|
||||
@@ -160,7 +160,7 @@ const fr = {
|
||||
save: 'Enregistrer',
|
||||
saved: 'Enregistr\u00e9 !',
|
||||
error: 'Erreur',
|
||||
skills: 'Comp\u00e9ENCES',
|
||||
skills: 'Compétences',
|
||||
noSkills: 'Aucune comp\u00e9tence install\u00e9e.',
|
||||
runSkillsInit: 'Ex\u00e9cutez muyue skills init',
|
||||
language: 'Langue',
|
||||
@@ -182,6 +182,8 @@ const fr = {
|
||||
installed: 'Install\u00e9',
|
||||
missing: 'Manquant',
|
||||
editProfile: 'Modifier',
|
||||
profileInfo: 'Informations personnelles',
|
||||
profilePrefs: 'Préférences',
|
||||
editProvider: 'Configurer',
|
||||
validateKey: 'Valider',
|
||||
validating: 'V\u00e9rification...',
|
||||
|
||||
@@ -154,7 +154,9 @@ input::placeholder { color: var(--text-disabled); }
|
||||
|
||||
.header-clock { font-family: var(--font-mono); font-size: 12px; color: var(--accent); font-weight: 600; }
|
||||
|
||||
.content { flex: 1; overflow: hidden; }
|
||||
.content { flex: 1; overflow: hidden; position: relative; }
|
||||
.content > div { height: 100%; }
|
||||
.tab-hidden { display: none; }
|
||||
|
||||
.statusbar {
|
||||
height: 28px;
|
||||
@@ -169,6 +171,12 @@ input::placeholder { color: var(--text-disabled); }
|
||||
color: var(--text-disabled);
|
||||
}
|
||||
.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 kbd {
|
||||
display: inline-block; padding: 1px 5px; border-radius: 3px;
|
||||
@@ -374,23 +382,47 @@ input::placeholder { color: var(--text-disabled); }
|
||||
}
|
||||
.shell-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; }
|
||||
|
||||
.shell-xterm-wrapper { flex: 1; background: var(--bg); overflow: hidden; position: relative; }
|
||||
.shell-xterm-wrapper { flex: 1; height: 100%; background: var(--bg); overflow: hidden; position: relative; }
|
||||
.shell-xterm-instance {
|
||||
position: absolute; inset: 0; padding: 4px;
|
||||
display: block !important;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.shell-xterm-instance .xterm { height: 100%; padding: 4px; }
|
||||
.shell-xterm-instance.active {
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.shell-xterm-instance .xterm { height: 100%; }
|
||||
|
||||
.connection-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
||||
.connection-dot.on { background: var(--success); box-shadow: 0 0 6px var(--success); }
|
||||
.connection-dot.off { background: var(--error); }
|
||||
|
||||
.shell-tab.ai-tab .shell-tab-name { color: var(--accent); }
|
||||
.shell-tab.ai-tab { border-bottom-color: var(--accent); }
|
||||
|
||||
.shell-ai-col { width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; }
|
||||
.ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); }
|
||||
.ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); display: flex; align-items: center; justify-content: space-between; }
|
||||
.shell-analyze-btn {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
padding: 4px 10px; border-radius: var(--radius);
|
||||
background: transparent; border: 1px solid var(--accent-dim);
|
||||
color: var(--accent); font-size: 11px; font-weight: 600;
|
||||
cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.shell-analyze-btn:hover:not(:disabled) { background: var(--accent-bg); }
|
||||
.shell-analyze-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.shell-ai-token-bar { display: flex; align-items: center; gap: 8px; padding: 6px 12px; border-bottom: 1px solid var(--border); }
|
||||
.shell-ai-token-track { flex: 1; height: 3px; background: var(--bg-input); border-radius: 2px; overflow: hidden; }
|
||||
.shell-ai-token-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.4s, background 0.3s; }
|
||||
.shell-ai-token-fill.warn { background: var(--warning); }
|
||||
.shell-ai-token-text { font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); white-space: nowrap; }
|
||||
.ai-panel-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.ai-message { padding: 8px 12px; border-radius: var(--radius); font-size: 13px; line-height: 1.5; word-break: break-word; }
|
||||
.ai-message.ai { background: var(--bg-card); border-left: 3px solid var(--accent); }
|
||||
.ai-message.user { background: var(--accent-bg); border-left: 3px solid var(--accent-muted); }
|
||||
.ai-message.assistant { background: var(--bg-card); border-left: 3px solid var(--accent); }
|
||||
.ai-message.system { background: var(--bg-elevated); border-left: 3px solid var(--info); font-style: italic; color: var(--text-tertiary); font-size: 12px; }
|
||||
.ai-message.assistant { background: var(--bg-card); border-left: 3px solid var(--accent); }
|
||||
.ai-message.thinking { background: var(--bg-elevated); border-left: 3px solid var(--info); font-style: italic; color: var(--text-tertiary); }
|
||||
.ai-message.tool { background: var(--bg-elevated); border-left: 3px solid var(--warning); }
|
||||
.ai-message.tool .tool-name { font-weight: 700; color: var(--warning); }
|
||||
@@ -398,6 +430,46 @@ input::placeholder { color: var(--text-disabled); }
|
||||
.ai-panel-input { display: flex; gap: 6px; padding: 10px 12px; border-top: 1px solid var(--border); }
|
||||
.ai-panel-input input { flex: 1; font-size: 13px; padding: 6px 10px; }
|
||||
|
||||
.shell-code-block {
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
margin: 8px 0 4px; overflow: hidden;
|
||||
}
|
||||
.shell-code-block pre {
|
||||
padding: 10px 12px; font-family: var(--font-mono); font-size: 12px; line-height: 1.5;
|
||||
overflow-x: auto; color: var(--text-primary); margin: 0;
|
||||
}
|
||||
.shell-code-lang {
|
||||
padding: 3px 10px; font-size: 10px; font-weight: 600; color: var(--text-tertiary);
|
||||
background: var(--bg-surface); border-bottom: 1px solid var(--border);
|
||||
text-transform: uppercase; letter-spacing: 0.5px;
|
||||
}
|
||||
.shell-code-actions {
|
||||
display: flex; border-top: 1px solid var(--border); background: var(--bg-surface);
|
||||
}
|
||||
.shell-code-actions button {
|
||||
flex: 1; display: flex; align-items: center; justify-content: center; gap: 4px;
|
||||
padding: 5px 0; background: transparent; border: none; border-right: 1px solid var(--border);
|
||||
color: var(--text-tertiary); font-size: 11px; cursor: pointer; transition: all 0.1s;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.shell-code-actions button:last-child { border-right: none; }
|
||||
.shell-code-actions button:hover { background: var(--accent-bg); color: var(--accent); }
|
||||
|
||||
.shell-analysis-modal {
|
||||
background: var(--bg-elevated); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg); width: 720px; max-width: 90vw; max-height: 80vh;
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
.shell-analysis-modal-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 20px; border-bottom: 1px solid var(--border);
|
||||
font-weight: 700; font-size: 15px; color: var(--accent);
|
||||
}
|
||||
.shell-analysis-modal-body {
|
||||
flex: 1; overflow-y: auto; padding: 20px; font-size: 14px; line-height: 1.5;
|
||||
color: var(--text-primary); word-break: break-word;
|
||||
}
|
||||
|
||||
.shell-modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 1000;
|
||||
@@ -423,12 +495,16 @@ input::placeholder { color: var(--text-disabled); }
|
||||
.config-window { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||
|
||||
.config-tabs-bar {
|
||||
display: flex; gap: 4px; padding: 12px 20px 0; background: var(--bg-surface);
|
||||
display: flex; gap: 4px; padding: 12px 20px; background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border); flex-shrink: 0;
|
||||
}
|
||||
|
||||
.config-panel-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.config-panel-body { flex: 1; overflow-y: auto; padding: 16px 28px 28px; }
|
||||
.config-profile-center {
|
||||
max-width: 540px; margin: 0 auto; width: 100%;
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
}
|
||||
|
||||
.config-card {
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
@@ -470,6 +546,9 @@ input::placeholder { color: var(--text-disabled); }
|
||||
.provider-card-name { font-weight: 600; color: var(--text-primary); font-size: 14px; }
|
||||
.provider-card-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
.provider-card-meta { display: flex; gap: 16px; font-size: 12px; color: var(--text-tertiary); font-family: var(--font-mono); margin-top: 8px; }
|
||||
.provider-card-model { display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border); }
|
||||
.provider-card-model-label { font-size: 11px; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.provider-card-model-value { font-size: 14px; font-weight: 600; font-family: var(--font-mono); color: var(--accent); }
|
||||
.provider-card-form { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
|
||||
|
||||
.provider-setup-hint {
|
||||
@@ -494,10 +573,24 @@ input::placeholder { color: var(--text-disabled); }
|
||||
.config-update-name { color: var(--text-primary); font-weight: 600; font-size: 13px; min-width: 100px; }
|
||||
.config-update-versions { color: var(--text-tertiary); font-size: 12px; font-family: var(--font-mono); }
|
||||
|
||||
.config-skill-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
|
||||
.config-skill-row:last-child { border-bottom: none; }
|
||||
.config-skill-name { color: var(--text-primary); font-weight: 600; min-width: 120px; }
|
||||
.config-skill-desc { color: var(--text-tertiary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.skill-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
|
||||
.skill-tile { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px; cursor: pointer; transition: border-color 0.15s; }
|
||||
.skill-tile:hover { border-color: var(--accent-dim); }
|
||||
.skill-tile-name { font-weight: 600; color: var(--text-primary); font-size: 14px; margin-bottom: 6px; }
|
||||
.skill-tile-desc { font-size: 12px; color: var(--text-tertiary); overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
.skill-tile-tags { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
|
||||
.skill-detail-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 50; display: flex; align-items: center; justify-content: center; }
|
||||
.skill-detail-panel { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius-lg); width: 90%; max-width: 600px; max-height: 80vh; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.skill-detail-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--border); }
|
||||
.skill-detail-name { font-weight: 600; font-size: 16px; color: var(--text-primary); }
|
||||
.skill-detail-body { flex: 1; overflow-y: auto; padding: 20px; }
|
||||
.skill-detail-section { margin-bottom: 16px; }
|
||||
.skill-detail-label { font-size: 11px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
|
||||
.skill-detail-meta { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.skill-detail-content { font-family: var(--font-mono); font-size: 12px; color: var(--text-secondary); white-space: pre-wrap; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; line-height: 1.6; max-height: 300px; overflow-y: auto; }
|
||||
.skill-detail-deps { display: flex; flex-direction: column; gap: 6px; }
|
||||
.skill-detail-dep { font-size: 12px; color: var(--text-tertiary); display: flex; align-items: center; gap: 8px; }
|
||||
.skill-detail-dep .badge { font-size: 10px; }
|
||||
.chip-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
.config-toast {
|
||||
@@ -525,10 +618,151 @@ 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; }
|
||||
|
||||
/* ── Dashboard Grid ── */
|
||||
.dash-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: repeat(2, 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; justify-content: center; 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); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.dash-proc-res {
|
||||
font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Commands */
|
||||
.dash-cmd-card .dash-cmd-list { max-height: 220px; }
|
||||
.dash-cmd-list { display: flex; flex-direction: column; gap: 2px; overflow-y: auto; }
|
||||
.dash-cmd-row {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
padding: 5px 8px; border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface); cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.dash-cmd-row:hover { background: var(--accent-bg); }
|
||||
.dash-cmd-left { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
||||
.dash-cmd-text {
|
||||
font-size: 11px; font-family: var(--font-mono); color: var(--text-primary);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.dash-cmd-time { font-size: 9px; color: var(--text-disabled); }
|
||||
.dash-cmd-copy { font-size: 13px; color: var(--text-disabled); flex-shrink: 0; }
|
||||
.dash-cmd-row:hover .dash-cmd-copy { color: var(--accent); }
|
||||
|
||||
.dash-cmd-freq { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid var(--border); }
|
||||
.dash-cmd-freq-title { font-size: 10px; font-weight: 700; text-transform: uppercase; color: var(--text-disabled); letter-spacing: 0.05em; margin-bottom: 2px; }
|
||||
.dash-cmd-freq-row {
|
||||
display: flex; align-items: center; gap: 8px; cursor: pointer;
|
||||
padding: 3px 4px; border-radius: var(--radius-sm);
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.dash-cmd-freq-row:hover { background: var(--accent-bg); }
|
||||
.dash-cmd-freq-name { font-size: 12px; font-weight: 600; font-family: var(--font-mono); color: var(--text-primary); width: 100px; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dash-cmd-freq-bar-wrap { flex: 1; height: 6px; background: var(--bg-input); border-radius: 3px; overflow: hidden; }
|
||||
.dash-cmd-freq-bar { height: 100%; background: var(--accent); border-radius: 3px; transition: width 0.3s ease; }
|
||||
.dash-cmd-freq-count { font-size: 10px; font-family: var(--font-mono); color: var(--accent); width: 28px; text-align: right; flex-shrink: 0; }
|
||||
|
||||
.dash-cmd-top { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
|
||||
|
||||
/* 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-content { flex: 1; overflow-y: auto; }
|
||||
.dashboard-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; padding: 24px; }
|
||||
|
||||
.dashboard-section {
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg); padding: 20px; transition: border-color 0.2s;
|
||||
@@ -540,11 +774,8 @@ input::placeholder { color: var(--text-disabled); }
|
||||
font-size: 12px; font-weight: 700; color: var(--accent); text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.dashboard-workflows-inline { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||
|
||||
.dashboard-notifications-inline { display: flex; flex-direction: column; gap: 2px; }
|
||||
|
||||
.dashboard-notifications { padding: 0; }
|
||||
.notif-row {
|
||||
display: flex; align-items: flex-start; gap: 12px;
|
||||
@@ -557,7 +788,6 @@ input::placeholder { color: var(--text-disabled); }
|
||||
.notif-ok .notif-text { color: var(--success); }
|
||||
.notif-warn .notif-text { color: var(--warning); }
|
||||
.notif-error .notif-text { color: var(--error); }
|
||||
|
||||
.dashboard-workflows { padding: 0; display: flex; flex-direction: column; gap: 24px; }
|
||||
.workflow-section { }
|
||||
.section-label {
|
||||
@@ -565,81 +795,6 @@ input::placeholder { color: var(--text-disabled); }
|
||||
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 {
|
||||
display: flex; align-items: center; justify-content: space-between; padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--border); background: var(--bg-surface);
|
||||
@@ -654,7 +809,17 @@ input::placeholder { color: var(--text-disabled); }
|
||||
|
||||
/* ── Studio Feed ── */
|
||||
.studio-feed-layout { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||
.studio-feed { flex: 1; overflow-y: auto; padding: 20px 24px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.studio-feed-scroll-wrap { flex: 1; position: relative; overflow: hidden; }
|
||||
.studio-feed { height: 100%; overflow-y: auto; padding: 20px 24px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.studio-scroll-btns { position: absolute; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 4px; z-index: 10; }
|
||||
.studio-scroll-btn {
|
||||
width: 32px; height: 32px; border-radius: 50%; padding: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
color: var(--text-tertiary); cursor: pointer; transition: all 0.15s;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.studio-scroll-btn:hover { background: var(--accent-bg); color: var(--accent); border-color: var(--accent-dim); opacity: 1; }
|
||||
.feed-loading { display: flex; align-items: center; justify-content: center; padding: 60px 0; }
|
||||
.feed-item { display: flex; gap: 10px; padding: 8px 12px; border-radius: var(--radius); animation: fadeIn 0.15s ease-out; }
|
||||
.feed-item:hover { background: var(--bg-card); }
|
||||
@@ -676,14 +841,28 @@ input::placeholder { color: var(--text-disabled); }
|
||||
}
|
||||
.feed-role { font-size: 11px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.feed-time { font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono); }
|
||||
.feed-content { font-size: 14px; line-height: 1.7; color: var(--text-primary); word-break: break-word; }
|
||||
.feed-content { font-size: 14px; line-height: 1.5; color: var(--text-primary); word-break: break-word; }
|
||||
.feed-system-badge { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-dim); flex-shrink: 0; }
|
||||
.feed-system-text { font-size: 12px; color: var(--text-tertiary); font-style: italic; flex: 1; }
|
||||
.feed-system-text.compressed { color: var(--accent); font-style: normal; }
|
||||
.feed-compressed-indicator {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 10px 12px; margin: 4px 0;
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.feed-compressed-indicator:hover { background: var(--bg-hover); border-color: var(--accent-dim); }
|
||||
.feed-compressed-indicator svg { color: var(--accent); flex-shrink: 0; }
|
||||
.feed-compressed-text { font-size: 12px; color: var(--text-tertiary); flex: 1; }
|
||||
.feed-compressed-count { font-size: 11px; color: var(--text-disabled); font-family: var(--font-mono); }
|
||||
|
||||
.feed-thinking-block {
|
||||
background: var(--bg-surface); border: 1px solid var(--border); border-left: 2px solid var(--accent-dim);
|
||||
border-radius: var(--radius); margin: 6px 0 8px; overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.feed-thinking-block.active {
|
||||
border-left-color: var(--warning);
|
||||
@@ -722,11 +901,11 @@ input::placeholder { color: var(--text-disabled); }
|
||||
background: var(--bg-surface); border-bottom: 1px solid var(--border); text-transform: uppercase; letter-spacing: 0.5px;
|
||||
}
|
||||
.inline-code { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 13px; color: var(--accent-muted); }
|
||||
.msg-h3 { font-size: 16px; font-weight: 700; color: var(--text-primary); margin: 16px 0 8px; display: block; }
|
||||
.msg-h4 { font-size: 14px; font-weight: 700; color: var(--text-secondary); margin: 12px 0 6px; display: block; }
|
||||
.msg-bullet { display: block; padding-left: 16px; position: relative; margin: 2px 0; }
|
||||
.msg-h3 { font-size: 16px; font-weight: 700; color: var(--text-primary); margin: 10px 0 4px; display: block; }
|
||||
.msg-h4 { font-size: 14px; font-weight: 700; color: var(--text-secondary); margin: 8px 0 3px; display: block; }
|
||||
.msg-bullet { display: block; padding-left: 16px; position: relative; margin: 1px 0; }
|
||||
.msg-bullet::before { content: '\2022'; position: absolute; left: 4px; color: var(--accent); }
|
||||
.msg-step { display: flex; gap: 8px; align-items: baseline; margin: 3px 0; }
|
||||
.msg-step { display: flex; gap: 8px; align-items: baseline; margin: 1px 0; }
|
||||
.msg-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); font-size: 13px; flex-shrink: 0; min-width: 20px; }
|
||||
.studio-cursor { display: inline-block; width: 8px; height: 16px; background: var(--accent); margin-left: 2px; vertical-align: text-bottom; animation: blink 0.8s step-end infinite; }
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
@@ -736,6 +915,22 @@ input::placeholder { color: var(--text-disabled); }
|
||||
.studio-thinking span:nth-child(3) { animation-delay: 0.3s; }
|
||||
@keyframes bounce { 0%, 80%, 100% { transform: translateY(0); opacity: 0.4; } 40% { transform: translateY(-6px); opacity: 1; } }
|
||||
.studio-input-area { padding: 12px 20px 8px; border-top: 1px solid var(--border); background: var(--bg-surface); }
|
||||
.studio-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-fill.compressed { height: 2px; }
|
||||
.studio-token-fill.animating { animation: compress-pulse 0.6s ease-in-out; }
|
||||
@keyframes compress-pulse {
|
||||
0% { height: 3px; opacity: 1; }
|
||||
50% { height: 5px; opacity: 0.8; background: var(--accent-light); }
|
||||
100% { height: 2px; opacity: 1; }
|
||||
}
|
||||
.studio-token-text { font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); white-space: nowrap; }
|
||||
.studio-token-text.compressed { font-size: 9px; }
|
||||
.studio-token-track.compressed { height: 2px; }
|
||||
.studio-token-bar.compressed { margin-bottom: 4px; }
|
||||
|
||||
.studio-input-row { display: flex; gap: 8px; align-items: flex-end; }
|
||||
.studio-input-row textarea {
|
||||
flex: 1; resize: none; min-height: 42px; max-height: 200px; padding: 10px 14px;
|
||||
@@ -760,6 +955,21 @@ input::placeholder { color: var(--text-disabled); }
|
||||
.studio-stop-btn:hover { opacity: 0.8; }
|
||||
.studio-input-hint { font-size: 11px; color: var(--text-disabled); text-align: center; margin-top: 6px; }
|
||||
|
||||
/* ── Collapsed Messages ── */
|
||||
.feed-collapsed-messages {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 16px; margin: 4px 0;
|
||||
background: linear-gradient(135deg, var(--bg-surface), var(--bg-elevated));
|
||||
border: 1px dashed var(--border-accent);
|
||||
border-radius: var(--radius); cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.feed-collapsed-messages:hover { background: var(--bg-hover); border-color: var(--accent); }
|
||||
.feed-collapsed-messages svg { color: var(--accent); flex-shrink: 0; }
|
||||
.feed-collapsed-text { font-size: 11px; color: var(--text-tertiary); flex: 1; }
|
||||
.feed-collapsed-count { font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono); }
|
||||
.feed-expanded-messages { animation: fadeIn 0.2s ease-out; }
|
||||
|
||||
/* ── Studio Tool Blocks ── */
|
||||
.studio-tool-block {
|
||||
background: var(--bg-surface);
|
||||
@@ -826,7 +1036,8 @@ input::placeholder { color: var(--text-disabled); }
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
||||
Reference in New Issue
Block a user