mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
Compare commits
20 Commits
feat/githu
...
feat/githu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9550229033 | ||
|
|
55e31c1e34 | ||
|
|
60660c5c17 | ||
|
|
45775f3e8d | ||
|
|
f0c9aa6f7b | ||
|
|
be6ed22af4 | ||
|
|
149cde118d | ||
|
|
9bc5e666c4 | ||
|
|
2271277b4d | ||
|
|
f865d301a2 | ||
|
|
6f398f0b36 | ||
|
|
8548bcf50a | ||
|
|
e3601bfdc1 | ||
|
|
2b4fdf1aad | ||
|
|
11d15d079f | ||
|
|
9257832acf | ||
|
|
7bde0d59fa | ||
|
|
1c737b0f02 | ||
|
|
5d0a2b9bfe | ||
|
|
720baaed3e |
127
.github/workflows/test.yml
vendored
127
.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,91 @@ 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"
|
||||
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/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.8/schema.json",
|
||||
"root": false,
|
||||
"extends": "//",
|
||||
"vcs": {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -18,6 +18,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
<Sonner
|
||||
theme={theme as ToasterProps['theme']}
|
||||
className="toaster group"
|
||||
closeButton
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { type FC, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { type FC, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -17,19 +17,25 @@ import {
|
||||
CHATGPT_PRO_OAUTH_COMPLETED_EVENT,
|
||||
CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT,
|
||||
CHATGPT_PRO_OAUTH_STARTED_EVENT,
|
||||
GITHUB_COPILOT_OAUTH_COMPLETED_EVENT,
|
||||
GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT,
|
||||
GITHUB_COPILOT_OAUTH_STARTED_EVENT,
|
||||
QWEN_CODE_OAUTH_COMPLETED_EVENT,
|
||||
QWEN_CODE_OAUTH_DISCONNECTED_EVENT,
|
||||
QWEN_CODE_OAUTH_STARTED_EVENT,
|
||||
} from '@/lib/constants/analyticsEvents'
|
||||
import { GetProfileIdByUserIdDocument } from '@/lib/conversations/graphql/uploadConversationDocument'
|
||||
import { getQueryKeyFromDocument } from '@/lib/graphql/getQueryKeyFromDocument'
|
||||
import { useGraphqlMutation } from '@/lib/graphql/useGraphqlMutation'
|
||||
import { useGraphqlQuery } from '@/lib/graphql/useGraphqlQuery'
|
||||
import {
|
||||
getProviderTemplate,
|
||||
type ProviderTemplate,
|
||||
} from '@/lib/llm-providers/providerTemplates'
|
||||
import type { ProviderTemplate } from '@/lib/llm-providers/providerTemplates'
|
||||
import { testProvider } from '@/lib/llm-providers/testProvider'
|
||||
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
|
||||
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
|
||||
import { useOAuthStatus } from '@/lib/llm-providers/useOAuthStatus'
|
||||
import {
|
||||
type OAuthProviderFlowConfig,
|
||||
useOAuthProviderFlow,
|
||||
} from '@/lib/llm-providers/useOAuthProviderFlow'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import { ConfiguredProvidersList } from './ConfiguredProvidersList'
|
||||
import {
|
||||
@@ -42,6 +48,47 @@ import { LlmProvidersHeader } from './LlmProvidersHeader'
|
||||
import { NewProviderDialog } from './NewProviderDialog'
|
||||
import { ProviderTemplatesSection } from './ProviderTemplatesSection'
|
||||
|
||||
// All OAuth providers share the same flow via useOAuthProviderFlow
|
||||
const OAUTH_PROVIDERS_CONFIG: Record<string, OAuthProviderFlowConfig> = {
|
||||
'chatgpt-pro': {
|
||||
providerType: 'chatgpt-pro',
|
||||
displayName: 'ChatGPT Plus/Pro',
|
||||
startedEvent: CHATGPT_PRO_OAUTH_STARTED_EVENT,
|
||||
completedEvent: CHATGPT_PRO_OAUTH_COMPLETED_EVENT,
|
||||
disconnectedEvent: CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT,
|
||||
},
|
||||
'github-copilot': {
|
||||
providerType: 'github-copilot',
|
||||
displayName: 'GitHub Copilot',
|
||||
startedEvent: GITHUB_COPILOT_OAUTH_STARTED_EVENT,
|
||||
completedEvent: GITHUB_COPILOT_OAUTH_COMPLETED_EVENT,
|
||||
disconnectedEvent: GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT,
|
||||
clientAuth: {
|
||||
deviceCodeEndpoint: 'https://github.com/login/device/code',
|
||||
tokenEndpoint: 'https://github.com/login/oauth/access_token',
|
||||
clientId: 'Ov23li8tweQw6odWQebz',
|
||||
scopes: 'read:user',
|
||||
requiresPKCE: false,
|
||||
contentType: 'json',
|
||||
},
|
||||
},
|
||||
'qwen-code': {
|
||||
providerType: 'qwen-code',
|
||||
displayName: 'Qwen Code',
|
||||
startedEvent: QWEN_CODE_OAUTH_STARTED_EVENT,
|
||||
completedEvent: QWEN_CODE_OAUTH_COMPLETED_EVENT,
|
||||
disconnectedEvent: QWEN_CODE_OAUTH_DISCONNECTED_EVENT,
|
||||
clientAuth: {
|
||||
deviceCodeEndpoint: 'https://chat.qwen.ai/api/v1/oauth2/device/code',
|
||||
tokenEndpoint: 'https://chat.qwen.ai/api/v1/oauth2/token',
|
||||
clientId: 'f0304373b74a44d2b584a3fb70ca9e56',
|
||||
scopes: 'openid profile email model.completion',
|
||||
requiresPKCE: true,
|
||||
contentType: 'form',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* AI Settings page for managing LLM providers
|
||||
* @public
|
||||
@@ -88,9 +135,7 @@ export const AISettingsPage: FC = () => {
|
||||
|
||||
const incompleteProviders = useMemo<IncompleteProvider[]>(() => {
|
||||
if (!remoteProvidersData?.llmProviders?.nodes) return []
|
||||
|
||||
const localProviderIds = new Set(providers.map((p) => p.id))
|
||||
|
||||
return remoteProvidersData.llmProviders.nodes
|
||||
.filter((node): node is NonNullable<typeof node> => node !== null)
|
||||
.filter((node) => !localProviderIds.has(node.rowId))
|
||||
@@ -111,56 +156,47 @@ export const AISettingsPage: FC = () => {
|
||||
null,
|
||||
)
|
||||
|
||||
// OAuth status for ChatGPT Plus/Pro
|
||||
const {
|
||||
status: chatgptProStatus,
|
||||
startPolling: startChatGPTProPolling,
|
||||
disconnect: disconnectChatGPTPro,
|
||||
} = useOAuthStatus('chatgpt-pro')
|
||||
// OAuth flows — shared hook eliminates per-provider duplication
|
||||
const chatgptPro = useOAuthProviderFlow(
|
||||
OAUTH_PROVIDERS_CONFIG['chatgpt-pro'],
|
||||
providers,
|
||||
saveProvider,
|
||||
)
|
||||
const copilot = useOAuthProviderFlow(
|
||||
OAUTH_PROVIDERS_CONFIG['github-copilot'],
|
||||
providers,
|
||||
saveProvider,
|
||||
)
|
||||
const qwenCode = useOAuthProviderFlow(
|
||||
OAUTH_PROVIDERS_CONFIG['qwen-code'],
|
||||
providers,
|
||||
saveProvider,
|
||||
)
|
||||
|
||||
// Track whether user explicitly started an OAuth flow this session
|
||||
const oauthFlowStartedRef = useRef(false)
|
||||
|
||||
// Auto-create provider only when user actively completed OAuth,
|
||||
// not on passive page load when server has old tokens
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — only trigger on auth status change
|
||||
useEffect(() => {
|
||||
if (!chatgptProStatus?.authenticated) return
|
||||
if (!oauthFlowStartedRef.current) return
|
||||
|
||||
const exists = providers.some((p) => p.type === 'chatgpt-pro')
|
||||
if (exists) return
|
||||
|
||||
const now = Date.now()
|
||||
try {
|
||||
const template = getProviderTemplate('chatgpt-pro')
|
||||
saveProvider({
|
||||
id: `chatgpt-pro-${now}`,
|
||||
type: 'chatgpt-pro',
|
||||
name: `ChatGPT Plus/Pro${chatgptProStatus.email ? ` (${chatgptProStatus.email})` : ''}`,
|
||||
modelId: template?.defaultModelId ?? 'gpt-5.3-codex',
|
||||
supportsImages: template?.supportsImages ?? true,
|
||||
contextWindow: template?.contextWindow ?? 400000,
|
||||
temperature: 0.2,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
track(CHATGPT_PRO_OAUTH_COMPLETED_EVENT, {
|
||||
email: chatgptProStatus.email,
|
||||
})
|
||||
toast.success('ChatGPT Plus/Pro Connected', {
|
||||
description: chatgptProStatus.email
|
||||
? `Authenticated as ${chatgptProStatus.email}`
|
||||
: 'Successfully authenticated with ChatGPT Plus/Pro',
|
||||
})
|
||||
} catch (err) {
|
||||
toast.error('Failed to create ChatGPT Plus/Pro provider', {
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
})
|
||||
} finally {
|
||||
oauthFlowStartedRef.current = false
|
||||
const oauthFlows: Record<
|
||||
string,
|
||||
{
|
||||
startOAuthFlow: (url: string | undefined) => Promise<void>
|
||||
disconnect: () => Promise<void>
|
||||
disconnectedEvent: string
|
||||
}
|
||||
}, [chatgptProStatus?.authenticated])
|
||||
> = {
|
||||
'chatgpt-pro': {
|
||||
startOAuthFlow: chatgptPro.startOAuthFlow,
|
||||
disconnect: chatgptPro.disconnect,
|
||||
disconnectedEvent: CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT,
|
||||
},
|
||||
'github-copilot': {
|
||||
startOAuthFlow: copilot.startOAuthFlow,
|
||||
disconnect: copilot.disconnect,
|
||||
disconnectedEvent: GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT,
|
||||
},
|
||||
'qwen-code': {
|
||||
startOAuthFlow: qwenCode.startOAuthFlow,
|
||||
disconnect: qwenCode.disconnect,
|
||||
disconnectedEvent: QWEN_CODE_OAUTH_DISCONNECTED_EVENT,
|
||||
},
|
||||
}
|
||||
|
||||
const handleAddProvider = () => {
|
||||
setTemplateValues(undefined)
|
||||
@@ -168,9 +204,10 @@ export const AISettingsPage: FC = () => {
|
||||
}
|
||||
|
||||
const handleUseTemplate = (template: ProviderTemplate) => {
|
||||
// OAuth providers: trigger OAuth flow instead of opening form dialog
|
||||
if (template.id === 'chatgpt-pro') {
|
||||
handleStartChatGPTProOAuth()
|
||||
// OAuth providers: trigger OAuth flow
|
||||
const oauthFlow = oauthFlows[template.id]
|
||||
if (oauthFlow) {
|
||||
oauthFlow.startOAuthFlow(agentServerUrl ?? undefined)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -186,27 +223,6 @@ export const AISettingsPage: FC = () => {
|
||||
setIsNewDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleStartChatGPTProOAuth = () => {
|
||||
if (!agentServerUrl) {
|
||||
toast.error('Server not available', {
|
||||
description: 'Cannot start OAuth flow without server connection.',
|
||||
})
|
||||
return
|
||||
}
|
||||
oauthFlowStartedRef.current = true
|
||||
|
||||
const extensionSettingsUrl = chrome.runtime.getURL('app.html#/ai-settings')
|
||||
const startUrl = `${agentServerUrl}/oauth/chatgpt-pro/start?redirect=${encodeURIComponent(extensionSettingsUrl)}`
|
||||
window.open(startUrl, '_blank')
|
||||
|
||||
// Start polling for OAuth completion
|
||||
startChatGPTProPolling()
|
||||
track(CHATGPT_PRO_OAUTH_STARTED_EVENT)
|
||||
toast.info('Authenticating with ChatGPT Plus/Pro', {
|
||||
description: 'Complete the login in the opened tab.',
|
||||
})
|
||||
}
|
||||
|
||||
const handleEditProvider = (provider: LlmProviderConfig) => {
|
||||
setEditingProvider(provider)
|
||||
setIsEditDialogOpen(true)
|
||||
@@ -217,16 +233,18 @@ export const AISettingsPage: FC = () => {
|
||||
}
|
||||
|
||||
const confirmDeleteProvider = async () => {
|
||||
if (providerToDelete) {
|
||||
// Clear OAuth tokens on server for OAuth-based providers
|
||||
if (providerToDelete.type === 'chatgpt-pro') {
|
||||
await disconnectChatGPTPro()
|
||||
track(CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT)
|
||||
}
|
||||
await deleteProvider(providerToDelete.id)
|
||||
deleteRemoteProviderMutation.mutate({ rowId: providerToDelete.id })
|
||||
setProviderToDelete(null)
|
||||
if (!providerToDelete) return
|
||||
|
||||
// Clear OAuth tokens on server for OAuth-based providers
|
||||
const oauthFlow = oauthFlows[providerToDelete.type]
|
||||
if (oauthFlow) {
|
||||
await oauthFlow.disconnect()
|
||||
track(oauthFlow.disconnectedEvent)
|
||||
}
|
||||
|
||||
await deleteProvider(providerToDelete.id)
|
||||
deleteRemoteProviderMutation.mutate({ rowId: providerToDelete.id })
|
||||
setProviderToDelete(null)
|
||||
}
|
||||
|
||||
const handleAddKeysToIncomplete = (provider: IncompleteProvider) => {
|
||||
|
||||
@@ -62,6 +62,8 @@ const providerTypeEnum = z.enum([
|
||||
'bedrock',
|
||||
'browseros',
|
||||
'chatgpt-pro',
|
||||
'github-copilot',
|
||||
'qwen-code',
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -131,8 +133,12 @@ export const providerFormSchema = z
|
||||
})
|
||||
}
|
||||
}
|
||||
// ChatGPT Pro: no credentials needed (server-managed OAuth)
|
||||
else if (data.type === 'chatgpt-pro') {
|
||||
// OAuth providers: no credentials needed (server-managed)
|
||||
else if (
|
||||
data.type === 'chatgpt-pro' ||
|
||||
data.type === 'github-copilot' ||
|
||||
data.type === 'qwen-code'
|
||||
) {
|
||||
// No validation needed — OAuth tokens are on the server
|
||||
}
|
||||
// Other providers: require baseUrl
|
||||
@@ -190,6 +196,11 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
const kimiLaunch = useKimiLaunch()
|
||||
|
||||
const filteredProviderTypeOptions = providerTypeOptions.filter((opt) => {
|
||||
if (opt.value === 'chatgpt-pro')
|
||||
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') {
|
||||
@@ -377,8 +388,13 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
const canTest = (): boolean => {
|
||||
if (!watchedModelId) return false
|
||||
|
||||
// ChatGPT Pro: always testable (server has the OAuth token)
|
||||
if (watchedType === 'chatgpt-pro') return true
|
||||
// OAuth providers: always testable (server has the OAuth token)
|
||||
if (
|
||||
watchedType === 'chatgpt-pro' ||
|
||||
watchedType === 'github-copilot' ||
|
||||
watchedType === 'qwen-code'
|
||||
)
|
||||
return true
|
||||
|
||||
if (watchedType === 'azure') {
|
||||
return !!(watchedResourceName || watchedBaseUrl) && !!watchedApiKey
|
||||
@@ -461,6 +477,15 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
}
|
||||
|
||||
const renderProviderSpecificFields = () => {
|
||||
// OAuth-only providers (no API key needed)
|
||||
if (watchedType === 'github-copilot' || watchedType === 'qwen-code') {
|
||||
const name = watchedType === 'github-copilot' ? 'GitHub' : 'Qwen Code'
|
||||
return (
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 p-3 text-green-700 text-sm dark:border-green-800 dark:bg-green-950 dark:text-green-300">
|
||||
Credentials are managed via {name} OAuth. No API key needed.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// ChatGPT Pro: OAuth credentials + Codex reasoning settings
|
||||
if (watchedType === 'chatgpt-pro') {
|
||||
return (
|
||||
|
||||
@@ -26,6 +26,11 @@ export const ProviderTemplatesSection: FC<ProviderTemplatesSectionProps> = ({
|
||||
const kimiLaunch = useKimiLaunch()
|
||||
|
||||
const filteredTemplates = providerTemplates.filter((template) => {
|
||||
if (template.id === 'chatgpt-pro')
|
||||
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)
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface ModelsData {
|
||||
browseros: ModelInfo[]
|
||||
moonshot: ModelInfo[]
|
||||
'chatgpt-pro': ModelInfo[]
|
||||
'github-copilot': ModelInfo[]
|
||||
'qwen-code': ModelInfo[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,6 +103,38 @@ export const MODELS_DATA: ModelsData = {
|
||||
{ modelId: 'gpt-5.1-codex-mini', contextLength: 400000 },
|
||||
{ modelId: 'gpt-5.1', contextLength: 200000 },
|
||||
],
|
||||
'github-copilot': [
|
||||
// Free tier (unlimited with Pro)
|
||||
{ modelId: 'gpt-5-mini', contextLength: 128000 },
|
||||
{ modelId: 'claude-haiku-4.5', contextLength: 128000 },
|
||||
{ modelId: 'gpt-4o', contextLength: 64000 },
|
||||
{ modelId: 'gpt-4.1', contextLength: 64000 },
|
||||
// Premium models (Pro: 300/mo, Pro+: 1500/mo)
|
||||
{ modelId: 'claude-sonnet-4.6', contextLength: 128000 },
|
||||
{ modelId: 'claude-sonnet-4.5', contextLength: 128000 },
|
||||
{ modelId: 'claude-sonnet-4', contextLength: 128000 },
|
||||
{ modelId: 'claude-opus-4.6', contextLength: 128000 },
|
||||
{ modelId: 'claude-opus-4.5', contextLength: 128000 },
|
||||
{ modelId: 'gemini-2.5-pro', contextLength: 128000 },
|
||||
{ modelId: 'gemini-3-pro-preview', contextLength: 128000 },
|
||||
{ modelId: 'gemini-3-flash-preview', contextLength: 128000 },
|
||||
{ modelId: 'gemini-3.1-pro-preview', contextLength: 128000 },
|
||||
{ modelId: 'gpt-5.4', contextLength: 272000 },
|
||||
{ modelId: 'gpt-5.4-mini', contextLength: 128000 },
|
||||
{ modelId: 'gpt-5.3-codex', contextLength: 272000 },
|
||||
{ modelId: 'gpt-5.2-codex', contextLength: 272000 },
|
||||
{ modelId: 'gpt-5.2', contextLength: 128000 },
|
||||
{ modelId: 'gpt-5.1-codex', contextLength: 128000 },
|
||||
{ modelId: 'gpt-5.1-codex-max', contextLength: 128000 },
|
||||
{ modelId: 'gpt-5.1', contextLength: 128000 },
|
||||
{ modelId: 'grok-code-fast-1', contextLength: 128000 },
|
||||
],
|
||||
'qwen-code': [
|
||||
{ modelId: 'coder-model', contextLength: 1000000 },
|
||||
{ modelId: 'qwen3-coder-plus', contextLength: 1000000 },
|
||||
{ modelId: 'qwen3-coder-flash', contextLength: 1000000 },
|
||||
{ modelId: 'qwen3.5-plus', contextLength: 1000000 },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,6 +24,7 @@ export const useGetUserMCPIntegrations = () => {
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [INTEGRATIONS_QUERY_KEY, agentServerUrl],
|
||||
// biome-ignore lint/style/noNonNullAssertion: guarded by enabled
|
||||
queryFn: () => getUserMCPIntegrations(agentServerUrl!),
|
||||
enabled: !!agentServerUrl,
|
||||
refetchOnWindowFocus: true,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AlertCircle, Eye, Pencil, Plus, Trash2, Wand2 } from 'lucide-react'
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import Markdown from 'react-markdown'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -26,7 +27,6 @@ import { Label } from '@/components/ui/label'
|
||||
import { MarkdownEditor } from '@/components/ui/MarkdownEditor'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import Markdown from 'react-markdown'
|
||||
import { type SkillDetail, type SkillMeta, useSkills } from './useSkills'
|
||||
|
||||
const loadingSkillCards = [
|
||||
@@ -330,9 +330,15 @@ const SkillCard: FC<{
|
||||
className="-ml-2 h-7 px-2 text-muted-foreground hover:bg-transparent hover:text-foreground"
|
||||
>
|
||||
{skill.builtIn ? (
|
||||
<><Eye className="size-3.5" />View</>
|
||||
<>
|
||||
<Eye className="size-3.5" />
|
||||
View
|
||||
</>
|
||||
) : (
|
||||
<><Pencil className="size-3.5" />Edit</>
|
||||
<>
|
||||
<Pencil className="size-3.5" />
|
||||
Edit
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{!skill.builtIn ? (
|
||||
@@ -408,7 +414,11 @@ const SkillDialog: FC<{
|
||||
<DialogContent className="flex max-h-[90vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-5xl">
|
||||
<DialogHeader className="border-b px-6 py-5">
|
||||
<DialogTitle>
|
||||
{readOnly ? 'View Skill' : editingSkill ? 'Edit Skill' : 'Create Skill'}
|
||||
{readOnly
|
||||
? 'View Skill'
|
||||
: editingSkill
|
||||
? 'Edit Skill'
|
||||
: 'Create Skill'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{readOnly
|
||||
@@ -472,7 +482,7 @@ const SkillDialog: FC<{
|
||||
</div>
|
||||
|
||||
{readOnly ? (
|
||||
<div className="prose prose-sm mt-4 min-h-[320px] max-w-none flex-1 overflow-y-auto rounded-md border p-4 text-sm dark:prose-invert">
|
||||
<div className="prose prose-sm dark:prose-invert mt-4 min-h-[320px] max-w-none flex-1 overflow-y-auto rounded-md border p-4 text-sm">
|
||||
<Markdown>{content}</Markdown>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -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,13 +5,17 @@ import {
|
||||
Folder,
|
||||
Globe,
|
||||
Layers,
|
||||
Loader2,
|
||||
Mic,
|
||||
PlugZap,
|
||||
Search,
|
||||
Square,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector'
|
||||
import { AppSelector } from '@/components/elements/AppSelector'
|
||||
import {
|
||||
GlowingBorder,
|
||||
@@ -27,6 +31,7 @@ import {
|
||||
} from '@/components/ui/tooltip'
|
||||
import { McpServerIcon } from '@/entrypoints/app/connect-mcp/McpServerIcon'
|
||||
import { useGetUserMCPIntegrations } from '@/entrypoints/app/connect-mcp/useGetUserMCPIntegrations'
|
||||
import { useChatSessionContext } from '@/entrypoints/sidepanel/layout/ChatSessionContext'
|
||||
import { Feature } from '@/lib/browseros/capabilities'
|
||||
import { useCapabilities } from '@/lib/browseros/useCapabilities'
|
||||
import {
|
||||
@@ -42,13 +47,20 @@ 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'
|
||||
import type { ProviderType } from '@/lib/llm-providers/types'
|
||||
import { useMcpServers } from '@/lib/mcp/mcpServerStorage'
|
||||
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'
|
||||
@@ -90,10 +102,42 @@ export const NewTab = () => {
|
||||
})
|
||||
const { selectedFolder } = useWorkspace()
|
||||
const { supports } = useCapabilities()
|
||||
const { providers, selectedProvider, handleSelectProvider } =
|
||||
useChatSessionContext()
|
||||
const { servers: mcpServers } = useMcpServers()
|
||||
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(
|
||||
@@ -424,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
|
||||
@@ -523,6 +624,34 @@ export const NewTab = () => {
|
||||
{mounted && (
|
||||
<div className="flex items-center justify-between border-border/50 border-t px-5 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
{selectedProvider && (
|
||||
<ChatProviderSelector
|
||||
providers={providers}
|
||||
selectedProvider={selectedProvider}
|
||||
onSelectProvider={handleSelectProvider}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={selectedProvider.name}
|
||||
className={cn(
|
||||
'h-8 w-8 rounded-lg transition-all',
|
||||
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'data-[state=open]:bg-accent',
|
||||
)}
|
||||
>
|
||||
{selectedProvider.type === 'browseros' ? (
|
||||
<BrowserOSIcon size={16} />
|
||||
) : (
|
||||
<ProviderIcon
|
||||
type={selectedProvider.type as ProviderType}
|
||||
size={16}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</ChatProviderSelector>
|
||||
)}
|
||||
|
||||
{supports(Feature.WORKSPACE_FOLDER_SUPPORT) && (
|
||||
<WorkspaceSelector>
|
||||
<Button
|
||||
|
||||
@@ -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<{
|
||||
@@ -502,6 +510,7 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
if (pending.action) {
|
||||
setTextToAction((prev) => {
|
||||
const next = new Map(prev)
|
||||
// biome-ignore lint/style/noNonNullAssertion: guarded by if (pending.action) above
|
||||
next.set(pending.text, pending.action!)
|
||||
return next
|
||||
})
|
||||
|
||||
@@ -19,6 +19,10 @@ function extractTabId(toolPart: ToolUIPart | null): number | undefined {
|
||||
return input?.tabId
|
||||
}
|
||||
|
||||
function sendGlow(tabId: number, message: GlowMessage): void {
|
||||
chrome.tabs.sendMessage(tabId, message).catch(() => {})
|
||||
}
|
||||
|
||||
export const useNotifyActiveTab = ({
|
||||
messages,
|
||||
status,
|
||||
@@ -28,7 +32,10 @@ export const useNotifyActiveTab = ({
|
||||
status: ChatStatus
|
||||
conversationId: string
|
||||
}) => {
|
||||
const lastTabIdRef = useRef<number | null>(null)
|
||||
// Track the single tab currently glowing
|
||||
const activeTabIdRef = useRef<number | null>(null)
|
||||
// Track all tabs that have been glowed during this stream (for cleanup)
|
||||
const allGlowedTabsRef = useRef<Set<number>>(new Set())
|
||||
|
||||
const lastMessage = messages?.[messages.length - 1]
|
||||
|
||||
@@ -41,27 +48,35 @@ export const useNotifyActiveTab = ({
|
||||
|
||||
useEffect(() => {
|
||||
const isStreaming = status === 'streaming'
|
||||
const previousTabId = lastTabIdRef.current
|
||||
|
||||
if (!isStreaming) {
|
||||
if (previousTabId) {
|
||||
// Deactivate ALL tabs that were glowed during this stream
|
||||
const allGlowed = allGlowedTabsRef.current
|
||||
if (allGlowed.size > 0) {
|
||||
const deactivate = async () => {
|
||||
// Capture tab IDs before any async work to avoid race with clear()
|
||||
const tabIds = Array.from(allGlowed)
|
||||
allGlowed.clear()
|
||||
|
||||
const alreadyShown = await firstRunConfettiShownStorage.getValue()
|
||||
const deactivateMessage: GlowMessage = {
|
||||
conversationId,
|
||||
isActive: false,
|
||||
showConfetti: !alreadyShown,
|
||||
let showConfetti = !alreadyShown
|
||||
|
||||
for (const tabId of tabIds) {
|
||||
sendGlow(tabId, {
|
||||
conversationId,
|
||||
isActive: false,
|
||||
showConfetti,
|
||||
})
|
||||
showConfetti = false
|
||||
}
|
||||
chrome.tabs
|
||||
.sendMessage(previousTabId, deactivateMessage)
|
||||
.catch(() => {})
|
||||
|
||||
if (!alreadyShown) {
|
||||
await firstRunConfettiShownStorage.setValue(true)
|
||||
}
|
||||
}
|
||||
deactivate()
|
||||
lastTabIdRef.current = null
|
||||
}
|
||||
activeTabIdRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
@@ -70,34 +85,41 @@ export const useNotifyActiveTab = ({
|
||||
let cancelled = false
|
||||
|
||||
const activate = async () => {
|
||||
let targetTabId = toolTabId ?? previousTabId ?? undefined
|
||||
let targetTabId = toolTabId ?? undefined
|
||||
|
||||
if (!targetTabId) {
|
||||
const tabs = await chrome.tabs.query({
|
||||
active: true,
|
||||
currentWindow: true,
|
||||
})
|
||||
targetTabId = tabs[0]?.id
|
||||
// Fallback: use the currently active tab, or query browser
|
||||
if (activeTabIdRef.current) {
|
||||
targetTabId = activeTabIdRef.current
|
||||
} else {
|
||||
const tabs = await chrome.tabs.query({
|
||||
active: true,
|
||||
currentWindow: true,
|
||||
})
|
||||
targetTabId = tabs[0]?.id
|
||||
}
|
||||
}
|
||||
|
||||
if (cancelled || !targetTabId) return
|
||||
|
||||
const previousTabId = activeTabIdRef.current
|
||||
|
||||
// If the agent moved to a different tab, deactivate the previous one
|
||||
if (previousTabId && previousTabId !== targetTabId) {
|
||||
const deactivateMessage: GlowMessage = {
|
||||
sendGlow(previousTabId, {
|
||||
conversationId,
|
||||
isActive: false,
|
||||
}
|
||||
chrome.tabs
|
||||
.sendMessage(previousTabId, deactivateMessage)
|
||||
.catch(() => {})
|
||||
})
|
||||
}
|
||||
|
||||
const activateMessage: GlowMessage = {
|
||||
// Activate glow on the target tab
|
||||
sendGlow(targetTabId, {
|
||||
conversationId,
|
||||
isActive: true,
|
||||
}
|
||||
chrome.tabs.sendMessage(targetTabId, activateMessage).catch(() => {})
|
||||
lastTabIdRef.current = targetTabId
|
||||
})
|
||||
|
||||
activeTabIdRef.current = targetTabId
|
||||
allGlowedTabsRef.current.add(targetTabId)
|
||||
}
|
||||
|
||||
activate()
|
||||
|
||||
@@ -45,6 +45,14 @@ export enum Feature {
|
||||
MEMORY_SUPPORT = 'MEMORY_SUPPORT',
|
||||
// Skills page: agent skills viewer and editor
|
||||
SKILLS_SUPPORT = 'SKILLS_SUPPORT',
|
||||
// ChatGPT Pro OAuth LLM provider
|
||||
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',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,6 +80,10 @@ const FEATURE_CONFIG: { [K in Feature]: FeatureConfig } = {
|
||||
[Feature.VERTICAL_TABS_SUPPORT]: { minBrowserOSVersion: '0.42.0.0' },
|
||||
[Feature.MEMORY_SUPPORT]: { minServerVersion: '0.0.73' },
|
||||
[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[] {
|
||||
|
||||
@@ -41,6 +41,29 @@ export const CHATGPT_PRO_OAUTH_COMPLETED_EVENT =
|
||||
export const CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT =
|
||||
'settings.chatgpt_pro.oauth_disconnected'
|
||||
|
||||
/** @public */
|
||||
export const GITHUB_COPILOT_OAUTH_STARTED_EVENT =
|
||||
'settings.github_copilot.oauth_started'
|
||||
|
||||
/** @public */
|
||||
export const GITHUB_COPILOT_OAUTH_COMPLETED_EVENT =
|
||||
'settings.github_copilot.oauth_completed'
|
||||
|
||||
/** @public */
|
||||
export const GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT =
|
||||
'settings.github_copilot.oauth_disconnected'
|
||||
|
||||
/** @public */
|
||||
export const QWEN_CODE_OAUTH_STARTED_EVENT = 'settings.qwen_code.oauth_started'
|
||||
|
||||
/** @public */
|
||||
export const QWEN_CODE_OAUTH_COMPLETED_EVENT =
|
||||
'settings.qwen_code.oauth_completed'
|
||||
|
||||
/** @public */
|
||||
export const QWEN_CODE_OAUTH_DISCONNECTED_EVENT =
|
||||
'settings.qwen_code.oauth_disconnected'
|
||||
|
||||
/** @public */
|
||||
export const HUB_PROVIDER_ADDED_EVENT = 'settings.hub_provider.added'
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Client-side OAuth Device Code flow.
|
||||
* Used for providers where server-side fetch is blocked by WAF (e.g. Qwen).
|
||||
* The extension makes requests using Chrome's network stack which bypasses
|
||||
* TLS fingerprint-based WAF detection.
|
||||
*/
|
||||
|
||||
export interface ClientAuthConfig {
|
||||
deviceCodeEndpoint: string
|
||||
tokenEndpoint: string
|
||||
clientId: string
|
||||
scopes: string
|
||||
requiresPKCE: boolean
|
||||
contentType: 'json' | 'form'
|
||||
}
|
||||
|
||||
interface DeviceCodeData {
|
||||
device_code: string
|
||||
user_code: string
|
||||
verification_uri: string
|
||||
verification_uri_complete?: string
|
||||
expires_in: number
|
||||
interval: number
|
||||
}
|
||||
|
||||
export interface TokenResult {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
expiresIn: number
|
||||
}
|
||||
|
||||
export async function requestDeviceCode(
|
||||
auth: ClientAuthConfig,
|
||||
): Promise<{ deviceData: DeviceCodeData; codeVerifier?: string }> {
|
||||
let codeVerifier: string | undefined
|
||||
const params: Record<string, string> = {
|
||||
client_id: auth.clientId,
|
||||
scope: auth.scopes,
|
||||
}
|
||||
|
||||
if (auth.requiresPKCE) {
|
||||
codeVerifier = generateCodeVerifier()
|
||||
params.code_challenge = await generateCodeChallenge(codeVerifier)
|
||||
params.code_challenge_method = 'S256'
|
||||
}
|
||||
|
||||
const res = await authFetch(auth.deviceCodeEndpoint, params, auth.contentType)
|
||||
|
||||
// WAF captcha detected — open the site for user to solve, then retry
|
||||
const ct = res.headers.get('content-type') ?? ''
|
||||
if (!ct.includes('application/json')) {
|
||||
const baseUrl = new URL(auth.deviceCodeEndpoint).origin
|
||||
window.open(baseUrl, '_blank')
|
||||
throw new Error(
|
||||
'Please complete the verification in the opened tab, then click USE again.',
|
||||
)
|
||||
}
|
||||
if (!res.ok) throw new Error(`Device code request failed: ${res.status}`)
|
||||
|
||||
const deviceData = (await res.json()) as DeviceCodeData
|
||||
if (!deviceData.device_code || !deviceData.user_code) {
|
||||
throw new Error('Invalid device code response')
|
||||
}
|
||||
|
||||
return { deviceData, codeVerifier }
|
||||
}
|
||||
|
||||
export function startTokenPolling(
|
||||
auth: ClientAuthConfig,
|
||||
deviceData: DeviceCodeData,
|
||||
codeVerifier: string | undefined,
|
||||
onToken: (token: TokenResult) => void,
|
||||
): void {
|
||||
let interval = deviceData.interval
|
||||
const deadline = Date.now() + deviceData.expires_in * 1000
|
||||
const safetyMargin = 3
|
||||
|
||||
const poll = async () => {
|
||||
if (Date.now() > deadline) return
|
||||
|
||||
const params: Record<string, string> = {
|
||||
client_id: auth.clientId,
|
||||
device_code: deviceData.device_code,
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
||||
}
|
||||
if (codeVerifier) params.code_verifier = codeVerifier
|
||||
|
||||
try {
|
||||
const res = await authFetch(auth.tokenEndpoint, params, auth.contentType)
|
||||
|
||||
// WAF returned HTML — retry later
|
||||
const ct = res.headers.get('content-type') ?? ''
|
||||
if (!ct.includes('application/json')) {
|
||||
setTimeout(poll, (interval + safetyMargin) * 1000)
|
||||
return
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
access_token?: string
|
||||
refresh_token?: string
|
||||
expires_in?: number
|
||||
error?: string
|
||||
interval?: number
|
||||
}
|
||||
|
||||
if (data.access_token) {
|
||||
onToken({
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token ?? '',
|
||||
expiresIn: data.expires_in ?? 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (data.error === 'authorization_pending') {
|
||||
setTimeout(poll, (interval + safetyMargin) * 1000)
|
||||
return
|
||||
}
|
||||
if (data.error === 'slow_down') {
|
||||
interval = (data.interval ?? interval) + 5
|
||||
setTimeout(poll, (interval + safetyMargin) * 1000)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
setTimeout(poll, (interval + safetyMargin) * 1000)
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(poll, (interval + safetyMargin) * 1000)
|
||||
}
|
||||
|
||||
function authFetch(
|
||||
endpoint: string,
|
||||
params: Record<string, string>,
|
||||
contentType: 'json' | 'form',
|
||||
): Promise<Response> {
|
||||
return fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type':
|
||||
contentType === 'form'
|
||||
? 'application/x-www-form-urlencoded'
|
||||
: 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body:
|
||||
contentType === 'form'
|
||||
? new URLSearchParams(params).toString()
|
||||
: JSON.stringify(params),
|
||||
})
|
||||
}
|
||||
|
||||
function generateCodeVerifier(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(32))
|
||||
return base64UrlEncode(bytes)
|
||||
}
|
||||
|
||||
async function generateCodeChallenge(verifier: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest(
|
||||
'SHA-256',
|
||||
new TextEncoder().encode(verifier),
|
||||
)
|
||||
return base64UrlEncode(new Uint8Array(digest))
|
||||
}
|
||||
|
||||
function base64UrlEncode(bytes: Uint8Array): string {
|
||||
const base64 = btoa(String.fromCharCode(...bytes))
|
||||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
@@ -8,8 +8,9 @@ import {
|
||||
Ollama,
|
||||
OpenAI,
|
||||
OpenRouter,
|
||||
Qwen,
|
||||
} from '@lobehub/icons'
|
||||
import { Bot } from 'lucide-react'
|
||||
import { Bot, Github } from 'lucide-react'
|
||||
import type { FC, SVGProps } from 'react'
|
||||
import ProductLogoSvg from '@/assets/product_logo.svg'
|
||||
import type { ProviderType } from './types'
|
||||
@@ -33,6 +34,8 @@ const providerIconMap: Record<ProviderType, IconComponent | null> = {
|
||||
browseros: null,
|
||||
moonshot: Kimi,
|
||||
'chatgpt-pro': OpenAI,
|
||||
'github-copilot': Github,
|
||||
'qwen-code': Qwen,
|
||||
}
|
||||
|
||||
interface ProviderIconProps {
|
||||
|
||||
@@ -29,6 +29,24 @@ export const providerTemplates: ProviderTemplate[] = [
|
||||
contextWindow: 400000,
|
||||
setupGuideUrl: 'https://docs.browseros.com/features/chatgpt-pro-oauth',
|
||||
},
|
||||
{
|
||||
id: 'github-copilot',
|
||||
name: 'GitHub Copilot',
|
||||
defaultBaseUrl: 'https://api.githubcopilot.com',
|
||||
defaultModelId: 'gpt-5-mini',
|
||||
supportsImages: true,
|
||||
contextWindow: 128000,
|
||||
setupGuideUrl: 'https://docs.browseros.com/features/github-copilot-oauth',
|
||||
},
|
||||
{
|
||||
id: 'qwen-code',
|
||||
name: 'Qwen Code',
|
||||
defaultBaseUrl: 'https://portal.qwen.ai/v1',
|
||||
defaultModelId: 'coder-model',
|
||||
supportsImages: true,
|
||||
contextWindow: 1000000,
|
||||
setupGuideUrl: 'https://docs.browseros.com/features/qwen-code-oauth',
|
||||
},
|
||||
{
|
||||
id: 'moonshot',
|
||||
name: 'Moonshot AI',
|
||||
@@ -139,6 +157,8 @@ export const providerTemplates: ProviderTemplate[] = [
|
||||
*/
|
||||
export const providerTypeOptions: { value: ProviderType; label: string }[] = [
|
||||
{ value: 'chatgpt-pro', label: 'ChatGPT Plus/Pro' },
|
||||
{ value: 'github-copilot', label: 'GitHub Copilot' },
|
||||
{ value: 'qwen-code', label: 'Qwen Code' },
|
||||
{ value: 'moonshot', label: 'Moonshot AI' },
|
||||
{ value: 'anthropic', label: 'Anthropic' },
|
||||
{ value: 'openai', label: 'OpenAI' },
|
||||
@@ -168,6 +188,8 @@ export const getProviderTemplate = (
|
||||
*/
|
||||
export const DEFAULT_BASE_URLS: Record<ProviderType, string> = {
|
||||
'chatgpt-pro': 'https://chatgpt.com/backend-api',
|
||||
'github-copilot': 'https://api.githubcopilot.com',
|
||||
'qwen-code': 'https://portal.qwen.ai/v1',
|
||||
moonshot: 'https://api.moonshot.ai/v1',
|
||||
anthropic: 'https://api.anthropic.com/v1',
|
||||
openai: 'https://api.openai.com/v1',
|
||||
|
||||
@@ -15,6 +15,8 @@ export type ProviderType =
|
||||
| 'browseros'
|
||||
| 'moonshot'
|
||||
| 'chatgpt-pro'
|
||||
| 'github-copilot'
|
||||
| 'qwen-code'
|
||||
|
||||
/**
|
||||
* LLM Provider configuration
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import {
|
||||
type ClientAuthConfig,
|
||||
requestDeviceCode,
|
||||
startTokenPolling,
|
||||
} from './client-oauth'
|
||||
import { getProviderTemplate } from './providerTemplates'
|
||||
import type { LlmProviderConfig, ProviderType } from './types'
|
||||
import { useOAuthStatus } from './useOAuthStatus'
|
||||
|
||||
export interface OAuthProviderFlowConfig {
|
||||
providerType: ProviderType
|
||||
displayName: string
|
||||
startedEvent: string
|
||||
completedEvent: string
|
||||
disconnectedEvent: string
|
||||
/** Client-side auth for providers with WAF-protected endpoints */
|
||||
clientAuth?: ClientAuthConfig
|
||||
}
|
||||
|
||||
interface OAuthProviderFlowReturn {
|
||||
status: { authenticated: boolean; email?: string } | null
|
||||
disconnect: () => Promise<void>
|
||||
startOAuthFlow: (agentServerUrl: string | undefined) => Promise<void>
|
||||
}
|
||||
|
||||
export function useOAuthProviderFlow(
|
||||
config: OAuthProviderFlowConfig,
|
||||
providers: LlmProviderConfig[],
|
||||
saveProvider: (provider: LlmProviderConfig) => Promise<void> | void,
|
||||
): OAuthProviderFlowReturn {
|
||||
const { status, startPolling, disconnect } = useOAuthStatus(
|
||||
config.providerType,
|
||||
)
|
||||
const flowStartedRef = useRef(false)
|
||||
|
||||
// Auto-create provider when OAuth completes
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — only trigger on auth status change
|
||||
useEffect(() => {
|
||||
if (!status?.authenticated) return
|
||||
if (!flowStartedRef.current) return
|
||||
if (providers.some((p) => p.type === config.providerType)) return
|
||||
|
||||
const now = Date.now()
|
||||
try {
|
||||
const template = getProviderTemplate(config.providerType)
|
||||
saveProvider({
|
||||
id: `${config.providerType}-${now}`,
|
||||
type: config.providerType,
|
||||
name: `${config.displayName}${status.email ? ` (${status.email})` : ''}`,
|
||||
modelId: template?.defaultModelId ?? '',
|
||||
supportsImages: template?.supportsImages ?? true,
|
||||
contextWindow: template?.contextWindow ?? 128000,
|
||||
temperature: 0.2,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
track(config.completedEvent, { email: status.email })
|
||||
toast.success(`${config.displayName} Connected`, {
|
||||
description: status.email
|
||||
? `Authenticated as ${status.email}`
|
||||
: `Successfully authenticated with ${config.displayName}`,
|
||||
})
|
||||
} catch (err) {
|
||||
toast.error(`Failed to create ${config.displayName} provider`, {
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
})
|
||||
} finally {
|
||||
flowStartedRef.current = false
|
||||
}
|
||||
}, [status?.authenticated])
|
||||
|
||||
async function startOAuthFlow(agentServerUrl: string | undefined) {
|
||||
if (!agentServerUrl) {
|
||||
toast.error('Server not available', {
|
||||
description: 'Cannot start OAuth flow without server connection.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
flowStartedRef.current = true
|
||||
|
||||
try {
|
||||
if (config.clientAuth) {
|
||||
await handleClientAuth(config.clientAuth, agentServerUrl)
|
||||
} else {
|
||||
await handleServerAuth(agentServerUrl)
|
||||
}
|
||||
} catch (err) {
|
||||
flowStartedRef.current = false
|
||||
toast.error(`Failed to start ${config.displayName} authentication`, {
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Client-side: extension handles device code + polling, sends token to server
|
||||
async function handleClientAuth(auth: ClientAuthConfig, serverUrl: string) {
|
||||
const { deviceData, codeVerifier } = await requestDeviceCode(auth)
|
||||
|
||||
const verificationUri =
|
||||
deviceData.verification_uri_complete ?? deviceData.verification_uri
|
||||
window.open(verificationUri, '_blank')
|
||||
track(config.startedEvent)
|
||||
toast.info(`Enter code: ${deviceData.user_code}`, {
|
||||
description: `Paste this code on the ${config.displayName} page that just opened.`,
|
||||
duration: 60_000,
|
||||
})
|
||||
|
||||
startTokenPolling(auth, deviceData, codeVerifier, async (token) => {
|
||||
await fetch(`${serverUrl}/oauth/${config.providerType}/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(token),
|
||||
})
|
||||
startPolling()
|
||||
})
|
||||
}
|
||||
|
||||
// Server-side: server handles device code + polling
|
||||
async function handleServerAuth(agentServerUrl: string) {
|
||||
const res = await fetch(
|
||||
`${agentServerUrl}/oauth/${config.providerType}/start`,
|
||||
)
|
||||
|
||||
if (res.headers.get('content-type')?.includes('application/json')) {
|
||||
const data = (await res.json()) as {
|
||||
userCode?: string
|
||||
verificationUri?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
if (!res.ok || data.error) {
|
||||
throw new Error(data.error || `Server returned ${res.status}`)
|
||||
}
|
||||
if (!data.userCode || !data.verificationUri) {
|
||||
throw new Error('Invalid response from server')
|
||||
}
|
||||
|
||||
window.open(data.verificationUri, '_blank')
|
||||
startPolling()
|
||||
track(config.startedEvent)
|
||||
toast.info(`Enter code: ${data.userCode}`, {
|
||||
description: `Paste this code on the ${config.displayName} page that just opened.`,
|
||||
duration: 60_000,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// PKCE redirect flow
|
||||
if (!res.ok) throw new Error(`Server returned ${res.status}`)
|
||||
window.open(res.url, '_blank')
|
||||
startPolling()
|
||||
track(config.startedEvent)
|
||||
toast.info(`Authenticating with ${config.displayName}`, {
|
||||
description: 'Complete the login in the opened tab.',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
disconnect,
|
||||
startOAuthFlow,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -55,6 +55,7 @@ export default defineConfig({
|
||||
permissions: [
|
||||
'topSites',
|
||||
'tabs',
|
||||
'tabGroups',
|
||||
'storage',
|
||||
'sidePanel',
|
||||
'browserOS',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -173,7 +173,9 @@ async function annotateScreenshot(
|
||||
|
||||
const image = sharp(inputPath)
|
||||
const metadata = await image.metadata()
|
||||
// biome-ignore lint/style/noNonNullAssertion: sharp metadata always has dimensions for valid images
|
||||
const imgWidth = metadata.width!
|
||||
// biome-ignore lint/style/noNonNullAssertion: sharp metadata always has dimensions for valid images
|
||||
const imgHeight = metadata.height!
|
||||
|
||||
const sx = Math.round(action.cssX * dpr)
|
||||
|
||||
@@ -49,10 +49,13 @@ async function callMcpTool(
|
||||
const result = await Promise.race([toolPromise, timeoutPromise])
|
||||
const duration = Date.now() - start
|
||||
|
||||
if ((result as any).isError) {
|
||||
const res = result as Record<string, unknown>
|
||||
if (res.isError) {
|
||||
const content = res.content as
|
||||
| Array<{ type: string; text?: string }>
|
||||
| undefined
|
||||
const errorText =
|
||||
(result as any).content?.find((c: any) => c.type === 'text')?.text ||
|
||||
'Unknown error'
|
||||
content?.find((c) => c.type === 'text')?.text || 'Unknown error'
|
||||
return { success: false, error: errorText, duration }
|
||||
}
|
||||
|
||||
@@ -96,13 +99,19 @@ async function main() {
|
||||
})
|
||||
|
||||
// Try structured content first
|
||||
windowId = (result as any).structuredContent?.windowId
|
||||
tabId = (result as any).structuredContent?.tabId
|
||||
const createRes = result as Record<string, unknown>
|
||||
const structured = createRes.structuredContent as
|
||||
| Record<string, number>
|
||||
| undefined
|
||||
windowId = structured?.windowId ?? 0
|
||||
tabId = structured?.tabId ?? 0
|
||||
|
||||
// Fall back to parsing text
|
||||
if (!windowId || !tabId) {
|
||||
const text =
|
||||
(result as any).content?.find((c: any) => c.type === 'text')?.text || ''
|
||||
const content = createRes.content as
|
||||
| Array<{ type: string; text?: string }>
|
||||
| undefined
|
||||
const text = content?.find((c) => c.type === 'text')?.text || ''
|
||||
const windowMatch = text.match(/window\s+(\d+)/i)
|
||||
const tabMatch =
|
||||
text.match(/Tab ID:\s*(\d+)/i) || text.match(/tab\s+(\d+)/i)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@browseros/server",
|
||||
"version": "0.0.76",
|
||||
"version": "0.0.79",
|
||||
"description": "BrowserOS server",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
@@ -63,8 +63,8 @@
|
||||
"@ai-sdk/amazon-bedrock": "^4.0.62",
|
||||
"@ai-sdk/anthropic": "^3.0.46",
|
||||
"@ai-sdk/azure": "^3.0.31",
|
||||
"@ai-sdk/google": "^3.0.30",
|
||||
"@ai-sdk/devtools": "^0.0.15",
|
||||
"@ai-sdk/google": "^3.0.30",
|
||||
"@ai-sdk/mcp": "^1.0.21",
|
||||
"@ai-sdk/openai": "^3.0.30",
|
||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||
@@ -89,6 +89,7 @@
|
||||
"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",
|
||||
|
||||
@@ -203,7 +203,28 @@ function getExecution(
|
||||
- Don't ask permission for routine steps. Act, then report.
|
||||
- Do not refuse by default, attempt tasks even when outcomes are uncertain.
|
||||
- For ambiguous/unclear requests, ask one targeted clarifying question.
|
||||
- Stay on the current page. Only open new tabs when the user explicitly asks.
|
||||
- Stay on the current page for single-page tasks. Use \`navigate_page\` to move within one tab.
|
||||
|
||||
### Multi-tab workflow
|
||||
When a task requires working on multiple pages simultaneously:
|
||||
1. **Inform the user** that you're creating background tabs for the task.
|
||||
2. **Open new tabs in background** using \`new_page\` (opens in background by default) — never steal focus from the user's current tab.
|
||||
3. **IMMEDIATELY create a tab group** using \`group_tabs\` with a descriptive title — do this right after opening the tabs, before any other work. Include the user's current tab in the group. Every multi-tab task MUST have a tab group.
|
||||
4. **Work on background tabs** — all tools (click, fill, navigate, snapshot) work on background tabs via their page ID.
|
||||
5. **Narrate progress in chat** — keep the user informed: "Checking Vercel pricing... Now checking Netlify..."
|
||||
6. **Report results in chat** — summarize findings so the user doesn't need to switch tabs. Leave tabs open for the user to browse later.
|
||||
7. **Never force-switch the user's active tab.** If you need user interaction on a background tab (e.g., login, CAPTCHA), tell the user which tab needs attention and let them switch manually.
|
||||
8. **Never navigate the user's current tab** during a multi-tab task. The current tab is the user's anchor — use it only for reading (snapshots, content extraction). All navigation should happen on background tabs.
|
||||
|
||||
**Do NOT use \`create_hidden_window\` or \`new_hidden_page\` for user-requested tasks.** Hidden windows are invisible to the user and cannot be screenshotted. Use \`new_page\` (background mode) instead — tabs appear in the user's tab strip and can be inspected. Reserve hidden windows for automated/scheduled runs only.
|
||||
|
||||
For single-page lookups (e.g., "go to X and read Y"), use \`navigate_page\` on the current tab. Only create new tabs when the task requires multiple pages open simultaneously.
|
||||
|
||||
### Tab retry discipline
|
||||
When a background tab fails (404, wrong content, unexpected redirect):
|
||||
- **Navigate the existing tab** to the correct URL with \`navigate_page\` — do NOT open a new tab for retries.
|
||||
- If you must abandon a tab, close it with \`close_page\` before opening a replacement.
|
||||
- Never let orphan tabs accumulate — each task should end with only the tabs that contain useful content.
|
||||
|
||||
### Observe → Act → Verify
|
||||
- **Before acting**: Take a snapshot to get interactive element IDs.
|
||||
@@ -247,6 +268,14 @@ function getToolSelection(): string {
|
||||
- Prefer \`fill\` over \`press_key\` for text input. Use \`press_key\` for keyboard shortcuts (Enter, Escape, Tab, Ctrl+A, etc.).
|
||||
- Prefer clicking links over \`navigate_page\` when the link is visible. Use \`navigate_page\` for direct URL access, back/forward, or reload.
|
||||
|
||||
### Navigation: single-tab vs multi-tab
|
||||
| Task | Approach |
|
||||
|------|----------|
|
||||
| Look up one page | \`navigate_page\` on current tab |
|
||||
| Research across multiple sites | \`new_page\` (background) for each site + \`group_tabs\` |
|
||||
| Compare two pages side by side | \`new_page\` (background) × 2 + \`group_tabs\` |
|
||||
| User says "open a new tab" | \`new_page\` (background) — don't steal focus |
|
||||
|
||||
### Connected apps: Strata vs browser
|
||||
When an app is Connected, prefer Strata tools over browser automation. Strata is faster, more reliable, and works without navigating away from the user's current page.
|
||||
</tool_selection>`
|
||||
@@ -351,7 +380,12 @@ function getErrorRecovery(
|
||||
### Strata errors
|
||||
- Authentication error → call \`suggest_app_connection\` for re-auth (STOP and wait)
|
||||
- Action not found → try \`search_documentation\`, then fall back to browser automation
|
||||
- Partial failure → report what succeeded and what didn't`
|
||||
- Partial failure → report what succeeded and what didn't
|
||||
|
||||
### Retry budget
|
||||
- If a site isn't cooperating after 3-4 attempts (form not filling, redirects, geo-blocks), stop trying.
|
||||
- Report what you've found so far and explain what didn't work: "Kayak kept defaulting to your local city. Here are the Google Flights results instead."
|
||||
- Don't exhaust 10+ tool calls on a single failing site — the user's time matters more than completeness.`
|
||||
|
||||
if (hasWorkspace) {
|
||||
recovery += `
|
||||
@@ -528,6 +562,12 @@ Default: do not narrate routine, low-risk tool calls (just call the tool).
|
||||
Narrate only when it helps: multi-step plans, complex navigation, or when the user explicitly asked for explanation.
|
||||
Keep narration brief. "Searching for flights..." then tool call — not "I will now search for flights by calling the search tool."
|
||||
Execute independent tool calls in parallel when possible.
|
||||
|
||||
When working on background tabs, always narrate progress so the user knows what's happening:
|
||||
- "Opening a background tab to check Yahoo News headlines..."
|
||||
- "Found 5 headlines on Yahoo News. Now checking Reuters..."
|
||||
- "Done! Here's what I found across all sources:"
|
||||
This is essential because the user can't see the background tabs — chat is their only window into your work.
|
||||
</tool_call_style>
|
||||
|
||||
- Be concise: 1-2 lines for status updates and action confirmations.
|
||||
|
||||
@@ -4,10 +4,13 @@ import { createAzure } from '@ai-sdk/azure'
|
||||
import { createGoogleGenerativeAI } from '@ai-sdk/google'
|
||||
import { createOpenAI } from '@ai-sdk/openai'
|
||||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
|
||||
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'
|
||||
import { createOpenRouterCompatibleFetch } from '../lib/openrouter-fetch'
|
||||
import type { ResolvedAgentConfig } from './types'
|
||||
@@ -102,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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -149,6 +164,30 @@ function createMoonshotFactory(
|
||||
})
|
||||
}
|
||||
|
||||
function createQwenCodeFactory(
|
||||
config: ResolvedAgentConfig,
|
||||
): (modelId: string) => unknown {
|
||||
if (!config.apiKey) throw new Error('Qwen Code requires OAuth authentication')
|
||||
return createOpenAICompatible({
|
||||
name: 'qwen-code',
|
||||
baseURL: EXTERNAL_URLS.QWEN_CODE_API,
|
||||
apiKey: config.apiKey,
|
||||
})
|
||||
}
|
||||
|
||||
function createGitHubCopilotFactory(
|
||||
config: ResolvedAgentConfig,
|
||||
): (modelId: string) => unknown {
|
||||
if (!config.apiKey)
|
||||
throw new Error('GitHub Copilot requires OAuth authentication')
|
||||
return createOpenAICompatible({
|
||||
name: 'github-copilot',
|
||||
baseURL: EXTERNAL_URLS.GITHUB_COPILOT_API,
|
||||
apiKey: config.apiKey,
|
||||
fetch: createCopilotFetch() as typeof globalThis.fetch,
|
||||
})
|
||||
}
|
||||
|
||||
function createChatGPTProFactory(
|
||||
config: ResolvedAgentConfig,
|
||||
): (modelId: string) => unknown {
|
||||
@@ -173,6 +212,8 @@ const PROVIDER_FACTORIES: Record<string, ProviderFactory> = {
|
||||
[LLM_PROVIDERS.OPENAI_COMPATIBLE]: createOpenAICompatibleFactory,
|
||||
[LLM_PROVIDERS.MOONSHOT]: createMoonshotFactory,
|
||||
[LLM_PROVIDERS.CHATGPT_PRO]: createChatGPTProFactory,
|
||||
[LLM_PROVIDERS.GITHUB_COPILOT]: createGitHubCopilotFactory,
|
||||
[LLM_PROVIDERS.QWEN_CODE]: createQwenCodeFactory,
|
||||
}
|
||||
|
||||
export function createLanguageModel(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -29,6 +29,17 @@ export function createOAuthRoutes(deps: OAuthRouteDeps) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Device Code flow: return JSON with user code for the extension to display
|
||||
if (provider.authFlow === 'device-code') {
|
||||
const result = await tokenManager.startDeviceCodeFlow(providerId)
|
||||
return c.json({
|
||||
userCode: result.userCode,
|
||||
verificationUri: result.verificationUri,
|
||||
expiresIn: result.expiresIn,
|
||||
})
|
||||
}
|
||||
|
||||
// PKCE flow: redirect to auth server
|
||||
const authUrl = await tokenManager.generateAuthorizationUrl(
|
||||
providerId,
|
||||
redirectBackUrl,
|
||||
@@ -39,7 +50,38 @@ export function createOAuthRoutes(deps: OAuthRouteDeps) {
|
||||
provider: providerId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return c.text('Failed to start authentication. Please try again.', 500)
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to start authentication. Please try again.'
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.post('/:provider/token', async (c) => {
|
||||
const providerId = c.req.param('provider')
|
||||
const provider = getOAuthProvider(providerId)
|
||||
if (!provider) return c.text(`Unknown OAuth provider: ${providerId}`, 400)
|
||||
|
||||
try {
|
||||
const body = await c.req.json()
|
||||
if (!body.accessToken) return c.text('Missing accessToken', 400)
|
||||
|
||||
tokenManager.storeTokens(providerId, {
|
||||
accessToken: body.accessToken,
|
||||
refreshToken: body.refreshToken ?? '',
|
||||
expiresIn: body.expiresIn ?? 0,
|
||||
})
|
||||
logger.info('OAuth tokens stored from client', {
|
||||
provider: providerId,
|
||||
})
|
||||
return c.json({ ok: true })
|
||||
} catch (error) {
|
||||
logger.error('Failed to store OAuth token', {
|
||||
provider: providerId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return c.text('Failed to store token', 500)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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'
|
||||
@@ -132,6 +134,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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -17,9 +17,34 @@ export async function resolveLLMConfig(
|
||||
config: LLMConfig,
|
||||
browserosId?: string,
|
||||
): Promise<ResolvedLLMConfig> {
|
||||
// ChatGPT Pro: resolve OAuth token from server-side storage
|
||||
// OAuth providers: resolve token from server-side storage
|
||||
if (config.provider === LLM_PROVIDERS.CHATGPT_PRO) {
|
||||
return resolveChatGPTProConfig(config, browserosId)
|
||||
return resolveOAuthConfig(config, browserosId, {
|
||||
providerId: 'chatgpt-pro',
|
||||
displayName: 'ChatGPT Plus/Pro',
|
||||
defaultModel: 'gpt-5.3-codex',
|
||||
useRefresh: true,
|
||||
extraFields: (tokens) => ({
|
||||
upstreamProvider: 'openai',
|
||||
accountId: tokens.accountId,
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (config.provider === LLM_PROVIDERS.GITHUB_COPILOT) {
|
||||
return resolveOAuthConfig(config, browserosId, {
|
||||
providerId: 'github-copilot',
|
||||
displayName: 'GitHub Copilot',
|
||||
defaultModel: 'gpt-5-mini',
|
||||
useRefresh: false,
|
||||
})
|
||||
}
|
||||
if (config.provider === LLM_PROVIDERS.QWEN_CODE) {
|
||||
return resolveOAuthConfig(config, browserosId, {
|
||||
providerId: 'qwen-code',
|
||||
displayName: 'Qwen Code',
|
||||
defaultModel: 'coder-model',
|
||||
useRefresh: true,
|
||||
})
|
||||
}
|
||||
|
||||
// BrowserOS gateway: fetch config from remote service
|
||||
@@ -34,30 +59,41 @@ export async function resolveLLMConfig(
|
||||
return config as ResolvedLLMConfig
|
||||
}
|
||||
|
||||
async function resolveChatGPTProConfig(
|
||||
interface OAuthResolveOptions {
|
||||
providerId: string
|
||||
displayName: string
|
||||
defaultModel: string
|
||||
useRefresh: boolean
|
||||
extraFields?: (tokens: { accountId?: string }) => Record<string, unknown>
|
||||
}
|
||||
|
||||
async function resolveOAuthConfig(
|
||||
config: LLMConfig,
|
||||
browserosId?: string,
|
||||
browserosId: string | undefined,
|
||||
opts: OAuthResolveOptions,
|
||||
): Promise<ResolvedLLMConfig> {
|
||||
const tokenManager = getOAuthTokenManager()
|
||||
if (!tokenManager || !browserosId) {
|
||||
throw new Error(
|
||||
'Not authenticated with ChatGPT Plus/Pro. Please login first.',
|
||||
`Not authenticated with ${opts.displayName}. Please login first.`,
|
||||
)
|
||||
}
|
||||
|
||||
const tokens = await tokenManager.refreshIfExpired('chatgpt-pro')
|
||||
const tokens = opts.useRefresh
|
||||
? await tokenManager.refreshIfExpired(opts.providerId)
|
||||
: tokenManager.getTokens(opts.providerId)
|
||||
|
||||
if (!tokens) {
|
||||
throw new Error(
|
||||
'Not authenticated with ChatGPT Plus/Pro. Please login first.',
|
||||
`Not authenticated with ${opts.displayName}. Please login first.`,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
model: config.model || 'gpt-5.3-codex',
|
||||
model: config.model || opts.defaultModel,
|
||||
apiKey: tokens.accessToken,
|
||||
upstreamProvider: 'openai',
|
||||
accountId: tokens.accountId,
|
||||
...opts.extraFields?.(tokens),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,5 +119,6 @@ async function resolveBrowserOSConfig(
|
||||
apiKey: llmConfig.apiKey,
|
||||
baseUrl: llmConfig.baseUrl,
|
||||
upstreamProvider: llmConfig.providerType,
|
||||
browserosId,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,12 +12,15 @@ import { createAzure } from '@ai-sdk/azure'
|
||||
import { createGoogleGenerativeAI } from '@ai-sdk/google'
|
||||
import { createOpenAI } from '@ai-sdk/openai'
|
||||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
|
||||
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'
|
||||
import { createCopilotFetch } from '../oauth/copilot-fetch'
|
||||
import type { ResolvedLLMConfig } from './types'
|
||||
|
||||
type ProviderFactory = (config: ResolvedLLMConfig) => LanguageModel
|
||||
@@ -90,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)
|
||||
}
|
||||
|
||||
@@ -135,6 +148,26 @@ function createMoonshotModel(config: ResolvedLLMConfig): LanguageModel {
|
||||
})(config.model)
|
||||
}
|
||||
|
||||
function createQwenCodeModel(config: ResolvedLLMConfig): LanguageModel {
|
||||
if (!config.apiKey) throw new Error('Qwen Code requires OAuth authentication')
|
||||
return createOpenAICompatible({
|
||||
name: 'qwen-code',
|
||||
baseURL: EXTERNAL_URLS.QWEN_CODE_API,
|
||||
apiKey: config.apiKey,
|
||||
})(config.model)
|
||||
}
|
||||
|
||||
function createGitHubCopilotModel(config: ResolvedLLMConfig): LanguageModel {
|
||||
if (!config.apiKey)
|
||||
throw new Error('GitHub Copilot requires OAuth authentication')
|
||||
return createOpenAICompatible({
|
||||
name: 'github-copilot',
|
||||
baseURL: EXTERNAL_URLS.GITHUB_COPILOT_API,
|
||||
apiKey: config.apiKey,
|
||||
fetch: createCopilotFetch() as typeof globalThis.fetch,
|
||||
})(config.model)
|
||||
}
|
||||
|
||||
function createChatGPTProModel(config: ResolvedLLMConfig): LanguageModel {
|
||||
if (!config.apiKey)
|
||||
throw new Error('ChatGPT Plus/Pro requires OAuth authentication')
|
||||
@@ -157,6 +190,8 @@ const PROVIDER_FACTORIES: Record<string, ProviderFactory> = {
|
||||
[LLM_PROVIDERS.OPENAI_COMPATIBLE]: createOpenAICompatibleModel,
|
||||
[LLM_PROVIDERS.MOONSHOT]: createMoonshotModel,
|
||||
[LLM_PROVIDERS.CHATGPT_PRO]: createChatGPTProModel,
|
||||
[LLM_PROVIDERS.GITHUB_COPILOT]: createGitHubCopilotModel,
|
||||
[LLM_PROVIDERS.QWEN_CODE]: createQwenCodeModel,
|
||||
}
|
||||
|
||||
export function createLLMProvider(config: ResolvedLLMConfig): LanguageModel {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Custom fetch wrapper for GitHub Copilot API requests.
|
||||
* Injects required Copilot headers and resizes images following
|
||||
* VS Code's algorithm (max 2048px longest side, 768px shortest side).
|
||||
*/
|
||||
|
||||
import { Jimp } from 'jimp'
|
||||
import { logger } from '../../logger'
|
||||
|
||||
const MAX_LONG_SIDE = 2048
|
||||
const MAX_SHORT_SIDE = 768
|
||||
|
||||
export function createCopilotFetch() {
|
||||
return async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const headers = new Headers(init?.headers as HeadersInit)
|
||||
|
||||
headers.set('Openai-Intent', 'conversation-edits')
|
||||
headers.set('x-initiator', 'user')
|
||||
|
||||
let body = init?.body
|
||||
if (body && typeof body === 'string') {
|
||||
try {
|
||||
const json = JSON.parse(body)
|
||||
if (hasImageContent(json)) {
|
||||
headers.set('Copilot-Vision-Request', 'true')
|
||||
await shrinkImages(json)
|
||||
body = JSON.stringify(json)
|
||||
}
|
||||
} catch {
|
||||
// Not JSON or resize failed, send as-is
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(input, { ...init, headers, body })
|
||||
}
|
||||
}
|
||||
|
||||
function hasImageContent(body: Record<string, unknown>): boolean {
|
||||
if (!Array.isArray(body.messages)) return false
|
||||
for (const msg of body.messages) {
|
||||
if (!Array.isArray(msg?.content)) continue
|
||||
for (const part of msg.content) {
|
||||
if (part?.type === 'image_url') return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Resize images following VS Code's algorithm for OpenAI vision token optimization
|
||||
async function shrinkImages(body: Record<string, unknown>): Promise<void> {
|
||||
if (!Array.isArray(body.messages)) return
|
||||
|
||||
for (const msg of body.messages) {
|
||||
if (!Array.isArray(msg?.content)) continue
|
||||
for (const part of msg.content) {
|
||||
if (part?.type !== 'image_url' || !part.image_url) continue
|
||||
|
||||
const url = part.image_url.url as string
|
||||
if (!url?.startsWith('data:')) continue
|
||||
|
||||
try {
|
||||
const resized = await resizeDataUrl(url)
|
||||
if (resized) part.image_url.url = resized
|
||||
} catch (err) {
|
||||
logger.warn('Failed to resize image for Copilot', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resizeDataUrl(dataUrl: string): Promise<string | null> {
|
||||
const commaIdx = dataUrl.indexOf(',')
|
||||
if (commaIdx === -1) return null
|
||||
|
||||
const base64Data = dataUrl.substring(commaIdx + 1)
|
||||
const buffer = Buffer.from(base64Data, 'base64')
|
||||
|
||||
const image = await Jimp.fromBuffer(buffer)
|
||||
const origWidth = image.width
|
||||
const origHeight = image.height
|
||||
if (!origWidth || !origHeight) return null
|
||||
|
||||
let width = origWidth
|
||||
let height = origHeight
|
||||
|
||||
// Skip if already within both limits (no resize step will fire)
|
||||
if (
|
||||
Math.max(width, height) <= MAX_LONG_SIDE &&
|
||||
Math.min(width, height) <= MAX_SHORT_SIDE
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Step 1: scale longest side to 2048
|
||||
if (width > MAX_LONG_SIDE || height > MAX_LONG_SIDE) {
|
||||
const scale = MAX_LONG_SIDE / Math.max(width, height)
|
||||
width = Math.round(width * scale)
|
||||
height = Math.round(height * scale)
|
||||
}
|
||||
|
||||
// Step 2: scale shortest side to 768
|
||||
const shortSide = Math.min(width, height)
|
||||
if (shortSide > MAX_SHORT_SIDE) {
|
||||
const scale = MAX_SHORT_SIDE / shortSide
|
||||
width = Math.round(width * scale)
|
||||
height = Math.round(height * scale)
|
||||
}
|
||||
|
||||
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: `${origWidth}x${origHeight} (${originalKB}KB)`,
|
||||
resized: `${width}x${height} (${resizedKB}KB)`,
|
||||
})
|
||||
|
||||
return `data:${mime};base64,${resizedB64}`
|
||||
}
|
||||
@@ -15,6 +15,11 @@ export interface OAuthProviderConfig {
|
||||
scopes: string[]
|
||||
extraAuthParams?: Record<string, string>
|
||||
upstreamLLMProvider: string
|
||||
authFlow?: 'pkce' | 'device-code'
|
||||
/** Device code flow uses form-urlencoded instead of JSON */
|
||||
deviceCodeContentType?: 'json' | 'form'
|
||||
/** Device code flow requires PKCE code_challenge/code_verifier */
|
||||
deviceCodeRequiresPKCE?: boolean
|
||||
}
|
||||
|
||||
export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
||||
@@ -32,6 +37,28 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
||||
},
|
||||
upstreamLLMProvider: 'openai',
|
||||
},
|
||||
'github-copilot': {
|
||||
id: 'github-copilot',
|
||||
name: 'GitHub Copilot',
|
||||
clientId: 'Ov23li8tweQw6odWQebz',
|
||||
authEndpoint: EXTERNAL_URLS.GITHUB_DEVICE_CODE,
|
||||
tokenEndpoint: EXTERNAL_URLS.GITHUB_OAUTH_TOKEN,
|
||||
scopes: ['read:user'],
|
||||
upstreamLLMProvider: 'github-copilot',
|
||||
authFlow: 'device-code',
|
||||
},
|
||||
'qwen-code': {
|
||||
id: 'qwen-code',
|
||||
name: 'Qwen Code',
|
||||
clientId: 'f0304373b74a44d2b584a3fb70ca9e56',
|
||||
authEndpoint: EXTERNAL_URLS.QWEN_DEVICE_CODE,
|
||||
tokenEndpoint: EXTERNAL_URLS.QWEN_OAUTH_TOKEN,
|
||||
scopes: ['openid', 'profile', 'email', 'model.completion'],
|
||||
upstreamLLMProvider: 'qwen-code',
|
||||
authFlow: 'device-code',
|
||||
deviceCodeContentType: 'form',
|
||||
deviceCodeRequiresPKCE: true,
|
||||
},
|
||||
}
|
||||
|
||||
export function getOAuthProvider(
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { OAUTH_CALLBACK_PORT } from '@browseros/shared/constants/ports'
|
||||
import { TIMEOUTS } from '@browseros/shared/constants/timeouts'
|
||||
import { logger } from '../../logger'
|
||||
import { getOAuthProvider } from './providers'
|
||||
import { getOAuthProvider, type OAuthProviderConfig } from './providers'
|
||||
import type { OAuthTokenStore, StoredOAuthTokens } from './token-store'
|
||||
|
||||
interface PendingOAuthFlow {
|
||||
@@ -25,6 +25,29 @@ interface OAuthTokenResponse {
|
||||
id_token?: string
|
||||
}
|
||||
|
||||
export interface DeviceCodeResult {
|
||||
userCode: string
|
||||
verificationUri: string
|
||||
expiresIn: number
|
||||
}
|
||||
|
||||
interface DeviceCodeResponse {
|
||||
device_code: string
|
||||
user_code: string
|
||||
verification_uri: string
|
||||
verification_uri_complete?: string
|
||||
expires_in: number
|
||||
interval: number
|
||||
}
|
||||
|
||||
interface DeviceCodeTokenPollResponse {
|
||||
access_token?: string
|
||||
refresh_token?: string
|
||||
expires_in?: number
|
||||
error?: string
|
||||
interval?: number
|
||||
}
|
||||
|
||||
export class OAuthTokenManager {
|
||||
private readonly pendingFlows = new Map<string, PendingOAuthFlow>()
|
||||
private readonly refreshLocks = new Map<
|
||||
@@ -37,6 +60,8 @@ export class OAuthTokenManager {
|
||||
private readonly browserosId: string,
|
||||
) {}
|
||||
|
||||
// --- PKCE flow (ChatGPT Plus/Pro) ---
|
||||
|
||||
async generateAuthorizationUrl(
|
||||
providerId: string,
|
||||
redirectBackUrl?: string,
|
||||
@@ -138,16 +163,192 @@ export class OAuthTokenManager {
|
||||
return { tokens, redirectBackUrl: flow.redirectBackUrl }
|
||||
}
|
||||
|
||||
// Mutex-protected refresh: concurrent callers share one in-flight refresh
|
||||
// --- Device Code flow (GitHub Copilot) ---
|
||||
|
||||
private readonly activeDeviceFlows = new Set<string>()
|
||||
|
||||
async startDeviceCodeFlow(providerId: string): Promise<DeviceCodeResult> {
|
||||
const provider = getOAuthProvider(providerId)
|
||||
if (!provider) throw new Error(`Unknown OAuth provider: ${providerId}`)
|
||||
|
||||
// Cancel any existing flow — user may be retrying
|
||||
this.activeDeviceFlows.delete(providerId)
|
||||
|
||||
// PKCE: generate verifier/challenge if provider requires it
|
||||
let codeVerifier: string | undefined
|
||||
const params: Record<string, string> = {
|
||||
client_id: provider.clientId,
|
||||
scope: provider.scopes.join(' '),
|
||||
}
|
||||
if (provider.deviceCodeRequiresPKCE) {
|
||||
codeVerifier = generateCodeVerifier()
|
||||
params.code_challenge = await generateCodeChallenge(codeVerifier)
|
||||
params.code_challenge_method = 'S256'
|
||||
}
|
||||
|
||||
// Build request body (form-urlencoded or JSON based on provider)
|
||||
const useForm = provider.deviceCodeContentType === 'form'
|
||||
const response = await fetch(provider.authEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': useForm
|
||||
? 'application/x-www-form-urlencoded'
|
||||
: 'application/json',
|
||||
},
|
||||
body: useForm
|
||||
? new URLSearchParams(params).toString()
|
||||
: JSON.stringify(params),
|
||||
})
|
||||
|
||||
// Detect WAF/captcha responses (HTML instead of JSON)
|
||||
const contentType = response.headers.get('content-type') ?? ''
|
||||
if (!contentType.includes('application/json')) {
|
||||
throw new Error(
|
||||
'Authentication service temporarily unavailable. Please try again in a few minutes.',
|
||||
)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to request device code: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as DeviceCodeResponse
|
||||
|
||||
// Some providers return 200 with an error payload
|
||||
const dataObj = data as unknown as Record<string, unknown>
|
||||
if ('error' in dataObj) {
|
||||
throw new Error(`Device code error: ${dataObj.error}`)
|
||||
}
|
||||
if (!data.device_code || !data.user_code) {
|
||||
throw new Error('Invalid device code response')
|
||||
}
|
||||
|
||||
// Start background polling with error handling
|
||||
this.activeDeviceFlows.add(providerId)
|
||||
this.pollDeviceCode(
|
||||
providerId,
|
||||
provider,
|
||||
data.device_code,
|
||||
data.interval,
|
||||
data.expires_in,
|
||||
codeVerifier,
|
||||
).finally(() => this.activeDeviceFlows.delete(providerId))
|
||||
|
||||
return {
|
||||
userCode: data.user_code,
|
||||
verificationUri: data.verification_uri_complete ?? data.verification_uri,
|
||||
expiresIn: data.expires_in,
|
||||
}
|
||||
}
|
||||
|
||||
private async pollDeviceCode(
|
||||
providerId: string,
|
||||
provider: OAuthProviderConfig,
|
||||
deviceCode: string,
|
||||
initialInterval: number,
|
||||
expiresIn: number,
|
||||
codeVerifier?: string,
|
||||
): Promise<void> {
|
||||
let interval = initialInterval
|
||||
const deadline = Date.now() + expiresIn * 1000
|
||||
const useForm = provider.deviceCodeContentType === 'form'
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(interval * 1000 + TIMEOUTS.DEVICE_CODE_POLL_SAFETY_MARGIN)
|
||||
|
||||
try {
|
||||
const params: Record<string, string> = {
|
||||
client_id: provider.clientId,
|
||||
device_code: deviceCode,
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
||||
}
|
||||
if (codeVerifier) params.code_verifier = codeVerifier
|
||||
|
||||
const response = await fetch(provider.tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': useForm
|
||||
? 'application/x-www-form-urlencoded'
|
||||
: 'application/json',
|
||||
},
|
||||
body: useForm
|
||||
? new URLSearchParams(params).toString()
|
||||
: JSON.stringify(params),
|
||||
})
|
||||
|
||||
// WAF returned HTML instead of JSON — retry later
|
||||
const ct = response.headers.get('content-type') ?? ''
|
||||
if (!ct.includes('application/json')) {
|
||||
logger.warn('WAF blocked poll request, retrying', {
|
||||
provider: providerId,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const data = (await response.json()) as DeviceCodeTokenPollResponse
|
||||
|
||||
// Token received — store and return
|
||||
if (data.access_token) {
|
||||
const tokens: StoredOAuthTokens = {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token ?? '',
|
||||
expiresAt: data.expires_in
|
||||
? Date.now() + data.expires_in * 1000
|
||||
: 0,
|
||||
email: undefined,
|
||||
accountId: undefined,
|
||||
}
|
||||
this.store.upsertTokens(this.browserosId, providerId, tokens)
|
||||
logger.info('Device code OAuth successful', { provider: providerId })
|
||||
return
|
||||
}
|
||||
|
||||
// Handle polling errors per RFC 8628
|
||||
if (data.error === 'authorization_pending') continue
|
||||
if (data.error === 'slow_down') {
|
||||
interval = (data.interval ?? interval) + 5
|
||||
continue
|
||||
}
|
||||
if (data.error === 'expired_token' || data.error === 'access_denied') {
|
||||
logger.warn('Device code flow ended', {
|
||||
provider: providerId,
|
||||
error: data.error,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
logger.warn('Unexpected device code poll response', {
|
||||
provider: providerId,
|
||||
error: data.error,
|
||||
})
|
||||
return
|
||||
} catch (err) {
|
||||
// Transient network error — loop continues to retry
|
||||
logger.warn('Device code poll request failed, retrying', {
|
||||
provider: providerId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn('Device code flow timed out', { provider: providerId })
|
||||
}
|
||||
|
||||
// --- Token refresh ---
|
||||
|
||||
async refreshIfExpired(provider: string): Promise<StoredOAuthTokens | null> {
|
||||
const tokens = this.store.getTokens(this.browserosId, provider)
|
||||
if (!tokens) return null
|
||||
|
||||
// GitHub Copilot tokens never expire (expiresAt = 0)
|
||||
if (tokens.expiresAt === 0) return tokens
|
||||
|
||||
if (Date.now() < tokens.expiresAt - TIMEOUTS.OAUTH_TOKEN_EXPIRY_BUFFER) {
|
||||
return tokens
|
||||
}
|
||||
|
||||
// If a refresh is already in progress, await it instead of starting another
|
||||
const existing = this.refreshLocks.get(provider)
|
||||
if (existing) return existing
|
||||
|
||||
@@ -214,6 +415,27 @@ export class OAuthTokenManager {
|
||||
return refreshed
|
||||
}
|
||||
|
||||
// --- Shared ---
|
||||
|
||||
// Store tokens provided by the extension (client-side auth flow)
|
||||
storeTokens(
|
||||
provider: string,
|
||||
params: { accessToken: string; refreshToken: string; expiresIn: number },
|
||||
): void {
|
||||
const tokens: StoredOAuthTokens = {
|
||||
accessToken: params.accessToken,
|
||||
refreshToken: params.refreshToken,
|
||||
expiresAt: params.expiresIn ? Date.now() + params.expiresIn * 1000 : 0,
|
||||
email: undefined,
|
||||
accountId: undefined,
|
||||
}
|
||||
this.store.upsertTokens(this.browserosId, provider, tokens)
|
||||
}
|
||||
|
||||
getTokens(provider: string): StoredOAuthTokens | null {
|
||||
return this.store.getTokens(this.browserosId, provider)
|
||||
}
|
||||
|
||||
getStatus(provider: string) {
|
||||
return this.store.getStatus(this.browserosId, provider)
|
||||
}
|
||||
@@ -257,6 +479,10 @@ function base64UrlEncode(bytes: Uint8Array): string {
|
||||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
// Extracts claims without signature verification — safe because the token
|
||||
// comes directly from OpenAI's HTTPS token endpoint. Do not reuse for
|
||||
// caller-supplied or externally-sourced tokens.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -121,14 +121,17 @@ export const navigate_page = defineTool({
|
||||
|
||||
export const new_page = defineTool({
|
||||
name: 'new_page',
|
||||
description: 'Open a new page (tab) and navigate to a URL',
|
||||
description:
|
||||
'Open a new page (tab) and navigate to a URL. Opens in background by default to keep the user on their current page. Use group_tabs to organize related tabs.',
|
||||
input: z.object({
|
||||
url: z.string().describe('URL to open'),
|
||||
hidden: z.boolean().default(false).describe('Create as hidden tab'),
|
||||
background: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe('Open in background without activating'),
|
||||
.default(true)
|
||||
.describe(
|
||||
'Open in background without stealing focus. Set to false only when user needs to see the tab immediately.',
|
||||
),
|
||||
windowId: z.number().optional().describe('Window ID to create tab in'),
|
||||
}),
|
||||
output: z.object({
|
||||
@@ -140,8 +143,8 @@ export const new_page = defineTool({
|
||||
}),
|
||||
handler: async (args, ctx, response) => {
|
||||
const pageId = await ctx.browser.newPage(args.url, {
|
||||
hidden: args.hidden || undefined,
|
||||
background: args.background || undefined,
|
||||
hidden: args.hidden ? true : undefined,
|
||||
background: args.background === false ? false : true,
|
||||
windowId: args.windowId,
|
||||
})
|
||||
response.text(`Opened new page: ${args.url}\nPage ID: ${pageId}`)
|
||||
|
||||
@@ -101,7 +101,13 @@ export const get_page_content = defineTool({
|
||||
extension: 'md',
|
||||
content: text,
|
||||
})
|
||||
response.text(`Saved page content to ${path}`)
|
||||
// Return truncated content inline so the agent can work immediately,
|
||||
// plus the file path for optional deep reading
|
||||
const truncated = text.slice(0, TOOL_LIMITS.INLINE_PAGE_CONTENT_MAX_CHARS)
|
||||
response.text(truncated)
|
||||
response.text(
|
||||
`\n\n[Content truncated at ${TOOL_LIMITS.INLINE_PAGE_CONTENT_MAX_CHARS} chars. Full content (${text.length} chars) saved to: ${path}]`,
|
||||
)
|
||||
response.data({
|
||||
path,
|
||||
contentLength: text.length,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -1032,9 +1032,56 @@ describe('execution section', () => {
|
||||
expect(prompt).toContain('500')
|
||||
})
|
||||
|
||||
it('includes new-tab restriction', () => {
|
||||
it('includes multi-tab workflow guidance', () => {
|
||||
// Why: The agent must know how to handle multi-tab tasks — open background
|
||||
// tabs, create tab groups, narrate progress, and never steal user focus.
|
||||
const prompt = buildRegular()
|
||||
expect(prompt).toContain('Only open new tabs when the user explicitly asks')
|
||||
expect(prompt).toContain('Multi-tab workflow')
|
||||
expect(prompt).toContain('background')
|
||||
expect(prompt).toContain('group_tabs')
|
||||
expect(prompt).toContain('Never force-switch')
|
||||
})
|
||||
|
||||
it('enforces mandatory tab group creation', () => {
|
||||
// Why: Run 7 showed the agent opening background tabs without creating
|
||||
// a tab group. The prompt must make tab groups mandatory, not optional.
|
||||
const prompt = buildRegular()
|
||||
expect(prompt).toContain('IMMEDIATELY create a tab group')
|
||||
expect(prompt).toContain('MUST have a tab group')
|
||||
})
|
||||
|
||||
it('prohibits navigating user current tab during multi-tab', () => {
|
||||
// Why: Run 7 showed the agent clicking a link on the user's current tab,
|
||||
// navigating away from their starting page. The current tab must be read-only.
|
||||
const prompt = buildRegular()
|
||||
expect(prompt).toContain('Never navigate the user')
|
||||
expect(prompt).toContain('anchor')
|
||||
})
|
||||
|
||||
it('prohibits hidden windows for user tasks', () => {
|
||||
// Why: Run 2 used create_hidden_window instead of background tabs.
|
||||
// Hidden windows are invisible to users and can't be screenshotted.
|
||||
const prompt = buildRegular()
|
||||
expect(prompt).toContain('Do NOT use')
|
||||
expect(prompt).toContain('create_hidden_window')
|
||||
expect(prompt).toContain('new_hidden_page')
|
||||
})
|
||||
|
||||
it('includes tab retry discipline', () => {
|
||||
// Why: Run 7 opened 7+ tabs for a 3-article task because retries
|
||||
// created new tabs instead of navigating existing ones.
|
||||
const prompt = buildRegular()
|
||||
expect(prompt).toContain('Tab retry discipline')
|
||||
expect(prompt).toContain('Navigate the existing tab')
|
||||
expect(prompt).toContain('close_page')
|
||||
})
|
||||
|
||||
it('includes retry budget for failing sites', () => {
|
||||
// Why: Run 8 spent 15+ tool calls fighting Kayak's geo-detection.
|
||||
// The agent should give up after 3-4 attempts and report partial results.
|
||||
const prompt = buildRegular()
|
||||
expect(prompt).toContain('Retry budget')
|
||||
expect(prompt).toContain('3-4 attempts')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
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),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.8/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"name": "browseros-monorepo",
|
||||
"devDependencies": {
|
||||
"@aws-sdk/client-s3": "^3.933.0",
|
||||
"@biomejs/biome": "2.4.5",
|
||||
"@biomejs/biome": "2.4.8",
|
||||
"@sentry/cli": "^2.42.2",
|
||||
"@types/bun": "^1.3.5",
|
||||
"@types/node": "^24.3.3",
|
||||
@@ -169,7 +169,7 @@
|
||||
},
|
||||
"apps/server": {
|
||||
"name": "@browseros/server",
|
||||
"version": "0.0.76",
|
||||
"version": "0.0.79",
|
||||
"bin": {
|
||||
"browseros-server": "./src/index.ts",
|
||||
},
|
||||
@@ -203,6 +203,7 @@
|
||||
"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",
|
||||
@@ -450,23 +451,23 @@
|
||||
|
||||
"@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="],
|
||||
|
||||
"@biomejs/biome": ["@biomejs/biome@2.4.5", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.5", "@biomejs/cli-darwin-x64": "2.4.5", "@biomejs/cli-linux-arm64": "2.4.5", "@biomejs/cli-linux-arm64-musl": "2.4.5", "@biomejs/cli-linux-x64": "2.4.5", "@biomejs/cli-linux-x64-musl": "2.4.5", "@biomejs/cli-win32-arm64": "2.4.5", "@biomejs/cli-win32-x64": "2.4.5" }, "bin": { "biome": "bin/biome" } }, "sha512-OWNCyMS0Q011R6YifXNOg6qsOg64IVc7XX6SqGsrGszPbkVCoaO7Sr/lISFnXZ9hjQhDewwZ40789QmrG0GYgQ=="],
|
||||
"@biomejs/biome": ["@biomejs/biome@2.4.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.8", "@biomejs/cli-darwin-x64": "2.4.8", "@biomejs/cli-linux-arm64": "2.4.8", "@biomejs/cli-linux-arm64-musl": "2.4.8", "@biomejs/cli-linux-x64": "2.4.8", "@biomejs/cli-linux-x64-musl": "2.4.8", "@biomejs/cli-win32-arm64": "2.4.8", "@biomejs/cli-win32-x64": "2.4.8" }, "bin": { "biome": "bin/biome" } }, "sha512-ponn0oKOky1oRXBV+rlSaUlixUxf1aZvWC19Z41zBfUOUesthrQqL3OtiAlSB1EjFjyWpn98Q64DHelhA6jNlA=="],
|
||||
|
||||
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lGS4Nd5O3KQJ6TeWv10mElnx1phERhBxqGP/IKq0SvZl78kcWDFMaTtVK+w3v3lusRFxJY78n07PbKplirsU5g=="],
|
||||
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ARx0tECE8I7S2C2yjnWYLNbBdDoPdq3oyNLhMglmuctThwUsuzFWRKrHmIGwIRWKz0Mat9DuzLEDp52hGnrxGQ=="],
|
||||
|
||||
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-6MoH4tyISIBNkZ2Q5T1R7dLd5BsITb2yhhhrU9jHZxnNSNMWl+s2Mxu7NBF8Y3a7JJcqq9nsk8i637z4gqkJxQ=="],
|
||||
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jg9/PsB9vDCJlANE8uhG7qDhb5w0Ix69D7XIIc8IfZPUoiPrbLm33k2Ig3NOJ/7nb3UbesFz3D1aDKm9DvzjhQ=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-U1GAG6FTjhAO04MyH4xn23wRNBkT6H7NentHh+8UxD6ShXKBm5SY4RedKJzkUThANxb9rUKIPc7B8ew9Xo/cWg=="],
|
||||
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-5CdrsJct76XG2hpKFwXnEtlT1p+4g4yV+XvvwBpzKsTNLO9c6iLlAxwcae2BJ7ekPGWjNGw9j09T5KGPKKxQig=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-iqLDgpzobG7gpBF0fwEVS/LT8kmN7+S0E2YKFDtqliJfzNLnAiV2Nnyb+ehCDCJgAZBASkYHR2o60VQWikpqIg=="],
|
||||
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-Zo9OhBQDJ3IBGPlqHiTISloo5H0+FBIpemqIJdW/0edJ+gEcLR+MZeZozcUyz3o1nXkVA7++DdRKQT0599j9jA=="],
|
||||
|
||||
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-NdODlSugMzTlENPTa4z0xB82dTUlCpsrOxc43///aNkTLblIYH4XpYflBbf5ySlQuP8AA4AZd1qXhV07IdrHdQ=="],
|
||||
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.8", "", { "os": "linux", "cpu": "x64" }, "sha512-PdKXspVEaMCQLjtZCn6vfSck/li4KX9KGwSDbZdgIqlrizJ2MnMcE3TvHa2tVfXNmbjMikzcfJpuPWH695yJrw=="],
|
||||
|
||||
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-NlKa7GpbQmNhZf9kakQeddqZyT7itN7jjWdakELeXyTU3pg/83fTysRRDPJD0akTfKDl6vZYNT9Zqn4MYZVBOA=="],
|
||||
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.8", "", { "os": "linux", "cpu": "x64" }, "sha512-Gi8quv8MEuDdKaPFtS2XjEnMqODPsRg6POT6KhoP+VrkNb+T2ywunVB+TvOU0LX1jAZzfBr+3V1mIbBhzAMKvw=="],
|
||||
|
||||
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-EBfrTqRIWOFSd7CQb/0ttjHMR88zm3hGravnDwUA9wHAaCAYsULKDebWcN5RmrEo1KBtl/gDVJMrFjNR0pdGUw=="],
|
||||
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-LoFatS0tnHv6KkCVpIy3qZCih+MxUMvdYiPWLHRri7mhi2vyOOs8OrbZBcLTUEWCS+ktO72nZMy4F96oMhkOHQ=="],
|
||||
|
||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Pmhv9zT95YzECfjEHNl3mN9Vhusw9VA5KHY0ZvlGsxsjwS5cb7vpRnHzJIv0vG7jB0JI7xEaMH9ddfZm/RozBw=="],
|
||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.8", "", { "os": "win32", "cpu": "x64" }, "sha512-vAn7iXDoUbqFXqVocuq1sMYAd33p8+mmurqJkWl6CtIhobd/O6moe4rY5AJvzbunn/qZCdiDVcveqtkFh1e7Hg=="],
|
||||
|
||||
"@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.1", "", {}, "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw=="],
|
||||
|
||||
@@ -894,6 +895,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=="],
|
||||
@@ -1734,6 +1791,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=="],
|
||||
@@ -2052,6 +2111,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=="],
|
||||
@@ -2086,6 +2147,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=="],
|
||||
@@ -2126,6 +2189,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=="],
|
||||
@@ -2214,7 +2279,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=="],
|
||||
|
||||
@@ -2618,6 +2683,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=="],
|
||||
@@ -2676,6 +2743,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=="],
|
||||
@@ -2754,6 +2823,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=="],
|
||||
@@ -2892,6 +2963,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=="],
|
||||
@@ -3016,12 +3089,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=="],
|
||||
@@ -3466,6 +3543,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=="],
|
||||
@@ -3520,6 +3599,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=="],
|
||||
@@ -3560,6 +3645,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=="],
|
||||
@@ -3582,12 +3669,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=="],
|
||||
@@ -3808,6 +3899,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=="],
|
||||
@@ -3996,6 +4089,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=="],
|
||||
@@ -4092,6 +4187,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=="],
|
||||
@@ -4148,6 +4245,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=="],
|
||||
@@ -4164,6 +4263,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=="],
|
||||
@@ -4282,6 +4383,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=="],
|
||||
@@ -4376,6 +4479,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=="],
|
||||
@@ -4566,6 +4671,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=="],
|
||||
@@ -4984,6 +5091,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=="],
|
||||
@@ -5048,12 +5157,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=="],
|
||||
|
||||
@@ -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**
|
||||
@@ -45,7 +45,7 @@
|
||||
"homepage": "https://github.com/browseros-ai/BrowserOS#readme",
|
||||
"devDependencies": {
|
||||
"@aws-sdk/client-s3": "^3.933.0",
|
||||
"@biomejs/biome": "2.4.5",
|
||||
"@biomejs/biome": "2.4.8",
|
||||
"@sentry/cli": "^2.42.2",
|
||||
"@types/bun": "^1.3.5",
|
||||
"@types/node": "^24.3.3",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -54,6 +54,7 @@ export const TIMEOUTS = {
|
||||
OAUTH_TOKEN_EXPIRY_BUFFER: 300_000,
|
||||
OAUTH_POLL_INTERVAL: 2_000,
|
||||
OAUTH_POLL_TIMEOUT: 300_000,
|
||||
DEVICE_CODE_POLL_SAFETY_MARGIN: 3_000,
|
||||
} as const
|
||||
|
||||
export type TimeoutKey = keyof typeof TIMEOUTS
|
||||
|
||||
@@ -13,4 +13,10 @@ export const EXTERNAL_URLS = {
|
||||
OPENAI_AUTH: 'https://auth.openai.com/oauth/authorize',
|
||||
OPENAI_TOKEN: 'https://auth.openai.com/oauth/token',
|
||||
SKILLS_CATALOG: 'https://cdn.browseros.com/skills/v1/catalog.json',
|
||||
GITHUB_DEVICE_CODE: 'https://github.com/login/device/code',
|
||||
GITHUB_OAUTH_TOKEN: 'https://github.com/login/oauth/access_token',
|
||||
GITHUB_COPILOT_API: 'https://api.githubcopilot.com',
|
||||
QWEN_DEVICE_CODE: 'https://chat.qwen.ai/api/v1/oauth2/device/code',
|
||||
QWEN_OAUTH_TOKEN: 'https://chat.qwen.ai/api/v1/oauth2/token',
|
||||
QWEN_CODE_API: 'https://portal.qwen.ai/v1',
|
||||
} as const
|
||||
|
||||
@@ -25,6 +25,8 @@ export const LLM_PROVIDERS = {
|
||||
OPENAI_COMPATIBLE: 'openai-compatible',
|
||||
MOONSHOT: 'moonshot',
|
||||
CHATGPT_PRO: 'chatgpt-pro',
|
||||
GITHUB_COPILOT: 'github-copilot',
|
||||
QWEN_CODE: 'qwen-code',
|
||||
} as const
|
||||
|
||||
/**
|
||||
@@ -44,6 +46,8 @@ export const LLMProviderSchema: z.ZodEnum<
|
||||
'openai-compatible',
|
||||
'moonshot',
|
||||
'chatgpt-pro',
|
||||
'github-copilot',
|
||||
'qwen-code',
|
||||
]
|
||||
> = z.enum([
|
||||
LLM_PROVIDERS.ANTHROPIC,
|
||||
@@ -58,6 +62,8 @@ export const LLMProviderSchema: z.ZodEnum<
|
||||
LLM_PROVIDERS.OPENAI_COMPATIBLE,
|
||||
LLM_PROVIDERS.MOONSHOT,
|
||||
LLM_PROVIDERS.CHATGPT_PRO,
|
||||
LLM_PROVIDERS.GITHUB_COPILOT,
|
||||
LLM_PROVIDERS.QWEN_CODE,
|
||||
])
|
||||
|
||||
export type LLMProvider = z.infer<typeof LLMProviderSchema>
|
||||
|
||||
@@ -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 @@
|
||||
139
|
||||
141
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
BROWSEROS_MAJOR=0
|
||||
BROWSEROS_MINOR=43
|
||||
BROWSEROS_MINOR=44
|
||||
BROWSEROS_BUILD=0
|
||||
BROWSEROS_PATCH=2
|
||||
BROWSEROS_PATCH=1
|
||||
|
||||
Reference in New Issue
Block a user