feat: RAG, memory, plugins, lessons, file editor, split panes, Markdown rendering, PWA + UI overhaul
All checks were successful
Stable Release / stable (push) Successful in 1m34s

Major additions:
- RAG pipeline (indexing, chunking, search) with sidebar upload button
- Memory system with CRUD API
- Plugins and lessons modules
- MCP discovery and MCP server
- Advanced skills (auto-create, conditional, improver)
- Agent browser/image support, delegate, sessions
- File editor with CodeMirror in split panes
- Markdown rendering via react-markdown + KaTeX + highlight.js
- Raw markdown toggle
- PWA manifest + service worker
- Extension UI redesign with new design tokens and studio-style chat
- Pipeline API for chat streaming
- Mobile responsive layout

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
This commit is contained in:
Augustin
2026-04-27 21:01:08 +02:00
parent 62c20eb174
commit 4523bbd42c
50 changed files with 11144 additions and 469 deletions

View File

@@ -162,6 +162,33 @@ const api = {
}).catch(reject)
})
},
ragIndex: (text, name, type) => request('/rag/index', { method: 'POST', body: JSON.stringify({ text, name, type: type || 'text' }) }),
ragIndexFile: (file) => {
const formData = new FormData()
formData.append('file', file)
return fetch(`${API_BASE}/rag/index`, { method: 'POST', body: formData }).then(r => {
if (!r.ok) return r.json().then(e => { throw new Error(e.error || r.statusText) })
return r.json()
})
},
ragSearch: (query, limit) => request('/rag/search', { method: 'POST', body: JSON.stringify({ query, limit: limit || 5 }) }),
ragStatus: () => request('/rag/status'),
ragDocuments: () => request('/rag/documents'),
ragDelete: (id) => fetch(`${API_BASE}/rag/index/${id}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' } }).then(r => r.json()),
pipelineFilters: () => request('/pipeline/filters'),
pipelineToggle: (name, enabled) => request(`/pipeline/filters/${name}`, { method: 'POST', body: JSON.stringify({ enabled }) }),
generateImage: (prompt, size, style) => request('/images/generate', { method: 'POST', body: JSON.stringify({ prompt, size: size || '1024x1024', style: style || 'vivid' }) }),
fileRead: (path) => request(`/files/content?path=${encodeURIComponent(path)}`),
fileWrite: (path, content) => request('/files/content', { method: 'PUT', body: JSON.stringify({ path, content }) }),
mcpServerStatus: () => request('/mcp-server/status'),
mcpServerStart: () => request('/mcp-server/start', { method: 'POST' }),
mcpServerStop: () => request('/mcp-server/stop', { method: 'POST' }),
getAgentSessions: () => request('/agent-sessions'),
getAgentSessionOutput: (id) => request(`/agent-sessions/${encodeURIComponent(id)}`),
getWorkspaces: () => request('/workspaces'),
saveWorkspace: (name, layout, tabs) => request('/workspace', { method: 'POST', body: JSON.stringify({ name, layout, tabs }) }),
getWorkspace: (name) => request(`/workspace/${encodeURIComponent(name)}`),
deleteWorkspace: (name) => request(`/workspace/${encodeURIComponent(name)}`, { method: 'DELETE' }),
}
export default api

View File

@@ -0,0 +1,262 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { EditorView, keymap, lineNumbers, highlightActiveLineGutter, highlightActiveLine, drawSelection, rectangularSelection, highlightSpecialChars } from '@codemirror/view'
import { EditorState, Compartment } from '@codemirror/state'
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
import { syntaxHighlighting, defaultHighlightStyle, bracketMatching, foldGutter, indentOnInput } from '@codemirror/language'
import { javascript } from '@codemirror/lang-javascript'
import { python } from '@codemirror/lang-python'
import { go } from '@codemirror/lang-go'
import { json } from '@codemirror/lang-json'
import { yaml } from '@codemirror/lang-yaml'
import { markdown } from '@codemirror/lang-markdown'
import { oneDark } from '@codemirror/theme-one-dark'
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'
import { closeBrackets, closeBracketsKeymap, autocompletion, completionKeymap } from '@codemirror/autocomplete'
import { X, Save, RotateCcw } from 'lucide-react'
const langExtensions = {
javascript: () => javascript({ jsx: true }),
typescript: () => javascript({ jsx: true, typescript: true }),
python: () => python(),
go: () => go(),
json: () => json(),
yaml: () => yaml(),
markdown: () => markdown(),
}
function getLangExtension(lang) {
const factory = langExtensions[lang]
if (factory) return factory()
return []
}
function createEditorTheme() {
return EditorView.theme({
'&': {
fontSize: '13px',
backgroundColor: 'var(--bg-base, #0F0D10)',
color: 'var(--text-primary, #EAE0E2)',
height: '100%',
},
'.cm-content': {
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
caretColor: 'var(--accent, #FF0033)',
padding: '4px 0',
},
'.cm-cursor': {
borderLeftColor: 'var(--accent, #FF0033)',
borderLeftWidth: '2px',
},
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground': {
backgroundColor: 'var(--accent-dim, #6B2033) !important',
},
'.cm-gutters': {
backgroundColor: 'var(--bg-surface, #161218)',
color: 'var(--text-tertiary, #8A7A7E)',
border: 'none',
borderRight: '1px solid var(--border, #2A1F22)',
},
'.cm-activeLineGutter': {
backgroundColor: 'var(--bg-elevated, #1C1719)',
color: 'var(--text-secondary, #D4C4C8)',
},
'.cm-activeLine': {
backgroundColor: 'rgba(255, 0, 51, 0.05)',
},
'.cm-matchingBracket': {
backgroundColor: 'var(--accent-dim, #6B2033)',
outline: '1px solid var(--accent, #FF0033)',
color: '#fff !important',
},
'.cm-selectionMatch': {
backgroundColor: 'var(--accent-dim, #6B2033)',
},
'.cm-foldGutter': {
color: 'var(--text-tertiary, #8A7A7E)',
},
'.cm-scroller': {
overflow: 'auto',
},
}, { dark: true })
}
export default function FileEditor({ api, filePath, onClose }) {
const editorRef = useRef(null)
const viewRef = useRef(null)
const langCompartment = useRef(new Compartment())
const [content, setContent] = useState('')
const [originalContent, setOriginalContent] = useState('')
const [lang, setLang] = useState('text')
const [dirty, setDirty] = useState(false)
const [saving, setSaving] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [fileName, setFileName] = useState('')
useEffect(() => {
if (!filePath) return
const name = filePath.split('/').pop()
setFileName(name)
api.fileRead(filePath).then(data => {
setContent(data.content || '')
setOriginalContent(data.content || '')
setLang(data.lang || 'text')
setLoading(false)
}).catch(err => {
setError(err.message || 'Failed to read file')
setLoading(false)
})
}, [filePath])
useEffect(() => {
if (!editorRef.current || loading || viewRef.current) return
const customTheme = createEditorTheme()
const state = EditorState.create({
doc: content,
extensions: [
customTheme,
oneDark,
lineNumbers(),
highlightActiveLineGutter(),
highlightSpecialChars(),
history(),
foldGutter(),
drawSelection(),
indentOnInput(),
bracketMatching(),
closeBrackets(),
autocompletion(),
rectangularSelection(),
highlightActiveLine(),
highlightSelectionMatches(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...completionKeymap,
indentWithTab,
{
key: 'Mod-s',
run: () => { handleSave() ; return true },
},
{
key: 'Escape',
run: () => { if (onClose) onClose() ; return true },
},
]),
langCompartment.current.of(getLangExtension(lang)),
EditorView.updateListener.of(update => {
if (update.docChanged) {
const newContent = update.state.doc.toString()
setDirty(newContent !== originalContent)
}
}),
EditorView.lineWrapping,
],
})
const view = new EditorView({
state,
parent: editorRef.current,
})
viewRef.current = view
return () => {
view.destroy()
viewRef.current = null
}
}, [loading])
useEffect(() => {
if (!viewRef.current || !lang) return
try {
viewRef.current.dispatch({
effects: langCompartment.current.reconfigure(getLangExtension(lang)),
})
} catch {}
}, [lang])
const handleSave = useCallback(async () => {
if (!viewRef.current || !filePath || saving) return
const newContent = viewRef.current.state.doc.toString()
setSaving(true)
try {
await api.fileWrite(filePath, newContent)
setOriginalContent(newContent)
setDirty(false)
setContent(newContent)
} catch (err) {
setError(err.message)
}
setSaving(false)
}, [filePath, saving, api])
const handleReload = useCallback(() => {
if (!viewRef.current) return
api.fileRead(filePath).then(data => {
const doc = data.content || ''
viewRef.current.dispatch({
changes: { from: 0, to: viewRef.current.state.doc.length, insert: doc },
})
setOriginalContent(doc)
setDirty(false)
setContent(doc)
setLang(data.lang || 'text')
}).catch(err => setError(err.message))
}, [filePath, api])
if (loading) {
return (
<div className="file-editor-panel">
<div className="file-editor-header">
<span className="file-editor-title">Loading...</span>
<button className="ghost sm" onClick={onClose}><X size={14} /></button>
</div>
</div>
)
}
if (error && !content) {
return (
<div className="file-editor-panel">
<div className="file-editor-header">
<span className="file-editor-title" style={{ color: 'var(--error)' }}>{error}</span>
<button className="ghost sm" onClick={onClose}><X size={14} /></button>
</div>
</div>
)
}
return (
<div className="file-editor-panel">
<div className="file-editor-header">
<span className="file-editor-title">
{fileName}
{dirty && <span className="file-editor-dirty"></span>}
</span>
<div className="file-editor-actions">
<span className="file-editor-lang-badge">{lang}</span>
<button className="ghost sm" onClick={handleReload} title="Reload">
<RotateCcw size={13} />
</button>
<button
className="sm primary"
onClick={handleSave}
disabled={!dirty || saving}
>
<Save size={13} />
{saving ? '...' : 'Save'}
</button>
<button className="ghost sm" onClick={onClose}><X size={14} /></button>
</div>
</div>
<div className="file-editor-body" ref={editorRef} />
</div>
)
}

View File

@@ -6,18 +6,21 @@ import { WebglAddon } from '@xterm/addon-webgl'
import { SearchAddon } from '@xterm/addon-search'
import { Unicode11Addon } from '@xterm/addon-unicode11'
import { ImageAddon } from '@xterm/addon-image'
import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2, Search, Copy, Send, Eye, Bot } from 'lucide-react'
import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2, Search, Copy, Send, Eye, Bot, Columns, Rows, Maximize2 } from 'lucide-react'
import '@xterm/xterm/css/xterm.css'
import { useI18n } from '../i18n'
import mermaid from 'mermaid'
import FileEditor from './FileEditor'
mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose', fontFamily: 'var(--font-mono)' })
const AI_TAB_ID = 0
const MAX_TABS = 7
const MAX_PANES = 4
const SHELL_MAX_TOKENS = 100000
const SHELL_AI_COMMANDS = ['/clear', '/help', '/model', '/model change']
const TABS_STORAGE_KEY = 'muyue_shell_tabs'
const LAYOUT_STORAGE_KEY = 'muyue_shell_layout'
const TERMINAL_BUFFER_KEY = 'muyue_terminal_buffers'
function renderContent(text) {
@@ -477,6 +480,107 @@ export default function Shell({ api, isSudo }) {
const _streamRafRef = useRef(null)
const _streamPendingRef = useRef(null)
const [splitLayout, setSplitLayout] = useState(() => {
try {
const raw = localStorage.getItem(LAYOUT_STORAGE_KEY)
if (raw) return JSON.parse(raw)
} catch {}
return null
})
const [editingFile, setEditingFile] = useState(null)
const [agentSessions, setAgentSessions] = useState([])
const paneCount = useMemo(() => {
const count = (node) => {
if (!node) return 1
if (node.type === 'leaf') return 1
return count(node.children?.[0]) + count(node.children?.[1])
}
return count(splitLayout)
}, [splitLayout])
const splitPane = useCallback((direction) => {
if (paneCount >= MAX_PANES) return
const activeId = activeTabRef.current
setSplitLayout(prev => {
if (!prev) {
return { type: 'split', direction, ratio: 0.5, activePane: activeId, children: [
{ type: 'leaf', tabId: activeId },
{ type: 'leaf', tabId: null },
]}
}
const clone = JSON.parse(JSON.stringify(prev))
const findAndSplit = (node) => {
if (node.type === 'leaf' && node.tabId === activeId) {
return { type: 'split', direction, ratio: 0.5, activePane: activeId, children: [
{ type: 'leaf', tabId: activeId },
{ type: 'leaf', tabId: null },
]}
}
if (node.children) {
return { ...node, children: node.children.map(findAndSplit) }
}
return node
}
return findAndSplit(clone)
})
}, [paneCount])
const removePane = useCallback((tabId) => {
setSplitLayout(prev => {
if (!prev) return null
if (prev.type === 'leaf') return null
const clone = JSON.parse(JSON.stringify(prev))
const removeFromTree = (node) => {
if (node.type !== 'split') return node
const left = node.children[0]
const right = node.children[1]
const leftIsTarget = left.type === 'leaf' && left.tabId === tabId
const rightIsTarget = right.type === 'leaf' && right.tabId === tabId
if (leftIsTarget) return removeFromTree(right)
if (rightIsTarget) return removeFromTree(left)
return { ...node, children: [removeFromTree(left), removeFromTree(right)] }
}
const result = removeFromTree(clone)
if (result.type === 'leaf' && result.tabId === null) return null
if (result.type === 'split' && (!result.children || result.children.length < 2)) return result.children?.[0] || null
return result
})
}, [])
const assignPaneTab = useCallback((paneLeaf, tabId) => {
setSplitLayout(prev => {
if (!prev) return prev
const clone = JSON.parse(JSON.stringify(prev))
const assign = (node) => {
if (node === paneLeaf) return { ...node, tabId }
if (node.children) return { ...node, children: node.children.map(assign) }
return node
}
return assign(clone)
})
}, [])
useEffect(() => {
if (splitLayout) {
localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(splitLayout))
} else {
localStorage.removeItem(LAYOUT_STORAGE_KEY)
}
}, [splitLayout])
useEffect(() => {
api.getAgentSessions?.().then(d => {
setAgentSessions(d?.sessions || [])
}).catch(() => {})
const iv = setInterval(() => {
api.getAgentSessions?.().then(d => {
setAgentSessions(d?.sessions || [])
}).catch(() => {})
}, 5000)
return () => clearInterval(iv)
}, [])
const _flushStreamUpdate = useCallback(() => {
_streamRafRef.current = null
const pending = _streamPendingRef.current
@@ -818,6 +922,22 @@ export default function Shell({ api, isSudo }) {
return
}
if (ctrl && e.shiftKey && e.key === 'D') {
const shellTab = document.querySelector('.shell-layout')
if (!shellTab || shellTab.closest('.tab-hidden')) return
e.preventDefault()
splitPane('vertical')
return
}
if (ctrl && e.shiftKey && e.key === 'H') {
const shellTab = document.querySelector('.shell-layout')
if (!shellTab || shellTab.closest('.tab-hidden')) return
e.preventDefault()
splitPane('horizontal')
return
}
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
if (!e.altKey && !(e.key === 'Tab' && e.shiftKey)) return
@@ -1318,6 +1438,27 @@ Sois concret : cite les vraies versions, les vrais chemins, les vrais nombres. L
{zoomLevel > 0 ? '+' : ''}{zoomLevel > 0 ? zoomLevel * 2 : zoomLevel * 2}px
</span>
)}
{paneCount < MAX_PANES && (
<>
<button className="shell-split-btn" onClick={() => splitPane('vertical')} title="Split Vertical (Ctrl+Shift+D)">
<Columns size={14} />
</button>
<button className="shell-split-btn" onClick={() => splitPane('horizontal')} title="Split Horizontal (Ctrl+Shift+H)">
<Rows size={14} />
</button>
</>
)}
{splitLayout && (
<button className="shell-split-btn" onClick={() => setSplitLayout(null)} title="Unsplit">
<Maximize2 size={14} />
</button>
)}
{agentSessions.length > 0 && (
<span className="shell-agent-indicator" title={`${agentSessions.length} agent(s) actif(s)`}>
<Bot size={12} />
<span className="shell-agent-count">{agentSessions.length}</span>
</span>
)}
{tabs.length < MAX_TABS && (
<div className="shell-new-tab-wrapper">
<button className="shell-new-tab-btn" onClick={() => setShowMenu(!showMenu)} title={t('shell.newTab')}>
@@ -1384,34 +1525,59 @@ Sois concret : cite les vraies versions, les vrais chemins, les vrais nombres. L
</div>
</div>
<div className="shell-xterm-wrapper">
{showSearch && (
<div className="shell-search-bar">
<Search size={14} className="shell-search-icon" />
<input
ref={searchInputRef}
className="shell-search-input"
value={searchText}
onChange={e => handleSearchChange(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') { e.shiftKey ? handleSearchPrev() : handleSearchNext() }
if (e.key === 'Escape') handleCloseSearch()
e.stopPropagation()
}}
placeholder="Rechercher..."
/>
<button className="shell-search-nav" onClick={handleSearchPrev} title="Précédent (Shift+Entrée)"></button>
<button className="shell-search-nav" onClick={handleSearchNext} title="Suivant (Entrée)"></button>
<button className="shell-search-close" onClick={handleCloseSearch}><X size={14} /></button>
</div>
<div className={`shell-xterm-wrapper ${splitLayout ? 'has-splits' : ''}`}>
{editingFile && (
<FileEditor api={api} filePath={editingFile} onClose={() => setEditingFile(null)} />
)}
{!editingFile && (
splitLayout ? (
<SplitPaneRenderer
node={splitLayout}
tabs={tabs}
activeTab={activeTab}
setActiveTab={setActiveTab}
showSearch={showSearch}
searchText={searchText}
searchInputRef={searchInputRef}
handleSearchChange={handleSearchChange}
handleSearchNext={handleSearchNext}
handleSearchPrev={handleSearchPrev}
handleCloseSearch={handleCloseSearch}
removePane={removePane}
onLayoutChange={setSplitLayout}
/>
) : (
<>
{showSearch && (
<div className="shell-search-bar">
<Search size={14} className="shell-search-icon" />
<input
ref={searchInputRef}
className="shell-search-input"
value={searchText}
onChange={e => handleSearchChange(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') { e.shiftKey ? handleSearchPrev() : handleSearchNext() }
if (e.key === 'Escape') handleCloseSearch()
e.stopPropagation()
}}
placeholder="Rechercher..."
/>
<button className="shell-search-nav" onClick={handleSearchPrev} title="Précédent (Shift+Entrée)"></button>
<button className="shell-search-nav" onClick={handleSearchNext} title="Suivant (Entrée)"></button>
<button className="shell-search-close" onClick={handleCloseSearch}><X size={14} /></button>
</div>
)}
{tabs.map(tab => (
<div
key={tab.id}
id={`terminal-${tab.id}`}
className={`shell-xterm-instance${activeTab === tab.id ? ' active' : ''}`}
/>
))}
</>
)
)}
{tabs.map(tab => (
<div
key={tab.id}
id={`terminal-${tab.id}`}
className={`shell-xterm-instance${activeTab === tab.id ? ' active' : ''}`}
/>
))}
</div>
</div>
@@ -1775,3 +1941,158 @@ const ShellAIMessage = memo(function ShellAIMessage({ msg, sendToTerminal, termi
</div>
)
})
function SplitPaneRenderer({ node, tabs, activeTab, setActiveTab, showSearch, searchText, searchInputRef, handleSearchChange, handleSearchNext, handleSearchPrev, handleCloseSearch, removePane, onLayoutChange }) {
if (!node) return null
if (node.type === 'leaf') {
const tabId = node.tabId
const tab = tabId ? tabs.find(t => t.id === tabId) : null
const isActive = activeTab === tabId
if (!tab && tabId !== null) {
const fallbackTab = tabs[0]
if (fallbackTab) {
return (
<div className="split-pane-leaf" onClick={() => setActiveTab(fallbackTab.id)}>
<div id={`terminal-${fallbackTab.id}`} className={`shell-xterm-instance${activeTab === fallbackTab.id ? ' active' : ''}`} />
</div>
)
}
}
if (!tab) {
return (
<div className="split-pane-leaf empty">
<div className="split-pane-empty">
<Monitor size={24} style={{ opacity: 0.3 }} />
<span style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 8 }}>
Select a tab for this pane
</span>
<div style={{ display: 'flex', gap: 4, marginTop: 8 }}>
{tabs.slice(0, 4).map(t => (
<button key={t.id} className="sm ghost" onClick={() => { onLayoutChange(prev => assignLeafTab(prev, node, t.id)) }}>
{t.name}
</button>
))}
</div>
</div>
</div>
)
}
return (
<div className={`split-pane-leaf ${isActive ? 'active' : ''}`} onClick={() => setActiveTab(tabId)}>
<div className="split-pane-header">
<span className="split-pane-title">{tab.name}</span>
<button className="split-pane-close" onClick={(e) => { e.stopPropagation(); removePane(tabId) }}>
<X size={10} />
</button>
</div>
<div className="split-pane-content">
<div id={`terminal-${tabId}`} className="shell-xterm-instance active" />
</div>
</div>
)
}
if (node.type === 'split') {
const dir = node.direction === 'horizontal' ? 'row' : 'column'
return (
<div className={`split-pane-split ${dir}`} style={{ flex: 1 }}>
<div className="split-pane-child" style={{ flex: node.ratio || 0.5, overflow: 'hidden' }}>
<SplitPaneRenderer
node={node.children?.[0]}
tabs={tabs}
activeTab={activeTab}
setActiveTab={setActiveTab}
showSearch={showSearch}
searchText={searchText}
searchInputRef={searchInputRef}
handleSearchChange={handleSearchChange}
handleSearchNext={handleSearchNext}
handleSearchPrev={handleSearchPrev}
handleCloseSearch={handleCloseSearch}
removePane={removePane}
onLayoutChange={onLayoutChange}
/>
</div>
<div
className="split-pane-resizer"
onMouseDown={(e) => {
e.preventDefault()
const parent = e.target.parentElement
const startX = e.clientX
const startY = e.clientY
const startRatio = node.ratio || 0.5
const isVertical = node.direction === 'vertical'
const parentSize = isVertical ? parent.offsetWidth : parent.offsetHeight
const onMouseMove = (me) => {
const delta = isVertical ? (me.clientX - startX) : (me.clientY - startY)
const newRatio = Math.max(0.15, Math.min(0.85, startRatio + delta / parentSize))
onLayoutChange(prev => updateSplitRatio(prev, node, newRatio))
}
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
document.body.style.cursor = isVertical ? 'col-resize' : 'row-resize'
document.body.style.userSelect = 'none'
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}}
/>
<div className="split-pane-child" style={{ flex: 1 - (node.ratio || 0.5), overflow: 'hidden' }}>
<SplitPaneRenderer
node={node.children?.[1]}
tabs={tabs}
activeTab={activeTab}
setActiveTab={setActiveTab}
showSearch={showSearch}
searchText={searchText}
searchInputRef={searchInputRef}
handleSearchChange={handleSearchChange}
handleSearchNext={handleSearchNext}
handleSearchPrev={handleSearchPrev}
handleCloseSearch={handleCloseSearch}
removePane={removePane}
onLayoutChange={onLayoutChange}
/>
</div>
</div>
)
}
return null
}
function assignLeafTab(layout, leaf, tabId) {
if (!layout) return layout
if (layout === leaf) return { ...layout, tabId }
if (layout.children) {
return {
...layout,
children: layout.children.map(c => assignLeafTab(c, leaf, tabId)),
}
}
return layout
}
function updateSplitRatio(layout, targetNode, ratio) {
if (layout === targetNode) {
return { ...layout, ratio }
}
if (layout.children) {
return {
...layout,
children: layout.children.map(c => updateSplitRatio(c, targetNode, ratio)),
}
}
return layout
}

View File

@@ -1,6 +1,13 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { useI18n } from '../i18n'
import mermaid from 'mermaid'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
import rehypeHighlight from 'rehype-highlight'
import 'katex/dist/katex.min.css'
import 'highlight.js/styles/github-dark.css'
mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose', fontFamily: 'var(--font-mono)' })
@@ -279,6 +286,14 @@ function FeedItem({ msg, activeAgents, onModeChange, collapseHistory }) {
const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''
const [showRawMarkdown, setShowRawMarkdown] = useState(() => {
try { return localStorage.getItem('muyue.showRawMarkdown') === 'true' } catch { return false }
})
const renderMarkdown = useCallback((content) => {
return <MarkdownContent content={content} raw={showRawMarkdown} />
}, [showRawMarkdown])
let parsedToolCalls = null
let parsedToolResults = null
let parsedSegments = null
@@ -322,7 +337,7 @@ function FeedItem({ msg, activeAgents, onModeChange, collapseHistory }) {
<span className="feed-role">{rank.label}</span>
{timeStr && <span className="feed-time">{timeStr}</span>}
</div>
{msg.thinking && <ThinkingBlock content={formatText(msg.thinking)} done raw />}
{msg.thinking && <ThinkingBlock content={msg.thinking} done raw />}
{msg.images && msg.images.length > 0 && (
<div className="feed-images">
{msg.images.map((imgId, i) => (
@@ -354,13 +369,7 @@ function FeedItem({ msg, activeAgents, onModeChange, collapseHistory }) {
if (!c) return null
return (
<div key={`t${i}`} className="feed-content">
{renderContent(c).map((part, j) =>
part.type === 'code' ? (
<CodeBlockWithCopy key={j} part={part} index={j} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
) : (
<span key={j} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
)
)}
{renderMarkdown(c)}
</div>
)
}
@@ -408,13 +417,7 @@ function FeedItem({ msg, activeAgents, onModeChange, collapseHistory }) {
})()}
{cleanContent && (
<div className="feed-content">
{renderContent(cleanContent).map((part, i) =>
part.type === 'code' ? (
<CodeBlockWithCopy key={i} part={part} index={i} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
) : (
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
)
)}
{renderMarkdown(cleanContent)}
</div>
)}
</>
@@ -432,13 +435,13 @@ function StreamingItem({ content, thinking, toolCalls, segments, activeAgents, o
const [forceExpand, setForceExpand] = useState(false)
const renderedContent = useMemo(() => {
if (!cleanContent) return []
return renderContent(cleanContent)
if (!cleanContent) return null
return null
}, [cleanContent])
const formattedThinking = useMemo(() => {
if (!thinking) return ''
return formatText(thinking)
return thinking
}, [thinking])
const hasOrderedSegments = segments && segments.some(s => s.type === 'tool')
@@ -457,7 +460,7 @@ function StreamingItem({ content, thinking, toolCalls, segments, activeAgents, o
</span>
<span className="feed-role">{rank.label}</span>
</div>
{thinking && <ThinkingBlock content={formattedThinking} raw done={false} />}
{thinking && <ThinkingBlock content={thinking} raw done={false} />}
{hasOrderedSegments ? (
<>
{compress && (
@@ -475,16 +478,9 @@ function StreamingItem({ content, thinking, toolCalls, segments, activeAgents, o
return segments.map((seg, i) => {
if (seg.type === 'text') {
if (!seg.content) return null
const parts = renderContent(seg.content)
return (
<div key={`t${i}`} className="feed-content">
{parts.map((part, j) =>
part.type === 'code' ? (
<CodeBlockWithCopy key={j} part={part} index={j} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
) : (
<span key={j} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
)
)}
<MarkdownContent content={seg.content} raw={false} />
</div>
)
}
@@ -506,13 +502,7 @@ function StreamingItem({ content, thinking, toolCalls, segments, activeAgents, o
)}
{cleanContent && (
<div className="feed-content">
{renderedContent.map((part, i) =>
part.type === 'code' ? (
<CodeBlockWithCopy key={i} part={part} index={i} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
) : (
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
)
)}
<MarkdownContent content={cleanContent} raw={false} />
<span className="studio-cursor" />
</div>
)}
@@ -561,6 +551,8 @@ export default function Studio({ api }) {
const textareaRef = useRef(null)
const abortRef = useRef(null)
const fileInputRef = useRef(null)
const ragFileRef = useRef(null)
const [ragStatus, setRagStatus] = useState(null)
useEffect(() => {
api.getChatHistory().then(data => {
@@ -589,6 +581,10 @@ export default function Studio({ api }) {
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, streaming, streamThinking, streamToolCalls])
useEffect(() => {
api.ragStatus().then(setRagStatus).catch(() => {})
}, [api])
useEffect(() => {
const onTab = (e) => {
if (e.key !== 'Tab') return
@@ -671,6 +667,20 @@ export default function Studio({ api }) {
setAttachedImages(prev => prev.filter((_, i) => i !== index))
}, [])
const handleRAGFileSelect = useCallback(async (e) => {
const files = Array.from(e.target.files || [])
if (files.length === 0) return
for (const file of files) {
try {
await api.ragIndexFile(file)
} catch (err) {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'system', content: `RAG: erreur d'indexation de ${file.name}: ${err.message}`, time: new Date().toISOString() }])
}
}
api.ragStatus().then(setRagStatus).catch(() => {})
e.target.value = ''
}, [api])
const handleSend = useCallback(async () => {
if (!input.trim() || loading) return
const text = input.trim()
@@ -1054,6 +1064,14 @@ export default function Studio({ api }) {
style={{ display: 'none' }}
onChange={handleImageSelect}
/>
<input
type="file"
ref={ragFileRef}
accept=".txt,.md,.go,.js,.ts,.py,.java,.rs,.jsx,.tsx,.json,.yaml,.yml,.csv,.html,.css,.sh,.bash,.zsh,.fish"
multiple
style={{ display: 'none' }}
onChange={handleRAGFileSelect}
/>
<button
className="studio-attach-btn"
onClick={() => fileInputRef.current?.click()}
@@ -1064,6 +1082,17 @@ export default function Studio({ api }) {
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>
</svg>
</button>
<button
className="studio-attach-btn"
onClick={() => ragFileRef.current?.click()}
disabled={loading}
title={ragStatus ? `RAG: ${ragStatus.documents || 0} docs, ${ragStatus.chunks || 0} chunks` : 'Ajouter un contexte RAG'}
style={ragStatus && ragStatus.documents > 0 ? { color: 'var(--accent, #6c5ce7)', borderColor: 'var(--accent, #6c5ce7)' } : undefined}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/>
</svg>
</button>
<button
className="studio-attach-btn"
onClick={() => {
@@ -1079,6 +1108,21 @@ export default function Studio({ api }) {
<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>
</svg>
</button>
<button
className="studio-attach-btn"
onClick={() => {
const next = !showRawMarkdown
setShowRawMarkdown(next)
try { localStorage.setItem('muyue.showRawMarkdown', String(next)) } catch {}
}}
disabled={loading}
title={showRawMarkdown ? "Markdown brut: ON" : "Markdown rendu"}
style={showRawMarkdown ? { color: 'var(--accent, #6c5ce7)', borderColor: 'var(--accent, #6c5ce7)' } : undefined}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>
</svg>
</button>
<button
className="studio-attach-btn"
onClick={() => {

View File

@@ -1313,6 +1313,90 @@ input::placeholder { color: var(--text-disabled); }
color: var(--accent-muted) !important;
}
.config-ai-tools-grid {
/* ── KaTeX overrides ── */
.katex { font-size: 1em; color: var(--text-primary); }
.katex-display { margin: 12px 0; overflow-x: auto; }
/* ── Raw Markdown Toggle ── */
.studio-raw-markdown {
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.5;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word;
background: var(--bg);
padding: 12px 16px;
border-radius: var(--radius);
border: 1px solid var(--border);
}
/* ── ReactMarkdown prose styles ── */
.feed-content > div:not(.studio-code-block):not(.studio-mermaid-container) {
line-height: 1.6;
}
.feed-content h1 { font-size: 20px; font-weight: 800; color: var(--accent); margin: 16px 0 8px; }
.feed-content h2 { font-size: 17px; font-weight: 700; color: var(--text-primary); margin: 12px 0 6px; }
.feed-content h3 { font-size: 15px; font-weight: 700; color: var(--text-primary); margin: 10px 0 4px; }
.feed-content h4 { font-size: 13px; font-weight: 600; color: var(--text-secondary); margin: 8px 0 3px; }
.feed-content h5 { font-size: 12px; font-weight: 600; color: var(--text-tertiary); margin: 6px 0 2px; }
.feed-content h6 { font-size: 11px; font-weight: 600; color: var(--text-tertiary); margin: 6px 0 2px; text-transform: uppercase; }
.feed-content p { margin: 4px 0; }
.feed-content ul { padding-left: 20px; margin: 4px 0; }
.feed-content ol { padding-left: 20px; margin: 4px 0; }
.feed-content li { margin: 2px 0; }
.feed-content blockquote {
border-left: 3px solid var(--accent-dim);
padding: 4px 12px;
margin: 8px 0;
color: var(--text-tertiary);
background: var(--bg-surface);
border-radius: 0 var(--radius) var(--radius) 0;
}
.feed-content hr { border: none; border-top: 1px solid var(--border); margin: 12px 0; }
.feed-content strong { color: var(--accent-light); font-weight: 700; }
.feed-content em { color: var(--text-secondary); }
.feed-content a { color: var(--accent); text-decoration: underline; }
.feed-content img { max-width: 100%; border-radius: var(--radius); }
.feed-content input[type="checkbox"] {
margin-right: 6px;
accent-color: var(--accent);
}
.feed-content del { color: var(--text-disabled); text-decoration: line-through; }
.feed-content sup { font-size: 0.75em; color: var(--text-tertiary); vertical-align: super; }
/* ── highlight.js overrides for dark theme ── */
.hljs { background: var(--bg) !important; color: var(--text-primary) !important; }
.hljs-keyword { color: var(--accent-muted) !important; }
.hljs-string { color: var(--success) !important; }
.hljs-comment { color: var(--text-disabled) !important; font-style: italic; }
.hljs-function { color: var(--accent-light) !important; }
.hljs-number { color: var(--warning) !important; }
/* ── Responsive / Mobile ── */
@media (max-width: 768px) {
:root { --sidebar-w: 100%; --header-h: 46px; }
.header { padding: 0 12px; gap: 8px; }
.header-nav { margin-left: 12px; gap: 2px; }
.nav-tab { padding: 6px 10px; font-size: 12px; }
.header-brand { gap: 6px; }
.header-logo { font-size: 15px; letter-spacing: 2px; }
.studio-feed { padding: 12px 8px; }
.studio-input-area { padding: 8px 8px 4px; }
.feed-item { padding: 6px 8px; }
.feed-avatar { width: 24px; height: 24px; }
.dash-grid { grid-template-columns: 1fr; grid-template-rows: auto; height: auto; overflow: auto; }
.dash-span-2 { grid-column: span 1; }
.grid-2 { grid-template-columns: 1fr; }
.split-horizontal { flex-direction: column; }
.split-right { width: 100%; border-left: none; border-top: 1px solid var(--border); max-height: 300px; }
.shell-ai-col { width: 100%; max-width: 100%; border-left: none; border-top: 1px solid var(--border); max-height: 50vh; }
.config-card-row { flex-wrap: wrap; gap: 8px; }
.config-card-label { width: 100%; }
}
.config-ai-tools-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
@@ -1360,3 +1444,228 @@ input::placeholder { color: var(--text-disabled); }
margin-bottom: 10px;
flex: 1;
}
/* === Split Panes === */
.shell-split-btn {
background: transparent;
border: 1px solid var(--border);
padding: 4px 8px;
border-radius: var(--radius-sm);
color: var(--text-tertiary);
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
transition: all 0.15s;
}
.shell-split-btn:hover {
background: var(--bg-hover);
border-color: var(--accent-dim);
color: var(--accent);
}
.shell-agent-indicator {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 10px;
background: var(--accent-dim);
color: var(--accent);
font-size: 11px;
font-weight: 600;
animation: agent-pulse 2s ease-in-out infinite;
}
.shell-agent-count {
min-width: 12px;
text-align: center;
}
@keyframes agent-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.shell-xterm-wrapper.has-splits {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.split-pane-split {
display: flex;
flex: 1;
min-height: 0;
min-width: 0;
}
.split-pane-split.row {
flex-direction: row;
}
.split-pane-split.column {
flex-direction: column;
}
.split-pane-child {
min-height: 0;
min-width: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.split-pane-resizer {
flex-shrink: 0;
background: var(--border);
transition: background 0.15s;
z-index: 10;
}
.split-pane-split.row > .split-pane-resizer {
width: 4px;
cursor: col-resize;
}
.split-pane-split.column > .split-pane-resizer {
height: 4px;
cursor: row-resize;
}
.split-pane-resizer:hover,
.split-pane-resizer:active {
background: var(--accent);
}
.split-pane-leaf {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
min-width: 0;
overflow: hidden;
border: 1px solid transparent;
}
.split-pane-leaf.active {
border-color: var(--accent-dim);
}
.split-pane-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 2px 8px;
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
font-size: 11px;
color: var(--text-tertiary);
flex-shrink: 0;
}
.split-pane-title {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.split-pane-close {
background: transparent;
border: none;
color: var(--text-disabled);
cursor: pointer;
padding: 2px;
display: flex;
align-items: center;
font-size: 10px;
}
.split-pane-close:hover {
color: var(--error);
}
.split-pane-content {
flex: 1;
min-height: 0;
overflow: hidden;
}
.split-pane-leaf.empty {
display: flex;
align-items: center;
justify-content: center;
}
.split-pane-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
color: var(--text-disabled);
}
/* === File Editor === */
.file-editor-panel {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.file-editor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 10px;
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.file-editor-title {
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 6px;
}
.file-editor-dirty {
color: var(--accent);
font-size: 14px;
}
.file-editor-actions {
display: flex;
align-items: center;
gap: 6px;
}
.file-editor-lang-badge {
font-size: 10px;
padding: 1px 6px;
border-radius: 4px;
background: var(--bg-card);
color: var(--text-tertiary);
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.5px;
}
.file-editor-body {
flex: 1;
min-height: 0;
overflow: hidden;
}
.file-editor-body .cm-editor {
height: 100%;
}