mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
Compare commits
34 Commits
felarof99-
...
fix/type-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ec6480d3f | ||
|
|
aff8afd9a4 | ||
|
|
0c96002cf5 | ||
|
|
76e5dcb801 | ||
|
|
a85f94de40 | ||
|
|
6708ab834b | ||
|
|
007208d54b | ||
|
|
dd85ae503f | ||
|
|
452906d3ca | ||
|
|
0397d3e393 | ||
|
|
edd681012c | ||
|
|
ce7c209ba6 | ||
|
|
6548220bcb | ||
|
|
14eeba7c20 | ||
|
|
3c629c5929 | ||
|
|
77dcd37000 | ||
|
|
6d0dff7b1a | ||
|
|
f78068bb9d | ||
|
|
6b18ebb1d8 | ||
|
|
1f2e783ab9 | ||
|
|
df7873562d | ||
|
|
412386b489 | ||
|
|
33617ba9e7 | ||
|
|
6712e1d321 | ||
|
|
94540d9e87 | ||
|
|
bb62213e84 | ||
|
|
dee3086a48 | ||
|
|
8de2bf984f | ||
|
|
1b8720740c | ||
|
|
91be726381 | ||
|
|
ff5386a24a | ||
|
|
a5f3c4da65 | ||
|
|
e5a852dd3d | ||
|
|
aee30ce8e1 |
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/
|
||||
|
||||
@@ -192,7 +192,7 @@ We'd love your help making BrowserOS better! See our [Contributing Guide](CONTRI
|
||||
|
||||
BrowserOS is open source under the [AGPL-3.0 license](LICENSE).
|
||||
|
||||
Copyright © 2025 Felafax, Inc.
|
||||
Copyright © 2026 Felafax, Inc.
|
||||
|
||||
## Stargazers
|
||||
|
||||
|
||||
@@ -3,13 +3,17 @@ title: "Ad Blocking"
|
||||
description: "BrowserOS supports full ad blocking with uBlock Origin"
|
||||
---
|
||||
|
||||
BrowserOS supports full ad blocking through [uBlock Origin](https://ublockorigin.com/), the most effective open-source ad blocker available.
|
||||
BrowserOS supports full ad blocking through [uBlock Origin](https://ublockorigin.com/), the most powerful open-source ad blocker available — the full extension, not the watered-down "Lite" version.
|
||||
|
||||
## How It Works
|
||||
## Why BrowserOS?
|
||||
|
||||
Chrome has been [phasing out support](https://developer.chrome.com/docs/extensions/develop/migrate/mv2-deprecation-timeline) for Manifest V2 extensions, which uBlock Origin relies on for its full blocking capabilities. We re-enabled Manifest V2 support in BrowserOS so uBlock Origin can run at full power.
|
||||
Chrome [killed support](https://developer.chrome.com/docs/extensions/develop/migrate/mv2-deprecation-timeline) for uBlock Origin by phasing out Manifest V2 extensions. The only option left on Chrome is "uBlock Origin Lite," a significantly weaker version that can't use advanced filtering rules.
|
||||
|
||||
Install it from the Chrome Web Store: [uBlock Origin](https://chromewebstore.google.com/detail/ublock-origin/cjpalhdlnbpafiamejdnhcphjbkeiagm)
|
||||
**BrowserOS re-enabled full Manifest V2 support**, so you can install and run the original uBlock Origin at full power — the same extension Chrome no longer allows.
|
||||
|
||||
<Card title="Install uBlock Origin" icon="shield-check" href="https://chromewebstore.google.com/detail/ublock-origin/cjpalhdlnbpafiamejdnhcphjbkeiagm">
|
||||
Install the full uBlock Origin extension from the Chrome Web Store. Works on BrowserOS out of the box.
|
||||
</Card>
|
||||
|
||||
## BrowserOS vs Chrome
|
||||
|
||||
|
||||
@@ -42,6 +42,10 @@ Welcome to BrowserOS! Let's get you set up.
|
||||
|
||||
## You're all set!
|
||||
|
||||
<Tip>
|
||||
**Block ads with uBlock Origin** — Chrome dropped support for the full uBlock Origin extension, but BrowserOS brought it back. [Install it from the Chrome Web Store](https://chromewebstore.google.com/detail/ublock-origin/cjpalhdlnbpafiamejdnhcphjbkeiagm) and browse ad-free. [Learn more →](/features/ad-blocking)
|
||||
</Tip>
|
||||
|
||||
Explore what BrowserOS can do:
|
||||
|
||||
<Columns cols={2}>
|
||||
|
||||
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
|
||||
|
||||
@@ -15,9 +15,6 @@ VITE_PUBLIC_SENTRY_DSN=
|
||||
# BrowserOS API URL
|
||||
VITE_PUBLIC_BROWSEROS_API=https://api.browseros.com
|
||||
|
||||
# Launch feature flags
|
||||
VITE_PUBLIC_KIMI_LAUNCH=false
|
||||
|
||||
# GraphQL Schema Path (optional — falls back to schema/schema.graphql)
|
||||
GRAPHQL_SCHEMA_PATH=
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,7 +12,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|------|------------|---------|
|
||||
| Folders | kebab-case | `ai-settings/`, `jtbd-popup/`, `llm-hub/` |
|
||||
| React components (.tsx) | PascalCase | `AISettingsPage.tsx`, `SurveyHeader.tsx` |
|
||||
| Hooks (.ts) | camelCase with `use` prefix | `useRunWorkflow.ts`, `useVoiceInput.ts` |
|
||||
| Hooks (.ts) | camelCase with `use` prefix | `useVoiceInput.ts`, `useMessageTree.ts` |
|
||||
| Non-component files (.ts) | lowercase | `types.ts`, `models.ts`, `storage.ts` |
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -4,12 +4,13 @@ import {
|
||||
Bot,
|
||||
Compass,
|
||||
CreditCard,
|
||||
GitBranch,
|
||||
MessageSquare,
|
||||
Palette,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Server,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { NavLink } from 'react-router'
|
||||
@@ -79,19 +80,15 @@ 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',
|
||||
icon: CreditCard,
|
||||
feature: Feature.CREDITS_SUPPORT,
|
||||
},
|
||||
{
|
||||
name: 'Workflows',
|
||||
to: '/workflows',
|
||||
icon: GitBranch,
|
||||
feature: Feature.WORKFLOW_SUPPORT,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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,9 +7,14 @@ 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 { CreateGraphWrapper } from './create-graph/CreateGraphWrapper'
|
||||
import { CustomizationPage } from './customization/CustomizationPage'
|
||||
import { SurveyPage } from './jtbd-agent/SurveyPage'
|
||||
import { AuthLayout } from './layout/AuthLayout'
|
||||
@@ -28,8 +31,8 @@ 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'
|
||||
import { WorkflowsPageWrapper } from './workflows/WorkflowsPageWrapper'
|
||||
|
||||
function getSurveyParams(): { maxTurns?: number; experimentId?: string } {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
@@ -53,9 +56,7 @@ const OptionsRedirect: FC = () => {
|
||||
soul: '/home/soul',
|
||||
skills: '/home/skills',
|
||||
'jtbd-agent': '/settings/survey',
|
||||
workflows: '/workflows',
|
||||
scheduled: '/scheduled',
|
||||
'create-graph': '/workflows/create-graph',
|
||||
}
|
||||
|
||||
const newPath = routeMap[path] || '/settings/ai'
|
||||
@@ -80,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 />} />
|
||||
@@ -90,8 +97,9 @@ export const App: FC = () => {
|
||||
|
||||
{/* Primary nav routes */}
|
||||
<Route path="connect-apps" element={<ConnectMCP />} />
|
||||
<Route path="workflows" element={<WorkflowsPageWrapper />} />
|
||||
<Route path="scheduled" element={<ScheduledTasksPage />} />
|
||||
<Route path="agents" element={<AgentsPage />} />
|
||||
<Route path="admin" element={<AdminDashboardPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Settings with dedicated sidebar */}
|
||||
@@ -105,12 +113,11 @@ 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>
|
||||
|
||||
{/* Full-screen without sidebar */}
|
||||
<Route path="workflows/create-graph" element={<CreateGraphWrapper />} />
|
||||
|
||||
{/* Onboarding routes - no sidebar, no auth required */}
|
||||
<Route path="onboarding">
|
||||
<Route index element={<Onboarding />} />
|
||||
@@ -137,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,
|
||||
})
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Loader2,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import { type FC, useEffect, useMemo, useState } from 'react'
|
||||
import { type FC, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod/v3'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -61,7 +61,6 @@ import {
|
||||
KIMI_API_KEY_GUIDE_CLICKED_EVENT,
|
||||
MODEL_SELECTED_EVENT,
|
||||
} from '@/lib/constants/analyticsEvents'
|
||||
import { useKimiLaunch } from '@/lib/feature-flags/useKimiLaunch'
|
||||
import {
|
||||
getDefaultBaseUrlForProviders,
|
||||
getProviderTemplate,
|
||||
@@ -223,9 +222,9 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
const [testResult, setTestResult] = useState<TestResult | null>(null)
|
||||
const [modelPickerOpen, setModelPickerOpen] = useState(false)
|
||||
const [modelSearch, setModelSearch] = useState('')
|
||||
const modelListRef = useRef<HTMLDivElement>(null)
|
||||
const { supports } = useCapabilities()
|
||||
const { baseUrl: agentServerUrl } = useAgentServerUrl()
|
||||
const kimiLaunch = useKimiLaunch()
|
||||
|
||||
const filteredProviderTypeOptions = providerTypeOptions.filter((opt) => {
|
||||
if (opt.value === 'chatgpt-pro')
|
||||
@@ -233,8 +232,6 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
if (opt.value === 'github-copilot')
|
||||
return supports(Feature.GITHUB_COPILOT_SUPPORT)
|
||||
if (opt.value === 'qwen-code') return supports(Feature.QWEN_CODE_SUPPORT)
|
||||
if (opt.value === 'moonshot')
|
||||
return kimiLaunch || initialValues?.type === 'moonshot'
|
||||
if (opt.value === 'openai-compatible') {
|
||||
return supports(Feature.OPENAI_COMPATIBLE_SUPPORT)
|
||||
}
|
||||
@@ -309,6 +306,9 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
? modelFuse.search(modelSearch).map((r) => r.item)
|
||||
: modelInfoList
|
||||
|
||||
const showCustomEntry =
|
||||
modelSearch && !filteredModels.some((m) => m.modelId === modelSearch)
|
||||
|
||||
// Handle provider type change (user-initiated via Select)
|
||||
const handleTypeChange = (newType: ProviderType) => {
|
||||
form.setValue('type', newType)
|
||||
@@ -894,59 +894,96 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
<CommandInput
|
||||
placeholder="Search models..."
|
||||
value={modelSearch}
|
||||
onValueChange={setModelSearch}
|
||||
onValueChange={(v) => {
|
||||
setModelSearch(v)
|
||||
requestAnimationFrame(() => {
|
||||
modelListRef.current?.scrollTo(0, 0)
|
||||
})
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === 'Enter' &&
|
||||
modelSearch &&
|
||||
filteredModels.length === 0
|
||||
) {
|
||||
if (e.key === 'Enter' && modelSearch) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
form.setValue('modelId', modelSearch)
|
||||
track(MODEL_SELECTED_EVENT, {
|
||||
provider_type: watchedType,
|
||||
model_id: modelSearch,
|
||||
is_custom_model: true,
|
||||
is_custom_model: !modelInfoList.some(
|
||||
(m) => m.modelId === modelSearch,
|
||||
),
|
||||
})
|
||||
setModelPickerOpen(false)
|
||||
setModelSearch('')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandList ref={modelListRef}>
|
||||
<CommandEmpty>
|
||||
No models found. Press Enter to use "
|
||||
{modelSearch}"
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filteredModels.map((model) => (
|
||||
{showCustomEntry && (
|
||||
<CommandGroup forceMount>
|
||||
<CommandItem
|
||||
key={model.modelId}
|
||||
value={model.modelId}
|
||||
forceMount
|
||||
value={`custom:${modelSearch}`}
|
||||
onSelect={() => {
|
||||
form.setValue('modelId', model.modelId)
|
||||
form.setValue('modelId', modelSearch)
|
||||
track(MODEL_SELECTED_EVENT, {
|
||||
provider_type: watchedType,
|
||||
model_id: model.modelId,
|
||||
context_window: model.contextLength,
|
||||
is_custom_model: false,
|
||||
model_id: modelSearch,
|
||||
is_custom_model: true,
|
||||
})
|
||||
setModelPickerOpen(false)
|
||||
setModelSearch('')
|
||||
}}
|
||||
>
|
||||
<span className="flex-1 truncate">
|
||||
{model.modelId}
|
||||
{modelSearch}
|
||||
</span>
|
||||
<span className="ml-2 shrink-0 rounded-md bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||||
{formatContextWindow(model.contextLength)}
|
||||
</span>
|
||||
{field.value === model.modelId && (
|
||||
{field.value === modelSearch && (
|
||||
<Check className="ml-2 h-4 w-4 shrink-0" />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandGroup>
|
||||
)}
|
||||
{filteredModels.length > 0 && (
|
||||
<CommandGroup>
|
||||
{filteredModels.map((model) => (
|
||||
<CommandItem
|
||||
key={model.modelId}
|
||||
value={model.modelId}
|
||||
onSelect={() => {
|
||||
form.setValue('modelId', model.modelId)
|
||||
track(MODEL_SELECTED_EVENT, {
|
||||
provider_type: watchedType,
|
||||
model_id: model.modelId,
|
||||
context_window: model.contextLength,
|
||||
is_custom_model: !modelInfoList.some(
|
||||
(m) => m.modelId === model.modelId,
|
||||
),
|
||||
})
|
||||
setModelPickerOpen(false)
|
||||
setModelSearch('')
|
||||
}}
|
||||
>
|
||||
<span className="flex-1 truncate">
|
||||
{model.modelId}
|
||||
</span>
|
||||
{model.contextLength > 0 && (
|
||||
<span className="ml-2 shrink-0 rounded-md bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||||
{formatContextWindow(
|
||||
model.contextLength,
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{field.value === model.modelId && (
|
||||
<Check className="ml-2 h-4 w-4 shrink-0" />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Check, Loader2, Trash2 } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useKimiLaunch } from '@/lib/feature-flags/useKimiLaunch'
|
||||
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
|
||||
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -30,7 +29,6 @@ export const ProviderCard: FC<ProviderCardProps> = ({
|
||||
isTesting = false,
|
||||
}) => {
|
||||
const inputId = `provider-${provider.id}`
|
||||
const kimiLaunch = useKimiLaunch()
|
||||
|
||||
return (
|
||||
<label
|
||||
@@ -79,30 +77,21 @@ export const ProviderCard: FC<ProviderCardProps> = ({
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{isBuiltIn && provider.type === 'browseros' && kimiLaunch && (
|
||||
<span className="mb-1 inline-block rounded-full border border-orange-300/60 bg-orange-100/70 px-3 py-0.5 font-semibold text-orange-700 text-xs dark:border-orange-400/40 dark:bg-orange-500/15 dark:text-orange-300">
|
||||
In partnership with Moonshot AI
|
||||
</span>
|
||||
)}
|
||||
<p className="truncate text-muted-foreground text-sm">
|
||||
{isBuiltIn ? (
|
||||
kimiLaunch ? (
|
||||
'Extended usage limits for the next 2 weeks!'
|
||||
) : (
|
||||
<>
|
||||
BrowserOS-hosted model with strict rate limits.{' '}
|
||||
<a
|
||||
href="https://docs.browseros.com/features/bring-your-own-llm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Bring your own key
|
||||
</a>{' '}
|
||||
for better performance.
|
||||
</>
|
||||
)
|
||||
<>
|
||||
BrowserOS-hosted model with strict rate limits.{' '}
|
||||
<a
|
||||
href="https://docs.browseros.com/features/bring-your-own-llm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Bring your own key
|
||||
</a>{' '}
|
||||
for better performance.
|
||||
</>
|
||||
) : provider.baseUrl ? (
|
||||
`${provider.modelId} • ${provider.baseUrl}`
|
||||
) : (
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Feature } from '@/lib/browseros/capabilities'
|
||||
import { useCapabilities } from '@/lib/browseros/useCapabilities'
|
||||
import { useKimiLaunch } from '@/lib/feature-flags/useKimiLaunch'
|
||||
import {
|
||||
type ProviderTemplate,
|
||||
providerTemplates,
|
||||
@@ -23,7 +22,6 @@ export const ProviderTemplatesSection: FC<ProviderTemplatesSectionProps> = ({
|
||||
onUseTemplate,
|
||||
}) => {
|
||||
const { supports } = useCapabilities()
|
||||
const kimiLaunch = useKimiLaunch()
|
||||
|
||||
const filteredTemplates = providerTemplates.filter((template) => {
|
||||
if (template.id === 'chatgpt-pro')
|
||||
@@ -31,7 +29,6 @@ export const ProviderTemplatesSection: FC<ProviderTemplatesSectionProps> = ({
|
||||
if (template.id === 'github-copilot')
|
||||
return supports(Feature.GITHUB_COPILOT_SUPPORT)
|
||||
if (template.id === 'qwen-code') return supports(Feature.QWEN_CODE_SUPPORT)
|
||||
if (template.id === 'moonshot') return kimiLaunch
|
||||
if (template.id === 'openai-compatible') {
|
||||
return supports(Feature.OPENAI_COMPATIBLE_SUPPORT)
|
||||
}
|
||||
@@ -67,7 +64,6 @@ export const ProviderTemplatesSection: FC<ProviderTemplatesSectionProps> = ({
|
||||
<ProviderTemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
highlighted={template.id === 'moonshot'}
|
||||
isNew={isNew}
|
||||
onUseTemplate={onUseTemplate}
|
||||
/>
|
||||
|
||||
@@ -1,484 +0,0 @@
|
||||
import { useChat } from '@ai-sdk/react'
|
||||
import { DefaultChatTransport, type UIMessage } from 'ai'
|
||||
import { compact } from 'es-toolkit/array'
|
||||
import type { FC, FormEvent } from 'react'
|
||||
import { 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 {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from '@/components/ui/resizable'
|
||||
import { useChatRefs } from '@/entrypoints/sidepanel/index/useChatRefs'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
import {
|
||||
GRAPH_SAVED_EVENT,
|
||||
GRAPH_UPDATED_EVENT,
|
||||
NEW_GRAPH_CREATED_EVENT,
|
||||
} from '@/lib/constants/analyticsEvents'
|
||||
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import { useRpcClient } from '@/lib/rpc/RpcClientProvider'
|
||||
import { sentry } from '@/lib/sentry/sentry'
|
||||
import { useWorkflows } from '@/lib/workflows/workflowStorage'
|
||||
import { GraphCanvas } from './GraphCanvas'
|
||||
import { GraphChat } from './GraphChat'
|
||||
import { WorkflowsChatHeader } from './WorkflowsChatHeader'
|
||||
|
||||
type MessageType = 'create-graph' | 'update-graph' | 'run-graph'
|
||||
|
||||
type GraphMessageMetadata = {
|
||||
messageType?: MessageType
|
||||
codeId?: string
|
||||
graph?: GraphData
|
||||
window?: chrome.windows.Window
|
||||
}
|
||||
|
||||
export type GraphData = {
|
||||
nodes: {
|
||||
id: string
|
||||
type: string
|
||||
data: {
|
||||
label: string
|
||||
}
|
||||
}[]
|
||||
edges: {
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
}[]
|
||||
}
|
||||
|
||||
const getLastMessageText = (messages: UIMessage[]) => {
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
if (!lastMessage) return ''
|
||||
return lastMessage.parts
|
||||
.filter((part) => part.type === 'text')
|
||||
.map((part) => part.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
export const CreateGraph: FC = () => {
|
||||
const [searchParams] = useSearchParams()
|
||||
const workflowIdParam = searchParams.get('workflowId')
|
||||
|
||||
const [graphName, setGraphName] = useState('')
|
||||
const [codeId, setCodeId] = useState<string | undefined>(undefined)
|
||||
const [graphData, setGraphData] = useState<GraphData | undefined>(undefined)
|
||||
const [savedWorkflowId, setSavedWorkflowId] = useState<string | undefined>(
|
||||
undefined,
|
||||
)
|
||||
const [savedCodeId, setSavedCodeId] = useState<string | undefined>(undefined)
|
||||
const [isInitialized, setIsInitialized] = useState(!workflowIdParam)
|
||||
const [canvasPanelSize, setCanvasPanelSize] = useState<
|
||||
{ asPercentage: number; inPixels: number } | undefined
|
||||
>(undefined)
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const [showDiscardDialog, setShowDiscardDialog] = useState(false)
|
||||
|
||||
const { workflows, addWorkflow, editWorkflow } = useWorkflows()
|
||||
const { providers: llmProviders, setDefaultProvider } = useLlmProviders()
|
||||
const rpcClient = useRpcClient()
|
||||
|
||||
// Initialize edit mode when workflowId is provided
|
||||
useDeepCompareEffect(() => {
|
||||
if (!workflowIdParam || isInitialized) return
|
||||
|
||||
const workflow = workflows.find((w) => w.id === workflowIdParam)
|
||||
if (!workflow) return
|
||||
|
||||
const initializeEditMode = async () => {
|
||||
setGraphName(workflow.workflowName)
|
||||
setCodeId(workflow.codeId)
|
||||
setSavedWorkflowId(workflow.id)
|
||||
setSavedCodeId(workflow.codeId)
|
||||
|
||||
try {
|
||||
const response = await rpcClient.graph[':id'].$get({
|
||||
param: { id: workflow.codeId },
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if ('graph' in data && data.graph) {
|
||||
setGraphData(data.graph as GraphData)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
sentry.captureException(error, {
|
||||
extra: {
|
||||
message: 'Failed to fetch graph data from the server',
|
||||
codeId: workflow.codeId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
setIsInitialized(true)
|
||||
}
|
||||
|
||||
initializeEditMode()
|
||||
}, [workflowIdParam, workflows, isInitialized, rpcClient])
|
||||
|
||||
const updateQuery = (newQuery: string) => {
|
||||
setQuery(newQuery)
|
||||
}
|
||||
|
||||
const onSubmit = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (codeId) {
|
||||
sendMessage({
|
||||
text: query,
|
||||
metadata: {
|
||||
messageType: 'update-graph' as MessageType,
|
||||
codeId,
|
||||
},
|
||||
})
|
||||
track(GRAPH_UPDATED_EVENT)
|
||||
} else {
|
||||
sendMessage({
|
||||
text: query,
|
||||
metadata: {
|
||||
messageType: 'create-graph' as MessageType,
|
||||
},
|
||||
})
|
||||
track(NEW_GRAPH_CREATED_EVENT)
|
||||
}
|
||||
setQuery('')
|
||||
}
|
||||
|
||||
const {
|
||||
baseUrl: agentServerUrl,
|
||||
isLoading: _isLoadingAgentUrl,
|
||||
error: agentUrlError,
|
||||
} = useAgentServerUrl()
|
||||
|
||||
const {
|
||||
selectedLlmProviderRef,
|
||||
enabledMcpServersRef,
|
||||
enabledCustomServersRef,
|
||||
personalizationRef,
|
||||
selectedLlmProvider,
|
||||
isLoadingProviders,
|
||||
} = useChatRefs()
|
||||
|
||||
const agentUrlRef = useRef(agentServerUrl)
|
||||
const codeIdRef = useRef(codeId)
|
||||
|
||||
useEffect(() => {
|
||||
agentUrlRef.current = agentServerUrl
|
||||
codeIdRef.current = codeId
|
||||
}, [agentServerUrl, codeId])
|
||||
|
||||
const { sendMessage, stop, status, messages, error, setMessages } = useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
prepareSendMessagesRequest: async ({ messages }) => {
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
const lastMessageText = getLastMessageText(messages)
|
||||
const metadata = lastMessage.metadata as
|
||||
| GraphMessageMetadata
|
||||
| undefined
|
||||
|
||||
if (metadata?.messageType === 'create-graph') {
|
||||
return {
|
||||
api: `${agentUrlRef.current}/graph`,
|
||||
body: {
|
||||
query: lastMessageText,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata?.messageType === 'update-graph' && codeIdRef.current) {
|
||||
return {
|
||||
api: `${agentUrlRef.current}/graph/${codeIdRef.current}`,
|
||||
body: {
|
||||
query: lastMessageText,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata?.messageType === 'run-graph' && codeIdRef.current) {
|
||||
const provider = selectedLlmProviderRef.current
|
||||
const enabledMcpServers = enabledMcpServersRef.current
|
||||
const customMcpServers = enabledCustomServersRef.current
|
||||
|
||||
return {
|
||||
api: `${agentUrlRef.current}/graph/${codeIdRef.current}/run`,
|
||||
body: {
|
||||
provider: provider?.type,
|
||||
providerType: provider?.type,
|
||||
providerName: provider?.name,
|
||||
model: provider?.modelId ?? 'browseros',
|
||||
contextWindowSize: provider?.contextWindow,
|
||||
temperature: provider?.temperature,
|
||||
resourceName: provider?.resourceName,
|
||||
// Bedrock-specific
|
||||
accessKeyId: provider?.accessKeyId,
|
||||
secretAccessKey: provider?.secretAccessKey,
|
||||
region: provider?.region,
|
||||
sessionToken: provider?.sessionToken,
|
||||
apiKey: provider?.apiKey,
|
||||
baseUrl: provider?.baseUrl,
|
||||
browserContext: {
|
||||
windowId: metadata?.window?.id,
|
||||
activeTab: metadata?.window?.tabs?.[0],
|
||||
enabledMcpServers: compact(enabledMcpServers),
|
||||
customMcpServers,
|
||||
},
|
||||
userSystemPrompt: personalizationRef.current,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
api: `${agentUrlRef.current}/graph`,
|
||||
body: {
|
||||
query: lastMessageText,
|
||||
},
|
||||
}
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
const lastAssistantMessageWithGraph = messages.findLast((m) => {
|
||||
if (m.role !== 'assistant') return false
|
||||
const metadata = m.metadata as GraphMessageMetadata | undefined
|
||||
return metadata?.graph !== undefined
|
||||
})
|
||||
|
||||
const onClickTest = async () => {
|
||||
let backgroundWindow: chrome.windows.Window | undefined
|
||||
try {
|
||||
backgroundWindow = await chrome.windows.create({
|
||||
url: 'chrome://newtab',
|
||||
focused: true,
|
||||
type: 'normal',
|
||||
})
|
||||
} catch {
|
||||
// Fallback when no window context is available (e.g. all windows closed)
|
||||
const tab = await chrome.tabs.create({
|
||||
url: 'chrome://newtab',
|
||||
active: true,
|
||||
})
|
||||
if (tab.windowId) {
|
||||
backgroundWindow = await chrome.windows.get(tab.windowId)
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage({
|
||||
text: 'Run a test of the graph you just created.',
|
||||
metadata: {
|
||||
messageType: 'run-graph' as MessageType,
|
||||
codeId,
|
||||
window: backgroundWindow,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const hasUnsavedChanges = savedWorkflowId ? codeId !== savedCodeId : true
|
||||
const shouldBlockNavigation = !!codeId && hasUnsavedChanges
|
||||
|
||||
// Handle browser refresh/close
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (shouldBlockNavigation) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}, [shouldBlockNavigation])
|
||||
|
||||
const onClickSave = async () => {
|
||||
if (!graphName || !codeId) return
|
||||
|
||||
if (savedWorkflowId) {
|
||||
await editWorkflow(savedWorkflowId, {
|
||||
workflowName: graphName,
|
||||
codeId,
|
||||
})
|
||||
setSavedCodeId(codeId)
|
||||
} else {
|
||||
const newWorkflow = await addWorkflow({
|
||||
workflowName: graphName,
|
||||
codeId,
|
||||
})
|
||||
setSavedWorkflowId(newWorkflow.id)
|
||||
setSavedCodeId(codeId)
|
||||
}
|
||||
track(GRAPH_SAVED_EVENT)
|
||||
}
|
||||
|
||||
// Provider data for header
|
||||
const providers: Provider[] = llmProviders.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
type: p.type,
|
||||
}))
|
||||
|
||||
const selectedProviderForHeader: Provider | undefined = selectedLlmProvider
|
||||
? {
|
||||
id: selectedLlmProvider.id,
|
||||
name: selectedLlmProvider.name,
|
||||
type: selectedLlmProvider.type,
|
||||
}
|
||||
: providers[0]
|
||||
|
||||
// Has generated code but can't auto-save (no name)
|
||||
const hasUnsavedWork = codeId && !graphName
|
||||
|
||||
const resetToNewWorkflow = () => {
|
||||
setCodeId(undefined)
|
||||
setGraphData(undefined)
|
||||
setGraphName('')
|
||||
setSavedWorkflowId(undefined)
|
||||
setSavedCodeId(undefined)
|
||||
setMessages([])
|
||||
}
|
||||
|
||||
const handleSelectProvider = (provider: Provider) => {
|
||||
setDefaultProvider(provider.id)
|
||||
}
|
||||
|
||||
const handleNewWorkflow = async () => {
|
||||
// Can auto-save: has name AND code
|
||||
if (graphName && codeId) {
|
||||
await onClickSave()
|
||||
resetToNewWorkflow()
|
||||
return
|
||||
}
|
||||
|
||||
// Has unsaved work that can't be auto-saved: show confirmation
|
||||
if (hasUnsavedWork) {
|
||||
setShowDiscardDialog(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Nothing to save, just reset
|
||||
resetToNewWorkflow()
|
||||
}
|
||||
|
||||
const handleConfirmDiscard = () => {
|
||||
setShowDiscardDialog(false)
|
||||
resetToNewWorkflow()
|
||||
}
|
||||
|
||||
const handleSuggestionClick = (prompt: string) => {
|
||||
sendMessage({
|
||||
text: prompt,
|
||||
metadata: {
|
||||
messageType: 'create-graph' as MessageType,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
useDeepCompareEffect(() => {
|
||||
if (status === 'ready' && lastAssistantMessageWithGraph) {
|
||||
const metadata = lastAssistantMessageWithGraph.metadata as
|
||||
| GraphMessageMetadata
|
||||
| undefined
|
||||
setCodeId(metadata?.codeId)
|
||||
setGraphData(metadata?.graph)
|
||||
}
|
||||
}, [status, lastAssistantMessageWithGraph ?? {}])
|
||||
|
||||
if (!isInitialized || isLoadingProviders || !selectedProviderForHeader) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-background text-foreground">
|
||||
<div className="fade-in animate-in text-muted-foreground duration-200 [animation-delay:300ms] [animation-fill-mode:backwards]">
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen bg-background text-foreground">
|
||||
<ResizablePanelGroup orientation="horizontal">
|
||||
<ResizablePanel
|
||||
id="graph-canvas"
|
||||
defaultSize={'70%'}
|
||||
minSize={'30%'}
|
||||
maxSize={'70%'}
|
||||
onResize={(size) => setCanvasPanelSize(size)}
|
||||
>
|
||||
<GraphCanvas
|
||||
graphName={graphName}
|
||||
onGraphNameChange={(val) => setGraphName(val)}
|
||||
graphData={graphData}
|
||||
codeId={codeId}
|
||||
onClickTest={onClickTest}
|
||||
onClickSave={onClickSave}
|
||||
isSaved={!!savedWorkflowId}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
shouldBlockNavigation={shouldBlockNavigation}
|
||||
panelSize={canvasPanelSize}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
id="graph-chat"
|
||||
defaultSize={'30%'}
|
||||
maxSize={'70%'}
|
||||
minSize={'30%'}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<WorkflowsChatHeader
|
||||
selectedProvider={selectedProviderForHeader}
|
||||
providers={providers}
|
||||
onSelectProvider={handleSelectProvider}
|
||||
onNewWorkflow={handleNewWorkflow}
|
||||
hasMessages={messages.length > 0}
|
||||
/>
|
||||
<div className="min-h-0 flex-1">
|
||||
<GraphChat
|
||||
messages={messages}
|
||||
onSubmit={onSubmit}
|
||||
onInputChange={updateQuery}
|
||||
onStop={stop}
|
||||
input={query}
|
||||
status={status}
|
||||
agentUrlError={agentUrlError}
|
||||
chatError={error}
|
||||
onSuggestionClick={handleSuggestionClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
|
||||
<AlertDialog open={showDiscardDialog} onOpenChange={setShowDiscardDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Discard unsaved workflow?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
You have an unsaved workflow. Creating a new one will discard your
|
||||
current changes.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirmDiscard}>
|
||||
Discard
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { type FC, Suspense } from 'react'
|
||||
import { RpcClientProvider } from '@/lib/rpc/RpcClientProvider'
|
||||
import { CreateGraph } from './CreateGraph'
|
||||
|
||||
export const CreateGraphWrapper: FC = () => {
|
||||
return (
|
||||
<RpcClientProvider>
|
||||
<Suspense fallback={<div className="h-screen w-screen bg-background" />}>
|
||||
<CreateGraph />
|
||||
</Suspense>
|
||||
</RpcClientProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import { Handle, type Node, type NodeProps, Position } from '@xyflow/react'
|
||||
import {
|
||||
CheckCircle,
|
||||
Download,
|
||||
GitBranch,
|
||||
GitMerge,
|
||||
MousePointer,
|
||||
Navigation,
|
||||
Play,
|
||||
RotateCw,
|
||||
Split,
|
||||
Square,
|
||||
} from 'lucide-react'
|
||||
import type React from 'react'
|
||||
import { memo } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const nodeConfig: Record<
|
||||
NodeType,
|
||||
{ color: string; icon: React.ElementType; label: string }
|
||||
> = {
|
||||
start: {
|
||||
color: 'text-green-600 dark:text-green-400',
|
||||
icon: Play,
|
||||
label: 'Start',
|
||||
},
|
||||
end: {
|
||||
color: 'text-red-600 dark:text-red-400',
|
||||
icon: Square,
|
||||
label: 'End',
|
||||
},
|
||||
nav: {
|
||||
color: 'text-blue-600 dark:text-blue-400',
|
||||
icon: Navigation,
|
||||
label: 'Navigate',
|
||||
},
|
||||
act: {
|
||||
color: 'text-purple-600 dark:text-purple-400',
|
||||
icon: MousePointer,
|
||||
label: 'Action',
|
||||
},
|
||||
extract: {
|
||||
color: 'text-amber-600 dark:text-amber-400',
|
||||
icon: Download,
|
||||
label: 'Extract',
|
||||
},
|
||||
verify: {
|
||||
color: 'text-emerald-600 dark:text-emerald-400',
|
||||
icon: CheckCircle,
|
||||
label: 'Verify',
|
||||
},
|
||||
decision: {
|
||||
color: 'text-pink-600 dark:text-pink-400',
|
||||
icon: GitBranch,
|
||||
label: 'Decision',
|
||||
},
|
||||
loop: {
|
||||
color: 'text-cyan-600 dark:text-cyan-400',
|
||||
icon: RotateCw,
|
||||
label: 'Loop',
|
||||
},
|
||||
fork: {
|
||||
color: 'text-indigo-600 dark:text-indigo-400',
|
||||
icon: Split,
|
||||
label: 'Fork',
|
||||
},
|
||||
join: {
|
||||
color: 'text-lime-600 dark:text-lime-400',
|
||||
icon: GitMerge,
|
||||
label: 'Join',
|
||||
},
|
||||
}
|
||||
|
||||
export type NodeType =
|
||||
| 'start'
|
||||
| 'end'
|
||||
| 'nav'
|
||||
| 'act'
|
||||
| 'extract'
|
||||
| 'verify'
|
||||
| 'decision'
|
||||
| 'loop'
|
||||
| 'fork'
|
||||
| 'join'
|
||||
|
||||
type CustomNodeData = Node<{
|
||||
type: NodeType
|
||||
label: string
|
||||
}>
|
||||
|
||||
export const CustomNode = memo(
|
||||
({ data: { label, type } }: NodeProps<CustomNodeData>) => {
|
||||
const config = nodeConfig[type || 'start']
|
||||
const Icon = config.icon
|
||||
|
||||
const showSourceHandle = type !== 'end'
|
||||
const showTargetHandle = type !== 'start'
|
||||
|
||||
return (
|
||||
<div className="min-w-45 rounded-lg border border-border bg-card px-4 py-3 shadow-md transition-all">
|
||||
{showTargetHandle && (
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="h-2 w-2 bg-accent-orange!"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn('shrink-0', config.color)}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
'mb-0.5 font-semibold text-xs uppercase tracking-wide',
|
||||
config.color,
|
||||
)}
|
||||
>
|
||||
{config.label}
|
||||
</div>
|
||||
<div className="wrap-break-word font-medium text-foreground text-sm">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSourceHandle && (
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className="h-2 w-2 bg-accent-orange!"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
CustomNode.displayName = 'CustomNode'
|
||||
@@ -1,514 +0,0 @@
|
||||
import cytoscape from 'cytoscape'
|
||||
import dagre from 'cytoscape-dagre'
|
||||
// @ts-expect-error no types available
|
||||
import nodeHtmlLabel from 'cytoscape-node-html-label'
|
||||
import DOMPurify from 'dompurify'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Maximize,
|
||||
Minus,
|
||||
Pencil,
|
||||
Play,
|
||||
Plus,
|
||||
Save,
|
||||
} from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import useDeepCompareEffect from 'use-deep-compare-effect'
|
||||
import ProductLogo from '@/assets/product_logo.svg'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import type { GraphData } from './CreateGraph'
|
||||
import type { NodeType } from './CustomNode'
|
||||
|
||||
cytoscape.use(dagre)
|
||||
nodeHtmlLabel(cytoscape)
|
||||
|
||||
const NODE_CONFIG: Record<
|
||||
NodeType,
|
||||
{ color: string; bgColor: string; icon: string; label: string }
|
||||
> = {
|
||||
start: {
|
||||
color: '#22c55e',
|
||||
bgColor: 'rgba(34, 197, 94, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 3 20 12 6 21 6 3"></polygon></svg>`,
|
||||
label: 'START',
|
||||
},
|
||||
end: {
|
||||
color: '#ef4444',
|
||||
bgColor: 'rgba(239, 68, 68, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"></rect></svg>`,
|
||||
label: 'END',
|
||||
},
|
||||
nav: {
|
||||
color: '#3b82f6',
|
||||
bgColor: 'rgba(59, 130, 246, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="3 11 22 2 13 21 11 13 3 11"></polygon></svg>`,
|
||||
label: 'NAVIGATE',
|
||||
},
|
||||
act: {
|
||||
color: '#8b5cf6',
|
||||
bgColor: 'rgba(139, 92, 246, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 4 7.07 17 2.51-7.39L21 11.07z"></path></svg>`,
|
||||
label: 'ACTION',
|
||||
},
|
||||
extract: {
|
||||
color: '#f59e0b',
|
||||
bgColor: 'rgba(245, 158, 11, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" x2="12" y1="15" y2="3"></line></svg>`,
|
||||
label: 'EXTRACT',
|
||||
},
|
||||
verify: {
|
||||
color: '#10b981',
|
||||
bgColor: 'rgba(16, 185, 129, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>`,
|
||||
label: 'VERIFY',
|
||||
},
|
||||
decision: {
|
||||
color: '#ec4899',
|
||||
bgColor: 'rgba(236, 72, 153, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" x2="6" y1="3" y2="15"></line><circle cx="18" cy="6" r="3"></circle><circle cx="6" cy="18" r="3"></circle><path d="M18 9a9 9 0 0 1-9 9"></path></svg>`,
|
||||
label: 'DECISION',
|
||||
},
|
||||
loop: {
|
||||
color: '#06b6d4',
|
||||
bgColor: 'rgba(6, 182, 212, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path></svg>`,
|
||||
label: 'LOOP',
|
||||
},
|
||||
fork: {
|
||||
color: '#6366f1',
|
||||
bgColor: 'rgba(99, 102, 241, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 3h5v5"></path><path d="M8 3H3v5"></path><path d="M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3"></path><path d="m15 9 6-6"></path></svg>`,
|
||||
label: 'FORK',
|
||||
},
|
||||
join: {
|
||||
color: '#84cc16',
|
||||
bgColor: 'rgba(132, 204, 22, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="18" r="3"></circle><circle cx="6" cy="6" r="3"></circle><path d="M6 21V9a9 9 0 0 0 9 9"></path></svg>`,
|
||||
label: 'JOIN',
|
||||
},
|
||||
}
|
||||
|
||||
const initialData: GraphData = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'start',
|
||||
type: 'start',
|
||||
data: { label: 'Use the Chat to build your workflow!' },
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
}
|
||||
|
||||
const MIN_NODE_WIDTH = 180
|
||||
const MAX_NODE_WIDTH = 240
|
||||
const BASE_NODE_HEIGHT = 70
|
||||
const CHAR_WIDTH = 7
|
||||
const ICON_AND_PADDING = 62
|
||||
const MAX_ZOOM = 1.2
|
||||
|
||||
const calculateNodeDimensions = (
|
||||
label: string,
|
||||
): { width: number; height: number } => {
|
||||
const textWidth = label.length * CHAR_WIDTH + ICON_AND_PADDING
|
||||
const width = Math.max(MIN_NODE_WIDTH, Math.min(MAX_NODE_WIDTH, textWidth))
|
||||
|
||||
const maxCharsPerLine = Math.floor((width - ICON_AND_PADDING) / CHAR_WIDTH)
|
||||
const lines = Math.ceil(label.length / maxCharsPerLine)
|
||||
const extraHeight = Math.max(0, lines - 1) * 18
|
||||
const height = BASE_NODE_HEIGHT + extraHeight
|
||||
|
||||
return { width, height }
|
||||
}
|
||||
|
||||
const createNodeHtml = (type: NodeType, label: string): string => {
|
||||
const config = NODE_CONFIG[type] || NODE_CONFIG.start
|
||||
const sanitizedLabel = DOMPurify.sanitize(label, { ALLOWED_TAGS: [] })
|
||||
return `
|
||||
<div class="graph-node" style="
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
min-width: 160px;
|
||||
max-width: 220px;
|
||||
padding: 12px 16px;
|
||||
background-color: var(--graph-node-bg);
|
||||
border: 1px solid var(--graph-node-border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
">
|
||||
<div style="
|
||||
flex-shrink: 0;
|
||||
color: ${config.color};
|
||||
margin-top: 2px;
|
||||
">
|
||||
${config.icon}
|
||||
</div>
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<div style="
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
color: ${config.color};
|
||||
margin-bottom: 4px;
|
||||
">${config.label}</div>
|
||||
<div style="
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--graph-node-text);
|
||||
line-height: 1.4;
|
||||
word-wrap: break-word;
|
||||
">${sanitizedLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
type GraphCanvasProps = {
|
||||
graphName: string
|
||||
onGraphNameChange: (name: string) => void
|
||||
graphData?: GraphData
|
||||
codeId?: string
|
||||
onClickTest: () => unknown
|
||||
onClickSave: () => unknown
|
||||
isSaved: boolean
|
||||
hasUnsavedChanges: boolean
|
||||
shouldBlockNavigation: boolean
|
||||
panelSize?: { asPercentage: number; inPixels: number }
|
||||
}
|
||||
|
||||
export const GraphCanvas: FC<GraphCanvasProps> = ({
|
||||
graphName,
|
||||
onGraphNameChange,
|
||||
graphData = initialData,
|
||||
codeId,
|
||||
onClickTest,
|
||||
onClickSave,
|
||||
isSaved,
|
||||
hasUnsavedChanges,
|
||||
shouldBlockNavigation,
|
||||
panelSize,
|
||||
}) => {
|
||||
const [isEditingName, setIsEditingName] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const cyRef = useRef<cytoscape.Core | null>(null)
|
||||
|
||||
const handleBack = () => {
|
||||
if (shouldBlockNavigation) {
|
||||
const confirmed = window.confirm(
|
||||
'You have unsaved changes. Are you sure you want to leave?',
|
||||
)
|
||||
if (!confirmed) return
|
||||
}
|
||||
navigate(-1)
|
||||
}
|
||||
|
||||
const canTest = !!codeId
|
||||
const canSave = !!graphName && !!codeId && hasUnsavedChanges
|
||||
|
||||
const getTestTooltip = () => {
|
||||
if (!codeId) return 'Create a workflow using the chat first'
|
||||
return 'Run a test of this workflow'
|
||||
}
|
||||
|
||||
const getSaveTooltip = () => {
|
||||
if (!codeId) return 'Create a workflow using the chat first'
|
||||
if (!graphName) return 'Provide a name for the workflow'
|
||||
if (isSaved && !hasUnsavedChanges) return 'Workflow already saved'
|
||||
return isSaved ? 'Save changes to this workflow' : 'Save this workflow'
|
||||
}
|
||||
|
||||
const getSaveButtonLabel = () => {
|
||||
return isSaved ? 'Save Changes' : 'Save Workflow'
|
||||
}
|
||||
|
||||
const zoomIn = useCallback(() => {
|
||||
cyRef.current?.zoom(cyRef.current.zoom() * 1.2)
|
||||
cyRef.current?.center()
|
||||
}, [])
|
||||
|
||||
const zoomOut = useCallback(() => {
|
||||
cyRef.current?.zoom(cyRef.current.zoom() / 1.2)
|
||||
cyRef.current?.center()
|
||||
}, [])
|
||||
|
||||
const fitView = useCallback(() => {
|
||||
cyRef.current?.fit(undefined, 50)
|
||||
cyRef.current?.center()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
const cy = cytoscape({
|
||||
container: containerRef.current,
|
||||
elements: [],
|
||||
style: [
|
||||
{
|
||||
selector: 'node',
|
||||
style: {
|
||||
width: 'data(nodeWidth)',
|
||||
height: 'data(nodeHeight)',
|
||||
'background-opacity': 0,
|
||||
'border-width': 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: 'edge',
|
||||
style: {
|
||||
width: 2,
|
||||
'line-color': '#f97316',
|
||||
'target-arrow-color': '#f97316',
|
||||
'target-arrow-shape': 'triangle',
|
||||
'curve-style': 'bezier',
|
||||
'arrow-scale': 1.2,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: 'edge.back-edge',
|
||||
style: {
|
||||
'line-style': 'dashed',
|
||||
'line-dash-pattern': [6, 3],
|
||||
'curve-style': 'unbundled-bezier',
|
||||
'control-point-distances': [100],
|
||||
'control-point-weights': [0.5],
|
||||
},
|
||||
},
|
||||
],
|
||||
layout: { name: 'preset' },
|
||||
userZoomingEnabled: true,
|
||||
userPanningEnabled: true,
|
||||
boxSelectionEnabled: false,
|
||||
selectionType: 'single',
|
||||
autoungrabify: true,
|
||||
autounselectify: true,
|
||||
maxZoom: MAX_ZOOM,
|
||||
minZoom: 0.2,
|
||||
})
|
||||
|
||||
// @ts-expect-error nodeHtmlLabel extension
|
||||
cy.nodeHtmlLabel([
|
||||
{
|
||||
query: 'node',
|
||||
halign: 'center',
|
||||
valign: 'center',
|
||||
halignBox: 'center',
|
||||
valignBox: 'center',
|
||||
tpl: (data: { type: NodeType; label: string }) => {
|
||||
return createNodeHtml(data.type, data.label)
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
cyRef.current = cy
|
||||
|
||||
return () => {
|
||||
cy.destroy()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const updateGraph = useCallback((data: GraphData) => {
|
||||
const cy = cyRef.current
|
||||
if (!cy) return
|
||||
|
||||
cy.elements().remove()
|
||||
|
||||
const nodes = data.nodes.map((node) => {
|
||||
const dimensions = calculateNodeDimensions(node.data.label)
|
||||
return {
|
||||
data: {
|
||||
id: node.id,
|
||||
label: node.data.label,
|
||||
type: node.type as NodeType,
|
||||
nodeWidth: dimensions.width,
|
||||
nodeHeight: dimensions.height,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const edges = data.edges.map((edge) => ({
|
||||
data: {
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
},
|
||||
}))
|
||||
|
||||
cy.add([...nodes, ...edges])
|
||||
|
||||
cy.layout({
|
||||
name: 'dagre',
|
||||
rankDir: 'TB',
|
||||
nodeSep: 80,
|
||||
rankSep: 100,
|
||||
padding: 50,
|
||||
animate: true,
|
||||
animationDuration: 300,
|
||||
fit: true,
|
||||
} as cytoscape.LayoutOptions).run()
|
||||
|
||||
setTimeout(() => {
|
||||
cy.edges().forEach((edge) => {
|
||||
const sourceNode = edge.source()
|
||||
const targetNode = edge.target()
|
||||
const sourceY = sourceNode.position('y')
|
||||
const targetY = targetNode.position('y')
|
||||
|
||||
if (sourceY > targetY) {
|
||||
edge.addClass('back-edge')
|
||||
}
|
||||
})
|
||||
}, 350)
|
||||
}, [])
|
||||
|
||||
useDeepCompareEffect(() => {
|
||||
updateGraph(graphData)
|
||||
}, [graphData])
|
||||
|
||||
useEffect(() => {
|
||||
if (panelSize?.inPixels !== undefined) {
|
||||
cyRef.current?.resize()
|
||||
setTimeout(() => fitView(), 100)
|
||||
}
|
||||
}, [panelSize?.inPixels, fitView])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col [--graph-node-bg:rgba(255,255,255,1)] [--graph-node-border:rgba(228,228,231,1)] [--graph-node-text:rgba(24,24,27,1)] dark:[--graph-node-bg:rgba(24,24,27,1)] dark:[--graph-node-border:rgba(63,63,70,1)] dark:[--graph-node-text:rgba(250,250,250,1)]">
|
||||
{/* Graph Header */}
|
||||
<header className="flex h-14 shrink-0 items-center justify-between border-border/40 border-b bg-background/80 px-3 backdrop-blur-md">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={handleBack}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<img src={ProductLogo} alt="BrowserOS" className="h-8 w-8 shrink-0" />
|
||||
{isEditingName ? (
|
||||
<input
|
||||
type="text"
|
||||
value={graphName}
|
||||
onChange={(e) => onGraphNameChange(e.target.value)}
|
||||
onBlur={() => setIsEditingName(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') setIsEditingName(false)
|
||||
}}
|
||||
// biome-ignore lint/a11y/noAutofocus: needed to autofocus field when edit mode is toggled
|
||||
autoFocus
|
||||
placeholder="Enter workflow name..."
|
||||
className="max-w-64 border-[var(--accent-orange)] border-b bg-transparent font-semibold text-sm outline-none placeholder:font-normal placeholder:text-muted-foreground/60"
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsEditingName(true)}
|
||||
className="group min-w-0 gap-2 px-2 py-1"
|
||||
>
|
||||
{graphName ? (
|
||||
<span className="truncate font-semibold text-sm">
|
||||
{graphName}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground/60 text-sm italic">
|
||||
Untitled workflow
|
||||
</span>
|
||||
)}
|
||||
<Pencil className="h-3.5 w-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Control Buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onClickTest}
|
||||
disabled={!canTest}
|
||||
>
|
||||
<Play className="mr-1.5 h-4 w-4" />
|
||||
Test Workflow
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{getTestTooltip()}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onClickSave}
|
||||
disabled={!canSave}
|
||||
className="bg-[var(--accent-orange)] shadow-lg shadow-orange-500/20 hover:bg-[var(--accent-orange-bright)] disabled:bg-[var(--accent-orange)]/50"
|
||||
>
|
||||
<Save className="mr-1.5 h-4 w-4" />
|
||||
{getSaveButtonLabel()}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{getSaveTooltip()}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Graph Canvas */}
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden [--dot-color:rgba(0,0,0,0.2)] dark:[--dot-color:rgba(255,255,255,0.15)]">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full bg-zinc-50 dark:bg-zinc-900"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'radial-gradient(circle, var(--dot-color) 1.5px, transparent 1.5px)',
|
||||
backgroundSize: '20px 20px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Zoom Controls */}
|
||||
<div className="absolute bottom-4 left-4 z-10 flex flex-col gap-1 rounded-lg border-2 border-border bg-card p-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={zoomIn}
|
||||
title="Zoom in"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={zoomOut}
|
||||
title="Zoom out"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={fitView}
|
||||
title="Fit view"
|
||||
>
|
||||
<Maximize className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
import type { UIMessage } from 'ai'
|
||||
import { Send, SquareStop } from 'lucide-react'
|
||||
import type { FC, FormEventHandler, KeyboardEvent } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ChatError } from '@/entrypoints/sidepanel/index/ChatError'
|
||||
import { ChatMessages } from '@/entrypoints/sidepanel/index/ChatMessages'
|
||||
import { getResponseAndQueryFromMessageId } from '@/entrypoints/sidepanel/index/useChatSession'
|
||||
import {
|
||||
GRAPH_MESSAGE_DISLIKE_EVENT,
|
||||
GRAPH_MESSAGE_LIKE_EVENT,
|
||||
} from '@/lib/constants/analyticsEvents'
|
||||
import { useJtbdPopup } from '@/lib/jtbd-popup/useJtbdPopup'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { GraphEmptyState } from './GraphEmptyState'
|
||||
import { getWorkflowDisplayMessages } from './workflow-tidbit-messages'
|
||||
|
||||
interface GraphChatProps {
|
||||
onSubmit: FormEventHandler<HTMLFormElement>
|
||||
onInputChange: (value: string) => void
|
||||
onStop: () => void
|
||||
input: string
|
||||
status: 'streaming' | 'submitted' | 'ready' | 'error'
|
||||
messages: UIMessage[]
|
||||
chatError?: Error
|
||||
agentUrlError?: Error | null
|
||||
onSuggestionClick: (prompt: string) => void
|
||||
}
|
||||
|
||||
export const GraphChat: FC<GraphChatProps> = ({
|
||||
onSubmit,
|
||||
onInputChange,
|
||||
onStop,
|
||||
input,
|
||||
status,
|
||||
messages,
|
||||
chatError,
|
||||
agentUrlError,
|
||||
onSuggestionClick,
|
||||
}) => {
|
||||
const [liked, setLiked] = useState<Record<string, boolean>>({})
|
||||
const [disliked, setDisliked] = useState<Record<string, boolean>>({})
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const displayMessages = getWorkflowDisplayMessages(messages)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const {
|
||||
popupVisible,
|
||||
recordMessageSent,
|
||||
triggerIfEligible,
|
||||
onTakeSurvey: onTakeSurveyBase,
|
||||
onDismiss: onDismissJtbdPopup,
|
||||
} = useJtbdPopup()
|
||||
|
||||
const onTakeSurvey = () =>
|
||||
onTakeSurveyBase({ experimentId: 'workflow_survey' })
|
||||
|
||||
// Trigger JTBD popup when AI finishes responding
|
||||
const previousChatStatus = useRef(status)
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally only trigger on status change
|
||||
useEffect(() => {
|
||||
const aiWasProcessing =
|
||||
previousChatStatus.current === 'streaming' ||
|
||||
previousChatStatus.current === 'submitted'
|
||||
const aiJustFinished = aiWasProcessing && status === 'ready'
|
||||
|
||||
if (aiJustFinished && messages.length > 0) {
|
||||
triggerIfEligible()
|
||||
}
|
||||
previousChatStatus.current = status
|
||||
}, [status])
|
||||
|
||||
const onClickLike = (messageId: string) => {
|
||||
const { responseText, queryText } = getResponseAndQueryFromMessageId(
|
||||
messages,
|
||||
messageId,
|
||||
)
|
||||
|
||||
track(GRAPH_MESSAGE_LIKE_EVENT, { responseText, queryText, messageId })
|
||||
|
||||
setLiked((prev) => ({
|
||||
...prev,
|
||||
[messageId]: !prev[messageId],
|
||||
}))
|
||||
}
|
||||
|
||||
const onClickDislike = (messageId: string, comment?: string) => {
|
||||
const { responseText, queryText } = getResponseAndQueryFromMessageId(
|
||||
messages,
|
||||
messageId,
|
||||
)
|
||||
|
||||
track(GRAPH_MESSAGE_DISLIKE_EVENT, {
|
||||
responseText,
|
||||
queryText,
|
||||
messageId,
|
||||
comment,
|
||||
})
|
||||
|
||||
setDisliked((prev) => ({
|
||||
...prev,
|
||||
[messageId]: !prev[messageId],
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (e) => {
|
||||
recordMessageSent()
|
||||
onSubmit(e)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (
|
||||
e.key === 'Enter' &&
|
||||
!e.shiftKey &&
|
||||
!e.metaKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.nativeEvent.isComposing
|
||||
) {
|
||||
e.preventDefault()
|
||||
if (input.trim()) {
|
||||
e.currentTarget.form?.requestSubmit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="styled-scrollbar min-h-0 flex-1 overflow-y-auto pb-2">
|
||||
{displayMessages.length === 0 ? (
|
||||
<GraphEmptyState
|
||||
mounted={mounted}
|
||||
onSuggestionClick={onSuggestionClick}
|
||||
/>
|
||||
) : (
|
||||
<ChatMessages
|
||||
liked={liked}
|
||||
disliked={disliked}
|
||||
onClickDislike={onClickDislike}
|
||||
onClickLike={onClickLike}
|
||||
messages={displayMessages}
|
||||
status={status}
|
||||
showJtbdPopup={popupVisible}
|
||||
showDontShowAgain={false}
|
||||
onTakeSurvey={onTakeSurvey}
|
||||
onDismissJtbdPopup={onDismissJtbdPopup}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{agentUrlError && <ChatError error={agentUrlError} />}
|
||||
{chatError && <ChatError error={chatError} />}
|
||||
<div className="shrink-0 border-border/40 border-t bg-background/80 p-2 backdrop-blur-md">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="relative flex w-full items-end gap-2"
|
||||
>
|
||||
<textarea
|
||||
className={cn(
|
||||
'field-sizing-content max-h-60 min-h-[42px] flex-1 resize-none overflow-hidden rounded-2xl border border-border/50 bg-muted/50 px-4 py-2.5 pr-11 text-sm outline-none transition-colors placeholder:text-muted-foreground/70 hover:border-border focus:border-[var(--accent-orange)]',
|
||||
)}
|
||||
value={input}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
'Visit Amazon and add sensodyne toothpaste to the cart.'
|
||||
}
|
||||
rows={1}
|
||||
/>
|
||||
{status === 'streaming' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
className="absolute right-1.5 bottom-1.5 cursor-pointer rounded-full bg-red-600 p-2 text-white shadow-sm transition-all duration-200 hover:bg-red-900 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<SquareStop className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">Stop</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim()}
|
||||
className="absolute right-1.5 bottom-1.5 cursor-pointer rounded-full bg-[var(--accent-orange)] p-2 text-white shadow-sm transition-all duration-200 hover:bg-[var(--accent-orange-bright)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">Send</span>
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { Workflow } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface Suggestion {
|
||||
display: string
|
||||
prompt: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
const WORKFLOW_SUGGESTIONS: Suggestion[] = [
|
||||
{
|
||||
display: 'Search Amazon and add toothpaste to cart',
|
||||
prompt:
|
||||
'Go to Amazon, search for toothpaste, select 1 pack filter and add the first result to cart',
|
||||
icon: '🛒',
|
||||
},
|
||||
{
|
||||
display: 'Accept LinkedIn connection requests',
|
||||
prompt:
|
||||
'Open LinkedIn and go to my connection requests, accept one by one in a loop for 25 times',
|
||||
icon: '🤝',
|
||||
},
|
||||
{
|
||||
display: 'Unsubscribe from Gmail subscriptions',
|
||||
prompt:
|
||||
'Go to Gmail, navigate to manage subscriptions and unsubscribe from all',
|
||||
icon: '📧',
|
||||
},
|
||||
]
|
||||
|
||||
interface GraphEmptyStateProps {
|
||||
mounted: boolean
|
||||
onSuggestionClick: (prompt: string) => void
|
||||
}
|
||||
|
||||
export const GraphEmptyState: FC<GraphEmptyStateProps> = ({
|
||||
mounted,
|
||||
onSuggestionClick,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'm-0! flex h-full flex-col items-center justify-center space-y-4 text-center opacity-0 transition-all duration-700',
|
||||
mounted ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0',
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex h-14 w-14 items-center justify-center rounded-2xl bg-muted/50">
|
||||
<Workflow className="h-7 w-7 text-[var(--accent-orange)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="mb-1 font-semibold text-lg">
|
||||
Create reliable workflows
|
||||
</h2>
|
||||
<p className="max-w-[240px] text-muted-foreground text-xs">
|
||||
Chat with the agent to create and refine browser automation
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid w-full max-w-[300px] grid-cols-1 gap-2">
|
||||
{WORKFLOW_SUGGESTIONS.map((suggestion) => (
|
||||
<button
|
||||
type="button"
|
||||
key={suggestion.display}
|
||||
onClick={() => onSuggestionClick(suggestion.prompt)}
|
||||
className="group flex items-center justify-between rounded-lg border border-border/50 bg-card px-3 py-2.5 text-left text-xs transition-all duration-200 hover:border-[var(--accent-orange)]/50 hover:bg-[var(--accent-orange)]/5"
|
||||
>
|
||||
{suggestion.display}
|
||||
<span className="opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
{suggestion.icon}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { Github, Plus, SettingsIcon } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector'
|
||||
import type { Provider } from '@/components/chat/chatComponentTypes'
|
||||
import { ThemeToggle } from '@/components/elements/theme-toggle'
|
||||
import { productRepositoryUrl } from '@/lib/constants/productUrls'
|
||||
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
|
||||
import type { ProviderType } from '@/lib/llm-providers/types'
|
||||
|
||||
interface WorkflowsChatHeaderProps {
|
||||
selectedProvider: Provider
|
||||
providers: Provider[]
|
||||
onSelectProvider: (provider: Provider) => void
|
||||
onNewWorkflow: () => void
|
||||
hasMessages: boolean
|
||||
}
|
||||
|
||||
export const WorkflowsChatHeader: FC<WorkflowsChatHeaderProps> = ({
|
||||
selectedProvider,
|
||||
providers,
|
||||
onSelectProvider,
|
||||
onNewWorkflow,
|
||||
hasMessages,
|
||||
}) => {
|
||||
return (
|
||||
<header className="flex h-14 shrink-0 items-center justify-between border-border/40 border-b bg-background/80 px-3 backdrop-blur-md">
|
||||
<div className="flex items-center gap-2">
|
||||
<ChatProviderSelector
|
||||
providers={providers}
|
||||
selectedProvider={selectedProvider}
|
||||
onSelectProvider={onSelectProvider}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="group relative inline-flex cursor-pointer items-center gap-2 rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground data-[state=open]:bg-accent"
|
||||
title="Change AI Provider"
|
||||
>
|
||||
{selectedProvider.type === 'browseros' ? (
|
||||
<BrowserOSIcon size={18} />
|
||||
) : (
|
||||
<ProviderIcon
|
||||
type={selectedProvider.type as ProviderType}
|
||||
size={18}
|
||||
/>
|
||||
)}
|
||||
<span className="font-semibold text-base">
|
||||
{selectedProvider.name}
|
||||
</span>
|
||||
</button>
|
||||
</ChatProviderSelector>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{hasMessages && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewWorkflow}
|
||||
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
title="New workflow"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={productRepositoryUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
title="Star on Github"
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/app.html#/settings"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
title="Settings"
|
||||
>
|
||||
<SettingsIcon className="h-4 w-4" />
|
||||
</a>
|
||||
|
||||
<ThemeToggle
|
||||
className="rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
iconClassName="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import type { UIMessage } from 'ai'
|
||||
|
||||
type MessagePart = UIMessage['parts'][number]
|
||||
|
||||
const TIDBIT_SUFFIXES = ['...', '\u2026'] as const
|
||||
|
||||
const isTextPart = (
|
||||
part: MessagePart,
|
||||
): part is MessagePart & { type: 'text' } => part.type === 'text'
|
||||
|
||||
const isTidbitLine = (line: string): boolean => {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed.length === 0) return false
|
||||
return TIDBIT_SUFFIXES.some((suffix) => trimmed.endsWith(suffix))
|
||||
}
|
||||
|
||||
const getNonEmptyLines = (text: string): string[] =>
|
||||
text.split('\n').filter((line) => line.trim().length > 0)
|
||||
|
||||
const isAllTidbitText = (text: string): boolean => {
|
||||
const lines = getNonEmptyLines(text)
|
||||
return lines.length > 0 && lines.every((line) => isTidbitLine(line))
|
||||
}
|
||||
|
||||
export const isWorkflowTidbitMessage = (message: UIMessage): boolean => {
|
||||
if (message.role !== 'assistant') return false
|
||||
if (message.parts.length === 0) return false
|
||||
if (message.parts.some((part) => !isTextPart(part))) return false
|
||||
|
||||
const fullText = message.parts
|
||||
.filter((part) => isTextPart(part))
|
||||
.map((part) => part.text)
|
||||
.join('')
|
||||
|
||||
return isAllTidbitText(fullText)
|
||||
}
|
||||
|
||||
// within a text part that has multiple tidbit lines, keep only the last line
|
||||
const compactTidbitLinesInPart = (part: MessagePart): MessagePart => {
|
||||
if (!isTextPart(part)) return part
|
||||
|
||||
const lines = getNonEmptyLines(part.text)
|
||||
if (lines.length <= 1) return part
|
||||
if (!lines.every((line) => isTidbitLine(line))) return part
|
||||
|
||||
return { ...part, text: lines[lines.length - 1] }
|
||||
}
|
||||
|
||||
// collapse consecutive tidbit text parts within a single message
|
||||
const compactTidbitPartsInMessage = (message: UIMessage): UIMessage => {
|
||||
if (message.role !== 'assistant') return message
|
||||
|
||||
// first compact multi-line tidbit text within each part
|
||||
const lineCompactedParts = message.parts.map(compactTidbitLinesInPart)
|
||||
|
||||
// then collapse consecutive tidbit parts to just the last one
|
||||
const compactedParts: UIMessage['parts'] = []
|
||||
let pendingTidbitPart: (MessagePart & { type: 'text' }) | null = null
|
||||
|
||||
const flushPendingTidbitPart = () => {
|
||||
if (!pendingTidbitPart) return
|
||||
compactedParts.push(pendingTidbitPart)
|
||||
pendingTidbitPart = null
|
||||
}
|
||||
|
||||
for (const part of lineCompactedParts) {
|
||||
if (isTextPart(part) && isAllTidbitText(part.text)) {
|
||||
pendingTidbitPart = part
|
||||
continue
|
||||
}
|
||||
|
||||
flushPendingTidbitPart()
|
||||
compactedParts.push(part)
|
||||
}
|
||||
|
||||
flushPendingTidbitPart()
|
||||
|
||||
const partsChanged =
|
||||
compactedParts.length !== message.parts.length ||
|
||||
compactedParts.some((p, i) => p !== message.parts[i])
|
||||
|
||||
if (!partsChanged) return message
|
||||
|
||||
return { ...message, parts: compactedParts }
|
||||
}
|
||||
|
||||
export const getWorkflowDisplayMessages = (
|
||||
messages: UIMessage[],
|
||||
): UIMessage[] => {
|
||||
// first compact tidbit parts within each message
|
||||
const normalizedMessages = messages.map(compactTidbitPartsInMessage)
|
||||
const compactedMessages: UIMessage[] = []
|
||||
|
||||
// then collapse consecutive tidbit-only messages
|
||||
for (const message of normalizedMessages) {
|
||||
const previousMessage = compactedMessages[compactedMessages.length - 1]
|
||||
const shouldReplacePreviousTidbit =
|
||||
previousMessage &&
|
||||
isWorkflowTidbitMessage(previousMessage) &&
|
||||
isWorkflowTidbitMessage(message)
|
||||
|
||||
if (shouldReplacePreviousTidbit) {
|
||||
compactedMessages[compactedMessages.length - 1] = message
|
||||
continue
|
||||
}
|
||||
|
||||
compactedMessages.push(message)
|
||||
}
|
||||
|
||||
return compactedMessages
|
||||
}
|
||||
@@ -2,8 +2,6 @@ import { Globe2, Trash2 } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useKimiLaunch } from '@/lib/feature-flags/useKimiLaunch'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getFaviconUrl, type LlmHubProvider } from './models'
|
||||
|
||||
interface HubProviderRowProps {
|
||||
@@ -20,20 +18,9 @@ export const HubProviderRow: FC<HubProviderRowProps> = ({
|
||||
onDelete,
|
||||
}) => {
|
||||
const iconUrl = useMemo(() => getFaviconUrl(provider.url), [provider.url])
|
||||
const kimiLaunch = useKimiLaunch()
|
||||
const normalizedName = provider.name.trim().toLowerCase()
|
||||
const normalizedUrl = provider.url.trim().toLowerCase()
|
||||
const isKimi = normalizedName === 'kimi' || normalizedUrl.includes('kimi.com')
|
||||
const showKimiFlare = isKimi && kimiLaunch
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex w-full items-center gap-4 rounded-xl border border-border bg-card p-4 transition-all hover:border-[var(--accent-orange)] hover:shadow-md',
|
||||
showKimiFlare &&
|
||||
'border-orange-300/80 bg-orange-50/20 shadow-sm ring-1 ring-orange-300/45 dark:bg-orange-500/5',
|
||||
)}
|
||||
>
|
||||
<div className="group flex w-full items-center gap-4 rounded-xl border border-border bg-card p-4 transition-all hover:border-[var(--accent-orange)] hover:shadow-md">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted">
|
||||
{iconUrl ? (
|
||||
<img
|
||||
@@ -49,16 +36,6 @@ export const HubProviderRow: FC<HubProviderRowProps> = ({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-0.5 flex items-center gap-2">
|
||||
<span className="block truncate font-semibold">{provider.name}</span>
|
||||
{showKimiFlare && (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<span className="rounded-full border border-orange-300/60 bg-orange-100/70 px-2 py-0.5 font-semibold text-[11px] text-orange-700 dark:border-orange-400/40 dark:bg-orange-500/15 dark:text-orange-300">
|
||||
Recommended
|
||||
</span>
|
||||
<span className="rounded-full border border-orange-300/60 bg-orange-100/60 px-2.5 py-0.5 font-medium text-orange-700 text-xs dark:border-orange-400/40 dark:bg-orange-500/15 dark:text-orange-300">
|
||||
Powered by Moonshot AI
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate text-muted-foreground/70 text-xs">
|
||||
{provider.url}
|
||||
|
||||
@@ -28,7 +28,7 @@ export const ScheduledTasksList: FC<ScheduledTasksListProps> = ({
|
||||
<div className="rounded-xl border border-border bg-card p-6 shadow-sm">
|
||||
<div className="rounded-lg border border-border border-dashed py-8 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No scheduled tasks yet. Create one to automate recurring workflows.
|
||||
No scheduled tasks yet. Create one to automate recurring tasks.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -238,7 +238,7 @@ const EmptyState: FC<{ onCreateClick: () => void }> = ({ onCreateClick }) => (
|
||||
<h3 className="mb-1 font-medium text-lg">No skills yet</h3>
|
||||
<p className="mb-5 max-w-sm text-muted-foreground text-sm leading-6">
|
||||
Skills teach your agent how to handle repeatable tasks like research,
|
||||
extraction, and structured workflows.
|
||||
extraction, and repeatable browser tasks.
|
||||
</p>
|
||||
<Button onClick={onCreateClick} size="sm">
|
||||
<Plus className="mr-1.5 size-4" />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import type { UIMessage } from 'ai'
|
||||
import { Loader2, RotateCcw, Square, X } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
interface RunWorkflowDialogProps {
|
||||
open: boolean
|
||||
workflowName: string
|
||||
messages: UIMessage[]
|
||||
status: 'streaming' | 'submitted' | 'ready' | 'error'
|
||||
wasCancelled: boolean
|
||||
error: Error | undefined
|
||||
onStop: () => void
|
||||
onRetry: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const RunWorkflowDialog: FC<RunWorkflowDialogProps> = ({
|
||||
open,
|
||||
workflowName,
|
||||
messages,
|
||||
status,
|
||||
wasCancelled,
|
||||
error,
|
||||
onStop,
|
||||
onRetry,
|
||||
onClose,
|
||||
}) => {
|
||||
const isProcessing = status === 'streaming' || status === 'submitted'
|
||||
const _isComplete = !isProcessing
|
||||
|
||||
const getStatusText = () => {
|
||||
if (status === 'submitted') return 'Starting workflow...'
|
||||
if (status === 'streaming') return 'Running...'
|
||||
if (wasCancelled) return 'Execution cancelled'
|
||||
if (status === 'error') return 'Error occurred'
|
||||
return 'Completed'
|
||||
}
|
||||
|
||||
const getMessageContent = (message: UIMessage) => {
|
||||
return message.parts
|
||||
.filter((part) => part.type === 'text')
|
||||
.map((part) => part.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
const assistantMessages = messages.filter((m) => m.role === 'assistant')
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={() => {}}>
|
||||
<DialogContent
|
||||
className="max-h-[80vh] max-w-2xl overflow-hidden [&>button]:hidden"
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader className="flex-row items-center justify-between space-y-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{isProcessing && (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-[var(--accent-orange)]" />
|
||||
)}
|
||||
Running: {workflowName}
|
||||
</DialogTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{isProcessing ? (
|
||||
<Button variant="destructive" size="sm" onClick={onStop}>
|
||||
<Square className="mr-1.5 h-3 w-3" />
|
||||
Stop
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="secondary" size="sm" onClick={onRetry}>
|
||||
<RotateCcw className="mr-1.5 h-3 w-3" />
|
||||
Retry
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
<X className="mr-1.5 h-3 w-3" />
|
||||
Close
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-muted-foreground text-sm">{getStatusText()}</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
|
||||
<div className="font-medium">Error Details</div>
|
||||
<div className="mt-1 whitespace-pre-wrap font-mono text-xs">
|
||||
{error.message}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-[50vh] overflow-y-auto rounded-lg border border-border bg-muted/30 p-4">
|
||||
{assistantMessages.length === 0 ? (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{isProcessing
|
||||
? 'Waiting for response...'
|
||||
: 'No output available.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{assistantMessages.map((message) => (
|
||||
<div key={message.id} className="whitespace-pre-wrap text-sm">
|
||||
{getMessageContent(message)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { Pencil, Play, Trash2 } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { NavLink } from 'react-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { Workflow } from '@/lib/workflows/workflowStorage'
|
||||
|
||||
interface WorkflowCardProps {
|
||||
workflow: Workflow
|
||||
onDelete: () => void
|
||||
onRun: () => void
|
||||
}
|
||||
|
||||
export const WorkflowCard: FC<WorkflowCardProps> = ({
|
||||
workflow,
|
||||
onDelete,
|
||||
onRun,
|
||||
}) => {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-4 shadow-sm transition-all hover:border-[var(--accent-orange)]/50 hover:shadow-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="truncate font-semibold">
|
||||
{workflow.workflowName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onRun}>
|
||||
<Play className="mr-1.5 h-3 w-3" />
|
||||
Run
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<NavLink to={`/workflows/create-graph?workflowId=${workflow.id}`}>
|
||||
<Pencil className="mr-1.5 h-3 w-3" />
|
||||
Edit
|
||||
</NavLink>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onDelete}
|
||||
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
aria-label={`Delete ${workflow.workflowName}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { HelpCircle, Plus, Workflow } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { NavLink } from 'react-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { workflowsHelpUrl } from '@/lib/constants/productUrls'
|
||||
|
||||
export const WorkflowsHeader: FC = () => {
|
||||
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">
|
||||
<Workflow className="h-6 w-6 text-[var(--accent-orange)]" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<h2 className="font-semibold text-xl">Workflows</h2>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={workflowsHelpUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-full p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Learn more about workflows</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Create and manage browser automation workflows
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
className="border-[var(--accent-orange)] bg-[var(--accent-orange)]/10 text-[var(--accent-orange)] hover:bg-[var(--accent-orange)]/20 hover:text-[var(--accent-orange)]"
|
||||
variant="outline"
|
||||
>
|
||||
<NavLink to="/workflows/create-graph">
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
New Workflow
|
||||
</NavLink>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { FC } from 'react'
|
||||
import type { Workflow } from '@/lib/workflows/workflowStorage'
|
||||
import { WorkflowCard } from './WorkflowCard'
|
||||
|
||||
interface WorkflowsListProps {
|
||||
workflows: Workflow[]
|
||||
onDelete: (workflowId: string) => void
|
||||
onRun: (workflowId: string) => void
|
||||
}
|
||||
|
||||
export const WorkflowsList: FC<WorkflowsListProps> = ({
|
||||
workflows,
|
||||
onDelete,
|
||||
onRun,
|
||||
}) => {
|
||||
if (workflows.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-6 shadow-sm">
|
||||
<div className="rounded-lg border border-border border-dashed py-8 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No workflows yet. Create one to automate browser tasks.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{workflows.map((workflow) => (
|
||||
<WorkflowCard
|
||||
key={workflow.id}
|
||||
workflow={workflow}
|
||||
onDelete={() => onDelete(workflow.id)}
|
||||
onRun={() => onRun(workflow.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import { type FC, useState } from 'react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
WORKFLOW_DELETED_EVENT,
|
||||
WORKFLOW_RUN_STARTED_EVENT,
|
||||
} from '@/lib/constants/analyticsEvents'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import { useRpcClient } from '@/lib/rpc/RpcClientProvider'
|
||||
import { sentry } from '@/lib/sentry/sentry'
|
||||
import { useWorkflows } from '@/lib/workflows/workflowStorage'
|
||||
import { RunWorkflowDialog } from './RunWorkflowDialog'
|
||||
import { useRunWorkflow } from './useRunWorkflow'
|
||||
import { WorkflowsHeader } from './WorkflowsHeader'
|
||||
import { WorkflowsList } from './WorkflowsList'
|
||||
|
||||
export const WorkflowsPage: FC = () => {
|
||||
const { workflows, removeWorkflow } = useWorkflows()
|
||||
const rpcClient = useRpcClient()
|
||||
|
||||
const [deleteWorkflowId, setDeleteWorkflowId] = useState<string | null>(null)
|
||||
|
||||
const {
|
||||
isRunning,
|
||||
runningWorkflowName,
|
||||
messages,
|
||||
status,
|
||||
wasCancelled,
|
||||
error,
|
||||
runWorkflow,
|
||||
stopRun,
|
||||
retry,
|
||||
closeDialog,
|
||||
} = useRunWorkflow()
|
||||
|
||||
const handleDelete = (workflowId: string) => {
|
||||
setDeleteWorkflowId(workflowId)
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteWorkflowId) return
|
||||
|
||||
const workflow = workflows.find((w) => w.id === deleteWorkflowId)
|
||||
if (!workflow) return
|
||||
|
||||
try {
|
||||
await rpcClient.graph[':id'].$delete({ param: { id: workflow.codeId } })
|
||||
} catch (error) {
|
||||
sentry.captureException(error, {
|
||||
extra: {
|
||||
message: 'Failed to delete graph from server',
|
||||
codeId: workflow.codeId,
|
||||
workflowId: deleteWorkflowId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await removeWorkflow(deleteWorkflowId)
|
||||
setDeleteWorkflowId(null)
|
||||
track(WORKFLOW_DELETED_EVENT)
|
||||
}
|
||||
|
||||
const handleRun = (workflowId: string) => {
|
||||
const workflow = workflows.find((w) => w.id === workflowId)
|
||||
if (workflow) {
|
||||
track(WORKFLOW_RUN_STARTED_EVENT)
|
||||
runWorkflow(workflow.codeId, workflow.workflowName)
|
||||
}
|
||||
}
|
||||
|
||||
const workflowToDelete = deleteWorkflowId
|
||||
? workflows.find((w) => w.id === deleteWorkflowId)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="fade-in slide-in-from-bottom-5 animate-in space-y-6 duration-500">
|
||||
<WorkflowsHeader />
|
||||
|
||||
<WorkflowsList
|
||||
workflows={workflows}
|
||||
onDelete={handleDelete}
|
||||
onRun={handleRun}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={deleteWorkflowId !== null}
|
||||
onOpenChange={(open) => !open && setDeleteWorkflowId(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Workflow</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Delete "{workflowToDelete?.workflowName}"? This action cannot be
|
||||
undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmDelete}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<RunWorkflowDialog
|
||||
open={isRunning}
|
||||
workflowName={runningWorkflowName}
|
||||
messages={messages}
|
||||
status={status}
|
||||
wasCancelled={wasCancelled}
|
||||
error={error}
|
||||
onStop={stopRun}
|
||||
onRetry={retry}
|
||||
onClose={closeDialog}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { type FC, Suspense } from 'react'
|
||||
import { WorkflowsPage } from './WorkflowsPage'
|
||||
|
||||
export const WorkflowsPageWrapper: FC = () => {
|
||||
return (
|
||||
<Suspense fallback={<div className="h-screen w-screen bg-background" />}>
|
||||
<WorkflowsPage />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
import { useChat } from '@ai-sdk/react'
|
||||
import { DefaultChatTransport } from 'ai'
|
||||
import { compact } from 'es-toolkit/array'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useChatRefs } from '@/entrypoints/sidepanel/index/useChatRefs'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
import {
|
||||
WORKFLOW_RUN_COMPLETED_EVENT,
|
||||
WORKFLOW_RUN_RETRIED_EVENT,
|
||||
WORKFLOW_RUN_STOPPED_EVENT,
|
||||
} from '@/lib/constants/analyticsEvents'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
|
||||
type WorkflowMessageMetadata = {
|
||||
window?: chrome.windows.Window
|
||||
}
|
||||
|
||||
export const useRunWorkflow = () => {
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [runningWorkflowName, setRunningWorkflowName] = useState<string>('')
|
||||
const [wasCancelled, setWasCancelled] = useState(false)
|
||||
const codeIdRef = useRef<string | undefined>(undefined)
|
||||
|
||||
const { baseUrl: agentServerUrl } = useAgentServerUrl()
|
||||
|
||||
const {
|
||||
selectedLlmProviderRef,
|
||||
enabledMcpServersRef,
|
||||
enabledCustomServersRef,
|
||||
personalizationRef,
|
||||
} = useChatRefs()
|
||||
|
||||
const agentUrlRef = useRef(agentServerUrl)
|
||||
|
||||
useEffect(() => {
|
||||
agentUrlRef.current = agentServerUrl
|
||||
}, [agentServerUrl])
|
||||
|
||||
const { sendMessage, stop, status, messages, setMessages, error } = useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
prepareSendMessagesRequest: async ({ messages }) => {
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
const metadata = lastMessage.metadata as
|
||||
| WorkflowMessageMetadata
|
||||
| undefined
|
||||
const provider = selectedLlmProviderRef.current
|
||||
const enabledMcpServers = enabledMcpServersRef.current
|
||||
const customMcpServers = enabledCustomServersRef.current
|
||||
|
||||
return {
|
||||
api: `${agentUrlRef.current}/graph/${codeIdRef.current}/run`,
|
||||
body: {
|
||||
provider: provider?.type,
|
||||
providerType: provider?.type,
|
||||
providerName: provider?.name,
|
||||
model: provider?.modelId ?? 'browseros',
|
||||
contextWindowSize: provider?.contextWindow,
|
||||
temperature: provider?.temperature,
|
||||
resourceName: provider?.resourceName,
|
||||
accessKeyId: provider?.accessKeyId,
|
||||
secretAccessKey: provider?.secretAccessKey,
|
||||
region: provider?.region,
|
||||
sessionToken: provider?.sessionToken,
|
||||
apiKey: provider?.apiKey,
|
||||
baseUrl: provider?.baseUrl,
|
||||
browserContext: {
|
||||
windowId: metadata?.window?.id,
|
||||
activeTab: metadata?.window?.tabs?.[0],
|
||||
enabledMcpServers: compact(enabledMcpServers),
|
||||
customMcpServers,
|
||||
},
|
||||
userSystemPrompt: personalizationRef.current,
|
||||
supportsImages: provider?.supportsImages,
|
||||
},
|
||||
}
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
const previousStatus = useRef(status)
|
||||
useEffect(() => {
|
||||
const wasProcessing =
|
||||
previousStatus.current === 'streaming' ||
|
||||
previousStatus.current === 'submitted'
|
||||
const justFinished =
|
||||
wasProcessing && (status === 'ready' || status === 'error')
|
||||
|
||||
if (justFinished && isRunning) {
|
||||
track(WORKFLOW_RUN_COMPLETED_EVENT, {
|
||||
status: wasCancelled
|
||||
? 'cancelled'
|
||||
: status === 'error'
|
||||
? 'failed'
|
||||
: 'completed',
|
||||
})
|
||||
}
|
||||
previousStatus.current = status
|
||||
}, [status, isRunning, wasCancelled])
|
||||
|
||||
const startWorkflowRun = async () => {
|
||||
setMessages([])
|
||||
setWasCancelled(false)
|
||||
|
||||
let backgroundWindow: chrome.windows.Window | undefined
|
||||
try {
|
||||
backgroundWindow = await chrome.windows.create({
|
||||
url: 'chrome://newtab',
|
||||
focused: true,
|
||||
type: 'normal',
|
||||
})
|
||||
} catch {
|
||||
// Fallback when no window context is available (e.g. all windows closed)
|
||||
const tab = await chrome.tabs.create({
|
||||
url: 'chrome://newtab',
|
||||
active: true,
|
||||
})
|
||||
if (tab.windowId) {
|
||||
backgroundWindow = await chrome.windows.get(tab.windowId)
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage({
|
||||
text: 'Run the workflow.',
|
||||
metadata: {
|
||||
window: backgroundWindow,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const runWorkflow = async (codeId: string, workflowName: string) => {
|
||||
codeIdRef.current = codeId
|
||||
setRunningWorkflowName(workflowName)
|
||||
setIsRunning(true)
|
||||
await startWorkflowRun()
|
||||
}
|
||||
|
||||
const stopRun = () => {
|
||||
track(WORKFLOW_RUN_STOPPED_EVENT)
|
||||
setWasCancelled(true)
|
||||
stop()
|
||||
}
|
||||
|
||||
const retry = async () => {
|
||||
track(WORKFLOW_RUN_RETRIED_EVENT)
|
||||
await startWorkflowRun()
|
||||
}
|
||||
|
||||
const closeDialog = () => {
|
||||
setIsRunning(false)
|
||||
setRunningWorkflowName('')
|
||||
setWasCancelled(false)
|
||||
setMessages([])
|
||||
}
|
||||
|
||||
return {
|
||||
isRunning,
|
||||
runningWorkflowName,
|
||||
messages,
|
||||
status,
|
||||
wasCancelled,
|
||||
error,
|
||||
runWorkflow,
|
||||
stopRun,
|
||||
retry,
|
||||
closeDialog,
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export const TIPS: Tip[] = [
|
||||
},
|
||||
{
|
||||
id: 'mcp-servers',
|
||||
text: 'Add MCP servers for Google Calendar, Gmail, Notion, and more to build multi-service workflows.',
|
||||
text: 'Add MCP servers for Google Calendar, Gmail, Notion, and more to power multi-service automations.',
|
||||
},
|
||||
{
|
||||
id: 'skills',
|
||||
@@ -75,10 +75,6 @@ export const TIPS: Tip[] = [
|
||||
id: 'at-mention-tabs',
|
||||
text: 'Type @ in the search bar to mention and attach open tabs as context for your AI queries.',
|
||||
},
|
||||
{
|
||||
id: 'workflows',
|
||||
text: 'For complex repeatable tasks, build visual Workflows instead of one-off prompts for consistent results.',
|
||||
},
|
||||
{
|
||||
id: 'mode-selection',
|
||||
text: 'Use Chat mode for read-only operations like questions and summaries, and Agent mode for multi-step browser tasks.',
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Bot,
|
||||
Code2,
|
||||
FolderOpen,
|
||||
GitBranch,
|
||||
LinkIcon,
|
||||
Plug,
|
||||
SplitSquareHorizontal,
|
||||
@@ -23,7 +22,6 @@ import {
|
||||
COWORK_DEMO_URL,
|
||||
MCP_SERVER_DEMO_URL,
|
||||
SPLIT_VIEW_GIF_URL,
|
||||
WORKFLOWS_DEMO_URL,
|
||||
} from '@/lib/constants/mediaUrls'
|
||||
import {
|
||||
discordUrl,
|
||||
@@ -44,7 +42,7 @@ const features: Feature[] = [
|
||||
description:
|
||||
'Describe any task and watch BrowserOS execute it—clicking, typing, and navigating for you.',
|
||||
detailedDescription:
|
||||
'The BrowserOS Agent turns your words into browser actions. Describe what you need in plain English—fill out this form, extract data from that page, navigate through these steps—and the agent handles the rest. It clicks buttons, types text, navigates between pages, and completes multi-step workflows automatically. Everything runs locally on your machine with your own API keys, so your data stays private.',
|
||||
'The BrowserOS Agent turns your words into browser actions. Describe what you need in plain English—fill out this form, extract data from that page, navigate through these steps—and the agent handles the rest. It clicks buttons, types text, navigates between pages, and completes multi-step browser tasks automatically. Everything runs locally on your machine with your own API keys, so your data stays private.',
|
||||
highlights: [
|
||||
'Multi-tab execution — run agents in multiple tabs simultaneously',
|
||||
'Smart navigation — automatically finds and interacts with page elements',
|
||||
@@ -75,24 +73,6 @@ const features: Feature[] = [
|
||||
gridClass: 'md:col-span-1',
|
||||
videoUrl: MCP_SERVER_DEMO_URL,
|
||||
},
|
||||
{
|
||||
id: 'workflows',
|
||||
Icon: GitBranch,
|
||||
tag: 'AUTOMATION',
|
||||
title: 'Visual Workflows',
|
||||
description:
|
||||
'Build reliable, repeatable automations with a visual graph builder.',
|
||||
detailedDescription:
|
||||
'Workflows turn complex browser tasks into reliable, reusable automations. Instead of hoping the agent figures out the right steps each time, you define the exact sequence in a visual graph. Describe what you want in chat, and the workflow agent generates the graph. Add loops, conditionals, and parallel branches. Save workflows and run them on-demand whenever you need.',
|
||||
highlights: [
|
||||
'Chat-to-graph — describe your automation and get a visual workflow',
|
||||
'Parallel execution — run multiple branches simultaneously',
|
||||
'Loops & conditionals — handle complex logic with flow control',
|
||||
'Save & reuse — run saved workflows on-demand, daily, or weekly',
|
||||
],
|
||||
gridClass: 'md:col-span-1',
|
||||
videoUrl: WORKFLOWS_DEMO_URL || undefined,
|
||||
},
|
||||
{
|
||||
id: 'cowork',
|
||||
Icon: FolderOpen,
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { AlertCircle, RefreshCw } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
// import { useMemo } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
// --- Commented out for Kimi partnership launch (restore after) ---
|
||||
// const SURVEY_DIRECTIONS = [
|
||||
// 'competitor',
|
||||
// 'switching',
|
||||
// 'workflow',
|
||||
// 'activation',
|
||||
// ] as const
|
||||
//
|
||||
// function pickRandomDirection(): string {
|
||||
// return SURVEY_DIRECTIONS[Math.floor(Math.random() * SURVEY_DIRECTIONS.length)]
|
||||
// }
|
||||
// --- End commented out survey code ---
|
||||
const SURVEY_DIRECTIONS = [
|
||||
'competitor',
|
||||
'switching',
|
||||
'workflow',
|
||||
'activation',
|
||||
] as const
|
||||
|
||||
function pickRandomDirection(): string {
|
||||
return SURVEY_DIRECTIONS[Math.floor(Math.random() * SURVEY_DIRECTIONS.length)]
|
||||
}
|
||||
|
||||
interface ChatErrorProps {
|
||||
error: Error
|
||||
@@ -95,13 +93,11 @@ export const ChatError: FC<ChatErrorProps> = ({
|
||||
const { text, url, isRateLimit, isCreditsExhausted, isConnectionError } =
|
||||
parseErrorMessage(error.message, providerType)
|
||||
|
||||
// --- Commented out for Kimi partnership launch (restore after) ---
|
||||
// const surveyUrl = useMemo(
|
||||
// () =>
|
||||
// `/app.html?page=survey&maxTurns=20&experimentId=daily_limit_${pickRandomDirection()}#/settings/survey`,
|
||||
// [],
|
||||
// )
|
||||
// --- End commented out survey code ---
|
||||
const surveyUrl = useMemo(
|
||||
() =>
|
||||
`/app.html?page=survey&maxTurns=20&experimentId=daily_limit_${pickRandomDirection()}#/settings/survey`,
|
||||
[],
|
||||
)
|
||||
|
||||
const getTitle = () => {
|
||||
if (isRateLimit) return 'Daily limit reached'
|
||||
@@ -126,8 +122,17 @@ export const ChatError: FC<ChatErrorProps> = ({
|
||||
View troubleshooting guide
|
||||
</a>
|
||||
)}
|
||||
{/* --- Commented out for Kimi partnership launch (restore after) ---
|
||||
{isRateLimit && (
|
||||
{isCreditsExhausted && url && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground text-xs underline hover:text-foreground"
|
||||
>
|
||||
View Usage & Billing
|
||||
</a>
|
||||
)}
|
||||
{isRateLimit && !isCreditsExhausted && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<a
|
||||
href={url}
|
||||
@@ -148,27 +153,6 @@ export const ChatError: FC<ChatErrorProps> = ({
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
--- End commented out survey code --- */}
|
||||
{isCreditsExhausted && url && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground text-xs underline hover:text-foreground"
|
||||
>
|
||||
View Usage & Billing
|
||||
</a>
|
||||
)}
|
||||
{isRateLimit && providerType === 'browseros' && (
|
||||
<a
|
||||
href="/app.html#/settings/ai"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--accent-orange)] bg-[var(--accent-orange)]/10 px-3 py-1.5 font-medium text-[var(--accent-orange)] text-xs transition-colors hover:bg-[var(--accent-orange)]/20"
|
||||
>
|
||||
Add your own provider for unlimited usage
|
||||
</a>
|
||||
)}
|
||||
{onRetry && (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -31,8 +31,6 @@ export enum Feature {
|
||||
WORKSPACE_FOLDER_SUPPORT = 'WORKSPACE_FOLDER_SUPPORT',
|
||||
// Proxy server support
|
||||
PROXY_SUPPORT = 'PROXY_SUPPORT',
|
||||
// Workflows feature
|
||||
WORKFLOW_SUPPORT = 'WORKFLOW_SUPPORT',
|
||||
// previousConversation as structured array (older servers only accept string)
|
||||
PREVIOUS_CONVERSATION_ARRAY = 'PREVIOUS_CONVERSATION_ARRAY',
|
||||
// Soul page: agent personality viewer and editor
|
||||
@@ -73,7 +71,6 @@ const FEATURE_CONFIG: { [K in Feature]: FeatureConfig } = {
|
||||
[Feature.CUSTOMIZATION_SUPPORT]: { minBrowserOSVersion: '0.36.1.0' },
|
||||
[Feature.WORKSPACE_FOLDER_SUPPORT]: { minBrowserOSVersion: '0.36.4.0' },
|
||||
[Feature.PROXY_SUPPORT]: { minBrowserOSVersion: '0.39.0.1' },
|
||||
[Feature.WORKFLOW_SUPPORT]: { minServerVersion: '0.0.41' },
|
||||
[Feature.PREVIOUS_CONVERSATION_ARRAY]: { minServerVersion: '0.0.64' },
|
||||
[Feature.SOUL_SUPPORT]: { minServerVersion: '0.0.67' },
|
||||
[Feature.NEWTAB_CHAT_SUPPORT]: { minBrowserOSVersion: '0.40.0.0' },
|
||||
|
||||
@@ -1,19 +1,6 @@
|
||||
/** @public */
|
||||
export const MESSAGE_LIKE_EVENT = 'ui.message.like'
|
||||
|
||||
export const GRAPH_MESSAGE_LIKE_EVENT = 'settings.graph.message.like'
|
||||
|
||||
export const GRAPH_MESSAGE_DISLIKE_EVENT = 'settings.graph.message.dislike'
|
||||
|
||||
/** @public */
|
||||
export const NEW_GRAPH_CREATED_EVENT = 'settings.graph.created'
|
||||
|
||||
/** @public */
|
||||
export const GRAPH_SAVED_EVENT = 'settings.graph.saved'
|
||||
|
||||
/** @public */
|
||||
export const GRAPH_UPDATED_EVENT = 'settings.graph.updated'
|
||||
|
||||
/** @public */
|
||||
export const MESSAGE_DISLIKE_EVENT = 'ui.message.dislike'
|
||||
|
||||
@@ -178,21 +165,6 @@ export const NEWTAB_VOICE_TRANSCRIPTION_COMPLETED_EVENT =
|
||||
/** @public */
|
||||
export const NEWTAB_VOICE_ERROR_EVENT = 'newtab.voice.error'
|
||||
|
||||
/** @public */
|
||||
export const WORKFLOW_DELETED_EVENT = 'settings.workflow.deleted'
|
||||
|
||||
/** @public */
|
||||
export const WORKFLOW_RUN_STARTED_EVENT = 'settings.workflow.run_started'
|
||||
|
||||
/** @public */
|
||||
export const WORKFLOW_RUN_STOPPED_EVENT = 'settings.workflow.run_stopped'
|
||||
|
||||
/** @public */
|
||||
export const WORKFLOW_RUN_RETRIED_EVENT = 'settings.workflow.run_retried'
|
||||
|
||||
/** @public */
|
||||
export const WORKFLOW_RUN_COMPLETED_EVENT = 'settings.workflow.run_completed'
|
||||
|
||||
/** @public */
|
||||
export const SIDEPANEL_AI_TRIGGERED_EVENT = 'sidepanel.ai.triggered'
|
||||
|
||||
@@ -308,14 +280,6 @@ export const KIMI_API_KEY_CONFIGURED_EVENT = 'settings.kimi.api_key_configured'
|
||||
export const KIMI_API_KEY_GUIDE_CLICKED_EVENT =
|
||||
'settings.kimi.api_key_guide_clicked'
|
||||
|
||||
/** @public */
|
||||
export const KIMI_RATE_LIMIT_DOCS_CLICKED_EVENT =
|
||||
'ui.rate_limit.kimi_docs_clicked'
|
||||
|
||||
/** @public */
|
||||
export const KIMI_RATE_LIMIT_PLATFORM_CLICKED_EVENT =
|
||||
'ui.rate_limit.moonshot_platform_clicked'
|
||||
|
||||
/** @public */
|
||||
export const SIDEPANEL_VOICE_RECORDING_STARTED_EVENT =
|
||||
'sidepanel.voice.recording_started'
|
||||
|
||||
@@ -49,11 +49,6 @@ export const productVideoUrl = 'https://youtu.be/J-lFhTP-7is'
|
||||
*/
|
||||
export const productRepositoryShortUrl = 'https://git.new/browseros'
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const workflowsHelpUrl = 'https://docs.browseros.com/features/workflows'
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ const EnvSchema = z.object({
|
||||
VITE_PUBLIC_POSTHOG_HOST: z.string().optional(),
|
||||
VITE_PUBLIC_SENTRY_DSN: z.string().optional(),
|
||||
VITE_PUBLIC_BROWSEROS_API: z.string().optional(),
|
||||
VITE_PUBLIC_KIMI_LAUNCH: z.string().optional(),
|
||||
PROD: z.boolean(),
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
>
|
||||
@@ -1,14 +0,0 @@
|
||||
import { env } from '@/lib/env'
|
||||
|
||||
const ENABLED_VALUES = new Set(['1', 'true', 'yes', 'on'])
|
||||
|
||||
function parseKimiLaunchFlag(value: string | undefined): boolean {
|
||||
if (!value) return false
|
||||
return ENABLED_VALUES.has(value.trim().toLowerCase())
|
||||
}
|
||||
|
||||
const kimiLaunchEnabled = parseKimiLaunchFlag(env.VITE_PUBLIC_KIMI_LAUNCH)
|
||||
|
||||
export function isKimiLaunchEnabled(): boolean {
|
||||
return kimiLaunchEnabled
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { isKimiLaunchEnabled } from './kimi-launch'
|
||||
|
||||
export function useKimiLaunch(): boolean {
|
||||
return isKimiLaunchEnabled()
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { getBrowserOSAdapter } from '@/lib/browseros/adapter'
|
||||
import { BROWSEROS_PREFS } from '@/lib/browseros/prefs'
|
||||
import { isKimiLaunchEnabled } from '@/lib/feature-flags/kimi-launch'
|
||||
|
||||
/** @public */
|
||||
export interface LlmHubProvider {
|
||||
@@ -8,43 +7,15 @@ export interface LlmHubProvider {
|
||||
url: string
|
||||
}
|
||||
|
||||
const KIMI_PROVIDER: LlmHubProvider = {
|
||||
name: 'Kimi',
|
||||
url: 'https://www.kimi.com',
|
||||
}
|
||||
|
||||
function ensureKimiFirst(providers: LlmHubProvider[]): LlmHubProvider[] {
|
||||
if (!isKimiLaunchEnabled()) return providers
|
||||
const hasKimi = providers.some(
|
||||
(p) => p.name === 'Kimi' || p.url.includes('kimi.com'),
|
||||
)
|
||||
return hasKimi ? providers : [KIMI_PROVIDER, ...providers]
|
||||
}
|
||||
|
||||
export async function loadProviders(): Promise<LlmHubProvider[]> {
|
||||
try {
|
||||
const adapter = getBrowserOSAdapter()
|
||||
const providersPref = await adapter.getPref(
|
||||
BROWSEROS_PREFS.THIRD_PARTY_LLM_PROVIDERS,
|
||||
)
|
||||
const providers = (providersPref?.value as LlmHubProvider[]) || []
|
||||
|
||||
if (providers.length === 0) {
|
||||
if (isKimiLaunchEnabled()) {
|
||||
const defaults = [KIMI_PROVIDER]
|
||||
await saveProviders(defaults)
|
||||
return defaults
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const normalized = ensureKimiFirst(providers)
|
||||
if (normalized !== providers) {
|
||||
await saveProviders(normalized)
|
||||
}
|
||||
return normalized
|
||||
return (providersPref?.value as LlmHubProvider[]) || []
|
||||
} catch {
|
||||
return isKimiLaunchEnabled() ? [KIMI_PROVIDER] : []
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,12 @@ import { storage } from '@wxt-dev/storage'
|
||||
import { sessionStorage } from '@/lib/auth/sessionStorage'
|
||||
import { getBrowserOSAdapter } from '@/lib/browseros/adapter'
|
||||
import { BROWSEROS_PREFS } from '@/lib/browseros/prefs'
|
||||
import { isKimiLaunchEnabled } from '@/lib/feature-flags/kimi-launch'
|
||||
import type { LlmProviderConfig, LlmProvidersBackup } from './types'
|
||||
import { uploadLlmProvidersToGraphql } from './uploadLlmProvidersToGraphql'
|
||||
|
||||
/** Default provider ID constant */
|
||||
export const DEFAULT_PROVIDER_ID = 'browseros'
|
||||
const DEFAULT_PROVIDER_NAME = 'BrowserOS'
|
||||
const KIMI_LAUNCH_PROVIDER_NAME = 'Kimi K2.5'
|
||||
|
||||
/** Storage key for LLM providers array */
|
||||
export const providersStorage = storage.defineItem<LlmProviderConfig[]>(
|
||||
@@ -91,7 +89,7 @@ export function setupLlmProvidersSyncToBackend(): () => void {
|
||||
/** Load providers from storage */
|
||||
export async function loadProviders(): Promise<LlmProviderConfig[]> {
|
||||
const providers = (await providersStorage.getValue()) || []
|
||||
const normalizedProviders = normalizeProvidersForLaunch(providers)
|
||||
const normalizedProviders = normalizeProviderNames(providers)
|
||||
|
||||
// Keep storage consistent so every consumer sees the same provider name.
|
||||
if (
|
||||
@@ -109,7 +107,7 @@ export function createDefaultBrowserOSProvider(): LlmProviderConfig {
|
||||
return {
|
||||
id: DEFAULT_PROVIDER_ID,
|
||||
type: 'browseros',
|
||||
name: getBuiltInProviderName(),
|
||||
name: DEFAULT_PROVIDER_NAME,
|
||||
baseUrl: 'https://api.browseros.com/v1',
|
||||
modelId: 'browseros-auto',
|
||||
supportsImages: true,
|
||||
@@ -125,26 +123,22 @@ export function createDefaultProvidersConfig(): LlmProviderConfig[] {
|
||||
return [createDefaultBrowserOSProvider()]
|
||||
}
|
||||
|
||||
function getBuiltInProviderName(): string {
|
||||
return isKimiLaunchEnabled()
|
||||
? KIMI_LAUNCH_PROVIDER_NAME
|
||||
: DEFAULT_PROVIDER_NAME
|
||||
}
|
||||
|
||||
function normalizeProvidersForLaunch(
|
||||
/**
|
||||
* Normalize built-in provider names back to "BrowserOS" (e.g. from "Kimi K2.5"
|
||||
* which was set during a previous partnership launch).
|
||||
*/
|
||||
function normalizeProviderNames(
|
||||
providers: LlmProviderConfig[],
|
||||
): LlmProviderConfig[] {
|
||||
const builtInProviderName = getBuiltInProviderName()
|
||||
|
||||
return providers.map((provider) => {
|
||||
if (
|
||||
provider.id === DEFAULT_PROVIDER_ID &&
|
||||
provider.type === 'browseros' &&
|
||||
provider.name !== builtInProviderName
|
||||
provider.name !== DEFAULT_PROVIDER_NAME
|
||||
) {
|
||||
return {
|
||||
...provider,
|
||||
name: builtInProviderName,
|
||||
name: DEFAULT_PROVIDER_NAME,
|
||||
}
|
||||
}
|
||||
return provider
|
||||
|
||||
@@ -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'
|
||||
@@ -1,54 +0,0 @@
|
||||
import { storage } from '@wxt-dev/storage'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export interface Workflow {
|
||||
id: string
|
||||
codeId: string
|
||||
workflowName: string
|
||||
}
|
||||
|
||||
export const workflowStorage = storage.defineItem<Workflow[]>(
|
||||
'local:workflows',
|
||||
{
|
||||
fallback: [],
|
||||
},
|
||||
)
|
||||
|
||||
export function useWorkflows() {
|
||||
const [workflows, setWorkflows] = useState<Workflow[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
workflowStorage.getValue().then(setWorkflows)
|
||||
const unwatch = workflowStorage.watch((newValue) => {
|
||||
setWorkflows(newValue ?? [])
|
||||
})
|
||||
return unwatch
|
||||
}, [])
|
||||
|
||||
const addWorkflow = async (workflow: Omit<Workflow, 'id'>) => {
|
||||
const newWorkflow: Workflow = {
|
||||
id: crypto.randomUUID(),
|
||||
...workflow,
|
||||
}
|
||||
const current = (await workflowStorage.getValue()) ?? []
|
||||
await workflowStorage.setValue([...current, newWorkflow])
|
||||
return newWorkflow
|
||||
}
|
||||
|
||||
const removeWorkflow = async (id: string) => {
|
||||
const current = (await workflowStorage.getValue()) ?? []
|
||||
await workflowStorage.setValue(current.filter((w) => w.id !== id))
|
||||
}
|
||||
|
||||
const editWorkflow = async (
|
||||
id: string,
|
||||
updates: Partial<Omit<Workflow, 'id'>>,
|
||||
) => {
|
||||
const current = (await workflowStorage.getValue()) ?? []
|
||||
await workflowStorage.setValue(
|
||||
current.map((w) => (w.id === id ? { ...w, ...updates } : w)),
|
||||
)
|
||||
}
|
||||
|
||||
return { workflows, addWorkflow, removeWorkflow, editWorkflow }
|
||||
}
|
||||
@@ -2,16 +2,16 @@
|
||||
"name": "@browseros/agent",
|
||||
"description": "manifest.json description",
|
||||
"private": true,
|
||||
"version": "0.0.98",
|
||||
"version": "0.0.99",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "test -d generated/graphql || bun run codegen; mkdir -p /tmp/browseros-dev; bun --env-file=.env.development wxt",
|
||||
"build": "bun run codegen && wxt build",
|
||||
"build:dev": "bun --env-file=.env.development wxt build --mode development",
|
||||
"zip": "wxt zip",
|
||||
"compile": "tsgo --noEmit",
|
||||
"compile": "bun --env-file=.env.development wxt prepare && tsgo --noEmit",
|
||||
"lint": "bunx biome check",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"typecheck": "bun --env-file=.env.development wxt prepare && tsgo --noEmit",
|
||||
"lint:fix": "bunx biome check --write --unsafe",
|
||||
"clean:cache": "rm -rf node_modules/.cache && rm -rf .output/ && rm -rf .wxt/",
|
||||
"codegen": "bun --env-file=.env.development graphql-codegen --config codegen.ts",
|
||||
@@ -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 {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user