From 12000e523c1f06d48eaf60b80d4300ec3c94bcf4 Mon Sep 17 00:00:00 2001 From: Augustin Date: Sun, 26 Apr 2026 15:19:26 +0200 Subject: [PATCH] fix: token persistence, context windows, CSS tables/bullets/hr, image attachments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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_ARCHITECTURE_REPORT.md | 1073 +++++++++++++++++++++++ internal/agent/definitions.go | 25 +- internal/agent/prompts/studio_system.md | 1 + internal/api/chat_engine.go | 8 +- internal/api/conversation.go | 51 +- internal/api/handlers_chat.go | 178 +++- internal/api/handlers_info.go | 3 +- internal/api/handlers_shell_chat.go | 12 +- internal/api/image_cache.go | 104 +++ internal/api/server.go | 3 +- internal/orchestrator/orchestrator.go | 46 +- internal/workflow/planner.go | 2 +- web/src/api/client.js | 6 +- web/src/components/App.jsx | 2 +- web/src/components/Shell.jsx | 119 ++- web/src/components/Studio.jsx | 86 +- web/src/styles/global.css | 76 +- 17 files changed, 1686 insertions(+), 109 deletions(-) create mode 100644 CRUSH_ARCHITECTURE_REPORT.md create mode 100644 internal/api/image_cache.go diff --git a/CRUSH_ARCHITECTURE_REPORT.md b/CRUSH_ARCHITECTURE_REPORT.md new file mode 100644 index 0000000..5ac3af2 --- /dev/null +++ b/CRUSH_ARCHITECTURE_REPORT.md @@ -0,0 +1,1073 @@ +# Rapport d'Architecture : CharmBracelet Crush + +> Analyse complĂšte de l'application [charmbracelet/crush](https://github.com/charmbracelet/crush.git) — comment elle communique avec les IA, gĂšre les outils, optimise les tokens et les performances. + +--- + +## Table des matiĂšres + +1. [Architecture globale](#1-architecture-globale) +2. [Ce qui est envoyĂ© Ă  l'IA](#2-ce-qui-est-envoyĂ©-Ă -lia) +3. [SystĂšme de prompts](#3-systĂšme-de-prompts) +4. [SystĂšme de rĂ©sumĂ© / compaction](#4-systĂšme-de-rĂ©sumĂ©--compaction) +5. [Fonctionnement des outils (Tools)](#5-fonctionnement-des-outils-tools) +6. [Optimisation de la consommation de tokens](#6-optimisation-de-la-consommation-de-tokens) +7. [Optimisations de performance](#7-optimisations-de-performance) +8. [SystĂšme de permissions](#8-systĂšme-de-permissions) +9. [Providers et modĂšles](#9-providers-et-modĂšles) +10. [SystĂšme de skills](#10-systĂšme-de-skills) +11. [IntĂ©gration LSP](#11-intĂ©gration-lsp) +12. [IntĂ©gration MCP](#12-intĂ©gration-mcp) +13. [Structure de la base de donnĂ©es](#13-structure-de-la-base-de-donnĂ©es) +14. [Fichiers clĂ©s Ă  explorer](#14-fichiers-clĂ©s-Ă -explorer) +15. [Leçons pour notre application](#15-leçons-pour-notre-application) + +--- + +## 1. Architecture globale + +``` +┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ +│ TUI / CLI │────▶│ Backend │────▶│ Coordinator │ +│ (UI chat) │ │ (workspace) │ │ (orchestrateur) │ +└──────────────┘ └──────────────┘ └────────┬─────────┘ + │ + ┌────────▌─────────┐ + │ SessionAgent │ + │ (moteur IA) │ + └────────┬─────────┘ + │ + ┌────────────────────┌────────────────────┐ + │ │ │ + ┌────────▌──────┐ ┌────────▌──────┐ ┌────────▌──────┐ + │ fantasy │ │ SQLite DB │ │ Tools │ + │ (LLM client) │ │ (messages) │ │ (22+ outils) │ + └───────────────┘ └───────────────┘ └───────────────┘ +``` + +**Flow de donnĂ©es complet :** + +``` +User Prompt + → Backend.SendMessage() + → Coordinator.Run() + → UpdateModels() (recharge models + tools) + → mergeCallOptions() (merge JSON des options provider) + → SessionAgent.Run() + → preparePrompt() (construit l'historique, filtre les orphelins) + → fantasy.Agent.Stream() avec : + - System prompt = coder.md.tpl + instructions MCP + - System prompt prefix = prefix provider + - Messages = historique filtrĂ© (tronquĂ© au rĂ©sumĂ© si existe) + - Files = piĂšces jointes binaires + - Tools = ensemble filtrĂ© d'outils + - Provider options = options merged Anthropic/OpenAI/Google/etc. + - Cache control = ephemeral sur dernier system + 2 derniers messages + → Auto-summarize si fenĂȘtre de contexte quasi pleine + → Queue + recurse pour messages en attente +``` + +### Fichiers clĂ©s de l'architecture + +| Fichier | RĂŽle | +|---------|------| +| `internal/agent/agent.go` | Moteur principal — construction des messages, streaming, rĂ©sumĂ© | +| `internal/agent/coordinator.go` | Orchestration — crĂ©ation agents, assembly tools/models | +| `internal/agent/prompts.go` | Factory de prompts systĂšme | +| `internal/agent/templates/coder.md.tpl` | Template du prompt systĂšme principal (405 lignes) | +| `internal/backend/backend.go` | Gestion des workspaces | +| `internal/backend/agent.go` | API transport-agnostic vers le coordinator | + +--- + +## 2. Ce qui est envoyĂ© Ă  l'IA + +### 2.1 System Prompt + +Le system prompt est construit Ă  partir du template `coder.md.tpl` et contient : + +``` +System Message: +├── — 13 rĂšgles absolues (read before edit, autonomous, etc.) +├── — Style de rĂ©ponse (concis, <4 lignes) +├── — SĂ©quence de travail (search → read → edit → test) +├── — DĂ©cisions autonomes vs. bloquantes +├── — RĂšgles d'Ă©dition (exact match, whitespace) +├── — Checklist prĂ©cision +├── — ComplĂ©tion exhaustive des tĂąches +├── — StratĂ©gies de rĂ©cupĂ©ration d'erreurs +├── — Gestion des fichiers mĂ©moire +├── — Conventions de code +├── — RĂšgles de test aprĂšs changements +├── — Utilisation des outils +├── — ProactivitĂ© vs. intention utilisateur +├── — Format des rĂ©ponses finales +├── — Variables dynamiques : +│ ├── Working Directory: {{.WorkingDir}} +│ ├── Is Git Repo: {{.IsGitRepo}} +│ ├── Platform: {{.Platform}} +│ ├── Date: {{.Date}} +│ └── Git Status (branch, git status --short | head -20, git log --oneline -n 3) +├── (optionnel) — État des serveurs LSP +├── — Skills disponibles en XML +├── — Instructions d'utilisation des skills +├── — Fichiers de contexte (crush.md, AGENTS.md, etc.) +└── — Instructions MCP injectĂ©es dynamiquement +``` + +### 2.2 Historique des messages + +Chaque appel Ă  l'IA inclut l'historique complet de la session, formatĂ© comme : + +``` +Messages (array de fantasy.Message): +├── [0] User: "This is a reminder that your todo list is currently empty..." +├── [1] User: "Premier prompt de l'utilisateur" +├── [2] Assistant: "RĂ©ponse IA + tool_calls" +├── [3] Tool: "RĂ©sultat du tool call" +├── [4] Assistant: "RĂ©ponse suivante" +├── ... +└── [N] User: "Nouveau prompt" +``` + +**Types de contenu dans les messages :** +- `ReasoningContent` — ChaĂźne de pensĂ©e (pour modĂšles thinking) +- `TextContent` — Texte brut +- `ImageURLContent` — Images (URL ou base64) +- `BinaryContent` — Fichiers binaires +- `ToolCall` — Appel d'outil (ID, nom, input) +- `ToolResult` — RĂ©sultat d'outil (text, error, ou media) +- `Finish` — MĂ©tadonnĂ©es de fin (end_turn, max_tokens, tool_use, canceled, error) + +### 2.3 Fichiers joints + +Les piĂšces jointes sont traitĂ©es diffĂ©remment selon leur type : +- **Fichiers texte** : contenus inline dans le prompt utilisateur via `PromptWithTextAttachments()` +- **Fichiers binaires** : envoyĂ©s comme `FilePart` sĂ©parĂ©s +- **Images** : envoyĂ©es comme `ImageURLContent` (base64) si le modĂšle les supporte + +### 2.4 Options provider fusionnĂ©es + +Les options sont fusionnĂ©es en 3 couches (le plus profond gagne) : + +``` +1. catwalk.Model.Options.ProviderOptions (dĂ©fauts du catalogue) +2. ProviderConfig.ProviderOptions (config du provider) +3. SelectedModel.ProviderOptions (choix utilisateur) +→ JSON merge avec sĂ©mantique "deepest wins" +``` + +Options spĂ©cifiques par provider : +- **Anthropic** : thinking, reasoning_effort, beta headers (`interleaved-thinking-2025-05-14`) +- **OpenAI** : responses API, reasoning +- **Google** : thinking_config +- **OpenRouter** : reasoning, suffixe `:exacto` + +--- + +## 3. SystĂšme de prompts + +### 3.1 Templates embarquĂ©s + +| Template | But | Taille | +|----------|-----|--------| +| `coder.md.tpl` | Prompt systĂšme principal de l'agent coder | ~405 lignes | +| `task.md.tpl` | Prompt systĂšme des sous-agents | ~15 lignes | +| `initialize.md.tpl` | Prompt d'initialisation du codebase | Analyse + gĂ©nĂ©ration AGENTS.md | +| `summary.md` | Prompt de rĂ©sumĂ©/compaction | Sections structurĂ©es obligatoires | +| `title.md` | GĂ©nĂ©ration de titre de session | ≀50 chars | +| `agent_tool.md` | Instructions du tool `agent` | Sous-agent read-only | +| `agentic_fetch.md` | Instructions du tool `agentic_fetch` | Sous-agent web | +| `agentic_fetch_prompt.md.tpl` | Prompt du sous-agent de fetch | Template dynamique | + +### 3.2 Prompt de rĂ©sumĂ© (`summary.md`) + +Ce prompt est crucial — il dĂ©finit comment la conversation est compactĂ©e : + +**Sections obligatoires du rĂ©sumĂ© :** +1. **Current State** — État actuel de la tĂąche +2. **Files & Changes** — Tous les fichiers modifiĂ©s et les changements +3. **Technical Context** — Stack technique, dĂ©pendances, patterns +4. **Strategy & Approach** — StratĂ©gie adoptĂ©e +5. **Exact Next Steps** — Prochaines Ă©tapes exactes + +**Philosophie :** *"No limit. Err on the side of too much detail rather than too little."* + +### 3.3 Prompt de sous-agent (`task.md.tpl`) + +Minimal et ciblĂ© : +``` +Rules: +1. Be concise, direct +2. Share file names and paths +3. Use absolute paths only + + +Working Directory: {{.WorkingDir}} +Is Git Repo: {{.IsGitRepo}} +Platform: {{.Platform}} +Date: {{.Date}} + +``` + +### 3.4 Variables dynamiques injectĂ©es + +Le systĂšme injecte des donnĂ©es en temps rĂ©el via le template : + +```go +type PromptData struct { + WorkingDir string + IsGitRepo bool + Platform string + Date string + GitStatus string // branch + git status --short | head -20 + git log --oneline -n 3 + Config Config + AvailSkillXML string // XML des skills disponibles + ContextFiles string // Contenu des fichiers de contexte +} +``` + +### 3.5 Fichiers de contexte (Memory) + +Chemins par dĂ©faut explorĂ©s et injectĂ©s dans `` : +``` +.github/copilot-instructions.md +.cursorrules +.cursor/rules/ +CLAUDE.md +CLAUDE.local.md +GEMINI.md +crush.md +CRUSH.md +AGENTS.md +``` + +--- + +## 4. SystĂšme de rĂ©sumĂ© / compaction + +### 4.1 DĂ©clenchement automatique + +Deux conditions de dĂ©clenchement vĂ©rifiĂ©es aprĂšs **chaque Ă©tape** de l'agent : + +``` +remaining = contextWindow - (completionTokens + promptTokens) + +if contextWindow > 200,000: + threshold = 20,000 tokens (buffer fixe) +else: + threshold = 20% × contextWindow (ratio) + +if remaining ≀ threshold AND !disableAutoSummarize: + → DÉCLENCHE LE RÉSUMÉ + +if contextWindow == 0 (modĂšle inconnu/local): + → PAS de rĂ©sumĂ© auto (Ă©vite la troncature aveugle) +``` + +**Constantes clĂ©s :** +- `largeContextWindowThreshold = 200,000` +- `largeContextWindowBuffer = 20,000` +- `smallContextWindowRatio = 0.2` + +### 4.2 Processus de rĂ©sumĂ© + +``` +┌─────────────────────────────────────────────┐ +│ 1. RĂ©cupĂ©rer tous les messages de session │ +│ 2. Convertir en fantasy.Message[] │ +│ 3. CrĂ©er message assistant │ +│ avec IsSummaryMessage: true │ +│ 4. Streamer le rĂ©sumĂ© via summary.md prompt │ +│ + todo list courante │ +│ 5. Update session: │ +│ - SummaryMessageID = summary.ID │ +│ - CompletionTokens = rĂ©sumĂ© output │ +│ - PromptTokens = 0 │ +└─────────────────────────────────────────────┘ +``` + +### 4.3 Comment la compaction fonctionne au chargement + +```go +func getSessionMessages(session): + msgs = loadAllMessages(sessionID) + + if session.SummaryMessageID != "": + summaryIndex = findIndex(msgs, summaryMessageID) + msgs = msgs[summaryIndex:] // TRONQUE tout avant le rĂ©sumĂ© + msgs[0].Role = "user" // Change rĂŽle assistant → user + + return msgs +``` + +**RĂ©sultat :** Seuls le rĂ©sumĂ© + les messages aprĂšs le rĂ©sumĂ© sont envoyĂ©s Ă  l'IA. Tout l'historique prĂ©cĂ©dent est Ă©liminĂ© du contexte. + +### 4.4 DĂ©tection de boucle (Loop Detection) + +MĂ©canisme secondaire de dĂ©clenchement du rĂ©sumĂ© : + +``` +- FenĂȘtre glissante de 10 derniĂšres Ă©tapes +- Signature = SHA-256(ToolName + \x00 + Input + \x00 + Output + \x00) +- Si une signature apparaĂźt > 5 fois dans la fenĂȘtre → BOUCLE DÉTECTÉE +- DĂ©clenche le rĂ©sumĂ© + arrĂȘt de l'agent +``` + +### 4.5 Reprise aprĂšs interruption + +Si le rĂ©sumĂ© est dĂ©clenchĂ© pendant des appels d'outils en cours : +``` +Re-queue le prompt original avec : +"The previous session was interrupted because it got too long, +the initial user request was: " +``` + +--- + +## 5. Fonctionnement des outils (Tools) + +### 5.1 Liste complĂšte des outils (22+) + +| Outil | Type | SĂ©quentiel/ParallĂšle | But | +|-------|------|---------------------|-----| +| `bash` | Core | SĂ©quentiel | ExĂ©cution de commandes shell | +| `edit` | Core | SĂ©quentiel | Remplacement find-and-replace dans un fichier | +| `multiedit` | Core | SĂ©quentiel | Multiples remplacements sĂ©quentiels | +| `write` | Core | SĂ©quentiel | CrĂ©ation/Ă©crasement de fichier | +| `view` | Core | SĂ©quentiel | Lecture de fichier avec line numbers | +| `ls` | Core | SĂ©quentiel | Arborescence de rĂ©pertoire | +| `glob` | Core | SĂ©quentiel | Recherche de fichiers par pattern | +| `grep` | Core | SĂ©quentiel | Recherche dans le contenu de fichiers | +| `fetch` | Core | ParallĂšle | Fetch URL brut (text/markdown/html) | +| `agentic_fetch` | Core | ParallĂšle | Fetch IA avec extraction/rĂ©sumĂ© | +| `sourcegraph` | Core | ParallĂšle | Recherche de code sur GitHub | +| `download` | Core | ParallĂšle | TĂ©lĂ©chargement de fichier | +| `agent` | Agent | ParallĂšle | Sous-agent de recherche/analyse | +| `todos` | Core | SĂ©quentiel | Gestion de la todo list | +| `crush_info` | Core | SĂ©quentiel | État runtime de Crush | +| `crush_logs` | Core | SĂ©quentiel | Logs internes de Crush | +| `job_output` | Core | SĂ©quentiel | Output de processus background | +| `job_kill` | Core | SĂ©quentiel | Terminaison de processus background | +| `lsp_diagnostics` | LSP | SĂ©quentiel | Diagnostics LSP | +| `lsp_references` | LSP | SĂ©quentiel | RĂ©fĂ©rences LSP | +| `lsp_restart` | LSP | SĂ©quentiel | RedĂ©marrage LSP | +| `list_mcp_resources` | MCP | ParallĂšle | Liste ressources MCP | +| `read_mcp_resource` | MCP | ParallĂšle | Lecture ressource MCP | +| `mcp_{server}_{tool}` | MCP | ParallĂšle | Outils dynamiques MCP | + +### 5.2 Architecture d'un outil + +Chaque outil suit le pattern : + +```go +fantasy.NewAgentTool(name, description, params, func(ctx context.Context, params Params) (fantasy.ToolResponse, error) { + // 1. Validation des paramĂštres + // 2. Extraction du contexte (sessionID, messageID, workingDir) + // 3. VĂ©rification de permission (si mutation) + // 4. ExĂ©cution de la logique + // 5. Retour de la rĂ©ponse avec mĂ©tadonnĂ©es +}) +``` + +**Deux constructeurs :** +- `fantasy.NewAgentTool` — Outil sĂ©quentiel (bloquant) +- `fantasy.NewParallelAgentTool` — Outil parallĂšle (peut tourner en //) + +### 5.3 Types de rĂ©ponses + +```go +fantasy.NewTextResponse(content) // SuccĂšs texte +fantasy.NewTextErrorResponse(message) // Erreur (IsError=true) +fantasy.NewImageResponse(data, mimeType) // Image +fantasy.NewMediaResponse(data, mimeType) // Autre mĂ©dia +fantasy.WithResponseMetadata(resp, meta) // Attache mĂ©tadonnĂ©es typĂ©es +``` + +### 5.4 DĂ©tails des outils clĂ©s + +#### `bash` — ExĂ©cution de commandes + +``` +ParamĂštres: {Description, Command, WorkingDir, RunInBackground, AutoBackgroundAfter} + +SĂ©curitĂ©: +├── safeCommands: ls, cat, head, tail, pwd, echo, which, env, git status/diff/log +│ → Pas de permission requise (read-only) +├── Banned: curl, wget, sudo, apt, npm, etc. +│ → BloquĂ© au niveau shell +├── Background: si RunInBackground=true ou > AutoBackgroundAfter (60s) +│ → Retourne ShellID pour suivi +└── Output: tronquĂ© Ă  30,000 chars (dĂ©but + fin + compte lignes tronquĂ©es) +``` + +#### `view` — Lecture de fichiers + +``` +ParamĂštres: {FilePath, Offset, Limit} + +Comportement: +├── crush: prefix → lecture depuis FS embarquĂ© (skills builtins) +├── Chemins relatifs → rĂ©solus via SmartJoin(workingDir, ...) +├── Permission requise si hors workingDir +├── Max fichier: 100KB, dĂ©faut 2000 lignes +├── Images JPG/PNG/GIF/WebP → NewImageResponse +├── Ajoute numĂ©ros de ligne (padding 6 chars) +├── LSP: ouvre fichier, attend 300ms pour diagnostics +├── Enregistre lecture via filetracker.RecordRead() +└── Suggestions si fichier non trouvĂ© +``` + +#### `edit` — Édition de fichiers + +``` +ParamĂštres: {FilePath, OldString, NewString, ReplaceAll} + +3 modes: +├── OldString="" → CrĂ©er nouveau fichier +├── NewString="" → Supprimer contenu +└── Les deux → Remplacer contenu + +SĂ©curitĂ©: +├── Doit avoir lu le fichier avant (filetracker check) +├── Fichier non modifiĂ© depuis la lecture (ModTime check) +├── OldString unique (sauf ReplaceAll=true) +├── Permission "write" avec diff affichĂ© +├── Gestion CRLF automatique +└── LSP notifiĂ© aprĂšs Ă©criture +``` + +#### `grep` — Recherche de contenu + +``` +ParamĂštres: {Pattern, Path, Include, LiteralText} + +Optimisations: +├── ripgrep (rg --json) en prioritĂ©, fallback Go regex +├── Respecte .gitignore et .crushignore +├── Cache de regex compilĂ©s (csync.Map thread-safe) +├── RĂ©sultats triĂ©s par date de modification (plus rĂ©cent d'abord) +├── Max 100 rĂ©sultats +├── Lignes tronquĂ©es Ă  500 chars +└── Timeout configurable +``` + +### 5.5 Outils spĂ©ciaux + +#### `agent` — Sous-agent + +``` +Flow: +1. Valide params.Prompt non vide +2. CrĂ©e une session enfant (CreateTaskSession) +3. Lance un SessionAgent sĂ©parĂ© (NonInteractive: true) +4. Outils du sous-agent: glob, grep, ls, sourcegraph, view (READ-ONLY) +5. Propage le coĂ»t de la session enfant → session parent +``` + +#### `agentic_fetch` — Fetch intelligent + +``` +Flow: +1. Mode URL: fetch + convert (HTML→MD) + → Si contenu > 50KB: sauve en temp file, dit au sous-agent d'utiliser view/grep +2. Mode Search: construit prompt pour web_search + web_fetch +3. Sous-agent avec petit modĂšle + outils restreints +4. Auto-approve des permissions pour le sous-agent +``` + +--- + +## 6. Optimisation de la consommation de tokens + +### 6.1 RĂ©sumĂ© automatique (Auto-Summarization) + +**La plus grande optimisation.** Voir [Section 4](#4-systĂšme-de-rĂ©sumĂ©--compaction). + +Quand la fenĂȘtre de contexte approche sa limite, toute la conversation est remplacĂ©e par un rĂ©sumĂ© dĂ©taillĂ©. Ce rĂ©sumĂ© devient le nouveau point de dĂ©part. + +### 6.2 Anthropic Prompt Caching + +Marqueurs `ephemeral` ajoutĂ©s automatiquement : + +```go +// Cache control placement: +├── Dernier message systĂšme → ephemeral +├── Avant-dernier message → ephemeral +└── AntĂ©pĂ©nultiĂšme message → ephemeral +``` + +Cela permet Ă  Anthropic de mettre en cache ces messages et de rĂ©duire les tokens d'input sur les tours suivants. + +### 6.3 Filtrage des orphelins de tool calls + +```go +// Avant d'envoyer Ă  l'IA: +filterOrphanedToolResults() // Supprime tool results sans tool call correspondant +syntheticToolResultsForOrphanedCalls() // Injecte erreurs synthĂ©tiques pour tool calls orphelins +``` + +Cela nettoie l'historique des messages inutiles qui consomment des tokens. + +### 6.4 Deux modĂšles (Large/Small) + +``` +├── Large Model: tĂąches principales (coder, rĂ©sumĂ©) +└── Small Model: titre de session, agentic_fetch + → Économise les tokens de haute qualitĂ© pour les tĂąches simples +``` + +### 6.5 Limitations de taille des outils + +| Outil | Limite | Impact token | +|-------|--------|-------------| +| bash output | 30,000 chars | Tronque les longues sorties | +| view | 100KB max, 2000 lignes | Limite les gros fichiers | +| grep | 100 rĂ©sultats, 500 chars/ligne | Limite les recherches larges | +| glob | 100 fichiers | Limite les rĂ©sultats | +| ls | 1000 fichiers | Limite les rĂ©pertoires massifs | +| fetch | 100KB | Limite les pages web | +| sourcegraph | 20 rĂ©sultats max | Limite les recherches code | +| description tools | FirstLineDescription() | N'envoie que la 1Ăšre ligne de description | + +### 6.6 Short Tool Descriptions + +```go +// Par dĂ©faut: seul le premier ligne non-vide de la description est envoyĂ© +FirstLineDescription(fullMarkdownDescription) + +// DĂ©sactivable via: CRUSH_SHORT_TOOL_DESCRIPTIONS=0 +``` + +Chaque outil a une description markdown complĂšte, mais seule la premiĂšre ligne est envoyĂ©e Ă  l'IA — Ă©conomie significative sur 22+ outils. + +### 6.7 Workaround mĂ©dia par provider + +Pour les providers non-Anthropic/Bedrock, les images dans les tool results sont converties en messages utilisateur sĂ©parĂ©s avec piĂšces jointes — Ă©vitant les erreurs API et les tokens gaspillĂ©s. + +### 6.8 Injection system_reminder minimale + +Seul un rappel minimal est injectĂ© comme premier message utilisateur : +``` +"This is a reminder that your todo list is currently empty. +DO NOT mention this to the user explicitly..." +``` + +Ce message est court et sert de garde-fou sans consommer beaucoup de tokens. + +### 6.9 Estimation de tokens + +```go +ApproxTokenCount(text) = len(text) / 4 // Heuristique 4 chars = 1 token +``` + +UtilisĂ© pour le logging et les mĂ©triques, pas pour le contrĂŽle strict. + +--- + +## 7. Optimisations de performance + +### 7.1 Concurrence + +``` +├── sync.WaitGroup pour chargement providers (Catwalk + Hyper en //) +├── sync.OnceValue pour cache Hyper (computed once) +├── fastwalk pour dĂ©couverte des skills (concurent, suit symlinks) +├── errgroup.Group pour readiness des agents (system prompt + tools en //) +├── csync.Map pour maps concurrent-safe (providers, regex cache) +├── sync.RWMutex pour skill tracker +└── Shell mutex sĂ©rialise les commandes par instance shell +``` + +### 7.2 File d'attente de messages (Message Queue) + +``` +Si agent occupĂ© → message en queue +↓ +Dans PrepareStep → injecte messages en queue comme user messages supplĂ©mentaires +↓ +Pas de perte de messages, traitement sĂ©quentiel garanti +``` + +### 7.3 Cache providers + +``` +├── Cache JSON: $XDG_DATA_HOME/crush/providers.json +├── ETag support pour revalidation HTTP +├── Fallback: frais → cache → embarquĂ© +└── Chargement concurrent Catwalk + Hyper +``` + +### 7.4 CGO et GC + +``` +├── CGO_ENABLED=0 (pas de CGO overhead) +└── GOEXPERIMENT=greenteagc (GC optimisĂ©) +``` + +### 7.5 Base de donnĂ©es SQLite + +``` +├── SQLC pour code SQL type-safe gĂ©nĂ©rĂ© +├── Transactions avec retry (3 tentatives) pour conflits UNIQUE +├── Atomic SQL increment pour token usage (Ă©vite race conditions) +└── Index sur (session_id, created_at) pour requĂȘtes messages rapides +``` + +### 7.6 Pub/Sub + +``` +├── SystĂšme d'Ă©vĂ©nements dĂ©couplĂ© +├── Canal d'Ă©vĂ©nements par workspace +└── Pas de polling — push-based +``` + +--- + +## 8. SystĂšme de permissions + +### 8.1 Actions + +| Action | Quand | Outils concernĂ©s | +|--------|-------|-----------------| +| `"read"` | Lecture hors workingDir | view, ls | +| `"write"` | Modification de fichiers | edit, multiedit, write | +| `"execute"` | Commande shell non-safe | bash | +| `"fetch"` | RequĂȘte rĂ©seau | fetch, agentic_fetch | +| `"download"` | TĂ©lĂ©chargement | download | +| `"list"` | Liste ressources MCP | list_mcp_resources | +| `"read"` | Lecture ressource MCP | read_mcp_resource | + +### 8.2 Auto-approbations + +- Commandes bash `safeCommands` (ls, cat, pwd, git status, etc.) → **pas de permission** +- Docker MCP tools whitelistĂ©s (`mcp_docker_mcp-find`, etc.) → **auto-approuvĂ©** +- Sous-agents (agent, agentic_fetch) → **auto-approuvĂ©** + +### 8.3 SĂ©curitĂ© shell + +``` +Banned commands: alias, aria2c, axel, chrome, curl, curlie, firefox, +http-prompt, httpie, links, lynx, nc, safari, scp, ssh, telnet, w3m, +wget, xh, doas, su, sudo, apk, apt, apt-cache, apt-get, dnf, dpkg, +emerge, home-manager, makepkg, opkg, pacman, paru, pkg, pkg_add, +pkg_delete, portage, rpm, yay, yum, zypper, at, batch, chkconfig, +crontab, fdisk, mkfs, mount, parted, service, systemctl, umount, +firewall-cmd, ifconfig, ip, iptables, netstat, pfctl, route, ufw +``` + +--- + +## 9. Providers et modĂšles + +### 9.1 Types de providers supportĂ©s + +``` +├── OpenAI +├── Anthropic +├── OpenRouter (suffixe :exacto pour modĂšles supportĂ©s) +├── Vercel +├── Azure +├── AWS Bedrock +├── Google (Gemini) +├── Google Vertex AI +├── OpenAI-compatible (gĂ©nĂ©rique) +└── Hyper (Charm's meta-provider) +``` + +### 9.2 Hyper Provider + +``` +├── Endpoint: https://hyper.charm.land/api/v1/fantasy +├── Activation: HYPER, HYPERCRUSH, HYPER_ENABLE, HYPER_ENABLED env vars +├── ModĂšles: GLM-5, GLM-5.1, gpt-oss-120b, Kimi K2.5, Kimi K2.6 +├── Routage: Anthropic/OpenAI/Google/OpenAI-compat selon model ID +└── Header: x-crush-id pour identification +``` + +### 9.3 Construction du provider + +```go +buildProvider(config) → fantasy.Provider: + 1. Parse le type de provider + 2. Configure base URL, API key, headers + 3. Ajoute beta headers si thinking model (Anthropic) + 4. Pour Hyper: route vers le bon provider selon model ID + 5. Pour OpenRouter: ajoute :exacto si supportĂ© +``` + +--- + +## 10. SystĂšme de skills + +### 10.1 Standard Agent Skills + +Crush implĂ©mente le standard ouvert [agentskills.io](https://agentskills.io). + +### 10.2 DĂ©couverte + +``` +Chemins explorĂ©s: +├── $CRUSH_SKILLS_DIR +├── ~/.config/agents/skills/ +├── ~/.config/crush/skills/ +├── .agents/skills/ +├── .crush/skills/ +├── .claude/skills/ +├── .cursor/skills/ +└── Custom via options.skills_paths + +Recherche: +├── fastwalk (concurent, suit symlinks) +├── Cherche fichiers SKILL.md +├── Parse YAML frontmatter (entre ---) +├── Validation: nom alphanum-hyphens ≀64 chars, description ≀1024 chars +└── DĂ©duplication: dernier occurence gagne (user > builtin) +``` + +### 10.3 Injection dans le prompt + +```xml + + + skill-name + Description courte + /path/to/SKILL.md + builtin + + + + + When a user task matches a skill's description, read the skill's SKILL.md file... + +``` + +**Important :** Seules les mĂ©tadonnĂ©es (nom, description, chemin) sont injectĂ©es dans le system prompt. Les **instructions complĂštes** ne sont lues que quand l'agent dĂ©cide d'activer le skill — Ă©conomie de tokens. + +### 10.4 Skill Tracker + +```go +type Tracker struct { + active map[string]bool // Skills actifs (post-dedup, post-filter) + loaded map[string]bool // Skills dont les instructions ont Ă©tĂ© lues + mu sync.RWMutex +} + +MarkLoaded(name) // Marque un skill comme lu (uniquement si dans active set) +IsLoaded(name) // VĂ©rifie si dĂ©jĂ  chargĂ© +LoadedNames() // Liste des skills chargĂ©s +``` + +--- + +## 11. IntĂ©gration LSP + +### 11.1 Configuration + +```yaml +lsp: + - command: "gopls" + args: ["serve"] + env: {} + file_types: [".go"] + root_markers: ["go.mod"] + init_options: {} +``` + +### 11.2 Outils LSP + +- `lsp_diagnostics` — Diagnostics par fichier ou projet entier (max 10 fichiers, 5s wait) +- `lsp_references` — Recherche de rĂ©fĂ©rences symboliques (grep + LSP FindReferences) +- `lsp_restart` — RedĂ©marrage d'un ou tous les clients LSP + +### 11.3 IntĂ©gration dans les outils + +``` +view → openInLSPs + wait 300ms pour diagnostics +edit → notifyLSPs + wait 5s pour diagnostics +write → notifyLSPs + wait 5s pour diagnostics +``` + +Les diagnostics sont appendĂ©s dans les rĂ©ponses des outils via des tags XML : +```xml +... +... +errors: N, warnings: M +``` + +--- + +## 12. IntĂ©gration MCP + +### 12.1 Configuration + +```yaml +mcp: + - name: "my-server" + command: "npx" # Mode stdio + args: ["-y", "my-mcp"] + env: {} + url: "" # OU mode HTTP/SSE + timeout: 30s + disabled_tools: [] +``` + +### 12.2 Outils MCP dynamiques + +- Outils nommĂ©s `mcp_{server}_{tool}` +- SchĂ©ma extrait de `InputSchema` MCP +- Permission requise (sauf whitelist Docker) +- Support image/mĂ©dia dans les rĂ©sultats + +### 12.3 Instructions MCP injectĂ©es + +Les instructions des serveurs MCP connectĂ©s sont injectĂ©es dans le system prompt via ``. + +--- + +## 13. Structure de la base de donnĂ©es + +### 13.1 Tables principales + +```sql +-- Sessions +sessions ( + id, title, prompt_tokens, completion_tokens, + summary_message_id, -- ID du message rĂ©sumĂ© (NULL si pas de rĂ©sumĂ©) + cost, todos, -- CoĂ»t et todo list + created_at, updated_at +) + +-- Messages +messages ( + id, session_id, role, -- user/assistant/system/tool + parts, -- JSON array de {type, data} + model, provider, + is_summary_message, -- Flag de compaction + created_at, finished_at +) + +-- Fichiers (version history) +files ( + id, session_id, path, + content, version, -- Auto-incrĂ©mentĂ© par path + is_new, + created_at +) + +-- Fichiers lus (read tracking) +read_files ( + path, session_id, + last_read_time -- Pour "read before edit" +) +``` + +### 13.2 RequĂȘtes clĂ©s + +```sql +-- Messages d'une session (chronologique) +SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC; + +-- Update atomique des tokens (Ă©vite race conditions) +UPDATE sessions SET + prompt_tokens = prompt_tokens + ?, + completion_tokens = completion_tokens + ?, + cost = cost + ? +WHERE id = ?; + +-- Derniers fichiers par path (version max) +SELECT f.* FROM files f +INNER JOIN ( + SELECT path, MAX(version) as max_ver + FROM files WHERE session_id = ? + GROUP BY path +) latest ON f.path = latest.path AND f.version = latest.max_ver; + +-- Stats: utilisation des outils via JSON +SELECT json_extract(part.data, '$.name') as tool_name, COUNT(*) +FROM messages, json_each(messages.parts) as part +WHERE json_extract(part.value, '$.type') = 'tool_call' +GROUP BY tool_name; +``` + +--- + +## 14. Fichiers clĂ©s Ă  explorer + +### Architecture et flow principal + +| Fichier | Ce qu'il contient | +|---------|------------------| +| `internal/agent/agent.go` | Moteur IA — construction messages, streaming, rĂ©sumĂ©, queue | +| `internal/agent/coordinator.go` | Orchestration — crĂ©ation agents, tools, models, provider options | +| `internal/agent/prompts.go` | Factory de prompts systĂšme | +| `internal/agent/loop_detection.go` | DĂ©tection de boucles (SHA-256 signatures) | + +### Templates (ce qui est envoyĂ© Ă  l'IA) + +| Fichier | Ce qu'il contient | +|---------|------------------| +| `internal/agent/templates/coder.md.tpl` | System prompt principal (405 lignes) | +| `internal/agent/templates/task.md.tpl` | Prompt sous-agent | +| `internal/agent/templates/summary.md` | Prompt de rĂ©sumĂ©/compaction | +| `internal/agent/templates/title.md` | Prompt de gĂ©nĂ©ration de titre | +| `internal/agent/templates/agent_tool.md` | Instructions tool agent | +| `internal/agent/templates/agentic_fetch_prompt.md.tpl` | Prompt sous-agent fetch | + +### Outils + +| Fichier | Ce qu'il contient | +|---------|------------------| +| `internal/agent/agent_tool.go` | Tool agent (sous-agent spawner) | +| `internal/agent/agentic_fetch_tool.go` | Tool agentic_fetch | +| `internal/agent/tools/bash.go` | Tool bash | +| `internal/agent/tools/edit.go` | Tool edit | +| `internal/agent/tools/multiedit.go` | Tool multiedit | +| `internal/agent/tools/write.go` | Tool write | +| `internal/agent/tools/view.go` | Tool view | +| `internal/agent/tools/glob.go` | Tool glob | +| `internal/agent/tools/grep.go` | Tool grep | +| `internal/agent/tools/ls.go` | Tool ls | +| `internal/agent/tools/fetch.go` | Tool fetch | +| `internal/agent/tools/sourcegraph.go` | Tool sourcegraph | +| `internal/agent/tools/web_search.go` | Tool web_search (DuckDuckGo) | +| `internal/agent/tools/web_fetch.go` | Tool web_fetch | +| `internal/agent/tools/download.go` | Tool download | +| `internal/agent/tools/todos.go` | Tool todos | +| `internal/agent/tools/diagnostics.go` | Tool LSP diagnostics + helpers | +| `internal/agent/tools/references.go` | Tool LSP references | +| `internal/agent/tools/lsp_restart.go` | Tool LSP restart | +| `internal/agent/tools/crush_info.go` | Tool crush_info | +| `internal/agent/tools/crush_logs.go` | Tool crush_logs | +| `internal/agent/tools/mcp-tools.go` | Outils MCP dynamiques | + +### Messages et donnĂ©es + +| Fichier | Ce qu'il contient | +|---------|------------------| +| `internal/message/message.go` | Service de messages (CRUD + events) | +| `internal/message/content.go` | Types de contenu + conversion fantasy.Message | +| `internal/message/attachment.go` | PiĂšces jointes | +| `internal/history/file.go` | Historique de versions fichiers | +| `internal/db/sql/sessions.sql` | RequĂȘtes SQL sessions | +| `internal/db/sql/messages.sql` | RequĂȘtes SQL messages | +| `internal/db/sql/files.sql` | RequĂȘtes SQL fichiers | +| `internal/db/sql/stats.sql` | RequĂȘtes SQL statistiques | + +### Configuration et providers + +| Fichier | Ce qu'il contient | +|---------|------------------| +| `internal/config/config.go` | Structures de config (models, providers, tools, agents) | +| `internal/config/provider.go` | Chargement des providers (Catwalk + Hyper + cache) | +| `internal/backend/config.go` | API config backend | +| `internal/agent/hyper/provider.go` | Provider Hyper | +| `internal/agent/hyper/provider.json` | DĂ©finition des modĂšles Hyper | + +### Infrastructure + +| Fichier | Ce qu'il contient | +|---------|------------------| +| `internal/shell/shell.go` | Shell POSIX cross-platform (mvdan/sh) | +| `internal/diff/diff.go` | GĂ©nĂ©ration de diffs unifiĂ©s | +| `internal/skills/skills.go` | SystĂšme de skills (dĂ©couverte, injection) | +| `internal/skills/tracker.go` | Suivi des skills chargĂ©s | +| `internal/lsp/manager.go` | Gestionnaire LSP | +| `internal/filetracker/service.go` | Suivi des fichiers lus | + +--- + +## 15. Leçons pour notre application + +### 15.1 Patterns Ă  adopter + +| Pattern | Pourquoi | Comment Crush le fait | +|---------|----------|----------------------| +| **RĂ©sumĂ© automatique adaptatif** | GĂšre les longues conversations sans perte | Seuil 20K tokens (fenĂȘtres >200K) ou 20% (fenĂȘtres <200K), skip si fenĂȘtre inconnue | +| **Deux niveaux de modĂšle** | Économise les tokens coĂ»teux | Large pour coder, Small pour titres et fetch | +| **Skills avec lazy loading** | N'injecte que les mĂ©tadonnĂ©es, charge les instructions Ă  la demande | MĂ©tadonnĂ©es dans system prompt, instructions lues via view | +| **Short tool descriptions** | Économise des tokens sur 22+ outils | FirstLineDescription() par dĂ©faut | +| **Anthropic prompt caching** | RĂ©duit les tokens d'input sur les tours suivants | `ephemeral` sur dernier system + 2 derniers messages | +| **Filetracker read-before-edit** | SĂ©curitĂ© + contexte pour l'IA | Enregistre chaque lecture, vĂ©rifie avant Ă©dition | +| **Loop detection** | ArrĂȘte les boucles infinies coĂ»teuses | SHA-256 signature sur fenĂȘtre de 10 Ă©tapes, max 5 rĂ©pĂ©titions | +| **Orphan filtering** | Nettoie l'historique des messages inutiles | Supprime tool results orphelins, injecte erreurs synthĂ©tiques | +| **Atomic SQL increments** | Pas de race conditions sur les compteurs | `prompt_tokens = prompt_tokens + ?` au lieu de read-modify-write | +| **Message queue** | Pas de perte de messages | Queue + injection dans PrepareStep | + +### 15.2 Optimisations token spĂ©cifiques + +1. **Troncature de sortie** — bash (30K), grep (500 chars/ligne), view (2000 lignes) +2. **Limites de rĂ©sultats** — glob (100), grep (100), ls (1000), sourcegraph (20) +3. **PremiĂšre ligne de description** — Seulement la 1Ăšre ligne des descriptions markdown d'outils +4. **Estimation heuristique** — `len(text) / 4` pour logging sans appel API +5. **RĂ©sumĂ© dĂ©taillĂ© structurĂ©** — Sections obligatoires assurent pas de perte d'information critique +6. **Reset des compteurs aprĂšs rĂ©sumĂ©** — PromptTokens=0, CompletionTokens=rĂ©sumĂ© seulement + +### 15.3 Points d'attention + +- **Le system prompt fait ~405 lignes** — C'est Ă©norme mais structurĂ© en sections XML claires +- **Pas de troncature du system prompt** — Les skills et context files sont injectĂ©s en entier +- **Le rĂ©sumĂ© n'a pas de limite** — *"Err on the side of too much detail"* +- **La dĂ©tection de boucle est basique** — SHA-256 sur ToolName+Input+Output, mais ne dĂ©tecte pas les boucles sĂ©mantiques +- **Pas de token counting prĂ©cis** — Heuristique 4 chars/token, pas de tiktoken +- **Le file history persiste** — Les versions de fichiers survivent au rĂ©sumĂ© (pas dans le contexte LLM mais disponibles dans l'UI) + +### 15.4 Architecture recommandĂ©e + +``` +Pour notre app, inspirons-nous de: + +1. Coordinator Pattern + └── Orchestrateur central qui assemble models + tools + prompts + └── Agents par session avec state isolĂ© + +2. Summary/Compaction Pipeline + └── Seuils adaptatifs (fenĂȘtre large vs small) + └── RĂ©sumĂ© structurĂ© avec sections obligatoires + └── Reset des compteurs aprĂšs compaction + +3. Tool Architecture + └── Interface uniforme (params, execute, response) + └── Permission system par action + └── Output truncation systĂ©matique + └── Metadata sur chaque rĂ©ponse + +4. Provider Layer + └── JSON merge en 3 couches pour options + └── Support multi-provider avec routage + └── Cache avec ETag + fallback + +5. Lazy Skill Loading + └── MĂ©tadonnĂ©es dans system prompt + └── Instructions complĂštes Ă  la demande + └── Tracker pour Ă©viter les rechargements +``` + +--- + +## Annexe : SchĂ©ma de dĂ©pendances + +``` +main.go + └── internal/cmd/ (CLI commands) + └── internal/backend/ (workspace management) + └── internal/app/ (application logic) + └── internal/agent/coordinator.go (orchestration) + ├── internal/agent/agent.go (moteur IA) + │ ├── internal/agent/templates/ (prompts) + │ ├── internal/agent/prompt/ (prompt builder) + │ ├── internal/message/ (message types) + │ └── internal/agent/loop_detection.go + ├── internal/agent/tools/ (22+ outils) + ├── internal/skills/ (skill discovery) + ├── internal/config/ (configuration) + ├── internal/lsp/ (LSP management) + ├── internal/shell/ (shell execution) + ├── internal/db/ (SQLite persistence) + ├── internal/filetracker/ (read tracking) + └── internal/diff/ (diff generation) +``` + +--- + +*Rapport gĂ©nĂ©rĂ© le 26 avril 2026 — BasĂ© sur l'analyse du commit `HEAD` de [charmbracelet/crush](https://github.com/charmbracelet/crush)* diff --git a/internal/agent/definitions.go b/internal/agent/definitions.go index 898b421..affc551 100644 --- a/internal/agent/definitions.go +++ b/internal/agent/definitions.go @@ -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 ") { diff --git a/internal/agent/prompts/studio_system.md b/internal/agent/prompts/studio_system.md index d81b6bb..fc16ae4 100644 --- a/internal/agent/prompts/studio_system.md +++ b/internal/agent/prompts/studio_system.md @@ -39,6 +39,7 @@ Muyue gĂšre : - **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 diff --git a/internal/api/chat_engine.go b/internal/api/chat_engine.go index 65d01f6..cc463df 100644 --- a/internal/api/chat_engine.go +++ b/internal/api/chat_engine.go @@ -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, }) diff --git a/internal/api/conversation.go b/internal/api/conversation.go index 195c846..9382870 100644 --- a/internal/api/conversation.go +++ b/internal/api/conversation.go @@ -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 { diff --git a/internal/api/handlers_chat.go b/internal/api/handlers_chat.go index 673f3a3..b3b7f9f 100644 --- a/internal/api/handlers_chat.go +++ b/internal/api/handlers_chat.go @@ -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[^>]*>.*?`) +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(), }) } diff --git a/internal/api/handlers_info.go b/internal/api/handlers_info.go index 33850c4..cf99f6b 100644 --- a/internal/api/handlers_info.go +++ b/internal/api/handlers_info.go @@ -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(), }) } diff --git a/internal/api/handlers_shell_chat.go b/internal/api/handlers_shell_chat.go index d1cd38a..bb736a0 100644 --- a/internal/api/handlers_shell_chat.go +++ b/internal/api/handlers_shell_chat.go @@ -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), }) } diff --git a/internal/api/image_cache.go b/internal/api/image_cache.go new file mode 100644 index 0000000..f5dd286 --- /dev/null +++ b/internal/api/image_cache.go @@ -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) +} diff --git a/internal/api/server.go b/internal/api/server.go index 1b37478..8ead886 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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 } diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 235c41e..57e95ae 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -20,14 +20,42 @@ var thinkRegex = regexp.MustCompile(`(?s)<[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...) diff --git a/internal/workflow/planner.go b/internal/workflow/planner.go index 3beeba0..1794879 100644 --- a/internal/workflow/planner.go +++ b/internal/workflow/planner.go @@ -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) diff --git a/web/src/api/client.js b/web/src/api/client.js index 0c1defa..f67b839 100644 --- a/web/src/api/client.js +++ b/web/src/api/client.js @@ -62,15 +62,15 @@ const api = { clearShellChat: () => request('/shell/chat/clear', { method: 'POST' }), analyzeSystem: () => request('/shell/analyze', { method: 'POST' }), getShellAnalysis: () => request('/shell/analysis'), - sendChat: (message, stream = true, onChunk, signal) => { + sendChat: (message, stream = true, onChunk, signal, images = []) => { 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) => { fetch(`${API_BASE}/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message, stream: true }), + body: JSON.stringify({ message, stream: true, images }), signal, }).then(async (res) => { if (!res.ok) { diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx index 6d707b4..13925e6 100644 --- a/web/src/components/App.jsx +++ b/web/src/components/App.jsx @@ -152,7 +152,7 @@ export default function App() {