refactor: remove agent selector — auto-route to best-fit agent by keyword score

- pickAgent(goal, agents): scores agents by name/description keyword overlap, falls back to first agent
- Removed AgentSelector component from Agent mode header
- Header now shows passive agent count (N agents / agent name) — informational only
- Bubble label shows actual resolved agent name instead of 'Agent'
- Empty state and placeholder copy updated to reinforce auto-routing UX
- Fixed TypeScript: role literal types in history, removed stale selectedAgentId deps
This commit is contained in:
2026-04-09 09:39:34 +05:30
parent da7140b6c6
commit 49458ba59c

View File

@@ -26,6 +26,7 @@ interface ChatMessage {
tokenCost?: number
createdAt: string
agentId?: string
agentName?: string
mode?: ChatMode
chunksUsed?: number
latencyMs?: number
@@ -63,6 +64,22 @@ function parseSlash(input: string): { cmd: string; args: string } | null {
const SKILL_INTENT_RE = /\b(create|build|make|set up|setup)\s+(an?\s+)?(agent|bot|skill|assistant)\s+(that|to|for|which)/i
const CONNECT_INTENT_RE = /\b(connect|integrate|set up|setup|link)\s+(my\s+)?(slack|telegram|gmail|whatsapp|discord|notion|github|linear|jira|google)/i
// ── Agent auto-routing ────────────────────────────────────────────────────
function pickAgent(goal: string, agents: ApiAgent[]): ApiAgent | null {
if (agents.length === 0) return null
if (agents.length === 1) return agents[0]
const words = goal.toLowerCase().split(/\W+/).filter(w => w.length > 2)
let best = agents[0]
let bestScore = -1
for (const agent of agents) {
const hay = `${agent.name} ${agent.description ?? ''}`.toLowerCase()
const score = words.reduce((acc, w) => acc + (hay.includes(w) ? 1 : 0), 0)
if (score > bestScore) { bestScore = score; best = agent }
}
return best
}
// ── Suggestion chips ───────────────────────────────────────────────────────
interface SuggestionChip {
@@ -144,109 +161,6 @@ function inlineFormat(text: string): React.ReactNode {
})}</>
}
// ── Agent Selector ─────────────────────────────────────────────────────────
function AgentSelector({ agents, selectedId, onSelect }: {
agents: ApiAgent[]
selectedId: string
onSelect: (id: string) => void
}) {
const [open, setOpen] = useState(false)
const selected = agents.find(a => a.id === selectedId)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
return (
<div ref={ref} style={{ position: 'relative' }}>
<button
onClick={() => setOpen(o => !o)}
style={{
display: 'flex',
alignItems: 'center',
gap: 7,
padding: '5px 10px 5px 8px',
borderRadius: 8,
background: 'var(--nbl-bg-surface)',
border: '1px solid var(--nbl-border)',
cursor: 'pointer',
fontSize: 12,
fontWeight: 500,
color: 'var(--nbl-text-primary)',
maxWidth: 200,
transition: 'border-color 120ms',
}}
onMouseEnter={e => (e.currentTarget.style.borderColor = 'var(--nbl-border-input-focus)')}
onMouseLeave={e => (e.currentTarget.style.borderColor = 'var(--nbl-border)')}
>
<Bot size={13} style={{ color: 'var(--nbl-cyan)', flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{selected?.name ?? 'Select agent…'}
</span>
<ChevronDown size={11} style={{ flexShrink: 0, color: 'var(--nbl-text-ghost)', marginLeft: 'auto', transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 150ms' }} />
</button>
{open && (
<div style={{
position: 'absolute',
top: 'calc(100% + 4px)',
left: 0,
zIndex: 200,
background: 'var(--nbl-surface)',
border: '1px solid var(--nbl-border)',
borderRadius: 10,
padding: 4,
minWidth: 220,
maxHeight: 280,
overflowY: 'auto',
boxShadow: '0 8px 24px -4px rgba(0,0,0,0.5)',
}}>
{agents.length === 0 && (
<div style={{ padding: '8px 10px', fontSize: 11, color: 'var(--nbl-text-ghost)' }}>No agents yet</div>
)}
{agents.map(a => (
<button
key={a.id}
onMouseDown={() => { onSelect(a.id); setOpen(false) }}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
width: '100%',
padding: '7px 10px',
borderRadius: 7,
background: a.id === selectedId ? 'color-mix(in srgb, var(--nbl-cyan) 10%, transparent)' : 'transparent',
border: 'none',
cursor: 'pointer',
textAlign: 'left',
transition: 'background 100ms',
}}
onMouseEnter={e => { if (a.id !== selectedId) e.currentTarget.style.background = 'var(--nbl-bg-surface)' }}
onMouseLeave={e => { if (a.id !== selectedId) e.currentTarget.style.background = 'transparent' }}
>
<div style={{
width: 7, height: 7, borderRadius: '50%', flexShrink: 0,
background: a.status === 'running' ? 'var(--nbl-green)' : 'var(--nbl-text-ghost)',
}} />
<span style={{ flex: 1, fontSize: 12, color: 'var(--nbl-text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{a.name}
</span>
{a.id === selectedId && <Check size={11} style={{ color: 'var(--nbl-cyan)', flexShrink: 0 }} />}
</button>
))}
</div>
)}
</div>
)
}
// ── Message bubble ─────────────────────────────────────────────────────────
function MessageBubble({ msg, navigate }: { msg: ChatMessage; navigate: ReturnType<typeof useNavigate> }) {
@@ -269,7 +183,7 @@ function MessageBubble({ msg, navigate }: { msg: ChatMessage; navigate: ReturnTy
? <Sparkles size={11} style={{ color: 'var(--nbl-nebula)' }} />
: <Bot size={11} style={{ color: 'var(--nbl-cyan)' }} />}
<span style={{ fontSize: 10, color: 'var(--nbl-text-ghost)', fontWeight: 500 }}>
{isNebula ? 'Nebula' : 'Agent'}
{isNebula ? 'Nebula' : (msg.agentName ?? 'Agent')}
</span>
</div>
)}
@@ -283,12 +197,12 @@ function MessageBubble({ msg, navigate }: { msg: ChatMessage; navigate: ReturnTy
? 'color-mix(in srgb, var(--nbl-nebula) 6%, var(--nbl-bg-surface))'
: 'var(--nbl-bg-surface)',
border: `1px solid ${isUser
? 'color-mix(in srgb, var(--nbl-nebula) 28%, var(--nbl-border))'
: failed
? 'color-mix(in srgb, var(--nbl-red) 30%, var(--nbl-border))'
: isNebula
? 'color-mix(in srgb, var(--nbl-nebula) 18%, var(--nbl-border))'
: 'var(--nbl-border)'}`,
? 'color-mix(in srgb, var(--nbl-nebula) 28%, var(--nbl-border))'
: failed
? 'color-mix(in srgb, var(--nbl-red) 30%, var(--nbl-border))'
: isNebula
? 'color-mix(in srgb, var(--nbl-nebula) 18%, var(--nbl-border))'
: 'var(--nbl-border)'}`,
fontSize: 13,
lineHeight: 1.6,
color: 'var(--nbl-text-primary)',
@@ -481,7 +395,6 @@ export function ChatWorkspace() {
const qc = useQueryClient()
const [input, setInput] = useState('')
const [chatMode, setChatMode] = useState<ChatMode>('nebula')
const [selectedAgentId, setSelectedAgentId] = useState<string>('')
const [messages, setMessages] = useState<ChatMessage[]>([])
const [pendingTaskIds, setPendingTaskIds] = useState<Set<string>>(new Set())
const [history, setHistory] = useState<ApiChatMessage[]>([])
@@ -505,11 +418,6 @@ export function ChatWorkspace() {
const agents = agentList?.items ?? []
const integrationCount = instanceData?.length ?? 0
// Auto-select first agent
useEffect(() => {
if (!selectedAgentId && agents.length > 0) setSelectedAgentId(agents[0].id)
}, [agents, selectedAgentId])
// ── WS — real-time task updates ─────────────────────────────────────────
const handleWsMessage = useCallback((msg: { type: string; entity_id?: string; payload?: Record<string, unknown> }) => {
@@ -555,8 +463,8 @@ export function ChatWorkspace() {
setMessages(prev => [...prev, userMsg, aiMsg])
setHistory(prev => [
...prev,
{ role: 'user', content: message },
{ role: 'assistant', content: resp.reply },
{ role: 'user' as const, content: message },
{ role: 'assistant' as const, content: resp.reply },
].slice(-20))
},
onError: (err: Error) => {
@@ -574,8 +482,9 @@ export function ChatWorkspace() {
mutationFn: ({ agentId, goal }: { agentId: string; goal: string }) =>
agentsApi.run(agentId, { goal }),
onSuccess: (task, { goal }) => {
const agentName = agents.find(a => a.id === task.agent_id)?.name
const userMsg: ChatMessage = { id: `u-${Date.now()}`, role: 'user', content: goal, agentId: task.agent_id, createdAt: new Date().toISOString(), mode: 'agent' }
const agentMsg: ChatMessage = { id: `a-${task.id}`, role: 'agent', content: '', taskId: task.id, taskStatus: task.status, agentId: task.agent_id, createdAt: new Date().toISOString(), mode: 'agent' }
const agentMsg: ChatMessage = { id: `a-${task.id}`, role: 'agent', content: '', taskId: task.id, taskStatus: task.status, agentId: task.agent_id, agentName, createdAt: new Date().toISOString(), mode: 'agent' }
setMessages(prev => [...prev, userMsg, agentMsg])
setPendingTaskIds(prev => new Set([...prev, task.id]))
qc.invalidateQueries({ queryKey: ['viz-agents'] })
@@ -614,12 +523,14 @@ export function ChatWorkspace() {
case '/kb':
if (args) nebulaMut.mutate(args)
return !!args
case '/run':
if (args && selectedAgentId) {
runMut.mutate({ agentId: selectedAgentId, goal: args })
case '/run': {
const agent = pickAgent(args, agents)
if (args && agent) {
runMut.mutate({ agentId: agent.id, goal: args })
return true
}
return false
}
case '/agent':
navigate(`/agents/new${args ? `?name=${encodeURIComponent(args)}` : ''}`)
return true
@@ -636,7 +547,7 @@ export function ChatWorkspace() {
default:
return false
}
}, [navigate, nebulaMut, runMut, selectedAgentId])
}, [navigate, nebulaMut, runMut, agents])
// ── Send ─────────────────────────────────────────────────────────────────
@@ -680,13 +591,14 @@ export function ChatWorkspace() {
if (mode === 'nebula') {
nebulaMut.mutate(text)
} else {
if (!selectedAgentId) {
setMessages(prev => [...prev, { id: `e-${Date.now()}`, role: 'agent', content: 'Select an agent first.', taskStatus: 'failed', mode: 'agent', createdAt: new Date().toISOString() }])
const agent = pickAgent(text, agents)
if (!agent) {
setMessages(prev => [...prev, { id: `e-${Date.now()}`, role: 'agent', content: 'No agents available. Create one first.', taskStatus: 'failed', mode: 'agent', createdAt: new Date().toISOString() }])
return
}
runMut.mutate({ agentId: selectedAgentId, goal: text })
runMut.mutate({ agentId: agent.id, goal: text })
}
}, [input, isPending, chatMode, handleSlash, navigate, nebulaMut, runMut, selectedAgentId])
}, [input, isPending, chatMode, handleSlash, navigate, nebulaMut, runMut, agents])
// ── Auto-scroll + resize ─────────────────────────────────────────────────
@@ -699,8 +611,6 @@ export function ChatWorkspace() {
el.style.height = `${Math.min(el.scrollHeight, 120)}px`
}, [input])
const selectedAgent = agents.find(a => a.id === selectedAgentId)
return (
<div className="ne-ChatWorkspace">
{/* Header */}
@@ -712,8 +622,11 @@ export function ChatWorkspace() {
<span style={{ fontSize: 11, color: 'var(--nbl-text-ghost)' }}>workspace</span>
</div>
<ModeToggle mode={chatMode} onChange={m => { setChatMode(m); setInput('') }} />
{chatMode === 'agent' && (
<AgentSelector agents={agents} selectedId={selectedAgentId} onSelect={setSelectedAgentId} />
{chatMode === 'agent' && agents.length > 0 && (
<span style={{ fontSize: 11, color: 'var(--nbl-text-ghost)', display: 'flex', alignItems: 'center', gap: 4 }}>
<Bot size={10} />
{agents.length === 1 ? agents[0].name : `${agents.length} agents`}
</span>
)}
<div style={{ marginLeft: 'auto', display: 'flex', gap: 4 }}>
<button
@@ -739,12 +652,14 @@ export function ChatWorkspace() {
<div className="ne-ChatWorkspace__empty">
<Sparkles size={30} style={{ color: 'var(--nbl-nebula)', marginBottom: 12, opacity: 0.7 }} />
<div style={{ fontSize: 14, color: 'var(--nbl-text-primary)', fontWeight: 600, marginBottom: 6 }}>
{chatMode === 'nebula' ? 'Ask Nebula anything' : (selectedAgent ? `Chat with ${selectedAgent.name}` : 'Select an agent')}
{chatMode === 'nebula' ? 'Ask Nebula anything' : 'Run a task'}
</div>
<div style={{ fontSize: 12, color: 'var(--nbl-text-ghost)', marginBottom: 20, maxWidth: 340, textAlign: 'center', lineHeight: 1.6 }}>
{chatMode === 'nebula'
? 'Get help with agents, workflows, integrations and more. Use /help for commands.'
: 'Send a goal and watch it run in real time.'}
: agents.length > 0
? 'Describe a task — the right agent will be picked automatically.'
: 'No agents yet. Create one to get started.'}
</div>
<SuggestionChips
agents={agents}
@@ -803,9 +718,9 @@ export function ChatWorkspace() {
placeholder={
chatMode === 'nebula'
? 'Ask anything or type / for commands…'
: selectedAgent
? `Goal for ${selectedAgent.name}`
: 'Select an agent first…'
: agents.length > 0
? 'Describe a task — best agent chosen automatically…'
: 'No agents yet — create one first…'
}
disabled={isPending}
rows={1}