mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
Compare commits
21 Commits
fix/cache-
...
feat/podma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c647310510 | ||
|
|
fe704e6c5b | ||
|
|
7ddd5e4f58 | ||
|
|
7ca6961922 | ||
|
|
d99d0e0ee6 | ||
|
|
4339d6955a | ||
|
|
1d12a0d579 | ||
|
|
07764f93f0 | ||
|
|
7386234542 | ||
|
|
a4e5ad947d | ||
|
|
ef0a665da4 | ||
|
|
dfb3e8ca74 | ||
|
|
12f03af32e | ||
|
|
f8880c0b4f | ||
|
|
afa1e6b868 | ||
|
|
88b591849c | ||
|
|
d66e789e7e | ||
|
|
b8dc70a26b | ||
|
|
18fc173dfe | ||
|
|
a535cb7d86 | ||
|
|
f962512828 |
@@ -3,6 +3,7 @@ import {
|
||||
BookOpen,
|
||||
Bot,
|
||||
Compass,
|
||||
Cpu,
|
||||
CreditCard,
|
||||
GitBranch,
|
||||
MessageSquare,
|
||||
@@ -80,6 +81,7 @@ const primarySettingsSections: NavSection[] = [
|
||||
feature: Feature.CUSTOMIZATION_SUPPORT,
|
||||
},
|
||||
{ name: 'BrowserOS as MCP', to: '/settings/mcp', icon: Server },
|
||||
{ name: 'Agents', to: '/settings/agents', icon: Cpu },
|
||||
{
|
||||
name: 'Usage & Billing',
|
||||
to: '/settings/usage',
|
||||
|
||||
@@ -9,6 +9,7 @@ import { OnboardingDemo } from '../onboarding/demo/OnboardingDemo'
|
||||
import { FeaturesPage } from '../onboarding/features/Features'
|
||||
import { Onboarding } from '../onboarding/index/Onboarding'
|
||||
import { StepsLayout } from '../onboarding/steps/StepsLayout'
|
||||
import { AgentsPage } from './agents/AgentsPage'
|
||||
import { AISettingsPage } from './ai-settings/AISettingsPage'
|
||||
import { ConnectMCP } from './connect-mcp/ConnectMCP'
|
||||
import { CreateGraphWrapper } from './create-graph/CreateGraphWrapper'
|
||||
@@ -104,6 +105,7 @@ export const App: FC = () => {
|
||||
<Route path="customization" element={<CustomizationPage />} />
|
||||
<Route path="search" element={<SearchProviderPage />} />
|
||||
<Route path="survey" element={<SurveyPage {...surveyParams} />} />
|
||||
<Route path="agents" element={<AgentsPage />} />
|
||||
<Route path="usage" element={<UsagePage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { type FC, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { getAgentServerUrl } from '@/lib/browseros/helpers'
|
||||
|
||||
interface AgentLogsDialogProps {
|
||||
agentId: string
|
||||
agentName: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export const AgentLogsDialog: FC<AgentLogsDialogProps> = ({
|
||||
agentId,
|
||||
agentName,
|
||||
open,
|
||||
onOpenChange,
|
||||
}) => {
|
||||
const [logs, setLogs] = useState<string[]>([])
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const isAtBottom = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
let eventSource: EventSource | null = null
|
||||
|
||||
const connect = async () => {
|
||||
try {
|
||||
const baseUrl = await getAgentServerUrl()
|
||||
eventSource = new EventSource(`${baseUrl}/agents/${agentId}/logs`)
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
const line = JSON.parse(event.data) as string
|
||||
setLogs((prev) => [...prev, line])
|
||||
}
|
||||
} catch {
|
||||
// Connection failed
|
||||
}
|
||||
}
|
||||
|
||||
setLogs([])
|
||||
connect()
|
||||
|
||||
return () => {
|
||||
eventSource?.close()
|
||||
}
|
||||
}, [open, agentId])
|
||||
|
||||
// Auto-scroll to bottom when new logs arrive
|
||||
const logsLength = logs.length
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: logsLength triggers scroll on new log lines
|
||||
useEffect(() => {
|
||||
if (isAtBottom.current && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [logsLength])
|
||||
|
||||
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget
|
||||
isAtBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Logs: {agentName}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="h-[400px] overflow-y-auto rounded-md border bg-black p-4"
|
||||
>
|
||||
<pre className="whitespace-pre-wrap font-mono text-green-400 text-xs">
|
||||
{logs.length === 0 ? 'Waiting for logs...' : logs.join('\n')}
|
||||
</pre>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
Cpu,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Play,
|
||||
Plus,
|
||||
ScrollText,
|
||||
Square,
|
||||
Trash2,
|
||||
} from 'lucide-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'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
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'
|
||||
|
||||
// Types that use OAuth and have no raw API key to forward
|
||||
const OAUTH_ONLY_TYPES = new Set(['chatgpt-pro', 'github-copilot', 'qwen-code'])
|
||||
|
||||
interface AgentInstance {
|
||||
id: string
|
||||
name: string
|
||||
status: 'creating' | 'running' | 'stopped' | 'error'
|
||||
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 [runtimeAvailable, setRuntimeAvailable] = useState<boolean | null>(null)
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||
const [newAgentName, setNewAgentName] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
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 that have an API key and can be forwarded to OpenClaw
|
||||
const compatibleProviders = useMemo(
|
||||
() => providers.filter((p) => p.apiKey && !OAUTH_ONLY_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(() => {
|
||||
let cancelled = false
|
||||
|
||||
const fetchAgents = async () => {
|
||||
try {
|
||||
const res = await client.agents.$get()
|
||||
const data = (await res.json()) as { agents: AgentInstance[] }
|
||||
if (!cancelled) setAgents(data.agents)
|
||||
} catch {
|
||||
// Server may not have the route yet
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const checkRuntime = async () => {
|
||||
try {
|
||||
const serverUrl = await getAgentServerUrl()
|
||||
const res = await fetch(`${serverUrl}/agents/runtime-status`)
|
||||
const data = await res.json()
|
||||
if (!cancelled) setRuntimeAvailable(data.available)
|
||||
} catch {
|
||||
if (!cancelled) setRuntimeAvailable(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchAgents()
|
||||
checkRuntime()
|
||||
const interval = setInterval(fetchAgents, 3000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [client, refreshKey])
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newAgentName.trim()) return
|
||||
setCreating(true)
|
||||
try {
|
||||
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,
|
||||
baseUrl: selectedProvider?.baseUrl,
|
||||
modelId: selectedProvider?.modelId,
|
||||
providerName: selectedProvider?.name,
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { agent: AgentInstance }
|
||||
setCreateDialogOpen(false)
|
||||
setNewAgentName('')
|
||||
triggerRefresh()
|
||||
setLogsAgentId(data.agent.id)
|
||||
}
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const agentAction = async (
|
||||
id: string,
|
||||
action: 'stop' | 'start' | 'delete',
|
||||
) => {
|
||||
setActionInProgress(id)
|
||||
try {
|
||||
const baseUrl = await getAgentServerUrl()
|
||||
const method = action === 'delete' ? 'DELETE' : 'POST'
|
||||
const actionPath =
|
||||
action === 'delete'
|
||||
? `${baseUrl}/agents/${id}`
|
||||
: `${baseUrl}/agents/${id}/${action}`
|
||||
await fetch(actionPath, { method })
|
||||
triggerRefresh()
|
||||
} finally {
|
||||
setActionInProgress(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStop = (id: string) => agentAction(id, 'stop')
|
||||
const handleStart = (id: string) => agentAction(id, 'start')
|
||||
const handleDelete = (id: string) => agentAction(id, 'delete')
|
||||
|
||||
const getStatusBadge = (status: AgentInstance['status']) => {
|
||||
const variants: Record<
|
||||
string,
|
||||
{
|
||||
variant: 'default' | 'secondary' | 'destructive' | 'outline'
|
||||
label: string
|
||||
}
|
||||
> = {
|
||||
creating: { variant: 'secondary', label: 'Creating...' },
|
||||
running: { variant: 'default', label: 'Running' },
|
||||
stopped: { variant: 'outline', label: 'Stopped' },
|
||||
error: { variant: 'destructive', label: 'Error' },
|
||||
}
|
||||
const { variant, label } = variants[status] ?? variants.stopped
|
||||
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">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-semibold text-2xl tracking-tight">Agents</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Create and manage OpenClaw agent instances.
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button disabled={runtimeAvailable === false}>
|
||||
<Plus className="mr-2 size-4" />
|
||||
New Agent
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Agent</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium text-sm" htmlFor="agent-name">
|
||||
Agent Name
|
||||
</label>
|
||||
<Input
|
||||
id="agent-name"
|
||||
placeholder="e.g. work, personal, research"
|
||||
value={newAgentName}
|
||||
onChange={(e) => setNewAgentName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleCreate()
|
||||
}}
|
||||
/>
|
||||
</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}
|
||||
disabled={!newAgentName.trim() || creating}
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
'Create Agent'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{runtimeAvailable === false && (
|
||||
<Card className="border-destructive/50 bg-destructive/5 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="mt-0.5 size-5 shrink-0 text-destructive" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
Container runtime not available
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Podman is required to run OpenClaw agents. It will be bundled
|
||||
with BrowserOS in a future update.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{agents.length === 0 ? (
|
||||
<Card className="flex flex-col items-center justify-center p-12 text-center">
|
||||
<Cpu className="mb-4 size-12 text-muted-foreground/50" />
|
||||
<h3 className="font-medium text-lg">No agents yet</h3>
|
||||
<p className="mt-1 max-w-sm text-muted-foreground text-sm">
|
||||
Create your first OpenClaw agent to get started. Each agent runs in
|
||||
an isolated container with its own workspace.
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{agents.map((agent) => (
|
||||
<Card key={agent.id} className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Cpu className="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{agent.name}</span>
|
||||
{getStatusBadge(agent.status)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Port {agent.port}
|
||||
{agent.status === 'running' && (
|
||||
<> · Gateway at ws://127.0.0.1:{agent.port}</>
|
||||
)}
|
||||
</p>
|
||||
{agent.error && (
|
||||
<p className="mt-1 text-destructive text-xs">
|
||||
{agent.error}
|
||||
</p>
|
||||
)}
|
||||
</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"
|
||||
className="size-8"
|
||||
onClick={() => setLogsAgentId(agent.id)}
|
||||
title="View logs"
|
||||
>
|
||||
<ScrollText className="size-4" />
|
||||
</Button>
|
||||
{agent.status === 'running' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={() => handleStop(agent.id)}
|
||||
disabled={actionInProgress === agent.id}
|
||||
title="Stop"
|
||||
>
|
||||
<Square className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
{agent.status === 'stopped' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={() => handleStart(agent.id)}
|
||||
disabled={actionInProgress === agent.id}
|
||||
title="Start"
|
||||
>
|
||||
<Play className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
{(agent.status === 'stopped' || agent.status === 'error') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(agent.id)}
|
||||
disabled={actionInProgress === agent.id}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
{actionInProgress === agent.id && (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{logsAgent && (
|
||||
<AgentLogsDialog
|
||||
agentId={logsAgent.id}
|
||||
agentName={logsAgent.name}
|
||||
open={!!logsAgentId}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) setLogsAgentId(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
798
packages/browseros-agent/apps/server/src/api/routes/agents.ts
Normal file
798
packages/browseros-agent/apps/server/src/api/routes/agents.ts
Normal file
@@ -0,0 +1,798 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Agent management routes for OpenClaw container instances.
|
||||
* Generates docker-compose.yml and uses Podman compose for lifecycle.
|
||||
* Manages Podman machine (Linux VM) automatically on macOS/Windows.
|
||||
* Persists agent metadata to ~/.browseros/agents.json.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { Hono } from 'hono'
|
||||
import { stream } from 'hono/streaming'
|
||||
import { getBrowserosDir } from '../../lib/browseros-dir'
|
||||
import { logger } from '../../lib/logger'
|
||||
import {
|
||||
getPodmanRuntime,
|
||||
type PodmanRuntime,
|
||||
} from '../services/podman-runtime'
|
||||
|
||||
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
|
||||
name: string
|
||||
status: 'creating' | 'running' | 'stopped' | 'error'
|
||||
port: number
|
||||
dir: string
|
||||
token: string
|
||||
createdAt: string
|
||||
error?: string
|
||||
providerType?: string
|
||||
}
|
||||
|
||||
// Runtime-only (not persisted)
|
||||
interface AgentRuntime {
|
||||
logs: string[]
|
||||
logListeners: Set<(line: string) => void>
|
||||
}
|
||||
|
||||
type AgentInstance = AgentRecord & AgentRuntime
|
||||
|
||||
// ─── Persistence ────────────────────────────────────────────────────────────
|
||||
|
||||
function getAgentsJsonPath(): string {
|
||||
return path.join(getBrowserosDir(), 'agents.json')
|
||||
}
|
||||
|
||||
function getAgentsBaseDir(): string {
|
||||
return path.join(getBrowserosDir(), 'agents')
|
||||
}
|
||||
|
||||
const instances = new Map<string, AgentInstance>()
|
||||
|
||||
function saveAgents(): void {
|
||||
const records: AgentRecord[] = Array.from(instances.values()).map(
|
||||
({ logs: _, logListeners: __, ...record }) => record,
|
||||
)
|
||||
try {
|
||||
const filePath = getAgentsJsonPath()
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
||||
fs.writeFileSync(filePath, JSON.stringify(records, null, 2))
|
||||
} catch (err) {
|
||||
logger.warn('Failed to save agents.json', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function loadAgents(): void {
|
||||
try {
|
||||
const filePath = getAgentsJsonPath()
|
||||
if (!fs.existsSync(filePath)) return
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as AgentRecord[]
|
||||
for (const record of data) {
|
||||
instances.set(record.id, {
|
||||
...record,
|
||||
logs: [],
|
||||
logListeners: new Set(),
|
||||
})
|
||||
}
|
||||
logger.info(`Loaded ${data.length} agent(s) from agents.json`)
|
||||
} catch (err) {
|
||||
logger.warn('Failed to load agents.json', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Load persisted agents on module init
|
||||
loadAgents()
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function pushLog(instance: AgentInstance, line: string) {
|
||||
const timestamped = `[${new Date().toISOString().slice(11, 19)}] ${line}`
|
||||
instance.logs.push(timestamped)
|
||||
if (instance.logs.length > MAX_LOG_LINES) {
|
||||
instance.logs.splice(0, instance.logs.length - MAX_LOG_LINES)
|
||||
}
|
||||
for (const listener of instance.logListeners) {
|
||||
listener(timestamped)
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatus(
|
||||
instance: AgentInstance,
|
||||
status: AgentRecord['status'],
|
||||
error?: string,
|
||||
): void {
|
||||
instance.status = status
|
||||
instance.error = error
|
||||
saveAgents()
|
||||
}
|
||||
|
||||
async function isRuntimeAvailable(): Promise<boolean> {
|
||||
return getPodmanRuntime().isPodmanAvailable()
|
||||
}
|
||||
|
||||
async function findAvailablePort(startPort: number): Promise<number> {
|
||||
const net = await import('node:net')
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer()
|
||||
server.listen(startPort, '127.0.0.1', () => {
|
||||
server.close(() => resolve(startPort))
|
||||
})
|
||||
server.on('error', () => {
|
||||
resolve(findAvailablePort(startPort + 1))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function runCommandWithLogs(
|
||||
instance: AgentInstance,
|
||||
args: string[],
|
||||
options?: { cwd?: string; env?: Record<string, string> },
|
||||
): Promise<number> {
|
||||
const seen = new Set<string>()
|
||||
return getPodmanRuntime().runCommand(args, {
|
||||
cwd: options?.cwd,
|
||||
env: options?.env,
|
||||
onOutput: (line) => {
|
||||
if (!seen.has(line)) {
|
||||
seen.add(line)
|
||||
pushLog(instance, line)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function composeEnv(name: string): Record<string, string> {
|
||||
return { COMPOSE_PROJECT_NAME: `browseros-claw-${name}` }
|
||||
}
|
||||
|
||||
function generateComposeFile(config: {
|
||||
image: string
|
||||
gatewayPort: number
|
||||
token: string
|
||||
configDir: string
|
||||
workspaceDir: string
|
||||
}): string {
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
return `services:
|
||||
openclaw-gateway:
|
||||
image: ${config.image}
|
||||
ports:
|
||||
- "127.0.0.1:${config.gatewayPort}:18789"
|
||||
environment:
|
||||
- OPENCLAW_GATEWAY_TOKEN=${config.token}
|
||||
- TZ=${tz}
|
||||
volumes:
|
||||
- ${config.configDir}:/home/node/.openclaw
|
||||
- ${config.workspaceDir}:/home/node/.openclaw/workspace
|
||||
command: node dist/index.js gateway --bind lan --port 18789
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://127.0.0.1:18789/healthz"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
`
|
||||
}
|
||||
|
||||
function generateOpenClawConfig(config: {
|
||||
port: number
|
||||
providerType?: string
|
||||
apiKey?: string
|
||||
baseUrl?: string
|
||||
modelId?: string
|
||||
providerName?: string
|
||||
}): Record<string, unknown> {
|
||||
const openclawConfig: Record<string, unknown> = {
|
||||
gateway: {
|
||||
mode: 'local',
|
||||
controlUi: {
|
||||
allowedOrigins: [
|
||||
`http://127.0.0.1:${config.port}`,
|
||||
`http://localhost:${config.port}`,
|
||||
],
|
||||
},
|
||||
http: {
|
||||
endpoints: {
|
||||
chatCompletions: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if (!config.apiKey || !config.providerType) {
|
||||
return openclawConfig
|
||||
}
|
||||
|
||||
const directEnvVar = OPENCLAW_PROVIDER_ENV_MAP[config.providerType]
|
||||
|
||||
if (directEnvVar) {
|
||||
// Built-in provider (Anthropic, OpenAI, Google, etc.)
|
||||
openclawConfig.env = { [directEnvVar]: config.apiKey }
|
||||
if (config.modelId) {
|
||||
openclawConfig.agents = {
|
||||
defaults: {
|
||||
model: { primary: `${config.providerType}/${config.modelId}` },
|
||||
},
|
||||
}
|
||||
}
|
||||
} else if (config.baseUrl) {
|
||||
// Custom OpenAI-compatible provider
|
||||
const providerId = (config.providerName || 'custom-provider')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
const envVarName = `${providerId.toUpperCase().replace(/-/g, '_')}_API_KEY`
|
||||
|
||||
openclawConfig.env = { [envVarName]: config.apiKey }
|
||||
openclawConfig.models = {
|
||||
mode: 'merge',
|
||||
providers: {
|
||||
[providerId]: {
|
||||
baseUrl: config.baseUrl,
|
||||
apiKey: `\${${envVarName}}`,
|
||||
api: 'openai-completions',
|
||||
...(config.modelId
|
||||
? { models: [{ id: config.modelId, name: config.modelId }] }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
}
|
||||
if (config.modelId) {
|
||||
openclawConfig.agents = {
|
||||
defaults: {
|
||||
model: { primary: `${providerId}/${config.modelId}` },
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return openclawConfig
|
||||
}
|
||||
|
||||
async function dumpContainerLogs(
|
||||
instance: AgentInstance,
|
||||
agentDir: string,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (fs.existsSync(path.join(agentDir, 'docker-compose.yml'))) {
|
||||
pushLog(instance, '--- Container logs ---')
|
||||
await runCommandWithLogs(
|
||||
instance,
|
||||
['compose', 'logs', '--no-color', '--tail', '50'],
|
||||
{ cwd: agentDir, env: composeEnv(name) },
|
||||
)
|
||||
pushLog(instance, '--- End container logs ---')
|
||||
}
|
||||
} catch {
|
||||
// Best effort
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Lifecycle ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Call on server startup. If agents exist from a previous session,
|
||||
* pre-start the Podman machine in the background so it's ready
|
||||
* when the user interacts with agents.
|
||||
*/
|
||||
export function initAgentRuntime(): void {
|
||||
if (instances.size === 0) return
|
||||
|
||||
const hasActiveAgents = Array.from(instances.values()).some(
|
||||
(i) => i.status === 'running' || i.status === 'creating',
|
||||
)
|
||||
if (!hasActiveAgents) return
|
||||
|
||||
logger.info('Agents exist from previous session, pre-starting Podman machine')
|
||||
getPodmanRuntime()
|
||||
.ensureReady()
|
||||
.then(() => logger.info('Podman machine ready'))
|
||||
.catch((err) =>
|
||||
logger.warn('Failed to pre-start Podman machine', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Call on server shutdown. Stops all BrowserOS agent containers.
|
||||
* Only stops the Podman machine if no other containers are running
|
||||
* (to avoid killing the user's own Podman workloads).
|
||||
*/
|
||||
export async function shutdownAgentRuntime(): Promise<void> {
|
||||
const runtime = getPodmanRuntime()
|
||||
const available = await runtime.isPodmanAvailable()
|
||||
if (!available) return
|
||||
|
||||
const runningAgents = Array.from(instances.values()).filter(
|
||||
(i) => i.status === 'running',
|
||||
)
|
||||
|
||||
for (const instance of runningAgents) {
|
||||
try {
|
||||
logger.info(`Stopping agent container: ${instance.name}`)
|
||||
await runtime.runCommand(['compose', 'stop'], {
|
||||
cwd: instance.dir,
|
||||
env: composeEnv(instance.name),
|
||||
})
|
||||
instance.status = 'stopped'
|
||||
} catch {
|
||||
// Best effort — shutting down
|
||||
}
|
||||
}
|
||||
saveAgents()
|
||||
|
||||
await stopMachineIfOnlyOurs(runtime)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the Podman machine only if no non-BrowserOS containers are running.
|
||||
* This prevents killing the user's own Podman workloads.
|
||||
*/
|
||||
async function stopMachineIfOnlyOurs(runtime: PodmanRuntime): Promise<void> {
|
||||
const status = await runtime.getMachineStatus()
|
||||
if (!status.running) return
|
||||
|
||||
try {
|
||||
const proc = Bun.spawn(
|
||||
[runtime.getPodmanPath(), 'ps', '--format', '{{.Names}}'],
|
||||
{ stdout: 'pipe', stderr: 'ignore' },
|
||||
)
|
||||
const output = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
|
||||
const runningContainers = output
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((name) => name.trim())
|
||||
|
||||
const allOurs = runningContainers.every((name) =>
|
||||
name.startsWith('browseros-claw-'),
|
||||
)
|
||||
|
||||
if (runningContainers.length === 0 || allOurs) {
|
||||
logger.info('No other containers running, stopping Podman machine')
|
||||
await runtime.stopMachine()
|
||||
} else {
|
||||
logger.info('Other containers running, keeping Podman machine alive', {
|
||||
count: runningContainers.length,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Best effort — don't stop machine if we can't check
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Routes ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function createAgentsRoutes() {
|
||||
return new Hono()
|
||||
.get('/', (c) => {
|
||||
const agentList = Array.from(instances.values()).map(
|
||||
({ logListeners: _, logs: __, ...rest }) => rest,
|
||||
)
|
||||
return c.json({ agents: agentList })
|
||||
})
|
||||
|
||||
.get('/runtime-status', async (c) => {
|
||||
const runtime = getPodmanRuntime()
|
||||
const available = await runtime.isPodmanAvailable()
|
||||
const machineStatus = available ? await runtime.getMachineStatus() : null
|
||||
return c.json({
|
||||
available,
|
||||
machineInitialized: machineStatus?.initialized ?? false,
|
||||
machineRunning: machineStatus?.running ?? false,
|
||||
needsSetup: available && !machineStatus?.initialized,
|
||||
})
|
||||
})
|
||||
|
||||
.get('/:id/logs', (c) => {
|
||||
const { id } = c.req.param()
|
||||
const instance = instances.get(id)
|
||||
|
||||
if (!instance) {
|
||||
return c.json({ error: 'Agent not found' }, 404)
|
||||
}
|
||||
|
||||
c.header('Content-Type', 'text/event-stream')
|
||||
c.header('Cache-Control', 'no-cache')
|
||||
c.header('Connection', 'keep-alive')
|
||||
|
||||
return stream(c, async (s) => {
|
||||
const write = async (line: string) => {
|
||||
await s.write(`data: ${JSON.stringify(line)}\n\n`)
|
||||
}
|
||||
|
||||
for (const line of instance.logs) {
|
||||
await write(line)
|
||||
}
|
||||
|
||||
const onLog = (line: string) => {
|
||||
write(line).catch(() => {
|
||||
instance.logListeners.delete(onLog)
|
||||
})
|
||||
}
|
||||
instance.logListeners.add(onLog)
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
s.onAbort(() => {
|
||||
instance.logListeners.delete(onLog)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
.post('/create', async (c) => {
|
||||
const body = await c.req.json<{
|
||||
name: string
|
||||
providerType?: string
|
||||
apiKey?: string
|
||||
baseUrl?: string
|
||||
modelId?: string
|
||||
providerName?: string
|
||||
}>()
|
||||
const name = body.name?.trim()
|
||||
|
||||
if (!name) {
|
||||
return c.json({ error: 'Name is required' }, 400)
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name)) {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
'Name must start with a letter or number and contain only letters, numbers, dots, hyphens, and underscores',
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
|
||||
const existing = Array.from(instances.values()).find(
|
||||
(i) => i.name === name,
|
||||
)
|
||||
if (existing) {
|
||||
return c.json({ error: `Agent "${name}" already exists` }, 409)
|
||||
}
|
||||
|
||||
const runtimeAvailable = await isRuntimeAvailable()
|
||||
if (!runtimeAvailable) {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
'Podman is not available. Install Podman to create local agents.',
|
||||
},
|
||||
503,
|
||||
)
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID()
|
||||
const port = await findAvailablePort(18789)
|
||||
const agentDir = path.join(getAgentsBaseDir(), name)
|
||||
const token = crypto.randomUUID()
|
||||
|
||||
const instance: AgentInstance = {
|
||||
id,
|
||||
name,
|
||||
status: 'creating',
|
||||
port,
|
||||
dir: agentDir,
|
||||
token,
|
||||
createdAt: new Date().toISOString(),
|
||||
providerType: body.providerType,
|
||||
logs: [],
|
||||
logListeners: new Set(),
|
||||
}
|
||||
instances.set(id, instance)
|
||||
saveAgents()
|
||||
|
||||
logger.info('Creating OpenClaw agent instance', {
|
||||
id,
|
||||
name,
|
||||
port,
|
||||
dir: agentDir,
|
||||
})
|
||||
|
||||
// Set up and start in the background
|
||||
;(async () => {
|
||||
try {
|
||||
const configDir = path.join(agentDir, 'config')
|
||||
const workspaceDir = path.join(agentDir, 'workspace')
|
||||
fs.mkdirSync(configDir, { recursive: true })
|
||||
fs.mkdirSync(workspaceDir, { recursive: true })
|
||||
pushLog(instance, 'Created agent directories')
|
||||
|
||||
// Generate docker-compose.yml
|
||||
const composeContent = generateComposeFile({
|
||||
image: OPENCLAW_IMAGE,
|
||||
gatewayPort: port,
|
||||
token,
|
||||
configDir,
|
||||
workspaceDir,
|
||||
})
|
||||
fs.writeFileSync(
|
||||
path.join(agentDir, 'docker-compose.yml'),
|
||||
composeContent,
|
||||
)
|
||||
pushLog(instance, 'Generated docker-compose.yml')
|
||||
|
||||
// Write openclaw.json config (gateway mode, allowed origins, LLM provider, model)
|
||||
const openclawConfig = generateOpenClawConfig({
|
||||
port,
|
||||
providerType: body.providerType,
|
||||
apiKey: body.apiKey,
|
||||
baseUrl: body.baseUrl,
|
||||
modelId: body.modelId,
|
||||
providerName: body.providerName,
|
||||
})
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, 'openclaw.json'),
|
||||
JSON.stringify(openclawConfig, null, 2),
|
||||
)
|
||||
pushLog(instance, 'Wrote openclaw.json configuration')
|
||||
|
||||
pushLog(instance, 'Checking container runtime...')
|
||||
await getPodmanRuntime().ensureReady((msg) => pushLog(instance, msg))
|
||||
pushLog(instance, 'Container runtime ready')
|
||||
|
||||
pushLog(instance, `Pulling image ${OPENCLAW_IMAGE}...`)
|
||||
const pullExit = await runCommandWithLogs(
|
||||
instance,
|
||||
['compose', 'pull', '--quiet'],
|
||||
{ cwd: agentDir, env: composeEnv(name) },
|
||||
)
|
||||
if (pullExit !== 0) {
|
||||
throw new Error('Failed to pull OpenClaw image')
|
||||
}
|
||||
pushLog(instance, 'Image pulled successfully')
|
||||
|
||||
pushLog(instance, 'Starting OpenClaw gateway...')
|
||||
const upExit = await runCommandWithLogs(
|
||||
instance,
|
||||
['compose', 'up', '-d'],
|
||||
{ cwd: agentDir, env: composeEnv(name) },
|
||||
)
|
||||
if (upExit !== 0) {
|
||||
throw new Error('Failed to start OpenClaw containers')
|
||||
}
|
||||
|
||||
pushLog(instance, 'Waiting for gateway to be ready...')
|
||||
let healthy = false
|
||||
for (let i = 0; i < 30; i++) {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/healthz`)
|
||||
if (res.ok) {
|
||||
healthy = true
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
// Not ready yet
|
||||
}
|
||||
await Bun.sleep(1000)
|
||||
}
|
||||
|
||||
if (!healthy) {
|
||||
await dumpContainerLogs(instance, agentDir, name)
|
||||
throw new Error('Gateway did not become healthy within 30 seconds')
|
||||
}
|
||||
|
||||
pushLog(
|
||||
instance,
|
||||
`OpenClaw gateway is ready at ws://127.0.0.1:${port}`,
|
||||
)
|
||||
pushLog(instance, `Control UI available at http://127.0.0.1:${port}`)
|
||||
updateStatus(instance, 'running')
|
||||
logger.info('OpenClaw agent instance started', { id, name, port })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
pushLog(instance, `ERROR: ${message}`)
|
||||
await dumpContainerLogs(instance, agentDir, name)
|
||||
updateStatus(instance, 'error', message)
|
||||
logger.error('Failed to create OpenClaw agent instance', {
|
||||
id,
|
||||
error: message,
|
||||
})
|
||||
}
|
||||
})()
|
||||
|
||||
return c.json(
|
||||
{
|
||||
agent: {
|
||||
id,
|
||||
name,
|
||||
status: 'creating',
|
||||
port,
|
||||
dir: agentDir,
|
||||
token,
|
||||
createdAt: instance.createdAt,
|
||||
},
|
||||
},
|
||||
201,
|
||||
)
|
||||
})
|
||||
|
||||
.post('/:id/stop', async (c) => {
|
||||
const { id } = c.req.param()
|
||||
const instance = instances.get(id)
|
||||
|
||||
if (!instance) {
|
||||
return c.json({ error: 'Agent not found' }, 404)
|
||||
}
|
||||
|
||||
try {
|
||||
await getPodmanRuntime().ensureReady()
|
||||
|
||||
pushLog(instance, 'Stopping agent...')
|
||||
await runCommandWithLogs(instance, ['compose', 'stop'], {
|
||||
cwd: instance.dir,
|
||||
env: composeEnv(instance.name),
|
||||
})
|
||||
updateStatus(instance, 'stopped')
|
||||
pushLog(instance, 'Agent stopped')
|
||||
return c.json({
|
||||
agent: {
|
||||
id,
|
||||
name: instance.name,
|
||||
status: instance.status,
|
||||
port: instance.port,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
pushLog(instance, `ERROR stopping: ${message}`)
|
||||
return c.json({ error: `Failed to stop agent: ${message}` }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.post('/:id/start', async (c) => {
|
||||
const { id } = c.req.param()
|
||||
const instance = instances.get(id)
|
||||
|
||||
if (!instance) {
|
||||
return c.json({ error: 'Agent not found' }, 404)
|
||||
}
|
||||
|
||||
try {
|
||||
pushLog(instance, 'Ensuring container runtime is ready...')
|
||||
await getPodmanRuntime().ensureReady((msg) => pushLog(instance, msg))
|
||||
|
||||
pushLog(instance, 'Starting agent...')
|
||||
await runCommandWithLogs(instance, ['compose', 'up', '-d'], {
|
||||
cwd: instance.dir,
|
||||
env: composeEnv(instance.name),
|
||||
})
|
||||
updateStatus(instance, 'running')
|
||||
pushLog(instance, 'Agent started')
|
||||
return c.json({
|
||||
agent: {
|
||||
id,
|
||||
name: instance.name,
|
||||
status: instance.status,
|
||||
port: instance.port,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
pushLog(instance, `ERROR starting: ${message}`)
|
||||
return c.json({ error: `Failed to start agent: ${message}` }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.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)
|
||||
|
||||
if (!instance) {
|
||||
return c.json({ error: 'Agent not found' }, 404)
|
||||
}
|
||||
|
||||
try {
|
||||
await getPodmanRuntime().ensureReady()
|
||||
|
||||
for (const listener of instance.logListeners) {
|
||||
instance.logListeners.delete(listener)
|
||||
}
|
||||
|
||||
await runCommandWithLogs(instance, ['compose', 'down', '-v'], {
|
||||
cwd: instance.dir,
|
||||
env: composeEnv(instance.name),
|
||||
})
|
||||
fs.rmSync(instance.dir, { recursive: true, force: true })
|
||||
instances.delete(id)
|
||||
saveAgents()
|
||||
|
||||
// Stop machine if no agents remain and no other containers running
|
||||
if (instances.size === 0) {
|
||||
stopMachineIfOnlyOurs(getPodmanRuntime()).catch(() => {})
|
||||
}
|
||||
|
||||
return c.json({ success: true })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: `Failed to delete agent: ${message}` }, 500)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -20,6 +20,11 @@ import { initializeOAuth } from '../lib/clients/oauth'
|
||||
import { getDb } from '../lib/db'
|
||||
import { logger } from '../lib/logger'
|
||||
import { Sentry } from '../lib/sentry'
|
||||
import {
|
||||
createAgentsRoutes,
|
||||
initAgentRuntime,
|
||||
shutdownAgentRuntime,
|
||||
} from './routes/agents'
|
||||
import { createChatRoutes } from './routes/chat'
|
||||
import { createCreditsRoutes } from './routes/credits'
|
||||
import { createGraphRoutes } from './routes/graph'
|
||||
@@ -104,6 +109,7 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
|
||||
const app = new Hono<Env>()
|
||||
.use('/*', cors(defaultCorsConfig))
|
||||
.route('/agents', createAgentsRoutes())
|
||||
.route('/health', createHealthRoute({ browser }))
|
||||
.route(
|
||||
'/shutdown',
|
||||
@@ -115,6 +121,11 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
)
|
||||
shutdownAgentRuntime().catch((err) =>
|
||||
logger.warn('Failed to shut down agent runtime', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
)
|
||||
onShutdown?.()
|
||||
},
|
||||
}),
|
||||
@@ -229,6 +240,9 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
|
||||
logger.info('Consolidated HTTP Server started', { port, host })
|
||||
|
||||
// Pre-start Podman machine if agents exist from a previous session
|
||||
initAgentRuntime()
|
||||
|
||||
if (config.aiSdkDevtoolsEnabled) {
|
||||
logger.info(
|
||||
'AI SDK DevTools enabled — run `npx @ai-sdk/devtools` to open the viewer',
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
const isLinux = process.platform === 'linux'
|
||||
|
||||
export class PodmanRuntime {
|
||||
private podmanPath: string
|
||||
private machineReady = false
|
||||
|
||||
constructor(config?: { podmanPath?: string }) {
|
||||
this.podmanPath = config?.podmanPath ?? 'podman'
|
||||
}
|
||||
|
||||
getPodmanPath(): string {
|
||||
return this.podmanPath
|
||||
}
|
||||
|
||||
async isPodmanAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const proc = Bun.spawn([this.podmanPath, '--version'], {
|
||||
stdout: 'ignore',
|
||||
stderr: 'ignore',
|
||||
})
|
||||
const code = await proc.exited
|
||||
return code === 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async getMachineStatus(): Promise<{
|
||||
initialized: boolean
|
||||
running: boolean
|
||||
}> {
|
||||
if (isLinux) return { initialized: true, running: true }
|
||||
|
||||
try {
|
||||
const proc = Bun.spawn(
|
||||
[this.podmanPath, 'machine', 'list', '--format', 'json'],
|
||||
{ stdout: 'pipe', stderr: 'ignore' },
|
||||
)
|
||||
const output = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
|
||||
const machines = JSON.parse(output) as Array<{
|
||||
Running?: boolean
|
||||
LastUp?: string
|
||||
}>
|
||||
|
||||
if (!machines.length) return { initialized: false, running: false }
|
||||
|
||||
const machine = machines[0]
|
||||
const running =
|
||||
machine.Running === true || machine.LastUp === 'Currently running'
|
||||
|
||||
return { initialized: true, running }
|
||||
} catch {
|
||||
return { initialized: false, running: false }
|
||||
}
|
||||
}
|
||||
|
||||
async initMachine(onLog?: (msg: string) => void): Promise<void> {
|
||||
if (isLinux) return
|
||||
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
this.podmanPath,
|
||||
'machine',
|
||||
'init',
|
||||
'--cpus',
|
||||
'2',
|
||||
'--memory',
|
||||
'2048',
|
||||
'--disk-size',
|
||||
'10',
|
||||
],
|
||||
{ stdout: 'ignore', stderr: 'pipe' },
|
||||
)
|
||||
|
||||
if (onLog && proc.stderr) {
|
||||
const reader = proc.stderr.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) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed) onLog(trimmed)
|
||||
}
|
||||
}
|
||||
if (buffer.trim()) onLog(buffer.trim())
|
||||
}
|
||||
|
||||
const code = await proc.exited
|
||||
if (code !== 0)
|
||||
throw new Error(`podman machine init failed with code ${code}`)
|
||||
}
|
||||
|
||||
async startMachine(onLog?: (msg: string) => void): Promise<void> {
|
||||
if (isLinux) return
|
||||
|
||||
const proc = Bun.spawn([this.podmanPath, 'machine', 'start'], {
|
||||
stdout: 'ignore',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
|
||||
if (onLog && proc.stderr) {
|
||||
const reader = proc.stderr.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) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed) onLog(trimmed)
|
||||
}
|
||||
}
|
||||
if (buffer.trim()) onLog(buffer.trim())
|
||||
}
|
||||
|
||||
const code = await proc.exited
|
||||
if (code !== 0)
|
||||
throw new Error(`podman machine start failed with code ${code}`)
|
||||
}
|
||||
|
||||
async stopMachine(): Promise<void> {
|
||||
if (isLinux) return
|
||||
|
||||
const proc = Bun.spawn([this.podmanPath, 'machine', 'stop'], {
|
||||
stdout: 'ignore',
|
||||
stderr: 'ignore',
|
||||
})
|
||||
const code = await proc.exited
|
||||
if (code !== 0)
|
||||
throw new Error(`podman machine stop failed with code ${code}`)
|
||||
this.machineReady = false
|
||||
}
|
||||
|
||||
async ensureReady(onLog?: (msg: string) => void): Promise<void> {
|
||||
if (this.machineReady) return
|
||||
|
||||
const status = await this.getMachineStatus()
|
||||
|
||||
if (!status.initialized) {
|
||||
onLog?.('Initializing Podman machine...')
|
||||
await this.initMachine(onLog)
|
||||
}
|
||||
|
||||
if (!status.running) {
|
||||
onLog?.('Starting Podman machine...')
|
||||
await this.startMachine(onLog)
|
||||
}
|
||||
|
||||
this.machineReady = true
|
||||
}
|
||||
|
||||
async runCommand(
|
||||
args: string[],
|
||||
options?: {
|
||||
cwd?: string
|
||||
env?: Record<string, string>
|
||||
onOutput?: (line: string) => void
|
||||
},
|
||||
): Promise<number> {
|
||||
const useStreaming = !!options?.onOutput
|
||||
const proc = Bun.spawn([this.podmanPath, ...args], {
|
||||
cwd: options?.cwd,
|
||||
env: options?.env ? { ...process.env, ...options.env } : undefined,
|
||||
stdout: useStreaming ? 'pipe' : 'ignore',
|
||||
stderr: useStreaming ? 'pipe' : 'ignore',
|
||||
})
|
||||
|
||||
if (options?.onOutput) {
|
||||
const streamLines = async (stream: ReadableStream<Uint8Array> | null) => {
|
||||
if (!stream) return
|
||||
const reader = stream.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) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed) options.onOutput!(trimmed)
|
||||
}
|
||||
}
|
||||
if (buffer.trim()) options.onOutput!(buffer.trim())
|
||||
}
|
||||
|
||||
await Promise.all([streamLines(proc.stdout), streamLines(proc.stderr)])
|
||||
}
|
||||
|
||||
return proc.exited
|
||||
}
|
||||
}
|
||||
|
||||
let runtime: PodmanRuntime | null = null
|
||||
|
||||
export function getPodmanRuntime(): PodmanRuntime {
|
||||
if (!runtime) runtime = new PodmanRuntime()
|
||||
return runtime
|
||||
}
|
||||
Reference in New Issue
Block a user