mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
Compare commits
47 Commits
fix/mar26-
...
fix/iframe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
914e8689de | ||
|
|
c289a76b63 | ||
|
|
df7873562d | ||
|
|
412386b489 | ||
|
|
33617ba9e7 | ||
|
|
6712e1d321 | ||
|
|
94540d9e87 | ||
|
|
bb62213e84 | ||
|
|
dee3086a48 | ||
|
|
8de2bf984f | ||
|
|
1b8720740c | ||
|
|
91be726381 | ||
|
|
ff5386a24a | ||
|
|
a5f3c4da65 | ||
|
|
e5a852dd3d | ||
|
|
aee30ce8e1 | ||
|
|
0833c8d42d | ||
|
|
036c7f280b | ||
|
|
000429277d | ||
|
|
f8535fd96d | ||
|
|
f0cbf77924 | ||
|
|
17be06eb2f | ||
|
|
0e90785500 | ||
|
|
2bb432b0f2 | ||
|
|
565ce18eba | ||
|
|
81350c0d7f | ||
|
|
9bdb2413ec | ||
|
|
ace9307878 | ||
|
|
83a25ad301 | ||
|
|
4b191a759c | ||
|
|
d02b3f74e6 | ||
|
|
86c62f14a5 | ||
|
|
42c3e8fe01 | ||
|
|
517750e880 | ||
|
|
6c053a5f29 | ||
|
|
1c5ffdf878 | ||
|
|
39a7d49c25 | ||
|
|
ed948f4b59 | ||
|
|
aad5bc16fd | ||
|
|
cee318a40b | ||
|
|
febaf58f91 | ||
|
|
aacb47f7ee | ||
|
|
b3003542d8 | ||
|
|
aba7a10430 | ||
|
|
b7462aa042 | ||
|
|
883bcc9670 | ||
|
|
279b41fdc4 |
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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Release Agent Extension
|
||||
name: Release BrowserOS Extension
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
run: |
|
||||
TAG="agent-extension-v${{ steps.version.outputs.version }}"
|
||||
RELEASE_SHA="${{ steps.version.outputs.release_sha }}"
|
||||
TITLE="BrowserOS Agent Extension v${{ steps.version.outputs.version }}"
|
||||
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"
|
||||
@@ -140,7 +140,7 @@ jobs:
|
||||
|
||||
gh pr create \
|
||||
--title "docs: update agent extension changelog for v${VERSION}" \
|
||||
--body "Auto-generated changelog update for BrowserOS Agent Extension v${VERSION}." \
|
||||
--body "Auto-generated changelog update for BrowserOS Extension v${VERSION}." \
|
||||
--base main \
|
||||
--head "$BRANCH"
|
||||
|
||||
|
||||
6
.github/workflows/release-agent-sdk.yml
vendored
6
.github/workflows/release-agent-sdk.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Release Agent SDK
|
||||
name: Release BrowserOS Agent SDK
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
run: |
|
||||
TAG="agent-sdk-v${{ steps.version.outputs.version }}"
|
||||
RELEASE_SHA="${{ steps.version.outputs.release_sha }}"
|
||||
TITLE="@browseros-ai/agent-sdk v${{ steps.version.outputs.version }}"
|
||||
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
|
||||
@@ -160,7 +160,7 @@ jobs:
|
||||
|
||||
gh pr create \
|
||||
--title "docs: update agent-sdk changelog for v${VERSION}" \
|
||||
--body "Auto-generated changelog update for @browseros-ai/agent-sdk v${VERSION}." \
|
||||
--body "Auto-generated changelog update for BrowserOS Agent SDK v${VERSION}." \
|
||||
--base main \
|
||||
--head "$BRANCH"
|
||||
|
||||
|
||||
113
.github/workflows/release-cli.yml
vendored
113
.github/workflows/release-cli.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Release CLI
|
||||
name: Release BrowserOS CLI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -16,6 +16,7 @@ jobs:
|
||||
release:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
environment: release-core
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
@@ -32,41 +33,37 @@ 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
|
||||
|
||||
- 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:
|
||||
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: |
|
||||
VERSION="${{ inputs.version }}"
|
||||
LDFLAGS="-s -w -X main.version=${VERSION}"
|
||||
DIST="dist"
|
||||
mkdir -p "$DIST"
|
||||
|
||||
for pair in darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64; do
|
||||
OS="${pair%/*}"
|
||||
ARCH="${pair#*/}"
|
||||
BIN="browseros-cli"
|
||||
EXT=""
|
||||
if [ "$OS" = "windows" ]; then EXT=".exe"; fi
|
||||
|
||||
echo "Building ${OS}/${ARCH}..."
|
||||
GOOS=$OS GOARCH=$ARCH CGO_ENABLED=0 go build -trimpath -ldflags "$LDFLAGS" -o "${DIST}/${BIN}${EXT}" .
|
||||
|
||||
ARCHIVE="browseros-cli_${VERSION}_${OS}_${ARCH}"
|
||||
if [ "$OS" = "windows" ]; then
|
||||
(cd "$DIST" && zip "${ARCHIVE}.zip" "${BIN}${EXT}")
|
||||
else
|
||||
(cd "$DIST" && tar czf "${ARCHIVE}.tar.gz" "${BIN}")
|
||||
fi
|
||||
rm "${DIST}/${BIN}${EXT}"
|
||||
done
|
||||
|
||||
(cd "$DIST" && sha256sum *.tar.gz *.zip > checksums.txt)
|
||||
echo "=== Built artifacts ==="
|
||||
ls -lh "$DIST"
|
||||
bun scripts/build/cli.ts \
|
||||
--release \
|
||||
--version "$CLI_VERSION" \
|
||||
--binaries-dir apps/cli/dist
|
||||
working-directory: packages/browseros-agent
|
||||
|
||||
- name: Generate release notes
|
||||
env:
|
||||
@@ -74,31 +71,59 @@ jobs:
|
||||
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." > /tmp/release-notes.md
|
||||
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." > /tmp/release-notes.md
|
||||
echo "No notable changes." > "$CHANGELOG_FILE"
|
||||
else
|
||||
echo "## What's Changed" > /tmp/release-notes.md
|
||||
echo "" >> /tmp/release-notes.md
|
||||
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})" >> /tmp/release-notes.md
|
||||
echo "- ${SUBJECT} (#${PR_NUM})" >> "$CHANGELOG_FILE"
|
||||
else
|
||||
echo "- ${SUBJECT}" >> /tmp/release-notes.md
|
||||
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
|
||||
@@ -116,7 +141,21 @@ jobs:
|
||||
|
||||
CLI_DIST="packages/browseros-agent/apps/cli/dist"
|
||||
gh release create "$TAG" \
|
||||
--title "browseros-cli v${{ inputs.version }}" \
|
||||
--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 }}
|
||||
233
README.md
233
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,163 +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!
|
||||
|
||||
## Install `browseros-cli`
|
||||
|
||||
Use `browseros-cli` when you want to control BrowserOS from the terminal or scripts via the BrowserOS MCP server.
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
```bash
|
||||
curl -fsSL https://cdn.browseros.com/cli/install.sh | bash
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
```powershell
|
||||
irm https://cdn.browseros.com/cli/install.ps1 | iex
|
||||
```
|
||||
|
||||
After install, run `browseros-cli init` to point the CLI at your BrowserOS MCP server.
|
||||
|
||||
## What makes BrowserOS special
|
||||
- 🏠 Feels like home — same Chrome interface, all your extensions just work
|
||||
- 🤖 AI agents that run on YOUR browser, not in the cloud
|
||||
- 🔒 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
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# BrowserOS Agent Extension
|
||||
|
||||
## 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 |
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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,10 +56,11 @@ 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,
|
||||
@@ -55,11 +70,7 @@ 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',
|
||||
@@ -182,100 +193,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 +220,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 +232,6 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
if (opt.value === 'github-copilot')
|
||||
return supports(Feature.GITHUB_COPILOT_SUPPORT)
|
||||
if (opt.value === 'qwen-code') return supports(Feature.QWEN_CODE_SUPPORT)
|
||||
if (opt.value === 'moonshot')
|
||||
return kimiLaunch || initialValues?.type === 'moonshot'
|
||||
if (opt.value === 'openai-compatible') {
|
||||
return supports(Feature.OPENAI_COMPATIBLE_SUPPORT)
|
||||
}
|
||||
@@ -376,6 +292,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)
|
||||
@@ -471,6 +404,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, {
|
||||
@@ -924,36 +862,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,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,18 @@
|
||||
import { AlertCircle, RefreshCw } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
// import { useMemo } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
// --- Commented out for Kimi partnership launch (restore after) ---
|
||||
// const SURVEY_DIRECTIONS = [
|
||||
// 'competitor',
|
||||
// 'switching',
|
||||
// 'workflow',
|
||||
// 'activation',
|
||||
// ] as const
|
||||
//
|
||||
// function pickRandomDirection(): string {
|
||||
// return SURVEY_DIRECTIONS[Math.floor(Math.random() * SURVEY_DIRECTIONS.length)]
|
||||
// }
|
||||
// --- End commented out survey code ---
|
||||
const SURVEY_DIRECTIONS = [
|
||||
'competitor',
|
||||
'switching',
|
||||
'workflow',
|
||||
'activation',
|
||||
] as const
|
||||
|
||||
function pickRandomDirection(): string {
|
||||
return SURVEY_DIRECTIONS[Math.floor(Math.random() * SURVEY_DIRECTIONS.length)]
|
||||
}
|
||||
|
||||
interface ChatErrorProps {
|
||||
error: Error
|
||||
@@ -95,13 +93,11 @@ export const ChatError: FC<ChatErrorProps> = ({
|
||||
const { text, url, isRateLimit, isCreditsExhausted, isConnectionError } =
|
||||
parseErrorMessage(error.message, providerType)
|
||||
|
||||
// --- Commented out for Kimi partnership launch (restore after) ---
|
||||
// const surveyUrl = useMemo(
|
||||
// () =>
|
||||
// `/app.html?page=survey&maxTurns=20&experimentId=daily_limit_${pickRandomDirection()}#/settings/survey`,
|
||||
// [],
|
||||
// )
|
||||
// --- End commented out survey code ---
|
||||
const surveyUrl = useMemo(
|
||||
() =>
|
||||
`/app.html?page=survey&maxTurns=20&experimentId=daily_limit_${pickRandomDirection()}#/settings/survey`,
|
||||
[],
|
||||
)
|
||||
|
||||
const getTitle = () => {
|
||||
if (isRateLimit) return 'Daily limit reached'
|
||||
@@ -126,8 +122,17 @@ export const ChatError: FC<ChatErrorProps> = ({
|
||||
View troubleshooting guide
|
||||
</a>
|
||||
)}
|
||||
{/* --- Commented out for Kimi partnership launch (restore after) ---
|
||||
{isRateLimit && (
|
||||
{isCreditsExhausted && url && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground text-xs underline hover:text-foreground"
|
||||
>
|
||||
View Usage & Billing
|
||||
</a>
|
||||
)}
|
||||
{isRateLimit && !isCreditsExhausted && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<a
|
||||
href={url}
|
||||
@@ -148,27 +153,6 @@ export const ChatError: FC<ChatErrorProps> = ({
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
--- End commented out survey code --- */}
|
||||
{isCreditsExhausted && url && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground text-xs underline hover:text-foreground"
|
||||
>
|
||||
View Usage & Billing
|
||||
</a>
|
||||
)}
|
||||
{isRateLimit && providerType === 'browseros' && (
|
||||
<a
|
||||
href="/app.html#/settings/ai"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--accent-orange)] bg-[var(--accent-orange)]/10 px-3 py-1.5 font-medium text-[var(--accent-orange)] text-xs transition-colors hover:bg-[var(--accent-orange)]/20"
|
||||
>
|
||||
Add your own provider for unlimited usage
|
||||
</a>
|
||||
)}
|
||||
{onRetry && (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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 []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,12 @@ import { storage } from '@wxt-dev/storage'
|
||||
import { sessionStorage } from '@/lib/auth/sessionStorage'
|
||||
import { getBrowserOSAdapter } from '@/lib/browseros/adapter'
|
||||
import { BROWSEROS_PREFS } from '@/lib/browseros/prefs'
|
||||
import { isKimiLaunchEnabled } from '@/lib/feature-flags/kimi-launch'
|
||||
import type { LlmProviderConfig, LlmProvidersBackup } from './types'
|
||||
import { uploadLlmProvidersToGraphql } from './uploadLlmProvidersToGraphql'
|
||||
|
||||
/** Default provider ID constant */
|
||||
export const DEFAULT_PROVIDER_ID = 'browseros'
|
||||
const DEFAULT_PROVIDER_NAME = 'BrowserOS'
|
||||
const KIMI_LAUNCH_PROVIDER_NAME = 'Kimi K2.5'
|
||||
|
||||
/** Storage key for LLM providers array */
|
||||
export const providersStorage = storage.defineItem<LlmProviderConfig[]>(
|
||||
@@ -91,7 +89,7 @@ export function setupLlmProvidersSyncToBackend(): () => void {
|
||||
/** Load providers from storage */
|
||||
export async function loadProviders(): Promise<LlmProviderConfig[]> {
|
||||
const providers = (await providersStorage.getValue()) || []
|
||||
const normalizedProviders = normalizeProvidersForLaunch(providers)
|
||||
const normalizedProviders = normalizeProviderNames(providers)
|
||||
|
||||
// Keep storage consistent so every consumer sees the same provider name.
|
||||
if (
|
||||
@@ -109,7 +107,7 @@ export function createDefaultBrowserOSProvider(): LlmProviderConfig {
|
||||
return {
|
||||
id: DEFAULT_PROVIDER_ID,
|
||||
type: 'browseros',
|
||||
name: getBuiltInProviderName(),
|
||||
name: DEFAULT_PROVIDER_NAME,
|
||||
baseUrl: 'https://api.browseros.com/v1',
|
||||
modelId: 'browseros-auto',
|
||||
supportsImages: true,
|
||||
@@ -125,26 +123,22 @@ export function createDefaultProvidersConfig(): LlmProviderConfig[] {
|
||||
return [createDefaultBrowserOSProvider()]
|
||||
}
|
||||
|
||||
function getBuiltInProviderName(): string {
|
||||
return isKimiLaunchEnabled()
|
||||
? KIMI_LAUNCH_PROVIDER_NAME
|
||||
: DEFAULT_PROVIDER_NAME
|
||||
}
|
||||
|
||||
function normalizeProvidersForLaunch(
|
||||
/**
|
||||
* Normalize built-in provider names back to "BrowserOS" (e.g. from "Kimi K2.5"
|
||||
* which was set during a previous partnership launch).
|
||||
*/
|
||||
function normalizeProviderNames(
|
||||
providers: LlmProviderConfig[],
|
||||
): LlmProviderConfig[] {
|
||||
const builtInProviderName = getBuiltInProviderName()
|
||||
|
||||
return providers.map((provider) => {
|
||||
if (
|
||||
provider.id === DEFAULT_PROVIDER_ID &&
|
||||
provider.type === 'browseros' &&
|
||||
provider.name !== builtInProviderName
|
||||
provider.name !== DEFAULT_PROVIDER_NAME
|
||||
) {
|
||||
return {
|
||||
...provider,
|
||||
name: builtInProviderName,
|
||||
name: DEFAULT_PROVIDER_NAME,
|
||||
}
|
||||
}
|
||||
return provider
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { storage } from '@wxt-dev/storage'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export interface Workflow {
|
||||
id: string
|
||||
codeId: string
|
||||
workflowName: string
|
||||
}
|
||||
|
||||
export const workflowStorage = storage.defineItem<Workflow[]>(
|
||||
'local:workflows',
|
||||
{
|
||||
fallback: [],
|
||||
},
|
||||
)
|
||||
|
||||
export function useWorkflows() {
|
||||
const [workflows, setWorkflows] = useState<Workflow[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
workflowStorage.getValue().then(setWorkflows)
|
||||
const unwatch = workflowStorage.watch((newValue) => {
|
||||
setWorkflows(newValue ?? [])
|
||||
})
|
||||
return unwatch
|
||||
}, [])
|
||||
|
||||
const addWorkflow = async (workflow: Omit<Workflow, 'id'>) => {
|
||||
const newWorkflow: Workflow = {
|
||||
id: crypto.randomUUID(),
|
||||
...workflow,
|
||||
}
|
||||
const current = (await workflowStorage.getValue()) ?? []
|
||||
await workflowStorage.setValue([...current, newWorkflow])
|
||||
return newWorkflow
|
||||
}
|
||||
|
||||
const removeWorkflow = async (id: string) => {
|
||||
const current = (await workflowStorage.getValue()) ?? []
|
||||
await workflowStorage.setValue(current.filter((w) => w.id !== id))
|
||||
}
|
||||
|
||||
const editWorkflow = async (
|
||||
id: string,
|
||||
updates: Partial<Omit<Workflow, 'id'>>,
|
||||
) => {
|
||||
const current = (await workflowStorage.getValue()) ?? []
|
||||
await workflowStorage.setValue(
|
||||
current.map((w) => (w.id === id ? { ...w, ...updates } : w)),
|
||||
)
|
||||
}
|
||||
|
||||
return { workflows, addWorkflow, removeWorkflow, editWorkflow }
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@browseros/agent",
|
||||
"description": "manifest.json description",
|
||||
"private": true,
|
||||
"version": "0.0.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",
|
||||
@@ -44,9 +44,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 +67,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) {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# Production upload env for CLI installer scripts
|
||||
# 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=
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
version: 2
|
||||
|
||||
project_name: browseros-cli
|
||||
|
||||
monorepo:
|
||||
tag_prefix: browseros-cli-
|
||||
|
||||
builds:
|
||||
- main: .
|
||||
binary: browseros-cli
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
flags:
|
||||
- -trimpath
|
||||
ldflags:
|
||||
- -s -w -X main.version={{ .Version }}
|
||||
targets:
|
||||
- darwin_amd64
|
||||
- darwin_arm64
|
||||
- linux_amd64
|
||||
- linux_arm64
|
||||
- windows_amd64
|
||||
- windows_arm64
|
||||
|
||||
archives:
|
||||
- format: tar.gz
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
files:
|
||||
- "none*"
|
||||
|
||||
checksum:
|
||||
name_template: checksums.txt
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- "^docs:"
|
||||
- "^test:"
|
||||
- "^ci:"
|
||||
|
||||
release:
|
||||
github:
|
||||
owner: browseros-ai
|
||||
name: BrowserOS
|
||||
prerelease: auto
|
||||
name_template: "browseros-cli v{{ .Version }}"
|
||||
@@ -1,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)
|
||||
})
|
||||
@@ -17,10 +17,10 @@ param(
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# TLS 1.2 — required for GitHub, older PS 5.1 defaults to TLS 1.0
|
||||
# TLS 1.2 — older PS 5.1 defaults to TLS 1.0
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
|
||||
$Repo = "browseros-ai/BrowserOS"
|
||||
$CdnBase = "https://cdn.browseros.com/cli"
|
||||
$Binary = "browseros-cli"
|
||||
|
||||
# When piped via irm | iex, param() is ignored — fall back to env vars
|
||||
@@ -31,15 +31,16 @@ if (-not $Dir) { $Dir = if ($env:BROWSEROS_DIR) { $env:BROWSEROS_DIR } else { "$
|
||||
|
||||
if (-not $Version) {
|
||||
Write-Host "Fetching latest version..."
|
||||
$releases = Invoke-RestMethod "https://api.github.com/repos/$Repo/releases?per_page=100"
|
||||
$tag = ($releases `
|
||||
| Where-Object { $_.tag_name -match "^browseros-cli-v" -and $_.tag_name -notmatch "-rc" } `
|
||||
| Select-Object -First 1).tag_name
|
||||
if (-not $tag) {
|
||||
$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
|
||||
}
|
||||
$Version = $tag -replace "^browseros-cli-v", ""
|
||||
}
|
||||
|
||||
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..."
|
||||
@@ -65,10 +66,8 @@ if (-not [Environment]::Is64BitOperatingSystem) {
|
||||
|
||||
# ── Download and extract ─────────────────────────────────────────────────────
|
||||
|
||||
$Tag = "browseros-cli-v$Version"
|
||||
$Filename = "${Binary}_${Version}_windows_${Arch}.zip"
|
||||
$Url = "https://github.com/$Repo/releases/download/$Tag/$Filename"
|
||||
$ChecksumUrl = "https://github.com/$Repo/releases/download/$Tag/checksums.txt"
|
||||
$Url = "$CdnBase/v$Version/$Filename"
|
||||
$TmpDir = Join-Path ([System.IO.Path]::GetTempPath()) ("browseros-cli-install-" + [System.IO.Path]::GetRandomFileName())
|
||||
|
||||
try {
|
||||
@@ -79,37 +78,6 @@ try {
|
||||
Write-Host "Downloading $Url..."
|
||||
Invoke-WebRequest -Uri $Url -OutFile $ZipPath -UseBasicParsing
|
||||
|
||||
$ChecksumPath = Join-Path $TmpDir "checksums.txt"
|
||||
$ChecksumAvailable = $true
|
||||
try {
|
||||
Invoke-WebRequest -Uri $ChecksumUrl -OutFile $ChecksumPath -UseBasicParsing
|
||||
} catch {
|
||||
$ChecksumAvailable = $false
|
||||
Write-Warning "Could not fetch checksums.txt; skipping checksum verification. $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
if ($ChecksumAvailable) {
|
||||
$ExpectedChecksum = $null
|
||||
foreach ($line in Get-Content $ChecksumPath) {
|
||||
$parts = $line -split '\s+', 2
|
||||
if ($parts.Length -eq 2 -and $parts[1] -eq $Filename) {
|
||||
$ExpectedChecksum = $parts[0].ToLowerInvariant()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ($ExpectedChecksum) {
|
||||
$ActualChecksum = (Get-FileHash -Path $ZipPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($ActualChecksum -ne $ExpectedChecksum) {
|
||||
Write-Error "Checksum mismatch (expected $ExpectedChecksum, got $ActualChecksum)"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Checksum verified."
|
||||
} else {
|
||||
Write-Warning "Checksum not found in checksums.txt; skipping checksum verification."
|
||||
}
|
||||
}
|
||||
|
||||
Expand-Archive -Path $ZipPath -DestinationPath $TmpDir -Force
|
||||
|
||||
$Exe = Get-ChildItem -Path $TmpDir -Filter "$Binary.exe" -File -Recurse | Select-Object -First 1
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO="browseros-ai/BrowserOS"
|
||||
CDN_BASE="https://cdn.browseros.com/cli"
|
||||
BINARY="browseros-cli"
|
||||
INSTALL_DIR="${HOME}/.browseros/bin"
|
||||
|
||||
@@ -43,13 +43,7 @@ done
|
||||
# ── Resolve latest version ───────────────────────────────────────────────────
|
||||
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
# Use per_page=1 with a tag name filter via the releases endpoint.
|
||||
# The tags all start with "browseros-cli-v" so we grab page 1 of those.
|
||||
VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases?per_page=100" \
|
||||
| grep -o '"tag_name": *"browseros-cli-v[^"]*"' \
|
||||
| grep -v -- "-rc" \
|
||||
| head -1 \
|
||||
| sed 's/.*browseros-cli-v//; s/"//')
|
||||
VERSION=$(curl -fsSL "${CDN_BASE}/latest/version.txt" | tr -d '[:space:]')
|
||||
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
echo "Error: could not determine latest version." >&2
|
||||
@@ -58,6 +52,11 @@ if [[ -z "$VERSION" ]]; then
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
@@ -80,9 +79,8 @@ esac
|
||||
# ── Download and extract ─────────────────────────────────────────────────────
|
||||
|
||||
FILENAME="${BINARY}_${VERSION}_${OS}_${ARCH}.tar.gz"
|
||||
TAG="browseros-cli-v${VERSION}"
|
||||
URL="https://github.com/${REPO}/releases/download/${TAG}/${FILENAME}"
|
||||
CHECKSUM_URL="https://github.com/${REPO}/releases/download/${TAG}/checksums.txt"
|
||||
URL="${CDN_BASE}/v${VERSION}/${FILENAME}"
|
||||
CHECKSUM_URL="${CDN_BASE}/v${VERSION}/checksums.txt"
|
||||
|
||||
TMPDIR_DL=$(mktemp -d)
|
||||
trap 'rm -rf "$TMPDIR_DL"' EXIT
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
80
packages/browseros-agent/apps/cli/update/state.go
Normal file
80
packages/browseros-agent/apps/cli/update/state.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"browseros-cli/config"
|
||||
)
|
||||
|
||||
type State struct {
|
||||
LastCheckedAt time.Time `json:"last_checked_at"`
|
||||
LatestVersion string `json:"latest_version,omitempty"`
|
||||
LatestPublishedAt string `json:"latest_published_at,omitempty"`
|
||||
AssetURL string `json:"asset_url,omitempty"`
|
||||
CheckError string `json:"check_error,omitempty"`
|
||||
}
|
||||
|
||||
func StatePath() string {
|
||||
return filepath.Join(config.Dir(), "update-state.json")
|
||||
}
|
||||
|
||||
func LoadState() (*State, error) {
|
||||
data, err := os.ReadFile(StatePath())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &State{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var state State
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
func SaveState(state *State) error {
|
||||
if state == nil {
|
||||
state = &State{}
|
||||
}
|
||||
|
||||
dir := config.Dir()
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmpFile, err := os.CreateTemp(dir, "update-state-*.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(tmpFile)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(state); err != nil {
|
||||
tmpFile.Close()
|
||||
os.Remove(tmpFile.Name())
|
||||
return err
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
os.Remove(tmpFile.Name())
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpFile.Name(), StatePath()); err != nil {
|
||||
os.Remove(tmpFile.Name())
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) IsStale(now time.Time, ttl time.Duration) bool {
|
||||
if s == nil || s.LastCheckedAt.IsZero() {
|
||||
return true
|
||||
}
|
||||
return now.Sub(s.LastCheckedAt) >= ttl
|
||||
}
|
||||
54
packages/browseros-agent/apps/cli/update/state_test.go
Normal file
54
packages/browseros-agent/apps/cli/update/state_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadStateMissing(t *testing.T) {
|
||||
configRoot := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", configRoot)
|
||||
|
||||
state, err := LoadState()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadState() error = %v", err)
|
||||
}
|
||||
if state == nil {
|
||||
t.Fatal("LoadState() returned nil state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveStateRoundTrip(t *testing.T) {
|
||||
configRoot := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", configRoot)
|
||||
|
||||
want := &State{
|
||||
LastCheckedAt: time.Unix(100, 0).UTC(),
|
||||
LatestVersion: "1.2.3",
|
||||
LatestPublishedAt: "2026-03-27T19:00:00Z",
|
||||
AssetURL: "https://cdn.example.com/cli/v1.2.3/browseros-cli.tar.gz",
|
||||
}
|
||||
if err := SaveState(want); err != nil {
|
||||
t.Fatalf("SaveState() error = %v", err)
|
||||
}
|
||||
|
||||
got, err := LoadState()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadState() error = %v", err)
|
||||
}
|
||||
if got.LatestVersion != want.LatestVersion {
|
||||
t.Fatalf("LatestVersion = %q, want %q", got.LatestVersion, want.LatestVersion)
|
||||
}
|
||||
if StatePath() != filepath.Join(configRoot, "browseros-cli", "update-state.json") {
|
||||
t.Fatalf("StatePath() = %q", StatePath())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateIsStale(t *testing.T) {
|
||||
now := time.Unix(200, 0).UTC()
|
||||
state := &State{LastCheckedAt: time.Unix(0, 0).UTC()}
|
||||
if !state.IsStale(now, time.Minute) {
|
||||
t.Fatal("IsStale() = false, want true")
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Build unpublished docs
|
||||
docs/
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Claude
|
||||
.claude
|
||||
@@ -1,430 +0,0 @@
|
||||
# BrowserOS Controller
|
||||
|
||||
WebSocket-based Chrome Extension that exposes browser automation APIs for remote control.
|
||||
|
||||
**⚠️ IMPORTANT:** This extension ONLY works in **BrowserOS Chrome**, not regular Chrome!
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Build the Extension
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 2. Load Extension in BrowserOS Chrome
|
||||
|
||||
1. Open BrowserOS Chrome
|
||||
2. Go to `chrome://extensions/`
|
||||
3. Enable **"Developer mode"** (top-right toggle)
|
||||
4. Click **"Load unpacked"**
|
||||
5. Select the `dist/` folder
|
||||
6. Verify extension is loaded (you should see "BrowserOS Controller")
|
||||
|
||||
### 3. Test the Extension
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
This starts an interactive test client. You should see:
|
||||
|
||||
```
|
||||
🚀 Starting BrowserOS Controller Test Client
|
||||
──────────────────────────────────────────────────────────
|
||||
|
||||
WebSocket Server Started
|
||||
Listening on: ws://localhost:9224/controller
|
||||
Waiting for extension to connect...
|
||||
|
||||
✅ Extension connected!
|
||||
|
||||
Running Diagnostic Test
|
||||
============================================================
|
||||
|
||||
📤 Sending: checkBrowserOS
|
||||
Request ID: test-1729012345678
|
||||
|
||||
📨 Response: test-1729012345678
|
||||
Status: ✅ SUCCESS
|
||||
Data: {
|
||||
"available": true,
|
||||
"apis": [
|
||||
"captureScreenshot",
|
||||
"clear",
|
||||
"click",
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**If you see "available": true**, you're all set! 🎉
|
||||
|
||||
**If you see "available": false**, you're not using BrowserOS Chrome.
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
The extension can be configured using environment variables. This is optional - sensible defaults are provided.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Create a `.env` file in the project root to customize configuration:
|
||||
|
||||
```bash
|
||||
# Copy the example file
|
||||
cp .env.example .env
|
||||
|
||||
# Edit .env with your values
|
||||
```
|
||||
|
||||
### Available Configuration Options
|
||||
|
||||
#### WebSocket Configuration
|
||||
|
||||
```bash
|
||||
WEBSOCKET_PROTOCOL=ws # ws or wss (default: ws)
|
||||
WEBSOCKET_HOST=localhost # Server host (default: localhost)
|
||||
WEBSOCKET_PORT=9224 # Server port (default: 9224)
|
||||
WEBSOCKET_PATH=/controller # Server path (default: /controller)
|
||||
```
|
||||
|
||||
#### Connection Settings
|
||||
|
||||
```bash
|
||||
WEBSOCKET_RECONNECT_DELAY=1000 # Initial reconnect delay in ms (default: 1000)
|
||||
WEBSOCKET_MAX_RECONNECT_DELAY=30000 # Max reconnect delay in ms (default: 30000)
|
||||
WEBSOCKET_RECONNECT_MULTIPLIER=1.5 # Exponential backoff multiplier (default: 1.5)
|
||||
WEBSOCKET_MAX_RECONNECT_ATTEMPTS=0 # Max reconnect attempts, 0 = infinite (default: 0)
|
||||
WEBSOCKET_HEARTBEAT_INTERVAL=30000 # Heartbeat interval in ms (default: 30000)
|
||||
WEBSOCKET_HEARTBEAT_TIMEOUT=5000 # Heartbeat timeout in ms (default: 5000)
|
||||
WEBSOCKET_CONNECTION_TIMEOUT=10000 # Connection timeout in ms (default: 10000)
|
||||
WEBSOCKET_REQUEST_TIMEOUT=30000 # Request timeout in ms (default: 30000)
|
||||
```
|
||||
|
||||
#### Concurrency Settings
|
||||
|
||||
```bash
|
||||
CONCURRENCY_MAX_CONCURRENT=100 # Max concurrent requests (default: 100)
|
||||
CONCURRENCY_MAX_QUEUE_SIZE=1000 # Max queued requests (default: 1000)
|
||||
```
|
||||
|
||||
#### Logging Settings
|
||||
|
||||
```bash
|
||||
LOGGING_ENABLED=true # Enable/disable logging (default: true)
|
||||
LOGGING_LEVEL=info # Log level: debug, info, warn, error (default: info)
|
||||
LOGGING_PREFIX=[BrowserOS Controller] # Log message prefix (default: [BrowserOS Controller])
|
||||
```
|
||||
|
||||
### Example: Custom Port Configuration
|
||||
|
||||
If you want to use a different port (e.g., 8080):
|
||||
|
||||
```bash
|
||||
# .env
|
||||
WEBSOCKET_PORT=8080
|
||||
```
|
||||
|
||||
Then rebuild the extension:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
The extension will now connect to `ws://localhost:8080/controller` instead of the default port 9224.
|
||||
|
||||
---
|
||||
|
||||
## 📖 Architecture
|
||||
|
||||
See [ARCHITECTURE.md](./ARCHITECTURE.md) for complete system documentation including:
|
||||
|
||||
- High-level architecture diagram
|
||||
- Request flow (step-by-step)
|
||||
- Component details
|
||||
- All 14 registered actions
|
||||
- WebSocket protocol specification
|
||||
- Debugging guide
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
The test client (`npm test`) provides an interactive menu:
|
||||
|
||||
```
|
||||
Available Commands:
|
||||
|
||||
Tab Actions:
|
||||
1. getActiveTab - Get currently active tab
|
||||
2. getTabs - Get all tabs
|
||||
|
||||
Browser Actions:
|
||||
3. getInteractiveSnapshot - Get page elements (requires tabId)
|
||||
4. click - Click element (requires tabId, nodeId)
|
||||
5. inputText - Type text (requires tabId, nodeId, text)
|
||||
6. captureScreenshot - Take screenshot (requires tabId)
|
||||
|
||||
Diagnostic:
|
||||
d. checkBrowserOS - Check if chrome.browserOS is available
|
||||
|
||||
Other:
|
||||
h. Show this menu
|
||||
q. Quit
|
||||
```
|
||||
|
||||
### Example Usage:
|
||||
|
||||
1. Type `1` → Get active tab
|
||||
2. Type `d` → Run diagnostic
|
||||
3. Type `q` → Quit
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Development
|
||||
|
||||
### Build Commands
|
||||
|
||||
```bash
|
||||
npm run build # Production build
|
||||
npm run build:dev # Development build (with source maps)
|
||||
npm run watch # Watch mode for development
|
||||
```
|
||||
|
||||
### Debug Extension
|
||||
|
||||
1. Go to `chrome://extensions/`
|
||||
2. Click **"Inspect views service worker"** under "BrowserOS Controller"
|
||||
3. Service worker console shows all logs
|
||||
|
||||
**Check extension status:**
|
||||
|
||||
```javascript
|
||||
__browserosController.getStats();
|
||||
```
|
||||
|
||||
**Expected output:**
|
||||
|
||||
```javascript
|
||||
{
|
||||
connection: "connected",
|
||||
requests: { inFlight: 0, avgDuration: 0, errorRate: 0, totalRequests: 0 },
|
||||
concurrency: { inFlight: 0, queued: 0, utilization: 0 },
|
||||
validator: { activeIds: 0 },
|
||||
responseQueue: { size: 0 }
|
||||
}
|
||||
```
|
||||
|
||||
**Check registered actions:**
|
||||
Look for this log on extension load:
|
||||
|
||||
```
|
||||
Registered 14 action(s): checkBrowserOS, getActiveTab, getTabs, ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Available Actions
|
||||
|
||||
| Action | Input | Output | Description |
|
||||
| ------------------------ | --------------------------------- | ------------------------------- | -------------------------------------- |
|
||||
| `checkBrowserOS` | `{}` | `{available, apis}` | Check if chrome.browserOS is available |
|
||||
| `getActiveTab` | `{}` | `{tabId, url, title, windowId}` | Get currently active tab |
|
||||
| `getTabs` | `{}` | `{tabs[]}` | Get all open tabs |
|
||||
| `getInteractiveSnapshot` | `{tabId, options?}` | `InteractiveSnapshot` | Get all interactive elements on page |
|
||||
| `click` | `{tabId, nodeId}` | `{success}` | Click element by nodeId |
|
||||
| `inputText` | `{tabId, nodeId, text}` | `{success}` | Type text into element |
|
||||
| `clear` | `{tabId, nodeId}` | `{success}` | Clear text from element |
|
||||
| `scrollToNode` | `{tabId, nodeId}` | `{scrolled}` | Scroll element into view |
|
||||
| `captureScreenshot` | `{tabId, size?, showHighlights?}` | `{dataUrl}` | Take screenshot |
|
||||
| `sendKeys` | `{tabId, keys}` | `{success}` | Send keyboard keys |
|
||||
| `getPageLoadStatus` | `{tabId}` | `PageLoadStatus` | Get page load status |
|
||||
| `getSnapshot` | `{tabId, type, options?}` | `Snapshot` | Get text/links snapshot |
|
||||
| `clickCoordinates` | `{tabId, x, y}` | `{success}` | Click at coordinates |
|
||||
| `typeAtCoordinates` | `{tabId, x, y, text}` | `{success}` | Type at coordinates |
|
||||
|
||||
---
|
||||
|
||||
## 🔌 WebSocket Protocol
|
||||
|
||||
**Endpoint:** `ws://localhost:9224/controller`
|
||||
|
||||
**Request Format:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "unique-request-id",
|
||||
"action": "click",
|
||||
"payload": {
|
||||
"tabId": 12345,
|
||||
"nodeId": 42
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response Format:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "unique-request-id",
|
||||
"ok": true,
|
||||
"data": {
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Error Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "unique-request-id",
|
||||
"ok": false,
|
||||
"error": "Element not found: nodeId 42"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Common Issues
|
||||
|
||||
### Issue 1: "chrome.browserOS is undefined"
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Diagnostic shows `"available": false`
|
||||
- All browser actions fail
|
||||
|
||||
**Cause:** Not using BrowserOS Chrome
|
||||
|
||||
**Solution:**
|
||||
|
||||
- Download and use BrowserOS Chrome (not regular Chrome)
|
||||
- Verify at `chrome://version` - should show "BrowserOS" in the name
|
||||
|
||||
---
|
||||
|
||||
### Issue 2: "Port 9224 is already in use"
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
```
|
||||
❌ Fatal Error: Port 9224 is already in use!
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
lsof -ti:9224 | xargs kill -9
|
||||
npm test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Issue 3: Extension Not Connecting
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Test client shows "Waiting for extension to connect..." forever
|
||||
- Service worker console shows "Connection timeout"
|
||||
|
||||
**Checklist:**
|
||||
|
||||
1. ✅ Test server running (`npm test`)
|
||||
2. ✅ Extension loaded in BrowserOS Chrome
|
||||
3. ✅ Extension enabled (chrome://extensions/)
|
||||
4. ✅ Service worker active (not suspended)
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Reload extension: chrome://extensions/ → "Reload" button
|
||||
2. Restart test server: Ctrl+C, then `npm test`
|
||||
|
||||
---
|
||||
|
||||
### Issue 4: "Unknown action"
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
```
|
||||
Error: Unknown action: "click". Available actions: getActiveTab, getTabs, ...
|
||||
```
|
||||
|
||||
**Cause:** Action not registered (extension didn't reload properly)
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Toggle extension OFF and ON at chrome://extensions/
|
||||
2. Check service worker console for: `Registered 14 action(s): ...`
|
||||
|
||||
---
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
browseros-controller/
|
||||
├── README.md # This file
|
||||
├── ARCHITECTURE.md # Complete architecture documentation
|
||||
├── .env.example # Environment variable template
|
||||
├── manifest.json # Extension manifest
|
||||
├── package.json # Node dependencies
|
||||
├── webpack.config.js # Build configuration
|
||||
│
|
||||
├── src/ # Source code
|
||||
│ ├── background/ # Service worker entry point
|
||||
│ ├── actions/ # Action handlers
|
||||
│ │ ├── bookmark/ # Bookmark management actions
|
||||
│ │ ├── browser/ # Browser interaction actions
|
||||
│ │ ├── diagnostics/ # Diagnostic actions
|
||||
│ │ ├── history/ # History management actions
|
||||
│ │ └── tab/ # Tab management actions
|
||||
│ ├── adapters/ # Chrome API wrappers
|
||||
│ ├── config/ # Configuration management
|
||||
│ │ ├── constants.ts # Application constants
|
||||
│ │ └── environment.ts # Environment variable handling
|
||||
│ ├── websocket/ # WebSocket client
|
||||
│ ├── utils/ # Utilities
|
||||
│ ├── protocol/ # Protocol types
|
||||
│ └── types/ # TypeScript definitions
|
||||
│
|
||||
├── tests/ # Test files
|
||||
│ ├── test-simple.js # Interactive test client
|
||||
│ └── test-auto.js # Automated test client
|
||||
│
|
||||
└── dist/ # Built extension (generated)
|
||||
├── background.js
|
||||
└── manifest.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Projects
|
||||
|
||||
- **BrowserOS-agent**: AI agent that uses this controller for browser automation
|
||||
- **BrowserOS Chrome**: Custom Chrome build with `chrome.browserOS` APIs
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT
|
||||
|
||||
---
|
||||
|
||||
## 🆘 Support
|
||||
|
||||
For issues or questions:
|
||||
|
||||
1. Check [ARCHITECTURE.md](./ARCHITECTURE.md) for detailed documentation
|
||||
2. Review the "Common Issues" section above
|
||||
3. Check service worker console for detailed error logs
|
||||
4. Verify you're using BrowserOS Chrome (run diagnostic test)
|
||||
|
||||
---
|
||||
|
||||
**Happy automating! 🚀**
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 574 B |
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "BrowserOS Controller",
|
||||
"version": "1.0.0.8",
|
||||
"description": "BrowserOS API bridge for BrowserOS Server",
|
||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhlh9i/c2A3f0PL86hXhGPzguLIOQ+sPf3/Y8RD11gmdvoU6XqnUqv7GgBvm7SW7316uPnS58AYZY13jGtF4rFrscdda5H2CjZrtOyOycmKp2KzibJLwibXNm/JwKhZ3QEfgsW/orh1SMY2kNj62JemkWLcLyn3E1T+KTcTVyFOxiJS3hyQ+Y0/Jp1HOqGh5lYS58YYzwhId5rrJjfL7wFYtALgt2dEA2r7p4qpe+SW0QLA+ayjRAjS+yt+qitR0eWg+XgqcIk1f1KblN8/yDISssSD4LWiPofe5CmJPnqlHIuI0CpgvAFv9dvgR/w8OFkXxK5h06i6saum1xExj+IwIDAQAB",
|
||||
"permissions": [
|
||||
"tabs",
|
||||
"activeTab",
|
||||
"bookmarks",
|
||||
"history",
|
||||
"scripting",
|
||||
"storage",
|
||||
"tabGroups",
|
||||
"webNavigation",
|
||||
"downloads",
|
||||
"browserOS",
|
||||
"alarms"
|
||||
],
|
||||
"update_url": "https://cdn.browseros.com/extensions/update-manifest.xml",
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"background": {
|
||||
"service_worker": "background.js",
|
||||
"type": "module"
|
||||
},
|
||||
"action": {
|
||||
"default_icon": {
|
||||
"16": "assets/icon16.png",
|
||||
"48": "assets/icon48.png",
|
||||
"128": "assets/icon128.png"
|
||||
}
|
||||
},
|
||||
"icons": {
|
||||
"16": "assets/icon16.png",
|
||||
"48": "assets/icon48.png",
|
||||
"128": "assets/icon128.png"
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"name": "browseros-controller",
|
||||
"version": "1.0.0",
|
||||
"description": "Chrome Extension API bridge for BrowserOS Server",
|
||||
"directories": {
|
||||
"doc": "docs"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "webpack --mode production",
|
||||
"build:dev": "webpack --mode development",
|
||||
"watch": "webpack --mode development --watch",
|
||||
"test": "node tests/test-simple.js",
|
||||
"test:auto": "node tests/test-auto.js",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"keywords": [
|
||||
"browser-automation",
|
||||
"chrome-extension",
|
||||
"browseros"
|
||||
],
|
||||
"author": "BrowserOS Team",
|
||||
"license": "MIT",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"@browseros/shared": "workspace:*",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.1.24",
|
||||
"@types/node": "^24.7.1",
|
||||
"copy-webpack-plugin": "^12.0.2",
|
||||
"terser-webpack-plugin": "^5.3.11",
|
||||
"ts-loader": "^9.5.4",
|
||||
"typescript": "^5.9.3",
|
||||
"webpack": "^5.102.1",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { ActionResponse } from '@/protocol/types'
|
||||
import { ActionResponseSchema } from '@/protocol/types'
|
||||
import { logger } from '@/utils/logger'
|
||||
|
||||
// Re-export for convenience
|
||||
export type { ActionResponse }
|
||||
export { ActionResponseSchema }
|
||||
|
||||
/**
|
||||
* ActionHandler - Abstract base class for all actions
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Define contract for all actions (must implement inputSchema + execute)
|
||||
* - Validate input using Zod schemas
|
||||
* - Handle validation and execution errors
|
||||
* - Return standardized ActionResponse
|
||||
*
|
||||
* Usage:
|
||||
* class MyAction extends ActionHandler<InputType, OutputType> {
|
||||
* inputSchema = z.object({ ... });
|
||||
* async execute(input: InputType): Promise<OutputType> { ... }
|
||||
* }
|
||||
*/
|
||||
export abstract class ActionHandler<TInput = unknown, TOutput = unknown> {
|
||||
/**
|
||||
* Zod schema for input validation
|
||||
* Must be implemented by concrete actions
|
||||
*/
|
||||
abstract readonly inputSchema: z.ZodSchema<TInput>
|
||||
|
||||
/**
|
||||
* Execute the action logic
|
||||
* Must be implemented by concrete actions
|
||||
*
|
||||
* @param input - Validated input (guaranteed to match inputSchema)
|
||||
* @returns Action result
|
||||
*/
|
||||
abstract execute(input: TInput): Promise<TOutput>
|
||||
|
||||
/**
|
||||
* Handle request with validation and error handling
|
||||
* Called by ActionRegistry
|
||||
*
|
||||
* Flow:
|
||||
* 1. Validate input with Zod schema
|
||||
* 2. Execute action logic
|
||||
* 3. Return standardized response (ok/error)
|
||||
*
|
||||
* @param payload - Raw payload from request (unvalidated)
|
||||
* @returns Standardized action response
|
||||
*/
|
||||
async handle(payload: unknown): Promise<ActionResponse> {
|
||||
const actionName = this.constructor.name
|
||||
|
||||
try {
|
||||
// Step 1: Validate input
|
||||
logger.debug(`[${actionName}] Validating input`)
|
||||
const validatedInput = this.inputSchema.parse(payload)
|
||||
|
||||
// Step 2: Execute action
|
||||
logger.debug(`[${actionName}] Executing action`)
|
||||
const result = await this.execute(validatedInput)
|
||||
|
||||
// Step 3: Return success response
|
||||
logger.debug(`[${actionName}] Action completed successfully`)
|
||||
return { ok: true, data: result }
|
||||
} catch (error) {
|
||||
// Handle validation or execution errors
|
||||
const errorMessage = this._formatError(error)
|
||||
logger.error(`[${actionName}] Action failed: ${errorMessage}`)
|
||||
return { ok: false, error: errorMessage }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format error for user-friendly response
|
||||
*
|
||||
* @param error - Error from validation or execution
|
||||
* @returns Formatted error message
|
||||
*/
|
||||
protected _formatError(error: unknown): string {
|
||||
// Zod validation error
|
||||
if (error instanceof z.ZodError) {
|
||||
const errors = error.issues.map((e: z.ZodIssue) => {
|
||||
const path = e.path.length > 0 ? `${e.path.join('.')}: ` : ''
|
||||
return `${path}${e.message}`
|
||||
})
|
||||
return `Validation error: ${errors.join(', ')}`
|
||||
}
|
||||
|
||||
// Standard Error
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
// Unknown error
|
||||
return String(error)
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { logger } from '@/utils/logger'
|
||||
import type { ActionHandler, ActionResponse } from './ActionHandler'
|
||||
|
||||
/**
|
||||
* ActionRegistry - Central dispatcher for all actions
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Register action handlers by name
|
||||
* - Dispatch requests to correct handler
|
||||
* - Return error for unknown actions
|
||||
* - Provide introspection (list available actions)
|
||||
*
|
||||
* Usage:
|
||||
* const registry = new ActionRegistry();
|
||||
* registry.register('getActiveTab', new GetActiveTabAction());
|
||||
* const response = await registry.dispatch('getActiveTab', {});
|
||||
*/
|
||||
export class ActionRegistry {
|
||||
private handlers = new Map<string, ActionHandler>()
|
||||
|
||||
/**
|
||||
* Register an action handler
|
||||
*
|
||||
* @param actionName - Unique action name (e.g., "getActiveTab")
|
||||
* @param handler - Action handler instance
|
||||
*/
|
||||
register(actionName: string, handler: ActionHandler): void {
|
||||
if (this.handlers.has(actionName)) {
|
||||
logger.warn(
|
||||
`[ActionRegistry] Action "${actionName}" already registered, overwriting`,
|
||||
)
|
||||
}
|
||||
|
||||
this.handlers.set(actionName, handler)
|
||||
logger.info(`[ActionRegistry] Registered action: ${actionName}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch request to appropriate action handler
|
||||
*
|
||||
* Flow:
|
||||
* 1. Find handler for action name
|
||||
* 2. If not found, return error
|
||||
* 3. If found, delegate to handler.handle()
|
||||
* 4. Handler validates input and executes
|
||||
* 5. Return result
|
||||
*
|
||||
* @param actionName - Action to execute
|
||||
* @param payload - Action payload (unvalidated)
|
||||
* @returns Action response
|
||||
*/
|
||||
async dispatch(
|
||||
actionName: string,
|
||||
payload: unknown,
|
||||
): Promise<ActionResponse> {
|
||||
logger.debug(`[ActionRegistry] Dispatching action: ${actionName}`)
|
||||
|
||||
// Check if action exists
|
||||
const handler = this.handlers.get(actionName)
|
||||
|
||||
if (!handler) {
|
||||
const availableActions = Array.from(this.handlers.keys()).join(', ')
|
||||
const errorMessage = `Unknown action: "${actionName}". Available actions: ${availableActions || 'none'}`
|
||||
logger.error(`[ActionRegistry] ${errorMessage}`)
|
||||
return {
|
||||
ok: false,
|
||||
error: errorMessage,
|
||||
}
|
||||
}
|
||||
|
||||
// Delegate to handler
|
||||
try {
|
||||
const response = await handler.handle(payload)
|
||||
logger.debug(
|
||||
`[ActionRegistry] Action "${actionName}" ${response.ok ? 'succeeded' : 'failed'}`,
|
||||
)
|
||||
return response
|
||||
} catch (error) {
|
||||
// Catch any unexpected errors from handler
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
logger.error(
|
||||
`[ActionRegistry] Unexpected error in "${actionName}": ${errorMessage}`,
|
||||
)
|
||||
return {
|
||||
ok: false,
|
||||
error: `Action execution failed: ${errorMessage}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of registered action names
|
||||
*
|
||||
* @returns Array of action names
|
||||
*/
|
||||
getAvailableActions(): string[] {
|
||||
return Array.from(this.handlers.keys())
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if action is registered
|
||||
*
|
||||
* @param actionName - Action name to check
|
||||
* @returns True if action exists
|
||||
*/
|
||||
hasAction(actionName: string): boolean {
|
||||
return this.handlers.has(actionName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of registered actions
|
||||
*
|
||||
* @returns Count of registered actions
|
||||
*/
|
||||
getActionCount(): number {
|
||||
return this.handlers.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister an action (useful for testing)
|
||||
*
|
||||
* @param actionName - Action to remove
|
||||
* @returns True if action was removed
|
||||
*/
|
||||
unregister(actionName: string): boolean {
|
||||
const removed = this.handlers.delete(actionName)
|
||||
if (removed) {
|
||||
logger.info(`[ActionRegistry] Unregistered action: ${actionName}`)
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all registered actions (useful for testing)
|
||||
*/
|
||||
clear(): void {
|
||||
const count = this.handlers.size
|
||||
this.handlers.clear()
|
||||
logger.info(`[ActionRegistry] Cleared ${count} registered actions`)
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { BookmarkAdapter } from '@/adapters/BookmarkAdapter'
|
||||
import { ActionHandler } from '../ActionHandler'
|
||||
|
||||
// Input schema
|
||||
const CreateBookmarkInputSchema = z.object({
|
||||
title: z.string().describe('Bookmark title'),
|
||||
url: z.string().url().describe('Bookmark URL'),
|
||||
parentId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Parent folder ID (optional, defaults to "Other Bookmarks")'),
|
||||
})
|
||||
|
||||
// Output schema
|
||||
const CreateBookmarkOutputSchema = z.object({
|
||||
id: z.string().describe('Created bookmark ID'),
|
||||
title: z.string().describe('Bookmark title'),
|
||||
url: z.string().describe('Bookmark URL'),
|
||||
dateAdded: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Timestamp when bookmark was created'),
|
||||
})
|
||||
|
||||
type CreateBookmarkInput = z.infer<typeof CreateBookmarkInputSchema>
|
||||
type CreateBookmarkOutput = z.infer<typeof CreateBookmarkOutputSchema>
|
||||
|
||||
/**
|
||||
* CreateBookmarkAction - Create a new bookmark
|
||||
*
|
||||
* Creates a bookmark with the specified title and URL.
|
||||
*
|
||||
* Input:
|
||||
* - title: Display title for the bookmark
|
||||
* - url: Full URL to bookmark
|
||||
* - parentId (optional): Parent folder ID
|
||||
*
|
||||
* Output:
|
||||
* - id: Created bookmark ID
|
||||
* - title: Bookmark title
|
||||
* - url: Bookmark URL
|
||||
* - dateAdded: Creation timestamp
|
||||
*
|
||||
* Usage:
|
||||
* Create a bookmark in the default location (Other Bookmarks).
|
||||
*
|
||||
* Example:
|
||||
* {
|
||||
* "title": "Google",
|
||||
* "url": "https://www.google.com"
|
||||
* }
|
||||
* // Returns: { id: "123", title: "Google", url: "https://www.google.com", dateAdded: 1729012345678 }
|
||||
*/
|
||||
export class CreateBookmarkAction extends ActionHandler<
|
||||
CreateBookmarkInput,
|
||||
CreateBookmarkOutput
|
||||
> {
|
||||
readonly inputSchema = CreateBookmarkInputSchema
|
||||
private bookmarkAdapter = new BookmarkAdapter()
|
||||
|
||||
async execute(input: CreateBookmarkInput): Promise<CreateBookmarkOutput> {
|
||||
const created = await this.bookmarkAdapter.createBookmark({
|
||||
title: input.title,
|
||||
url: input.url,
|
||||
parentId: input.parentId,
|
||||
})
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
title: created.title,
|
||||
url: created.url || '',
|
||||
dateAdded: created.dateAdded,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { BookmarkAdapter } from '@/adapters/BookmarkAdapter'
|
||||
import { ActionHandler } from '../ActionHandler'
|
||||
|
||||
const CreateBookmarkFolderInputSchema = z.object({
|
||||
title: z.string().describe('Folder name'),
|
||||
parentId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Parent folder ID (defaults to "1" = Bookmarks Bar)'),
|
||||
})
|
||||
|
||||
const CreateBookmarkFolderOutputSchema = z.object({
|
||||
id: z.string().describe('Created folder ID'),
|
||||
title: z.string().describe('Folder name'),
|
||||
parentId: z.string().optional().describe('Parent folder ID'),
|
||||
dateAdded: z.number().optional().describe('Creation timestamp'),
|
||||
})
|
||||
|
||||
type CreateBookmarkFolderInput = z.infer<typeof CreateBookmarkFolderInputSchema>
|
||||
type CreateBookmarkFolderOutput = z.infer<
|
||||
typeof CreateBookmarkFolderOutputSchema
|
||||
>
|
||||
|
||||
export class CreateBookmarkFolderAction extends ActionHandler<
|
||||
CreateBookmarkFolderInput,
|
||||
CreateBookmarkFolderOutput
|
||||
> {
|
||||
readonly inputSchema = CreateBookmarkFolderInputSchema
|
||||
private bookmarkAdapter = new BookmarkAdapter()
|
||||
|
||||
async execute(
|
||||
input: CreateBookmarkFolderInput,
|
||||
): Promise<CreateBookmarkFolderOutput> {
|
||||
const created = await this.bookmarkAdapter.createBookmarkFolder({
|
||||
title: input.title,
|
||||
parentId: input.parentId,
|
||||
})
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
title: created.title,
|
||||
parentId: created.parentId,
|
||||
dateAdded: created.dateAdded,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { BookmarkAdapter } from '@/adapters/BookmarkAdapter'
|
||||
import { ActionHandler } from '../ActionHandler'
|
||||
|
||||
const GetBookmarkChildrenInputSchema = z.object({
|
||||
folderId: z.string().describe('Folder ID to get children from'),
|
||||
})
|
||||
|
||||
const GetBookmarkChildrenOutputSchema = z.object({
|
||||
children: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
url: z.string().optional(),
|
||||
parentId: z.string().optional(),
|
||||
dateAdded: z.number().optional(),
|
||||
isFolder: z.boolean(),
|
||||
}),
|
||||
),
|
||||
count: z.number(),
|
||||
})
|
||||
|
||||
type GetBookmarkChildrenInput = z.infer<typeof GetBookmarkChildrenInputSchema>
|
||||
type GetBookmarkChildrenOutput = z.infer<typeof GetBookmarkChildrenOutputSchema>
|
||||
|
||||
export class GetBookmarkChildrenAction extends ActionHandler<
|
||||
GetBookmarkChildrenInput,
|
||||
GetBookmarkChildrenOutput
|
||||
> {
|
||||
readonly inputSchema = GetBookmarkChildrenInputSchema
|
||||
private bookmarkAdapter = new BookmarkAdapter()
|
||||
|
||||
async execute(
|
||||
input: GetBookmarkChildrenInput,
|
||||
): Promise<GetBookmarkChildrenOutput> {
|
||||
const results = await this.bookmarkAdapter.getBookmarkChildren(
|
||||
input.folderId,
|
||||
)
|
||||
|
||||
const children = results.map((node) => ({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
url: node.url,
|
||||
parentId: node.parentId,
|
||||
dateAdded: node.dateAdded,
|
||||
isFolder: !node.url,
|
||||
}))
|
||||
|
||||
return {
|
||||
children,
|
||||
count: children.length,
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user