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:
@@ -7,9 +7,32 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"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 {
|
||||
Command string `json:"command" description:"The shell command to execute"`
|
||||
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
|
||||
}
|
||||
|
||||
if os.Geteuid() != 0 {
|
||||
if NeedsSudoPassword() {
|
||||
trimmed := strings.TrimSpace(p.Command)
|
||||
lower := strings.ToLower(trimmed)
|
||||
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>
|
||||
- **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
|
||||
- **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
|
||||
- **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
|
||||
|
||||
@@ -92,7 +92,7 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
|
||||
|
||||
assistantMsg := orchestrator.Message{
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
Content: orchestrator.TextContent(content),
|
||||
ToolCalls: choice.Message.ToolCalls,
|
||||
}
|
||||
messages = append(messages, assistantMsg)
|
||||
@@ -147,7 +147,7 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
|
||||
|
||||
messages = append(messages, orchestrator.Message{
|
||||
Role: "tool",
|
||||
Content: result.Content,
|
||||
Content: orchestrator.TextContent(result.Content),
|
||||
ToolCallID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
})
|
||||
@@ -191,7 +191,7 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.
|
||||
|
||||
assistantMsg := orchestrator.Message{
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
Content: orchestrator.TextContent(content),
|
||||
ToolCalls: choice.Message.ToolCalls,
|
||||
}
|
||||
messages = append(messages, assistantMsg)
|
||||
@@ -213,7 +213,7 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.
|
||||
|
||||
messages = append(messages, orchestrator.Message{
|
||||
Role: "tool",
|
||||
Content: result.Content,
|
||||
Content: orchestrator.TextContent(result.Content),
|
||||
ToolCallID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
})
|
||||
|
||||
@@ -13,22 +13,24 @@ import (
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
)
|
||||
|
||||
const maxTokensApprox = 100000
|
||||
const summarizeThreshold = 80000
|
||||
const contextWindowTokens = 150000
|
||||
const summarizeRatio = 0.80
|
||||
const charsPerToken = 4
|
||||
|
||||
type FeedMessage struct {
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Time string `json:"time"`
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Time string `json:"time"`
|
||||
Images []string `json:"images,omitempty"`
|
||||
}
|
||||
|
||||
type Conversation struct {
|
||||
Messages []FeedMessage `json:"messages"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Messages []FeedMessage `json:"messages"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
RealTokens int `json:"real_tokens,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ConversationStore struct {
|
||||
@@ -85,6 +87,7 @@ func (cs *ConversationStore) load() {
|
||||
conv.Messages = []FeedMessage{}
|
||||
}
|
||||
cs.conv = &conv
|
||||
cs.realTokens = conv.RealTokens
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) save() error {
|
||||
@@ -127,15 +130,40 @@ func (cs *ConversationStore) Add(role, content string) FeedMessage {
|
||||
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() {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
|
||||
var imageIDs []string
|
||||
for _, m := range cs.conv.Messages {
|
||||
imageIDs = append(imageIDs, m.Images...)
|
||||
}
|
||||
|
||||
cs.conv.Messages = []FeedMessage{}
|
||||
cs.conv.Summary = ""
|
||||
cs.conv.RealTokens = 0
|
||||
cs.conv.CreatedAt = time.Now().Format(time.RFC3339)
|
||||
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
cs.realTokens = 0
|
||||
cs.save()
|
||||
|
||||
go cleanupImages(imageIDs)
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) SetSummary(summary string) {
|
||||
@@ -169,6 +197,7 @@ func (cs *ConversationStore) AddRealTokens(tokens int) {
|
||||
}
|
||||
cs.mu.Lock()
|
||||
cs.realTokens += tokens
|
||||
cs.conv.RealTokens = cs.realTokens
|
||||
cs.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -196,7 +225,7 @@ func (cs *ConversationStore) ApproxTokenCountDetailed() TokenCount {
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) NeedsSummarization() bool {
|
||||
return cs.ApproxTokenCount() > summarizeThreshold
|
||||
return cs.ApproxTokenCount() > int(float64(contextWindowTokens)*summarizeRatio)
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) Search(query string) []SearchResult {
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -15,6 +19,114 @@ import (
|
||||
)
|
||||
|
||||
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) {
|
||||
if r.Method != "POST" {
|
||||
@@ -22,8 +134,9 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Message string `json:"message"`
|
||||
Stream bool `json:"stream"`
|
||||
Message string `json:"message"`
|
||||
Stream bool `json:"stream"`
|
||||
Images []ImageAttachment `json:"images"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
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)
|
||||
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() {
|
||||
s.autoSummarize()
|
||||
@@ -48,17 +197,20 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
var studioPrompt strings.Builder
|
||||
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("Root: %t\n", os.Geteuid() == 0))
|
||||
if os.Geteuid() != 0 {
|
||||
studioPrompt.WriteString("⚠️ Session utilisateur standard — les commandes sudo/doas nécessitent une autorisation. N'utilise PAS sudo ou doas sans demander.\n")
|
||||
canSudo := !agent.NeedsSudoPassword()
|
||||
studioPrompt.WriteString(fmt.Sprintf("Root: %t\n", !canSudo))
|
||||
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.SetTools(s.agentToolsJSON)
|
||||
|
||||
if body.Stream {
|
||||
s.handleStreamChat(w, orb, body.Message)
|
||||
s.handleStreamChat(w, orb, enrichedMessage)
|
||||
} 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 != "" {
|
||||
messages = append(messages, orchestrator.Message{
|
||||
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{
|
||||
Role: role,
|
||||
Content: content,
|
||||
Content: orchestrator.TextContent(content),
|
||||
})
|
||||
}
|
||||
|
||||
messages = append(messages, orchestrator.Message{
|
||||
Role: "user",
|
||||
Content: userMessage,
|
||||
Content: orchestrator.TextContent(userMessage),
|
||||
})
|
||||
|
||||
return messages
|
||||
@@ -225,8 +377,8 @@ func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"messages": messages,
|
||||
"tokens": s.convStore.ApproxTokenCount(),
|
||||
"max_tokens": maxTokensApprox,
|
||||
"summarize_at": summarizeThreshold,
|
||||
"max_tokens": contextWindowTokens,
|
||||
"summarize_at": int(float64(contextWindowTokens) * summarizeRatio),
|
||||
"summary": s.convStore.GetSummary(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/muyue/muyue/internal/agent"
|
||||
"github.com/muyue/muyue/internal/lsp"
|
||||
"github.com/muyue/muyue/internal/mcp"
|
||||
"github.com/muyue/muyue/internal/scanner"
|
||||
@@ -24,7 +25,7 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
|
||||
"name": version.Name,
|
||||
"version": version.Version,
|
||||
"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")
|
||||
}
|
||||
|
||||
isRoot := os.Geteuid() == 0
|
||||
sb.WriteString(fmt.Sprintf("Root: %t\n", isRoot))
|
||||
if isRoot {
|
||||
sb.WriteString("⚠️ Session en root — toutes les commandes ont les privilèges administrateur.\n")
|
||||
canSudo := !agent.NeedsSudoPassword()
|
||||
sb.WriteString(fmt.Sprintf("Root: %t\n", !canSudo))
|
||||
if canSudo {
|
||||
sb.WriteString("⚠️ Session avec privilèges sudo sans mot de passe — les commandes sudo s'exécuteront directement.\n")
|
||||
} 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()
|
||||
@@ -196,7 +196,7 @@ func (s *Server) buildShellContextMessages() []orchestrator.Message {
|
||||
}
|
||||
messages = append(messages, orchestrator.Message{
|
||||
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/providers/validate", s.handleValidateProvider)
|
||||
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/history", s.handleChatHistory)
|
||||
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) {
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -20,14 +20,42 @@ var thinkRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
|
||||
|
||||
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 {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Content json.RawMessage `json:"content,omitempty"`
|
||||
ToolCalls []ToolCallMsg `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,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 {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
@@ -143,7 +171,7 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
|
||||
o.histMu.Lock()
|
||||
o.history = append(o.history, Message{
|
||||
Role: "user",
|
||||
Content: userMessage,
|
||||
Content: TextContent(userMessage),
|
||||
})
|
||||
|
||||
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)
|
||||
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...)
|
||||
|
||||
@@ -173,7 +201,7 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
|
||||
o.histMu.Lock()
|
||||
o.history = append(o.history, Message{
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
Content: TextContent(content),
|
||||
})
|
||||
_ = providerName
|
||||
o.histMu.Unlock()
|
||||
@@ -185,7 +213,7 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str
|
||||
o.histMu.Lock()
|
||||
o.history = append(o.history, Message{
|
||||
Role: "user",
|
||||
Content: userMessage,
|
||||
Content: TextContent(userMessage),
|
||||
})
|
||||
|
||||
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)
|
||||
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...)
|
||||
|
||||
@@ -273,7 +301,7 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str
|
||||
o.histMu.Lock()
|
||||
o.history = append(o.history, Message{
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
Content: TextContent(content),
|
||||
})
|
||||
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) {
|
||||
fullMessages := make([]Message, 0, len(messages)+1)
|
||||
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...)
|
||||
|
||||
@@ -314,7 +342,7 @@ type ChunkCallback func(content string, toolCalls []ToolCallMsg)
|
||||
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, Message{Role: "system", Content: TextContent(o.systemPrompt)})
|
||||
}
|
||||
fullMessages = append(fullMessages, messages...)
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ func (p *Planner) GeneratePlan(ctx context.Context, goal string) ([]Step, error)
|
||||
prompt := buildPlanPrompt(goal)
|
||||
|
||||
messages := []orchestrator.Message{
|
||||
{Role: "user", Content: prompt},
|
||||
{Role: "user", Content: orchestrator.TextContent(prompt)},
|
||||
}
|
||||
|
||||
resp, err := p.orchestrator.SendWithTools(messages)
|
||||
|
||||
Reference in New Issue
Block a user