feat(agent): refactor AI chat with streaming, agent registry, and tool execution
All checks were successful
Beta Release / beta (push) Successful in 47s
All checks were successful
Beta Release / beta (push) Successful in 47s
- Replace old tool-call regex with proper agent registry - Add streaming chat via SSE (handleStreamChat / handleNonStreamChat) - Add internal/agent package with tool definitions and execution - Add orchestrator with system prompt and tool scaffolding - Add internal/agent/ directory - Studio.jsx: streaming chat with thinking indicator and tool result rendering - global.css: chat bubble styles, streaming animation, thinking dots - handlers_chat.go: full rewrite using new agent/orchestrator architecture 💘 Generated with Crush Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
This commit is contained in:
@@ -78,6 +78,71 @@ function ThinkingBlock({ content, done }) {
|
||||
)
|
||||
}
|
||||
|
||||
const TOOL_ICONS = {
|
||||
terminal: '⌨',
|
||||
crush_run: '⚡',
|
||||
read_file: '📄',
|
||||
list_files: '📁',
|
||||
search_files: '🔍',
|
||||
grep_content: '🔎',
|
||||
get_config: '⚙',
|
||||
set_provider: '🔑',
|
||||
manage_ssh: '🌐',
|
||||
web_fetch: '🌐',
|
||||
}
|
||||
|
||||
const TOOL_LABELS = {
|
||||
terminal: 'Terminal',
|
||||
crush_run: 'Crush Agent',
|
||||
read_file: 'Read File',
|
||||
list_files: 'List Files',
|
||||
search_files: 'Search Files',
|
||||
grep_content: 'Grep',
|
||||
get_config: 'Config',
|
||||
set_provider: 'Set Provider',
|
||||
manage_ssh: 'SSH',
|
||||
web_fetch: 'Web Fetch',
|
||||
}
|
||||
|
||||
function ToolCallBlock({ call, result }) {
|
||||
const icon = TOOL_ICONS[call.name] || '🔧'
|
||||
const label = TOOL_LABELS[call.name] || call.name
|
||||
const isErr = result && result.is_error
|
||||
|
||||
let argsPreview = ''
|
||||
try {
|
||||
const args = typeof call.args === 'string' ? JSON.parse(call.args) : call.args
|
||||
if (args.command) argsPreview = args.command
|
||||
else if (args.task) argsPreview = args.task
|
||||
else if (args.path) argsPreview = args.path
|
||||
else if (args.pattern) argsPreview = args.pattern
|
||||
else if (args.url) argsPreview = args.url
|
||||
else if (args.action) argsPreview = args.action
|
||||
else argsPreview = JSON.stringify(args).slice(0, 80)
|
||||
} catch {
|
||||
argsPreview = String(call.args).slice(0, 80)
|
||||
}
|
||||
|
||||
const truncatedResult = result ? (result.content || '').slice(0, 2000) : null
|
||||
|
||||
return (
|
||||
<div className={`studio-tool-block ${isErr ? 'error' : ''} ${result ? 'done' : 'running'}`}>
|
||||
<div className="studio-tool-header">
|
||||
<span className="studio-tool-icon">{icon}</span>
|
||||
<span className="studio-tool-name">{label}</span>
|
||||
{!result && <span className="studio-tool-spinner"><span/><span/><span/></span>}
|
||||
{result && <span className={`studio-tool-status ${isErr ? 'error' : 'ok'}`}>{isErr ? '✗' : '✓'}</span>}
|
||||
</div>
|
||||
<div className="studio-tool-args" title={argsPreview}>{argsPreview}</div>
|
||||
{truncatedResult && (
|
||||
<div className="studio-tool-result">
|
||||
<pre>{truncatedResult}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FeedItem({ msg }) {
|
||||
const isUser = msg.role === 'user'
|
||||
const isSystem = msg.role === 'system'
|
||||
@@ -85,6 +150,16 @@ function FeedItem({ msg }) {
|
||||
|
||||
const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''
|
||||
|
||||
let parsedToolCalls = null
|
||||
let displayContent = msg.content
|
||||
try {
|
||||
const parsed = JSON.parse(msg.content)
|
||||
if (parsed && Array.isArray(parsed.tool_calls)) {
|
||||
parsedToolCalls = parsed.tool_calls
|
||||
displayContent = parsed.content || ''
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (isSystem) {
|
||||
return (
|
||||
<div className="feed-item system">
|
||||
@@ -95,7 +170,7 @@ function FeedItem({ msg }) {
|
||||
)
|
||||
}
|
||||
|
||||
const cleanContent = msg.content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||||
const cleanContent = displayContent.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||||
|
||||
return (
|
||||
<div className={`feed-item ${msg.role}`}>
|
||||
@@ -111,26 +186,32 @@ function FeedItem({ msg }) {
|
||||
{timeStr && <span className="feed-time">{timeStr}</span>}
|
||||
</div>
|
||||
{msg.thinking && <ThinkingBlock content={msg.thinking} done />}
|
||||
<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) }} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{parsedToolCalls && parsedToolCalls.map((tc, i) => (
|
||||
<ToolCallBlock key={tc.tool_call_id || i} call={tc} result={null} />
|
||||
))}
|
||||
{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) }} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StreamingItem({ content, thinking }) {
|
||||
function StreamingItem({ content, thinking, toolCalls }) {
|
||||
const rank = RANKS.general
|
||||
const cleanContent = content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||||
const hasToolCalls = toolCalls && toolCalls.length > 0
|
||||
|
||||
return (
|
||||
<div className="feed-item assistant">
|
||||
@@ -145,7 +226,10 @@ function StreamingItem({ content, thinking }) {
|
||||
<span className="feed-role">{rank.label}</span>
|
||||
</div>
|
||||
{thinking && <ThinkingBlock content={thinking} done={false} />}
|
||||
{!thinking && !cleanContent && (
|
||||
{hasToolCalls && toolCalls.map((tc, i) => (
|
||||
<ToolCallBlock key={tc.call?.tool_call_id || i} call={tc.call} result={tc.result} />
|
||||
))}
|
||||
{!thinking && !cleanContent && !hasToolCalls && (
|
||||
<div className="feed-content">
|
||||
<div className="studio-thinking"><span /><span /><span /></div>
|
||||
</div>
|
||||
@@ -177,6 +261,7 @@ export default function Studio({ api }) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [streaming, setStreaming] = useState('')
|
||||
const [streamThinking, setStreamThinking] = useState('')
|
||||
const [streamToolCalls, setStreamToolCalls] = useState([])
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const messagesEnd = useRef(null)
|
||||
const textareaRef = useRef(null)
|
||||
@@ -201,7 +286,7 @@ export default function Studio({ api }) {
|
||||
|
||||
useEffect(() => {
|
||||
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages, streaming, streamThinking])
|
||||
}, [messages, streaming, streamThinking, streamToolCalls])
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
@@ -234,10 +319,12 @@ export default function Studio({ api }) {
|
||||
setLoading(true)
|
||||
setStreaming('')
|
||||
setStreamThinking('')
|
||||
setStreamToolCalls([])
|
||||
|
||||
try {
|
||||
let accumulated = ''
|
||||
let thinking = ''
|
||||
let toolCalls = []
|
||||
|
||||
await api.sendChat(text, true, (partial, event) => {
|
||||
if (event && (event.thinking_start || event.thinking_end || event.thinking !== undefined)) {
|
||||
@@ -247,6 +334,19 @@ export default function Studio({ api }) {
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event && event.tool_call) {
|
||||
toolCalls = [...toolCalls, { call: event.tool_call, result: null }]
|
||||
setStreamToolCalls([...toolCalls])
|
||||
return
|
||||
}
|
||||
if (event && event.tool_result) {
|
||||
const idx = toolCalls.findIndex(tc => tc.call && tc.call.tool_call_id === event.tool_result.tool_call_id)
|
||||
if (idx >= 0) {
|
||||
toolCalls[idx] = { ...toolCalls[idx], result: event.tool_result }
|
||||
setStreamToolCalls([...toolCalls])
|
||||
}
|
||||
return
|
||||
}
|
||||
accumulated = partial
|
||||
setStreaming(partial)
|
||||
})
|
||||
@@ -259,6 +359,12 @@ export default function Studio({ api }) {
|
||||
time: new Date().toISOString(),
|
||||
}
|
||||
if (thinking) aiMsg.thinking = thinking
|
||||
if (toolCalls.length > 0) {
|
||||
aiMsg.content = JSON.stringify({
|
||||
content: finalContent,
|
||||
tool_calls: toolCalls.map(tc => tc.call),
|
||||
})
|
||||
}
|
||||
setMessages(prev => [...prev, aiMsg])
|
||||
} catch (err) {
|
||||
setMessages(prev => [...prev, {
|
||||
@@ -271,6 +377,7 @@ export default function Studio({ api }) {
|
||||
setLoading(false)
|
||||
setStreaming('')
|
||||
setStreamThinking('')
|
||||
setStreamToolCalls([])
|
||||
}
|
||||
}, [input, loading, api, t, handleClear])
|
||||
|
||||
@@ -299,8 +406,8 @@ export default function Studio({ api }) {
|
||||
{messages.map(msg => (
|
||||
<FeedItem key={msg.id} msg={msg} />
|
||||
))}
|
||||
{(streaming || streamThinking || loading) && (
|
||||
<StreamingItem content={streaming} thinking={streamThinking} />
|
||||
{(streaming || streamThinking || loading || streamToolCalls.length > 0) && (
|
||||
<StreamingItem content={streaming} thinking={streamThinking} toolCalls={streamToolCalls} />
|
||||
)}
|
||||
<div ref={messagesEnd} />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user