mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-14 08:03:58 +00:00
Compare commits
23 Commits
fix/cli-in
...
fix/iframe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
914e8689de | ||
|
|
c289a76b63 | ||
|
|
df7873562d | ||
|
|
412386b489 | ||
|
|
33617ba9e7 | ||
|
|
6712e1d321 | ||
|
|
94540d9e87 | ||
|
|
bb62213e84 | ||
|
|
dee3086a48 | ||
|
|
8de2bf984f | ||
|
|
1b8720740c | ||
|
|
91be726381 | ||
|
|
ff5386a24a | ||
|
|
a5f3c4da65 | ||
|
|
e5a852dd3d | ||
|
|
aee30ce8e1 | ||
|
|
0833c8d42d | ||
|
|
036c7f280b | ||
|
|
000429277d | ||
|
|
f8535fd96d | ||
|
|
f0cbf77924 | ||
|
|
17be06eb2f | ||
|
|
0e90785500 |
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -9,4 +9,6 @@ packages/browseros/chromium_patches/**/*.py linguist-generated
|
||||
scripts/*.py linguist-generated
|
||||
# Mark build directories as generated
|
||||
build/* linguist-generated
|
||||
# Mark eval/test framework as vendored so it's excluded from language stats
|
||||
packages/browseros-agent/apps/eval/** linguist-vendored
|
||||
docs/videos/** filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
147
.github/workflows/release-server.yml
vendored
Normal file
147
.github/workflows/release-server.yml
vendored
Normal file
@@ -0,0 +1,147 @@
|
||||
name: Release BrowserOS Server
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release version (e.g. 0.0.80)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: release-server
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
environment: release-core
|
||||
permissions:
|
||||
contents: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/browseros-agent
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.6"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun ci
|
||||
|
||||
- name: Prepare production env file
|
||||
run: cp apps/server/.env.production.example apps/server/.env.production
|
||||
|
||||
- name: Validate version
|
||||
id: version
|
||||
env:
|
||||
REQUESTED_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
PACKAGE_VERSION=$(node -p "require('./apps/server/package.json').version")
|
||||
echo "package_version=$PACKAGE_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "release_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [ "$PACKAGE_VERSION" != "$REQUESTED_VERSION" ]; then
|
||||
echo "Requested version $REQUESTED_VERSION does not match apps/server/package.json ($PACKAGE_VERSION)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build release artifacts
|
||||
run: bun run build:server:ci
|
||||
|
||||
- name: Verify release artifacts
|
||||
run: |
|
||||
mapfile -t ZIP_FILES < <(find dist/prod/server -maxdepth 1 -type f -name 'browseros-server-resources-*.zip' | sort)
|
||||
|
||||
if [ "${#ZIP_FILES[@]}" -eq 0 ]; then
|
||||
echo "No server release zip files were produced"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'Found release artifacts:\n%s\n' "${ZIP_FILES[@]}"
|
||||
|
||||
- name: Generate release notes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PACKAGE_VERSION: ${{ steps.version.outputs.package_version }}
|
||||
run: |
|
||||
SERVER_APP_PATH="packages/browseros-agent/apps/server"
|
||||
SERVER_BUILD_DIR="packages/browseros-agent/scripts/build/server"
|
||||
SERVER_BUILD_ENTRY="packages/browseros-agent/scripts/build/server.ts"
|
||||
SERVER_RESOURCE_MANIFEST="packages/browseros-agent/scripts/build/config/server-prod-resources.json"
|
||||
SERVER_WORKSPACE_PKG="packages/browseros-agent/package.json"
|
||||
CURRENT_TAG="browseros-server-v$PACKAGE_VERSION"
|
||||
PREV_TAG=$(git tag -l "browseros-server-v*" --sort=-v:refname | grep -v "^${CURRENT_TAG}$" | head -n 1)
|
||||
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
echo "Initial release of browseros-server." > /tmp/release-notes.md
|
||||
else
|
||||
COMMITS=$(git log "$PREV_TAG"..HEAD --pretty=format:"%H" -- \
|
||||
"$SERVER_APP_PATH" \
|
||||
"$SERVER_BUILD_DIR" \
|
||||
"$SERVER_BUILD_ENTRY" \
|
||||
"$SERVER_RESOURCE_MANIFEST" \
|
||||
"$SERVER_WORKSPACE_PKG")
|
||||
|
||||
if [ -z "$COMMITS" ]; then
|
||||
echo "No notable changes." > /tmp/release-notes.md
|
||||
else
|
||||
echo "## What's Changed" > /tmp/release-notes.md
|
||||
echo "" >> /tmp/release-notes.md
|
||||
|
||||
while IFS= read -r SHA; do
|
||||
SUBJECT=$(git log -1 --pretty=format:"%s" "$SHA")
|
||||
PR_NUM=$(gh api "/repos/${{ github.repository }}/commits/${SHA}/pulls" --jq '.[0].number // empty' 2>/dev/null)
|
||||
|
||||
if [ -n "$PR_NUM" ] && ! echo "$SUBJECT" | grep -qF "(#${PR_NUM})"; then
|
||||
echo "- ${SUBJECT} (#${PR_NUM})" >> /tmp/release-notes.md
|
||||
else
|
||||
echo "- ${SUBJECT}" >> /tmp/release-notes.md
|
||||
fi
|
||||
done <<< "$COMMITS"
|
||||
fi
|
||||
fi
|
||||
working-directory: ${{ github.workspace }}
|
||||
|
||||
- name: Create GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PACKAGE_VERSION: ${{ steps.version.outputs.package_version }}
|
||||
RELEASE_SHA: ${{ steps.version.outputs.release_sha }}
|
||||
run: |
|
||||
TAG="browseros-server-v$PACKAGE_VERSION"
|
||||
TITLE="BrowserOS Server - v$PACKAGE_VERSION"
|
||||
mapfile -t ZIP_FILES < <(find packages/browseros-agent/dist/prod/server -maxdepth 1 -type f -name 'browseros-server-resources-*.zip' | sort)
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
if git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
echo "Tag $TAG already exists, skipping tag creation"
|
||||
else
|
||||
git tag -a "$TAG" -m "browseros-server v$PACKAGE_VERSION" "$RELEASE_SHA"
|
||||
fi
|
||||
|
||||
if git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then
|
||||
echo "Tag $TAG already on remote, skipping push"
|
||||
else
|
||||
git push origin "$TAG"
|
||||
fi
|
||||
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
echo "Release $TAG already exists, updating"
|
||||
gh release edit "$TAG" --title "$TITLE" --notes-file /tmp/release-notes.md
|
||||
gh release upload "$TAG" "${ZIP_FILES[@]}" --clobber
|
||||
else
|
||||
gh release create "$TAG" \
|
||||
--title "$TITLE" \
|
||||
--notes-file /tmp/release-notes.md \
|
||||
"${ZIP_FILES[@]}"
|
||||
fi
|
||||
working-directory: ${{ github.workspace }}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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=
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Bot,
|
||||
Compass,
|
||||
CreditCard,
|
||||
GitBranch,
|
||||
MessageSquare,
|
||||
Palette,
|
||||
RotateCcw,
|
||||
@@ -86,12 +85,6 @@ const primarySettingsSections: NavSection[] = [
|
||||
icon: CreditCard,
|
||||
feature: Feature.CREDITS_SUPPORT,
|
||||
},
|
||||
{
|
||||
name: 'Workflows',
|
||||
to: '/workflows',
|
||||
icon: GitBranch,
|
||||
feature: Feature.WORKFLOW_SUPPORT,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Onboarding } from '../onboarding/index/Onboarding'
|
||||
import { StepsLayout } from '../onboarding/steps/StepsLayout'
|
||||
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'
|
||||
@@ -29,7 +28,6 @@ import { SearchProviderPage } from './search-provider/SearchProviderPage'
|
||||
import { SkillsPage } from './skills/SkillsPage'
|
||||
import { SoulPage } from './soul/SoulPage'
|
||||
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 +51,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'
|
||||
@@ -90,7 +86,6 @@ export const App: FC = () => {
|
||||
|
||||
{/* Primary nav routes */}
|
||||
<Route path="connect-apps" element={<ConnectMCP />} />
|
||||
<Route path="workflows" element={<WorkflowsPageWrapper />} />
|
||||
<Route path="scheduled" element={<ScheduledTasksPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -108,9 +103,6 @@ export const App: FC = () => {
|
||||
</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 />} />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,7 +2,7 @@
|
||||
"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",
|
||||
|
||||
@@ -2,13 +2,17 @@ BINARY := browseros-cli
|
||||
SOURCES := $(shell find . -name '*.go')
|
||||
VERSION ?= dev
|
||||
POSTHOG_API_KEY ?=
|
||||
DIST := dist
|
||||
LDFLAGS := -X main.version=$(VERSION) -X browseros-cli/analytics.posthogAPIKey=$(POSTHOG_API_KEY)
|
||||
HOST_OS := $(shell go env GOOS)
|
||||
HOST_ARCH := $(shell go env GOARCH)
|
||||
HOST_EXT := $(if $(filter windows,$(HOST_OS)),.exe,)
|
||||
HOST_BINARY = $(DIST)/$(BINARY)_$(HOST_OS)_$(HOST_ARCH)$(HOST_EXT)
|
||||
|
||||
$(BINARY): $(SOURCES)
|
||||
go build -ldflags "$(LDFLAGS)" -o $(BINARY) .
|
||||
|
||||
PLATFORMS := darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64
|
||||
DIST := dist
|
||||
|
||||
.PHONY: install clean vet test release
|
||||
|
||||
@@ -45,6 +49,11 @@ release:
|
||||
fi; \
|
||||
mv "$(DIST)/$(BINARY)$$EXT" "$(DIST)/$(BINARY)_$${OS}_$${ARCH}$$EXT"; \
|
||||
done
|
||||
@ACTUAL_VERSION=$$($(HOST_BINARY) --version | awk '{print $$3}'); \
|
||||
if [ "$$ACTUAL_VERSION" != "$(VERSION)" ]; then \
|
||||
echo "Error: expected $(HOST_BINARY) to report version $(VERSION), got $$ACTUAL_VERSION" >&2; \
|
||||
exit 1; \
|
||||
fi
|
||||
@cd $(DIST) && (command -v sha256sum >/dev/null 2>&1 && sha256sum *.tar.gz *.zip || shasum -a 256 *.tar.gz *.zip) > checksums.txt
|
||||
@echo "=== Built artifacts ==="
|
||||
@ls -lh $(DIST)
|
||||
|
||||
@@ -34,6 +34,7 @@ const automaticUpdateDrainTimeout = 150 * time.Millisecond
|
||||
|
||||
func SetVersion(v string) {
|
||||
version = v
|
||||
rootCmd.Version = v
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -351,11 +352,12 @@ func isPortOnly(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
port, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return false
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return port >= 1 && port <= 65535
|
||||
return true
|
||||
}
|
||||
|
||||
func validateServerURL(raw string) (string, error) {
|
||||
|
||||
@@ -5,6 +5,24 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSetVersionUpdatesRootCommand(t *testing.T) {
|
||||
originalVersion := version
|
||||
originalRootVersion := rootCmd.Version
|
||||
t.Cleanup(func() {
|
||||
version = originalVersion
|
||||
rootCmd.Version = originalRootVersion
|
||||
})
|
||||
|
||||
SetVersion("1.2.3")
|
||||
|
||||
if version != "1.2.3" {
|
||||
t.Fatalf("version = %q, want %q", version, "1.2.3")
|
||||
}
|
||||
if rootCmd.Version != "1.2.3" {
|
||||
t.Fatalf("rootCmd.Version = %q, want %q", rootCmd.Version, "1.2.3")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -92,10 +92,6 @@ Skills are custom instruction sets that shape agent behavior:
|
||||
- **Loader** (`src/skills/loader.ts`) — loads skills from local and remote sources
|
||||
- **Remote sync** (`src/skills/remote-sync.ts`) — syncs skills from the BrowserOS cloud
|
||||
|
||||
## Graph Executor (Workflows)
|
||||
|
||||
The graph executor (`src/graph/executor.ts`) runs visual workflow graphs built in the BrowserOS workflow editor. Each node in the graph maps to agent actions, conditionals, or data transformations.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
@@ -120,14 +116,12 @@ apps/server/
|
||||
│ │ ├── filesystem/
|
||||
│ │ └── ...
|
||||
│ ├── skills/ # Skills system
|
||||
│ ├── graph/ # Workflow graph executor
|
||||
│ ├── lib/ # Shared utilities
|
||||
│ └── rpc.ts # JSON-RPC type definitions
|
||||
├── tests/
|
||||
│ ├── tools/ # Tool-level tests
|
||||
│ ├── sdk/ # SDK integration tests
|
||||
│ └── server.integration.test.ts
|
||||
├── graph/ # Workflow graph definitions
|
||||
└── package.json
|
||||
```
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@browseros/server",
|
||||
"version": "0.0.80",
|
||||
"version": "0.0.82",
|
||||
"description": "BrowserOS server",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createMCPClient } from '@ai-sdk/mcp'
|
||||
import { TIMEOUTS } from '@browseros/shared/constants/timeouts'
|
||||
import type { BrowserContext } from '@browseros/shared/schemas/browser-context'
|
||||
import type { ToolSet } from 'ai'
|
||||
import { klavisStrataCache } from '../api/services/klavis/strata-cache'
|
||||
import type { KlavisClient } from '../lib/clients/klavis/klavis-client'
|
||||
import { logger } from '../lib/logger'
|
||||
import {
|
||||
@@ -40,7 +41,8 @@ export async function buildMcpServerSpecs(
|
||||
deps.browserContext?.enabledMcpServers?.length
|
||||
) {
|
||||
try {
|
||||
const result = await deps.klavisClient.createStrata(
|
||||
const result = await klavisStrataCache.getOrFetch(
|
||||
deps.klavisClient,
|
||||
deps.browserosId,
|
||||
deps.browserContext.enabledMcpServers,
|
||||
)
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { PATHS } from '@browseros/shared/constants/paths'
|
||||
import { zValidator } from '@hono/zod-validator'
|
||||
import type { Context } from 'hono'
|
||||
import { Hono } from 'hono'
|
||||
import { stream } from 'hono/streaming'
|
||||
import { logger } from '../../lib/logger'
|
||||
import { GraphService } from '../services/graph-service'
|
||||
import {
|
||||
CreateGraphRequestSchema,
|
||||
RunGraphRequestSchema,
|
||||
UpdateGraphRequestSchema,
|
||||
} from '../types'
|
||||
import {
|
||||
formatUIMessageStreamDone,
|
||||
formatUIMessageStreamEvent,
|
||||
} from '../utils/ui-message-stream'
|
||||
import { SessionIdParamSchema } from '../utils/validation'
|
||||
|
||||
interface SSEStreamOptions {
|
||||
vercelAIStream?: boolean
|
||||
logLabel: string
|
||||
}
|
||||
|
||||
type SSEStreamCallback = (
|
||||
stream: { write: (data: string) => Promise<unknown> },
|
||||
signal: AbortSignal,
|
||||
) => Promise<void>
|
||||
|
||||
function createSSEStream(
|
||||
c: Context,
|
||||
options: SSEStreamOptions,
|
||||
callback: SSEStreamCallback,
|
||||
) {
|
||||
c.header('Content-Type', 'text/event-stream')
|
||||
c.header('Cache-Control', 'no-cache')
|
||||
c.header('Connection', 'keep-alive')
|
||||
|
||||
if (options.vercelAIStream) {
|
||||
c.header('x-vercel-ai-ui-message-stream', 'v1')
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
|
||||
if (c.req.raw.signal) {
|
||||
c.req.raw.signal.addEventListener('abort', () => abortController.abort(), {
|
||||
once: true,
|
||||
})
|
||||
}
|
||||
|
||||
return stream(c, async (honoStream) => {
|
||||
honoStream.onAbort(() => {
|
||||
abortController.abort()
|
||||
logger.debug(`${options.logLabel} stream aborted`)
|
||||
})
|
||||
|
||||
await callback(honoStream, abortController.signal)
|
||||
})
|
||||
}
|
||||
|
||||
interface GraphRouteDeps {
|
||||
port: number
|
||||
tempDir?: string
|
||||
codegenServiceUrl?: string
|
||||
}
|
||||
|
||||
export function createGraphRoutes(deps: GraphRouteDeps) {
|
||||
const { port, codegenServiceUrl } = deps
|
||||
|
||||
const serverUrl = `http://127.0.0.1:${port}`
|
||||
const tempDir = deps.tempDir || PATHS.DEFAULT_EXECUTION_DIR
|
||||
|
||||
const graphService = codegenServiceUrl
|
||||
? new GraphService({ codegenServiceUrl, serverUrl, tempDir })
|
||||
: null
|
||||
|
||||
// Chain route definitions for proper Hono RPC type inference
|
||||
return new Hono()
|
||||
.post('/', zValidator('json', CreateGraphRequestSchema), async (c) => {
|
||||
if (!graphService) {
|
||||
return c.json({ error: 'CODEGEN_SERVICE_URL not configured' }, 503)
|
||||
}
|
||||
const request = c.req.valid('json')
|
||||
logger.info('Graph create request received', { query: request.query })
|
||||
|
||||
return createSSEStream(
|
||||
c,
|
||||
{ logLabel: 'Graph create', vercelAIStream: true },
|
||||
async (s, signal) => {
|
||||
try {
|
||||
await graphService.createGraph(
|
||||
request.query,
|
||||
async (event) => {
|
||||
await s.write(formatUIMessageStreamEvent(event))
|
||||
},
|
||||
signal,
|
||||
)
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
await s.write(
|
||||
formatUIMessageStreamEvent({
|
||||
type: 'error',
|
||||
errorText: errorMessage,
|
||||
}),
|
||||
)
|
||||
await s.write(
|
||||
formatUIMessageStreamEvent({
|
||||
type: 'finish',
|
||||
finishReason: 'error',
|
||||
}),
|
||||
)
|
||||
} finally {
|
||||
await s.write(formatUIMessageStreamDone())
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
.post(
|
||||
'/:id',
|
||||
zValidator('param', SessionIdParamSchema),
|
||||
zValidator('json', UpdateGraphRequestSchema),
|
||||
async (c) => {
|
||||
if (!graphService) {
|
||||
return c.json({ error: 'CODEGEN_SERVICE_URL not configured' }, 503)
|
||||
}
|
||||
const { id: sessionId } = c.req.valid('param')
|
||||
const request = c.req.valid('json')
|
||||
logger.info('Graph update request received', {
|
||||
sessionId,
|
||||
query: request.query,
|
||||
})
|
||||
|
||||
return createSSEStream(
|
||||
c,
|
||||
{ logLabel: 'Graph update', vercelAIStream: true },
|
||||
async (s, signal) => {
|
||||
try {
|
||||
await graphService.updateGraph(
|
||||
sessionId,
|
||||
request.query,
|
||||
async (event) => {
|
||||
await s.write(formatUIMessageStreamEvent(event))
|
||||
},
|
||||
signal,
|
||||
)
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
await s.write(
|
||||
formatUIMessageStreamEvent({
|
||||
type: 'error',
|
||||
errorText: errorMessage,
|
||||
}),
|
||||
)
|
||||
await s.write(
|
||||
formatUIMessageStreamEvent({
|
||||
type: 'finish',
|
||||
finishReason: 'error',
|
||||
}),
|
||||
)
|
||||
} finally {
|
||||
await s.write(formatUIMessageStreamDone())
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
.get('/:id', zValidator('param', SessionIdParamSchema), async (c) => {
|
||||
if (!graphService) {
|
||||
return c.json({ error: 'CODEGEN_SERVICE_URL not configured' }, 503)
|
||||
}
|
||||
const { id: sessionId } = c.req.valid('param')
|
||||
|
||||
logger.debug('Graph get request received', { sessionId })
|
||||
|
||||
const session = await graphService.getGraph(sessionId)
|
||||
|
||||
if (!session) {
|
||||
return c.json({ error: 'Graph not found' }, 404)
|
||||
}
|
||||
|
||||
return c.json(session)
|
||||
})
|
||||
.post(
|
||||
'/:id/run',
|
||||
zValidator('param', SessionIdParamSchema),
|
||||
zValidator('json', RunGraphRequestSchema),
|
||||
async (c) => {
|
||||
if (!graphService) {
|
||||
return c.json({ error: 'CODEGEN_SERVICE_URL not configured' }, 503)
|
||||
}
|
||||
const { id: sessionId } = c.req.valid('param')
|
||||
const request = c.req.valid('json')
|
||||
logger.info('Graph run request received', {
|
||||
sessionId,
|
||||
provider: request.provider,
|
||||
model: request.model,
|
||||
})
|
||||
|
||||
return createSSEStream(
|
||||
c,
|
||||
{ logLabel: 'Graph run', vercelAIStream: true },
|
||||
async (s, signal) => {
|
||||
try {
|
||||
// Emit start event at route level
|
||||
await s.write(
|
||||
formatUIMessageStreamEvent({
|
||||
type: 'start',
|
||||
messageId: sessionId,
|
||||
}),
|
||||
)
|
||||
|
||||
await graphService.runGraph(
|
||||
sessionId,
|
||||
request,
|
||||
async (event) => {
|
||||
// Agent SDK handles proper event formatting
|
||||
// Skip start/finish (managed at route level), forward everything else
|
||||
if (event.type === 'start' || event.type === 'finish') {
|
||||
return
|
||||
}
|
||||
await s.write(formatUIMessageStreamEvent(event))
|
||||
},
|
||||
signal,
|
||||
)
|
||||
|
||||
// Emit finish at route level
|
||||
await s.write(
|
||||
formatUIMessageStreamEvent({
|
||||
type: 'finish',
|
||||
finishReason: 'stop',
|
||||
}),
|
||||
)
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
await s.write(
|
||||
formatUIMessageStreamEvent({
|
||||
type: 'error',
|
||||
errorText: errorMessage,
|
||||
}),
|
||||
)
|
||||
await s.write(
|
||||
formatUIMessageStreamEvent({
|
||||
type: 'finish',
|
||||
finishReason: 'error',
|
||||
}),
|
||||
)
|
||||
} finally {
|
||||
await s.write(formatUIMessageStreamDone())
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
.delete('/:id', zValidator('param', SessionIdParamSchema), async (c) => {
|
||||
if (!graphService) {
|
||||
return c.json({ error: 'CODEGEN_SERVICE_URL not configured' }, 503)
|
||||
}
|
||||
const { id: sessionId } = c.req.valid('param')
|
||||
|
||||
logger.debug('Graph delete request received', { sessionId })
|
||||
|
||||
await graphService.deleteGraph(sessionId)
|
||||
|
||||
return c.json({ success: true, message: `Graph ${sessionId} deleted` })
|
||||
})
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { z } from 'zod'
|
||||
import { KlavisClient } from '../../lib/clients/klavis/klavis-client'
|
||||
import { OAUTH_MCP_SERVERS } from '../../lib/clients/klavis/oauth-mcp-servers'
|
||||
import { logger } from '../../lib/logger'
|
||||
import { klavisStrataCache } from '../services/klavis/strata-cache'
|
||||
|
||||
const ServerNameSchema = z.object({
|
||||
serverName: z.string().min(1),
|
||||
@@ -125,6 +126,7 @@ export function createKlavisRoutes(deps: KlavisRouteDeps) {
|
||||
logger.info('Adding server to strata', { serverName })
|
||||
|
||||
const result = await klavisClient.createStrata(browserosId, [serverName])
|
||||
klavisStrataCache.invalidate(browserosId)
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
@@ -184,7 +186,17 @@ export function createKlavisRoutes(deps: KlavisRouteDeps) {
|
||||
|
||||
logger.info('Removing server from strata', { serverName })
|
||||
|
||||
await klavisClient.removeServer(browserosId, serverName)
|
||||
// The chat hot path keys its cache by the user's full enabled set,
|
||||
// so a single-server lookup here would always miss and immediately
|
||||
// be cleared by invalidate() below — call createStrata directly
|
||||
// to recover the strataId, mirroring the original removeServer flow.
|
||||
const strata = await klavisClient.createStrata(browserosId, [
|
||||
serverName,
|
||||
])
|
||||
await klavisClient.deleteServersFromStrata(strata.strataId, [
|
||||
serverName,
|
||||
])
|
||||
klavisStrataCache.invalidate(browserosId)
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
|
||||
@@ -11,8 +11,8 @@ import { logger } from '../../lib/logger'
|
||||
import { metrics } from '../../lib/metrics'
|
||||
import { Sentry } from '../../lib/sentry'
|
||||
import type { ToolRegistry } from '../../tools/tool-registry'
|
||||
import type { KlavisProxyHandle } from '../services/klavis/strata-proxy'
|
||||
import { createMcpServer } from '../services/mcp/mcp-server'
|
||||
import type { KlavisProxyHandle } from '../services/mcp/register-klavis-mcp'
|
||||
import type { Env } from '../types'
|
||||
|
||||
interface McpRouteDeps {
|
||||
|
||||
@@ -22,7 +22,6 @@ import { logger } from '../lib/logger'
|
||||
import { Sentry } from '../lib/sentry'
|
||||
import { createChatRoutes } from './routes/chat'
|
||||
import { createCreditsRoutes } from './routes/credits'
|
||||
import { createGraphRoutes } from './routes/graph'
|
||||
import { createHealthRoute } from './routes/health'
|
||||
import { createKlavisRoutes } from './routes/klavis'
|
||||
import { createMcpRoutes } from './routes/mcp'
|
||||
@@ -38,7 +37,7 @@ import { createStatusRoute } from './routes/status'
|
||||
import {
|
||||
connectKlavisProxy,
|
||||
type KlavisProxyHandle,
|
||||
} from './services/mcp/register-klavis-mcp'
|
||||
} from './services/klavis/strata-proxy'
|
||||
import type { Env, HttpServerConfig } from './types'
|
||||
import { defaultCorsConfig } from './utils/cors'
|
||||
|
||||
@@ -171,14 +170,6 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
browserosId,
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
'/graph',
|
||||
createGraphRoutes({
|
||||
port,
|
||||
tempDir: executionDir,
|
||||
codegenServiceUrl: config.codegenServiceUrl,
|
||||
}),
|
||||
)
|
||||
|
||||
// Error handler
|
||||
app.onError((err, c) => {
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { UIMessageStreamEventSchema } from '@browseros/shared/schemas/ui-stream'
|
||||
import type { LLMConfig, UIMessageStreamEvent } from '@browseros-ai/agent-sdk'
|
||||
import { createParser, type EventSourceMessage } from 'eventsource-parser'
|
||||
import { cleanupExecution, executeGraph } from '../../graph/executor'
|
||||
import { logger } from '../../lib/logger'
|
||||
import {
|
||||
CodegenFinishMetadataSchema,
|
||||
CodegenGetResponseSchema,
|
||||
type GraphSession,
|
||||
type RunGraphRequest,
|
||||
type WorkflowGraph,
|
||||
} from '../types'
|
||||
|
||||
export interface GraphServiceDeps {
|
||||
codegenServiceUrl: string
|
||||
serverUrl: string
|
||||
tempDir: string
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
codeId: string | null
|
||||
code: string | null
|
||||
graph: WorkflowGraph | null
|
||||
}
|
||||
|
||||
export class GraphService {
|
||||
constructor(private deps: GraphServiceDeps) {}
|
||||
|
||||
/**
|
||||
* Create a new graph by proxying to codegen service.
|
||||
* Streams UIMessageStreamEvent events back to caller.
|
||||
*/
|
||||
async createGraph(
|
||||
query: string,
|
||||
onEvent: (event: UIMessageStreamEvent) => Promise<void>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<GraphSession | null> {
|
||||
const url = `${this.deps.codegenServiceUrl}/api/code`
|
||||
|
||||
logger.debug('Creating graph via codegen service', { url, query })
|
||||
|
||||
return this.proxyCodegenRequest(url, 'POST', { query }, onEvent, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing graph by proxying to codegen service.
|
||||
*/
|
||||
async updateGraph(
|
||||
sessionId: string,
|
||||
query: string,
|
||||
onEvent: (event: UIMessageStreamEvent) => Promise<void>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<GraphSession | null> {
|
||||
const url = `${this.deps.codegenServiceUrl}/api/code/${sessionId}`
|
||||
|
||||
logger.debug('Updating graph via codegen service', {
|
||||
url,
|
||||
sessionId,
|
||||
query,
|
||||
})
|
||||
|
||||
return this.proxyCodegenRequest(url, 'PUT', { query }, onEvent, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get graph code and visualization from codegen service.
|
||||
*/
|
||||
async getGraph(sessionId: string): Promise<GraphSession | null> {
|
||||
const url = `${this.deps.codegenServiceUrl}/api/code/${sessionId}`
|
||||
|
||||
logger.debug('Fetching graph from codegen service', { url, sessionId })
|
||||
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return null
|
||||
}
|
||||
throw new Error(`Codegen service error: ${response.status}`)
|
||||
}
|
||||
|
||||
const json = await response.json()
|
||||
const result = CodegenGetResponseSchema.safeParse(json)
|
||||
|
||||
if (!result.success) {
|
||||
logger.error('Invalid codegen response', {
|
||||
issues: result.error.issues,
|
||||
})
|
||||
throw new Error('Invalid response from codegen service')
|
||||
}
|
||||
|
||||
return {
|
||||
id: sessionId,
|
||||
code: result.data.code,
|
||||
graph: result.data.graph,
|
||||
createdAt: new Date(result.data.createdAt || Date.now()),
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
logger.error('Failed to fetch graph', { sessionId, error: errorMessage })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a graph by fetching code from codegen and running it.
|
||||
*/
|
||||
async runGraph(
|
||||
sessionId: string,
|
||||
request: RunGraphRequest,
|
||||
onProgress: (event: UIMessageStreamEvent) => Promise<void>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
// Fetch code from codegen service
|
||||
const graph = await this.getGraph(sessionId)
|
||||
|
||||
if (!graph) {
|
||||
throw new Error(`Graph not found: ${sessionId}`)
|
||||
}
|
||||
|
||||
logger.debug('Executing graph', {
|
||||
sessionId,
|
||||
codeLength: graph.code.length,
|
||||
})
|
||||
|
||||
// Build LLM config from request
|
||||
const llmConfig: LLMConfig | undefined = request.provider
|
||||
? {
|
||||
provider: request.provider,
|
||||
model: request.model,
|
||||
apiKey: request.apiKey,
|
||||
baseUrl: request.baseUrl,
|
||||
resourceName: request.resourceName,
|
||||
region: request.region,
|
||||
accessKeyId: request.accessKeyId,
|
||||
secretAccessKey: request.secretAccessKey,
|
||||
sessionToken: request.sessionToken,
|
||||
}
|
||||
: undefined
|
||||
|
||||
const result = await executeGraph(
|
||||
graph.code,
|
||||
sessionId,
|
||||
this.deps.tempDir,
|
||||
{
|
||||
serverUrl: this.deps.serverUrl,
|
||||
llmConfig,
|
||||
browserContext: request.browserContext,
|
||||
onProgress: (event) => {
|
||||
onProgress(event).catch((err) => {
|
||||
logger.warn('Failed to send progress event', { error: String(err) })
|
||||
})
|
||||
},
|
||||
signal,
|
||||
},
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Graph execution failed')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete execution files for a graph.
|
||||
*/
|
||||
async deleteGraph(sessionId: string): Promise<void> {
|
||||
await cleanupExecution(sessionId, this.deps.tempDir)
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy a request to codegen service and stream UIMessageStreamEvent events.
|
||||
*/
|
||||
private async proxyCodegenRequest(
|
||||
url: string,
|
||||
method: 'POST' | 'PUT',
|
||||
body: { query: string },
|
||||
onEvent: (event: UIMessageStreamEvent) => Promise<void>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<GraphSession | null> {
|
||||
try {
|
||||
const response = await this.fetchCodegenService(url, method, body, signal)
|
||||
return await this.parseUIMessageStream(response, onEvent)
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
logger.error('Codegen proxy request failed', { url, error: errorMessage })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchCodegenService(
|
||||
url: string,
|
||||
method: 'POST' | 'PUT',
|
||||
body: { query: string },
|
||||
signal?: AbortSignal,
|
||||
): Promise<Response> {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Codegen service error: ${response.status}`)
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('No response body from codegen service')
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse UIMessageStreamEvent SSE stream from codegen service.
|
||||
* Extracts codeId, code, graph from the finish event's messageMetadata.
|
||||
*/
|
||||
private async parseUIMessageStream(
|
||||
response: Response,
|
||||
onEvent: (event: UIMessageStreamEvent) => Promise<void>,
|
||||
): Promise<GraphSession | null> {
|
||||
if (!response.body) {
|
||||
throw new Error('No response body')
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
const state: SessionState = { codeId: null, code: null, graph: null }
|
||||
const pendingEvents: UIMessageStreamEvent[] = []
|
||||
|
||||
const parser = createParser({
|
||||
onEvent: (msg: EventSourceMessage) => {
|
||||
if (msg.data === '[DONE]') return
|
||||
|
||||
try {
|
||||
const json = JSON.parse(msg.data)
|
||||
const result = UIMessageStreamEventSchema.safeParse(json)
|
||||
|
||||
if (!result.success) {
|
||||
logger.warn('Invalid UIMessageStream event', {
|
||||
data: msg.data,
|
||||
issues: result.error.issues,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
pendingEvents.push(result.data as UIMessageStreamEvent)
|
||||
} catch {
|
||||
logger.warn('Failed to parse UIMessageStream event', {
|
||||
data: msg.data,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
const text = decoder.decode(value, { stream: true })
|
||||
parser.feed(text)
|
||||
|
||||
// Process any events that were parsed
|
||||
let event = pendingEvents.shift()
|
||||
while (event) {
|
||||
this.extractSessionData(event, state)
|
||||
await onEvent(event)
|
||||
event = pendingEvents.shift()
|
||||
}
|
||||
}
|
||||
|
||||
// Process any remaining events
|
||||
let remaining = pendingEvents.shift()
|
||||
while (remaining) {
|
||||
this.extractSessionData(remaining, state)
|
||||
await onEvent(remaining)
|
||||
remaining = pendingEvents.shift()
|
||||
}
|
||||
|
||||
if (state.codeId && state.code) {
|
||||
return {
|
||||
id: state.codeId,
|
||||
code: state.code,
|
||||
graph: state.graph,
|
||||
createdAt: new Date(),
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract session data (codeId, code, graph) from UIMessageStreamEvent.
|
||||
*/
|
||||
private extractSessionData(
|
||||
event: UIMessageStreamEvent,
|
||||
state: SessionState,
|
||||
): void {
|
||||
if (event.type === 'start' && event.messageId) {
|
||||
state.codeId = event.messageId
|
||||
} else if (event.type === 'finish' && event.messageMetadata) {
|
||||
const result = CodegenFinishMetadataSchema.safeParse(
|
||||
event.messageMetadata,
|
||||
)
|
||||
if (result.success) {
|
||||
if (result.data.codeId) state.codeId = result.data.codeId
|
||||
if (result.data.code) state.code = result.data.code
|
||||
if (result.data.graph !== undefined) state.graph = result.data.graph
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* In-process cache for Klavis Strata `createStrata` responses.
|
||||
*
|
||||
* Conversation creation in `/chat` was blocking on a Worker-proxied
|
||||
* `klavisClient.createStrata` round-trip every time the user had any
|
||||
* managed Klavis app connected. This cache stores the (immutable) JSON
|
||||
* metadata returned by `createStrata` so that subsequent chats with the
|
||||
* same `(browserosId, enabled-server-set)` skip the round-trip entirely.
|
||||
*
|
||||
* It does NOT cache live MCP client connections — only URL/id metadata.
|
||||
* Per-session MCP clients continue to be opened and closed by
|
||||
* `AiSdkAgent.create` / `dispose` exactly as before, which makes the
|
||||
* cache safe across concurrent chats by construction.
|
||||
*/
|
||||
|
||||
import type {
|
||||
KlavisClient,
|
||||
StrataCreateResponse,
|
||||
} from '../../../lib/clients/klavis/klavis-client'
|
||||
import { logger } from '../../../lib/logger'
|
||||
|
||||
const DEFAULT_TTL_MS = 60 * 60 * 1000
|
||||
|
||||
interface CacheEntry {
|
||||
strataServerUrl: string
|
||||
strataId: string
|
||||
addedServers: string[]
|
||||
serverKey: string
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
function normalizeServers(servers: readonly string[]): string {
|
||||
return [...new Set(servers)].sort().join(',')
|
||||
}
|
||||
|
||||
function keyOf(browserosId: string, normalized: string): string {
|
||||
// xxhash64 → 16 hex chars, fixed width. Birthday-bound collision risk
|
||||
// for our scale (<10k entries) is ~5e-15; we additionally verify
|
||||
// serverKey on read so collisions cannot affect correctness.
|
||||
const hash = Bun.hash(normalized).toString(16).padStart(16, '0')
|
||||
return `${browserosId}|${hash}`
|
||||
}
|
||||
|
||||
export class KlavisStrataCache {
|
||||
private entries = new Map<string, Promise<CacheEntry>>()
|
||||
|
||||
constructor(private ttlMs: number = DEFAULT_TTL_MS) {}
|
||||
|
||||
async getOrFetch(
|
||||
client: KlavisClient,
|
||||
browserosId: string,
|
||||
servers: readonly string[],
|
||||
): Promise<StrataCreateResponse> {
|
||||
const normalized = normalizeServers(servers)
|
||||
const key = keyOf(browserosId, normalized)
|
||||
const existing = this.entries.get(key)
|
||||
|
||||
if (existing) {
|
||||
const resolved = await existing.catch(() => null)
|
||||
if (
|
||||
resolved &&
|
||||
resolved.serverKey === normalized &&
|
||||
Date.now() < resolved.expiresAt
|
||||
) {
|
||||
logger.debug('Klavis strata cache hit', { key })
|
||||
return this.toResponse(resolved)
|
||||
}
|
||||
// Stale/collision/rejected — evict, but only if we're the rightful
|
||||
// evictor (a racing caller may have already replaced this slot).
|
||||
if (this.entries.get(key) === existing) {
|
||||
this.entries.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('Klavis strata cache miss', {
|
||||
key,
|
||||
serverCount: servers.length,
|
||||
})
|
||||
const inflight = this.fetch(client, browserosId, servers, normalized)
|
||||
this.entries.set(key, inflight)
|
||||
|
||||
try {
|
||||
return this.toResponse(await inflight)
|
||||
} catch (err) {
|
||||
// Identity-check: only drop OUR entry. A racing invalidate() may have
|
||||
// already removed it, or a racing miss may have inserted a new one
|
||||
// that we must not clobber.
|
||||
if (this.entries.get(key) === inflight) {
|
||||
this.entries.delete(key)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
invalidate(browserosId: string): void {
|
||||
const prefix = `${browserosId}|`
|
||||
let dropped = 0
|
||||
for (const key of this.entries.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
this.entries.delete(key)
|
||||
dropped++
|
||||
}
|
||||
}
|
||||
if (dropped > 0) {
|
||||
logger.debug('Klavis strata cache invalidated', {
|
||||
browserosId: browserosId.slice(0, 12),
|
||||
dropped,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.entries.clear()
|
||||
}
|
||||
|
||||
private async fetch(
|
||||
client: KlavisClient,
|
||||
browserosId: string,
|
||||
servers: readonly string[],
|
||||
normalized: string,
|
||||
): Promise<CacheEntry> {
|
||||
const result = await client.createStrata(browserosId, [...servers])
|
||||
return {
|
||||
strataServerUrl: result.strataServerUrl,
|
||||
strataId: result.strataId,
|
||||
addedServers: result.addedServers,
|
||||
serverKey: normalized,
|
||||
expiresAt: Date.now() + this.ttlMs,
|
||||
}
|
||||
}
|
||||
|
||||
private toResponse(entry: CacheEntry): StrataCreateResponse {
|
||||
return {
|
||||
strataServerUrl: entry.strataServerUrl,
|
||||
strataId: entry.strataId,
|
||||
addedServers: entry.addedServers,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const klavisStrataCache = new KlavisStrataCache()
|
||||
@@ -14,6 +14,7 @@ import type { KlavisClient } from '../../../lib/clients/klavis/klavis-client'
|
||||
import { OAUTH_MCP_SERVERS } from '../../../lib/clients/klavis/oauth-mcp-servers'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import { metrics } from '../../../lib/metrics'
|
||||
import { klavisStrataCache } from './strata-cache'
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, label: string): Promise<T> {
|
||||
let timerId: ReturnType<typeof setTimeout> | undefined
|
||||
@@ -49,7 +50,8 @@ export async function connectKlavisProxy(
|
||||
// even unauthenticated ones (Klavis handles auth prompts on call)
|
||||
const allServers = OAUTH_MCP_SERVERS.map((s) => s.name)
|
||||
|
||||
const strata = await deps.klavisClient.createStrata(
|
||||
const strata = await klavisStrataCache.getOrFetch(
|
||||
deps.klavisClient,
|
||||
deps.browserosId,
|
||||
allServers,
|
||||
)
|
||||
@@ -8,11 +8,11 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { SetLevelRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
||||
import type { Browser } from '../../../browser/browser'
|
||||
import type { ToolRegistry } from '../../../tools/tool-registry'
|
||||
import { MCP_INSTRUCTIONS } from './mcp-prompt'
|
||||
import {
|
||||
type KlavisProxyHandle,
|
||||
registerKlavisTools,
|
||||
} from './register-klavis-mcp'
|
||||
} from '../klavis/strata-proxy'
|
||||
import { MCP_INSTRUCTIONS } from './mcp-prompt'
|
||||
import { registerTools } from './register-mcp'
|
||||
|
||||
export interface McpServiceDeps {
|
||||
|
||||
@@ -102,87 +102,3 @@ export interface HttpServerConfig {
|
||||
|
||||
onShutdown?: () => void
|
||||
}
|
||||
|
||||
// Graph request schemas
|
||||
export const CreateGraphRequestSchema = z.object({
|
||||
query: z.string().min(1, 'Query cannot be empty'),
|
||||
})
|
||||
|
||||
export type CreateGraphRequest = z.infer<typeof CreateGraphRequestSchema>
|
||||
|
||||
export const UpdateGraphRequestSchema = z.object({
|
||||
query: z.string().min(1, 'Query cannot be empty'),
|
||||
})
|
||||
|
||||
export type UpdateGraphRequest = z.infer<typeof UpdateGraphRequestSchema>
|
||||
|
||||
// Run graph request - similar to ChatRequest, needs provider config for Agent SDK
|
||||
export const RunGraphRequestSchema = AgentLLMConfigSchema.extend({
|
||||
browserContext: BrowserContextSchema.optional(),
|
||||
})
|
||||
|
||||
export type RunGraphRequest = z.infer<typeof RunGraphRequestSchema>
|
||||
|
||||
// Workflow graph schemas (matching codegen-service)
|
||||
export const WorkflowNodeTypeSchema = z.enum([
|
||||
'start',
|
||||
'end',
|
||||
'nav',
|
||||
'act',
|
||||
'extract',
|
||||
'verify',
|
||||
'decision',
|
||||
'loop',
|
||||
'fork',
|
||||
'join',
|
||||
])
|
||||
|
||||
export type WorkflowNodeType = z.infer<typeof WorkflowNodeTypeSchema>
|
||||
|
||||
export const WorkflowNodeSchema = z.object({
|
||||
id: z.string(),
|
||||
type: WorkflowNodeTypeSchema,
|
||||
data: z.object({ label: z.string() }),
|
||||
})
|
||||
|
||||
export type WorkflowNode = z.infer<typeof WorkflowNodeSchema>
|
||||
|
||||
export const WorkflowEdgeSchema = z.object({
|
||||
id: z.string(),
|
||||
source: z.string(),
|
||||
target: z.string(),
|
||||
})
|
||||
|
||||
export type WorkflowEdge = z.infer<typeof WorkflowEdgeSchema>
|
||||
|
||||
export const WorkflowGraphSchema = z.object({
|
||||
nodes: z.array(WorkflowNodeSchema),
|
||||
edges: z.array(WorkflowEdgeSchema),
|
||||
})
|
||||
|
||||
export type WorkflowGraph = z.infer<typeof WorkflowGraphSchema>
|
||||
|
||||
export interface GraphSession {
|
||||
id: string
|
||||
code: string
|
||||
graph: WorkflowGraph | null
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
// Codegen service response schema for GET /api/code/:id
|
||||
export const CodegenGetResponseSchema = z.object({
|
||||
code: z.string(),
|
||||
graph: WorkflowGraphSchema.nullable(),
|
||||
createdAt: z.string().optional(),
|
||||
})
|
||||
|
||||
export type CodegenGetResponse = z.infer<typeof CodegenGetResponseSchema>
|
||||
|
||||
// Metadata schema for finish events from codegen service
|
||||
export const CodegenFinishMetadataSchema = z.object({
|
||||
codeId: z.string().optional(),
|
||||
code: z.string().optional(),
|
||||
graph: WorkflowGraphSchema.nullable().optional(),
|
||||
})
|
||||
|
||||
export type CodegenFinishMetadata = z.infer<typeof CodegenFinishMetadataSchema>
|
||||
|
||||
@@ -392,9 +392,48 @@ export class Browser {
|
||||
|
||||
// --- Observation ---
|
||||
|
||||
private async getFrameIds(session: ProtocolApi): Promise<string[]> {
|
||||
try {
|
||||
const result = await session.Page.getFrameTree()
|
||||
const ids: string[] = []
|
||||
type Tree = { frame: { id: string }; childFrames?: Tree[] }
|
||||
function collect(tree: Tree) {
|
||||
ids.push(tree.frame.id)
|
||||
if (tree.childFrames)
|
||||
for (const child of tree.childFrames) collect(child)
|
||||
}
|
||||
collect(result.frameTree as Tree)
|
||||
return ids
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchAXTree(session: ProtocolApi): Promise<AXNode[]> {
|
||||
const result = await session.Accessibility.getFullAXTree()
|
||||
return (result.nodes as AXNode[]) ?? []
|
||||
const frameIds = await this.getFrameIds(session)
|
||||
|
||||
if (frameIds.length <= 1) {
|
||||
const result = await session.Accessibility.getFullAXTree()
|
||||
return (result.nodes as AXNode[]) ?? []
|
||||
}
|
||||
|
||||
const allNodes: AXNode[] = []
|
||||
for (const frameId of frameIds) {
|
||||
try {
|
||||
const result = await session.Accessibility.getFullAXTree({ frameId })
|
||||
const nodes = (result.nodes as AXNode[]) ?? []
|
||||
for (const node of nodes) {
|
||||
allNodes.push({
|
||||
...node,
|
||||
nodeId: `${frameId}:${node.nodeId}`,
|
||||
childIds: node.childIds?.map((id) => `${frameId}:${id}`),
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Cross-origin or detached frames may fail — skip
|
||||
}
|
||||
}
|
||||
return allNodes
|
||||
}
|
||||
|
||||
async snapshot(page: number): Promise<string> {
|
||||
|
||||
@@ -20,7 +20,7 @@ export function buildContentMarkdownExpression(
|
||||
// Uses var + ES5 style for consistency with other injected scripts.
|
||||
// Context object: { pre: bool, ld: listDepth, lt: listType, td: tableDepth }
|
||||
const DOM_WALKER_SCRIPT = `(function(o) {
|
||||
var SKIP = {SCRIPT:1,STYLE:1,NOSCRIPT:1,SVG:1,TEMPLATE:1,IFRAME:1,CANVAS:1,VIDEO:1,AUDIO:1,OBJECT:1,EMBED:1};
|
||||
var SKIP = {SCRIPT:1,STYLE:1,NOSCRIPT:1,SVG:1,TEMPLATE:1,CANVAS:1,VIDEO:1,AUDIO:1,OBJECT:1,EMBED:1};
|
||||
var FORM = {INPUT:1,SELECT:1,TEXTAREA:1,BUTTON:1};
|
||||
var vh = window.innerHeight, vw = window.innerWidth;
|
||||
var root = o.selector ? document.querySelector(o.selector) : document.body;
|
||||
@@ -219,6 +219,15 @@ function walk(node, ctx) {
|
||||
t = kids(el, ctx).trim();
|
||||
return t ? '\\n*' + t + '*\\n' : '';
|
||||
|
||||
case 'IFRAME':
|
||||
try {
|
||||
var idoc = el.contentDocument;
|
||||
if (idoc && idoc.body) return walk(idoc.body, ctx);
|
||||
} catch(e) {}
|
||||
var isrc = el.src || el.getAttribute('src');
|
||||
if (isrc) return '\\n\\n[iframe: ' + isrc + ']\\n\\n';
|
||||
return '';
|
||||
|
||||
default:
|
||||
return kids(el, ctx);
|
||||
}
|
||||
|
||||
@@ -100,11 +100,16 @@ export function buildInteractiveTree(nodes: AXNode[]): string[] {
|
||||
if (node.childIds) for (const childId of node.childIds) walk(childId)
|
||||
}
|
||||
|
||||
const root =
|
||||
nodes.find(
|
||||
(n) => n.role?.value === 'RootWebArea' || n.role?.value === 'WebArea',
|
||||
) ?? nodes[0]
|
||||
if (root?.childIds) for (const childId of root.childIds) walk(childId)
|
||||
const roots = nodes.filter(
|
||||
(n) => n.role?.value === 'RootWebArea' || n.role?.value === 'WebArea',
|
||||
)
|
||||
if (roots.length === 0 && nodes[0]?.childIds) {
|
||||
for (const childId of nodes[0].childIds) walk(childId)
|
||||
} else {
|
||||
for (const root of roots) {
|
||||
if (root.childIds) for (const childId of root.childIds) walk(childId)
|
||||
}
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
@@ -160,11 +165,16 @@ export function buildEnhancedTree(nodes: AXNode[]): string[] {
|
||||
for (const childId of node.childIds) walk(childId, depth + 1)
|
||||
}
|
||||
|
||||
const root =
|
||||
nodes.find(
|
||||
(n) => n.role?.value === 'RootWebArea' || n.role?.value === 'WebArea',
|
||||
) ?? nodes[0]
|
||||
if (root?.childIds) for (const childId of root.childIds) walk(childId, 0)
|
||||
const roots = nodes.filter(
|
||||
(n) => n.role?.value === 'RootWebArea' || n.role?.value === 'WebArea',
|
||||
)
|
||||
if (roots.length === 0 && nodes[0]?.childIds) {
|
||||
for (const childId of nodes[0].childIds) walk(childId, 0)
|
||||
} else {
|
||||
for (const root of roots) {
|
||||
if (root.childIds) for (const childId of root.childIds) walk(childId, 0)
|
||||
}
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
@@ -292,11 +302,16 @@ export function extractLinkNodes(nodes: AXNode[]): LinkNode[] {
|
||||
if (node.childIds) for (const childId of node.childIds) walk(childId)
|
||||
}
|
||||
|
||||
const root =
|
||||
nodes.find(
|
||||
(n) => n.role?.value === 'RootWebArea' || n.role?.value === 'WebArea',
|
||||
) ?? nodes[0]
|
||||
if (root?.childIds) for (const childId of root.childIds) walk(childId)
|
||||
const roots = nodes.filter(
|
||||
(n) => n.role?.value === 'RootWebArea' || n.role?.value === 'WebArea',
|
||||
)
|
||||
if (roots.length === 0 && nodes[0]?.childIds) {
|
||||
for (const childId of nodes[0].childIds) walk(childId)
|
||||
} else {
|
||||
for (const root of roots) {
|
||||
if (root.childIds) for (const childId of root.childIds) walk(childId)
|
||||
}
|
||||
}
|
||||
|
||||
return links
|
||||
}
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { mkdir, rm } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import type { BrowserContext } from '@browseros/shared/schemas/browser-context'
|
||||
import type { LLMConfig, UIMessageStreamEvent } from '@browseros-ai/agent-sdk'
|
||||
import { Agent } from '@browseros-ai/agent-sdk'
|
||||
import { z } from 'zod'
|
||||
import { logger } from '../lib/logger'
|
||||
|
||||
//TODO: nikhil - Fix this with new bun package logic
|
||||
// Expose zod globally for generated graph code. The codegen service generates code
|
||||
// that uses `z` for schema validation, but transformCodeForExecution strips all imports
|
||||
// since dependencies can't be resolved in dynamically imported files (especially in
|
||||
// compiled binaries where modules are bundled). By exposing `z` as a global, the
|
||||
// generated code can reference it without an import statement.
|
||||
;(globalThis as unknown as Record<string, unknown>).z = z
|
||||
|
||||
export interface ExecutorOptions {
|
||||
serverUrl: string
|
||||
llmConfig?: LLMConfig
|
||||
browserContext?: BrowserContext
|
||||
onProgress: (event: UIMessageStreamEvent) => void
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export interface ExecutorResult {
|
||||
success: boolean
|
||||
result?: unknown
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes generated graph code using the Agent SDK.
|
||||
*
|
||||
* @param code - Generated code from codegen service
|
||||
* @param sessionId - Unique session ID for this execution
|
||||
* @param tempDir - Base temp directory for execution files
|
||||
* @param options - Execution options (serverUrl, llmConfig, onProgress, signal)
|
||||
*/
|
||||
export async function executeGraph(
|
||||
code: string,
|
||||
sessionId: string,
|
||||
tempDir: string,
|
||||
options: ExecutorOptions,
|
||||
): Promise<ExecutorResult> {
|
||||
const execDir = path.join(tempDir, 'graph', sessionId)
|
||||
|
||||
try {
|
||||
// Check if aborted before starting
|
||||
if (options.signal?.aborted) {
|
||||
return { success: false, error: 'Execution aborted' }
|
||||
}
|
||||
|
||||
// Create execution directory
|
||||
await mkdir(execDir, { recursive: true })
|
||||
|
||||
// Transform code: remove import statements (Agent is passed directly)
|
||||
const transformedCode = transformCodeForExecution(code)
|
||||
|
||||
// Write code to file
|
||||
const codePath = path.join(execDir, 'graph.ts')
|
||||
await Bun.write(codePath, transformedCode)
|
||||
|
||||
logger.debug(`Wrote graph code to ${codePath}`)
|
||||
|
||||
// Create Agent instance with progress callback (auto-disposed on scope exit)
|
||||
await using agent = new Agent({
|
||||
url: options.serverUrl,
|
||||
llm: options.llmConfig,
|
||||
onProgress: options.onProgress,
|
||||
signal: options.signal,
|
||||
browserContext: options.browserContext,
|
||||
stateful: true,
|
||||
})
|
||||
|
||||
// Dynamic import with cache-busting (Bun caches imports by path)
|
||||
const module = await import(`${codePath}?t=${Date.now()}`)
|
||||
|
||||
if (typeof module.run !== 'function') {
|
||||
throw new Error('Generated code must export a "run" function')
|
||||
}
|
||||
|
||||
let abortHandler: (() => void) | undefined
|
||||
try {
|
||||
// Only use Promise.race if we have a signal to listen to
|
||||
const result = options.signal
|
||||
? await Promise.race([
|
||||
module.run(agent),
|
||||
new Promise<never>((_, reject) => {
|
||||
abortHandler = () => reject(new Error('Execution aborted'))
|
||||
options.signal?.addEventListener('abort', abortHandler, {
|
||||
once: true,
|
||||
})
|
||||
}),
|
||||
])
|
||||
: await module.run(agent)
|
||||
|
||||
return { success: true, result }
|
||||
} finally {
|
||||
if (abortHandler && options.signal) {
|
||||
options.signal.removeEventListener('abort', abortHandler)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
logger.error(`Graph execution failed: ${errorMessage}`)
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
}
|
||||
|
||||
export function transformCodeForExecution(code: string): string {
|
||||
// Remove multi-line imports: import { ... } from 'any-package'
|
||||
let result = code.replace(
|
||||
/^\s*import\s+(?:type\s+)?\{[\s\S]*?\}\s*from\s*['"][^'"\n]*['"].*$/gm,
|
||||
'',
|
||||
)
|
||||
|
||||
// Remove single-line imports: import X from '...', import 'side-effect', etc.
|
||||
result = result.replace(/^\s*import\s+.*['"][^'"\n]*['"].*$/gm, '')
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up execution files for a session.
|
||||
*/
|
||||
export async function cleanupExecution(
|
||||
sessionId: string,
|
||||
tempDir: string,
|
||||
): Promise<void> {
|
||||
const execDir = path.join(tempDir, 'graph', sessionId)
|
||||
|
||||
try {
|
||||
await rm(execDir, { recursive: true, force: true })
|
||||
logger.debug(`Cleaned up execution directory: ${execDir}`)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
logger.warn(`Failed to cleanup execution directory: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
@@ -145,16 +145,14 @@ export class KlavisClient {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a server from a Strata instance
|
||||
* Flow: createStrata(server) to get strataId → DELETE /strata/{strataId}/servers?servers=X
|
||||
*/
|
||||
async removeServer(userId: string, serverName: string): Promise<void> {
|
||||
// createStrata to get strataId (passing same server ensures it exists)
|
||||
const strata = await this.createStrata(userId, [serverName])
|
||||
async deleteServersFromStrata(
|
||||
strataId: string,
|
||||
servers: string[],
|
||||
): Promise<void> {
|
||||
const query = servers.map(encodeURIComponent).join(',')
|
||||
await this.request(
|
||||
'DELETE',
|
||||
`/mcp-server/strata/${strata.strataId}/servers?servers=${encodeURIComponent(serverName)}`,
|
||||
`/mcp-server/strata/${strataId}/servers?servers=${query}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,7 @@ BrowserOS is an AI-native browser built on Chromium that turns plain English int
|
||||
## Modes
|
||||
|
||||
- **Chat Mode** — Ask questions about any webpage: summarize articles, extract data, translate content. Activate with Option+K. Works with any LLM, including local models.
|
||||
- **Agent Mode** — Describe a task and the agent executes it: clicking, typing, navigating, filling forms, extracting data, multi-step workflows. Best with Claude Opus 4.5 or Kimi K2.5.
|
||||
- **Graph Mode (Workflows)** — Build visual workflow graphs for repeatable, reliable automations with parallel execution, loops, and conditionals.
|
||||
- **Agent Mode** — Describe a task and the agent executes it: clicking, typing, navigating, filling forms, extracting data, and multi-step browser tasks. Best with Claude Opus 4.5 or Kimi K2.5.
|
||||
|
||||
---
|
||||
|
||||
@@ -23,16 +22,12 @@ BrowserOS is an AI-native browser built on Chromium that turns plain English int
|
||||
Connect your preferred AI provider or run models locally. Supported providers: Gemini (free tier), Claude/Anthropic, OpenAI, OpenRouter (500+ models). Local options: Ollama, LM Studio. Configure at chrome://browseros/settings.
|
||||
Learn more: https://docs.browseros.com/features/bring-your-own-llm
|
||||
|
||||
### Workflows
|
||||
Convert complex browser tasks into repeatable visual automations. Describe the task, the agent generates a workflow graph, refine it through conversation, then run it on demand. Ideal for data entry, outreach, price monitoring, bulk operations.
|
||||
Learn more: https://docs.browseros.com/features/workflows
|
||||
|
||||
### Scheduled Tasks
|
||||
Automate tasks on a schedule — daily, hourly, or every few minutes. Runs in a background window without interrupting your work. Use cases: morning briefings, LinkedIn automation, price monitoring. Requires BrowserOS to be open.
|
||||
Learn more: https://docs.browseros.com/features/scheduled-tasks
|
||||
|
||||
### Filesystem Access
|
||||
Grant the agent controlled access to a local folder to read files, write reports, and run shell commands. Sandboxed — cannot access parent directories. Combine web research with local file creation in a single workflow.
|
||||
Grant the agent controlled access to a local folder to read files, write reports, and run shell commands. Sandboxed — cannot access parent directories. Combine web research with local file creation in a single task.
|
||||
Learn more: https://docs.browseros.com/features/cowork
|
||||
|
||||
### Connect Apps (MCPs)
|
||||
@@ -54,7 +49,6 @@ Learn more: https://docs.browseros.com/features/ad-blocking`
|
||||
const VALID_TOPICS = [
|
||||
'overview',
|
||||
'bring-your-own-llm',
|
||||
'workflows',
|
||||
'scheduled-tasks',
|
||||
'filesystem-access',
|
||||
'connect-apps',
|
||||
@@ -67,9 +61,8 @@ const TOPIC_SECTIONS: Record<string, { start: string; end?: string }> = {
|
||||
overview: { start: '# BrowserOS', end: '## Core Features' },
|
||||
'bring-your-own-llm': {
|
||||
start: '### Bring Your Own LLM',
|
||||
end: '### Workflows',
|
||||
end: '### Scheduled Tasks',
|
||||
},
|
||||
workflows: { start: '### Workflows', end: '### Scheduled Tasks' },
|
||||
'scheduled-tasks': {
|
||||
start: '### Scheduled Tasks',
|
||||
end: '### Filesystem Access',
|
||||
|
||||
@@ -3,12 +3,17 @@
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, describe, it } from 'bun:test'
|
||||
import { afterEach, beforeEach, describe, it } from 'bun:test'
|
||||
import assert from 'node:assert'
|
||||
import { createKlavisRoutes } from '../../../src/api/routes/klavis'
|
||||
import { klavisStrataCache } from '../../../src/api/services/klavis/strata-cache'
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
beforeEach(() => {
|
||||
klavisStrataCache.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { KlavisStrataCache } from '../../../../src/api/services/klavis/strata-cache'
|
||||
import type {
|
||||
KlavisClient,
|
||||
StrataCreateResponse,
|
||||
} from '../../../../src/lib/clients/klavis/klavis-client'
|
||||
|
||||
class StubKlavisClient {
|
||||
callCount = 0
|
||||
delayMs = 0
|
||||
shouldThrowOnce = false
|
||||
lastServers: string[] | null = null
|
||||
|
||||
async createStrata(
|
||||
userId: string,
|
||||
servers: string[],
|
||||
): Promise<StrataCreateResponse> {
|
||||
this.callCount++
|
||||
this.lastServers = servers
|
||||
if (this.shouldThrowOnce) {
|
||||
this.shouldThrowOnce = false
|
||||
throw new Error('boom')
|
||||
}
|
||||
if (this.delayMs > 0) {
|
||||
await new Promise((r) => setTimeout(r, this.delayMs))
|
||||
}
|
||||
return {
|
||||
strataServerUrl: `https://strata.test/${userId}/${servers.join('-')}`,
|
||||
strataId: `strata_${userId}`,
|
||||
addedServers: servers,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const asClient = (stub: StubKlavisClient): KlavisClient =>
|
||||
stub as unknown as KlavisClient
|
||||
|
||||
describe('KlavisStrataCache', () => {
|
||||
it('cache hit returns the same value without re-calling the client', async () => {
|
||||
const cache = new KlavisStrataCache()
|
||||
const client = new StubKlavisClient()
|
||||
const a = await cache.getOrFetch(asClient(client), 'user1', ['Gmail'])
|
||||
const b = await cache.getOrFetch(asClient(client), 'user1', ['Gmail'])
|
||||
expect(client.callCount).toBe(1)
|
||||
expect(a.strataServerUrl).toBe(b.strataServerUrl)
|
||||
expect(a.strataId).toBe(b.strataId)
|
||||
})
|
||||
|
||||
it('normalizes server order — [Gmail, Linear] === [Linear, Gmail]', async () => {
|
||||
const cache = new KlavisStrataCache()
|
||||
const client = new StubKlavisClient()
|
||||
await cache.getOrFetch(asClient(client), 'u', ['Gmail', 'Linear'])
|
||||
await cache.getOrFetch(asClient(client), 'u', ['Linear', 'Gmail'])
|
||||
expect(client.callCount).toBe(1)
|
||||
})
|
||||
|
||||
it('dedupes duplicate server names within one call', async () => {
|
||||
const cache = new KlavisStrataCache()
|
||||
const client = new StubKlavisClient()
|
||||
await cache.getOrFetch(asClient(client), 'u', ['Gmail', 'Gmail'])
|
||||
await cache.getOrFetch(asClient(client), 'u', ['Gmail'])
|
||||
expect(client.callCount).toBe(1)
|
||||
})
|
||||
|
||||
it('different user gets a separate cache entry', async () => {
|
||||
const cache = new KlavisStrataCache()
|
||||
const client = new StubKlavisClient()
|
||||
await cache.getOrFetch(asClient(client), 'userA', ['Gmail'])
|
||||
await cache.getOrFetch(asClient(client), 'userB', ['Gmail'])
|
||||
expect(client.callCount).toBe(2)
|
||||
})
|
||||
|
||||
it('different server set (same user) gets a separate cache entry', async () => {
|
||||
const cache = new KlavisStrataCache()
|
||||
const client = new StubKlavisClient()
|
||||
await cache.getOrFetch(asClient(client), 'u', ['Gmail'])
|
||||
await cache.getOrFetch(asClient(client), 'u', ['Gmail', 'Linear'])
|
||||
expect(client.callCount).toBe(2)
|
||||
})
|
||||
|
||||
it('concurrent misses share a single in-flight Promise', async () => {
|
||||
const cache = new KlavisStrataCache()
|
||||
const client = new StubKlavisClient()
|
||||
client.delayMs = 30
|
||||
const [a, b, c] = await Promise.all([
|
||||
cache.getOrFetch(asClient(client), 'u', ['Gmail']),
|
||||
cache.getOrFetch(asClient(client), 'u', ['Gmail']),
|
||||
cache.getOrFetch(asClient(client), 'u', ['Gmail']),
|
||||
])
|
||||
expect(client.callCount).toBe(1)
|
||||
expect(a.strataId).toBe(b.strataId)
|
||||
expect(b.strataId).toBe(c.strataId)
|
||||
})
|
||||
|
||||
it('TTL expiry triggers a fresh fetch', async () => {
|
||||
const cache = new KlavisStrataCache(10) // 10 ms TTL
|
||||
const client = new StubKlavisClient()
|
||||
await cache.getOrFetch(asClient(client), 'u', ['Gmail'])
|
||||
await new Promise((r) => setTimeout(r, 25))
|
||||
await cache.getOrFetch(asClient(client), 'u', ['Gmail'])
|
||||
expect(client.callCount).toBe(2)
|
||||
})
|
||||
|
||||
it('invalidate(userA) drops only userA entries', async () => {
|
||||
const cache = new KlavisStrataCache()
|
||||
const client = new StubKlavisClient()
|
||||
await cache.getOrFetch(asClient(client), 'userA', ['Gmail'])
|
||||
await cache.getOrFetch(asClient(client), 'userB', ['Gmail'])
|
||||
cache.invalidate('userA')
|
||||
await cache.getOrFetch(asClient(client), 'userA', ['Gmail'])
|
||||
await cache.getOrFetch(asClient(client), 'userB', ['Gmail'])
|
||||
expect(client.callCount).toBe(3) // userA: cold + cold, userB: cold + hit
|
||||
})
|
||||
|
||||
it('invalidate while a fetch is in flight does not store the result', async () => {
|
||||
const cache = new KlavisStrataCache()
|
||||
const client = new StubKlavisClient()
|
||||
client.delayMs = 30
|
||||
const inflight = cache.getOrFetch(asClient(client), 'u', ['Gmail'])
|
||||
cache.invalidate('u')
|
||||
const result = await inflight
|
||||
expect(result.strataId).toBe('strata_u')
|
||||
// Next call should not see the post-invalidate write — must re-fetch.
|
||||
await cache.getOrFetch(asClient(client), 'u', ['Gmail'])
|
||||
expect(client.callCount).toBe(2)
|
||||
})
|
||||
|
||||
it('rejected fetches do not poison the cache', async () => {
|
||||
const cache = new KlavisStrataCache()
|
||||
const client = new StubKlavisClient()
|
||||
client.shouldThrowOnce = true
|
||||
await expect(
|
||||
cache.getOrFetch(asClient(client), 'u', ['Gmail']),
|
||||
).rejects.toThrow('boom')
|
||||
await cache.getOrFetch(asClient(client), 'u', ['Gmail'])
|
||||
expect(client.callCount).toBe(2)
|
||||
})
|
||||
|
||||
it('clear() drops all entries', async () => {
|
||||
const cache = new KlavisStrataCache()
|
||||
const client = new StubKlavisClient()
|
||||
await cache.getOrFetch(asClient(client), 'userA', ['Gmail'])
|
||||
await cache.getOrFetch(asClient(client), 'userB', ['Linear'])
|
||||
cache.clear()
|
||||
await cache.getOrFetch(asClient(client), 'userA', ['Gmail'])
|
||||
await cache.getOrFetch(asClient(client), 'userB', ['Linear'])
|
||||
expect(client.callCount).toBe(4)
|
||||
})
|
||||
|
||||
it('passes a defensive copy of the servers array to the client', async () => {
|
||||
const cache = new KlavisStrataCache()
|
||||
const client = new StubKlavisClient()
|
||||
const input: readonly string[] = ['Gmail', 'Linear']
|
||||
await cache.getOrFetch(asClient(client), 'u', input)
|
||||
expect(client.lastServers).not.toBe(input)
|
||||
expect(client.lastServers).toEqual(['Gmail', 'Linear'])
|
||||
})
|
||||
})
|
||||
@@ -8,11 +8,16 @@
|
||||
|
||||
import { afterAll, describe, it } from 'bun:test'
|
||||
import assert from 'node:assert'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
// Derive the build target from the current platform so the test is portable
|
||||
function getNativeTarget(): { id: string; ext: string } {
|
||||
const os =
|
||||
process.platform === 'darwin'
|
||||
@@ -24,12 +29,30 @@ function getNativeTarget(): { id: string; ext: string } {
|
||||
return { id: `${os}-${cpu}`, ext: process.platform === 'win32' ? '.exe' : '' }
|
||||
}
|
||||
|
||||
// Stub values so the build config validation passes without real secrets
|
||||
const BUILD_ENV_STUBS: Record<string, string> = {
|
||||
const REQUIRED_INLINE_ENV_KEYS = [
|
||||
'BROWSEROS_CONFIG_URL',
|
||||
'CODEGEN_SERVICE_URL',
|
||||
'POSTHOG_API_KEY',
|
||||
'SENTRY_DSN',
|
||||
] as const
|
||||
|
||||
const R2_ENV_KEYS = [
|
||||
'R2_ACCOUNT_ID',
|
||||
'R2_ACCESS_KEY_ID',
|
||||
'R2_SECRET_ACCESS_KEY',
|
||||
'R2_BUCKET',
|
||||
] as const
|
||||
|
||||
const PROD_SECRET_KEYS = [...REQUIRED_INLINE_ENV_KEYS, ...R2_ENV_KEYS]
|
||||
|
||||
const INLINE_ENV_STUBS: Record<string, string> = {
|
||||
BROWSEROS_CONFIG_URL: 'https://stub.test/config',
|
||||
CODEGEN_SERVICE_URL: 'https://stub.test/codegen',
|
||||
POSTHOG_API_KEY: 'phc_test_stub',
|
||||
SENTRY_DSN: 'https://stub@sentry.test/0',
|
||||
}
|
||||
|
||||
const R2_ENV_STUBS: Record<string, string> = {
|
||||
R2_ACCOUNT_ID: 'test',
|
||||
R2_ACCESS_KEY_ID: 'test',
|
||||
R2_SECRET_ACCESS_KEY: 'test',
|
||||
@@ -39,23 +62,58 @@ const BUILD_ENV_STUBS: Record<string, string> = {
|
||||
describe('server build', () => {
|
||||
const rootDir = resolve(import.meta.dir, '../../..')
|
||||
const serverPkgPath = resolve(rootDir, 'apps/server/package.json')
|
||||
const prodEnvPath = resolve(rootDir, 'apps/server/.env.production')
|
||||
const prodEnvTemplatePath = resolve(
|
||||
rootDir,
|
||||
'apps/server/.env.production.example',
|
||||
)
|
||||
const originalProdEnv = existsSync(prodEnvPath)
|
||||
? readFileSync(prodEnvPath, 'utf-8')
|
||||
: null
|
||||
const prodEnvTemplate = readFileSync(prodEnvTemplatePath, 'utf-8')
|
||||
const buildScript = resolve(rootDir, 'scripts/build/server.ts')
|
||||
const target = getNativeTarget()
|
||||
const binaryPath = resolve(
|
||||
rootDir,
|
||||
`dist/prod/server/.tmp/binaries/browseros-server-${target.id}${target.ext}`,
|
||||
)
|
||||
|
||||
// Empty manifest so the build skips R2 resource downloads
|
||||
const zipPath = resolve(
|
||||
rootDir,
|
||||
`dist/prod/server/browseros-server-resources-${target.id}.zip`,
|
||||
)
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'browseros-build-test-'))
|
||||
const emptyManifestPath = join(tempDir, 'empty-manifest.json')
|
||||
writeFileSync(emptyManifestPath, JSON.stringify({ resources: [] }))
|
||||
|
||||
function buildEnv(
|
||||
extraEnv: Record<string, string>,
|
||||
omitKeys: readonly string[] = [],
|
||||
): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
...extraEnv,
|
||||
}
|
||||
for (const key of omitKeys) {
|
||||
delete env[key]
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
function resetProdEnvToTemplate(): void {
|
||||
writeFileSync(prodEnvPath, prodEnvTemplate)
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
if (originalProdEnv === null) {
|
||||
rmSync(prodEnvPath, { force: true })
|
||||
return
|
||||
}
|
||||
writeFileSync(prodEnvPath, originalProdEnv)
|
||||
})
|
||||
|
||||
it('compiles and --version outputs correct version', async () => {
|
||||
resetProdEnvToTemplate()
|
||||
const pkg = await Bun.file(serverPkgPath).json()
|
||||
const expectedVersion: string = pkg.version
|
||||
|
||||
@@ -71,7 +129,7 @@ describe('server build', () => {
|
||||
cwd: rootDir,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: { ...process.env, ...BUILD_ENV_STUBS },
|
||||
env: buildEnv({ ...INLINE_ENV_STUBS, ...R2_ENV_STUBS }),
|
||||
},
|
||||
)
|
||||
const buildExit = await build.exited
|
||||
@@ -97,4 +155,26 @@ describe('server build', () => {
|
||||
)
|
||||
assert.strictEqual(versionOutput.trim(), expectedVersion)
|
||||
}, 300_000)
|
||||
|
||||
it('archives CI builds without R2 config or production env secrets', async () => {
|
||||
resetProdEnvToTemplate()
|
||||
rmSync(zipPath, { force: true })
|
||||
|
||||
const build = Bun.spawn(
|
||||
['bun', buildScript, `--target=${target.id}`, '--ci'],
|
||||
{
|
||||
cwd: rootDir,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: buildEnv({}, PROD_SECRET_KEYS),
|
||||
},
|
||||
)
|
||||
const buildExit = await build.exited
|
||||
if (buildExit !== 0) {
|
||||
const stderr = await new Response(build.stderr).text()
|
||||
assert.fail(`CI build failed (exit ${buildExit}):\n${stderr}`)
|
||||
}
|
||||
|
||||
assert.ok(existsSync(zipPath), `Expected archive at ${zipPath}`)
|
||||
}, 300_000)
|
||||
})
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { describe, it } from 'bun:test'
|
||||
import assert from 'node:assert'
|
||||
|
||||
import { transformCodeForExecution } from '../../src/graph/executor'
|
||||
|
||||
describe('transformCodeForExecution', () => {
|
||||
describe('single-line imports', () => {
|
||||
it('removes default import', () => {
|
||||
const code = `import foo from 'pkg'
|
||||
const x = 1`
|
||||
const result = transformCodeForExecution(code)
|
||||
assert.strictEqual(result.trim(), 'const x = 1')
|
||||
})
|
||||
|
||||
it('removes named import', () => {
|
||||
const code = `import { foo } from 'pkg'
|
||||
const x = 1`
|
||||
const result = transformCodeForExecution(code)
|
||||
assert.strictEqual(result.trim(), 'const x = 1')
|
||||
})
|
||||
|
||||
it('removes multiple named imports', () => {
|
||||
const code = `import { foo, bar, baz } from 'pkg'
|
||||
const x = 1`
|
||||
const result = transformCodeForExecution(code)
|
||||
assert.strictEqual(result.trim(), 'const x = 1')
|
||||
})
|
||||
|
||||
it('removes namespace import', () => {
|
||||
const code = `import * as pkg from 'pkg'
|
||||
const x = 1`
|
||||
const result = transformCodeForExecution(code)
|
||||
assert.strictEqual(result.trim(), 'const x = 1')
|
||||
})
|
||||
|
||||
it('removes side-effect import', () => {
|
||||
const code = `import 'side-effect'
|
||||
const x = 1`
|
||||
const result = transformCodeForExecution(code)
|
||||
assert.strictEqual(result.trim(), 'const x = 1')
|
||||
})
|
||||
|
||||
it('removes default + named import', () => {
|
||||
const code = `import foo, { bar } from 'pkg'
|
||||
const x = 1`
|
||||
const result = transformCodeForExecution(code)
|
||||
assert.strictEqual(result.trim(), 'const x = 1')
|
||||
})
|
||||
|
||||
it('removes import with alias', () => {
|
||||
const code = `import { foo as f } from 'pkg'
|
||||
const x = 1`
|
||||
const result = transformCodeForExecution(code)
|
||||
assert.strictEqual(result.trim(), 'const x = 1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('type imports', () => {
|
||||
it('removes type import', () => {
|
||||
const code = `import type { Foo } from 'pkg'
|
||||
const x = 1`
|
||||
const result = transformCodeForExecution(code)
|
||||
assert.strictEqual(result.trim(), 'const x = 1')
|
||||
})
|
||||
|
||||
it('removes type default import', () => {
|
||||
const code = `import type Foo from 'pkg'
|
||||
const x = 1`
|
||||
const result = transformCodeForExecution(code)
|
||||
assert.strictEqual(result.trim(), 'const x = 1')
|
||||
})
|
||||
|
||||
it('removes inline type specifier', () => {
|
||||
const code = `import { type Foo, bar } from 'pkg'
|
||||
const x = 1`
|
||||
const result = transformCodeForExecution(code)
|
||||
assert.strictEqual(result.trim(), 'const x = 1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('multi-line imports', () => {
|
||||
it('removes multi-line named imports', () => {
|
||||
const code = `import {
|
||||
foo,
|
||||
bar,
|
||||
} from 'pkg'
|
||||
const x = 1`
|
||||
const result = transformCodeForExecution(code)
|
||||
assert.strictEqual(result.trim(), 'const x = 1')
|
||||
})
|
||||
|
||||
it('removes multi-line type imports', () => {
|
||||
const code = `import type {
|
||||
Foo,
|
||||
Bar,
|
||||
} from 'pkg'
|
||||
const x = 1`
|
||||
const result = transformCodeForExecution(code)
|
||||
assert.strictEqual(result.trim(), 'const x = 1')
|
||||
})
|
||||
|
||||
it('removes multi-line imports with aliases', () => {
|
||||
const code = `import {
|
||||
foo as f,
|
||||
bar as b,
|
||||
} from 'pkg'
|
||||
const x = 1`
|
||||
const result = transformCodeForExecution(code)
|
||||
assert.strictEqual(result.trim(), 'const x = 1')
|
||||
})
|
||||
|
||||
it('removes deeply nested multi-line imports', () => {
|
||||
const code = `import {
|
||||
foo,
|
||||
bar,
|
||||
baz,
|
||||
qux,
|
||||
} from '@scoped/package-name'
|
||||
const x = 1`
|
||||
const result = transformCodeForExecution(code)
|
||||
assert.strictEqual(result.trim(), 'const x = 1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('quote styles', () => {
|
||||
it('handles single quotes', () => {
|
||||
const code = `import foo from 'pkg'
|
||||
const x = 1`
|
||||
const result = transformCodeForExecution(code)
|
||||
assert.strictEqual(result.trim(), 'const x = 1')
|
||||
})
|
||||
|
||||
it('handles double quotes', () => {
|
||||
const code = `import foo from "pkg"
|
||||
const x = 1`
|
||||
const result = transformCodeForExecution(code)
|
||||
assert.strictEqual(result.trim(), 'const x = 1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('multiple imports', () => {
|
||||
it('removes all imports from different packages', () => {
|
||||
const code = `import { z } from 'zod'
|
||||
import { Agent } from '@browseros-ai/agent-sdk'
|
||||
import type { Config } from './types'
|
||||
const x = 1`
|
||||
const result = transformCodeForExecution(code)
|
||||
assert.strictEqual(result.trim(), 'const x = 1')
|
||||
})
|
||||
|
||||
it('removes mixed single and multi-line imports', () => {
|
||||
const code = `import foo from 'foo'
|
||||
import {
|
||||
bar,
|
||||
baz,
|
||||
} from 'bar'
|
||||
import qux from 'qux'
|
||||
const x = 1`
|
||||
const result = transformCodeForExecution(code)
|
||||
assert.strictEqual(result.trim(), 'const x = 1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('indentation', () => {
|
||||
it('removes indented imports', () => {
|
||||
const code = ` import foo from 'pkg'
|
||||
const x = 1`
|
||||
const result = transformCodeForExecution(code)
|
||||
assert.strictEqual(result.trim(), 'const x = 1')
|
||||
})
|
||||
|
||||
it('removes tab-indented imports', () => {
|
||||
const code = `\timport foo from 'pkg'
|
||||
const x = 1`
|
||||
const result = transformCodeForExecution(code)
|
||||
assert.strictEqual(result.trim(), 'const x = 1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('preserves non-import code', () => {
|
||||
it('preserves all code after imports', () => {
|
||||
const code = `import foo from 'pkg'
|
||||
|
||||
export async function run(agent) {
|
||||
await agent.navigate('https://example.com')
|
||||
return 'done'
|
||||
}`
|
||||
const result = transformCodeForExecution(code)
|
||||
assert.ok(result.includes('export async function run(agent)'))
|
||||
assert.ok(result.includes("await agent.navigate('https://example.com')"))
|
||||
assert.ok(result.includes("return 'done'"))
|
||||
assert.ok(!result.includes('import'))
|
||||
})
|
||||
|
||||
it('preserves code with import-like strings', () => {
|
||||
const code = `import foo from 'pkg'
|
||||
const str = "import { x } from 'y'"
|
||||
const x = 1`
|
||||
const result = transformCodeForExecution(code)
|
||||
assert.ok(result.includes(`const str = "import { x } from 'y'"`))
|
||||
assert.ok(result.includes('const x = 1'))
|
||||
})
|
||||
|
||||
it('preserves dynamic imports', () => {
|
||||
const code = `import foo from 'pkg'
|
||||
const mod = await import('./dynamic')
|
||||
const x = 1`
|
||||
const result = transformCodeForExecution(code)
|
||||
assert.ok(result.includes("const mod = await import('./dynamic')"))
|
||||
assert.ok(result.includes('const x = 1'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('scoped packages', () => {
|
||||
it('removes @scoped/package imports', () => {
|
||||
const code = `import { Agent } from '@browseros-ai/agent-sdk'
|
||||
const x = 1`
|
||||
const result = transformCodeForExecution(code)
|
||||
assert.strictEqual(result.trim(), 'const x = 1')
|
||||
})
|
||||
|
||||
it('removes deeply scoped package imports', () => {
|
||||
const code = `import { foo } from '@org/pkg/sub/path'
|
||||
const x = 1`
|
||||
const result = transformCodeForExecution(code)
|
||||
assert.strictEqual(result.trim(), 'const x = 1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('relative imports', () => {
|
||||
it('removes relative imports', () => {
|
||||
const code = `import foo from './foo'
|
||||
const x = 1`
|
||||
const result = transformCodeForExecution(code)
|
||||
assert.strictEqual(result.trim(), 'const x = 1')
|
||||
})
|
||||
|
||||
it('removes parent directory imports', () => {
|
||||
const code = `import foo from '../foo'
|
||||
const x = 1`
|
||||
const result = transformCodeForExecution(code)
|
||||
assert.strictEqual(result.trim(), 'const x = 1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles empty code', () => {
|
||||
const result = transformCodeForExecution('')
|
||||
assert.strictEqual(result, '')
|
||||
})
|
||||
|
||||
it('handles code with no imports', () => {
|
||||
const code = `const x = 1
|
||||
const y = 2`
|
||||
const result = transformCodeForExecution(code)
|
||||
assert.strictEqual(result, code)
|
||||
})
|
||||
|
||||
it('handles code with only imports', () => {
|
||||
const code = `import foo from 'foo'
|
||||
import bar from 'bar'`
|
||||
const result = transformCodeForExecution(code)
|
||||
assert.strictEqual(result.trim(), '')
|
||||
})
|
||||
|
||||
it('handles imports with trailing semicolons', () => {
|
||||
const code = `import foo from 'pkg';
|
||||
const x = 1`
|
||||
const result = transformCodeForExecution(code)
|
||||
assert.strictEqual(result.trim(), 'const x = 1')
|
||||
})
|
||||
|
||||
it('handles imports with trailing comments', () => {
|
||||
const code = `import foo from 'pkg' // comment
|
||||
const x = 1`
|
||||
const result = transformCodeForExecution(code)
|
||||
assert.strictEqual(result.trim(), 'const x = 1')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"apps/agent": {
|
||||
"name": "@browseros/agent",
|
||||
"version": "0.0.98",
|
||||
"version": "0.0.99",
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^3.0.96",
|
||||
"@browseros/server": "workspace:*",
|
||||
@@ -152,7 +152,7 @@
|
||||
},
|
||||
"apps/server": {
|
||||
"name": "@browseros/server",
|
||||
"version": "0.0.80",
|
||||
"version": "0.0.82",
|
||||
"bin": {
|
||||
"browseros-server": "./src/index.ts",
|
||||
},
|
||||
|
||||
@@ -141,21 +141,6 @@ Prefix: `browseros.native.extension.`
|
||||
| `settings.scheduled_task.cancelled` | — | Running task was cancelled |
|
||||
| `settings.scheduled_task.retried` | — | Task run was retried |
|
||||
|
||||
### Settings — Workflows
|
||||
|
||||
| Event | Properties | Description |
|
||||
|-------|-----------|-------------|
|
||||
| `settings.graph.created` | — | New workflow graph created |
|
||||
| `settings.graph.saved` | — | Workflow graph saved |
|
||||
| `settings.graph.updated` | — | Workflow graph updated |
|
||||
| `settings.graph.message.like` | — | Workflow message liked |
|
||||
| `settings.graph.message.dislike` | — | Workflow message disliked |
|
||||
| `settings.workflow.deleted` | — | Workflow deleted |
|
||||
| `settings.workflow.run_started` | — | Workflow run started |
|
||||
| `settings.workflow.run_stopped` | — | Workflow run stopped |
|
||||
| `settings.workflow.run_retried` | — | Workflow run retried |
|
||||
| `settings.workflow.run_completed` | — | Workflow run completed |
|
||||
|
||||
### Onboarding
|
||||
|
||||
| Event | Properties | Description |
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"start:agent": "bun run --filter @browseros/agent dev",
|
||||
"build": "bun run build:server && bun run build:agent",
|
||||
"build:server": "FORCE_COLOR=1 bun scripts/build/server.ts --target=all",
|
||||
"build:server:ci": "FORCE_COLOR=1 bun scripts/build/server.ts --target=all --compile-only",
|
||||
"build:server:ci": "FORCE_COLOR=1 bun scripts/build/server.ts --target=all --ci",
|
||||
"build:server:test": "FORCE_COLOR=1 bun scripts/build/server.ts --target=darwin-arm64 --no-upload",
|
||||
"upload:cli-installers": "bun scripts/build/cli.ts",
|
||||
"start:server:test": "bun run build:server:test && set -a && . apps/server/.env.development && set +a && dist/prod/server/.tmp/binaries/browseros-server-darwin-arm64",
|
||||
|
||||
@@ -37,29 +37,39 @@ export async function archiveAndUploadArtifacts(
|
||||
r2: R2Config,
|
||||
upload: boolean,
|
||||
): Promise<UploadResult[]> {
|
||||
const results: UploadResult[] = []
|
||||
const results = await archiveArtifacts(artifacts)
|
||||
if (!upload) {
|
||||
return results
|
||||
}
|
||||
|
||||
for (const artifact of artifacts) {
|
||||
const zipPath = zipPathForArtifact(artifact)
|
||||
await zipArtifactRoot(artifact.rootDir, zipPath)
|
||||
|
||||
if (!upload) {
|
||||
results.push({ targetId: artifact.target.id, zipPath })
|
||||
continue
|
||||
}
|
||||
|
||||
const fileName = basename(zipPath)
|
||||
const uploadedResults: UploadResult[] = []
|
||||
for (const result of results) {
|
||||
const fileName = basename(result.zipPath)
|
||||
const latestR2Key = joinObjectKey(r2.uploadPrefix, 'latest', fileName)
|
||||
const versionR2Key = joinObjectKey(r2.uploadPrefix, version, fileName)
|
||||
await uploadFileToObject(client, r2, latestR2Key, zipPath)
|
||||
await uploadFileToObject(client, r2, versionR2Key, zipPath)
|
||||
results.push({
|
||||
targetId: artifact.target.id,
|
||||
zipPath,
|
||||
await uploadFileToObject(client, r2, latestR2Key, result.zipPath)
|
||||
await uploadFileToObject(client, r2, versionR2Key, result.zipPath)
|
||||
uploadedResults.push({
|
||||
targetId: result.targetId,
|
||||
zipPath: result.zipPath,
|
||||
latestR2Key,
|
||||
versionR2Key,
|
||||
})
|
||||
}
|
||||
|
||||
return uploadedResults
|
||||
}
|
||||
|
||||
export async function archiveArtifacts(
|
||||
artifacts: StagedArtifact[],
|
||||
): Promise<UploadResult[]> {
|
||||
const results: UploadResult[] = []
|
||||
|
||||
for (const artifact of artifacts) {
|
||||
const zipPath = zipPathForArtifact(artifact)
|
||||
await zipArtifactRoot(artifact.rootDir, zipPath)
|
||||
results.push({ targetId: artifact.target.id, zipPath })
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
@@ -22,23 +22,26 @@ export function parseBuildArgs(argv: string[]): BuildArgs {
|
||||
.option('--upload', 'Upload artifact zips to R2')
|
||||
.option('--no-upload', 'Skip zip upload to R2')
|
||||
.option(
|
||||
'--compile-only',
|
||||
'Compile binaries only (skip R2 staging and upload)',
|
||||
'--ci',
|
||||
'Build local release zip artifacts for CI without R2 and without requiring production env secrets',
|
||||
)
|
||||
program.parse(argv, { from: 'user' })
|
||||
const options = program.opts<{
|
||||
target: string
|
||||
manifest: string
|
||||
upload: boolean
|
||||
compileOnly: boolean
|
||||
ci: boolean
|
||||
}>()
|
||||
|
||||
const compileOnly = options.compileOnly ?? false
|
||||
const ci = options.ci ?? false
|
||||
if (ci && options.upload) {
|
||||
throw new Error('--ci cannot be combined with --upload')
|
||||
}
|
||||
|
||||
return {
|
||||
targets: resolveTargets(options.target),
|
||||
manifestPath: options.manifest,
|
||||
upload: compileOnly ? false : (options.upload ?? true),
|
||||
compileOnly,
|
||||
upload: ci ? false : (options.upload ?? true),
|
||||
ci,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mkdirSync, rmSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { log } from '../log'
|
||||
import { wasmBinaryPlugin } from '../plugins/wasm-binary'
|
||||
import { runCommand } from './command'
|
||||
import type { BuildTarget, CompiledServerBinary } from './types'
|
||||
@@ -52,6 +53,7 @@ async function bundleServer(
|
||||
async function compileTarget(
|
||||
target: BuildTarget,
|
||||
env: NodeJS.ProcessEnv,
|
||||
ci: boolean,
|
||||
): Promise<string> {
|
||||
const binaryPath = compiledBinaryPath(target)
|
||||
const args = [
|
||||
@@ -66,11 +68,15 @@ async function compileTarget(
|
||||
await runCommand('bun', args, env)
|
||||
|
||||
if (target.os === 'windows') {
|
||||
await runCommand(
|
||||
'bun',
|
||||
['scripts/patch-windows-exe.ts', binaryPath],
|
||||
process.env,
|
||||
)
|
||||
if (ci) {
|
||||
log.warn('Skipping Windows exe metadata patching in CI mode')
|
||||
} else {
|
||||
await runCommand(
|
||||
'bun',
|
||||
['scripts/patch-windows-exe.ts', binaryPath],
|
||||
process.env,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return binaryPath
|
||||
@@ -81,14 +87,16 @@ export async function compileServerBinaries(
|
||||
envVars: Record<string, string>,
|
||||
processEnv: NodeJS.ProcessEnv,
|
||||
version: string,
|
||||
options?: { ci?: boolean },
|
||||
): Promise<CompiledServerBinary[]> {
|
||||
const ci = options?.ci ?? false
|
||||
rmSync(TMP_ROOT, { recursive: true, force: true })
|
||||
mkdirSync(BINARIES_DIR, { recursive: true })
|
||||
await bundleServer(envVars, version)
|
||||
|
||||
const compiled: CompiledServerBinary[] = []
|
||||
for (const target of targets) {
|
||||
const binaryPath = await compileTarget(target, processEnv)
|
||||
const binaryPath = await compileTarget(target, processEnv, ci)
|
||||
compiled.push({ target, binaryPath })
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ function validateProductionEnv(envVars: Record<string, string>): void {
|
||||
}
|
||||
|
||||
export interface LoadBuildConfigOptions {
|
||||
compileOnly?: boolean
|
||||
ci?: boolean
|
||||
}
|
||||
|
||||
export function loadBuildConfig(
|
||||
@@ -84,7 +84,9 @@ export function loadBuildConfig(
|
||||
): BuildConfig {
|
||||
const fileEnv = loadProdEnv(rootDir)
|
||||
const envVars = buildInlineEnv(fileEnv)
|
||||
validateProductionEnv(envVars)
|
||||
if (!options.ci) {
|
||||
validateProductionEnv(envVars)
|
||||
}
|
||||
|
||||
const processEnv: NodeJS.ProcessEnv = {
|
||||
PATH: process.env.PATH ?? '',
|
||||
@@ -92,7 +94,7 @@ export function loadBuildConfig(
|
||||
...process.env,
|
||||
}
|
||||
|
||||
if (options.compileOnly) {
|
||||
if (options.ci) {
|
||||
return { version: readServerVersion(rootDir), envVars, processEnv }
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,17 @@ import { existsSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
import { log } from '../log'
|
||||
import { archiveAndUploadArtifacts } from './archive'
|
||||
import { archiveAndUploadArtifacts, archiveArtifacts } from './archive'
|
||||
import { parseBuildArgs } from './cli'
|
||||
import { compileServerBinaries, getDistProdRoot } from './compile'
|
||||
import { loadBuildConfig } from './config'
|
||||
import { getTargetRules, loadManifest } from './manifest'
|
||||
import { createR2Client } from './r2'
|
||||
import { stageTargetArtifact } from './stage'
|
||||
import { stageCompiledArtifact, stageTargetArtifact } from './stage'
|
||||
|
||||
function buildModeLabel(ci: boolean): string {
|
||||
return ci ? 'ci' : 'full'
|
||||
}
|
||||
|
||||
export async function runProdResourceBuild(argv: string[]): Promise<void> {
|
||||
const rootDir = resolve(import.meta.dir, '../../..')
|
||||
@@ -16,25 +20,40 @@ export async function runProdResourceBuild(argv: string[]): Promise<void> {
|
||||
|
||||
const args = parseBuildArgs(argv)
|
||||
|
||||
const buildConfig = loadBuildConfig(rootDir, {
|
||||
compileOnly: args.compileOnly,
|
||||
})
|
||||
const buildConfig = loadBuildConfig(rootDir, { ci: args.ci })
|
||||
|
||||
log.header(`Building BrowserOS server artifacts v${buildConfig.version}`)
|
||||
log.info(`Targets: ${args.targets.map((target) => target.id).join(', ')}`)
|
||||
log.info(`Mode: ${args.compileOnly ? 'compile-only' : 'full'}`)
|
||||
log.info(`Mode: ${buildModeLabel(args.ci)}`)
|
||||
|
||||
const compiled = await compileServerBinaries(
|
||||
args.targets,
|
||||
buildConfig.envVars,
|
||||
buildConfig.processEnv,
|
||||
buildConfig.version,
|
||||
{ ci: args.ci },
|
||||
)
|
||||
|
||||
if (args.compileOnly) {
|
||||
log.done('Compile-only build completed')
|
||||
if (args.ci) {
|
||||
const distRoot = getDistProdRoot()
|
||||
const localArtifacts = []
|
||||
|
||||
for (const binary of compiled) {
|
||||
log.info(`${binary.target.id}: ${binary.binaryPath}`)
|
||||
log.step(`Packaging ${binary.target.name}`)
|
||||
const staged = await stageCompiledArtifact(
|
||||
distRoot,
|
||||
binary.binaryPath,
|
||||
binary.target,
|
||||
buildConfig.version,
|
||||
)
|
||||
localArtifacts.push(staged)
|
||||
log.success(`Packaged ${binary.target.id}`)
|
||||
}
|
||||
|
||||
const archiveResults = await archiveArtifacts(localArtifacts)
|
||||
log.done('CI build completed')
|
||||
for (const result of archiveResults) {
|
||||
log.info(`${result.targetId}: ${result.zipPath}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -32,6 +32,36 @@ async function copyServerBinary(
|
||||
}
|
||||
}
|
||||
|
||||
async function createArtifactRoot(
|
||||
distRoot: string,
|
||||
compiledBinaryPath: string,
|
||||
target: BuildTarget,
|
||||
): Promise<string> {
|
||||
const rootDir = artifactRoot(distRoot, target)
|
||||
await rm(rootDir, { recursive: true, force: true })
|
||||
await mkdir(rootDir, { recursive: true })
|
||||
await copyServerBinary(
|
||||
compiledBinaryPath,
|
||||
serverDestinationPath(rootDir, target),
|
||||
target,
|
||||
)
|
||||
return rootDir
|
||||
}
|
||||
|
||||
async function finalizeArtifact(
|
||||
rootDir: string,
|
||||
target: BuildTarget,
|
||||
version: string,
|
||||
): Promise<StagedArtifact> {
|
||||
const metadataPath = await writeArtifactMetadata(rootDir, target, version)
|
||||
return {
|
||||
target,
|
||||
rootDir,
|
||||
resourcesDir: join(rootDir, 'resources'),
|
||||
metadataPath,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDestination(rootDir: string, destination: string): string {
|
||||
const outputPath = join(rootDir, destination)
|
||||
const relativePath = relative(rootDir, outputPath)
|
||||
@@ -67,25 +97,21 @@ export async function stageTargetArtifact(
|
||||
r2: R2Config,
|
||||
version: string,
|
||||
): Promise<StagedArtifact> {
|
||||
const rootDir = artifactRoot(distRoot, target)
|
||||
await rm(rootDir, { recursive: true, force: true })
|
||||
await mkdir(rootDir, { recursive: true })
|
||||
|
||||
await copyServerBinary(
|
||||
compiledBinaryPath,
|
||||
serverDestinationPath(rootDir, target),
|
||||
target,
|
||||
)
|
||||
const rootDir = await createArtifactRoot(distRoot, compiledBinaryPath, target)
|
||||
|
||||
for (const rule of rules) {
|
||||
await stageRule(rootDir, rule, target, client, r2)
|
||||
}
|
||||
|
||||
const metadataPath = await writeArtifactMetadata(rootDir, target, version)
|
||||
return {
|
||||
target,
|
||||
rootDir,
|
||||
resourcesDir: join(rootDir, 'resources'),
|
||||
metadataPath,
|
||||
}
|
||||
return finalizeArtifact(rootDir, target, version)
|
||||
}
|
||||
|
||||
export async function stageCompiledArtifact(
|
||||
distRoot: string,
|
||||
compiledBinaryPath: string,
|
||||
target: BuildTarget,
|
||||
version: string,
|
||||
): Promise<StagedArtifact> {
|
||||
const rootDir = await createArtifactRoot(distRoot, compiledBinaryPath, target)
|
||||
return finalizeArtifact(rootDir, target, version)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface BuildArgs {
|
||||
targets: BuildTarget[]
|
||||
manifestPath: string
|
||||
upload: boolean
|
||||
compileOnly: boolean
|
||||
ci: boolean
|
||||
}
|
||||
|
||||
export interface R2Config {
|
||||
|
||||
48
packages/browseros/build/cli/build.py
generated
48
packages/browseros/build/cli/build.py
generated
@@ -402,9 +402,11 @@ def main(
|
||||
"upload": upload,
|
||||
}
|
||||
|
||||
# Resolve build context (CONFIG mode or DIRECT mode)
|
||||
# Resolve build context (CONFIG mode or DIRECT mode).
|
||||
# Returns one Context per architecture — single-element for normal
|
||||
# builds, multi-element when YAML declares `architecture: [x64, arm64]`.
|
||||
try:
|
||||
ctx = resolve_config(cli_args, config_data)
|
||||
arch_ctxs = resolve_config(cli_args, config_data)
|
||||
except ValueError as e:
|
||||
log_error(str(e))
|
||||
raise typer.Exit(1)
|
||||
@@ -459,20 +461,40 @@ def main(
|
||||
os.environ["DEPOT_TOOLS_WIN_TOOLCHAIN"] = "0"
|
||||
log_info("Set DEPOT_TOOLS_WIN_TOOLCHAIN=0 for Windows build")
|
||||
|
||||
# Print build summary using the first context — versions and paths
|
||||
# are identical across per-arch contexts. Architecture is logged again
|
||||
# inside the loop below for multi-arch runs.
|
||||
summary_ctx = arch_ctxs[0]
|
||||
log_info(f"📍 Root: {root_dir}")
|
||||
log_info(f"📍 Chromium: {ctx.chromium_src}")
|
||||
log_info(f"📍 Architecture: {ctx.architecture}")
|
||||
log_info(f"📍 Build type: {ctx.build_type}")
|
||||
log_info(f"📍 Output: {ctx.out_dir}")
|
||||
log_info(f"📍 Semantic version: {ctx.semantic_version}")
|
||||
log_info(f"📍 Chromium version: {ctx.chromium_version}")
|
||||
log_info(f"📍 Build offset: {ctx.browseros_build_offset}")
|
||||
log_info(f"📍 Chromium: {summary_ctx.chromium_src}")
|
||||
if len(arch_ctxs) > 1:
|
||||
log_info(
|
||||
f"📍 Architectures: {[c.architecture for c in arch_ctxs]} (multi-arch loop)"
|
||||
)
|
||||
else:
|
||||
log_info(f"📍 Architecture: {summary_ctx.architecture}")
|
||||
log_info(f"📍 Build type: {summary_ctx.build_type}")
|
||||
log_info(f"📍 Semantic version: {summary_ctx.semantic_version}")
|
||||
log_info(f"📍 Chromium version: {summary_ctx.chromium_version}")
|
||||
log_info(f"📍 Build offset: {summary_ctx.browseros_build_offset}")
|
||||
log_info(f"📍 Pipeline: {' → '.join(pipeline)}")
|
||||
log_info("=" * 70)
|
||||
|
||||
# Set notification context for OS and architecture
|
||||
os_name = "macOS" if IS_MACOS() else "Windows" if IS_WINDOWS() else "Linux"
|
||||
set_build_context(os_name, ctx.architecture)
|
||||
|
||||
# Execute pipeline
|
||||
execute_pipeline(ctx, pipeline, AVAILABLE_MODULES, pipeline_name="build")
|
||||
# Execute the pipeline once per architecture. Modules see a normal
|
||||
# single-arch ctx; the runner is the only thing that knows about the
|
||||
# multi-arch loop.
|
||||
for i, arch_ctx in enumerate(arch_ctxs, start=1):
|
||||
if len(arch_ctxs) > 1:
|
||||
log_info("\n" + "#" * 70)
|
||||
log_info(
|
||||
f"# Architecture {i}/{len(arch_ctxs)}: {arch_ctx.architecture}"
|
||||
)
|
||||
log_info(f"# Output: {arch_ctx.out_dir}")
|
||||
log_info("#" * 70)
|
||||
|
||||
set_build_context(os_name, arch_ctx.architecture)
|
||||
execute_pipeline(
|
||||
arch_ctx, pipeline, AVAILABLE_MODULES, pipeline_name="build"
|
||||
)
|
||||
|
||||
91
packages/browseros/build/common/resolver.py
generated
91
packages/browseros/build/common/resolver.py
generated
@@ -26,11 +26,13 @@ from .context import Context
|
||||
from .env import EnvConfig
|
||||
from .utils import get_platform_arch, log_info
|
||||
|
||||
VALID_ARCHITECTURES = {"x64", "arm64", "universal"}
|
||||
|
||||
|
||||
def resolve_config(
|
||||
cli_args: Dict[str, Any],
|
||||
yaml_config: Optional[Dict[str, Any]] = None,
|
||||
) -> Context:
|
||||
) -> List[Context]:
|
||||
"""Resolve build configuration - single entry point.
|
||||
|
||||
Args:
|
||||
@@ -38,7 +40,9 @@ def resolve_config(
|
||||
yaml_config: Optional YAML configuration (triggers CONFIG mode)
|
||||
|
||||
Returns:
|
||||
Fully resolved Context object
|
||||
List of fully resolved Context objects. Single-element for the
|
||||
common single-arch case; multi-element when YAML declares
|
||||
`architecture: [x64, arm64]` (Linux multi-arch).
|
||||
|
||||
Raises:
|
||||
ValueError: If required fields missing or invalid
|
||||
@@ -59,7 +63,7 @@ def resolve_config(
|
||||
|
||||
def _resolve_config_mode(
|
||||
yaml_config: Dict[str, Any], cli_args: Dict[str, Any]
|
||||
) -> Context:
|
||||
) -> List[Context]:
|
||||
"""CONFIG MODE: YAML is base, CLI can override.
|
||||
|
||||
Args:
|
||||
@@ -67,7 +71,7 @@ def _resolve_config_mode(
|
||||
cli_args: CLI arguments (can override YAML values)
|
||||
|
||||
Returns:
|
||||
Context with values from YAML, optionally overridden by CLI
|
||||
List of Contexts. One per architecture when YAML provides a list.
|
||||
|
||||
Raises:
|
||||
ValueError: If required fields missing from both YAML and CLI
|
||||
@@ -94,41 +98,66 @@ def _resolve_config_mode(
|
||||
f"Expected directory with Chromium source code"
|
||||
)
|
||||
|
||||
# architecture: CLI override > YAML > platform default
|
||||
architecture = (
|
||||
cli_args.get("arch")
|
||||
or build_section.get("architecture")
|
||||
or build_section.get("arch")
|
||||
)
|
||||
arch_source = "cli" if cli_args.get("arch") else "yaml"
|
||||
if not architecture:
|
||||
architecture = get_platform_arch()
|
||||
# architecture: CLI override > YAML > platform default.
|
||||
# YAML may be a string OR a list (e.g. [x64, arm64]) — list form runs
|
||||
# the entire pipeline once per arch.
|
||||
cli_arch = cli_args.get("arch")
|
||||
yaml_arch = build_section.get("architecture") or build_section.get("arch")
|
||||
|
||||
if cli_arch:
|
||||
architectures = [cli_arch]
|
||||
arch_source = "cli"
|
||||
elif yaml_arch is not None:
|
||||
architectures = yaml_arch if isinstance(yaml_arch, list) else [yaml_arch]
|
||||
arch_source = "yaml"
|
||||
else:
|
||||
architectures = [get_platform_arch()]
|
||||
arch_source = "default"
|
||||
log_info(f"CONFIG MODE: Using platform default architecture: {architecture}")
|
||||
log_info(
|
||||
f"CONFIG MODE: Using platform default architecture: {architectures[0]}"
|
||||
)
|
||||
|
||||
for arch in architectures:
|
||||
if arch not in VALID_ARCHITECTURES:
|
||||
raise ValueError(
|
||||
f"CONFIG MODE: invalid architecture '{arch}'. "
|
||||
f"Valid: {sorted(VALID_ARCHITECTURES)}"
|
||||
)
|
||||
|
||||
# build_type: CLI override > YAML > debug
|
||||
build_type = cli_args.get("build_type") or build_section.get("type", "debug")
|
||||
build_type_source = "cli" if cli_args.get("build_type") else "yaml"
|
||||
|
||||
log_info(f"✓ CONFIG MODE: chromium_src={chromium_src} ({chromium_src_source})")
|
||||
log_info(f"✓ CONFIG MODE: architecture={architecture} ({arch_source})")
|
||||
if len(architectures) > 1:
|
||||
log_info(
|
||||
f"✓ CONFIG MODE: architectures={architectures} ({arch_source}, multi-arch loop)"
|
||||
)
|
||||
else:
|
||||
log_info(
|
||||
f"✓ CONFIG MODE: architecture={architectures[0]} ({arch_source})"
|
||||
)
|
||||
log_info(f"✓ CONFIG MODE: build_type={build_type} ({build_type_source})")
|
||||
|
||||
return Context(
|
||||
chromium_src=chromium_src,
|
||||
architecture=architecture,
|
||||
build_type=build_type,
|
||||
)
|
||||
return [
|
||||
Context(
|
||||
chromium_src=chromium_src,
|
||||
architecture=arch,
|
||||
build_type=build_type,
|
||||
)
|
||||
for arch in architectures
|
||||
]
|
||||
|
||||
|
||||
def _resolve_direct_mode(cli_args: Dict[str, Any]) -> Context:
|
||||
def _resolve_direct_mode(cli_args: Dict[str, Any]) -> List[Context]:
|
||||
"""DIRECT MODE: CLI > Env > Defaults.
|
||||
|
||||
Args:
|
||||
cli_args: CLI arguments (None if not provided by user)
|
||||
|
||||
Returns:
|
||||
Context with resolved values
|
||||
Single-element list with the resolved Context. DIRECT mode is
|
||||
always single-arch (CLI --arch is a scalar).
|
||||
|
||||
Raises:
|
||||
ValueError: If chromium_src not provided
|
||||
@@ -160,6 +189,12 @@ def _resolve_direct_mode(cli_args: Dict[str, Any]) -> Context:
|
||||
architecture = get_platform_arch()
|
||||
log_info(f"DIRECT MODE: Using platform default architecture: {architecture}")
|
||||
|
||||
if architecture not in VALID_ARCHITECTURES:
|
||||
raise ValueError(
|
||||
f"DIRECT MODE: invalid architecture '{architecture}'. "
|
||||
f"Valid: {sorted(VALID_ARCHITECTURES)}"
|
||||
)
|
||||
|
||||
# build_type: CLI > Default
|
||||
build_type = cli_args.get("build_type") or "debug"
|
||||
|
||||
@@ -167,11 +202,13 @@ def _resolve_direct_mode(cli_args: Dict[str, Any]) -> Context:
|
||||
log_info(f"✓ DIRECT MODE: architecture={architecture} (cli/env/default)")
|
||||
log_info(f"✓ DIRECT MODE: build_type={build_type} (cli/default)")
|
||||
|
||||
return Context(
|
||||
chromium_src=chromium_src,
|
||||
architecture=architecture,
|
||||
build_type=build_type,
|
||||
)
|
||||
return [
|
||||
Context(
|
||||
chromium_src=chromium_src,
|
||||
architecture=architecture,
|
||||
build_type=build_type,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def resolve_pipeline(
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
# This config packages an already-built Linux application.
|
||||
# Use this when you have a pre-built app and only need to package it.
|
||||
#
|
||||
# Expects: out/Default/chrome (Linux binary)
|
||||
# Expects: out/Default_<arch>/browseros
|
||||
# Invoke with:
|
||||
# browseros build --config build/config/package.linux.yaml --arch x64
|
||||
# browseros build --config build/config/package.linux.yaml --arch arm64
|
||||
#
|
||||
# Environment Variables:
|
||||
# Use !env tag to reference environment variables:
|
||||
@@ -11,7 +14,6 @@
|
||||
|
||||
build:
|
||||
type: release
|
||||
architecture: x64 # Linux x64
|
||||
|
||||
gn_flags:
|
||||
file: build/config/gn/flags.linux.release.gn
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
# BrowserOS Linux Release Build Configuration
|
||||
#
|
||||
# Pinned to arm64-only to validate the cross-compile sysroot bootstrap
|
||||
# end-to-end on a Linux x64 host. Flip back to `[x64, arm64]` once arm64
|
||||
# is green.
|
||||
#
|
||||
# Run:
|
||||
# browseros build --config build/config/release.linux.yaml
|
||||
#
|
||||
# Environment Variables:
|
||||
# Use !env tag to reference environment variables:
|
||||
# Example: chromium_src: !env CHROMIUM_SRC
|
||||
|
||||
build:
|
||||
type: release
|
||||
architecture: x64 # Linux x64
|
||||
architecture: arm64
|
||||
|
||||
gn_flags:
|
||||
file: build/config/gn/flags.linux.release.gn
|
||||
|
||||
# Explicit module execution order
|
||||
# Explicit module execution order. Runs once per architecture above.
|
||||
modules:
|
||||
# Phase 1: Setup
|
||||
- clean
|
||||
|
||||
94
packages/browseros/build/modules/package/linux.py
generated
94
packages/browseros/build/modules/package/linux.py
generated
@@ -17,10 +17,64 @@ from ...common.utils import (
|
||||
run_command,
|
||||
safe_rmtree,
|
||||
join_paths,
|
||||
get_platform_arch,
|
||||
IS_LINUX,
|
||||
)
|
||||
from ...common.notify import get_notifier, COLOR_GREEN
|
||||
|
||||
# Target-arch packaging metadata. These describe the artifact we're
|
||||
# producing, not the build machine. `appimage_arch` is passed to
|
||||
# appimagetool via the ARCH env var; `deb_arch` is written into the
|
||||
# .deb control file.
|
||||
LINUX_ARCHITECTURE_CONFIG = {
|
||||
"x64": {
|
||||
"appimage_arch": "x86_64",
|
||||
"deb_arch": "amd64",
|
||||
},
|
||||
"arm64": {
|
||||
"appimage_arch": "aarch64",
|
||||
"deb_arch": "arm64",
|
||||
},
|
||||
}
|
||||
|
||||
# Host-arch tool selection. appimagetool is a normal binary that runs on
|
||||
# the build machine — when cross-compiling arm64 from an x64 host, we
|
||||
# still need the x86_64 tool to actually execute. Keyed on
|
||||
# get_platform_arch() (BUILD machine arch), NOT ctx.architecture.
|
||||
LINUX_HOST_APPIMAGETOOL = {
|
||||
"x64": (
|
||||
"appimagetool-x86_64.AppImage",
|
||||
"https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage",
|
||||
),
|
||||
"arm64": (
|
||||
"appimagetool-aarch64.AppImage",
|
||||
"https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-aarch64.AppImage",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_linux_architecture_config(architecture: str) -> dict[str, str]:
|
||||
config = LINUX_ARCHITECTURE_CONFIG.get(architecture)
|
||||
if not config:
|
||||
supported = ", ".join(sorted(LINUX_ARCHITECTURE_CONFIG))
|
||||
raise ValueError(
|
||||
f"Unsupported Linux architecture: {architecture}. Supported: {supported}"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def get_host_appimagetool() -> tuple[str, str]:
|
||||
"""Return (filename, url) for the appimagetool binary that runs on
|
||||
the current build machine. Critical for cross-compile correctness."""
|
||||
host_arch = get_platform_arch()
|
||||
tool = LINUX_HOST_APPIMAGETOOL.get(host_arch)
|
||||
if not tool:
|
||||
supported = ", ".join(sorted(LINUX_HOST_APPIMAGETOOL))
|
||||
raise ValueError(
|
||||
f"No appimagetool binary for host arch '{host_arch}'. Supported: {supported}"
|
||||
)
|
||||
return tool
|
||||
|
||||
|
||||
class LinuxPackageModule(CommandModule):
|
||||
produces = ["appimage", "deb"]
|
||||
@@ -30,6 +84,10 @@ class LinuxPackageModule(CommandModule):
|
||||
def validate(self, ctx: Context) -> None:
|
||||
if not IS_LINUX():
|
||||
raise ValidationError("Linux packaging requires Linux")
|
||||
try:
|
||||
get_linux_architecture_config(ctx.architecture)
|
||||
except ValueError as exc:
|
||||
raise ValidationError(str(exc)) from exc
|
||||
|
||||
out_dir = join_paths(ctx.chromium_src, ctx.out_dir)
|
||||
chrome_binary = join_paths(out_dir, ctx.BROWSEROS_APP_NAME)
|
||||
@@ -73,7 +131,7 @@ class LinuxPackageModule(CommandModule):
|
||||
artifacts.append(deb_path.name)
|
||||
notifier.notify(
|
||||
"📦 Package Created",
|
||||
f"Linux packages created successfully",
|
||||
"Linux packages created successfully",
|
||||
{
|
||||
"Artifacts": ", ".join(artifacts),
|
||||
"Version": ctx.semantic_version,
|
||||
@@ -284,25 +342,30 @@ export CHROME_WRAPPER="${{THIS}}"
|
||||
|
||||
|
||||
def download_appimagetool(ctx: Context) -> Optional[Path]:
|
||||
"""Download appimagetool if not available"""
|
||||
"""Download the appimagetool binary that runs on the build machine.
|
||||
|
||||
Note: this is keyed on the HOST arch, not ctx.architecture. When
|
||||
cross-compiling arm64 packages from an x64 host, we still need the
|
||||
x86_64 appimagetool because the tool executes locally; the target
|
||||
arch is communicated via the ARCH env var in create_appimage().
|
||||
"""
|
||||
tool_dir = Path(join_paths(ctx.root_dir, "build", "tools"))
|
||||
tool_dir.mkdir(exist_ok=True)
|
||||
|
||||
tool_path = Path(join_paths(tool_dir, "appimagetool-x86_64.AppImage"))
|
||||
tool_filename, url = get_host_appimagetool()
|
||||
tool_path = Path(join_paths(tool_dir, tool_filename))
|
||||
|
||||
if tool_path.exists():
|
||||
log_info("✓ appimagetool already available")
|
||||
log_info(f"✓ appimagetool already available ({tool_filename})")
|
||||
return tool_path
|
||||
|
||||
log_info("📥 Downloading appimagetool...")
|
||||
url = "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage"
|
||||
|
||||
log_info(f"📥 Downloading {tool_filename}...")
|
||||
cmd = ["wget", "-O", str(tool_path), url]
|
||||
result = run_command(cmd, check=False)
|
||||
|
||||
if result.returncode == 0:
|
||||
tool_path.chmod(0o755)
|
||||
log_success("✓ Downloaded appimagetool")
|
||||
log_success(f"✓ Downloaded {tool_filename}")
|
||||
return tool_path
|
||||
else:
|
||||
log_error("Failed to download appimagetool")
|
||||
@@ -312,6 +375,7 @@ def download_appimagetool(ctx: Context) -> Optional[Path]:
|
||||
def create_appimage(ctx: Context, appdir: Path, output_path: Path) -> bool:
|
||||
"""Create AppImage from AppDir"""
|
||||
log_info("📦 Creating AppImage...")
|
||||
arch_config = get_linux_architecture_config(ctx.architecture)
|
||||
|
||||
# Download appimagetool if needed
|
||||
appimagetool = download_appimagetool(ctx)
|
||||
@@ -319,7 +383,7 @@ def create_appimage(ctx: Context, appdir: Path, output_path: Path) -> bool:
|
||||
return False
|
||||
|
||||
# Set architecture environment variable (required by appimagetool)
|
||||
arch = "x86_64" if ctx.architecture == "x64" else "aarch64"
|
||||
arch = arch_config["appimage_arch"]
|
||||
|
||||
# Create AppImage with ARCH env var set for this command only
|
||||
cmd = [
|
||||
@@ -384,7 +448,7 @@ def create_control_file(ctx: Context, debian_dir: Path) -> None:
|
||||
version = version.lstrip("v").replace(" ", "").replace("_", ".")
|
||||
|
||||
# Architecture mapping
|
||||
deb_arch = "amd64" if ctx.architecture == "x64" else "arm64"
|
||||
deb_arch = get_linux_architecture_config(ctx.architecture)["deb_arch"]
|
||||
|
||||
control_content = f"""Package: browseros
|
||||
Version: {version}
|
||||
@@ -653,7 +717,9 @@ def package_appimage(ctx: Context, package_dir: Path) -> Optional[Path]:
|
||||
"""
|
||||
log_info("🖼️ Building AppImage...")
|
||||
|
||||
appdir = Path(join_paths(package_dir, f"{ctx.BROWSEROS_APP_BASE_NAME}.AppDir"))
|
||||
appdir = Path(
|
||||
join_paths(package_dir, f"{ctx.BROWSEROS_APP_BASE_NAME}-{ctx.architecture}.AppDir")
|
||||
)
|
||||
if appdir.exists():
|
||||
safe_rmtree(appdir)
|
||||
|
||||
@@ -683,7 +749,9 @@ def package_deb(ctx: Context, package_dir: Path) -> Optional[Path]:
|
||||
"""
|
||||
log_info("📦 Building .deb package...")
|
||||
|
||||
debdir = Path(join_paths(package_dir, f"{ctx.BROWSEROS_APP_BASE_NAME}_deb"))
|
||||
debdir = Path(
|
||||
join_paths(package_dir, f"{ctx.BROWSEROS_APP_BASE_NAME}_{ctx.architecture}_deb")
|
||||
)
|
||||
if debdir.exists():
|
||||
safe_rmtree(debdir)
|
||||
|
||||
@@ -703,6 +771,8 @@ def package_deb(ctx: Context, package_dir: Path) -> Optional[Path]:
|
||||
return output_path
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def package_universal(contexts: List[Context]) -> bool:
|
||||
"""Linux doesn't support universal binaries"""
|
||||
log_warning("Universal binaries are not supported on Linux")
|
||||
|
||||
63
packages/browseros/build/modules/package/linux_test.py
generated
Normal file
63
packages/browseros/build/modules/package/linux_test.py
generated
Normal file
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for Linux packaging architecture helpers."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from build.modules.package.linux import (
|
||||
LINUX_HOST_APPIMAGETOOL,
|
||||
get_host_appimagetool,
|
||||
get_linux_architecture_config,
|
||||
)
|
||||
|
||||
|
||||
class LinuxArchitectureConfigTest(unittest.TestCase):
|
||||
def test_returns_x64_packaging_config(self) -> None:
|
||||
config = get_linux_architecture_config("x64")
|
||||
|
||||
self.assertEqual(config["appimage_arch"], "x86_64")
|
||||
self.assertEqual(config["deb_arch"], "amd64")
|
||||
|
||||
def test_returns_arm64_packaging_config(self) -> None:
|
||||
config = get_linux_architecture_config("arm64")
|
||||
|
||||
self.assertEqual(config["appimage_arch"], "aarch64")
|
||||
self.assertEqual(config["deb_arch"], "arm64")
|
||||
|
||||
def test_rejects_unsupported_architecture(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "Unsupported Linux architecture"):
|
||||
get_linux_architecture_config("universal")
|
||||
|
||||
|
||||
class HostAppImageToolTest(unittest.TestCase):
|
||||
"""The appimagetool binary must match the BUILD machine's arch, not
|
||||
the target arch — otherwise cross-compiling arm64 packages from an x64
|
||||
host fails because the aarch64 tool can't execute on x64."""
|
||||
|
||||
def test_x64_host_picks_x86_64_tool(self) -> None:
|
||||
with patch(
|
||||
"build.modules.package.linux.get_platform_arch", return_value="x64"
|
||||
):
|
||||
filename, url = get_host_appimagetool()
|
||||
|
||||
self.assertEqual(filename, "appimagetool-x86_64.AppImage")
|
||||
self.assertIn("x86_64", url)
|
||||
|
||||
def test_arm64_host_picks_aarch64_tool(self) -> None:
|
||||
with patch(
|
||||
"build.modules.package.linux.get_platform_arch", return_value="arm64"
|
||||
):
|
||||
filename, url = get_host_appimagetool()
|
||||
|
||||
self.assertEqual(filename, "appimagetool-aarch64.AppImage")
|
||||
self.assertIn("aarch64", url)
|
||||
|
||||
def test_host_lookup_independent_of_target(self) -> None:
|
||||
# Both architectures must be present in the host lookup so cross
|
||||
# builds work in either direction.
|
||||
self.assertIn("x64", LINUX_HOST_APPIMAGETOOL)
|
||||
self.assertIn("arm64", LINUX_HOST_APPIMAGETOOL)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -6,7 +6,6 @@ from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from ...common.env import EnvConfig
|
||||
from ...common.utils import log_warning
|
||||
from ..storage import get_release_json, get_r2_client, BOTO3_AVAILABLE
|
||||
|
||||
PLATFORMS = ["macos", "win", "linux"]
|
||||
@@ -24,6 +23,8 @@ DOWNLOAD_PATH_MAPPING = {
|
||||
"linux": {
|
||||
"x64_appimage": "download/BrowserOS.AppImage",
|
||||
"x64_deb": "download/BrowserOS.deb",
|
||||
"arm64_appimage": "download/BrowserOS-arm64.AppImage",
|
||||
"arm64_deb": "download/BrowserOS-arm64.deb",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
45
packages/browseros/build/modules/setup/configure.py
generated
45
packages/browseros/build/modules/setup/configure.py
generated
@@ -1,9 +1,19 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build configuration module for BrowserOS build system"""
|
||||
|
||||
import sys
|
||||
|
||||
from ...common.module import CommandModule, ValidationError
|
||||
from ...common.context import Context
|
||||
from ...common.utils import run_command, log_info, log_success, join_paths, IS_WINDOWS
|
||||
from ...common.utils import (
|
||||
run_command,
|
||||
log_info,
|
||||
log_warning,
|
||||
log_success,
|
||||
join_paths,
|
||||
IS_LINUX,
|
||||
IS_WINDOWS,
|
||||
)
|
||||
|
||||
|
||||
class ConfigureModule(CommandModule):
|
||||
@@ -25,6 +35,16 @@ class ConfigureModule(CommandModule):
|
||||
def execute(self, ctx: Context) -> None:
|
||||
log_info(f"\n⚙️ Configuring {ctx.build_type} build for {ctx.architecture}...")
|
||||
|
||||
# Linux: ensure the target-arch Debian sysroot is installed before
|
||||
# `gn gen`. sysroot.gni asserts on missing sysroots, and relying on
|
||||
# `gclient sync` DEPS hooks is fragile — the hook only fires when
|
||||
# .gclient declared the right `target_cpus` *before* sync, which
|
||||
# isn't guaranteed for chromium_src checkouts that predate
|
||||
# cross-arch support. install-sysroot.py is idempotent and fast,
|
||||
# so call it unconditionally for the target arch.
|
||||
if IS_LINUX():
|
||||
self._ensure_linux_sysroot(ctx)
|
||||
|
||||
out_path = join_paths(ctx.chromium_src, ctx.out_dir)
|
||||
out_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -43,3 +63,26 @@ class ConfigureModule(CommandModule):
|
||||
run_command(gn_args, cwd=ctx.chromium_src)
|
||||
|
||||
log_success("Build configured")
|
||||
|
||||
def _ensure_linux_sysroot(self, ctx: Context) -> None:
|
||||
install_script = (
|
||||
ctx.chromium_src / "build" / "linux" / "sysroot_scripts" / "install-sysroot.py"
|
||||
)
|
||||
if not install_script.exists():
|
||||
log_warning(
|
||||
f"⚠️ install-sysroot.py not found at {install_script}; "
|
||||
f"skipping sysroot bootstrap. gn gen will fail if the "
|
||||
f"{ctx.architecture} sysroot is missing."
|
||||
)
|
||||
return
|
||||
|
||||
# install-sysroot.py accepts our arch names directly: it translates
|
||||
# `x64`→`amd64` internally via ARCH_TRANSLATIONS, and `arm64` is a
|
||||
# valid pass-through value.
|
||||
log_info(
|
||||
f"📦 Ensuring Linux sysroot for {ctx.architecture} (idempotent)..."
|
||||
)
|
||||
run_command(
|
||||
[sys.executable, str(install_script), f"--arch={ctx.architecture}"],
|
||||
cwd=ctx.chromium_src,
|
||||
)
|
||||
|
||||
63
packages/browseros/build/modules/setup/git.py
generated
63
packages/browseros/build/modules/setup/git.py
generated
@@ -1,12 +1,24 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Git operations module for BrowserOS build system"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import tarfile
|
||||
import urllib.request
|
||||
from typing import List
|
||||
|
||||
from ...common.module import CommandModule, ValidationError
|
||||
from ...common.context import Context
|
||||
from ...common.utils import run_command, log_info, log_error, log_success, IS_WINDOWS, safe_rmtree
|
||||
from ...common.utils import (
|
||||
run_command,
|
||||
log_info,
|
||||
log_warning,
|
||||
log_error,
|
||||
log_success,
|
||||
IS_LINUX,
|
||||
IS_WINDOWS,
|
||||
safe_rmtree,
|
||||
)
|
||||
|
||||
|
||||
class GitSetupModule(CommandModule):
|
||||
@@ -32,6 +44,12 @@ class GitSetupModule(CommandModule):
|
||||
log_info(f"🔀 Checking out tag: {ctx.chromium_version}")
|
||||
run_command(["git", "checkout", f"tags/{ctx.chromium_version}"], cwd=ctx.chromium_src)
|
||||
|
||||
# On Linux, depot_tools fetches per-arch sysroots automatically when
|
||||
# `.gclient` declares `target_cpus`. Ensure both x64 and arm64 are
|
||||
# listed before sync so cross-compilation just works on x64 hosts.
|
||||
if IS_LINUX():
|
||||
self._ensure_gclient_target_cpus(ctx, ["x64", "arm64"])
|
||||
|
||||
log_info("📥 Syncing dependencies (this may take a while)...")
|
||||
if IS_WINDOWS():
|
||||
run_command(["gclient.bat", "sync", "-D", "--no-history", "--shallow"], cwd=ctx.chromium_src)
|
||||
@@ -40,6 +58,49 @@ class GitSetupModule(CommandModule):
|
||||
|
||||
log_success("Git setup complete")
|
||||
|
||||
def _ensure_gclient_target_cpus(self, ctx: Context, required: List[str]) -> None:
|
||||
"""Idempotently add `target_cpus` to .gclient so depot_tools fetches
|
||||
the matching Linux sysroots for cross-compilation.
|
||||
|
||||
depot_tools convention: .gclient lives one directory above
|
||||
chromium_src (i.e. ../.gclient). It is a Python file with a list
|
||||
of solution dicts followed by optional top-level assignments.
|
||||
We append a `target_cpus = [...]` line if missing or merge in any
|
||||
archs that aren't already present.
|
||||
"""
|
||||
gclient_path = ctx.chromium_src.parent / ".gclient"
|
||||
if not gclient_path.exists():
|
||||
log_warning(
|
||||
f"⚠️ .gclient not found at {gclient_path}; "
|
||||
f"skipping target_cpus bootstrap. "
|
||||
f"Cross-arch builds may fail until you run `fetch chromium`."
|
||||
)
|
||||
return
|
||||
|
||||
content = gclient_path.read_text()
|
||||
match = re.search(r"^\s*target_cpus\s*=\s*\[([^\]]*)\]", content, re.MULTILINE)
|
||||
|
||||
if match:
|
||||
existing = re.findall(r"['\"]([^'\"]+)['\"]", match.group(1))
|
||||
missing = [arch for arch in required if arch not in existing]
|
||||
if not missing:
|
||||
log_info(f"✓ .gclient target_cpus already includes {required}")
|
||||
return
|
||||
merged = sorted(set(existing) | set(required))
|
||||
new_line = f"target_cpus = {merged!r}"
|
||||
content = (
|
||||
content[: match.start()] + new_line + content[match.end() :]
|
||||
)
|
||||
log_info(
|
||||
f"📝 Updating .gclient target_cpus: {existing} → {merged}"
|
||||
)
|
||||
else:
|
||||
new_line = f"\ntarget_cpus = {required!r}\n"
|
||||
content = content.rstrip() + "\n" + new_line
|
||||
log_info(f"📝 Adding target_cpus = {required} to .gclient")
|
||||
|
||||
gclient_path.write_text(content)
|
||||
|
||||
def _verify_tag_exists(self, ctx: Context) -> None:
|
||||
result = subprocess.run(
|
||||
["git", "tag", "-l", ctx.chromium_version],
|
||||
|
||||
57
packages/browseros/build/modules/storage/upload.py
generated
57
packages/browseros/build/modules/storage/upload.py
generated
@@ -4,7 +4,7 @@
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from typing import Any, Dict, List, Optional, Tuple, cast
|
||||
|
||||
from ...common.module import CommandModule, ValidationError
|
||||
from ...common.context import Context
|
||||
@@ -21,6 +21,7 @@ from ...common.notify import get_notifier, COLOR_GREEN
|
||||
from .r2 import (
|
||||
BOTO3_AVAILABLE,
|
||||
get_r2_client,
|
||||
get_release_json,
|
||||
upload_file_to_r2,
|
||||
)
|
||||
|
||||
@@ -58,7 +59,10 @@ class UploadModule(CommandModule):
|
||||
log_info("\nUploading package artifacts to R2...")
|
||||
|
||||
extra_metadata = {}
|
||||
sparkle_signatures = ctx.artifacts.get("sparkle_signatures")
|
||||
sparkle_signatures = cast(
|
||||
Optional[dict[str, tuple[str, int]]],
|
||||
ctx.artifacts.get("sparkle_signatures"),
|
||||
)
|
||||
if sparkle_signatures:
|
||||
for filename, (sig, length) in sparkle_signatures.items():
|
||||
extra_metadata[filename] = {
|
||||
@@ -120,6 +124,36 @@ def generate_release_json(
|
||||
return release_data
|
||||
|
||||
|
||||
def merge_release_metadata(existing: Optional[Dict], new: Dict) -> Dict:
|
||||
if not existing:
|
||||
return new
|
||||
|
||||
merged = dict(existing)
|
||||
merged.update({key: value for key, value in new.items() if key != "artifacts"})
|
||||
|
||||
artifacts = dict(existing.get("artifacts", {}))
|
||||
artifacts.update(new.get("artifacts", {}))
|
||||
merged["artifacts"] = artifacts
|
||||
return merged
|
||||
|
||||
|
||||
def _get_linux_artifact_key(filename: str) -> Optional[str]:
|
||||
lower = filename.lower()
|
||||
|
||||
if ".appimage" in lower:
|
||||
if "arm64" in lower or "aarch64" in lower:
|
||||
return "arm64_appimage"
|
||||
if "x64" in lower or "x86_64" in lower:
|
||||
return "x64_appimage"
|
||||
elif ".deb" in lower:
|
||||
if "arm64" in lower or "aarch64" in lower:
|
||||
return "arm64_deb"
|
||||
if "amd64" in lower or "x64" in lower or "x86_64" in lower:
|
||||
return "x64_deb"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _get_artifact_key(filename: str, platform: str) -> str:
|
||||
"""Get artifact key name from filename
|
||||
|
||||
@@ -147,10 +181,10 @@ def _get_artifact_key(filename: str, platform: str) -> str:
|
||||
return "x64_zip"
|
||||
|
||||
elif platform == "linux":
|
||||
if ".appimage" in lower:
|
||||
return "x64_appimage"
|
||||
elif ".deb" in lower:
|
||||
return "x64_deb"
|
||||
artifact_key = _get_linux_artifact_key(filename)
|
||||
if artifact_key:
|
||||
return artifact_key
|
||||
log_warning(f"Unrecognized Linux artifact name: {filename}; using stem key")
|
||||
|
||||
return Path(filename).stem
|
||||
|
||||
@@ -181,7 +215,7 @@ def detect_artifacts(ctx: Context) -> List[Path]:
|
||||
|
||||
def upload_release_artifacts(
|
||||
ctx: Context,
|
||||
extra_metadata: Optional[Dict[str, Dict[str, any]]] = None,
|
||||
extra_metadata: Optional[Dict[str, Dict[str, Any]]] = None,
|
||||
) -> Tuple[bool, Optional[Dict]]:
|
||||
"""Upload release artifacts to R2 and generate release.json
|
||||
|
||||
@@ -240,6 +274,13 @@ def upload_release_artifacts(
|
||||
artifact_metadata.append(metadata)
|
||||
|
||||
release_data = generate_release_json(ctx, artifact_metadata, platform)
|
||||
if platform == "linux":
|
||||
# Linux x64 and arm64 release jobs must be sequenced. A parallel
|
||||
# fetch-merge-upload flow can still race and drop one architecture.
|
||||
existing_release_data = get_release_json(
|
||||
ctx.get_semantic_version(), platform, env
|
||||
)
|
||||
release_data = merge_release_metadata(existing_release_data, release_data)
|
||||
release_json_path = ctx.get_dist_dir() / "release.json"
|
||||
release_json_path.write_text(json.dumps(release_data, indent=2))
|
||||
|
||||
@@ -248,7 +289,7 @@ def upload_release_artifacts(
|
||||
return False, None
|
||||
|
||||
log_success(f"\nSuccessfully uploaded {len(artifacts)} artifact(s) to R2")
|
||||
log_info(f"\nRelease metadata:")
|
||||
log_info("\nRelease metadata:")
|
||||
log_info(f" Version: {release_data['version']}")
|
||||
if platform == "macos":
|
||||
log_info(f" Sparkle version: {release_data.get('sparkle_version', 'N/A')}")
|
||||
|
||||
85
packages/browseros/build/modules/storage/upload_test.py
generated
Normal file
85
packages/browseros/build/modules/storage/upload_test.py
generated
Normal file
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for release artifact upload metadata helpers."""
|
||||
|
||||
import unittest
|
||||
|
||||
from build.modules.storage.upload import _get_artifact_key, merge_release_metadata
|
||||
|
||||
|
||||
class UploadMetadataTest(unittest.TestCase):
|
||||
def test_linux_x64_artifacts_use_x64_keys(self) -> None:
|
||||
self.assertEqual(
|
||||
_get_artifact_key("BrowserOS_v1.2.3_x64.AppImage", "linux"),
|
||||
"x64_appimage",
|
||||
)
|
||||
self.assertEqual(
|
||||
_get_artifact_key("BrowserOS_v1.2.3_amd64.deb", "linux"),
|
||||
"x64_deb",
|
||||
)
|
||||
|
||||
def test_linux_arm64_artifacts_use_arm64_keys(self) -> None:
|
||||
self.assertEqual(
|
||||
_get_artifact_key("BrowserOS_v1.2.3_arm64.AppImage", "linux"),
|
||||
"arm64_appimage",
|
||||
)
|
||||
self.assertEqual(
|
||||
_get_artifact_key("BrowserOS_v1.2.3_arm64.deb", "linux"),
|
||||
"arm64_deb",
|
||||
)
|
||||
self.assertEqual(
|
||||
_get_artifact_key("BrowserOS_v1.2.3_aarch64.deb", "linux"),
|
||||
"arm64_deb",
|
||||
)
|
||||
|
||||
def test_merge_release_metadata_preserves_existing_artifacts(self) -> None:
|
||||
existing = {
|
||||
"platform": "linux",
|
||||
"version": "1.2.3",
|
||||
"build_date": "old",
|
||||
"artifacts": {
|
||||
"x64_appimage": {"filename": "BrowserOS_v1.2.3_x64.AppImage"},
|
||||
"x64_deb": {"filename": "BrowserOS_v1.2.3_amd64.deb"},
|
||||
},
|
||||
}
|
||||
new = {
|
||||
"platform": "linux",
|
||||
"version": "1.2.3",
|
||||
"build_date": "new",
|
||||
"artifacts": {
|
||||
"arm64_appimage": {"filename": "BrowserOS_v1.2.3_arm64.AppImage"},
|
||||
"arm64_deb": {"filename": "BrowserOS_v1.2.3_arm64.deb"},
|
||||
},
|
||||
}
|
||||
|
||||
merged = merge_release_metadata(existing, new)
|
||||
|
||||
self.assertEqual(merged["build_date"], "new")
|
||||
self.assertEqual(
|
||||
sorted(merged["artifacts"]),
|
||||
["arm64_appimage", "arm64_deb", "x64_appimage", "x64_deb"],
|
||||
)
|
||||
|
||||
def test_merge_release_metadata_overwrites_matching_artifact_keys(self) -> None:
|
||||
existing = {
|
||||
"platform": "linux",
|
||||
"version": "1.2.3",
|
||||
"artifacts": {
|
||||
"x64_appimage": {"filename": "old.AppImage", "size": 1},
|
||||
},
|
||||
}
|
||||
new = {
|
||||
"platform": "linux",
|
||||
"version": "1.2.3",
|
||||
"artifacts": {
|
||||
"x64_appimage": {"filename": "new.AppImage", "size": 2},
|
||||
},
|
||||
}
|
||||
|
||||
merged = merge_release_metadata(existing, new)
|
||||
|
||||
self.assertEqual(merged["artifacts"]["x64_appimage"]["filename"], "new.AppImage")
|
||||
self.assertEqual(merged["artifacts"]["x64_appimage"]["size"], 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,9 +1,9 @@
|
||||
diff --git a/chrome/browser/browseros/extensions/browseros_extension_loader.cc b/chrome/browser/browseros/extensions/browseros_extension_loader.cc
|
||||
new file mode 100644
|
||||
index 0000000000000..e61b45d08b7e2
|
||||
index 0000000000000..fdb6be443f25b
|
||||
--- /dev/null
|
||||
+++ b/chrome/browser/browseros/extensions/browseros_extension_loader.cc
|
||||
@@ -0,0 +1,226 @@
|
||||
@@ -0,0 +1,269 @@
|
||||
+// Copyright 2024 The Chromium Authors
|
||||
+// Use of this source code is governed by a BSD-style license that can be
|
||||
+// found in the LICENSE file.
|
||||
@@ -89,10 +89,53 @@ index 0000000000000..e61b45d08b7e2
|
||||
+ extension_ids_.merge(result.extension_ids);
|
||||
+ last_config_ = std::move(result.config);
|
||||
+
|
||||
+ LoadFinished(std::move(result.prefs));
|
||||
+ base::DictValue prefs_to_load = std::move(result.prefs);
|
||||
+
|
||||
+ if (prefs_to_load.empty()) {
|
||||
+ LOG(WARNING) << "browseros: Install returned empty prefs, "
|
||||
+ << "reconstructing from installed extensions";
|
||||
+ prefs_to_load = ReconstructPrefsFromInstalledExtensions();
|
||||
+ LOG(INFO) << "browseros: Reconstructed prefs for "
|
||||
+ << prefs_to_load.size() << " installed extensions";
|
||||
+ }
|
||||
+
|
||||
+ LoadFinished(std::move(prefs_to_load));
|
||||
+ OnStartupComplete(result.from_bundled);
|
||||
+}
|
||||
+
|
||||
+base::DictValue
|
||||
+BrowserOSExtensionLoader::ReconstructPrefsFromInstalledExtensions() {
|
||||
+ base::DictValue prefs;
|
||||
+
|
||||
+ extensions::ExtensionRegistry* registry =
|
||||
+ extensions::ExtensionRegistry::Get(profile_);
|
||||
+ if (!registry) {
|
||||
+ return prefs;
|
||||
+ }
|
||||
+
|
||||
+ const std::string update_url =
|
||||
+ base::FeatureList::IsEnabled(features::kBrowserOsAlphaFeatures)
|
||||
+ ? kBrowserOSAlphaUpdateUrl
|
||||
+ : kBrowserOSUpdateUrl;
|
||||
+
|
||||
+ for (const std::string& id : GetBrowserOSExtensionIds()) {
|
||||
+ const extensions::Extension* ext = registry->GetInstalledExtension(id);
|
||||
+ if (!ext) {
|
||||
+ continue;
|
||||
+ }
|
||||
+
|
||||
+ base::DictValue ext_pref;
|
||||
+ ext_pref.Set(extensions::ExternalProviderImpl::kExternalUpdateUrl,
|
||||
+ update_url);
|
||||
+ prefs.Set(id, std::move(ext_pref));
|
||||
+
|
||||
+ LOG(INFO) << "browseros: Reconstructed pref for installed extension "
|
||||
+ << id << " v" << ext->version().GetString();
|
||||
+ }
|
||||
+
|
||||
+ return prefs;
|
||||
+}
|
||||
+
|
||||
+const base::FilePath BrowserOSExtensionLoader::GetBaseCrxFilePath() {
|
||||
+ return bundled_crx_base_path_;
|
||||
+}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
diff --git a/chrome/browser/browseros/extensions/browseros_extension_loader.h b/chrome/browser/browseros/extensions/browseros_extension_loader.h
|
||||
new file mode 100644
|
||||
index 0000000000000..2a14e9068156e
|
||||
index 0000000000000..ea2c856556f5f
|
||||
--- /dev/null
|
||||
+++ b/chrome/browser/browseros/extensions/browseros_extension_loader.h
|
||||
@@ -0,0 +1,81 @@
|
||||
@@ -0,0 +1,86 @@
|
||||
+// Copyright 2024 The Chromium Authors
|
||||
+// Use of this source code is governed by a BSD-style license that can be
|
||||
+// found in the LICENSE file.
|
||||
@@ -62,6 +62,11 @@ index 0000000000000..2a14e9068156e
|
||||
+ // Convergence point for both startup paths.
|
||||
+ void OnStartupComplete(bool from_bundled);
|
||||
+
|
||||
+ // Reconstructs minimal prefs from already-installed BrowserOS extensions.
|
||||
+ // Used as a fallback when both bundled CRX and remote config fail,
|
||||
+ // preventing orphan detection from uninstalling existing extensions.
|
||||
+ base::DictValue ReconstructPrefsFromInstalledExtensions();
|
||||
+
|
||||
+ // Installs remote extensions immediately via PendingExtensionManager + updater.
|
||||
+ void InstallRemoteExtensionsNow(base::DictValue config);
|
||||
+
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
diff --git a/chrome/browser/browseros/extensions/browseros_extension_maintainer.cc b/chrome/browser/browseros/extensions/browseros_extension_maintainer.cc
|
||||
new file mode 100644
|
||||
index 0000000000000..bb33ae5d3b156
|
||||
index 0000000000000..5804d54696e8f
|
||||
--- /dev/null
|
||||
+++ b/chrome/browser/browseros/extensions/browseros_extension_maintainer.cc
|
||||
@@ -0,0 +1,395 @@
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
diff --git a/chrome/browser/devtools/protocol/browser_handler.cc b/chrome/browser/devtools/protocol/browser_handler.cc
|
||||
index 30bd52d09c3fc..33c7d6d8455fc 100644
|
||||
index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
|
||||
--- a/chrome/browser/devtools/protocol/browser_handler.cc
|
||||
+++ b/chrome/browser/devtools/protocol/browser_handler.cc
|
||||
@@ -8,19 +8,32 @@
|
||||
@@ -4,23 +4,37 @@
|
||||
|
||||
#include "chrome/browser/devtools/protocol/browser_handler.h"
|
||||
|
||||
+#include <algorithm>
|
||||
#include <set>
|
||||
#include <vector>
|
||||
|
||||
#include "base/functional/bind.h"
|
||||
@@ -35,7 +40,7 @@ index 30bd52d09c3fc..33c7d6d8455fc 100644
|
||||
#include "content/public/browser/browser_task_traits.h"
|
||||
#include "content/public/browser/browser_thread.h"
|
||||
#include "content/public/browser/devtools_agent_host.h"
|
||||
@@ -30,10 +43,21 @@
|
||||
@@ -30,10 +44,21 @@
|
||||
#include "ui/gfx/image/image.h"
|
||||
#include "ui/gfx/image/image_png_rep.h"
|
||||
|
||||
@@ -57,7 +62,7 @@ index 30bd52d09c3fc..33c7d6d8455fc 100644
|
||||
BrowserWindow* GetBrowserWindow(int window_id) {
|
||||
BrowserWindow* result = nullptr;
|
||||
ForEachCurrentBrowserWindowInterfaceOrderedByActivation(
|
||||
@@ -72,17 +96,411 @@ std::unique_ptr<protocol::Browser::Bounds> GetBrowserWindowBounds(
|
||||
@@ -72,17 +97,419 @@ std::unique_ptr<protocol::Browser::Bounds> GetBrowserWindowBounds(
|
||||
.Build();
|
||||
}
|
||||
|
||||
@@ -437,6 +442,14 @@ index 30bd52d09c3fc..33c7d6d8455fc 100644
|
||||
+ out_indices->push_back(found_index);
|
||||
+ }
|
||||
+
|
||||
+ if (!(*out_bwi)->GetTabStripModel()->SupportsTabGroups()) {
|
||||
+ return Response::ServerError("Tab grouping not supported for this window");
|
||||
+ }
|
||||
+
|
||||
+ std::ranges::sort(*out_indices);
|
||||
+ out_indices->erase(std::ranges::unique(*out_indices).begin(),
|
||||
+ out_indices->end());
|
||||
+
|
||||
+ return Response::Success();
|
||||
+}
|
||||
+
|
||||
@@ -471,7 +484,7 @@ index 30bd52d09c3fc..33c7d6d8455fc 100644
|
||||
|
||||
Response BrowserHandler::GetWindowForTarget(
|
||||
std::optional<std::string> target_id,
|
||||
@@ -120,6 +538,65 @@ Response BrowserHandler::GetWindowForTarget(
|
||||
@@ -120,6 +547,65 @@ Response BrowserHandler::GetWindowForTarget(
|
||||
return Response::Success();
|
||||
}
|
||||
|
||||
@@ -537,7 +550,7 @@ index 30bd52d09c3fc..33c7d6d8455fc 100644
|
||||
Response BrowserHandler::GetWindowBounds(
|
||||
int window_id,
|
||||
std::unique_ptr<protocol::Browser::Bounds>* out_bounds) {
|
||||
@@ -297,3 +774,910 @@ protocol::Response BrowserHandler::AddPrivacySandboxEnrollmentOverride(
|
||||
@@ -297,3 +783,909 @@ protocol::Response BrowserHandler::AddPrivacySandboxEnrollmentOverride(
|
||||
net::SchemefulSite(url_to_add));
|
||||
return Response::Success();
|
||||
}
|
||||
@@ -1447,4 +1460,3 @@ index 30bd52d09c3fc..33c7d6d8455fc 100644
|
||||
+bool BrowserHandler::IsHiddenWindow(int window_id) const {
|
||||
+ return hidden_window_ids_.contains(window_id);
|
||||
+}
|
||||
+
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
diff --git a/chrome/browser/devtools/protocol/devtools_protocol_browsertest.cc b/chrome/browser/devtools/protocol/devtools_protocol_browsertest.cc
|
||||
index e57b0883b725f..58bfa8d8f5412 100644
|
||||
--- a/chrome/browser/devtools/protocol/devtools_protocol_browsertest.cc
|
||||
+++ b/chrome/browser/devtools/protocol/devtools_protocol_browsertest.cc
|
||||
@@ -20,6 +20,7 @@
|
||||
#include "base/test/test_switches.h"
|
||||
#include "base/test/values_test_util.h"
|
||||
#include "base/threading/thread_restrictions.h"
|
||||
+#include "base/time/time.h"
|
||||
#include "base/values.h"
|
||||
#include "build/build_config.h"
|
||||
#include "chrome/browser/apps/app_service/app_service_proxy.h"
|
||||
@@ -30,6 +31,7 @@
|
||||
#include "chrome/browser/data_saver/data_saver.h"
|
||||
#include "chrome/browser/devtools/devtools_window.h"
|
||||
#include "chrome/browser/devtools/protocol/devtools_protocol_test_support.h"
|
||||
+#include "chrome/browser/history/history_service_factory.h"
|
||||
#include "chrome/browser/preloading/preloading_prefs.h"
|
||||
#include "chrome/browser/privacy_sandbox/privacy_sandbox_attestations/privacy_sandbox_attestations_mixin.h"
|
||||
#include "chrome/browser/profiles/profile.h"
|
||||
@@ -43,6 +45,8 @@
|
||||
#include "components/content_settings/core/browser/cookie_settings.h"
|
||||
#include "components/content_settings/core/common/pref_names.h"
|
||||
#include "components/custom_handlers/protocol_handler_registry.h"
|
||||
+#include "components/history/core/browser/history_service.h"
|
||||
+#include "components/history/core/test/history_service_test_util.h"
|
||||
#include "components/infobars/content/content_infobar_manager.h"
|
||||
#include "components/infobars/core/infobar.h"
|
||||
#include "components/infobars/core/infobar_delegate.h"
|
||||
@@ -2202,6 +2206,93 @@ IN_PROC_BROWSER_TEST_F(DevToolsProtocolTest,
|
||||
SendCommandSync("Target.getTargets");
|
||||
EXPECT_EQ(2u, result()->FindList("targetInfos")->size());
|
||||
}
|
||||
+
|
||||
+IN_PROC_BROWSER_TEST_F(DevToolsProtocolTest,
|
||||
+ CreateTabGroupAcceptsUnsortedTabIds) {
|
||||
+ AttachToBrowserTarget();
|
||||
+
|
||||
+ ASSERT_EQ(1, browser()->tab_strip_model()->count());
|
||||
+
|
||||
+ base::DictValue params;
|
||||
+ params.Set("url", "about:blank");
|
||||
+ params.Set("background", true);
|
||||
+ ASSERT_TRUE(SendCommandSync("Browser.createTab", params.Clone()));
|
||||
+ ASSERT_TRUE(SendCommandSync("Browser.createTab", std::move(params)));
|
||||
+
|
||||
+ const base::DictValue* tabs_result = SendCommandSync("Browser.getTabs");
|
||||
+ ASSERT_TRUE(tabs_result);
|
||||
+ const base::ListValue* tabs = tabs_result->FindList("tabs");
|
||||
+ ASSERT_TRUE(tabs);
|
||||
+ ASSERT_EQ(3u, tabs->size());
|
||||
+
|
||||
+ std::vector<int> tab_ids;
|
||||
+ tab_ids.reserve(tabs->size());
|
||||
+ for (const auto& tab : *tabs) {
|
||||
+ tab_ids.push_back(*tab.GetDict().FindInt("tabId"));
|
||||
+ }
|
||||
+
|
||||
+ base::ListValue unsorted_tab_ids;
|
||||
+ unsorted_tab_ids.Append(tab_ids[2]);
|
||||
+ unsorted_tab_ids.Append(tab_ids[0]);
|
||||
+
|
||||
+ base::DictValue create_group_params;
|
||||
+ create_group_params.Set("tabIds", std::move(unsorted_tab_ids));
|
||||
+ create_group_params.Set("title", "Unsorted");
|
||||
+
|
||||
+ const base::DictValue* create_group_result =
|
||||
+ SendCommandSync("Browser.createTabGroup", std::move(create_group_params));
|
||||
+ ASSERT_TRUE(create_group_result);
|
||||
+ ASSERT_FALSE(error());
|
||||
+
|
||||
+ const base::DictValue* group = create_group_result->FindDict("group");
|
||||
+ ASSERT_TRUE(group);
|
||||
+ const base::ListValue* grouped_tab_ids = group->FindList("tabIds");
|
||||
+ ASSERT_TRUE(grouped_tab_ids);
|
||||
+ ASSERT_EQ(2u, grouped_tab_ids->size());
|
||||
+ EXPECT_EQ(tab_ids[0], *grouped_tab_ids->front().GetIfInt());
|
||||
+ EXPECT_EQ(tab_ids[2], *grouped_tab_ids->back().GetIfInt());
|
||||
+ EXPECT_EQ("Unsorted", *group->FindString("title"));
|
||||
+}
|
||||
+
|
||||
+IN_PROC_BROWSER_TEST_F(DevToolsProtocolTest, HistorySearchUsesVisitTime) {
|
||||
+ AttachToBrowserTarget();
|
||||
+
|
||||
+ history::HistoryService* history_service =
|
||||
+ HistoryServiceFactory::GetForProfile(browser()->profile(),
|
||||
+ ServiceAccessType::EXPLICIT_ACCESS);
|
||||
+ ui_test_utils::WaitForHistoryToLoad(history_service);
|
||||
+
|
||||
+ const GURL url("https://history-timestamp-test.example/path");
|
||||
+ const base::Time older_visit = base::Time::Now() - base::Days(2);
|
||||
+ const base::Time newer_visit = base::Time::Now() - base::Hours(1);
|
||||
+
|
||||
+ history_service->AddPage(url, older_visit, history::SOURCE_BROWSED);
|
||||
+ history_service->AddPage(url, newer_visit, history::SOURCE_BROWSED);
|
||||
+ history::BlockUntilHistoryProcessesPendingRequests(history_service);
|
||||
+
|
||||
+ base::DictValue search_params;
|
||||
+ search_params.Set("query", "");
|
||||
+ search_params.Set(
|
||||
+ "startTime",
|
||||
+ (older_visit - base::Minutes(1)).InMillisecondsFSinceUnixEpoch());
|
||||
+ search_params.Set(
|
||||
+ "endTime",
|
||||
+ (newer_visit - base::Minutes(1)).InMillisecondsFSinceUnixEpoch());
|
||||
+
|
||||
+ const base::DictValue* search_result =
|
||||
+ SendCommandSync("History.search", std::move(search_params));
|
||||
+ ASSERT_TRUE(search_result);
|
||||
+ ASSERT_FALSE(error());
|
||||
+
|
||||
+ const base::ListValue* entries = search_result->FindList("entries");
|
||||
+ ASSERT_TRUE(entries);
|
||||
+ ASSERT_EQ(1u, entries->size());
|
||||
+
|
||||
+ const base::DictValue& entry = entries->front().GetDict();
|
||||
+ EXPECT_EQ(url.spec(), *entry.FindString("url"));
|
||||
+ EXPECT_EQ(older_visit.InMillisecondsFSinceUnixEpoch(),
|
||||
+ *entry.FindDouble("lastVisitTime"));
|
||||
+}
|
||||
#endif // !BUILDFLAG(IS_ANDROID)
|
||||
|
||||
#if !BUILDFLAG(IS_ANDROID)
|
||||
@@ -1,6 +1,6 @@
|
||||
diff --git a/chrome/browser/devtools/protocol/history_handler.cc b/chrome/browser/devtools/protocol/history_handler.cc
|
||||
new file mode 100644
|
||||
index 0000000000000..689f6e900a968
|
||||
index 0000000000000..4087a679a527f
|
||||
--- /dev/null
|
||||
+++ b/chrome/browser/devtools/protocol/history_handler.cc
|
||||
@@ -0,0 +1,188 @@
|
||||
@@ -36,7 +36,7 @@ index 0000000000000..689f6e900a968
|
||||
+ .SetId(base::NumberToString(result.id()))
|
||||
+ .SetUrl(result.url().spec())
|
||||
+ .SetTitle(base::UTF16ToUTF8(result.title()))
|
||||
+ .SetLastVisitTime(result.last_visit().InMillisecondsFSinceUnixEpoch())
|
||||
+ .SetLastVisitTime(result.visit_time().InMillisecondsFSinceUnixEpoch())
|
||||
+ .SetVisitCount(result.visit_count())
|
||||
+ .SetTypedCount(result.typed_count())
|
||||
+ .Build();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
diff --git a/chrome/browser/extensions/chrome_extension_registrar_delegate.cc b/chrome/browser/extensions/chrome_extension_registrar_delegate.cc
|
||||
index 6eec0585e8925..55c2a73647527 100644
|
||||
index adfb4e4d49fa4..409e26fa1cb1b 100644
|
||||
--- a/chrome/browser/extensions/chrome_extension_registrar_delegate.cc
|
||||
+++ b/chrome/browser/extensions/chrome_extension_registrar_delegate.cc
|
||||
@@ -12,6 +12,7 @@
|
||||
@@ -10,7 +10,26 @@ index 6eec0585e8925..55c2a73647527 100644
|
||||
#include "chrome/browser/extensions/component_loader.h"
|
||||
#include "chrome/browser/extensions/corrupted_extension_reinstaller.h"
|
||||
#include "chrome/browser/extensions/data_deleter.h"
|
||||
@@ -317,6 +318,13 @@ bool ChromeExtensionRegistrarDelegate::CanDisableExtension(
|
||||
@@ -256,7 +257,17 @@ void ChromeExtensionRegistrarDelegate::PostUninstallExtension(
|
||||
}
|
||||
}
|
||||
|
||||
- DataDeleter::StartDeleting(profile_, extension.get(), subtask_done_callback);
|
||||
+ // Preserve chrome.storage.local data for BrowserOS extensions. These may be
|
||||
+ // transiently uninstalled during update cycles (e.g., when both bundled CRX
|
||||
+ // and remote config fail on startup). User configuration must survive.
|
||||
+ if (browseros::IsBrowserOSExtension(extension->id())) {
|
||||
+ LOG(INFO) << "browseros: Preserving storage for extension "
|
||||
+ << extension->id();
|
||||
+ subtask_done_callback.Run();
|
||||
+ } else {
|
||||
+ DataDeleter::StartDeleting(profile_, extension.get(),
|
||||
+ subtask_done_callback);
|
||||
+ }
|
||||
}
|
||||
|
||||
void ChromeExtensionRegistrarDelegate::DoLoadExtensionForReload(
|
||||
@@ -322,6 +333,13 @@ bool ChromeExtensionRegistrarDelegate::CanDisableExtension(
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
diff --git a/chrome/install_static/chromium_install_modes.h b/chrome/install_static/chromium_install_modes.h
|
||||
index 0cf937413e08a..a61c438a77379 100644
|
||||
index ee62888f89705..7ec72d302bc4b 100644
|
||||
--- a/chrome/install_static/chromium_install_modes.h
|
||||
+++ b/chrome/install_static/chromium_install_modes.h
|
||||
@@ -33,48 +33,49 @@ inline constexpr auto kInstallModes = std::to_array<InstallConstants>({
|
||||
@@ -21,7 +21,7 @@ inline constexpr wchar_t kCompanyPathName[] = L"";
|
||||
|
||||
// The brand-specific product name to be included as a component of the install
|
||||
// and user data directory paths.
|
||||
-inline constexpr wchar_t kProductPathName[] = L"Chromium";
|
||||
+inline constexpr wchar_t kProductPathName[] = L"BrowserOS";
|
||||
|
||||
// The brand-specific safe browsing client name.
|
||||
inline constexpr char kSafeBrowsingName[] = "chromium";
|
||||
@@ -44,48 +44,49 @@ inline constexpr auto kInstallModes = std::to_array<InstallConstants>({
|
||||
L"", // Empty install_suffix for the primary install mode.
|
||||
.logo_suffix = L"", // No logo suffix for the primary install mode.
|
||||
.app_guid =
|
||||
|
||||
Reference in New Issue
Block a user