fix: token persistence, context windows, CSS tables/bullets/hr, image attachments
All checks were successful
Beta Release / beta (push) Successful in 1m1s
All checks were successful
Beta Release / beta (push) Successful in 1m1s
- Fix token count reset on app restart: persist realTokens in conversation.json - Fix token/context window values: Studio 150K (summarize at 120K), Terminal 100K - Fix table rendering in terminal tab: correct thead/tbody display model - Fix copy button always top-right in Studio code blocks - Add markdown horizontal rule (---) support in Studio and Terminal - Fix bullet list double dot: remove CSS ::before duplicate bullet point - Add image attachments support (VLM description, file mentions @file.ext) - Add sudo detection with cache (sync.Once) - Fix message content serialization (TextContent wrapper) - Guide AI to use read_file instead of cat in studio prompt 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush <crush@charm.land>
This commit is contained in:
1073
CRUSH_ARCHITECTURE_REPORT.md
Normal file
1073
CRUSH_ARCHITECTURE_REPORT.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,9 +7,32 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
sudoCache bool
|
||||||
|
sudoCacheSet bool
|
||||||
|
sudoCacheOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
func NeedsSudoPassword() bool {
|
||||||
|
sudoCacheOnce.Do(func() {
|
||||||
|
if os.Geteuid() == 0 {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
err := exec.CommandContext(ctx, "sudo", "-n", "true").Run()
|
||||||
|
sudoCacheSet = true
|
||||||
|
sudoCache = err != nil
|
||||||
|
} else {
|
||||||
|
sudoCache = true
|
||||||
|
sudoCacheSet = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return sudoCache
|
||||||
|
}
|
||||||
|
|
||||||
type TerminalParams struct {
|
type TerminalParams struct {
|
||||||
Command string `json:"command" description:"The shell command to execute"`
|
Command string `json:"command" description:"The shell command to execute"`
|
||||||
Timeout int `json:"timeout,omitempty" description:"Timeout in seconds (default 60, max 300)"`
|
Timeout int `json:"timeout,omitempty" description:"Timeout in seconds (default 60, max 300)"`
|
||||||
@@ -30,7 +53,7 @@ func NewTerminalTool() (*ToolDefinition, error) {
|
|||||||
return TextErrorResponse("command is required"), nil
|
return TextErrorResponse("command is required"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if os.Geteuid() != 0 {
|
if NeedsSudoPassword() {
|
||||||
trimmed := strings.TrimSpace(p.Command)
|
trimmed := strings.TrimSpace(p.Command)
|
||||||
lower := strings.ToLower(trimmed)
|
lower := strings.ToLower(trimmed)
|
||||||
if strings.HasPrefix(lower, "sudo ") || strings.HasPrefix(lower, "doas ") || strings.HasPrefix(lower, "run0 ") || strings.HasPrefix(lower, "pkexec ") {
|
if strings.HasPrefix(lower, "sudo ") || strings.HasPrefix(lower, "doas ") || strings.HasPrefix(lower, "run0 ") || strings.HasPrefix(lower, "pkexec ") {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ Muyue gère :
|
|||||||
<tool_strategy>
|
<tool_strategy>
|
||||||
- **Recherche avant action** — Utilise `search_files`, `grep_content`, `read_file` avant de supposer quoi que ce soit sur l'état du système
|
- **Recherche avant action** — Utilise `search_files`, `grep_content`, `read_file` avant de supposer quoi que ce soit sur l'état du système
|
||||||
- **Délégation intelligente** — Pour les tâches complexes (refactoring, création de fichiers, debug multi-fichiers), utilise `crush_run` au lieu d'enchaîner des commandes terminal
|
- **Délégation intelligente** — Pour les tâches complexes (refactoring, création de fichiers, debug multi-fichiers), utilise `crush_run` au lieu d'enchaîner des commandes terminal
|
||||||
|
- **Lecture de fichiers** — Utilise TOUJOURS `read_file` pour lire le contenu d'un fichier. N'utilise PAS `terminal` avec `cat` pour lire des fichiers — `read_file` est plus rapide, plus précis, et consomme moins de tokens
|
||||||
- **Parallélisme** — Lance plusieurs appels d'outils en parallèle quand les opérations sont indépendantes
|
- **Parallélisme** — Lance plusieurs appels d'outils en parallèle quand les opérations sont indépendantes
|
||||||
- **Troncature** — Si un résultat d'outil dépasse 2000 caractères, résume les points clés au lieu de tout afficher
|
- **Troncature** — Si un résultat d'outil dépasse 2000 caractères, résume les points clés au lieu de tout afficher
|
||||||
- **Une chose à la fois** — Sauf si les opérations sont indépendantes, exécute séquentiellement
|
- **Une chose à la fois** — Sauf si les opérations sont indépendantes, exécute séquentiellement
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
|
|||||||
|
|
||||||
assistantMsg := orchestrator.Message{
|
assistantMsg := orchestrator.Message{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: content,
|
Content: orchestrator.TextContent(content),
|
||||||
ToolCalls: choice.Message.ToolCalls,
|
ToolCalls: choice.Message.ToolCalls,
|
||||||
}
|
}
|
||||||
messages = append(messages, assistantMsg)
|
messages = append(messages, assistantMsg)
|
||||||
@@ -147,7 +147,7 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
|
|||||||
|
|
||||||
messages = append(messages, orchestrator.Message{
|
messages = append(messages, orchestrator.Message{
|
||||||
Role: "tool",
|
Role: "tool",
|
||||||
Content: result.Content,
|
Content: orchestrator.TextContent(result.Content),
|
||||||
ToolCallID: tc.ID,
|
ToolCallID: tc.ID,
|
||||||
Name: tc.Function.Name,
|
Name: tc.Function.Name,
|
||||||
})
|
})
|
||||||
@@ -191,7 +191,7 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.
|
|||||||
|
|
||||||
assistantMsg := orchestrator.Message{
|
assistantMsg := orchestrator.Message{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: content,
|
Content: orchestrator.TextContent(content),
|
||||||
ToolCalls: choice.Message.ToolCalls,
|
ToolCalls: choice.Message.ToolCalls,
|
||||||
}
|
}
|
||||||
messages = append(messages, assistantMsg)
|
messages = append(messages, assistantMsg)
|
||||||
@@ -213,7 +213,7 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.
|
|||||||
|
|
||||||
messages = append(messages, orchestrator.Message{
|
messages = append(messages, orchestrator.Message{
|
||||||
Role: "tool",
|
Role: "tool",
|
||||||
Content: result.Content,
|
Content: orchestrator.TextContent(result.Content),
|
||||||
ToolCallID: tc.ID,
|
ToolCallID: tc.ID,
|
||||||
Name: tc.Function.Name,
|
Name: tc.Function.Name,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,22 +13,24 @@ import (
|
|||||||
"github.com/muyue/muyue/internal/config"
|
"github.com/muyue/muyue/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxTokensApprox = 100000
|
const contextWindowTokens = 150000
|
||||||
const summarizeThreshold = 80000
|
const summarizeRatio = 0.80
|
||||||
const charsPerToken = 4
|
const charsPerToken = 4
|
||||||
|
|
||||||
type FeedMessage struct {
|
type FeedMessage struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Time string `json:"time"`
|
Time string `json:"time"`
|
||||||
|
Images []string `json:"images,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Conversation struct {
|
type Conversation struct {
|
||||||
Messages []FeedMessage `json:"messages"`
|
Messages []FeedMessage `json:"messages"`
|
||||||
Summary string `json:"summary,omitempty"`
|
Summary string `json:"summary,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
RealTokens int `json:"real_tokens,omitempty"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ConversationStore struct {
|
type ConversationStore struct {
|
||||||
@@ -85,6 +87,7 @@ func (cs *ConversationStore) load() {
|
|||||||
conv.Messages = []FeedMessage{}
|
conv.Messages = []FeedMessage{}
|
||||||
}
|
}
|
||||||
cs.conv = &conv
|
cs.conv = &conv
|
||||||
|
cs.realTokens = conv.RealTokens
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cs *ConversationStore) save() error {
|
func (cs *ConversationStore) save() error {
|
||||||
@@ -127,15 +130,40 @@ func (cs *ConversationStore) Add(role, content string) FeedMessage {
|
|||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cs *ConversationStore) AddWithImages(role, content string, imageIDs []string) FeedMessage {
|
||||||
|
cs.mu.Lock()
|
||||||
|
defer cs.mu.Unlock()
|
||||||
|
|
||||||
|
msg := FeedMessage{
|
||||||
|
ID: generateMsgID(),
|
||||||
|
Role: role,
|
||||||
|
Content: content,
|
||||||
|
Time: time.Now().Format(time.RFC3339),
|
||||||
|
Images: imageIDs,
|
||||||
|
}
|
||||||
|
cs.conv.Messages = append(cs.conv.Messages, msg)
|
||||||
|
cs.save()
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
func (cs *ConversationStore) Clear() {
|
func (cs *ConversationStore) Clear() {
|
||||||
cs.mu.Lock()
|
cs.mu.Lock()
|
||||||
defer cs.mu.Unlock()
|
defer cs.mu.Unlock()
|
||||||
|
|
||||||
|
var imageIDs []string
|
||||||
|
for _, m := range cs.conv.Messages {
|
||||||
|
imageIDs = append(imageIDs, m.Images...)
|
||||||
|
}
|
||||||
|
|
||||||
cs.conv.Messages = []FeedMessage{}
|
cs.conv.Messages = []FeedMessage{}
|
||||||
cs.conv.Summary = ""
|
cs.conv.Summary = ""
|
||||||
|
cs.conv.RealTokens = 0
|
||||||
cs.conv.CreatedAt = time.Now().Format(time.RFC3339)
|
cs.conv.CreatedAt = time.Now().Format(time.RFC3339)
|
||||||
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
|
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||||
cs.realTokens = 0
|
cs.realTokens = 0
|
||||||
cs.save()
|
cs.save()
|
||||||
|
|
||||||
|
go cleanupImages(imageIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cs *ConversationStore) SetSummary(summary string) {
|
func (cs *ConversationStore) SetSummary(summary string) {
|
||||||
@@ -169,6 +197,7 @@ func (cs *ConversationStore) AddRealTokens(tokens int) {
|
|||||||
}
|
}
|
||||||
cs.mu.Lock()
|
cs.mu.Lock()
|
||||||
cs.realTokens += tokens
|
cs.realTokens += tokens
|
||||||
|
cs.conv.RealTokens = cs.realTokens
|
||||||
cs.mu.Unlock()
|
cs.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +225,7 @@ func (cs *ConversationStore) ApproxTokenCountDetailed() TokenCount {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (cs *ConversationStore) NeedsSummarization() bool {
|
func (cs *ConversationStore) NeedsSummarization() bool {
|
||||||
return cs.ApproxTokenCount() > summarizeThreshold
|
return cs.ApproxTokenCount() > int(float64(contextWindowTokens)*summarizeRatio)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cs *ConversationStore) Search(query string) []SearchResult {
|
func (cs *ConversationStore) Search(query string) []SearchResult {
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -15,6 +19,114 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var thinkingTagRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
|
var thinkingTagRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
|
||||||
|
var fileMentionRegex = regexp.MustCompile(`@(\S+\.[a-zA-Z0-9]+)`)
|
||||||
|
|
||||||
|
type ImageAttachment struct {
|
||||||
|
Data string `json:"data"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveFileMentions(text string) string {
|
||||||
|
return fileMentionRegex.ReplaceAllStringFunc(text, func(match string) string {
|
||||||
|
filePath := match[1:]
|
||||||
|
if strings.HasPrefix(filePath, "~/") {
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
filePath = filepath.Join(home, filePath[2:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(filePath) {
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
filePath = filepath.Join(home, filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return match + fmt.Sprintf(" (erreur: fichier non trouve)")
|
||||||
|
}
|
||||||
|
content := string(data)
|
||||||
|
if len(content) > 50000 {
|
||||||
|
content = content[:50000] + "\n... (tronque a 50Ko)"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("[Fichier: %s]\n%s\n[Fin du fichier: %s]", filepath.Base(filePath), content, filepath.Base(filePath))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var vlmClient = &http.Client{Timeout: 60 * time.Second}
|
||||||
|
|
||||||
|
func (s *Server) describeImages(images []ImageAttachment) []string {
|
||||||
|
var apiKey string
|
||||||
|
for i := range s.config.AI.Providers {
|
||||||
|
if s.config.AI.Providers[i].Active {
|
||||||
|
apiKey = s.config.AI.Providers[i].APIKey
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if apiKey == "" {
|
||||||
|
log.Printf("[vlm] no API key found for image description")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
descriptions := make([]string, 0, len(images))
|
||||||
|
for i, img := range images {
|
||||||
|
desc, err := s.callVLM(apiKey, img)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[vlm] image %d (%s) failed: %v", i+1, img.Filename, err)
|
||||||
|
descriptions = append(descriptions, fmt.Sprintf("(description unavailable: %v)", err))
|
||||||
|
} else {
|
||||||
|
descriptions = append(descriptions, desc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return descriptions
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) callVLM(apiKey string, img ImageAttachment) (string, error) {
|
||||||
|
payload := map[string]string{
|
||||||
|
"prompt": "Describe this image in detail. Include all text, UI elements, code, diagrams, or data visible. Be thorough and specific.",
|
||||||
|
"image_url": img.Data,
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("marshal vlm request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 55*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", "https://api.minimax.io/v1/coding_plan/vlm", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create vlm request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
|
||||||
|
resp, err := vlmClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("vlm request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read vlm response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("vlm API error (%d): %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||||
|
return "", fmt.Errorf("parse vlm response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Content == "" {
|
||||||
|
return "(empty description)", nil
|
||||||
|
}
|
||||||
|
return result.Content, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != "POST" {
|
if r.Method != "POST" {
|
||||||
@@ -22,8 +134,9 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var body struct {
|
var body struct {
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Stream bool `json:"stream"`
|
Stream bool `json:"stream"`
|
||||||
|
Images []ImageAttachment `json:"images"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
writeError(w, err.Error(), http.StatusBadRequest)
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
@@ -33,8 +146,44 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, "no message", http.StatusMethodNotAllowed)
|
writeError(w, "no message", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if len(body.Images) > 3 {
|
||||||
|
writeError(w, "max 3 images", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
s.convStore.Add("user", body.Message)
|
enrichedMessage := resolveFileMentions(body.Message)
|
||||||
|
|
||||||
|
var imageIDs []string
|
||||||
|
if len(body.Images) > 0 {
|
||||||
|
descriptions := s.describeImages(body.Images)
|
||||||
|
var imgContext strings.Builder
|
||||||
|
for i, desc := range descriptions {
|
||||||
|
imgContext.WriteString(fmt.Sprintf("\n[Image %d (%s): %s]\n", i+1, body.Images[i].Filename, desc))
|
||||||
|
|
||||||
|
id, err := saveImage(body.Images[i].Data, body.Images[i].Filename, body.Images[i].MimeType)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[images] failed to save %s: %v", body.Images[i].Filename, err)
|
||||||
|
} else {
|
||||||
|
imageIDs = append(imageIDs, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enrichedMessage = imgContext.String() + enrichedMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
displayMsg := body.Message
|
||||||
|
if len(body.Images) > 0 {
|
||||||
|
imgNames := make([]string, len(body.Images))
|
||||||
|
for i, img := range body.Images {
|
||||||
|
imgNames[i] = img.Filename
|
||||||
|
}
|
||||||
|
displayMsg += " [" + strings.Join(imgNames, ", ") + "]"
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(imageIDs) > 0 {
|
||||||
|
s.convStore.AddWithImages("user", displayMsg, imageIDs)
|
||||||
|
} else {
|
||||||
|
s.convStore.Add("user", displayMsg)
|
||||||
|
}
|
||||||
|
|
||||||
if s.convStore.NeedsSummarization() {
|
if s.convStore.NeedsSummarization() {
|
||||||
s.autoSummarize()
|
s.autoSummarize()
|
||||||
@@ -48,17 +197,20 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
var studioPrompt strings.Builder
|
var studioPrompt strings.Builder
|
||||||
studioPrompt.WriteString(agent.StudioSystemPrompt())
|
studioPrompt.WriteString(agent.StudioSystemPrompt())
|
||||||
studioPrompt.WriteString(fmt.Sprintf("\nDate: %s\nHeure: %s\n", time.Now().Format("02/01/2006"), time.Now().Format("15:04:05")))
|
studioPrompt.WriteString(fmt.Sprintf("\nDate: %s\nHeure: %s\n", time.Now().Format("02/01/2006"), time.Now().Format("15:04:05")))
|
||||||
studioPrompt.WriteString(fmt.Sprintf("Root: %t\n", os.Geteuid() == 0))
|
canSudo := !agent.NeedsSudoPassword()
|
||||||
if os.Geteuid() != 0 {
|
studioPrompt.WriteString(fmt.Sprintf("Root: %t\n", !canSudo))
|
||||||
studioPrompt.WriteString("⚠️ Session utilisateur standard — les commandes sudo/doas nécessitent une autorisation. N'utilise PAS sudo ou doas sans demander.\n")
|
if !canSudo {
|
||||||
|
studioPrompt.WriteString("⚠️ Session sans sudo sans mot de passe — les commandes sudo/doas nécessitent une autorisation. N'utilise PAS sudo ou doas sans demander.\n")
|
||||||
|
} else {
|
||||||
|
studioPrompt.WriteString("⚠️ Session avec privilèges sudo sans mot de passe — les commandes sudo s'exécuteront directement.\n")
|
||||||
}
|
}
|
||||||
orb.SetSystemPrompt(studioPrompt.String())
|
orb.SetSystemPrompt(studioPrompt.String())
|
||||||
orb.SetTools(s.agentToolsJSON)
|
orb.SetTools(s.agentToolsJSON)
|
||||||
|
|
||||||
if body.Stream {
|
if body.Stream {
|
||||||
s.handleStreamChat(w, orb, body.Message)
|
s.handleStreamChat(w, orb, enrichedMessage)
|
||||||
} else {
|
} else {
|
||||||
s.handleNonStreamChat(w, orb, body.Message)
|
s.handleNonStreamChat(w, orb, enrichedMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +298,7 @@ func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message
|
|||||||
if summary != "" {
|
if summary != "" {
|
||||||
messages = append(messages, orchestrator.Message{
|
messages = append(messages, orchestrator.Message{
|
||||||
Role: "system",
|
Role: "system",
|
||||||
Content: "Résumé de la conversation précédente:\n" + summary,
|
Content: orchestrator.TextContent("Résumé de la conversation précédente:\n" + summary),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,13 +323,13 @@ func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message
|
|||||||
}
|
}
|
||||||
messages = append(messages, orchestrator.Message{
|
messages = append(messages, orchestrator.Message{
|
||||||
Role: role,
|
Role: role,
|
||||||
Content: content,
|
Content: orchestrator.TextContent(content),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
messages = append(messages, orchestrator.Message{
|
messages = append(messages, orchestrator.Message{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: userMessage,
|
Content: orchestrator.TextContent(userMessage),
|
||||||
})
|
})
|
||||||
|
|
||||||
return messages
|
return messages
|
||||||
@@ -225,8 +377,8 @@ func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, map[string]interface{}{
|
writeJSON(w, map[string]interface{}{
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"tokens": s.convStore.ApproxTokenCount(),
|
"tokens": s.convStore.ApproxTokenCount(),
|
||||||
"max_tokens": maxTokensApprox,
|
"max_tokens": contextWindowTokens,
|
||||||
"summarize_at": summarizeThreshold,
|
"summarize_at": int(float64(contextWindowTokens) * summarizeRatio),
|
||||||
"summary": s.convStore.GetSummary(),
|
"summary": s.convStore.GetSummary(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/agent"
|
||||||
"github.com/muyue/muyue/internal/lsp"
|
"github.com/muyue/muyue/internal/lsp"
|
||||||
"github.com/muyue/muyue/internal/mcp"
|
"github.com/muyue/muyue/internal/mcp"
|
||||||
"github.com/muyue/muyue/internal/scanner"
|
"github.com/muyue/muyue/internal/scanner"
|
||||||
@@ -24,7 +25,7 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
"name": version.Name,
|
"name": version.Name,
|
||||||
"version": version.Version,
|
"version": version.Version,
|
||||||
"author": version.Author,
|
"author": version.Author,
|
||||||
"sudo": os.Geteuid() == 0,
|
"sudo": !agent.NeedsSudoPassword(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,12 +83,12 @@ func (s *Server) buildShellSystemPrompt(req ShellChatRequest) string {
|
|||||||
sb.WriteString("User: " + user + "\n")
|
sb.WriteString("User: " + user + "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
isRoot := os.Geteuid() == 0
|
canSudo := !agent.NeedsSudoPassword()
|
||||||
sb.WriteString(fmt.Sprintf("Root: %t\n", isRoot))
|
sb.WriteString(fmt.Sprintf("Root: %t\n", !canSudo))
|
||||||
if isRoot {
|
if canSudo {
|
||||||
sb.WriteString("⚠️ Session en root — toutes les commandes ont les privilèges administrateur.\n")
|
sb.WriteString("⚠️ Session avec privilèges sudo sans mot de passe — les commandes sudo s'exécuteront directement.\n")
|
||||||
} else {
|
} else {
|
||||||
sb.WriteString("⚠️ Session utilisateur standard — les commandes sudo/doas nécessitent une autorisation. N'utilise PAS sudo ou doas sans demander.\n")
|
sb.WriteString("⚠️ Session sans sudo sans mot de passe — les commandes sudo/doas nécessitent une autorisation. N'utilise PAS sudo ou doas sans demander.\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -196,7 +196,7 @@ func (s *Server) buildShellContextMessages() []orchestrator.Message {
|
|||||||
}
|
}
|
||||||
messages = append(messages, orchestrator.Message{
|
messages = append(messages, orchestrator.Message{
|
||||||
Role: role,
|
Role: role,
|
||||||
Content: content,
|
Content: orchestrator.TextContent(content),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
104
internal/api/image_cache.go
Normal file
104
internal/api/image_cache.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
var imageDir string
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
dir, err := config.ConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
dir = "/tmp/muyue"
|
||||||
|
}
|
||||||
|
imageDir = filepath.Join(dir, "images")
|
||||||
|
os.MkdirAll(imageDir, 0755)
|
||||||
|
}
|
||||||
|
|
||||||
|
var imageCounter uint64
|
||||||
|
|
||||||
|
func saveImage(dataURI, filename, mimeType string) (string, error) {
|
||||||
|
parts := strings.SplitN(dataURI, ",", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return "", fmt.Errorf("invalid data URI")
|
||||||
|
}
|
||||||
|
encoded := parts[1]
|
||||||
|
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("base64 decode: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
id := fmt.Sprintf("%d-%d", time.Now().UnixMilli(), atomic.AddUint64(&imageCounter, 1))
|
||||||
|
ext := ".png"
|
||||||
|
switch mimeType {
|
||||||
|
case "image/jpeg":
|
||||||
|
ext = ".jpg"
|
||||||
|
case "image/webp":
|
||||||
|
ext = ".webp"
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := filepath.Join(imageDir, id+ext)
|
||||||
|
if err := os.WriteFile(filePath, decoded, 0600); err != nil {
|
||||||
|
return "", fmt.Errorf("write image: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return id + ext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func imagePath(id string) string {
|
||||||
|
return filepath.Join(imageDir, filepath.Base(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanupImages(ids []string) {
|
||||||
|
for _, id := range ids {
|
||||||
|
p := imagePath(id)
|
||||||
|
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
|
||||||
|
log.Printf("[images] failed to delete %s: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleServeImage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" {
|
||||||
|
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := strings.TrimPrefix(r.URL.Path, "/api/images/")
|
||||||
|
if id == "" {
|
||||||
|
writeError(w, "image id required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := imagePath(id)
|
||||||
|
if _, err := os.Stat(filePath); err != nil {
|
||||||
|
writeError(w, "image not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(id))
|
||||||
|
switch ext {
|
||||||
|
case ".jpg", ".jpeg":
|
||||||
|
w.Header().Set("Content-Type", "image/jpeg")
|
||||||
|
case ".png":
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
case ".webp":
|
||||||
|
w.Header().Set("Content-Type", "image/webp")
|
||||||
|
default:
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||||
|
|
||||||
|
http.ServeFile(w, r, filePath)
|
||||||
|
}
|
||||||
@@ -96,6 +96,7 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("/api/starship/apply-theme", s.handleApplyStarshipTheme)
|
s.mux.HandleFunc("/api/starship/apply-theme", s.handleApplyStarshipTheme)
|
||||||
s.mux.HandleFunc("/api/providers/validate", s.handleValidateProvider)
|
s.mux.HandleFunc("/api/providers/validate", s.handleValidateProvider)
|
||||||
s.mux.HandleFunc("/api/update/run", s.handleRunUpdate)
|
s.mux.HandleFunc("/api/update/run", s.handleRunUpdate)
|
||||||
|
s.mux.HandleFunc("/api/images/", s.handleServeImage)
|
||||||
s.mux.HandleFunc("/api/chat", s.handleChat)
|
s.mux.HandleFunc("/api/chat", s.handleChat)
|
||||||
s.mux.HandleFunc("/api/chat/history", s.handleChatHistory)
|
s.mux.HandleFunc("/api/chat/history", s.handleChatHistory)
|
||||||
s.mux.HandleFunc("/api/chat/clear", s.handleChatClear)
|
s.mux.HandleFunc("/api/chat/clear", s.handleChatClear)
|
||||||
@@ -140,7 +141,7 @@ func (s *Server) routes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
if strings.HasPrefix(r.URL.Path, "/api/ws/") {
|
if strings.HasPrefix(r.URL.Path, "/api/ws/") || strings.HasPrefix(r.URL.Path, "/api/images/") {
|
||||||
s.mux.ServeHTTP(w, r)
|
s.mux.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,14 +20,42 @@ var thinkRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
|
|||||||
|
|
||||||
const maxHistorySize = 100
|
const maxHistorySize = 100
|
||||||
|
|
||||||
|
type ContentPart struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
ImageURL *ImageURL `json:"image_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImageURL struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
type Message struct {
|
type Message struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content,omitempty"`
|
Content json.RawMessage `json:"content,omitempty"`
|
||||||
ToolCalls []ToolCallMsg `json:"tool_calls,omitempty"`
|
ToolCalls []ToolCallMsg `json:"tool_calls,omitempty"`
|
||||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TextContent(s string) json.RawMessage {
|
||||||
|
b, _ := json.Marshal(s)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func PartsContent(parts []ContentPart) json.RawMessage {
|
||||||
|
b, _ := json.Marshal(parts)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Message) ContentString() string {
|
||||||
|
var s string
|
||||||
|
if json.Unmarshal(m.Content, &s) == nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(m.Content)
|
||||||
|
}
|
||||||
|
|
||||||
type ToolCallMsg struct {
|
type ToolCallMsg struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
@@ -143,7 +171,7 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
|
|||||||
o.histMu.Lock()
|
o.histMu.Lock()
|
||||||
o.history = append(o.history, Message{
|
o.history = append(o.history, Message{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: userMessage,
|
Content: TextContent(userMessage),
|
||||||
})
|
})
|
||||||
|
|
||||||
if len(o.history) > maxHistorySize {
|
if len(o.history) > maxHistorySize {
|
||||||
@@ -152,7 +180,7 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
|
|||||||
|
|
||||||
messages := make([]Message, 0, len(o.history)+1)
|
messages := make([]Message, 0, len(o.history)+1)
|
||||||
if o.systemPrompt != "" {
|
if o.systemPrompt != "" {
|
||||||
messages = append(messages, Message{Role: "system", Content: o.systemPrompt})
|
messages = append(messages, Message{Role: "system", Content: TextContent(o.systemPrompt)})
|
||||||
}
|
}
|
||||||
messages = append(messages, o.history...)
|
messages = append(messages, o.history...)
|
||||||
|
|
||||||
@@ -173,7 +201,7 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
|
|||||||
o.histMu.Lock()
|
o.histMu.Lock()
|
||||||
o.history = append(o.history, Message{
|
o.history = append(o.history, Message{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: content,
|
Content: TextContent(content),
|
||||||
})
|
})
|
||||||
_ = providerName
|
_ = providerName
|
||||||
o.histMu.Unlock()
|
o.histMu.Unlock()
|
||||||
@@ -185,7 +213,7 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str
|
|||||||
o.histMu.Lock()
|
o.histMu.Lock()
|
||||||
o.history = append(o.history, Message{
|
o.history = append(o.history, Message{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: userMessage,
|
Content: TextContent(userMessage),
|
||||||
})
|
})
|
||||||
|
|
||||||
if len(o.history) > maxHistorySize {
|
if len(o.history) > maxHistorySize {
|
||||||
@@ -194,7 +222,7 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str
|
|||||||
|
|
||||||
messages := make([]Message, 0, len(o.history)+1)
|
messages := make([]Message, 0, len(o.history)+1)
|
||||||
if o.systemPrompt != "" {
|
if o.systemPrompt != "" {
|
||||||
messages = append(messages, Message{Role: "system", Content: o.systemPrompt})
|
messages = append(messages, Message{Role: "system", Content: TextContent(o.systemPrompt)})
|
||||||
}
|
}
|
||||||
messages = append(messages, o.history...)
|
messages = append(messages, o.history...)
|
||||||
|
|
||||||
@@ -273,7 +301,7 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str
|
|||||||
o.histMu.Lock()
|
o.histMu.Lock()
|
||||||
o.history = append(o.history, Message{
|
o.history = append(o.history, Message{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: content,
|
Content: TextContent(content),
|
||||||
})
|
})
|
||||||
o.histMu.Unlock()
|
o.histMu.Unlock()
|
||||||
|
|
||||||
@@ -283,7 +311,7 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str
|
|||||||
func (o *Orchestrator) SendWithTools(messages []Message) (*ChatResponse, error) {
|
func (o *Orchestrator) SendWithTools(messages []Message) (*ChatResponse, error) {
|
||||||
fullMessages := make([]Message, 0, len(messages)+1)
|
fullMessages := make([]Message, 0, len(messages)+1)
|
||||||
if o.systemPrompt != "" {
|
if o.systemPrompt != "" {
|
||||||
fullMessages = append(fullMessages, Message{Role: "system", Content: o.systemPrompt})
|
fullMessages = append(fullMessages, Message{Role: "system", Content: TextContent(o.systemPrompt)})
|
||||||
}
|
}
|
||||||
fullMessages = append(fullMessages, messages...)
|
fullMessages = append(fullMessages, messages...)
|
||||||
|
|
||||||
@@ -314,7 +342,7 @@ type ChunkCallback func(content string, toolCalls []ToolCallMsg)
|
|||||||
func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCallback) (*ChatResponse, error) {
|
func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCallback) (*ChatResponse, error) {
|
||||||
fullMessages := make([]Message, 0, len(messages)+1)
|
fullMessages := make([]Message, 0, len(messages)+1)
|
||||||
if o.systemPrompt != "" {
|
if o.systemPrompt != "" {
|
||||||
fullMessages = append(fullMessages, Message{Role: "system", Content: o.systemPrompt})
|
fullMessages = append(fullMessages, Message{Role: "system", Content: TextContent(o.systemPrompt)})
|
||||||
}
|
}
|
||||||
fullMessages = append(fullMessages, messages...)
|
fullMessages = append(fullMessages, messages...)
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func (p *Planner) GeneratePlan(ctx context.Context, goal string) ([]Step, error)
|
|||||||
prompt := buildPlanPrompt(goal)
|
prompt := buildPlanPrompt(goal)
|
||||||
|
|
||||||
messages := []orchestrator.Message{
|
messages := []orchestrator.Message{
|
||||||
{Role: "user", Content: prompt},
|
{Role: "user", Content: orchestrator.TextContent(prompt)},
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := p.orchestrator.SendWithTools(messages)
|
resp, err := p.orchestrator.SendWithTools(messages)
|
||||||
|
|||||||
@@ -62,15 +62,15 @@ const api = {
|
|||||||
clearShellChat: () => request('/shell/chat/clear', { method: 'POST' }),
|
clearShellChat: () => request('/shell/chat/clear', { method: 'POST' }),
|
||||||
analyzeSystem: () => request('/shell/analyze', { method: 'POST' }),
|
analyzeSystem: () => request('/shell/analyze', { method: 'POST' }),
|
||||||
getShellAnalysis: () => request('/shell/analysis'),
|
getShellAnalysis: () => request('/shell/analysis'),
|
||||||
sendChat: (message, stream = true, onChunk, signal) => {
|
sendChat: (message, stream = true, onChunk, signal, images = []) => {
|
||||||
if (!stream) {
|
if (!stream) {
|
||||||
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) })
|
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false, images }) })
|
||||||
}
|
}
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
fetch(`${API_BASE}/chat`, {
|
fetch(`${API_BASE}/chat`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ message, stream: true }),
|
body: JSON.stringify({ message, stream: true, images }),
|
||||||
signal,
|
signal,
|
||||||
}).then(async (res) => {
|
}).then(async (res) => {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ export default function App() {
|
|||||||
|
|
||||||
<footer className="statusbar">
|
<footer className="statusbar">
|
||||||
<div className="statusbar-left">
|
<div className="statusbar-left">
|
||||||
{isSudo && <span className="statusbar-sudo">⚡ ROOT</span>}
|
{isSudo && <span className="statusbar-sudo">⚡ SUDO</span>}
|
||||||
{activeTab === 'dash' && (
|
{activeTab === 'dash' && (
|
||||||
<span className="statusbar-shortcut">
|
<span className="statusbar-shortcut">
|
||||||
<kbd>{layout.keys.ctrl}+R</kbd> refresh
|
<kbd>{layout.keys.ctrl}+R</kbd> refresh
|
||||||
|
|||||||
@@ -70,14 +70,15 @@ function formatText(text) {
|
|||||||
.replace(/^### (.+)$/gm, '<h4 class="msg-h4">$1</h4>')
|
.replace(/^### (.+)$/gm, '<h4 class="msg-h4">$1</h4>')
|
||||||
.replace(/^## (.+)$/gm, '<h3 class="msg-h3">$1</h3>')
|
.replace(/^## (.+)$/gm, '<h3 class="msg-h3">$1</h3>')
|
||||||
.replace(/^# (.+)$/gm, '<h2 class="msg-h2">$1</h2>')
|
.replace(/^# (.+)$/gm, '<h2 class="msg-h2">$1</h2>')
|
||||||
.replace(/^\s*[-*] (.+)$/gm, '<div class="msg-bullet">• $1</div>')
|
.replace(/^---+$/gm, '<hr>')
|
||||||
|
.replace(/^\s*[-*] (.+)$/gm, '<div class="msg-bullet">\u2022 $1</div>')
|
||||||
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<div class="msg-step"><span class="msg-step-num">$1</span> $2</div>')
|
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<div class="msg-step"><span class="msg-step-num">$1</span> $2</div>')
|
||||||
.replace(/\n/g, '<br/>')
|
.replace(/\n/g, '<br/>')
|
||||||
|
|
||||||
html = html
|
html = html
|
||||||
.replace(/<br\/>\s*<br\/>/g, '<br/>')
|
.replace(/<br\/>\s*<br\/>/g, '<br/>')
|
||||||
.replace(/<br\/>\s*(<h[234]|<div class="msg-|<table)/g, '$1')
|
.replace(/<br\/>\s*(<h[234]|<div class="msg-|<table|<hr)/g, '$1')
|
||||||
.replace(/(<\/h[234]|<\/div>|<\/table>)\s*<br\/>/g, '$1')
|
.replace(/(<\/h[234]|<\/div>|<\/table>|<hr>)\s*<br\/>/g, '$1')
|
||||||
.replace(/\s+on\w+=["'][^"']*["']/gi, '')
|
.replace(/\s+on\w+=["'][^"']*["']/gi, '')
|
||||||
.replace(/javascript:/gi, '')
|
.replace(/javascript:/gi, '')
|
||||||
.replace(/data:/gi, '')
|
.replace(/data:/gi, '')
|
||||||
@@ -470,6 +471,7 @@ export default function Shell({ api }) {
|
|||||||
const aiMessagesRef = useRef(null)
|
const aiMessagesRef = useRef(null)
|
||||||
const aiLoadedRef = useRef(false)
|
const aiLoadedRef = useRef(false)
|
||||||
const aiLoadingRef = useRef(false)
|
const aiLoadingRef = useRef(false)
|
||||||
|
const analysisSavingRef = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
|
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
|
||||||
@@ -482,6 +484,8 @@ export default function Shell({ api }) {
|
|||||||
const stored = localStorage.getItem('shell_analysis')
|
const stored = localStorage.getItem('shell_analysis')
|
||||||
if (stored) setAnalysisContent(stored)
|
if (stored) setAnalysisContent(stored)
|
||||||
})
|
})
|
||||||
|
const stored = localStorage.getItem('shell_analysis')
|
||||||
|
if (stored && !analysisContent) setAnalysisContent(stored)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1138,6 +1142,14 @@ export default function Shell({ api }) {
|
|||||||
const filtered = prev.filter(m => !m._streaming)
|
const filtered = prev.filter(m => !m._streaming)
|
||||||
return [...filtered, finalMsg]
|
return [...filtered, finalMsg]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (analysisSavingRef.current && accumulated) {
|
||||||
|
analysisSavingRef.current = false
|
||||||
|
setAnalysisContent(accumulated)
|
||||||
|
try { localStorage.setItem('shell_analysis', accumulated) } catch {}
|
||||||
|
setAnalyzing(false)
|
||||||
|
}
|
||||||
|
|
||||||
api.getShellChatHistory().then(d => {
|
api.getShellChatHistory().then(d => {
|
||||||
setAiTokens(d.tokens || 0)
|
setAiTokens(d.tokens || 0)
|
||||||
setAiAtLimit(d.at_limit || false)
|
setAiAtLimit(d.at_limit || false)
|
||||||
@@ -1147,6 +1159,10 @@ export default function Shell({ api }) {
|
|||||||
setAiAtLimit(true)
|
setAiAtLimit(true)
|
||||||
}
|
}
|
||||||
setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }])
|
setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }])
|
||||||
|
if (analysisSavingRef.current) {
|
||||||
|
analysisSavingRef.current = false
|
||||||
|
setAnalyzing(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setAiLoading(false)
|
setAiLoading(false)
|
||||||
aiLoadingRef.current = false
|
aiLoadingRef.current = false
|
||||||
@@ -1165,7 +1181,25 @@ export default function Shell({ api }) {
|
|||||||
return () => window.removeEventListener('ask-ai-terminal', handler)
|
return () => window.removeEventListener('ask-ai-terminal', handler)
|
||||||
}, [_sendAiMessage])
|
}, [_sendAiMessage])
|
||||||
|
|
||||||
const handleAnalyze = () => {
|
const handleClearChat = async () => {
|
||||||
|
try {
|
||||||
|
await api.clearShellChat()
|
||||||
|
setAiMessages([])
|
||||||
|
setAiTokens(0)
|
||||||
|
setAiAtLimit(false)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAnalyze = async () => {
|
||||||
|
if (analyzing) return
|
||||||
|
setAnalyzing(true)
|
||||||
|
try {
|
||||||
|
await api.clearShellChat()
|
||||||
|
setAiMessages([])
|
||||||
|
setAiTokens(0)
|
||||||
|
setAiAtLimit(false)
|
||||||
|
} catch {}
|
||||||
|
analysisSavingRef.current = true
|
||||||
_sendAiMessage(`Fais une analyse complète du système. Utilise l'outil terminal pour explorer et rédige un rapport structuré en markdown. Couvre:
|
_sendAiMessage(`Fais une analyse complète du système. Utilise l'outil terminal pour explorer et rédige un rapport structuré en markdown. Couvre:
|
||||||
|
|
||||||
1. **OS & Matériel** — distrib, kernel, CPU, RAM, GPU, hostname
|
1. **OS & Matériel** — distrib, kernel, CPU, RAM, GPU, hostname
|
||||||
@@ -1359,44 +1393,49 @@ Sois concret : cite les vraies versions, les vrais chemins, les vrais nombres. L
|
|||||||
{aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>}
|
{aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="ai-panel-input">
|
<div className="ai-panel-input">
|
||||||
<input
|
{aiAtLimit ? (
|
||||||
value={aiInput}
|
<button className="ai-clear-btn" onClick={handleClearChat}>Nettoyer la conversation</button>
|
||||||
onChange={e => setAiInput(e.target.value)}
|
) : (
|
||||||
onKeyDown={e => {
|
<>
|
||||||
if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); handleAiSend(); return }
|
<input
|
||||||
if (e.key === 'Tab') {
|
value={aiInput}
|
||||||
e.preventDefault()
|
onChange={e => setAiInput(e.target.value)}
|
||||||
const val = aiInput
|
onKeyDown={e => {
|
||||||
const pos = e.target.selectionStart
|
if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); handleAiSend(); return }
|
||||||
const before = val.slice(0, pos)
|
if (e.key === 'Tab') {
|
||||||
const afterSlash = before.match(/\/[\w ]*$/)
|
e.preventDefault()
|
||||||
if (afterSlash) {
|
const val = aiInput
|
||||||
const partial = afterSlash[0]
|
const pos = e.target.selectionStart
|
||||||
const matches = SHELL_AI_COMMANDS.filter(c => c.startsWith(partial) && c !== partial)
|
const before = val.slice(0, pos)
|
||||||
if (matches.length >= 1) {
|
const afterSlash = before.match(/\/[\w ]*$/)
|
||||||
let completed = matches[0]
|
if (afterSlash) {
|
||||||
for (const m of matches) {
|
const partial = afterSlash[0]
|
||||||
while (!m.startsWith(completed)) completed = completed.slice(0, -1)
|
const matches = SHELL_AI_COMMANDS.filter(c => c.startsWith(partial) && c !== partial)
|
||||||
}
|
if (matches.length >= 1) {
|
||||||
if (completed === partial && matches.length === 1) completed = matches[0]
|
let completed = matches[0]
|
||||||
if (completed.length > partial.length) {
|
for (const m of matches) {
|
||||||
const suffix = completed[completed.length - 1] === ' ' ? '' : (matches.length === 1 ? ' ' : '')
|
while (!m.startsWith(completed)) completed = completed.slice(0, -1)
|
||||||
completed += suffix
|
}
|
||||||
const newText = val.slice(0, pos - afterSlash[0].length) + completed + val.slice(pos)
|
if (completed === partial && matches.length === 1) completed = matches[0]
|
||||||
setAiInput(newText)
|
if (completed.length > partial.length) {
|
||||||
requestAnimationFrame(() => {
|
const suffix = completed[completed.length - 1] === ' ' ? '' : (matches.length === 1 ? ' ' : '')
|
||||||
e.target.selectionStart = e.target.selectionEnd = pos - afterSlash[0].length + completed.length
|
completed += suffix
|
||||||
})
|
const newText = val.slice(0, pos - afterSlash[0].length) + completed + val.slice(pos)
|
||||||
|
setAiInput(newText)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
e.target.selectionStart = e.target.selectionEnd = pos - afterSlash[0].length + completed.length
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}}
|
||||||
return
|
placeholder={t('shell.askAi')}
|
||||||
}
|
/>
|
||||||
}}
|
<button className="sm" onClick={handleAiSend} disabled={!aiInput.trim()}>{t('shell.send')}</button>
|
||||||
placeholder={aiAtLimit ? '/clear pour continuer' : t('shell.askAi')}
|
</>
|
||||||
disabled={aiAtLimit && aiInput !== '/clear'}
|
)}
|
||||||
/>
|
|
||||||
<button className="sm" onClick={handleAiSend} disabled={(!aiInput.trim() && !aiAtLimit) || (aiAtLimit && aiInput !== '/clear')}>{t('shell.send')}</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -83,14 +83,15 @@ function formatText(text) {
|
|||||||
.replace(/^### (.+)$/gm, '<h4 class="msg-h4">$1</h4>')
|
.replace(/^### (.+)$/gm, '<h4 class="msg-h4">$1</h4>')
|
||||||
.replace(/^## (.+)$/gm, '<h3 class="msg-h3">$1</h3>')
|
.replace(/^## (.+)$/gm, '<h3 class="msg-h3">$1</h3>')
|
||||||
.replace(/^# (.+)$/gm, '<h2 class="msg-h2">$1</h2>')
|
.replace(/^# (.+)$/gm, '<h2 class="msg-h2">$1</h2>')
|
||||||
.replace(/^\s*[-*] (.+)$/gm, '<div class="msg-bullet">• $1</div>')
|
.replace(/^---+$/gm, '<hr>')
|
||||||
|
.replace(/^\s*[-*] (.+)$/gm, '<div class="msg-bullet">\u2022 $1</div>')
|
||||||
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<div class="msg-step"><span class="msg-step-num">$1</span> $2</div>')
|
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<div class="msg-step"><span class="msg-step-num">$1</span> $2</div>')
|
||||||
.replace(/\n/g, '<br/>')
|
.replace(/\n/g, '<br/>')
|
||||||
|
|
||||||
html = html
|
html = html
|
||||||
.replace(/<br\/>\s*<br\/>/g, '<br/>')
|
.replace(/<br\/>\s*<br\/>/g, '<br/>')
|
||||||
.replace(/<br\/>\s*(<h[234]|<div class="msg-|<table)/g, '$1')
|
.replace(/<br\/>\s*(<h[234]|<div class="msg-|<table|<hr)/g, '$1')
|
||||||
.replace(/(<\/h[234]|<\/div>|<\/table>)\s*<br\/>/g, '$1')
|
.replace(/(<\/h[234]|<\/div>|<\/table>|<hr>)\s*<br\/>/g, '$1')
|
||||||
.replace(/\s+on\w+=["'][^"']*["']/gi, '')
|
.replace(/\s+on\w+=["'][^"']*["']/gi, '')
|
||||||
.replace(/javascript:/gi, '')
|
.replace(/javascript:/gi, '')
|
||||||
.replace(/data:/gi, '')
|
.replace(/data:/gi, '')
|
||||||
@@ -284,6 +285,13 @@ function FeedItem({ msg }) {
|
|||||||
{timeStr && <span className="feed-time">{timeStr}</span>}
|
{timeStr && <span className="feed-time">{timeStr}</span>}
|
||||||
</div>
|
</div>
|
||||||
{msg.thinking && <ThinkingBlock content={formatText(msg.thinking)} done raw />}
|
{msg.thinking && <ThinkingBlock content={formatText(msg.thinking)} done raw />}
|
||||||
|
{msg.images && msg.images.length > 0 && (
|
||||||
|
<div className="feed-images">
|
||||||
|
{msg.images.map((imgId, i) => (
|
||||||
|
<img key={i} className="feed-image" src={`/api/images/${imgId}`} alt={`Image ${i + 1}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{parsedToolCalls && parsedToolCalls.map((tc, i) => {
|
{parsedToolCalls && parsedToolCalls.map((tc, i) => {
|
||||||
const resultData = parsedToolResults
|
const resultData = parsedToolResults
|
||||||
? parsedToolResults.find(r => r.tool_call_id === tc.tool_call_id)
|
? parsedToolResults.find(r => r.tool_call_id === tc.tool_call_id)
|
||||||
@@ -372,14 +380,16 @@ export default function Studio({ api }) {
|
|||||||
const [streamThinking, setStreamThinking] = useState('')
|
const [streamThinking, setStreamThinking] = useState('')
|
||||||
const [streamToolCalls, setStreamToolCalls] = useState([])
|
const [streamToolCalls, setStreamToolCalls] = useState([])
|
||||||
const [loaded, setLoaded] = useState(false)
|
const [loaded, setLoaded] = useState(false)
|
||||||
const [tokenInfo, setTokenInfo] = useState({ used: 0, max: 100000, summarizeAt: 80000 })
|
const [tokenInfo, setTokenInfo] = useState({ used: 0, max: 150000, summarizeAt: 120000 })
|
||||||
const [contextCollapsed, setContextCollapsed] = useState(false)
|
const [contextCollapsed, setContextCollapsed] = useState(false)
|
||||||
const [messagesCollapsed, setMessagesCollapsed] = useState(false)
|
const [messagesCollapsed, setMessagesCollapsed] = useState(false)
|
||||||
const [sudoModal, setSudoModal] = useState(null)
|
const [sudoModal, setSudoModal] = useState(null)
|
||||||
|
const [attachedImages, setAttachedImages] = useState([])
|
||||||
const messagesEnd = useRef(null)
|
const messagesEnd = useRef(null)
|
||||||
const feedRef = useRef(null)
|
const feedRef = useRef(null)
|
||||||
const textareaRef = useRef(null)
|
const textareaRef = useRef(null)
|
||||||
const abortRef = useRef(null)
|
const abortRef = useRef(null)
|
||||||
|
const fileInputRef = useRef(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getChatHistory().then(data => {
|
api.getChatHistory().then(data => {
|
||||||
@@ -392,8 +402,8 @@ export default function Studio({ api }) {
|
|||||||
}
|
}
|
||||||
setTokenInfo({
|
setTokenInfo({
|
||||||
used: data.tokens || 0,
|
used: data.tokens || 0,
|
||||||
max: data.max_tokens || 100000,
|
max: data.max_tokens || 150000,
|
||||||
summarizeAt: data.summarize_at || 80000,
|
summarizeAt: data.summarize_at || 120000,
|
||||||
})
|
})
|
||||||
setLoaded(true)
|
setLoaded(true)
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
@@ -434,8 +444,8 @@ export default function Studio({ api }) {
|
|||||||
const data = await api.getChatHistory()
|
const data = await api.getChatHistory()
|
||||||
setTokenInfo({
|
setTokenInfo({
|
||||||
used: data.tokens || 0,
|
used: data.tokens || 0,
|
||||||
max: data.max_tokens || 100000,
|
max: data.max_tokens || 150000,
|
||||||
summarizeAt: data.summarize_at || 80000,
|
summarizeAt: data.summarize_at || 120000,
|
||||||
})
|
})
|
||||||
} catch {}
|
} catch {}
|
||||||
}, [api])
|
}, [api])
|
||||||
@@ -466,10 +476,36 @@ export default function Studio({ api }) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}, [api, t])
|
}, [api, t])
|
||||||
|
|
||||||
|
const handleImageSelect = useCallback((e) => {
|
||||||
|
const files = Array.from(e.target.files || [])
|
||||||
|
if (files.length === 0) return
|
||||||
|
const remaining = 3 - attachedImages.length
|
||||||
|
const toProcess = files.slice(0, remaining)
|
||||||
|
toProcess.forEach(file => {
|
||||||
|
if (!file.type.match(/^image\/(jpeg|jpg|png|webp)$/)) return
|
||||||
|
if (file.size > 50 * 1024 * 1024) return
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (ev) => {
|
||||||
|
setAttachedImages(prev => {
|
||||||
|
if (prev.length >= 3) return prev
|
||||||
|
return [...prev, { data: ev.target.result, filename: file.name, mime_type: file.type }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
|
e.target.value = ''
|
||||||
|
}, [attachedImages.length])
|
||||||
|
|
||||||
|
const removeImage = useCallback((index) => {
|
||||||
|
setAttachedImages(prev => prev.filter((_, i) => i !== index))
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleSend = useCallback(async () => {
|
const handleSend = useCallback(async () => {
|
||||||
if (!input.trim() || loading) return
|
if (!input.trim() || loading) return
|
||||||
const text = input.trim()
|
const text = input.trim()
|
||||||
|
const images = [...attachedImages]
|
||||||
setInput('')
|
setInput('')
|
||||||
|
setAttachedImages([])
|
||||||
|
|
||||||
const isSlashCommand = (t) => /^\/(clear|help|summarize|model(?:\s+\S+)?)$/.test(t)
|
const isSlashCommand = (t) => /^\/(clear|help|summarize|model(?:\s+\S+)?)$/.test(t)
|
||||||
|
|
||||||
@@ -580,7 +616,7 @@ export default function Studio({ api }) {
|
|||||||
}
|
}
|
||||||
accumulated = partial
|
accumulated = partial
|
||||||
setStreaming(partial)
|
setStreaming(partial)
|
||||||
}, controller.signal)
|
}, controller.signal, images)
|
||||||
|
|
||||||
const finalContent = accumulated || t('studio.noResponse')
|
const finalContent = accumulated || t('studio.noResponse')
|
||||||
const aiMsg = {
|
const aiMsg = {
|
||||||
@@ -628,7 +664,7 @@ export default function Studio({ api }) {
|
|||||||
abortRef.current = null
|
abortRef.current = null
|
||||||
refreshTokens()
|
refreshTokens()
|
||||||
}
|
}
|
||||||
}, [input, loading, api, t, handleClear, streaming, refreshTokens, handleSummarize])
|
}, [input, loading, api, t, handleClear, streaming, refreshTokens, handleSummarize, attachedImages])
|
||||||
|
|
||||||
const handleStop = useCallback(() => {
|
const handleStop = useCallback(() => {
|
||||||
if (abortRef.current) {
|
if (abortRef.current) {
|
||||||
@@ -732,6 +768,16 @@ export default function Studio({ api }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="studio-input-area">
|
<div className="studio-input-area">
|
||||||
|
{attachedImages.length > 0 && (
|
||||||
|
<div className="studio-image-previews">
|
||||||
|
{attachedImages.map((img, i) => (
|
||||||
|
<div key={i} className="studio-image-preview">
|
||||||
|
<img src={img.data} alt={img.filename} />
|
||||||
|
<button className="studio-image-remove" onClick={() => removeImage(i)}>×</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className={`studio-token-bar ${contextCollapsed === true ? 'compressed' : ''}`}>
|
<div className={`studio-token-bar ${contextCollapsed === true ? 'compressed' : ''}`}>
|
||||||
<div className={`studio-token-track ${contextCollapsed === true ? 'compressed' : ''}`}>
|
<div className={`studio-token-track ${contextCollapsed === true ? 'compressed' : ''}`}>
|
||||||
<div
|
<div
|
||||||
@@ -751,6 +797,24 @@ export default function Studio({ api }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="studio-input-row">
|
<div className="studio-input-row">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
multiple
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handleImageSelect}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="studio-attach-btn"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={loading || attachedImages.length >= 3}
|
||||||
|
title="Joindre des images (max 3)"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={input}
|
value={input}
|
||||||
@@ -778,7 +842,7 @@ export default function Studio({ api }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="studio-input-hint">
|
<div className="studio-input-hint">
|
||||||
{t('studio.inputHint')} · /clear /summarize /help /model
|
{t('studio.inputHint')} · /clear /summarize /help /model · @fichier.ext pour joindre un fichier{attachedImages.length > 0 && ` · ${attachedImages.length} image${attachedImages.length > 1 ? 's' : ''} attachée${attachedImages.length > 1 ? 's' : ''}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -459,7 +459,7 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.ai-panel-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
|
.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 { padding: 8px 12px; border-radius: var(--radius); font-size: 13px; line-height: 1.5; word-break: break-word; }
|
||||||
.ai-message.user { background: var(--bg-elevated); border-left: 3px solid var(--accent-bright); color: var(--text-primary); }
|
.ai-message.user { background: var(--bg-elevated); border-left: 3px solid var(--accent-bright); color: var(--text-primary); }
|
||||||
.ai-message.user.analysis { border-left-color: var(--info); background: color-mix(in srgb, var(--info) 8%, var(--bg-elevated)); }
|
.ai-message.user.analysis { border-left-color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, var(--bg-elevated)); }
|
||||||
.ai-message.assistant { background: var(--bg-card); border-left: 3px solid var(--accent); }
|
.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.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.assistant { background: var(--bg-card); border-left: 3px solid var(--accent); }
|
||||||
@@ -469,6 +469,13 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.ai-message.tool .tool-args { font-family: var(--font-mono); font-size: 12px; color: var(--text-tertiary); margin-top: 4px; }
|
.ai-message.tool .tool-args { font-family: var(--font-mono); font-size: 12px; color: var(--text-tertiary); margin-top: 4px; }
|
||||||
.ai-panel-input { display: flex; gap: 6px; padding: 10px 12px; border-top: 1px solid var(--border); }
|
.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; }
|
.ai-panel-input input { flex: 1; font-size: 13px; padding: 6px 10px; }
|
||||||
|
.ai-panel-input .ai-clear-btn {
|
||||||
|
flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||||
|
padding: 10px 16px; border-radius: var(--radius); border: 1px solid var(--accent);
|
||||||
|
background: var(--accent-bg); color: var(--accent); font-size: 13px; font-weight: 700;
|
||||||
|
cursor: pointer; transition: all 0.15s; font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
.ai-panel-input .ai-clear-btn:hover { background: var(--accent); color: #fff; }
|
||||||
|
|
||||||
.shell-code-block {
|
.shell-code-block {
|
||||||
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
@@ -501,9 +508,11 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.shell-mermaid-loading { padding: 16px; text-align: center; color: var(--text-tertiary); font-size: 12px; }
|
.shell-mermaid-loading { padding: 16px; text-align: center; color: var(--text-tertiary); font-size: 12px; }
|
||||||
.shell-mermaid-error { padding: 10px 12px; color: var(--accent-bright); font-family: var(--font-mono); font-size: 12px; white-space: pre-wrap; }
|
.shell-mermaid-error { padding: 10px 12px; color: var(--accent-bright); font-family: var(--font-mono); font-size: 12px; white-space: pre-wrap; }
|
||||||
|
|
||||||
.ai-message table { width: 100%; border-collapse: collapse; margin: 6px 0; font-size: 12px; }
|
.ai-message table { width: 100%; border-collapse: collapse; margin: 6px 0; font-size: 12px; display: block; overflow-x: auto; }
|
||||||
.ai-message th { background: var(--bg-surface); padding: 6px 10px; text-align: left; font-weight: 600; border: 1px solid var(--border); color: var(--text-secondary); }
|
.ai-message thead, .ai-message tbody { display: table-row-group; }
|
||||||
.ai-message td { padding: 5px 10px; border: 1px solid var(--border); color: var(--text-primary); }
|
.ai-message th { background: var(--bg-surface); padding: 4px 8px; text-align: left; font-weight: 600; border: 1px solid var(--border); color: var(--text-secondary); white-space: nowrap; }
|
||||||
|
.ai-message td { padding: 3px 8px; border: 1px solid var(--border); color: var(--text-primary); white-space: nowrap; }
|
||||||
|
.ai-message tr { display: table-row; }
|
||||||
.ai-message tr:nth-child(even) td { background: var(--bg-surface); }
|
.ai-message tr:nth-child(even) td { background: var(--bg-surface); }
|
||||||
|
|
||||||
@keyframes copy-flash {
|
@keyframes copy-flash {
|
||||||
@@ -526,6 +535,18 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
flex: 1; overflow-y: auto; padding: 20px; font-size: 14px; line-height: 1.5;
|
flex: 1; overflow-y: auto; padding: 20px; font-size: 14px; line-height: 1.5;
|
||||||
color: var(--text-primary); word-break: break-word;
|
color: var(--text-primary); word-break: break-word;
|
||||||
}
|
}
|
||||||
|
.shell-analysis-modal-body table { width: 100%; border-collapse: collapse; margin: 8px 0; font-size: 13px; }
|
||||||
|
.shell-analysis-modal-body th { background: var(--bg-surface); padding: 4px 10px; text-align: left; font-weight: 600; border: 1px solid var(--border); color: var(--text-secondary); }
|
||||||
|
.shell-analysis-modal-body td { padding: 3px 10px; border: 1px solid var(--border); color: var(--text-primary); }
|
||||||
|
.shell-analysis-modal-body tr:nth-child(even) td { background: var(--bg-surface); }
|
||||||
|
.shell-analysis-modal-body .msg-h3 { font-size: 18px; font-weight: 700; color: var(--text-primary); margin: 16px 0 6px; display: block; }
|
||||||
|
.shell-analysis-modal-body .msg-h4 { font-size: 15px; font-weight: 700; color: var(--text-secondary); margin: 12px 0 4px; display: block; }
|
||||||
|
.shell-analysis-modal-body .msg-h2 { font-size: 20px; font-weight: 700; color: var(--accent); margin: 20px 0 8px; display: block; }
|
||||||
|
.shell-analysis-modal-body .msg-bullet { display: block; padding-left: 4px; margin: 2px 0; color: var(--text-primary); }
|
||||||
|
.shell-analysis-modal-body .msg-step { display: flex; gap: 8px; align-items: baseline; margin: 2px 0; }
|
||||||
|
.shell-analysis-modal-body .msg-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); flex-shrink: 0; }
|
||||||
|
.shell-analysis-modal-body strong { color: var(--accent-light); }
|
||||||
|
.shell-analysis-modal-body .inline-code { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 13px; color: var(--accent-muted); }
|
||||||
|
|
||||||
.shell-modal-overlay {
|
.shell-modal-overlay {
|
||||||
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
||||||
@@ -972,7 +993,7 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
overflow: hidden; margin: 8px 0;
|
overflow: hidden; margin: 8px 0;
|
||||||
}
|
}
|
||||||
.studio-code-header {
|
.studio-code-header {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: flex-end;
|
||||||
background: var(--bg-surface); border-bottom: 1px solid var(--border);
|
background: var(--bg-surface); border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.studio-code-block pre { padding: 12px 16px; font-family: var(--font-mono); font-size: 13px; line-height: 1.5; overflow-x: auto; color: var(--text-primary); margin: 0; }
|
.studio-code-block pre { padding: 12px 16px; font-family: var(--font-mono); font-size: 13px; line-height: 1.5; overflow-x: auto; color: var(--text-primary); margin: 0; }
|
||||||
@@ -998,11 +1019,11 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.feed-content th { background: var(--bg-surface); padding: 6px 12px; text-align: left; font-weight: 600; border: 1px solid var(--border); color: var(--text-secondary); }
|
.feed-content th { background: var(--bg-surface); padding: 6px 12px; text-align: left; font-weight: 600; border: 1px solid var(--border); color: var(--text-secondary); }
|
||||||
.feed-content td { padding: 5px 12px; border: 1px solid var(--border); color: var(--text-primary); }
|
.feed-content td { padding: 5px 12px; border: 1px solid var(--border); color: var(--text-primary); }
|
||||||
.feed-content tr:nth-child(even) td { background: var(--bg-surface); }
|
.feed-content tr:nth-child(even) td { background: var(--bg-surface); }
|
||||||
|
.feed-content hr, .ai-message hr { border: none; border-top: 1px solid var(--border); margin: 12px 0; }
|
||||||
.inline-code { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 13px; color: var(--accent-muted); }
|
.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: 10px 0 4px; display: block; }
|
.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-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 { display: block; padding-left: 4px; margin: 1px 0; color: var(--text-primary); }
|
||||||
.msg-bullet::before { content: '\2022'; position: absolute; left: 4px; color: var(--accent); }
|
|
||||||
.msg-step { display: flex; gap: 8px; align-items: baseline; margin: 1px 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; }
|
.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; }
|
.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; }
|
||||||
@@ -1051,6 +1072,47 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
cursor: pointer; transition: all 0.15s; flex-shrink: 0;
|
cursor: pointer; transition: all 0.15s; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.studio-stop-btn:hover { opacity: 0.8; }
|
.studio-stop-btn:hover { opacity: 0.8; }
|
||||||
|
|
||||||
|
/* ── Image Attachments ── */
|
||||||
|
.studio-attach-btn {
|
||||||
|
width: 42px; height: 42px; padding: 0; display: flex; align-items: center; justify-content: center;
|
||||||
|
border-radius: var(--radius); background: var(--bg-card); color: var(--text-tertiary);
|
||||||
|
border: 1px solid var(--border); cursor: pointer; transition: all 0.15s; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.studio-attach-btn:hover:not(:disabled) { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); }
|
||||||
|
.studio-attach-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||||
|
.studio-image-previews {
|
||||||
|
display: flex; gap: 10px; padding: 10px 8px; flex-wrap: wrap; justify-content: center;
|
||||||
|
}
|
||||||
|
.studio-image-preview {
|
||||||
|
position: relative; width: 110px; height: 110px; border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden; border: 2px solid var(--border); background: var(--bg-surface);
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.studio-image-preview:hover { border-color: var(--accent-dim); }
|
||||||
|
.studio-image-preview img {
|
||||||
|
width: 100%; height: 100%; object-fit: cover;
|
||||||
|
}
|
||||||
|
.studio-image-remove {
|
||||||
|
position: absolute; top: 4px; right: 4px; width: 24px; height: 24px;
|
||||||
|
border-radius: 50%; background: rgba(0,0,0,0.75); color: #fff; border: none;
|
||||||
|
font-size: 14px; font-weight: 600; cursor: pointer; display: flex; align-items: center;
|
||||||
|
justify-content: center; line-height: 1; transition: background 0.15s;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
.studio-image-remove:hover { background: var(--error); }
|
||||||
|
|
||||||
|
/* ── Feed Images (in chat messages) ── */
|
||||||
|
.feed-images {
|
||||||
|
display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.feed-image {
|
||||||
|
max-width: 240px; max-height: 180px; border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border); object-fit: cover; cursor: pointer;
|
||||||
|
transition: transform 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.feed-image:hover { transform: scale(1.03); border-color: var(--accent-dim); }
|
||||||
|
|
||||||
.studio-input-hint { font-size: 11px; color: var(--text-disabled); text-align: center; margin-top: 6px; }
|
.studio-input-hint { font-size: 11px; color: var(--text-disabled); text-align: center; margin-top: 6px; }
|
||||||
|
|
||||||
/* ── Collapsed Messages ── */
|
/* ── Collapsed Messages ── */
|
||||||
|
|||||||
Reference in New Issue
Block a user