mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
Merge pull request #1 from openswarm-ai/haik/updates-v1
Haik/updates v1
This commit is contained in:
+9
-1
@@ -3,4 +3,12 @@
|
||||
.env
|
||||
backend/data/**
|
||||
!backend/data/outputs/
|
||||
!backend/data/outputs/*.json
|
||||
!backend/data/outputs/*.json
|
||||
|
||||
# Electron build artifacts
|
||||
electron/dist/
|
||||
electron/python-env/
|
||||
electron/node_modules/
|
||||
|
||||
# Frontend build output
|
||||
frontend/dist/
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 ClusterLabs
|
||||
Copyright (c) 2026 Haik Decie
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,104 +1,242 @@
|
||||
# Open Swarm — Agent Orchestrator
|
||||
<p align="center">
|
||||
<img src="assets/icon.png" alt="Open Swarm" width="128" height="128">
|
||||
</p>
|
||||
|
||||
A locally-running React + FastAPI application for managing multiple Claude Code instances in parallel. Designed for power users who run multiple agents simultaneously and need a unified interface to monitor, control, and coordinate them.
|
||||
<h1 align="center">Open Swarm</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>An Army of AI Agents at Your Fingertips</strong>
|
||||
<br>
|
||||
A locally-running orchestrator for managing multiple Claude Code instances in parallel.
|
||||
<br>
|
||||
Launch, monitor, and coordinate entire swarms of coding agents from a single interface.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/platform-macOS-lightgrey.svg" alt="Platform"></a>
|
||||
<a href="https://github.com/openswarm-ai/openswarm/stargazers"><img src="https://img.shields.io/github/stars/openswarm-ai/openswarm?style=social" alt="GitHub Stars"></a>
|
||||
<a href="https://github.com/openswarm-ai/openswarm/pulls"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" alt="PRs Welcome"></a>
|
||||
</p>
|
||||
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/screenshot.png" alt="Open Swarm Dashboard" width="900">
|
||||
</p>
|
||||
|
||||
<br>
|
||||
|
||||
## Why Open Swarm?
|
||||
|
||||
Running Claude Code in a terminal works fine for one task. But when you're juggling five agents across different branches, approving tool calls in separate windows, and losing track of who's doing what — it falls apart fast.
|
||||
|
||||
- **Parallel agents, one screen** — Launch as many Claude Code instances as you need, arranged on a spatial canvas you can pan and zoom freely
|
||||
- **Unified approval workflow** — Every tool-use request from every agent surfaces in one place. Approve or deny with a click or a keyboard shortcut.
|
||||
- **Full conversation control** — Edit prior messages to fork conversations, navigate between branches, resume closed sessions
|
||||
- **100% local** — Everything runs on your machine. No cloud relay, no telemetry, no third-party backend.
|
||||
|
||||
<br>
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-agent management** — Launch and monitor multiple Claude Code instances side by side
|
||||
- **Git worktree isolation** — Each agent works on its own git worktree/branch to avoid conflicts
|
||||
- **Real-time streaming** — WebSocket-based streaming of agent messages and status updates
|
||||
- **HITL approvals** — Approve or deny tool usage requests from the dashboard or within each chat
|
||||
- **Message branching** — Edit prior messages to fork conversations, navigate between branches
|
||||
- **Prompt template library** — Reusable prompt templates with structured input fields, invoked via `/` commands
|
||||
- **Skills library** — Manage skills synced to the native `~/.claude/skills/` directory
|
||||
- **Tools library** — Define custom tool configurations (bash, MCP, Python)
|
||||
- **Keyboard shortcuts** — Navigate between agents and approve/deny requests without a mouse
|
||||
- **Diff viewer** — View uncommitted changes in each agent's worktree
|
||||
**Spatial Dashboard** — Infinite canvas with drag-and-drop agent cards, view cards, and embedded browser cards. Create multiple dashboards for different workspaces.
|
||||
|
||||
**Agent Chat** — Full streaming chat interface powered by WebSockets. Real-time token output, cost tracking per session, and persistent history that survives restarts.
|
||||
|
||||
**Human-in-the-Loop Approvals** — Agents request permission before executing tools. Approve or deny individually, or batch-approve from the dashboard. Configurable per-tool permissions (always allow, ask, deny).
|
||||
|
||||
**Message Branching** — Edit any prior message to fork the conversation. Navigate freely between branches without losing context.
|
||||
|
||||
**Prompt Templates** — Build reusable templates with structured input fields. Invoke them inline via `/` slash commands.
|
||||
|
||||
**Skills Library** — Manage skills that sync directly to `~/.claude/skills/`. Browse and install from the official Anthropic skills marketplace.
|
||||
|
||||
**Tools Library** — Configure MCP tool servers (stdio, HTTP, SSE) with automatic tool discovery. Browse the MCP registry and Google's catalog with GitHub star counts. Includes Google Workspace OAuth integration.
|
||||
|
||||
**Agent Modes** — Five built-in modes (Agent, Ask, Plan, View Builder, Skill Builder) plus custom user-defined modes with configurable system prompts and tool restrictions.
|
||||
|
||||
**Views & Outputs** — Create interactive HTML/JS/CSS artifacts rendered in iframes. Supports vibe coding (LLM-generates the view), backend Python execution, auto-run with LLM-generated data, and agent-driven data gathering.
|
||||
|
||||
**Git Worktree Isolation** — Each agent operates in its own git worktree and branch, preventing conflicts between parallel workstreams.
|
||||
|
||||
**Diff Viewer** — Inspect uncommitted changes in any agent's worktree without leaving the app.
|
||||
|
||||
**Cost Tracking** — Real-time USD spend tracking per agent session.
|
||||
|
||||
**Dark & Light Themes** — Full theme support with Claude-inspired design tokens.
|
||||
|
||||
**Keyboard Shortcuts** — Navigate between agents, approve/deny requests, and switch pages without touching a mouse.
|
||||
|
||||
<br>
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Desktop App
|
||||
|
||||
Download the latest release for macOS from [GitHub Releases](https://github.com/openswarm-ai/openswarm/releases).
|
||||
|
||||
> Windows and Linux builds are planned but not yet available.
|
||||
|
||||
### Development Setup
|
||||
|
||||
**Prerequisites:** Python 3.11+, Node.js 18+, Git
|
||||
|
||||
```bash
|
||||
git clone https://github.com/openswarm-ai/openswarm.git
|
||||
cd openswarm
|
||||
bash run.sh
|
||||
```
|
||||
|
||||
This starts the backend (port 8324), frontend (port 3000), and Electron shell together. Once running, set your Anthropic API key in the in-app Settings page.
|
||||
|
||||
To run services individually:
|
||||
|
||||
```bash
|
||||
bash backend/run.sh # API at http://localhost:8324 — docs at /docs
|
||||
bash frontend/run.sh # App at http://localhost:3000
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Frontend (React/TypeScript :3000) Backend (FastAPI/Python :8324)
|
||||
┌─────────────────────────────┐ ┌──────────────────────────────┐
|
||||
│ Dashboard │◄────►│ REST API (/api/*) │
|
||||
│ Agent Chat (per session) │ │ WebSocket (/ws/*) │
|
||||
│ Templates / Skills / Tools │ │ Agent Manager │
|
||||
│ Slash Command Picker │ │ └─ Claude Agent SDK │
|
||||
│ Keyboard Shortcuts │ │ Worktree Manager │
|
||||
│ Diff Viewer │ │ Library Storage (JSON/files)│
|
||||
└─────────────────────────────┘ └──────────────────────────────┘
|
||||
Electron Shell (desktop wrapper, auto-updater)
|
||||
├─────────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Frontend (React/TypeScript :3000) Backend (FastAPI :8324) │
|
||||
│ ┌───────────────────────────────┐ ┌───────────────────────┐ │
|
||||
│ │ Spatial Dashboard Canvas │◄────►│ REST API (/api/*) │ │
|
||||
│ │ Agent Chat (streaming) │ │ WebSocket (/ws/*) │ │
|
||||
│ │ Templates / Skills / Tools │ WS │ Agent Manager │ │
|
||||
│ │ Modes / Views / Commands │◄────►│ └─ claude-agent-sdk│ │
|
||||
│ │ Settings │ │ MCP Tool Discovery │ │
|
||||
│ │ Redux Toolkit (state) │ │ JSON File Storage │ │
|
||||
│ └───────────────────────────────┘ └───────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
<br>
|
||||
|
||||
### Prerequisites
|
||||
## Configuration
|
||||
|
||||
- Python 3.11+
|
||||
- Node.js 18+
|
||||
- Git
|
||||
- An Anthropic API key (for real agent usage) — `export ANTHROPIC_API_KEY=...`
|
||||
The Anthropic API key is configured in-app via the **Settings** page — no environment variable needed for normal usage.
|
||||
|
||||
### Backend
|
||||
For advanced configuration, copy `backend/.env.example` to `backend/.env`:
|
||||
|
||||
```bash
|
||||
bash backend/run/dev.sh
|
||||
# API runs at http://localhost:8324
|
||||
# Docs at http://localhost:8324/docs
|
||||
```
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `BACKEND_PORT` | Backend server port (default: `8324`) |
|
||||
| `GOOGLE_OAUTH_CLIENT_ID` | Google Workspace integration (Gmail, Calendar, Drive) |
|
||||
| `GOOGLE_OAUTH_CLIENT_SECRET` | Google Workspace integration |
|
||||
| `APPLE_ID` | macOS code signing & notarization (release builds only) |
|
||||
| `APPLE_APP_SPECIFIC_PASSWORD` | macOS notarization (release builds only) |
|
||||
| `APPLE_TEAM_ID` | macOS code signing (release builds only) |
|
||||
| `GH_TOKEN` | GitHub Releases publishing (release builds only) |
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
bash frontend/run/dev.sh
|
||||
# App runs at http://localhost:3000
|
||||
```
|
||||
|
||||
### Mock Mode
|
||||
|
||||
If `claude-agent-sdk` is not installed, the backend runs in **mock mode** — agents simulate tool calls and responses so you can develop and test the UI without an API key.
|
||||
<br>
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `d` | Go to Dashboard |
|
||||
| `t` | Go to Templates |
|
||||
| `1`–`9` | Open agent by position |
|
||||
| `D` | Go to Dashboard |
|
||||
| `T` | Go to Templates |
|
||||
| `1` – `9` | Open agent by position |
|
||||
| `Shift+A` | Approve all pending requests |
|
||||
| `Shift+D` | Deny all pending requests |
|
||||
| `?` | Show shortcuts help |
|
||||
|
||||
## Slash Commands
|
||||
Type `/` in the chat input to invoke prompt templates and skills as slash commands.
|
||||
|
||||
In the chat input, type `/` to invoke templates and skills:
|
||||
- `/template-name` — Opens the template's input modal
|
||||
- `/skill-name` — Inserts the skill content into the message
|
||||
<br>
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
backend/
|
||||
apps/
|
||||
agents/ — Agent lifecycle, WebSocket, worktree management
|
||||
templates/ — Prompt template CRUD (JSON file storage)
|
||||
skills/ — Skills CRUD (synced to ~/.claude/skills/)
|
||||
tools_lib/ — Tool definitions CRUD (JSON file storage)
|
||||
health/ — Health check endpoint
|
||||
config/ — FastAPI app configuration
|
||||
data/ — Persistent JSON file storage (sessions, dashboards, settings, templates, tools, etc.)
|
||||
agents/ Agent lifecycle, streaming, worktree management
|
||||
dashboards/ Dashboard CRUD and layout persistence
|
||||
dashboard_layout/ Card positions and spatial canvas state
|
||||
templates/ Prompt template CRUD
|
||||
skills/ Skills CRUD (synced to ~/.claude/skills/)
|
||||
tools_lib/ MCP tool configuration and discovery
|
||||
modes/ Agent mode definitions
|
||||
outputs/ Views/outputs, vibe coding, Python executor
|
||||
settings/ App settings and file browser
|
||||
health/ Health check endpoint
|
||||
mcp_registry/ MCP server registry proxy
|
||||
skill_registry/ Anthropic skills marketplace proxy
|
||||
config/ FastAPI app configuration
|
||||
data/ Persistent JSON file storage
|
||||
|
||||
frontend/
|
||||
src/
|
||||
app/
|
||||
components/ — AppShell, NewAgentModal, SlashCommandPicker, KeyboardShortcutsHelp
|
||||
components/ AppShell, Layout, shared UI
|
||||
pages/
|
||||
Dashboard/ — Agent overview grid with live status
|
||||
AgentChat/ — Full chat UI with streaming, HITL, branching, diff viewer
|
||||
Templates/ — Template library with editor
|
||||
Skills/ — Skills library with editor
|
||||
Tools/ — Tools library with editor
|
||||
Dashboard/ Spatial canvas with agent/view/browser cards
|
||||
AgentChat/ Streaming chat, HITL approvals, branching, diff viewer
|
||||
Templates/ Template library with structured input fields
|
||||
Skills/ Skills library, skill builder, registry browser
|
||||
Tools/ Tool config, MCP discovery, OAuth, registry browser
|
||||
Modes/ Mode definitions with system prompts
|
||||
Views/ Output artifacts, code editor, vibe coding
|
||||
Commands/ Keyboard shortcuts reference
|
||||
Settings/ App configuration
|
||||
shared/
|
||||
state/ — Redux slices (agents, templates, skills, tools)
|
||||
ws/ — WebSocket manager
|
||||
hooks/ — Custom hooks (keyboard shortcuts)
|
||||
state/ Redux slices (agents, dashboards, templates, skills, tools, modes, etc.)
|
||||
ws/ WebSocket manager
|
||||
hooks/ Custom hooks
|
||||
styles/ Theme tokens, global styles
|
||||
|
||||
electron/
|
||||
main.js Electron main process, auto-updater, Python env management
|
||||
scripts/ Build and notarization scripts
|
||||
|
||||
scripts/
|
||||
build-app.sh Desktop app packaging (electron-builder)
|
||||
build-python-env.sh Standalone Python 3.13 environment bundler
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
## Tech Stack
|
||||
|
||||
**Frontend** — React 18, TypeScript, Redux Toolkit, Material UI v7, CodeMirror 6, Framer Motion, React Router v7, Webpack 5
|
||||
|
||||
**Backend** — FastAPI, Python 3.11+, Pydantic v2, claude-agent-sdk, Anthropic SDK, WebSockets, httpx
|
||||
|
||||
**Desktop** — Electron 33, electron-builder, electron-updater (auto-updates via GitHub Releases)
|
||||
|
||||
**Bundled Runtime** — Standalone Python 3.13 (via python-build-standalone) so end users don't need Python installed
|
||||
|
||||
<br>
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome. To get started:
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/your-feature`)
|
||||
3. Make your changes
|
||||
4. Submit a pull request
|
||||
|
||||
Please open an issue first for larger changes so we can discuss the approach.
|
||||
|
||||
<br>
|
||||
|
||||
## Community
|
||||
|
||||
- [Twitter / X](https://twitter.com/openswarm_ai)
|
||||
- [Discord](https://discord.gg/openswarm)
|
||||
- [Website](https://openswarm.ai)
|
||||
|
||||
<br>
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE) for details.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 320 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
+12
-1
@@ -3,6 +3,17 @@
|
||||
# =============================================================================
|
||||
BACKEND_PORT=8324
|
||||
|
||||
|
||||
GOOGLE_OAUTH_CLIENT_ID=your-google-oauth-client-id.apps.googleusercontent.com
|
||||
GOOGLE_OAUTH_CLIENT_SECRET=your-google-oauth-client-secret
|
||||
|
||||
# =============================================================================
|
||||
# macOS Code Signing & Notarization (required for ./scripts/build-app.sh --publish)
|
||||
# =============================================================================
|
||||
APPLE_ID=your-apple-id@example.com
|
||||
APPLE_APP_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx
|
||||
APPLE_TEAM_ID=ABCDE12345
|
||||
|
||||
# =============================================================================
|
||||
# GitHub Releases (required for --publish)
|
||||
# =============================================================================
|
||||
GH_TOKEN=ghp_your-github-personal-access-token
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
# Environment Variables Setup Guide
|
||||
|
||||
Copy `.env.example` to `.env` and fill in the values below. This guide walks you through getting every single one.
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `BACKEND_PORT`
|
||||
|
||||
The port the backend server runs on. The default is fine — only change it if something else is already using port 8324.
|
||||
|
||||
```
|
||||
BACKEND_PORT=8324
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `GOOGLE_OAUTH_CLIENT_ID` & `GOOGLE_OAUTH_CLIENT_SECRET`
|
||||
|
||||
These let users sign in with their Google account.
|
||||
|
||||
### Step 1 — Go to Google Cloud Console
|
||||
|
||||
1. Open https://console.cloud.google.com/
|
||||
2. Sign in with your Google account (or create one).
|
||||
|
||||
### Step 2 — Create a project
|
||||
|
||||
1. Click the project dropdown at the very top of the page (it says "Select a project" or shows your current project name).
|
||||
2. Click **New Project** in the top-right of the popup.
|
||||
3. Name it something like `OpenSwarm`.
|
||||
4. Click **Create**.
|
||||
5. Wait a few seconds, then click the project dropdown again and select your new `OpenSwarm` project.
|
||||
|
||||
### Step 3 — Enable the Google+ API (required for OAuth)
|
||||
|
||||
1. In the left sidebar, click **APIs & Services** > **Library**.
|
||||
2. Search for `Google+ API` (or `Google Identity`).
|
||||
3. Click on it, then click **Enable**.
|
||||
|
||||
### Step 4 — Configure the OAuth consent screen
|
||||
|
||||
1. In the left sidebar, click **APIs & Services** > **OAuth consent screen**.
|
||||
2. Select **External** (unless you're inside a Google Workspace org and only want internal users).
|
||||
3. Click **Create**.
|
||||
4. Fill in the required fields:
|
||||
- **App name**: `OpenSwarm`
|
||||
- **User support email**: your email
|
||||
- **Developer contact email**: your email
|
||||
5. Click **Save and Continue**.
|
||||
6. On the **Scopes** page, click **Add or Remove Scopes**.
|
||||
- Check `email` and `profile` (the `openid` scope is added automatically).
|
||||
- Click **Update**, then **Save and Continue**.
|
||||
7. On the **Test users** page, click **Add Users**, enter your own email, click **Add**, then **Save and Continue**.
|
||||
8. Click **Back to Dashboard**.
|
||||
|
||||
### Step 5 — Create OAuth credentials
|
||||
|
||||
1. In the left sidebar, click **APIs & Services** > **Credentials**.
|
||||
2. Click **+ Create Credentials** at the top.
|
||||
3. Select **OAuth client ID**.
|
||||
4. For **Application type**, select **Web application**.
|
||||
5. **Name**: `OpenSwarm` (or anything you want).
|
||||
6. Under **Authorized redirect URIs**, click **+ Add URI** and add:
|
||||
```
|
||||
http://localhost:8324/api/auth/google/callback
|
||||
```
|
||||
(Replace `8324` with your `BACKEND_PORT` if you changed it.)
|
||||
7. Click **Create**.
|
||||
|
||||
### Step 6 — Copy the values
|
||||
|
||||
A popup appears with your credentials:
|
||||
|
||||
- **Client ID** — copy this into `GOOGLE_OAUTH_CLIENT_ID`
|
||||
- **Client Secret** — copy this into `GOOGLE_OAUTH_CLIENT_SECRET`
|
||||
|
||||
```
|
||||
GOOGLE_OAUTH_CLIENT_ID=123456789-xxxxxxxxx.apps.googleusercontent.com
|
||||
GOOGLE_OAUTH_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxxxxx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `APPLE_ID`
|
||||
|
||||
This is the email address you use to sign in to your Apple Developer account.
|
||||
|
||||
1. If you don't have one, go to https://developer.apple.com/programs/ and click **Enroll**. It costs $99/year.
|
||||
2. Once enrolled, your `APPLE_ID` is just the email you signed up with.
|
||||
|
||||
```
|
||||
APPLE_ID=you@example.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `APPLE_TEAM_ID`
|
||||
|
||||
Your 10-character Apple Developer team identifier.
|
||||
|
||||
1. Go to https://developer.apple.com/account
|
||||
2. Sign in.
|
||||
3. Look at the top-right — your name is shown. Click it, or scroll down.
|
||||
4. Under **Membership Details** (or at https://developer.apple.com/account#MembershipDetailsCard), you'll see **Team ID**.
|
||||
5. It looks like `ABCDE12345`. Copy it.
|
||||
|
||||
```
|
||||
APPLE_TEAM_ID=ABCDE12345
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `APPLE_APP_SPECIFIC_PASSWORD`
|
||||
|
||||
Apple doesn't let you use your regular password for automated tools. You need to generate a special one-time password.
|
||||
|
||||
### Step 1 — Turn on two-factor authentication (if you haven't already)
|
||||
|
||||
1. On your Mac, go to **System Settings** > **[your name]** > **Sign-In & Security** > **Two-Factor Authentication**.
|
||||
2. Turn it on and follow the prompts.
|
||||
|
||||
### Step 2 — Generate the app-specific password
|
||||
|
||||
1. Go to https://account.apple.com/
|
||||
2. Sign in with your Apple ID.
|
||||
3. In the **Sign-In and Security** section, click **App-Specific Passwords**.
|
||||
4. Click **Generate an app-specific password** (or the **+** button).
|
||||
5. Enter a label like `OpenSwarm Notarization`.
|
||||
6. Click **Create**.
|
||||
7. Apple shows you a password in the format `xxxx-xxxx-xxxx-xxxx`. **Copy it now** — you can't see it again.
|
||||
|
||||
```
|
||||
APPLE_APP_SPECIFIC_PASSWORD=abcd-efgh-ijkl-mnop
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## macOS Signing Certificate (no env var, but required)
|
||||
|
||||
Before you can sign and notarize, you need a **Developer ID Application** certificate installed in your macOS Keychain. This is what Apple uses to verify that the app was built by you.
|
||||
|
||||
### Step 1 — Open Xcode
|
||||
|
||||
1. Open **Xcode** on your Mac (install it from the Mac App Store if you don't have it).
|
||||
2. Go to **Xcode** menu > **Settings** (or **Preferences** on older versions).
|
||||
3. Click the **Accounts** tab.
|
||||
4. Click **+** in the bottom-left and sign in with your Apple ID.
|
||||
|
||||
### Step 2 — Create the certificate
|
||||
|
||||
1. Select your account in the list, then click **Manage Certificates...** in the bottom-right.
|
||||
2. Click the **+** in the bottom-left of the popup.
|
||||
3. Select **Developer ID Application**.
|
||||
4. Click **Create**.
|
||||
|
||||
That's it — the certificate is now in your macOS Keychain. `electron-builder` will auto-discover it during builds. You don't need to set any env var for this.
|
||||
|
||||
### Alternative — manual method (without Xcode)
|
||||
|
||||
1. Go to https://developer.apple.com/account/resources/certificates/list
|
||||
2. Click the **+** button.
|
||||
3. Select **Developer ID Application**, click **Continue**.
|
||||
4. You'll be asked to upload a **Certificate Signing Request (CSR)**:
|
||||
- Open **Keychain Access** on your Mac.
|
||||
- In the menu bar: **Keychain Access** > **Certificate Assistant** > **Request a Certificate From a Certificate Authority**.
|
||||
- Enter your email, leave CA Email blank, select **Saved to disk**, click **Continue**.
|
||||
- Save the `.certSigningRequest` file.
|
||||
5. Upload that file on the Apple Developer page, click **Continue**.
|
||||
6. Download the `.cer` file.
|
||||
7. Double-click it — it installs into your Keychain.
|
||||
|
||||
---
|
||||
|
||||
## `GH_TOKEN`
|
||||
|
||||
A GitHub Personal Access Token that lets the build script upload release artifacts to GitHub Releases.
|
||||
|
||||
### Step 1 — Go to GitHub token settings
|
||||
|
||||
1. Go to https://github.com/settings/tokens
|
||||
2. Sign in if needed.
|
||||
|
||||
### Step 2 — Create a token
|
||||
|
||||
1. Click **Generate new token** > **Generate new token (classic)**.
|
||||
2. **Note**: `OpenSwarm Releases` (or whatever you want).
|
||||
3. **Expiration**: pick a duration (90 days, or "No expiration" if you don't want to rotate it).
|
||||
4. **Scopes**: check the **`repo`** checkbox (this gives full access to your repositories, which is needed to create releases and upload assets).
|
||||
5. Click **Generate token** at the bottom.
|
||||
6. **Copy the token now** — it starts with `ghp_` and you won't be able to see it again.
|
||||
|
||||
```
|
||||
GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final `.env` example
|
||||
|
||||
```
|
||||
BACKEND_PORT=8324
|
||||
|
||||
GOOGLE_OAUTH_CLIENT_ID=123456789-xxxxxxxxx.apps.googleusercontent.com
|
||||
GOOGLE_OAUTH_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxxxxx
|
||||
|
||||
APPLE_ID=you@example.com
|
||||
APPLE_APP_SPECIFIC_PASSWORD=abcd-efgh-ijkl-mnop
|
||||
APPLE_TEAM_ID=ABCDE12345
|
||||
|
||||
GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running a production build
|
||||
|
||||
Once your `.env` is filled in, just run:
|
||||
|
||||
```bash
|
||||
./scripts/build-app.sh --publish
|
||||
```
|
||||
|
||||
The build script automatically loads `backend/.env`, so you don't need to source it yourself. This will build the app, sign it with your certificate, notarize it with Apple, and upload the `.dmg` and `.zip` to a GitHub Release.
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
@@ -10,7 +11,6 @@ from typing import Optional
|
||||
from backend.apps.agents.models import (
|
||||
AgentConfig, AgentSession, Message, MessageBranch, ApprovalRequest, ToolGroupMeta,
|
||||
)
|
||||
from backend.apps.agents.worktree_manager import WorktreeManager
|
||||
from backend.apps.agents.ws_manager import ws_manager
|
||||
from backend.apps.modes.modes import load_mode
|
||||
from backend.apps.outputs.outputs import _load_all as load_all_outputs
|
||||
@@ -22,16 +22,12 @@ from backend.apps.tools_lib.tools_lib import (
|
||||
load_builtin_permissions,
|
||||
refresh_google_token,
|
||||
)
|
||||
from backend.config.paths import SESSIONS_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
os.environ.setdefault("CLAUDE_CODE_STREAM_CLOSE_TIMEOUT", "3600000")
|
||||
|
||||
SESSIONS_DIR = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
"data", "sessions",
|
||||
)
|
||||
|
||||
|
||||
def _save_session(session_id: str, doc_data: dict):
|
||||
os.makedirs(SESSIONS_DIR, exist_ok=True)
|
||||
@@ -72,9 +68,6 @@ FULL_TOOLS = [
|
||||
"RenderOutput",
|
||||
]
|
||||
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
|
||||
|
||||
def _get_denied_tool_names(tool) -> set[str]:
|
||||
"""Return the set of MCP sub-tool names whose permission is 'deny'."""
|
||||
return {
|
||||
@@ -122,7 +115,6 @@ class AgentManager:
|
||||
def __init__(self):
|
||||
self.sessions: dict[str, AgentSession] = {}
|
||||
self.tasks: dict[str, asyncio.Task] = {}
|
||||
self.worktree_mgr = WorktreeManager(REPO_ROOT)
|
||||
|
||||
def _resolve_mode(self, mode_id: str) -> tuple[list[str], str | None, str | None]:
|
||||
"""Return (tools, system_prompt, default_folder) resolved from the mode store."""
|
||||
@@ -246,10 +238,7 @@ class AgentManager:
|
||||
|
||||
async def launch_agent(self, config: AgentConfig) -> AgentSession:
|
||||
session_id = uuid4().hex
|
||||
branch_name = f"agent-{session_id[:8]}"
|
||||
|
||||
worktree_path = await self.worktree_mgr.create_worktree(branch_name)
|
||||
|
||||
|
||||
mode_tools, _, mode_folder = self._resolve_mode(config.mode)
|
||||
tools = mode_tools
|
||||
|
||||
@@ -258,7 +247,7 @@ class AgentManager:
|
||||
config.target_directory
|
||||
or mode_folder
|
||||
or global_settings.default_folder
|
||||
or str(REPO_ROOT)
|
||||
or os.path.expanduser("~")
|
||||
)
|
||||
|
||||
if config.mode in ("view-builder", "skill-builder") and not config.target_directory:
|
||||
@@ -271,8 +260,6 @@ class AgentManager:
|
||||
name=config.name,
|
||||
model=config.model,
|
||||
mode=config.mode,
|
||||
worktree_path=worktree_path,
|
||||
branch_name=branch_name,
|
||||
system_prompt=config.system_prompt,
|
||||
allowed_tools=tools,
|
||||
max_turns=config.max_turns,
|
||||
@@ -561,6 +548,17 @@ class AgentManager:
|
||||
|
||||
mcp_servers = await self._build_mcp_servers(session.allowed_tools)
|
||||
|
||||
browser_server_path = os.path.join(
|
||||
os.path.dirname(__file__), "browser_mcp_server.py"
|
||||
)
|
||||
backend_port = os.environ.get("OPENSWARM_PORT", "8324")
|
||||
mcp_servers["openswarm-browser"] = {
|
||||
"command": sys.executable,
|
||||
"args": [browser_server_path],
|
||||
"env": {"OPENSWARM_PORT": backend_port},
|
||||
"type": "stdio",
|
||||
}
|
||||
|
||||
effective_allowed = [
|
||||
t for t in session.allowed_tools
|
||||
if _builtin_perms.get(t, "always_allow") == "always_allow"
|
||||
@@ -583,6 +581,8 @@ class AgentManager:
|
||||
else:
|
||||
effective_allowed.append(f"mcp__{name}__*")
|
||||
|
||||
effective_allowed.append("mcp__openswarm-browser__*")
|
||||
|
||||
options_kwargs = {
|
||||
"model": session.model,
|
||||
"can_use_tool": can_use_tool,
|
||||
@@ -593,8 +593,9 @@ class AgentManager:
|
||||
"allowed_tools": effective_allowed,
|
||||
"include_partial_messages": True,
|
||||
}
|
||||
if global_settings.anthropic_api_key:
|
||||
options_kwargs["env"] = {"ANTHROPIC_API_KEY": global_settings.anthropic_api_key}
|
||||
if not global_settings.anthropic_api_key:
|
||||
raise ValueError("Anthropic API key not configured. Set it in Settings.")
|
||||
options_kwargs["env"] = {"ANTHROPIC_API_KEY": global_settings.anthropic_api_key}
|
||||
if mcp_servers:
|
||||
options_kwargs["mcp_servers"] = mcp_servers
|
||||
if composed_prompt:
|
||||
@@ -867,7 +868,7 @@ class AgentManager:
|
||||
f"I've processed your request: \"{prompt}\"\n\n"
|
||||
"This is a mock response because `claude-agent-sdk` is not installed. "
|
||||
"Install it with `pip install claude-agent-sdk` to use real Claude Code instances.\n\n"
|
||||
f"The agent was configured with:\n- Model: {session.model}\n- Mode: {session.mode}\n- Worktree: {session.branch_name}"
|
||||
f"The agent was configured with:\n- Model: {session.model}\n- Mode: {session.mode}"
|
||||
)
|
||||
asst_msg_id = uuid4().hex
|
||||
await self._stream_text(session_id, asst_msg_id, asst_text)
|
||||
@@ -946,7 +947,7 @@ class AgentManager:
|
||||
task = asyncio.create_task(self._run_agent_loop(session_id, prompt, images=images, context_paths=context_paths, forced_tools=forced_tools, attached_skills=attached_skills))
|
||||
self.tasks[session_id] = task
|
||||
|
||||
async def stop_agent(self, session_id: str, remove_worktree: bool = False):
|
||||
async def stop_agent(self, session_id: str):
|
||||
"""Stop a running agent."""
|
||||
task = self.tasks.get(session_id)
|
||||
if task and not task.done():
|
||||
@@ -964,9 +965,6 @@ class AgentManager:
|
||||
"status": "stopped",
|
||||
"session": session.model_dump(mode="json"),
|
||||
})
|
||||
|
||||
if remove_worktree and session and session.branch_name:
|
||||
await self.worktree_mgr.remove_worktree(session.branch_name)
|
||||
|
||||
def handle_approval(self, request_id: str, decision: dict):
|
||||
"""Resolve a pending HITL approval."""
|
||||
@@ -1039,10 +1037,9 @@ class AgentManager:
|
||||
try:
|
||||
import anthropic
|
||||
global_settings = load_settings()
|
||||
client_kwargs = {}
|
||||
if global_settings.anthropic_api_key:
|
||||
client_kwargs["api_key"] = global_settings.anthropic_api_key
|
||||
client = anthropic.AsyncAnthropic(**client_kwargs)
|
||||
if not global_settings.anthropic_api_key:
|
||||
raise ValueError("API key not configured")
|
||||
client = anthropic.AsyncAnthropic(api_key=global_settings.anthropic_api_key)
|
||||
resp = await client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=30,
|
||||
@@ -1084,10 +1081,9 @@ class AgentManager:
|
||||
try:
|
||||
import anthropic, json as _json
|
||||
global_settings = load_settings()
|
||||
client_kwargs = {}
|
||||
if global_settings.anthropic_api_key:
|
||||
client_kwargs["api_key"] = global_settings.anthropic_api_key
|
||||
client = anthropic.AsyncAnthropic(**client_kwargs)
|
||||
if not global_settings.anthropic_api_key:
|
||||
raise ValueError("API key not configured")
|
||||
client = anthropic.AsyncAnthropic(api_key=global_settings.anthropic_api_key)
|
||||
|
||||
tool_desc = "\n".join(
|
||||
f"- {tc.get('tool', '?')}: {tc.get('input_summary', '')}" for tc in tool_calls
|
||||
@@ -1171,7 +1167,7 @@ class AgentManager:
|
||||
|
||||
async def close_session(self, session_id: str) -> None:
|
||||
"""Close a session: pause the agent if running, persist to JSON file,
|
||||
and remove from in-memory state. Worktree is kept on disk for resume."""
|
||||
and remove from in-memory state."""
|
||||
task = self.tasks.get(session_id)
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
@@ -1211,7 +1207,7 @@ class AgentManager:
|
||||
logger.info(f"Session {session_id} closed and persisted")
|
||||
|
||||
async def delete_session(self, session_id: str) -> None:
|
||||
"""Permanently delete a session: remove from memory, JSON file, worktree, and branch."""
|
||||
"""Permanently delete a session: remove from memory and JSON file."""
|
||||
task = self.tasks.get(session_id)
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
@@ -1220,21 +1216,9 @@ class AgentManager:
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
session = self.sessions.pop(session_id, None)
|
||||
self.sessions.pop(session_id, None)
|
||||
self.tasks.pop(session_id, None)
|
||||
|
||||
branch_name: str | None = None
|
||||
if session:
|
||||
branch_name = session.branch_name
|
||||
else:
|
||||
data = _load_session_data(session_id)
|
||||
if data:
|
||||
branch_name = data.get("branch_name")
|
||||
|
||||
if branch_name:
|
||||
await self.worktree_mgr.remove_worktree(branch_name)
|
||||
await self.worktree_mgr.delete_branch(branch_name)
|
||||
|
||||
_delete_session_file(session_id)
|
||||
logger.info(f"Session {session_id} permanently deleted")
|
||||
|
||||
@@ -1249,17 +1233,6 @@ class AgentManager:
|
||||
|
||||
session = AgentSession(**data)
|
||||
|
||||
if session.branch_name:
|
||||
worktree_path = os.path.join(self.worktree_mgr.worktrees_dir, session.branch_name)
|
||||
if not os.path.exists(worktree_path):
|
||||
try:
|
||||
worktree_path = await self.worktree_mgr.create_worktree(session.branch_name)
|
||||
except RuntimeError:
|
||||
new_branch = f"agent-{session_id[:8]}"
|
||||
worktree_path = await self.worktree_mgr.create_worktree(new_branch)
|
||||
session.branch_name = new_branch
|
||||
session.worktree_path = worktree_path
|
||||
|
||||
session.closed_at = None
|
||||
self.sessions[session_id] = session
|
||||
|
||||
@@ -1349,21 +1322,6 @@ class AgentManager:
|
||||
session.status = "stopped"
|
||||
session.closed_at = None
|
||||
session.pending_approvals = []
|
||||
if session.branch_name:
|
||||
worktree_path = os.path.join(
|
||||
self.worktree_mgr.worktrees_dir, session.branch_name
|
||||
)
|
||||
if not os.path.exists(worktree_path):
|
||||
try:
|
||||
worktree_path = await self.worktree_mgr.create_worktree(
|
||||
session.branch_name
|
||||
)
|
||||
except RuntimeError:
|
||||
logger.warning(
|
||||
f"Could not restore worktree for session {session.id}, skipping"
|
||||
)
|
||||
continue
|
||||
session.worktree_path = worktree_path
|
||||
self.sessions[session.id] = session
|
||||
_delete_session_file(sid)
|
||||
logger.info(f"Restored session {session.id}")
|
||||
|
||||
@@ -13,7 +13,6 @@ logger = logging.getLogger(__name__)
|
||||
@asynccontextmanager
|
||||
async def agents_lifespan():
|
||||
logger.info("Agents sub-app starting")
|
||||
await agent_manager.worktree_mgr.cleanup_all_worktrees()
|
||||
await agent_manager.reconcile_on_startup()
|
||||
await agent_manager.restore_all_sessions()
|
||||
yield
|
||||
@@ -61,9 +60,8 @@ async def send_message(session_id: str, body: dict):
|
||||
return {"ok": True}
|
||||
|
||||
@agents.router.post("/sessions/{session_id}/stop")
|
||||
async def stop_agent(session_id: str, body: dict = {}):
|
||||
remove_worktree = body.get("remove_worktree", False)
|
||||
await agent_manager.stop_agent(session_id, remove_worktree=remove_worktree)
|
||||
async def stop_agent(session_id: str):
|
||||
await agent_manager.stop_agent(session_id)
|
||||
return {"ok": True}
|
||||
|
||||
@agents.router.post("/approval")
|
||||
@@ -161,17 +159,3 @@ async def resume_session(session_id: str):
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
return {"session": session.model_dump(mode="json")}
|
||||
|
||||
@agents.router.get("/worktrees")
|
||||
async def list_worktrees():
|
||||
worktrees = await agent_manager.worktree_mgr.list_worktrees()
|
||||
return {"worktrees": worktrees}
|
||||
|
||||
@agents.router.get("/sessions/{session_id}/diff")
|
||||
async def get_session_diff(session_id: str):
|
||||
session = agent_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
if not session.branch_name:
|
||||
return {"diff": ""}
|
||||
diff = await agent_manager.worktree_mgr.get_worktree_diff(session.branch_name)
|
||||
return {"diff": diff}
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Minimal stdio MCP server that exposes browser interaction tools.
|
||||
|
||||
Launched as a subprocess by the Claude Agent SDK. Proxies tool calls
|
||||
to the OpenSwarm backend via HTTP, which bridges them to the Electron
|
||||
frontend via WebSocket where the actual webview lives.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from io import BytesIO
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
HAS_PIL = True
|
||||
except ImportError:
|
||||
HAS_PIL = False
|
||||
|
||||
BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324")
|
||||
BACKEND_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/browser/command"
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "BrowserScreenshot",
|
||||
"description": (
|
||||
"Capture a screenshot of the browser page. Returns the screenshot as a "
|
||||
"base64-encoded PNG image. Use this to see what is currently displayed."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"browser_id": {
|
||||
"type": "string",
|
||||
"description": "The browser card ID to capture. Use the ID from the selected browser card context.",
|
||||
},
|
||||
},
|
||||
"required": ["browser_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "BrowserGetText",
|
||||
"description": (
|
||||
"Get the visible text content of the browser page. Returns the page's "
|
||||
"innerText (up to 15000 characters)."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"browser_id": {
|
||||
"type": "string",
|
||||
"description": "The browser card ID.",
|
||||
},
|
||||
},
|
||||
"required": ["browser_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "BrowserNavigate",
|
||||
"description": "Navigate the browser to a URL.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"browser_id": {
|
||||
"type": "string",
|
||||
"description": "The browser card ID.",
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "The URL to navigate to.",
|
||||
},
|
||||
},
|
||||
"required": ["browser_id", "url"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "BrowserClick",
|
||||
"description": (
|
||||
"Click an element in the browser page identified by a CSS selector."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"browser_id": {
|
||||
"type": "string",
|
||||
"description": "The browser card ID.",
|
||||
},
|
||||
"selector": {
|
||||
"type": "string",
|
||||
"description": "CSS selector of the element to click.",
|
||||
},
|
||||
},
|
||||
"required": ["browser_id", "selector"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "BrowserType",
|
||||
"description": (
|
||||
"Type text into an input element in the browser page. Clears the "
|
||||
"existing value first, then types the new text."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"browser_id": {
|
||||
"type": "string",
|
||||
"description": "The browser card ID.",
|
||||
},
|
||||
"selector": {
|
||||
"type": "string",
|
||||
"description": "CSS selector of the input element.",
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "The text to type.",
|
||||
},
|
||||
},
|
||||
"required": ["browser_id", "selector", "text"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "BrowserEvaluate",
|
||||
"description": (
|
||||
"Evaluate a JavaScript expression in the browser page and return the result. "
|
||||
"The expression is run via executeJavaScript on the webview."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"browser_id": {
|
||||
"type": "string",
|
||||
"description": "The browser card ID.",
|
||||
},
|
||||
"expression": {
|
||||
"type": "string",
|
||||
"description": "JavaScript expression to evaluate.",
|
||||
},
|
||||
},
|
||||
"required": ["browser_id", "expression"],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def send_response(id_, result=None, error=None):
|
||||
msg = {"jsonrpc": "2.0", "id": id_}
|
||||
if error is not None:
|
||||
msg["error"] = error
|
||||
else:
|
||||
msg["result"] = result
|
||||
sys.stdout.write(json.dumps(msg) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def send_notification(method, params=None):
|
||||
msg = {"jsonrpc": "2.0", "method": method}
|
||||
if params is not None:
|
||||
msg["params"] = params
|
||||
sys.stdout.write(json.dumps(msg) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def call_backend(action: str, browser_id: str, params: dict | None = None) -> dict:
|
||||
payload = json.dumps({
|
||||
"action": action,
|
||||
"browser_id": browser_id,
|
||||
"params": params or {},
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
BACKEND_URL,
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode() if e.fp else str(e)
|
||||
return {"error": f"HTTP {e.code}: {body}"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
MAX_IMAGE_B64_BYTES = 700_000
|
||||
|
||||
|
||||
def compress_screenshot(b64_png: str) -> tuple[str, str] | None:
|
||||
"""Resize and re-encode as JPEG to stay under the stdio buffer limit."""
|
||||
if not HAS_PIL:
|
||||
return None
|
||||
try:
|
||||
raw = base64.b64decode(b64_png)
|
||||
img = Image.open(BytesIO(raw))
|
||||
max_width = 1280
|
||||
if img.width > max_width:
|
||||
ratio = max_width / img.width
|
||||
img = img.resize((max_width, int(img.height * ratio)), Image.LANCZOS)
|
||||
buf = BytesIO()
|
||||
img.convert("RGB").save(buf, format="JPEG", quality=55)
|
||||
return base64.b64encode(buf.getvalue()).decode(), "image/jpeg"
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def handle_tool_call(tool_name: str, arguments: dict) -> dict:
|
||||
browser_id = arguments.get("browser_id", "")
|
||||
if not browser_id:
|
||||
return {"content": [{"type": "text", "text": "Error: browser_id is required"}], "isError": True}
|
||||
|
||||
action_map = {
|
||||
"BrowserScreenshot": "screenshot",
|
||||
"BrowserGetText": "get_text",
|
||||
"BrowserNavigate": "navigate",
|
||||
"BrowserClick": "click",
|
||||
"BrowserType": "type",
|
||||
"BrowserEvaluate": "evaluate",
|
||||
}
|
||||
action = action_map.get(tool_name)
|
||||
if not action:
|
||||
return {"content": [{"type": "text", "text": f"Unknown tool: {tool_name}"}], "isError": True}
|
||||
|
||||
params = {k: v for k, v in arguments.items() if k != "browser_id"}
|
||||
result = call_backend(action, browser_id, params)
|
||||
|
||||
if "error" in result:
|
||||
return {"content": [{"type": "text", "text": f"Error: {result['error']}"}], "isError": True}
|
||||
|
||||
if action == "screenshot" and result.get("image"):
|
||||
image_data = result["image"]
|
||||
mime_type = "image/png"
|
||||
|
||||
if len(image_data) > MAX_IMAGE_B64_BYTES:
|
||||
compressed = compress_screenshot(image_data)
|
||||
if compressed:
|
||||
image_data, mime_type = compressed
|
||||
|
||||
if len(image_data) > MAX_IMAGE_B64_BYTES:
|
||||
return {
|
||||
"content": [
|
||||
{"type": "text", "text": (
|
||||
f"Screenshot too large to return ({len(image_data)} bytes base64). "
|
||||
f"URL: {result.get('url', 'unknown')}. "
|
||||
"Use BrowserGetText to read the page content instead."
|
||||
)},
|
||||
],
|
||||
}
|
||||
|
||||
return {
|
||||
"content": [
|
||||
{"type": "image", "data": image_data, "mimeType": mime_type},
|
||||
{"type": "text", "text": f"Screenshot captured. URL: {result.get('url', 'unknown')}"},
|
||||
],
|
||||
}
|
||||
|
||||
text = result.get("text", result.get("data", json.dumps(result)))
|
||||
return {"content": [{"type": "text", "text": str(text)}]}
|
||||
|
||||
|
||||
def main():
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
method = msg.get("method")
|
||||
id_ = msg.get("id")
|
||||
params = msg.get("params", {})
|
||||
|
||||
if method == "initialize":
|
||||
send_response(id_, {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {
|
||||
"name": "openswarm-browser",
|
||||
"version": "1.0.0",
|
||||
},
|
||||
})
|
||||
elif method == "notifications/initialized":
|
||||
pass
|
||||
elif method == "tools/list":
|
||||
send_response(id_, {"tools": TOOLS})
|
||||
elif method == "tools/call":
|
||||
tool_name = params.get("name", "")
|
||||
arguments = params.get("arguments", {})
|
||||
result = handle_tool_call(tool_name, arguments)
|
||||
send_response(id_, result)
|
||||
elif method == "ping":
|
||||
send_response(id_, {})
|
||||
elif id_ is not None:
|
||||
send_response(id_, error={"code": -32601, "message": f"Method not found: {method}"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -56,8 +56,6 @@ class AgentSession(BaseModel):
|
||||
status: Literal["running", "waiting_approval", "completed", "error", "stopped"] = "running"
|
||||
model: str = "sonnet"
|
||||
mode: str = "agent"
|
||||
worktree_path: Optional[str] = None
|
||||
branch_name: Optional[str] = None
|
||||
sdk_session_id: Optional[str] = None
|
||||
system_prompt: Optional[str] = None
|
||||
allowed_tools: list[str] = Field(default_factory=list)
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class WorktreeManager:
|
||||
def __init__(self, repo_root: str):
|
||||
self.repo_root = repo_root
|
||||
self.worktrees_dir = os.path.join(repo_root, ".worktrees")
|
||||
os.makedirs(self.worktrees_dir, exist_ok=True)
|
||||
|
||||
async def create_worktree(self, branch_name: str) -> str:
|
||||
"""Create a new git worktree and return its path."""
|
||||
worktree_path = os.path.join(self.worktrees_dir, branch_name)
|
||||
if os.path.exists(worktree_path):
|
||||
return worktree_path
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"git", "worktree", "add", worktree_path, "-b", branch_name,
|
||||
cwd=self.repo_root,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"git", "worktree", "add", worktree_path, branch_name,
|
||||
cwd=self.repo_root,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"Failed to create worktree: {stderr.decode()}")
|
||||
|
||||
logger.info(f"Created worktree at {worktree_path} on branch {branch_name}")
|
||||
return worktree_path
|
||||
|
||||
async def remove_worktree(self, branch_name: str) -> None:
|
||||
"""Remove a git worktree."""
|
||||
worktree_path = os.path.join(self.worktrees_dir, branch_name)
|
||||
if not os.path.exists(worktree_path):
|
||||
return
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"git", "worktree", "remove", worktree_path, "--force",
|
||||
cwd=self.repo_root,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
await proc.communicate()
|
||||
logger.info(f"Removed worktree at {worktree_path}")
|
||||
|
||||
async def list_worktrees(self) -> list[dict]:
|
||||
"""List all worktrees with their branch and path info."""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"git", "worktree", "list", "--porcelain",
|
||||
cwd=self.repo_root,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
|
||||
worktrees = []
|
||||
current = {}
|
||||
for line in stdout.decode().strip().split("\n"):
|
||||
if line.startswith("worktree "):
|
||||
if current:
|
||||
worktrees.append(current)
|
||||
current = {"path": line.split(" ", 1)[1]}
|
||||
elif line.startswith("HEAD "):
|
||||
current["head"] = line.split(" ", 1)[1]
|
||||
elif line.startswith("branch "):
|
||||
current["branch"] = line.split(" ", 1)[1].replace("refs/heads/", "")
|
||||
elif line == "":
|
||||
if current:
|
||||
worktrees.append(current)
|
||||
current = {}
|
||||
if current:
|
||||
worktrees.append(current)
|
||||
return worktrees
|
||||
|
||||
async def delete_branch(self, branch_name: str) -> None:
|
||||
"""Delete a local git branch."""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"git", "branch", "-D", branch_name,
|
||||
cwd=self.repo_root,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode == 0:
|
||||
logger.info(f"Deleted branch {branch_name}")
|
||||
else:
|
||||
logger.warning(f"Could not delete branch {branch_name}: {stderr.decode().strip()}")
|
||||
|
||||
async def cleanup_all_worktrees(self) -> None:
|
||||
"""Remove all worktree directories and prune stale git worktree refs."""
|
||||
if os.path.exists(self.worktrees_dir):
|
||||
for entry in os.listdir(self.worktrees_dir):
|
||||
entry_path = os.path.join(self.worktrees_dir, entry)
|
||||
if os.path.isdir(entry_path):
|
||||
shutil.rmtree(entry_path, ignore_errors=True)
|
||||
logger.info("Removed all worktree directories")
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"git", "worktree", "prune",
|
||||
cwd=self.repo_root,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
await proc.communicate()
|
||||
logger.info("Pruned stale git worktree refs")
|
||||
|
||||
async def get_worktree_diff(self, branch_name: str) -> str:
|
||||
"""Get the diff of uncommitted changes in a worktree."""
|
||||
worktree_path = os.path.join(self.worktrees_dir, branch_name)
|
||||
if not os.path.exists(worktree_path):
|
||||
return ""
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"git", "diff", "HEAD",
|
||||
cwd=worktree_path,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
return stdout.decode()
|
||||
@@ -12,6 +12,7 @@ class ConnectionManager:
|
||||
self.connections: dict[str, list[WebSocket]] = {}
|
||||
self.global_connections: list[WebSocket] = []
|
||||
self.pending_futures: dict[str, asyncio.Future] = {}
|
||||
self.browser_futures: dict[str, asyncio.Future] = {}
|
||||
|
||||
async def connect_session(self, session_id: str, websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
@@ -85,4 +86,32 @@ class ConnectionManager:
|
||||
if future and not future.done():
|
||||
future.set_result(decision)
|
||||
|
||||
async def send_browser_command(
|
||||
self, request_id: str, action: str, browser_id: str, params: dict
|
||||
) -> dict:
|
||||
"""Send a browser command to the frontend and wait for the result."""
|
||||
future = asyncio.get_event_loop().create_future()
|
||||
self.browser_futures[request_id] = future
|
||||
|
||||
await self.broadcast_global("browser:command", {
|
||||
"request_id": request_id,
|
||||
"action": action,
|
||||
"browser_id": browser_id,
|
||||
"params": params,
|
||||
})
|
||||
|
||||
try:
|
||||
result = await asyncio.wait_for(future, timeout=30.0)
|
||||
return result
|
||||
except asyncio.TimeoutError:
|
||||
return {"error": "Browser command timed out"}
|
||||
finally:
|
||||
self.browser_futures.pop(request_id, None)
|
||||
|
||||
def resolve_browser_command(self, request_id: str, result: dict):
|
||||
"""Resolve a pending browser command Future with the frontend's result."""
|
||||
future = self.browser_futures.get(request_id)
|
||||
if future and not future.done():
|
||||
future.set_result(result)
|
||||
|
||||
ws_manager = ConnectionManager()
|
||||
|
||||
@@ -7,7 +7,8 @@ from backend.apps.dashboard_layout.models import DashboardLayout, DashboardLayou
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "data", "dashboard_layout")
|
||||
from backend.config.paths import DASHBOARD_LAYOUT_DIR as DATA_DIR
|
||||
|
||||
LAYOUT_FILE = os.path.join(DATA_DIR, "layout.json")
|
||||
|
||||
|
||||
|
||||
@@ -13,16 +13,14 @@ from backend.apps.dashboards.models import (
|
||||
DashboardLayout,
|
||||
CardPosition,
|
||||
ViewCardPosition,
|
||||
BrowserCardPosition,
|
||||
)
|
||||
from fastapi import HTTPException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
DATA_DIR = os.path.join(BACKEND_DIR, "data", "dashboards")
|
||||
SESSIONS_DIR = os.path.join(BACKEND_DIR, "data", "sessions")
|
||||
from backend.config.paths import DASHBOARDS_DIR as DATA_DIR, SESSIONS_DIR, DASHBOARD_LAYOUT_DIR as OLD_LAYOUT_DIR
|
||||
|
||||
OLD_LAYOUT_DIR = os.path.join(BACKEND_DIR, "data", "dashboard_layout")
|
||||
OLD_LAYOUT_FILE = os.path.join(OLD_LAYOUT_DIR, "layout.json")
|
||||
|
||||
|
||||
@@ -115,6 +113,7 @@ async def list_dashboards():
|
||||
items.append({
|
||||
"id": dumped["id"],
|
||||
"name": dumped.get("name", "Untitled"),
|
||||
"auto_named": dumped.get("auto_named", False),
|
||||
"created_at": dumped.get("created_at"),
|
||||
"updated_at": dumped.get("updated_at"),
|
||||
})
|
||||
@@ -128,6 +127,69 @@ async def create_dashboard(body: DashboardCreate):
|
||||
return dashboard.model_dump(mode="json")
|
||||
|
||||
|
||||
@dashboards.router.post("/{dashboard_id}/generate-name")
|
||||
async def generate_name(dashboard_id: str):
|
||||
dashboard = _load(dashboard_id)
|
||||
|
||||
if not dashboard.auto_named and dashboard.name != "Untitled Dashboard":
|
||||
return {"name": dashboard.name, "auto_named": dashboard.auto_named}
|
||||
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
|
||||
prompts = []
|
||||
for session in agent_manager.sessions.values():
|
||||
if getattr(session, "dashboard_id", None) != dashboard_id:
|
||||
continue
|
||||
for msg in session.messages:
|
||||
if msg.role == "user" and isinstance(msg.content, str) and msg.content.strip():
|
||||
prompts.append(msg.content.strip()[:200])
|
||||
break
|
||||
|
||||
if not prompts:
|
||||
return {"name": dashboard.name, "auto_named": dashboard.auto_named}
|
||||
|
||||
fallback = prompts[0][:40]
|
||||
try:
|
||||
import anthropic
|
||||
from backend.apps.settings.settings import load_settings
|
||||
global_settings = load_settings()
|
||||
if not global_settings.anthropic_api_key:
|
||||
raise ValueError("API key not configured")
|
||||
|
||||
client = anthropic.AsyncAnthropic(api_key=global_settings.anthropic_api_key)
|
||||
|
||||
if len(prompts) == 1:
|
||||
system = (
|
||||
"Generate a concise 2-5 word workspace name for a project based on this task. "
|
||||
"Return only the name, nothing else."
|
||||
)
|
||||
user_content = prompts[0]
|
||||
else:
|
||||
system = (
|
||||
"Generate a concise 2-5 word workspace name that captures the overall theme of these tasks. "
|
||||
"Return only the name, nothing else."
|
||||
)
|
||||
user_content = "\n".join(f"- {p}" for p in prompts)
|
||||
|
||||
resp = await client.messages.create(
|
||||
model="claude-haiku-4-20250414",
|
||||
max_tokens=30,
|
||||
system=system,
|
||||
messages=[{"role": "user", "content": user_content}],
|
||||
)
|
||||
generated = resp.content[0].text.strip().strip('"\'')
|
||||
if generated:
|
||||
fallback = generated
|
||||
except Exception as e:
|
||||
logger.warning(f"Dashboard name generation failed, using fallback: {e}")
|
||||
|
||||
dashboard.name = fallback
|
||||
dashboard.auto_named = True
|
||||
dashboard.updated_at = datetime.now()
|
||||
_save(dashboard)
|
||||
return {"name": dashboard.name, "auto_named": True}
|
||||
|
||||
|
||||
@dashboards.router.get("/{dashboard_id}")
|
||||
async def get_dashboard(dashboard_id: str):
|
||||
dashboard = _load(dashboard_id)
|
||||
@@ -139,6 +201,7 @@ async def update_dashboard(dashboard_id: str, body: DashboardUpdate):
|
||||
dashboard = _load(dashboard_id)
|
||||
if body.name is not None:
|
||||
dashboard.name = body.name
|
||||
dashboard.auto_named = False
|
||||
if body.layout is not None:
|
||||
dashboard.layout = body.layout
|
||||
dashboard.updated_at = datetime.now()
|
||||
@@ -191,7 +254,11 @@ async def duplicate_dashboard(dashboard_id: str):
|
||||
"name": f"{source_data.get('name', 'Untitled')} (copy)",
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"layout": {"cards": {}, "view_cards": source_data.get("layout", {}).get("view_cards", {})},
|
||||
"layout": {
|
||||
"cards": {},
|
||||
"view_cards": source_data.get("layout", {}).get("view_cards", {}),
|
||||
"browser_cards": source_data.get("layout", {}).get("browser_cards", {}),
|
||||
},
|
||||
}
|
||||
with open(os.path.join(DATA_DIR, f"{new_id}.json"), "w") as f:
|
||||
json.dump(new_dashboard, f, indent=2)
|
||||
|
||||
@@ -20,14 +20,26 @@ class ViewCardPosition(BaseModel):
|
||||
height: float = 360
|
||||
|
||||
|
||||
class BrowserCardPosition(BaseModel):
|
||||
browser_id: str
|
||||
url: str = ""
|
||||
x: float = 0
|
||||
y: float = 0
|
||||
width: float = 640
|
||||
height: float = 480
|
||||
|
||||
|
||||
class DashboardLayout(BaseModel):
|
||||
cards: dict[str, CardPosition] = Field(default_factory=dict)
|
||||
view_cards: dict[str, ViewCardPosition] = Field(default_factory=dict)
|
||||
browser_cards: dict[str, BrowserCardPosition] = Field(default_factory=dict)
|
||||
expanded_session_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class Dashboard(BaseModel):
|
||||
id: str = Field(default_factory=lambda: uuid4().hex)
|
||||
name: str = "Untitled Dashboard"
|
||||
auto_named: bool = False
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
updated_at: datetime = Field(default_factory=datetime.now)
|
||||
layout: DashboardLayout = Field(default_factory=DashboardLayout)
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import os
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
OUTPUTS_WORKSPACE = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
"data", "outputs_workspace",
|
||||
)
|
||||
|
||||
SKILLS_WORKSPACE = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
"data", "skills_workspace",
|
||||
)
|
||||
from backend.config.paths import OUTPUTS_WORKSPACE_DIR as OUTPUTS_WORKSPACE, SKILLS_WORKSPACE_DIR as SKILLS_WORKSPACE
|
||||
|
||||
|
||||
class Mode(BaseModel):
|
||||
|
||||
@@ -8,7 +8,7 @@ from backend.apps.modes.models import Mode, ModeCreate, ModeUpdate, BUILTIN_MODE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "data", "modes")
|
||||
from backend.config.paths import MODES_DIR as DATA_DIR
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
||||
@@ -31,18 +31,13 @@ def _resolve_model(short_name: str) -> str:
|
||||
|
||||
|
||||
def _get_anthropic_client():
|
||||
"""Create an AsyncAnthropic client, pulling the API key from app settings if
|
||||
the ANTHROPIC_API_KEY env var isn't set."""
|
||||
"""Create an AsyncAnthropic client using the API key from app settings."""
|
||||
import anthropic
|
||||
|
||||
if os.environ.get("ANTHROPIC_API_KEY"):
|
||||
return anthropic.AsyncAnthropic()
|
||||
|
||||
settings = load_settings()
|
||||
if settings.anthropic_api_key:
|
||||
return anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||
|
||||
return anthropic.AsyncAnthropic()
|
||||
if not settings.anthropic_api_key:
|
||||
raise ValueError("Anthropic API key not configured. Set it in Settings.")
|
||||
return anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||
|
||||
|
||||
def _validate_against_schema(data: dict, schema: dict) -> str | None:
|
||||
@@ -54,15 +49,7 @@ def _validate_against_schema(data: dict, schema: dict) -> str | None:
|
||||
path = " -> ".join(str(p) for p in exc.absolute_path) if exc.absolute_path else "(root)"
|
||||
return f"Schema validation failed at {path}: {exc.message}"
|
||||
|
||||
DATA_DIR = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
"data", "outputs",
|
||||
)
|
||||
|
||||
WORKSPACE_DIR = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
"data", "outputs_workspace",
|
||||
)
|
||||
from backend.config.paths import OUTPUTS_DIR as DATA_DIR, OUTPUTS_WORKSPACE_DIR as WORKSPACE_DIR
|
||||
|
||||
|
||||
def _build_data_injection(input_json: str, result_json: str) -> str:
|
||||
@@ -586,7 +573,7 @@ async def auto_run_agent(body: AutoRunAgentRequest):
|
||||
|
||||
@outputs.router.delete("/auto-run-agent/{session_id}")
|
||||
async def cleanup_auto_run_agent(session_id: str):
|
||||
"""Delete a temporary auto-run agent session and its worktree."""
|
||||
"""Delete a temporary auto-run agent session."""
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
|
||||
try:
|
||||
|
||||
@@ -12,3 +12,4 @@ class AppSettings(BaseModel):
|
||||
theme: str = "dark"
|
||||
new_agent_shortcut: str = "Meta+l"
|
||||
anthropic_api_key: Optional[str] = None
|
||||
browser_homepage: str = "https://www.google.com"
|
||||
|
||||
@@ -13,10 +13,8 @@ from backend.apps.settings.models import AppSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DATA_DIR = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
"data", "settings",
|
||||
)
|
||||
from backend.config.paths import SETTINGS_DIR as DATA_DIR
|
||||
|
||||
SETTINGS_FILE = os.path.join(DATA_DIR, "settings.json")
|
||||
|
||||
|
||||
|
||||
@@ -13,10 +13,7 @@ logger = logging.getLogger(__name__)
|
||||
SKILLS_DIR = os.path.expanduser("~/.claude/skills")
|
||||
INDEX_PATH = os.path.join(SKILLS_DIR, ".skills_index.json")
|
||||
|
||||
SKILLS_WORKSPACE_DIR = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
"data", "skills_workspace",
|
||||
)
|
||||
from backend.config.paths import SKILLS_WORKSPACE_DIR
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
||||
@@ -8,7 +8,7 @@ from backend.apps.templates.models import PromptTemplate, PromptTemplateCreate,
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "data", "templates")
|
||||
from backend.config.paths import TEMPLATES_DIR as DATA_DIR
|
||||
|
||||
@asynccontextmanager
|
||||
async def templates_lifespan():
|
||||
|
||||
@@ -18,11 +18,9 @@ from backend.apps.tools_lib.models import ToolDefinition, ToolCreate, ToolUpdate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
load_dotenv(os.path.join(BACKEND_DIR, ".env"))
|
||||
from backend.config.paths import BACKEND_DIR, TOOLS_DIR as DATA_DIR, BUILTIN_PERMISSIONS_PATH as BUILTIN_PERMS_PATH
|
||||
|
||||
DATA_DIR = os.path.join(BACKEND_DIR, "data", "tools")
|
||||
BUILTIN_PERMS_PATH = os.path.join(BACKEND_DIR, "data", "builtin_permissions.json")
|
||||
load_dotenv(os.path.join(BACKEND_DIR, ".env"))
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -121,7 +119,8 @@ async def oauth_callback(code: str = Query(...), state: str = Query("")):
|
||||
tool = _load(tool_id)
|
||||
client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "")
|
||||
client_secret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "")
|
||||
redirect_uri = "http://localhost:8324/api/tools/oauth/callback"
|
||||
_port = os.environ.get("OPENSWARM_PORT", "8324")
|
||||
redirect_uri = f"http://localhost:{_port}/api/tools/oauth/callback"
|
||||
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.post(GOOGLE_TOKEN_URL, data={
|
||||
@@ -660,7 +659,8 @@ async def oauth_start(tool_id: str):
|
||||
if not client_id:
|
||||
raise HTTPException(status_code=400, detail="GOOGLE_OAUTH_CLIENT_ID not set in backend .env")
|
||||
|
||||
redirect_uri = "http://localhost:8324/api/tools/oauth/callback"
|
||||
_port = os.environ.get("OPENSWARM_PORT", "8324")
|
||||
redirect_uri = f"http://localhost:{_port}/api/tools/oauth/callback"
|
||||
state = tool_id
|
||||
|
||||
_pending_oauth[state] = tool_id
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI, APIRouter
|
||||
import debug
|
||||
from uuid import uuid4
|
||||
@@ -30,7 +32,8 @@ class MainApp:
|
||||
for sub_app in sub_apps:
|
||||
debug(sub_app.name)
|
||||
await stack.enter_async_context(sub_app.lifespan())
|
||||
print("\nCheck out the API docs at: http://127.0.0.1:8324/docs\n")
|
||||
_port = os.environ.get("OPENSWARM_PORT", "8324")
|
||||
print(f"\nCheck out the API docs at: http://127.0.0.1:{_port}/docs\n")
|
||||
yield
|
||||
|
||||
self.app = FastAPI(lifespan=lifespan)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Centralised path definitions for the OpenSwarm backend.
|
||||
|
||||
In dev mode (default) data lives under ``backend/data/``.
|
||||
When packaged as a desktop app, Electron sets ``OPENSWARM_PACKAGED=1`` and
|
||||
data is stored in a platform-appropriate location
|
||||
(``~/Library/Application Support/OpenSwarm/data/`` on macOS).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
_BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
_is_packaged = os.environ.get("OPENSWARM_PACKAGED") == "1"
|
||||
|
||||
if _is_packaged:
|
||||
if sys.platform == "darwin":
|
||||
_app_support = os.path.join(os.path.expanduser("~"), "Library", "Application Support", "OpenSwarm")
|
||||
elif sys.platform == "win32":
|
||||
_app_support = os.path.join(os.environ.get("APPDATA", os.path.expanduser("~")), "OpenSwarm")
|
||||
else:
|
||||
_app_support = os.path.join(os.environ.get("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")), "OpenSwarm")
|
||||
DATA_ROOT = os.path.join(_app_support, "data")
|
||||
else:
|
||||
DATA_ROOT = os.path.join(_BACKEND_DIR, "data")
|
||||
|
||||
SESSIONS_DIR = os.path.join(DATA_ROOT, "sessions")
|
||||
TOOLS_DIR = os.path.join(DATA_ROOT, "tools")
|
||||
SETTINGS_DIR = os.path.join(DATA_ROOT, "settings")
|
||||
MODES_DIR = os.path.join(DATA_ROOT, "modes")
|
||||
TEMPLATES_DIR = os.path.join(DATA_ROOT, "templates")
|
||||
DASHBOARDS_DIR = os.path.join(DATA_ROOT, "dashboards")
|
||||
OUTPUTS_DIR = os.path.join(DATA_ROOT, "outputs")
|
||||
OUTPUTS_WORKSPACE_DIR = os.path.join(DATA_ROOT, "outputs_workspace")
|
||||
SKILLS_WORKSPACE_DIR = os.path.join(DATA_ROOT, "skills_workspace")
|
||||
DASHBOARD_LAYOUT_DIR = os.path.join(DATA_ROOT, "dashboard_layout")
|
||||
BUILTIN_PERMISSIONS_PATH = os.path.join(DATA_ROOT, "builtin_permissions.json")
|
||||
|
||||
BACKEND_DIR = _BACKEND_DIR
|
||||
+52
-1
@@ -1,4 +1,8 @@
|
||||
import os
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi import Request
|
||||
from backend.config.Apps import MainApp
|
||||
from backend.apps.health.health import health
|
||||
from backend.apps.agents.agents import agents
|
||||
@@ -83,9 +87,56 @@ async def websocket_dashboard(websocket: WebSocket):
|
||||
"message": payload.get("message"),
|
||||
"updated_input": payload.get("updated_input"),
|
||||
})
|
||||
elif event == "browser:result":
|
||||
ws_manager.resolve_browser_command(
|
||||
payload.get("request_id", ""),
|
||||
payload,
|
||||
)
|
||||
except WebSocketDisconnect:
|
||||
ws_manager.disconnect_global(websocket)
|
||||
|
||||
|
||||
@app.post("/api/browser/command")
|
||||
async def browser_command(request: Request):
|
||||
"""HTTP endpoint called by the browser MCP server subprocess.
|
||||
Proxies commands to the frontend via WebSocket and waits for results."""
|
||||
body = await request.json()
|
||||
action = body.get("action", "")
|
||||
browser_id = body.get("browser_id", "")
|
||||
params = body.get("params", {})
|
||||
|
||||
if not action or not browser_id:
|
||||
return JSONResponse({"error": "action and browser_id are required"}, status_code=400)
|
||||
|
||||
request_id = uuid4().hex
|
||||
result = await ws_manager.send_browser_command(request_id, action, browser_id, params)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
import uvicorn
|
||||
uvicorn.run("backend.main:app", host="0.0.0.0", port=8324, reload=True)
|
||||
|
||||
parser = argparse.ArgumentParser(description="OpenSwarm backend server")
|
||||
parser.add_argument("--port", type=int, default=int(os.environ.get("OPENSWARM_PORT", "8324")))
|
||||
parser.add_argument("--host", default=os.environ.get("OPENSWARM_HOST", "127.0.0.1"))
|
||||
parser.add_argument("--reload", action="store_true", default=False)
|
||||
args = parser.parse_args()
|
||||
|
||||
os.environ["OPENSWARM_PORT"] = str(args.port)
|
||||
|
||||
import uvicorn.config
|
||||
|
||||
class _ReadyServer(uvicorn.Server):
|
||||
"""Subclass that prints a machine-readable READY line on startup."""
|
||||
async def startup(self, sockets=None):
|
||||
await super().startup(sockets)
|
||||
print(f"READY:PORT={args.port}", flush=True)
|
||||
|
||||
if args.reload:
|
||||
uvicorn.run("backend.main:app", host=args.host, port=args.port, reload=True)
|
||||
else:
|
||||
config = uvicorn.Config("backend.main:app", host=args.host, port=args.port)
|
||||
server = _ReadyServer(config)
|
||||
import asyncio
|
||||
asyncio.run(server.serve())
|
||||
|
||||
@@ -8,4 +8,5 @@ langchain-openai==0.3.12
|
||||
pytest==8.3.4
|
||||
pytest-asyncio==0.25.2
|
||||
typeguard==4.4.2
|
||||
python-dotenv==1.1.1
|
||||
python-dotenv==1.1.1
|
||||
Pillow
|
||||
@@ -13,14 +13,13 @@ else
|
||||
# echo "NOT in macOS server START"
|
||||
fi
|
||||
chmod +x "$DEV_ABSPATH"
|
||||
source "$(dirname "$DEV_ABSPATH")/_utils.sh"
|
||||
|
||||
|
||||
PROJECT_ROOT_ABSPATH="$(dirname "$BACKEND_DIR_ABSPATH")"
|
||||
PROJECT_ROOT_ABSPATH="$(dirname "$(dirname "$DEV_ABSPATH")")"
|
||||
BACKEND_DIR_ABSPATH="$PROJECT_ROOT_ABSPATH/backend"
|
||||
|
||||
# Cleanup function on exit
|
||||
cleanup() {
|
||||
formatted_echo --yellow "Shutting down..."
|
||||
echo "Shutting down..."
|
||||
cd - > /dev/null 2>&1
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
@@ -28,34 +27,34 @@ trap cleanup EXIT INT TERM
|
||||
# --- Create virtual environment if it doesn't exist ---
|
||||
VENV_DIR="$BACKEND_DIR_ABSPATH/.venv"
|
||||
if [[ ! -d "$VENV_DIR" ]]; then
|
||||
formatted_echo --green "Creating virtual environment..."
|
||||
echo "Creating virtual environment..."
|
||||
python -m venv "$VENV_DIR"
|
||||
fi
|
||||
source "$VENV_DIR/bin/activate"
|
||||
|
||||
# --- Install custom debugger module if not already installed ---
|
||||
DEBUGGER_DIR_ABSPATH="$PROJECT_ROOT_ABSPATH/debugger"
|
||||
if ! pip show debug > /dev/null 2>&1; then
|
||||
formatted_echo --green "Installing debugger module..."
|
||||
if ! pip3 show debug > /dev/null 2>&1; then
|
||||
echo "Installing debugger module..."
|
||||
cd "$DEBUGGER_DIR_ABSPATH"
|
||||
pip install -e .
|
||||
pip3 install -e .
|
||||
if [[ $? -ne 0 ]]; then
|
||||
formatted_error "Failed to install debugger module."
|
||||
echo "Failed to install debugger module."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Install Python dependencies ---
|
||||
formatted_echo --green "Installing dependencies..."
|
||||
echo "Installing dependencies..."
|
||||
cd "$BACKEND_DIR_ABSPATH"
|
||||
pip install -r requirements.txt
|
||||
pip3 install -r requirements.txt
|
||||
if [[ $? -ne 0 ]]; then
|
||||
formatted_error "Failed to install Python dependencies."
|
||||
echo "Failed to install Python dependencies."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Start the backend server ---
|
||||
formatted_echo --green "Starting backend server on http://0.0.0.0:8324 ..."
|
||||
echo "Starting backend server on http://0.0.0.0:8324 ..."
|
||||
cd "$PROJECT_ROOT_ABSPATH"
|
||||
python -m uvicorn backend.main:app --host 0.0.0.0 --port 8324 --reload \
|
||||
--reload-dir "$BACKEND_DIR_ABSPATH" \
|
||||
@@ -1,202 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Flag processing function with namespacing and global variable declaration
|
||||
UTILS_FILE_ABSPATH="$(readlink -f "${BASH_SOURCE[0]}")"
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
# echo "In macOS utils sed START"
|
||||
# echo "UTILS_ABSPATH: $UTILS_FILE_ABSPATH"
|
||||
sed -i '' 's/\r//g' "$UTILS_FILE_ABSPATH"
|
||||
# echo "In macOS utils sed END"
|
||||
else
|
||||
# echo "NOT in macOS utils START"
|
||||
# echo "UTILS_ABSPATH: $UTILS_FILE_ABSPATH"
|
||||
sed -i 's/\r//g' "$UTILS_FILE_ABSPATH"
|
||||
# echo "NOT in macOS utils END"
|
||||
fi
|
||||
chmod +x "$UTILS_FILE_ABSPATH"
|
||||
|
||||
RUN_DIR_ABSPATH="$(dirname "$UTILS_FILE_ABSPATH")"
|
||||
BACKEND_DIR_ABSPATH="$(dirname "$RUN_DIR_ABSPATH")"
|
||||
|
||||
formatted_error() {
|
||||
# Arguments: error message and array of conflicting flags
|
||||
local initial_message="$1"
|
||||
shift
|
||||
local conflicting_flags=("$@")
|
||||
|
||||
# Red color for the error message box
|
||||
local COLOR_CODE='\033[0;31m'
|
||||
local NC='\033[0m' # No Color
|
||||
|
||||
# Start the error message with the initial message
|
||||
local error_message="$initial_message"
|
||||
|
||||
# Add each conflicting flag on a new line with indentation
|
||||
for conflict in "${conflicting_flags[@]}"; do
|
||||
error_message+="\n $conflict" # Replacing `\t` with four spaces
|
||||
done
|
||||
|
||||
# Prepare for printing by finding max length of each line in the message
|
||||
local lines=()
|
||||
local max_length=0
|
||||
|
||||
# Use printf to interpret new lines and calculate max length with spaces instead of tabs
|
||||
while IFS= read -r line; do
|
||||
# Substitute tabs with spaces for consistent width measurement
|
||||
local line_with_spaces="${line//$'\t'/ }"
|
||||
lines+=("$line_with_spaces")
|
||||
if (( ${#line_with_spaces} > max_length )); then
|
||||
max_length=${#line_with_spaces}
|
||||
fi
|
||||
done <<< "$(printf "$error_message")"
|
||||
|
||||
# Create the top and bottom borders based on the maximum line length
|
||||
local border=$(printf '%*s' "$((max_length + 4))" '' | tr ' ' '-')
|
||||
|
||||
# Print the formatted error message with a red box
|
||||
printf "\n${COLOR_CODE}%s${NC}\n" "$border"
|
||||
for line in "${lines[@]}"; do
|
||||
printf "${COLOR_CODE}| %-*s |${NC}\n" "$max_length" "$line"
|
||||
done
|
||||
printf "${COLOR_CODE}| %-*s |${NC}\n" "$max_length" ""
|
||||
printf "${COLOR_CODE}%s${NC}\n" "$border"
|
||||
}
|
||||
|
||||
|
||||
function process_flags() {
|
||||
local -n flags_to_commands="$1" # Reference to the dictionary of flags and commands
|
||||
local -n exclusives="$2" # Reference to the list of exclusive flag groups
|
||||
local namespace="$3" # Unique prefix for variables
|
||||
local calling_script_name="$(basename "$(readlink -f "${BASH_SOURCE[1]}")")"
|
||||
local caller_id="${FUNCNAME[1]}"
|
||||
local should_exit=false
|
||||
if [ "$caller_id" == "main" ]; then
|
||||
caller_id="$calling_script_name"
|
||||
fi
|
||||
# echo "caller_id: $caller_id"
|
||||
# echo "Initializing flags with namespace: $namespace"
|
||||
|
||||
# Initialize flag variables with namespacing in the sourcing script context
|
||||
for flag in "${!flags_to_commands[@]}"; do
|
||||
# Convert flag to uppercase variable name and apply namespace, e.g., MYAPP_FLAG1
|
||||
local flag_var="${namespace}_${flag^^}" # Prefix and uppercase
|
||||
flag_var="${flag_var//-}" # Remove dashes
|
||||
eval "declare -g $flag_var=false" # Initialize as global false
|
||||
|
||||
# Diagnostic output for each initialized variable
|
||||
# echo "Initialized $flag_var as false"
|
||||
done
|
||||
|
||||
# echo "Parsing command line arguments: $@"
|
||||
|
||||
# Parse command line arguments and set flags
|
||||
local unsupported_flags=()
|
||||
# local potential_typos=()
|
||||
for arg in "$@"; do
|
||||
# echo "Processing argument: $arg"
|
||||
if [[ -n "${flags_to_commands[$arg]}" ]]; then
|
||||
# echo "Flag found: $arg"
|
||||
local flag_var="${namespace}_${arg^^}" # Prefix and uppercase
|
||||
flag_var="${flag_var//-}" # Remove dashes
|
||||
eval "declare -g $flag_var=true" # Set as global true
|
||||
# echo "Set $flag_var to true"
|
||||
# echo "Executing command for $arg: ${flags_to_commands[$arg]}"
|
||||
eval "${flags_to_commands[$arg]}"
|
||||
else
|
||||
if [[ "$arg" == "-"* ]]; then
|
||||
# echo "Flag not found: $arg"
|
||||
unsupported_flags+=("$arg")
|
||||
if [[ -n "${flags_to_commands[-$arg]}" ]]; then
|
||||
# echo "Potential typo: $arg"
|
||||
unsupported_flags+=("\t*Note: Potential typo detected.")
|
||||
unsupported_flags+=("\tDid you mean: -$arg")
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
# Check exclusive flag groups for conflicts
|
||||
# echo "Checking exclusive flag groups"
|
||||
for group in "${exclusives[@]}"; do
|
||||
local count=0
|
||||
local conflicting_flags=()
|
||||
for flag in $group; do
|
||||
local flag_var="${namespace}_${flag^^}"
|
||||
flag_var="${flag_var//-}"
|
||||
if [[ "$(eval echo "\$$flag_var")" == "true" ]]; then
|
||||
conflicting_flags+=("$flag")
|
||||
# count=$((count + 1))
|
||||
# ec "$flag_var is true in exclusive group"
|
||||
fi
|
||||
done
|
||||
if (( ${#conflicting_flags[@]} > 1 )); then
|
||||
# echo "found conflicting flags"
|
||||
formatted_error "Error: $caller_id\n-----------------------------------------\nIncompatible flags:\nThe flags below cannot be used together\n-----------------------------------------\n" "${conflicting_flags[@]}"
|
||||
should_exit=true
|
||||
fi
|
||||
done
|
||||
# echo "should_exit: $should_exit"
|
||||
# echo "unsupported_flags: ${#unsupported_flags[@]}"
|
||||
if (( ${#unsupported_flags[@]} > 0 )); then
|
||||
# echo "found unsupported flags 2"
|
||||
formatted_error "Error: $caller_id\n-------------------------------------------------\nUnsupported flags:\nThe flags below are not supported by this script\n-------------------------------------------------\n" "${unsupported_flags[@]}"
|
||||
should_exit=true
|
||||
fi
|
||||
if [[ $should_exit == true ]]; then
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
|
||||
formatted_echo() {
|
||||
local COLOR_CODE='\033[0m' # No Color
|
||||
local NC='\033[0m' # No Color variable
|
||||
# local message="$2"
|
||||
# If the second argument is empty, then theres no color specified, so we use the first argument as the message
|
||||
if [[ -z "$2" ]]; then
|
||||
message="$1"
|
||||
else
|
||||
message="$2"
|
||||
fi
|
||||
|
||||
declare -A format_flags
|
||||
format_flags=(
|
||||
[--red]="COLOR_CODE='\033[0;31m'"
|
||||
[--green]="COLOR_CODE='\033[0;32m'"
|
||||
[--yellow]="COLOR_CODE='\033[0;33m'"
|
||||
[--blue]="COLOR_CODE='\033[0;34m'"
|
||||
[--purple]="COLOR_CODE='\033[0;35m'"
|
||||
[--cyan]="COLOR_CODE='\033[0;36m'"
|
||||
)
|
||||
exclusive_format_flags=(
|
||||
"--red --green --yellow --blue --purple --cyan"
|
||||
)
|
||||
|
||||
# Pass the arguments with the namespace "FORMAT"
|
||||
process_flags format_flags exclusive_format_flags "FORMAT" "$@"
|
||||
|
||||
# Process the message
|
||||
local text
|
||||
text=$(printf "%b" "$message")
|
||||
|
||||
# Expand any escaped characters in the input (e.g., \n)
|
||||
local lines=()
|
||||
local max_length=0
|
||||
|
||||
# Read the text line by line and find the maximum length
|
||||
while IFS= read -r line; do
|
||||
lines+=("$line")
|
||||
if (( ${#line} > max_length )); then
|
||||
max_length=${#line}
|
||||
fi
|
||||
done <<< "$text"
|
||||
|
||||
# Create the top and bottom borders based on the maximum line length
|
||||
local border=$(printf '%*s' "$((max_length + 4))" '' | tr ' ' '-')
|
||||
|
||||
# Print the formatted box with selected color
|
||||
printf "\n${COLOR_CODE}%s${NC}\n" "$border"
|
||||
for line in "${lines[@]}"; do
|
||||
printf "${COLOR_CODE}| %-*s |${NC}\n" "$max_length" "$line"
|
||||
done
|
||||
printf "${COLOR_CODE}%s${NC}\n" "$border"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.inherit</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 320 KiB |
@@ -0,0 +1,226 @@
|
||||
const { app, BrowserWindow, ipcMain } = require('electron');
|
||||
const { autoUpdater } = require('electron-updater');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
const getPort = require('get-port');
|
||||
const http = require('http');
|
||||
|
||||
let mainWindow = null;
|
||||
let backendProcess = null;
|
||||
let backendPort = null;
|
||||
|
||||
const isPackaged = app.isPackaged;
|
||||
const isDev = process.env.ELECTRON_DEV === '1';
|
||||
const iconPath = path.join(__dirname, 'build', 'icon.png');
|
||||
|
||||
function getResourcePath(...segments) {
|
||||
if (isPackaged) {
|
||||
return path.join(process.resourcesPath, ...segments);
|
||||
}
|
||||
return path.join(__dirname, '..', ...segments);
|
||||
}
|
||||
|
||||
function getPythonPath() {
|
||||
if (isPackaged) {
|
||||
const envPath = path.join(process.resourcesPath, 'python-env');
|
||||
return path.join(envPath, 'bin', 'python3');
|
||||
}
|
||||
const venvPython = path.join(__dirname, '..', 'backend', '.venv', 'bin', 'python3');
|
||||
return venvPython;
|
||||
}
|
||||
|
||||
function waitForBackend(port, timeoutMs = 60000) {
|
||||
const start = Date.now();
|
||||
return new Promise((resolve, reject) => {
|
||||
function check() {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
return reject(new Error('Backend startup timed out'));
|
||||
}
|
||||
const req = http.get(`http://127.0.0.1:${port}/api/health/check`, (res) => {
|
||||
if (res.statusCode === 200) {
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(check, 500);
|
||||
}
|
||||
});
|
||||
req.on('error', () => setTimeout(check, 500));
|
||||
req.setTimeout(2000, () => {
|
||||
req.destroy();
|
||||
setTimeout(check, 500);
|
||||
});
|
||||
}
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
async function startBackend() {
|
||||
backendPort = await getPort({ port: getPort.makeRange(8324, 8424) });
|
||||
|
||||
const pythonPath = getPythonPath();
|
||||
const backendDir = getResourcePath('backend');
|
||||
const projectRoot = isPackaged ? process.resourcesPath : path.join(__dirname, '..');
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
OPENSWARM_PACKAGED: isPackaged ? '1' : '0',
|
||||
OPENSWARM_PORT: String(backendPort),
|
||||
PYTHONDONTWRITEBYTECODE: '1',
|
||||
};
|
||||
|
||||
if (isPackaged) {
|
||||
const pythonEnvSitePackages = path.join(
|
||||
process.resourcesPath, 'python-env', 'lib',
|
||||
'python3.13', 'site-packages'
|
||||
);
|
||||
const debuggerDir = getResourcePath('debugger');
|
||||
env.PYTHONPATH = [projectRoot, debuggerDir, pythonEnvSitePackages].join(':');
|
||||
}
|
||||
|
||||
console.log(`Starting backend: ${pythonPath} on port ${backendPort}`);
|
||||
console.log(`Project root: ${projectRoot}`);
|
||||
|
||||
backendProcess = spawn(
|
||||
pythonPath,
|
||||
['-m', 'uvicorn', 'backend.main:app', '--host', '127.0.0.1', '--port', String(backendPort)],
|
||||
{
|
||||
cwd: projectRoot,
|
||||
env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}
|
||||
);
|
||||
|
||||
backendProcess.stdout.on('data', (data) => {
|
||||
const text = data.toString();
|
||||
process.stdout.write(`[backend] ${text}`);
|
||||
});
|
||||
|
||||
backendProcess.stderr.on('data', (data) => {
|
||||
const text = data.toString();
|
||||
process.stderr.write(`[backend] ${text}`);
|
||||
});
|
||||
|
||||
backendProcess.on('exit', (code) => {
|
||||
console.log(`Backend exited with code ${code}`);
|
||||
if (code !== 0 && code !== null && mainWindow) {
|
||||
mainWindow.webContents.executeJavaScript(
|
||||
`document.title = "OpenSwarm (backend crashed)";`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await waitForBackend(backendPort);
|
||||
console.log(`Backend ready on port ${backendPort}`);
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1400,
|
||||
height: 900,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
title: 'OpenSwarm',
|
||||
icon: iconPath,
|
||||
titleBarStyle: 'hiddenInset',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
webviewTag: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (isDev) {
|
||||
mainWindow.loadURL(`http://localhost:3000`);
|
||||
} else {
|
||||
const frontendPath = getResourcePath('frontend', 'index.html');
|
||||
mainWindow.loadFile(frontendPath);
|
||||
}
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
}
|
||||
|
||||
function setupAutoUpdater() {
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
console.log(`Update available: ${info.version}`);
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.executeJavaScript(
|
||||
`window.__OPENSWARM_UPDATE_AVAILABLE__ = ${JSON.stringify(info)};`
|
||||
);
|
||||
}
|
||||
autoUpdater.downloadUpdate();
|
||||
});
|
||||
|
||||
autoUpdater.on('update-downloaded', (info) => {
|
||||
console.log(`Update downloaded: ${info.version}`);
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.executeJavaScript(
|
||||
`window.__OPENSWARM_UPDATE_DOWNLOADED__ = ${JSON.stringify(info)};`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
autoUpdater.on('error', (err) => {
|
||||
console.error('Auto-update error:', err);
|
||||
});
|
||||
|
||||
autoUpdater.checkForUpdates().catch((err) => {
|
||||
console.log('Update check skipped:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
function killBackend() {
|
||||
if (backendProcess) {
|
||||
console.log('Killing backend process...');
|
||||
backendProcess.kill('SIGTERM');
|
||||
setTimeout(() => {
|
||||
if (backendProcess && !backendProcess.killed) {
|
||||
backendProcess.kill('SIGKILL');
|
||||
}
|
||||
}, 3000);
|
||||
backendProcess = null;
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
if (process.platform === 'darwin' && !isPackaged) {
|
||||
try { app.dock.setIcon(iconPath); } catch (_) {}
|
||||
}
|
||||
|
||||
try {
|
||||
if (isDev) {
|
||||
backendPort = parseInt(process.env.OPENSWARM_PORT || '8324', 10);
|
||||
console.log(`Dev mode: using existing backend on port ${backendPort}`);
|
||||
} else {
|
||||
await startBackend();
|
||||
}
|
||||
createWindow();
|
||||
if (!isDev) {
|
||||
setupAutoUpdater();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to start:', err);
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (!isDev) killBackend();
|
||||
app.quit();
|
||||
});
|
||||
|
||||
app.on('will-quit', () => {
|
||||
if (!isDev) killBackend();
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0 && backendPort) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('get-backend-port', () => backendPort);
|
||||
Generated
+5373
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"name": "openswarm",
|
||||
"version": "1.0.0",
|
||||
"description": "OpenSwarm — AI Agent Orchestrator",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"dev": "ELECTRON_DEV=1 electron .",
|
||||
"dist": "electron-builder --mac --publish never",
|
||||
"dist:publish": "electron-builder --mac --publish always",
|
||||
"dist:all": "electron-builder --mac --win --linux"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.3.0",
|
||||
"get-port": "^5.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/notarize": "^3.1.1",
|
||||
"electron": "^33.0.0",
|
||||
"electron-builder": "^25.1.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.clusterlabs.openswarm",
|
||||
"productName": "OpenSwarm",
|
||||
"directories": {
|
||||
"output": "dist"
|
||||
},
|
||||
"icon": "build/icon.png",
|
||||
"mac": {
|
||||
"icon": "build/icon.icns",
|
||||
"target": [
|
||||
{
|
||||
"target": "dmg",
|
||||
"arch": [
|
||||
"arm64",
|
||||
"x64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": [
|
||||
"arm64",
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"category": "public.app-category.developer-tools",
|
||||
"hardenedRuntime": true,
|
||||
"entitlements": "build/entitlements.mac.plist",
|
||||
"entitlementsInherit": "build/entitlements.mac.plist"
|
||||
},
|
||||
"dmg": {
|
||||
"title": "OpenSwarm",
|
||||
"contents": [
|
||||
{
|
||||
"x": 130,
|
||||
"y": 220
|
||||
},
|
||||
{
|
||||
"x": 410,
|
||||
"y": 220,
|
||||
"type": "link",
|
||||
"path": "/Applications"
|
||||
}
|
||||
]
|
||||
},
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "../frontend/dist",
|
||||
"to": "frontend",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../backend",
|
||||
"to": "backend",
|
||||
"filter": [
|
||||
"**/*",
|
||||
"!__pycache__/**",
|
||||
"!**/__pycache__/**",
|
||||
"!.venv/**",
|
||||
"!*.pyc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../debugger",
|
||||
"to": "debugger",
|
||||
"filter": [
|
||||
"**/*",
|
||||
"!__pycache__/**",
|
||||
"!**/__pycache__/**",
|
||||
"!*.pyc",
|
||||
"!.venv/**",
|
||||
"!**/.venv/**",
|
||||
"!**/node_modules/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "python-env",
|
||||
"to": "python-env",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
}
|
||||
],
|
||||
"publish": {
|
||||
"provider": "github",
|
||||
"owner": "openswarm-ai",
|
||||
"repo": "production"
|
||||
},
|
||||
"afterSign": "scripts/notarize.js"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
(async () => {
|
||||
const port = await ipcRenderer.invoke('get-backend-port');
|
||||
|
||||
contextBridge.exposeInMainWorld('__OPENSWARM_PORT__', port);
|
||||
|
||||
contextBridge.exposeInMainWorld('openswarm', {
|
||||
getBackendPort: () => port,
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,31 @@
|
||||
const { notarize } = require('@electron/notarize');
|
||||
|
||||
exports.default = async function notarizing(context) {
|
||||
const { electronPlatformName, appOutDir } = context;
|
||||
if (electronPlatformName !== 'darwin') return;
|
||||
|
||||
if (process.env.CSC_IDENTITY_AUTO_DISCOVERY === 'false') {
|
||||
console.log('Skipping notarization (CSC_IDENTITY_AUTO_DISCOVERY=false)');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!process.env.APPLE_ID || !process.env.APPLE_TEAM_ID) {
|
||||
console.log('Skipping notarization (APPLE_ID or APPLE_TEAM_ID not set)');
|
||||
return;
|
||||
}
|
||||
|
||||
const appName = context.packager.appInfo.productFilename;
|
||||
const appPath = `${appOutDir}/${appName}.app`;
|
||||
|
||||
console.log(`Notarizing ${appPath}...`);
|
||||
|
||||
await notarize({
|
||||
appBundleId: 'com.clusterlabs.openswarm',
|
||||
appPath,
|
||||
appleId: process.env.APPLE_ID,
|
||||
appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD,
|
||||
teamId: process.env.APPLE_TEAM_ID,
|
||||
});
|
||||
|
||||
console.log('Notarization complete.');
|
||||
};
|
||||
@@ -41,6 +41,7 @@
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@types/react-redux": "^7.1.34",
|
||||
"babel-loader": "^9.2.1",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"css-loader": "^6.8.0",
|
||||
"css-modules-types-loader": "^0.6.10",
|
||||
"html-webpack-plugin": "^5.5.0",
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Open Swarm</title>
|
||||
<link rel="icon" href="/favicon.ico?v=2" sizes="16x16 32x32 48x48" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" href="./favicon.ico?v=2" sizes="16x16 32x32 48x48" />
|
||||
<link rel="apple-touch-icon" href="./apple-touch-icon.png" />
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -13,14 +13,14 @@ else
|
||||
# echo "NOT in macOS server START"
|
||||
fi
|
||||
chmod +x "$DEV_ABSPATH"
|
||||
source "$(dirname "$DEV_ABSPATH")/_utils.sh"
|
||||
|
||||
FRONTEND_DIR_ABSPATH="$(dirname "$DEV_ABSPATH")"
|
||||
|
||||
formatted_echo --green "Installing dependencies..."
|
||||
echo "Installing dependencies..."
|
||||
cd "$FRONTEND_DIR_ABSPATH"
|
||||
npm install
|
||||
|
||||
formatted_echo --green "Building with development mode..."
|
||||
echo "Building with development mode..."
|
||||
npm run dev
|
||||
|
||||
# exit back to the dir that we were in before
|
||||
@@ -1,202 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Flag processing function with namespacing and global variable declaration
|
||||
UTILS_FILE_ABSPATH="$(readlink -f "${BASH_SOURCE[0]}")"
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
# echo "In macOS utils sed START"
|
||||
# echo "UTILS_ABSPATH: $UTILS_FILE_ABSPATH"
|
||||
sed -i '' 's/\r//g' "$UTILS_FILE_ABSPATH"
|
||||
# echo "In macOS utils sed END"
|
||||
else
|
||||
# echo "NOT in macOS utils START"
|
||||
# echo "UTILS_ABSPATH: $UTILS_FILE_ABSPATH"
|
||||
sed -i 's/\r//g' "$UTILS_FILE_ABSPATH"
|
||||
# echo "NOT in macOS utils END"
|
||||
fi
|
||||
chmod +x "$UTILS_FILE_ABSPATH"
|
||||
|
||||
RUN_DIR_ABSPATH="$(dirname "$UTILS_FILE_ABSPATH")"
|
||||
FRONTEND_DIR_ABSPATH="$(dirname "$RUN_DIR_ABSPATH")"
|
||||
|
||||
formatted_error() {
|
||||
# Arguments: error message and array of conflicting flags
|
||||
local initial_message="$1"
|
||||
shift
|
||||
local conflicting_flags=("$@")
|
||||
|
||||
# Red color for the error message box
|
||||
local COLOR_CODE='\033[0;31m'
|
||||
local NC='\033[0m' # No Color
|
||||
|
||||
# Start the error message with the initial message
|
||||
local error_message="$initial_message"
|
||||
|
||||
# Add each conflicting flag on a new line with indentation
|
||||
for conflict in "${conflicting_flags[@]}"; do
|
||||
error_message+="\n $conflict" # Replacing `\t` with four spaces
|
||||
done
|
||||
|
||||
# Prepare for printing by finding max length of each line in the message
|
||||
local lines=()
|
||||
local max_length=0
|
||||
|
||||
# Use printf to interpret new lines and calculate max length with spaces instead of tabs
|
||||
while IFS= read -r line; do
|
||||
# Substitute tabs with spaces for consistent width measurement
|
||||
local line_with_spaces="${line//$'\t'/ }"
|
||||
lines+=("$line_with_spaces")
|
||||
if (( ${#line_with_spaces} > max_length )); then
|
||||
max_length=${#line_with_spaces}
|
||||
fi
|
||||
done <<< "$(printf "$error_message")"
|
||||
|
||||
# Create the top and bottom borders based on the maximum line length
|
||||
local border=$(printf '%*s' "$((max_length + 4))" '' | tr ' ' '-')
|
||||
|
||||
# Print the formatted error message with a red box
|
||||
printf "\n${COLOR_CODE}%s${NC}\n" "$border"
|
||||
for line in "${lines[@]}"; do
|
||||
printf "${COLOR_CODE}| %-*s |${NC}\n" "$max_length" "$line"
|
||||
done
|
||||
printf "${COLOR_CODE}| %-*s |${NC}\n" "$max_length" ""
|
||||
printf "${COLOR_CODE}%s${NC}\n" "$border"
|
||||
}
|
||||
|
||||
|
||||
function process_flags() {
|
||||
local -n flags_to_commands="$1" # Reference to the dictionary of flags and commands
|
||||
local -n exclusives="$2" # Reference to the list of exclusive flag groups
|
||||
local namespace="$3" # Unique prefix for variables
|
||||
local calling_script_name="$(basename "$(readlink -f "${BASH_SOURCE[1]}")")"
|
||||
local caller_id="${FUNCNAME[1]}"
|
||||
local should_exit=false
|
||||
if [ "$caller_id" == "main" ]; then
|
||||
caller_id="$calling_script_name"
|
||||
fi
|
||||
# echo "caller_id: $caller_id"
|
||||
# echo "Initializing flags with namespace: $namespace"
|
||||
|
||||
# Initialize flag variables with namespacing in the sourcing script context
|
||||
for flag in "${!flags_to_commands[@]}"; do
|
||||
# Convert flag to uppercase variable name and apply namespace, e.g., MYAPP_FLAG1
|
||||
local flag_var="${namespace}_${flag^^}" # Prefix and uppercase
|
||||
flag_var="${flag_var//-}" # Remove dashes
|
||||
eval "declare -g $flag_var=false" # Initialize as global false
|
||||
|
||||
# Diagnostic output for each initialized variable
|
||||
# echo "Initialized $flag_var as false"
|
||||
done
|
||||
|
||||
# echo "Parsing command line arguments: $@"
|
||||
|
||||
# Parse command line arguments and set flags
|
||||
local unsupported_flags=()
|
||||
# local potential_typos=()
|
||||
for arg in "$@"; do
|
||||
# echo "Processing argument: $arg"
|
||||
if [[ -n "${flags_to_commands[$arg]}" ]]; then
|
||||
# echo "Flag found: $arg"
|
||||
local flag_var="${namespace}_${arg^^}" # Prefix and uppercase
|
||||
flag_var="${flag_var//-}" # Remove dashes
|
||||
eval "declare -g $flag_var=true" # Set as global true
|
||||
# echo "Set $flag_var to true"
|
||||
# echo "Executing command for $arg: ${flags_to_commands[$arg]}"
|
||||
eval "${flags_to_commands[$arg]}"
|
||||
else
|
||||
if [[ "$arg" == "-"* ]]; then
|
||||
# echo "Flag not found: $arg"
|
||||
unsupported_flags+=("$arg")
|
||||
if [[ -n "${flags_to_commands[-$arg]}" ]]; then
|
||||
# echo "Potential typo: $arg"
|
||||
unsupported_flags+=("\t*Note: Potential typo detected.")
|
||||
unsupported_flags+=("\tDid you mean: -$arg")
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
# Check exclusive flag groups for conflicts
|
||||
# echo "Checking exclusive flag groups"
|
||||
for group in "${exclusives[@]}"; do
|
||||
local count=0
|
||||
local conflicting_flags=()
|
||||
for flag in $group; do
|
||||
local flag_var="${namespace}_${flag^^}"
|
||||
flag_var="${flag_var//-}"
|
||||
if [[ "$(eval echo "\$$flag_var")" == "true" ]]; then
|
||||
conflicting_flags+=("$flag")
|
||||
# count=$((count + 1))
|
||||
# ec "$flag_var is true in exclusive group"
|
||||
fi
|
||||
done
|
||||
if (( ${#conflicting_flags[@]} > 1 )); then
|
||||
# echo "found conflicting flags"
|
||||
formatted_error "Error: $caller_id\n-----------------------------------------\nIncompatible flags:\nThe flags below cannot be used together\n-----------------------------------------\n" "${conflicting_flags[@]}"
|
||||
should_exit=true
|
||||
fi
|
||||
done
|
||||
# echo "should_exit: $should_exit"
|
||||
# echo "unsupported_flags: ${#unsupported_flags[@]}"
|
||||
if (( ${#unsupported_flags[@]} > 0 )); then
|
||||
# echo "found unsupported flags 2"
|
||||
formatted_error "Error: $caller_id\n-------------------------------------------------\nUnsupported flags:\nThe flags below are not supported by this script\n-------------------------------------------------\n" "${unsupported_flags[@]}"
|
||||
should_exit=true
|
||||
fi
|
||||
if [[ $should_exit == true ]]; then
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
|
||||
formatted_echo() {
|
||||
local COLOR_CODE='\033[0m' # No Color
|
||||
local NC='\033[0m' # No Color variable
|
||||
# local message="$2"
|
||||
# If the second argument is empty, then theres no color specified, so we use the first argument as the message
|
||||
if [[ -z "$2" ]]; then
|
||||
message="$1"
|
||||
else
|
||||
message="$2"
|
||||
fi
|
||||
|
||||
declare -A format_flags
|
||||
format_flags=(
|
||||
[--red]="COLOR_CODE='\033[0;31m'"
|
||||
[--green]="COLOR_CODE='\033[0;32m'"
|
||||
[--yellow]="COLOR_CODE='\033[0;33m'"
|
||||
[--blue]="COLOR_CODE='\033[0;34m'"
|
||||
[--purple]="COLOR_CODE='\033[0;35m'"
|
||||
[--cyan]="COLOR_CODE='\033[0;36m'"
|
||||
)
|
||||
exclusive_format_flags=(
|
||||
"--red --green --yellow --blue --purple --cyan"
|
||||
)
|
||||
|
||||
# Pass the arguments with the namespace "FORMAT"
|
||||
process_flags format_flags exclusive_format_flags "FORMAT" "$@"
|
||||
|
||||
# Process the message
|
||||
local text
|
||||
text=$(printf "%b" "$message")
|
||||
|
||||
# Expand any escaped characters in the input (e.g., \n)
|
||||
local lines=()
|
||||
local max_length=0
|
||||
|
||||
# Read the text line by line and find the maximum length
|
||||
while IFS= read -r line; do
|
||||
lines+=("$line")
|
||||
if (( ${#line} > max_length )); then
|
||||
max_length=${#line}
|
||||
fi
|
||||
done <<< "$text"
|
||||
|
||||
# Create the top and bottom borders based on the maximum line length
|
||||
local border=$(printf '%*s' "$((max_length + 4))" '' | tr ' ' '-')
|
||||
|
||||
# Print the formatted box with selected color
|
||||
printf "\n${COLOR_CODE}%s${NC}\n" "$border"
|
||||
for line in "${lines[@]}"; do
|
||||
printf "${COLOR_CODE}| %-*s |${NC}\n" "$max_length" "$line"
|
||||
done
|
||||
printf "${COLOR_CODE}%s${NC}\n" "$border"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useMemo, useEffect } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import { HashRouter, Routes, Route } from 'react-router-dom';
|
||||
import { ThemeProvider as MuiThemeProvider, createTheme, CssBaseline } from '@mui/material';
|
||||
import { store } from '../shared/state/store';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
@@ -138,7 +138,7 @@ const ThemedApp: React.FC = () => {
|
||||
return (
|
||||
<MuiThemeProvider theme={muiTheme}>
|
||||
<CssBaseline />
|
||||
<BrowserRouter>
|
||||
<HashRouter>
|
||||
<ShortcutsProvider>
|
||||
<SettingsLoader>
|
||||
<Routes>
|
||||
@@ -155,7 +155,7 @@ const ThemedApp: React.FC = () => {
|
||||
</Routes>
|
||||
</SettingsLoader>
|
||||
</ShortcutsProvider>
|
||||
</BrowserRouter>
|
||||
</HashRouter>
|
||||
</MuiThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,8 +20,9 @@ import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutl
|
||||
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { BrowseResult } from '@/shared/state/settingsSlice';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
|
||||
const API_BASE = `http://${window.location.hostname}:8324/api/settings`;
|
||||
const SETTINGS_API = `${API_BASE}/settings`;
|
||||
|
||||
export interface ContextPath {
|
||||
path: string;
|
||||
@@ -48,7 +49,7 @@ const DirectoryBrowser: React.FC<DirectoryBrowserProps> = ({ open, onClose, onSe
|
||||
setError(null);
|
||||
setSelected(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/browse-directories?path=${encodeURIComponent(path)}`);
|
||||
const res = await fetch(`${SETTINGS_API}/browse-directories?path=${encodeURIComponent(path)}`);
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.detail || 'Failed to browse');
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface SelectedElement {
|
||||
computedStyles: Record<string, string>;
|
||||
screenshot?: string;
|
||||
boundingRect: { x: number; y: number; width: number; height: number };
|
||||
semanticType?: 'agent-card' | 'message' | 'tool-call' | 'tool-group' | 'view-card' | 'dom-element';
|
||||
semanticType?: 'agent-card' | 'message' | 'tool-call' | 'tool-group' | 'view-card' | 'browser-card' | 'dom-element';
|
||||
semanticLabel?: string;
|
||||
semanticData?: Record<string, any>;
|
||||
}
|
||||
@@ -18,6 +18,8 @@ interface ElementSelectionContextValue {
|
||||
selectMode: boolean;
|
||||
toggleSelectMode: () => void;
|
||||
setSelectMode: (active: boolean) => void;
|
||||
excludeSelectId: string | null;
|
||||
setExcludeSelectId: (id: string | null) => void;
|
||||
selectedElements: SelectedElement[];
|
||||
addSelectedElement: (el: SelectedElement) => void;
|
||||
updateSelectedElement: (id: string, patch: Partial<SelectedElement>) => void;
|
||||
@@ -34,11 +36,15 @@ export function useElementSelection() {
|
||||
|
||||
export const ElementSelectionProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const [excludeSelectId, setExcludeSelectId] = useState<string | null>(null);
|
||||
const [selectedElements, setSelectedElements] = useState<SelectedElement[]>([]);
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
|
||||
const toggleSelectMode = useCallback(() => {
|
||||
setSelectMode((prev) => !prev);
|
||||
setSelectMode((prev) => {
|
||||
if (prev) setExcludeSelectId(null);
|
||||
return !prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const addSelectedElement = useCallback((el: SelectedElement) => {
|
||||
@@ -66,6 +72,8 @@ export const ElementSelectionProvider: React.FC<{ children: React.ReactNode }> =
|
||||
selectMode,
|
||||
toggleSelectMode,
|
||||
setSelectMode,
|
||||
excludeSelectId,
|
||||
setExcludeSelectId,
|
||||
selectedElements,
|
||||
addSelectedElement,
|
||||
updateSelectedElement,
|
||||
|
||||
@@ -10,6 +10,8 @@ const shortcuts = [
|
||||
{ key: 'd', description: 'Go to Dashboard' },
|
||||
{ key: 't', description: 'Go to Templates' },
|
||||
{ key: '1-9', description: 'Open agent by position' },
|
||||
{ key: '⌘M', description: 'Add View' },
|
||||
{ key: '⌘O', description: 'History' },
|
||||
{ key: 'Shift+A', description: 'Approve all pending' },
|
||||
{ key: 'Shift+D', description: 'Deny all pending' },
|
||||
{ key: '?', description: 'Show this help' },
|
||||
|
||||
@@ -20,6 +20,9 @@ import ViewQuiltIcon from '@mui/icons-material/ViewQuilt';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import ViewSidebarOutlinedIcon from '@mui/icons-material/ViewSidebarOutlined';
|
||||
import ArrowBackOutlinedIcon from '@mui/icons-material/ArrowBackOutlined';
|
||||
import ArrowForwardOutlinedIcon from '@mui/icons-material/ArrowForwardOutlined';
|
||||
import Settings from '@/app/pages/Settings/Settings';
|
||||
import GlobalApprovalOverlay from '@/app/components/GlobalApprovalOverlay';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
@@ -42,6 +45,7 @@ const AppShell: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [dashboardsExpanded, setDashboardsExpanded] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
|
||||
const dashboardItems = useAppSelector((state) => state.dashboards.items);
|
||||
const dashboardList = Object.values(dashboardItems).sort(
|
||||
@@ -79,7 +83,101 @@ const AppShell: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', height: '100vh', bgcolor: c.bg.page }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100vh', bgcolor: c.bg.page }}>
|
||||
{/* Draggable title bar */}
|
||||
<Box
|
||||
sx={{
|
||||
height: 38,
|
||||
flexShrink: 0,
|
||||
bgcolor: c.bg.secondary,
|
||||
borderBottom: `0.5px solid ${c.border.medium}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
WebkitAppRegion: 'drag',
|
||||
userSelect: 'none',
|
||||
pl: '78px',
|
||||
gap: 0.25,
|
||||
}}
|
||||
>
|
||||
<Tooltip title={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setSidebarCollapsed((prev) => !prev)}
|
||||
sx={{
|
||||
WebkitAppRegion: 'no-drag',
|
||||
color: c.text.tertiary,
|
||||
p: 0.5,
|
||||
borderRadius: 1,
|
||||
'&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}14` },
|
||||
}}
|
||||
>
|
||||
<ViewSidebarOutlinedIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Back">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => navigate(-1)}
|
||||
sx={{
|
||||
WebkitAppRegion: 'no-drag',
|
||||
color: c.text.tertiary,
|
||||
p: 0.5,
|
||||
borderRadius: 1,
|
||||
'&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}14` },
|
||||
}}
|
||||
>
|
||||
<ArrowBackOutlinedIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Forward">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => navigate(1)}
|
||||
sx={{
|
||||
WebkitAppRegion: 'no-drag',
|
||||
color: c.text.tertiary,
|
||||
p: 0.5,
|
||||
borderRadius: 1,
|
||||
'&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}14` },
|
||||
}}
|
||||
>
|
||||
<ArrowForwardOutlinedIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Box sx={{ flex: 1 }} />
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
pr: 1.5,
|
||||
WebkitAppRegion: 'no-drag',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src="./logo.png"
|
||||
alt="OpenSwarm"
|
||||
sx={{ width: 18, height: 18, borderRadius: 0.5, opacity: 0.7 }}
|
||||
/>
|
||||
<Typography
|
||||
sx={{
|
||||
color: c.text.tertiary,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
letterSpacing: 0.3,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
OpenSwarm
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', flex: 1, minHeight: 0 }}>
|
||||
{!sidebarCollapsed && (
|
||||
<Box
|
||||
sx={{
|
||||
width: 240,
|
||||
@@ -90,25 +188,6 @@ const AppShell: React.FC = () => {
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 2.5, borderBottom: `0.5px solid ${c.border.medium}`, display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Box
|
||||
component="img"
|
||||
src="/logo.png"
|
||||
alt="Open Swarm"
|
||||
sx={{ width: 36, height: 36, borderRadius: 1, flexShrink: 0 }}
|
||||
/>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{ color: c.text.primary, fontWeight: 700, letterSpacing: 0.5, lineHeight: 1.2 }}
|
||||
>
|
||||
Open Swarm
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: c.text.tertiary }}>
|
||||
Agent Orchestrator
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<List sx={{ pt: 1, px: 1, flex: 1, overflow: 'auto'}}>
|
||||
<ListItemButton
|
||||
onClick={handleDashboardsClick}
|
||||
@@ -289,10 +368,12 @@ const AppShell: React.FC = () => {
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ flex: 1, overflow: 'hidden', bgcolor: c.bg.page }}>
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Settings />
|
||||
<GlobalApprovalOverlay />
|
||||
|
||||
@@ -31,12 +31,16 @@ const SEMANTIC_LABELS: Record<string, string> = {
|
||||
'tool-call': 'Tool Call',
|
||||
'tool-group': 'Tool Group',
|
||||
'view-card': 'View',
|
||||
'browser-card': 'Browser',
|
||||
};
|
||||
|
||||
function findSelectableAncestor(target: Element): Element | null {
|
||||
function findSelectableAncestor(target: Element, excludeId?: string | null): Element | null {
|
||||
let current: Element | null = target;
|
||||
while (current) {
|
||||
if (current.hasAttribute(SELECT_ATTR)) return current;
|
||||
if (current.hasAttribute(SELECT_ATTR)) {
|
||||
if (excludeId && current.getAttribute(SELECT_ID_ATTR) === excludeId) return null;
|
||||
return current;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return null;
|
||||
@@ -114,6 +118,11 @@ export function useDomElementSelector(): DomSelectorState {
|
||||
const isDraggingRef = useRef(false);
|
||||
const dragBoundsRef = useRef<{ left: number; top: number; right: number; bottom: number } | null>(null);
|
||||
|
||||
const excludeIdRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
excludeIdRef.current = ctx?.excludeSelectId ?? null;
|
||||
}, [ctx?.excludeSelectId]);
|
||||
|
||||
const selectedIdsRef = useRef(new Map<string, string>());
|
||||
useEffect(() => {
|
||||
const map = new Map<string, string>();
|
||||
@@ -161,10 +170,12 @@ export function useDomElementSelector(): DomSelectorState {
|
||||
const allSelectables = document.querySelectorAll(`[${SELECT_ATTR}]`);
|
||||
const preview: DragPreviewElement[] = [];
|
||||
const seen = new Set<string>();
|
||||
const excId = excludeIdRef.current;
|
||||
allSelectables.forEach((el) => {
|
||||
const selectId = el.getAttribute(SELECT_ID_ATTR) || '';
|
||||
if (excId && selectId === excId) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rectsIntersect(b, { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom })) {
|
||||
const selectId = el.getAttribute(SELECT_ID_ATTR) || '';
|
||||
if (seen.has(selectId)) return;
|
||||
seen.add(selectId);
|
||||
const type = el.getAttribute(SELECT_ATTR) || '';
|
||||
@@ -200,7 +211,7 @@ export function useDomElementSelector(): DomSelectorState {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectable = findSelectableAncestor(target);
|
||||
const selectable = findSelectableAncestor(target, excludeIdRef.current);
|
||||
if (!selectable) {
|
||||
setOverlay(EMPTY_OVERLAY);
|
||||
hoveredRef.current = null;
|
||||
@@ -231,7 +242,7 @@ export function useDomElementSelector(): DomSelectorState {
|
||||
if (e.button !== 0) return;
|
||||
const target = e.target as Element;
|
||||
// Only start drag on "empty" canvas areas (not on selectable elements)
|
||||
if (target && findSelectableAncestor(target)) return;
|
||||
if (target && findSelectableAncestor(target, excludeIdRef.current)) return;
|
||||
dragOriginRef.current = { x: e.clientX, y: e.clientY };
|
||||
isDraggingRef.current = false;
|
||||
}, []);
|
||||
@@ -250,7 +261,10 @@ export function useDomElementSelector(): DomSelectorState {
|
||||
const allSelectables = document.querySelectorAll(`[${SELECT_ATTR}]`);
|
||||
const processed = new Set<string>();
|
||||
|
||||
const excId = excludeIdRef.current;
|
||||
allSelectables.forEach((el) => {
|
||||
const selectId = el.getAttribute(SELECT_ID_ATTR) || '';
|
||||
if (excId && selectId === excId) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const elRect = {
|
||||
left: rect.left,
|
||||
@@ -260,7 +274,6 @@ export function useDomElementSelector(): DomSelectorState {
|
||||
};
|
||||
|
||||
if (rectsIntersect(dr, elRect)) {
|
||||
const selectId = el.getAttribute(SELECT_ID_ATTR) || '';
|
||||
if (processed.has(selectId)) return;
|
||||
processed.add(selectId);
|
||||
|
||||
|
||||
@@ -629,6 +629,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
isRunning={!isDraft && (session.status === 'running' || session.status === 'waiting_approval')}
|
||||
onStop={handleStop}
|
||||
contextEstimate={contextEstimate}
|
||||
sessionId={id}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -26,6 +26,8 @@ import AttachFileIcon from '@mui/icons-material/AttachFile';
|
||||
import AdsClickIcon from '@mui/icons-material/AdsClick';
|
||||
import CommandPicker, { CommandPickerItem, getToolGroupIcon } from '@/app/components/CommandPicker';
|
||||
import { useElementSelection, SelectedElement } from '@/app/components/ElementSelectionContext';
|
||||
import { getWebview } from '@/shared/browserRegistry';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
import { ContextPath } from '@/app/components/DirectoryBrowser';
|
||||
import {
|
||||
SKILL_PILL_ATTR,
|
||||
@@ -70,6 +72,7 @@ interface Props {
|
||||
contextEstimate?: { used: number; limit: number };
|
||||
embedded?: boolean;
|
||||
autoFocus?: boolean;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export interface ChatInputHandle {
|
||||
@@ -127,7 +130,7 @@ const ContextRing: React.FC<{ used: number; limit: number; accentColor: string;
|
||||
);
|
||||
};
|
||||
|
||||
const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode, onModeChange, model, onModelChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus }, ref) => {
|
||||
const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode, onModeChange, model, onModelChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId }, ref) => {
|
||||
const c = useClaudeTokens();
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -253,7 +256,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
|
||||
try {
|
||||
const formData = new FormData();
|
||||
files.forEach((f) => formData.append('files', f));
|
||||
const resp = await fetch(`http://${window.location.hostname}:8324/api/settings/upload-files`, {
|
||||
const resp = await fetch(`${API_BASE}/settings/upload-files`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
@@ -271,7 +274,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const handleSend = useCallback(async () => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor || disabled) return;
|
||||
const serialized = serializeEditorContent(editor, attachedSkillsRef.current);
|
||||
@@ -285,14 +288,47 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
|
||||
|
||||
if (selectedEls.length > 0) {
|
||||
const lines: string[] = ['\n\n---\nSelected UI Elements:\n'];
|
||||
selectedEls.forEach((el, i) => {
|
||||
if (el.semanticType && el.semanticData) {
|
||||
for (let i = 0; i < selectedEls.length; i++) {
|
||||
const el = selectedEls[i];
|
||||
|
||||
if (el.semanticType === 'browser-card' && el.semanticData?.selectId) {
|
||||
const wv = getWebview(el.semanticData.selectId as string);
|
||||
if (wv) {
|
||||
const url = el.semanticData.url || wv.getURL();
|
||||
const title = el.semanticData.name || wv.getTitle();
|
||||
lines.push(`${i + 1}. [Browser Card] ${title}`);
|
||||
lines.push(` ID: ${el.semanticData.selectId}`);
|
||||
lines.push(` URL: ${url}`);
|
||||
|
||||
try {
|
||||
const nativeImage = await wv.capturePage();
|
||||
const dataUrl = nativeImage.toDataURL();
|
||||
const base64 = dataUrl.replace(/^data:image\/\w+;base64,/, '');
|
||||
allImages.push({ data: base64, media_type: 'image/png' });
|
||||
lines.push(` [Screenshot captured and attached as image]`);
|
||||
} catch { /* screenshot unavailable */ }
|
||||
|
||||
try {
|
||||
const pageText: string = await wv.executeJavaScript(
|
||||
'document.body.innerText.substring(0, 15000)'
|
||||
);
|
||||
if (pageText?.trim()) {
|
||||
lines.push(` Page text content:\n ---\n${pageText.trim().split('\n').map(l => ' ' + l).join('\n')}\n ---`);
|
||||
}
|
||||
} catch { /* text extraction unavailable */ }
|
||||
} else {
|
||||
lines.push(`${i + 1}. [Browser Card] ${el.semanticLabel || ''}`);
|
||||
if (el.semanticData.selectId) lines.push(` ID: ${el.semanticData.selectId}`);
|
||||
if (el.semanticData.url) lines.push(` URL: ${el.semanticData.url}`);
|
||||
}
|
||||
} else if (el.semanticType && el.semanticData) {
|
||||
const typeLabel = {
|
||||
'agent-card': 'Agent Card',
|
||||
'message': 'Message',
|
||||
'tool-call': 'Tool Call',
|
||||
'tool-group': 'Tool Group',
|
||||
'view-card': 'View Card',
|
||||
'browser-card': 'Browser Card',
|
||||
'dom-element': 'Element',
|
||||
}[el.semanticType] || el.semanticType;
|
||||
lines.push(`${i + 1}. [${typeLabel}] ${el.semanticLabel || ''}`);
|
||||
@@ -318,7 +354,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
|
||||
const base64 = el.screenshot.replace(/^data:image\/\w+;base64,/, '');
|
||||
allImages.push({ data: base64, media_type: 'image/png' });
|
||||
}
|
||||
});
|
||||
}
|
||||
trimmed += lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -963,7 +999,12 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
|
||||
<Tooltip title={elementSelection.selectMode ? 'Exit select mode' : 'Select UI element'}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={elementSelection.toggleSelectMode}
|
||||
onClick={() => {
|
||||
if (!elementSelection.selectMode && sessionId) {
|
||||
elementSelection.setExcludeSelectId(sessionId);
|
||||
}
|
||||
elementSelection.toggleSelectMode();
|
||||
}}
|
||||
sx={{
|
||||
p: 0.5,
|
||||
...(elementSelection.selectMode
|
||||
|
||||
@@ -6,8 +6,9 @@ import Tooltip from '@mui/material/Tooltip';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import DifferenceIcon from '@mui/icons-material/Difference';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
|
||||
const API_BASE = `http://${window.location.hostname}:8324/api/agents`;
|
||||
const AGENTS_API = `${API_BASE}/agents`;
|
||||
|
||||
interface Props {
|
||||
sessionId: string;
|
||||
@@ -22,7 +23,7 @@ const DiffViewer: React.FC<Props> = ({ sessionId }) => {
|
||||
const fetchDiff = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/sessions/${sessionId}/diff`);
|
||||
const res = await fetch(`${AGENTS_API}/sessions/${sessionId}/diff`);
|
||||
const data = await res.json();
|
||||
setDiff(data.diff || '');
|
||||
} catch {
|
||||
|
||||
@@ -10,6 +10,7 @@ import OpenInFullIcon from '@mui/icons-material/OpenInFull';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import { useAppSelector } from '@/shared/hooks';
|
||||
import { SERVE_BASE } from '@/shared/state/outputsSlice';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import ViewPreview from '../Views/ViewPreview';
|
||||
|
||||
@@ -41,7 +42,7 @@ const ViewBubble: React.FC<Props> = ({ toolInput, toolResult, isStreaming }) =>
|
||||
const outputColor = c.accent.primary;
|
||||
const outputIcon = output?.icon || 'view_quilt';
|
||||
const hasPreview = !!frontendCode.trim();
|
||||
const serveUrl = outputId ? `/api/outputs/${outputId}/serve/index.html` : undefined;
|
||||
const serveUrl = outputId ? `${SERVE_BASE}/${outputId}/serve/index.html` : undefined;
|
||||
const inputEntries = Object.entries(inputData);
|
||||
|
||||
if (isStreaming && !hasPreview) {
|
||||
|
||||
@@ -169,6 +169,12 @@ interface Props {
|
||||
cardHeight: number;
|
||||
zoom?: number;
|
||||
spawnFrom?: { x: number; y: number };
|
||||
isSelected?: boolean;
|
||||
multiDragDelta?: { dx: number; dy: number } | null;
|
||||
onCardSelect?: (id: string, type: 'agent' | 'view', shiftKey: boolean) => void;
|
||||
onDragStart?: (id: string, type: 'agent' | 'view') => void;
|
||||
onDragMove?: (dx: number, dy: number) => void;
|
||||
onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void;
|
||||
}
|
||||
|
||||
const MIN_W = 480;
|
||||
@@ -177,7 +183,10 @@ const EXPANDED_OVERLAY_H = 620;
|
||||
|
||||
const SPAWN_SPRING = { type: 'spring' as const, stiffness: 400, damping: 28, mass: 0.6 };
|
||||
|
||||
const AgentCard: React.FC<Props> = ({ session, expanded, cardX, cardY, cardWidth, cardHeight, zoom = 1, spawnFrom }) => {
|
||||
const AgentCard: React.FC<Props> = ({
|
||||
session, expanded, cardX, cardY, cardWidth, cardHeight, zoom = 1, spawnFrom,
|
||||
isSelected = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd,
|
||||
}) => {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
@@ -199,6 +208,7 @@ const AgentCard: React.FC<Props> = ({ session, expanded, cardX, cardY, cardWidth
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [localDragPos, setLocalDragPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const didDrag = useRef(false);
|
||||
const justDraggedRef = useRef(false);
|
||||
|
||||
const handleDragPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -207,7 +217,8 @@ const AgentCard: React.FC<Props> = ({ session, expanded, cardX, cardY, cardWidth
|
||||
didDrag.current = false;
|
||||
setIsDragging(true);
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}, [cardX, cardY]);
|
||||
onDragStart?.(session.id, 'agent');
|
||||
}, [cardX, cardY, onDragStart, session.id]);
|
||||
|
||||
const handleDragPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragState.current) return;
|
||||
@@ -215,31 +226,35 @@ const AgentCard: React.FC<Props> = ({ session, expanded, cardX, cardY, cardWidth
|
||||
const rawDy = e.clientY - dragState.current.startY;
|
||||
if (!didDrag.current && Math.sqrt(rawDx * rawDx + rawDy * rawDy) < DRAG_THRESHOLD) return;
|
||||
didDrag.current = true;
|
||||
const dx = rawDx / zoom;
|
||||
const dy = rawDy / zoom;
|
||||
setLocalDragPos({
|
||||
x: dragState.current.origX + rawDx / zoom,
|
||||
y: dragState.current.origY + rawDy / zoom,
|
||||
x: dragState.current.origX + dx,
|
||||
y: dragState.current.origY + dy,
|
||||
});
|
||||
}, [zoom]);
|
||||
onDragMove?.(dx, dy);
|
||||
}, [zoom, onDragMove]);
|
||||
|
||||
const handleDragPointerUp = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragState.current) return;
|
||||
const dx = (e.clientX - dragState.current.startX) / zoom;
|
||||
const dy = (e.clientY - dragState.current.startY) / zoom;
|
||||
if (didDrag.current) {
|
||||
const dx = (e.clientX - dragState.current.startX) / zoom;
|
||||
const dy = (e.clientY - dragState.current.startY) / zoom;
|
||||
dispatch(setCardPosition({
|
||||
sessionId: session.id,
|
||||
x: dragState.current.origX + dx,
|
||||
y: dragState.current.origY + dy,
|
||||
}));
|
||||
} else if (expanded) {
|
||||
dispatch(toggleExpandSession(session.id));
|
||||
justDraggedRef.current = true;
|
||||
requestAnimationFrame(() => { justDraggedRef.current = false; });
|
||||
}
|
||||
onDragEnd?.(dx, dy, didDrag.current);
|
||||
dragState.current = null;
|
||||
didDrag.current = false;
|
||||
setLocalDragPos(null);
|
||||
setIsDragging(false);
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}, [zoom, dispatch, session.id, expanded]);
|
||||
}, [zoom, dispatch, session.id, onDragEnd]);
|
||||
|
||||
// ---- Unified edge / corner resize ----
|
||||
const resizeRef = useRef<{
|
||||
@@ -351,10 +366,12 @@ const AgentCard: React.FC<Props> = ({ session, expanded, cardX, cardY, cardWidth
|
||||
const pendingReq = session.pending_approvals[0];
|
||||
const statusStyle = STATUS_COLORS[session.status] || { color: c.text.tertiary, bg: c.bg.secondary };
|
||||
|
||||
const noTransition = isDragging || isResizing;
|
||||
const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta);
|
||||
|
||||
const activeX = localResize?.x ?? localDragPos?.x ?? cardX;
|
||||
const activeY = localResize?.y ?? localDragPos?.y ?? cardY;
|
||||
const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0;
|
||||
const mdDy = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dy : 0;
|
||||
const activeX = localResize?.x ?? localDragPos?.x ?? (cardX + mdDx);
|
||||
const activeY = localResize?.y ?? localDragPos?.y ?? (cardY + mdDy);
|
||||
const activeW = localResize?.w ?? cardWidth;
|
||||
const activeH = localResize?.h ?? cardHeight;
|
||||
|
||||
@@ -380,26 +397,38 @@ const AgentCard: React.FC<Props> = ({ session, expanded, cardX, cardY, cardWidth
|
||||
data-select-type="agent-card"
|
||||
data-select-id={session.id}
|
||||
data-select-meta={JSON.stringify({ name: session.name || session.id, status: session.status, model: session.model, mode: session.mode })}
|
||||
onClick={expanded ? undefined : () => dispatch(toggleExpandSession(session.id))}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
if (justDraggedRef.current) return;
|
||||
onCardSelect?.(session.id, 'agent', e.shiftKey);
|
||||
}}
|
||||
onDoubleClick={() => dispatch(toggleExpandSession(session.id))}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: localResize ? activeW : Math.max(cardWidth, MIN_W),
|
||||
height: localResize ? activeH : (expanded ? Math.max(EXPANDED_OVERLAY_H, cardHeight) : 'auto'),
|
||||
bgcolor: c.bg.surface,
|
||||
border: hasPending && !expanded
|
||||
? `1px solid ${c.status.warning}`
|
||||
: expanded
|
||||
? `1px solid ${c.border.strong}`
|
||||
: `1px solid ${c.border.subtle}`,
|
||||
border: isSelected
|
||||
? '2px solid #3b82f6'
|
||||
: hasPending && !expanded
|
||||
? `1px solid ${c.status.warning}`
|
||||
: expanded
|
||||
? `1px solid ${c.border.strong}`
|
||||
: `1px solid ${c.border.subtle}`,
|
||||
borderRadius: 3,
|
||||
p: 2,
|
||||
cursor: expanded ? 'default' : 'pointer',
|
||||
transition: noTransition ? 'none' : c.transition,
|
||||
boxShadow: isDragging ? c.shadow.lg : expanded ? c.shadow.md : c.shadow.sm,
|
||||
boxShadow: isDragging
|
||||
? c.shadow.lg
|
||||
: isSelected
|
||||
? `0 0 0 1px #3b82f6, ${c.shadow.md}`
|
||||
: expanded
|
||||
? c.shadow.md
|
||||
: c.shadow.sm,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
...(!expanded && !isDragging && {
|
||||
...(!expanded && !isDragging && !isSelected && {
|
||||
'&:hover': {
|
||||
boxShadow: c.shadow.md,
|
||||
borderColor: hasPending ? c.status.warning : c.border.strong,
|
||||
@@ -426,120 +455,131 @@ const AgentCard: React.FC<Props> = ({ session, expanded, cardX, cardY, cardWidth
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Header: always visible – entire bar is draggable */}
|
||||
{/* Drag zone: header + metadata – entire region above separator is draggable */}
|
||||
<Box
|
||||
onPointerDown={handleDragPointerDown}
|
||||
onPointerMove={handleDragPointerMove}
|
||||
onPointerUp={handleDragPointerUp}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
mb: 1,
|
||||
flexShrink: 0,
|
||||
mx: -2,
|
||||
mt: -2,
|
||||
px: 2,
|
||||
pt: 2,
|
||||
pb: 1.5,
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
touchAction: 'none',
|
||||
userSelect: 'none',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
className="drag-handle"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mr: 0.5,
|
||||
color: c.text.ghost,
|
||||
justifyContent: 'space-between',
|
||||
mb: 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<DragIndicatorIcon sx={{ fontSize: 16 }} />
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{session.name}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={session.status.replace('_', ' ')}
|
||||
size="small"
|
||||
<Box
|
||||
className="drag-handle"
|
||||
sx={{
|
||||
bgcolor: statusStyle.bg,
|
||||
color: statusStyle.color,
|
||||
fontWeight: 600,
|
||||
fontSize: '0.7rem',
|
||||
height: 22,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mr: 0.5,
|
||||
color: c.text.ghost,
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<DragIndicatorIcon sx={{ fontSize: 16 }} />
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{session.name}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={session.status.replace('_', ' ')}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: statusStyle.bg,
|
||||
color: statusStyle.color,
|
||||
fontWeight: 600,
|
||||
fontSize: '0.7rem',
|
||||
height: 22,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0, ml: 0.5 }}
|
||||
>
|
||||
{expanded ? (
|
||||
<Tooltip title="Collapse">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleCollapse}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
sx={{
|
||||
color: c.text.ghost,
|
||||
p: 0.5,
|
||||
'&:hover': { color: c.text.secondary, bgcolor: c.bg.secondary },
|
||||
}}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title={isDraft ? 'Remove' : 'Close chat'}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleRemove}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
sx={{
|
||||
color: c.text.ghost,
|
||||
p: 0.5,
|
||||
'&:hover': { color: c.status.error, bgcolor: `${c.status.errorBg}` },
|
||||
}}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0, ml: 0.5 }}
|
||||
>
|
||||
{expanded ? (
|
||||
<Tooltip title="Collapse">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleCollapse}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
sx={{
|
||||
color: c.text.ghost,
|
||||
p: 0.5,
|
||||
'&:hover': { color: c.text.secondary, bgcolor: c.bg.secondary },
|
||||
}}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title={isDraft ? 'Remove' : 'Close chat'}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleRemove}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
sx={{
|
||||
color: c.text.ghost,
|
||||
p: 0.5,
|
||||
'&:hover': { color: c.status.error, bgcolor: `${c.status.errorBg}` },
|
||||
}}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{/* Metadata row */}
|
||||
<Box sx={{
|
||||
display: isDraft && !expanded ? 'none' : 'flex',
|
||||
gap: 1.5,
|
||||
flexShrink: 0,
|
||||
...(isDraft && { visibility: 'hidden' }),
|
||||
}}>
|
||||
<Typography variant="caption" sx={{ color: c.text.tertiary }}>
|
||||
{session.model}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: c.text.tertiary }}>
|
||||
{session.mode}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: c.text.tertiary }}>
|
||||
{formatDuration(session.created_at)}
|
||||
</Typography>
|
||||
{session.cost_usd > 0 && (
|
||||
<Typography variant="caption" sx={{ color: c.accent.primary }}>
|
||||
${session.cost_usd.toFixed(4)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Metadata row */}
|
||||
<Box sx={{
|
||||
display: isDraft && !expanded ? 'none' : 'flex',
|
||||
gap: 1.5,
|
||||
mb: 1.5,
|
||||
flexShrink: 0,
|
||||
...(isDraft && { visibility: 'hidden' }),
|
||||
}}>
|
||||
<Typography variant="caption" sx={{ color: c.text.tertiary }}>
|
||||
{session.model}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: c.text.tertiary }}>
|
||||
{session.mode}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: c.text.tertiary }}>
|
||||
{formatDuration(session.created_at)}
|
||||
</Typography>
|
||||
{session.cost_usd > 0 && (
|
||||
<Typography variant="caption" sx={{ color: c.accent.primary }}>
|
||||
${session.cost_usd.toFixed(4)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Expanded: inline chat fills remaining space */}
|
||||
{expanded && (
|
||||
<Box
|
||||
|
||||
@@ -0,0 +1,779 @@
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import InputBase from '@mui/material/InputBase';
|
||||
import LinearProgress from '@mui/material/LinearProgress';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import LanguageIcon from '@mui/icons-material/Language';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import LockIcon from '@mui/icons-material/Lock';
|
||||
import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
|
||||
import {
|
||||
setBrowserCardPosition,
|
||||
setBrowserCardSize,
|
||||
removeBrowserCard,
|
||||
updateBrowserCardUrl,
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { registerWebview, unregisterWebview, type BrowserWebview } from '@/shared/browserRegistry';
|
||||
import { useBrowserActivity } from '@/shared/useBrowserActivity';
|
||||
import { getActionLabel } from '@/shared/browserCommandHandler';
|
||||
|
||||
type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
|
||||
|
||||
const EDGE_THICKNESS = 6;
|
||||
const CORNER_SIZE = 14;
|
||||
const MIN_W = 400;
|
||||
const MIN_H = 300;
|
||||
|
||||
const CURSOR_MAP: Record<ResizeDir, string> = {
|
||||
n: 'ns-resize', s: 'ns-resize', e: 'ew-resize', w: 'ew-resize',
|
||||
nw: 'nwse-resize', se: 'nwse-resize', ne: 'nesw-resize', sw: 'nesw-resize',
|
||||
};
|
||||
|
||||
const HANDLE_DEFS: { dir: ResizeDir; sx: Record<string, any> }[] = [
|
||||
{ dir: 'n', sx: { top: -EDGE_THICKNESS / 2, left: CORNER_SIZE, right: CORNER_SIZE, height: EDGE_THICKNESS } },
|
||||
{ dir: 's', sx: { bottom: -EDGE_THICKNESS / 2, left: CORNER_SIZE, right: CORNER_SIZE, height: EDGE_THICKNESS } },
|
||||
{ dir: 'w', sx: { left: -EDGE_THICKNESS / 2, top: CORNER_SIZE, bottom: CORNER_SIZE, width: EDGE_THICKNESS } },
|
||||
{ dir: 'e', sx: { right: -EDGE_THICKNESS / 2, top: CORNER_SIZE, bottom: CORNER_SIZE, width: EDGE_THICKNESS } },
|
||||
{ dir: 'nw', sx: { top: -EDGE_THICKNESS / 2, left: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } },
|
||||
{ dir: 'ne', sx: { top: -EDGE_THICKNESS / 2, right: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } },
|
||||
{ dir: 'sw', sx: { bottom: -EDGE_THICKNESS / 2, left: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } },
|
||||
{ dir: 'se', sx: { bottom: -EDGE_THICKNESS / 2, right: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } },
|
||||
];
|
||||
|
||||
const isElectron = navigator.userAgent.includes('Electron');
|
||||
|
||||
type WebviewElement = BrowserWebview;
|
||||
|
||||
interface Props {
|
||||
browserId: string;
|
||||
url: string;
|
||||
cardX: number;
|
||||
cardY: number;
|
||||
cardWidth: number;
|
||||
cardHeight: number;
|
||||
zoom?: number;
|
||||
isSelected?: boolean;
|
||||
multiDragDelta?: { dx: number; dy: number } | null;
|
||||
onCardSelect?: (id: string, type: 'agent' | 'view' | 'browser', shiftKey: boolean) => void;
|
||||
onDragStart?: (id: string, type: 'agent' | 'view' | 'browser') => void;
|
||||
onDragMove?: (dx: number, dy: number) => void;
|
||||
onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void;
|
||||
}
|
||||
|
||||
function ensureProtocol(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
||||
if (/^[a-zA-Z0-9-]+\.[a-zA-Z]{2,}/.test(trimmed)) return `https://${trimmed}`;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
const BrowserCard: React.FC<Props> = ({
|
||||
browserId, url, cardX, cardY, cardWidth, cardHeight, zoom = 1,
|
||||
isSelected = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd,
|
||||
}) => {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const webviewRef = useRef<WebviewElement | null>(null);
|
||||
const activity = useBrowserActivity(browserId);
|
||||
const agentActive = activity.active;
|
||||
const agentAction = activity.action;
|
||||
const lastAction = activity.lastAction;
|
||||
|
||||
const [currentUrl, setCurrentUrl] = useState(url);
|
||||
const [urlBarValue, setUrlBarValue] = useState(url);
|
||||
const [pageTitle, setPageTitle] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [canGoBack, setCanGoBack] = useState(false);
|
||||
const [canGoForward, setCanGoForward] = useState(false);
|
||||
|
||||
// ---- Webview event wiring ----
|
||||
useEffect(() => {
|
||||
if (!isElectron) return;
|
||||
const wv = webviewRef.current;
|
||||
if (!wv) return;
|
||||
|
||||
const onNavigate = () => {
|
||||
const newUrl = wv.getURL();
|
||||
setCurrentUrl(newUrl);
|
||||
setUrlBarValue(newUrl);
|
||||
setCanGoBack(wv.canGoBack());
|
||||
setCanGoForward(wv.canGoForward());
|
||||
dispatch(updateBrowserCardUrl({ browserId, url: newUrl }));
|
||||
};
|
||||
|
||||
const onTitleUpdate = () => {
|
||||
setPageTitle(wv.getTitle());
|
||||
};
|
||||
|
||||
const onLoadStart = () => setLoading(true);
|
||||
const onLoadStop = () => {
|
||||
setLoading(false);
|
||||
onNavigate();
|
||||
onTitleUpdate();
|
||||
};
|
||||
|
||||
const onNewWindow = (e: any) => {
|
||||
if (e.url) wv.loadURL(e.url);
|
||||
};
|
||||
|
||||
wv.addEventListener('did-navigate', onNavigate);
|
||||
wv.addEventListener('did-navigate-in-page', onNavigate);
|
||||
wv.addEventListener('page-title-updated', onTitleUpdate);
|
||||
wv.addEventListener('did-start-loading', onLoadStart);
|
||||
wv.addEventListener('did-stop-loading', onLoadStop);
|
||||
wv.addEventListener('new-window', onNewWindow);
|
||||
|
||||
return () => {
|
||||
wv.removeEventListener('did-navigate', onNavigate);
|
||||
wv.removeEventListener('did-navigate-in-page', onNavigate);
|
||||
wv.removeEventListener('page-title-updated', onTitleUpdate);
|
||||
wv.removeEventListener('did-start-loading', onLoadStart);
|
||||
wv.removeEventListener('did-stop-loading', onLoadStop);
|
||||
wv.removeEventListener('new-window', onNewWindow);
|
||||
};
|
||||
}, [browserId, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isElectron) return;
|
||||
const wv = webviewRef.current;
|
||||
if (!wv) return;
|
||||
registerWebview(browserId, wv);
|
||||
return () => { unregisterWebview(browserId); };
|
||||
}, [browserId]);
|
||||
|
||||
const navigate = useCallback((targetUrl: string) => {
|
||||
const finalUrl = ensureProtocol(targetUrl);
|
||||
setUrlBarValue(finalUrl);
|
||||
if (isElectron && webviewRef.current) {
|
||||
webviewRef.current.loadURL(finalUrl);
|
||||
}
|
||||
setCurrentUrl(finalUrl);
|
||||
dispatch(updateBrowserCardUrl({ browserId, url: finalUrl }));
|
||||
}, [browserId, dispatch]);
|
||||
|
||||
const handleUrlKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
navigate(urlBarValue);
|
||||
}
|
||||
}, [navigate, urlBarValue]);
|
||||
|
||||
const handleBack = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
webviewRef.current?.goBack();
|
||||
}, []);
|
||||
|
||||
const handleForward = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
webviewRef.current?.goForward();
|
||||
}, []);
|
||||
|
||||
const handleRefresh = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
webviewRef.current?.reload();
|
||||
}, []);
|
||||
|
||||
const handleRemove = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
dispatch(removeBrowserCard(browserId));
|
||||
}, [dispatch, browserId]);
|
||||
|
||||
// ---- Drag via header ----
|
||||
const DRAG_THRESHOLD = 3;
|
||||
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [localDragPos, setLocalDragPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const didDrag = useRef(false);
|
||||
const justDraggedRef = useRef(false);
|
||||
|
||||
const handleDragPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY };
|
||||
didDrag.current = false;
|
||||
setIsDragging(true);
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
onDragStart?.(browserId, 'browser');
|
||||
}, [cardX, cardY, onDragStart, browserId]);
|
||||
|
||||
const handleDragPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragState.current) return;
|
||||
const rawDx = e.clientX - dragState.current.startX;
|
||||
const rawDy = e.clientY - dragState.current.startY;
|
||||
if (!didDrag.current && Math.sqrt(rawDx * rawDx + rawDy * rawDy) < DRAG_THRESHOLD) return;
|
||||
didDrag.current = true;
|
||||
const dx = rawDx / zoom;
|
||||
const dy = rawDy / zoom;
|
||||
setLocalDragPos({
|
||||
x: dragState.current.origX + dx,
|
||||
y: dragState.current.origY + dy,
|
||||
});
|
||||
onDragMove?.(dx, dy);
|
||||
}, [zoom, onDragMove]);
|
||||
|
||||
const handleDragPointerUp = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragState.current) return;
|
||||
const dx = (e.clientX - dragState.current.startX) / zoom;
|
||||
const dy = (e.clientY - dragState.current.startY) / zoom;
|
||||
if (didDrag.current) {
|
||||
dispatch(setBrowserCardPosition({
|
||||
browserId,
|
||||
x: dragState.current.origX + dx,
|
||||
y: dragState.current.origY + dy,
|
||||
}));
|
||||
justDraggedRef.current = true;
|
||||
requestAnimationFrame(() => { justDraggedRef.current = false; });
|
||||
}
|
||||
onDragEnd?.(dx, dy, didDrag.current);
|
||||
dragState.current = null;
|
||||
didDrag.current = false;
|
||||
setLocalDragPos(null);
|
||||
setIsDragging(false);
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}, [zoom, dispatch, browserId, onDragEnd]);
|
||||
|
||||
// ---- Resize ----
|
||||
const resizeRef = useRef<{
|
||||
dir: ResizeDir; startX: number; startY: number;
|
||||
origX: number; origY: number; origW: number; origH: number;
|
||||
} | null>(null);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [localResize, setLocalResize] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
|
||||
|
||||
const handleResizeDown = useCallback(
|
||||
(dir: ResizeDir) => (e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
resizeRef.current = {
|
||||
dir, startX: e.clientX, startY: e.clientY,
|
||||
origX: cardX, origY: cardY, origW: cardWidth, origH: cardHeight,
|
||||
};
|
||||
setIsResizing(true);
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
},
|
||||
[cardX, cardY, cardWidth, cardHeight],
|
||||
);
|
||||
|
||||
const computeResize = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!resizeRef.current) return null;
|
||||
const { dir, startX, startY, origX, origY, origW, origH } = resizeRef.current;
|
||||
const dx = (e.clientX - startX) / zoom;
|
||||
const dy = (e.clientY - startY) / zoom;
|
||||
let newX = origX, newY = origY, newW = origW, newH = origH;
|
||||
if (dir.includes('e')) newW = origW + dx;
|
||||
if (dir.includes('w')) { newW = origW - dx; newX = origX + dx; }
|
||||
if (dir.includes('s')) newH = origH + dy;
|
||||
if (dir.includes('n')) { newH = origH - dy; newY = origY + dy; }
|
||||
if (newW < MIN_W) { if (dir.includes('w')) newX = origX + origW - MIN_W; newW = MIN_W; }
|
||||
if (newH < MIN_H) { if (dir.includes('n')) newY = origY + origH - MIN_H; newH = MIN_H; }
|
||||
return { x: newX, y: newY, w: newW, h: newH };
|
||||
},
|
||||
[zoom],
|
||||
);
|
||||
|
||||
const handleResizeMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
const result = computeResize(e);
|
||||
if (result) setLocalResize(result);
|
||||
},
|
||||
[computeResize],
|
||||
);
|
||||
|
||||
const handleResizeUp = useCallback((e: React.PointerEvent) => {
|
||||
if (!resizeRef.current) return;
|
||||
const result = computeResize(e);
|
||||
if (result) {
|
||||
dispatch(setBrowserCardPosition({ browserId, x: result.x, y: result.y }));
|
||||
dispatch(setBrowserCardSize({ browserId, width: result.w, height: result.h }));
|
||||
}
|
||||
resizeRef.current = null;
|
||||
setLocalResize(null);
|
||||
setIsResizing(false);
|
||||
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}, [computeResize, dispatch, browserId]);
|
||||
|
||||
const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0;
|
||||
const mdDy = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dy : 0;
|
||||
const displayX = localResize?.x ?? localDragPos?.x ?? (cardX + mdDx);
|
||||
const displayY = localResize?.y ?? localDragPos?.y ?? (cardY + mdDy);
|
||||
const displayW = localResize?.w ?? cardWidth;
|
||||
const displayH = localResize?.h ?? cardHeight;
|
||||
const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta);
|
||||
|
||||
const isSecure = currentUrl.startsWith('https://');
|
||||
|
||||
const accentColor = c.accent.primary;
|
||||
const accentHover = c.accent.hover;
|
||||
|
||||
const agentBorder = agentActive
|
||||
? `2px solid ${accentColor}`
|
||||
: isSelected ? '2px solid #3b82f6' : `1px solid ${c.border.medium}`;
|
||||
|
||||
const agentShadow = agentActive
|
||||
? `0 0 0 2px ${accentColor}40, 0 0 18px ${accentColor}30, 0 0 40px ${accentColor}15`
|
||||
: isDragging || isResizing
|
||||
? c.shadow.lg
|
||||
: isSelected
|
||||
? `0 0 0 1px #3b82f6, ${c.shadow.md}`
|
||||
: c.shadow.md;
|
||||
|
||||
return (
|
||||
<Box
|
||||
data-select-type="browser-card"
|
||||
data-select-id={browserId}
|
||||
data-select-meta={JSON.stringify({ name: pageTitle || 'Browser', url: currentUrl })}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
if (justDraggedRef.current) return;
|
||||
onCardSelect?.(browserId, 'browser', e.shiftKey);
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: displayX,
|
||||
top: displayY,
|
||||
width: displayW,
|
||||
height: displayH,
|
||||
borderRadius: `${c.radius.lg}px`,
|
||||
border: agentBorder,
|
||||
bgcolor: c.bg.surface,
|
||||
boxShadow: agentShadow,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
zIndex: (isDragging || isResizing) ? 100 : agentActive ? 50 : 1,
|
||||
transition: noTransition ? 'none' : 'box-shadow 0.4s ease, border 0.3s ease',
|
||||
'&:hover .resize-handle': { opacity: 1 },
|
||||
...(agentActive && {
|
||||
animation: 'agent-glow-pulse 2s ease-in-out infinite',
|
||||
'@keyframes agent-glow-pulse': {
|
||||
'0%, 100%': {
|
||||
boxShadow: `0 0 0 2px ${accentColor}40, 0 0 18px ${accentColor}30, 0 0 40px ${accentColor}15`,
|
||||
},
|
||||
'50%': {
|
||||
boxShadow: `0 0 0 3px ${accentColor}60, 0 0 28px ${accentColor}45, 0 0 56px ${accentColor}25`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{/* Animated border glow (top edge overlay) */}
|
||||
{agentActive && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '2px',
|
||||
zIndex: 20,
|
||||
background: `linear-gradient(90deg, transparent, ${accentColor}, ${accentHover}, ${accentColor}, transparent)`,
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'border-shimmer 2s linear infinite',
|
||||
'@keyframes border-shimmer': {
|
||||
'0%': { backgroundPosition: '200% 0' },
|
||||
'100%': { backgroundPosition: '-200% 0' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Header / drag handle */}
|
||||
<Box
|
||||
onPointerDown={handleDragPointerDown}
|
||||
onPointerMove={handleDragPointerMove}
|
||||
onPointerUp={handleDragPointerUp}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
bgcolor: agentActive ? `${accentColor}0a` : c.bg.secondary,
|
||||
borderBottom: `1px solid ${agentActive ? `${accentColor}30` : c.border.subtle}`,
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
flexShrink: 0,
|
||||
minHeight: 36,
|
||||
userSelect: 'none',
|
||||
transition: 'background 0.3s ease',
|
||||
}}
|
||||
>
|
||||
<LanguageIcon sx={{ fontSize: 16, color: c.accent.primary, flexShrink: 0 }} />
|
||||
<Typography
|
||||
sx={{
|
||||
flex: 1,
|
||||
fontSize: '0.78rem',
|
||||
fontWeight: 600,
|
||||
color: c.text.primary,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{pageTitle || 'Browser'}
|
||||
</Typography>
|
||||
|
||||
{/* Agent activity badge */}
|
||||
{agentActive && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
px: 0.75,
|
||||
py: 0.25,
|
||||
borderRadius: '6px',
|
||||
bgcolor: `${accentColor}18`,
|
||||
border: `1px solid ${accentColor}30`,
|
||||
animation: 'badge-fade-in 0.25s ease-out',
|
||||
'@keyframes badge-fade-in': {
|
||||
'0%': { opacity: 0, transform: 'scale(0.85)' },
|
||||
'100%': { opacity: 1, transform: 'scale(1)' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
bgcolor: accentColor,
|
||||
animation: 'badge-dot-pulse 1.4s ease-in-out infinite',
|
||||
'@keyframes badge-dot-pulse': {
|
||||
'0%, 100%': { opacity: 0.5, transform: 'scale(0.8)' },
|
||||
'50%': { opacity: 1, transform: 'scale(1.3)' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Typography sx={{ fontSize: '0.65rem', fontWeight: 600, color: accentColor, lineHeight: 1 }}>
|
||||
AI
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Tooltip title="Back" placement="top">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleBack}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
disabled={!canGoBack}
|
||||
sx={{ color: c.text.muted, p: 0.4, '&:hover': { color: c.text.primary } }}
|
||||
>
|
||||
<ArrowBackIcon sx={{ fontSize: 15 }} />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Forward" placement="top">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleForward}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
disabled={!canGoForward}
|
||||
sx={{ color: c.text.muted, p: 0.4, '&:hover': { color: c.text.primary } }}
|
||||
>
|
||||
<ArrowForwardIcon sx={{ fontSize: 15 }} />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Reload" placement="top">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleRefresh}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
sx={{ color: c.text.muted, p: 0.4, '&:hover': { color: c.text.primary } }}
|
||||
>
|
||||
<RefreshIcon sx={{ fontSize: 15 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Close browser" placement="top">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleRemove}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
sx={{ color: c.text.ghost, p: 0.4, '&:hover': { color: c.status.error } }}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 15 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* URL bar */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
px: 1,
|
||||
py: 0.4,
|
||||
bgcolor: c.bg.page,
|
||||
borderBottom: `1px solid ${c.border.subtle}`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{isSecure && (
|
||||
<LockIcon sx={{ fontSize: 13, color: c.status.success, flexShrink: 0 }} />
|
||||
)}
|
||||
<InputBase
|
||||
value={urlBarValue}
|
||||
onChange={(e) => setUrlBarValue(e.target.value)}
|
||||
onKeyDown={handleUrlKeyDown}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onFocus={(e) => (e.target as HTMLInputElement).select()}
|
||||
placeholder="Enter URL..."
|
||||
sx={{
|
||||
flex: 1,
|
||||
fontSize: '0.76rem',
|
||||
fontFamily: c.font.mono,
|
||||
color: c.text.secondary,
|
||||
py: 0,
|
||||
'& input': { py: '3px' },
|
||||
'& input::placeholder': { color: c.text.ghost, opacity: 1 },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Loading indicator — accent-colored when agent is navigating */}
|
||||
{(loading || (agentActive && agentAction === 'navigate')) && (
|
||||
<LinearProgress
|
||||
sx={{
|
||||
height: 2,
|
||||
flexShrink: 0,
|
||||
bgcolor: 'transparent',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
bgcolor: agentActive ? accentColor : c.accent.primary,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Browser body */}
|
||||
<Box sx={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
|
||||
{isElectron ? (
|
||||
<webview
|
||||
ref={webviewRef as any}
|
||||
src={currentUrl}
|
||||
style={{ width: '100%', height: '100%', border: 'none' }}
|
||||
/>
|
||||
) : (
|
||||
<Box sx={{ width: '100%', height: '100%', position: 'relative' }}>
|
||||
<iframe
|
||||
src={currentUrl}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
style={{ width: '100%', height: '100%', border: 'none' }}
|
||||
title="Browser"
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bgcolor: `${c.status.warningBg}`,
|
||||
borderTop: `1px solid ${c.status.warning}`,
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontSize: '0.68rem', color: c.status.warning }}>
|
||||
iframe mode — some sites may not load. Use the Electron build for full browser support.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ===== Action micro-animations ===== */}
|
||||
|
||||
{/* Camera flash — screenshot */}
|
||||
{(agentAction === 'screenshot' || lastAction === 'screenshot') && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
bgcolor: '#fff',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 15,
|
||||
animation: 'camera-flash 0.4s ease-out forwards',
|
||||
'@keyframes camera-flash': {
|
||||
'0%': { opacity: 0.45 },
|
||||
'100%': { opacity: 0 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Scanning line — get_text */}
|
||||
{agentAction === 'get_text' && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
zIndex: 15,
|
||||
pointerEvents: 'none',
|
||||
background: `linear-gradient(180deg, transparent, ${accentColor}90, transparent)`,
|
||||
boxShadow: `0 0 12px ${accentColor}60`,
|
||||
animation: 'scan-sweep 1.5s ease-in-out infinite',
|
||||
'@keyframes scan-sweep': {
|
||||
'0%': { top: '0%' },
|
||||
'100%': { top: '100%' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Click ripple */}
|
||||
{(agentAction === 'click' || lastAction === 'click') && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
border: `2px solid ${accentColor}`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 15,
|
||||
animation: 'click-ripple 0.5s ease-out forwards',
|
||||
'@keyframes click-ripple': {
|
||||
'0%': { opacity: 0.8, width: 10, height: 10, borderWidth: '2px' },
|
||||
'100%': { opacity: 0, width: 60, height: 60, borderWidth: '1px' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Typing indicator */}
|
||||
{agentAction === 'type' && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
display: 'flex',
|
||||
gap: '4px',
|
||||
alignItems: 'center',
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
borderRadius: '8px',
|
||||
bgcolor: `${accentColor}20`,
|
||||
border: `1px solid ${accentColor}40`,
|
||||
zIndex: 15,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
width: 5,
|
||||
height: 5,
|
||||
borderRadius: '50%',
|
||||
bgcolor: accentColor,
|
||||
animation: `typing-dot 1s ease-in-out ${i * 0.15}s infinite`,
|
||||
'@keyframes typing-dot': {
|
||||
'0%, 60%, 100%': { opacity: 0.3, transform: 'scale(0.8)' },
|
||||
'30%': { opacity: 1, transform: 'scale(1.2)' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ===== Frosted glass overlay ===== */}
|
||||
{agentActive && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 16,
|
||||
backdropFilter: 'blur(2px)',
|
||||
bgcolor: 'rgba(0,0,0,0.15)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 1.5,
|
||||
animation: 'overlay-fade-in 0.25s ease-out',
|
||||
'@keyframes overlay-fade-in': {
|
||||
'0%': { opacity: 0 },
|
||||
'100%': { opacity: 1 },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CircularProgress
|
||||
size={28}
|
||||
thickness={3}
|
||||
sx={{ color: accentColor }}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
px: 1.5,
|
||||
py: 0.75,
|
||||
borderRadius: '10px',
|
||||
bgcolor: 'rgba(0,0,0,0.55)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
border: `1px solid ${accentColor}30`,
|
||||
}}
|
||||
>
|
||||
<SmartToyOutlinedIcon sx={{ fontSize: 14, color: accentColor }} />
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
letterSpacing: '0.02em',
|
||||
}}
|
||||
>
|
||||
{getActionLabel(agentAction ?? '')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Resize handles */}
|
||||
{HANDLE_DEFS.map(({ dir, sx }) => (
|
||||
<Box
|
||||
key={dir}
|
||||
className="resize-handle"
|
||||
onPointerDown={handleResizeDown(dir)}
|
||||
onPointerMove={handleResizeMove}
|
||||
onPointerUp={handleResizeUp}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
cursor: CURSOR_MAP[dir],
|
||||
opacity: 0,
|
||||
zIndex: 10,
|
||||
...sx,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BrowserCard;
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
launchAndSendFirstMessage,
|
||||
generateTitle,
|
||||
resumeSession,
|
||||
setExpandedSessionIds,
|
||||
} from '@/shared/state/agentsSlice';
|
||||
import type { AgentConfig } from '@/shared/state/agentsSlice';
|
||||
import {
|
||||
@@ -21,30 +22,47 @@ import {
|
||||
reconcileSessions,
|
||||
tidyLayout,
|
||||
addViewCard,
|
||||
addBrowserCard,
|
||||
moveCards,
|
||||
resetLayout,
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
import { fetchOutputs } from '@/shared/state/outputsSlice';
|
||||
import { generateDashboardName } from '@/shared/state/dashboardsSlice';
|
||||
import { dashboardWs } from '@/shared/ws/WebSocketManager';
|
||||
import { initBrowserCommandHandler } from '@/shared/browserCommandHandler';
|
||||
import AgentCard from './AgentCard';
|
||||
import DashboardViewCard from './DashboardViewCard';
|
||||
import BrowserCard from './BrowserCard';
|
||||
import CanvasControls from './CanvasControls';
|
||||
import DashboardToolbar from './DashboardToolbar';
|
||||
import { useCanvasControls } from './useCanvasControls';
|
||||
import { useDashboardSelection } from './useDashboardSelection';
|
||||
import type { CardType } from './useDashboardSelection';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import type { ContextPath } from '@/app/components/DirectoryBrowser';
|
||||
import { ElementSelectionProvider, useElementSelection } from '@/app/components/ElementSelectionContext';
|
||||
import { ElementSelectionProvider } from '@/app/components/ElementSelectionContext';
|
||||
import { useDomElementSelector } from '@/app/components/useDomElementSelector';
|
||||
import SelectionOverlay from '@/app/components/SelectionOverlay';
|
||||
|
||||
const SELECT_ATTR = 'data-select-type';
|
||||
|
||||
const DashboardSelectionOverlay: React.FC = () => {
|
||||
const { overlay, dragRect, dragPreview } = useDomElementSelector();
|
||||
return <SelectionOverlay overlay={overlay} dragRect={dragRect} dragPreview={dragPreview} />;
|
||||
};
|
||||
|
||||
function isCardTarget(target: EventTarget | null, boundary: EventTarget | null): boolean {
|
||||
let el = target as HTMLElement | null;
|
||||
while (el && el !== boundary) {
|
||||
if (el.hasAttribute(SELECT_ATTR)) return true;
|
||||
el = el.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const DashboardInner: React.FC = () => {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const elementSelection = useElementSelection();
|
||||
const { id: dashboardId } = useParams<{ id: string }>();
|
||||
const dashboardName = useAppSelector((state) =>
|
||||
dashboardId ? state.dashboards.items[dashboardId]?.name : undefined,
|
||||
@@ -53,30 +71,106 @@ const DashboardInner: React.FC = () => {
|
||||
const expandedSessionIds = useAppSelector((state) => state.agents.expandedSessionIds);
|
||||
const cards = useAppSelector((state) => state.dashboardLayout.cards);
|
||||
const viewCards = useAppSelector((state) => state.dashboardLayout.viewCards);
|
||||
const browserCards = useAppSelector((state) => state.dashboardLayout.browserCards);
|
||||
const layoutInitialized = useAppSelector((state) => state.dashboardLayout.initialized);
|
||||
const persistedExpandedSessionIds = useAppSelector((state) => state.dashboardLayout.persistedExpandedSessionIds);
|
||||
const zoomSensitivity = useAppSelector((state) => state.settings.data.zoom_sensitivity);
|
||||
const newAgentShortcut = useAppSelector((state) => state.settings.data.new_agent_shortcut);
|
||||
const browserHomepage = useAppSelector((state) => state.settings.data.browser_homepage);
|
||||
const outputs = useAppSelector((state) => state.outputs.items);
|
||||
const sessionList = Object.values(sessions);
|
||||
|
||||
const selectModeActive = elementSelection?.selectMode ?? false;
|
||||
const canvas = useCanvasControls(zoomSensitivity, selectModeActive);
|
||||
const canvas = useCanvasControls(zoomSensitivity);
|
||||
const selection = useDashboardSelection(
|
||||
{ panX: canvas.panX, panY: canvas.panY, zoom: canvas.zoom, viewportRef: canvas.viewportRef },
|
||||
cards,
|
||||
viewCards,
|
||||
browserCards,
|
||||
);
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [toolbarOpen, setToolbarOpen] = useState(false);
|
||||
const spawnOriginsRef = useRef<Record<string, { x: number; y: number }>>({});
|
||||
const hasFittedRef = useRef(false);
|
||||
const restoredExpandedRef = useRef(false);
|
||||
|
||||
// ---- Multi-drag coordination ----
|
||||
const [multiDragDelta, setMultiDragDelta] = useState<{ dx: number; dy: number } | null>(null);
|
||||
const activeDragCardRef = useRef<string | null>(null);
|
||||
const isMultiDragRef = useRef(false);
|
||||
|
||||
const handleCardDragStart = useCallback((id: string, _type: CardType) => {
|
||||
if (selection.isSelected(id)) {
|
||||
activeDragCardRef.current = id;
|
||||
isMultiDragRef.current = true;
|
||||
} else {
|
||||
selection.deselectAll();
|
||||
activeDragCardRef.current = null;
|
||||
isMultiDragRef.current = false;
|
||||
}
|
||||
}, [selection]);
|
||||
|
||||
const handleCardDragMove = useCallback((dx: number, dy: number) => {
|
||||
if (isMultiDragRef.current) {
|
||||
setMultiDragDelta({ dx, dy });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCardDragEnd = useCallback((dx: number, dy: number, didDrag: boolean) => {
|
||||
if (isMultiDragRef.current && didDrag) {
|
||||
const items = selection.selectedArray()
|
||||
.filter((s) => s.id !== activeDragCardRef.current);
|
||||
if (items.length > 0) {
|
||||
dispatch(moveCards({ items, dx, dy }));
|
||||
}
|
||||
}
|
||||
activeDragCardRef.current = null;
|
||||
isMultiDragRef.current = false;
|
||||
setMultiDragDelta(null);
|
||||
}, [selection, dispatch]);
|
||||
|
||||
const handleCardSelect = useCallback((id: string, type: CardType, shiftKey: boolean) => {
|
||||
selection.selectCard(id, type, shiftKey);
|
||||
}, [selection]);
|
||||
|
||||
// ---- Viewport event handlers (compose pan + marquee) ----
|
||||
const handleViewportMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (e.button === 1) {
|
||||
canvas.handlers.onMouseDown(e);
|
||||
return;
|
||||
}
|
||||
if (e.button !== 0) return;
|
||||
if (isCardTarget(e.target, e.currentTarget)) return;
|
||||
|
||||
if (e.metaKey || e.ctrlKey || canvas.spaceHeld) {
|
||||
selection.handleCanvasMouseDown(e.nativeEvent);
|
||||
} else {
|
||||
canvas.handlers.onMouseDown(e);
|
||||
}
|
||||
}, [canvas, selection]);
|
||||
|
||||
const handleViewportMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
canvas.handlers.onMouseMove(e);
|
||||
selection.handleCanvasMouseMove(e.nativeEvent);
|
||||
}, [canvas.handlers, selection]);
|
||||
|
||||
const handleViewportMouseUp = useCallback((e: React.MouseEvent) => {
|
||||
canvas.handlers.onMouseUp();
|
||||
selection.handleCanvasMouseUp(e.nativeEvent);
|
||||
}, [canvas.handlers, selection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dashboardId) return;
|
||||
hasFittedRef.current = false;
|
||||
restoredExpandedRef.current = false;
|
||||
dispatch(resetLayout());
|
||||
dispatch(fetchSessions({ dashboardId }));
|
||||
dispatch(fetchHistory({ dashboardId }));
|
||||
dispatch(fetchLayout(dashboardId));
|
||||
dispatch(fetchOutputs());
|
||||
dashboardWs.connect();
|
||||
return () => dashboardWs.disconnect();
|
||||
const cleanupBrowserHandler = initBrowserCommandHandler();
|
||||
return () => { cleanupBrowserHandler(); dashboardWs.disconnect(); };
|
||||
}, [dispatch, dashboardId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -86,6 +180,14 @@ const DashboardInner: React.FC = () => {
|
||||
return () => clearTimeout(timer);
|
||||
}, [layoutInitialized, canvas.actions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!layoutInitialized || restoredExpandedRef.current) return;
|
||||
restoredExpandedRef.current = true;
|
||||
if (persistedExpandedSessionIds.length > 0) {
|
||||
dispatch(setExpandedSessionIds(persistedExpandedSessionIds));
|
||||
}
|
||||
}, [layoutInitialized, persistedExpandedSessionIds, dispatch]);
|
||||
|
||||
const prevSessionIdsRef = useRef<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -101,6 +203,8 @@ const DashboardInner: React.FC = () => {
|
||||
|
||||
const cardsJson = JSON.stringify(cards);
|
||||
const viewCardsJson = JSON.stringify(viewCards);
|
||||
const browserCardsJson = JSON.stringify(browserCards);
|
||||
const expandedJson = JSON.stringify(expandedSessionIds);
|
||||
const skipInitialSave = useRef(true);
|
||||
useEffect(() => {
|
||||
if (!layoutInitialized || !dashboardId) return;
|
||||
@@ -108,8 +212,8 @@ const DashboardInner: React.FC = () => {
|
||||
skipInitialSave.current = false;
|
||||
return;
|
||||
}
|
||||
dispatch(saveLayout({ dashboardId, cards, viewCards }));
|
||||
}, [cardsJson, viewCardsJson, layoutInitialized, dashboardId]);
|
||||
dispatch(saveLayout({ dashboardId, cards, viewCards, browserCards, expandedSessionIds }));
|
||||
}, [cardsJson, viewCardsJson, browserCardsJson, expandedJson, layoutInitialized, dashboardId]);
|
||||
|
||||
useEffect(() => {
|
||||
const parts = newAgentShortcut.toLowerCase().split('+');
|
||||
@@ -188,6 +292,22 @@ const DashboardInner: React.FC = () => {
|
||||
dispatch(generateTitle({ sessionId: realId, prompt }));
|
||||
spawnOriginsRef.current[realId] = spawnOriginsRef.current[draftId];
|
||||
delete spawnOriginsRef.current[draftId];
|
||||
|
||||
if (dashboardId) {
|
||||
const currentSessions = store.getState().agents.sessions;
|
||||
const agentCount = Object.values(currentSessions).filter(
|
||||
(s) => s.status !== 'draft' && s.dashboard_id === dashboardId,
|
||||
).length;
|
||||
const NAME_GEN_TRIGGERS = [1, 3, 6];
|
||||
const currentDash = store.getState().dashboards.items[dashboardId];
|
||||
const canAutoName =
|
||||
currentDash &&
|
||||
(currentDash.auto_named || currentDash.name === 'Untitled Dashboard');
|
||||
|
||||
if (NAME_GEN_TRIGGERS.includes(agentCount) && canAutoName) {
|
||||
dispatch(generateDashboardName(dashboardId));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
delete spawnOriginsRef.current[draftId];
|
||||
}
|
||||
@@ -200,6 +320,10 @@ const DashboardInner: React.FC = () => {
|
||||
dispatch(addViewCard({ outputId }));
|
||||
}, [dispatch]);
|
||||
|
||||
const handleAddBrowser = useCallback(() => {
|
||||
dispatch(addBrowserCard({ url: browserHomepage }));
|
||||
}, [dispatch, browserHomepage]);
|
||||
|
||||
const handleHistoryResume = useCallback((sessionId: string) => {
|
||||
dispatch(resumeSession({ sessionId })).then((action) => {
|
||||
if (resumeSession.fulfilled.match(action)) {
|
||||
@@ -212,10 +336,11 @@ const DashboardInner: React.FC = () => {
|
||||
dispatch(collapseAllSessions());
|
||||
dispatch(tidyLayout());
|
||||
|
||||
const { cards: tidied, viewCards: tidiedViews } = store.getState().dashboardLayout;
|
||||
const { cards: tidied, viewCards: tidiedViews, browserCards: tidiedBrowsers } = store.getState().dashboardLayout;
|
||||
const allRects = [
|
||||
...Object.values(tidied).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })),
|
||||
...Object.values(tidiedViews).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })),
|
||||
...Object.values(tidiedBrowsers).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })),
|
||||
];
|
||||
canvas.actions.fitToCards(allRects);
|
||||
}, [dispatch, canvas.actions]);
|
||||
@@ -279,14 +404,20 @@ const DashboardInner: React.FC = () => {
|
||||
{/* Canvas viewport */}
|
||||
<Box
|
||||
ref={canvas.viewportRef}
|
||||
onMouseDown={canvas.handlers.onMouseDown}
|
||||
onMouseMove={canvas.handlers.onMouseMove}
|
||||
onMouseUp={canvas.handlers.onMouseUp}
|
||||
onMouseDown={handleViewportMouseDown}
|
||||
onMouseMove={handleViewportMouseMove}
|
||||
onMouseUp={handleViewportMouseUp}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
overflow: 'hidden',
|
||||
cursor: canvas.isPanning ? 'grabbing' : canvas.spaceHeld ? 'grab' : selectModeActive ? 'crosshair' : 'default',
|
||||
cursor: canvas.isPanning
|
||||
? 'grabbing'
|
||||
: (canvas.spaceHeld || canvas.cmdHeld)
|
||||
? 'crosshair'
|
||||
: selection.marquee
|
||||
? 'crosshair'
|
||||
: 'default',
|
||||
}}
|
||||
>
|
||||
{/* Dot grid background */}
|
||||
@@ -301,7 +432,7 @@ const DashboardInner: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
|
||||
{sessionList.length === 0 && Object.keys(viewCards).length === 0 ? (
|
||||
{sessionList.length === 0 && Object.keys(viewCards).length === 0 && Object.keys(browserCards).length === 0 ? (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
@@ -346,6 +477,12 @@ const DashboardInner: React.FC = () => {
|
||||
cardHeight={card.height}
|
||||
zoom={canvas.zoom}
|
||||
spawnFrom={origin}
|
||||
isSelected={selection.isSelected(session.id)}
|
||||
multiDragDelta={multiDragDelta}
|
||||
onCardSelect={handleCardSelect}
|
||||
onDragStart={handleCardDragStart}
|
||||
onDragMove={handleCardDragMove}
|
||||
onDragEnd={handleCardDragEnd}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -361,9 +498,50 @@ const DashboardInner: React.FC = () => {
|
||||
cardWidth={vc.width}
|
||||
cardHeight={vc.height}
|
||||
zoom={canvas.zoom}
|
||||
isSelected={selection.isSelected(vc.output_id)}
|
||||
multiDragDelta={multiDragDelta}
|
||||
onCardSelect={handleCardSelect}
|
||||
onDragStart={handleCardDragStart}
|
||||
onDragMove={handleCardDragMove}
|
||||
onDragEnd={handleCardDragEnd}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{Object.values(browserCards).map((bc) => (
|
||||
<BrowserCard
|
||||
key={`browser-${bc.browser_id}`}
|
||||
browserId={bc.browser_id}
|
||||
url={bc.url}
|
||||
cardX={bc.x}
|
||||
cardY={bc.y}
|
||||
cardWidth={bc.width}
|
||||
cardHeight={bc.height}
|
||||
zoom={canvas.zoom}
|
||||
isSelected={selection.isSelected(bc.browser_id)}
|
||||
multiDragDelta={multiDragDelta}
|
||||
onCardSelect={handleCardSelect}
|
||||
onDragStart={handleCardDragStart}
|
||||
onDragMove={handleCardDragMove}
|
||||
onDragEnd={handleCardDragEnd}
|
||||
/>
|
||||
))}
|
||||
{/* Marquee selection rectangle */}
|
||||
{selection.marquee && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: selection.marquee.x,
|
||||
top: selection.marquee.y,
|
||||
width: selection.marquee.width,
|
||||
height: selection.marquee.height,
|
||||
border: '1.5px dashed rgba(59, 130, 246, 0.6)',
|
||||
background: 'rgba(59, 130, 246, 0.08)',
|
||||
borderRadius: 2,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 9999,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
@@ -378,6 +556,7 @@ const DashboardInner: React.FC = () => {
|
||||
onSend={handleToolbarSend}
|
||||
onAddView={handleAddView}
|
||||
onHistoryResume={handleHistoryResume}
|
||||
onAddBrowser={handleAddBrowser}
|
||||
dashboardId={dashboardId}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -10,6 +10,7 @@ import AddIcon from '@mui/icons-material/Add';
|
||||
import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded';
|
||||
import StickyNote2OutlinedIcon from '@mui/icons-material/StickyNote2Outlined';
|
||||
import HistoryRoundedIcon from '@mui/icons-material/HistoryRounded';
|
||||
import LanguageIcon from '@mui/icons-material/Language';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import { motion } from 'framer-motion';
|
||||
import ChatInput from '@/app/pages/AgentChat/ChatInput';
|
||||
@@ -36,6 +37,7 @@ interface Props {
|
||||
) => void;
|
||||
onAddView: (outputId: string) => void;
|
||||
onHistoryResume: (sessionId: string) => void;
|
||||
onAddBrowser: () => void;
|
||||
dashboardId?: string;
|
||||
}
|
||||
|
||||
@@ -79,7 +81,7 @@ function formatRelativeTime(dateStr: string | null): string {
|
||||
}
|
||||
|
||||
const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
({ inputOpen, onNewAgent, onCancel, onSend, onAddView, onHistoryResume, dashboardId }, ref) => {
|
||||
({ inputOpen, onNewAgent, onCancel, onSend, onAddView, onHistoryResume, onAddBrowser, dashboardId }, ref) => {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const elementSelection = useElementSelection();
|
||||
@@ -156,16 +158,32 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
}, [onAddView]);
|
||||
|
||||
const handleOpenViewPicker = useCallback(() => {
|
||||
if (viewPickerOpen) {
|
||||
setViewPickerOpen(false);
|
||||
setViewSearch('');
|
||||
return;
|
||||
}
|
||||
setHistoryOpen(false);
|
||||
setHistoryQuery('');
|
||||
dispatch(clearHistorySearch());
|
||||
setViewPickerOpen(true);
|
||||
setViewSearch('');
|
||||
}, []);
|
||||
}, [viewPickerOpen, dispatch]);
|
||||
|
||||
const handleOpenHistory = useCallback(() => {
|
||||
if (historyOpen) {
|
||||
setHistoryOpen(false);
|
||||
setHistoryQuery('');
|
||||
dispatch(clearHistorySearch());
|
||||
return;
|
||||
}
|
||||
setViewPickerOpen(false);
|
||||
setViewSearch('');
|
||||
setHistoryOpen(true);
|
||||
setHistoryQuery('');
|
||||
dispatch(clearHistorySearch());
|
||||
dispatch(searchHistory({ q: '', limit: HISTORY_PAGE_SIZE, offset: 0, dashboardId }));
|
||||
}, [dispatch, dashboardId]);
|
||||
}, [historyOpen, dispatch, dashboardId]);
|
||||
|
||||
const handleHistorySelect = useCallback((sessionId: string) => {
|
||||
onHistoryResume(sessionId);
|
||||
@@ -184,10 +202,12 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
|
||||
const isExpanded = inputOpen || viewPickerOpen || historyOpen;
|
||||
|
||||
const prevInputOpenRef = useRef(inputOpen);
|
||||
useEffect(() => {
|
||||
if (!inputOpen && elementSelection?.selectMode) {
|
||||
if (prevInputOpenRef.current && !inputOpen && elementSelection?.selectMode) {
|
||||
elementSelection.setSelectMode(false);
|
||||
}
|
||||
prevInputOpenRef.current = inputOpen;
|
||||
}, [inputOpen, elementSelection]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -234,6 +254,21 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
}
|
||||
}, [historyOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.metaKey && e.key.toLowerCase() === 'm' && !e.ctrlKey && !e.shiftKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
handleOpenViewPicker();
|
||||
}
|
||||
if (e.metaKey && e.key.toLowerCase() === 'o' && !e.ctrlKey && !e.shiftKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
handleOpenHistory();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
}, [handleOpenViewPicker, handleOpenHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!historyOpen) return;
|
||||
const timer = setTimeout(() => {
|
||||
@@ -526,8 +561,6 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
</Box>
|
||||
</WarmTooltip>
|
||||
|
||||
<Box sx={{ width: '1px', height: 24, bgcolor: c.border.medium, mx: '6px', flexShrink: 0 }} />
|
||||
|
||||
<WarmTooltip
|
||||
tokens={c}
|
||||
placement="top"
|
||||
@@ -535,7 +568,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
enterDelay={200}
|
||||
title={
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Box sx={{ fontWeight: 600 }}>Add View</Box>
|
||||
<Box sx={{ fontWeight: 600 }}>Add View ⌘M</Box>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
@@ -568,7 +601,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
enterDelay={200}
|
||||
title={
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Box sx={{ fontWeight: 600 }}>History</Box>
|
||||
<Box sx={{ fontWeight: 600 }}>History ⌘O</Box>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
@@ -594,6 +627,39 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
</Box>
|
||||
</WarmTooltip>
|
||||
|
||||
<WarmTooltip
|
||||
tokens={c}
|
||||
placement="top"
|
||||
arrow
|
||||
enterDelay={200}
|
||||
title={
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Box sx={{ fontWeight: 600 }}>Browser</Box>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
role="button"
|
||||
aria-label="Browser"
|
||||
tabIndex={0}
|
||||
onClick={onAddBrowser}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: BTN,
|
||||
height: BTN,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
color: c.text.tertiary,
|
||||
cursor: 'pointer',
|
||||
transition: 'opacity 0.15s, background-color 0.15s',
|
||||
'&:hover': { opacity: 1, bgcolor: c.bg.secondary, color: c.accent.primary },
|
||||
}}
|
||||
>
|
||||
<LanguageIcon sx={{ fontSize: 22 }} />
|
||||
</Box>
|
||||
</WarmTooltip>
|
||||
|
||||
{placeholderItems.map(({ icon: PlaceholderIcon, label, sub }) => (
|
||||
<WarmTooltip
|
||||
key={label}
|
||||
|
||||
@@ -8,7 +8,7 @@ import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import BoltIcon from '@mui/icons-material/Bolt';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded';
|
||||
import { Output, autoRunOutput, autoRunAgentOutput, executeOutput, OutputExecuteResult, getBackendCode } from '@/shared/state/outputsSlice';
|
||||
import { Output, autoRunOutput, autoRunAgentOutput, executeOutput, OutputExecuteResult, getBackendCode, SERVE_BASE } from '@/shared/state/outputsSlice';
|
||||
import { setViewCardPosition, setViewCardSize, removeViewCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
@@ -45,9 +45,18 @@ interface Props {
|
||||
cardWidth: number;
|
||||
cardHeight: number;
|
||||
zoom?: number;
|
||||
isSelected?: boolean;
|
||||
multiDragDelta?: { dx: number; dy: number } | null;
|
||||
onCardSelect?: (id: string, type: 'agent' | 'view', shiftKey: boolean) => void;
|
||||
onDragStart?: (id: string, type: 'agent' | 'view') => void;
|
||||
onDragMove?: (dx: number, dy: number) => void;
|
||||
onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void;
|
||||
}
|
||||
|
||||
const DashboardViewCard: React.FC<Props> = ({ output, cardX, cardY, cardWidth, cardHeight, zoom = 1 }) => {
|
||||
const DashboardViewCard: React.FC<Props> = ({
|
||||
output, cardX, cardY, cardWidth, cardHeight, zoom = 1,
|
||||
isSelected = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd,
|
||||
}) => {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const previewRef = useRef<ViewPreviewHandle>(null);
|
||||
@@ -64,6 +73,7 @@ const DashboardViewCard: React.FC<Props> = ({ output, cardX, cardY, cardWidth, c
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [localDragPos, setLocalDragPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const didDrag = useRef(false);
|
||||
const justDraggedRef = useRef(false);
|
||||
|
||||
const handleDragPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -72,7 +82,8 @@ const DashboardViewCard: React.FC<Props> = ({ output, cardX, cardY, cardWidth, c
|
||||
didDrag.current = false;
|
||||
setIsDragging(true);
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}, [cardX, cardY]);
|
||||
onDragStart?.(output.id, 'view');
|
||||
}, [cardX, cardY, onDragStart, output.id]);
|
||||
|
||||
const handleDragPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragState.current) return;
|
||||
@@ -80,29 +91,35 @@ const DashboardViewCard: React.FC<Props> = ({ output, cardX, cardY, cardWidth, c
|
||||
const rawDy = e.clientY - dragState.current.startY;
|
||||
if (!didDrag.current && Math.sqrt(rawDx * rawDx + rawDy * rawDy) < DRAG_THRESHOLD) return;
|
||||
didDrag.current = true;
|
||||
const dx = rawDx / zoom;
|
||||
const dy = rawDy / zoom;
|
||||
setLocalDragPos({
|
||||
x: dragState.current.origX + rawDx / zoom,
|
||||
y: dragState.current.origY + rawDy / zoom,
|
||||
x: dragState.current.origX + dx,
|
||||
y: dragState.current.origY + dy,
|
||||
});
|
||||
}, [zoom]);
|
||||
onDragMove?.(dx, dy);
|
||||
}, [zoom, onDragMove]);
|
||||
|
||||
const handleDragPointerUp = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragState.current) return;
|
||||
const dx = (e.clientX - dragState.current.startX) / zoom;
|
||||
const dy = (e.clientY - dragState.current.startY) / zoom;
|
||||
if (didDrag.current) {
|
||||
const dx = (e.clientX - dragState.current.startX) / zoom;
|
||||
const dy = (e.clientY - dragState.current.startY) / zoom;
|
||||
dispatch(setViewCardPosition({
|
||||
outputId: output.id,
|
||||
x: dragState.current.origX + dx,
|
||||
y: dragState.current.origY + dy,
|
||||
}));
|
||||
justDraggedRef.current = true;
|
||||
requestAnimationFrame(() => { justDraggedRef.current = false; });
|
||||
}
|
||||
onDragEnd?.(dx, dy, didDrag.current);
|
||||
dragState.current = null;
|
||||
didDrag.current = false;
|
||||
setLocalDragPos(null);
|
||||
setIsDragging(false);
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}, [zoom, dispatch, output.id]);
|
||||
}, [zoom, dispatch, output.id, onDragEnd]);
|
||||
|
||||
// ---- Resize ----
|
||||
const resizeRef = useRef<{
|
||||
@@ -223,17 +240,23 @@ const DashboardViewCard: React.FC<Props> = ({ output, cardX, cardY, cardWidth, c
|
||||
}
|
||||
};
|
||||
|
||||
// Compute display position (prefer local drag/resize during interaction)
|
||||
const displayX = localResize?.x ?? localDragPos?.x ?? cardX;
|
||||
const displayY = localResize?.y ?? localDragPos?.y ?? cardY;
|
||||
const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0;
|
||||
const mdDy = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dy : 0;
|
||||
const displayX = localResize?.x ?? localDragPos?.x ?? (cardX + mdDx);
|
||||
const displayY = localResize?.y ?? localDragPos?.y ?? (cardY + mdDy);
|
||||
const displayW = localResize?.w ?? cardWidth;
|
||||
const displayH = localResize?.h ?? cardHeight;
|
||||
const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta);
|
||||
|
||||
return (
|
||||
<Box
|
||||
data-select-type="view-card"
|
||||
data-select-id={output.id}
|
||||
data-select-meta={JSON.stringify({ name: output.name, description: output.description })}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
if (justDraggedRef.current) return;
|
||||
onCardSelect?.(output.id, 'view', e.shiftKey);
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: displayX,
|
||||
@@ -241,14 +264,18 @@ const DashboardViewCard: React.FC<Props> = ({ output, cardX, cardY, cardWidth, c
|
||||
width: displayW,
|
||||
height: displayH,
|
||||
borderRadius: `${c.radius.lg}px`,
|
||||
border: `1px solid ${c.border.medium}`,
|
||||
border: isSelected ? '2px solid #3b82f6' : `1px solid ${c.border.medium}`,
|
||||
bgcolor: c.bg.surface,
|
||||
boxShadow: (isDragging || isResizing) ? c.shadow.lg : c.shadow.md,
|
||||
boxShadow: isDragging || isResizing
|
||||
? c.shadow.lg
|
||||
: isSelected
|
||||
? `0 0 0 1px #3b82f6, ${c.shadow.md}`
|
||||
: c.shadow.md,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
zIndex: (isDragging || isResizing) ? 100 : 1,
|
||||
transition: (isDragging || isResizing) ? 'none' : 'box-shadow 0.2s',
|
||||
transition: noTransition ? 'none' : 'box-shadow 0.2s',
|
||||
'&:hover .resize-handle': { opacity: 1 },
|
||||
}}
|
||||
>
|
||||
@@ -329,7 +356,7 @@ const DashboardViewCard: React.FC<Props> = ({ output, cardX, cardY, cardWidth, c
|
||||
<Box sx={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
|
||||
<ViewPreview
|
||||
ref={previewRef}
|
||||
serveUrl={`/api/outputs/${output.id}/serve/index.html`}
|
||||
serveUrl={`${SERVE_BASE}/${output.id}/serve/index.html`}
|
||||
frontendCode={output.files?.['index.html'] ?? ''}
|
||||
inputData={inputData}
|
||||
backendResult={backendResult}
|
||||
|
||||
@@ -22,16 +22,18 @@ function clamp(val: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, val));
|
||||
}
|
||||
|
||||
export function useCanvasControls(zoomSensitivity: number = 50, panDisabled: boolean = false) {
|
||||
export function useCanvasControls(zoomSensitivity: number = 50) {
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [state, setState] = useState<CanvasState>({ panX: 0, panY: 0, zoom: 1 });
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const [spaceHeld, setSpaceHeld] = useState(false);
|
||||
const [cmdHeld, setCmdHeld] = useState(false);
|
||||
|
||||
const panStartRef = useRef<{ x: number; y: number; panX: number; panY: number } | null>(null);
|
||||
const spaceRef = useRef(false);
|
||||
const cmdRef = useRef(false);
|
||||
const sensitivityRef = useRef(zoomSensitivity);
|
||||
sensitivityRef.current = zoomSensitivity;
|
||||
|
||||
@@ -109,6 +111,10 @@ export function useCanvasControls(zoomSensitivity: number = 50, panDisabled: boo
|
||||
spaceRef.current = true;
|
||||
setSpaceHeld(true);
|
||||
}
|
||||
if ((e.key === 'Meta' || e.key === 'Control') && !e.repeat) {
|
||||
cmdRef.current = true;
|
||||
setCmdHeld(true);
|
||||
}
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.key === '0') {
|
||||
e.preventDefault();
|
||||
@@ -145,6 +151,10 @@ export function useCanvasControls(zoomSensitivity: number = 50, panDisabled: boo
|
||||
spaceRef.current = false;
|
||||
setSpaceHeld(false);
|
||||
}
|
||||
if (e.key === 'Meta' || e.key === 'Control') {
|
||||
cmdRef.current = false;
|
||||
setCmdHeld(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
@@ -156,23 +166,15 @@ export function useCanvasControls(zoomSensitivity: number = 50, panDisabled: boo
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
const isMiddle = e.button === 1;
|
||||
const isBackgroundLeft = e.button === 0 && e.target === viewportRef.current;
|
||||
const isSpaceDrag = e.button === 0 && spaceRef.current;
|
||||
|
||||
if (panDisabled && !isMiddle && !isSpaceDrag) return;
|
||||
|
||||
if (isMiddle || isBackgroundLeft || isSpaceDrag) {
|
||||
e.preventDefault();
|
||||
setIsPanning(true);
|
||||
panStartRef.current = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
panX: state.panX,
|
||||
panY: state.panY,
|
||||
};
|
||||
}
|
||||
}, [state.panX, state.panY, panDisabled]);
|
||||
e.preventDefault();
|
||||
setIsPanning(true);
|
||||
panStartRef.current = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
panX: state.panX,
|
||||
panY: state.panY,
|
||||
};
|
||||
}, [state.panX, state.panY]);
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
const start = panStartRef.current;
|
||||
@@ -309,6 +311,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, panDisabled: boo
|
||||
...state,
|
||||
isPanning,
|
||||
spaceHeld,
|
||||
cmdHeld,
|
||||
viewportRef,
|
||||
contentRef,
|
||||
handlers: {
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useState, useCallback, useRef, useEffect, RefObject } from 'react';
|
||||
import type { CardPosition, ViewCardPosition, BrowserCardPosition } from '@/shared/state/dashboardLayoutSlice';
|
||||
|
||||
export type CardType = 'agent' | 'view' | 'browser';
|
||||
|
||||
export interface SelectedCard {
|
||||
id: string;
|
||||
type: CardType;
|
||||
}
|
||||
|
||||
export interface MarqueeRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface ScreenToCanvas {
|
||||
panX: number;
|
||||
panY: number;
|
||||
zoom: number;
|
||||
viewportRef: RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
const DRAG_THRESHOLD = 4;
|
||||
|
||||
function rectsIntersect(
|
||||
a: { x: number; y: number; width: number; height: number },
|
||||
b: { x: number; y: number; width: number; height: number },
|
||||
): boolean {
|
||||
return (
|
||||
a.x < b.x + b.width &&
|
||||
a.x + a.width > b.x &&
|
||||
a.y < b.y + b.height &&
|
||||
a.y + a.height > b.y
|
||||
);
|
||||
}
|
||||
|
||||
export function useDashboardSelection(
|
||||
canvas: ScreenToCanvas,
|
||||
cards: Record<string, CardPosition>,
|
||||
viewCards: Record<string, ViewCardPosition>,
|
||||
browserCards: Record<string, BrowserCardPosition> = {},
|
||||
) {
|
||||
const [selectedIds, setSelectedIds] = useState<Map<string, CardType>>(new Map());
|
||||
const [marquee, setMarquee] = useState<MarqueeRect | null>(null);
|
||||
|
||||
const marqueeOriginRef = useRef<{ screenX: number; screenY: number } | null>(null);
|
||||
const isDraggingMarqueeRef = useRef(false);
|
||||
const shiftHeldRef = useRef(false);
|
||||
const selectionBeforeMarqueeRef = useRef<Map<string, CardType>>(new Map());
|
||||
|
||||
const screenToCanvas = useCallback(
|
||||
(screenX: number, screenY: number) => {
|
||||
const vp = canvas.viewportRef.current;
|
||||
if (!vp) return { x: 0, y: 0 };
|
||||
const rect = vp.getBoundingClientRect();
|
||||
return {
|
||||
x: (screenX - rect.left - canvas.panX) / canvas.zoom,
|
||||
y: (screenY - rect.top - canvas.panY) / canvas.zoom,
|
||||
};
|
||||
},
|
||||
[canvas.panX, canvas.panY, canvas.zoom, canvas.viewportRef],
|
||||
);
|
||||
|
||||
const isSelected = useCallback((id: string) => selectedIds.has(id), [selectedIds]);
|
||||
|
||||
const deselectAll = useCallback(() => setSelectedIds(new Map()), []);
|
||||
|
||||
const selectCard = useCallback(
|
||||
(id: string, type: CardType, shiftKey: boolean) => {
|
||||
setSelectedIds((prev) => {
|
||||
if (shiftKey) {
|
||||
const next = new Map(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.set(id, type);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
return new Map([[id, type]]);
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const selectedArray = useCallback((): SelectedCard[] => {
|
||||
return Array.from(selectedIds.entries()).map(([id, type]) => ({ id, type }));
|
||||
}, [selectedIds]);
|
||||
|
||||
const computeMarqueeSelection = useCallback(
|
||||
(rect: MarqueeRect, shiftKey: boolean) => {
|
||||
const intersecting = new Map<string, CardType>();
|
||||
|
||||
for (const card of Object.values(cards)) {
|
||||
if (
|
||||
rectsIntersect(rect, {
|
||||
x: card.x,
|
||||
y: card.y,
|
||||
width: card.width,
|
||||
height: card.height,
|
||||
})
|
||||
) {
|
||||
intersecting.set(card.session_id, 'agent');
|
||||
}
|
||||
}
|
||||
|
||||
for (const vc of Object.values(viewCards)) {
|
||||
if (
|
||||
rectsIntersect(rect, {
|
||||
x: vc.x,
|
||||
y: vc.y,
|
||||
width: vc.width,
|
||||
height: vc.height,
|
||||
})
|
||||
) {
|
||||
intersecting.set(vc.output_id, 'view');
|
||||
}
|
||||
}
|
||||
|
||||
for (const bc of Object.values(browserCards)) {
|
||||
if (
|
||||
rectsIntersect(rect, {
|
||||
x: bc.x,
|
||||
y: bc.y,
|
||||
width: bc.width,
|
||||
height: bc.height,
|
||||
})
|
||||
) {
|
||||
intersecting.set(bc.browser_id, 'browser');
|
||||
}
|
||||
}
|
||||
|
||||
if (shiftKey) {
|
||||
const base = selectionBeforeMarqueeRef.current;
|
||||
const next = new Map(base);
|
||||
for (const [id, type] of intersecting) {
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.set(id, type);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
return intersecting;
|
||||
},
|
||||
[cards, viewCards, browserCards],
|
||||
);
|
||||
|
||||
const handleCanvasMouseDown = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
|
||||
marqueeOriginRef.current = { screenX: e.clientX, screenY: e.clientY };
|
||||
isDraggingMarqueeRef.current = false;
|
||||
shiftHeldRef.current = e.shiftKey;
|
||||
selectionBeforeMarqueeRef.current = new Map(selectedIds);
|
||||
},
|
||||
[selectedIds],
|
||||
);
|
||||
|
||||
const handleCanvasMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
const origin = marqueeOriginRef.current;
|
||||
if (!origin) return;
|
||||
|
||||
const dx = e.clientX - origin.screenX;
|
||||
const dy = e.clientY - origin.screenY;
|
||||
|
||||
if (!isDraggingMarqueeRef.current) {
|
||||
if (Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return;
|
||||
isDraggingMarqueeRef.current = true;
|
||||
}
|
||||
|
||||
const start = screenToCanvas(origin.screenX, origin.screenY);
|
||||
const end = screenToCanvas(e.clientX, e.clientY);
|
||||
|
||||
const rect: MarqueeRect = {
|
||||
x: Math.min(start.x, end.x),
|
||||
y: Math.min(start.y, end.y),
|
||||
width: Math.abs(end.x - start.x),
|
||||
height: Math.abs(end.y - start.y),
|
||||
};
|
||||
|
||||
setMarquee(rect);
|
||||
setSelectedIds(computeMarqueeSelection(rect, shiftHeldRef.current));
|
||||
},
|
||||
[screenToCanvas, computeMarqueeSelection],
|
||||
);
|
||||
|
||||
const handleCanvasMouseUp = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
const origin = marqueeOriginRef.current;
|
||||
if (!origin) return;
|
||||
|
||||
if (!isDraggingMarqueeRef.current) {
|
||||
if (!e.shiftKey) {
|
||||
deselectAll();
|
||||
}
|
||||
}
|
||||
|
||||
marqueeOriginRef.current = null;
|
||||
isDraggingMarqueeRef.current = false;
|
||||
setMarquee(null);
|
||||
},
|
||||
[deselectAll],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
deselectAll();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [deselectAll]);
|
||||
|
||||
return {
|
||||
selectedIds,
|
||||
selectedArray,
|
||||
marquee,
|
||||
isSelected,
|
||||
selectCard,
|
||||
deselectAll,
|
||||
handleCanvasMouseDown,
|
||||
handleCanvasMouseMove,
|
||||
handleCanvasMouseUp,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Button from '@mui/material/Button';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
@@ -26,6 +25,7 @@ import DarkModeIcon from '@mui/icons-material/DarkMode';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import KeyboardIcon from '@mui/icons-material/Keyboard';
|
||||
import LanguageIcon from '@mui/icons-material/Language';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { updateSettings, closeSettingsModal, AppSettings } from '@/shared/state/settingsSlice';
|
||||
import { fetchModes } from '@/shared/state/modesSlice';
|
||||
@@ -96,11 +96,56 @@ const Settings: React.FC = () => {
|
||||
|
||||
const fieldSx = {
|
||||
'& .MuiOutlinedInput-root': {
|
||||
bgcolor: c.bg.page,
|
||||
fontSize: '0.85rem',
|
||||
},
|
||||
};
|
||||
|
||||
const sectionSx = {
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase' as const,
|
||||
color: c.text.tertiary,
|
||||
mb: 0.5,
|
||||
mt: 0.5,
|
||||
};
|
||||
|
||||
const rowSx = {
|
||||
py: 2,
|
||||
borderBottom: `1px solid ${c.border.subtle}`,
|
||||
};
|
||||
|
||||
const rowLastSx = {
|
||||
py: 2,
|
||||
};
|
||||
|
||||
const inlineRowSx = {
|
||||
...rowSx,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
};
|
||||
|
||||
const inlineRowLastSx = {
|
||||
...rowLastSx,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
};
|
||||
|
||||
const labelSx = {
|
||||
color: c.text.primary,
|
||||
fontWeight: 500,
|
||||
fontSize: '0.875rem',
|
||||
lineHeight: 1.4,
|
||||
};
|
||||
|
||||
const descSx = {
|
||||
color: c.text.tertiary,
|
||||
fontSize: '0.75rem',
|
||||
lineHeight: 1.4,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
@@ -109,12 +154,12 @@ const Settings: React.FC = () => {
|
||||
maxWidth={false}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
width: 800,
|
||||
width: 660,
|
||||
maxHeight: '85vh',
|
||||
bgcolor: c.bg.page,
|
||||
borderRadius: 4,
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
boxShadow: c.shadow.lg,
|
||||
boxShadow: c.shadow.md,
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -125,38 +170,35 @@ const Settings: React.FC = () => {
|
||||
justifyContent: 'space-between',
|
||||
borderBottom: `1px solid ${c.border.subtle}`,
|
||||
px: 3,
|
||||
py: 2,
|
||||
py: 1.5,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ color: c.text.primary, fontWeight: 700 }}>
|
||||
Settings
|
||||
</Typography>
|
||||
<Typography sx={{ color: c.text.tertiary, fontSize: '0.8rem' }}>
|
||||
Global defaults and application configuration.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '1rem' }}>
|
||||
Settings
|
||||
</Typography>
|
||||
<IconButton onClick={handleRequestClose} size="small" sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }}>
|
||||
<CloseIcon sx={{ fontSize: 20 }} />
|
||||
<CloseIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent sx={{
|
||||
p: 3,
|
||||
px: 3,
|
||||
py: 0,
|
||||
'&::-webkit-scrollbar': { width: 6 },
|
||||
'&::-webkit-scrollbar-track': { background: 'transparent' },
|
||||
'&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 3, '&:hover': { background: c.border.strong } },
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: `${c.border.medium} transparent`,
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 640, mx: 'auto', pt: 1 }}>
|
||||
{/* Default System Prompt */}
|
||||
<Paper sx={{ bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: 3, p: 3, boxShadow: c.shadow.sm }}>
|
||||
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem', mb: 0.5 }}>
|
||||
Default System Prompt
|
||||
</Typography>
|
||||
<Typography sx={{ color: c.text.tertiary, fontSize: '0.8rem', mb: 2 }}>
|
||||
A global system prompt prepended to every agent session, before any mode-specific instructions.
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1 }}>
|
||||
|
||||
{/* ── Agent Defaults ── */}
|
||||
<Typography sx={sectionSx}>Agent Defaults</Typography>
|
||||
|
||||
<Box sx={rowSx}>
|
||||
<Typography sx={labelSx}>System prompt</Typography>
|
||||
<Typography sx={{ ...descSx, mb: 1.5 }}>
|
||||
Prepended to every agent session before mode-specific instructions.
|
||||
</Typography>
|
||||
<TextField
|
||||
value={form.default_system_prompt ?? ''}
|
||||
@@ -165,7 +207,7 @@ const Settings: React.FC = () => {
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={3}
|
||||
maxRows={10}
|
||||
maxRows={8}
|
||||
placeholder="Enter a default system prompt..."
|
||||
sx={{
|
||||
...fieldSx,
|
||||
@@ -183,15 +225,12 @@ const Settings: React.FC = () => {
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
{/* Default Folder */}
|
||||
<Paper sx={{ bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: 3, p: 3, boxShadow: c.shadow.sm }}>
|
||||
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem', mb: 0.5 }}>
|
||||
Default Folder
|
||||
</Typography>
|
||||
<Typography sx={{ color: c.text.tertiary, fontSize: '0.8rem', mb: 2 }}>
|
||||
The working directory agents start in by default. Modes can override this per-mode.
|
||||
<Box sx={rowSx}>
|
||||
<Typography sx={labelSx}>Working directory</Typography>
|
||||
<Typography sx={{ ...descSx, mb: 1.5 }}>
|
||||
Default folder agents start in. Modes can override per-mode.
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<TextField
|
||||
@@ -211,33 +250,32 @@ const Settings: React.FC = () => {
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setBrowseOpen(true)}
|
||||
startIcon={<FolderOpenIcon />}
|
||||
startIcon={<FolderOpenIcon sx={{ fontSize: 16 }} />}
|
||||
sx={{
|
||||
color: c.accent.primary,
|
||||
color: c.text.tertiary,
|
||||
borderColor: c.border.medium,
|
||||
textTransform: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
minWidth: 'auto',
|
||||
fontSize: '0.8rem',
|
||||
'&:hover': { color: c.accent.primary, borderColor: c.accent.primary },
|
||||
}}
|
||||
>
|
||||
Browse
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
{/* Default Model */}
|
||||
<Paper sx={{ bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: 3, p: 3, boxShadow: c.shadow.sm }}>
|
||||
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem', mb: 0.5 }}>
|
||||
Default Model
|
||||
</Typography>
|
||||
<Typography sx={{ color: c.text.tertiary, fontSize: '0.8rem', mb: 2 }}>
|
||||
The default model for new agent sessions.
|
||||
</Typography>
|
||||
<FormControl fullWidth size="small">
|
||||
<Box sx={inlineRowSx}>
|
||||
<Box sx={{ mr: 3 }}>
|
||||
<Typography sx={labelSx}>Model</Typography>
|
||||
<Typography sx={descSx}>Default model for new sessions.</Typography>
|
||||
</Box>
|
||||
<FormControl size="small" sx={{ minWidth: 170 }}>
|
||||
<Select
|
||||
value={form.default_model}
|
||||
onChange={(e) => setForm({ ...form, default_model: e.target.value })}
|
||||
sx={{ bgcolor: c.bg.page }}
|
||||
sx={{ fontSize: '0.85rem' }}
|
||||
MenuProps={{ PaperProps: { sx: { bgcolor: c.bg.surface, color: c.text.primary } } }}
|
||||
>
|
||||
<MenuItem value="sonnet">Sonnet 4.6</MenuItem>
|
||||
@@ -245,21 +283,18 @@ const Settings: React.FC = () => {
|
||||
<MenuItem value="haiku">Haiku 3.5</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
{/* Default Mode */}
|
||||
<Paper sx={{ bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: 3, p: 3, boxShadow: c.shadow.sm }}>
|
||||
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem', mb: 0.5 }}>
|
||||
Default Mode
|
||||
</Typography>
|
||||
<Typography sx={{ color: c.text.tertiary, fontSize: '0.8rem', mb: 2 }}>
|
||||
The default interaction mode for new agent sessions.
|
||||
</Typography>
|
||||
<FormControl fullWidth size="small">
|
||||
<Box sx={inlineRowSx}>
|
||||
<Box sx={{ mr: 3 }}>
|
||||
<Typography sx={labelSx}>Mode</Typography>
|
||||
<Typography sx={descSx}>Default interaction mode for new sessions.</Typography>
|
||||
</Box>
|
||||
<FormControl size="small" sx={{ minWidth: 170 }}>
|
||||
<Select
|
||||
value={form.default_mode}
|
||||
onChange={(e) => setForm({ ...form, default_mode: e.target.value })}
|
||||
sx={{ bgcolor: c.bg.page }}
|
||||
sx={{ fontSize: '0.85rem' }}
|
||||
MenuProps={{ PaperProps: { sx: { bgcolor: c.bg.surface, color: c.text.primary } } }}
|
||||
>
|
||||
{modesList.map((m) => (
|
||||
@@ -267,35 +302,68 @@ const Settings: React.FC = () => {
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
{/* Default Max Turns */}
|
||||
<Paper sx={{ bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: 3, p: 3, boxShadow: c.shadow.sm }}>
|
||||
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem', mb: 0.5 }}>
|
||||
Default Max Turns
|
||||
</Typography>
|
||||
<Typography sx={{ color: c.text.tertiary, fontSize: '0.8rem', mb: 2 }}>
|
||||
Maximum number of agent turns before auto-stopping. Leave empty for unlimited.
|
||||
</Typography>
|
||||
<Box sx={inlineRowLastSx}>
|
||||
<Box sx={{ mr: 3 }}>
|
||||
<Typography sx={labelSx}>Max turns</Typography>
|
||||
<Typography sx={descSx}>Auto-stop after this many turns. Empty = unlimited.</Typography>
|
||||
</Box>
|
||||
<TextField
|
||||
type="number"
|
||||
value={form.default_max_turns ?? ''}
|
||||
onChange={(e) => setForm({ ...form, default_max_turns: e.target.value ? parseInt(e.target.value) : null })}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="Unlimited"
|
||||
placeholder="∞"
|
||||
inputProps={{ min: 1 }}
|
||||
sx={fieldSx}
|
||||
sx={{ ...fieldSx, width: 100 }}
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
{/* Zoom Sensitivity */}
|
||||
<Paper sx={{ bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: 3, p: 3, boxShadow: c.shadow.sm }}>
|
||||
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem', mb: 0.5 }}>
|
||||
Zoom Sensitivity
|
||||
</Typography>
|
||||
<Typography sx={{ color: c.text.tertiary, fontSize: '0.8rem', mb: 2 }}>
|
||||
Controls how responsive scroll-to-zoom is on the dashboard canvas. Lower values suit trackpads; higher values suit mouse wheels.
|
||||
{/* ── Interface ── */}
|
||||
<Typography sx={{ ...sectionSx, mt: 3 }}>Interface</Typography>
|
||||
|
||||
<Box sx={inlineRowSx}>
|
||||
<Box sx={{ mr: 3 }}>
|
||||
<Typography sx={labelSx}>Theme</Typography>
|
||||
<Typography sx={descSx}>Application color scheme.</Typography>
|
||||
</Box>
|
||||
<ToggleButtonGroup
|
||||
value={form.theme}
|
||||
exclusive
|
||||
onChange={(_, v) => { if (v) setForm({ ...form, theme: v }); }}
|
||||
size="small"
|
||||
sx={{
|
||||
'& .MuiToggleButton-root': {
|
||||
color: c.text.muted,
|
||||
borderColor: c.border.medium,
|
||||
textTransform: 'none',
|
||||
px: 2,
|
||||
py: 0.5,
|
||||
gap: 0.5,
|
||||
fontSize: '0.8rem',
|
||||
'&.Mui-selected': {
|
||||
bgcolor: `${c.accent.primary}15`,
|
||||
color: c.accent.primary,
|
||||
borderColor: c.accent.primary,
|
||||
'&:hover': { bgcolor: `${c.accent.primary}20` },
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="light">
|
||||
<LightModeIcon sx={{ fontSize: 16 }} /> Light
|
||||
</ToggleButton>
|
||||
<ToggleButton value="dark">
|
||||
<DarkModeIcon sx={{ fontSize: 16 }} /> Dark
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
|
||||
<Box sx={rowSx}>
|
||||
<Typography sx={labelSx}>Zoom sensitivity</Typography>
|
||||
<Typography sx={{ ...descSx, mb: 1 }}>
|
||||
Scroll-to-zoom responsiveness. Lower for trackpads, higher for mouse wheels.
|
||||
</Typography>
|
||||
<Box sx={{ px: 1 }}>
|
||||
<Slider
|
||||
@@ -312,21 +380,18 @@ const Settings: React.FC = () => {
|
||||
]}
|
||||
sx={{
|
||||
color: c.accent.primary,
|
||||
'& .MuiSlider-markLabel': { color: c.text.tertiary, fontSize: '0.75rem' },
|
||||
'& .MuiSlider-markLabel': { color: c.text.tertiary, fontSize: '0.7rem' },
|
||||
'& .MuiSlider-valueLabel': { bgcolor: c.accent.primary },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
{/* New Agent Shortcut */}
|
||||
<Paper sx={{ bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: 3, p: 3, boxShadow: c.shadow.sm }}>
|
||||
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem', mb: 0.5 }}>
|
||||
New Agent Shortcut
|
||||
</Typography>
|
||||
<Typography sx={{ color: c.text.tertiary, fontSize: '0.8rem', mb: 2 }}>
|
||||
Keyboard shortcut to open the new agent input on the Dashboard.
|
||||
</Typography>
|
||||
<Box sx={inlineRowLastSx}>
|
||||
<Box sx={{ mr: 3 }}>
|
||||
<Typography sx={labelSx}>New agent shortcut</Typography>
|
||||
<Typography sx={descSx}>Keyboard shortcut to create an agent.</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
@@ -347,25 +412,24 @@ const Settings: React.FC = () => {
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
px: 2,
|
||||
py: 1,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
gap: 0.75,
|
||||
px: 1.5,
|
||||
py: 0.75,
|
||||
borderRadius: `${c.radius.sm}px`,
|
||||
border: `1px solid ${recordingShortcut ? c.accent.primary : c.border.medium}`,
|
||||
bgcolor: c.bg.page,
|
||||
cursor: 'pointer',
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.15s',
|
||||
'&:hover': { borderColor: c.accent.primary },
|
||||
}}
|
||||
>
|
||||
<KeyboardIcon sx={{ fontSize: 18, color: recordingShortcut ? c.accent.primary : c.text.tertiary }} />
|
||||
<KeyboardIcon sx={{ fontSize: 16, color: recordingShortcut ? c.accent.primary : c.text.tertiary }} />
|
||||
{recordingShortcut ? (
|
||||
<Typography sx={{ fontSize: '0.85rem', color: c.accent.primary, fontWeight: 500 }}>
|
||||
<Typography sx={{ fontSize: '0.8rem', color: c.accent.primary, fontWeight: 500 }}>
|
||||
Press shortcut…
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography sx={{ fontSize: '0.85rem', color: c.text.primary, fontFamily: c.font.mono, fontWeight: 500 }}>
|
||||
<Typography sx={{ fontSize: '0.8rem', color: c.text.primary, fontFamily: c.font.mono, fontWeight: 500 }}>
|
||||
{form.new_agent_shortcut
|
||||
.split('+')
|
||||
.map((p) => {
|
||||
@@ -379,53 +443,42 @@ const Settings: React.FC = () => {
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
{/* Theme */}
|
||||
<Paper sx={{ bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: 3, p: 3, boxShadow: c.shadow.sm }}>
|
||||
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem', mb: 0.5 }}>
|
||||
Theme
|
||||
{/* ── Browser ── */}
|
||||
<Typography sx={{ ...sectionSx, mt: 3 }}>Browser</Typography>
|
||||
|
||||
<Box sx={rowLastSx}>
|
||||
<Typography sx={labelSx}>Default homepage</Typography>
|
||||
<Typography sx={{ ...descSx, mb: 1.5 }}>
|
||||
URL loaded when opening a new browser card on the dashboard.
|
||||
</Typography>
|
||||
<Typography sx={{ color: c.text.tertiary, fontSize: '0.8rem', mb: 2 }}>
|
||||
Application color scheme.
|
||||
</Typography>
|
||||
<ToggleButtonGroup
|
||||
value={form.theme}
|
||||
exclusive
|
||||
onChange={(_, v) => { if (v) setForm({ ...form, theme: v }); }}
|
||||
size="small"
|
||||
sx={{
|
||||
'& .MuiToggleButton-root': {
|
||||
color: c.text.muted,
|
||||
borderColor: c.border.medium,
|
||||
textTransform: 'none',
|
||||
px: 2.5,
|
||||
gap: 0.75,
|
||||
'&.Mui-selected': {
|
||||
bgcolor: `${c.accent.primary}15`,
|
||||
color: c.accent.primary,
|
||||
borderColor: c.accent.primary,
|
||||
'&:hover': { bgcolor: `${c.accent.primary}20` },
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<LanguageIcon sx={{ fontSize: 18, color: c.text.tertiary, flexShrink: 0 }} />
|
||||
<TextField
|
||||
value={form.browser_homepage}
|
||||
onChange={(e) => setForm({ ...form, browser_homepage: e.target.value })}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="https://www.google.com"
|
||||
sx={{
|
||||
...fieldSx,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
...fieldSx['& .MuiOutlinedInput-root'],
|
||||
fontFamily: c.font.mono,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="light">
|
||||
<LightModeIcon sx={{ fontSize: 18 }} /> Light
|
||||
</ToggleButton>
|
||||
<ToggleButton value="dark">
|
||||
<DarkModeIcon sx={{ fontSize: 18 }} /> Dark
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Paper>
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Anthropic API Key */}
|
||||
<Paper sx={{ bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: 3, p: 3, boxShadow: c.shadow.sm }}>
|
||||
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem', mb: 0.5 }}>
|
||||
Anthropic API Key
|
||||
</Typography>
|
||||
<Typography sx={{ color: c.text.tertiary, fontSize: '0.8rem', mb: 2 }}>
|
||||
Your API key for the Anthropic Claude API. Stored securely in the database.
|
||||
{/* ── API ── */}
|
||||
<Typography sx={{ ...sectionSx, mt: 3 }}>API</Typography>
|
||||
|
||||
<Box sx={rowLastSx}>
|
||||
<Typography sx={labelSx}>Anthropic API key</Typography>
|
||||
<Typography sx={{ ...descSx, mb: 1.5 }}>
|
||||
Stored securely in the local database.
|
||||
</Typography>
|
||||
<TextField
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
@@ -450,27 +503,27 @@ const Settings: React.FC = () => {
|
||||
size="small"
|
||||
sx={{ color: c.text.tertiary }}
|
||||
>
|
||||
{showApiKey ? <VisibilityOffIcon sx={{ fontSize: 18 }} /> : <VisibilityIcon sx={{ fontSize: 18 }} />}
|
||||
{showApiKey ? <VisibilityOffIcon sx={{ fontSize: 16 }} /> : <VisibilityIcon sx={{ fontSize: 16 }} />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ borderTop: `1px solid ${c.border.subtle}`, px: 3, py: 2, justifyContent: 'flex-end' }}>
|
||||
<DialogActions sx={{ borderTop: `1px solid ${c.border.subtle}`, px: 3, py: 1.5, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
onClick={handleRequestClose}
|
||||
sx={{ color: c.text.muted, textTransform: 'none' }}
|
||||
sx={{ color: c.text.muted, textTransform: 'none', fontSize: '0.85rem' }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<SaveIcon />}
|
||||
startIcon={<SaveIcon sx={{ fontSize: 16 }} />}
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges}
|
||||
sx={{
|
||||
@@ -478,11 +531,12 @@ const Settings: React.FC = () => {
|
||||
'&:hover': { bgcolor: c.accent.pressed },
|
||||
'&.Mui-disabled': { bgcolor: c.bg.secondary, color: c.text.ghost },
|
||||
textTransform: 'none',
|
||||
borderRadius: 2,
|
||||
px: 3,
|
||||
borderRadius: 1.5,
|
||||
px: 2.5,
|
||||
fontSize: '0.85rem',
|
||||
}}
|
||||
>
|
||||
Save Settings
|
||||
Save
|
||||
</Button>
|
||||
</DialogActions>
|
||||
|
||||
@@ -500,43 +554,42 @@ const Settings: React.FC = () => {
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert onClose={() => setSaved(false)} severity="success" sx={{ bgcolor: c.bg.surface, color: c.text.primary, border: `1px solid ${c.status.success}` }}>
|
||||
Settings saved successfully
|
||||
Settings saved
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Dialog>
|
||||
|
||||
{/* Discard changes confirmation */}
|
||||
<Dialog
|
||||
open={confirmDiscard}
|
||||
onClose={() => setConfirmDiscard(false)}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
bgcolor: c.bg.surface,
|
||||
borderRadius: 3,
|
||||
bgcolor: c.bg.page,
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
boxShadow: c.shadow.lg,
|
||||
maxWidth: 400,
|
||||
boxShadow: c.shadow.md,
|
||||
maxWidth: 380,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ color: c.text.primary, fontWeight: 600, fontSize: '1rem', pb: 0.5 }}>
|
||||
Unsaved Changes
|
||||
<DialogTitle sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem', pb: 0.5, px: 3, pt: 2.5 }}>
|
||||
Unsaved changes
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography sx={{ color: c.text.muted, fontSize: '0.875rem' }}>
|
||||
<DialogContent sx={{ px: 3 }}>
|
||||
<Typography sx={{ color: c.text.muted, fontSize: '0.85rem' }}>
|
||||
You have unsaved changes. Would you like to save them before closing?
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2, gap: 1 }}>
|
||||
<Button
|
||||
onClick={handleConfirmDiscard}
|
||||
sx={{ color: c.status.error, textTransform: 'none' }}
|
||||
sx={{ color: c.status.error, textTransform: 'none', fontSize: '0.85rem' }}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setConfirmDiscard(false)}
|
||||
sx={{ color: c.text.muted, textTransform: 'none' }}
|
||||
sx={{ color: c.text.muted, textTransform: 'none', fontSize: '0.85rem' }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -547,7 +600,8 @@ const Settings: React.FC = () => {
|
||||
bgcolor: c.accent.primary,
|
||||
'&:hover': { bgcolor: c.accent.pressed },
|
||||
textTransform: 'none',
|
||||
borderRadius: 2,
|
||||
borderRadius: 1.5,
|
||||
fontSize: '0.85rem',
|
||||
}}
|
||||
>
|
||||
Save & Close
|
||||
|
||||
@@ -16,8 +16,9 @@ import { createDraftSession, removeDraftSession } from '@/shared/state/agentsSli
|
||||
import { createSkill } from '@/shared/state/skillsSlice';
|
||||
import AgentChat from '../AgentChat/AgentChat';
|
||||
import { ContextPath } from '@/app/components/DirectoryBrowser';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
|
||||
const SKILLS_WORKSPACE_API = `http://${window.location.hostname}:8324/api/skills`;
|
||||
const SKILLS_WORKSPACE_API = `${API_BASE}/skills`;
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
|
||||
export interface SkillPreviewData {
|
||||
|
||||
@@ -398,7 +398,7 @@ const Tools: React.FC = () => {
|
||||
// Registry browser
|
||||
const [registryOpen, setRegistryOpen] = useState(false);
|
||||
const [regQuery, setRegQuery] = useState('');
|
||||
const [regSort, setRegSort] = useState<'name' | 'stars'>('name');
|
||||
const [regSort, setRegSort] = useState<'name' | 'stars'>('stars');
|
||||
const [regSource, setRegSource] = useState<'' | 'community' | 'google'>('');
|
||||
const [expandedServer, setExpandedServer] = useState<string | null>(null);
|
||||
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity?: 'success' | 'error' }>({ open: false, message: '' });
|
||||
@@ -609,11 +609,11 @@ const Tools: React.FC = () => {
|
||||
handleMenuClose();
|
||||
setRegistryOpen(true);
|
||||
setRegQuery('');
|
||||
setRegSort('name');
|
||||
setRegSort('stars');
|
||||
setRegSource('');
|
||||
setExpandedServer(null);
|
||||
dispatch(fetchRegistryStats());
|
||||
dispatch(searchRegistry({ q: '', limit: 20, offset: 0, sort: 'name', source: '' }));
|
||||
dispatch(searchRegistry({ q: '', limit: 20, offset: 0, sort: 'stars', source: '' }));
|
||||
};
|
||||
|
||||
// --------------- Tool CRUD ---------------
|
||||
@@ -681,8 +681,7 @@ const Tools: React.FC = () => {
|
||||
try { parsedConfig = JSON.parse(mcpConfigJson); } catch { setMcpConfigError('Invalid JSON'); return; }
|
||||
|
||||
const f = serverToToolForm(mcpConfigServer);
|
||||
const isStdioConfig = parsedConfig.type === 'stdio' || !!parsedConfig.command;
|
||||
const authStatus = (mcpAuthType !== 'none' || isStdioConfig) ? 'configured' : 'none';
|
||||
const authStatus = 'configured';
|
||||
|
||||
await dispatch(createTool({
|
||||
name: f.name,
|
||||
@@ -1038,7 +1037,7 @@ const Tools: React.FC = () => {
|
||||
const isExpanded = expandedToolId === tool.id;
|
||||
const isMcp = tool.mcp_config && Object.keys(tool.mcp_config).length > 0;
|
||||
const isStdio = isMcp && (tool.mcp_config.type === 'stdio' || !!tool.mcp_config.command);
|
||||
const canDiscover = isMcp && (isStdio || tool.auth_status !== 'none');
|
||||
const canDiscover = isMcp;
|
||||
const perms = tool.tool_permissions || {};
|
||||
const services = perms._services as Record<string, { read?: string[]; write?: string[] }> | undefined;
|
||||
const descriptions = (perms._tool_descriptions || {}) as Record<string, string>;
|
||||
@@ -1303,7 +1302,7 @@ const Tools: React.FC = () => {
|
||||
Discover Tools
|
||||
</Button>
|
||||
{!canDiscover && (
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.72rem' }}>Connect the tool first to discover available permissions</Typography>
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.72rem' }}>Add an MCP configuration to enable tool discovery</Typography>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
|
||||
@@ -41,8 +41,9 @@ import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
||||
import CodeEditor from './CodeEditor';
|
||||
import { ElementSelectionProvider } from '@/app/components/ElementSelectionContext';
|
||||
import { captureViewThumbnail } from './captureViewThumbnail';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
|
||||
const WORKSPACE_API = `http://${window.location.hostname}:8324/api/outputs/workspace`;
|
||||
const WORKSPACE_API = `${API_BASE}/outputs/workspace`;
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
|
||||
function getFileIcon(filename: string): React.ReactNode {
|
||||
@@ -477,7 +478,11 @@ interface Props {
|
||||
const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const isNew = !output;
|
||||
|
||||
const [createdId, setCreatedId] = useState<string | null>(null);
|
||||
const createdIdRef = useRef<string | null>(null);
|
||||
const effectiveId = output?.id ?? createdId;
|
||||
const isNew = !effectiveId;
|
||||
|
||||
const [name, setName] = useState(output?.name ?? '');
|
||||
const [description, setDescription] = useState(output?.description ?? '');
|
||||
@@ -502,6 +507,10 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
|
||||
const [activeTab, setActiveTab] = useState(TAB_PREVIEW);
|
||||
const [activeFile, setActiveFile] = useState('index.html');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'unsaved' | 'saving' | 'saved'>('idle');
|
||||
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const savedStatusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const savingRef = useRef(false);
|
||||
const [executeResult, setExecuteResult] = useState<OutputExecuteResult | null>(null);
|
||||
const [showConsole, setShowConsole] = useState(false);
|
||||
const [consoleEntry, setConsoleEntry] = useState<ConsoleEntry | null>(null);
|
||||
@@ -609,6 +618,9 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
|
||||
const isAgentActive = agentStatus === 'running' || agentStatus === 'waiting_approval';
|
||||
|
||||
const workspaceId = workspacePath ? stableWorkspaceId : null;
|
||||
const workspaceIdRef = useRef<string | null>(null);
|
||||
workspaceIdRef.current = workspaceId;
|
||||
const wsPushTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
|
||||
const initialContextPaths = useMemo(
|
||||
() => workspacePath ? [{ path: workspacePath, type: 'directory' as const }] : undefined,
|
||||
@@ -742,36 +754,58 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const handleSave = async (close = true) => {
|
||||
const performSaveRef = useRef<((close: boolean) => Promise<void>) | null>(null);
|
||||
|
||||
performSaveRef.current = async (close: boolean) => {
|
||||
if (savingRef.current) return;
|
||||
savingRef.current = true;
|
||||
setSaving(true);
|
||||
setSaveStatus('saving');
|
||||
try {
|
||||
const body = buildBody();
|
||||
const eid = output?.id ?? createdIdRef.current;
|
||||
let savedId: string;
|
||||
if (output) {
|
||||
await dispatch(updateOutput({ id: output.id, ...body })).unwrap();
|
||||
savedId = output.id;
|
||||
if (eid) {
|
||||
await dispatch(updateOutput({ id: eid, ...body })).unwrap();
|
||||
savedId = eid;
|
||||
} else {
|
||||
const created = await dispatch(createOutput(body)).unwrap();
|
||||
savedId = created.id;
|
||||
createdIdRef.current = savedId;
|
||||
setCreatedId(savedId);
|
||||
}
|
||||
savedRef.current = true;
|
||||
setSaveStatus('saved');
|
||||
if (savedStatusTimerRef.current) clearTimeout(savedStatusTimerRef.current);
|
||||
savedStatusTimerRef.current = setTimeout(() => setSaveStatus('idle'), 3000);
|
||||
if (close) onClose();
|
||||
else previewRef.current?.reload();
|
||||
captureThumbnailAsync(savedId);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to save output:', err);
|
||||
setSaveStatus('unsaved');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
savingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (close = true) => {
|
||||
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current);
|
||||
await performSaveRef.current?.(close);
|
||||
};
|
||||
|
||||
const handleClose = async () => {
|
||||
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current);
|
||||
if (savedStatusTimerRef.current) clearTimeout(savedStatusTimerRef.current);
|
||||
const eid = output?.id ?? createdIdRef.current;
|
||||
if (!savedRef.current && (files['index.html'] ?? '').trim()) {
|
||||
try {
|
||||
const body = buildBody();
|
||||
let savedId: string;
|
||||
if (output) {
|
||||
await dispatch(updateOutput({ id: output.id, ...body })).unwrap();
|
||||
savedId = output.id;
|
||||
if (eid) {
|
||||
await dispatch(updateOutput({ id: eid, ...body })).unwrap();
|
||||
savedId = eid;
|
||||
} else {
|
||||
const created = await dispatch(createOutput(body)).unwrap();
|
||||
savedId = created.id;
|
||||
@@ -783,7 +817,8 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
|
||||
};
|
||||
|
||||
const handleRunPreview = async () => {
|
||||
if (!output) {
|
||||
const eid = output?.id ?? createdIdRef.current;
|
||||
if (!eid) {
|
||||
setExecuteResult(null);
|
||||
return;
|
||||
}
|
||||
@@ -791,7 +826,7 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
|
||||
setHasNewConsoleOutput(true);
|
||||
try {
|
||||
const res = await dispatch(
|
||||
executeOutput({ output_id: output.id, input_data: testInput })
|
||||
executeOutput({ output_id: eid, input_data: testInput })
|
||||
).unwrap();
|
||||
setExecuteResult(res);
|
||||
setConsoleEntry({ timestamp: Date.now(), inputData: res.input_data, stdout: res.stdout ?? null, stderr: res.stderr ?? null, backendResult: res.backend_result, error: res.error, source: 'execute' });
|
||||
@@ -809,12 +844,13 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
|
||||
try { schema = JSON.parse(schemaText); } catch { schema = { type: 'object', properties: {} }; }
|
||||
const forcedToolNames = config.forcedTools.flatMap((ft) => ft.tools);
|
||||
|
||||
if (forcedToolNames.length > 0 && output?.id) {
|
||||
const eid = output?.id ?? createdIdRef.current;
|
||||
if (forcedToolNames.length > 0 && eid) {
|
||||
try {
|
||||
const res = await dispatch(autoRunAgentOutput({
|
||||
prompt: config.prompt,
|
||||
input_schema: schema,
|
||||
output_id: output.id,
|
||||
output_id: eid,
|
||||
model: autoRunModel,
|
||||
forced_tools: forcedToolNames,
|
||||
context_paths: config.contextPaths.map((cp) => ({ path: cp.path, type: cp.type })),
|
||||
@@ -840,7 +876,7 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
|
||||
if (res.input_data) {
|
||||
setTestInput(res.input_data);
|
||||
setExecuteResult({
|
||||
output_id: output?.id ?? '',
|
||||
output_id: output?.id ?? createdIdRef.current ?? '',
|
||||
output_name: name,
|
||||
frontend_code: files['index.html'] ?? '',
|
||||
input_data: res.input_data,
|
||||
@@ -869,7 +905,7 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
|
||||
if (tc.tool !== 'RenderOutput' || !tc.input?.input_data) continue;
|
||||
setTestInput(tc.input.input_data);
|
||||
setExecuteResult({
|
||||
output_id: output?.id ?? '',
|
||||
output_id: output?.id ?? createdIdRef.current ?? '',
|
||||
output_name: name,
|
||||
frontend_code: files['index.html'] ?? '',
|
||||
input_data: tc.input.input_data,
|
||||
@@ -890,7 +926,7 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
|
||||
if (lastSys) {
|
||||
const errMsg = typeof lastSys.content === 'string' ? lastSys.content : JSON.stringify(lastSys.content);
|
||||
setExecuteResult({
|
||||
output_id: output?.id ?? '',
|
||||
output_id: output?.id ?? createdIdRef.current ?? '',
|
||||
output_name: name,
|
||||
frontend_code: files['index.html'] ?? '',
|
||||
input_data: {},
|
||||
@@ -939,6 +975,21 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
|
||||
|
||||
const updateFile = useCallback((path: string, content: string) => {
|
||||
setFiles(prev => ({ ...prev, [path]: content }));
|
||||
const wsId = workspaceIdRef.current;
|
||||
if (wsId) {
|
||||
const existing = wsPushTimers.current.get(path);
|
||||
if (existing) clearTimeout(existing);
|
||||
wsPushTimers.current.set(path, setTimeout(() => {
|
||||
wsPushTimers.current.delete(path);
|
||||
fetch(`${WORKSPACE_API}/${wsId}/file/${encodeURIComponent(path)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content }),
|
||||
})
|
||||
.then(() => previewRef.current?.reload())
|
||||
.catch(() => {});
|
||||
}, 300));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [newFileName, setNewFileName] = useState('');
|
||||
@@ -986,6 +1037,34 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
|
||||
|
||||
const activeFileContent = files[activeFile] ?? '';
|
||||
|
||||
const autoSaveInitRef = useRef(true);
|
||||
useEffect(() => {
|
||||
if (autoSaveInitRef.current) {
|
||||
autoSaveInitRef.current = false;
|
||||
return;
|
||||
}
|
||||
const hasContent = name.trim() || (files['index.html'] ?? '').trim();
|
||||
if (!hasContent) return;
|
||||
if (!savingRef.current) {
|
||||
setSaveStatus('unsaved');
|
||||
}
|
||||
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current);
|
||||
autoSaveTimerRef.current = setTimeout(() => {
|
||||
performSaveRef.current?.(false);
|
||||
}, 1500);
|
||||
return () => {
|
||||
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current);
|
||||
};
|
||||
}, [files, name, description, autoRunEnabled, autoRunMode, autoRunModel]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current);
|
||||
if (savedStatusTimerRef.current) clearTimeout(savedStatusTimerRef.current);
|
||||
wsPushTimers.current.forEach(t => clearTimeout(t));
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ElementSelectionProvider>
|
||||
<Box sx={{ height: '100%', display: 'flex', overflow: 'hidden' }}>
|
||||
@@ -1108,6 +1187,27 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
|
||||
{autoRunning ? 'Running…' : 'Auto Run'}
|
||||
</Button>
|
||||
)}
|
||||
{saveStatus === 'unsaved' && (
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.ghost, fontStyle: 'italic', whiteSpace: 'nowrap' }}>
|
||||
Unsaved changes
|
||||
</Typography>
|
||||
)}
|
||||
{saveStatus === 'saving' && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<CircularProgress size={12} sx={{ color: c.text.ghost }} />
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.ghost, whiteSpace: 'nowrap' }}>
|
||||
Saving…
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{saveStatus === 'saved' && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<CheckCircleOutlineIcon sx={{ fontSize: 14, color: c.accent.primary }} />
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.accent.primary, whiteSpace: 'nowrap' }}>
|
||||
Saved
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<SaveIcon sx={{ fontSize: 16 }} />}
|
||||
@@ -1123,7 +1223,7 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
|
||||
'&:hover': { bgcolor: c.accent.hover },
|
||||
}}
|
||||
>
|
||||
{saving ? 'Saving...' : isNew ? 'Create' : 'Save'}
|
||||
{saving ? 'Saving…' : isNew ? 'Create' : 'Save'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -1172,7 +1272,7 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
|
||||
<RefreshIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{output && (
|
||||
{effectiveId && (
|
||||
<Tooltip title="Execute with backend code">
|
||||
<IconButton
|
||||
size="small"
|
||||
|
||||
@@ -7,7 +7,7 @@ import Button from '@mui/material/Button';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import { Output, executeOutput, OutputExecuteResult, getFrontendCode, getBackendCode, buildServeUrl } from '@/shared/state/outputsSlice';
|
||||
import { Output, executeOutput, OutputExecuteResult, getFrontendCode, getBackendCode, buildServeUrl, SERVE_BASE } from '@/shared/state/outputsSlice';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import InputSchemaForm, { getDefault } from './InputSchemaForm';
|
||||
@@ -109,14 +109,14 @@ const ViewRunDialog: React.FC<Props> = ({ output, onClose }) => {
|
||||
)}
|
||||
{result ? (
|
||||
<ViewPreview
|
||||
serveUrl={`/api/outputs/${output.id}/serve/index.html`}
|
||||
serveUrl={`${SERVE_BASE}/${output.id}/serve/index.html`}
|
||||
frontendCode={result.frontend_code}
|
||||
inputData={result.input_data}
|
||||
backendResult={result.backend_result}
|
||||
/>
|
||||
) : (
|
||||
<ViewPreview
|
||||
serveUrl={`/api/outputs/${output.id}/serve/index.html`}
|
||||
serveUrl={`${SERVE_BASE}/${output.id}/serve/index.html`}
|
||||
frontendCode={getFrontendCode(output)}
|
||||
inputData={inputData}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { getWebview, type BrowserWebview } from './browserRegistry';
|
||||
import { dashboardWs } from './ws/WebSocketManager';
|
||||
|
||||
let initialized = false;
|
||||
|
||||
export type BrowserAction = 'screenshot' | 'get_text' | 'navigate' | 'click' | 'type' | 'evaluate';
|
||||
|
||||
export interface BrowserActivity {
|
||||
action: BrowserAction;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
type ActivityListener = (browserId: string, activity: BrowserActivity | null) => void;
|
||||
|
||||
const activityMap = new Map<string, BrowserActivity>();
|
||||
const listeners = new Set<ActivityListener>();
|
||||
|
||||
function setActivity(browserId: string, activity: BrowserActivity | null) {
|
||||
if (activity) {
|
||||
activityMap.set(browserId, activity);
|
||||
} else {
|
||||
activityMap.delete(browserId);
|
||||
}
|
||||
listeners.forEach((fn) => fn(browserId, activity));
|
||||
}
|
||||
|
||||
export function getActivity(browserId: string): BrowserActivity | null {
|
||||
return activityMap.get(browserId) ?? null;
|
||||
}
|
||||
|
||||
export function subscribeActivity(fn: ActivityListener): () => void {
|
||||
listeners.add(fn);
|
||||
return () => { listeners.delete(fn); };
|
||||
}
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
screenshot: 'Capturing...',
|
||||
get_text: 'Reading...',
|
||||
navigate: 'Navigating...',
|
||||
click: 'Clicking...',
|
||||
type: 'Typing...',
|
||||
evaluate: 'Evaluating...',
|
||||
};
|
||||
|
||||
export function getActionLabel(action: string): string {
|
||||
return ACTION_LABELS[action] ?? 'Working...';
|
||||
}
|
||||
|
||||
async function handleScreenshot(wv: BrowserWebview): Promise<Record<string, any>> {
|
||||
const nativeImage = await wv.capturePage();
|
||||
const dataUrl = nativeImage.toDataURL();
|
||||
const base64 = dataUrl.replace(/^data:image\/\w+;base64,/, '');
|
||||
return { image: base64, url: wv.getURL(), title: wv.getTitle() };
|
||||
}
|
||||
|
||||
async function handleGetText(wv: BrowserWebview): Promise<Record<string, any>> {
|
||||
const text: string = await wv.executeJavaScript(
|
||||
'document.body.innerText.substring(0, 15000)'
|
||||
);
|
||||
return { text, url: wv.getURL(), title: wv.getTitle() };
|
||||
}
|
||||
|
||||
async function handleNavigate(wv: BrowserWebview, params: Record<string, any>): Promise<Record<string, any>> {
|
||||
const url = params.url as string;
|
||||
if (!url) return { error: 'url parameter is required' };
|
||||
await wv.loadURL(url);
|
||||
return { text: `Navigated to ${url}`, url };
|
||||
}
|
||||
|
||||
async function handleClick(wv: BrowserWebview, params: Record<string, any>): Promise<Record<string, any>> {
|
||||
const selector = params.selector as string;
|
||||
if (!selector) return { error: 'selector parameter is required' };
|
||||
const safeSelector = JSON.stringify(selector);
|
||||
const code = `(()=>{const el=document.querySelector(${safeSelector});if(!el)return{error:'Element not found: '+${safeSelector}};el.click();return{text:'Clicked element: '+el.tagName.toLowerCase()+(el.id?'#'+el.id:'')}})()`;
|
||||
const result = await wv.executeJavaScript(code);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function handleType(wv: BrowserWebview, params: Record<string, any>): Promise<Record<string, any>> {
|
||||
const selector = params.selector as string;
|
||||
const text = params.text as string;
|
||||
if (!selector) return { error: 'selector parameter is required' };
|
||||
if (text == null) return { error: 'text parameter is required' };
|
||||
const safeSelector = JSON.stringify(selector);
|
||||
const safeText = JSON.stringify(text);
|
||||
const code = `(()=>{const el=document.querySelector(${safeSelector});if(!el)return{error:'Element not found: '+${safeSelector}};el.focus();el.value=${safeText};el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));return{text:'Typed into: '+el.tagName.toLowerCase()+(el.id?'#'+el.id:'')}})()`;
|
||||
const result = await wv.executeJavaScript(code);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function handleEvaluate(wv: BrowserWebview, params: Record<string, any>): Promise<Record<string, any>> {
|
||||
const expression = params.expression as string;
|
||||
if (!expression) return { error: 'expression parameter is required' };
|
||||
try {
|
||||
const result = await wv.executeJavaScript(expression);
|
||||
const text = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
|
||||
return { text: text ?? 'undefined', url: wv.getURL() };
|
||||
} catch (err: any) {
|
||||
return { error: `JS evaluation error: ${err?.message || String(err)}` };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBrowserCommand(data: Record<string, any>) {
|
||||
const { request_id, action, browser_id, params = {} } = data;
|
||||
if (!request_id) return;
|
||||
|
||||
const wv = getWebview(browser_id);
|
||||
if (!wv) {
|
||||
dashboardWs.send('browser:result', {
|
||||
request_id,
|
||||
error: `Browser card '${browser_id}' not found or not an Electron webview`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const detail = params.url || params.selector || params.expression || undefined;
|
||||
setActivity(browser_id, { action: action as BrowserAction, detail });
|
||||
|
||||
let result: Record<string, any>;
|
||||
try {
|
||||
switch (action) {
|
||||
case 'screenshot':
|
||||
result = await handleScreenshot(wv);
|
||||
break;
|
||||
case 'get_text':
|
||||
result = await handleGetText(wv);
|
||||
break;
|
||||
case 'navigate':
|
||||
result = await handleNavigate(wv, params);
|
||||
break;
|
||||
case 'click':
|
||||
result = await handleClick(wv, params);
|
||||
break;
|
||||
case 'type':
|
||||
result = await handleType(wv, params);
|
||||
break;
|
||||
case 'evaluate':
|
||||
result = await handleEvaluate(wv, params);
|
||||
break;
|
||||
default:
|
||||
result = { error: `Unknown browser action: ${action}` };
|
||||
}
|
||||
} catch (err: any) {
|
||||
result = { error: `Browser command failed: ${err?.message || String(err)}` };
|
||||
}
|
||||
|
||||
setActivity(browser_id, null);
|
||||
dashboardWs.send('browser:result', { request_id, ...result });
|
||||
}
|
||||
|
||||
export function initBrowserCommandHandler(): () => void {
|
||||
if (initialized) return () => {};
|
||||
initialized = true;
|
||||
const unsub = dashboardWs.on('browser:command', handleBrowserCommand);
|
||||
return () => {
|
||||
unsub();
|
||||
initialized = false;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export interface BrowserWebview extends HTMLElement {
|
||||
src: string;
|
||||
loadURL: (url: string) => Promise<void>;
|
||||
goBack: () => void;
|
||||
goForward: () => void;
|
||||
reload: () => void;
|
||||
canGoBack: () => boolean;
|
||||
canGoForward: () => boolean;
|
||||
getURL: () => string;
|
||||
getTitle: () => string;
|
||||
capturePage: (rect?: { x: number; y: number; width: number; height: number }) => Promise<{
|
||||
toDataURL: () => string;
|
||||
toPNG: () => Buffer;
|
||||
}>;
|
||||
executeJavaScript: (code: string) => Promise<any>;
|
||||
sendInputEvent: (event: any) => void;
|
||||
addEventListener: (event: string, listener: (...args: any[]) => void) => void;
|
||||
removeEventListener: (event: string, listener: (...args: any[]) => void) => void;
|
||||
}
|
||||
|
||||
const registry = new Map<string, BrowserWebview>();
|
||||
|
||||
export function registerWebview(browserId: string, wv: BrowserWebview): void {
|
||||
registry.set(browserId, wv);
|
||||
}
|
||||
|
||||
export function unregisterWebview(browserId: string): void {
|
||||
registry.delete(browserId);
|
||||
}
|
||||
|
||||
export function getWebview(browserId: string): BrowserWebview | undefined {
|
||||
return registry.get(browserId);
|
||||
}
|
||||
|
||||
export function getAllWebviews(): Map<string, BrowserWebview> {
|
||||
return new Map(registry);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
const port = (window as any).__OPENSWARM_PORT__ || 8324;
|
||||
const host = window.location.hostname || 'localhost';
|
||||
|
||||
export const API_BASE = `http://${host}:${port}/api`;
|
||||
export const WS_BASE = `ws://${host}:${port}`;
|
||||
@@ -1,14 +0,0 @@
|
||||
const is_local = true; // NOTE: Change this to false when deploying to production
|
||||
|
||||
const LOCAL_API_URL = 'http://localhost:8324/api';
|
||||
const PROD_API_URL = 'https://your-domain.com/api';
|
||||
|
||||
// HEALTH - Endpoints
|
||||
export const HEALTH_CHECK_URL = (is_local ? LOCAL_API_URL : PROD_API_URL) + '/health/check';
|
||||
|
||||
// ITEM DB - Endpoints
|
||||
export const CREATE_ITEM_URL = (is_local ? LOCAL_API_URL : PROD_API_URL) + '/item_db/create';
|
||||
export const LIST_ITEMS_URL = (is_local ? LOCAL_API_URL : PROD_API_URL) + '/item_db/list';
|
||||
export const GET_ITEM_URL = (is_local ? LOCAL_API_URL : PROD_API_URL) + '/item_db/get';
|
||||
export const UPDATE_ITEM_URL = (is_local ? LOCAL_API_URL : PROD_API_URL) + '/item_db/update';
|
||||
export const DELETE_ITEM_URL = (is_local ? LOCAL_API_URL : PROD_API_URL) + '/item_db/delete';
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
|
||||
const API_BASE = `http://${window.location.hostname}:8324/api/agents`;
|
||||
const AGENTS_API = `${API_BASE}/agents`;
|
||||
|
||||
export interface AgentMessage {
|
||||
id: string;
|
||||
@@ -124,14 +125,14 @@ export const fetchSessions = createAsyncThunk(
|
||||
const params = new URLSearchParams();
|
||||
if (dashboardId) params.set('dashboard_id', dashboardId);
|
||||
const qs = params.toString();
|
||||
const res = await fetch(`${API_BASE}/sessions${qs ? `?${qs}` : ''}`);
|
||||
const res = await fetch(`${AGENTS_API}/sessions${qs ? `?${qs}` : ''}`);
|
||||
const data = await res.json();
|
||||
return data.sessions as AgentSession[];
|
||||
},
|
||||
);
|
||||
|
||||
export const launchAgent = createAsyncThunk('agents/launchAgent', async (config: AgentConfig) => {
|
||||
const res = await fetch(`${API_BASE}/launch`, {
|
||||
const res = await fetch(`${AGENTS_API}/launch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
@@ -154,7 +155,7 @@ export interface SendMessagePayload {
|
||||
export const sendMessage = createAsyncThunk(
|
||||
'agents/sendMessage',
|
||||
async ({ sessionId, prompt, mode, model, images, contextPaths, forcedTools, attachedSkills }: SendMessagePayload) => {
|
||||
await fetch(`${API_BASE}/sessions/${sessionId}/message`, {
|
||||
await fetch(`${AGENTS_API}/sessions/${sessionId}/message`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt, mode, model, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills }),
|
||||
@@ -166,7 +167,7 @@ export const sendMessage = createAsyncThunk(
|
||||
export const stopAgent = createAsyncThunk(
|
||||
'agents/stopAgent',
|
||||
async ({ sessionId, removeWorktree = false }: { sessionId: string; removeWorktree?: boolean }) => {
|
||||
await fetch(`${API_BASE}/sessions/${sessionId}/stop`, {
|
||||
await fetch(`${AGENTS_API}/sessions/${sessionId}/stop`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ remove_worktree: removeWorktree }),
|
||||
@@ -178,7 +179,7 @@ export const stopAgent = createAsyncThunk(
|
||||
export const editMessage = createAsyncThunk(
|
||||
'agents/editMessage',
|
||||
async ({ sessionId, messageId, content }: { sessionId: string; messageId: string; content: string }) => {
|
||||
await fetch(`${API_BASE}/sessions/${sessionId}/edit_message`, {
|
||||
await fetch(`${AGENTS_API}/sessions/${sessionId}/edit_message`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message_id: messageId, content }),
|
||||
@@ -190,7 +191,7 @@ export const editMessage = createAsyncThunk(
|
||||
export const switchBranch = createAsyncThunk(
|
||||
'agents/switchBranch',
|
||||
async ({ sessionId, branchId }: { sessionId: string; branchId: string }) => {
|
||||
await fetch(`${API_BASE}/sessions/${sessionId}/switch_branch`, {
|
||||
await fetch(`${AGENTS_API}/sessions/${sessionId}/switch_branch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ branch_id: branchId }),
|
||||
@@ -215,7 +216,7 @@ export interface LaunchAndSendPayload {
|
||||
export const fetchSession = createAsyncThunk(
|
||||
'agents/fetchSession',
|
||||
async (sessionId: string) => {
|
||||
const res = await fetch(`${API_BASE}/sessions/${sessionId}`);
|
||||
const res = await fetch(`${AGENTS_API}/sessions/${sessionId}`);
|
||||
const session = await res.json();
|
||||
return session as AgentSession;
|
||||
}
|
||||
@@ -224,7 +225,7 @@ export const fetchSession = createAsyncThunk(
|
||||
export const launchAndSendFirstMessage = createAsyncThunk(
|
||||
'agents/launchAndSendFirstMessage',
|
||||
async ({ draftId, config, prompt, mode, model, images, contextPaths, forcedTools, attachedSkills }: LaunchAndSendPayload) => {
|
||||
const launchRes = await fetch(`${API_BASE}/launch`, {
|
||||
const launchRes = await fetch(`${AGENTS_API}/launch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
@@ -232,13 +233,13 @@ export const launchAndSendFirstMessage = createAsyncThunk(
|
||||
const launchData = await launchRes.json();
|
||||
const session = launchData.session as AgentSession;
|
||||
|
||||
await fetch(`${API_BASE}/sessions/${session.id}/message`, {
|
||||
await fetch(`${AGENTS_API}/sessions/${session.id}/message`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt, mode, model, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills }),
|
||||
});
|
||||
|
||||
const refreshRes = await fetch(`${API_BASE}/sessions/${session.id}`);
|
||||
const refreshRes = await fetch(`${AGENTS_API}/sessions/${session.id}`);
|
||||
const updatedSession = await refreshRes.json() as AgentSession;
|
||||
|
||||
return { draftId, session: updatedSession };
|
||||
@@ -248,7 +249,7 @@ export const launchAndSendFirstMessage = createAsyncThunk(
|
||||
export const generateTitle = createAsyncThunk(
|
||||
'agents/generateTitle',
|
||||
async ({ sessionId, prompt }: { sessionId: string; prompt: string }) => {
|
||||
const res = await fetch(`${API_BASE}/sessions/${sessionId}/generate-title`, {
|
||||
const res = await fetch(`${AGENTS_API}/sessions/${sessionId}/generate-title`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt }),
|
||||
@@ -269,7 +270,7 @@ export interface GenerateGroupMetaPayload {
|
||||
export const generateGroupMeta = createAsyncThunk(
|
||||
'agents/generateGroupMeta',
|
||||
async ({ sessionId, groupId, toolCalls, resultsSummary, isRefinement }: GenerateGroupMetaPayload) => {
|
||||
const res = await fetch(`${API_BASE}/sessions/${sessionId}/generate-group-meta`, {
|
||||
const res = await fetch(`${AGENTS_API}/sessions/${sessionId}/generate-group-meta`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -287,7 +288,7 @@ export const generateGroupMeta = createAsyncThunk(
|
||||
export const updateSystemPrompt = createAsyncThunk(
|
||||
'agents/updateSystemPrompt',
|
||||
async ({ sessionId, systemPrompt }: { sessionId: string; systemPrompt: string }) => {
|
||||
await fetch(`${API_BASE}/sessions/${sessionId}`, {
|
||||
await fetch(`${AGENTS_API}/sessions/${sessionId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ system_prompt: systemPrompt }),
|
||||
@@ -309,7 +310,7 @@ export const handleApproval = createAsyncThunk(
|
||||
message?: string;
|
||||
updatedInput?: Record<string, any>;
|
||||
}) => {
|
||||
await fetch(`${API_BASE}/approval`, {
|
||||
await fetch(`${AGENTS_API}/approval`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ request_id: requestId, behavior, message, updated_input: updatedInput }),
|
||||
@@ -321,7 +322,7 @@ export const handleApproval = createAsyncThunk(
|
||||
export const closeSession = createAsyncThunk(
|
||||
'agents/closeSession',
|
||||
async ({ sessionId }: { sessionId: string }) => {
|
||||
await fetch(`${API_BASE}/sessions/${sessionId}/close`, { method: 'POST' });
|
||||
await fetch(`${AGENTS_API}/sessions/${sessionId}/close`, { method: 'POST' });
|
||||
return sessionId;
|
||||
}
|
||||
);
|
||||
@@ -329,7 +330,7 @@ export const closeSession = createAsyncThunk(
|
||||
export const deleteSession = createAsyncThunk(
|
||||
'agents/deleteSession',
|
||||
async ({ sessionId }: { sessionId: string }) => {
|
||||
await fetch(`${API_BASE}/sessions/${sessionId}`, { method: 'DELETE' });
|
||||
await fetch(`${AGENTS_API}/sessions/${sessionId}`, { method: 'DELETE' });
|
||||
return sessionId;
|
||||
}
|
||||
);
|
||||
@@ -339,7 +340,7 @@ export const fetchHistory = createAsyncThunk(
|
||||
async ({ dashboardId }: { dashboardId?: string } = {}) => {
|
||||
const params = new URLSearchParams({ limit: '10000' });
|
||||
if (dashboardId) params.set('dashboard_id', dashboardId);
|
||||
const res = await fetch(`${API_BASE}/history?${params}`);
|
||||
const res = await fetch(`${AGENTS_API}/history?${params}`);
|
||||
const data = await res.json();
|
||||
return data.sessions as HistorySession[];
|
||||
},
|
||||
@@ -357,7 +358,7 @@ export const searchHistory = createAsyncThunk(
|
||||
async ({ q = '', limit = 20, offset = 0, dashboardId }: SearchHistoryParams) => {
|
||||
const params = new URLSearchParams({ q, limit: String(limit), offset: String(offset) });
|
||||
if (dashboardId) params.set('dashboard_id', dashboardId);
|
||||
const res = await fetch(`${API_BASE}/history?${params}`);
|
||||
const res = await fetch(`${AGENTS_API}/history?${params}`);
|
||||
const data = await res.json();
|
||||
return {
|
||||
sessions: data.sessions as HistorySession[],
|
||||
@@ -372,7 +373,7 @@ export const searchHistory = createAsyncThunk(
|
||||
export const resumeSession = createAsyncThunk(
|
||||
'agents/resumeSession',
|
||||
async ({ sessionId }: { sessionId: string }) => {
|
||||
const res = await fetch(`${API_BASE}/sessions/${sessionId}/resume`, { method: 'POST' });
|
||||
const res = await fetch(`${AGENTS_API}/sessions/${sessionId}/resume`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
return data.session as AgentSession;
|
||||
}
|
||||
@@ -454,6 +455,10 @@ const agentsSlice = createSlice({
|
||||
state.expandedSessionIds = [];
|
||||
},
|
||||
|
||||
setExpandedSessionIds(state, action: PayloadAction<string[]>) {
|
||||
state.expandedSessionIds = action.payload;
|
||||
},
|
||||
|
||||
updateSessionName(state, action: PayloadAction<{ sessionId: string; name: string }>) {
|
||||
const session = state.sessions[action.payload.sessionId];
|
||||
if (session) {
|
||||
@@ -838,6 +843,7 @@ export const {
|
||||
expandSession,
|
||||
collapseSession,
|
||||
collapseAllSessions,
|
||||
setExpandedSessionIds,
|
||||
updateSessionName,
|
||||
updateGroupMeta,
|
||||
setDraftSystemPrompt,
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { launchAndSendFirstMessage } from './agentsSlice';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
|
||||
const API_BASE = `http://${window.location.hostname}:8324/api/dashboards`;
|
||||
const DASHBOARDS_API = `${API_BASE}/dashboards`;
|
||||
|
||||
export const DEFAULT_CARD_W = 480;
|
||||
export const DEFAULT_CARD_H = 280;
|
||||
export const DEFAULT_VIEW_CARD_W = 480;
|
||||
export const DEFAULT_VIEW_CARD_H = 360;
|
||||
export const DEFAULT_BROWSER_CARD_W = 640;
|
||||
export const DEFAULT_BROWSER_CARD_H = 480;
|
||||
const GRID_GAP = 24;
|
||||
const GRID_ORIGIN = { x: 40, y: 100 };
|
||||
const GRID_COLS_FALLBACK = 4;
|
||||
@@ -27,9 +30,20 @@ export interface ViewCardPosition {
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface BrowserCardPosition {
|
||||
browser_id: string;
|
||||
url: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface DashboardLayoutState {
|
||||
cards: Record<string, CardPosition>;
|
||||
viewCards: Record<string, ViewCardPosition>;
|
||||
browserCards: Record<string, BrowserCardPosition>;
|
||||
persistedExpandedSessionIds: string[];
|
||||
loading: boolean;
|
||||
initialized: boolean;
|
||||
}
|
||||
@@ -37,6 +51,8 @@ export interface DashboardLayoutState {
|
||||
const initialState: DashboardLayoutState = {
|
||||
cards: {},
|
||||
viewCards: {},
|
||||
browserCards: {},
|
||||
persistedExpandedSessionIds: [],
|
||||
loading: false,
|
||||
initialized: false,
|
||||
};
|
||||
@@ -44,17 +60,21 @@ const initialState: DashboardLayoutState = {
|
||||
interface LayoutPayload {
|
||||
cards: Record<string, CardPosition>;
|
||||
viewCards: Record<string, ViewCardPosition>;
|
||||
browserCards: Record<string, BrowserCardPosition>;
|
||||
expandedSessionIds: string[];
|
||||
}
|
||||
|
||||
export const fetchLayout = createAsyncThunk(
|
||||
'dashboardLayout/fetch',
|
||||
async (dashboardId: string) => {
|
||||
const res = await fetch(`${API_BASE}/${dashboardId}`);
|
||||
const res = await fetch(`${DASHBOARDS_API}/${dashboardId}`);
|
||||
const data = await res.json();
|
||||
const layout = data.layout ?? {};
|
||||
return {
|
||||
cards: (layout.cards ?? {}) as Record<string, CardPosition>,
|
||||
viewCards: (layout.view_cards ?? {}) as Record<string, ViewCardPosition>,
|
||||
browserCards: (layout.browser_cards ?? {}) as Record<string, BrowserCardPosition>,
|
||||
expandedSessionIds: (layout.expanded_session_ids ?? []) as string[],
|
||||
} satisfies LayoutPayload;
|
||||
},
|
||||
);
|
||||
@@ -71,11 +91,16 @@ export const saveLayout = createAsyncThunk(
|
||||
if (saveTimeout) clearTimeout(saveTimeout);
|
||||
return new Promise<SaveLayoutPayload>((resolve) => {
|
||||
saveTimeout = setTimeout(async () => {
|
||||
await fetch(`${API_BASE}/${payload.dashboardId}`, {
|
||||
await fetch(`${DASHBOARDS_API}/${payload.dashboardId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
layout: { cards: payload.cards, view_cards: payload.viewCards },
|
||||
layout: {
|
||||
cards: payload.cards,
|
||||
view_cards: payload.viewCards,
|
||||
browser_cards: payload.browserCards,
|
||||
expanded_session_ids: payload.expandedSessionIds,
|
||||
},
|
||||
}),
|
||||
});
|
||||
resolve(payload);
|
||||
@@ -165,10 +190,14 @@ const dashboardLayoutSlice = createSlice({
|
||||
}
|
||||
|
||||
const hasDraftCard = Object.keys(state.cards).some((id) => id.startsWith('draft-'));
|
||||
const extraOccupied: Record<string, { x: number; y: number }> = {
|
||||
...Object.fromEntries(Object.values(state.viewCards).map((c) => [c.output_id, c])),
|
||||
...Object.fromEntries(Object.values(state.browserCards).map((c) => [c.browser_id, c])),
|
||||
};
|
||||
const newIds = action.payload.filter((id) => !state.cards[id]);
|
||||
for (const id of newIds) {
|
||||
if (hasDraftCard && !id.startsWith('draft-')) continue;
|
||||
const pos = findOpenGridCell(state.cards, new Set(), state.viewCards);
|
||||
const pos = findOpenGridCell(state.cards, new Set(), extraOccupied);
|
||||
state.cards[id] = {
|
||||
session_id: id,
|
||||
x: pos.x,
|
||||
@@ -182,12 +211,14 @@ const dashboardLayoutSlice = createSlice({
|
||||
tidyLayout(state) {
|
||||
const agentCards = Object.values(state.cards);
|
||||
const viewCards = Object.values(state.viewCards);
|
||||
const total = agentCards.length + viewCards.length;
|
||||
const bCards = Object.values(state.browserCards);
|
||||
const total = agentCards.length + viewCards.length + bCards.length;
|
||||
if (total === 0) return;
|
||||
|
||||
const allItems = [
|
||||
...agentCards.map((c) => ({ kind: 'agent' as const, id: c.session_id, x: c.x, y: c.y })),
|
||||
...viewCards.map((c) => ({ kind: 'view' as const, id: c.output_id, x: c.x, y: c.y })),
|
||||
...bCards.map((c) => ({ kind: 'browser' as const, id: c.browser_id, x: c.x, y: c.y })),
|
||||
];
|
||||
allItems.sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
|
||||
@@ -210,9 +241,12 @@ const dashboardLayoutSlice = createSlice({
|
||||
if (item.kind === 'agent') {
|
||||
const card = state.cards[item.id];
|
||||
if (card) { card.x = nx; card.y = ny; card.width = DEFAULT_CARD_W; card.height = DEFAULT_CARD_H; }
|
||||
} else {
|
||||
} else if (item.kind === 'view') {
|
||||
const card = state.viewCards[item.id];
|
||||
if (card) { card.x = nx; card.y = ny; card.width = DEFAULT_VIEW_CARD_W; card.height = DEFAULT_VIEW_CARD_H; }
|
||||
} else {
|
||||
const card = state.browserCards[item.id];
|
||||
if (card) { card.x = nx; card.y = ny; card.width = DEFAULT_BROWSER_CARD_W; card.height = DEFAULT_BROWSER_CARD_H; }
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -255,6 +289,89 @@ const dashboardLayoutSlice = createSlice({
|
||||
delete state.viewCards[action.payload];
|
||||
},
|
||||
|
||||
addBrowserCard(state, action: PayloadAction<{ url: string }>) {
|
||||
const id = `browser-${Date.now().toString(36)}`;
|
||||
const allOccupied: Record<string, { x: number; y: number }> = {
|
||||
...Object.fromEntries(Object.values(state.viewCards).map((c) => [c.output_id, c])),
|
||||
...Object.fromEntries(Object.values(state.browserCards).map((c) => [c.browser_id, c])),
|
||||
};
|
||||
const pos = findOpenGridCell(state.cards, new Set(), allOccupied);
|
||||
state.browserCards[id] = {
|
||||
browser_id: id,
|
||||
url: action.payload.url,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
width: DEFAULT_BROWSER_CARD_W,
|
||||
height: DEFAULT_BROWSER_CARD_H,
|
||||
};
|
||||
},
|
||||
|
||||
setBrowserCardPosition(
|
||||
state,
|
||||
action: PayloadAction<{ browserId: string; x: number; y: number }>
|
||||
) {
|
||||
const { browserId, x, y } = action.payload;
|
||||
const card = state.browserCards[browserId];
|
||||
if (card) { card.x = x; card.y = y; }
|
||||
},
|
||||
|
||||
setBrowserCardSize(
|
||||
state,
|
||||
action: PayloadAction<{ browserId: string; width: number; height: number }>
|
||||
) {
|
||||
const { browserId, width, height } = action.payload;
|
||||
const card = state.browserCards[browserId];
|
||||
if (card) {
|
||||
card.width = Math.max(400, width);
|
||||
card.height = Math.max(300, height);
|
||||
}
|
||||
},
|
||||
|
||||
removeBrowserCard(state, action: PayloadAction<string>) {
|
||||
delete state.browserCards[action.payload];
|
||||
},
|
||||
|
||||
updateBrowserCardUrl(
|
||||
state,
|
||||
action: PayloadAction<{ browserId: string; url: string }>
|
||||
) {
|
||||
const { browserId, url } = action.payload;
|
||||
const card = state.browserCards[browserId];
|
||||
if (card) { card.url = url; }
|
||||
},
|
||||
|
||||
moveCards(
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
items: Array<{ id: string; type: 'agent' | 'view' | 'browser' }>;
|
||||
dx: number;
|
||||
dy: number;
|
||||
}>,
|
||||
) {
|
||||
const { items, dx, dy } = action.payload;
|
||||
for (const item of items) {
|
||||
if (item.type === 'agent') {
|
||||
const card = state.cards[item.id];
|
||||
if (card) {
|
||||
card.x += dx;
|
||||
card.y += dy;
|
||||
}
|
||||
} else if (item.type === 'view') {
|
||||
const card = state.viewCards[item.id];
|
||||
if (card) {
|
||||
card.x += dx;
|
||||
card.y += dy;
|
||||
}
|
||||
} else {
|
||||
const card = state.browserCards[item.id];
|
||||
if (card) {
|
||||
card.x += dx;
|
||||
card.y += dy;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
replaceDraftId(
|
||||
state,
|
||||
action: PayloadAction<{ oldId: string; newId: string }>
|
||||
@@ -270,6 +387,8 @@ const dashboardLayoutSlice = createSlice({
|
||||
resetLayout(state) {
|
||||
state.cards = {};
|
||||
state.viewCards = {};
|
||||
state.browserCards = {};
|
||||
state.persistedExpandedSessionIds = [];
|
||||
state.initialized = false;
|
||||
},
|
||||
|
||||
@@ -284,6 +403,8 @@ const dashboardLayoutSlice = createSlice({
|
||||
state.initialized = true;
|
||||
state.cards = action.payload.cards;
|
||||
state.viewCards = action.payload.viewCards;
|
||||
state.browserCards = action.payload.browserCards;
|
||||
state.persistedExpandedSessionIds = action.payload.expandedSessionIds;
|
||||
})
|
||||
.addCase(fetchLayout.rejected, (state) => {
|
||||
state.loading = false;
|
||||
@@ -311,6 +432,12 @@ export const {
|
||||
setViewCardPosition,
|
||||
setViewCardSize,
|
||||
removeViewCard,
|
||||
addBrowserCard,
|
||||
setBrowserCardPosition,
|
||||
setBrowserCardSize,
|
||||
removeBrowserCard,
|
||||
updateBrowserCardUrl,
|
||||
moveCards,
|
||||
resetLayout,
|
||||
} = dashboardLayoutSlice.actions;
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
|
||||
const API_BASE = `http://${window.location.hostname}:8324/api/dashboards`;
|
||||
const DASHBOARDS_API = `${API_BASE}/dashboards`;
|
||||
|
||||
export interface Dashboard {
|
||||
id: string;
|
||||
name: string;
|
||||
auto_named: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -20,7 +22,7 @@ const initialState: DashboardsState = {
|
||||
};
|
||||
|
||||
export const fetchDashboards = createAsyncThunk('dashboards/fetchAll', async () => {
|
||||
const res = await fetch(`${API_BASE}/list`);
|
||||
const res = await fetch(`${DASHBOARDS_API}/list`);
|
||||
const data = await res.json();
|
||||
return data.dashboards as Dashboard[];
|
||||
});
|
||||
@@ -28,7 +30,7 @@ export const fetchDashboards = createAsyncThunk('dashboards/fetchAll', async ()
|
||||
export const createDashboard = createAsyncThunk(
|
||||
'dashboards/create',
|
||||
async (name: string) => {
|
||||
const res = await fetch(`${API_BASE}/create`, {
|
||||
const res = await fetch(`${DASHBOARDS_API}/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
@@ -40,7 +42,7 @@ export const createDashboard = createAsyncThunk(
|
||||
export const renameDashboard = createAsyncThunk(
|
||||
'dashboards/rename',
|
||||
async ({ id, name }: { id: string; name: string }) => {
|
||||
const res = await fetch(`${API_BASE}/${id}`, {
|
||||
const res = await fetch(`${DASHBOARDS_API}/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
@@ -52,7 +54,7 @@ export const renameDashboard = createAsyncThunk(
|
||||
export const deleteDashboard = createAsyncThunk(
|
||||
'dashboards/delete',
|
||||
async (id: string) => {
|
||||
await fetch(`${API_BASE}/${id}`, { method: 'DELETE' });
|
||||
await fetch(`${DASHBOARDS_API}/${id}`, { method: 'DELETE' });
|
||||
return id;
|
||||
},
|
||||
);
|
||||
@@ -60,11 +62,22 @@ export const deleteDashboard = createAsyncThunk(
|
||||
export const duplicateDashboard = createAsyncThunk(
|
||||
'dashboards/duplicate',
|
||||
async (id: string) => {
|
||||
const res = await fetch(`${API_BASE}/${id}/duplicate`, { method: 'POST' });
|
||||
const res = await fetch(`${DASHBOARDS_API}/${id}/duplicate`, { method: 'POST' });
|
||||
return (await res.json()) as Dashboard;
|
||||
},
|
||||
);
|
||||
|
||||
export const generateDashboardName = createAsyncThunk(
|
||||
'dashboards/generateName',
|
||||
async (dashboardId: string) => {
|
||||
const res = await fetch(`${DASHBOARDS_API}/${dashboardId}/generate-name`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const data = await res.json();
|
||||
return { id: dashboardId, name: data.name as string, auto_named: data.auto_named as boolean };
|
||||
},
|
||||
);
|
||||
|
||||
const dashboardsSlice = createSlice({
|
||||
name: 'dashboards',
|
||||
initialState,
|
||||
@@ -91,7 +104,12 @@ const dashboardsSlice = createSlice({
|
||||
.addCase(renameDashboard.fulfilled, (state, action) => {
|
||||
const d = action.payload;
|
||||
if (state.items[d.id]) {
|
||||
state.items[d.id] = { ...state.items[d.id], name: d.name, updated_at: d.updated_at };
|
||||
state.items[d.id] = {
|
||||
...state.items[d.id],
|
||||
name: d.name,
|
||||
auto_named: d.auto_named ?? false,
|
||||
updated_at: d.updated_at,
|
||||
};
|
||||
}
|
||||
})
|
||||
.addCase(deleteDashboard.fulfilled, (state, action) => {
|
||||
@@ -99,6 +117,13 @@ const dashboardsSlice = createSlice({
|
||||
})
|
||||
.addCase(duplicateDashboard.fulfilled, (state, action) => {
|
||||
state.items[action.payload.id] = action.payload;
|
||||
})
|
||||
.addCase(generateDashboardName.fulfilled, (state, action) => {
|
||||
const { id, name, auto_named } = action.payload;
|
||||
if (state.items[id]) {
|
||||
state.items[id].name = name;
|
||||
state.items[id].auto_named = auto_named;
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
|
||||
const API_BASE = `http://${window.location.hostname}:8324/api/mcp-registry`;
|
||||
const MCP_REGISTRY_API = `${API_BASE}/mcp-registry`;
|
||||
|
||||
export interface McpServer {
|
||||
name: string;
|
||||
@@ -48,20 +49,20 @@ export const searchRegistry = createAsyncThunk(
|
||||
'mcpRegistry/search',
|
||||
async ({ q, limit = 20, offset = 0, sort = 'name', source = '' }: { q: string; limit?: number; offset?: number; sort?: string; source?: string }) => {
|
||||
const params = new URLSearchParams({ q, limit: String(limit), offset: String(offset), sort, source });
|
||||
const res = await fetch(`${API_BASE}/search?${params}`);
|
||||
const res = await fetch(`${MCP_REGISTRY_API}/search?${params}`);
|
||||
return (await res.json()) as { servers: McpServer[]; total: number; offset: number; limit: number };
|
||||
}
|
||||
);
|
||||
|
||||
export const fetchRegistryStats = createAsyncThunk('mcpRegistry/stats', async () => {
|
||||
const res = await fetch(`${API_BASE}/stats`);
|
||||
const res = await fetch(`${MCP_REGISTRY_API}/stats`);
|
||||
return (await res.json()) as { total: number; google: number; community: number; lastUpdated: number };
|
||||
});
|
||||
|
||||
export const fetchServerDetail = createAsyncThunk(
|
||||
'mcpRegistry/detail',
|
||||
async (name: string) => {
|
||||
const res = await fetch(`${API_BASE}/detail/${encodeURIComponent(name)}`);
|
||||
const res = await fetch(`${MCP_REGISTRY_API}/detail/${encodeURIComponent(name)}`);
|
||||
const data = await res.json();
|
||||
return data.server as McpServerDetail;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
|
||||
const API_BASE = `http://${window.location.hostname}:8324/api/modes`;
|
||||
const MODES_API = `${API_BASE}/modes`;
|
||||
|
||||
export interface Mode {
|
||||
id: string;
|
||||
@@ -26,7 +27,7 @@ const initialState: ModesState = { items: {}, loading: false, loaded: false };
|
||||
export const fetchModes = createAsyncThunk(
|
||||
'modes/fetch',
|
||||
async () => {
|
||||
const res = await fetch(`${API_BASE}/list`);
|
||||
const res = await fetch(`${MODES_API}/list`);
|
||||
const data = await res.json();
|
||||
return data.modes as Mode[];
|
||||
},
|
||||
@@ -36,7 +37,7 @@ export const fetchModes = createAsyncThunk(
|
||||
export const createMode = createAsyncThunk(
|
||||
'modes/create',
|
||||
async (body: Omit<Mode, 'id' | 'is_builtin'>) => {
|
||||
const res = await fetch(`${API_BASE}/create`, {
|
||||
const res = await fetch(`${MODES_API}/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
@@ -49,7 +50,7 @@ export const createMode = createAsyncThunk(
|
||||
export const updateMode = createAsyncThunk(
|
||||
'modes/update',
|
||||
async ({ id, ...updates }: Partial<Mode> & { id: string }) => {
|
||||
const res = await fetch(`${API_BASE}/${id}`, {
|
||||
const res = await fetch(`${MODES_API}/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
@@ -60,7 +61,7 @@ export const updateMode = createAsyncThunk(
|
||||
);
|
||||
|
||||
export const deleteMode = createAsyncThunk('modes/delete', async (id: string) => {
|
||||
await fetch(`${API_BASE}/${id}`, { method: 'DELETE' });
|
||||
await fetch(`${MODES_API}/${id}`, { method: 'DELETE' });
|
||||
return id;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
|
||||
const API_BASE = `http://${window.location.hostname}:8324/api/outputs`;
|
||||
const OUTPUTS_API = `${API_BASE}/outputs`;
|
||||
|
||||
export const SERVE_BASE = `http://${window.location.hostname}:8324/api/outputs`;
|
||||
export const SERVE_BASE = `${API_BASE}/outputs`;
|
||||
|
||||
export const IFRAME_SERVE_BASE = '/api/outputs';
|
||||
|
||||
export interface AutoRunConfig {
|
||||
enabled: boolean;
|
||||
@@ -79,7 +79,7 @@ const initialState: OutputsState = { items: {}, loading: false, loaded: false };
|
||||
export const fetchOutputs = createAsyncThunk(
|
||||
'outputs/fetch',
|
||||
async () => {
|
||||
const res = await fetch(`${API_BASE}/list`);
|
||||
const res = await fetch(`${OUTPUTS_API}/list`);
|
||||
const data = await res.json();
|
||||
return data.outputs as Output[];
|
||||
},
|
||||
@@ -89,7 +89,7 @@ export const fetchOutputs = createAsyncThunk(
|
||||
export const createOutput = createAsyncThunk(
|
||||
'outputs/create',
|
||||
async (body: Omit<Output, 'id' | 'created_at' | 'updated_at' | 'permission'>) => {
|
||||
const res = await fetch(`${API_BASE}/create`, {
|
||||
const res = await fetch(`${OUTPUTS_API}/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
@@ -103,7 +103,7 @@ export const createOutput = createAsyncThunk(
|
||||
export const updateOutput = createAsyncThunk(
|
||||
'outputs/update',
|
||||
async ({ id, ...updates }: Partial<Output> & { id: string }) => {
|
||||
const res = await fetch(`${API_BASE}/${id}`, {
|
||||
const res = await fetch(`${OUTPUTS_API}/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
@@ -115,14 +115,14 @@ export const updateOutput = createAsyncThunk(
|
||||
);
|
||||
|
||||
export const deleteOutput = createAsyncThunk('outputs/delete', async (id: string) => {
|
||||
await fetch(`${API_BASE}/${id}`, { method: 'DELETE' });
|
||||
await fetch(`${OUTPUTS_API}/${id}`, { method: 'DELETE' });
|
||||
return id;
|
||||
});
|
||||
|
||||
export const executeOutput = createAsyncThunk(
|
||||
'outputs/execute',
|
||||
async (body: { output_id: string; input_data: Record<string, any> }) => {
|
||||
const res = await fetch(`${API_BASE}/execute`, {
|
||||
const res = await fetch(`${OUTPUTS_API}/execute`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
@@ -142,7 +142,7 @@ export interface AutoRunResult {
|
||||
export const autoRunOutput = createAsyncThunk(
|
||||
'outputs/autoRun',
|
||||
async (body: { prompt: string; input_schema: Record<string, any>; backend_code?: string | null; context_paths?: Array<{ path: string; type: string }>; forced_tools?: string[]; model?: string }) => {
|
||||
const res = await fetch(`${API_BASE}/auto-run`, {
|
||||
const res = await fetch(`${OUTPUTS_API}/auto-run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
@@ -165,7 +165,7 @@ export const autoRunAgentOutput = createAsyncThunk(
|
||||
forced_tools?: string[];
|
||||
context_paths?: Array<{ path: string; type: string }>;
|
||||
}) => {
|
||||
const res = await fetch(`${API_BASE}/auto-run-agent`, {
|
||||
const res = await fetch(`${OUTPUTS_API}/auto-run-agent`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
@@ -176,7 +176,7 @@ export const autoRunAgentOutput = createAsyncThunk(
|
||||
);
|
||||
|
||||
export async function cleanupAutoRunAgent(sessionId: string): Promise<void> {
|
||||
await fetch(`${API_BASE}/auto-run-agent/${sessionId}`, { method: 'DELETE' });
|
||||
await fetch(`${OUTPUTS_API}/auto-run-agent/${sessionId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
const outputsSlice = createSlice({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
|
||||
const API_BASE = `http://${window.location.hostname}:8324/api/settings`;
|
||||
const SETTINGS_API = `${API_BASE}/settings`;
|
||||
|
||||
export interface AppSettings {
|
||||
default_system_prompt: string | null;
|
||||
@@ -12,6 +13,7 @@ export interface AppSettings {
|
||||
theme: 'light' | 'dark';
|
||||
new_agent_shortcut: string;
|
||||
anthropic_api_key: string | null;
|
||||
browser_homepage: string;
|
||||
}
|
||||
|
||||
export interface BrowseResult {
|
||||
@@ -39,6 +41,7 @@ const initialState: SettingsState = {
|
||||
theme: 'dark',
|
||||
new_agent_shortcut: 'Meta+l',
|
||||
anthropic_api_key: null,
|
||||
browser_homepage: 'https://www.google.com',
|
||||
},
|
||||
loading: false,
|
||||
loaded: false,
|
||||
@@ -46,14 +49,14 @@ const initialState: SettingsState = {
|
||||
};
|
||||
|
||||
export const fetchSettings = createAsyncThunk('settings/fetch', async () => {
|
||||
const res = await fetch(API_BASE);
|
||||
const res = await fetch(SETTINGS_API);
|
||||
return (await res.json()) as AppSettings;
|
||||
});
|
||||
|
||||
export const updateSettings = createAsyncThunk(
|
||||
'settings/update',
|
||||
async (settings: AppSettings) => {
|
||||
const res = await fetch(API_BASE, {
|
||||
const res = await fetch(SETTINGS_API, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
@@ -66,7 +69,7 @@ export const updateSettings = createAsyncThunk(
|
||||
export const browseDirectories = createAsyncThunk(
|
||||
'settings/browseDirectories',
|
||||
async (path: string) => {
|
||||
const res = await fetch(`${API_BASE}/browse-directories?path=${encodeURIComponent(path)}`);
|
||||
const res = await fetch(`${SETTINGS_API}/browse-directories?path=${encodeURIComponent(path)}`);
|
||||
if (!res.ok) throw new Error((await res.json()).detail);
|
||||
return (await res.json()) as BrowseResult;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
|
||||
const API_BASE = `http://${window.location.hostname}:8324/api/skill-registry`;
|
||||
const SKILL_REGISTRY_API = `${API_BASE}/skill-registry`;
|
||||
|
||||
export interface RegistrySkill {
|
||||
name: string;
|
||||
@@ -40,13 +41,13 @@ export const searchSkillRegistry = createAsyncThunk(
|
||||
'skillRegistry/search',
|
||||
async ({ q, limit = 20, offset = 0, sort = 'name', category = '' }: { q: string; limit?: number; offset?: number; sort?: string; category?: string }) => {
|
||||
const params = new URLSearchParams({ q, limit: String(limit), offset: String(offset), sort, category });
|
||||
const res = await fetch(`${API_BASE}/search?${params}`);
|
||||
const res = await fetch(`${SKILL_REGISTRY_API}/search?${params}`);
|
||||
return (await res.json()) as { skills: RegistrySkill[]; total: number; offset: number; limit: number };
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchSkillRegistryStats = createAsyncThunk('skillRegistry/stats', async () => {
|
||||
const res = await fetch(`${API_BASE}/stats`);
|
||||
const res = await fetch(`${SKILL_REGISTRY_API}/stats`);
|
||||
return (await res.json()) as { total: number; categories: Record<string, number>; lastUpdated: number };
|
||||
});
|
||||
|
||||
@@ -54,7 +55,7 @@ export const fetchAllRegistrySkills = createAsyncThunk(
|
||||
'skillRegistry/fetchAll',
|
||||
async () => {
|
||||
const params = new URLSearchParams({ q: '', limit: '100', offset: '0', sort: 'name', category: '' });
|
||||
const res = await fetch(`${API_BASE}/search?${params}`);
|
||||
const res = await fetch(`${SKILL_REGISTRY_API}/search?${params}`);
|
||||
return (await res.json()) as { skills: RegistrySkill[]; total: number; offset: number; limit: number };
|
||||
},
|
||||
);
|
||||
@@ -62,7 +63,7 @@ export const fetchAllRegistrySkills = createAsyncThunk(
|
||||
export const fetchSkillDetail = createAsyncThunk(
|
||||
'skillRegistry/detail',
|
||||
async (name: string) => {
|
||||
const res = await fetch(`${API_BASE}/detail/${encodeURIComponent(name)}`);
|
||||
const res = await fetch(`${SKILL_REGISTRY_API}/detail/${encodeURIComponent(name)}`);
|
||||
const data = await res.json();
|
||||
return data.skill as RegistrySkillDetail;
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
|
||||
const API_BASE = `http://${window.location.hostname}:8324/api/skills`;
|
||||
const SKILLS_API = `${API_BASE}/skills`;
|
||||
|
||||
export interface Skill {
|
||||
id: string;
|
||||
@@ -22,7 +23,7 @@ const initialState: SkillsState = { items: {}, loading: false, loaded: false };
|
||||
export const fetchSkills = createAsyncThunk(
|
||||
'skills/fetch',
|
||||
async () => {
|
||||
const res = await fetch(`${API_BASE}/list`);
|
||||
const res = await fetch(`${SKILLS_API}/list`);
|
||||
const data = await res.json();
|
||||
return data.skills as Skill[];
|
||||
},
|
||||
@@ -32,7 +33,7 @@ export const fetchSkills = createAsyncThunk(
|
||||
export const createSkill = createAsyncThunk(
|
||||
'skills/create',
|
||||
async (body: { name: string; description?: string; content: string; command?: string }) => {
|
||||
const res = await fetch(`${API_BASE}/create`, {
|
||||
const res = await fetch(`${SKILLS_API}/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
@@ -45,7 +46,7 @@ export const createSkill = createAsyncThunk(
|
||||
export const updateSkill = createAsyncThunk(
|
||||
'skills/update',
|
||||
async ({ id, ...updates }: Partial<Skill> & { id: string }) => {
|
||||
const res = await fetch(`${API_BASE}/${id}`, {
|
||||
const res = await fetch(`${SKILLS_API}/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
@@ -56,7 +57,7 @@ export const updateSkill = createAsyncThunk(
|
||||
);
|
||||
|
||||
export const deleteSkill = createAsyncThunk('skills/delete', async (id: string) => {
|
||||
await fetch(`${API_BASE}/${id}`, { method: 'DELETE' });
|
||||
await fetch(`${SKILLS_API}/${id}`, { method: 'DELETE' });
|
||||
return id;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
|
||||
const API_BASE = `http://${window.location.hostname}:8324/api/templates`;
|
||||
const TEMPLATES_API = `${API_BASE}/templates`;
|
||||
|
||||
export interface TemplateField {
|
||||
name: string;
|
||||
@@ -34,7 +35,7 @@ const initialState: TemplatesState = {
|
||||
export const fetchTemplates = createAsyncThunk(
|
||||
'templates/fetch',
|
||||
async () => {
|
||||
const res = await fetch(`${API_BASE}/list`);
|
||||
const res = await fetch(`${TEMPLATES_API}/list`);
|
||||
const data = await res.json();
|
||||
return data.templates as PromptTemplate[];
|
||||
},
|
||||
@@ -44,7 +45,7 @@ export const fetchTemplates = createAsyncThunk(
|
||||
export const createTemplate = createAsyncThunk(
|
||||
'templates/create',
|
||||
async (body: Omit<PromptTemplate, 'id'>) => {
|
||||
const res = await fetch(`${API_BASE}/create`, {
|
||||
const res = await fetch(`${TEMPLATES_API}/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
@@ -57,7 +58,7 @@ export const createTemplate = createAsyncThunk(
|
||||
export const updateTemplate = createAsyncThunk(
|
||||
'templates/update',
|
||||
async ({ id, ...updates }: Partial<PromptTemplate> & { id: string }) => {
|
||||
const res = await fetch(`${API_BASE}/${id}`, {
|
||||
const res = await fetch(`${TEMPLATES_API}/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
@@ -68,14 +69,14 @@ export const updateTemplate = createAsyncThunk(
|
||||
);
|
||||
|
||||
export const deleteTemplate = createAsyncThunk('templates/delete', async (id: string) => {
|
||||
await fetch(`${API_BASE}/${id}`, { method: 'DELETE' });
|
||||
await fetch(`${TEMPLATES_API}/${id}`, { method: 'DELETE' });
|
||||
return id;
|
||||
});
|
||||
|
||||
export const renderTemplate = createAsyncThunk(
|
||||
'templates/render',
|
||||
async ({ templateId, values }: { templateId: string; values: Record<string, any> }) => {
|
||||
const res = await fetch(`${API_BASE}/render`, {
|
||||
const res = await fetch(`${TEMPLATES_API}/render`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ template_id: templateId, values }),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
|
||||
const API_BASE = `http://${window.location.hostname}:8324/api/tools`;
|
||||
const TOOLS_API = `${API_BASE}/tools`;
|
||||
|
||||
export interface ToolDefinition {
|
||||
id: string;
|
||||
@@ -38,7 +39,7 @@ const initialState: ToolsState = { items: {}, builtinTools: [], builtinPermissio
|
||||
export const fetchTools = createAsyncThunk(
|
||||
'tools/fetch',
|
||||
async () => {
|
||||
const res = await fetch(`${API_BASE}/list`);
|
||||
const res = await fetch(`${TOOLS_API}/list`);
|
||||
const data = await res.json();
|
||||
return data.tools as ToolDefinition[];
|
||||
},
|
||||
@@ -48,7 +49,7 @@ export const fetchTools = createAsyncThunk(
|
||||
export const fetchBuiltinTools = createAsyncThunk(
|
||||
'tools/fetchBuiltin',
|
||||
async () => {
|
||||
const res = await fetch(`${API_BASE}/builtin`);
|
||||
const res = await fetch(`${TOOLS_API}/builtin`);
|
||||
const data = await res.json();
|
||||
return data.tools as BuiltinTool[];
|
||||
},
|
||||
@@ -58,7 +59,7 @@ export const fetchBuiltinTools = createAsyncThunk(
|
||||
export const createTool = createAsyncThunk(
|
||||
'tools/create',
|
||||
async (body: Partial<Omit<ToolDefinition, 'id'>> & { name: string }) => {
|
||||
const res = await fetch(`${API_BASE}/create`, {
|
||||
const res = await fetch(`${TOOLS_API}/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
@@ -71,7 +72,7 @@ export const createTool = createAsyncThunk(
|
||||
export const updateTool = createAsyncThunk(
|
||||
'tools/update',
|
||||
async ({ id, ...updates }: Partial<ToolDefinition> & { id: string }) => {
|
||||
const res = await fetch(`${API_BASE}/${id}`, {
|
||||
const res = await fetch(`${TOOLS_API}/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
@@ -82,14 +83,14 @@ export const updateTool = createAsyncThunk(
|
||||
);
|
||||
|
||||
export const deleteTool = createAsyncThunk('tools/delete', async (id: string) => {
|
||||
await fetch(`${API_BASE}/${id}`, { method: 'DELETE' });
|
||||
await fetch(`${TOOLS_API}/${id}`, { method: 'DELETE' });
|
||||
return id;
|
||||
});
|
||||
|
||||
export const startOAuth = createAsyncThunk(
|
||||
'tools/startOAuth',
|
||||
async (toolId: string) => {
|
||||
const res = await fetch(`${API_BASE}/${toolId}/oauth/start`, { method: 'POST' });
|
||||
const res = await fetch(`${TOOLS_API}/${toolId}/oauth/start`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error('Failed to start OAuth');
|
||||
const data = await res.json();
|
||||
return data as { auth_url: string };
|
||||
@@ -99,7 +100,7 @@ export const startOAuth = createAsyncThunk(
|
||||
export const fetchToolStatus = createAsyncThunk(
|
||||
'tools/fetchStatus',
|
||||
async (toolId: string) => {
|
||||
const res = await fetch(`${API_BASE}/${toolId}`);
|
||||
const res = await fetch(`${TOOLS_API}/${toolId}`);
|
||||
const data = await res.json();
|
||||
return data as ToolDefinition;
|
||||
}
|
||||
@@ -108,7 +109,7 @@ export const fetchToolStatus = createAsyncThunk(
|
||||
export const discoverTools = createAsyncThunk(
|
||||
'tools/discover',
|
||||
async (toolId: string) => {
|
||||
const res = await fetch(`${API_BASE}/${toolId}/discover`, { method: 'POST' });
|
||||
const res = await fetch(`${TOOLS_API}/${toolId}/discover`, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: 'Discovery failed' }));
|
||||
throw new Error(err.detail || 'Discovery failed');
|
||||
@@ -119,7 +120,7 @@ export const discoverTools = createAsyncThunk(
|
||||
);
|
||||
|
||||
export const fetchBuiltinPermissions = createAsyncThunk('tools/fetchBuiltinPermissions', async () => {
|
||||
const res = await fetch(`${API_BASE}/builtin/permissions`);
|
||||
const res = await fetch(`${TOOLS_API}/builtin/permissions`);
|
||||
const data = await res.json();
|
||||
return data.permissions as Record<string, string>;
|
||||
});
|
||||
@@ -127,7 +128,7 @@ export const fetchBuiltinPermissions = createAsyncThunk('tools/fetchBuiltinPermi
|
||||
export const updateBuiltinPermissions = createAsyncThunk(
|
||||
'tools/updateBuiltinPermissions',
|
||||
async (permissions: Record<string, string>) => {
|
||||
const res = await fetch(`${API_BASE}/builtin/permissions`, {
|
||||
const res = await fetch(`${TOOLS_API}/builtin/permissions`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ permissions }),
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
subscribeActivity,
|
||||
getActivity,
|
||||
type BrowserActivity,
|
||||
type BrowserAction,
|
||||
} from './browserCommandHandler';
|
||||
|
||||
export interface BrowserActivityState {
|
||||
active: boolean;
|
||||
action: BrowserAction | null;
|
||||
detail: string | null;
|
||||
/** The action that just completed — stays set briefly for exit animations */
|
||||
lastAction: BrowserAction | null;
|
||||
}
|
||||
|
||||
const EMPTY: BrowserActivityState = { active: false, action: null, detail: null, lastAction: null };
|
||||
|
||||
export function useBrowserActivity(browserId: string): BrowserActivityState {
|
||||
const [state, setState] = useState<BrowserActivityState>(() => {
|
||||
const current = getActivity(browserId);
|
||||
return current
|
||||
? { active: true, action: current.action, detail: current.detail ?? null, lastAction: null }
|
||||
: EMPTY;
|
||||
});
|
||||
|
||||
const lastActionTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(changedId: string, activity: BrowserActivity | null) => {
|
||||
if (changedId !== browserId) return;
|
||||
if (activity) {
|
||||
if (lastActionTimer.current) clearTimeout(lastActionTimer.current);
|
||||
setState({
|
||||
active: true,
|
||||
action: activity.action,
|
||||
detail: activity.detail ?? null,
|
||||
lastAction: null,
|
||||
});
|
||||
} else {
|
||||
setState((prev) => ({
|
||||
active: false,
|
||||
action: null,
|
||||
detail: null,
|
||||
lastAction: prev.action,
|
||||
}));
|
||||
lastActionTimer.current = setTimeout(() => {
|
||||
setState((prev) => (prev.active ? prev : { ...prev, lastAction: null }));
|
||||
}, 600);
|
||||
}
|
||||
},
|
||||
[browserId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeActivity(handleChange);
|
||||
}, [handleChange]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (lastActionTimer.current) clearTimeout(lastActionTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -281,7 +281,7 @@ class WebSocketManager {
|
||||
}
|
||||
}
|
||||
|
||||
const WS_BASE = `ws://${window.location.hostname}:8324`;
|
||||
import { WS_BASE } from '@/shared/config';
|
||||
|
||||
export const dashboardWs = new WebSocketManager(`${WS_BASE}/ws/dashboard`, { skipStreamEvents: true });
|
||||
|
||||
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
declare namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
webview: React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLElement> & {
|
||||
src?: string;
|
||||
preload?: string;
|
||||
partition?: string;
|
||||
allowpopups?: string;
|
||||
nodeintegration?: string;
|
||||
},
|
||||
HTMLElement
|
||||
>;
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,6 @@
|
||||
const path = require('path');
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
const CopyWebpackPlugin = require('copy-webpack-plugin');
|
||||
|
||||
module.exports = (env, argv) => {
|
||||
const isDevelopment = argv.mode === 'development';
|
||||
@@ -10,6 +11,7 @@ module.exports = (env, argv) => {
|
||||
output: {
|
||||
path: path.resolve(__dirname, 'dist'),
|
||||
filename: 'bundle.js',
|
||||
publicPath: isDevelopment ? '/' : './',
|
||||
clean: true
|
||||
},
|
||||
|
||||
@@ -60,7 +62,16 @@ module.exports = (env, argv) => {
|
||||
template: './public/index.html',
|
||||
filename: 'index.html',
|
||||
favicon: './public/favicon.ico'
|
||||
})
|
||||
}),
|
||||
new CopyWebpackPlugin({
|
||||
patterns: [
|
||||
{
|
||||
from: 'public',
|
||||
to: '.',
|
||||
globOptions: { ignore: ['**/index.html', '**/favicon.ico'] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
|
||||
devtool: isDevelopment ? 'source-map' : false,
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
#!/bin/bash
|
||||
# The comment above is shebang, DO NOT REMOVE
|
||||
SCRIPT_ABSPATH="$(readlink -f "${BASH_SOURCE[0]}")"
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
sed -i '' 's/\r//g' "$SCRIPT_ABSPATH"
|
||||
else
|
||||
sed -i 's/\r//g' "$SCRIPT_ABSPATH"
|
||||
fi
|
||||
chmod +x "$SCRIPT_ABSPATH"
|
||||
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_ABSPATH")"
|
||||
|
||||
BLUE='\033[0;34m'
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[0;33m'
|
||||
BOLD='\033[1m'
|
||||
RESET='\033[0m'
|
||||
|
||||
BACKEND_PID=""
|
||||
FRONTEND_PID=""
|
||||
ELECTRON_PID=""
|
||||
SHUTTING_DOWN=false
|
||||
|
||||
kill_tree() {
|
||||
local pid=$1 sig=${2:-TERM}
|
||||
local children
|
||||
children=$(pgrep -P "$pid" 2>/dev/null)
|
||||
for child in $children; do
|
||||
kill_tree "$child" "$sig"
|
||||
done
|
||||
kill -"$sig" "$pid" 2>/dev/null
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
$SHUTTING_DOWN && return
|
||||
SHUTTING_DOWN=true
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}${BOLD}Gracefully shutting down all services...${RESET}"
|
||||
|
||||
for pid in $ELECTRON_PID $BACKEND_PID $FRONTEND_PID; do
|
||||
[[ -n "$pid" ]] && kill_tree "$pid" TERM
|
||||
done
|
||||
|
||||
local elapsed=0
|
||||
while (( elapsed < 5 )); do
|
||||
local alive=false
|
||||
for pid in $ELECTRON_PID $BACKEND_PID $FRONTEND_PID; do
|
||||
[[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null && alive=true
|
||||
done
|
||||
$alive || break
|
||||
sleep 1
|
||||
((elapsed++))
|
||||
done
|
||||
|
||||
for pid in $ELECTRON_PID $BACKEND_PID $FRONTEND_PID; do
|
||||
[[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null && kill_tree "$pid" KILL
|
||||
done
|
||||
|
||||
wait 2>/dev/null
|
||||
echo -e "${GREEN}${BOLD}All services stopped.${RESET}"
|
||||
}
|
||||
|
||||
trap 'cleanup; exit 0' INT TERM
|
||||
trap cleanup EXIT
|
||||
|
||||
# --- Start backend ---
|
||||
echo -e "${BLUE}${BOLD}[backend]${RESET} Starting backend server..."
|
||||
bash "$PROJECT_ROOT/backend/run.sh" > >(
|
||||
while IFS= read -r line; do
|
||||
printf "${BLUE}${BOLD}[backend]${RESET} %s\n" "$line"
|
||||
done
|
||||
) 2>&1 &
|
||||
BACKEND_PID=$!
|
||||
|
||||
# --- Wait for backend to become healthy ---
|
||||
echo -e "${YELLOW}${BOLD}Waiting for backend (http://localhost:8324) to be ready...${RESET}"
|
||||
MAX_WAIT=120
|
||||
elapsed=0
|
||||
while (( elapsed < MAX_WAIT )); do
|
||||
if curl -s -o /dev/null --connect-timeout 1 http://localhost:8324/ 2>/dev/null; then
|
||||
echo -e "${GREEN}${BOLD}Backend is ready!${RESET}"
|
||||
break
|
||||
fi
|
||||
if ! kill -0 "$BACKEND_PID" 2>/dev/null; then
|
||||
echo -e "${RED}${BOLD}Backend process exited before becoming ready.${RESET}"
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
((elapsed += 2))
|
||||
done
|
||||
|
||||
if (( elapsed >= MAX_WAIT )); then
|
||||
echo -e "${RED}${BOLD}Backend did not become ready within ${MAX_WAIT}s. Aborting.${RESET}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Start frontend ---
|
||||
echo -e "${GREEN}${BOLD}[frontend]${RESET} Starting frontend dev server..."
|
||||
bash "$PROJECT_ROOT/frontend/run.sh" > >(
|
||||
while IFS= read -r line; do
|
||||
printf "${GREEN}${BOLD}[frontend]${RESET} %s\n" "$line"
|
||||
done
|
||||
) 2>&1 &
|
||||
FRONTEND_PID=$!
|
||||
|
||||
# --- Wait for frontend dev server to become available ---
|
||||
echo -e "${YELLOW}${BOLD}Waiting for frontend (http://localhost:3000) to be ready...${RESET}"
|
||||
FRONTEND_MAX_WAIT=60
|
||||
frontend_elapsed=0
|
||||
while (( frontend_elapsed < FRONTEND_MAX_WAIT )); do
|
||||
if curl -s -o /dev/null --connect-timeout 1 http://localhost:3000/ 2>/dev/null; then
|
||||
echo -e "${GREEN}${BOLD}Frontend is ready!${RESET}"
|
||||
break
|
||||
fi
|
||||
if ! kill -0 "$FRONTEND_PID" 2>/dev/null; then
|
||||
echo -e "${RED}${BOLD}Frontend process exited before becoming ready.${RESET}"
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
((frontend_elapsed += 2))
|
||||
done
|
||||
|
||||
if (( frontend_elapsed >= FRONTEND_MAX_WAIT )); then
|
||||
echo -e "${RED}${BOLD}Frontend did not become ready within ${FRONTEND_MAX_WAIT}s. Aborting.${RESET}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Start Electron in dev mode ---
|
||||
MAGENTA='\033[0;35m'
|
||||
echo -e "${MAGENTA}${BOLD}[electron]${RESET} Launching Electron dev shell..."
|
||||
(cd "$PROJECT_ROOT/electron" && ELECTRON_DEV=1 npx electron .) > >(
|
||||
while IFS= read -r line; do
|
||||
printf "${MAGENTA}${BOLD}[electron]${RESET} %s\n" "$line"
|
||||
done
|
||||
) 2>&1 &
|
||||
ELECTRON_PID=$!
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}All services are running. Press Ctrl+C to stop.${RESET}"
|
||||
echo -e " Backend: ${BLUE}http://localhost:8324${RESET}"
|
||||
echo -e " Frontend: ${GREEN}http://localhost:3000${RESET}"
|
||||
echo -e " Electron: ${MAGENTA}dev shell (pid $ELECTRON_PID)${RESET}"
|
||||
echo ""
|
||||
|
||||
# --- Monitor: if any service exits, tear down all ---
|
||||
while ! $SHUTTING_DOWN; do
|
||||
if ! kill -0 "$BACKEND_PID" 2>/dev/null; then
|
||||
echo -e "${RED}${BOLD}Backend process exited unexpectedly. Shutting down...${RESET}"
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$FRONTEND_PID" ]] && ! kill -0 "$FRONTEND_PID" 2>/dev/null; then
|
||||
echo -e "${RED}${BOLD}Frontend process exited unexpectedly. Shutting down...${RESET}"
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$ELECTRON_PID" ]] && ! kill -0 "$ELECTRON_PID" 2>/dev/null; then
|
||||
echo -e "${YELLOW}${BOLD}Electron process exited. Shutting down...${RESET}"
|
||||
exit 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
Executable
+94
@@ -0,0 +1,94 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Master build script for the OpenSwarm desktop app.
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/build-app.sh Local dev build (unsigned)
|
||||
# bash scripts/build-app.sh --publish Production build (signed, notarized, published to GitHub Releases)
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
ENV_FILE="$PROJECT_ROOT/backend/.env"
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
set -a
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
fi
|
||||
|
||||
PUBLISH_MODE=false
|
||||
if [[ "${1:-}" == "--publish" ]]; then
|
||||
PUBLISH_MODE=true
|
||||
fi
|
||||
|
||||
echo "========================================"
|
||||
echo " OpenSwarm Desktop App Builder"
|
||||
if $PUBLISH_MODE; then
|
||||
echo " Mode: PRODUCTION (sign + notarize + publish)"
|
||||
else
|
||||
echo " Mode: LOCAL (unsigned)"
|
||||
fi
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
if $PUBLISH_MODE; then
|
||||
missing_vars=()
|
||||
[[ -z "${APPLE_ID:-}" ]] && missing_vars+=("APPLE_ID")
|
||||
[[ -z "${APPLE_APP_SPECIFIC_PASSWORD:-}" ]] && missing_vars+=("APPLE_APP_SPECIFIC_PASSWORD")
|
||||
[[ -z "${APPLE_TEAM_ID:-}" ]] && missing_vars+=("APPLE_TEAM_ID")
|
||||
[[ -z "${GH_TOKEN:-}" ]] && missing_vars+=("GH_TOKEN")
|
||||
if [[ ${#missing_vars[@]} -gt 0 ]]; then
|
||||
echo "ERROR: Missing required environment variables for --publish mode:"
|
||||
printf ' - %s\n' "${missing_vars[@]}"
|
||||
echo ""
|
||||
echo "See script header for details."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Step 1: Build frontend
|
||||
echo "[1/3] Building frontend..."
|
||||
cd "$PROJECT_ROOT/frontend"
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
if [[ ! -f "$PROJECT_ROOT/frontend/dist/index.html" ]]; then
|
||||
echo "ERROR: Frontend build failed — dist/index.html not found"
|
||||
exit 1
|
||||
fi
|
||||
echo "Frontend build complete."
|
||||
echo ""
|
||||
|
||||
# Step 2: Build Python environment
|
||||
echo "[2/3] Building Python environment..."
|
||||
bash "$SCRIPT_DIR/build-python-env.sh"
|
||||
|
||||
if [[ ! -d "$PROJECT_ROOT/electron/python-env" ]]; then
|
||||
echo "ERROR: Python environment not found at electron/python-env/"
|
||||
exit 1
|
||||
fi
|
||||
echo "Python environment ready."
|
||||
echo ""
|
||||
|
||||
# Step 3: Package with electron-builder
|
||||
echo "[3/3] Packaging with electron-builder..."
|
||||
cd "$PROJECT_ROOT/electron"
|
||||
npm install
|
||||
|
||||
if $PUBLISH_MODE; then
|
||||
npx electron-builder --mac --publish always
|
||||
else
|
||||
export CSC_IDENTITY_AUTO_DISCOVERY=false
|
||||
npx electron-builder --mac --publish never
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " Build Complete!"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
echo "Output files:"
|
||||
ls -lh "$PROJECT_ROOT/electron/dist/"*.dmg 2>/dev/null || true
|
||||
ls -lh "$PROJECT_ROOT/electron/dist/"*.zip 2>/dev/null || true
|
||||
echo ""
|
||||
Executable
+121
@@ -0,0 +1,121 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Build an embedded Python environment for the Electron app.
|
||||
#
|
||||
# Downloads a standalone Python build from python-build-standalone,
|
||||
# creates a venv, and installs all backend dependencies.
|
||||
# The resulting python-env/ directory is bundled into the Electron app.
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
ELECTRON_DIR="$PROJECT_ROOT/electron"
|
||||
PYTHON_ENV_DIR="$ELECTRON_DIR/python-env"
|
||||
|
||||
PYTHON_VERSION="3.13"
|
||||
PYTHON_FULL_VERSION="3.13.2"
|
||||
ARCH="$(uname -m)"
|
||||
|
||||
if [[ "$ARCH" == "arm64" ]]; then
|
||||
PLATFORM_TAG="aarch64-apple-darwin"
|
||||
elif [[ "$ARCH" == "x86_64" ]]; then
|
||||
PLATFORM_TAG="x86_64-apple-darwin"
|
||||
else
|
||||
echo "Unsupported architecture: $ARCH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RELEASE_TAG="20250212"
|
||||
TARBALL_NAME="cpython-${PYTHON_FULL_VERSION}+${RELEASE_TAG}-${PLATFORM_TAG}-install_only_stripped.tar.gz"
|
||||
DOWNLOAD_URL="https://github.com/indygreg/python-build-standalone/releases/download/${RELEASE_TAG}/${TARBALL_NAME}"
|
||||
TEMP_DIR="$(mktemp -d)"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$TEMP_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "=== Building Python Environment ==="
|
||||
echo "Architecture: $ARCH ($PLATFORM_TAG)"
|
||||
echo "Python: $PYTHON_FULL_VERSION"
|
||||
|
||||
# Remove old env if present
|
||||
if [[ -d "$PYTHON_ENV_DIR" ]]; then
|
||||
echo "Removing old python-env..."
|
||||
rm -rf "$PYTHON_ENV_DIR"
|
||||
fi
|
||||
|
||||
# Download standalone Python
|
||||
echo "Downloading standalone Python from python-build-standalone..."
|
||||
echo "URL: $DOWNLOAD_URL"
|
||||
curl -fSL --progress-bar -o "$TEMP_DIR/python.tar.gz" "$DOWNLOAD_URL"
|
||||
|
||||
echo "Extracting..."
|
||||
tar xzf "$TEMP_DIR/python.tar.gz" -C "$TEMP_DIR"
|
||||
|
||||
# The tarball extracts to python/
|
||||
EXTRACTED_DIR="$TEMP_DIR/python"
|
||||
if [[ ! -d "$EXTRACTED_DIR" ]]; then
|
||||
echo "Error: Expected extracted directory at $EXTRACTED_DIR"
|
||||
ls -la "$TEMP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Move into place
|
||||
mv "$EXTRACTED_DIR" "$PYTHON_ENV_DIR"
|
||||
echo "Python installed to $PYTHON_ENV_DIR"
|
||||
|
||||
PYTHON_BIN="$PYTHON_ENV_DIR/bin/python${PYTHON_VERSION}"
|
||||
if [[ ! -f "$PYTHON_BIN" ]]; then
|
||||
PYTHON_BIN="$PYTHON_ENV_DIR/bin/python3"
|
||||
fi
|
||||
|
||||
echo "Python binary: $PYTHON_BIN"
|
||||
"$PYTHON_BIN" --version
|
||||
|
||||
# Install pip (standalone builds may not include it)
|
||||
if ! "$PYTHON_BIN" -m pip --version &>/dev/null; then
|
||||
echo "Installing pip..."
|
||||
"$PYTHON_BIN" -m ensurepip --upgrade
|
||||
fi
|
||||
|
||||
# Install backend dependencies
|
||||
echo "Installing backend dependencies..."
|
||||
"$PYTHON_BIN" -m pip install --upgrade pip
|
||||
"$PYTHON_BIN" -m pip install -r "$PROJECT_ROOT/backend/requirements.txt"
|
||||
|
||||
# Install the debugger module
|
||||
echo "Installing debugger module..."
|
||||
"$PYTHON_BIN" -m pip install -e "$PROJECT_ROOT/debugger"
|
||||
|
||||
# Verify claude-agent-sdk and its bundled binary
|
||||
echo "Verifying claude-agent-sdk..."
|
||||
"$PYTHON_BIN" -c "import claude_agent_sdk; print(f'claude-agent-sdk installed')"
|
||||
|
||||
CLAUDE_BIN=$("$PYTHON_BIN" -c "
|
||||
from pathlib import Path
|
||||
import claude_agent_sdk
|
||||
sdk_dir = Path(claude_agent_sdk.__file__).parent
|
||||
bundled = sdk_dir / '_bundled' / 'claude'
|
||||
print(bundled)
|
||||
")
|
||||
if [[ -f "$CLAUDE_BIN" ]]; then
|
||||
echo "Claude binary found: $CLAUDE_BIN"
|
||||
chmod +x "$CLAUDE_BIN"
|
||||
else
|
||||
echo "WARNING: Claude binary not found at $CLAUDE_BIN"
|
||||
fi
|
||||
|
||||
# Clean up build artifacts to reduce size
|
||||
echo "Cleaning up..."
|
||||
find "$PYTHON_ENV_DIR" -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$PYTHON_ENV_DIR" -name "*.pyc" -delete 2>/dev/null || true
|
||||
find "$PYTHON_ENV_DIR" -type d -name "tests" -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$PYTHON_ENV_DIR" -type d -name "test" -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
TOTAL_SIZE=$(du -sh "$PYTHON_ENV_DIR" | cut -f1)
|
||||
echo ""
|
||||
echo "=== Python Environment Ready ==="
|
||||
echo "Location: $PYTHON_ENV_DIR"
|
||||
echo "Size: $TOTAL_SIZE"
|
||||
echo ""
|
||||
Reference in New Issue
Block a user