mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
Compare commits
20 Commits
fix/iframe
...
feat/progr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fdad55b4a | ||
|
|
aff8afd9a4 | ||
|
|
0c96002cf5 | ||
|
|
76e5dcb801 | ||
|
|
a85f94de40 | ||
|
|
6708ab834b | ||
|
|
007208d54b | ||
|
|
dd85ae503f | ||
|
|
452906d3ca | ||
|
|
0397d3e393 | ||
|
|
edd681012c | ||
|
|
ce7c209ba6 | ||
|
|
6548220bcb | ||
|
|
14eeba7c20 | ||
|
|
3c629c5929 | ||
|
|
77dcd37000 | ||
|
|
6d0dff7b1a | ||
|
|
f78068bb9d | ||
|
|
6b18ebb1d8 | ||
|
|
1f2e783ab9 |
1
.github/workflows/code-quality.yml
vendored
1
.github/workflows/code-quality.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- "packages/browseros-agent/**"
|
||||
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -23,9 +23,11 @@ nxtscape-cli-access.json
|
||||
gclient.json
|
||||
.env
|
||||
.grove/
|
||||
AGENTS.md
|
||||
**/resources/binaries/
|
||||
|
||||
packages/browseros/build/tools/
|
||||
|
||||
# AI SDK DevTools traces
|
||||
.devtools/
|
||||
.omc/
|
||||
|
||||
1
packages/browseros-agent/.gitignore
vendored
1
packages/browseros-agent/.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
CLAUDE.md
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
@@ -148,7 +148,7 @@ When creating new packages in this monorepo:
|
||||
## Test Organization
|
||||
|
||||
Tests are in `apps/server/tests/`:
|
||||
- `tools/` - Tool tests (require BrowserOS running with CDP)
|
||||
- `tools/` - Tool tests (require BrowserOS running with CDP), plus ACL scorer tests (standalone)
|
||||
- `browser/` - Browser backend tests
|
||||
- `agent/` - Agent tests (compaction, rate limiter)
|
||||
- `sdk/` - Agent SDK tests
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# BrowserOS Agent Extension
|
||||
|
||||
## v0.0.99 (2026-04-08)
|
||||
|
||||
## What's Changed
|
||||
|
||||
- chore: bump server and extension version (#659)
|
||||
- chore(agent): remove workflows feature (#656)
|
||||
- feat: replace model picker with shadcn Combobox + fuse.js fuzzy search (#617)
|
||||
- feat: clean-up - remove obsolete controller extension (#610)
|
||||
- docs: update agent extension changelog for v0.0.98 (#609)
|
||||
|
||||
|
||||
## v0.0.98 (2026-03-27)
|
||||
|
||||
## What's Changed
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
CircleDotDashed,
|
||||
Clock3,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import { type FC, useState } from 'react'
|
||||
import { ToolInput, ToolOutput } from '@/components/ai-elements/tool'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import type { ExecutionStepRecord } from '@/lib/execution-history/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const formatToolName = (name: string) =>
|
||||
name
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
.replace(/^./, (value) => value.toUpperCase())
|
||||
|
||||
const formatStateLabel = (state: ExecutionStepRecord['state']) => {
|
||||
if (state === 'input-streaming') return 'Preparing'
|
||||
if (state === 'input-available') return 'Running'
|
||||
if (state === 'approval-requested') return 'Approval Needed'
|
||||
if (state === 'approval-responded') return 'Approval Responded'
|
||||
if (state === 'output-available') return 'Completed'
|
||||
if (state === 'output-denied') return 'Denied'
|
||||
return 'Error'
|
||||
}
|
||||
|
||||
const getStateIcon = (step: ExecutionStepRecord) => {
|
||||
if (step.state === 'output-available') {
|
||||
return <CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
}
|
||||
|
||||
if (
|
||||
step.state === 'input-streaming' ||
|
||||
step.state === 'input-available' ||
|
||||
step.state === 'approval-requested'
|
||||
) {
|
||||
return <Clock3 className="h-4 w-4 text-[var(--accent-orange)]" />
|
||||
}
|
||||
|
||||
if (step.state === 'approval-responded') {
|
||||
return <ShieldCheck className="h-4 w-4 text-blue-500" />
|
||||
}
|
||||
|
||||
if (step.state === 'output-denied') {
|
||||
return <ShieldAlert className="h-4 w-4 text-orange-500" />
|
||||
}
|
||||
|
||||
if (step.state === 'output-error') {
|
||||
return <XCircle className="h-4 w-4 text-destructive" />
|
||||
}
|
||||
|
||||
return <CircleDotDashed className="h-4 w-4 text-muted-foreground" />
|
||||
}
|
||||
|
||||
const isAclBlocked = (step: ExecutionStepRecord) =>
|
||||
Boolean(
|
||||
step.errorText?.includes('Action blocked by ACL rule') ||
|
||||
step.approval?.reason?.includes('Action blocked by ACL rule') ||
|
||||
step.previewText === 'Blocked by ACL rule',
|
||||
)
|
||||
|
||||
const shouldShowPreview = (step: ExecutionStepRecord) =>
|
||||
step.state === 'input-streaming' ||
|
||||
step.state === 'input-available' ||
|
||||
step.state === 'approval-requested' ||
|
||||
step.state === 'approval-responded'
|
||||
|
||||
export const ExecutionStepItem: FC<{
|
||||
step: ExecutionStepRecord
|
||||
defaultOpen?: boolean
|
||||
}> = ({ step, defaultOpen = false }) => {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
const deniedReason =
|
||||
step.state === 'output-denied' ? step.approval?.reason : undefined
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<div className="rounded-xl border border-border/60 bg-card/60">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-start gap-3 px-4 py-3 text-left"
|
||||
>
|
||||
<div className="mt-0.5 shrink-0">{getStateIcon(step)}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="font-medium text-foreground text-sm">
|
||||
{formatToolName(step.toolName)}
|
||||
</p>
|
||||
<Badge variant="secondary">
|
||||
{formatStateLabel(step.state)}
|
||||
</Badge>
|
||||
{isAclBlocked(step) && (
|
||||
<Badge variant="outline">ACL Blocked</Badge>
|
||||
)}
|
||||
</div>
|
||||
{shouldShowPreview(step) && (
|
||||
<p className="mt-1 text-muted-foreground text-xs">
|
||||
{step.previewText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform',
|
||||
open && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="border-border/60 border-t">
|
||||
{step.input !== undefined && <ToolInput input={step.input} />}
|
||||
{step.state === 'output-denied' ? (
|
||||
<div className="space-y-2 p-4">
|
||||
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Result
|
||||
</h4>
|
||||
<div className="rounded-md bg-orange-500/10 p-3 text-orange-700 text-sm dark:text-orange-300">
|
||||
{deniedReason ?? 'The requested action was denied.'}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ToolOutput
|
||||
output={step.output}
|
||||
errorText={step.errorText}
|
||||
className="pt-0"
|
||||
/>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import dayjs from 'dayjs'
|
||||
import duration from 'dayjs/plugin/duration'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
CircleDot,
|
||||
CircleSlash2,
|
||||
MessageSquareText,
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import { type FC, useMemo, useState } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import type { ExecutionTaskRecord } from '@/lib/execution-history/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ExecutionStepItem } from './ExecutionStepItem'
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
dayjs.extend(duration)
|
||||
|
||||
function getTaskStatusIcon(status: ExecutionTaskRecord['status']) {
|
||||
if (status === 'completed') {
|
||||
return <CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
}
|
||||
|
||||
if (status === 'running') {
|
||||
return <CircleDot className="h-4 w-4 text-[var(--accent-orange)]" />
|
||||
}
|
||||
|
||||
if (status === 'stopped') {
|
||||
return <CircleSlash2 className="h-4 w-4 text-orange-500" />
|
||||
}
|
||||
|
||||
return <XCircle className="h-4 w-4 text-destructive" />
|
||||
}
|
||||
|
||||
function getTaskStatusLabel(status: ExecutionTaskRecord['status']) {
|
||||
if (status === 'completed') return 'Completed'
|
||||
if (status === 'running') return 'Running'
|
||||
if (status === 'stopped') return 'Stopped'
|
||||
if (status === 'interrupted') return 'Interrupted'
|
||||
return 'Failed'
|
||||
}
|
||||
|
||||
function formatDuration(task: ExecutionTaskRecord): string | null {
|
||||
if (!task.completedAt) return null
|
||||
const diff = dayjs(task.completedAt).diff(task.startedAt)
|
||||
const parsed = dayjs.duration(diff)
|
||||
const minutes = Math.floor(parsed.asMinutes())
|
||||
const seconds = parsed.seconds()
|
||||
if (minutes === 0) return `${seconds}s`
|
||||
return `${minutes}m ${seconds}s`
|
||||
}
|
||||
|
||||
export const ExecutionTaskCard: FC<{
|
||||
task: ExecutionTaskRecord
|
||||
defaultOpen?: boolean
|
||||
onDelete?: (task: ExecutionTaskRecord) => void
|
||||
}> = ({ task, defaultOpen = false, onDelete }) => {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
const startedAgo = useMemo(
|
||||
() => dayjs(task.startedAt).fromNow(),
|
||||
[task.startedAt],
|
||||
)
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<div className="rounded-2xl border border-border/60 bg-card shadow-sm">
|
||||
<div className="flex items-start gap-2 px-5 py-5">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-start gap-3 text-left"
|
||||
>
|
||||
<div className="mt-0.5 shrink-0">
|
||||
{getTaskStatusIcon(task.status)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="line-clamp-2 font-medium text-base text-foreground">
|
||||
{task.promptText}
|
||||
</p>
|
||||
<Badge variant="secondary">
|
||||
{getTaskStatusLabel(task.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-muted-foreground text-xs">
|
||||
<span>{startedAgo}</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
{task.actionCount} action{task.actionCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
{formatDuration(task) && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{formatDuration(task)}</span>
|
||||
</>
|
||||
)}
|
||||
{task.deniedCount > 0 && (
|
||||
<Badge variant="outline" className="h-5 rounded-full px-2">
|
||||
{task.deniedCount} denied
|
||||
</Badge>
|
||||
)}
|
||||
{task.errorCount > 0 && (
|
||||
<Badge variant="outline" className="h-5 rounded-full px-2">
|
||||
{task.errorCount} error
|
||||
{task.errorCount === 1 ? '' : 's'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{task.responsePreview ? (
|
||||
<div className="mt-4 flex items-start gap-2 rounded-xl bg-muted/40 px-3 py-2 text-muted-foreground text-sm">
|
||||
<MessageSquareText className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p className="line-clamp-2">{task.responsePreview}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'mt-1 h-4 w-4 shrink-0 text-muted-foreground transition-transform',
|
||||
open && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
{onDelete ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="mt-0.5 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onDelete(task)}
|
||||
aria-label={`Delete ${task.promptText}`}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<CollapsibleContent className="border-border/60 border-t px-5 py-5">
|
||||
{task.steps.length === 0 ? (
|
||||
<div className="rounded-xl border border-border/70 border-dashed bg-muted/30 px-4 py-6 text-center text-muted-foreground text-sm">
|
||||
No tool actions were recorded for this task.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{task.steps.map((step, index) => (
|
||||
<ExecutionStepItem
|
||||
key={step.id}
|
||||
step={step}
|
||||
defaultOpen={
|
||||
task.status === 'running' && index === task.steps.length - 1
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
RotateCcw,
|
||||
Search,
|
||||
Server,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { NavLink } from 'react-router'
|
||||
@@ -78,7 +80,9 @@ const primarySettingsSections: NavSection[] = [
|
||||
icon: Palette,
|
||||
feature: Feature.CUSTOMIZATION_SUPPORT,
|
||||
},
|
||||
{ name: 'Tool Approvals', to: '/settings/approvals', icon: ShieldCheck },
|
||||
{ name: 'BrowserOS as MCP', to: '/settings/mcp', icon: Server },
|
||||
{ name: 'ACL Rules', to: '/settings/acl', icon: ShieldAlert },
|
||||
{
|
||||
name: 'Usage & Billing',
|
||||
to: '/settings/usage',
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
Brain,
|
||||
CalendarClock,
|
||||
Cpu,
|
||||
Home,
|
||||
PlugZap,
|
||||
Settings,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Wand2,
|
||||
} from 'lucide-react'
|
||||
@@ -39,6 +41,7 @@ const primaryNavItems: NavItem[] = [
|
||||
feature: Feature.MANAGED_MCP_SUPPORT,
|
||||
},
|
||||
{ name: 'Scheduled Tasks', to: '/scheduled', icon: CalendarClock },
|
||||
{ name: 'Agents', to: '/agents', icon: Cpu },
|
||||
{
|
||||
name: 'Skills',
|
||||
to: '/home/skills',
|
||||
@@ -57,6 +60,7 @@ const primaryNavItems: NavItem[] = [
|
||||
icon: Sparkles,
|
||||
feature: Feature.SOUL_SUPPORT,
|
||||
},
|
||||
{ name: 'Governance', to: '/admin', icon: Shield },
|
||||
{ name: 'Settings', to: '/settings/ai', icon: Settings },
|
||||
]
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { FC } from 'react'
|
||||
import { HashRouter, Navigate, Route, Routes, useParams } from 'react-router'
|
||||
|
||||
import { NewTab } from '../newtab/index/NewTab'
|
||||
import { NewTabChat } from '../newtab/index/NewTabChat'
|
||||
import { NewTabLayout } from '../newtab/layout/NewTabLayout'
|
||||
import { Personalize } from '../newtab/personalize/Personalize'
|
||||
@@ -9,6 +7,12 @@ 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 { AclSettingsPage } from './acl-settings/AclSettingsPage'
|
||||
import { AdminDashboardPage } from './admin-dashboard/AdminDashboardPage'
|
||||
import { AgentCommandConversation } from './agent-command/AgentCommandConversation'
|
||||
import { AgentCommandHome } from './agent-command/AgentCommandHome'
|
||||
import { AgentCommandLayout } from './agent-command/agent-command-layout'
|
||||
import { AgentsPage } from './agents/AgentsPage'
|
||||
import { AISettingsPage } from './ai-settings/AISettingsPage'
|
||||
import { ConnectMCP } from './connect-mcp/ConnectMCP'
|
||||
import { CustomizationPage } from './customization/CustomizationPage'
|
||||
@@ -27,6 +31,7 @@ import { ScheduledTasksPage } from './scheduled-tasks/ScheduledTasksPage'
|
||||
import { SearchProviderPage } from './search-provider/SearchProviderPage'
|
||||
import { SkillsPage } from './skills/SkillsPage'
|
||||
import { SoulPage } from './soul/SoulPage'
|
||||
import { ToolApprovalsPage } from './tool-approvals/ToolApprovalsPage'
|
||||
import { UsagePage } from './usage/UsagePage'
|
||||
|
||||
function getSurveyParams(): { maxTurns?: number; experimentId?: string } {
|
||||
@@ -76,7 +81,13 @@ export const App: FC = () => {
|
||||
<Route element={<SidebarLayout />}>
|
||||
{/* Home routes */}
|
||||
<Route path="home" element={<NewTabLayout />}>
|
||||
<Route index element={<NewTab />} />
|
||||
<Route element={<AgentCommandLayout />}>
|
||||
<Route index element={<AgentCommandHome />} />
|
||||
<Route
|
||||
path="agents/:agentId"
|
||||
element={<AgentCommandConversation />}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="chat" element={<NewTabChat />} />
|
||||
<Route path="personalize" element={<Personalize />} />
|
||||
<Route path="soul" element={<SoulPage />} />
|
||||
@@ -87,6 +98,8 @@ export const App: FC = () => {
|
||||
{/* Primary nav routes */}
|
||||
<Route path="connect-apps" element={<ConnectMCP />} />
|
||||
<Route path="scheduled" element={<ScheduledTasksPage />} />
|
||||
<Route path="agents" element={<AgentsPage />} />
|
||||
<Route path="admin" element={<AdminDashboardPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Settings with dedicated sidebar */}
|
||||
@@ -100,6 +113,8 @@ export const App: FC = () => {
|
||||
<Route path="search" element={<SearchProviderPage />} />
|
||||
<Route path="survey" element={<SurveyPage {...surveyParams} />} />
|
||||
<Route path="usage" element={<UsagePage />} />
|
||||
<Route path="acl" element={<AclSettingsPage />} />
|
||||
<Route path="approvals" element={<ToolApprovalsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
@@ -129,6 +144,12 @@ export const App: FC = () => {
|
||||
path="/settings/skills"
|
||||
element={<Navigate to="/home/skills" replace />}
|
||||
/>
|
||||
<Route path="/audit" element={<Navigate to="/admin" replace />} />
|
||||
<Route
|
||||
path="/observability"
|
||||
element={<Navigate to="/admin" replace />}
|
||||
/>
|
||||
<Route path="/executions" element={<Navigate to="/admin" replace />} />
|
||||
<Route path="/options/*" element={<OptionsRedirect />} />
|
||||
|
||||
{/* Fallback to home */}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { AclRule } from '@browseros/shared/types/acl'
|
||||
import { Globe, Trash2 } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface AclRuleCardProps {
|
||||
rule: AclRule
|
||||
onToggle: (id: string, enabled: boolean) => void
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
|
||||
export const AclRuleCard: FC<AclRuleCardProps> = ({
|
||||
rule,
|
||||
onToggle,
|
||||
onDelete,
|
||||
}) => {
|
||||
const summary =
|
||||
rule.description ?? rule.textMatch ?? rule.selector ?? 'Block actions'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-4 rounded-xl border p-4 transition-all',
|
||||
rule.enabled
|
||||
? 'border-red-300 bg-red-50/50 dark:border-red-800 dark:bg-red-950/20'
|
||||
: 'border-border bg-card opacity-60',
|
||||
)}
|
||||
>
|
||||
<Switch
|
||||
checked={rule.enabled}
|
||||
onCheckedChange={(checked) => onToggle(rule.id, checked)}
|
||||
/>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="truncate font-medium text-sm">{summary}</span>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary" className="gap-1 font-mono text-xs">
|
||||
<Globe className="size-3" />
|
||||
{rule.sitePattern}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => onDelete(rule.id)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { AclRule } from '@browseros/shared/types/acl'
|
||||
import { Plus, ShieldAlert } from 'lucide-react'
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { aclRulesStorage } from '@/lib/acl/storage'
|
||||
import { AclRuleCard } from './AclRuleCard'
|
||||
import { NewAclRuleDialog } from './NewAclRuleDialog'
|
||||
|
||||
export const AclSettingsPage: FC = () => {
|
||||
const [rules, setRules] = useState<AclRule[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
aclRulesStorage.getValue().then(setRules)
|
||||
const unwatch = aclRulesStorage.watch(setRules)
|
||||
return () => unwatch()
|
||||
}, [])
|
||||
|
||||
const saveRules = (next: AclRule[]) => {
|
||||
setRules(next)
|
||||
aclRulesStorage.setValue(next)
|
||||
}
|
||||
|
||||
const handleAddRule = (rule: AclRule) => {
|
||||
saveRules([...rules, rule])
|
||||
}
|
||||
|
||||
const handleToggle = (id: string, enabled: boolean) => {
|
||||
saveRules(rules.map((r) => (r.id === id ? { ...r, enabled } : r)))
|
||||
}
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
saveRules(rules.filter((r) => r.id !== id))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl p-6">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-semibold text-xl">ACL Rules</h1>
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
Describe what the agent should avoid on a site and BrowserOS will
|
||||
block matching actions.
|
||||
</p>
|
||||
</div>
|
||||
<NewAclRuleDialog onSave={handleAddRule}>
|
||||
<Button size="sm">
|
||||
<Plus className="mr-1 size-4" />
|
||||
Add Rule
|
||||
</Button>
|
||||
</NewAclRuleDialog>
|
||||
</div>
|
||||
|
||||
{rules.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed p-12 text-center">
|
||||
<ShieldAlert className="size-10 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">No ACL rules defined</p>
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
Add a plain-English rule like “payments and checkout”
|
||||
or “send email” and BrowserOS will apply broad safety
|
||||
blocking on that site.
|
||||
</p>
|
||||
</div>
|
||||
<NewAclRuleDialog onSave={handleAddRule}>
|
||||
<Button variant="outline" size="sm">
|
||||
<Plus className="mr-1 size-4" />
|
||||
Add your first rule
|
||||
</Button>
|
||||
</NewAclRuleDialog>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{rules.map((rule) => (
|
||||
<AclRuleCard
|
||||
key={rule.id}
|
||||
rule={rule}
|
||||
onToggle={handleToggle}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { AclRule } from '@browseros/shared/types/acl'
|
||||
import { type FC, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
interface NewAclRuleDialogProps {
|
||||
onSave: (rule: AclRule) => void
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const NewAclRuleDialog: FC<NewAclRuleDialogProps> = ({
|
||||
onSave,
|
||||
children,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [sitePattern, setSitePattern] = useState('')
|
||||
const [intent, setIntent] = useState('')
|
||||
|
||||
const reset = () => {
|
||||
setSitePattern('')
|
||||
setIntent('')
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!sitePattern.trim() || !intent.trim()) return
|
||||
onSave({
|
||||
id: crypto.randomUUID(),
|
||||
sitePattern: sitePattern.trim(),
|
||||
description: intent.trim(),
|
||||
enabled: true,
|
||||
})
|
||||
reset()
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add ACL Rule</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 py-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="site-pattern">
|
||||
Domain <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="site-pattern"
|
||||
placeholder="amazon.com"
|
||||
value={sitePattern}
|
||||
onChange={(e) => setSitePattern(e.target.value)}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Matches the domain and all subdomains.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="intent">
|
||||
What should BrowserOS block?{' '}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="intent"
|
||||
placeholder="Payments and checkout"
|
||||
value={intent}
|
||||
onChange={(e) => setIntent(e.target.value)}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Use plain English. BrowserOS will block matching actions on this
|
||||
site.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!sitePattern.trim() || !intent.trim()}
|
||||
>
|
||||
Add Rule
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Shield } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
interface AdminDashboardHeaderProps {
|
||||
pendingCount: number
|
||||
runningCount: number
|
||||
}
|
||||
|
||||
export const AdminDashboardHeader: FC<AdminDashboardHeaderProps> = ({
|
||||
pendingCount,
|
||||
runningCount,
|
||||
}) => {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-6 shadow-sm transition-all hover:shadow-md">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-[var(--accent-orange)]/10">
|
||||
<Shield className="h-6 w-6 text-[var(--accent-orange)]" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="mb-1 flex flex-wrap items-center gap-2">
|
||||
<h2 className="font-semibold text-xl">Governance</h2>
|
||||
{pendingCount > 0 && (
|
||||
<Badge className="gap-1.5 rounded-full bg-yellow-500/10 text-yellow-600">
|
||||
{pendingCount} pending
|
||||
</Badge>
|
||||
)}
|
||||
{runningCount > 0 && (
|
||||
<Badge className="gap-1.5 rounded-full">
|
||||
{runningCount} live
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Control agent permissions and audit every action.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { Shield } from 'lucide-react'
|
||||
import { type FC, useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { ExecutionTaskCard } from '@/components/execution-history/ExecutionTaskCard'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
removeConversationExecutionTask,
|
||||
useExecutionHistoryByConversation,
|
||||
} from '@/lib/execution-history/storage'
|
||||
import type { ExecutionTaskRecord } from '@/lib/execution-history/types'
|
||||
import { pendingToolApprovalsStorage } from '@/lib/tool-approvals/approval-sync-storage'
|
||||
import { AdminDashboardHeader } from './AdminDashboardHeader'
|
||||
import { PendingApprovals } from './PendingApprovals'
|
||||
|
||||
type TaskGroup = {
|
||||
label: string
|
||||
tasks: ExecutionTaskRecord[]
|
||||
}
|
||||
|
||||
function getGroupLabel(date: string) {
|
||||
const startedAt = dayjs(date)
|
||||
if (startedAt.isSame(dayjs(), 'day')) return 'Today'
|
||||
if (startedAt.isSame(dayjs().subtract(1, 'day'), 'day')) return 'Yesterday'
|
||||
return startedAt.format('MMMM D, YYYY')
|
||||
}
|
||||
|
||||
function groupTasks(tasks: ExecutionTaskRecord[]): TaskGroup[] {
|
||||
const grouped = new Map<string, ExecutionTaskRecord[]>()
|
||||
|
||||
for (const task of tasks) {
|
||||
const label = getGroupLabel(task.startedAt)
|
||||
const existing = grouped.get(label) ?? []
|
||||
grouped.set(label, [...existing, task])
|
||||
}
|
||||
|
||||
return Array.from(grouped.entries()).map(([label, groupItems]) => ({
|
||||
label,
|
||||
tasks: groupItems,
|
||||
}))
|
||||
}
|
||||
|
||||
export const AdminDashboardPage: FC = () => {
|
||||
const [pendingCount, setPendingCount] = useState(0)
|
||||
const historyByConversation = useExecutionHistoryByConversation()
|
||||
const [taskToDelete, setTaskToDelete] = useState<ExecutionTaskRecord | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
pendingToolApprovalsStorage
|
||||
.getValue()
|
||||
.then((v) => setPendingCount(v.length))
|
||||
const unwatch = pendingToolApprovalsStorage.watch((v) =>
|
||||
setPendingCount(v.length),
|
||||
)
|
||||
return () => unwatch()
|
||||
}, [])
|
||||
|
||||
const historyList = useMemo(
|
||||
() => Object.values(historyByConversation),
|
||||
[historyByConversation],
|
||||
)
|
||||
|
||||
const tasks = useMemo(() => {
|
||||
return historyList
|
||||
.flatMap((history) => history.tasks)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
new Date(right.startedAt).getTime() -
|
||||
new Date(left.startedAt).getTime(),
|
||||
)
|
||||
}, [historyList])
|
||||
|
||||
const groupedTasks = useMemo(() => groupTasks(tasks), [tasks])
|
||||
const runningCount = useMemo(
|
||||
() => tasks.filter((task) => task.status === 'running').length,
|
||||
[tasks],
|
||||
)
|
||||
const conversationCount = historyList.length
|
||||
|
||||
const handleDeleteTask = async () => {
|
||||
if (!taskToDelete) return
|
||||
|
||||
try {
|
||||
await removeConversationExecutionTask({
|
||||
conversationId: taskToDelete.conversationId,
|
||||
taskId: taskToDelete.id,
|
||||
})
|
||||
toast.success('Run removed')
|
||||
} catch {
|
||||
toast.error('Failed to remove run')
|
||||
} finally {
|
||||
setTaskToDelete(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fade-in slide-in-from-bottom-5 animate-in space-y-6 duration-500">
|
||||
<AdminDashboardHeader
|
||||
pendingCount={pendingCount}
|
||||
runningCount={runningCount}
|
||||
/>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="font-semibold text-sm">Approvals</h3>
|
||||
<PendingApprovals />
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">Audit Trail</h3>
|
||||
{tasks.length > 0 && (
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
{tasks.length} recorded run{tasks.length === 1 ? '' : 's'}
|
||||
{conversationCount > 1
|
||||
? ` across ${conversationCount} chats`
|
||||
: ''}
|
||||
. Newest first.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tasks.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed px-6 py-14 text-center">
|
||||
<div className="mx-auto mb-4 flex size-12 items-center justify-center rounded-2xl bg-[var(--accent-orange)]/10">
|
||||
<Shield className="size-5 text-[var(--accent-orange)]" />
|
||||
</div>
|
||||
<h3 className="mb-1 font-medium text-lg">No agent runs yet</h3>
|
||||
<p className="mx-auto max-w-sm text-muted-foreground text-sm">
|
||||
Run a task in BrowserOS and the execution history will appear
|
||||
here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{groupedTasks.map((group, groupIndex) => (
|
||||
<section key={group.label} className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h4 className="font-medium text-muted-foreground text-xs">
|
||||
{group.label}
|
||||
</h4>
|
||||
<div className="h-px flex-1 bg-border/60" />
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{group.tasks.length} run
|
||||
{group.tasks.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{group.tasks.map((task, index) => (
|
||||
<ExecutionTaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
defaultOpen={
|
||||
task.status === 'running' ||
|
||||
(groupIndex === 0 && index === 0)
|
||||
}
|
||||
onDelete={setTaskToDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<AlertDialog
|
||||
open={taskToDelete !== null}
|
||||
onOpenChange={(open) => !open && setTaskToDelete(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Run</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Remove "{taskToDelete?.promptText}" from local history? This only
|
||||
clears the recorded run on this device.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteTask}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Clock, ShieldCheck, ShieldX } from 'lucide-react'
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
type ApprovalResponse,
|
||||
approvalResponsesStorage,
|
||||
type PendingApproval,
|
||||
pendingToolApprovalsStorage,
|
||||
queueApprovalResponse,
|
||||
} from '@/lib/tool-approvals/approval-sync-storage'
|
||||
|
||||
const formatToolName = (name: string) =>
|
||||
name
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
.replace(/^./, (s) => s.toUpperCase())
|
||||
|
||||
export const PendingApprovals: FC = () => {
|
||||
const [pending, setPending] = useState<PendingApproval[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
pendingToolApprovalsStorage.getValue().then(setPending)
|
||||
const unwatch = pendingToolApprovalsStorage.watch(setPending)
|
||||
return () => unwatch()
|
||||
}, [])
|
||||
|
||||
const respond = async (approvalId: string, approved: boolean) => {
|
||||
const response: ApprovalResponse = {
|
||||
approvalId,
|
||||
approved,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
const existing = (await approvalResponsesStorage.getValue()) ?? []
|
||||
await approvalResponsesStorage.setValue(
|
||||
queueApprovalResponse(existing, response),
|
||||
)
|
||||
}
|
||||
|
||||
if (pending.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed px-6 py-14 text-center">
|
||||
<div className="mx-auto mb-4 flex size-12 items-center justify-center rounded-2xl bg-[var(--accent-orange)]/10">
|
||||
<ShieldCheck className="size-5 text-[var(--accent-orange)]" />
|
||||
</div>
|
||||
<h3 className="mb-1 font-medium text-lg">No pending approvals</h3>
|
||||
<p className="mx-auto max-w-sm text-muted-foreground text-sm">
|
||||
When the agent needs permission to execute a tool, approval requests
|
||||
will appear here.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{pending.map((item) => (
|
||||
<div
|
||||
key={item.approvalId}
|
||||
className="flex items-start gap-4 rounded-xl border border-yellow-500/20 bg-yellow-500/5 p-4"
|
||||
>
|
||||
<div className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-full bg-yellow-500/10">
|
||||
<Clock className="size-4 text-yellow-600" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">
|
||||
{formatToolName(item.toolName)}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
awaiting
|
||||
</Badge>
|
||||
</div>
|
||||
{Object.keys(item.input).length > 0 && (
|
||||
<pre className="mt-1 max-h-20 overflow-auto rounded bg-muted/50 p-2 font-mono text-muted-foreground text-xs">
|
||||
{JSON.stringify(item.input, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-3 text-xs"
|
||||
onClick={() => respond(item.approvalId, true)}
|
||||
>
|
||||
<ShieldCheck className="size-3" />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 gap-1 px-3 text-xs"
|
||||
onClick={() => respond(item.approvalId, false)}
|
||||
>
|
||||
<ShieldX className="size-3" />
|
||||
Deny
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Bot } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import type { AgentCardData } from '@/lib/agent-conversations/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface AgentCardProps {
|
||||
agent: AgentCardData
|
||||
onClick: () => void
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp?: number): string {
|
||||
if (!timestamp) return 'No activity yet'
|
||||
const diff = Date.now() - timestamp
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
if (minutes < 1) return 'just now'
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
return `${Math.floor(hours / 24)}d ago`
|
||||
}
|
||||
|
||||
function getStatusLabel(status: AgentCardData['status']): string {
|
||||
if (status === 'working') return 'Working'
|
||||
if (status === 'error') return 'Error'
|
||||
return 'Ready'
|
||||
}
|
||||
|
||||
function getStatusTone(status: AgentCardData['status']): string {
|
||||
if (status === 'working') return 'bg-amber-500'
|
||||
if (status === 'error') return 'bg-destructive'
|
||||
return 'bg-emerald-500'
|
||||
}
|
||||
|
||||
export const AgentCardExpanded: FC<AgentCardProps> = ({
|
||||
agent,
|
||||
onClick,
|
||||
active,
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'group flex min-h-32 w-full min-w-0 flex-col rounded-2xl border p-4 text-left shadow-sm transition-all duration-200',
|
||||
active
|
||||
? 'border-border/80 bg-card shadow-md ring-1 ring-[var(--accent-orange)]/20'
|
||||
: 'border-border/60 bg-card/85 hover:border-border hover:bg-card hover:shadow-md',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-10 shrink-0 items-center justify-center rounded-xl',
|
||||
active
|
||||
? 'bg-[var(--accent-orange)]/10 text-[var(--accent-orange)]'
|
||||
: 'bg-muted text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<Bot className="size-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-semibold text-sm">{agent.name}</div>
|
||||
<div className="truncate text-muted-foreground text-xs">
|
||||
{agent.model ?? 'OpenClaw agent'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-full border border-border/60 bg-background/70 px-2.5 py-1 text-[11px] text-muted-foreground">
|
||||
<span
|
||||
className={cn('size-2 rounded-full', getStatusTone(agent.status))}
|
||||
/>
|
||||
<span>{getStatusLabel(agent.status)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex-1">
|
||||
<p className="line-clamp-2 text-foreground/90 text-sm">
|
||||
{agent.lastMessage ??
|
||||
'Start a conversation to see recent work and summaries.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between gap-3 text-muted-foreground text-xs">
|
||||
<span>{formatTimestamp(agent.lastMessageTimestamp)}</span>
|
||||
<span>Open conversation</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
|
||||
export const AgentCardCompact: FC<AgentCardProps> = ({
|
||||
agent,
|
||||
onClick,
|
||||
active,
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm transition-colors',
|
||||
active
|
||||
? 'border-border bg-card shadow-sm ring-1 ring-[var(--accent-orange)]/20'
|
||||
: 'border-border/60 bg-card/85 text-foreground hover:border-border hover:bg-card',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'size-2 rounded-full',
|
||||
active ? 'bg-[var(--accent-orange)]' : getStatusTone(agent.status),
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">{agent.name}</span>
|
||||
</button>
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Plus } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import type { AgentCardData } from '@/lib/agent-conversations/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AgentCardCompact, AgentCardExpanded } from './AgentCard'
|
||||
|
||||
interface AgentCardDockProps {
|
||||
agents: AgentCardData[]
|
||||
activeAgentId?: string
|
||||
onSelectAgent: (agentId: string) => void
|
||||
onCreateAgent?: () => void
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
function CreateAgentButton({
|
||||
compact,
|
||||
onCreateAgent,
|
||||
}: {
|
||||
compact?: boolean
|
||||
onCreateAgent: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreateAgent}
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center gap-2 border border-dashed text-muted-foreground transition-colors hover:border-[var(--accent-orange)] hover:text-[var(--accent-orange)]',
|
||||
compact
|
||||
? 'rounded-full px-3 py-2 text-sm'
|
||||
: 'min-h-32 rounded-2xl px-5 py-4',
|
||||
)}
|
||||
>
|
||||
<Plus className={compact ? 'size-3.5' : 'size-5'} />
|
||||
<span>{compact ? 'New' : 'Create agent'}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export const AgentCardDock: FC<AgentCardDockProps> = ({
|
||||
agents,
|
||||
activeAgentId,
|
||||
onSelectAgent,
|
||||
onCreateAgent,
|
||||
compact,
|
||||
}) => {
|
||||
if (agents.length === 0 && !onCreateAgent) return null
|
||||
|
||||
const Card = compact ? AgentCardCompact : AgentCardExpanded
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
compact
|
||||
? 'flex items-center gap-2 overflow-x-auto pb-1'
|
||||
: 'grid gap-4 md:grid-cols-3',
|
||||
)}
|
||||
>
|
||||
{agents.map((agent) => (
|
||||
<Card
|
||||
key={agent.agentId}
|
||||
agent={agent}
|
||||
active={agent.agentId === activeAgentId}
|
||||
onClick={() => onSelectAgent(agent.agentId)}
|
||||
/>
|
||||
))}
|
||||
{onCreateAgent ? (
|
||||
<CreateAgentButton compact={compact} onCreateAgent={onCreateAgent} />
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { Bot, Home, RotateCcw } from 'lucide-react'
|
||||
import { type FC, useEffect, useRef } from 'react'
|
||||
import { Navigate, useNavigate, useParams, useSearchParams } from 'react-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAgentCommandData } from './agent-command-layout'
|
||||
import { ConversationInput } from './ConversationInput'
|
||||
import { ConversationMessage } from './ConversationMessage'
|
||||
import { useAgentConversation } from './useAgentConversation'
|
||||
|
||||
function ConversationHeader({
|
||||
agentName,
|
||||
status,
|
||||
onGoHome,
|
||||
onReset,
|
||||
}: {
|
||||
agentName: string
|
||||
status: string
|
||||
onGoHome: () => void
|
||||
onReset: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[1.5rem] border border-border/60 bg-card/95 shadow-sm backdrop-blur">
|
||||
<div className="flex items-center justify-between gap-3 px-5 py-4">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onGoHome}
|
||||
className="rounded-xl"
|
||||
title="Back to home"
|
||||
>
|
||||
<Home className="size-4" />
|
||||
</Button>
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
||||
<Bot className="size-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-semibold text-sm">{agentName}</div>
|
||||
<div className="truncate text-muted-foreground text-sm">
|
||||
{status}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onReset}
|
||||
className="rounded-xl text-muted-foreground"
|
||||
>
|
||||
<RotateCcw className="mr-2 size-4" />
|
||||
New conversation
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyConversationState({ agentName }: { agentName: string }) {
|
||||
return (
|
||||
<div className="flex min-h-full items-center justify-center py-10">
|
||||
<div className="max-w-md rounded-[1.5rem] border border-border/60 bg-card/90 px-8 py-10 text-center shadow-sm backdrop-blur">
|
||||
<div className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
||||
<Bot className="size-6" />
|
||||
</div>
|
||||
<h2 className="mt-4 font-semibold text-lg">{agentName}</h2>
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
Send a message to start a focused conversation with this agent.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getConversationStatusCopy(
|
||||
status: string | undefined,
|
||||
streaming: boolean,
|
||||
): string {
|
||||
if (streaming) return 'Working on your request'
|
||||
if (status === 'running') return 'Ready for the next task'
|
||||
if (status === 'starting') return 'Connecting to OpenClaw'
|
||||
if (status === 'error') return 'OpenClaw needs attention'
|
||||
if (status === 'stopped') return 'OpenClaw is offline'
|
||||
return 'Open agent setup to continue'
|
||||
}
|
||||
|
||||
export const AgentCommandConversation: FC = () => {
|
||||
const { agentId } = useParams<{ agentId: string }>()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const initialQuerySent = useRef(false)
|
||||
const { status, agents } = useAgentCommandData()
|
||||
const shouldRedirectHome = !agentId
|
||||
const resolvedAgentId = agentId ?? ''
|
||||
const agent = agents.find((entry) => entry.agentId === resolvedAgentId)
|
||||
const agentName = agent?.name || resolvedAgentId || 'Agent'
|
||||
const { turns, streaming, loading, send, resetConversation } =
|
||||
useAgentConversation(resolvedAgentId, agentName)
|
||||
const lastTurn = turns[turns.length - 1]
|
||||
const lastTurnPartCount = lastTurn?.parts.length ?? 0
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldRedirectHome) return
|
||||
|
||||
const query = searchParams.get('q')
|
||||
if (query && !initialQuerySent.current && !loading) {
|
||||
initialQuerySent.current = true
|
||||
setSearchParams({}, { replace: true })
|
||||
void send(query)
|
||||
}
|
||||
}, [loading, searchParams, send, setSearchParams, shouldRedirectHome])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
shouldRedirectHome ||
|
||||
(turns.length === 0 && lastTurnPartCount === 0 && !streaming)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
scrollRef.current?.scrollTo({
|
||||
top: scrollRef.current.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}, [lastTurnPartCount, shouldRedirectHome, streaming, turns.length])
|
||||
|
||||
if (shouldRedirectHome) {
|
||||
return <Navigate to="/home" replace />
|
||||
}
|
||||
|
||||
const handleSelectAgent = (entry: AgentEntry) => {
|
||||
navigate(`/home/agents/${entry.agentId}`)
|
||||
}
|
||||
|
||||
const statusCopy = getConversationStatusCopy(status?.status, streaming)
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="fade-in slide-in-from-bottom-5 mx-auto flex h-full w-full max-w-3xl animate-in flex-col gap-3 px-4 pt-4 pb-2 duration-300">
|
||||
<ConversationHeader
|
||||
agentName={agentName}
|
||||
status={statusCopy}
|
||||
onGoHome={() => navigate('/home')}
|
||||
onReset={resetConversation}
|
||||
/>
|
||||
|
||||
<main
|
||||
ref={scrollRef}
|
||||
className={cn(
|
||||
'styled-scrollbar min-h-0 flex-1 overflow-y-auto overflow-x-hidden rounded-[1.5rem] border border-border/50 bg-card/85 px-5 py-5 shadow-sm',
|
||||
'[&_[data-streamdown="code-block"]]:!max-w-full [&_[data-streamdown="table-wrapper"]]:!max-w-full [&_[data-streamdown="code-block"]]:overflow-x-auto [&_[data-streamdown="table-wrapper"]]:overflow-x-auto',
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
|
||||
Loading conversation...
|
||||
</div>
|
||||
) : turns.length === 0 ? (
|
||||
<EmptyConversationState agentName={agentName} />
|
||||
) : (
|
||||
<div className="w-full space-y-4">
|
||||
{turns.map((turn, index) => (
|
||||
<ConversationMessage
|
||||
key={turn.id}
|
||||
turn={turn}
|
||||
streaming={streaming && index === turns.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<div className="w-full flex-shrink-0">
|
||||
<ConversationInput
|
||||
variant="conversation"
|
||||
agents={agents}
|
||||
selectedAgentId={resolvedAgentId}
|
||||
onSelectAgent={handleSelectAgent}
|
||||
onSend={(text) => {
|
||||
void send(text)
|
||||
}}
|
||||
onCreateAgent={() => navigate('/agents')}
|
||||
streaming={streaming}
|
||||
disabled={status?.status !== 'running'}
|
||||
status={status?.status}
|
||||
placeholder={`Message ${agentName}...`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import { ImportDataHint } from '@/entrypoints/newtab/index/ImportDataHint'
|
||||
import { NewTabBranding } from '@/entrypoints/newtab/index/NewTabBranding'
|
||||
import { NewTabTip } from '@/entrypoints/newtab/index/NewTabTip'
|
||||
import { ScheduleResults } from '@/entrypoints/newtab/index/ScheduleResults'
|
||||
import { SignInHint } from '@/entrypoints/newtab/index/SignInHint'
|
||||
import { TopSites } from '@/entrypoints/newtab/index/TopSites'
|
||||
import { useActiveHint } from '@/entrypoints/newtab/index/useActiveHint'
|
||||
import { AgentCardDock } from './AgentCardDock'
|
||||
import { useAgentCommandData } from './agent-command-layout'
|
||||
import { ConversationInput } from './ConversationInput'
|
||||
import { useAgentCardData } from './useAgentCardData'
|
||||
|
||||
function AgentCommandSetupState({
|
||||
onOpenAgents,
|
||||
}: {
|
||||
onOpenAgents: () => void
|
||||
}) {
|
||||
return (
|
||||
<Card className="border-border/60 bg-card/85 shadow-sm">
|
||||
<CardContent className="flex flex-col items-center gap-4 p-6 text-center">
|
||||
<p className="max-w-xl text-muted-foreground text-sm">
|
||||
Set up OpenClaw agents to turn your new tab into an agent command
|
||||
center.
|
||||
</p>
|
||||
<Button onClick={onOpenAgents} className="gap-2">
|
||||
Open Agent Setup
|
||||
<ArrowRight className="size-4" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyAgentsState({ onOpenAgents }: { onOpenAgents: () => void }) {
|
||||
return (
|
||||
<Card className="border-border/60 bg-card/85 shadow-sm">
|
||||
<CardContent className="flex flex-col items-center gap-4 p-6 text-center">
|
||||
<p className="max-w-xl text-muted-foreground text-sm">
|
||||
OpenClaw is running, but you do not have any agents yet.
|
||||
</p>
|
||||
<Button variant="outline" onClick={onOpenAgents}>
|
||||
Create your first agent
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function OpenClawUnavailableState({
|
||||
onOpenAgents,
|
||||
}: {
|
||||
onOpenAgents: () => void
|
||||
}) {
|
||||
return (
|
||||
<Card className="border-border/60 bg-card/85 shadow-sm">
|
||||
<CardContent className="flex flex-col items-center gap-4 p-6 text-center">
|
||||
<p className="max-w-xl text-muted-foreground text-sm">
|
||||
OpenClaw is unavailable right now. Open the Agents page to restart the
|
||||
gateway or review setup.
|
||||
</p>
|
||||
<Button onClick={onOpenAgents} className="gap-2">
|
||||
Open Agent Setup
|
||||
<ArrowRight className="size-4" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export const AgentCommandHome: FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const activeHint = useActiveHint()
|
||||
const { status, agents } = useAgentCommandData()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null)
|
||||
const cardData = useAgentCardData(agents, status?.status)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (agents.length === 0) {
|
||||
if (selectedAgentId) {
|
||||
setSelectedAgentId(null)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
!selectedAgentId ||
|
||||
!agents.some((agent) => agent.agentId === selectedAgentId)
|
||||
) {
|
||||
setSelectedAgentId(agents[0].agentId)
|
||||
}
|
||||
}, [agents, selectedAgentId])
|
||||
|
||||
const handleSend = (text: string) => {
|
||||
if (!selectedAgentId) return
|
||||
navigate(`/home/agents/${selectedAgentId}?q=${encodeURIComponent(text)}`)
|
||||
}
|
||||
|
||||
const handleSelectAgent = (agent: AgentEntry) => {
|
||||
setSelectedAgentId(agent.agentId)
|
||||
}
|
||||
|
||||
const openClawStatus = status?.status
|
||||
const isSetup = openClawStatus != null && openClawStatus !== 'uninitialized'
|
||||
const shouldShowUnavailableState =
|
||||
openClawStatus != null &&
|
||||
openClawStatus !== 'running' &&
|
||||
openClawStatus !== 'uninitialized' &&
|
||||
cardData.length === 0
|
||||
|
||||
return (
|
||||
<div className="pt-[max(25vh,16px)]">
|
||||
<div className="relative w-full space-y-8 md:w-3xl">
|
||||
<NewTabBranding />
|
||||
|
||||
<ConversationInput
|
||||
variant="home"
|
||||
agents={agents}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onSelectAgent={handleSelectAgent}
|
||||
onSend={handleSend}
|
||||
onCreateAgent={() => navigate('/agents')}
|
||||
streaming={false}
|
||||
disabled={status?.status !== 'running'}
|
||||
status={status?.status}
|
||||
placeholder={
|
||||
status?.status === 'running'
|
||||
? undefined
|
||||
: 'OpenClaw is not running...'
|
||||
}
|
||||
/>
|
||||
|
||||
{mounted ? <NewTabTip /> : null}
|
||||
|
||||
{isSetup ? (
|
||||
shouldShowUnavailableState ? (
|
||||
<OpenClawUnavailableState
|
||||
onOpenAgents={() => navigate('/agents')}
|
||||
/>
|
||||
) : cardData.length > 0 ? (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-base">Agents</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Pick up where your agents left off.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<AgentCardDock
|
||||
agents={cardData}
|
||||
activeAgentId={selectedAgentId ?? undefined}
|
||||
onSelectAgent={(agentId) => navigate(`/home/agents/${agentId}`)}
|
||||
onCreateAgent={() => navigate('/agents')}
|
||||
/>
|
||||
</section>
|
||||
) : (
|
||||
<EmptyAgentsState onOpenAgents={() => navigate('/agents')} />
|
||||
)
|
||||
) : (
|
||||
<AgentCommandSetupState onOpenAgents={() => navigate('/agents')} />
|
||||
)}
|
||||
|
||||
{mounted ? <TopSites /> : null}
|
||||
{mounted ? <ScheduleResults /> : null}
|
||||
</div>
|
||||
|
||||
{activeHint === 'signin' ? <SignInHint /> : null}
|
||||
{activeHint === 'import' ? <ImportDataHint /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Bot, Check, ChevronDown, Plus } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
type AgentEntry,
|
||||
getModelDisplayName,
|
||||
} from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface AgentSelectorProps {
|
||||
agents: AgentEntry[]
|
||||
selectedAgentId: string | null
|
||||
onSelectAgent: (agent: AgentEntry) => void
|
||||
onCreateAgent?: () => void
|
||||
status?: string
|
||||
}
|
||||
|
||||
function getStatusDot(status?: string) {
|
||||
if (status === 'running') return 'bg-emerald-500'
|
||||
if (status === 'starting') return 'bg-amber-500 animate-pulse'
|
||||
if (status === 'error') return 'bg-destructive'
|
||||
return 'bg-muted-foreground/50'
|
||||
}
|
||||
|
||||
export const AgentSelector: FC<AgentSelectorProps> = ({
|
||||
agents,
|
||||
selectedAgentId,
|
||||
onSelectAgent,
|
||||
onCreateAgent,
|
||||
status,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const selectedAgent = agents.find(
|
||||
(agent) => agent.agentId === selectedAgentId,
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
|
||||
'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'data-[state=open]:bg-accent',
|
||||
)}
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
<span className={cn('size-2 rounded-full', getStatusDot(status))} />
|
||||
<span className="max-w-32 truncate">
|
||||
{selectedAgent?.name ?? 'Select agent'}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="bottom" align="start" className="w-72 p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search agents..." className="h-9" />
|
||||
<CommandList>
|
||||
<CommandEmpty>No agents found</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{agents.map((agent) => {
|
||||
const isSelected = selectedAgentId === agent.agentId
|
||||
const modelLabel = getModelDisplayName(agent.model)
|
||||
return (
|
||||
<CommandItem
|
||||
key={agent.agentId}
|
||||
value={`${agent.agentId} ${agent.name}`}
|
||||
onSelect={() => {
|
||||
onSelectAgent(agent)
|
||||
setOpen(false)
|
||||
}}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-md px-3 py-2',
|
||||
isSelected && 'bg-[var(--accent-orange)]/10',
|
||||
)}
|
||||
>
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-orange)]/10 text-[var(--accent-orange)]">
|
||||
<Bot className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium text-sm">
|
||||
{agent.name}
|
||||
</span>
|
||||
{modelLabel ? (
|
||||
<span className="block truncate text-muted-foreground text-xs">
|
||||
{modelLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{isSelected ? (
|
||||
<Check className="size-4 shrink-0 text-[var(--accent-orange)]" />
|
||||
) : null}
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
{onCreateAgent ? (
|
||||
<div className="border-border border-t p-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-muted-foreground text-sm transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={() => {
|
||||
onCreateAgent()
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
<span>Create agent</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import {
|
||||
ArrowRight,
|
||||
Bot,
|
||||
ChevronDown,
|
||||
Folder,
|
||||
Layers,
|
||||
Loader2,
|
||||
Mic,
|
||||
Square,
|
||||
} from 'lucide-react'
|
||||
import { type FC, type ReactNode, useEffect, useState } from 'react'
|
||||
import { AppSelector } from '@/components/elements/AppSelector'
|
||||
import { TabPickerPopover } from '@/components/elements/tab-picker-popover'
|
||||
import { WorkspaceSelector } from '@/components/elements/workspace-selector'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import { McpServerIcon } from '@/entrypoints/app/connect-mcp/McpServerIcon'
|
||||
import { useGetUserMCPIntegrations } from '@/entrypoints/app/connect-mcp/useGetUserMCPIntegrations'
|
||||
import { Feature } from '@/lib/browseros/capabilities'
|
||||
import { useCapabilities } from '@/lib/browseros/useCapabilities'
|
||||
import { useMcpServers } from '@/lib/mcp/mcpServerStorage'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useVoiceInput } from '@/lib/voice/useVoiceInput'
|
||||
import { useWorkspace } from '@/lib/workspace/use-workspace'
|
||||
import { AgentSelector } from './AgentSelector'
|
||||
|
||||
interface ConversationInputProps {
|
||||
agents: AgentEntry[]
|
||||
selectedAgentId: string | null
|
||||
onSelectAgent: (agent: AgentEntry) => void
|
||||
onSend: (text: string) => void
|
||||
onCreateAgent?: () => void
|
||||
streaming: boolean
|
||||
disabled?: boolean
|
||||
status?: string
|
||||
placeholder?: string
|
||||
variant?: 'home' | 'conversation'
|
||||
}
|
||||
|
||||
function InputActionButton({
|
||||
disabled,
|
||||
onClick,
|
||||
streaming,
|
||||
}: {
|
||||
disabled: boolean
|
||||
onClick: () => void
|
||||
streaming: boolean
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
onClick={onClick}
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
className="h-10 w-10 flex-shrink-0 rounded-xl bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{streaming ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function VoiceButton({
|
||||
isRecording,
|
||||
isTranscribing,
|
||||
onStart,
|
||||
onStop,
|
||||
}: {
|
||||
isRecording: boolean
|
||||
isTranscribing: boolean
|
||||
onStart: () => void
|
||||
onStop: () => void
|
||||
}) {
|
||||
if (isRecording) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
onClick={onStop}
|
||||
className="h-10 w-10 flex-shrink-0 rounded-xl bg-red-600 text-white hover:bg-red-700"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
if (isTranscribing) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled
|
||||
className="h-10 w-10 flex-shrink-0 rounded-xl"
|
||||
>
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onStart}
|
||||
className="h-10 w-10 flex-shrink-0 rounded-xl text-muted-foreground transition-colors hover:text-foreground"
|
||||
title="Voice input"
|
||||
>
|
||||
<Mic className="h-5 w-5" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextControls({
|
||||
agents,
|
||||
onCreateAgent,
|
||||
onSelectAgent,
|
||||
selectedAgentId,
|
||||
selectedTabs,
|
||||
onToggleTab,
|
||||
showAgentSelector,
|
||||
status,
|
||||
}: {
|
||||
agents: AgentEntry[]
|
||||
onCreateAgent?: () => void
|
||||
onSelectAgent: (agent: AgentEntry) => void
|
||||
selectedAgentId: string | null
|
||||
selectedTabs: chrome.tabs.Tab[]
|
||||
onToggleTab: (tab: chrome.tabs.Tab) => void
|
||||
showAgentSelector: boolean
|
||||
status?: string
|
||||
}) {
|
||||
const { supports } = useCapabilities()
|
||||
const { selectedFolder } = useWorkspace()
|
||||
const { servers: mcpServers } = useMcpServers()
|
||||
const { data: userMCPIntegrations } = useGetUserMCPIntegrations()
|
||||
|
||||
const connectedManagedServers = mcpServers.filter((server) => {
|
||||
if (server.type !== 'managed' || !server.managedServerName) return false
|
||||
return userMCPIntegrations?.integrations?.find(
|
||||
(integration) => integration.name === server.managedServerName,
|
||||
)?.is_authenticated
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between border-border/50 border-t px-5 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
{showAgentSelector ? (
|
||||
<AgentSelector
|
||||
agents={agents}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onSelectAgent={onSelectAgent}
|
||||
onCreateAgent={onCreateAgent}
|
||||
status={status}
|
||||
/>
|
||||
) : null}
|
||||
{supports(Feature.WORKSPACE_FOLDER_SUPPORT) ? (
|
||||
<WorkspaceSelector>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
|
||||
'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'data-[state=open]:bg-accent',
|
||||
)}
|
||||
>
|
||||
<Folder className="h-4 w-4" />
|
||||
<span>{selectedFolder?.name || 'Add workspace'}</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</WorkspaceSelector>
|
||||
) : null}
|
||||
<TabPickerPopover
|
||||
variant="selector"
|
||||
selectedTabs={selectedTabs}
|
||||
onToggleTab={onToggleTab}
|
||||
>
|
||||
<Button
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
|
||||
selectedTabs.length > 0
|
||||
? 'bg-[var(--accent-orange)]! text-white shadow-sm'
|
||||
: 'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'data-[state=open]:bg-accent',
|
||||
)}
|
||||
>
|
||||
<Layers className="h-4 w-4" />
|
||||
<span>Tabs</span>
|
||||
</Button>
|
||||
</TabPickerPopover>
|
||||
</div>
|
||||
|
||||
{supports(Feature.MANAGED_MCP_SUPPORT) ? (
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<AppSelector side="bottom">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
|
||||
'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'data-[state=open]:bg-accent',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center -space-x-1.5">
|
||||
{connectedManagedServers.slice(0, 4).map((server) => (
|
||||
<div
|
||||
key={server.id}
|
||||
className="rounded-full ring-2 ring-card"
|
||||
>
|
||||
<McpServerIcon
|
||||
serverName={server.managedServerName ?? ''}
|
||||
size={16}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{connectedManagedServers.length > 4 ? (
|
||||
<span className="text-xs">
|
||||
+{connectedManagedServers.length - 4}
|
||||
</span>
|
||||
) : null}
|
||||
<span>Apps</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</AppSelector>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[1.5rem] border border-border/60 bg-card/95 shadow-sm backdrop-blur">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConversationShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[1.5rem] border border-border/60 bg-card/95 shadow-sm backdrop-blur">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ConversationInput: FC<ConversationInputProps> = ({
|
||||
agents,
|
||||
selectedAgentId,
|
||||
onSelectAgent,
|
||||
onSend,
|
||||
onCreateAgent,
|
||||
streaming,
|
||||
disabled,
|
||||
status,
|
||||
placeholder,
|
||||
variant = 'conversation',
|
||||
}) => {
|
||||
const [input, setInput] = useState('')
|
||||
const [selectedTabs, setSelectedTabs] = useState<chrome.tabs.Tab[]>([])
|
||||
const voice = useVoiceInput()
|
||||
const selectedAgent = agents.find(
|
||||
(agent) => agent.agentId === selectedAgentId,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (voice.transcript && !voice.isTranscribing) {
|
||||
setInput(voice.transcript)
|
||||
voice.clearTranscript()
|
||||
}
|
||||
}, [voice.transcript, voice.isTranscribing, voice])
|
||||
|
||||
const toggleTab = (tab: chrome.tabs.Tab) => {
|
||||
setSelectedTabs((prev) => {
|
||||
const isSelected = prev.some((selected) => selected.id === tab.id)
|
||||
if (isSelected) {
|
||||
return prev.filter((selected) => selected.id !== tab.id)
|
||||
}
|
||||
return [...prev, tab]
|
||||
})
|
||||
}
|
||||
|
||||
const handleSend = () => {
|
||||
const text = input.trim()
|
||||
if (!text || streaming || disabled) return
|
||||
onSend(text)
|
||||
setInput('')
|
||||
}
|
||||
|
||||
const shell = variant === 'home' ? HomeShell : ConversationShell
|
||||
const Shell = shell
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
<div className="flex items-center gap-3 px-5 py-4">
|
||||
<BotInputIcon variant={variant} />
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
voice.isTranscribing
|
||||
? 'Transcribing...'
|
||||
: (placeholder ?? `Message ${selectedAgent?.name ?? 'agent'}...`)
|
||||
}
|
||||
disabled={disabled || voice.isTranscribing}
|
||||
className="flex-1 border-none bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground disabled:opacity-60"
|
||||
/>
|
||||
<VoiceButton
|
||||
isRecording={voice.isRecording}
|
||||
isTranscribing={voice.isTranscribing}
|
||||
onStart={() => {
|
||||
void voice.startRecording()
|
||||
}}
|
||||
onStop={() => {
|
||||
void voice.stopRecording()
|
||||
}}
|
||||
/>
|
||||
<InputActionButton
|
||||
disabled={
|
||||
!input.trim() ||
|
||||
streaming ||
|
||||
!!disabled ||
|
||||
voice.isRecording ||
|
||||
voice.isTranscribing
|
||||
}
|
||||
onClick={handleSend}
|
||||
streaming={streaming}
|
||||
/>
|
||||
</div>
|
||||
{voice.error ? (
|
||||
<div className="px-5 pb-2 text-destructive text-xs">{voice.error}</div>
|
||||
) : null}
|
||||
<ContextControls
|
||||
agents={agents}
|
||||
onCreateAgent={onCreateAgent}
|
||||
onSelectAgent={onSelectAgent}
|
||||
selectedAgentId={selectedAgentId}
|
||||
selectedTabs={selectedTabs}
|
||||
onToggleTab={toggleTab}
|
||||
showAgentSelector={variant === 'home'}
|
||||
status={status}
|
||||
/>
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
|
||||
function BotInputIcon({ variant }: { variant: 'home' | 'conversation' }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center text-[var(--accent-orange)]',
|
||||
variant === 'home'
|
||||
? 'h-10 w-10 rounded-xl bg-[var(--accent-orange)]/10'
|
||||
: 'h-9 w-9 rounded-xl bg-[var(--accent-orange)]/12',
|
||||
)}
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Bot, CheckCircle2, Loader2, XCircle } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import {
|
||||
Message,
|
||||
MessageContent,
|
||||
MessageResponse,
|
||||
} from '@/components/ai-elements/message'
|
||||
import {
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
} from '@/components/ai-elements/reasoning'
|
||||
import type { AgentConversationTurn } from '@/lib/agent-conversations/types'
|
||||
|
||||
interface ConversationMessageProps {
|
||||
turn: AgentConversationTurn
|
||||
streaming: boolean
|
||||
}
|
||||
|
||||
export const ConversationMessage: FC<ConversationMessageProps> = ({
|
||||
turn,
|
||||
streaming,
|
||||
}) => (
|
||||
<div className="space-y-3">
|
||||
<Message from="user">
|
||||
<MessageContent>
|
||||
<pre className="whitespace-pre-wrap font-sans text-sm">
|
||||
{turn.userText}
|
||||
</pre>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
|
||||
{turn.parts.length > 0 && (
|
||||
<Message from="assistant">
|
||||
<MessageContent>
|
||||
{turn.parts.map((part, i) => {
|
||||
const key = `${turn.id}-part-${i}`
|
||||
|
||||
switch (part.kind) {
|
||||
case 'thinking':
|
||||
return (
|
||||
<Reasoning
|
||||
key={key}
|
||||
className="w-full"
|
||||
isStreaming={!part.done}
|
||||
defaultOpen={!part.done}
|
||||
>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>{part.text}</ReasoningContent>
|
||||
</Reasoning>
|
||||
)
|
||||
|
||||
case 'tool-batch':
|
||||
return (
|
||||
<div key={key} className="w-full space-y-1">
|
||||
{part.tools.map((tool) => (
|
||||
<div
|
||||
key={tool.id}
|
||||
className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
{tool.status === 'running' && (
|
||||
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
{tool.status === 'completed' && (
|
||||
<CheckCircle2 className="size-3.5 text-green-500" />
|
||||
)}
|
||||
{tool.status === 'error' && (
|
||||
<XCircle className="size-3.5 text-destructive" />
|
||||
)}
|
||||
<span className="font-mono text-xs">{tool.name}</span>
|
||||
{tool.durationMs != null && (
|
||||
<span className="ml-auto text-muted-foreground text-xs">
|
||||
{(tool.durationMs / 1000).toFixed(1)}s
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'text':
|
||||
return <MessageResponse key={key}>{part.text}</MessageResponse>
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)}
|
||||
|
||||
{!turn.done && turn.parts.length === 0 && streaming && (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-full bg-[var(--accent-orange)] text-white">
|
||||
<Bot className="size-3.5" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded-xl rounded-tl-none border border-border/50 bg-card px-3 py-2.5 shadow-sm">
|
||||
<span className="size-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.3s]" />
|
||||
<span className="size-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.15s]" />
|
||||
<span className="size-1.5 animate-bounce rounded-full bg-[var(--accent-orange)]" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { FC } from 'react'
|
||||
import { Outlet, useOutletContext } from 'react-router'
|
||||
import {
|
||||
type AgentEntry,
|
||||
type OpenClawStatus,
|
||||
useOpenClawAgents,
|
||||
useOpenClawStatus,
|
||||
} from '@/entrypoints/app/agents/useOpenClaw'
|
||||
|
||||
interface AgentCommandContextValue {
|
||||
agents: AgentEntry[]
|
||||
agentsLoading: boolean
|
||||
status: OpenClawStatus | null
|
||||
statusLoading: boolean
|
||||
}
|
||||
|
||||
export const AgentCommandLayout: FC = () => {
|
||||
const { status, loading: statusLoading } = useOpenClawStatus(5000)
|
||||
const { agents, loading: agentsLoading } = useOpenClawAgents(
|
||||
status?.status === 'running' && status.controlPlaneStatus === 'connected',
|
||||
)
|
||||
|
||||
return (
|
||||
<Outlet
|
||||
context={
|
||||
{
|
||||
agents,
|
||||
agentsLoading,
|
||||
status,
|
||||
statusLoading,
|
||||
} satisfies AgentCommandContextValue
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAgentCommandData(): AgentCommandContextValue {
|
||||
return useOutletContext<AgentCommandContextValue>()
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
type AgentEntry,
|
||||
getModelDisplayName,
|
||||
type OpenClawStatus,
|
||||
} from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import { getLatestConversation } from '@/lib/agent-conversations/storage'
|
||||
import type { AgentCardData } from '@/lib/agent-conversations/types'
|
||||
|
||||
function getAgentStatusTone(
|
||||
status: OpenClawStatus['status'] | undefined,
|
||||
): AgentCardData['status'] {
|
||||
if (status === 'error') return 'error'
|
||||
if (status === 'starting') return 'working'
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
async function getAgentCardData(
|
||||
agent: AgentEntry,
|
||||
status: OpenClawStatus['status'] | undefined,
|
||||
): Promise<AgentCardData> {
|
||||
const conversation = await getLatestConversation(agent.agentId)
|
||||
const lastTurn = conversation?.turns[conversation.turns.length - 1]
|
||||
const lastTextPart = lastTurn?.parts.findLast((part) => part.kind === 'text')
|
||||
|
||||
return {
|
||||
agentId: agent.agentId,
|
||||
name: agent.name,
|
||||
model: getModelDisplayName(agent.model),
|
||||
status: getAgentStatusTone(status),
|
||||
lastMessage:
|
||||
lastTextPart?.kind === 'text'
|
||||
? lastTextPart.text.slice(0, 120)
|
||||
: undefined,
|
||||
lastMessageTimestamp: lastTurn?.timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
export function useAgentCardData(
|
||||
agents: AgentEntry[],
|
||||
status: OpenClawStatus['status'] | undefined,
|
||||
) {
|
||||
const [cardData, setCardData] = useState<AgentCardData[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
const loadCardData = async () => {
|
||||
const nextCardData = await Promise.all(
|
||||
agents.map((agent) => getAgentCardData(agent, status)),
|
||||
)
|
||||
if (active) {
|
||||
setCardData(nextCardData)
|
||||
}
|
||||
}
|
||||
|
||||
if (agents.length > 0) {
|
||||
void loadCardData()
|
||||
} else {
|
||||
setCardData([])
|
||||
}
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [agents, status])
|
||||
|
||||
return cardData
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
chatWithAgent,
|
||||
type OpenClawStreamEvent,
|
||||
} from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import {
|
||||
getLatestConversation,
|
||||
saveConversation,
|
||||
} from '@/lib/agent-conversations/storage'
|
||||
import type {
|
||||
AgentConversation,
|
||||
AgentConversationTurn,
|
||||
AssistantPart,
|
||||
} from '@/lib/agent-conversations/types'
|
||||
import { consumeSSEStream } from '@/lib/sse'
|
||||
|
||||
export function useAgentConversation(agentId: string, agentName: string) {
|
||||
const [turns, setTurns] = useState<AgentConversationTurn[]>([])
|
||||
const [streaming, setStreaming] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const sessionKeyRef = useRef('')
|
||||
const textAccRef = useRef('')
|
||||
const thinkAccRef = useRef('')
|
||||
const streamAbortRef = useRef<AbortController | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
getLatestConversation(agentId)
|
||||
.then((conv) => {
|
||||
if (!active) return
|
||||
if (conv) {
|
||||
setTurns(conv.turns)
|
||||
sessionKeyRef.current = conv.sessionKey
|
||||
} else {
|
||||
sessionKeyRef.current = crypto.randomUUID()
|
||||
}
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
sessionKeyRef.current = crypto.randomUUID()
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [agentId])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
streamAbortRef.current?.abort()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const persistTurns = (updatedTurns: AgentConversationTurn[]) => {
|
||||
const conv: AgentConversation = {
|
||||
agentId,
|
||||
agentName,
|
||||
sessionKey: sessionKeyRef.current,
|
||||
turns: updatedTurns,
|
||||
createdAt: updatedTurns[0]?.timestamp ?? Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
saveConversation(conv).catch(() => {})
|
||||
}
|
||||
|
||||
const updateCurrentTurnParts = (
|
||||
updater: (parts: AssistantPart[]) => AssistantPart[],
|
||||
) => {
|
||||
setTurns((prev) => {
|
||||
const last = prev[prev.length - 1]
|
||||
if (!last) return prev
|
||||
return [...prev.slice(0, -1), { ...last, parts: updater(last.parts) }]
|
||||
})
|
||||
}
|
||||
|
||||
const processStreamEvent = (event: OpenClawStreamEvent) => {
|
||||
switch (event.type) {
|
||||
case 'text-delta': {
|
||||
const delta = (event.data.text as string) ?? ''
|
||||
textAccRef.current += delta
|
||||
const text = textAccRef.current
|
||||
updateCurrentTurnParts((parts) => {
|
||||
const last = parts[parts.length - 1]
|
||||
if (last?.kind === 'text') {
|
||||
return [...parts.slice(0, -1), { ...last, text }]
|
||||
}
|
||||
return [...parts, { kind: 'text', text }]
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'thinking': {
|
||||
const delta = (event.data.text as string) ?? ''
|
||||
thinkAccRef.current += delta
|
||||
const text = thinkAccRef.current
|
||||
updateCurrentTurnParts((parts) => {
|
||||
const idx = parts.findIndex((p) => p.kind === 'thinking' && !p.done)
|
||||
if (idx >= 0) {
|
||||
return [
|
||||
...parts.slice(0, idx),
|
||||
{ ...parts[idx], text, done: false },
|
||||
...parts.slice(idx + 1),
|
||||
]
|
||||
}
|
||||
return [...parts, { kind: 'thinking', text, done: false }]
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool-start': {
|
||||
const tool = {
|
||||
id: (event.data.toolCallId as string) ?? crypto.randomUUID(),
|
||||
name: (event.data.toolName as string) ?? 'unknown',
|
||||
status: 'running' as const,
|
||||
}
|
||||
updateCurrentTurnParts((parts) => {
|
||||
const last = parts[parts.length - 1]
|
||||
if (last?.kind === 'tool-batch') {
|
||||
return [
|
||||
...parts.slice(0, -1),
|
||||
{ ...last, tools: [...last.tools, tool] },
|
||||
]
|
||||
}
|
||||
return [...parts, { kind: 'tool-batch', tools: [tool] }]
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool-end': {
|
||||
const toolId = event.data.toolCallId as string
|
||||
const toolStatus: 'completed' | 'error' =
|
||||
(event.data.status as string) === 'error' ? 'error' : 'completed'
|
||||
const durationMs = event.data.durationMs as number | undefined
|
||||
updateCurrentTurnParts((parts) => {
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
const part = parts[i]
|
||||
if (
|
||||
part.kind === 'tool-batch' &&
|
||||
part.tools.some((t) => t.id === toolId)
|
||||
) {
|
||||
const updatedTools = part.tools.map((t) =>
|
||||
t.id === toolId ? { ...t, status: toolStatus, durationMs } : t,
|
||||
)
|
||||
return [
|
||||
...parts.slice(0, i),
|
||||
{ ...part, tools: updatedTools },
|
||||
...parts.slice(i + 1),
|
||||
]
|
||||
}
|
||||
}
|
||||
return parts
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'done': {
|
||||
updateCurrentTurnParts((parts) =>
|
||||
parts.map((part) =>
|
||||
part.kind === 'thinking' ? { ...part, done: true } : part,
|
||||
),
|
||||
)
|
||||
setTurns((prev) => {
|
||||
const last = prev[prev.length - 1]
|
||||
if (!last) return prev
|
||||
const updated = [...prev.slice(0, -1), { ...last, done: true }]
|
||||
persistTurns(updated)
|
||||
return updated
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
const msg =
|
||||
(event.data.message as string) ??
|
||||
(event.data.error as string) ??
|
||||
'Unknown error'
|
||||
updateCurrentTurnParts((parts) => [
|
||||
...parts,
|
||||
{ kind: 'text', text: `Error: ${msg}` },
|
||||
])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const send = async (text: string) => {
|
||||
if (!text.trim() || streaming) return
|
||||
|
||||
const turn: AgentConversationTurn = {
|
||||
id: crypto.randomUUID(),
|
||||
userText: text.trim(),
|
||||
parts: [],
|
||||
done: false,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
setTurns((prev) => [...prev, turn])
|
||||
setStreaming(true)
|
||||
textAccRef.current = ''
|
||||
thinkAccRef.current = ''
|
||||
const abortController = new AbortController()
|
||||
streamAbortRef.current = abortController
|
||||
|
||||
try {
|
||||
const response = await chatWithAgent(
|
||||
agentId,
|
||||
text.trim(),
|
||||
sessionKeyRef.current,
|
||||
abortController.signal,
|
||||
)
|
||||
if (!response.ok) {
|
||||
const err = await response.text()
|
||||
updateCurrentTurnParts((parts) => [
|
||||
...parts,
|
||||
{ kind: 'text', text: `Error: ${err}` },
|
||||
])
|
||||
return
|
||||
}
|
||||
await consumeSSEStream(
|
||||
response,
|
||||
processStreamEvent,
|
||||
abortController.signal,
|
||||
)
|
||||
} catch (err) {
|
||||
if (abortController.signal.aborted) return
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
updateCurrentTurnParts((parts) => [
|
||||
...parts,
|
||||
{ kind: 'text', text: `Error: ${msg}` },
|
||||
])
|
||||
} finally {
|
||||
if (streamAbortRef.current === abortController) {
|
||||
streamAbortRef.current = null
|
||||
}
|
||||
setStreaming(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resetConversation = () => {
|
||||
streamAbortRef.current?.abort()
|
||||
streamAbortRef.current = null
|
||||
setTurns([])
|
||||
setStreaming(false)
|
||||
sessionKeyRef.current = crypto.randomUUID()
|
||||
}
|
||||
|
||||
return {
|
||||
turns,
|
||||
streaming,
|
||||
loading,
|
||||
sessionKey: sessionKeyRef.current,
|
||||
send,
|
||||
resetConversation,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
Send,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import { type FC, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Message,
|
||||
MessageContent,
|
||||
MessageResponse,
|
||||
} from '@/components/ai-elements/message'
|
||||
import {
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
} from '@/components/ai-elements/reasoning'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { consumeSSEStream } from '@/lib/sse'
|
||||
import { chatWithAgent, type OpenClawStreamEvent } from './useOpenClaw'
|
||||
|
||||
interface ToolEntry {
|
||||
id: string
|
||||
name: string
|
||||
status: 'running' | 'completed' | 'error'
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
type AssistantPart =
|
||||
| { kind: 'thinking'; text: string; done: boolean }
|
||||
| { kind: 'tool-batch'; tools: ToolEntry[] }
|
||||
| { kind: 'text'; text: string }
|
||||
|
||||
interface ChatTurn {
|
||||
id: string
|
||||
userText: string
|
||||
parts: AssistantPart[]
|
||||
done: boolean
|
||||
}
|
||||
|
||||
interface AgentChatProps {
|
||||
agentId: string
|
||||
agentName: string
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export const AgentChat: FC<AgentChatProps> = ({
|
||||
agentId,
|
||||
agentName,
|
||||
onBack,
|
||||
}) => {
|
||||
const [turns, setTurns] = useState<ChatTurn[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [streaming, setStreaming] = useState(false)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const sessionKeyRef = useRef(crypto.randomUUID())
|
||||
const streamAbortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const textAccRef = useRef('')
|
||||
const thinkAccRef = useRef('')
|
||||
|
||||
const scrollToBottom = () => {
|
||||
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight)
|
||||
}
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll on every turns change
|
||||
useEffect(() => {
|
||||
scrollToBottom()
|
||||
}, [turns])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
streamAbortRef.current?.abort()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const updateCurrentTurnParts = (
|
||||
updater: (parts: AssistantPart[]) => AssistantPart[],
|
||||
) => {
|
||||
setTurns((prev) => {
|
||||
const last = prev[prev.length - 1]
|
||||
if (!last) return prev
|
||||
return [...prev.slice(0, -1), { ...last, parts: updater(last.parts) }]
|
||||
})
|
||||
}
|
||||
|
||||
const processStreamEvent = (event: OpenClawStreamEvent) => {
|
||||
switch (event.type) {
|
||||
case 'text-delta': {
|
||||
const delta = (event.data.text as string) ?? ''
|
||||
textAccRef.current += delta
|
||||
const text = textAccRef.current
|
||||
updateCurrentTurnParts((parts) => {
|
||||
const last = parts[parts.length - 1]
|
||||
if (last?.kind === 'text') {
|
||||
return [...parts.slice(0, -1), { ...last, text }]
|
||||
}
|
||||
return [...parts, { kind: 'text', text }]
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'thinking': {
|
||||
const delta = (event.data.text as string) ?? ''
|
||||
thinkAccRef.current += delta
|
||||
const text = thinkAccRef.current
|
||||
updateCurrentTurnParts((parts) => {
|
||||
const idx = parts.findIndex((p) => p.kind === 'thinking' && !p.done)
|
||||
if (idx >= 0) {
|
||||
return [
|
||||
...parts.slice(0, idx),
|
||||
{ ...parts[idx], text, done: false },
|
||||
...parts.slice(idx + 1),
|
||||
]
|
||||
}
|
||||
return [...parts, { kind: 'thinking', text, done: false }]
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool-start': {
|
||||
const tool: ToolEntry = {
|
||||
id: (event.data.toolCallId as string) ?? crypto.randomUUID(),
|
||||
name: (event.data.toolName as string) ?? 'unknown',
|
||||
status: 'running',
|
||||
}
|
||||
updateCurrentTurnParts((parts) => {
|
||||
const last = parts[parts.length - 1]
|
||||
if (last?.kind === 'tool-batch') {
|
||||
return [
|
||||
...parts.slice(0, -1),
|
||||
{ ...last, tools: [...last.tools, tool] },
|
||||
]
|
||||
}
|
||||
return [...parts, { kind: 'tool-batch', tools: [tool] }]
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool-end': {
|
||||
const toolId = event.data.toolCallId as string
|
||||
const status =
|
||||
(event.data.status as string) === 'error' ? 'error' : 'completed'
|
||||
const durationMs = event.data.durationMs as number | undefined
|
||||
updateCurrentTurnParts((parts) => {
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
const part = parts[i]
|
||||
if (
|
||||
part.kind === 'tool-batch' &&
|
||||
part.tools.some((t) => t.id === toolId)
|
||||
) {
|
||||
const updatedTools = part.tools.map((t) =>
|
||||
t.id === toolId
|
||||
? {
|
||||
...t,
|
||||
status: status as ToolEntry['status'],
|
||||
durationMs,
|
||||
}
|
||||
: t,
|
||||
)
|
||||
return [
|
||||
...parts.slice(0, i),
|
||||
{ ...part, tools: updatedTools },
|
||||
...parts.slice(i + 1),
|
||||
]
|
||||
}
|
||||
}
|
||||
return parts
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'done': {
|
||||
updateCurrentTurnParts((parts) =>
|
||||
parts.map((part) =>
|
||||
part.kind === 'thinking' ? { ...part, done: true } : part,
|
||||
),
|
||||
)
|
||||
setTurns((prev) => {
|
||||
const last = prev[prev.length - 1]
|
||||
if (!last) return prev
|
||||
return [...prev.slice(0, -1), { ...last, done: true }]
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
const msg =
|
||||
(event.data.message as string) ??
|
||||
(event.data.error as string) ??
|
||||
'Unknown error'
|
||||
updateCurrentTurnParts((parts) => [
|
||||
...parts,
|
||||
{ kind: 'text', text: `Error: ${msg}` },
|
||||
])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
const text = input.trim()
|
||||
if (!text || streaming) return
|
||||
|
||||
const turn: ChatTurn = {
|
||||
id: crypto.randomUUID(),
|
||||
userText: text,
|
||||
parts: [],
|
||||
done: false,
|
||||
}
|
||||
setTurns((prev) => [...prev, turn])
|
||||
setInput('')
|
||||
setStreaming(true)
|
||||
|
||||
textAccRef.current = ''
|
||||
thinkAccRef.current = ''
|
||||
const abortController = new AbortController()
|
||||
streamAbortRef.current = abortController
|
||||
|
||||
try {
|
||||
const response = await chatWithAgent(
|
||||
agentId,
|
||||
text,
|
||||
sessionKeyRef.current,
|
||||
abortController.signal,
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text()
|
||||
updateCurrentTurnParts((parts) => [
|
||||
...parts,
|
||||
{ kind: 'text', text: `Error: ${err}` },
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
await consumeSSEStream(
|
||||
response,
|
||||
processStreamEvent,
|
||||
abortController.signal,
|
||||
)
|
||||
} catch (err) {
|
||||
if (abortController.signal.aborted) return
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
updateCurrentTurnParts((parts) => [
|
||||
...parts,
|
||||
{ kind: 'text', text: `Error: ${msg}` },
|
||||
])
|
||||
} finally {
|
||||
if (streamAbortRef.current === abortController) {
|
||||
streamAbortRef.current = null
|
||||
}
|
||||
setStreaming(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col">
|
||||
<div className="flex items-center gap-2 border-b px-4 py-3">
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<h2 className="font-semibold text-lg">{agentName}</h2>
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||
{turns.map((turn) => (
|
||||
<div key={turn.id} className="space-y-3">
|
||||
{/* User message */}
|
||||
<Message from="user">
|
||||
<MessageContent>
|
||||
<pre className="whitespace-pre-wrap font-sans text-sm">
|
||||
{turn.userText}
|
||||
</pre>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
|
||||
{/* Assistant response — all parts grouped */}
|
||||
{turn.parts.length > 0 && (
|
||||
<Message from="assistant">
|
||||
<MessageContent>
|
||||
{turn.parts.map((part, i) => {
|
||||
const key = `${turn.id}-part-${i}`
|
||||
|
||||
switch (part.kind) {
|
||||
case 'thinking':
|
||||
return (
|
||||
<Reasoning
|
||||
key={key}
|
||||
className="w-full"
|
||||
isStreaming={!part.done}
|
||||
defaultOpen={!part.done}
|
||||
>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>{part.text}</ReasoningContent>
|
||||
</Reasoning>
|
||||
)
|
||||
|
||||
case 'tool-batch':
|
||||
return (
|
||||
<div key={key} className="w-full space-y-1">
|
||||
{part.tools.map((tool) => (
|
||||
<div
|
||||
key={tool.id}
|
||||
className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
{tool.status === 'running' && (
|
||||
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
{tool.status === 'completed' && (
|
||||
<CheckCircle2 className="size-3.5 text-green-500" />
|
||||
)}
|
||||
{tool.status === 'error' && (
|
||||
<XCircle className="size-3.5 text-destructive" />
|
||||
)}
|
||||
<span className="font-mono text-xs">
|
||||
{tool.name}
|
||||
</span>
|
||||
{tool.durationMs != null && (
|
||||
<span className="ml-auto text-muted-foreground text-xs">
|
||||
{(tool.durationMs / 1000).toFixed(1)}s
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'text':
|
||||
return (
|
||||
<MessageResponse key={key}>
|
||||
{part.text}
|
||||
</MessageResponse>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)}
|
||||
|
||||
{/* Streaming indicator when waiting for first part */}
|
||||
{!turn.done && turn.parts.length === 0 && streaming && (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[var(--accent-orange)] text-white">
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded-xl rounded-tl-none border border-border/50 bg-card px-3 py-2.5 shadow-sm">
|
||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.3s]" />
|
||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.15s]" />
|
||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)]" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t p-4">
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}}
|
||||
placeholder="Send a message..."
|
||||
className="min-h-[44px] resize-none"
|
||||
rows={1}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || streaming}
|
||||
size="icon"
|
||||
>
|
||||
{streaming ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import {
|
||||
OPENCLAW_CONTAINER_HOME,
|
||||
OPENCLAW_TERMINAL_SHELL,
|
||||
} from '@browseros/shared/constants/openclaw'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { type FC, useEffect, useRef } from 'react'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getAgentServerUrl } from '@/lib/browseros/helpers'
|
||||
|
||||
interface AgentTerminalProps {
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
type TerminalServerMessage =
|
||||
| { type: 'output'; data: string }
|
||||
| { type: 'exit'; exitCode: number }
|
||||
| { type: 'error'; message: string }
|
||||
|
||||
const TERMINAL_HOME_DIR = OPENCLAW_CONTAINER_HOME
|
||||
const TERMINAL_FONT_FAMILY =
|
||||
'"Geist Mono", Menlo, Monaco, "Courier New", monospace'
|
||||
|
||||
function resolveCssColor(variableName: string): string {
|
||||
const probe = document.createElement('div')
|
||||
probe.style.position = 'fixed'
|
||||
probe.style.visibility = 'hidden'
|
||||
probe.style.pointerEvents = 'none'
|
||||
probe.style.color = `var(${variableName})`
|
||||
document.body.append(probe)
|
||||
const color = window.getComputedStyle(probe).color
|
||||
probe.remove()
|
||||
return color
|
||||
}
|
||||
|
||||
function withAlpha(color: string, alpha: number): string {
|
||||
const channels = color.match(/[\d.]+/g)
|
||||
if (!channels || channels.length < 3) return color
|
||||
const [red, green, blue] = channels
|
||||
return `rgb(${red} ${green} ${blue} / ${alpha})`
|
||||
}
|
||||
|
||||
function createTerminalTheme() {
|
||||
const isDark = document.documentElement.classList.contains('dark')
|
||||
const background = resolveCssColor('--background')
|
||||
const foreground = resolveCssColor('--foreground')
|
||||
const muted = resolveCssColor('--muted-foreground')
|
||||
const accent = resolveCssColor('--accent-orange')
|
||||
|
||||
return {
|
||||
background,
|
||||
foreground,
|
||||
cursor: foreground,
|
||||
cursorAccent: background,
|
||||
selectionBackground: withAlpha(accent, isDark ? 0.3 : 0.2),
|
||||
selectionForeground: foreground,
|
||||
black: isDark ? '#16131a' : '#1f1b22',
|
||||
red: isDark ? '#ef8c7c' : '#c25544',
|
||||
green: isDark ? '#9ac67c' : '#5c8754',
|
||||
yellow: isDark ? '#e5c07b' : '#b7791f',
|
||||
blue: isDark ? '#8ba9ff' : '#4667d8',
|
||||
magenta: isDark ? '#d2a8ff' : '#955ec7',
|
||||
cyan: isDark ? '#7fd4d1' : '#0f766e',
|
||||
white: isDark ? '#e8e0d9' : '#f7f1eb',
|
||||
brightBlack: muted,
|
||||
brightRed: isDark ? '#ffb0a4' : '#dc7b6d',
|
||||
brightGreen: isDark ? '#b6d99e' : '#7bab74',
|
||||
brightYellow: isDark ? '#f2d59b' : '#d49a44',
|
||||
brightBlue: isDark ? '#b3c4ff' : '#6f8cf0',
|
||||
brightMagenta: isDark ? '#e2c6ff' : '#b789dd',
|
||||
brightCyan: isDark ? '#a6ece7' : '#3aa5a0',
|
||||
brightWhite: isDark ? '#fff8f1' : '#ffffff',
|
||||
}
|
||||
}
|
||||
|
||||
function parseTerminalMessage(data: unknown): TerminalServerMessage | null {
|
||||
if (typeof data !== 'string') return null
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(data) as unknown
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === 'object' &&
|
||||
'type' in parsed &&
|
||||
parsed.type === 'output' &&
|
||||
'data' in parsed &&
|
||||
typeof parsed.data === 'string'
|
||||
) {
|
||||
return { type: 'output', data: parsed.data }
|
||||
}
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === 'object' &&
|
||||
'type' in parsed &&
|
||||
parsed.type === 'error' &&
|
||||
'message' in parsed &&
|
||||
typeof parsed.message === 'string'
|
||||
) {
|
||||
return { type: 'error', message: parsed.message }
|
||||
}
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === 'object' &&
|
||||
'type' in parsed &&
|
||||
parsed.type === 'exit' &&
|
||||
'exitCode' in parsed &&
|
||||
typeof parsed.exitCode === 'number'
|
||||
) {
|
||||
return { type: 'exit', exitCode: parsed.exitCode }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const AgentTerminal: FC<AgentTerminalProps> = ({ onBack }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
const terminal = new Terminal({
|
||||
fontSize: 14,
|
||||
fontFamily: TERMINAL_FONT_FAMILY,
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'block',
|
||||
lineHeight: 1.25,
|
||||
scrollback: 8000,
|
||||
theme: createTerminalTheme(),
|
||||
})
|
||||
|
||||
const fitAddon = new FitAddon()
|
||||
terminal.loadAddon(fitAddon)
|
||||
terminal.loadAddon(new WebLinksAddon())
|
||||
terminal.open(containerRef.current)
|
||||
|
||||
let ws: WebSocket | null = null
|
||||
let sawExit = false
|
||||
|
||||
const applyTheme = (): void => {
|
||||
terminal.options.theme = createTerminalTheme()
|
||||
}
|
||||
|
||||
const sendMessage = (
|
||||
message:
|
||||
| { type: 'input'; data: string }
|
||||
| { type: 'resize'; cols: number; rows: number },
|
||||
): void => {
|
||||
if (ws?.readyState !== WebSocket.OPEN) return
|
||||
ws.send(JSON.stringify(message))
|
||||
}
|
||||
|
||||
const sendResize = (cols = terminal.cols, rows = terminal.rows): void => {
|
||||
sendMessage({ type: 'resize', cols, rows })
|
||||
}
|
||||
|
||||
const connect = async () => {
|
||||
const baseUrl = await getAgentServerUrl()
|
||||
const wsUrl = new URL('/terminal/ws', baseUrl)
|
||||
wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
|
||||
ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
fitAddon.fit()
|
||||
terminal.focus()
|
||||
sendResize()
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const message = parseTerminalMessage(event.data)
|
||||
if (!message) return
|
||||
|
||||
if (message.type === 'output') {
|
||||
terminal.write(message.data)
|
||||
} else if (message.type === 'error') {
|
||||
terminal.write(`\r\n\x1b[31m${message.message}\x1b[0m\r\n`)
|
||||
} else {
|
||||
sawExit = true
|
||||
terminal.write(
|
||||
`\r\n\x1b[90m[session ended with exit ${message.exitCode}]\x1b[0m\r\n`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
if (sawExit) return
|
||||
terminal.write('\r\n\x1b[90m[session ended]\x1b[0m\r\n')
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
terminal.write('\r\n\x1b[31m[connection error]\x1b[0m\r\n')
|
||||
}
|
||||
|
||||
const inputDisposable = terminal.onData((data) => {
|
||||
sendMessage({ type: 'input', data })
|
||||
})
|
||||
|
||||
const resizeDisposable = terminal.onResize(({ cols, rows }) => {
|
||||
sendResize(cols, rows)
|
||||
})
|
||||
|
||||
return () => {
|
||||
inputDisposable.dispose()
|
||||
resizeDisposable.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
let disposeSocketBindings: (() => void) | undefined
|
||||
void connect().then((disposeBindings) => {
|
||||
disposeSocketBindings = disposeBindings
|
||||
})
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
fitAddon.fit()
|
||||
sendResize()
|
||||
})
|
||||
resizeObserver.observe(containerRef.current)
|
||||
|
||||
const themeObserver = new MutationObserver(() => {
|
||||
applyTheme()
|
||||
})
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
})
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
themeObserver.disconnect()
|
||||
disposeSocketBindings?.()
|
||||
ws?.close()
|
||||
terminal.dispose()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100dvh-10rem)] min-h-[32rem] w-full flex-col py-2 sm:min-h-[42rem] sm:py-4">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-border bg-card shadow-sm">
|
||||
<div className="flex items-center gap-3 border-border border-b px-4 py-3 sm:px-6">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-semibold text-sm">
|
||||
Container Terminal
|
||||
</div>
|
||||
<div className="truncate text-muted-foreground text-sm">
|
||||
OpenClaw shell in {TERMINAL_HOME_DIR}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 p-4 sm:p-6">
|
||||
<div className="agent-terminal-shell flex h-full min-h-0 flex-col overflow-hidden rounded-lg border border-border bg-background">
|
||||
<div className="flex items-center justify-between gap-3 border-border border-b px-4 py-2.5">
|
||||
<div className="truncate font-mono text-muted-foreground text-xs">
|
||||
{TERMINAL_HOME_DIR}
|
||||
</div>
|
||||
<div className="font-mono text-[11px] text-muted-foreground">
|
||||
{OPENCLAW_TERMINAL_SHELL.split('/').pop()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 px-4 py-4 sm:px-5 sm:py-5">
|
||||
<div ref={containerRef} className="h-full w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,330 @@
|
||||
import type {
|
||||
BrowserOSAgentRoleId,
|
||||
BrowserOSCustomRoleInput,
|
||||
} from '@browseros/shared/types/role-aware-agents'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { getAgentServerUrl } from '@/lib/browseros/helpers'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
|
||||
export interface AgentEntry {
|
||||
agentId: string
|
||||
name: string
|
||||
workspace: string
|
||||
model?: unknown
|
||||
role?: {
|
||||
roleSource: 'builtin' | 'custom'
|
||||
roleId?: BrowserOSAgentRoleId
|
||||
roleName: string
|
||||
shortDescription: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface RoleTemplateSummary {
|
||||
id: BrowserOSAgentRoleId
|
||||
name: string
|
||||
shortDescription: string
|
||||
longDescription: string
|
||||
recommendedApps: string[]
|
||||
defaultAgentName: string
|
||||
boundaries: Array<{
|
||||
key: string
|
||||
label: string
|
||||
description: string
|
||||
defaultMode: 'allow' | 'ask' | 'block'
|
||||
}>
|
||||
}
|
||||
|
||||
export interface OpenClawStatus {
|
||||
status: 'uninitialized' | 'starting' | 'running' | 'stopped' | 'error'
|
||||
podmanAvailable: boolean
|
||||
machineReady: boolean
|
||||
port: number | null
|
||||
agentCount: number
|
||||
error: string | null
|
||||
controlPlaneStatus:
|
||||
| 'disconnected'
|
||||
| 'connecting'
|
||||
| 'connected'
|
||||
| 'reconnecting'
|
||||
| 'recovering'
|
||||
| 'failed'
|
||||
lastGatewayError: string | null
|
||||
lastRecoveryReason:
|
||||
| 'transient_disconnect'
|
||||
| 'signature_expired'
|
||||
| 'pairing_required'
|
||||
| 'token_mismatch'
|
||||
| 'container_not_ready'
|
||||
| 'unknown'
|
||||
| null
|
||||
}
|
||||
|
||||
export interface OpenClawAgentMutationInput {
|
||||
name: string
|
||||
roleId?: BrowserOSAgentRoleId
|
||||
customRole?: BrowserOSCustomRoleInput
|
||||
providerType?: string
|
||||
providerName?: string
|
||||
baseUrl?: string
|
||||
apiKey?: string
|
||||
modelId?: string
|
||||
}
|
||||
|
||||
export interface OpenClawSetupInput {
|
||||
providerType?: string
|
||||
providerName?: string
|
||||
baseUrl?: string
|
||||
apiKey?: string
|
||||
modelId?: string
|
||||
}
|
||||
|
||||
export function getModelDisplayName(model: unknown): string | undefined {
|
||||
if (typeof model === 'string') return model.split('/').pop()
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const OPENCLAW_QUERY_KEYS = {
|
||||
status: 'openclaw-status',
|
||||
agents: 'openclaw-agents',
|
||||
roles: 'openclaw-roles',
|
||||
} as const
|
||||
|
||||
async function clawFetch<T>(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
const res = await fetch(`${baseUrl}/claw${path}`, init)
|
||||
if (!res.ok) {
|
||||
let message = `Request failed with status ${res.status}`
|
||||
try {
|
||||
const body = (await res.json()) as { error?: string }
|
||||
if (body.error) {
|
||||
message = body.error
|
||||
}
|
||||
} catch {}
|
||||
throw new Error(message)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
async function fetchOpenClawStatus(baseUrl: string): Promise<OpenClawStatus> {
|
||||
return clawFetch<OpenClawStatus>(baseUrl, '/status')
|
||||
}
|
||||
|
||||
async function fetchOpenClawAgents(baseUrl: string): Promise<AgentEntry[]> {
|
||||
const data = await clawFetch<{ agents: AgentEntry[] }>(baseUrl, '/agents')
|
||||
return data.agents ?? []
|
||||
}
|
||||
|
||||
async function fetchOpenClawRoles(
|
||||
baseUrl: string,
|
||||
): Promise<RoleTemplateSummary[]> {
|
||||
const data = await clawFetch<{ roles: RoleTemplateSummary[] }>(
|
||||
baseUrl,
|
||||
'/roles',
|
||||
)
|
||||
return data.roles ?? []
|
||||
}
|
||||
|
||||
async function invalidateOpenClawQueries(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
): Promise<void> {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: [OPENCLAW_QUERY_KEYS.status] }),
|
||||
queryClient.invalidateQueries({ queryKey: [OPENCLAW_QUERY_KEYS.agents] }),
|
||||
])
|
||||
}
|
||||
|
||||
export function useOpenClawStatus(pollMs = 5000) {
|
||||
const {
|
||||
baseUrl,
|
||||
isLoading: urlLoading,
|
||||
error: urlError,
|
||||
} = useAgentServerUrl()
|
||||
|
||||
const query = useQuery<OpenClawStatus, Error>({
|
||||
queryKey: [OPENCLAW_QUERY_KEYS.status, baseUrl],
|
||||
queryFn: () => fetchOpenClawStatus(baseUrl as string),
|
||||
enabled: !!baseUrl && !urlLoading,
|
||||
refetchInterval: pollMs,
|
||||
})
|
||||
|
||||
return {
|
||||
status: query.data ?? null,
|
||||
loading: query.isLoading || urlLoading,
|
||||
error: query.error ?? urlError,
|
||||
refetch: query.refetch,
|
||||
}
|
||||
}
|
||||
|
||||
export function useOpenClawAgents(enabled = true) {
|
||||
const {
|
||||
baseUrl,
|
||||
isLoading: urlLoading,
|
||||
error: urlError,
|
||||
} = useAgentServerUrl()
|
||||
|
||||
const query = useQuery<AgentEntry[], Error>({
|
||||
queryKey: [OPENCLAW_QUERY_KEYS.agents, baseUrl],
|
||||
queryFn: () => fetchOpenClawAgents(baseUrl as string),
|
||||
enabled: !!baseUrl && !urlLoading && enabled,
|
||||
})
|
||||
|
||||
return {
|
||||
agents: query.data ?? [],
|
||||
loading: query.isLoading || urlLoading,
|
||||
error: query.error ?? urlError,
|
||||
refetch: query.refetch,
|
||||
}
|
||||
}
|
||||
|
||||
export function useOpenClawRoles() {
|
||||
const {
|
||||
baseUrl,
|
||||
isLoading: urlLoading,
|
||||
error: urlError,
|
||||
} = useAgentServerUrl()
|
||||
|
||||
const query = useQuery<RoleTemplateSummary[], Error>({
|
||||
queryKey: [OPENCLAW_QUERY_KEYS.roles, baseUrl],
|
||||
queryFn: () => fetchOpenClawRoles(baseUrl as string),
|
||||
enabled: !!baseUrl && !urlLoading,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
return {
|
||||
roles: query.data ?? [],
|
||||
loading: query.isLoading || urlLoading,
|
||||
error: query.error ?? urlError,
|
||||
refetch: query.refetch,
|
||||
}
|
||||
}
|
||||
|
||||
export function useOpenClawMutations() {
|
||||
const { baseUrl, isLoading: urlLoading } = useAgentServerUrl()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const ensureBaseUrl = () => {
|
||||
if (!baseUrl || urlLoading) {
|
||||
throw new Error('BrowserOS agent server URL is not ready')
|
||||
}
|
||||
return baseUrl
|
||||
}
|
||||
|
||||
const onSuccess = () => invalidateOpenClawQueries(queryClient)
|
||||
|
||||
const setupMutation = useMutation({
|
||||
mutationFn: async (input: OpenClawSetupInput) =>
|
||||
clawFetch<{ status: string; agents: AgentEntry[] }>(
|
||||
ensureBaseUrl(),
|
||||
'/setup',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
},
|
||||
),
|
||||
onSuccess,
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (input: OpenClawAgentMutationInput) =>
|
||||
clawFetch<{ agent: AgentEntry }>(ensureBaseUrl(), '/agents', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
onSuccess,
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: string) =>
|
||||
clawFetch<{ success: boolean }>(ensureBaseUrl(), `/agents/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
onSuccess,
|
||||
})
|
||||
|
||||
const startMutation = useMutation({
|
||||
mutationFn: async () =>
|
||||
clawFetch<{ status: string }>(ensureBaseUrl(), '/start', {
|
||||
method: 'POST',
|
||||
}),
|
||||
onSuccess,
|
||||
})
|
||||
|
||||
const stopMutation = useMutation({
|
||||
mutationFn: async () =>
|
||||
clawFetch<{ status: string }>(ensureBaseUrl(), '/stop', {
|
||||
method: 'POST',
|
||||
}),
|
||||
onSuccess,
|
||||
})
|
||||
|
||||
const restartMutation = useMutation({
|
||||
mutationFn: async () =>
|
||||
clawFetch<{ status: string }>(ensureBaseUrl(), '/restart', {
|
||||
method: 'POST',
|
||||
}),
|
||||
onSuccess,
|
||||
})
|
||||
|
||||
const reconnectMutation = useMutation({
|
||||
mutationFn: async () =>
|
||||
clawFetch<{ status: string }>(ensureBaseUrl(), '/reconnect', {
|
||||
method: 'POST',
|
||||
}),
|
||||
onSuccess,
|
||||
})
|
||||
|
||||
return {
|
||||
setupOpenClaw: setupMutation.mutateAsync,
|
||||
createAgent: createMutation.mutateAsync,
|
||||
deleteAgent: deleteMutation.mutateAsync,
|
||||
startOpenClaw: startMutation.mutateAsync,
|
||||
stopOpenClaw: stopMutation.mutateAsync,
|
||||
restartOpenClaw: restartMutation.mutateAsync,
|
||||
reconnectOpenClaw: reconnectMutation.mutateAsync,
|
||||
actionInProgress:
|
||||
setupMutation.isPending ||
|
||||
createMutation.isPending ||
|
||||
deleteMutation.isPending ||
|
||||
startMutation.isPending ||
|
||||
stopMutation.isPending ||
|
||||
restartMutation.isPending ||
|
||||
reconnectMutation.isPending,
|
||||
settingUp: setupMutation.isPending,
|
||||
creating: createMutation.isPending,
|
||||
deleting: deleteMutation.isPending,
|
||||
reconnecting: reconnectMutation.isPending,
|
||||
}
|
||||
}
|
||||
|
||||
export interface OpenClawStreamEvent {
|
||||
type:
|
||||
| 'text-delta'
|
||||
| 'thinking'
|
||||
| 'tool-start'
|
||||
| 'tool-end'
|
||||
| 'tool-output'
|
||||
| 'lifecycle'
|
||||
| 'done'
|
||||
| 'error'
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
export async function chatWithAgent(
|
||||
agentId: string,
|
||||
message: string,
|
||||
sessionKey?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Response> {
|
||||
const baseUrl = await getAgentServerUrl()
|
||||
return fetch(`${baseUrl}/claw/agents/${agentId}/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message, sessionKey }),
|
||||
signal,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
Bot,
|
||||
Camera,
|
||||
Code,
|
||||
Database,
|
||||
Eye,
|
||||
Hand,
|
||||
MousePointerClick,
|
||||
Navigation,
|
||||
} from 'lucide-react'
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
normalizeToolApprovalConfig,
|
||||
toolApprovalConfigStorage,
|
||||
} from '@/lib/tool-approvals/storage'
|
||||
import {
|
||||
TOOL_CATEGORIES,
|
||||
type ToolApprovalConfig,
|
||||
} from '@/lib/tool-approvals/types'
|
||||
|
||||
const CATEGORY_ICONS: Record<string, typeof Hand> = {
|
||||
input: MousePointerClick,
|
||||
navigation: Navigation,
|
||||
observation: Eye,
|
||||
screenshots: Camera,
|
||||
scripts: Code,
|
||||
'data-modification': Database,
|
||||
assistant: Bot,
|
||||
}
|
||||
|
||||
export const ToolApprovalsPage: FC = () => {
|
||||
const [config, setConfig] = useState<ToolApprovalConfig>({ categories: {} })
|
||||
|
||||
useEffect(() => {
|
||||
const applyConfig = (value: ToolApprovalConfig) =>
|
||||
setConfig(normalizeToolApprovalConfig(value))
|
||||
|
||||
toolApprovalConfigStorage.getValue().then(applyConfig)
|
||||
const unwatch = toolApprovalConfigStorage.watch(applyConfig)
|
||||
return () => unwatch()
|
||||
}, [])
|
||||
|
||||
const allEnabled =
|
||||
TOOL_CATEGORIES.length > 0 &&
|
||||
TOOL_CATEGORIES.every((category) => config.categories[category.id] === true)
|
||||
|
||||
const toggleCategory = (categoryId: string, enabled: boolean) => {
|
||||
const next = {
|
||||
...config,
|
||||
categories: { ...config.categories, [categoryId]: enabled },
|
||||
}
|
||||
setConfig(next)
|
||||
toolApprovalConfigStorage.setValue(normalizeToolApprovalConfig(next))
|
||||
}
|
||||
|
||||
const toggleAll = (enabled: boolean) => {
|
||||
const categories: Record<string, boolean> = {}
|
||||
for (const cat of TOOL_CATEGORIES) {
|
||||
categories[cat.id] = enabled
|
||||
}
|
||||
const next = { ...config, categories }
|
||||
setConfig(next)
|
||||
toolApprovalConfigStorage.setValue(normalizeToolApprovalConfig(next))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="font-semibold text-xl tracking-tight">Tool Approvals</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Require human approval before the agent executes certain actions.
|
||||
Changes apply immediately.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border bg-card p-4">
|
||||
<div className="space-y-0.5">
|
||||
<div className="font-medium text-sm">Require approval for all</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Toggle all categories at once
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={allEnabled} onCheckedChange={toggleAll} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{TOOL_CATEGORIES.map((category) => {
|
||||
const Icon = CATEGORY_ICONS[category.id] ?? Hand
|
||||
const enabled = config.categories[category.id] ?? false
|
||||
|
||||
return (
|
||||
<div
|
||||
key={category.id}
|
||||
className="flex items-start gap-4 rounded-lg border bg-card p-4 transition-colors"
|
||||
>
|
||||
<div className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-md bg-muted">
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{category.name}</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{category.description}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleCategory(category.id, checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,21 +2,20 @@ import type { FC } from 'react'
|
||||
import { Outlet, useLocation } from 'react-router'
|
||||
import { ChatSessionProvider } from '@/entrypoints/sidepanel/layout/ChatSessionContext'
|
||||
import { NewTabFocusGrid } from './NewTabFocusGrid'
|
||||
|
||||
const HIDE_FOCUS_GRID_PATHS = new Set([
|
||||
'/home/soul',
|
||||
'/home/memory',
|
||||
'/home/skills',
|
||||
'/home/chat',
|
||||
])
|
||||
import { shouldHideFocusGrid, shouldUseChatSession } from './route-utils'
|
||||
|
||||
export const NewTabLayout: FC = () => {
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<ChatSessionProvider origin="newtab">
|
||||
{!HIDE_FOCUS_GRID_PATHS.has(location.pathname) && <NewTabFocusGrid />}
|
||||
const hideGrid = shouldHideFocusGrid(location.pathname)
|
||||
const useChatSession = shouldUseChatSession(location.pathname)
|
||||
const content = (
|
||||
<>
|
||||
{!hideGrid && <NewTabFocusGrid />}
|
||||
<Outlet />
|
||||
</ChatSessionProvider>
|
||||
</>
|
||||
)
|
||||
|
||||
if (!useChatSession) return content
|
||||
|
||||
return <ChatSessionProvider origin="newtab">{content}</ChatSessionProvider>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
isAgentCommandPath,
|
||||
isAgentConversationPath,
|
||||
shouldHideFocusGrid,
|
||||
shouldUseChatSession,
|
||||
} from './route-utils'
|
||||
|
||||
describe('route-utils', () => {
|
||||
it('treats command center routes as non-chat-session paths', () => {
|
||||
expect(isAgentCommandPath('/home')).toBe(true)
|
||||
expect(isAgentCommandPath('/home/agents/main')).toBe(true)
|
||||
expect(isAgentConversationPath('/home')).toBe(false)
|
||||
expect(isAgentConversationPath('/home/agents/main')).toBe(true)
|
||||
expect(shouldUseChatSession('/home')).toBe(false)
|
||||
expect(shouldUseChatSession('/home/agents/main')).toBe(false)
|
||||
expect(shouldUseChatSession('/home/chat')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the focus grid on home while hiding it on dedicated full-screen routes', () => {
|
||||
expect(shouldHideFocusGrid('/home')).toBe(false)
|
||||
expect(shouldHideFocusGrid('/home/agents/main')).toBe(true)
|
||||
expect(shouldHideFocusGrid('/home/chat')).toBe(true)
|
||||
expect(shouldHideFocusGrid('/home/skills')).toBe(true)
|
||||
expect(shouldHideFocusGrid('/home/personalize')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
const HIDE_FOCUS_GRID_PATHS = new Set([
|
||||
'/home/soul',
|
||||
'/home/memory',
|
||||
'/home/skills',
|
||||
'/home/chat',
|
||||
])
|
||||
|
||||
export function isAgentCommandPath(pathname: string): boolean {
|
||||
return pathname === '/home' || isAgentConversationPath(pathname)
|
||||
}
|
||||
|
||||
export function isAgentConversationPath(pathname: string): boolean {
|
||||
return pathname.startsWith('/home/agents/')
|
||||
}
|
||||
|
||||
export function shouldHideFocusGrid(pathname: string): boolean {
|
||||
return (
|
||||
HIDE_FOCUS_GRID_PATHS.has(pathname) || isAgentConversationPath(pathname)
|
||||
)
|
||||
}
|
||||
|
||||
export function shouldUseChatSession(pathname: string): boolean {
|
||||
return pathname === '/home/chat'
|
||||
}
|
||||
@@ -43,6 +43,7 @@ export const Chat = () => {
|
||||
disliked,
|
||||
onClickDislike,
|
||||
isRestoringConversation,
|
||||
addToolApprovalResponse,
|
||||
} = useChatSessionContext()
|
||||
|
||||
const {
|
||||
@@ -222,6 +223,12 @@ export const Chat = () => {
|
||||
showDontShowAgain={showDontShowAgain}
|
||||
onTakeSurvey={onTakeSurvey}
|
||||
onDismissJtbdPopup={onDismissJtbdPopup}
|
||||
onToolApprove={(id) =>
|
||||
addToolApprovalResponse({ id, approved: true })
|
||||
}
|
||||
onToolDeny={(id) =>
|
||||
addToolApprovalResponse({ id, approved: false })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{agentUrlError && (
|
||||
|
||||
@@ -37,6 +37,8 @@ interface ChatMessagesProps {
|
||||
showDontShowAgain: boolean
|
||||
onTakeSurvey: (opts?: { dontShowAgain?: boolean }) => void
|
||||
onDismissJtbdPopup: (dontShowAgain: boolean) => void
|
||||
onToolApprove?: (approvalId: string) => void
|
||||
onToolDeny?: (approvalId: string) => void
|
||||
}
|
||||
|
||||
export const ChatMessages: FC<ChatMessagesProps> = ({
|
||||
@@ -51,6 +53,8 @@ export const ChatMessages: FC<ChatMessagesProps> = ({
|
||||
showDontShowAgain,
|
||||
onTakeSurvey,
|
||||
onDismissJtbdPopup,
|
||||
onToolApprove,
|
||||
onToolDeny,
|
||||
}) => {
|
||||
const isStreaming = status === 'streaming' || status === 'submitted'
|
||||
|
||||
@@ -114,6 +118,8 @@ export const ChatMessages: FC<ChatMessagesProps> = ({
|
||||
isLastBatch={segment.key === lastToolBatchKey}
|
||||
isLastMessage={isLastMessage}
|
||||
isStreaming={isStreaming}
|
||||
onApprove={onToolApprove}
|
||||
onDeny={onToolDeny}
|
||||
/>
|
||||
)
|
||||
case 'nudge':
|
||||
|
||||
@@ -2,16 +2,20 @@ import {
|
||||
BotIcon,
|
||||
CheckCircle2,
|
||||
CircleDashed,
|
||||
Clock,
|
||||
Loader2,
|
||||
ShieldCheck,
|
||||
ShieldX,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import {
|
||||
Task,
|
||||
TaskContent,
|
||||
TaskItem,
|
||||
TaskTrigger,
|
||||
} from '@/components/ai-elements/task'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type {
|
||||
ToolInvocationInfo,
|
||||
ToolInvocationState,
|
||||
@@ -22,6 +26,8 @@ interface ToolBatchProps {
|
||||
isLastBatch: boolean
|
||||
isLastMessage: boolean
|
||||
isStreaming: boolean
|
||||
onApprove?: (approvalId: string) => void
|
||||
onDeny?: (approvalId: string) => void
|
||||
}
|
||||
|
||||
export const ToolBatch: FC<ToolBatchProps> = ({
|
||||
@@ -29,12 +35,20 @@ export const ToolBatch: FC<ToolBatchProps> = ({
|
||||
isLastBatch,
|
||||
isLastMessage,
|
||||
isStreaming,
|
||||
onApprove,
|
||||
onDeny,
|
||||
}) => {
|
||||
const shouldBeOpen = isLastMessage && isLastBatch && isStreaming
|
||||
const hasPendingApproval = tools.some((t) => t.state === 'approval-requested')
|
||||
const shouldBeOpen =
|
||||
(isLastMessage && isLastBatch && isStreaming) || hasPendingApproval
|
||||
const [isOpen, setIsOpen] = useState(shouldBeOpen)
|
||||
const [hasUserInteracted, setHasUserInteracted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (hasPendingApproval) {
|
||||
setIsOpen(true)
|
||||
return
|
||||
}
|
||||
if (isLastMessage && !hasUserInteracted) {
|
||||
if (isLastBatch) {
|
||||
setIsOpen(isStreaming)
|
||||
@@ -42,9 +56,18 @@ export const ToolBatch: FC<ToolBatchProps> = ({
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
}, [isStreaming, isLastMessage, isLastBatch, hasUserInteracted])
|
||||
}, [
|
||||
isStreaming,
|
||||
isLastMessage,
|
||||
isLastBatch,
|
||||
hasUserInteracted,
|
||||
hasPendingApproval,
|
||||
])
|
||||
|
||||
const completedCount = tools.filter((t) => isToolCompleted(t.state)).length
|
||||
const triggerTitle = hasPendingApproval
|
||||
? 'Waiting for approval...'
|
||||
: `${completedCount}/${tools.length} actions completed`
|
||||
|
||||
const onManualToggle = (newState: boolean) => {
|
||||
setHasUserInteracted(true)
|
||||
@@ -53,16 +76,23 @@ export const ToolBatch: FC<ToolBatchProps> = ({
|
||||
|
||||
return (
|
||||
<Task open={isOpen} onOpenChange={onManualToggle}>
|
||||
<TaskTrigger
|
||||
title={`${completedCount}/${tools.length} actions completed`}
|
||||
TriggerIcon={BotIcon}
|
||||
/>
|
||||
<TaskTrigger title={triggerTitle} TriggerIcon={BotIcon} />
|
||||
<TaskContent>
|
||||
{tools.map((tool) => (
|
||||
<TaskItem key={tool.toolCallId} className="flex items-center gap-2">
|
||||
<ToolStatusIcon state={tool.state} />
|
||||
<span>{formatToolName(tool.toolName)}</span>
|
||||
</TaskItem>
|
||||
<div key={tool.toolCallId}>
|
||||
<TaskItem className="flex items-center gap-2">
|
||||
<ToolStatusIcon state={tool.state} />
|
||||
<span className="flex-1">{formatToolName(tool.toolName)}</span>
|
||||
</TaskItem>
|
||||
{tool.state === 'approval-requested' &&
|
||||
tool.approval?.id != null && (
|
||||
<ApprovalButtons
|
||||
approvalId={tool.approval.id}
|
||||
onApprove={onApprove}
|
||||
onDeny={onDeny}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</TaskContent>
|
||||
</Task>
|
||||
@@ -84,10 +114,47 @@ const isToolInProgress = (state: ToolInvocationState) =>
|
||||
|
||||
const isToolError = (state: ToolInvocationState) => state === 'output-error'
|
||||
|
||||
const isToolDenied = (state: ToolInvocationState) => state === 'output-denied'
|
||||
|
||||
const isToolApprovalPending = (state: ToolInvocationState) =>
|
||||
state === 'approval-requested'
|
||||
|
||||
const ApprovalButtons: FC<{
|
||||
approvalId: string
|
||||
onApprove?: (id: string) => void
|
||||
onDeny?: (id: string) => void
|
||||
}> = ({ approvalId, onApprove, onDeny }) => (
|
||||
<div className="mt-1 mb-2 ml-6 flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2.5 text-xs"
|
||||
onClick={() => onApprove?.(approvalId)}
|
||||
>
|
||||
<ShieldCheck className="size-3" />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 gap-1 px-2.5 text-xs"
|
||||
onClick={() => onDeny?.(approvalId)}
|
||||
>
|
||||
<ShieldX className="size-3" />
|
||||
Deny
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
const ToolStatusIcon: FC<{ state: ToolInvocationState }> = ({ state }) => {
|
||||
if (isToolCompleted(state)) {
|
||||
return <CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
|
||||
}
|
||||
if (isToolApprovalPending(state)) {
|
||||
return <Clock className="h-3.5 w-3.5 text-yellow-500" />
|
||||
}
|
||||
if (isToolDenied(state)) {
|
||||
return <ShieldX className="h-3.5 w-3.5 text-red-400" />
|
||||
}
|
||||
if (isToolInProgress(state)) {
|
||||
return (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-[var(--accent-orange)]" />
|
||||
|
||||
@@ -8,6 +8,9 @@ export type ToolInvocationState =
|
||||
| 'input-available'
|
||||
| 'output-available'
|
||||
| 'output-error'
|
||||
| 'approval-requested'
|
||||
| 'approval-responded'
|
||||
| 'output-denied'
|
||||
|
||||
export interface ToolInvocationInfo {
|
||||
state: ToolInvocationState
|
||||
@@ -15,6 +18,7 @@ export interface ToolInvocationInfo {
|
||||
toolName: string
|
||||
input: Record<string, unknown>
|
||||
output: unknown[]
|
||||
approval?: { id: string; approved?: boolean; reason?: string }
|
||||
}
|
||||
|
||||
export type NudgeType = 'schedule_suggestion' | 'app_connection'
|
||||
@@ -106,6 +110,7 @@ export const getMessageSegments = (
|
||||
state: ToolInvocationState
|
||||
input: Record<string, unknown>
|
||||
output: unknown
|
||||
approval?: { id: string; approved?: boolean; reason?: string }
|
||||
}
|
||||
const toolName = toolPart.type?.replace('tool-', '')
|
||||
|
||||
@@ -127,6 +132,7 @@ export const getMessageSegments = (
|
||||
toolName,
|
||||
input: toolPart?.input ?? {},
|
||||
output: (toolPart?.output as unknown[]) ?? [],
|
||||
approval: toolPart?.approval,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useChat } from '@ai-sdk/react'
|
||||
import { DefaultChatTransport, type UIMessage } from 'ai'
|
||||
import { compact } from 'es-toolkit/array'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
import useDeepCompareEffect from 'use-deep-compare-effect'
|
||||
import type { Provider } from '@/components/chat/chatComponentTypes'
|
||||
import { aclRulesStorage } from '@/lib/acl/storage'
|
||||
import { Capabilities, Feature } from '@/lib/browseros/capabilities'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
import type { ChatAction } from '@/lib/chat-actions/types'
|
||||
@@ -26,17 +27,59 @@ import { declinedAppsStorage } from '@/lib/declined-apps/storage'
|
||||
import { useGraphqlQuery } from '@/lib/graphql/useGraphqlQuery'
|
||||
import { createDefaultBrowserOSProvider } from '@/lib/llm-providers/storage'
|
||||
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
|
||||
import {
|
||||
type ApprovalResponseData,
|
||||
buildChatRequestBody,
|
||||
type ChatRequestBrowserContext,
|
||||
} from '@/lib/messaging/server/buildChatRequestBody'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import { searchActionsStorage } from '@/lib/search-actions/searchActionsStorage'
|
||||
import { selectedTextStorage } from '@/lib/selected-text/selectedTextStorage'
|
||||
import { stopAgentStorage } from '@/lib/stop-agent/stop-agent-storage'
|
||||
import {
|
||||
type ApprovalResponse,
|
||||
approvalResponsesStorage,
|
||||
extractPendingApprovals,
|
||||
pendingToolApprovalsStorage,
|
||||
removeApprovalResponsesById,
|
||||
removePendingApprovalsById,
|
||||
replacePendingApprovalsForConversation,
|
||||
} from '@/lib/tool-approvals/approval-sync-storage'
|
||||
import {
|
||||
normalizeToolApprovalConfig,
|
||||
toolApprovalConfigStorage,
|
||||
} from '@/lib/tool-approvals/storage'
|
||||
import { selectedWorkspaceStorage } from '@/lib/workspace/workspace-storage'
|
||||
import type { ChatMode } from './chatTypes'
|
||||
import { GetConversationWithMessagesDocument } from './graphql/chatSessionDocument'
|
||||
import { useChatRefs } from './useChatRefs'
|
||||
import { useExecutionHistoryTracker } from './useExecutionHistoryTracker'
|
||||
import { useNotifyActiveTab } from './useNotifyActiveTab'
|
||||
import { useRemoteConversationSave } from './useRemoteConversationSave'
|
||||
|
||||
const extractApprovalResponses = (
|
||||
messages: UIMessage[],
|
||||
): ApprovalResponseData[] | null => {
|
||||
const lastMsg = messages[messages.length - 1]
|
||||
if (lastMsg?.role !== 'assistant') return null
|
||||
|
||||
const approvals: ApprovalResponseData[] = []
|
||||
for (const part of lastMsg.parts) {
|
||||
const p = part as {
|
||||
state?: string
|
||||
approval?: { id: string; approved?: boolean; reason?: string }
|
||||
}
|
||||
if (p.state === 'approval-responded' && p.approval?.approved != null) {
|
||||
approvals.push({
|
||||
approvalId: p.approval.id,
|
||||
approved: p.approval.approved,
|
||||
reason: p.approval.reason,
|
||||
})
|
||||
}
|
||||
}
|
||||
return approvals.length > 0 ? approvals : null
|
||||
}
|
||||
|
||||
const getLastMessageText = (messages: UIMessage[]) => {
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
if (!lastMessage) return ''
|
||||
@@ -46,6 +89,15 @@ const getLastMessageText = (messages: UIMessage[]) => {
|
||||
.join('')
|
||||
}
|
||||
|
||||
const getLastUserMessageText = (messages: UIMessage[]) => {
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
if (messages[i]?.role === 'user') {
|
||||
return getLastMessageText([messages[i]])
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export const getResponseAndQueryFromMessageId = (
|
||||
messages: UIMessage[],
|
||||
messageId: string,
|
||||
@@ -76,6 +128,61 @@ export interface ChatSessionOptions {
|
||||
isIntegrationsSynced?: boolean
|
||||
}
|
||||
|
||||
const NEWTAB_SYSTEM_PROMPT = `IMPORTANT: The user is chatting from the New Tab page. When performing browser actions, ALWAYS open content in a NEW TAB rather than navigating the current tab. The user's new tab page should remain accessible.`
|
||||
|
||||
const getUserSystemPrompt = (
|
||||
origin: ChatOrigin | undefined,
|
||||
personalization: string,
|
||||
) =>
|
||||
origin === 'newtab'
|
||||
? [personalization, NEWTAB_SYSTEM_PROMPT].filter(Boolean).join('\n\n')
|
||||
: personalization
|
||||
|
||||
const buildRequestBrowserContext = ({
|
||||
activeTab,
|
||||
action,
|
||||
enabledMcpServers,
|
||||
customMcpServers,
|
||||
}: {
|
||||
activeTab?: chrome.tabs.Tab
|
||||
action?: ChatAction
|
||||
enabledMcpServers: Array<string | undefined>
|
||||
customMcpServers: {
|
||||
name: string
|
||||
url?: string
|
||||
}[]
|
||||
}): ChatRequestBrowserContext | undefined => {
|
||||
const browserContext: ChatRequestBrowserContext = {}
|
||||
|
||||
if (activeTab) {
|
||||
browserContext.windowId = activeTab.windowId
|
||||
browserContext.activeTab = {
|
||||
id: activeTab.id,
|
||||
url: activeTab.url,
|
||||
title: activeTab.title,
|
||||
}
|
||||
}
|
||||
|
||||
if (action?.tabs?.length) {
|
||||
browserContext.selectedTabs = action.tabs.map((tab) => ({
|
||||
id: tab.id,
|
||||
url: tab.url,
|
||||
title: tab.title,
|
||||
}))
|
||||
}
|
||||
|
||||
const managedMcpServers = compact(enabledMcpServers)
|
||||
if (managedMcpServers.length) {
|
||||
browserContext.enabledMcpServers = managedMcpServers
|
||||
}
|
||||
|
||||
if (customMcpServers.length) {
|
||||
browserContext.customMcpServers = customMcpServers
|
||||
}
|
||||
|
||||
return Object.keys(browserContext).length ? browserContext : undefined
|
||||
}
|
||||
|
||||
export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
const {
|
||||
selectedLlmProviderRef,
|
||||
@@ -130,6 +237,12 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
conversationIdRef.current = conversationId
|
||||
}, [conversationId])
|
||||
|
||||
const {
|
||||
startTask: startExecutionTask,
|
||||
syncFromMessages: syncExecutionHistory,
|
||||
finishTask: finishExecutionTask,
|
||||
} = useExecutionHistoryTracker()
|
||||
|
||||
const onClickLike = (messageId: string) => {
|
||||
const { responseText, queryText } = getResponseAndQueryFromMessageId(
|
||||
messages,
|
||||
@@ -164,6 +277,7 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
}
|
||||
|
||||
const modeRef = useRef<ChatMode>(mode)
|
||||
const approvalJustRespondedRef = useRef(false)
|
||||
const textToActionRef = useRef<Map<string, ChatAction>>(textToAction)
|
||||
const workingDirRef = useRef<string | undefined>(undefined)
|
||||
const selectionMapRef = useRef<
|
||||
@@ -228,10 +342,12 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
status,
|
||||
stop,
|
||||
error: chatError,
|
||||
addToolApprovalResponse,
|
||||
} = useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
// Important: this chat logic is also used in apps/agent/lib/schedules/getChatServerResponse.ts for scheduled jobs. Make sure to keep them in sync for any future changes.
|
||||
prepareSendMessagesRequest: async ({ messages }) => {
|
||||
const provider =
|
||||
selectedLlmProviderRef.current ?? createDefaultBrowserOSProvider()
|
||||
const activeTabsList = await chrome.tabs.query({
|
||||
active: true,
|
||||
currentWindow: true,
|
||||
@@ -240,67 +356,24 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
const activeTabSelection = activeTab?.id
|
||||
? (selectionMapRef.current[String(activeTab.id)] ?? null)
|
||||
: null
|
||||
const message = getLastMessageText(messages)
|
||||
const provider =
|
||||
selectedLlmProviderRef.current ?? createDefaultBrowserOSProvider()
|
||||
const currentMode = modeRef.current
|
||||
const enabledMcpServers = enabledMcpServersRef.current
|
||||
const customMcpServers = enabledCustomServersRef.current
|
||||
|
||||
const getActionForMessage = (messageText: string) => {
|
||||
return textToActionRef.current.get(messageText)
|
||||
}
|
||||
|
||||
const action = getActionForMessage(message)
|
||||
|
||||
const browserContext: {
|
||||
windowId?: number
|
||||
activeTab?: {
|
||||
id?: number
|
||||
url?: string
|
||||
title?: string
|
||||
}
|
||||
selectedTabs?: {
|
||||
id?: number
|
||||
url?: string
|
||||
title?: string
|
||||
}[]
|
||||
enabledMcpServers?: string[]
|
||||
customMcpServers?: {
|
||||
name: string
|
||||
url: string
|
||||
}[]
|
||||
} = {}
|
||||
|
||||
if (activeTab) {
|
||||
browserContext.windowId = activeTab.windowId
|
||||
browserContext.activeTab = {
|
||||
id: activeTab.id,
|
||||
url: activeTab.url,
|
||||
title: activeTab.title,
|
||||
}
|
||||
}
|
||||
|
||||
if (action?.tabs?.length) {
|
||||
browserContext.selectedTabs = action?.tabs?.map((tab) => ({
|
||||
id: tab.id,
|
||||
url: tab.url,
|
||||
title: tab.title,
|
||||
}))
|
||||
}
|
||||
|
||||
if (enabledMcpServers.length) {
|
||||
browserContext.enabledMcpServers = compact(enabledMcpServers)
|
||||
}
|
||||
|
||||
if (customMcpServers.length) {
|
||||
browserContext.customMcpServers = customMcpServers as {
|
||||
name: string
|
||||
url: string
|
||||
}[]
|
||||
}
|
||||
const lastUserMessage = getLastUserMessageText(messages)
|
||||
const action = textToActionRef.current.get(lastUserMessage)
|
||||
const requestBrowserContext = buildRequestBrowserContext({
|
||||
activeTab,
|
||||
action,
|
||||
enabledMcpServers,
|
||||
customMcpServers,
|
||||
})
|
||||
|
||||
const declinedApps = await declinedAppsStorage.getValue()
|
||||
const allAclRules = await aclRulesStorage.getValue()
|
||||
const enabledAclRules = allAclRules.filter((r) => r.enabled)
|
||||
const approvalConfig = normalizeToolApprovalConfig(
|
||||
await toolApprovalConfigStorage.getValue(),
|
||||
)
|
||||
|
||||
const supportsArrayConversation = await Capabilities.supports(
|
||||
Feature.PREVIOUS_CONVERSATION_ARRAY,
|
||||
@@ -317,37 +390,46 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
: history.map((m) => `${m.role}: ${m.content}`).join('\n')
|
||||
: undefined
|
||||
|
||||
const userSystemPrompt = getUserSystemPrompt(
|
||||
options?.origin,
|
||||
personalizationRef.current,
|
||||
)
|
||||
|
||||
const approvalResponses = extractApprovalResponses(messages)
|
||||
if (approvalResponses) {
|
||||
return {
|
||||
api: `${agentUrlRef.current}/chat`,
|
||||
body: buildChatRequestBody({
|
||||
conversationId: conversationIdRef.current,
|
||||
provider,
|
||||
mode: currentMode,
|
||||
browserContext: requestBrowserContext,
|
||||
userSystemPrompt,
|
||||
userWorkingDir: workingDirRef.current,
|
||||
previousConversation,
|
||||
declinedApps,
|
||||
aclRules: enabledAclRules,
|
||||
toolApprovalConfig: approvalConfig,
|
||||
toolApprovalResponses: approvalResponses,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const message = getLastMessageText(messages)
|
||||
|
||||
const result = {
|
||||
api: `${agentUrlRef.current}/chat`,
|
||||
body: {
|
||||
body: buildChatRequestBody({
|
||||
message,
|
||||
provider: provider?.type,
|
||||
providerType: provider?.type,
|
||||
providerName: provider?.name,
|
||||
apiKey: provider?.apiKey,
|
||||
baseUrl: provider?.baseUrl,
|
||||
conversationId: conversationIdRef.current,
|
||||
model: provider?.modelId ?? 'default',
|
||||
provider,
|
||||
mode: currentMode,
|
||||
contextWindowSize: provider?.contextWindow,
|
||||
temperature: provider?.temperature,
|
||||
// Azure-specific
|
||||
resourceName: provider?.resourceName,
|
||||
// Bedrock-specific
|
||||
accessKeyId: provider?.accessKeyId,
|
||||
secretAccessKey: provider?.secretAccessKey,
|
||||
region: provider?.region,
|
||||
sessionToken: provider?.sessionToken,
|
||||
// ChatGPT Pro (Codex)
|
||||
reasoningEffort: provider?.reasoningEffort,
|
||||
reasoningSummary: provider?.reasoningSummary,
|
||||
browserContext,
|
||||
origin: options?.origin ?? 'sidepanel',
|
||||
userSystemPrompt: personalizationRef.current,
|
||||
browserContext: requestBrowserContext,
|
||||
userSystemPrompt,
|
||||
userWorkingDir: workingDirRef.current,
|
||||
supportsImages: provider?.supportsImages,
|
||||
previousConversation,
|
||||
declinedApps: declinedApps.length > 0 ? declinedApps : undefined,
|
||||
declinedApps,
|
||||
aclRules: enabledAclRules,
|
||||
selectedText: activeTabSelection?.text,
|
||||
selectedTextSource: activeTabSelection
|
||||
? {
|
||||
@@ -355,7 +437,8 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
title: activeTabSelection.title,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
toolApprovalConfig: approvalConfig,
|
||||
}),
|
||||
}
|
||||
|
||||
// Track which tab's selection was sent so we can clear it on success
|
||||
@@ -365,6 +448,20 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
return result
|
||||
},
|
||||
}),
|
||||
sendAutomaticallyWhen: () => {
|
||||
if (approvalJustRespondedRef.current) {
|
||||
approvalJustRespondedRef.current = false
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
onFinish: async ({ message, isAbort, isError }) => {
|
||||
await finishExecutionTask({
|
||||
responseText: getLastMessageText([message]),
|
||||
isAbort,
|
||||
isError,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// Remove messages with empty parts (e.g. interrupted assistant responses)
|
||||
@@ -442,7 +539,8 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
// Keep messagesRef in sync on every change (cheap ref assignment)
|
||||
useEffect(() => {
|
||||
messagesRef.current = messages
|
||||
}, [messages])
|
||||
syncExecutionHistory(messages, status)
|
||||
}, [messages, status, syncExecutionHistory])
|
||||
|
||||
// Save conversation only after streaming completes — not on every token
|
||||
const previousStatusRef = useRef(status)
|
||||
@@ -485,6 +583,69 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
if (chatError) invalidateCredits()
|
||||
}, [chatError, invalidateCredits])
|
||||
|
||||
// Sync pending tool approvals to shared storage for the admin dashboard
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
|
||||
const syncPendingApprovals = async () => {
|
||||
const pending = extractPendingApprovals(
|
||||
messages,
|
||||
conversationIdRef.current,
|
||||
)
|
||||
const current = (await pendingToolApprovalsStorage.getValue()) ?? []
|
||||
if (isCancelled) return
|
||||
|
||||
await pendingToolApprovalsStorage.setValue(
|
||||
replacePendingApprovalsForConversation(
|
||||
current,
|
||||
conversationIdRef.current,
|
||||
pending,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
syncPendingApprovals()
|
||||
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
// Watch for approval responses from the admin dashboard
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: only set up once
|
||||
useEffect(() => {
|
||||
const handleResponses = async (responses: ApprovalResponse[]) => {
|
||||
if (!responses?.length) return
|
||||
try {
|
||||
for (const resp of responses) {
|
||||
respondToToolApproval({
|
||||
id: resp.approvalId,
|
||||
approved: resp.approved,
|
||||
reason: resp.reason,
|
||||
})
|
||||
}
|
||||
const approvalIds = responses.map((resp) => resp.approvalId)
|
||||
const currentResponses =
|
||||
(await approvalResponsesStorage.getValue()) ?? []
|
||||
const currentPending =
|
||||
(await pendingToolApprovalsStorage.getValue()) ?? []
|
||||
|
||||
await approvalResponsesStorage.setValue(
|
||||
removeApprovalResponsesById(currentResponses, approvalIds),
|
||||
)
|
||||
await pendingToolApprovalsStorage.setValue(
|
||||
removePendingApprovalsById(currentPending, approvalIds),
|
||||
)
|
||||
} catch {
|
||||
// Leave storage intact so the dashboard can retry
|
||||
}
|
||||
}
|
||||
|
||||
approvalResponsesStorage.getValue().then(handleResponses)
|
||||
const unwatch = approvalResponsesStorage.watch(handleResponses)
|
||||
return () => unwatch()
|
||||
}, [])
|
||||
|
||||
const isIntegrationsSynced = options?.isIntegrationsSynced ?? true
|
||||
const isIntegrationsSyncedRef = useRef(isIntegrationsSynced)
|
||||
const pendingMessageRef = useRef<{
|
||||
@@ -492,6 +653,17 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
action?: ChatAction
|
||||
} | null>(null)
|
||||
|
||||
const dispatchMessage = useCallback(
|
||||
(text: string) => {
|
||||
startExecutionTask({
|
||||
conversationId: conversationIdRef.current,
|
||||
promptText: text,
|
||||
})
|
||||
baseSendMessage({ text })
|
||||
},
|
||||
[baseSendMessage, startExecutionTask],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
isIntegrationsSyncedRef.current = isIntegrationsSynced
|
||||
}, [isIntegrationsSynced])
|
||||
@@ -509,9 +681,9 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
return next
|
||||
})
|
||||
}
|
||||
baseSendMessage({ text: pending.text })
|
||||
dispatchMessage(pending.text)
|
||||
}
|
||||
}, [isIntegrationsSynced, baseSendMessage])
|
||||
}, [dispatchMessage, isIntegrationsSynced])
|
||||
|
||||
const sendMessage = (params: { text: string; action?: ChatAction }) => {
|
||||
track(MESSAGE_SENT_EVENT, {
|
||||
@@ -534,7 +706,7 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
return next
|
||||
})
|
||||
}
|
||||
baseSendMessage({ text: params.text })
|
||||
dispatchMessage(params.text)
|
||||
}
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: only need to run this once
|
||||
@@ -560,6 +732,15 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
return () => unwatch()
|
||||
}, [])
|
||||
|
||||
const respondToToolApproval = (params: {
|
||||
id: string
|
||||
approved: boolean
|
||||
reason?: string
|
||||
}) => {
|
||||
approvalJustRespondedRef.current = true
|
||||
addToolApprovalResponse(params)
|
||||
}
|
||||
|
||||
const handleSelectProvider = (provider: Provider) => {
|
||||
const fullProvider = llmProviders.find((p) => p.id === provider.id)
|
||||
track(PROVIDER_SELECTED_EVENT, {
|
||||
@@ -582,6 +763,7 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
const resetConversation = () => {
|
||||
track(CONVERSATION_RESET_EVENT, { message_count: messages.length })
|
||||
stop()
|
||||
void finishExecutionTask({ isAbort: true })
|
||||
setConversationId(crypto.randomUUID())
|
||||
setMessages([])
|
||||
setTextToAction(new Map())
|
||||
@@ -616,5 +798,6 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
disliked,
|
||||
onClickDislike,
|
||||
conversationId,
|
||||
addToolApprovalResponse: respondToToolApproval,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { ChatStatus, UIMessage } from 'ai'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import {
|
||||
getResponsePreview,
|
||||
normalizeExecutionSteps,
|
||||
} from '@/lib/execution-history/normalize'
|
||||
import { upsertConversationExecutionTask } from '@/lib/execution-history/storage'
|
||||
import type {
|
||||
ExecutionTaskRecord,
|
||||
ExecutionTaskStatus,
|
||||
} from '@/lib/execution-history/types'
|
||||
import { sentry } from '@/lib/sentry/sentry'
|
||||
|
||||
interface StartExecutionTaskInput {
|
||||
conversationId: string
|
||||
promptText: string
|
||||
}
|
||||
|
||||
interface FinishExecutionTaskInput {
|
||||
responseText?: string
|
||||
isAbort?: boolean
|
||||
isError?: boolean
|
||||
}
|
||||
|
||||
function createTask(input: StartExecutionTaskInput): ExecutionTaskRecord {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
conversationId: input.conversationId,
|
||||
promptText: input.promptText,
|
||||
startedAt: new Date().toISOString(),
|
||||
status: 'running',
|
||||
actionCount: 0,
|
||||
approvalCount: 0,
|
||||
deniedCount: 0,
|
||||
errorCount: 0,
|
||||
steps: [],
|
||||
}
|
||||
}
|
||||
|
||||
function getLastUserMessage(messages: UIMessage[]): UIMessage | undefined {
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
if (messages[index]?.role === 'user') {
|
||||
return messages[index]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getLastAssistantMessage(messages: UIMessage[]): UIMessage | undefined {
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
if (lastMessage?.role === 'assistant') {
|
||||
return lastMessage
|
||||
}
|
||||
}
|
||||
|
||||
function getFinishedStatus(
|
||||
input: FinishExecutionTaskInput,
|
||||
): ExecutionTaskStatus {
|
||||
if (input.isError) return 'failed'
|
||||
if (input.isAbort) return 'stopped'
|
||||
return 'completed'
|
||||
}
|
||||
|
||||
export function useExecutionHistoryTracker() {
|
||||
const activeTaskRef = useRef<ExecutionTaskRecord | null>(null)
|
||||
const lastSavedHashRef = useRef('')
|
||||
const writeQueueRef = useRef(Promise.resolve())
|
||||
|
||||
const persistTask = useCallback((task: ExecutionTaskRecord) => {
|
||||
const taskHash = JSON.stringify(task)
|
||||
if (taskHash === lastSavedHashRef.current) return
|
||||
|
||||
activeTaskRef.current = task
|
||||
writeQueueRef.current = writeQueueRef.current
|
||||
.then(async () => {
|
||||
await upsertConversationExecutionTask(task)
|
||||
lastSavedHashRef.current = taskHash
|
||||
})
|
||||
.catch((error) => {
|
||||
sentry.captureException(error, {
|
||||
extra: {
|
||||
message: 'Failed to persist execution history task',
|
||||
conversationId: task.conversationId,
|
||||
taskId: task.id,
|
||||
},
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
const startTask = useCallback(
|
||||
(input: StartExecutionTaskInput) => {
|
||||
const task = createTask(input)
|
||||
lastSavedHashRef.current = ''
|
||||
persistTask(task)
|
||||
return task.id
|
||||
},
|
||||
[persistTask],
|
||||
)
|
||||
|
||||
const syncFromMessages = useCallback(
|
||||
(messages: UIMessage[], _status: ChatStatus) => {
|
||||
const activeTask = activeTaskRef.current
|
||||
if (!activeTask) return
|
||||
|
||||
const promptMessage = getLastUserMessage(messages)
|
||||
const assistantMessage = getLastAssistantMessage(messages)
|
||||
const normalized = normalizeExecutionSteps({
|
||||
assistantMessage,
|
||||
previousSteps: activeTask.steps,
|
||||
nowIso: new Date().toISOString(),
|
||||
})
|
||||
|
||||
persistTask({
|
||||
...activeTask,
|
||||
promptMessageId: activeTask.promptMessageId ?? promptMessage?.id,
|
||||
assistantMessageId:
|
||||
normalized.assistantMessageId ?? activeTask.assistantMessageId,
|
||||
responsePreview:
|
||||
getResponsePreview(assistantMessage) || activeTask.responsePreview,
|
||||
actionCount: normalized.actionCount,
|
||||
approvalCount: normalized.approvalCount,
|
||||
deniedCount: normalized.deniedCount,
|
||||
errorCount: normalized.errorCount,
|
||||
steps: normalized.steps,
|
||||
})
|
||||
},
|
||||
[persistTask],
|
||||
)
|
||||
|
||||
const finishTask = useCallback(
|
||||
async (input: FinishExecutionTaskInput) => {
|
||||
const activeTask = activeTaskRef.current
|
||||
if (!activeTask) return
|
||||
|
||||
const responseText = input.responseText?.trim() || activeTask.responseText
|
||||
const nextTask: ExecutionTaskRecord = {
|
||||
...activeTask,
|
||||
completedAt: new Date().toISOString(),
|
||||
status: getFinishedStatus(input),
|
||||
responseText,
|
||||
responsePreview: responseText
|
||||
? getResponsePreview({
|
||||
parts: [{ type: 'text', text: responseText }],
|
||||
} as Pick<UIMessage, 'parts'>)
|
||||
: activeTask.responsePreview,
|
||||
}
|
||||
|
||||
persistTask(nextTask)
|
||||
activeTaskRef.current = null
|
||||
},
|
||||
[persistTask],
|
||||
)
|
||||
|
||||
const clearActiveTask = useCallback(() => {
|
||||
activeTaskRef.current = null
|
||||
lastSavedHashRef.current = ''
|
||||
}, [])
|
||||
|
||||
return {
|
||||
startTask,
|
||||
syncFromMessages,
|
||||
finishTask,
|
||||
clearActiveTask,
|
||||
}
|
||||
}
|
||||
7
packages/browseros-agent/apps/agent/lib/acl/storage.ts
Normal file
7
packages/browseros-agent/apps/agent/lib/acl/storage.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { AclRule } from '@browseros/shared/types/acl'
|
||||
import { storage } from '#imports'
|
||||
|
||||
export const aclRulesStorage = storage.defineItem<AclRule[]>(
|
||||
'local:acl-rules',
|
||||
{ fallback: [] },
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
import { del, get, keys, set } from 'idb-keyval'
|
||||
import type { AgentConversation } from './types'
|
||||
|
||||
const PREFIX = 'agent-conv:'
|
||||
|
||||
export async function saveConversation(conv: AgentConversation): Promise<void> {
|
||||
await set(`${PREFIX}${conv.agentId}:${conv.sessionKey}`, conv)
|
||||
}
|
||||
|
||||
export async function getLatestConversation(
|
||||
agentId: string,
|
||||
): Promise<AgentConversation | undefined> {
|
||||
const allKeys = await keys()
|
||||
const agentKeys = (allKeys as string[]).filter((k) =>
|
||||
k.startsWith(`${PREFIX}${agentId}:`),
|
||||
)
|
||||
if (!agentKeys.length) return undefined
|
||||
|
||||
const conversations = await Promise.all(
|
||||
agentKeys.map((k) => get<AgentConversation>(k)),
|
||||
)
|
||||
const valid = conversations.filter((c): c is AgentConversation => c != null)
|
||||
return valid.sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? undefined
|
||||
}
|
||||
|
||||
export async function deleteConversation(
|
||||
agentId: string,
|
||||
sessionKey: string,
|
||||
): Promise<void> {
|
||||
await del(`${PREFIX}${agentId}:${sessionKey}`)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export interface AssistantTextPart {
|
||||
kind: 'text'
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface AssistantThinkingPart {
|
||||
kind: 'thinking'
|
||||
text: string
|
||||
done: boolean
|
||||
}
|
||||
|
||||
export interface ToolEntry {
|
||||
id: string
|
||||
name: string
|
||||
status: 'running' | 'completed' | 'error'
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
export interface AssistantToolBatchPart {
|
||||
kind: 'tool-batch'
|
||||
tools: ToolEntry[]
|
||||
}
|
||||
|
||||
export type AssistantPart =
|
||||
| AssistantTextPart
|
||||
| AssistantThinkingPart
|
||||
| AssistantToolBatchPart
|
||||
|
||||
export interface AgentConversationTurn {
|
||||
id: string
|
||||
userText: string
|
||||
parts: AssistantPart[]
|
||||
done: boolean
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface AgentConversation {
|
||||
agentId: string
|
||||
agentName: string
|
||||
sessionKey: string
|
||||
turns: AgentConversationTurn[]
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface AgentCardData {
|
||||
agentId: string
|
||||
name: string
|
||||
model?: string
|
||||
status: 'idle' | 'working' | 'error'
|
||||
lastMessage?: string
|
||||
lastMessageTimestamp?: number
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { storage } from '@wxt-dev/storage'
|
||||
import type { UIMessage } from 'ai'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSessionInfo } from '../auth/sessionStorage'
|
||||
import { removeConversationExecutionHistory } from '../execution-history/storage'
|
||||
import { uploadConversationsToGraphql } from './uploadConversationsToGraphql'
|
||||
|
||||
const MAX_CONVERSATIONS = 50
|
||||
@@ -42,6 +43,7 @@ export function useConversations() {
|
||||
const removeConversation = async (id: string) => {
|
||||
const current = (await conversationStorage.getValue()) ?? []
|
||||
await conversationStorage.setValue(current.filter((c) => c.id !== id))
|
||||
await removeConversationExecutionHistory(id)
|
||||
}
|
||||
|
||||
const saveConversation = async (id: string, messages: UIMessage[]) => {
|
||||
@@ -68,11 +70,16 @@ export function useConversations() {
|
||||
messages,
|
||||
lastMessagedAt: Date.now(),
|
||||
}
|
||||
let updated = [newConversation, ...current]
|
||||
if (updated.length > MAX_CONVERSATIONS) {
|
||||
updated = updated.slice(0, MAX_CONVERSATIONS)
|
||||
}
|
||||
await conversationStorage.setValue(updated)
|
||||
const nextConversations = [newConversation, ...current]
|
||||
const removedConversations = nextConversations.slice(MAX_CONVERSATIONS)
|
||||
await conversationStorage.setValue(
|
||||
nextConversations.slice(0, MAX_CONVERSATIONS),
|
||||
)
|
||||
await Promise.all(
|
||||
removedConversations.map((conversation) =>
|
||||
removeConversationExecutionHistory(conversation.id),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import type { UIMessage } from 'ai'
|
||||
import {
|
||||
getMessageText,
|
||||
getResponsePreview,
|
||||
normalizeExecutionSteps,
|
||||
} from './normalize'
|
||||
|
||||
function asMessagePart(
|
||||
part: Record<string, unknown>,
|
||||
): UIMessage['parts'][number] {
|
||||
return part as unknown as UIMessage['parts'][number]
|
||||
}
|
||||
|
||||
function createAssistantMessage(parts: UIMessage['parts']): UIMessage {
|
||||
return {
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
parts,
|
||||
} as UIMessage
|
||||
}
|
||||
|
||||
describe('normalizeExecutionSteps', () => {
|
||||
it('filters nudge tools from the execution history', () => {
|
||||
const message = createAssistantMessage([
|
||||
asMessagePart({ type: 'text', text: 'I checked that for you.' }),
|
||||
asMessagePart({
|
||||
type: 'tool-suggest_schedule',
|
||||
toolCallId: 'nudge-1',
|
||||
state: 'output-available',
|
||||
input: { scheduleType: 'daily' },
|
||||
output: { suggestedName: 'Morning briefing' },
|
||||
}),
|
||||
asMessagePart({
|
||||
type: 'tool-open',
|
||||
toolCallId: 'tool-1',
|
||||
state: 'output-available',
|
||||
input: { ref_id: 'page-1' },
|
||||
output: { pageId: 1 },
|
||||
}),
|
||||
])
|
||||
|
||||
const normalized = normalizeExecutionSteps({
|
||||
assistantMessage: message,
|
||||
nowIso: '2026-03-26T10:00:00.000Z',
|
||||
})
|
||||
|
||||
expect(normalized.assistantMessageId).toBe('assistant-1')
|
||||
expect(normalized.actionCount).toBe(1)
|
||||
expect(normalized.steps).toHaveLength(1)
|
||||
expect(normalized.steps[0]).toMatchObject({
|
||||
id: 'tool-1',
|
||||
toolName: 'open',
|
||||
state: 'output-available',
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves the original start time when a tool step reaches a terminal state', () => {
|
||||
const initialTimestamp = '2026-03-26T10:00:00.000Z'
|
||||
const completedTimestamp = '2026-03-26T10:00:04.000Z'
|
||||
|
||||
const running = normalizeExecutionSteps({
|
||||
assistantMessage: createAssistantMessage([
|
||||
asMessagePart({
|
||||
type: 'tool-open',
|
||||
toolCallId: 'tool-1',
|
||||
state: 'input-available',
|
||||
input: { ref_id: 'page-1' },
|
||||
}),
|
||||
]),
|
||||
nowIso: initialTimestamp,
|
||||
})
|
||||
|
||||
const completed = normalizeExecutionSteps({
|
||||
assistantMessage: createAssistantMessage([
|
||||
asMessagePart({
|
||||
type: 'tool-open',
|
||||
toolCallId: 'tool-1',
|
||||
state: 'output-available',
|
||||
input: { ref_id: 'page-1' },
|
||||
output: { title: 'Example Domain' },
|
||||
}),
|
||||
]),
|
||||
previousSteps: running.steps,
|
||||
nowIso: completedTimestamp,
|
||||
})
|
||||
|
||||
expect(completed.steps[0]?.startedAt).toBe(initialTimestamp)
|
||||
expect(completed.steps[0]?.completedAt).toBe(completedTimestamp)
|
||||
})
|
||||
|
||||
it('uses a compact preview for completed tool output', () => {
|
||||
const normalized = normalizeExecutionSteps({
|
||||
assistantMessage: createAssistantMessage([
|
||||
asMessagePart({
|
||||
type: 'tool-open',
|
||||
toolCallId: 'tool-1',
|
||||
state: 'output-available',
|
||||
input: { ref_id: 'page-1' },
|
||||
output: {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Navigated to https://amazon.com. Additional context: page snapshot follows.',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
nowIso: '2026-03-26T10:00:00.000Z',
|
||||
})
|
||||
|
||||
expect(normalized.steps[0]?.previewText).toBe('Completed successfully')
|
||||
})
|
||||
|
||||
it('surfaces ACL blocks as a compact issue label', () => {
|
||||
const normalized = normalizeExecutionSteps({
|
||||
assistantMessage: createAssistantMessage([
|
||||
asMessagePart({
|
||||
type: 'tool-click',
|
||||
toolCallId: 'tool-1',
|
||||
state: 'output-available',
|
||||
input: { x: 10, y: 20 },
|
||||
output: {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: "Action blocked by ACL rule: 'add to cart'. The element on this page is restricted.",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
nowIso: '2026-03-26T10:00:00.000Z',
|
||||
})
|
||||
|
||||
expect(normalized.steps[0]?.previewText).toBe('Blocked by ACL rule')
|
||||
})
|
||||
})
|
||||
|
||||
describe('execution history text helpers', () => {
|
||||
it('joins text parts into a single response body', () => {
|
||||
const text = getMessageText({
|
||||
parts: [
|
||||
asMessagePart({ type: 'text', text: 'First line' }),
|
||||
asMessagePart({ type: 'text', text: 'Second line' }),
|
||||
],
|
||||
})
|
||||
|
||||
expect(text).toBe('First line\n\nSecond line')
|
||||
})
|
||||
|
||||
it('truncates long response previews', () => {
|
||||
const preview = getResponsePreview({
|
||||
parts: [asMessagePart({ type: 'text', text: 'a'.repeat(220) })],
|
||||
})
|
||||
|
||||
expect(preview).toHaveLength(180)
|
||||
expect(preview.endsWith('...')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,215 @@
|
||||
import type { DynamicToolUIPart, ToolUIPart, UIMessage } from 'ai'
|
||||
import type {
|
||||
ExecutionStepApproval,
|
||||
ExecutionStepRecord,
|
||||
ExecutionStepState,
|
||||
} from './types'
|
||||
|
||||
const NUDGE_TOOL_NAMES = new Set(['suggest_schedule', 'suggest_app_connection'])
|
||||
const TERMINAL_STEP_STATES = new Set<ExecutionStepState>([
|
||||
'output-available',
|
||||
'output-error',
|
||||
'output-denied',
|
||||
])
|
||||
const MAX_PREVIEW_CHARS = 180
|
||||
|
||||
type ToolLikePart = ToolUIPart | DynamicToolUIPart
|
||||
|
||||
function truncateText(value: string): string {
|
||||
if (value.length <= MAX_PREVIEW_CHARS) return value
|
||||
return `${value.slice(0, MAX_PREVIEW_CHARS - 3)}...`
|
||||
}
|
||||
|
||||
function stringifyValue(value: unknown): string {
|
||||
if (typeof value === 'string') return value
|
||||
if (value == null) return ''
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeText(value: string): string {
|
||||
return value.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function getNestedText(value: unknown, depth = 0): string | undefined {
|
||||
if (depth > 5 || value == null) return undefined
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const text = normalizeText(value)
|
||||
return text || undefined
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const text = getNestedText(item, depth + 1)
|
||||
if (text) return text
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (typeof value !== 'object') return undefined
|
||||
|
||||
const record = value as Record<string, unknown>
|
||||
for (const key of ['text', 'message', 'reason', 'content']) {
|
||||
const text = getNestedText(record[key], depth + 1)
|
||||
if (text) return text
|
||||
}
|
||||
|
||||
for (const nestedValue of Object.values(record)) {
|
||||
const text = getNestedText(nestedValue, depth + 1)
|
||||
if (text) return text
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getCompactIssueLabel(value?: string): string | undefined {
|
||||
if (!value) return undefined
|
||||
|
||||
if (value.includes('Action blocked by ACL rule')) {
|
||||
return 'Blocked by ACL rule'
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getToolName(part: ToolLikePart): string {
|
||||
if (part.type === 'dynamic-tool') {
|
||||
return part.toolName
|
||||
}
|
||||
|
||||
return part.type.replace('tool-', '')
|
||||
}
|
||||
|
||||
function isToolPart(part: UIMessage['parts'][number]): part is ToolLikePart {
|
||||
return part.type === 'dynamic-tool' || part.type.startsWith('tool-')
|
||||
}
|
||||
|
||||
function isExecutionToolPart(
|
||||
part: UIMessage['parts'][number],
|
||||
): part is ToolLikePart {
|
||||
if (!isToolPart(part)) return false
|
||||
return !NUDGE_TOOL_NAMES.has(getToolName(part))
|
||||
}
|
||||
|
||||
function getPreviewText(part: ToolLikePart): string {
|
||||
if (part.state === 'approval-requested') {
|
||||
return 'Waiting for approval'
|
||||
}
|
||||
|
||||
if (part.state === 'approval-responded') {
|
||||
return part.approval?.approved === false
|
||||
? 'Approval rejected'
|
||||
: 'Approval granted'
|
||||
}
|
||||
|
||||
if (part.state === 'output-denied') {
|
||||
return getCompactIssueLabel(part.approval?.reason) ?? 'Action denied'
|
||||
}
|
||||
|
||||
if (part.state === 'output-error') {
|
||||
return getCompactIssueLabel(part.errorText) ?? 'Action failed'
|
||||
}
|
||||
|
||||
if (part.state === 'output-available') {
|
||||
const preview =
|
||||
getCompactIssueLabel(getNestedText(part.output)) ??
|
||||
getCompactIssueLabel(stringifyValue(part.output))
|
||||
return preview ?? 'Completed successfully'
|
||||
}
|
||||
|
||||
if (part.state === 'input-available') {
|
||||
return 'Action running'
|
||||
}
|
||||
|
||||
return 'Preparing action'
|
||||
}
|
||||
|
||||
function getApproval(part: ToolLikePart): ExecutionStepApproval | undefined {
|
||||
return part.approval
|
||||
? {
|
||||
id: part.approval.id,
|
||||
approved: part.approval.approved,
|
||||
reason: part.approval.reason,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
function getCompletedAt(
|
||||
existingStep: ExecutionStepRecord | undefined,
|
||||
state: ExecutionStepState,
|
||||
nowIso: string,
|
||||
): string | undefined {
|
||||
if (existingStep?.completedAt) return existingStep.completedAt
|
||||
if (!TERMINAL_STEP_STATES.has(state)) return undefined
|
||||
return nowIso
|
||||
}
|
||||
|
||||
function createStepRecord(
|
||||
part: ToolLikePart,
|
||||
order: number,
|
||||
nowIso: string,
|
||||
existingStep?: ExecutionStepRecord,
|
||||
): ExecutionStepRecord {
|
||||
const state = part.state as ExecutionStepState
|
||||
return {
|
||||
id: part.toolCallId,
|
||||
toolName: getToolName(part),
|
||||
order,
|
||||
state,
|
||||
startedAt: existingStep?.startedAt ?? nowIso,
|
||||
completedAt: getCompletedAt(existingStep, state, nowIso),
|
||||
input: part.input,
|
||||
output: 'output' in part ? part.output : undefined,
|
||||
errorText: 'errorText' in part ? part.errorText : undefined,
|
||||
previewText: getPreviewText(part),
|
||||
approval: getApproval(part),
|
||||
}
|
||||
}
|
||||
|
||||
export function getMessageText(
|
||||
message?: Pick<UIMessage, 'parts'> | null,
|
||||
): string {
|
||||
if (!message) return ''
|
||||
|
||||
return message.parts
|
||||
.filter((part) => part.type === 'text')
|
||||
.map((part) => part.text)
|
||||
.join('\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function getResponsePreview(message?: Pick<UIMessage, 'parts'> | null) {
|
||||
return truncateText(getMessageText(message))
|
||||
}
|
||||
|
||||
export function normalizeExecutionSteps(args: {
|
||||
assistantMessage?: UIMessage | null
|
||||
previousSteps?: ExecutionStepRecord[]
|
||||
nowIso: string
|
||||
}) {
|
||||
const { assistantMessage, previousSteps = [], nowIso } = args
|
||||
const previousStepsById = new Map(
|
||||
previousSteps.map((step) => [step.id, step]),
|
||||
)
|
||||
|
||||
const steps = assistantMessage
|
||||
? assistantMessage.parts.flatMap((part, index) => {
|
||||
if (!isExecutionToolPart(part)) return []
|
||||
const existingStep = previousStepsById.get(part.toolCallId)
|
||||
return [createStepRecord(part, index, nowIso, existingStep)]
|
||||
})
|
||||
: []
|
||||
|
||||
return {
|
||||
assistantMessageId: assistantMessage?.id,
|
||||
steps,
|
||||
actionCount: steps.length,
|
||||
approvalCount: steps.filter((step) => step.approval).length,
|
||||
deniedCount: steps.filter((step) => step.state === 'output-denied').length,
|
||||
errorCount: steps.filter((step) => step.state === 'output-error').length,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { storage } from '@wxt-dev/storage'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type {
|
||||
ConversationExecutionHistory,
|
||||
ExecutionHistoryByConversation,
|
||||
ExecutionTaskRecord,
|
||||
} from './types'
|
||||
|
||||
export const executionHistoryStorage =
|
||||
storage.defineItem<ExecutionHistoryByConversation>(
|
||||
'local:executionHistoryByConversation',
|
||||
{
|
||||
fallback: {},
|
||||
version: 1,
|
||||
},
|
||||
)
|
||||
|
||||
function upsertTaskInHistory(
|
||||
history: ConversationExecutionHistory,
|
||||
task: ExecutionTaskRecord,
|
||||
): ConversationExecutionHistory {
|
||||
const existingIndex = history.tasks.findIndex((item) => item.id === task.id)
|
||||
if (existingIndex === -1) {
|
||||
return {
|
||||
...history,
|
||||
updatedAt: Date.now(),
|
||||
tasks: [...history.tasks, task],
|
||||
}
|
||||
}
|
||||
|
||||
const nextTasks = [...history.tasks]
|
||||
nextTasks[existingIndex] = task
|
||||
return {
|
||||
...history,
|
||||
updatedAt: Date.now(),
|
||||
tasks: nextTasks,
|
||||
}
|
||||
}
|
||||
|
||||
function createConversationHistory(
|
||||
conversationId: string,
|
||||
): ConversationExecutionHistory {
|
||||
return {
|
||||
conversationId,
|
||||
updatedAt: Date.now(),
|
||||
tasks: [],
|
||||
}
|
||||
}
|
||||
|
||||
export async function upsertConversationExecutionTask(
|
||||
task: ExecutionTaskRecord,
|
||||
): Promise<void> {
|
||||
const current = (await executionHistoryStorage.getValue()) ?? {}
|
||||
const history =
|
||||
current[task.conversationId] ??
|
||||
createConversationHistory(task.conversationId)
|
||||
|
||||
await executionHistoryStorage.setValue({
|
||||
...current,
|
||||
[task.conversationId]: upsertTaskInHistory(history, task),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getConversationExecutionHistory(
|
||||
conversationId: string,
|
||||
): Promise<ConversationExecutionHistory | null> {
|
||||
const current = (await executionHistoryStorage.getValue()) ?? {}
|
||||
return current[conversationId] ?? null
|
||||
}
|
||||
|
||||
export async function getExecutionHistoryByConversation(): Promise<ExecutionHistoryByConversation> {
|
||||
return (await executionHistoryStorage.getValue()) ?? {}
|
||||
}
|
||||
|
||||
export async function removeConversationExecutionHistory(
|
||||
conversationId: string,
|
||||
): Promise<void> {
|
||||
const current = (await executionHistoryStorage.getValue()) ?? {}
|
||||
if (!(conversationId in current)) return
|
||||
|
||||
const { [conversationId]: _removed, ...rest } = current
|
||||
await executionHistoryStorage.setValue(rest)
|
||||
}
|
||||
|
||||
export async function removeConversationExecutionTask(args: {
|
||||
conversationId: string
|
||||
taskId: string
|
||||
}): Promise<void> {
|
||||
const current = (await executionHistoryStorage.getValue()) ?? {}
|
||||
const history = current[args.conversationId]
|
||||
if (!history) return
|
||||
|
||||
const nextTasks = history.tasks.filter((task) => task.id !== args.taskId)
|
||||
if (nextTasks.length === history.tasks.length) return
|
||||
|
||||
if (nextTasks.length === 0) {
|
||||
const { [args.conversationId]: _removed, ...rest } = current
|
||||
await executionHistoryStorage.setValue(rest)
|
||||
return
|
||||
}
|
||||
|
||||
await executionHistoryStorage.setValue({
|
||||
...current,
|
||||
[args.conversationId]: {
|
||||
...history,
|
||||
updatedAt: Date.now(),
|
||||
tasks: nextTasks,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useConversationExecutionHistory(conversationId?: string) {
|
||||
const [history, setHistory] = useState<ConversationExecutionHistory | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversationId) {
|
||||
setHistory(null)
|
||||
return
|
||||
}
|
||||
|
||||
getConversationExecutionHistory(conversationId).then(setHistory)
|
||||
const unwatch = executionHistoryStorage.watch((nextValue) => {
|
||||
setHistory(nextValue?.[conversationId] ?? null)
|
||||
})
|
||||
return () => unwatch()
|
||||
}, [conversationId])
|
||||
|
||||
return history
|
||||
}
|
||||
|
||||
export function useExecutionHistoryByConversation() {
|
||||
const [historyByConversation, setHistoryByConversation] =
|
||||
useState<ExecutionHistoryByConversation>({})
|
||||
|
||||
useEffect(() => {
|
||||
getExecutionHistoryByConversation().then(setHistoryByConversation)
|
||||
const unwatch = executionHistoryStorage.watch((nextValue) => {
|
||||
setHistoryByConversation(nextValue ?? {})
|
||||
})
|
||||
return () => unwatch()
|
||||
}, [])
|
||||
|
||||
return historyByConversation
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
export type ExecutionTaskStatus =
|
||||
| 'running'
|
||||
| 'completed'
|
||||
| 'stopped'
|
||||
| 'failed'
|
||||
| 'interrupted'
|
||||
|
||||
export type ExecutionStepState =
|
||||
| 'input-streaming'
|
||||
| 'input-available'
|
||||
| 'approval-requested'
|
||||
| 'approval-responded'
|
||||
| 'output-available'
|
||||
| 'output-error'
|
||||
| 'output-denied'
|
||||
|
||||
export interface ExecutionStepApproval {
|
||||
id: string
|
||||
approved?: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface ExecutionStepRecord {
|
||||
id: string
|
||||
toolName: string
|
||||
order: number
|
||||
state: ExecutionStepState
|
||||
startedAt: string
|
||||
completedAt?: string
|
||||
input?: unknown
|
||||
output?: unknown
|
||||
errorText?: string
|
||||
previewText: string
|
||||
approval?: ExecutionStepApproval
|
||||
}
|
||||
|
||||
export interface ExecutionTaskRecord {
|
||||
id: string
|
||||
conversationId: string
|
||||
promptText: string
|
||||
promptMessageId?: string
|
||||
assistantMessageId?: string
|
||||
startedAt: string
|
||||
completedAt?: string
|
||||
status: ExecutionTaskStatus
|
||||
responseText?: string
|
||||
responsePreview?: string
|
||||
actionCount: number
|
||||
approvalCount: number
|
||||
deniedCount: number
|
||||
errorCount: number
|
||||
steps: ExecutionStepRecord[]
|
||||
}
|
||||
|
||||
export interface ConversationExecutionHistory {
|
||||
conversationId: string
|
||||
updatedAt: number
|
||||
tasks: ExecutionTaskRecord[]
|
||||
}
|
||||
|
||||
export type ExecutionHistoryByConversation = Record<
|
||||
string,
|
||||
ConversationExecutionHistory
|
||||
>
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
|
||||
import type { ToolApprovalConfig } from '@/lib/tool-approvals/types'
|
||||
import { buildChatRequestBody } from './buildChatRequestBody'
|
||||
|
||||
const provider: LlmProviderConfig = {
|
||||
id: 'browseros',
|
||||
type: 'browseros',
|
||||
name: 'BrowserOS',
|
||||
modelId: 'browseros-auto',
|
||||
supportsImages: true,
|
||||
contextWindow: 200000,
|
||||
temperature: 0,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
}
|
||||
|
||||
describe('buildChatRequestBody', () => {
|
||||
it('preserves approval config and browser context on approval resumes', () => {
|
||||
const toolApprovalConfig: ToolApprovalConfig = {
|
||||
categories: {
|
||||
input: true,
|
||||
navigation: true,
|
||||
observation: true,
|
||||
screenshots: true,
|
||||
scripts: true,
|
||||
'data-modification': true,
|
||||
assistant: true,
|
||||
},
|
||||
}
|
||||
|
||||
const body = buildChatRequestBody({
|
||||
conversationId: '6ff46e3b-e45a-40a4-9157-ca520e800f43',
|
||||
provider,
|
||||
mode: 'agent',
|
||||
browserContext: {
|
||||
windowId: 2,
|
||||
activeTab: {
|
||||
id: 10,
|
||||
url: 'https://amazon.com',
|
||||
title: 'Amazon',
|
||||
},
|
||||
enabledMcpServers: ['slack'],
|
||||
},
|
||||
userSystemPrompt: 'Stay in the current tab.',
|
||||
toolApprovalConfig,
|
||||
toolApprovalResponses: [
|
||||
{
|
||||
approvalId: 'approval-1',
|
||||
approved: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(body.toolApprovalConfig).toEqual(toolApprovalConfig)
|
||||
expect(body.browserContext).toEqual({
|
||||
windowId: 2,
|
||||
activeTab: {
|
||||
id: 10,
|
||||
url: 'https://amazon.com',
|
||||
title: 'Amazon',
|
||||
},
|
||||
enabledMcpServers: ['slack'],
|
||||
})
|
||||
expect(body.toolApprovalResponses).toEqual([
|
||||
{
|
||||
approvalId: 'approval-1',
|
||||
approved: true,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('omits empty approval configs from requests', () => {
|
||||
const body = buildChatRequestBody({
|
||||
conversationId: '6ff46e3b-e45a-40a4-9157-ca520e800f43',
|
||||
provider,
|
||||
toolApprovalConfig: {
|
||||
categories: {},
|
||||
},
|
||||
})
|
||||
|
||||
expect(body.toolApprovalConfig).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { AclRule } from '@browseros/shared/types/acl'
|
||||
import type { ChatMode } from '@/entrypoints/sidepanel/index/chatTypes'
|
||||
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
|
||||
import type { ToolApprovalConfig } from '@/lib/tool-approvals/types'
|
||||
|
||||
export interface ApprovalResponseData {
|
||||
approvalId: string
|
||||
approved: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface ChatHistoryEntry {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface ChatRequestBrowserContext {
|
||||
windowId?: number
|
||||
activeTab?: {
|
||||
id?: number
|
||||
url?: string
|
||||
title?: string
|
||||
}
|
||||
selectedTabs?: {
|
||||
id?: number
|
||||
url?: string
|
||||
title?: string
|
||||
}[]
|
||||
enabledMcpServers?: string[]
|
||||
customMcpServers?: {
|
||||
name: string
|
||||
url?: string
|
||||
}[]
|
||||
}
|
||||
|
||||
interface ChatRequestBodyParams {
|
||||
conversationId: string
|
||||
provider: LlmProviderConfig
|
||||
message?: string
|
||||
mode?: ChatMode
|
||||
browserContext?: ChatRequestBrowserContext
|
||||
userSystemPrompt?: string
|
||||
userWorkingDir?: string
|
||||
supportsImages?: boolean
|
||||
previousConversation?: ChatHistoryEntry[] | string
|
||||
declinedApps?: string[]
|
||||
aclRules?: AclRule[]
|
||||
selectedText?: string
|
||||
selectedTextSource?: {
|
||||
url: string
|
||||
title: string
|
||||
}
|
||||
toolApprovalConfig?: ToolApprovalConfig
|
||||
toolApprovalResponses?: ApprovalResponseData[]
|
||||
isScheduledTask?: boolean
|
||||
}
|
||||
|
||||
export const toRequestToolApprovalConfig = (
|
||||
approvalConfig?: ToolApprovalConfig,
|
||||
): ToolApprovalConfig | undefined => {
|
||||
if (!approvalConfig) return undefined
|
||||
return Object.values(approvalConfig.categories).some(Boolean)
|
||||
? approvalConfig
|
||||
: undefined
|
||||
}
|
||||
|
||||
export const buildChatRequestBody = ({
|
||||
conversationId,
|
||||
provider,
|
||||
message = '',
|
||||
mode,
|
||||
browserContext,
|
||||
userSystemPrompt,
|
||||
userWorkingDir,
|
||||
supportsImages,
|
||||
previousConversation,
|
||||
declinedApps,
|
||||
aclRules,
|
||||
selectedText,
|
||||
selectedTextSource,
|
||||
toolApprovalConfig,
|
||||
toolApprovalResponses,
|
||||
isScheduledTask,
|
||||
}: ChatRequestBodyParams) => ({
|
||||
message,
|
||||
provider: provider.type,
|
||||
providerType: provider.type,
|
||||
providerName: provider.name,
|
||||
apiKey: provider.apiKey,
|
||||
baseUrl: provider.baseUrl,
|
||||
conversationId,
|
||||
model: provider.modelId ?? 'default',
|
||||
mode,
|
||||
contextWindowSize: provider.contextWindow,
|
||||
temperature: provider.temperature,
|
||||
resourceName: provider.resourceName,
|
||||
accessKeyId: provider.accessKeyId,
|
||||
secretAccessKey: provider.secretAccessKey,
|
||||
region: provider.region,
|
||||
sessionToken: provider.sessionToken,
|
||||
reasoningEffort: provider.reasoningEffort,
|
||||
reasoningSummary: provider.reasoningSummary,
|
||||
browserContext,
|
||||
userSystemPrompt,
|
||||
userWorkingDir,
|
||||
supportsImages: supportsImages ?? provider.supportsImages,
|
||||
previousConversation,
|
||||
declinedApps: declinedApps?.length ? declinedApps : undefined,
|
||||
aclRules: aclRules?.length ? aclRules : undefined,
|
||||
selectedText,
|
||||
selectedTextSource,
|
||||
toolApprovalConfig: toRequestToolApprovalConfig(toolApprovalConfig),
|
||||
toolApprovalResponses,
|
||||
isScheduledTask,
|
||||
})
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@/lib/llm-providers/storage'
|
||||
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
|
||||
import { mcpServerStorage } from '@/lib/mcp/mcpServerStorage'
|
||||
import { buildChatRequestBody } from '@/lib/messaging/server/buildChatRequestBody'
|
||||
import { personalizationStorage } from '../personalization/personalizationStorage'
|
||||
import { scheduleSystemPrompt } from './scheduleSystemPrompt'
|
||||
import type { ToolCallExecution } from './scheduleTypes'
|
||||
@@ -112,42 +113,31 @@ export async function getChatServerResponse(
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
// Important: this chat logic is also used in apps/agent/entrypoints/sidepanel/index/useChatSession.ts for sidepanel conversation. Make sure to keep them in sync for any future changes.
|
||||
body: JSON.stringify({
|
||||
messages: [{ role: 'user', content: request.message }],
|
||||
message: request.message,
|
||||
provider: provider?.type,
|
||||
providerType: provider?.type,
|
||||
providerName: provider?.name,
|
||||
apiKey: provider?.apiKey,
|
||||
baseUrl: provider?.baseUrl,
|
||||
conversationId,
|
||||
model: provider?.modelId ?? 'default',
|
||||
mode: request.mode ?? 'agent',
|
||||
contextWindowSize: provider?.contextWindow,
|
||||
temperature: provider?.temperature,
|
||||
resourceName: provider?.resourceName,
|
||||
accessKeyId: provider?.accessKeyId,
|
||||
secretAccessKey: provider?.secretAccessKey,
|
||||
region: provider?.region,
|
||||
sessionToken: provider?.sessionToken,
|
||||
browserContext:
|
||||
request.activeTab ||
|
||||
request.windowId ||
|
||||
enabledMcpServers.length ||
|
||||
customMcpServers.length
|
||||
? {
|
||||
windowId: request.windowId,
|
||||
activeTab: request.activeTab,
|
||||
enabledMcpServers:
|
||||
enabledMcpServers.length > 0 ? enabledMcpServers : undefined,
|
||||
customMcpServers:
|
||||
customMcpServers.length > 0 ? customMcpServers : undefined,
|
||||
}
|
||||
: undefined,
|
||||
userSystemPrompt: `${personalization}\n${scheduleSystemPrompt}`,
|
||||
isScheduledTask: true,
|
||||
supportsImages: provider?.supportsImages,
|
||||
...buildChatRequestBody({
|
||||
message: request.message,
|
||||
conversationId,
|
||||
provider,
|
||||
mode: request.mode ?? 'agent',
|
||||
browserContext:
|
||||
request.activeTab ||
|
||||
request.windowId ||
|
||||
enabledMcpServers.length ||
|
||||
customMcpServers.length
|
||||
? {
|
||||
windowId: request.windowId,
|
||||
activeTab: request.activeTab,
|
||||
enabledMcpServers:
|
||||
enabledMcpServers.length > 0 ? enabledMcpServers : undefined,
|
||||
customMcpServers:
|
||||
customMcpServers.length > 0 ? customMcpServers : undefined,
|
||||
}
|
||||
: undefined,
|
||||
userSystemPrompt: `${personalization}\n${scheduleSystemPrompt}`,
|
||||
supportsImages: provider.supportsImages,
|
||||
isScheduledTask: true,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
71
packages/browseros-agent/apps/agent/lib/sse.ts
Normal file
71
packages/browseros-agent/apps/agent/lib/sse.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof DOMException && error.name === 'AbortError'
|
||||
}
|
||||
|
||||
export function parseSSELines<T>(buffer: string): {
|
||||
events: T[]
|
||||
remainder: string
|
||||
} {
|
||||
const lines = buffer.split('\n')
|
||||
const remainder = lines.pop() ?? ''
|
||||
const events: T[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue
|
||||
const payload = line.slice(6)
|
||||
if (payload === '[DONE]') continue
|
||||
try {
|
||||
events.push(JSON.parse(payload) as T)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return { events, remainder }
|
||||
}
|
||||
|
||||
export async function consumeSSEStream<T>(
|
||||
response: Response,
|
||||
onEvent: (event: T) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) return
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
const abortReader = () => {
|
||||
void reader.cancel()
|
||||
}
|
||||
|
||||
signal?.addEventListener('abort', abortReader, { once: true })
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const { events, remainder } = parseSSELines<T>(buffer)
|
||||
buffer = remainder
|
||||
|
||||
for (const event of events) {
|
||||
onEvent(event)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal?.aborted || isAbortError(error)) return
|
||||
throw error
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abortReader)
|
||||
const trailing = decoder.decode()
|
||||
if (trailing) {
|
||||
buffer += trailing
|
||||
}
|
||||
if (buffer) {
|
||||
const { events } = parseSSELines<T>(buffer)
|
||||
for (const event of events) {
|
||||
onEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { UIMessage } from 'ai'
|
||||
import type { ApprovalResponse, PendingApproval } from './approval-sync-storage'
|
||||
|
||||
export function extractPendingApprovals(
|
||||
messages: UIMessage[],
|
||||
conversationId: string,
|
||||
timestamp = Date.now(),
|
||||
): PendingApproval[] {
|
||||
const pending: PendingApproval[] = []
|
||||
|
||||
for (const msg of messages) {
|
||||
for (const part of msg.parts) {
|
||||
const toolPart = part as {
|
||||
type?: string
|
||||
state?: string
|
||||
toolCallId?: string
|
||||
input?: Record<string, unknown>
|
||||
approval?: { id: string }
|
||||
}
|
||||
|
||||
if (
|
||||
toolPart.state === 'approval-requested' &&
|
||||
toolPart.approval?.id &&
|
||||
toolPart.toolCallId
|
||||
) {
|
||||
pending.push({
|
||||
approvalId: toolPart.approval.id,
|
||||
toolCallId: toolPart.toolCallId,
|
||||
toolName: (toolPart.type ?? '').replace('tool-', ''),
|
||||
input: toolPart.input ?? {},
|
||||
conversationId,
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pending
|
||||
}
|
||||
|
||||
export function replacePendingApprovalsForConversation(
|
||||
existing: PendingApproval[],
|
||||
conversationId: string,
|
||||
next: PendingApproval[],
|
||||
): PendingApproval[] {
|
||||
const existingByApprovalId = new Map(
|
||||
existing.map((item) => [item.approvalId, item]),
|
||||
)
|
||||
const preserved = next.map((item) => {
|
||||
const current = existingByApprovalId.get(item.approvalId)
|
||||
return current ? { ...item, timestamp: current.timestamp } : item
|
||||
})
|
||||
|
||||
return [
|
||||
...existing.filter((item) => item.conversationId !== conversationId),
|
||||
...preserved,
|
||||
]
|
||||
}
|
||||
|
||||
export function queueApprovalResponse(
|
||||
existing: ApprovalResponse[],
|
||||
response: ApprovalResponse,
|
||||
): ApprovalResponse[] {
|
||||
return [
|
||||
...existing.filter((item) => item.approvalId !== response.approvalId),
|
||||
response,
|
||||
]
|
||||
}
|
||||
|
||||
export function removePendingApprovalsById(
|
||||
existing: PendingApproval[],
|
||||
approvalIds: string[],
|
||||
): PendingApproval[] {
|
||||
const ids = new Set(approvalIds)
|
||||
return existing.filter((item) => !ids.has(item.approvalId))
|
||||
}
|
||||
|
||||
export function removeApprovalResponsesById(
|
||||
existing: ApprovalResponse[],
|
||||
approvalIds: string[],
|
||||
): ApprovalResponse[] {
|
||||
const ids = new Set(approvalIds)
|
||||
return existing.filter((item) => !ids.has(item.approvalId))
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import type { UIMessage } from 'ai'
|
||||
import {
|
||||
extractPendingApprovals,
|
||||
queueApprovalResponse,
|
||||
removeApprovalResponsesById,
|
||||
removePendingApprovalsById,
|
||||
replacePendingApprovalsForConversation,
|
||||
} from './approval-sync-helpers'
|
||||
|
||||
describe('approval sync storage helpers', () => {
|
||||
it('extracts pending approvals from assistant tool parts', () => {
|
||||
const messages = [
|
||||
{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
parts: [
|
||||
{
|
||||
type: 'tool-click',
|
||||
state: 'approval-requested',
|
||||
toolCallId: 'tool-1',
|
||||
input: { selector: '#buy-now' },
|
||||
approval: { id: 'approval-1' },
|
||||
},
|
||||
],
|
||||
},
|
||||
] as UIMessage[]
|
||||
|
||||
expect(extractPendingApprovals(messages, 'conversation-1', 123)).toEqual([
|
||||
{
|
||||
approvalId: 'approval-1',
|
||||
toolCallId: 'tool-1',
|
||||
toolName: 'click',
|
||||
input: { selector: '#buy-now' },
|
||||
conversationId: 'conversation-1',
|
||||
timestamp: 123,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('replaces pending approvals for one conversation without clearing others', () => {
|
||||
const existing = [
|
||||
{
|
||||
approvalId: 'approval-a',
|
||||
toolCallId: 'tool-a',
|
||||
toolName: 'click',
|
||||
input: {},
|
||||
conversationId: 'conversation-a',
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
approvalId: 'approval-b',
|
||||
toolCallId: 'tool-b',
|
||||
toolName: 'navigate_page',
|
||||
input: {},
|
||||
conversationId: 'conversation-b',
|
||||
timestamp: 2,
|
||||
},
|
||||
]
|
||||
|
||||
expect(
|
||||
replacePendingApprovalsForConversation(existing, 'conversation-a', []),
|
||||
).toEqual([existing[1]])
|
||||
})
|
||||
|
||||
it('queues and removes approval responses by approval id', () => {
|
||||
const queued = queueApprovalResponse(
|
||||
[
|
||||
{
|
||||
approvalId: 'approval-a',
|
||||
approved: true,
|
||||
timestamp: 1,
|
||||
},
|
||||
],
|
||||
{
|
||||
approvalId: 'approval-b',
|
||||
approved: false,
|
||||
timestamp: 2,
|
||||
},
|
||||
)
|
||||
|
||||
expect(queued).toEqual([
|
||||
{
|
||||
approvalId: 'approval-a',
|
||||
approved: true,
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
approvalId: 'approval-b',
|
||||
approved: false,
|
||||
timestamp: 2,
|
||||
},
|
||||
])
|
||||
|
||||
expect(removeApprovalResponsesById(queued, ['approval-a'])).toEqual([
|
||||
{
|
||||
approvalId: 'approval-b',
|
||||
approved: false,
|
||||
timestamp: 2,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('removes only handled pending approvals', () => {
|
||||
const pending = [
|
||||
{
|
||||
approvalId: 'approval-a',
|
||||
toolCallId: 'tool-a',
|
||||
toolName: 'click',
|
||||
input: {},
|
||||
conversationId: 'conversation-a',
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
approvalId: 'approval-b',
|
||||
toolCallId: 'tool-b',
|
||||
toolName: 'fill',
|
||||
input: {},
|
||||
conversationId: 'conversation-b',
|
||||
timestamp: 2,
|
||||
},
|
||||
]
|
||||
|
||||
expect(removePendingApprovalsById(pending, ['approval-b'])).toEqual([
|
||||
pending[0],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { storage } from '@wxt-dev/storage'
|
||||
|
||||
export {
|
||||
extractPendingApprovals,
|
||||
queueApprovalResponse,
|
||||
removeApprovalResponsesById,
|
||||
removePendingApprovalsById,
|
||||
replacePendingApprovalsForConversation,
|
||||
} from './approval-sync-helpers'
|
||||
|
||||
export interface PendingApproval {
|
||||
approvalId: string
|
||||
toolCallId: string
|
||||
toolName: string
|
||||
input: Record<string, unknown>
|
||||
conversationId: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface ApprovalResponse {
|
||||
approvalId: string
|
||||
approved: boolean
|
||||
reason?: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface ToolExecutionLogEntry {
|
||||
toolCallId: string
|
||||
toolName: string
|
||||
status: 'auto-allowed' | 'approved' | 'denied' | 'error'
|
||||
conversationId: string
|
||||
timestamp: number
|
||||
input?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export const pendingToolApprovalsStorage = storage.defineItem<
|
||||
PendingApproval[]
|
||||
>('local:pending-tool-approvals', { fallback: [] })
|
||||
|
||||
export const approvalResponsesStorage = storage.defineItem<ApprovalResponse[]>(
|
||||
'local:approval-responses',
|
||||
{ fallback: [] },
|
||||
)
|
||||
|
||||
export const toolExecutionLogStorage = storage.defineItem<
|
||||
ToolExecutionLogEntry[]
|
||||
>('local:tool-execution-log', { fallback: [] })
|
||||
@@ -0,0 +1,38 @@
|
||||
import { storage } from '@wxt-dev/storage'
|
||||
import type { ToolApprovalCategoryId, ToolApprovalConfig } from './types'
|
||||
|
||||
export const toolApprovalConfigStorage = storage.defineItem<ToolApprovalConfig>(
|
||||
'local:tool-approval-config',
|
||||
{
|
||||
fallback: {
|
||||
categories: {},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const LEGACY_ALL_CATEGORY_IDS: ToolApprovalCategoryId[] = [
|
||||
'input',
|
||||
'navigation',
|
||||
'screenshots',
|
||||
'scripts',
|
||||
'data-modification',
|
||||
]
|
||||
|
||||
const NEW_CATEGORY_IDS: ToolApprovalCategoryId[] = ['observation', 'assistant']
|
||||
|
||||
export function normalizeToolApprovalConfig(
|
||||
config: ToolApprovalConfig,
|
||||
): ToolApprovalConfig {
|
||||
const categories = { ...config.categories }
|
||||
const shouldMigrateLegacyAll =
|
||||
LEGACY_ALL_CATEGORY_IDS.every((id) => categories[id] === true) &&
|
||||
NEW_CATEGORY_IDS.every((id) => categories[id] === undefined)
|
||||
|
||||
if (shouldMigrateLegacyAll) {
|
||||
for (const id of NEW_CATEGORY_IDS) {
|
||||
categories[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
return { categories }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
TOOL_APPROVAL_CATEGORIES as TOOL_CATEGORIES,
|
||||
TOOL_APPROVAL_CATEGORIES,
|
||||
type ToolApprovalCategory,
|
||||
type ToolApprovalCategoryId,
|
||||
type ToolApprovalConfig,
|
||||
} from '@browseros/shared/constants/tool-approval'
|
||||
@@ -20,6 +20,7 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^3.0.96",
|
||||
"@browseros/server": "workspace:*",
|
||||
"@browseros/shared": "workspace:*",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@lobehub/icons": "^2.44.0",
|
||||
"@mdxeditor/editor": "^3.52.4",
|
||||
@@ -51,6 +52,9 @@
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@webext-core/messaging": "^2.3.0",
|
||||
"@wxt-dev/storage": "^1.2.8",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"@xyflow/react": "^12.9.3",
|
||||
"ai": "^6.0.94",
|
||||
"better-auth": "^1.4.17",
|
||||
|
||||
@@ -221,6 +221,37 @@
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.agent-terminal-shell .xterm {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.agent-terminal-shell .xterm-viewport {
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: oklch(from var(--border) l c h / 0.45) transparent;
|
||||
}
|
||||
|
||||
.agent-terminal-shell .xterm-viewport::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.agent-terminal-shell .xterm-viewport::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.agent-terminal-shell .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
background: oklch(from var(--border) l c h / 0.45);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 999px;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.agent-terminal-shell .xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(from var(--border) l c h / 0.7);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
/* Custom animation keyframes for minimal, elegant hero animations */
|
||||
@keyframes float {
|
||||
0%,
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"allowImportingTsExtensions": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
"@/*": ["./*"],
|
||||
"@browseros/shared/*": ["../../packages/shared/src/*"]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
|
||||
@@ -56,6 +56,7 @@ var groupOrder = []string{
|
||||
"Observe:",
|
||||
"Input:",
|
||||
"Resources:",
|
||||
"Integrations:",
|
||||
"Setup:",
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ func TestCommandName(t *testing.T) {
|
||||
{"known command", []string{"health"}, "browseros-cli health"},
|
||||
{"unknown command", []string{"nonexistent"}, "unknown"},
|
||||
{"subcommand", []string{"bookmark", "search"}, "browseros-cli bookmark search"},
|
||||
{"strata subcommand", []string{"strata", "check"}, "browseros-cli strata check"},
|
||||
{"known with extra args", []string{"snap", "--enhanced"}, "browseros-cli snap"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
|
||||
235
packages/browseros-agent/apps/cli/cmd/strata.go
Normal file
235
packages/browseros-agent/apps/cli/cmd/strata.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"browseros-cli/output"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
strataCmd := &cobra.Command{
|
||||
Use: "strata",
|
||||
Annotations: map[string]string{"group": "Integrations:"},
|
||||
Short: "Manage Strata MCP integrations (Gmail, Slack, GitHub, etc.)",
|
||||
Long: `Interact with 40+ external services via Strata MCP integrations.
|
||||
|
||||
Supported services:
|
||||
gmail, google calendar, google docs, google drive, google sheets, slack,
|
||||
linkedin, notion, airtable, confluence, github, gitlab, linear, jira,
|
||||
figma, salesforce, hubspot, stripe, discord, asana, clickup, zendesk,
|
||||
monday, shopify, dropbox, onedrive, box, youtube, whatsapp, resend,
|
||||
posthog, mixpanel, vercel, supabase, cloudflare, wordpress, postman,
|
||||
intercom, cal.com, brave search, microsoft teams, outlook mail,
|
||||
outlook calendar, google forms, mem0
|
||||
|
||||
Discovery flow — do not guess action names:
|
||||
1. check → verify the service is connected (get auth URL if not)
|
||||
2. discover → find categories or actions for a service
|
||||
3. actions → expand categories into specific actions
|
||||
4. details → get the parameter schema before executing
|
||||
5. exec → execute the action with parameters
|
||||
6. search → fallback keyword search if discover doesn't find it
|
||||
|
||||
Authentication:
|
||||
If a service is not connected, "check" returns an authUrl.
|
||||
Open that URL in a browser to authenticate, then retry.
|
||||
If "exec" fails with an auth error, use "auth" to get a fresh authUrl.
|
||||
|
||||
Example — search Gmail:
|
||||
browseros-cli strata check gmail
|
||||
browseros-cli strata discover "search emails" gmail
|
||||
browseros-cli strata actions GMAIL_EMAIL
|
||||
browseros-cli strata details GMAIL_EMAIL gmail_search_emails
|
||||
browseros-cli strata exec gmail GMAIL_EMAIL gmail_search_emails \
|
||||
--body '{"query":"from:user@example.com","maxResults":5}'`,
|
||||
}
|
||||
|
||||
checkCmd := &cobra.Command{
|
||||
Use: "check <server-name>",
|
||||
Short: "Check if a service is connected and ready",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
c := newClient()
|
||||
result, err := c.CallTool("connector_mcp_servers", map[string]any{
|
||||
"server_name": args[0],
|
||||
})
|
||||
if err != nil {
|
||||
output.Error(err.Error(), 1)
|
||||
}
|
||||
if jsonOut {
|
||||
output.JSON(result)
|
||||
} else {
|
||||
output.Text(result)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
discoverCmd := &cobra.Command{
|
||||
Use: "discover <query> <server> [servers...]",
|
||||
Short: "Discover available categories or actions for servers",
|
||||
Args: cobra.MinimumNArgs(2),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
c := newClient()
|
||||
result, err := c.CallTool("discover_server_categories_or_actions", map[string]any{
|
||||
"user_query": args[0],
|
||||
"server_names": args[1:],
|
||||
})
|
||||
if err != nil {
|
||||
output.Error(err.Error(), 1)
|
||||
}
|
||||
if jsonOut {
|
||||
output.JSON(result)
|
||||
} else {
|
||||
output.Text(result)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
actionsCmd := &cobra.Command{
|
||||
Use: "actions <category> [categories...]",
|
||||
Short: "Get actions within categories",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
c := newClient()
|
||||
result, err := c.CallTool("get_category_actions", map[string]any{
|
||||
"category_names": args,
|
||||
})
|
||||
if err != nil {
|
||||
output.Error(err.Error(), 1)
|
||||
}
|
||||
if jsonOut {
|
||||
output.JSON(result)
|
||||
} else {
|
||||
output.Text(result)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
detailsCmd := &cobra.Command{
|
||||
Use: "details <category> <action>",
|
||||
Short: "Get parameter schema for an action",
|
||||
Args: cobra.ExactArgs(2),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
c := newClient()
|
||||
result, err := c.CallTool("get_action_details", map[string]any{
|
||||
"category_name": args[0],
|
||||
"action_name": args[1],
|
||||
})
|
||||
if err != nil {
|
||||
output.Error(err.Error(), 1)
|
||||
}
|
||||
if jsonOut {
|
||||
output.JSON(result)
|
||||
} else {
|
||||
output.Text(result)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
execCmd := &cobra.Command{
|
||||
Use: "exec <server> <category> <action>",
|
||||
Short: "Execute an action on a connected service",
|
||||
Long: `Execute an action on a connected service.
|
||||
|
||||
Pass request body as a JSON string with --body.
|
||||
Use --query and --path for query/path parameters.
|
||||
Use --output-field to limit response fields.
|
||||
|
||||
Example:
|
||||
browseros-cli strata exec gmail GMAIL_EMAIL gmail_search_emails \
|
||||
--body '{"query":"from:user@example.com","maxResults":5}'`,
|
||||
Args: cobra.ExactArgs(3),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
bodySchema, _ := cmd.Flags().GetString("body")
|
||||
queryParams, _ := cmd.Flags().GetString("query")
|
||||
pathParams, _ := cmd.Flags().GetString("path")
|
||||
outputFields, _ := cmd.Flags().GetStringArray("output-field")
|
||||
maxChars, _ := cmd.Flags().GetInt("max-chars")
|
||||
|
||||
toolArgs := map[string]any{
|
||||
"server_name": args[0],
|
||||
"category_name": args[1],
|
||||
"action_name": args[2],
|
||||
}
|
||||
|
||||
if bodySchema != "" {
|
||||
toolArgs["body_schema"] = bodySchema
|
||||
}
|
||||
if queryParams != "" {
|
||||
toolArgs["query_params"] = queryParams
|
||||
}
|
||||
if pathParams != "" {
|
||||
toolArgs["path_params"] = pathParams
|
||||
}
|
||||
if len(outputFields) > 0 {
|
||||
toolArgs["include_output_fields"] = outputFields
|
||||
}
|
||||
if cmd.Flags().Changed("max-chars") {
|
||||
toolArgs["maximum_output_characters"] = maxChars
|
||||
}
|
||||
|
||||
c := newClient()
|
||||
result, err := c.CallTool("execute_action", toolArgs)
|
||||
if err != nil {
|
||||
output.Error(err.Error(), 1)
|
||||
}
|
||||
if jsonOut {
|
||||
output.JSON(result)
|
||||
} else {
|
||||
output.Text(result)
|
||||
}
|
||||
},
|
||||
}
|
||||
execCmd.Flags().String("body", "", "Request body as JSON string")
|
||||
execCmd.Flags().String("query", "", "Query parameters as JSON string")
|
||||
execCmd.Flags().String("path", "", "Path parameters as JSON string")
|
||||
execCmd.Flags().StringArray("output-field", nil, "Limit response to these fields (repeatable)")
|
||||
execCmd.Flags().Int("max-chars", 0, "Maximum output characters")
|
||||
|
||||
searchCmd := &cobra.Command{
|
||||
Use: "search <query> <server>",
|
||||
Short: "Search documentation for a service",
|
||||
Args: cobra.ExactArgs(2),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
c := newClient()
|
||||
result, err := c.CallTool("search_documentation", map[string]any{
|
||||
"query": args[0],
|
||||
"server_name": args[1],
|
||||
})
|
||||
if err != nil {
|
||||
output.Error(err.Error(), 1)
|
||||
}
|
||||
if jsonOut {
|
||||
output.JSON(result)
|
||||
} else {
|
||||
output.Text(result)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
authCmd := &cobra.Command{
|
||||
Use: "auth <server-name>",
|
||||
Short: "Handle authentication failure for a service",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
intention, _ := cmd.Flags().GetString("intention")
|
||||
c := newClient()
|
||||
result, err := c.CallTool("handle_auth_failure", map[string]any{
|
||||
"server_name": args[0],
|
||||
"intention": intention,
|
||||
})
|
||||
if err != nil {
|
||||
output.Error(err.Error(), 1)
|
||||
}
|
||||
if jsonOut {
|
||||
output.JSON(result)
|
||||
} else {
|
||||
output.Text(result)
|
||||
}
|
||||
},
|
||||
}
|
||||
authCmd.Flags().String("intention", "get_auth_url", "Auth intention")
|
||||
|
||||
strataCmd.AddCommand(checkCmd, discoverCmd, actionsCmd, detailsCmd, execCmd, searchCmd, authCmd)
|
||||
rootCmd.AddCommand(strataCmd)
|
||||
}
|
||||
@@ -270,3 +270,84 @@ func TestInvalidPage(t *testing.T) {
|
||||
t.Errorf("expected snap with invalid page ID to exit non-zero")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataCheck(t *testing.T) {
|
||||
r := run(t, "--json", "strata", "check", "Gmail")
|
||||
// Klavis may not be configured — accept success or structured error
|
||||
out := strings.TrimSpace(r.Stdout + r.Stderr)
|
||||
if out == "" {
|
||||
t.Fatal("strata check produced no output")
|
||||
}
|
||||
if r.ExitCode == 0 {
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(r.Stdout)), &data); err != nil {
|
||||
t.Fatalf("strata check returned non-JSON: %s", r.Stdout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataDiscover(t *testing.T) {
|
||||
r := run(t, "--json", "strata", "discover", "send email", "Gmail")
|
||||
out := strings.TrimSpace(r.Stdout + r.Stderr)
|
||||
if out == "" {
|
||||
t.Fatal("strata discover produced no output")
|
||||
}
|
||||
if r.ExitCode == 0 {
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(r.Stdout)), &data); err != nil {
|
||||
t.Fatalf("strata discover returned non-JSON: %s", r.Stdout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataSearch(t *testing.T) {
|
||||
r := run(t, "--json", "strata", "search", "send email", "Gmail")
|
||||
out := strings.TrimSpace(r.Stdout + r.Stderr)
|
||||
if out == "" {
|
||||
t.Fatal("strata search produced no output")
|
||||
}
|
||||
if r.ExitCode == 0 {
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(r.Stdout)), &data); err != nil {
|
||||
t.Fatalf("strata search returned non-JSON: %s", r.Stdout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataActions(t *testing.T) {
|
||||
r := run(t, "--json", "strata", "actions", "Gmail")
|
||||
out := strings.TrimSpace(r.Stdout + r.Stderr)
|
||||
if out == "" {
|
||||
t.Fatal("strata actions produced no output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataDetails(t *testing.T) {
|
||||
r := run(t, "--json", "strata", "details", "Gmail", "send_email")
|
||||
out := strings.TrimSpace(r.Stdout + r.Stderr)
|
||||
if out == "" {
|
||||
t.Fatal("strata details produced no output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataAuth(t *testing.T) {
|
||||
r := run(t, "--json", "strata", "auth", "Gmail")
|
||||
out := strings.TrimSpace(r.Stdout + r.Stderr)
|
||||
if out == "" {
|
||||
t.Fatal("strata auth produced no output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataExecMissingArgs(t *testing.T) {
|
||||
r := run(t, "strata", "exec")
|
||||
if r.ExitCode == 0 {
|
||||
t.Error("expected strata exec without args to exit non-zero")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataCheckMissingArgs(t *testing.T) {
|
||||
r := run(t, "strata", "check")
|
||||
if r.ExitCode == 0 {
|
||||
t.Error("expected strata check without args to exit non-zero")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@browseros/server",
|
||||
"version": "0.0.82",
|
||||
"version": "0.0.83",
|
||||
"description": "BrowserOS server",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
@@ -70,6 +70,7 @@
|
||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||
"@ai-sdk/provider": "^3.0.8",
|
||||
"@browseros-ai/agent-sdk": "workspace:*",
|
||||
"@huggingface/transformers": "^3.4.0",
|
||||
"@browseros/cdp-protocol": "workspace:*",
|
||||
"@browseros/shared": "workspace:*",
|
||||
"@google/gemini-cli-core": "^0.16.0",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
services:
|
||||
openclaw-gateway:
|
||||
image: ${OPENCLAW_IMAGE:-ghcr.io/openclaw/openclaw:latest}
|
||||
ports:
|
||||
- "127.0.0.1:${OPENCLAW_GATEWAY_PORT:-18789}:18789"
|
||||
environment:
|
||||
- HOME=/home/node
|
||||
- NODE_ENV=production
|
||||
- OPENCLAW_GATEWAY_TOKEN=${OPENCLAW_GATEWAY_TOKEN}
|
||||
- OPENCLAW_GATEWAY_BIND=lan
|
||||
- TZ=${TZ}
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
|
||||
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
|
||||
- GROQ_API_KEY=${GROQ_API_KEY:-}
|
||||
- MISTRAL_API_KEY=${MISTRAL_API_KEY:-}
|
||||
- MOONSHOT_API_KEY=${MOONSHOT_API_KEY:-}
|
||||
volumes:
|
||||
- ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw
|
||||
extra_hosts:
|
||||
- "host.containers.internal:host-gateway"
|
||||
command:
|
||||
- node
|
||||
- dist/index.js
|
||||
- gateway
|
||||
- --bind
|
||||
- lan
|
||||
- --port
|
||||
- "18789"
|
||||
- --allow-unconfigured
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://127.0.0.1:18789/healthz"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
import { AGENT_LIMITS } from '@browseros/shared/constants/limits'
|
||||
import type { BrowserContext } from '@browseros/shared/schemas/browser-context'
|
||||
import { LLM_PROVIDERS } from '@browseros/shared/schemas/llm'
|
||||
import type { AclRule } from '@browseros/shared/types/acl'
|
||||
import {
|
||||
type LanguageModel,
|
||||
type ModelMessage,
|
||||
@@ -23,6 +24,7 @@ import { isSoulBootstrap, readSoul } from '../lib/soul'
|
||||
import { buildSkillsCatalog } from '../skills/catalog'
|
||||
import { loadSkills } from '../skills/loader'
|
||||
import { buildFilesystemToolSet } from '../tools/filesystem/build-toolset'
|
||||
import type { ToolContext } from '../tools/framework'
|
||||
import { buildMemoryToolSet } from '../tools/memory/build-toolset'
|
||||
import type { ToolRegistry } from '../tools/tool-registry'
|
||||
import { CHAT_MODE_ALLOWED_TOOLS } from './chat-mode'
|
||||
@@ -46,6 +48,7 @@ export interface AiSdkAgentConfig {
|
||||
klavisClient?: KlavisClient
|
||||
browserosId?: string
|
||||
aiSdkDevtoolsEnabled?: boolean
|
||||
aclRules?: AclRule[]
|
||||
}
|
||||
|
||||
export class AiSdkAgent {
|
||||
@@ -55,6 +58,7 @@ export class AiSdkAgent {
|
||||
private _mcpClients: Array<{ close(): Promise<void> }>,
|
||||
private conversationId: string,
|
||||
private _toolNames: Set<string>,
|
||||
private toolContext: ToolContext,
|
||||
) {}
|
||||
|
||||
/** Tool names registered on this agent — used to sanitize messages during session rebuilds. */
|
||||
@@ -99,14 +103,19 @@ export class AiSdkAgent {
|
||||
|
||||
// Build browser tools from the unified tool registry
|
||||
const originPageId = config.browserContext?.activeTab?.pageId
|
||||
const allBrowserTools = buildBrowserToolSet(
|
||||
config.registry,
|
||||
config.browser,
|
||||
config.resolvedConfig.workingDir,
|
||||
{
|
||||
const toolContext: ToolContext = {
|
||||
browser: config.browser,
|
||||
directories: { workingDir: config.resolvedConfig.workingDir },
|
||||
session: {
|
||||
origin: config.resolvedConfig.origin,
|
||||
originPageId,
|
||||
},
|
||||
aclRules: config.aclRules,
|
||||
}
|
||||
const allBrowserTools = buildBrowserToolSet(
|
||||
config.registry,
|
||||
toolContext,
|
||||
config.resolvedConfig.toolApprovalConfig,
|
||||
)
|
||||
const browserTools = config.resolvedConfig.chatMode
|
||||
? Object.fromEntries(
|
||||
@@ -277,6 +286,7 @@ export class AiSdkAgent {
|
||||
clients,
|
||||
config.resolvedConfig.conversationId,
|
||||
new Set(Object.keys(tools)),
|
||||
toolContext,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -300,6 +310,10 @@ export class AiSdkAgent {
|
||||
})
|
||||
}
|
||||
|
||||
updateAclRules(rules?: AclRule[]): void {
|
||||
this.toolContext.aclRules = rules
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
for (const client of this._mcpClients) {
|
||||
await client.close().catch(() => {})
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface AgentSession {
|
||||
mcpServerKey?: string
|
||||
/** Workspace directory when the session was created, for change detection. */
|
||||
workingDir?: string
|
||||
/** Tool approval category key for change detection. */
|
||||
approvalConfigKey?: string
|
||||
}
|
||||
|
||||
export class SessionStore {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { LanguageModelV2ToolResultOutput } from '@ai-sdk/provider'
|
||||
import type { ToolApprovalConfig } from '@browseros/shared/constants/tool-approval'
|
||||
import { type ToolSet, tool } from 'ai'
|
||||
import type { Browser } from '../browser/browser'
|
||||
import { logger } from '../lib/logger'
|
||||
import { metrics } from '../lib/metrics'
|
||||
import { executeTool, type ToolContext } from '../tools/framework'
|
||||
@@ -35,23 +35,29 @@ function contentToModelOutput(
|
||||
}
|
||||
}
|
||||
|
||||
export function getApprovedBrowserToolNames(
|
||||
registry: ToolRegistry,
|
||||
approvalConfig?: ToolApprovalConfig,
|
||||
): string[] {
|
||||
if (!approvalConfig) return []
|
||||
return registry
|
||||
.all()
|
||||
.filter((def) => approvalConfig.categories[def.approvalCategory] === true)
|
||||
.map((def) => def.name)
|
||||
}
|
||||
|
||||
export function buildBrowserToolSet(
|
||||
registry: ToolRegistry,
|
||||
browser: Browser,
|
||||
workingDir: string | undefined,
|
||||
session?: { origin?: 'sidepanel' | 'newtab'; originPageId?: number },
|
||||
ctx: ToolContext,
|
||||
approvalConfig?: ToolApprovalConfig,
|
||||
): ToolSet {
|
||||
const toolSet: ToolSet = {}
|
||||
const ctx: ToolContext = {
|
||||
browser,
|
||||
directories: { workingDir },
|
||||
session,
|
||||
}
|
||||
|
||||
for (const def of registry.all()) {
|
||||
toolSet[def.name] = tool({
|
||||
description: def.description,
|
||||
inputSchema: def.input,
|
||||
needsApproval: approvalConfig?.categories[def.approvalCategory] === true,
|
||||
execute: async (params) => {
|
||||
const startTime = performance.now()
|
||||
try {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import type { ToolApprovalConfig } from '@browseros/shared/constants/tool-approval'
|
||||
import type { LLMProvider } from '@browseros/shared/schemas/llm'
|
||||
|
||||
export interface ProviderConfig {
|
||||
@@ -50,4 +51,6 @@ export interface ResolvedAgentConfig {
|
||||
origin?: 'sidepanel' | 'newtab'
|
||||
/** BrowserOS installation ID for credit-based tracking. */
|
||||
browserosId?: string
|
||||
/** Tool approval configuration — which categories require human approval. */
|
||||
toolApprovalConfig?: ToolApprovalConfig
|
||||
}
|
||||
|
||||
477
packages/browseros-agent/apps/server/src/api/routes/openclaw.ts
Normal file
477
packages/browseros-agent/apps/server/src/api/routes/openclaw.ts
Normal file
@@ -0,0 +1,477 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* HTTP routes for OpenClaw agent management.
|
||||
* Thin layer delegating to OpenClawService.
|
||||
*/
|
||||
|
||||
import { OPENCLAW_GATEWAY_PORT } from '@browseros/shared/constants/openclaw'
|
||||
import { BROWSEROS_ROLE_TEMPLATES } from '@browseros/shared/constants/role-aware-agents'
|
||||
import type {
|
||||
BrowserOSAgentRoleId,
|
||||
BrowserOSCustomRoleInput,
|
||||
} from '@browseros/shared/types/role-aware-agents'
|
||||
import { Hono } from 'hono'
|
||||
import { stream } from 'hono/streaming'
|
||||
import { getOpenClawDir } from '../../lib/browseros-dir'
|
||||
import {
|
||||
OpenClawAgentAlreadyExistsError,
|
||||
OpenClawAgentNotFoundError,
|
||||
OpenClawInvalidAgentNameError,
|
||||
OpenClawProtectedAgentError,
|
||||
} from '../services/openclaw/errors'
|
||||
import {
|
||||
getOpenClawService,
|
||||
type OpenClawAgentEntry,
|
||||
} from '../services/openclaw/openclaw-service'
|
||||
import { OpenClawProgramMaterializer } from '../services/openclaw/program-materializer'
|
||||
import { OpenClawProgramStorage } from '../services/openclaw/program-storage'
|
||||
import {
|
||||
validateCreateProgramInput,
|
||||
validateUpdateProgramInput,
|
||||
} from '../services/openclaw/program-validation'
|
||||
|
||||
function isValidBoundaryMode(
|
||||
value: unknown,
|
||||
): value is BrowserOSCustomRoleInput['boundaries'][number]['defaultMode'] {
|
||||
return value === 'allow' || value === 'ask' || value === 'block'
|
||||
}
|
||||
|
||||
function isValidCustomRoleBoundary(value: unknown): boolean {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const boundary = value as Record<string, unknown>
|
||||
return (
|
||||
typeof boundary.key === 'string' &&
|
||||
typeof boundary.label === 'string' &&
|
||||
typeof boundary.description === 'string' &&
|
||||
isValidBoundaryMode(boundary.defaultMode)
|
||||
)
|
||||
}
|
||||
|
||||
const openclawProgramStorage = new OpenClawProgramStorage(getOpenClawDir())
|
||||
const openclawProgramMaterializer = new OpenClawProgramMaterializer(
|
||||
getOpenClawDir(),
|
||||
openclawProgramStorage,
|
||||
)
|
||||
|
||||
async function findOpenClawAgent(
|
||||
agentId: string,
|
||||
): Promise<OpenClawAgentEntry | null> {
|
||||
const agents = await getOpenClawService().listAgents()
|
||||
return agents.find((agent) => agent.agentId === agentId) ?? null
|
||||
}
|
||||
|
||||
export function createOpenClawRoutes() {
|
||||
return new Hono()
|
||||
.get('/status', async (c) => {
|
||||
const status = await getOpenClawService().getStatus()
|
||||
return c.json(status)
|
||||
})
|
||||
|
||||
.post('/setup', async (c) => {
|
||||
const body = await c.req.json<{
|
||||
providerType?: string
|
||||
providerName?: string
|
||||
baseUrl?: string
|
||||
apiKey?: string
|
||||
modelId?: string
|
||||
}>()
|
||||
|
||||
try {
|
||||
const logs: string[] = []
|
||||
await getOpenClawService().setup(body, (msg) => logs.push(msg))
|
||||
|
||||
const agents = await getOpenClawService().listAgents()
|
||||
return c.json(
|
||||
{
|
||||
status: 'running',
|
||||
port: OPENCLAW_GATEWAY_PORT,
|
||||
agents: agents.map((a) => ({
|
||||
agentId: a.agentId,
|
||||
name: a.name,
|
||||
status: 'running',
|
||||
})),
|
||||
logs,
|
||||
},
|
||||
201,
|
||||
)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
if (message.includes('Podman is not available')) {
|
||||
return c.json({ error: message }, 503)
|
||||
}
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.post('/start', async (c) => {
|
||||
try {
|
||||
await getOpenClawService().start()
|
||||
return c.json({ status: 'running' })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.post('/stop', async (c) => {
|
||||
try {
|
||||
await getOpenClawService().stop()
|
||||
return c.json({ status: 'stopped' })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.post('/restart', async (c) => {
|
||||
try {
|
||||
await getOpenClawService().restart()
|
||||
return c.json({ status: 'running' })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.post('/reconnect', async (c) => {
|
||||
try {
|
||||
await getOpenClawService().reconnectControlPlane()
|
||||
return c.json({ status: 'connected' })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.get('/agents', async (c) => {
|
||||
try {
|
||||
const agents = await getOpenClawService().listAgents()
|
||||
return c.json({ agents })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.get('/agents/:id/programs', async (c) => {
|
||||
try {
|
||||
const agent = await findOpenClawAgent(c.req.param('id'))
|
||||
if (!agent) {
|
||||
return c.json({ error: 'Agent not found' }, 404)
|
||||
}
|
||||
|
||||
const programs = await openclawProgramStorage.listPrograms(agent.name)
|
||||
return c.json({ programs })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.post('/agents/:id/programs', async (c) => {
|
||||
try {
|
||||
const agent = await findOpenClawAgent(c.req.param('id'))
|
||||
if (!agent) {
|
||||
return c.json({ error: 'Agent not found' }, 404)
|
||||
}
|
||||
|
||||
const input = validateCreateProgramInput(await c.req.json())
|
||||
const program = await openclawProgramStorage.createProgram(agent, input)
|
||||
await openclawProgramMaterializer.syncAgentPrograms(agent.name)
|
||||
return c.json({ program }, 201)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
if (
|
||||
message.includes('required') ||
|
||||
message.includes('must be') ||
|
||||
message.includes('invalid')
|
||||
) {
|
||||
return c.json({ error: message }, 400)
|
||||
}
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.patch('/agents/:id/programs/:programId', async (c) => {
|
||||
try {
|
||||
const agent = await findOpenClawAgent(c.req.param('id'))
|
||||
if (!agent) {
|
||||
return c.json({ error: 'Agent not found' }, 404)
|
||||
}
|
||||
|
||||
const input = validateUpdateProgramInput(await c.req.json())
|
||||
const program = await openclawProgramStorage.updateProgram(
|
||||
agent.name,
|
||||
c.req.param('programId'),
|
||||
input,
|
||||
)
|
||||
if (!program) {
|
||||
return c.json({ error: 'Program not found' }, 404)
|
||||
}
|
||||
|
||||
await openclawProgramMaterializer.syncAgentPrograms(agent.name)
|
||||
return c.json({ program })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
if (
|
||||
message.includes('required') ||
|
||||
message.includes('must be') ||
|
||||
message.includes('invalid') ||
|
||||
message.includes('At least one')
|
||||
) {
|
||||
return c.json({ error: message }, 400)
|
||||
}
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.delete('/agents/:id/programs/:programId', async (c) => {
|
||||
try {
|
||||
const agent = await findOpenClawAgent(c.req.param('id'))
|
||||
if (!agent) {
|
||||
return c.json({ error: 'Agent not found' }, 404)
|
||||
}
|
||||
|
||||
const deleted = await openclawProgramStorage.deleteProgram(
|
||||
agent.name,
|
||||
c.req.param('programId'),
|
||||
)
|
||||
if (!deleted) {
|
||||
return c.json({ error: 'Program not found' }, 404)
|
||||
}
|
||||
|
||||
await openclawProgramMaterializer.syncAgentPrograms(agent.name)
|
||||
return c.json({ success: true })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.get('/agents/:id/program-runs', async (c) => {
|
||||
try {
|
||||
const agent = await findOpenClawAgent(c.req.param('id'))
|
||||
if (!agent) {
|
||||
return c.json({ error: 'Agent not found' }, 404)
|
||||
}
|
||||
|
||||
const runs = await openclawProgramStorage.listRuns(agent.name)
|
||||
return c.json({ runs })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.get('/roles', async (c) => {
|
||||
return c.json({
|
||||
roles: BROWSEROS_ROLE_TEMPLATES.map((role) => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
shortDescription: role.shortDescription,
|
||||
longDescription: role.longDescription,
|
||||
recommendedApps: role.recommendedApps,
|
||||
boundaries: role.boundaries,
|
||||
defaultAgentName: role.defaultAgentName,
|
||||
})),
|
||||
})
|
||||
})
|
||||
|
||||
.post('/agents', async (c) => {
|
||||
const body = await c.req.json<{
|
||||
name: string
|
||||
roleId?: BrowserOSAgentRoleId
|
||||
customRole?: BrowserOSCustomRoleInput
|
||||
providerType?: string
|
||||
providerName?: string
|
||||
baseUrl?: string
|
||||
apiKey?: string
|
||||
modelId?: string
|
||||
}>()
|
||||
const name = body.name?.trim()
|
||||
|
||||
if (!name) {
|
||||
return c.json({ error: 'Name is required' }, 400)
|
||||
}
|
||||
if (body.roleId && body.customRole) {
|
||||
return c.json(
|
||||
{ error: 'Provide either roleId or customRole, not both' },
|
||||
400,
|
||||
)
|
||||
}
|
||||
if (
|
||||
body.customRole &&
|
||||
(!body.customRole.name?.trim() ||
|
||||
!body.customRole.shortDescription?.trim() ||
|
||||
!body.customRole.longDescription?.trim())
|
||||
) {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
'Custom roles require name, shortDescription, and longDescription',
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
if (
|
||||
body.customRole &&
|
||||
(!Array.isArray(body.customRole.recommendedApps) ||
|
||||
!Array.isArray(body.customRole.boundaries))
|
||||
) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'Custom roles require recommendedApps and boundaries arrays',
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
if (
|
||||
body.customRole &&
|
||||
!body.customRole.recommendedApps.every((app) => typeof app === 'string')
|
||||
) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'Custom role recommendedApps must be an array of strings',
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
if (
|
||||
body.customRole &&
|
||||
!body.customRole.boundaries.every(isValidCustomRoleBoundary)
|
||||
) {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
'Custom role boundaries must include key, label, description, and a valid defaultMode',
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const agent = await getOpenClawService().createAgent({
|
||||
name,
|
||||
roleId: body.roleId,
|
||||
customRole: body.customRole,
|
||||
providerType: body.providerType,
|
||||
providerName: body.providerName,
|
||||
baseUrl: body.baseUrl,
|
||||
apiKey: body.apiKey,
|
||||
modelId: body.modelId,
|
||||
})
|
||||
return c.json({ agent }, 201)
|
||||
} catch (err) {
|
||||
if (err instanceof OpenClawAgentAlreadyExistsError) {
|
||||
return c.json({ error: err.message }, 409)
|
||||
}
|
||||
if (err instanceof OpenClawInvalidAgentNameError) {
|
||||
return c.json({ error: err.message }, 400)
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.delete('/agents/:id', async (c) => {
|
||||
const { id } = c.req.param()
|
||||
|
||||
try {
|
||||
await getOpenClawService().removeAgent(id)
|
||||
return c.json({ success: true })
|
||||
} catch (err) {
|
||||
if (err instanceof OpenClawAgentNotFoundError) {
|
||||
return c.json({ error: err.message }, 404)
|
||||
}
|
||||
if (err instanceof OpenClawProtectedAgentError) {
|
||||
return c.json({ error: err.message }, 400)
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.post('/agents/:id/chat', async (c) => {
|
||||
const { id } = c.req.param()
|
||||
const body = await c.req.json<{
|
||||
message: string
|
||||
sessionKey?: string
|
||||
}>()
|
||||
|
||||
if (!body.message?.trim()) {
|
||||
return c.json({ error: 'Message is required' }, 400)
|
||||
}
|
||||
|
||||
const sessionKey = body.sessionKey ?? crypto.randomUUID()
|
||||
|
||||
try {
|
||||
const eventStream = await getOpenClawService().chatStream(
|
||||
id,
|
||||
sessionKey,
|
||||
body.message,
|
||||
)
|
||||
|
||||
c.header('Content-Type', 'text/event-stream')
|
||||
c.header('Cache-Control', 'no-cache')
|
||||
c.header('X-Session-Key', sessionKey)
|
||||
|
||||
return stream(c, async (s) => {
|
||||
const reader = eventStream.getReader()
|
||||
const encoder = new TextEncoder()
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
await s.write(
|
||||
encoder.encode(`data: ${JSON.stringify(value)}\n\n`),
|
||||
)
|
||||
}
|
||||
await s.write(encoder.encode('data: [DONE]\n\n'))
|
||||
} finally {
|
||||
await reader.cancel()
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.get('/logs', async (c) => {
|
||||
try {
|
||||
const logs = await getOpenClawService().getLogs()
|
||||
return c.json({ logs })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.post('/providers', async (c) => {
|
||||
const body = await c.req.json<{
|
||||
providerType: string
|
||||
apiKey: string
|
||||
providerName?: string
|
||||
baseUrl?: string
|
||||
modelId?: string
|
||||
}>()
|
||||
|
||||
if (!body.providerType || !body.apiKey) {
|
||||
return c.json({ error: 'providerType and apiKey are required' }, 400)
|
||||
}
|
||||
|
||||
try {
|
||||
await getOpenClawService().updateProviderKeys(body)
|
||||
return c.json({
|
||||
status: 'restarting',
|
||||
message: 'Provider updated, restarting gateway',
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Hono } from 'hono'
|
||||
import { upgradeWebSocket } from 'hono/bun'
|
||||
import { logger } from '../../lib/logger'
|
||||
import {
|
||||
parseTerminalClientMessage,
|
||||
serializeTerminalServerMessage,
|
||||
} from '../services/terminal/terminal-protocol'
|
||||
import {
|
||||
createTerminalSession,
|
||||
TERMINAL_HOME_DIR,
|
||||
type TerminalSession,
|
||||
} from '../services/terminal/terminal-session'
|
||||
import type { Env } from '../types'
|
||||
|
||||
export const TERMINAL_WS_PATH = '/terminal/ws'
|
||||
|
||||
interface TerminalRouteDeps {
|
||||
containerName: string
|
||||
podmanPath: string
|
||||
}
|
||||
|
||||
function safeSend(ws: { send(data: string): void }, data: string): void {
|
||||
try {
|
||||
ws.send(data)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function sendOutput(ws: { send(data: string): void }, data: string): void {
|
||||
safeSend(ws, serializeTerminalServerMessage({ type: 'output', data }))
|
||||
}
|
||||
|
||||
function sendError(ws: { send(data: string): void }, message: string): void {
|
||||
safeSend(ws, serializeTerminalServerMessage({ type: 'error', message }))
|
||||
}
|
||||
|
||||
function sendExit(ws: { send(data: string): void }, exitCode: number): void {
|
||||
safeSend(ws, serializeTerminalServerMessage({ type: 'exit', exitCode }))
|
||||
}
|
||||
|
||||
function createSocketEvents(deps: TerminalRouteDeps) {
|
||||
let session: TerminalSession | null = null
|
||||
|
||||
return {
|
||||
onOpen(_event: Event, ws: { send(data: string): void; close(): void }) {
|
||||
try {
|
||||
session = createTerminalSession({
|
||||
containerName: deps.containerName,
|
||||
podmanPath: deps.podmanPath,
|
||||
workingDir: TERMINAL_HOME_DIR,
|
||||
onOutput(data) {
|
||||
sendOutput(ws, data)
|
||||
},
|
||||
onExit(exitCode) {
|
||||
sendExit(ws, exitCode)
|
||||
ws.close()
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
logger.warn('Failed to start terminal session', { error: message })
|
||||
sendError(ws, message)
|
||||
ws.close()
|
||||
}
|
||||
},
|
||||
onMessage(event: MessageEvent, _ws: { send(data: string): void }) {
|
||||
const message = parseTerminalClientMessage(event.data)
|
||||
if (!session || !message) return
|
||||
|
||||
if (message.type === 'input') {
|
||||
session.writeInput(message.data)
|
||||
} else {
|
||||
session.resize(message.cols, message.rows)
|
||||
}
|
||||
},
|
||||
onClose() {
|
||||
session?.close()
|
||||
session = null
|
||||
},
|
||||
onError(_event: Event, ws: { send(data: string): void; close(): void }) {
|
||||
if (!session) return
|
||||
session.close()
|
||||
session = null
|
||||
sendError(ws, 'Terminal connection error')
|
||||
ws.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createTerminalRoutes(deps: TerminalRouteDeps) {
|
||||
return new Hono<Env>().get(
|
||||
'/ws',
|
||||
upgradeWebSocket(() => createSocketEvents(deps)),
|
||||
)
|
||||
}
|
||||
@@ -10,7 +10,9 @@
|
||||
* - MCP HTTP routes (using @hono/mcp transport)
|
||||
*/
|
||||
|
||||
import { OPENCLAW_GATEWAY_CONTAINER_NAME } from '@browseros/shared/constants/openclaw'
|
||||
import { Hono } from 'hono'
|
||||
import { websocket } from 'hono/bun'
|
||||
import { cors } from 'hono/cors'
|
||||
import type { ContentfulStatusCode } from 'hono/utils/http-status'
|
||||
import { HttpAgentError } from '../agent/errors'
|
||||
@@ -27,6 +29,7 @@ import { createKlavisRoutes } from './routes/klavis'
|
||||
import { createMcpRoutes } from './routes/mcp'
|
||||
import { createMemoryRoutes } from './routes/memory'
|
||||
import { createOAuthRoutes } from './routes/oauth'
|
||||
import { createOpenClawRoutes } from './routes/openclaw'
|
||||
import { createProviderRoutes } from './routes/provider'
|
||||
import { createRefinePromptRoutes } from './routes/refine-prompt'
|
||||
import { createSdkRoutes } from './routes/sdk'
|
||||
@@ -34,12 +37,15 @@ import { createShutdownRoute } from './routes/shutdown'
|
||||
import { createSkillsRoutes } from './routes/skills'
|
||||
import { createSoulRoutes } from './routes/soul'
|
||||
import { createStatusRoute } from './routes/status'
|
||||
import { createTerminalRoutes } from './routes/terminal'
|
||||
import {
|
||||
connectKlavisProxy,
|
||||
type KlavisProxyHandle,
|
||||
} from './services/klavis/strata-proxy'
|
||||
import { getPodmanRuntime } from './services/openclaw/podman-runtime'
|
||||
import type { Env, HttpServerConfig } from './types'
|
||||
import { defaultCorsConfig } from './utils/cors'
|
||||
import { requireTrustedAppOrigin } from './utils/request-auth'
|
||||
|
||||
async function assertPortAvailable(port: number): Promise<void> {
|
||||
const net = await import('node:net')
|
||||
@@ -101,6 +107,20 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
const clawRoutes = new Hono<Env>()
|
||||
.use('/*', requireTrustedAppOrigin())
|
||||
.route('/', createOpenClawRoutes())
|
||||
|
||||
const terminalRoutes = new Hono<Env>()
|
||||
.use('/*', requireTrustedAppOrigin())
|
||||
.route(
|
||||
'/',
|
||||
createTerminalRoutes({
|
||||
containerName: OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
podmanPath: getPodmanRuntime().getPodmanPath(),
|
||||
}),
|
||||
)
|
||||
|
||||
const app = new Hono<Env>()
|
||||
.use('/*', cors(defaultCorsConfig))
|
||||
.route('/health', createHealthRoute({ browser }))
|
||||
@@ -170,6 +190,7 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
browserosId,
|
||||
}),
|
||||
)
|
||||
.route('/claw', clawRoutes)
|
||||
|
||||
// Error handler
|
||||
app.onError((err, c) => {
|
||||
@@ -211,11 +232,14 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
|
||||
await assertPortAvailable(port)
|
||||
|
||||
app.route('/terminal', terminalRoutes)
|
||||
|
||||
const server = Bun.serve({
|
||||
fetch: (request, server) => app.fetch(request, { server }),
|
||||
port,
|
||||
hostname: host,
|
||||
idleTimeout: 0,
|
||||
websocket,
|
||||
})
|
||||
|
||||
logger.info('Consolidated HTTP Server started', { port, host })
|
||||
|
||||
@@ -64,14 +64,18 @@ export class ChatService {
|
||||
origin: request.origin,
|
||||
declinedApps: request.declinedApps,
|
||||
browserosId: this.deps.browserosId,
|
||||
toolApprovalConfig: request.toolApprovalConfig,
|
||||
}
|
||||
|
||||
let session = sessionStore.get(request.conversationId)
|
||||
let isNewSession = false
|
||||
const contextChanges: string[] = []
|
||||
|
||||
// Build a stable key from enabled MCP servers for change detection
|
||||
// Build stable keys for change detection
|
||||
const mcpServerKey = this.buildMcpServerKey(request.browserContext)
|
||||
const approvalConfigKey = this.buildApprovalConfigKey(
|
||||
request.toolApprovalConfig,
|
||||
)
|
||||
|
||||
// Detect MCP config change mid-conversation → rebuild session
|
||||
if (session && session.mcpServerKey !== mcpServerKey) {
|
||||
@@ -144,6 +148,20 @@ export class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
// Detect approval config change mid-conversation → rebuild session
|
||||
if (session && session.approvalConfigKey !== approvalConfigKey) {
|
||||
logger.info(
|
||||
'Approval config changed mid-conversation, rebuilding session',
|
||||
{ conversationId: request.conversationId },
|
||||
)
|
||||
session = await this.rebuildSession(
|
||||
session,
|
||||
request,
|
||||
agentConfig,
|
||||
mcpServerKey,
|
||||
)
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
isNewSession = true
|
||||
let hiddenPageId: number | undefined
|
||||
@@ -202,6 +220,7 @@ export class ChatService {
|
||||
klavisClient: this.deps.klavisClient,
|
||||
browserosId: this.deps.browserosId,
|
||||
aiSdkDevtoolsEnabled: this.deps.aiSdkDevtoolsEnabled,
|
||||
aclRules: request.aclRules,
|
||||
})
|
||||
session = {
|
||||
agent,
|
||||
@@ -209,10 +228,13 @@ export class ChatService {
|
||||
browserContext,
|
||||
mcpServerKey,
|
||||
workingDir: request.userWorkingDir,
|
||||
approvalConfigKey,
|
||||
}
|
||||
sessionStore.set(request.conversationId, session)
|
||||
}
|
||||
|
||||
session.agent.updateAclRules(request.aclRules)
|
||||
|
||||
if (isNewSession && request.previousConversation?.length) {
|
||||
for (const msg of request.previousConversation) {
|
||||
if (!msg.content.trim()) continue
|
||||
@@ -228,6 +250,26 @@ export class ChatService {
|
||||
})
|
||||
}
|
||||
|
||||
// Handle tool approval responses: patch the agent's messages and re-run
|
||||
if (request.toolApprovalResponses?.length) {
|
||||
this.applyToolApprovalResponses(
|
||||
session.agent.messages,
|
||||
request.toolApprovalResponses,
|
||||
)
|
||||
logger.info('Applied tool approval responses', {
|
||||
conversationId: request.conversationId,
|
||||
count: request.toolApprovalResponses.length,
|
||||
})
|
||||
return createAgentUIStreamResponse({
|
||||
agent: session.agent.toolLoopAgent,
|
||||
uiMessages: filterValidMessages(session.agent.messages),
|
||||
abortSignal,
|
||||
onFinish: async ({ messages }: { messages: UIMessage[] }) => {
|
||||
session.agent.messages = filterValidMessages(messages)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const messageContext = request.isScheduledTask
|
||||
? (session.browserContext ?? request.browserContext)
|
||||
: request.browserContext
|
||||
@@ -358,6 +400,7 @@ export class ChatService {
|
||||
klavisClient: this.deps.klavisClient,
|
||||
browserosId: this.deps.browserosId,
|
||||
aiSdkDevtoolsEnabled: this.deps.aiSdkDevtoolsEnabled,
|
||||
aclRules: request.aclRules,
|
||||
})
|
||||
const newSession: AgentSession = {
|
||||
agent,
|
||||
@@ -365,6 +408,9 @@ export class ChatService {
|
||||
browserContext,
|
||||
mcpServerKey,
|
||||
workingDir: request.userWorkingDir,
|
||||
approvalConfigKey: this.buildApprovalConfigKey(
|
||||
request.toolApprovalConfig,
|
||||
),
|
||||
}
|
||||
newSession.agent.messages = sanitizeMessagesForToolset(
|
||||
previousMessages,
|
||||
@@ -374,6 +420,51 @@ export class ChatService {
|
||||
return newSession
|
||||
}
|
||||
|
||||
private applyToolApprovalResponses(
|
||||
messages: UIMessage[],
|
||||
responses: Array<{
|
||||
approvalId: string
|
||||
approved: boolean
|
||||
reason?: string
|
||||
}>,
|
||||
): void {
|
||||
const responseMap = new Map(responses.map((r) => [r.approvalId, r]))
|
||||
for (const msg of messages) {
|
||||
if (msg.role !== 'assistant') continue
|
||||
for (const part of msg.parts) {
|
||||
const toolPart = part as {
|
||||
state?: string
|
||||
approval?: { id: string; approved?: boolean; reason?: string }
|
||||
}
|
||||
if (
|
||||
toolPart.state === 'approval-requested' &&
|
||||
toolPart.approval?.id &&
|
||||
responseMap.has(toolPart.approval.id)
|
||||
) {
|
||||
const resp = responseMap.get(toolPart.approval.id)
|
||||
if (!resp) continue
|
||||
toolPart.state = 'approval-responded'
|
||||
toolPart.approval = {
|
||||
...toolPart.approval,
|
||||
approved: resp.approved,
|
||||
reason: resp.reason,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private buildApprovalConfigKey(config?: {
|
||||
categories: Record<string, boolean>
|
||||
}): string {
|
||||
if (!config) return ''
|
||||
return Object.entries(config.categories)
|
||||
.filter(([, v]) => v)
|
||||
.map(([k]) => k)
|
||||
.sort()
|
||||
.join(',')
|
||||
}
|
||||
|
||||
private buildMcpServerKey(browserContext?: BrowserContext): string {
|
||||
const managed = browserContext?.enabledMcpServers?.slice().sort() ?? []
|
||||
const custom =
|
||||
|
||||
@@ -9,8 +9,9 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import type { CallToolResult, Tool } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { z } from 'zod'
|
||||
import { jsonSchemaObjectToZodRawShape } from 'zod-from-json-schema'
|
||||
import type { KlavisClient } from '../../../lib/clients/klavis/klavis-client'
|
||||
import { KlavisClient } from '../../../lib/clients/klavis/klavis-client'
|
||||
import { OAUTH_MCP_SERVERS } from '../../../lib/clients/klavis/oauth-mcp-servers'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import { metrics } from '../../../lib/metrics'
|
||||
@@ -28,6 +29,7 @@ function withTimeout<T>(promise: Promise<T>, label: string): Promise<T> {
|
||||
}
|
||||
|
||||
export interface KlavisProxyHandle {
|
||||
browserosId: string
|
||||
tools: Tool[]
|
||||
inputSchemas: Map<string, Record<string, never>>
|
||||
callTool: (
|
||||
@@ -85,6 +87,7 @@ export async function connectKlavisProxy(
|
||||
})
|
||||
|
||||
return {
|
||||
browserosId: deps.browserosId,
|
||||
tools,
|
||||
inputSchemas,
|
||||
callTool: (name, args) =>
|
||||
@@ -93,10 +96,121 @@ export async function connectKlavisProxy(
|
||||
}
|
||||
}
|
||||
|
||||
const serverNames = OAUTH_MCP_SERVERS.map((s) => s.name) as [
|
||||
string,
|
||||
...string[],
|
||||
]
|
||||
|
||||
const serverDescriptions = OAUTH_MCP_SERVERS.map(
|
||||
(s) => `${s.name} (${s.description})`,
|
||||
).join(', ')
|
||||
|
||||
// Double cast works around TS2589 in registerTool's recursive generics.
|
||||
const connectorInputSchema = {
|
||||
server_name: z
|
||||
.enum(serverNames)
|
||||
.describe(
|
||||
`The name of the service to check. Available: ${serverDescriptions}`,
|
||||
),
|
||||
} as unknown as Record<string, never>
|
||||
|
||||
export function registerKlavisTools(
|
||||
mcpServer: McpServer,
|
||||
handle: KlavisProxyHandle,
|
||||
): void {
|
||||
// Register the connector discovery tool
|
||||
mcpServer.registerTool(
|
||||
'connector_mcp_servers',
|
||||
{
|
||||
description:
|
||||
'Check if an external service is connected and ready for use with Strata MCP tools (discover_server_categories_or_actions, execute_action, etc.). Call this BEFORE using any Strata integration tool. If connected, proceed with Strata tools. If not connected, returns an authUrl — prompt the user to open it and authenticate.',
|
||||
inputSchema: connectorInputSchema,
|
||||
},
|
||||
async (args: Record<string, unknown>) => {
|
||||
const startTime = performance.now()
|
||||
const server_name = args.server_name as string
|
||||
|
||||
try {
|
||||
const klavisClient = new KlavisClient()
|
||||
const integrations = await klavisClient.getUserIntegrations(
|
||||
handle.browserosId,
|
||||
)
|
||||
|
||||
const integration = integrations.find((i) => i.name === server_name)
|
||||
const isConnected = integration?.isAuthenticated === true
|
||||
|
||||
if (isConnected) {
|
||||
metrics.log('tool_executed', {
|
||||
tool_name: 'connector_mcp_servers',
|
||||
source: 'mcp',
|
||||
duration_ms: Math.round(performance.now() - startTime),
|
||||
success: true,
|
||||
})
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
connected: true,
|
||||
server_name,
|
||||
}),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// Not connected — get auth URL
|
||||
const strata = await klavisClient.createStrata(handle.browserosId, [
|
||||
server_name,
|
||||
])
|
||||
const authUrl =
|
||||
strata.oauthUrls?.[server_name] ??
|
||||
strata.apiKeyUrls?.[server_name] ??
|
||||
null
|
||||
|
||||
metrics.log('tool_executed', {
|
||||
tool_name: 'connector_mcp_servers',
|
||||
source: 'mcp',
|
||||
duration_ms: Math.round(performance.now() - startTime),
|
||||
success: true,
|
||||
})
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
connected: false,
|
||||
server_name,
|
||||
authUrl,
|
||||
message: authUrl
|
||||
? `${server_name} is not connected. Ask the user to open this URL to authenticate: ${authUrl}`
|
||||
: `${server_name} is not connected. Could not retrieve auth URL.`,
|
||||
}),
|
||||
},
|
||||
],
|
||||
}
|
||||
} catch (error) {
|
||||
const errorText = error instanceof Error ? error.message : String(error)
|
||||
|
||||
metrics.log('tool_executed', {
|
||||
tool_name: 'connector_mcp_servers',
|
||||
source: 'mcp',
|
||||
duration_ms: Math.round(performance.now() - startTime),
|
||||
success: false,
|
||||
error_message: errorText,
|
||||
})
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: errorText }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Register Strata proxy tools
|
||||
for (const tool of handle.tools) {
|
||||
const inputSchema = handle.inputSchemas.get(tool.name)
|
||||
|
||||
@@ -141,6 +255,6 @@ export function registerKlavisTools(
|
||||
}
|
||||
|
||||
logger.debug('Registered Klavis tools on MCP server', {
|
||||
count: handle.tools.length,
|
||||
count: handle.tools.length + 1,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -27,16 +27,21 @@ Error recovery:
|
||||
|
||||
40+ services: Gmail, Slack, GitHub, Notion, Google Calendar, Jira, Linear, Figma, Salesforce, and more.
|
||||
|
||||
Before using any Strata integration, call connector_mcp_servers(server_name) to verify the service is connected.
|
||||
- If connected → proceed with Strata discovery tools below.
|
||||
- If not connected → prompt the user with the returned authUrl to authenticate. After they confirm, call connector_mcp_servers again to verify.
|
||||
|
||||
Progressive discovery — do not guess action names:
|
||||
1. discover_server_categories_or_actions → always start here.
|
||||
2. get_category_actions → expand categories from step 1.
|
||||
3. get_action_details → get parameter schema before executing.
|
||||
4. execute_action → use include_output_fields to limit response size.
|
||||
5. search_documentation → fallback keyword search.
|
||||
1. connector_mcp_servers → check connection status first.
|
||||
2. discover_server_categories_or_actions → discover available actions.
|
||||
3. get_category_actions → expand categories from step 2.
|
||||
4. get_action_details → get parameter schema before executing.
|
||||
5. execute_action → use include_output_fields to limit response size.
|
||||
6. search_documentation → fallback keyword search.
|
||||
|
||||
Authentication — when execute_action returns an auth error:
|
||||
1. handle_auth_failure(server_name, intention: "get_auth_url").
|
||||
2. new_page(auth_url) to open in browser for user to authenticate.
|
||||
1. Call connector_mcp_servers(server_name) to get a fresh authUrl.
|
||||
2. Prompt the user to open the authUrl and authenticate.
|
||||
3. Wait for explicit user confirmation before retrying.
|
||||
|
||||
## General
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Compose-level abstraction over PodmanRuntime.
|
||||
* Manages a single compose project for the OpenClaw gateway container.
|
||||
*/
|
||||
|
||||
import { copyFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
OPENCLAW_COMPOSE_PROJECT_NAME,
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
} from '@browseros/shared/constants/openclaw'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import type { LogFn, PodmanRuntime } from './podman-runtime'
|
||||
|
||||
const COMPOSE_FILE_NAME = 'docker-compose.yml'
|
||||
const ENV_FILE_NAME = '.env'
|
||||
|
||||
export class ContainerRuntime {
|
||||
constructor(
|
||||
private podman: PodmanRuntime,
|
||||
private projectDir: string,
|
||||
) {}
|
||||
|
||||
async ensureReady(onLog?: LogFn): Promise<void> {
|
||||
return this.podman.ensureReady(onLog)
|
||||
}
|
||||
|
||||
async isPodmanAvailable(): Promise<boolean> {
|
||||
return this.podman.isPodmanAvailable()
|
||||
}
|
||||
|
||||
async getMachineStatus(): Promise<{
|
||||
initialized: boolean
|
||||
running: boolean
|
||||
}> {
|
||||
return this.podman.getMachineStatus()
|
||||
}
|
||||
|
||||
async composeUp(onLog?: LogFn): Promise<void> {
|
||||
const code = await this.compose(['up', '-d'], onLog)
|
||||
if (code !== 0) throw new Error(`compose up failed with code ${code}`)
|
||||
}
|
||||
|
||||
async composeDown(onLog?: LogFn): Promise<void> {
|
||||
const code = await this.compose(['down'], onLog)
|
||||
if (code !== 0) throw new Error(`compose down failed with code ${code}`)
|
||||
}
|
||||
|
||||
async composeStop(onLog?: LogFn): Promise<void> {
|
||||
const code = await this.compose(['stop'], onLog)
|
||||
if (code !== 0) throw new Error(`compose stop failed with code ${code}`)
|
||||
}
|
||||
|
||||
async composeRestart(onLog?: LogFn): Promise<void> {
|
||||
const code = await this.compose(['restart'], onLog)
|
||||
if (code !== 0) throw new Error(`compose restart failed with code ${code}`)
|
||||
}
|
||||
|
||||
async composePull(onLog?: LogFn): Promise<void> {
|
||||
const code = await this.compose(['pull', '--quiet'], onLog)
|
||||
if (code !== 0) throw new Error(`compose pull failed with code ${code}`)
|
||||
}
|
||||
|
||||
async composeLogs(tail = 50): Promise<string[]> {
|
||||
const lines: string[] = []
|
||||
await this.compose(['logs', '--no-color', '--tail', String(tail)], (line) =>
|
||||
lines.push(line),
|
||||
)
|
||||
return lines
|
||||
}
|
||||
|
||||
async isHealthy(port: number): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/healthz`)
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async isReady(port: number): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/readyz`)
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async waitForReady(port: number, timeoutMs = 30_000): Promise<boolean> {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (await this.isReady(port)) return true
|
||||
await Bun.sleep(1000)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async copyComposeFile(sourceTemplatePath: string): Promise<void> {
|
||||
await copyFile(sourceTemplatePath, join(this.projectDir, COMPOSE_FILE_NAME))
|
||||
}
|
||||
|
||||
async writeEnvFile(content: string): Promise<void> {
|
||||
await writeFile(join(this.projectDir, ENV_FILE_NAME), content, {
|
||||
mode: 0o600,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the Podman machine only if no non-BrowserOS containers are running.
|
||||
* Prevents killing the user's own Podman workloads.
|
||||
*/
|
||||
async stopMachineIfSafe(): Promise<void> {
|
||||
const status = await this.podman.getMachineStatus()
|
||||
if (!status.running) return
|
||||
|
||||
try {
|
||||
const containers = await this.podman.listRunningContainers()
|
||||
const allOurs = containers.every((name) =>
|
||||
name.startsWith(OPENCLAW_COMPOSE_PROJECT_NAME),
|
||||
)
|
||||
|
||||
if (containers.length === 0 || allOurs) {
|
||||
await this.podman.stopMachine()
|
||||
}
|
||||
} catch {
|
||||
// Best effort — don't stop machine if we can't check
|
||||
}
|
||||
}
|
||||
|
||||
async execInContainer(command: string[], onLog?: LogFn): Promise<number> {
|
||||
return this.podman.runCommand(
|
||||
['exec', OPENCLAW_GATEWAY_CONTAINER_NAME, ...command],
|
||||
{
|
||||
onOutput: onLog,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private async compose(args: string[], onLog?: LogFn): Promise<number> {
|
||||
const lines: string[] = []
|
||||
const code = await this.podman.runCommand(['compose', ...args], {
|
||||
cwd: this.projectDir,
|
||||
env: { COMPOSE_PROJECT_NAME: OPENCLAW_COMPOSE_PROJECT_NAME },
|
||||
onOutput: (line) => {
|
||||
lines.push(line)
|
||||
onLog?.(line)
|
||||
},
|
||||
})
|
||||
|
||||
if (code !== 0) {
|
||||
logger.error('OpenClaw compose command failed', {
|
||||
command: ['podman', 'compose', ...args].join(' '),
|
||||
projectDir: this.projectDir,
|
||||
exitCode: code,
|
||||
output: lines,
|
||||
})
|
||||
}
|
||||
|
||||
return code
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export class OpenClawInvalidAgentNameError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
'Agent name must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens',
|
||||
)
|
||||
this.name = 'OpenClawInvalidAgentNameError'
|
||||
}
|
||||
}
|
||||
|
||||
export class OpenClawAgentAlreadyExistsError extends Error {
|
||||
constructor(agentId: string) {
|
||||
super(`Agent "${agentId}" already exists`)
|
||||
this.name = 'OpenClawAgentAlreadyExistsError'
|
||||
}
|
||||
}
|
||||
|
||||
export class OpenClawAgentNotFoundError extends Error {
|
||||
constructor(agentId: string) {
|
||||
super(`Agent "${agentId}" not found`)
|
||||
this.name = 'OpenClawAgentNotFoundError'
|
||||
}
|
||||
}
|
||||
|
||||
export class OpenClawProtectedAgentError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'OpenClawProtectedAgentError'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,744 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* WebSocket client for the OpenClaw Gateway protocol.
|
||||
* Handles handshake (challenge → connect → hello-ok) with Ed25519 device
|
||||
* identity signing, JSON-RPC over WS, and auto-reconnect.
|
||||
* Used for agent CRUD and health — chat uses HTTP.
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { OPENCLAW_CONTAINER_HOME } from '@browseros/shared/constants/openclaw'
|
||||
import { logger } from '../../../lib/logger'
|
||||
|
||||
const RPC_TIMEOUT_MS = 15_000
|
||||
const SCOPES = [
|
||||
'operator.read',
|
||||
'operator.write',
|
||||
'operator.admin',
|
||||
'operator.approvals',
|
||||
'operator.pairing',
|
||||
]
|
||||
|
||||
interface DeviceIdentity {
|
||||
deviceId: string
|
||||
publicKeyPem: string
|
||||
privateKeyPem: string
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (value: unknown) => void
|
||||
reject: (reason: Error) => void
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
interface WsFrame {
|
||||
type: 'req' | 'res' | 'event'
|
||||
id?: string
|
||||
method?: string
|
||||
params?: Record<string, unknown>
|
||||
ok?: boolean
|
||||
payload?: Record<string, unknown>
|
||||
error?: { message: string; code?: string }
|
||||
event?: string
|
||||
}
|
||||
|
||||
export type GatewayClientConnectionState =
|
||||
| 'idle'
|
||||
| 'connecting'
|
||||
| 'connected'
|
||||
| 'closed'
|
||||
| 'failed'
|
||||
|
||||
export interface GatewayHandshakeError {
|
||||
code?: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface OpenClawStreamEvent {
|
||||
type:
|
||||
| 'text-delta'
|
||||
| 'thinking'
|
||||
| 'tool-start'
|
||||
| 'tool-end'
|
||||
| 'tool-output'
|
||||
| 'lifecycle'
|
||||
| 'done'
|
||||
| 'error'
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface GatewayAgentEntry {
|
||||
agentId: string
|
||||
name: string
|
||||
workspace: string
|
||||
model?: string
|
||||
}
|
||||
|
||||
// ── Device Identity Helpers ─────────────────────────────────────────
|
||||
|
||||
function rawPublicKeyFromPem(pem: string): Buffer {
|
||||
const der = Buffer.from(
|
||||
pem.replace(/-----[^-]+-----/g, '').replace(/\s/g, ''),
|
||||
'base64',
|
||||
)
|
||||
return der.subarray(12)
|
||||
}
|
||||
|
||||
function signChallenge(
|
||||
device: DeviceIdentity,
|
||||
nonce: string,
|
||||
token: string,
|
||||
): { signature: string; signedAt: number; publicKey: string } {
|
||||
const signedAt = Date.now()
|
||||
const payload = `v3|${device.deviceId}|cli|cli|operator|${SCOPES.join(',')}|${signedAt}|${token}|${nonce}|${process.platform}|`
|
||||
const privateKey = crypto.createPrivateKey(device.privateKeyPem)
|
||||
const sig = crypto.sign(null, Buffer.from(payload, 'utf-8'), privateKey)
|
||||
|
||||
return {
|
||||
signature: sig.toString('base64url'),
|
||||
signedAt,
|
||||
publicKey: rawPublicKeyFromPem(device.publicKeyPem).toString('base64url'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a client Ed25519 identity and pre-seeds it into the gateway's
|
||||
* paired devices file so the gateway trusts it on next boot.
|
||||
* Must be called before compose up (or requires a restart after).
|
||||
*/
|
||||
export function ensureClientIdentity(openclawDir: string): DeviceIdentity {
|
||||
const identityPath = join(openclawDir, 'client-identity.json')
|
||||
|
||||
try {
|
||||
return JSON.parse(readFileSync(identityPath, 'utf-8'))
|
||||
} catch {
|
||||
// Generate new identity
|
||||
}
|
||||
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519')
|
||||
const publicKeyPem = publicKey
|
||||
.export({ type: 'spki', format: 'pem' })
|
||||
.toString()
|
||||
const privateKeyPem = privateKey
|
||||
.export({ type: 'pkcs8', format: 'pem' })
|
||||
.toString()
|
||||
|
||||
const rawPub = rawPublicKeyFromPem(publicKeyPem)
|
||||
const deviceId = crypto.createHash('sha256').update(rawPub).digest('hex')
|
||||
|
||||
const identity: DeviceIdentity = { deviceId, publicKeyPem, privateKeyPem }
|
||||
writeFileSync(identityPath, JSON.stringify(identity, null, 2), {
|
||||
mode: 0o600,
|
||||
})
|
||||
|
||||
seedPairedDevice(openclawDir, identity)
|
||||
logger.info('Generated client device identity and pre-seeded pairing')
|
||||
|
||||
return identity
|
||||
}
|
||||
|
||||
function seedPairedDevice(openclawDir: string, identity: DeviceIdentity): void {
|
||||
const devicesDir = join(openclawDir, 'devices')
|
||||
mkdirSync(devicesDir, { recursive: true })
|
||||
|
||||
const pairedPath = join(devicesDir, 'paired.json')
|
||||
let paired: Record<string, unknown> = {}
|
||||
try {
|
||||
paired = JSON.parse(readFileSync(pairedPath, 'utf-8'))
|
||||
} catch {
|
||||
// First time
|
||||
}
|
||||
|
||||
const rawPub = rawPublicKeyFromPem(identity.publicKeyPem)
|
||||
paired[identity.deviceId] = {
|
||||
deviceId: identity.deviceId,
|
||||
publicKey: rawPub.toString('base64url'),
|
||||
platform: process.platform,
|
||||
clientId: 'cli',
|
||||
clientMode: 'cli',
|
||||
role: 'operator',
|
||||
roles: ['operator'],
|
||||
scopes: SCOPES,
|
||||
pairedAt: Date.now(),
|
||||
label: 'browseros-server',
|
||||
}
|
||||
|
||||
writeFileSync(pairedPath, JSON.stringify(paired, null, 2), { mode: 0o600 })
|
||||
}
|
||||
|
||||
// ── Gateway Client ──────────────────────────────────────────────────
|
||||
|
||||
export class GatewayClient {
|
||||
private ws: WebSocket | null = null
|
||||
private _connected = false
|
||||
private pendingRequests = new Map<string, PendingRequest>()
|
||||
private device: DeviceIdentity | null = null
|
||||
private connectionState: GatewayClientConnectionState = 'idle'
|
||||
private lastHandshakeError: GatewayHandshakeError | null = null
|
||||
|
||||
constructor(
|
||||
private readonly port: number,
|
||||
private readonly token: string,
|
||||
private readonly openclawDir: string,
|
||||
private readonly version = '1.0.0',
|
||||
) {
|
||||
try {
|
||||
const identityPath = join(this.openclawDir, 'client-identity.json')
|
||||
this.device = JSON.parse(readFileSync(identityPath, 'utf-8'))
|
||||
} catch {
|
||||
logger.warn('Client device identity not found, WS auth may fail')
|
||||
}
|
||||
}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this._connected
|
||||
}
|
||||
|
||||
get state(): GatewayClientConnectionState {
|
||||
return this.connectionState
|
||||
}
|
||||
|
||||
get lastError(): GatewayHandshakeError | null {
|
||||
return this.lastHandshakeError
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.connectionState = 'connecting'
|
||||
this.lastHandshakeError = null
|
||||
const url = `ws://127.0.0.1:${this.port}`
|
||||
this.ws = new WebSocket(url, {
|
||||
headers: { Origin: `http://127.0.0.1:${this.port}` },
|
||||
} as unknown as string[])
|
||||
|
||||
let handshakeComplete = false
|
||||
let connectReqId: string | null = null
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
const frame = GatewayClient.parseFrame(event.data)
|
||||
if (!frame) return
|
||||
|
||||
if (!handshakeComplete) {
|
||||
if (frame.type === 'event' && frame.event === 'connect.challenge') {
|
||||
const nonce = (frame.payload as Record<string, unknown>)
|
||||
?.nonce as string
|
||||
connectReqId = globalThis.crypto.randomUUID()
|
||||
|
||||
const params: Record<string, unknown> = {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
client: {
|
||||
id: 'cli',
|
||||
version: this.version,
|
||||
platform: process.platform,
|
||||
mode: 'cli',
|
||||
},
|
||||
role: 'operator',
|
||||
scopes: SCOPES,
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: {},
|
||||
auth: { token: this.token },
|
||||
locale: 'en-US',
|
||||
userAgent: `browseros-server/${this.version}`,
|
||||
}
|
||||
|
||||
if (this.device && nonce) {
|
||||
const signed = signChallenge(this.device, nonce, this.token)
|
||||
params.device = {
|
||||
id: this.device.deviceId,
|
||||
publicKey: signed.publicKey,
|
||||
signature: signed.signature,
|
||||
signedAt: signed.signedAt,
|
||||
nonce,
|
||||
}
|
||||
}
|
||||
|
||||
this.ws?.send(
|
||||
JSON.stringify({
|
||||
type: 'req',
|
||||
id: connectReqId,
|
||||
method: 'connect',
|
||||
params,
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (frame.type === 'res' && frame.id === connectReqId) {
|
||||
if (frame.ok) {
|
||||
handshakeComplete = true
|
||||
this._connected = true
|
||||
this.connectionState = 'connected'
|
||||
logger.info('Gateway WS connected')
|
||||
resolve()
|
||||
} else {
|
||||
const msg = frame.error?.message ?? 'Handshake failed'
|
||||
this.connectionState = 'failed'
|
||||
this.lastHandshakeError = {
|
||||
message: msg,
|
||||
code: frame.error?.code,
|
||||
}
|
||||
logger.error('Gateway WS handshake rejected', {
|
||||
error: msg,
|
||||
code: frame.error?.code,
|
||||
})
|
||||
reject(new Error(msg))
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
this.resolvePendingRequest(frame)
|
||||
}
|
||||
|
||||
this.ws.onerror = (err) => {
|
||||
if (!handshakeComplete) {
|
||||
this.connectionState = 'failed'
|
||||
reject(
|
||||
new Error(
|
||||
`WS connection error: ${err instanceof Error ? err.message : 'unknown'}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this._connected = false
|
||||
this.connectionState = 'closed'
|
||||
this.rejectAllPending('WebSocket closed')
|
||||
if (handshakeComplete) {
|
||||
logger.info('Gateway WS disconnected')
|
||||
}
|
||||
this.ws = null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this._connected = false
|
||||
this.connectionState = 'closed'
|
||||
this.rejectAllPending('Client disconnecting')
|
||||
if (this.ws) {
|
||||
this.ws.onclose = null
|
||||
this.ws.close()
|
||||
this.ws = null
|
||||
}
|
||||
}
|
||||
|
||||
// ── RPC ──────────────────────────────────────────────────────────────
|
||||
|
||||
async rpc<T = Record<string, unknown>>(
|
||||
method: string,
|
||||
params: Record<string, unknown> = {},
|
||||
): Promise<T> {
|
||||
if (!this._connected || !this.ws) {
|
||||
throw new Error('Gateway WS not connected')
|
||||
}
|
||||
|
||||
const id = globalThis.crypto.randomUUID()
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingRequests.delete(id)
|
||||
reject(new Error(`RPC timeout: ${method}`))
|
||||
}, RPC_TIMEOUT_MS)
|
||||
|
||||
this.pendingRequests.set(id, {
|
||||
resolve: resolve as (value: unknown) => void,
|
||||
reject,
|
||||
timer,
|
||||
})
|
||||
|
||||
this.ws?.send(JSON.stringify({ type: 'req', id, method, params }))
|
||||
})
|
||||
}
|
||||
|
||||
// ── Agent Methods ────────────────────────────────────────────────────
|
||||
|
||||
async listAgents(): Promise<GatewayAgentEntry[]> {
|
||||
const result = await this.rpc<{
|
||||
agents: Array<{
|
||||
id: string
|
||||
name?: string
|
||||
workspace: string
|
||||
model?: string
|
||||
}>
|
||||
}>('agents.list')
|
||||
|
||||
return (result.agents ?? []).map((a) => ({
|
||||
agentId: a.id,
|
||||
name: a.name ?? a.id,
|
||||
workspace: a.workspace,
|
||||
model: a.model,
|
||||
}))
|
||||
}
|
||||
|
||||
async createAgent(input: {
|
||||
name: string
|
||||
workspace: string
|
||||
model?: string
|
||||
}): Promise<GatewayAgentEntry> {
|
||||
const result = await this.rpc<{
|
||||
agentId?: string
|
||||
id?: string
|
||||
name?: string
|
||||
workspace?: string
|
||||
model?: string
|
||||
}>('agents.create', input)
|
||||
|
||||
return {
|
||||
agentId: result.agentId ?? result.id ?? input.name,
|
||||
name: result.name ?? input.name,
|
||||
workspace: result.workspace ?? input.workspace,
|
||||
model: result.model ?? input.model,
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAgent(agentId: string): Promise<void> {
|
||||
await this.rpc('agents.delete', { id: agentId })
|
||||
}
|
||||
|
||||
// ── Health ───────────────────────────────────────────────────────────
|
||||
|
||||
async getHealth(): Promise<Record<string, unknown>> {
|
||||
return this.rpc('health')
|
||||
}
|
||||
|
||||
// ── Chat Stream ─────────────────────────────────────────────────────
|
||||
|
||||
chatStream(
|
||||
agentId: string,
|
||||
sessionKey: string,
|
||||
message: string,
|
||||
): ReadableStream<OpenClawStreamEvent> {
|
||||
if (!this._connected) {
|
||||
throw new Error('Gateway WS not connected')
|
||||
}
|
||||
|
||||
const fullSessionKey = `agent:${agentId}:browseros-${sessionKey}`
|
||||
const idempotencyKey = globalThis.crypto.randomUUID()
|
||||
const streamClient = new GatewayClient(
|
||||
this.port,
|
||||
this.token,
|
||||
this.openclawDir,
|
||||
this.version,
|
||||
)
|
||||
|
||||
return new ReadableStream<OpenClawStreamEvent>({
|
||||
start: async (controller) => {
|
||||
try {
|
||||
await streamClient.connect()
|
||||
} catch (error) {
|
||||
controller.enqueue({
|
||||
type: 'error',
|
||||
data: {
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Gateway WS not connected',
|
||||
},
|
||||
})
|
||||
controller.close()
|
||||
return
|
||||
}
|
||||
|
||||
const ws = streamClient.ws
|
||||
if (!ws) {
|
||||
controller.enqueue({
|
||||
type: 'error',
|
||||
data: { message: 'Gateway WS not connected' },
|
||||
})
|
||||
controller.close()
|
||||
return
|
||||
}
|
||||
|
||||
const subscribeId = globalThis.crypto.randomUUID()
|
||||
const agentReqId = globalThis.crypto.randomUUID()
|
||||
let finished = false
|
||||
|
||||
const finish = (event?: OpenClawStreamEvent) => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
if (event) controller.enqueue(event)
|
||||
controller.close()
|
||||
streamClient.disconnect()
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const frame = GatewayClient.parseFrame(event.data)
|
||||
if (!frame) return
|
||||
|
||||
if (
|
||||
this.handleChatStreamControlFrame(
|
||||
frame,
|
||||
subscribeId,
|
||||
agentReqId,
|
||||
finish,
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
this.handleChatStreamEventFrame(frame, controller, finish)
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
if (finished) return
|
||||
finish({
|
||||
type: 'error',
|
||||
data: { message: 'Gateway WS disconnected' },
|
||||
})
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
if (finished) return
|
||||
finish({
|
||||
type: 'error',
|
||||
data: { message: 'Gateway WS connection error' },
|
||||
})
|
||||
}
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'req',
|
||||
id: subscribeId,
|
||||
method: 'sessions.subscribe',
|
||||
params: { sessionKey: fullSessionKey },
|
||||
}),
|
||||
)
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'req',
|
||||
id: agentReqId,
|
||||
method: 'agent',
|
||||
params: {
|
||||
message,
|
||||
sessionKey: fullSessionKey,
|
||||
idempotencyKey,
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
cancel: () => {
|
||||
if (streamClient.ws?.readyState === WebSocket.OPEN) {
|
||||
streamClient.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'req',
|
||||
id: globalThis.crypto.randomUUID(),
|
||||
method: 'sessions.abort',
|
||||
params: { sessionKey: fullSessionKey },
|
||||
}),
|
||||
)
|
||||
}
|
||||
streamClient.disconnect()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
static agentWorkspace(name: string): string {
|
||||
return name === 'main'
|
||||
? `${OPENCLAW_CONTAINER_HOME}/workspace`
|
||||
: `${OPENCLAW_CONTAINER_HOME}/workspace-${name}`
|
||||
}
|
||||
|
||||
private static parseFrame(data: unknown): WsFrame | null {
|
||||
try {
|
||||
return JSON.parse(
|
||||
typeof data === 'string'
|
||||
? data
|
||||
: new TextDecoder().decode(data as ArrayBuffer),
|
||||
) as WsFrame
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private rejectAllPending(reason: string): void {
|
||||
for (const [id, pending] of this.pendingRequests) {
|
||||
clearTimeout(pending.timer)
|
||||
pending.reject(new Error(reason))
|
||||
this.pendingRequests.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
private resolvePendingRequest(frame: WsFrame): void {
|
||||
if (frame.type !== 'res' || !frame.id) return
|
||||
|
||||
const pending = this.pendingRequests.get(frame.id)
|
||||
if (!pending) return
|
||||
|
||||
this.pendingRequests.delete(frame.id)
|
||||
clearTimeout(pending.timer)
|
||||
if (frame.ok) {
|
||||
pending.resolve(frame.payload)
|
||||
} else {
|
||||
pending.reject(new Error(frame.error?.message ?? 'RPC error'))
|
||||
}
|
||||
}
|
||||
|
||||
private handleChatStreamControlFrame(
|
||||
frame: WsFrame,
|
||||
subscribeId: string,
|
||||
agentReqId: string,
|
||||
finish: (event?: OpenClawStreamEvent) => void,
|
||||
): boolean {
|
||||
if (frame.type !== 'res' || !frame.id) return false
|
||||
if (frame.id !== subscribeId && frame.id !== agentReqId) return false
|
||||
|
||||
if (!frame.ok) {
|
||||
finish({
|
||||
type: 'error',
|
||||
data: {
|
||||
message: frame.error?.message ?? 'RPC error',
|
||||
code: frame.error?.code,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private handleChatStreamEventFrame(
|
||||
frame: WsFrame,
|
||||
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
|
||||
finish: (event?: OpenClawStreamEvent) => void,
|
||||
): void {
|
||||
if (frame.type !== 'event' || !frame.event || !frame.payload) return
|
||||
|
||||
switch (frame.event) {
|
||||
case 'agent':
|
||||
this.handleAgentStreamEvent(frame.payload, controller)
|
||||
return
|
||||
case 'session.tool':
|
||||
this.handleSessionToolStreamEvent(frame.payload, controller)
|
||||
return
|
||||
case 'session.message':
|
||||
this.handleSessionMessageStreamEvent(frame.payload, controller)
|
||||
return
|
||||
case 'chat':
|
||||
this.handleChatCompletionEvent(frame.payload, finish)
|
||||
return
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private handleAgentStreamEvent(
|
||||
payload: Record<string, unknown>,
|
||||
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
|
||||
): void {
|
||||
const streamType = payload.stream as string | undefined
|
||||
const data = payload.data as Record<string, unknown> | undefined
|
||||
|
||||
if (streamType === 'assistant' && data?.delta) {
|
||||
controller.enqueue({
|
||||
type: 'text-delta',
|
||||
data: { text: data.delta },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (streamType === 'item' && data) {
|
||||
const phase = data.phase as string | undefined
|
||||
if (phase === 'start') {
|
||||
controller.enqueue({
|
||||
type: 'tool-start',
|
||||
data: {
|
||||
toolCallId: data.toolCallId ?? data.id,
|
||||
toolName: data.name ?? data.title,
|
||||
kind: data.kind,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (phase === 'end') {
|
||||
controller.enqueue({
|
||||
type: 'tool-end',
|
||||
data: {
|
||||
toolCallId: data.toolCallId ?? data.id,
|
||||
status: data.status,
|
||||
durationMs: data.durationMs,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (streamType === 'lifecycle') {
|
||||
controller.enqueue({
|
||||
type: 'lifecycle',
|
||||
data: { phase: data?.phase ?? payload.phase },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private handleSessionToolStreamEvent(
|
||||
payload: Record<string, unknown>,
|
||||
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
|
||||
): void {
|
||||
const toolData = (payload.data as Record<string, unknown>) ?? payload
|
||||
const phase = (toolData.phase as string) ?? (payload.phase as string)
|
||||
if (phase !== 'result') return
|
||||
|
||||
controller.enqueue({
|
||||
type: 'tool-output',
|
||||
data: {
|
||||
toolCallId: toolData.toolCallId,
|
||||
isError: toolData.isError ?? false,
|
||||
meta: toolData.meta,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private handleSessionMessageStreamEvent(
|
||||
payload: Record<string, unknown>,
|
||||
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
|
||||
): void {
|
||||
const message = payload.message as Record<string, unknown> | undefined
|
||||
if (message?.role !== 'assistant') return
|
||||
|
||||
const content = message.content as
|
||||
| Array<Record<string, unknown>>
|
||||
| undefined
|
||||
if (!content) return
|
||||
|
||||
for (const block of content) {
|
||||
if (block.type !== 'thinking') continue
|
||||
|
||||
const text =
|
||||
(block.thinking as string) ??
|
||||
(block.content as string) ??
|
||||
(block.text as string) ??
|
||||
''
|
||||
if (!text) continue
|
||||
|
||||
controller.enqueue({
|
||||
type: 'thinking',
|
||||
data: { text },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private handleChatCompletionEvent(
|
||||
payload: Record<string, unknown>,
|
||||
finish: (event?: OpenClawStreamEvent) => void,
|
||||
): void {
|
||||
if ((payload.state as string | undefined) !== 'final') return
|
||||
|
||||
finish({
|
||||
type: 'done',
|
||||
data: { text: (payload.text as string) ?? '' },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Pure functions for building OpenClaw bootstrap configuration.
|
||||
* Config is write-once at setup — agent CRUD uses WS RPC, not config edits.
|
||||
*/
|
||||
|
||||
import {
|
||||
OPENCLAW_CONTAINER_HOME,
|
||||
OPENCLAW_GATEWAY_PORT,
|
||||
} from '@browseros/shared/constants/openclaw'
|
||||
import { DEFAULT_PORTS } from '@browseros/shared/constants/ports'
|
||||
|
||||
const OPENCLAW_IMAGE = 'ghcr.io/openclaw/openclaw:latest'
|
||||
|
||||
export const 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',
|
||||
}
|
||||
|
||||
export interface OpenClawProviderInput {
|
||||
providerType?: string
|
||||
providerName?: string
|
||||
baseUrl?: string
|
||||
modelId?: string
|
||||
apiKey?: string
|
||||
}
|
||||
|
||||
export interface BootstrapConfigInput {
|
||||
gatewayPort: number
|
||||
gatewayToken: string
|
||||
browserosServerPort?: number
|
||||
providerType?: string
|
||||
providerName?: string
|
||||
baseUrl?: string
|
||||
modelId?: string
|
||||
}
|
||||
|
||||
export interface EnvFileInput {
|
||||
image?: string
|
||||
port?: number
|
||||
token: string
|
||||
configDir: string
|
||||
timezone?: string
|
||||
providerKeys?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface ResolvedProviderConfig {
|
||||
model?: string
|
||||
providerKeys: Record<string, string>
|
||||
models?: {
|
||||
mode: 'merge'
|
||||
providers: Record<string, Record<string, unknown>>
|
||||
}
|
||||
}
|
||||
|
||||
function hasBuiltinProvider(providerType?: string): providerType is string {
|
||||
return !!providerType && providerType in PROVIDER_ENV_MAP
|
||||
}
|
||||
|
||||
export function deriveOpenClawProviderId(providerInput: {
|
||||
providerType?: string
|
||||
providerName?: string
|
||||
baseUrl?: string
|
||||
}): string {
|
||||
const source =
|
||||
providerInput.providerName?.trim() ||
|
||||
providerInput.baseUrl?.trim() ||
|
||||
providerInput.providerType?.trim() ||
|
||||
'custom-provider'
|
||||
|
||||
const candidate = source
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
|
||||
return candidate || 'custom-provider'
|
||||
}
|
||||
|
||||
export function deriveOpenClawApiKeyEnvVar(providerId: string): string {
|
||||
return `${providerId.toUpperCase().replace(/-/g, '_')}_API_KEY`
|
||||
}
|
||||
|
||||
export function resolveProviderConfig(
|
||||
input: OpenClawProviderInput,
|
||||
): ResolvedProviderConfig {
|
||||
if (!input.providerType) {
|
||||
return { providerKeys: {} }
|
||||
}
|
||||
|
||||
if (hasBuiltinProvider(input.providerType)) {
|
||||
const providerKeys: Record<string, string> = {}
|
||||
if (input.apiKey) {
|
||||
providerKeys[PROVIDER_ENV_MAP[input.providerType]] = input.apiKey
|
||||
}
|
||||
|
||||
return {
|
||||
providerKeys,
|
||||
model: input.modelId
|
||||
? `${input.providerType}/${input.modelId}`
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
if (!input.baseUrl) {
|
||||
return { providerKeys: {} }
|
||||
}
|
||||
|
||||
const providerId = deriveOpenClawProviderId(input)
|
||||
const apiKeyEnvVar = deriveOpenClawApiKeyEnvVar(providerId)
|
||||
const providerKeys: Record<string, string> = {}
|
||||
|
||||
if (input.apiKey) {
|
||||
providerKeys[apiKeyEnvVar] = input.apiKey
|
||||
}
|
||||
|
||||
const providerConfig: Record<string, unknown> = {
|
||||
baseUrl: input.baseUrl,
|
||||
apiKey: `\${${apiKeyEnvVar}}`,
|
||||
api: 'openai-completions',
|
||||
}
|
||||
|
||||
if (input.modelId) {
|
||||
providerConfig.models = [{ id: input.modelId, name: input.modelId }]
|
||||
}
|
||||
|
||||
return {
|
||||
providerKeys,
|
||||
model: input.modelId ? `${providerId}/${input.modelId}` : undefined,
|
||||
models: {
|
||||
mode: 'merge',
|
||||
providers: {
|
||||
[providerId]: providerConfig,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function buildBootstrapConfig(
|
||||
input: BootstrapConfigInput,
|
||||
): Record<string, unknown> {
|
||||
const serverPort = input.browserosServerPort ?? DEFAULT_PORTS.server
|
||||
const provider = resolveProviderConfig(input)
|
||||
|
||||
const defaults: Record<string, unknown> = {
|
||||
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
|
||||
timeoutSeconds: 4200,
|
||||
thinkingDefault: 'adaptive',
|
||||
}
|
||||
|
||||
if (provider.model) {
|
||||
defaults.model = { primary: provider.model }
|
||||
}
|
||||
const config: Record<string, unknown> = {
|
||||
gateway: {
|
||||
mode: 'local',
|
||||
port: input.gatewayPort,
|
||||
bind: 'lan',
|
||||
auth: { mode: 'token', token: input.gatewayToken },
|
||||
reload: { mode: 'restart' },
|
||||
controlUi: {
|
||||
allowInsecureAuth: true,
|
||||
allowedOrigins: [
|
||||
`http://127.0.0.1:${input.gatewayPort}`,
|
||||
`http://localhost:${input.gatewayPort}`,
|
||||
],
|
||||
},
|
||||
http: {
|
||||
endpoints: {
|
||||
chatCompletions: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: { defaults },
|
||||
tools: {
|
||||
profile: 'full',
|
||||
web: {
|
||||
search: { provider: 'duckduckgo', enabled: true },
|
||||
},
|
||||
exec: {
|
||||
host: 'gateway',
|
||||
security: 'full',
|
||||
ask: 'off',
|
||||
},
|
||||
},
|
||||
cron: { enabled: true },
|
||||
hooks: {
|
||||
internal: {
|
||||
enabled: true,
|
||||
entries: {
|
||||
'boot-md': { enabled: true },
|
||||
'bootstrap-extra-files': { enabled: true },
|
||||
'session-memory': { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
servers: {
|
||||
browseros: {
|
||||
url: `http://host.containers.internal:${serverPort}/mcp`,
|
||||
transport: 'streamable-http',
|
||||
},
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: { enabled: false },
|
||||
},
|
||||
skills: {
|
||||
install: { nodeManager: 'bun' },
|
||||
},
|
||||
}
|
||||
|
||||
if (provider.models) {
|
||||
config.models = provider.models
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
export function buildEnvFile(input: EnvFileInput): string {
|
||||
const lines: string[] = [
|
||||
`OPENCLAW_IMAGE=${input.image ?? OPENCLAW_IMAGE}`,
|
||||
`OPENCLAW_GATEWAY_PORT=${input.port ?? OPENCLAW_GATEWAY_PORT}`,
|
||||
`OPENCLAW_GATEWAY_TOKEN=${input.token}`,
|
||||
`OPENCLAW_CONFIG_DIR=${input.configDir}`,
|
||||
`TZ=${input.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone}`,
|
||||
]
|
||||
|
||||
if (input.providerKeys) {
|
||||
for (const [key, value] of Object.entries(input.providerKeys)) {
|
||||
lines.push(`${key}=${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
export function resolveProviderKeys(
|
||||
input: OpenClawProviderInput,
|
||||
): Record<string, string> {
|
||||
return resolveProviderConfig(input).providerKeys
|
||||
}
|
||||
|
||||
export function resolveProviderModel(
|
||||
input: OpenClawProviderInput,
|
||||
): string | undefined {
|
||||
return resolveProviderConfig(input).model
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Abstraction over the Podman CLI for container lifecycle management.
|
||||
* Handles Podman machine init/start on macOS/Windows (where a Linux VM is required).
|
||||
* On Linux, machine operations are no-ops since Podman runs natively.
|
||||
*/
|
||||
|
||||
const isLinux = process.platform === 'linux'
|
||||
|
||||
export type LogFn = (msg: string) => void
|
||||
|
||||
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',
|
||||
})
|
||||
return (await proc.exited) === 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?: LogFn): Promise<void> {
|
||||
if (isLinux) return
|
||||
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
this.podmanPath,
|
||||
'machine',
|
||||
'init',
|
||||
'--cpus',
|
||||
'2',
|
||||
'--memory',
|
||||
'2048',
|
||||
'--disk-size',
|
||||
'10',
|
||||
],
|
||||
{ stdout: 'ignore', stderr: 'pipe' },
|
||||
)
|
||||
|
||||
await this.drainStderr(proc, onLog)
|
||||
const code = await proc.exited
|
||||
if (code !== 0)
|
||||
throw new Error(`podman machine init failed with code ${code}`)
|
||||
}
|
||||
|
||||
async startMachine(onLog?: LogFn): Promise<void> {
|
||||
if (isLinux) return
|
||||
|
||||
const proc = Bun.spawn([this.podmanPath, 'machine', 'start'], {
|
||||
stdout: 'ignore',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
|
||||
await this.drainStderr(proc, onLog)
|
||||
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?: LogFn): 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) {
|
||||
await Promise.all([
|
||||
this.drainStream(proc.stdout ?? null, options.onOutput),
|
||||
this.drainStream(proc.stderr ?? null, options.onOutput),
|
||||
])
|
||||
}
|
||||
|
||||
return proc.exited
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists running container names. Used to check whether non-BrowserOS
|
||||
* containers are running before stopping the Podman machine.
|
||||
*/
|
||||
async listRunningContainers(): Promise<string[]> {
|
||||
const proc = Bun.spawn([this.podmanPath, 'ps', '--format', '{{.Names}}'], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'ignore',
|
||||
})
|
||||
const output = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
|
||||
return output
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((name) => name.trim())
|
||||
}
|
||||
|
||||
private async drainStderr(
|
||||
proc: {
|
||||
stderr: ReadableStream<Uint8Array> | null
|
||||
exited: Promise<number>
|
||||
},
|
||||
onLog?: LogFn,
|
||||
): Promise<void> {
|
||||
if (!onLog || !proc.stderr) return
|
||||
await this.drainStream(proc.stderr, onLog)
|
||||
}
|
||||
|
||||
private async drainStream(
|
||||
stream: ReadableStream<Uint8Array> | null,
|
||||
onLine: (line: string) => void,
|
||||
): Promise<void> {
|
||||
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) onLine(trimmed)
|
||||
}
|
||||
}
|
||||
if (buffer.trim()) onLine(buffer.trim())
|
||||
}
|
||||
}
|
||||
|
||||
let runtime: PodmanRuntime | null = null
|
||||
|
||||
export function getPodmanRuntime(): PodmanRuntime {
|
||||
if (!runtime) runtime = new PodmanRuntime()
|
||||
return runtime
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import type { BrowserOSAgentProgram } from '@browseros/shared/types/role-programs'
|
||||
import type { OpenClawProgramStorage } from './program-storage'
|
||||
|
||||
function describeSchedule(program: BrowserOSAgentProgram): string {
|
||||
switch (program.schedule.type) {
|
||||
case 'manual':
|
||||
return 'manual only'
|
||||
case 'daily': {
|
||||
const weekdaySummary = program.schedule.daysOfWeek?.length
|
||||
? ` on ${program.schedule.daysOfWeek.join(', ')}`
|
||||
: ''
|
||||
return `daily at ${program.schedule.time}${weekdaySummary}`
|
||||
}
|
||||
case 'hourly':
|
||||
return `every ${program.schedule.interval} hour(s)`
|
||||
case 'minutes':
|
||||
return `every ${program.schedule.interval} minute(s)`
|
||||
}
|
||||
}
|
||||
|
||||
function buildProgramsMd(programs: BrowserOSAgentProgram[]): string {
|
||||
const sections =
|
||||
programs.length === 0
|
||||
? ['No BrowserOS-managed programs configured yet.']
|
||||
: programs.map(
|
||||
(program) => `## ${program.name}
|
||||
- Status: ${program.enabled ? 'enabled' : 'disabled'}
|
||||
- Schedule: ${describeSchedule(program)}
|
||||
- Goal: ${program.description}
|
||||
- Prompt: ${program.prompt}
|
||||
`,
|
||||
)
|
||||
|
||||
return `# BrowserOS Programs
|
||||
|
||||
This file is generated by BrowserOS. Edit program settings in BrowserOS, not here.
|
||||
|
||||
${sections.join('\n')}
|
||||
`
|
||||
}
|
||||
|
||||
function buildStandingOrdersMd(programs: BrowserOSAgentProgram[]): string {
|
||||
const sections = programs.flatMap((program) => {
|
||||
if (program.standingOrders.length === 0) return []
|
||||
const lines = program.standingOrders
|
||||
.map(
|
||||
(order) =>
|
||||
`- ${order.title} (${order.enabled ? 'enabled' : 'disabled'}): ${order.instruction}`,
|
||||
)
|
||||
.join('\n')
|
||||
|
||||
return [`## ${program.name}\n${lines}`]
|
||||
})
|
||||
|
||||
return `# Standing Orders
|
||||
|
||||
This file is generated by BrowserOS. Edit standing orders in BrowserOS, not here.
|
||||
|
||||
${sections.length > 0 ? sections.join('\n\n') : 'No standing orders configured yet.'}
|
||||
`
|
||||
}
|
||||
|
||||
function buildProgramsMetadata(
|
||||
agentName: string,
|
||||
programs: BrowserOSAgentProgram[],
|
||||
): string {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
agentName,
|
||||
programs: programs.map((program) => ({
|
||||
id: program.id,
|
||||
name: program.name,
|
||||
enabled: program.enabled,
|
||||
schedule: program.schedule,
|
||||
updatedAt: program.updatedAt,
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`
|
||||
}
|
||||
|
||||
export class OpenClawProgramMaterializer {
|
||||
constructor(
|
||||
private openclawDir: string,
|
||||
private storage: OpenClawProgramStorage,
|
||||
) {}
|
||||
|
||||
private getHostWorkspaceDir(agentName: string): string {
|
||||
return join(
|
||||
this.openclawDir,
|
||||
agentName === 'main' ? 'workspace' : `workspace-${agentName}`,
|
||||
)
|
||||
}
|
||||
|
||||
async syncAgentPrograms(agentName: string): Promise<void> {
|
||||
const programs = await this.storage.listPrograms(agentName)
|
||||
const workspaceDir = this.getHostWorkspaceDir(agentName)
|
||||
await mkdir(workspaceDir, { recursive: true })
|
||||
|
||||
await Promise.all([
|
||||
writeFile(join(workspaceDir, 'PROGRAMS.md'), buildProgramsMd(programs)),
|
||||
writeFile(
|
||||
join(workspaceDir, 'STANDING-ORDERS.md'),
|
||||
buildStandingOrdersMd(programs),
|
||||
),
|
||||
writeFile(
|
||||
join(workspaceDir, '.browseros-programs.json'),
|
||||
buildProgramsMetadata(agentName, programs),
|
||||
),
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import type {
|
||||
BrowserOSAgentProgram,
|
||||
BrowserOSProgramRun,
|
||||
CreateAgentProgramInput,
|
||||
UpdateAgentProgramInput,
|
||||
} from '@browseros/shared/types/role-programs'
|
||||
|
||||
interface ProgramStorageAgent {
|
||||
agentId: string
|
||||
name: string
|
||||
role?: {
|
||||
roleId?: string
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonFile<T>(filePath: string, fallback: T): Promise<T> {
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf-8')
|
||||
return JSON.parse(content) as T
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
|
||||
await mkdir(dirname(filePath), { recursive: true })
|
||||
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`)
|
||||
}
|
||||
|
||||
function sortPrograms(programs: BrowserOSAgentProgram[]) {
|
||||
return [...programs].sort((left, right) =>
|
||||
left.createdAt.localeCompare(right.createdAt),
|
||||
)
|
||||
}
|
||||
|
||||
export class OpenClawProgramStorage {
|
||||
constructor(private openclawDir: string) {}
|
||||
|
||||
private getProgramsFile(agentName: string): string {
|
||||
return join(this.openclawDir, 'programs', `${agentName}.json`)
|
||||
}
|
||||
|
||||
private getProgramRunsFile(agentName: string): string {
|
||||
return join(this.openclawDir, 'program-runs', `${agentName}.json`)
|
||||
}
|
||||
|
||||
async listPrograms(agentName: string): Promise<BrowserOSAgentProgram[]> {
|
||||
const programs = await readJsonFile<BrowserOSAgentProgram[]>(
|
||||
this.getProgramsFile(agentName),
|
||||
[],
|
||||
)
|
||||
return sortPrograms(programs)
|
||||
}
|
||||
|
||||
async getProgram(
|
||||
agentName: string,
|
||||
programId: string,
|
||||
): Promise<BrowserOSAgentProgram | null> {
|
||||
const programs = await this.listPrograms(agentName)
|
||||
return programs.find((program) => program.id === programId) ?? null
|
||||
}
|
||||
|
||||
async createProgram(
|
||||
agent: ProgramStorageAgent,
|
||||
input: CreateAgentProgramInput,
|
||||
): Promise<BrowserOSAgentProgram> {
|
||||
const programs = await this.listPrograms(agent.name)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const program: BrowserOSAgentProgram = {
|
||||
id: crypto.randomUUID(),
|
||||
agentId: agent.agentId,
|
||||
agentName: agent.name,
|
||||
roleId: agent.role?.roleId,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
prompt: input.prompt,
|
||||
schedule: input.schedule,
|
||||
enabled: input.enabled ?? true,
|
||||
standingOrders: input.standingOrders ?? [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
await writeJsonFile(this.getProgramsFile(agent.name), [
|
||||
...programs,
|
||||
program,
|
||||
])
|
||||
return program
|
||||
}
|
||||
|
||||
async updateProgram(
|
||||
agentName: string,
|
||||
programId: string,
|
||||
input: UpdateAgentProgramInput,
|
||||
): Promise<BrowserOSAgentProgram | null> {
|
||||
const programs = await this.listPrograms(agentName)
|
||||
const current = programs.find((program) => program.id === programId)
|
||||
if (!current) return null
|
||||
|
||||
const nextProgram: BrowserOSAgentProgram = {
|
||||
...current,
|
||||
...input,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
await writeJsonFile(
|
||||
this.getProgramsFile(agentName),
|
||||
programs.map((program) =>
|
||||
program.id === programId ? nextProgram : program,
|
||||
),
|
||||
)
|
||||
return nextProgram
|
||||
}
|
||||
|
||||
async deleteProgram(agentName: string, programId: string): Promise<boolean> {
|
||||
const programs = await this.listPrograms(agentName)
|
||||
const remaining = programs.filter((program) => program.id !== programId)
|
||||
if (remaining.length === programs.length) return false
|
||||
|
||||
await writeJsonFile(this.getProgramsFile(agentName), remaining)
|
||||
return true
|
||||
}
|
||||
|
||||
async listRuns(agentName: string): Promise<BrowserOSProgramRun[]> {
|
||||
return readJsonFile<BrowserOSProgramRun[]>(
|
||||
this.getProgramRunsFile(agentName),
|
||||
[],
|
||||
)
|
||||
}
|
||||
|
||||
async writeRuns(
|
||||
agentName: string,
|
||||
runs: BrowserOSProgramRun[],
|
||||
): Promise<void> {
|
||||
await writeJsonFile(this.getProgramRunsFile(agentName), runs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import type {
|
||||
BrowserOSProgramSchedule,
|
||||
BrowserOSStandingOrder,
|
||||
CreateAgentProgramInput,
|
||||
UpdateAgentProgramInput,
|
||||
} from '@browseros/shared/types/role-programs'
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function assertNonEmptyString(
|
||||
value: unknown,
|
||||
field: string,
|
||||
): asserts value is string {
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
throw new Error(`${field} is required`)
|
||||
}
|
||||
}
|
||||
|
||||
function validateStandingOrder(value: unknown): BrowserOSStandingOrder {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error('Standing orders must be objects')
|
||||
}
|
||||
|
||||
assertNonEmptyString(value.title, 'Standing order title')
|
||||
assertNonEmptyString(value.instruction, 'Standing order instruction')
|
||||
|
||||
if (typeof value.enabled !== 'boolean') {
|
||||
throw new Error('Standing order enabled must be a boolean')
|
||||
}
|
||||
|
||||
return {
|
||||
id:
|
||||
typeof value.id === 'string' && value.id.trim() !== ''
|
||||
? value.id
|
||||
: crypto.randomUUID(),
|
||||
title: value.title.trim(),
|
||||
instruction: value.instruction.trim(),
|
||||
enabled: value.enabled,
|
||||
}
|
||||
}
|
||||
|
||||
function validateStandingOrders(
|
||||
value: unknown,
|
||||
): BrowserOSStandingOrder[] | undefined {
|
||||
if (value === undefined) return undefined
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error('standingOrders must be an array')
|
||||
}
|
||||
|
||||
return value.map(validateStandingOrder)
|
||||
}
|
||||
|
||||
function isValidTime(value: string): boolean {
|
||||
return /^([01]\d|2[0-3]):[0-5]\d$/.test(value)
|
||||
}
|
||||
|
||||
function validateDaysOfWeek(value: unknown): Array<0 | 1 | 2 | 3 | 4 | 5 | 6> {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error('schedule.daysOfWeek must be an array')
|
||||
}
|
||||
|
||||
return value.map((day) => {
|
||||
if (
|
||||
typeof day !== 'number' ||
|
||||
!Number.isInteger(day) ||
|
||||
day < 0 ||
|
||||
day > 6
|
||||
) {
|
||||
throw new Error('schedule.daysOfWeek must contain values from 0 to 6')
|
||||
}
|
||||
return day as 0 | 1 | 2 | 3 | 4 | 5 | 6
|
||||
})
|
||||
}
|
||||
|
||||
function validateSchedule(value: unknown): BrowserOSProgramSchedule {
|
||||
if (!isRecord(value) || typeof value.type !== 'string') {
|
||||
throw new Error('schedule is required')
|
||||
}
|
||||
|
||||
switch (value.type) {
|
||||
case 'manual':
|
||||
return { type: 'manual' }
|
||||
case 'daily': {
|
||||
assertNonEmptyString(value.time, 'schedule.time')
|
||||
if (!isValidTime(value.time)) {
|
||||
throw new Error('schedule.time must be in HH:MM format')
|
||||
}
|
||||
return {
|
||||
type: 'daily',
|
||||
time: value.time,
|
||||
daysOfWeek:
|
||||
value.daysOfWeek === undefined
|
||||
? undefined
|
||||
: validateDaysOfWeek(value.daysOfWeek),
|
||||
}
|
||||
}
|
||||
case 'hourly':
|
||||
case 'minutes': {
|
||||
if (
|
||||
typeof value.interval !== 'number' ||
|
||||
!Number.isInteger(value.interval) ||
|
||||
value.interval < 1
|
||||
) {
|
||||
throw new Error('schedule.interval must be an integer >= 1')
|
||||
}
|
||||
|
||||
return {
|
||||
type: value.type,
|
||||
interval: value.interval,
|
||||
}
|
||||
}
|
||||
default:
|
||||
throw new Error('schedule.type is invalid')
|
||||
}
|
||||
}
|
||||
|
||||
export function validateCreateProgramInput(
|
||||
value: unknown,
|
||||
): CreateAgentProgramInput {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error('Program payload must be an object')
|
||||
}
|
||||
|
||||
assertNonEmptyString(value.name, 'name')
|
||||
assertNonEmptyString(value.description, 'description')
|
||||
assertNonEmptyString(value.prompt, 'prompt')
|
||||
|
||||
return {
|
||||
name: value.name.trim(),
|
||||
description: value.description.trim(),
|
||||
prompt: value.prompt.trim(),
|
||||
schedule: validateSchedule(value.schedule),
|
||||
enabled: value.enabled === undefined ? true : !!value.enabled,
|
||||
standingOrders: validateStandingOrders(value.standingOrders) ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
export function validateUpdateProgramInput(
|
||||
value: unknown,
|
||||
): UpdateAgentProgramInput {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error('Program payload must be an object')
|
||||
}
|
||||
|
||||
const output: UpdateAgentProgramInput = {}
|
||||
|
||||
if (value.name !== undefined) {
|
||||
assertNonEmptyString(value.name, 'name')
|
||||
output.name = value.name.trim()
|
||||
}
|
||||
if (value.description !== undefined) {
|
||||
assertNonEmptyString(value.description, 'description')
|
||||
output.description = value.description.trim()
|
||||
}
|
||||
if (value.prompt !== undefined) {
|
||||
assertNonEmptyString(value.prompt, 'prompt')
|
||||
output.prompt = value.prompt.trim()
|
||||
}
|
||||
if (value.enabled !== undefined) {
|
||||
if (typeof value.enabled !== 'boolean') {
|
||||
throw new Error('enabled must be a boolean')
|
||||
}
|
||||
output.enabled = value.enabled
|
||||
}
|
||||
if (value.schedule !== undefined) {
|
||||
output.schedule = validateSchedule(value.schedule)
|
||||
}
|
||||
if (value.standingOrders !== undefined) {
|
||||
output.standingOrders = validateStandingOrders(value.standingOrders)
|
||||
}
|
||||
|
||||
if (Object.keys(output).length === 0) {
|
||||
throw new Error('At least one program field must be provided')
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import {
|
||||
type BROWSEROS_ROLE_TEMPLATES,
|
||||
getBrowserOSRoleTemplate,
|
||||
} from '@browseros/shared/constants/role-aware-agents'
|
||||
import type {
|
||||
BrowserOSAgentRoleId,
|
||||
BrowserOSAgentRoleSummary,
|
||||
BrowserOSCustomRoleInput,
|
||||
BrowserOSRoleTemplate,
|
||||
} from '@browseros/shared/types/role-aware-agents'
|
||||
|
||||
type RoleTemplate = (typeof BROWSEROS_ROLE_TEMPLATES)[number]
|
||||
interface BootstrapRenderableRole {
|
||||
name: string
|
||||
shortDescription: string
|
||||
longDescription: string
|
||||
recommendedApps: string[]
|
||||
boundaries: BrowserOSRoleTemplate['boundaries']
|
||||
bootstrap: BrowserOSRoleTemplate['bootstrap']
|
||||
}
|
||||
|
||||
export interface RoleBootstrapFiles {
|
||||
'AGENTS.md': string
|
||||
'SOUL.md': string
|
||||
'TOOLS.md': string
|
||||
'.browseros-role.json': string
|
||||
}
|
||||
|
||||
export function resolveRoleTemplate(
|
||||
roleId: BrowserOSAgentRoleId,
|
||||
): RoleTemplate {
|
||||
const role = getBrowserOSRoleTemplate(roleId)
|
||||
if (!role) {
|
||||
throw new Error(`Unknown BrowserOS role: ${roleId}`)
|
||||
}
|
||||
return role
|
||||
}
|
||||
|
||||
export function buildRoleBootstrapFiles(input: {
|
||||
role: BrowserOSRoleTemplate | BrowserOSCustomRoleInput
|
||||
agentName: string
|
||||
}): RoleBootstrapFiles {
|
||||
const normalizedRole = normalizeRoleForBootstrap(input.role)
|
||||
const roleId = 'id' in input.role ? input.role.id : undefined
|
||||
return {
|
||||
'AGENTS.md': normalizedRole.bootstrap.agentsMd,
|
||||
'SOUL.md': normalizedRole.bootstrap.soulMd,
|
||||
'TOOLS.md': normalizedRole.bootstrap.toolsMd,
|
||||
'.browseros-role.json': `${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
roleSource: roleId ? 'builtin' : 'custom',
|
||||
roleId,
|
||||
roleName: normalizedRole.name,
|
||||
shortDescription: normalizedRole.shortDescription,
|
||||
createdBy: 'browseros',
|
||||
agentName: input.agentName,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
}
|
||||
}
|
||||
|
||||
export function toRoleSummary(
|
||||
role: BrowserOSRoleTemplate | BrowserOSCustomRoleInput,
|
||||
): BrowserOSAgentRoleSummary {
|
||||
const normalizedRole = normalizeRoleForBootstrap(role)
|
||||
return {
|
||||
roleSource: 'id' in role ? 'builtin' : 'custom',
|
||||
roleId: 'id' in role ? role.id : undefined,
|
||||
roleName: normalizedRole.name,
|
||||
shortDescription: normalizedRole.shortDescription,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeCustomRole(
|
||||
role: BrowserOSCustomRoleInput,
|
||||
): BootstrapRenderableRole {
|
||||
const recommendedApps = Array.isArray(role.recommendedApps)
|
||||
? role.recommendedApps.filter(
|
||||
(app): app is string => typeof app === 'string',
|
||||
)
|
||||
: []
|
||||
const boundaries = Array.isArray(role.boundaries) ? role.boundaries : []
|
||||
|
||||
return {
|
||||
name: role.name,
|
||||
shortDescription: role.shortDescription,
|
||||
longDescription: role.longDescription,
|
||||
recommendedApps,
|
||||
boundaries,
|
||||
bootstrap: {
|
||||
agentsMd:
|
||||
role.bootstrap?.agentsMd?.trim() ||
|
||||
buildAgentsMd({
|
||||
name: role.name,
|
||||
longDescription: role.longDescription,
|
||||
boundaries,
|
||||
}),
|
||||
soulMd:
|
||||
role.bootstrap?.soulMd?.trim() ||
|
||||
buildSoulMd({
|
||||
name: role.name,
|
||||
shortDescription: role.shortDescription,
|
||||
longDescription: role.longDescription,
|
||||
}),
|
||||
toolsMd:
|
||||
role.bootstrap?.toolsMd?.trim() ||
|
||||
buildToolsMd({
|
||||
boundaries,
|
||||
recommendedApps,
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRoleForBootstrap(
|
||||
role: BrowserOSRoleTemplate | BrowserOSCustomRoleInput,
|
||||
): BootstrapRenderableRole {
|
||||
return 'id' in role ? role : normalizeCustomRole(role)
|
||||
}
|
||||
|
||||
function buildAgentsMd(input: {
|
||||
name: string
|
||||
longDescription: string
|
||||
boundaries: BrowserOSRoleTemplate['boundaries']
|
||||
}): string {
|
||||
const boundaryLines = input.boundaries
|
||||
.map(
|
||||
(boundary) =>
|
||||
`- ${boundary.label}: ${boundary.description} Default mode: ${boundary.defaultMode}.`,
|
||||
)
|
||||
.join('\n')
|
||||
|
||||
return `# ${input.name}
|
||||
|
||||
You are the ${input.name} specialist for this workspace.
|
||||
|
||||
## Core Purpose
|
||||
${input.longDescription}
|
||||
|
||||
## Operating Rules
|
||||
${boundaryLines}
|
||||
|
||||
## Default Output Style
|
||||
- concise
|
||||
- action-oriented
|
||||
- explicit about blockers and approvals
|
||||
`
|
||||
}
|
||||
|
||||
function buildSoulMd(input: {
|
||||
name: string
|
||||
shortDescription: string
|
||||
longDescription: string
|
||||
}): string {
|
||||
return `# Operating Style
|
||||
|
||||
You act like a trusted ${input.name}.
|
||||
|
||||
## Working Posture
|
||||
- calm
|
||||
- structured
|
||||
- direct
|
||||
- explicit about tradeoffs
|
||||
|
||||
## Role Framing
|
||||
${input.shortDescription}
|
||||
|
||||
${input.longDescription}
|
||||
`
|
||||
}
|
||||
|
||||
function buildToolsMd(input: {
|
||||
boundaries: BrowserOSRoleTemplate['boundaries']
|
||||
recommendedApps: string[]
|
||||
}): string {
|
||||
const boundaryLines = input.boundaries
|
||||
.map((boundary) => `- ${boundary.label}: ${boundary.defaultMode}`)
|
||||
.join('\n')
|
||||
|
||||
const appsLine =
|
||||
input.recommendedApps.length > 0
|
||||
? input.recommendedApps.join(', ')
|
||||
: 'No specific apps configured yet.'
|
||||
|
||||
return `# Tooling Guidelines
|
||||
|
||||
- Use BrowserOS MCP for browser and connected SaaS tasks.
|
||||
- Prefer read, summarize, and draft flows.
|
||||
- Keep outputs in the workspace when possible so work remains inspectable.
|
||||
|
||||
## Recommended Apps
|
||||
${appsLine}
|
||||
|
||||
## Boundary Defaults
|
||||
${boundaryLines}
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { WSMessageReceive } from 'hono/ws'
|
||||
import { z } from 'zod'
|
||||
|
||||
const TerminalInputMessageSchema = z.object({
|
||||
type: z.literal('input'),
|
||||
data: z.string(),
|
||||
})
|
||||
|
||||
const TerminalResizeMessageSchema = z.object({
|
||||
type: z.literal('resize'),
|
||||
cols: z.number().int().positive(),
|
||||
rows: z.number().int().positive(),
|
||||
})
|
||||
|
||||
const TerminalClientMessageSchema = z.discriminatedUnion('type', [
|
||||
TerminalInputMessageSchema,
|
||||
TerminalResizeMessageSchema,
|
||||
])
|
||||
|
||||
const TerminalOutputMessageSchema = z.object({
|
||||
type: z.literal('output'),
|
||||
data: z.string(),
|
||||
})
|
||||
|
||||
const TerminalExitMessageSchema = z.object({
|
||||
type: z.literal('exit'),
|
||||
exitCode: z.number().int(),
|
||||
})
|
||||
|
||||
const TerminalErrorMessageSchema = z.object({
|
||||
type: z.literal('error'),
|
||||
message: z.string(),
|
||||
})
|
||||
|
||||
const TerminalServerMessageSchema = z.discriminatedUnion('type', [
|
||||
TerminalOutputMessageSchema,
|
||||
TerminalExitMessageSchema,
|
||||
TerminalErrorMessageSchema,
|
||||
])
|
||||
|
||||
export type TerminalClientMessage = z.infer<typeof TerminalClientMessageSchema>
|
||||
export type TerminalServerMessage = z.infer<typeof TerminalServerMessageSchema>
|
||||
|
||||
function readSocketMessage(data: WSMessageReceive): string | null {
|
||||
if (typeof data === 'string') return data
|
||||
if (data instanceof ArrayBuffer) return new TextDecoder().decode(data)
|
||||
return null
|
||||
}
|
||||
|
||||
export function parseTerminalClientMessage(
|
||||
data: WSMessageReceive,
|
||||
): TerminalClientMessage | null {
|
||||
const text = readSocketMessage(data)
|
||||
if (!text) return null
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(text) as unknown
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const result = TerminalClientMessageSchema.safeParse(parsed)
|
||||
return result.success ? result.data : null
|
||||
}
|
||||
|
||||
export function serializeTerminalServerMessage(
|
||||
message: TerminalServerMessage,
|
||||
): string {
|
||||
return JSON.stringify(TerminalServerMessageSchema.parse(message))
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
OPENCLAW_CONTAINER_HOME,
|
||||
OPENCLAW_TERMINAL_SHELL,
|
||||
} from '@browseros/shared/constants/openclaw'
|
||||
import { logger } from '../../../lib/logger'
|
||||
|
||||
export const TERMINAL_HOME_DIR = OPENCLAW_CONTAINER_HOME
|
||||
const DEFAULT_COLS = 80
|
||||
const DEFAULT_ROWS = 24
|
||||
const TERMINAL_NAME = 'xterm-256color'
|
||||
|
||||
interface TerminalSessionDeps {
|
||||
containerName: string
|
||||
podmanPath: string
|
||||
workingDir: string
|
||||
onExit: (exitCode: number) => void
|
||||
onOutput: (data: string) => void
|
||||
}
|
||||
|
||||
export interface TerminalSession {
|
||||
close(): void
|
||||
resize(cols: number, rows: number): void
|
||||
writeInput(data: string): void
|
||||
}
|
||||
|
||||
export function buildTerminalExecCommand(
|
||||
podmanPath: string,
|
||||
containerName: string,
|
||||
workingDir: string,
|
||||
): string[] {
|
||||
return [
|
||||
podmanPath,
|
||||
'exec',
|
||||
'-it',
|
||||
'-w',
|
||||
workingDir,
|
||||
containerName,
|
||||
OPENCLAW_TERMINAL_SHELL,
|
||||
]
|
||||
}
|
||||
|
||||
export function createTerminalSession(
|
||||
deps: TerminalSessionDeps,
|
||||
): TerminalSession {
|
||||
const decoder = new TextDecoder()
|
||||
const proc = Bun.spawn(
|
||||
buildTerminalExecCommand(
|
||||
deps.podmanPath,
|
||||
deps.containerName,
|
||||
deps.workingDir,
|
||||
),
|
||||
{
|
||||
terminal: {
|
||||
cols: DEFAULT_COLS,
|
||||
rows: DEFAULT_ROWS,
|
||||
data(_terminal, data) {
|
||||
const chunk = decoder.decode(data, { stream: true })
|
||||
if (chunk) deps.onOutput(chunk)
|
||||
},
|
||||
},
|
||||
env: { ...process.env, TERM: TERMINAL_NAME },
|
||||
},
|
||||
)
|
||||
let closed = false
|
||||
|
||||
void proc.exited.then((exitCode) => {
|
||||
const trailing = decoder.decode()
|
||||
if (trailing) deps.onOutput(trailing)
|
||||
deps.onExit(exitCode)
|
||||
})
|
||||
|
||||
logger.debug('Terminal session created', { workingDir: deps.workingDir })
|
||||
|
||||
return {
|
||||
writeInput(data) {
|
||||
proc.terminal?.write(data)
|
||||
},
|
||||
resize(cols, rows) {
|
||||
proc.terminal?.resize(cols, rows)
|
||||
},
|
||||
close() {
|
||||
if (closed) return
|
||||
closed = true
|
||||
try {
|
||||
proc.terminal?.close()
|
||||
proc.kill()
|
||||
} catch {
|
||||
logger.debug('Terminal session cleanup failed')
|
||||
}
|
||||
logger.debug('Terminal session destroyed')
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export type AgentLLMConfig = z.infer<typeof AgentLLMConfigSchema>
|
||||
|
||||
export const ChatRequestSchema = AgentLLMConfigSchema.extend({
|
||||
conversationId: z.string().uuid(),
|
||||
message: z.string().min(1, 'Message cannot be empty'),
|
||||
message: z.string().optional().default(''),
|
||||
contextWindowSize: z.number().optional(),
|
||||
browserContext: BrowserContextSchema.optional(),
|
||||
userSystemPrompt: z.string().optional(),
|
||||
@@ -46,6 +46,32 @@ export const ChatRequestSchema = AgentLLMConfigSchema.extend({
|
||||
mode: z.enum(['chat', 'agent']).optional().default('agent'),
|
||||
origin: z.enum(['sidepanel', 'newtab']).optional().default('sidepanel'),
|
||||
declinedApps: z.array(z.string()).optional(),
|
||||
aclRules: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
sitePattern: z.string(),
|
||||
selector: z.string().optional(),
|
||||
textMatch: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
enabled: z.boolean(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
toolApprovalConfig: z
|
||||
.object({
|
||||
categories: z.record(z.boolean()),
|
||||
})
|
||||
.optional(),
|
||||
toolApprovalResponses: z
|
||||
.array(
|
||||
z.object({
|
||||
approvalId: z.string(),
|
||||
approved: z.boolean(),
|
||||
reason: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
selectedText: z.string().optional(),
|
||||
selectedTextSource: z
|
||||
.object({
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { MiddlewareHandler } from 'hono'
|
||||
import { isLocalhostRequest } from './security'
|
||||
|
||||
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]', '::1'])
|
||||
const EXTENSION_PROTOCOLS = new Set(['chrome-extension:', 'moz-extension:'])
|
||||
|
||||
export function isTrustedAppOrigin(origin: string | undefined): boolean {
|
||||
if (!origin) return false
|
||||
|
||||
try {
|
||||
const url = new URL(origin)
|
||||
|
||||
if (
|
||||
(url.protocol === 'http:' || url.protocol === 'https:') &&
|
||||
LOOPBACK_HOSTS.has(url.hostname)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return EXTENSION_PROTOCOLS.has(url.protocol)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function requireTrustedAppOrigin(): MiddlewareHandler {
|
||||
return async (c, next) => {
|
||||
const origin = c.req.header('origin')
|
||||
if (origin) {
|
||||
if (!isTrustedAppOrigin(origin)) {
|
||||
return c.json({ error: 'Forbidden' }, 403)
|
||||
}
|
||||
return next()
|
||||
}
|
||||
|
||||
// Some local reads arrive without an Origin header. Allow those only when
|
||||
// the actual client socket is loopback. This avoids Host-header spoofing.
|
||||
if (
|
||||
['GET', 'HEAD', 'OPTIONS'].includes(c.req.method) &&
|
||||
isLocalhostRequest(c)
|
||||
) {
|
||||
return next()
|
||||
}
|
||||
|
||||
return c.json({ error: 'Forbidden' }, 403)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ProtocolApi } from '@browseros/cdp-protocol/protocol-api'
|
||||
import type { ElementProperties } from '@browseros/shared/types/acl'
|
||||
import { logger } from '../lib/logger'
|
||||
import type { CdpBackend } from './backends/types'
|
||||
import type { BookmarkNode } from './bookmarks'
|
||||
@@ -85,6 +86,24 @@ const EXCLUDED_URL_PREFIXES = [
|
||||
'devtools://',
|
||||
]
|
||||
|
||||
const ACTIONABLE_SELECTOR = [
|
||||
'button',
|
||||
'a[href]',
|
||||
'input',
|
||||
'select',
|
||||
'textarea',
|
||||
'summary',
|
||||
'[role="button"]',
|
||||
'[role="link"]',
|
||||
'[role="checkbox"]',
|
||||
'[role="radio"]',
|
||||
'[role="switch"]',
|
||||
'[role="tab"]',
|
||||
'[role="option"]',
|
||||
'[onclick]',
|
||||
'[tabindex]',
|
||||
].join(',')
|
||||
|
||||
export class Browser {
|
||||
private cdp: CdpBackend
|
||||
private consoleCollector: ConsoleCollector
|
||||
@@ -226,6 +245,253 @@ export class Browser {
|
||||
return this.pages.get(pageId)?.tabId
|
||||
}
|
||||
|
||||
getPageInfo(pageId: number): PageInfo | undefined {
|
||||
return this.pages.get(pageId)
|
||||
}
|
||||
|
||||
async refreshPageInfo(pageId: number): Promise<PageInfo | undefined> {
|
||||
let info = this.pages.get(pageId)
|
||||
if (!info) {
|
||||
await this.listPages()
|
||||
info = this.pages.get(pageId)
|
||||
}
|
||||
if (!info) return undefined
|
||||
|
||||
try {
|
||||
const result = await this.cdp.Browser.getTabInfo({ tabId: info.tabId })
|
||||
const tab = result.tab as TabInfo
|
||||
const updated: PageInfo = {
|
||||
...info,
|
||||
targetId: tab.targetId,
|
||||
tabId: tab.tabId,
|
||||
url: tab.url,
|
||||
title: tab.title,
|
||||
isActive: tab.isActive,
|
||||
isLoading: tab.isLoading,
|
||||
loadProgress: tab.loadProgress,
|
||||
isPinned: tab.isPinned,
|
||||
isHidden: tab.isHidden,
|
||||
windowId: tab.windowId,
|
||||
index: tab.index,
|
||||
groupId: tab.groupId,
|
||||
}
|
||||
this.pages.set(pageId, updated)
|
||||
return updated
|
||||
} catch {
|
||||
await this.listPages()
|
||||
return this.pages.get(pageId)
|
||||
}
|
||||
}
|
||||
|
||||
async getSession(pageId: number): Promise<ProtocolApi | null> {
|
||||
const info = this.pages.get(pageId)
|
||||
if (!info) return null
|
||||
const sessionId = this.sessions.get(info.targetId)
|
||||
if (!sessionId) return null
|
||||
return this.cdp.session(sessionId)
|
||||
}
|
||||
|
||||
async resolveActionableElement(
|
||||
pageId: number,
|
||||
backendNodeId: number,
|
||||
): Promise<number | null> {
|
||||
const session = await this.resolveSession(pageId)
|
||||
try {
|
||||
const resolved = await session.DOM.resolveNode({ backendNodeId })
|
||||
const objectId = resolved.object?.objectId
|
||||
if (!objectId) return backendNodeId
|
||||
|
||||
const actionable = await session.Runtime.callFunctionOn({
|
||||
functionDeclaration: `function(selector){
|
||||
var element = this instanceof Element
|
||||
? this
|
||||
: this && this.parentElement
|
||||
? this.parentElement
|
||||
: this && this.parentNode instanceof Element
|
||||
? this.parentNode
|
||||
: null;
|
||||
if (!element) return null;
|
||||
return element.closest(selector) || element;
|
||||
}`,
|
||||
objectId,
|
||||
arguments: [{ value: ACTIONABLE_SELECTOR }],
|
||||
})
|
||||
|
||||
const actionableObjectId = actionable.result?.objectId
|
||||
if (!actionableObjectId) return backendNodeId
|
||||
|
||||
const desc = await session.DOM.describeNode({
|
||||
objectId: actionableObjectId,
|
||||
})
|
||||
return desc.node?.backendNodeId ?? backendNodeId
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async resolveElementAtPoint(
|
||||
pageId: number,
|
||||
x: number,
|
||||
y: number,
|
||||
): Promise<number | null> {
|
||||
const session = await this.resolveSession(pageId)
|
||||
try {
|
||||
const fromDom = await session.Runtime.evaluate({
|
||||
expression: `document.elementFromPoint(${Math.round(x)}, ${Math.round(y)})`,
|
||||
})
|
||||
const objectId = fromDom.result?.objectId
|
||||
if (objectId) {
|
||||
const desc = await session.DOM.describeNode({ objectId })
|
||||
const backendNodeId = desc.node?.backendNodeId
|
||||
if (backendNodeId) {
|
||||
return await this.resolveActionableElement(pageId, backendNodeId)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fall through to CDP hit-testing
|
||||
}
|
||||
|
||||
try {
|
||||
const located = await session.DOM.getNodeForLocation({
|
||||
x: Math.round(x),
|
||||
y: Math.round(y),
|
||||
includeUserAgentShadowDOM: true,
|
||||
ignorePointerEventsNone: true,
|
||||
})
|
||||
return await this.resolveActionableElement(pageId, located.backendNodeId)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async resolveElementProperties(
|
||||
pageId: number,
|
||||
backendNodeId: number,
|
||||
): Promise<ElementProperties | null> {
|
||||
const session = await this.resolveSession(pageId)
|
||||
try {
|
||||
const targetNodeId =
|
||||
(await this.resolveActionableElement(pageId, backendNodeId)) ??
|
||||
backendNodeId
|
||||
const desc = await session.DOM.describeNode({
|
||||
backendNodeId: targetNodeId,
|
||||
depth: 0,
|
||||
})
|
||||
const node = desc.node
|
||||
const attrs = parseNodeAttributes(node)
|
||||
|
||||
const resolved = await session.DOM.resolveNode({
|
||||
backendNodeId: targetNodeId,
|
||||
})
|
||||
const objectId = resolved.object?.objectId
|
||||
let textContent = ''
|
||||
let labelText = ''
|
||||
if (objectId) {
|
||||
const textResult = await session.Runtime.callFunctionOn({
|
||||
functionDeclaration: `function(){
|
||||
var text = (this.innerText || this.textContent || '').trim();
|
||||
var aria = this.getAttribute('aria-label') || '';
|
||||
var placeholder = this.getAttribute('placeholder') || '';
|
||||
var title = this.getAttribute('title') || '';
|
||||
var value = typeof this.value === 'string' ? this.value : '';
|
||||
var labels = Array.from(this.labels || [])
|
||||
.map(function(label){ return (label.innerText || label.textContent || '').trim(); })
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
return {
|
||||
textContent: text.substring(0, 200),
|
||||
labelText: [aria, labels, placeholder, title, value, text]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.trim()
|
||||
.substring(0, 400),
|
||||
};
|
||||
}`,
|
||||
objectId,
|
||||
returnByValue: true,
|
||||
})
|
||||
const value = (textResult.result?.value ?? {}) as {
|
||||
textContent?: string
|
||||
labelText?: string
|
||||
}
|
||||
textContent = value.textContent ?? ''
|
||||
labelText = value.labelText ?? ''
|
||||
}
|
||||
|
||||
return {
|
||||
tagName: node.localName ?? '',
|
||||
textContent,
|
||||
attributes: attrs,
|
||||
labelText,
|
||||
ariaLabel: attrs['aria-label'],
|
||||
role: attrs.role,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async highlightBlockedElement(
|
||||
pageId: number,
|
||||
backendNodeId: number,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
const session = await this.resolveSession(pageId)
|
||||
const targetNodeId =
|
||||
(await this.resolveActionableElement(pageId, backendNodeId)) ??
|
||||
backendNodeId
|
||||
|
||||
try {
|
||||
const resolved = await session.DOM.resolveNode({
|
||||
backendNodeId: targetNodeId,
|
||||
})
|
||||
const objectId = resolved.object?.objectId
|
||||
if (!objectId) return
|
||||
|
||||
await session.Runtime.callFunctionOn({
|
||||
functionDeclaration: `function(reason){
|
||||
var existing = document.getElementById('__browseros_acl_block_overlay');
|
||||
if (existing) existing.remove();
|
||||
var existingStyle = document.getElementById('__browseros_acl_block_style');
|
||||
if (!existingStyle) {
|
||||
var style = document.createElement('style');
|
||||
style.id = '__browseros_acl_block_style';
|
||||
style.textContent = [
|
||||
'#__browseros_acl_block_overlay{position:absolute;pointer-events:none;z-index:2147483647;}',
|
||||
'#__browseros_acl_block_overlay .ring{position:absolute;inset:0;border:2px solid rgba(220,38,38,0.95);background:rgba(220,38,38,0.14);border-radius:10px;box-shadow:0 0 0 3px rgba(255,255,255,0.75);}',
|
||||
'#__browseros_acl_block_overlay .badge{position:absolute;top:-10px;right:-10px;background:rgba(153,27,27,0.96);color:white;font:600 11px/1.2 system-ui,sans-serif;padding:6px 8px;border-radius:999px;white-space:nowrap;box-shadow:0 6px 18px rgba(0,0,0,0.2);}',
|
||||
].join('');
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
var rect = this.getBoundingClientRect();
|
||||
if (!rect.width || !rect.height) return;
|
||||
var overlay = document.createElement('div');
|
||||
overlay.id = '__browseros_acl_block_overlay';
|
||||
overlay.style.left = (rect.left + window.scrollX) + 'px';
|
||||
overlay.style.top = (rect.top + window.scrollY) + 'px';
|
||||
overlay.style.width = rect.width + 'px';
|
||||
overlay.style.height = rect.height + 'px';
|
||||
var ring = document.createElement('div');
|
||||
ring.className = 'ring';
|
||||
var badge = document.createElement('div');
|
||||
badge.className = 'badge';
|
||||
badge.textContent = reason || 'Blocked';
|
||||
overlay.appendChild(ring);
|
||||
overlay.appendChild(badge);
|
||||
document.body.appendChild(overlay);
|
||||
window.setTimeout(function(){
|
||||
var current = document.getElementById('__browseros_acl_block_overlay');
|
||||
if (current) current.remove();
|
||||
}, 2500);
|
||||
}`,
|
||||
objectId,
|
||||
arguments: [{ value: reason }],
|
||||
})
|
||||
} catch {
|
||||
// best-effort visual feedback
|
||||
}
|
||||
}
|
||||
|
||||
async resolveTabIds(tabIds: number[]): Promise<Map<number, number>> {
|
||||
await this.listPages()
|
||||
const tabToPage = new Map<number, number>()
|
||||
@@ -392,9 +658,48 @@ export class Browser {
|
||||
|
||||
// --- Observation ---
|
||||
|
||||
private async getFrameIds(session: ProtocolApi): Promise<string[]> {
|
||||
try {
|
||||
const result = await session.Page.getFrameTree()
|
||||
const ids: string[] = []
|
||||
type Tree = { frame: { id: string }; childFrames?: Tree[] }
|
||||
function collect(tree: Tree) {
|
||||
ids.push(tree.frame.id)
|
||||
if (tree.childFrames)
|
||||
for (const child of tree.childFrames) collect(child)
|
||||
}
|
||||
collect(result.frameTree as Tree)
|
||||
return ids
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchAXTree(session: ProtocolApi): Promise<AXNode[]> {
|
||||
const result = await session.Accessibility.getFullAXTree()
|
||||
return (result.nodes as AXNode[]) ?? []
|
||||
const frameIds = await this.getFrameIds(session)
|
||||
|
||||
if (frameIds.length <= 1) {
|
||||
const result = await session.Accessibility.getFullAXTree()
|
||||
return (result.nodes as AXNode[]) ?? []
|
||||
}
|
||||
|
||||
const allNodes: AXNode[] = []
|
||||
for (const frameId of frameIds) {
|
||||
try {
|
||||
const result = await session.Accessibility.getFullAXTree({ frameId })
|
||||
const nodes = (result.nodes as AXNode[]) ?? []
|
||||
for (const node of nodes) {
|
||||
allNodes.push({
|
||||
...node,
|
||||
nodeId: `${frameId}:${node.nodeId}`,
|
||||
childIds: node.childIds?.map((id) => `${frameId}:${id}`),
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Cross-origin or detached frames may fail — skip
|
||||
}
|
||||
}
|
||||
return allNodes
|
||||
}
|
||||
|
||||
async snapshot(page: number): Promise<string> {
|
||||
|
||||
@@ -20,7 +20,7 @@ export function buildContentMarkdownExpression(
|
||||
// Uses var + ES5 style for consistency with other injected scripts.
|
||||
// Context object: { pre: bool, ld: listDepth, lt: listType, td: tableDepth }
|
||||
const DOM_WALKER_SCRIPT = `(function(o) {
|
||||
var SKIP = {SCRIPT:1,STYLE:1,NOSCRIPT:1,SVG:1,TEMPLATE:1,IFRAME:1,CANVAS:1,VIDEO:1,AUDIO:1,OBJECT:1,EMBED:1};
|
||||
var SKIP = {SCRIPT:1,STYLE:1,NOSCRIPT:1,SVG:1,TEMPLATE:1,CANVAS:1,VIDEO:1,AUDIO:1,OBJECT:1,EMBED:1};
|
||||
var FORM = {INPUT:1,SELECT:1,TEXTAREA:1,BUTTON:1};
|
||||
var vh = window.innerHeight, vw = window.innerWidth;
|
||||
var root = o.selector ? document.querySelector(o.selector) : document.body;
|
||||
@@ -219,6 +219,15 @@ function walk(node, ctx) {
|
||||
t = kids(el, ctx).trim();
|
||||
return t ? '\\n*' + t + '*\\n' : '';
|
||||
|
||||
case 'IFRAME':
|
||||
try {
|
||||
var idoc = el.contentDocument;
|
||||
if (idoc && idoc.body) return walk(idoc.body, ctx);
|
||||
} catch(e) {}
|
||||
var isrc = el.src || el.getAttribute('src');
|
||||
if (isrc) return '\\n\\n[iframe: ' + isrc + ']\\n\\n';
|
||||
return '';
|
||||
|
||||
default:
|
||||
return kids(el, ctx);
|
||||
}
|
||||
|
||||
@@ -100,11 +100,16 @@ export function buildInteractiveTree(nodes: AXNode[]): string[] {
|
||||
if (node.childIds) for (const childId of node.childIds) walk(childId)
|
||||
}
|
||||
|
||||
const root =
|
||||
nodes.find(
|
||||
(n) => n.role?.value === 'RootWebArea' || n.role?.value === 'WebArea',
|
||||
) ?? nodes[0]
|
||||
if (root?.childIds) for (const childId of root.childIds) walk(childId)
|
||||
const roots = nodes.filter(
|
||||
(n) => n.role?.value === 'RootWebArea' || n.role?.value === 'WebArea',
|
||||
)
|
||||
if (roots.length === 0 && nodes[0]?.childIds) {
|
||||
for (const childId of nodes[0].childIds) walk(childId)
|
||||
} else {
|
||||
for (const root of roots) {
|
||||
if (root.childIds) for (const childId of root.childIds) walk(childId)
|
||||
}
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
@@ -160,11 +165,16 @@ export function buildEnhancedTree(nodes: AXNode[]): string[] {
|
||||
for (const childId of node.childIds) walk(childId, depth + 1)
|
||||
}
|
||||
|
||||
const root =
|
||||
nodes.find(
|
||||
(n) => n.role?.value === 'RootWebArea' || n.role?.value === 'WebArea',
|
||||
) ?? nodes[0]
|
||||
if (root?.childIds) for (const childId of root.childIds) walk(childId, 0)
|
||||
const roots = nodes.filter(
|
||||
(n) => n.role?.value === 'RootWebArea' || n.role?.value === 'WebArea',
|
||||
)
|
||||
if (roots.length === 0 && nodes[0]?.childIds) {
|
||||
for (const childId of nodes[0].childIds) walk(childId, 0)
|
||||
} else {
|
||||
for (const root of roots) {
|
||||
if (root.childIds) for (const childId of root.childIds) walk(childId, 0)
|
||||
}
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
@@ -292,11 +302,16 @@ export function extractLinkNodes(nodes: AXNode[]): LinkNode[] {
|
||||
if (node.childIds) for (const childId of node.childIds) walk(childId)
|
||||
}
|
||||
|
||||
const root =
|
||||
nodes.find(
|
||||
(n) => n.role?.value === 'RootWebArea' || n.role?.value === 'WebArea',
|
||||
) ?? nodes[0]
|
||||
if (root?.childIds) for (const childId of root.childIds) walk(childId)
|
||||
const roots = nodes.filter(
|
||||
(n) => n.role?.value === 'RootWebArea' || n.role?.value === 'WebArea',
|
||||
)
|
||||
if (roots.length === 0 && nodes[0]?.childIds) {
|
||||
for (const childId of nodes[0].childIds) walk(childId)
|
||||
} else {
|
||||
for (const root of roots) {
|
||||
if (root.childIds) for (const childId of root.childIds) walk(childId)
|
||||
}
|
||||
}
|
||||
|
||||
return links
|
||||
}
|
||||
|
||||
@@ -34,6 +34,10 @@ export function getBuiltinSkillsDir(): string {
|
||||
return join(getSkillsDir(), PATHS.BUILTIN_DIR_NAME)
|
||||
}
|
||||
|
||||
export function getOpenClawDir(): string {
|
||||
return join(getBrowserosDir(), PATHS.OPENCLAW_DIR_NAME)
|
||||
}
|
||||
|
||||
export function getServerConfigPath(): string {
|
||||
return join(getBrowserosDir(), PATHS.SERVER_CONFIG_FILE_NAME)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { EXIT_CODES } from '@browseros/shared/constants/exit-codes'
|
||||
import { createHttpServer } from './api/server'
|
||||
import { getOpenClawService } from './api/services/openclaw/openclaw-service'
|
||||
import { CdpBackend } from './browser/backends/cdp'
|
||||
import { Browser } from './browser/browser'
|
||||
import type { ServerConfig } from './config'
|
||||
@@ -118,12 +119,23 @@ export class Application {
|
||||
this.logStartupSummary()
|
||||
startSkillSync()
|
||||
|
||||
getOpenClawService(this.config.serverPort)
|
||||
.tryAutoStart()
|
||||
.catch((err) =>
|
||||
logger.warn('OpenClaw auto-start failed', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
)
|
||||
|
||||
metrics.log('http_server.started', { version: VERSION })
|
||||
}
|
||||
|
||||
stop(reason?: string): void {
|
||||
logger.info('Shutting down server...', { reason })
|
||||
stopSkillSync()
|
||||
getOpenClawService()
|
||||
.shutdown()
|
||||
.catch(() => {})
|
||||
removeServerConfigSync()
|
||||
|
||||
// Immediate exit without graceful shutdown. Chromium may kill us on update/restart,
|
||||
|
||||
55
packages/browseros-agent/apps/server/src/tools/acl/README.md
Normal file
55
packages/browseros-agent/apps/server/src/tools/acl/README.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# ACL Matcher
|
||||
|
||||
The ACL matcher blocks guarded tool actions (click, fill, hover, etc.) when they target elements that match user-defined access control rules. It scores each rule against the target element using a combination of exact, fuzzy, and semantic similarity — then blocks if the confidence exceeds a threshold.
|
||||
|
||||
## How it works
|
||||
|
||||
When a guarded tool is invoked, `acl-guard.ts` resolves the target element's properties (text content, aria labels, attributes, etc.) and runs them through the scoring pipeline:
|
||||
|
||||
1. **Site filtering** — rules are filtered to those matching the current page URL
|
||||
2. **Site-only rules** — rules with no selector/text/description block the entire site immediately
|
||||
3. **Element scoring** — remaining rules are scored against the element using three signals:
|
||||
|
||||
| Signal | Weight | How it works |
|
||||
|--------|--------|-------------|
|
||||
| Exact | 25% | Are any compiled rule terms a substring of an element field? |
|
||||
| Fuzzy | 25% | Edit distance ratio between rule terms and element text windows |
|
||||
| Semantic | 50% | Cosine similarity of sentence embeddings (BAAI/bge-small-en-v1.5 via ONNX) |
|
||||
|
||||
The weighted scores produce a **confidence** value between 0 and 1. If confidence >= **0.4** (Handpicked, needs updating), the action is blocked.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `acl-guard.ts` | Entry point — called by `framework.ts` during tool execution |
|
||||
| `acl-scorer.ts` | Core pipeline: text normalization, feature extraction, scoring, decision |
|
||||
| `acl-embeddings.ts` | Lazy-loaded `@huggingface/transformers` pipeline for semantic similarity |
|
||||
| `acl-edit-distance.ts` | Levenshtein edit distance ratio for fuzzy matching |
|
||||
| `acl-stopwords.ts` | Static set of 198 English stopwords (from NLTK corpus) |
|
||||
|
||||
Shared types and basic matchers live in `packages/shared/`:
|
||||
- `src/types/acl.ts` — `AclRule` and `ElementProperties` interfaces
|
||||
- `src/acl/match.ts` — site pattern globbing and CSS selector matching
|
||||
|
||||
## Embedding model
|
||||
|
||||
The semantic scoring uses [BAAI/bge-small-en-v1.5](https://huggingface.co/BAAI/bge-small-en-v1.5) (~33MB ONNX model) via `@huggingface/transformers`. The model downloads automatically on first use and is cached for the process lifetime.
|
||||
|
||||
Override the model with the `ACL_EMBEDDING_MODEL` environment variable (e.g. `ACL_EMBEDDING_MODEL=Xenova/bge-base-en-v1.5`).
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
bun --env-file=.env.development test apps/server/tests/tools/acl-scorer.test.ts
|
||||
```
|
||||
|
||||
Test fixtures live in `apps/server/tests/__fixtures__/acl/` (courtesy of claude code):
|
||||
|
||||
| Fixture | Tests |
|
||||
|---------|-------|
|
||||
| `submit-button.json` | Exact match — "Place Order" button vs "block checkout submit" rule |
|
||||
| `semantic-payment.json` | Semantic match — "Proceed to Checkout" vs "prevent purchase actions" |
|
||||
| `semantic-delete.json` | Semantic match — "Remove my account permanently" vs "block account deletion" |
|
||||
| `semantic-send-email.json` | Semantic match — send button vs "do not dispatch emails" |
|
||||
| `semantic-safe.json` | False positive — "View Report" should NOT be blocked by payment/delete rules |
|
||||
@@ -0,0 +1,25 @@
|
||||
export function editDistanceRatio(a: string, b: string): number {
|
||||
const maxLength = Math.max(a.length, b.length)
|
||||
if (maxLength === 0) return 1.0
|
||||
|
||||
let previousRow = Array.from({ length: b.length + 1 }, (_, index) => index)
|
||||
let currentRow = new Array<number>(b.length + 1)
|
||||
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
currentRow[0] = i
|
||||
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
const substitutionCost = a[i - 1] === b[j - 1] ? 0 : 1
|
||||
currentRow[j] = Math.min(
|
||||
previousRow[j] + 1,
|
||||
currentRow[j - 1] + 1,
|
||||
previousRow[j - 1] + substitutionCost,
|
||||
)
|
||||
}
|
||||
|
||||
;[previousRow, currentRow] = [currentRow, previousRow]
|
||||
}
|
||||
|
||||
const distance = previousRow[b.length]
|
||||
return 1.0 - distance / maxLength
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { logger } from '../../lib/logger'
|
||||
|
||||
interface SemanticScore {
|
||||
score: number
|
||||
backend: string
|
||||
}
|
||||
|
||||
type FeatureExtractionPipeline = (
|
||||
texts: string[],
|
||||
options: { pooling: string; normalize: boolean },
|
||||
) => Promise<{ tolist: () => number[][] }>
|
||||
|
||||
let pipelineInstance: FeatureExtractionPipeline | null = null
|
||||
const LOAD_RETRY_MS = 60_000
|
||||
let lastLoadFailedAt = 0
|
||||
|
||||
function getModelName(): string {
|
||||
return process.env.ACL_EMBEDDING_MODEL ?? 'Xenova/bge-small-en-v1.5'
|
||||
}
|
||||
|
||||
async function ensurePipeline(): Promise<FeatureExtractionPipeline | null> {
|
||||
if (pipelineInstance) return pipelineInstance
|
||||
if (lastLoadFailedAt > 0 && Date.now() - lastLoadFailedAt < LOAD_RETRY_MS) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const { pipeline } = await import('@huggingface/transformers')
|
||||
const extractor = await pipeline('feature-extraction', getModelName(), {
|
||||
dtype: 'fp32',
|
||||
})
|
||||
pipelineInstance = extractor as unknown as FeatureExtractionPipeline
|
||||
lastLoadFailedAt = 0
|
||||
logger.info('ACL embedding model loaded', { model: getModelName() })
|
||||
return pipelineInstance
|
||||
} catch (error) {
|
||||
lastLoadFailedAt = Date.now()
|
||||
logger.warn(
|
||||
'ACL embedding model failed to load, semantic scoring disabled',
|
||||
{
|
||||
model: getModelName(),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function cosineSimilarity(a: number[], b: number[]): number {
|
||||
let dot = 0
|
||||
let normA = 0
|
||||
let normB = 0
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
dot += a[i] * b[i]
|
||||
normA += a[i] * a[i]
|
||||
normB += b[i] * b[i]
|
||||
}
|
||||
const denom = Math.sqrt(normA) * Math.sqrt(normB)
|
||||
return denom === 0 ? 0 : dot / denom
|
||||
}
|
||||
|
||||
export async function computeSemanticSimilarity(
|
||||
left: string,
|
||||
right: string,
|
||||
): Promise<SemanticScore> {
|
||||
if (!left || !right) return { score: 0, backend: 'none' }
|
||||
|
||||
const extractor = await ensurePipeline()
|
||||
if (!extractor) return { score: 0, backend: 'error' }
|
||||
|
||||
try {
|
||||
const output = await extractor([left, right], {
|
||||
pooling: 'cls',
|
||||
normalize: true,
|
||||
})
|
||||
const embeddings = output.tolist()
|
||||
const score = cosineSimilarity(embeddings[0], embeddings[1])
|
||||
return {
|
||||
score: Math.max(0, Math.min(score, 1)),
|
||||
backend: 'transformers.js',
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('ACL semantic similarity computation failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return { score: 0, backend: 'error' }
|
||||
}
|
||||
}
|
||||
127
packages/browseros-agent/apps/server/src/tools/acl/acl-guard.ts
Normal file
127
packages/browseros-agent/apps/server/src/tools/acl/acl-guard.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { matchesSitePattern } from '@browseros/shared/acl/match'
|
||||
import type { AclRule } from '@browseros/shared/types/acl'
|
||||
import type { Browser } from '../../browser/browser'
|
||||
import { logger } from '../../lib/logger'
|
||||
import { scoreFixture } from './acl-scorer'
|
||||
|
||||
const GUARDED_TOOLS = new Set([
|
||||
'click',
|
||||
'click_at',
|
||||
'fill',
|
||||
'type_at',
|
||||
'hover',
|
||||
'hover_at',
|
||||
'drag',
|
||||
'drag_at',
|
||||
'focus',
|
||||
'clear',
|
||||
'check',
|
||||
'uncheck',
|
||||
'select_option',
|
||||
'press_key',
|
||||
'upload_file',
|
||||
])
|
||||
|
||||
export interface AclCheckResult {
|
||||
blocked: boolean
|
||||
rule?: AclRule
|
||||
pageId?: number
|
||||
elementId?: number
|
||||
}
|
||||
|
||||
async function resolveTargetElementId(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
browser: Browser,
|
||||
pageId: number,
|
||||
): Promise<number | undefined> {
|
||||
if (typeof args.element === 'number') return args.element
|
||||
if (toolName === 'drag' && typeof args.sourceElement === 'number') {
|
||||
return args.sourceElement
|
||||
}
|
||||
|
||||
if (typeof args.x === 'number' && typeof args.y === 'number') {
|
||||
return (
|
||||
(await browser.resolveElementAtPoint(pageId, args.x, args.y)) ?? undefined
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
toolName === 'drag_at' &&
|
||||
typeof args.startX === 'number' &&
|
||||
typeof args.startY === 'number'
|
||||
) {
|
||||
return (
|
||||
(await browser.resolveElementAtPoint(pageId, args.startX, args.startY)) ??
|
||||
undefined
|
||||
)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export async function checkAcl(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
browser: Browser,
|
||||
rules: AclRule[],
|
||||
): Promise<AclCheckResult> {
|
||||
if (!GUARDED_TOOLS.has(toolName)) return { blocked: false }
|
||||
if (!rules.length) return { blocked: false }
|
||||
|
||||
const pageId = args.page as number | undefined
|
||||
if (pageId === undefined) return { blocked: false }
|
||||
|
||||
const pageInfo = await browser.refreshPageInfo(pageId)
|
||||
if (!pageInfo) return { blocked: false }
|
||||
|
||||
const siteRules = rules.filter((r) =>
|
||||
matchesSitePattern(pageInfo.url, r.sitePattern),
|
||||
)
|
||||
if (!siteRules.length) return { blocked: false }
|
||||
|
||||
const siteOnlyRule = siteRules.find(
|
||||
(r) => !r.selector && !r.textMatch && !r.description,
|
||||
)
|
||||
if (siteOnlyRule) {
|
||||
logger.info('ACL blocked by site-only rule', {
|
||||
toolName,
|
||||
pageId,
|
||||
pageUrl: pageInfo.url,
|
||||
ruleId: siteOnlyRule.id,
|
||||
sitePattern: siteOnlyRule.sitePattern,
|
||||
})
|
||||
return { blocked: true, rule: siteOnlyRule, pageId }
|
||||
}
|
||||
|
||||
const elementId = await resolveTargetElementId(
|
||||
toolName,
|
||||
args,
|
||||
browser,
|
||||
pageId,
|
||||
)
|
||||
if (elementId === undefined) return { blocked: false }
|
||||
|
||||
const props = await browser.resolveElementProperties(pageId, elementId)
|
||||
if (!props) return { blocked: false }
|
||||
|
||||
const decision = await scoreFixture(toolName, pageInfo.url, props, siteRules)
|
||||
|
||||
if (decision.blocked) {
|
||||
const matchedRule = decision.matchedRuleId
|
||||
? rules.find((rule) => rule.id === decision.matchedRuleId)
|
||||
: undefined
|
||||
logger.info('ACL blocked by scorer', {
|
||||
toolName,
|
||||
pageId,
|
||||
pageUrl: pageInfo.url,
|
||||
elementId,
|
||||
ruleId: decision.matchedRuleId,
|
||||
confidence: decision.confidence,
|
||||
reason: decision.reason,
|
||||
})
|
||||
return { blocked: true, rule: matchedRule, pageId, elementId }
|
||||
}
|
||||
|
||||
return { blocked: false }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user