Compare commits
68 Commits
feat/githu
...
fix/mar26-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
578667f9ee | ||
|
|
9c43a9d6fe | ||
|
|
220577b41c | ||
|
|
03b45013a6 | ||
|
|
aa85907212 | ||
|
|
085352a6f0 | ||
|
|
c0578d0e53 | ||
|
|
663c18ee97 | ||
|
|
48727750b4 | ||
|
|
30a3a96a57 | ||
|
|
6773ce39da | ||
|
|
342a3e4a07 | ||
|
|
09406ea794 | ||
|
|
1f00cbc9cc | ||
|
|
422a829f5e | ||
|
|
ed109fcedf | ||
|
|
19af96d08e | ||
|
|
e0304b203c | ||
|
|
af65bdbcfb | ||
|
|
d79c2a4123 | ||
|
|
e3d57e5347 | ||
|
|
392312f203 | ||
|
|
0f193055c7 | ||
|
|
f45cb58889 | ||
|
|
37ead6d129 | ||
|
|
5ea9463030 | ||
|
|
dde35ccbd5 | ||
|
|
7f20319272 | ||
|
|
c8204efab6 | ||
|
|
fb5143b563 | ||
|
|
fe257cd8d1 | ||
|
|
890d3406dd | ||
|
|
c316e09c11 | ||
|
|
65547c60c0 | ||
|
|
0babc05077 | ||
|
|
1270b5b55c | ||
|
|
e97d8bc1cb | ||
|
|
5109ca4347 | ||
|
|
f14942c6f9 | ||
|
|
86ec88ed80 | ||
|
|
4928b7e84b | ||
|
|
94a1a701f6 | ||
|
|
ecf2efa857 | ||
|
|
026c6a03a3 | ||
|
|
2b53daf641 | ||
|
|
3cc946ded8 | ||
|
|
70be5c5c21 | ||
|
|
0f9d93058f | ||
|
|
cafed57832 | ||
|
|
f157436e7d | ||
|
|
ba7892322b | ||
|
|
4e90b4561a | ||
|
|
86eed82350 | ||
|
|
be6ed22af4 | ||
|
|
149cde118d | ||
|
|
9bc5e666c4 | ||
|
|
2271277b4d | ||
|
|
f865d301a2 | ||
|
|
6f398f0b36 | ||
|
|
8548bcf50a | ||
|
|
e3601bfdc1 | ||
|
|
2b4fdf1aad | ||
|
|
11d15d079f | ||
|
|
9257832acf | ||
|
|
7bde0d59fa | ||
|
|
1c737b0f02 | ||
|
|
5d0a2b9bfe | ||
|
|
720baaed3e |
98
.github/workflows/eval-weekly.yml
vendored
Normal file
@@ -0,0 +1,98 @@
|
||||
name: Weekly Eval
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every Saturday at 06:00 UTC
|
||||
- cron: '0 6 * * 6'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'packages/browseros-agent/apps/server/src/agent/**'
|
||||
- 'packages/browseros-agent/apps/server/src/tools/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
config:
|
||||
description: 'Eval config file (relative to apps/eval/)'
|
||||
required: false
|
||||
default: 'configs/browseros-agent-weekly.json'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
eval:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 360
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install BrowserOS
|
||||
run: |
|
||||
wget -q https://github.com/browseros-ai/BrowserOS/releases/download/v0.44.0.1/BrowserOS_v0.44.0.1_amd64.deb
|
||||
sudo dpkg -i BrowserOS_v0.44.0.1_amd64.deb
|
||||
browseros --version || echo "BrowserOS installed at $(which browseros)"
|
||||
|
||||
- name: Install Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: packages/browseros-agent
|
||||
run: bun install --ignore-scripts && bun run build:agent-sdk
|
||||
|
||||
- name: Install xvfb
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb
|
||||
|
||||
- name: Install captcha solver extension
|
||||
working-directory: packages/browseros-agent/apps/eval
|
||||
run: |
|
||||
mkdir -p extensions
|
||||
curl -sL -o /tmp/nopecha.zip https://github.com/NopeCHALLC/nopecha-extension/releases/latest/download/chromium_automation.zip
|
||||
unzip -qo /tmp/nopecha.zip -d extensions/nopecha
|
||||
|
||||
- name: Run eval
|
||||
working-directory: packages/browseros-agent/apps/eval
|
||||
env:
|
||||
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
NOPECHA_API_KEY: ${{ secrets.NOPECHA_API_KEY }}
|
||||
BROWSEROS_BINARY: /usr/bin/browseros
|
||||
EVAL_CONFIG: ${{ github.event.inputs.config || 'configs/browseros-agent-weekly.json' }}
|
||||
run: |
|
||||
echo "Running eval with config: $EVAL_CONFIG"
|
||||
xvfb-run --auto-servernum --server-args="-screen 0 1440x900x24" bun run src/index.ts -c "$EVAL_CONFIG"
|
||||
|
||||
- name: Upload runs to R2
|
||||
if: success()
|
||||
working-directory: packages/browseros-agent/apps/eval
|
||||
env:
|
||||
EVAL_R2_ACCOUNT_ID: ${{ secrets.EVAL_R2_ACCOUNT_ID }}
|
||||
EVAL_R2_ACCESS_KEY_ID: ${{ secrets.EVAL_R2_ACCESS_KEY_ID }}
|
||||
EVAL_R2_SECRET_ACCESS_KEY: ${{ secrets.EVAL_R2_SECRET_ACCESS_KEY }}
|
||||
EVAL_R2_BUCKET: ${{ secrets.EVAL_R2_BUCKET }}
|
||||
EVAL_R2_CDN_BASE_URL: ${{ secrets.EVAL_R2_CDN_BASE_URL }}
|
||||
EVAL_CONFIG: ${{ github.event.inputs.config || 'configs/browseros-agent-weekly.json' }}
|
||||
run: |
|
||||
CONFIG_NAME=$(basename "$EVAL_CONFIG" .json)
|
||||
bun scripts/upload-run.ts "results/$CONFIG_NAME"
|
||||
|
||||
- name: Generate trend report
|
||||
if: success()
|
||||
working-directory: packages/browseros-agent
|
||||
env:
|
||||
EVAL_R2_ACCOUNT_ID: ${{ secrets.EVAL_R2_ACCOUNT_ID }}
|
||||
EVAL_R2_ACCESS_KEY_ID: ${{ secrets.EVAL_R2_ACCESS_KEY_ID }}
|
||||
EVAL_R2_SECRET_ACCESS_KEY: ${{ secrets.EVAL_R2_SECRET_ACCESS_KEY }}
|
||||
EVAL_R2_BUCKET: ${{ secrets.EVAL_R2_BUCKET }}
|
||||
EVAL_R2_CDN_BASE_URL: ${{ secrets.EVAL_R2_CDN_BASE_URL }}
|
||||
run: bun apps/eval/scripts/weekly-report.ts /tmp/eval-report.html
|
||||
|
||||
- name: Upload report as artifact
|
||||
if: success()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: eval-report-${{ github.run_id }}
|
||||
path: /tmp/eval-report.html
|
||||
2
.github/workflows/pr-title.yml
vendored
@@ -2,7 +2,7 @@ name: PR Conventional Commit Validation
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
types: [opened, edited]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
148
.github/workflows/release-agent-extension.yml
vendored
Normal file
@@ -0,0 +1,148 @@
|
||||
name: Release Agent Extension
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: release-agent-extension
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/browseros-agent/apps/agent
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun ci
|
||||
working-directory: packages/browseros-agent
|
||||
|
||||
- name: Build and zip extension
|
||||
run: bun run codegen && bun run zip
|
||||
env:
|
||||
VITE_PUBLIC_BROWSEROS_API: https://api.browseros.com
|
||||
|
||||
- name: Get version and zip path
|
||||
id: version
|
||||
run: |
|
||||
echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
|
||||
echo "release_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
ZIP_FILE=$(ls "$(pwd)/dist/"*-chrome.zip | head -n 1)
|
||||
echo "zip_path=$ZIP_FILE" >> "$GITHUB_OUTPUT"
|
||||
echo "zip_name=$(basename "$ZIP_FILE")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Generate release notes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
AGENT_PATH="packages/browseros-agent/apps/agent"
|
||||
CURRENT_TAG="agent-extension-v${{ steps.version.outputs.version }}"
|
||||
PREV_TAG=$(git tag -l "agent-extension-v*" --sort=-v:refname | grep -v "^${CURRENT_TAG}$" | head -n 1)
|
||||
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
echo "Initial release" > /tmp/release-notes.md
|
||||
else
|
||||
COMMITS=$(git log "$PREV_TAG"..HEAD --pretty=format:"%H" -- "$AGENT_PATH")
|
||||
|
||||
if [ -z "$COMMITS" ]; then
|
||||
echo "No notable changes." > /tmp/release-notes.md
|
||||
else
|
||||
echo "## What's Changed" > /tmp/release-notes.md
|
||||
echo "" >> /tmp/release-notes.md
|
||||
|
||||
while IFS= read -r SHA; do
|
||||
SUBJECT=$(git log -1 --pretty=format:"%s" "$SHA")
|
||||
PR_NUM=$(gh api "/repos/${{ github.repository }}/commits/${SHA}/pulls" --jq '.[0].number // empty' 2>/dev/null)
|
||||
|
||||
# Skip PR number if already in the commit subject (squash merges include it)
|
||||
if [ -n "$PR_NUM" ] && ! echo "$SUBJECT" | grep -qF "(#${PR_NUM})"; then
|
||||
echo "- ${SUBJECT} (#${PR_NUM})" >> /tmp/release-notes.md
|
||||
else
|
||||
echo "- ${SUBJECT}" >> /tmp/release-notes.md
|
||||
fi
|
||||
done <<< "$COMMITS"
|
||||
fi
|
||||
fi
|
||||
working-directory: ${{ github.workspace }}
|
||||
|
||||
- name: Create GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="agent-extension-v${{ steps.version.outputs.version }}"
|
||||
RELEASE_SHA="${{ steps.version.outputs.release_sha }}"
|
||||
TITLE="BrowserOS Agent Extension v${{ steps.version.outputs.version }}"
|
||||
|
||||
if git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
echo "Tag $TAG already exists, skipping tag creation"
|
||||
else
|
||||
git tag "$TAG" "$RELEASE_SHA"
|
||||
fi
|
||||
|
||||
if git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then
|
||||
echo "Tag $TAG already on remote, skipping push"
|
||||
else
|
||||
git push origin "$TAG"
|
||||
fi
|
||||
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
echo "Release $TAG already exists, updating"
|
||||
gh release edit "$TAG" --title "$TITLE" --notes-file /tmp/release-notes.md
|
||||
gh release upload "$TAG" "${{ steps.version.outputs.zip_path }}" --clobber
|
||||
else
|
||||
gh release create "$TAG" \
|
||||
--title "$TITLE" \
|
||||
--notes-file /tmp/release-notes.md \
|
||||
"${{ steps.version.outputs.zip_path }}"
|
||||
fi
|
||||
working-directory: ${{ github.workspace }}
|
||||
|
||||
- name: Update CHANGELOG.md via PR
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
DATE=$(date -u +"%Y-%m-%d")
|
||||
BRANCH="docs/agent-extension-changelog-v${VERSION}"
|
||||
CHANGELOG="packages/browseros-agent/apps/agent/CHANGELOG.md"
|
||||
|
||||
git checkout main
|
||||
|
||||
{
|
||||
head -n 1 "$CHANGELOG"
|
||||
echo ""
|
||||
echo "## v${VERSION} (${DATE})"
|
||||
echo ""
|
||||
cat /tmp/release-notes.md
|
||||
echo ""
|
||||
tail -n +2 "$CHANGELOG"
|
||||
} > /tmp/new-changelog.md
|
||||
mv /tmp/new-changelog.md "$CHANGELOG"
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git checkout -b "$BRANCH"
|
||||
git add "$CHANGELOG"
|
||||
git commit -m "docs: update agent extension changelog for v${VERSION}"
|
||||
git push origin "$BRANCH"
|
||||
|
||||
gh pr create \
|
||||
--title "docs: update agent extension changelog for v${VERSION}" \
|
||||
--body "Auto-generated changelog update for BrowserOS Agent Extension v${VERSION}." \
|
||||
--base main \
|
||||
--head "$BRANCH"
|
||||
|
||||
gh pr merge "$BRANCH" --squash --auto || true
|
||||
working-directory: ${{ github.workspace }}
|
||||
131
.github/workflows/release-agent-sdk.yml
vendored
@@ -3,16 +3,25 @@ name: Release Agent SDK
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: release-agent-sdk
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/browseros-agent/packages/agent-sdk
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
@@ -31,7 +40,129 @@ jobs:
|
||||
- name: Test
|
||||
run: bun test
|
||||
|
||||
- name: Get version
|
||||
id: version
|
||||
run: |
|
||||
echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
|
||||
echo "release_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Generate release notes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
SDK_PATH="packages/browseros-agent/packages/agent-sdk"
|
||||
CURRENT_TAG="agent-sdk-v${{ steps.version.outputs.version }}"
|
||||
# Find the previous tag, excluding the current version's tag
|
||||
# (which may already exist from a prior failed run)
|
||||
PREV_TAG=$(git tag -l "agent-sdk-v*" --sort=-v:refname | grep -v "^${CURRENT_TAG}$" | head -n 1)
|
||||
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
echo "Initial release" > /tmp/release-notes.md
|
||||
else
|
||||
# Get commits scoped to the SDK directory
|
||||
COMMITS=$(git log "$PREV_TAG"..HEAD --pretty=format:"%H" -- "$SDK_PATH")
|
||||
|
||||
if [ -z "$COMMITS" ]; then
|
||||
echo "No notable changes." > /tmp/release-notes.md
|
||||
else
|
||||
echo "## What's Changed" > /tmp/release-notes.md
|
||||
echo "" >> /tmp/release-notes.md
|
||||
|
||||
# For each commit, find the associated PR and format with author
|
||||
CONTRIBUTORS=""
|
||||
while IFS= read -r SHA; do
|
||||
# Get commit subject and author
|
||||
SUBJECT=$(git log -1 --pretty=format:"%s" "$SHA")
|
||||
AUTHOR=$(git log -1 --pretty=format:"%an" "$SHA")
|
||||
GITHUB_USER=$(gh api "/repos/${{ github.repository }}/commits/${SHA}" --jq '.author.login // empty' 2>/dev/null)
|
||||
|
||||
# Find associated PR number
|
||||
PR_NUM=$(gh api "/repos/${{ github.repository }}/commits/${SHA}/pulls" --jq '.[0].number // empty' 2>/dev/null)
|
||||
|
||||
# Format line: skip PR number if already in the commit subject
|
||||
# (squash merges include "(#123)" in the subject automatically)
|
||||
if [ -n "$PR_NUM" ] && ! echo "$SUBJECT" | grep -qF "(#${PR_NUM})"; then
|
||||
echo "- ${SUBJECT} (#${PR_NUM})" >> /tmp/release-notes.md
|
||||
else
|
||||
echo "- ${SUBJECT}" >> /tmp/release-notes.md
|
||||
fi
|
||||
done <<< "$COMMITS"
|
||||
fi
|
||||
fi
|
||||
working-directory: ${{ github.workspace }}
|
||||
|
||||
- name: Publish
|
||||
run: npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Create GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="agent-sdk-v${{ steps.version.outputs.version }}"
|
||||
RELEASE_SHA="${{ steps.version.outputs.release_sha }}"
|
||||
TITLE="@browseros-ai/agent-sdk v${{ steps.version.outputs.version }}"
|
||||
|
||||
# Create or reuse tag (idempotent for re-runs)
|
||||
if git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
echo "Tag $TAG already exists, skipping tag creation"
|
||||
else
|
||||
git tag "$TAG" "$RELEASE_SHA"
|
||||
fi
|
||||
|
||||
# Push tag (skip if already on remote)
|
||||
if git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then
|
||||
echo "Tag $TAG already on remote, skipping push"
|
||||
else
|
||||
git push origin "$TAG"
|
||||
fi
|
||||
|
||||
# Create or update release
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
echo "Release $TAG already exists, updating"
|
||||
gh release edit "$TAG" --title "$TITLE" --notes-file /tmp/release-notes.md
|
||||
else
|
||||
gh release create "$TAG" --title "$TITLE" --notes-file /tmp/release-notes.md
|
||||
fi
|
||||
working-directory: ${{ github.workspace }}
|
||||
|
||||
- name: Update CHANGELOG.md via PR
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
DATE=$(date -u +"%Y-%m-%d")
|
||||
BRANCH="docs/agent-sdk-changelog-v${VERSION}"
|
||||
CHANGELOG="packages/browseros-agent/packages/agent-sdk/CHANGELOG.md"
|
||||
|
||||
# Return to main before branching
|
||||
git checkout main
|
||||
|
||||
# Use head/tail to safely insert without sed quoting issues
|
||||
{
|
||||
head -n 1 "$CHANGELOG"
|
||||
echo ""
|
||||
echo "## v${VERSION} (${DATE})"
|
||||
echo ""
|
||||
cat /tmp/release-notes.md
|
||||
echo ""
|
||||
tail -n +2 "$CHANGELOG"
|
||||
} > /tmp/new-changelog.md
|
||||
mv /tmp/new-changelog.md "$CHANGELOG"
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git checkout -b "$BRANCH"
|
||||
git add "$CHANGELOG"
|
||||
git commit -m "docs: update agent-sdk changelog for v${VERSION}"
|
||||
git push origin "$BRANCH"
|
||||
|
||||
gh pr create \
|
||||
--title "docs: update agent-sdk changelog for v${VERSION}" \
|
||||
--body "Auto-generated changelog update for @browseros-ai/agent-sdk v${VERSION}." \
|
||||
--base main \
|
||||
--head "$BRANCH"
|
||||
|
||||
gh pr merge "$BRANCH" --squash --auto || true
|
||||
working-directory: ${{ github.workspace }}
|
||||
|
||||
144
.github/workflows/release-cli.yml
vendored
Normal file
@@ -0,0 +1,144 @@
|
||||
name: Release CLI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release version (e.g. 0.1.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: release-cli
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/browseros-agent/apps/cli
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: packages/browseros-agent/apps/cli/go.mod
|
||||
|
||||
- name: Run tests
|
||||
run: go test ./... -v
|
||||
|
||||
- name: Run vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: Build all platforms
|
||||
run: |
|
||||
VERSION="${{ inputs.version }}"
|
||||
LDFLAGS="-s -w -X main.version=${VERSION}"
|
||||
DIST="dist"
|
||||
mkdir -p "$DIST"
|
||||
|
||||
for pair in darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64; do
|
||||
OS="${pair%/*}"
|
||||
ARCH="${pair#*/}"
|
||||
BIN="browseros-cli"
|
||||
EXT=""
|
||||
if [ "$OS" = "windows" ]; then EXT=".exe"; fi
|
||||
|
||||
echo "Building ${OS}/${ARCH}..."
|
||||
GOOS=$OS GOARCH=$ARCH CGO_ENABLED=0 go build -trimpath -ldflags "$LDFLAGS" -o "${DIST}/${BIN}${EXT}" .
|
||||
|
||||
ARCHIVE="browseros-cli_${VERSION}_${OS}_${ARCH}"
|
||||
if [ "$OS" = "windows" ]; then
|
||||
(cd "$DIST" && zip "${ARCHIVE}.zip" "${BIN}${EXT}")
|
||||
else
|
||||
(cd "$DIST" && tar czf "${ARCHIVE}.tar.gz" "${BIN}")
|
||||
fi
|
||||
rm "${DIST}/${BIN}${EXT}"
|
||||
done
|
||||
|
||||
(cd "$DIST" && sha256sum *.tar.gz *.zip > checksums.txt)
|
||||
echo "=== Built artifacts ==="
|
||||
ls -lh "$DIST"
|
||||
|
||||
- name: Generate release notes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
CLI_PATH="packages/browseros-agent/apps/cli"
|
||||
TAG="browseros-cli-v${{ inputs.version }}"
|
||||
CHANGELOG_FILE="/tmp/release-changelog.md"
|
||||
PREV_TAG=$(git tag -l "browseros-cli-v*" --sort=-v:refname | grep -v "^${TAG}$" | head -n 1)
|
||||
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
echo "Initial release of browseros-cli." > "$CHANGELOG_FILE"
|
||||
else
|
||||
COMMITS=$(git log "$PREV_TAG"..HEAD --pretty=format:"%H" -- "$CLI_PATH")
|
||||
|
||||
if [ -z "$COMMITS" ]; then
|
||||
echo "No notable changes." > "$CHANGELOG_FILE"
|
||||
else
|
||||
echo "## What's Changed" > "$CHANGELOG_FILE"
|
||||
echo "" >> "$CHANGELOG_FILE"
|
||||
|
||||
while IFS= read -r SHA; do
|
||||
SUBJECT=$(git log -1 --pretty=format:"%s" "$SHA")
|
||||
PR_NUM=$(gh api "/repos/${{ github.repository }}/commits/${SHA}/pulls" --jq '.[0].number // empty' 2>/dev/null)
|
||||
|
||||
if [ -n "$PR_NUM" ] && ! echo "$SUBJECT" | grep -qF "(#${PR_NUM})"; then
|
||||
echo "- ${SUBJECT} (#${PR_NUM})" >> "$CHANGELOG_FILE"
|
||||
else
|
||||
echo "- ${SUBJECT}" >> "$CHANGELOG_FILE"
|
||||
fi
|
||||
done <<< "$COMMITS"
|
||||
fi
|
||||
fi
|
||||
|
||||
cat > /tmp/release-notes.md <<'EOF'
|
||||
## Install `browseros-cli`
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
```bash
|
||||
curl -fsSL https://cdn.browseros.com/cli/install.sh | bash
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
```powershell
|
||||
irm https://cdn.browseros.com/cli/install.ps1 | iex
|
||||
```
|
||||
|
||||
After install, run `browseros-cli init` to point the CLI at your BrowserOS MCP server.
|
||||
EOF
|
||||
|
||||
echo "" >> /tmp/release-notes.md
|
||||
cat "$CHANGELOG_FILE" >> /tmp/release-notes.md
|
||||
working-directory: ${{ github.workspace }}
|
||||
|
||||
- name: Create tag and release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="browseros-cli-v${{ inputs.version }}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
if ! git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
git tag -a "$TAG" -m "browseros-cli v${{ inputs.version }}"
|
||||
git push origin "$TAG"
|
||||
fi
|
||||
|
||||
CLI_DIST="packages/browseros-agent/apps/cli/dist"
|
||||
gh release create "$TAG" \
|
||||
--title "browseros-cli v${{ inputs.version }}" \
|
||||
--notes-file /tmp/release-notes.md \
|
||||
${CLI_DIST}/*
|
||||
working-directory: ${{ github.workspace }}
|
||||
128
.github/workflows/test.yml
vendored
@@ -1,15 +1,44 @@
|
||||
name: Tests
|
||||
|
||||
on: []
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
paths:
|
||||
- .github/workflows/test.yml
|
||||
- packages/browseros-agent/**
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
BROWSEROS_APPIMAGE_URL: https://files.browseros.com/download/BrowserOS.AppImage
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run Tests
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 10
|
||||
name: Tests / ${{ matrix.suite }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/browseros-agent
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- suite: tools
|
||||
test_path: tests/tools
|
||||
junit_path: test-results/tools.xml
|
||||
- suite: integration
|
||||
test_path: tests/server.integration.test.ts
|
||||
junit_path: test-results/integration.xml
|
||||
- suite: sdk
|
||||
test_path: tests/sdk
|
||||
junit_path: test-results/sdk.xml
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -21,7 +50,92 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: bun ci
|
||||
|
||||
- name: Run all tests
|
||||
run: bun test:all
|
||||
- name: Resolve BrowserOS cache key
|
||||
id: browseros-cache-key
|
||||
run: |
|
||||
set -euo pipefail
|
||||
headers="$(curl -fsSI "$BROWSEROS_APPIMAGE_URL")"
|
||||
etag="$(printf '%s\n' "$headers" | awk 'BEGIN{IGNORECASE=1} /^etag:/ {sub(/\r$/, "", $2); gsub(/"/, "", $2); print $2; exit}')"
|
||||
last_modified="$(printf '%s\n' "$headers" | awk 'BEGIN{IGNORECASE=1} /^last-modified:/ {$1=""; sub(/^ /, ""); sub(/\r$/, ""); print; exit}')"
|
||||
raw_key="${etag:-$last_modified}"
|
||||
if [ -z "$raw_key" ]; then
|
||||
raw_key="$BROWSEROS_APPIMAGE_URL"
|
||||
fi
|
||||
cache_key="$(printf '%s' "$raw_key" | shasum -a 256 | awk '{print $1}')"
|
||||
echo "key=browseros-appimage-${{ runner.os }}-$cache_key" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore BrowserOS cache
|
||||
id: browseros-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: packages/browseros-agent/.ci/bin/BrowserOS.AppImage
|
||||
key: ${{ steps.browseros-cache-key.outputs.key }}
|
||||
|
||||
- name: Download BrowserOS
|
||||
if: steps.browseros-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
mkdir -p .ci/bin
|
||||
curl -fsSL "$BROWSEROS_APPIMAGE_URL" -o .ci/bin/BrowserOS.AppImage
|
||||
chmod +x .ci/bin/BrowserOS.AppImage
|
||||
|
||||
- name: Prepare BrowserOS wrapper
|
||||
run: |
|
||||
mkdir -p .ci/bin
|
||||
cat > .ci/bin/browseros <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
export APPIMAGE_EXTRACT_AND_RUN=1
|
||||
exec "$(dirname "$0")/BrowserOS.AppImage" "$@"
|
||||
EOF
|
||||
chmod +x .ci/bin/browseros
|
||||
|
||||
- name: Create server env file
|
||||
working-directory: packages/browseros-agent/apps/server
|
||||
run: cp .env.example .env.development
|
||||
|
||||
- name: Run ${{ matrix.suite }} tests
|
||||
id: test
|
||||
env:
|
||||
PUPPETEER_EXECUTABLE_PATH: /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
|
||||
BROWSEROS_BINARY: ${{ github.workspace }}/packages/browseros-agent/.ci/bin/browseros
|
||||
BROWSEROS_TEST_HEADLESS: "true"
|
||||
BROWSEROS_TEST_EXTRA_ARGS: --no-sandbox --disable-dev-shm-usage
|
||||
run: |
|
||||
set +e
|
||||
mkdir -p test-results
|
||||
cd apps/server
|
||||
bun run test:cleanup
|
||||
bun --env-file=.env.development test "${{ matrix.test_path }}" --reporter=junit --reporter-outfile="../../${{ matrix.junit_path }}"
|
||||
exit_code=$?
|
||||
cd ../..
|
||||
if [ ! -f "${{ matrix.junit_path }}" ]; then
|
||||
cat > "${{ matrix.junit_path }}" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<testsuites tests="1" failures="1">
|
||||
<testsuite name="${{ matrix.suite }}" tests="1" failures="1">
|
||||
<testcase classname="workflow" name="${{ matrix.suite }} setup">
|
||||
<failure message="Test run failed before JUnit output was written">See workflow logs for details.</failure>
|
||||
</testcase>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
EOF
|
||||
fi
|
||||
echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload JUnit XML
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: junit-${{ matrix.suite }}
|
||||
path: packages/browseros-agent/${{ matrix.junit_path }}
|
||||
|
||||
- name: Summarize suite result
|
||||
if: always()
|
||||
run: |
|
||||
if [ "${{ steps.test.outputs.exit_code }}" = "0" ]; then
|
||||
echo "### :white_check_mark: ${{ matrix.suite }} suite passed" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "### :x: ${{ matrix.suite }} suite failed (exit code ${{ steps.test.outputs.exit_code }})" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "See the uploaded \`junit-${{ matrix.suite }}\` artifact for details." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
4
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"terminal.integrated.tabs.title": "${sequence} ${process}",
|
||||
"terminal.integrated.tabs.description": "${cwd}"
|
||||
}
|
||||
19
README.md
@@ -43,6 +43,24 @@
|
||||
|
||||
4. Start automating!
|
||||
|
||||
## Install `browseros-cli`
|
||||
|
||||
Use `browseros-cli` when you want to control BrowserOS from the terminal or scripts via the BrowserOS MCP server.
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
```bash
|
||||
curl -fsSL https://cdn.browseros.com/cli/install.sh | bash
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
```powershell
|
||||
irm https://cdn.browseros.com/cli/install.ps1 | iex
|
||||
```
|
||||
|
||||
After install, run `browseros-cli init` to point the CLI at your BrowserOS MCP server.
|
||||
|
||||
## What makes BrowserOS special
|
||||
- 🏠 Feels like home — same Chrome interface, all your extensions just work
|
||||
- 🤖 AI agents that run on YOUR browser, not in the cloud
|
||||
@@ -164,4 +182,3 @@ Thank you to all our supporters!
|
||||
Built with ❤️ from San Francisco
|
||||
</p>
|
||||
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
"group": "Core Features",
|
||||
"pages": [
|
||||
"features/bring-your-own-llm",
|
||||
"features/chatgpt-pro-oauth",
|
||||
"features/github-copilot-oauth",
|
||||
"features/qwen-code-oauth",
|
||||
"features/local-models",
|
||||
"features/workflows",
|
||||
"features/scheduled-tasks",
|
||||
|
||||
@@ -13,6 +13,33 @@ See how to connect your own LLM in under a minute:
|
||||
src="https://pub-80f8a01e6e8b4239ae53a7652ef85877.r2.dev/resources/feature-videos/1-bring-your-own-LLM.mov"
|
||||
></video>
|
||||
|
||||
## Use Your Existing Subscription
|
||||
|
||||
Already paying for ChatGPT Pro, GitHub Copilot, or Qwen Code? Connect your existing account to BrowserOS with a single sign-in — no API keys, no extra cost.
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card href="/features/chatgpt-pro-oauth">
|
||||
<svg fill="currentColor" fillRule="evenodd" height="24" width="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"></path></svg>
|
||||
**ChatGPT Pro / Plus**
|
||||
|
||||
Sign in with your OpenAI account. Access GPT-5 Codex, GPT-5.4, and the full Codex lineup with up to 400K context.
|
||||
</Card>
|
||||
<Card href="/features/github-copilot-oauth">
|
||||
<svg fill="currentColor" fillRule="evenodd" height="24" width="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M19.245 5.364c1.322 1.36 1.877 3.216 2.11 5.817.622 0 1.2.135 1.592.654l.73.964c.21.278.323.61.323.955v2.62c0 .339-.173.669-.453.868C20.239 19.602 16.157 21.5 12 21.5c-4.6 0-9.205-2.583-11.547-4.258-.28-.2-.452-.53-.453-.868v-2.62c0-.345.113-.679.321-.956l.73-.963c.392-.517.974-.654 1.593-.654l.029-.297c.25-2.446.81-4.213 2.082-5.52 2.461-2.54 5.71-2.851 7.146-2.864h.198c1.436.013 4.685.323 7.146 2.864zm-7.244 4.328c-.284 0-.613.016-.962.05-.123.447-.305.85-.57 1.108-1.05 1.023-2.316 1.18-2.994 1.18-.638 0-1.306-.13-1.851-.464-.516.165-1.012.403-1.044.996a65.882 65.882 0 00-.063 2.884l-.002.48c-.002.563-.005 1.126-.013 1.69.002.326.204.63.51.765 2.482 1.102 4.83 1.657 6.99 1.657 2.156 0 4.504-.555 6.985-1.657a.854.854 0 00.51-.766c.03-1.682.006-3.372-.076-5.053-.031-.596-.528-.83-1.046-.996-.546.333-1.212.464-1.85.464-.677 0-1.942-.157-2.993-1.18-.266-.258-.447-.661-.57-1.108-.32-.032-.64-.049-.96-.05zm-2.525 4.013c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zm5 0c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zM7.635 5.087c-1.05.102-1.935.438-2.385.906-.975 1.037-.765 3.668-.21 4.224.405.394 1.17.657 1.995.657h.09c.649-.013 1.785-.176 2.73-1.11.435-.41.705-1.433.675-2.47-.03-.834-.27-1.52-.63-1.813-.39-.336-1.275-.482-2.265-.394zm6.465.394c-.36.292-.6.98-.63 1.813-.03 1.037.24 2.06.675 2.47.968.957 2.136 1.104 2.776 1.11h.044c.825 0 1.59-.263 1.995-.657.555-.556.765-3.187-.21-4.224-.45-.468-1.335-.804-2.385-.906-.99-.088-1.875.058-2.265.394zM12 7.615c-.24 0-.525.015-.84.044.03.16.045.336.06.526l-.001.159a2.94 2.94 0 01-.014.25c.225-.022.425-.027.612-.028h.366c.187 0 .387.006.612.028-.015-.146-.015-.277-.015-.409.015-.19.03-.365.06-.526a9.29 9.29 0 00-.84-.044z"></path></svg>
|
||||
**GitHub Copilot**
|
||||
|
||||
Sign in with your GitHub account. Access 19+ models including Claude, GPT-5, and Gemini through one subscription.
|
||||
</Card>
|
||||
<Card href="/features/qwen-code-oauth">
|
||||
<svg fill="currentColor" fillRule="evenodd" height="24" width="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z"></path></svg>
|
||||
**Qwen Code**
|
||||
|
||||
Sign in with your Qwen account. Access Qwen 3 Coder with a 1 million token context window.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
---
|
||||
|
||||
## Which Model Should I Use?
|
||||
|
||||
| Mode | What works | Recommendation |
|
||||
|
||||
56
docs/features/chatgpt-pro-oauth.mdx
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
title: "ChatGPT Pro / Plus"
|
||||
description: "Use your ChatGPT subscription to power BrowserOS"
|
||||
---
|
||||
|
||||
Connect your ChatGPT Pro or Plus subscription to BrowserOS and access GPT-5 Codex, GPT-5.4, and the full lineup of OpenAI's most advanced models — with up to 400K context. No API keys needed.
|
||||
|
||||
## Setup
|
||||
|
||||
**1.** Open BrowserOS and go to **Settings** (`chrome://browseros/settings`). You'll see the AI Providers section.
|
||||
|
||||

|
||||
|
||||
**2.** Click **USE** on the **ChatGPT Plus/Pro** card. You'll be prompted to sign in with your OpenAI account.
|
||||
|
||||

|
||||
|
||||
**3.** Sign in with the OpenAI account that has your ChatGPT Pro or Plus subscription active, and accept the authorization.
|
||||
|
||||

|
||||
|
||||
**4.** Once authorized, ChatGPT will appear as a provider in your settings. Select a model and start using it.
|
||||
|
||||
## Available Models
|
||||
|
||||
| Model | Context Window |
|
||||
|-------|---------------|
|
||||
| `gpt-5.4` | 400K |
|
||||
| `gpt-5.3-codex` | 400K |
|
||||
| `gpt-5.2-codex` | 400K |
|
||||
| `gpt-5.2` | 200K |
|
||||
| `gpt-5.1-codex` | 400K |
|
||||
| `gpt-5.1-codex-max` | 400K |
|
||||
| `gpt-5.1-codex-mini` | 400K |
|
||||
| `gpt-5.1` | 200K |
|
||||
|
||||
<Info>
|
||||
ChatGPT Pro subscribers have access to the full model lineup. ChatGPT Plus subscribers can access a subset of models depending on their plan. The available models will be shown automatically after you connect.
|
||||
</Info>
|
||||
|
||||
<Tip>
|
||||
The Codex models (e.g., `gpt-5.3-codex`) are optimized for code and reasoning tasks — ideal for complex browser automation workflows that involve form filling, data extraction, and multi-step navigation.
|
||||
</Tip>
|
||||
|
||||
## Reasoning Settings
|
||||
|
||||
ChatGPT Pro includes additional settings for models that support reasoning:
|
||||
|
||||
- **Reasoning Effort** — Control how much the model "thinks" before responding. Options: none, low, medium, high.
|
||||
- **Reasoning Summary** — Choose how reasoning is displayed. Options: auto, concise, detailed.
|
||||
|
||||
These settings are available in the provider configuration after connecting.
|
||||
|
||||
## Disconnecting
|
||||
|
||||
To disconnect your OpenAI account, go to **Settings**, find the ChatGPT Plus/Pro provider, and click **Disconnect**. Your OAuth tokens will be immediately deleted from your machine.
|
||||
60
docs/features/github-copilot-oauth.mdx
Normal file
@@ -0,0 +1,60 @@
|
||||
---
|
||||
title: "GitHub Copilot"
|
||||
description: "Use your GitHub Copilot subscription to power BrowserOS"
|
||||
---
|
||||
|
||||
Connect your GitHub Copilot subscription to BrowserOS and access 19+ models — including Claude, GPT-5, and Gemini — through a single GitHub sign-in. No API keys needed.
|
||||
|
||||
<Info>
|
||||
**Free tier** includes GPT-5 Mini, Claude Haiku 4.5, GPT-4o, and GPT-4.1. **Copilot Pro** ($10/month) unlocks Claude Sonnet 4.6, Claude Opus 4.6, Gemini 3 Pro, GPT-5.4, and more.
|
||||
</Info>
|
||||
|
||||
## Setup
|
||||
|
||||
**1.** Open BrowserOS and go to **Settings** (`chrome://browseros/settings`). You'll see the AI Providers section.
|
||||
|
||||

|
||||
|
||||
**2.** Click **USE** on the **GitHub Copilot** card. A device code will appear — copy it, then click the link to open GitHub's device authorization page.
|
||||
|
||||

|
||||
|
||||
**3.** Select your GitHub account to authorize.
|
||||
|
||||

|
||||
|
||||
**4.** Paste the device code and authorize BrowserOS to access your Copilot subscription.
|
||||
|
||||

|
||||
|
||||
**5.** Once authorized, GitHub Copilot will appear as a provider in your settings. Select a model and start using it.
|
||||
|
||||
## Available Models
|
||||
|
||||
### Free Tier
|
||||
| Model | Context Window |
|
||||
|-------|---------------|
|
||||
| `gpt-5-mini` | 128K |
|
||||
| `claude-haiku-4.5` | 128K |
|
||||
| `gpt-4o` | 64K |
|
||||
| `gpt-4.1` | 64K |
|
||||
|
||||
### Copilot Pro / Pro+
|
||||
| Model | Context Window |
|
||||
|-------|---------------|
|
||||
| `claude-sonnet-4.6` | 200K |
|
||||
| `claude-opus-4.6` | 200K |
|
||||
| `gemini-2.5-pro` | 1M |
|
||||
| `gemini-3-pro-preview` | 1M |
|
||||
| `gpt-5.4` | 400K |
|
||||
| `gpt-5.3-codex` | 400K |
|
||||
| `gpt-5.2-codex` | 400K |
|
||||
| `grok-code-fast-1` | 128K |
|
||||
|
||||
<Tip>
|
||||
GitHub Copilot is the most versatile provider — one subscription gives you access to models from OpenAI, Anthropic, Google, and xAI. Great if you want to switch between models for different tasks.
|
||||
</Tip>
|
||||
|
||||
## Disconnecting
|
||||
|
||||
To disconnect your GitHub account, go to **Settings**, find the GitHub Copilot provider, and click **Disconnect**. Your OAuth tokens will be immediately deleted from your machine.
|
||||
39
docs/features/qwen-code-oauth.mdx
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: "Qwen Code"
|
||||
description: "Use your Qwen Code account to power BrowserOS"
|
||||
---
|
||||
|
||||
Connect your Qwen Code account to BrowserOS and access Alibaba's coding models with up to a **1 million token context window** — the largest of any provider we support. No API keys needed.
|
||||
|
||||
## Setup
|
||||
|
||||
**1.** Open BrowserOS and go to **Settings** (`chrome://browseros/settings`). You'll see the AI Providers section.
|
||||
|
||||

|
||||
|
||||
**2.** Click **USE** on the **Qwen Code** card. You'll be prompted to sign in with your Qwen account.
|
||||
|
||||

|
||||
|
||||
**3.** Sign in with your Alibaba Cloud / Qwen account to authorize BrowserOS.
|
||||
|
||||

|
||||
|
||||
**4.** Once authorized, Qwen Code will appear as a provider in your settings. Select a model and start using it.
|
||||
|
||||
## Available Models
|
||||
|
||||
| Model | Context Window |
|
||||
|-------|---------------|
|
||||
| `coder-model` | 1M |
|
||||
| `qwen3-coder-plus` | 1M |
|
||||
| `qwen3-coder-flash` | 1M |
|
||||
| `qwen3.5-plus` | 1M |
|
||||
|
||||
<Tip>
|
||||
Qwen Code's 1 million token context window is ideal for tasks that involve long documents, entire documentation sites, or working across many browser tabs simultaneously — the agent can hold everything in context at once.
|
||||
</Tip>
|
||||
|
||||
## Disconnecting
|
||||
|
||||
To disconnect your Qwen account, go to **Settings**, find the Qwen Code provider, and click **Disconnect**. Your OAuth tokens will be immediately deleted from your machine.
|
||||
1
docs/images/icons/githubcopilot.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>GithubCopilot</title><path d="M19.245 5.364c1.322 1.36 1.877 3.216 2.11 5.817.622 0 1.2.135 1.592.654l.73.964c.21.278.323.61.323.955v2.62c0 .339-.173.669-.453.868C20.239 19.602 16.157 21.5 12 21.5c-4.6 0-9.205-2.583-11.547-4.258-.28-.2-.452-.53-.453-.868v-2.62c0-.345.113-.679.321-.956l.73-.963c.392-.517.974-.654 1.593-.654l.029-.297c.25-2.446.81-4.213 2.082-5.52 2.461-2.54 5.71-2.851 7.146-2.864h.198c1.436.013 4.685.323 7.146 2.864zm-7.244 4.328c-.284 0-.613.016-.962.05-.123.447-.305.85-.57 1.108-1.05 1.023-2.316 1.18-2.994 1.18-.638 0-1.306-.13-1.851-.464-.516.165-1.012.403-1.044.996a65.882 65.882 0 00-.063 2.884l-.002.48c-.002.563-.005 1.126-.013 1.69.002.326.204.63.51.765 2.482 1.102 4.83 1.657 6.99 1.657 2.156 0 4.504-.555 6.985-1.657a.854.854 0 00.51-.766c.03-1.682.006-3.372-.076-5.053-.031-.596-.528-.83-1.046-.996-.546.333-1.212.464-1.85.464-.677 0-1.942-.157-2.993-1.18-.266-.258-.447-.661-.57-1.108-.32-.032-.64-.049-.96-.05zm-2.525 4.013c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zm5 0c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zM7.635 5.087c-1.05.102-1.935.438-2.385.906-.975 1.037-.765 3.668-.21 4.224.405.394 1.17.657 1.995.657h.09c.649-.013 1.785-.176 2.73-1.11.435-.41.705-1.433.675-2.47-.03-.834-.27-1.52-.63-1.813-.39-.336-1.275-.482-2.265-.394zm6.465.394c-.36.292-.6.98-.63 1.813-.03 1.037.24 2.06.675 2.47.968.957 2.136 1.104 2.776 1.11h.044c.825 0 1.59-.263 1.995-.657.555-.556.765-3.187-.21-4.224-.45-.468-1.335-.804-2.385-.906-.99-.088-1.875.058-2.265.394zM12 7.615c-.24 0-.525.015-.84.044.03.16.045.336.06.526l-.001.159a2.94 2.94 0 01-.014.25c.225-.022.425-.027.612-.028h.366c.187 0 .387.006.612.028-.015-.146-.015-.277-.015-.409.015-.19.03-.365.06-.526a9.29 9.29 0 00-.84-.044z"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
1
docs/images/icons/openai.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
1
docs/images/icons/qwen.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qwen</title><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
BIN
docs/images/setting-up-chatgpt/accept-screen.png
Normal file
|
After Width: | Height: | Size: 717 KiB |
BIN
docs/images/setting-up-chatgpt/llm-screen.png
Normal file
|
After Width: | Height: | Size: 815 KiB |
BIN
docs/images/setting-up-chatgpt/login-screen.png
Normal file
|
After Width: | Height: | Size: 637 KiB |
BIN
docs/images/setting-up-copilot/authorize-device.png
Normal file
|
After Width: | Height: | Size: 687 KiB |
BIN
docs/images/setting-up-copilot/device-code.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
docs/images/setting-up-copilot/llm-screen.png
Normal file
|
After Width: | Height: | Size: 825 KiB |
BIN
docs/images/setting-up-copilot/select-account.png
Normal file
|
After Width: | Height: | Size: 634 KiB |
BIN
docs/images/setting-up-qwen/llm-screen.png
Normal file
|
After Width: | Height: | Size: 837 KiB |
BIN
docs/images/setting-up-qwen/qwen-signin.png
Normal file
|
After Width: | Height: | Size: 712 KiB |
BIN
docs/images/setting-up-qwen/select-qwen.png
Normal file
|
After Width: | Height: | Size: 843 KiB |
5
packages/browseros-agent/.gitignore
vendored
@@ -187,7 +187,12 @@ log.txt
|
||||
# Testing iteration temp files
|
||||
tmp/
|
||||
|
||||
# CI artifacts
|
||||
.ci/
|
||||
test-results/
|
||||
|
||||
# Coding agent artifacts
|
||||
.agent/
|
||||
.llm/
|
||||
.grove/
|
||||
docs/plans/2026-03-24-models-dev-integration.md
|
||||
|
||||
@@ -81,6 +81,9 @@ bun run dev:server # Build server for development
|
||||
bun run dev:ext # Build extension for development
|
||||
bun run dist:server # Build server for production (all targets)
|
||||
bun run dist:ext # Build extension for production
|
||||
|
||||
# Refresh models.dev data
|
||||
bun run generate:models # Fetches latest from models.dev/api.json
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
6
packages/browseros-agent/apps/agent/CHANGELOG.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# BrowserOS Agent Extension
|
||||
|
||||
## v0.0.52 (2026-03-26)
|
||||
|
||||
Initial release
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -66,7 +66,7 @@ export const RunResultDialog: FC<RunResultDialogProps> = ({
|
||||
|
||||
return (
|
||||
<Dialog open={!!run} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogContent className="sm:w-[70vw] sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{run.status === 'completed' ? (
|
||||
@@ -94,7 +94,7 @@ export const RunResultDialog: FC<RunResultDialogProps> = ({
|
||||
<p className="text-destructive text-sm">{run.result}</p>
|
||||
</div>
|
||||
) : run.result ? (
|
||||
<div className="prose prose-sm dark:prose-invert [&_[data-streamdown='code-block']]:!w-full [&_[data-streamdown='table-wrapper']]:!w-full max-w-none break-words rounded-lg border border-border bg-muted/50 p-4">
|
||||
<div className="prose prose-sm dark:prose-invert [&_[data-streamdown='code-block']]:!w-full [&_[data-streamdown='table-wrapper']]:!w-full max-w-none break-words rounded-lg border border-border bg-muted/50 p-4 [&_[data-streamdown='table-wrapper']]:overflow-x-auto">
|
||||
<MessageResponse>{run.result}</MessageResponse>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Check } from 'lucide-react'
|
||||
import { Check, Plus } from 'lucide-react'
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
@@ -77,6 +77,19 @@ export const ChatProviderSelector: FC<
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
<div className="border-border border-t p-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 rounded-md p-2 text-muted-foreground text-sm transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={() => {
|
||||
window.open('/app.html#/settings/ai', '_blank')
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Provider
|
||||
</button>
|
||||
</div>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
|
||||
@@ -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 cursor-pointer 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,21 +17,28 @@ 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 { DeviceCodeDialog } from './DeviceCodeDialog'
|
||||
import {
|
||||
DeleteRemoteLlmProviderDocument,
|
||||
GetRemoteLlmProvidersDocument,
|
||||
@@ -39,9 +46,51 @@ import {
|
||||
import type { IncompleteProvider } from './IncompleteProviderCard'
|
||||
import { IncompleteProvidersList } from './IncompleteProvidersList'
|
||||
import { LlmProvidersHeader } from './LlmProvidersHeader'
|
||||
import { McpPromoBanner } from './McpPromoBanner'
|
||||
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 +137,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 +158,57 @@ 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)
|
||||
const activeDeviceCode =
|
||||
chatgptPro.pendingDeviceCode ??
|
||||
copilot.pendingDeviceCode ??
|
||||
qwenCode.pendingDeviceCode
|
||||
const clearActiveDeviceCode = () => {
|
||||
chatgptPro.clearDeviceCode()
|
||||
copilot.clearDeviceCode()
|
||||
qwenCode.clearDeviceCode()
|
||||
}
|
||||
|
||||
// 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 +216,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 +235,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 +245,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) => {
|
||||
@@ -329,6 +359,8 @@ export const AISettingsPage: FC = () => {
|
||||
onAddProvider={handleAddProvider}
|
||||
/>
|
||||
|
||||
<McpPromoBanner />
|
||||
|
||||
<ProviderTemplatesSection onUseTemplate={handleUseTemplate} />
|
||||
|
||||
<ConfiguredProvidersList
|
||||
@@ -403,6 +435,11 @@ export const AISettingsPage: FC = () => {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<DeviceCodeDialog
|
||||
deviceCode={activeDeviceCode}
|
||||
onClose={clearActiveDeviceCode}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Check, Copy, ExternalLink } from 'lucide-react'
|
||||
import { type FC, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import type { PendingDeviceCode } from '@/lib/llm-providers/useOAuthProviderFlow'
|
||||
|
||||
interface DeviceCodeDialogProps {
|
||||
deviceCode: PendingDeviceCode | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const DeviceCodeDialog: FC<DeviceCodeDialogProps> = ({
|
||||
deviceCode,
|
||||
onClose,
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!deviceCode) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(deviceCode.userCode)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch {
|
||||
// Clipboard API failed
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={!!deviceCode} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connect to {deviceCode?.providerName}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Paste this code on the {deviceCode?.providerName} page that just
|
||||
opened in your browser.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col items-center gap-4 py-4">
|
||||
<div className="flex items-center gap-3 rounded-xl border-2 border-[var(--accent-orange)]/40 border-dashed bg-[var(--accent-orange)]/5 px-6 py-4">
|
||||
<code className="font-bold font-mono text-2xl text-foreground tracking-widest">
|
||||
{deviceCode?.userCode}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleCopy}
|
||||
className="shrink-0"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-center text-muted-foreground text-xs">
|
||||
This dialog will close automatically once authentication completes.
|
||||
</p>
|
||||
{deviceCode?.verificationUri && (
|
||||
<a
|
||||
href={deviceCode.verificationUri}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-[var(--accent-orange)] text-xs transition-colors hover:underline"
|
||||
>
|
||||
Open verification page
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ArrowRight, Server, X } from 'lucide-react'
|
||||
import { type FC, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { MCP_PROMO_BANNER_CLICKED_EVENT } from '@/lib/constants/analyticsEvents'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
|
||||
export const McpPromoBanner: FC = () => {
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
if (dismissed) return null
|
||||
|
||||
const handleClick = () => {
|
||||
track(MCP_PROMO_BANNER_CLICKED_EVENT)
|
||||
navigate('/settings/mcp')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 rounded-xl border border-border bg-card p-4 shadow-sm transition-all hover:shadow-md">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-orange)]/10">
|
||||
<Server className="h-5 w-5 text-[var(--accent-orange)]" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex items-center gap-2 font-semibold text-sm">
|
||||
Use BrowserOS with Claude Code, Cursor & more
|
||||
<span className="text-[var(--accent-orange)] text-xs">
|
||||
(66+ tools)
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--accent-orange)]/10 px-2.5 py-1 font-semibold text-[var(--accent-orange)] text-xs">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-[var(--accent-orange)]" />
|
||||
New
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Connect your favorite coding tools to BrowserOS as an MCP server
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClick}
|
||||
className="shrink-0 border-[var(--accent-orange)] bg-[var(--accent-orange)]/10 text-[var(--accent-orange)] hover:bg-[var(--accent-orange)]/20 hover:text-[var(--accent-orange)]"
|
||||
>
|
||||
Set up
|
||||
<ArrowRight className="ml-1 h-3 w-3" />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDismissed(true)}
|
||||
className="shrink-0 rounded-sm p-1 text-muted-foreground opacity-50 transition-opacity hover:opacity-100"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { CheckCircle2, ExternalLink, Loader2, XCircle } from 'lucide-react'
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
SearchIcon,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import { type FC, useEffect, useRef, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod/v3'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -47,7 +54,12 @@ import {
|
||||
import { type TestResult, testProvider } from '@/lib/llm-providers/testProvider'
|
||||
import type { LlmProviderConfig, ProviderType } from '@/lib/llm-providers/types'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import { getModelContextLength, getModelOptions } from './models'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
getModelContextLength,
|
||||
getModelsForProvider,
|
||||
type ModelInfo,
|
||||
} from './models'
|
||||
|
||||
const providerTypeEnum = z.enum([
|
||||
'moonshot',
|
||||
@@ -62,6 +74,8 @@ const providerTypeEnum = z.enum([
|
||||
'bedrock',
|
||||
'browseros',
|
||||
'chatgpt-pro',
|
||||
'github-copilot',
|
||||
'qwen-code',
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -131,8 +145,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
|
||||
@@ -157,6 +175,107 @@ export const providerFormSchema = z
|
||||
*/
|
||||
export type ProviderFormValues = z.infer<typeof providerFormSchema>
|
||||
|
||||
function formatContextWindow(tokens: number): string {
|
||||
if (tokens >= 1000000)
|
||||
return `${(tokens / 1000000).toFixed(tokens % 1000000 === 0 ? 0 : 1)}M`
|
||||
if (tokens >= 1000) return `${Math.round(tokens / 1000)}K`
|
||||
return `${tokens}`
|
||||
}
|
||||
|
||||
function ModelPickerList({
|
||||
models,
|
||||
selectedModelId,
|
||||
onSelect,
|
||||
onCustomSubmit,
|
||||
onClose,
|
||||
}: {
|
||||
models: ModelInfo[]
|
||||
selectedModelId: string
|
||||
onSelect: (modelId: string) => void
|
||||
onCustomSubmit: (modelId: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [search, setSearch] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [onClose])
|
||||
|
||||
const query = search.toLowerCase()
|
||||
const filtered = query
|
||||
? models.filter((m) => m.modelId.toLowerCase().includes(query))
|
||||
: models
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && search) {
|
||||
e.preventDefault()
|
||||
onCustomSubmit(search)
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="rounded-md border">
|
||||
<div className="flex items-center gap-2 border-b px-3">
|
||||
<SearchIcon className="h-4 w-4 shrink-0 text-muted-foreground opacity-50" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search or type a custom model ID..."
|
||||
className="flex h-9 w-full bg-transparent py-2 text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[200px] overflow-y-auto">
|
||||
{filtered.length > 0 ? (
|
||||
filtered.map((model) => {
|
||||
const isSelected = selectedModelId === model.modelId
|
||||
return (
|
||||
<button
|
||||
key={model.modelId}
|
||||
type="button"
|
||||
onClick={() => onSelect(model.modelId)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between px-3 py-2 text-left text-sm transition-colors hover:bg-accent',
|
||||
isSelected && 'bg-accent font-medium',
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{model.modelId}</span>
|
||||
<span className="ml-2 shrink-0 rounded-md bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||||
{formatContextWindow(model.contextLength)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="px-3 py-6 text-center text-muted-foreground text-sm">
|
||||
No models match. Press Enter to use "{search}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for NewProviderDialog
|
||||
* @public
|
||||
@@ -182,14 +301,19 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
initialValues,
|
||||
onSave,
|
||||
}) => {
|
||||
const [isCustomModel, setIsCustomModel] = useState(false)
|
||||
const [isTesting, setIsTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState<TestResult | null>(null)
|
||||
const [modelListOpen, setModelListOpen] = useState(false)
|
||||
const { supports } = useCapabilities()
|
||||
const { baseUrl: agentServerUrl } = useAgentServerUrl()
|
||||
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') {
|
||||
@@ -250,8 +374,7 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
watchedSessionToken,
|
||||
])
|
||||
|
||||
// Get model options for current provider type
|
||||
const modelOptions = getModelOptions(watchedType as ProviderType)
|
||||
const modelInfoList = getModelsForProvider(watchedType as ProviderType)
|
||||
|
||||
// Handle provider type change (user-initiated via Select)
|
||||
const handleTypeChange = (newType: ProviderType) => {
|
||||
@@ -261,14 +384,13 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
form.setValue('baseUrl', defaultUrl)
|
||||
}
|
||||
form.setValue('modelId', '')
|
||||
setIsCustomModel(false)
|
||||
}
|
||||
|
||||
// Auto-fill context window when model changes (only for new providers)
|
||||
useEffect(() => {
|
||||
if (initialValues?.id) return
|
||||
|
||||
if (watchedModelId && watchedModelId !== 'custom') {
|
||||
if (watchedModelId) {
|
||||
const contextLength = getModelContextLength(
|
||||
watchedType as ProviderType,
|
||||
watchedModelId,
|
||||
@@ -279,17 +401,6 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
}
|
||||
}, [watchedModelId, watchedType, form, initialValues?.id])
|
||||
|
||||
// Handle model selection (including custom option)
|
||||
const handleModelChange = (value: string) => {
|
||||
if (value === 'custom') {
|
||||
setIsCustomModel(true)
|
||||
form.setValue('modelId', '')
|
||||
} else {
|
||||
setIsCustomModel(false)
|
||||
form.setValue('modelId', value)
|
||||
}
|
||||
}
|
||||
|
||||
// Reset form when initialValues change
|
||||
useEffect(() => {
|
||||
if (initialValues) {
|
||||
@@ -314,7 +425,6 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
reasoningEffort: initialValues.reasoningEffort || 'high',
|
||||
reasoningSummary: initialValues.reasoningSummary || 'auto',
|
||||
})
|
||||
setIsCustomModel(false)
|
||||
}
|
||||
}, [initialValues, form])
|
||||
|
||||
@@ -341,7 +451,6 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
reasoningEffort: 'high',
|
||||
reasoningSummary: 'auto',
|
||||
})
|
||||
setIsCustomModel(false)
|
||||
}
|
||||
// Clear test result when dialog opens/closes
|
||||
setTestResult(null)
|
||||
@@ -377,8 +486,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 +575,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 (
|
||||
@@ -786,52 +909,51 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
control={form.control}
|
||||
name="modelId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>Model *</FormLabel>
|
||||
{isCustomModel || modelOptions.length === 1 ? (
|
||||
<>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={
|
||||
watchedType === 'azure'
|
||||
? 'Enter your deployment name'
|
||||
: watchedType === 'bedrock'
|
||||
? 'e.g., anthropic.claude-3-5-sonnet-20241022-v2:0'
|
||||
: 'Enter custom model ID'
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
{modelOptions.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-auto p-0 text-xs"
|
||||
onClick={() => setIsCustomModel(false)}
|
||||
>
|
||||
← Back to model list
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
{modelInfoList.length === 0 ? (
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={
|
||||
watchedType === 'azure'
|
||||
? 'Enter your deployment name'
|
||||
: watchedType === 'bedrock'
|
||||
? 'e.g., anthropic.claude-3-5-sonnet-20241022-v2:0'
|
||||
: 'Enter model ID'
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
) : modelListOpen ? (
|
||||
<ModelPickerList
|
||||
models={modelInfoList}
|
||||
selectedModelId={field.value}
|
||||
onSelect={(modelId) => {
|
||||
form.setValue('modelId', modelId)
|
||||
setModelListOpen(false)
|
||||
}}
|
||||
onCustomSubmit={(modelId) => {
|
||||
form.setValue('modelId', modelId)
|
||||
setModelListOpen(false)
|
||||
}}
|
||||
onClose={() => setModelListOpen(false)}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
onValueChange={handleModelChange}
|
||||
value={field.value}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModelListOpen(true)}
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs',
|
||||
field.value
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{modelOptions.map((modelId) => (
|
||||
<SelectItem key={modelId} value={modelId}>
|
||||
{modelId === 'custom' ? '+ Custom model' : modelId}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="truncate">
|
||||
{field.value || 'Select a model...'}
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</button>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -7,12 +7,14 @@ import { cn } from '@/lib/utils'
|
||||
interface ProviderTemplateCardProps {
|
||||
template: ProviderTemplate
|
||||
highlighted?: boolean
|
||||
isNew?: boolean
|
||||
onUseTemplate: (template: ProviderTemplate) => void
|
||||
}
|
||||
|
||||
export const ProviderTemplateCard: FC<ProviderTemplateCardProps> = ({
|
||||
template,
|
||||
highlighted = false,
|
||||
isNew = false,
|
||||
onUseTemplate,
|
||||
}) => {
|
||||
return (
|
||||
@@ -20,12 +22,19 @@ export const ProviderTemplateCard: FC<ProviderTemplateCardProps> = ({
|
||||
type="button"
|
||||
onClick={() => onUseTemplate(template)}
|
||||
className={cn(
|
||||
'group flex w-full items-center gap-3 rounded-lg border bg-background p-4 text-left transition-all hover:border-[var(--accent-orange)] hover:shadow-md',
|
||||
'group relative flex w-full items-center gap-3 rounded-lg border bg-background p-4 text-left transition-all hover:border-[var(--accent-orange)] hover:shadow-md',
|
||||
highlighted
|
||||
? 'border-orange-300/80 bg-orange-50/30 shadow-sm ring-1 ring-orange-300/45 dark:bg-orange-500/5'
|
||||
: 'border-border',
|
||||
: isNew
|
||||
? 'border-2 border-[var(--accent-orange)]/50'
|
||||
: 'border-border',
|
||||
)}
|
||||
>
|
||||
{isNew && (
|
||||
<span className="absolute -top-2 left-3 rounded-full bg-[var(--accent-orange)] px-2 py-0.5 font-semibold text-[9px] text-white uppercase tracking-wider">
|
||||
New
|
||||
</span>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<ProviderIcon
|
||||
type={template.id}
|
||||
|
||||
@@ -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)
|
||||
@@ -53,14 +58,21 @@ export const ProviderTemplatesSection: FC<ProviderTemplatesSectionProps> = ({
|
||||
|
||||
<CollapsibleContent>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredTemplates.map((template) => (
|
||||
<ProviderTemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
highlighted={template.id === 'moonshot'}
|
||||
onUseTemplate={onUseTemplate}
|
||||
/>
|
||||
))}
|
||||
{filteredTemplates.map((template) => {
|
||||
const isNew =
|
||||
template.id === 'chatgpt-pro' ||
|
||||
template.id === 'github-copilot' ||
|
||||
template.id === 'qwen-code'
|
||||
return (
|
||||
<ProviderTemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
highlighted={template.id === 'moonshot'}
|
||||
isNew={isNew}
|
||||
onUseTemplate={onUseTemplate}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
|
||||
@@ -1,96 +1,21 @@
|
||||
import {
|
||||
getModelsDevModels,
|
||||
type ModelsDevModel,
|
||||
} from '@/lib/llm-providers/models-dev'
|
||||
import type { ProviderType } from '@/lib/llm-providers/types'
|
||||
|
||||
/**
|
||||
* Model information with context length
|
||||
*/
|
||||
export interface ModelInfo {
|
||||
modelId: string
|
||||
contextLength: number
|
||||
supportsImages?: boolean
|
||||
supportsReasoning?: boolean
|
||||
supportsToolCall?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Models data organized by provider type (matches backend AIProvider enum)
|
||||
*/
|
||||
export interface ModelsData {
|
||||
anthropic: ModelInfo[]
|
||||
openai: ModelInfo[]
|
||||
'openai-compatible': ModelInfo[]
|
||||
google: ModelInfo[]
|
||||
openrouter: ModelInfo[]
|
||||
azure: ModelInfo[]
|
||||
ollama: ModelInfo[]
|
||||
lmstudio: ModelInfo[]
|
||||
bedrock: ModelInfo[]
|
||||
browseros: ModelInfo[]
|
||||
moonshot: ModelInfo[]
|
||||
'chatgpt-pro': ModelInfo[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Available models per provider with context lengths
|
||||
* Based on: https://github.com/browseros-ai/BrowserOS-agent/blob/main/src/options/data/models.ts
|
||||
*/
|
||||
export const MODELS_DATA: ModelsData = {
|
||||
moonshot: [{ modelId: 'kimi-k2.5', contextLength: 200000 }],
|
||||
anthropic: [
|
||||
{ modelId: 'claude-opus-4-5-20251101', contextLength: 200000 },
|
||||
{ modelId: 'claude-haiku-4-5-20251001', contextLength: 200000 },
|
||||
{ modelId: 'claude-sonnet-4-5-20250929', contextLength: 200000 },
|
||||
{ modelId: 'claude-sonnet-4-20250514', contextLength: 200000 },
|
||||
{ modelId: 'claude-opus-4-20250514', contextLength: 200000 },
|
||||
{ modelId: 'claude-3-7-sonnet-20250219', contextLength: 200000 },
|
||||
{ modelId: 'claude-3-5-haiku-20241022', contextLength: 200000 },
|
||||
],
|
||||
openai: [
|
||||
{ modelId: 'gpt-5.2', contextLength: 200000 },
|
||||
{ modelId: 'gpt-5.2-pro', contextLength: 200000 },
|
||||
{ modelId: 'gpt-5', contextLength: 200000 },
|
||||
{ modelId: 'gpt-5-mini', contextLength: 200000 },
|
||||
{ modelId: 'gpt-5-nano', contextLength: 200000 },
|
||||
{ modelId: 'gpt-4.1', contextLength: 200000 },
|
||||
{ modelId: 'gpt-4.1-mini', contextLength: 200000 },
|
||||
{ modelId: 'o4-mini', contextLength: 200000 },
|
||||
{ modelId: 'o3-mini', contextLength: 200000 },
|
||||
{ modelId: 'gpt-4o', contextLength: 128000 },
|
||||
{ modelId: 'gpt-4o-mini', contextLength: 128000 },
|
||||
],
|
||||
'openai-compatible': [],
|
||||
google: [
|
||||
{ modelId: 'gemini-3-pro-preview', contextLength: 1048576 },
|
||||
{ modelId: 'gemini-3-flash-preview', contextLength: 1048576 },
|
||||
{ modelId: 'gemini-2.5-flash', contextLength: 1048576 },
|
||||
{ modelId: 'gemini-2.5-pro', contextLength: 1048576 },
|
||||
],
|
||||
openrouter: [
|
||||
{ modelId: 'google/gemini-3-pro-preview', contextLength: 1048576 },
|
||||
{ modelId: 'google/gemini-3-flash-preview', contextLength: 1048576 },
|
||||
{ modelId: 'google/gemini-2.5-flash', contextLength: 1048576 },
|
||||
{ modelId: 'anthropic/claude-opus-4.5', contextLength: 200000 },
|
||||
{ modelId: 'anthropic/claude-haiku-4.5', contextLength: 200000 },
|
||||
{ modelId: 'anthropic/claude-sonnet-4.5', contextLength: 200000 },
|
||||
{ modelId: 'anthropic/claude-sonnet-4', contextLength: 200000 },
|
||||
{ modelId: 'anthropic/claude-3.7-sonnet', contextLength: 200000 },
|
||||
{ modelId: 'openai/gpt-4o', contextLength: 128000 },
|
||||
{ modelId: 'openai/gpt-oss-120b', contextLength: 128000 },
|
||||
{ modelId: 'openai/gpt-oss-20b', contextLength: 128000 },
|
||||
{ modelId: 'qwen/qwen3-14b', contextLength: 131072 },
|
||||
{ modelId: 'qwen/qwen3-8b', contextLength: 131072 },
|
||||
],
|
||||
azure: [],
|
||||
ollama: [
|
||||
{ modelId: 'qwen3:4b', contextLength: 262144 },
|
||||
{ modelId: 'qwen3:8b', contextLength: 40960 },
|
||||
{ modelId: 'qwen3:14b', contextLength: 40960 },
|
||||
{ modelId: 'gpt-oss:20b', contextLength: 128000 },
|
||||
{ modelId: 'gpt-oss:120b', contextLength: 128000 },
|
||||
],
|
||||
lmstudio: [
|
||||
{ modelId: 'openai/gpt-oss-20b', contextLength: 128000 },
|
||||
{ modelId: 'openai/gpt-oss-120b', contextLength: 128000 },
|
||||
{ modelId: 'qwen/qwen3-vl-8b', contextLength: 131072 },
|
||||
],
|
||||
bedrock: [],
|
||||
const CUSTOM_PROVIDER_MODELS: Partial<Record<ProviderType, ModelInfo[]>> = {
|
||||
browseros: [{ modelId: 'browseros-auto', contextLength: 200000 }],
|
||||
'openai-compatible': [],
|
||||
ollama: [],
|
||||
'chatgpt-pro': [
|
||||
{ modelId: 'gpt-5.4', contextLength: 400000 },
|
||||
{ modelId: 'gpt-5.3-codex', contextLength: 400000 },
|
||||
@@ -101,27 +26,31 @@ export const MODELS_DATA: ModelsData = {
|
||||
{ modelId: 'gpt-5.1-codex-mini', contextLength: 400000 },
|
||||
{ modelId: 'gpt-5.1', contextLength: 200000 },
|
||||
],
|
||||
'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 },
|
||||
],
|
||||
}
|
||||
|
||||
function fromModelsDevModel(m: ModelsDevModel): ModelInfo {
|
||||
return {
|
||||
modelId: m.id,
|
||||
contextLength: m.contextWindow,
|
||||
supportsImages: m.supportsImages,
|
||||
supportsReasoning: m.supportsReasoning,
|
||||
supportsToolCall: m.supportsToolCall,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get models for a specific provider type
|
||||
*/
|
||||
export function getModelsForProvider(providerType: ProviderType): ModelInfo[] {
|
||||
return MODELS_DATA[providerType] || []
|
||||
const custom = CUSTOM_PROVIDER_MODELS[providerType]
|
||||
if (custom !== undefined) return custom
|
||||
|
||||
return getModelsDevModels(providerType).map(fromModelsDevModel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model options for select dropdown (model IDs + custom option)
|
||||
*/
|
||||
export function getModelOptions(providerType: ProviderType): string[] {
|
||||
const models = getModelsForProvider(providerType)
|
||||
const modelIds = models.map((m) => m.modelId)
|
||||
return modelIds.length > 0 ? [...modelIds, 'custom'] : ['custom']
|
||||
}
|
||||
|
||||
/**
|
||||
* Get context length for a specific model
|
||||
*/
|
||||
export function getModelContextLength(
|
||||
providerType: ProviderType,
|
||||
modelId: string,
|
||||
@@ -130,14 +59,3 @@ export function getModelContextLength(
|
||||
const model = models.find((m) => m.modelId === modelId)
|
||||
return model?.contextLength
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if model ID is a custom (user-entered) value
|
||||
*/
|
||||
export function isCustomModel(
|
||||
providerType: ProviderType,
|
||||
modelId: string,
|
||||
): boolean {
|
||||
const models = getModelsForProvider(providerType)
|
||||
return !models.some((m) => m.modelId === modelId)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -259,11 +259,23 @@ export const CreateGraph: FC = () => {
|
||||
})
|
||||
|
||||
const onClickTest = async () => {
|
||||
const backgroundWindow = await chrome.windows.create({
|
||||
url: 'chrome://newtab',
|
||||
focused: true,
|
||||
type: 'normal',
|
||||
})
|
||||
let backgroundWindow: chrome.windows.Window | undefined
|
||||
try {
|
||||
backgroundWindow = await chrome.windows.create({
|
||||
url: 'chrome://newtab',
|
||||
focused: true,
|
||||
type: 'normal',
|
||||
})
|
||||
} catch {
|
||||
// Fallback when no window context is available (e.g. all windows closed)
|
||||
const tab = await chrome.tabs.create({
|
||||
url: 'chrome://newtab',
|
||||
active: true,
|
||||
})
|
||||
if (tab.windowId) {
|
||||
backgroundWindow = await chrome.windows.get(tab.windowId)
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage({
|
||||
text: 'Run a test of the graph you just created.',
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { resetIdentity } from '@/lib/analytics/identify'
|
||||
import { signOut } from '@/lib/auth/auth-client'
|
||||
import { providersStorage } from '@/lib/llm-providers/storage'
|
||||
import { scheduledJobStorage } from '@/lib/schedules/scheduleStorage'
|
||||
@@ -26,6 +27,7 @@ export const LogoutPage: FC = () => {
|
||||
queryClient.clear()
|
||||
await localforage.clear()
|
||||
|
||||
resetIdentity()
|
||||
await signOut()
|
||||
navigate('/home', { replace: true })
|
||||
}
|
||||
|
||||
@@ -1,31 +1,40 @@
|
||||
import { Check, Copy, ExternalLink, Globe, Server } from 'lucide-react'
|
||||
import { type FC, useState } from 'react'
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Server,
|
||||
} from 'lucide-react'
|
||||
import { type FC, useCallback, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { MCP_SERVER_RESTARTED_EVENT } from '@/lib/constants/analyticsEvents'
|
||||
import { sendServerMessage } from '@/lib/messaging/server/serverMessages'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
|
||||
interface MCPServerHeaderProps {
|
||||
serverUrl: string | null
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
title?: string
|
||||
description?: string
|
||||
remoteAccessEnabled?: boolean
|
||||
onServerRestart?: () => void
|
||||
}
|
||||
|
||||
const DOCS_URL = 'https://docs.browseros.com/features/use-with-claude-code'
|
||||
const HEALTH_CHECK_TIMEOUT_MS = 60_000
|
||||
const HEALTH_CHECK_INTERVAL_MS = 2_000
|
||||
|
||||
export const MCPServerHeader: FC<MCPServerHeaderProps> = ({
|
||||
serverUrl,
|
||||
isLoading,
|
||||
error,
|
||||
title = 'BrowserOS MCP Server',
|
||||
description = 'Connect BrowserOS to MCP clients like claude code, gemini and others.',
|
||||
remoteAccessEnabled = false,
|
||||
onServerRestart,
|
||||
}) => {
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
const [isRestarting, setIsRestarting] = useState(false)
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!serverUrl) return
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(serverUrl)
|
||||
setIsCopied(true)
|
||||
@@ -35,6 +44,57 @@ export const MCPServerHeader: FC<MCPServerHeaderProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
const checkServerHealth = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const result = await sendServerMessage('checkHealth', undefined)
|
||||
return result.healthy
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleRestart = async () => {
|
||||
setIsRestarting(true)
|
||||
try {
|
||||
const { getBrowserOSAdapter } = await import('@/lib/browseros/adapter')
|
||||
const { BROWSEROS_PREFS } = await import('@/lib/browseros/prefs')
|
||||
const adapter = getBrowserOSAdapter()
|
||||
await adapter.setPref(BROWSEROS_PREFS.RESTART_SERVER, true)
|
||||
|
||||
const startTime = Date.now()
|
||||
const waitForHealth = (): Promise<boolean> =>
|
||||
new Promise((resolve) => {
|
||||
const check = async () => {
|
||||
if (Date.now() - startTime >= HEALTH_CHECK_TIMEOUT_MS) {
|
||||
resolve(false)
|
||||
return
|
||||
}
|
||||
if (await checkServerHealth()) {
|
||||
resolve(true)
|
||||
return
|
||||
}
|
||||
setTimeout(check, HEALTH_CHECK_INTERVAL_MS)
|
||||
}
|
||||
setTimeout(check, HEALTH_CHECK_INTERVAL_MS)
|
||||
})
|
||||
|
||||
const healthy = await waitForHealth()
|
||||
if (healthy) {
|
||||
track(MCP_SERVER_RESTARTED_EVENT)
|
||||
toast.success('Server restarted successfully')
|
||||
onServerRestart?.()
|
||||
} else {
|
||||
toast.error('Server did not respond. Try restarting the browser.')
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : 'Failed to restart server',
|
||||
)
|
||||
} finally {
|
||||
setIsRestarting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-6 shadow-sm transition-all hover:shadow-md">
|
||||
<div className="flex items-start gap-4">
|
||||
@@ -43,18 +103,21 @@ export const MCPServerHeader: FC<MCPServerHeaderProps> = ({
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-xl">{title}</h2>
|
||||
<h2 className="font-semibold text-xl">BrowserOS MCP Server</h2>
|
||||
<a
|
||||
href={DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-muted-foreground text-sm transition-colors hover:text-[var(--accent-orange)]"
|
||||
>
|
||||
Setup a client
|
||||
Docs
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
<p className="mb-6 text-muted-foreground text-sm">{description}</p>
|
||||
<p className="mb-6 text-muted-foreground text-sm">
|
||||
Connect BrowserOS to MCP clients like Claude Code, Gemini CLI and
|
||||
others.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<span className="whitespace-nowrap font-medium text-sm">
|
||||
@@ -76,6 +139,7 @@ export const MCPServerHeader: FC<MCPServerHeaderProps> = ({
|
||||
onClick={handleCopy}
|
||||
disabled={!serverUrl || isLoading}
|
||||
className="shrink-0"
|
||||
title="Copy URL"
|
||||
>
|
||||
{isCopied ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
@@ -83,19 +147,22 @@ export const MCPServerHeader: FC<MCPServerHeaderProps> = ({
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleRestart}
|
||||
disabled={isLoading || isRestarting}
|
||||
className="shrink-0"
|
||||
title="Restart server"
|
||||
>
|
||||
{isRestarting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{remoteAccessEnabled && serverUrl && !isLoading && (
|
||||
<div className="mt-3 flex items-start gap-2 rounded-lg bg-muted/50 px-3 py-2">
|
||||
<Globe className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<p className="text-muted-foreground text-xs">
|
||||
External access is enabled. To connect from another device,
|
||||
replace <span className="font-mono">127.0.0.1</span> with this
|
||||
machine's IP address.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { McpTool } from '@/lib/mcp/client'
|
||||
import { sendServerMessage } from '@/lib/messaging/server/serverMessages'
|
||||
import { MCPServerHeader } from './MCPServerHeader'
|
||||
import { MCPToolsSection } from './MCPToolsSection'
|
||||
import { ServerSettingsCard } from './ServerSettingsCard'
|
||||
import { QuickSetupSection } from './QuickSetupSection'
|
||||
|
||||
/** @public */
|
||||
export const MCPSettingsPage: FC = () => {
|
||||
@@ -12,8 +12,6 @@ export const MCPSettingsPage: FC = () => {
|
||||
const [urlLoading, setUrlLoading] = useState(true)
|
||||
const [urlError, setUrlError] = useState<string | null>(null)
|
||||
|
||||
const [remoteAccessEnabled, setRemoteAccessEnabled] = useState(false)
|
||||
|
||||
const [tools, setTools] = useState<McpTool[]>([])
|
||||
const [toolsLoading, setToolsLoading] = useState(false)
|
||||
const [toolsError, setToolsError] = useState<string | null>(null)
|
||||
@@ -82,13 +80,10 @@ export const MCPSettingsPage: FC = () => {
|
||||
serverUrl={serverUrl}
|
||||
isLoading={urlLoading}
|
||||
error={urlError}
|
||||
remoteAccessEnabled={remoteAccessEnabled}
|
||||
onServerRestart={loadServerUrlAndTools}
|
||||
/>
|
||||
|
||||
<ServerSettingsCard
|
||||
onServerRestart={loadServerUrlAndTools}
|
||||
onRemoteAccessChange={setRemoteAccessEnabled}
|
||||
/>
|
||||
<QuickSetupSection serverUrl={serverUrl} />
|
||||
|
||||
<MCPToolsSection
|
||||
tools={tools}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { Check, Copy, Terminal } from 'lucide-react'
|
||||
import { type FC, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
|
||||
interface QuickSetupSectionProps {
|
||||
serverUrl: string | null
|
||||
}
|
||||
|
||||
interface ClientConfig {
|
||||
id: string
|
||||
name: string
|
||||
type: 'command' | 'json'
|
||||
getSnippet: (url: string) => string
|
||||
fileName?: string
|
||||
}
|
||||
|
||||
const clients: ClientConfig[] = [
|
||||
{
|
||||
id: 'claude-code',
|
||||
name: 'Claude Code',
|
||||
type: 'command',
|
||||
getSnippet: (url) =>
|
||||
`claude mcp add --transport http browseros ${url} --scope user`,
|
||||
},
|
||||
{
|
||||
id: 'gemini-cli',
|
||||
name: 'Gemini CLI',
|
||||
type: 'command',
|
||||
getSnippet: (url) =>
|
||||
`gemini mcp add local-server ${url} --transport http --scope user`,
|
||||
},
|
||||
{
|
||||
id: 'codex',
|
||||
name: 'Codex',
|
||||
type: 'command',
|
||||
getSnippet: (url) => `codex mcp add browseros ${url}`,
|
||||
},
|
||||
{
|
||||
id: 'claude-desktop',
|
||||
name: 'Claude Desktop',
|
||||
type: 'json',
|
||||
fileName: 'claude_desktop_config.json',
|
||||
getSnippet: (url) =>
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
browserOS: {
|
||||
command: 'npx',
|
||||
args: ['mcp-remote', url],
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'openclaw',
|
||||
name: 'OpenClaw',
|
||||
type: 'json',
|
||||
fileName: 'openclaw.json',
|
||||
getSnippet: (url) =>
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
browseros: { url },
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const CopyButton: FC<{ text: string }> = ({ text }) => {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch {
|
||||
// Clipboard API failed
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={handleCopy}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export const QuickSetupSection: FC<QuickSetupSectionProps> = ({
|
||||
serverUrl,
|
||||
}) => {
|
||||
if (!serverUrl) return null
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-6 shadow-sm transition-all hover:shadow-md">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-[var(--accent-orange)]/10">
|
||||
<Terminal className="h-6 w-6 text-[var(--accent-orange)]" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="mb-1 font-semibold text-xl">Quick Setup</h2>
|
||||
<p className="mb-4 text-muted-foreground text-sm">
|
||||
Copy and run the command for your tool
|
||||
</p>
|
||||
|
||||
<Tabs defaultValue="claude-code">
|
||||
<TabsList className="mb-3 flex-wrap">
|
||||
{clients.map((client) => (
|
||||
<TabsTrigger key={client.id} value={client.id}>
|
||||
{client.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{clients.map((client) => {
|
||||
const snippet = client.getSnippet(serverUrl)
|
||||
return (
|
||||
<TabsContent key={client.id} value={client.id}>
|
||||
<div className="space-y-3">
|
||||
{client.fileName && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Add to{' '}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">
|
||||
{client.fileName}
|
||||
</code>
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-start gap-2 rounded-lg border border-border bg-background px-3 py-2.5">
|
||||
<pre className="flex-1 overflow-x-auto whitespace-pre-wrap break-all font-mono text-xs">
|
||||
{client.type === 'command' && (
|
||||
<span className="mr-1 text-muted-foreground">$</span>
|
||||
)}
|
||||
{snippet}
|
||||
</pre>
|
||||
<CopyButton text={snippet} />
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
)
|
||||
})}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -22,9 +22,7 @@ import {
|
||||
SCHEDULED_TASK_TOGGLED_EVENT,
|
||||
SCHEDULED_TASK_VIEW_RESULTS_EVENT,
|
||||
} from '@/lib/constants/analyticsEvents'
|
||||
import { useGraphqlMutation } from '@/lib/graphql/useGraphqlMutation'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import { DeleteScheduledJobDocument } from '@/lib/schedules/graphql/syncSchedulesDocument'
|
||||
import {
|
||||
scheduledJobRunStorage,
|
||||
useScheduledJobRuns,
|
||||
@@ -46,8 +44,6 @@ export const ScheduledTasksPage: FC = () => {
|
||||
useScheduledJobs()
|
||||
const { jobRuns, cancelJobRun } = useScheduledJobRuns()
|
||||
|
||||
const deleteRemoteJobMutation = useGraphqlMutation(DeleteScheduledJobDocument)
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string | null>(null)
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const [editingJob, setEditingJob] = useState<ScheduledJob | null>(null)
|
||||
@@ -102,7 +98,6 @@ export const ScheduledTasksPage: FC = () => {
|
||||
const confirmDelete = async () => {
|
||||
if (deleteJobId) {
|
||||
await removeJob(deleteJobId)
|
||||
deleteRemoteJobMutation.mutate({ rowId: deleteJobId })
|
||||
setDeleteJobId(null)
|
||||
track(SCHEDULED_TASK_DELETED_EVENT)
|
||||
}
|
||||
|
||||
@@ -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,147 @@
|
||||
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 gap-3">
|
||||
<CreditCard className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="flex items-center gap-2 font-semibold text-sm">
|
||||
Need more credits?
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 font-medium text-[10px] text-muted-foreground uppercase tracking-wide">
|
||||
Coming soon
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Additional credit packages will be available soon
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-[var(--accent-orange)]/30 bg-[var(--accent-orange)]/5 p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Zap className="h-5 w-5 text-[var(--accent-orange)]" />
|
||||
<div>
|
||||
<p className="font-semibold text-sm">Want unlimited usage?</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Add your own LLM provider — no credit limits
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-[var(--accent-orange)] bg-[var(--accent-orange)]/10 text-[var(--accent-orange)] hover:bg-[var(--accent-orange)]/20"
|
||||
asChild
|
||||
>
|
||||
<a href="/app.html#/settings/ai">Add Provider</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -101,11 +101,23 @@ export const useRunWorkflow = () => {
|
||||
setMessages([])
|
||||
setWasCancelled(false)
|
||||
|
||||
const backgroundWindow = await chrome.windows.create({
|
||||
url: 'chrome://newtab',
|
||||
focused: true,
|
||||
type: 'normal',
|
||||
})
|
||||
let backgroundWindow: chrome.windows.Window | undefined
|
||||
try {
|
||||
backgroundWindow = await chrome.windows.create({
|
||||
url: 'chrome://newtab',
|
||||
focused: true,
|
||||
type: 'normal',
|
||||
})
|
||||
} catch {
|
||||
// Fallback when no window context is available (e.g. all windows closed)
|
||||
const tab = await chrome.tabs.create({
|
||||
url: 'chrome://newtab',
|
||||
active: true,
|
||||
})
|
||||
if (tab.windowId) {
|
||||
backgroundWindow = await chrome.windows.get(tab.windowId)
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage({
|
||||
text: 'Run the workflow.',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -169,8 +169,15 @@ export const NewTabChat: FC = () => {
|
||||
onDismissJtbdPopup={() => {}}
|
||||
/>
|
||||
)}
|
||||
{agentUrlError && <ChatError error={agentUrlError} />}
|
||||
{chatError && <ChatError error={chatError} />}
|
||||
{agentUrlError && (
|
||||
<ChatError
|
||||
error={agentUrlError}
|
||||
providerType={selectedProvider?.type}
|
||||
/>
|
||||
)}
|
||||
{chatError && (
|
||||
<ChatError error={chatError} providerType={selectedProvider?.type} />
|
||||
)}
|
||||
</main>
|
||||
|
||||
<div className="mx-auto w-full max-w-3xl flex-shrink-0 px-4 pb-2">
|
||||
|
||||
@@ -36,6 +36,7 @@ export const Chat = () => {
|
||||
stop,
|
||||
agentUrlError,
|
||||
chatError,
|
||||
selectedProvider,
|
||||
getActionForMessage,
|
||||
liked,
|
||||
onClickLike,
|
||||
@@ -223,8 +224,15 @@ export const Chat = () => {
|
||||
onDismissJtbdPopup={onDismissJtbdPopup}
|
||||
/>
|
||||
)}
|
||||
{agentUrlError && <ChatError error={agentUrlError} />}
|
||||
{chatError && <ChatError error={chatError} />}
|
||||
{agentUrlError && (
|
||||
<ChatError
|
||||
error={agentUrlError}
|
||||
providerType={selectedProvider?.type}
|
||||
/>
|
||||
)}
|
||||
{chatError && (
|
||||
<ChatError error={chatError} providerType={selectedProvider?.type} />
|
||||
)}
|
||||
</main>
|
||||
|
||||
<ChatFooter
|
||||
|
||||
@@ -2,11 +2,6 @@ import { AlertCircle, RefreshCw } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
// import { useMemo } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
KIMI_RATE_LIMIT_DOCS_CLICKED_EVENT,
|
||||
KIMI_RATE_LIMIT_PLATFORM_CLICKED_EVENT,
|
||||
} from '@/lib/constants/analyticsEvents'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
|
||||
// --- Commented out for Kimi partnership launch (restore after) ---
|
||||
// const SURVEY_DIRECTIONS = [
|
||||
@@ -24,19 +19,24 @@ import { track } from '@/lib/metrics/track'
|
||||
interface ChatErrorProps {
|
||||
error: Error
|
||||
onRetry?: () => void
|
||||
providerType?: string
|
||||
}
|
||||
|
||||
function parseErrorMessage(message: string): {
|
||||
function parseErrorMessage(
|
||||
message: string,
|
||||
providerType?: string,
|
||||
): {
|
||||
text: string
|
||||
url?: string
|
||||
isRateLimit?: boolean
|
||||
isCreditsExhausted?: boolean
|
||||
isConnectionError?: boolean
|
||||
} {
|
||||
// Detect MCP server connection failures
|
||||
if (
|
||||
(message.includes('Failed to fetch') || message.includes('fetch failed')) &&
|
||||
message.includes('127.0.0.1')
|
||||
) {
|
||||
const isBrowserosProvider = providerType === 'browseros'
|
||||
|
||||
// All chat requests go through the local BrowserOS agent server, so any
|
||||
// fetch failure is always a local connection issue.
|
||||
if (message.includes('Failed to fetch') || message.includes('fetch failed')) {
|
||||
return {
|
||||
text: 'Unable to connect to BrowserOS agent. Follow below instructions.',
|
||||
url: 'https://docs.browseros.com/troubleshooting/connection-issues',
|
||||
@@ -44,8 +44,26 @@ function parseErrorMessage(message: string): {
|
||||
}
|
||||
}
|
||||
|
||||
// Detect BrowserOS rate limit (unique pattern, no provider uses this)
|
||||
if (message.includes('BrowserOS LLM daily limit reached')) {
|
||||
// Detect credit exhaustion from gateway (BrowserOS provider only)
|
||||
if (
|
||||
isBrowserosProvider &&
|
||||
(message.includes('CREDITS_EXHAUSTED') ||
|
||||
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 (BrowserOS provider only)
|
||||
if (
|
||||
isBrowserosProvider &&
|
||||
message.includes('BrowserOS LLM daily limit reached')
|
||||
) {
|
||||
return {
|
||||
text: 'Add your own API key for unlimited usage.',
|
||||
url: 'https://dub.sh/browseros-usage-limit',
|
||||
@@ -69,10 +87,13 @@ function parseErrorMessage(message: string): {
|
||||
return { text: text || 'An unexpected error occurred', url }
|
||||
}
|
||||
|
||||
export const ChatError: FC<ChatErrorProps> = ({ error, onRetry }) => {
|
||||
const { text, url, isRateLimit, isConnectionError } = parseErrorMessage(
|
||||
error.message,
|
||||
)
|
||||
export const ChatError: FC<ChatErrorProps> = ({
|
||||
error,
|
||||
onRetry,
|
||||
providerType,
|
||||
}) => {
|
||||
const { text, url, isRateLimit, isCreditsExhausted, isConnectionError } =
|
||||
parseErrorMessage(error.message, providerType)
|
||||
|
||||
// --- Commented out for Kimi partnership launch (restore after) ---
|
||||
// const surveyUrl = useMemo(
|
||||
@@ -128,31 +149,25 @@ export const ChatError: FC<ChatErrorProps> = ({ error, onRetry }) => {
|
||||
</p>
|
||||
)}
|
||||
--- End commented out survey code --- */}
|
||||
{isRateLimit && (
|
||||
<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 */}
|
||||
<a
|
||||
href="https://docs.browseros.com/features/bring-your-own-llm#kimi-k2-5-%E2%80%94-in-partnership-with-moonshot-ai"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-foreground"
|
||||
onClick={() => track(KIMI_RATE_LIMIT_DOCS_CLICKED_EVENT)}
|
||||
>
|
||||
Learn how to get a Kimi API key
|
||||
</a>
|
||||
{' or '}
|
||||
<a
|
||||
href="https://platform.moonshot.ai"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-foreground"
|
||||
onClick={() => track(KIMI_RATE_LIMIT_PLATFORM_CLICKED_EVENT)}
|
||||
>
|
||||
get your API key
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
{isCreditsExhausted && url && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground text-xs underline hover:text-foreground"
|
||||
>
|
||||
View Usage & Billing
|
||||
</a>
|
||||
)}
|
||||
{isRateLimit && providerType === 'browseros' && (
|
||||
<a
|
||||
href="/app.html#/settings/ai"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--accent-orange)] bg-[var(--accent-orange)]/10 px-3 py-1.5 font-medium text-[var(--accent-orange)] text-xs transition-colors hover:bg-[var(--accent-orange)]/20"
|
||||
>
|
||||
Add your own provider for unlimited usage
|
||||
</a>
|
||||
)}
|
||||
{onRetry && (
|
||||
<Button
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { sentry } from '../sentry/sentry'
|
||||
import { posthog } from './posthog'
|
||||
|
||||
/**
|
||||
* Identify the current user across all analytics and error tracking services.
|
||||
* Call this when the user logs in or when a stored session is restored.
|
||||
*/
|
||||
export function identify(user: { id: string; email?: string; name?: string }) {
|
||||
sentry.setUser({ id: user.id, email: user.email })
|
||||
posthog.identify(user.id, {
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear user identity across all services.
|
||||
* Call this when the user logs out.
|
||||
*/
|
||||
export function resetIdentity() {
|
||||
sentry.setUser(null)
|
||||
posthog.reset()
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { identify, resetIdentity } from '@/lib/analytics/identify'
|
||||
import { useSession } from './auth-client'
|
||||
import { useSessionInfo } from './sessionStorage'
|
||||
|
||||
@@ -14,6 +15,16 @@ export const AuthProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
session: data?.session,
|
||||
user: data?.user,
|
||||
})
|
||||
|
||||
if (data?.user?.id) {
|
||||
identify({
|
||||
id: data.user.id,
|
||||
email: data.user.email,
|
||||
name: data.user.name || undefined,
|
||||
})
|
||||
} else {
|
||||
resetIdentity()
|
||||
}
|
||||
}
|
||||
}, [data, isPending])
|
||||
|
||||
|
||||
@@ -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,9 +41,36 @@ 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'
|
||||
|
||||
/** @public */
|
||||
export const MCP_PROMO_BANNER_CLICKED_EVENT =
|
||||
'settings.mcp_promo_banner.clicked'
|
||||
|
||||
/** @public */
|
||||
export const MCP_EXTERNAL_ACCESS_ENABLED_EVENT =
|
||||
'settings.mcp_external_access.enabled'
|
||||
|
||||
@@ -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(/=+$/, '')
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import data from './models-dev-data.json'
|
||||
|
||||
export interface ModelsDevModel {
|
||||
id: string
|
||||
name: string
|
||||
contextWindow: number
|
||||
maxOutput: number
|
||||
supportsImages: boolean
|
||||
supportsReasoning: boolean
|
||||
supportsToolCall: boolean
|
||||
inputCost?: number
|
||||
outputCost?: number
|
||||
}
|
||||
|
||||
export interface ModelsDevProvider {
|
||||
name: string
|
||||
api?: string
|
||||
doc: string
|
||||
models: ModelsDevModel[]
|
||||
}
|
||||
|
||||
const modelsDevData: Record<string, ModelsDevProvider> = data as Record<
|
||||
string,
|
||||
ModelsDevProvider
|
||||
>
|
||||
|
||||
export function getModelsDevProvider(
|
||||
providerId: string,
|
||||
): ModelsDevProvider | undefined {
|
||||
return modelsDevData[providerId]
|
||||
}
|
||||
|
||||
export function getModelsDevModels(providerId: string): ModelsDevModel[] {
|
||||
return modelsDevData[providerId]?.models ?? []
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getModelsDevProvider } from './models-dev'
|
||||
import type { ProviderType } from './types'
|
||||
|
||||
/**
|
||||
@@ -15,6 +16,30 @@ export interface ProviderTemplate {
|
||||
apiKeyUrl?: string
|
||||
}
|
||||
|
||||
function enrichTemplate(
|
||||
providerId: ProviderType,
|
||||
overrides: {
|
||||
defaultModelId: string
|
||||
defaultBaseUrl?: string
|
||||
apiKeyUrl?: string
|
||||
setupGuideUrl?: string
|
||||
},
|
||||
): ProviderTemplate {
|
||||
const provider = getModelsDevProvider(providerId)
|
||||
const model = provider?.models.find((m) => m.id === overrides.defaultModelId)
|
||||
|
||||
return {
|
||||
id: providerId,
|
||||
name: provider?.name ?? providerId,
|
||||
defaultBaseUrl: overrides.defaultBaseUrl ?? provider?.api ?? '',
|
||||
defaultModelId: overrides.defaultModelId,
|
||||
supportsImages: model?.supportsImages ?? true,
|
||||
contextWindow: model?.contextWindow ?? 128000,
|
||||
...(overrides.apiKeyUrl && { apiKeyUrl: overrides.apiKeyUrl }),
|
||||
...(overrides.setupGuideUrl && { setupGuideUrl: overrides.setupGuideUrl }),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Available provider templates for quick setup
|
||||
* @public
|
||||
@@ -29,6 +54,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',
|
||||
@@ -39,17 +82,12 @@ export const providerTemplates: ProviderTemplate[] = [
|
||||
apiKeyUrl: 'https://platform.moonshot.ai/console/api-keys',
|
||||
setupGuideUrl: 'https://platform.moonshot.ai/console/api-keys',
|
||||
},
|
||||
{
|
||||
id: 'openai',
|
||||
name: 'OpenAI',
|
||||
defaultBaseUrl: 'https://api.openai.com/v1',
|
||||
defaultModelId: 'gpt-4',
|
||||
supportsImages: true,
|
||||
contextWindow: 128000,
|
||||
enrichTemplate('openai', {
|
||||
defaultModelId: 'gpt-5',
|
||||
apiKeyUrl: 'https://platform.openai.com/api-keys',
|
||||
setupGuideUrl:
|
||||
'https://docs.browseros.com/features/bring-your-own-llm#openai',
|
||||
},
|
||||
}),
|
||||
{
|
||||
id: 'openai-compatible',
|
||||
name: 'OpenAI Compatible',
|
||||
@@ -58,28 +96,18 @@ export const providerTemplates: ProviderTemplate[] = [
|
||||
supportsImages: true,
|
||||
contextWindow: 128000,
|
||||
},
|
||||
{
|
||||
id: 'anthropic',
|
||||
name: 'Anthropic',
|
||||
defaultBaseUrl: 'https://api.anthropic.com/v1',
|
||||
defaultModelId: 'claude-3-5-sonnet-20241022',
|
||||
supportsImages: true,
|
||||
contextWindow: 200000,
|
||||
enrichTemplate('anthropic', {
|
||||
defaultModelId: 'claude-sonnet-4-6',
|
||||
apiKeyUrl: 'https://console.anthropic.com/settings/keys',
|
||||
setupGuideUrl:
|
||||
'https://docs.browseros.com/features/bring-your-own-llm#claude',
|
||||
},
|
||||
{
|
||||
id: 'google',
|
||||
name: 'Gemini',
|
||||
defaultBaseUrl: 'https://generativelanguage.googleapis.com/v1beta',
|
||||
defaultModelId: 'gemini-1.5-pro',
|
||||
supportsImages: true,
|
||||
contextWindow: 1000000,
|
||||
}),
|
||||
enrichTemplate('google', {
|
||||
defaultModelId: 'gemini-2.5-flash',
|
||||
apiKeyUrl: 'https://aistudio.google.com/app/apikey',
|
||||
setupGuideUrl:
|
||||
'https://docs.browseros.com/features/bring-your-own-llm#gemini',
|
||||
},
|
||||
}),
|
||||
{
|
||||
id: 'ollama',
|
||||
name: 'Ollama',
|
||||
@@ -90,47 +118,28 @@ export const providerTemplates: ProviderTemplate[] = [
|
||||
setupGuideUrl:
|
||||
'https://docs.browseros.com/features/bring-your-own-llm#ollama',
|
||||
},
|
||||
{
|
||||
id: 'openrouter',
|
||||
name: 'OpenRouter',
|
||||
defaultBaseUrl: 'https://openrouter.ai/api/v1',
|
||||
defaultModelId: 'openai/gpt-4-turbo',
|
||||
supportsImages: true,
|
||||
contextWindow: 128000,
|
||||
enrichTemplate('openrouter', {
|
||||
defaultModelId: 'anthropic/claude-sonnet-4.5',
|
||||
apiKeyUrl: 'https://openrouter.ai/keys',
|
||||
setupGuideUrl:
|
||||
'https://docs.browseros.com/features/bring-your-own-llm#openrouter',
|
||||
},
|
||||
{
|
||||
id: 'lmstudio',
|
||||
name: 'LM Studio',
|
||||
}),
|
||||
enrichTemplate('lmstudio', {
|
||||
defaultModelId: 'openai/gpt-oss-20b',
|
||||
defaultBaseUrl: 'http://localhost:1234/v1',
|
||||
defaultModelId: 'local-model',
|
||||
supportsImages: false,
|
||||
contextWindow: 32000,
|
||||
setupGuideUrl:
|
||||
'https://docs.browseros.com/features/bring-your-own-llm#lmstudio',
|
||||
},
|
||||
{
|
||||
id: 'azure',
|
||||
name: 'Azure',
|
||||
defaultBaseUrl: '',
|
||||
}),
|
||||
enrichTemplate('azure', {
|
||||
defaultModelId: '',
|
||||
supportsImages: true,
|
||||
contextWindow: 128000,
|
||||
apiKeyUrl:
|
||||
'https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/OpenAI',
|
||||
},
|
||||
{
|
||||
id: 'bedrock',
|
||||
name: 'AWS Bedrock',
|
||||
defaultBaseUrl: '',
|
||||
defaultModelId: '',
|
||||
supportsImages: true,
|
||||
contextWindow: 200000,
|
||||
}),
|
||||
enrichTemplate('bedrock', {
|
||||
defaultModelId: 'anthropic.claude-sonnet-4-6',
|
||||
setupGuideUrl:
|
||||
'https://docs.aws.amazon.com/bedrock/latest/userguide/getting-started.html',
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -139,6 +148,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 +179,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,182 @@
|
||||
import { useEffect, useRef, useState } 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
|
||||
}
|
||||
|
||||
export interface PendingDeviceCode {
|
||||
userCode: string
|
||||
providerName: string
|
||||
verificationUri: string
|
||||
}
|
||||
|
||||
interface OAuthProviderFlowReturn {
|
||||
status: { authenticated: boolean; email?: string } | null
|
||||
disconnect: () => Promise<void>
|
||||
startOAuthFlow: (agentServerUrl: string | undefined) => Promise<void>
|
||||
pendingDeviceCode: PendingDeviceCode | null
|
||||
clearDeviceCode: () => 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)
|
||||
const [pendingDeviceCode, setPendingDeviceCode] =
|
||||
useState<PendingDeviceCode | null>(null)
|
||||
|
||||
// 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,
|
||||
})
|
||||
setPendingDeviceCode(null)
|
||||
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)
|
||||
setPendingDeviceCode({
|
||||
userCode: deviceData.user_code,
|
||||
providerName: config.displayName,
|
||||
verificationUri,
|
||||
})
|
||||
|
||||
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)
|
||||
setPendingDeviceCode({
|
||||
userCode: data.userCode,
|
||||
providerName: config.displayName,
|
||||
verificationUri: data.verificationUri,
|
||||
})
|
||||
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,
|
||||
pendingDeviceCode,
|
||||
clearDeviceCode: () => setPendingDeviceCode(null),
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,13 @@ export const scheduledJobRunStorage = storage.defineItem<ScheduledJobRun[]>(
|
||||
},
|
||||
)
|
||||
|
||||
export const pendingDeletionStorage = storage.defineItem<string[]>(
|
||||
'local:scheduledJobsPendingDeletion',
|
||||
{
|
||||
fallback: [],
|
||||
},
|
||||
)
|
||||
|
||||
export function useScheduledJobs() {
|
||||
const [jobs, setJobs] = useState<ScheduledJob[]>([])
|
||||
|
||||
@@ -54,6 +61,11 @@ export function useScheduledJobs() {
|
||||
const removeJob = async (id: string) => {
|
||||
await chrome.alarms.clear(getAlarmName(id))
|
||||
|
||||
const pending = (await pendingDeletionStorage.getValue()) ?? []
|
||||
if (!pending.includes(id)) {
|
||||
await pendingDeletionStorage.setValue([...pending, id])
|
||||
}
|
||||
|
||||
const currentJobs = (await scheduledJobStorage.getValue()) ?? []
|
||||
await scheduledJobStorage.setValue(currentJobs.filter((j) => j.id !== id))
|
||||
|
||||
|
||||
@@ -5,10 +5,11 @@ import { sentry } from '@/lib/sentry/sentry'
|
||||
import { createAlarmFromJob } from './createAlarmFromJob'
|
||||
import {
|
||||
CreateScheduledJobDocument,
|
||||
DeleteScheduledJobDocument,
|
||||
GetScheduledJobsByProfileIdDocument,
|
||||
UpdateScheduledJobDocument,
|
||||
} from './graphql/syncSchedulesDocument'
|
||||
import { scheduledJobStorage } from './scheduleStorage'
|
||||
import { pendingDeletionStorage, scheduledJobStorage } from './scheduleStorage'
|
||||
import type { ScheduledJob } from './scheduleTypes'
|
||||
|
||||
type RemoteScheduledJob = {
|
||||
@@ -99,6 +100,32 @@ export async function syncSchedulesToBackend(
|
||||
}
|
||||
}
|
||||
|
||||
const pendingDeletions = new Set(
|
||||
(await pendingDeletionStorage.getValue()) ?? [],
|
||||
)
|
||||
const resolvedDeletions = new Set<string>()
|
||||
|
||||
for (const rowId of pendingDeletions) {
|
||||
if (remoteJobs.has(rowId)) {
|
||||
try {
|
||||
await execute(DeleteScheduledJobDocument, { rowId })
|
||||
remoteJobs.delete(rowId)
|
||||
resolvedDeletions.add(rowId)
|
||||
} catch (error) {
|
||||
sentry.captureException(error, {
|
||||
extra: { jobId: rowId, context: 'sync-pending-deletion' },
|
||||
})
|
||||
}
|
||||
} else {
|
||||
resolvedDeletions.add(rowId)
|
||||
}
|
||||
}
|
||||
|
||||
const latestPending = (await pendingDeletionStorage.getValue()) ?? []
|
||||
await pendingDeletionStorage.setValue(
|
||||
latestPending.filter((id) => !resolvedDeletions.has(id)),
|
||||
)
|
||||
|
||||
const localJobsMap = new Map(localJobs.map((j) => [j.id, j]))
|
||||
const jobsToAddLocally: ScheduledJob[] = []
|
||||
const jobsToUpdateLocally: ScheduledJob[] = []
|
||||
|
||||
77
packages/browseros-agent/apps/agent/lib/sentry/sanitize.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Sanitize Sentry event data by redacting values at keys that match known
|
||||
* sensitive patterns. Used in `beforeSend` to prevent credentials from
|
||||
* leaking into error reports.
|
||||
*/
|
||||
|
||||
const REDACTED = '[REDACTED]'
|
||||
|
||||
const SENSITIVE_KEY_PATTERNS = [
|
||||
'apikey',
|
||||
'api_key',
|
||||
'accesskeyid',
|
||||
'secretaccesskey',
|
||||
'sessiontoken',
|
||||
'authorization',
|
||||
'token',
|
||||
'password',
|
||||
'secret',
|
||||
'credential',
|
||||
]
|
||||
|
||||
function isSensitiveKey(key: string): boolean {
|
||||
const lower = key.toLowerCase()
|
||||
return SENSITIVE_KEY_PATTERNS.some((p) => lower.includes(p))
|
||||
}
|
||||
|
||||
function sanitize<T>(obj: T): T {
|
||||
if (obj === null || obj === undefined) return obj
|
||||
if (
|
||||
typeof obj === 'string' ||
|
||||
typeof obj === 'number' ||
|
||||
typeof obj === 'boolean'
|
||||
) {
|
||||
return obj
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(sanitize) as T
|
||||
}
|
||||
if (typeof obj === 'object') {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
result[key] = isSensitiveKey(key) ? REDACTED : sanitize(value)
|
||||
}
|
||||
return result as T
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Sentry event type varies by SDK
|
||||
export function sanitizeEvent<E>(event: E): E {
|
||||
const e = event as Record<string, any>
|
||||
|
||||
if (Array.isArray(e.breadcrumbs)) {
|
||||
e.breadcrumbs = e.breadcrumbs.map((b: Record<string, unknown>) => ({
|
||||
...b,
|
||||
data: b.data ? sanitize(b.data) : b.data,
|
||||
}))
|
||||
}
|
||||
|
||||
if (e.contexts) {
|
||||
e.contexts = sanitize(e.contexts)
|
||||
}
|
||||
|
||||
if (e.extra) {
|
||||
e.extra = sanitize(e.extra)
|
||||
}
|
||||
|
||||
for (const value of e.exception?.values ?? []) {
|
||||
for (const frame of value.stacktrace?.frames ?? []) {
|
||||
if (frame.vars) {
|
||||
frame.vars = sanitize(frame.vars)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return event
|
||||
}
|
||||
@@ -1,6 +1,21 @@
|
||||
import * as Sentry from '@sentry/react'
|
||||
import { getBrowserOSAdapter } from '../browseros/adapter'
|
||||
import { env } from '../env'
|
||||
import { sanitizeEvent } from './sanitize'
|
||||
|
||||
/** Errors that are expected during normal operation and should not be reported */
|
||||
const SUPPRESSED_ERRORS = ['The browser is shutting down', 'No current window']
|
||||
|
||||
function getExtensionPage(): string {
|
||||
try {
|
||||
const url = new URL(location.href)
|
||||
// Extract the entry point name from the extension URL pathname
|
||||
// e.g. chrome-extension://<id>/sidepanel.html -> sidepanel
|
||||
return url.pathname.replace(/^\//, '').replace(/\.html$/, '') || 'unknown'
|
||||
} catch {
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
if (env.VITE_PUBLIC_SENTRY_DSN) {
|
||||
Sentry.init({
|
||||
@@ -10,6 +25,29 @@ if (env.VITE_PUBLIC_SENTRY_DSN) {
|
||||
sendDefaultPii: true,
|
||||
environment: env.PROD ? 'production' : 'development',
|
||||
release: chrome.runtime.getManifest().version,
|
||||
|
||||
beforeSend(event) {
|
||||
const message = event.exception?.values?.[0]?.value ?? ''
|
||||
if (SUPPRESSED_ERRORS.some((s) => message.includes(s))) {
|
||||
return null
|
||||
}
|
||||
|
||||
event.tags = {
|
||||
...event.tags,
|
||||
extensionPage: getExtensionPage(),
|
||||
}
|
||||
|
||||
return sanitizeEvent(event)
|
||||
},
|
||||
|
||||
integrations: [
|
||||
Sentry.breadcrumbsIntegration({
|
||||
console: true,
|
||||
dom: true,
|
||||
fetch: true,
|
||||
xhr: true,
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
;(async () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -54,11 +54,18 @@ export default defineConfig({
|
||||
},
|
||||
permissions: [
|
||||
'topSites',
|
||||
'tabs',
|
||||
'storage',
|
||||
'unlimitedStorage',
|
||||
'scripting',
|
||||
'tabs',
|
||||
'tabGroups',
|
||||
'sidePanel',
|
||||
'bookmarks',
|
||||
'history',
|
||||
'browserOS',
|
||||
'alarms',
|
||||
'webNavigation',
|
||||
'downloads',
|
||||
],
|
||||
host_permissions: [
|
||||
'http://127.0.0.1/*',
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Production upload env for CLI installer scripts
|
||||
|
||||
R2_ACCOUNT_ID=
|
||||
R2_ACCESS_KEY_ID=
|
||||
R2_SECRET_ACCESS_KEY=
|
||||
R2_BUCKET=browseros
|
||||
R2_UPLOAD_PREFIX=cli
|
||||
1
packages/browseros-agent/apps/cli/.gitignore
vendored
@@ -1 +1,2 @@
|
||||
browseros-cli
|
||||
dist
|
||||
|
||||
50
packages/browseros-agent/apps/cli/.goreleaser.yml
Normal file
@@ -0,0 +1,50 @@
|
||||
version: 2
|
||||
|
||||
project_name: browseros-cli
|
||||
|
||||
monorepo:
|
||||
tag_prefix: browseros-cli-
|
||||
|
||||
builds:
|
||||
- main: .
|
||||
binary: browseros-cli
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
flags:
|
||||
- -trimpath
|
||||
ldflags:
|
||||
- -s -w -X main.version={{ .Version }}
|
||||
targets:
|
||||
- darwin_amd64
|
||||
- darwin_arm64
|
||||
- linux_amd64
|
||||
- linux_arm64
|
||||
- windows_amd64
|
||||
- windows_arm64
|
||||
|
||||
archives:
|
||||
- format: tar.gz
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
files:
|
||||
- "none*"
|
||||
|
||||
checksum:
|
||||
name_template: checksums.txt
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- "^docs:"
|
||||
- "^test:"
|
||||
- "^ci:"
|
||||
|
||||
release:
|
||||
github:
|
||||
owner: browseros-ai
|
||||
name: BrowserOS
|
||||
prerelease: auto
|
||||
name_template: "browseros-cli v{{ .Version }}"
|
||||
1
packages/browseros-agent/apps/cli/CHANGELOG.md
Normal file
@@ -0,0 +1 @@
|
||||
# BrowserOS CLI
|
||||
@@ -18,3 +18,9 @@ vet:
|
||||
|
||||
test:
|
||||
go test -tags integration -v -timeout 120s ./...
|
||||
|
||||
release-dry:
|
||||
goreleaser release --snapshot --clean
|
||||
|
||||
release:
|
||||
goreleaser release --clean
|
||||
|
||||
@@ -17,8 +17,10 @@ import (
|
||||
)
|
||||
|
||||
func init() {
|
||||
var autoDiscover bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "init",
|
||||
Use: "init [url]",
|
||||
Short: "Configure the BrowserOS server connection",
|
||||
Long: `Set up the CLI by providing the MCP server URL from BrowserOS.
|
||||
|
||||
@@ -26,33 +28,59 @@ Open BrowserOS → Settings → BrowserOS MCP to find your Server URL.
|
||||
The URL looks like: http://127.0.0.1:9004/mcp
|
||||
|
||||
The port varies per installation, so this step is required on first use.
|
||||
Run again if your port changes.`,
|
||||
Run again if your port changes.
|
||||
|
||||
Three modes:
|
||||
browseros-cli init <url> Non-interactive, use the provided URL
|
||||
browseros-cli init --auto Auto-discover from ~/.browseros/server.json
|
||||
browseros-cli init Interactive prompt`,
|
||||
Annotations: map[string]string{"group": "Setup:"},
|
||||
Args: cobra.NoArgs,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
bold := color.New(color.Bold)
|
||||
green := color.New(color.FgGreen)
|
||||
dim := color.New(color.Faint)
|
||||
|
||||
fmt.Println()
|
||||
bold.Println("BrowserOS CLI Setup")
|
||||
fmt.Println()
|
||||
fmt.Println("Open BrowserOS → Settings → BrowserOS MCP")
|
||||
fmt.Println("Copy the Server URL shown there.")
|
||||
fmt.Println()
|
||||
dim.Println("It looks like: http://127.0.0.1:9004/mcp")
|
||||
fmt.Println()
|
||||
var input string
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
fmt.Print("Server URL: ")
|
||||
input, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
output.Error("failed to read input", 1)
|
||||
}
|
||||
input = strings.TrimSpace(input)
|
||||
switch {
|
||||
case len(args) == 1:
|
||||
// Non-interactive: URL provided as argument
|
||||
input = args[0]
|
||||
|
||||
if input == "" {
|
||||
output.Error("no URL provided", 1)
|
||||
case autoDiscover:
|
||||
// Auto-discover: server.json → config → probe common ports
|
||||
discovered := probeRunningServer()
|
||||
if discovered == "" {
|
||||
output.Error("auto-discovery failed: no running BrowserOS found.\n\n"+
|
||||
" If not running: browseros-cli launch\n"+
|
||||
" If not installed: browseros-cli install", 1)
|
||||
}
|
||||
input = discovered
|
||||
fmt.Printf("Auto-discovered server at %s\n", input)
|
||||
|
||||
default:
|
||||
// Interactive prompt (original behavior)
|
||||
fmt.Println()
|
||||
bold.Println("BrowserOS CLI Setup")
|
||||
fmt.Println()
|
||||
fmt.Println("Open BrowserOS → Settings → BrowserOS MCP")
|
||||
fmt.Println("Copy the Server URL shown there.")
|
||||
fmt.Println()
|
||||
dim.Println("It looks like: http://127.0.0.1:9004/mcp")
|
||||
fmt.Println()
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
fmt.Print("Server URL: ")
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
output.Error("failed to read input", 1)
|
||||
}
|
||||
input = strings.TrimSpace(line)
|
||||
|
||||
if input == "" {
|
||||
output.Error("no URL provided", 1)
|
||||
}
|
||||
}
|
||||
|
||||
baseURL := normalizeServerURL(input)
|
||||
@@ -88,5 +116,6 @@ Run again if your port changes.`,
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&autoDiscover, "auto", false, "Auto-discover server URL from ~/.browseros/server.json")
|
||||
rootCmd.AddCommand(cmd)
|
||||
}
|
||||
|
||||
247
packages/browseros-agent/apps/cli/cmd/install.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"browseros-cli/output"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
cmd := &cobra.Command{
|
||||
Use: "install",
|
||||
Short: "Download and install BrowserOS for the current platform",
|
||||
Long: `Download BrowserOS for your platform and start the installation.
|
||||
|
||||
macOS: Downloads .dmg, mounts it, and copies BrowserOS to /Applications
|
||||
Windows: Downloads installer .exe and launches it
|
||||
Linux: Downloads AppImage (or .deb with --deb flag)
|
||||
|
||||
After installation:
|
||||
browseros-cli launch # start BrowserOS
|
||||
browseros-cli init --auto # configure the CLI`,
|
||||
Annotations: map[string]string{"group": "Setup:"},
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
dir, _ := cmd.Flags().GetString("dir")
|
||||
deb, _ := cmd.Flags().GetBool("deb")
|
||||
|
||||
if deb && runtime.GOOS != "linux" {
|
||||
output.Error("--deb is only available on Linux", 1)
|
||||
}
|
||||
|
||||
downloadURL, filename := resolveDownload(deb)
|
||||
destPath := filepath.Join(dir, filename)
|
||||
|
||||
bold := color.New(color.Bold)
|
||||
green := color.New(color.FgGreen)
|
||||
dim := color.New(color.Faint)
|
||||
|
||||
bold.Printf("Downloading BrowserOS for %s...\n", platformDisplayName())
|
||||
dim.Printf(" %s\n", downloadURL)
|
||||
fmt.Println()
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Minute}
|
||||
resp, err := client.Get(downloadURL)
|
||||
if err != nil {
|
||||
output.Errorf(1, "download failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
output.Errorf(1, "download failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
file, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
output.Errorf(1, "create file: %v", err)
|
||||
}
|
||||
|
||||
written, err := io.Copy(file, resp.Body)
|
||||
file.Close()
|
||||
if err != nil {
|
||||
os.Remove(destPath)
|
||||
output.Errorf(1, "download interrupted: %v", err)
|
||||
}
|
||||
|
||||
green.Printf("Downloaded %s (%.1f MB)\n", filename, float64(written)/(1024*1024))
|
||||
fmt.Println()
|
||||
|
||||
runPostInstall(destPath, deb, dim)
|
||||
|
||||
fmt.Println()
|
||||
bold.Println("Next steps:")
|
||||
dim.Println(" browseros-cli launch # start BrowserOS")
|
||||
dim.Println(" browseros-cli init --auto # configure the CLI")
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().String("dir", ".", "Directory to download the installer to")
|
||||
cmd.Flags().Bool("deb", false, "Download .deb package instead of AppImage (Linux only)")
|
||||
|
||||
rootCmd.AddCommand(cmd)
|
||||
}
|
||||
|
||||
func resolveDownload(deb bool) (url, filename string) {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return "https://files.browseros.com/download/BrowserOS.dmg", "BrowserOS.dmg"
|
||||
case "windows":
|
||||
return "https://files.browseros.com/download/BrowserOS_installer.exe", "BrowserOS_installer.exe"
|
||||
case "linux":
|
||||
if deb {
|
||||
return "https://cdn.browseros.com/download/BrowserOS.deb", "BrowserOS.deb"
|
||||
}
|
||||
return "https://files.browseros.com/download/BrowserOS.AppImage", "BrowserOS.AppImage"
|
||||
default:
|
||||
output.Errorf(1, "unsupported platform: %s/%s\n Download manually from https://browseros.com", runtime.GOOS, runtime.GOARCH)
|
||||
return "", ""
|
||||
}
|
||||
}
|
||||
|
||||
func platformDisplayName() string {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return "macOS"
|
||||
case "windows":
|
||||
return "Windows"
|
||||
case "linux":
|
||||
return "Linux"
|
||||
default:
|
||||
return runtime.GOOS
|
||||
}
|
||||
}
|
||||
|
||||
func runPostInstall(path string, deb bool, dim *color.Color) {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
installMacOS(path, dim)
|
||||
|
||||
case "linux":
|
||||
if deb {
|
||||
dim.Println("Install the .deb package:")
|
||||
fmt.Printf(" sudo dpkg -i %s\n", path)
|
||||
} else {
|
||||
os.Chmod(path, 0755)
|
||||
dim.Printf("AppImage is ready to run: ./%s\n", filepath.Base(path))
|
||||
}
|
||||
|
||||
case "windows":
|
||||
fmt.Println("Launching installer...")
|
||||
if err := exec.Command("cmd", "/c", "start", "", path).Run(); err != nil {
|
||||
dim.Printf("Could not launch installer automatically. Run: %s\n", path)
|
||||
} else {
|
||||
dim.Println("Follow the installer prompts to complete setup.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// installMacOS mounts the DMG and copies BrowserOS.app to /Applications.
|
||||
func installMacOS(dmgPath string, dim *color.Color) {
|
||||
fmt.Println("Mounting disk image...")
|
||||
mountOut, err := exec.Command("hdiutil", "attach", dmgPath, "-nobrowse", "-quiet").Output()
|
||||
if err != nil {
|
||||
dim.Println("Could not mount DMG automatically.")
|
||||
dim.Printf(" Open it manually: open %s\n", dmgPath)
|
||||
return
|
||||
}
|
||||
|
||||
// Find the mount point (last field of last line of hdiutil output)
|
||||
mountPoint := ""
|
||||
for _, line := range splitLines(string(mountOut)) {
|
||||
fields := splitTabs(line)
|
||||
if len(fields) > 0 {
|
||||
mountPoint = fields[len(fields)-1]
|
||||
}
|
||||
}
|
||||
|
||||
if mountPoint == "" {
|
||||
dim.Println("DMG mounted but could not determine mount point.")
|
||||
dim.Printf(" Open it manually: open %s\n", dmgPath)
|
||||
return
|
||||
}
|
||||
|
||||
// Look for BrowserOS.app in the mounted volume
|
||||
appSrc := filepath.Join(mountPoint, "BrowserOS.app")
|
||||
if _, err := os.Stat(appSrc); err != nil {
|
||||
dim.Printf("DMG mounted at %s but BrowserOS.app not found inside.\n", mountPoint)
|
||||
dim.Printf(" Check the volume manually: open %s\n", mountPoint)
|
||||
exec.Command("hdiutil", "detach", mountPoint, "-quiet").Run()
|
||||
return
|
||||
}
|
||||
|
||||
appDest := "/Applications/BrowserOS.app"
|
||||
fmt.Printf("Installing to %s...\n", appDest)
|
||||
|
||||
// Remove existing installation if present
|
||||
os.RemoveAll(appDest)
|
||||
|
||||
// Copy using cp -R (preserves code signatures, symlinks, etc.)
|
||||
if err := exec.Command("cp", "-R", appSrc, appDest).Run(); err != nil {
|
||||
dim.Printf("Could not copy to /Applications (may need sudo).\n")
|
||||
dim.Printf(" Try: sudo cp -R \"%s\" /Applications/\n", appSrc)
|
||||
exec.Command("hdiutil", "detach", mountPoint, "-quiet").Run()
|
||||
return
|
||||
}
|
||||
|
||||
// Unmount
|
||||
exec.Command("hdiutil", "detach", mountPoint, "-quiet").Run()
|
||||
|
||||
// Clean up DMG
|
||||
os.Remove(dmgPath)
|
||||
|
||||
fmt.Println("BrowserOS installed to /Applications/BrowserOS.app")
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
var lines []string
|
||||
for _, line := range filepath.SplitList(s) {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
// filepath.SplitList uses : on unix, not newlines — use manual split
|
||||
result := []string{}
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\n' {
|
||||
line := s[start:i]
|
||||
if len(line) > 0 {
|
||||
result = append(result, line)
|
||||
}
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(s) {
|
||||
result = append(result, s[start:])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func splitTabs(s string) []string {
|
||||
result := []string{}
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\t' {
|
||||
field := s[start:i]
|
||||
if len(field) > 0 {
|
||||
result = append(result, field)
|
||||
}
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(s) {
|
||||
field := s[start:]
|
||||
if len(field) > 0 {
|
||||
result = append(result, field)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
287
packages/browseros-agent/apps/cli/cmd/launch.go
Normal file
@@ -0,0 +1,287 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"browseros-cli/output"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// macOS bundle identifier — verified from BrowserOS.app/Contents/Info.plist
|
||||
const browserOSBundleID = "com.browseros.BrowserOS"
|
||||
|
||||
func init() {
|
||||
cmd := &cobra.Command{
|
||||
Use: "launch",
|
||||
Short: "Launch the BrowserOS application",
|
||||
Long: `Find and launch the BrowserOS application.
|
||||
|
||||
Uses platform-native detection to find BrowserOS, launches it,
|
||||
and waits for the server to become ready.
|
||||
|
||||
If BrowserOS is already running, reports the server URL.`,
|
||||
Annotations: map[string]string{"group": "Setup:"},
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
green := color.New(color.FgGreen)
|
||||
dim := color.New(color.Faint)
|
||||
waitSecs, _ := cmd.Flags().GetInt("wait")
|
||||
|
||||
if url := probeRunningServer(); url != "" {
|
||||
green.Printf("BrowserOS is already running at %s\n", url)
|
||||
return
|
||||
}
|
||||
|
||||
if !isBrowserOSInstalled() {
|
||||
output.Error("BrowserOS is not installed.\n\n"+
|
||||
" To install: browseros-cli install", 1)
|
||||
}
|
||||
|
||||
fmt.Println("Launching BrowserOS...")
|
||||
if err := startBrowserOS(); err != nil {
|
||||
output.Errorf(1, "failed to launch: %v", err)
|
||||
}
|
||||
|
||||
fmt.Print("Waiting for server")
|
||||
url, ok := waitForServer(time.Duration(waitSecs) * time.Second)
|
||||
fmt.Println()
|
||||
|
||||
if !ok {
|
||||
output.Error("BrowserOS launched but server didn't respond within "+
|
||||
fmt.Sprintf("%d seconds.\n", waitSecs)+
|
||||
" Check if BrowserOS is fully loaded, then retry.", 1)
|
||||
}
|
||||
|
||||
green.Printf("BrowserOS is ready at %s\n", url)
|
||||
fmt.Println()
|
||||
dim.Println("Next: browseros-cli init --auto")
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().Int("wait", 30, "Seconds to wait for server to start")
|
||||
rootCmd.AddCommand(cmd)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server probing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// probeRunningServer checks server.json, config, and common ports for a running server.
|
||||
func probeRunningServer() string {
|
||||
check := func(baseURL string) bool {
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
resp, err := client.Get(baseURL + "/health")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode == 200
|
||||
}
|
||||
|
||||
// 1. server.json — written by BrowserOS on startup with the actual port
|
||||
if url := loadBrowserosServerURL(); url != "" && check(url) {
|
||||
return url
|
||||
}
|
||||
|
||||
// 2. Saved config / env var
|
||||
if url := defaultServerURL(); url != "" && check(url) {
|
||||
return url
|
||||
}
|
||||
|
||||
// 3. Probe common BrowserOS ports as last resort
|
||||
for _, port := range []int{9100, 9200, 9300} {
|
||||
url := fmt.Sprintf("http://127.0.0.1:%d", port)
|
||||
if check(url) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Platform-native installation detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// isBrowserOSInstalled checks if BrowserOS is installed using platform-native methods.
|
||||
//
|
||||
// macOS: `open -Ra "BrowserOS"` — queries Launch Services (finds apps anywhere)
|
||||
// Linux: checks /usr/bin/browseros (.deb), browseros.desktop, or AppImage files
|
||||
// Windows: checks executable at %LOCALAPPDATA%\BrowserOS\Application\BrowserOS.exe
|
||||
// and registry uninstall key (per-user Chromium install pattern)
|
||||
func isBrowserOSInstalled() bool {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
// open -Ra checks if Launch Services knows about the app without launching it.
|
||||
// Works regardless of where the app is installed.
|
||||
return exec.Command("open", "-Ra", "BrowserOS").Run() == nil
|
||||
|
||||
case "linux":
|
||||
// .deb install puts `browseros` in /usr/bin/
|
||||
if _, err := exec.LookPath("browseros"); err == nil {
|
||||
return true
|
||||
}
|
||||
// .deb also creates browseros.desktop
|
||||
for _, dir := range []string{
|
||||
"/usr/share/applications",
|
||||
filepath.Join(userHomeDir(), ".local/share/applications"),
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join(dir, "browseros.desktop")); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// AppImage — user may have it in ~/Downloads, ~/Applications, etc.
|
||||
return findLinuxAppImage() != ""
|
||||
|
||||
case "windows":
|
||||
// Chromium per-user install: %LOCALAPPDATA%\BrowserOS\Application\BrowserOS.exe
|
||||
if exePath := windowsBrowserOSExe(); exePath != "" {
|
||||
if _, err := os.Stat(exePath); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Fallback: check uninstall registry (per-user install uses HKCU)
|
||||
for _, root := range []string{"HKCU", "HKLM"} {
|
||||
key := root + `\Software\Microsoft\Windows\CurrentVersion\Uninstall\BrowserOS`
|
||||
if exec.Command("reg", "query", key, "/v", "DisplayName").Run() == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Platform-native launch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// startBrowserOS launches BrowserOS using platform-native methods.
|
||||
//
|
||||
// macOS: `open -b com.browseros.BrowserOS` — launches by bundle ID
|
||||
// Linux: runs `browseros` binary or AppImage directly
|
||||
// Windows: runs BrowserOS.exe from the known install path
|
||||
func startBrowserOS() error {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
// Launch by bundle ID via Launch Services — no hardcoded paths needed.
|
||||
return exec.Command("open", "-b", browserOSBundleID).Run()
|
||||
|
||||
case "linux":
|
||||
// .deb install: browseros is in PATH
|
||||
if p, err := exec.LookPath("browseros"); err == nil {
|
||||
return startDetached(p)
|
||||
}
|
||||
// AppImage: run it directly
|
||||
if appImage := findLinuxAppImage(); appImage != "" {
|
||||
return startDetached(appImage)
|
||||
}
|
||||
// .desktop file: use gtk-launch (not xdg-open, which opens by MIME type)
|
||||
if _, err := exec.LookPath("gtk-launch"); err == nil {
|
||||
return exec.Command("gtk-launch", "browseros").Run()
|
||||
}
|
||||
return fmt.Errorf("BrowserOS found but could not determine how to launch it")
|
||||
|
||||
case "windows":
|
||||
if exePath := windowsBrowserOSExe(); exePath != "" {
|
||||
if _, err := os.Stat(exePath); err == nil {
|
||||
return startDetached(exePath)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("BrowserOS.exe not found at expected location")
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// startDetached starts a process in the background without inheriting stdio.
|
||||
func startDetached(path string, args ...string) error {
|
||||
cmd := exec.Command(path, args...)
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
cmd.Stdin = nil
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
// windowsBrowserOSExe returns the expected BrowserOS.exe path on Windows.
|
||||
// Chromium per-user installs go to %LOCALAPPDATA%\<base_app_name>\Application\<binary>.
|
||||
// base_app_name = "BrowserOS" (from chromium_install_modes.h)
|
||||
func windowsBrowserOSExe() string {
|
||||
localAppData := os.Getenv("LOCALAPPDATA")
|
||||
if localAppData == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(localAppData, "BrowserOS", "Application", "BrowserOS.exe")
|
||||
}
|
||||
|
||||
// findLinuxAppImage searches common locations for a BrowserOS AppImage.
|
||||
func findLinuxAppImage() string {
|
||||
home := userHomeDir()
|
||||
if home == "" {
|
||||
return ""
|
||||
}
|
||||
for _, dir := range []string{
|
||||
home,
|
||||
filepath.Join(home, "Applications"),
|
||||
filepath.Join(home, "Downloads"),
|
||||
"/opt",
|
||||
} {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if strings.HasPrefix(name, "BrowserOS") && strings.HasSuffix(name, ".AppImage") {
|
||||
return filepath.Join(dir, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// userHomeDir returns the home directory or empty string.
|
||||
func userHomeDir() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return home
|
||||
}
|
||||
|
||||
// waitForServer polls until a BrowserOS server responds or timeout.
|
||||
func waitForServer(maxWait time.Duration) (string, bool) {
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
deadline := time.Now().Add(maxWait)
|
||||
|
||||
for time.Now().Before(deadline) {
|
||||
// server.json is written by BrowserOS on startup with the actual port
|
||||
if url := loadBrowserosServerURL(); url != "" {
|
||||
resp, err := client.Get(url + "/health")
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == 200 {
|
||||
return url, true
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Print(".")
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -165,16 +167,52 @@ func envBool(key string) bool {
|
||||
}
|
||||
|
||||
func defaultServerURL() string {
|
||||
// 1. Explicit env var always wins
|
||||
if env := normalizeServerURL(os.Getenv("BROWSEROS_URL")); env != "" {
|
||||
return env
|
||||
}
|
||||
|
||||
// 2. Live discovery file from running BrowserOS (most current)
|
||||
if url := loadBrowserosServerURL(); url != "" {
|
||||
return url
|
||||
}
|
||||
|
||||
// 3. Saved config (may be stale if port changed)
|
||||
cfg, err := config.Load()
|
||||
if err == nil {
|
||||
if url := normalizeServerURL(cfg.ServerURL); 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 {
|
||||
@@ -190,6 +228,9 @@ func validateServerURL(raw string) (string, error) {
|
||||
}
|
||||
|
||||
return "", fmt.Errorf(
|
||||
"BrowserOS server URL is not configured.\n Open BrowserOS -> Settings -> BrowserOS MCP and copy the Server URL.\n Then run: browseros-cli init",
|
||||
"BrowserOS server URL is not configured.\n\n" +
|
||||
" If BrowserOS is running: browseros-cli init --auto\n" +
|
||||
" If BrowserOS is closed: browseros-cli launch\n" +
|
||||
" If not installed: browseros-cli install",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -44,7 +44,10 @@ func (c *Client) connect(ctx context.Context) (*sdkmcp.ClientSession, error) {
|
||||
|
||||
session, err := sdkClient.Connect(ctx, transport, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot connect to BrowserOS at %s: %w\n Is the server running? Try: browseros-cli init", c.BaseURL, err)
|
||||
return nil, fmt.Errorf("cannot connect to BrowserOS at %s: %w\n\n"+
|
||||
" If BrowserOS is running on a different port: browseros-cli init --auto\n"+
|
||||
" If BrowserOS is not running: browseros-cli launch\n"+
|
||||
" If not installed: browseros-cli install", c.BaseURL, err)
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
@@ -184,7 +187,10 @@ func (c *Client) Status() (map[string]any, error) {
|
||||
func (c *Client) restGET(path string) (map[string]any, error) {
|
||||
resp, err := c.HTTPClient.Get(c.BaseURL + path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot connect to BrowserOS at %s: %w\n Try: browseros-cli init", c.BaseURL, err)
|
||||
return nil, fmt.Errorf("cannot connect to BrowserOS at %s: %w\n\n"+
|
||||
" If BrowserOS is running on a different port: browseros-cli init --auto\n"+
|
||||
" If BrowserOS is not running: browseros-cli launch\n"+
|
||||
" If not installed: browseros-cli install", c.BaseURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
|
||||
147
packages/browseros-agent/apps/cli/scripts/install.ps1
Normal file
@@ -0,0 +1,147 @@
|
||||
#
|
||||
# Install browseros-cli for Windows — downloads the latest release binary.
|
||||
#
|
||||
# Usage (PowerShell — save and run):
|
||||
# Invoke-WebRequest -Uri "https://cdn.browseros.com/cli/install.ps1" -OutFile install.ps1
|
||||
# .\install.ps1
|
||||
# .\install.ps1 -Version "0.1.0" -Dir "C:\tools\browseros"
|
||||
#
|
||||
# Usage (one-liner, uses env vars for options):
|
||||
# & { $env:BROWSEROS_VERSION="0.1.0"; irm https://cdn.browseros.com/cli/install.ps1 | iex }
|
||||
#
|
||||
|
||||
param(
|
||||
[string]$Version = "",
|
||||
[string]$Dir = ""
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# TLS 1.2 — required for GitHub, older PS 5.1 defaults to TLS 1.0
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
|
||||
$Repo = "browseros-ai/BrowserOS"
|
||||
$Binary = "browseros-cli"
|
||||
|
||||
# When piped via irm | iex, param() is ignored — fall back to env vars
|
||||
if (-not $Version) { $Version = $env:BROWSEROS_VERSION }
|
||||
if (-not $Dir) { $Dir = if ($env:BROWSEROS_DIR) { $env:BROWSEROS_DIR } else { "$env:LOCALAPPDATA\browseros-cli\bin" } }
|
||||
|
||||
# ── Resolve latest version ───────────────────────────────────────────────────
|
||||
|
||||
if (-not $Version) {
|
||||
Write-Host "Fetching latest version..."
|
||||
$releases = Invoke-RestMethod "https://api.github.com/repos/$Repo/releases?per_page=100"
|
||||
$tag = ($releases `
|
||||
| Where-Object { $_.tag_name -match "^browseros-cli-v" -and $_.tag_name -notmatch "-rc" } `
|
||||
| Select-Object -First 1).tag_name
|
||||
if (-not $tag) {
|
||||
Write-Error "Could not determine latest version. Try: -Version 0.1.0"
|
||||
exit 1
|
||||
}
|
||||
$Version = $tag -replace "^browseros-cli-v", ""
|
||||
}
|
||||
|
||||
Write-Host "Installing browseros-cli v$Version..."
|
||||
|
||||
# ── Detect architecture ──────────────────────────────────────────────────────
|
||||
|
||||
# $env:PROCESSOR_ARCHITECTURE lies under x64 emulation on ARM64 Windows.
|
||||
# Use .NET RuntimeInformation when available, fall back to PROCESSOR_ARCHITEW6432.
|
||||
$Arch = "amd64"
|
||||
try {
|
||||
$osArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture
|
||||
if ($osArch -eq [System.Runtime.InteropServices.Architecture]::Arm64) { $Arch = "arm64" }
|
||||
} catch {
|
||||
if ($env:PROCESSOR_ARCHITEW6432 -eq "ARM64" -or $env:PROCESSOR_ARCHITECTURE -eq "ARM64") {
|
||||
$Arch = "arm64"
|
||||
}
|
||||
}
|
||||
|
||||
if (-not [Environment]::Is64BitOperatingSystem) {
|
||||
Write-Error "32-bit Windows is not supported."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── Download and extract ─────────────────────────────────────────────────────
|
||||
|
||||
$Tag = "browseros-cli-v$Version"
|
||||
$Filename = "${Binary}_${Version}_windows_${Arch}.zip"
|
||||
$Url = "https://github.com/$Repo/releases/download/$Tag/$Filename"
|
||||
$ChecksumUrl = "https://github.com/$Repo/releases/download/$Tag/checksums.txt"
|
||||
$TmpDir = Join-Path ([System.IO.Path]::GetTempPath()) ("browseros-cli-install-" + [System.IO.Path]::GetRandomFileName())
|
||||
|
||||
try {
|
||||
New-Item -ItemType Directory -Path $TmpDir | Out-Null
|
||||
|
||||
$ZipPath = Join-Path $TmpDir $Filename
|
||||
|
||||
Write-Host "Downloading $Url..."
|
||||
Invoke-WebRequest -Uri $Url -OutFile $ZipPath -UseBasicParsing
|
||||
|
||||
$ChecksumPath = Join-Path $TmpDir "checksums.txt"
|
||||
$ChecksumAvailable = $true
|
||||
try {
|
||||
Invoke-WebRequest -Uri $ChecksumUrl -OutFile $ChecksumPath -UseBasicParsing
|
||||
} catch {
|
||||
$ChecksumAvailable = $false
|
||||
Write-Warning "Could not fetch checksums.txt; skipping checksum verification. $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
if ($ChecksumAvailable) {
|
||||
$ExpectedChecksum = $null
|
||||
foreach ($line in Get-Content $ChecksumPath) {
|
||||
$parts = $line -split '\s+', 2
|
||||
if ($parts.Length -eq 2 -and $parts[1] -eq $Filename) {
|
||||
$ExpectedChecksum = $parts[0].ToLowerInvariant()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ($ExpectedChecksum) {
|
||||
$ActualChecksum = (Get-FileHash -Path $ZipPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($ActualChecksum -ne $ExpectedChecksum) {
|
||||
Write-Error "Checksum mismatch (expected $ExpectedChecksum, got $ActualChecksum)"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Checksum verified."
|
||||
} else {
|
||||
Write-Warning "Checksum not found in checksums.txt; skipping checksum verification."
|
||||
}
|
||||
}
|
||||
|
||||
Expand-Archive -Path $ZipPath -DestinationPath $TmpDir -Force
|
||||
|
||||
$Exe = Get-ChildItem -Path $TmpDir -Filter "$Binary.exe" -File -Recurse | Select-Object -First 1
|
||||
if (-not $Exe) {
|
||||
Write-Error "Binary not found in archive."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── Install ──────────────────────────────────────────────────────────────
|
||||
|
||||
if (-not (Test-Path $Dir)) {
|
||||
New-Item -ItemType Directory -Path $Dir -Force | Out-Null
|
||||
}
|
||||
|
||||
Move-Item -Force $Exe.FullName (Join-Path $Dir "$Binary.exe")
|
||||
|
||||
Write-Host "Installed $Binary.exe to $Dir"
|
||||
} finally {
|
||||
if (Test-Path $TmpDir) { Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue }
|
||||
}
|
||||
|
||||
# ── PATH ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
$UserPath = [Environment]::GetEnvironmentVariable("Path", "User")
|
||||
$PathEntries = $UserPath -split ";" | Where-Object { $_ -ne "" }
|
||||
if ($Dir -notin $PathEntries) {
|
||||
Write-Host ""
|
||||
Write-Host "Adding $Dir to your user PATH..."
|
||||
[Environment]::SetEnvironmentVariable("Path", "$Dir;$UserPath", "User")
|
||||
$env:Path = "$Dir;$env:Path"
|
||||
Write-Host "Done. Restart your terminal for PATH changes to take effect."
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Run 'browseros-cli --help' to get started."
|
||||
153
packages/browseros-agent/apps/cli/scripts/install.sh
Executable file
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install browseros-cli — downloads the latest release binary for your platform.
|
||||
#
|
||||
# Usage:
|
||||
# curl -fsSL https://cdn.browseros.com/cli/install.sh | bash
|
||||
#
|
||||
# # Or with options:
|
||||
# curl -fsSL https://cdn.browseros.com/cli/install.sh | bash -s -- --version 0.1.0 --dir /usr/local/bin
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO="browseros-ai/BrowserOS"
|
||||
BINARY="browseros-cli"
|
||||
INSTALL_DIR="${HOME}/.browseros/bin"
|
||||
|
||||
# ── Parse arguments ──────────────────────────────────────────────────────────
|
||||
|
||||
VERSION=""
|
||||
CUSTOM_DIR=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--version)
|
||||
[[ $# -lt 2 ]] && { echo "Error: --version requires a value" >&2; exit 1; }
|
||||
VERSION="$2"; shift 2 ;;
|
||||
--dir)
|
||||
[[ $# -lt 2 ]] && { echo "Error: --dir requires a value" >&2; exit 1; }
|
||||
CUSTOM_DIR="$2"; shift 2 ;;
|
||||
--help)
|
||||
echo "Usage: install.sh [--version VERSION] [--dir INSTALL_DIR]"
|
||||
echo ""
|
||||
echo " --version Install a specific version (default: latest)"
|
||||
echo " --dir Install directory (default: ~/.browseros/bin)"
|
||||
exit 0
|
||||
;;
|
||||
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -n "$CUSTOM_DIR" ]] && INSTALL_DIR="$CUSTOM_DIR"
|
||||
|
||||
# ── Resolve latest version ───────────────────────────────────────────────────
|
||||
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
# Use per_page=1 with a tag name filter via the releases endpoint.
|
||||
# The tags all start with "browseros-cli-v" so we grab page 1 of those.
|
||||
VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases?per_page=100" \
|
||||
| grep -o '"tag_name": *"browseros-cli-v[^"]*"' \
|
||||
| grep -v -- "-rc" \
|
||||
| head -1 \
|
||||
| sed 's/.*browseros-cli-v//; s/"//')
|
||||
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
echo "Error: could not determine latest version." >&2
|
||||
echo " Try: install.sh --version 0.1.0" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Installing browseros-cli v${VERSION}..."
|
||||
|
||||
# ── Detect platform ──────────────────────────────────────────────────────────
|
||||
|
||||
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
ARCH=$(uname -m)
|
||||
|
||||
case "$OS" in
|
||||
darwin) OS="darwin" ;;
|
||||
linux) OS="linux" ;;
|
||||
*) echo "Error: unsupported OS: $OS" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
case "$ARCH" in
|
||||
x86_64|amd64) ARCH="amd64" ;;
|
||||
arm64|aarch64) ARCH="arm64" ;;
|
||||
*) echo "Error: unsupported architecture: $ARCH" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
# ── Download and extract ─────────────────────────────────────────────────────
|
||||
|
||||
FILENAME="${BINARY}_${VERSION}_${OS}_${ARCH}.tar.gz"
|
||||
TAG="browseros-cli-v${VERSION}"
|
||||
URL="https://github.com/${REPO}/releases/download/${TAG}/${FILENAME}"
|
||||
CHECKSUM_URL="https://github.com/${REPO}/releases/download/${TAG}/checksums.txt"
|
||||
|
||||
TMPDIR_DL=$(mktemp -d)
|
||||
trap 'rm -rf "$TMPDIR_DL"' EXIT
|
||||
|
||||
echo "Downloading ${URL}..."
|
||||
curl -fSL --progress-bar -o "${TMPDIR_DL}/${FILENAME}" "$URL"
|
||||
|
||||
# Verify checksum if sha256sum/shasum is available
|
||||
if curl -fsSL -o "${TMPDIR_DL}/checksums.txt" "$CHECKSUM_URL" 2>/dev/null; then
|
||||
expected=$(awk -v filename="$FILENAME" '$2 == filename { print $1; exit }' "${TMPDIR_DL}/checksums.txt")
|
||||
if [[ -n "$expected" ]]; then
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
actual=$(sha256sum "${TMPDIR_DL}/${FILENAME}" | awk '{print $1}')
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
actual=$(shasum -a 256 "${TMPDIR_DL}/${FILENAME}" | awk '{print $1}')
|
||||
else
|
||||
actual=""
|
||||
echo "Warning: no sha256sum/shasum found; skipping checksum verification." >&2
|
||||
fi
|
||||
if [[ -n "$actual" && "$actual" != "$expected" ]]; then
|
||||
echo "Error: checksum mismatch (expected ${expected}, got ${actual})" >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ -n "$actual" ]] && echo "Checksum verified."
|
||||
else
|
||||
echo "Warning: checksum not found in checksums.txt; skipping verification." >&2
|
||||
fi
|
||||
else
|
||||
echo "Warning: could not fetch checksums.txt; skipping checksum verification." >&2
|
||||
fi
|
||||
|
||||
tar -xzf "${TMPDIR_DL}/${FILENAME}" -C "$TMPDIR_DL"
|
||||
|
||||
BINARY_PATH="${TMPDIR_DL}/${BINARY}"
|
||||
if [[ ! -f "$BINARY_PATH" ]]; then
|
||||
BINARY_PATH=$(find "$TMPDIR_DL" -type f -name "$BINARY" -print -quit)
|
||||
fi
|
||||
|
||||
if [[ -z "$BINARY_PATH" || ! -f "$BINARY_PATH" ]]; then
|
||||
echo "Error: binary not found in archive." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Install ──────────────────────────────────────────────────────────────────
|
||||
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
mv "$BINARY_PATH" "${INSTALL_DIR}/${BINARY}"
|
||||
chmod +x "${INSTALL_DIR}/${BINARY}"
|
||||
|
||||
echo "Installed ${BINARY} to ${INSTALL_DIR}/${BINARY}"
|
||||
|
||||
# ── PATH hint ────────────────────────────────────────────────────────────────
|
||||
|
||||
if ! echo "$PATH" | tr ':' '\n' | grep -qx "$INSTALL_DIR"; then
|
||||
echo ""
|
||||
echo "Add browseros-cli to your PATH:"
|
||||
echo ""
|
||||
|
||||
SHELL_NAME=$(basename "${SHELL:-/bin/bash}")
|
||||
case "$SHELL_NAME" in
|
||||
zsh) echo " echo 'export PATH=\"${INSTALL_DIR}:\$PATH\"' >> ~/.zshrc && source ~/.zshrc" ;;
|
||||
fish) echo " fish_add_path ${INSTALL_DIR}" ;;
|
||||
*) echo " echo 'export PATH=\"${INSTALL_DIR}:\$PATH\"' >> ~/.bashrc && source ~/.bashrc" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Run 'browseros-cli --help' to get started."
|
||||
@@ -1,2 +1,3 @@
|
||||
data/raw/
|
||||
results/
|
||||
extensions/
|
||||
|
||||
@@ -7,19 +7,23 @@
|
||||
"baseUrl": "https://api.fireworks.ai/inference/v1",
|
||||
"supportsImages": true
|
||||
},
|
||||
"dataset": "../data/webvoyager_e2e_test.jsonl",
|
||||
"output_dir": "../results/webvoyager-test",
|
||||
"num_workers": 3,
|
||||
"dataset": "../data/webbench-2of4-50.jsonl",
|
||||
"num_workers": 10,
|
||||
"restart_server_per_task": true,
|
||||
"browseros": {
|
||||
"server_url": "http://127.0.0.1:9110",
|
||||
"base_cdp_port": 9010,
|
||||
"base_server_port": 9110,
|
||||
"base_extension_port": 9310,
|
||||
"load_extensions": false,
|
||||
"headless": true
|
||||
"headless": false
|
||||
},
|
||||
"captcha": {
|
||||
"api_key_env": "NOPECHA_API_KEY"
|
||||
},
|
||||
"graders": ["performance_grader"],
|
||||
"grader_api_key_env": "OPENROUTER_API_KEY",
|
||||
"grader_base_url": "https://openrouter.ai/api/v1",
|
||||
"grader_model": "openai/gpt-4.1",
|
||||
"timeout_ms": 1200000
|
||||
"timeout_ms": 1800000
|
||||
}
|
||||
@@ -2,24 +2,21 @@
|
||||
"agent": {
|
||||
"type": "orchestrator-executor",
|
||||
"orchestrator": {
|
||||
"type": "single",
|
||||
"provider": "openai-compatible",
|
||||
"model": "accounts/fireworks/models/kimi-k2p5",
|
||||
"apiKey": "FIREWORKS_API_KEY",
|
||||
"baseUrl": "https://api.fireworks.ai/inference/v1",
|
||||
"supportsImages": true
|
||||
"baseUrl": "https://api.fireworks.ai/inference/v1"
|
||||
},
|
||||
"executor": {
|
||||
"provider": "openai-compatible",
|
||||
"model": "accounts/fireworks/models/kimi-k2p5",
|
||||
"apiKey": "FIREWORKS_API_KEY",
|
||||
"baseUrl": "https://api.fireworks.ai/inference/v1",
|
||||
"supportsImages": true
|
||||
"baseUrl": "https://api.fireworks.ai/inference/v1"
|
||||
}
|
||||
},
|
||||
"dataset": "../data/webvoyager_e2e_test.jsonl",
|
||||
"output_dir": "../results/orchestrator-executor-webvoyager-test",
|
||||
"num_workers": 3,
|
||||
"dataset": "../data/webbench-2of4-50.jsonl",
|
||||
"num_workers": 10,
|
||||
"restart_server_per_task": true,
|
||||
"browseros": {
|
||||
"server_url": "http://127.0.0.1:9110",
|
||||
"base_cdp_port": 9010,
|
||||
@@ -28,8 +25,12 @@
|
||||
"load_extensions": false,
|
||||
"headless": false
|
||||
},
|
||||
"captcha": {
|
||||
"api_key_env": "NOPECHA_API_KEY"
|
||||
},
|
||||
"graders": ["performance_grader"],
|
||||
"grader_api_key_env": "OPENROUTER_API_KEY",
|
||||
"grader_base_url": "https://openrouter.ai/api/v1",
|
||||
"grader_model": "openai/gpt-4.1",
|
||||
"timeout_ms": 1200000
|
||||
"timeout_ms": 1800000
|
||||
}
|
||||
@@ -14,19 +14,23 @@
|
||||
"baseUrl": "https://clado-ai--clado-browseros-action-actionmodel-generate.modal.run"
|
||||
}
|
||||
},
|
||||
"dataset": "../data/webvoyager_e2e_test.jsonl",
|
||||
"output_dir": "../results/orchestrator-executor-clado-webvoyager-test",
|
||||
"num_workers": 3,
|
||||
"dataset": "../data/webbench-2of4-50.jsonl",
|
||||
"num_workers": 10,
|
||||
"restart_server_per_task": true,
|
||||
"browseros": {
|
||||
"server_url": "http://127.0.0.1:9110",
|
||||
"base_cdp_port": 9010,
|
||||
"base_server_port": 9110,
|
||||
"base_extension_port": 9310,
|
||||
"load_extensions": false,
|
||||
"headless": true
|
||||
"headless": false
|
||||
},
|
||||
"captcha": {
|
||||
"api_key_env": "NOPECHA_API_KEY"
|
||||
},
|
||||
"graders": ["performance_grader"],
|
||||
"grader_api_key_env": "OPENROUTER_API_KEY",
|
||||
"grader_base_url": "https://openrouter.ai/api/v1",
|
||||
"grader_model": "openai/gpt-4.1",
|
||||
"timeout_ms": 1200000
|
||||
"timeout_ms": 1800000
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"agent": {
|
||||
"type": "orchestrator-executor",
|
||||
"orchestrator": {
|
||||
"provider": "openrouter",
|
||||
"model": "openai/gpt-4o",
|
||||
"apiKey": "OPENROUTER_API_KEY",
|
||||
"maxTurns": 3
|
||||
},
|
||||
"executor": {
|
||||
"provider": "openrouter",
|
||||
"model": "openai/gpt-4o",
|
||||
"apiKey": "OPENROUTER_API_KEY"
|
||||
}
|
||||
},
|
||||
"dataset": "../data/webvoyager_e2e_test.jsonl",
|
||||
"output_dir": "../results/debug-test",
|
||||
"num_workers": 1,
|
||||
"browseros": {
|
||||
"server_url": "http://127.0.0.1:9110"
|
||||
},
|
||||
"timeout_ms": 90000
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"agent": {
|
||||
"type": "single",
|
||||
"provider": "openai-compatible",
|
||||
"model": "accounts/fireworks/models/kimi-k2p5",
|
||||
"apiKey": "FIREWORKS_API_KEY",
|
||||
"baseUrl": "https://api.fireworks.ai/inference/v1",
|
||||
"supportsImages": true
|
||||
},
|
||||
"dataset": "../data/test-set.jsonl",
|
||||
"output_dir": "../results/fireworks-minimax-k2p5-test-set",
|
||||
"num_workers": 1,
|
||||
"restart_server_per_task": true,
|
||||
"browseros": {
|
||||
"server_url": "http://127.0.0.1:9110"
|
||||
},
|
||||
"grader_api_key_env": "OPENROUTER_API_KEY",
|
||||
"grader_base_url": "https://openrouter.ai/api/v1",
|
||||
"grader_model": "openai/o4-mini-high",
|
||||
"timeout_ms": 3600000
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"agent": {
|
||||
"type": "single",
|
||||
"provider": "openrouter",
|
||||
"model": "openai/gpt-4.1",
|
||||
"apiKey": "OPENROUTER_API_KEY"
|
||||
},
|
||||
"dataset": "../data/mind2web_e2e_test.jsonl",
|
||||
"output_dir": "../results/mind2web-test",
|
||||
"num_workers": 5,
|
||||
"browseros": {
|
||||
"server_url": "http://127.0.0.1:9110"
|
||||
},
|
||||
"grader_api_key_env": "OPENROUTER_API_KEY",
|
||||
"grader_base_url": "https://openrouter.ai/api/v1",
|
||||
"grader_model": "openai/gpt-4.1",
|
||||
"timeout_ms": 300000
|
||||
}
|
||||
@@ -9,12 +9,20 @@
|
||||
"turnLimit": 100
|
||||
},
|
||||
"dataset": "../data/test-set.jsonl",
|
||||
"output_dir": "../results/gemini-computer-use-test-set2",
|
||||
"num_workers": 1,
|
||||
"restart_server_per_task": true,
|
||||
"browseros": {
|
||||
"server_url": "http://127.0.0.1:9110"
|
||||
"server_url": "http://127.0.0.1:9110",
|
||||
"base_cdp_port": 9010,
|
||||
"base_server_port": 9110,
|
||||
"base_extension_port": 9310,
|
||||
"load_extensions": false,
|
||||
"headless": false
|
||||
},
|
||||
"captcha": {
|
||||
"api_key_env": "NOPECHA_API_KEY"
|
||||
},
|
||||
"graders": ["performance_grader"],
|
||||
"grader_api_key_env": "OPENROUTER_API_KEY",
|
||||
"grader_base_url": "https://openrouter.ai/api/v1",
|
||||
"grader_model": "openai/gpt-4.1",
|
||||