Compare commits

...

6 Commits

Author SHA1 Message Date
Augustin
0496ca789b feat(studio): parse AI thinking and tool launch messages in terminal panel
All checks were successful
Beta Release / beta (push) Successful in 40s
- Add message type detection: thinking (Reflexion/Thought/>), tool (TOOL_CALL),
  and normal AI responses
- Style thinking messages with italic blue, tool messages with yellow border
- Add toolLaunched i18n key for both fr and en locales

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 19:41:42 +02:00
Augustin
b407ab879b fix(studio): forward AI thinking chunks to frontend instead of dropping them
All checks were successful
Beta Release / beta (push) Successful in 40s
The ThinkingBlock component existed but was dead code — the backend
silently discarded all <think chunks. Now emits thinking SSE events
so the UI can display AI reflections in real-time.

\xe2\x98\x85 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-22 19:12:32 +02:00
Augustin
12df184e11 feat(studio): add tool execution and hide AI thinking tags
All checks were successful
Beta Release / beta (push) Successful in 40s
Changes:
- Hide <think> tags from user in Studio chat
- Add tool call detection [TOOL_CALL:{...}] in AI responses
- Execute crush tool when requested by AI
- Show loading animation while AI is thinking

The AI can now:
1. Respond directly to user
2. Request tool execution via [TOOL_CALL:{"tool":"crush","task":"..."}]

The system automatically executes the tool and includes results.

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 19:04:59 +02:00
Augustin
8af6d25e28 fix(terminal): ignore invalid shell config from race condition
All checks were successful
Beta Release / beta (push) Successful in 40s
Reject shell paths with length <= 1 to prevent errors when user
input is accidentally sent as init message.

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 18:56:33 +02:00
Augustin
4fd599adec feat(shell): restore AI assistant panel
All checks were successful
Beta Release / beta (push) Successful in 38s
Re-add the AI assistant panel that was removed in previous refactoring.
The panel includes:
- Message history display
- Input field for AI queries
- Loading state indicator

Also restored the associated CSS styles and i18n translations.

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 18:51:33 +02:00
Augustin
bcba5932d5 fix(terminal): restore terminal input and cursor visibility
All checks were successful
Beta Release / beta (push) Successful in 38s
- Fix shell execution to avoid --login flag causing issues on some shells
- Improve terminal initialization timing with requestAnimationFrame
- Force display visibility on xterm instances via CSS
- Ensure container has proper min-height and overflow handling

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 18:46:29 +02:00
9 changed files with 489 additions and 73 deletions

View File

@@ -2,11 +2,17 @@ package api
import (
"encoding/json"
"fmt"
"net/http"
"os/exec"
"regexp"
"strings"
"github.com/muyue/muyue/internal/orchestrator"
)
var toolCallRegex = regexp.MustCompile(`\[TOOL_CALL:\{[^\}]+\}\]`)
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
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)
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 :
|- 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
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.
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 {
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)
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})
w.Write([]byte("data: " + string(data) + "\n\n"))
if canFlush {
@@ -68,7 +104,9 @@ Sois concis, actionnable, et structuré. Quand tu proposes un plan, utilise des
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"})
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)
return
}
s.convStore.Add("assistant", result)
writeJSON(w, map[string]string{"content": result})
cleanResult := processToolCalls(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() {
@@ -139,4 +233,4 @@ func (s *Server) handleChatClear(w http.ResponseWriter, r *http.Request) {
}
s.convStore.Clear()
writeJSON(w, map[string]string{"status": "ok"})
}
}

View 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()
}

View File

@@ -7,6 +7,7 @@ import (
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
@@ -104,17 +105,27 @@ 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 {
conn.WriteJSON(wsMessage{Type: "error", Data: fmt.Sprintf("shell not found: %s", shell)})
return
}
if strings.Contains(shell, "wsl") {
shellName := filepath.Base(shell)
switch shellName {
case "wsl":
cmd = exec.Command(shell, "--shell-type", "login")
} else if strings.Contains(shell, "powershell") || strings.Contains(shell, "pwsh") {
case "powershell", "pwsh":
cmd = exec.Command(shell, "-NoLogo", "-NoProfile")
} else {
case "fish":
cmd = exec.Command(shell, "--login")
default:
cmd = exec.Command(shell)
}
}

View File

@@ -68,7 +68,9 @@ const api = {
if (data.done) { resolve(full); return }
if (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 {}
}

View File

@@ -163,6 +163,17 @@ export default function Shell({ api }) {
name: '', host: '', port: 22, user: '', key_path: '',
})
const [aiMessages, setAiMessages] = useState([
{ role: 'ai', content: t('shell.aiWelcome') }
])
const [aiInput, setAiInput] = useState('')
const [aiLoading, setAiLoading] = useState(false)
const aiMessagesRef = useRef(null)
useEffect(() => {
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
}, [aiMessages])
useEffect(() => {
api.getTerminalSessions().then(d => {
setSshConnections(d.ssh || [])
@@ -239,15 +250,25 @@ export default function Shell({ api }) {
useEffect(() => {
const tab = tabs.find(t => t.id === activeTab)
if (tab && !tabsRef.current[tab.id]) {
const timer = setTimeout(() => initTerminal(tab.id, tab), 50)
return () => clearTimeout(timer)
} else if (tab && tabsRef.current[tab.id]) {
if (!tab) return
const container = document.getElementById(`terminal-${tab.id}`)
if (!container) return
if (!tabsRef.current[tab.id]) {
const timer = setTimeout(() => {
const { fitAddon } = tabsRef.current[tab.id]
fitAddon.fit()
}, 50)
initTerminal(tab.id, tab)
requestAnimationFrame(() => {
const entry = tabsRef.current[tab.id]
if (entry) entry.fitAddon.fit()
})
}, 100)
return () => clearTimeout(timer)
} else {
requestAnimationFrame(() => {
const entry = tabsRef.current[tab.id]
if (entry) entry.fitAddon.fit()
})
}
}, [activeTab, tabs, initTerminal])
@@ -351,6 +372,69 @@ export default function Shell({ api }) {
}
}
const handleAiSend = async () => {
if (!aiInput.trim() || aiLoading) return
const text = aiInput.trim()
setAiMessages(prev => [...prev, { role: 'user', content: text }])
setAiInput('')
setAiLoading(true)
try {
const res = await api.runCommand(`echo "AI: ${text}"`, '')
const output = res.output || t('shell.noResponse')
parseAndAddAiMessages(output)
} catch (err) {
setAiMessages(prev => [...prev, { role: 'ai', content: `${t('shell.error')}: ${err.message}` }])
}
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 (
<div className="shell-layout">
<div className="shell-terminal-col">
@@ -465,6 +549,28 @@ export default function Shell({ api }) {
</div>
</div>
<div className="shell-ai-col">
<div className="ai-panel-header">{t('shell.aiAssistant')}</div>
<div className="ai-panel-messages" ref={aiMessagesRef}>
{aiMessages.map((msg, i) => (
<div key={i} className={`ai-message ${msg.role}`}>
{msg.content}
{msg.args && <div className="tool-args">{msg.args}</div>}
</div>
))}
{aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>}
</div>
<div className="ai-panel-input">
<input
value={aiInput}
onChange={e => setAiInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAiSend()}
placeholder={t('shell.askAi')}
/>
<button className="sm" onClick={handleAiSend} disabled={!aiInput.trim()}>{t('shell.send')}</button>
</div>
</div>
{showSshModal && (
<div className="shell-modal-overlay" onClick={() => setShowSshModal(false)}>
<div className="shell-modal" onClick={e => e.stopPropagation()}>

View File

@@ -1,6 +1,35 @@
import { useState, useRef, useEffect, useCallback } from 'react'
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) {
const parts = []
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>')
}
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 }) {
const isUser = msg.role === 'user'
const isSystem = msg.role === 'system'
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 rank = getRank(msg.role)
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 (
<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-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>}
</div>
{msg.thinking && <ThinkingBlock content={msg.thinking} done />}
<div className="feed-content">
{renderContent(msg.content).map((part, i) =>
{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>}
@@ -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 (
<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 className="feed-avatar ai-rank">
<RankIcon rank={rank} />
</div>
<div className="feed-body">
<div className="feed-header">
<span className="feed-role">IA</span>
</div>
<div className="feed-content">
{renderContent(content).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" />
<span className="feed-rank-badge" style={{ color: rank.color, borderColor: rank.color }}>
{rank.short}
</span>
<span className="feed-role">{rank.label}</span>
</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>
)
@@ -119,6 +176,7 @@ export default function Studio({ api }) {
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const [streaming, setStreaming] = useState('')
const [streamThinking, setStreamThinking] = useState('')
const [loaded, setLoaded] = useState(false)
const messagesEnd = useRef(null)
const textareaRef = useRef(null)
@@ -143,7 +201,7 @@ export default function Studio({ api }) {
useEffect(() => {
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, streaming])
}, [messages, streaming, streamThinking])
useEffect(() => {
if (textareaRef.current) {
@@ -175,21 +233,33 @@ export default function Studio({ api }) {
setMessages(prev => [...prev, userMsg])
setLoading(true)
setStreaming('')
setStreamThinking('')
try {
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
setStreaming(partial)
})
const finalContent = accumulated || t('studio.noResponse')
setMessages(prev => [...prev, {
const aiMsg = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: finalContent,
time: new Date().toISOString(),
}])
}
if (thinking) aiMsg.thinking = thinking
setMessages(prev => [...prev, aiMsg])
} catch (err) {
setMessages(prev => [...prev, {
id: (Date.now() + 1).toString(),
@@ -200,6 +270,7 @@ export default function Studio({ api }) {
} finally {
setLoading(false)
setStreaming('')
setStreamThinking('')
}
}, [input, loading, api, t, handleClear])
@@ -228,20 +299,8 @@ export default function Studio({ api }) {
{messages.map(msg => (
<FeedItem key={msg.id} msg={msg} />
))}
{streaming && <StreamingItem content={streaming} />}
{loading && !streaming && (
<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>
{(streaming || streamThinking || loading) && (
<StreamingItem content={streaming} thinking={streamThinking} />
)}
<div ref={messagesEnd} />
</div>

View File

@@ -107,6 +107,10 @@ const en = {
systemTerminals: 'System terminals',
switchTerminal: 'Switch terminal',
localShell: 'Local Shell',
aiAssistant: 'AI Assistant',
aiWelcome: 'Hello! I can help you with terminal commands. Ask me anything!',
askAi: 'Ask AI assistant...',
toolLaunched: 'Tool launched',
},
config: {

View File

@@ -107,6 +107,10 @@ const fr = {
systemTerminals: 'Terminaux syst\u00e8me',
switchTerminal: 'Changer de terminal',
localShell: 'Shell local',
aiAssistant: 'Assistant IA',
aiWelcome: 'Bonjour ! Je peux vous aider avec les commandes du terminal. Demandez-moi n\'importe quoi !',
askAi: 'Interroger l\'assistant IA...',
toolLaunched: 'Outil lanc\u00e9',
},
config: {

View File

@@ -269,7 +269,7 @@ input::placeholder { color: var(--text-disabled); }
.sidebar-tab.active { background: var(--accent); color: #fff; font-weight: 600; }
.shell-layout { display: flex; height: 100%; }
.shell-terminal-col { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.shell-terminal-col { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; }
.shell-tabs-bar {
display: flex; align-items: center; background: var(--bg-surface);
@@ -377,6 +377,7 @@ input::placeholder { color: var(--text-disabled); }
.shell-xterm-wrapper { flex: 1; background: var(--bg); overflow: hidden; position: relative; }
.shell-xterm-instance {
position: absolute; inset: 0; padding: 4px;
display: block !important;
}
.shell-xterm-instance .xterm { height: 100%; padding: 4px; }
@@ -384,6 +385,19 @@ input::placeholder { color: var(--text-disabled); }
.connection-dot.on { background: var(--success); box-shadow: 0 0 6px var(--success); }
.connection-dot.off { background: var(--error); }
.shell-ai-col { width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; }
.ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); }
.ai-panel-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
.ai-message { padding: 8px 12px; border-radius: var(--radius); font-size: 13px; line-height: 1.5; word-break: break-word; }
.ai-message.ai { background: var(--bg-card); border-left: 3px solid var(--accent); }
.ai-message.user { background: var(--accent-bg); border-left: 3px solid var(--accent-muted); }
.ai-message.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 input { flex: 1; font-size: 13px; padding: 6px 10px; }
.shell-modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
display: flex; align-items: center; justify-content: center; z-index: 1000;
@@ -569,18 +583,60 @@ input::placeholder { color: var(--text-disabled); }
.feed-loading { display: flex; align-items: center; justify-content: center; padding: 60px 0; }
.feed-item { display: flex; gap: 10px; padding: 8px 12px; border-radius: var(--radius); animation: fadeIn 0.15s ease-out; }
.feed-item:hover { background: var(--bg-card); }
.feed-item.user { background: var(--bg-card); border-left: 3px solid var(--accent-muted); }
.feed-item.assistant { }
.feed-item.user { background: var(--bg-card); border-left: 3px solid #FFD740; }
.feed-item.assistant { border-left: 3px solid transparent; }
.feed-item.assistant:hover { border-left-color: var(--accent-dark); }
.feed-item.system { align-items: center; gap: 8px; padding: 6px 12px; }
.feed-avatar { width: 24px; height: 24px; border-radius: 50%; background: var(--accent-bg); color: var(--accent); display: flex; align-items: center; justify-content: center; flex-shrink: 0; margin-top: 2px; }
.feed-avatar { width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; margin-top: 2px; font-size: 14px; }
.feed-avatar.user-rank { background: rgba(255, 215, 64, 0.15); }
.feed-avatar.ai-rank { background: var(--accent-bg); }
.feed-rank-icon { display: flex; align-items: center; justify-content: center; }
.feed-body { flex: 1; min-width: 0; }
.feed-header { display: flex; align-items: center; gap: 8px; margin-bottom: 2px; }
.feed-rank-badge {
font-size: 9px; font-weight: 800; font-family: var(--font-mono);
padding: 1px 6px; border-radius: 3px; border: 1px solid;
letter-spacing: 0.5px; text-transform: uppercase;
background: rgba(255, 215, 64, 0.08);
}
.feed-role { font-size: 11px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; }
.feed-time { font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono); }
.feed-content { font-size: 14px; line-height: 1.7; color: var(--text-primary); word-break: break-word; }
.feed-system-badge { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-dim); flex-shrink: 0; }
.feed-system-text { font-size: 12px; color: var(--text-tertiary); font-style: italic; flex: 1; }
.feed-thinking-block {
background: var(--bg-surface); border: 1px solid var(--border); border-left: 2px solid var(--accent-dim);
border-radius: var(--radius); margin: 6px 0 8px; overflow: hidden;
transition: all 0.3s ease;
}
.feed-thinking-block.active {
border-left-color: var(--warning);
}
.feed-thinking-block.done {
border-left-color: var(--text-disabled);
opacity: 0.7;
}
.feed-thinking-block.done .feed-thinking-content {
max-height: 80px;
overflow-y: auto;
}
.feed-thinking-header {
display: flex; align-items: center; gap: 6px;
padding: 6px 10px; font-size: 10px; font-weight: 700;
color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.5px;
background: var(--bg-card); border-bottom: 1px solid var(--border);
}
.feed-thinking-header svg { color: var(--warning); }
.feed-thinking-dots { display: inline-flex; gap: 2px; margin-left: 4px; }
.feed-thinking-dots span { width: 4px; height: 4px; border-radius: 50%; background: var(--warning); animation: bounce 1.2s ease-in-out infinite; }
.feed-thinking-dots span:nth-child(2) { animation-delay: 0.15s; }
.feed-thinking-dots span:nth-child(3) { animation-delay: 0.3s; }
.feed-thinking-content {
padding: 8px 10px; font-size: 12px; color: var(--text-tertiary);
font-style: italic; line-height: 1.5; max-height: 120px; overflow-y: auto;
}
.studio-code-block {
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
overflow: hidden; margin: 8px 0;