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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user