Compare commits
4 Commits
v0.3.1-bet
...
v0.3.1-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0496ca789b | ||
|
|
b407ab879b | ||
|
|
12df184e11 | ||
|
|
8af6d25e28 |
@@ -2,11 +2,17 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/muyue/muyue/internal/orchestrator"
|
"github.com/muyue/muyue/internal/orchestrator"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var toolCallRegex = regexp.MustCompile(`\[TOOL_CALL:\{[^\}]+\}\]`)
|
||||||
|
|
||||||
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != "POST" {
|
if r.Method != "POST" {
|
||||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
@@ -36,13 +42,27 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
orb.SetSystemPrompt(`Tu es l'orchestrateur IA de Muyue Studio. Tu aides l'utilisateur dans ses tâches de développement logiciel. Tu peux :
|
orb.SetSystemPrompt(`Tu es l'assistant IA de Muyue Studio. Tu as accès à un outil "crush" pour exécuter des tâches complexes sur l'ordinateur de l'utilisateur.
|
||||||
|- Créer et gérer des plans de développement étape par étape
|
|
||||||
|- Proposer des agents (outils comme Crush, Claude Code, etc.) pour exécuter des tâches spécifiques
|
|
||||||
|- Suivre la progression de tâches multi-étapes
|
|
||||||
|- Suggérer des modifications de fichiers, des revues de code, et des décisions d'architecture
|
|
||||||
|
|
||||||
Sois concis, actionnable, et structuré. Quand tu proposes un plan, utilise des étapes numérotées claires. Quand tu références des fichiers, utilise des chemins relatifs. Tu es intégré dans l'application desktop Muyue.`)
|
RÈGLES ABSOLUES:
|
||||||
|
1. Tu as DEUX possibilités ONLY:
|
||||||
|
- Répondre directement à l'utilisateur avec tes connaissances
|
||||||
|
- Demander l'exécution d'une tâche via crush en utilisant ce format EXACT:
|
||||||
|
[TOOL_CALL:{"tool":"crush","task":"description de la tâche"}]
|
||||||
|
|
||||||
|
2. Quand tu utilises [TOOL_CALL:...], le système exécutera la tâche et te donnera le résultat.
|
||||||
|
Tu peux ensuite répondre à l'utilisateur avec ce résultat.
|
||||||
|
|
||||||
|
3. SOIS CONCIS - pas de blabla, vais droit au but.
|
||||||
|
|
||||||
|
4. L'utilisateur ne voit PAS tes pensées entre <think> tags.
|
||||||
|
|
||||||
|
5. EXEMPLES d'utilisation de tool:
|
||||||
|
- "cherche tous les fichiers .md dans le projet" → [TOOL_CALL:{"tool":"crush","task":"Recherche les fichiers .md dans le projet courant"}]
|
||||||
|
- "aide-moi à déboguer cette erreur" → tu peux répondre directement si tu as assez d'info, sinon utiliser tool
|
||||||
|
- "quelle est la météo?" → [TOOL_CALL:{"tool":"crush","task":"Cherche la météo actuelle"}]
|
||||||
|
|
||||||
|
6. Ne fais PAS de multi-step tool calls dans une seule réponse. Attends le résultat avant de continuer.`)
|
||||||
|
|
||||||
if body.Stream {
|
if body.Stream {
|
||||||
w.Header().Set("Content-Type", "text/event-stream")
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
@@ -53,6 +73,22 @@ Sois concis, actionnable, et structuré. Quand tu proposes un plan, utilise des
|
|||||||
flusher, canFlush := w.(http.Flusher)
|
flusher, canFlush := w.(http.Flusher)
|
||||||
|
|
||||||
result, err := orb.SendStream(body.Message, func(chunk string) {
|
result, err := orb.SendStream(body.Message, func(chunk string) {
|
||||||
|
if strings.HasPrefix(chunk, "<think") {
|
||||||
|
data, _ := json.Marshal(map[string]string{"thinking": strings.TrimPrefix(chunk, "<think")})
|
||||||
|
w.Write([]byte("data: " + string(data) + "\n\n"))
|
||||||
|
if canFlush {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if chunk == "</think>" {
|
||||||
|
data, _ := json.Marshal(map[string]string{"thinking_end": "true"})
|
||||||
|
w.Write([]byte("data: " + string(data) + "\n\n"))
|
||||||
|
if canFlush {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
data, _ := json.Marshal(map[string]string{"content": chunk})
|
data, _ := json.Marshal(map[string]string{"content": chunk})
|
||||||
w.Write([]byte("data: " + string(data) + "\n\n"))
|
w.Write([]byte("data: " + string(data) + "\n\n"))
|
||||||
if canFlush {
|
if canFlush {
|
||||||
@@ -68,7 +104,9 @@ Sois concis, actionnable, et structuré. Quand tu proposes un plan, utilise des
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.convStore.Add("assistant", result)
|
// Process tool calls if any
|
||||||
|
cleanResult := processToolCalls(result)
|
||||||
|
s.convStore.Add("assistant", cleanResult)
|
||||||
|
|
||||||
data, _ := json.Marshal(map[string]string{"done": "true"})
|
data, _ := json.Marshal(map[string]string{"done": "true"})
|
||||||
w.Write([]byte("data: " + string(data) + "\n\n"))
|
w.Write([]byte("data: " + string(data) + "\n\n"))
|
||||||
@@ -83,8 +121,64 @@ Sois concis, actionnable, et structuré. Quand tu proposes un plan, utilise des
|
|||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.convStore.Add("assistant", result)
|
cleanResult := processToolCalls(result)
|
||||||
writeJSON(w, map[string]string{"content": result})
|
s.convStore.Add("assistant", cleanResult)
|
||||||
|
writeJSON(w, map[string]string{"content": cleanResult})
|
||||||
|
}
|
||||||
|
|
||||||
|
func processToolCalls(content string) string {
|
||||||
|
matches := toolCallRegex.FindAllString(content, -1)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return cleanThinkingTags(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result strings.Builder
|
||||||
|
clean := content
|
||||||
|
|
||||||
|
for _, match := range matches {
|
||||||
|
// Extract tool and task from [TOOL_CALL:{...}]
|
||||||
|
inner := strings.TrimPrefix(match, "[TOOL_CALL:")
|
||||||
|
inner = strings.TrimSuffix(inner, "]}") + "}"
|
||||||
|
|
||||||
|
var call struct {
|
||||||
|
Tool string `json:"tool"`
|
||||||
|
Task string `json:"task"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(inner), &call); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if call.Tool == "crush" && call.Task != "" {
|
||||||
|
result.WriteString(fmt.Sprintf("> %s\n\n", call.Task))
|
||||||
|
output := executeCrush(call.Task)
|
||||||
|
result.WriteString(output)
|
||||||
|
result.WriteString("\n\n---\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
clean = strings.Replace(clean, match, "", 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
clean = cleanThinkingTags(clean)
|
||||||
|
|
||||||
|
if result.Len() > 0 {
|
||||||
|
clean = strings.TrimSpace(clean) + "\n\n" + strings.TrimSpace(result.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
return clean
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanThinkingTags(content string) string {
|
||||||
|
re := regexp.MustCompile(`(?s)<think[^>]*>.*?</think>`)
|
||||||
|
return re.ReplaceAllString(content, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeCrush(task string) string {
|
||||||
|
cmd := exec.Command("crush", "run", task)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("Erreur: %v\n%s", err, string(output))
|
||||||
|
}
|
||||||
|
return string(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) autoSummarize() {
|
func (s *Server) autoSummarize() {
|
||||||
@@ -139,4 +233,4 @@ func (s *Server) handleChatClear(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
s.convStore.Clear()
|
s.convStore.Clear()
|
||||||
writeJSON(w, map[string]string{"status": "ok"})
|
writeJSON(w, map[string]string{"status": "ok"})
|
||||||
}
|
}
|
||||||
80
internal/api/handlers_tools_exec.go
Normal file
80
internal/api/handlers_tools_exec.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type toolCallRequest struct {
|
||||||
|
Tool string `json:"tool"`
|
||||||
|
Task string `json:"task"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type toolResult struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Output string `json:"output"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleToolCall(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req toolCallRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Tool != "crush" {
|
||||||
|
writeError(w, "unsupported tool: "+req.Tool, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Task == "" {
|
||||||
|
writeError(w, "task is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := executeTool(req.Tool, req.Task)
|
||||||
|
writeJSON(w, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeTool(tool, task string) toolResult {
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
|
||||||
|
switch tool {
|
||||||
|
case "crush":
|
||||||
|
cmd = exec.Command("crush", "run", task)
|
||||||
|
default:
|
||||||
|
return toolResult{Success: false, Error: "unknown tool: " + tool}
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return toolResult{
|
||||||
|
Success: false,
|
||||||
|
Output: string(output),
|
||||||
|
Error: err.Error(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return toolResult{
|
||||||
|
Success: true,
|
||||||
|
Output: string(output),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildToolMessage(tool, task string, history []string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("TASK: " + task + "\n\n")
|
||||||
|
b.WriteString("CONVERSATION HISTORY:\n")
|
||||||
|
for _, msg := range history {
|
||||||
|
b.WriteString(strings.Repeat(" ", 4) + strings.Join(strings.Split(msg, "\n"), "\n"+strings.Repeat(" ", 4)) + "\n")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -105,6 +105,12 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ignore invalid shell paths (e.g., single characters from race condition)
|
||||||
|
if len(shell) <= 1 {
|
||||||
|
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid shell config"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := os.Stat(shell); err != nil {
|
if _, err := os.Stat(shell); err != nil {
|
||||||
conn.WriteJSON(wsMessage{Type: "error", Data: fmt.Sprintf("shell not found: %s", shell)})
|
conn.WriteJSON(wsMessage{Type: "error", Data: fmt.Sprintf("shell not found: %s", shell)})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -68,7 +68,9 @@ const api = {
|
|||||||
if (data.done) { resolve(full); return }
|
if (data.done) { resolve(full); return }
|
||||||
if (data.content) {
|
if (data.content) {
|
||||||
full += data.content
|
full += data.content
|
||||||
if (onChunk) onChunk(full)
|
if (onChunk) onChunk(full, data)
|
||||||
|
} else if (data.thinking !== undefined || data.thinking_end) {
|
||||||
|
if (onChunk) onChunk(full, data)
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -380,13 +380,61 @@ export default function Shell({ api }) {
|
|||||||
setAiLoading(true)
|
setAiLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await api.runCommand(`echo "AI: ${text}"`, '')
|
const res = await api.runCommand(`echo "AI: ${text}"`, '')
|
||||||
setAiMessages(prev => [...prev, { role: 'ai', content: res.output || t('shell.noResponse') }])
|
const output = res.output || t('shell.noResponse')
|
||||||
|
parseAndAddAiMessages(output)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setAiMessages(prev => [...prev, { role: 'ai', content: `${t('shell.error')}: ${err.message}` }])
|
setAiMessages(prev => [...prev, { role: 'ai', content: `${t('shell.error')}: ${err.message}` }])
|
||||||
}
|
}
|
||||||
setAiLoading(false)
|
setAiLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parseAndAddAiMessages = (text) => {
|
||||||
|
const lines = text.split('\n')
|
||||||
|
let buffer = ''
|
||||||
|
let inBlock = false
|
||||||
|
|
||||||
|
const flushBuffer = () => {
|
||||||
|
if (buffer.trim()) {
|
||||||
|
setAiMessages(prev => [...prev, { role: 'ai', content: buffer.trim() }])
|
||||||
|
}
|
||||||
|
buffer = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const toolMatch = line.match(/^\[TOOL_CALL:\{.*\}\]$/)
|
||||||
|
if (toolMatch) {
|
||||||
|
flushBuffer()
|
||||||
|
try {
|
||||||
|
const toolData = JSON.parse(toolMatch[0].slice(10, -1))
|
||||||
|
setAiMessages(prev => [...prev, {
|
||||||
|
role: 'tool',
|
||||||
|
content: `${t('shell.toolLaunched')}: ${toolData.tool || 'tool'}`,
|
||||||
|
args: toolData.task || toolData.args || '',
|
||||||
|
}])
|
||||||
|
} catch {
|
||||||
|
setAiMessages(prev => [...prev, { role: 'tool', content: line, args: '' }])
|
||||||
|
}
|
||||||
|
} else if (line.match(/^(Reflexion|Thought|thinking):/i) || line.startsWith('>')) {
|
||||||
|
if (buffer.trim() && !inBlock) {
|
||||||
|
flushBuffer()
|
||||||
|
}
|
||||||
|
inBlock = true
|
||||||
|
const cleaned = line.replace(/^(Reflexion|Thought|thinking):\s*/i, '').replace(/^>\s*/, '')
|
||||||
|
if (buffer) buffer += ' '
|
||||||
|
buffer += cleaned
|
||||||
|
} else {
|
||||||
|
if (inBlock && buffer.trim()) {
|
||||||
|
setAiMessages(prev => [...prev, { role: 'thinking', content: buffer.trim() }])
|
||||||
|
buffer = ''
|
||||||
|
}
|
||||||
|
inBlock = false
|
||||||
|
if (buffer) buffer += '\n'
|
||||||
|
buffer += line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushBuffer()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shell-layout">
|
<div className="shell-layout">
|
||||||
<div className="shell-terminal-col">
|
<div className="shell-terminal-col">
|
||||||
@@ -507,6 +555,7 @@ export default function Shell({ api }) {
|
|||||||
{aiMessages.map((msg, i) => (
|
{aiMessages.map((msg, i) => (
|
||||||
<div key={i} className={`ai-message ${msg.role}`}>
|
<div key={i} className={`ai-message ${msg.role}`}>
|
||||||
{msg.content}
|
{msg.content}
|
||||||
|
{msg.args && <div className="tool-args">{msg.args}</div>}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>}
|
{aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>}
|
||||||
|
|||||||
@@ -1,6 +1,35 @@
|
|||||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
import { useI18n } from '../i18n'
|
import { useI18n } from '../i18n'
|
||||||
|
|
||||||
|
const RANKS = {
|
||||||
|
commandant: { label: 'Commandant', short: 'CDT', color: '#FFD740' },
|
||||||
|
general: { label: 'General', short: 'GEN', color: '#FF9100' },
|
||||||
|
colonel: { label: 'Colonel', short: 'COL', color: '#FF6D00' },
|
||||||
|
lieutenant: { label: 'Lieutenant', short: 'LT', color: '#448AFF' },
|
||||||
|
soldat: { label: 'Soldat', short: 'SDT', color: '#00E676' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRank(role) {
|
||||||
|
if (role === 'user') return RANKS.commandant
|
||||||
|
if (role === 'system') return null
|
||||||
|
return RANKS.general
|
||||||
|
}
|
||||||
|
|
||||||
|
function RankIcon({ rank }) {
|
||||||
|
if (rank === RANKS.commandant) {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={rank.color} strokeWidth="2">
|
||||||
|
<polygon points="12,2 15.09,8.26 22,9.27 17,14.14 18.18,21.02 12,17.77 5.82,21.02 7,14.14 2,9.27 8.91,8.26"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={rank.color} strokeWidth="2">
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function renderContent(text) {
|
function renderContent(text) {
|
||||||
const parts = []
|
const parts = []
|
||||||
const codeBlockRegex = /(```[\s\S]*?```)/g
|
const codeBlockRegex = /(```[\s\S]*?```)/g
|
||||||
@@ -34,17 +63,25 @@ function formatText(text) {
|
|||||||
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<span class="msg-step"><span class="msg-step-num">$1</span>$2</span>')
|
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<span class="msg-step"><span class="msg-step-num">$1</span>$2</span>')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ThinkingBlock({ content, done }) {
|
||||||
|
return (
|
||||||
|
<div className={`feed-thinking-block ${done ? 'done' : 'active'}`}>
|
||||||
|
<div className="feed-thinking-header">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>
|
||||||
|
</svg>
|
||||||
|
<span>Reflexion</span>
|
||||||
|
{!done && <span className="feed-thinking-dots"><span/><span/><span/></span>}
|
||||||
|
</div>
|
||||||
|
<div className="feed-thinking-content">{content}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function FeedItem({ msg }) {
|
function FeedItem({ msg }) {
|
||||||
const isUser = msg.role === 'user'
|
const isUser = msg.role === 'user'
|
||||||
const isSystem = msg.role === 'system'
|
const isSystem = msg.role === 'system'
|
||||||
|
const rank = getRank(msg.role)
|
||||||
const roleLabel = isUser ? null : isSystem ? null : (
|
|
||||||
<div className="feed-avatar">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''
|
const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''
|
||||||
|
|
||||||
@@ -58,16 +95,24 @@ function FeedItem({ msg }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cleanContent = msg.content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`feed-item ${msg.role}`}>
|
<div className={`feed-item ${msg.role}`}>
|
||||||
{roleLabel}
|
<div className={`feed-avatar ${isUser ? 'user-rank' : 'ai-rank'}`}>
|
||||||
|
<RankIcon rank={rank} />
|
||||||
|
</div>
|
||||||
<div className="feed-body">
|
<div className="feed-body">
|
||||||
<div className="feed-header">
|
<div className="feed-header">
|
||||||
<span className="feed-role">{isUser ? 'Vous' : 'IA'}</span>
|
<span className="feed-rank-badge" style={{ color: rank.color, borderColor: rank.color }}>
|
||||||
|
{rank.short}
|
||||||
|
</span>
|
||||||
|
<span className="feed-role">{rank.label}</span>
|
||||||
{timeStr && <span className="feed-time">{timeStr}</span>}
|
{timeStr && <span className="feed-time">{timeStr}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
{msg.thinking && <ThinkingBlock content={msg.thinking} done />}
|
||||||
<div className="feed-content">
|
<div className="feed-content">
|
||||||
{renderContent(msg.content).map((part, i) =>
|
{renderContent(cleanContent).map((part, i) =>
|
||||||
part.type === 'code' ? (
|
part.type === 'code' ? (
|
||||||
<div key={i} className="studio-code-block">
|
<div key={i} className="studio-code-block">
|
||||||
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
|
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
|
||||||
@@ -83,31 +128,43 @@ function FeedItem({ msg }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function StreamingItem({ content }) {
|
function StreamingItem({ content, thinking }) {
|
||||||
|
const rank = RANKS.general
|
||||||
|
const cleanContent = content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="feed-item assistant">
|
<div className="feed-item assistant">
|
||||||
<div className="feed-avatar">
|
<div className="feed-avatar ai-rank">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<RankIcon rank={rank} />
|
||||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="feed-body">
|
<div className="feed-body">
|
||||||
<div className="feed-header">
|
<div className="feed-header">
|
||||||
<span className="feed-role">IA</span>
|
<span className="feed-rank-badge" style={{ color: rank.color, borderColor: rank.color }}>
|
||||||
</div>
|
{rank.short}
|
||||||
<div className="feed-content">
|
</span>
|
||||||
{renderContent(content).map((part, i) =>
|
<span className="feed-role">{rank.label}</span>
|
||||||
part.type === 'code' ? (
|
|
||||||
<div key={i} className="studio-code-block">
|
|
||||||
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
|
|
||||||
<pre><code>{part.content}</code></pre>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
<span className="studio-cursor" />
|
|
||||||
</div>
|
</div>
|
||||||
|
{thinking && <ThinkingBlock content={thinking} done={false} />}
|
||||||
|
{!thinking && !cleanContent && (
|
||||||
|
<div className="feed-content">
|
||||||
|
<div className="studio-thinking"><span /><span /><span /></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{cleanContent && (
|
||||||
|
<div className="feed-content">
|
||||||
|
{renderContent(cleanContent).map((part, i) =>
|
||||||
|
part.type === 'code' ? (
|
||||||
|
<div key={i} className="studio-code-block">
|
||||||
|
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
|
||||||
|
<pre><code>{part.content}</code></pre>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<span className="studio-cursor" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -119,6 +176,7 @@ export default function Studio({ api }) {
|
|||||||
const [input, setInput] = useState('')
|
const [input, setInput] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [streaming, setStreaming] = useState('')
|
const [streaming, setStreaming] = useState('')
|
||||||
|
const [streamThinking, setStreamThinking] = useState('')
|
||||||
const [loaded, setLoaded] = useState(false)
|
const [loaded, setLoaded] = useState(false)
|
||||||
const messagesEnd = useRef(null)
|
const messagesEnd = useRef(null)
|
||||||
const textareaRef = useRef(null)
|
const textareaRef = useRef(null)
|
||||||
@@ -143,7 +201,7 @@ export default function Studio({ api }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
|
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
}, [messages, streaming])
|
}, [messages, streaming, streamThinking])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (textareaRef.current) {
|
if (textareaRef.current) {
|
||||||
@@ -175,21 +233,33 @@ export default function Studio({ api }) {
|
|||||||
setMessages(prev => [...prev, userMsg])
|
setMessages(prev => [...prev, userMsg])
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setStreaming('')
|
setStreaming('')
|
||||||
|
setStreamThinking('')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let accumulated = ''
|
let accumulated = ''
|
||||||
await api.sendChat(text, true, (partial) => {
|
let thinking = ''
|
||||||
|
|
||||||
|
await api.sendChat(text, true, (partial, event) => {
|
||||||
|
if (event && (event.thinking_start || event.thinking_end || event.thinking !== undefined)) {
|
||||||
|
if (event.thinking !== undefined) {
|
||||||
|
thinking += event.thinking
|
||||||
|
setStreamThinking(thinking)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
accumulated = partial
|
accumulated = partial
|
||||||
setStreaming(partial)
|
setStreaming(partial)
|
||||||
})
|
})
|
||||||
|
|
||||||
const finalContent = accumulated || t('studio.noResponse')
|
const finalContent = accumulated || t('studio.noResponse')
|
||||||
setMessages(prev => [...prev, {
|
const aiMsg = {
|
||||||
id: (Date.now() + 1).toString(),
|
id: (Date.now() + 1).toString(),
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: finalContent,
|
content: finalContent,
|
||||||
time: new Date().toISOString(),
|
time: new Date().toISOString(),
|
||||||
}])
|
}
|
||||||
|
if (thinking) aiMsg.thinking = thinking
|
||||||
|
setMessages(prev => [...prev, aiMsg])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMessages(prev => [...prev, {
|
setMessages(prev => [...prev, {
|
||||||
id: (Date.now() + 1).toString(),
|
id: (Date.now() + 1).toString(),
|
||||||
@@ -200,6 +270,7 @@ export default function Studio({ api }) {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setStreaming('')
|
setStreaming('')
|
||||||
|
setStreamThinking('')
|
||||||
}
|
}
|
||||||
}, [input, loading, api, t, handleClear])
|
}, [input, loading, api, t, handleClear])
|
||||||
|
|
||||||
@@ -228,20 +299,8 @@ export default function Studio({ api }) {
|
|||||||
{messages.map(msg => (
|
{messages.map(msg => (
|
||||||
<FeedItem key={msg.id} msg={msg} />
|
<FeedItem key={msg.id} msg={msg} />
|
||||||
))}
|
))}
|
||||||
{streaming && <StreamingItem content={streaming} />}
|
{(streaming || streamThinking || loading) && (
|
||||||
{loading && !streaming && (
|
<StreamingItem content={streaming} thinking={streamThinking} />
|
||||||
<div className="feed-item assistant">
|
|
||||||
<div className="feed-avatar">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="feed-body">
|
|
||||||
<div className="feed-content">
|
|
||||||
<div className="studio-thinking"><span /><span /><span /></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<div ref={messagesEnd} />
|
<div ref={messagesEnd} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ const en = {
|
|||||||
aiAssistant: 'AI Assistant',
|
aiAssistant: 'AI Assistant',
|
||||||
aiWelcome: 'Hello! I can help you with terminal commands. Ask me anything!',
|
aiWelcome: 'Hello! I can help you with terminal commands. Ask me anything!',
|
||||||
askAi: 'Ask AI assistant...',
|
askAi: 'Ask AI assistant...',
|
||||||
|
toolLaunched: 'Tool launched',
|
||||||
},
|
},
|
||||||
|
|
||||||
config: {
|
config: {
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ const fr = {
|
|||||||
aiAssistant: 'Assistant IA',
|
aiAssistant: 'Assistant IA',
|
||||||
aiWelcome: 'Bonjour ! Je peux vous aider avec les commandes du terminal. Demandez-moi n\'importe quoi !',
|
aiWelcome: 'Bonjour ! Je peux vous aider avec les commandes du terminal. Demandez-moi n\'importe quoi !',
|
||||||
askAi: 'Interroger l\'assistant IA...',
|
askAi: 'Interroger l\'assistant IA...',
|
||||||
|
toolLaunched: 'Outil lanc\u00e9',
|
||||||
},
|
},
|
||||||
|
|
||||||
config: {
|
config: {
|
||||||
|
|||||||
@@ -391,6 +391,10 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.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.ai { background: var(--bg-card); border-left: 3px solid var(--accent); }
|
.ai-message.ai { background: var(--bg-card); border-left: 3px solid var(--accent); }
|
||||||
.ai-message.user { background: var(--accent-bg); border-left: 3px solid var(--accent-muted); }
|
.ai-message.user { background: var(--accent-bg); border-left: 3px solid var(--accent-muted); }
|
||||||
|
.ai-message.thinking { background: var(--bg-elevated); border-left: 3px solid var(--info); font-style: italic; color: var(--text-tertiary); }
|
||||||
|
.ai-message.tool { background: var(--bg-elevated); border-left: 3px solid var(--warning); }
|
||||||
|
.ai-message.tool .tool-name { font-weight: 700; color: var(--warning); }
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user