mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
Compare commits
38 Commits
browseros-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dde403962f | ||
|
|
4a3b9ff294 | ||
|
|
1a2fe3a5bf | ||
|
|
392cd58932 | ||
|
|
b5bbbe1aff | ||
|
|
4f03afcac8 | ||
|
|
6d3498c91b | ||
|
|
7f2e387903 | ||
|
|
fc00ed23bf | ||
|
|
b6d6d4eb1d | ||
|
|
f78068bb9d | ||
|
|
6b18ebb1d8 | ||
|
|
1f2e783ab9 | ||
|
|
df7873562d | ||
|
|
412386b489 | ||
|
|
33617ba9e7 | ||
|
|
6712e1d321 | ||
|
|
94540d9e87 | ||
|
|
bb62213e84 | ||
|
|
dee3086a48 | ||
|
|
8de2bf984f | ||
|
|
1b8720740c | ||
|
|
91be726381 | ||
|
|
ff5386a24a | ||
|
|
a5f3c4da65 | ||
|
|
e5a852dd3d | ||
|
|
aee30ce8e1 | ||
|
|
0833c8d42d | ||
|
|
036c7f280b | ||
|
|
000429277d | ||
|
|
f8535fd96d | ||
|
|
f0cbf77924 | ||
|
|
17be06eb2f | ||
|
|
0e90785500 | ||
|
|
2bb432b0f2 | ||
|
|
565ce18eba | ||
|
|
81350c0d7f | ||
|
|
9bdb2413ec |
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -9,4 +9,6 @@ packages/browseros/chromium_patches/**/*.py linguist-generated
|
||||
scripts/*.py linguist-generated
|
||||
# Mark build directories as generated
|
||||
build/* linguist-generated
|
||||
# Mark eval/test framework as vendored so it's excluded from language stats
|
||||
packages/browseros-agent/apps/eval/** linguist-vendored
|
||||
docs/videos/** filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
10
.github/workflows/eval-weekly.yml
vendored
10
.github/workflows/eval-weekly.yml
vendored
@@ -43,6 +43,12 @@ jobs:
|
||||
working-directory: packages/browseros-agent
|
||||
run: bun install --ignore-scripts && bun run build:agent-sdk
|
||||
|
||||
- name: Install Python eval dependencies
|
||||
run: pip install agisdk requests
|
||||
|
||||
- name: Clone WebArena-Infinity
|
||||
run: git clone --depth 1 https://github.com/web-arena-x/webarena-infinity.git /tmp/webarena-infinity
|
||||
|
||||
- name: Install xvfb
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb
|
||||
|
||||
@@ -57,9 +63,11 @@ jobs:
|
||||
working-directory: packages/browseros-agent/apps/eval
|
||||
env:
|
||||
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
NOPECHA_API_KEY: ${{ secrets.NOPECHA_API_KEY }}
|
||||
BROWSEROS_BINARY: /usr/bin/browseros
|
||||
WEBARENA_INFINITY_DIR: /tmp/webarena-infinity
|
||||
EVAL_CONFIG: ${{ github.event.inputs.config || 'configs/browseros-agent-weekly.json' }}
|
||||
run: |
|
||||
echo "Running eval with config: $EVAL_CONFIG"
|
||||
@@ -81,6 +89,8 @@ jobs:
|
||||
|
||||
- name: Generate trend report
|
||||
if: success()
|
||||
timeout-minutes: 5
|
||||
continue-on-error: true
|
||||
working-directory: packages/browseros-agent
|
||||
env:
|
||||
EVAL_R2_ACCOUNT_ID: ${{ secrets.EVAL_R2_ACCOUNT_ID }}
|
||||
|
||||
21
.github/workflows/release-cli.yml
vendored
21
.github/workflows/release-cli.yml
vendored
@@ -103,6 +103,13 @@ jobs:
|
||||
|
||||
## Install `browseros-cli`
|
||||
|
||||
### npm / npx
|
||||
|
||||
```bash
|
||||
npx browseros-cli --help
|
||||
npm install -g browseros-cli
|
||||
```
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
```bash
|
||||
@@ -138,3 +145,17 @@ jobs:
|
||||
--notes-file /tmp/release-notes.md \
|
||||
${CLI_DIST}/*
|
||||
working-directory: ${{ github.workspace }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Publish to npm
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
make npm-version VERSION=${{ inputs.version }}
|
||||
cd npm
|
||||
npm publish --access public
|
||||
|
||||
147
.github/workflows/release-server.yml
vendored
Normal file
147
.github/workflows/release-server.yml
vendored
Normal file
@@ -0,0 +1,147 @@
|
||||
name: Release BrowserOS Server
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release version (e.g. 0.0.80)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: release-server
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
environment: release-core
|
||||
permissions:
|
||||
contents: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/browseros-agent
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.6"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun ci
|
||||
|
||||
- name: Prepare production env file
|
||||
run: cp apps/server/.env.production.example apps/server/.env.production
|
||||
|
||||
- name: Validate version
|
||||
id: version
|
||||
env:
|
||||
REQUESTED_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
PACKAGE_VERSION=$(node -p "require('./apps/server/package.json').version")
|
||||
echo "package_version=$PACKAGE_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "release_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [ "$PACKAGE_VERSION" != "$REQUESTED_VERSION" ]; then
|
||||
echo "Requested version $REQUESTED_VERSION does not match apps/server/package.json ($PACKAGE_VERSION)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build release artifacts
|
||||
run: bun run build:server:ci
|
||||
|
||||
- name: Verify release artifacts
|
||||
run: |
|
||||
mapfile -t ZIP_FILES < <(find dist/prod/server -maxdepth 1 -type f -name 'browseros-server-resources-*.zip' | sort)
|
||||
|
||||
if [ "${#ZIP_FILES[@]}" -eq 0 ]; then
|
||||
echo "No server release zip files were produced"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'Found release artifacts:\n%s\n' "${ZIP_FILES[@]}"
|
||||
|
||||
- name: Generate release notes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PACKAGE_VERSION: ${{ steps.version.outputs.package_version }}
|
||||
run: |
|
||||
SERVER_APP_PATH="packages/browseros-agent/apps/server"
|
||||
SERVER_BUILD_DIR="packages/browseros-agent/scripts/build/server"
|
||||
SERVER_BUILD_ENTRY="packages/browseros-agent/scripts/build/server.ts"
|
||||
SERVER_RESOURCE_MANIFEST="packages/browseros-agent/scripts/build/config/server-prod-resources.json"
|
||||
SERVER_WORKSPACE_PKG="packages/browseros-agent/package.json"
|
||||
CURRENT_TAG="browseros-server-v$PACKAGE_VERSION"
|
||||
PREV_TAG=$(git tag -l "browseros-server-v*" --sort=-v:refname | grep -v "^${CURRENT_TAG}$" | head -n 1)
|
||||
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
echo "Initial release of browseros-server." > /tmp/release-notes.md
|
||||
else
|
||||
COMMITS=$(git log "$PREV_TAG"..HEAD --pretty=format:"%H" -- \
|
||||
"$SERVER_APP_PATH" \
|
||||
"$SERVER_BUILD_DIR" \
|
||||
"$SERVER_BUILD_ENTRY" \
|
||||
"$SERVER_RESOURCE_MANIFEST" \
|
||||
"$SERVER_WORKSPACE_PKG")
|
||||
|
||||
if [ -z "$COMMITS" ]; then
|
||||
echo "No notable changes." > /tmp/release-notes.md
|
||||
else
|
||||
echo "## What's Changed" > /tmp/release-notes.md
|
||||
echo "" >> /tmp/release-notes.md
|
||||
|
||||
while IFS= read -r SHA; do
|
||||
SUBJECT=$(git log -1 --pretty=format:"%s" "$SHA")
|
||||
PR_NUM=$(gh api "/repos/${{ github.repository }}/commits/${SHA}/pulls" --jq '.[0].number // empty' 2>/dev/null)
|
||||
|
||||
if [ -n "$PR_NUM" ] && ! echo "$SUBJECT" | grep -qF "(#${PR_NUM})"; then
|
||||
echo "- ${SUBJECT} (#${PR_NUM})" >> /tmp/release-notes.md
|
||||
else
|
||||
echo "- ${SUBJECT}" >> /tmp/release-notes.md
|
||||
fi
|
||||
done <<< "$COMMITS"
|
||||
fi
|
||||
fi
|
||||
working-directory: ${{ github.workspace }}
|
||||
|
||||
- name: Create GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PACKAGE_VERSION: ${{ steps.version.outputs.package_version }}
|
||||
RELEASE_SHA: ${{ steps.version.outputs.release_sha }}
|
||||
run: |
|
||||
TAG="browseros-server-v$PACKAGE_VERSION"
|
||||
TITLE="BrowserOS Server - v$PACKAGE_VERSION"
|
||||
mapfile -t ZIP_FILES < <(find packages/browseros-agent/dist/prod/server -maxdepth 1 -type f -name 'browseros-server-resources-*.zip' | sort)
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
if git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
echo "Tag $TAG already exists, skipping tag creation"
|
||||
else
|
||||
git tag -a "$TAG" -m "browseros-server v$PACKAGE_VERSION" "$RELEASE_SHA"
|
||||
fi
|
||||
|
||||
if git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then
|
||||
echo "Tag $TAG already on remote, skipping push"
|
||||
else
|
||||
git push origin "$TAG"
|
||||
fi
|
||||
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
echo "Release $TAG already exists, updating"
|
||||
gh release edit "$TAG" --title "$TITLE" --notes-file /tmp/release-notes.md
|
||||
gh release upload "$TAG" "${ZIP_FILES[@]}" --clobber
|
||||
else
|
||||
gh release create "$TAG" \
|
||||
--title "$TITLE" \
|
||||
--notes-file /tmp/release-notes.md \
|
||||
"${ZIP_FILES[@]}"
|
||||
fi
|
||||
working-directory: ${{ github.workspace }}
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,4 +1,6 @@
|
||||
**/.DS_Store
|
||||
**.auctor/**
|
||||
.auctor.json
|
||||
.gcs_entries
|
||||
**/dmg
|
||||
**/env
|
||||
@@ -29,3 +31,4 @@ packages/browseros/build/tools/
|
||||
|
||||
# AI SDK DevTools traces
|
||||
.devtools/
|
||||
.omc/
|
||||
|
||||
@@ -192,7 +192,7 @@ We'd love your help making BrowserOS better! See our [Contributing Guide](CONTRI
|
||||
|
||||
BrowserOS is open source under the [AGPL-3.0 license](LICENSE).
|
||||
|
||||
Copyright © 2025 Felafax, Inc.
|
||||
Copyright © 2026 Felafax, Inc.
|
||||
|
||||
## Stargazers
|
||||
|
||||
|
||||
@@ -3,13 +3,17 @@ title: "Ad Blocking"
|
||||
description: "BrowserOS supports full ad blocking with uBlock Origin"
|
||||
---
|
||||
|
||||
BrowserOS supports full ad blocking through [uBlock Origin](https://ublockorigin.com/), the most effective open-source ad blocker available.
|
||||
BrowserOS supports full ad blocking through [uBlock Origin](https://ublockorigin.com/), the most powerful open-source ad blocker available — the full extension, not the watered-down "Lite" version.
|
||||
|
||||
## How It Works
|
||||
## Why BrowserOS?
|
||||
|
||||
Chrome has been [phasing out support](https://developer.chrome.com/docs/extensions/develop/migrate/mv2-deprecation-timeline) for Manifest V2 extensions, which uBlock Origin relies on for its full blocking capabilities. We re-enabled Manifest V2 support in BrowserOS so uBlock Origin can run at full power.
|
||||
Chrome [killed support](https://developer.chrome.com/docs/extensions/develop/migrate/mv2-deprecation-timeline) for uBlock Origin by phasing out Manifest V2 extensions. The only option left on Chrome is "uBlock Origin Lite," a significantly weaker version that can't use advanced filtering rules.
|
||||
|
||||
Install it from the Chrome Web Store: [uBlock Origin](https://chromewebstore.google.com/detail/ublock-origin/cjpalhdlnbpafiamejdnhcphjbkeiagm)
|
||||
**BrowserOS re-enabled full Manifest V2 support**, so you can install and run the original uBlock Origin at full power — the same extension Chrome no longer allows.
|
||||
|
||||
<Card title="Install uBlock Origin" icon="shield-check" href="https://chromewebstore.google.com/detail/ublock-origin/cjpalhdlnbpafiamejdnhcphjbkeiagm">
|
||||
Install the full uBlock Origin extension from the Chrome Web Store. Works on BrowserOS out of the box.
|
||||
</Card>
|
||||
|
||||
## BrowserOS vs Chrome
|
||||
|
||||
|
||||
@@ -131,6 +131,29 @@ Connect to powerful AI models using your API keys. Your keys stay on your machin
|
||||

|
||||
</Accordion>
|
||||
|
||||
<div id="nvidia" />
|
||||
<Accordion title="NVIDIA (Free)" icon="microchip">
|
||||
NVIDIA's [build.nvidia.com](https://build.nvidia.com/models) hosts 80+ models — including GLM 5.1, MiniMax M2.7, GPT-OSS-120B, Qwen 3.5, Mistral, and Nemotron — behind a **free OpenAI-compatible API endpoint**. Great for chatting, prototyping, and personal projects.
|
||||
|
||||
**Get your API key:**
|
||||
1. Go to [build.nvidia.com/models](https://build.nvidia.com/models) and sign in with a free NVIDIA developer account
|
||||
2. Pick any model tagged **Free Endpoint** (e.g. [`minimaxai/minimax-m2.7`](https://build.nvidia.com/minimaxai/minimax-m2.7), [`z-ai/glm-5.1`](https://build.nvidia.com/z-ai/glm-5.1), [`qwen/qwen3.5-122b-a10b`](https://build.nvidia.com/qwen/qwen3.5-122b-a10b))
|
||||
3. Click **Get API Key** on the model page and copy the `nvapi-...` key
|
||||
|
||||
**Add to BrowserOS:**
|
||||
1. Go to `chrome://browseros/settings`
|
||||
2. Click **USE** on the **OpenAI Compatible** card
|
||||
3. Set **Base URL** to `https://integrate.api.nvidia.com/v1`
|
||||
4. Set **Model ID** to a model from the catalog (e.g. `minimaxai/minimax-m2.7`, `z-ai/glm-5.1`, `qwen/qwen3.5-122b-a10b`)
|
||||
5. Paste your NVIDIA API key
|
||||
6. Set **Context Window** based on the model (most are `128000` or higher)
|
||||
7. Click **Save**
|
||||
|
||||
<Tip>
|
||||
NVIDIA's free endpoints share GPU capacity across all developers, so throughput is slower than a paid API. They're best for Chat Mode, exploring new open-source models, and personal projects. For production agent workloads, use a paid provider like Claude or Kimi.
|
||||
</Tip>
|
||||
</Accordion>
|
||||
|
||||
<div id="claude" />
|
||||
<Accordion title="Claude (Best for Agents)" icon="message-bot">
|
||||
Claude Opus 4.5 gives the best results for Agent Mode.
|
||||
|
||||
@@ -42,6 +42,10 @@ Welcome to BrowserOS! Let's get you set up.
|
||||
|
||||
## You're all set!
|
||||
|
||||
<Tip>
|
||||
**Block ads with uBlock Origin** — Chrome dropped support for the full uBlock Origin extension, but BrowserOS brought it back. [Install it from the Chrome Web Store](https://chromewebstore.google.com/detail/ublock-origin/cjpalhdlnbpafiamejdnhcphjbkeiagm) and browse ad-free. [Learn more →](/features/ad-blocking)
|
||||
</Tip>
|
||||
|
||||
Explore what BrowserOS can do:
|
||||
|
||||
<Columns cols={2}>
|
||||
|
||||
@@ -32,7 +32,7 @@ Use **kebab-case** for all file and folder names:
|
||||
| Multi-word files | kebab-case | `gemini-agent.ts`, `mcp-context.ts` |
|
||||
| Single-word files | lowercase | `types.ts`, `browser.ts`, `index.ts` |
|
||||
| Test files | `.test.ts` suffix | `mcp-context.test.ts` |
|
||||
| Folders | kebab-case | `controller-server/`, `rate-limiter/` |
|
||||
| Folders | kebab-case | `rate-limiter/`, `browser-tools/` |
|
||||
|
||||
Classes remain PascalCase in code, but live in kebab-case files:
|
||||
```typescript
|
||||
@@ -97,21 +97,16 @@ The main MCP server that exposes browser automation tools via HTTP/SSE.
|
||||
|
||||
**Key components:**
|
||||
- `src/tools/` - MCP tool definitions, split into:
|
||||
- `cdp-based/` - Tools using Chrome DevTools Protocol (network, console, emulation, input, etc.)
|
||||
- `controller-based/` - Tools using the browser extension (navigation, clicks, screenshots, tabs, history, bookmarks)
|
||||
- `src/controller-server/` - WebSocket server that bridges to the browser extension
|
||||
- `ControllerBridge` handles WebSocket connections with extension clients
|
||||
- `ControllerContext` wraps the bridge for tool handlers
|
||||
- `cdp-based/` - Tools using Chrome DevTools Protocol (navigation, DOM interaction, network, console, emulation, input, etc.)
|
||||
- `src/common/` - Shared utilities (McpContext, PageCollector, browser connection, identity, db)
|
||||
- `src/agent/` - AI agent functionality (Gemini adapter, rate limiting, session management)
|
||||
- `src/http/` - Hono HTTP server with MCP, health, and provider routes
|
||||
|
||||
**Tool types:**
|
||||
- CDP tools require a direct CDP connection (`--cdp-port`)
|
||||
- Controller tools work via the browser extension over WebSocket
|
||||
|
||||
### Shared (`packages/shared`)
|
||||
Shared constants, types, and configuration used by both server and extension. Avoids magic numbers.
|
||||
Shared constants, types, and configuration used across packages. Avoids magic numbers.
|
||||
|
||||
**Structure:**
|
||||
- `src/constants/` - Configuration values (ports, timeouts, limits, urls, paths)
|
||||
@@ -119,22 +114,12 @@ Shared constants, types, and configuration used by both server and extension. Av
|
||||
|
||||
**Exports:** `@browseros/shared/constants/*`, `@browseros/shared/types/*`
|
||||
|
||||
### Controller Extension (`apps/controller-ext`)
|
||||
Chrome extension that receives commands from the server via WebSocket.
|
||||
|
||||
**Entry point:** `src/background/index.ts` → `BrowserOSController`
|
||||
|
||||
**Structure:**
|
||||
- `src/actions/` - Action handlers organized by domain (browser/, tab/, bookmark/, history/)
|
||||
- `src/adapters/` - Chrome API adapters (TabAdapter, BookmarkAdapter, HistoryAdapter)
|
||||
- `src/websocket/` - WebSocket client that connects to the server
|
||||
|
||||
### Communication Flow
|
||||
|
||||
```
|
||||
AI Agent/MCP Client → HTTP Server (Hono) → Tool Handler
|
||||
↓
|
||||
CDP (direct) ←── or ──→ WebSocket → Extension → Chrome APIs
|
||||
CDP → BrowserOS / Chrome APIs
|
||||
```
|
||||
|
||||
## Creating Packages
|
||||
|
||||
@@ -10,7 +10,6 @@ apps/
|
||||
agent/ # Agent UI (Chrome extension)
|
||||
cli/ # Go CLI for controlling BrowserOS from the terminal
|
||||
eval/ # Evaluation framework for benchmarking agents
|
||||
controller-ext/ # BrowserOS Controller (Chrome extension for chrome.* APIs)
|
||||
|
||||
packages/
|
||||
agent-sdk/ # Node.js SDK (@browseros-ai/agent-sdk)
|
||||
@@ -24,7 +23,6 @@ packages/
|
||||
| `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 |
|
||||
| `apps/controller-ext` | BrowserOS Controller — bridges `chrome.*` APIs to the server via WebSocket |
|
||||
| `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 |
|
||||
@@ -33,7 +31,6 @@ packages/
|
||||
|
||||
- `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.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
@@ -51,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
|
||||
@@ -72,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
|
||||
|
||||
@@ -96,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
|
||||
|
||||
@@ -114,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) |
|
||||
@@ -146,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 |
|
||||
@@ -163,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,16 @@
|
||||
# BrowserOS Agent Extension
|
||||
|
||||
## v0.0.99 (2026-04-08)
|
||||
|
||||
## What's Changed
|
||||
|
||||
- chore: bump server and extension version (#659)
|
||||
- chore(agent): remove workflows feature (#656)
|
||||
- feat: replace model picker with shadcn Combobox + fuse.js fuzzy search (#617)
|
||||
- feat: clean-up - remove obsolete controller extension (#610)
|
||||
- docs: update agent extension changelog for v0.0.98 (#609)
|
||||
|
||||
|
||||
## v0.0.98 (2026-03-27)
|
||||
|
||||
## What's Changed
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { REFERRAL_LIMITS } from '@browseros/shared/constants/limits'
|
||||
import { ExternalLink, Loader2, Send } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useCredits, useInvalidateCredits } from '@/lib/credits/useCredits'
|
||||
import {
|
||||
getShareOnTwitterUrl,
|
||||
submitReferral,
|
||||
} from '@/lib/referral/submit-referral'
|
||||
|
||||
interface ShareForCreditsProps {
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export const ShareForCredits: FC<ShareForCreditsProps> = ({ compact }) => {
|
||||
const [tweetUrl, setTweetUrl] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [result, setResult] = useState<{
|
||||
success: boolean
|
||||
message: string
|
||||
} | null>(null)
|
||||
|
||||
const { data } = useCredits()
|
||||
const invalidateCredits = useInvalidateCredits()
|
||||
|
||||
const credits = data?.credits ?? 0
|
||||
const atDailyMax = credits >= REFERRAL_LIMITS.MAX_DAILY_CREDITS
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!tweetUrl.trim() || !data?.browserosId || atDailyMax) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
setResult(null)
|
||||
|
||||
try {
|
||||
const res = await submitReferral(tweetUrl.trim(), data.browserosId)
|
||||
if (res.success) {
|
||||
setResult({
|
||||
success: true,
|
||||
message: `${res.creditsAdded ?? 200} credits added!`,
|
||||
})
|
||||
setTweetUrl('')
|
||||
invalidateCredits()
|
||||
} else {
|
||||
setResult({
|
||||
success: false,
|
||||
message: res.reason ?? 'Submission failed. Please try again.',
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
setResult({
|
||||
success: false,
|
||||
message: 'Network error. Please try again.',
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (atDailyMax) {
|
||||
return (
|
||||
<div className={compact ? 'space-y-2' : 'space-y-3'}>
|
||||
<p className={compact ? 'text-muted-foreground text-xs' : 'text-sm'}>
|
||||
You've reached the daily cap of {REFERRAL_LIMITS.MAX_DAILY_CREDITS}{' '}
|
||||
credits. Come back tomorrow to earn more!
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={compact ? 'space-y-2' : 'space-y-3'}>
|
||||
<p className={compact ? 'text-muted-foreground text-xs' : 'text-sm'}>
|
||||
Share BrowserOS on Twitter to earn{' '}
|
||||
{REFERRAL_LIMITS.CREDITS_PER_REFERRAL} bonus credits!
|
||||
</p>
|
||||
|
||||
<ul className="list-disc space-y-0.5 pl-4 text-muted-foreground text-xs">
|
||||
<li>
|
||||
Tweet must mention <span className="font-medium">@browserOS_ai</span>
|
||||
</li>
|
||||
<li>Tweet must be posted within the last 30 minutes</li>
|
||||
<li>Each tweet can only be submitted once</li>
|
||||
<li>
|
||||
Daily cap of {REFERRAL_LIMITS.MAX_DAILY_CREDITS} credits — resets at
|
||||
midnight UTC
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Button variant="outline" size="sm" className="w-full gap-2" asChild>
|
||||
<a
|
||||
href={getShareOnTwitterUrl()}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => {
|
||||
e.currentTarget.href = getShareOnTwitterUrl()
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
Share on Twitter
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Already shared? Paste your tweet link:
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://x.com/..."
|
||||
value={tweetUrl}
|
||||
onChange={(e) => setTweetUrl(e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !tweetUrl.trim()}
|
||||
className="shrink-0 gap-1.5"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<p
|
||||
className={
|
||||
result.success
|
||||
? 'text-green-600 text-xs dark:text-green-400'
|
||||
: 'text-destructive text-xs'
|
||||
}
|
||||
>
|
||||
{result.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Bot,
|
||||
Compass,
|
||||
CreditCard,
|
||||
GitBranch,
|
||||
MessageSquare,
|
||||
Palette,
|
||||
RotateCcw,
|
||||
@@ -86,12 +85,6 @@ const primarySettingsSections: NavSection[] = [
|
||||
icon: CreditCard,
|
||||
feature: Feature.CREDITS_SUPPORT,
|
||||
},
|
||||
{
|
||||
name: 'Workflows',
|
||||
to: '/workflows',
|
||||
icon: GitBranch,
|
||||
feature: Feature.WORKFLOW_SUPPORT,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Onboarding } from '../onboarding/index/Onboarding'
|
||||
import { StepsLayout } from '../onboarding/steps/StepsLayout'
|
||||
import { AISettingsPage } from './ai-settings/AISettingsPage'
|
||||
import { ConnectMCP } from './connect-mcp/ConnectMCP'
|
||||
import { CreateGraphWrapper } from './create-graph/CreateGraphWrapper'
|
||||
import { CustomizationPage } from './customization/CustomizationPage'
|
||||
import { SurveyPage } from './jtbd-agent/SurveyPage'
|
||||
import { AuthLayout } from './layout/AuthLayout'
|
||||
@@ -29,7 +28,6 @@ import { SearchProviderPage } from './search-provider/SearchProviderPage'
|
||||
import { SkillsPage } from './skills/SkillsPage'
|
||||
import { SoulPage } from './soul/SoulPage'
|
||||
import { UsagePage } from './usage/UsagePage'
|
||||
import { WorkflowsPageWrapper } from './workflows/WorkflowsPageWrapper'
|
||||
|
||||
function getSurveyParams(): { maxTurns?: number; experimentId?: string } {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
@@ -53,9 +51,7 @@ const OptionsRedirect: FC = () => {
|
||||
soul: '/home/soul',
|
||||
skills: '/home/skills',
|
||||
'jtbd-agent': '/settings/survey',
|
||||
workflows: '/workflows',
|
||||
scheduled: '/scheduled',
|
||||
'create-graph': '/workflows/create-graph',
|
||||
}
|
||||
|
||||
const newPath = routeMap[path] || '/settings/ai'
|
||||
@@ -90,7 +86,6 @@ export const App: FC = () => {
|
||||
|
||||
{/* Primary nav routes */}
|
||||
<Route path="connect-apps" element={<ConnectMCP />} />
|
||||
<Route path="workflows" element={<WorkflowsPageWrapper />} />
|
||||
<Route path="scheduled" element={<ScheduledTasksPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -108,9 +103,6 @@ export const App: FC = () => {
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Full-screen without sidebar */}
|
||||
<Route path="workflows/create-graph" element={<CreateGraphWrapper />} />
|
||||
|
||||
{/* Onboarding routes - no sidebar, no auth required */}
|
||||
<Route path="onboarding">
|
||||
<Route index element={<Onboarding />} />
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import Fuse from 'fuse.js'
|
||||
import {
|
||||
Check,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
SearchIcon,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import { type FC, useEffect, useRef, useState } from 'react'
|
||||
import { type FC, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod/v3'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -30,6 +39,11 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -42,24 +56,22 @@ import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
import { useCapabilities } from '@/lib/browseros/useCapabilities'
|
||||
import {
|
||||
AI_PROVIDER_ADDED_EVENT,
|
||||
AI_PROVIDER_UPDATED_EVENT,
|
||||
KIMI_API_KEY_CONFIGURED_EVENT,
|
||||
KIMI_API_KEY_GUIDE_CLICKED_EVENT,
|
||||
MODEL_SELECTED_EVENT,
|
||||
} from '@/lib/constants/analyticsEvents'
|
||||
import { useKimiLaunch } from '@/lib/feature-flags/useKimiLaunch'
|
||||
import {
|
||||
getDefaultBaseUrlForProviders,
|
||||
getProviderTemplate,
|
||||
MINIMAX_REGIONS,
|
||||
providerTypeOptions,
|
||||
} from '@/lib/llm-providers/providerTemplates'
|
||||
import { type TestResult, testProvider } from '@/lib/llm-providers/testProvider'
|
||||
import type { LlmProviderConfig, ProviderType } from '@/lib/llm-providers/types'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
getModelContextLength,
|
||||
getModelsForProvider,
|
||||
type ModelInfo,
|
||||
} from './models'
|
||||
import { getModelContextLength, getModelsForProvider } from './models'
|
||||
|
||||
const providerTypeEnum = z.enum([
|
||||
'moonshot',
|
||||
@@ -76,6 +88,7 @@ const providerTypeEnum = z.enum([
|
||||
'chatgpt-pro',
|
||||
'github-copilot',
|
||||
'qwen-code',
|
||||
'minimax',
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -94,7 +107,7 @@ export const providerFormSchema = z
|
||||
temperature: z.number().min(0).max(2),
|
||||
// Azure-specific
|
||||
resourceName: z.string().optional(),
|
||||
// Bedrock-specific
|
||||
// Bedrock-specific / MiniMax region
|
||||
accessKeyId: z.string().optional(),
|
||||
secretAccessKey: z.string().optional(),
|
||||
region: z.string().optional(),
|
||||
@@ -153,6 +166,30 @@ export const providerFormSchema = z
|
||||
) {
|
||||
// No validation needed — OAuth tokens are on the server
|
||||
}
|
||||
// MiniMax: require baseUrl + apiKey
|
||||
else if (data.type === 'minimax') {
|
||||
if (!data.baseUrl) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Base URL is required',
|
||||
path: ['baseUrl'],
|
||||
})
|
||||
} else if (!/^https?:\/\/.+/.test(data.baseUrl)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Must be a valid URL',
|
||||
path: ['baseUrl'],
|
||||
})
|
||||
}
|
||||
|
||||
if (!data.apiKey?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'API Key is required',
|
||||
path: ['apiKey'],
|
||||
})
|
||||
}
|
||||
}
|
||||
// Other providers: require baseUrl
|
||||
else if (!data.baseUrl) {
|
||||
ctx.addIssue({
|
||||
@@ -182,100 +219,6 @@ function formatContextWindow(tokens: number): string {
|
||||
return `${tokens}`
|
||||
}
|
||||
|
||||
function ModelPickerList({
|
||||
models,
|
||||
selectedModelId,
|
||||
onSelect,
|
||||
onCustomSubmit,
|
||||
onClose,
|
||||
}: {
|
||||
models: ModelInfo[]
|
||||
selectedModelId: string
|
||||
onSelect: (modelId: string) => void
|
||||
onCustomSubmit: (modelId: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [search, setSearch] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [onClose])
|
||||
|
||||
const query = search.toLowerCase()
|
||||
const filtered = query
|
||||
? models.filter((m) => m.modelId.toLowerCase().includes(query))
|
||||
: models
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && search) {
|
||||
e.preventDefault()
|
||||
onCustomSubmit(search)
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="rounded-md border">
|
||||
<div className="flex items-center gap-2 border-b px-3">
|
||||
<SearchIcon className="h-4 w-4 shrink-0 text-muted-foreground opacity-50" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search or type a custom model ID..."
|
||||
className="flex h-9 w-full bg-transparent py-2 text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[200px] overflow-y-auto">
|
||||
{filtered.length > 0 ? (
|
||||
filtered.map((model) => {
|
||||
const isSelected = selectedModelId === model.modelId
|
||||
return (
|
||||
<button
|
||||
key={model.modelId}
|
||||
type="button"
|
||||
onClick={() => onSelect(model.modelId)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between px-3 py-2 text-left text-sm transition-colors hover:bg-accent',
|
||||
isSelected && 'bg-accent font-medium',
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{model.modelId}</span>
|
||||
<span className="ml-2 shrink-0 rounded-md bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||||
{formatContextWindow(model.contextLength)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="px-3 py-6 text-center text-muted-foreground text-sm">
|
||||
No models match. Press Enter to use "{search}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for NewProviderDialog
|
||||
* @public
|
||||
@@ -303,10 +246,11 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
}) => {
|
||||
const [isTesting, setIsTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState<TestResult | null>(null)
|
||||
const [modelListOpen, setModelListOpen] = useState(false)
|
||||
const [modelPickerOpen, setModelPickerOpen] = useState(false)
|
||||
const [modelSearch, setModelSearch] = useState('')
|
||||
const modelListRef = useRef<HTMLDivElement>(null)
|
||||
const { supports } = useCapabilities()
|
||||
const { baseUrl: agentServerUrl } = useAgentServerUrl()
|
||||
const kimiLaunch = useKimiLaunch()
|
||||
|
||||
const filteredProviderTypeOptions = providerTypeOptions.filter((opt) => {
|
||||
if (opt.value === 'chatgpt-pro')
|
||||
@@ -314,8 +258,6 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
if (opt.value === 'github-copilot')
|
||||
return supports(Feature.GITHUB_COPILOT_SUPPORT)
|
||||
if (opt.value === 'qwen-code') return supports(Feature.QWEN_CODE_SUPPORT)
|
||||
if (opt.value === 'moonshot')
|
||||
return kimiLaunch || initialValues?.type === 'moonshot'
|
||||
if (opt.value === 'openai-compatible') {
|
||||
return supports(Feature.OPENAI_COMPATIBLE_SUPPORT)
|
||||
}
|
||||
@@ -376,6 +318,23 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
|
||||
const modelInfoList = getModelsForProvider(watchedType as ProviderType)
|
||||
|
||||
const modelFuse = useMemo(
|
||||
() =>
|
||||
new Fuse(modelInfoList, {
|
||||
keys: ['modelId'],
|
||||
threshold: 0.4,
|
||||
distance: 100,
|
||||
}),
|
||||
[modelInfoList],
|
||||
)
|
||||
|
||||
const filteredModels = modelSearch
|
||||
? modelFuse.search(modelSearch).map((r) => r.item)
|
||||
: modelInfoList
|
||||
|
||||
const showCustomEntry =
|
||||
modelSearch && !filteredModels.some((m) => m.modelId === modelSearch)
|
||||
|
||||
// Handle provider type change (user-initiated via Select)
|
||||
const handleTypeChange = (newType: ProviderType) => {
|
||||
form.setValue('type', newType)
|
||||
@@ -383,6 +342,9 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
if (defaultUrl) {
|
||||
form.setValue('baseUrl', defaultUrl)
|
||||
}
|
||||
if (newType === 'minimax') {
|
||||
form.setValue('region', 'chinese')
|
||||
}
|
||||
form.setValue('modelId', '')
|
||||
}
|
||||
|
||||
@@ -471,6 +433,11 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
provider_type: values.type,
|
||||
model: values.modelId,
|
||||
})
|
||||
} else {
|
||||
track(AI_PROVIDER_UPDATED_EVENT, {
|
||||
provider_type: values.type,
|
||||
model: values.modelId,
|
||||
})
|
||||
}
|
||||
if (values.type === 'moonshot') {
|
||||
track(KIMI_API_KEY_CONFIGURED_EVENT, {
|
||||
@@ -784,6 +751,94 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
// Minimax: region selector
|
||||
if (watchedType === 'minimax') {
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="region"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Region *</FormLabel>
|
||||
<Select
|
||||
onValueChange={(v) => {
|
||||
field.onChange(v)
|
||||
form.setValue(
|
||||
'baseUrl',
|
||||
MINIMAX_REGIONS[v as keyof typeof MINIMAX_REGIONS].api,
|
||||
)
|
||||
}}
|
||||
value={field.value || 'chinese'}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="chinese">
|
||||
Chinese (api.minimaxi.com)
|
||||
</SelectItem>
|
||||
<SelectItem value="international">
|
||||
International (api.minimax.io)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Choose the endpoint closest to your location
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="baseUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Base URL *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://api.minimaxi.com/v1" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="apiKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Key *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter your MiniMax API key"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your API key is encrypted and stored locally.{' '}
|
||||
{setupGuideUrl && (
|
||||
<a
|
||||
href={setupGuideUrl}
|
||||
onClick={handleSetupGuideClick}
|
||||
className="inline-flex cursor-pointer items-center gap-1 text-primary hover:underline"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{setupGuideText}
|
||||
</a>
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Standard providers (OpenAI, Anthropic, Google, etc.)
|
||||
return (
|
||||
<>
|
||||
@@ -924,36 +979,132 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
) : modelListOpen ? (
|
||||
<ModelPickerList
|
||||
models={modelInfoList}
|
||||
selectedModelId={field.value}
|
||||
onSelect={(modelId) => {
|
||||
form.setValue('modelId', modelId)
|
||||
setModelListOpen(false)
|
||||
}}
|
||||
onCustomSubmit={(modelId) => {
|
||||
form.setValue('modelId', modelId)
|
||||
setModelListOpen(false)
|
||||
}}
|
||||
onClose={() => setModelListOpen(false)}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModelListOpen(true)}
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs',
|
||||
field.value
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
<Popover
|
||||
open={modelPickerOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
setModelPickerOpen(isOpen)
|
||||
if (!isOpen) setModelSearch('')
|
||||
}}
|
||||
>
|
||||
<span className="truncate">
|
||||
{field.value || 'Select a model...'}
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</button>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs',
|
||||
field.value
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{field.value || 'Select a model...'}
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder="Search models..."
|
||||
value={modelSearch}
|
||||
onValueChange={(v) => {
|
||||
setModelSearch(v)
|
||||
requestAnimationFrame(() => {
|
||||
modelListRef.current?.scrollTo(0, 0)
|
||||
})
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && modelSearch) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
form.setValue('modelId', modelSearch)
|
||||
track(MODEL_SELECTED_EVENT, {
|
||||
provider_type: watchedType,
|
||||
model_id: modelSearch,
|
||||
is_custom_model: !modelInfoList.some(
|
||||
(m) => m.modelId === modelSearch,
|
||||
),
|
||||
})
|
||||
setModelPickerOpen(false)
|
||||
setModelSearch('')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<CommandList ref={modelListRef}>
|
||||
<CommandEmpty>
|
||||
No models found. Press Enter to use "
|
||||
{modelSearch}"
|
||||
</CommandEmpty>
|
||||
{showCustomEntry && (
|
||||
<CommandGroup forceMount>
|
||||
<CommandItem
|
||||
forceMount
|
||||
value={`custom:${modelSearch}`}
|
||||
onSelect={() => {
|
||||
form.setValue('modelId', modelSearch)
|
||||
track(MODEL_SELECTED_EVENT, {
|
||||
provider_type: watchedType,
|
||||
model_id: modelSearch,
|
||||
is_custom_model: true,
|
||||
})
|
||||
setModelPickerOpen(false)
|
||||
setModelSearch('')
|
||||
}}
|
||||
>
|
||||
<span className="flex-1 truncate">
|
||||
{modelSearch}
|
||||
</span>
|
||||
{field.value === modelSearch && (
|
||||
<Check className="ml-2 h-4 w-4 shrink-0" />
|
||||
)}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
{filteredModels.length > 0 && (
|
||||
<CommandGroup>
|
||||
{filteredModels.map((model) => (
|
||||
<CommandItem
|
||||
key={model.modelId}
|
||||
value={model.modelId}
|
||||
onSelect={() => {
|
||||
form.setValue('modelId', model.modelId)
|
||||
track(MODEL_SELECTED_EVENT, {
|
||||
provider_type: watchedType,
|
||||
model_id: model.modelId,
|
||||
context_window: model.contextLength,
|
||||
is_custom_model: !modelInfoList.some(
|
||||
(m) => m.modelId === model.modelId,
|
||||
),
|
||||
})
|
||||
setModelPickerOpen(false)
|
||||
setModelSearch('')
|
||||
}}
|
||||
>
|
||||
<span className="flex-1 truncate">
|
||||
{model.modelId}
|
||||
</span>
|
||||
{model.contextLength > 0 && (
|
||||
<span className="ml-2 shrink-0 rounded-md bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||||
{formatContextWindow(
|
||||
model.contextLength,
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{field.value === model.modelId && (
|
||||
<Check className="ml-2 h-4 w-4 shrink-0" />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Check, Loader2, Trash2 } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useKimiLaunch } from '@/lib/feature-flags/useKimiLaunch'
|
||||
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
|
||||
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -30,7 +29,6 @@ export const ProviderCard: FC<ProviderCardProps> = ({
|
||||
isTesting = false,
|
||||
}) => {
|
||||
const inputId = `provider-${provider.id}`
|
||||
const kimiLaunch = useKimiLaunch()
|
||||
|
||||
return (
|
||||
<label
|
||||
@@ -79,30 +77,21 @@ export const ProviderCard: FC<ProviderCardProps> = ({
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{isBuiltIn && provider.type === 'browseros' && kimiLaunch && (
|
||||
<span className="mb-1 inline-block rounded-full border border-orange-300/60 bg-orange-100/70 px-3 py-0.5 font-semibold text-orange-700 text-xs dark:border-orange-400/40 dark:bg-orange-500/15 dark:text-orange-300">
|
||||
In partnership with Moonshot AI
|
||||
</span>
|
||||
)}
|
||||
<p className="truncate text-muted-foreground text-sm">
|
||||
{isBuiltIn ? (
|
||||
kimiLaunch ? (
|
||||
'Extended usage limits for the next 2 weeks!'
|
||||
) : (
|
||||
<>
|
||||
BrowserOS-hosted model with strict rate limits.{' '}
|
||||
<a
|
||||
href="https://docs.browseros.com/features/bring-your-own-llm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Bring your own key
|
||||
</a>{' '}
|
||||
for better performance.
|
||||
</>
|
||||
)
|
||||
<>
|
||||
BrowserOS-hosted model with strict rate limits.{' '}
|
||||
<a
|
||||
href="https://docs.browseros.com/features/bring-your-own-llm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Bring your own key
|
||||
</a>{' '}
|
||||
for better performance.
|
||||
</>
|
||||
) : provider.baseUrl ? (
|
||||
`${provider.modelId} • ${provider.baseUrl}`
|
||||
) : (
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Feature } from '@/lib/browseros/capabilities'
|
||||
import { useCapabilities } from '@/lib/browseros/useCapabilities'
|
||||
import { useKimiLaunch } from '@/lib/feature-flags/useKimiLaunch'
|
||||
import {
|
||||
type ProviderTemplate,
|
||||
providerTemplates,
|
||||
@@ -23,7 +22,6 @@ export const ProviderTemplatesSection: FC<ProviderTemplatesSectionProps> = ({
|
||||
onUseTemplate,
|
||||
}) => {
|
||||
const { supports } = useCapabilities()
|
||||
const kimiLaunch = useKimiLaunch()
|
||||
|
||||
const filteredTemplates = providerTemplates.filter((template) => {
|
||||
if (template.id === 'chatgpt-pro')
|
||||
@@ -31,7 +29,6 @@ export const ProviderTemplatesSection: FC<ProviderTemplatesSectionProps> = ({
|
||||
if (template.id === 'github-copilot')
|
||||
return supports(Feature.GITHUB_COPILOT_SUPPORT)
|
||||
if (template.id === 'qwen-code') return supports(Feature.QWEN_CODE_SUPPORT)
|
||||
if (template.id === 'moonshot') return kimiLaunch
|
||||
if (template.id === 'openai-compatible') {
|
||||
return supports(Feature.OPENAI_COMPATIBLE_SUPPORT)
|
||||
}
|
||||
@@ -67,7 +64,6 @@ export const ProviderTemplatesSection: FC<ProviderTemplatesSectionProps> = ({
|
||||
<ProviderTemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
highlighted={template.id === 'moonshot'}
|
||||
isNew={isNew}
|
||||
onUseTemplate={onUseTemplate}
|
||||
/>
|
||||
|
||||
@@ -1,484 +0,0 @@
|
||||
import { useChat } from '@ai-sdk/react'
|
||||
import { DefaultChatTransport, type UIMessage } from 'ai'
|
||||
import { compact } from 'es-toolkit/array'
|
||||
import type { FC, FormEvent } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
import useDeepCompareEffect from 'use-deep-compare-effect'
|
||||
import type { Provider } from '@/components/chat/chatComponentTypes'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from '@/components/ui/resizable'
|
||||
import { useChatRefs } from '@/entrypoints/sidepanel/index/useChatRefs'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
import {
|
||||
GRAPH_SAVED_EVENT,
|
||||
GRAPH_UPDATED_EVENT,
|
||||
NEW_GRAPH_CREATED_EVENT,
|
||||
} from '@/lib/constants/analyticsEvents'
|
||||
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import { useRpcClient } from '@/lib/rpc/RpcClientProvider'
|
||||
import { sentry } from '@/lib/sentry/sentry'
|
||||
import { useWorkflows } from '@/lib/workflows/workflowStorage'
|
||||
import { GraphCanvas } from './GraphCanvas'
|
||||
import { GraphChat } from './GraphChat'
|
||||
import { WorkflowsChatHeader } from './WorkflowsChatHeader'
|
||||
|
||||
type MessageType = 'create-graph' | 'update-graph' | 'run-graph'
|
||||
|
||||
type GraphMessageMetadata = {
|
||||
messageType?: MessageType
|
||||
codeId?: string
|
||||
graph?: GraphData
|
||||
window?: chrome.windows.Window
|
||||
}
|
||||
|
||||
export type GraphData = {
|
||||
nodes: {
|
||||
id: string
|
||||
type: string
|
||||
data: {
|
||||
label: string
|
||||
}
|
||||
}[]
|
||||
edges: {
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
}[]
|
||||
}
|
||||
|
||||
const getLastMessageText = (messages: UIMessage[]) => {
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
if (!lastMessage) return ''
|
||||
return lastMessage.parts
|
||||
.filter((part) => part.type === 'text')
|
||||
.map((part) => part.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
export const CreateGraph: FC = () => {
|
||||
const [searchParams] = useSearchParams()
|
||||
const workflowIdParam = searchParams.get('workflowId')
|
||||
|
||||
const [graphName, setGraphName] = useState('')
|
||||
const [codeId, setCodeId] = useState<string | undefined>(undefined)
|
||||
const [graphData, setGraphData] = useState<GraphData | undefined>(undefined)
|
||||
const [savedWorkflowId, setSavedWorkflowId] = useState<string | undefined>(
|
||||
undefined,
|
||||
)
|
||||
const [savedCodeId, setSavedCodeId] = useState<string | undefined>(undefined)
|
||||
const [isInitialized, setIsInitialized] = useState(!workflowIdParam)
|
||||
const [canvasPanelSize, setCanvasPanelSize] = useState<
|
||||
{ asPercentage: number; inPixels: number } | undefined
|
||||
>(undefined)
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const [showDiscardDialog, setShowDiscardDialog] = useState(false)
|
||||
|
||||
const { workflows, addWorkflow, editWorkflow } = useWorkflows()
|
||||
const { providers: llmProviders, setDefaultProvider } = useLlmProviders()
|
||||
const rpcClient = useRpcClient()
|
||||
|
||||
// Initialize edit mode when workflowId is provided
|
||||
useDeepCompareEffect(() => {
|
||||
if (!workflowIdParam || isInitialized) return
|
||||
|
||||
const workflow = workflows.find((w) => w.id === workflowIdParam)
|
||||
if (!workflow) return
|
||||
|
||||
const initializeEditMode = async () => {
|
||||
setGraphName(workflow.workflowName)
|
||||
setCodeId(workflow.codeId)
|
||||
setSavedWorkflowId(workflow.id)
|
||||
setSavedCodeId(workflow.codeId)
|
||||
|
||||
try {
|
||||
const response = await rpcClient.graph[':id'].$get({
|
||||
param: { id: workflow.codeId },
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if ('graph' in data && data.graph) {
|
||||
setGraphData(data.graph as GraphData)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
sentry.captureException(error, {
|
||||
extra: {
|
||||
message: 'Failed to fetch graph data from the server',
|
||||
codeId: workflow.codeId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
setIsInitialized(true)
|
||||
}
|
||||
|
||||
initializeEditMode()
|
||||
}, [workflowIdParam, workflows, isInitialized, rpcClient])
|
||||
|
||||
const updateQuery = (newQuery: string) => {
|
||||
setQuery(newQuery)
|
||||
}
|
||||
|
||||
const onSubmit = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (codeId) {
|
||||
sendMessage({
|
||||
text: query,
|
||||
metadata: {
|
||||
messageType: 'update-graph' as MessageType,
|
||||
codeId,
|
||||
},
|
||||
})
|
||||
track(GRAPH_UPDATED_EVENT)
|
||||
} else {
|
||||
sendMessage({
|
||||
text: query,
|
||||
metadata: {
|
||||
messageType: 'create-graph' as MessageType,
|
||||
},
|
||||
})
|
||||
track(NEW_GRAPH_CREATED_EVENT)
|
||||
}
|
||||
setQuery('')
|
||||
}
|
||||
|
||||
const {
|
||||
baseUrl: agentServerUrl,
|
||||
isLoading: _isLoadingAgentUrl,
|
||||
error: agentUrlError,
|
||||
} = useAgentServerUrl()
|
||||
|
||||
const {
|
||||
selectedLlmProviderRef,
|
||||
enabledMcpServersRef,
|
||||
enabledCustomServersRef,
|
||||
personalizationRef,
|
||||
selectedLlmProvider,
|
||||
isLoadingProviders,
|
||||
} = useChatRefs()
|
||||
|
||||
const agentUrlRef = useRef(agentServerUrl)
|
||||
const codeIdRef = useRef(codeId)
|
||||
|
||||
useEffect(() => {
|
||||
agentUrlRef.current = agentServerUrl
|
||||
codeIdRef.current = codeId
|
||||
}, [agentServerUrl, codeId])
|
||||
|
||||
const { sendMessage, stop, status, messages, error, setMessages } = useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
prepareSendMessagesRequest: async ({ messages }) => {
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
const lastMessageText = getLastMessageText(messages)
|
||||
const metadata = lastMessage.metadata as
|
||||
| GraphMessageMetadata
|
||||
| undefined
|
||||
|
||||
if (metadata?.messageType === 'create-graph') {
|
||||
return {
|
||||
api: `${agentUrlRef.current}/graph`,
|
||||
body: {
|
||||
query: lastMessageText,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata?.messageType === 'update-graph' && codeIdRef.current) {
|
||||
return {
|
||||
api: `${agentUrlRef.current}/graph/${codeIdRef.current}`,
|
||||
body: {
|
||||
query: lastMessageText,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata?.messageType === 'run-graph' && codeIdRef.current) {
|
||||
const provider = selectedLlmProviderRef.current
|
||||
const enabledMcpServers = enabledMcpServersRef.current
|
||||
const customMcpServers = enabledCustomServersRef.current
|
||||
|
||||
return {
|
||||
api: `${agentUrlRef.current}/graph/${codeIdRef.current}/run`,
|
||||
body: {
|
||||
provider: provider?.type,
|
||||
providerType: provider?.type,
|
||||
providerName: provider?.name,
|
||||
model: provider?.modelId ?? 'browseros',
|
||||
contextWindowSize: provider?.contextWindow,
|
||||
temperature: provider?.temperature,
|
||||
resourceName: provider?.resourceName,
|
||||
// Bedrock-specific
|
||||
accessKeyId: provider?.accessKeyId,
|
||||
secretAccessKey: provider?.secretAccessKey,
|
||||
region: provider?.region,
|
||||
sessionToken: provider?.sessionToken,
|
||||
apiKey: provider?.apiKey,
|
||||
baseUrl: provider?.baseUrl,
|
||||
browserContext: {
|
||||
windowId: metadata?.window?.id,
|
||||
activeTab: metadata?.window?.tabs?.[0],
|
||||
enabledMcpServers: compact(enabledMcpServers),
|
||||
customMcpServers,
|
||||
},
|
||||
userSystemPrompt: personalizationRef.current,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
api: `${agentUrlRef.current}/graph`,
|
||||
body: {
|
||||
query: lastMessageText,
|
||||
},
|
||||
}
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
const lastAssistantMessageWithGraph = messages.findLast((m) => {
|
||||
if (m.role !== 'assistant') return false
|
||||
const metadata = m.metadata as GraphMessageMetadata | undefined
|
||||
return metadata?.graph !== undefined
|
||||
})
|
||||
|
||||
const onClickTest = async () => {
|
||||
let backgroundWindow: chrome.windows.Window | undefined
|
||||
try {
|
||||
backgroundWindow = await chrome.windows.create({
|
||||
url: 'chrome://newtab',
|
||||
focused: true,
|
||||
type: 'normal',
|
||||
})
|
||||
} catch {
|
||||
// Fallback when no window context is available (e.g. all windows closed)
|
||||
const tab = await chrome.tabs.create({
|
||||
url: 'chrome://newtab',
|
||||
active: true,
|
||||
})
|
||||
if (tab.windowId) {
|
||||
backgroundWindow = await chrome.windows.get(tab.windowId)
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage({
|
||||
text: 'Run a test of the graph you just created.',
|
||||
metadata: {
|
||||
messageType: 'run-graph' as MessageType,
|
||||
codeId,
|
||||
window: backgroundWindow,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const hasUnsavedChanges = savedWorkflowId ? codeId !== savedCodeId : true
|
||||
const shouldBlockNavigation = !!codeId && hasUnsavedChanges
|
||||
|
||||
// Handle browser refresh/close
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (shouldBlockNavigation) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}, [shouldBlockNavigation])
|
||||
|
||||
const onClickSave = async () => {
|
||||
if (!graphName || !codeId) return
|
||||
|
||||
if (savedWorkflowId) {
|
||||
await editWorkflow(savedWorkflowId, {
|
||||
workflowName: graphName,
|
||||
codeId,
|
||||
})
|
||||
setSavedCodeId(codeId)
|
||||
} else {
|
||||
const newWorkflow = await addWorkflow({
|
||||
workflowName: graphName,
|
||||
codeId,
|
||||
})
|
||||
setSavedWorkflowId(newWorkflow.id)
|
||||
setSavedCodeId(codeId)
|
||||
}
|
||||
track(GRAPH_SAVED_EVENT)
|
||||
}
|
||||
|
||||
// Provider data for header
|
||||
const providers: Provider[] = llmProviders.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
type: p.type,
|
||||
}))
|
||||
|
||||
const selectedProviderForHeader: Provider | undefined = selectedLlmProvider
|
||||
? {
|
||||
id: selectedLlmProvider.id,
|
||||
name: selectedLlmProvider.name,
|
||||
type: selectedLlmProvider.type,
|
||||
}
|
||||
: providers[0]
|
||||
|
||||
// Has generated code but can't auto-save (no name)
|
||||
const hasUnsavedWork = codeId && !graphName
|
||||
|
||||
const resetToNewWorkflow = () => {
|
||||
setCodeId(undefined)
|
||||
setGraphData(undefined)
|
||||
setGraphName('')
|
||||
setSavedWorkflowId(undefined)
|
||||
setSavedCodeId(undefined)
|
||||
setMessages([])
|
||||
}
|
||||
|
||||
const handleSelectProvider = (provider: Provider) => {
|
||||
setDefaultProvider(provider.id)
|
||||
}
|
||||
|
||||
const handleNewWorkflow = async () => {
|
||||
// Can auto-save: has name AND code
|
||||
if (graphName && codeId) {
|
||||
await onClickSave()
|
||||
resetToNewWorkflow()
|
||||
return
|
||||
}
|
||||
|
||||
// Has unsaved work that can't be auto-saved: show confirmation
|
||||
if (hasUnsavedWork) {
|
||||
setShowDiscardDialog(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Nothing to save, just reset
|
||||
resetToNewWorkflow()
|
||||
}
|
||||
|
||||
const handleConfirmDiscard = () => {
|
||||
setShowDiscardDialog(false)
|
||||
resetToNewWorkflow()
|
||||
}
|
||||
|
||||
const handleSuggestionClick = (prompt: string) => {
|
||||
sendMessage({
|
||||
text: prompt,
|
||||
metadata: {
|
||||
messageType: 'create-graph' as MessageType,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
useDeepCompareEffect(() => {
|
||||
if (status === 'ready' && lastAssistantMessageWithGraph) {
|
||||
const metadata = lastAssistantMessageWithGraph.metadata as
|
||||
| GraphMessageMetadata
|
||||
| undefined
|
||||
setCodeId(metadata?.codeId)
|
||||
setGraphData(metadata?.graph)
|
||||
}
|
||||
}, [status, lastAssistantMessageWithGraph ?? {}])
|
||||
|
||||
if (!isInitialized || isLoadingProviders || !selectedProviderForHeader) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-background text-foreground">
|
||||
<div className="fade-in animate-in text-muted-foreground duration-200 [animation-delay:300ms] [animation-fill-mode:backwards]">
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen bg-background text-foreground">
|
||||
<ResizablePanelGroup orientation="horizontal">
|
||||
<ResizablePanel
|
||||
id="graph-canvas"
|
||||
defaultSize={'70%'}
|
||||
minSize={'30%'}
|
||||
maxSize={'70%'}
|
||||
onResize={(size) => setCanvasPanelSize(size)}
|
||||
>
|
||||
<GraphCanvas
|
||||
graphName={graphName}
|
||||
onGraphNameChange={(val) => setGraphName(val)}
|
||||
graphData={graphData}
|
||||
codeId={codeId}
|
||||
onClickTest={onClickTest}
|
||||
onClickSave={onClickSave}
|
||||
isSaved={!!savedWorkflowId}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
shouldBlockNavigation={shouldBlockNavigation}
|
||||
panelSize={canvasPanelSize}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
id="graph-chat"
|
||||
defaultSize={'30%'}
|
||||
maxSize={'70%'}
|
||||
minSize={'30%'}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<WorkflowsChatHeader
|
||||
selectedProvider={selectedProviderForHeader}
|
||||
providers={providers}
|
||||
onSelectProvider={handleSelectProvider}
|
||||
onNewWorkflow={handleNewWorkflow}
|
||||
hasMessages={messages.length > 0}
|
||||
/>
|
||||
<div className="min-h-0 flex-1">
|
||||
<GraphChat
|
||||
messages={messages}
|
||||
onSubmit={onSubmit}
|
||||
onInputChange={updateQuery}
|
||||
onStop={stop}
|
||||
input={query}
|
||||
status={status}
|
||||
agentUrlError={agentUrlError}
|
||||
chatError={error}
|
||||
onSuggestionClick={handleSuggestionClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
|
||||
<AlertDialog open={showDiscardDialog} onOpenChange={setShowDiscardDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Discard unsaved workflow?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
You have an unsaved workflow. Creating a new one will discard your
|
||||
current changes.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirmDiscard}>
|
||||
Discard
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { type FC, Suspense } from 'react'
|
||||
import { RpcClientProvider } from '@/lib/rpc/RpcClientProvider'
|
||||
import { CreateGraph } from './CreateGraph'
|
||||
|
||||
export const CreateGraphWrapper: FC = () => {
|
||||
return (
|
||||
<RpcClientProvider>
|
||||
<Suspense fallback={<div className="h-screen w-screen bg-background" />}>
|
||||
<CreateGraph />
|
||||
</Suspense>
|
||||
</RpcClientProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import { Handle, type Node, type NodeProps, Position } from '@xyflow/react'
|
||||
import {
|
||||
CheckCircle,
|
||||
Download,
|
||||
GitBranch,
|
||||
GitMerge,
|
||||
MousePointer,
|
||||
Navigation,
|
||||
Play,
|
||||
RotateCw,
|
||||
Split,
|
||||
Square,
|
||||
} from 'lucide-react'
|
||||
import type React from 'react'
|
||||
import { memo } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const nodeConfig: Record<
|
||||
NodeType,
|
||||
{ color: string; icon: React.ElementType; label: string }
|
||||
> = {
|
||||
start: {
|
||||
color: 'text-green-600 dark:text-green-400',
|
||||
icon: Play,
|
||||
label: 'Start',
|
||||
},
|
||||
end: {
|
||||
color: 'text-red-600 dark:text-red-400',
|
||||
icon: Square,
|
||||
label: 'End',
|
||||
},
|
||||
nav: {
|
||||
color: 'text-blue-600 dark:text-blue-400',
|
||||
icon: Navigation,
|
||||
label: 'Navigate',
|
||||
},
|
||||
act: {
|
||||
color: 'text-purple-600 dark:text-purple-400',
|
||||
icon: MousePointer,
|
||||
label: 'Action',
|
||||
},
|
||||
extract: {
|
||||
color: 'text-amber-600 dark:text-amber-400',
|
||||
icon: Download,
|
||||
label: 'Extract',
|
||||
},
|
||||
verify: {
|
||||
color: 'text-emerald-600 dark:text-emerald-400',
|
||||
icon: CheckCircle,
|
||||
label: 'Verify',
|
||||
},
|
||||
decision: {
|
||||
color: 'text-pink-600 dark:text-pink-400',
|
||||
icon: GitBranch,
|
||||
label: 'Decision',
|
||||
},
|
||||
loop: {
|
||||
color: 'text-cyan-600 dark:text-cyan-400',
|
||||
icon: RotateCw,
|
||||
label: 'Loop',
|
||||
},
|
||||
fork: {
|
||||
color: 'text-indigo-600 dark:text-indigo-400',
|
||||
icon: Split,
|
||||
label: 'Fork',
|
||||
},
|
||||
join: {
|
||||
color: 'text-lime-600 dark:text-lime-400',
|
||||
icon: GitMerge,
|
||||
label: 'Join',
|
||||
},
|
||||
}
|
||||
|
||||
export type NodeType =
|
||||
| 'start'
|
||||
| 'end'
|
||||
| 'nav'
|
||||
| 'act'
|
||||
| 'extract'
|
||||
| 'verify'
|
||||
| 'decision'
|
||||
| 'loop'
|
||||
| 'fork'
|
||||
| 'join'
|
||||
|
||||
type CustomNodeData = Node<{
|
||||
type: NodeType
|
||||
label: string
|
||||
}>
|
||||
|
||||
export const CustomNode = memo(
|
||||
({ data: { label, type } }: NodeProps<CustomNodeData>) => {
|
||||
const config = nodeConfig[type || 'start']
|
||||
const Icon = config.icon
|
||||
|
||||
const showSourceHandle = type !== 'end'
|
||||
const showTargetHandle = type !== 'start'
|
||||
|
||||
return (
|
||||
<div className="min-w-45 rounded-lg border border-border bg-card px-4 py-3 shadow-md transition-all">
|
||||
{showTargetHandle && (
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="h-2 w-2 bg-accent-orange!"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn('shrink-0', config.color)}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
'mb-0.5 font-semibold text-xs uppercase tracking-wide',
|
||||
config.color,
|
||||
)}
|
||||
>
|
||||
{config.label}
|
||||
</div>
|
||||
<div className="wrap-break-word font-medium text-foreground text-sm">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSourceHandle && (
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className="h-2 w-2 bg-accent-orange!"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
CustomNode.displayName = 'CustomNode'
|
||||
@@ -1,514 +0,0 @@
|
||||
import cytoscape from 'cytoscape'
|
||||
import dagre from 'cytoscape-dagre'
|
||||
// @ts-expect-error no types available
|
||||
import nodeHtmlLabel from 'cytoscape-node-html-label'
|
||||
import DOMPurify from 'dompurify'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Maximize,
|
||||
Minus,
|
||||
Pencil,
|
||||
Play,
|
||||
Plus,
|
||||
Save,
|
||||
} from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import useDeepCompareEffect from 'use-deep-compare-effect'
|
||||
import ProductLogo from '@/assets/product_logo.svg'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import type { GraphData } from './CreateGraph'
|
||||
import type { NodeType } from './CustomNode'
|
||||
|
||||
cytoscape.use(dagre)
|
||||
nodeHtmlLabel(cytoscape)
|
||||
|
||||
const NODE_CONFIG: Record<
|
||||
NodeType,
|
||||
{ color: string; bgColor: string; icon: string; label: string }
|
||||
> = {
|
||||
start: {
|
||||
color: '#22c55e',
|
||||
bgColor: 'rgba(34, 197, 94, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 3 20 12 6 21 6 3"></polygon></svg>`,
|
||||
label: 'START',
|
||||
},
|
||||
end: {
|
||||
color: '#ef4444',
|
||||
bgColor: 'rgba(239, 68, 68, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"></rect></svg>`,
|
||||
label: 'END',
|
||||
},
|
||||
nav: {
|
||||
color: '#3b82f6',
|
||||
bgColor: 'rgba(59, 130, 246, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="3 11 22 2 13 21 11 13 3 11"></polygon></svg>`,
|
||||
label: 'NAVIGATE',
|
||||
},
|
||||
act: {
|
||||
color: '#8b5cf6',
|
||||
bgColor: 'rgba(139, 92, 246, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 4 7.07 17 2.51-7.39L21 11.07z"></path></svg>`,
|
||||
label: 'ACTION',
|
||||
},
|
||||
extract: {
|
||||
color: '#f59e0b',
|
||||
bgColor: 'rgba(245, 158, 11, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" x2="12" y1="15" y2="3"></line></svg>`,
|
||||
label: 'EXTRACT',
|
||||
},
|
||||
verify: {
|
||||
color: '#10b981',
|
||||
bgColor: 'rgba(16, 185, 129, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>`,
|
||||
label: 'VERIFY',
|
||||
},
|
||||
decision: {
|
||||
color: '#ec4899',
|
||||
bgColor: 'rgba(236, 72, 153, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" x2="6" y1="3" y2="15"></line><circle cx="18" cy="6" r="3"></circle><circle cx="6" cy="18" r="3"></circle><path d="M18 9a9 9 0 0 1-9 9"></path></svg>`,
|
||||
label: 'DECISION',
|
||||
},
|
||||
loop: {
|
||||
color: '#06b6d4',
|
||||
bgColor: 'rgba(6, 182, 212, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path></svg>`,
|
||||
label: 'LOOP',
|
||||
},
|
||||
fork: {
|
||||
color: '#6366f1',
|
||||
bgColor: 'rgba(99, 102, 241, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 3h5v5"></path><path d="M8 3H3v5"></path><path d="M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3"></path><path d="m15 9 6-6"></path></svg>`,
|
||||
label: 'FORK',
|
||||
},
|
||||
join: {
|
||||
color: '#84cc16',
|
||||
bgColor: 'rgba(132, 204, 22, 0.1)',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="18" r="3"></circle><circle cx="6" cy="6" r="3"></circle><path d="M6 21V9a9 9 0 0 0 9 9"></path></svg>`,
|
||||
label: 'JOIN',
|
||||
},
|
||||
}
|
||||
|
||||
const initialData: GraphData = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'start',
|
||||
type: 'start',
|
||||
data: { label: 'Use the Chat to build your workflow!' },
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
}
|
||||
|
||||
const MIN_NODE_WIDTH = 180
|
||||
const MAX_NODE_WIDTH = 240
|
||||
const BASE_NODE_HEIGHT = 70
|
||||
const CHAR_WIDTH = 7
|
||||
const ICON_AND_PADDING = 62
|
||||
const MAX_ZOOM = 1.2
|
||||
|
||||
const calculateNodeDimensions = (
|
||||
label: string,
|
||||
): { width: number; height: number } => {
|
||||
const textWidth = label.length * CHAR_WIDTH + ICON_AND_PADDING
|
||||
const width = Math.max(MIN_NODE_WIDTH, Math.min(MAX_NODE_WIDTH, textWidth))
|
||||
|
||||
const maxCharsPerLine = Math.floor((width - ICON_AND_PADDING) / CHAR_WIDTH)
|
||||
const lines = Math.ceil(label.length / maxCharsPerLine)
|
||||
const extraHeight = Math.max(0, lines - 1) * 18
|
||||
const height = BASE_NODE_HEIGHT + extraHeight
|
||||
|
||||
return { width, height }
|
||||
}
|
||||
|
||||
const createNodeHtml = (type: NodeType, label: string): string => {
|
||||
const config = NODE_CONFIG[type] || NODE_CONFIG.start
|
||||
const sanitizedLabel = DOMPurify.sanitize(label, { ALLOWED_TAGS: [] })
|
||||
return `
|
||||
<div class="graph-node" style="
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
min-width: 160px;
|
||||
max-width: 220px;
|
||||
padding: 12px 16px;
|
||||
background-color: var(--graph-node-bg);
|
||||
border: 1px solid var(--graph-node-border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
">
|
||||
<div style="
|
||||
flex-shrink: 0;
|
||||
color: ${config.color};
|
||||
margin-top: 2px;
|
||||
">
|
||||
${config.icon}
|
||||
</div>
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<div style="
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
color: ${config.color};
|
||||
margin-bottom: 4px;
|
||||
">${config.label}</div>
|
||||
<div style="
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--graph-node-text);
|
||||
line-height: 1.4;
|
||||
word-wrap: break-word;
|
||||
">${sanitizedLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
type GraphCanvasProps = {
|
||||
graphName: string
|
||||
onGraphNameChange: (name: string) => void
|
||||
graphData?: GraphData
|
||||
codeId?: string
|
||||
onClickTest: () => unknown
|
||||
onClickSave: () => unknown
|
||||
isSaved: boolean
|
||||
hasUnsavedChanges: boolean
|
||||
shouldBlockNavigation: boolean
|
||||
panelSize?: { asPercentage: number; inPixels: number }
|
||||
}
|
||||
|
||||
export const GraphCanvas: FC<GraphCanvasProps> = ({
|
||||
graphName,
|
||||
onGraphNameChange,
|
||||
graphData = initialData,
|
||||
codeId,
|
||||
onClickTest,
|
||||
onClickSave,
|
||||
isSaved,
|
||||
hasUnsavedChanges,
|
||||
shouldBlockNavigation,
|
||||
panelSize,
|
||||
}) => {
|
||||
const [isEditingName, setIsEditingName] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const cyRef = useRef<cytoscape.Core | null>(null)
|
||||
|
||||
const handleBack = () => {
|
||||
if (shouldBlockNavigation) {
|
||||
const confirmed = window.confirm(
|
||||
'You have unsaved changes. Are you sure you want to leave?',
|
||||
)
|
||||
if (!confirmed) return
|
||||
}
|
||||
navigate(-1)
|
||||
}
|
||||
|
||||
const canTest = !!codeId
|
||||
const canSave = !!graphName && !!codeId && hasUnsavedChanges
|
||||
|
||||
const getTestTooltip = () => {
|
||||
if (!codeId) return 'Create a workflow using the chat first'
|
||||
return 'Run a test of this workflow'
|
||||
}
|
||||
|
||||
const getSaveTooltip = () => {
|
||||
if (!codeId) return 'Create a workflow using the chat first'
|
||||
if (!graphName) return 'Provide a name for the workflow'
|
||||
if (isSaved && !hasUnsavedChanges) return 'Workflow already saved'
|
||||
return isSaved ? 'Save changes to this workflow' : 'Save this workflow'
|
||||
}
|
||||
|
||||
const getSaveButtonLabel = () => {
|
||||
return isSaved ? 'Save Changes' : 'Save Workflow'
|
||||
}
|
||||
|
||||
const zoomIn = useCallback(() => {
|
||||
cyRef.current?.zoom(cyRef.current.zoom() * 1.2)
|
||||
cyRef.current?.center()
|
||||
}, [])
|
||||
|
||||
const zoomOut = useCallback(() => {
|
||||
cyRef.current?.zoom(cyRef.current.zoom() / 1.2)
|
||||
cyRef.current?.center()
|
||||
}, [])
|
||||
|
||||
const fitView = useCallback(() => {
|
||||
cyRef.current?.fit(undefined, 50)
|
||||
cyRef.current?.center()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
const cy = cytoscape({
|
||||
container: containerRef.current,
|
||||
elements: [],
|
||||
style: [
|
||||
{
|
||||
selector: 'node',
|
||||
style: {
|
||||
width: 'data(nodeWidth)',
|
||||
height: 'data(nodeHeight)',
|
||||
'background-opacity': 0,
|
||||
'border-width': 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: 'edge',
|
||||
style: {
|
||||
width: 2,
|
||||
'line-color': '#f97316',
|
||||
'target-arrow-color': '#f97316',
|
||||
'target-arrow-shape': 'triangle',
|
||||
'curve-style': 'bezier',
|
||||
'arrow-scale': 1.2,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: 'edge.back-edge',
|
||||
style: {
|
||||
'line-style': 'dashed',
|
||||
'line-dash-pattern': [6, 3],
|
||||
'curve-style': 'unbundled-bezier',
|
||||
'control-point-distances': [100],
|
||||
'control-point-weights': [0.5],
|
||||
},
|
||||
},
|
||||
],
|
||||
layout: { name: 'preset' },
|
||||
userZoomingEnabled: true,
|
||||
userPanningEnabled: true,
|
||||
boxSelectionEnabled: false,
|
||||
selectionType: 'single',
|
||||
autoungrabify: true,
|
||||
autounselectify: true,
|
||||
maxZoom: MAX_ZOOM,
|
||||
minZoom: 0.2,
|
||||
})
|
||||
|
||||
// @ts-expect-error nodeHtmlLabel extension
|
||||
cy.nodeHtmlLabel([
|
||||
{
|
||||
query: 'node',
|
||||
halign: 'center',
|
||||
valign: 'center',
|
||||
halignBox: 'center',
|
||||
valignBox: 'center',
|
||||
tpl: (data: { type: NodeType; label: string }) => {
|
||||
return createNodeHtml(data.type, data.label)
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
cyRef.current = cy
|
||||
|
||||
return () => {
|
||||
cy.destroy()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const updateGraph = useCallback((data: GraphData) => {
|
||||
const cy = cyRef.current
|
||||
if (!cy) return
|
||||
|
||||
cy.elements().remove()
|
||||
|
||||
const nodes = data.nodes.map((node) => {
|
||||
const dimensions = calculateNodeDimensions(node.data.label)
|
||||
return {
|
||||
data: {
|
||||
id: node.id,
|
||||
label: node.data.label,
|
||||
type: node.type as NodeType,
|
||||
nodeWidth: dimensions.width,
|
||||
nodeHeight: dimensions.height,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const edges = data.edges.map((edge) => ({
|
||||
data: {
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
},
|
||||
}))
|
||||
|
||||
cy.add([...nodes, ...edges])
|
||||
|
||||
cy.layout({
|
||||
name: 'dagre',
|
||||
rankDir: 'TB',
|
||||
nodeSep: 80,
|
||||
rankSep: 100,
|
||||
padding: 50,
|
||||
animate: true,
|
||||
animationDuration: 300,
|
||||
fit: true,
|
||||
} as cytoscape.LayoutOptions).run()
|
||||
|
||||
setTimeout(() => {
|
||||
cy.edges().forEach((edge) => {
|
||||
const sourceNode = edge.source()
|
||||
const targetNode = edge.target()
|
||||
const sourceY = sourceNode.position('y')
|
||||
const targetY = targetNode.position('y')
|
||||
|
||||
if (sourceY > targetY) {
|
||||
edge.addClass('back-edge')
|
||||
}
|
||||
})
|
||||
}, 350)
|
||||
}, [])
|
||||
|
||||
useDeepCompareEffect(() => {
|
||||
updateGraph(graphData)
|
||||
}, [graphData])
|
||||
|
||||
useEffect(() => {
|
||||
if (panelSize?.inPixels !== undefined) {
|
||||
cyRef.current?.resize()
|
||||
setTimeout(() => fitView(), 100)
|
||||
}
|
||||
}, [panelSize?.inPixels, fitView])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col [--graph-node-bg:rgba(255,255,255,1)] [--graph-node-border:rgba(228,228,231,1)] [--graph-node-text:rgba(24,24,27,1)] dark:[--graph-node-bg:rgba(24,24,27,1)] dark:[--graph-node-border:rgba(63,63,70,1)] dark:[--graph-node-text:rgba(250,250,250,1)]">
|
||||
{/* Graph Header */}
|
||||
<header className="flex h-14 shrink-0 items-center justify-between border-border/40 border-b bg-background/80 px-3 backdrop-blur-md">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={handleBack}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<img src={ProductLogo} alt="BrowserOS" className="h-8 w-8 shrink-0" />
|
||||
{isEditingName ? (
|
||||
<input
|
||||
type="text"
|
||||
value={graphName}
|
||||
onChange={(e) => onGraphNameChange(e.target.value)}
|
||||
onBlur={() => setIsEditingName(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') setIsEditingName(false)
|
||||
}}
|
||||
// biome-ignore lint/a11y/noAutofocus: needed to autofocus field when edit mode is toggled
|
||||
autoFocus
|
||||
placeholder="Enter workflow name..."
|
||||
className="max-w-64 border-[var(--accent-orange)] border-b bg-transparent font-semibold text-sm outline-none placeholder:font-normal placeholder:text-muted-foreground/60"
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsEditingName(true)}
|
||||
className="group min-w-0 gap-2 px-2 py-1"
|
||||
>
|
||||
{graphName ? (
|
||||
<span className="truncate font-semibold text-sm">
|
||||
{graphName}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground/60 text-sm italic">
|
||||
Untitled workflow
|
||||
</span>
|
||||
)}
|
||||
<Pencil className="h-3.5 w-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Control Buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onClickTest}
|
||||
disabled={!canTest}
|
||||
>
|
||||
<Play className="mr-1.5 h-4 w-4" />
|
||||
Test Workflow
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{getTestTooltip()}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onClickSave}
|
||||
disabled={!canSave}
|
||||
className="bg-[var(--accent-orange)] shadow-lg shadow-orange-500/20 hover:bg-[var(--accent-orange-bright)] disabled:bg-[var(--accent-orange)]/50"
|
||||
>
|
||||
<Save className="mr-1.5 h-4 w-4" />
|
||||
{getSaveButtonLabel()}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{getSaveTooltip()}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Graph Canvas */}
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden [--dot-color:rgba(0,0,0,0.2)] dark:[--dot-color:rgba(255,255,255,0.15)]">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full bg-zinc-50 dark:bg-zinc-900"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'radial-gradient(circle, var(--dot-color) 1.5px, transparent 1.5px)',
|
||||
backgroundSize: '20px 20px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Zoom Controls */}
|
||||
<div className="absolute bottom-4 left-4 z-10 flex flex-col gap-1 rounded-lg border-2 border-border bg-card p-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={zoomIn}
|
||||
title="Zoom in"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={zoomOut}
|
||||
title="Zoom out"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={fitView}
|
||||
title="Fit view"
|
||||
>
|
||||
<Maximize className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
import type { UIMessage } from 'ai'
|
||||
import { Send, SquareStop } from 'lucide-react'
|
||||
import type { FC, FormEventHandler, KeyboardEvent } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ChatError } from '@/entrypoints/sidepanel/index/ChatError'
|
||||
import { ChatMessages } from '@/entrypoints/sidepanel/index/ChatMessages'
|
||||
import { getResponseAndQueryFromMessageId } from '@/entrypoints/sidepanel/index/useChatSession'
|
||||
import {
|
||||
GRAPH_MESSAGE_DISLIKE_EVENT,
|
||||
GRAPH_MESSAGE_LIKE_EVENT,
|
||||
} from '@/lib/constants/analyticsEvents'
|
||||
import { useJtbdPopup } from '@/lib/jtbd-popup/useJtbdPopup'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { GraphEmptyState } from './GraphEmptyState'
|
||||
import { getWorkflowDisplayMessages } from './workflow-tidbit-messages'
|
||||
|
||||
interface GraphChatProps {
|
||||
onSubmit: FormEventHandler<HTMLFormElement>
|
||||
onInputChange: (value: string) => void
|
||||
onStop: () => void
|
||||
input: string
|
||||
status: 'streaming' | 'submitted' | 'ready' | 'error'
|
||||
messages: UIMessage[]
|
||||
chatError?: Error
|
||||
agentUrlError?: Error | null
|
||||
onSuggestionClick: (prompt: string) => void
|
||||
}
|
||||
|
||||
export const GraphChat: FC<GraphChatProps> = ({
|
||||
onSubmit,
|
||||
onInputChange,
|
||||
onStop,
|
||||
input,
|
||||
status,
|
||||
messages,
|
||||
chatError,
|
||||
agentUrlError,
|
||||
onSuggestionClick,
|
||||
}) => {
|
||||
const [liked, setLiked] = useState<Record<string, boolean>>({})
|
||||
const [disliked, setDisliked] = useState<Record<string, boolean>>({})
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const displayMessages = getWorkflowDisplayMessages(messages)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const {
|
||||
popupVisible,
|
||||
recordMessageSent,
|
||||
triggerIfEligible,
|
||||
onTakeSurvey: onTakeSurveyBase,
|
||||
onDismiss: onDismissJtbdPopup,
|
||||
} = useJtbdPopup()
|
||||
|
||||
const onTakeSurvey = () =>
|
||||
onTakeSurveyBase({ experimentId: 'workflow_survey' })
|
||||
|
||||
// Trigger JTBD popup when AI finishes responding
|
||||
const previousChatStatus = useRef(status)
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally only trigger on status change
|
||||
useEffect(() => {
|
||||
const aiWasProcessing =
|
||||
previousChatStatus.current === 'streaming' ||
|
||||
previousChatStatus.current === 'submitted'
|
||||
const aiJustFinished = aiWasProcessing && status === 'ready'
|
||||
|
||||
if (aiJustFinished && messages.length > 0) {
|
||||
triggerIfEligible()
|
||||
}
|
||||
previousChatStatus.current = status
|
||||
}, [status])
|
||||
|
||||
const onClickLike = (messageId: string) => {
|
||||
const { responseText, queryText } = getResponseAndQueryFromMessageId(
|
||||
messages,
|
||||
messageId,
|
||||
)
|
||||
|
||||
track(GRAPH_MESSAGE_LIKE_EVENT, { responseText, queryText, messageId })
|
||||
|
||||
setLiked((prev) => ({
|
||||
...prev,
|
||||
[messageId]: !prev[messageId],
|
||||
}))
|
||||
}
|
||||
|
||||
const onClickDislike = (messageId: string, comment?: string) => {
|
||||
const { responseText, queryText } = getResponseAndQueryFromMessageId(
|
||||
messages,
|
||||
messageId,
|
||||
)
|
||||
|
||||
track(GRAPH_MESSAGE_DISLIKE_EVENT, {
|
||||
responseText,
|
||||
queryText,
|
||||
messageId,
|
||||
comment,
|
||||
})
|
||||
|
||||
setDisliked((prev) => ({
|
||||
...prev,
|
||||
[messageId]: !prev[messageId],
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (e) => {
|
||||
recordMessageSent()
|
||||
onSubmit(e)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (
|
||||
e.key === 'Enter' &&
|
||||
!e.shiftKey &&
|
||||
!e.metaKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.nativeEvent.isComposing
|
||||
) {
|
||||
e.preventDefault()
|
||||
if (input.trim()) {
|
||||
e.currentTarget.form?.requestSubmit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="styled-scrollbar min-h-0 flex-1 overflow-y-auto pb-2">
|
||||
{displayMessages.length === 0 ? (
|
||||
<GraphEmptyState
|
||||
mounted={mounted}
|
||||
onSuggestionClick={onSuggestionClick}
|
||||
/>
|
||||
) : (
|
||||
<ChatMessages
|
||||
liked={liked}
|
||||
disliked={disliked}
|
||||
onClickDislike={onClickDislike}
|
||||
onClickLike={onClickLike}
|
||||
messages={displayMessages}
|
||||
status={status}
|
||||
showJtbdPopup={popupVisible}
|
||||
showDontShowAgain={false}
|
||||
onTakeSurvey={onTakeSurvey}
|
||||
onDismissJtbdPopup={onDismissJtbdPopup}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{agentUrlError && <ChatError error={agentUrlError} />}
|
||||
{chatError && <ChatError error={chatError} />}
|
||||
<div className="shrink-0 border-border/40 border-t bg-background/80 p-2 backdrop-blur-md">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="relative flex w-full items-end gap-2"
|
||||
>
|
||||
<textarea
|
||||
className={cn(
|
||||
'field-sizing-content max-h-60 min-h-[42px] flex-1 resize-none overflow-hidden rounded-2xl border border-border/50 bg-muted/50 px-4 py-2.5 pr-11 text-sm outline-none transition-colors placeholder:text-muted-foreground/70 hover:border-border focus:border-[var(--accent-orange)]',
|
||||
)}
|
||||
value={input}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
'Visit Amazon and add sensodyne toothpaste to the cart.'
|
||||
}
|
||||
rows={1}
|
||||
/>
|
||||
{status === 'streaming' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
className="absolute right-1.5 bottom-1.5 cursor-pointer rounded-full bg-red-600 p-2 text-white shadow-sm transition-all duration-200 hover:bg-red-900 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<SquareStop className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">Stop</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim()}
|
||||
className="absolute right-1.5 bottom-1.5 cursor-pointer rounded-full bg-[var(--accent-orange)] p-2 text-white shadow-sm transition-all duration-200 hover:bg-[var(--accent-orange-bright)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">Send</span>
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { Workflow } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface Suggestion {
|
||||
display: string
|
||||
prompt: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
const WORKFLOW_SUGGESTIONS: Suggestion[] = [
|
||||
{
|
||||
display: 'Search Amazon and add toothpaste to cart',
|
||||
prompt:
|
||||
'Go to Amazon, search for toothpaste, select 1 pack filter and add the first result to cart',
|
||||
icon: '🛒',
|
||||
},
|
||||
{
|
||||
display: 'Accept LinkedIn connection requests',
|
||||
prompt:
|
||||
'Open LinkedIn and go to my connection requests, accept one by one in a loop for 25 times',
|
||||
icon: '🤝',
|
||||
},
|
||||
{
|
||||
display: 'Unsubscribe from Gmail subscriptions',
|
||||
prompt:
|
||||
'Go to Gmail, navigate to manage subscriptions and unsubscribe from all',
|
||||
icon: '📧',
|
||||
},
|
||||
]
|
||||
|
||||
interface GraphEmptyStateProps {
|
||||
mounted: boolean
|
||||
onSuggestionClick: (prompt: string) => void
|
||||
}
|
||||
|
||||
export const GraphEmptyState: FC<GraphEmptyStateProps> = ({
|
||||
mounted,
|
||||
onSuggestionClick,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'm-0! flex h-full flex-col items-center justify-center space-y-4 text-center opacity-0 transition-all duration-700',
|
||||
mounted ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0',
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex h-14 w-14 items-center justify-center rounded-2xl bg-muted/50">
|
||||
<Workflow className="h-7 w-7 text-[var(--accent-orange)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="mb-1 font-semibold text-lg">
|
||||
Create reliable workflows
|
||||
</h2>
|
||||
<p className="max-w-[240px] text-muted-foreground text-xs">
|
||||
Chat with the agent to create and refine browser automation
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid w-full max-w-[300px] grid-cols-1 gap-2">
|
||||
{WORKFLOW_SUGGESTIONS.map((suggestion) => (
|
||||
<button
|
||||
type="button"
|
||||
key={suggestion.display}
|
||||
onClick={() => onSuggestionClick(suggestion.prompt)}
|
||||
className="group flex items-center justify-between rounded-lg border border-border/50 bg-card px-3 py-2.5 text-left text-xs transition-all duration-200 hover:border-[var(--accent-orange)]/50 hover:bg-[var(--accent-orange)]/5"
|
||||
>
|
||||
{suggestion.display}
|
||||
<span className="opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
{suggestion.icon}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { Github, Plus, SettingsIcon } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector'
|
||||
import type { Provider } from '@/components/chat/chatComponentTypes'
|
||||
import { ThemeToggle } from '@/components/elements/theme-toggle'
|
||||
import { productRepositoryUrl } from '@/lib/constants/productUrls'
|
||||
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
|
||||
import type { ProviderType } from '@/lib/llm-providers/types'
|
||||
|
||||
interface WorkflowsChatHeaderProps {
|
||||
selectedProvider: Provider
|
||||
providers: Provider[]
|
||||
onSelectProvider: (provider: Provider) => void
|
||||
onNewWorkflow: () => void
|
||||
hasMessages: boolean
|
||||
}
|
||||
|
||||
export const WorkflowsChatHeader: FC<WorkflowsChatHeaderProps> = ({
|
||||
selectedProvider,
|
||||
providers,
|
||||
onSelectProvider,
|
||||
onNewWorkflow,
|
||||
hasMessages,
|
||||
}) => {
|
||||
return (
|
||||
<header className="flex h-14 shrink-0 items-center justify-between border-border/40 border-b bg-background/80 px-3 backdrop-blur-md">
|
||||
<div className="flex items-center gap-2">
|
||||
<ChatProviderSelector
|
||||
providers={providers}
|
||||
selectedProvider={selectedProvider}
|
||||
onSelectProvider={onSelectProvider}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="group relative inline-flex cursor-pointer items-center gap-2 rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground data-[state=open]:bg-accent"
|
||||
title="Change AI Provider"
|
||||
>
|
||||
{selectedProvider.type === 'browseros' ? (
|
||||
<BrowserOSIcon size={18} />
|
||||
) : (
|
||||
<ProviderIcon
|
||||
type={selectedProvider.type as ProviderType}
|
||||
size={18}
|
||||
/>
|
||||
)}
|
||||
<span className="font-semibold text-base">
|
||||
{selectedProvider.name}
|
||||
</span>
|
||||
</button>
|
||||
</ChatProviderSelector>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{hasMessages && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewWorkflow}
|
||||
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
title="New workflow"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={productRepositoryUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
title="Star on Github"
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/app.html#/settings"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
title="Settings"
|
||||
>
|
||||
<SettingsIcon className="h-4 w-4" />
|
||||
</a>
|
||||
|
||||
<ThemeToggle
|
||||
className="rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
iconClassName="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import type { UIMessage } from 'ai'
|
||||
|
||||
type MessagePart = UIMessage['parts'][number]
|
||||
|
||||
const TIDBIT_SUFFIXES = ['...', '\u2026'] as const
|
||||
|
||||
const isTextPart = (
|
||||
part: MessagePart,
|
||||
): part is MessagePart & { type: 'text' } => part.type === 'text'
|
||||
|
||||
const isTidbitLine = (line: string): boolean => {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed.length === 0) return false
|
||||
return TIDBIT_SUFFIXES.some((suffix) => trimmed.endsWith(suffix))
|
||||
}
|
||||
|
||||
const getNonEmptyLines = (text: string): string[] =>
|
||||
text.split('\n').filter((line) => line.trim().length > 0)
|
||||
|
||||
const isAllTidbitText = (text: string): boolean => {
|
||||
const lines = getNonEmptyLines(text)
|
||||
return lines.length > 0 && lines.every((line) => isTidbitLine(line))
|
||||
}
|
||||
|
||||
export const isWorkflowTidbitMessage = (message: UIMessage): boolean => {
|
||||
if (message.role !== 'assistant') return false
|
||||
if (message.parts.length === 0) return false
|
||||
if (message.parts.some((part) => !isTextPart(part))) return false
|
||||
|
||||
const fullText = message.parts
|
||||
.filter((part) => isTextPart(part))
|
||||
.map((part) => part.text)
|
||||
.join('')
|
||||
|
||||
return isAllTidbitText(fullText)
|
||||
}
|
||||
|
||||
// within a text part that has multiple tidbit lines, keep only the last line
|
||||
const compactTidbitLinesInPart = (part: MessagePart): MessagePart => {
|
||||
if (!isTextPart(part)) return part
|
||||
|
||||
const lines = getNonEmptyLines(part.text)
|
||||
if (lines.length <= 1) return part
|
||||
if (!lines.every((line) => isTidbitLine(line))) return part
|
||||
|
||||
return { ...part, text: lines[lines.length - 1] }
|
||||
}
|
||||
|
||||
// collapse consecutive tidbit text parts within a single message
|
||||
const compactTidbitPartsInMessage = (message: UIMessage): UIMessage => {
|
||||
if (message.role !== 'assistant') return message
|
||||
|
||||
// first compact multi-line tidbit text within each part
|
||||
const lineCompactedParts = message.parts.map(compactTidbitLinesInPart)
|
||||
|
||||
// then collapse consecutive tidbit parts to just the last one
|
||||
const compactedParts: UIMessage['parts'] = []
|
||||
let pendingTidbitPart: (MessagePart & { type: 'text' }) | null = null
|
||||
|
||||
const flushPendingTidbitPart = () => {
|
||||
if (!pendingTidbitPart) return
|
||||
compactedParts.push(pendingTidbitPart)
|
||||
pendingTidbitPart = null
|
||||
}
|
||||
|
||||
for (const part of lineCompactedParts) {
|
||||
if (isTextPart(part) && isAllTidbitText(part.text)) {
|
||||
pendingTidbitPart = part
|
||||
continue
|
||||
}
|
||||
|
||||
flushPendingTidbitPart()
|
||||
compactedParts.push(part)
|
||||
}
|
||||
|
||||
flushPendingTidbitPart()
|
||||
|
||||
const partsChanged =
|
||||
compactedParts.length !== message.parts.length ||
|
||||
compactedParts.some((p, i) => p !== message.parts[i])
|
||||
|
||||
if (!partsChanged) return message
|
||||
|
||||
return { ...message, parts: compactedParts }
|
||||
}
|
||||
|
||||
export const getWorkflowDisplayMessages = (
|
||||
messages: UIMessage[],
|
||||
): UIMessage[] => {
|
||||
// first compact tidbit parts within each message
|
||||
const normalizedMessages = messages.map(compactTidbitPartsInMessage)
|
||||
const compactedMessages: UIMessage[] = []
|
||||
|
||||
// then collapse consecutive tidbit-only messages
|
||||
for (const message of normalizedMessages) {
|
||||
const previousMessage = compactedMessages[compactedMessages.length - 1]
|
||||
const shouldReplacePreviousTidbit =
|
||||
previousMessage &&
|
||||
isWorkflowTidbitMessage(previousMessage) &&
|
||||
isWorkflowTidbitMessage(message)
|
||||
|
||||
if (shouldReplacePreviousTidbit) {
|
||||
compactedMessages[compactedMessages.length - 1] = message
|
||||
continue
|
||||
}
|
||||
|
||||
compactedMessages.push(message)
|
||||
}
|
||||
|
||||
return compactedMessages
|
||||
}
|
||||
@@ -2,8 +2,6 @@ import { Globe2, Trash2 } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useKimiLaunch } from '@/lib/feature-flags/useKimiLaunch'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getFaviconUrl, type LlmHubProvider } from './models'
|
||||
|
||||
interface HubProviderRowProps {
|
||||
@@ -20,20 +18,9 @@ export const HubProviderRow: FC<HubProviderRowProps> = ({
|
||||
onDelete,
|
||||
}) => {
|
||||
const iconUrl = useMemo(() => getFaviconUrl(provider.url), [provider.url])
|
||||
const kimiLaunch = useKimiLaunch()
|
||||
const normalizedName = provider.name.trim().toLowerCase()
|
||||
const normalizedUrl = provider.url.trim().toLowerCase()
|
||||
const isKimi = normalizedName === 'kimi' || normalizedUrl.includes('kimi.com')
|
||||
const showKimiFlare = isKimi && kimiLaunch
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex w-full items-center gap-4 rounded-xl border border-border bg-card p-4 transition-all hover:border-[var(--accent-orange)] hover:shadow-md',
|
||||
showKimiFlare &&
|
||||
'border-orange-300/80 bg-orange-50/20 shadow-sm ring-1 ring-orange-300/45 dark:bg-orange-500/5',
|
||||
)}
|
||||
>
|
||||
<div className="group flex w-full items-center gap-4 rounded-xl border border-border bg-card p-4 transition-all hover:border-[var(--accent-orange)] hover:shadow-md">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted">
|
||||
{iconUrl ? (
|
||||
<img
|
||||
@@ -49,16 +36,6 @@ export const HubProviderRow: FC<HubProviderRowProps> = ({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-0.5 flex items-center gap-2">
|
||||
<span className="block truncate font-semibold">{provider.name}</span>
|
||||
{showKimiFlare && (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<span className="rounded-full border border-orange-300/60 bg-orange-100/70 px-2 py-0.5 font-semibold text-[11px] text-orange-700 dark:border-orange-400/40 dark:bg-orange-500/15 dark:text-orange-300">
|
||||
Recommended
|
||||
</span>
|
||||
<span className="rounded-full border border-orange-300/60 bg-orange-100/60 px-2.5 py-0.5 font-medium text-orange-700 text-xs dark:border-orange-400/40 dark:bg-orange-500/15 dark:text-orange-300">
|
||||
Powered by Moonshot AI
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate text-muted-foreground/70 text-xs">
|
||||
{provider.url}
|
||||
|
||||
@@ -28,7 +28,7 @@ export const ScheduledTasksList: FC<ScheduledTasksListProps> = ({
|
||||
<div className="rounded-xl border border-border bg-card p-6 shadow-sm">
|
||||
<div className="rounded-lg border border-border border-dashed py-8 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No scheduled tasks yet. Create one to automate recurring workflows.
|
||||
No scheduled tasks yet. Create one to automate recurring tasks.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -238,7 +238,7 @@ const EmptyState: FC<{ onCreateClick: () => void }> = ({ onCreateClick }) => (
|
||||
<h3 className="mb-1 font-medium text-lg">No skills yet</h3>
|
||||
<p className="mb-5 max-w-sm text-muted-foreground text-sm leading-6">
|
||||
Skills teach your agent how to handle repeatable tasks like research,
|
||||
extraction, and structured workflows.
|
||||
extraction, and repeatable browser tasks.
|
||||
</p>
|
||||
<Button onClick={onCreateClick} size="sm">
|
||||
<Plus className="mr-1.5 size-4" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AlertCircle, Clock, Coins, CreditCard, Zap } from 'lucide-react'
|
||||
import { AlertCircle, Clock, Coins, Gift, Zap } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { ShareForCredits } from '@/components/referral/ShareForCredits'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
getCreditBarColor,
|
||||
@@ -43,8 +44,10 @@ export const UsagePage: FC = () => {
|
||||
}
|
||||
|
||||
const credits = data?.credits ?? 0
|
||||
const total = data?.dailyLimit ?? 100
|
||||
const total = data?.dailyLimit ?? 50
|
||||
const percentage = Math.min((credits / total) * 100, 100)
|
||||
const bonusCredits = Math.max(0, credits - total)
|
||||
const creditsUsed = Math.max(0, total - credits)
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
@@ -95,30 +98,32 @@ export const UsagePage: FC = () => {
|
||||
<div className="flex items-center gap-2.5 rounded-lg bg-muted/50 px-3 py-2.5">
|
||||
<Zap className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium text-xs">Credits used today</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{total - credits} of {total}
|
||||
</p>
|
||||
{bonusCredits > 0 ? (
|
||||
<>
|
||||
<p className="font-medium text-xs">Bonus credits</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
+{bonusCredits} from referrals
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-medium text-xs">Credits used today</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{creditsUsed} of {total}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<CreditCard className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="flex items-center gap-2 font-semibold text-sm">
|
||||
Need more credits?
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 font-medium text-[10px] text-muted-foreground uppercase tracking-wide">
|
||||
Coming soon
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Additional credit packages will be available soon
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Gift className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="font-semibold text-sm">Earn More Credits</span>
|
||||
</div>
|
||||
<ShareForCredits />
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-[var(--accent-orange)]/30 bg-[var(--accent-orange)]/5 p-5">
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import type { UIMessage } from 'ai'
|
||||
import { Loader2, RotateCcw, Square, X } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
interface RunWorkflowDialogProps {
|
||||
open: boolean
|
||||
workflowName: string
|
||||
messages: UIMessage[]
|
||||
status: 'streaming' | 'submitted' | 'ready' | 'error'
|
||||
wasCancelled: boolean
|
||||
error: Error | undefined
|
||||
onStop: () => void
|
||||
onRetry: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const RunWorkflowDialog: FC<RunWorkflowDialogProps> = ({
|
||||
open,
|
||||
workflowName,
|
||||
messages,
|
||||
status,
|
||||
wasCancelled,
|
||||
error,
|
||||
onStop,
|
||||
onRetry,
|
||||
onClose,
|
||||
}) => {
|
||||
const isProcessing = status === 'streaming' || status === 'submitted'
|
||||
const _isComplete = !isProcessing
|
||||
|
||||
const getStatusText = () => {
|
||||
if (status === 'submitted') return 'Starting workflow...'
|
||||
if (status === 'streaming') return 'Running...'
|
||||
if (wasCancelled) return 'Execution cancelled'
|
||||
if (status === 'error') return 'Error occurred'
|
||||
return 'Completed'
|
||||
}
|
||||
|
||||
const getMessageContent = (message: UIMessage) => {
|
||||
return message.parts
|
||||
.filter((part) => part.type === 'text')
|
||||
.map((part) => part.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
const assistantMessages = messages.filter((m) => m.role === 'assistant')
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={() => {}}>
|
||||
<DialogContent
|
||||
className="max-h-[80vh] max-w-2xl overflow-hidden [&>button]:hidden"
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader className="flex-row items-center justify-between space-y-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{isProcessing && (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-[var(--accent-orange)]" />
|
||||
)}
|
||||
Running: {workflowName}
|
||||
</DialogTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{isProcessing ? (
|
||||
<Button variant="destructive" size="sm" onClick={onStop}>
|
||||
<Square className="mr-1.5 h-3 w-3" />
|
||||
Stop
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="secondary" size="sm" onClick={onRetry}>
|
||||
<RotateCcw className="mr-1.5 h-3 w-3" />
|
||||
Retry
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
<X className="mr-1.5 h-3 w-3" />
|
||||
Close
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-muted-foreground text-sm">{getStatusText()}</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
|
||||
<div className="font-medium">Error Details</div>
|
||||
<div className="mt-1 whitespace-pre-wrap font-mono text-xs">
|
||||
{error.message}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-[50vh] overflow-y-auto rounded-lg border border-border bg-muted/30 p-4">
|
||||
{assistantMessages.length === 0 ? (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{isProcessing
|
||||
? 'Waiting for response...'
|
||||
: 'No output available.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{assistantMessages.map((message) => (
|
||||
<div key={message.id} className="whitespace-pre-wrap text-sm">
|
||||
{getMessageContent(message)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { Pencil, Play, Trash2 } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { NavLink } from 'react-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { Workflow } from '@/lib/workflows/workflowStorage'
|
||||
|
||||
interface WorkflowCardProps {
|
||||
workflow: Workflow
|
||||
onDelete: () => void
|
||||
onRun: () => void
|
||||
}
|
||||
|
||||
export const WorkflowCard: FC<WorkflowCardProps> = ({
|
||||
workflow,
|
||||
onDelete,
|
||||
onRun,
|
||||
}) => {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-4 shadow-sm transition-all hover:border-[var(--accent-orange)]/50 hover:shadow-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="truncate font-semibold">
|
||||
{workflow.workflowName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onRun}>
|
||||
<Play className="mr-1.5 h-3 w-3" />
|
||||
Run
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<NavLink to={`/workflows/create-graph?workflowId=${workflow.id}`}>
|
||||
<Pencil className="mr-1.5 h-3 w-3" />
|
||||
Edit
|
||||
</NavLink>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onDelete}
|
||||
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
aria-label={`Delete ${workflow.workflowName}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { HelpCircle, Plus, Workflow } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { NavLink } from 'react-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { workflowsHelpUrl } from '@/lib/constants/productUrls'
|
||||
|
||||
export const WorkflowsHeader: FC = () => {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-6 shadow-sm transition-all hover:shadow-md">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-[var(--accent-orange)]/10">
|
||||
<Workflow className="h-6 w-6 text-[var(--accent-orange)]" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<h2 className="font-semibold text-xl">Workflows</h2>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={workflowsHelpUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-full p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Learn more about workflows</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Create and manage browser automation workflows
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
className="border-[var(--accent-orange)] bg-[var(--accent-orange)]/10 text-[var(--accent-orange)] hover:bg-[var(--accent-orange)]/20 hover:text-[var(--accent-orange)]"
|
||||
variant="outline"
|
||||
>
|
||||
<NavLink to="/workflows/create-graph">
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
New Workflow
|
||||
</NavLink>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { FC } from 'react'
|
||||
import type { Workflow } from '@/lib/workflows/workflowStorage'
|
||||
import { WorkflowCard } from './WorkflowCard'
|
||||
|
||||
interface WorkflowsListProps {
|
||||
workflows: Workflow[]
|
||||
onDelete: (workflowId: string) => void
|
||||
onRun: (workflowId: string) => void
|
||||
}
|
||||
|
||||
export const WorkflowsList: FC<WorkflowsListProps> = ({
|
||||
workflows,
|
||||
onDelete,
|
||||
onRun,
|
||||
}) => {
|
||||
if (workflows.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-6 shadow-sm">
|
||||
<div className="rounded-lg border border-border border-dashed py-8 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No workflows yet. Create one to automate browser tasks.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{workflows.map((workflow) => (
|
||||
<WorkflowCard
|
||||
key={workflow.id}
|
||||
workflow={workflow}
|
||||
onDelete={() => onDelete(workflow.id)}
|
||||
onRun={() => onRun(workflow.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import { type FC, useState } from 'react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
WORKFLOW_DELETED_EVENT,
|
||||
WORKFLOW_RUN_STARTED_EVENT,
|
||||
} from '@/lib/constants/analyticsEvents'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import { useRpcClient } from '@/lib/rpc/RpcClientProvider'
|
||||
import { sentry } from '@/lib/sentry/sentry'
|
||||
import { useWorkflows } from '@/lib/workflows/workflowStorage'
|
||||
import { RunWorkflowDialog } from './RunWorkflowDialog'
|
||||
import { useRunWorkflow } from './useRunWorkflow'
|
||||
import { WorkflowsHeader } from './WorkflowsHeader'
|
||||
import { WorkflowsList } from './WorkflowsList'
|
||||
|
||||
export const WorkflowsPage: FC = () => {
|
||||
const { workflows, removeWorkflow } = useWorkflows()
|
||||
const rpcClient = useRpcClient()
|
||||
|
||||
const [deleteWorkflowId, setDeleteWorkflowId] = useState<string | null>(null)
|
||||
|
||||
const {
|
||||
isRunning,
|
||||
runningWorkflowName,
|
||||
messages,
|
||||
status,
|
||||
wasCancelled,
|
||||
error,
|
||||
runWorkflow,
|
||||
stopRun,
|
||||
retry,
|
||||
closeDialog,
|
||||
} = useRunWorkflow()
|
||||
|
||||
const handleDelete = (workflowId: string) => {
|
||||
setDeleteWorkflowId(workflowId)
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteWorkflowId) return
|
||||
|
||||
const workflow = workflows.find((w) => w.id === deleteWorkflowId)
|
||||
if (!workflow) return
|
||||
|
||||
try {
|
||||
await rpcClient.graph[':id'].$delete({ param: { id: workflow.codeId } })
|
||||
} catch (error) {
|
||||
sentry.captureException(error, {
|
||||
extra: {
|
||||
message: 'Failed to delete graph from server',
|
||||
codeId: workflow.codeId,
|
||||
workflowId: deleteWorkflowId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await removeWorkflow(deleteWorkflowId)
|
||||
setDeleteWorkflowId(null)
|
||||
track(WORKFLOW_DELETED_EVENT)
|
||||
}
|
||||
|
||||
const handleRun = (workflowId: string) => {
|
||||
const workflow = workflows.find((w) => w.id === workflowId)
|
||||
if (workflow) {
|
||||
track(WORKFLOW_RUN_STARTED_EVENT)
|
||||
runWorkflow(workflow.codeId, workflow.workflowName)
|
||||
}
|
||||
}
|
||||
|
||||
const workflowToDelete = deleteWorkflowId
|
||||
? workflows.find((w) => w.id === deleteWorkflowId)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="fade-in slide-in-from-bottom-5 animate-in space-y-6 duration-500">
|
||||
<WorkflowsHeader />
|
||||
|
||||
<WorkflowsList
|
||||
workflows={workflows}
|
||||
onDelete={handleDelete}
|
||||
onRun={handleRun}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={deleteWorkflowId !== null}
|
||||
onOpenChange={(open) => !open && setDeleteWorkflowId(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Workflow</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Delete "{workflowToDelete?.workflowName}"? This action cannot be
|
||||
undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmDelete}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<RunWorkflowDialog
|
||||
open={isRunning}
|
||||
workflowName={runningWorkflowName}
|
||||
messages={messages}
|
||||
status={status}
|
||||
wasCancelled={wasCancelled}
|
||||
error={error}
|
||||
onStop={stopRun}
|
||||
onRetry={retry}
|
||||
onClose={closeDialog}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { type FC, Suspense } from 'react'
|
||||
import { WorkflowsPage } from './WorkflowsPage'
|
||||
|
||||
export const WorkflowsPageWrapper: FC = () => {
|
||||
return (
|
||||
<Suspense fallback={<div className="h-screen w-screen bg-background" />}>
|
||||
<WorkflowsPage />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
import { useChat } from '@ai-sdk/react'
|
||||
import { DefaultChatTransport } from 'ai'
|
||||
import { compact } from 'es-toolkit/array'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useChatRefs } from '@/entrypoints/sidepanel/index/useChatRefs'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
import {
|
||||
WORKFLOW_RUN_COMPLETED_EVENT,
|
||||
WORKFLOW_RUN_RETRIED_EVENT,
|
||||
WORKFLOW_RUN_STOPPED_EVENT,
|
||||
} from '@/lib/constants/analyticsEvents'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
|
||||
type WorkflowMessageMetadata = {
|
||||
window?: chrome.windows.Window
|
||||
}
|
||||
|
||||
export const useRunWorkflow = () => {
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [runningWorkflowName, setRunningWorkflowName] = useState<string>('')
|
||||
const [wasCancelled, setWasCancelled] = useState(false)
|
||||
const codeIdRef = useRef<string | undefined>(undefined)
|
||||
|
||||
const { baseUrl: agentServerUrl } = useAgentServerUrl()
|
||||
|
||||
const {
|
||||
selectedLlmProviderRef,
|
||||
enabledMcpServersRef,
|
||||
enabledCustomServersRef,
|
||||
personalizationRef,
|
||||
} = useChatRefs()
|
||||
|
||||
const agentUrlRef = useRef(agentServerUrl)
|
||||
|
||||
useEffect(() => {
|
||||
agentUrlRef.current = agentServerUrl
|
||||
}, [agentServerUrl])
|
||||
|
||||
const { sendMessage, stop, status, messages, setMessages, error } = useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
prepareSendMessagesRequest: async ({ messages }) => {
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
const metadata = lastMessage.metadata as
|
||||
| WorkflowMessageMetadata
|
||||
| undefined
|
||||
const provider = selectedLlmProviderRef.current
|
||||
const enabledMcpServers = enabledMcpServersRef.current
|
||||
const customMcpServers = enabledCustomServersRef.current
|
||||
|
||||
return {
|
||||
api: `${agentUrlRef.current}/graph/${codeIdRef.current}/run`,
|
||||
body: {
|
||||
provider: provider?.type,
|
||||
providerType: provider?.type,
|
||||
providerName: provider?.name,
|
||||
model: provider?.modelId ?? 'browseros',
|
||||
contextWindowSize: provider?.contextWindow,
|
||||
temperature: provider?.temperature,
|
||||
resourceName: provider?.resourceName,
|
||||
accessKeyId: provider?.accessKeyId,
|
||||
secretAccessKey: provider?.secretAccessKey,
|
||||
region: provider?.region,
|
||||
sessionToken: provider?.sessionToken,
|
||||
apiKey: provider?.apiKey,
|
||||
baseUrl: provider?.baseUrl,
|
||||
browserContext: {
|
||||
windowId: metadata?.window?.id,
|
||||
activeTab: metadata?.window?.tabs?.[0],
|
||||
enabledMcpServers: compact(enabledMcpServers),
|
||||
customMcpServers,
|
||||
},
|
||||
userSystemPrompt: personalizationRef.current,
|
||||
supportsImages: provider?.supportsImages,
|
||||
},
|
||||
}
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
const previousStatus = useRef(status)
|
||||
useEffect(() => {
|
||||
const wasProcessing =
|
||||
previousStatus.current === 'streaming' ||
|
||||
previousStatus.current === 'submitted'
|
||||
const justFinished =
|
||||
wasProcessing && (status === 'ready' || status === 'error')
|
||||
|
||||
if (justFinished && isRunning) {
|
||||
track(WORKFLOW_RUN_COMPLETED_EVENT, {
|
||||
status: wasCancelled
|
||||
? 'cancelled'
|
||||
: status === 'error'
|
||||
? 'failed'
|
||||
: 'completed',
|
||||
})
|
||||
}
|
||||
previousStatus.current = status
|
||||
}, [status, isRunning, wasCancelled])
|
||||
|
||||
const startWorkflowRun = async () => {
|
||||
setMessages([])
|
||||
setWasCancelled(false)
|
||||
|
||||
let backgroundWindow: chrome.windows.Window | undefined
|
||||
try {
|
||||
backgroundWindow = await chrome.windows.create({
|
||||
url: 'chrome://newtab',
|
||||
focused: true,
|
||||
type: 'normal',
|
||||
})
|
||||
} catch {
|
||||
// Fallback when no window context is available (e.g. all windows closed)
|
||||
const tab = await chrome.tabs.create({
|
||||
url: 'chrome://newtab',
|
||||
active: true,
|
||||
})
|
||||
if (tab.windowId) {
|
||||
backgroundWindow = await chrome.windows.get(tab.windowId)
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage({
|
||||
text: 'Run the workflow.',
|
||||
metadata: {
|
||||
window: backgroundWindow,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const runWorkflow = async (codeId: string, workflowName: string) => {
|
||||
codeIdRef.current = codeId
|
||||
setRunningWorkflowName(workflowName)
|
||||
setIsRunning(true)
|
||||
await startWorkflowRun()
|
||||
}
|
||||
|
||||
const stopRun = () => {
|
||||
track(WORKFLOW_RUN_STOPPED_EVENT)
|
||||
setWasCancelled(true)
|
||||
stop()
|
||||
}
|
||||
|
||||
const retry = async () => {
|
||||
track(WORKFLOW_RUN_RETRIED_EVENT)
|
||||
await startWorkflowRun()
|
||||
}
|
||||
|
||||
const closeDialog = () => {
|
||||
setIsRunning(false)
|
||||
setRunningWorkflowName('')
|
||||
setWasCancelled(false)
|
||||
setMessages([])
|
||||
}
|
||||
|
||||
return {
|
||||
isRunning,
|
||||
runningWorkflowName,
|
||||
messages,
|
||||
status,
|
||||
wasCancelled,
|
||||
error,
|
||||
runWorkflow,
|
||||
stopRun,
|
||||
retry,
|
||||
closeDialog,
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export const TIPS: Tip[] = [
|
||||
},
|
||||
{
|
||||
id: 'mcp-servers',
|
||||
text: 'Add MCP servers for Google Calendar, Gmail, Notion, and more to build multi-service workflows.',
|
||||
text: 'Add MCP servers for Google Calendar, Gmail, Notion, and more to power multi-service automations.',
|
||||
},
|
||||
{
|
||||
id: 'skills',
|
||||
@@ -75,10 +75,6 @@ export const TIPS: Tip[] = [
|
||||
id: 'at-mention-tabs',
|
||||
text: 'Type @ in the search bar to mention and attach open tabs as context for your AI queries.',
|
||||
},
|
||||
{
|
||||
id: 'workflows',
|
||||
text: 'For complex repeatable tasks, build visual Workflows instead of one-off prompts for consistent results.',
|
||||
},
|
||||
{
|
||||
id: 'mode-selection',
|
||||
text: 'Use Chat mode for read-only operations like questions and summaries, and Agent mode for multi-step browser tasks.',
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Bot,
|
||||
Code2,
|
||||
FolderOpen,
|
||||
GitBranch,
|
||||
LinkIcon,
|
||||
Plug,
|
||||
SplitSquareHorizontal,
|
||||
@@ -23,7 +22,6 @@ import {
|
||||
COWORK_DEMO_URL,
|
||||
MCP_SERVER_DEMO_URL,
|
||||
SPLIT_VIEW_GIF_URL,
|
||||
WORKFLOWS_DEMO_URL,
|
||||
} from '@/lib/constants/mediaUrls'
|
||||
import {
|
||||
discordUrl,
|
||||
@@ -44,7 +42,7 @@ const features: Feature[] = [
|
||||
description:
|
||||
'Describe any task and watch BrowserOS execute it—clicking, typing, and navigating for you.',
|
||||
detailedDescription:
|
||||
'The BrowserOS Agent turns your words into browser actions. Describe what you need in plain English—fill out this form, extract data from that page, navigate through these steps—and the agent handles the rest. It clicks buttons, types text, navigates between pages, and completes multi-step workflows automatically. Everything runs locally on your machine with your own API keys, so your data stays private.',
|
||||
'The BrowserOS Agent turns your words into browser actions. Describe what you need in plain English—fill out this form, extract data from that page, navigate through these steps—and the agent handles the rest. It clicks buttons, types text, navigates between pages, and completes multi-step browser tasks automatically. Everything runs locally on your machine with your own API keys, so your data stays private.',
|
||||
highlights: [
|
||||
'Multi-tab execution — run agents in multiple tabs simultaneously',
|
||||
'Smart navigation — automatically finds and interacts with page elements',
|
||||
@@ -75,24 +73,6 @@ const features: Feature[] = [
|
||||
gridClass: 'md:col-span-1',
|
||||
videoUrl: MCP_SERVER_DEMO_URL,
|
||||
},
|
||||
{
|
||||
id: 'workflows',
|
||||
Icon: GitBranch,
|
||||
tag: 'AUTOMATION',
|
||||
title: 'Visual Workflows',
|
||||
description:
|
||||
'Build reliable, repeatable automations with a visual graph builder.',
|
||||
detailedDescription:
|
||||
'Workflows turn complex browser tasks into reliable, reusable automations. Instead of hoping the agent figures out the right steps each time, you define the exact sequence in a visual graph. Describe what you want in chat, and the workflow agent generates the graph. Add loops, conditionals, and parallel branches. Save workflows and run them on-demand whenever you need.',
|
||||
highlights: [
|
||||
'Chat-to-graph — describe your automation and get a visual workflow',
|
||||
'Parallel execution — run multiple branches simultaneously',
|
||||
'Loops & conditionals — handle complex logic with flow control',
|
||||
'Save & reuse — run saved workflows on-demand, daily, or weekly',
|
||||
],
|
||||
gridClass: 'md:col-span-1',
|
||||
videoUrl: WORKFLOWS_DEMO_URL || undefined,
|
||||
},
|
||||
{
|
||||
id: 'cowork',
|
||||
Icon: FolderOpen,
|
||||
|
||||
@@ -1,20 +1,59 @@
|
||||
import { AlertCircle, RefreshCw } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
// import { useMemo } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { ShareForCredits } from '@/components/referral/ShareForCredits'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { ProviderType } from '@/lib/llm-providers/types'
|
||||
|
||||
// --- Commented out for Kimi partnership launch (restore after) ---
|
||||
// const SURVEY_DIRECTIONS = [
|
||||
// 'competitor',
|
||||
// 'switching',
|
||||
// 'workflow',
|
||||
// 'activation',
|
||||
// ] as const
|
||||
//
|
||||
// function pickRandomDirection(): string {
|
||||
// return SURVEY_DIRECTIONS[Math.floor(Math.random() * SURVEY_DIRECTIONS.length)]
|
||||
// }
|
||||
// --- End commented out survey code ---
|
||||
const SURVEY_DIRECTIONS = [
|
||||
'competitor',
|
||||
'switching',
|
||||
'workflow',
|
||||
'activation',
|
||||
] as const
|
||||
|
||||
function pickRandomDirection(): string {
|
||||
return SURVEY_DIRECTIONS[Math.floor(Math.random() * SURVEY_DIRECTIONS.length)]
|
||||
}
|
||||
|
||||
const PROVIDER_DISPLAY_NAMES: Record<ProviderType, string> = {
|
||||
anthropic: 'Anthropic',
|
||||
openai: 'OpenAI',
|
||||
'openai-compatible': 'OpenAI-compatible',
|
||||
google: 'Google',
|
||||
openrouter: 'OpenRouter',
|
||||
azure: 'Azure OpenAI',
|
||||
ollama: 'Ollama',
|
||||
lmstudio: 'LM Studio',
|
||||
bedrock: 'AWS Bedrock',
|
||||
browseros: 'BrowserOS',
|
||||
moonshot: 'Moonshot',
|
||||
'chatgpt-pro': 'ChatGPT Pro',
|
||||
'github-copilot': 'GitHub Copilot',
|
||||
'qwen-code': 'Qwen Code',
|
||||
minimax: 'MiniMax',
|
||||
}
|
||||
|
||||
const UPSTREAM_RATE_LIMIT_PATTERNS: Array<string | RegExp> = [
|
||||
'usage limit',
|
||||
'rate limit',
|
||||
'rate-limit',
|
||||
'quota',
|
||||
/\b429\b/,
|
||||
'too many requests',
|
||||
'insufficient_quota',
|
||||
]
|
||||
|
||||
function getProviderDisplayName(providerType?: string): string {
|
||||
if (providerType && providerType in PROVIDER_DISPLAY_NAMES) {
|
||||
return PROVIDER_DISPLAY_NAMES[providerType as ProviderType]
|
||||
}
|
||||
return 'your provider'
|
||||
}
|
||||
|
||||
function stripRetryPrefix(message: string): string {
|
||||
return message.replace(/^Failed after \d+ attempts?\.\s*Last error:\s*/i, '')
|
||||
}
|
||||
|
||||
interface ChatErrorProps {
|
||||
error: Error
|
||||
@@ -31,6 +70,8 @@ function parseErrorMessage(
|
||||
isRateLimit?: boolean
|
||||
isCreditsExhausted?: boolean
|
||||
isConnectionError?: boolean
|
||||
isUpstreamRateLimit?: boolean
|
||||
providerName?: string
|
||||
} {
|
||||
const isBrowserosProvider = providerType === 'browseros'
|
||||
|
||||
@@ -71,6 +112,28 @@ function parseErrorMessage(
|
||||
}
|
||||
}
|
||||
|
||||
// Detect rate limits from non-BrowserOS upstream providers. Users were
|
||||
// confused that a quota/429 from OpenAI/Anthropic/etc. looked like a
|
||||
// BrowserOS-imposed limit.
|
||||
if (!isBrowserosProvider && providerType) {
|
||||
const lower = message.toLowerCase()
|
||||
const matchesRateLimit = UPSTREAM_RATE_LIMIT_PATTERNS.some((p) =>
|
||||
typeof p === 'string' ? lower.includes(p) : p.test(lower),
|
||||
)
|
||||
if (matchesRateLimit) {
|
||||
let stripped = stripRetryPrefix(message).trim()
|
||||
try {
|
||||
const parsed = JSON.parse(stripped)
|
||||
if (parsed?.error?.message) stripped = parsed.error.message
|
||||
} catch {}
|
||||
return {
|
||||
text: stripped || message,
|
||||
isUpstreamRateLimit: true,
|
||||
providerName: getProviderDisplayName(providerType),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let text = message
|
||||
try {
|
||||
const parsed = JSON.parse(message)
|
||||
@@ -92,18 +155,28 @@ export const ChatError: FC<ChatErrorProps> = ({
|
||||
onRetry,
|
||||
providerType,
|
||||
}) => {
|
||||
const { text, url, isRateLimit, isCreditsExhausted, isConnectionError } =
|
||||
parseErrorMessage(error.message, providerType)
|
||||
const {
|
||||
text,
|
||||
url,
|
||||
isRateLimit,
|
||||
isCreditsExhausted,
|
||||
isConnectionError,
|
||||
isUpstreamRateLimit,
|
||||
providerName,
|
||||
} = parseErrorMessage(error.message, providerType)
|
||||
|
||||
// --- Commented out for Kimi partnership launch (restore after) ---
|
||||
// const surveyUrl = useMemo(
|
||||
// () =>
|
||||
// `/app.html?page=survey&maxTurns=20&experimentId=daily_limit_${pickRandomDirection()}#/settings/survey`,
|
||||
// [],
|
||||
// )
|
||||
// --- End commented out survey code ---
|
||||
const surveyUrl = useMemo(
|
||||
() =>
|
||||
`/app.html?page=survey&maxTurns=20&experimentId=daily_limit_${pickRandomDirection()}#/settings/survey`,
|
||||
[],
|
||||
)
|
||||
|
||||
const getTitle = () => {
|
||||
if (isUpstreamRateLimit) {
|
||||
return providerName && providerName !== 'your provider'
|
||||
? `${providerName} rate limit reached`
|
||||
: 'Upstream rate limit reached'
|
||||
}
|
||||
if (isRateLimit) return 'Daily limit reached'
|
||||
if (isConnectionError) return 'Connection failed'
|
||||
return 'Something went wrong'
|
||||
@@ -116,6 +189,14 @@ export const ChatError: FC<ChatErrorProps> = ({
|
||||
<span className="font-medium text-sm">{getTitle()}</span>
|
||||
</div>
|
||||
<p className="text-center text-destructive text-xs">{text}</p>
|
||||
{isUpstreamRateLimit && (
|
||||
<p className="text-center text-muted-foreground text-xs">
|
||||
This is a limit from{' '}
|
||||
<span className="font-medium">{providerName}</span>
|
||||
{' — your configured model provider — not BrowserOS. Check your '}
|
||||
provider's dashboard for quota, usage, or billing details.
|
||||
</p>
|
||||
)}
|
||||
{isConnectionError && url && (
|
||||
<a
|
||||
href={url}
|
||||
@@ -126,8 +207,24 @@ export const ChatError: FC<ChatErrorProps> = ({
|
||||
View troubleshooting guide
|
||||
</a>
|
||||
)}
|
||||
{/* --- Commented out for Kimi partnership launch (restore after) ---
|
||||
{isRateLimit && (
|
||||
{isCreditsExhausted && (
|
||||
<>
|
||||
<div className="w-full border-border/50 border-t pt-3">
|
||||
<ShareForCredits compact />
|
||||
</div>
|
||||
{url && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground text-xs underline hover:text-foreground"
|
||||
>
|
||||
View Usage & Billing
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isRateLimit && !isCreditsExhausted && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<a
|
||||
href={url}
|
||||
@@ -148,27 +245,6 @@ export const ChatError: FC<ChatErrorProps> = ({
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
--- End commented out survey code --- */}
|
||||
{isCreditsExhausted && url && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground text-xs underline hover:text-foreground"
|
||||
>
|
||||
View Usage & Billing
|
||||
</a>
|
||||
)}
|
||||
{isRateLimit && providerType === 'browseros' && (
|
||||
<a
|
||||
href="/app.html#/settings/ai"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--accent-orange)] bg-[var(--accent-orange)]/10 px-3 py-1.5 font-medium text-[var(--accent-orange)] text-xs transition-colors hover:bg-[var(--accent-orange)]/20"
|
||||
>
|
||||
Add your own provider for unlimited usage
|
||||
</a>
|
||||
)}
|
||||
{onRetry && (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -561,9 +561,11 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
}, [])
|
||||
|
||||
const handleSelectProvider = (provider: Provider) => {
|
||||
const fullProvider = llmProviders.find((p) => p.id === provider.id)
|
||||
track(PROVIDER_SELECTED_EVENT, {
|
||||
provider_id: provider.id,
|
||||
provider_type: provider.type,
|
||||
model_id: fullProvider?.modelId,
|
||||
})
|
||||
setDefaultProvider(provider.id)
|
||||
}
|
||||
|
||||
@@ -31,8 +31,6 @@ export enum Feature {
|
||||
WORKSPACE_FOLDER_SUPPORT = 'WORKSPACE_FOLDER_SUPPORT',
|
||||
// Proxy server support
|
||||
PROXY_SUPPORT = 'PROXY_SUPPORT',
|
||||
// Workflows feature
|
||||
WORKFLOW_SUPPORT = 'WORKFLOW_SUPPORT',
|
||||
// previousConversation as structured array (older servers only accept string)
|
||||
PREVIOUS_CONVERSATION_ARRAY = 'PREVIOUS_CONVERSATION_ARRAY',
|
||||
// Soul page: agent personality viewer and editor
|
||||
@@ -73,7 +71,6 @@ const FEATURE_CONFIG: { [K in Feature]: FeatureConfig } = {
|
||||
[Feature.CUSTOMIZATION_SUPPORT]: { minBrowserOSVersion: '0.36.1.0' },
|
||||
[Feature.WORKSPACE_FOLDER_SUPPORT]: { minBrowserOSVersion: '0.36.4.0' },
|
||||
[Feature.PROXY_SUPPORT]: { minBrowserOSVersion: '0.39.0.1' },
|
||||
[Feature.WORKFLOW_SUPPORT]: { minServerVersion: '0.0.41' },
|
||||
[Feature.PREVIOUS_CONVERSATION_ARRAY]: { minServerVersion: '0.0.64' },
|
||||
[Feature.SOUL_SUPPORT]: { minServerVersion: '0.0.67' },
|
||||
[Feature.NEWTAB_CHAT_SUPPORT]: { minBrowserOSVersion: '0.40.0.0' },
|
||||
|
||||
@@ -1,19 +1,6 @@
|
||||
/** @public */
|
||||
export const MESSAGE_LIKE_EVENT = 'ui.message.like'
|
||||
|
||||
export const GRAPH_MESSAGE_LIKE_EVENT = 'settings.graph.message.like'
|
||||
|
||||
export const GRAPH_MESSAGE_DISLIKE_EVENT = 'settings.graph.message.dislike'
|
||||
|
||||
/** @public */
|
||||
export const NEW_GRAPH_CREATED_EVENT = 'settings.graph.created'
|
||||
|
||||
/** @public */
|
||||
export const GRAPH_SAVED_EVENT = 'settings.graph.saved'
|
||||
|
||||
/** @public */
|
||||
export const GRAPH_UPDATED_EVENT = 'settings.graph.updated'
|
||||
|
||||
/** @public */
|
||||
export const MESSAGE_DISLIKE_EVENT = 'ui.message.dislike'
|
||||
|
||||
@@ -29,6 +16,12 @@ export const CONVERSATION_RESET_EVENT = 'ui.conversation.reset'
|
||||
/** @public */
|
||||
export const AI_PROVIDER_ADDED_EVENT = 'settings.ai_provider.added'
|
||||
|
||||
/** @public */
|
||||
export const AI_PROVIDER_UPDATED_EVENT = 'settings.ai_provider.updated'
|
||||
|
||||
/** @public */
|
||||
export const MODEL_SELECTED_EVENT = 'settings.model.selected'
|
||||
|
||||
/** @public */
|
||||
export const CHATGPT_PRO_OAUTH_STARTED_EVENT =
|
||||
'settings.chatgpt_pro.oauth_started'
|
||||
@@ -172,21 +165,6 @@ export const NEWTAB_VOICE_TRANSCRIPTION_COMPLETED_EVENT =
|
||||
/** @public */
|
||||
export const NEWTAB_VOICE_ERROR_EVENT = 'newtab.voice.error'
|
||||
|
||||
/** @public */
|
||||
export const WORKFLOW_DELETED_EVENT = 'settings.workflow.deleted'
|
||||
|
||||
/** @public */
|
||||
export const WORKFLOW_RUN_STARTED_EVENT = 'settings.workflow.run_started'
|
||||
|
||||
/** @public */
|
||||
export const WORKFLOW_RUN_STOPPED_EVENT = 'settings.workflow.run_stopped'
|
||||
|
||||
/** @public */
|
||||
export const WORKFLOW_RUN_RETRIED_EVENT = 'settings.workflow.run_retried'
|
||||
|
||||
/** @public */
|
||||
export const WORKFLOW_RUN_COMPLETED_EVENT = 'settings.workflow.run_completed'
|
||||
|
||||
/** @public */
|
||||
export const SIDEPANEL_AI_TRIGGERED_EVENT = 'sidepanel.ai.triggered'
|
||||
|
||||
@@ -302,14 +280,6 @@ export const KIMI_API_KEY_CONFIGURED_EVENT = 'settings.kimi.api_key_configured'
|
||||
export const KIMI_API_KEY_GUIDE_CLICKED_EVENT =
|
||||
'settings.kimi.api_key_guide_clicked'
|
||||
|
||||
/** @public */
|
||||
export const KIMI_RATE_LIMIT_DOCS_CLICKED_EVENT =
|
||||
'ui.rate_limit.kimi_docs_clicked'
|
||||
|
||||
/** @public */
|
||||
export const KIMI_RATE_LIMIT_PLATFORM_CLICKED_EVENT =
|
||||
'ui.rate_limit.moonshot_platform_clicked'
|
||||
|
||||
/** @public */
|
||||
export const SIDEPANEL_VOICE_RECORDING_STARTED_EVENT =
|
||||
'sidepanel.voice.recording_started'
|
||||
|
||||
@@ -49,11 +49,6 @@ export const productVideoUrl = 'https://youtu.be/J-lFhTP-7is'
|
||||
*/
|
||||
export const productRepositoryShortUrl = 'https://git.new/browseros'
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const workflowsHelpUrl = 'https://docs.browseros.com/features/workflows'
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { getBrowserOSAdapter } from '@/lib/browseros/adapter'
|
||||
import { BROWSEROS_PREFS } from '@/lib/browseros/prefs'
|
||||
|
||||
// TODO(credits-identity): temporary shim — reuses the BrowserOS metrics
|
||||
// install_id as the credits/referral identifier. Replace with a dedicated
|
||||
// identity module once we have one.
|
||||
export async function getBrowserosId(): Promise<string> {
|
||||
const adapter = getBrowserOSAdapter()
|
||||
const pref = await adapter.getPref(BROWSEROS_PREFS.INSTALL_ID)
|
||||
const id = pref.value
|
||||
if (typeof id !== 'string' || id.length === 0) {
|
||||
throw new Error('browseros.metrics_install_id is not set')
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -1,20 +1,25 @@
|
||||
import { EXTERNAL_URLS } from '@browseros/shared/constants/urls'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { getAgentServerUrl } from '@/lib/browseros/helpers'
|
||||
import { getBrowserosId } from './browseros-id'
|
||||
|
||||
export interface CreditsInfo {
|
||||
credits: number
|
||||
dailyLimit: number
|
||||
lastResetAt?: string
|
||||
browserosId?: string
|
||||
}
|
||||
|
||||
const CREDITS_QUERY_KEY = ['credits']
|
||||
|
||||
async function fetchCredits(): Promise<CreditsInfo> {
|
||||
const baseUrl = await getAgentServerUrl()
|
||||
const response = await fetch(`${baseUrl}/credits`)
|
||||
const browserosId = await getBrowserosId()
|
||||
const response = await fetch(
|
||||
`${EXTERNAL_URLS.CREDITS_GATEWAY}/credits/${browserosId}`,
|
||||
)
|
||||
if (!response.ok)
|
||||
throw new Error(`Failed to fetch credits: ${response.status}`)
|
||||
return response.json()
|
||||
const data = (await response.json()) as CreditsInfo
|
||||
return { ...data, browserosId }
|
||||
}
|
||||
|
||||
export function useCredits() {
|
||||
|
||||
@@ -6,7 +6,6 @@ const EnvSchema = z.object({
|
||||
VITE_PUBLIC_POSTHOG_HOST: z.string().optional(),
|
||||
VITE_PUBLIC_SENTRY_DSN: z.string().optional(),
|
||||
VITE_PUBLIC_BROWSEROS_API: z.string().optional(),
|
||||
VITE_PUBLIC_KIMI_LAUNCH: z.string().optional(),
|
||||
PROD: z.boolean(),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { env } from '@/lib/env'
|
||||
|
||||
const ENABLED_VALUES = new Set(['1', 'true', 'yes', 'on'])
|
||||
|
||||
function parseKimiLaunchFlag(value: string | undefined): boolean {
|
||||
if (!value) return false
|
||||
return ENABLED_VALUES.has(value.trim().toLowerCase())
|
||||
}
|
||||
|
||||
const kimiLaunchEnabled = parseKimiLaunchFlag(env.VITE_PUBLIC_KIMI_LAUNCH)
|
||||
|
||||
export function isKimiLaunchEnabled(): boolean {
|
||||
return kimiLaunchEnabled
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { isKimiLaunchEnabled } from './kimi-launch'
|
||||
|
||||
export function useKimiLaunch(): boolean {
|
||||
return isKimiLaunchEnabled()
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { getBrowserOSAdapter } from '@/lib/browseros/adapter'
|
||||
import { BROWSEROS_PREFS } from '@/lib/browseros/prefs'
|
||||
import { isKimiLaunchEnabled } from '@/lib/feature-flags/kimi-launch'
|
||||
|
||||
/** @public */
|
||||
export interface LlmHubProvider {
|
||||
@@ -8,43 +7,15 @@ export interface LlmHubProvider {
|
||||
url: string
|
||||
}
|
||||
|
||||
const KIMI_PROVIDER: LlmHubProvider = {
|
||||
name: 'Kimi',
|
||||
url: 'https://www.kimi.com',
|
||||
}
|
||||
|
||||
function ensureKimiFirst(providers: LlmHubProvider[]): LlmHubProvider[] {
|
||||
if (!isKimiLaunchEnabled()) return providers
|
||||
const hasKimi = providers.some(
|
||||
(p) => p.name === 'Kimi' || p.url.includes('kimi.com'),
|
||||
)
|
||||
return hasKimi ? providers : [KIMI_PROVIDER, ...providers]
|
||||
}
|
||||
|
||||
export async function loadProviders(): Promise<LlmHubProvider[]> {
|
||||
try {
|
||||
const adapter = getBrowserOSAdapter()
|
||||
const providersPref = await adapter.getPref(
|
||||
BROWSEROS_PREFS.THIRD_PARTY_LLM_PROVIDERS,
|
||||
)
|
||||
const providers = (providersPref?.value as LlmHubProvider[]) || []
|
||||
|
||||
if (providers.length === 0) {
|
||||
if (isKimiLaunchEnabled()) {
|
||||
const defaults = [KIMI_PROVIDER]
|
||||
await saveProviders(defaults)
|
||||
return defaults
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const normalized = ensureKimiFirst(providers)
|
||||
if (normalized !== providers) {
|
||||
await saveProviders(normalized)
|
||||
}
|
||||
return normalized
|
||||
return (providersPref?.value as LlmHubProvider[]) || []
|
||||
} catch {
|
||||
return isKimiLaunchEnabled() ? [KIMI_PROVIDER] : []
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5402,5 +5402,89 @@
|
||||
"outputCost": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"minimax": {
|
||||
"name": "MiniMax",
|
||||
"api": "https://api.minimaxi.com/v1",
|
||||
"doc": "https://platform.minimax.io",
|
||||
"models": [
|
||||
{
|
||||
"id": "MiniMax-M2.7",
|
||||
"name": "MiniMax M2.7",
|
||||
"contextWindow": 204800,
|
||||
"maxOutput": 8192,
|
||||
"supportsImages": false,
|
||||
"supportsReasoning": true,
|
||||
"supportsToolCall": true,
|
||||
"inputCost": 0.3,
|
||||
"outputCost": 1.2
|
||||
},
|
||||
{
|
||||
"id": "MiniMax-M2.7-highspeed",
|
||||
"name": "MiniMax M2.7 Highspeed",
|
||||
"contextWindow": 204800,
|
||||
"maxOutput": 8192,
|
||||
"supportsImages": false,
|
||||
"supportsReasoning": true,
|
||||
"supportsToolCall": true,
|
||||
"inputCost": 0.6,
|
||||
"outputCost": 2.4
|
||||
},
|
||||
{
|
||||
"id": "MiniMax-M2.5",
|
||||
"name": "MiniMax M2.5",
|
||||
"contextWindow": 204800,
|
||||
"maxOutput": 8192,
|
||||
"supportsImages": false,
|
||||
"supportsReasoning": true,
|
||||
"supportsToolCall": true,
|
||||
"inputCost": 0.3,
|
||||
"outputCost": 1.2
|
||||
},
|
||||
{
|
||||
"id": "MiniMax-M2.5-highspeed",
|
||||
"name": "MiniMax M2.5 Highspeed",
|
||||
"contextWindow": 204800,
|
||||
"maxOutput": 8192,
|
||||
"supportsImages": false,
|
||||
"supportsReasoning": true,
|
||||
"supportsToolCall": true,
|
||||
"inputCost": 0.6,
|
||||
"outputCost": 2.4
|
||||
},
|
||||
{
|
||||
"id": "MiniMax-M2.1",
|
||||
"name": "MiniMax M2.1",
|
||||
"contextWindow": 204800,
|
||||
"maxOutput": 8192,
|
||||
"supportsImages": false,
|
||||
"supportsReasoning": true,
|
||||
"supportsToolCall": true,
|
||||
"inputCost": 0.3,
|
||||
"outputCost": 1.2
|
||||
},
|
||||
{
|
||||
"id": "MiniMax-M2.1-highspeed",
|
||||
"name": "MiniMax M2.1 Highspeed",
|
||||
"contextWindow": 204800,
|
||||
"maxOutput": 8192,
|
||||
"supportsImages": false,
|
||||
"supportsReasoning": true,
|
||||
"supportsToolCall": true,
|
||||
"inputCost": 0.6,
|
||||
"outputCost": 2.4
|
||||
},
|
||||
{
|
||||
"id": "M2-her",
|
||||
"name": "M2-her",
|
||||
"contextWindow": 204800,
|
||||
"maxOutput": 8192,
|
||||
"supportsImages": false,
|
||||
"supportsReasoning": false,
|
||||
"supportsToolCall": true,
|
||||
"inputCost": 0.3,
|
||||
"outputCost": 1.2
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Gemini,
|
||||
Kimi,
|
||||
LmStudio,
|
||||
Minimax,
|
||||
Ollama,
|
||||
OpenAI,
|
||||
OpenRouter,
|
||||
@@ -36,6 +37,7 @@ const providerIconMap: Record<ProviderType, IconComponent | null> = {
|
||||
'chatgpt-pro': OpenAI,
|
||||
'github-copilot': Github,
|
||||
'qwen-code': Qwen,
|
||||
minimax: Minimax,
|
||||
}
|
||||
|
||||
interface ProviderIconProps {
|
||||
|
||||
@@ -140,8 +140,31 @@ export const providerTemplates: ProviderTemplate[] = [
|
||||
setupGuideUrl:
|
||||
'https://docs.aws.amazon.com/bedrock/latest/userguide/getting-started.html',
|
||||
}),
|
||||
enrichTemplate('minimax', {
|
||||
defaultModelId: 'MiniMax-M2.7',
|
||||
apiKeyUrl:
|
||||
'https://platform.minimax.io/user-center/basic-information/interface-key',
|
||||
setupGuideUrl: 'https://platform.minimax.io/docs/guides/models-intro',
|
||||
}),
|
||||
]
|
||||
|
||||
export const MINIMAX_REGIONS = {
|
||||
chinese: {
|
||||
api: 'https://api.minimaxi.com/v1',
|
||||
apiKeyUrl:
|
||||
'https://platform.minimaxi.com/user-center/basic-information/interface-key',
|
||||
setupGuideUrl: 'https://platform.minimaxi.com/document',
|
||||
},
|
||||
international: {
|
||||
api: 'https://api.minimax.io/v1',
|
||||
apiKeyUrl:
|
||||
'https://platform.minimax.io/user-center/basic-information/interface-key',
|
||||
setupGuideUrl: 'https://platform.minimax.io/docs/guides/models-intro',
|
||||
},
|
||||
} as const
|
||||
|
||||
export type MinimaxRegion = keyof typeof MINIMAX_REGIONS
|
||||
|
||||
/**
|
||||
* Provider type options for select dropdowns
|
||||
* @public
|
||||
@@ -161,6 +184,7 @@ export const providerTypeOptions: { value: ProviderType; label: string }[] = [
|
||||
{ value: 'lmstudio', label: 'LM Studio' },
|
||||
{ value: 'bedrock', label: 'AWS Bedrock' },
|
||||
{ value: 'browseros', label: 'BrowserOS' },
|
||||
{ value: 'minimax', label: 'MiniMax' },
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -192,6 +216,7 @@ export const DEFAULT_BASE_URLS: Record<ProviderType, string> = {
|
||||
lmstudio: 'http://localhost:1234/v1',
|
||||
bedrock: '',
|
||||
browseros: '',
|
||||
minimax: MINIMAX_REGIONS.chinese.api,
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,14 +2,12 @@ import { storage } from '@wxt-dev/storage'
|
||||
import { sessionStorage } from '@/lib/auth/sessionStorage'
|
||||
import { getBrowserOSAdapter } from '@/lib/browseros/adapter'
|
||||
import { BROWSEROS_PREFS } from '@/lib/browseros/prefs'
|
||||
import { isKimiLaunchEnabled } from '@/lib/feature-flags/kimi-launch'
|
||||
import type { LlmProviderConfig, LlmProvidersBackup } from './types'
|
||||
import { uploadLlmProvidersToGraphql } from './uploadLlmProvidersToGraphql'
|
||||
|
||||
/** Default provider ID constant */
|
||||
export const DEFAULT_PROVIDER_ID = 'browseros'
|
||||
const DEFAULT_PROVIDER_NAME = 'BrowserOS'
|
||||
const KIMI_LAUNCH_PROVIDER_NAME = 'Kimi K2.5'
|
||||
|
||||
/** Storage key for LLM providers array */
|
||||
export const providersStorage = storage.defineItem<LlmProviderConfig[]>(
|
||||
@@ -91,7 +89,7 @@ export function setupLlmProvidersSyncToBackend(): () => void {
|
||||
/** Load providers from storage */
|
||||
export async function loadProviders(): Promise<LlmProviderConfig[]> {
|
||||
const providers = (await providersStorage.getValue()) || []
|
||||
const normalizedProviders = normalizeProvidersForLaunch(providers)
|
||||
const normalizedProviders = normalizeProviderNames(providers)
|
||||
|
||||
// Keep storage consistent so every consumer sees the same provider name.
|
||||
if (
|
||||
@@ -109,7 +107,7 @@ export function createDefaultBrowserOSProvider(): LlmProviderConfig {
|
||||
return {
|
||||
id: DEFAULT_PROVIDER_ID,
|
||||
type: 'browseros',
|
||||
name: getBuiltInProviderName(),
|
||||
name: DEFAULT_PROVIDER_NAME,
|
||||
baseUrl: 'https://api.browseros.com/v1',
|
||||
modelId: 'browseros-auto',
|
||||
supportsImages: true,
|
||||
@@ -125,26 +123,22 @@ export function createDefaultProvidersConfig(): LlmProviderConfig[] {
|
||||
return [createDefaultBrowserOSProvider()]
|
||||
}
|
||||
|
||||
function getBuiltInProviderName(): string {
|
||||
return isKimiLaunchEnabled()
|
||||
? KIMI_LAUNCH_PROVIDER_NAME
|
||||
: DEFAULT_PROVIDER_NAME
|
||||
}
|
||||
|
||||
function normalizeProvidersForLaunch(
|
||||
/**
|
||||
* Normalize built-in provider names back to "BrowserOS" (e.g. from "Kimi K2.5"
|
||||
* which was set during a previous partnership launch).
|
||||
*/
|
||||
function normalizeProviderNames(
|
||||
providers: LlmProviderConfig[],
|
||||
): LlmProviderConfig[] {
|
||||
const builtInProviderName = getBuiltInProviderName()
|
||||
|
||||
return providers.map((provider) => {
|
||||
if (
|
||||
provider.id === DEFAULT_PROVIDER_ID &&
|
||||
provider.type === 'browseros' &&
|
||||
provider.name !== builtInProviderName
|
||||
provider.name !== DEFAULT_PROVIDER_NAME
|
||||
) {
|
||||
return {
|
||||
...provider,
|
||||
name: builtInProviderName,
|
||||
name: DEFAULT_PROVIDER_NAME,
|
||||
}
|
||||
}
|
||||
return provider
|
||||
|
||||
@@ -17,6 +17,7 @@ export type ProviderType =
|
||||
| 'chatgpt-pro'
|
||||
| 'github-copilot'
|
||||
| 'qwen-code'
|
||||
| 'minimax'
|
||||
|
||||
/**
|
||||
* LLM Provider configuration
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { EXTERNAL_URLS } from '@browseros/shared/constants/urls'
|
||||
|
||||
interface ReferralResult {
|
||||
success: boolean
|
||||
creditsAdded?: number
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export async function submitReferral(
|
||||
tweetUrl: string,
|
||||
browserosId: string,
|
||||
): Promise<ReferralResult> {
|
||||
const response = await fetch(
|
||||
`${EXTERNAL_URLS.REFERRAL_SERVICE}/referral/submit`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tweetUrl, browserosId }),
|
||||
},
|
||||
)
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
reason: `Request failed with status ${response.status}`,
|
||||
}
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
const TWEET_VARIATIONS = [
|
||||
`ngl @browseros_ai is kinda wild
|
||||
|
||||
just type what u want in plain english and it handles the annoying web shit
|
||||
|
||||
forms, research, data pulls... all automated
|
||||
|
||||
actually works`,
|
||||
|
||||
`been using @browseros_ai to chat with webpages lately
|
||||
|
||||
summarize articles, pull data, translate stuff
|
||||
|
||||
all happens in the same tab
|
||||
|
||||
no copy/paste, no switching windows
|
||||
|
||||
just ask and it does it`,
|
||||
|
||||
`wake up to @browseros_ai having already read ur emails and calendar while u were sleeping
|
||||
|
||||
scheduled agents are lowkey magic`,
|
||||
|
||||
`ngl @browseros_ai is kinda crazy
|
||||
|
||||
connects gmail, slack, linear, notion + 40 other apps into one ai assistant
|
||||
|
||||
just talk to it in plain english and it handles cross-app workflows for u
|
||||
|
||||
no more switching between tabs like a psycho`,
|
||||
|
||||
`i use @browseros_ai to automate research
|
||||
|
||||
it handles the browser work and drops reports straight into local folders
|
||||
|
||||
no switching between tools or manually saving files
|
||||
|
||||
just one task instead of three`,
|
||||
|
||||
`been messing with @browseros_ai lately
|
||||
|
||||
it comes with a prebuilt MCP server and I connect it claude code or codex and it just runs things for you
|
||||
|
||||
set it up once, use it whenever
|
||||
|
||||
way better than clicking through the same shit manually every time`,
|
||||
|
||||
`the ai actually remembers what we talked about yesterday
|
||||
|
||||
no more "here's the context again" every single conversation
|
||||
|
||||
@browseros_ai just picks up where we left off
|
||||
|
||||
feels like talking to someone who actually pays attention`,
|
||||
|
||||
`i built a skill library for my ai agent
|
||||
|
||||
now when i need it to do something specific, i just load the recipe i made earlier
|
||||
|
||||
@browseros_ai MCP is very handy`,
|
||||
|
||||
`been running @browseros_ai with ollama locally
|
||||
|
||||
everything stays on my machine, nothing gets sent out
|
||||
|
||||
kinda nice not having to think about what data i'm sharing`,
|
||||
|
||||
`switched to @browseros_ai from chrome
|
||||
|
||||
blocks 10x more ads and runs full ublock origin (not the lite version)
|
||||
|
||||
check it out`,
|
||||
]
|
||||
|
||||
export function getShareOnTwitterUrl(): string {
|
||||
const text =
|
||||
TWEET_VARIATIONS[Math.floor(Math.random() * TWEET_VARIATIONS.length)]
|
||||
return `https://x.com/intent/tweet?text=${encodeURIComponent(text)}`
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { storage } from '@wxt-dev/storage'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export interface Workflow {
|
||||
id: string
|
||||
codeId: string
|
||||
workflowName: string
|
||||
}
|
||||
|
||||
export const workflowStorage = storage.defineItem<Workflow[]>(
|
||||
'local:workflows',
|
||||
{
|
||||
fallback: [],
|
||||
},
|
||||
)
|
||||
|
||||
export function useWorkflows() {
|
||||
const [workflows, setWorkflows] = useState<Workflow[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
workflowStorage.getValue().then(setWorkflows)
|
||||
const unwatch = workflowStorage.watch((newValue) => {
|
||||
setWorkflows(newValue ?? [])
|
||||
})
|
||||
return unwatch
|
||||
}, [])
|
||||
|
||||
const addWorkflow = async (workflow: Omit<Workflow, 'id'>) => {
|
||||
const newWorkflow: Workflow = {
|
||||
id: crypto.randomUUID(),
|
||||
...workflow,
|
||||
}
|
||||
const current = (await workflowStorage.getValue()) ?? []
|
||||
await workflowStorage.setValue([...current, newWorkflow])
|
||||
return newWorkflow
|
||||
}
|
||||
|
||||
const removeWorkflow = async (id: string) => {
|
||||
const current = (await workflowStorage.getValue()) ?? []
|
||||
await workflowStorage.setValue(current.filter((w) => w.id !== id))
|
||||
}
|
||||
|
||||
const editWorkflow = async (
|
||||
id: string,
|
||||
updates: Partial<Omit<Workflow, 'id'>>,
|
||||
) => {
|
||||
const current = (await workflowStorage.getValue()) ?? []
|
||||
await workflowStorage.setValue(
|
||||
current.map((w) => (w.id === id ? { ...w, ...updates } : w)),
|
||||
)
|
||||
}
|
||||
|
||||
return { workflows, addWorkflow, removeWorkflow, editWorkflow }
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@browseros/agent",
|
||||
"description": "manifest.json description",
|
||||
"private": true,
|
||||
"version": "0.0.98",
|
||||
"version": "0.0.99",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "test -d generated/graphql || bun run codegen; mkdir -p /tmp/browseros-dev; bun --env-file=.env.development wxt",
|
||||
@@ -20,6 +20,7 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^3.0.96",
|
||||
"@browseros/server": "workspace:*",
|
||||
"@browseros/shared": "workspace:*",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@lobehub/icons": "^2.44.0",
|
||||
"@mdxeditor/editor": "^3.52.4",
|
||||
@@ -67,6 +68,7 @@
|
||||
"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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -2,13 +2,17 @@ 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 "$(LDFLAGS)" -o $(BINARY) .
|
||||
|
||||
PLATFORMS := darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64
|
||||
DIST := dist
|
||||
|
||||
.PHONY: install clean vet test release
|
||||
|
||||
@@ -45,6 +49,21 @@ release:
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -34,6 +34,7 @@ const automaticUpdateDrainTimeout = 150 * time.Millisecond
|
||||
|
||||
func SetVersion(v string) {
|
||||
version = v
|
||||
rootCmd.Version = v
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -338,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 != "" {
|
||||
|
||||
@@ -5,6 +5,24 @@ import (
|
||||
"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
|
||||
|
||||
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)
|
||||
})
|
||||
@@ -15,6 +15,7 @@ const (
|
||||
DefaultHTTPTimeout = 2 * time.Second
|
||||
DefaultDownloadTimeout = 5 * time.Minute
|
||||
SkipCheckEnv = "BROWSEROS_SKIP_UPDATE_CHECK"
|
||||
InstallMethodEnv = "BROWSEROS_INSTALL_METHOD"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
@@ -95,9 +96,21 @@ func (m *Manager) AutomaticEnabled() bool {
|
||||
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
|
||||
@@ -210,11 +223,22 @@ func (m *Manager) Apply(ctx context.Context, result *CheckResult) error {
|
||||
}
|
||||
|
||||
func FormatNotice(currentVersion, latestVersion string) string {
|
||||
return fmt.Sprintf(
|
||||
"Update available: browseros-cli v%s (current v%s)\nRun `browseros-cli update` to upgrade.",
|
||||
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) {
|
||||
|
||||
@@ -144,6 +144,32 @@ func TestManagerSaveAppliedState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,111 +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 GetBookmarksInputSchema = z.object({
|
||||
query: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Search query to filter bookmarks (optional, returns all if not provided)',
|
||||
),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.default(20)
|
||||
.describe('Maximum number of results (default: 20)'),
|
||||
recent: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(false)
|
||||
.describe('Get recent bookmarks instead of searching'),
|
||||
})
|
||||
|
||||
// Output schema
|
||||
const GetBookmarksOutputSchema = z.object({
|
||||
bookmarks: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
url: z.string().optional(),
|
||||
dateAdded: z.number().optional(),
|
||||
parentId: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
count: z.number(),
|
||||
})
|
||||
|
||||
type GetBookmarksInput = z.infer<typeof GetBookmarksInputSchema>
|
||||
type GetBookmarksOutput = z.infer<typeof GetBookmarksOutputSchema>
|
||||
|
||||
/**
|
||||
* GetBookmarksAction - Get or search bookmarks
|
||||
*
|
||||
* Retrieves bookmarks with optional filtering.
|
||||
*
|
||||
* Input:
|
||||
* - query (optional): Search query to match title or URL
|
||||
* - limit (optional): Maximum results (default: 20)
|
||||
* - recent (optional): Get recent bookmarks instead (default: false)
|
||||
*
|
||||
* Output:
|
||||
* - bookmarks: Array of bookmark objects
|
||||
* - count: Number of bookmarks returned
|
||||
*
|
||||
* Usage:
|
||||
* - Get recent: { "recent": true }
|
||||
* - Search: { "query": "github" }
|
||||
* - Get all (limited): { "limit": 50 }
|
||||
*
|
||||
* Example:
|
||||
* {
|
||||
* "query": "google",
|
||||
* "limit": 10
|
||||
* }
|
||||
* // Returns: { bookmarks: [{id: "1", title: "Google", url: "https://google.com"}], count: 1 }
|
||||
*/
|
||||
export class GetBookmarksAction extends ActionHandler<
|
||||
GetBookmarksInput,
|
||||
GetBookmarksOutput
|
||||
> {
|
||||
readonly inputSchema = GetBookmarksInputSchema
|
||||
private bookmarkAdapter = new BookmarkAdapter()
|
||||
|
||||
async execute(input: GetBookmarksInput): Promise<GetBookmarksOutput> {
|
||||
let results: chrome.bookmarks.BookmarkTreeNode[]
|
||||
|
||||
if (input.recent) {
|
||||
// Get recent bookmarks
|
||||
results = await this.bookmarkAdapter.getRecentBookmarks(input.limit)
|
||||
} else if (input.query) {
|
||||
// Search bookmarks
|
||||
results = await this.bookmarkAdapter.searchBookmarks(input.query)
|
||||
results = results.slice(0, input.limit)
|
||||
} else {
|
||||
// Get recent by default
|
||||
results = await this.bookmarkAdapter.getRecentBookmarks(input.limit)
|
||||
}
|
||||
|
||||
// Map to output format
|
||||
const bookmarks = results.map((b) => ({
|
||||
id: b.id,
|
||||
title: b.title,
|
||||
url: b.url,
|
||||
dateAdded: b.dateAdded,
|
||||
parentId: b.parentId,
|
||||
}))
|
||||
|
||||
return {
|
||||
bookmarks,
|
||||
count: bookmarks.length,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +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 MoveBookmarkInputSchema = z.object({
|
||||
id: z.string().describe('Bookmark or folder ID to move'),
|
||||
parentId: z.string().optional().describe('New parent folder ID'),
|
||||
index: z.number().int().min(0).optional().describe('Position within parent'),
|
||||
})
|
||||
|
||||
const MoveBookmarkOutputSchema = z.object({
|
||||
id: z.string().describe('Moved bookmark ID'),
|
||||
title: z.string().describe('Bookmark title'),
|
||||
url: z.string().optional().describe('Bookmark URL (undefined if folder)'),
|
||||
parentId: z.string().optional().describe('New parent folder ID'),
|
||||
index: z.number().optional().describe('New position within parent'),
|
||||
})
|
||||
|
||||
type MoveBookmarkInput = z.infer<typeof MoveBookmarkInputSchema>
|
||||
type MoveBookmarkOutput = z.infer<typeof MoveBookmarkOutputSchema>
|
||||
|
||||
export class MoveBookmarkAction extends ActionHandler<
|
||||
MoveBookmarkInput,
|
||||
MoveBookmarkOutput
|
||||
> {
|
||||
readonly inputSchema = MoveBookmarkInputSchema
|
||||
private bookmarkAdapter = new BookmarkAdapter()
|
||||
|
||||
async execute(input: MoveBookmarkInput): Promise<MoveBookmarkOutput> {
|
||||
const destination: { parentId?: string; index?: number } = {}
|
||||
if (input.parentId !== undefined) destination.parentId = input.parentId
|
||||
if (input.index !== undefined) destination.index = input.index
|
||||
|
||||
const moved = await this.bookmarkAdapter.moveBookmark(input.id, destination)
|
||||
|
||||
return {
|
||||
id: moved.id,
|
||||
title: moved.title,
|
||||
url: moved.url,
|
||||
parentId: moved.parentId,
|
||||
index: moved.index,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,62 +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 RemoveBookmarkInputSchema = z.object({
|
||||
id: z.string().describe('Bookmark ID to remove'),
|
||||
})
|
||||
|
||||
// Output schema
|
||||
const RemoveBookmarkOutputSchema = z.object({
|
||||
success: z
|
||||
.boolean()
|
||||
.describe('Whether the bookmark was successfully removed'),
|
||||
message: z.string().describe('Confirmation message'),
|
||||
})
|
||||
|
||||
type RemoveBookmarkInput = z.infer<typeof RemoveBookmarkInputSchema>
|
||||
type RemoveBookmarkOutput = z.infer<typeof RemoveBookmarkOutputSchema>
|
||||
|
||||
/**
|
||||
* RemoveBookmarkAction - Remove a bookmark
|
||||
*
|
||||
* Deletes a bookmark by its ID.
|
||||
*
|
||||
* Input:
|
||||
* - id: Bookmark ID to remove
|
||||
*
|
||||
* Output:
|
||||
* - success: true if removed
|
||||
* - message: Confirmation message
|
||||
*
|
||||
* Usage:
|
||||
* Get the bookmark ID from getBookmarks first, then remove it.
|
||||
*
|
||||
* Example:
|
||||
* {
|
||||
* "id": "123"
|
||||
* }
|
||||
* // Returns: { success: true, message: "Removed bookmark 123" }
|
||||
*/
|
||||
export class RemoveBookmarkAction extends ActionHandler<
|
||||
RemoveBookmarkInput,
|
||||
RemoveBookmarkOutput
|
||||
> {
|
||||
readonly inputSchema = RemoveBookmarkInputSchema
|
||||
private bookmarkAdapter = new BookmarkAdapter()
|
||||
|
||||
async execute(input: RemoveBookmarkInput): Promise<RemoveBookmarkOutput> {
|
||||
await this.bookmarkAdapter.removeBookmark(input.id)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Removed bookmark ${input.id}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,48 +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 RemoveBookmarkTreeInputSchema = z.object({
|
||||
id: z.string().describe('Folder ID to remove'),
|
||||
confirm: z.boolean().describe('Must be true to confirm recursive deletion'),
|
||||
})
|
||||
|
||||
const RemoveBookmarkTreeOutputSchema = z.object({
|
||||
success: z.boolean().describe('Whether the folder was removed'),
|
||||
message: z.string().describe('Result message'),
|
||||
})
|
||||
|
||||
type RemoveBookmarkTreeInput = z.infer<typeof RemoveBookmarkTreeInputSchema>
|
||||
type RemoveBookmarkTreeOutput = z.infer<typeof RemoveBookmarkTreeOutputSchema>
|
||||
|
||||
export class RemoveBookmarkTreeAction extends ActionHandler<
|
||||
RemoveBookmarkTreeInput,
|
||||
RemoveBookmarkTreeOutput
|
||||
> {
|
||||
readonly inputSchema = RemoveBookmarkTreeInputSchema
|
||||
private bookmarkAdapter = new BookmarkAdapter()
|
||||
|
||||
async execute(
|
||||
input: RemoveBookmarkTreeInput,
|
||||
): Promise<RemoveBookmarkTreeOutput> {
|
||||
if (input.confirm !== true) {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
'Recursive deletion requires confirm: true. This will permanently delete the folder and all its contents.',
|
||||
}
|
||||
}
|
||||
|
||||
await this.bookmarkAdapter.removeBookmarkTree(input.id)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Removed folder ${input.id} and all its contents`,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,82 +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 UpdateBookmarkInputSchema = z.object({
|
||||
id: z.string().describe('Bookmark ID to update'),
|
||||
title: z.string().optional().describe('New bookmark title'),
|
||||
url: z.string().url().optional().describe('New bookmark URL'),
|
||||
})
|
||||
|
||||
// Output schema
|
||||
const UpdateBookmarkOutputSchema = z.object({
|
||||
id: z.string().describe('Bookmark ID'),
|
||||
title: z.string().describe('Updated bookmark title'),
|
||||
url: z.string().optional().describe('Updated bookmark URL'),
|
||||
})
|
||||
|
||||
type UpdateBookmarkInput = z.infer<typeof UpdateBookmarkInputSchema>
|
||||
type UpdateBookmarkOutput = z.infer<typeof UpdateBookmarkOutputSchema>
|
||||
|
||||
/**
|
||||
* UpdateBookmarkAction - Update a bookmark's title or URL
|
||||
*
|
||||
* Updates an existing bookmark with new title and/or URL.
|
||||
*
|
||||
* Input:
|
||||
* - id: Bookmark ID to update
|
||||
* - title (optional): New title for the bookmark
|
||||
* - url (optional): New URL for the bookmark
|
||||
*
|
||||
* Output:
|
||||
* - id: Bookmark ID
|
||||
* - title: Updated title
|
||||
* - url: Updated URL
|
||||
*
|
||||
* Usage:
|
||||
* Update a bookmark's title or URL (at least one must be provided).
|
||||
*
|
||||
* Example:
|
||||
* {
|
||||
* "id": "123",
|
||||
* "title": "New Title",
|
||||
* "url": "https://www.example.com"
|
||||
* }
|
||||
* // Returns: { id: "123", title: "New Title", url: "https://www.example.com" }
|
||||
*/
|
||||
export class UpdateBookmarkAction extends ActionHandler<
|
||||
UpdateBookmarkInput,
|
||||
UpdateBookmarkOutput
|
||||
> {
|
||||
readonly inputSchema = UpdateBookmarkInputSchema
|
||||
private bookmarkAdapter = new BookmarkAdapter()
|
||||
|
||||
async execute(input: UpdateBookmarkInput): Promise<UpdateBookmarkOutput> {
|
||||
const changes: { title?: string; url?: string } = {}
|
||||
|
||||
if (input.title !== undefined) {
|
||||
changes.title = input.title
|
||||
}
|
||||
if (input.url !== undefined) {
|
||||
changes.url = input.url
|
||||
}
|
||||
|
||||
if (Object.keys(changes).length === 0) {
|
||||
throw new Error('At least one of title or url must be provided')
|
||||
}
|
||||
|
||||
const updated = await this.bookmarkAdapter.updateBookmark(input.id, changes)
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
title: updated.title,
|
||||
url: updated.url,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
BrowserOSAdapter,
|
||||
type ScreenshotSizeKey,
|
||||
} from '@/adapters/BrowserOSAdapter'
|
||||
import { ActionHandler } from '../ActionHandler'
|
||||
|
||||
// Input schema
|
||||
const CaptureScreenshotInputSchema = z.object({
|
||||
tabId: z.number().describe('The tab ID to capture'),
|
||||
size: z
|
||||
.enum(['small', 'medium', 'large'])
|
||||
.optional()
|
||||
.default('medium')
|
||||
.describe('Screenshot size preset (default: medium)'),
|
||||
showHighlights: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(true)
|
||||
.describe('Show element highlights (default: true)'),
|
||||
width: z.number().optional().describe('Exact width in pixels'),
|
||||
height: z.number().optional().describe('Exact height in pixels'),
|
||||
})
|
||||
|
||||
// Output schema
|
||||
const CaptureScreenshotOutputSchema = z.object({
|
||||
dataUrl: z.string().describe('Base64-encoded PNG data URL'),
|
||||
})
|
||||
|
||||
type CaptureScreenshotInput = z.infer<typeof CaptureScreenshotInputSchema>
|
||||
type CaptureScreenshotOutput = z.infer<typeof CaptureScreenshotOutputSchema>
|
||||
|
||||
/**
|
||||
* CaptureScreenshotAction - Capture a screenshot of the page
|
||||
*
|
||||
* Captures a screenshot with configurable size and options.
|
||||
*
|
||||
* Size Options:
|
||||
* - small (512px): Low detail, minimal tokens
|
||||
* - medium (768px): Balanced quality/tokens (default)
|
||||
* - large (1028px): High detail, maximum tokens
|
||||
*
|
||||
* Or specify exact dimensions with width/height.
|
||||
*
|
||||
* Returns:
|
||||
* - dataUrl: PNG image as base64 data URL (data:image/png;base64,...)
|
||||
*
|
||||
* Usage:
|
||||
* 1. For AI vision models: use 'medium' or 'large'
|
||||
* 2. For debugging: use 'small'
|
||||
* 3. For exact size: specify width and height
|
||||
*
|
||||
* Used by: ScreenshotTool, VisualClick, VisualType
|
||||
*/
|
||||
export class CaptureScreenshotAction extends ActionHandler<
|
||||
CaptureScreenshotInput,
|
||||
CaptureScreenshotOutput
|
||||
> {
|
||||
readonly inputSchema = CaptureScreenshotInputSchema
|
||||
private browserOSAdapter = BrowserOSAdapter.getInstance()
|
||||
|
||||
async execute(
|
||||
input: CaptureScreenshotInput,
|
||||
): Promise<CaptureScreenshotOutput> {
|
||||
const dataUrl = await this.browserOSAdapter.captureScreenshot(
|
||||
input.tabId,
|
||||
input.size as ScreenshotSizeKey | undefined,
|
||||
input.showHighlights,
|
||||
input.width,
|
||||
input.height,
|
||||
)
|
||||
return { dataUrl }
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
BrowserOSAdapter,
|
||||
type ScreenshotSizeKey,
|
||||
} from '@/adapters/BrowserOSAdapter'
|
||||
import { logger } from '@/utils/logger'
|
||||
import { PointerOverlay } from '@/utils/PointerOverlay'
|
||||
import { SnapshotCache } from '@/utils/SnapshotCache'
|
||||
import { ActionHandler } from '../ActionHandler'
|
||||
|
||||
// Input schema
|
||||
const CaptureScreenshotPointerInputSchema = z.object({
|
||||
tabId: z.number().describe('The tab ID to capture'),
|
||||
nodeId: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.describe('The nodeId to show pointer over'),
|
||||
size: z
|
||||
.enum(['small', 'medium', 'large'])
|
||||
.optional()
|
||||
.default('medium')
|
||||
.describe('Screenshot size preset (default: medium)'),
|
||||
pointerLabel: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Optional label to show with pointer (e.g., "Click", "Type")'),
|
||||
})
|
||||
|
||||
// Output schema
|
||||
const CaptureScreenshotPointerOutputSchema = z.object({
|
||||
dataUrl: z.string().describe('Base64-encoded PNG data URL'),
|
||||
pointerPosition: z
|
||||
.object({
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
})
|
||||
.optional()
|
||||
.describe('Coordinates where pointer was shown'),
|
||||
})
|
||||
|
||||
type CaptureScreenshotPointerInput = z.infer<
|
||||
typeof CaptureScreenshotPointerInputSchema
|
||||
>
|
||||
type CaptureScreenshotPointerOutput = z.infer<
|
||||
typeof CaptureScreenshotPointerOutputSchema
|
||||
>
|
||||
|
||||
/**
|
||||
* CaptureScreenshotPointerAction - Show pointer over element and capture screenshot
|
||||
*
|
||||
* Shows a visual pointer overlay at the center of the specified element,
|
||||
* then captures a screenshot with the pointer visible.
|
||||
*
|
||||
* Prerequisites:
|
||||
* - Must call getInteractiveSnapshot first to populate the cache
|
||||
* - NodeId must exist in the cached snapshot
|
||||
*
|
||||
* Usage:
|
||||
* 1. Get snapshot to find elements and populate cache
|
||||
* 2. Call captureScreenshotPointer with tabId and nodeId
|
||||
* 3. Returns screenshot with pointer overlay visible
|
||||
*
|
||||
* Used by: Visual debugging, automation demos, step-by-step captures
|
||||
*/
|
||||
export class CaptureScreenshotPointerAction extends ActionHandler<
|
||||
CaptureScreenshotPointerInput,
|
||||
CaptureScreenshotPointerOutput
|
||||
> {
|
||||
readonly inputSchema = CaptureScreenshotPointerInputSchema
|
||||
private browserOSAdapter = BrowserOSAdapter.getInstance()
|
||||
|
||||
async execute(
|
||||
input: CaptureScreenshotPointerInput,
|
||||
): Promise<CaptureScreenshotPointerOutput> {
|
||||
const { tabId, nodeId, size, pointerLabel } = input
|
||||
|
||||
// Get element rect from cache
|
||||
const rect = SnapshotCache.getNodeRect(tabId, nodeId)
|
||||
|
||||
let pointerPosition: { x: number; y: number } | undefined
|
||||
|
||||
if (rect) {
|
||||
// Calculate center coordinates
|
||||
const { x, y } = PointerOverlay.getCenterCoordinates(rect)
|
||||
pointerPosition = { x, y }
|
||||
|
||||
// Show pointer
|
||||
await PointerOverlay.showPointer(tabId, x, y, pointerLabel)
|
||||
|
||||
logger.debug(
|
||||
`[CaptureScreenshotPointerAction] Showed pointer at (${x}, ${y}) for node ${nodeId}`,
|
||||
)
|
||||
} else {
|
||||
logger.warn(
|
||||
`[CaptureScreenshotPointerAction] No cached rect for node ${nodeId} in tab ${tabId}. Capturing without pointer.`,
|
||||
)
|
||||
}
|
||||
|
||||
// Small delay to ensure pointer is rendered
|
||||
await this.delay(100)
|
||||
|
||||
// Capture screenshot with pointer visible
|
||||
const dataUrl = await this.browserOSAdapter.captureScreenshot(
|
||||
tabId,
|
||||
size as ScreenshotSizeKey | undefined,
|
||||
false, // Don't show highlights, we have the pointer
|
||||
)
|
||||
|
||||
return {
|
||||
dataUrl,
|
||||
pointerPosition,
|
||||
}
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { BrowserOSAdapter } from '@/adapters/BrowserOSAdapter'
|
||||
import { ActionHandler } from '../ActionHandler'
|
||||
|
||||
const ClearInputSchema = z.object({
|
||||
tabId: z.number().describe('The tab ID containing the element'),
|
||||
nodeId: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.describe('The nodeId from interactive snapshot'),
|
||||
})
|
||||
|
||||
type ClearInput = z.infer<typeof ClearInputSchema>
|
||||
interface ClearOutput {
|
||||
success: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* ClearAction - Clear text from an input element
|
||||
*
|
||||
* Clears all text from an input field or textarea.
|
||||
* Used before inputText or to reset form fields.
|
||||
*/
|
||||
export class ClearAction extends ActionHandler<ClearInput, ClearOutput> {
|
||||
readonly inputSchema = ClearInputSchema
|
||||
private browserOSAdapter = BrowserOSAdapter.getInstance()
|
||||
|
||||
async execute(input: ClearInput): Promise<ClearOutput> {
|
||||
await this.browserOSAdapter.clear(input.tabId, input.nodeId)
|
||||
return { success: true }
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { BrowserOSAdapter } from '@/adapters/BrowserOSAdapter'
|
||||
import { PointerOverlay } from '@/utils/PointerOverlay'
|
||||
import { SnapshotCache } from '@/utils/SnapshotCache'
|
||||
import { ActionHandler } from '../ActionHandler'
|
||||
|
||||
// Input schema
|
||||
const ClickInputSchema = z.object({
|
||||
tabId: z.number().describe('The tab ID containing the element'),
|
||||
nodeId: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.describe('The nodeId from interactive snapshot'),
|
||||
})
|
||||
|
||||
// Output schema
|
||||
const ClickOutputSchema = z.object({
|
||||
success: z.boolean().describe('Whether the click succeeded'),
|
||||
})
|
||||
|
||||
type ClickInput = z.infer<typeof ClickInputSchema>
|
||||
type ClickOutput = z.infer<typeof ClickOutputSchema>
|
||||
|
||||
/**
|
||||
* ClickAction - Click an element by its nodeId
|
||||
*
|
||||
* This action clicks an interactive element identified by its nodeId from getInteractiveSnapshot.
|
||||
*
|
||||
* Prerequisites:
|
||||
* - Must call getInteractiveSnapshot first to get valid nodeIds
|
||||
* - NodeIds are valid only for the current page state
|
||||
* - NodeIds are invalidated on page navigation
|
||||
*
|
||||
* Usage:
|
||||
* 1. Get snapshot to find clickable elements
|
||||
* 2. Choose element by nodeId
|
||||
* 3. Call click with tabId and nodeId
|
||||
*
|
||||
* Used by: ClickTool, all automation workflows
|
||||
*/
|
||||
export class ClickAction extends ActionHandler<ClickInput, ClickOutput> {
|
||||
readonly inputSchema = ClickInputSchema
|
||||
private browserOSAdapter = BrowserOSAdapter.getInstance()
|
||||
|
||||
async execute(input: ClickInput): Promise<ClickOutput> {
|
||||
// Show pointer overlay before click
|
||||
const rect = SnapshotCache.getNodeRect(input.tabId, input.nodeId)
|
||||
if (rect) {
|
||||
const { x, y } = PointerOverlay.getCenterCoordinates(rect)
|
||||
await PointerOverlay.showPointerAndWait(input.tabId, x, y, 'Click')
|
||||
}
|
||||
|
||||
await this.browserOSAdapter.click(input.tabId, input.nodeId)
|
||||
return { success: true }
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { getBrowserOSAdapter } from '@/adapters/BrowserOSAdapter'
|
||||
import { PointerOverlay } from '@/utils/PointerOverlay'
|
||||
import { ActionHandler } from '../ActionHandler'
|
||||
|
||||
// Input schema for clickCoordinates action
|
||||
const ClickCoordinatesInputSchema = z.object({
|
||||
tabId: z.number().int().positive().describe('Tab ID to click in'),
|
||||
x: z.number().int().nonnegative().describe('X coordinate in viewport pixels'),
|
||||
y: z.number().int().nonnegative().describe('Y coordinate in viewport pixels'),
|
||||
})
|
||||
|
||||
type ClickCoordinatesInput = z.infer<typeof ClickCoordinatesInputSchema>
|
||||
|
||||
// Output confirms the click
|
||||
export interface ClickCoordinatesOutput {
|
||||
success: boolean
|
||||
message: string
|
||||
coordinates: {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ClickCoordinatesAction - Click at specific viewport coordinates
|
||||
*
|
||||
* Performs a click at the specified (x, y) coordinates in the viewport.
|
||||
* Coordinates are in pixels relative to the top-left of the visible viewport (0, 0).
|
||||
*
|
||||
* Useful when:
|
||||
* - Elements don't have accessible node IDs
|
||||
* - Working with canvas or interactive graphics
|
||||
* - Vision-based automation (e.g., AI identifies coordinates from screenshots)
|
||||
*
|
||||
* Example payload:
|
||||
* {
|
||||
* "tabId": 123,
|
||||
* "x": 500,
|
||||
* "y": 300
|
||||
* }
|
||||
*/
|
||||
export class ClickCoordinatesAction extends ActionHandler<
|
||||
ClickCoordinatesInput,
|
||||
ClickCoordinatesOutput
|
||||
> {
|
||||
readonly inputSchema = ClickCoordinatesInputSchema
|
||||
private browserOS = getBrowserOSAdapter()
|
||||
|
||||
async execute(input: ClickCoordinatesInput): Promise<ClickCoordinatesOutput> {
|
||||
const { tabId, x, y } = input
|
||||
|
||||
// Show pointer overlay before click
|
||||
await PointerOverlay.showPointerAndWait(tabId, x, y, 'Click')
|
||||
|
||||
await this.browserOS.clickCoordinates(tabId, x, y)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully clicked at coordinates (${x}, ${y}) in tab ${tabId}`,
|
||||
coordinates: { x, y },
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { CHROME_API_TIMEOUTS, withTimeout } from '@/utils/timeout'
|
||||
import { ActionHandler } from '../ActionHandler'
|
||||
|
||||
const CloseWindowInputSchema = z.object({
|
||||
windowId: z.number().int().positive().describe('ID of the window to close'),
|
||||
})
|
||||
|
||||
const CloseWindowOutputSchema = z.object({
|
||||
success: z.boolean().describe('Whether the window was successfully closed'),
|
||||
})
|
||||
|
||||
type CloseWindowInput = z.infer<typeof CloseWindowInputSchema>
|
||||
type CloseWindowOutput = z.infer<typeof CloseWindowOutputSchema>
|
||||
|
||||
export class CloseWindowAction extends ActionHandler<
|
||||
CloseWindowInput,
|
||||
CloseWindowOutput
|
||||
> {
|
||||
readonly inputSchema = CloseWindowInputSchema
|
||||
|
||||
async execute(input: CloseWindowInput): Promise<CloseWindowOutput> {
|
||||
await withTimeout(
|
||||
chrome.windows.remove(input.windowId),
|
||||
CHROME_API_TIMEOUTS.CHROME_API,
|
||||
'chrome.windows.remove',
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { CHROME_API_TIMEOUTS, withTimeout } from '@/utils/timeout'
|
||||
import { ActionHandler } from '../ActionHandler'
|
||||
|
||||
const CreateWindowInputSchema = z.object({
|
||||
url: z
|
||||
.string()
|
||||
.optional()
|
||||
.default('about:blank')
|
||||
.describe('URL to open in the new window'),
|
||||
incognito: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(false)
|
||||
.describe('Create an incognito window'),
|
||||
focused: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(true)
|
||||
.describe('Whether to focus the new window'),
|
||||
})
|
||||
|
||||
const CreateWindowOutputSchema = z.object({
|
||||
windowId: z.number().describe('ID of the newly created window'),
|
||||
tabId: z.number().describe('ID of the first tab in the new window'),
|
||||
})
|
||||
|
||||
type CreateWindowInput = z.infer<typeof CreateWindowInputSchema>
|
||||
type CreateWindowOutput = z.infer<typeof CreateWindowOutputSchema>
|
||||
|
||||
export class CreateWindowAction extends ActionHandler<
|
||||
CreateWindowInput,
|
||||
CreateWindowOutput
|
||||
> {
|
||||
readonly inputSchema = CreateWindowInputSchema
|
||||
|
||||
async execute(input: CreateWindowInput): Promise<CreateWindowOutput> {
|
||||
const createData: chrome.windows.CreateData = {
|
||||
url: input.url,
|
||||
focused: input.focused,
|
||||
incognito: input.incognito,
|
||||
}
|
||||
|
||||
const createdWindow = await withTimeout(
|
||||
chrome.windows.create(createData),
|
||||
CHROME_API_TIMEOUTS.CHROME_API,
|
||||
'chrome.windows.create',
|
||||
)
|
||||
|
||||
if (!createdWindow) {
|
||||
throw new Error('Failed to create window')
|
||||
}
|
||||
|
||||
if (createdWindow.id === undefined) {
|
||||
throw new Error('Created window has no ID')
|
||||
}
|
||||
|
||||
const tabId = createdWindow.tabs?.[0]?.id
|
||||
if (tabId === undefined) {
|
||||
throw new Error('Created window has no tab')
|
||||
}
|
||||
|
||||
return {
|
||||
windowId: createdWindow.id,
|
||||
tabId,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { BrowserOSAdapter } from '@/adapters/BrowserOSAdapter'
|
||||
import { ActionHandler } from '../ActionHandler'
|
||||
|
||||
// Input schema
|
||||
const ExecuteJavaScriptInputSchema = z.object({
|
||||
tabId: z.number().describe('The tab ID to execute code in'),
|
||||
code: z.string().describe('JavaScript code to execute'),
|
||||
})
|
||||
|
||||
// Output schema
|
||||
const ExecuteJavaScriptOutputSchema = z.object({
|
||||
result: z.any().describe('The result of the code execution'),
|
||||
})
|
||||
|
||||
type ExecuteJavaScriptInput = z.infer<typeof ExecuteJavaScriptInputSchema>
|
||||
type ExecuteJavaScriptOutput = z.infer<typeof ExecuteJavaScriptOutputSchema>
|
||||
|
||||
/**
|
||||
* ExecuteJavaScriptAction - Execute JavaScript code in page context
|
||||
*
|
||||
* Executes arbitrary JavaScript code in the page and returns the result.
|
||||
*
|
||||
* Input:
|
||||
* - tabId: Tab ID to execute code in
|
||||
* - code: JavaScript code as string
|
||||
*
|
||||
* Output:
|
||||
* - result: The return value of the executed code
|
||||
*
|
||||
* Usage:
|
||||
* - Extract data from page: "document.title"
|
||||
* - Manipulate DOM: "document.body.style.background = 'red'"
|
||||
* - Get element values: "document.querySelector('#email').value"
|
||||
*
|
||||
* Example:
|
||||
* {
|
||||
* "tabId": 123,
|
||||
* "code": "document.title"
|
||||
* }
|
||||
* // Returns: { result: "Google" }
|
||||
*/
|
||||
export class ExecuteJavaScriptAction extends ActionHandler<
|
||||
ExecuteJavaScriptInput,
|
||||
ExecuteJavaScriptOutput
|
||||
> {
|
||||
readonly inputSchema = ExecuteJavaScriptInputSchema
|
||||
private browserOSAdapter = BrowserOSAdapter.getInstance()
|
||||
|
||||
async execute(
|
||||
input: ExecuteJavaScriptInput,
|
||||
): Promise<ExecuteJavaScriptOutput> {
|
||||
const result = await this.browserOSAdapter.executeJavaScript(
|
||||
input.tabId,
|
||||
input.code,
|
||||
)
|
||||
return { result }
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { BrowserOSAdapter } from '@/adapters/BrowserOSAdapter'
|
||||
import { ActionHandler } from '../ActionHandler'
|
||||
|
||||
const GetAccessibilityTreeInputSchema = z.object({
|
||||
tabId: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.describe('Tab ID to get accessibility tree from'),
|
||||
})
|
||||
|
||||
type GetAccessibilityTreeInput = z.infer<typeof GetAccessibilityTreeInputSchema>
|
||||
export type GetAccessibilityTreeOutput = chrome.browserOS.AccessibilityTree
|
||||
|
||||
/**
|
||||
* GetAccessibilityTreeAction - Get accessibility tree for a tab
|
||||
*
|
||||
* Returns the full accessibility tree structure containing:
|
||||
* - rootId: The root node ID
|
||||
* - nodes: Map of node IDs to accessibility nodes
|
||||
*
|
||||
* Each node contains:
|
||||
* - nodeId: Unique node identifier
|
||||
* - role: Accessibility role (e.g., 'staticText', 'heading', 'button')
|
||||
* - name: Text content or label
|
||||
* - childIds: Array of child node IDs
|
||||
*
|
||||
* Example payload:
|
||||
* {
|
||||
* "tabId": 123
|
||||
* }
|
||||
*/
|
||||
export class GetAccessibilityTreeAction extends ActionHandler<
|
||||
GetAccessibilityTreeInput,
|
||||
GetAccessibilityTreeOutput
|
||||
> {
|
||||
readonly inputSchema = GetAccessibilityTreeInputSchema
|
||||
private browserOSAdapter = BrowserOSAdapter.getInstance()
|
||||
|
||||
async execute(
|
||||
input: GetAccessibilityTreeInput,
|
||||
): Promise<GetAccessibilityTreeOutput> {
|
||||
const { tabId } = input
|
||||
const tree = await this.browserOSAdapter.getAccessibilityTree(tabId)
|
||||
return tree
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import type {
|
||||
InteractiveSnapshot,
|
||||
InteractiveSnapshotOptions,
|
||||
} from '@/adapters/BrowserOSAdapter'
|
||||
import { BrowserOSAdapter } from '@/adapters/BrowserOSAdapter'
|
||||
import { SnapshotCache } from '@/utils/SnapshotCache'
|
||||
import { ActionHandler } from '../ActionHandler'
|
||||
|
||||
// Input schema
|
||||
const GetInteractiveSnapshotInputSchema = z.object({
|
||||
tabId: z.number().describe('The tab ID to get snapshot from'),
|
||||
options: z
|
||||
.object({
|
||||
includeHidden: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(false)
|
||||
.describe('Include hidden elements (default: false)'),
|
||||
})
|
||||
.optional()
|
||||
.describe('Optional snapshot options'),
|
||||
})
|
||||
|
||||
type GetInteractiveSnapshotInput = z.infer<
|
||||
typeof GetInteractiveSnapshotInputSchema
|
||||
>
|
||||
|
||||
/**
|
||||
* GetInteractiveSnapshotAction - Get interactive elements from the page
|
||||
*
|
||||
* This is THE MOST CRITICAL action - it returns all interactive elements
|
||||
* with their nodeIds, which are needed by click, inputText, clear, and scrollToNode actions.
|
||||
*
|
||||
* Returns:
|
||||
* - elements: Array of interactive nodes with nodeIds
|
||||
* - hierarchicalStructure: String representation of page structure
|
||||
*
|
||||
* Each element contains:
|
||||
* - nodeId: Sequential integer ID (1, 2, 3...)
|
||||
* - type: 'clickable' | 'typeable' | 'selectable'
|
||||
* - name: Element text/label
|
||||
* - attributes: Element properties (html-tag, role, etc.)
|
||||
* - rect: Bounding box coordinates
|
||||
*/
|
||||
export class GetInteractiveSnapshotAction extends ActionHandler<
|
||||
GetInteractiveSnapshotInput,
|
||||
InteractiveSnapshot
|
||||
> {
|
||||
readonly inputSchema = GetInteractiveSnapshotInputSchema
|
||||
private browserOSAdapter = BrowserOSAdapter.getInstance()
|
||||
|
||||
async execute(
|
||||
input: GetInteractiveSnapshotInput,
|
||||
): Promise<InteractiveSnapshot> {
|
||||
const snapshot = await this.browserOSAdapter.getInteractiveSnapshot(
|
||||
input.tabId,
|
||||
input.options as InteractiveSnapshotOptions | undefined,
|
||||
)
|
||||
|
||||
// Cache snapshot for pointer overlay lookup
|
||||
SnapshotCache.set(input.tabId, snapshot)
|
||||
|
||||
return snapshot
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user