mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
Compare commits
21 Commits
feat/gate-
...
fix/eval-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71cb158739 | ||
|
|
654a871841 | ||
|
|
ff167fb855 | ||
|
|
3d4e493512 | ||
|
|
2b53daf641 | ||
|
|
3cc946ded8 | ||
|
|
70be5c5c21 | ||
|
|
0f9d93058f | ||
|
|
cafed57832 | ||
|
|
f157436e7d | ||
|
|
ba7892322b | ||
|
|
4e90b4561a | ||
|
|
86eed82350 | ||
|
|
be6ed22af4 | ||
|
|
149cde118d | ||
|
|
9bc5e666c4 | ||
|
|
2271277b4d | ||
|
|
f865d301a2 | ||
|
|
6f398f0b36 | ||
|
|
8548bcf50a | ||
|
|
e3601bfdc1 |
89
.github/workflows/eval-weekly.yml
vendored
Normal file
89
.github/workflows/eval-weekly.yml
vendored
Normal file
@@ -0,0 +1,89 @@
|
||||
name: Weekly Eval
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every Saturday at 06:00 UTC
|
||||
- cron: '0 6 * * 6'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
config:
|
||||
description: 'Eval config file (relative to apps/eval/)'
|
||||
required: false
|
||||
default: 'configs/browseros-agent-weekly.json'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
eval:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 360
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install BrowserOS
|
||||
run: |
|
||||
wget -q https://github.com/browseros-ai/BrowserOS/releases/download/v0.44.0.1/BrowserOS_v0.44.0.1_amd64.deb
|
||||
sudo dpkg -i BrowserOS_v0.44.0.1_amd64.deb
|
||||
browseros --version || echo "BrowserOS installed at $(which browseros)"
|
||||
|
||||
- name: Install Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: packages/browseros-agent
|
||||
run: bun install --ignore-scripts && bun run build:agent-sdk
|
||||
|
||||
- name: Install captcha solver extension
|
||||
working-directory: packages/browseros-agent/apps/eval
|
||||
run: |
|
||||
mkdir -p extensions
|
||||
curl -sL -o /tmp/nopecha.zip https://github.com/NopeCHALLC/nopecha-extension/releases/latest/download/chromium_automation.zip
|
||||
unzip -qo /tmp/nopecha.zip -d extensions/nopecha
|
||||
|
||||
- name: Run eval
|
||||
working-directory: packages/browseros-agent/apps/eval
|
||||
env:
|
||||
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
BROWSEROS_BINARY: /usr/bin/browseros
|
||||
EVAL_CONFIG: ${{ github.event.inputs.config || 'configs/browseros-agent-weekly.json' }}
|
||||
run: |
|
||||
echo "Running eval with config: $EVAL_CONFIG"
|
||||
bun run src/index.ts -c "$EVAL_CONFIG"
|
||||
|
||||
- name: Upload runs to R2
|
||||
if: success()
|
||||
working-directory: packages/browseros-agent/apps/eval
|
||||
env:
|
||||
EVAL_R2_ACCOUNT_ID: ${{ secrets.EVAL_R2_ACCOUNT_ID }}
|
||||
EVAL_R2_ACCESS_KEY_ID: ${{ secrets.EVAL_R2_ACCESS_KEY_ID }}
|
||||
EVAL_R2_SECRET_ACCESS_KEY: ${{ secrets.EVAL_R2_SECRET_ACCESS_KEY }}
|
||||
EVAL_R2_BUCKET: ${{ secrets.EVAL_R2_BUCKET }}
|
||||
EVAL_R2_CDN_BASE_URL: ${{ secrets.EVAL_R2_CDN_BASE_URL }}
|
||||
EVAL_CONFIG: ${{ github.event.inputs.config || 'configs/browseros-agent-weekly.json' }}
|
||||
run: |
|
||||
CONFIG_NAME=$(basename "$EVAL_CONFIG" .json)
|
||||
bun scripts/upload-run.ts "results/$CONFIG_NAME"
|
||||
|
||||
- name: Generate trend report
|
||||
if: success()
|
||||
working-directory: packages/browseros-agent
|
||||
env:
|
||||
EVAL_R2_ACCOUNT_ID: ${{ secrets.EVAL_R2_ACCOUNT_ID }}
|
||||
EVAL_R2_ACCESS_KEY_ID: ${{ secrets.EVAL_R2_ACCESS_KEY_ID }}
|
||||
EVAL_R2_SECRET_ACCESS_KEY: ${{ secrets.EVAL_R2_SECRET_ACCESS_KEY }}
|
||||
EVAL_R2_BUCKET: ${{ secrets.EVAL_R2_BUCKET }}
|
||||
EVAL_R2_CDN_BASE_URL: ${{ secrets.EVAL_R2_CDN_BASE_URL }}
|
||||
run: bun apps/eval/scripts/weekly-report.ts /tmp/eval-report.html
|
||||
|
||||
- name: Upload report as artifact
|
||||
if: success()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: eval-report-${{ github.run_id }}
|
||||
path: /tmp/eval-report.html
|
||||
128
.github/workflows/test.yml
vendored
128
.github/workflows/test.yml
vendored
@@ -1,15 +1,44 @@
|
||||
name: Tests
|
||||
|
||||
on: []
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
paths:
|
||||
- .github/workflows/test.yml
|
||||
- packages/browseros-agent/**
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
BROWSEROS_APPIMAGE_URL: https://files.browseros.com/download/BrowserOS.AppImage
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run Tests
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 10
|
||||
name: Tests / ${{ matrix.suite }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/browseros-agent
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- suite: tools
|
||||
test_path: tests/tools
|
||||
junit_path: test-results/tools.xml
|
||||
- suite: integration
|
||||
test_path: tests/server.integration.test.ts
|
||||
junit_path: test-results/integration.xml
|
||||
- suite: sdk
|
||||
test_path: tests/sdk
|
||||
junit_path: test-results/sdk.xml
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -21,7 +50,92 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: bun ci
|
||||
|
||||
- name: Run all tests
|
||||
run: bun test:all
|
||||
- name: Resolve BrowserOS cache key
|
||||
id: browseros-cache-key
|
||||
run: |
|
||||
set -euo pipefail
|
||||
headers="$(curl -fsSI "$BROWSEROS_APPIMAGE_URL")"
|
||||
etag="$(printf '%s\n' "$headers" | awk 'BEGIN{IGNORECASE=1} /^etag:/ {sub(/\r$/, "", $2); gsub(/"/, "", $2); print $2; exit}')"
|
||||
last_modified="$(printf '%s\n' "$headers" | awk 'BEGIN{IGNORECASE=1} /^last-modified:/ {$1=""; sub(/^ /, ""); sub(/\r$/, ""); print; exit}')"
|
||||
raw_key="${etag:-$last_modified}"
|
||||
if [ -z "$raw_key" ]; then
|
||||
raw_key="$BROWSEROS_APPIMAGE_URL"
|
||||
fi
|
||||
cache_key="$(printf '%s' "$raw_key" | shasum -a 256 | awk '{print $1}')"
|
||||
echo "key=browseros-appimage-${{ runner.os }}-$cache_key" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore BrowserOS cache
|
||||
id: browseros-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: packages/browseros-agent/.ci/bin/BrowserOS.AppImage
|
||||
key: ${{ steps.browseros-cache-key.outputs.key }}
|
||||
|
||||
- name: Download BrowserOS
|
||||
if: steps.browseros-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
mkdir -p .ci/bin
|
||||
curl -fsSL "$BROWSEROS_APPIMAGE_URL" -o .ci/bin/BrowserOS.AppImage
|
||||
chmod +x .ci/bin/BrowserOS.AppImage
|
||||
|
||||
- name: Prepare BrowserOS wrapper
|
||||
run: |
|
||||
mkdir -p .ci/bin
|
||||
cat > .ci/bin/browseros <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
export APPIMAGE_EXTRACT_AND_RUN=1
|
||||
exec "$(dirname "$0")/BrowserOS.AppImage" "$@"
|
||||
EOF
|
||||
chmod +x .ci/bin/browseros
|
||||
|
||||
- name: Create server env file
|
||||
working-directory: packages/browseros-agent/apps/server
|
||||
run: cp .env.example .env.development
|
||||
|
||||
- name: Run ${{ matrix.suite }} tests
|
||||
id: test
|
||||
env:
|
||||
PUPPETEER_EXECUTABLE_PATH: /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
|
||||
BROWSEROS_BINARY: ${{ github.workspace }}/packages/browseros-agent/.ci/bin/browseros
|
||||
BROWSEROS_TEST_HEADLESS: "true"
|
||||
BROWSEROS_TEST_EXTRA_ARGS: --no-sandbox --disable-dev-shm-usage
|
||||
run: |
|
||||
set +e
|
||||
mkdir -p test-results
|
||||
cd apps/server
|
||||
bun run test:cleanup
|
||||
bun --env-file=.env.development test "${{ matrix.test_path }}" --reporter=junit --reporter-outfile="../../${{ matrix.junit_path }}"
|
||||
exit_code=$?
|
||||
cd ../..
|
||||
if [ ! -f "${{ matrix.junit_path }}" ]; then
|
||||
cat > "${{ matrix.junit_path }}" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<testsuites tests="1" failures="1">
|
||||
<testsuite name="${{ matrix.suite }}" tests="1" failures="1">
|
||||
<testcase classname="workflow" name="${{ matrix.suite }} setup">
|
||||
<failure message="Test run failed before JUnit output was written">See workflow logs for details.</failure>
|
||||
</testcase>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
EOF
|
||||
fi
|
||||
echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload JUnit XML
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: junit-${{ matrix.suite }}
|
||||
path: packages/browseros-agent/${{ matrix.junit_path }}
|
||||
|
||||
- name: Summarize suite result
|
||||
if: always()
|
||||
run: |
|
||||
if [ "${{ steps.test.outputs.exit_code }}" = "0" ]; then
|
||||
echo "### :white_check_mark: ${{ matrix.suite }} suite passed" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "### :x: ${{ matrix.suite }} suite failed (exit code ${{ steps.test.outputs.exit_code }})" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "See the uploaded \`junit-${{ matrix.suite }}\` artifact for details." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
4
.vscode/settings.json
vendored
Normal file
4
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"terminal.integrated.tabs.title": "${sequence} ${process}",
|
||||
"terminal.integrated.tabs.description": "${cwd}"
|
||||
}
|
||||
4
packages/browseros-agent/.gitignore
vendored
4
packages/browseros-agent/.gitignore
vendored
@@ -187,6 +187,10 @@ log.txt
|
||||
# Testing iteration temp files
|
||||
tmp/
|
||||
|
||||
# CI artifacts
|
||||
.ci/
|
||||
test-results/
|
||||
|
||||
# Coding agent artifacts
|
||||
.agent/
|
||||
.llm/
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Coins } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { getCreditTextColor } from '@/lib/credits/credit-colors'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CreditBadgeProps {
|
||||
credits: number
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export const CreditBadge: FC<CreditBadgeProps> = ({ credits, onClick }) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 font-medium text-xs transition-colors hover:bg-muted/50',
|
||||
getCreditTextColor(credits),
|
||||
)}
|
||||
title={`${credits} credits remaining`}
|
||||
>
|
||||
<Coins className="h-3.5 w-3.5" />
|
||||
<span>{credits}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
BookOpen,
|
||||
Bot,
|
||||
Compass,
|
||||
CreditCard,
|
||||
GitBranch,
|
||||
MessageSquare,
|
||||
Palette,
|
||||
@@ -79,6 +80,12 @@ const primarySettingsSections: NavSection[] = [
|
||||
feature: Feature.CUSTOMIZATION_SUPPORT,
|
||||
},
|
||||
{ name: 'BrowserOS as MCP', to: '/settings/mcp', icon: Server },
|
||||
{
|
||||
name: 'Usage & Billing',
|
||||
to: '/settings/usage',
|
||||
icon: CreditCard,
|
||||
feature: Feature.CREDITS_SUPPORT,
|
||||
},
|
||||
{
|
||||
name: 'Workflows',
|
||||
to: '/workflows',
|
||||
|
||||
@@ -28,6 +28,7 @@ import { ScheduledTasksPage } from './scheduled-tasks/ScheduledTasksPage'
|
||||
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 } {
|
||||
@@ -103,6 +104,7 @@ export const App: FC = () => {
|
||||
<Route path="customization" element={<CustomizationPage />} />
|
||||
<Route path="search" element={<SearchProviderPage />} />
|
||||
<Route path="survey" element={<SurveyPage {...surveyParams} />} />
|
||||
<Route path="usage" element={<UsagePage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
|
||||
@@ -200,6 +200,7 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
return supports(Feature.CHATGPT_PRO_SUPPORT)
|
||||
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') {
|
||||
|
||||
@@ -30,6 +30,7 @@ export const ProviderTemplatesSection: FC<ProviderTemplatesSectionProps> = ({
|
||||
return supports(Feature.CHATGPT_PRO_SUPPORT)
|
||||
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)
|
||||
|
||||
@@ -22,9 +22,7 @@ import {
|
||||
SCHEDULED_TASK_TOGGLED_EVENT,
|
||||
SCHEDULED_TASK_VIEW_RESULTS_EVENT,
|
||||
} from '@/lib/constants/analyticsEvents'
|
||||
import { useGraphqlMutation } from '@/lib/graphql/useGraphqlMutation'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import { DeleteScheduledJobDocument } from '@/lib/schedules/graphql/syncSchedulesDocument'
|
||||
import {
|
||||
scheduledJobRunStorage,
|
||||
useScheduledJobRuns,
|
||||
@@ -46,8 +44,6 @@ export const ScheduledTasksPage: FC = () => {
|
||||
useScheduledJobs()
|
||||
const { jobRuns, cancelJobRun } = useScheduledJobRuns()
|
||||
|
||||
const deleteRemoteJobMutation = useGraphqlMutation(DeleteScheduledJobDocument)
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string | null>(null)
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const [editingJob, setEditingJob] = useState<ScheduledJob | null>(null)
|
||||
@@ -102,7 +98,6 @@ export const ScheduledTasksPage: FC = () => {
|
||||
const confirmDelete = async () => {
|
||||
if (deleteJobId) {
|
||||
await removeJob(deleteJobId)
|
||||
deleteRemoteJobMutation.mutate({ rowId: deleteJobId })
|
||||
setDeleteJobId(null)
|
||||
track(SCHEDULED_TASK_DELETED_EVENT)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { AlertCircle, Clock, Coins, CreditCard, Zap } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
getCreditBarColor,
|
||||
getCreditTextColor,
|
||||
} from '@/lib/credits/credit-colors'
|
||||
import { useCredits } from '@/lib/credits/useCredits'
|
||||
import { BrowserOSIcon } from '@/lib/llm-providers/providerIcons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export const UsagePage: FC = () => {
|
||||
const { data, isLoading, error } = useCredits()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-12 text-muted-foreground text-sm">
|
||||
Loading usage data...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="flex items-center gap-4 rounded-xl border p-5">
|
||||
<BrowserOSIcon size={40} />
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg">Usage & Billing</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Monitor your BrowserOS AI credit usage
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-8">
|
||||
<AlertCircle className="h-6 w-6 text-muted-foreground" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Unable to load credit information
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const credits = data?.credits ?? 0
|
||||
const total = data?.dailyLimit ?? 100
|
||||
const percentage = Math.min((credits / total) * 100, 100)
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="flex items-center gap-4 rounded-xl border p-5">
|
||||
<BrowserOSIcon size={40} />
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg">Usage & Billing</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Monitor your BrowserOS AI credit usage
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border p-5">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Coins className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="font-semibold text-sm">Daily Credits</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn('font-bold text-2xl', getCreditTextColor(credits))}
|
||||
>
|
||||
{credits}
|
||||
<span className="ml-1 font-normal text-muted-foreground text-sm">
|
||||
/ {total}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 h-2.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all duration-500',
|
||||
getCreditBarColor(credits),
|
||||
)}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex items-center gap-2.5 rounded-lg bg-muted/50 px-3 py-2.5">
|
||||
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium text-xs">Resets daily</p>
|
||||
<p className="text-muted-foreground text-xs">Midnight UTC</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 rounded-lg bg-muted/50 px-3 py-2.5">
|
||||
<Zap className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium text-xs">Credits used today</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{total - credits} of {total}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<CreditCard className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-semibold text-sm">Need more credits?</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Additional credit packages coming soon
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" disabled className="opacity-50">
|
||||
Add Credits
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,8 +5,11 @@ import {
|
||||
Folder,
|
||||
Globe,
|
||||
Layers,
|
||||
Loader2,
|
||||
Mic,
|
||||
PlugZap,
|
||||
Search,
|
||||
Square,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
@@ -44,6 +47,10 @@ import {
|
||||
NEWTAB_TAB_REMOVED_EVENT,
|
||||
NEWTAB_TAB_TOGGLED_EVENT,
|
||||
NEWTAB_TABS_OPENED_EVENT,
|
||||
NEWTAB_VOICE_ERROR_EVENT,
|
||||
NEWTAB_VOICE_RECORDING_STARTED_EVENT,
|
||||
NEWTAB_VOICE_RECORDING_STOPPED_EVENT,
|
||||
NEWTAB_VOICE_TRANSCRIPTION_COMPLETED_EVENT,
|
||||
NEWTAB_WORKSPACE_OPENED_EVENT,
|
||||
} from '@/lib/constants/analyticsEvents'
|
||||
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
|
||||
@@ -53,6 +60,7 @@ import { useSyncRemoteIntegrations } from '@/lib/mcp/useSyncRemoteIntegrations'
|
||||
import { openSidePanelWithSearch } from '@/lib/messaging/sidepanel/openSidepanelWithSearch'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useVoiceInput } from '@/lib/voice/useVoiceInput'
|
||||
import { useWorkspace } from '@/lib/workspace/use-workspace'
|
||||
import { ImportDataHint } from './ImportDataHint'
|
||||
import type { SuggestionItem } from './lib/suggestions/types'
|
||||
@@ -100,6 +108,36 @@ export const NewTab = () => {
|
||||
const { data: userMCPIntegrations } = useGetUserMCPIntegrations()
|
||||
useSyncRemoteIntegrations()
|
||||
|
||||
const voice = useVoiceInput()
|
||||
|
||||
// Voice transcript → populate search input
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: only trigger on transcript/transcribing change
|
||||
useEffect(() => {
|
||||
if (voice.transcript && !voice.isTranscribing) {
|
||||
setComboboxInputValue(voice.transcript)
|
||||
track(NEWTAB_VOICE_TRANSCRIPTION_COMPLETED_EVENT)
|
||||
voice.clearTranscript()
|
||||
}
|
||||
}, [voice.transcript, voice.isTranscribing])
|
||||
|
||||
useEffect(() => {
|
||||
if (voice.error) {
|
||||
track(NEWTAB_VOICE_ERROR_EVENT, { error: voice.error })
|
||||
}
|
||||
}, [voice.error])
|
||||
|
||||
const handleStartRecording = async () => {
|
||||
const started = await voice.startRecording()
|
||||
if (started) {
|
||||
track(NEWTAB_VOICE_RECORDING_STARTED_EVENT)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStopRecording = async () => {
|
||||
await voice.stopRecording()
|
||||
track(NEWTAB_VOICE_RECORDING_STOPPED_EVENT)
|
||||
}
|
||||
|
||||
const connectedManagedServers = mcpServers.filter((s) => {
|
||||
if (s.type !== 'managed' || !s.managedServerName) return false
|
||||
return userMCPIntegrations?.integrations?.find(
|
||||
@@ -430,32 +468,89 @@ export const NewTab = () => {
|
||||
anchorRef={inputRef}
|
||||
side="bottom"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={searchPlaceholder}
|
||||
className="flex-1 border-none bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground"
|
||||
{...getInputProps({
|
||||
ref: inputRef,
|
||||
onChange: (e) => handleInputChange(e.currentTarget.value),
|
||||
onKeyDown: (e) => {
|
||||
if (!mentionStateRef.current.isOpen) return
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
closeMention()
|
||||
}
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{voice.isRecording ? (
|
||||
<div className="flex min-h-[40px] flex-1 items-center justify-center gap-1.5">
|
||||
{voice.audioLevels.map((level, i) => (
|
||||
<div
|
||||
key={i.toString()}
|
||||
className="w-1.5 rounded-full bg-red-500 transition-all duration-75"
|
||||
style={{
|
||||
height: `${Math.max(6, Math.min(28, level * 0.7))}px`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
placeholder={
|
||||
voice.isTranscribing ? 'Transcribing...' : searchPlaceholder
|
||||
}
|
||||
disabled={voice.isTranscribing}
|
||||
className="flex-1 border-none bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground disabled:opacity-60"
|
||||
{...getInputProps({
|
||||
ref: inputRef,
|
||||
onChange: (e) => handleInputChange(e.currentTarget.value),
|
||||
onKeyDown: (e) => {
|
||||
if (!mentionStateRef.current.isOpen) return
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
closeMention()
|
||||
}
|
||||
},
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
size="icon"
|
||||
className="h-10 w-10 flex-shrink-0 rounded-xl bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{voice.isRecording ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
onClick={handleStopRecording}
|
||||
className="h-10 w-10 flex-shrink-0 rounded-xl bg-red-600 text-white hover:bg-red-700"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
) : voice.isTranscribing ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled
|
||||
className="h-10 w-10 flex-shrink-0 rounded-xl"
|
||||
>
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleStartRecording}
|
||||
className="h-10 w-10 flex-shrink-0 rounded-xl text-muted-foreground transition-colors hover:text-foreground"
|
||||
title="Voice input"
|
||||
>
|
||||
<Mic className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
size="icon"
|
||||
disabled={voice.isRecording || voice.isTranscribing}
|
||||
className="h-10 w-10 flex-shrink-0 rounded-xl bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{voice.error && (
|
||||
<div className="px-5 pb-2 text-destructive text-xs">
|
||||
{voice.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{selectedTabs.length > 0 && (
|
||||
<motion.div
|
||||
|
||||
@@ -30,6 +30,7 @@ function parseErrorMessage(message: string): {
|
||||
text: string
|
||||
url?: string
|
||||
isRateLimit?: boolean
|
||||
isCreditsExhausted?: boolean
|
||||
isConnectionError?: boolean
|
||||
} {
|
||||
// Detect MCP server connection failures
|
||||
@@ -44,6 +45,19 @@ function parseErrorMessage(message: string): {
|
||||
}
|
||||
}
|
||||
|
||||
// Detect credit exhaustion from gateway
|
||||
if (
|
||||
message.includes('CREDITS_EXHAUSTED') ||
|
||||
message.includes('Daily credits exhausted')
|
||||
) {
|
||||
return {
|
||||
text: 'Daily credits exhausted. Credits reset at midnight UTC.',
|
||||
url: '/app.html#/settings/usage',
|
||||
isRateLimit: true,
|
||||
isCreditsExhausted: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Detect BrowserOS rate limit (unique pattern, no provider uses this)
|
||||
if (message.includes('BrowserOS LLM daily limit reached')) {
|
||||
return {
|
||||
@@ -70,9 +84,8 @@ function parseErrorMessage(message: string): {
|
||||
}
|
||||
|
||||
export const ChatError: FC<ChatErrorProps> = ({ error, onRetry }) => {
|
||||
const { text, url, isRateLimit, isConnectionError } = parseErrorMessage(
|
||||
error.message,
|
||||
)
|
||||
const { text, url, isRateLimit, isCreditsExhausted, isConnectionError } =
|
||||
parseErrorMessage(error.message)
|
||||
|
||||
// --- Commented out for Kimi partnership launch (restore after) ---
|
||||
// const surveyUrl = useMemo(
|
||||
@@ -128,7 +141,17 @@ export const ChatError: FC<ChatErrorProps> = ({ error, onRetry }) => {
|
||||
</p>
|
||||
)}
|
||||
--- End commented out survey code --- */}
|
||||
{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 && (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{/* biome-ignore lint/a11y/useValidAnchor: link with click tracking */}
|
||||
|
||||
@@ -3,11 +3,27 @@ import type { FC } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router'
|
||||
import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector'
|
||||
import type { Provider } from '@/components/chat/chatComponentTypes'
|
||||
import { CreditBadge } from '@/components/credits/CreditBadge'
|
||||
import { ThemeToggle } from '@/components/elements/theme-toggle'
|
||||
import { Feature } from '@/lib/browseros/capabilities'
|
||||
import { useCapabilities } from '@/lib/browseros/useCapabilities'
|
||||
import { productRepositoryUrl } from '@/lib/constants/productUrls'
|
||||
import { useCredits } from '@/lib/credits/useCredits'
|
||||
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
|
||||
import type { ProviderType } from '@/lib/llm-providers/types'
|
||||
|
||||
const CreditsBadgeWrapper: FC = () => {
|
||||
const { supports } = useCapabilities()
|
||||
const { data } = useCredits()
|
||||
if (!supports(Feature.CREDITS_SUPPORT) || data === undefined) return null
|
||||
return (
|
||||
<CreditBadge
|
||||
credits={data.credits}
|
||||
onClick={() => window.open('/app.html#/settings/usage', '_blank')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface ChatHeaderProps {
|
||||
selectedProvider: Provider
|
||||
providers: Provider[]
|
||||
@@ -61,6 +77,7 @@ export const ChatHeader: FC<ChatHeaderProps> = ({
|
||||
</span>
|
||||
</button>
|
||||
</ChatProviderSelector>
|
||||
{selectedProvider.type === 'browseros' && <CreditsBadgeWrapper />}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
useConversations,
|
||||
} from '@/lib/conversations/conversationStorage'
|
||||
import { formatConversationHistory } from '@/lib/conversations/formatConversationHistory'
|
||||
import { useInvalidateCredits } from '@/lib/credits/useCredits'
|
||||
import { declinedAppsStorage } from '@/lib/declined-apps/storage'
|
||||
import { useGraphqlQuery } from '@/lib/graphql/useGraphqlQuery'
|
||||
import { createDefaultBrowserOSProvider } from '@/lib/llm-providers/storage'
|
||||
@@ -86,6 +87,7 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
selectedLlmProvider,
|
||||
isLoadingProviders,
|
||||
} = useChatRefs()
|
||||
const invalidateCredits = useInvalidateCredits()
|
||||
|
||||
const { providers: llmProviders, setDefaultProvider } = useLlmProviders()
|
||||
|
||||
@@ -481,8 +483,14 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
} else {
|
||||
saveLocalConversation(conversationIdRef.current, messagesToSave)
|
||||
}
|
||||
|
||||
invalidateCredits()
|
||||
}, [status])
|
||||
|
||||
useEffect(() => {
|
||||
if (chatError) invalidateCredits()
|
||||
}, [chatError, invalidateCredits])
|
||||
|
||||
const isIntegrationsSynced = options?.isIntegrationsSynced ?? true
|
||||
const isIntegrationsSyncedRef = useRef(isIntegrationsSynced)
|
||||
const pendingMessageRef = useRef<{
|
||||
|
||||
@@ -49,6 +49,10 @@ export enum Feature {
|
||||
CHATGPT_PRO_SUPPORT = 'CHATGPT_PRO_SUPPORT',
|
||||
// GitHub Copilot OAuth LLM provider
|
||||
GITHUB_COPILOT_SUPPORT = 'GITHUB_COPILOT_SUPPORT',
|
||||
// Qwen Code OAuth LLM provider
|
||||
QWEN_CODE_SUPPORT = 'QWEN_CODE_SUPPORT',
|
||||
// Credit-based usage tracking
|
||||
CREDITS_SUPPORT = 'CREDITS_SUPPORT',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,6 +82,8 @@ const FEATURE_CONFIG: { [K in Feature]: FeatureConfig } = {
|
||||
[Feature.SKILLS_SUPPORT]: { minBrowserOSVersion: '0.43.0.0' },
|
||||
[Feature.CHATGPT_PRO_SUPPORT]: { minServerVersion: '0.0.77' },
|
||||
[Feature.GITHUB_COPILOT_SUPPORT]: { minServerVersion: '0.0.77' },
|
||||
[Feature.QWEN_CODE_SUPPORT]: { minServerVersion: '0.0.77' },
|
||||
[Feature.CREDITS_SUPPORT]: { minServerVersion: '0.0.78' },
|
||||
}
|
||||
|
||||
function parseVersion(version: string): number[] {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
const LOW_THRESHOLD = 30
|
||||
|
||||
export function getCreditTextColor(credits: number): string {
|
||||
if (credits <= 0) return 'text-red-500'
|
||||
if (credits <= LOW_THRESHOLD) return 'text-yellow-500'
|
||||
return 'text-green-500'
|
||||
}
|
||||
|
||||
export function getCreditBarColor(credits: number): string {
|
||||
if (credits <= 0) return 'bg-red-500'
|
||||
if (credits <= LOW_THRESHOLD) return 'bg-yellow-500'
|
||||
return 'bg-green-500'
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { getAgentServerUrl } from '@/lib/browseros/helpers'
|
||||
|
||||
export interface CreditsInfo {
|
||||
credits: number
|
||||
dailyLimit: number
|
||||
lastResetAt?: string
|
||||
}
|
||||
|
||||
const CREDITS_QUERY_KEY = ['credits']
|
||||
|
||||
async function fetchCredits(): Promise<CreditsInfo> {
|
||||
const baseUrl = await getAgentServerUrl()
|
||||
const response = await fetch(`${baseUrl}/credits`)
|
||||
if (!response.ok)
|
||||
throw new Error(`Failed to fetch credits: ${response.status}`)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export function useCredits() {
|
||||
return useQuery<CreditsInfo>({
|
||||
queryKey: CREDITS_QUERY_KEY,
|
||||
queryFn: fetchCredits,
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
})
|
||||
}
|
||||
|
||||
export function useInvalidateCredits() {
|
||||
const queryClient = useQueryClient()
|
||||
return () => queryClient.invalidateQueries({ queryKey: CREDITS_QUERY_KEY })
|
||||
}
|
||||
@@ -22,6 +22,13 @@ export const scheduledJobRunStorage = storage.defineItem<ScheduledJobRun[]>(
|
||||
},
|
||||
)
|
||||
|
||||
export const pendingDeletionStorage = storage.defineItem<string[]>(
|
||||
'local:scheduledJobsPendingDeletion',
|
||||
{
|
||||
fallback: [],
|
||||
},
|
||||
)
|
||||
|
||||
export function useScheduledJobs() {
|
||||
const [jobs, setJobs] = useState<ScheduledJob[]>([])
|
||||
|
||||
@@ -54,6 +61,11 @@ export function useScheduledJobs() {
|
||||
const removeJob = async (id: string) => {
|
||||
await chrome.alarms.clear(getAlarmName(id))
|
||||
|
||||
const pending = (await pendingDeletionStorage.getValue()) ?? []
|
||||
if (!pending.includes(id)) {
|
||||
await pendingDeletionStorage.setValue([...pending, id])
|
||||
}
|
||||
|
||||
const currentJobs = (await scheduledJobStorage.getValue()) ?? []
|
||||
await scheduledJobStorage.setValue(currentJobs.filter((j) => j.id !== id))
|
||||
|
||||
|
||||
@@ -5,10 +5,11 @@ import { sentry } from '@/lib/sentry/sentry'
|
||||
import { createAlarmFromJob } from './createAlarmFromJob'
|
||||
import {
|
||||
CreateScheduledJobDocument,
|
||||
DeleteScheduledJobDocument,
|
||||
GetScheduledJobsByProfileIdDocument,
|
||||
UpdateScheduledJobDocument,
|
||||
} from './graphql/syncSchedulesDocument'
|
||||
import { scheduledJobStorage } from './scheduleStorage'
|
||||
import { pendingDeletionStorage, scheduledJobStorage } from './scheduleStorage'
|
||||
import type { ScheduledJob } from './scheduleTypes'
|
||||
|
||||
type RemoteScheduledJob = {
|
||||
@@ -99,6 +100,32 @@ export async function syncSchedulesToBackend(
|
||||
}
|
||||
}
|
||||
|
||||
const pendingDeletions = new Set(
|
||||
(await pendingDeletionStorage.getValue()) ?? [],
|
||||
)
|
||||
const resolvedDeletions = new Set<string>()
|
||||
|
||||
for (const rowId of pendingDeletions) {
|
||||
if (remoteJobs.has(rowId)) {
|
||||
try {
|
||||
await execute(DeleteScheduledJobDocument, { rowId })
|
||||
remoteJobs.delete(rowId)
|
||||
resolvedDeletions.add(rowId)
|
||||
} catch (error) {
|
||||
sentry.captureException(error, {
|
||||
extra: { jobId: rowId, context: 'sync-pending-deletion' },
|
||||
})
|
||||
}
|
||||
} else {
|
||||
resolvedDeletions.add(rowId)
|
||||
}
|
||||
}
|
||||
|
||||
const latestPending = (await pendingDeletionStorage.getValue()) ?? []
|
||||
await pendingDeletionStorage.setValue(
|
||||
latestPending.filter((id) => !resolvedDeletions.has(id)),
|
||||
)
|
||||
|
||||
const localJobsMap = new Map(localJobs.map((j) => [j.id, j]))
|
||||
const jobsToAddLocally: ScheduledJob[] = []
|
||||
const jobsToUpdateLocally: ScheduledJob[] = []
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
const GATEWAY_URL = 'https://llm.browseros.com'
|
||||
|
||||
interface TranscribeResponse {
|
||||
text: string
|
||||
}
|
||||
|
||||
export async function transcribeAudio(audioBlob: Blob): Promise<string> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', audioBlob, 'recording.webm')
|
||||
formData.append('response_format', 'json')
|
||||
|
||||
const response = await fetch(`${GATEWAY_URL}/api/transcribe`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody: { error?: string } = await response
|
||||
.json()
|
||||
.catch(() => ({ error: 'Transcription failed' }))
|
||||
throw new Error(
|
||||
errorBody.error || `Transcription failed: ${response.status}`,
|
||||
)
|
||||
}
|
||||
|
||||
const result: TranscribeResponse = await response.json()
|
||||
return result.text || ''
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { transcribeAudio } from './transcribe-audio'
|
||||
|
||||
const GATEWAY_URL = 'https://llm.browseros.com'
|
||||
const WAVEFORM_BAND_COUNT = 5
|
||||
|
||||
export interface VoiceInputState {
|
||||
@@ -26,34 +26,6 @@ export interface UseVoiceInputReturn {
|
||||
|
||||
const EMPTY_LEVELS = Array(WAVEFORM_BAND_COUNT).fill(0)
|
||||
|
||||
interface TranscribeResponse {
|
||||
text: string
|
||||
}
|
||||
|
||||
async function transcribeAudio(audioBlob: Blob): Promise<string> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', audioBlob, 'recording.webm')
|
||||
formData.append('response_format', 'json')
|
||||
|
||||
const response = await fetch(`${GATEWAY_URL}/api/transcribe`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody: { error?: string } = await response
|
||||
.json()
|
||||
.catch(() => ({ error: 'Transcription failed' }))
|
||||
throw new Error(
|
||||
errorBody.error || `Transcription failed: ${response.status}`,
|
||||
)
|
||||
}
|
||||
|
||||
const result: TranscribeResponse = await response.json()
|
||||
return result.text || ''
|
||||
}
|
||||
|
||||
export function useVoiceInput(): UseVoiceInputReturn {
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
const [isTranscribing, setIsTranscribing] = useState(false)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -170,11 +172,44 @@ func defaultServerURL() string {
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err == nil {
|
||||
if url := normalizeServerURL(cfg.ServerURL); url != "" {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
if url := loadBrowserosServerURL(); url != "" {
|
||||
return url
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
type serverDiscoveryConfig struct {
|
||||
ServerPort int `json:"server_port"`
|
||||
URL string `json:"url"`
|
||||
ServerVersion string `json:"server_version"`
|
||||
BrowserOSVersion string `json:"browseros_version,omitempty"`
|
||||
ChromiumVersion string `json:"chromium_version,omitempty"`
|
||||
}
|
||||
|
||||
func loadBrowserosServerURL() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return normalizeServerURL(cfg.ServerURL)
|
||||
data, err := os.ReadFile(filepath.Join(home, ".browseros", "server.json"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sc serverDiscoveryConfig
|
||||
if err := json.Unmarshal(data, &sc); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return normalizeServerURL(sc.URL)
|
||||
}
|
||||
|
||||
func normalizeServerURL(raw string) string {
|
||||
|
||||
11
packages/browseros-agent/apps/eval/.env.example
Normal file
11
packages/browseros-agent/apps/eval/.env.example
Normal file
@@ -0,0 +1,11 @@
|
||||
# Showcase generator — LLM config
|
||||
OPENROUTER_API_KEY=
|
||||
SHOWCASE_MODEL=openai/gpt-4o
|
||||
SHOWCASE_PROVIDER=openrouter
|
||||
SHOWCASE_BASE_URL=https://openrouter.ai/api/v1
|
||||
|
||||
# R2 upload (for --upload flag)
|
||||
R2_ACCOUNT_ID=
|
||||
R2_ACCESS_KEY_ID=
|
||||
R2_SECRET_ACCESS_KEY=
|
||||
R2_BUCKET=rl-env
|
||||
@@ -1,2 +1,5 @@
|
||||
data/raw/
|
||||
results/
|
||||
extensions/
|
||||
showcase-output/
|
||||
.env
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"agent": {
|
||||
"type": "single",
|
||||
"provider": "openai-compatible",
|
||||
"model": "accounts/fireworks/models/kimi-k2p5",
|
||||
"apiKey": "FIREWORKS_API_KEY",
|
||||
"baseUrl": "https://api.fireworks.ai/inference/v1",
|
||||
"supportsImages": true
|
||||
},
|
||||
"dataset": "../data/webbench-2of4-50.jsonl",
|
||||
"num_workers": 10,
|
||||
"restart_server_per_task": true,
|
||||
"browseros": {
|
||||
"server_url": "http://127.0.0.1:9110",
|
||||
"base_cdp_port": 9010,
|
||||
"base_server_port": 9110,
|
||||
"base_extension_port": 9310,
|
||||
"load_extensions": false,
|
||||
"headless": true
|
||||
},
|
||||
"graders": ["performance_grader"],
|
||||
"grader_api_key_env": "OPENROUTER_API_KEY",
|
||||
"grader_base_url": "https://openrouter.ai/api/v1",
|
||||
"grader_model": "openai/gpt-4.1",
|
||||
"timeout_ms": 1800000
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"agent": {
|
||||
"type": "orchestrator-executor",
|
||||
"orchestrator": {
|
||||
"provider": "openai-compatible",
|
||||
"model": "accounts/fireworks/models/kimi-k2p5",
|
||||
"apiKey": "FIREWORKS_API_KEY",
|
||||
"baseUrl": "https://api.fireworks.ai/inference/v1"
|
||||
},
|
||||
"executor": {
|
||||
"provider": "clado-action",
|
||||
"model": "qwen3-vl-30b-a3b-instruct",
|
||||
"apiKey": "",
|
||||
"baseUrl": "https://clado-ai--clado-browseros-action-actionmodel-generate.modal.run"
|
||||
}
|
||||
},
|
||||
"dataset": "../data/webbench-2of4-50.jsonl",
|
||||
"num_workers": 10,
|
||||
"restart_server_per_task": true,
|
||||
"browseros": {
|
||||
"server_url": "http://127.0.0.1:9110",
|
||||
"base_cdp_port": 9010,
|
||||
"base_server_port": 9110,
|
||||
"base_extension_port": 9310,
|
||||
"load_extensions": false,
|
||||
"headless": true
|
||||
},
|
||||
"graders": ["performance_grader"],
|
||||
"grader_api_key_env": "OPENROUTER_API_KEY",
|
||||
"grader_base_url": "https://openrouter.ai/api/v1",
|
||||
"grader_model": "openai/gpt-4.1",
|
||||
"timeout_ms": 1800000
|
||||
}
|
||||
20
packages/browseros-agent/apps/eval/data/prod-tasks.jsonl
Normal file
20
packages/browseros-agent/apps/eval/data/prod-tasks.jsonl
Normal file
@@ -0,0 +1,20 @@
|
||||
{"query_id":"prod-financial-advisor-morningstar","dataset":"prod","query":"Look up the Morningstar rating and expense ratio for Vanguard Total Stock Market Index Fund (VTSAX) on morningstar.com","start_url":"https://www.morningstar.com","metadata":{"original_task_id":"prod-financial-advisor-morningstar","category":"finance"}}
|
||||
{"query_id":"prod-lawyer-pacer-search","dataset":"prod","query":"Go to courtlistener.com and search for recent federal court opinions mentioning 'non-compete agreement' from 2025","start_url":"https://www.courtlistener.com","metadata":{"original_task_id":"prod-lawyer-pacer-search","category":"legal"}}
|
||||
{"query_id":"prod-doctor-drug-interaction","dataset":"prod","query":"Check drug interactions between metformin and lisinopril on drugs.com","start_url":"https://www.drugs.com/drug_interactions.html","metadata":{"original_task_id":"prod-doctor-drug-interaction","category":"healthcare"}}
|
||||
{"query_id":"prod-software-eng-github-trending","dataset":"prod","query":"Find the top trending Python repositories on GitHub this week and open the most starred one","start_url":"https://github.com/trending","metadata":{"original_task_id":"prod-software-eng-github-trending","category":"technology"}}
|
||||
{"query_id":"prod-cfo-sec-filing","dataset":"prod","query":"Look up Apple's most recent 10-K filing on SEC EDGAR and find their total revenue for fiscal year 2024","start_url":"https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&company=apple&CIK=&type=10-K&dateb=&owner=include&count=10&search_text=&action=getcompany","metadata":{"original_task_id":"prod-cfo-sec-filing","category":"finance"}}
|
||||
{"query_id":"prod-realtor-zillow-comp","dataset":"prod","query":"Search for recently sold homes in Palo Alto, CA on Zillow and filter for 3+ bedrooms sold in the last 30 days","start_url":"https://www.zillow.com","metadata":{"original_task_id":"prod-realtor-zillow-comp","category":"real_estate"}}
|
||||
{"query_id":"prod-hr-manager-linkedin-job","dataset":"prod","query":"Post a job listing search on LinkedIn for a Senior Product Manager role in San Francisco with salary range $180k-$220k","start_url":"https://www.linkedin.com/jobs/","metadata":{"original_task_id":"prod-hr-manager-linkedin-job","category":"hr"}}
|
||||
{"query_id":"prod-analyst-fred-data","dataset":"prod","query":"Go to FRED and pull up the US Consumer Price Index chart, change the time range to the last 5 years","start_url":"https://fred.stlouisfed.org","metadata":{"original_task_id":"prod-analyst-fred-data","category":"finance"}}
|
||||
{"query_id":"prod-accountant-irs-form","dataset":"prod","query":"Find and download the latest IRS Form W-9 from irs.gov","start_url":"https://www.irs.gov","metadata":{"original_task_id":"prod-accountant-irs-form","category":"finance"}}
|
||||
{"query_id":"prod-sales-manager-crm-research","dataset":"prod","query":"Go to g2.com and compare the top 3 CRM software platforms by user rating and pricing","start_url":"https://www.g2.com/categories/crm","metadata":{"original_task_id":"prod-sales-manager-crm-research","category":"sales"}}
|
||||
{"query_id":"prod-engineer-stackoverflow","dataset":"prod","query":"Search Stack Overflow for how to implement retry logic with exponential backoff in Python and find the highest voted answer","start_url":"https://stackoverflow.com","metadata":{"original_task_id":"prod-engineer-stackoverflow","category":"technology"}}
|
||||
{"query_id":"prod-pm-producthunt","dataset":"prod","query":"Browse today's top launches on Product Hunt and upvote the highest ranked AI product","start_url":"https://www.producthunt.com","metadata":{"original_task_id":"prod-pm-producthunt","category":"technology"}}
|
||||
{"query_id":"prod-pharmacist-fda-recall","dataset":"prod","query":"Check the FDA website for any recent drug recalls in the last month","start_url":"https://www.fda.gov/safety/recalls-market-withdrawals-safety-alerts","metadata":{"original_task_id":"prod-pharmacist-fda-recall","category":"healthcare"}}
|
||||
{"query_id":"prod-investment-analyst-yahoo-finance","dataset":"prod","query":"Look up NVIDIA stock on Yahoo Finance, check the P/E ratio, and add it to a watchlist","start_url":"https://finance.yahoo.com","metadata":{"original_task_id":"prod-investment-analyst-yahoo-finance","category":"finance"}}
|
||||
{"query_id":"prod-compliance-officer-regulations","dataset":"prod","query":"Search for the latest GDPR enforcement actions on the European Data Protection Board website","start_url":"https://www.edpb.europa.eu/news/news_en","metadata":{"original_task_id":"prod-compliance-officer-regulations","category":"compliance"}}
|
||||
{"query_id":"prod-management-consultant-mckinsey","dataset":"prod","query":"Go to McKinsey's insights page and find their latest article about generative AI's impact on productivity","start_url":"https://www.mckinsey.com/featured-insights","metadata":{"original_task_id":"prod-management-consultant-mckinsey","category":"consulting"}}
|
||||
{"query_id":"prod-operations-manager-shipping","dataset":"prod","query":"Track a FedEx package with tracking number 123456789012 on fedex.com","start_url":"https://www.fedex.com/en-us/tracking.html","metadata":{"original_task_id":"prod-operations-manager-shipping","category":"operations"}}
|
||||
{"query_id":"prod-market-researcher-statista","dataset":"prod","query":"Search Statista for the global AI market size forecast and find the projected value for 2026","start_url":"https://www.statista.com","metadata":{"original_task_id":"prod-market-researcher-statista","category":"research"}}
|
||||
{"query_id":"prod-nurse-uptodate","dataset":"prod","query":"Search WebMD for the recommended dosing guidelines for adult acetaminophen and check the maximum daily dose","start_url":"https://www.webmd.com","metadata":{"original_task_id":"prod-nurse-uptodate","category":"healthcare"}}
|
||||
{"query_id":"prod-executive-flights","dataset":"prod","query":"Search Google Flights for a business class round trip from SFO to JFK departing next Monday returning Friday","start_url":"https://www.google.com/travel/flights","metadata":{"original_task_id":"prod-executive-flights","category":"travel"}}
|
||||
@@ -0,0 +1 @@
|
||||
{"query_id":"showcase-amazon-order","dataset":"showcase","query":"Open amazon.com and order Sensodyne toothpaste","start_url":"https://www.amazon.com","metadata":{"original_task_id":"showcase-amazon-order"}}
|
||||
@@ -9,12 +9,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.63",
|
||||
"@aws-sdk/client-s3": "^3.1014.0",
|
||||
"@browseros/server": "workspace:*",
|
||||
"@browseros/shared": "workspace:*",
|
||||
"@google/gemini-cli-core": "^0.16.0",
|
||||
"ai": "^6.0.94",
|
||||
"@google/genai": "1.30.0",
|
||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||
"ai": "^6.0.94",
|
||||
"hono": "^4.6.0",
|
||||
"openai": "^4.0.0",
|
||||
"sharp": "^0.34.5",
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Browser } from '@browseros/server/browser'
|
||||
|
||||
const CROSSHAIR_ID = '__browseros_showcase_crosshair__'
|
||||
|
||||
export async function injectCrosshair(
|
||||
browser: Browser,
|
||||
pageId: number,
|
||||
coords: { x: number; y: number },
|
||||
toolName: string,
|
||||
): Promise<void> {
|
||||
const x = Math.round(coords.x)
|
||||
const y = Math.round(coords.y)
|
||||
const label = toolName.replace(/_/g, ' ')
|
||||
const labelWidth = Math.round(label.length * 9 + 24)
|
||||
const labelX = x + 32
|
||||
const labelY = y - 32
|
||||
|
||||
await browser.evaluate(
|
||||
pageId,
|
||||
`(() => {
|
||||
const existing = document.getElementById('${CROSSHAIR_ID}');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.id = '${CROSSHAIR_ID}';
|
||||
el.style.cssText = 'position:fixed;top:0;left:0;width:100vw;height:100vh;pointer-events:none;z-index:2147483647';
|
||||
el.innerHTML = '<svg style="position:absolute;top:0;left:0;width:100%;height:100%" xmlns="http://www.w3.org/2000/svg">'
|
||||
+ '<defs><filter id="glow"><feGaussianBlur stdDeviation="3" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs>'
|
||||
+ '<circle cx="${x}" cy="${y}" r="28" fill="rgba(255,59,48,0.12)" stroke="none"/>'
|
||||
+ '<line x1="${x - 40}" y1="${y}" x2="${x - 12}" y2="${y}" stroke="#FF3B30" stroke-width="3" filter="url(#glow)"/>'
|
||||
+ '<line x1="${x + 12}" y1="${y}" x2="${x + 40}" y2="${y}" stroke="#FF3B30" stroke-width="3" filter="url(#glow)"/>'
|
||||
+ '<line x1="${x}" y1="${y - 40}" x2="${x}" y2="${y - 12}" stroke="#FF3B30" stroke-width="3" filter="url(#glow)"/>'
|
||||
+ '<line x1="${x}" y1="${y + 12}" x2="${x}" y2="${y + 40}" stroke="#FF3B30" stroke-width="3" filter="url(#glow)"/>'
|
||||
+ '<circle cx="${x}" cy="${y}" r="20" fill="none" stroke="#FF3B30" stroke-width="3" filter="url(#glow)"/>'
|
||||
+ '<circle cx="${x}" cy="${y}" r="4" fill="#FF3B30"/>'
|
||||
+ '<rect x="${labelX}" y="${labelY}" rx="6" ry="6" width="${labelWidth}" height="28" fill="rgba(0,0,0,0.85)"/>'
|
||||
+ '<text x="${labelX + 12}" y="${labelY + 19}" font-family="system-ui,-apple-system,sans-serif" font-size="14" fill="white" font-weight="600">${label}</text>'
|
||||
+ '</svg>';
|
||||
document.body.appendChild(el);
|
||||
})()`,
|
||||
)
|
||||
}
|
||||
|
||||
export async function removeCrosshair(
|
||||
browser: Browser,
|
||||
pageId: number,
|
||||
): Promise<void> {
|
||||
await browser.evaluate(
|
||||
pageId,
|
||||
`document.getElementById('${CROSSHAIR_ID}')?.remove()`,
|
||||
)
|
||||
}
|
||||
288
packages/browseros-agent/apps/eval/scripts/showcase/executor.ts
Normal file
288
packages/browseros-agent/apps/eval/scripts/showcase/executor.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { AiSdkAgent } from '@browseros/server/agent/tool-loop'
|
||||
import type { ResolvedAgentConfig } from '@browseros/server/agent/types'
|
||||
import { Browser } from '@browseros/server/browser'
|
||||
import { CdpBackend } from '@browseros/server/browser/backends/cdp'
|
||||
import { registry } from '@browseros/server/tools/registry'
|
||||
import type { Task } from '../../src/types'
|
||||
import { injectCrosshair, removeCrosshair } from './crosshair'
|
||||
import { buildTaskManifest, saveTaskManifest } from './manifest'
|
||||
import type { ShowcaseStep, ShowcaseTaskManifest } from './types'
|
||||
|
||||
const ELEMENT_TOOLS = new Set([
|
||||
'click',
|
||||
'fill',
|
||||
'hover',
|
||||
'clear',
|
||||
'select_option',
|
||||
'drag',
|
||||
'focus',
|
||||
'check',
|
||||
'uncheck',
|
||||
])
|
||||
|
||||
const COORDINATE_TOOLS = new Set(['click_at', 'hover_at', 'type_at', 'drag_at'])
|
||||
|
||||
const CONTROLLER_STUB = {
|
||||
start: async () => {},
|
||||
stop: async () => {},
|
||||
isConnected: () => false,
|
||||
send: async () => ({}),
|
||||
// biome-ignore lint/suspicious/noExplicitAny: ControllerBackend type not exported
|
||||
} as any
|
||||
|
||||
async function resolvePageId(
|
||||
browser: Browser,
|
||||
requestedId: number,
|
||||
): Promise<number> {
|
||||
const pages = await browser.listPages()
|
||||
if (pages.some((p) => p.pageId === requestedId)) return requestedId
|
||||
if (pages.length > 0) return pages[0].pageId
|
||||
return requestedId
|
||||
}
|
||||
|
||||
export interface ExecuteTaskResult {
|
||||
manifest: ShowcaseTaskManifest
|
||||
status: 'completed' | 'timeout' | 'failed'
|
||||
}
|
||||
|
||||
export async function executeShowcaseTask(
|
||||
task: Task,
|
||||
cdpPort: number,
|
||||
outputDir: string,
|
||||
agentConfig: {
|
||||
model: string
|
||||
provider: string
|
||||
apiKey?: string
|
||||
baseUrl?: string
|
||||
},
|
||||
timeoutMs: number,
|
||||
): Promise<ExecuteTaskResult> {
|
||||
const executionId = randomUUID()
|
||||
const taskDir = join(outputDir, executionId)
|
||||
const screenshotDir = join(taskDir, 'screenshots')
|
||||
await mkdir(screenshotDir, { recursive: true })
|
||||
|
||||
const cdp = new CdpBackend({ port: cdpPort })
|
||||
await cdp.connect()
|
||||
const browser = new Browser(cdp, CONTROLLER_STUB)
|
||||
|
||||
const pages = await browser.listPages()
|
||||
const activePage = pages[0]
|
||||
let activePageId = activePage?.pageId ?? 1
|
||||
|
||||
// Navigate to start URL
|
||||
if (task.start_url && task.start_url !== 'about:blank') {
|
||||
await browser.goto(activePageId, task.start_url)
|
||||
}
|
||||
|
||||
const conversationId = randomUUID()
|
||||
const resolvedConfig: ResolvedAgentConfig = {
|
||||
conversationId,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: LLMProvider type validated at runtime
|
||||
provider: agentConfig.provider as any,
|
||||
model: agentConfig.model,
|
||||
apiKey: agentConfig.apiKey,
|
||||
baseUrl: agentConfig.baseUrl,
|
||||
workingDir: `/tmp/browseros-showcase-${conversationId}`,
|
||||
evalMode: true,
|
||||
supportsImages: true,
|
||||
}
|
||||
|
||||
const browserContext = activePage
|
||||
? {
|
||||
activeTab: {
|
||||
id: activePage.tabId,
|
||||
pageId: activePage.pageId,
|
||||
url: activePage.url,
|
||||
title: activePage.title,
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
|
||||
let agent: AiSdkAgent | null = null
|
||||
const steps: ShowcaseStep[] = []
|
||||
let stepNum = 0
|
||||
let finalText: string | null = null
|
||||
let status: 'completed' | 'timeout' | 'failed' = 'completed'
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
agent = await AiSdkAgent.create({
|
||||
resolvedConfig,
|
||||
browser,
|
||||
registry,
|
||||
browserContext,
|
||||
})
|
||||
|
||||
let pendingStep: Partial<ShowcaseStep> | null = null
|
||||
|
||||
const abortController = new AbortController()
|
||||
const timeoutHandle = setTimeout(() => abortController.abort(), timeoutMs)
|
||||
|
||||
try {
|
||||
const result = await agent.toolLoopAgent.generate({
|
||||
prompt: task.query,
|
||||
abortSignal: abortController.signal,
|
||||
|
||||
experimental_onToolCallStart: async ({ toolCall }) => {
|
||||
try {
|
||||
const input = (toolCall.input ?? {}) as Record<string, unknown>
|
||||
if (typeof input.page === 'number') {
|
||||
activePageId = input.page
|
||||
}
|
||||
const pageId = await resolvePageId(browser, activePageId)
|
||||
activePageId = pageId
|
||||
|
||||
const beforeResult = await browser.screenshot(pageId, {
|
||||
format: 'png',
|
||||
fullPage: false,
|
||||
})
|
||||
const beforePath = join(screenshotDir, `${stepNum}_before.png`)
|
||||
await writeFile(
|
||||
beforePath,
|
||||
Buffer.from(beforeResult.data, 'base64'),
|
||||
)
|
||||
|
||||
let axTree = ''
|
||||
try {
|
||||
axTree = await browser.snapshot(pageId)
|
||||
} catch {
|
||||
// snapshot can fail on some pages
|
||||
}
|
||||
|
||||
let coords: { x: number; y: number } | undefined
|
||||
const elementId = input.element as number | undefined
|
||||
if (
|
||||
elementId !== undefined &&
|
||||
ELEMENT_TOOLS.has(toolCall.toolName)
|
||||
) {
|
||||
try {
|
||||
coords = await browser.getElementCenter(pageId, elementId)
|
||||
} catch {
|
||||
// element may have been removed
|
||||
}
|
||||
} else if (
|
||||
COORDINATE_TOOLS.has(toolCall.toolName) &&
|
||||
typeof input.x === 'number' &&
|
||||
typeof input.y === 'number'
|
||||
) {
|
||||
coords = { x: input.x, y: input.y }
|
||||
}
|
||||
|
||||
pendingStep = {
|
||||
stepIndex: stepNum,
|
||||
toolName: toolCall.toolName,
|
||||
toolInput: input,
|
||||
beforeScreenshot: beforePath,
|
||||
accessibilitySnapshot: axTree,
|
||||
elementCoordinates: coords,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
if (coords) {
|
||||
try {
|
||||
await injectCrosshair(
|
||||
browser,
|
||||
pageId,
|
||||
coords,
|
||||
toolCall.toolName,
|
||||
)
|
||||
const annotatedResult = await browser.screenshot(pageId, {
|
||||
format: 'png',
|
||||
fullPage: false,
|
||||
})
|
||||
const annotatedPath = join(
|
||||
screenshotDir,
|
||||
`${stepNum}_annotated.png`,
|
||||
)
|
||||
await writeFile(
|
||||
annotatedPath,
|
||||
Buffer.from(annotatedResult.data, 'base64'),
|
||||
)
|
||||
pendingStep.annotatedScreenshot = annotatedPath
|
||||
await removeCrosshair(browser, pageId)
|
||||
} catch {
|
||||
// annotation is best-effort
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
` Step ${stepNum} before-capture failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
experimental_onToolCallFinish: async ({ toolResult }) => {
|
||||
try {
|
||||
const pageId = await resolvePageId(browser, activePageId)
|
||||
activePageId = pageId
|
||||
const afterResult = await browser.screenshot(pageId, {
|
||||
format: 'png',
|
||||
fullPage: false,
|
||||
})
|
||||
const afterPath = join(screenshotDir, `${stepNum}_after.png`)
|
||||
await writeFile(afterPath, Buffer.from(afterResult.data, 'base64'))
|
||||
|
||||
if (pendingStep) {
|
||||
pendingStep.afterScreenshot = afterPath
|
||||
pendingStep.toolOutput = toolResult
|
||||
steps.push(pendingStep as ShowcaseStep)
|
||||
stepNum++
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
` Step ${stepNum} after-capture failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
}
|
||||
pendingStep = null
|
||||
},
|
||||
|
||||
onStepFinish: async ({ text }) => {
|
||||
if (text && steps.length > 0) {
|
||||
const lastStep = steps[steps.length - 1]
|
||||
lastStep.assistantText = text
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
finalText = result.text || null
|
||||
} catch (err) {
|
||||
if (abortController.signal.aborted) {
|
||||
status = 'timeout'
|
||||
console.log(` ${task.query_id}: timed out after ${timeoutMs / 1000}s`)
|
||||
} else {
|
||||
status = 'failed'
|
||||
console.error(
|
||||
` ${task.query_id}: failed — ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeoutHandle)
|
||||
}
|
||||
|
||||
const totalDurationMs = Date.now() - startTime
|
||||
|
||||
const manifest = buildTaskManifest({
|
||||
executionId,
|
||||
taskId: task.query_id,
|
||||
query: task.query,
|
||||
startUrl: task.start_url ?? 'about:blank',
|
||||
dataset: task.dataset,
|
||||
steps,
|
||||
finalAnswer: finalText,
|
||||
model: agentConfig.model,
|
||||
provider: agentConfig.provider,
|
||||
totalDurationMs,
|
||||
})
|
||||
|
||||
await saveTaskManifest(outputDir, executionId, manifest)
|
||||
|
||||
return { manifest, status }
|
||||
} finally {
|
||||
if (agent) await agent.dispose().catch(() => {})
|
||||
await cdp.disconnect().catch(() => {})
|
||||
}
|
||||
}
|
||||
235
packages/browseros-agent/apps/eval/scripts/showcase/generate.ts
Normal file
235
packages/browseros-agent/apps/eval/scripts/showcase/generate.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { BrowserOSAppManager } from '../../src/runner/browseros-app-manager'
|
||||
import { loadTasks } from '../../src/runner/task-loader'
|
||||
import type { Task } from '../../src/types'
|
||||
import { executeShowcaseTask } from './executor'
|
||||
import { saveRunIndex } from './manifest'
|
||||
import type { ShowcaseRunIndex } from './types'
|
||||
import { uploadShowcase } from './uploader'
|
||||
|
||||
const BASE_PORTS = { cdp: 9010, server: 9110, extension: 9310 }
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
tasks: { type: 'string', short: 't' },
|
||||
output: { type: 'string', short: 'o', default: './showcase-output' },
|
||||
model: { type: 'string', short: 'm' },
|
||||
provider: { type: 'string', short: 'p' },
|
||||
'base-url': { type: 'string' },
|
||||
workers: { type: 'string', short: 'w', default: '1' },
|
||||
'cdp-port': { type: 'string' },
|
||||
timeout: { type: 'string', default: '300000' },
|
||||
upload: { type: 'boolean', default: false },
|
||||
help: { type: 'boolean', short: 'h' },
|
||||
},
|
||||
})
|
||||
|
||||
if (values.help || !values.tasks) {
|
||||
console.log(`
|
||||
Showcase Dataset Generator
|
||||
|
||||
Runs the BrowserOS agent on tasks and captures before/after screenshots
|
||||
with crosshair annotations for element-targeting tool calls.
|
||||
|
||||
Usage:
|
||||
bun scripts/showcase/generate.ts --tasks <path> [options]
|
||||
|
||||
Options:
|
||||
-t, --tasks <path> JSONL task file (required)
|
||||
-o, --output <dir> Output directory (default: ./showcase-output)
|
||||
-m, --model <model> LLM model (env: SHOWCASE_MODEL, default: openai/gpt-4o)
|
||||
-p, --provider <name> LLM provider (env: SHOWCASE_PROVIDER, default: openrouter)
|
||||
--base-url <url> LLM base URL (env: SHOWCASE_BASE_URL)
|
||||
-w, --workers <n> Parallel workers (default: 1)
|
||||
--cdp-port <port> Connect to existing Chrome (single-worker only)
|
||||
--timeout <ms> Per-task timeout in ms (default: 300000)
|
||||
--upload Upload results to R2 after generation
|
||||
-h, --help Show this help
|
||||
`)
|
||||
process.exit(values.help ? 0 : 1)
|
||||
}
|
||||
|
||||
const config = {
|
||||
tasks: values.tasks as string,
|
||||
output: (values.output ?? './showcase-output') as string,
|
||||
model: (values.model ??
|
||||
process.env.SHOWCASE_MODEL ??
|
||||
'openai/gpt-4o') as string,
|
||||
provider: (values.provider ??
|
||||
process.env.SHOWCASE_PROVIDER ??
|
||||
'openrouter') as string,
|
||||
baseUrl: (values['base-url'] ?? process.env.SHOWCASE_BASE_URL) as
|
||||
| string
|
||||
| undefined,
|
||||
workers: Math.max(1, Number(values.workers ?? '1')),
|
||||
cdpPort: values['cdp-port'] ? Number(values['cdp-port']) : undefined,
|
||||
timeout: Number(values.timeout ?? '300000'),
|
||||
upload: values.upload ?? false,
|
||||
}
|
||||
|
||||
if (config.cdpPort && config.workers > 1) {
|
||||
console.error('--cdp-port only works with a single worker (--workers 1)')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const apiKey = process.env.OPENROUTER_API_KEY ?? process.env.OPENAI_API_KEY
|
||||
if (!apiKey) {
|
||||
console.error(
|
||||
'Missing API key: set OPENROUTER_API_KEY or OPENAI_API_KEY environment variable',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const { tasks } = await loadTasks({ type: 'file', path: config.tasks })
|
||||
console.log(`Loaded ${tasks.length} task(s), ${config.workers} worker(s)`)
|
||||
|
||||
await mkdir(config.output, { recursive: true })
|
||||
|
||||
const runId = `${new Date().toISOString().slice(0, 10)}-${crypto.randomUUID().slice(0, 8)}`
|
||||
const runIndex: ShowcaseRunIndex = {
|
||||
runId,
|
||||
createdAt: new Date().toISOString(),
|
||||
agentConfig: { model: config.model, provider: config.provider },
|
||||
tasks: [],
|
||||
}
|
||||
|
||||
console.log(`\nRun ID: ${runId}`)
|
||||
console.log(`Output: ${config.output}\n`)
|
||||
|
||||
// --- Task Queue ---
|
||||
|
||||
class TaskQueue {
|
||||
private index = 0
|
||||
private stopped = false
|
||||
constructor(private tasks: Task[]) {}
|
||||
|
||||
next(): Task | null {
|
||||
if (this.stopped || this.index >= this.tasks.length) return null
|
||||
return this.tasks[this.index++]
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true
|
||||
}
|
||||
}
|
||||
|
||||
const queue = new TaskQueue(tasks)
|
||||
let completedCount = 0
|
||||
const appManagers: BrowserOSAppManager[] = []
|
||||
|
||||
// --- Signal handling ---
|
||||
|
||||
const onSignal = async () => {
|
||||
console.log('\nShutting down workers...')
|
||||
queue.stop()
|
||||
await Promise.allSettled(appManagers.map((m) => m.killApp()))
|
||||
process.exit(0)
|
||||
}
|
||||
process.on('SIGINT', onSignal)
|
||||
process.on('SIGTERM', onSignal)
|
||||
|
||||
// --- Worker ---
|
||||
|
||||
async function runWorker(workerIndex: number): Promise<void> {
|
||||
let appManager: BrowserOSAppManager | null = null
|
||||
let cdpPort = config.cdpPort ?? BASE_PORTS.cdp + workerIndex
|
||||
|
||||
if (!config.cdpPort) {
|
||||
appManager = new BrowserOSAppManager(workerIndex, BASE_PORTS)
|
||||
appManagers.push(appManager)
|
||||
console.log(` [W${workerIndex}] Starting BrowserOS...`)
|
||||
await appManager.restart()
|
||||
cdpPort = BASE_PORTS.cdp + workerIndex
|
||||
}
|
||||
|
||||
const agentConfig = {
|
||||
model: config.model,
|
||||
provider: config.provider,
|
||||
apiKey,
|
||||
baseUrl: config.baseUrl,
|
||||
}
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const task = queue.next()
|
||||
if (!task) break
|
||||
|
||||
completedCount++
|
||||
const tag = config.workers > 1 ? `[W${workerIndex}] ` : ''
|
||||
console.log(
|
||||
`${tag}[${completedCount}/${tasks.length}] ${task.query_id}: ${task.query}`,
|
||||
)
|
||||
|
||||
// Restart browser between tasks for clean state
|
||||
if (appManager) {
|
||||
await appManager.restart()
|
||||
}
|
||||
|
||||
try {
|
||||
const { manifest, status } = await executeShowcaseTask(
|
||||
task,
|
||||
cdpPort,
|
||||
config.output,
|
||||
agentConfig,
|
||||
config.timeout,
|
||||
)
|
||||
|
||||
runIndex.tasks.push({
|
||||
executionId: manifest.executionId,
|
||||
taskId: task.query_id,
|
||||
query: task.query,
|
||||
stepCount: manifest.steps.length,
|
||||
status,
|
||||
manifestPath: `${manifest.executionId}/manifest.json`,
|
||||
})
|
||||
|
||||
const duration = (manifest.totalDurationMs / 1000).toFixed(1)
|
||||
console.log(
|
||||
`${tag} ${status.toUpperCase()} — ${manifest.steps.length} steps, ${duration}s\n`,
|
||||
)
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`${tag} FAILED — ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
)
|
||||
runIndex.tasks.push({
|
||||
executionId: 'unknown',
|
||||
taskId: task.query_id,
|
||||
query: task.query,
|
||||
stepCount: 0,
|
||||
status: 'failed',
|
||||
manifestPath: '',
|
||||
})
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (appManager) await appManager.killApp()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Run ---
|
||||
|
||||
try {
|
||||
const workers = Array.from({ length: config.workers }, (_, i) => runWorker(i))
|
||||
await Promise.all(workers)
|
||||
|
||||
await saveRunIndex(config.output, runIndex)
|
||||
console.log(`\nResults saved to: ${config.output}`)
|
||||
console.log(
|
||||
`Tasks: ${runIndex.tasks.filter((t) => t.status === 'completed').length} completed, ` +
|
||||
`${runIndex.tasks.filter((t) => t.status === 'failed').length} failed, ` +
|
||||
`${runIndex.tasks.filter((t) => t.status === 'timeout').length} timed out`,
|
||||
)
|
||||
|
||||
if (config.upload) {
|
||||
console.log('\nUploading to R2...')
|
||||
const baseUrl = await uploadShowcase(config.output, runId)
|
||||
console.log(`Uploaded to: ${baseUrl}`)
|
||||
}
|
||||
} finally {
|
||||
process.off('SIGINT', onSignal)
|
||||
process.off('SIGTERM', onSignal)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import type {
|
||||
ShowcaseRunIndex,
|
||||
ShowcaseStep,
|
||||
ShowcaseTaskManifest,
|
||||
} from './types'
|
||||
|
||||
export function buildTaskManifest(opts: {
|
||||
executionId: string
|
||||
taskId: string
|
||||
query: string
|
||||
startUrl: string
|
||||
dataset: string
|
||||
steps: ShowcaseStep[]
|
||||
finalAnswer: string | null
|
||||
model: string
|
||||
provider: string
|
||||
totalDurationMs: number
|
||||
}): ShowcaseTaskManifest {
|
||||
return {
|
||||
executionId: opts.executionId,
|
||||
taskId: opts.taskId,
|
||||
query: opts.query,
|
||||
startUrl: opts.startUrl,
|
||||
dataset: opts.dataset,
|
||||
steps: opts.steps,
|
||||
finalAnswer: opts.finalAnswer,
|
||||
agentConfig: { model: opts.model, provider: opts.provider },
|
||||
totalDurationMs: opts.totalDurationMs,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveTaskManifest(
|
||||
outputDir: string,
|
||||
executionId: string,
|
||||
manifest: ShowcaseTaskManifest,
|
||||
): Promise<string> {
|
||||
const manifestPath = join(outputDir, executionId, 'manifest.json')
|
||||
await writeFile(manifestPath, JSON.stringify(manifest, null, 2))
|
||||
return manifestPath
|
||||
}
|
||||
|
||||
export async function saveRunIndex(
|
||||
outputDir: string,
|
||||
index: ShowcaseRunIndex,
|
||||
): Promise<string> {
|
||||
const indexPath = join(outputDir, 'index.json')
|
||||
await writeFile(indexPath, JSON.stringify(index, null, 2))
|
||||
return indexPath
|
||||
}
|
||||
53
packages/browseros-agent/apps/eval/scripts/showcase/types.ts
Normal file
53
packages/browseros-agent/apps/eval/scripts/showcase/types.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export interface ShowcaseConfig {
|
||||
tasks: string
|
||||
output: string
|
||||
upload: boolean
|
||||
model: string
|
||||
provider: string
|
||||
apiKeyEnv: string
|
||||
cdpPort?: number
|
||||
timeout: number
|
||||
}
|
||||
|
||||
export interface ShowcaseStep {
|
||||
stepIndex: number
|
||||
toolName: string
|
||||
toolInput: Record<string, unknown>
|
||||
toolOutput: unknown
|
||||
elementCoordinates?: { x: number; y: number }
|
||||
beforeScreenshot: string
|
||||
afterScreenshot: string
|
||||
annotatedScreenshot?: string
|
||||
accessibilitySnapshot: string
|
||||
assistantText?: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface ShowcaseTaskManifest {
|
||||
executionId: string
|
||||
taskId: string
|
||||
query: string
|
||||
startUrl: string
|
||||
dataset: string
|
||||
steps: ShowcaseStep[]
|
||||
finalAnswer: string | null
|
||||
agentConfig: { model: string; provider: string }
|
||||
totalDurationMs: number
|
||||
createdAt: string
|
||||
uploadedAt?: string
|
||||
}
|
||||
|
||||
export interface ShowcaseRunIndex {
|
||||
runId: string
|
||||
createdAt: string
|
||||
uploadedAt?: string
|
||||
agentConfig: { model: string; provider: string }
|
||||
tasks: Array<{
|
||||
executionId: string
|
||||
taskId: string
|
||||
query: string
|
||||
stepCount: number
|
||||
status: 'completed' | 'timeout' | 'failed'
|
||||
manifestPath: string
|
||||
}>
|
||||
}
|
||||
155
packages/browseros-agent/apps/eval/scripts/showcase/uploader.ts
Normal file
155
packages/browseros-agent/apps/eval/scripts/showcase/uploader.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { readdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { extname, join, relative } from 'node:path'
|
||||
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
|
||||
import type { ShowcaseRunIndex, ShowcaseTaskManifest } from './types'
|
||||
|
||||
interface R2Config {
|
||||
accountId: string
|
||||
accessKeyId: string
|
||||
secretAccessKey: string
|
||||
bucket: string
|
||||
}
|
||||
|
||||
function contentTypeFor(filePath: string): string {
|
||||
const ext = extname(filePath).toLowerCase()
|
||||
if (ext === '.png') return 'image/png'
|
||||
if (ext === '.json') return 'application/json'
|
||||
if (ext === '.jsonl') return 'application/jsonl'
|
||||
return 'application/octet-stream'
|
||||
}
|
||||
|
||||
function loadR2Config(): R2Config {
|
||||
const accountId = process.env.R2_ACCOUNT_ID
|
||||
const accessKeyId = process.env.R2_ACCESS_KEY_ID
|
||||
const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY
|
||||
const bucket = process.env.R2_BUCKET ?? 'rl-env'
|
||||
|
||||
if (!accountId || !accessKeyId || !secretAccessKey) {
|
||||
throw new Error(
|
||||
'Missing R2 credentials. Set R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY',
|
||||
)
|
||||
}
|
||||
|
||||
return { accountId, accessKeyId, secretAccessKey, bucket }
|
||||
}
|
||||
|
||||
function toR2Key(prefix: string, outputDir: string, filePath: string): string {
|
||||
return `${prefix}/${relative(outputDir, filePath).replaceAll('\\', '/')}`
|
||||
}
|
||||
|
||||
async function walkDir(dir: string): Promise<string[]> {
|
||||
const files: string[] = []
|
||||
const entries = await readdir(dir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await walkDir(fullPath)))
|
||||
} else {
|
||||
files.push(fullPath)
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
export async function uploadShowcase(
|
||||
outputDir: string,
|
||||
runId: string,
|
||||
): Promise<string> {
|
||||
const r2 = loadR2Config()
|
||||
const client = new S3Client({
|
||||
region: 'auto',
|
||||
endpoint: `https://${r2.accountId}.r2.cloudflarestorage.com`,
|
||||
credentials: {
|
||||
accessKeyId: r2.accessKeyId,
|
||||
secretAccessKey: r2.secretAccessKey,
|
||||
},
|
||||
})
|
||||
|
||||
const prefix = `showcase/${runId}`
|
||||
const files = await walkDir(outputDir)
|
||||
console.log(
|
||||
`Uploading ${files.length} files to R2 (${r2.bucket}/${prefix})...`,
|
||||
)
|
||||
|
||||
for (const filePath of files) {
|
||||
const key = toR2Key(prefix, outputDir, filePath)
|
||||
const data = await readFile(filePath)
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: r2.bucket,
|
||||
Key: key,
|
||||
Body: data,
|
||||
ContentType: contentTypeFor(filePath),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const baseUrl = `https://${r2.bucket}.${r2.accountId}.r2.cloudflarestorage.com/${prefix}`
|
||||
console.log(`Upload complete. Base: ${baseUrl}`)
|
||||
|
||||
// Stamp uploadedAt on index.json
|
||||
const indexPath = join(outputDir, 'index.json')
|
||||
try {
|
||||
const indexData = JSON.parse(
|
||||
await readFile(indexPath, 'utf-8'),
|
||||
) as ShowcaseRunIndex
|
||||
indexData.uploadedAt = new Date().toISOString()
|
||||
await writeFile(indexPath, JSON.stringify(indexData, null, 2))
|
||||
} catch {
|
||||
// index may not exist if run was partial
|
||||
}
|
||||
|
||||
// Stamp uploadedAt on each task manifest
|
||||
for (const file of files) {
|
||||
if (file.endsWith('manifest.json') && file !== indexPath) {
|
||||
try {
|
||||
const manifestData = JSON.parse(
|
||||
await readFile(file, 'utf-8'),
|
||||
) as ShowcaseTaskManifest
|
||||
manifestData.uploadedAt = new Date().toISOString()
|
||||
// Rewrite screenshot paths to R2 keys
|
||||
for (const step of manifestData.steps) {
|
||||
step.beforeScreenshot = toR2Key(
|
||||
prefix,
|
||||
outputDir,
|
||||
step.beforeScreenshot,
|
||||
)
|
||||
step.afterScreenshot = toR2Key(
|
||||
prefix,
|
||||
outputDir,
|
||||
step.afterScreenshot,
|
||||
)
|
||||
if (step.annotatedScreenshot) {
|
||||
step.annotatedScreenshot = toR2Key(
|
||||
prefix,
|
||||
outputDir,
|
||||
step.annotatedScreenshot,
|
||||
)
|
||||
}
|
||||
}
|
||||
await writeFile(file, JSON.stringify(manifestData, null, 2))
|
||||
} catch {
|
||||
// skip malformed manifests
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-upload rewritten manifests + index
|
||||
const jsonFiles = files.filter(
|
||||
(f) => f.endsWith('.json') && !f.includes('node_modules'),
|
||||
)
|
||||
for (const filePath of jsonFiles) {
|
||||
const key = toR2Key(prefix, outputDir, filePath)
|
||||
const data = await readFile(filePath)
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: r2.bucket,
|
||||
Key: key,
|
||||
Body: data,
|
||||
ContentType: 'application/json',
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return baseUrl
|
||||
}
|
||||
349
packages/browseros-agent/apps/eval/scripts/upload-run.ts
Normal file
349
packages/browseros-agent/apps/eval/scripts/upload-run.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* Upload eval runs to R2.
|
||||
*
|
||||
* Two modes:
|
||||
* bun scripts/upload-run.ts results/browseros-agent-weekly/2026-03-21-1730
|
||||
* → uploads that specific run
|
||||
*
|
||||
* bun scripts/upload-run.ts results/browseros-agent-weekly
|
||||
* → finds all timestamped subfolders, uploads any not yet in R2
|
||||
*
|
||||
* Env vars: EVAL_R2_ACCOUNT_ID, EVAL_R2_ACCESS_KEY_ID, EVAL_R2_SECRET_ACCESS_KEY
|
||||
* EVAL_R2_BUCKET (default: browseros-eval)
|
||||
* EVAL_R2_CDN_BASE_URL (default: https://eval.browseros.com)
|
||||
*/
|
||||
|
||||
import { readdir, readFile, stat } from 'node:fs/promises'
|
||||
import { basename, dirname, extname, join } from 'node:path'
|
||||
import {
|
||||
GetObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3'
|
||||
|
||||
const CONCURRENCY = 20
|
||||
|
||||
const CONTENT_TYPES: Record<string, string> = {
|
||||
'.json': 'application/json',
|
||||
'.jsonl': 'application/x-ndjson',
|
||||
'.png': 'image/png',
|
||||
}
|
||||
|
||||
interface R2Config {
|
||||
accountId: string
|
||||
accessKeyId: string
|
||||
secretAccessKey: string
|
||||
bucket: string
|
||||
cdnBaseUrl: string
|
||||
}
|
||||
|
||||
function loadConfig(): R2Config {
|
||||
const accountId = process.env.EVAL_R2_ACCOUNT_ID
|
||||
const accessKeyId = process.env.EVAL_R2_ACCESS_KEY_ID
|
||||
const secretAccessKey = process.env.EVAL_R2_SECRET_ACCESS_KEY
|
||||
|
||||
if (!accountId || !accessKeyId || !secretAccessKey) {
|
||||
console.error(
|
||||
'Missing required env vars: EVAL_R2_ACCOUNT_ID, EVAL_R2_ACCESS_KEY_ID, EVAL_R2_SECRET_ACCESS_KEY',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
return {
|
||||
accountId,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
bucket: process.env.EVAL_R2_BUCKET || 'browseros-eval',
|
||||
cdnBaseUrl: (
|
||||
process.env.EVAL_R2_CDN_BASE_URL || 'https://eval.browseros.com'
|
||||
).replace(/\/+$/, ''),
|
||||
}
|
||||
}
|
||||
|
||||
function createClient(config: R2Config): S3Client {
|
||||
return new S3Client({
|
||||
region: 'auto',
|
||||
endpoint: `https://${config.accountId}.r2.cloudflarestorage.com`,
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function upload(
|
||||
client: S3Client,
|
||||
bucket: string,
|
||||
key: string,
|
||||
body: Buffer,
|
||||
contentType: string,
|
||||
) {
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentType: contentType,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function collectFiles(dir: string): Promise<string[]> {
|
||||
const files: string[] = []
|
||||
const entries = await readdir(dir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await collectFiles(full)))
|
||||
} else {
|
||||
files.push(full)
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
async function runPool<T>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
fn: (item: T) => Promise<void>,
|
||||
) {
|
||||
let i = 0
|
||||
const workers = Array.from({ length: concurrency }, async () => {
|
||||
while (i < items.length) {
|
||||
const idx = i++
|
||||
await fn(items[idx])
|
||||
}
|
||||
})
|
||||
await Promise.all(workers)
|
||||
}
|
||||
|
||||
// Check if a run has already been uploaded to R2
|
||||
async function isUploaded(
|
||||
client: S3Client,
|
||||
bucket: string,
|
||||
runId: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: `runs/${runId}/manifest.json`,
|
||||
}),
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Detect if a directory is a run dir (has task subdirs with metadata.json)
|
||||
// vs a config dir (has timestamped subdirs like 2026-03-21-1730/)
|
||||
async function isRunDir(dir: string): Promise<boolean> {
|
||||
const entries = await readdir(dir, { withFileTypes: true })
|
||||
const subdirs = entries.filter((e) => e.isDirectory())
|
||||
for (const subdir of subdirs) {
|
||||
const metaPath = join(dir, subdir.name, 'metadata.json')
|
||||
const metaStat = await stat(metaPath).catch(() => null)
|
||||
if (metaStat?.isFile()) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function uploadSingleRun(
|
||||
runDir: string,
|
||||
runId: string,
|
||||
r2Config: R2Config,
|
||||
client: S3Client,
|
||||
): Promise<void> {
|
||||
const taskDirs = await readdir(runDir, { withFileTypes: true })
|
||||
const taskEntries = taskDirs.filter((d) => d.isDirectory())
|
||||
|
||||
if (taskEntries.length === 0) {
|
||||
console.warn(` No task subdirectories in ${runId}, skipping`)
|
||||
return
|
||||
}
|
||||
|
||||
const manifestTasks: Record<string, unknown>[] = []
|
||||
const jobs: { key: string; filePath: string; contentType: string }[] = []
|
||||
|
||||
// Extract agent config from first task
|
||||
let agentConfig: Record<string, unknown> | undefined
|
||||
let dataset: string | undefined
|
||||
|
||||
for (const taskDir of taskEntries) {
|
||||
const taskId = taskDir.name
|
||||
const taskPath = join(runDir, taskId)
|
||||
const metaPath = join(taskPath, 'metadata.json')
|
||||
|
||||
let meta: Record<string, unknown> = {}
|
||||
try {
|
||||
meta = JSON.parse(await readFile(metaPath, 'utf-8'))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!agentConfig && meta.agent_config)
|
||||
agentConfig = meta.agent_config as Record<string, unknown>
|
||||
if (!dataset && meta.dataset) dataset = meta.dataset as string
|
||||
|
||||
const files = await collectFiles(taskPath)
|
||||
let screenshotCount = 0
|
||||
|
||||
for (const file of files) {
|
||||
const relative = file.slice(taskPath.length + 1)
|
||||
const ext = extname(file)
|
||||
if (relative.startsWith('screenshots/') && ext === '.png')
|
||||
screenshotCount++
|
||||
|
||||
jobs.push({
|
||||
key: `runs/${runId}/${taskId}/${relative}`,
|
||||
filePath: file,
|
||||
contentType: CONTENT_TYPES[ext] || 'application/octet-stream',
|
||||
})
|
||||
}
|
||||
|
||||
manifestTasks.push({
|
||||
queryId: meta.query_id || taskId,
|
||||
query: meta.query || '',
|
||||
startUrl: meta.start_url || '',
|
||||
status:
|
||||
meta.termination_reason === 'completed'
|
||||
? 'completed'
|
||||
: meta.termination_reason || 'unknown',
|
||||
durationMs: meta.total_duration_ms || 0,
|
||||
screenshotCount: (meta.screenshot_count as number) || screenshotCount,
|
||||
graderResults: meta.grader_results || {},
|
||||
})
|
||||
}
|
||||
|
||||
if (manifestTasks.length === 0) {
|
||||
console.warn(` No completed tasks in ${runId}, skipping`)
|
||||
return
|
||||
}
|
||||
|
||||
console.log(
|
||||
` Uploading ${jobs.length} files across ${manifestTasks.length} tasks...`,
|
||||
)
|
||||
|
||||
let uploaded = 0
|
||||
await runPool(jobs, CONCURRENCY, async (job) => {
|
||||
const body = await readFile(job.filePath)
|
||||
await upload(client, r2Config.bucket, job.key, body, job.contentType)
|
||||
uploaded++
|
||||
if (uploaded % 50 === 0 || uploaded === jobs.length) {
|
||||
console.log(` ${uploaded}/${jobs.length}`)
|
||||
}
|
||||
})
|
||||
|
||||
// Read summary.json if it exists
|
||||
let summaryData: Record<string, unknown> | undefined
|
||||
try {
|
||||
summaryData = JSON.parse(
|
||||
await readFile(join(runDir, 'summary.json'), 'utf-8'),
|
||||
)
|
||||
} catch {}
|
||||
|
||||
// Upload manifest
|
||||
const manifest = {
|
||||
runId,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
agentConfig,
|
||||
dataset,
|
||||
summary: summaryData
|
||||
? {
|
||||
passRate: summaryData.passRate,
|
||||
avgDurationMs: summaryData.avgDurationMs,
|
||||
}
|
||||
: undefined,
|
||||
tasks: manifestTasks,
|
||||
}
|
||||
const manifestBody = Buffer.from(JSON.stringify(manifest, null, 2))
|
||||
await upload(
|
||||
client,
|
||||
r2Config.bucket,
|
||||
`runs/${runId}/manifest.json`,
|
||||
manifestBody,
|
||||
'application/json',
|
||||
)
|
||||
|
||||
// Upload viewer.html to bucket root
|
||||
const viewerPath = join(
|
||||
import.meta.dir,
|
||||
'..',
|
||||
'src',
|
||||
'dashboard',
|
||||
'viewer.html',
|
||||
)
|
||||
const viewerBody = await readFile(viewerPath)
|
||||
await upload(client, r2Config.bucket, 'viewer.html', viewerBody, 'text/html')
|
||||
|
||||
console.log(` Uploaded ${uploaded + 2} files`)
|
||||
console.log(` ${r2Config.cdnBaseUrl}/viewer.html?run=${runId}`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const inputDir = process.argv[2]
|
||||
if (!inputDir) {
|
||||
console.error(
|
||||
'Usage:\n' +
|
||||
' bun scripts/upload-run.ts results/config-name/2026-03-21-1730 (specific run)\n' +
|
||||
' bun scripts/upload-run.ts results/config-name (all un-uploaded runs)',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const dirStat = await stat(inputDir).catch(() => null)
|
||||
if (!dirStat?.isDirectory()) {
|
||||
console.error(`Not a directory: ${inputDir}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const r2Config = loadConfig()
|
||||
const client = createClient(r2Config)
|
||||
|
||||
if (await isRunDir(inputDir)) {
|
||||
// Single run: results/config-name/2026-03-21-1730
|
||||
const timestamp = basename(inputDir)
|
||||
const configName = basename(dirname(inputDir))
|
||||
const runId = `${configName}-${timestamp}`
|
||||
console.log(`Uploading run: ${runId}`)
|
||||
await uploadSingleRun(inputDir, runId, r2Config, client)
|
||||
} else {
|
||||
// Config dir: results/config-name/ — upload all un-uploaded runs
|
||||
const configName = basename(inputDir)
|
||||
const entries = await readdir(inputDir, { withFileTypes: true })
|
||||
const runDirs = entries
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((e) => e.name)
|
||||
.sort()
|
||||
|
||||
if (runDirs.length === 0) {
|
||||
console.error('No run subdirectories found')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Found ${runDirs.length} runs for config "${configName}", checking R2...`,
|
||||
)
|
||||
|
||||
let uploadedCount = 0
|
||||
for (const dir of runDirs) {
|
||||
const runId = `${configName}-${dir}`
|
||||
const alreadyUploaded = await isUploaded(client, r2Config.bucket, runId)
|
||||
if (alreadyUploaded) {
|
||||
console.log(` ${runId}: already uploaded, skipping`)
|
||||
continue
|
||||
}
|
||||
|
||||
console.log(` ${runId}: uploading...`)
|
||||
await uploadSingleRun(join(inputDir, dir), runId, r2Config, client)
|
||||
uploadedCount++
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\nDone. Uploaded ${uploadedCount} new run(s), ${runDirs.length - uploadedCount} already in R2.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
590
packages/browseros-agent/apps/eval/scripts/weekly-report.ts
Normal file
590
packages/browseros-agent/apps/eval/scripts/weekly-report.ts
Normal file
@@ -0,0 +1,590 @@
|
||||
/**
|
||||
* Weekly Report Generator
|
||||
*
|
||||
* Reads all uploaded eval runs from R2, builds cumulative score history,
|
||||
* and generates an HTML dashboard with:
|
||||
* - Config selector dropdown (groups runs by config/runId pattern)
|
||||
* - Config details card (architecture, model, dataset, grader)
|
||||
* - Interactive trend chart (filtered by selected config)
|
||||
* - Stat cards (latest, trend, best, duration)
|
||||
* - Searchable table of all runs
|
||||
*
|
||||
* Usage:
|
||||
* bun apps/eval/scripts/weekly-report.ts [local-output-path]
|
||||
*
|
||||
* Env vars required:
|
||||
* EVAL_R2_ACCOUNT_ID, EVAL_R2_ACCESS_KEY_ID, EVAL_R2_SECRET_ACCESS_KEY
|
||||
* EVAL_R2_BUCKET (default: browseros-eval)
|
||||
*/
|
||||
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import {
|
||||
GetObjectCommand,
|
||||
ListObjectsV2Command,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3'
|
||||
|
||||
interface ManifestTask {
|
||||
queryId: string
|
||||
query: string
|
||||
status: string
|
||||
durationMs: number
|
||||
screenshotCount: number
|
||||
graderResults: Record<string, { pass: boolean; score: number }>
|
||||
}
|
||||
|
||||
interface Manifest {
|
||||
runId: string
|
||||
uploadedAt: string
|
||||
agentConfig?: { type?: string; model?: string }
|
||||
dataset?: string
|
||||
summary?: { passRate?: number; avgDurationMs?: number }
|
||||
tasks: ManifestTask[]
|
||||
}
|
||||
|
||||
interface RunSummary {
|
||||
runId: string
|
||||
configName: string
|
||||
date: string
|
||||
passRate: number
|
||||
total: number
|
||||
completed: number
|
||||
failed: number
|
||||
timeout: number
|
||||
avgDurationMs: number
|
||||
model: string
|
||||
dataset: string
|
||||
agentType: string
|
||||
}
|
||||
|
||||
const PASS_FAIL_GRADER_ORDER = [
|
||||
'performance_grader',
|
||||
'webvoyager_grader',
|
||||
'fara_combined',
|
||||
'fara_grader',
|
||||
]
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const value = process.env[name]
|
||||
if (!value) {
|
||||
console.error(`Missing required env var: ${name}`)
|
||||
process.exit(1)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const accountId = requireEnv('EVAL_R2_ACCOUNT_ID')
|
||||
const accessKeyId = requireEnv('EVAL_R2_ACCESS_KEY_ID')
|
||||
const secretAccessKey = requireEnv('EVAL_R2_SECRET_ACCESS_KEY')
|
||||
const bucket = process.env.EVAL_R2_BUCKET || 'browseros-eval'
|
||||
|
||||
const client = new S3Client({
|
||||
region: 'auto',
|
||||
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
|
||||
credentials: { accessKeyId, secretAccessKey },
|
||||
})
|
||||
|
||||
// Step 1: List all manifest.json files in runs/
|
||||
console.log('Scanning R2 for eval runs...')
|
||||
|
||||
const manifests: Manifest[] = []
|
||||
let continuationToken: string | undefined
|
||||
|
||||
do {
|
||||
const listRes = await client.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: bucket,
|
||||
Prefix: 'runs/',
|
||||
ContinuationToken: continuationToken,
|
||||
}),
|
||||
)
|
||||
|
||||
const manifestKeys =
|
||||
listRes.Contents?.filter((obj) => obj.Key?.endsWith('/manifest.json')).map(
|
||||
(obj) => obj.Key as string,
|
||||
) ?? []
|
||||
|
||||
for (const key of manifestKeys) {
|
||||
try {
|
||||
const res = await client.send(
|
||||
new GetObjectCommand({ Bucket: bucket, Key: key }),
|
||||
)
|
||||
const body = await res.Body?.transformToString()
|
||||
if (body) manifests.push(JSON.parse(body))
|
||||
} catch {
|
||||
console.warn(` Failed to read ${key}, skipping`)
|
||||
}
|
||||
}
|
||||
|
||||
continuationToken = listRes.NextContinuationToken
|
||||
} while (continuationToken)
|
||||
|
||||
console.log(`Found ${manifests.length} runs`)
|
||||
|
||||
if (manifests.length === 0) {
|
||||
console.log('No runs found. Nothing to report.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// Step 2: Build run summaries
|
||||
const runs: RunSummary[] = manifests
|
||||
.map((m) => {
|
||||
const total = m.tasks.length
|
||||
const completed = m.tasks.filter((t) => t.status === 'completed').length
|
||||
const failed = m.tasks.filter((t) => t.status === 'failed').length
|
||||
const timeout = m.tasks.filter((t) => t.status === 'timeout').length
|
||||
|
||||
let graded = 0
|
||||
let passed = 0
|
||||
for (const task of m.tasks) {
|
||||
if (!task.graderResults) continue
|
||||
for (const name of PASS_FAIL_GRADER_ORDER) {
|
||||
if (task.graderResults[name]) {
|
||||
graded++
|
||||
if (task.graderResults[name].pass) passed++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const passRate = graded > 0 ? passed / graded : 0
|
||||
const durations = m.tasks
|
||||
.filter((t) => t.durationMs > 0)
|
||||
.map((t) => t.durationMs)
|
||||
const avgDurationMs =
|
||||
durations.length > 0
|
||||
? durations.reduce((a, b) => a + b, 0) / durations.length
|
||||
: 0
|
||||
|
||||
const date = m.uploadedAt
|
||||
? `${m.uploadedAt.split('T')[0]} ${m.uploadedAt.split('T')[1]?.slice(0, 5) || ''}`
|
||||
: m.runId.slice(0, 15)
|
||||
|
||||
const model = m.agentConfig?.model || 'unknown'
|
||||
const dataset = m.dataset || m.runId
|
||||
const agentType = m.agentConfig?.type || 'unknown'
|
||||
|
||||
const configName = extractConfigName(m.runId)
|
||||
return {
|
||||
runId: m.runId,
|
||||
configName,
|
||||
date,
|
||||
passRate,
|
||||
total,
|
||||
completed,
|
||||
failed,
|
||||
timeout,
|
||||
avgDurationMs,
|
||||
model,
|
||||
dataset,
|
||||
agentType,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.date.localeCompare(b.date))
|
||||
|
||||
// Step 3: Identify unique config groups
|
||||
// runId can be "ci-weekly" (old) or "ci-weekly-2026-03-21-1730" (timestamped)
|
||||
// Extract config name by stripping the date-time suffix pattern
|
||||
function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function extractConfigName(runId: string): string {
|
||||
// "browseros-agent-weekly-2026-03-21-1730" → "browseros-agent-weekly"
|
||||
// "ci-weekly" → "ci-weekly" (no timestamp, old format)
|
||||
return runId.replace(/-\d{4}-\d{2}-\d{2}-\d{4}$/, '')
|
||||
}
|
||||
|
||||
const configGroups = [...new Set(runs.map((r) => r.configName))]
|
||||
const defaultConfig = configGroups.includes('ci-weekly')
|
||||
? 'ci-weekly'
|
||||
: configGroups[0]
|
||||
|
||||
// Step 4: Generate HTML report
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>BrowserOS Eval Dashboard</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; background: #0d1117; color: #e6edf3; padding: 2rem; max-width: 1400px; margin: 0 auto; }
|
||||
|
||||
/* Header */
|
||||
.page-header { display: flex; align-items: center; gap: 16px; margin-bottom: 2rem; flex-wrap: wrap; }
|
||||
.page-header h1 { font-size: 1.5rem; }
|
||||
.page-header h1 span { color: #58a6ff; }
|
||||
.page-header .gen-date { color: #6e7681; font-size: 12px; margin-left: auto; }
|
||||
|
||||
/* Config selector */
|
||||
.config-bar { display: flex; align-items: center; gap: 16px; margin-bottom: 1.5rem; flex-wrap: wrap; }
|
||||
.config-bar label { font-size: 13px; color: #8b949e; font-weight: 600; }
|
||||
.config-bar select { background: #161b22; border: 1px solid #30363d; color: #e6edf3; padding: 8px 12px; border-radius: 6px; font-size: 13px; font-family: 'SF Mono', Consolas, monospace; cursor: pointer; min-width: 200px; }
|
||||
.config-bar select:focus { outline: none; border-color: #58a6ff; }
|
||||
|
||||
/* Config details card */
|
||||
.config-details { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px 20px; margin-bottom: 1.5rem; display: flex; gap: 32px; flex-wrap: wrap; }
|
||||
.config-detail { display: flex; flex-direction: column; gap: 2px; }
|
||||
.config-detail .cd-label { font-size: 10px; font-weight: 600; color: #6e7681; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.config-detail .cd-value { font-size: 13px; color: #e6edf3; font-family: 'SF Mono', Consolas, monospace; }
|
||||
|
||||
/* Stat cards */
|
||||
.stats { display: flex; gap: 1rem; margin-bottom: 1.5rem; flex-wrap: wrap; }
|
||||
.stat-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 1.25rem; flex: 1; min-width: 140px; }
|
||||
.stat-label { color: #8b949e; font-size: 0.8rem; margin-bottom: 0.25rem; }
|
||||
.stat-value { font-size: 1.4rem; font-weight: 600; }
|
||||
.stat-value.big { font-size: 2.5rem; font-weight: 700; }
|
||||
.pass { color: #3fb950; }
|
||||
.fail { color: #f85149; }
|
||||
.neutral { color: #8b949e; }
|
||||
.trend-up { color: #3fb950; }
|
||||
.trend-down { color: #f85149; }
|
||||
.trend-flat { color: #8b949e; }
|
||||
|
||||
/* Chart */
|
||||
.chart-container { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 1.5rem; margin-bottom: 2rem; position: relative; }
|
||||
canvas { width: 100%; height: 300px; }
|
||||
#tooltip { display: none; position: absolute; background: #1c2128; border: 1px solid #30363d; border-radius: 6px; padding: 8px 12px; pointer-events: none; font-size: 12px; z-index: 10; box-shadow: 0 4px 12px rgba(0,0,0,0.4); }
|
||||
|
||||
/* Section headers */
|
||||
.section-header { display: flex; align-items: center; gap: 12px; margin-bottom: 1rem; }
|
||||
.section-header h2 { font-size: 1rem; font-weight: 600; }
|
||||
.section-header .search-input { margin-left: auto; background: #0d1117; border: 1px solid #30363d; color: #e6edf3; padding: 6px 12px; border-radius: 6px; font-size: 12px; font-family: inherit; width: 220px; }
|
||||
.section-header .search-input:focus { outline: none; border-color: #58a6ff; }
|
||||
.section-header .search-input::placeholder { color: #484f58; }
|
||||
|
||||
/* Table */
|
||||
table { width: 100%; border-collapse: collapse; background: #161b22; border: 1px solid #30363d; border-radius: 8px; overflow: hidden; }
|
||||
th, td { padding: 0.65rem 1rem; text-align: left; border-bottom: 1px solid #21262d; }
|
||||
th { background: #1c2128; color: #8b949e; font-weight: 600; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
td { font-size: 0.85rem; }
|
||||
td.mono { font-family: 'SF Mono', Consolas, monospace; font-size: 0.8rem; }
|
||||
a.view-link { color: #58a6ff; text-decoration: none; font-weight: 500; }
|
||||
a.view-link:hover { text-decoration: underline; }
|
||||
tr.hidden { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="page-header">
|
||||
<h1>BrowserOS <span>Eval Dashboard</span></h1>
|
||||
<span class="gen-date">Generated ${new Date().toISOString().split('T')[0]}</span>
|
||||
</div>
|
||||
|
||||
<!-- Config selector -->
|
||||
<div class="config-bar">
|
||||
<label>Config:</label>
|
||||
<select id="config-select">
|
||||
${configGroups.map((c) => `<option value="${escHtml(c)}"${c === defaultConfig ? ' selected' : ''}>${escHtml(c)}</option>`).join('\n ')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Config details (populated by JS) -->
|
||||
<div class="config-details" id="config-details"></div>
|
||||
|
||||
<!-- Stat cards (populated by JS) -->
|
||||
<div class="stats" id="stat-cards"></div>
|
||||
|
||||
<!-- Chart -->
|
||||
<div class="chart-container">
|
||||
<canvas id="chart"></canvas>
|
||||
<div id="tooltip">
|
||||
<div id="tt-date" style="color:#8b949e;margin-bottom:2px;"></div>
|
||||
<div id="tt-score" style="font-size:16px;font-weight:700;"></div>
|
||||
<div id="tt-detail" style="color:#8b949e;margin-top:2px;font-size:11px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent runs table -->
|
||||
<div class="section-header">
|
||||
<h2>All Runs</h2>
|
||||
<input type="text" class="search-input" id="table-search" placeholder="Search runs..." autocomplete="off" spellcheck="false" />
|
||||
</div>
|
||||
<table id="runs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Config</th>
|
||||
<th>Model</th>
|
||||
<th>Dataset</th>
|
||||
<th>Architecture</th>
|
||||
<th>Pass Rate</th>
|
||||
<th>Tasks</th>
|
||||
<th>Timeout</th>
|
||||
<th>Avg Duration</th>
|
||||
<th>View</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${runs
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((r) => {
|
||||
const viewerUrl = `viewer.html?run=${encodeURIComponent(r.runId)}`
|
||||
const passed = Math.round(r.passRate * r.total)
|
||||
const archLabel =
|
||||
r.agentType === 'orchestrator-executor'
|
||||
? 'Orch-Exec'
|
||||
: r.agentType === 'single'
|
||||
? 'Tool Loop'
|
||||
: r.agentType === 'gemini-computer-use'
|
||||
? 'Gemini CU'
|
||||
: r.agentType || '—'
|
||||
return `<tr data-config="${escHtml(r.runId)}" data-search="${escHtml(`${r.date} ${r.runId} ${r.model} ${r.dataset} ${archLabel}`)}">
|
||||
<td>${escHtml(r.date)}</td>
|
||||
<td class="mono">${escHtml(r.runId)}</td>
|
||||
<td class="mono" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escHtml(r.model)}">${escHtml(r.model)}</td>
|
||||
<td>${escHtml(r.dataset)}</td>
|
||||
<td>${escHtml(archLabel)}</td>
|
||||
<td class="${r.passRate >= 0.7 ? 'pass' : r.passRate >= 0.4 ? 'neutral' : 'fail'}">${(r.passRate * 100).toFixed(1)}% <span style="color:#6e7681;font-size:11px;">(${passed}/${r.total})</span></td>
|
||||
<td>${r.total}</td>
|
||||
<td class="${r.timeout > 0 ? 'neutral' : ''}">${r.timeout}</td>
|
||||
<td>${(r.avgDurationMs / 1000).toFixed(0)}s</td>
|
||||
<td><a href="${viewerUrl}" class="view-link">View →</a></td>
|
||||
</tr>`
|
||||
})
|
||||
.join('\n')}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
var allRuns = ${JSON.stringify(runs)};
|
||||
var configSelect = document.getElementById('config-select');
|
||||
var canvas = document.getElementById('chart');
|
||||
var ctx = canvas.getContext('2d');
|
||||
var tooltip = document.getElementById('tooltip');
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
var dotPositions = [];
|
||||
|
||||
function getFilteredRuns() {
|
||||
var cfg = configSelect.value;
|
||||
return allRuns.filter(function(r) { return r.configName === cfg; });
|
||||
}
|
||||
|
||||
function updateDashboard() {
|
||||
var runs = getFilteredRuns();
|
||||
renderConfigDetails(runs);
|
||||
renderStatCards(runs);
|
||||
drawChart(runs);
|
||||
}
|
||||
|
||||
// Config details card
|
||||
function renderConfigDetails(runs) {
|
||||
var el = document.getElementById('config-details');
|
||||
if (runs.length === 0) { el.innerHTML = '<span style="color:#6e7681;">No runs found for this config.</span>'; return; }
|
||||
var latest = runs[runs.length - 1];
|
||||
var archLabel = latest.agentType === 'orchestrator-executor' ? 'Orchestrator-Executor'
|
||||
: latest.agentType === 'single' ? 'Single Agent (Tool Loop)'
|
||||
: latest.agentType === 'gemini-computer-use' ? 'Gemini Computer Use'
|
||||
: latest.agentType || 'Unknown';
|
||||
el.innerHTML =
|
||||
'<div class="config-detail"><span class="cd-label">Architecture</span><span class="cd-value">' + archLabel + '</span></div>' +
|
||||
'<div class="config-detail"><span class="cd-label">Model</span><span class="cd-value">' + (latest.model || 'unknown') + '</span></div>' +
|
||||
'<div class="config-detail"><span class="cd-label">Dataset</span><span class="cd-value">' + (latest.dataset || 'unknown') + '</span></div>' +
|
||||
'<div class="config-detail"><span class="cd-label">Tasks</span><span class="cd-value">' + latest.total + '</span></div>' +
|
||||
'<div class="config-detail"><span class="cd-label">Runs</span><span class="cd-value">' + runs.length + '</span></div>';
|
||||
}
|
||||
|
||||
// Stat cards
|
||||
function renderStatCards(runs) {
|
||||
var el = document.getElementById('stat-cards');
|
||||
if (runs.length === 0) { el.innerHTML = ''; return; }
|
||||
var latest = runs[runs.length - 1];
|
||||
var prev = runs.length >= 2 ? runs[runs.length - 2] : null;
|
||||
var best = Math.max.apply(null, runs.map(function(r) { return r.passRate; }));
|
||||
var delta = prev ? latest.passRate - prev.passRate : 0;
|
||||
var sign = delta > 0 ? '+' : '';
|
||||
var trendCls = delta > 0 ? 'trend-up' : delta < 0 ? 'trend-down' : 'trend-flat';
|
||||
|
||||
el.innerHTML =
|
||||
'<div class="stat-card"><div class="stat-label">Latest Pass Rate</div><div class="stat-value big ' + (latest.passRate >= 0.7 ? 'pass' : 'fail') + '">' + (latest.passRate * 100).toFixed(1) + '%</div></div>' +
|
||||
'<div class="stat-card"><div class="stat-label">Trend</div><div class="stat-value ' + trendCls + '">' + (prev ? sign + (delta * 100).toFixed(1) + ' pp' : 'N/A') + '</div></div>' +
|
||||
'<div class="stat-card"><div class="stat-label">Best Score</div><div class="stat-value pass">' + (best * 100).toFixed(1) + '%</div></div>' +
|
||||
'<div class="stat-card"><div class="stat-label">Avg Duration</div><div class="stat-value">' + (latest.avgDurationMs / 1000).toFixed(0) + 's</div></div>' +
|
||||
'<div class="stat-card"><div class="stat-label">Runs</div><div class="stat-value">' + runs.length + '</div></div>';
|
||||
}
|
||||
|
||||
// Chart
|
||||
function drawChart(runs) {
|
||||
var rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
var W = rect.width, H = rect.height;
|
||||
var pad = { top: 20, right: 20, bottom: 50, left: 50 };
|
||||
var plotW = W - pad.left - pad.right;
|
||||
var plotH = H - pad.top - pad.bottom;
|
||||
dotPositions = [];
|
||||
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
if (runs.length === 0) {
|
||||
ctx.fillStyle = '#8b949e';
|
||||
ctx.font = '14px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('No data for this config', W / 2, H / 2);
|
||||
return;
|
||||
}
|
||||
|
||||
var scores = runs.map(function(r) { return r.passRate * 100; });
|
||||
var minY = Math.max(0, Math.floor(Math.min.apply(null, scores) / 10) * 10 - 10);
|
||||
var maxY = Math.min(100, Math.ceil(Math.max.apply(null, scores) / 10) * 10 + 10);
|
||||
if (minY === maxY) { minY = Math.max(0, minY - 10); maxY = Math.min(100, maxY + 10); }
|
||||
|
||||
// Grid
|
||||
ctx.strokeStyle = '#21262d';
|
||||
ctx.lineWidth = 1;
|
||||
for (var y = minY; y <= maxY; y += 10) {
|
||||
var py = pad.top + plotH - ((y - minY) / (maxY - minY)) * plotH;
|
||||
ctx.beginPath(); ctx.moveTo(pad.left, py); ctx.lineTo(pad.left + plotW, py); ctx.stroke();
|
||||
ctx.fillStyle = '#8b949e'; ctx.font = '11px sans-serif'; ctx.textAlign = 'right';
|
||||
ctx.fillText(y + '%', pad.left - 8, py + 4);
|
||||
}
|
||||
|
||||
// X labels
|
||||
ctx.fillStyle = '#8b949e'; ctx.font = '11px sans-serif'; ctx.textAlign = 'center';
|
||||
runs.forEach(function(r, i) {
|
||||
var px = pad.left + (runs.length === 1 ? plotW / 2 : (i / (runs.length - 1)) * plotW);
|
||||
ctx.save(); ctx.translate(px, pad.top + plotH + 15); ctx.rotate(-Math.PI / 6);
|
||||
ctx.fillText(r.date, 0, 0); ctx.restore();
|
||||
});
|
||||
|
||||
// Line
|
||||
ctx.strokeStyle = '#58a6ff'; ctx.lineWidth = 2; ctx.beginPath();
|
||||
runs.forEach(function(r, i) {
|
||||
var px = pad.left + (runs.length === 1 ? plotW / 2 : (i / (runs.length - 1)) * plotW);
|
||||
var py2 = pad.top + plotH - ((r.passRate * 100 - minY) / (maxY - minY)) * plotH;
|
||||
if (i === 0) ctx.moveTo(px, py2); else ctx.lineTo(px, py2);
|
||||
});
|
||||
ctx.stroke();
|
||||
|
||||
// Dots
|
||||
runs.forEach(function(r, i) {
|
||||
var px = pad.left + (runs.length === 1 ? plotW / 2 : (i / (runs.length - 1)) * plotW);
|
||||
var py2 = pad.top + plotH - ((r.passRate * 100 - minY) / (maxY - minY)) * plotH;
|
||||
dotPositions.push({ x: px, y: py2, run: r });
|
||||
ctx.beginPath(); ctx.arc(px, py2, 4, 0, Math.PI * 2);
|
||||
ctx.fillStyle = r.passRate >= 0.7 ? '#3fb950' : '#f85149';
|
||||
ctx.fill(); ctx.strokeStyle = '#0d1117'; ctx.lineWidth = 2; ctx.stroke();
|
||||
});
|
||||
}
|
||||
|
||||
// Tooltip
|
||||
canvas.addEventListener('mousemove', function(e) {
|
||||
var rect = canvas.getBoundingClientRect();
|
||||
var mx = e.clientX - rect.left, my = e.clientY - rect.top;
|
||||
var closest = null, closestDist = Infinity;
|
||||
dotPositions.forEach(function(dot) {
|
||||
var d = Math.sqrt(Math.pow(mx - dot.x, 2) + Math.pow(my - dot.y, 2));
|
||||
if (d < closestDist) { closestDist = d; closest = dot; }
|
||||
});
|
||||
|
||||
if (closest && closestDist < 40) {
|
||||
var r = closest.run;
|
||||
var passed = Math.round(r.passRate * r.total);
|
||||
document.getElementById('tt-date').textContent = r.date;
|
||||
document.getElementById('tt-score').textContent = (r.passRate * 100).toFixed(1) + '%';
|
||||
document.getElementById('tt-score').style.color = r.passRate >= 0.7 ? '#3fb950' : '#f85149';
|
||||
document.getElementById('tt-detail').textContent = passed + '/' + r.total + ' pass \\u00B7 ' + (r.avgDurationMs / 1000).toFixed(0) + 's avg \\u00B7 ' + r.model;
|
||||
tooltip.style.display = 'block';
|
||||
|
||||
var tx = closest.x + 12, ty = closest.y - 50;
|
||||
if (tx + 200 > rect.width) tx = closest.x - 210;
|
||||
if (ty < 0) ty = closest.y + 12;
|
||||
tooltip.style.left = tx + 'px'; tooltip.style.top = ty + 'px';
|
||||
|
||||
// Highlight dot
|
||||
drawChart(getFilteredRuns());
|
||||
ctx.beginPath(); ctx.arc(closest.x, closest.y, 7, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(88, 166, 255, 0.3)'; ctx.fill();
|
||||
ctx.beginPath(); ctx.arc(closest.x, closest.y, 5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = r.passRate >= 0.7 ? '#3fb950' : '#f85149'; ctx.fill();
|
||||
ctx.strokeStyle = '#e6edf3'; ctx.lineWidth = 2; ctx.stroke();
|
||||
canvas.style.cursor = 'pointer';
|
||||
} else {
|
||||
tooltip.style.display = 'none';
|
||||
canvas.style.cursor = 'default';
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener('mouseleave', function() {
|
||||
tooltip.style.display = 'none';
|
||||
drawChart(getFilteredRuns());
|
||||
});
|
||||
|
||||
canvas.addEventListener('click', function(e) {
|
||||
var rect = canvas.getBoundingClientRect();
|
||||
var mx = e.clientX - rect.left, my = e.clientY - rect.top;
|
||||
dotPositions.forEach(function(dot) {
|
||||
if (Math.sqrt(Math.pow(mx - dot.x, 2) + Math.pow(my - dot.y, 2)) < 20) {
|
||||
window.open('viewer.html?run=' + encodeURIComponent(dot.run.runId), '_blank');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Config selector change
|
||||
configSelect.addEventListener('change', function() {
|
||||
tooltip.style.display = 'none';
|
||||
updateDashboard();
|
||||
});
|
||||
|
||||
// Table search
|
||||
document.getElementById('table-search').addEventListener('input', function(e) {
|
||||
var q = e.target.value.toLowerCase();
|
||||
var rows = document.querySelectorAll('#runs-table tbody tr');
|
||||
rows.forEach(function(row) {
|
||||
var searchText = row.getAttribute('data-search') || '';
|
||||
row.classList.toggle('hidden', q && searchText.toLowerCase().indexOf(q) === -1);
|
||||
});
|
||||
});
|
||||
|
||||
// Resize
|
||||
window.addEventListener('resize', function() { tooltip.style.display = 'none'; drawChart(getFilteredRuns()); });
|
||||
|
||||
// Init
|
||||
updateDashboard();
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// Step 5: Save locally and upload to R2
|
||||
const localPath = process.argv[2] || '/tmp/eval-report.html'
|
||||
await writeFile(localPath, html)
|
||||
console.log(`Report saved locally: ${localPath}`)
|
||||
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: 'report.html',
|
||||
Body: html,
|
||||
ContentType: 'text/html',
|
||||
CacheControl: 'public, max-age=300',
|
||||
}),
|
||||
)
|
||||
|
||||
const cdnBaseUrl = (
|
||||
process.env.EVAL_R2_CDN_BASE_URL || 'https://eval.browseros.com'
|
||||
).replace(/\/+$/, '')
|
||||
|
||||
console.log(`Report uploaded to R2: ${bucket}/report.html`)
|
||||
console.log(` View at: ${cdnBaseUrl}/report.html`)
|
||||
|
||||
// Print summary
|
||||
console.log('\nScore trend:')
|
||||
for (const run of runs.slice(-10)) {
|
||||
const bar = '\u2588'.repeat(Math.round(run.passRate * 20))
|
||||
const pct = (run.passRate * 100).toFixed(0).padStart(3)
|
||||
console.log(` ${run.date} ${pct}% ${bar}`)
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* Eval-specific constants shared across agents, runners, and capture modules.
|
||||
*/
|
||||
|
||||
export const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000 // 15 minutes
|
||||
export const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000 // 30 minutes
|
||||
export const SCREENSHOT_TIMEOUT_MS = 65_000 // 65s — ensures we get extension's error (60s)
|
||||
export const MAX_ACTIONS_PER_DELEGATION = 15
|
||||
export const CLADO_REQUEST_TIMEOUT_MS = 120_000
|
||||
|
||||
@@ -354,7 +354,7 @@
|
||||
</div>
|
||||
<div class="config-field">
|
||||
<label>Timeout (ms)</label>
|
||||
<input type="number" id="cfg-timeout" value="600000" min="30000" max="3600000">
|
||||
<input type="number" id="cfg-timeout" value="1800000" min="30000" max="3600000">
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-row" style="gap: 16px;">
|
||||
@@ -454,6 +454,17 @@
|
||||
<button class="btn-run" id="btn-run" onclick="submitConfig()">Run Eval</button>
|
||||
</div>
|
||||
|
||||
<!-- Load previous run -->
|
||||
<div class="config-actions">
|
||||
<div class="load-config">
|
||||
<label>Load run:</label>
|
||||
<select id="cfg-run-select">
|
||||
<option value="">-- select --</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn-secondary" onclick="loadPreviousRun()">Load Run</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -529,6 +540,7 @@ async function init() {
|
||||
|
||||
// Load saved configs into dropdown
|
||||
loadConfigList();
|
||||
loadRunList();
|
||||
|
||||
if (mode.configMode) {
|
||||
// Config mode — show panel expanded
|
||||
@@ -656,6 +668,53 @@ async function loadConfigList() {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadRunList() {
|
||||
try {
|
||||
const res = await fetch('/api/runs');
|
||||
const runs = await res.json();
|
||||
const sel = document.getElementById('cfg-run-select');
|
||||
sel.innerHTML = '<option value="">-- select --</option>';
|
||||
runs.forEach(r => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = r;
|
||||
opt.textContent = r;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadPreviousRun() {
|
||||
const runName = document.getElementById('cfg-run-select').value;
|
||||
if (!runName) return;
|
||||
const errEl = document.getElementById('config-error');
|
||||
errEl.textContent = '';
|
||||
try {
|
||||
const res = await fetch('/api/load-run', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ runName }),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (!res.ok) {
|
||||
errEl.textContent = result.error || 'Failed to load run';
|
||||
return;
|
||||
}
|
||||
const stateRes = await fetch('/api/state');
|
||||
const state = await stateRes.json();
|
||||
document.getElementById('config-name').textContent =
|
||||
state.configName ? `${state.configName} \u00B7 ${state.agentType}` : '';
|
||||
tasks = state.tasks;
|
||||
setConfigPanelOpen(false);
|
||||
updateConfigSummary(state.configName, state.agentType);
|
||||
selectedTaskId = null;
|
||||
renderTaskList();
|
||||
updateProgress();
|
||||
if (tasks.length > 0) selectTask(tasks[0].queryId);
|
||||
} catch (e) {
|
||||
errEl.textContent = `Network error: ${e.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSavedConfig(name) {
|
||||
if (!name) return;
|
||||
try {
|
||||
@@ -1055,7 +1114,8 @@ function renderTaskList() {
|
||||
if (t.graderResults) {
|
||||
const primary = getPrimaryGrader(t.graderResults);
|
||||
if (primary) {
|
||||
graderBadge = `<span class="grade-badge ${primary.pass ? 'pass' : 'fail'}">${primary.pass ? 'PASS' : 'FAIL'}</span>`;
|
||||
const pct = typeof primary.score === 'number' ? `${(primary.score * 100).toFixed(0)}%` : (primary.pass ? 'PASS' : 'FAIL');
|
||||
graderBadge = `<span class="grade-badge ${primary.pass ? 'pass' : 'fail'}">${pct}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1144,12 +1204,35 @@ function toggleAutoplay() {
|
||||
// ============================================================================
|
||||
// Agent Stream
|
||||
// ============================================================================
|
||||
function renderStreamForTask(taskId) {
|
||||
async function renderStreamForTask(taskId) {
|
||||
const body = document.getElementById('stream-body');
|
||||
body.innerHTML = '';
|
||||
const events = streamEvents[taskId] || [];
|
||||
events.forEach(e => appendStreamEntry(e, false));
|
||||
body.scrollTop = body.scrollHeight;
|
||||
if (events.length > 0) {
|
||||
events.forEach(e => appendStreamEntry(e, false));
|
||||
body.scrollTop = body.scrollHeight;
|
||||
return;
|
||||
}
|
||||
const task = tasks.find(t => t.queryId === taskId);
|
||||
if (!task || task.status === 'pending' || task.status === 'running') return;
|
||||
body.innerHTML = '<div class="empty-state">Loading events...</div>';
|
||||
try {
|
||||
const res = await fetch(`/api/messages/${taskId}`);
|
||||
if (!res.ok) {
|
||||
body.innerHTML = '<div class="empty-state">No event log available</div>';
|
||||
return;
|
||||
}
|
||||
const text = await res.text();
|
||||
const parsed = text.trim().split('\n').filter(Boolean).map(line => {
|
||||
try { return JSON.parse(line); } catch { return null; }
|
||||
}).filter(Boolean);
|
||||
streamEvents[taskId] = parsed;
|
||||
body.innerHTML = '';
|
||||
parsed.forEach(e => appendStreamEntry(e, false));
|
||||
body.scrollTop = body.scrollHeight;
|
||||
} catch {
|
||||
body.innerHTML = '<div class="empty-state">Failed to load events</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function appendStreamEntry(event, scroll = true) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdir, readdir, readFile } from 'node:fs/promises'
|
||||
import { mkdir, readdir, readFile, stat } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { Hono } from 'hono'
|
||||
import { streamSSE } from 'hono/streaming'
|
||||
@@ -199,6 +199,133 @@ app.get('/api/screenshots/:taskId/:index', async (c) => {
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/messages/:taskId', async (c) => {
|
||||
const { taskId } = c.req.param()
|
||||
if (taskId.includes('..') || taskId.includes('/')) {
|
||||
return c.json({ error: 'Invalid parameters' }, 400)
|
||||
}
|
||||
const filepath = join(dashboardState.outputDir, taskId, 'messages.jsonl')
|
||||
const resolved = resolve(filepath)
|
||||
if (!resolved.startsWith(resolve(dashboardState.outputDir))) {
|
||||
return c.json({ error: 'Invalid path' }, 400)
|
||||
}
|
||||
try {
|
||||
const file = Bun.file(filepath)
|
||||
if (!(await file.exists())) return c.notFound()
|
||||
const data = await file.arrayBuffer()
|
||||
return c.body(data, 200, {
|
||||
'Content-Type': 'application/x-ndjson',
|
||||
'Cache-Control': 'no-cache',
|
||||
})
|
||||
} catch {
|
||||
return c.notFound()
|
||||
}
|
||||
})
|
||||
|
||||
const resultsDir = join(import.meta.dir, '..', '..', 'results')
|
||||
|
||||
app.get('/api/runs', async (c) => {
|
||||
try {
|
||||
const runs: string[] = []
|
||||
const entries = await readdir(resultsDir, { withFileTypes: true })
|
||||
for (const entry of entries.filter((e) => e.isDirectory())) {
|
||||
const subEntries = await readdir(join(resultsDir, entry.name), {
|
||||
withFileTypes: true,
|
||||
}).catch(() => [] as import('node:fs').Dirent[])
|
||||
const hasTimestampDirs = subEntries.some(
|
||||
(s) => s.isDirectory() && /^\d{4}-\d{2}-\d{2}-\d{4}$/.test(s.name),
|
||||
)
|
||||
if (hasTimestampDirs) {
|
||||
for (const sub of subEntries.filter((s) => s.isDirectory())) {
|
||||
runs.push(`${entry.name}/${sub.name}`)
|
||||
}
|
||||
} else {
|
||||
runs.push(entry.name)
|
||||
}
|
||||
}
|
||||
runs.sort().reverse()
|
||||
return c.json(runs)
|
||||
} catch {
|
||||
return c.json([])
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/load-run', async (c) => {
|
||||
if (evalRunning)
|
||||
return c.json({ error: 'Cannot load while eval is running' }, 409)
|
||||
let body: { runName: string }
|
||||
try {
|
||||
body = await c.req.json()
|
||||
} catch {
|
||||
return c.json({ error: 'Invalid JSON body' }, 400)
|
||||
}
|
||||
const runName = body.runName
|
||||
if (!runName || runName.includes('..')) {
|
||||
return c.json({ error: 'Invalid run name' }, 400)
|
||||
}
|
||||
if ((runName.match(/\//g) || []).length > 1) {
|
||||
return c.json({ error: 'Invalid run name' }, 400)
|
||||
}
|
||||
const outputDir = resolve(resultsDir, runName)
|
||||
if (!outputDir.startsWith(resolve(resultsDir))) {
|
||||
return c.json({ error: 'Invalid path' }, 400)
|
||||
}
|
||||
const dirStat = await stat(outputDir).catch(() => null)
|
||||
if (!dirStat?.isDirectory()) {
|
||||
return c.json({ error: 'Run directory not found' }, 404)
|
||||
}
|
||||
const entries = await readdir(outputDir, { withFileTypes: true })
|
||||
const taskDirs = entries.filter((e) => e.isDirectory())
|
||||
const loadedTasks: DashboardTask[] = []
|
||||
let agentType = ''
|
||||
for (const taskDir of taskDirs) {
|
||||
const metaPath = join(outputDir, taskDir.name, 'metadata.json')
|
||||
try {
|
||||
const raw = JSON.parse(await readFile(metaPath, 'utf-8'))
|
||||
if (!agentType && raw.agent_config?.type) {
|
||||
agentType = raw.agent_config.type
|
||||
}
|
||||
const screenshotDir = join(outputDir, taskDir.name, 'screenshots')
|
||||
let screenshotCount = raw.screenshot_count ?? 0
|
||||
if (!screenshotCount) {
|
||||
try {
|
||||
const files = await readdir(screenshotDir)
|
||||
screenshotCount = files.filter((f: string) =>
|
||||
f.endsWith('.png'),
|
||||
).length
|
||||
} catch {}
|
||||
}
|
||||
loadedTasks.push({
|
||||
queryId: raw.query_id || taskDir.name,
|
||||
query: raw.query || '',
|
||||
startUrl: raw.start_url,
|
||||
status:
|
||||
raw.termination_reason === 'completed'
|
||||
? 'completed'
|
||||
: raw.termination_reason === 'timeout'
|
||||
? 'timeout'
|
||||
: 'failed',
|
||||
durationMs: raw.total_duration_ms,
|
||||
graderResults: raw.grader_results,
|
||||
screenshotCount,
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
if (loadedTasks.length === 0) {
|
||||
return c.json({ error: 'No completed tasks found in this run' }, 404)
|
||||
}
|
||||
dashboardState.configName = runName
|
||||
dashboardState.agentType = agentType
|
||||
dashboardState.outputDir = outputDir
|
||||
dashboardState.tasks = loadedTasks
|
||||
return c.json({
|
||||
status: 'loaded',
|
||||
configName: runName,
|
||||
agentType,
|
||||
taskCount: loadedTasks.length,
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Config & Run API
|
||||
// ============================================================================
|
||||
|
||||
1270
packages/browseros-agent/apps/eval/src/dashboard/viewer.html
Normal file
1270
packages/browseros-agent/apps/eval/src/dashboard/viewer.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -53,8 +53,8 @@ The raw event stream — one JSON object per line with a "type" field.
|
||||
- "tool-output-error" / "tool-input-error" — Tool call failed. Fields: toolCallId, error.
|
||||
- "text-delta" — Agent's reasoning text. Field: delta (small text chunk).
|
||||
|
||||
**Event types to AVOID reading:**
|
||||
- "tool-output-available" — Tool output. The "output" field contains FULL PAGE DOM CONTENT — hundreds of interactive elements, entire page text, etc. These lines are 5-50KB each. NEVER read them. The tool-input-available lines already tell you what the agent did. Screenshots show you the visual result.
|
||||
**Event types to handle carefully:**
|
||||
- "tool-output-available" — Tool output. The "output" field contains FULL PAGE DOM CONTENT — hundreds of interactive elements, entire page text, etc. These lines are 5-50KB each. NEVER read them in bulk. However, you CAN and SHOULD use Grep to search within these lines for specific keywords when screenshots alone can't verify a claim. For example, if the task asks "find the price of X" and the screenshot is unclear, grep messages.jsonl for the product name or price value to confirm the agent actually saw it in the DOM.
|
||||
|
||||
### 2. screenshots/ directory
|
||||
Numbered PNG screenshots (1.png, 2.png, ...) captured after each tool execution.
|
||||
@@ -95,6 +95,13 @@ Grep for "tool-output-error" or "tool-input-error". If none found, zero errors.
|
||||
**Step 3: Sample reasoning (only if needed for reasoning_quality)**
|
||||
Grep for "text-delta" but LIMIT to the first 10 and last 10 results. Don't read all reasoning text.
|
||||
|
||||
**Step 4: Verify claims from DOM content (critical for task_completion)**
|
||||
When the agent's final answer contains specific data (prices, names, dates, counts, etc.) that you can't confirm from screenshots alone, use Grep to search messages.jsonl for those specific values or keywords. This searches the tool-output-available lines which contain DOM content the agent actually saw. For example:
|
||||
- Task asks "find cheapest flight price" → grep for the dollar amount from the final answer
|
||||
- Task asks "list the top 3 articles" → grep for the article titles mentioned in the answer
|
||||
- Task asks "extract the email address" → grep for the email pattern
|
||||
This is the most reliable way to verify whether the agent actually found the data it claims, since screenshots may be blurry, truncated, or missing the relevant section.
|
||||
|
||||
## How to View Screenshots
|
||||
|
||||
You have {screenshot_count} screenshots. View 3-5 strategically:
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
type PerformanceGraderOptions,
|
||||
} from './types'
|
||||
|
||||
export const DEFAULT_MAX_TURNS = 15
|
||||
export const DEFAULT_MAX_BUDGET_USD = 0.5
|
||||
export const DEFAULT_MAX_TURNS = 100
|
||||
export const DEFAULT_MAX_BUDGET_USD = 100
|
||||
export const DEFAULT_PASS_THRESHOLD = 75
|
||||
const DEFAULT_MODEL = 'claude-opus-4-5-20251101'
|
||||
const GRADER_TIMEOUT_MS = 300_000
|
||||
|
||||
@@ -30,9 +30,15 @@ const MONOREPO_ROOT = join(
|
||||
'../../../..',
|
||||
)
|
||||
|
||||
const BROWSEROS_BINARY = '/Applications/BrowserOS.app/Contents/MacOS/BrowserOS'
|
||||
const BROWSEROS_BINARY =
|
||||
process.env.BROWSEROS_BINARY ||
|
||||
'/Applications/BrowserOS.app/Contents/MacOS/BrowserOS'
|
||||
|
||||
const CONTROLLER_EXT_DIR = join(MONOREPO_ROOT, 'apps/controller-ext/dist')
|
||||
const CAPTCHA_EXT_DIR = join(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
'../../../extensions/nopecha',
|
||||
)
|
||||
|
||||
export class BrowserOSAppManager {
|
||||
private ports: EvalPorts
|
||||
@@ -152,8 +158,15 @@ export class BrowserOSAppManager {
|
||||
`--user-data-dir=${this.tempDir}`,
|
||||
]
|
||||
|
||||
const extensions: string[] = []
|
||||
if (this.loadExtensions && existsSync(CONTROLLER_EXT_DIR)) {
|
||||
chromeArgs.push(`--load-extension=${CONTROLLER_EXT_DIR}`)
|
||||
extensions.push(CONTROLLER_EXT_DIR)
|
||||
}
|
||||
if (existsSync(CAPTCHA_EXT_DIR)) {
|
||||
extensions.push(CAPTCHA_EXT_DIR)
|
||||
}
|
||||
if (extensions.length > 0) {
|
||||
chromeArgs.push(`--load-extension=${extensions.join(',')}`)
|
||||
}
|
||||
|
||||
chromeArgs.push('about:blank')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import {
|
||||
dashboardState,
|
||||
setActiveExecutor,
|
||||
@@ -120,18 +120,31 @@ function resolvePaths(
|
||||
? config.dataset
|
||||
: resolve(configDir, config.dataset)
|
||||
|
||||
// Resolve output directory: use options.outputDir if provided, otherwise resolve from config
|
||||
const outputDir = options.outputDir
|
||||
? options.outputDir
|
||||
: config.output_dir
|
||||
? config.output_dir.startsWith('/')
|
||||
? config.output_dir
|
||||
: resolve(configDir, config.output_dir)
|
||||
: resolve(configDir, 'results')
|
||||
// Resolve output directory: results/{config-name}/{timestamp}/
|
||||
// Config name derived from config filename (e.g., "browseros-agent-weekly.json" → "browseros-agent-weekly")
|
||||
const configName = options.configPath
|
||||
? basename(resolve(options.configPath), '.json')
|
||||
: 'eval'
|
||||
const timestamp = formatTimestamp(new Date())
|
||||
const resultsBase = config.output_dir
|
||||
? config.output_dir.startsWith('/')
|
||||
? config.output_dir
|
||||
: resolve(configDir, config.output_dir)
|
||||
: resolve(configDir, '..', 'results')
|
||||
const outputDir = join(resultsBase, configName, timestamp)
|
||||
|
||||
return { dataPath, outputDir }
|
||||
}
|
||||
|
||||
function formatTimestamp(date: Date): string {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
const h = String(date.getHours()).padStart(2, '0')
|
||||
const min = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${y}-${m}-${d}-${h}${min}`
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Task Loading
|
||||
// ============================================================================
|
||||
@@ -229,6 +242,12 @@ function printTaskProgress(
|
||||
if (result.status === 'failed') {
|
||||
console.log(` ERROR: ${result.error.message}`)
|
||||
} else if (isSuccessfulResult(result)) {
|
||||
// Log agent errors (e.g., LLM API failures) even if task "completed"
|
||||
if (result.agentResult.metadata.errors?.length) {
|
||||
for (const err of result.agentResult.metadata.errors) {
|
||||
console.log(` ERROR [${err.source}]: ${err.message}`)
|
||||
}
|
||||
}
|
||||
for (const [name, gr] of Object.entries(result.graderResults)) {
|
||||
const icon = gr.pass ? 'PASS' : 'FAIL'
|
||||
console.log(` ${name}: ${icon}`)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@browseros/server",
|
||||
"version": "0.0.77",
|
||||
"version": "0.0.79",
|
||||
"description": "BrowserOS server",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
@@ -89,11 +89,11 @@
|
||||
"fuse.js": "^7.1.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"hono": "^4.12.3",
|
||||
"jimp": "^1.6.0",
|
||||
"klavis": "^2.15.0",
|
||||
"pino": "^9.6.0",
|
||||
"posthog-node": "^4.17.0",
|
||||
"puppeteer-core": "24.23.0",
|
||||
"sharp": "^0.34.5",
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.24.2",
|
||||
"zod-from-json-schema": "^0.1.0"
|
||||
|
||||
@@ -8,6 +8,7 @@ import { EXTERNAL_URLS } from '@browseros/shared/constants/urls'
|
||||
import { LLM_PROVIDERS } from '@browseros/shared/schemas/llm'
|
||||
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
|
||||
import type { LanguageModel } from 'ai'
|
||||
import { createBrowserOSFetch } from '../lib/browseros-fetch'
|
||||
import { createCodexFetch } from '../lib/clients/oauth/codex-fetch'
|
||||
import { createCopilotFetch } from '../lib/clients/oauth/copilot-fetch'
|
||||
import { logger } from '../lib/logger'
|
||||
@@ -104,26 +105,38 @@ function createBrowserOSFactory(
|
||||
config: ResolvedAgentConfig,
|
||||
): (modelId: string) => unknown {
|
||||
if (!config.baseUrl) throw new Error('BrowserOS provider requires baseUrl')
|
||||
const { baseUrl, apiKey, upstreamProvider } = config
|
||||
const { baseUrl, apiKey, upstreamProvider, browserosId } = config
|
||||
const browserosFetch = browserosId
|
||||
? createBrowserOSFetch(browserosId)
|
||||
: createOpenRouterCompatibleFetch()
|
||||
|
||||
if (upstreamProvider === LLM_PROVIDERS.OPENROUTER) {
|
||||
return createOpenRouter({
|
||||
baseURL: baseUrl,
|
||||
...(apiKey && { apiKey }),
|
||||
fetch: createOpenRouterCompatibleFetch(),
|
||||
fetch: browserosFetch,
|
||||
})
|
||||
}
|
||||
if (upstreamProvider === LLM_PROVIDERS.ANTHROPIC) {
|
||||
return createAnthropic({ baseURL: baseUrl, ...(apiKey && { apiKey }) })
|
||||
return createAnthropic({
|
||||
baseURL: baseUrl,
|
||||
...(apiKey && { apiKey }),
|
||||
fetch: browserosFetch,
|
||||
})
|
||||
}
|
||||
if (upstreamProvider === LLM_PROVIDERS.AZURE) {
|
||||
return createAzure({ baseURL: baseUrl, ...(apiKey && { apiKey }) })
|
||||
return createAzure({
|
||||
baseURL: baseUrl,
|
||||
...(apiKey && { apiKey }),
|
||||
fetch: browserosFetch,
|
||||
})
|
||||
}
|
||||
logger.info('creating openai-compatible')
|
||||
logger.debug('Creating OpenAI-compatible provider for BrowserOS')
|
||||
return createOpenAICompatible({
|
||||
name: 'browseros',
|
||||
baseURL: baseUrl,
|
||||
...(apiKey && { apiKey }),
|
||||
fetch: browserosFetch,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -46,4 +46,6 @@ export interface ResolvedAgentConfig {
|
||||
isScheduledTask?: boolean
|
||||
/** Apps the user previously declined to connect via MCP (chose "do it manually"). */
|
||||
declinedApps?: string[]
|
||||
/** BrowserOS installation ID for credit-based tracking. */
|
||||
browserosId?: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono'
|
||||
import { fetchCredits } from '../../lib/clients/gateway'
|
||||
import { logger } from '../../lib/logger'
|
||||
|
||||
interface CreditsDeps {
|
||||
browserosId?: string
|
||||
gatewayBaseUrl?: string
|
||||
}
|
||||
|
||||
export function createCreditsRoutes(deps: CreditsDeps) {
|
||||
const { browserosId, gatewayBaseUrl } = deps
|
||||
|
||||
if (!browserosId || !gatewayBaseUrl) {
|
||||
return new Hono().all('/*', (c) =>
|
||||
c.json({ error: 'Credits not configured' }, 503),
|
||||
)
|
||||
}
|
||||
|
||||
return new Hono().get('/', async (c) => {
|
||||
try {
|
||||
const credits = await fetchCredits(gatewayBaseUrl, browserosId)
|
||||
return c.json(credits)
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch credits', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return c.json({ error: 'Failed to fetch credits' }, 502)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -50,11 +50,19 @@ export function createOAuthRoutes(deps: OAuthRouteDeps) {
|
||||
provider: providerId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to start authentication. Please try again.'
|
||||
return c.json({ error: message }, 500)
|
||||
: 'Failed to start authentication.'
|
||||
|
||||
// Port conflict — clear actionable message
|
||||
const status =
|
||||
error instanceof Error && error.message.includes('callback port')
|
||||
? 503
|
||||
: 500
|
||||
|
||||
return c.json({ error: message }, status)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -14,11 +14,13 @@ import { Hono } from 'hono'
|
||||
import { cors } from 'hono/cors'
|
||||
import type { ContentfulStatusCode } from 'hono/utils/http-status'
|
||||
import { HttpAgentError } from '../agent/errors'
|
||||
import { INLINED_ENV } from '../env'
|
||||
import { KlavisClient } from '../lib/clients/klavis/klavis-client'
|
||||
import { initializeOAuth } from '../lib/clients/oauth'
|
||||
import { getDb } from '../lib/db'
|
||||
import { logger } from '../lib/logger'
|
||||
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'
|
||||
@@ -78,7 +80,7 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
|
||||
const { onShutdown } = config
|
||||
|
||||
// Initialize OAuth token manager + callback server (port released on process exit)
|
||||
// Initialize OAuth token manager (callback server binds lazily on first PKCE login)
|
||||
const tokenManager = browserosId
|
||||
? initializeOAuth(getDb(), browserosId)
|
||||
: null
|
||||
@@ -108,6 +110,7 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
'/shutdown',
|
||||
createShutdownRoute({
|
||||
onShutdown: () => {
|
||||
tokenManager?.stopCallbackServer()
|
||||
klavisProxy?.close().catch((err) =>
|
||||
logger.warn('Failed to close Klavis proxy transport', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
@@ -132,6 +135,15 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
),
|
||||
)
|
||||
.route('/klavis', createKlavisRoutes({ browserosId: browserosId || '' }))
|
||||
.route(
|
||||
'/credits',
|
||||
createCreditsRoutes({
|
||||
browserosId,
|
||||
gatewayBaseUrl: INLINED_ENV.BROWSEROS_CONFIG_URL
|
||||
? new URL(INLINED_ENV.BROWSEROS_CONFIG_URL).origin
|
||||
: undefined,
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
'/mcp',
|
||||
createMcpRoutes({
|
||||
|
||||
@@ -64,6 +64,7 @@ export class ChatService {
|
||||
chatMode: request.mode === 'chat',
|
||||
isScheduledTask: request.isScheduledTask,
|
||||
declinedApps: request.declinedApps,
|
||||
browserosId: this.deps.browserosId,
|
||||
}
|
||||
|
||||
let session = sessionStore.get(request.conversationId)
|
||||
|
||||
@@ -701,6 +701,17 @@ export class Browser {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Element Geometry ---
|
||||
|
||||
async getElementCenter(
|
||||
page: number,
|
||||
element: number,
|
||||
): Promise<{ x: number; y: number }> {
|
||||
const session = await this.resolveSession(page)
|
||||
await elements.scrollIntoView(session, element)
|
||||
return elements.getElementCenter(session, element)
|
||||
}
|
||||
|
||||
// --- Input ---
|
||||
|
||||
async click(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { mkdir, readdir, rm, stat } from 'node:fs/promises'
|
||||
import { unlinkSync } from 'node:fs'
|
||||
import { mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PATHS } from '@browseros/shared/constants/paths'
|
||||
import type { ServerDiscoveryConfig } from '@browseros/shared/types/server-config'
|
||||
import { logger } from './logger'
|
||||
|
||||
export function getBrowserosDir(): string {
|
||||
@@ -32,6 +34,24 @@ export function getBuiltinSkillsDir(): string {
|
||||
return join(getSkillsDir(), PATHS.BUILTIN_DIR_NAME)
|
||||
}
|
||||
|
||||
export function getServerConfigPath(): string {
|
||||
return join(getBrowserosDir(), PATHS.SERVER_CONFIG_FILE_NAME)
|
||||
}
|
||||
|
||||
export async function writeServerConfig(
|
||||
config: ServerDiscoveryConfig,
|
||||
): Promise<void> {
|
||||
await writeFile(getServerConfigPath(), `${JSON.stringify(config, null, 2)}\n`)
|
||||
}
|
||||
|
||||
export function removeServerConfigSync(): void {
|
||||
try {
|
||||
unlinkSync(getServerConfigPath())
|
||||
} catch {
|
||||
// File may not exist or already be removed
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureBrowserosDir(): Promise<void> {
|
||||
await mkdir(getMemoryDir(), { recursive: true })
|
||||
await mkdir(getSkillsDir(), { recursive: true })
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Custom fetch for BrowserOS gateway requests.
|
||||
* Adds X-BrowserOS-ID header for credit tracking,
|
||||
* handles CREDITS_EXHAUSTED (429), and extracts OpenRouter-style error details.
|
||||
*/
|
||||
|
||||
import { APICallError } from '@ai-sdk/provider'
|
||||
import { logger } from './logger'
|
||||
|
||||
function resolveUrl(url: RequestInfo | URL): string {
|
||||
return typeof url === 'string' ? url : url.toString()
|
||||
}
|
||||
|
||||
function parseErrorBody(
|
||||
body: string,
|
||||
): { message?: string; code?: string; metadata?: { raw?: unknown } } | null {
|
||||
try {
|
||||
const parsed = JSON.parse(body)
|
||||
return parsed.error ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function buildErrorMessage(
|
||||
statusCode: number,
|
||||
statusText: string,
|
||||
error: NonNullable<ReturnType<typeof parseErrorBody>>,
|
||||
): string {
|
||||
if (!error.message) return `HTTP ${statusCode}: ${statusText}`
|
||||
let msg = error.message
|
||||
if (error.code) msg = `[${error.code}] ${msg}`
|
||||
if (error.metadata?.raw) msg += ` (${JSON.stringify(error.metadata.raw)})`
|
||||
return msg
|
||||
}
|
||||
|
||||
export function createBrowserOSFetch(browserosId: string): typeof fetch {
|
||||
return (async (url: RequestInfo | URL, options?: RequestInit) => {
|
||||
const headers = new Headers(options?.headers)
|
||||
headers.set('X-BrowserOS-ID', browserosId)
|
||||
|
||||
const response = await globalThis.fetch(url, { ...options, headers })
|
||||
|
||||
const creditsRemaining = response.headers.get('X-Credits-Remaining')
|
||||
if (creditsRemaining !== null) {
|
||||
logger.debug('Credits remaining', { creditsRemaining })
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const statusCode = response.status
|
||||
const responseBody = await response.text()
|
||||
const error = parseErrorBody(responseBody)
|
||||
|
||||
if (statusCode === 429 && error?.code === 'CREDITS_EXHAUSTED') {
|
||||
throw new APICallError({
|
||||
message: error.message ?? 'Daily credits exhausted',
|
||||
url: resolveUrl(url),
|
||||
requestBodyValues: {},
|
||||
statusCode,
|
||||
responseBody,
|
||||
isRetryable: false,
|
||||
})
|
||||
}
|
||||
|
||||
throw new APICallError({
|
||||
message: error
|
||||
? buildErrorMessage(statusCode, response.statusText, error)
|
||||
: `HTTP ${statusCode}: ${response.statusText}`,
|
||||
url: resolveUrl(url),
|
||||
requestBodyValues: {},
|
||||
statusCode,
|
||||
responseBody,
|
||||
})
|
||||
}
|
||||
|
||||
return response
|
||||
}) as typeof fetch
|
||||
}
|
||||
@@ -11,9 +11,18 @@ export interface Provider {
|
||||
apiKey: string
|
||||
baseUrl?: string
|
||||
dailyRateLimit?: number
|
||||
dailyCredits?: number
|
||||
creditCostPerRequest?: number
|
||||
resetInterval?: string
|
||||
providerType?: string // LLMProvider value from ai-gateway: "openrouter" | "azure" | "anthropic"
|
||||
}
|
||||
|
||||
export interface CreditsInfo {
|
||||
credits: number
|
||||
dailyLimit: number
|
||||
lastResetAt?: string
|
||||
}
|
||||
|
||||
export interface BrowserOSConfig {
|
||||
providers: Provider[]
|
||||
}
|
||||
@@ -109,3 +118,20 @@ export function getLLMConfigFromProvider(
|
||||
providerType: provider.providerType,
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchCredits(
|
||||
gatewayBaseUrl: string,
|
||||
browserosId: string,
|
||||
): Promise<CreditsInfo> {
|
||||
const url = new URL(`/credits/${browserosId}`, gatewayBaseUrl).href
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(
|
||||
`Failed to fetch credits: ${response.status} ${response.statusText} - ${errorText}`,
|
||||
)
|
||||
}
|
||||
const result = (await response.json()) as CreditsInfo
|
||||
logger.debug('Credits fetched', { credits: result.credits })
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -119,5 +119,6 @@ async function resolveBrowserOSConfig(
|
||||
apiKey: llmConfig.apiKey,
|
||||
baseUrl: llmConfig.baseUrl,
|
||||
upstreamProvider: llmConfig.providerType,
|
||||
browserosId,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { EXTERNAL_URLS } from '@browseros/shared/constants/urls'
|
||||
import { LLM_PROVIDERS } from '@browseros/shared/schemas/llm'
|
||||
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
|
||||
import type { LanguageModel } from 'ai'
|
||||
import { createBrowserOSFetch } from '../../browseros-fetch'
|
||||
import { logger } from '../../logger'
|
||||
import { createOpenRouterCompatibleFetch } from '../../openrouter-fetch'
|
||||
import { createCodexFetch } from '../oauth/codex-fetch'
|
||||
@@ -92,28 +93,38 @@ function createBedrockModel(config: ResolvedLLMConfig): LanguageModel {
|
||||
|
||||
function createBrowserOSModel(config: ResolvedLLMConfig): LanguageModel {
|
||||
if (!config.baseUrl) throw new Error('BrowserOS provider requires baseUrl')
|
||||
const { baseUrl, apiKey, model, upstreamProvider } = config
|
||||
const { baseUrl, apiKey, model, upstreamProvider, browserosId } = config
|
||||
const browserosFetch = browserosId
|
||||
? createBrowserOSFetch(browserosId)
|
||||
: createOpenRouterCompatibleFetch()
|
||||
|
||||
if (upstreamProvider === LLM_PROVIDERS.OPENROUTER) {
|
||||
return createOpenRouter({
|
||||
baseURL: baseUrl,
|
||||
...(apiKey && { apiKey }),
|
||||
fetch: createOpenRouterCompatibleFetch(),
|
||||
fetch: browserosFetch,
|
||||
})(model)
|
||||
}
|
||||
if (upstreamProvider === LLM_PROVIDERS.ANTHROPIC) {
|
||||
return createAnthropic({ baseURL: baseUrl, ...(apiKey && { apiKey }) })(
|
||||
model,
|
||||
)
|
||||
return createAnthropic({
|
||||
baseURL: baseUrl,
|
||||
...(apiKey && { apiKey }),
|
||||
fetch: browserosFetch,
|
||||
})(model)
|
||||
}
|
||||
if (upstreamProvider === LLM_PROVIDERS.AZURE) {
|
||||
return createAzure({ baseURL: baseUrl, ...(apiKey && { apiKey }) })(model)
|
||||
return createAzure({
|
||||
baseURL: baseUrl,
|
||||
...(apiKey && { apiKey }),
|
||||
fetch: browserosFetch,
|
||||
})(model)
|
||||
}
|
||||
logger.debug('Creating OpenAI-compatible provider for BrowserOS')
|
||||
return createOpenAICompatible({
|
||||
name: 'browseros',
|
||||
baseURL: baseUrl,
|
||||
...(apiKey && { apiKey }),
|
||||
fetch: browserosFetch,
|
||||
})(model)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,5 +11,6 @@ import type { LLMConfig } from '@browseros/shared/schemas/llm'
|
||||
export interface ResolvedLLMConfig extends LLMConfig {
|
||||
model: string
|
||||
upstreamProvider?: string
|
||||
browserosId?: string
|
||||
accountId?: string
|
||||
}
|
||||
|
||||
@@ -3,68 +3,164 @@
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Temporary HTTP server on port 1455 for OAuth callbacks.
|
||||
* OpenAI's OAuth requires redirect_uri to use this specific port
|
||||
* (matching the Codex CLI client ID registration).
|
||||
* Lazy OAuth callback server on port 1455.
|
||||
*
|
||||
* Port 1455 is required by OpenAI's Codex CLI OAuth client registration
|
||||
* (redirect_uri must be http://localhost:1455/auth/callback).
|
||||
*
|
||||
* Unlike the old implementation that bound the port at startup, this class:
|
||||
* - Only binds when the user initiates a PKCE login flow
|
||||
* - Sends GET /cancel to any existing server on the port first (Codex CLI pattern)
|
||||
* - Exposes /cancel so other instances can cancel us
|
||||
* - Releases the port after the callback arrives and no flows are pending
|
||||
*/
|
||||
|
||||
import { OAUTH_CALLBACK_PORT } from '@browseros/shared/constants/ports'
|
||||
import { logger } from '../../logger'
|
||||
import type { OAuthTokenManager } from './token-manager'
|
||||
|
||||
export function startOAuthCallbackServer(tokenManager: OAuthTokenManager): {
|
||||
stop: () => void
|
||||
} {
|
||||
const server = Bun.serve({
|
||||
port: OAUTH_CALLBACK_PORT,
|
||||
hostname: '127.0.0.1',
|
||||
fetch: async (req) => {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname !== '/auth/callback') {
|
||||
return new Response('Not found', { status: 404 })
|
||||
}
|
||||
const MAX_BIND_ATTEMPTS = 5
|
||||
const RETRY_DELAY_MS = 300
|
||||
|
||||
const code = url.searchParams.get('code')
|
||||
const state = url.searchParams.get('state')
|
||||
const error = url.searchParams.get('error')
|
||||
export class OAuthCallbackServer {
|
||||
private server: ReturnType<typeof Bun.serve> | null = null
|
||||
private tokenManager: OAuthTokenManager | null = null
|
||||
|
||||
if (error) {
|
||||
const description = url.searchParams.get('error_description') || error
|
||||
logger.warn('OAuth callback received error', { error, description })
|
||||
return htmlResponse(errorPage(description))
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
return htmlResponse(errorPage('Missing authorization code or state'))
|
||||
}
|
||||
|
||||
try {
|
||||
await tokenManager.handleCallback(code, state)
|
||||
|
||||
// Always show success page — chrome-extension:// redirects are blocked by Chromium.
|
||||
// The extension polls /oauth/:provider/status and detects auth automatically.
|
||||
return htmlResponse(successPage())
|
||||
} catch (err) {
|
||||
logger.error('OAuth callback failed', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return htmlResponse(
|
||||
errorPage(
|
||||
err instanceof Error ? err.message : 'Authentication failed',
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
logger.info('OAuth callback server started', { port: OAUTH_CALLBACK_PORT })
|
||||
|
||||
return {
|
||||
stop: () => {
|
||||
server.stop()
|
||||
logger.info('OAuth callback server stopped')
|
||||
},
|
||||
setTokenManager(manager: OAuthTokenManager): void {
|
||||
this.tokenManager = manager
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this.server !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the callback server is running on port 1455.
|
||||
* If the port is already held by another process, sends GET /cancel
|
||||
* to ask it to release, then retries.
|
||||
*/
|
||||
async ensureRunning(): Promise<void> {
|
||||
if (this.server) return
|
||||
|
||||
if (!this.tokenManager) {
|
||||
throw new Error('OAuth callback server not initialized')
|
||||
}
|
||||
|
||||
let cancelSent = false
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_BIND_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
this.bind()
|
||||
return
|
||||
} catch {
|
||||
if (!cancelSent) {
|
||||
cancelSent = true
|
||||
await this.sendCancel()
|
||||
}
|
||||
|
||||
if (attempt < MAX_BIND_ATTEMPTS) {
|
||||
await sleep(RETRY_DELAY_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`OAuth callback port ${OAUTH_CALLBACK_PORT} is in use by another process. ` +
|
||||
'Close other BrowserOS instances or CLI tools and try again.',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the server and release port 1455.
|
||||
*/
|
||||
stop(): void {
|
||||
if (this.server) {
|
||||
this.server.stop()
|
||||
this.server = null
|
||||
logger.info('OAuth callback server stopped', {
|
||||
port: OAUTH_CALLBACK_PORT,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private bind(): void {
|
||||
const tokenManager = this.tokenManager!
|
||||
|
||||
this.server = Bun.serve({
|
||||
port: OAUTH_CALLBACK_PORT,
|
||||
hostname: '127.0.0.1',
|
||||
fetch: async (req) => {
|
||||
const url = new URL(req.url)
|
||||
|
||||
// /cancel — let other instances ask us to release the port
|
||||
if (url.pathname === '/cancel') {
|
||||
logger.info('OAuth callback server received cancel request')
|
||||
// Schedule stop after responding
|
||||
queueMicrotask(() => this.stop())
|
||||
return new Response('Login cancelled', { status: 200 })
|
||||
}
|
||||
|
||||
if (url.pathname !== '/auth/callback') {
|
||||
return new Response('Not found', { status: 404 })
|
||||
}
|
||||
|
||||
const code = url.searchParams.get('code')
|
||||
const state = url.searchParams.get('state')
|
||||
const error = url.searchParams.get('error')
|
||||
|
||||
if (error) {
|
||||
const description = url.searchParams.get('error_description') || error
|
||||
logger.warn('OAuth callback received error', {
|
||||
error,
|
||||
description,
|
||||
})
|
||||
return htmlResponse(errorPage(description))
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
return htmlResponse(errorPage('Missing authorization code or state'))
|
||||
}
|
||||
|
||||
try {
|
||||
await tokenManager.handleCallback(code, state)
|
||||
return htmlResponse(successPage())
|
||||
} catch (err) {
|
||||
logger.error('OAuth callback failed', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return htmlResponse(
|
||||
errorPage(
|
||||
err instanceof Error ? err.message : 'Authentication failed',
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
logger.info('OAuth callback server started', {
|
||||
port: OAUTH_CALLBACK_PORT,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Send GET /cancel to any existing server on port 1455.
|
||||
* This politely asks the other process to release the port.
|
||||
* Follows the Codex CLI pattern (codex-rs/login/src/server.rs).
|
||||
*/
|
||||
private async sendCancel(): Promise<void> {
|
||||
try {
|
||||
await fetch(`http://127.0.0.1:${OAUTH_CALLBACK_PORT}/cancel`, {
|
||||
signal: AbortSignal.timeout(2000),
|
||||
})
|
||||
logger.info('Sent cancel to existing OAuth callback server')
|
||||
} catch {
|
||||
// Server might not support /cancel or might not be running
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function htmlResponse(html: string): Response {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* VS Code's algorithm (max 2048px longest side, 768px shortest side).
|
||||
*/
|
||||
|
||||
import sharp from 'sharp'
|
||||
import { Jimp } from 'jimp'
|
||||
import { logger } from '../../logger'
|
||||
|
||||
const MAX_LONG_SIDE = 2048
|
||||
@@ -81,11 +81,13 @@ async function resizeDataUrl(dataUrl: string): Promise<string | null> {
|
||||
const base64Data = dataUrl.substring(commaIdx + 1)
|
||||
const buffer = Buffer.from(base64Data, 'base64')
|
||||
|
||||
const image = sharp(buffer)
|
||||
const metadata = await image.metadata()
|
||||
if (!metadata.width || !metadata.height) return null
|
||||
const image = await Jimp.fromBuffer(buffer)
|
||||
const origWidth = image.width
|
||||
const origHeight = image.height
|
||||
if (!origWidth || !origHeight) return null
|
||||
|
||||
let { width, height } = metadata
|
||||
let width = origWidth
|
||||
let height = origHeight
|
||||
|
||||
// Skip if already within both limits (no resize step will fire)
|
||||
if (
|
||||
@@ -110,24 +112,20 @@ async function resizeDataUrl(dataUrl: string): Promise<string | null> {
|
||||
height = Math.round(height * scale)
|
||||
}
|
||||
|
||||
// Preserve PNG for images with alpha, use JPEG otherwise
|
||||
const hasAlpha = metadata.channels === 4 || metadata.hasAlpha
|
||||
const resizedBuffer = hasAlpha
|
||||
? await sharp(buffer)
|
||||
.resize(width, height, { fit: 'inside' })
|
||||
.png()
|
||||
.toBuffer()
|
||||
: await sharp(buffer)
|
||||
.resize(width, height, { fit: 'inside' })
|
||||
.jpeg({ quality: 75 })
|
||||
.toBuffer()
|
||||
image.resize({ w: width, h: height })
|
||||
|
||||
// Jimp always outputs with alpha; use PNG for alpha sources, JPEG otherwise
|
||||
const hasAlpha = image.hasAlpha()
|
||||
const mime = hasAlpha ? 'image/png' : 'image/jpeg'
|
||||
const resizedBuffer = hasAlpha
|
||||
? await image.getBuffer('image/png')
|
||||
: await image.getBuffer('image/jpeg', { quality: 75 })
|
||||
|
||||
const originalKB = Math.round(base64Data.length / 1024)
|
||||
const resizedB64 = resizedBuffer.toString('base64')
|
||||
const resizedKB = Math.round(resizedB64.length / 1024)
|
||||
logger.debug('Resized image for Copilot', {
|
||||
original: `${metadata.width}x${metadata.height} (${originalKB}KB)`,
|
||||
original: `${origWidth}x${origHeight} (${originalKB}KB)`,
|
||||
resized: `${width}x${height} (${resizedKB}KB)`,
|
||||
})
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { Database } from 'bun:sqlite'
|
||||
import { startOAuthCallbackServer } from './callback-server'
|
||||
import { OAuthCallbackServer } from './callback-server'
|
||||
import { OAuthTokenManager } from './token-manager'
|
||||
import { OAuthTokenStore } from './token-store'
|
||||
|
||||
@@ -16,8 +16,9 @@ export function initializeOAuth(
|
||||
browserosId: string,
|
||||
): OAuthTokenManager {
|
||||
const store = new OAuthTokenStore(db)
|
||||
tokenManager = new OAuthTokenManager(store, browserosId)
|
||||
startOAuthCallbackServer(tokenManager)
|
||||
const callbackServer = new OAuthCallbackServer()
|
||||
tokenManager = new OAuthTokenManager(store, browserosId, callbackServer)
|
||||
callbackServer.setTokenManager(tokenManager)
|
||||
return tokenManager
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { OAUTH_CALLBACK_PORT } from '@browseros/shared/constants/ports'
|
||||
import { TIMEOUTS } from '@browseros/shared/constants/timeouts'
|
||||
import { logger } from '../../logger'
|
||||
import type { OAuthCallbackServer } from './callback-server'
|
||||
import { getOAuthProvider, type OAuthProviderConfig } from './providers'
|
||||
import type { OAuthTokenStore, StoredOAuthTokens } from './token-store'
|
||||
|
||||
@@ -58,6 +59,7 @@ export class OAuthTokenManager {
|
||||
constructor(
|
||||
private readonly store: OAuthTokenStore,
|
||||
private readonly browserosId: string,
|
||||
private readonly callbackServer: OAuthCallbackServer,
|
||||
) {}
|
||||
|
||||
// --- PKCE flow (ChatGPT Plus/Pro) ---
|
||||
@@ -66,6 +68,9 @@ export class OAuthTokenManager {
|
||||
providerId: string,
|
||||
redirectBackUrl?: string,
|
||||
): Promise<string> {
|
||||
// Lazily start callback server — only needed for PKCE flow
|
||||
await this.callbackServer.ensureRunning()
|
||||
|
||||
const provider = getOAuthProvider(providerId)
|
||||
if (!provider) throw new Error(`Unknown OAuth provider: ${providerId}`)
|
||||
|
||||
@@ -154,6 +159,7 @@ export class OAuthTokenManager {
|
||||
|
||||
this.store.upsertTokens(this.browserosId, flow.provider, tokens)
|
||||
this.pendingFlows.delete(state)
|
||||
this.stopCallbackIfIdle()
|
||||
|
||||
logger.info('OAuth authentication successful', {
|
||||
provider: flow.provider,
|
||||
@@ -444,6 +450,17 @@ export class OAuthTokenManager {
|
||||
this.store.deleteTokens(this.browserosId, provider)
|
||||
}
|
||||
|
||||
stopCallbackServer(): void {
|
||||
this.callbackServer.stop()
|
||||
}
|
||||
|
||||
private stopCallbackIfIdle(): void {
|
||||
const hasPkceFlows = [...this.pendingFlows.values()].some(() => true)
|
||||
if (!hasPkceFlows) {
|
||||
this.callbackServer.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private cleanExpiredFlows(): void {
|
||||
const now = Date.now()
|
||||
for (const [state, flow] of this.pendingFlows) {
|
||||
|
||||
@@ -18,7 +18,12 @@ import { ControllerBackend } from './browser/backends/controller'
|
||||
import { Browser } from './browser/browser'
|
||||
import type { ServerConfig } from './config'
|
||||
import { INLINED_ENV } from './env'
|
||||
import { cleanOldSessions, ensureBrowserosDir } from './lib/browseros-dir'
|
||||
import {
|
||||
cleanOldSessions,
|
||||
ensureBrowserosDir,
|
||||
removeServerConfigSync,
|
||||
writeServerConfig,
|
||||
} from './lib/browseros-dir'
|
||||
import { initializeDb } from './lib/db'
|
||||
import { identity } from './lib/identity'
|
||||
import { logger } from './lib/logger'
|
||||
@@ -109,6 +114,20 @@ export class Application {
|
||||
this.handleStartupError('HTTP server', this.config.serverPort, error)
|
||||
}
|
||||
|
||||
try {
|
||||
await writeServerConfig({
|
||||
server_port: this.config.serverPort,
|
||||
url: `http://127.0.0.1:${this.config.serverPort}`,
|
||||
server_version: VERSION,
|
||||
browseros_version: this.config.instanceBrowserosVersion,
|
||||
chromium_version: this.config.instanceChromiumVersion,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.warn('Failed to write server config for auto-discovery', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`HTTP server listening on http://127.0.0.1:${this.config.serverPort}`,
|
||||
)
|
||||
@@ -125,6 +144,7 @@ export class Application {
|
||||
stop(reason?: string): void {
|
||||
logger.info('Shutting down server...', { reason })
|
||||
stopSkillSync()
|
||||
removeServerConfigSync()
|
||||
|
||||
// Immediate exit without graceful shutdown. Chromium may kill us on update/restart,
|
||||
// and we need to free the port instantly so the HTTP port doesn't keep switching.
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface BrowserConfig {
|
||||
binaryPath: string
|
||||
userDataDir: string
|
||||
headless: boolean
|
||||
extraArgs: string[]
|
||||
}
|
||||
|
||||
interface BrowserState {
|
||||
@@ -26,6 +27,12 @@ interface BrowserState {
|
||||
|
||||
let browserState: BrowserState | null = null
|
||||
|
||||
function shouldLogBrowserOutput(): boolean {
|
||||
return (
|
||||
process.env.CI === 'true' || process.env.BROWSEROS_TEST_DEBUG === 'true'
|
||||
)
|
||||
}
|
||||
|
||||
export async function isBrowserRunning(cdpPort: number): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${cdpPort}/json/version`, {
|
||||
@@ -74,6 +81,7 @@ export async function spawnBrowser(
|
||||
'--show-component-extension-options',
|
||||
'--enable-logging=stderr',
|
||||
...(config.headless ? ['--headless=new'] : []),
|
||||
...config.extraArgs,
|
||||
`--user-data-dir=${config.userDataDir}`,
|
||||
// TODO: replace with --browseros-cdp-port once we fix the browseros bug
|
||||
`--remote-debugging-port=${config.cdpPort}`,
|
||||
@@ -86,14 +94,18 @@ export async function spawnBrowser(
|
||||
},
|
||||
)
|
||||
|
||||
browserProcess.stdout?.on('data', (_data) => {
|
||||
// Uncomment for debugging
|
||||
// console.log(`[BROWSER] ${_data.toString().trim()}`)
|
||||
browserProcess.stdout?.on('data', (data) => {
|
||||
if (!shouldLogBrowserOutput()) {
|
||||
return
|
||||
}
|
||||
console.log(`[BROWSER] ${data.toString().trim()}`)
|
||||
})
|
||||
|
||||
browserProcess.stderr?.on('data', (_data) => {
|
||||
// Uncomment for debugging
|
||||
// console.log(`[BROWSER] ${_data.toString().trim()}`)
|
||||
browserProcess.stderr?.on('data', (data) => {
|
||||
if (!shouldLogBrowserOutput()) {
|
||||
return
|
||||
}
|
||||
console.error(`[BROWSER] ${data.toString().trim()}`)
|
||||
})
|
||||
|
||||
browserProcess.on('error', (error) => {
|
||||
|
||||
@@ -132,6 +132,7 @@ export async function ensureBrowserOS(
|
||||
binaryPath: runtimePlan.binaryPath,
|
||||
userDataDir: runtimePlan.userDataDir,
|
||||
headless: runtimePlan.headless,
|
||||
extraArgs: runtimePlan.extraArgs,
|
||||
}
|
||||
await spawnBrowser(browserConfig)
|
||||
|
||||
|
||||
@@ -20,9 +20,20 @@ export interface TestRuntimePlan {
|
||||
userDataDir: string
|
||||
binaryPath: string
|
||||
headless: boolean
|
||||
extraArgs: string[]
|
||||
usesFixedPorts: boolean
|
||||
}
|
||||
|
||||
function parseExtraArgs(value: string | undefined): string[] {
|
||||
if (!value) {
|
||||
return []
|
||||
}
|
||||
return value
|
||||
.split(/\s+/)
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0)
|
||||
}
|
||||
|
||||
function parsePort(
|
||||
value: string | undefined,
|
||||
envName: string,
|
||||
@@ -137,12 +148,14 @@ export async function createTestRuntimePlan(): Promise<TestRuntimePlan> {
|
||||
const resolvedPorts = await resolveRuntimePorts()
|
||||
const userDataDir = mkdtempSync(join(tmpdir(), 'browseros-test-'))
|
||||
const headless = process.env.BROWSEROS_TEST_HEADLESS === 'true'
|
||||
const extraArgs = parseExtraArgs(process.env.BROWSEROS_TEST_EXTRA_ARGS)
|
||||
|
||||
return {
|
||||
ports: resolvedPorts.ports,
|
||||
userDataDir,
|
||||
binaryPath: DEFAULT_BINARY_PATH,
|
||||
headless,
|
||||
extraArgs,
|
||||
usesFixedPorts: resolvedPorts.usesFixedPorts,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ async function getOrCreateBrowser(): Promise<Browser> {
|
||||
binaryPath: runtimePlan.binaryPath,
|
||||
userDataDir: runtimePlan.userDataDir,
|
||||
headless: runtimePlan.headless,
|
||||
extraArgs: runtimePlan.extraArgs,
|
||||
}
|
||||
await spawnBrowser(config)
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { describe, it } from 'bun:test'
|
||||
import assert from 'node:assert'
|
||||
|
||||
import { Jimp } from 'jimp'
|
||||
|
||||
import { createCopilotFetch } from '../../src/lib/clients/oauth/copilot-fetch'
|
||||
|
||||
function makeImageBody(dataUrls: string[]) {
|
||||
return JSON.stringify({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: dataUrls.map((url) => ({
|
||||
type: 'image_url',
|
||||
image_url: { url },
|
||||
})),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
async function createTestImage(
|
||||
width: number,
|
||||
height: number,
|
||||
hasAlpha = false,
|
||||
): Promise<string> {
|
||||
const image = new Jimp({ width, height, color: 0xff0000ff })
|
||||
if (hasAlpha) {
|
||||
// Set some pixels to transparent so hasAlpha() returns true
|
||||
for (let x = 0; x < Math.min(width, 10); x++) {
|
||||
image.setPixelColor(0xff000080, x, 0)
|
||||
}
|
||||
}
|
||||
const mime = hasAlpha ? 'image/png' : 'image/jpeg'
|
||||
const buffer = await image.getBuffer(mime)
|
||||
return `data:${mime};base64,${buffer.toString('base64')}`
|
||||
}
|
||||
|
||||
function parseDataUrl(dataUrl: string) {
|
||||
const [header, b64] = dataUrl.split(',')
|
||||
const mime = header.match(/data:([^;]+)/)?.[1]
|
||||
return { mime, buffer: Buffer.from(b64, 'base64') }
|
||||
}
|
||||
|
||||
describe('createCopilotFetch', () => {
|
||||
it('sets Copilot headers on every request', async () => {
|
||||
const copilotFetch = createCopilotFetch()
|
||||
const calls: { input: RequestInfo | URL; init?: RequestInit }[] = []
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
globalThis.fetch = (async (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => {
|
||||
calls.push({ input, init })
|
||||
return new Response('ok')
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
await copilotFetch('https://api.example.com', {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ messages: [{ role: 'user', content: 'hi' }] }),
|
||||
})
|
||||
|
||||
assert.strictEqual(calls.length, 1)
|
||||
const headers = new Headers(calls[0].init?.headers as HeadersInit)
|
||||
assert.strictEqual(headers.get('Openai-Intent'), 'conversation-edits')
|
||||
assert.strictEqual(headers.get('x-initiator'), 'user')
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
it('sets Copilot-Vision-Request header when images present', async () => {
|
||||
const copilotFetch = createCopilotFetch()
|
||||
const calls: { input: RequestInfo | URL; init?: RequestInit }[] = []
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
globalThis.fetch = (async (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => {
|
||||
calls.push({ input, init })
|
||||
return new Response('ok')
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
const dataUrl = await createTestImage(100, 100)
|
||||
await copilotFetch('https://api.example.com', {
|
||||
body: makeImageBody([dataUrl]),
|
||||
})
|
||||
|
||||
const headers = new Headers(calls[0].init?.headers as HeadersInit)
|
||||
assert.strictEqual(headers.get('Copilot-Vision-Request'), 'true')
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('image resizing', () => {
|
||||
async function resizeViaFetch(dataUrl: string): Promise<string> {
|
||||
let capturedBody = ''
|
||||
const originalFetch = globalThis.fetch
|
||||
globalThis.fetch = (async (_: RequestInfo | URL, init?: RequestInit) => {
|
||||
capturedBody = init?.body as string
|
||||
return new Response('ok')
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
const copilotFetch = createCopilotFetch()
|
||||
await copilotFetch('https://api.example.com', {
|
||||
body: makeImageBody([dataUrl]),
|
||||
})
|
||||
const parsed = JSON.parse(capturedBody)
|
||||
return parsed.messages[0].content[0].image_url.url
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
}
|
||||
|
||||
it('does not resize images already within limits', async () => {
|
||||
const dataUrl = await createTestImage(800, 600)
|
||||
const result = await resizeViaFetch(dataUrl)
|
||||
assert.strictEqual(result, dataUrl)
|
||||
})
|
||||
|
||||
it('scales down when longest side exceeds 2048', async () => {
|
||||
const dataUrl = await createTestImage(4096, 2048)
|
||||
const result = await resizeViaFetch(dataUrl)
|
||||
|
||||
assert.notStrictEqual(result, dataUrl)
|
||||
const { buffer } = parseDataUrl(result)
|
||||
const resized = await Jimp.fromBuffer(buffer)
|
||||
assert.ok(resized.width <= 2048)
|
||||
assert.ok(resized.height <= 1024)
|
||||
})
|
||||
|
||||
it('scales down when shortest side exceeds 768', async () => {
|
||||
const dataUrl = await createTestImage(2000, 1500)
|
||||
const result = await resizeViaFetch(dataUrl)
|
||||
|
||||
assert.notStrictEqual(result, dataUrl)
|
||||
const { buffer } = parseDataUrl(result)
|
||||
const resized = await Jimp.fromBuffer(buffer)
|
||||
assert.ok(Math.min(resized.width, resized.height) <= 768)
|
||||
})
|
||||
|
||||
it('outputs JPEG for opaque images', async () => {
|
||||
const dataUrl = await createTestImage(4096, 3072, false)
|
||||
const result = await resizeViaFetch(dataUrl)
|
||||
const { mime } = parseDataUrl(result)
|
||||
assert.strictEqual(mime, 'image/jpeg')
|
||||
})
|
||||
|
||||
it('outputs PNG for images with alpha', async () => {
|
||||
const dataUrl = await createTestImage(4096, 3072, true)
|
||||
const result = await resizeViaFetch(dataUrl)
|
||||
const { mime } = parseDataUrl(result)
|
||||
assert.strictEqual(mime, 'image/png')
|
||||
})
|
||||
|
||||
it('applies both scaling steps (long side then short side)', async () => {
|
||||
// 4000x3000: step 1 scales to 2048x1536, step 2 scales 1536→768 so 1024x768
|
||||
const dataUrl = await createTestImage(4000, 3000)
|
||||
const result = await resizeViaFetch(dataUrl)
|
||||
|
||||
const { buffer } = parseDataUrl(result)
|
||||
const resized = await Jimp.fromBuffer(buffer)
|
||||
assert.ok(resized.width <= 2048)
|
||||
assert.ok(Math.min(resized.width, resized.height) <= 768)
|
||||
})
|
||||
})
|
||||
100
packages/browseros-agent/apps/server/tests/build.test.ts
Normal file
100
packages/browseros-agent/apps/server/tests/build.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*
|
||||
* Build smoke test — compiles the server binary and verifies --version output.
|
||||
* Catches compile failures, broken imports, and version injection bugs.
|
||||
*/
|
||||
|
||||
import { afterAll, describe, it } from 'bun:test'
|
||||
import assert from 'node:assert'
|
||||
import { mkdtempSync, 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'
|
||||
? 'darwin'
|
||||
: process.platform === 'win32'
|
||||
? 'windows'
|
||||
: 'linux'
|
||||
const cpu = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
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> = {
|
||||
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',
|
||||
R2_ACCOUNT_ID: 'test',
|
||||
R2_ACCESS_KEY_ID: 'test',
|
||||
R2_SECRET_ACCESS_KEY: 'test',
|
||||
R2_BUCKET: 'test',
|
||||
}
|
||||
|
||||
describe('server build', () => {
|
||||
const rootDir = resolve(import.meta.dir, '../../..')
|
||||
const serverPkgPath = resolve(rootDir, 'apps/server/package.json')
|
||||
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 tempDir = mkdtempSync(join(tmpdir(), 'browseros-build-test-'))
|
||||
const emptyManifestPath = join(tempDir, 'empty-manifest.json')
|
||||
writeFileSync(emptyManifestPath, JSON.stringify({ resources: [] }))
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('compiles and --version outputs correct version', async () => {
|
||||
const pkg = await Bun.file(serverPkgPath).json()
|
||||
const expectedVersion: string = pkg.version
|
||||
|
||||
const build = Bun.spawn(
|
||||
[
|
||||
'bun',
|
||||
buildScript,
|
||||
`--target=${target.id}`,
|
||||
'--no-upload',
|
||||
`--manifest=${emptyManifestPath}`,
|
||||
],
|
||||
{
|
||||
cwd: rootDir,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: { ...process.env, ...BUILD_ENV_STUBS },
|
||||
},
|
||||
)
|
||||
const buildExit = await build.exited
|
||||
if (buildExit !== 0) {
|
||||
const stderr = await new Response(build.stderr).text()
|
||||
assert.fail(`Build failed (exit ${buildExit}):\n${stderr}`)
|
||||
}
|
||||
|
||||
const proc = Bun.spawn([binaryPath, '--version'], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
const [versionOutput, versionStderr] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
])
|
||||
const versionExit = await proc.exited
|
||||
|
||||
assert.strictEqual(
|
||||
versionExit,
|
||||
0,
|
||||
`Binary --version exited non-zero:\n${versionStderr}`,
|
||||
)
|
||||
assert.strictEqual(versionOutput.trim(), expectedVersion)
|
||||
}, 300_000)
|
||||
})
|
||||
@@ -55,7 +55,7 @@ describe('filesystem_bash', () => {
|
||||
})
|
||||
|
||||
it('times out long-running commands', async () => {
|
||||
const result = await exec({ command: 'sleep 30', timeout: 1 })
|
||||
const result = await exec({ command: 'exec sleep 30', timeout: 1 })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.text).toContain('timed out')
|
||||
}, 10_000)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, it } from 'bun:test'
|
||||
import assert from 'node:assert'
|
||||
import type { Browser } from '../../src/browser/browser'
|
||||
import { executeTool, type ToolContext } from '../../src/tools/framework'
|
||||
import {
|
||||
check,
|
||||
click,
|
||||
@@ -320,37 +322,48 @@ describe('input tools', () => {
|
||||
}, 60_000)
|
||||
|
||||
it('scroll dispatches without error', async () => {
|
||||
await withBrowser(async ({ execute }) => {
|
||||
const newResult = await execute(new_page, {
|
||||
url: FORM_PAGE,
|
||||
})
|
||||
const pageId = pageIdOf(newResult)
|
||||
const calls: Array<{
|
||||
page: number
|
||||
direction: string
|
||||
amount: number
|
||||
element?: number
|
||||
}> = []
|
||||
const browser = {
|
||||
getTabIdForPage: () => undefined,
|
||||
scroll: async (
|
||||
page: number,
|
||||
direction: string,
|
||||
amount: number,
|
||||
element?: number,
|
||||
) => {
|
||||
calls.push({ page, direction, amount, element })
|
||||
},
|
||||
} as unknown as Browser
|
||||
const ctx: ToolContext = {
|
||||
browser,
|
||||
directories: { workingDir: process.cwd() },
|
||||
}
|
||||
|
||||
const before = await execute(evaluate_script, {
|
||||
page: pageId,
|
||||
expression: 'window.scrollY',
|
||||
})
|
||||
const result = await executeTool(
|
||||
scroll,
|
||||
{ page: 7, direction: 'down', amount: 5 },
|
||||
ctx,
|
||||
AbortSignal.timeout(1_000),
|
||||
)
|
||||
|
||||
const scrollResult = await execute(scroll, {
|
||||
page: pageId,
|
||||
direction: 'down',
|
||||
amount: 5,
|
||||
})
|
||||
assert.ok(!scrollResult.isError, textOf(scrollResult))
|
||||
assert.ok(textOf(scrollResult).includes('Scrolled down'))
|
||||
|
||||
const after = await execute(evaluate_script, {
|
||||
page: pageId,
|
||||
expression: 'window.scrollY',
|
||||
})
|
||||
assert.ok(
|
||||
Number(textOf(after)) > Number(textOf(before)),
|
||||
`Expected scrollY to increase, before=${textOf(before)} after=${textOf(after)}`,
|
||||
)
|
||||
|
||||
await execute(close_page, { page: pageId })
|
||||
assert.ok(!result.isError, textOf(result))
|
||||
assert.ok(textOf(result).includes('Scrolled down'))
|
||||
assert.deepStrictEqual(calls, [
|
||||
{ page: 7, direction: 'down', amount: 5, element: undefined },
|
||||
])
|
||||
assert.deepStrictEqual(structuredOf(result), {
|
||||
action: 'scroll',
|
||||
page: 7,
|
||||
direction: 'down',
|
||||
amount: 5,
|
||||
element: undefined,
|
||||
})
|
||||
}, 60_000)
|
||||
})
|
||||
|
||||
it('hover moves cursor over element', async () => {
|
||||
await withBrowser(async ({ execute }) => {
|
||||
|
||||
@@ -203,7 +203,8 @@ describe('observation tools', () => {
|
||||
savedPath = data.path
|
||||
|
||||
assert.strictEqual(data.writtenToFile, true)
|
||||
assert.ok(textOf(contentResult).includes('Saved page content'))
|
||||
assert.ok(textOf(contentResult).includes('Content truncated'))
|
||||
assert.ok(textOf(contentResult).includes(savedPath))
|
||||
assert.ok(existsSync(savedPath), 'Saved page content file should exist')
|
||||
assert.ok(
|
||||
dirname(savedPath).startsWith(
|
||||
|
||||
@@ -30,13 +30,13 @@ function structuredOf<T>(result: { structuredContent?: unknown }): T {
|
||||
|
||||
function createToolContext(
|
||||
browser: Browser,
|
||||
executionDir: string,
|
||||
workingDir: string,
|
||||
resourcesDir?: string,
|
||||
): ToolContext {
|
||||
return {
|
||||
browser,
|
||||
directories: {
|
||||
executionDir,
|
||||
workingDir,
|
||||
resourcesDir,
|
||||
},
|
||||
}
|
||||
@@ -50,10 +50,8 @@ function createBrowserStub(methods: Record<string, unknown>): Browser {
|
||||
}
|
||||
|
||||
describe('page action tools', () => {
|
||||
it('save_pdf resolves relative paths against the execution directory by default', async () => {
|
||||
const executionDir = await mkdtemp(
|
||||
join(tmpdir(), 'browseros-page-actions-'),
|
||||
)
|
||||
it('save_pdf resolves relative paths against the working directory by default', async () => {
|
||||
const workingDir = await mkdtemp(join(tmpdir(), 'browseros-page-actions-'))
|
||||
const browser = createBrowserStub({
|
||||
printToPDF: async () => ({
|
||||
data: Buffer.from('pdf-data').toString('base64'),
|
||||
@@ -64,26 +62,24 @@ describe('page action tools', () => {
|
||||
const result = await executeTool(
|
||||
save_pdf,
|
||||
{ page: 1, path: 'report.pdf' },
|
||||
createToolContext(browser, executionDir),
|
||||
createToolContext(browser, workingDir),
|
||||
AbortSignal.timeout(1_000),
|
||||
)
|
||||
|
||||
assert.ok(!result.isError, textOf(result))
|
||||
const outputPath = join(executionDir, 'report.pdf')
|
||||
const outputPath = join(workingDir, 'report.pdf')
|
||||
assert.strictEqual(
|
||||
structuredOf<{ path: string }>(result).path,
|
||||
outputPath,
|
||||
)
|
||||
assert.ok(existsSync(outputPath), 'PDF file should exist in executionDir')
|
||||
assert.ok(existsSync(outputPath), 'PDF file should exist in workingDir')
|
||||
} finally {
|
||||
await rm(executionDir, { recursive: true, force: true })
|
||||
await rm(workingDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('save_screenshot still honors an explicit cwd override', async () => {
|
||||
const executionDir = await mkdtemp(
|
||||
join(tmpdir(), 'browseros-page-actions-'),
|
||||
)
|
||||
const workingDir = await mkdtemp(join(tmpdir(), 'browseros-page-actions-'))
|
||||
const overrideDir = await mkdtemp(join(tmpdir(), 'browseros-page-actions-'))
|
||||
const browser = createBrowserStub({
|
||||
screenshot: async () => ({
|
||||
@@ -95,7 +91,7 @@ describe('page action tools', () => {
|
||||
const result = await executeTool(
|
||||
save_screenshot,
|
||||
{ page: 1, path: 'capture.png', cwd: overrideDir },
|
||||
createToolContext(browser, executionDir),
|
||||
createToolContext(browser, workingDir),
|
||||
AbortSignal.timeout(1_000),
|
||||
)
|
||||
|
||||
@@ -110,18 +106,18 @@ describe('page action tools', () => {
|
||||
'Screenshot should exist in overrideDir',
|
||||
)
|
||||
assert.ok(
|
||||
!existsSync(join(executionDir, 'capture.png')),
|
||||
'Execution directory should not be used when cwd is provided',
|
||||
!existsSync(join(workingDir, 'capture.png')),
|
||||
'Working directory should not be used when cwd is provided',
|
||||
)
|
||||
} finally {
|
||||
await rm(executionDir, { recursive: true, force: true })
|
||||
await rm(workingDir, { recursive: true, force: true })
|
||||
await rm(overrideDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('download_file resolves relative directories against the execution directory by default', async () => {
|
||||
it('download_file resolves relative directories against the working directory by default', async () => {
|
||||
const baseDir = await mkdtemp(join(tmpdir(), 'browseros-page-actions-'))
|
||||
const executionDir = join(baseDir, 'execution')
|
||||
const workingDir = join(baseDir, 'working')
|
||||
let stagingDir: string | undefined
|
||||
const browser = createBrowserStub({
|
||||
downloadViaClick: async (
|
||||
@@ -143,23 +139,23 @@ describe('page action tools', () => {
|
||||
const result = await executeTool(
|
||||
download_file,
|
||||
{ page: 1, element: 7, path: '.' },
|
||||
createToolContext(browser, executionDir),
|
||||
createToolContext(browser, workingDir),
|
||||
AbortSignal.timeout(1_000),
|
||||
)
|
||||
|
||||
assert.ok(!result.isError, textOf(result))
|
||||
const outputPath = join(executionDir, 'download.txt')
|
||||
const outputPath = join(workingDir, 'download.txt')
|
||||
const structured = structuredOf<{
|
||||
directory: string
|
||||
destinationPath: string
|
||||
}>(result)
|
||||
assert.strictEqual(structured.directory, executionDir)
|
||||
assert.strictEqual(structured.directory, workingDir)
|
||||
assert.strictEqual(structured.destinationPath, outputPath)
|
||||
assert.ok(existsSync(outputPath), 'Download should land in executionDir')
|
||||
assert.ok(existsSync(outputPath), 'Download should land in workingDir')
|
||||
assert.ok(stagingDir, 'Download should use a staging directory')
|
||||
assert.ok(
|
||||
stagingDir.startsWith(join(executionDir, 'browseros-dl-')),
|
||||
'Staging directory should be created inside executionDir',
|
||||
stagingDir.startsWith(join(workingDir, 'browseros-dl-')),
|
||||
'Staging directory should be created inside workingDir',
|
||||
)
|
||||
assert.ok(
|
||||
!existsSync(stagingDir),
|
||||
|
||||
@@ -149,6 +149,7 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.63",
|
||||
"@aws-sdk/client-s3": "^3.1014.0",
|
||||
"@browseros/server": "workspace:*",
|
||||
"@browseros/shared": "workspace:*",
|
||||
"@google/gemini-cli-core": "^0.16.0",
|
||||
@@ -169,7 +170,7 @@
|
||||
},
|
||||
"apps/server": {
|
||||
"name": "@browseros/server",
|
||||
"version": "0.0.77",
|
||||
"version": "0.0.79",
|
||||
"bin": {
|
||||
"browseros-server": "./src/index.ts",
|
||||
},
|
||||
@@ -203,11 +204,11 @@
|
||||
"fuse.js": "^7.1.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"hono": "^4.12.3",
|
||||
"jimp": "^1.6.0",
|
||||
"klavis": "^2.15.0",
|
||||
"pino": "^9.6.0",
|
||||
"posthog-node": "^4.17.0",
|
||||
"puppeteer-core": "24.23.0",
|
||||
"sharp": "^0.34.5",
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.24.2",
|
||||
"zod-from-json-schema": "^0.1.0",
|
||||
@@ -895,6 +896,62 @@
|
||||
|
||||
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
|
||||
|
||||
"@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="],
|
||||
|
||||
"@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="],
|
||||
|
||||
"@jimp/file-ops": ["@jimp/file-ops@1.6.0", "", {}, "sha512-Dx/bVDmgnRe1AlniRpCKrGRm5YvGmUwbDzt+MAkgmLGf+jvBT75hmMEZ003n9HQI/aPnm/YKnXjg/hOpzNCpHQ=="],
|
||||
|
||||
"@jimp/js-bmp": ["@jimp/js-bmp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "bmp-ts": "^1.0.9" } }, "sha512-FU6Q5PC/e3yzLyBDXupR3SnL3htU7S3KEs4e6rjDP6gNEOXRFsWs6YD3hXuXd50jd8ummy+q2WSwuGkr8wi+Gw=="],
|
||||
|
||||
"@jimp/js-gif": ["@jimp/js-gif@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "gifwrap": "^0.10.1", "omggif": "^1.0.10" } }, "sha512-N9CZPHOrJTsAUoWkWZstLPpwT5AwJ0wge+47+ix3++SdSL/H2QzyMqxbcDYNFe4MoI5MIhATfb0/dl/wmX221g=="],
|
||||
|
||||
"@jimp/js-jpeg": ["@jimp/js-jpeg@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "jpeg-js": "^0.4.4" } }, "sha512-6vgFDqeusblf5Pok6B2DUiMXplH8RhIKAryj1yn+007SIAQ0khM1Uptxmpku/0MfbClx2r7pnJv9gWpAEJdMVA=="],
|
||||
|
||||
"@jimp/js-png": ["@jimp/js-png@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "pngjs": "^7.0.0" } }, "sha512-AbQHScy3hDDgMRNfG0tPjL88AV6qKAILGReIa3ATpW5QFjBKpisvUaOqhzJ7Reic1oawx3Riyv152gaPfqsBVg=="],
|
||||
|
||||
"@jimp/js-tiff": ["@jimp/js-tiff@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "utif2": "^4.1.0" } }, "sha512-zhReR8/7KO+adijj3h0ZQUOiun3mXUv79zYEAKvE0O+rP7EhgtKvWJOZfRzdZSNv0Pu1rKtgM72qgtwe2tFvyw=="],
|
||||
|
||||
"@jimp/plugin-blit": ["@jimp/plugin-blit@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M+uRWl1csi7qilnSK8uxK4RJMSuVeBiO1AY0+7APnfUbQNZm6hCe0CCFv1Iyw1D/Dhb8ph8fQgm5mwM0eSxgVA=="],
|
||||
|
||||
"@jimp/plugin-blur": ["@jimp/plugin-blur@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-zrM7iic1OTwUCb0g/rN5y+UnmdEsT3IfuCXCJJNs8SZzP0MkZ1eTvuwK9ZidCuMo4+J3xkzCidRwYXB5CyGZTw=="],
|
||||
|
||||
"@jimp/plugin-circle": ["@jimp/plugin-circle@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-xt1Gp+LtdMKAXfDp3HNaG30SPZW6AQ7dtAtTnoRKorRi+5yCJjKqXRgkewS5bvj8DEh87Ko1ydJfzqS3P2tdWw=="],
|
||||
|
||||
"@jimp/plugin-color": ["@jimp/plugin-color@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "tinycolor2": "^1.6.0", "zod": "^3.23.8" } }, "sha512-J5q8IVCpkBsxIXM+45XOXTrsyfblyMZg3a9eAo0P7VPH4+CrvyNQwaYatbAIamSIN1YzxmO3DkIZXzRjFSz1SA=="],
|
||||
|
||||
"@jimp/plugin-contain": ["@jimp/plugin-contain@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-oN/n+Vdq/Qg9bB4yOBOxtY9IPAtEfES8J1n9Ddx+XhGBYT1/QTU/JYkGaAkIGoPnyYvmLEDqMz2SGihqlpqfzQ=="],
|
||||
|
||||
"@jimp/plugin-cover": ["@jimp/plugin-cover@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Iow0h6yqSC269YUJ8HC3Q/MpCi2V55sMlbkkTTx4zPvd8mWZlC0ykrNDeAy9IJegrQ7v5E99rJwmQu25lygKLA=="],
|
||||
|
||||
"@jimp/plugin-crop": ["@jimp/plugin-crop@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-KqZkEhvs+21USdySCUDI+GFa393eDIzbi1smBqkUPTE+pRwSWMAf01D5OC3ZWB+xZsNla93BDS9iCkLHA8wang=="],
|
||||
|
||||
"@jimp/plugin-displace": ["@jimp/plugin-displace@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-4Y10X9qwr5F+Bo5ME356XSACEF55485j5nGdiyJ9hYzjQP9nGgxNJaZ4SAOqpd+k5sFaIeD7SQ0Occ26uIng5Q=="],
|
||||
|
||||
"@jimp/plugin-dither": ["@jimp/plugin-dither@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0" } }, "sha512-600d1RxY0pKwgyU0tgMahLNKsqEcxGdbgXadCiVCoGd6V6glyCvkNrnnwC0n5aJ56Htkj88PToSdF88tNVZEEQ=="],
|
||||
|
||||
"@jimp/plugin-fisheye": ["@jimp/plugin-fisheye@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-E5QHKWSCBFtpgZarlmN3Q6+rTQxjirFqo44ohoTjzYVrDI6B6beXNnPIThJgPr0Y9GwfzgyarKvQuQuqCnnfbA=="],
|
||||
|
||||
"@jimp/plugin-flip": ["@jimp/plugin-flip@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-/+rJVDuBIVOgwoyVkBjUFHtP+wmW0r+r5OQ2GpatQofToPVbJw1DdYWXlwviSx7hvixTWLKVgRWQ5Dw862emDg=="],
|
||||
|
||||
"@jimp/plugin-hash": ["@jimp/plugin-hash@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "any-base": "^1.1.0" } }, "sha512-wWzl0kTpDJgYVbZdajTf+4NBSKvmI3bRI8q6EH9CVeIHps9VWVsUvEyb7rpbcwVLWYuzDtP2R0lTT6WeBNQH9Q=="],
|
||||
|
||||
"@jimp/plugin-mask": ["@jimp/plugin-mask@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Cwy7ExSJMZszvkad8NV8o/Z92X2kFUFM8mcDAhNVxU0Q6tA0op2UKRJY51eoK8r6eds/qak3FQkXakvNabdLnA=="],
|
||||
|
||||
"@jimp/plugin-print": ["@jimp/plugin-print@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/types": "1.6.0", "parse-bmfont-ascii": "^1.0.6", "parse-bmfont-binary": "^1.0.6", "parse-bmfont-xml": "^1.1.6", "simple-xml-to-json": "^1.2.2", "zod": "^3.23.8" } }, "sha512-zarTIJi8fjoGMSI/M3Xh5yY9T65p03XJmPsuNet19K/Q7mwRU6EV2pfj+28++2PV2NJ+htDF5uecAlnGyxFN2A=="],
|
||||
|
||||
"@jimp/plugin-quantize": ["@jimp/plugin-quantize@1.6.0", "", { "dependencies": { "image-q": "^4.0.0", "zod": "^3.23.8" } }, "sha512-EmzZ/s9StYQwbpG6rUGBCisc3f64JIhSH+ncTJd+iFGtGo0YvSeMdAd+zqgiHpfZoOL54dNavZNjF4otK+mvlg=="],
|
||||
|
||||
"@jimp/plugin-resize": ["@jimp/plugin-resize@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-uSUD1mqXN9i1SGSz5ov3keRZ7S9L32/mAQG08wUwZiEi5FpbV0K8A8l1zkazAIZi9IJzLlTauRNU41Mi8IF9fA=="],
|
||||
|
||||
"@jimp/plugin-rotate": ["@jimp/plugin-rotate@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-JagdjBLnUZGSG4xjCLkIpQOZZ3Mjbg8aGCCi4G69qR+OjNpOeGI7N2EQlfK/WE8BEHOW5vdjSyglNqcYbQBWRw=="],
|
||||
|
||||
"@jimp/plugin-threshold": ["@jimp/plugin-threshold@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M59m5dzLoHOVWdM41O8z9SyySzcDn43xHseOH0HavjsfQsT56GGCC4QzU1banJidbUrePhzoEdS42uFE8Fei8w=="],
|
||||
|
||||
"@jimp/types": ["@jimp/types@1.6.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-7UfRsiKo5GZTAATxm2qQ7jqmUXP0DxTArztllTcYdyw6Xi5oT4RaoXynVtCD4UyLK5gJgkZJcwonoijrhYFKfg=="],
|
||||
|
||||
"@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="],
|
||||
|
||||
"@joshua.litt/get-ripgrep": ["@joshua.litt/get-ripgrep@0.0.3", "", { "dependencies": { "@lvce-editor/verror": "^1.6.0", "execa": "^9.5.2", "extract-zip": "^2.0.1", "fs-extra": "^11.3.0", "got": "^14.4.5", "path-exists": "^5.0.0", "xdg-basedir": "^5.1.0" } }, "sha512-rycdieAKKqXi2bsM7G2ayDiNk5CAX8ZOzsTQsirfOqUKPef04Xw40BWGGyimaOOuvPgLWYt3tPnLLG3TvPXi5Q=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
@@ -1735,6 +1792,8 @@
|
||||
|
||||
"@theguild/federation-composition": ["@theguild/federation-composition@0.21.3", "", { "dependencies": { "constant-case": "^3.0.4", "debug": "4.4.3", "json5": "^2.2.3", "lodash.sortby": "^4.7.0" }, "peerDependencies": { "graphql": "^16.0.0" } }, "sha512-+LlHTa4UbRpZBog3ggAxjYIFvdfH3UMvvBUptur19TMWkqU4+n3GmN+mDjejU+dyBXIG27c25RsiQP1HyvM99g=="],
|
||||
|
||||
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
|
||||
|
||||
"@tokenlens/core": ["@tokenlens/core@1.3.0", "", {}, "sha512-d8YNHNC+q10bVpi95fELJwJyPVf1HfvBEI18eFQxRSZTdByXrP+f/ZtlhSzkx0Jl0aEmYVeBA5tPeeYRioLViQ=="],
|
||||
|
||||
"@tokenlens/fetch": ["@tokenlens/fetch@1.3.0", "", { "dependencies": { "@tokenlens/core": "1.3.0" } }, "sha512-RONDRmETYly9xO8XMKblmrZjKSwCva4s5ebJwQNfNlChZoA5kplPoCgnWceHnn1J1iRjLVlrCNB43ichfmGBKQ=="],
|
||||
@@ -2053,6 +2112,8 @@
|
||||
|
||||
"antd-style": ["antd-style@3.7.1", "", { "dependencies": { "@ant-design/cssinjs": "^1.21.1", "@babel/runtime": "^7.24.1", "@emotion/cache": "^11.11.0", "@emotion/css": "^11.11.2", "@emotion/react": "^11.11.4", "@emotion/serialize": "^1.1.3", "@emotion/utils": "^1.2.1", "use-merge-value": "^1.2.0" }, "peerDependencies": { "antd": ">=5.8.1", "react": ">=18" } }, "sha512-CQOfddVp4aOvBfCepa+Kj2e7ap+2XBINg1Kn2osdE3oQvrD7KJu/K0sfnLcFLkgCJygbxmuazYdWLKb+drPDYA=="],
|
||||
|
||||
"any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="],
|
||||
|
||||
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
@@ -2087,6 +2148,8 @@
|
||||
|
||||
"auto-bind": ["auto-bind@4.0.0", "", {}, "sha512-Hdw8qdNiqdJ8LqT0iK0sVzkFbzg6fhnQqqfWhBDxcHZvU75+B+ayzTy8x+k5Ix0Y92XOhOUlx74ps+bA6BeYMQ=="],
|
||||
|
||||
"await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="],
|
||||
|
||||
"aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
|
||||
|
||||
"axios": ["axios@1.13.5", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q=="],
|
||||
@@ -2127,6 +2190,8 @@
|
||||
|
||||
"bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="],
|
||||
|
||||
"bmp-ts": ["bmp-ts@1.0.9", "", {}, "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw=="],
|
||||
|
||||
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||
|
||||
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
|
||||
@@ -2215,7 +2280,7 @@
|
||||
|
||||
"chrome-devtools-frontend": ["chrome-devtools-frontend@1.0.1577886", "", {}, "sha512-B9hY3o/0RuVCDWNYh9YnkEbRrPUMCY+NaOgBxvZRzGvqbGSMNckkVSdO67SwWR8bm4fo/qplXbUj0cSr229V6w=="],
|
||||
|
||||
"chrome-devtools-mcp": ["chrome-devtools-mcp@0.20.2", "", { "bin": { "chrome-devtools-mcp": "build/src/bin/chrome-devtools-mcp.js", "chrome-devtools": "build/src/bin/chrome-devtools.js" } }, "sha512-QYwRj8YJjvFHODiYVDJpHaUYLD8/wt0DP+HXQPn/IF+QbbAfr7Vn2JtACyrIPEzTX3XJXgDPZkr4gSYHRDgqvQ=="],
|
||||
"chrome-devtools-mcp": ["chrome-devtools-mcp@0.20.3", "", { "bin": { "chrome-devtools-mcp": "build/src/bin/chrome-devtools-mcp.js", "chrome-devtools": "build/src/bin/chrome-devtools.js" } }, "sha512-6MlNKlKa+J1FX9w4SUnFERF4MRGWLlrnZvIJGhhsuuMPM7qUG0F4SwheRyjwl0+tsTemxMCBHiib8mXkg5j6og=="],
|
||||
|
||||
"chrome-launcher": ["chrome-launcher@1.2.0", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^2.0.1" }, "bin": { "print-chrome-path": "bin/print-chrome-path.cjs" } }, "sha512-JbuGuBNss258bvGil7FT4HKdC3SC2K7UAEUqiPy3ACS3Yxo3hAW6bvFpCu2HsIJLgTqxgEX6BkujvzZfLpUD0Q=="],
|
||||
|
||||
@@ -2619,6 +2684,8 @@
|
||||
|
||||
"execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
|
||||
|
||||
"exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="],
|
||||
|
||||
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
|
||||
"express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="],
|
||||
@@ -2677,6 +2744,8 @@
|
||||
|
||||
"file-selector": ["file-selector@0.5.0", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-s8KNnmIDTBoD0p9uJ9uD0XY38SCeBOtj0UMXyQSLg1Ypfrfj8+dAvwsLjYQkQ2GjhVtp2HrnF5cJzMhBjfD8HA=="],
|
||||
|
||||
"file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="],
|
||||
|
||||
"filesize": ["filesize@11.0.13", "", {}, "sha512-mYJ/qXKvREuO0uH8LTQJ6v7GsUvVOguqxg2VTwQUkyTPXXRRWPdjuUPVqdBrJQhvci48OHlNGRnux+Slr2Rnvw=="],
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
@@ -2755,6 +2824,8 @@
|
||||
|
||||
"get-value": ["get-value@2.0.6", "", {}, "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA=="],
|
||||
|
||||
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
|
||||
|
||||
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
|
||||
|
||||
"giscus": ["giscus@1.6.0", "", { "dependencies": { "lit": "^3.2.1" } }, "sha512-Zrsi8r4t1LVW950keaWcsURuZUQwUaMKjvJgTCY125vkW6OiEBkatE7ScJDbpqKHdZwb///7FVC21SE3iFK3PQ=="],
|
||||
@@ -2893,6 +2964,8 @@
|
||||
|
||||
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
|
||||
"image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="],
|
||||
|
||||
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
|
||||
|
||||
"immer": ["immer@11.1.3", "", {}, "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q=="],
|
||||
@@ -3017,12 +3090,16 @@
|
||||
|
||||
"jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="],
|
||||
|
||||
"jimp": ["jimp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/diff": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-gif": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-blur": "1.6.0", "@jimp/plugin-circle": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-contain": "1.6.0", "@jimp/plugin-cover": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-displace": "1.6.0", "@jimp/plugin-dither": "1.6.0", "@jimp/plugin-fisheye": "1.6.0", "@jimp/plugin-flip": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/plugin-mask": "1.6.0", "@jimp/plugin-print": "1.6.0", "@jimp/plugin-quantize": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/plugin-rotate": "1.6.0", "@jimp/plugin-threshold": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
|
||||
|
||||
"joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="],
|
||||
|
||||
"jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="],
|
||||
|
||||
"js-base64": ["js-base64@3.7.7", "", {}, "sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw=="],
|
||||
|
||||
"js-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="],
|
||||
@@ -3467,6 +3544,8 @@
|
||||
|
||||
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
|
||||
|
||||
"omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="],
|
||||
|
||||
"on-change": ["on-change@4.0.2", "", {}, "sha512-cMtCyuJmTx/bg2HCpHo3ZLeF7FZnBOapLqZHr2AlLeJ5Ul0Zu2mUJJz051Fdwu/Et2YW04ZD+TtU+gVy0ACNCA=="],
|
||||
|
||||
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
|
||||
@@ -3521,6 +3600,12 @@
|
||||
|
||||
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||
|
||||
"parse-bmfont-ascii": ["parse-bmfont-ascii@1.0.6", "", {}, "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA=="],
|
||||
|
||||
"parse-bmfont-binary": ["parse-bmfont-binary@1.0.6", "", {}, "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA=="],
|
||||
|
||||
"parse-bmfont-xml": ["parse-bmfont-xml@1.1.6", "", { "dependencies": { "xml-parse-from-string": "^1.0.0", "xml2js": "^0.5.0" } }, "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA=="],
|
||||
|
||||
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
|
||||
|
||||
"parse-filepath": ["parse-filepath@1.0.2", "", { "dependencies": { "is-absolute": "^1.0.0", "map-cache": "^0.2.0", "path-root": "^0.1.1" } }, "sha512-FwdRXKCohSVeXqwtYonZTXtbGJKrn+HNyWDYVcp5yuJlesTwNH4rsmRZ+GrKAPJ5bLpRxESMeS+Rl0VCHRvB2Q=="],
|
||||
@@ -3543,6 +3628,8 @@
|
||||
|
||||
"path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="],
|
||||
|
||||
"path-expression-matcher": ["path-expression-matcher@1.2.0", "", {}, "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||
@@ -3561,6 +3648,8 @@
|
||||
|
||||
"peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="],
|
||||
|
||||
"peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="],
|
||||
|
||||
"pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
|
||||
|
||||
"perfect-debounce": ["perfect-debounce@2.1.0", "", {}, "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g=="],
|
||||
@@ -3583,12 +3672,16 @@
|
||||
|
||||
"pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
|
||||
|
||||
"pixelmatch": ["pixelmatch@5.3.0", "", { "dependencies": { "pngjs": "^6.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q=="],
|
||||
|
||||
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||
|
||||
"pkg-dir": ["pkg-dir@4.2.0", "", { "dependencies": { "find-up": "^4.0.0" } }, "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ=="],
|
||||
|
||||
"pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
|
||||
|
||||
"pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
|
||||
|
||||
"points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="],
|
||||
|
||||
"points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="],
|
||||
@@ -3809,6 +3902,8 @@
|
||||
|
||||
"readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
|
||||
|
||||
"readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="],
|
||||
|
||||
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
||||
|
||||
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
|
||||
@@ -3997,6 +4092,8 @@
|
||||
|
||||
"simple-git": ["simple-git@3.30.0", "", { "dependencies": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", "debug": "^4.4.0" } }, "sha512-q6lxyDsCmEal/MEGhP1aVyQ3oxnagGlBDOVSIB4XUVLl1iZh0Pah6ebC9V4xBap/RfgP2WlI8EKs0WS0rMEJHg=="],
|
||||
|
||||
"simple-xml-to-json": ["simple-xml-to-json@1.2.4", "", {}, "sha512-3MY16e0ocMHL7N1ufpdObURGyX+lCo0T/A+y6VCwosLdH1HSda4QZl1Sdt/O+2qWp48WFi26XEp5rF0LoaL0Dg=="],
|
||||
|
||||
"sinon": ["sinon@21.0.1", "", { "dependencies": { "@sinonjs/commons": "^3.0.1", "@sinonjs/fake-timers": "^15.1.0", "@sinonjs/samsam": "^8.0.3", "diff": "^8.0.2", "supports-color": "^7.2.0" } }, "sha512-Z0NVCW45W8Mg5oC/27/+fCqIHFnW8kpkFOq0j9XJIev4Ld0mKmERaZv5DMLAb9fGCevjKwaEeIQz5+MBXfZcDw=="],
|
||||
|
||||
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
|
||||
@@ -4093,6 +4190,8 @@
|
||||
|
||||
"strnum": ["strnum@2.2.0", "", {}, "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg=="],
|
||||
|
||||
"strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="],
|
||||
|
||||
"stubborn-fs": ["stubborn-fs@2.0.0", "", { "dependencies": { "stubborn-utils": "^1.0.1" } }, "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA=="],
|
||||
|
||||
"stubborn-utils": ["stubborn-utils@1.0.2", "", {}, "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg=="],
|
||||
@@ -4149,6 +4248,8 @@
|
||||
|
||||
"timeout-signal": ["timeout-signal@2.0.0", "", {}, "sha512-YBGpG4bWsHoPvofT6y/5iqulfXIiIErl5B0LdtHT1mGXDFTAhhRrbUpTvBgYbovr+3cKblya2WAOcpoy90XguA=="],
|
||||
|
||||
"tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
@@ -4165,6 +4266,8 @@
|
||||
|
||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
"token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="],
|
||||
|
||||
"tokenlens": ["tokenlens@1.3.1", "", { "dependencies": { "@tokenlens/core": "1.3.0", "@tokenlens/fetch": "1.3.0", "@tokenlens/helpers": "1.3.1", "@tokenlens/models": "1.3.0" } }, "sha512-7oxmsS5PNCX3z+b+z07hL5vCzlgHKkCGrEQjQmWl5l+v5cUrtL7S1cuST4XThaL1XyjbTX8J5hfP0cjDJRkaLA=="],
|
||||
|
||||
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||
@@ -4283,6 +4386,8 @@
|
||||
|
||||
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||
|
||||
"utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
|
||||
@@ -4377,6 +4482,8 @@
|
||||
|
||||
"xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="],
|
||||
|
||||
"xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="],
|
||||
|
||||
"xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="],
|
||||
|
||||
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
|
||||
@@ -4449,7 +4556,9 @@
|
||||
|
||||
"@browseros/agent/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
|
||||
|
||||
"@browseros/eval/@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
|
||||
"@browseros/eval/@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1014.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/credential-provider-node": "^3.972.24", "@aws-sdk/middleware-bucket-endpoint": "^3.972.8", "@aws-sdk/middleware-expect-continue": "^3.972.8", "@aws-sdk/middleware-flexible-checksums": "^3.974.3", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-location-constraint": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-sdk-s3": "^3.972.23", "@aws-sdk/middleware-ssec": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/signature-v4-multi-region": "^3.996.11", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/eventstream-serde-browser": "^4.2.12", "@smithy/eventstream-serde-config-resolver": "^4.3.12", "@smithy/eventstream-serde-node": "^4.2.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-blob-browser": "^4.2.13", "@smithy/hash-node": "^4.2.12", "@smithy/hash-stream-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/md5-js": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.13", "tslib": "^2.6.2" } }, "sha512-0XLrOT4Cm3NEhhiME7l/8LbTXS4KdsbR4dSrY207KNKTcHLLTZ9EXt4ZpgnTfLvWQF3pGP2us4Zi1fYLo0N+Ow=="],
|
||||
|
||||
"@browseros/eval/@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
|
||||
|
||||
"@browseros/server/@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
|
||||
|
||||
@@ -4567,6 +4676,8 @@
|
||||
|
||||
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||
|
||||
"@jimp/core/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
|
||||
|
||||
"@lexical/react/react-error-boundary": ["react-error-boundary@3.1.4", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "react": ">=16.13.1" } }, "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA=="],
|
||||
|
||||
"@lobehub/fluent-emoji/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||
@@ -4985,6 +5096,8 @@
|
||||
|
||||
"http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="],
|
||||
|
||||
"import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
|
||||
"is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
|
||||
@@ -5049,12 +5162,16 @@
|
||||
|
||||
"pac-proxy-agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"parse-bmfont-xml/xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
|
||||
"pino/pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="],
|
||||
|
||||
"pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
|
||||
|
||||
"pkg-dir/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
||||
|
||||
"postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
@@ -5191,7 +5308,101 @@
|
||||
|
||||
"@browseros/agent/@types/bun/bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
||||
|
||||
"@browseros/eval/@types/bun/bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core": ["@aws-sdk/core@3.973.23", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/xml-builder": "^3.972.15", "@smithy/core": "^3.23.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-aoJncvD1XvloZ9JLnKqTRL9dBy+Szkryoag9VT+V1TqsuUgIxV9cnBVM/hrDi2vE8bDqLiDR8nirdRcCdtJu0w=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.24", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.21", "@aws-sdk/credential-provider-http": "^3.972.23", "@aws-sdk/credential-provider-ini": "^3.972.23", "@aws-sdk/credential-provider-process": "^3.972.21", "@aws-sdk/credential-provider-sso": "^3.972.23", "@aws-sdk/credential-provider-web-identity": "^3.972.23", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-9Jwi7aps3AfUicJyF5udYadPypPpCwUZ6BSKr/QjRbVCpRVS1wc+1Q6AEZ/qz8J4JraeRd247pSzyMQSIHVebw=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-WR525Rr2QJSETa9a050isktyWi/4yIGcmY3BQ1kpHqb0LqUglQHCS8R27dTJxxWNZvQ0RVGtEZjTCbZJpyF3Aw=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-5DTBTiotEES1e2jOHAq//zyzCjeMB78lEHd35u15qnrid4Nxm7diqIf9fQQ3Ov0ChH1V3Vvt13thOnrACmfGVQ=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.974.3", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/crc64-nvme": "^3.972.5", "@aws-sdk/types": "^3.973.6", "@smithy/is-array-buffer": "^4.2.2", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-fB7FNLH1+VPUs0QL3PLrHW+DD4gKu6daFgWtyq3R0Y0Lx8DLZPvyGAxCZNFBxH+M2xt9KvBJX6USwjuqvitmCQ=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-KaUoFuoFPziIa98DSQsTPeke1gvGXlc5ZGMhy+b+nLxZ4A7jmJgLzjEF95l8aOQN2T/qlPP3MrAyELm8ExXucw=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-CWl5UCM57WUFaFi5kB7IBY1UmOeLvNZAZ2/OZ5l20ldiJ3TiIz1pC65gYj8X0BCPWkeR1E32mpsCk1L1I4n+lA=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-BnnvYs2ZEpdlmZ2PNlV2ZyQ8j8AEkMTjN79y/YA475ER1ByFYrkVR85qmhni8oeTaJcDqbx364wDpitDAA/wCA=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/core": "^3.23.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-50QgHGPQAb2veqFOmTF1A3GsAklLHZXL47KbY35khIkfbXH5PLvqpEc/gOAEBPj/yFxrlgxz/8mqWcWTNxBkwQ=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wqlK0yO/TxEC2UsY9wIlqeeutF6jjLe0f96Pbm40XscTo57nImUk9lBcw0dPgsm0sppFtAkSlDrfpK+pC30Wqw=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@smithy/core": "^3.23.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-retry": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-dLTWy6IfAMhNiSEvMr07g/qZ54be6pLqlxVblbF6AzafmmGAzMMj8qMoY9B4+YgT+gY9IcuxZslNh03L6PyMCQ=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/config-resolver": "^4.4.13", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-eQ+dFU05ZRC/lC2XpYlYSPlXtX3VT8sn5toxN2Fv7EXlMoA2p9V7vUBKqHunfD4TRLpxUq8Y8Ol/nCqiv327Ng=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.11", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.23", "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-SKgZY7x6AloLUXO20FJGnkKJ3a6CXzNDt6PYs2yqoPzgU0xKWcUoGGJGEBTsfM5eihKW42lbwp+sXzACLbSsaA=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/types": ["@aws-sdk/types@3.973.6", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.5", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-endpoints": "^3.3.3", "tslib": "^2.6.2" } }, "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.10", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/types": "^3.973.6", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-E99zeTscCc+pTMfsvnfi6foPpKmdD1cZfOC7/P8UUrjsoQdg9VEWPRD+xdFduKnfPXwcvby58AlO9jwwF6U96g=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/config-resolver": ["@smithy/config-resolver@4.4.13", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-iIzMC5NmOUP6WL6o8iPBjFhUhBZ9pPjpUpQYWMUFQqKyXXzOftbfK8zcQCz/jFV1Psmf05BK5ypx4K2r4Tnwdg=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/core": ["@smithy/core@3.23.12", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-o9VycsYNtgC+Dy3I0yrwCqv9CWicDnke0L7EVOrZtJpjb2t0EjaEofmMrYc0T1Kn3yk32zm6cspxF9u9Bj7e5w=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.12", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-XUSuMxlTxV5pp4VpqZf6Sa3vT/Q75FVkLSpSSE3KkWBvAQWeuWt1msTv8fJfgA4/jcJhrbrbMzN1AC/hvPmm5A=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.3.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-7epsAZ3QvfHkngz6RXQYseyZYHlmWXSTPOfPmXkiS+zA6TBNo1awUaMFL9vxyXlGdoELmCZyZe1nQE+imbmV+Q=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.2.12", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-D1pFuExo31854eAvg89KMn9Oab/wEeJR6Buy32B49A9Ogdtx5fwZPqBHUlDzaCDpycTFk2+fSQgX689Qsk7UGA=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.15", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/hash-blob-browser": ["@smithy/hash-blob-browser@4.2.13", "", { "dependencies": { "@smithy/chunked-blob-reader": "^5.2.2", "@smithy/chunked-blob-reader-native": "^4.2.3", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-YrF4zWKh+ghLuquldj6e/RzE3xZYL8wIPfkt0MqCRphVICjyyjH8OwKD7LLlKpVEbk4FLizFfC1+gwK6XQdR3g=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/hash-node": ["@smithy/hash-node@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/hash-stream-node": ["@smithy/hash-stream-node@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-O3YbmGExeafuM/kP7Y8r6+1y0hIh3/zn6GROx0uNlB54K9oihAL75Qtc+jFfLNliTi6pxOAYZrRKD9A7iA6UFw=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/md5-js": ["@smithy/md5-js@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-W/oIpHCpWU2+iAkfZYyGWE+qkpuf3vEXHLxQQDx9FPNZTTdnul0dZ2d/gUFrtQ5je1G2kp4cjG0/24YueG2LbQ=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.12", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.27", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/middleware-serde": "^4.2.15", "@smithy/node-config-provider": "^4.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-T3TFfUgXQlpcg+UdzcAISdZpj4Z+XECZ/cefgA6wLBd6V4lRi0svN2hBouN/be9dXQ31X4sLWz3fAQDf+nt6BA=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.44", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/service-error-classification": "^4.2.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-Y1Rav7m5CFRPQyM4CI0koD/bXjyjJu3EQxZZhtLGD88WIrBrQ7kqXM96ncd6rYnojwOo/u9MXu57JrEvu/nLrA=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.15", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-ExYhcltZSli0pgAKOpQQe1DLFBLryeZ22605y/YS+mQpdNWekum9Ujb/jMKfJKgjtz1AZldtwA/wCYuKJgjjlg=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.12", "", { "dependencies": { "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/node-http-handler": ["@smithy/node-http-handler@4.5.0", "", { "dependencies": { "@smithy/abort-controller": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Rnq9vQWiR1+/I6NZZMNzJHV6pZYyEHt2ZnuV3MG8z2NNenC4i/8Kzttz7CjZiHSmsN5frhXhg17z3Zqjjhmz1A=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/protocol-http": ["@smithy/protocol-http@5.3.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/smithy-client": ["@smithy/smithy-client@4.12.7", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-stack": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-stream": "^4.5.20", "tslib": "^2.6.2" } }, "sha512-q3gqnwml60G44FECaEEsdQMplYhDMZYCtYhMCzadCnRnnHIobZJjegmdoUo6ieLQlPUzvrMdIJUpx6DoPmzANQ=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/types": ["@smithy/types@4.13.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/url-parser": ["@smithy/url-parser@4.2.12", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.43", "", { "dependencies": { "@smithy/property-provider": "^4.2.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Qd/0wCKMaXxev/z00TvNzGCH2jlKKKxXP1aDxB6oKwSQthe3Og2dMhSayGCnsma1bK/kQX1+X7SMP99t6FgiiQ=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.47", "", { "dependencies": { "@smithy/config-resolver": "^4.4.13", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-qSRbYp1EQ7th+sPFuVcVO05AE0QH635hycdEXlpzIahqHHf2Fyd/Zl+8v0XYMJ3cgDVPa0lkMefU7oNUjAP+DQ=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/util-endpoints": ["@smithy/util-endpoints@3.3.3", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/util-middleware": ["@smithy/util-middleware@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/util-retry": ["@smithy/util-retry@4.2.12", "", { "dependencies": { "@smithy/service-error-classification": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/util-stream": ["@smithy/util-stream@4.5.20", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.0", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-4yXLm5n/B5SRBR2p8cZ90Sbv4zL4NKsgxdzCzp/83cXw2KxLEumt5p+GAVyRNZgQOSrzXn9ARpO0lUe8XSlSDw=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/util-waiter": ["@smithy/util-waiter@4.2.13", "", { "dependencies": { "@smithy/abort-controller": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-2zdZ9DTHngRtcYxJK1GUDxruNr53kv5W2Lupe0LMU+Imr6ohQg8M2T14MNkj1Y0wS3FFwpgpGQyvuaMF7CiTmQ=="],
|
||||
|
||||
"@browseros/eval/@types/bun/bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||
|
||||
"@browseros/server/@types/bun/bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
|
||||
|
||||
@@ -5505,6 +5716,70 @@
|
||||
|
||||
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.15", "", { "dependencies": { "@smithy/types": "^4.13.1", "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" } }, "sha512-PxMRlCFNiQnke9YR29vjFQwz4jq+6Q04rOVFeTDR2K7Qpv9h9FOWOxG+zJjageimYbWqE3bTuLjmryWHAWbvaA=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.3.12", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.21", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-BkAfKq8Bd4shCtec1usNz//urPJF/SZy14qJyxkSaRJQ/Vv1gVh0VZSTmS7aE6aLMELkFV5wHHrS9ZcdG8Kxsg=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.0", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-stream": "^4.5.20", "tslib": "^2.6.2" } }, "sha512-4XZ3+Gu5DY8/n8zQFHBgcKTF7hWQl42G6CY9xfXVo2d25FM/lYkpmuzhYopYoPL1ITWkJ2OSBQfYEu5JRfHOhA=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/credential-provider-env": "^3.972.21", "@aws-sdk/credential-provider-http": "^3.972.23", "@aws-sdk/credential-provider-login": "^3.972.23", "@aws-sdk/credential-provider-process": "^3.972.21", "@aws-sdk/credential-provider-sso": "^3.972.23", "@aws-sdk/credential-provider-web-identity": "^3.972.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-PZLSmU0JFpNCDFReidBezsgL5ji9jOBry8CnZdw4Jj6d0K2z3Ftnp44NXgADqYx5BLMu/ZHujfeJReaDoV+IwQ=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.21", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-nRxbeOJ1E1gVA0lNQezuMVndx+ZcuyaW/RB05pUsznN5BxykSlH6KkZ/7Ca/ubJf3i5N3p0gwNO5zgPSCzj+ww=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/token-providers": "3.1014.0", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-APUccADuYPLL0f2htpM8Z4czabSmHOdo4r41W6lKEZdy++cNJ42Radqy6x4TopENzr3hR6WYMyhiuiqtbf/nAA=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-H5JNqtIwOu/feInmMMWcK0dL5r897ReEn7n2m16Dd0DPD9gA2Hg8Cq4UDzZ/9OzaLh/uqBM6seixz0U6Fi2Eag=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.12", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.7", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/middleware-bucket-endpoint/@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.972.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/middleware-flexible-checksums/@aws-sdk/crc64-nvme": ["@aws-sdk/crc64-nvme@3.972.5", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-2VbTstbjKdT+yKi8m7b3a9CiVac+pL/IY2PHJwsaGkkHmuuqkJZIErPck1h6P3T9ghQMLSdMPyW6Qp7Di5swFg=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/middleware-sdk-s3/@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.972.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/middleware-sdk-s3/@smithy/signature-v4": ["@smithy/signature-v4@5.3.12", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.3.12", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/eventstream-serde-browser/@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.12", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/eventstream-serde-node/@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.12", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/fetch-http-handler/@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/middleware-endpoint/@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.7", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/middleware-retry/@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1" } }, "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/node-config-provider/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/node-config-provider/@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.7", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/node-http-handler/@smithy/abort-controller": ["@smithy/abort-controller@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/node-http-handler/@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/url-parser/@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/util-defaults-mode-browser/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/util-defaults-mode-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.12", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/util-defaults-mode-node/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/util-retry/@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1" } }, "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/util-waiter/@smithy/abort-controller": ["@smithy/abort-controller@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q=="],
|
||||
|
||||
"@google/gemini-cli-core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.0.1", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw=="],
|
||||
|
||||
"@google/gemini-cli-core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.0.1", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g=="],
|
||||
@@ -5549,6 +5824,22 @@
|
||||
|
||||
"wxt/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.5.8", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-OmE/pSkbMM3dCj1HdOnZ5kXnKK+R/Yz+kbBugraBecp0pGAs21eEURfQRz+1N2gzIHLVyGIP1MEjk/uSrFsngg=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-ptZ1HF4yYHNJX8cgFF+8NdYO69XJKZn7ft0/ynV3c0hCbN+89fAbrLS+fqniU2tW8o9Kfqhj8FUh+IPXb2Qsuw=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-ptZ1HF4yYHNJX8cgFF+8NdYO69XJKZn7ft0/ynV3c0hCbN+89fAbrLS+fqniU2tW8o9Kfqhj8FUh+IPXb2Qsuw=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1014.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-gHTHNUoaOGNrSWkl32A7wFsU78jlNTlqMccLu0byUk5CysYYXaxNMIonIVr4YcykC7vgtDS5ABuz83giy6fzJA=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-ptZ1HF4yYHNJX8cgFF+8NdYO69XJKZn7ft0/ynV3c0hCbN+89fAbrLS+fqniU2tW8o9Kfqhj8FUh+IPXb2Qsuw=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/eventstream-serde-browser/@smithy/eventstream-serde-universal/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@smithy/eventstream-serde-node/@smithy/eventstream-serde-universal/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="],
|
||||
|
||||
"@google/genai/google-auth-library/gaxios/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"@google/genai/google-auth-library/gaxios/rimraf/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
|
||||
@@ -5563,6 +5854,8 @@
|
||||
|
||||
"publish-browser-extension/listr2/cli-truncate/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser/fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
|
||||
|
||||
"@google/genai/google-auth-library/gaxios/rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||
|
||||
"@google/genai/google-auth-library/gaxios/rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Credits Tracking UI Design
|
||||
|
||||
## Overview
|
||||
|
||||
Surface credit balance to users across two locations: a compact badge in the side panel chat header, and a dedicated Usage & Billing settings page. Credits refresh after each completed message turn or on error.
|
||||
|
||||
## 1. Side Panel — Credit Badge
|
||||
|
||||
**Location:** Chat header, next to provider selector. Only visible when provider is `browseros`.
|
||||
|
||||
**Display:**
|
||||
- Coin/credit icon + remaining count (e.g., "87")
|
||||
- Color-coded by threshold:
|
||||
- Green: >30 credits
|
||||
- Yellow/orange: 1–30 credits
|
||||
- Red: 0 credits
|
||||
- Clicking the badge navigates to the Usage & Billing settings page
|
||||
|
||||
**Update triggers:**
|
||||
- Message turn completes successfully (agent finishes all tool calls and responds)
|
||||
- CREDITS_EXHAUSTED error mid-turn (badge syncs to 0, error shown in chat)
|
||||
|
||||
## 2. Settings — Usage & Billing Page
|
||||
|
||||
**Sidebar entry:** "Usage & Billing" in the "Other" section (icon: CreditCard or Coins).
|
||||
|
||||
**Route:** `/settings/usage`
|
||||
|
||||
**Content:**
|
||||
- Credits card: large display of remaining credits (e.g., "87 / 100") with color-coded progress bar
|
||||
- Reset info: "Resets daily at midnight UTC" with last reset date
|
||||
- Credit cost: "1 credit per request"
|
||||
- Placeholder section: "Need more credits?" with disabled "Add Credits" button (future payment/recharge)
|
||||
|
||||
## 3. Data Flow
|
||||
|
||||
**Hook:** `useCredits()` — React Query hook fetching `GET /credits` from the agent server.
|
||||
|
||||
**Refresh strategy:**
|
||||
- Refetch after each completed message turn (`onFinish` callback in chat session)
|
||||
- Refetch on CREDITS_EXHAUSTED error
|
||||
- Refetch on window focus (React Query default)
|
||||
- No aggressive polling
|
||||
|
||||
**State sharing:** Credits query is global (React Query cache). Both side panel badge and settings page read from the same cache key.
|
||||
|
||||
## 4. Error Handling (0 credits)
|
||||
|
||||
When credits are exhausted mid-conversation:
|
||||
- Chat stream shows error via existing `ChatError.tsx` pattern: "Daily credits exhausted. Resets at midnight UTC." with link to Usage & Billing page
|
||||
- Header badge turns red (0 credits)
|
||||
- Chat input stays enabled — user can switch to a different provider
|
||||
|
||||
## 5. Future Hooks
|
||||
|
||||
- "Add Credits" button on Usage & Billing page (currently disabled placeholder)
|
||||
- Payment integration will live entirely within the Usage & Billing page
|
||||
- Credit badge could show a "+" icon when balance is low, linking to recharge
|
||||
@@ -0,0 +1,384 @@
|
||||
# Credits Tracking UI Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Show credit balance in the side panel chat header and a dedicated Usage & Billing settings page, with live updates after each message turn.
|
||||
|
||||
**Architecture:** A `useCredits()` React Query hook fetches `GET /credits` from the agent server. The side panel header shows a color-coded badge (green >30, yellow 1-30, red 0). A new settings page at `/settings/usage` shows full details. Credits refresh after each completed chat turn or on CREDITS_EXHAUSTED error.
|
||||
|
||||
**Tech Stack:** React, React Query, Shadcn UI, Lucide icons, Hono (server already done)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Create useCredits() hook
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/agent/lib/credits/useCredits.ts`
|
||||
|
||||
**Step 1: Write the hook**
|
||||
|
||||
```typescript
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { getAgentServerUrl } from '@/lib/browseros/helpers'
|
||||
|
||||
interface CreditsInfo {
|
||||
credits: number
|
||||
lastResetAt?: string
|
||||
}
|
||||
|
||||
const CREDITS_QUERY_KEY = ['credits']
|
||||
|
||||
async function fetchCredits(): Promise<CreditsInfo> {
|
||||
const baseUrl = await getAgentServerUrl()
|
||||
const response = await fetch(`${baseUrl}/credits`)
|
||||
if (!response.ok) throw new Error(`Failed to fetch credits: ${response.status}`)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export function useCredits() {
|
||||
return useQuery<CreditsInfo>({
|
||||
queryKey: CREDITS_QUERY_KEY,
|
||||
queryFn: fetchCredits,
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
})
|
||||
}
|
||||
|
||||
export function useInvalidateCredits() {
|
||||
const queryClient = useQueryClient()
|
||||
return () => queryClient.invalidateQueries({ queryKey: CREDITS_QUERY_KEY })
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/agent/lib/credits/useCredits.ts
|
||||
git commit -m "feat: add useCredits React Query hook"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create CreditBadge component
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/agent/components/credits/CreditBadge.tsx`
|
||||
|
||||
**Step 1: Write the component**
|
||||
|
||||
The badge shows a coin icon + credit count, color-coded by threshold. Only renders when credits data is available.
|
||||
|
||||
```tsx
|
||||
import { Coins } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CreditBadgeProps {
|
||||
credits: number
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
function getCreditColor(credits: number): string {
|
||||
if (credits <= 0) return 'text-red-500'
|
||||
if (credits <= 30) return 'text-yellow-500'
|
||||
return 'text-green-500'
|
||||
}
|
||||
|
||||
export const CreditBadge: FC<CreditBadgeProps> = ({ credits, onClick }) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs font-medium transition-colors hover:bg-muted/50',
|
||||
getCreditColor(credits),
|
||||
)}
|
||||
title={`${credits} credits remaining`}
|
||||
>
|
||||
<Coins className="h-3.5 w-3.5" />
|
||||
<span>{credits}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/agent/components/credits/CreditBadge.tsx
|
||||
git commit -m "feat: add CreditBadge component with color thresholds"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add CreditBadge to ChatHeader
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/agent/entrypoints/sidepanel/index/ChatHeader.tsx`
|
||||
|
||||
**Step 1: Update ChatHeader**
|
||||
|
||||
Add the credit badge after the provider selector, only when provider is `browseros`. The badge links to the Usage & Billing settings page.
|
||||
|
||||
Changes to `ChatHeader.tsx`:
|
||||
1. Import `CreditBadge` and `useCredits`
|
||||
2. After the `ChatProviderSelector` closing tag (line 61), add the badge conditionally
|
||||
|
||||
```tsx
|
||||
// Add imports at top:
|
||||
import { CreditBadge } from '@/components/credits/CreditBadge'
|
||||
import { useCredits } from '@/lib/credits/useCredits'
|
||||
|
||||
// After line 61 (closing </ChatProviderSelector>), before closing </div>:
|
||||
{selectedProvider.type === 'browseros' && <CreditsBadgeWrapper />}
|
||||
```
|
||||
|
||||
Create a small wrapper component inside the file to keep the hook call conditional:
|
||||
|
||||
```tsx
|
||||
const CreditsBadgeWrapper: FC = () => {
|
||||
const { data } = useCredits()
|
||||
if (data === undefined) return null
|
||||
return (
|
||||
<CreditBadge
|
||||
credits={data.credits}
|
||||
onClick={() => window.open('/app.html#/settings/usage', '_blank')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/agent/entrypoints/sidepanel/index/ChatHeader.tsx
|
||||
git commit -m "feat: show credit badge in chat header for BrowserOS provider"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add credit refresh on message completion
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/agent/entrypoints/sidepanel/index/useChatSession.ts`
|
||||
|
||||
**Step 1: Update useChatSession**
|
||||
|
||||
Import `useInvalidateCredits` and call it when a message turn completes (status transitions from streaming/submitted to ready) and when an error occurs.
|
||||
|
||||
```typescript
|
||||
// Add import:
|
||||
import { useInvalidateCredits } from '@/lib/credits/useCredits'
|
||||
|
||||
// Inside useChatSession(), near other hook calls:
|
||||
const invalidateCredits = useInvalidateCredits()
|
||||
```
|
||||
|
||||
Find the existing completion detection logic (where `saveLocalConversation` or `saveRemoteConversation` is called after status becomes 'ready'). Add `invalidateCredits()` call there.
|
||||
|
||||
Also, in the error handling path (where `chatError` is set), add `invalidateCredits()` to sync badge on CREDITS_EXHAUSTED.
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/agent/entrypoints/sidepanel/index/useChatSession.ts
|
||||
git commit -m "feat: refresh credits after chat message completion and on error"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Update ChatError for CREDITS_EXHAUSTED
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/agent/entrypoints/sidepanel/index/ChatError.tsx`
|
||||
|
||||
**Step 1: Add CREDITS_EXHAUSTED detection to parseErrorMessage**
|
||||
|
||||
In `parseErrorMessage()` (line 29), add a new detection block after the existing rate limit check (line 48):
|
||||
|
||||
```typescript
|
||||
// After the 'BrowserOS LLM daily limit reached' block, add:
|
||||
if (message.includes('CREDITS_EXHAUSTED') || message.includes('Daily credits exhausted')) {
|
||||
return {
|
||||
text: 'Daily credits exhausted. Credits reset at midnight UTC.',
|
||||
url: '/app.html#/settings/usage',
|
||||
isRateLimit: true,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/agent/entrypoints/sidepanel/index/ChatError.tsx
|
||||
git commit -m "feat: handle CREDITS_EXHAUSTED error in chat"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Create Usage & Billing settings page
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/agent/entrypoints/app/usage/UsagePage.tsx`
|
||||
|
||||
**Step 1: Write the page component**
|
||||
|
||||
Follow the same pattern as `AISettingsPage.tsx` — a standalone page component rendered inside the settings sidebar layout.
|
||||
|
||||
```tsx
|
||||
import { Coins } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { useCredits } from '@/lib/credits/useCredits'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function getCreditColor(credits: number): string {
|
||||
if (credits <= 0) return 'text-red-500'
|
||||
if (credits <= 30) return 'text-yellow-500'
|
||||
return 'text-green-500'
|
||||
}
|
||||
|
||||
function getProgressColor(credits: number): string {
|
||||
if (credits <= 0) return 'bg-red-500'
|
||||
if (credits <= 30) return 'bg-yellow-500'
|
||||
return 'bg-green-500'
|
||||
}
|
||||
|
||||
export const UsagePage: FC = () => {
|
||||
const { data, isLoading } = useCredits()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-12 text-muted-foreground text-sm">
|
||||
Loading usage data...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const credits = data?.credits ?? 0
|
||||
const total = 100
|
||||
const percentage = Math.min((credits / total) * 100, 100)
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg">Usage & Billing</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Monitor your BrowserOS AI credit usage.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Coins className="h-5 w-5" />
|
||||
Credits
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className={cn('font-bold text-3xl', getCreditColor(credits))}>
|
||||
{credits}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-sm">/ {total} daily</span>
|
||||
</div>
|
||||
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all', getProgressColor(credits))}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-muted-foreground text-sm">
|
||||
<p>1 credit per request</p>
|
||||
<p>Resets daily at midnight UTC</p>
|
||||
{data?.lastResetAt && (
|
||||
<p>Last reset: {new Date(data.lastResetAt).toLocaleDateString()}</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Need more credits?</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="mb-3 text-muted-foreground text-sm">
|
||||
Additional credit packages will be available soon.
|
||||
</p>
|
||||
<Button variant="outline" disabled>
|
||||
Add Credits (Coming Soon)
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/agent/entrypoints/app/usage/UsagePage.tsx
|
||||
git commit -m "feat: add Usage & Billing settings page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Register route and sidebar entry
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/agent/entrypoints/app/App.tsx` — add route
|
||||
- Modify: `apps/agent/components/sidebar/SettingsSidebar.tsx` — add sidebar entry
|
||||
|
||||
**Step 1: Add route to App.tsx**
|
||||
|
||||
Inside the `<Route path="settings">` block (after line 103, before closing `</Route>`):
|
||||
|
||||
```tsx
|
||||
import { UsagePage } from './usage/UsagePage'
|
||||
|
||||
// Add as new route:
|
||||
<Route path="usage" element={<UsagePage />} />
|
||||
```
|
||||
|
||||
**Step 2: Add sidebar entry to SettingsSidebar.tsx**
|
||||
|
||||
Import `CreditCard` from lucide-react (line 1). Add entry to the "Other" section in `primarySettingsSections` array (after line 81):
|
||||
|
||||
```typescript
|
||||
{ name: 'Usage & Billing', to: '/settings/usage', icon: CreditCard },
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/agent/entrypoints/app/App.tsx apps/agent/components/sidebar/SettingsSidebar.tsx
|
||||
git commit -m "feat: register usage page route and sidebar entry"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Verify end-to-end
|
||||
|
||||
**Step 1: Start dev server**
|
||||
|
||||
```bash
|
||||
bun run dev:watch -- --new
|
||||
```
|
||||
|
||||
**Step 2: Visual verification checklist**
|
||||
|
||||
- [ ] Open side panel — credit badge shows next to BrowserOS provider name
|
||||
- [ ] Badge color is green when credits > 30
|
||||
- [ ] Send a chat message — after response completes, badge count decrements
|
||||
- [ ] Click badge — opens settings/usage page
|
||||
- [ ] Settings sidebar shows "Usage & Billing" under "Other"
|
||||
- [ ] Usage page shows credit count, progress bar, reset info
|
||||
- [ ] Exhaust credits — badge turns red, chat shows error message
|
||||
|
||||
**Step 3: Commit any fixes**
|
||||
@@ -37,6 +37,10 @@
|
||||
"types": "./src/types/logger.ts",
|
||||
"default": "./src/types/logger.ts"
|
||||
},
|
||||
"./types/server-config": {
|
||||
"types": "./src/types/server-config.ts",
|
||||
"default": "./src/types/server-config.ts"
|
||||
},
|
||||
"./schemas/llm": {
|
||||
"types": "./src/schemas/llm.ts",
|
||||
"default": "./src/schemas/llm.ts"
|
||||
|
||||
@@ -16,6 +16,7 @@ export const PATHS = {
|
||||
CORE_MEMORY_FILE_NAME: 'CORE.md',
|
||||
SKILLS_DIR_NAME: 'skills',
|
||||
BUILTIN_DIR_NAME: 'builtin',
|
||||
SERVER_CONFIG_FILE_NAME: 'server.json',
|
||||
SOUL_MAX_LINES: 150,
|
||||
MEMORY_RETENTION_DAYS: 30,
|
||||
SESSION_RETENTION_DAYS: 30,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Shape of ~/.browseros/server.json written by the server on startup.
|
||||
* The CLI reads this file for auto-discovery of the server URL.
|
||||
*/
|
||||
|
||||
export interface ServerDiscoveryConfig {
|
||||
server_port: number
|
||||
url: string
|
||||
server_version: string
|
||||
browseros_version?: string
|
||||
chromium_version?: string
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
140
|
||||
141
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
BROWSEROS_MAJOR=0
|
||||
BROWSEROS_MINOR=44
|
||||
BROWSEROS_BUILD=0
|
||||
BROWSEROS_PATCH=0
|
||||
BROWSEROS_PATCH=1
|
||||
|
||||
Reference in New Issue
Block a user