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
This commit is contained in:
Dani Akash
2026-04-01 17:42:14 +05:30
parent f8880c0b4f
commit 12f03af32e
3 changed files with 449 additions and 11 deletions

View File

@@ -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<AgentChatProps> = ({
agentId,
agentName,
onBack,
}) => {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState('')
const [streaming, setStreaming] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(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<Uint8Array>).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 (
<div className="flex h-[calc(100vh-4rem)] flex-col">
<div className="flex items-center gap-2 border-b pb-4">
<Button variant="ghost" size="icon" className="size-8" onClick={onBack}>
<ArrowLeft className="size-4" />
</Button>
<div>
<h1 className="font-semibold text-lg">{agentName}</h1>
<p className="text-muted-foreground text-xs">
Chat with your OpenClaw agent
</p>
</div>
</div>
<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto py-4">
{messages.length === 0 && (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">
Send a message to start chatting with your agent.
</p>
</div>
)}
{messages.map((msg, i) => (
<div
key={msg.id}
className={cn(
'flex',
msg.role === 'user' ? 'justify-end' : 'justify-start',
)}
>
<div
className={cn(
'max-w-[80%] rounded-lg px-4 py-2 text-sm',
msg.role === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-muted',
)}
>
<pre className="whitespace-pre-wrap font-sans">{msg.content}</pre>
{msg.role === 'assistant' &&
streaming &&
i === messages.length - 1 &&
!msg.content && (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
)}
</div>
</div>
))}
</div>
<div className="flex gap-2 border-t pt-4">
<Input
ref={inputRef}
placeholder="Type a message..."
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendMessage()
}
}}
disabled={streaming}
/>
<Button
size="icon"
onClick={sendMessage}
disabled={!input.trim() || streaming}
>
{streaming ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
</Button>
</div>
</div>
)
}

View File

@@ -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<AgentInstance[]>([])
const [loading, setLoading] = useState(true)
const [dockerAvailable, setDockerAvailable] = useState<boolean | null>(null)
@@ -44,11 +66,35 @@ export const AgentsPage: FC = () => {
const [actionInProgress, setActionInProgress] = useState<string | null>(null)
const [refreshKey, setRefreshKey] = useState(0)
const [logsAgentId, setLogsAgentId] = useState<string | null>(null)
const [chatAgentId, setChatAgentId] = useState<string | null>(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 <Badge variant={variant}>{label}</Badge>
}
// Show chat view when an agent is selected
if (chatAgent) {
return (
<AgentChat
agentId={chatAgent.id}
agentName={chatAgent.name}
onBack={() => setChatAgentId(null)}
/>
)
}
if (loading) {
return (
<div className="flex items-center justify-center py-16">
@@ -188,11 +256,47 @@ export const AgentsPage: FC = () => {
if (e.key === 'Enter') handleCreate()
}}
/>
<p className="text-muted-foreground text-xs">
A Docker container with OpenClaw will be created locally.
Requires ~500MB disk space.
</p>
</div>
<div className="space-y-2">
<label className="font-medium text-sm" htmlFor="agent-provider">
LLM Provider
</label>
{compatibleProviders.length > 0 ? (
<>
<Select
value={selectedProviderId}
onValueChange={setSelectedProviderId}
>
<SelectTrigger id="agent-provider">
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
{compatibleProviders.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name} {p.modelId}
{p.id === defaultProviderId ? ' (default)' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
Uses your existing API key from BrowserOS settings. The
key is passed to the container and never leaves your
machine.
</p>
</>
) : (
<p className="text-muted-foreground text-sm">
No compatible LLM providers configured.{' '}
<a href="#/settings/ai" className="underline">
Add one in AI settings
</a>{' '}
first, or create the agent without one and configure later.
</p>
)}
</div>
<Button
className="w-full"
onClick={handleCreate}
@@ -281,6 +385,17 @@ export const AgentsPage: FC = () => {
</div>
</div>
<div className="flex items-center gap-1">
{agent.status === 'running' && (
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={() => setChatAgentId(agent.id)}
title="Chat"
>
<MessageSquare className="size-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"

View File

@@ -19,6 +19,17 @@ import { logger } from '../../lib/logger'
const OPENCLAW_IMAGE = 'ghcr.io/openclaw/openclaw:latest'
const MAX_LOG_LINES = 1000
// Maps BrowserOS provider types to OpenClaw environment variable names
const OPENCLAW_PROVIDER_ENV_MAP: Record<string, string> = {
anthropic: 'ANTHROPIC_API_KEY',
openai: 'OPENAI_API_KEY',
google: 'GEMINI_API_KEY',
openrouter: 'OPENROUTER_API_KEY',
moonshot: 'MOONSHOT_API_KEY',
groq: 'GROQ_API_KEY',
mistral: 'MISTRAL_API_KEY',
}
// Persisted to agents.json
interface AgentRecord {
id: string
@@ -29,6 +40,7 @@ interface AgentRecord {
token: string
createdAt: string
error?: string
providerType?: string
}
// Runtime-only (not persisted)
@@ -203,16 +215,25 @@ function generateComposeFile(config: {
token: string
configDir: string
workspaceDir: string
llmProvider?: { envVar: string; apiKey: string }
}): string {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
const envLines = [
` - OPENCLAW_GATEWAY_TOKEN=${config.token}`,
` - TZ=${tz}`,
]
if (config.llmProvider) {
envLines.push(
` - ${config.llmProvider.envVar}=${config.llmProvider.apiKey}`,
)
}
return `services:
openclaw-gateway:
image: ${config.image}
ports:
- "127.0.0.1:${config.gatewayPort}:18789"
environment:
- OPENCLAW_GATEWAY_TOKEN=${config.token}
- TZ=${tz}
${envLines.join('\n')}
volumes:
- ${config.configDir}:/home/node/.openclaw
- ${config.workspaceDir}:/home/node/.openclaw/workspace
@@ -329,7 +350,11 @@ export function createAgentsRoutes() {
})
.post('/create', async (c) => {
const body = await c.req.json<{ name: string }>()
const body = await c.req.json<{
name: string
providerType?: string
apiKey?: string
}>()
const name = body.name?.trim()
if (!name) {
@@ -369,6 +394,15 @@ export function createAgentsRoutes() {
const agentDir = path.join(getAgentsBaseDir(), name)
const token = crypto.randomUUID()
// Map BrowserOS provider type to OpenClaw env var
const llmEnvVar = body.providerType
? OPENCLAW_PROVIDER_ENV_MAP[body.providerType]
: undefined
const llmProvider =
llmEnvVar && body.apiKey
? { envVar: llmEnvVar, apiKey: body.apiKey }
: undefined
const instance: AgentInstance = {
id,
name,
@@ -377,6 +411,7 @@ export function createAgentsRoutes() {
dir: agentDir,
token,
createdAt: new Date().toISOString(),
providerType: body.providerType,
logs: [],
logListeners: new Set(),
}
@@ -405,6 +440,7 @@ export function createAgentsRoutes() {
token,
configDir,
workspaceDir,
llmProvider,
})
fs.writeFileSync(
path.join(agentDir, 'docker-compose.yml'),
@@ -449,6 +485,18 @@ export function createAgentsRoutes() {
if (originsExit !== 0) {
throw new Error('Failed to configure Control UI allowed origins')
}
// Enable OpenAI-compatible HTTP API for chat
const httpApiExit = await runConfigSet(
instance,
agentDir,
name,
'gateway.http.endpoints.chatCompletions.enabled',
'true',
)
if (httpApiExit !== 0) {
throw new Error('Failed to enable chat completions API')
}
pushLog(instance, 'Gateway configured for local mode')
pushLog(instance, 'Starting OpenClaw gateway...')
@@ -579,6 +627,65 @@ export function createAgentsRoutes() {
}
})
.post('/:id/chat', async (c) => {
const { id } = c.req.param()
const instance = instances.get(id)
if (!instance) {
return c.json({ error: 'Agent not found' }, 404)
}
if (instance.status !== 'running') {
return c.json({ error: 'Agent is not running' }, 400)
}
const body = await c.req.json<{ message: string }>()
if (!body.message?.trim()) {
return c.json({ error: 'Message is required' }, 400)
}
const openclawUrl = `http://127.0.0.1:${instance.port}/v1/chat/completions`
try {
const response = await fetch(openclawUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${instance.token}`,
},
body: JSON.stringify({
model: 'openclaw/default',
stream: true,
messages: [{ role: 'user', content: body.message.trim() }],
}),
})
if (!response.ok) {
const errText = await response.text()
return c.json(
{ error: `OpenClaw error: ${errText}` },
response.status as 400,
)
}
c.header('Content-Type', 'text/event-stream')
c.header('Cache-Control', 'no-cache')
return stream(c, async (s) => {
const reader = (
response.body as ReadableStream<Uint8Array>
).getReader()
while (true) {
const { done, value } = await reader.read()
if (done) break
await s.write(value)
}
})
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: `Failed to chat: ${message}` }, 500)
}
})
.delete('/:id', async (c) => {
const { id } = c.req.param()
const instance = instances.get(id)