release: v0.6.0 — security audit fixes + 7 new features
All checks were successful
PR Check / check (pull_request) Successful in 57s
All checks were successful
PR Check / check (pull_request) Successful in 57s
Audit corrections (security, concurrency, stability): - chat_engine: bound resp.Choices[0] access, release tool slot per-iteration - conversation_multi: synchronous save under existing lock (was racy fire-and-forget) - workflow/engine: short-circuit on failed deps (no more infinite busy-wait); track failed/skipped status - handlers_workflow: rune-aware truncate for plan goal (UTF-8 safe) - server: CORS limited to localhost origins (was wildcard) - handlers_info / terminal: mask API keys and SSH passwords as "***" in GET responses; preserve stored secret if "***" sent on update - terminal: sshpass uses -e + SSHPASS env var (was both -p and -e) - handlers_chat: MaxBytesReader 50 MB on /api/chat - image_cache: 10 MB cap per image - handlers_config: font size <= 72; profile-save unmarshal errors propagated - handlers_info: /lsp/auto-install ProjectDir restricted to user home - Shell.jsx: parenthesized resize-condition (operator precedence) - orchestrator_test: CleanAIResponse capitalization (fixes failing vet) New features: - platform: detect OS name (Debian, Ubuntu, Windows 11, macOS X.Y) and inject in Studio system prompt next to the date - agents: default timeout 30 min for crush_run/claude_run (cap also 30 min) - agents: new cwd, wsl_distro, wsl_user params; on Windows hosts launch via "wsl -d <distro> -u <user> --cd <cwd> --" - agents: new claude_run tool (mirror of crush_run for Claude Code CLI) - terminal: list installed WSL distros individually in new-tab menu (Windows only) - studio: system prompt rewritten around BMAD-METHOD personas + mandatory delegation template - studio: "Réflexion avancée" toggle — inactive provider produces a preliminary report injected as [RAPPORT PRÉALABLE] context for the active provider - studio: "Historique compressé" toggle — collapses past tool calls to last action only, with "Tout afficher" expansion
This commit is contained in:
@@ -270,11 +270,12 @@ function CodeBlockWithCopy({ part, index, copiedIdx, setCopiedIdx }) {
|
||||
)
|
||||
}
|
||||
|
||||
function FeedItem({ msg, activeAgents, onModeChange }) {
|
||||
function FeedItem({ msg, activeAgents, onModeChange, collapseHistory }) {
|
||||
const isUser = msg.role === 'user'
|
||||
const isSystem = msg.role === 'system'
|
||||
const rank = getRank(msg.role)
|
||||
const [copiedIdx, setCopiedIdx] = useState(null)
|
||||
const [forceExpand, setForceExpand] = useState(false)
|
||||
|
||||
const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''
|
||||
|
||||
@@ -330,43 +331,81 @@ function FeedItem({ msg, activeAgents, onModeChange }) {
|
||||
</div>
|
||||
)}
|
||||
{parsedSegments && parsedSegments.some(s => s.type === 'tool') ? (
|
||||
parsedSegments.map((seg, i) => {
|
||||
if (seg.type === 'text') {
|
||||
if (!seg.content) return null
|
||||
const c = seg.content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||||
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) }} />
|
||||
(() => {
|
||||
const toolSegs = parsedSegments.filter(s => s.type === 'tool')
|
||||
const compress = collapseHistory && !forceExpand && toolSegs.length > 1
|
||||
const lastTool = toolSegs.length > 0 ? toolSegs[toolSegs.length - 1] : null
|
||||
return (
|
||||
<>
|
||||
{compress && (
|
||||
<div className="feed-content" style={{ opacity: 0.7, fontSize: '0.85em', display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>… {toolSegs.length - 1} action{toolSegs.length - 1 > 1 ? 's' : ''} précédente{toolSegs.length - 1 > 1 ? 's' : ''} masquée{toolSegs.length - 1 > 1 ? 's' : ''}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForceExpand(true)}
|
||||
style={{ background: 'transparent', border: 'none', color: 'var(--accent, #6c5ce7)', cursor: 'pointer', fontSize: 'inherit' }}
|
||||
>Tout afficher</button>
|
||||
</div>
|
||||
)}
|
||||
{parsedSegments.map((seg, i) => {
|
||||
if (seg.type === 'text') {
|
||||
if (!seg.content) return null
|
||||
const c = seg.content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||||
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) }} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (seg.type === 'tool') {
|
||||
const r = seg.result
|
||||
const result = r && (r.content !== undefined || r.is_error !== undefined)
|
||||
? { content: r.content, is_error: r.is_error }
|
||||
: null
|
||||
return <ToolCallBlock key={`tc${i}`} call={seg.call} result={result} activeAgents={activeAgents} onModeChange={onModeChange} />
|
||||
}
|
||||
return null
|
||||
})
|
||||
}
|
||||
if (seg.type === 'tool') {
|
||||
if (compress && seg !== lastTool) return null
|
||||
const r = seg.result
|
||||
const result = r && (r.content !== undefined || r.is_error !== undefined)
|
||||
? { content: r.content, is_error: r.is_error }
|
||||
: null
|
||||
return <ToolCallBlock key={`tc${i}`} call={seg.call} result={result} activeAgents={activeAgents} onModeChange={onModeChange} />
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</>
|
||||
)
|
||||
})()
|
||||
) : (
|
||||
<>
|
||||
{parsedToolCalls && parsedToolCalls.map((tc, i) => {
|
||||
const resultData = parsedToolResults
|
||||
? parsedToolResults.find(r => r.tool_call_id === tc.tool_call_id)
|
||||
: null
|
||||
const result = resultData
|
||||
? { content: resultData.result, is_error: resultData.is_error }
|
||||
: null
|
||||
return <ToolCallBlock key={tc.tool_call_id || i} call={tc} result={result} activeAgents={activeAgents} onModeChange={onModeChange} />
|
||||
})}
|
||||
{parsedToolCalls && (() => {
|
||||
const compress = collapseHistory && !forceExpand && parsedToolCalls.length > 1
|
||||
const items = compress ? parsedToolCalls.slice(-1) : parsedToolCalls
|
||||
return (
|
||||
<>
|
||||
{compress && (
|
||||
<div className="feed-content" style={{ opacity: 0.7, fontSize: '0.85em', display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>… {parsedToolCalls.length - 1} action{parsedToolCalls.length - 1 > 1 ? 's' : ''} précédente{parsedToolCalls.length - 1 > 1 ? 's' : ''} masquée{parsedToolCalls.length - 1 > 1 ? 's' : ''}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForceExpand(true)}
|
||||
style={{ background: 'transparent', border: 'none', color: 'var(--accent, #6c5ce7)', cursor: 'pointer', fontSize: 'inherit' }}
|
||||
>Tout afficher</button>
|
||||
</div>
|
||||
)}
|
||||
{items.map((tc, i) => {
|
||||
const resultData = parsedToolResults
|
||||
? parsedToolResults.find(r => r.tool_call_id === tc.tool_call_id)
|
||||
: null
|
||||
const result = resultData
|
||||
? { content: resultData.result, is_error: resultData.is_error }
|
||||
: null
|
||||
return <ToolCallBlock key={tc.tool_call_id || i} call={tc} result={result} activeAgents={activeAgents} onModeChange={onModeChange} />
|
||||
})}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
{cleanContent && (
|
||||
<div className="feed-content">
|
||||
{renderContent(cleanContent).map((part, i) =>
|
||||
@@ -385,11 +424,12 @@ function FeedItem({ msg, activeAgents, onModeChange }) {
|
||||
)
|
||||
}
|
||||
|
||||
function StreamingItem({ content, thinking, toolCalls, segments, activeAgents, onModeChange }) {
|
||||
function StreamingItem({ content, thinking, toolCalls, segments, activeAgents, onModeChange, collapseHistory }) {
|
||||
const rank = RANKS.general
|
||||
const cleanContent = content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||||
const hasToolCalls = toolCalls && toolCalls.length > 0
|
||||
const [copiedIdx, setCopiedIdx] = useState(null)
|
||||
const [forceExpand, setForceExpand] = useState(false)
|
||||
|
||||
const renderedContent = useMemo(() => {
|
||||
if (!cleanContent) return []
|
||||
@@ -402,6 +442,8 @@ function StreamingItem({ content, thinking, toolCalls, segments, activeAgents, o
|
||||
}, [thinking])
|
||||
|
||||
const hasOrderedSegments = segments && segments.some(s => s.type === 'tool')
|
||||
const toolSegments = (segments || []).filter(s => s.type === 'tool')
|
||||
const compress = collapseHistory && !forceExpand && toolSegments.length > 1
|
||||
|
||||
return (
|
||||
<div className="feed-item assistant">
|
||||
@@ -417,32 +459,51 @@ function StreamingItem({ content, thinking, toolCalls, segments, activeAgents, o
|
||||
</div>
|
||||
{thinking && <ThinkingBlock content={formattedThinking} raw done={false} />}
|
||||
{hasOrderedSegments ? (
|
||||
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) }} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (seg.type === 'tool') {
|
||||
return <ToolCallBlock key={`tc${i}`} call={seg.call} result={seg.result} activeAgents={activeAgents} onModeChange={onModeChange} />
|
||||
}
|
||||
return null
|
||||
})
|
||||
<>
|
||||
{compress && (
|
||||
<div className="feed-content" style={{ opacity: 0.7, fontSize: '0.85em', display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>… {toolSegments.length - 1} action{toolSegments.length - 1 > 1 ? 's' : ''} précédente{toolSegments.length - 1 > 1 ? 's' : ''} masquée{toolSegments.length - 1 > 1 ? 's' : ''} (mode compressé)</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForceExpand(true)}
|
||||
style={{ background: 'transparent', border: 'none', color: 'var(--accent, #6c5ce7)', cursor: 'pointer', fontSize: 'inherit' }}
|
||||
>Tout afficher</button>
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
const lastToolId = toolSegments.length > 0 ? toolSegments[toolSegments.length - 1] : null
|
||||
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) }} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (seg.type === 'tool') {
|
||||
if (compress && seg !== lastToolId) return null
|
||||
return <ToolCallBlock key={`tc${i}`} call={seg.call} result={seg.result} activeAgents={activeAgents} onModeChange={onModeChange} />
|
||||
}
|
||||
return null
|
||||
})
|
||||
})()}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{hasToolCalls && toolCalls.map((tc, i) => (
|
||||
<ToolCallBlock key={tc.call?.tool_call_id || i} call={tc.call} result={tc.result} activeAgents={activeAgents} onModeChange={onModeChange} />
|
||||
))}
|
||||
{hasToolCalls && (compress
|
||||
? [<ToolCallBlock key={toolCalls[toolCalls.length - 1].call?.tool_call_id || 'last'} call={toolCalls[toolCalls.length - 1].call} result={toolCalls[toolCalls.length - 1].result} activeAgents={activeAgents} onModeChange={onModeChange} />]
|
||||
: toolCalls.map((tc, i) => (
|
||||
<ToolCallBlock key={tc.call?.tool_call_id || i} call={tc.call} result={tc.result} activeAgents={activeAgents} onModeChange={onModeChange} />
|
||||
))
|
||||
)}
|
||||
{cleanContent && (
|
||||
<div className="feed-content">
|
||||
{renderedContent.map((part, i) =>
|
||||
@@ -487,6 +548,12 @@ export default function Studio({ api }) {
|
||||
const [attachedImages, setAttachedImages] = useState([])
|
||||
const [activeAgents, setActiveAgents] = useState({ crush: 0, claude: 0 })
|
||||
const [toolModes, setToolModes] = useState({})
|
||||
const [advancedReflection, setAdvancedReflection] = useState(() => {
|
||||
try { return localStorage.getItem('muyue.advancedReflection') === 'true' } catch { return false }
|
||||
})
|
||||
const [collapseHistory, setCollapseHistory] = useState(() => {
|
||||
try { return localStorage.getItem('muyue.collapseHistory') !== 'false' } catch { return true }
|
||||
})
|
||||
const MAX_CRUSH_AGENTS = 2
|
||||
const MAX_CLAUDE_AGENTS = 2
|
||||
const messagesEnd = useRef(null)
|
||||
@@ -747,7 +814,7 @@ export default function Studio({ api }) {
|
||||
setStreaming(partial)
|
||||
const snap = segments.map(s => ({ ...s }))
|
||||
setStreamSegments(snap)
|
||||
}, controller.signal, images)
|
||||
}, controller.signal, images, advancedReflection)
|
||||
|
||||
const allText = segments.filter(s => s.type === 'text').map(s => s.content).join('')
|
||||
const toolSegs = segments.filter(s => s.type === 'tool')
|
||||
@@ -882,7 +949,7 @@ export default function Studio({ api }) {
|
||||
<span className="feed-summary-toggle">{summarizedExpanded ? 'masquer' : 'voir'}</span>
|
||||
</div>
|
||||
{summarizedExpanded && summarizedMsgs.map(msg => (
|
||||
<FeedItem key={msg.id} msg={msg} activeAgents={activeAgents} onModeChange={handleToolModeChange} />
|
||||
<FeedItem key={msg.id} msg={msg} activeAgents={activeAgents} onModeChange={handleToolModeChange} collapseHistory={collapseHistory} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -894,7 +961,7 @@ export default function Studio({ api }) {
|
||||
<>
|
||||
{renderSummaryBlock()}
|
||||
{activeMsgs.slice(0, visibleCount).map(msg => (
|
||||
<FeedItem key={msg.id} msg={msg} activeAgents={activeAgents} onModeChange={handleToolModeChange} />
|
||||
<FeedItem key={msg.id} msg={msg} activeAgents={activeAgents} onModeChange={handleToolModeChange} collapseHistory={collapseHistory} />
|
||||
))}
|
||||
<div className="feed-collapsed-messages" onClick={handleToggleCollapsed}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
@@ -911,7 +978,7 @@ export default function Studio({ api }) {
|
||||
<>
|
||||
{renderSummaryBlock()}
|
||||
{activeMsgs.map(msg => (
|
||||
<FeedItem key={msg.id} msg={msg} activeAgents={activeAgents} onModeChange={handleToolModeChange} />
|
||||
<FeedItem key={msg.id} msg={msg} activeAgents={activeAgents} onModeChange={handleToolModeChange} collapseHistory={collapseHistory} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
@@ -935,7 +1002,7 @@ export default function Studio({ api }) {
|
||||
<div className="studio-feed" ref={feedRef}>
|
||||
{renderMessages()}
|
||||
{(streaming || streamThinking || loading || streamToolCalls.length > 0) && (
|
||||
<StreamingItem content={streaming} thinking={streamThinking} toolCalls={streamToolCalls} segments={streamSegments} activeAgents={activeAgents} onModeChange={handleToolModeChange} />
|
||||
<StreamingItem content={streaming} thinking={streamThinking} toolCalls={streamToolCalls} segments={streamSegments} activeAgents={activeAgents} onModeChange={handleToolModeChange} collapseHistory={collapseHistory} />
|
||||
)}
|
||||
<div ref={messagesEnd} style={{ height: '24px' }} />
|
||||
</div>
|
||||
@@ -997,6 +1064,36 @@ 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={() => {
|
||||
const next = !advancedReflection
|
||||
setAdvancedReflection(next)
|
||||
try { localStorage.setItem('muyue.advancedReflection', String(next)) } catch {}
|
||||
}}
|
||||
disabled={loading}
|
||||
title={advancedReflection ? "Réflexion avancée: ON (un autre modèle produit un rapport préalable)" : "Réflexion avancée: OFF"}
|
||||
style={advancedReflection ? { 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">
|
||||
<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className="studio-attach-btn"
|
||||
onClick={() => {
|
||||
const next = !collapseHistory
|
||||
setCollapseHistory(next)
|
||||
try { localStorage.setItem('muyue.collapseHistory', String(next)) } catch {}
|
||||
}}
|
||||
disabled={loading}
|
||||
title={collapseHistory ? "Historique compressé (dernière action visible)" : "Historique complet (tout visible)"}
|
||||
style={collapseHistory ? { 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">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
|
||||
Reference in New Issue
Block a user