mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
Compare commits
76 Commits
fix/build-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dde403962f | ||
|
|
4a3b9ff294 | ||
|
|
1a2fe3a5bf | ||
|
|
392cd58932 | ||
|
|
b5bbbe1aff | ||
|
|
4f03afcac8 | ||
|
|
6d3498c91b | ||
|
|
7f2e387903 | ||
|
|
fc00ed23bf | ||
|
|
b6d6d4eb1d | ||
|
|
f78068bb9d | ||
|
|
6b18ebb1d8 | ||
|
|
1f2e783ab9 | ||
|
|
df7873562d | ||
|
|
412386b489 | ||
|
|
33617ba9e7 | ||
|
|
6712e1d321 | ||
|
|
94540d9e87 | ||
|
|
bb62213e84 | ||
|
|
dee3086a48 | ||
|
|
8de2bf984f | ||
|
|
1b8720740c | ||
|
|
91be726381 | ||
|
|
ff5386a24a | ||
|
|
a5f3c4da65 | ||
|
|
e5a852dd3d | ||
|
|
aee30ce8e1 | ||
|
|
0833c8d42d | ||
|
|
036c7f280b | ||
|
|
000429277d | ||
|
|
f8535fd96d | ||
|
|
f0cbf77924 | ||
|
|
17be06eb2f | ||
|
|
0e90785500 | ||
|
|
2bb432b0f2 | ||
|
|
565ce18eba | ||
|
|
81350c0d7f | ||
|
|
9bdb2413ec | ||
|
|
ace9307878 | ||
|
|
83a25ad301 | ||
|
|
4b191a759c | ||
|
|
d02b3f74e6 | ||
|
|
86c62f14a5 | ||
|
|
42c3e8fe01 | ||
|
|
517750e880 | ||
|
|
6c053a5f29 | ||
|
|
1c5ffdf878 | ||
|
|
39a7d49c25 | ||
|
|
ed948f4b59 | ||
|
|
aad5bc16fd | ||
|
|
cee318a40b | ||
|
|
febaf58f91 | ||
|
|
aacb47f7ee | ||
|
|
b3003542d8 | ||
|
|
aba7a10430 | ||
|
|
b7462aa042 | ||
|
|
883bcc9670 | ||
|
|
279b41fdc4 | ||
|
|
220577b41c | ||
|
|
03b45013a6 | ||
|
|
aa85907212 | ||
|
|
085352a6f0 | ||
|
|
c0578d0e53 | ||
|
|
663c18ee97 | ||
|
|
48727750b4 | ||
|
|
30a3a96a57 | ||
|
|
6773ce39da | ||
|
|
342a3e4a07 | ||
|
|
09406ea794 | ||
|
|
1f00cbc9cc | ||
|
|
422a829f5e | ||
|
|
ed109fcedf | ||
|
|
19af96d08e | ||
|
|
e0304b203c | ||
|
|
af65bdbcfb | ||
|
|
d79c2a4123 |
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -9,4 +9,6 @@ packages/browseros/chromium_patches/**/*.py linguist-generated
|
||||
scripts/*.py linguist-generated
|
||||
# Mark build directories as generated
|
||||
build/* linguist-generated
|
||||
# Mark eval/test framework as vendored so it's excluded from language stats
|
||||
packages/browseros-agent/apps/eval/** linguist-vendored
|
||||
docs/videos/** filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
10
.github/workflows/eval-weekly.yml
vendored
10
.github/workflows/eval-weekly.yml
vendored
@@ -43,6 +43,12 @@ jobs:
|
||||
working-directory: packages/browseros-agent
|
||||
run: bun install --ignore-scripts && bun run build:agent-sdk
|
||||
|
||||
- name: Install Python eval dependencies
|
||||
run: pip install agisdk requests
|
||||
|
||||
- name: Clone WebArena-Infinity
|
||||
run: git clone --depth 1 https://github.com/web-arena-x/webarena-infinity.git /tmp/webarena-infinity
|
||||
|
||||
- name: Install xvfb
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb
|
||||
|
||||
@@ -57,9 +63,11 @@ jobs:
|
||||
working-directory: packages/browseros-agent/apps/eval
|
||||
env:
|
||||
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
NOPECHA_API_KEY: ${{ secrets.NOPECHA_API_KEY }}
|
||||
BROWSEROS_BINARY: /usr/bin/browseros
|
||||
WEBARENA_INFINITY_DIR: /tmp/webarena-infinity
|
||||
EVAL_CONFIG: ${{ github.event.inputs.config || 'configs/browseros-agent-weekly.json' }}
|
||||
run: |
|
||||
echo "Running eval with config: $EVAL_CONFIG"
|
||||
@@ -81,6 +89,8 @@ jobs:
|
||||
|
||||
- name: Generate trend report
|
||||
if: success()
|
||||
timeout-minutes: 5
|
||||
continue-on-error: true
|
||||
working-directory: packages/browseros-agent
|
||||
env:
|
||||
EVAL_R2_ACCOUNT_ID: ${{ secrets.EVAL_R2_ACCOUNT_ID }}
|
||||
|
||||
148
.github/workflows/release-agent-extension.yml
vendored
Normal file
148
.github/workflows/release-agent-extension.yml
vendored
Normal file
@@ -0,0 +1,148 @@
|
||||
name: Release BrowserOS 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 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 Extension v${VERSION}." \
|
||||
--base main \
|
||||
--head "$BRANCH"
|
||||
|
||||
gh pr merge "$BRANCH" --squash --auto || true
|
||||
working-directory: ${{ github.workspace }}
|
||||
133
.github/workflows/release-agent-sdk.yml
vendored
133
.github/workflows/release-agent-sdk.yml
vendored
@@ -1,18 +1,27 @@
|
||||
name: Release Agent SDK
|
||||
name: Release BrowserOS 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 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 Agent SDK v${VERSION}." \
|
||||
--base main \
|
||||
--head "$BRANCH"
|
||||
|
||||
gh pr merge "$BRANCH" --squash --auto || true
|
||||
working-directory: ${{ github.workspace }}
|
||||
|
||||
150
.github/workflows/release-cli.yml
vendored
150
.github/workflows/release-cli.yml
vendored
@@ -1,16 +1,25 @@
|
||||
name: Release CLI
|
||||
name: Release BrowserOS CLI
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "cli/v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release version (e.g. 0.1.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
concurrency:
|
||||
group: release-cli
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
environment: release-core
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/browseros-agent/apps/cli
|
||||
@@ -24,16 +33,129 @@ jobs:
|
||||
with:
|
||||
go-version-file: packages/browseros-agent/apps/cli/go.mod
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.6"
|
||||
|
||||
- name: Run tests
|
||||
run: go test ./... -v
|
||||
run: make test
|
||||
|
||||
- name: Run vet
|
||||
run: go vet ./...
|
||||
run: make vet
|
||||
|
||||
- uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
version: "~> v2"
|
||||
args: release --clean
|
||||
workdir: packages/browseros-agent/apps/cli
|
||||
- name: Build all platforms
|
||||
run: make release VERSION=${{ inputs.version }} POSTHOG_API_KEY=${{ secrets.POSTHOG_API_KEY }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
working-directory: packages/browseros-agent
|
||||
|
||||
- name: Upload to CDN
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_UPLOAD_PREFIX: cli
|
||||
CLI_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
bun scripts/build/cli.ts \
|
||||
--release \
|
||||
--version "$CLI_VERSION" \
|
||||
--binaries-dir apps/cli/dist
|
||||
working-directory: packages/browseros-agent
|
||||
|
||||
- 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 "$CHANGELOG_FILE" > /tmp/release-notes.md
|
||||
cat >> /tmp/release-notes.md <<'EOF'
|
||||
|
||||
## Install `browseros-cli`
|
||||
|
||||
### npm / npx
|
||||
|
||||
```bash
|
||||
npx browseros-cli --help
|
||||
npm install -g 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
|
||||
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 }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Publish to npm
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
make npm-version VERSION=${{ inputs.version }}
|
||||
cd npm
|
||||
npm publish --access public
|
||||
|
||||
147
.github/workflows/release-server.yml
vendored
Normal file
147
.github/workflows/release-server.yml
vendored
Normal file
@@ -0,0 +1,147 @@
|
||||
name: Release BrowserOS Server
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release version (e.g. 0.0.80)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: release-server
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
environment: release-core
|
||||
permissions:
|
||||
contents: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/browseros-agent
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.6"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun ci
|
||||
|
||||
- name: Prepare production env file
|
||||
run: cp apps/server/.env.production.example apps/server/.env.production
|
||||
|
||||
- name: Validate version
|
||||
id: version
|
||||
env:
|
||||
REQUESTED_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
PACKAGE_VERSION=$(node -p "require('./apps/server/package.json').version")
|
||||
echo "package_version=$PACKAGE_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "release_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [ "$PACKAGE_VERSION" != "$REQUESTED_VERSION" ]; then
|
||||
echo "Requested version $REQUESTED_VERSION does not match apps/server/package.json ($PACKAGE_VERSION)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build release artifacts
|
||||
run: bun run build:server:ci
|
||||
|
||||
- name: Verify release artifacts
|
||||
run: |
|
||||
mapfile -t ZIP_FILES < <(find dist/prod/server -maxdepth 1 -type f -name 'browseros-server-resources-*.zip' | sort)
|
||||
|
||||
if [ "${#ZIP_FILES[@]}" -eq 0 ]; then
|
||||
echo "No server release zip files were produced"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'Found release artifacts:\n%s\n' "${ZIP_FILES[@]}"
|
||||
|
||||
- name: Generate release notes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PACKAGE_VERSION: ${{ steps.version.outputs.package_version }}
|
||||
run: |
|
||||
SERVER_APP_PATH="packages/browseros-agent/apps/server"
|
||||
SERVER_BUILD_DIR="packages/browseros-agent/scripts/build/server"
|
||||
SERVER_BUILD_ENTRY="packages/browseros-agent/scripts/build/server.ts"
|
||||
SERVER_RESOURCE_MANIFEST="packages/browseros-agent/scripts/build/config/server-prod-resources.json"
|
||||
SERVER_WORKSPACE_PKG="packages/browseros-agent/package.json"
|
||||
CURRENT_TAG="browseros-server-v$PACKAGE_VERSION"
|
||||
PREV_TAG=$(git tag -l "browseros-server-v*" --sort=-v:refname | grep -v "^${CURRENT_TAG}$" | head -n 1)
|
||||
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
echo "Initial release of browseros-server." > /tmp/release-notes.md
|
||||
else
|
||||
COMMITS=$(git log "$PREV_TAG"..HEAD --pretty=format:"%H" -- \
|
||||
"$SERVER_APP_PATH" \
|
||||
"$SERVER_BUILD_DIR" \
|
||||
"$SERVER_BUILD_ENTRY" \
|
||||
"$SERVER_RESOURCE_MANIFEST" \
|
||||
"$SERVER_WORKSPACE_PKG")
|
||||
|
||||
if [ -z "$COMMITS" ]; then
|
||||
echo "No notable changes." > /tmp/release-notes.md
|
||||
else
|
||||
echo "## What's Changed" > /tmp/release-notes.md
|
||||
echo "" >> /tmp/release-notes.md
|
||||
|
||||
while IFS= read -r SHA; do
|
||||
SUBJECT=$(git log -1 --pretty=format:"%s" "$SHA")
|
||||
PR_NUM=$(gh api "/repos/${{ github.repository }}/commits/${SHA}/pulls" --jq '.[0].number // empty' 2>/dev/null)
|
||||
|
||||
if [ -n "$PR_NUM" ] && ! echo "$SUBJECT" | grep -qF "(#${PR_NUM})"; then
|
||||
echo "- ${SUBJECT} (#${PR_NUM})" >> /tmp/release-notes.md
|
||||
else
|
||||
echo "- ${SUBJECT}" >> /tmp/release-notes.md
|
||||
fi
|
||||
done <<< "$COMMITS"
|
||||
fi
|
||||
fi
|
||||
working-directory: ${{ github.workspace }}
|
||||
|
||||
- name: Create GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PACKAGE_VERSION: ${{ steps.version.outputs.package_version }}
|
||||
RELEASE_SHA: ${{ steps.version.outputs.release_sha }}
|
||||
run: |
|
||||
TAG="browseros-server-v$PACKAGE_VERSION"
|
||||
TITLE="BrowserOS Server - v$PACKAGE_VERSION"
|
||||
mapfile -t ZIP_FILES < <(find packages/browseros-agent/dist/prod/server -maxdepth 1 -type f -name 'browseros-server-resources-*.zip' | sort)
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
if git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
echo "Tag $TAG already exists, skipping tag creation"
|
||||
else
|
||||
git tag -a "$TAG" -m "browseros-server v$PACKAGE_VERSION" "$RELEASE_SHA"
|
||||
fi
|
||||
|
||||
if git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then
|
||||
echo "Tag $TAG already on remote, skipping push"
|
||||
else
|
||||
git push origin "$TAG"
|
||||
fi
|
||||
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
echo "Release $TAG already exists, updating"
|
||||
gh release edit "$TAG" --title "$TITLE" --notes-file /tmp/release-notes.md
|
||||
gh release upload "$TAG" "${ZIP_FILES[@]}" --clobber
|
||||
else
|
||||
gh release create "$TAG" \
|
||||
--title "$TITLE" \
|
||||
--notes-file /tmp/release-notes.md \
|
||||
"${ZIP_FILES[@]}"
|
||||
fi
|
||||
working-directory: ${{ github.workspace }}
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,4 +1,6 @@
|
||||
**/.DS_Store
|
||||
**.auctor/**
|
||||
.auctor.json
|
||||
.gcs_entries
|
||||
**/dmg
|
||||
**/env
|
||||
@@ -29,3 +31,4 @@ packages/browseros/build/tools/
|
||||
|
||||
# AI SDK DevTools traces
|
||||
.devtools/
|
||||
.omc/
|
||||
|
||||
216
README.md
216
README.md
@@ -6,6 +6,7 @@
|
||||
[](https://dub.sh/browserOS-slack)
|
||||
[](https://twitter.com/browseros_ai)
|
||||
[](LICENSE)
|
||||
[](https://docs.browseros.com)
|
||||
<br></br>
|
||||
<a href="https://files.browseros.com/download/BrowserOS.dmg">
|
||||
<img src="https://img.shields.io/badge/Download-macOS-black?style=flat&logo=apple&logoColor=white" alt="Download for macOS (beta)" />
|
||||
@@ -22,146 +23,183 @@
|
||||
<br />
|
||||
</div>
|
||||
|
||||
##
|
||||
🌐 BrowserOS is an open-source Chromium fork that runs AI agents natively. **The privacy-first alternative to ChatGPT Atlas, Perplexity Comet, and Dia.**
|
||||
BrowserOS is an open-source Chromium fork that runs AI agents natively. **The privacy-first alternative to ChatGPT Atlas, Perplexity Comet, and Dia.**
|
||||
|
||||
🔒 Use your own API keys or run local models with Ollama. Your data never leaves your machine.
|
||||
Use your own API keys or run local models with Ollama. Your data never leaves your machine.
|
||||
|
||||
💡 Join our [Discord](https://discord.gg/YKwjt5vuKr) or [Slack](https://dub.sh/browserOS-slack) and help us build! Have feature requests? [Suggest here](https://github.com/browseros-ai/BrowserOS/issues/99).
|
||||
> **[Documentation](https://docs.browseros.com)** · **[Discord](https://discord.gg/YKwjt5vuKr)** · **[Slack](https://dub.sh/browserOS-slack)** · **[Twitter](https://x.com/browserOS_ai)** · **[Feature Requests](https://github.com/browseros-ai/BrowserOS/issues/99)**
|
||||
|
||||
## Quick start
|
||||
## Quick Start
|
||||
|
||||
1. Download and install BrowserOS:
|
||||
- [macOS](https://files.browseros.com/download/BrowserOS.dmg)
|
||||
- [Windows](https://files.browseros.com/download/BrowserOS_installer.exe)
|
||||
- [Linux (AppImage)](https://files.browseros.com/download/BrowserOS.AppImage)
|
||||
- [Linux (Debian)](https://cdn.browseros.com/download/BrowserOS.deb)
|
||||
1. **Download and install** BrowserOS — [macOS](https://files.browseros.com/download/BrowserOS.dmg) · [Windows](https://files.browseros.com/download/BrowserOS_installer.exe) · [Linux (AppImage)](https://files.browseros.com/download/BrowserOS.AppImage) · [Linux (Debian)](https://cdn.browseros.com/download/BrowserOS.deb)
|
||||
2. **Import your Chrome data** (optional) — bookmarks, passwords, extensions all carry over
|
||||
3. **Connect your AI provider** — Claude, OpenAI, Gemini, ChatGPT Pro via OAuth, or local models via Ollama/LM Studio
|
||||
|
||||
2. Import your Chrome data (optional)
|
||||
## Features
|
||||
|
||||
3. Connect your AI provider — use Claude, OpenAI, Gemini, or local models via Ollama and LMStudio.
|
||||
|
||||
4. Start automating!
|
||||
|
||||
## 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
|
||||
- 🔒 Privacy first — bring your own keys or run local models with Ollama. Your browsing history stays on your machine
|
||||
- 🤝 [BrowserOS as MCP server](https://docs.browseros.com/features/use-with-claude-code) — control the browser from `claude-code`, `gemini-cli`, or any MCP client (31 tools)
|
||||
- 🔄 [Workflows](https://docs.browseros.com/features/workflows) — build repeatable browser automations with a visual graph builder
|
||||
- 📂 [Cowork](https://docs.browseros.com/features/cowork) — combine browser automation with local file operations. Research the web, save reports to your folder
|
||||
- ⏰ [Scheduled Tasks](https://docs.browseros.com/features/scheduled-tasks) — run the agent on autopilot, daily or every few minutes
|
||||
- 💬 [LLM Hub](https://docs.browseros.com/features/llm-chat-hub) — compare Claude, ChatGPT, and Gemini side-by-side on any page
|
||||
- 🛡️ Built-in ad blocker — [10x more protection than Chrome](https://docs.browseros.com/features/ad-blocking) with uBlock Origin + Manifest V2 support
|
||||
- 🚀 100% open source under AGPL-3.0
|
||||
| Feature | Description | Docs |
|
||||
|---------|-------------|------|
|
||||
| **AI Agent** | 53+ browser automation tools — navigate, click, type, extract data, all with natural language | [Guide](https://docs.browseros.com/getting-started) |
|
||||
| **MCP Server** | Control the browser from Claude Code, Gemini CLI, or any MCP client | [Setup](https://docs.browseros.com/features/use-with-claude-code) |
|
||||
| **Workflows** | Build repeatable browser automations with a visual graph builder | [Docs](https://docs.browseros.com/features/workflows) |
|
||||
| **Cowork** | Combine browser automation with local file operations — research the web, save reports to your folder | [Docs](https://docs.browseros.com/features/cowork) |
|
||||
| **Scheduled Tasks** | Run agents on autopilot — daily, hourly, or every few minutes | [Docs](https://docs.browseros.com/features/scheduled-tasks) |
|
||||
| **Memory** | Persistent memory across conversations — your assistant remembers context over time | [Docs](https://docs.browseros.com/features/memory) |
|
||||
| **SOUL.md** | Define your AI's personality and instructions in a single markdown file | [Docs](https://docs.browseros.com/features/soul-md) |
|
||||
| **LLM Hub** | Compare Claude, ChatGPT, and Gemini responses side-by-side on any page | [Docs](https://docs.browseros.com/features/llm-chat-hub) |
|
||||
| **40+ App Integrations** | Gmail, Slack, GitHub, Linear, Notion, Figma, Salesforce, and more via MCP | [Docs](https://docs.browseros.com/features/connect-apps) |
|
||||
| **Vertical Tabs** | Side-panel tab management — stay organized even with 100+ tabs open | [Docs](https://docs.browseros.com/features/vertical-tabs) |
|
||||
| **Ad Blocking** | uBlock Origin + Manifest V2 support — [10x more protection](https://docs.browseros.com/features/ad-blocking) than Chrome | [Docs](https://docs.browseros.com/features/ad-blocking) |
|
||||
| **Cloud Sync** | Sync browser config and agent history across devices | [Docs](https://docs.browseros.com/features/sync) |
|
||||
| **Skills** | Custom instruction sets that shape how your AI assistant behaves | [Docs](https://docs.browseros.com/features/skills) |
|
||||
| **Smart Nudges** | Contextual suggestions to connect apps and use features at the right moment | [Docs](https://docs.browseros.com/features/smart-nudges) |
|
||||
|
||||
## Demos
|
||||
|
||||
### 🤖 BrowserOS agent in action
|
||||
### BrowserOS agent in action
|
||||
[](https://www.youtube.com/watch?v=SoSFev5R5dI)
|
||||
<br/><br/>
|
||||
|
||||
### 🎇 Install [BrowserOS as MCP](https://docs.browseros.com/features/use-with-claude-code) and control it from `claude-code`
|
||||
### Install [BrowserOS as MCP](https://docs.browseros.com/features/use-with-claude-code) and control it from `claude-code`
|
||||
|
||||
https://github.com/user-attachments/assets/c725d6df-1a0d-40eb-a125-ea009bf664dc
|
||||
|
||||
<br/><br/>
|
||||
|
||||
### 💬 Use BrowserOS to chat
|
||||
### Use BrowserOS to chat
|
||||
|
||||
https://github.com/user-attachments/assets/726803c5-8e36-420e-8694-c63a2607beca
|
||||
|
||||
<br/><br/>
|
||||
|
||||
### ⚡ Use BrowserOS to scrape data
|
||||
### Use BrowserOS to scrape data
|
||||
|
||||
https://github.com/user-attachments/assets/9f038216-bc24-4555-abf1-af2adcb7ebc0
|
||||
|
||||
<br/><br/>
|
||||
|
||||
## Why We're Building BrowserOS
|
||||
## Install `browseros-cli`
|
||||
|
||||
For the first time since Netscape pioneered the web in 1994, AI gives us the chance to completely reimagine the browser. We've seen tools like Cursor deliver 10x productivity gains for developers—yet everyday browsing remains frustratingly archaic.
|
||||
Use `browseros-cli` to launch and control BrowserOS from the terminal or from AI coding agents like Claude Code.
|
||||
|
||||
You're likely juggling 70+ tabs, battling your browser instead of having it assist you. Routine tasks, like ordering something from amazon or filling a form should be handled seamlessly by AI agents.
|
||||
**macOS / Linux:**
|
||||
|
||||
At BrowserOS, we're convinced that AI should empower you by automating tasks locally and securely—keeping your data private. We are building the best browser for this future!
|
||||
```bash
|
||||
curl -fsSL https://cdn.browseros.com/cli/install.sh | bash
|
||||
```
|
||||
|
||||
## How we compare
|
||||
**Windows:**
|
||||
|
||||
<details>
|
||||
<summary><b>vs Chrome</b></summary>
|
||||
<br>
|
||||
While we're grateful for Google open-sourcing Chromium, but Chrome hasn't evolved much in 10 years. No AI features, no automation, no MCP support.
|
||||
</details>
|
||||
```powershell
|
||||
irm https://cdn.browseros.com/cli/install.ps1 | iex
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>vs Brave</b></summary>
|
||||
<br>
|
||||
We love what Brave started, but they've spread themselves too thin with crypto, search, VPNs. We're laser-focused on AI-powered browsing.
|
||||
</details>
|
||||
After install, run `browseros-cli init` to connect the CLI to your running BrowserOS instance.
|
||||
|
||||
<details>
|
||||
<summary><b>vs Arc/Dia</b></summary>
|
||||
<br>
|
||||
Many loved Arc, but it was closed source. When they abandoned users, there was no recourse. We're 100% open source - fork it anytime!
|
||||
</details>
|
||||
## LLM Providers
|
||||
|
||||
<details>
|
||||
<summary><b>vs Perplexity Comet</b></summary>
|
||||
<br>
|
||||
They're a search/ad company. Your browser history becomes their product. We keep everything local.
|
||||
</details>
|
||||
BrowserOS works with any LLM. Bring your own keys, use OAuth, or run models locally.
|
||||
|
||||
<details>
|
||||
<summary><b>vs ChatGPT Atlas</b></summary>
|
||||
<br>
|
||||
Your browsing data could be used for ads or to train their models. We keep your history and agent interactions strictly local.
|
||||
</details>
|
||||
| Provider | Type | Auth |
|
||||
|----------|------|------|
|
||||
| Kimi K2.5 | Cloud (default) | Built-in |
|
||||
| ChatGPT Pro/Plus | Cloud | [OAuth](https://docs.browseros.com/features/chatgpt) |
|
||||
| GitHub Copilot | Cloud | [OAuth](https://docs.browseros.com/features/github-copilot) |
|
||||
| Qwen Code | Cloud | [OAuth](https://docs.browseros.com/features/qwen-code) |
|
||||
| Claude (Anthropic) | Cloud | API key |
|
||||
| GPT-4o / o3 (OpenAI) | Cloud | API key |
|
||||
| Gemini (Google) | Cloud | API key |
|
||||
| Azure OpenAI | Cloud | API key |
|
||||
| AWS Bedrock | Cloud | IAM credentials |
|
||||
| OpenRouter | Cloud | API key |
|
||||
| Ollama | Local | [Setup](https://docs.browseros.com/features/ollama) |
|
||||
| LM Studio | Local | [Setup](https://docs.browseros.com/features/lm-studio) |
|
||||
|
||||
## How We Compare
|
||||
|
||||
| | BrowserOS | Chrome | Brave | Dia | Comet | Atlas |
|
||||
|---|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
| Open Source | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
| AI Agent | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ |
|
||||
| MCP Server | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| Visual Workflows | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| Cowork (files + browser) | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| Scheduled Tasks | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| Bring Your Own Keys | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
| Local Models (Ollama) | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
| Local-first Privacy | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
| Ad Blocking (MV2) | ✅ | ❌ | ✅ | ❌ | ✅ | ❌ |
|
||||
|
||||
**Detailed comparisons:**
|
||||
- [BrowserOS vs Chrome DevTools MCP](https://docs.browseros.com/comparisons/chrome-devtools-mcp) — developer-focused comparison for browser automation
|
||||
- [BrowserOS vs Claude Cowork](https://docs.browseros.com/comparisons/claude-cowork) — getting real work done with AI
|
||||
- [BrowserOS vs OpenClaw](https://docs.browseros.com/comparisons/openclaw) — everyday AI assistance
|
||||
|
||||
## Architecture
|
||||
|
||||
BrowserOS is a monorepo with two main subsystems: the **browser** (Chromium fork) and the **agent platform** (TypeScript/Go).
|
||||
|
||||
```
|
||||
BrowserOS/
|
||||
├── packages/browseros/ # Chromium fork + build system (Python)
|
||||
│ ├── chromium_patches/ # Patches applied to Chromium source
|
||||
│ ├── build/ # Build CLI and modules
|
||||
│ └── resources/ # Icons, entitlements, signing
|
||||
│
|
||||
├── packages/browseros-agent/ # Agent platform (TypeScript/Go)
|
||||
│ ├── apps/
|
||||
│ │ ├── server/ # MCP server + AI agent loop (Bun)
|
||||
│ │ ├── agent/ # Browser extension UI (WXT + React)
|
||||
│ │ ├── cli/ # CLI tool (Go)
|
||||
│ │ ├── eval/ # Benchmark framework
|
||||
│ │ └── controller-ext/ # Chrome API bridge extension
|
||||
│ │
|
||||
│ └── packages/
|
||||
│ ├── agent-sdk/ # Node.js SDK (npm: @browseros-ai/agent-sdk)
|
||||
│ ├── cdp-protocol/ # CDP type bindings
|
||||
│ └── shared/ # Shared constants
|
||||
```
|
||||
|
||||
| Package | What it does |
|
||||
|---------|-------------|
|
||||
| [`packages/browseros`](packages/browseros/) | Chromium fork — patches, build system, signing |
|
||||
| [`apps/server`](packages/browseros-agent/apps/server/) | Bun server exposing 53+ MCP tools and running the AI agent loop |
|
||||
| [`apps/agent`](packages/browseros-agent/apps/agent/) | Browser extension — new tab, side panel chat, onboarding, settings |
|
||||
| [`apps/cli`](packages/browseros-agent/apps/cli/) | Go CLI — control BrowserOS from the terminal or AI coding agents |
|
||||
| [`apps/eval`](packages/browseros-agent/apps/eval/) | Benchmark framework — WebVoyager, Mind2Web evaluation |
|
||||
| [`agent-sdk`](packages/browseros-agent/packages/agent-sdk/) | Node.js SDK for browser automation with natural language |
|
||||
| [`cdp-protocol`](packages/browseros-agent/packages/cdp-protocol/) | Type-safe Chrome DevTools Protocol bindings |
|
||||
|
||||
## Contributing
|
||||
|
||||
We'd love your help making BrowserOS better!
|
||||
We'd love your help making BrowserOS better! See our [Contributing Guide](CONTRIBUTING.md) for details.
|
||||
|
||||
- 🐛 [Report bugs](https://github.com/browseros-ai/BrowserOS/issues)
|
||||
- 💡 [Suggest features](https://github.com/browseros-ai/BrowserOS/issues/99)
|
||||
- 💬 [Join Discord](https://discord.gg/YKwjt5vuKr)
|
||||
- 🐦 [Follow on Twitter](https://x.com/browserOS_ai)
|
||||
- [Report bugs](https://github.com/browseros-ai/BrowserOS/issues)
|
||||
- [Suggest features](https://github.com/browseros-ai/BrowserOS/issues/99)
|
||||
- [Join Discord](https://discord.gg/YKwjt5vuKr) · [Join Slack](https://dub.sh/browserOS-slack)
|
||||
- [Follow on Twitter](https://x.com/browserOS_ai)
|
||||
|
||||
**Agent development** (TypeScript/Go) — see the [agent monorepo README](packages/browseros-agent/README.md) for setup instructions.
|
||||
|
||||
**Browser development** (C++/Python) — requires ~100GB disk space. See [`packages/browseros`](packages/browseros/) for build instructions.
|
||||
|
||||
## Credits
|
||||
|
||||
- [ungoogled-chromium](https://github.com/ungoogled-software/ungoogled-chromium) — BrowserOS uses some patches for enhanced privacy. Thanks to everyone behind this project!
|
||||
- [The Chromium Project](https://www.chromium.org/) — at the core of BrowserOS, making it possible to exist in the first place.
|
||||
|
||||
## License
|
||||
|
||||
BrowserOS is open source under the [AGPL-3.0 license](LICENSE).
|
||||
|
||||
## Credits
|
||||
|
||||
- [ungoogled-chromium](https://github.com/ungoogled-software/ungoogled-chromium) - BrowserOS uses some patches for enhanced privacy. Thanks to everyone behind this project!
|
||||
- [The Chromium Project](https://www.chromium.org/) - At the core of BrowserOS, making it possible to exist in the first place.
|
||||
|
||||
## Citation
|
||||
|
||||
If you use BrowserOS in your research or project, please cite:
|
||||
|
||||
```bibtex
|
||||
@software{browseros2025,
|
||||
author = {Sonti, Nithin and Sonti, Nikhil and {BrowserOS-team}},
|
||||
title = {BrowserOS: The open-source Agentic browser},
|
||||
url = {https://github.com/browseros-ai/BrowserOS},
|
||||
year = {2025},
|
||||
publisher = {GitHub},
|
||||
license = {AGPL-3.0},
|
||||
}
|
||||
```
|
||||
|
||||
Copyright © 2025 Felafax, Inc.
|
||||
Copyright © 2026 Felafax, Inc.
|
||||
|
||||
## Stargazers
|
||||
|
||||
Thank you to all our supporters!
|
||||
|
||||
[](https://www.star-history.com/#browseros-ai/BrowserOS&Date)
|
||||
|
||||
##
|
||||
<p align="center">
|
||||
Built with ❤️ from San Francisco
|
||||
</p>
|
||||
|
||||
|
||||
|
||||
@@ -3,13 +3,17 @@ title: "Ad Blocking"
|
||||
description: "BrowserOS supports full ad blocking with uBlock Origin"
|
||||
---
|
||||
|
||||
BrowserOS supports full ad blocking through [uBlock Origin](https://ublockorigin.com/), the most effective open-source ad blocker available.
|
||||
BrowserOS supports full ad blocking through [uBlock Origin](https://ublockorigin.com/), the most powerful open-source ad blocker available — the full extension, not the watered-down "Lite" version.
|
||||
|
||||
## How It Works
|
||||
## Why BrowserOS?
|
||||
|
||||
Chrome has been [phasing out support](https://developer.chrome.com/docs/extensions/develop/migrate/mv2-deprecation-timeline) for Manifest V2 extensions, which uBlock Origin relies on for its full blocking capabilities. We re-enabled Manifest V2 support in BrowserOS so uBlock Origin can run at full power.
|
||||
Chrome [killed support](https://developer.chrome.com/docs/extensions/develop/migrate/mv2-deprecation-timeline) for uBlock Origin by phasing out Manifest V2 extensions. The only option left on Chrome is "uBlock Origin Lite," a significantly weaker version that can't use advanced filtering rules.
|
||||
|
||||
Install it from the Chrome Web Store: [uBlock Origin](https://chromewebstore.google.com/detail/ublock-origin/cjpalhdlnbpafiamejdnhcphjbkeiagm)
|
||||
**BrowserOS re-enabled full Manifest V2 support**, so you can install and run the original uBlock Origin at full power — the same extension Chrome no longer allows.
|
||||
|
||||
<Card title="Install uBlock Origin" icon="shield-check" href="https://chromewebstore.google.com/detail/ublock-origin/cjpalhdlnbpafiamejdnhcphjbkeiagm">
|
||||
Install the full uBlock Origin extension from the Chrome Web Store. Works on BrowserOS out of the box.
|
||||
</Card>
|
||||
|
||||
## BrowserOS vs Chrome
|
||||
|
||||
|
||||
@@ -131,6 +131,29 @@ Connect to powerful AI models using your API keys. Your keys stay on your machin
|
||||

|
||||
</Accordion>
|
||||
|
||||
<div id="nvidia" />
|
||||
<Accordion title="NVIDIA (Free)" icon="microchip">
|
||||
NVIDIA's [build.nvidia.com](https://build.nvidia.com/models) hosts 80+ models — including GLM 5.1, MiniMax M2.7, GPT-OSS-120B, Qwen 3.5, Mistral, and Nemotron — behind a **free OpenAI-compatible API endpoint**. Great for chatting, prototyping, and personal projects.
|
||||
|
||||
**Get your API key:**
|
||||
1. Go to [build.nvidia.com/models](https://build.nvidia.com/models) and sign in with a free NVIDIA developer account
|
||||
2. Pick any model tagged **Free Endpoint** (e.g. [`minimaxai/minimax-m2.7`](https://build.nvidia.com/minimaxai/minimax-m2.7), [`z-ai/glm-5.1`](https://build.nvidia.com/z-ai/glm-5.1), [`qwen/qwen3.5-122b-a10b`](https://build.nvidia.com/qwen/qwen3.5-122b-a10b))
|
||||
3. Click **Get API Key** on the model page and copy the `nvapi-...` key
|
||||
|
||||
**Add to BrowserOS:**
|
||||
1. Go to `chrome://browseros/settings`
|
||||
2. Click **USE** on the **OpenAI Compatible** card
|
||||
3. Set **Base URL** to `https://integrate.api.nvidia.com/v1`
|
||||
4. Set **Model ID** to a model from the catalog (e.g. `minimaxai/minimax-m2.7`, `z-ai/glm-5.1`, `qwen/qwen3.5-122b-a10b`)
|
||||
5. Paste your NVIDIA API key
|
||||
6. Set **Context Window** based on the model (most are `128000` or higher)
|
||||
7. Click **Save**
|
||||
|
||||
<Tip>
|
||||
NVIDIA's free endpoints share GPU capacity across all developers, so throughput is slower than a paid API. They're best for Chat Mode, exploring new open-source models, and personal projects. For production agent workloads, use a paid provider like Claude or Kimi.
|
||||
</Tip>
|
||||
</Accordion>
|
||||
|
||||
<div id="claude" />
|
||||
<Accordion title="Claude (Best for Agents)" icon="message-bot">
|
||||
Claude Opus 4.5 gives the best results for Agent Mode.
|
||||
|
||||
@@ -42,6 +42,10 @@ Welcome to BrowserOS! Let's get you set up.
|
||||
|
||||
## You're all set!
|
||||
|
||||
<Tip>
|
||||
**Block ads with uBlock Origin** — Chrome dropped support for the full uBlock Origin extension, but BrowserOS brought it back. [Install it from the Chrome Web Store](https://chromewebstore.google.com/detail/ublock-origin/cjpalhdlnbpafiamejdnhcphjbkeiagm) and browse ad-free. [Learn more →](/features/ad-blocking)
|
||||
</Tip>
|
||||
|
||||
Explore what BrowserOS can do:
|
||||
|
||||
<Columns cols={2}>
|
||||
|
||||
@@ -32,7 +32,7 @@ Use **kebab-case** for all file and folder names:
|
||||
| Multi-word files | kebab-case | `gemini-agent.ts`, `mcp-context.ts` |
|
||||
| Single-word files | lowercase | `types.ts`, `browser.ts`, `index.ts` |
|
||||
| Test files | `.test.ts` suffix | `mcp-context.test.ts` |
|
||||
| Folders | kebab-case | `controller-server/`, `rate-limiter/` |
|
||||
| Folders | kebab-case | `rate-limiter/`, `browser-tools/` |
|
||||
|
||||
Classes remain PascalCase in code, but live in kebab-case files:
|
||||
```typescript
|
||||
@@ -97,21 +97,16 @@ The main MCP server that exposes browser automation tools via HTTP/SSE.
|
||||
|
||||
**Key components:**
|
||||
- `src/tools/` - MCP tool definitions, split into:
|
||||
- `cdp-based/` - Tools using Chrome DevTools Protocol (network, console, emulation, input, etc.)
|
||||
- `controller-based/` - Tools using the browser extension (navigation, clicks, screenshots, tabs, history, bookmarks)
|
||||
- `src/controller-server/` - WebSocket server that bridges to the browser extension
|
||||
- `ControllerBridge` handles WebSocket connections with extension clients
|
||||
- `ControllerContext` wraps the bridge for tool handlers
|
||||
- `cdp-based/` - Tools using Chrome DevTools Protocol (navigation, DOM interaction, network, console, emulation, input, etc.)
|
||||
- `src/common/` - Shared utilities (McpContext, PageCollector, browser connection, identity, db)
|
||||
- `src/agent/` - AI agent functionality (Gemini adapter, rate limiting, session management)
|
||||
- `src/http/` - Hono HTTP server with MCP, health, and provider routes
|
||||
|
||||
**Tool types:**
|
||||
- CDP tools require a direct CDP connection (`--cdp-port`)
|
||||
- Controller tools work via the browser extension over WebSocket
|
||||
|
||||
### Shared (`packages/shared`)
|
||||
Shared constants, types, and configuration used by both server and extension. Avoids magic numbers.
|
||||
Shared constants, types, and configuration used across packages. Avoids magic numbers.
|
||||
|
||||
**Structure:**
|
||||
- `src/constants/` - Configuration values (ports, timeouts, limits, urls, paths)
|
||||
@@ -119,22 +114,12 @@ Shared constants, types, and configuration used by both server and extension. Av
|
||||
|
||||
**Exports:** `@browseros/shared/constants/*`, `@browseros/shared/types/*`
|
||||
|
||||
### Controller Extension (`apps/controller-ext`)
|
||||
Chrome extension that receives commands from the server via WebSocket.
|
||||
|
||||
**Entry point:** `src/background/index.ts` → `BrowserOSController`
|
||||
|
||||
**Structure:**
|
||||
- `src/actions/` - Action handlers organized by domain (browser/, tab/, bookmark/, history/)
|
||||
- `src/adapters/` - Chrome API adapters (TabAdapter, BookmarkAdapter, HistoryAdapter)
|
||||
- `src/websocket/` - WebSocket client that connects to the server
|
||||
|
||||
### Communication Flow
|
||||
|
||||
```
|
||||
AI Agent/MCP Client → HTTP Server (Hono) → Tool Handler
|
||||
↓
|
||||
CDP (direct) ←── or ──→ WebSocket → Extension → Chrome APIs
|
||||
CDP → BrowserOS / Chrome APIs
|
||||
```
|
||||
|
||||
## Creating Packages
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
# BrowserOS Agent
|
||||
|
||||
Monorepo for the BrowserOS-agent -- contains 3 packages: agent-UI, server (which contains the agent loop) and controller-extension (which is used by the tools within the agent loop).
|
||||
|
||||
> **⚠️ NOTE:** This is only a submodule, the main project is at -- https://github.com/browseros-ai/BrowserOS
|
||||
The agent platform powering [BrowserOS](https://github.com/browseros-ai/BrowserOS) — contains the MCP server, agent UI, CLI, evaluation framework, and SDK.
|
||||
|
||||
## Monorepo Structure
|
||||
|
||||
@@ -10,24 +8,29 @@ Monorepo for the BrowserOS-agent -- contains 3 packages: agent-UI, server (which
|
||||
apps/
|
||||
server/ # Bun server - MCP endpoints + agent loop
|
||||
agent/ # Agent UI (Chrome extension)
|
||||
controller-ext/ # BrowserOS Controller (Chrome extension for chrome.* APIs)
|
||||
cli/ # Go CLI for controlling BrowserOS from the terminal
|
||||
eval/ # Evaluation framework for benchmarking agents
|
||||
|
||||
packages/
|
||||
agent-sdk/ # Node.js SDK (@browseros-ai/agent-sdk)
|
||||
cdp-protocol/ # Type-safe Chrome DevTools Protocol bindings
|
||||
shared/ # Shared constants (ports, timeouts, limits)
|
||||
```
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `apps/server` | Bun server exposing MCP tools and running the agent loop |
|
||||
| `apps/agent` | Agent UI - Chrome extension for the chat interface |
|
||||
| `apps/controller-ext` | BrowserOS Controller - Chrome extension that bridges `chrome.*` APIs (tabs, bookmarks, history) to the server via WebSocket |
|
||||
| `apps/agent` | Agent UI — Chrome extension for the chat interface |
|
||||
| `apps/cli` | Go CLI — control BrowserOS from the terminal or AI coding agents |
|
||||
| `apps/eval` | Benchmark framework — WebVoyager, Mind2Web evaluation |
|
||||
| `packages/agent-sdk` | Node.js SDK for browser automation with natural language |
|
||||
| `packages/cdp-protocol` | Auto-generated CDP type bindings used by the server |
|
||||
| `packages/shared` | Shared constants used across packages |
|
||||
|
||||
## Architecture
|
||||
|
||||
- `apps/server`: Bun server which contains the agent loop and tools.
|
||||
- `apps/agent`: Agent UI (Chrome extension).
|
||||
- `apps/controller-ext`: BrowserOS Controller - a Chrome extension that bridges `chrome.*` APIs to the server. Controller tools within the server communicate with this extension via WebSocket.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
@@ -45,19 +48,19 @@ packages/
|
||||
│ /health ─── Health check │
|
||||
│ │
|
||||
│ Tools: │
|
||||
│ ├── CDP Tools (console, network, input, screenshot, ...) │
|
||||
│ └── Controller Tools (tabs, navigation, clicks, bookmarks, history) │
|
||||
│ └── CDP-backed browser tools (tabs, navigation, input, screenshots, │
|
||||
│ bookmarks, history, console, DOM, tab groups, windows, ...) │
|
||||
└──────────────────────────────────────────────────────────────────────────┘
|
||||
│ │
|
||||
│ CDP (client) │ WebSocket (server)
|
||||
▼ ▼
|
||||
┌─────────────────────┐ ┌─────────────────────────────────────┐
|
||||
│ Chromium CDP │ │ BrowserOS Controller Extension │
|
||||
│ (cdpPort: 9000) │ │ (extensionPort: 9300) │
|
||||
│ │ │ │
|
||||
│ Server connects │ │ Bridges chrome.tabs, chrome.history │
|
||||
│ TO this as client │ │ chrome.bookmarks to the server │
|
||||
└─────────────────────┘ └─────────────────────────────────────┘
|
||||
│
|
||||
│ CDP (client)
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Chromium CDP │
|
||||
│ (cdpPort: 9000) │
|
||||
│ │
|
||||
│ Server connects │
|
||||
│ TO this as client │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
### Ports
|
||||
@@ -66,7 +69,7 @@ packages/
|
||||
|------|--------------|---------|
|
||||
| 9100 | `BROWSEROS_SERVER_PORT` | HTTP server - MCP endpoints, agent chat, health |
|
||||
| 9000 | `BROWSEROS_CDP_PORT` | Chromium CDP server (BrowserOS Server connects as client) |
|
||||
| 9300 | `BROWSEROS_EXTENSION_PORT` | WebSocket server for controller extension |
|
||||
| 9300 | `BROWSEROS_EXTENSION_PORT` | Legacy BrowserOS launch arg kept for compatibility; not used by the server |
|
||||
|
||||
## Development
|
||||
|
||||
@@ -90,9 +93,8 @@ process-compose up
|
||||
|
||||
The `process-compose up` command runs the following in order:
|
||||
1. `bun install` — installs dependencies
|
||||
2. `bun --cwd apps/controller-ext build` — builds the controller extension
|
||||
3. `bun --cwd apps/agent codegen` — generates agent code
|
||||
4. `bun --cwd apps/server start` and `bun --cwd apps/agent dev` — starts server and agent in parallel
|
||||
2. `bun --cwd apps/agent codegen` — generates agent code
|
||||
3. `bun --cwd apps/server start` and `bun --cwd apps/agent dev` — starts server and agent in parallel
|
||||
|
||||
### Environment Variables
|
||||
|
||||
@@ -108,7 +110,7 @@ Runtime uses `.env.development`, while production artifact builds use `.env.prod
|
||||
|----------|---------|-------------|
|
||||
| `BROWSEROS_SERVER_PORT` | 9100 | HTTP server port (MCP, chat, health) |
|
||||
| `BROWSEROS_CDP_PORT` | 9000 | Chromium CDP port (server connects as client) |
|
||||
| `BROWSEROS_EXTENSION_PORT` | 9300 | WebSocket port for controller extension |
|
||||
| `BROWSEROS_EXTENSION_PORT` | 9300 | Legacy BrowserOS launch arg kept for compatibility |
|
||||
| `BROWSEROS_CONFIG_URL` | - | Remote config endpoint for rate limits |
|
||||
| `BROWSEROS_INSTALL_ID` | - | Unique installation identifier (analytics) |
|
||||
| `BROWSEROS_CLIENT_ID` | - | Client identifier (analytics) |
|
||||
@@ -140,7 +142,7 @@ Copy from `apps/server/.env.production.example` before running `build:server`.
|
||||
|----------|---------|-------------|
|
||||
| `BROWSEROS_SERVER_PORT` | 9100 | Passed to BrowserOS via CLI args |
|
||||
| `BROWSEROS_CDP_PORT` | 9000 | Passed to BrowserOS via CLI args |
|
||||
| `BROWSEROS_EXTENSION_PORT` | 9300 | Passed to BrowserOS via CLI args |
|
||||
| `BROWSEROS_EXTENSION_PORT` | 9300 | Legacy BrowserOS CLI arg still passed for compatibility |
|
||||
| `VITE_BROWSEROS_SERVER_PORT` | 9100 | Agent UI connects to server (must match `BROWSEROS_SERVER_PORT`) |
|
||||
| `BROWSEROS_BINARY` | - | Path to BrowserOS binary |
|
||||
| `USE_BROWSEROS_BINARY` | true | Use BrowserOS instead of default Chrome |
|
||||
@@ -157,15 +159,13 @@ bun run start:server # Start the server
|
||||
bun run start:agent # Start agent extension (dev mode)
|
||||
|
||||
# Build
|
||||
bun run build # Build server, agent, and controller extension
|
||||
bun run build # Build server and agent
|
||||
bun run build:server # Build production server resource artifacts and upload zips to R2
|
||||
bun run build:agent # Build agent extension
|
||||
bun run build:ext # Build controller extension
|
||||
|
||||
# Test
|
||||
bun run test # Run standard tests
|
||||
bun run test:cdp # Run CDP-based tests
|
||||
bun run test:controller # Run controller-based tests
|
||||
bun run test:integration # Run integration tests
|
||||
|
||||
# Quality
|
||||
|
||||
@@ -15,9 +15,6 @@ VITE_PUBLIC_SENTRY_DSN=
|
||||
# BrowserOS API URL
|
||||
VITE_PUBLIC_BROWSEROS_API=https://api.browseros.com
|
||||
|
||||
# Launch feature flags
|
||||
VITE_PUBLIC_KIMI_LAUNCH=false
|
||||
|
||||
# GraphQL Schema Path (optional — falls back to schema/schema.graphql)
|
||||
GRAPHQL_SCHEMA_PATH=
|
||||
|
||||
|
||||
30
packages/browseros-agent/apps/agent/CHANGELOG.md
Normal file
30
packages/browseros-agent/apps/agent/CHANGELOG.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# BrowserOS Agent Extension
|
||||
|
||||
## v0.0.99 (2026-04-08)
|
||||
|
||||
## What's Changed
|
||||
|
||||
- chore: bump server and extension version (#659)
|
||||
- chore(agent): remove workflows feature (#656)
|
||||
- feat: replace model picker with shadcn Combobox + fuse.js fuzzy search (#617)
|
||||
- feat: clean-up - remove obsolete controller extension (#610)
|
||||
- docs: update agent extension changelog for v0.0.98 (#609)
|
||||
|
||||
|
||||
## v0.0.98 (2026-03-27)
|
||||
|
||||
## What's Changed
|
||||
|
||||
- chore: update agent version (#608)
|
||||
- chore: fix version number for extension (#606)
|
||||
- fix: improve chat history freshness and reduce query payload (#598)
|
||||
- feat: isolate new-tab agent navigation from origin tab (#593)
|
||||
- docs: overhaul READMEs across all major packages (#594)
|
||||
- fix(ui): resolve MCP promo banner dismiss button overlapping with text (#581)
|
||||
- docs: update agent extension changelog for v0.0.52 (#573)
|
||||
|
||||
|
||||
## v0.0.52 (2026-03-26)
|
||||
|
||||
Initial release
|
||||
|
||||
@@ -12,7 +12,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|------|------------|---------|
|
||||
| Folders | kebab-case | `ai-settings/`, `jtbd-popup/`, `llm-hub/` |
|
||||
| React components (.tsx) | PascalCase | `AISettingsPage.tsx`, `SurveyHeader.tsx` |
|
||||
| Hooks (.ts) | camelCase with `use` prefix | `useRunWorkflow.ts`, `useVoiceInput.ts` |
|
||||
| Hooks (.ts) | camelCase with `use` prefix | `useVoiceInput.ts`, `useMessageTree.ts` |
|
||||
| Non-component files (.ts) | lowercase | `types.ts`, `models.ts`, `storage.ts` |
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
# BrowserOS Agent Chrome Extension
|
||||
# BrowserOS Agent Extension
|
||||
|
||||
The official Chrome extension for BrowserOS Agent, providing the UI layer for interacting with BrowserOS Core and Controllers. This extension enables intelligent browser automation, AI-powered search, and seamless integration with multiple LLM providers.
|
||||
[](../../../../LICENSE)
|
||||
|
||||
The built-in browser extension that powers BrowserOS's AI interface — new tab with unified search, side panel chat, onboarding, and settings. Built with [WXT](https://wxt.dev) and React.
|
||||
|
||||
> For user-facing feature documentation, see [docs.browseros.com](https://docs.browseros.com).
|
||||
|
||||
## Features
|
||||
|
||||
- **AI-Powered New Tab**: Custom new tab page with unified search across Google and AI assistants
|
||||
- **Side Panel Chat**: Full-featured chat interface for interacting with BrowserOS Core
|
||||
- **Side Panel Chat**: Full-featured chat interface for interacting with BrowserOS
|
||||
- **Multi-Provider Support**: Connect to various LLM providers (OpenAI, Anthropic, Azure, Bedrock, and more)
|
||||
- **MCP Integration**: Model Context Protocol support for extending AI capabilities
|
||||
- **Visual Feedback**: Animated glow effect on tabs during AI agent operations
|
||||
- **Privacy-First**: Local data handling with configurable provider settings
|
||||
|
||||
## How It Connects
|
||||
|
||||
The extension communicates with the [BrowserOS Server](../../apps/server/) running locally. The server handles the AI agent loop, MCP tools, and CDP connections — the extension provides the UI layer.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
@@ -80,47 +88,20 @@ Settings dashboard with multiple sections:
|
||||
|
||||
Content script that creates a visual indicator (pulsing orange glow) around the browser viewport when an AI agent is actively working on a tab.
|
||||
|
||||
## How Tools Are Used
|
||||
|
||||
### Bun
|
||||
|
||||
Bun is the exclusive runtime and package manager:
|
||||
- All scripts use `bun run <script>` instead of npm
|
||||
- Package installation via `bun install`
|
||||
- Environment files automatically loaded (no dotenv needed)
|
||||
- Enforced via `engines` field in `package.json`
|
||||
|
||||
```bash
|
||||
bun install # Install dependencies
|
||||
bun run dev # Development mode
|
||||
bun run build # Production build
|
||||
bun run lint # Run Biome linting
|
||||
```
|
||||
|
||||
### Biome
|
||||
|
||||
Unified linter and formatter configured in `biome.json`:
|
||||
- **Formatting**: 2-space indentation, single quotes, no semicolons
|
||||
- **Linting**: Recommended rules plus custom rules for unused imports/variables
|
||||
- **CSS Support**: Tailwind directives parsing enabled
|
||||
- **Import Organization**: Automatic import sorting via assist actions
|
||||
|
||||
```bash
|
||||
bun run lint # Check for issues
|
||||
bun run lint:fix # Auto-fix issues
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Bun](https://bun.sh) installed
|
||||
- Chrome or Chromium-based browser
|
||||
- BrowserOS Core running locally (for full functionality)
|
||||
- BrowserOS Server running locally (for full functionality)
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Copy environment file
|
||||
cp .env.example .env.development
|
||||
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
@@ -153,12 +134,30 @@ SENTRY_AUTH_TOKEN=your-token
|
||||
|
||||
### GraphQL Schema
|
||||
|
||||
Codegen requires a GraphQL schema. By default it uses the bundled `schema/schema.graphql`, so no extra setup is needed. If you have access to the original API source, you can set the following environment variable
|
||||
Codegen requires a GraphQL schema. By default it uses the bundled `schema/schema.graphql`, so no extra setup is needed. If you have access to the original API source, you can set the following environment variable:
|
||||
|
||||
```env
|
||||
GRAPHQL_SCHEMA_PATH=/path/to/api-repo/.../schema.graphql
|
||||
```
|
||||
|
||||
## Development Tooling
|
||||
|
||||
### Bun
|
||||
|
||||
Bun is the exclusive runtime and package manager:
|
||||
- All scripts use `bun run <script>` instead of npm
|
||||
- Package installation via `bun install`
|
||||
- Environment files automatically loaded (no dotenv needed)
|
||||
- Enforced via `engines` field in `package.json`
|
||||
|
||||
### Biome
|
||||
|
||||
Unified linter and formatter configured in `biome.json`:
|
||||
- **Formatting**: 2-space indentation, single quotes, no semicolons
|
||||
- **Linting**: Recommended rules plus custom rules for unused imports/variables
|
||||
- **CSS Support**: Tailwind directives parsing enabled
|
||||
- **Import Organization**: Automatic import sorting via assist actions
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Description |
|
||||
@@ -169,4 +168,5 @@ GRAPHQL_SCHEMA_PATH=/path/to/api-repo/.../schema.graphql
|
||||
| `bun run lint` | Run Biome linter |
|
||||
| `bun run lint:fix` | Auto-fix linting issues |
|
||||
| `bun run typecheck` | Run TypeScript type checking |
|
||||
| `bun run codegen` | Generate GraphQL types |
|
||||
| `bun run clean:cache` | Clear build caches |
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { REFERRAL_LIMITS } from '@browseros/shared/constants/limits'
|
||||
import { ExternalLink, Loader2, Send } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useCredits, useInvalidateCredits } from '@/lib/credits/useCredits'
|
||||
import {
|
||||
getShareOnTwitterUrl,
|
||||
submitReferral,
|
||||
} from '@/lib/referral/submit-referral'
|
||||
|
||||
interface ShareForCreditsProps {
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export const ShareForCredits: FC<ShareForCreditsProps> = ({ compact }) => {
|
||||
const [tweetUrl, setTweetUrl] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [result, setResult] = useState<{
|
||||
success: boolean
|
||||
message: string
|
||||
} | null>(null)
|
||||
|
||||
const { data } = useCredits()
|
||||
const invalidateCredits = useInvalidateCredits()
|
||||
|
||||
const credits = data?.credits ?? 0
|
||||
const atDailyMax = credits >= REFERRAL_LIMITS.MAX_DAILY_CREDITS
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!tweetUrl.trim() || !data?.browserosId || atDailyMax) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
setResult(null)
|
||||
|
||||
try {
|
||||
const res = await submitReferral(tweetUrl.trim(), data.browserosId)
|
||||
if (res.success) {
|
||||
setResult({
|
||||
success: true,
|
||||
message: `${res.creditsAdded ?? 200} credits added!`,
|
||||
})
|
||||
setTweetUrl('')
|
||||
invalidateCredits()
|
||||
} else {
|
||||
setResult({
|
||||
success: false,
|
||||
message: res.reason ?? 'Submission failed. Please try again.',
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
setResult({
|
||||
success: false,
|
||||
message: 'Network error. Please try again.',
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (atDailyMax) {
|
||||
return (
|
||||
<div className={compact ? 'space-y-2' : 'space-y-3'}>
|
||||
<p className={compact ? 'text-muted-foreground text-xs' : 'text-sm'}>
|
||||
You've reached the daily cap of {REFERRAL_LIMITS.MAX_DAILY_CREDITS}{' '}
|
||||
credits. Come back tomorrow to earn more!
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={compact ? 'space-y-2' : 'space-y-3'}>
|
||||
<p className={compact ? 'text-muted-foreground text-xs' : 'text-sm'}>
|
||||
Share BrowserOS on Twitter to earn{' '}
|
||||
{REFERRAL_LIMITS.CREDITS_PER_REFERRAL} bonus credits!
|
||||
</p>
|
||||
|
||||
<ul className="list-disc space-y-0.5 pl-4 text-muted-foreground text-xs">
|
||||
<li>
|
||||
Tweet must mention <span className="font-medium">@browserOS_ai</span>
|
||||
</li>
|
||||
<li>Tweet must be posted within the last 30 minutes</li>
|
||||
<li>Each tweet can only be submitted once</li>
|
||||
<li>
|
||||
Daily cap of {REFERRAL_LIMITS.MAX_DAILY_CREDITS} credits — resets at
|
||||
midnight UTC
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Button variant="outline" size="sm" className="w-full gap-2" asChild>
|
||||
<a
|
||||
href={getShareOnTwitterUrl()}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => {
|
||||
e.currentTarget.href = getShareOnTwitterUrl()
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
Share on Twitter
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Already shared? Paste your tweet link:
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://x.com/..."
|
||||
value={tweetUrl}
|
||||
onChange={(e) => setTweetUrl(e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !tweetUrl.trim()}
|
||||
className="shrink-0 gap-1.5"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<p
|
||||
className={
|
||||
result.success
|
||||
? 'text-green-600 text-xs dark:text-green-400'
|
||||
: 'text-destructive text-xs'
|
||||
}
|
||||
>
|
||||
{result.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Bot,
|
||||
Compass,
|
||||
CreditCard,
|
||||
GitBranch,
|
||||
MessageSquare,
|
||||
Palette,
|
||||
RotateCcw,
|
||||
@@ -86,12 +85,6 @@ const primarySettingsSections: NavSection[] = [
|
||||
icon: CreditCard,
|
||||
feature: Feature.CREDITS_SUPPORT,
|
||||
},
|
||||
{
|
||||
name: 'Workflows',
|
||||
to: '/workflows',
|
||||
icon: GitBranch,
|
||||
feature: Feature.WORKFLOW_SUPPORT,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Onboarding } from '../onboarding/index/Onboarding'
|
||||
import { StepsLayout } from '../onboarding/steps/StepsLayout'
|
||||
import { AISettingsPage } from './ai-settings/AISettingsPage'
|
||||
import { ConnectMCP } from './connect-mcp/ConnectMCP'
|
||||
import { CreateGraphWrapper } from './create-graph/CreateGraphWrapper'
|
||||
import { CustomizationPage } from './customization/CustomizationPage'
|
||||
import { SurveyPage } from './jtbd-agent/SurveyPage'
|
||||
import { AuthLayout } from './layout/AuthLayout'
|
||||
@@ -29,7 +28,6 @@ import { SearchProviderPage } from './search-provider/SearchProviderPage'
|
||||
import { SkillsPage } from './skills/SkillsPage'
|
||||
import { SoulPage } from './soul/SoulPage'
|
||||
import { UsagePage } from './usage/UsagePage'
|
||||
import { WorkflowsPageWrapper } from './workflows/WorkflowsPageWrapper'
|
||||
|
||||
function getSurveyParams(): { maxTurns?: number; experimentId?: string } {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
@@ -53,9 +51,7 @@ const OptionsRedirect: FC = () => {
|
||||
soul: '/home/soul',
|
||||
skills: '/home/skills',
|
||||
'jtbd-agent': '/settings/survey',
|
||||
workflows: '/workflows',
|
||||
scheduled: '/scheduled',
|
||||
'create-graph': '/workflows/create-graph',
|
||||
}
|
||||
|
||||
const newPath = routeMap[path] || '/settings/ai'
|
||||
@@ -90,7 +86,6 @@ export const App: FC = () => {
|
||||
|
||||
{/* Primary nav routes */}
|
||||
<Route path="connect-apps" element={<ConnectMCP />} />
|
||||
<Route path="workflows" element={<WorkflowsPageWrapper />} />
|
||||
<Route path="scheduled" element={<ScheduledTasksPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -108,9 +103,6 @@ export const App: FC = () => {
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Full-screen without sidebar */}
|
||||
<Route path="workflows/create-graph" element={<CreateGraphWrapper />} />
|
||||
|
||||
{/* Onboarding routes - no sidebar, no auth required */}
|
||||
<Route path="onboarding">
|
||||
<Route index element={<Onboarding />} />
|
||||
|
||||
@@ -17,7 +17,7 @@ export const McpPromoBanner: FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-4 rounded-xl border border-border bg-card p-4 shadow-sm transition-all hover:shadow-md">
|
||||
<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>
|
||||
@@ -48,7 +48,7 @@ export const McpPromoBanner: FC = () => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDismissed(true)}
|
||||
className="absolute top-2 right-2 rounded-sm p-1 text-muted-foreground opacity-50 transition-opacity hover:opacity-100"
|
||||
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>
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import Fuse from 'fuse.js'
|
||||
import {
|
||||
Check,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
SearchIcon,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import { type FC, useEffect, useRef, useState } from 'react'
|
||||
import { type FC, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod/v3'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -30,6 +39,11 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -42,24 +56,22 @@ import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
import { useCapabilities } from '@/lib/browseros/useCapabilities'
|
||||
import {
|
||||
AI_PROVIDER_ADDED_EVENT,
|
||||
AI_PROVIDER_UPDATED_EVENT,
|
||||
KIMI_API_KEY_CONFIGURED_EVENT,
|
||||
KIMI_API_KEY_GUIDE_CLICKED_EVENT,
|
||||
MODEL_SELECTED_EVENT,
|
||||
} from '@/lib/constants/analyticsEvents'
|
||||
import { useKimiLaunch } from '@/lib/feature-flags/useKimiLaunch'
|
||||
import {
|
||||
getDefaultBaseUrlForProviders,
|
||||
getProviderTemplate,
|
||||
MINIMAX_REGIONS,
|
||||
providerTypeOptions,
|
||||
} from '@/lib/llm-providers/providerTemplates'
|
||||
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 { cn } from '@/lib/utils'
|
||||
import {
|
||||
getModelContextLength,
|
||||
getModelsForProvider,
|
||||
type ModelInfo,
|
||||
} from './models'
|
||||
import { getModelContextLength, getModelsForProvider } from './models'
|
||||
|
||||
const providerTypeEnum = z.enum([
|
||||
'moonshot',
|
||||
@@ -76,6 +88,7 @@ const providerTypeEnum = z.enum([
|
||||
'chatgpt-pro',
|
||||
'github-copilot',
|
||||
'qwen-code',
|
||||
'minimax',
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -94,7 +107,7 @@ export const providerFormSchema = z
|
||||
temperature: z.number().min(0).max(2),
|
||||
// Azure-specific
|
||||
resourceName: z.string().optional(),
|
||||
// Bedrock-specific
|
||||
// Bedrock-specific / MiniMax region
|
||||
accessKeyId: z.string().optional(),
|
||||
secretAccessKey: z.string().optional(),
|
||||
region: z.string().optional(),
|
||||
@@ -153,6 +166,30 @@ export const providerFormSchema = z
|
||||
) {
|
||||
// No validation needed — OAuth tokens are on the server
|
||||
}
|
||||
// MiniMax: require baseUrl + apiKey
|
||||
else if (data.type === 'minimax') {
|
||||
if (!data.baseUrl) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Base URL is required',
|
||||
path: ['baseUrl'],
|
||||
})
|
||||
} else if (!/^https?:\/\/.+/.test(data.baseUrl)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Must be a valid URL',
|
||||
path: ['baseUrl'],
|
||||
})
|
||||
}
|
||||
|
||||
if (!data.apiKey?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'API Key is required',
|
||||
path: ['apiKey'],
|
||||
})
|
||||
}
|
||||
}
|
||||
// Other providers: require baseUrl
|
||||
else if (!data.baseUrl) {
|
||||
ctx.addIssue({
|
||||
@@ -182,100 +219,6 @@ function formatContextWindow(tokens: number): string {
|
||||
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
|
||||
@@ -303,10 +246,11 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
}) => {
|
||||
const [isTesting, setIsTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState<TestResult | null>(null)
|
||||
const [modelListOpen, setModelListOpen] = useState(false)
|
||||
const [modelPickerOpen, setModelPickerOpen] = useState(false)
|
||||
const [modelSearch, setModelSearch] = useState('')
|
||||
const modelListRef = useRef<HTMLDivElement>(null)
|
||||
const { supports } = useCapabilities()
|
||||
const { baseUrl: agentServerUrl } = useAgentServerUrl()
|
||||
const kimiLaunch = useKimiLaunch()
|
||||
|
||||
const filteredProviderTypeOptions = providerTypeOptions.filter((opt) => {
|
||||
if (opt.value === 'chatgpt-pro')
|
||||
@@ -314,8 +258,6 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
if (opt.value === 'github-copilot')
|
||||
return supports(Feature.GITHUB_COPILOT_SUPPORT)
|
||||
if (opt.value === 'qwen-code') return supports(Feature.QWEN_CODE_SUPPORT)
|
||||
if (opt.value === 'moonshot')
|
||||
return kimiLaunch || initialValues?.type === 'moonshot'
|
||||
if (opt.value === 'openai-compatible') {
|
||||
return supports(Feature.OPENAI_COMPATIBLE_SUPPORT)
|
||||
}
|
||||
@@ -376,6 +318,23 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
|
||||
const modelInfoList = getModelsForProvider(watchedType as ProviderType)
|
||||
|
||||
const modelFuse = useMemo(
|
||||
() =>
|
||||
new Fuse(modelInfoList, {
|
||||
keys: ['modelId'],
|
||||
threshold: 0.4,
|
||||
distance: 100,
|
||||
}),
|
||||
[modelInfoList],
|
||||
)
|
||||
|
||||
const filteredModels = modelSearch
|
||||
? modelFuse.search(modelSearch).map((r) => r.item)
|
||||
: modelInfoList
|
||||
|
||||
const showCustomEntry =
|
||||
modelSearch && !filteredModels.some((m) => m.modelId === modelSearch)
|
||||
|
||||
// Handle provider type change (user-initiated via Select)
|
||||
const handleTypeChange = (newType: ProviderType) => {
|
||||
form.setValue('type', newType)
|
||||
@@ -383,6 +342,9 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
if (defaultUrl) {
|
||||
form.setValue('baseUrl', defaultUrl)
|
||||
}
|
||||
if (newType === 'minimax') {
|
||||
form.setValue('region', 'chinese')
|
||||
}
|
||||
form.setValue('modelId', '')
|
||||
}
|
||||
|
||||
@@ -471,6 +433,11 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
provider_type: values.type,
|
||||
model: values.modelId,
|
||||
})
|
||||
} else {
|
||||
track(AI_PROVIDER_UPDATED_EVENT, {
|
||||
provider_type: values.type,
|
||||
model: values.modelId,
|
||||
})
|
||||
}
|
||||
if (values.type === 'moonshot') {
|
||||
track(KIMI_API_KEY_CONFIGURED_EVENT, {
|
||||
@@ -784,6 +751,94 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
// Minimax: region selector
|
||||
if (watchedType === 'minimax') {
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="region"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Region *</FormLabel>
|
||||
<Select
|
||||
onValueChange={(v) => {
|
||||
field.onChange(v)
|
||||
form.setValue(
|
||||
'baseUrl',
|
||||
MINIMAX_REGIONS[v as keyof typeof MINIMAX_REGIONS].api,
|
||||
)
|
||||
}}
|
||||
value={field.value || 'chinese'}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="chinese">
|
||||
Chinese (api.minimaxi.com)
|
||||
</SelectItem>
|
||||
<SelectItem value="international">
|
||||
International (api.minimax.io)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Choose the endpoint closest to your location
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="baseUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Base URL *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://api.minimaxi.com/v1" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="apiKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Key *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter your MiniMax API key"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your API key is encrypted and stored locally.{' '}
|
||||
{setupGuideUrl && (
|
||||
<a
|
||||
href={setupGuideUrl}
|
||||
onClick={handleSetupGuideClick}
|
||||
className="inline-flex cursor-pointer items-center gap-1 text-primary hover:underline"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{setupGuideText}
|
||||
</a>
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Standard providers (OpenAI, Anthropic, Google, etc.)
|
||||
return (
|
||||
<>
|
||||
@@ -924,36 +979,132 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
{...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)}
|
||||
/>
|
||||
) : (
|
||||
<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',
|
||||
)}
|
||||
<Popover
|
||||
open={modelPickerOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
setModelPickerOpen(isOpen)
|
||||
if (!isOpen) setModelSearch('')
|
||||
}}
|
||||
>
|
||||
<span className="truncate">
|
||||
{field.value || 'Select a model...'}
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</button>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
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',
|
||||
)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{field.value || 'Select a model...'}
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder="Search models..."
|
||||
value={modelSearch}
|
||||
onValueChange={(v) => {
|
||||
setModelSearch(v)
|
||||
requestAnimationFrame(() => {
|
||||
modelListRef.current?.scrollTo(0, 0)
|
||||
})
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && modelSearch) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
form.setValue('modelId', modelSearch)
|
||||
track(MODEL_SELECTED_EVENT, {
|
||||
provider_type: watchedType,
|
||||
model_id: modelSearch,
|
||||
is_custom_model: !modelInfoList.some(
|
||||
(m) => m.modelId === modelSearch,
|
||||
),
|
||||
})
|
||||
setModelPickerOpen(false)
|
||||
setModelSearch('')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<CommandList ref={modelListRef}>
|
||||
<CommandEmpty>
|
||||
No models found. Press Enter to use "
|
||||
{modelSearch}"
|
||||
</CommandEmpty>
|
||||
{showCustomEntry && (
|
||||
<CommandGroup forceMount>
|
||||
<CommandItem
|
||||
forceMount
|
||||
value={`custom:${modelSearch}`}
|
||||
onSelect={() => {
|
||||
form.setValue('modelId', modelSearch)
|
||||
track(MODEL_SELECTED_EVENT, {
|
||||
provider_type: watchedType,
|
||||
model_id: modelSearch,
|
||||
is_custom_model: true,
|
||||
})
|
||||
setModelPickerOpen(false)
|
||||
setModelSearch('')
|
||||
}}
|
||||
>
|
||||
<span className="flex-1 truncate">
|
||||
{modelSearch}
|
||||
</span>
|
||||
{field.value === modelSearch && (
|
||||
<Check className="ml-2 h-4 w-4 shrink-0" />
|
||||
)}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
{filteredModels.length > 0 && (
|
||||
<CommandGroup>
|
||||
{filteredModels.map((model) => (
|
||||
<CommandItem
|
||||
key={model.modelId}
|
||||
value={model.modelId}
|
||||
onSelect={() => {
|
||||
form.setValue('modelId', model.modelId)
|
||||
track(MODEL_SELECTED_EVENT, {
|
||||
provider_type: watchedType,
|
||||
model_id: model.modelId,
|
||||
context_window: model.contextLength,
|
||||
is_custom_model: !modelInfoList.some(
|
||||
(m) => m.modelId === model.modelId,
|
||||
),
|
||||
})
|
||||
setModelPickerOpen(false)
|
||||
setModelSearch('')
|
||||
}}
|
||||
>
|
||||
<span className="flex-1 truncate">
|
||||
{model.modelId}
|
||||
</span>
|
||||
{model.contextLength > 0 && (
|
||||
<span className="ml-2 shrink-0 rounded-md bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||||
{formatContextWindow(
|
||||
model.contextLength,
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{field.value === model.modelId && (
|
||||
<Check className="ml-2 h-4 w-4 shrink-0" />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Check, Loader2, Trash2 } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useKimiLaunch } from '@/lib/feature-flags/useKimiLaunch'
|
||||
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
|
||||
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -30,7 +29,6 @@ export const ProviderCard: FC<ProviderCardProps> = ({
|
||||
isTesting = false,
|
||||
}) => {
|
||||
const inputId = `provider-${provider.id}`
|
||||
const kimiLaunch = useKimiLaunch()
|
||||
|
||||
return (
|
||||
<label
|
||||
@@ -79,30 +77,21 @@ export const ProviderCard: FC<ProviderCardProps> = ({
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{isBuiltIn && provider.type === 'browseros' && kimiLaunch && (
|
||||
<span className="mb-1 inline-block rounded-full border border-orange-300/60 bg-orange-100/70 px-3 py-0.5 font-semibold text-orange-700 text-xs dark:border-orange-400/40 dark:bg-orange-500/15 dark:text-orange-300">
|
||||
In partnership with Moonshot AI
|
||||
</span>
|
||||
)}
|
||||
<p className="truncate text-muted-foreground text-sm">
|
||||
{isBuiltIn ? (
|
||||
kimiLaunch ? (
|
||||
'Extended usage limits for the next 2 weeks!'
|
||||
) : (
|
||||
<>
|
||||
BrowserOS-hosted model with strict rate limits.{' '}
|
||||
<a
|
||||
href="https://docs.browseros.com/features/bring-your-own-llm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Bring your own key
|
||||
</a>{' '}
|
||||
for better performance.
|
||||
</>
|
||||
)
|
||||
<>
|
||||
BrowserOS-hosted model with strict rate limits.{' '}
|
||||
<a
|
||||
href="https://docs.browseros.com/features/bring-your-own-llm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Bring your own key
|
||||
</a>{' '}
|
||||
for better performance.
|
||||
</>
|
||||
) : provider.baseUrl ? (
|
||||
`${provider.modelId} • ${provider.baseUrl}`
|
||||
) : (
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Feature } from '@/lib/browseros/capabilities'
|
||||
import { useCapabilities } from '@/lib/browseros/useCapabilities'
|
||||
import { useKimiLaunch } from '@/lib/feature-flags/useKimiLaunch'
|
||||
import {
|
||||
type ProviderTemplate,
|
||||
providerTemplates,
|
||||
@@ -23,7 +22,6 @@ export const ProviderTemplatesSection: FC<ProviderTemplatesSectionProps> = ({
|
||||
onUseTemplate,
|
||||
}) => {
|
||||
const { supports } = useCapabilities()
|
||||
const kimiLaunch = useKimiLaunch()
|
||||
|
||||
const filteredTemplates = providerTemplates.filter((template) => {
|
||||
if (template.id === 'chatgpt-pro')
|
||||
@@ -31,7 +29,6 @@ export const ProviderTemplatesSection: FC<ProviderTemplatesSectionProps> = ({
|
||||
if (template.id === 'github-copilot')
|
||||
return supports(Feature.GITHUB_COPILOT_SUPPORT)
|
||||
if (template.id === 'qwen-code') return supports(Feature.QWEN_CODE_SUPPORT)
|
||||
if (template.id === 'moonshot') return kimiLaunch
|
||||
if (template.id === 'openai-compatible') {
|
||||
return supports(Feature.OPENAI_COMPATIBLE_SUPPORT)
|
||||
}
|
||||
@@ -67,7 +64,6 @@ export const ProviderTemplatesSection: FC<ProviderTemplatesSectionProps> = ({
|
||||
<ProviderTemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
highlighted={template.id === 'moonshot'}
|
||||
isNew={isNew}
|
||||
onUseTemplate={onUseTemplate}
|
||||
/>
|
||||
|
||||
@@ -1,484 +0,0 @@
|
||||
import { useChat } from '@ai-sdk/react'
|
||||
import { DefaultChatTransport, type UIMessage } from 'ai'
|
||||
import { compact } from 'es-toolkit/array'
|
||||
import type { FC, FormEvent } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
import useDeepCompareEffect from 'use-deep-compare-effect'
|
||||
import type { Provider } from '@/components/chat/chatComponentTypes'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from '@/components/ui/resizable'
|
||||
import { useChatRefs } from '@/entrypoints/sidepanel/index/useChatRefs'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
import {
|
||||
GRAPH_SAVED_EVENT,
|
||||
GRAPH_UPDATED_EVENT,
|
||||
NEW_GRAPH_CREATED_EVENT,
|
||||
} from '@/lib/constants/analyticsEvents'
|
||||
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import { useRpcClient } from '@/lib/rpc/RpcClientProvider'
|
||||
import { sentry } from '@/lib/sentry/sentry'
|
||||
import { useWorkflows } from '@/lib/workflows/workflowStorage'
|
||||
import { GraphCanvas } from './GraphCanvas'
|
||||
import { GraphChat } from './GraphChat'
|
||||
import { WorkflowsChatHeader } from './WorkflowsChatHeader'
|
||||
|
||||
type MessageType = 'create-graph' | 'update-graph' | 'run-graph'
|
||||
|
||||
type GraphMessageMetadata = {
|
||||
messageType?: MessageType
|
||||
codeId?: string
|
||||
graph?: GraphData
|
||||
window?: chrome.windows.Window
|
||||
}
|
||||
|
||||
export type GraphData = {
|
||||
nodes: {
|
||||
id: string
|
||||
type: string
|
||||
data: {
|
||||
label: string
|
||||
}
|
||||
}[]
|
||||
edges: {
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
}[]
|
||||
}
|
||||
|
||||
const getLastMessageText = (messages: UIMessage[]) => {
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
if (!lastMessage) return ''
|
||||
return lastMessage.parts
|
||||
.filter((part) => part.type === 'text')
|
||||
.map((part) => part.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
export const CreateGraph: FC = () => {
|
||||
const [searchParams] = useSearchParams()
|
||||
const workflowIdParam = searchParams.get('workflowId')
|
||||
|
||||
const [graphName, setGraphName] = useState('')
|
||||
const [codeId, setCodeId] = useState<string | undefined>(undefined)
|
||||
const [graphData, setGraphData] = useState<GraphData | undefined>(undefined)
|
||||
const [savedWorkflowId, setSavedWorkflowId] = useState<string | undefined>(
|
||||
undefined,
|
||||
)
|
||||
const [savedCodeId, setSavedCodeId] = useState<string | undefined>(undefined)
|
||||
const [isInitialized, setIsInitialized] = useState(!workflowIdParam)
|
||||
const [canvasPanelSize, setCanvasPanelSize] = useState<
|
||||
{ asPercentage: number; inPixels: number } | undefined
|
||||
>(undefined)
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const [showDiscardDialog, setShowDiscardDialog] = useState(false)
|
||||
|
||||
const { workflows, addWorkflow, editWorkflow } = useWorkflows()
|
||||
const { providers: llmProviders, setDefaultProvider } = useLlmProviders()
|
||||
const rpcClient = useRpcClient()
|
||||
|
||||
// Initialize edit mode when workflowId is provided
|
||||
useDeepCompareEffect(() => {
|
||||
if (!workflowIdParam || isInitialized) return
|
||||
|
||||
const workflow = workflows.find((w) => w.id === workflowIdParam)
|
||||
if (!workflow) return
|
||||
|
||||
const initializeEditMode = async () => {
|
||||
setGraphName(workflow.workflowName)
|
||||
setCodeId(workflow.codeId)
|
||||
setSavedWorkflowId(workflow.id)
|
||||
setSavedCodeId(workflow.codeId)
|
||||
|
||||
try {
|
||||
const response = await rpcClient.graph[':id'].$get({
|
||||
param: { id: workflow.codeId },
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if ('graph' in data && data.graph) {
|
||||
setGraphData(data.graph as GraphData)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
sentry.captureException(error, {
|
||||
extra: {
|
||||
message: 'Failed to fetch graph data from the server',
|
||||
codeId: workflow.codeId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
setIsInitialized(true)
|
||||
}
|
||||
|
||||
initializeEditMode()
|
||||
}, [workflowIdParam, workflows, isInitialized, rpcClient])
|
||||
|
||||
const updateQuery = (newQuery: string) => {
|
||||
setQuery(newQuery)
|
||||
}
|
||||
|
||||
const onSubmit = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (codeId) {
|
||||
sendMessage({
|
||||
text: query,
|
||||
metadata: {
|
||||
messageType: 'update-graph' as MessageType,
|
||||
codeId,
|
||||
},
|
||||
})
|
||||
track(GRAPH_UPDATED_EVENT)
|
||||
} else {
|
||||
sendMessage({
|
||||
text: query,
|
||||
metadata: {
|
||||
messageType: 'create-graph' as MessageType,
|
||||
},
|
||||
})
|
||||
track(NEW_GRAPH_CREATED_EVENT)
|
||||
}
|
||||
setQuery('')
|
||||
}
|
||||
|
||||
const {
|
||||
baseUrl: agentServerUrl,
|
||||
isLoading: _isLoadingAgentUrl,
|
||||
error: agentUrlError,
|
||||
} = useAgentServerUrl()
|
||||
|
||||
const {
|
||||
selectedLlmProviderRef,
|
||||
enabledMcpServersRef,
|
||||
enabledCustomServersRef,
|
||||
personalizationRef,
|
||||
selectedLlmProvider,
|
||||
isLoadingProviders,
|
||||
} = useChatRefs()
|
||||
|
||||
const agentUrlRef = useRef(agentServerUrl)
|
||||
const codeIdRef = useRef(codeId)
|
||||
|
||||
useEffect(() => {
|
||||
agentUrlRef.current = agentServerUrl
|
||||
codeIdRef.current = codeId
|
||||
}, [agentServerUrl, codeId])
|
||||
|
||||
const { sendMessage, stop, status, messages, error, setMessages } = useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
prepareSendMessagesRequest: async ({ messages }) => {
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
const lastMessageText = getLastMessageText(messages)
|
||||
const metadata = lastMessage.metadata as
|
||||
| GraphMessageMetadata
|
||||
| undefined
|
||||
|
||||
if (metadata?.messageType === 'create-graph') {
|
||||
return {
|
||||
api: `${agentUrlRef.current}/graph`,
|
||||
body: {
|
||||
query: lastMessageText,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata?.messageType === 'update-graph' && codeIdRef.current) {
|
||||
return {
|
||||
api: `${agentUrlRef.current}/graph/${codeIdRef.current}`,
|
||||
body: {
|
||||
query: lastMessageText,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata?.messageType === 'run-graph' && codeIdRef.current) {
|
||||
const provider = selectedLlmProviderRef.current
|
||||
const enabledMcpServers = enabledMcpServersRef.current
|
||||
const customMcpServers = enabledCustomServersRef.current
|
||||
|
||||
return {
|
||||
api: `${agentUrlRef.current}/graph/${codeIdRef.current}/run`,
|
||||
body: {
|
||||
provider: provider?.type,
|
||||
providerType: provider?.type,
|
||||
providerName: provider?.name,
|
||||
model: provider?.modelId ?? 'browseros',
|
||||
contextWindowSize: provider?.contextWindow,
|
||||
temperature: provider?.temperature,
|
||||
resourceName: provider?.resourceName,
|
||||
// Bedrock-specific
|
||||
accessKeyId: provider?.accessKeyId,
|
||||
secretAccessKey: provider?.secretAccessKey,
|
||||
region: provider?.region,
|
||||
sessionToken: provider?.sessionToken,
|
||||
apiKey: provider?.apiKey,
|
||||
baseUrl: provider?.baseUrl,
|
||||
browserContext: {
|
||||
windowId: metadata?.window?.id,
|
||||
activeTab: metadata?.window?.tabs?.[0],
|
||||
enabledMcpServers: compact(enabledMcpServers),
|
||||
customMcpServers,
|
||||
},
|
||||
userSystemPrompt: personalizationRef.current,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
api: `${agentUrlRef.current}/graph`,
|
||||
body: {
|
||||
query: lastMessageText,
|
||||
},
|
||||
}
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
const lastAssistantMessageWithGraph = messages.findLast((m) => {
|
||||
if (m.role !== 'assistant') return false
|
||||
const metadata = m.metadata as GraphMessageMetadata | undefined
|
||||
return metadata?.graph !== undefined
|
||||
})
|
||||
|
||||
const onClickTest = async () => {
|
||||
let backgroundWindow: chrome.windows.Window | undefined
|
||||
try {
|
||||
backgroundWindow = await chrome.windows.create({
|
||||
url: 'chrome://newtab',
|
||||
focused: true,
|
||||
type: 'normal',
|
||||
})
|
||||
} catch {
|
||||
// Fallback when no window context is available (e.g. all windows closed)
|
||||
const tab = await chrome.tabs.create({
|
||||
url: 'chrome://newtab',
|
||||
active: true,
|
||||
})
|
||||
if (tab.windowId) {
|
||||
backgroundWindow = await chrome.windows.get(tab.windowId)
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage({
|
||||
text: 'Run a test of the graph you just created.',
|
||||
metadata: {
|
||||
messageType: 'run-graph' as MessageType,
|
||||
codeId,
|
||||
window: backgroundWindow,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const hasUnsavedChanges = savedWorkflowId ? codeId !== savedCodeId : true
|
||||
const shouldBlockNavigation = !!codeId && hasUnsavedChanges
|
||||
|
||||
// Handle browser refresh/close
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (shouldBlockNavigation) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}, [shouldBlockNavigation])
|
||||
|
||||
const onClickSave = async () => {
|
||||
if (!graphName || !codeId) return
|
||||
|
||||
if (savedWorkflowId) {
|
||||
await editWorkflow(savedWorkflowId, {
|
||||
workflowName: graphName,
|
||||
codeId,
|
||||
})
|
||||
setSavedCodeId(codeId)
|
||||
} else {
|
||||
const newWorkflow = await addWorkflow({
|
||||
workflowName: graphName,
|
||||
codeId,
|
||||
})
|
||||
setSavedWorkflowId(newWorkflow.id)
|
||||
setSavedCodeId(codeId)
|
||||
}
|
||||
track(GRAPH_SAVED_EVENT)
|
||||
}
|
||||
|
||||
// Provider data for header
|
||||
const providers: Provider[] = llmProviders.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
type: p.type,
|
||||
}))
|
||||
|
||||
const selectedProviderForHeader: Provider | undefined = selectedLlmProvider
|
||||
? {
|
||||
id: selectedLlmProvider.id,
|
||||
name: selectedLlmProvider.name,
|
||||
type: selectedLlmProvider.type,
|
||||
}
|
||||
: providers[0]
|
||||
|
||||
// Has generated code but can't auto-save (no name)
|
||||
const hasUnsavedWork = codeId && !graphName
|
||||
|
||||
const resetToNewWorkflow = () => {
|
||||
setCodeId(undefined)
|
||||
setGraphData(undefined)
|
||||
setGraphName('')
|
||||
setSavedWorkflowId(undefined)
|
||||
setSavedCodeId(undefined)
|
||||
setMessages([])
|
||||
}
|
||||
|
||||
const handleSelectProvider = (provider: Provider) => {
|
||||
setDefaultProvider(provider.id)
|
||||
}
|
||||
|
||||
const handleNewWorkflow = async () => {
|
||||
// Can auto-save: has name AND code
|
||||
if (graphName && codeId) {
|
||||
await onClickSave()
|
||||
resetToNewWorkflow()
|
||||
return
|
||||
}
|
||||
|
||||
// Has unsaved work that can't be auto-saved: show confirmation
|
||||
if (hasUnsavedWork) {
|
||||
setShowDiscardDialog(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Nothing to save, just reset
|
||||
resetToNewWorkflow()
|
||||
}
|
||||
|
||||
const handleConfirmDiscard = () => {
|
||||
setShowDiscardDialog(false)
|
||||
resetToNewWorkflow()
|
||||
}
|
||||
|
||||
const handleSuggestionClick = (prompt: string) => {
|
||||
sendMessage({
|
||||
text: prompt,
|
||||
metadata: {
|
||||
messageType: 'create-graph' as MessageType,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
useDeepCompareEffect(() => {
|
||||
if (status === 'ready' && lastAssistantMessageWithGraph) {
|
||||
const metadata = lastAssistantMessageWithGraph.metadata as
|
||||
| GraphMessageMetadata
|
||||
| undefined
|
||||
setCodeId(metadata?.codeId)
|
||||
setGraphData(metadata?.graph)
|
||||
}
|
||||
}, [status, lastAssistantMessageWithGraph ?? {}])
|
||||
|
||||
if (!isInitialized || isLoadingProviders || !selectedProviderForHeader) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-background text-foreground">
|
||||
<div className="fade-in animate-in text-muted-foreground duration-200 [animation-delay:300ms] [animation-fill-mode:backwards]">
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen bg-background text-foreground">
|
||||
<ResizablePanelGroup orientation="horizontal">
|
||||
<ResizablePanel
|
||||
id="graph-canvas"
|
||||
defaultSize={'70%'}
|
||||
minSize={'30%'}
|
||||
maxSize={'70%'}
|
||||
onResize={(size) => setCanvasPanelSize(size)}
|
||||
>
|
||||
<GraphCanvas
|
||||
graphName={graphName}
|
||||
onGraphNameChange={(val) => setGraphName(val)}
|
||||
graphData={graphData}
|
||||
codeId={codeId}
|
||||
onClickTest={onClickTest}
|
||||
onClickSave={onClickSave}
|
||||
isSaved={!!savedWorkflowId}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
shouldBlockNavigation={shouldBlockNavigation}
|
||||
panelSize={canvasPanelSize}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
id="graph-chat"
|
||||
defaultSize={'30%'}
|
||||
maxSize={'70%'}
|
||||
minSize={'30%'}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<WorkflowsChatHeader
|
||||
selectedProvider={selectedProviderForHeader}
|
||||
providers={providers}
|
||||
onSelectProvider={handleSelectProvider}
|
||||
onNewWorkflow={handleNewWorkflow}
|
||||
hasMessages={messages.length > 0}
|
||||
/>
|
||||
<div className="min-h-0 flex-1">
|
||||
<GraphChat
|
||||
messages={messages}
|
||||
onSubmit={onSubmit}
|
||||
onInputChange={updateQuery}
|
||||
onStop={stop}
|
||||
input={query}
|
||||
status={status}
|
||||
agentUrlError={agentUrlError}
|
||||
chatError={error}
|
||||
onSuggestionClick={handleSuggestionClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
|
||||
<AlertDialog open={showDiscardDialog} onOpenChange={setShowDiscardDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Discard unsaved workflow?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
You have an unsaved workflow. Creating a new one will discard your
|
||||
current changes.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirmDiscard}>
|
||||
Discard
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { type FC, Suspense } from 'react'
|
||||
import { RpcClientProvider } from '@/lib/rpc/RpcClientProvider'
|
||||
import { CreateGraph } from './CreateGraph'
|
||||
|
||||
export const CreateGraphWrapper: FC = () => {
|
||||
return (
|
||||
<RpcClientProvider>
|
||||
<Suspense fallback={<div className="h-screen w-screen bg-background" />}>
|
||||
<CreateGraph />
|
||||
</Suspense>
|
||||
</RpcClientProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import { Handle, type Node, type NodeProps, Position } from '@xyflow/react'
|
||||
import {
|
||||
CheckCircle,
|
||||
Download,
|
||||
GitBranch,
|
||||
GitMerge,
|
||||
MousePointer,
|
||||
Navigation,
|
||||
Play,
|
||||
RotateCw,
|
||||
Split,
|
||||
Square,
|
||||
} from 'lucide-react'
|
||||
import type React from 'react'
|
||||
import { memo } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const nodeConfig: Record<
|
||||
NodeType,
|
||||
{ color: string; icon: React.ElementType; label: string }
|
||||
> = {
|
||||
start: {
|
||||
color: 'text-green-600 dark:text-green-400',
|
||||
icon: Play,
|
||||
label: 'Start',
|
||||
},
|
||||
end: {
|
||||
color: 'text-red-600 dark:text-red-400',
|
||||
icon: Square,
|
||||
label: 'End',
|
||||
},
|
||||
nav: {
|
||||
color: 'text-blue-600 dark:text-blue-400',
|
||||
icon: Navigation,
|
||||
label: 'Navigate',
|
||||
},
|
||||
act: {
|
||||
color: 'text-purple-600 dark:text-purple-400',
|
||||
icon: MousePointer,
|
||||
label: 'Action',
|
||||
},
|
||||
extract: {
|
||||
color: 'text-amber-600 dark:text-amber-400',
|
||||
icon: Download,
|
||||
label: 'Extract',
|
||||
},
|
||||
verify: {
|
||||
color: 'text-emerald-600 dark:text-emerald-400',
|
||||
icon: CheckCircle,
|
||||
label: 'Verify',
|
||||
},
|
||||
decision: {
|
||||
color: 'text-pink-600 dark:text-pink-400',
|
||||
icon: GitBranch,
|
||||
label: 'Decision',
|
||||
},
|
||||
loop: {
|
||||
color: 'text-cyan-600 dark:text-cyan-400',
|
||||
icon: RotateCw,
|
||||
label: 'Loop',
|
||||
},
|
||||
fork: {
|
||||
color: 'text-indigo-600 dark:text-indigo-400',
|
||||
icon: Split,
|
||||
label: 'Fork',
|
||||
},
|
||||
join: {
|
||||
color: 'text-lime-600 dark:text-lime-400',
|
||||
icon: GitMerge,
|
||||
label: 'Join',
|
||||
},
|
||||
}
|
||||
|
||||
export type NodeType =
|
||||
| 'start'
|
||||
| 'end'
|
||||
| 'nav'
|
||||
| 'act'
|
||||
| 'extract'
|
||||
| 'verify'
|
||||
| 'decision'
|
||||
| 'loop'
|
||||
| 'fork'
|
||||
| 'join'
|
||||
|
||||
type CustomNodeData = Node<{
|
||||
type: NodeType
|
||||
label: string
|
||||
}>
|
||||
|
||||
export const CustomNode = memo(
|
||||
({ data: { label, type } }: NodeProps<CustomNodeData>) => {
|
||||
const config = nodeConfig[type || 'start']
|
||||
const Icon = config.icon
|
||||
|
||||
const showSourceHandle = type !== 'end'
|
||||
const showTargetHandle = type !== 'start'
|
||||
|
||||
return (
|
||||
<div className="min-w-45 rounded-lg border border-border bg-card px-4 py-3 shadow-md transition-all">
|
||||
{showTargetHandle && (
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="h-2 w-2 bg-accent-orange!"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn('shrink-0', config.color)}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
'mb-0.5 font-semibold text-xs uppercase tracking-wide',
|
||||
config.color,
|
||||
)}
|
||||
>
|
||||
{config.label}
|
||||
</div>
|
||||
<div className="wrap-break-word font-medium text-foreground text-sm">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSourceHandle && (
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className="h-2 w-2 bg-accent-orange!"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
CustomNode.displayName = 'CustomNode'
|
||||
@@ -1,514 +0,0 @@
|
||||
import cytoscape from 'cytoscape'
|
||||
import dagre from 'cytoscape-dagre'
|
||||
// @ts-expect-error no types available
|
||||
import nodeHtmlLabel from 'cytoscape-node-html-label'
|
||||
import DOMPurify from 'dompurify'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Maximize,
|
||||
Minus,
|
||||
Pencil,
|
||||
Play,
|
||||
Plus,
|
||||
Save,
|
||||
} from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import useDeepCompareEffect from 'use-deep-compare-effect'
|
||||
import ProductLogo from '@/assets/product_logo.svg'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import type { GraphData } from './CreateGraph'
|
||||
import type { NodeType } from './CustomNode'
|
||||
|
||||
cytoscape.use(dagre)
|
||||
nodeHtmlLabel(cytoscape)
|
||||
|
||||
const NODE_CONFIG: Record<
|
||||
NodeType,
|
||||
{ color: string; bgColor: string; icon: string; label: string }
|
||||
> = {
|
||||
start: {
|
||||
color: '#22c55e',
|
||||
bgColor: 'rgba(34, 197, 94, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 3 20 12 6 21 6 3"></polygon></svg>`,
|
||||
label: 'START',
|
||||
},
|
||||
end: {
|
||||
color: '#ef4444',
|
||||
bgColor: 'rgba(239, 68, 68, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"></rect></svg>`,
|
||||
label: 'END',
|
||||
},
|
||||
nav: {
|
||||
color: '#3b82f6',
|
||||
bgColor: 'rgba(59, 130, 246, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="3 11 22 2 13 21 11 13 3 11"></polygon></svg>`,
|
||||
label: 'NAVIGATE',
|
||||
},
|
||||
act: {
|
||||
color: '#8b5cf6',
|
||||
bgColor: 'rgba(139, 92, 246, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 4 7.07 17 2.51-7.39L21 11.07z"></path></svg>`,
|
||||
label: 'ACTION',
|
||||
},
|
||||
extract: {
|
||||
color: '#f59e0b',
|
||||
bgColor: 'rgba(245, 158, 11, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" x2="12" y1="15" y2="3"></line></svg>`,
|
||||
label: 'EXTRACT',
|
||||
},
|
||||
verify: {
|
||||
color: '#10b981',
|
||||
bgColor: 'rgba(16, 185, 129, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>`,
|
||||
label: 'VERIFY',
|
||||
},
|
||||
decision: {
|
||||
color: '#ec4899',
|
||||
bgColor: 'rgba(236, 72, 153, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" x2="6" y1="3" y2="15"></line><circle cx="18" cy="6" r="3"></circle><circle cx="6" cy="18" r="3"></circle><path d="M18 9a9 9 0 0 1-9 9"></path></svg>`,
|
||||
label: 'DECISION',
|
||||
},
|
||||
loop: {
|
||||
color: '#06b6d4',
|
||||
bgColor: 'rgba(6, 182, 212, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path></svg>`,
|
||||
label: 'LOOP',
|
||||
},
|
||||
fork: {
|
||||
color: '#6366f1',
|
||||
bgColor: 'rgba(99, 102, 241, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 3h5v5"></path><path d="M8 3H3v5"></path><path d="M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3"></path><path d="m15 9 6-6"></path></svg>`,
|
||||
label: 'FORK',
|
||||
},
|
||||
join: {
|
||||
color: '#84cc16',
|
||||
bgColor: 'rgba(132, 204, 22, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="18" r="3"></circle><circle cx="6" cy="6" r="3"></circle><path d="M6 21V9a9 9 0 0 0 9 9"></path></svg>`,
|
||||
label: 'JOIN',
|
||||
},
|
||||
}
|
||||
|
||||
const initialData: GraphData = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'start',
|
||||
type: 'start',
|
||||
data: { label: 'Use the Chat to build your workflow!' },
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
}
|
||||
|
||||
const MIN_NODE_WIDTH = 180
|
||||
const MAX_NODE_WIDTH = 240
|
||||
const BASE_NODE_HEIGHT = 70
|
||||
const CHAR_WIDTH = 7
|
||||
const ICON_AND_PADDING = 62
|
||||
const MAX_ZOOM = 1.2
|
||||
|
||||
const calculateNodeDimensions = (
|
||||
label: string,
|
||||
): { width: number; height: number } => {
|
||||
const textWidth = label.length * CHAR_WIDTH + ICON_AND_PADDING
|
||||
const width = Math.max(MIN_NODE_WIDTH, Math.min(MAX_NODE_WIDTH, textWidth))
|
||||
|
||||
const maxCharsPerLine = Math.floor((width - ICON_AND_PADDING) / CHAR_WIDTH)
|
||||
const lines = Math.ceil(label.length / maxCharsPerLine)
|
||||
const extraHeight = Math.max(0, lines - 1) * 18
|
||||
const height = BASE_NODE_HEIGHT + extraHeight
|
||||
|
||||
return { width, height }
|
||||
}
|
||||
|
||||
const createNodeHtml = (type: NodeType, label: string): string => {
|
||||
const config = NODE_CONFIG[type] || NODE_CONFIG.start
|
||||
const sanitizedLabel = DOMPurify.sanitize(label, { ALLOWED_TAGS: [] })
|
||||
return `
|
||||
<div class="graph-node" style="
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
min-width: 160px;
|
||||
max-width: 220px;
|
||||
padding: 12px 16px;
|
||||
background-color: var(--graph-node-bg);
|
||||
border: 1px solid var(--graph-node-border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
">
|
||||
<div style="
|
||||
flex-shrink: 0;
|
||||
color: ${config.color};
|
||||
margin-top: 2px;
|
||||
">
|
||||
${config.icon}
|
||||
</div>
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<div style="
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
color: ${config.color};
|
||||
margin-bottom: 4px;
|
||||
">${config.label}</div>
|
||||
<div style="
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--graph-node-text);
|
||||
line-height: 1.4;
|
||||
word-wrap: break-word;
|
||||
">${sanitizedLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
type GraphCanvasProps = {
|
||||
graphName: string
|
||||
onGraphNameChange: (name: string) => void
|
||||
graphData?: GraphData
|
||||
codeId?: string
|
||||
onClickTest: () => unknown
|
||||
onClickSave: () => unknown
|
||||
isSaved: boolean
|
||||
hasUnsavedChanges: boolean
|
||||
shouldBlockNavigation: boolean
|
||||
panelSize?: { asPercentage: number; inPixels: number }
|
||||
}
|
||||
|
||||
export const GraphCanvas: FC<GraphCanvasProps> = ({
|
||||
graphName,
|
||||
onGraphNameChange,
|
||||
graphData = initialData,
|
||||
codeId,
|
||||
onClickTest,
|
||||
onClickSave,
|
||||
isSaved,
|
||||
hasUnsavedChanges,
|
||||
shouldBlockNavigation,
|
||||
panelSize,
|
||||
}) => {
|
||||
const [isEditingName, setIsEditingName] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const cyRef = useRef<cytoscape.Core | null>(null)
|
||||
|
||||
const handleBack = () => {
|
||||
if (shouldBlockNavigation) {
|
||||
const confirmed = window.confirm(
|
||||
'You have unsaved changes. Are you sure you want to leave?',
|
||||
)
|
||||
if (!confirmed) return
|
||||
}
|
||||
navigate(-1)
|
||||
}
|
||||
|
||||
const canTest = !!codeId
|
||||
const canSave = !!graphName && !!codeId && hasUnsavedChanges
|
||||
|
||||
const getTestTooltip = () => {
|
||||
if (!codeId) return 'Create a workflow using the chat first'
|
||||
return 'Run a test of this workflow'
|
||||
}
|
||||
|
||||
const getSaveTooltip = () => {
|
||||
if (!codeId) return 'Create a workflow using the chat first'
|
||||
if (!graphName) return 'Provide a name for the workflow'
|
||||
if (isSaved && !hasUnsavedChanges) return 'Workflow already saved'
|
||||
return isSaved ? 'Save changes to this workflow' : 'Save this workflow'
|
||||
}
|
||||
|
||||
const getSaveButtonLabel = () => {
|
||||
return isSaved ? 'Save Changes' : 'Save Workflow'
|
||||
}
|
||||
|
||||
const zoomIn = useCallback(() => {
|
||||
cyRef.current?.zoom(cyRef.current.zoom() * 1.2)
|
||||
cyRef.current?.center()
|
||||
}, [])
|
||||
|
||||
const zoomOut = useCallback(() => {
|
||||
cyRef.current?.zoom(cyRef.current.zoom() / 1.2)
|
||||
cyRef.current?.center()
|
||||
}, [])
|
||||
|
||||
const fitView = useCallback(() => {
|
||||
cyRef.current?.fit(undefined, 50)
|
||||
cyRef.current?.center()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
const cy = cytoscape({
|
||||
container: containerRef.current,
|
||||
elements: [],
|
||||
style: [
|
||||
{
|
||||
selector: 'node',
|
||||
style: {
|
||||
width: 'data(nodeWidth)',
|
||||
height: 'data(nodeHeight)',
|
||||
'background-opacity': 0,
|
||||
'border-width': 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: 'edge',
|
||||
style: {
|
||||
width: 2,
|
||||
'line-color': '#f97316',
|
||||
'target-arrow-color': '#f97316',
|
||||
'target-arrow-shape': 'triangle',
|
||||
'curve-style': 'bezier',
|
||||
'arrow-scale': 1.2,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: 'edge.back-edge',
|
||||
style: {
|
||||
'line-style': 'dashed',
|
||||
'line-dash-pattern': [6, 3],
|
||||
'curve-style': 'unbundled-bezier',
|
||||
'control-point-distances': [100],
|
||||
'control-point-weights': [0.5],
|
||||
},
|
||||
},
|
||||
],
|
||||
layout: { name: 'preset' },
|
||||
userZoomingEnabled: true,
|
||||
userPanningEnabled: true,
|
||||
boxSelectionEnabled: false,
|
||||
selectionType: 'single',
|
||||
autoungrabify: true,
|
||||
autounselectify: true,
|
||||
maxZoom: MAX_ZOOM,
|
||||
minZoom: 0.2,
|
||||
})
|
||||
|
||||
// @ts-expect-error nodeHtmlLabel extension
|
||||
cy.nodeHtmlLabel([
|
||||
{
|
||||
query: 'node',
|
||||
halign: 'center',
|
||||
valign: 'center',
|
||||
halignBox: 'center',
|
||||
valignBox: 'center',
|
||||
tpl: (data: { type: NodeType; label: string }) => {
|
||||
return createNodeHtml(data.type, data.label)
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
cyRef.current = cy
|
||||
|
||||
return () => {
|
||||
cy.destroy()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const updateGraph = useCallback((data: GraphData) => {
|
||||
const cy = cyRef.current
|
||||
if (!cy) return
|
||||
|
||||
cy.elements().remove()
|
||||
|
||||
const nodes = data.nodes.map((node) => {
|
||||
const dimensions = calculateNodeDimensions(node.data.label)
|
||||
return {
|
||||
data: {
|
||||
id: node.id,
|
||||
label: node.data.label,
|
||||
type: node.type as NodeType,
|
||||
nodeWidth: dimensions.width,
|
||||
nodeHeight: dimensions.height,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const edges = data.edges.map((edge) => ({
|
||||
data: {
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
},
|
||||
}))
|
||||
|
||||
cy.add([...nodes, ...edges])
|
||||
|
||||
cy.layout({
|
||||
name: 'dagre',
|
||||
rankDir: 'TB',
|
||||
nodeSep: 80,
|
||||
rankSep: 100,
|
||||
padding: 50,
|
||||
animate: true,
|
||||
animationDuration: 300,
|
||||
fit: true,
|
||||
} as cytoscape.LayoutOptions).run()
|
||||
|
||||
setTimeout(() => {
|
||||
cy.edges().forEach((edge) => {
|
||||
const sourceNode = edge.source()
|
||||
const targetNode = edge.target()
|
||||
const sourceY = sourceNode.position('y')
|
||||
const targetY = targetNode.position('y')
|
||||
|
||||
if (sourceY > targetY) {
|
||||
edge.addClass('back-edge')
|
||||
}
|
||||
})
|
||||
}, 350)
|
||||
}, [])
|
||||
|
||||
useDeepCompareEffect(() => {
|
||||
updateGraph(graphData)
|
||||
}, [graphData])
|
||||
|
||||
useEffect(() => {
|
||||
if (panelSize?.inPixels !== undefined) {
|
||||
cyRef.current?.resize()
|
||||
setTimeout(() => fitView(), 100)
|
||||
}
|
||||
}, [panelSize?.inPixels, fitView])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col [--graph-node-bg:rgba(255,255,255,1)] [--graph-node-border:rgba(228,228,231,1)] [--graph-node-text:rgba(24,24,27,1)] dark:[--graph-node-bg:rgba(24,24,27,1)] dark:[--graph-node-border:rgba(63,63,70,1)] dark:[--graph-node-text:rgba(250,250,250,1)]">
|
||||
{/* Graph Header */}
|
||||
<header className="flex h-14 shrink-0 items-center justify-between border-border/40 border-b bg-background/80 px-3 backdrop-blur-md">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={handleBack}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<img src={ProductLogo} alt="BrowserOS" className="h-8 w-8 shrink-0" />
|
||||
{isEditingName ? (
|
||||
<input
|
||||
type="text"
|
||||
value={graphName}
|
||||
onChange={(e) => onGraphNameChange(e.target.value)}
|
||||
onBlur={() => setIsEditingName(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') setIsEditingName(false)
|
||||
}}
|
||||
// biome-ignore lint/a11y/noAutofocus: needed to autofocus field when edit mode is toggled
|
||||
autoFocus
|
||||
placeholder="Enter workflow name..."
|
||||
className="max-w-64 border-[var(--accent-orange)] border-b bg-transparent font-semibold text-sm outline-none placeholder:font-normal placeholder:text-muted-foreground/60"
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsEditingName(true)}
|
||||
className="group min-w-0 gap-2 px-2 py-1"
|
||||
>
|
||||
{graphName ? (
|
||||
<span className="truncate font-semibold text-sm">
|
||||
{graphName}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground/60 text-sm italic">
|
||||
Untitled workflow
|
||||
</span>
|
||||
)}
|
||||
<Pencil className="h-3.5 w-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Control Buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onClickTest}
|
||||
disabled={!canTest}
|
||||
>
|
||||
<Play className="mr-1.5 h-4 w-4" />
|
||||
Test Workflow
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{getTestTooltip()}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onClickSave}
|
||||
disabled={!canSave}
|
||||
className="bg-[var(--accent-orange)] shadow-lg shadow-orange-500/20 hover:bg-[var(--accent-orange-bright)] disabled:bg-[var(--accent-orange)]/50"
|
||||
>
|
||||
<Save className="mr-1.5 h-4 w-4" />
|
||||
{getSaveButtonLabel()}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{getSaveTooltip()}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Graph Canvas */}
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden [--dot-color:rgba(0,0,0,0.2)] dark:[--dot-color:rgba(255,255,255,0.15)]">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full bg-zinc-50 dark:bg-zinc-900"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'radial-gradient(circle, var(--dot-color) 1.5px, transparent 1.5px)',
|
||||
backgroundSize: '20px 20px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Zoom Controls */}
|
||||
<div className="absolute bottom-4 left-4 z-10 flex flex-col gap-1 rounded-lg border-2 border-border bg-card p-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={zoomIn}
|
||||
title="Zoom in"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={zoomOut}
|
||||
title="Zoom out"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={fitView}
|
||||
title="Fit view"
|
||||
>
|
||||
<Maximize className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
import type { UIMessage } from 'ai'
|
||||
import { Send, SquareStop } from 'lucide-react'
|
||||
import type { FC, FormEventHandler, KeyboardEvent } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ChatError } from '@/entrypoints/sidepanel/index/ChatError'
|
||||
import { ChatMessages } from '@/entrypoints/sidepanel/index/ChatMessages'
|
||||
import { getResponseAndQueryFromMessageId } from '@/entrypoints/sidepanel/index/useChatSession'
|
||||
import {
|
||||
GRAPH_MESSAGE_DISLIKE_EVENT,
|
||||
GRAPH_MESSAGE_LIKE_EVENT,
|
||||
} from '@/lib/constants/analyticsEvents'
|
||||
import { useJtbdPopup } from '@/lib/jtbd-popup/useJtbdPopup'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { GraphEmptyState } from './GraphEmptyState'
|
||||
import { getWorkflowDisplayMessages } from './workflow-tidbit-messages'
|
||||
|
||||
interface GraphChatProps {
|
||||
onSubmit: FormEventHandler<HTMLFormElement>
|
||||
onInputChange: (value: string) => void
|
||||
onStop: () => void
|
||||
input: string
|
||||
status: 'streaming' | 'submitted' | 'ready' | 'error'
|
||||
messages: UIMessage[]
|
||||
chatError?: Error
|
||||
agentUrlError?: Error | null
|
||||
onSuggestionClick: (prompt: string) => void
|
||||
}
|
||||
|
||||
export const GraphChat: FC<GraphChatProps> = ({
|
||||
onSubmit,
|
||||
onInputChange,
|
||||
onStop,
|
||||
input,
|
||||
status,
|
||||
messages,
|
||||
chatError,
|
||||
agentUrlError,
|
||||
onSuggestionClick,
|
||||
}) => {
|
||||
const [liked, setLiked] = useState<Record<string, boolean>>({})
|
||||
const [disliked, setDisliked] = useState<Record<string, boolean>>({})
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const displayMessages = getWorkflowDisplayMessages(messages)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const {
|
||||
popupVisible,
|
||||
recordMessageSent,
|
||||
triggerIfEligible,
|
||||
onTakeSurvey: onTakeSurveyBase,
|
||||
onDismiss: onDismissJtbdPopup,
|
||||
} = useJtbdPopup()
|
||||
|
||||
const onTakeSurvey = () =>
|
||||
onTakeSurveyBase({ experimentId: 'workflow_survey' })
|
||||
|
||||
// Trigger JTBD popup when AI finishes responding
|
||||
const previousChatStatus = useRef(status)
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally only trigger on status change
|
||||
useEffect(() => {
|
||||
const aiWasProcessing =
|
||||
previousChatStatus.current === 'streaming' ||
|
||||
previousChatStatus.current === 'submitted'
|
||||
const aiJustFinished = aiWasProcessing && status === 'ready'
|
||||
|
||||
if (aiJustFinished && messages.length > 0) {
|
||||
triggerIfEligible()
|
||||
}
|
||||
previousChatStatus.current = status
|
||||
}, [status])
|
||||
|
||||
const onClickLike = (messageId: string) => {
|
||||
const { responseText, queryText } = getResponseAndQueryFromMessageId(
|
||||
messages,
|
||||
messageId,
|
||||
)
|
||||
|
||||
track(GRAPH_MESSAGE_LIKE_EVENT, { responseText, queryText, messageId })
|
||||
|
||||
setLiked((prev) => ({
|
||||
...prev,
|
||||
[messageId]: !prev[messageId],
|
||||
}))
|
||||
}
|
||||
|
||||
const onClickDislike = (messageId: string, comment?: string) => {
|
||||
const { responseText, queryText } = getResponseAndQueryFromMessageId(
|
||||
messages,
|
||||
messageId,
|
||||
)
|
||||
|
||||
track(GRAPH_MESSAGE_DISLIKE_EVENT, {
|
||||
responseText,
|
||||
queryText,
|
||||
messageId,
|
||||
comment,
|
||||
})
|
||||
|
||||
setDisliked((prev) => ({
|
||||
...prev,
|
||||
[messageId]: !prev[messageId],
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (e) => {
|
||||
recordMessageSent()
|
||||
onSubmit(e)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (
|
||||
e.key === 'Enter' &&
|
||||
!e.shiftKey &&
|
||||
!e.metaKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.nativeEvent.isComposing
|
||||
) {
|
||||
e.preventDefault()
|
||||
if (input.trim()) {
|
||||
e.currentTarget.form?.requestSubmit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="styled-scrollbar min-h-0 flex-1 overflow-y-auto pb-2">
|
||||
{displayMessages.length === 0 ? (
|
||||
<GraphEmptyState
|
||||
mounted={mounted}
|
||||
onSuggestionClick={onSuggestionClick}
|
||||
/>
|
||||
) : (
|
||||
<ChatMessages
|
||||
liked={liked}
|
||||
disliked={disliked}
|
||||
onClickDislike={onClickDislike}
|
||||
onClickLike={onClickLike}
|
||||
messages={displayMessages}
|
||||
status={status}
|
||||
showJtbdPopup={popupVisible}
|
||||
showDontShowAgain={false}
|
||||
onTakeSurvey={onTakeSurvey}
|
||||
onDismissJtbdPopup={onDismissJtbdPopup}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{agentUrlError && <ChatError error={agentUrlError} />}
|
||||
{chatError && <ChatError error={chatError} />}
|
||||
<div className="shrink-0 border-border/40 border-t bg-background/80 p-2 backdrop-blur-md">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="relative flex w-full items-end gap-2"
|
||||
>
|
||||
<textarea
|
||||
className={cn(
|
||||
'field-sizing-content max-h-60 min-h-[42px] flex-1 resize-none overflow-hidden rounded-2xl border border-border/50 bg-muted/50 px-4 py-2.5 pr-11 text-sm outline-none transition-colors placeholder:text-muted-foreground/70 hover:border-border focus:border-[var(--accent-orange)]',
|
||||
)}
|
||||
value={input}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
'Visit Amazon and add sensodyne toothpaste to the cart.'
|
||||
}
|
||||
rows={1}
|
||||
/>
|
||||
{status === 'streaming' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
className="absolute right-1.5 bottom-1.5 cursor-pointer rounded-full bg-red-600 p-2 text-white shadow-sm transition-all duration-200 hover:bg-red-900 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<SquareStop className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">Stop</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim()}
|
||||
className="absolute right-1.5 bottom-1.5 cursor-pointer rounded-full bg-[var(--accent-orange)] p-2 text-white shadow-sm transition-all duration-200 hover:bg-[var(--accent-orange-bright)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">Send</span>
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { Workflow } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface Suggestion {
|
||||
display: string
|
||||
prompt: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
const WORKFLOW_SUGGESTIONS: Suggestion[] = [
|
||||
{
|
||||
display: 'Search Amazon and add toothpaste to cart',
|
||||
prompt:
|
||||
'Go to Amazon, search for toothpaste, select 1 pack filter and add the first result to cart',
|
||||
icon: '🛒',
|
||||
},
|
||||
{
|
||||
display: 'Accept LinkedIn connection requests',
|
||||
prompt:
|
||||
'Open LinkedIn and go to my connection requests, accept one by one in a loop for 25 times',
|
||||
icon: '🤝',
|
||||
},
|
||||
{
|
||||
display: 'Unsubscribe from Gmail subscriptions',
|
||||
prompt:
|
||||
'Go to Gmail, navigate to manage subscriptions and unsubscribe from all',
|
||||
icon: '📧',
|
||||
},
|
||||
]
|
||||
|
||||
interface GraphEmptyStateProps {
|
||||
mounted: boolean
|
||||
onSuggestionClick: (prompt: string) => void
|
||||
}
|
||||
|
||||
export const GraphEmptyState: FC<GraphEmptyStateProps> = ({
|
||||
mounted,
|
||||
onSuggestionClick,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'm-0! flex h-full flex-col items-center justify-center space-y-4 text-center opacity-0 transition-all duration-700',
|
||||
mounted ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0',
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex h-14 w-14 items-center justify-center rounded-2xl bg-muted/50">
|
||||
<Workflow className="h-7 w-7 text-[var(--accent-orange)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="mb-1 font-semibold text-lg">
|
||||
Create reliable workflows
|
||||
</h2>
|
||||
<p className="max-w-[240px] text-muted-foreground text-xs">
|
||||
Chat with the agent to create and refine browser automation
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid w-full max-w-[300px] grid-cols-1 gap-2">
|
||||
{WORKFLOW_SUGGESTIONS.map((suggestion) => (
|
||||
<button
|
||||
type="button"
|
||||
key={suggestion.display}
|
||||
onClick={() => onSuggestionClick(suggestion.prompt)}
|
||||
className="group flex items-center justify-between rounded-lg border border-border/50 bg-card px-3 py-2.5 text-left text-xs transition-all duration-200 hover:border-[var(--accent-orange)]/50 hover:bg-[var(--accent-orange)]/5"
|
||||
>
|
||||
{suggestion.display}
|
||||
<span className="opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
{suggestion.icon}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { Github, Plus, SettingsIcon } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector'
|
||||
import type { Provider } from '@/components/chat/chatComponentTypes'
|
||||
import { ThemeToggle } from '@/components/elements/theme-toggle'
|
||||
import { productRepositoryUrl } from '@/lib/constants/productUrls'
|
||||
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
|
||||
import type { ProviderType } from '@/lib/llm-providers/types'
|
||||
|
||||
interface WorkflowsChatHeaderProps {
|
||||
selectedProvider: Provider
|
||||
providers: Provider[]
|
||||
onSelectProvider: (provider: Provider) => void
|
||||
onNewWorkflow: () => void
|
||||
hasMessages: boolean
|
||||
}
|
||||
|
||||
export const WorkflowsChatHeader: FC<WorkflowsChatHeaderProps> = ({
|
||||
selectedProvider,
|
||||
providers,
|
||||
onSelectProvider,
|
||||
onNewWorkflow,
|
||||
hasMessages,
|
||||
}) => {
|
||||
return (
|
||||
<header className="flex h-14 shrink-0 items-center justify-between border-border/40 border-b bg-background/80 px-3 backdrop-blur-md">
|
||||
<div className="flex items-center gap-2">
|
||||
<ChatProviderSelector
|
||||
providers={providers}
|
||||
selectedProvider={selectedProvider}
|
||||
onSelectProvider={onSelectProvider}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="group relative inline-flex cursor-pointer items-center gap-2 rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground data-[state=open]:bg-accent"
|
||||
title="Change AI Provider"
|
||||
>
|
||||
{selectedProvider.type === 'browseros' ? (
|
||||
<BrowserOSIcon size={18} />
|
||||
) : (
|
||||
<ProviderIcon
|
||||
type={selectedProvider.type as ProviderType}
|
||||
size={18}
|
||||
/>
|
||||
)}
|
||||
<span className="font-semibold text-base">
|
||||
{selectedProvider.name}
|
||||
</span>
|
||||
</button>
|
||||
</ChatProviderSelector>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{hasMessages && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewWorkflow}
|
||||
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
title="New workflow"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={productRepositoryUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
title="Star on Github"
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/app.html#/settings"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
title="Settings"
|
||||
>
|
||||
<SettingsIcon className="h-4 w-4" />
|
||||
</a>
|
||||
|
||||
<ThemeToggle
|
||||
className="rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
iconClassName="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import type { UIMessage } from 'ai'
|
||||
|
||||
type MessagePart = UIMessage['parts'][number]
|
||||
|
||||
const TIDBIT_SUFFIXES = ['...', '\u2026'] as const
|
||||
|
||||
const isTextPart = (
|
||||
part: MessagePart,
|
||||
): part is MessagePart & { type: 'text' } => part.type === 'text'
|
||||
|
||||
const isTidbitLine = (line: string): boolean => {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed.length === 0) return false
|
||||
return TIDBIT_SUFFIXES.some((suffix) => trimmed.endsWith(suffix))
|
||||
}
|
||||
|
||||
const getNonEmptyLines = (text: string): string[] =>
|
||||
text.split('\n').filter((line) => line.trim().length > 0)
|
||||
|
||||
const isAllTidbitText = (text: string): boolean => {
|
||||
const lines = getNonEmptyLines(text)
|
||||
return lines.length > 0 && lines.every((line) => isTidbitLine(line))
|
||||
}
|
||||
|
||||
export const isWorkflowTidbitMessage = (message: UIMessage): boolean => {
|
||||
if (message.role !== 'assistant') return false
|
||||
if (message.parts.length === 0) return false
|
||||
if (message.parts.some((part) => !isTextPart(part))) return false
|
||||
|
||||
const fullText = message.parts
|
||||
.filter((part) => isTextPart(part))
|
||||
.map((part) => part.text)
|
||||
.join('')
|
||||
|
||||
return isAllTidbitText(fullText)
|
||||
}
|
||||
|
||||
// within a text part that has multiple tidbit lines, keep only the last line
|
||||
const compactTidbitLinesInPart = (part: MessagePart): MessagePart => {
|
||||
if (!isTextPart(part)) return part
|
||||
|
||||
const lines = getNonEmptyLines(part.text)
|
||||
if (lines.length <= 1) return part
|
||||
if (!lines.every((line) => isTidbitLine(line))) return part
|
||||
|
||||
return { ...part, text: lines[lines.length - 1] }
|
||||
}
|
||||
|
||||
// collapse consecutive tidbit text parts within a single message
|
||||
const compactTidbitPartsInMessage = (message: UIMessage): UIMessage => {
|
||||
if (message.role !== 'assistant') return message
|
||||
|
||||
// first compact multi-line tidbit text within each part
|
||||
const lineCompactedParts = message.parts.map(compactTidbitLinesInPart)
|
||||
|
||||
// then collapse consecutive tidbit parts to just the last one
|
||||
const compactedParts: UIMessage['parts'] = []
|
||||
let pendingTidbitPart: (MessagePart & { type: 'text' }) | null = null
|
||||
|
||||
const flushPendingTidbitPart = () => {
|
||||
if (!pendingTidbitPart) return
|
||||
compactedParts.push(pendingTidbitPart)
|
||||
pendingTidbitPart = null
|
||||
}
|
||||
|
||||
for (const part of lineCompactedParts) {
|
||||
if (isTextPart(part) && isAllTidbitText(part.text)) {
|
||||
pendingTidbitPart = part
|
||||
continue
|
||||
}
|
||||
|
||||
flushPendingTidbitPart()
|
||||
compactedParts.push(part)
|
||||
}
|
||||
|
||||
flushPendingTidbitPart()
|
||||
|
||||
const partsChanged =
|
||||
compactedParts.length !== message.parts.length ||
|
||||
compactedParts.some((p, i) => p !== message.parts[i])
|
||||
|
||||
if (!partsChanged) return message
|
||||
|
||||
return { ...message, parts: compactedParts }
|
||||
}
|
||||
|
||||
export const getWorkflowDisplayMessages = (
|
||||
messages: UIMessage[],
|
||||
): UIMessage[] => {
|
||||
// first compact tidbit parts within each message
|
||||
const normalizedMessages = messages.map(compactTidbitPartsInMessage)
|
||||
const compactedMessages: UIMessage[] = []
|
||||
|
||||
// then collapse consecutive tidbit-only messages
|
||||
for (const message of normalizedMessages) {
|
||||
const previousMessage = compactedMessages[compactedMessages.length - 1]
|
||||
const shouldReplacePreviousTidbit =
|
||||
previousMessage &&
|
||||
isWorkflowTidbitMessage(previousMessage) &&
|
||||
isWorkflowTidbitMessage(message)
|
||||
|
||||
if (shouldReplacePreviousTidbit) {
|
||||
compactedMessages[compactedMessages.length - 1] = message
|
||||
continue
|
||||
}
|
||||
|
||||
compactedMessages.push(message)
|
||||
}
|
||||
|
||||
return compactedMessages
|
||||
}
|
||||
@@ -2,8 +2,6 @@ import { Globe2, Trash2 } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useKimiLaunch } from '@/lib/feature-flags/useKimiLaunch'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getFaviconUrl, type LlmHubProvider } from './models'
|
||||
|
||||
interface HubProviderRowProps {
|
||||
@@ -20,20 +18,9 @@ export const HubProviderRow: FC<HubProviderRowProps> = ({
|
||||
onDelete,
|
||||
}) => {
|
||||
const iconUrl = useMemo(() => getFaviconUrl(provider.url), [provider.url])
|
||||
const kimiLaunch = useKimiLaunch()
|
||||
const normalizedName = provider.name.trim().toLowerCase()
|
||||
const normalizedUrl = provider.url.trim().toLowerCase()
|
||||
const isKimi = normalizedName === 'kimi' || normalizedUrl.includes('kimi.com')
|
||||
const showKimiFlare = isKimi && kimiLaunch
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex w-full items-center gap-4 rounded-xl border border-border bg-card p-4 transition-all hover:border-[var(--accent-orange)] hover:shadow-md',
|
||||
showKimiFlare &&
|
||||
'border-orange-300/80 bg-orange-50/20 shadow-sm ring-1 ring-orange-300/45 dark:bg-orange-500/5',
|
||||
)}
|
||||
>
|
||||
<div className="group flex w-full items-center gap-4 rounded-xl border border-border bg-card p-4 transition-all hover:border-[var(--accent-orange)] hover:shadow-md">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted">
|
||||
{iconUrl ? (
|
||||
<img
|
||||
@@ -49,16 +36,6 @@ export const HubProviderRow: FC<HubProviderRowProps> = ({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-0.5 flex items-center gap-2">
|
||||
<span className="block truncate font-semibold">{provider.name}</span>
|
||||
{showKimiFlare && (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<span className="rounded-full border border-orange-300/60 bg-orange-100/70 px-2 py-0.5 font-semibold text-[11px] text-orange-700 dark:border-orange-400/40 dark:bg-orange-500/15 dark:text-orange-300">
|
||||
Recommended
|
||||
</span>
|
||||
<span className="rounded-full border border-orange-300/60 bg-orange-100/60 px-2.5 py-0.5 font-medium text-orange-700 text-xs dark:border-orange-400/40 dark:bg-orange-500/15 dark:text-orange-300">
|
||||
Powered by Moonshot AI
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate text-muted-foreground/70 text-xs">
|
||||
{provider.url}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import localforage from 'localforage'
|
||||
import { clear } from 'idb-keyval'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
@@ -25,7 +25,7 @@ export const LogoutPage: FC = () => {
|
||||
await providersStorage.removeValue()
|
||||
await scheduledJobStorage.removeValue()
|
||||
queryClient.clear()
|
||||
await localforage.clear()
|
||||
await clear()
|
||||
|
||||
resetIdentity()
|
||||
await signOut()
|
||||
|
||||
@@ -28,7 +28,7 @@ export const ScheduledTasksList: FC<ScheduledTasksListProps> = ({
|
||||
<div className="rounded-xl border border-border bg-card p-6 shadow-sm">
|
||||
<div className="rounded-lg border border-border border-dashed py-8 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No scheduled tasks yet. Create one to automate recurring workflows.
|
||||
No scheduled tasks yet. Create one to automate recurring tasks.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -238,7 +238,7 @@ const EmptyState: FC<{ onCreateClick: () => void }> = ({ onCreateClick }) => (
|
||||
<h3 className="mb-1 font-medium text-lg">No skills yet</h3>
|
||||
<p className="mb-5 max-w-sm text-muted-foreground text-sm leading-6">
|
||||
Skills teach your agent how to handle repeatable tasks like research,
|
||||
extraction, and structured workflows.
|
||||
extraction, and repeatable browser tasks.
|
||||
</p>
|
||||
<Button onClick={onCreateClick} size="sm">
|
||||
<Plus className="mr-1.5 size-4" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AlertCircle, Clock, Coins, CreditCard, Zap } from 'lucide-react'
|
||||
import { AlertCircle, Clock, Coins, Gift, Zap } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { ShareForCredits } from '@/components/referral/ShareForCredits'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
getCreditBarColor,
|
||||
@@ -43,8 +44,10 @@ export const UsagePage: FC = () => {
|
||||
}
|
||||
|
||||
const credits = data?.credits ?? 0
|
||||
const total = data?.dailyLimit ?? 100
|
||||
const total = data?.dailyLimit ?? 50
|
||||
const percentage = Math.min((credits / total) * 100, 100)
|
||||
const bonusCredits = Math.max(0, credits - total)
|
||||
const creditsUsed = Math.max(0, total - credits)
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
@@ -95,30 +98,32 @@ export const UsagePage: FC = () => {
|
||||
<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>
|
||||
{bonusCredits > 0 ? (
|
||||
<>
|
||||
<p className="font-medium text-xs">Bonus credits</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
+{bonusCredits} from referrals
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-medium text-xs">Credits used today</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{creditsUsed} 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 className="mb-4 flex items-center gap-2">
|
||||
<Gift className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="font-semibold text-sm">Earn More Credits</span>
|
||||
</div>
|
||||
<ShareForCredits />
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-[var(--accent-orange)]/30 bg-[var(--accent-orange)]/5 p-5">
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import type { UIMessage } from 'ai'
|
||||
import { Loader2, RotateCcw, Square, X } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
interface RunWorkflowDialogProps {
|
||||
open: boolean
|
||||
workflowName: string
|
||||
messages: UIMessage[]
|
||||
status: 'streaming' | 'submitted' | 'ready' | 'error'
|
||||
wasCancelled: boolean
|
||||
error: Error | undefined
|
||||
onStop: () => void
|
||||
onRetry: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const RunWorkflowDialog: FC<RunWorkflowDialogProps> = ({
|
||||
open,
|
||||
workflowName,
|
||||
messages,
|
||||
status,
|
||||
wasCancelled,
|
||||
error,
|
||||
onStop,
|
||||
onRetry,
|
||||
onClose,
|
||||
}) => {
|
||||
const isProcessing = status === 'streaming' || status === 'submitted'
|
||||
const _isComplete = !isProcessing
|
||||
|
||||
const getStatusText = () => {
|
||||
if (status === 'submitted') return 'Starting workflow...'
|
||||
if (status === 'streaming') return 'Running...'
|
||||
if (wasCancelled) return 'Execution cancelled'
|
||||
if (status === 'error') return 'Error occurred'
|
||||
return 'Completed'
|
||||
}
|
||||
|
||||
const getMessageContent = (message: UIMessage) => {
|
||||
return message.parts
|
||||
.filter((part) => part.type === 'text')
|
||||
.map((part) => part.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
const assistantMessages = messages.filter((m) => m.role === 'assistant')
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={() => {}}>
|
||||
<DialogContent
|
||||
className="max-h-[80vh] max-w-2xl overflow-hidden [&>button]:hidden"
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader className="flex-row items-center justify-between space-y-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{isProcessing && (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-[var(--accent-orange)]" />
|
||||
)}
|
||||
Running: {workflowName}
|
||||
</DialogTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{isProcessing ? (
|
||||
<Button variant="destructive" size="sm" onClick={onStop}>
|
||||
<Square className="mr-1.5 h-3 w-3" />
|
||||
Stop
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="secondary" size="sm" onClick={onRetry}>
|
||||
<RotateCcw className="mr-1.5 h-3 w-3" />
|
||||
Retry
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
<X className="mr-1.5 h-3 w-3" />
|
||||
Close
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-muted-foreground text-sm">{getStatusText()}</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
|
||||
<div className="font-medium">Error Details</div>
|
||||
<div className="mt-1 whitespace-pre-wrap font-mono text-xs">
|
||||
{error.message}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-[50vh] overflow-y-auto rounded-lg border border-border bg-muted/30 p-4">
|
||||
{assistantMessages.length === 0 ? (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{isProcessing
|
||||
? 'Waiting for response...'
|
||||
: 'No output available.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{assistantMessages.map((message) => (
|
||||
<div key={message.id} className="whitespace-pre-wrap text-sm">
|
||||
{getMessageContent(message)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { Pencil, Play, Trash2 } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { NavLink } from 'react-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { Workflow } from '@/lib/workflows/workflowStorage'
|
||||
|
||||
interface WorkflowCardProps {
|
||||
workflow: Workflow
|
||||
onDelete: () => void
|
||||
onRun: () => void
|
||||
}
|
||||
|
||||
export const WorkflowCard: FC<WorkflowCardProps> = ({
|
||||
workflow,
|
||||
onDelete,
|
||||
onRun,
|
||||
}) => {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-4 shadow-sm transition-all hover:border-[var(--accent-orange)]/50 hover:shadow-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="truncate font-semibold">
|
||||
{workflow.workflowName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onRun}>
|
||||
<Play className="mr-1.5 h-3 w-3" />
|
||||
Run
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<NavLink to={`/workflows/create-graph?workflowId=${workflow.id}`}>
|
||||
<Pencil className="mr-1.5 h-3 w-3" />
|
||||
Edit
|
||||
</NavLink>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onDelete}
|
||||
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
aria-label={`Delete ${workflow.workflowName}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { HelpCircle, Plus, Workflow } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { NavLink } from 'react-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { workflowsHelpUrl } from '@/lib/constants/productUrls'
|
||||
|
||||
export const WorkflowsHeader: FC = () => {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-6 shadow-sm transition-all hover:shadow-md">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-[var(--accent-orange)]/10">
|
||||
<Workflow className="h-6 w-6 text-[var(--accent-orange)]" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<h2 className="font-semibold text-xl">Workflows</h2>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={workflowsHelpUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-full p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Learn more about workflows</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Create and manage browser automation workflows
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
className="border-[var(--accent-orange)] bg-[var(--accent-orange)]/10 text-[var(--accent-orange)] hover:bg-[var(--accent-orange)]/20 hover:text-[var(--accent-orange)]"
|
||||
variant="outline"
|
||||
>
|
||||
<NavLink to="/workflows/create-graph">
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
New Workflow
|
||||
</NavLink>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { FC } from 'react'
|
||||
import type { Workflow } from '@/lib/workflows/workflowStorage'
|
||||
import { WorkflowCard } from './WorkflowCard'
|
||||
|
||||
interface WorkflowsListProps {
|
||||
workflows: Workflow[]
|
||||
onDelete: (workflowId: string) => void
|
||||
onRun: (workflowId: string) => void
|
||||
}
|
||||
|
||||
export const WorkflowsList: FC<WorkflowsListProps> = ({
|
||||
workflows,
|
||||
onDelete,
|
||||
onRun,
|
||||
}) => {
|
||||
if (workflows.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-6 shadow-sm">
|
||||
<div className="rounded-lg border border-border border-dashed py-8 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No workflows yet. Create one to automate browser tasks.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{workflows.map((workflow) => (
|
||||
<WorkflowCard
|
||||
key={workflow.id}
|
||||
workflow={workflow}
|
||||
onDelete={() => onDelete(workflow.id)}
|
||||
onRun={() => onRun(workflow.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import { type FC, useState } from 'react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
WORKFLOW_DELETED_EVENT,
|
||||
WORKFLOW_RUN_STARTED_EVENT,
|
||||
} from '@/lib/constants/analyticsEvents'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import { useRpcClient } from '@/lib/rpc/RpcClientProvider'
|
||||
import { sentry } from '@/lib/sentry/sentry'
|
||||
import { useWorkflows } from '@/lib/workflows/workflowStorage'
|
||||
import { RunWorkflowDialog } from './RunWorkflowDialog'
|
||||
import { useRunWorkflow } from './useRunWorkflow'
|
||||
import { WorkflowsHeader } from './WorkflowsHeader'
|
||||
import { WorkflowsList } from './WorkflowsList'
|
||||
|
||||
export const WorkflowsPage: FC = () => {
|
||||
const { workflows, removeWorkflow } = useWorkflows()
|
||||
const rpcClient = useRpcClient()
|
||||
|
||||
const [deleteWorkflowId, setDeleteWorkflowId] = useState<string | null>(null)
|
||||
|
||||
const {
|
||||
isRunning,
|
||||
runningWorkflowName,
|
||||
messages,
|
||||
status,
|
||||
wasCancelled,
|
||||
error,
|
||||
runWorkflow,
|
||||
stopRun,
|
||||
retry,
|
||||
closeDialog,
|
||||
} = useRunWorkflow()
|
||||
|
||||
const handleDelete = (workflowId: string) => {
|
||||
setDeleteWorkflowId(workflowId)
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteWorkflowId) return
|
||||
|
||||
const workflow = workflows.find((w) => w.id === deleteWorkflowId)
|
||||
if (!workflow) return
|
||||
|
||||
try {
|
||||
await rpcClient.graph[':id'].$delete({ param: { id: workflow.codeId } })
|
||||
} catch (error) {
|
||||
sentry.captureException(error, {
|
||||
extra: {
|
||||
message: 'Failed to delete graph from server',
|
||||
codeId: workflow.codeId,
|
||||
workflowId: deleteWorkflowId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await removeWorkflow(deleteWorkflowId)
|
||||
setDeleteWorkflowId(null)
|
||||
track(WORKFLOW_DELETED_EVENT)
|
||||
}
|
||||
|
||||
const handleRun = (workflowId: string) => {
|
||||
const workflow = workflows.find((w) => w.id === workflowId)
|
||||
if (workflow) {
|
||||
track(WORKFLOW_RUN_STARTED_EVENT)
|
||||
runWorkflow(workflow.codeId, workflow.workflowName)
|
||||
}
|
||||
}
|
||||
|
||||
const workflowToDelete = deleteWorkflowId
|
||||
? workflows.find((w) => w.id === deleteWorkflowId)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="fade-in slide-in-from-bottom-5 animate-in space-y-6 duration-500">
|
||||
<WorkflowsHeader />
|
||||
|
||||
<WorkflowsList
|
||||
workflows={workflows}
|
||||
onDelete={handleDelete}
|
||||
onRun={handleRun}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={deleteWorkflowId !== null}
|
||||
onOpenChange={(open) => !open && setDeleteWorkflowId(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Workflow</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Delete "{workflowToDelete?.workflowName}"? This action cannot be
|
||||
undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmDelete}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<RunWorkflowDialog
|
||||
open={isRunning}
|
||||
workflowName={runningWorkflowName}
|
||||
messages={messages}
|
||||
status={status}
|
||||
wasCancelled={wasCancelled}
|
||||
error={error}
|
||||
onStop={stopRun}
|
||||
onRetry={retry}
|
||||
onClose={closeDialog}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { type FC, Suspense } from 'react'
|
||||
import { WorkflowsPage } from './WorkflowsPage'
|
||||
|
||||
export const WorkflowsPageWrapper: FC = () => {
|
||||
return (
|
||||
<Suspense fallback={<div className="h-screen w-screen bg-background" />}>
|
||||
<WorkflowsPage />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
import { useChat } from '@ai-sdk/react'
|
||||
import { DefaultChatTransport } from 'ai'
|
||||
import { compact } from 'es-toolkit/array'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useChatRefs } from '@/entrypoints/sidepanel/index/useChatRefs'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
import {
|
||||
WORKFLOW_RUN_COMPLETED_EVENT,
|
||||
WORKFLOW_RUN_RETRIED_EVENT,
|
||||
WORKFLOW_RUN_STOPPED_EVENT,
|
||||
} from '@/lib/constants/analyticsEvents'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
|
||||
type WorkflowMessageMetadata = {
|
||||
window?: chrome.windows.Window
|
||||
}
|
||||
|
||||
export const useRunWorkflow = () => {
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [runningWorkflowName, setRunningWorkflowName] = useState<string>('')
|
||||
const [wasCancelled, setWasCancelled] = useState(false)
|
||||
const codeIdRef = useRef<string | undefined>(undefined)
|
||||
|
||||
const { baseUrl: agentServerUrl } = useAgentServerUrl()
|
||||
|
||||
const {
|
||||
selectedLlmProviderRef,
|
||||
enabledMcpServersRef,
|
||||
enabledCustomServersRef,
|
||||
personalizationRef,
|
||||
} = useChatRefs()
|
||||
|
||||
const agentUrlRef = useRef(agentServerUrl)
|
||||
|
||||
useEffect(() => {
|
||||
agentUrlRef.current = agentServerUrl
|
||||
}, [agentServerUrl])
|
||||
|
||||
const { sendMessage, stop, status, messages, setMessages, error } = useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
prepareSendMessagesRequest: async ({ messages }) => {
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
const metadata = lastMessage.metadata as
|
||||
| WorkflowMessageMetadata
|
||||
| undefined
|
||||
const provider = selectedLlmProviderRef.current
|
||||
const enabledMcpServers = enabledMcpServersRef.current
|
||||
const customMcpServers = enabledCustomServersRef.current
|
||||
|
||||
return {
|
||||
api: `${agentUrlRef.current}/graph/${codeIdRef.current}/run`,
|
||||
body: {
|
||||
provider: provider?.type,
|
||||
providerType: provider?.type,
|
||||
providerName: provider?.name,
|
||||
model: provider?.modelId ?? 'browseros',
|
||||
contextWindowSize: provider?.contextWindow,
|
||||
temperature: provider?.temperature,
|
||||
resourceName: provider?.resourceName,
|
||||
accessKeyId: provider?.accessKeyId,
|
||||
secretAccessKey: provider?.secretAccessKey,
|
||||
region: provider?.region,
|
||||
sessionToken: provider?.sessionToken,
|
||||
apiKey: provider?.apiKey,
|
||||
baseUrl: provider?.baseUrl,
|
||||
browserContext: {
|
||||
windowId: metadata?.window?.id,
|
||||
activeTab: metadata?.window?.tabs?.[0],
|
||||
enabledMcpServers: compact(enabledMcpServers),
|
||||
customMcpServers,
|
||||
},
|
||||
userSystemPrompt: personalizationRef.current,
|
||||
supportsImages: provider?.supportsImages,
|
||||
},
|
||||
}
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
const previousStatus = useRef(status)
|
||||
useEffect(() => {
|
||||
const wasProcessing =
|
||||
previousStatus.current === 'streaming' ||
|
||||
previousStatus.current === 'submitted'
|
||||
const justFinished =
|
||||
wasProcessing && (status === 'ready' || status === 'error')
|
||||
|
||||
if (justFinished && isRunning) {
|
||||
track(WORKFLOW_RUN_COMPLETED_EVENT, {
|
||||
status: wasCancelled
|
||||
? 'cancelled'
|
||||
: status === 'error'
|
||||
? 'failed'
|
||||
: 'completed',
|
||||
})
|
||||
}
|
||||
previousStatus.current = status
|
||||
}, [status, isRunning, wasCancelled])
|
||||
|
||||
const startWorkflowRun = async () => {
|
||||
setMessages([])
|
||||
setWasCancelled(false)
|
||||
|
||||
let backgroundWindow: chrome.windows.Window | undefined
|
||||
try {
|
||||
backgroundWindow = await chrome.windows.create({
|
||||
url: 'chrome://newtab',
|
||||
focused: true,
|
||||
type: 'normal',
|
||||
})
|
||||
} catch {
|
||||
// Fallback when no window context is available (e.g. all windows closed)
|
||||
const tab = await chrome.tabs.create({
|
||||
url: 'chrome://newtab',
|
||||
active: true,
|
||||
})
|
||||
if (tab.windowId) {
|
||||
backgroundWindow = await chrome.windows.get(tab.windowId)
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage({
|
||||
text: 'Run the workflow.',
|
||||
metadata: {
|
||||
window: backgroundWindow,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const runWorkflow = async (codeId: string, workflowName: string) => {
|
||||
codeIdRef.current = codeId
|
||||
setRunningWorkflowName(workflowName)
|
||||
setIsRunning(true)
|
||||
await startWorkflowRun()
|
||||
}
|
||||
|
||||
const stopRun = () => {
|
||||
track(WORKFLOW_RUN_STOPPED_EVENT)
|
||||
setWasCancelled(true)
|
||||
stop()
|
||||
}
|
||||
|
||||
const retry = async () => {
|
||||
track(WORKFLOW_RUN_RETRIED_EVENT)
|
||||
await startWorkflowRun()
|
||||
}
|
||||
|
||||
const closeDialog = () => {
|
||||
setIsRunning(false)
|
||||
setRunningWorkflowName('')
|
||||
setWasCancelled(false)
|
||||
setMessages([])
|
||||
}
|
||||
|
||||
return {
|
||||
isRunning,
|
||||
runningWorkflowName,
|
||||
messages,
|
||||
status,
|
||||
wasCancelled,
|
||||
error,
|
||||
runWorkflow,
|
||||
stopRun,
|
||||
retry,
|
||||
closeDialog,
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export const TIPS: Tip[] = [
|
||||
},
|
||||
{
|
||||
id: 'mcp-servers',
|
||||
text: 'Add MCP servers for Google Calendar, Gmail, Notion, and more to build multi-service workflows.',
|
||||
text: 'Add MCP servers for Google Calendar, Gmail, Notion, and more to power multi-service automations.',
|
||||
},
|
||||
{
|
||||
id: 'skills',
|
||||
@@ -75,10 +75,6 @@ export const TIPS: Tip[] = [
|
||||
id: 'at-mention-tabs',
|
||||
text: 'Type @ in the search bar to mention and attach open tabs as context for your AI queries.',
|
||||
},
|
||||
{
|
||||
id: 'workflows',
|
||||
text: 'For complex repeatable tasks, build visual Workflows instead of one-off prompts for consistent results.',
|
||||
},
|
||||
{
|
||||
id: 'mode-selection',
|
||||
text: 'Use Chat mode for read-only operations like questions and summaries, and Agent mode for multi-step browser tasks.',
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Bot,
|
||||
Code2,
|
||||
FolderOpen,
|
||||
GitBranch,
|
||||
LinkIcon,
|
||||
Plug,
|
||||
SplitSquareHorizontal,
|
||||
@@ -23,7 +22,6 @@ import {
|
||||
COWORK_DEMO_URL,
|
||||
MCP_SERVER_DEMO_URL,
|
||||
SPLIT_VIEW_GIF_URL,
|
||||
WORKFLOWS_DEMO_URL,
|
||||
} from '@/lib/constants/mediaUrls'
|
||||
import {
|
||||
discordUrl,
|
||||
@@ -44,7 +42,7 @@ const features: Feature[] = [
|
||||
description:
|
||||
'Describe any task and watch BrowserOS execute it—clicking, typing, and navigating for you.',
|
||||
detailedDescription:
|
||||
'The BrowserOS Agent turns your words into browser actions. Describe what you need in plain English—fill out this form, extract data from that page, navigate through these steps—and the agent handles the rest. It clicks buttons, types text, navigates between pages, and completes multi-step workflows automatically. Everything runs locally on your machine with your own API keys, so your data stays private.',
|
||||
'The BrowserOS Agent turns your words into browser actions. Describe what you need in plain English—fill out this form, extract data from that page, navigate through these steps—and the agent handles the rest. It clicks buttons, types text, navigates between pages, and completes multi-step browser tasks automatically. Everything runs locally on your machine with your own API keys, so your data stays private.',
|
||||
highlights: [
|
||||
'Multi-tab execution — run agents in multiple tabs simultaneously',
|
||||
'Smart navigation — automatically finds and interacts with page elements',
|
||||
@@ -75,24 +73,6 @@ const features: Feature[] = [
|
||||
gridClass: 'md:col-span-1',
|
||||
videoUrl: MCP_SERVER_DEMO_URL,
|
||||
},
|
||||
{
|
||||
id: 'workflows',
|
||||
Icon: GitBranch,
|
||||
tag: 'AUTOMATION',
|
||||
title: 'Visual Workflows',
|
||||
description:
|
||||
'Build reliable, repeatable automations with a visual graph builder.',
|
||||
detailedDescription:
|
||||
'Workflows turn complex browser tasks into reliable, reusable automations. Instead of hoping the agent figures out the right steps each time, you define the exact sequence in a visual graph. Describe what you want in chat, and the workflow agent generates the graph. Add loops, conditionals, and parallel branches. Save workflows and run them on-demand whenever you need.',
|
||||
highlights: [
|
||||
'Chat-to-graph — describe your automation and get a visual workflow',
|
||||
'Parallel execution — run multiple branches simultaneously',
|
||||
'Loops & conditionals — handle complex logic with flow control',
|
||||
'Save & reuse — run saved workflows on-demand, daily, or weekly',
|
||||
],
|
||||
gridClass: 'md:col-span-1',
|
||||
videoUrl: WORKFLOWS_DEMO_URL || undefined,
|
||||
},
|
||||
{
|
||||
id: 'cowork',
|
||||
Icon: FolderOpen,
|
||||
|
||||
@@ -32,6 +32,7 @@ const RemoteChatHistory: FC<{ userId: string }> = ({ userId }) => {
|
||||
const {
|
||||
data: graphqlData,
|
||||
isLoading: isLoadingConversations,
|
||||
isFetching,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
@@ -112,6 +113,7 @@ const RemoteChatHistory: FC<{ userId: string }> = ({ userId }) => {
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onLoadMore={fetchNextPage}
|
||||
isRefreshing={isFetching && !isLoadingConversations}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ interface ConversationListProps {
|
||||
hasNextPage?: boolean
|
||||
isFetchingNextPage?: boolean
|
||||
onLoadMore?: () => void
|
||||
isRefreshing?: boolean
|
||||
}
|
||||
|
||||
export const ConversationList: FC<ConversationListProps> = ({
|
||||
@@ -21,6 +22,7 @@ export const ConversationList: FC<ConversationListProps> = ({
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
onLoadMore,
|
||||
isRefreshing,
|
||||
}) => {
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -57,6 +59,12 @@ export const ConversationList: FC<ConversationListProps> = ({
|
||||
return (
|
||||
<main className="mt-4 flex h-full flex-1 flex-col space-y-4 overflow-y-auto">
|
||||
<div className="w-full p-3">
|
||||
{isRefreshing && (
|
||||
<div className="flex items-center justify-center gap-2 pb-3 text-muted-foreground text-xs">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>Fetching latest conversations</span>
|
||||
</div>
|
||||
)}
|
||||
{!hasConversations ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<MessageSquare className="mb-3 h-10 w-10 text-muted-foreground/50" />
|
||||
|
||||
@@ -11,7 +11,7 @@ export const GetConversationsForHistoryDocument = graphql(`
|
||||
nodes {
|
||||
rowId
|
||||
lastMessagedAt
|
||||
conversationMessages(last: 5, orderBy: ORDER_INDEX_ASC) {
|
||||
conversationMessages(first: 2, orderBy: ORDER_INDEX_DESC) {
|
||||
nodes {
|
||||
message
|
||||
}
|
||||
|
||||
@@ -1,20 +1,59 @@
|
||||
import { AlertCircle, RefreshCw } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
// import { useMemo } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { ShareForCredits } from '@/components/referral/ShareForCredits'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { ProviderType } from '@/lib/llm-providers/types'
|
||||
|
||||
// --- Commented out for Kimi partnership launch (restore after) ---
|
||||
// const SURVEY_DIRECTIONS = [
|
||||
// 'competitor',
|
||||
// 'switching',
|
||||
// 'workflow',
|
||||
// 'activation',
|
||||
// ] as const
|
||||
//
|
||||
// function pickRandomDirection(): string {
|
||||
// return SURVEY_DIRECTIONS[Math.floor(Math.random() * SURVEY_DIRECTIONS.length)]
|
||||
// }
|
||||
// --- End commented out survey code ---
|
||||
const SURVEY_DIRECTIONS = [
|
||||
'competitor',
|
||||
'switching',
|
||||
'workflow',
|
||||
'activation',
|
||||
] as const
|
||||
|
||||
function pickRandomDirection(): string {
|
||||
return SURVEY_DIRECTIONS[Math.floor(Math.random() * SURVEY_DIRECTIONS.length)]
|
||||
}
|
||||
|
||||
const PROVIDER_DISPLAY_NAMES: Record<ProviderType, string> = {
|
||||
anthropic: 'Anthropic',
|
||||
openai: 'OpenAI',
|
||||
'openai-compatible': 'OpenAI-compatible',
|
||||
google: 'Google',
|
||||
openrouter: 'OpenRouter',
|
||||
azure: 'Azure OpenAI',
|
||||
ollama: 'Ollama',
|
||||
lmstudio: 'LM Studio',
|
||||
bedrock: 'AWS Bedrock',
|
||||
browseros: 'BrowserOS',
|
||||
moonshot: 'Moonshot',
|
||||
'chatgpt-pro': 'ChatGPT Pro',
|
||||
'github-copilot': 'GitHub Copilot',
|
||||
'qwen-code': 'Qwen Code',
|
||||
minimax: 'MiniMax',
|
||||
}
|
||||
|
||||
const UPSTREAM_RATE_LIMIT_PATTERNS: Array<string | RegExp> = [
|
||||
'usage limit',
|
||||
'rate limit',
|
||||
'rate-limit',
|
||||
'quota',
|
||||
/\b429\b/,
|
||||
'too many requests',
|
||||
'insufficient_quota',
|
||||
]
|
||||
|
||||
function getProviderDisplayName(providerType?: string): string {
|
||||
if (providerType && providerType in PROVIDER_DISPLAY_NAMES) {
|
||||
return PROVIDER_DISPLAY_NAMES[providerType as ProviderType]
|
||||
}
|
||||
return 'your provider'
|
||||
}
|
||||
|
||||
function stripRetryPrefix(message: string): string {
|
||||
return message.replace(/^Failed after \d+ attempts?\.\s*Last error:\s*/i, '')
|
||||
}
|
||||
|
||||
interface ChatErrorProps {
|
||||
error: Error
|
||||
@@ -31,6 +70,8 @@ function parseErrorMessage(
|
||||
isRateLimit?: boolean
|
||||
isCreditsExhausted?: boolean
|
||||
isConnectionError?: boolean
|
||||
isUpstreamRateLimit?: boolean
|
||||
providerName?: string
|
||||
} {
|
||||
const isBrowserosProvider = providerType === 'browseros'
|
||||
|
||||
@@ -71,6 +112,28 @@ function parseErrorMessage(
|
||||
}
|
||||
}
|
||||
|
||||
// Detect rate limits from non-BrowserOS upstream providers. Users were
|
||||
// confused that a quota/429 from OpenAI/Anthropic/etc. looked like a
|
||||
// BrowserOS-imposed limit.
|
||||
if (!isBrowserosProvider && providerType) {
|
||||
const lower = message.toLowerCase()
|
||||
const matchesRateLimit = UPSTREAM_RATE_LIMIT_PATTERNS.some((p) =>
|
||||
typeof p === 'string' ? lower.includes(p) : p.test(lower),
|
||||
)
|
||||
if (matchesRateLimit) {
|
||||
let stripped = stripRetryPrefix(message).trim()
|
||||
try {
|
||||
const parsed = JSON.parse(stripped)
|
||||
if (parsed?.error?.message) stripped = parsed.error.message
|
||||
} catch {}
|
||||
return {
|
||||
text: stripped || message,
|
||||
isUpstreamRateLimit: true,
|
||||
providerName: getProviderDisplayName(providerType),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let text = message
|
||||
try {
|
||||
const parsed = JSON.parse(message)
|
||||
@@ -92,18 +155,28 @@ export const ChatError: FC<ChatErrorProps> = ({
|
||||
onRetry,
|
||||
providerType,
|
||||
}) => {
|
||||
const { text, url, isRateLimit, isCreditsExhausted, isConnectionError } =
|
||||
parseErrorMessage(error.message, providerType)
|
||||
const {
|
||||
text,
|
||||
url,
|
||||
isRateLimit,
|
||||
isCreditsExhausted,
|
||||
isConnectionError,
|
||||
isUpstreamRateLimit,
|
||||
providerName,
|
||||
} = parseErrorMessage(error.message, providerType)
|
||||
|
||||
// --- Commented out for Kimi partnership launch (restore after) ---
|
||||
// const surveyUrl = useMemo(
|
||||
// () =>
|
||||
// `/app.html?page=survey&maxTurns=20&experimentId=daily_limit_${pickRandomDirection()}#/settings/survey`,
|
||||
// [],
|
||||
// )
|
||||
// --- End commented out survey code ---
|
||||
const surveyUrl = useMemo(
|
||||
() =>
|
||||
`/app.html?page=survey&maxTurns=20&experimentId=daily_limit_${pickRandomDirection()}#/settings/survey`,
|
||||
[],
|
||||
)
|
||||
|
||||
const getTitle = () => {
|
||||
if (isUpstreamRateLimit) {
|
||||
return providerName && providerName !== 'your provider'
|
||||
? `${providerName} rate limit reached`
|
||||
: 'Upstream rate limit reached'
|
||||
}
|
||||
if (isRateLimit) return 'Daily limit reached'
|
||||
if (isConnectionError) return 'Connection failed'
|
||||
return 'Something went wrong'
|
||||
@@ -116,6 +189,14 @@ export const ChatError: FC<ChatErrorProps> = ({
|
||||
<span className="font-medium text-sm">{getTitle()}</span>
|
||||
</div>
|
||||
<p className="text-center text-destructive text-xs">{text}</p>
|
||||
{isUpstreamRateLimit && (
|
||||
<p className="text-center text-muted-foreground text-xs">
|
||||
This is a limit from{' '}
|
||||
<span className="font-medium">{providerName}</span>
|
||||
{' — your configured model provider — not BrowserOS. Check your '}
|
||||
provider's dashboard for quota, usage, or billing details.
|
||||
</p>
|
||||
)}
|
||||
{isConnectionError && url && (
|
||||
<a
|
||||
href={url}
|
||||
@@ -126,8 +207,24 @@ export const ChatError: FC<ChatErrorProps> = ({
|
||||
View troubleshooting guide
|
||||
</a>
|
||||
)}
|
||||
{/* --- Commented out for Kimi partnership launch (restore after) ---
|
||||
{isRateLimit && (
|
||||
{isCreditsExhausted && (
|
||||
<>
|
||||
<div className="w-full border-border/50 border-t pt-3">
|
||||
<ShareForCredits compact />
|
||||
</div>
|
||||
{url && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground text-xs underline hover:text-foreground"
|
||||
>
|
||||
View Usage & Billing
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isRateLimit && !isCreditsExhausted && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<a
|
||||
href={url}
|
||||
@@ -148,27 +245,6 @@ export const ChatError: FC<ChatErrorProps> = ({
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
--- End commented out survey code --- */}
|
||||
{isCreditsExhausted && url && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground text-xs underline hover:text-foreground"
|
||||
>
|
||||
View Usage & Billing
|
||||
</a>
|
||||
)}
|
||||
{isRateLimit && providerType === 'browseros' && (
|
||||
<a
|
||||
href="/app.html#/settings/ai"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--accent-orange)] bg-[var(--accent-orange)]/10 px-3 py-1.5 font-medium text-[var(--accent-orange)] text-xs transition-colors hover:bg-[var(--accent-orange)]/20"
|
||||
>
|
||||
Add your own provider for unlimited usage
|
||||
</a>
|
||||
)}
|
||||
{onRetry && (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -76,8 +76,6 @@ export interface ChatSessionOptions {
|
||||
isIntegrationsSynced?: boolean
|
||||
}
|
||||
|
||||
const NEWTAB_SYSTEM_PROMPT = `IMPORTANT: The user is chatting from the New Tab page. When performing browser actions, ALWAYS open content in a NEW TAB rather than navigating the current tab. The user's new tab page should remain accessible.`
|
||||
|
||||
export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
const {
|
||||
selectedLlmProviderRef,
|
||||
@@ -344,12 +342,8 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
reasoningEffort: provider?.reasoningEffort,
|
||||
reasoningSummary: provider?.reasoningSummary,
|
||||
browserContext,
|
||||
userSystemPrompt:
|
||||
options?.origin === 'newtab'
|
||||
? [personalizationRef.current, NEWTAB_SYSTEM_PROMPT]
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
: personalizationRef.current,
|
||||
origin: options?.origin ?? 'sidepanel',
|
||||
userSystemPrompt: personalizationRef.current,
|
||||
userWorkingDir: workingDirRef.current,
|
||||
supportsImages: provider?.supportsImages,
|
||||
previousConversation,
|
||||
@@ -567,9 +561,11 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
}, [])
|
||||
|
||||
const handleSelectProvider = (provider: Provider) => {
|
||||
const fullProvider = llmProviders.find((p) => p.id === provider.id)
|
||||
track(PROVIDER_SELECTED_EVENT, {
|
||||
provider_id: provider.id,
|
||||
provider_type: provider.type,
|
||||
model_id: fullProvider?.modelId,
|
||||
})
|
||||
setDefaultProvider(provider.id)
|
||||
}
|
||||
|
||||
@@ -31,8 +31,6 @@ export enum Feature {
|
||||
WORKSPACE_FOLDER_SUPPORT = 'WORKSPACE_FOLDER_SUPPORT',
|
||||
// Proxy server support
|
||||
PROXY_SUPPORT = 'PROXY_SUPPORT',
|
||||
// Workflows feature
|
||||
WORKFLOW_SUPPORT = 'WORKFLOW_SUPPORT',
|
||||
// previousConversation as structured array (older servers only accept string)
|
||||
PREVIOUS_CONVERSATION_ARRAY = 'PREVIOUS_CONVERSATION_ARRAY',
|
||||
// Soul page: agent personality viewer and editor
|
||||
@@ -73,7 +71,6 @@ const FEATURE_CONFIG: { [K in Feature]: FeatureConfig } = {
|
||||
[Feature.CUSTOMIZATION_SUPPORT]: { minBrowserOSVersion: '0.36.1.0' },
|
||||
[Feature.WORKSPACE_FOLDER_SUPPORT]: { minBrowserOSVersion: '0.36.4.0' },
|
||||
[Feature.PROXY_SUPPORT]: { minBrowserOSVersion: '0.39.0.1' },
|
||||
[Feature.WORKFLOW_SUPPORT]: { minServerVersion: '0.0.41' },
|
||||
[Feature.PREVIOUS_CONVERSATION_ARRAY]: { minServerVersion: '0.0.64' },
|
||||
[Feature.SOUL_SUPPORT]: { minServerVersion: '0.0.67' },
|
||||
[Feature.NEWTAB_CHAT_SUPPORT]: { minBrowserOSVersion: '0.40.0.0' },
|
||||
|
||||
@@ -1,19 +1,6 @@
|
||||
/** @public */
|
||||
export const MESSAGE_LIKE_EVENT = 'ui.message.like'
|
||||
|
||||
export const GRAPH_MESSAGE_LIKE_EVENT = 'settings.graph.message.like'
|
||||
|
||||
export const GRAPH_MESSAGE_DISLIKE_EVENT = 'settings.graph.message.dislike'
|
||||
|
||||
/** @public */
|
||||
export const NEW_GRAPH_CREATED_EVENT = 'settings.graph.created'
|
||||
|
||||
/** @public */
|
||||
export const GRAPH_SAVED_EVENT = 'settings.graph.saved'
|
||||
|
||||
/** @public */
|
||||
export const GRAPH_UPDATED_EVENT = 'settings.graph.updated'
|
||||
|
||||
/** @public */
|
||||
export const MESSAGE_DISLIKE_EVENT = 'ui.message.dislike'
|
||||
|
||||
@@ -29,6 +16,12 @@ export const CONVERSATION_RESET_EVENT = 'ui.conversation.reset'
|
||||
/** @public */
|
||||
export const AI_PROVIDER_ADDED_EVENT = 'settings.ai_provider.added'
|
||||
|
||||
/** @public */
|
||||
export const AI_PROVIDER_UPDATED_EVENT = 'settings.ai_provider.updated'
|
||||
|
||||
/** @public */
|
||||
export const MODEL_SELECTED_EVENT = 'settings.model.selected'
|
||||
|
||||
/** @public */
|
||||
export const CHATGPT_PRO_OAUTH_STARTED_EVENT =
|
||||
'settings.chatgpt_pro.oauth_started'
|
||||
@@ -172,21 +165,6 @@ export const NEWTAB_VOICE_TRANSCRIPTION_COMPLETED_EVENT =
|
||||
/** @public */
|
||||
export const NEWTAB_VOICE_ERROR_EVENT = 'newtab.voice.error'
|
||||
|
||||
/** @public */
|
||||
export const WORKFLOW_DELETED_EVENT = 'settings.workflow.deleted'
|
||||
|
||||
/** @public */
|
||||
export const WORKFLOW_RUN_STARTED_EVENT = 'settings.workflow.run_started'
|
||||
|
||||
/** @public */
|
||||
export const WORKFLOW_RUN_STOPPED_EVENT = 'settings.workflow.run_stopped'
|
||||
|
||||
/** @public */
|
||||
export const WORKFLOW_RUN_RETRIED_EVENT = 'settings.workflow.run_retried'
|
||||
|
||||
/** @public */
|
||||
export const WORKFLOW_RUN_COMPLETED_EVENT = 'settings.workflow.run_completed'
|
||||
|
||||
/** @public */
|
||||
export const SIDEPANEL_AI_TRIGGERED_EVENT = 'sidepanel.ai.triggered'
|
||||
|
||||
@@ -302,14 +280,6 @@ export const KIMI_API_KEY_CONFIGURED_EVENT = 'settings.kimi.api_key_configured'
|
||||
export const KIMI_API_KEY_GUIDE_CLICKED_EVENT =
|
||||
'settings.kimi.api_key_guide_clicked'
|
||||
|
||||
/** @public */
|
||||
export const KIMI_RATE_LIMIT_DOCS_CLICKED_EVENT =
|
||||
'ui.rate_limit.kimi_docs_clicked'
|
||||
|
||||
/** @public */
|
||||
export const KIMI_RATE_LIMIT_PLATFORM_CLICKED_EVENT =
|
||||
'ui.rate_limit.moonshot_platform_clicked'
|
||||
|
||||
/** @public */
|
||||
export const SIDEPANEL_VOICE_RECORDING_STARTED_EVENT =
|
||||
'sidepanel.voice.recording_started'
|
||||
|
||||
@@ -49,11 +49,6 @@ export const productVideoUrl = 'https://youtu.be/J-lFhTP-7is'
|
||||
*/
|
||||
export const productRepositoryShortUrl = 'https://git.new/browseros'
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const workflowsHelpUrl = 'https://docs.browseros.com/features/workflows'
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { getBrowserOSAdapter } from '@/lib/browseros/adapter'
|
||||
import { BROWSEROS_PREFS } from '@/lib/browseros/prefs'
|
||||
|
||||
// TODO(credits-identity): temporary shim — reuses the BrowserOS metrics
|
||||
// install_id as the credits/referral identifier. Replace with a dedicated
|
||||
// identity module once we have one.
|
||||
export async function getBrowserosId(): Promise<string> {
|
||||
const adapter = getBrowserOSAdapter()
|
||||
const pref = await adapter.getPref(BROWSEROS_PREFS.INSTALL_ID)
|
||||
const id = pref.value
|
||||
if (typeof id !== 'string' || id.length === 0) {
|
||||
throw new Error('browseros.metrics_install_id is not set')
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -1,20 +1,25 @@
|
||||
import { EXTERNAL_URLS } from '@browseros/shared/constants/urls'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { getAgentServerUrl } from '@/lib/browseros/helpers'
|
||||
import { getBrowserosId } from './browseros-id'
|
||||
|
||||
export interface CreditsInfo {
|
||||
credits: number
|
||||
dailyLimit: number
|
||||
lastResetAt?: string
|
||||
browserosId?: string
|
||||
}
|
||||
|
||||
const CREDITS_QUERY_KEY = ['credits']
|
||||
|
||||
async function fetchCredits(): Promise<CreditsInfo> {
|
||||
const baseUrl = await getAgentServerUrl()
|
||||
const response = await fetch(`${baseUrl}/credits`)
|
||||
const browserosId = await getBrowserosId()
|
||||
const response = await fetch(
|
||||
`${EXTERNAL_URLS.CREDITS_GATEWAY}/credits/${browserosId}`,
|
||||
)
|
||||
if (!response.ok)
|
||||
throw new Error(`Failed to fetch credits: ${response.status}`)
|
||||
return response.json()
|
||||
const data = (await response.json()) as CreditsInfo
|
||||
return { ...data, browserosId }
|
||||
}
|
||||
|
||||
export function useCredits() {
|
||||
|
||||
@@ -6,7 +6,6 @@ const EnvSchema = z.object({
|
||||
VITE_PUBLIC_POSTHOG_HOST: z.string().optional(),
|
||||
VITE_PUBLIC_SENTRY_DSN: z.string().optional(),
|
||||
VITE_PUBLIC_BROWSEROS_API: z.string().optional(),
|
||||
VITE_PUBLIC_KIMI_LAUNCH: z.string().optional(),
|
||||
PROD: z.boolean(),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { env } from '@/lib/env'
|
||||
|
||||
const ENABLED_VALUES = new Set(['1', 'true', 'yes', 'on'])
|
||||
|
||||
function parseKimiLaunchFlag(value: string | undefined): boolean {
|
||||
if (!value) return false
|
||||
return ENABLED_VALUES.has(value.trim().toLowerCase())
|
||||
}
|
||||
|
||||
const kimiLaunchEnabled = parseKimiLaunchFlag(env.VITE_PUBLIC_KIMI_LAUNCH)
|
||||
|
||||
export function isKimiLaunchEnabled(): boolean {
|
||||
return kimiLaunchEnabled
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { isKimiLaunchEnabled } from './kimi-launch'
|
||||
|
||||
export function useKimiLaunch(): boolean {
|
||||
return isKimiLaunchEnabled()
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
|
||||
import localforage from 'localforage'
|
||||
import {
|
||||
type AsyncStorage,
|
||||
PersistQueryClientProvider,
|
||||
} from '@tanstack/react-query-persist-client'
|
||||
import { del, get, set } from 'idb-keyval'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -12,8 +15,14 @@ const queryClient = new QueryClient({
|
||||
},
|
||||
})
|
||||
|
||||
const idbStorage: AsyncStorage<string> = {
|
||||
getItem: (key: string) => get<string>(key).then((v) => v ?? null),
|
||||
setItem: (key: string, value: string) => set(key, value),
|
||||
removeItem: (key: string) => del(key),
|
||||
}
|
||||
|
||||
const asyncStoragePersister = createAsyncStoragePersister({
|
||||
storage: localforage,
|
||||
storage: idbStorage,
|
||||
})
|
||||
|
||||
export const QueryProvider: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { getBrowserOSAdapter } from '@/lib/browseros/adapter'
|
||||
import { BROWSEROS_PREFS } from '@/lib/browseros/prefs'
|
||||
import { isKimiLaunchEnabled } from '@/lib/feature-flags/kimi-launch'
|
||||
|
||||
/** @public */
|
||||
export interface LlmHubProvider {
|
||||
@@ -8,43 +7,15 @@ export interface LlmHubProvider {
|
||||
url: string
|
||||
}
|
||||
|
||||
const KIMI_PROVIDER: LlmHubProvider = {
|
||||
name: 'Kimi',
|
||||
url: 'https://www.kimi.com',
|
||||
}
|
||||
|
||||
function ensureKimiFirst(providers: LlmHubProvider[]): LlmHubProvider[] {
|
||||
if (!isKimiLaunchEnabled()) return providers
|
||||
const hasKimi = providers.some(
|
||||
(p) => p.name === 'Kimi' || p.url.includes('kimi.com'),
|
||||
)
|
||||
return hasKimi ? providers : [KIMI_PROVIDER, ...providers]
|
||||
}
|
||||
|
||||
export async function loadProviders(): Promise<LlmHubProvider[]> {
|
||||
try {
|
||||
const adapter = getBrowserOSAdapter()
|
||||
const providersPref = await adapter.getPref(
|
||||
BROWSEROS_PREFS.THIRD_PARTY_LLM_PROVIDERS,
|
||||
)
|
||||
const providers = (providersPref?.value as LlmHubProvider[]) || []
|
||||
|
||||
if (providers.length === 0) {
|
||||
if (isKimiLaunchEnabled()) {
|
||||
const defaults = [KIMI_PROVIDER]
|
||||
await saveProviders(defaults)
|
||||
return defaults
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const normalized = ensureKimiFirst(providers)
|
||||
if (normalized !== providers) {
|
||||
await saveProviders(normalized)
|
||||
}
|
||||
return normalized
|
||||
return (providersPref?.value as LlmHubProvider[]) || []
|
||||
} catch {
|
||||
return isKimiLaunchEnabled() ? [KIMI_PROVIDER] : []
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5402,5 +5402,89 @@
|
||||
"outputCost": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"minimax": {
|
||||
"name": "MiniMax",
|
||||
"api": "https://api.minimaxi.com/v1",
|
||||
"doc": "https://platform.minimax.io",
|
||||
"models": [
|
||||
{
|
||||
"id": "MiniMax-M2.7",
|
||||
"name": "MiniMax M2.7",
|
||||
"contextWindow": 204800,
|
||||
"maxOutput": 8192,
|
||||
"supportsImages": false,
|
||||
"supportsReasoning": true,
|
||||
"supportsToolCall": true,
|
||||
"inputCost": 0.3,
|
||||
"outputCost": 1.2
|
||||
},
|
||||
{
|
||||
"id": "MiniMax-M2.7-highspeed",
|
||||
"name": "MiniMax M2.7 Highspeed",
|
||||
"contextWindow": 204800,
|
||||
"maxOutput": 8192,
|
||||
"supportsImages": false,
|
||||
"supportsReasoning": true,
|
||||
"supportsToolCall": true,
|
||||
"inputCost": 0.6,
|
||||
"outputCost": 2.4
|
||||
},
|
||||
{
|
||||
"id": "MiniMax-M2.5",
|
||||
"name": "MiniMax M2.5",
|
||||
"contextWindow": 204800,
|
||||
"maxOutput": 8192,
|
||||
"supportsImages": false,
|
||||
"supportsReasoning": true,
|
||||
"supportsToolCall": true,
|
||||
"inputCost": 0.3,
|
||||
"outputCost": 1.2
|
||||
},
|
||||
{
|
||||
"id": "MiniMax-M2.5-highspeed",
|
||||
"name": "MiniMax M2.5 Highspeed",
|
||||
"contextWindow": 204800,
|
||||
"maxOutput": 8192,
|
||||
"supportsImages": false,
|
||||
"supportsReasoning": true,
|
||||
"supportsToolCall": true,
|
||||
"inputCost": 0.6,
|
||||
"outputCost": 2.4
|
||||
},
|
||||
{
|
||||
"id": "MiniMax-M2.1",
|
||||
"name": "MiniMax M2.1",
|
||||
"contextWindow": 204800,
|
||||
"maxOutput": 8192,
|
||||
"supportsImages": false,
|
||||
"supportsReasoning": true,
|
||||
"supportsToolCall": true,
|
||||
"inputCost": 0.3,
|
||||
"outputCost": 1.2
|
||||
},
|
||||
{
|
||||
"id": "MiniMax-M2.1-highspeed",
|
||||
"name": "MiniMax M2.1 Highspeed",
|
||||
"contextWindow": 204800,
|
||||
"maxOutput": 8192,
|
||||
"supportsImages": false,
|
||||
"supportsReasoning": true,
|
||||
"supportsToolCall": true,
|
||||
"inputCost": 0.6,
|
||||
"outputCost": 2.4
|
||||
},
|
||||
{
|
||||
"id": "M2-her",
|
||||
"name": "M2-her",
|
||||
"contextWindow": 204800,
|
||||
"maxOutput": 8192,
|
||||
"supportsImages": false,
|
||||
"supportsReasoning": false,
|
||||
"supportsToolCall": true,
|
||||
"inputCost": 0.3,
|
||||
"outputCost": 1.2
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Gemini,
|
||||
Kimi,
|
||||
LmStudio,
|
||||
Minimax,
|
||||
Ollama,
|
||||
OpenAI,
|
||||
OpenRouter,
|
||||
@@ -36,6 +37,7 @@ const providerIconMap: Record<ProviderType, IconComponent | null> = {
|
||||
'chatgpt-pro': OpenAI,
|
||||
'github-copilot': Github,
|
||||
'qwen-code': Qwen,
|
||||
minimax: Minimax,
|
||||
}
|
||||
|
||||
interface ProviderIconProps {
|
||||
|
||||
@@ -140,8 +140,31 @@ export const providerTemplates: ProviderTemplate[] = [
|
||||
setupGuideUrl:
|
||||
'https://docs.aws.amazon.com/bedrock/latest/userguide/getting-started.html',
|
||||
}),
|
||||
enrichTemplate('minimax', {
|
||||
defaultModelId: 'MiniMax-M2.7',
|
||||
apiKeyUrl:
|
||||
'https://platform.minimax.io/user-center/basic-information/interface-key',
|
||||
setupGuideUrl: 'https://platform.minimax.io/docs/guides/models-intro',
|
||||
}),
|
||||
]
|
||||
|
||||
export const MINIMAX_REGIONS = {
|
||||
chinese: {
|
||||
api: 'https://api.minimaxi.com/v1',
|
||||
apiKeyUrl:
|
||||
'https://platform.minimaxi.com/user-center/basic-information/interface-key',
|
||||
setupGuideUrl: 'https://platform.minimaxi.com/document',
|
||||
},
|
||||
international: {
|
||||
api: 'https://api.minimax.io/v1',
|
||||
apiKeyUrl:
|
||||
'https://platform.minimax.io/user-center/basic-information/interface-key',
|
||||
setupGuideUrl: 'https://platform.minimax.io/docs/guides/models-intro',
|
||||
},
|
||||
} as const
|
||||
|
||||
export type MinimaxRegion = keyof typeof MINIMAX_REGIONS
|
||||
|
||||
/**
|
||||
* Provider type options for select dropdowns
|
||||
* @public
|
||||
@@ -161,6 +184,7 @@ export const providerTypeOptions: { value: ProviderType; label: string }[] = [
|
||||
{ value: 'lmstudio', label: 'LM Studio' },
|
||||
{ value: 'bedrock', label: 'AWS Bedrock' },
|
||||
{ value: 'browseros', label: 'BrowserOS' },
|
||||
{ value: 'minimax', label: 'MiniMax' },
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -192,6 +216,7 @@ export const DEFAULT_BASE_URLS: Record<ProviderType, string> = {
|
||||
lmstudio: 'http://localhost:1234/v1',
|
||||
bedrock: '',
|
||||
browseros: '',
|
||||
minimax: MINIMAX_REGIONS.chinese.api,
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,14 +2,12 @@ import { storage } from '@wxt-dev/storage'
|
||||
import { sessionStorage } from '@/lib/auth/sessionStorage'
|
||||
import { getBrowserOSAdapter } from '@/lib/browseros/adapter'
|
||||
import { BROWSEROS_PREFS } from '@/lib/browseros/prefs'
|
||||
import { isKimiLaunchEnabled } from '@/lib/feature-flags/kimi-launch'
|
||||
import type { LlmProviderConfig, LlmProvidersBackup } from './types'
|
||||
import { uploadLlmProvidersToGraphql } from './uploadLlmProvidersToGraphql'
|
||||
|
||||
/** Default provider ID constant */
|
||||
export const DEFAULT_PROVIDER_ID = 'browseros'
|
||||
const DEFAULT_PROVIDER_NAME = 'BrowserOS'
|
||||
const KIMI_LAUNCH_PROVIDER_NAME = 'Kimi K2.5'
|
||||
|
||||
/** Storage key for LLM providers array */
|
||||
export const providersStorage = storage.defineItem<LlmProviderConfig[]>(
|
||||
@@ -91,7 +89,7 @@ export function setupLlmProvidersSyncToBackend(): () => void {
|
||||
/** Load providers from storage */
|
||||
export async function loadProviders(): Promise<LlmProviderConfig[]> {
|
||||
const providers = (await providersStorage.getValue()) || []
|
||||
const normalizedProviders = normalizeProvidersForLaunch(providers)
|
||||
const normalizedProviders = normalizeProviderNames(providers)
|
||||
|
||||
// Keep storage consistent so every consumer sees the same provider name.
|
||||
if (
|
||||
@@ -109,7 +107,7 @@ export function createDefaultBrowserOSProvider(): LlmProviderConfig {
|
||||
return {
|
||||
id: DEFAULT_PROVIDER_ID,
|
||||
type: 'browseros',
|
||||
name: getBuiltInProviderName(),
|
||||
name: DEFAULT_PROVIDER_NAME,
|
||||
baseUrl: 'https://api.browseros.com/v1',
|
||||
modelId: 'browseros-auto',
|
||||
supportsImages: true,
|
||||
@@ -125,26 +123,22 @@ export function createDefaultProvidersConfig(): LlmProviderConfig[] {
|
||||
return [createDefaultBrowserOSProvider()]
|
||||
}
|
||||
|
||||
function getBuiltInProviderName(): string {
|
||||
return isKimiLaunchEnabled()
|
||||
? KIMI_LAUNCH_PROVIDER_NAME
|
||||
: DEFAULT_PROVIDER_NAME
|
||||
}
|
||||
|
||||
function normalizeProvidersForLaunch(
|
||||
/**
|
||||
* Normalize built-in provider names back to "BrowserOS" (e.g. from "Kimi K2.5"
|
||||
* which was set during a previous partnership launch).
|
||||
*/
|
||||
function normalizeProviderNames(
|
||||
providers: LlmProviderConfig[],
|
||||
): LlmProviderConfig[] {
|
||||
const builtInProviderName = getBuiltInProviderName()
|
||||
|
||||
return providers.map((provider) => {
|
||||
if (
|
||||
provider.id === DEFAULT_PROVIDER_ID &&
|
||||
provider.type === 'browseros' &&
|
||||
provider.name !== builtInProviderName
|
||||
provider.name !== DEFAULT_PROVIDER_NAME
|
||||
) {
|
||||
return {
|
||||
...provider,
|
||||
name: builtInProviderName,
|
||||
name: DEFAULT_PROVIDER_NAME,
|
||||
}
|
||||
}
|
||||
return provider
|
||||
|
||||
@@ -17,6 +17,7 @@ export type ProviderType =
|
||||
| 'chatgpt-pro'
|
||||
| 'github-copilot'
|
||||
| 'qwen-code'
|
||||
| 'minimax'
|
||||
|
||||
/**
|
||||
* LLM Provider configuration
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { EXTERNAL_URLS } from '@browseros/shared/constants/urls'
|
||||
|
||||
interface ReferralResult {
|
||||
success: boolean
|
||||
creditsAdded?: number
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export async function submitReferral(
|
||||
tweetUrl: string,
|
||||
browserosId: string,
|
||||
): Promise<ReferralResult> {
|
||||
const response = await fetch(
|
||||
`${EXTERNAL_URLS.REFERRAL_SERVICE}/referral/submit`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tweetUrl, browserosId }),
|
||||
},
|
||||
)
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
reason: `Request failed with status ${response.status}`,
|
||||
}
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
const TWEET_VARIATIONS = [
|
||||
`ngl @browseros_ai is kinda wild
|
||||
|
||||
just type what u want in plain english and it handles the annoying web shit
|
||||
|
||||
forms, research, data pulls... all automated
|
||||
|
||||
actually works`,
|
||||
|
||||
`been using @browseros_ai to chat with webpages lately
|
||||
|
||||
summarize articles, pull data, translate stuff
|
||||
|
||||
all happens in the same tab
|
||||
|
||||
no copy/paste, no switching windows
|
||||
|
||||
just ask and it does it`,
|
||||
|
||||
`wake up to @browseros_ai having already read ur emails and calendar while u were sleeping
|
||||
|
||||
scheduled agents are lowkey magic`,
|
||||
|
||||
`ngl @browseros_ai is kinda crazy
|
||||
|
||||
connects gmail, slack, linear, notion + 40 other apps into one ai assistant
|
||||
|
||||
just talk to it in plain english and it handles cross-app workflows for u
|
||||
|
||||
no more switching between tabs like a psycho`,
|
||||
|
||||
`i use @browseros_ai to automate research
|
||||
|
||||
it handles the browser work and drops reports straight into local folders
|
||||
|
||||
no switching between tools or manually saving files
|
||||
|
||||
just one task instead of three`,
|
||||
|
||||
`been messing with @browseros_ai lately
|
||||
|
||||
it comes with a prebuilt MCP server and I connect it claude code or codex and it just runs things for you
|
||||
|
||||
set it up once, use it whenever
|
||||
|
||||
way better than clicking through the same shit manually every time`,
|
||||
|
||||
`the ai actually remembers what we talked about yesterday
|
||||
|
||||
no more "here's the context again" every single conversation
|
||||
|
||||
@browseros_ai just picks up where we left off
|
||||
|
||||
feels like talking to someone who actually pays attention`,
|
||||
|
||||
`i built a skill library for my ai agent
|
||||
|
||||
now when i need it to do something specific, i just load the recipe i made earlier
|
||||
|
||||
@browseros_ai MCP is very handy`,
|
||||
|
||||
`been running @browseros_ai with ollama locally
|
||||
|
||||
everything stays on my machine, nothing gets sent out
|
||||
|
||||
kinda nice not having to think about what data i'm sharing`,
|
||||
|
||||
`switched to @browseros_ai from chrome
|
||||
|
||||
blocks 10x more ads and runs full ublock origin (not the lite version)
|
||||
|
||||
check it out`,
|
||||
]
|
||||
|
||||
export function getShareOnTwitterUrl(): string {
|
||||
const text =
|
||||
TWEET_VARIATIONS[Math.floor(Math.random() * TWEET_VARIATIONS.length)]
|
||||
return `https://x.com/intent/tweet?text=${encodeURIComponent(text)}`
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { storage } from '@wxt-dev/storage'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export interface Workflow {
|
||||
id: string
|
||||
codeId: string
|
||||
workflowName: string
|
||||
}
|
||||
|
||||
export const workflowStorage = storage.defineItem<Workflow[]>(
|
||||
'local:workflows',
|
||||
{
|
||||
fallback: [],
|
||||
},
|
||||
)
|
||||
|
||||
export function useWorkflows() {
|
||||
const [workflows, setWorkflows] = useState<Workflow[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
workflowStorage.getValue().then(setWorkflows)
|
||||
const unwatch = workflowStorage.watch((newValue) => {
|
||||
setWorkflows(newValue ?? [])
|
||||
})
|
||||
return unwatch
|
||||
}, [])
|
||||
|
||||
const addWorkflow = async (workflow: Omit<Workflow, 'id'>) => {
|
||||
const newWorkflow: Workflow = {
|
||||
id: crypto.randomUUID(),
|
||||
...workflow,
|
||||
}
|
||||
const current = (await workflowStorage.getValue()) ?? []
|
||||
await workflowStorage.setValue([...current, newWorkflow])
|
||||
return newWorkflow
|
||||
}
|
||||
|
||||
const removeWorkflow = async (id: string) => {
|
||||
const current = (await workflowStorage.getValue()) ?? []
|
||||
await workflowStorage.setValue(current.filter((w) => w.id !== id))
|
||||
}
|
||||
|
||||
const editWorkflow = async (
|
||||
id: string,
|
||||
updates: Partial<Omit<Workflow, 'id'>>,
|
||||
) => {
|
||||
const current = (await workflowStorage.getValue()) ?? []
|
||||
await workflowStorage.setValue(
|
||||
current.map((w) => (w.id === id ? { ...w, ...updates } : w)),
|
||||
)
|
||||
}
|
||||
|
||||
return { workflows, addWorkflow, removeWorkflow, editWorkflow }
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@browseros/agent",
|
||||
"description": "manifest.json description",
|
||||
"private": true,
|
||||
"version": "0.0.52",
|
||||
"version": "0.0.99",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "test -d generated/graphql || bun run codegen; mkdir -p /tmp/browseros-dev; bun --env-file=.env.development wxt",
|
||||
@@ -20,6 +20,7 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^3.0.96",
|
||||
"@browseros/server": "workspace:*",
|
||||
"@browseros/shared": "workspace:*",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@lobehub/icons": "^2.44.0",
|
||||
"@mdxeditor/editor": "^3.52.4",
|
||||
@@ -44,9 +45,9 @@
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@sentry/react": "^10.31.0",
|
||||
"@sentry/vite-plugin": "^4.6.1",
|
||||
"@tanstack/query-async-storage-persister": "^5.90.21",
|
||||
"@tanstack/react-query": "^5.90.19",
|
||||
"@tanstack/react-query-persist-client": "^5.90.21",
|
||||
"@tanstack/query-async-storage-persister": "^5.95.2",
|
||||
"@tanstack/react-query": "^5.95.2",
|
||||
"@tanstack/react-query-persist-client": "^5.95.2",
|
||||
"@types/cytoscape": "^3.31.0",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@webext-core/messaging": "^2.3.0",
|
||||
@@ -67,10 +68,11 @@
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"es-toolkit": "^1.42.0",
|
||||
"eventsource-parser": "^3.0.6",
|
||||
"fuse.js": "^7.1.0",
|
||||
"graphql": "^16.12.0",
|
||||
"hono": "^4.12.3",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"klavis": "^2.15.0",
|
||||
"localforage": "^1.10.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"motion": "^12.23.24",
|
||||
"nanoid": "^5.1.6",
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineWebExtConfig } from 'wxt'
|
||||
|
||||
// biome-ignore lint/style/noProcessEnv: config file needs env access
|
||||
const env = process.env
|
||||
|
||||
const MONOREPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..')
|
||||
const CONTROLLER_EXT_DIR = join(MONOREPO_ROOT, 'apps/controller-ext/dist')
|
||||
|
||||
const chromiumArgs = [
|
||||
'--use-mock-keychain',
|
||||
'--show-component-extension-options',
|
||||
'--disable-browseros-server',
|
||||
'--disable-browseros-extensions',
|
||||
`--load-extension=${CONTROLLER_EXT_DIR}`,
|
||||
]
|
||||
|
||||
if (env.BROWSEROS_CDP_PORT) {
|
||||
|
||||
10
packages/browseros-agent/apps/cli/.env.production.example
Normal file
10
packages/browseros-agent/apps/cli/.env.production.example
Normal file
@@ -0,0 +1,10 @@
|
||||
# Production build env for CLI
|
||||
|
||||
POSTHOG_API_KEY=
|
||||
|
||||
# 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,47 +0,0 @@
|
||||
version: 2
|
||||
|
||||
project_name: 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
1
packages/browseros-agent/apps/cli/CHANGELOG.md
Normal file
@@ -0,0 +1 @@
|
||||
# BrowserOS CLI
|
||||
@@ -1,17 +1,27 @@
|
||||
BINARY := browseros-cli
|
||||
SOURCES := $(shell find . -name '*.go')
|
||||
VERSION ?= dev
|
||||
POSTHOG_API_KEY ?=
|
||||
DIST := dist
|
||||
LDFLAGS := -X main.version=$(VERSION) -X browseros-cli/analytics.posthogAPIKey=$(POSTHOG_API_KEY)
|
||||
HOST_OS := $(shell go env GOOS)
|
||||
HOST_ARCH := $(shell go env GOARCH)
|
||||
HOST_EXT := $(if $(filter windows,$(HOST_OS)),.exe,)
|
||||
HOST_BINARY = $(DIST)/$(BINARY)_$(HOST_OS)_$(HOST_ARCH)$(HOST_EXT)
|
||||
|
||||
$(BINARY): $(SOURCES)
|
||||
go build -ldflags "-X main.version=$(VERSION)" -o $(BINARY) .
|
||||
go build -ldflags "$(LDFLAGS)" -o $(BINARY) .
|
||||
|
||||
.PHONY: install clean vet test
|
||||
PLATFORMS := darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64
|
||||
|
||||
.PHONY: install clean vet test release
|
||||
|
||||
install:
|
||||
go install -ldflags "-X main.version=$(VERSION)" .
|
||||
go install -ldflags "$(LDFLAGS)" .
|
||||
|
||||
clean:
|
||||
rm -f $(BINARY)
|
||||
rm -rf $(DIST)
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
@@ -19,8 +29,41 @@ vet:
|
||||
test:
|
||||
go test -tags integration -v -timeout 120s ./...
|
||||
|
||||
release-dry:
|
||||
goreleaser release --snapshot --clean
|
||||
|
||||
release:
|
||||
goreleaser release --clean
|
||||
@if [ "$(VERSION)" = "dev" ]; then echo "Error: VERSION required (e.g. make release VERSION=0.1.0)" >&2; exit 1; fi
|
||||
@rm -rf $(DIST) && mkdir -p $(DIST)
|
||||
@for pair in $(PLATFORMS); do \
|
||||
OS=$${pair%/*}; \
|
||||
ARCH=$${pair#*/}; \
|
||||
EXT=""; \
|
||||
if [ "$$OS" = "windows" ]; then EXT=".exe"; fi; \
|
||||
echo "Building $$OS/$$ARCH..."; \
|
||||
GOOS=$$OS GOARCH=$$ARCH CGO_ENABLED=0 go build -trimpath \
|
||||
-ldflags "-s -w $(LDFLAGS)" \
|
||||
-o "$(DIST)/$(BINARY)$$EXT" .; \
|
||||
ARCHIVE="$(BINARY)_$(VERSION)_$${OS}_$${ARCH}"; \
|
||||
if [ "$$OS" = "windows" ]; then \
|
||||
(cd $(DIST) && zip "$${ARCHIVE}.zip" "$(BINARY)$$EXT"); \
|
||||
else \
|
||||
(cd $(DIST) && tar czf "$${ARCHIVE}.tar.gz" "$(BINARY)"); \
|
||||
fi; \
|
||||
mv "$(DIST)/$(BINARY)$$EXT" "$(DIST)/$(BINARY)_$${OS}_$${ARCH}$$EXT"; \
|
||||
done
|
||||
@ACTUAL_VERSION=$$($(HOST_BINARY) --version | awk '{print $$3}'); \
|
||||
if [ "$$ACTUAL_VERSION" != "$(VERSION)" ]; then \
|
||||
echo "Error: expected $(HOST_BINARY) to report version $(VERSION), got $$ACTUAL_VERSION" >&2; \
|
||||
exit 1; \
|
||||
fi
|
||||
@cd $(DIST) && (command -v sha256sum >/dev/null 2>&1 && sha256sum *.tar.gz *.zip || shasum -a 256 *.tar.gz *.zip) > checksums.txt
|
||||
@echo "=== Built artifacts ==="
|
||||
@ls -lh $(DIST)
|
||||
|
||||
.PHONY: npm-version npm-publish
|
||||
|
||||
npm-version:
|
||||
@if [ "$(VERSION)" = "dev" ]; then echo "Error: VERSION required" >&2; exit 1; fi
|
||||
@node -e "const p=require('./npm/package.json');p.version='$(VERSION)';require('fs').writeFileSync('./npm/package.json',JSON.stringify(p,null,2)+'\n')"
|
||||
@echo "npm/package.json version set to $(VERSION)"
|
||||
|
||||
npm-publish: npm-version
|
||||
cd npm && npm publish
|
||||
|
||||
@@ -1,25 +1,68 @@
|
||||
# browseros-cli
|
||||
|
||||
Command-line interface for controlling BrowserOS via MCP. Talks to the BrowserOS MCP server over JSON-RPC 2.0 / StreamableHTTP.
|
||||
[](../../../../LICENSE)
|
||||
|
||||
## Setup
|
||||
Command-line interface for controlling BrowserOS — launch and automate the browser from the terminal or from AI coding agents like Claude Code and Gemini CLI.
|
||||
|
||||
Communicates with the BrowserOS MCP server over JSON-RPC 2.0 / StreamableHTTP. All 53+ MCP tools are mapped to CLI commands.
|
||||
|
||||
## Install
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
```bash
|
||||
curl -fsSL https://cdn.browseros.com/cli/install.sh | bash
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
```powershell
|
||||
irm https://cdn.browseros.com/cli/install.ps1 | iex
|
||||
```
|
||||
|
||||
### Build from Source
|
||||
|
||||
Requires Go 1.25+.
|
||||
|
||||
```bash
|
||||
# Build
|
||||
make
|
||||
|
||||
# First run — configure server connection
|
||||
./browseros-cli init
|
||||
make # Build binary
|
||||
make install # Install to $GOPATH/bin
|
||||
```
|
||||
|
||||
The `init` command prompts for your MCP server URL. Find it in:
|
||||
**BrowserOS → Settings → BrowserOS MCP → Server URL**
|
||||
## Quick Start
|
||||
|
||||
The port varies per installation (e.g., `http://127.0.0.1:9004/mcp`).
|
||||
```bash
|
||||
# If BrowserOS is not installed yet
|
||||
browseros-cli install # downloads BrowserOS for your platform
|
||||
|
||||
Config is saved to `~/.config/browseros-cli/config.yaml`.
|
||||
# If BrowserOS is installed but not running
|
||||
browseros-cli launch # opens BrowserOS, waits for server
|
||||
|
||||
# Configure the CLI (auto-discovers running BrowserOS)
|
||||
browseros-cli init --auto # detects server URL and saves config
|
||||
|
||||
# Verify connection
|
||||
browseros-cli health
|
||||
```
|
||||
|
||||
### Other init modes
|
||||
|
||||
```bash
|
||||
browseros-cli init <url> # non-interactive — pass URL directly
|
||||
browseros-cli init # interactive — prompts for URL
|
||||
```
|
||||
|
||||
Config is saved to `~/.config/browseros-cli/config.yaml`. The CLI also auto-discovers the server from `~/.browseros/server.json` (written by BrowserOS on startup).
|
||||
|
||||
### CLI updates
|
||||
|
||||
The CLI checks for a newer BrowserOS CLI release in the background about once per day and will suggest an update on a later run when one is available.
|
||||
|
||||
```bash
|
||||
browseros-cli update # check and apply the latest CLI release
|
||||
browseros-cli update --check # check only
|
||||
browseros-cli update --yes # apply without prompting
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -67,6 +110,12 @@ browseros-cli history recent
|
||||
browseros-cli group list
|
||||
```
|
||||
|
||||
## Use as MCP Server
|
||||
|
||||
BrowserOS exposes an MCP server that AI coding agents can connect to directly. The CLI is the easiest way to verify the connection and interact with tools from the terminal.
|
||||
|
||||
To connect Claude Code, Gemini CLI, or any MCP client, see the [MCP setup guide](https://docs.browseros.com/features/use-with-claude-code).
|
||||
|
||||
## Global Flags
|
||||
|
||||
| Flag | Env Var | Description |
|
||||
@@ -77,9 +126,9 @@ browseros-cli group list
|
||||
| `--debug` | `BOS_DEBUG=1` | Debug output |
|
||||
| `--timeout, -t` | | Request timeout (default: 2m) |
|
||||
|
||||
Priority for server URL: `--server` flag > `BROWSEROS_URL` env > config file
|
||||
Priority for server URL: `--server` flag > `BROWSEROS_URL` env > `~/.browseros/server.json` > config file
|
||||
|
||||
If no server URL is configured, the CLI exits with setup instructions instead of assuming a localhost port.
|
||||
If no server URL is configured, the CLI exits with setup instructions pointing to `install`, `launch`, and `init`.
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -130,7 +179,9 @@ apps/cli/
|
||||
│ └── config.go # Config file (~/.config/browseros-cli/config.yaml)
|
||||
├── cmd/
|
||||
│ ├── root.go # Root command, global flags
|
||||
│ ├── init.go # Server URL configuration
|
||||
│ ├── init.go # Server URL configuration (URL arg, --auto, interactive)
|
||||
│ ├── install.go # install (download BrowserOS for current platform)
|
||||
│ ├── launch.go # launch (find and start BrowserOS, wait for server)
|
||||
│ ├── open.go # open (new_page / new_hidden_page)
|
||||
│ ├── nav.go # nav, back, forward, reload
|
||||
│ ├── pages.go # pages, active, close
|
||||
@@ -163,4 +214,8 @@ The CLI communicates with BrowserOS via two HTTP POST requests per command:
|
||||
1. `initialize` — MCP handshake
|
||||
2. `tools/call` — execute the actual tool
|
||||
|
||||
All 54 MCP tools are mapped to CLI commands.
|
||||
## Links
|
||||
|
||||
- [Documentation](https://docs.browseros.com)
|
||||
- [MCP Setup Guide](https://docs.browseros.com/features/use-with-claude-code)
|
||||
- [Changelog](./CHANGELOG.md)
|
||||
|
||||
129
packages/browseros-agent/apps/cli/analytics/analytics.go
Normal file
129
packages/browseros-agent/apps/cli/analytics/analytics.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"browseros-cli/config"
|
||||
|
||||
"github.com/posthog/posthog-go"
|
||||
)
|
||||
|
||||
var (
|
||||
posthogAPIKey string // set via ldflags
|
||||
posthogHost = "https://us.i.posthog.com"
|
||||
)
|
||||
|
||||
const eventPrefix = "browseros.cli."
|
||||
|
||||
var svc *service
|
||||
|
||||
type service struct {
|
||||
client posthog.Client
|
||||
distinctID string
|
||||
}
|
||||
|
||||
func Init(version string) {
|
||||
if posthogAPIKey == "" {
|
||||
return
|
||||
}
|
||||
|
||||
distinctID := resolveDistinctID()
|
||||
if distinctID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
client, err := posthog.NewWithConfig(posthogAPIKey, posthog.Config{
|
||||
Endpoint: posthogHost,
|
||||
BatchSize: 10,
|
||||
ShutdownTimeout: 3 * time.Second,
|
||||
DefaultEventProperties: posthog.NewProperties().
|
||||
Set("cli_version", version).
|
||||
Set("os", runtime.GOOS).
|
||||
Set("arch", runtime.GOARCH),
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
svc = &service{client: client, distinctID: distinctID}
|
||||
}
|
||||
|
||||
func Track(command string, success bool, duration time.Duration) {
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
svc.client.Enqueue(posthog.Capture{
|
||||
DistinctId: svc.distinctID,
|
||||
Event: eventPrefix + "command_executed",
|
||||
Properties: posthog.NewProperties().
|
||||
Set("command", command).
|
||||
Set("success", success).
|
||||
Set("duration_ms", duration.Milliseconds()).
|
||||
Set("$process_person_profile", false),
|
||||
})
|
||||
}
|
||||
|
||||
func Close() {
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
svc.client.Close()
|
||||
svc = nil
|
||||
}
|
||||
|
||||
func resolveDistinctID() string {
|
||||
if id := loadBrowserosID(); id != "" {
|
||||
return id
|
||||
}
|
||||
return loadOrCreateInstallID()
|
||||
}
|
||||
|
||||
func loadBrowserosID() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(home, ".browseros", "server.json"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var sc struct {
|
||||
BrowserosID string `json:"browseros_id"`
|
||||
}
|
||||
if json.Unmarshal(data, &sc) != nil {
|
||||
return ""
|
||||
}
|
||||
return sc.BrowserosID
|
||||
}
|
||||
|
||||
func loadOrCreateInstallID() string {
|
||||
dir := config.Dir()
|
||||
idPath := filepath.Join(dir, "install_id")
|
||||
|
||||
data, err := os.ReadFile(idPath)
|
||||
if err == nil {
|
||||
if id := strings.TrimSpace(string(data)); id != "" {
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
id := generateUUID()
|
||||
os.MkdirAll(dir, 0755)
|
||||
os.WriteFile(idPath, []byte(id), 0644)
|
||||
return id
|
||||
}
|
||||
|
||||
func generateUUID() string {
|
||||
var b [16]byte
|
||||
rand.Read(b[:])
|
||||
b[6] = (b[6] & 0x0f) | 0x40 // version 4
|
||||
b[8] = (b[8] & 0x3f) | 0x80 // variant 2
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
|
||||
}
|
||||
132
packages/browseros-agent/apps/cli/analytics/analytics_test.go
Normal file
132
packages/browseros-agent/apps/cli/analytics/analytics_test.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGenerateUUID(t *testing.T) {
|
||||
id := generateUUID()
|
||||
uuidRe := regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)
|
||||
if !uuidRe.MatchString(id) {
|
||||
t.Errorf("generateUUID() = %q, does not match UUID v4 pattern", id)
|
||||
}
|
||||
|
||||
id2 := generateUUID()
|
||||
if id == id2 {
|
||||
t.Error("generateUUID() returned the same value twice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBrowserosID(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("HOME", tmp)
|
||||
|
||||
// No server.json → empty
|
||||
if got := loadBrowserosID(); got != "" {
|
||||
t.Errorf("loadBrowserosID() = %q, want empty", got)
|
||||
}
|
||||
|
||||
// server.json without browseros_id → empty
|
||||
dir := filepath.Join(tmp, ".browseros")
|
||||
os.MkdirAll(dir, 0755)
|
||||
data, _ := json.Marshal(map[string]any{"server_port": 9100, "url": "http://127.0.0.1:9100"})
|
||||
os.WriteFile(filepath.Join(dir, "server.json"), data, 0644)
|
||||
|
||||
if got := loadBrowserosID(); got != "" {
|
||||
t.Errorf("loadBrowserosID() = %q, want empty (no browseros_id field)", got)
|
||||
}
|
||||
|
||||
// server.json with browseros_id → returns it
|
||||
data, _ = json.Marshal(map[string]any{
|
||||
"server_port": 9100,
|
||||
"url": "http://127.0.0.1:9100",
|
||||
"browseros_id": "test-uuid-1234",
|
||||
})
|
||||
os.WriteFile(filepath.Join(dir, "server.json"), data, 0644)
|
||||
|
||||
if got := loadBrowserosID(); got != "test-uuid-1234" {
|
||||
t.Errorf("loadBrowserosID() = %q, want %q", got, "test-uuid-1234")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOrCreateInstallID(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
configDir := filepath.Join(tmp, "browseros-cli")
|
||||
t.Setenv("XDG_CONFIG_HOME", tmp)
|
||||
|
||||
// First call creates the file
|
||||
id := loadOrCreateInstallID()
|
||||
if id == "" {
|
||||
t.Fatal("loadOrCreateInstallID() returned empty string")
|
||||
}
|
||||
|
||||
// File was persisted
|
||||
data, err := os.ReadFile(filepath.Join(configDir, "install_id"))
|
||||
if err != nil {
|
||||
t.Fatalf("install_id file not created: %v", err)
|
||||
}
|
||||
if string(data) != id {
|
||||
t.Errorf("persisted id = %q, want %q", string(data), id)
|
||||
}
|
||||
|
||||
// Second call returns the same ID
|
||||
id2 := loadOrCreateInstallID()
|
||||
if id2 != id {
|
||||
t.Errorf("loadOrCreateInstallID() = %q, want stable %q", id2, id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDistinctID_PrefersBrowserosID(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("HOME", tmp)
|
||||
t.Setenv("XDG_CONFIG_HOME", tmp)
|
||||
|
||||
// Write server.json with browseros_id
|
||||
dir := filepath.Join(tmp, ".browseros")
|
||||
os.MkdirAll(dir, 0755)
|
||||
data, _ := json.Marshal(map[string]any{"browseros_id": "server-uuid"})
|
||||
os.WriteFile(filepath.Join(dir, "server.json"), data, 0644)
|
||||
|
||||
got := resolveDistinctID()
|
||||
if got != "server-uuid" {
|
||||
t.Errorf("resolveDistinctID() = %q, want %q", got, "server-uuid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDistinctID_FallsBackToInstallID(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("HOME", tmp)
|
||||
t.Setenv("XDG_CONFIG_HOME", tmp)
|
||||
|
||||
// No server.json → should generate install_id
|
||||
got := resolveDistinctID()
|
||||
if got == "" {
|
||||
t.Error("resolveDistinctID() returned empty string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitNoopsWithoutAPIKey(t *testing.T) {
|
||||
old := posthogAPIKey
|
||||
posthogAPIKey = ""
|
||||
defer func() { posthogAPIKey = old }()
|
||||
|
||||
Init("1.0.0")
|
||||
if svc != nil {
|
||||
t.Error("Init() created service without API key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackAndCloseNoopWithoutInit(t *testing.T) {
|
||||
old := svc
|
||||
svc = nil
|
||||
defer func() { svc = old }()
|
||||
|
||||
// Should not panic
|
||||
Track("test", true, time.Second)
|
||||
Close()
|
||||
}
|
||||
@@ -49,7 +49,7 @@ func init() {
|
||||
statusCmd := &cobra.Command{
|
||||
Use: "status",
|
||||
Annotations: map[string]string{"group": "Setup:"},
|
||||
Short: "Check extension connection status",
|
||||
Short: "Check BrowserOS runtime status",
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
c := newClient()
|
||||
@@ -64,12 +64,12 @@ func init() {
|
||||
green := color.New(color.FgGreen).SprintFunc()
|
||||
red := color.New(color.FgRed).SprintFunc()
|
||||
|
||||
ext := data["extensionConnected"]
|
||||
extStr := red("disconnected")
|
||||
if b, ok := ext.(bool); ok && b {
|
||||
extStr = green("connected")
|
||||
cdp := data["cdpConnected"]
|
||||
cdpStr := red("disconnected")
|
||||
if b, ok := cdp.(bool); ok && b {
|
||||
cdpStr = green("connected")
|
||||
}
|
||||
fmt.Printf("Extension: %s\n", extStr)
|
||||
fmt.Printf("Browser: %s\n", cdpStr)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -25,13 +25,17 @@ func init() {
|
||||
Long: `Set up the CLI by providing the MCP server URL from BrowserOS.
|
||||
|
||||
Open BrowserOS → Settings → BrowserOS MCP to find your Server URL.
|
||||
The URL looks like: http://127.0.0.1:9004/mcp
|
||||
The URL looks like: http://127.0.0.1:9000/mcp
|
||||
|
||||
The port varies per installation, so this step is required on first use.
|
||||
Run again if your port changes.
|
||||
|
||||
You can provide the full URL or just the port number:
|
||||
browseros-cli init http://127.0.0.1:9000/mcp
|
||||
browseros-cli init 9000
|
||||
|
||||
Three modes:
|
||||
browseros-cli init <url> Non-interactive, use the provided URL
|
||||
browseros-cli init <url> Non-interactive (full URL or port number)
|
||||
browseros-cli init --auto Auto-discover from ~/.browseros/server.json
|
||||
browseros-cli init Interactive prompt`,
|
||||
Annotations: map[string]string{"group": "Setup:"},
|
||||
@@ -65,13 +69,14 @@ Three modes:
|
||||
bold.Println("BrowserOS CLI Setup")
|
||||
fmt.Println()
|
||||
fmt.Println("Open BrowserOS → Settings → BrowserOS MCP")
|
||||
fmt.Println("Copy the Server URL shown there.")
|
||||
fmt.Println("Copy the Server URL or port number shown there.")
|
||||
fmt.Println()
|
||||
dim.Println("It looks like: http://127.0.0.1:9004/mcp")
|
||||
dim.Println("Examples: http://127.0.0.1:9000/mcp")
|
||||
dim.Println(" 9000")
|
||||
fmt.Println()
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
fmt.Print("Server URL: ")
|
||||
fmt.Print("Server URL or port: ")
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
output.Error("failed to read input", 1)
|
||||
|
||||
@@ -148,7 +148,7 @@ func runPostInstall(path string, deb bool, dim *color.Color) {
|
||||
// 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()
|
||||
mountOut, err := exec.Command("hdiutil", "attach", dmgPath, "-nobrowse").Output()
|
||||
if err != nil {
|
||||
dim.Println("Could not mount DMG automatically.")
|
||||
dim.Printf(" Open it manually: open %s\n", dmgPath)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -9,9 +10,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"browseros-cli/analytics"
|
||||
"browseros-cli/config"
|
||||
"browseros-cli/mcp"
|
||||
"browseros-cli/output"
|
||||
"browseros-cli/update"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -27,8 +30,11 @@ var (
|
||||
version = "dev"
|
||||
)
|
||||
|
||||
const automaticUpdateDrainTimeout = 150 * time.Millisecond
|
||||
|
||||
func SetVersion(v string) {
|
||||
version = v
|
||||
rootCmd.Version = v
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -113,11 +119,40 @@ var rootCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
func Execute() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
automaticUpdater := newAutomaticUpdateManager(os.Args[1:])
|
||||
automaticNotice := ""
|
||||
var automaticCheckDone <-chan struct{}
|
||||
if automaticUpdater != nil {
|
||||
automaticNotice = automaticUpdater.CachedNotice()
|
||||
automaticCheckDone = automaticUpdater.StartBackgroundCheck(context.Background())
|
||||
}
|
||||
|
||||
analytics.Init(version)
|
||||
start := time.Now()
|
||||
|
||||
err := rootCmd.Execute()
|
||||
|
||||
if automaticNotice != "" && err == nil {
|
||||
fmt.Fprintln(os.Stderr, automaticNotice)
|
||||
}
|
||||
drainAutomaticUpdateCheck(automaticCheckDone)
|
||||
|
||||
analytics.Track(commandName(os.Args[1:]), err == nil, time.Since(start))
|
||||
analytics.Close()
|
||||
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func commandName(args []string) string {
|
||||
cmd, _, err := rootCmd.Find(args)
|
||||
if err != nil || cmd == rootCmd {
|
||||
return "unknown"
|
||||
}
|
||||
return cmd.CommandPath()
|
||||
}
|
||||
|
||||
func init() {
|
||||
cobra.AddTemplateFunc("helpHeader", helpHeader)
|
||||
cobra.AddTemplateFunc("helpCmdCol", helpCmdCol)
|
||||
@@ -166,6 +201,93 @@ func envBool(key string) bool {
|
||||
return v == "1" || v == "true"
|
||||
}
|
||||
|
||||
func newAutomaticUpdateManager(args []string) *update.Manager {
|
||||
if shouldSkipAutomaticUpdates(args) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return update.NewManager(update.Options{
|
||||
CurrentVersion: version,
|
||||
JSONOutput: requestedBoolFlag(args, "--json", jsonOut),
|
||||
Debug: requestedBoolFlag(args, "--debug", debug),
|
||||
Automatic: true,
|
||||
})
|
||||
}
|
||||
|
||||
func shouldSkipAutomaticUpdates(args []string) bool {
|
||||
if hasHelpFlag(args) || requestedBoolFlag(args, "--version", false) {
|
||||
return true
|
||||
}
|
||||
|
||||
switch primaryCommand(args) {
|
||||
case "help", "completion", "update", "self-update", "upgrade":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func hasHelpFlag(args []string) bool {
|
||||
if requestedBoolFlag(args, "--help", false) {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, arg := range args {
|
||||
if arg == "-h" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func primaryCommand(args []string) string {
|
||||
for _, arg := range args {
|
||||
if strings.HasPrefix(arg, "-") {
|
||||
continue
|
||||
}
|
||||
return arg
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func requestedBoolFlag(args []string, flagName string, current bool) bool {
|
||||
if current {
|
||||
return true
|
||||
}
|
||||
|
||||
prefix := flagName + "="
|
||||
for _, arg := range args {
|
||||
if arg == flagName {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(arg, prefix) {
|
||||
value, err := strconv.ParseBool(strings.TrimPrefix(arg, prefix))
|
||||
return err == nil && value
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func drainAutomaticUpdateCheck(done <-chan struct{}) {
|
||||
drainAutomaticUpdateCheckWithTimeout(done, automaticUpdateDrainTimeout)
|
||||
}
|
||||
|
||||
func drainAutomaticUpdateCheckWithTimeout(done <-chan struct{}, timeout time.Duration) {
|
||||
if done == nil {
|
||||
return
|
||||
}
|
||||
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
|
||||
func defaultServerURL() string {
|
||||
// 1. Explicit env var always wins
|
||||
if env := normalizeServerURL(os.Getenv("BROWSEROS_URL")); env != "" {
|
||||
@@ -217,10 +339,27 @@ func loadBrowserosServerURL() string {
|
||||
|
||||
func normalizeServerURL(raw string) string {
|
||||
normalized := strings.TrimSpace(raw)
|
||||
|
||||
if isPortOnly(normalized) {
|
||||
normalized = "http://127.0.0.1:" + normalized
|
||||
}
|
||||
|
||||
normalized = strings.TrimSuffix(normalized, "/mcp")
|
||||
return strings.TrimSuffix(normalized, "/")
|
||||
}
|
||||
|
||||
func isPortOnly(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validateServerURL(raw string) (string, error) {
|
||||
baseURL := normalizeServerURL(raw)
|
||||
if baseURL != "" {
|
||||
|
||||
146
packages/browseros-agent/apps/cli/cmd/root_test.go
Normal file
146
packages/browseros-agent/apps/cli/cmd/root_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSetVersionUpdatesRootCommand(t *testing.T) {
|
||||
originalVersion := version
|
||||
originalRootVersion := rootCmd.Version
|
||||
t.Cleanup(func() {
|
||||
version = originalVersion
|
||||
rootCmd.Version = originalRootVersion
|
||||
})
|
||||
|
||||
SetVersion("1.2.3")
|
||||
|
||||
if version != "1.2.3" {
|
||||
t.Fatalf("version = %q, want %q", version, "1.2.3")
|
||||
}
|
||||
if rootCmd.Version != "1.2.3" {
|
||||
t.Fatalf("rootCmd.Version = %q, want %q", rootCmd.Version, "1.2.3")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{"empty args", nil, "unknown"},
|
||||
{"known command", []string{"health"}, "browseros-cli health"},
|
||||
{"unknown command", []string{"nonexistent"}, "unknown"},
|
||||
{"subcommand", []string{"bookmark", "search"}, "browseros-cli bookmark search"},
|
||||
{"known with extra args", []string{"snap", "--enhanced"}, "browseros-cli snap"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := commandName(tt.args)
|
||||
if got != tt.want {
|
||||
t.Errorf("commandName(%v) = %q, want %q", tt.args, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrimaryCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{"empty", nil, ""},
|
||||
{"root flag then command", []string{"--json", "update"}, "update"},
|
||||
{"subcommand", []string{"bookmark", "update"}, "bookmark"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := primaryCommand(tt.args); got != tt.want {
|
||||
t.Fatalf("primaryCommand(%v) = %q, want %q", tt.args, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestedBoolFlag(t *testing.T) {
|
||||
if !requestedBoolFlag([]string{"--json"}, "--json", false) {
|
||||
t.Fatal("requestedBoolFlag() = false, want true")
|
||||
}
|
||||
if !requestedBoolFlag([]string{"--debug=true"}, "--debug", false) {
|
||||
t.Fatal("requestedBoolFlag() with assignment = false, want true")
|
||||
}
|
||||
if requestedBoolFlag([]string{"--debug=false"}, "--debug", false) {
|
||||
t.Fatal("requestedBoolFlag() with false assignment = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldSkipAutomaticUpdates(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want bool
|
||||
}{
|
||||
{"short help flag", []string{"-h"}, true},
|
||||
{"help flag", []string{"--help"}, true},
|
||||
{"version flag", []string{"--version"}, true},
|
||||
{"update command", []string{"update"}, true},
|
||||
{"bookmark update subcommand", []string{"bookmark", "update"}, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := shouldSkipAutomaticUpdates(tt.args); got != tt.want {
|
||||
t.Fatalf("shouldSkipAutomaticUpdates(%v) = %t, want %t", tt.args, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrainAutomaticUpdateCheckWithTimeoutWaitsForCompletion(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
returned := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
drainAutomaticUpdateCheckWithTimeout(done, time.Second)
|
||||
close(returned)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-returned:
|
||||
t.Fatal("drainAutomaticUpdateCheckWithTimeout() returned before check completed")
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
}
|
||||
|
||||
close(done)
|
||||
|
||||
select {
|
||||
case <-returned:
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("drainAutomaticUpdateCheckWithTimeout() did not return after check completed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrainAutomaticUpdateCheckWithTimeoutStopsWaiting(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
returned := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
drainAutomaticUpdateCheckWithTimeout(done, 20*time.Millisecond)
|
||||
close(returned)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-returned:
|
||||
t.Fatal("drainAutomaticUpdateCheckWithTimeout() returned before timeout elapsed")
|
||||
case <-time.After(5 * time.Millisecond):
|
||||
}
|
||||
|
||||
select {
|
||||
case <-returned:
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("drainAutomaticUpdateCheckWithTimeout() did not return after timeout")
|
||||
}
|
||||
}
|
||||
179
packages/browseros-agent/apps/cli/cmd/update.go
Normal file
179
packages/browseros-agent/apps/cli/cmd/update.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"browseros-cli/output"
|
||||
"browseros-cli/update"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type updateManager interface {
|
||||
CheckNow(context.Context) (*update.CheckResult, error)
|
||||
Apply(context.Context, *update.CheckResult) error
|
||||
}
|
||||
|
||||
type updateOutcome struct {
|
||||
result *update.CheckResult
|
||||
applied bool
|
||||
canceled bool
|
||||
}
|
||||
|
||||
func init() {
|
||||
cmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Aliases: []string{"self-update", "upgrade"},
|
||||
Annotations: map[string]string{"group": "Setup:"},
|
||||
Short: "Check for and apply CLI updates",
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
checkOnly, _ := cmd.Flags().GetBool("check")
|
||||
yes, _ := cmd.Flags().GetBool("yes")
|
||||
|
||||
manager := update.NewManager(update.Options{
|
||||
CurrentVersion: version,
|
||||
JSONOutput: jsonOut,
|
||||
Debug: debug,
|
||||
Automatic: false,
|
||||
})
|
||||
outcome, err := runUpdateCommand(
|
||||
cmd.Context(),
|
||||
manager,
|
||||
checkOnly,
|
||||
yes,
|
||||
stdinIsInteractive(os.Stdin),
|
||||
os.Stdin,
|
||||
os.Stderr,
|
||||
)
|
||||
if err != nil {
|
||||
output.Error(err.Error(), 1)
|
||||
}
|
||||
printUpdateOutcome(outcome)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().Bool("check", false, "Check for updates without applying them")
|
||||
cmd.Flags().Bool("yes", false, "Apply update without prompting")
|
||||
|
||||
rootCmd.AddCommand(cmd)
|
||||
}
|
||||
|
||||
func runUpdateCommand(
|
||||
ctx context.Context,
|
||||
manager updateManager,
|
||||
checkOnly bool,
|
||||
yes bool,
|
||||
interactive bool,
|
||||
stdin io.Reader,
|
||||
stderr io.Writer,
|
||||
) (*updateOutcome, error) {
|
||||
result, err := manager.CheckNow(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
outcome := &updateOutcome{result: result}
|
||||
if checkOnly || !result.UpdateAvailable {
|
||||
return outcome, nil
|
||||
}
|
||||
if !yes {
|
||||
if !interactive {
|
||||
return nil, fmt.Errorf("update requires confirmation; rerun with --yes")
|
||||
}
|
||||
|
||||
confirmed, err := confirmUpdate(stdin, stderr, result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !confirmed {
|
||||
outcome.canceled = true
|
||||
return outcome, nil
|
||||
}
|
||||
}
|
||||
if err := manager.Apply(ctx, result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
outcome.applied = true
|
||||
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
func printUpdateOutcome(outcome *updateOutcome) {
|
||||
if jsonOut {
|
||||
output.JSONRaw(updateOutcomePayload(outcome))
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case outcome.applied:
|
||||
fmt.Printf("Updated browseros-cli to v%s\n", outcome.result.LatestVersion)
|
||||
case outcome.canceled:
|
||||
fmt.Println("Update canceled.")
|
||||
case outcome.result.UpdateAvailable:
|
||||
fmt.Println(update.FormatNotice(outcome.result.CurrentVersion, outcome.result.LatestVersion))
|
||||
case outcome.result != nil:
|
||||
fmt.Printf("browseros-cli is up to date (v%s)\n", outcome.result.CurrentVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func updateOutcomePayload(outcome *updateOutcome) map[string]any {
|
||||
payload := map[string]any{
|
||||
"applied": outcome.applied,
|
||||
}
|
||||
if outcome.canceled {
|
||||
payload["canceled"] = true
|
||||
}
|
||||
if outcome.result == nil {
|
||||
return payload
|
||||
}
|
||||
|
||||
payload["currentVersion"] = outcome.result.CurrentVersion
|
||||
payload["latestVersion"] = outcome.result.LatestVersion
|
||||
payload["updateAvailable"] = outcome.result.UpdateAvailable
|
||||
if outcome.result.Asset != nil {
|
||||
payload["asset"] = map[string]any{
|
||||
"filename": outcome.result.Asset.Filename,
|
||||
"url": outcome.result.Asset.URL,
|
||||
"archiveFormat": outcome.result.Asset.ArchiveFormat,
|
||||
}
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
func confirmUpdate(
|
||||
stdin io.Reader,
|
||||
stderr io.Writer,
|
||||
result *update.CheckResult,
|
||||
) (bool, error) {
|
||||
if _, err := fmt.Fprintf(
|
||||
stderr,
|
||||
"Install browseros-cli v%s over v%s? [y/N]: ",
|
||||
result.LatestVersion,
|
||||
result.CurrentVersion,
|
||||
); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
line, err := bufio.NewReader(stdin).ReadString('\n')
|
||||
if err != nil && err != io.EOF {
|
||||
return false, err
|
||||
}
|
||||
answer := strings.ToLower(strings.TrimSpace(line))
|
||||
|
||||
return answer == "y" || answer == "yes", nil
|
||||
}
|
||||
|
||||
func stdinIsInteractive(file *os.File) bool {
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return info.Mode()&os.ModeCharDevice != 0
|
||||
}
|
||||
176
packages/browseros-agent/apps/cli/cmd/update_test.go
Normal file
176
packages/browseros-agent/apps/cli/cmd/update_test.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"browseros-cli/update"
|
||||
)
|
||||
|
||||
func TestRunUpdateCommandCheckOnly(t *testing.T) {
|
||||
configRoot := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", configRoot)
|
||||
|
||||
manager := newTestUpdateManager(t)
|
||||
outcome, err := runUpdateCommand(
|
||||
context.Background(),
|
||||
manager,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
bytes.NewBufferString(""),
|
||||
&bytes.Buffer{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("runUpdateCommand() error = %v", err)
|
||||
}
|
||||
if outcome.applied {
|
||||
t.Fatal("runUpdateCommand() applied = true, want false")
|
||||
}
|
||||
if !outcome.result.UpdateAvailable {
|
||||
t.Fatal("runUpdateCommand() UpdateAvailable = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUpdateCommandRequiresYesWithoutTTY(t *testing.T) {
|
||||
configRoot := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", configRoot)
|
||||
|
||||
_, err := runUpdateCommand(
|
||||
context.Background(),
|
||||
newTestUpdateManager(t),
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
bytes.NewBufferString(""),
|
||||
&bytes.Buffer{},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("runUpdateCommand() error = nil, want confirmation error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUpdateCommandCancel(t *testing.T) {
|
||||
configRoot := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", configRoot)
|
||||
|
||||
stderr := &bytes.Buffer{}
|
||||
outcome, err := runUpdateCommand(
|
||||
context.Background(),
|
||||
newTestUpdateManager(t),
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
bytes.NewBufferString("n\n"),
|
||||
stderr,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("runUpdateCommand() error = %v", err)
|
||||
}
|
||||
if !outcome.canceled {
|
||||
t.Fatal("runUpdateCommand() canceled = false, want true")
|
||||
}
|
||||
if stderr.Len() == 0 {
|
||||
t.Fatal("confirm prompt was not written to stderr")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUpdateCommandYesAppliesWithoutPrompt(t *testing.T) {
|
||||
manager := &fakeUpdateManager{
|
||||
result: &update.CheckResult{
|
||||
CurrentVersion: "1.0.0",
|
||||
LatestVersion: "9.9.9",
|
||||
UpdateAvailable: true,
|
||||
Asset: &update.Asset{
|
||||
Filename: "browseros-cli_9.9.9_test.tar.gz",
|
||||
URL: "https://cdn.example.com/cli/v9.9.9/browseros-cli_9.9.9_test.tar.gz",
|
||||
ArchiveFormat: "tar.gz",
|
||||
SHA256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
},
|
||||
},
|
||||
}
|
||||
stderr := &bytes.Buffer{}
|
||||
|
||||
outcome, err := runUpdateCommand(
|
||||
context.Background(),
|
||||
manager,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
bytes.NewBufferString(""),
|
||||
stderr,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("runUpdateCommand() error = %v", err)
|
||||
}
|
||||
if !outcome.applied {
|
||||
t.Fatal("runUpdateCommand() applied = false, want true")
|
||||
}
|
||||
if manager.applyCalls != 1 {
|
||||
t.Fatalf("Apply() calls = %d, want 1", manager.applyCalls)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatal("prompt was written despite --yes")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeUpdateManager struct {
|
||||
result *update.CheckResult
|
||||
checkErr error
|
||||
applyErr error
|
||||
applyCalls int
|
||||
}
|
||||
|
||||
func (m *fakeUpdateManager) CheckNow(context.Context) (*update.CheckResult, error) {
|
||||
if m.checkErr != nil {
|
||||
return nil, m.checkErr
|
||||
}
|
||||
if m.result == nil {
|
||||
return nil, errors.New("missing check result")
|
||||
}
|
||||
return m.result, nil
|
||||
}
|
||||
|
||||
func (m *fakeUpdateManager) Apply(context.Context, *update.CheckResult) error {
|
||||
m.applyCalls++
|
||||
return m.applyErr
|
||||
}
|
||||
|
||||
func newTestUpdateManager(t *testing.T) *update.Manager {
|
||||
t.Helper()
|
||||
|
||||
key, err := update.PlatformKey(runtime.GOOS, runtime.GOARCH)
|
||||
if err != nil {
|
||||
t.Fatalf("PlatformKey() error = %v", err)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"version":"9.9.9",
|
||||
"published_at":"2026-03-27T19:00:00Z",
|
||||
"tag":"browseros-cli-v9.9.9",
|
||||
"assets":{
|
||||
"` + key + `":{
|
||||
"filename":"browseros-cli_9.9.9_test.tar.gz",
|
||||
"url":"https://cdn.example.com/cli/v9.9.9/browseros-cli_9.9.9_test.tar.gz",
|
||||
"archive_format":"tar.gz",
|
||||
"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
}
|
||||
}
|
||||
}`))
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
return update.NewManager(update.Options{
|
||||
CurrentVersion: "1.0.0",
|
||||
ManifestURL: server.URL,
|
||||
Automatic: false,
|
||||
HTTPClient: server.Client(),
|
||||
})
|
||||
}
|
||||
@@ -4,20 +4,28 @@ go 1.25.7
|
||||
|
||||
require (
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/minio/selfupdate v0.6.0
|
||||
github.com/modelcontextprotocol/go-sdk v1.4.0
|
||||
github.com/posthog/posthog-go v1.11.2
|
||||
github.com/spf13/cobra v1.10.2
|
||||
golang.org/x/mod v0.34.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
aead.dev/minisign v0.2.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modelcontextprotocol/go-sdk v1.4.0 // indirect
|
||||
github.com/segmentio/asm v1.1.3 // indirect
|
||||
github.com/segmentio/encoding v0.5.3 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b // indirect
|
||||
golang.org/x/oauth2 v0.34.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
aead.dev/minisign v0.2.0 h1:kAWrq/hBRu4AARY6AlciO83xhNnW9UaC8YipS2uhLPk=
|
||||
aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
@@ -10,8 +24,14 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
|
||||
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
|
||||
github.com/modelcontextprotocol/go-sdk v1.4.0 h1:u0kr8lbJc1oBcawK7Df+/ajNMpIDFE41OEPxdeTLOn8=
|
||||
github.com/modelcontextprotocol/go-sdk v1.4.0/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/posthog/posthog-go v1.11.2 h1:ApKTtOhIeWhUBc4ByO+mlbg2o0iZaEGJnJHX2QDnn5Q=
|
||||
github.com/posthog/posthog-go v1.11.2/go.mod h1:xsVOW9YImilUcazwPNEq4PJDqEZf2KeCS758zXjwkPg=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||
@@ -21,17 +41,39 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b h1:QAqMVf3pSa6eeTsuklijukjXBlj7Es2QQplab+/RbQ4=
|
||||
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
||||
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
2
packages/browseros-agent/apps/cli/npm/.npmignore
Normal file
2
packages/browseros-agent/apps/cli/npm/.npmignore
Normal file
@@ -0,0 +1,2 @@
|
||||
.binary/
|
||||
node_modules/
|
||||
81
packages/browseros-agent/apps/cli/npm/README.md
Normal file
81
packages/browseros-agent/apps/cli/npm/README.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# browseros-cli
|
||||
|
||||
Command-line interface for controlling BrowserOS -- launch and automate the browser from the terminal.
|
||||
|
||||
## Installation
|
||||
|
||||
**Zero install (recommended):**
|
||||
|
||||
```bash
|
||||
npx browseros-cli --help
|
||||
```
|
||||
|
||||
**Global install:**
|
||||
|
||||
```bash
|
||||
npm install -g browseros-cli
|
||||
```
|
||||
|
||||
**Shell script fallback:**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://cdn.browseros.com/cli/install.sh | bash
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Download BrowserOS
|
||||
browseros-cli install
|
||||
|
||||
# Start BrowserOS
|
||||
browseros-cli launch
|
||||
|
||||
# Auto-configure MCP settings for your AI tools
|
||||
browseros-cli init --auto
|
||||
|
||||
# Verify everything is working
|
||||
browseros-cli health
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Navigation
|
||||
|
||||
```bash
|
||||
browseros-cli navigate "https://example.com"
|
||||
```
|
||||
|
||||
### Observation
|
||||
|
||||
```bash
|
||||
browseros-cli snapshot # Get the accessibility tree of the current page
|
||||
browseros-cli console-logs # View browser console output
|
||||
```
|
||||
|
||||
### Screenshots
|
||||
|
||||
```bash
|
||||
browseros-cli screenshot # Capture the current page
|
||||
```
|
||||
|
||||
### Input
|
||||
|
||||
```bash
|
||||
browseros-cli click 42 # Click an element by its node ID
|
||||
browseros-cli fill 85 "query" # Type text into an input field
|
||||
```
|
||||
|
||||
### Agent Mode
|
||||
|
||||
```bash
|
||||
browseros-cli agent "Search for flights to Tokyo"
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
Full documentation is available at [browseros.com](https://browseros.com).
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
32
packages/browseros-agent/apps/cli/npm/bin/browseros-cli.js
Normal file
32
packages/browseros-agent/apps/cli/npm/bin/browseros-cli.js
Normal file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { execFileSync, spawnSync } = require('node:child_process')
|
||||
const path = require('node:path')
|
||||
const fs = require('node:fs')
|
||||
|
||||
const BINARY_DIR = path.join(__dirname, '..', '.binary')
|
||||
const EXT = process.platform === 'win32' ? '.exe' : ''
|
||||
const BIN_PATH = path.join(BINARY_DIR, `browseros-cli${EXT}`)
|
||||
|
||||
if (!fs.existsSync(BIN_PATH)) {
|
||||
console.error('browseros-cli: binary not found, downloading...')
|
||||
try {
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[path.join(__dirname, '..', 'scripts', 'postinstall.js')],
|
||||
{ stdio: 'inherit', env: { ...process.env, BROWSEROS_NPM_FORCE: '1' } },
|
||||
)
|
||||
} catch {
|
||||
console.error(
|
||||
'browseros-cli: failed to download binary. Try reinstalling:\n npm install -g browseros-cli',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
const result = spawnSync(BIN_PATH, process.argv.slice(2), {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, BROWSEROS_INSTALL_METHOD: 'npm' },
|
||||
})
|
||||
|
||||
process.exit(result.status ?? 1)
|
||||
45
packages/browseros-agent/apps/cli/npm/package.json
Normal file
45
packages/browseros-agent/apps/cli/npm/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "browseros-cli",
|
||||
"version": "0.2.0",
|
||||
"description": "Command-line interface for controlling BrowserOS — launch and automate the browser from the terminal",
|
||||
"bin": {
|
||||
"browseros-cli": "bin/browseros-cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/postinstall.js"
|
||||
},
|
||||
"keywords": [
|
||||
"browseros",
|
||||
"cli",
|
||||
"browser",
|
||||
"automation",
|
||||
"mcp",
|
||||
"ai-agent",
|
||||
"model-context-protocol"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/browseros-ai/BrowserOS",
|
||||
"directory": "packages/browseros-agent/apps/cli/npm"
|
||||
},
|
||||
"homepage": "https://browseros.com",
|
||||
"bugs": "https://github.com/browseros-ai/BrowserOS/issues",
|
||||
"license": "MIT",
|
||||
"os": [
|
||||
"darwin",
|
||||
"linux",
|
||||
"win32"
|
||||
],
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"files": [
|
||||
"bin/",
|
||||
"scripts/",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
142
packages/browseros-agent/apps/cli/npm/scripts/postinstall.js
Normal file
142
packages/browseros-agent/apps/cli/npm/scripts/postinstall.js
Normal file
@@ -0,0 +1,142 @@
|
||||
const https = require('node:https')
|
||||
const http = require('node:http')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { execSync } = require('node:child_process')
|
||||
const { createHash } = require('node:crypto')
|
||||
|
||||
const VERSION = require('../package.json').version
|
||||
const GITHUB_RELEASE_BASE = `https://github.com/browseros-ai/BrowserOS/releases/download/browseros-cli-v${VERSION}`
|
||||
const BINARY_DIR = path.join(__dirname, '..', '.binary')
|
||||
const EXT = process.platform === 'win32' ? '.exe' : ''
|
||||
const BINARY_PATH = path.join(BINARY_DIR, `browseros-cli${EXT}`)
|
||||
|
||||
if (process.env.CI && !process.env.BROWSEROS_NPM_FORCE) {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const PLATFORM_MAP = { darwin: 'darwin', linux: 'linux', win32: 'windows' }
|
||||
const ARCH_MAP = { x64: 'amd64', arm64: 'arm64' }
|
||||
|
||||
const platform = PLATFORM_MAP[process.platform]
|
||||
const arch = ARCH_MAP[process.arch]
|
||||
|
||||
if (!platform || !arch) {
|
||||
console.error(
|
||||
`browseros-cli: unsupported platform ${process.platform}/${process.arch}`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const isWindows = platform === 'windows'
|
||||
const archiveExt = isWindows ? 'zip' : 'tar.gz'
|
||||
const archiveName = `browseros-cli_${VERSION}_${platform}_${arch}.${archiveExt}`
|
||||
const archiveURL = `${GITHUB_RELEASE_BASE}/${archiveName}`
|
||||
const checksumURL = `${GITHUB_RELEASE_BASE}/checksums.txt`
|
||||
|
||||
const MAX_REDIRECTS = 5
|
||||
|
||||
function download(url, redirects = 0) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (redirects > MAX_REDIRECTS) {
|
||||
return reject(new Error(`Too many redirects for ${url}`))
|
||||
}
|
||||
const client = url.startsWith('https') ? https : http
|
||||
client
|
||||
.get(url, { headers: { 'User-Agent': 'browseros-cli-npm' } }, (res) => {
|
||||
if (
|
||||
res.statusCode >= 300 &&
|
||||
res.statusCode < 400 &&
|
||||
res.headers.location
|
||||
) {
|
||||
return download(res.headers.location, redirects + 1).then(
|
||||
resolve,
|
||||
reject,
|
||||
)
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
return reject(new Error(`HTTP ${res.statusCode} for ${url}`))
|
||||
}
|
||||
const chunks = []
|
||||
res.on('data', (chunk) => chunks.push(chunk))
|
||||
res.on('end', () => resolve(Buffer.concat(chunks)))
|
||||
res.on('error', reject)
|
||||
})
|
||||
.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(
|
||||
`browseros-cli: downloading v${VERSION} for ${platform}/${arch}...`,
|
||||
)
|
||||
|
||||
const [archiveBuffer, checksumBuffer] = await Promise.all([
|
||||
download(archiveURL),
|
||||
download(checksumURL).catch(() => null),
|
||||
])
|
||||
|
||||
if (checksumBuffer) {
|
||||
const checksumText = checksumBuffer.toString('utf-8')
|
||||
const expectedLine = checksumText
|
||||
.split('\n')
|
||||
.find((l) => l.includes(archiveName))
|
||||
if (expectedLine) {
|
||||
const expected = expectedLine.split(/\s+/)[0]
|
||||
const actual = createHash('sha256').update(archiveBuffer).digest('hex')
|
||||
if (actual !== expected) {
|
||||
console.error(
|
||||
`browseros-cli: checksum mismatch!\n expected: ${expected}\n got: ${actual}`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('browseros-cli: checksum verified.')
|
||||
} else {
|
||||
console.warn(
|
||||
'browseros-cli: warning: checksum entry not found in checksums.txt, skipping verification.',
|
||||
)
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
'browseros-cli: warning: could not fetch checksums.txt, skipping verification.',
|
||||
)
|
||||
}
|
||||
|
||||
fs.mkdirSync(BINARY_DIR, { recursive: true })
|
||||
const tmpArchive = path.join(BINARY_DIR, archiveName)
|
||||
fs.writeFileSync(tmpArchive, archiveBuffer)
|
||||
|
||||
if (isWindows) {
|
||||
execSync(
|
||||
`powershell -Command "Expand-Archive -Force -Path '${tmpArchive}' -DestinationPath '${BINARY_DIR}'"`,
|
||||
{ stdio: 'inherit' },
|
||||
)
|
||||
} else {
|
||||
execSync(`tar -xzf "${tmpArchive}" -C "${BINARY_DIR}"`, {
|
||||
stdio: 'inherit',
|
||||
})
|
||||
}
|
||||
|
||||
fs.unlinkSync(tmpArchive)
|
||||
|
||||
if (!fs.existsSync(BINARY_PATH)) {
|
||||
console.error(
|
||||
`browseros-cli: binary not found after extraction at ${BINARY_PATH}`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!isWindows) {
|
||||
fs.chmodSync(BINARY_PATH, 0o755)
|
||||
}
|
||||
|
||||
console.log(`browseros-cli: installed v${VERSION} successfully.`)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`browseros-cli: installation failed: ${err.message}`)
|
||||
console.error(
|
||||
'You can install manually: curl -fsSL https://cdn.browseros.com/cli/install.sh | bash',
|
||||
)
|
||||
process.exit(1)
|
||||
})
|
||||
115
packages/browseros-agent/apps/cli/scripts/install.ps1
Normal file
115
packages/browseros-agent/apps/cli/scripts/install.ps1
Normal file
@@ -0,0 +1,115 @@
|
||||
#
|
||||
# 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 — older PS 5.1 defaults to TLS 1.0
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
|
||||
$CdnBase = "https://cdn.browseros.com/cli"
|
||||
$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..."
|
||||
$Version = (Invoke-WebRequest -Uri "$CdnBase/latest/version.txt" -UseBasicParsing).Content.Trim()
|
||||
if (-not $Version) {
|
||||
Write-Error "Could not determine latest version. Try: -Version 0.1.0"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
if ($Version -notmatch '^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$') {
|
||||
Write-Error "Unexpected version format: '$Version'"
|
||||
exit 1
|
||||
}
|
||||
|
||||
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 ─────────────────────────────────────────────────────
|
||||
|
||||
$Filename = "${Binary}_${Version}_windows_${Arch}.zip"
|
||||
$Url = "$CdnBase/v$Version/$Filename"
|
||||
$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
|
||||
|
||||
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."
|
||||
151
packages/browseros-agent/apps/cli/scripts/install.sh
Executable file
151
packages/browseros-agent/apps/cli/scripts/install.sh
Executable file
@@ -0,0 +1,151 @@
|
||||
#!/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
|
||||
|
||||
CDN_BASE="https://cdn.browseros.com/cli"
|
||||
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
|
||||
VERSION=$(curl -fsSL "${CDN_BASE}/latest/version.txt" | tr -d '[:space:]')
|
||||
|
||||
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
|
||||
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
|
||||
echo "Error: unexpected version format: '$VERSION'" >&2
|
||||
exit 1
|
||||
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"
|
||||
URL="${CDN_BASE}/v${VERSION}/${FILENAME}"
|
||||
CHECKSUM_URL="${CDN_BASE}/v${VERSION}/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."
|
||||
49
packages/browseros-agent/apps/cli/update/apply.go
Normal file
49
packages/browseros-agent/apps/cli/update/apply.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/minio/selfupdate"
|
||||
)
|
||||
|
||||
func CheckPermissions(targetPath string) error {
|
||||
options := selfupdate.Options{TargetPath: targetPath}
|
||||
return options.CheckPermissions()
|
||||
}
|
||||
|
||||
func VerifyChecksum(data []byte, expectedHex string) error {
|
||||
expected, err := decodeChecksum(expectedHex)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actual := sha256.Sum256(data)
|
||||
if !bytes.Equal(actual[:], expected) {
|
||||
return fmt.Errorf(
|
||||
"checksum mismatch: expected %s, got %s",
|
||||
hex.EncodeToString(expected),
|
||||
hex.EncodeToString(actual[:]),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ApplyBinary(binary []byte, targetPath string) error {
|
||||
options := selfupdate.Options{TargetPath: targetPath}
|
||||
err := selfupdate.Apply(bytes.NewReader(binary), options)
|
||||
if rollbackErr := selfupdate.RollbackError(err); rollbackErr != nil {
|
||||
return fmt.Errorf("update failed and rollback failed: %w", rollbackErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func decodeChecksum(checksumHex string) ([]byte, error) {
|
||||
value := strings.TrimSpace(checksumHex)
|
||||
if value == "" {
|
||||
return nil, fmt.Errorf("missing checksum")
|
||||
}
|
||||
return hex.DecodeString(value)
|
||||
}
|
||||
138
packages/browseros-agent/apps/cli/update/archive.go
Normal file
138
packages/browseros-agent/apps/cli/update/archive.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const maxAssetSize = 64 << 20
|
||||
const maxBinarySize = 256 << 20
|
||||
|
||||
func DownloadAsset(ctx context.Context, client *http.Client, asset Asset) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, asset.URL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("update download returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return readAssetBytes(resp.Body)
|
||||
}
|
||||
|
||||
func readAssetBytes(reader io.Reader) ([]byte, error) {
|
||||
limited := io.LimitReader(reader, maxAssetSize+1)
|
||||
data, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) > maxAssetSize {
|
||||
return nil, fmt.Errorf("update asset exceeds %d bytes", maxAssetSize)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func ExtractBinary(archive []byte, format string) ([]byte, error) {
|
||||
switch format {
|
||||
case "tar.gz":
|
||||
return extractTarGzBinary(archive)
|
||||
case "zip":
|
||||
return extractZipBinary(archive)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported archive format %q", format)
|
||||
}
|
||||
}
|
||||
|
||||
func extractTarGzBinary(archive []byte) ([]byte, error) {
|
||||
gzipReader, err := gzip.NewReader(bytes.NewReader(archive))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer gzipReader.Close()
|
||||
|
||||
tarReader := tar.NewReader(gzipReader)
|
||||
return readTarBinary(tarReader)
|
||||
}
|
||||
|
||||
func readTarBinary(reader *tar.Reader) ([]byte, error) {
|
||||
var binary []byte
|
||||
|
||||
for {
|
||||
header, err := reader.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if header.Typeflag != tar.TypeReg {
|
||||
continue
|
||||
}
|
||||
if binary != nil {
|
||||
return nil, fmt.Errorf("archive contains multiple files; expected exactly one binary")
|
||||
}
|
||||
|
||||
binary, err = io.ReadAll(io.LimitReader(reader, maxBinarySize+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(binary) > maxBinarySize {
|
||||
return nil, fmt.Errorf("extracted binary exceeds %d bytes", maxBinarySize)
|
||||
}
|
||||
}
|
||||
|
||||
if binary == nil {
|
||||
return nil, fmt.Errorf("archive does not contain a file")
|
||||
}
|
||||
|
||||
return binary, nil
|
||||
}
|
||||
|
||||
func extractZipBinary(archive []byte) ([]byte, error) {
|
||||
reader, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var binary []byte
|
||||
for _, file := range reader.File {
|
||||
if file.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
if binary != nil {
|
||||
return nil, fmt.Errorf("archive contains multiple files; expected exactly one binary")
|
||||
}
|
||||
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
binary, err = io.ReadAll(io.LimitReader(rc, maxBinarySize+1))
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(binary) > maxBinarySize {
|
||||
return nil, fmt.Errorf("extracted binary exceeds %d bytes", maxBinarySize)
|
||||
}
|
||||
}
|
||||
|
||||
if binary == nil {
|
||||
return nil, fmt.Errorf("archive does not contain a file")
|
||||
}
|
||||
|
||||
return binary, nil
|
||||
}
|
||||
168
packages/browseros-agent/apps/cli/update/archive_test.go
Normal file
168
packages/browseros-agent/apps/cli/update/archive_test.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractBinaryTarGz(t *testing.T) {
|
||||
archive := createTarGz(t, map[string]string{"browseros-cli": "new-binary"})
|
||||
|
||||
binary, err := ExtractBinary(archive, "tar.gz")
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractBinary() error = %v", err)
|
||||
}
|
||||
if string(binary) != "new-binary" {
|
||||
t.Fatalf("ExtractBinary() = %q, want %q", string(binary), "new-binary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractBinaryZip(t *testing.T) {
|
||||
archive := createZip(t, map[string]string{"browseros-cli.exe": "new-binary"})
|
||||
|
||||
binary, err := ExtractBinary(archive, "zip")
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractBinary() error = %v", err)
|
||||
}
|
||||
if string(binary) != "new-binary" {
|
||||
t.Fatalf("ExtractBinary() = %q, want %q", string(binary), "new-binary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractBinaryTarGzRejectsMultipleFiles(t *testing.T) {
|
||||
archive := createTarGz(t, map[string]string{
|
||||
"browseros-cli": "new-binary",
|
||||
"browseros-cli.sig": "signature",
|
||||
})
|
||||
|
||||
_, err := ExtractBinary(archive, "tar.gz")
|
||||
if err == nil {
|
||||
t.Fatal("ExtractBinary() error = nil, want multiple files error")
|
||||
}
|
||||
if err.Error() != "archive contains multiple files; expected exactly one binary" {
|
||||
t.Fatalf("ExtractBinary() error = %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyChecksumValid(t *testing.T) {
|
||||
data := []byte("some-data")
|
||||
sum := sha256.Sum256(data)
|
||||
if err := VerifyChecksum(data, hex.EncodeToString(sum[:])); err != nil {
|
||||
t.Fatalf("VerifyChecksum() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyChecksumMismatch(t *testing.T) {
|
||||
data := []byte("some-data")
|
||||
badChecksum := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
if err := VerifyChecksum(data, badChecksum); err == nil {
|
||||
t.Fatal("VerifyChecksum() error = nil, want mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyBinary(t *testing.T) {
|
||||
targetPath := filepath.Join(t.TempDir(), "browseros-cli")
|
||||
if err := os.WriteFile(targetPath, []byte("old-binary"), 0755); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
newBinary := []byte("new-binary")
|
||||
if err := ApplyBinary(newBinary, targetPath); err != nil {
|
||||
t.Fatalf("ApplyBinary() error = %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(targetPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
if string(data) != "new-binary" {
|
||||
t.Fatalf("updated binary = %q, want %q", string(data), "new-binary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyThenApplyIntegration(t *testing.T) {
|
||||
archive := createTarGz(t, map[string]string{"browseros-cli": "updated-binary"})
|
||||
archiveSum := sha256.Sum256(archive)
|
||||
|
||||
if err := VerifyChecksum(archive, hex.EncodeToString(archiveSum[:])); err != nil {
|
||||
t.Fatalf("VerifyChecksum(archive) error = %v", err)
|
||||
}
|
||||
|
||||
binary, err := ExtractBinary(archive, "tar.gz")
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractBinary() error = %v", err)
|
||||
}
|
||||
|
||||
targetPath := filepath.Join(t.TempDir(), "browseros-cli")
|
||||
if err := os.WriteFile(targetPath, []byte("old"), 0755); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
if err := ApplyBinary(binary, targetPath); err != nil {
|
||||
t.Fatalf("ApplyBinary() error = %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(targetPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
if string(data) != "updated-binary" {
|
||||
t.Fatalf("binary = %q, want %q", string(data), "updated-binary")
|
||||
}
|
||||
}
|
||||
|
||||
func createTarGz(t *testing.T, files map[string]string) []byte {
|
||||
t.Helper()
|
||||
|
||||
var buffer bytes.Buffer
|
||||
gzipWriter := gzip.NewWriter(&buffer)
|
||||
tarWriter := tar.NewWriter(gzipWriter)
|
||||
for name, body := range files {
|
||||
data := []byte(body)
|
||||
if err := tarWriter.WriteHeader(&tar.Header{
|
||||
Name: name,
|
||||
Mode: 0755,
|
||||
Size: int64(len(data)),
|
||||
}); err != nil {
|
||||
t.Fatalf("WriteHeader() error = %v", err)
|
||||
}
|
||||
if _, err := tarWriter.Write(data); err != nil {
|
||||
t.Fatalf("Write() error = %v", err)
|
||||
}
|
||||
}
|
||||
if err := tarWriter.Close(); err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
if err := gzipWriter.Close(); err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
|
||||
return buffer.Bytes()
|
||||
}
|
||||
|
||||
func createZip(t *testing.T, files map[string]string) []byte {
|
||||
t.Helper()
|
||||
|
||||
var buffer bytes.Buffer
|
||||
zipWriter := zip.NewWriter(&buffer)
|
||||
for name, body := range files {
|
||||
fileWriter, err := zipWriter.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
if _, err := fileWriter.Write([]byte(body)); err != nil {
|
||||
t.Fatalf("Write() error = %v", err)
|
||||
}
|
||||
}
|
||||
if err := zipWriter.Close(); err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
|
||||
return buffer.Bytes()
|
||||
}
|
||||
273
packages/browseros-agent/apps/cli/update/manager.go
Normal file
273
packages/browseros-agent/apps/cli/update/manager.go
Normal file
@@ -0,0 +1,273 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultManifestURL = "https://cdn.browseros.com/cli/latest/manifest.json"
|
||||
DefaultCheckTTL = 24 * time.Hour
|
||||
DefaultHTTPTimeout = 2 * time.Second
|
||||
DefaultDownloadTimeout = 5 * time.Minute
|
||||
SkipCheckEnv = "BROWSEROS_SKIP_UPDATE_CHECK"
|
||||
InstallMethodEnv = "BROWSEROS_INSTALL_METHOD"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
CurrentVersion string
|
||||
ManifestURL string
|
||||
CheckTTL time.Duration
|
||||
HTTPTimeout time.Duration
|
||||
DownloadTimeout time.Duration
|
||||
JSONOutput bool
|
||||
Debug bool
|
||||
Automatic bool
|
||||
HTTPClient *http.Client
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
options Options
|
||||
state *State
|
||||
}
|
||||
|
||||
type CheckResult struct {
|
||||
CurrentVersion string `json:"current_version"`
|
||||
LatestVersion string `json:"latest_version"`
|
||||
LatestPublishedAt string `json:"latest_published_at,omitempty"`
|
||||
UpdateAvailable bool `json:"update_available"`
|
||||
CheckedAt time.Time `json:"checked_at"`
|
||||
Asset *Asset `json:"asset,omitempty"`
|
||||
}
|
||||
|
||||
func NewManager(options Options) *Manager {
|
||||
if options.ManifestURL == "" {
|
||||
options.ManifestURL = DefaultManifestURL
|
||||
}
|
||||
if options.CheckTTL == 0 {
|
||||
options.CheckTTL = DefaultCheckTTL
|
||||
}
|
||||
if options.HTTPTimeout == 0 {
|
||||
options.HTTPTimeout = DefaultHTTPTimeout
|
||||
}
|
||||
if options.DownloadTimeout == 0 {
|
||||
options.DownloadTimeout = DefaultDownloadTimeout
|
||||
}
|
||||
if options.Now == nil {
|
||||
options.Now = time.Now
|
||||
}
|
||||
if options.HTTPClient == nil {
|
||||
options.HTTPClient = &http.Client{}
|
||||
}
|
||||
|
||||
state, err := LoadState()
|
||||
if err != nil {
|
||||
state = &State{}
|
||||
}
|
||||
|
||||
return &Manager{
|
||||
options: options,
|
||||
state: state,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) CachedNotice() string {
|
||||
if !m.AutomaticEnabled() || m.state == nil || m.state.LatestVersion == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
comparison, err := CompareVersions(m.options.CurrentVersion, m.state.LatestVersion)
|
||||
if err != nil || comparison >= 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return FormatNotice(m.options.CurrentVersion, m.state.LatestVersion)
|
||||
}
|
||||
|
||||
func (m *Manager) AutomaticEnabled() bool {
|
||||
if !m.options.Automatic || m.options.JSONOutput {
|
||||
return false
|
||||
}
|
||||
if os.Getenv(SkipCheckEnv) != "" {
|
||||
return false
|
||||
}
|
||||
if installedViaPackageManager() {
|
||||
return false
|
||||
}
|
||||
return IsReleaseVersion(m.options.CurrentVersion)
|
||||
}
|
||||
|
||||
func installedViaPackageManager() bool {
|
||||
method := os.Getenv(InstallMethodEnv)
|
||||
switch method {
|
||||
case "npm", "brew", "homebrew":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Manager) ShouldCheck() bool {
|
||||
if !m.AutomaticEnabled() {
|
||||
return false
|
||||
}
|
||||
return m.state.IsStale(m.options.Now(), m.options.CheckTTL)
|
||||
}
|
||||
|
||||
func (m *Manager) StartBackgroundCheck(ctx context.Context) <-chan struct{} {
|
||||
done := make(chan struct{})
|
||||
if !m.ShouldCheck() {
|
||||
close(done)
|
||||
return done
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
_, _ = m.CheckNow(ctx)
|
||||
}()
|
||||
|
||||
return done
|
||||
}
|
||||
|
||||
func (m *Manager) CheckNow(ctx context.Context) (*CheckResult, error) {
|
||||
if !IsReleaseVersion(m.options.CurrentVersion) {
|
||||
return nil, fmt.Errorf("self-update is unavailable for non-release build %q", m.options.CurrentVersion)
|
||||
}
|
||||
|
||||
checkCtx, cancel := context.WithTimeout(ctx, m.options.HTTPTimeout)
|
||||
defer cancel()
|
||||
|
||||
manifest, err := FetchManifest(checkCtx, cloneHTTPClient(m.options.HTTPClient), m.options.ManifestURL)
|
||||
if err != nil {
|
||||
m.recordError(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
asset, err := SelectAsset(manifest, runtime.GOOS, runtime.GOARCH)
|
||||
if err != nil {
|
||||
m.recordError(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
comparison, err := CompareVersions(m.options.CurrentVersion, manifest.Version)
|
||||
if err != nil {
|
||||
m.recordError(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &CheckResult{
|
||||
CurrentVersion: m.options.CurrentVersion,
|
||||
LatestVersion: manifest.Version,
|
||||
LatestPublishedAt: manifest.PublishedAt,
|
||||
UpdateAvailable: comparison < 0,
|
||||
CheckedAt: m.options.Now(),
|
||||
}
|
||||
if result.UpdateAvailable {
|
||||
assetCopy := asset
|
||||
result.Asset = &assetCopy
|
||||
}
|
||||
|
||||
m.state = &State{
|
||||
LastCheckedAt: result.CheckedAt,
|
||||
LatestVersion: manifest.Version,
|
||||
LatestPublishedAt: manifest.PublishedAt,
|
||||
AssetURL: asset.URL,
|
||||
}
|
||||
_ = SaveState(m.state)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Apply(ctx context.Context, result *CheckResult) error {
|
||||
if result == nil || !result.UpdateAvailable || result.Asset == nil {
|
||||
return fmt.Errorf("browseros-cli is already up to date")
|
||||
}
|
||||
|
||||
downloadCtx, cancel := context.WithTimeout(ctx, m.options.DownloadTimeout)
|
||||
defer cancel()
|
||||
|
||||
archive, err := DownloadAsset(downloadCtx, cloneHTTPClient(m.options.HTTPClient), *result.Asset)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := VerifyChecksum(archive, result.Asset.SHA256); err != nil {
|
||||
return err
|
||||
}
|
||||
binary, err := ExtractBinary(archive, result.Asset.ArchiveFormat)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targetPath, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := CheckPermissions(targetPath); err != nil {
|
||||
return fmt.Errorf(
|
||||
"cannot replace %s: %w\n\nReinstall with the installer script or move the binary to a writable location.",
|
||||
targetPath,
|
||||
err,
|
||||
)
|
||||
}
|
||||
if err := ApplyBinary(binary, targetPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.saveAppliedState(result)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func FormatNotice(currentVersion, latestVersion string) string {
|
||||
notice := fmt.Sprintf(
|
||||
"Update available: browseros-cli v%s (current v%s)",
|
||||
latestVersion,
|
||||
currentVersion,
|
||||
)
|
||||
|
||||
switch os.Getenv(InstallMethodEnv) {
|
||||
case "npm":
|
||||
notice += "\nRun `npm update -g browseros-cli` to upgrade."
|
||||
case "brew", "homebrew":
|
||||
notice += "\nRun `brew upgrade browseros-cli` to upgrade."
|
||||
default:
|
||||
notice += "\nRun `browseros-cli update` to upgrade."
|
||||
}
|
||||
|
||||
return notice
|
||||
}
|
||||
|
||||
func (m *Manager) recordError(err error) {
|
||||
state := &State{}
|
||||
if m.state != nil {
|
||||
*state = *m.state
|
||||
}
|
||||
state.CheckError = err.Error()
|
||||
m.state = state
|
||||
_ = SaveState(state)
|
||||
}
|
||||
|
||||
func (m *Manager) saveAppliedState(result *CheckResult) {
|
||||
state := &State{
|
||||
LastCheckedAt: m.options.Now(),
|
||||
LatestVersion: result.LatestVersion,
|
||||
LatestPublishedAt: result.LatestPublishedAt,
|
||||
AssetURL: result.Asset.URL,
|
||||
}
|
||||
m.state = state
|
||||
_ = SaveState(state)
|
||||
}
|
||||
|
||||
func cloneHTTPClient(client *http.Client) *http.Client {
|
||||
if client == nil {
|
||||
return &http.Client{}
|
||||
}
|
||||
|
||||
cloned := *client
|
||||
cloned.Timeout = 0
|
||||
return &cloned
|
||||
}
|
||||
188
packages/browseros-agent/apps/cli/update/manager_test.go
Normal file
188
packages/browseros-agent/apps/cli/update/manager_test.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestManagerCachedNotice(t *testing.T) {
|
||||
manager := NewManager(Options{
|
||||
CurrentVersion: "1.0.0",
|
||||
Automatic: true,
|
||||
})
|
||||
manager.state = &State{LatestVersion: "1.2.0"}
|
||||
|
||||
notice := manager.CachedNotice()
|
||||
if notice == "" {
|
||||
t.Fatal("CachedNotice() returned empty notice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerShouldCheck(t *testing.T) {
|
||||
manager := NewManager(Options{
|
||||
CurrentVersion: "1.0.0",
|
||||
Automatic: true,
|
||||
CheckTTL: time.Minute,
|
||||
Now: func() time.Time {
|
||||
return time.Unix(1000, 0).UTC()
|
||||
},
|
||||
})
|
||||
manager.state = &State{LastCheckedAt: time.Unix(0, 0).UTC()}
|
||||
|
||||
if !manager.ShouldCheck() {
|
||||
t.Fatal("ShouldCheck() = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerCheckNow(t *testing.T) {
|
||||
configRoot := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", configRoot)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"version":"9.9.9",
|
||||
"published_at":"2026-03-27T19:00:00Z",
|
||||
"tag":"browseros-cli-v9.9.9",
|
||||
"assets":{
|
||||
"` + runtimePlatformKey(t) + `":{
|
||||
"filename":"browseros-cli_9.9.9_test.tar.gz",
|
||||
"url":"https://cdn.example.com/cli/v9.9.9/browseros-cli_9.9.9_test.tar.gz",
|
||||
"archive_format":"tar.gz",
|
||||
"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
}
|
||||
}
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
manager := NewManager(Options{
|
||||
CurrentVersion: "1.0.0",
|
||||
ManifestURL: server.URL,
|
||||
Automatic: false,
|
||||
HTTPClient: server.Client(),
|
||||
Now: func() time.Time {
|
||||
return time.Unix(100, 0).UTC()
|
||||
},
|
||||
})
|
||||
|
||||
result, err := manager.CheckNow(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("CheckNow() error = %v", err)
|
||||
}
|
||||
if !result.UpdateAvailable {
|
||||
t.Fatal("CheckNow() UpdateAvailable = false, want true")
|
||||
}
|
||||
if result.LatestPublishedAt != "2026-03-27T19:00:00Z" {
|
||||
t.Fatalf(
|
||||
"CheckNow() LatestPublishedAt = %q, want %q",
|
||||
result.LatestPublishedAt,
|
||||
"2026-03-27T19:00:00Z",
|
||||
)
|
||||
}
|
||||
if manager.state.LatestPublishedAt != "2026-03-27T19:00:00Z" {
|
||||
t.Fatalf(
|
||||
"state LatestPublishedAt = %q, want %q",
|
||||
manager.state.LatestPublishedAt,
|
||||
"2026-03-27T19:00:00Z",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneHTTPClientClearsTimeout(t *testing.T) {
|
||||
base := &http.Client{Timeout: time.Second}
|
||||
|
||||
cloned := cloneHTTPClient(base)
|
||||
|
||||
if cloned == base {
|
||||
t.Fatal("cloneHTTPClient() returned the original client")
|
||||
}
|
||||
if cloned.Timeout != 0 {
|
||||
t.Fatalf("cloneHTTPClient() Timeout = %s, want 0", cloned.Timeout)
|
||||
}
|
||||
if base.Timeout != time.Second {
|
||||
t.Fatalf("base Timeout = %s, want %s", base.Timeout, time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerSaveAppliedState(t *testing.T) {
|
||||
configRoot := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", configRoot)
|
||||
|
||||
now := time.Unix(200, 0).UTC()
|
||||
manager := NewManager(Options{
|
||||
CurrentVersion: "1.0.0",
|
||||
Now: func() time.Time {
|
||||
return now
|
||||
},
|
||||
})
|
||||
manager.state = &State{
|
||||
LastCheckedAt: time.Unix(100, 0).UTC(),
|
||||
CheckError: "manifest fetch failed",
|
||||
}
|
||||
|
||||
manager.saveAppliedState(&CheckResult{
|
||||
LatestVersion: "9.9.9",
|
||||
LatestPublishedAt: "2026-03-27T19:00:00Z",
|
||||
Asset: &Asset{
|
||||
URL: "https://cdn.example.com/cli/v9.9.9/browseros-cli_9.9.9_test.tar.gz",
|
||||
},
|
||||
})
|
||||
|
||||
if manager.state.LastCheckedAt != now {
|
||||
t.Fatalf("LastCheckedAt = %v, want %v", manager.state.LastCheckedAt, now)
|
||||
}
|
||||
if manager.state.CheckError != "" {
|
||||
t.Fatalf("CheckError = %q, want empty", manager.state.CheckError)
|
||||
}
|
||||
if manager.state.LatestPublishedAt != "2026-03-27T19:00:00Z" {
|
||||
t.Fatalf("LatestPublishedAt = %q", manager.state.LatestPublishedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomaticEnabledSkipsForPackageManagerInstall(t *testing.T) {
|
||||
t.Setenv("BROWSEROS_INSTALL_METHOD", "npm")
|
||||
|
||||
manager := NewManager(Options{
|
||||
CurrentVersion: "1.0.0",
|
||||
Automatic: true,
|
||||
})
|
||||
|
||||
if manager.AutomaticEnabled() {
|
||||
t.Fatal("AutomaticEnabled() = true, want false when BROWSEROS_INSTALL_METHOD=npm")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomaticEnabledAllowsNormalInstall(t *testing.T) {
|
||||
t.Setenv("BROWSEROS_INSTALL_METHOD", "")
|
||||
|
||||
manager := NewManager(Options{
|
||||
CurrentVersion: "1.0.0",
|
||||
Automatic: true,
|
||||
})
|
||||
|
||||
if !manager.AutomaticEnabled() {
|
||||
t.Fatal("AutomaticEnabled() = false, want true when BROWSEROS_INSTALL_METHOD is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func runtimePlatformKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
key, err := PlatformKey(runtimeGOOS(), runtimeGOARCH())
|
||||
if err != nil {
|
||||
t.Fatalf("PlatformKey() error = %v", err)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func runtimeGOOS() string {
|
||||
return runtime.GOOS
|
||||
}
|
||||
|
||||
func runtimeGOARCH() string {
|
||||
return runtime.GOARCH
|
||||
}
|
||||
144
packages/browseros-agent/apps/cli/update/manifest.go
Normal file
144
packages/browseros-agent/apps/cli/update/manifest.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
const maxManifestSize = 1 << 20
|
||||
|
||||
type Manifest struct {
|
||||
Version string `json:"version"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
Tag string `json:"tag"`
|
||||
Assets map[string]Asset `json:"assets"`
|
||||
}
|
||||
|
||||
type Asset struct {
|
||||
Filename string `json:"filename"`
|
||||
URL string `json:"url"`
|
||||
ArchiveFormat string `json:"archive_format"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
func FetchManifest(
|
||||
ctx context.Context,
|
||||
client *http.Client,
|
||||
url string,
|
||||
) (*Manifest, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("update manifest returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var manifest Manifest
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, maxManifestSize)).Decode(&manifest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := manifest.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &manifest, nil
|
||||
}
|
||||
|
||||
func (m *Manifest) Validate() error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("update manifest is nil")
|
||||
}
|
||||
if !IsReleaseVersion(m.Version) {
|
||||
return fmt.Errorf("invalid manifest version %q", m.Version)
|
||||
}
|
||||
if len(m.Assets) == 0 {
|
||||
return fmt.Errorf("update manifest has no assets")
|
||||
}
|
||||
|
||||
for key, asset := range m.Assets {
|
||||
if asset.URL == "" {
|
||||
return fmt.Errorf("asset %q is missing url", key)
|
||||
}
|
||||
if asset.SHA256 == "" {
|
||||
return fmt.Errorf("asset %q is missing sha256", key)
|
||||
}
|
||||
if asset.ArchiveFormat != "tar.gz" && asset.ArchiveFormat != "zip" {
|
||||
return fmt.Errorf("asset %q has unsupported archive format %q", key, asset.ArchiveFormat)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func NormalizeVersion(version string) string {
|
||||
value := strings.TrimSpace(version)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(value, "v") {
|
||||
value = "v" + value
|
||||
}
|
||||
return semver.Canonical(value)
|
||||
}
|
||||
|
||||
func IsReleaseVersion(version string) bool {
|
||||
return NormalizeVersion(version) != ""
|
||||
}
|
||||
|
||||
func CompareVersions(current, latest string) (int, error) {
|
||||
normalizedCurrent := NormalizeVersion(current)
|
||||
if normalizedCurrent == "" {
|
||||
return 0, fmt.Errorf("invalid current version %q", current)
|
||||
}
|
||||
|
||||
normalizedLatest := NormalizeVersion(latest)
|
||||
if normalizedLatest == "" {
|
||||
return 0, fmt.Errorf("invalid latest version %q", latest)
|
||||
}
|
||||
|
||||
return semver.Compare(normalizedCurrent, normalizedLatest), nil
|
||||
}
|
||||
|
||||
func PlatformKey(goos, goarch string) (string, error) {
|
||||
switch goos {
|
||||
case "darwin", "linux", "windows":
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported os %q", goos)
|
||||
}
|
||||
|
||||
switch goarch {
|
||||
case "amd64", "arm64":
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported arch %q", goarch)
|
||||
}
|
||||
|
||||
return goos + "/" + goarch, nil
|
||||
}
|
||||
|
||||
func SelectAsset(manifest *Manifest, goos, goarch string) (Asset, error) {
|
||||
key, err := PlatformKey(goos, goarch)
|
||||
if err != nil {
|
||||
return Asset{}, err
|
||||
}
|
||||
|
||||
asset, ok := manifest.Assets[key]
|
||||
if !ok {
|
||||
return Asset{}, fmt.Errorf("no update asset for %s", key)
|
||||
}
|
||||
|
||||
return asset, nil
|
||||
}
|
||||
102
packages/browseros-agent/apps/cli/update/manifest_test.go
Normal file
102
packages/browseros-agent/apps/cli/update/manifest_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeVersion(t *testing.T) {
|
||||
if got := NormalizeVersion("1.2.3"); got != "v1.2.3" {
|
||||
t.Fatalf("NormalizeVersion() = %q, want %q", got, "v1.2.3")
|
||||
}
|
||||
if got := NormalizeVersion("dev"); got != "" {
|
||||
t.Fatalf("NormalizeVersion(dev) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareVersions(t *testing.T) {
|
||||
got, err := CompareVersions("1.2.3", "1.3.0")
|
||||
if err != nil {
|
||||
t.Fatalf("CompareVersions() error = %v", err)
|
||||
}
|
||||
if got >= 0 {
|
||||
t.Fatalf("CompareVersions() = %d, want < 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectAsset(t *testing.T) {
|
||||
manifest := &Manifest{
|
||||
Version: "1.2.3",
|
||||
Assets: map[string]Asset{
|
||||
"darwin/arm64": {
|
||||
URL: "https://cdn.example.com/cli/v1.2.3/browseros-cli.tar.gz",
|
||||
ArchiveFormat: "tar.gz",
|
||||
SHA256: "abc",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
asset, err := SelectAsset(manifest, "darwin", "arm64")
|
||||
if err != nil {
|
||||
t.Fatalf("SelectAsset() error = %v", err)
|
||||
}
|
||||
if asset.URL == "" {
|
||||
t.Fatal("SelectAsset() returned empty URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchManifest(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"version":"1.2.3",
|
||||
"published_at":"2026-03-27T19:00:00Z",
|
||||
"tag":"browseros-cli-v1.2.3",
|
||||
"assets":{
|
||||
"darwin/arm64":{
|
||||
"filename":"browseros-cli_1.2.3_darwin_arm64.tar.gz",
|
||||
"url":"https://cdn.example.com/cli/v1.2.3/browseros-cli_1.2.3_darwin_arm64.tar.gz",
|
||||
"archive_format":"tar.gz",
|
||||
"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
}
|
||||
}
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
manifest, err := FetchManifest(context.Background(), server.Client(), server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("FetchManifest() error = %v", err)
|
||||
}
|
||||
if manifest.Version != "1.2.3" {
|
||||
t.Fatalf("FetchManifest() version = %q, want %q", manifest.Version, "1.2.3")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchManifestRejectsOversizedResponse(t *testing.T) {
|
||||
hugeName := strings.Repeat("a", maxManifestSize)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"version":"1.2.3",
|
||||
"published_at":"2026-03-27T19:00:00Z",
|
||||
"tag":"browseros-cli-v1.2.3",
|
||||
"assets":{
|
||||
"darwin/arm64":{
|
||||
"filename":"` + hugeName + `",
|
||||
"url":"https://cdn.example.com/cli/v1.2.3/browseros-cli_1.2.3_darwin_arm64.tar.gz",
|
||||
"archive_format":"tar.gz",
|
||||
"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
}
|
||||
}
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if _, err := FetchManifest(context.Background(), server.Client(), server.URL); err == nil {
|
||||
t.Fatal("FetchManifest() error = nil, want oversized response error")
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user