From 12f03af32e7cc75e9879bc2f01b9e00a260cc1db Mon Sep 17 00:00:00 2001 From: Dani Akash Date: Wed, 1 Apr 2026 17:42:14 +0530 Subject: [PATCH] feat: add chat interface and LLM provider selection for agents Chat: - Add POST /agents/:id/chat proxy endpoint that forwards to OpenClaw's OpenAI-compatible /v1/chat/completions API with SSE streaming - Enable chatCompletions API during agent creation - Add AgentChat component with message bubbles, streaming, auto-scroll - Chat button on running agent cards, inline chat view LLM Provider: - Reuse BrowserOS LLM provider configs via useLlmProviders() - Provider dropdown in create dialog filtered to OpenClaw-compatible types - API key injected into docker-compose.yml as environment variable - Pre-selects user's default provider --- .../entrypoints/app/agents/AgentChat.tsx | 216 ++++++++++++++++++ .../entrypoints/app/agents/AgentsPage.tsx | 131 ++++++++++- .../apps/server/src/api/routes/agents.ts | 113 ++++++++- 3 files changed, 449 insertions(+), 11 deletions(-) create mode 100644 packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentChat.tsx diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentChat.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentChat.tsx new file mode 100644 index 00000000..b0c94cc0 --- /dev/null +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentChat.tsx @@ -0,0 +1,216 @@ +import { ArrowLeft, Loader2, Send } from 'lucide-react' +import { type FC, useEffect, useRef, useState } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { getAgentServerUrl } from '@/lib/browseros/helpers' +import { cn } from '@/lib/utils' + +interface ChatMessage { + id: string + role: 'user' | 'assistant' + content: string +} + +let msgCounter = 0 +function nextMsgId(): string { + return `msg-${++msgCounter}` +} + +interface AgentChatProps { + agentId: string + agentName: string + onBack: () => void +} + +export const AgentChat: FC = ({ + agentId, + agentName, + onBack, +}) => { + const [messages, setMessages] = useState([]) + const [input, setInput] = useState('') + const [streaming, setStreaming] = useState(false) + const scrollRef = useRef(null) + const inputRef = useRef(null) + + useEffect(() => { + inputRef.current?.focus() + }, []) + + const messagesLength = messages.length + const lastMessageContent = messages[messages.length - 1]?.content ?? '' + // biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new messages and content updates + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight + } + }, [messagesLength, lastMessageContent]) + + const sendMessage = async () => { + const text = input.trim() + if (!text || streaming) return + + setInput('') + setMessages((prev) => [ + ...prev, + { id: nextMsgId(), role: 'user', content: text }, + ]) + setStreaming(true) + + // Add an empty assistant message to stream into + const assistantId = nextMsgId() + setMessages((prev) => [ + ...prev, + { id: assistantId, role: 'assistant', content: '' }, + ]) + + try { + const baseUrl = await getAgentServerUrl() + const response = await fetch(`${baseUrl}/agents/${agentId}/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: text }), + }) + + if (!response.ok) { + const err = await response.json() + setMessages((prev) => { + const updated = [...prev] + const last = updated[updated.length - 1] + updated[updated.length - 1] = { + ...last, + content: `Error: ${(err as { error?: string }).error ?? 'Unknown error'}`, + } + return updated + }) + return + } + + const reader = (response.body as ReadableStream).getReader() + const decoder = new TextDecoder() + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + + for (const line of lines) { + if (!line.startsWith('data: ') || line === 'data: [DONE]') continue + try { + const chunk = JSON.parse(line.slice(6)) + const content = chunk.choices?.[0]?.delta?.content + if (content) { + setMessages((prev) => { + const updated = [...prev] + const last = updated[updated.length - 1] + updated[updated.length - 1] = { + ...last, + content: last.content + content, + } + return updated + }) + } + } catch { + // Skip malformed chunks + } + } + } + } catch (err) { + setMessages((prev) => { + const updated = [...prev] + const last = updated[updated.length - 1] + updated[updated.length - 1] = { + ...last, + content: `Connection error: ${err instanceof Error ? err.message : 'Failed to reach agent'}`, + } + return updated + }) + } finally { + setStreaming(false) + inputRef.current?.focus() + } + } + + return ( +
+
+ +
+

{agentName}

+

+ Chat with your OpenClaw agent +

+
+
+ +
+ {messages.length === 0 && ( +
+

+ Send a message to start chatting with your agent. +

+
+ )} + {messages.map((msg, i) => ( +
+
+
{msg.content}
+ {msg.role === 'assistant' && + streaming && + i === messages.length - 1 && + !msg.content && ( + + )} +
+
+ ))} +
+ +
+ setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + sendMessage() + } + }} + disabled={streaming} + /> + +
+
+ ) +} diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsPage.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsPage.tsx index 2b963cb5..d5184af1 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsPage.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsPage.tsx @@ -2,13 +2,14 @@ import { AlertCircle, Cpu, Loader2, + MessageSquare, Play, Plus, ScrollText, Square, Trash2, } from 'lucide-react' -import { type FC, useEffect, useState } from 'react' +import { type FC, useEffect, useMemo, useState } from 'react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card } from '@/components/ui/card' @@ -20,10 +21,29 @@ import { DialogTrigger, } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' import { getAgentServerUrl } from '@/lib/browseros/helpers' +import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders' import { useRpcClient } from '@/lib/rpc/RpcClientProvider' +import { AgentChat } from './AgentChat' import { AgentLogsDialog } from './AgentLogsDialog' +const OPENCLAW_COMPATIBLE_TYPES = new Set([ + 'anthropic', + 'openai', + 'google', + 'openrouter', + 'moonshot', + 'groq', + 'mistral', +]) + interface AgentInstance { id: string name: string @@ -31,10 +51,12 @@ interface AgentInstance { port: number createdAt: string error?: string + providerType?: string } export const AgentsPage: FC = () => { const client = useRpcClient() + const { providers, defaultProviderId } = useLlmProviders() const [agents, setAgents] = useState([]) const [loading, setLoading] = useState(true) const [dockerAvailable, setDockerAvailable] = useState(null) @@ -44,11 +66,35 @@ export const AgentsPage: FC = () => { const [actionInProgress, setActionInProgress] = useState(null) const [refreshKey, setRefreshKey] = useState(0) const [logsAgentId, setLogsAgentId] = useState(null) + const [chatAgentId, setChatAgentId] = useState(null) + const [selectedProviderId, setSelectedProviderId] = useState('') const triggerRefresh = () => setRefreshKey((k) => k + 1) const logsAgent = logsAgentId ? agents.find((a) => a.id === logsAgentId) : null + const chatAgent = chatAgentId + ? agents.find((a) => a.id === chatAgentId) + : null + + // Filter providers compatible with OpenClaw (have an API key + supported type) + const compatibleProviders = useMemo( + () => + providers.filter( + (p) => p.apiKey && OPENCLAW_COMPATIBLE_TYPES.has(p.type), + ), + [providers], + ) + + // Pre-select default provider when dialog opens + useEffect(() => { + if (createDialogOpen && compatibleProviders.length > 0) { + const defaultMatch = compatibleProviders.find( + (p) => p.id === defaultProviderId, + ) + setSelectedProviderId(defaultMatch?.id ?? compatibleProviders[0].id) + } + }, [createDialogOpen, compatibleProviders, defaultProviderId]) // biome-ignore lint/correctness/useExhaustiveDependencies: refreshKey triggers refetch from outside the effect useEffect(() => { @@ -89,15 +135,26 @@ export const AgentsPage: FC = () => { if (!newAgentName.trim()) return setCreating(true) try { - const res = await client.agents.create.$post({ - json: { name: newAgentName.trim() }, + const selectedProvider = compatibleProviders.find( + (p) => p.id === selectedProviderId, + ) + + const baseUrl = await getAgentServerUrl() + const res = await fetch(`${baseUrl}/agents/create`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: newAgentName.trim(), + providerType: selectedProvider?.type, + apiKey: selectedProvider?.apiKey, + }), }) + if (res.ok) { const data = (await res.json()) as { agent: AgentInstance } setCreateDialogOpen(false) setNewAgentName('') triggerRefresh() - // Auto-open logs for the new agent setLogsAgentId(data.agent.id) } } finally { @@ -145,6 +202,17 @@ export const AgentsPage: FC = () => { return {label} } + // Show chat view when an agent is selected + if (chatAgent) { + return ( + setChatAgentId(null)} + /> + ) + } + if (loading) { return (
@@ -188,11 +256,47 @@ export const AgentsPage: FC = () => { if (e.key === 'Enter') handleCreate() }} /> -

- A Docker container with OpenClaw will be created locally. - Requires ~500MB disk space. -

+ +
+ + {compatibleProviders.length > 0 ? ( + <> + +

+ Uses your existing API key from BrowserOS settings. The + key is passed to the container and never leaves your + machine. +

+ + ) : ( +

+ No compatible LLM providers configured.{' '} + + Add one in AI settings + {' '} + first, or create the agent without one and configure later. +

+ )} +
+ + )}