fix: restore buffer after WebSocket init, fix clear detection, fix streaming chunks
All checks were successful
Beta Release / beta (push) Successful in 50s
All checks were successful
Beta Release / beta (push) Successful in 50s
- Delay buffer restoration by 300ms to avoid race condition with WebSocket init - Read current line from terminal buffer on Enter (reliable) instead of keystroke tracking - Fix streaming to emit full content instead of word-by-word chunks - Fix WebSocket readyState check in sendToTerminal - Extract and deduplicate AI message sending logic - Fix localStorage cleanup on tab close 💘 Generated with Crush Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
This commit is contained in:
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/muyue/muyue/internal/agent"
|
"github.com/muyue/muyue/internal/agent"
|
||||||
"github.com/muyue/muyue/internal/orchestrator"
|
"github.com/muyue/muyue/internal/orchestrator"
|
||||||
@@ -76,12 +75,8 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
|
|||||||
content := cleanThinkingTags(choice.Message.Content)
|
content := cleanThinkingTags(choice.Message.Content)
|
||||||
|
|
||||||
if content != "" {
|
if content != "" {
|
||||||
words := strings.Fields(content)
|
if ce.onChunk != nil {
|
||||||
for _, w := range words {
|
ce.onChunk(map[string]interface{}{"content": content})
|
||||||
chunk := w
|
|
||||||
if ce.onChunk != nil {
|
|
||||||
ce.onChunk(map[string]interface{}{"content": chunk})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
finalContent = content
|
finalContent = content
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -337,13 +337,17 @@ export default function Shell({ api }) {
|
|||||||
|
|
||||||
const ws = connectWebSocket(term, fitAddon, initPayload)
|
const ws = connectWebSocket(term, fitAddon, initPayload)
|
||||||
|
|
||||||
// Restore saved terminal buffer
|
// Restore saved terminal buffer after first output settles
|
||||||
try {
|
const restoreBuffer = () => {
|
||||||
const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
try {
|
||||||
if (savedBuffers[tabId]) {
|
const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
||||||
term.write(savedBuffers[tabId])
|
if (savedBuffers[tabId]) {
|
||||||
}
|
term.write('\x1b[90m— session restaurée —\x1b[0m\r\n')
|
||||||
} catch {}
|
term.write(savedBuffers[tabId])
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
setTimeout(restoreBuffer, 300)
|
||||||
|
|
||||||
const saveBuffer = () => {
|
const saveBuffer = () => {
|
||||||
try {
|
try {
|
||||||
@@ -362,24 +366,30 @@ export default function Shell({ api }) {
|
|||||||
const bufferSaveInterval = setInterval(saveBuffer, 5000)
|
const bufferSaveInterval = setInterval(saveBuffer, 5000)
|
||||||
|
|
||||||
// Detect clear command to wipe saved buffer
|
// Detect clear command to wipe saved buffer
|
||||||
let inputBuffer = ''
|
// We read the current line from the terminal buffer on Enter
|
||||||
term.onData((data) => {
|
// instead of trying to reconstruct from keystrokes (unreliable with history, ANSI, etc.)
|
||||||
if (data === '\r') {
|
const clearBufferOnClear = () => {
|
||||||
const cmd = inputBuffer.replace(/[\x1b\x00-\x1f]/g, '').trim().toLowerCase()
|
try {
|
||||||
if (cmd === 'clear') {
|
const buf = term.buffer.active
|
||||||
try {
|
const lineY = buf.baseY + buf.cursorY
|
||||||
|
const line = buf.getLine(lineY)
|
||||||
|
if (line) {
|
||||||
|
const text = line.translateToString(true).trim().toLowerCase()
|
||||||
|
if (text === 'clear' || text === '$ clear' || text.endsWith(' clear')) {
|
||||||
const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
||||||
delete savedBuffers[tabId]
|
delete savedBuffers[tabId]
|
||||||
localStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
|
localStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
|
||||||
} catch {}
|
}
|
||||||
}
|
}
|
||||||
inputBuffer = ''
|
} catch {}
|
||||||
} else if (data === '\x7f' || data === '\b') {
|
}
|
||||||
inputBuffer = inputBuffer.slice(0, -1)
|
|
||||||
} else if (data === '\x03') {
|
// Hook into onData to detect Enter for clear detection
|
||||||
inputBuffer = ''
|
// The connectWebSocket already registered its own onData for WS forwarding,
|
||||||
} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
// this one is purely for clear detection
|
||||||
inputBuffer += data
|
term.onData((data) => {
|
||||||
|
if (data === '\r') {
|
||||||
|
clearBufferOnClear()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -507,21 +517,30 @@ export default function Shell({ api }) {
|
|||||||
|
|
||||||
const closeTab = (tabId, e) => {
|
const closeTab = (tabId, e) => {
|
||||||
if (e) e.stopPropagation()
|
if (e) e.stopPropagation()
|
||||||
const tab = tabs.find(t => t.id === tabId)
|
|
||||||
if (!tab || tabs.length <= 1) return
|
|
||||||
|
|
||||||
if (tabsRef.current[tabId]) {
|
|
||||||
const { ws, resizeObserver, onResize, term, bufferSaveInterval, saveBuffer } = tabsRef.current[tabId]
|
|
||||||
if (saveBuffer) saveBuffer()
|
|
||||||
if (bufferSaveInterval) clearInterval(bufferSaveInterval)
|
|
||||||
window.removeEventListener('resize', onResize)
|
|
||||||
resizeObserver.disconnect()
|
|
||||||
ws.close()
|
|
||||||
term.dispose()
|
|
||||||
delete tabsRef.current[tabId]
|
|
||||||
}
|
|
||||||
|
|
||||||
setTabs(prev => {
|
setTabs(prev => {
|
||||||
|
if (prev.length <= 1) return prev
|
||||||
|
const tab = prev.find(t => t.id === tabId)
|
||||||
|
if (!tab) return prev
|
||||||
|
|
||||||
|
const entry = tabsRef.current[tabId]
|
||||||
|
if (entry) {
|
||||||
|
entry.saveBuffer?.()
|
||||||
|
if (entry.bufferSaveInterval) clearInterval(entry.bufferSaveInterval)
|
||||||
|
window.removeEventListener('resize', entry.onResize)
|
||||||
|
entry.resizeObserver.disconnect()
|
||||||
|
entry.ws.close()
|
||||||
|
entry.term.dispose()
|
||||||
|
delete tabsRef.current[tabId]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean buffer from localStorage
|
||||||
|
try {
|
||||||
|
const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
||||||
|
delete savedBuffers[tabId]
|
||||||
|
localStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
|
||||||
|
} catch {}
|
||||||
|
|
||||||
const next = prev.filter(t => t.id !== tabId)
|
const next = prev.filter(t => t.id !== tabId)
|
||||||
if (activeTab === tabId && next.length > 0) {
|
if (activeTab === tabId && next.length > 0) {
|
||||||
setActiveTab(next[next.length - 1].id)
|
setActiveTab(next[next.length - 1].id)
|
||||||
@@ -568,9 +587,15 @@ export default function Shell({ api }) {
|
|||||||
|
|
||||||
const sendToTerminal = useCallback((code) => {
|
const sendToTerminal = useCallback((code) => {
|
||||||
const entry = tabsRef.current[activeTab]
|
const entry = tabsRef.current[activeTab]
|
||||||
if (entry?.ws && entry.ws.readyState === WebSocket.OPEN) {
|
if (!entry) {
|
||||||
entry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' }))
|
console.warn('sendToTerminal: no terminal initialized for tab', activeTab)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
if (!entry.ws || entry.ws.readyState !== WebSocket.OPEN) {
|
||||||
|
console.warn('sendToTerminal: WebSocket not ready for tab', activeTab)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' }))
|
||||||
}, [activeTab])
|
}, [activeTab])
|
||||||
|
|
||||||
const focusAiTerminal = useCallback(() => {
|
const focusAiTerminal = useCallback(() => {
|
||||||
@@ -578,13 +603,16 @@ export default function Shell({ api }) {
|
|||||||
if (entry) entry.term.focus()
|
if (entry) entry.term.focus()
|
||||||
}, [activeTab])
|
}, [activeTab])
|
||||||
|
|
||||||
const handleAiSend = async () => {
|
const _sendAiMessage = useCallback(async (text, fromEvent = false) => {
|
||||||
if (!aiInput.trim() || aiLoading || aiAtLimit) return
|
if (!text || !text.trim() || aiLoading || aiAtLimit) return
|
||||||
const text = aiInput.trim()
|
const trimmed = text.trim()
|
||||||
setAiInput('')
|
|
||||||
focusAiTerminal()
|
|
||||||
|
|
||||||
if (text === '/clear') {
|
if (!fromEvent) {
|
||||||
|
setAiInput('')
|
||||||
|
focusAiTerminal()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed === '/clear') {
|
||||||
try {
|
try {
|
||||||
await api.clearShellChat()
|
await api.clearShellChat()
|
||||||
setAiMessages([{ role: 'assistant', content: t('shell.aiWelcome') || 'Contexte effacé. Prêt.' }])
|
setAiMessages([{ role: 'assistant', content: t('shell.aiWelcome') || 'Contexte effacé. Prêt.' }])
|
||||||
@@ -594,65 +622,20 @@ export default function Shell({ api }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (text === '/help') {
|
if (trimmed === '/help') {
|
||||||
setAiMessages(prev => [...prev,
|
setAiMessages(prev => [...prev,
|
||||||
{ role: 'user', content: text },
|
{ role: 'user', content: trimmed },
|
||||||
{ role: 'assistant', content: 'Commandes disponibles:\n• /clear — Effacer la conversation\n• /help — Afficher l\'aide\n\nJe ne peux pas exécuter de code. Les blocs de code proposés peuvent être copiés ou envoyés directement au terminal actif.' }
|
{ role: 'assistant', content: 'Commandes disponibles:\n• /clear — Effacer la conversation\n• /help — Afficher l\'aide\n\nJe ne peux pas exécuter de code. Les blocs de code proposés peuvent être copiés ou envoyés directement au terminal actif.' }
|
||||||
])
|
])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setAiMessages(prev => [...prev, { role: 'user', content: text }])
|
setAiMessages(prev => [...prev, { role: 'user', content: trimmed }])
|
||||||
setAiLoading(true)
|
setAiLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let accumulated = ''
|
let accumulated = ''
|
||||||
await api.sendShellChat(text, {}, true, (partial) => {
|
await api.sendShellChat(trimmed, {}, true, (partial) => {
|
||||||
accumulated = partial
|
|
||||||
setAiMessages(prev => {
|
|
||||||
const filtered = prev.filter(m => !m._streaming)
|
|
||||||
return [...filtered, { role: 'assistant', content: partial, _streaming: true }]
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
setAiMessages(prev => {
|
|
||||||
const filtered = prev.filter(m => !m._streaming)
|
|
||||||
return [...filtered, { role: 'assistant', content: accumulated }]
|
|
||||||
})
|
|
||||||
// Refresh token count
|
|
||||||
api.getShellChatHistory().then(d => {
|
|
||||||
setAiTokens(d.tokens || 0)
|
|
||||||
setAiAtLimit(d.at_limit || false)
|
|
||||||
}).catch(() => {})
|
|
||||||
} catch (err) {
|
|
||||||
if (err.message.includes('context limit')) {
|
|
||||||
setAiAtLimit(true)
|
|
||||||
}
|
|
||||||
setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }])
|
|
||||||
}
|
|
||||||
setAiLoading(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAiSendDirect = useCallback(async (text) => {
|
|
||||||
if (!text || aiLoading || aiAtLimit) return
|
|
||||||
setAiInput('')
|
|
||||||
|
|
||||||
if (text === '/clear') {
|
|
||||||
try {
|
|
||||||
await api.clearShellChat()
|
|
||||||
setAiMessages([{ role: 'assistant', content: t('shell.aiWelcome') || 'Contexte effacé. Prêt.' }])
|
|
||||||
setAiTokens(0)
|
|
||||||
setAiAtLimit(false)
|
|
||||||
} catch {}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setAiMessages(prev => [...prev, { role: 'user', content: text }])
|
|
||||||
setAiLoading(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
let accumulated = ''
|
|
||||||
await api.sendShellChat(text, {}, true, (partial) => {
|
|
||||||
accumulated = partial
|
accumulated = partial
|
||||||
setAiMessages(prev => {
|
setAiMessages(prev => {
|
||||||
const filtered = prev.filter(m => !m._streaming)
|
const filtered = prev.filter(m => !m._streaming)
|
||||||
@@ -675,20 +658,20 @@ export default function Shell({ api }) {
|
|||||||
setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }])
|
setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }])
|
||||||
}
|
}
|
||||||
setAiLoading(false)
|
setAiLoading(false)
|
||||||
}, [api, t, aiLoading, aiAtLimit])
|
}, [api, t, aiLoading, aiAtLimit, focusAiTerminal])
|
||||||
|
|
||||||
|
const handleAiSend = () => _sendAiMessage(aiInput, false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e) => {
|
const handler = (e) => {
|
||||||
const msg = e.detail?.message
|
const msg = e.detail?.message
|
||||||
if (!msg) return
|
if (!msg) return
|
||||||
setAiInput(msg)
|
setAiInput(msg)
|
||||||
setTimeout(() => {
|
setTimeout(() => _sendAiMessage(msg, true), 100)
|
||||||
handleAiSendDirect(msg)
|
|
||||||
}, 100)
|
|
||||||
}
|
}
|
||||||
window.addEventListener('ask-ai-terminal', handler)
|
window.addEventListener('ask-ai-terminal', handler)
|
||||||
return () => window.removeEventListener('ask-ai-terminal', handler)
|
return () => window.removeEventListener('ask-ai-terminal', handler)
|
||||||
}, [handleAiSendDirect])
|
}, [_sendAiMessage])
|
||||||
|
|
||||||
const handleAnalyze = async () => {
|
const handleAnalyze = async () => {
|
||||||
setAnalyzing(true)
|
setAnalyzing(true)
|
||||||
|
|||||||
Reference in New Issue
Block a user